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, } }