平台共享模型改为「管理员逐用户授权」+ 品牌中文名改「能力网络」+ 修掉跨机模型落地/语言偏好静默失效
三条线合并入库(同一次部署批次,源码与生产此前已一致):
一、档案 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:
1 parent
971ccc3703
commit
c2b7c5ef71
30 files changed
+770
-70
No files matched your search
@@ -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
@@ -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 }
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user