三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 138 · 平台共享模型:管理员逐用户授权(默认关闭)
用户口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,默认关闭),
用户才能在会话中使用(以及在设置的模型设置页面展示)」。
· DB 迁移 v11:新增 users.shared_model_granted(DEFAULT 0 = 默认关闭)。
⚠️ 刻意**不**复用 v6 的 shared_model_enabled —— 那是用户侧偏好(用户能自己关,默认 1),
而本需求要的是**管理员门禁**;共用一列则用户点一下就给自己授权,门禁形同不存在。
生效 = granted ∧ enabled(server.ts#sharedLandingRows)。
· 新路由 POST /api/admin/users/:id/models/shared(requireAdmin)+ 审计 shared_model_grant
+ **尽力而为**重启该用户实例(租约被占/实例未运行都不算失败)。
· admin 用户列表新增「共享模型」列;用户侧 /api/me/keys 增 shared.granted / sharedModelGranted;
插件 0.3.24:未授权时「平台共享模型」整块不渲染。
二、顺带修掉一个既有真缺陷:平台侧写用户 home 必须走 UserFs(用户卷在 worker 上)
landModels 原先 join(owner.home_dir, …) + 本机 fs ⇒ 对「实例不在控制面本机」的用户
读到空串(**不报错**)⇒ 模型落地一直是**静默空操作**(托管清单还被清空)
——即档案 87 的模型设置页对 guest 这类用户**从未生效**。
· UserFs 新增 readHomeFile/writeHomeFile + 文件名白名单(settings.yaml / .credentials.yaml)
· worker agent 新增 /fs/home-read /fs/home-write(只认白名单裸文件名)
· landModels 改走 userFs(与"文件面"同一份按归属路由 ⇒ 落地与实例必然同机)
· 同轮把 /api/me/locale(档案 102 语言偏好)也改成同一套(原先同样失效)
· home-files.ts 抽出 backupHomeFile(备份留平台侧,命名规则逐字不变)
三、档案 139 · 品牌中文名:能力枢纽 → 能力网络(其他语言仍 CapabilityNet)
落点四处:i18n 中文词条 / admin.html 顶栏 / favicon.svg 的 title+aria-label / design.css 注释;
test/i18n-brand.test.mjs 期望值同步。档案 137 顶部加"后续"指针,不改历史。
验证(全部真机实测):
· 红腿:未授权 → 106 上 guest 的 .credentials.yaml refs 变空(共享 key 被撤)
· 绿腿:授权 → key 回来 + 托管清单恢复 ["DEEPSEEK_API_KEY"]
· admin 列表带出 sharedModelGranted;用户侧 granted 随授权翻面(true/shared ↔ false/none)
· 开关两次均 200(不再假失败);插件实装 0.3.24 且含 gating 字符串
· 语言偏好:106 上 settings.yaml 出现 locale.preference=en(属主=实例属主,既有段逐字保留)
· 本机 npm test 226 tests / 225 pass / 0 fail / 1 skipped;四个 verify 脚本全绿
部署:47 推 51 个 lib 产物、106 推 12 个(lib/ 是 gitignore ⇒ 回滚点物化在
/opt/dsh/backups/seq138b-20260919-125345/,逐文件对账 0 不一致;先 106 后 47);
插件 business-plugins 0.3.24(两机 artifacts 与本机 pack md5 一致)。
94 lines
3.8 KiB
JavaScript
94 lines
3.8 KiB
JavaScript
/**
|
||
* `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。
|
||
*
|
||
* 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力网络」(2026-09-19
|
||
* 定名)/英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文**
|
||
* —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。
|
||
*
|
||
* 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑),
|
||
* 配一个够用的最小 DOM 桩,断言六种语言解析路径下的**实际渲染结果**。
|
||
*
|
||
* ⚠️ 桩里必须注入 `URLSearchParams` / `URL`:`vm` 上下文只含 ECMAScript 内置、
|
||
* 不含 Web API;少了它们 `new URLSearchParams(location.search)` 会抛 ReferenceError,
|
||
* 被 i18n.js 的 try/catch 吞掉 ⇒ 表现为"`?lang=` 不生效"的**假失败**(第一版踩过)。
|
||
*/
|
||
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import vm from 'node:vm'
|
||
|
||
const SRC = readFileSync(new URL('../web/i18n.js', import.meta.url), 'utf8')
|
||
|
||
/** 造一个够 i18n.js 用的最小元素桩(只需要 getAttribute / textContent)。 */
|
||
function makeEl(attrs = {}) {
|
||
return {
|
||
_attrs: { ...attrs },
|
||
textContent: attrs.__text ?? '',
|
||
className: '',
|
||
innerHTML: '',
|
||
style: {},
|
||
getAttribute(name) {
|
||
return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null
|
||
},
|
||
setAttribute(name, value) {
|
||
this._attrs[name] = value
|
||
},
|
||
appendChild() {},
|
||
addEventListener() {},
|
||
querySelectorAll() {
|
||
return []
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 在最小 DOM 桩里跑一次 i18n.js,返回品牌节点的实际渲染结果与解析出的语言。 */
|
||
function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) {
|
||
const brandNode = makeEl({ 'data-i18n': 'brand.name', __text: 'CapabilityNet' })
|
||
const sandbox = {
|
||
console,
|
||
setTimeout: () => {},
|
||
URLSearchParams,
|
||
URL,
|
||
document: {
|
||
readyState: 'complete',
|
||
cookie,
|
||
documentElement: makeEl(),
|
||
querySelectorAll(selector) {
|
||
return selector === '[data-i18n]' ? [brandNode] : []
|
||
},
|
||
createElement: () => makeEl(),
|
||
addEventListener: () => {},
|
||
},
|
||
navigator: { language, languages: [language] },
|
||
location: { search, href: `https://ai1net.com/login.html${search}`, reload: () => {} },
|
||
window: {},
|
||
}
|
||
sandbox.window = sandbox
|
||
vm.createContext(sandbox)
|
||
vm.runInContext(SRC, sandbox, { filename: 'web/i18n.js' })
|
||
return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang }
|
||
}
|
||
|
||
test('品牌文案:中文显示「能力网络」,英文及其他语言显示 CapabilityNet', () => {
|
||
const cases = [
|
||
['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'],
|
||
['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力网络'],
|
||
['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力网络'],
|
||
['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'],
|
||
['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力网络'],
|
||
['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'],
|
||
]
|
||
for (const [label, opts, expected] of cases) {
|
||
const got = renderBrand(opts)
|
||
assert.equal(got.brand, expected, label)
|
||
}
|
||
})
|
||
|
||
test('品牌文案:不会退化成裸 key、不留空', () => {
|
||
for (const language of ['en-US', 'zh-CN', 'ja-JP', 'de-DE']) {
|
||
const { brand } = renderBrand({ language })
|
||
assert.notEqual(brand, 'brand.name', `裸 key 泄露(${language})`)
|
||
assert.ok(brand.trim() !== '', `空文案(${language})`)
|
||
}
|
||
})
|