增加自定义绑定域名
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/services"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// AcmeChallengeController 处理 Let's Encrypt 的 HTTP-01 验证回访。
|
||||
//
|
||||
// 路由:GET /.well-known/acme-challenge/:token
|
||||
//
|
||||
// 该路由在 routers/router.go 中无条件注册,不受 APP_MODE 影响——证书颁发机构
|
||||
// 什么时候来验证与本服务以哪种模式启动无关。Nginx 扑底站点必须把这个路径按
|
||||
// 明文 HTTP 反代过来,不能 301 跳转到 HTTPS(首次签发时还没有证书)。
|
||||
type AcmeChallengeController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// Serve 返回 token 对应的 keyAuthorization(纯文本)
|
||||
func (c *AcmeChallengeController) Serve() {
|
||||
token := strings.TrimSpace(c.Ctx.Input.Param(":token"))
|
||||
keyAuth, ok := services.GetHTTP01Challenge(token)
|
||||
if !ok {
|
||||
c.Ctx.Output.SetStatus(404)
|
||||
_ = c.Ctx.Output.Body([]byte("not found"))
|
||||
return
|
||||
}
|
||||
c.Ctx.Output.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
// challenge 是一次性的,不能被任何中间层缓存
|
||||
c.Ctx.Output.Header("Cache-Control", "no-store")
|
||||
_ = c.Ctx.Output.Body([]byte(keyAuth))
|
||||
}
|
||||
@@ -323,20 +323,31 @@ func (c *BackendTenantDomainController) Index() {
|
||||
|
||||
tid, _ := c.GetUint64("tid")
|
||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||
subDomain := strings.TrimSpace(c.GetString("sub_domain"))
|
||||
keyword := strings.TrimSpace(c.GetString("sub_domain"))
|
||||
domainTypeStr := strings.TrimSpace(c.GetString("domain_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("delete_time__isnull", true)
|
||||
// 条件一次性拼好:SetCond 会覆盖 Filter 累积的条件,不能混用
|
||||
cond := orm.NewCondition().And("delete_time__isnull", true)
|
||||
if tid > 0 {
|
||||
qs = qs.Filter("tid", tid)
|
||||
cond = cond.And("tid", tid)
|
||||
}
|
||||
if statusStr != "" {
|
||||
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||
qs = qs.Filter("status", st)
|
||||
cond = cond.And("status", st)
|
||||
}
|
||||
}
|
||||
if subDomain != "" {
|
||||
qs = qs.Filter("sub_domain__icontains", subDomain)
|
||||
if domainTypeStr != "" {
|
||||
if dt, err := strconv.Atoi(domainTypeStr); err == nil {
|
||||
cond = cond.And("domain_type", dt)
|
||||
}
|
||||
}
|
||||
// 关键字同时匹配二级前缀与完整域名:自有域名没有 sub_domain,只能靠 full_domain 命中
|
||||
if keyword != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("sub_domain__icontains", keyword).
|
||||
Or("full_domain__icontains", keyword))
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).SetCond(cond)
|
||||
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
@@ -349,8 +360,7 @@ func (c *BackendTenantDomainController) Index() {
|
||||
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SystemTenantDomain, 0, len(rows))
|
||||
list = append(list, rows...)
|
||||
list := tenantDomainList(rows)
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
@@ -359,20 +369,21 @@ func (c *BackendTenantDomainController) Index() {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// MyDomains GET /backend/domain/tenant/myDomains?tid=1
|
||||
// MyDomains GET /backend/domain/tenant/myDomains
|
||||
// 租户 ID 取登录态,忽略请求参数里的 tid,避免越权查看其他租户域名。
|
||||
func (c *BackendTenantDomainController) MyDomains() {
|
||||
if _, err := requireBackend(&c.Controller); err != nil {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid, _ := c.GetUint64("tid")
|
||||
if tid == 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
|
||||
if claims.TenantId <= 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||
return
|
||||
}
|
||||
var rows []models.SystemTenantDomain
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
Filter("tid", tid).
|
||||
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("delete_time__isnull", true).
|
||||
OrderBy("-id").
|
||||
All(&rows)
|
||||
@@ -380,23 +391,101 @@ func (c *BackendTenantDomainController) MyDomains() {
|
||||
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": tenantDomainList(rows)}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Apply POST /backend/domain/tenant/apply body:{tid,sub_domain,main_domain}
|
||||
func (c *BackendTenantDomainController) Apply() {
|
||||
// BindCustom POST /backend/domain/tenant/bindCustom body:{domain}
|
||||
// 绑定租户自有域名。租户 ID 取登录态。
|
||||
func (c *BackendTenantDomainController) BindCustom() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if claims.TenantId <= 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p customDomainPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
id, status, errMsg := bindCustomDomain(uint64(claims.TenantId), p.Domain)
|
||||
if errMsg != "" {
|
||||
jsonErr(&c.Controller, status, status, errMsg)
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "绑定成功,请按提示配置解析后点击「检测解析」",
|
||||
"data": map[string]interface{}{"id": id},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// CheckCustomDns POST /backend/domain/tenant/checkCustomDns body:{id}
|
||||
// 检测自有域名解析,通过后异步申请 HTTPS 证书。
|
||||
func (c *BackendTenantDomainController) CheckCustomDns() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
if claims.TenantId <= 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p domainIDPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
data, status, errMsg := checkCustomDomainDNS(p.ID, uint64(claims.TenantId))
|
||||
if errMsg != "" {
|
||||
jsonErr(&c.Controller, status, status, errMsg)
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// CustomDomainGuide GET /backend/domain/tenant/customDomainGuide
|
||||
// 返回租户该怎么配解析(CNAME 目标 / A 记录 IP),前端不硬编码。
|
||||
func (c *BackendTenantDomainController) CustomDomainGuide() {
|
||||
if _, err := requireBackend(&c.Controller); err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": customDomainGuideData()}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// Apply POST /backend/domain/tenant/apply body:{sub_domain,main_domain}
|
||||
// 租户 ID 取登录态,不信请求体,避免替其他租户申请。
|
||||
func (c *BackendTenantDomainController) Apply() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p struct {
|
||||
Tid uint64 `json:"tid"`
|
||||
SubDomain string `json:"sub_domain"`
|
||||
MainDomain string `json:"main_domain"`
|
||||
}
|
||||
@@ -404,10 +493,11 @@ func (c *BackendTenantDomainController) Apply() {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
if p.Tid == 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
|
||||
if claims.TenantId <= 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||
return
|
||||
}
|
||||
tenantID := uint64(claims.TenantId)
|
||||
sub := strings.TrimSpace(p.SubDomain)
|
||||
main := strings.TrimSpace(p.MainDomain)
|
||||
if sub == "" {
|
||||
@@ -423,13 +513,14 @@ func (c *BackendTenantDomainController) Apply() {
|
||||
return
|
||||
}
|
||||
|
||||
// 该租户是否已有域名
|
||||
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
|
||||
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
Filter("tid", p.Tid).
|
||||
Filter("tid", tenantID).
|
||||
Filter("domain_type", models.DomainTypePlatformSub).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if cnt > 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
|
||||
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -457,9 +548,10 @@ func (c *BackendTenantDomainController) Apply() {
|
||||
|
||||
full := sub + "." + main
|
||||
now := time.Now()
|
||||
tid := p.Tid
|
||||
tid := tenantID
|
||||
row := &models.SystemTenantDomain{
|
||||
Tid: &tid,
|
||||
DomainType: models.DomainTypePlatformSub,
|
||||
SubDomain: &sub,
|
||||
MainDomain: &main,
|
||||
FullDomain: &full,
|
||||
@@ -500,6 +592,10 @@ func (c *BackendTenantDomainController) Audit() {
|
||||
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
||||
return
|
||||
}
|
||||
if row.DomainType == models.DomainTypeCustom {
|
||||
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
|
||||
return
|
||||
}
|
||||
if row.Status != 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
||||
return
|
||||
@@ -547,11 +643,20 @@ func (c *BackendTenantDomainController) ToggleStatus() {
|
||||
return
|
||||
}
|
||||
if row.Status == 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
|
||||
if row.DomainType == models.DomainTypeCustom {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名尚未生效(解析或证书未就绪),无需禁用")
|
||||
} else {
|
||||
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
|
||||
}
|
||||
return
|
||||
}
|
||||
newStatus := 2
|
||||
if row.Status == 2 {
|
||||
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
|
||||
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
|
||||
return
|
||||
}
|
||||
newStatus = 1
|
||||
}
|
||||
now := time.Now()
|
||||
@@ -568,8 +673,10 @@ func (c *BackendTenantDomainController) ToggleStatus() {
|
||||
}
|
||||
|
||||
// Delete DELETE /backend/domain/tenant/delete/:id
|
||||
// 只能删除本租户的域名记录。
|
||||
func (c *BackendTenantDomainController) Delete() {
|
||||
if _, err := requireBackend(&c.Controller); err != nil {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -579,11 +686,14 @@ func (c *BackendTenantDomainController) Delete() {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
n, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
Filter("id", id).
|
||||
Filter("delete_time__isnull", true).
|
||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
Filter("delete_time__isnull", true)
|
||||
if claims.TenantId > 0 {
|
||||
qs = qs.Filter("tid", claims.TenantId)
|
||||
}
|
||||
now := time.Now()
|
||||
n, err := qs.Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||
return
|
||||
|
||||
@@ -2,6 +2,11 @@ package controllers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/services"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
@@ -19,3 +24,278 @@ type domainPoolPayload struct {
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,20 +323,31 @@ func (c *PlatformTenantDomainController) Index() {
|
||||
|
||||
tid, _ := c.GetUint64("tid")
|
||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||
subDomain := strings.TrimSpace(c.GetString("sub_domain"))
|
||||
keyword := strings.TrimSpace(c.GetString("sub_domain"))
|
||||
domainTypeStr := strings.TrimSpace(c.GetString("domain_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("delete_time__isnull", true)
|
||||
// 条件一次性拼好:SetCond 会覆盖 Filter 累积的条件,不能混用
|
||||
cond := orm.NewCondition().And("delete_time__isnull", true)
|
||||
if tid > 0 {
|
||||
qs = qs.Filter("tid", tid)
|
||||
cond = cond.And("tid", tid)
|
||||
}
|
||||
if statusStr != "" {
|
||||
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||
qs = qs.Filter("status", st)
|
||||
cond = cond.And("status", st)
|
||||
}
|
||||
}
|
||||
if subDomain != "" {
|
||||
qs = qs.Filter("sub_domain__icontains", subDomain)
|
||||
if domainTypeStr != "" {
|
||||
if dt, err := strconv.Atoi(domainTypeStr); err == nil {
|
||||
cond = cond.And("domain_type", dt)
|
||||
}
|
||||
}
|
||||
// 关键字同时匹配二级前缀与完整域名:自有域名没有 sub_domain,只能靠 full_domain 命中
|
||||
if keyword != "" {
|
||||
cond = cond.AndCond(orm.NewCondition().
|
||||
Or("sub_domain__icontains", keyword).
|
||||
Or("full_domain__icontains", keyword))
|
||||
}
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).SetCond(cond)
|
||||
|
||||
total, err := qs.Count()
|
||||
if err != nil {
|
||||
@@ -349,8 +360,7 @@ func (c *PlatformTenantDomainController) Index() {
|
||||
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
list := make([]models.SystemTenantDomain, 0, len(rows))
|
||||
list = append(list, rows...)
|
||||
list := tenantDomainList(rows)
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
@@ -360,6 +370,7 @@ func (c *PlatformTenantDomainController) Index() {
|
||||
}
|
||||
|
||||
// MyDomains GET /platform/domain/tenant/myDomains?tid=1
|
||||
// 平台端账号不归属租户,tid 必须显式传入(运营代租户查看)。
|
||||
func (c *PlatformTenantDomainController) MyDomains() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
@@ -380,7 +391,73 @@ func (c *PlatformTenantDomainController) MyDomains() {
|
||||
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": rows}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": tenantDomainList(rows)}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// BindCustom POST /platform/domain/tenant/bindCustom body:{tid,domain}
|
||||
// 平台端代租户绑定自有域名,tid 必传。
|
||||
func (c *PlatformTenantDomainController) BindCustom() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p customDomainPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
id, status, errMsg := bindCustomDomain(p.Tid, p.Domain)
|
||||
if errMsg != "" {
|
||||
jsonErr(&c.Controller, status, status, errMsg)
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "绑定成功,请按提示配置解析后点击「检测解析」",
|
||||
"data": map[string]interface{}{"id": id},
|
||||
}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// CheckCustomDns POST /platform/domain/tenant/checkCustomDns body:{id}
|
||||
// 平台端不限制域名归属,可为任意租户触发检测与签发。
|
||||
func (c *PlatformTenantDomainController) CheckCustomDns() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
var p domainIDPayload
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||
return
|
||||
}
|
||||
data, status, errMsg := checkCustomDomainDNS(p.ID, 0)
|
||||
if errMsg != "" {
|
||||
jsonErr(&c.Controller, status, status, errMsg)
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": data}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// CustomDomainGuide GET /platform/domain/tenant/customDomainGuide
|
||||
func (c *PlatformTenantDomainController) CustomDomainGuide() {
|
||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": customDomainGuideData()}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
@@ -423,13 +500,14 @@ func (c *PlatformTenantDomainController) Apply() {
|
||||
return
|
||||
}
|
||||
|
||||
// 该租户是否已有域名
|
||||
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
|
||||
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||
Filter("tid", p.Tid).
|
||||
Filter("domain_type", models.DomainTypePlatformSub).
|
||||
Filter("delete_time__isnull", true).
|
||||
Count()
|
||||
if cnt > 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
|
||||
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -460,6 +538,7 @@ func (c *PlatformTenantDomainController) Apply() {
|
||||
tid := p.Tid
|
||||
row := &models.SystemTenantDomain{
|
||||
Tid: &tid,
|
||||
DomainType: models.DomainTypePlatformSub,
|
||||
SubDomain: &sub,
|
||||
MainDomain: &main,
|
||||
FullDomain: &full,
|
||||
@@ -500,6 +579,10 @@ func (c *PlatformTenantDomainController) Audit() {
|
||||
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
||||
return
|
||||
}
|
||||
if row.DomainType == models.DomainTypeCustom {
|
||||
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
|
||||
return
|
||||
}
|
||||
if row.Status != 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
||||
return
|
||||
@@ -547,11 +630,20 @@ func (c *PlatformTenantDomainController) ToggleStatus() {
|
||||
return
|
||||
}
|
||||
if row.Status == 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
|
||||
if row.DomainType == models.DomainTypeCustom {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名尚未生效(解析或证书未就绪),无需禁用")
|
||||
} else {
|
||||
jsonErr(&c.Controller, 400, 400, "审核中不可操作")
|
||||
}
|
||||
return
|
||||
}
|
||||
newStatus := 2
|
||||
if row.Status == 2 {
|
||||
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
|
||||
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
|
||||
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
|
||||
return
|
||||
}
|
||||
newStatus = 1
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
Reference in New Issue
Block a user