增加自定义绑定域名
This commit is contained in:
@@ -83,6 +83,32 @@ export function applyTenantDomain(data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定自有域名(提交后需按提示配置解析,再调 checkCustomDns)
|
||||||
|
export function bindCustomDomain(data) {
|
||||||
|
return request({
|
||||||
|
url: '/backend/domain/tenant/bindCustom',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测自有域名解析,通过后后端自动申请 HTTPS 证书
|
||||||
|
export function checkCustomDomainDns(id) {
|
||||||
|
return request({
|
||||||
|
url: '/backend/domain/tenant/checkCustomDns',
|
||||||
|
method: 'post',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取自有域名解析配置指引(CNAME 目标 / A 记录 IP)
|
||||||
|
export function getCustomDomainGuide() {
|
||||||
|
return request({
|
||||||
|
url: '/backend/domain/tenant/customDomainGuide',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 审核租户域名
|
// 审核租户域名
|
||||||
export function auditTenantDomain(data) {
|
export function auditTenantDomain(data) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -14,13 +14,17 @@
|
|||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchForm.sub_domain"
|
v-model="searchForm.sub_domain"
|
||||||
placeholder="搜索二级域名"
|
placeholder="搜索二级前缀或完整域名"
|
||||||
clearable
|
clearable
|
||||||
style="width: 200px; margin-right: 10px;"
|
style="width: 220px; margin-right: 10px;"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
|
<el-select v-model="searchForm.domain_type" placeholder="类型" clearable style="width: 150px; margin-right: 10px;">
|
||||||
|
<el-option label="平台二级域名" :value="1" />
|
||||||
|
<el-option label="自有域名" :value="2" />
|
||||||
|
</el-select>
|
||||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
||||||
<el-option label="审核中" :value="0" />
|
<el-option label="待生效/审核中" :value="0" />
|
||||||
<el-option label="已生效" :value="1" />
|
<el-option label="已生效" :value="1" />
|
||||||
<el-option label="已禁用" :value="2" />
|
<el-option label="已禁用" :value="2" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -37,20 +41,63 @@
|
|||||||
>
|
>
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
||||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
<el-table-column label="类型" width="130" align="center">
|
||||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
|
||||||
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
|
||||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
<el-tag v-if="isCustom(scope.row)" type="warning">自有域名</el-tag>
|
||||||
<el-tag v-else-if="scope.row.status === 1" type="success">已生效</el-tag>
|
<el-tag v-else type="info">平台二级域名</el-tag>
|
||||||
<el-tag v-else type="danger">已禁用</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
||||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
<el-table-column prop="status" label="状态" width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="scope.row.status === 0">
|
<el-tag v-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||||
|
<el-tag v-else-if="scope.row.status === 2" type="danger">已禁用</el-tag>
|
||||||
|
<el-tag v-else type="warning">{{ isCustom(scope.row) ? '待生效' : '审核中' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="解析检测" width="120" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">—</span>
|
||||||
|
<el-tooltip v-else-if="scope.row.verify_msg" :content="scope.row.verify_msg" placement="top">
|
||||||
|
<el-tag :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="HTTPS 证书" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">平台证书</span>
|
||||||
|
<el-tooltip v-else-if="scope.row.ssl_status === 3 && scope.row.ssl_error" :content="scope.row.ssl_error" placement="top">
|
||||||
|
<el-tag type="danger">签发失败</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip v-else-if="scope.row.ssl_status === 2 && scope.row.ssl_expire_at" :content="'到期时间:' + scope.row.ssl_expire_at" placement="top">
|
||||||
|
<el-tag type="success">已签发</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="sslTagType(scope.row.ssl_status)">{{ sslText(scope.row.ssl_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="申请时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<!-- 自有域名无人工审核:解析与证书就绪后自动生效,这里只提供重新检测与禁用 -->
|
||||||
|
<template v-if="isCustom(scope.row)">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingId === scope.row.id"
|
||||||
|
@click="handleCheckDns(scope.row)"
|
||||||
|
>
|
||||||
|
检测解析
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="scope.row.status === 1" size="small" text type="danger" @click="handleDisable(scope.row)">
|
||||||
|
禁用
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else-if="scope.row.status === 2" size="small" text type="success" @click="handleEnable(scope.row)">
|
||||||
|
启用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="scope.row.status === 0">
|
||||||
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
||||||
通过
|
通过
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -87,13 +134,23 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
import { getTenantDomainList, auditTenantDomain, toggleTenantDomainStatus } from '@/api/domain'
|
import {
|
||||||
|
getTenantDomainList,
|
||||||
|
auditTenantDomain,
|
||||||
|
toggleTenantDomainStatus,
|
||||||
|
checkCustomDomainDns
|
||||||
|
} from '@/api/domain'
|
||||||
|
|
||||||
|
// 域名类型:与后端 models.DomainTypeCustom 对应
|
||||||
|
const DOMAIN_TYPE_CUSTOM = 2
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
sub_domain: '',
|
sub_domain: '',
|
||||||
status: ''
|
status: '',
|
||||||
|
domain_type: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
@@ -104,6 +161,20 @@ const pagination = reactive({
|
|||||||
|
|
||||||
const tableData = ref<any[]>([])
|
const tableData = ref<any[]>([])
|
||||||
|
|
||||||
|
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM
|
||||||
|
|
||||||
|
const verifyText = (status: number) =>
|
||||||
|
status === 1 ? '已通过' : status === 2 ? '未通过' : '未检测'
|
||||||
|
|
||||||
|
const verifyTagType = (status: number) =>
|
||||||
|
status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
|
||||||
|
|
||||||
|
const sslText = (status: number) =>
|
||||||
|
status === 2 ? '已签发' : status === 1 ? '签发中' : status === 3 ? '签发失败' : '待处理'
|
||||||
|
|
||||||
|
const sslTagType = (status: number) =>
|
||||||
|
status === 2 ? 'success' : status === 1 ? 'warning' : status === 3 ? 'danger' : 'info'
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +200,7 @@ const handleSearch = () => {
|
|||||||
const handleAudit = async (row: any, action: string) => {
|
const handleAudit = async (row: any, action: string) => {
|
||||||
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
||||||
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
||||||
|
|
||||||
const res = await auditTenantDomain({ id: row.id, action })
|
const res = await auditTenantDomain({ id: row.id, action })
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg)
|
ElMessage.success(res.msg)
|
||||||
@@ -140,8 +211,8 @@ const handleAudit = async (row: any, action: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDisable = async (row: any) => {
|
const handleDisable = async (row: any) => {
|
||||||
await ElMessageBox.confirm('确定要禁用该域名吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定要禁用该域名吗?禁用后该域名将无法访问官网。', '提示', { type: 'warning' })
|
||||||
|
|
||||||
const res = await toggleTenantDomainStatus(row.id)
|
const res = await toggleTenantDomainStatus(row.id)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg)
|
ElMessage.success(res.msg)
|
||||||
@@ -151,6 +222,39 @@ const handleDisable = async (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEnable = async (row: any) => {
|
||||||
|
await ElMessageBox.confirm('确定要重新启用该域名吗?', '提示', { type: 'warning' })
|
||||||
|
|
||||||
|
const res = await toggleTenantDomainStatus(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(res.msg)
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自有域名:重新检测解析,通过后后端自动申请证书
|
||||||
|
const handleCheckDns = async (row: any) => {
|
||||||
|
checkingId.value = row.id
|
||||||
|
try {
|
||||||
|
const res = await checkCustomDomainDns(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data || {}
|
||||||
|
if (data.verify_status === 1) {
|
||||||
|
ElMessage.success(data.ssl_message || data.verify_msg || '解析检测通过')
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(data.verify_msg || '解析尚未生效')
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '检测失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
@@ -177,6 +281,10 @@ onMounted(() => {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
<div class="container-box">
|
<div class="container-box">
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<h2>我的域名</h2>
|
<h2>我的域名</h2>
|
||||||
<el-button type="primary" @click="dialogVisible = true">
|
<div class="header-actions">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-button type="primary" @click="applyDialogVisible = true">
|
||||||
申请二级域名
|
<el-icon><Plus /></el-icon>
|
||||||
</el-button>
|
申请二级域名
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="openBindDialog">
|
||||||
|
<el-icon><Link /></el-icon>
|
||||||
|
绑定自有域名
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-divider></el-divider>
|
<el-divider></el-divider>
|
||||||
@@ -19,36 +25,89 @@
|
|||||||
element-loading-text="正在加载..."
|
element-loading-text="正在加载..."
|
||||||
>
|
>
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
<el-table-column label="类型" width="130" align="center">
|
||||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
<template #default="scope">
|
||||||
<el-table-column prop="full_domain" label="完整域名" min-width="200">
|
<el-tag v-if="isCustom(scope.row)" type="warning">自有域名</el-tag>
|
||||||
|
<el-tag v-else type="info">平台二级域名</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="full_domain" label="完整域名" min-width="220">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link
|
<el-link
|
||||||
type="primary"
|
type="primary"
|
||||||
:href="'http://' + scope.row.full_domain"
|
:href="domainUrl(scope.row)"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
{{ scope.row.full_domain }}
|
{{ scope.row.full_domain }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
<el-table-column prop="status" label="状态" width="110" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
<el-tag v-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||||
<el-tag v-else-if="scope.row.status === 1" type="success"
|
<el-tag v-else-if="scope.row.status === 2" type="danger">已禁用</el-tag>
|
||||||
>已生效</el-tag
|
<el-tag v-else type="warning">{{
|
||||||
>
|
isCustom(scope.row) ? "待生效" : "审核中"
|
||||||
<el-tag v-else type="danger">已禁用</el-tag>
|
}}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
<el-table-column label="解析检测" width="120" align="center">
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">—</span>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.verify_msg"
|
||||||
|
:content="scope.row.verify_msg"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag :type="verifyTagType(scope.row.verify_status)">
|
||||||
|
{{ verifyText(scope.row.verify_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="verifyTagType(scope.row.verify_status)">
|
||||||
|
{{ verifyText(scope.row.verify_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="HTTPS 证书" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">平台证书</span>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.ssl_status === 3 && scope.row.ssl_error"
|
||||||
|
:content="scope.row.ssl_error"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag type="danger">签发失败</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.ssl_status === 2 && scope.row.ssl_expire_at"
|
||||||
|
:content="'到期时间:' + scope.row.ssl_expire_at"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag type="success">已签发</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="sslTagType(scope.row.ssl_status)">
|
||||||
|
{{ sslText(scope.row.ssl_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="申请时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="isCustom(scope.row)"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingId === scope.row.id"
|
||||||
|
@click="handleCheckDns(scope.row)"
|
||||||
|
>
|
||||||
|
检测解析
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
text
|
text
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleCopy(scope.row.full_domain)"
|
@click="handleCopy(scope.row)"
|
||||||
>
|
>
|
||||||
复制
|
复制
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -63,13 +122,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<!-- 申请二级域名弹窗 -->
|
||||||
<!-- 申请域名弹窗 -->
|
<el-dialog
|
||||||
<el-dialog v-model="dialogVisible" title="申请二级域名" width="500px">
|
v-model="applyDialogVisible"
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
title="申请二级域名"
|
||||||
|
width="500px"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
:model="applyForm"
|
||||||
|
:rules="applyRules"
|
||||||
|
ref="applyFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
<el-form-item label="选择主域名" prop="main_domain">
|
<el-form-item label="选择主域名" prop="main_domain">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="form.main_domain"
|
v-model="applyForm.main_domain"
|
||||||
placeholder="请选择主域名"
|
placeholder="请选择主域名"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -82,9 +149,12 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="二级前缀" prop="sub_domain">
|
<el-form-item label="二级前缀" prop="sub_domain">
|
||||||
<el-input v-model="form.sub_domain" placeholder="请输入二级域名前缀">
|
<el-input
|
||||||
|
v-model="applyForm.sub_domain"
|
||||||
|
placeholder="请输入二级域名前缀"
|
||||||
|
>
|
||||||
<template #append>{{
|
<template #append>{{
|
||||||
form.main_domain ? "." + form.main_domain : ""
|
applyForm.main_domain ? "." + applyForm.main_domain : ""
|
||||||
}}</template>
|
}}</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<div class="form-tip">
|
<div class="form-tip">
|
||||||
@@ -94,50 +164,120 @@
|
|||||||
<el-form-item label="预览">
|
<el-form-item label="预览">
|
||||||
<div class="domain-preview">
|
<div class="domain-preview">
|
||||||
{{
|
{{
|
||||||
form.sub_domain
|
applyForm.sub_domain
|
||||||
? form.sub_domain + "." + (form.main_domain || "example.com")
|
? applyForm.sub_domain +
|
||||||
|
"." +
|
||||||
|
(applyForm.main_domain || "example.com")
|
||||||
: "请填写上方信息"
|
: "请填写上方信息"
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="applyDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading"
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleApplySubmit"
|
||||||
|
:loading="applyLoading"
|
||||||
>提交申请</el-button
|
>提交申请</el-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 绑定自有域名弹窗 -->
|
||||||
|
<el-dialog v-model="bindDialogVisible" title="绑定自有域名" width="620px">
|
||||||
|
<el-form
|
||||||
|
:model="bindForm"
|
||||||
|
:rules="bindRules"
|
||||||
|
ref="bindFormRef"
|
||||||
|
label-width="90px"
|
||||||
|
>
|
||||||
|
<el-form-item label="域名" prop="domain">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.domain"
|
||||||
|
placeholder="请输入完整域名,如 www.example.com"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
不要带 http:// 与路径。根域名(example.com)与 www
|
||||||
|
视为两个域名,需分别绑定。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" class="guide-box">
|
||||||
|
<template #title>绑定后请到域名服务商处添加解析</template>
|
||||||
|
<div v-if="guide.configured" class="guide-body">
|
||||||
|
<p v-if="guide.cname_target">
|
||||||
|
<strong>子域名(如 www)</strong>:添加 CNAME 记录,记录值
|
||||||
|
<code>{{ guide.cname_target }}</code>
|
||||||
|
</p>
|
||||||
|
<p v-if="guide.a_ips && guide.a_ips.length">
|
||||||
|
<strong>根域名(如 example.com)</strong>:添加 A 记录,记录值
|
||||||
|
<code>{{ guide.a_ips.join(" 或 ") }}</code>
|
||||||
|
</p>
|
||||||
|
<p class="guide-note">
|
||||||
|
解析生效后回到本页点击「检测解析」,系统会自动申请 HTTPS
|
||||||
|
证书,签发成功后域名即生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="guide-body">
|
||||||
|
平台尚未配置解析目标,请联系平台管理员补全后再绑定。
|
||||||
|
</div>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleBindSubmit" :loading="bindLoading"
|
||||||
|
>确认绑定</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from "vue";
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus, Link } from "@element-plus/icons-vue";
|
||||||
import {
|
import {
|
||||||
getMyDomains,
|
getMyDomains,
|
||||||
applyTenantDomain,
|
applyTenantDomain,
|
||||||
getEnabledDomains,
|
getEnabledDomains,
|
||||||
deleteTenantDomain,
|
deleteTenantDomain,
|
||||||
|
bindCustomDomain,
|
||||||
|
checkCustomDomainDns,
|
||||||
|
getCustomDomainGuide,
|
||||||
} from "@/api/domain";
|
} from "@/api/domain";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
// 域名类型:与后端 models.DomainTypeCustom 对应
|
||||||
|
const DOMAIN_TYPE_CUSTOM = 2;
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitLoading = ref(false);
|
const applyLoading = ref(false);
|
||||||
const dialogVisible = ref(false);
|
const bindLoading = ref(false);
|
||||||
const formRef = ref();
|
const checkingId = ref<number | null>(null);
|
||||||
|
const applyDialogVisible = ref(false);
|
||||||
|
const bindDialogVisible = ref(false);
|
||||||
|
const applyFormRef = ref();
|
||||||
|
const bindFormRef = ref();
|
||||||
|
|
||||||
const tableData = ref<any[]>([]);
|
const tableData = ref<any[]>([]);
|
||||||
const domainList = ref<any[]>([]);
|
const domainList = ref<any[]>([]);
|
||||||
|
const guide = reactive<{
|
||||||
|
cname_target: string;
|
||||||
|
a_ips: string[];
|
||||||
|
configured: boolean;
|
||||||
|
}>({ cname_target: "", a_ips: [], configured: false });
|
||||||
|
|
||||||
const form = reactive({
|
const applyForm = reactive({
|
||||||
main_domain: "",
|
main_domain: "",
|
||||||
sub_domain: "",
|
sub_domain: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = {
|
const bindForm = reactive({
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyRules = {
|
||||||
main_domain: [{ required: true, message: "请选择主域名", trigger: "change" }],
|
main_domain: [{ required: true, message: "请选择主域名", trigger: "change" }],
|
||||||
sub_domain: [
|
sub_domain: [
|
||||||
{ required: true, message: "请输入二级域名前缀", trigger: "blur" },
|
{ required: true, message: "请输入二级域名前缀", trigger: "blur" },
|
||||||
@@ -149,16 +289,58 @@ const rules = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bindRules = {
|
||||||
|
domain: [
|
||||||
|
{ required: true, message: "请输入要绑定的域名", trigger: "blur" },
|
||||||
|
{
|
||||||
|
pattern: /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/,
|
||||||
|
message: "请输入合法的完整域名,如 www.example.com",
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM;
|
||||||
|
|
||||||
|
// 已签发证书的自有域名用 https 打开,其余用 http 避免证书告警
|
||||||
|
const domainUrl = (row: any) => {
|
||||||
|
const scheme = !isCustom(row) || row.ssl_status === 2 ? "https" : "http";
|
||||||
|
return `${scheme}://${row.full_domain}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyText = (status: number) =>
|
||||||
|
status === 1 ? "已通过" : status === 2 ? "未通过" : "未检测";
|
||||||
|
|
||||||
|
const verifyTagType = (status: number) =>
|
||||||
|
status === 1 ? "success" : status === 2 ? "danger" : "info";
|
||||||
|
|
||||||
|
const sslText = (status: number) =>
|
||||||
|
status === 2
|
||||||
|
? "已签发"
|
||||||
|
: status === 1
|
||||||
|
? "签发中"
|
||||||
|
: status === 3
|
||||||
|
? "签发失败"
|
||||||
|
: "待处理";
|
||||||
|
|
||||||
|
const sslTagType = (status: number) =>
|
||||||
|
status === 2
|
||||||
|
? "success"
|
||||||
|
: status === 1
|
||||||
|
? "warning"
|
||||||
|
: status === 3
|
||||||
|
? "danger"
|
||||||
|
: "info";
|
||||||
|
|
||||||
const fetchDomains = async () => {
|
const fetchDomains = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 获取我的域名列表
|
// 我的域名列表:租户 ID 由后端从登录态取,前端不再传 tid
|
||||||
const res = await getMyDomains({ tid: authStore.user.tid });
|
const res = await getMyDomains();
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
tableData.value = res.data || [];
|
tableData.value = res.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取可选主域名
|
|
||||||
const domainRes = await getEnabledDomains();
|
const domainRes = await getEnabledDomains();
|
||||||
if (domainRes.code === 200) {
|
if (domainRes.code === 200) {
|
||||||
domainList.value = domainRes.data || [];
|
domainList.value = domainRes.data || [];
|
||||||
@@ -168,37 +350,92 @@ const fetchDomains = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const fetchGuide = async () => {
|
||||||
await formRef.value.validate();
|
const res = await getCustomDomainGuide();
|
||||||
submitLoading.value = true;
|
if (res.code === 200 && res.data) {
|
||||||
|
guide.cname_target = res.data.cname_target || "";
|
||||||
|
guide.a_ips = res.data.a_ips || [];
|
||||||
|
guide.configured = !!res.data.configured;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBindDialog = async () => {
|
||||||
|
bindForm.domain = "";
|
||||||
|
bindDialogVisible.value = true;
|
||||||
|
await fetchGuide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplySubmit = async () => {
|
||||||
|
await applyFormRef.value.validate();
|
||||||
|
applyLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await applyTenantDomain({
|
const res = await applyTenantDomain({
|
||||||
tid: authStore.user.tid,
|
main_domain: applyForm.main_domain,
|
||||||
main_domain: form.main_domain,
|
sub_domain: applyForm.sub_domain,
|
||||||
sub_domain: form.sub_domain,
|
|
||||||
});
|
});
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg);
|
ElMessage.success(res.msg);
|
||||||
dialogVisible.value = false;
|
applyDialogVisible.value = false;
|
||||||
form.sub_domain = "";
|
applyForm.sub_domain = "";
|
||||||
fetchDomains();
|
fetchDomains();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.msg || "申请失败");
|
ElMessage.error(res.msg || "申请失败");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false;
|
applyLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (domain: string) => {
|
const handleBindSubmit = async () => {
|
||||||
navigator.clipboard.writeText("http://" + domain);
|
await bindFormRef.value.validate();
|
||||||
|
bindLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await bindCustomDomain({ domain: bindForm.domain });
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(res.msg || "绑定成功");
|
||||||
|
bindDialogVisible.value = false;
|
||||||
|
bindForm.domain = "";
|
||||||
|
fetchDomains();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "绑定失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bindLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckDns = async (row: any) => {
|
||||||
|
checkingId.value = row.id;
|
||||||
|
try {
|
||||||
|
const res = await checkCustomDomainDns(row.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data || {};
|
||||||
|
if (data.verify_status === 1) {
|
||||||
|
ElMessage.success(data.ssl_message || data.verify_msg || "解析检测通过");
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(data.verify_msg || "解析尚未生效,请稍后重试");
|
||||||
|
}
|
||||||
|
fetchDomains();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "检测失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (row: any) => {
|
||||||
|
navigator.clipboard.writeText(domainUrl(row));
|
||||||
ElMessage.success("链接已复制到剪贴板");
|
ElMessage.success("链接已复制到剪贴板");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (row: any) => {
|
const handleDelete = async (row: any) => {
|
||||||
try {
|
try {
|
||||||
|
const extra = isCustom(row)
|
||||||
|
? "删除后该域名将无法访问,已签发的证书也不再续期。"
|
||||||
|
: "";
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除域名 "${row.full_domain}" 吗?`,
|
`确定要删除域名 "${row.full_domain}" 吗?${extra}`,
|
||||||
"提示",
|
"提示",
|
||||||
{
|
{
|
||||||
confirmButtonText: "确定",
|
confirmButtonText: "确定",
|
||||||
@@ -222,6 +459,7 @@ const handleDelete = async (row: any) => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchDomains();
|
fetchDomains();
|
||||||
|
fetchGuide();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -242,10 +480,16 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-tip {
|
.form-tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain-preview {
|
.domain-preview {
|
||||||
@@ -255,4 +499,34 @@ onMounted(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-box {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-body {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.9;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-note {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
+165
-12
@@ -461,7 +461,154 @@ server {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 五、重要注意事项
|
## 五、租户自有域名(扑底站点 + 自动 HTTPS)
|
||||||
|
|
||||||
|
> 前面两节的站点只匹配 `yunzer.com.cn` / `*.yunzer.com.cn` / `dh2.fun` / `*.dh2.fun`。租户绑定自己的域名(如 `www.客户公司.com`)后,解析过来会落到 nginx 的默认站点,命中不了上面的任何 `server_name`。本节的扑底站点解决两件事:**让未知域名也能进 Go**,以及**为这些域名提供 HTTPS 证书**。
|
||||||
|
|
||||||
|
### 5.1 工作流程
|
||||||
|
|
||||||
|
1. 租户在后台「我的域名」点「绑定自有域名」,填写完整域名;
|
||||||
|
2. 系统返回 CNAME 目标 / A 记录 IP(来自 `app.conf` 的 `customdomain_cname_target`、`customdomain_a_ips`);
|
||||||
|
3. 租户到域名服务商添加解析,回来点「检测解析」;
|
||||||
|
4. Go 用 `net.LookupCNAME` / `net.LookupHost` 校验解析是否指向平台;
|
||||||
|
5. 校验通过后 Go 异步向 Let's Encrypt 发起 HTTP-01 申请,CA 回访 `http://域名/.well-known/acme-challenge/<token>`;
|
||||||
|
6. nginx 扑底站点把该路径**明文**反代给 Go,Go 应答 token;
|
||||||
|
7. 签发成功后证书写入 `{ssl_cert_dir}/{域名}/fullchain.pem` 与 `privkey.pem`,记录置 `ssl_status=2`、`status=1`,域名生效;
|
||||||
|
8. 每 12 小时扫一次,到期前 30 天自动续期。
|
||||||
|
|
||||||
|
### 5.2 app.conf 需要填的五项
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# 租户子域名 CNAME 到这个主机名(该主机名需已 A 记录指向本服务器)
|
||||||
|
customdomain_cname_target = sites.yunzer.com.cn
|
||||||
|
|
||||||
|
# 根域名只能用 A 记录,这里写本服务器公网 IP(多个用英文逗号分隔)
|
||||||
|
customdomain_a_ips = 1.2.3.4
|
||||||
|
|
||||||
|
# Let's Encrypt 账号邮箱
|
||||||
|
acme_email = ops@yunzer.com.cn
|
||||||
|
|
||||||
|
# 留空使用正式环境;联调先填 staging,避免打满正式环境频控
|
||||||
|
# acme_directory = https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
acme_directory =
|
||||||
|
|
||||||
|
# 证书落盘目录,Go 进程必须可写,nginx 必须可读
|
||||||
|
ssl_cert_dir = /www/wwwroot/ssl-certs
|
||||||
|
```
|
||||||
|
|
||||||
|
`customdomain_cname_target` 建议单独用一个平台子域(如 `sites.yunzer.com.cn`),指向服务器 IP。以后换服务器只改这条 A 记录,所有租户的 CNAME 都跟着走,不用逐个通知租户改解析。
|
||||||
|
|
||||||
|
### 5.3 扑底站点配置
|
||||||
|
|
||||||
|
新建一个宝塔站点(域名可随便填一个不用的,例如 `custom-domains.local`),或直接在任一站点配置文件里追加下面内容。**注意 upstream 名字不能与 `go_backend_dh2` / `go_backend_yunzer` 重复。**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream go_backend_custom {
|
||||||
|
server 127.0.0.1:8081;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SNI 决定用哪个域名的证书目录;无 SNI 的请求回落到 _default
|
||||||
|
map $ssl_server_name $cert_host {
|
||||||
|
"" _default;
|
||||||
|
default $ssl_server_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- HTTP:只负责 ACME 验证与明文访问,不做整站跳转 ----------
|
||||||
|
server {
|
||||||
|
listen 80 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# 关键:ACME 验证不能 301 到 HTTPS。首次签发时还没有证书,
|
||||||
|
# 跳过去 CA 会因为证书不可信而验证失败。
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
proxy_pass http://go_backend_custom;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://go_backend_custom;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/custom-domain.log;
|
||||||
|
error_log /www/wwwlogs/custom-domain.error.log;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- HTTPS:按 SNI 从证书目录取对应域名的证书 ----------
|
||||||
|
server {
|
||||||
|
listen 443 ssl default_server;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /www/wwwroot/ssl-certs/$cert_host/fullchain.pem;
|
||||||
|
ssl_certificate_key /www/wwwroot/ssl-certs/$cert_host/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_session_cache shared:SSL:10m;
|
||||||
|
ssl_session_timeout 10m;
|
||||||
|
|
||||||
|
# 这里不要加 HSTS:证书是自动签的,一旦某个域名签发失败又被浏览器记住 HSTS,
|
||||||
|
# 租户连 HTTP 兜底访问都用不了。
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://go_backend_custom;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
access_log /www/wwwlogs/custom-domain.ssl.log;
|
||||||
|
error_log /www/wwwlogs/custom-domain.ssl.error.log;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 上线前必做的四件事
|
||||||
|
|
||||||
|
1. **放一份 `_default` 兜底证书**。`ssl_certificate` 用了变量,nginx 启动时不校验文件是否存在,但握手时找不到文件会直接失败。没有 SNI 的客户端(老设备、直接用 IP 访问)会落到 `_default`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /www/wwwroot/ssl-certs/_default
|
||||||
|
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||||
|
-keyout /www/wwwroot/ssl-certs/_default/privkey.pem \
|
||||||
|
-out /www/wwwroot/ssl-certs/_default/fullchain.pem \
|
||||||
|
-subj "/CN=invalid"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **nginx 版本 ≥ 1.15.9**。`ssl_certificate` 支持变量是从这个版本开始的,宝塔默认的 1.2x 都满足。低版本会报 `invalid number of arguments` 或直接不生效。
|
||||||
|
|
||||||
|
3. **证书目录权限**。Go 进程要能创建子目录与写文件,nginx 要能读:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /www/wwwroot/ssl-certs
|
||||||
|
chown -R <go进程用户>:www /www/wwwroot/ssl-certs
|
||||||
|
chmod 755 /www/wwwroot/ssl-certs
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **公网 80 与 443 都要可达**。HTTP-01 验证走 80 端口,被防火墙或安全组挡住会签发失败。
|
||||||
|
|
||||||
|
### 5.5 default_server 的影响范围
|
||||||
|
|
||||||
|
`listen 80 default_server` 会把「所有没匹配到具体 server_name 的请求」都吃掉。原来这类请求落到宝塔默认站点(通常是一个空白页或宝塔欢迎页)。如果服务器上还有其他靠默认站点兜底的业务,先确认它们都有精确的 `server_name`,否则会被这个扑底站点抢走。nginx 的匹配优先级是:精确 `server_name` > 前导通配 > 尾随通配 > 正则 > `default_server`,前面两节的配置都是精确匹配或正则,不受影响。
|
||||||
|
|
||||||
|
### 5.6 排查
|
||||||
|
|
||||||
|
| 现象 | 原因 | 处理 |
|
||||||
|
|---|---|---|
|
||||||
|
| 「解析检测」提示当前解析到别的 IP | 解析没生效或填错 | 等 TTL 过期;`dig +short 域名` 核对 |
|
||||||
|
| 「解析检测」提示平台尚未配置解析目标 | `app.conf` 两项为空 | 填 `customdomain_cname_target` / `customdomain_a_ips` 后重启 Go |
|
||||||
|
| 证书状态一直「签发中」 | CA 回访拿不到 token | `curl http://域名/.well-known/acme-challenge/test` 应到 Go 而非 301 |
|
||||||
|
| 证书「签发失败」,原因含 403/404 | 80 端口不可达或被 HTTPS 跳转拦了 | 检查安全组、确认 challenge 的 location 在跳转规则之前 |
|
||||||
|
| 浏览器提示证书名称不匹配 | 落到了 `_default` 自签证书 | 说明 SNI 对应目录没有证书,看该域名的 `ssl_status` 与 `ssl_error` |
|
||||||
|
| 签发失败后短时间点不动 | 失败冷却 30 分钟 | 等冷却结束再点,或先用 staging 联调 |
|
||||||
|
|
||||||
|
> 签发失败原因会写进 `yz_system_tenant_domain.ssl_error`,前端「证书」列悬浮即可看到。Let's Encrypt 对同一账号每小时失败次数有限制,所以代码里加了 30 分钟冷却,避免租户反复点击把额度打满。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、重要注意事项
|
||||||
|
|
||||||
1. **子域名必须用泛域名证书**。租户域名是二级域名(`ceshi.yunzer.com.cn`),单域名证书会导致 HTTPS 证书错误。需在宝塔为 `*.yunzer.com.cn`、`*.dh2.fun` 申请泛域名证书(DNS 验证),并替换配置中的证书路径。
|
1. **子域名必须用泛域名证书**。租户域名是二级域名(`ceshi.yunzer.com.cn`),单域名证书会导致 HTTPS 证书错误。需在宝塔为 `*.yunzer.com.cn`、`*.dh2.fun` 申请泛域名证书(DNS 验证),并替换配置中的证书路径。
|
||||||
2. **upstream 不能重名**。两个站点配置都在同一 http 上下文,本文分别用 `go_backend_dh2`、`go_backend_yunzer`;重名会 `duplicate upstream` 启动失败。
|
2. **upstream 不能重名**。两个站点配置都在同一 http 上下文,本文分别用 `go_backend_dh2`、`go_backend_yunzer`;重名会 `duplicate upstream` 启动失败。
|
||||||
@@ -480,9 +627,9 @@ server {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 六、验证与排查
|
## 七、验证与排查
|
||||||
|
|
||||||
### 6.1 部署后验证顺序
|
### 7.1 部署后验证顺序
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 绕过 nginx 直接验证 Go(在服务器上执行)
|
# 1. 绕过 nginx 直接验证 Go(在服务器上执行)
|
||||||
@@ -494,10 +641,16 @@ curl -k -H "Host: ceshi.yunzer.com.cn" https://127.0.0.1/
|
|||||||
# 3. 验证七牛代理:应返回图片内容或 200/304,而不是 Go 的 404 页面
|
# 3. 验证七牛代理:应返回图片内容或 200/304,而不是 Go 的 404 页面
|
||||||
curl -I https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png
|
curl -I https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png
|
||||||
|
|
||||||
# 4. 浏览器访问 https://ceshi.yunzer.com.cn
|
# 4. 自有域名:先确认扑底站点能把 challenge 明文送到 Go(不应返回 301)
|
||||||
|
curl -i -H "Host: 待绑域名" http://127.0.0.1/.well-known/acme-challenge/probe
|
||||||
|
|
||||||
|
# 5. 自有域名:确认 Go 能按 Host 命中租户
|
||||||
|
curl -H "Host: 待绑域名" http://127.0.0.1:8081/
|
||||||
|
|
||||||
|
# 6. 浏览器访问 https://ceshi.yunzer.com.cn
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6.2 常见现象对照表
|
### 7.2 常见现象对照表
|
||||||
|
|
||||||
| 现象 | 原因 | 处理 |
|
| 现象 | 原因 | 处理 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -510,18 +663,18 @@ curl -I https://yunzer.com.cn/qiniu/2026/06/02/1780371759517804300.png
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 七、迁移服务器清单(以迁移到 192.168.1.101 为例)
|
## 八、迁移服务器清单(以迁移到 192.168.1.101 为例)
|
||||||
|
|
||||||
1. **新机器部署 Go**:复制 `go/` 目录(含 `themes/`、`conf/app.conf`),修改 `app.conf` 数据库连接为新库地址,启动服务;
|
1. **新机器部署 Go**:复制 `go/` 目录(含 `themes/`、`conf/app.conf`),修改 `app.conf` 数据库连接为新库地址,启动服务;
|
||||||
2. **新库初始化**:导入业务数据,执行 `sql/` 下全部建表/变更 SQL(尤其 `yz_cms_frontend_tables.sql`);
|
2. **新库初始化**:导入业务数据,执行 `sql/` 下全部建表/变更 SQL(尤其 `yz_cms_frontend_tables.sql`、`alter_tenant_domain_add_custom.sql`);
|
||||||
3. **nginx**:
|
3. **nginx**:
|
||||||
- nginx 与 Go 同机 → upstream 用 `127.0.0.1:8081`,配置零改动;
|
- nginx 与 Go 同机 → upstream 用 `127.0.0.1:8081`,配置零改动;
|
||||||
- 跨机 → 只改两个 upstream 块中的 `server` 一行;
|
- 跨机 → 只改三个 upstream 块中的 `server` 一行(含扑底站点的 `go_backend_custom`);
|
||||||
4. **证书**:新机器宝塔上为两个域名重新申请/导入证书(含泛域名),路径与配置保持一致;
|
4. **证书**:新机器宝塔上为两个域名重新申请/导入证书(含泛域名),路径与配置保持一致;自有域名的证书目录 `/www/wwwroot/ssl-certs` 整个复制过去(含 `_account/key.pem`——换了账号私钥等于换 ACME 账号,历史签发记录与频控额度都会重置);
|
||||||
5. **DNS**:将 `dh2.fun`、`yunzer.com.cn` 及泛解析 `*` 的 A 记录改指向新服务器 IP;
|
5. **DNS**:将 `dh2.fun`、`yunzer.com.cn` 及泛解析 `*` 的 A 记录改指向新服务器 IP;`customdomain_cname_target` 对应子域的 A 记录也要改,租户侧的 CNAME 不用动;
|
||||||
6. **验证**:按第六节顺序逐一验证;
|
6. **验证**:按第七节顺序逐一验证;
|
||||||
7. 确认无误后到域名服务商处调低旧解析 TTL、观察切换。
|
7. 确认无误后到域名服务商处调低旧解析 TTL、观察切换。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*本文档对应代码:`go/controllers/tenant_site.go`(Host 识别与渲染)、`go/pkg/tagengine/`(标签引擎)、`go/themes/default/`(内置默认模板)、`sql/yz_cms_frontend_tables.sql`(建站数据表)。*
|
*本文档对应代码:`go/controllers/tenant_site.go`(Host 识别与渲染)、`go/services/acme_cert.go`(自动签发与续期)、`go/services/domain_verify.go`(解析检测)、`go/controllers/acme_challenge.go`(HTTP-01 应答)、`go/pkg/tagengine/`(标签引擎)、`go/themes/default/`(内置默认模板)、`sql/yz_cms_frontend_tables.sql`(建站数据表)、`sql/alter_tenant_domain_add_custom.sql`(自有域名字段)。*
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# 绑定自有域名操作说明(给租户看的版本)
|
||||||
|
|
||||||
|
把你自己的域名(例如 `www.你的公司.com`)指向平台,访客访问时打开的还是你在后台配置的那套官网内容,与平台分配的二级域名完全一致。HTTPS 证书由系统自动申请与续期,你不需要买证书、不需要上传证书。
|
||||||
|
|
||||||
|
整个过程三步:**填域名 → 加解析 → 点检测**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、填域名
|
||||||
|
|
||||||
|
后台 →「我的域名」→ 右上角「绑定自有域名」。
|
||||||
|
|
||||||
|
填写规则:
|
||||||
|
|
||||||
|
- 填完整域名,例如 `www.example.com`;
|
||||||
|
- 不要带 `http://`、`https://`,不要带路径和端口;
|
||||||
|
- 根域名(`example.com`)和 `www.example.com` 是两个不同的域名。想两个都能访问,就绑两次;
|
||||||
|
- 域名必须已完成实名认证与备案(国内服务器要求),否则解析能通但访问会被拦。
|
||||||
|
|
||||||
|
提交后会看到一条记录,状态是「待生效」,这是正常的——还没加解析。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、加解析
|
||||||
|
|
||||||
|
弹窗里会显示平台给你的解析目标。到你买域名的地方(阿里云、腾讯云、DNSPod 等)的「域名解析」里添加记录:
|
||||||
|
|
||||||
|
### 子域名(如 www、cn、shop)
|
||||||
|
|
||||||
|
| 记录类型 | 主机记录 | 记录值 |
|
||||||
|
|---|---|---|
|
||||||
|
| CNAME | www | 弹窗里显示的 CNAME 目标 |
|
||||||
|
|
||||||
|
### 根域名(如 example.com 本身)
|
||||||
|
|
||||||
|
根域名在 DNS 标准里不能用 CNAME,只能用 A 记录:
|
||||||
|
|
||||||
|
| 记录类型 | 主机记录 | 记录值 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | @ | 弹窗里显示的 IP |
|
||||||
|
|
||||||
|
几个容易踩的点:
|
||||||
|
|
||||||
|
- **主机记录填 `www`,不要填 `www.example.com`**。解析商会自动补上域名后缀,填全了会变成 `www.example.com.example.com`。
|
||||||
|
- **同一个主机记录下不能既有 CNAME 又有 A/MX 记录**。如果 `www` 已经有 A 记录指向别处,先删掉再加 CNAME。
|
||||||
|
- **有 CDN 的先关掉**。域名挂在 CDN 上时解析指向的是 CDN 节点,系统检测不到平台地址,证书也签不下来。
|
||||||
|
- **TTL 用默认值就行**。修改解析后需要等 TTL 时间生效,通常 10 分钟内,最长可能要几小时。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、点检测
|
||||||
|
|
||||||
|
回到「我的域名」,在这条自有域名记录上点「检测解析」。
|
||||||
|
|
||||||
|
- 解析已生效 → 提示「正在申请 HTTPS 证书」。等 1~2 分钟刷新页面,「证书」列变成「已签发」、状态变成「已生效」,域名就能用了;
|
||||||
|
- 解析没生效 → 提示当前解析到了哪里,对照第二步核对记录值,等一会儿再点。
|
||||||
|
|
||||||
|
生效后用 `https://你的域名` 访问,看到的就是你的官网。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、状态怎么看
|
||||||
|
|
||||||
|
「我的域名」列表里有三列状态:
|
||||||
|
|
||||||
|
| 列 | 取值 | 含义 |
|
||||||
|
|---|---|---|
|
||||||
|
| 状态 | 待生效 | 解析或证书还没就绪,暂时不能访问 |
|
||||||
|
| 状态 | 已生效 | 可以正常访问 |
|
||||||
|
| 状态 | 已禁用 | 被平台停用,联系平台管理员 |
|
||||||
|
| 解析检测 | 未检测 / 已通过 / 未通过 | 鼠标悬浮看具体原因 |
|
||||||
|
| 证书 | 待处理 / 签发中 / 已签发 / 签发失败 | 悬浮「已签发」看到期时间,悬浮「签发失败」看失败原因 |
|
||||||
|
|
||||||
|
证书到期前系统会自动续期,你不用管。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、常见问题
|
||||||
|
|
||||||
|
**点了检测一直提示解析未生效**
|
||||||
|
|
||||||
|
用命令行确认解析实际指向哪里(Windows 用 `nslookup`,Mac/Linux 用 `dig`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nslookup www.example.com
|
||||||
|
dig +short www.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
看返回的结果是不是弹窗里给的目标。如果不是,说明记录没加对或者还没生效。
|
||||||
|
|
||||||
|
**证书显示「签发失败」**
|
||||||
|
|
||||||
|
悬浮在「签发失败」上看原因,常见两种:
|
||||||
|
|
||||||
|
- 提到 80 端口或验证失败:你的域名可能开了强制 HTTPS 跳转,或者解析被 CDN 拦了。证书颁发机构需要通过 80 端口访问 `http://你的域名/.well-known/acme-challenge/...` 来验证域名归属;
|
||||||
|
- 提到频率限制:短时间内失败次数太多。系统有 30 分钟冷却,等半小时再试。
|
||||||
|
|
||||||
|
**能不能同时用平台二级域名和自己的域名**
|
||||||
|
|
||||||
|
可以,两个都能访问同一套官网内容,互不影响。
|
||||||
|
|
||||||
|
**换域名怎么办**
|
||||||
|
|
||||||
|
先删掉旧的那条记录,再绑新域名。删除后旧域名立即无法访问,已签发的证书也不再续期。
|
||||||
|
|
||||||
|
**一个租户能绑几个自有域名**
|
||||||
|
|
||||||
|
一个。需要多绑(比如根域名和 www 都要)请联系平台管理员。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、需要联系平台的情况
|
||||||
|
|
||||||
|
- 弹窗提示「平台尚未配置解析目标」;
|
||||||
|
- 提示「该域名已被绑定」但你确认没绑过;
|
||||||
|
- 提示「该域名属于平台域名」——这种情况请改用「申请二级域名」;
|
||||||
|
- 检测通过但证书连续失败超过一天。
|
||||||
+22
-1
@@ -30,4 +30,25 @@ orm = mysql
|
|||||||
# StaticDir = /static:../front/dist
|
# StaticDir = /static:../front/dist
|
||||||
|
|
||||||
# 映射 /uploads 路径到上传文件目录(项目根目录下的 uploads 文件夹)
|
# 映射 /uploads 路径到上传文件目录(项目根目录下的 uploads 文件夹)
|
||||||
StaticDir = /uploads:../uploads
|
StaticDir = /uploads:../uploads
|
||||||
|
|
||||||
|
# ==================== 租户自有域名绑定 / 自动 HTTPS ====================
|
||||||
|
# 租户把自己的子域名 CNAME 到这个主机名(该主机名需先 A 记录指向本服务器)
|
||||||
|
# 例:customdomain_cname_target = sites.yunzer.com.cn
|
||||||
|
customdomain_cname_target =
|
||||||
|
|
||||||
|
# 允许的 A 记录目标 IP(多个用英文逗号分隔)。根域通常不能用 CNAME,只能 A 记录。
|
||||||
|
# 例:customdomain_a_ips = 1.2.3.4,5.6.7.8
|
||||||
|
customdomain_a_ips =
|
||||||
|
|
||||||
|
# Let's Encrypt 账号邮箱(证书到期提醒、账号注册用)
|
||||||
|
acme_email =
|
||||||
|
|
||||||
|
# ACME 目录地址。留空使用 Let's Encrypt 正式环境。
|
||||||
|
# 联调时建议先填 staging 避免触发正式环境频控:
|
||||||
|
# acme_directory = https://acme-staging-v02.api.letsencrypt.org/directory
|
||||||
|
acme_directory =
|
||||||
|
|
||||||
|
# 证书落盘根目录。签发后生成 {ssl_cert_dir}/{域名}/fullchain.pem 与 privkey.pem,
|
||||||
|
# Nginx 扑底站点按 SNI 读取该目录。Go 进程需要对此目录有写权限。
|
||||||
|
ssl_cert_dir = /www/wwwroot/ssl-certs
|
||||||
@@ -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")
|
tid, _ := c.GetUint64("tid")
|
||||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
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 {
|
if tid > 0 {
|
||||||
qs = qs.Filter("tid", tid)
|
cond = cond.And("tid", tid)
|
||||||
}
|
}
|
||||||
if statusStr != "" {
|
if statusStr != "" {
|
||||||
if st, err := strconv.Atoi(statusStr); err == nil {
|
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||||
qs = qs.Filter("status", st)
|
cond = cond.And("status", st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subDomain != "" {
|
if domainTypeStr != "" {
|
||||||
qs = qs.Filter("sub_domain__icontains", subDomain)
|
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()
|
total, err := qs.Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,8 +360,7 @@ func (c *BackendTenantDomainController) Index() {
|
|||||||
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list := make([]models.SystemTenantDomain, 0, len(rows))
|
list := tenantDomainList(rows)
|
||||||
list = append(list, rows...)
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "success",
|
"msg": "success",
|
||||||
@@ -359,20 +369,21 @@ func (c *BackendTenantDomainController) Index() {
|
|||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MyDomains GET /backend/domain/tenant/myDomains?tid=1
|
// MyDomains GET /backend/domain/tenant/myDomains
|
||||||
|
// 租户 ID 取登录态,忽略请求参数里的 tid,避免越权查看其他租户域名。
|
||||||
func (c *BackendTenantDomainController) MyDomains() {
|
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())
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tid, _ := c.GetUint64("tid")
|
if claims.TenantId <= 0 {
|
||||||
if tid == 0 {
|
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||||
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var rows []models.SystemTenantDomain
|
var rows []models.SystemTenantDomain
|
||||||
_, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
Filter("tid", tid).
|
Filter("tid", claims.TenantId).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
OrderBy("-id").
|
OrderBy("-id").
|
||||||
All(&rows)
|
All(&rows)
|
||||||
@@ -380,23 +391,101 @@ func (c *BackendTenantDomainController) MyDomains() {
|
|||||||
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||||
return
|
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()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply POST /backend/domain/tenant/apply body:{tid,sub_domain,main_domain}
|
// BindCustom POST /backend/domain/tenant/bindCustom body:{domain}
|
||||||
func (c *BackendTenantDomainController) Apply() {
|
// 绑定租户自有域名。租户 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 {
|
if _, err := requireBackend(&c.Controller); err != nil {
|
||||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
return
|
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)
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var p struct {
|
var p struct {
|
||||||
Tid uint64 `json:"tid"`
|
|
||||||
SubDomain string `json:"sub_domain"`
|
SubDomain string `json:"sub_domain"`
|
||||||
MainDomain string `json:"main_domain"`
|
MainDomain string `json:"main_domain"`
|
||||||
}
|
}
|
||||||
@@ -404,10 +493,11 @@ func (c *BackendTenantDomainController) Apply() {
|
|||||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if p.Tid == 0 {
|
if claims.TenantId <= 0 {
|
||||||
jsonErr(&c.Controller, 400, 400, "租户ID不能为空")
|
jsonErr(&c.Controller, 400, 400, "当前登录账号未归属租户")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tenantID := uint64(claims.TenantId)
|
||||||
sub := strings.TrimSpace(p.SubDomain)
|
sub := strings.TrimSpace(p.SubDomain)
|
||||||
main := strings.TrimSpace(p.MainDomain)
|
main := strings.TrimSpace(p.MainDomain)
|
||||||
if sub == "" {
|
if sub == "" {
|
||||||
@@ -423,13 +513,14 @@ func (c *BackendTenantDomainController) Apply() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 该租户是否已有域名
|
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
|
||||||
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
Filter("tid", p.Tid).
|
Filter("tid", tenantID).
|
||||||
|
Filter("domain_type", models.DomainTypePlatformSub).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
Count()
|
Count()
|
||||||
if cnt > 0 {
|
if cnt > 0 {
|
||||||
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
|
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,9 +548,10 @@ func (c *BackendTenantDomainController) Apply() {
|
|||||||
|
|
||||||
full := sub + "." + main
|
full := sub + "." + main
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
tid := p.Tid
|
tid := tenantID
|
||||||
row := &models.SystemTenantDomain{
|
row := &models.SystemTenantDomain{
|
||||||
Tid: &tid,
|
Tid: &tid,
|
||||||
|
DomainType: models.DomainTypePlatformSub,
|
||||||
SubDomain: &sub,
|
SubDomain: &sub,
|
||||||
MainDomain: &main,
|
MainDomain: &main,
|
||||||
FullDomain: &full,
|
FullDomain: &full,
|
||||||
@@ -500,6 +592,10 @@ func (c *BackendTenantDomainController) Audit() {
|
|||||||
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if row.DomainType == models.DomainTypeCustom {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
|
||||||
|
return
|
||||||
|
}
|
||||||
if row.Status != 0 {
|
if row.Status != 0 {
|
||||||
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
||||||
return
|
return
|
||||||
@@ -547,11 +643,20 @@ func (c *BackendTenantDomainController) ToggleStatus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row.Status == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
newStatus := 2
|
newStatus := 2
|
||||||
if row.Status == 2 {
|
if row.Status == 2 {
|
||||||
|
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
|
||||||
|
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
|
||||||
|
return
|
||||||
|
}
|
||||||
newStatus = 1
|
newStatus = 1
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -568,8 +673,10 @@ func (c *BackendTenantDomainController) ToggleStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Delete DELETE /backend/domain/tenant/delete/:id
|
// Delete DELETE /backend/domain/tenant/delete/:id
|
||||||
|
// 只能删除本租户的域名记录。
|
||||||
func (c *BackendTenantDomainController) Delete() {
|
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())
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -579,11 +686,14 @@ func (c *BackendTenantDomainController) Delete() {
|
|||||||
jsonErr(&c.Controller, 400, 400, "参数错误")
|
jsonErr(&c.Controller, 400, 400, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
qs := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
n, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
|
||||||
Filter("id", id).
|
Filter("id", id).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true)
|
||||||
Update(map[string]interface{}{"delete_time": now, "update_time": now})
|
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 {
|
if err != nil {
|
||||||
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
jsonErr(&c.Controller, 500, 500, "删除失败: "+err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ package controllers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/services"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
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]$`)
|
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")
|
tid, _ := c.GetUint64("tid")
|
||||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
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 {
|
if tid > 0 {
|
||||||
qs = qs.Filter("tid", tid)
|
cond = cond.And("tid", tid)
|
||||||
}
|
}
|
||||||
if statusStr != "" {
|
if statusStr != "" {
|
||||||
if st, err := strconv.Atoi(statusStr); err == nil {
|
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||||
qs = qs.Filter("status", st)
|
cond = cond.And("status", st)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if subDomain != "" {
|
if domainTypeStr != "" {
|
||||||
qs = qs.Filter("sub_domain__icontains", subDomain)
|
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()
|
total, err := qs.Count()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,8 +360,7 @@ func (c *PlatformTenantDomainController) Index() {
|
|||||||
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
jsonErr(&c.Controller, 500, 500, "获取租户域名失败: "+err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
list := make([]models.SystemTenantDomain, 0, len(rows))
|
list := tenantDomainList(rows)
|
||||||
list = append(list, rows...)
|
|
||||||
c.Data["json"] = map[string]interface{}{
|
c.Data["json"] = map[string]interface{}{
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"msg": "success",
|
"msg": "success",
|
||||||
@@ -360,6 +370,7 @@ func (c *PlatformTenantDomainController) Index() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MyDomains GET /platform/domain/tenant/myDomains?tid=1
|
// MyDomains GET /platform/domain/tenant/myDomains?tid=1
|
||||||
|
// 平台端账号不归属租户,tid 必须显式传入(运营代租户查看)。
|
||||||
func (c *PlatformTenantDomainController) MyDomains() {
|
func (c *PlatformTenantDomainController) MyDomains() {
|
||||||
if _, err := requirePlatform(&c.Controller); err != nil {
|
if _, err := requirePlatform(&c.Controller); err != nil {
|
||||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||||
@@ -380,7 +391,73 @@ func (c *PlatformTenantDomainController) MyDomains() {
|
|||||||
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
jsonErr(&c.Controller, 500, 500, "获取失败: "+err.Error())
|
||||||
return
|
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()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,13 +500,14 @@ func (c *PlatformTenantDomainController) Apply() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 该租户是否已有域名
|
// 该租户是否已申请过平台二级域名(自有域名单独计数,两者可以共存)
|
||||||
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
cnt, _ := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
Filter("tid", p.Tid).
|
Filter("tid", p.Tid).
|
||||||
|
Filter("domain_type", models.DomainTypePlatformSub).
|
||||||
Filter("delete_time__isnull", true).
|
Filter("delete_time__isnull", true).
|
||||||
Count()
|
Count()
|
||||||
if cnt > 0 {
|
if cnt > 0 {
|
||||||
jsonErr(&c.Controller, 400, 400, "该租户已有域名,请删除后再次申请")
|
jsonErr(&c.Controller, 400, 400, "该租户已有二级域名,请删除后再次申请")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,6 +538,7 @@ func (c *PlatformTenantDomainController) Apply() {
|
|||||||
tid := p.Tid
|
tid := p.Tid
|
||||||
row := &models.SystemTenantDomain{
|
row := &models.SystemTenantDomain{
|
||||||
Tid: &tid,
|
Tid: &tid,
|
||||||
|
DomainType: models.DomainTypePlatformSub,
|
||||||
SubDomain: &sub,
|
SubDomain: &sub,
|
||||||
MainDomain: &main,
|
MainDomain: &main,
|
||||||
FullDomain: &full,
|
FullDomain: &full,
|
||||||
@@ -500,6 +579,10 @@ func (c *PlatformTenantDomainController) Audit() {
|
|||||||
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
jsonErr(&c.Controller, 404, 404, "域名不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if row.DomainType == models.DomainTypeCustom {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
|
||||||
|
return
|
||||||
|
}
|
||||||
if row.Status != 0 {
|
if row.Status != 0 {
|
||||||
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
|
||||||
return
|
return
|
||||||
@@ -547,11 +630,20 @@ func (c *PlatformTenantDomainController) ToggleStatus() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if row.Status == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
newStatus := 2
|
newStatus := 2
|
||||||
if row.Status == 2 {
|
if row.Status == 2 {
|
||||||
|
// 自有域名重新启用要求证书还在,否则启用后访问会报证书错误
|
||||||
|
if row.DomainType == models.DomainTypeCustom && row.SSLStatus != models.SSLStatusIssued {
|
||||||
|
jsonErr(&c.Controller, 400, 400, "该域名的 HTTPS 证书未就绪,请先检测解析并等待证书签发")
|
||||||
|
return
|
||||||
|
}
|
||||||
newStatus = 1
|
newStatus = 1
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ require (
|
|||||||
github.com/beego/beego/v2 v2.1.0
|
github.com/beego/beego/v2 v2.1.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/qiniu/go-sdk/v7 v7.18.2
|
github.com/qiniu/go-sdk/v7 v7.18.2
|
||||||
golang.org/x/crypto v0.1.0 // indirect
|
golang.org/x/crypto v0.1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
+4
-1
@@ -2,8 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"server/models"
|
"server/models"
|
||||||
"server/services"
|
|
||||||
_ "server/routers"
|
_ "server/routers"
|
||||||
|
"server/services"
|
||||||
"server/version"
|
"server/version"
|
||||||
|
|
||||||
beego "github.com/beego/beego/v2/server/web"
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
@@ -27,5 +27,8 @@ func main() {
|
|||||||
// 启动日程提醒定时任务
|
// 启动日程提醒定时任务
|
||||||
services.StartReminderScheduler(make(chan struct{}))
|
services.StartReminderScheduler(make(chan struct{}))
|
||||||
|
|
||||||
|
// 启动自有域名证书续期任务(到期前 30 天自动续签 Let's Encrypt 证书)
|
||||||
|
services.StartCertRenewScheduler(make(chan struct{}))
|
||||||
|
|
||||||
beego.Run()
|
beego.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,50 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// SystemTenantDomain 租户域名表 yz_tenant_domain
|
// 域名类型
|
||||||
|
const (
|
||||||
|
DomainTypePlatformSub = 1 // 平台二级域名(从主域名池申请)
|
||||||
|
DomainTypeCustom = 2 // 租户自有域名(绑定后自动签发证书)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 证书状态
|
||||||
|
const (
|
||||||
|
SSLStatusPending = 0 // 待处理
|
||||||
|
SSLStatusIssuing = 1 // 签发中
|
||||||
|
SSLStatusIssued = 2 // 已签发
|
||||||
|
SSLStatusFailed = 3 // 签发失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNS 解析校验状态
|
||||||
|
const (
|
||||||
|
VerifyStatusNone = 0 // 未检测
|
||||||
|
VerifyStatusPassed = 1 // 已通过
|
||||||
|
VerifyStatusFailed = 2 // 未通过
|
||||||
|
)
|
||||||
|
|
||||||
|
// SystemTenantDomain 租户域名表 yz_system_tenant_domain
|
||||||
|
//
|
||||||
|
// 一条记录代表一个可访问租户官网的域名。两类来源:
|
||||||
|
// - domain_type=1 平台二级域名:sub_domain + main_domain 组成 full_domain,走人工审核,
|
||||||
|
// HTTPS 由平台泛域名证书覆盖,ssl_status 不参与判断。
|
||||||
|
// - domain_type=2 租户自有域名:full_domain 为租户自己的完整主机名,sub_domain / main_domain 留空,
|
||||||
|
// DNS 检测通过后自动签发 Let's Encrypt 证书,签发成功才置 status=1。
|
||||||
type SystemTenantDomain struct {
|
type SystemTenantDomain struct {
|
||||||
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
Tid *uint64 `orm:"column(tid);null" json:"tid"`
|
Tid *uint64 `orm:"column(tid);null" json:"tid"`
|
||||||
SubDomain *string `orm:"column(sub_domain);size(50);null" json:"sub_domain"`
|
DomainType int `orm:"column(domain_type);default(1)" json:"domain_type"`
|
||||||
MainDomain *string `orm:"column(main_domain);size(255);null" json:"main_domain"`
|
SubDomain *string `orm:"column(sub_domain);size(50);null" json:"sub_domain"`
|
||||||
FullDomain *string `orm:"column(full_domain);size(255);null" json:"full_domain"`
|
MainDomain *string `orm:"column(main_domain);size(255);null" json:"main_domain"`
|
||||||
Status int `orm:"column(status);null" json:"status"` // 1已生效 / 0审核中 / 2禁用
|
FullDomain *string `orm:"column(full_domain);size(255);null" json:"full_domain"`
|
||||||
|
Status int `orm:"column(status);null" json:"status"` // 1已生效 / 0审核中 / 2禁用
|
||||||
|
|
||||||
|
SSLStatus int `orm:"column(ssl_status);default(0)" json:"ssl_status"`
|
||||||
|
SSLExpireAt *time.Time `orm:"column(ssl_expire_at);type(datetime);null" json:"ssl_expire_at"`
|
||||||
|
SSLError *string `orm:"column(ssl_error);size(500);null" json:"ssl_error"`
|
||||||
|
VerifyStatus int `orm:"column(verify_status);default(0)" json:"verify_status"`
|
||||||
|
VerifyMsg *string `orm:"column(verify_msg);size(500);null" json:"verify_msg"`
|
||||||
|
LastVerifyAt *time.Time `orm:"column(last_verify_at);type(datetime);null" json:"last_verify_at"`
|
||||||
|
|
||||||
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"`
|
||||||
UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"`
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);auto_now;null" json:"update_time"`
|
||||||
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/domain/tenant/index", &controllers.BackendTenantDomainController{}, "get:Index")
|
beego.Router("/backend/domain/tenant/index", &controllers.BackendTenantDomainController{}, "get:Index")
|
||||||
beego.Router("/backend/domain/tenant/myDomains", &controllers.BackendTenantDomainController{}, "get:MyDomains")
|
beego.Router("/backend/domain/tenant/myDomains", &controllers.BackendTenantDomainController{}, "get:MyDomains")
|
||||||
beego.Router("/backend/domain/tenant/apply", &controllers.BackendTenantDomainController{}, "post:Apply")
|
beego.Router("/backend/domain/tenant/apply", &controllers.BackendTenantDomainController{}, "post:Apply")
|
||||||
|
beego.Router("/backend/domain/tenant/bindCustom", &controllers.BackendTenantDomainController{}, "post:BindCustom")
|
||||||
|
beego.Router("/backend/domain/tenant/checkCustomDns", &controllers.BackendTenantDomainController{}, "post:CheckCustomDns")
|
||||||
|
beego.Router("/backend/domain/tenant/customDomainGuide", &controllers.BackendTenantDomainController{}, "get:CustomDomainGuide")
|
||||||
beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit")
|
beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit")
|
||||||
beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus")
|
beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus")
|
||||||
beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete")
|
beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete")
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ func Register() {
|
|||||||
beego.Router("/platform/domain/tenant/index", &controllers.PlatformTenantDomainController{}, "get:Index")
|
beego.Router("/platform/domain/tenant/index", &controllers.PlatformTenantDomainController{}, "get:Index")
|
||||||
beego.Router("/platform/domain/tenant/myDomains", &controllers.PlatformTenantDomainController{}, "get:MyDomains")
|
beego.Router("/platform/domain/tenant/myDomains", &controllers.PlatformTenantDomainController{}, "get:MyDomains")
|
||||||
beego.Router("/platform/domain/tenant/apply", &controllers.PlatformTenantDomainController{}, "post:Apply")
|
beego.Router("/platform/domain/tenant/apply", &controllers.PlatformTenantDomainController{}, "post:Apply")
|
||||||
|
beego.Router("/platform/domain/tenant/bindCustom", &controllers.PlatformTenantDomainController{}, "post:BindCustom")
|
||||||
|
beego.Router("/platform/domain/tenant/checkCustomDns", &controllers.PlatformTenantDomainController{}, "post:CheckCustomDns")
|
||||||
|
beego.Router("/platform/domain/tenant/customDomainGuide", &controllers.PlatformTenantDomainController{}, "get:CustomDomainGuide")
|
||||||
beego.Router("/platform/domain/tenant/audit", &controllers.PlatformTenantDomainController{}, "post:Audit")
|
beego.Router("/platform/domain/tenant/audit", &controllers.PlatformTenantDomainController{}, "post:Audit")
|
||||||
beego.Router("/platform/domain/tenant/toggleStatus", &controllers.PlatformTenantDomainController{}, "post:ToggleStatus")
|
beego.Router("/platform/domain/tenant/toggleStatus", &controllers.PlatformTenantDomainController{}, "post:ToggleStatus")
|
||||||
beego.Router("/platform/domain/tenant/delete/:id", &controllers.PlatformTenantDomainController{}, "delete:Delete")
|
beego.Router("/platform/domain/tenant/delete/:id", &controllers.PlatformTenantDomainController{}, "delete:Delete")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package routers
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"server/controllers"
|
||||||
"server/middleware"
|
"server/middleware"
|
||||||
"server/routers/api"
|
"server/routers/api"
|
||||||
"server/routers/app"
|
"server/routers/app"
|
||||||
@@ -35,6 +36,10 @@ func init() {
|
|||||||
// 全局操作日志:请求开始采集并异步写入
|
// 全局操作日志:请求开始采集并异步写入
|
||||||
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
beego.InsertFilter("*", beego.BeforeRouter, middleware.BeginOperationLog)
|
||||||
|
|
||||||
|
// Let's Encrypt HTTP-01 验证回访。
|
||||||
|
// 无条件注册:证书颁发机构何时来验证与 APP_MODE 无关,任何启动模式都必须能应答。
|
||||||
|
beego.Router("/.well-known/acme-challenge/:token", &controllers.AcmeChallengeController{}, "get:Serve")
|
||||||
|
|
||||||
// 根据运行模式选择要注册的路由组
|
// 根据运行模式选择要注册的路由组
|
||||||
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
|
// 优先读取环境变量 APP_MODE,其次读取配置 app_mode,默认 all
|
||||||
mode := os.Getenv("APP_MODE")
|
mode := os.Getenv("APP_MODE")
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/core/logs"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"golang.org/x/crypto/acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
acmeAccountDir = "_account"
|
||||||
|
acmeAccountKeyFile = "key.pem"
|
||||||
|
acmeChallengeDir = "_challenges"
|
||||||
|
certFileName = "fullchain.pem"
|
||||||
|
keyFileName = "privkey.pem"
|
||||||
|
|
||||||
|
// 签发失败后的冷却时间。Let's Encrypt 对同一账号的验证失败次数有频控
|
||||||
|
// (每小时 5 次),冷却可避免租户反复点击把额度打满。
|
||||||
|
issueFailureCooldown = 30 * time.Minute
|
||||||
|
|
||||||
|
// 单次签发的整体超时。HTTP-01 需要等 CA 回访,给足时间但不能无限等。
|
||||||
|
issueTimeout = 3 * time.Minute
|
||||||
|
|
||||||
|
// 证书到期前多少天开始续期
|
||||||
|
renewBeforeDays = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
challengeMu sync.RWMutex
|
||||||
|
challengeStore = make(map[string]string) // token -> keyAuthorization
|
||||||
|
|
||||||
|
issueMu sync.Mutex
|
||||||
|
issuing = make(map[string]bool) // host -> 是否正在签发
|
||||||
|
lastFailureAt = make(map[string]time.Time) // host -> 最近一次签发失败时间
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== challenge 存取(供 /.well-known/acme-challenge/:token 路由使用)=====
|
||||||
|
|
||||||
|
// challengeTokenRe ACME token 的合法字符集(base64url)。
|
||||||
|
// token 会被拼进文件名,必须先校验,避免路径穿越。
|
||||||
|
var challengeTokenRe = regexp.MustCompile(`^[A-Za-z0-9_-]{16,128}$`)
|
||||||
|
|
||||||
|
func challengeFilePath(token string) string {
|
||||||
|
return filepath.Join(SSLCertDir(), acmeChallengeDir, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTTP01Challenge 返回 token 对应的 keyAuthorization。
|
||||||
|
//
|
||||||
|
// 先查内存,再查磁盘。磁盘这层是为拆进程部署准备的:签发是由 backend/platform
|
||||||
|
// 接口触发的,而 CA 的回访只会落到 Nginx 扑底站点指向的那个进程,两者可能不是
|
||||||
|
// 同一个进程,只靠内存会验证失败。
|
||||||
|
func GetHTTP01Challenge(token string) (string, bool) {
|
||||||
|
challengeMu.RLock()
|
||||||
|
v, ok := challengeStore[token]
|
||||||
|
challengeMu.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !challengeTokenRe.MatchString(token) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(challengeFilePath(token))
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(raw)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func putHTTP01Challenge(token, keyAuth string) {
|
||||||
|
challengeMu.Lock()
|
||||||
|
challengeStore[token] = keyAuth
|
||||||
|
challengeMu.Unlock()
|
||||||
|
|
||||||
|
if !challengeTokenRe.MatchString(token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir := filepath.Join(SSLCertDir(), acmeChallengeDir)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
logs.Warn("[acme] 创建 challenge 目录失败(将只依赖内存): %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(challengeFilePath(token), []byte(keyAuth), 0o644); err != nil {
|
||||||
|
logs.Warn("[acme] 写入 challenge 文件失败(将只依赖内存): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteHTTP01Challenge(token string) {
|
||||||
|
challengeMu.Lock()
|
||||||
|
delete(challengeStore, token)
|
||||||
|
challengeMu.Unlock()
|
||||||
|
|
||||||
|
if challengeTokenRe.MatchString(token) {
|
||||||
|
_ = os.Remove(challengeFilePath(token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 配置 =====
|
||||||
|
|
||||||
|
// SSLCertDir 证书落盘根目录
|
||||||
|
func SSLCertDir() string {
|
||||||
|
dir, _ := beego.AppConfig.String("ssl_cert_dir")
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
dir = "/www/wwwroot/ssl-certs"
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func acmeDirectoryURL() string {
|
||||||
|
u, _ := beego.AppConfig.String("acme_directory")
|
||||||
|
return strings.TrimSpace(u)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acmeContactEmail() string {
|
||||||
|
e, _ := beego.AppConfig.String("acme_email")
|
||||||
|
return strings.TrimSpace(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertPaths 返回某域名的证书与私钥落盘路径。
|
||||||
|
// host 会先做归一化:域名来自数据库,归一化可避免脏数据里的 ../ 之类写法拼出目录外的路径。
|
||||||
|
func CertPaths(host string) (certPath, keyPath string) {
|
||||||
|
h := NormalizeHost(host)
|
||||||
|
if h == "" {
|
||||||
|
h = "_invalid"
|
||||||
|
}
|
||||||
|
base := filepath.Join(SSLCertDir(), h)
|
||||||
|
return filepath.Join(base, certFileName), filepath.Join(base, keyFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ACME 账号 =====
|
||||||
|
|
||||||
|
// loadOrCreateAccountKey 读取(或首次生成)ACME 账号私钥。
|
||||||
|
// 账号私钥必须持久化:换了 key 等于换账号,历史签发记录和频控额度都会重置。
|
||||||
|
func loadOrCreateAccountKey() (crypto.Signer, error) {
|
||||||
|
dir := filepath.Join(SSLCertDir(), acmeAccountDir)
|
||||||
|
path := filepath.Join(dir, acmeAccountKeyFile)
|
||||||
|
|
||||||
|
if raw, err := os.ReadFile(path); err == nil {
|
||||||
|
block, _ := pem.Decode(raw)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("账号私钥格式错误: %s", path)
|
||||||
|
}
|
||||||
|
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("解析账号私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建账号目录失败: %v", err)
|
||||||
|
}
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("生成账号私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
der, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("序列化账号私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
|
||||||
|
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入账号私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newACMEClient 构造 ACME 客户端并确保账号已注册
|
||||||
|
func newACMEClient(ctx context.Context) (*acme.Client, error) {
|
||||||
|
key, err := loadOrCreateAccountKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
client := &acme.Client{
|
||||||
|
Key: key,
|
||||||
|
DirectoryURL: acmeDirectoryURL(), // 留空则 acme 包默认使用 Let's Encrypt 正式环境
|
||||||
|
}
|
||||||
|
|
||||||
|
acct := &acme.Account{}
|
||||||
|
if email := acmeContactEmail(); email != "" {
|
||||||
|
acct.Contact = []string{"mailto:" + email}
|
||||||
|
}
|
||||||
|
// 已注册过会返回 ErrAccountAlreadyExists,属于正常情况
|
||||||
|
if _, err := client.Register(ctx, acct, acme.AcceptTOS); err != nil && err != acme.ErrAccountAlreadyExists {
|
||||||
|
return nil, fmt.Errorf("注册 ACME 账号失败: %v", err)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 证书签发 =====
|
||||||
|
|
||||||
|
// obtainCertificate 走完整的 HTTP-01 流程拿到证书并落盘,返回证书到期时间。
|
||||||
|
func obtainCertificate(ctx context.Context, host string) (time.Time, error) {
|
||||||
|
client, err := newACMEClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := client.AuthorizeOrder(ctx, acme.DomainIDs(host))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("创建证书订单失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐个完成 pending 的授权
|
||||||
|
for _, authzURL := range order.AuthzURLs {
|
||||||
|
authz, err := client.GetAuthorization(ctx, authzURL)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("获取授权信息失败: %v", err)
|
||||||
|
}
|
||||||
|
if authz.Status != acme.StatusPending {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var chal *acme.Challenge
|
||||||
|
for _, c := range authz.Challenges {
|
||||||
|
if c.Type == "http-01" {
|
||||||
|
chal = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if chal == nil {
|
||||||
|
return time.Time{}, fmt.Errorf("证书颁发机构未提供 http-01 验证方式")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyAuth, err := client.HTTP01ChallengeResponse(chal.Token)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("生成验证响应失败: %v", err)
|
||||||
|
}
|
||||||
|
putHTTP01Challenge(chal.Token, keyAuth)
|
||||||
|
|
||||||
|
if _, err := client.Accept(ctx, chal); err != nil {
|
||||||
|
deleteHTTP01Challenge(chal.Token)
|
||||||
|
return time.Time{}, fmt.Errorf("提交验证失败: %v", err)
|
||||||
|
}
|
||||||
|
_, err = client.WaitAuthorization(ctx, authz.URI)
|
||||||
|
deleteHTTP01Challenge(chal.Token)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("域名验证未通过(请确认 80 端口可从公网访问且未强制跳转 HTTPS): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := client.WaitOrder(ctx, order.URI); err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("订单未就绪: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成证书私钥与 CSR
|
||||||
|
certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("生成证书私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: host},
|
||||||
|
DNSNames: []string{host},
|
||||||
|
}, certKey)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("生成 CSR 失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chainDER, _, err := client.CreateOrderCert(ctx, order.FinalizeURL, csrDER, true)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("签发证书失败: %v", err)
|
||||||
|
}
|
||||||
|
if len(chainDER) == 0 {
|
||||||
|
return time.Time{}, fmt.Errorf("证书颁发机构返回了空证书链")
|
||||||
|
}
|
||||||
|
|
||||||
|
leaf, err := x509.ParseCertificate(chainDER[0])
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("解析证书失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeCertFiles(host, chainDER, certKey); err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
return leaf.NotAfter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCertFiles 把证书链与私钥写入 {ssl_cert_dir}/{host}/。
|
||||||
|
// 先写临时文件再 rename,避免 nginx 读到写了一半的文件。
|
||||||
|
func writeCertFiles(host string, chainDER [][]byte, key *ecdsa.PrivateKey) error {
|
||||||
|
certPath, keyPath := CertPaths(host)
|
||||||
|
dir := filepath.Dir(certPath)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("创建证书目录失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var chainPEM strings.Builder
|
||||||
|
for _, der := range chainDER {
|
||||||
|
if err := pem.Encode(&chainPEM, &pem.Block{Type: "CERTIFICATE", Bytes: der}); err != nil {
|
||||||
|
return fmt.Errorf("编码证书失败: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeFileAtomic(certPath, []byte(chainPEM.String()), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("写入证书失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("序列化证书私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
if err := writeFileAtomic(keyPath, keyPEM, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("写入证书私钥失败: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||||
|
tmp := path + ".tmp"
|
||||||
|
if err := os.WriteFile(tmp, data, perm); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Linux 上 rename 覆盖同名文件是原子的,nginx 不会读到半个文件。
|
||||||
|
// Windows 上 rename 到已存在文件会失败,才退回「先删再改名」。
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
if err2 := os.Rename(tmp, path); err2 != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 与域名记录联动 =====
|
||||||
|
|
||||||
|
// tryLockIssue 抢占某域名的签发权。返回 false 表示正在签发或处于失败冷却期。
|
||||||
|
func tryLockIssue(host string) (bool, string) {
|
||||||
|
issueMu.Lock()
|
||||||
|
defer issueMu.Unlock()
|
||||||
|
|
||||||
|
if issuing[host] {
|
||||||
|
return false, "该域名正在签发证书,请稍后查看结果"
|
||||||
|
}
|
||||||
|
if at, ok := lastFailureAt[host]; ok {
|
||||||
|
if wait := issueFailureCooldown - time.Since(at); wait > 0 {
|
||||||
|
return false, fmt.Sprintf("上次签发失败,请在 %d 分钟后重试", int(wait.Minutes())+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issuing[host] = true
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func unlockIssue(host string, failed bool) {
|
||||||
|
issueMu.Lock()
|
||||||
|
defer issueMu.Unlock()
|
||||||
|
delete(issuing, host)
|
||||||
|
if failed {
|
||||||
|
lastFailureAt[host] = time.Now()
|
||||||
|
} else {
|
||||||
|
delete(lastFailureAt, host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueCertificateAsync 异步为域名记录签发证书。
|
||||||
|
// 立即返回;成功后把记录置为 ssl_status=2 且 status=1(已生效)。
|
||||||
|
// 返回 false 表示当前不可发起(正在签发或冷却中),第二个返回值为原因。
|
||||||
|
func IssueCertificateAsync(domainID uint64, host string) (bool, string) {
|
||||||
|
host = NormalizeHost(host)
|
||||||
|
if host == "" {
|
||||||
|
return false, "域名格式不正确"
|
||||||
|
}
|
||||||
|
ok, reason := tryLockIssue(host)
|
||||||
|
if !ok {
|
||||||
|
return false, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSSLState(domainID, models.SSLStatusIssuing, nil, nil)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), issueTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
expireAt, err := obtainCertificate(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error("[acme] 签发证书失败 host=%s: %v", host, err)
|
||||||
|
msg := err.Error()
|
||||||
|
updateSSLState(domainID, models.SSLStatusFailed, nil, &msg)
|
||||||
|
unlockIssue(host, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs.Info("[acme] 签发证书成功 host=%s 到期=%s", host, expireAt.Format("2006-01-02"))
|
||||||
|
updateSSLState(domainID, models.SSLStatusIssued, &expireAt, nil)
|
||||||
|
// 证书就绪,域名正式生效。
|
||||||
|
// 只把「待生效(0)」推到「已生效(1)」:被平台禁用(2)的记录不能靠续期自己复活。
|
||||||
|
now := time.Now()
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
|
Filter("id", domainID).
|
||||||
|
Filter("status", 0).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(map[string]interface{}{"status": 1, "update_time": now}); err != nil {
|
||||||
|
logs.Error("[acme] 更新域名生效状态失败 id=%d: %v", domainID, err)
|
||||||
|
}
|
||||||
|
unlockIssue(host, false)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return true, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSSLState 更新证书状态字段。
|
||||||
|
// 注意:beego ORM 的 Update 会对参数做 reflect 解引用,直接塞 nil 的 *string 会 panic,
|
||||||
|
// 因此这里统一转成空字符串 / 显式值。
|
||||||
|
func updateSSLState(domainID uint64, sslStatus int, expireAt *time.Time, errMsg *string) {
|
||||||
|
msg := ""
|
||||||
|
if errMsg != nil {
|
||||||
|
msg = *errMsg
|
||||||
|
// ssl_error 字段 varchar(500),超长会被 MySQL 严格模式拒绝
|
||||||
|
if len(msg) > 480 {
|
||||||
|
msg = msg[:480]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values := map[string]interface{}{
|
||||||
|
"ssl_status": sslStatus,
|
||||||
|
"ssl_error": msg,
|
||||||
|
"update_time": time.Now(),
|
||||||
|
}
|
||||||
|
if expireAt != nil {
|
||||||
|
values["ssl_expire_at"] = *expireAt
|
||||||
|
}
|
||||||
|
if _, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
|
Filter("id", domainID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Update(values); err != nil {
|
||||||
|
logs.Error("[acme] 更新证书状态失败 id=%d: %v", domainID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 续期调度 =====
|
||||||
|
|
||||||
|
// StartCertRenewScheduler 启动证书续期调度:启动 1 分钟后跑一次,之后每 12 小时扫一次。
|
||||||
|
func StartCertRenewScheduler(stopChan chan struct{}) {
|
||||||
|
go func() {
|
||||||
|
// 启动稍等,避开数据库初始化与首屏请求高峰
|
||||||
|
firstRun := time.NewTimer(1 * time.Minute)
|
||||||
|
defer firstRun.Stop()
|
||||||
|
select {
|
||||||
|
case <-firstRun.C:
|
||||||
|
renewExpiringCertificates()
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(12 * time.Hour)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
renewExpiringCertificates()
|
||||||
|
case <-stopChan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// renewExpiringCertificates 扫描自有域名,为「即将到期」「证书文件缺失」「之前失败」的记录重新签发。
|
||||||
|
// 已生效域名续期失败不会下线站点,只写 ssl_error 等下一轮重试。
|
||||||
|
func renewExpiringCertificates() {
|
||||||
|
var rows []models.SystemTenantDomain
|
||||||
|
_, err := models.Orm.QueryTable(new(models.SystemTenantDomain)).
|
||||||
|
Filter("domain_type", models.DomainTypeCustom).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
Exclude("status", 2). // 已禁用的不续
|
||||||
|
All(&rows)
|
||||||
|
if err != nil {
|
||||||
|
logs.Error("[acme] 扫描待续期域名失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().AddDate(0, 0, renewBeforeDays)
|
||||||
|
for i := range rows {
|
||||||
|
row := rows[i]
|
||||||
|
if row.FullDomain == nil || *row.FullDomain == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// DNS 还没通过的不去打 CA,避免浪费频控额度
|
||||||
|
if row.VerifyStatus != models.VerifyStatusPassed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !needsRenew(row, deadline) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok, reason := IssueCertificateAsync(row.ID, *row.FullDomain); !ok {
|
||||||
|
logs.Info("[acme] 跳过续期 host=%s: %s", *row.FullDomain, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsRenew 判断一条记录是否需要(重新)签发
|
||||||
|
func needsRenew(row models.SystemTenantDomain, deadline time.Time) bool {
|
||||||
|
if row.SSLStatus == models.SSLStatusIssuing {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if row.SSLStatus != models.SSLStatusIssued {
|
||||||
|
// 待处理或曾失败:重试(失败冷却由 tryLockIssue 兜住)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 证书文件被误删也要补签
|
||||||
|
certPath, keyPath := CertPaths(*row.FullDomain)
|
||||||
|
if !fileExists(certPath) || !fileExists(keyPath) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return row.SSLExpireAt == nil || row.SSLExpireAt.Before(deadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hostnameRe 合法主机名:多段标签,每段字母数字或连字符,末段为字母(顶级域)
|
||||||
|
var hostnameRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
|
||||||
|
|
||||||
|
// NormalizeHost 归一化用户输入的域名:去协议、去路径、去端口、去尾点、转小写。
|
||||||
|
// 返回空字符串表示输入不是合法主机名。
|
||||||
|
func NormalizeHost(raw string) string {
|
||||||
|
h := strings.TrimSpace(strings.ToLower(raw))
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// 去掉协议前缀
|
||||||
|
if idx := strings.Index(h, "://"); idx >= 0 {
|
||||||
|
h = h[idx+3:]
|
||||||
|
}
|
||||||
|
// 去掉路径与查询
|
||||||
|
if idx := strings.IndexAny(h, "/?#"); idx >= 0 {
|
||||||
|
h = h[:idx]
|
||||||
|
}
|
||||||
|
// 去掉可能的 user@ 前缀
|
||||||
|
if idx := strings.LastIndex(h, "@"); idx >= 0 {
|
||||||
|
h = h[idx+1:]
|
||||||
|
}
|
||||||
|
// 去掉端口
|
||||||
|
if idx := strings.Index(h, ":"); idx >= 0 {
|
||||||
|
h = h[:idx]
|
||||||
|
}
|
||||||
|
// 去掉 FQDN 末尾的点
|
||||||
|
h = strings.TrimSuffix(h, ".")
|
||||||
|
// 去掉通配符写法
|
||||||
|
h = strings.TrimPrefix(h, "*.")
|
||||||
|
|
||||||
|
if len(h) > 253 || !hostnameRe.MatchString(h) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomDomainGuide 自有域名的解析配置指引,供前端展示。
|
||||||
|
type CustomDomainGuide struct {
|
||||||
|
CnameTarget string `json:"cname_target"`
|
||||||
|
AIPs []string `json:"a_ips"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCustomDomainGuide 读取配置中的 CNAME 目标与 A 记录 IP。
|
||||||
|
func GetCustomDomainGuide() CustomDomainGuide {
|
||||||
|
target, _ := beego.AppConfig.String("customdomain_cname_target")
|
||||||
|
target = NormalizeHost(target)
|
||||||
|
|
||||||
|
ipsRaw, _ := beego.AppConfig.String("customdomain_a_ips")
|
||||||
|
ips := make([]string, 0, 2)
|
||||||
|
for _, item := range strings.Split(ipsRaw, ",") {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if net.ParseIP(item) == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, item)
|
||||||
|
}
|
||||||
|
return CustomDomainGuide{CnameTarget: target, AIPs: ips}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCustomDomainDNS 检测域名是否已解析到本平台。
|
||||||
|
// 命中任一条件即通过:
|
||||||
|
// 1. CNAME 链最终指向配置的 cname_target(或其本身);
|
||||||
|
// 2. A/AAAA 记录中出现配置的任一 IP;
|
||||||
|
// 3. cname_target 自身解析出的 IP 与域名解析出的 IP 有交集(兼容 CNAME 被 DNS 服务商拍平的情况)。
|
||||||
|
//
|
||||||
|
// 返回 (是否通过, 说明文案)。说明文案会写入 verify_msg 展示给租户。
|
||||||
|
func VerifyCustomDomainDNS(host string) (bool, string) {
|
||||||
|
host = NormalizeHost(host)
|
||||||
|
if host == "" {
|
||||||
|
return false, "域名格式不正确"
|
||||||
|
}
|
||||||
|
|
||||||
|
guide := GetCustomDomainGuide()
|
||||||
|
if guide.CnameTarget == "" && len(guide.AIPs) == 0 {
|
||||||
|
return false, "平台尚未配置解析目标(customdomain_cname_target / customdomain_a_ips),请联系平台管理员"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. CNAME 校验
|
||||||
|
if guide.CnameTarget != "" {
|
||||||
|
if cname, err := net.LookupCNAME(host); err == nil {
|
||||||
|
actual := NormalizeHost(cname)
|
||||||
|
if actual != "" && actual != host && actual == guide.CnameTarget {
|
||||||
|
return true, "CNAME 已指向 " + guide.CnameTarget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2/3. 解析出的 IP 与允许列表或 cname_target 的 IP 比对
|
||||||
|
addrs, err := net.LookupHost(host)
|
||||||
|
if err != nil {
|
||||||
|
return false, "域名无法解析,请确认解析记录已添加并等待生效:" + trimDNSError(err)
|
||||||
|
}
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
return false, "域名未解析到任何 IP,请确认解析记录已添加并等待生效"
|
||||||
|
}
|
||||||
|
|
||||||
|
got := make(map[string]bool, len(addrs))
|
||||||
|
for _, a := range addrs {
|
||||||
|
got[a] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range guide.AIPs {
|
||||||
|
if got[want] {
|
||||||
|
return true, "解析已指向 " + want
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if guide.CnameTarget != "" {
|
||||||
|
if targetAddrs, err := net.LookupHost(guide.CnameTarget); err == nil {
|
||||||
|
for _, want := range targetAddrs {
|
||||||
|
if got[want] {
|
||||||
|
return true, "解析已指向平台服务器(" + want + ")"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect := describeExpectedTarget(guide)
|
||||||
|
return false, fmt.Sprintf("当前解析到 %s,与平台地址不一致。%s", strings.Join(addrs, ", "), expect)
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeExpectedTarget 生成「应该怎么配」的提示文案
|
||||||
|
func describeExpectedTarget(guide CustomDomainGuide) string {
|
||||||
|
parts := make([]string, 0, 2)
|
||||||
|
if guide.CnameTarget != "" {
|
||||||
|
parts = append(parts, "子域名请添加 CNAME 记录指向 "+guide.CnameTarget)
|
||||||
|
}
|
||||||
|
if len(guide.AIPs) > 0 {
|
||||||
|
parts = append(parts, "根域名请添加 A 记录指向 "+strings.Join(guide.AIPs, " 或 "))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ";") + "。"
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimDNSError 精简 DNS 错误信息,避免把内部解析器地址写进给租户看的文案
|
||||||
|
func trimDNSError(err error) string {
|
||||||
|
msg := err.Error()
|
||||||
|
if idx := strings.LastIndex(msg, ": "); idx >= 0 && idx+2 < len(msg) {
|
||||||
|
msg = msg[idx+2:]
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestNormalizeHost 覆盖用户在绑定框里可能输入的各种写法。
|
||||||
|
// 归一化结果直接写进 yz_system_tenant_domain.full_domain,
|
||||||
|
// 而官网渲染是按 Host 精确等值查这张表,所以这里一旦放宽就会出现「绑了但打不开」。
|
||||||
|
func TestNormalizeHost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"普通子域名", "www.example.com", "www.example.com"},
|
||||||
|
{"大写转小写", "WWW.Example.COM", "www.example.com"},
|
||||||
|
{"两端空格", " www.example.com ", "www.example.com"},
|
||||||
|
{"带 http 协议", "http://www.example.com", "www.example.com"},
|
||||||
|
{"带 https 与路径", "https://www.example.com/news/1", "www.example.com"},
|
||||||
|
{"带端口", "www.example.com:8080", "www.example.com"},
|
||||||
|
{"带协议端口路径", "https://www.example.com:443/a/b?c=1", "www.example.com"},
|
||||||
|
{"FQDN 末尾点", "www.example.com.", "www.example.com"},
|
||||||
|
{"通配符写法", "*.example.com", "example.com"},
|
||||||
|
{"根域名", "example.com", "example.com"},
|
||||||
|
{"多级子域名", "a.b.c.example.com", "a.b.c.example.com"},
|
||||||
|
{"含连字符", "my-site.example.com", "my-site.example.com"},
|
||||||
|
{"新顶级域", "example.technology", "example.technology"},
|
||||||
|
|
||||||
|
{"空字符串", "", ""},
|
||||||
|
{"纯空格", " ", ""},
|
||||||
|
{"无顶级域", "localhost", ""},
|
||||||
|
{"纯 IP", "1.2.3.4", ""},
|
||||||
|
{"顶级域含数字", "example.c0m", ""},
|
||||||
|
{"标签以连字符开头", "-bad.example.com", ""},
|
||||||
|
{"标签以连字符结尾", "bad-.example.com", ""},
|
||||||
|
{"含下划线", "bad_name.example.com", ""},
|
||||||
|
{"连续点", "www..example.com", ""},
|
||||||
|
{"以点开头", ".example.com", ""},
|
||||||
|
{"含空格", "www .example.com", ""},
|
||||||
|
{"路径穿越写法", "../../etc/passwd", ""},
|
||||||
|
{"单标签顶级域太短", "example.c", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := NormalizeHost(c.in); got != c.want {
|
||||||
|
t.Errorf("NormalizeHost(%q) = %q, 期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeHostTooLong 超过 253 字节的主机名不合法,必须拒绝。
|
||||||
|
func TestNormalizeHostTooLong(t *testing.T) {
|
||||||
|
long := strings.Repeat("a.", 130) + "com" // 远超 253
|
||||||
|
if got := NormalizeHost(long); got != "" {
|
||||||
|
t.Errorf("超长域名应被拒绝,实际返回 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCertPathsStaysInsideCertDir 证书目录名来自数据库里的域名,
|
||||||
|
// 必须保证拼出来的路径落在 ssl_cert_dir 内,不能被 ../ 之类的脏数据带出去。
|
||||||
|
func TestCertPathsStaysInsideCertDir(t *testing.T) {
|
||||||
|
root := filepath.Clean(SSLCertDir())
|
||||||
|
|
||||||
|
for _, host := range []string{
|
||||||
|
"www.example.com",
|
||||||
|
"../../etc/ssl",
|
||||||
|
"..",
|
||||||
|
"",
|
||||||
|
"/absolute/path",
|
||||||
|
} {
|
||||||
|
certPath, keyPath := CertPaths(host)
|
||||||
|
for _, p := range []string{certPath, keyPath} {
|
||||||
|
cleaned := filepath.Clean(p)
|
||||||
|
if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
|
||||||
|
t.Errorf("host=%q 生成的路径 %q 逃出了证书目录 %q", host, cleaned, root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCertPathsFileNames 落盘文件名必须与 Nginx 配置里引用的名字一致。
|
||||||
|
func TestCertPathsFileNames(t *testing.T) {
|
||||||
|
certPath, keyPath := CertPaths("www.example.com")
|
||||||
|
if filepath.Base(certPath) != "fullchain.pem" {
|
||||||
|
t.Errorf("证书文件名应为 fullchain.pem,实际 %q", filepath.Base(certPath))
|
||||||
|
}
|
||||||
|
if filepath.Base(keyPath) != "privkey.pem" {
|
||||||
|
t.Errorf("私钥文件名应为 privkey.pem,实际 %q", filepath.Base(keyPath))
|
||||||
|
}
|
||||||
|
if filepath.Base(filepath.Dir(certPath)) != "www.example.com" {
|
||||||
|
t.Errorf("证书应放在以域名命名的目录下,实际 %q", filepath.Dir(certPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyCustomDomainDNSWithoutConfig 平台没配解析目标时,
|
||||||
|
// 不能去打 DNS 也不能误判通过,要直接给出「联系管理员」的提示。
|
||||||
|
func TestVerifyCustomDomainDNSWithoutConfig(t *testing.T) {
|
||||||
|
// 测试环境没有加载 app.conf,GetCustomDomainGuide 返回空配置
|
||||||
|
guide := GetCustomDomainGuide()
|
||||||
|
if guide.CnameTarget != "" || len(guide.AIPs) > 0 {
|
||||||
|
t.Skip("当前环境读到了解析目标配置,跳过空配置分支")
|
||||||
|
}
|
||||||
|
|
||||||
|
passed, msg := VerifyCustomDomainDNS("www.example.com")
|
||||||
|
if passed {
|
||||||
|
t.Error("平台未配置解析目标时不应判定通过")
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "平台管理员") {
|
||||||
|
t.Errorf("提示文案应引导联系平台管理员,实际 %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyCustomDomainDNSRejectsBadHost 非法域名在检测入口就要拦住。
|
||||||
|
func TestVerifyCustomDomainDNSRejectsBadHost(t *testing.T) {
|
||||||
|
passed, msg := VerifyCustomDomainDNS("not a domain")
|
||||||
|
if passed {
|
||||||
|
t.Error("非法域名不应判定通过")
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
t.Error("非法域名应给出说明文案")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTempCertDir 把 ssl_cert_dir 指到临时目录。
|
||||||
|
// 默认值是 /www/wwwroot/ssl-certs,在 Windows 上会落到当前盘根目录,
|
||||||
|
// 跑测试不该在仓库外面留文件。
|
||||||
|
func withTempCertDir(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
old, _ := beego.AppConfig.String("ssl_cert_dir")
|
||||||
|
if err := beego.AppConfig.Set("ssl_cert_dir", dir); err != nil {
|
||||||
|
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = beego.AppConfig.Set("ssl_cert_dir", old)
|
||||||
|
})
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChallengeTokenValidation token 会被拼进文件名,
|
||||||
|
// 必须先过正则;否则 `../../` 这类 token 能让 Go 读写证书目录外的文件。
|
||||||
|
func TestChallengeTokenValidation(t *testing.T) {
|
||||||
|
// 合法:base64url,长度 16-128
|
||||||
|
valid := []string{
|
||||||
|
strings.Repeat("a", 16),
|
||||||
|
strings.Repeat("a", 128),
|
||||||
|
"abcABC012_-abcABC012_-",
|
||||||
|
}
|
||||||
|
for _, tok := range valid {
|
||||||
|
if !challengeTokenRe.MatchString(tok) {
|
||||||
|
t.Errorf("token %q 应被接受", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalid := []string{
|
||||||
|
"",
|
||||||
|
"short",
|
||||||
|
strings.Repeat("a", 129),
|
||||||
|
"../../etc/passwd",
|
||||||
|
"has/slash1234567890",
|
||||||
|
"has\\backslash12345678",
|
||||||
|
"has.dot1234567890",
|
||||||
|
"has space1234567890",
|
||||||
|
}
|
||||||
|
for _, tok := range invalid {
|
||||||
|
if challengeTokenRe.MatchString(tok) {
|
||||||
|
t.Errorf("token %q 应被拒绝", tok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetHTTP01ChallengeRejectsBadToken 未命中内存时,
|
||||||
|
// 非法 token 不能进到读文件那一步。
|
||||||
|
func TestGetHTTP01ChallengeRejectsBadToken(t *testing.T) {
|
||||||
|
if _, ok := GetHTTP01Challenge("../../etc/passwd"); ok {
|
||||||
|
t.Error("非法 token 不应返回内容")
|
||||||
|
}
|
||||||
|
if _, ok := GetHTTP01Challenge(""); ok {
|
||||||
|
t.Error("空 token 不应返回内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestChallengeStoreRoundTrip 内存与磁盘两层的存取与删除。
|
||||||
|
// 磁盘那层是给「签发进程」与「应答 CA 回访的进程」不是同一个的部署方式兜底的。
|
||||||
|
func TestChallengeStoreRoundTrip(t *testing.T) {
|
||||||
|
dir := withTempCertDir(t)
|
||||||
|
|
||||||
|
token := strings.Repeat("t", 32)
|
||||||
|
keyAuth := token + ".someThumbprint"
|
||||||
|
|
||||||
|
putHTTP01Challenge(token, keyAuth)
|
||||||
|
t.Cleanup(func() { deleteHTTP01Challenge(token) })
|
||||||
|
|
||||||
|
got, ok := GetHTTP01Challenge(token)
|
||||||
|
if !ok || got != keyAuth {
|
||||||
|
t.Fatalf("取回的 keyAuth = %q ok=%v,期望 %q true", got, ok, keyAuth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 磁盘兜底:清掉内存后仍要能读到
|
||||||
|
challengeMu.Lock()
|
||||||
|
delete(challengeStore, token)
|
||||||
|
challengeMu.Unlock()
|
||||||
|
|
||||||
|
fromDisk, ok := GetHTTP01Challenge(token)
|
||||||
|
if !ok || fromDisk != keyAuth {
|
||||||
|
t.Errorf("应能从磁盘读回 keyAuth,实际 %q ok=%v(目录 %s)", fromDisk, ok, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteHTTP01Challenge(token)
|
||||||
|
if _, ok := GetHTTP01Challenge(token); ok {
|
||||||
|
t.Error("删除后内存与磁盘都不应再返回内容")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTryLockIssueSerializes 同一域名不能并发签发,
|
||||||
|
// 否则会向 CA 重复下单,白白消耗频控额度。
|
||||||
|
func TestTryLockIssueSerializes(t *testing.T) {
|
||||||
|
host := "lock-test.example.com"
|
||||||
|
// 清掉可能残留的状态
|
||||||
|
t.Cleanup(func() { unlockIssue(host, false) })
|
||||||
|
|
||||||
|
ok, _ := tryLockIssue(host)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("首次抢锁应成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
ok2, reason := tryLockIssue(host)
|
||||||
|
if ok2 {
|
||||||
|
t.Error("已在签发中时不应再次拿到锁")
|
||||||
|
}
|
||||||
|
if reason == "" {
|
||||||
|
t.Error("拒绝时应返回原因")
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockIssue(host, false)
|
||||||
|
if ok3, _ := tryLockIssue(host); !ok3 {
|
||||||
|
t.Error("解锁后应能重新抢到")
|
||||||
|
}
|
||||||
|
unlockIssue(host, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTryLockIssueCooldown 失败后进入冷却期,避免租户连点把 CA 额度打满。
|
||||||
|
func TestTryLockIssueCooldown(t *testing.T) {
|
||||||
|
host := "cooldown-test.example.com"
|
||||||
|
t.Cleanup(func() { unlockIssue(host, false) })
|
||||||
|
|
||||||
|
ok, _ := tryLockIssue(host)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("首次抢锁应成功")
|
||||||
|
}
|
||||||
|
unlockIssue(host, true) // 标记失败
|
||||||
|
|
||||||
|
ok2, reason := tryLockIssue(host)
|
||||||
|
if ok2 {
|
||||||
|
t.Error("冷却期内不应放行")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "分钟") {
|
||||||
|
t.Errorf("冷却提示应包含等待时间,实际 %q", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功一次要把冷却清掉
|
||||||
|
unlockIssue(host, false)
|
||||||
|
if ok3, reason3 := tryLockIssue(host); !ok3 {
|
||||||
|
t.Errorf("成功后应清除冷却,实际被拒: %s", reason3)
|
||||||
|
}
|
||||||
|
unlockIssue(host, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIssueCertificateAsyncRejectsBadHost 非法域名不该发起签发流程。
|
||||||
|
func TestIssueCertificateAsyncRejectsBadHost(t *testing.T) {
|
||||||
|
if ok, _ := IssueCertificateAsync(0, "not a domain"); ok {
|
||||||
|
t.Error("非法域名不应发起签发")
|
||||||
|
}
|
||||||
|
if ok, _ := IssueCertificateAsync(0, ""); ok {
|
||||||
|
t.Error("空域名不应发起签发")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetCustomDomainGuideParsing 配置里的 A 记录是逗号分隔的自由文本,
|
||||||
|
// 非法 IP 必须被丢掉,否则会把错误的地址当成期望值展示给租户。
|
||||||
|
func TestGetCustomDomainGuideParsing(t *testing.T) {
|
||||||
|
restore := func(key, val string) {
|
||||||
|
if err := beego.AppConfig.Set(key, val); err != nil {
|
||||||
|
t.Fatalf("恢复配置 %s 失败: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oldTarget, _ := beego.AppConfig.String("customdomain_cname_target")
|
||||||
|
oldIPs, _ := beego.AppConfig.String("customdomain_a_ips")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
restore("customdomain_cname_target", oldTarget)
|
||||||
|
restore("customdomain_a_ips", oldIPs)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := beego.AppConfig.Set("customdomain_cname_target", " Sites.Example.COM. "); err != nil {
|
||||||
|
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||||
|
}
|
||||||
|
if err := beego.AppConfig.Set("customdomain_a_ips", "1.2.3.4, , not-an-ip ,5.6.7.8,999.1.1.1"); err != nil {
|
||||||
|
t.Skipf("当前环境不支持运行时改配置: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
guide := GetCustomDomainGuide()
|
||||||
|
if guide.CnameTarget != "sites.example.com" {
|
||||||
|
t.Errorf("CNAME 目标应被归一化为 sites.example.com,实际 %q", guide.CnameTarget)
|
||||||
|
}
|
||||||
|
want := []string{"1.2.3.4", "5.6.7.8"}
|
||||||
|
if len(guide.AIPs) != len(want) {
|
||||||
|
t.Fatalf("应只保留 %d 个合法 IP,实际 %v", len(want), guide.AIPs)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if guide.AIPs[i] != want[i] {
|
||||||
|
t.Errorf("第 %d 个 IP = %q,期望 %q", i, guide.AIPs[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,32 @@ export function applyTenantDomain(data) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 绑定自有域名(提交后需按提示配置解析,再调 checkCustomDns)
|
||||||
|
export function bindCustomDomain(data) {
|
||||||
|
return request({
|
||||||
|
url: '/platform/domain/tenant/bindCustom',
|
||||||
|
method: 'post',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测自有域名解析,通过后后端自动申请 HTTPS 证书
|
||||||
|
export function checkCustomDomainDns(id) {
|
||||||
|
return request({
|
||||||
|
url: '/platform/domain/tenant/checkCustomDns',
|
||||||
|
method: 'post',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取自有域名解析配置指引(CNAME 目标 / A 记录 IP)
|
||||||
|
export function getCustomDomainGuide() {
|
||||||
|
return request({
|
||||||
|
url: '/platform/domain/tenant/customDomainGuide',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 审核租户域名
|
// 审核租户域名
|
||||||
export function auditTenantDomain(data) {
|
export function auditTenantDomain(data) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -14,13 +14,17 @@
|
|||||||
<div class="search-box">
|
<div class="search-box">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchForm.sub_domain"
|
v-model="searchForm.sub_domain"
|
||||||
placeholder="搜索二级域名"
|
placeholder="搜索二级前缀或完整域名"
|
||||||
clearable
|
clearable
|
||||||
style="width: 200px; margin-right: 10px;"
|
style="width: 220px; margin-right: 10px;"
|
||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
|
<el-select v-model="searchForm.domain_type" placeholder="类型" clearable style="width: 150px; margin-right: 10px;">
|
||||||
|
<el-option label="平台二级域名" :value="1" />
|
||||||
|
<el-option label="自有域名" :value="2" />
|
||||||
|
</el-select>
|
||||||
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
|
||||||
<el-option label="审核中" :value="0" />
|
<el-option label="待生效/审核中" :value="0" />
|
||||||
<el-option label="已生效" :value="1" />
|
<el-option label="已生效" :value="1" />
|
||||||
<el-option label="已禁用" :value="2" />
|
<el-option label="已禁用" :value="2" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -37,20 +41,63 @@
|
|||||||
>
|
>
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
|
||||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
<el-table-column label="类型" width="130" align="center">
|
||||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
|
||||||
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
|
||||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
<el-tag v-if="isCustom(scope.row)" type="warning">自有域名</el-tag>
|
||||||
<el-tag v-else-if="scope.row.status === 1" type="success">已生效</el-tag>
|
<el-tag v-else type="info">平台二级域名</el-tag>
|
||||||
<el-tag v-else type="danger">已禁用</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
|
||||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
<el-table-column prop="status" label="状态" width="120" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<template v-if="scope.row.status === 0">
|
<el-tag v-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||||
|
<el-tag v-else-if="scope.row.status === 2" type="danger">已禁用</el-tag>
|
||||||
|
<el-tag v-else type="warning">{{ isCustom(scope.row) ? '待生效' : '审核中' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="解析检测" width="120" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">—</span>
|
||||||
|
<el-tooltip v-else-if="scope.row.verify_msg" :content="scope.row.verify_msg" placement="top">
|
||||||
|
<el-tag :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="HTTPS 证书" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">平台证书</span>
|
||||||
|
<el-tooltip v-else-if="scope.row.ssl_status === 3 && scope.row.ssl_error" :content="scope.row.ssl_error" placement="top">
|
||||||
|
<el-tag type="danger">签发失败</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip v-else-if="scope.row.ssl_status === 2 && scope.row.ssl_expire_at" :content="'到期时间:' + scope.row.ssl_expire_at" placement="top">
|
||||||
|
<el-tag type="success">已签发</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="sslTagType(scope.row.ssl_status)">{{ sslText(scope.row.ssl_status) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="申请时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<!-- 自有域名无人工审核:解析与证书就绪后自动生效,这里只提供重新检测与禁用 -->
|
||||||
|
<template v-if="isCustom(scope.row)">
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingId === scope.row.id"
|
||||||
|
@click="handleCheckDns(scope.row)"
|
||||||
|
>
|
||||||
|
检测解析
|
||||||
|
</el-button>
|
||||||
|
<el-button v-if="scope.row.status === 1" size="small" text type="danger" @click="handleDisable(scope.row)">
|
||||||
|
禁用
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else-if="scope.row.status === 2" size="small" text type="success" @click="handleEnable(scope.row)">
|
||||||
|
启用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="scope.row.status === 0">
|
||||||
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
|
||||||
通过
|
通过
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -87,13 +134,23 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
import { getTenantDomainList, auditTenantDomain, toggleTenantDomainStatus } from '@/api/domain'
|
import {
|
||||||
|
getTenantDomainList,
|
||||||
|
auditTenantDomain,
|
||||||
|
toggleTenantDomainStatus,
|
||||||
|
checkCustomDomainDns
|
||||||
|
} from '@/api/domain'
|
||||||
|
|
||||||
|
// 域名类型:与后端 models.DomainTypeCustom 对应
|
||||||
|
const DOMAIN_TYPE_CUSTOM = 2
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const checkingId = ref<number | null>(null)
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
sub_domain: '',
|
sub_domain: '',
|
||||||
status: ''
|
status: '',
|
||||||
|
domain_type: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
@@ -104,6 +161,20 @@ const pagination = reactive({
|
|||||||
|
|
||||||
const tableData = ref<any[]>([])
|
const tableData = ref<any[]>([])
|
||||||
|
|
||||||
|
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM
|
||||||
|
|
||||||
|
const verifyText = (status: number) =>
|
||||||
|
status === 1 ? '已通过' : status === 2 ? '未通过' : '未检测'
|
||||||
|
|
||||||
|
const verifyTagType = (status: number) =>
|
||||||
|
status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
|
||||||
|
|
||||||
|
const sslText = (status: number) =>
|
||||||
|
status === 2 ? '已签发' : status === 1 ? '签发中' : status === 3 ? '签发失败' : '待处理'
|
||||||
|
|
||||||
|
const sslTagType = (status: number) =>
|
||||||
|
status === 2 ? 'success' : status === 1 ? 'warning' : status === 3 ? 'danger' : 'info'
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -129,7 +200,7 @@ const handleSearch = () => {
|
|||||||
const handleAudit = async (row: any, action: string) => {
|
const handleAudit = async (row: any, action: string) => {
|
||||||
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
|
||||||
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
|
||||||
|
|
||||||
const res = await auditTenantDomain({ id: row.id, action })
|
const res = await auditTenantDomain({ id: row.id, action })
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg)
|
ElMessage.success(res.msg)
|
||||||
@@ -140,8 +211,8 @@ const handleAudit = async (row: any, action: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDisable = async (row: any) => {
|
const handleDisable = async (row: any) => {
|
||||||
await ElMessageBox.confirm('确定要禁用该域名吗?', '提示', { type: 'warning' })
|
await ElMessageBox.confirm('确定要禁用该域名吗?禁用后该域名将无法访问官网。', '提示', { type: 'warning' })
|
||||||
|
|
||||||
const res = await toggleTenantDomainStatus(row.id)
|
const res = await toggleTenantDomainStatus(row.id)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg)
|
ElMessage.success(res.msg)
|
||||||
@@ -151,6 +222,39 @@ const handleDisable = async (row: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleEnable = async (row: any) => {
|
||||||
|
await ElMessageBox.confirm('确定要重新启用该域名吗?', '提示', { type: 'warning' })
|
||||||
|
|
||||||
|
const res = await toggleTenantDomainStatus(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(res.msg)
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自有域名:重新检测解析,通过后后端自动申请证书
|
||||||
|
const handleCheckDns = async (row: any) => {
|
||||||
|
checkingId.value = row.id
|
||||||
|
try {
|
||||||
|
const res = await checkCustomDomainDns(row.id)
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data || {}
|
||||||
|
if (data.verify_status === 1) {
|
||||||
|
ElMessage.success(data.ssl_message || data.verify_msg || '解析检测通过')
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(data.verify_msg || '解析尚未生效')
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || '检测失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchData()
|
||||||
})
|
})
|
||||||
@@ -177,6 +281,10 @@ onMounted(() => {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
.pagination-wrap {
|
.pagination-wrap {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container-box">
|
<div class="container-box">
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<h2>我的域名</h2>
|
<h2>租户域名</h2>
|
||||||
<el-button type="primary" @click="dialogVisible = true">
|
<div class="header-actions">
|
||||||
<el-icon><Plus /></el-icon>
|
<el-select
|
||||||
申请二级域名
|
v-model="currentTid"
|
||||||
</el-button>
|
placeholder="请选择租户"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
style="width: 240px"
|
||||||
|
@change="fetchDomains"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="item in tenantOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="item.tenant_name || item.tenant_short_name"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" :disabled="!currentTid" @click="applyDialogVisible = true">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
申请二级域名
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" :disabled="!currentTid" @click="openBindDialog">
|
||||||
|
<el-icon><Link /></el-icon>
|
||||||
|
绑定自有域名
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-divider></el-divider>
|
<el-divider></el-divider>
|
||||||
|
|
||||||
<!-- 我的域名列表 -->
|
<el-empty v-if="!currentTid" description="请先在上方选择要管理的租户" />
|
||||||
|
|
||||||
|
<!-- 该租户的域名列表 -->
|
||||||
<el-table
|
<el-table
|
||||||
|
v-else
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
border
|
border
|
||||||
@@ -19,36 +43,85 @@
|
|||||||
element-loading-text="正在加载..."
|
element-loading-text="正在加载..."
|
||||||
>
|
>
|
||||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||||
<el-table-column prop="sub_domain" label="二级域名前缀" width="150" />
|
<el-table-column label="类型" width="130" align="center">
|
||||||
<el-table-column prop="main_domain" label="主域名" min-width="150" />
|
|
||||||
<el-table-column prop="full_domain" label="完整域名" min-width="200">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-link
|
<el-tag v-if="isCustom(scope.row)" type="warning">自有域名</el-tag>
|
||||||
type="primary"
|
<el-tag v-else type="info">平台二级域名</el-tag>
|
||||||
:href="'http://' + scope.row.full_domain"
|
</template>
|
||||||
target="_blank"
|
</el-table-column>
|
||||||
>
|
<el-table-column prop="full_domain" label="完整域名" min-width="220">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-link type="primary" :href="domainUrl(scope.row)" target="_blank">
|
||||||
{{ scope.row.full_domain }}
|
{{ scope.row.full_domain }}
|
||||||
</el-link>
|
</el-link>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
<el-table-column prop="status" label="状态" width="110" align="center">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag v-if="scope.row.status === 0" type="warning">审核中</el-tag>
|
<el-tag v-if="scope.row.status === 1" type="success">已生效</el-tag>
|
||||||
<el-tag v-else-if="scope.row.status === 1" type="success"
|
<el-tag v-else-if="scope.row.status === 2" type="danger">已禁用</el-tag>
|
||||||
>已生效</el-tag
|
<el-tag v-else type="warning">{{
|
||||||
>
|
isCustom(scope.row) ? "待生效" : "审核中"
|
||||||
<el-tag v-else type="danger">已禁用</el-tag>
|
}}</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="create_time" label="申请时间" width="180" />
|
<el-table-column label="解析检测" width="120" align="center">
|
||||||
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">—</span>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.verify_msg"
|
||||||
|
:content="scope.row.verify_msg"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag :type="verifyTagType(scope.row.verify_status)">
|
||||||
|
{{ verifyText(scope.row.verify_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="verifyTagType(scope.row.verify_status)">
|
||||||
|
{{ verifyText(scope.row.verify_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="HTTPS 证书" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="!isCustom(scope.row)" class="text-muted">平台证书</span>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.ssl_status === 3 && scope.row.ssl_error"
|
||||||
|
:content="scope.row.ssl_error"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag type="danger">签发失败</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tooltip
|
||||||
|
v-else-if="scope.row.ssl_status === 2 && scope.row.ssl_expire_at"
|
||||||
|
:content="'到期时间:' + scope.row.ssl_expire_at"
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<el-tag type="success">已签发</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="sslTagType(scope.row.ssl_status)">
|
||||||
|
{{ sslText(scope.row.ssl_status) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="create_time" label="申请时间" width="170" />
|
||||||
|
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button
|
||||||
|
v-if="isCustom(scope.row)"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
:loading="checkingId === scope.row.id"
|
||||||
|
@click="handleCheckDns(scope.row)"
|
||||||
|
>
|
||||||
|
检测解析
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
size="small"
|
size="small"
|
||||||
text
|
text
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="handleCopy(scope.row.full_domain)"
|
@click="handleCopy(scope.row)"
|
||||||
>
|
>
|
||||||
复制
|
复制
|
||||||
</el-button>
|
</el-button>
|
||||||
@@ -63,13 +136,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<!-- 申请二级域名弹窗 -->
|
||||||
<!-- 申请域名弹窗 -->
|
<el-dialog v-model="applyDialogVisible" title="申请二级域名" width="500px">
|
||||||
<el-dialog v-model="dialogVisible" title="申请二级域名" width="500px">
|
<el-form
|
||||||
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
|
:model="applyForm"
|
||||||
|
:rules="applyRules"
|
||||||
|
ref="applyFormRef"
|
||||||
|
label-width="100px"
|
||||||
|
>
|
||||||
<el-form-item label="选择主域名" prop="main_domain">
|
<el-form-item label="选择主域名" prop="main_domain">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="form.main_domain"
|
v-model="applyForm.main_domain"
|
||||||
placeholder="请选择主域名"
|
placeholder="请选择主域名"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
@@ -82,9 +159,12 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="二级前缀" prop="sub_domain">
|
<el-form-item label="二级前缀" prop="sub_domain">
|
||||||
<el-input v-model="form.sub_domain" placeholder="请输入二级域名前缀">
|
<el-input
|
||||||
|
v-model="applyForm.sub_domain"
|
||||||
|
placeholder="请输入二级域名前缀"
|
||||||
|
>
|
||||||
<template #append>{{
|
<template #append>{{
|
||||||
form.main_domain ? "." + form.main_domain : ""
|
applyForm.main_domain ? "." + applyForm.main_domain : ""
|
||||||
}}</template>
|
}}</template>
|
||||||
</el-input>
|
</el-input>
|
||||||
<div class="form-tip">
|
<div class="form-tip">
|
||||||
@@ -94,50 +174,128 @@
|
|||||||
<el-form-item label="预览">
|
<el-form-item label="预览">
|
||||||
<div class="domain-preview">
|
<div class="domain-preview">
|
||||||
{{
|
{{
|
||||||
form.sub_domain
|
applyForm.sub_domain
|
||||||
? form.sub_domain + "." + (form.main_domain || "example.com")
|
? applyForm.sub_domain +
|
||||||
|
"." +
|
||||||
|
(applyForm.main_domain || "example.com")
|
||||||
: "请填写上方信息"
|
: "请填写上方信息"
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="dialogVisible = false">取消</el-button>
|
<el-button @click="applyDialogVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleSubmit" :loading="submitLoading"
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleApplySubmit"
|
||||||
|
:loading="applyLoading"
|
||||||
>提交申请</el-button
|
>提交申请</el-button
|
||||||
>
|
>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 绑定自有域名弹窗 -->
|
||||||
|
<el-dialog v-model="bindDialogVisible" title="绑定自有域名" width="620px">
|
||||||
|
<el-form
|
||||||
|
:model="bindForm"
|
||||||
|
:rules="bindRules"
|
||||||
|
ref="bindFormRef"
|
||||||
|
label-width="90px"
|
||||||
|
>
|
||||||
|
<el-form-item label="域名" prop="domain">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.domain"
|
||||||
|
placeholder="请输入完整域名,如 www.example.com"
|
||||||
|
/>
|
||||||
|
<div class="form-tip">
|
||||||
|
不要带 http:// 与路径。根域名(example.com)与 www
|
||||||
|
视为两个域名,需分别绑定。
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" class="guide-box">
|
||||||
|
<template #title>绑定后请让租户到域名服务商处添加解析</template>
|
||||||
|
<div v-if="guide.configured" class="guide-body">
|
||||||
|
<p v-if="guide.cname_target">
|
||||||
|
<strong>子域名(如 www)</strong>:添加 CNAME 记录,记录值
|
||||||
|
<code>{{ guide.cname_target }}</code>
|
||||||
|
</p>
|
||||||
|
<p v-if="guide.a_ips && guide.a_ips.length">
|
||||||
|
<strong>根域名(如 example.com)</strong>:添加 A 记录,记录值
|
||||||
|
<code>{{ guide.a_ips.join(" 或 ") }}</code>
|
||||||
|
</p>
|
||||||
|
<p class="guide-note">
|
||||||
|
解析生效后点击「检测解析」,系统会自动申请 HTTPS
|
||||||
|
证书,签发成功后域名即生效。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="guide-body">
|
||||||
|
平台尚未配置解析目标,请先在 app.conf 中填写
|
||||||
|
customdomain_cname_target / customdomain_a_ips。
|
||||||
|
</div>
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="handleBindSubmit"
|
||||||
|
:loading="bindLoading"
|
||||||
|
>确认绑定</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, reactive, onMounted } from "vue";
|
import { ref, reactive, onMounted } from "vue";
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { Plus } from "@element-plus/icons-vue";
|
import { Plus, Link } from "@element-plus/icons-vue";
|
||||||
import {
|
import {
|
||||||
getMyDomains,
|
getMyDomains,
|
||||||
applyTenantDomain,
|
applyTenantDomain,
|
||||||
getEnabledDomains,
|
getEnabledDomains,
|
||||||
deleteTenantDomain,
|
deleteTenantDomain,
|
||||||
|
bindCustomDomain,
|
||||||
|
checkCustomDomainDns,
|
||||||
|
getCustomDomainGuide,
|
||||||
} from "@/api/domain";
|
} from "@/api/domain";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { getTenantList } from "@/api/tenant";
|
||||||
|
|
||||||
|
// 域名类型:与后端 models.DomainTypeCustom 对应
|
||||||
|
const DOMAIN_TYPE_CUSTOM = 2;
|
||||||
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const submitLoading = ref(false);
|
const applyLoading = ref(false);
|
||||||
const dialogVisible = ref(false);
|
const bindLoading = ref(false);
|
||||||
const formRef = ref();
|
const checkingId = ref<number | null>(null);
|
||||||
|
const applyDialogVisible = ref(false);
|
||||||
|
const bindDialogVisible = ref(false);
|
||||||
|
const applyFormRef = ref();
|
||||||
|
const bindFormRef = ref();
|
||||||
|
|
||||||
|
// 平台端账号不归属租户,域名操作必须显式指定租户
|
||||||
|
const currentTid = ref<number | null>(null);
|
||||||
|
const tenantOptions = ref<any[]>([]);
|
||||||
const tableData = ref<any[]>([]);
|
const tableData = ref<any[]>([]);
|
||||||
const domainList = ref<any[]>([]);
|
const domainList = ref<any[]>([]);
|
||||||
|
const guide = reactive<{
|
||||||
|
cname_target: string;
|
||||||
|
a_ips: string[];
|
||||||
|
configured: boolean;
|
||||||
|
}>({ cname_target: "", a_ips: [], configured: false });
|
||||||
|
|
||||||
const form = reactive({
|
const applyForm = reactive({
|
||||||
main_domain: "",
|
main_domain: "",
|
||||||
sub_domain: "",
|
sub_domain: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = {
|
const bindForm = reactive({
|
||||||
|
domain: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const applyRules = {
|
||||||
main_domain: [{ required: true, message: "请选择主域名", trigger: "change" }],
|
main_domain: [{ required: true, message: "请选择主域名", trigger: "change" }],
|
||||||
sub_domain: [
|
sub_domain: [
|
||||||
{ required: true, message: "请输入二级域名前缀", trigger: "blur" },
|
{ required: true, message: "请输入二级域名前缀", trigger: "blur" },
|
||||||
@@ -149,16 +307,68 @@ const rules = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bindRules = {
|
||||||
|
domain: [
|
||||||
|
{ required: true, message: "请输入要绑定的域名", trigger: "blur" },
|
||||||
|
{
|
||||||
|
pattern: /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/,
|
||||||
|
message: "请输入合法的完整域名,如 www.example.com",
|
||||||
|
trigger: "blur",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM;
|
||||||
|
|
||||||
|
// 已签发证书的自有域名用 https 打开,其余用 http 避免证书告警
|
||||||
|
const domainUrl = (row: any) => {
|
||||||
|
const scheme = !isCustom(row) || row.ssl_status === 2 ? "https" : "http";
|
||||||
|
return `${scheme}://${row.full_domain}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyText = (status: number) =>
|
||||||
|
status === 1 ? "已通过" : status === 2 ? "未通过" : "未检测";
|
||||||
|
|
||||||
|
const verifyTagType = (status: number) =>
|
||||||
|
status === 1 ? "success" : status === 2 ? "danger" : "info";
|
||||||
|
|
||||||
|
const sslText = (status: number) =>
|
||||||
|
status === 2
|
||||||
|
? "已签发"
|
||||||
|
: status === 1
|
||||||
|
? "签发中"
|
||||||
|
: status === 3
|
||||||
|
? "签发失败"
|
||||||
|
: "待处理";
|
||||||
|
|
||||||
|
const sslTagType = (status: number) =>
|
||||||
|
status === 2
|
||||||
|
? "success"
|
||||||
|
: status === 1
|
||||||
|
? "warning"
|
||||||
|
: status === 3
|
||||||
|
? "danger"
|
||||||
|
: "info";
|
||||||
|
|
||||||
|
const fetchTenants = async () => {
|
||||||
|
const res = await getTenantList({ page: 1, pageSize: 1000 });
|
||||||
|
if (res.code === 200) {
|
||||||
|
tenantOptions.value = res.data?.list || res.data || [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDomains = async () => {
|
const fetchDomains = async () => {
|
||||||
|
if (!currentTid.value) {
|
||||||
|
tableData.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
// 获取我的域名列表
|
const res = await getMyDomains({ tid: currentTid.value });
|
||||||
const res = await getMyDomains({ tid: authStore.user.tid });
|
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
tableData.value = res.data || [];
|
tableData.value = res.data || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取可选主域名
|
|
||||||
const domainRes = await getEnabledDomains();
|
const domainRes = await getEnabledDomains();
|
||||||
if (domainRes.code === 200) {
|
if (domainRes.code === 200) {
|
||||||
domainList.value = domainRes.data || [];
|
domainList.value = domainRes.data || [];
|
||||||
@@ -168,37 +378,96 @@ const fetchDomains = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const fetchGuide = async () => {
|
||||||
await formRef.value.validate();
|
const res = await getCustomDomainGuide();
|
||||||
submitLoading.value = true;
|
if (res.code === 200 && res.data) {
|
||||||
|
guide.cname_target = res.data.cname_target || "";
|
||||||
|
guide.a_ips = res.data.a_ips || [];
|
||||||
|
guide.configured = !!res.data.configured;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBindDialog = async () => {
|
||||||
|
bindForm.domain = "";
|
||||||
|
bindDialogVisible.value = true;
|
||||||
|
await fetchGuide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplySubmit = async () => {
|
||||||
|
await applyFormRef.value.validate();
|
||||||
|
applyLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await applyTenantDomain({
|
const res = await applyTenantDomain({
|
||||||
tid: authStore.user.tid,
|
tid: currentTid.value,
|
||||||
main_domain: form.main_domain,
|
main_domain: applyForm.main_domain,
|
||||||
sub_domain: form.sub_domain,
|
sub_domain: applyForm.sub_domain,
|
||||||
});
|
});
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
ElMessage.success(res.msg);
|
ElMessage.success(res.msg);
|
||||||
dialogVisible.value = false;
|
applyDialogVisible.value = false;
|
||||||
form.sub_domain = "";
|
applyForm.sub_domain = "";
|
||||||
fetchDomains();
|
fetchDomains();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(res.msg || "申请失败");
|
ElMessage.error(res.msg || "申请失败");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false;
|
applyLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (domain: string) => {
|
const handleBindSubmit = async () => {
|
||||||
navigator.clipboard.writeText("http://" + domain);
|
await bindFormRef.value.validate();
|
||||||
|
bindLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await bindCustomDomain({
|
||||||
|
tid: currentTid.value,
|
||||||
|
domain: bindForm.domain,
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
ElMessage.success(res.msg || "绑定成功");
|
||||||
|
bindDialogVisible.value = false;
|
||||||
|
bindForm.domain = "";
|
||||||
|
fetchDomains();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "绑定失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bindLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckDns = async (row: any) => {
|
||||||
|
checkingId.value = row.id;
|
||||||
|
try {
|
||||||
|
const res = await checkCustomDomainDns(row.id);
|
||||||
|
if (res.code === 200) {
|
||||||
|
const data = res.data || {};
|
||||||
|
if (data.verify_status === 1) {
|
||||||
|
ElMessage.success(data.ssl_message || data.verify_msg || "解析检测通过");
|
||||||
|
} else {
|
||||||
|
ElMessage.warning(data.verify_msg || "解析尚未生效,请稍后重试");
|
||||||
|
}
|
||||||
|
fetchDomains();
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res.msg || "检测失败");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
checkingId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (row: any) => {
|
||||||
|
navigator.clipboard.writeText(domainUrl(row));
|
||||||
ElMessage.success("链接已复制到剪贴板");
|
ElMessage.success("链接已复制到剪贴板");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (row: any) => {
|
const handleDelete = async (row: any) => {
|
||||||
try {
|
try {
|
||||||
|
const extra = isCustom(row)
|
||||||
|
? "删除后该域名将无法访问,已签发的证书也不再续期。"
|
||||||
|
: "";
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`确定要删除域名 "${row.full_domain}" 吗?`,
|
`确定要删除域名 "${row.full_domain}" 吗?${extra}`,
|
||||||
"提示",
|
"提示",
|
||||||
{
|
{
|
||||||
confirmButtonText: "确定",
|
confirmButtonText: "确定",
|
||||||
@@ -221,7 +490,8 @@ const handleDelete = async (row: any) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchDomains();
|
fetchTenants();
|
||||||
|
fetchGuide();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -242,10 +512,17 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.form-tip {
|
.form-tip {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.domain-preview {
|
.domain-preview {
|
||||||
@@ -255,4 +532,34 @@ onMounted(() => {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #409eff;
|
color: #409eff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-box {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-body {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.9;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-note {
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- 租户域名表增加「自有域名绑定 + 自动 HTTPS」所需字段(兼容已有数据库)
|
||||||
|
-- 已存在字段时请跳过对应 ALTER 语句。
|
||||||
|
-- 对应模型:go/models/system_tenant_domain.go
|
||||||
|
|
||||||
|
-- 域名类型:1-平台二级域名(历史数据默认值即为 1)2-租户自有域名
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `domain_type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '域名类型 1-平台二级域名 2-自有域名' AFTER `tid`;
|
||||||
|
|
||||||
|
-- 证书状态:0-待处理 1-签发中 2-已签发 3-签发失败
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `ssl_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '证书状态 0-待处理 1-签发中 2-已签发 3-失败' AFTER `status`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `ssl_expire_at` datetime DEFAULT NULL COMMENT '证书到期时间' AFTER `ssl_status`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `ssl_error` varchar(500) DEFAULT NULL COMMENT '最近一次签发失败原因' AFTER `ssl_expire_at`;
|
||||||
|
|
||||||
|
-- DNS 解析校验:0-未检测 1-已通过 2-未通过
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `verify_status` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'DNS校验 0-未检测 1-已通过 2-未通过' AFTER `ssl_error`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `verify_msg` varchar(500) DEFAULT NULL COMMENT '最近一次DNS检测说明' AFTER `verify_status`;
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD COLUMN `last_verify_at` datetime DEFAULT NULL COMMENT '最近一次DNS检测时间' AFTER `verify_msg`;
|
||||||
|
|
||||||
|
-- 索引:按 Host 查租户是官网渲染的热路径;域名类型用于「同类型只能一条」的判断
|
||||||
|
-- 注意:这里用普通索引而非唯一索引,避免历史脏数据(含软删重复域名)导致 ALTER 失败;
|
||||||
|
-- 域名唯一性由应用层 bindCustom / apply 接口校验。
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD KEY `idx_full_domain` (`full_domain`);
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD KEY `idx_tid_domain_type` (`tid`, `domain_type`);
|
||||||
|
|
||||||
|
ALTER TABLE `yz_system_tenant_domain`
|
||||||
|
ADD KEY `idx_ssl_status` (`ssl_status`);
|
||||||
Reference in New Issue
Block a user