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
}
]
+17 -2
View File
@@ -29,7 +29,9 @@
ai.*/smtp.* 对齐 admin 平台设置页)即时生效,无需重启。
鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户
管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。
管理员可自助改配置);settings = require_super(例外:GET /settings/widget-domain
= platform_user 两种角色可读——嵌入代码生成器的原料,spec 2026-10-19 §5)。
409 门经 platform_user 连锁。
密钥掩码哨兵(Phase 3 Task 1):push.api_key / ai.api_key /
email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的响应
@@ -53,7 +55,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.engine.services import boundary_filter
from app.engine.services.email_service import send_test_email
from app.engine.wall_schema import WallConfigError, compile_wall
from app.platform.auth import UserContext, require_super
from app.platform.auth import UserContext, platform_user, require_super
from app.platform.config_validation import (
CONFIG_GROUPS,
ValidationContext,
@@ -743,3 +745,16 @@ async def put_setting(request: Request, user=Depends(require_super)):
await session.commit()
await settings_store.refresh(engine)
return {"key": key, "value": _mask_setting_item(key, value)}
@settings_router.get("/widget-domain")
async def get_widget_domain(user: platform_user):
"""共享挂件域名读取(spec 2026-10-19 §5)——配置中心嵌入代码生成器
消费的平台设置键 shared_widget_domain。两种平台角色可读:超管在平台
设置页配置、租户管理员生成 /t/<slug>/ 形态嵌入代码都要用(与 settings
CRUD 的 require_super 不同——本值非密钥、无敏感面,公开到登录管理员
层即可)。读 settings_store 内存快照(lifespan 装载 / PUT settings 后
refresh 热刷新,Phase 4 Task 5A 同链路);空串/None/非字符串值视同
未配置 → None(前端生成侧还有域名合法字符校验兜底)。"""
raw = settings_store.get("shared_widget_domain")
return {"widget_domain": raw if isinstance(raw, str) and raw else None}
+32
View File
@@ -424,6 +424,38 @@ def test_platform_settings_crud(super_client):
assert super_client.get(SETTINGS).status_code == 401
def test_widget_domain_read_matrix(super_client):
"""GET settings/widget-domain(spec 2026-10-19 §5):嵌入代码生成器的
原料——两种角色可读(超管配置/租户管理员生成);未认证 401;未配置
null;PUT settings 后经 settings_store.refresh 热刷新即时可见。"""
url = f"{SETTINGS}/widget-domain"
# 未认证 401
assert super_client.get(url).status_code == 401
# 超管读:未配置 → None
r = super_client.get(url, headers=_auth(super_client))
assert r.status_code == 200
assert r.json() == {"widget_domain": None}
# 配置后即时可见(PUT settings 触发内存快照热刷新)
r = super_client.put(SETTINGS, headers=_auth(super_client),
json={"key": "shared_widget_domain",
"value": "kefu.example.com"})
assert r.status_code == 200
r = super_client.get(url, headers=_auth(super_client))
assert r.json() == {"widget_domain": "kefu.example.com"}
# 租户管理员同可读(与 settings CRUD 的 require_super 不同——嵌入卡原料)
_create(super_client, slug="wd1")
token = _tenant_admin_token(super_client, "wd1-admin", "wd1-pass-1")
r = super_client.get(url, headers=_bearer(token))
assert r.status_code == 200
assert r.json() == {"widget_domain": "kefu.example.com"}
# 空串值视同未配置(settings_store 空槽语义)
assert super_client.put(
SETTINGS, headers=_auth(super_client),
json={"key": "shared_widget_domain", "value": ""}).status_code == 200
assert super_client.get(
url, headers=_auth(super_client)).json() == {"widget_domain": None}
# ---- 版本列表形态 -----------------------------------------------------------------