diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts index 625705b..a891a46 100644 --- a/admin/src/api/settings.ts +++ b/admin/src/api/settings.ts @@ -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 { return http.put(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 { + return http.get(SETTINGS_ENDPOINTS.widgetDomain, config) +} diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts index a2359bc..57069cd 100644 --- a/admin/src/views/config/center/__tests__/model.test.ts +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -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('') + }) + it('shared 形态带 /t//(路径前缀即租户标识)', () => { + expect(buildEmbedCode('shared', base)) + .toBe('') + }) + it('data-site 追加(两形态通用)', () => { + expect(buildEmbedCode('domain', { ...base, site: 'es' })) + .toBe('') + expect(buildEmbedCode('shared', { ...base, site: 'es' })) + .toBe('') + }) + 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('') + }) +}) diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue index 560660a..2c7c573 100644 --- a/admin/src/views/config/center/index.vue +++ b/admin/src/views/config/center/index.vue @@ -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//static/widget.js——路径前缀 + 即租户标识);可用性:两料齐 → 双形态,无域名 → 仅共享,共享未配 → + 仅域名,都无 → 引导文案(配共享域名 / 绑域名);域名经 + normalizeSharedDomain 校验(防配置侧把恶意字符注入生成的 src 串); + 站点下拉"租户默认(无 data-site)/ 具体站点"两形态通用; 复制 navigator.clipboard + execCommand 降级 - 头像上传:前端预检(扩展名 png/jpg/jpeg/webp + ≤2MB)→ POST multipart (后端魔数校验兜底)→ 表单 avatar 更新为落盘文件名、版本 +1 @@ -366,13 +372,22 @@ @@ -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(null) const detailLoading = ref(false) const detailError = ref('') const detailSeq = ref(0) const embedDomain = ref('') + /** 嵌入形态选中态(生效态见 effectiveForm——选中项不可用时自动回退) */ + const embedForm = ref('domain') + /** 平台共享挂件域名(settings 键 shared_widget_domain;未配置/读取失败 + * → null——平台级一次加载,不随租户切换重取) */ + const widgetDomain = ref(null) /** 站点选择哨兵:ElSelect 把空串 model 当"未选"显 placeholder——默认项 * 用非空哨兵值代表"租户默认(无 data-site)" */ const DEFAULT_SITE_SENTINEL = '__tenant_default__' const embedSite = ref(DEFAULT_SITE_SENTINEL) + /** 共享挂件域名(一次加载,平台级;失败按未配置对待——域名形态不受影响) */ + async function loadWidgetDomain(): Promise { + try { + const res = await getWidgetDomain({ showErrorMessage: false }) + widgetDomain.value = res.widget_domain + } catch { + widgetDomain.value = null + } + } + async function loadDetail(): Promise { 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(() => { + 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 ` @@ -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 { diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts index a9ae524..7b49bb7 100644 --- a/admin/src/views/config/center/model.ts +++ b/admin/src/views/config/center/model.ts @@ -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])?$(拒绝引号/尖括号/空格等—— + * 防配置侧把恶意字符注入生成的 ` : '' + } + const d = o.sharedDomain ? normalizeSharedDomain(o.sharedDomain) : null + return d + ? `` + : '' +} diff --git a/admin/src/views/settings/platform/__tests__/model.test.ts b/admin/src/views/settings/platform/__tests__/model.test.ts index b1b14a1..0c9fc05 100644 --- a/admin/src/views/settings/platform/__tests__/model.test.ts +++ b/admin/src/views/settings/platform/__tests__/model.test.ts @@ -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) }) }) diff --git a/admin/src/views/settings/platform/model.ts b/admin/src/views/settings/platform/model.ts index 63b92b8..4e585b9 100644 --- a/admin/src/views/settings/platform/model.ts +++ b/admin/src/views/settings/platform/model.ts @@ -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 } ] diff --git a/app/platform/routes/config_routes.py b/app/platform/routes/config_routes.py index da70756..27c00b3 100644 --- a/app/platform/routes/config_routes.py +++ b/app/platform/routes/config_routes.py @@ -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// 形态嵌入代码都要用(与 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} diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 09a9e89..f0a6e94 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -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} + + # ---- 版本列表形态 -----------------------------------------------------------------