302 lines
9.6 KiB
Go
302 lines
9.6 KiB
Go
package controllers
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"server/models"
|
||
"server/services"
|
||
|
||
beego "github.com/beego/beego/v2/server/web"
|
||
)
|
||
|
||
func jsonErr(c *beego.Controller, httpStatus, bizCode int, msg string) {
|
||
c.Ctx.Output.SetStatus(httpStatus)
|
||
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||
_ = c.ServeJSON()
|
||
}
|
||
|
||
type domainPoolPayload struct {
|
||
ID uint64 `json:"id"`
|
||
MainDomain string `json:"main_domain"`
|
||
Status int8 `json:"status"`
|
||
}
|
||
|
||
var subDomainRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$`)
|
||
|
||
// customDomainPayload 绑定自有域名的请求体。
|
||
// tid 仅平台端(运营代操作)使用;租户端一律以登录态里的租户 ID 为准。
|
||
type customDomainPayload struct {
|
||
Tid uint64 `json:"tid"`
|
||
Domain string `json:"domain"`
|
||
}
|
||
|
||
// domainIDPayload 仅带域名记录 ID 的请求体
|
||
type domainIDPayload struct {
|
||
ID uint64 `json:"id"`
|
||
}
|
||
|
||
// ===== 列表输出 =====
|
||
|
||
// tenantDomainDTO 把域名记录转成前端需要的结构。
|
||
// 直接把 ORM 结构体丢给前端会让时间字段变成 RFC3339,且拿不到租户名称,
|
||
// 因此列表统一走这里:时间格式化,租户名按 tid 批量补齐。
|
||
func tenantDomainDTO(row models.SystemTenantDomain, tenantNames map[uint64]string) map[string]interface{} {
|
||
item := map[string]interface{}{
|
||
"id": row.ID,
|
||
"tid": row.Tid,
|
||
"domain_type": row.DomainType,
|
||
"sub_domain": strPtrVal(row.SubDomain),
|
||
"main_domain": strPtrVal(row.MainDomain),
|
||
"full_domain": strPtrVal(row.FullDomain),
|
||
"status": row.Status,
|
||
"ssl_status": row.SSLStatus,
|
||
"ssl_error": strPtrVal(row.SSLError),
|
||
"verify_status": row.VerifyStatus,
|
||
"verify_msg": strPtrVal(row.VerifyMsg),
|
||
"tenant_name": "",
|
||
"ssl_expire_at": "",
|
||
"last_verify_at": "",
|
||
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||
}
|
||
if row.Tid != nil && tenantNames != nil {
|
||
item["tenant_name"] = tenantNames[*row.Tid]
|
||
}
|
||
if row.SSLExpireAt != nil {
|
||
item["ssl_expire_at"] = row.SSLExpireAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
if row.LastVerifyAt != nil {
|
||
item["last_verify_at"] = row.LastVerifyAt.Format("2006-01-02 15:04:05")
|
||
}
|
||
return item
|
||
}
|
||
|
||
// tenantDomainList 批量转换,顺带一次性查出涉及的租户名称
|
||
func tenantDomainList(rows []models.SystemTenantDomain) []map[string]interface{} {
|
||
tenantNames := lookupTenantNames(rows)
|
||
list := make([]map[string]interface{}, 0, len(rows))
|
||
for i := range rows {
|
||
list = append(list, tenantDomainDTO(rows[i], tenantNames))
|
||
}
|
||
return list
|
||
}
|
||
|
||
// lookupTenantNames 按 tid 查租户名称,避免列表里逐行查库
|
||
func lookupTenantNames(rows []models.SystemTenantDomain) map[uint64]string {
|
||
ids := make([]uint64, 0, len(rows))
|
||
seen := make(map[uint64]bool, len(rows))
|
||
for i := range rows {
|
||
if rows[i].Tid == nil || *rows[i].Tid == 0 || seen[*rows[i].Tid] {
|
||
continue
|
||
}
|
||
seen[*rows[i].Tid] = true
|
||
ids = append(ids, *rows[i].Tid)
|
||
}
|
||
names := make(map[uint64]string, len(ids))
|
||
if len(ids) == 0 {
|
||
return names
|
||
}
|
||
var tenants []models.SystemTenant
|
||
if _, err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||
Filter("id__in", ids).
|
||
All(&tenants, "ID", "TenantName", "TenantShortName"); err != nil {
|
||
return names
|
||
}
|
||
for i := range tenants {
|
||
name := strings.TrimSpace(tenants[i].TenantName)
|
||
if name == "" && tenants[i].TenantShortName != nil {
|
||
name = strings.TrimSpace(*tenants[i].TenantShortName)
|
||
}
|
||
names[tenants[i].ID] = name
|
||
}
|
||
return names
|
||
}
|
||
|
||
func strPtrVal(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return *p
|
||
}
|
||
|
||
// ===== 以下逻辑被 backend / platform 两套控制器共用 =====
|
||
|
||
// bindCustomDomain 为租户绑定自有域名。
|
||
// 返回 (记录ID, HTTP状态码, 错误信息);错误信息为空表示成功。
|
||
func bindCustomDomain(tid uint64, rawDomain string) (uint64, int, string) {
|
||
if tid == 0 {
|
||
return 0, 400, "租户ID不能为空"
|
||
}
|
||
|
||
host := services.NormalizeHost(rawDomain)
|
||
if host == "" {
|
||
return 0, 400, "域名格式不正确,请填写完整域名(如 www.example.com)"
|
||
}
|
||
|
||
// 通配符只在平台域名池里有意义:自有域名一条记录对应一个精确 Host,
|
||
// 悄悄把 *.example.com 收成 example.com 会绑到用户没想绑的域名上。
|
||
if strings.HasPrefix(strings.TrimSpace(rawDomain), "*.") {
|
||
return 0, 400, "暂不支持通配符域名,请填写要绑定的具体域名(如 www.example.com)"
|
||
}
|
||
|
||
// 租户必须存在且未停用(平台端可以传任意 tid,这里兜住手误)
|
||
tenantOK, _ := models.Orm.QueryTable(new(models.SystemTenant)).
|
||
Filter("id", tid).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if tenantOK == 0 {
|
||
return 0, 400, "租户不存在"
|
||
}
|
||
|
||
// 不允许绑定平台自己的域名:平台主域及其任意子域都由域名池统一发放
|
||
msg, err := checkNotPlatformDomain(host)
|
||
if err != nil {
|
||
return 0, 500, "域名校验失败,请稍后重试"
|
||
}
|
||
if msg != "" {
|
||
return 0, 400, msg
|
||
}
|
||
|
||
// 全局唯一:同一个域名只能指向一个租户官网
|
||
used, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||
Filter("full_domain", host).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if used > 0 {
|
||
return 0, 400, "该域名已被绑定"
|
||
}
|
||
|
||
// 每个租户一个自有域名(平台二级域名不受此限制,两者可以同时存在)
|
||
exists, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||
Filter("tid", tid).
|
||
Filter("domain_type", models.DomainTypeCustom).
|
||
Filter("delete_time__isnull", true).
|
||
Count()
|
||
if exists > 0 {
|
||
return 0, 400, "该租户已绑定自有域名,请先删除后再绑定新域名"
|
||
}
|
||
|
||
now := time.Now()
|
||
tidCopy := tid
|
||
row := &models.SystemTenantDomain{
|
||
Tid: &tidCopy,
|
||
DomainType: models.DomainTypeCustom,
|
||
FullDomain: &host,
|
||
Status: 0, // DNS 检测通过且证书签发成功后自动置 1
|
||
SSLStatus: models.SSLStatusPending,
|
||
VerifyStatus: models.VerifyStatusNone,
|
||
CreateTime: now,
|
||
UpdateTime: &now,
|
||
}
|
||
id, err := models.Orm.Insert(row)
|
||
if err != nil {
|
||
return 0, 500, "绑定失败: " + err.Error()
|
||
}
|
||
return uint64(id), 200, ""
|
||
}
|
||
|
||
// checkNotPlatformDomain 校验域名不属于平台域名池(含子域)。
|
||
// 平台域名的解析与证书由平台统一维护,租户只能通过「申请二级域名」获得。
|
||
// 返回 (拒绝原因, 查询错误)。查询出错时必须由调用方拒绝,
|
||
// 否则一次数据库抖动就能让租户把平台主域绑走。
|
||
func checkNotPlatformDomain(host string) (string, error) {
|
||
var pools []models.SystemDomainPool
|
||
if _, err := models.Orm.QueryTable(new(models.SystemDomainPool)).
|
||
Filter("delete_time__isnull", true).
|
||
All(&pools); err != nil {
|
||
return "", err
|
||
}
|
||
for i := range pools {
|
||
main := services.NormalizeHost(pools[i].MainDomain)
|
||
if main == "" {
|
||
continue
|
||
}
|
||
if host == main || strings.HasSuffix(host, "."+main) {
|
||
return "该域名属于平台域名 " + main + ",请改用「申请二级域名」功能", nil
|
||
}
|
||
}
|
||
return "", nil
|
||
}
|
||
|
||
// checkCustomDomainDNS 检测自有域名解析,通过则异步触发证书签发。
|
||
// tid 传 0 表示不限制归属(平台端运营场景)。
|
||
// 返回 (给前端的数据, HTTP状态码, 错误信息)。
|
||
func checkCustomDomainDNS(id, tid uint64) (map[string]interface{}, int, string) {
|
||
if id == 0 {
|
||
return nil, 400, "参数错误"
|
||
}
|
||
|
||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||
Filter("id", id).
|
||
Filter("delete_time__isnull", true)
|
||
if tid > 0 {
|
||
qs = qs.Filter("tid", tid)
|
||
}
|
||
var row models.SystemTenantDomain
|
||
if err := qs.One(&row); err != nil {
|
||
return nil, 404, "域名不存在"
|
||
}
|
||
if row.DomainType != models.DomainTypeCustom {
|
||
return nil, 400, "平台二级域名无需检测解析"
|
||
}
|
||
if row.FullDomain == nil || *row.FullDomain == "" {
|
||
return nil, 400, "域名数据异常,请删除后重新绑定"
|
||
}
|
||
|
||
host := *row.FullDomain
|
||
passed, msg := services.VerifyCustomDomainDNS(host)
|
||
// verify_msg 字段 varchar(500),解析出的 IP 列表可能很长,截断避免写库失败
|
||
if len(msg) > 480 {
|
||
msg = msg[:480]
|
||
}
|
||
|
||
verifyStatus := models.VerifyStatusFailed
|
||
if passed {
|
||
verifyStatus = models.VerifyStatusPassed
|
||
}
|
||
now := time.Now()
|
||
if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||
Filter("id", id).
|
||
Update(map[string]interface{}{
|
||
"verify_status": verifyStatus,
|
||
"verify_msg": msg,
|
||
"last_verify_at": now,
|
||
"update_time": now,
|
||
}); err != nil {
|
||
return nil, 500, "保存检测结果失败: " + err.Error()
|
||
}
|
||
|
||
data := map[string]interface{}{
|
||
"verify_status": verifyStatus,
|
||
"verify_msg": msg,
|
||
"ssl_status": row.SSLStatus,
|
||
}
|
||
if !passed {
|
||
return data, 200, ""
|
||
}
|
||
|
||
// 解析已通过:证书没签好就发起签发
|
||
if row.SSLStatus == models.SSLStatusIssued {
|
||
data["ssl_message"] = "解析正常,证书已就绪"
|
||
return data, 200, ""
|
||
}
|
||
if ok, reason := services.IssueCertificateAsync(id, host); ok {
|
||
data["ssl_status"] = models.SSLStatusIssuing
|
||
data["ssl_message"] = "解析已通过,正在申请 HTTPS 证书,请稍后刷新查看"
|
||
} else {
|
||
data["ssl_message"] = reason
|
||
}
|
||
return data, 200, ""
|
||
}
|
||
|
||
// customDomainGuideData 返回租户配置解析所需的信息
|
||
func customDomainGuideData() map[string]interface{} {
|
||
guide := services.GetCustomDomainGuide()
|
||
return map[string]interface{}{
|
||
"cname_target": guide.CnameTarget,
|
||
"a_ips": guide.AIPs,
|
||
"configured": guide.CnameTarget != "" || len(guide.AIPs) > 0,
|
||
}
|
||
}
|