平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效

三条线合并入库(同一次部署批次,源码与生产此前已一致):

一、档案 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 一致)。
This commit is contained in:
admin committed 2026-09-19 13:51:01 +08:00
1 parent 971ccc3703
commit c2b7c5ef71
30 files changed
+770 -70

No files matched your search

+54
View File
@@ -14,6 +14,16 @@ import { userRoot } from '../../fs/workspace.js'
const ENSURE_BIZ_PLUGINS =
process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs'
/** 档案 138:「平台共享模型」逐用户授权的入参(只有开关本身)。 */
const sharedModelSchema = {
body: {
type: 'object',
required: ['enabled'],
additionalProperties: false,
properties: { enabled: { type: 'boolean' } },
},
} as const
export const adminRoutes: FastifyPluginAsync = async (app) => {
// 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
@@ -66,6 +76,50 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
return { ok: true }
})
/**
* **逐个用户**开启/关闭「平台共享模型」(档案 138 · 2026-09-19 用户口径)。
*
* 口径原文:「admin 设置的共享模型,需要 admin 在用户列表中开启(新增选项,**默认关闭**),
* 用户才能在会话中使用(以及在设置的模型设置页面展示)」。
*
* 为什么放在 admin 路由而不是复用 `/api/me/models/shared`:那一条写的是**用户偏好**
* (`shared_model_enabled`,用户自己在设置页开关);本条的写入目标是**管理员授权**
* (`shared_model_granted`,默认 0)—— 两列、两个主体,⛔ 不能共用一个写入口,
* 否则用户点一下就等于给自己授权了(门禁失效)。
*
* ⚠️ 落地发生在 **spawn 时**(`server.ts#landModels`)⇒ 改完必须让目标用户的实例重启
* 才能看到变化。这里直接调 `restartMain(id)`:**只影响这一个用户**;他没在跑就是空操作
* (下次启动自然按新授权落地)。这与用户自己改条目的行为一致(档案 87 §四)。
*/
app.post(
'/api/admin/users/:id/models/shared',
{ preHandler: requireAdmin, schema: sharedModelSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { enabled } = request.body as { enabled: boolean }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (!(await app.db.setSharedModelGranted(id, enabled))) return reply.code(404).send({ error: 'not_found' })
await app.db.audit(
request.user?.id ?? null,
'shared_model_grant',
JSON.stringify({ userId: id, username: user.username, enabled }),
)
// 重启目标实例是**尽力而为**:授权已经落库了,它才是这次操作的真结果。
// ⚠️ 两种"没重启"都不算失败:① 实例没在跑(`restartMain` 返 undefined)——
// 下次启动自然按新授权落地;② 归属租约被别的持有者占着(抛 LeaseBusyError)
// —— 那不是本次授权的问题,⛔ 不能把它报成"开启失败"(2026-09-19 实测:
// 报 500 时 admin 界面显示"操作失败",而库里其实已经改对了)。
let restarted = false
try {
restarted = (await app.supervisor.restartMain(id)) !== undefined
} catch (err) {
app.log.warn({ err, userId: id }, '共享模型授权已落库,但重启目标实例失败(下次启动生效)')
}
return { ok: true, sharedModelGranted: enabled, restarted }
},
)
// 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 →
// 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。
app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => {
+41 -8
View File
@@ -12,7 +12,7 @@ import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProviders } from '../model-catalog.js'
import { PROTOCOLS } from '../model-landing.js'
import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
import { backupHomeFile } from '../home-files.js'
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
import {
captchaActive,
@@ -380,23 +380,37 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
// 关掉了共享开关的人**就是** none —— 这正是验收③要的语义。
if (!(await app.db.getSharedModelEnabled(userId))) return 'none'
// 档案 138:**管理员没授权 ⇒ 就是 none**(门禁先于内容;用户自己的条目不受影响,
// 因为上面那一步已经判过了)。
if (!(await app.db.getSharedModelGranted(userId))) return 'none'
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
if (admins.length === 0) return 'none'
return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none'
}
/** 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。 */
/**
* 平台共享模型的**非敏感**信息(名字 / 归属 / 条数;绝不返回密钥本身)。
*
* `granted`(档案 138)= **管理员有没有给这个人开**;前端据它决定**要不要渲染**整个
* 「平台共享模型」区块 —— 未授权时连块都不出现(用户口径:开了才"在使用 + 在设置页展示")。
* ⚠️ 未授权时**仍然返回** `owner` / `count` 等字段(不额外做信息收窄),因为这条接口
* 只有**本人或 admin** 能看,且这些是"平台有哪些共享模型"这种非敏感目录信息。
*/
async function sharedKeyInfo(userId: string): Promise<{
available: boolean
name: string | null
owner: string | null
ownerIsMe: boolean
enabled: boolean
granted: boolean
count: number
}> {
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
const enabled = await app.db.getSharedModelEnabled(userId)
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false, enabled, count: 0 }
const granted = await app.db.getSharedModelGranted(userId)
if (admins.length === 0) {
return { available: false, name: null, owner: null, ownerIsMe: false, enabled, granted, count: 0 }
}
// 档案 87:共享**不再假设只有一把** —— admin 也能配多条(与用户侧同一套口径)。
const keys = await app.db.listEnabledCredentialKeys(admins[0].id)
// `ownerIsMe`:admin 看的是**自己**配的那些 ⇒ 前端文案要区分「我配的」与「别人配的」。
@@ -406,6 +420,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
owner: admins[0].username,
ownerIsMe: admins[0].id === userId,
enabled,
granted,
count: keys.length,
}
}
@@ -418,9 +433,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({
keys: await app.db.listCredentialKeys(request.user!.id),
effective: await keySourceOf(request.user!.id),
// `shared.granted`(档案 138)= admin 是否已授权;`shared.enabled` = 用户自己的偏好。
shared: await sharedKeyInfo(request.user!.id),
// 档 87:把「共享开关」与「协议枚举」一并给出,免得前端各写一份常量然后漂掉。
sharedModelEnabled: await app.db.getSharedModelEnabled(request.user!.id),
// 档 138:管理员授权(前端只在 true 时渲染「平台共享模型」区块)。
sharedModelGranted: await app.db.getSharedModelGranted(request.user!.id),
protocols: [...PROTOCOLS],
}))
@@ -536,7 +554,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return { ok: true }
})
/** 平台共享模型的开关(档案 87 口径②)—— 只动**自己**的偏好,不碰 admin 的配置。 */
/**
* 平台共享模型的开关(档案 87 口径② + 档案 138)—— 只动**自己**的偏好,不碰 admin 的配置。
*
* ⚠️ 语义边界(别混):本路由写的是 `shared_model_enabled` = **用户偏好**("我要不要用");
* **能否用**由 admin 在用户列表里写的 `shared_model_granted` 先定(默认关闭)。
* 两者是**与**关系 ⇒ 用户把开关打开、但 admin 没授权时,仍然什么也不会落地。
* 所以这里**不校验授权**:让用户能先关掉自己不想用的、也允许他先打开(授权一到即生效),
* 且**不泄露**"管理员是否授权了别人"这类信息。
*/
app.post('/api/me/models/shared', { preHandler: requireAuth, schema: toggleSchema }, async (request, reply) => {
const { enabled } = request.body as { enabled: boolean }
if (!(await app.db.setSharedModelEnabled(request.user!.id, enabled))) {
@@ -558,15 +584,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
* 本路由**替官方把它自己的设置写进它自己的文件**(顶层 `locale: → preference:`),
* ⇒ 官方语义不破(实例启动时读自己的设置即生效)+ 用户选择跨页面 / 跨重启保留。
* ⚠️ 写文件沿用 `model-landing` 那套(备份到平台目录 + chown 给 home 属主),见 `home-files.ts`。
* 🔴 但**读写必须走 `app.userFs`**(档案 138 §五):用户卷跟着实例走 —— 实例在 worker 上时
* `home/` 就在那台机,直接 `join(home_dir, …)` + 本机 fs 只会读到自己盘上一个不存在的路径
* (**空串、不报错**)⇒ 这条路由对"实例不在控制面本机"的用户**静默失效**(语言选择永远存不上)。
*/
app.post('/api/me/locale', { preHandler: requireAuth, schema: localeSchema }, async (request, reply) => {
const { locale } = request.body as { locale: string }
if (!isLocaleId(locale)) return reply.code(400).send({ error: 'invalid_locale' })
const homeDir = homeRoot(userRoot(app.config.dataRoot, request.user!.id))
const file = join(homeDir, 'settings.yaml')
const text = await readTextOrEmpty(file)
const userId = request.user!.id
const text = (await app.userFs.readHomeFile(userId, 'settings.yaml')) ?? ''
const next = reconcileLocalePreference(text, locale)
if (next.changed) await writeHomeFile(homeDir, file, next.text)
if (next.changed) {
// 备份放**平台侧**(控制面的备份目录),写入走 `userFs`(可能落到远端那台机)——
// 与 `server.ts#landModels` 同一套口径,⛔ 别在这里自己拼绝对路径。
await backupHomeFile(homeRoot(userRoot(app.config.dataRoot, userId)), 'settings.yaml', text)
await app.userFs.writeHomeFile(userId, 'settings.yaml', next.text)
}
return { ok: true, locale, changed: next.changed }
})