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:
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/<租户>/
|
||||
解析(脚本地址即租户标识,无需绑定域名)。缺省 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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user