feat(admin): 嵌入代码双形态 + shared_widget_domain 平台键

- 后端 GET /api/platform/settings/widget-domain:读平台设置键
  shared_widget_domain(settings_store 内存快照,PUT settings 后热刷新
  即时可见);platform_user 两种角色可读(超管配置/租户管理员生成嵌入
  代码——与 settings CRUD 的 require_super 不同,非密钥无敏感面);
  空串/非字符串视同未配置 → null
- config/center 嵌入卡双形态(spec 2026-10-19 §5):域名形态 +
  共享域名形态(/t/<slug>/static/widget.js,路径前缀即租户标识);
  可用性规则(两料齐双形态/无域名仅共享/共享未配仅域名/都无引导文案)
  与生效态回退;data-site 两形态通用
- normalizeSharedDomain 主机名字符集校验(拒引号/尖括号/空格——防
  配置侧把恶意字符注入生成的 script src 串)+ buildEmbedCode 纯函数,
  vitest 6 例钉位
- 平台设置页新增「共享域名」组(shared_widget_domain 推荐键,表驱动
  渲染自动生效);settings API 模块加 getWidgetDomain

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-19 19:24:56 +08:00
parent f938118fee
commit 5813fc91c7
8 changed files with 268 additions and 24 deletions
+19 -1
View File
@@ -41,7 +41,8 @@ export interface PutPlatformSettingRequest {
/** 平台设置端点 */
export const SETTINGS_ENDPOINTS = {
list: '/api/platform/settings',
put: '/api/platform/settings'
put: '/api/platform/settings',
widgetDomain: '/api/platform/settings/widget-domain'
} as const
/**
@@ -68,3 +69,20 @@ export function putPlatformSetting(
): Promise<PlatformSettingItem> {
return http.put<PlatformSettingItem>(SETTINGS_ENDPOINTS.put, body, config)
}
/** 共享挂件域名响应(未配置 → null) */
export interface WidgetDomainResponse {
widget_domain: string | null
}
/**
* 共享挂件域名读取(GET /api/platform/settings/widget-domain,spec
* 2026-10-19 §5):配置中心嵌入代码生成器 /t/<租户>/ 形态的基地址。
* 与 settings CRUD 的仅超管不同——两种平台角色可读(超管配置、租户
* 管理员生成嵌入代码都要用);非密钥、无敏感面。
*/
export function getWidgetDomain(
config?: RequestOptions
): Promise<WidgetDomainResponse> {
return http.get<WidgetDomainResponse>(SETTINGS_ENDPOINTS.widgetDomain, config)
}
@@ -32,6 +32,7 @@ import {
asSecretMasked,
buildAiCredsPayload,
buildEmailCredsPayload,
buildEmbedCode,
buildPushCredsPayload,
buildWallPayload,
COMPARE_SCOPE_ALL,
@@ -47,6 +48,7 @@ import {
miscToForm,
miscToPayload,
normalizeAiCredsGroup,
normalizeSharedDomain,
normalizeEmailCredsGroup,
normalizePromptsGroup,
normalizePushCredsGroup,
@@ -857,3 +859,46 @@ describe('KeyValue 行校验(engine seed / misc extras 共用)', () => {
expect(validateKeyValueRows([{ key: '', value: '1' }], '映射')).toEqual([])
})
})
// ---- 嵌入代码双形态(spec 2026-10-19 §5;拼接安全防线 §4) ---------------------------
describe('normalizeSharedDomain(安全防线 §4)', () => {
it('规范形态:协议/尾斜杠/大小写/空白', () => {
expect(normalizeSharedDomain(' https://Kefu.Example.com/ ')).toBe('kefu.example.com')
expect(normalizeSharedDomain('chat.example.com')).toBe('chat.example.com')
expect(normalizeSharedDomain('http://chat.example.com//')).toBe('chat.example.com')
})
it('恶意字符拒绝:引号/尖括号/空格/onload 注入样例;空串 null', () => {
expect(normalizeSharedDomain('kefu.com" onload="x')).toBeNull()
expect(normalizeSharedDomain('<script>')).toBeNull()
expect(normalizeSharedDomain('kefu .com')).toBeNull()
expect(normalizeSharedDomain('')).toBeNull()
// 首尾非主机名字符集(点/横杠开头或结尾)同样拒绝
expect(normalizeSharedDomain('.example.com')).toBeNull()
expect(normalizeSharedDomain('example.com-')).toBeNull()
})
})
describe('buildEmbedCode 双形态', () => {
const base = { domain: 'chat.example.com', sharedDomain: 'kefu.example.com', tenantSlug: 'jiancai', site: null }
it('domain 形态:https://<域名>/static/widget.js', () => {
expect(buildEmbedCode('domain', base))
.toBe('<script src="https://chat.example.com/static/widget.js"></script>')
})
it('shared 形态带 /t/<slug>/(路径前缀即租户标识)', () => {
expect(buildEmbedCode('shared', base))
.toBe('<script src="https://kefu.example.com/t/jiancai/static/widget.js"></script>')
})
it('data-site 追加(两形态通用)', () => {
expect(buildEmbedCode('domain', { ...base, site: 'es' }))
.toBe('<script src="https://chat.example.com/static/widget.js" data-site="es"></script>')
expect(buildEmbedCode('shared', { ...base, site: 'es' }))
.toBe('<script src="https://kefu.example.com/t/jiancai/static/widget.js" data-site="es"></script>')
})
it('缺料/非法域名 → 空串(不产出可注入的 src 串)', () => {
expect(buildEmbedCode('shared', { ...base, sharedDomain: null })).toBe('')
expect(buildEmbedCode('shared', { ...base, sharedDomain: 'bad"domain' })).toBe('')
expect(buildEmbedCode('domain', { ...base, domain: 'bad"domain' })).toBe('')
expect(buildEmbedCode('domain', { ...base, domain: '' })).toBe('')
})
})
+90 -14
View File
@@ -41,8 +41,14 @@
ElMessageBox「丢弃修改 / 留下」确认后才放行
- 挂件实时预览:**本地简化预览组件**(标题/颜色气泡/头像/话题/占位的
静态模拟,零后端依赖、即时反映表单值;真机预览留给嵌入代码上真站)
- 嵌入代码:域名取租户详情 sites[].domains(超管可枚举全租户,租户管理
员可读自己);站点下拉"租户默认(无 data-site)/ 具体站点";
- 嵌入代码(双形态,spec 2026-10-19 §5):域名形态(域名取租户详情
sites[].domains,超管可枚举全租户,租户管理员可读自己)+ 共享域名
形态(平台设置 shared_widget_domain 经 GET settings/widget-domain
下发,地址 https://<共享域名>/t/<slug>/static/widget.js——路径前缀
即租户标识);可用性:两料齐 → 双形态,无域名 → 仅共享,共享未配 →
仅域名,都无 → 引导文案(配共享域名 / 绑域名);域名经
normalizeSharedDomain 校验(防配置侧把恶意字符注入生成的 src 串);
站点下拉"租户默认(无 data-site)/ 具体站点"两形态通用;
复制 navigator.clipboard + execCommand 降级
- 头像上传:前端预检(扩展名 png/jpg/jpeg/webp + ≤2MB)→ POST multipart
(后端魔数校验兜底)→ 表单 avatar 更新为落盘文件名、版本 +1
@@ -366,13 +372,22 @@
</ElButton>
</ElAlert>
<ElEmpty
v-else-if="!detailLoading && domainOptions.length === 0"
description="该租户暂无绑定域名——到「租户管理 → 详情 → 绑定域名」绑定后可生成嵌入代码"
v-else-if="detail && !hasDomains && !hasShared"
description="暂无可用的嵌入形态——到「平台设置」配置共享挂件域名,或在「租户管理 → 详情 → 绑定域名」绑定域名"
:image-size="60"
/>
<template v-else-if="detail">
<div class="embed-controls">
<ElRadioGroup v-model="embedForm" size="small">
<ElRadioButton value="domain" :disabled="!hasDomains">
域名形态
</ElRadioButton>
<ElRadioButton value="shared" :disabled="!hasShared">
共享域名形态
</ElRadioButton>
</ElRadioGroup>
<ElSelect
v-if="effectiveForm === 'domain'"
v-model="embedDomain"
placeholder="租户域名"
class="embed-domain"
@@ -384,6 +399,9 @@
:label="`${item.domain}(站点 ${item.site})`"
/>
</ElSelect>
<div v-else class="embed-shared-base mono">
https://{{ sharedHost }}/t/{{ activeSlug }}/
</div>
<ElSelect v-model="embedSite" class="embed-site">
<ElOption
:value="DEFAULT_SITE_SENTINEL"
@@ -408,8 +426,10 @@
</ElButton>
</div>
<p class="embed-tip">
脚本按域名解析租户;缺省 data-site 时门面按该域名所属站点解析,
选具体站点(如空域名站点 es)则以 data-site 强制指定门面。
域名形态按 Host 解析租户;共享域名形态按路径前缀 /t/&lt;租户&gt;/
解析(脚本地址即租户标识,无需绑定域名)。缺省 data-site
时门面按域名所属站点解析(共享形态取租户默认站点),
选具体站点则以 data-site 强制指定门面。
</p>
</template>
</div>
@@ -580,6 +600,7 @@
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/store/modules/auth'
import { fetchTenantDetail, type TenantDetailResponse } from '@/api/tenants'
import { getWidgetDomain } from '@/api/settings'
import { useTenantOptions } from '@/composables/useTenantOptions'
import {
fetchTenantConfig,
@@ -588,6 +609,11 @@
uploadBrandAvatar,
type BrandGroupValue
} from '@/api/config'
import {
buildEmbedCode,
normalizeSharedDomain,
type EmbedForm
} from './model'
import ConfigCard from './ConfigCard.vue'
import PromptsPane from './PromptsPane.vue'
import WallPane from './WallPane.vue'
@@ -935,18 +961,33 @@
return title ? title.charAt(0).toUpperCase() : 'K'
})
// ---- 嵌入代码(域名取租户详情;data-site 可选) -----------------------------------------
// ---- 嵌入代码(双形态:域名形态 + 共享域名形态——spec 2026-10-19 §5) ---------------------
const detail = ref<TenantDetailResponse | null>(null)
const detailLoading = ref(false)
const detailError = ref('')
const detailSeq = ref(0)
const embedDomain = ref('')
/** 嵌入形态选中态(生效态见 effectiveForm——选中项不可用时自动回退) */
const embedForm = ref<EmbedForm>('domain')
/** 平台共享挂件域名(settings 键 shared_widget_domain;未配置/读取失败
* → null——平台级一次加载,不随租户切换重取) */
const widgetDomain = ref<string | null>(null)
/** 站点选择哨兵:ElSelect 把空串 model 当"未选"显 placeholder——默认项
* 用非空哨兵值代表"租户默认(无 data-site)" */
const DEFAULT_SITE_SENTINEL = '__tenant_default__'
const embedSite = ref(DEFAULT_SITE_SENTINEL)
/** 共享挂件域名(一次加载,平台级;失败按未配置对待——域名形态不受影响) */
async function loadWidgetDomain(): Promise<void> {
try {
const res = await getWidgetDomain({ showErrorMessage: false })
widgetDomain.value = res.widget_domain
} catch {
widgetDomain.value = null
}
}
async function loadDetail(): Promise<void> {
if (!activeSlug.value) return
const seq = ++detailSeq.value
@@ -977,14 +1018,36 @@
detail.value ? domainOptionsFrom(detail.value) : []
)
/** 共享域名规范化形态(协议/尾斜杠清洗 + 主机名字符集校验;未配置/非法 → null) */
const sharedHost = computed(() =>
widgetDomain.value ? normalizeSharedDomain(widgetDomain.value) : null
)
const hasDomains = computed(() => domainOptions.value.length > 0)
const hasShared = computed(() => sharedHost.value !== null)
/** 实际生效形态:选中项不可用(切到无域名租户 / 共享未配)时回退到可用形态 */
const effectiveForm = computed<EmbedForm>(() => {
if (embedForm.value === 'domain' && hasDomains.value) return 'domain'
if (embedForm.value === 'shared' && hasShared.value) return 'shared'
return hasDomains.value ? 'domain' : 'shared'
})
// 选中态回写:兜底回退发生时(如切到无域名租户而选中态还是 domain)把
// 生效态同步回选中态——radio 高亮与实际生成的形态保持一致(用户点选
// 可用项时 effectiveForm 随 embedForm 等值重算,本 watch 无操作)
watch(effectiveForm, (form) => {
embedForm.value = form
})
const embedCode = computed(() => {
const domain = embedDomain.value
if (!domain) return ''
const site =
embedSite.value && embedSite.value !== DEFAULT_SITE_SENTINEL
? ` data-site="${embedSite.value}"`
: ''
return `<script src="https://${domain}/static/widget.js"${site}><\/script>`
if (!detail.value) return ''
return buildEmbedCode(effectiveForm.value, {
domain: embedDomain.value,
sharedDomain: widgetDomain.value,
tenantSlug: activeSlug.value ?? '',
site: embedSite.value !== DEFAULT_SITE_SENTINEL ? embedSite.value : null
})
})
/** 复制:clipboard API 优先,非安全上下文/旧浏览器降级 execCommand */
@@ -1045,6 +1108,7 @@
detail.value = null
detailError.value = ''
embedDomain.value = ''
embedForm.value = 'domain'
embedSite.value = DEFAULT_SITE_SENTINEL
reloadConfig()
loadDetail()
@@ -1052,6 +1116,7 @@
onMounted(() => {
bootstrapTenantContext()
loadWidgetDomain()
})
</script>
@@ -1455,6 +1520,17 @@
.embed-site {
width: 100%;
}
// 共享形态的基地址展示(域名形态的 ElSelect 等位替换)
.embed-shared-base {
padding: 7px 10px;
overflow-x: auto;
border-radius: 4px;
background: var(--art-main-bg-color, var(--el-fill-color-lighter));
font-size: 12px;
line-height: 1.5;
white-space: nowrap;
}
}
.embed-code-row {
+40
View File
@@ -1019,3 +1019,43 @@ export function miscToPayload(
name_to_region_extras: rowsToRecord(form.extrasRows)
}
}
// ---- 嵌入代码双形态(spec 2026-10-19 §5 + 拼接安全防线 §4) --------------------------
/**
* 域名(共享或租户)规范化:去协议/尾斜杠/转小写/去空白;须过主机名
* 字符集 ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$(拒绝引号/尖括号/空格等——
* 防配置侧把恶意字符注入生成的 <script src> 串);空或非法 → null。
*/
export function normalizeSharedDomain(v: string): string | null {
const s = v.trim().replace(/^https?:\/\//i, '').replace(/\/+$/, '').toLowerCase()
return /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(s) ? s : null
}
export type EmbedForm = 'domain' | 'shared'
export interface EmbedCodeOptions {
domain: string
sharedDomain: string | null
tenantSlug: string
site: string | null
}
/**
* 嵌入代码生成:domain 形态 = https://<域名>/static/widget.js;
* shared 形态 = https://<共享域名>/t/<slug>/static/widget.js(路径前缀
* 路由,脚本地址即租户标识)。域名均过 normalizeSharedDomain 校验
* (tenantSlug 由创建侧 SLUG_RE 保证);缺料/校验不过 → ''(不产出
* 可注入的 src 串)。data-site 可选追加(两形态通用)。
*/
export function buildEmbedCode(form: EmbedForm, o: EmbedCodeOptions): string {
const siteAttr = o.site ? ` data-site="${o.site}"` : ''
if (form === 'domain') {
const d = normalizeSharedDomain(o.domain)
return d ? `<script src="https://${d}/static/widget.js"${siteAttr}></script>` : ''
}
const d = o.sharedDomain ? normalizeSharedDomain(o.sharedDomain) : null
return d
? `<script src="https://${d}/t/${o.tenantSlug}/static/widget.js"${siteAttr}></script>`
: ''
}
@@ -140,10 +140,11 @@ describe('平台设置模型(settings/platform/model.ts)', () => {
expect(formatSettingValue('x.list', [1, 2])).toBe('[1,2]')
})
it('推荐键集合:ai.* 三键 + smtp.* 四键', () => {
expect(ALL_SETTING_FIELDS).toHaveLength(7)
it('推荐键集合:ai.* 三键 + smtp.* 四键 + site 单键', () => {
expect(ALL_SETTING_FIELDS).toHaveLength(8)
expect(isRecommendedKey('ai.api_key')).toBe(true)
expect(isRecommendedKey('smtp.port')).toBe(true)
expect(isRecommendedKey('shared_widget_domain')).toBe(true)
expect(isRecommendedKey('other.custom')).toBe(false)
})
})
+22 -5
View File
@@ -7,10 +7,11 @@
* 事实源对齐:
* - 后端 app/platform/routes/config_routes.py 的 settings_router:
* PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON),
* 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*)
* 是前端约定,不是后端 schema——但推荐键已接线后端 resolver 的平台
* 默认回退链(Phase 4 Task 5A:租户槽位 → 平台设置 → env,保存后
* 即时生效);
* 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp 四键 /
* shared_widget_domain)是前端约定,不是后端 schema——ai 与 smtp 两组
* 已接线后端 resolver 的平台默认回退链(Phase 4 Task 5A:租户槽位 →
* 平台设置 → env,保存后即时生效);shared_widget_domain 经
* GET settings/widget-domain 下发配置中心嵌入卡(spec 2026-10-19 §5);
* - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含
* key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中,
* 与后端行为一致是契约,不"修正");
@@ -102,9 +103,19 @@ export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [
}
]
/** 共享挂件域名(嵌入代码 /t/<租户>/ 形态的基地址——spec 2026-10-19 §5;
* 后端经 GET settings/widget-domain 下发给配置中心嵌入卡,两种角色可读) */
export const SITE_SETTING_FIELDS: SettingFieldMeta[] = [
{
key: 'shared_widget_domain',
label: '共享挂件域名',
placeholder: '如 kefu.example.com(嵌入代码 /t/<租户>/ 形态的基地址,不带 https://)'
}
]
/** 表单分组(页卡顺序) */
export const SETTING_FIELD_GROUPS: Array<{
id: 'ai' | 'smtp'
id: 'ai' | 'smtp' | 'site'
title: string
description: string
fields: SettingFieldMeta[]
@@ -120,6 +131,12 @@ export const SETTING_FIELD_GROUPS: Array<{
title: '默认 SMTP',
description: '租户 email 组空槽的回退源(推荐键名 smtp.*——键名自由,本页约定)',
fields: SMTP_SETTING_FIELDS
},
{
id: 'site',
title: '共享域名',
description: '一个域名承载全部租户的挂件(嵌入代码 /t/<租户>/ 形态——键名自由,本页约定)',
fields: SITE_SETTING_FIELDS
}
]