Files
yunzerwebsiteallinone/go/controllers/domain_common.go
T

302 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}