From 12101b5ce2c56c2f5c581e8ad595c99a6e71a07f Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 19:07:17 +0800 Subject: [PATCH 01/45] =?UTF-8?q?docs:=20=E9=A1=B9=E7=9B=AE=E9=A1=B5?= =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=B7=A5=E4=BD=9C=E6=B5=81=20A=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20spec=EF=BC=88=E9=AA=A8=E6=9E=B6/=E4=BF=A1=E6=81=AF/?= =?UTF-8?q?=E6=96=87=E4=BB=B6/=E9=A1=B9=E7=9B=AE=E8=AE=BE=E7=BD=AE=20+=20?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E5=88=87=E6=8D=A2=E5=99=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6 工作流大改造之 A(地基):ProjectShell 侧栏骨架 + 嵌套分区;信息页只读、文件浏览器方案1、管理→项目设置(头像上传/主源语言/可编辑 slug);后端迁移 0007(primary_source_lang/avatar_path)、MediaStore、tree 每文件进度、PUT 扩展;附带顶栏语言切换器。已与作者逐张 mockup 确认。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-01-project-page-restructure-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-project-page-restructure-design.md diff --git a/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md b/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md new file mode 100644 index 0000000..eafdbc7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md @@ -0,0 +1,103 @@ +# 项目页重构 · 工作流 A(骨架 + 信息 + 文件)— 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **A**(地基);后续 B 上传/文件管理 · C 任务 · D 术语 · E 编辑器 · F 平台杂项 | +| 基线 | `master` @ `6b19657` | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | CLAUDE.md、`plan/26-06-28-init_system.md`、`docs/architecture.md` | + +> 已与作者确认(含可视化 mockup 逐张过):**骨架方案 A**(左侧栏含身份,默认落地「信息」);分区顺序 `信息·文件·任务·术语·排行榜·下载·管理`;**编辑器不占分区**,从文件页点文件进入;**信息页只读**,一切编辑集中「管理→项目设置」;**主源语言**限源语言之一(新字段 `primary_source_lang`);**头像上传到磁盘存储**(`MediaStore` 可插拔,本地磁盘 + Docker 卷);**文件浏览器方案 1**(面包屑单栏),状态筛选按「是否仍含某状态词条」,排序 名称/进度/词条数/最近更新;**slug 可编辑**(不在 URL 内,仅展示 + zip 文件名,校验唯一即可)。样式:**少用圆角**、词条状态**用全称**(未翻译/已翻译/有疑问/已检查/已审核)、计数用「万」。 + +--- + +## 1. 范围 + +**做**:把当前单页 `ProjectDetailView` 拆成「侧栏骨架 + 分区」的项目工作区外壳,落地其中 4 个分区(信息 / 文件 / 下载 / 管理→项目设置)+ 2 个占位(任务 / 排行榜,术语分区亦占位)。新增 2 个项目字段(主源语言、头像)与其所需的媒体存储、每文件进度数据。另附带全局顶栏**界面语言切换器**(与 A 的 i18n/外壳同批做)。 + +**不做(属后续工作流,本阶段仅占位或不动)**:任务功能实现(C)、术语功能实现(D)、管理→成员管理 / 文件管理(B/E)、排行榜真实榜单(P6)、编辑器改造与搜索重构(E)、删除词条 context(E)、平台用户管理与删项目数学门(F)、字体回退修复(F)。**编辑器 `/editor` 本阶段不改**,仅调整「从文件页进入」的入口与传参。 + +## 2. 决策要点(已确认,逐条落地) + +1. 外壳 = 左侧栏(顶部小头像+项目名+语言对)+ 右侧分区内容区;默认 `信息`。 +2. 分区固定 7 项,顺序 `信息·文件·任务·术语·排行榜·下载·管理`;`任务/术语/排行榜` 渲染占位(「该功能在后续阶段实现」)。 +3. 信息页**只读**;无「编辑信息」按钮。 +4. 编辑器从**文件页点文件**进入(`/projects/:id/editor?file=`);侧栏不放翻译直达。 +5. `主源语言 primary_source_lang`:限 `source_langs` 之一;`null` 时隐含取 `source_langs[0]`(与现搜索一致)。 +6. 头像:上传**磁盘存储**,`MediaStore` trait(本地磁盘实现,配置化 media 根目录 + Docker 卷),日后可换对象存储;未设时前端按项目名首字生成默认块。 +7. 文件浏览器**方案 1**:面包屑 + 当前目录单栏列表;搜索文件名/目录名(前端过滤,命中即拍平显示完整路径);排序 名称/进度/词条数/最近更新;状态筛选「含某状态词条」= 全部 / 含未翻译 / 含有疑问 / 已译完 / 已审完;每文件与每文件夹显示进度;点文件进编辑器。维护操作(建夹/上传/移动/删除/历史)**不在此**,留「管理」。 +8. slug 可编辑,校验唯一。 +9. 样式令牌:小圆角;状态标签全称;数字用「万」;进度定义沿用 `进度 =(总数 − 未翻译)/ 总数`,分段条按 5 状态。 + +## 3. 前端设计 + +**路由(hash,数字 id 不变)**:`ProjectShell` 承载嵌套子路由—— +- `/projects/:id(\d+)` → **302 到 `…/info`**(兼容旧书签;旧 `ProjectDetailView` 退役后此路径仍可达) +- 子路由:`info` `files` `tasks` `glossary` `leaderboard` `download` `manage`(`` 挂在 Shell 内容区) +- `/projects/:id(\d+)/editor` 保持独立**全屏**路由(不套 Shell),由文件页跳入并带 `?file=`。 + +**组件** +- `ProjectShell.vue`:拉取 project 详情(复用 `GET /projects/{id}`);侧栏身份 + 导航;响应式——窄屏侧栏折叠为顶部下拉/抽屉。分区高亮由当前子路由决定。 +- `ProjectInfoView.vue`(只读):头像(`GET …/avatar` 失败→默认块)+ 名称 + 公开/私有;语言行(源语言 chips,主源标 `主源` → 目标);简介;总进度(分段条 + 全称图例 + 「万」计数 + 「进度 X%」头条);统计卡(文件/词条/成员;术语数待 D,本阶段不显示)。 +- `ProjectFilesView.vue`(方案 1):顶部搜索框 + 排序下拉 + 状态筛选下拉;面包屑;当前目录行列表(文件夹在前,含聚合进度;文件含进度条 + 词条数);点文件夹下钻/面包屑返回;**搜索态**拍平全树匹配项显示完整路径。全部前端过滤/排序,数据来自扩展后的 tree 接口。空态/加载态。 +- `ProjectDownloadView.vue`:迁移现有导出(`GET …/export` → `{slug}.zip`),保留下载按钮 + 说明。 +- `ProjectLeaderboardView.vue` / `ProjectTasksView.vue` / `ProjectGlossaryView.vue`:占位组件(统一「后续阶段实现」空态)。 +- `ProjectManageView.vue`:子标签 `项目设置`(本阶段)·`成员管理`·`文件管理`(后二者占位)。**项目设置表单**:头像(上传/移除 + 提示 ≤512KB、PNG/JPG/WebP、方形建议、客户端预压缩)、名称、slug(可编辑,失焦查重)、简介、可见性(公开/私有)、源语言(多 chip 增删,BCP-47)、主源语言(下拉,选项 = 当前源语言)、目标语言(单,BCP-47)、保存/重置。仅 `project.manage` 可见可用。 +- 现 `ProjectDetailView.vue` 拆解退役:文件树逻辑迁 `ProjectFilesView`,进度/统计迁 `ProjectInfoView`,导出迁 `ProjectDownloadView`,删除/上传/成员相关入口迁「管理」(占位挂钩,B/E 实现)。 + +**附带 · 界面语言切换器(全局顶栏)**:`App.vue` 顶栏右侧加 🌐 下拉(变体 2,可扩展多语言),切换即时设 `i18n.global.locale` + 存 `localStorage`(键 `prts.locale`;初始化读取,缺省跟随浏览器→回落 zh-CN);API 客户端按当前 locale 带 `Accept-Language`,后端本地化消息同步。 + +**i18n**:新增分区/字段/状态筛选/排序文案,zh-CN + en 双语。**样式**:抽 `--radius`(小)与状态色令牌到 `theme.scss`,状态标签常量统一全称。 + +## 4. 后端设计 + +**迁移 `0007_project_meta.sql`** +``` +ALTER TABLE projects + ADD COLUMN primary_source_lang TEXT, -- 须 ∈ source_langs;null=隐含 source_langs[0] + ADD COLUMN avatar_path TEXT, -- MediaStore 相对键,含扩展名;null=未设 + ADD COLUMN avatar_updated_at TIMESTAMPTZ; -- 作 ETag / 缓存击穿 +``` +(`primary_source_lang` 的「∈ source_langs」在应用层校验:数组成员随 source_langs 变动,跨列 CHECK 维护成本高。) + +**每文件进度**:扩展 `GET /projects/{id}/tree`,文件节点新增 `state_counts: {state→count}`(一次 `SELECT file_id, state, COUNT(*) FROM entries WHERE project_id=$1 GROUP BY file_id, state`,与项目级 state_counts 口径一致——计全部词条)。文件夹聚合在前端做。索引:现有 `entries(file_id)` 足够;必要时加 `entries(project_id, file_id, state)`。20w 量级单次分组扫描可接受,无需实时 COUNT 热路径。 + +**媒体存储**:`MediaStore` trait(`put/get/delete(key)`)+ `LocalDiskStore`(根目录来自 `prts-common` 配置 `PRTS__MEDIA__DIR`,默认 `./data/media`)。放 `prts-api::media` 模块(暂不新起 crate;日后接对象存储再提升)。avatar key = `projects/{id}/avatar.`。 + +**端点(`prts-api`,全部进 utoipa/Swagger)** +- `POST /projects/{id}/avatar`(`project.manage`;multipart 单图;校验 MIME ∈ {png,jpg,webp} 且 ≤512KB;写 MediaStore + 置 `avatar_path/avatar_updated_at`)。 +- `DELETE /projects/{id}/avatar`(`project.manage`;删文件 + 清列)。 +- `GET /projects/{id}/avatar`(**公开读**:流式返回图 + `Cache-Control` + `ETag=avatar_updated_at`;未设→404,前端回落默认块。取公开是因 `` 不便带 JWT,且头像本身低敏;若作者要私有项目头像也鉴权,则前端改带鉴权 blob 拉取——见 §9)。 +- 扩展 `PUT /projects/{id}`:接受 `primary_source_lang`(校验 ∈ 最终 `source_langs`,否则 400)与 `slug`(变更时查重,冲突 409)。 +- axum 启用 `multipart` feature(无重依赖);上传大小限流。 +- **跨域/部署**:`` 跨源加载头像无需 CORS,但 nginx 须正确反代该端点并回传 `Content-Type`/缓存头;若日后前端改 `fetch` blob 则需 `Access-Control-Allow-Origin`。P0 现放开 CORS,P7 收紧时白名单勿漏头像端点。 + +**审计**:`audit_log` 属 P5 尚未落地——本阶段编辑/上传处**预留** audit 调用点(TODO 注释),P5 接入时补。 + +## 5. 权限 + +- 分区可见性:信息/文件/下载/排行榜 = 任何可读该项目者(私有项目限成员);管理 = `project.manage`(拥有者/管理)。 +- 头像写、项目设置写 = `project.manage`;头像读随项目可见性。 +- 沿用现 `crate::auth::project::load` + 权限节点,不新增节点。 + +## 6. 性能 + +- 每文件进度:单次 `GROUP BY file_id,state`,走索引;文件数级别结果(百级),前端聚合文件夹。 +- 文件浏览器搜索/排序/筛选**纯前端**(tree 一次性加载,百级文件可控);不引入新分页。 +- 头像小图(≤512KB,客户端预压缩至 ≤256px),流式返回 + 缓存。 + +## 7. 测试 + +- **单元**:slug 唯一校验;`primary_source_lang ∈ source_langs` 校验(含 source_langs 收缩使原主源失效的分支);`LocalDiskStore` put/get/delete;每文件进度聚合函数;进度百分比与分段计算。 +- **db-test**:`PUT /projects` 带主源语言/改 slug(唯一冲突 409);tree 返回 per-file `state_counts`;头像 POST→GET→DELETE 生命周期与权限(非 manage 403、私有项目非成员读 403/404)。 +- **前端**:CI build/lint;关键组件渲染与响应式退化(交 CI)。 + +## 8. 涉及文件 + +迁移 `0007_project_meta.sql`;`prts-common`(media 配置)、`prts-api`(`media` 模块、`routes/projects.rs` 头像三端点 + PUT 扩展、tree 扩展、`mod.rs`、Swagger)、`prts-db/projects.rs`(新列读写、slug 查重、per-file 进度查询);`deploy/`(media 卷 + `.env.example` `PRTS__MEDIA__DIR`);前端(`router` + 旧路由 302、`ProjectShell` + 6 分区视图、`ProjectDetailView` 退役拆解、`App.vue`(语言切换器)、`i18n/index.ts`(locale 持久化/初始化)、`api`(avatar/tree 类型 + `Accept-Language`)、`theme.scss` 圆角/状态令牌);`docs/architecture.md`(补 §项目工作区)。 + +## 9. 红线 / 未决 + +- 密钥/媒体根仅经 env;上传严格校验类型与大小;sqlx 参数化;私有项目资源鉴权。 +- 不用大 OFFSET、不加实时 `COUNT(*)` 热路径;进度用一次性分组。 +- **已确认(原未决)**:media 根 `./data/media` + Docker 卷;头像**公开读、不鉴权**(注意跨域,见 §4);`/projects/:id` **302→`…/info`** 兼容旧书签。实现中如遇其它细节不明再问作者(蓝图 §8)。 From d0177bc77acdad735f08db2e0a227935fc9fbbf0 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 19:34:40 +0800 Subject: [PATCH 02/45] =?UTF-8?q?docs:=20=E4=B8=8A=E4=BC=A0=E6=94=B9?= =?UTF-8?q?=E9=80=A0=20+=20=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=20B=20=E8=AE=BE=E8=AE=A1=20spec=EF=BC=88?= =?UTF-8?q?=E5=A4=9A=E6=96=87=E4=BB=B6/=E6=95=B4=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B9=E4=B8=8A=E4=BC=A0=20=C2=B7=20=E5=BB=BA=E5=A4=B9/?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8/=E9=87=8D=E5=91=BD=E5=90=8D/=E5=88=A0?= =?UTF-8?q?=E9=99=A4=20=C2=B7=20UNNEST=20=E6=8F=90=E9=80=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作流 B(前置 A):上传去 JSON 文本框、多文件 + webkitdirectory 整文件夹上传、落当前目录重名即重传;管理→文件管理(建夹/移动/重命名/删除,含级联 path 重写 + 防环,门统一 project.file.upload);bulk_upsert 改 UNNEST 提速;文件历史依赖 P5 暂不做。无新迁移。已与作者逐张 mockup 确认。 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...26-07-01-uploads-file-management-design.md | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-uploads-file-management-design.md diff --git a/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md b/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md new file mode 100644 index 0000000..91a591a --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md @@ -0,0 +1,75 @@ +# 上传改造 + 文件管理 · 工作流 B — 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **B**;前置 **A**(管理分区外壳 + 文件列表组件) | +| 基线 | `master` @ `12101b5`(A spec 提交后) | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | A spec(`2026-07-01-project-page-restructure-design.md`)、CLAUDE.md、蓝图 §14 上传下载 / §5.2 性能 | + +> 已与作者确认(含可视化 mockup):**上传文件 = PRTS 词条数组现格式**(`[{key, original:{lang:text}, translation?, state?}]`,无需格式转换,只把「粘贴」换「传文件」);支持**多文件 + 整文件夹上传**(保留相对子路径);文件落**当前所在目录**,路径 = 目录 + 文件名,**重名 = 重传**(沿用现「保留旧译文 + 变动置未翻译 + 记 diff」);文件管理 = **建夹 / 上传 / 移动 / 重命名 / 删除**(移动用目录选择器);**文件历史推迟到 P5**(依赖 `audit_log`,蓝图 §8,现仅到 P4);性能用 **UNNEST** 优化 `bulk_upsert`。 + +--- + +## 1. 范围 + +**做**:把上传从「文本框粘贴 JSON」改为「传文件」(多文件 + 整文件夹);在 **管理 → 文件管理** 子标签实现文件维护(建夹 / 上传 / 移动 / 重命名 / 删除);后端补建夹、移动/重命名端点,并用 UNNEST 优化批量入库。 + +**不做(后续/推迟)**:**文件历史 / 上传批次记录 / 全操作审计 / 按时间清除**(均属 P5 `audit_log`,本阶段仅在 UI 留位、不实现);上传其它文件格式(PO/CSV/properties——蓝图 §20 未决,暂仅 JSON 词条数组);拖拽移动(用目录选择器替代)。删除词条 `context` 属 E,不在此。 + +## 2. 决策要点 + +1. 上传体不变(词条数组现格式);前端**去掉 JSON 文本框**,改文件选择/拖拽。 +2. 多文件:前端逐文件(顺序或有限并发)POST 现有 `/upload`,逐条显示进度。 +3. 整文件夹:`webkitdirectory` 取目录,按 `webkitRelativePath` 保留相对路径,逐文件带相对路径上传(`ensure_file_at_path` 自动建嵌套夹)。 +4. 归属:落当前目录,`path = 当前目录 + 文件名`;重名走现有 upsert(保留旧译文、变动置未翻译)。 +5. 文件管理操作门 = `project.file.upload`(owner/manager);删除端点由 `project.manage` **统一改为** `project.file.upload`(二者同为 owner/manager,行为不变)。 +6. 移动/重命名 = 改 `folder_id`/`parent_id` + `name`,**重算 path**;移动文件夹**级联重写**所有后代 `folders`/`files` 的 path 前缀;防环(不可移入自身子孙);`(project_id, path)` 冲突 → 409。 +7. 文件历史 UI 位(行 ⋯ 菜单)**本阶段不放**,待 P5。 + +## 3. 数据模型 + +**无新迁移**。复用现有 `folders(id, project_id, parent_id?, name, path, UNIQUE(project_id,path))` 与 `files(id, project_id, folder_id?, name, path, entry_count, UNIQUE(project_id,path))`。移动/重命名仅改 `parent_id/folder_id/name/path`,`entries` 引用 `file_id` 不受影响。 + +## 4. 后端(`prts-api` + `prts-db`,全部进 Swagger) + +**上传性能**:重写 `prts_db::entries::bulk_upsert` 用 **UNNEST**——将一批 `key/original/...` 拆成数组参数,一条 `INSERT ... SELECT * FROM unnest(...) ON CONFLICT (file_id,key) DO UPDATE`(分 500–1000 一块,事务内),显著减少往返;保持返回 `created/updated/unchanged` 语义(用 `xmax=0` 或 RETURNING 判定新旧)与「原文变→置未翻译 + 记 `entry_versions`」逻辑。`/upload` 端点签名不变。 + +**新端点** +- `POST /projects/{id}/folders` `{parent_id?: i64|null, name}` → 建夹:`path = parent.path + "/" + name`(或根级 `name`);`(project_id,path)` 查重(409);门 `project.file.upload`。 +- `PATCH /projects/{id}/files/{file_id}` `{folder_id?: i64|null, name?}` → 移动/重命名:重算 `path`;查重(409);门 `project.file.upload`。 +- `PATCH /projects/{id}/folders/{folder_id}` `{parent_id?: i64|null, name?}` → 移动/重命名文件夹:**事务内**改本夹 `parent_id/name/path` + 前缀重写所有后代(`UPDATE folders/files SET path = new || substring(path from len(old)+1) WHERE project_id=$ AND (path=old OR path LIKE old||'/%')`);**防环**(`parent_id` 不得为自身或后代);查重(409);门 `project.file.upload`。 +- 复用 `DELETE .../files/{id}`、`DELETE .../folders/{id}`(级联删词条),门统一为 `project.file.upload`。 + +**`prts-db/files.rs`**:`create_folder`、`move_or_rename_file`、`move_or_rename_folder`(含级联 path 重写 + 防环);复用 `refresh_entry_count`。 + +## 5. 前端 + +**`ProjectManageView` → 文件管理子标签**(owner/manager 可见): +- 复用 A 的**面包屑文件列表组件**,加:勾选多选;工具栏 `新建文件夹` / `上传文件`;选中后 `移动`/`删除`;行尾 ⋯ 菜单 `移动 / 重命名 / 删除`。 +- **上传对话框**:多文件选择 + 拖拽 + `整文件夹上传`(webkitdirectory);提示「上传到:<当前目录>」;预览列表按现有/新增标「新增 / 将更新」;点上传后逐文件进度(顺序或并发上限 3~4)+ 总进度;完成汇总 created/updated/unchanged。 +- **新建文件夹对话框**(名称,落当前目录);**移动对话框**(目录选择器树,选目标夹);**删除**二次确认(普通确认即可,非删项目那种数学门)。 +- **`api`**:新增 `createFolder / moveFile / renameFile / moveFolder / renameFolder`;复用 `upload / deleteFile / deleteFolder`;多文件上传封装一个带进度回调的编排函数。 +- i18n 双语;样式少圆角、状态全称。旧 `ProjectDetailView` 的文本框上传随 A 退役移除。 + +## 6. 性能 + +- UNNEST 批量 upsert(减少 20w 词条上传往返);多文件顺序/有限并发编排。 +- 文件夹移动的后代 path 重写:单事务两条前缀 `UPDATE`(folders/files),走 `(project_id,path)` 索引。 +- verify:20w 词条单文件上传基准(UNNEST 前后对比);整文件夹(多文件)上传总耗时。 + +## 7. 测试 + +- **单元**:path 重算(移动/重命名/建夹);文件夹移动防环判定;path 前缀重写正确性;上传冲突计数(created/updated/unchanged)不因 UNNEST 改写而变。 +- **db-test**:建夹(重名 409);移动文件(改 folder_id/path、重名 409);移动文件夹(级联后代 path、防环拒绝、重名 409);重命名;删除级联;各操作权限门(非 `file.upload` → 403);`bulk_upsert` UNNEST 版与旧版结果一致(含原文变置未翻译 + entry_versions)。 +- **前端**:CI build/lint。 + +## 8. 涉及文件 + +`prts-db/files.rs`(create_folder / move_or_rename_file / move_or_rename_folder+级联+防环)、`prts-db/entries.rs`(bulk_upsert 改 UNNEST);`prts-api/routes/files.rs`(POST folders、PATCH files、PATCH folders、删除门统一)、`mod.rs`、Swagger;前端(`ProjectManageView` 文件管理子标签、上传/新建夹/移动对话框、多文件上传编排、复用文件列表、`api`、`i18n`);`docs/architecture.md`(补文件管理)。 + +## 9. 红线 / 未决 + +- 移动**防环**必检;`(project_id, path)` 唯一冲突返 409;所有维护操作过 `project.file.upload` 门 + 审计留位(P5 补)。 +- 上传严格校验 JSON 结构与非空 key;分块事务,避免长事务与内存峰值。 +- **未决(实现时定)**:整文件夹/多文件上传的**单次文件数与单文件大小上限**阈值;多文件上传的并发上限取值。文件历史依赖 P5,本阶段不实现。 From 1cc1a332db21a8562b25c25e50f421565119636e Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 20:59:59 +0800 Subject: [PATCH 03/45] =?UTF-8?q?docs:=20=E4=BB=BB=E5=8A=A1=EF=BC=88Tasks?= =?UTF-8?q?=EF=BC=89=E5=B7=A5=E4=BD=9C=E6=B5=81=20C=20=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=20spec=EF=BC=88=E5=BF=AB=E7=85=A7=E5=9F=BA=E7=BA=BF=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=20=C2=B7=20Markdown=20=E4=BB=8B=E7=BB=8D=20=C2=B7=20?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E4=BB=BB=E5=8A=A1=E7=95=8C=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作流 C(前置 A):迁移 0008 tasks + task_files(baseline_untranslated 快照、文件多对多);进度=Σmax(0,基线−当前未译)/Σ基线;端点 list/create/get/PUT(统一存 title+description+file_ids)/delete,写操作过 project.manage;前端 列表/详情(渲染 Markdown+文件管理器式)/管理界面(标题+Markdown 编辑预览+勾选文件),新增 markdown-it+dompurify。已与作者逐张 mockup 确认。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-tasks-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-tasks-design.md diff --git a/docs/superpowers/specs/2026-07-01-tasks-design.md b/docs/superpowers/specs/2026-07-01-tasks-design.md new file mode 100644 index 0000000..c9d6bc5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-tasks-design.md @@ -0,0 +1,106 @@ +# 任务(Tasks)· 工作流 C — 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **C**;前置 **A**(任务分区外壳 + 文件列表组件 + 每文件状态计数) | +| 基线 | `master` @ `d0177bc`(B spec 提交后) | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | A spec、B spec、CLAUDE.md | + +> 已与作者确认(含可视化 mockup):任务 = 一组项目文件 + 标题 + **Markdown 介绍** + 进度;进度用**快照基线**——加入文件时快照该文件「未翻译数」为分母,`进度 = Σ max(0, 基线未翻译 − 当前未翻译) / Σ 基线未翻译`;文件**可属多任务**(多对多);**独立「管理任务」界面**统一设 标题/介绍/增删文件(勾选项目文件加入、勾文件夹=其下整体、加入即快照);任务详情的文件用**文件管理器样式**展示(下钻、点文件进编辑器);管理权限**复用 `project.manage`**(owner/manager),其余成员只读;创建流程 = 新建 → 进管理界面 → 保存。 + +--- + +## 1. 范围 + +**做**:任务分区(列表 / 详情 / 管理界面);`tasks` + `task_files` 数据模型与端点;快照基线进度计算;Markdown 介绍(渲染 + 编辑预览);任务文件的文件管理器式展示与勾选式增删。 + +**不做(后续/别处)**:「在此任务翻译」的**搜索/过滤**实现(属 **E**,本阶段仅留入口 + 传 `task_id`);任务与 CP/排行榜关联(无);任务级新权限节点(复用 `project.manage`);任务的历史/审计(P5)。 + +## 2. 决策要点 + +1. 进度 = 快照基线:`task_files.baseline_untranslated` 加入时快照;`完成 = Σ_file max(0, baseline − 当前未翻译)`;`进度 = 完成 / Σ baseline`(`Σbaseline=0` → 视为 100%)。当前未翻译 = 该文件 `state='untranslated'` 实时计数(复用 A 的每文件状态计数口径)。 +2. 文件↔任务**多对多**(`task_files` 联结表,主键 `(task_id, file_id)`)。 +3. 「管理任务」界面统一保存:标题 + Markdown 介绍 + 文件成员集合(勾选)。保存以**期望成员集合**协调:新增的快照基线、取消的移除、既有的**保留原基线**。 +4. 权限:查看=可查看项目者;建/改/删/增移文件 = `project.manage`。 +5. Markdown 介绍:存**源文**(TEXT),前端 `markdown-it` 渲染 + `DOMPurify` 净化。 + +## 3. 数据模型 · 迁移 `0008_tasks.sql` + +``` +tasks( + id BIGINT IDENTITY PK, + project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, + title TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', -- Markdown 源文 + created_by BIGINT REFERENCES users ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +task_files( + task_id BIGINT NOT NULL REFERENCES tasks ON DELETE CASCADE, + file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, + baseline_untranslated INTEGER NOT NULL, -- 加入时快照 + added_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (task_id, file_id) +) +索引:tasks(project_id);task_files(file_id)(供「文件删除级联」与反查)。 +``` +文件删除 → `task_files` 级联移除(该文件退出所有任务)。 + +## 4. 后端(`prts-api` + `prts-db`,进 Swagger) + +**`prts-db/tasks.rs`**:`create`、`get`、`list_with_progress`、`update_meta`、`set_files`(协调成员:新增项 `baseline = 当前未翻译计数`,移除缺席项,保留既有基线)、`delete`、`list_files_with_progress`(任务详情用:每文件 baseline + 当前未翻译 + 完成度)。 + +**进度查询**(列表一条 SQL 汇总): +```sql +SELECT t.id, COALESCE(SUM(tf.baseline_untranslated),0) AS base, + COALESCE(SUM(GREATEST(0, tf.baseline_untranslated - COALESCE(cur.untrans,0))),0) AS done +FROM tasks t +LEFT JOIN task_files tf ON tf.task_id = t.id +LEFT JOIN (SELECT file_id, COUNT(*) FILTER (WHERE state='untranslated') AS untrans + FROM entries WHERE project_id=$1 GROUP BY file_id) cur ON cur.file_id = tf.file_id +WHERE t.project_id=$1 GROUP BY t.id; +``` + +**端点** +- `GET /projects/{id}/tasks` — 列表(title、description、base、done、file_count)。可查看项目者。 +- `POST /projects/{id}/tasks` `{title?, description?}` — 建空任务,返回 id(`project.manage`)。 +- `GET /projects/{id}/tasks/{task_id}` — 详情(meta + 进度 + 文件列表,每文件 baseline/当前未译/完成度)。 +- `PUT /projects/{id}/tasks/{task_id}` `{title, description, file_ids:[i64]}` — 统一保存(改 meta + 协调成员)。`project.manage`。 +- `DELETE /projects/{id}/tasks/{task_id}` — 删除(`task_files` 级联)。`project.manage`。 +- 「在此任务翻译」不新增端点:前端带 `task_id` 进编辑器,过滤逻辑属 E(E 的搜索支持「在当前任务」)。 + +## 5. 前端 + +**路由**:`/projects/:id/tasks`(列表)、`/projects/:id/tasks/:taskId`(详情浏览)、`/projects/:id/tasks/:taskId/manage`(管理,owner/manager)。新建 → `POST` → 跳 `…/manage`。 + +- **`TaskListView`**:任务卡(标题、介绍摘要、单色进度条 = done/base、文件数)+ `新建任务`(manager)。 +- **`TaskDetailView`**(浏览):标题 + **渲染 Markdown 介绍** + 进度条(`done/base`,`base=0`→100%)+ 文件**文件管理器样式**(复用 A 面包屑列表,数据=任务文件子集;每文件显示 基线/当前未译/完成度;点文件进编辑器)+ `管理任务`(manager)/`在此任务翻译` 入口。 +- **`TaskManageView`**(manager):标题输入;**Markdown 介绍**编辑器(源 + 实时预览);**文件勾选**——复用 A 面包屑文件浏览器加勾选框,勾=加入(保存时对新增项快照基线)、勾文件夹=其下文件整体加入、取消=移出;`保存`(PUT)/`删除任务`。 +- **`api` tasksApi**:list/create/get/update(含 file_ids)/remove。 +- **依赖**:`markdown-it` + `dompurify`(轻量、标准)用于介绍渲染;封装 `` / `` 组件(术语 D 若需也可复用)。 +- i18n 双语;样式少圆角、状态全称;进度条单色(完成比例,非 5 状态)。 + +## 6. 性能 + +- 列表进度一条聚合 SQL(按 `entries(project_id)` + `file_id` 分组);任务数量级小,可接受,无需物化。 +- 详情每文件进度同口径;文件浏览器前端过滤(任务文件子集,规模小)。 +- `set_files` 协调在单事务内完成(新增快照 + 删除)。 + +## 7. 测试 + +- **单元**:进度公式(含 `base=0`→100%、`current>baseline` 钳位 0);成员协调(新增快照/移除/既有保留基线)。 +- **db-test**:建任务、PUT 改 meta + file_ids(新增项 baseline = 当时未翻译数、移除生效、既有基线不变)、列表进度 SQL 正确、删除级联、权限门(非 manage → 403)、文件删除→task_files 级联。 +- **前端**:CI build/lint;Markdown 渲染净化(XSS 输入被清理)。 + +## 8. 涉及文件 + +迁移 `0008_tasks.sql`;`prts-db/tasks.rs`(+`lib`);`prts-api/routes/tasks.rs`(5 端点)、`mod.rs`、Swagger;前端(`TaskListView`/`TaskDetailView`/`TaskManageView`、`MarkdownView`/`MarkdownEditor`、复用文件浏览器(勾选变体)、`api` tasksApi、`router`、`i18n`、依赖 `markdown-it`/`dompurify`);`docs/architecture.md`(补任务)。 + +## 9. 红线 / 未决 + +- Markdown 介绍**必须净化**(DOMPurify)防 XSS;渲染只在前端,存源文。 +- 进度查询避免 N+1(列表一条聚合 SQL);不实时全表 COUNT 热路径。 +- 权限:写操作过 `project.manage`;查看随项目可见性。 +- **未决(实现时)**:`base=0`(加入时文件已全译)UI 呈现(100% 还是「—」,mockup 用「—」于单文件、任务总体用 100%);「在此任务翻译」的过滤在 E 落地。 From e6c213ed00573f83f86a48e9edba0fd5b4912b47 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 21:18:33 +0800 Subject: [PATCH 04/45] =?UTF-8?q?docs:=20=E6=9C=AF=E8=AF=AD=EF=BC=88Termin?= =?UTF-8?q?ology=EF=BC=89=E5=B7=A5=E4=BD=9C=E6=B5=81=20D=20=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=20spec=EF=BC=88=E9=A1=B9=E7=9B=AE=E7=BA=A7=E6=9C=AF?= =?UTF-8?q?=E8=AF=AD=20+=20=E5=B9=B3=E5=8F=B0=E5=85=A8=E5=B1=80=E8=AF=8D?= =?UTF-8?q?=E6=80=A7=20+=20CSV/JSON=20=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作流 D(前置 A):迁移 0009 pos_presets(全局+默认种子)+terms(项目级 原文/翻译/备注/pos_id);术语 CRUD+键集分页+搜索+CSV/JSON 导入导出(upsert),词性平台后台管理+导入导出;新增节点 project.term.manage(owner/manager/reviewer);前端术语分区+平台后台词性管理。编辑器术语匹配留 E。已与作者 mockup 确认。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-terminology-design.md | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-terminology-design.md diff --git a/docs/superpowers/specs/2026-07-01-terminology-design.md b/docs/superpowers/specs/2026-07-01-terminology-design.md new file mode 100644 index 0000000..7fcdbc4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-terminology-design.md @@ -0,0 +1,104 @@ +# 术语(Terminology)· 工作流 D — 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **D**;前置 **A**(`primary_source_lang` 主源语言字段);平台后台 POS 管理与 F 同处 | +| 基线 | `master` @ `1cc1a33`(C spec 提交后) | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | A/B/C spec、CLAUDE.md | + +> 已与作者确认(含可视化 mockup):**术语项目级**——原文(**主源语言**)→ 翻译(目标语言)+ 备注 + 词性;**词性 = 平台全局预设**(内置一套完整默认集,平台后台增删 + CSV/JSON 导入导出);**术语与词性都支持 CSV+JSON 导入导出**;术语维护权限 = **owner/manager/校对**(新增节点 `project.term.manage`),其余成员只读;术语列表**键集分页 + 后端搜索**(注意翻页,术语可上千);词性 UI 用文字描述放平台后台;**编辑器术语匹配/高亮属 E**(本阶段仅提供数据)。 + +--- + +## 1. 范围 + +**做**:项目级术语表(CRUD + 键集分页 + 搜索 + CSV/JSON 导入导出);平台全局词性预设(默认集 + 平台后台管理 + CSV/JSON 导入导出);术语分区 UI + 平台后台词性管理 UI。 + +**不做(后续/别处)**:编辑器内**术语匹配高亮 / 建议**(属 E,D 仅提供术语数据与查询);词性**双语名**(暂单名 `name`,见 §10);术语与译文的自动一致性校验/告警。 + +## 2. 决策要点 + +1. 词性预设 = **平台全局**(无 `project_id`),迁移内置默认集;平台管理员(`platform.settings`)增删改 + CSV/JSON 导入导出。 +2. 术语 = 项目级:`source_text`(主源语言)、`translation`(目标语言)、`notes`、`pos_id?`(引用全局预设)。允许**同形异性**(同原文不同词性并存)。 +3. 权限:查看/导出 = 可查看项目者;增/改/删/导入 = 新节点 **`project.term.manage`**(owner/manager/reviewer)。 +4. 列表 **键集分页**(`after` 游标 + `limit`)+ 后端搜索(原文/翻译 `ILIKE`/trgm)。 +5. 导入导出:术语与词性均支持 **CSV 与 JSON**;术语导入按 `(project_id, source_text, pos_id)` **upsert**(命中更新译文/备注,否则插入),返回 created/updated 计数。 + +## 3. 数据模型 · 迁移 `0009_terminology.sql` + +``` +pos_presets( -- 平台全局词性预设 + id BIGINT IDENTITY PK, + name TEXT NOT NULL UNIQUE, + sort_order INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +-- 迁移内置默认集(按 sort_order):名词/动词/形容词/副词/代词/数词/量词/ +-- 介词/连词/助词/叹词/专有名词/短语/习语/拟声词/其他 +terms( + id BIGINT IDENTITY PK, + project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, + source_text TEXT NOT NULL, -- 主源语言原文 + translation TEXT NOT NULL DEFAULT '', -- 目标语言译文 + notes TEXT NOT NULL DEFAULT '', + pos_id BIGINT REFERENCES pos_presets ON DELETE SET NULL, + created_by BIGINT REFERENCES users ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +) +索引:terms(project_id, source_text, id)(键集 + 排序);GIN trgm on terms.source_text(搜索 + E 匹配);可选 GIN trgm on translation。 +``` + +## 4. 后端(`prts-api` + `prts-db`,进 Swagger) + +**术语(项目级)** +- `GET /projects/{id}/terms?q=&after=&limit=` — 键集分页 + 搜索(原文/译文)。可查看项目者。 +- `POST /projects/{id}/terms` `{source_text, translation, notes, pos_id?}` — 建。`project.term.manage`。 +- `PUT /projects/{id}/terms/{term_id}` — 改。`project.term.manage`。 +- `DELETE /projects/{id}/terms/{term_id}` — 删。`project.term.manage`。 +- `POST /projects/{id}/terms/import?format=csv|json` — multipart 上传,upsert(按 `(source_text,pos_id)`),未知词性名→`pos_id=null` + 计入告警;返回 created/updated/warnings。`project.term.manage`。 +- `GET /projects/{id}/terms/export?format=csv|json` — 全量导出。可查看项目者。 + +**词性预设(平台全局)** +- `GET /pos-presets` — 列表(任意登录者;术语编辑下拉用)。 +- `POST/PUT/DELETE /admin/pos-presets[/{pos_id}]` — 平台管理(`platform.settings`)。 +- `POST /admin/pos-presets/import?format=csv|json`、`GET /admin/pos-presets/export?format=csv|json` — 平台管理。 + +**权限**:新增 `project.term.manage` 节点,加入 owner/manager/reviewer 的默认节点集(`prts-core/permission.rs`)。POS 管理走平台 `platform.settings`。 + +## 5. 前端 + +- **`ProjectGlossaryView`(术语分区)**:表格(原文/翻译/词性/备注)+ 顶部搜索(后端)+ **键集分页**(滚动加载或分页器)+ `新建/编辑`对话框(原文/翻译/词性下拉[GET /pos-presets]/备注)+ 删除 + `导入▾`/`导出▾`(CSV·JSON)。写操作/导入按 `project.term.manage` 显隐;导出对可查看者开放;翻译成员只读。 +- **平台后台(`AdminView`)词性管理**:全局词性列表(增删改 + 拖拽排序)+ CSV/JSON 导入导出。仅平台管理员。 +- **`api`**:`termsApi`(list/create/update/delete/import/export)、`posPresetsApi`(list;admin:manage/import/export)。 +- 信息页「术语数」统计(A 暂隐)本阶段可点亮(GET terms 计数)。 +- i18n 双语;样式少圆角、状态全称。 + +## 6. 导入导出格式 + +- **术语 CSV**:表头 `source_text,translation,pos,notes`(`pos`=词性名,导入时映射到 `pos_id`,未知→空 + 告警)。**术语 JSON**:`[{source_text, translation, pos, notes}]`。 +- **词性 CSV**:`name,sort_order`。**词性 JSON**:`[{name, sort_order}]`。 +- 术语导入 upsert(`(project_id, source_text, pos_id)`);词性导入 upsert(`name`)。导出为当前全量。 + +## 7. 性能 + +- 术语列表**键集分页**(`(source_text, id)` 游标),禁用大 OFFSET;搜索走 trgm GIN。 +- 导入分批事务(沿用上传批处理思路);导出流式/分页拉取避免大内存。 +- 术语规模通常百~千级,索引足够;E 的编辑器匹配复用 `source_text` trgm 索引。 + +## 8. 测试 + +- **单元**:CSV/JSON 解析与序列化(术语、词性);导入 upsert 语义(命中更新/新插/未知词性告警);键集游标。 +- **db-test**:术语 CRUD + 键集分页 + 搜索;导入导出往返一致;权限门(非 `term.manage` 增删导入→403、非成员私有项目查看→403);词性全局 CRUD + 平台权限门;`pos_id` 删除置空(ON DELETE SET NULL)。 +- **前端**:CI build/lint。 + +## 9. 涉及文件 + +迁移 `0009_terminology.sql`(建表 + 默认词性种子);`prts-core/permission.rs`(`project.term.manage` 节点 + 角色集);`prts-db/terms.rs`、`prts-db/pos.rs`;`prts-api/routes/terms.rs`、`routes/pos.rs`(或并入 admin)、`mod.rs`、Swagger;前端(`ProjectGlossaryView`、术语对话框、`AdminView` 词性管理、`api` termsApi/posPresetsApi、`router`、`i18n`);`docs/architecture.md`(补术语)。 + +## 10. 红线 / 未决 + +- 导入严格校验列/字段;参数化 SQL;键集分页不深翻;导入分批事务。 +- 权限:写/导入过 `project.term.manage`,词性过 `platform.settings`;私有项目术语按可见性鉴权。 +- **未决(实现时)**:词性**双语名**(当前单 `name`,默认集用中文;若英文界面需本地化再加 `name_en` 或 i18n key);术语导入的“覆盖 vs 追加”是否需要用户可选(默认 upsert);编辑器术语匹配在 E 落地(可能加 `GET /projects/{id}/terms/match?entry_id=` 或前端本地匹配)。 From e7d6d452393a634aa937e8832adb1a319c6a73ed Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 22:10:22 +0800 Subject: [PATCH 05/45] =?UTF-8?q?docs:=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=B5=81=20E=20=E8=AE=BE=E8=AE=A1=20spec?= =?UTF-8?q?=EF=BC=88=E6=99=BA=E8=83=BD=E5=B7=A5=E4=BD=9C=E5=8F=B0=20+=20?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E6=90=9C=E7=B4=A2=E9=87=8D=E6=9E=84?= =?UTF-8?q?=20+=20=E5=88=A0=20context=20+=20MDI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作流 E(最大):工作台底栏智能按钮(翻译/保存/检查/审核/强制/禁用)+状态下拉+自编头像,撤左下 combobox;搜索移列表上方 Enter/Shift+Enter+筛选下拉+高级弹窗,POST /search 结构化(AND 条件[字段/操作符/值]+范围[全项目/path/file/task]+多选状态+向量开关);迁移 0010 删 entries.context;全站图标改 mdi-v7。已与作者逐张 mockup 确认(含无 emoji、MDI 决策)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-07-01-editor-search-design.md | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-editor-search-design.md diff --git a/docs/superpowers/specs/2026-07-01-editor-search-design.md b/docs/superpowers/specs/2026-07-01-editor-search-design.md new file mode 100644 index 0000000..3abc6d6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-editor-search-design.md @@ -0,0 +1,97 @@ +# 编辑器(工作台 + 搜索重构 + 删 context)· 工作流 E — 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **E**(最大);前置 **A**(每文件状态计数)、**C**(任务→文件,供「当前任务」范围)、**D**(术语,供编辑器匹配,见 §1 不做) | +| 基线 | `master` @ `e6c213e`(D spec 提交后) | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | A–D spec、P3 实时编辑器、P4 混合搜索 | + +> 已与作者确认(含逐张 mockup):**工作台**——撤左下状态 combobox,右下「状态下拉 + 智能按钮」并排;按钮随「改动+状态+权限」变:未翻译·改动→`翻译`(靛)、已翻译·改动→`保存`(蓝)、已翻译·未改→`检查`(青)、已检查·未改→`审核`(绿)、他人编辑·管理→`强制保存`(红)、终态/无权→禁用;状态下拉设任意**有权限**状态、无权灰掉;列表里**自己**编辑的词条显示**自己头像**。**搜索**——移到列表上方;`Enter` 全项目 / `Shift+Enter` 当前文件;筛选图标下拉=单选本文件状态 + 高级筛选入口;高级弹窗=**AND** 多条件(字段:原文·各源语言 / 原文·全部源语言 / 译文 / 键名;操作符:包含·不包含·开头是·结尾是·等于·正则)+ 范围(全项目 / 指定文件·目录 / 当前文件 / 当前任务)+ 多选状态 + 向量开关(默认关,开启才出「语义查询」)。**删除词条 `context`**(数据丢失,作者已确认「删了吧」)。**全站图标统一 MDI**。 + +--- + +## 1. 范围 + +**做**:① 工作台底栏智能按钮 + 状态下拉 + 自编头像;② 搜索重构(位置 + 快捷键 + 筛选下拉 + 高级筛选弹窗 + 结构化搜索端点);③ 删除 `entries.context`;④ 全站图标改 MDI。 + +**不做(后续/别处)**:编辑器内**术语匹配高亮/建议**(D 数据已具,匹配 UI 可作为 E 后续小增或单列,本 spec 不含);OR 条件组合(仅 AND);保存乐观锁本身(P3 已有,不改);审计(P5)。 + +## 2. 决策要点 + +1. **按钮语义**(精化现 `lib/saveButton.ts`):`未翻译+dirty→翻译(置已翻译)`;`有译文+dirty→保存(不改状态)`;`未 dirty` 时按状态与权限给推进动作 `检查(→已检查)/审核(→已审核)`;他人编辑且本人管理→`强制保存`;否则禁用。标签/色/图标随 mode。 +2. **状态下拉**紧邻按钮:列全部工作流状态,按角色权限灰掉不可设项(翻译只可 未翻译/已翻译/有疑问;校对/管理可全部);选中=保存并置该状态。 +3. **自编头像**:列表行的「正在编辑」头像**含本人**(现仅显示他人);复用实时 editingMap。 +4. **搜索位置/快捷键**:列表上方;`Enter`=全项目、`Shift+Enter`=当前文件。 +5. **筛选下拉**:单选本文件状态 + 「高级筛选…」。 +6. **高级筛选**:AND 多条件(字段/操作符/值)+ 范围 + 多选状态 + 向量开关(默认关)。范围选「当前文件/当前任务」时禁用文件·目录选择。 +7. **删 context**:迁移 drop 列 + 前后端清理 + 上传忽略该字段。 +8. **MDI**:Quasar 图标集改 `mdi-v7`,替换现有 Material 图标名。 + +## 3. 数据模型 · 迁移 `0010_drop_context.sql` + +``` +ALTER TABLE entries DROP COLUMN context; -- 数据丢失(作者已确认) +``` +`entry_versions` 无 context 列,不受影响。搜索**无新表**;复用 P4 的 `source_text/source_tsv/translation_tsv/embedding` 及 trgm/GIN/HNSW 索引。 + +## 4. 后端 + +**结构化搜索** `POST /projects/{id}/search`(取代/扩展现 GET /search): +``` +body: { + q?: string, // 关键词:FTS+trgm;vector=true 时兼作语义查询 + conditions?: [{ field, op, value }], // AND;field=source: | source_any | translation | key + scope?: { type: all|path|file|task, file_id?, folder_id?, task_id? }, + states?: string[], vector?: bool = false, + sort?: relevance|key|updated, after?, limit? +} +``` +- **条件→WHERE**(参数化):`source:` = `original->>''`;`source_any` = 对项目 `source_langs` 各值 OR(或对 `source_text` 匹配);`translation`、`key` 直列。操作符:`包含`→`ILIKE '%v%'`、`不包含`→`NOT ILIKE`、`开头是`→`ILIKE 'v%'`、`结尾是`→`ILIKE '%v'`、`等于`→`=`、`正则`→`~`。 +- **范围**:`file`→`file_id=`;`path`→`files.path LIKE '/%'`(连表或先查 file_ids);`task`→ 取该任务 `task_files.file_id ∈`;`all`→ 项目内。 +- **状态**:`state = ANY(states)`。 +- **排序/召回**:有 `q` 或 `vector` → 走 `prts-search` 混合编排(`vector=false` 时只 FTS+trgm;`true` 且平台已配 Embedding 时加向量路,RRF),在其上叠加 conditions/scope/states 过滤;无 `q` → 纯过滤,键集分页(`key,id`)。 +- **正则安全**:`~` 交给 PG;设 `statement_timeout` 兜底防病态正则;`value` 参数化。 +- 保留/弃用旧 `GET /search`:前端快捷框也走 `POST`(`{q, scope}`);GET 可保留兼容或移除(实现时定)。 + +**删 context**:移除 `prts-db` 实体、`UploadEntry`、`EntryDto`、`bulk_upsert` 与 upload 对 context 的读写;上传体若含 `context` 字段则**忽略**(不报错)。Swagger 更新。 + +**工作台**:无新端点;复用 `PUT /entries/{id}`(改译文/状态,权限已校验)。 + +## 5. 前端 + +**工作台(`EditorView` + `lib/saveButton.ts`)** +- 重构 `computeSaveButton`:新增 `translate` mode(未翻译+dirty),mode→{label, color, mdiIcon}:翻译=`primary/indigo·mdi-translate`、保存=`blue·mdi-content-save`、检查=`cyan·mdi-check-circle-outline`、审核=`green·mdi-shield-check`、强制=`negative·mdi-flash`、禁用=灰·`mdi-lock`。 +- 右下:`q-btn`(主动作)+ 紧邻 `q-btn-dropdown`/`q-select`(状态,按权限 `disable` 项);**移除**左下状态 combobox。 +- 列表行「正在编辑」头像逻辑加入本人(`editingMap` 含自己 → 显示自己头像)。 + +**搜索(`EditorView` + 新 `SearchBar` / `AdvancedFilterDialog`,替换现 `SearchFilters`)** +- 列表上方 `SearchBar`:输入框 + 筛选图标;`Enter`=全项目、`Shift+Enter`=当前文件(IME 合成中不触发);结果态显示范围 chip + 清除。 +- 筛选下拉:单选本文件状态(全部/未翻译/…)+「高级筛选…」。 +- `AdvancedFilterDialog`:AND 条件行(字段 select[原文·各源语言 / 原文·全部源语言 / 译文 / 键名] + 操作符 select + 值 + 删除 + 添加条件);范围(全项目 / 指定文件·目录[选择器] / 当前文件 / 当前任务,后两者禁用选择器);多选状态 chips;向量开关(默认关,开启显「语义查询」输入);重置/搜索。调 `POST /search`。 +- **MDI**:`quasar.config` 用 `iconSet: 'mdi-v7'` + 装 `@quasar/extras`;把现有 `name="person/mail/shield/logout/…"` 换成 `mdi-*`。 +- 删 context:移除编辑器原文块的「注释」展示;`api`/类型去 context。 +- i18n 双语(按钮标签、操作符、字段、范围、向量);样式少圆角、状态全称、**无 emoji**。 + +## 6. 性能 + +- 条件过滤走参数化 SQL;`contains` 命中 P4 的 `source_text/translation/key` trgm GIN;`source:`(`original->>lang`)与 `等于/开头/结尾/正则` 可能走顺扫——大项目下建议限定范围(文件/目录/任务)后再跑,UI 提示;必要时后续加按源语言的表达式索引(未决)。 +- 有排序(q/vector)→ 复用 P4 融合上限 + offset 窗口;纯过滤→键集分页。 +- 正则设 `statement_timeout` 兜底。 + +## 7. 测试 + +- **单元**:`computeSaveButton` 各分支(翻译/保存/检查/审核/强制/禁用 × 角色/dirty/他人编辑);条件→SQL 片段构造(各字段/操作符,参数化,正则转义);范围解析(file/path/task/all)。 +- **db-test**:结构化搜索(单/多条件 AND、各操作符、source:lang vs source_any、范围 file/path/task、states 多选、vector 开关降级);删 context 迁移后上传/CRUD 正常;`PUT` 改状态权限门。 +- **前端**:CI build/lint;Enter/Shift+Enter(含 IME 合成);工作台按钮态快照。 + +## 8. 涉及文件 + +迁移 `0010_drop_context.sql`;`prts-db`(entries 去 context、search 结构化查询构造)、`prts-search`(编排接入 conditions/scope/states)、`prts-api`(`routes/search.rs` 改 POST 结构化、`entries.rs`/DTO 去 context、Swagger)、`prts-core`(若状态推进逻辑辅助);前端(`EditorView`、`lib/saveButton.ts`、`SearchBar`、`AdvancedFilterDialog`(替换 `SearchFilters`)、`api`(search POST/去 context)、`quasar.config`(mdi-v7)+ 全站图标名、`i18n`);`docs/architecture.md`。 + +## 9. 红线 / 未决 + +- 删 context **不可逆**(已确认);迁移前无需备份(作者定)。 +- 搜索全程参数化;正则/大范围加 `statement_timeout` 与 UI 提示,避免热路径病态查询;键集分页不深翻。 +- 权限:状态设置/推进过 `entry.edit`/`entry.review` 节点;`locked` 词条仅管理/拥有者。 +- **未决(实现时)**:`source:` 等非 trgm 命中的**表达式索引**是否加(先不加,靠范围收窄);旧 `GET /search` 保留兼容或移除;编辑器**术语匹配高亮**是否本阶段附带(默认否,留后续);MDI 切换后逐一核对现有图标名映射。 From 90734e59a8cfbea59e96b69ef1c49205a0425edd Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Wed, 1 Jul 2026 22:25:29 +0800 Subject: [PATCH 06/45] =?UTF-8?q?docs:=20=E5=B9=B3=E5=8F=B0/=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=9D=82=E9=A1=B9=E5=B7=A5=E4=BD=9C=E6=B5=81=20F=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=20spec=EF=BC=88=E7=94=A8=E6=88=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=20+=20=E6=88=90=E5=91=98=E7=AE=A1=E7=90=86=20+=20?= =?UTF-8?q?=E5=88=A0=E9=A1=B9=E7=9B=AE=E6=95=B0=E5=AD=A6=E9=97=A8=20+=20CP?= =?UTF-8?q?=20=E5=AD=98=E5=82=A8=20+=20=E5=AD=97=E4=BD=93=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作流 F(末):平台用户管理(列表/排序/筛选/搜索/键集 + 直接建号 + 改角色按角色秩不越权);项目成员管理(按用户名增删改 + 项目 CP 列 + 秩约束);删项目后端出题校验(delete-challenge + DELETE 带答案,平台开关 高数/简单);迁移 0011 memberships.cp,平台 CP=SUM 派生;字体回退排查修复。CP 模型:项目 CP + 平台 CP(=各项目和),计分属 P6。已与作者 mockup 确认。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-07-01-platform-admin-misc-design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md diff --git a/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md b/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md new file mode 100644 index 0000000..c88f2c0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md @@ -0,0 +1,94 @@ +# 平台 / 管理杂项 · 工作流 F — 设计 Spec + +| 项 | 值 | +| --- | --- | +| 阶段 | 大改造 6 工作流之 **F**(末);前置 **A**(管理分区外壳/成员管理位)、CP 展示依赖 P6 计分 | +| 基线 | `master` @ `e7d6d45`(E spec 提交后) | +| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | +| 前置 | A–E spec、CLAUDE.md、蓝图 §7 权限 / §10 CP | + +> 已与作者确认(含 mockup):**平台用户管理**——列表(用户名/UID/平台角色/**平台 CP**/加入时间/项目数),点表头排序 + 角色筛选 + 用户名搜索 + 键集分页;**直接添加用户 = 用户名 + 初始密码**(无 SMTP);改平台角色**「低于自身可授」**(不可授同级/更高)。**项目成员管理**(管理→成员管理)——按用户名搜索添加 + 改项目角色 + 移除,新增**「项目 CP」列**;越权约束:管理不可设「拥有者/管理」,仅拥有者可;**拥有者转让不做**(本阶段)。**CP 模型**:项目 CP 与平台 CP(平台 = 各项目 CP 之和),平台 CP 显示于用户管理 + 个人主页,项目 CP 显示于成员管理 + 排行榜;**计分本身属 P6**,F 仅加存储列 + 展示。**删项目**——二次确认 + **后端出题校验**(题型平台后台切「高数 / 简单算术」,高数用整数答案模板)。**字体回退修复**。 + +--- + +## 1. 范围 + +**做**:① 平台用户管理(列表/排序/筛选/搜索/键集分页 + 直接添加 + 改角色不越权);② 项目成员管理(按用户名增删改,含项目 CP 列);③ 删项目二次确认 + 后端数学题门槛(含平台题型开关);④ 为 CP 展示加最小存储(`memberships.cp`)并派生平台 CP;⑤ 字体回退修复。 + +**不做(后续/别处)**:**CP 计分逻辑**(Levenshtein 累加,属 P6,F 仅显示 0 值列直到 P6 填充);**拥有者转让**;SMTP/邮件邀请(无 SMTP,P7);审计(P5)。 + +## 2. 决策要点 + +1. 平台角色秩:`super_admin(3) > admin(2) > maintainer(1) > user(0)`;**仅可授秩 < 自身**的角色,且**仅可修改现秩 < 自身**的用户。取代原「`platform.admin.grant` 仅超管」的粗粒度。 +2. 项目角色秩:`owner(3) > manager(2) > reviewer(1) > translator(0)`;`project.member.manage`(owner/manager)者**仅可设秩 < 自身**的角色、**仅可改秩 < 自身**的成员;拥有者不可被改(转让本阶段不做)。 +3. 添加用户:管理员填 用户名 + 初始密码(+ 可选平台角色,默认 user,受秩约束);Argon2id 存储。 +4. CP:加 `memberships.cp`(默认 0,P6 填);**平台 CP = 该用户各 `memberships.cp` 之和**(用户管理列、个人主页);**项目 CP = `memberships.cp`**(成员管理列、排行榜)。 +5. 删项目门槛:`POST …/delete-challenge`(仅 `project.delete`=拥有者)后端出题存 Redis(TTL),`DELETE …` 带 `{challenge_id, answer}` 后端校验通过才删;题型平台设置 `project_delete_challenge_mode = advanced|simple`(默认 advanced)。真正保护靠权限,题目仅防误删。 +6. 用户检索端点复用于「成员管理按用户名添加」与用户管理搜索。 + +## 3. 数据模型 · 迁移 `0011_member_cp.sql` + +``` +ALTER TABLE memberships ADD COLUMN cp BIGINT NOT NULL DEFAULT 0; -- 项目 CP,P6 填充 +-- 平台设置新键(若 settings 为 KV 动态表则无需迁移,插入默认): +-- project_delete_challenge_mode = 'advanced' +索引:memberships(user_id)(供平台 CP 求和 / 用户项目数)。 +``` +`users.cp` 单列在 P6 收敛为派生(`SUM(memberships.cp)`);F 起用户管理/主页的平台 CP 走求和,`users.cp` 不再直接展示(P6 决定是否移除)。 + +## 4. 后端(`prts-api` + `prts-db` + `prts-core`,进 Swagger) + +**权限模型**(`prts-core/permission.rs`):以「角色秩」实现「低于自身可授/可改」;平台与项目各一套秩比较 helper(取代平台仅超管授权的写法)。 + +**用户管理(平台)** +- `GET /admin/users?q=&role=&sort=&after=&limit=` — 列表:用户名/UID/平台角色/**平台 CP(=SUM memberships.cp)**/加入时间/项目数;键集分页 + 排序(username/cp/joined/projects)+ 角色筛选 + 搜索。平台管理员。 +- `POST /admin/users` `{username, password, role?}` — 建号(Argon2id;`role` 受秩约束)。 +- `PUT /admin/users/{uid}/role` `{role}` — 改角色(秩校验:目标角色 < 自身秩 且 被改用户现秩 < 自身秩,否则 403)。取代/收敛现 `POST /admin/users/{id}/role`。 +- `GET /users/search?q=` — 用户名检索(成员添加 + 用户管理共用;返回精简公开资料)。 + +**成员管理(项目)** +- `GET /projects/{id}/members` — 扩展返回 `cp`(项目 CP);支持按 CP 排序。 +- `POST /projects/{id}/members` `{user_id|username, role}` — 按用户名/ID 加(`project.member.manage` + 秩约束)。 +- `PUT /projects/{id}/members/{user_id}` `{role}` — 改角色(秩约束:目标与被改现秩均 < 自身;拥有者不可被改)。 +- `DELETE /projects/{id}/members/{user_id}` — 移除(现有)。 + +**删项目门槛** +- `POST /projects/{id}/delete-challenge` — 仅 `project.delete`(拥有者);按 `project_delete_challenge_mode` 生成题目(advanced:导数在某点/定积分/极限,**整数答案**模板;simple:`a op b`),Redis 存 `challenge:{id} → answer`(TTL ~5min,绑定 user+project),返回 `{challenge_id, question}`。 +- `DELETE /projects/{id}` — 请求体 `{challenge_id, answer}`;校验 challenge 归属 + 答案(整数精确比对)通过 → 删除(级联);错误 → 400(前端可重取题)。 +- 平台设置 `project_delete_challenge_mode` 在 `GET/PUT /admin/settings` 暴露。 + +## 5. 前端 + +- **`AdminView` 用户管理**:表格(列如上,表头排序、角色筛选、搜索、键集分页);`添加用户`对话框(用户名 + 初始密码 + 角色[受秩约束下拉]);行内`改角色`下拉(只列可授项)。 +- **`ProjectManageView` 成员管理子标签**:成员表(+项目 CP 列,可排序);`按用户名添加`(`/users/search` 自动补全)+ 角色下拉(受秩约束);`改角色`/`移除`;拥有者行锁定。 +- **删项目对话框**:红色不可逆警告 + 拉取 `delete-challenge` 显示题目 + 答案输入 → 提交带 `{challenge_id, answer}` 的 `DELETE`;答错重取题。平台后台设置加「删除项目题型」开关。 +- **个人主页**:CP 显示为**平台 CP**(=SUM);(排行榜 A 占位,P6 用项目 CP 实装)。 +- **字体修复**(见 §6)。 +- MDI 图标(E 已定);i18n 双语;样式少圆角、状态全称、无 emoji。 + +## 6. 字体回退修复(排查 + 修) + +现象:许多字仍回退到 SimSun/宋体,尽管已配 `Noto Sans SC / Noto Serif SC`(`@fontsource`)。排查方向:① `@fontsource` 是否真被打包/加载(Network 查 woff2 是否 200;`main.ts` import 是否被 tree-shake);② `--font-display` 链 `'Noto Serif SC','Songti SC','SimSun',serif` —— 思源宋子集缺字时回退 SimSun;③ Quasar 默认 `$typography-font-family` 覆盖 body 字体;④ 权重/unicode-range 子集未覆盖所需字形。修:确保思源黑/宋按需权重与全 CJK 子集加载;`--font-*` 链移除 SimSun 或换更好回退;必要时 body 统一 `Noto Sans SC` 优先;设置 Quasar 字体变量与全局 `font-family` 一致。验收:常见界面文本不再出现宋体。 + +## 7. 性能 + +- 用户列表键集分页(`(cp,id)` 或 `(joined,id)` 视排序);平台 CP 用 `SUM(memberships.cp)`(走 `memberships(user_id)` 索引)——用户量大时可考虑物化,暂直算。 +- 用户检索 `q` 走 `users.username` 前缀/trgm。 +- 删题校验命中 Redis,O(1)。 + +## 8. 测试 + +- **单元**:秩比较授权(平台/项目:可授<自身、不可改≥自身、拥有者不可改);删题模板生成 + 整数答案校验;平台 CP 求和。 +- **db-test**:建用户(重名 409、密码哈希)、改角色越权 403、用户检索;成员按用户名加 + 改角色越权 + 移除 + 项目 CP 返回;`delete-challenge`→`DELETE` 正确答案删除/错误答案 400/无权 403/challenge 过期。 +- **前端**:CI build/lint;字体回退目视/快照核验。 + +## 9. 涉及文件 + +迁移 `0011_member_cp.sql`;`prts-core/permission.rs`(角色秩 + 授权 helper);`prts-db`(users 建号/检索/列表、memberships cp/角色改、平台 CP 求和);`prts-api`(`routes/admin.rs` 用户管理、`routes/users.rs` search、`routes/projects.rs` 成员增改+CP、delete-challenge + DELETE 校验、settings 题型键、Swagger);`prts-search`/Redis(challenge 存取);前端(`AdminView` 用户管理、`ProjectManageView` 成员管理、删项目对话框、`ProfileView` 平台 CP、`api`、`i18n`、`theme.scss`/`main.ts` 字体修复、`quasar-variables.sass`);`docs/architecture.md`。 + +## 10. 红线 / 未决 + +- 授权严格按秩,**不可越权**(可授<自身、不可改≥自身);建号密码 Argon2id、仅服务端。 +- 删项目:权限(仅拥有者)为真边界,数学题仅防误删;challenge 绑定 user+project + TTL,防重放。 +- 键集分页不深翻;检索参数化。 +- **未决(实现时)**:`memberships.cp` 与 P6 最终 CP 存储是否需迁移调整(F 先占位);`users.cp` 何时移除(P6);高数题模板集合与难度;平台 CP 求和是否需缓存/物化(用户量决定);拥有者转让(暂不做,日后单列)。 From 6074f06d8bd1a61f67874b85f0602563885fcb77 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Fri, 10 Jul 2026 12:56:03 +0800 Subject: [PATCH 07/45] chore: ignore isolated worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index d6d7d3d..d6ea7a4 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,6 @@ tmp/ # Visual Companion / brainstorm mockups .superpowers/ + +# Superpowers isolated feature worktrees +.worktrees/ From a09fe1486b6d80101049aefb8fd5e5338e797880 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Fri, 10 Jul 2026 13:22:30 +0800 Subject: [PATCH 08/45] chore(frontend): restore typecheck baseline --- .github/workflows/ci.yml | 3 +- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 48 ++++++++++++++------- frontend/tsconfig.json | 7 ++- frontend/tsconfig.node.json | 15 +++++++ frontend/type-tests/browser-node-globals.ts | 6 +++ frontend/vite.config.ts | 4 +- 7 files changed, 65 insertions(+), 21 deletions(-) create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/type-tests/browser-node-globals.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e570d8d..a6ef564 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: PRTS__REDIS__URL: redis://localhost:6379 frontend: - name: Frontend (lint / build) + name: Frontend (lint / typecheck / test / build) runs-on: ubuntu-latest defaults: run: @@ -65,6 +65,7 @@ jobs: cache-dependency-path: frontend/pnpm-lock.yaml - run: pnpm install --frozen-lockfile - run: pnpm lint + - run: pnpm typecheck - run: pnpm test - run: pnpm build diff --git a/frontend/package.json b/frontend/package.json index 051c7f9..317fb55 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview --port 8080", - "typecheck": "vue-tsc --noEmit", + "typecheck": "vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json", "test": "vitest run", "lint": "eslint .", "format": "prettier --write \"src/**/*.{vue,ts,js,json,css,scss}\"" @@ -29,6 +29,7 @@ "devDependencies": { "@eslint/js": "^9.14.0", "@quasar/vite-plugin": "^1.8.0", + "@types/node": "20", "@vitejs/plugin-vue": "^5.1.0", "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 22999b9..75f3b1e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -44,10 +44,13 @@ importers: version: 9.39.4 '@quasar/vite-plugin': specifier: ^1.8.0 - version: 1.12.0(@vitejs/plugin-vue@5.2.4(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)))(quasar@2.20.1)(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) + version: 1.12.0(@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)))(quasar@2.20.1)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) + '@types/node': + specifier: '20' + version: 20.19.43 '@vitejs/plugin-vue': specifier: ^5.1.0 - version: 5.2.4(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) + version: 5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) eslint: specifier: ^9.14.0 version: 9.39.4 @@ -71,10 +74,10 @@ importers: version: 8.62.0(eslint@9.39.4)(typescript@5.9.3) vite: specifier: ^6.0.0 - version: 6.4.3(sass-embedded@1.100.0)(sass@1.100.0) + version: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) vitest: specifier: ^4.1.9 - version: 4.1.9(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0)) + version: 4.1.9(@types/node@20.19.43)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)) vue-tsc: specifier: ^2.1.0 version: 2.2.12(typescript@5.9.3) @@ -592,6 +595,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1498,6 +1504,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1896,11 +1905,11 @@ snapshots: '@quasar/extras@1.18.0': {} - '@quasar/vite-plugin@1.12.0(@vitejs/plugin-vue@5.2.4(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)))(quasar@2.20.1)(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3))': + '@quasar/vite-plugin@1.12.0(@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)))(quasar@2.20.1)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3))': dependencies: - '@vitejs/plugin-vue': 5.2.4(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) quasar: 2.20.1 - vite: 6.4.3(sass-embedded@1.100.0)(sass@1.100.0) + vite: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) vue: 3.5.39(typescript@5.9.3) '@rollup/rollup-android-arm-eabi@4.62.2': @@ -1991,6 +2000,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2082,9 +2095,9 @@ snapshots: '@typescript-eslint/types': 8.62.0 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3))': dependencies: - vite: 6.4.3(sass-embedded@1.100.0)(sass@1.100.0) + vite: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) vue: 3.5.39(typescript@5.9.3) '@vitest/expect@4.1.9': @@ -2096,13 +2109,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0))': + '@vitest/mocker@4.1.9(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.3(sass-embedded@1.100.0)(sass@1.100.0) + vite: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -2942,6 +2955,8 @@ snapshots: typescript@5.9.3: {} + undici-types@6.21.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2950,7 +2965,7 @@ snapshots: varint@6.0.0: {} - vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0): + vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -2959,14 +2974,15 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 20.19.43 fsevents: 2.3.3 sass: 1.100.0 sass-embedded: 1.100.0 - vitest@4.1.9(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0)): + vitest@4.1.9(@types/node@20.19.43)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@6.4.3(sass-embedded@1.100.0)(sass@1.100.0)) + '@vitest/mocker': 4.1.9(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -2983,8 +2999,10 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 6.4.3(sass-embedded@1.100.0)(sass@1.100.0) + vite: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.43 transitivePeerDependencies: - msw diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index a5dd5e6..6ba8b9c 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -18,6 +18,11 @@ }, "types": ["vite/client"] }, - "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"], + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue", + "type-tests/**/*.ts" + ], "exclude": ["node_modules", "dist"] } diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..4df84b5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ESNext"], + "types": ["node"] + }, + "include": ["vite.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/type-tests/browser-node-globals.ts b/frontend/type-tests/browser-node-globals.ts new file mode 100644 index 0000000..d167b9d --- /dev/null +++ b/frontend/type-tests/browser-node-globals.ts @@ -0,0 +1,6 @@ +// 浏览器类型程序不得继承 Vite Node 配置使用的全局声明。 +// @ts-expect-error 浏览器代码中不应存在 Node.js 的 process 全局变量。 +export type BrowserProcessMustBeUnavailable = typeof process + +// @ts-expect-error 浏览器代码中不应存在 Node.js 的 Buffer 全局变量。 +export type BrowserBufferMustBeUnavailable = typeof Buffer diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 05a194a..101e21e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,11 +1,9 @@ import { fileURLToPath, URL } from 'node:url' +import process from 'node:process' import { defineConfig } from 'vitest/config' import vue from '@vitejs/plugin-vue' import { quasar, transformAssetUrls } from '@quasar/vite-plugin' -// Node 运行 vite.config 时可用;声明以避免引入 @types/node。 -declare const process: { env: Record } - const projectRoot = fileURLToPath(new URL('.', import.meta.url)) // 开发服务器端口:默认 8080,可经环境变量 VITE_DEV_PORT 覆盖(端口被占用时)。 const devPort = Number(process.env.VITE_DEV_PORT) || 8080 From d3c2a5450de7ff220c8f379838be000ddd50db24 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Fri, 10 Jul 2026 17:44:09 +0800 Subject: [PATCH 09/45] docs: reconcile project workspace overhaul design --- docs/architecture.md | 292 ++-- .../plans/2026-06-29-p4-hybrid-search.md | 6 +- .../2026-07-10-project-workspace-overhaul.md | 1215 +++++++++++++++++ .../2026-06-29-p4-hybrid-search-design.md | 6 +- .../specs/2026-07-01-editor-search-design.md | 191 ++- .../2026-07-01-platform-admin-misc-design.md | 159 ++- ...6-07-01-project-page-restructure-design.md | 136 +- .../specs/2026-07-01-tasks-design.md | 152 +-- .../specs/2026-07-01-terminology-design.md | 149 +- ...26-07-01-uploads-file-management-design.md | 140 +- ...07-10-project-workspace-overhaul-design.md | 287 ++++ plan/26-06-28-init_system.md | 112 +- 12 files changed, 2296 insertions(+), 549 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md create mode 100644 docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md diff --git a/docs/architecture.md b/docs/architecture.md index e8f0402..2fd0b9c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,146 +1,252 @@ # PRTS 架构文档 -> 本文是面向开发者的架构详述,与权威蓝图 [`plan/26-06-28-init_system.md`](../plan/26-06-28-init_system.md) 配套。决策依据见蓝图 §0「决策记录」。 +> **状态:目标架构,不代表当前仓库已经实现全部组件。** 当前实施状态以已应用迁移、已发布 feature gate 和 [`2026-07-10 实施计划`](./superpowers/plans/2026-07-10-project-workspace-overhaul.md) 的阶段为准。精确生命周期、状态矩阵、可见性谓词与 API truth table 以 [`2026-07-10 规范总纲`](./superpowers/specs/2026-07-10-project-workspace-overhaul-design.md) 为唯一规范源;本文只描述目标组件及其组合方式。 +> +> 本文与权威蓝图 [`plan/26-06-28-init_system.md`](../plan/26-06-28-init_system.md) 配套。迁移/阶段未完成前,不应以本文的现在时描述推断某项能力已经上线。 -## 1. 分层与 crate 职责 +## 1. 分层、依赖与 composition root -| Crate | 职责 | 不应包含 | +| Crate | 目标职责 | 不应包含 | | --- | --- | --- | -| `prts-api` | axum 路由、中间件(鉴权/权限/限流/CORS)、WS 入口、utoipa 文档聚合、错误→HTTP/i18n 映射 | 领域规则 | -| `prts-core` | 领域逻辑:项目/文件/词条/状态机/CP/权限判定/历史。**纯逻辑,不依赖 axum/sqlx 细节** | HTTP、SQL 方言 | -| `prts-auth` | `AuthProvider` 框架 + `password`/`oauth2`/`zoot` 实现、JWT 签发/校验、API-Key | 业务权限 | -| `prts-search` | 混合检索编排(FTS+trgm+vector、RRF)、`EmbeddingProvider` 抽象与 Qwen 实现 | 路由 | -| `prts-realtime` | WS 连接管理、房间(按文件/项目)、在线状态、Redis pub/sub 广播 | 持久化 | -| `prts-db` | sqlx 连接池、迁移、查询、实体映射 | 领域规则 | -| `prts-common` | 错误类型、分层配置加载、i18n、通用工具 | 任何具体业务 | +| `prts-api` | axum 路由、中间件、WS 入口、worker 编排、utoipa、错误→HTTP/i18n;应用 composition root | 领域规则、手写 SQL | +| `prts-core` | 项目/文件/词条/状态机/权限/能力/任务/术语/历史/删除挑战的框架无关逻辑与 ports | axum、sqlx、HTTP、SQL 方言、`prts-db` | +| `prts-auth` | `AuthProvider`、password/oauth2/zoot、JWT、API-Key 与认证 ports | 项目业务权限 | +| `prts-search` | FTS+trgm+vector/RRF 编排、`EmbeddingProvider` 与搜索 ports | 路由、业务持久化 | +| `prts-realtime` | WS 房间、在线状态、Redis pub/sub adapter | 业务数据库写入 | +| `prts-db` | sqlx 连接池、迁移、查询、行模型,以及 core/search/auth persistence ports 的 adapter | HTTP 与 UI 规则 | +| `prts-common` | 错误、配置、i18n、框架无关通用类型/工具 | 具体业务 | + +依赖/组合规则明确如下: + +- `prts-api` 是 composition root,显式组合 `prts-core`、`prts-auth`、`prts-search`、`prts-realtime`、`prts-db` 与 `prts-common`。 +- `prts-core` 只依赖框架无关类型、traits/ports 与 `prts-common`;它永不依赖 `prts-db`、axum 或 sqlx。 +- 数据库 adapters 位于 core 外部,由 `prts-db` 实现 core/search/auth 声明的 ports;axum handlers 依赖 port 接口并由 `prts-api` 注入具体 adapter。 +- `prts-auth`、`prts-search` 与 `prts-realtime` 不通过 `prts-core` 反向获得 Web/DB 细节;跨 crate 共享数据使用稳定 DTO/domain types。 + +## 2. 请求、审计与持久化任务 + +### 2.1 REST mutation + +```text +请求 + → CORS/限流 + → 可选或必需鉴权(JWT/API-Key → Identity) + → 项目可见性 + 权限节点校验 + → prts-core 领域校验 + → prts-db 参数化事务 + → 业务变更 + action-specific redacted audit 同事务提交 + → 返回 capabilities + {code, message(Accept-Language), details} +``` -**依赖方向**:`api → {core, auth, search, realtime} → {db, common}`;`core` 不反向依赖 `api`。 +公开项目只读端点可使用匿名 Identity;私有项目、mutation 与协作动作要求相应 capability。所有 `prts-db` writer 提供接受 `&mut PgConnection` 的 `_tx`/transaction-scoped 入口;route 只开事务、调用 domain/repository、写 audit 并提交,不写裸 SQL。成功 mutation 的 audit 失败时同事务回滚并返回本地化 `AUDIT_UNAVAILABLE` 503/内部错误。 -## 2. 请求生命周期(REST) +成功认证/令牌签发使用 `0007` 的 DB-authoritative `auth_sessions` + intent/outbox:只存 token hash/opaque handle,state 与 audit 同事务;refresh/rotation/revoke 先查 DB active state,Redis 仅缓存。issuance/rotation DB commit 后才返回 raw token;logout/revoke DB commit 即失效,Redis DEL 失败由 durable outbox 重试且 stale cache 不能绕过 DB。失败认证 audit 写失败时返回通用 503。 -``` -请求 → CORS/限流 → 鉴权(JWT/API-Key→Identity) → 权限节点校验 → 处理器 - → prts-core 领域逻辑 → prts-db(sqlx 事务) → 响应(DTO) - → 错误统一映射为 {code, message(i18n), details} - → 写 audit_log(旁路,不阻塞主流程) +Audit payload 按 action 使用 allowlist,只存 ID、允许的路径、计数、结果/错误码、actor/IP/time 与脱敏 metadata;不存密码/hash、OAuth/refresh/access/API secrets、challenge answer、raw upload body 或完整源文/译文。普通 read 不审计,敏感管理员导出/清除除外。完整规则见总纲 §2.3。 + +### 2.2 持久化任务 + +通用状态为: + +```text +queued → running → succeeded + ├→ failed → retry same job + ├→ paused → queued + └→ cancelled ``` +worker 使用 `FOR UPDATE SKIP LOCKED`、租约续期和崩溃接管,并持久化 stage/progress/error/retry。`jobs.project_id` 可空且 `ON DELETE SET NULL`;需要在项目删除后继续工作的 job 把不可变 project snapshot、media/temp keys 与 deadline 放入 payload。 + +主源切换明确拆为 lexical reindex 与 embedding backfill 两个 job;provider 禁用/未配置只让 embedding 进入 degraded/skipped,不阻塞 lexical search。上传 file retry 复用 logical processing job,但每次 byte-zero 传输/处理使用新的 attempt 行。项目待删除时,除 `project_purge` 外的任务暂停。 + ## 3. 关键数据流 ### 3.1 ZOOT 登录(OAuth2 + PKCE) -``` -前端「用 ZOOT 登录」 - → prts-auth(zoot.begin):生成 state+PKCE,存 Redis,返回 authorize URL - → 浏览器跳 ZOOT 授权 → 回调带 code - → zoot.complete:校验 state → /oauth/token 换 token → /oauth/userinfo - → 映射:profile(username/picture/role)、work(work_scope/content→翻译类别)、external(github_id→external_account) - → upsert user → 签发 PRTS 自有 JWT(access)+refresh(Redis) +```text +前端选择 ZOOT + → prts-auth 生成 state+PKCE,state 存 Redis + → 浏览器授权回调 code + → 校验 state → token → userinfo + → 映射 profile/work/external + → 同 PostgreSQL 事务 upsert user/external_account + auth session active state + redacted audit/outbox + → DB commit 后返回 access/refresh + → Redis populate/invalidate 由 durable outbox 收敛;stale cache 始终受 DB session state 否决 ``` -ZOOT 端点与字段详见 [`external/oauth_integration.md`](./external/oauth_integration.md)。其它 OAuth 源只需以不同配置实例化通用 `OAuth2Provider`。 +ZOOT 字段见 [`external/oauth_integration.md`](./external/oauth_integration.md)。ZOOT 是通用 `OAuth2Provider` 的配置实例与映射器;code/verifier/token 永不进入 audit payload。 -### 3.2 文件上传 +### 3.2 项目工作区与主源语言 -``` -上传 JSON [{key, original:{lang:text}, context, translation?, state?}] - → 校验 + 分批解析 - → 对每个 key:存在则比对 → 覆盖原文、state=untranslated、写 entry_version(diff) - 不存在则插入 - → COPY/批量事务入库 → 增量更新文件/项目状态计数 → 写 audit_log -``` +项目工作区目标分区为信息/文件/任务/术语/排行榜/下载/管理,编辑器是独立全屏路由。项目/任务 Markdown 保存源文、净化展示。 -### 3.3 混合搜索(P4) +语言入口共享 `language-tags` canonicalizer:language 小写、script Titlecase、region 大写,variant/extension/private-use 按 parser 规范序列化。项目 source/primary/target、上传 original keys、term source_lang、search source selector 与用户语言偏好都先规范化;invalid/规范化后重复拒绝。 -**列与索引**(迁移 `0004`,触发器维护):`entries` 增 `source_text`(主源语言文本)、`source_tsv`/`translation_tsv`(按语言选 `regconfig`,中文经 **zhparser**)、`embedding vector(1024)`、`embed_attempts`。索引:tsv GIN、`source_text`/`translation`/`key` 的 trgm GIN、`embedding` 的 HNSW(cosine)。触发器仅在**源文变化**时作废 `embedding`(译文编辑不触发重嵌)。 +`0008_workspace_meta_stats.sql` 与 `0009_primary_source_search.sql` 必须在同一 foundation release 部署。durable repair 先按键集批次规范化 legacy project/entry/term/user language data;冲突或 invalid 数据把 project 标 `needs_language_resolution`,禁用 search 与普通语言写,只有 owner resolution UI/API 可选择 mapping/value,platform admin 只有无正文诊断/retry。repair-ready search trigger/function、既有行 backfill/reconciliation 与 lexical worker readiness 完成之前,不开放非首主源创建或已有项目主源更新。部署边界与状态矩阵以总纲 §2.5、§4.2–§4.3 为准。 -`GET /projects/{id}/search`: +主源变化的目标数据流: +```text +校验 owner/cooldown/languages/无 active 或 unresolved failed jobs + → 同事务写新 primary source + terms archive/activate + → lexical=rebuilding, embedding=pending + → 创建 lexical job + redacted audit + → lexical 按 keyset backfill source_text/source_tsv + → lexical=ready,恢复 FTS/trgm,创建 embedding job + → embedding ready | degraded(skipped) | failed(retry same stage) ``` -q + 过滤(file/状态/排序) + 可见性(hidden 需编辑权限) - → 查询期对 q 取一次 embedding(向量启用且配 key 时) - → 并行三路:FTS(source_tsv/translation_tsv) | trgm(source_text/translation/key) | pgvector kNN - → RRF 融合(k=60, 每路 top-100) → 有界 top-200 → offset/limit 窗口 → 取行返回(含 relevance) -(向量默认关闭 / key 缺失 / 调用失败 → 自动降级为 FTS+trgm) + +相同值无副作用、不消费冷却。配置了 provider 的 embedding 失败做有界指数退避,耗尽后手动重试同一 embedding job,不重新提交主源变更。 + +### 3.3 有效可见性、删除继承与统计 + +普通读、统计、search、export 与 task 统一使用总纲 §3 的 `effective_visible`:entry 未 tombstone、file active、无 deleted ancestor folder,且普通流要求 entry 非 hidden。`include_hidden` 只移除 hidden 条件,永不穿透任何删除层。 + +`0008` 仅预建 nullable deletion columns/effective-visible stats;foundation legacy delete 继续硬删除并同事务维护 stats,所有 deletion_change_set_id 为 NULL,不提供 restore/history。`0010` 断言该列全 NULL、创建 change-set schema/FK 后,才原子切换 soft-delete writer:folder delete 为 active subtree/descendant files 写同一 operation,restore 只清匹配 ID 且不触碰 entry tombstone。 + +### 3.4 头像与可见性 + +项目头像使用 `MediaStore` 的本地 Docker volume adapter。前端 canvas 1:1 裁剪,目标输出 256×256 WebP;服务端校验签名和可解码内容,并要求正方形、宽高各 `<=1024`、总像素 `<=1,048,576`、编码体积 `<=512KB`。公开项目头像公开读;私有项目头像鉴权;项目 DB purge 提交后按 purge job payload 幂等清理媒体。 + +### 3.5 上传、完整替换与文件历史 + +最终 UI 上传原始 JSON 文件/文件夹,不粘贴 JSON,浏览器不解析内容。上传限制来自 `UploadConfigDto`,不内置浏览器并发常量。 + +```text +create logical batch/files + → 为每个 file 创建 byte-zero attempt + → PUT 原始流到 upload temp volume(V1 无 Range/offset resume) + → complete batch + → durable job 逐文件流式解析到事务临时表 + → 重复 key/结构校验 + → 集合 SQL 原子 replacement + → effective stats + allowlisted change set + redacted audit + → 成功立即清理 raw temp;其它 temp 由 durable cleanup 清理 ``` -**向量化**:`EmbeddingProvider`(默认 `QwenProvider`,DashScope OpenAI 兼容端点)。**默认关闭**;管理后台 `GET|PUT /admin/settings/search` 开关并配置 model/base_url/batch 与 TM 参数(存 `settings` 表 `search.config`,运行时热生效);**API Key 仅经 env,绝不下发前端**(后台只显示「已配置:是/否」)。后台 **sweep worker** 分批回填 `embedding IS NULL` 的词条(batch ≤10,失败退避),覆盖 20w 存量,不阻塞主流程。 +retry 在同一 logical batch file 下创建新 attempt,并保留旧 attempt/error;cancel 先进入 `cancelling`,取消 queued/temp items,已开始的单文件事务允许原子完成或回滚,最后进入 `cancelled`。未完成/abandoned batch 默认 24h 过期。精确状态见总纲 §5.1。 + +同路径重传是完整 replacement:缺失 key 形成独立 entry tombstone;旧译文/locked/hidden/history 保留;源文变化重置未翻译;上传 translation/state 只 seed 全新 key。file/folder restore 不清除 entry tombstone。 -**TM 翻译建议** `GET /projects/{id}/entries/{entry_id}/suggestions`:从**当前用户已加入**、`target_lang` 一致的项目里,按源文相似度(向量 cosine,关则 trgm)召回状态≥已翻译、译文非空的词条(排除自身),≤ `tm_top_n`(默认 3) 条,供编辑器译文面板下方展示、点击填入。 +这些 transition 由 `prts-core` typed plans 决定;prts-db 执行计划,API/worker 仅编排。SQL 不重新定义 translation preserve、reset、tombstone、restore/rollback 真值。 -### 3.4 实时编辑 +上传 `original` JSON keys 在解析阶段 canonicalize;invalid、规范化后重复或不属于项目 source set 会拒绝该文件。文件历史只保存 allowlisted change set,不存 raw upload 或 context。`files/folders.deletion_change_set_id -> file_change_sets(id)` 使用 RESTRICT/NO ACTION,`file_change_sets.file_id/folder_id` 可空且 ON DELETE SET NULL。30 天内 restore 后 change set 保留为普通历史;到期 purge 按 entries/files/folders 叶到根→显式 delete items→delete change set 顺序移除 restoration payload,之后 rollback/restore 不可能,只剩无恢复正文的 audit metadata。 +### 3.6 混合搜索(P4 扩展) + +搜索列与索引使用 `source_text`、`source_tsv`、`translation_tsv`、trgm GIN 与 vector HNSW;foundation release 后所有触发器/函数只读取 `primary_source_lang`。 + +`POST /projects/{id}/search` 的 `scope` 是拒绝未知字段、以 `type` 为 discriminator 的 tagged union:`all`、`path {path:string}`、`file {file_id:i64}`、`current_file {file_id:i64}`、`current_task {task_id:i64}`。file/task 沿用数据库 BIGINT,无 UUID migration;`{type:"all",file_id:...}` 必须拒绝。route 验证引用资源属于 URL project 且 caller 可见,并排除 deleted file/folder/task。旧 GET 只把 i64 `file_id` 映射为 `file`,没有时映射为 `all`,绝不猜 current context。完整 DTO/examples/tests 见总纲 §8.2。 + +path resolver 精确区分 file 与 folder;folder 仅包含 segment-boundary descendants,禁止 naive prefix。默认稳定 keyset 为 `(rrf_score DESC, entry_id ASC)`;opaque v1 cursor 绑定 URL project_id、query/filter/scope fingerprint 与最后 score/id,错误、跨项目或跨查询 cursor 400。limit 默认 50、最大 100,响应返回 `next_after`。 + +```text +query + AND conditions + tagged scope + states + include_hidden + vector(false) + → project/resource visibility + lexical state + → FTS | trgm | optional vector + → RRF + → effective-visible/scope/condition 一致过滤 + → keyset/bounded result ``` -进入文件 → WS 加入房间(file_id) - → 广播 presence;编辑中广播「正在编辑」 - → 保存:乐观锁校验 version - 命中→更新+version+1+entry_version+CP+audit,向房间广播变更 - 冲突→返回最新版本,前端提示合并 - → locked=true 的词条仅 manager/owner 可写 -多实例:节点间经 Redis pub/sub 同步房间事件 + +lexical ready 后立即恢复 FTS/trgm;embedding degraded/failed 只关闭 vector 并以 trgm 提供 TM。GET 与 POST 复用同一 service/SQL。 + +### 3.7 实时编辑与游客只读 + +```text +登录成员进入 active file → WS file room → presence/editing + → 保存携带 expected version + → 命中:translation/state/version + entry_version + stats + redacted audit + → 冲突:409 最新版本,前端合并 ``` -### 3.5 通知与在场协调(Spec C) +locked 修改和“强制保存”由 capability 控制;强制保存只越过 presence 提示,仍校验 version。右下只有状态下拉 + 一个智能按钮,真值表以总纲 §8.1 为准。公开游客只用只读 REST,不建立可写 presence 或 mutation/协作动作。 -**通知底座**:`notifications(user_id 收件人, type, payload JSONB, read_at, created_at)`(键集分页;未读 = `read_at IS NULL`)。per-user 实时推送经 `prts-realtime` Hub 的**用户房间**(独立 Redis 频道 `prts:rt:user`,与项目房间并行、互不串扰);前端 App 根部登录后常连 `GET /ws/user`,右上角铃铛显未读数 + 列表,收到即 toast。REST:`GET /notifications`、`/unread_count`、`POST /notifications/read`。 +### 3.8 任务与术语 -**编辑器「戳一下」**(第一种通知 `type=poke`):翻译编辑器点在场头像 → 写一段提示 → `POST /projects/{id}/poke {to_user_id, text}`(收发双方均需项目成员)→ 建通知 + `publish_user` 实时推。用于协调(如提醒占用文件的译者换文件)。poke 文本上限 **140** 字;持久私信见 §3.6(复用此底座)。 +任务加入 active file 时快照 effective-visible+untranslated entry IDs。有效分母动态使用 effective-visible;文件/文件夹删除使 exposure 离开,符合 deletion change-set 的 restore 后返回。`current_task` 搜索使用 task 当前 active files,而不是 snapshot 子集。 -### 3.6 私信(Spec D) +术语保存任意合法 canonical source_lang/source_text/translation/notes/POS/archived,不要求 source_lang 属于项目 source set;active 术语必须精确匹配当前 primary,非主源 active 请求稳定拒绝,legacy old-primary 保持 archived/migration-ready。CSV/JSON 先 preview 后 NULL-safe upsert;preview token 至少 128-bit entropy、15 分钟 TTL,绑定 actor/project/import kind/content digest,confirm 原子一次性消费并重验权限。编辑器建议只改本地 draft。 -**持久私信**:独立 `/messages` 页(会话列表 + 会话线程),复用 §3.5 的 per-user 实时底座。 +### 3.9 通知与私信 -- **数据**:`messages(id, sender_id→users, recipient_id→users, content, read_at, created_at)`(迁移 0006)。会话 = 一对用户间的消息,无独立会话表;键集分页(正反双向复合索引 + 收件人未读部分索引)。 -- **门限**:私信双方须**共享 ≥1 项目**(`memberships` 交集 `EXISTS`)否则 403(防陌生人骚扰);`content` ≤ 2000 字、不可自发;消息仅收发双方可见(端点按 user 鉴权)。 -- **实时**:`prts-realtime` 用户频道加 `UserEvent::DmMessage`;`POST /messages` 落库后 `publish_user` 推给收件人 → 在线即时追加 + 顶栏 ✉️ 红点(独立于铃铛);离线经 `GET /messages` / `/messages/unread_count` 补看(持久 DB)。 -- **REST**:`GET /messages`、`GET /messages/{user_id}`(键集会话)、`POST /messages`、`POST /messages/{user_id}/read`、`GET /messages/unread_count`;另 `GET /users/{id}`(公开资料,**不含 email**)供会话页展示对话方头名。 -- **前端**:单一 `/ws/user` 共享连接(`useUserStream`)按事件 `type` 分发给通知(`useNotifications`)与私信(`useMessages`);`MessagesView`(会话列表)、`MessageThreadView`(会话线程:气泡 + 发送 + 实时追加 + 进入即已读);编辑器在场头像菜单与项目成员列表均可「发私信」。 +通知使用 `notifications(user_id,type,payload,read_at,created_at)` 与 `/ws/user`;列表键集分页。poke 仅项目成员间,文本上限 140。 -## 4. 数据模型(ER 摘要) +私信使用 `messages(sender_id,recipient_id,content,read_at,created_at)`;双方须共享至少一个项目,内容 `<=2000`,仅收发双方可见。通知与私信复用用户 WS,并按 event type 分发。 -``` +### 3.10 24 小时项目删除 + +删除对话框先展示后果/24h/只读并要求显式继续,再要求完整 slug 精确匹配,之后才向服务端请求数学 challenge。challenge 仍由后端 owner-only 校验,绑定 user+project、短 TTL、一次性消费;正确答案返回 202 并安排 purge。`projects.deletion_job_id` 为可空非级联关系。 + +purge 到期后先在数据库事务中锁 job/project、写 audit metadata、detach/cancel 其它 jobs,再对每棵文件树执行 entries/files/folders 叶到根业务行清理,随后显式删除全部 project-scoped file_change_items/change_sets、tasks/terms/memberships/其它关系与 project,并持久化 `external_cleanup_pending` stage;不得依赖 project cascade 穿过 RESTRICT FK。purge job 以 `project_id=NULL` 存活。提交后按 immutable payload 删除 media/temp,成功后标 job succeeded。外部清理失败只重试同一 job 的 external-cleanup stage,绝不恢复 DB project。精确顺序见总纲 §9.3。 + +## 4. 数据模型摘要 + +```text user 1─* external_account user 1─* api_key -user *─* project (membership: role=owner|manager|reviewer|translator) -user 1─* platform_role (super_admin|admin|maintainer) +user *─* project (membership: owner|manager|reviewer|translator) +project.owner_id = 唯一拥有者 + +project 1─* folder 1─* file 1─* entry 1─* entry_version +folder/file -- deleted_at + deletion_change_set_id +project 1─1 project_stats +file 1─1 file_stats + +project 1─* task *─* file +task_file 1─* task_baseline_entry + +project 1─* term *─0..1 pos_preset -project 1─* folder (parent_id 自引用树) -project 1─* file -folder 1─* file -file 1─* entry -entry 1─* entry_version +upload_batch 1─* upload_batch_file 1─* upload_file_attempt +upload_batch_file → reusable processing job +file_change_set 1─* file_change_item -audit_log (actor_id, action, target, payload, created_at) -- 追加式 -setting (分类的平台运行时配置:SMTP/注册/OAuth 模式/Embedding…) +job -- nullable project_id, durable state/stage/progress/retry/lease/payload snapshot +audit_log -- append-only redacted allowlisted event +setting -- runtime non-secret configuration ``` -`entry`:`key`、`original`(JSONB `{bcp47:text}`)、`context`、`translation`、`state`(枚举)、`locked`、`hidden`、`version`。 +`entry` 不含 context;entry history payload 只允许总纲 §5.3 列出的字段。`projects.owner_id` 是 owner 真源;CP 使用 `users.cp_tenths/memberships.cp_tenths BIGINT`(一单位 0.1 CP)为未来计分准备,本轮不评分且不引入 decimal 依赖。 -## 5. 性能要点(20w+ 词条) +## 5. 权限与能力 -- 列表:键集分页 `WHERE (file_id,id) > (:f,:cursor) ORDER BY id LIMIT n`。 -- 索引:`entry(file_id,state)`、`entry(key)`、`tsvector` GIN、`pg_trgm` GIN、`pgvector` HNSW/IVFFlat。 -- 计数:状态计数物化于 `file.stats` / `project`,增量维护,避免实时 `COUNT(*)`。 -- 写放大:`audit_log` 按月分区或队列批写。 +- owner 授 manager/reviewer/translator;manager 授 reviewer/translator;任何 API 不授 owner,本轮不转让 owner。 +- 主源变化与项目删除只认 owner_id,平台管理员不能替代。 +- 任务 owner/manager 写;术语 owner/manager/reviewer 写;历史成员读、owner/manager 回滚;hidden overlay owner/manager。 +- 平台用户管理严格按秩;API 返回 capabilities,前端不自行推导。 -## 6. 配置与密钥 +## 6. 性能要点(20 万+ 词条) -- 启动期配置(连接串、JWT 密钥、OAuth secret、Qwen key)→ **环境变量 / `.env`(不入库、不进前端)**。 -- 运行时可调配置(SMTP、是否邮箱验证、注册开关、OAuth 模式、Embedding 开关)→ `setting` 表,管理后台维护。 +- 列表、terms、tasks、admin users 与清理扫描使用 keyset/cursor,禁止大 OFFSET。 +- project/file 五状态与 visible total 物化;正常详情不实时 COUNT/GROUP BY entries。 +- 上传流式解析、每文件原子、batch 部分成功/cancel;不在浏览器或后端整文件内存解析。 +- lexical backfill 按 entry id 批次断点;搜索使用 GIN/HNSW 与有界 RRF。 +- audit 可按时间分区但保持追加式;job worker 使用租约与 `SKIP LOCKED`。 -## 7. 部署拓扑 +## 7. 配置、安全与 UI +- DB/Redis/JWT/OAuth/Qwen 等密钥只经环境变量,不入库、不下发前端。 +- 运行时非密钥设置包括搜索、上传四项限制、batch 默认 24h 过期、文件默认 30 天保留、删除题型等。 +- sqlx 参数化、HTTPS、最小权限、输入/签名/大小校验、稳定错误码和 fail-closed audit。 +- 交互 UI 仅 Vue 3 + Quasar,浅/深主题、MDI、方角/2–4px;中文使用 Noto Sans SC 同类 sans;前端 zh-CN/en,后端按 Accept-Language 本地化。 + +## 8. 部署 + +```text +nginx ── frontend(Quasar 静态) + └─ /api,/ws → backend(axum + workers) +backend ─ PostgreSQL 16(pg_trgm+pgvector+zhparser) + ├ Redis(session/refresh/limit/ws/challenge) + └ media/upload-temp Docker volumes ``` -nginx ──┬── frontend(静态, Quasar 构建) - └── /api, /ws → backend(axum) -backend ── PostgreSQL(pg_trgm+pgvector) - └── Redis(session/refresh/限流/WS pubsub) -镜像:GHCR;编排:deploy/docker-compose.yml -``` -## 8. 测试策略 +foundation release 必须把 `0008+0009`、language repair、primary search trigger/backfill 和 worker readiness 作为一个部署门。镜像发布到 GHCR,docker-compose 编排;media 与 upload temp 使用独立持久卷。 + +## 9. 测试与交付 -- `prts-core` 纯逻辑单测(状态机、权限判定、CP 计分、RRF)。 -- API 集成测试(鉴权、权限、上传覆盖、搜索)。 -- 性能验证:20w 词条的上传与分页/搜索基准。 -- verify 脚本随阶段交付。 +- `prts-core`:状态机、权限/能力、主源 gate/state、任务 snapshot、术语、数学题纯逻辑。 +- API/db:audit fail-closed/redaction、job 恢复/FK、effective visibility、upload cancel/expiry/attempt、history retention、tagged search scope、purge 顺序。 +- 前端:智能按钮、IME、capabilities、Markdown 净化、游客只读、上传取消/重试、字体/主题/i18n。 +- 性能:20 万词条 stats reconciliation、lexical backfill、100MB 单文件、500-file batch、五种 scope 和 task progress。 +- 每阶段测试 → verify → Conventional Commit → 推 master → 等待 CI → 推 GHCR;替代能力稳定前保留兼容入口。 diff --git a/docs/superpowers/plans/2026-06-29-p4-hybrid-search.md b/docs/superpowers/plans/2026-06-29-p4-hybrid-search.md index e906b1c..5793bcd 100644 --- a/docs/superpowers/plans/2026-06-29-p4-hybrid-search.md +++ b/docs/superpowers/plans/2026-06-29-p4-hybrid-search.md @@ -8,7 +8,9 @@ **Tech Stack:** Rust (axum, sqlx, tokio, reqwest), PostgreSQL 16 (pgvector + zhparser), Vue 3 + Quasar + Pinia, Docker/GHCR. -**Authoritative spec:** [`docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md`](../specs/2026-06-29-p4-hybrid-search-design.md). Read it before starting. +**Historical status:** This plan records the already-applied P4/`0004_search.sql` implementation. Do not execute its `source_langs[1]` trigger/backfill snippets as current design; [`2026-07-10-project-workspace-overhaul-design.md`](../specs/2026-07-10-project-workspace-overhaul-design.md) and `0009_primary_source_search.sql` supersede them with `primary_source_lang` and the foundation release gate. + +**Historical spec:** [`docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md`](../specs/2026-06-29-p4-hybrid-search-design.md). --- @@ -1583,7 +1585,7 @@ git push - §12 tests → unit (Tasks 1, 6, 12, 13); db (Tasks 5, 8, 10, 15, 16, 19, 20); perf (Task 23). - §13 files → covered across tasks. -**Placeholder scan** — no TBD/"handle edge cases"/"similar to". Where existing symbol names are uncertain (admin guard extractor, `load_access`, `primary_langs`, `parse_states`), tasks explicitly say to find the real name in the named file and reuse/extract it — these are lookups, not placeholders. +**Placeholder scan** — no unresolved markers or generic “handle edge cases”/“similar to” steps. Where historical symbol names were uncertain (admin guard extractor, `load_access`, `primary_langs`, `parse_states`), tasks explicitly required locating and reusing the real symbol in the named file. **Type consistency** — `SearchConfig` (Task 13) is reused verbatim in Tasks 14/15/17; `QwenProvider::embed_batch(base_url, model, texts)` signature consistent across Tasks 12/16/17; `SuggestionRow`→`SuggestionDto` field names align (Tasks 19/20); `vector_ids: Option>` slot defined in Task 9, filled in Task 16. `Entry.source_text` field dependency flagged in Task 20. diff --git a/docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md b/docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md new file mode 100644 index 0000000..438824b --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md @@ -0,0 +1,1215 @@ +# PRTS 项目工作区大改造 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在不回退现有功能的前提下,完成项目工作区、持久化上传与历史、任务、术语、编辑器搜索、平台管理和延迟删除的统一改造。 + +**Architecture:** 先建立追加式 fail-closed 审计、持久化任务、effective-visible 物化统计、项目元信息和能力下发,并在同一个 foundation release 部署 `0008+0009`、primary search trigger/backfill 与 dormant worker;之后才按 A–F 暴露新路由/UI。所有大文件处理与长耗时操作进入可恢复 worker;读路径只消费物化状态或键集查询;旧上传和 GET 搜索通过适配层保留一个兼容周期。 + +**Tech Stack:** Rust、tokio、axum、sqlx、PostgreSQL 16(pg_trgm/pgvector/zhparser)、Redis、`language-tags`、Vue 3、Quasar 2、Vite、pnpm、Pinia、vue-i18n、utoipa、Docker、GHCR。 + +--- + +## 0. 执行约束与迁移顺序 + +实现以 [`../specs/2026-07-10-project-workspace-overhaul-design.md`](../specs/2026-07-10-project-workspace-overhaul-design.md) 为唯一总纲。当前数据库最大迁移号为 `0006`,本计划固定使用: + +| 迁移 | 责任 | +| --- | --- | +| `0007_audit_jobs.sql` | 追加式审计、持久化任务、DB-authoritative auth sessions/intents/outbox、nullable `jobs.project_id ON DELETE SET NULL`、任务租约与重试 | +| `0008_workspace_meta_stats.sql` | canonical BCP-47 配置/repair 状态与 issue 元数据、主源分阶段状态、头像元数据、唯一 owner 修复、nullable entry/file/folder 删除基础、effective-visible 项目/文件统计回填;不启用 soft delete writer | +| `0009_primary_source_search.sql` | 仅在 language repair ready 边界后修正搜索函数/触发器读取 canonical `primary_source_lang`,并提供受 gate 的 search rows backfill/reconciliation | +| `0010_upload_file_history.sql` | 上传批次/file attempts、文件变更集、删除保留期与 cleanup 索引;补 `deletion_change_set_id -> file_change_sets(id) ON DELETE RESTRICT` 与 nullable target `ON DELETE SET NULL` | +| `0011_tasks.sql` | 任务、多对多文件、基线词条 ID、任务统计 | +| `0012_terminology.sql` | 双语 POS、带 source_lang 的术语、归档、导入预览 | +| `0013_editor_search.sql` | 删除 context、scrub 历史 JSONB context key、结构化 POST search metadata/indexes/functions | +| `0014_admin_delete_cp.sql` | 初始密码提醒、`cp_tenths BIGINT` 精确 CP 基础、项目待删除状态与 nullable 非级联 `deletion_job_id` | + +迁移一经其阶段合并、推送或在任一环境应用即不可修改;任何后续纠正只新增更大编号迁移。每个迁移在首次创建任务中完整声明该阶段后续任务所需的表、字段、约束与索引,后续任务不得 retroactively 修改它。 + +`0008` 与 `0009` 是不可拆分的 foundation release:同一发布物还必须包含共享 BCP-47 canonicalizer、durable legacy repair worker、读取 canonical `primary_source_lang` 的 trigger/function、repair-ready 项目的 search rows backfill/reconciliation 和 lexical backfill worker。该 release readiness 通过前,API/UI feature gate 不接受非首个主源或已有项目主源更新;任何可独立推送阶段都不得让新字段已暴露而搜索仍读取 `source_langs[1]`,也不得在 legacy JSON keys 未规范化时运行 exact-key backfill。文件/文件夹 soft-delete 行为明确延后到 `0010`:foundation 期间所有 `deletion_change_set_id` 必须为 NULL,legacy delete writer 保持当前硬删除语义并事务维护 stats,不暴露 restore/history。 + +每个端点都必须进入 utoipa/Swagger,返回稳定错误码和 zh-CN/en 消息。新表和新字段的 Rust 模型只放在 `prts-db`;业务规则放在 `prts-core`;axum handler 只做协议、鉴权与事务编排。 + +### 每阶段固定闭环 + +每个阶段完成后执行并保存输出: + +```powershell +Set-Location -LiteralPath (git rev-parse --show-toplevel) +function Invoke-Checked([scriptblock]$Command) { + & $Command + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +} + +Invoke-Checked { docker compose -f deploy/docker-compose.yml up -d postgres redis } + +Set-Location .\backend +Invoke-Checked { cargo fmt --check } +Invoke-Checked { cargo clippy --all-targets } +Invoke-Checked { cargo test } +Invoke-Checked { cargo test -p prts-api --features db-tests } + +Set-Location ..\frontend +Invoke-Checked { pnpm install --frozen-lockfile } +Invoke-Checked { pnpm lint } +Invoke-Checked { pnpm test } +Invoke-Checked { pnpm typecheck } +Invoke-Checked { pnpm build } + +Set-Location .. +Invoke-Checked { docker compose -f deploy/docker-compose.yml up -d --build } +``` + +预期:命令退出码均为 0;数据库集成测试无 ignored 之外失败;前端 lint/test/typecheck/build 全部通过;`/health` 与 `/swagger-ui` 可访问。随后运行该阶段 verify、创建 Conventional Commit、合入并推送 `master`、等待 GitHub Actions 成功,确认 backend/frontend/postgres GHCR 镜像完成后再进入下一阶段。 + +## 阶段 0:基线与文档 + +### Task 0.1:冻结当前行为基线 + +**Files:** + +- Modify: `backend/crates/prts-api/tests/db_integration.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `frontend/src/lib/saveButton.spec.ts` +- Create: `frontend/src/api/compatibility.spec.ts` + +- [ ] **Step 1: 记录当前公开项目、旧上传、GET 搜索、编辑保存和导出的成功路径** + + 为每条既有能力补回归测试,断言旧接口在替代接口上线前仍可工作;测试不得把当前错误行为(`source_langs[1]`、实时统计、context)固化为目标规则。 + +- [ ] **Step 2: 运行基线测试并保存测试数量** + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location ..\frontend + pnpm test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: 当前分支测试全部通过;记录后端与前端通过数量,作为每阶段“不回退”比较基线。 + +- [ ] **Step 3: 提交基线测试** + + ```powershell + git add backend/crates/prts-api/tests frontend/src + git commit -m "test: capture project workspace baseline" + ``` + +### Task 0.2:确认规范总纲与兼容策略 + +**Files:** + +- Modify: `docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md` +- Modify: `docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md` +- Modify: `docs/architecture.md` +- Modify: `plan/26-06-28-init_system.md` + +- [ ] **Step 1: 核对 A–F 规格均链接总纲且无冲突条款** + + Run: `rg -n -g '2026-07-01-*' "source_langs\[1\]|scope: all|永久保存行为|entry history 本来不保存 context|公开读、不鉴权|实时 COUNT|NUMERIC\(20,1\)|BigDecimal|rust_decimal" docs/superpowers/specs docs/architecture.md plan/26-06-28-init_system.md` + + Expected: 不出现被总纲否决的设计主张;命中只能是明确说明已删除或历史背景的文字。 + +- [ ] **Step 2: 提交文档基线** + + ```powershell + git add docs plan + git commit -m "docs: reconcile project workspace overhaul design" + ``` + +## 阶段 1:Foundation release(审计、任务、统计、元信息与 primary search) + +### Task 1.1:建立追加式审计与持久化任务 + +**Files:** + +- Create: `backend/migrations/0007_audit_jobs.sql` +- Create: `backend/crates/prts-db/src/audit.rs` +- Create: `backend/crates/prts-db/src/jobs.rs` +- Create: `backend/crates/prts-db/src/auth_sessions.rs` +- Create: `backend/crates/prts-core/src/jobs.rs` +- Create: `backend/crates/prts-api/src/job_worker.rs` +- Create: `backend/crates/prts-api/src/jobs/mod.rs` +- Create: `backend/crates/prts-api/src/routes/jobs.rs` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-core/src/lib.rs` +- Modify: `backend/crates/prts-api/src/main.rs` +- Modify: `backend/crates/prts-api/src/state.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写迁移测试并验证缺表失败** + + 测试覆盖审计只增不改、worker 抢占、租约过期接管、同一 job 重试、项目暂停过滤、进度更新、`jobs.project_id` 删除后置 NULL、purge job 依靠 payload snapshot 继续运行;另覆盖 auth session pending/active/rotating/revoked/expired、intent/outbox lease/retry,以及 payload/rows 只保存 token hash/opaque handle、不保存 raw token。 + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test -p prts-api --features db-tests audit_jobs -- --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: 在迁移加入前失败,错误指向 `audit_log` 或 `jobs` 不存在。 + +- [ ] **Step 2: 创建审计表与防篡改约束** + + `audit_log` 至少包含:`id`、`actor_id`、`actor_kind`、`action`、`target_type`、文本型 `target_id`、可空 `project_id_snapshot`、`payload JSONB`、`ip INET`、`created_at`。项目和用户删除后保留快照字段;应用账号只获得 INSERT/SELECT,数据库触发器拒绝 UPDATE/DELETE。payload 由 action-specific DTO/allowlist 生成,禁止通用实体序列化。 + +- [ ] **Step 3: 创建任务表与租约模型** + + `jobs` 使用稳定 id,字段包含 `kind`、nullable `project_id REFERENCES projects(id) ON DELETE SET NULL`、`state`、`stage`、`payload`、`result`、`progress_current`、`progress_total`、`attempts`、`max_attempts`、`run_after`、`lease_until`、`worker_id`、`last_error_code`、`last_error_message`、时间戳。状态固定为 `queued|running|paused|succeeded|failed|cancelled`;重试重置同一行到 `queued` 并递增 attempts。purge payload 保存 immutable project id/slug/media/temp keys/deadline。 + + 同一 `0007` 创建 DB-authoritative `auth_sessions` 与 `auth_session_intents`/outbox:只保存 refresh hash、opaque session/family handle、user、state、expiry、predecessor/successor、intent kind/state/lease/retry,不保存 raw access/refresh。refresh 验证、rotation、logout/revoke 必须先锁/读取 DB authoritative state;Redis 只能缓存 active lookup 或保存不可认证 pending material。 + +- [ ] **Step 4: 实现 `FOR UPDATE SKIP LOCKED` worker** + + worker 领取到期任务、周期续租、进程崩溃后由其它实例接管。`project_id` 对应项目待删除时,仅 `project_purge` 可运行;其它任务变为 `paused`。 + +- [ ] **Step 5: 暴露只读任务进度与受控重试 API** + + 实现 `GET /jobs/{id}` 和按所属资源过滤的进度查询;手动重试只允许任务拥有的业务权限主体调用,并复用原 job id。 + +- [ ] **Step 6: 运行测试并提交** + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test -p prts-api --features db-tests audit_jobs -- --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: 审计防改、并发领取、租约恢复和同 job 重试全部通过。 + + Commit: `feat: add durable audit and job foundation` + +### Task 1.2:把现有认证与变更写入审计 + +**Files:** + +- Modify: `backend/crates/prts-api/src/routes/auth.rs` +- Modify: `backend/crates/prts-api/src/routes/users.rs` +- Modify: `backend/crates/prts-api/src/routes/admin.rs` +- Modify: `backend/crates/prts-api/src/routes/admin_settings.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-api/src/routes/files.rs` +- Modify: `backend/crates/prts-api/src/routes/entries.rs` +- Modify: `backend/crates/prts-api/src/routes/notifications.rs` +- Modify: `backend/crates/prts-api/src/routes/messages.rs` +- Modify: `backend/crates/prts-api/src/main.rs` +- Modify: `backend/crates/prts-api/src/auth/session.rs` +- Modify: `backend/crates/prts-api/src/auth/extract.rs` +- Modify: `backend/crates/prts-db/src/audit.rs` +- Modify: `backend/crates/prts-db/src/auth_sessions.rs` +- Modify: `backend/crates/prts-db/src/jobs.rs` +- Modify: `backend/crates/prts-db/src/users.rs` +- Modify: `backend/crates/prts-db/src/projects.rs` +- Modify: `backend/crates/prts-db/src/files.rs` +- Modify: `backend/crates/prts-db/src/entries.rs` +- Modify: `backend/crates/prts-db/src/memberships.rs` +- Modify: `backend/crates/prts-db/src/settings.rs` +- Modify: `backend/crates/prts-db/src/search_settings.rs` +- Modify: `backend/crates/prts-db/src/api_keys.rs` +- Modify: `backend/crates/prts-db/src/notifications.rs` +- Modify: `backend/crates/prts-db/src/messages.rs` +- Modify: `backend/crates/prts-auth/src/token.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 为 mutation/auth 写 fail-closed 与脱敏测试** + + 为当前全部 writer 建立清单并逐项覆盖:`users`、`projects`、`files`、`entries`、`memberships`、`settings/search_settings`、`api_keys`、`notifications`、`messages`,以及启动期 bootstrap role mutation、password/OAuth 用户落库、API-key touch/revoke、session refresh rotation/revoke 等认证持久化。消息发送、mark-read、poke、设置、角色、旧上传、编辑、flags、删除等成功 mutation 均断言业务与 audit 同事务;注入 audit insert failure 时业务不提交并返回本地化 `AUDIT_UNAVAILABLE` 503/内部错误。成功登录/OAuth/refresh/token issuance 在 audit 失败时不得激活或返回 token。失败认证同步写脱敏事件;其 audit 失败时返回通用 503,而不是原认证结论。 + + 对每个 action 断言 allowlist payload 不含 password/hash、OAuth code/verifier/token、refresh/access/API key、challenge answer、raw file body、完整 original/translation/source text 或任意 secret/content。普通 reads 不产生日志,敏感 admin export/purge 明确产生日志。 + +- [ ] **Step 2: 为全部 repository writer 建立 transaction-aware 接口** + + `prts-db` 的上述模块把每个被审计 writer 改为接受 `&mut PgConnection`/transaction-scoped executor 的签名,或提供明确的 `*_tx` 配对函数;只读函数可继续接受 `PgPool`。handler 开 `PgTransaction`,用 `&mut *tx` 调用 repository/domain writer,再写 allowlisted audit 并提交。route 只做协议、鉴权与事务编排,不得用裸 `sqlx::query*` 写业务表来绕过 repository/audit 契约。 + +- [ ] **Step 3: 实现 DB-authoritative session 状态机与 crash recovery** + + access/refresh token 在返回前必须满足 DB session state 与 audit 已同事务提交。issuance 在事务中写 active session hash + issued audit + Redis-sync outbox,提交后才返回 raw token;Redis 写失败不影响 DB authority,由 outbox 重放。rotation 在一个事务中锁 active predecessor、插入 active successor hash、把 predecessor 置 revoked、写 rotation audit/outbox,提交后才返回 successor;事务失败不返回 token。logout/revoke/改密全量撤销在事务中先把 DB state 置 revoked 并写 audit,提交即立即失效;Redis DEL 失败绝不恢复有效性,只由 durable cleanup 重试。 + + 每次 refresh/authenticate 都先验证 hash 对应 DB session=`active` 且未过期,再把 Redis 当 cache hint;DB 为 pending/rotating/revoked/expired 时,即使 Redis 残留也拒绝。intent/outbox worker 用租约恢复 crash:清理未完成 pending/rotating 状态、重放 cache invalidate/populate;payload 不含 raw token。测试在 issuance/rotation/revoke 的每个 commit/Redis 边界杀 worker,证明 stale Redis token 不可认证、rotation 不双活、logout DB commit 后立即失效且 cleanup 可恢复。 + +- [ ] **Step 4: 运行全量后端测试并提交** + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test --all-targets + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: 既有 API 行为不变,新增审计断言通过。 + + Commit: `feat: audit authentication and mutations` + +### Task 1.3:建立不可拆分的 workspace/primary-search foundation + +**Files:** + +- Create: `backend/migrations/0008_workspace_meta_stats.sql` +- Create: `backend/migrations/0009_primary_source_search.sql` +- Create: `backend/crates/prts-db/src/stats.rs` +- Create: `backend/crates/prts-core/src/language.rs` +- Create: `backend/crates/prts-core/src/capabilities.rs` +- Create: `backend/crates/prts-api/src/jobs/repair_languages.rs` +- Create: `backend/crates/prts-api/src/jobs/reindex_project.rs` +- Create: `backend/crates/prts-api/src/dto/capabilities.rs` +- Modify: `backend/Cargo.toml` +- Modify: `backend/crates/prts-core/Cargo.toml` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-db/src/projects.rs` +- Modify: `backend/crates/prts-db/src/files.rs` +- Modify: `backend/crates/prts-db/src/entries.rs` +- Modify: `backend/crates/prts-db/src/users.rs` +- Modify: `backend/crates/prts-db/src/search.rs` +- Modify: `backend/crates/prts-core/src/permission.rs` +- Modify: `backend/crates/prts-core/src/lib.rs` +- Modify: `backend/crates/prts-api/src/dto.rs` +- Modify: `backend/crates/prts-api/src/auth/project.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-api/src/routes/users.rs` +- Modify: `backend/crates/prts-api/src/routes/files.rs` +- Modify: `backend/crates/prts-api/src/routes/entries.rs` +- Modify: `backend/crates/prts-api/src/job_worker.rs` +- Modify: `backend/crates/prts-api/src/embed_worker.rs` +- Modify: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写主源、owner 修复、统计口径和 capabilities 失败测试** + + foundation 覆盖 canonical language/repair、owner、effective visibility、stats 与 capabilities;legacy file/folder hard delete 后 stats 正确且 deletion_change_set_id 始终 NULL。upload/term/search ingress 测试留给后续任务;soft-delete/restore 测试只在 Task 3.1/3.3,foundation 不伪造历史。 + +- [ ] **Step 2: 建立共享 BCP-47 canonicalizer 与 repair schema** + + workspace manifest 增加 `language-tags`,`prts-core::language` 统一解析并输出 language 小写、script Titlecase、region 大写、variant/extension/private-use 按 parser canonical serialization 的 tag。所有 ingress 只能调用这一 helper;无效 tag 和规范化后重复均返回稳定错误码。foundation 当场接入 project create/update 与 user language preferences;后续 upload/terms/search 任务必须复用同一 helper,不能自写正则或大小写逻辑。 + + `0008` 增加 language repair state/job 与 issues;issue 保存 immutable entity type/id snapshot,若有 live project/entry ref 则 nullable `ON DELETE SET NULL`,不复制正文/secret。durable job 以 users/projects/entries/terms stage+cursor 分批规范化;等值 canonical keys才折叠,冲突/invalid 保留原内容并将项目置 needs_language_resolution;user preference issue 独立隔离。 + +- [ ] **Step 3: 增加项目、搜索与删除基础字段** + + `0008` 先 nullable 增加 `primary_source_lang`,由 repair worker 依据规范化后的旧 `source_langs` 选择现有首项;空数组或歧义进入 issue/resolution 流程,不在迁移中静默猜测。约束写成条件式 CHECK:`language_repair_state='ready'` 时 primary 必须非空且属于 canonical source set;unresolved 行可暂时保留 raw/nullable 数据,直至 owner resolution,不能提前 VALIDATE 全局 NOT NULL。另增 `primary_source_changed_at`、搜索状态/job、avatar 字段;entries/files/folders 可预建 nullable `deleted_at/deleted_by/deletion_change_set_id`,但 `0008` 不创建伪 change set、不 backfill restoration payload、不切换 delete writer。运行时不保留 `[1]` fallback。 + +- [ ] **Step 4: 修复 owner 数据并建立约束** + + 以 `projects.owner_id` 为准补 owner membership;其它 owner membership 改 manager;写通知与审计。增加唯一 owner 部分索引与约束触发器,阻止 owner membership 指向非 `owner_id`。 + +- [ ] **Step 5: 创建 effective-visible `project_stats` 与 `file_stats` 并接管全部旧 writer** + + `0008` 以规范总纲 §3 的 effective predicate backfill visible total/五状态,并建立集合增量。显式改造 `prts-db/entries.rs` 的 legacy upload/edit/flags 与 `prts-db/files.rs` 的现有硬删除 writer,使业务变化与 stats 同事务。foundation 的 `delete_file/delete_folder` 仍物理删除并正确扣减 project/file stats;所有 `deletion_change_set_id` 保持 NULL,不存在 restore。`0010` 才原子替换为 soft-delete/restore writer。提供按同一谓词的 reconciliation/rebuild,正常读不使用。 + +- [ ] **Step 6: 在 canonical repair 边界后启用 `0009` search 派生** + + `0009` 以 `CREATE OR REPLACE`/重建 trigger 修正已应用 `0004_search.sql`,使 `source_text/source_tsv` 只从 canonical `primary_source_lang` exact JSON key 派生。trigger 对 `language_repair_state != ready` 的项目拒绝/跳过派生并保持 search gated;backfill/reconciliation job 只领取 ready 项目,且必须在该项目 repair commit 之后运行,不能把未规范化 key 的 exact lookup 静默写为空。部署可领取 `primary_source_lexical_reindex` 的 worker 与 embedding-stage worker,但暂不暴露变更 route。不得修改 `0004/0008`。 + +- [ ] **Step 7: API 返回显式 capabilities 与 language resolution 状态,但保持主源 route gate 关闭** + + 返回既定 capability 集;`edit_locked_entry/force_save_presence` 给 owner/manager,owner-only 项只由 owner_id 产生。`needs_language_resolution` 时 search、普通语言 edits、upload/terms 等受影响入口返回 `PROJECT_LANGUAGE_RESOLUTION_REQUIRED`。 + + owner API 固定为 `GET /projects/{id}/language-resolution`(issue、entity ref、raw/canonical tag;经项目可见性授权后才可带冲突值)与 `POST /projects/{id}/language-resolution/resolve`(逐 issue 的 canonical tag/mapping/selected value、最终 source/primary/target)。平台 admin 只有 `GET /admin/language-resolutions` 的 metadata/count 列表和 `POST /admin/language-resolutions/{project_id}/retry`,不能读取私有正文或替 owner 选择 primary。resolve 在单事务应用选择、清 issue、置 `repairing`、排 canonical repair + lexical reconcile 并写 `project.language_resolution_completed` audit;worker 校验成功后才置 `ready`。retry 写 `project.language_repair_retried`。直到 foundation readiness 验证通过且阶段 A route release 开启前,`change_primary_source=false`,create/update DTO 不接受非首主源或更新。 + +- [ ] **Step 8: 删除正常统计读取中的实时聚合** + + `GET /projects/{id}`、tree 与文件列表改读 stats 表;`refresh_entry_count` 改为 stats 修复入口或移除。文件夹聚合留给前端。 + +- [ ] **Step 9: 验证 foundation readiness、language repair、统计与 20 万行 backfill** + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test -p prts-api --features db-tests workspace_foundation -- --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: `0008/0009` 同环境均已应用;自动可修复项目全部 canonical,冲突项目保持 gated 且 owner resolution 可闭环;所有 trigger/function 不再读取 `source_langs[1]`;未 repair 项目不运行 exact-key backfill;ready 项目的 search rows reconciliation 一致;lexical worker readiness 通过;随机 legacy/current writer 的 state/visibility 变化后物化值等于离线校验;正常详情无 entries 全表 aggregate。只有这些断言通过后,此 foundation release 才可推送/部署。 + + Commit: `feat: add workspace primary search foundation` + +### Task 1.4:建立共享前端基础与运行配置 + +**Files:** + +- Create: `frontend/src/components/MarkdownView.vue` +- Create: `frontend/src/components/MarkdownEditor.vue` +- Create: `frontend/src/composables/useJobProgress.ts` +- Create: `frontend/src/lib/capabilities.ts` +- Modify: `frontend/package.json` +- Modify: `frontend/pnpm-lock.yaml` +- Modify: `frontend/src/api/http.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/api/index.ts` +- Create: `backend/crates/prts-db/src/upload_settings.rs` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-api/src/routes/admin_settings.rs` +- Modify: `backend/crates/prts-api/src/routes/meta.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Create: `frontend/src/api/uploads.ts` +- Modify: `frontend/src/i18n/index.ts` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` +- Modify: `frontend/src/main.ts` +- Modify: `frontend/src/styles/theme.scss` +- Modify: `frontend/src/quasar-variables.sass` +- Modify: `backend/crates/prts-common/src/config.rs` +- Modify: `backend/config/default.toml` +- Modify: `.env.example` +- Modify: `deploy/docker-compose.yml` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 添加 Markdown 安全渲染测试** + + 输入脚本、事件属性、危险 URL 和合法 Markdown,断言输出保留文字格式并移除可执行内容。 + +- [ ] **Step 2: 安装并封装 `markdown-it` 与 `dompurify`** + + 所有项目/任务 Markdown 只通过共享组件渲染;调用方不能使用 `v-html` 绕过净化组件。 + +- [ ] **Step 3: 统一字体、圆角与 MDI** + + 删除 Noto Serif SC 依赖和 import;`--font-sans` 以 Noto Sans SC 开头;`--font-mono` 在 JetBrains Mono 后加入 Noto Sans SC CJK fallback;Quasar `$typography-font-family` 与 sans 链一致;交互控件圆角限制 2–4px。切换到 `mdi-v7` 图标集并保留浅/深主题。 + +- [ ] **Step 4: 完成 locale 与能力基础** + + locale 初始化顺序为 localStorage → 浏览器语言 → zh-CN;Axios 每次请求写当前 `Accept-Language`。共享 capability helper 只读取 API 字段,不接受角色字符串。 + +- [ ] **Step 5: 加入 media、上传运行时设置和文件保留期配置** + + 媒体目录默认 `./data/media`,Docker 增加 media 与 upload temp 持久卷,密钥仍只经环境变量。上传的 `max_files_per_batch=500`、`max_bytes_per_file=100MB`、`max_bytes_per_batch=2GB`、`client_concurrency=3` 四项全部存入平台 `settings`;另设 `upload_batch_expiry_hours=24` 运行时设置供 durable cleanup。由 `prts-db::upload_settings::UploadConfig` 统一默认值、校验和持久化,不进入 `default.toml`、环境变量或前端常量。 + + `GET/PUT /admin/settings/upload` 使用 `UploadConfigDto` 管理四项;`GET /meta/upload-config` 只读返回相同 DTO 给普通上传客户端。`frontend/src/api/types.ts` 声明 DTO,`frontend/src/api/uploads.ts` 获取当前配置,后续 `useUploadBatch` 使用返回的 `client_concurrency`,不得定义固定前端常量。所有端点进入 utoipa/Swagger,边界值有 API/DB 测试。 + +- [ ] **Step 6: 运行设置 API 与前端测试并提交** + + Run from repository root: + + ```powershell + Set-Location .\backend + cargo test -p prts-api --features db-tests upload_settings -- --nocapture + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Run from repository root: + + ```powershell + Set-Location .\frontend + pnpm lint + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm test + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm typecheck + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + pnpm build + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + Set-Location .. + ``` + + Expected: 四项默认/更新/只读 DTO 测试,以及 Markdown 安全、字体/图标编译与现有界面测试通过。 + + Commit: `feat: add workspace shared foundations` + +## 阶段 2:A 项目工作区 route/UI exposure + +### Task 2.1:实现项目 Shell 与只读信息页 + +**Files:** + +- Create: `frontend/src/views/project/ProjectShell.vue` +- Create: `frontend/src/views/project/ProjectInfoView.vue` +- Create: `frontend/src/views/project/ProjectFilesView.vue` +- Create: `frontend/src/views/project/ProjectDownloadView.vue` +- Create: `frontend/src/views/project/ProjectManageView.vue` +- Create: `frontend/src/views/project/ProjectLeaderboardView.vue` +- Create: `frontend/src/components/project/ProjectFileBrowser.vue` +- Create: `frontend/src/components/project/ProjectProgress.vue` +- Create: `frontend/src/components/project/LegacyProjectControls.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/views/ProjectDetailView.vue` +- Modify: `frontend/src/api/index.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` + +- [ ] **Step 1: 写嵌套路由与能力显隐测试** + + 断言 `/projects/:id` 重定向到 info;编辑器不嵌 Shell;信息只读;管理只由 capability 显示;固定分区顺序正确;排行榜显示本轮唯一允许的功能占位。 + +- [ ] **Step 2: 拆分旧 `ProjectDetailView` 并保留兼容控件** + + Shell 负责一次项目详情请求和导航;信息、文件、下载、管理各自消费共享 DTO。把尚未替换的上传、成员和立即删除控件移入临时 `LegacyProjectControls`。Task 2.1 不展示/提交主源变更或非首主源创建;只有 Task 2.2 readiness 通过后才开放。B/F 替换对应旧控件后再删除临时组件。 + +- [ ] **Step 3: 文件浏览器只读视图使用物化统计** + + 支持面包屑、名称搜索、名称/进度/词条数/最近时间排序和状态筛选。文件夹前端聚合后代文件;空进度显示“—”;空文件夹最近时间取 created_at。 + +- [ ] **Step 4: 项目介绍改用安全 Markdown** + + 信息页只展示净化 Markdown;管理页编辑源文并预览。所有文案补齐 zh-CN/en。 + +- [ ] **Step 5: 运行组件测试并提交** + + Commit: `feat(frontend): add project workspace shell` + +### Task 2.2:暴露主源语言变更与双 job 重建状态 + +**Files:** + +- Create: `backend/crates/prts-core/src/project_language.rs` +- Modify: `backend/crates/prts-api/src/jobs/reindex_project.rs` +- Modify: `backend/crates/prts-db/src/projects.rs` +- Modify: `backend/crates/prts-db/src/search.rs` +- Modify: `backend/crates/prts-api/src/job_worker.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-api/src/routes/search.rs` +- Modify: `backend/crates/prts-api/src/routes/suggestions.rs` +- Modify: `backend/crates/prts-api/src/embed_worker.rs` +- Create: `frontend/src/components/project/LanguageResolutionDialog.vue` +- Modify: `frontend/src/views/project/ProjectManageView.vue` +- Modify: `frontend/src/composables/useJobProgress.ts` +- Test: `backend/crates/prts-api/tests/db_integration.rs` +- Test: `backend/crates/prts-api/tests/search_perf.rs` + +- [ ] **Step 1: 写规则测试** + + 覆盖 foundation readiness 未通过时 route 不暴露;所有项目语言 ingress 的 canonical casing、无效/重复拒绝;单源自动、多源必选、主源属于 source_langs、相同值不消费冷却、非 owner 拒绝、7 天冷却、active/unresolved failed lexical/embedding job 阻塞新变化、degraded/skipped 不阻塞、移除当前主源需替换、有词条后目标语言锁定;`needs_language_resolution` 时普通语言 edits/search 拒绝,owner 显式 mapping/value resolution 与 admin metadata-only retry 安全。 + +- [ ] **Step 2: 以 readiness check 开启 route/feature exposure** + + 启动/部署检查 `0008+0009` migration version、primary trigger/function revision、backfill reconciliation marker 与 lexical worker health;任一不满足就保持 `change_primary_source=false`、拒绝非首主源 create/update。不得在本任务改写已部署迁移。 + +- [ ] **Step 3: 接受请求时原子切换状态** + + 同一事务更新主源与 `primary_source_changed_at`,设置 `lexical=rebuilding`、`embedding=pending`,创建 `primary_source_lexical_reindex` job,写 allowlisted audit。相同值直接返回,不更新时间、冷却或 job。 + + 项目 create/update 的 source/primary/target 先调用共享 `language-tags` canonicalizer,canonical duplicate、无效 tag或 primary 不在 source set 均在事务前拒绝。普通设置端点不得充当 language resolution 旁路。 + +- [ ] **Step 4: 词法阶段按 entry id 键集批处理** + + 每批从 `original ->> primary_source_lang` 更新 `source_text/source_tsv`,清空旧 embedding 并更新 lexical job 进度。成功时原子设 `lexical=ready`、恢复 FTS/trgm,并创建 `primary_source_embedding_backfill` job。失败/耗尽设 lexical/project failed,search/TM 继续返回稳定状态与该 job id,手动重试同 job。 + +- [ ] **Step 5: Embedding/TM 阶段安全降级** + + Provider 禁用/未配置时不调用 provider:embedding job 以 `succeeded + outcome=skipped + reason` 结束,项目设 degraded,trgm TM 可用。已配置 provider 失败时,同一 embedding job 有界指数退避并暴露 `next_retry_at`;耗尽设 failed,用户手动重试同 job,不重新调用主源变更。任何失败不回滚新主源或 lexical success。 + +- [ ] **Step 6: 前端展示冷却、阶段、进度与降级原因** + + 项目 DTO/job API 返回 `language_repair_state`、resolution issue 摘要、`primary_source_changed_at/cooldown_until`,以及 lexical/embedding 各自 state/job_id/progress/attempts/max_attempts/next_retry_at/error_code/degraded_reason/manual_retry_allowed。管理页只按 capability 与这些字段操作;唯一 owner 的 resolution dialog 显式展示 raw/canonical tag 和冲突值选择后提交,普通语言表单保持禁用。平台 admin 页面只显示 project/issue counts 和 retry,不显示私有原文。lexical ready 后 search 立即恢复,不等待 embedding。 + +- [ ] **Step 7: 运行 20 万词条重建 verify 并提交** + + Expected: foundation gate 不能旁路;lexical 可断点/同 job 手动重试;lexical ready 立即恢复 FTS/trgm;provider 缺失=degraded/skipped,配置 provider 失败按 bounded backoff 并只重试 embedding stage;新主源请求阻塞条件与冷却正确。 + + Commit: `feat: add primary source rebuild workflow` + +### Task 2.3:实现项目头像与私有可见性 + +**Files:** + +- Create: `backend/crates/prts-api/src/media.rs` +- Create: `backend/crates/prts-api/src/routes/project_media.rs` +- Create: `frontend/src/components/project/AvatarCropDialog.vue` +- Modify: `backend/crates/prts-api/Cargo.toml` +- Modify: `backend/crates/prts-api/src/state.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Modify: `deploy/docker-compose.yml` +- Modify: `frontend/src/views/project/ProjectInfoView.vue` +- Modify: `frontend/src/views/project/ProjectManageView.vue` +- Modify: `frontend/src/api/index.ts` + +- [ ] **Step 1: 写签名、尺寸、大小与权限测试** + + 伪造 MIME、非 WebP、非正方形、解码失败、任一维度超过 1024、总像素超过 1,048,576、编码体积超过 512KB、公开项目匿名读、私有项目非成员拒绝均需覆盖。 + +- [ ] **Step 2: 实现 `MediaStore` 与 `LocalMediaStore`** + + key 固定在 `projects/{project_id}/avatar.webp`;写入采用临时文件 + 原子 rename;替换和删除写审计。 + +- [ ] **Step 3: 实现头像 API** + + `POST /projects/{id}/avatar`、`DELETE /projects/{id}/avatar`、`GET /projects/{id}/avatar` 全部入 Swagger。读取先判断项目可见性;私有项目必须鉴权。 + +- [ ] **Step 4: 实现 canvas 1:1 裁剪** + + 对话框目标输出 256×256 WebP;服务端仍独立执行正方形、维度、像素总量与编码体积 ceiling。私有头像始终通过鉴权 blob + object URL 展示并在组件卸载时 revoke。 + +- [ ] **Step 5: 运行生命周期测试并提交** + + Commit: `feat: add secure project avatars` + +## 阶段 3:B 上传、文件管理与历史 + +### Task 3.1:建立流式上传 batch 与临时存储 + +**Files:** + +- Create: `backend/migrations/0010_upload_file_history.sql` +- Create: `backend/crates/prts-db/src/uploads.rs` +- Create: `backend/crates/prts-api/src/routes/uploads.rs` +- Create: `backend/crates/prts-api/src/jobs/process_upload.rs` +- Create: `backend/crates/prts-api/src/jobs/cleanup_uploads.rs` +- Modify: `frontend/src/api/uploads.ts` +- Create: `frontend/src/composables/useUploadBatch.ts` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Modify: `backend/crates/prts-api/src/job_worker.rs` +- Modify: `backend/crates/prts-common/src/config.rs` +- Modify: `backend/crates/prts-db/src/upload_settings.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写限制、byte-zero retry、取消、过期和部分成功测试** + + 首先写 `0010` 整阶段 schema contract RED:完整断言 upload/history 表、状态 CHECK、retention/cleanup 索引、`file_change_sets.id UUID`、nullable target SET NULL、deletion_change_set_id RESTRICT、`0008` 遗留 deletion ids 全 NULL前置条件,以及迁移后 writer cutover marker;在迁移存在前必须因缺 schema 失败。后续 Task 3.3 只补行为 RED,不得回改已冻结 `0010`。 + + 覆盖四项设置/DTO、501 files、100MB+1、2GB+1、路径越界;V1 拒绝 Range/offset resume;断流/失败重试创建新 attempt 并从 byte zero;旧 attempt/error 保留;cancel 在 queued/processing race 下原子;abandoned batch 默认 24h expired;durable cleanup;一个坏文件不回滚其它文件;processing job id 复用。 + +- [ ] **Step 2: 一次性创建本阶段完整上传、历史与生命周期 schema** + + `0010` 首句断言 deletion ids 全 NULL;随后创建完整 upload/history schema。upload batch-file/attempt 对业务 target_file_id 使用 nullable `ON DELETE SET NULL`,逻辑 attempt/history 不随业务 file cascade;deletion FK RESTRICT,change-set target SET NULL。与同一 release 原子切换 soft-delete writer。 + +- [ ] **Step 3: 实现声明、传输、提交、状态、重试与取消 API** + + - `POST /projects/{id}/upload-batches`:从当前 `UploadConfig` 校验 max files/per-file/batch 三项,不使用编译期常量; + - `PUT /projects/{id}/upload-batches/{batch_id}/files/{file_id}/attempts/{attempt_id}`:从 byte zero 流式写临时卷,不接受 Range/offset; + - `POST /projects/{id}/upload-batches/{batch_id}/complete`:校验完整性并排队; + - `GET /projects/{id}/upload-batches/{batch_id}`:batch/file/attempt 进度与历史; + - `POST .../files/{file_id}/retry`:复用 logical job,创建并返回新 attempt; + - `POST /projects/{id}/upload-batches/{batch_id}/cancel`:进入 cancelling,取消 queued jobs/temp attempts;已开始数据库事务允许完成/回滚,最后 cancelled,已成功文件不撤销。 + + incomplete/abandoned batch 默认 24h 由 durable cleanup job 标 expired 并清 temp;成功 raw temp 处理后立即删,失败/取消/过期 temp 幂等清理。 + +- [ ] **Step 4: 保留旧 `/projects/{id}/upload`** + + 旧 handler 标为 deprecated,但保持既有客户端可用;新 UI 全量切换并稳定一个兼容周期后才允许删除。 + +- [ ] **Step 5: 运行 API 测试并提交** + + Commit: `feat: add durable streaming upload batches` + +### Task 3.2:实现文件原子完整替换 + +**Files:** + +- Create: `backend/crates/prts-core/src/upload_replacement.rs` +- Create: `backend/crates/prts-core/src/ports/file_repository.rs` +- Modify: `backend/crates/prts-core/src/lib.rs` +- Modify: `backend/crates/prts-db/src/entries.rs` +- Modify: `backend/crates/prts-db/src/files.rs` +- Modify: `backend/crates/prts-db/src/uploads.rs` +- Modify: `backend/crates/prts-api/src/jobs/process_upload.rs` +- Modify: `backend/crates/prts-api/src/routes/entries.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` +- Create: `backend/crates/prts-api/tests/upload_perf.rs` + +- [ ] **Step 1: 在 prts-core 写 typed transition plan 纯规则测试** + + `prts-core::upload_replacement` 输入现状+上传记录,输出 typed `ReplacementPlan`(insert/restore/source_changed/tombstone/unchanged 与 stats/history deltas)。纯测试覆盖译文保留、源文变化重置 untranslated、缺失 tombstone、旧 key 恢复、hidden/locked 保留、seed 规则;不得把真值写在 SQL/worker if 分支。 + +- [ ] **Step 2: 流式解析进事务临时表** + + 使用 serde JSON stream 逐项读取,按批写 PostgreSQL 临时表;临时表以 key 唯一并保存 ordinal。每个 `original` object key 先走共享 BCP-47 canonicalizer;无效 tag、canonicalization 后重复(即使原始大小写不同)或不属于项目 canonical source set 都返回位置化错误。重复 entry key 返回首个与重复数组位置,语法错误返回 parser 行列;任一错误回滚该文件事务。 + +- [ ] **Step 3: 用集合 SQL 应用完整替换** + + API worker 只解析/鉴权/开事务并调用 core service;prts-db adapter 按 `ReplacementPlan` 执行参数化集合 SQL、stats/history/audit,不自行重判领域规则。成功后清 temp;失败由 cleanup 处理。 + +- [ ] **Step 4: 运行 100MB 与 20 万词条 verify** + + Expected: backend RSS 不随文件大小线性增长到整文件量级;文件事务失败时线上文件状态完全不变;成功时统计与 delta 一致。 + + Commit: `feat: implement atomic file replacement uploads` + +### Task 3.3:实现文件操作、变更集、回滚、恢复与保留期 + +**Files:** + +- Create: `backend/crates/prts-core/src/file_history.rs` +- Modify: `backend/crates/prts-core/src/ports/file_repository.rs` +- Modify: `backend/crates/prts-core/src/lib.rs` +- Create: `backend/crates/prts-db/src/file_history.rs` +- Create: `backend/crates/prts-api/src/routes/file_history.rs` +- Create: `backend/crates/prts-api/src/jobs/purge_deleted_files.rs` +- Modify: `backend/crates/prts-db/src/files.rs` +- Modify: `backend/crates/prts-api/src/routes/files.rs` +- Modify: `backend/crates/prts-api/src/job_worker.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 用 core typed plan 定义历史/回滚/恢复行为** + + `prts-core::file_history` 纯测试生成 typed move/delete/restore/rollback plans,包含 path/tree、operation ownership、before/after allowlist 与 stats deltas;restore/rollback 真值不得塞进 handler/SQL。prts-db 只执行 plan,API 只编排。Task 3.3 的 RED 只测行为,不修改 Task 3.1 已冻结 schema。 + +- [ ] **Step 2: 实现建夹、移动、重命名、软删除、恢复** + + 文件夹移动检查环与后代 path 冲突。删除创建 operation/change-set id,并在事务中只给 active folder subtree/descendant files 写相同 `deletion_change_set_id` 与 `deleted_at/purge_after`;从 project/task exposure 扣 materialized file stats,不改 entry tombstone。restore 只清相同 operation id 并加回实际恢复 files;默认 purge_after=+30d。 + +- [ ] **Step 3: 实现历史列表与回滚** + + 项目成员可 `GET` 历史;owner/manager 可选版本回滚。服务端把目标版本物化为期望状态,再生成 current→target 新 delta;回滚本身写新版本、审计、0 CP,不改旧记录。 + +- [ ] **Step 4: 实现到期清除任务** + + worker 键集扫描到期软删除树,确认 operation 未恢复后锁定 tree/change sets。固定顺序:cancel/detach 指向业务 file 的 upload attempts/jobs(target FK SET NULL,attempt history按 batch retention保留);将 task live FKs 与 language-issue live refs置 NULL并重算 task/project stats;删除 entry-derived search/vector rows(明确 `ON DELETE CASCADE`)和 entry_versions;叶到根删除 entries/files/folders,file_stats 随 file CASCADE;显式删除相关 file_change_items/file_change_sets。不得依赖未声明默认 RESTRICT/模糊 cascade;audit 与 immutable snapshot IDs 保留。 + + 30 天内 restore 锁定相同 tree/change set,只清匹配 operation 的 `deleted_at/deleted_by/purge_after/deletion_change_set_id`;此前删除后代和 entry tombstone 不变。restore 成功后 change set 继续保留为普通历史,直到对象未来实际 purge/历史策略清除。 + +- [ ] **Step 5: 运行路径/回滚/保留期测试并提交** + + Commit: `feat: add reversible file history and retention` + +### Task 3.4:替换上传与文件管理 UI + +**Files:** + +- Create: `frontend/src/components/project/UploadBatchDialog.vue` +- Create: `frontend/src/components/project/FileMoveDialog.vue` +- Create: `frontend/src/components/project/FileHistoryDialog.vue` +- Modify: `frontend/src/views/project/ProjectFilesView.vue` +- Modify: `frontend/src/views/project/ProjectManageView.vue` +- Modify: `frontend/src/components/project/ProjectFileBrowser.vue` +- Modify: `frontend/src/components/project/LegacyProjectControls.vue` +- Modify: `frontend/src/views/ProjectDetailView.vue` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` + +- [ ] **Step 1: 浏览器只上传原始 File** + + 不调用 `File.text()`、`JSON.parse()` 或把内容放进 Pinia。文件和文件夹选择保留 relative path,并使用 `UploadConfigDto.client_concurrency`,不硬编码 3。 + +- [ ] **Step 2: 完成批次进度与逐文件重试** + + 显示 batch/file/attempt 的 uploading/queued/processing/succeeded/failed/cancelling/cancelled/expired;失败展示错误并从 byte zero 新建 attempt。提供 batch cancel;cancelling 中说明已开始的文件事务可能原子完成。 + +- [ ] **Step 3: 完成文件维护与历史交互** + + 新建、移动、重命名、删除、恢复、历史、回滚均按 capability 显示;删除展示 30 天恢复说明。 + +- [ ] **Step 4: 新 UI 稳定后隐藏旧粘贴控件** + + 从 `LegacyProjectControls` 删除旧上传部分;旧 API 继续兼容一个周期。成员与项目删除旧控件保留到 F 的替代能力完成。 + +- [ ] **Step 5: 运行前端测试并提交** + + Commit: `feat(frontend): add upload and file history workflows` + +## 阶段 4:C 任务 + +### Task 4.1:实现任务基线 ID 与进度 + +**Files:** + +- Create: `backend/migrations/0011_tasks.sql` +- Create: `backend/crates/prts-db/src/tasks.rs` +- Create: `backend/crates/prts-core/src/tasks.rs` +- Create: `backend/crates/prts-api/src/routes/tasks.rs` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-core/src/permission.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Modify: `backend/crates/prts-api/src/dto/capabilities.rs` +- Modify: `backend/crates/prts-api/src/jobs/purge_deleted_files.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写基线语义测试** + + 覆盖加入时只快照 `effective_visible(..., false)+untranslated`;hidden、entry tombstone、file/folder deletion 离开分母;各自合法恢复返回且 folder restore 不清 tombstone;翻译完成/回退、移除重加、新 entry 排除和零基线。 + +- [ ] **Step 2: 创建四表模型** + + `tasks.project_id REFERENCES projects ON DELETE CASCADE`;`task_files` 保存 `file_id_snapshot BIGINT NOT NULL` 与 nullable `live_file_id REFERENCES files(id) ON DELETE SET NULL`,active uniqueness 只约束非 NULL live ref;`task_baseline_entries` 保存 `entry_id_snapshot BIGINT NOT NULL` 与 nullable `live_entry_id REFERENCES entries(id) ON DELETE SET NULL`,并以 `task_file_id REFERENCES task_files ON DELETE CASCADE`。`task_stats.task_id REFERENCES tasks ON DELETE CASCADE`。显式删除 task_file 才删除其快照;永久 file/entry purge 仅置 live FK 为 NULL,snapshot IDs 保留解释性。同任务更新 file-purge worker:先 SET NULL live refs并重算 task_stats,再删除业务行。 + +- [ ] **Step 3: 快照与任务统计同事务更新** + + 添加 active file 时以规范 `effective_visible(..., false) AND state='untranslated'` 同时写 snapshot/live IDs。entry state/hidden/tombstone 以及 file/folder deletion exposure 变化使用集合更新维护 task_stats;file/folder soft delete/restore 复用 materialized file exposure。永久 purge 先让 live FKs SET NULL并重算 stats,NULL live rows退出分母但保留 snapshot。 + +- [ ] **Step 4: 实现 API 与权限** + + `GET/POST /projects/{id}/tasks`、`GET/PUT/DELETE /projects/{id}/tasks/{task_id}`;owner/manager 写,其它项目可见者读。Markdown 保存源文,前端净化展示。 + +- [ ] **Step 5: 当前任务范围返回当前可见词条集合** + + 提供给 E search 的 db 查询只按当前 active task_files 限定,不联接 baseline entries;route 仍须验证 task 属于 URL project 且 task/project 对 caller 可见。 + +- [ ] **Step 6: 运行测试并提交** + + Commit: `feat: add snapshot-based project tasks` + +### Task 4.2:实现任务 UI + +**Files:** + +- Create: `frontend/src/views/project/tasks/TaskListView.vue` +- Create: `frontend/src/views/project/tasks/TaskDetailView.vue` +- Create: `frontend/src/views/project/tasks/TaskManageView.vue` +- Create: `frontend/src/api/tasks.ts` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/views/project/ProjectShell.vue` +- Modify: `frontend/src/components/project/ProjectFileBrowser.vue` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` + +- [ ] **Step 1: 实现列表、详情与管理路由** + + 详情显示净化 Markdown、有效完成/分母、文件集合和“在此任务翻译”;零基线显示 100% 与“无需处理”。 + +- [ ] **Step 2: 文件勾选保存期望集合** + + 文件夹勾选展开为当时的后代文件 ID;后端对新增项快照、既有项保留、移除项删除。 + +- [ ] **Step 3: capability 驱动操作并提交** + + Commit: `feat(frontend): add project task workspace` + +## 阶段 5:D 术语 + +### Task 5.1:实现双语 POS 与 source-aware terms + +**Files:** + +- Create: `backend/migrations/0012_terminology.sql` +- Create: `backend/crates/prts-db/src/terms.rs` +- Create: `backend/crates/prts-db/src/pos.rs` +- Create: `backend/crates/prts-core/src/terms.rs` +- Create: `backend/crates/prts-api/src/routes/terms.rs` +- Create: `backend/crates/prts-api/src/routes/pos.rs` +- Modify: `backend/crates/prts-db/src/lib.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-core/src/permission.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Modify: `backend/crates/prts-api/src/dto/capabilities.rs` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写 NULL-safe 唯一、归档与语言测试** + + 两个相同 `(project,source_lang,source_text,NULL)` 必须冲突;不同 POS 可并存。任意合法 canonical BCP-47 source_lang 可存,不要求属于项目 source set;但 `archived=false` 只允许 `source_lang == primary_source_lang`,非主源 active 请求返回稳定校验错误且不得静默归档。主源切换归档旧 active、激活新主源已有归档术语;legacy old-primary 保持 archived/migration-ready。CRUD/import 统一 canonical casing,无效 tag 与 canonical duplicate 拒绝。 + +- [ ] **Step 2: 创建 POS 与术语表** + + `pos_presets` 保存 `name_zh_cn`、`name_en`、sort_order,至少一个名称非空;API 按 Accept-Language 回退。`terms` 只保存共享 `language-tags` canonicalizer 输出的 source_lang/source_text/translation/notes/pos_id/archived_at,并用 `UNIQUE NULLS NOT DISTINCT` 建唯一约束。若部署环境已存在兼容 term 表,`0012` 先调用 foundation repair 逻辑;冲突项目进入 `needs_language_resolution`,D 路由保持 gated。 + +- [ ] **Step 3: 把归档接入主源切换事务** + + 切换时令 `source_lang != new_primary` 的 active terms 归档,`source_lang = new_primary` 的术语激活;与项目主源更新、job 创建和审计同事务。 + +- [ ] **Step 4: 实现 CRUD、键集分页与匹配 API** + + owner/manager/reviewer 写,其它项目可见者读。所有 source_lang 入参先规范化并在规范化后做唯一校验;合法 canonical tag 可作为 archived term 保存,无需属于项目 source set。若请求 active,则事务内校验 source_lang 精确等于当前 primary,否则返回稳定错误。`needs_language_resolution` 项目禁止普通 term mutation。匹配只返回当前主源 active terms;列表可按 current/archived/mixed 过滤。 + +- [ ] **Step 5: 平台 POS 管理只授予平台管理员** + + maintainer 与项目 owner 均不能修改 POS;所有 POS mutation 写审计。 + +- [ ] **Step 6: 运行测试并提交** + + Commit: `feat: add source-aware project terminology` + +### Task 5.2:实现 CSV/JSON 预览确认导入与混合导出 + +**Files:** + +- Create: `backend/crates/prts-api/src/term_import.rs` +- Create: `frontend/src/components/terms/TermImportDialog.vue` +- Create: `frontend/src/api/terms.ts` +- Modify: `backend/crates/prts-api/src/routes/terms.rs` +- Modify: `backend/crates/prts-api/src/routes/pos.rs` +- Modify: `backend/crates/prts-db/src/terms.rs` +- Modify: `frontend/src/views/project/ProjectShell.vue` +- Create: `frontend/src/views/project/ProjectTermsView.vue` +- Modify: `frontend/src/views/AdminView.vue` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` + +- [ ] **Step 1: 定义稳定格式** + + 术语 CSV 字段为 `source_lang,source_text,translation,pos,notes,archived`;JSON 使用同名字段。POS CSV/JSON 同时包含 `name_zh_cn,name_en,sort_order`。混合导出始终带 source_lang 与 archived。 + +- [ ] **Step 2: 实现 preview token** + + `POST .../imports/preview` 只解析校验并返回行预览、created/updated、未知 POS 警告和一次性 token;source_lang 在 digest/唯一性计算前 canonicalize,无效或规范化后重复行明确报错。合法非项目 source-set tag 可导入,但 `archived=false` 且不等于当前 primary 的行必须报稳定错误,不能静默改 archived。token 使用 CSPRNG,至少 128-bit entropy,TTL 固定 15 分钟,并绑定 `actor_id + project_id + import_kind(term|pos) + canonical content digest`。 + + `POST .../imports/{token}/confirm` 使用 Redis Lua/等价原子 GET-and-consume,校验 actor/project/kind/digest 后一次性消费,并在开启数据库事务后重新检查当前 permission、当前 primary 与每行 active/archived 约束,再按 NULL-safe key upsert 与写 audit。token 不匹配、过期、已使用、权限已撤销、非主源 active 行或项目进入 language-resolution/pending-deletion 状态均拒绝且不写业务表;并发 confirm 只能一个成功。未知 POS 写 NULL,不阻断其它合法行。 + +- [ ] **Step 3: 实现术语/POS UI 与权限** + + 项目术语支持 current/archived/mixed;POS 名按 locale 回退;导入必须先显示预览再允许确认。 + +- [ ] **Step 4: 往返、过期、重放与权限撤销测试并提交** + + Commit: `feat: add terminology import preview and export` + +## 阶段 6:E 编辑器、搜索与 context 清理 + +### Task 6.1:删除 context 并保持旧上传兼容 + +**Files:** + +- Create: `backend/migrations/0013_editor_search.sql` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-db/src/entries.rs` +- Modify: `backend/crates/prts-api/src/routes/entries.rs` +- Modify: `backend/crates/prts-api/src/dto.rs` +- Modify: `backend/crates/prts-api/tests/db_integration.rs` +- Modify: `backend/crates/prts-api/tests/search_perf.rs` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/api/index.ts` +- Modify: `frontend/src/views/EditorView.vue` +- Modify: `frontend/src/views/ProjectDetailView.vue` + +- [ ] **Step 1: 写 `0013` 完整迁移契约测试** + + API DTO 不含 context;数据库列不存在;新旧上传带 context 时忽略;`ProjectDetailView.vue` 旧上传文案/fixture 不再提 context;导出与 history JSONB 无 context;POST search schema存在。 + +- [ ] **Step 2: 在 `0013` 完成 drop、history scrub 与结构化 search schema** + + 不改写 `0003`。`0013` scrub history 后 DROP;entry-derived search/vector metadata 若为独立表必须 `entry_id ON DELETE CASCADE`、project metadata明确 CASCADE,不能默认 RESTRICT。同步删除 models/DTO/Swagger/types/EditorView 与 `ProjectDetailView.vue` context 引用。 + +- [ ] **Step 3: 运行仓库级搜索** + + Run: `rg -n "\bcontext\b" backend/crates frontend/src backend/migrations -g '!0003_projects.sql'` + + Expected: 运行时代码/前端无词条 context;`ProjectDetailView.vue` 上传文案/fixture 也无 context;命中只允许已应用 `0003` 历史与 `0013` 的 DROP/scrub;file-history serializer 只出现明确 deny/allowlist 测试。 + +- [ ] **Step 4: 提交** + + Commit: `refactor: remove entry context field` + +### Task 6.2:实现结构化 POST 搜索与 GET 兼容适配 + +**Files:** + +- Create: `backend/crates/prts-core/src/search_query.rs` +- Modify: `backend/crates/prts-db/src/search.rs` +- Modify: `backend/crates/prts-search/src/orchestrator.rs` +- Modify: `backend/crates/prts-search/src/lib.rs` +- Modify: `backend/crates/prts-api/src/routes/search.rs` +- Modify: `backend/crates/prts-api/src/routes/mod.rs` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Modify: `backend/crates/prts-api/tests/db_integration.rs` +- Modify: `backend/crates/prts-api/tests/search_perf.rs` + +- [ ] **Step 1: 定义并测试请求类型** + + ```rust + #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] + enum SearchScope { + All, + Path { path: String }, + File { file_id: i64 }, + CurrentFile { file_id: i64 }, + CurrentTask { task_id: i64 }, + } + + struct StructuredSearchRequest { + query: Option, + conditions: Vec, + scope: SearchScope, + states: Vec, + #[serde(default)] + include_hidden: bool, + #[serde(default)] + vector: bool, + after: Option, + limit: u16, + } + ``` + + JSON scope 精确为 `{ "type": "all" }`、`{ "type": "path", "path": "..." }`、`{ "type": "file", "file_id": 41 }`、`{ "type": "current_file", "file_id": 41 }`、`{ "type": "current_task", "task_id": 73 }`。files/tasks 沿用 BIGINT/i64。limit 默认 50、范围 1..=100;响应 DTO 固定 `{ items, next_after }`。测试 variant、缺 payload、unknown field/type、错误 ID、limit 0/101。 + +- [ ] **Step 2: 在项目 route 验证 scope resource** + + path 先 canonicalize 并按 segment boundary resolve:精确命中 active file 时只取该 file;命中 folder 时取 active descendants,SQL 使用 exact path 或 `folder_path || '/%'` 并转义 LIKE,禁止 naive prefix。path/file/task 必须属于 URL project且可见;歧义、跨项目、deleted ancestor稳定拒绝。 + +- [ ] **Step 3: 参数化构造过滤并接入 P4** + + query 继续走 FTS/trgm/vector/RRF;conditions/scope/states 与规范 effective-visible filter 在召回/fetch 一致应用。`source:` field selector 在构造 JSON lookup 前使用共享 `language-tags` canonicalizer,无效 tag 或不属于项目 canonical source set 时拒绝;`needs_language_resolution` 项目 search 保持 gated。include_hidden 只覆盖 hidden,绝不包含 tombstone/deleted file/folder。`vector=false` 默认不调用 provider。 + + 默认排序固定 `(rrf_score DESC, entry_id ASC)`。opaque cursor v1 的签名/fingerprint 必须包含 URL project_id、canonical query/conditions/states/scope/include_hidden/vector 与最后 score+id;keyset按 score降序/id升序继续。tamper、未知版本、跨 project/query/filter/scope cursor 返回 400;不得静默重置。测试用两个项目构造相同 all scope/过滤,证明 cursor 不能跨项目重用。 + +- [ ] **Step 4: 强制 hidden 与重建状态** + + 默认使用 `effective_visible(..., false)`;include_hidden 越权返回 403,授权后也只覆盖 hidden。lexical rebuilding/failed 返回稳定状态和 lexical job;lexical ready + embedding degraded/failed 仍返回词法结果。 + +- [ ] **Step 5: GET 只做 file/all 适配** + + 旧 GET 有 `file_id` 时按现有 i64 解析并映射 `{ "type": "file", "file_id": 41 }`,否则 `{ "type": "all" }`;绝不制造 current_file/current_task。响应加入 `Deprecation: true` 与 Sunset,OpenAPI 标 deprecated;不得维护第二套 SQL。 + +- [ ] **Step 6: 运行操作符、path truth table、cursor/limit、scope 资源、兼容适配与性能测试并提交** + + Commit: `feat: add structured project search` + +### Task 6.3:重构编辑器智能动作、术语建议与游客只读模式 + +**Files:** + +- Create: `frontend/src/components/editor/SearchBar.vue` +- Create: `frontend/src/components/editor/AdvancedFilterDialog.vue` +- Create: `frontend/src/components/editor/TermSuggestions.vue` +- Modify: `frontend/src/components/SearchFilters.vue` +- Modify: `frontend/src/views/EditorView.vue` +- Modify: `frontend/src/lib/saveButton.ts` +- Modify: `frontend/src/lib/saveButton.spec.ts` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/composables/useRealtime.ts` +- Modify: `frontend/src/api/index.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` +- Modify: `backend/crates/prts-api/src/routes/entries.rs` +- Modify: `backend/crates/prts-api/src/routes/ws.rs` + +- [ ] **Step 1: 先写智能按钮真值表测试** + + dirty+untranslated=translate;dirty+其它=save 且 state 不变;clean+translated+`review_entry`=check;clean+checked+`review_entry`=review;presence conflict+`force_save_presence`=force;其它 disabled。owner/manager 获得 force capability,但前后端不比较角色名;force 请求仍携带 expected version,版本冲突返回 409。 + +- [ ] **Step 2: 底部只保留状态下拉与一个按钮** + + 删除左下 combobox 和其它保存按钮。状态项按 capabilities 置灰,服务端仍校验状态、locked 和 version。 + +- [ ] **Step 3: 实现快捷搜索和高级筛选** + + SearchBar 位于列表上方;composition 期间 Enter 不触发;Enter 发送 `{ "type": "all" }`,Shift+Enter 发送 `{ "type": "current_file", "file_id": 41 }`(实际当前文件的 i64)。高级对话框为 path/file/current_file/current_task 收集必需 payload,不以 UI 隐式上下文替代;支持字段/五操作符/多状态/include_hidden/vector=false。 + +- [ ] **Step 4: 实现 active term 高亮与插入** + + 只请求当前主源 active terms。点击建议时有 selection 就替换 selection,否则插入 cursor;只更新本地 draft,不调用保存或状态 API。 + +- [ ] **Step 5: 实现公开游客只读编辑器** + + 公开项目 editor 路由取消强制登录;匿名只调用可读 REST,不建立可写 presence/WS,不显示保存、状态、poke、私信、锁定或隐藏动作。私有项目匿名仍拒绝。 + +- [ ] **Step 6: 自己头像无协作菜单** + + 列表显示本人 editing avatar;点击本人不显示 poke/DM,其它成员仍按 capability 使用既有功能。 + +- [ ] **Step 7: 运行前端与 API 测试并提交** + + Commit: `feat(frontend): overhaul editor search workflow` + +## 阶段 7:F 管理、权限、CP 与项目删除 + +### Task 7.1:实现管理员用户列表、建号与密码提醒 + +**Files:** + +- Create: `backend/migrations/0014_admin_delete_cp.sql` +- Modify: `backend/crates/prts-db/src/users.rs` +- Modify: `backend/crates/prts-db/src/models.rs` +- Modify: `backend/crates/prts-api/src/dto.rs` +- Modify: `backend/crates/prts-api/src/routes/admin.rs` +- Modify: `backend/crates/prts-api/src/routes/users.rs` +- Modify: `backend/crates/prts-auth/src/password.rs` +- Modify: `backend/crates/prts-core/src/permission.rs` +- Modify: `frontend/src/views/AdminView.vue` +- Modify: `frontend/src/views/ProfileView.vue` +- Modify: `frontend/src/stores/auth.ts` +- Modify: `frontend/src/App.vue` +- Modify: `frontend/src/api/index.ts` +- Modify: `frontend/src/api/types.ts` +- Modify: `frontend/src/i18n/locales/zh-CN.json` +- Modify: `frontend/src/i18n/locales/en.json` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写平台秩与 cursor 测试** + + 首先写 `0014` 完整 schema contract RED:users.cp_tenths/memberships.cp_tenths、旧 cp 列消失、password_change_required、pending deletion columns、deletion_job_id nullable SET NULL、无 project/job cascade、索引/约束全部断言;迁移前必须 RED。后续 Task 7.3 只补行为 RED,不回改已冻结 `0014`。 + + super_admin 只能管理 admin 以下;admin 只能管理 maintainer/user;不能管理同级、更高或自己。各 sort 的 cursor 包含排序值+id,翻页无重复/遗漏。 + +- [ ] **Step 2: 一次性创建本阶段完整管理、CP 与待删除 schema** + + `0014` 增 password_change_required;受控转换旧 users.cp 为 `users.cp_tenths BIGINT`,新增 `memberships.cp_tenths BIGINT DEFAULT 0`;新增 deletion_scheduled_at/requested_by 与 nullable `deletion_job_id REFERENCES jobs ON DELETE SET NULL`,禁止双向 cascade。同步删除/转换所有 cp:f64 引用:prts-db model、prts-api DTO/users routes、frontend api types/Profile/Admin/auth store 全部改为 cp_tenths i64或不暴露列;迁移、后端与前端必须在 Task 7.1 GREEN,不能把编译修复留给 7.2。 + +- [ ] **Step 3: 实现用户管理 API** + + `GET /admin/users` 支持 q/role/sort/after/limit;`POST /admin/users` 接 username/initial_password/role;角色变更执行严格秩规则。响应不增加全为 0 的 CP 列。 + +- [ ] **Step 4: 实现 UI 与非阻断提醒** + + App 展示持久提醒,Profile 提供修改密码;用户可继续正常使用其它功能。 + +- [ ] **Step 5: 运行测试并提交** + + Commit: `feat: add admin user and password workflows` + +### Task 7.2:收紧项目成员授权并消费已完成的 CP schema + +**Files:** + +- Modify: `backend/crates/prts-db/src/memberships.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-core/src/permission.rs` +- Modify: `frontend/src/views/project/ProjectManageView.vue` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写项目授权矩阵测试** + + owner 可授 manager/reviewer/translator;manager 可授 reviewer/translator;任何 owner 输入被拒;manager 不能改/移除 manager;owner_id 不可被改/移除;平台管理员也不能绕过 owner transfer 禁令。 + +- [ ] **Step 2: 验证 Task 7.1 已完成的 exact-tenths 类型** + + 本任务只消费已 GREEN 的 cp_tenths model/DTO,不修改迁移或修补遗留 f64;扫描发现旧 cp:f64 即失败并回到 Task 7.1 修正。 + +- [ ] **Step 3: 不实现评分与 CP 表格** + + 本阶段不在编辑保存路径累加 CP,不上线真实排行榜,不在管理员或成员表添加全 0 CP 列。未来排序 SQL 直接按 `cp_tenths`;UI 展示时再把 exact tenths 四舍五入为整数。 + +- [ ] **Step 4: capability 驱动成员 UI 并提交** + + Commit: `feat: enforce owner membership invariants` + +### Task 7.3:实现 Redis 数学 challenge 与 24 小时延迟删除 + +**Files:** + +- Create: `backend/crates/prts-core/src/delete_challenge.rs` +- Create: `backend/crates/prts-api/src/jobs/purge_project.rs` +- Modify: `backend/crates/prts-db/src/projects.rs` +- Modify: `backend/crates/prts-db/src/jobs.rs` +- Modify: `backend/crates/prts-api/src/routes/projects.rs` +- Modify: `backend/crates/prts-api/src/routes/admin.rs` +- Modify: `backend/crates/prts-api/src/auth/project.rs` +- Modify: `backend/crates/prts-api/src/job_worker.rs` +- Modify: `backend/crates/prts-api/src/media.rs` +- Create: `frontend/src/components/project/ProjectDeleteDialog.vue` +- Modify: `frontend/src/views/project/ProjectManageView.vue` +- Delete: `frontend/src/components/project/LegacyProjectControls.vue` +- Modify: `frontend/src/views/project/ProjectShell.vue` +- Modify: `frontend/src/views/ProjectsView.vue` +- Modify: `frontend/src/views/AdminView.vue` +- Modify: `frontend/src/api/index.ts` +- Test: `backend/crates/prts-api/tests/db_integration.rs` + +- [ ] **Step 1: 写 challenge、待删除和取消测试** + + 前端覆盖三阶段门槛:第一次显示删除后果/24h/待删除只读并要求显式继续;第二次完整 slug 精确匹配;任一未通过都不得请求 challenge。API 覆盖 owner-only、challenge 绑定 user+project、TTL、一次性消费、过期/重放/错误、正确答案 202;并覆盖 24h 前不清除、列表隐藏、mutation 拒绝、jobs pause/cancel、nullable FK。到期 DB-first purge 后 job 以 project_id=NULL 存活;external cleanup 失败重试同 job 且 project 不复活。 + +- [ ] **Step 2: 接入迁移已建立的待删除字段** + + 使用 `0014` 已建立的 `deletion_scheduled_at`、`deletion_requested_by`、`deletion_job_id`。普通项目查询统一过滤 scheduled;项目 access guard 对唯一 owner 返回只读倒计时视图,对其它主体按不可见处理;本任务不修改迁移。 + +- [ ] **Step 3: 实现安全题库** + + advanced 使用预定义整数微分/定积分/极限模板,simple 使用有界整数四则运算;生成器直接计算整数答案,不 eval 字符串。Redis TTL challenge 一次性消费。 + +- [ ] **Step 4: 正确答案只安排清除** + + `DELETE /projects/{id}` 的安排事务固定为:创建/排队 project_purge job(payload 复制 immutable project id/slug/media/temp keys/deadline)→更新 scheduled_at/deletion_job_id→写 allowlisted audit→提交并返回 202。`GET .../deletion` 与 `POST .../cancel` 只允许 owner_id。 + +- [ ] **Step 5: 按 DB-first 固定顺序清除并保留 purge job** + + 到期 worker:锁定 purge job/project→写 audit metadata→detach/cancel其它 jobs与 upload attempts→逐树先 NULL task live refs/language issue refs、清 stats/search/vector/entry_versions,再叶到根删 entries/files/folders→显式删除 project-scoped file_change_items/change_sets→按已声明 FK 顺序删除 task baselines/task_files/tasks、terms/POS links、stats、upload metadata、language issues、memberships→删除 project并写 `external_cleanup_pending` 后提交。只允许明确列出的 CASCADE/SET NULL,不能依赖默认 RESTRICT或模糊 project cascade;jobs.project_id SET NULL 保留 purge job。提交后幂等清 media/temp。 + +- [ ] **Step 6: 完成三阶段删除确认、倒计时与取消 UI** + + `ProjectDeleteDialog` 第一屏完整展示不可逆后果、24 小时等待期和 pending 期间只读语义,显式“继续”后进入第二屏;第二屏要求输入完整项目 slug,逐字符精确匹配后才启用“获取验证题”;第三屏才请求并展示服务端数学 challenge,正确答案提交到后端并以 202 进入 pending。两次前端确认不写可信状态,后端仍重新校验 owner、challenge 绑定/TTL/一次性和答案。待删除 owner 进入项目只能查看倒计时和取消;其它操作置灰并由后端再次拒绝。成员与立即删除替代完成后删除 `LegacyProjectControls`。 + +- [ ] **Step 7: 运行时间控制测试并提交** + + Commit: `feat: add delayed owner-only project deletion` + +## 阶段 8:最终验证、兼容交接与发布 + +### Task 8.1:全量契约与文档核对 + +**Files:** + +- Modify: `docs/architecture.md` +- Modify: `plan/26-06-28-init_system.md` +- Modify: `README.md` +- Modify: `README.en.md` +- Modify: `backend/crates/prts-api/src/openapi.rs` +- Create: `scripts/verify-project-workspace.ps1` + +- [ ] **Step 1: 生成并检查 OpenAPI** + + 确认每个新端点有鉴权、请求、响应、错误码、中文描述;旧 upload 与 GET search 标 deprecated;SearchScope 是 `deny_unknown_fields` 的 tagged union,file/task ID 为 i64;无 context schema/history key;所有 BCP-47 ingress 使用共享 canonicalizer。 + +- [ ] **Step 2: 运行文档关键词审计** + + Run: `rg -n -g '!2026-07-10-project-workspace-overhaul.md' "scope: all|永久保存行为|entry history 本来不保存 context|断流.*续传|source_langs\[1\].*运行时|audit.*旁路|先删媒体.*项目|NUMERIC\(20,1\)|BigDecimal|rust_decimal|file_id:\s*Uu[id]|task_id:\s*Uu[id]" docs plan README.md README.en.md` + + Expected: 不存在与总纲冲突的现行规则;历史说明必须明确已被 2026-07-10 总纲覆盖。 + +- [ ] **Step 3: 运行相对链接与路径检查** + + verify 脚本解析 Markdown 相对链接并对本地目标执行 `Test-Path`;同时校验计划中列出的 Create/Modify 路径在相应阶段已存在。 + +- [ ] **Step 4: 提交文档与 verify** + + Commit: `docs: finalize project workspace overhaul` + +### Task 8.2:规模、故障恢复与安全验证 + +**Files:** + +- Modify: `backend/crates/prts-api/tests/search_perf.rs` +- Modify: `backend/crates/prts-api/tests/upload_perf.rs` +- Modify: `scripts/verify-project-workspace.ps1` + +- [ ] **Step 1: 20 万词条场景** + + 验证 project/file stats 不扫 entries;effective-visible reconciliation 覆盖 deleted ancestor/restore op/tombstone;lexical 重建可断点恢复;tagged search 五 scope 满足延迟预算;task progress 不实时 COUNT。 + +- [ ] **Step 2: 上传与历史场景** + + 验证 500 文件/2GB、100MB 流式、无 Range/offset、byte-zero retry/attempt history、cancel race、24h expiry/cleanup、部分成功、replacement、回滚再回滚、30 天 purge 后 restoration payload 消失。 + +- [ ] **Step 3: 故障注入** + + 在 language repair、解析、lexical、embedding、file purge、project DB purge/external cleanup 中途终止 worker;同一 job/stage 恢复。配置 provider 失败只重试 embedding;项目 DB 删除后 external retry 不复活项目。 + +- [ ] **Step 4: 权限与数据泄露检查** + + 匿名公开只读、头像 1024/像素/512KB ceiling、include_hidden 不穿透删除、scope i64/unknown-field/跨项目/不可见资源、BCP-47 canonical ingress 与 resolution gate、preview token 绑定/TTL/一次性/权限重验、owner-only 主源/删除、管理员/成员秩、audit redaction/fail-closed、待删除项目、temp 文件逐项验证。 + +### Task 8.3:兼容周期与生产发布 + +**Files:** + +- Modify: `.github/workflows/ci.yml` +- Modify: `deploy/docker-compose.yml` +- Modify: `deploy/nginx/default.conf` + +- [ ] **Step 1: 保留并监控兼容端点** + + 统计旧 upload 与 GET search 调用;兼容周期内保持测试。新前端不得再调用旧 upload,快捷/高级搜索不得再调用 GET。 + +- [ ] **Step 2: 运行固定闭环全部命令** + + Expected: fmt/clippy/test/db-tests、lint/test/typecheck/build、Docker health、Swagger、verify、规模测试均通过。 + +- [ ] **Step 3: 推送 master 并等待 CI/GHCR** + + ```powershell + git push origin master + gh run watch --exit-status + ``` + + Expected: GitHub Actions 成功;GHCR 的 backend、frontend、postgres 镜像均有本次 commit 对应标签。 + +- [ ] **Step 4: 部署后冒烟** + + 创建公开与私有项目,上传文件夹,重传并恢复,创建任务和术语,切换主源并观察降级恢复,以游客打开只读编辑器,安排并取消一次项目删除。所有步骤产生审计,任务进度可查询。 diff --git a/docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md b/docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md index f576f98..bdf5324 100644 --- a/docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md +++ b/docs/superpowers/specs/2026-06-29-p4-hybrid-search-design.md @@ -5,9 +5,11 @@ | 阶段 | P4(搜索)+ 翻译建议(TM),同一 spec | | 基线 | `master` @ `a4ec807` | | 日期 | 2026-06-29 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置阅读 | [`CLAUDE.md`](../../../CLAUDE.md)、蓝图 [`plan/26-06-28-init_system.md`](../../../plan/26-06-28-init_system.md) §12,接力 [`plan/26-06-29-next_steps.md`](../../../plan/26-06-29-next_steps.md) §2 | +| 前置阅读 | [`CLAUDE.md`](../../../CLAUDE.md)、蓝图 [`plan/26-06-28-init_system.md`](../../../plan/26-06-28-init_system.md) §12;原接力文件未随当前仓库保留 | -> 设计约束以蓝图为准。本 spec 已与作者逐条确认(见「决策摘要」)。实现阶段如再遇不确定 → 先问作者(蓝图 §8)。 +> **历史实现基线:** 本文件描述已落地 `0004_search.sql` 的 P4 设计,不是当前主源生命周期规范。其中 `source_langs[1]` 只说明旧迁移/旧触发器行为,已由 [`2026-07-10 规范总纲`](./2026-07-10-project-workspace-overhaul-design.md) 与计划中的 `0009_primary_source_search.sql` 明确取代;不得把该表达复制到新运行时代码。 +> +> 其余设计约束以蓝图为准。实现阶段如再遇不确定 → 先问作者。 --- diff --git a/docs/superpowers/specs/2026-07-01-editor-search-design.md b/docs/superpowers/specs/2026-07-01-editor-search-design.md index 3abc6d6..2899382 100644 --- a/docs/superpowers/specs/2026-07-01-editor-search-design.md +++ b/docs/superpowers/specs/2026-07-01-editor-search-design.md @@ -1,97 +1,152 @@ -# 编辑器(工作台 + 搜索重构 + 删 context)· 工作流 E — 设计 Spec +# 编辑器(工作台 + 搜索重构 + 删 context)· 工作流 E — 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **E**(最大);前置 **A**(每文件状态计数)、**C**(任务→文件,供「当前任务」范围)、**D**(术语,供编辑器匹配,见 §1 不做) | -| 基线 | `master` @ `e6c213e`(D spec 提交后) | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | A–D spec、P3 实时编辑器、P4 混合搜索 | +| 历史阶段 | 2026-07-01 大改造工作流 E | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含逐张 mockup):**工作台**——撤左下状态 combobox,右下「状态下拉 + 智能按钮」并排;按钮随「改动+状态+权限」变:未翻译·改动→`翻译`(靛)、已翻译·改动→`保存`(蓝)、已翻译·未改→`检查`(青)、已检查·未改→`审核`(绿)、他人编辑·管理→`强制保存`(红)、终态/无权→禁用;状态下拉设任意**有权限**状态、无权灰掉;列表里**自己**编辑的词条显示**自己头像**。**搜索**——移到列表上方;`Enter` 全项目 / `Shift+Enter` 当前文件;筛选图标下拉=单选本文件状态 + 高级筛选入口;高级弹窗=**AND** 多条件(字段:原文·各源语言 / 原文·全部源语言 / 译文 / 键名;操作符:包含·不包含·开头是·结尾是·等于·正则)+ 范围(全项目 / 指定文件·目录 / 当前文件 / 当前任务)+ 多选状态 + 向量开关(默认关,开启才出「语义查询」)。**删除词条 `context`**(数据丢失,作者已确认「删了吧」)。**全站图标统一 MDI**。 - ---- +> 2026-07-01 版本确认了智能动作、搜索位置、AND 条件、删 context 与 MDI。本文件保留编辑器交互细节;精确 search scope DTO、资源验证、可见性和 context/history 清理契约只以规范总纲 §3、§8 为准。 ## 1. 范围 -**做**:① 工作台底栏智能按钮 + 状态下拉 + 自编头像;② 搜索重构(位置 + 快捷键 + 筛选下拉 + 高级筛选弹窗 + 结构化搜索端点);③ 删除 `entries.context`;④ 全站图标改 MDI。 +工作流 E 完成: -**不做(后续/别处)**:编辑器内**术语匹配高亮/建议**(D 数据已具,匹配 UI 可作为 E 后续小增或单列,本 spec 不含);OR 条件组合(仅 AND);保存乐观锁本身(P3 已有,不改);审计(P5)。 +1. 删除词条 `context` 的数据库、DTO、上传、前端、测试和权威文档引用; +2. 右下“状态下拉 + 一个智能按钮”; +3. 列表上方快捷搜索、状态菜单和高级结构化搜索; +4. active 术语高亮/建议; +5. 公开项目游客只读编辑器; +6. 本人 editing avatar 展示但不提供 poke/DM。 -## 2. 决策要点 +## 2. 智能动作 -1. **按钮语义**(精化现 `lib/saveButton.ts`):`未翻译+dirty→翻译(置已翻译)`;`有译文+dirty→保存(不改状态)`;`未 dirty` 时按状态与权限给推进动作 `检查(→已检查)/审核(→已审核)`;他人编辑且本人管理→`强制保存`;否则禁用。标签/色/图标随 mode。 -2. **状态下拉**紧邻按钮:列全部工作流状态,按角色权限灰掉不可设项(翻译只可 未翻译/已翻译/有疑问;校对/管理可全部);选中=保存并置该状态。 -3. **自编头像**:列表行的「正在编辑」头像**含本人**(现仅显示他人);复用实时 editingMap。 -4. **搜索位置/快捷键**:列表上方;`Enter`=全项目、`Shift+Enter`=当前文件。 -5. **筛选下拉**:单选本文件状态 + 「高级筛选…」。 -6. **高级筛选**:AND 多条件(字段/操作符/值)+ 范围 + 多选状态 + 向量开关(默认关)。范围选「当前文件/当前任务」时禁用文件·目录选择。 -7. **删 context**:迁移 drop 列 + 前后端清理 + 上传忽略该字段。 -8. **MDI**:Quasar 图标集改 `mdi-v7`,替换现有 Material 图标名。 +删除左下状态 combobox。右下角恰好保留状态下拉与一个主按钮,按钮真值表固定为: -## 3. 数据模型 · 迁移 `0010_drop_context.sql` +| 条件 | 标签 | 保存后的状态 | +| --- | --- | --- | +| dirty 且当前 `untranslated` | 翻译 | `translated` | +| dirty 且当前为其它状态 | 保存 | 状态不变 | +| clean、当前 `translated`、有 `review_entry` capability | 检查 | `checked` | +| clean、当前 `checked`、有 `review_entry` capability | 审核 | `reviewed` | +| 他人 presence 占用、本人有 `force_save_presence` capability | 强制保存 | 按 dirty 规则;仍校验 expected version | +| 其它 | 禁用 | 无请求 | -``` -ALTER TABLE entries DROP COLUMN context; -- 数据丢失(作者已确认) -``` -`entry_versions` 无 context 列,不受影响。搜索**无新表**;复用 P4 的 `source_text/source_tsv/translation_tsv/embedding` 及 trgm/GIN/HNSW 索引。 +- 状态下拉列出完整工作流状态,无设置能力的选项置灰。 +- 服务端继续校验 capability、状态机、locked 和乐观锁版本。 +- owner 与 manager 获得 `force_save_presence`;前后端只检查 capability。“强制保存”只越过 presence 占用提示,不绕过 version mismatch;过期版本仍返回 409。 +- 自己正在编辑的列表行显示自己的头像;点击自己不显示 poke 或私信菜单。 -## 4. 后端 +## 3. 公开游客只读 -**结构化搜索** `POST /projects/{id}/search`(取代/扩展现 GET /search): -``` -body: { - q?: string, // 关键词:FTS+trgm;vector=true 时兼作语义查询 - conditions?: [{ field, op, value }], // AND;field=source: | source_any | translation | key - scope?: { type: all|path|file|task, file_id?, folder_id?, task_id? }, - states?: string[], vector?: bool = false, - sort?: relevance|key|updated, after?, limit? +- 公开项目的 editor 路由不要求登录;匿名可读取项目、文件、词条、普通搜索和术语只读数据。 +- 匿名不建立可写 presence,不发送 editing、poke、DM 或其它协作事件。 +- 匿名不显示/调用保存、状态、locked、hidden、history rollback 等 mutation。 +- 私有项目匿名访问仍拒绝;登录成员能力由 API `capabilities` 决定,前端不从角色字符串推断。 + +## 4. context 清理 + +计划迁移 `0013_editor_search.sql` 不改写已应用的 `0003`,并在同一迁移中完成: + +- `ALTER TABLE entries DROP COLUMN context`,并从 `prts-db::Entry`、UploadEntry、API DTO、Swagger、前端类型和 editor UI 移除字段; +- 创建/更新结构化 POST search 所需 metadata、indexes 和 functions; +- 从既有 `file_change_items.before/after` 的 entry JSONB payload scrub `context` key。 + +从 B 的 file-history 首次发布起,entry change-set payload 就只能序列化 `key/original/translation/state/locked/hidden/deleted_at`;不得等到 `0013` 才停止捕获 context。 + +- 旧上传兼容期若请求仍携带 context,反序列化层忽略未知字段,不写库、不回显。 +- 权威蓝图与 architecture 同步删除“保留/展示上下文”的描述。 + +## 5. 快捷与高级搜索 + +### 5.1 快捷搜索 + +- 输入框位于词条列表上方。 +- IME composing 期间 Enter/Shift+Enter 不触发。 +- `Enter`:全项目 scope;`Shift+Enter`:当前文件 scope。 +- 筛选图标菜单只提供当前文件单一 state 选择和“高级筛选”入口。 + +### 5.2 结构化 POST + +`POST /projects/{id}/search` 请求模型: + +```rust +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum SearchScope { + All, + Path { path: String }, + File { file_id: i64 }, + CurrentFile { file_id: i64 }, + CurrentTask { task_id: i64 }, +} + +struct StructuredSearchRequest { + query: Option, + conditions: Vec, + scope: SearchScope, + states: Vec, + #[serde(default)] + include_hidden: bool, + #[serde(default)] + vector: bool, + after: Option, + limit: u16, } ``` -- **条件→WHERE**(参数化):`source:` = `original->>''`;`source_any` = 对项目 `source_langs` 各值 OR(或对 `source_text` 匹配);`translation`、`key` 直列。操作符:`包含`→`ILIKE '%v%'`、`不包含`→`NOT ILIKE`、`开头是`→`ILIKE 'v%'`、`结尾是`→`ILIKE '%v'`、`等于`→`=`、`正则`→`~`。 -- **范围**:`file`→`file_id=`;`path`→`files.path LIKE '/%'`(连表或先查 file_ids);`task`→ 取该任务 `task_files.file_id ∈`;`all`→ 项目内。 -- **状态**:`state = ANY(states)`。 -- **排序/召回**:有 `q` 或 `vector` → 走 `prts-search` 混合编排(`vector=false` 时只 FTS+trgm;`true` 且平台已配 Embedding 时加向量路,RRF),在其上叠加 conditions/scope/states 过滤;无 `q` → 纯过滤,键集分页(`key,id`)。 -- **正则安全**:`~` 交给 PG;设 `statement_timeout` 兜底防病态正则;`value` 参数化。 -- 保留/弃用旧 `GET /search`:前端快捷框也走 `POST`(`{q, scope}`);GET 可保留兼容或移除(实现时定)。 -**删 context**:移除 `prts-db` 实体、`UploadEntry`、`EntryDto`、`bulk_upsert` 与 upload 对 context 的读写;上传体若含 `context` 字段则**忽略**(不报错)。Swagger 更新。 +字段: + +- `source:`:指定源语言;tag 先通过共享 `language-tags` canonicalizer 规范化,invalid/非项目 source set 拒绝; +- `source_any`:任意源语言; +- `translation`; +- `key`。 + +操作符仅:`contains`、`not_contains`、`starts_with`、`ends_with`、`equals`。所有 conditions 为 AND,不支持 regex,也不提供 OR 分组。 + +scope 的 JSON 形状: -**工作台**:无新端点;复用 `PUT /entries/{id}`(改译文/状态,权限已校验)。 +- `{ "type": "all" }` 全项目; +- `{ "type": "path", "path": "chapter/01" }` 指定 active 文件夹/路径; +- `{ "type": "file", "file_id": 41 }` 指定任意 active file; +- `{ "type": "current_file", "file_id": 41 }` 明确指定当前编辑 file; +- `{ "type": "current_task", "task_id": 73 }` 指定 task 当前 active files,不限 snapshot IDs。 -## 5. 前端 +file/task ID 沿用现有 PostgreSQL `BIGINT`/Rust `i64`;path 仍为 string,不做 UUID migration。tagged union 拒绝未知字段,因此 `{ "type": "all", "file_id": 41 }`、variant 多余字段、缺 payload、未知 type 和错误 ID 类型都必须返回 400。 -**工作台(`EditorView` + `lib/saveButton.ts`)** -- 重构 `computeSaveButton`:新增 `translate` mode(未翻译+dirty),mode→{label, color, mdiIcon}:翻译=`primary/indigo·mdi-translate`、保存=`blue·mdi-content-save`、检查=`cyan·mdi-check-circle-outline`、审核=`green·mdi-shield-check`、强制=`negative·mdi-flash`、禁用=灰·`mdi-lock`。 -- 右下:`q-btn`(主动作)+ 紧邻 `q-btn-dropdown`/`q-select`(状态,按权限 `disable` 项);**移除**左下状态 combobox。 -- 列表行「正在编辑」头像逻辑加入本人(`editingMap` 含自己 → 显示自己头像)。 +项目 route 先用共享 file-path canonicalizer 规范化 path,再验证 path/file/task 属于 URL project 且对 caller 可见;`current_task` 还验证 task/project 可见。deleted file/folder/ancestor/task 一律排除。服务端不从 session 或其它 query 参数推断 current context。 -**搜索(`EditorView` + 新 `SearchBar` / `AdvancedFilterDialog`,替换现 `SearchFilters`)** -- 列表上方 `SearchBar`:输入框 + 筛选图标;`Enter`=全项目、`Shift+Enter`=当前文件(IME 合成中不触发);结果态显示范围 chip + 清除。 -- 筛选下拉:单选本文件状态(全部/未翻译/…)+「高级筛选…」。 -- `AdvancedFilterDialog`:AND 条件行(字段 select[原文·各源语言 / 原文·全部源语言 / 译文 / 键名] + 操作符 select + 值 + 删除 + 添加条件);范围(全项目 / 指定文件·目录[选择器] / 当前文件 / 当前任务,后两者禁用选择器);多选状态 chips;向量开关(默认关,开启显「语义查询」输入);重置/搜索。调 `POST /search`。 -- **MDI**:`quasar.config` 用 `iconSet: 'mdi-v7'` + 装 `@quasar/extras`;把现有 `name="person/mail/shield/logout/…"` 换成 `mdi-*`。 -- 删 context:移除编辑器原文块的「注释」展示;`api`/类型去 context。 -- i18n 双语(按钮标签、操作符、字段、范围、向量);样式少圆角、状态全称、**无 emoji**。 +path 解析按 segment boundary:精确解析为 active file 时仅该 file;解析为 active folder 时包含 active descendant files,只允许 exact path 或 `folder/` subtree,禁止 naive prefix。歧义、跨项目与 deleted ancestor 稳定拒绝。 -## 6. 性能 +### 5.3 P4 管线与兼容 -- 条件过滤走参数化 SQL;`contains` 命中 P4 的 `source_text/translation/key` trgm GIN;`source:`(`original->>lang`)与 `等于/开头/结尾/正则` 可能走顺扫——大项目下建议限定范围(文件/目录/任务)后再跑,UI 提示;必要时后续加按源语言的表达式索引(未决)。 -- 有排序(q/vector)→ 复用 P4 融合上限 + offset 窗口;纯过滤→键集分页。 -- 正则设 `statement_timeout` 兜底。 +- query 继续进入现有 FTS + trgm + 可选 pgvector + RRF;conditions/scope/states 在召回和取行时一致应用。 +- `vector=false` 是默认,不调用 EmbeddingProvider。provider 缺失/失败时安全降级词法路径。 +- 普通搜索使用规范 `effective_visible`。只有 owner/manager 有 `include_hidden` capability;它只覆盖 hidden,永不包含 tombstone/deleted file/folder;越权请求 true 返回 403。 +- 主源 lexical 重建期间返回稳定 `PROJECT_SEARCH_REBUILDING` 与 job 引用;lexical ready 后恢复 FTS/trgm,即使 embedding 仍 degraded。 +- 旧 `GET /projects/{id}/search` 映射到同一 service:有 i64 `file_id` 时只映射为 `file {file_id}`,否则只映射为 `all`;绝不制造 `current_file/current_task`。保留一个兼容周期,OpenAPI 标 deprecated,响应加入 Deprecation/Sunset;不得维护第二套 SQL。 +- POST 唯一默认排序为 `(rrf_score DESC, entry_id ASC)`,limit 默认 50、允许 1..=100;响应包含 `items` 与 `next_after`。opaque `after` cursor 版本化并绑定 URL `project_id`、canonical query/filter/scope fingerprint、最后 score+id;错误/未知版本/跨 project 或跨查询 cursor 返回 400。新增 sort 必须逐一声明稳定 tie-break。 -## 7. 测试 +## 6. 术语高亮与建议 -- **单元**:`computeSaveButton` 各分支(翻译/保存/检查/审核/强制/禁用 × 角色/dirty/他人编辑);条件→SQL 片段构造(各字段/操作符,参数化,正则转义);范围解析(file/path/task/all)。 -- **db-test**:结构化搜索(单/多条件 AND、各操作符、source:lang vs source_any、范围 file/path/task、states 多选、vector 开关降级);删 context 迁移后上传/CRUD 正常;`PUT` 改状态权限门。 -- **前端**:CI build/lint;Enter/Shift+Enter(含 IME 合成);工作台按钮态快照。 +- 只请求 D 定义的当前主源 active terms;归档或其它 source_lang 不匹配。 +- 对当前 primary source 文本做可定位高亮,并在译文区展示 translation、POS、notes。 +- 点击建议:有 selection 时替换 selection;无 selection 时插入 cursor。 +- 点击只更新本地 translation draft,不发送保存请求、不改变 state、不获取 CP。 -## 8. 涉及文件 +## 7. 前端结构 -迁移 `0010_drop_context.sql`;`prts-db`(entries 去 context、search 结构化查询构造)、`prts-search`(编排接入 conditions/scope/states)、`prts-api`(`routes/search.rs` 改 POST 结构化、`entries.rs`/DTO 去 context、Swagger)、`prts-core`(若状态推进逻辑辅助);前端(`EditorView`、`lib/saveButton.ts`、`SearchBar`、`AdvancedFilterDialog`(替换 `SearchFilters`)、`api`(search POST/去 context)、`quasar.config`(mdi-v7)+ 全站图标名、`i18n`);`docs/architecture.md`。 +- `SearchBar.vue`:快捷输入、IME、安全快捷键、scope chip。 +- `AdvancedFilterDialog.vue`:AND 条件、五种操作符、五种 scope、多状态、include_hidden、vector。 +- `TermSuggestions.vue`:active matches 与插入行为。 +- `saveButton.ts`:纯函数实现真值表;EditorView 只消费 mode/label/color/icon/disabled。 +- 图标统一 MDI;中文字体、2–4px 圆角、浅/深主题和 zh-CN/en 使用共享基础。 -## 9. 红线 / 未决 +## 8. 验收 -- 删 context **不可逆**(已确认);迁移前无需备份(作者定)。 -- 搜索全程参数化;正则/大范围加 `statement_timeout` 与 UI 提示,避免热路径病态查询;键集分页不深翻。 -- 权限:状态设置/推进过 `entry.edit`/`entry.review` 节点;`locked` 词条仅管理/拥有者。 -- **未决(实现时)**:`source:` 等非 trgm 命中的**表达式索引**是否加(先不加,靠范围收窄);旧 `GET /search` 保留兼容或移除;编辑器**术语匹配高亮**是否本阶段附带(默认否,留后续);MDI 切换后逐一核对现有图标名映射。 +- `saveButton` 覆盖 dirty/state/review/manage/presence/version 组合;force + stale version 必须 409。 +- 状态下拉按 capability 灰显,服务端拒绝伪造越权 state。 +- 搜索测试覆盖各 field/op、AND、tagged union 五 variant、i64 ID、path file/folder/segment-boundary、缺 payload/未知 type/未知字段、跨项目/deleted path-file-task、多状态、hidden overlay、vector 降级、稳定 score+id keyset、next_after、limit 边界、cursor tamper/version/fingerprint mismatch、同过滤跨 URL project cursor 400 和 GET file/all 映射。 +- Enter/Shift+Enter 在 composition 期间不触发;普通按键触发正确 scope。 +- context 在代码、API schema、数据库、历史 JSONB 和权威文档中移除;B 首发历史不捕获它,旧上传带字段仍可兼容。 +- 游客公开只读、私有拒绝、无 WS mutation;术语点击不自动保存/改状态。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md b/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md index c88f2c0..34d8a54 100644 --- a/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md +++ b/docs/superpowers/specs/2026-07-01-platform-admin-misc-design.md @@ -1,94 +1,123 @@ -# 平台 / 管理杂项 · 工作流 F — 设计 Spec +# 平台 / 管理杂项 · 工作流 F — 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **F**(末);前置 **A**(管理分区外壳/成员管理位)、CP 展示依赖 P6 计分 | -| 基线 | `master` @ `e7d6d45`(E spec 提交后) | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | A–E spec、CLAUDE.md、蓝图 §7 权限 / §10 CP | +| 历史阶段 | 2026-07-01 大改造工作流 F | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含 mockup):**平台用户管理**——列表(用户名/UID/平台角色/**平台 CP**/加入时间/项目数),点表头排序 + 角色筛选 + 用户名搜索 + 键集分页;**直接添加用户 = 用户名 + 初始密码**(无 SMTP);改平台角色**「低于自身可授」**(不可授同级/更高)。**项目成员管理**(管理→成员管理)——按用户名搜索添加 + 改项目角色 + 移除,新增**「项目 CP」列**;越权约束:管理不可设「拥有者/管理」,仅拥有者可;**拥有者转让不做**(本阶段)。**CP 模型**:项目 CP 与平台 CP(平台 = 各项目 CP 之和),平台 CP 显示于用户管理 + 个人主页,项目 CP 显示于成员管理 + 排行榜;**计分本身属 P6**,F 仅加存储列 + 展示。**删项目**——二次确认 + **后端出题校验**(题型平台后台切「高数 / 简单算术」,高数用整数答案模板)。**字体回退修复**。 - ---- +> 2026-07-01 版本确定了用户管理、成员秩、数学门和字体排查方向。本文件保留 F 工作流与 UI 细节;审计 fail-closed/redaction、job FK 和 purge 顺序只以规范总纲 §2.3–§2.4、§9.3 为准。 ## 1. 范围 -**做**:① 平台用户管理(列表/排序/筛选/搜索/键集分页 + 直接添加 + 改角色不越权);② 项目成员管理(按用户名增删改,含项目 CP 列);③ 删项目二次确认 + 后端数学题门槛(含平台题型开关);④ 为 CP 展示加最小存储(`memberships.cp`)并派生平台 CP;⑤ 字体回退修复。 +工作流 F 完成: + +1. 管理员用户列表、搜索/过滤/排序/键集分页和直接建号; +2. 初始密码提醒与自助修改密码; +3. 项目成员严格授权与唯一 owner 规则; +4. 未来兼容的 exact-tenths CP 整数存储,不实现评分或真实榜单; +5. Redis 数学 challenge、24 小时待删除、倒计时、取消和持久化清除; +6. 字体、MDI、圆角和 zh-CN/en 的最终收口。 + +审计、持久化 job、物化统计和 capabilities 已在地基阶段建立;F 新增 mutation 必须直接接入,不能再留待其它阶段。 + +## 2. 平台用户管理 + +### 2.1 列表与建号 + +- `GET /admin/users?q=&role=&sort=&after=&limit=` 使用稳定 cursor,支持用户名搜索、平台角色过滤及 username/joined/projects 等稳定排序。 +- `POST /admin/users` 接收 username、initial_password 和受秩约束的可选 platform role;密码只以 Argon2id hash 保存。 +- 管理表本轮不增加全为 0 的 CP 列。 + +### 2.2 平台秩 + +平台秩为 `super_admin > admin > maintainer > user`: + +- actor 只能管理当前秩严格低于自己的人; +- 只能授予严格低于自己的角色; +- 不能修改自己、同级或更高用户; +- maintainer 不具备用户管理能力; +- 服务端执行规则,前端只按 capabilities 显示可用选项。 + +### 2.3 初始密码与自助改密 -**不做(后续/别处)**:**CP 计分逻辑**(Levenshtein 累加,属 P6,F 仅显示 0 值列直到 P6 填充);**拥有者转让**;SMTP/邮件邀请(无 SMTP,P7);审计(P5)。 +- 管理员直接建的密码账号设置持久化 `password_change_required=true`。 +- 登录后 App 和个人设置持续提醒,但不阻止正常使用。 +- `PUT /me/password` 验证旧密码、写新 Argon2id hash、清除提醒,并撤销旧 refresh token。 +- API 永不返回初始密码或 password hash;audit payload 也不得保存密码/hash、token 或 challenge answer。建号、改密和失败尝试同步写脱敏审计;审计失败时不提交成功状态/令牌,失败认证也返回通用 503 而非未审计结果。 -## 2. 决策要点 +## 3. 项目成员与唯一 owner -1. 平台角色秩:`super_admin(3) > admin(2) > maintainer(1) > user(0)`;**仅可授秩 < 自身**的角色,且**仅可修改现秩 < 自身**的用户。取代原「`platform.admin.grant` 仅超管」的粗粒度。 -2. 项目角色秩:`owner(3) > manager(2) > reviewer(1) > translator(0)`;`project.member.manage`(owner/manager)者**仅可设秩 < 自身**的角色、**仅可改秩 < 自身**的成员;拥有者不可被改(转让本阶段不做)。 -3. 添加用户:管理员填 用户名 + 初始密码(+ 可选平台角色,默认 user,受秩约束);Argon2id 存储。 -4. CP:加 `memberships.cp`(默认 0,P6 填);**平台 CP = 该用户各 `memberships.cp` 之和**(用户管理列、个人主页);**项目 CP = `memberships.cp`**(成员管理列、排行榜)。 -5. 删项目门槛:`POST …/delete-challenge`(仅 `project.delete`=拥有者)后端出题存 Redis(TTL),`DELETE …` 带 `{challenge_id, answer}` 后端校验通过才删;题型平台设置 `project_delete_challenge_mode = advanced|simple`(默认 advanced)。真正保护靠权限,题目仅防误删。 -6. 用户检索端点复用于「成员管理按用户名添加」与用户管理搜索。 +- `projects.owner_id` 是唯一拥有者。升级时补 owner membership,把其它 owner membership 降为 manager,并产生 audit + notification。 +- 拥有者可添加/改为 manager、reviewer、translator;管理可添加/改为 reviewer、translator。 +- 任何 API 都拒绝把 role 设置为 owner;本轮不提供 owner transfer。 +- manager 不能修改/移除 manager;任何人不能通过成员 API 修改/移除 `owner_id`。 +- 平台管理员不能绕过 owner-only 主源变化或项目删除;其它跨项目能力仍按服务端 capabilities 明确返回。 +- 成员 UI 只消费 `manage_members` 与每行可执行 capability,不比较角色字符串。 -## 3. 数据模型 · 迁移 `0011_member_cp.sql` +## 4. CP 存储边界 -``` -ALTER TABLE memberships ADD COLUMN cp BIGINT NOT NULL DEFAULT 0; -- 项目 CP,P6 填充 --- 平台设置新键(若 settings 为 KV 动态表则无需迁移,插入默认): --- project_delete_challenge_mode = 'advanced' -索引:memberships(user_id)(供平台 CP 求和 / 用户项目数)。 -``` -`users.cp` 单列在 P6 收敛为派生(`SUM(memberships.cp)`);F 起用户管理/主页的平台 CP 走求和,`users.cp` 不再直接展示(P6 决定是否移除)。 +- `users.cp_tenths BIGINT NOT NULL DEFAULT 0` 与 `memberships.cp_tenths BIGINT NOT NULL DEFAULT 0` 是唯一真源;一单位代表 0.1 CP,准确保存未来 reviewer 的 0.3 权重结果。 +- Rust 直接使用 `i64`。不得引入十进制 crate/sqlx decimal feature,也不得保留浮点或十进制 CP 真源。 +- 本轮不在翻译、校对、回滚路径计分,不生成真实排行榜,不增加 0 CP 管理列。排行榜继续显示明确的功能占位。 +- 未来排序直接使用 `cp_tenths`,UI 展示时再四舍五入为整数;回滚/恢复固定 0 CP 且不追扣旧分。 -## 4. 后端(`prts-api` + `prts-db` + `prts-core`,进 Swagger) +## 5. 项目删除 -**权限模型**(`prts-core/permission.rs`):以「角色秩」实现「低于自身可授/可改」;平台与项目各一套秩比较 helper(取代平台仅超管授权的写法)。 +### 5.1 权限与 challenge -**用户管理(平台)** -- `GET /admin/users?q=&role=&sort=&after=&limit=` — 列表:用户名/UID/平台角色/**平台 CP(=SUM memberships.cp)**/加入时间/项目数;键集分页 + 排序(username/cp/joined/projects)+ 角色筛选 + 搜索。平台管理员。 -- `POST /admin/users` `{username, password, role?}` — 建号(Argon2id;`role` 受秩约束)。 -- `PUT /admin/users/{uid}/role` `{role}` — 改角色(秩校验:目标角色 < 自身秩 且 被改用户现秩 < 自身秩,否则 403)。取代/收敛现 `POST /admin/users/{id}/role`。 -- `GET /users/search?q=` — 用户名检索(成员添加 + 用户管理共用;返回精简公开资料)。 +- 只有 `projects.owner_id` 可领取和提交 challenge;平台管理员、额外 owner membership 和 manager 均不可。 +- `ProjectDeleteDialog` 使用三阶段 UX:第一次完整展示删除后果、24 小时等待期和待删除期间只读状态,owner 必须显式继续;第二次要求输入完整项目 slug 并精确匹配;两关通过后才请求 `POST /projects/{id}/delete-challenge`。 +- 两次确认只是前端交互门槛。challenge 仍由服务端执行 owner-only、绑定 user+project、短 TTL、一次性消费与最终答案校验,前端状态不能代替后端授权。 +- Redis challenge 绑定 user+project、短 TTL、一次性消费,防重放。 +- 平台设置在 `advanced`(预定义标准整数高等微积分题)和 `simple`(简单整数算术)之间切换。两种模式答案都是整数,服务端使用模板计算,不 eval 用户输入。 -**成员管理(项目)** -- `GET /projects/{id}/members` — 扩展返回 `cp`(项目 CP);支持按 CP 排序。 -- `POST /projects/{id}/members` `{user_id|username, role}` — 按用户名/ID 加(`project.member.manage` + 秩约束)。 -- `PUT /projects/{id}/members/{user_id}` `{role}` — 改角色(秩约束:目标与被改现秩均 < 自身;拥有者不可被改)。 -- `DELETE /projects/{id}/members/{user_id}` — 移除(现有)。 +### 5.2 待删除 24 小时 -**删项目门槛** -- `POST /projects/{id}/delete-challenge` — 仅 `project.delete`(拥有者);按 `project_delete_challenge_mode` 生成题目(advanced:导数在某点/定积分/极限,**整数答案**模板;simple:`a op b`),Redis 存 `challenge:{id} → answer`(TTL ~5min,绑定 user+project),返回 `{challenge_id, question}`。 -- `DELETE /projects/{id}` — 请求体 `{challenge_id, answer}`;校验 challenge 归属 + 答案(整数精确比对)通过 → 删除(级联);错误 → 400(前端可重取题)。 -- 平台设置 `project_delete_challenge_mode` 在 `GET/PUT /admin/settings` 暴露。 +- 正确答案调用现 DELETE 语义但返回 202;安排事务固定为:先创建/排队 durable `project_purge` job(payload 复制不可变 project id/slug/media/temp keys/deadline)→再把项目更新为 pending,并写 `deletion_scheduled_at=now()+24h`、`deletion_requested_by`、`deletion_job_id`→写 allowlisted audit→提交。job 成功持久化前不得先更新项目,也不立即级联删除。 +- 待删除项目从普通公开/成员列表消失;除唯一 owner 外按不可见处理。 +- 唯一 owner 只能查看只读倒计时和取消入口;所有其它 mutation 返回稳定 `PROJECT_PENDING_DELETION`。 +- 该项目除 `project_purge` 外的 jobs 变 paused;取消后恢复项目可见性和可恢复 jobs,并写 audit。 +- `GET /projects/{id}/deletion` 返回倒计时;`POST /projects/{id}/deletion/cancel` 仅 owner_id。 -## 5. 前端 +### 5.3 到期清除 -- **`AdminView` 用户管理**:表格(列如上,表头排序、角色筛选、搜索、键集分页);`添加用户`对话框(用户名 + 初始密码 + 角色[受秩约束下拉]);行内`改角色`下拉(只列可授项)。 -- **`ProjectManageView` 成员管理子标签**:成员表(+项目 CP 列,可排序);`按用户名添加`(`/users/search` 自动补全)+ 角色下拉(受秩约束);`改角色`/`移除`;拥有者行锁定。 -- **删项目对话框**:红色不可逆警告 + 拉取 `delete-challenge` 显示题目 + 答案输入 → 提交带 `{challenge_id, answer}` 的 `DELETE`;答错重取题。平台后台设置加「删除项目题型」开关。 -- **个人主页**:CP 显示为**平台 CP**(=SUM);(排行榜 A 占位,P6 用项目 CP 实装)。 -- **字体修复**(见 §6)。 -- MDI 图标(E 已定);i18n 双语;样式少圆角、状态全称、无 emoji。 +- job 到期显式清除项目数据库业务内容、媒体、上传临时文件、任务、术语和文件历史。文件树必须先按叶到根删除 entries/files/folders,再删 `file_change_items`/`file_change_sets`,兼容 `deletion_change_set_id ON DELETE RESTRICT`;不得依赖 project cascade 猜测顺序。 +- `audit_log` 不以项目 FK 级联,保留 project id/name/slug 快照、actor、challenge 成功和 purge 元数据。 +- `jobs.project_id` 可空且 `ON DELETE SET NULL`;`projects.deletion_job_id` 可空且非级联。purge payload 保存不可变 project id/slug/media/temp keys/deadline。 +- 到期先锁 job/project、写 audit metadata、detach/cancel 其它 jobs,在同一 DB 事务中执行上述文件历史顺序和其它关系清理后删除 project;提交后才清理外部 media/temp,最后标同一 job succeeded。外部失败只重试同一 job 的 cleanup stage,绝不复活项目。 -## 6. 字体回退修复(排查 + 修) +## 6. 字体与界面收口 -现象:许多字仍回退到 SimSun/宋体,尽管已配 `Noto Sans SC / Noto Serif SC`(`@fontsource`)。排查方向:① `@fontsource` 是否真被打包/加载(Network 查 woff2 是否 200;`main.ts` import 是否被 tree-shake);② `--font-display` 链 `'Noto Serif SC','Songti SC','SimSun',serif` —— 思源宋子集缺字时回退 SimSun;③ Quasar 默认 `$typography-font-family` 覆盖 body 字体;④ 权重/unicode-range 子集未覆盖所需字形。修:确保思源黑/宋按需权重与全 CJK 子集加载;`--font-*` 链移除 SimSun 或换更好回退;必要时 body 统一 `Noto Sans SC` 优先;设置 Quasar 字体变量与全局 `font-family` 一致。验收:常见界面文本不再出现宋体。 +- 删除 `@fontsource/noto-serif-sc` 依赖和所有 serif/SimSun/Songti UI font chain。 +- 普通文本、标题和按钮统一 `Noto Sans SC` 同类 sans;Quasar `$typography-font-family` 同步。 +- `JetBrains Mono` 只用于 code/key/number class,并把 Noto Sans SC 放在其后承接 CJK,避免中文落入系统宋体。 +- 全站 MDI、浅/深主题、方角或 2–4px 圆角;头像圆形不受此限制。 +- 管理、删除、密码、成员和设置文案完整覆盖 zh-CN/en,后端错误按 Accept-Language 返回。 -## 7. 性能 +## 7. 数据与 API -- 用户列表键集分页(`(cp,id)` 或 `(joined,id)` 视排序);平台 CP 用 `SUM(memberships.cp)`(走 `memberships(user_id)` 索引)——用户量大时可考虑物化,暂直算。 -- 用户检索 `q` 走 `users.username` 前缀/trgm。 -- 删题校验命中 Redis,O(1)。 +计划迁移 `0014_admin_delete_cp.sql`: -## 8. 测试 +Task 7.1 先写完整 schema contract RED,覆盖 CP、password reminder、pending deletion/job FK 与索引;同任务把后端 model/DTO/users routes 和前端 api types/Profile/Admin/auth store 的旧 cp:f64 全部转换/移除并 GREEN。Task 7.2 不承担编译遗留,Task 7.3 不回改冻结迁移。 -- **单元**:秩比较授权(平台/项目:可授<自身、不可改≥自身、拥有者不可改);删题模板生成 + 整数答案校验;平台 CP 求和。 -- **db-test**:建用户(重名 409、密码哈希)、改角色越权 403、用户检索;成员按用户名加 + 改角色越权 + 移除 + 项目 CP 返回;`delete-challenge`→`DELETE` 正确答案删除/错误答案 400/无权 403/challenge 过期。 -- **前端**:CI build/lint;字体回退目视/快照核验。 +- `users.password_change_required BOOLEAN NOT NULL DEFAULT false`; +- 旧 `users.cp DOUBLE PRECISION` 经受控 `round(cp*10)` 与一致性断言迁移为 `users.cp_tenths BIGINT NOT NULL DEFAULT 0`,随后删除旧列; +- `memberships.cp_tenths BIGINT NOT NULL DEFAULT 0`; +- `projects.deletion_scheduled_at`、`deletion_requested_by`、`deletion_job_id`; +- `deletion_job_id` 的 nullable 非级联关系,以及项目删除 challenge 模式等本阶段运行时设置; +- 待删除扫描、用户列表 cursor 和未来 exact-tenths CP 排序索引。 -## 9. 涉及文件 +`jobs` 及其 nullable `project_id REFERENCES projects(id) ON DELETE SET NULL` 由不可变 foundation 迁移 `0007_audit_jobs.sql` 一次性建立;`0014` 不得回改或重复接管该约束。 -迁移 `0011_member_cp.sql`;`prts-core/permission.rs`(角色秩 + 授权 helper);`prts-db`(users 建号/检索/列表、memberships cp/角色改、平台 CP 求和);`prts-api`(`routes/admin.rs` 用户管理、`routes/users.rs` search、`routes/projects.rs` 成员增改+CP、delete-challenge + DELETE 校验、settings 题型键、Swagger);`prts-search`/Redis(challenge 存取);前端(`AdminView` 用户管理、`ProjectManageView` 成员管理、删项目对话框、`ProfileView` 平台 CP、`api`、`i18n`、`theme.scss`/`main.ts` 字体修复、`quasar-variables.sass`);`docs/architecture.md`。 +所有管理员、成员、密码、challenge、schedule、cancel、purge 端点进入 Swagger 并返回 capabilities/稳定错误码。 -## 10. 红线 / 未决 +## 8. 验收 -- 授权严格按秩,**不可越权**(可授<自身、不可改≥自身);建号密码 Argon2id、仅服务端。 -- 删项目:权限(仅拥有者)为真边界,数学题仅防误删;challenge 绑定 user+project + TTL,防重放。 -- 键集分页不深翻;检索参数化。 -- **未决(实现时)**:`memberships.cp` 与 P6 最终 CP 存储是否需迁移调整(F 先占位);`users.cp` 何时移除(P6);高数题模板集合与难度;平台 CP 求和是否需缓存/物化(用户量决定);拥有者转让(暂不做,日后单列)。 +- 平台秩覆盖低于/同级/高于/自己;项目矩阵覆盖 owner/manager/reviewer/translator 与 owner 输入拒绝。 +- 升级数据测试证明只有 owner_id 保留 owner,额外 owner 被降级并收到通知/审计。 +- 建号、非阻断提醒、旧密码验证、改密、refresh 撤销与 Argon2id 有集成测试。 +- CP schema 以 tenths integer 精确往返 0.1/0.3,不存在 decimal/f64 依赖;保存和回滚路径保持 0,不出现虚假榜单/列。 +- 前端测试覆盖第一次后果/24h/只读确认、完整 slug 精确匹配(大小写与字符均精确)、未完成任一确认不得请求 challenge;challenge API 覆盖两题型、绑定、过期、重放、错误答案、owner-only 与正确答案 202。待删除覆盖列表隐藏、只读、jobs pause/cancel、nullable FK、DB-first purge、外部清理失败同 job 重试且项目不复活。 +- 字体构建产物不包含 Noto Serif SC,computed style 不落入 SimSun/serif;MDI、主题、圆角、i18n 通过前端验证。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md b/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md index eafdbc7..4bdd8bd 100644 --- a/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md +++ b/docs/superpowers/specs/2026-07-01-project-page-restructure-design.md @@ -1,103 +1,87 @@ -# 项目页重构 · 工作流 A(骨架 + 信息 + 文件)— 设计 Spec +# 项目页重构 · 工作流 A(骨架 + 信息 + 文件)— 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **A**(地基);后续 B 上传/文件管理 · C 任务 · D 术语 · E 编辑器 · F 平台杂项 | -| 基线 | `master` @ `6b19657` | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | CLAUDE.md、`plan/26-06-28-init_system.md`、`docs/architecture.md` | +| 历史阶段 | 2026-07-01 大改造工作流 A | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含可视化 mockup 逐张过):**骨架方案 A**(左侧栏含身份,默认落地「信息」);分区顺序 `信息·文件·任务·术语·排行榜·下载·管理`;**编辑器不占分区**,从文件页点文件进入;**信息页只读**,一切编辑集中「管理→项目设置」;**主源语言**限源语言之一(新字段 `primary_source_lang`);**头像上传到磁盘存储**(`MediaStore` 可插拔,本地磁盘 + Docker 卷);**文件浏览器方案 1**(面包屑单栏),状态筛选按「是否仍含某状态词条」,排序 名称/进度/词条数/最近更新;**slug 可编辑**(不在 URL 内,仅展示 + zip 文件名,校验唯一即可)。样式:**少用圆角**、词条状态**用全称**(未翻译/已翻译/有疑问/已检查/已审核)、计数用「万」。 - ---- +> 本文件保留 2026-07-01 的工作流拆分与交互背景。精确主源状态矩阵、发布门、有效可见性和统计真值只在规范总纲 §3–§4 定义;本文不构成第二份生命周期规范。 ## 1. 范围 -**做**:把当前单页 `ProjectDetailView` 拆成「侧栏骨架 + 分区」的项目工作区外壳,落地其中 4 个分区(信息 / 文件 / 下载 / 管理→项目设置)+ 2 个占位(任务 / 排行榜,术语分区亦占位)。新增 2 个项目字段(主源语言、头像)与其所需的媒体存储、每文件进度数据。另附带全局顶栏**界面语言切换器**(与 A 的 i18n/外壳同批做)。 - -**不做(属后续工作流,本阶段仅占位或不动)**:任务功能实现(C)、术语功能实现(D)、管理→成员管理 / 文件管理(B/E)、排行榜真实榜单(P6)、编辑器改造与搜索重构(E)、删除词条 context(E)、平台用户管理与删项目数学门(F)、字体回退修复(F)。**编辑器 `/editor` 本阶段不改**,仅调整「从文件页进入」的入口与传参。 - -## 2. 决策要点(已确认,逐条落地) - -1. 外壳 = 左侧栏(顶部小头像+项目名+语言对)+ 右侧分区内容区;默认 `信息`。 -2. 分区固定 7 项,顺序 `信息·文件·任务·术语·排行榜·下载·管理`;`任务/术语/排行榜` 渲染占位(「该功能在后续阶段实现」)。 -3. 信息页**只读**;无「编辑信息」按钮。 -4. 编辑器从**文件页点文件**进入(`/projects/:id/editor?file=`);侧栏不放翻译直达。 -5. `主源语言 primary_source_lang`:限 `source_langs` 之一;`null` 时隐含取 `source_langs[0]`(与现搜索一致)。 -6. 头像:上传**磁盘存储**,`MediaStore` trait(本地磁盘实现,配置化 media 根目录 + Docker 卷),日后可换对象存储;未设时前端按项目名首字生成默认块。 -7. 文件浏览器**方案 1**:面包屑 + 当前目录单栏列表;搜索文件名/目录名(前端过滤,命中即拍平显示完整路径);排序 名称/进度/词条数/最近更新;状态筛选「含某状态词条」= 全部 / 含未翻译 / 含有疑问 / 已译完 / 已审完;每文件与每文件夹显示进度;点文件进编辑器。维护操作(建夹/上传/移动/删除/历史)**不在此**,留「管理」。 -8. slug 可编辑,校验唯一。 -9. 样式令牌:小圆角;状态标签全称;数字用「万」;进度定义沿用 `进度 =(总数 − 未翻译)/ 总数`,分段条按 5 状态。 - -## 3. 前端设计 +工作流 A 建立项目工作区外壳、只读信息页、文件浏览、下载和项目设置,并提供后续 B–F 使用的路由、文件浏览器、能力 DTO、物化统计、主源语言与媒体接口。 -**路由(hash,数字 id 不变)**:`ProjectShell` 承载嵌套子路由—— -- `/projects/:id(\d+)` → **302 到 `…/info`**(兼容旧书签;旧 `ProjectDetailView` 退役后此路径仍可达) -- 子路由:`info` `files` `tasks` `glossary` `leaderboard` `download` `manage`(`` 挂在 Shell 内容区) -- `/projects/:id(\d+)/editor` 保持独立**全屏**路由(不套 Shell),由文件页跳入并带 `?file=`。 +固定分区顺序为 `信息·文件·任务·术语·排行榜·下载·管理`。任务与术语在 C/D 完成后再开放,开放时必须是可用成品;排行榜是本轮唯一明确保留的功能占位。编辑器保持独立全屏路由。 -**组件** -- `ProjectShell.vue`:拉取 project 详情(复用 `GET /projects/{id}`);侧栏身份 + 导航;响应式——窄屏侧栏折叠为顶部下拉/抽屉。分区高亮由当前子路由决定。 -- `ProjectInfoView.vue`(只读):头像(`GET …/avatar` 失败→默认块)+ 名称 + 公开/私有;语言行(源语言 chips,主源标 `主源` → 目标);简介;总进度(分段条 + 全称图例 + 「万」计数 + 「进度 X%」头条);统计卡(文件/词条/成员;术语数待 D,本阶段不显示)。 -- `ProjectFilesView.vue`(方案 1):顶部搜索框 + 排序下拉 + 状态筛选下拉;面包屑;当前目录行列表(文件夹在前,含聚合进度;文件含进度条 + 词条数);点文件夹下钻/面包屑返回;**搜索态**拍平全树匹配项显示完整路径。全部前端过滤/排序,数据来自扩展后的 tree 接口。空态/加载态。 -- `ProjectDownloadView.vue`:迁移现有导出(`GET …/export` → `{slug}.zip`),保留下载按钮 + 说明。 -- `ProjectLeaderboardView.vue` / `ProjectTasksView.vue` / `ProjectGlossaryView.vue`:占位组件(统一「后续阶段实现」空态)。 -- `ProjectManageView.vue`:子标签 `项目设置`(本阶段)·`成员管理`·`文件管理`(后二者占位)。**项目设置表单**:头像(上传/移除 + 提示 ≤512KB、PNG/JPG/WebP、方形建议、客户端预压缩)、名称、slug(可编辑,失焦查重)、简介、可见性(公开/私有)、源语言(多 chip 增删,BCP-47)、主源语言(下拉,选项 = 当前源语言)、目标语言(单,BCP-47)、保存/重置。仅 `project.manage` 可见可用。 -- 现 `ProjectDetailView.vue` 拆解退役:文件树逻辑迁 `ProjectFilesView`,进度/统计迁 `ProjectInfoView`,导出迁 `ProjectDownloadView`,删除/上传/成员相关入口迁「管理」(占位挂钩,B/E 实现)。 +旧项目页的上传与其它已存在能力在替代入口就绪前继续可用,不能因拆 Shell 而回退。 -**附带 · 界面语言切换器(全局顶栏)**:`App.vue` 顶栏右侧加 🌐 下拉(变体 2,可扩展多语言),切换即时设 `i18n.global.locale` + 存 `localStorage`(键 `prts.locale`;初始化读取,缺省跟随浏览器→回落 zh-CN);API 客户端按当前 locale 带 `Accept-Language`,后端本地化消息同步。 +## 2. 已确认决策 -**i18n**:新增分区/字段/状态筛选/排序文案,zh-CN + en 双语。**样式**:抽 `--radius`(小)与状态色令牌到 `theme.scss`,状态标签常量统一全称。 +1. `/projects/:id` 默认进入 `info`;信息页只读,编辑集中在 `manage`。 +2. 编辑器从文件或任务文件进入 `/projects/:id/editor?file=`,不嵌套在 Shell。 +3. slug 可编辑、保持唯一,不进入项目 URL;下载文件名使用 slug。 +4. 项目简介保存 Markdown 源文并通过共享净化组件展示。 +5. 交互 UI 只用 Vue 3 + Quasar,保留浅/深主题,图标统一 MDI,普通控件为方角或 2–4px 小圆角。 +6. 中文 UI 只使用 Noto Sans SC 同类无衬线字体;JetBrains Mono 仅用于代码、键和数字,并以 CJK sans 承接中文;不使用宋体或其它衬线 UI。 +7. 前端完整 zh-CN/en,API 请求携带当前 locale 的 `Accept-Language`。 +8. 公共项目游客可浏览文件并进入只读编辑器;游客不能保存、改状态或发起协作动作。私有项目限获授权主体。 -## 4. 后端设计 +## 3. 主源语言 -**迁移 `0007_project_meta.sql`** -``` -ALTER TABLE projects - ADD COLUMN primary_source_lang TEXT, -- 须 ∈ source_langs;null=隐含 source_langs[0] - ADD COLUMN avatar_path TEXT, -- MediaStore 相对键,含扩展名;null=未设 - ADD COLUMN avatar_updated_at TIMESTAMPTZ; -- 作 ETag / 缓存击穿 -``` -(`primary_source_lang` 的「∈ source_langs」在应用层校验:数组成员随 source_langs 变动,跨列 CHECK 维护成本高。) +- ready 项目的 `source_langs`、`primary_source_lang` 与 `target_lang` 先经共享 `language-tags` canonicalizer:language 小写、script Titlecase、region 大写,variant/extension/private-use 按 parser 规范序列化;无效 tag 与规范化后重复拒绝。primary 是非空 canonical BCP-47 且必须属于最终 `source_langs`。单源创建自动选择唯一值;多源创建必须显式提供。legacy unresolved 行只允许在 repair state 条件约束下暂存,不能进入普通管理/search。 +- 不存在 `null → source_langs[0]` 的永久回退规则。旧项目只在迁移时以当前首个源语言回填一次。 +- `0008_workspace_meta_stats.sql` 与 `0009_primary_source_search.sql` 必须随同一 foundation release 部署;legacy project arrays/target/primary、entry original keys、term source_lang 与用户语言偏好先由 durable batched repair canonicalize。冲突/无效数据进入 `needs_language_resolution`,search 与普通语言 edits 保持 gated;owner 用专用 UI/API 解决,platform admin 只有无正文诊断/retry。trigger/function、repair-ready backfill/reconciliation 与 lexical worker readiness 完成前,不开放非首主源或更新路由。任何独立发布都不得让 API 使用新字段而 search 仍读取 `source_langs[1]`。 +- 已有项目只有 `projects.owner_id` 能更改主源;平台管理员不能代替。相同值直接成功,不触发 7 天冷却或新 job。 +- 真正变化从请求被接受时开始 7 天冷却;下一次变化还要求没有 active/unresolved failed lexical 或 embedding job。失败只重试原阶段 job。 +- 移除当前主源时必须在同一保存提交替代主源。已有词条的项目不得修改目标语言。 +- 接受变化后立即切换主源并暂停 search/TM。独立 lexical job 完成后恢复 FTS/trgm,再创建/运行 embedding job;provider 禁用/未配置标 degraded/skipped,不阻塞词法。配置了 provider 的失败有界退避,耗尽后手动重试同一 embedding job。 +- 项目/job API 按规范总纲 §4.3 返回两个阶段各自的状态、job id、进度、重试和错误/降级原因。D 落地后,同一事务还会归档旧主源术语并激活新主源术语。 -**每文件进度**:扩展 `GET /projects/{id}/tree`,文件节点新增 `state_counts: {state→count}`(一次 `SELECT file_id, state, COUNT(*) FROM entries WHERE project_id=$1 GROUP BY file_id, state`,与项目级 state_counts 口径一致——计全部词条)。文件夹聚合在前端做。索引:现有 `entries(file_id)` 足够;必要时加 `entries(project_id, file_id, state)`。20w 量级单次分组扫描可接受,无需实时 COUNT 热路径。 +## 4. 头像、文件浏览与统计 -**媒体存储**:`MediaStore` trait(`put/get/delete(key)`)+ `LocalDiskStore`(根目录来自 `prts-common` 配置 `PRTS__MEDIA__DIR`,默认 `./data/media`)。放 `prts-api::media` 模块(暂不新起 crate;日后接对象存储再提升)。avatar key = `projects/{id}/avatar.`。 +### 4.1 头像 -**端点(`prts-api`,全部进 utoipa/Swagger)** -- `POST /projects/{id}/avatar`(`project.manage`;multipart 单图;校验 MIME ∈ {png,jpg,webp} 且 ≤512KB;写 MediaStore + 置 `avatar_path/avatar_updated_at`)。 -- `DELETE /projects/{id}/avatar`(`project.manage`;删文件 + 清列)。 -- `GET /projects/{id}/avatar`(**公开读**:流式返回图 + `Cache-Control` + `ETag=avatar_updated_at`;未设→404,前端回落默认块。取公开是因 `` 不便带 JWT,且头像本身低敏;若作者要私有项目头像也鉴权,则前端改带鉴权 blob 拉取——见 §9)。 -- 扩展 `PUT /projects/{id}`:接受 `primary_source_lang`(校验 ∈ 最终 `source_langs`,否则 400)与 `slug`(变更时查重,冲突 409)。 -- axum 启用 `multipart` feature(无重依赖);上传大小限流。 -- **跨域/部署**:`` 跨源加载头像无需 CORS,但 nginx 须正确反代该端点并回传 `Content-Type`/缓存头;若日后前端改 `fetch` blob 则需 `Access-Control-Allow-Origin`。P0 现放开 CORS,P7 收紧时白名单勿漏头像端点。 +- 使用 `MediaStore` + 本地 Docker volume;默认 key 为 `projects/{id}/avatar.webp`。 +- 前端 Quasar 对话框配合原生 canvas 做 1:1 裁剪,目标输出 256×256 WebP。 +- 服务端校验真实签名与可解码内容,要求正方形、宽高各 `<=1024`、总像素 `<=1,048,576`、编码体积 `<=512KB`,不信任 MIME/扩展名。 +- 公开项目头像公开读取;私有项目头像遵循项目可见性与认证,前端以鉴权 blob 展示。 +- 写入、替换和移除都写追加式审计;项目清除时清理媒体。 -**审计**:`audit_log` 属 P5 尚未落地——本阶段编辑/上传处**预留** audit 调用点(TODO 注释),P5 接入时补。 +### 4.2 文件浏览器 -## 5. 权限 +- 面包屑 + 当前目录单栏;文件夹在前;支持名称搜索、名称/进度/词条数/最近时间排序和状态筛选。 +- 文件夹统计由前端聚合后代文件物化统计。文件夹最近时间为后代文件 `updated_at` 最大值;空文件夹使用 `created_at`。 +- 点文件进入编辑器;维护操作由 B 在管理区加入。 -- 分区可见性:信息/文件/下载/排行榜 = 任何可读该项目者(私有项目限成员);管理 = `project.manage`(拥有者/管理)。 -- 头像写、项目设置写 = `project.manage`;头像读随项目可见性。 -- 沿用现 `crate::auth::project::load` + 权限节点,不新增节点。 +### 4.3 统计口径 -## 6. 性能 +- `project_stats` 与 `file_stats` 按规范总纲 §3 的 `effective_visible` 物化可见总数和五状态计数。详情和 tree 正常读路径禁止实时扫描 entries 做 `COUNT(*)`/`GROUP BY`。 +- 普通统计同时排除 hidden、entry tombstone、deleted file 和 deleted ancestor folder。`include_hidden` 不穿透删除;file/folder restore 也不清除 reupload tombstone。 +- 总数为零显示“—”。进度仍定义为 `(visible_total - visible_untranslated) / visible_total`,状态分段使用五种完整状态名。 -- 每文件进度:单次 `GROUP BY file_id,state`,走索引;文件数级别结果(百级),前端聚合文件夹。 -- 文件浏览器搜索/排序/筛选**纯前端**(tree 一次性加载,百级文件可控);不引入新分页。 -- 头像小图(≤512KB,客户端预压缩至 ≤256px),流式返回 + 缓存。 +## 5. API 与前端边界 -## 7. 测试 +### 后端 -- **单元**:slug 唯一校验;`primary_source_lang ∈ source_langs` 校验(含 source_langs 收缩使原主源失效的分支);`LocalDiskStore` put/get/delete;每文件进度聚合函数;进度百分比与分段计算。 -- **db-test**:`PUT /projects` 带主源语言/改 slug(唯一冲突 409);tree 返回 per-file `state_counts`;头像 POST→GET→DELETE 生命周期与权限(非 manage 403、私有项目非成员读 403/404)。 -- **前端**:CI build/lint;关键组件渲染与响应式退化(交 CI)。 +- 项目 DTO 增加 `primary_source_lang`、搜索重建状态、头像元数据、物化统计和 `capabilities`。 +- `POST/DELETE/GET /projects/{id}/avatar` 全部进入 Swagger。 +- 项目更新在事务内校验 slug、canonical 语言、owner-only 主源变更、目标语言限制并写审计;`needs_language_resolution` 只允许 owner resolution endpoint,不允许普通 update 旁路。 +- `GET /projects/{id}/tree` 返回文件物化统计和时间,不计算文件夹聚合。 +- 所有能力由服务端计算;`change_primary_source` 只给 owner_id,`include_hidden` 只给 owner/manager。 -## 8. 涉及文件 +### 前端 -迁移 `0007_project_meta.sql`;`prts-common`(media 配置)、`prts-api`(`media` 模块、`routes/projects.rs` 头像三端点 + PUT 扩展、tree 扩展、`mod.rs`、Swagger)、`prts-db/projects.rs`(新列读写、slug 查重、per-file 进度查询);`deploy/`(media 卷 + `.env.example` `PRTS__MEDIA__DIR`);前端(`router` + 旧路由 302、`ProjectShell` + 6 分区视图、`ProjectDetailView` 退役拆解、`App.vue`(语言切换器)、`i18n/index.ts`(locale 持久化/初始化)、`api`(avatar/tree 类型 + `Accept-Language`)、`theme.scss` 圆角/状态令牌);`docs/architecture.md`(补 §项目工作区)。 +- `ProjectShell.vue` 承载嵌套路由和项目上下文;窄屏使用 Quasar drawer/menu。 +- `ProjectInfoView.vue` 展示头像、语言、净化 Markdown、物化进度和统计。 +- `ProjectFilesView.vue` 使用共享 `ProjectFileBrowser`;`ProjectDownloadView.vue` 保持导出。 +- `ProjectManageView.vue` 编辑项目设置并展示 language repair/resolution、主源冷却/重建进度;owner resolution dialog 显式选择 canonical mapping/冲突值,控件只依据 capabilities 显隐或禁用。 +- locale 持久化到 `prts.locale`,初始化顺序为 localStorage → 浏览器 → zh-CN。 -## 9. 红线 / 未决 +## 6. 验收 -- 密钥/媒体根仅经 env;上传严格校验类型与大小;sqlx 参数化;私有项目资源鉴权。 -- 不用大 OFFSET、不加实时 `COUNT(*)` 热路径;进度用一次性分组。 -- **已确认(原未决)**:media 根 `./data/media` + Docker 卷;头像**公开读、不鉴权**(注意跨域,见 §4);`/projects/:id` **302→`…/info`** 兼容旧书签。实现中如遇其它细节不明再问作者(蓝图 §8)。 +- foundation 发布门、主源创建/更新、7 天冷却、lexical/embedding 分阶段重试、目标语言锁定均有单元与数据库测试。 +- 头像覆盖伪 MIME、错误尺寸、大小、公开/私有读取和替换删除生命周期。 +- 随机状态/hidden/deleted 变化后,物化统计等于离线校验值;项目详情 SQL 不以 entries 聚合为正常读取。 +- 路由、浅/深主题、MDI、字体、zh-CN/en、Markdown 净化、游客只读与 capability 显隐有前端测试。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-01-tasks-design.md b/docs/superpowers/specs/2026-07-01-tasks-design.md index c9d6bc5..528afd2 100644 --- a/docs/superpowers/specs/2026-07-01-tasks-design.md +++ b/docs/superpowers/specs/2026-07-01-tasks-design.md @@ -1,106 +1,102 @@ -# 任务(Tasks)· 工作流 C — 设计 Spec +# 任务(Tasks)· 工作流 C — 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **C**;前置 **A**(任务分区外壳 + 文件列表组件 + 每文件状态计数) | -| 基线 | `master` @ `d0177bc`(B spec 提交后) | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | A spec、B spec、CLAUDE.md | +| 历史阶段 | 2026-07-01 大改造工作流 C | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含可视化 mockup):任务 = 一组项目文件 + 标题 + **Markdown 介绍** + 进度;进度用**快照基线**——加入文件时快照该文件「未翻译数」为分母,`进度 = Σ max(0, 基线未翻译 − 当前未翻译) / Σ 基线未翻译`;文件**可属多任务**(多对多);**独立「管理任务」界面**统一设 标题/介绍/增删文件(勾选项目文件加入、勾文件夹=其下整体、加入即快照);任务详情的文件用**文件管理器样式**展示(下钻、点文件进编辑器);管理权限**复用 `project.manage`**(owner/manager),其余成员只读;创建流程 = 新建 → 进管理界面 → 保存。 +> 2026-07-01 版本确认了标题、Markdown、多对多文件与快照进度方向。精确有效可见性与 `current_task` tagged scope 由规范总纲 §3、§6、§8.2 定义;本文保留任务工作流细节。 ---- +## 1. 范围与权限 -## 1. 范围 +任务包含标题、Markdown 介绍、多对多文件关系、基线快照和进度。owner/manager 通过 `manage_tasks` capability 创建、修改、删除、增移文件;其它项目可见者只读。 -**做**:任务分区(列表 / 详情 / 管理界面);`tasks` + `task_files` 数据模型与端点;快照基线进度计算;Markdown 介绍(渲染 + 编辑预览);任务文件的文件管理器式展示与勾选式增删。 +Markdown 只保存源文,使用共享净化组件渲染。所有 mutation 与文件集合变化写 audit。 -**不做(后续/别处)**:「在此任务翻译」的**搜索/过滤**实现(属 **E**,本阶段仅留入口 + 传 `task_id`);任务与 CP/排行榜关联(无);任务级新权限节点(复用 `project.manage`);任务的历史/审计(P5)。 +## 2. 基线快照 -## 2. 决策要点 +### 2.1 加入文件 -1. 进度 = 快照基线:`task_files.baseline_untranslated` 加入时快照;`完成 = Σ_file max(0, baseline − 当前未翻译)`;`进度 = 完成 / Σ baseline`(`Σbaseline=0` → 视为 100%)。当前未翻译 = 该文件 `state='untranslated'` 实时计数(复用 A 的每文件状态计数口径)。 -2. 文件↔任务**多对多**(`task_files` 联结表,主键 `(task_id, file_id)`)。 -3. 「管理任务」界面统一保存:标题 + Markdown 介绍 + 文件成员集合(勾选)。保存以**期望成员集合**协调:新增的快照基线、取消的移除、既有的**保留原基线**。 -4. 权限:查看=可查看项目者;建/改/删/增移文件 = `project.manage`。 -5. Markdown 介绍:存**源文**(TEXT),前端 `markdown-it` 渲染 + `DOMPurify` 净化。 +文件加入任务时,在同一事务创建新的 `task_files.id`,并把当时满足以下条件的 entry id 写入 `task_baseline_entries`: -## 3. 数据模型 · 迁移 `0008_tasks.sql` - -``` -tasks( - id BIGINT IDENTITY PK, - project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, - title TEXT NOT NULL DEFAULT '', - description TEXT NOT NULL DEFAULT '', -- Markdown 源文 - created_by BIGINT REFERENCES users ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -) -task_files( - task_id BIGINT NOT NULL REFERENCES tasks ON DELETE CASCADE, - file_id BIGINT NOT NULL REFERENCES files ON DELETE CASCADE, - baseline_untranslated INTEGER NOT NULL, -- 加入时快照 - added_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (task_id, file_id) -) -索引:tasks(project_id);task_files(file_id)(供「文件删除级联」与反查)。 +```text +file_id = joined_active_file +state = untranslated +effective_visible(entry, file, include_hidden=false) ``` -文件删除 → `task_files` 级联移除(该文件退出所有任务)。 -## 4. 后端(`prts-api` + `prts-db`,进 Swagger) +不能只保存数量。保存 ID 后才能正确处理隐藏、删除、恢复、状态回退和 key 重传。 + +### 2.2 有效分母与完成数 + +- 有效分母 = snapshot IDs 中当前满足规范 `effective_visible(..., false)` 的数量。 +- 完成数 = 有效分母中当前 state 不等于 `untranslated` 的数量。 +- hidden、entry tombstone、file/folder delete 使 snapshot entry 暂时离开分母;只有符合各自恢复规则时才返回。file/folder restore 不清除 reupload tombstone。 +- 完成 entry 退回 `untranslated` 时,完成数和百分比同步回退。 +- 文件加入后的新 entry 不进入旧 snapshot。 +- 从任务移除文件会删除该 task_file 与 snapshot;重新加入创建新 task_file id 和新 snapshot。 +- 有效分母为 0 时,任务显示 100% 与“无需处理”。文件、文件夹和项目自身的空进度仍显示“—”。 + +## 3. 数据模型 + +计划迁移 `0011_tasks.sql`: + +```text +tasks + id, project_id, title, description_markdown, created_by, created_at, updated_at + +task_files + id, task_id, file_id_snapshot BIGINT NOT NULL, + live_file_id BIGINT NULL REFERENCES files(id) ON DELETE SET NULL, + added_by, added_at + UNIQUE(task_id, live_file_id) WHERE live_file_id IS NOT NULL -**`prts-db/tasks.rs`**:`create`、`get`、`list_with_progress`、`update_meta`、`set_files`(协调成员:新增项 `baseline = 当前未翻译计数`,移除缺席项,保留既有基线)、`delete`、`list_files_with_progress`(任务详情用:每文件 baseline + 当前未翻译 + 完成度)。 +task_baseline_entries + task_file_id REFERENCES task_files(id) ON DELETE CASCADE, + entry_id_snapshot BIGINT NOT NULL, + live_entry_id BIGINT NULL REFERENCES entries(id) ON DELETE SET NULL + PRIMARY KEY(task_file_id, entry_id_snapshot) -**进度查询**(列表一条 SQL 汇总): -```sql -SELECT t.id, COALESCE(SUM(tf.baseline_untranslated),0) AS base, - COALESCE(SUM(GREATEST(0, tf.baseline_untranslated - COALESCE(cur.untrans,0))),0) AS done -FROM tasks t -LEFT JOIN task_files tf ON tf.task_id = t.id -LEFT JOIN (SELECT file_id, COUNT(*) FILTER (WHERE state='untranslated') AS untrans - FROM entries WHERE project_id=$1 GROUP BY file_id) cur ON cur.file_id = tf.file_id -WHERE t.project_id=$1 GROUP BY t.id; +task_stats + task_id, effective_baseline, completed, updated_at ``` -**端点** -- `GET /projects/{id}/tasks` — 列表(title、description、base、done、file_count)。可查看项目者。 -- `POST /projects/{id}/tasks` `{title?, description?}` — 建空任务,返回 id(`project.manage`)。 -- `GET /projects/{id}/tasks/{task_id}` — 详情(meta + 进度 + 文件列表,每文件 baseline/当前未译/完成度)。 -- `PUT /projects/{id}/tasks/{task_id}` `{title, description, file_ids:[i64]}` — 统一保存(改 meta + 协调成员)。`project.manage`。 -- `DELETE /projects/{id}/tasks/{task_id}` — 删除(`task_files` 级联)。`project.manage`。 -- 「在此任务翻译」不新增端点:前端带 `task_id` 进编辑器,过滤逻辑属 E(E 的搜索支持「在当前任务」)。 +`task_stats` 由 task file、entry state/hidden/tombstone 和 file/folder deletion exposure 变化集合更新。任务列表正常读路径不扫描项目全部 entries 做实时 `COUNT(*)`;离线 rebuild/verify 使用规范 effective-visible predicate。 -## 5. 前端 +文件软删除不会删除 task_files 或 snapshot,因此恢复后 live relationship 与分母自然返回。文件/entry 永久清除时,`live_file_id/live_entry_id` 由 FK 置 NULL,snapshot ID 仍保留以解释历史基线;NULL live refs 永久退出有效分母。显式从任务移除文件才删除 task_file,并由 CASCADE 删除该成员关系自己的 baseline rows。 -**路由**:`/projects/:id/tasks`(列表)、`/projects/:id/tasks/:taskId`(详情浏览)、`/projects/:id/tasks/:taskId/manage`(管理,owner/manager)。新建 → `POST` → 跳 `…/manage`。 +## 4. API -- **`TaskListView`**:任务卡(标题、介绍摘要、单色进度条 = done/base、文件数)+ `新建任务`(manager)。 -- **`TaskDetailView`**(浏览):标题 + **渲染 Markdown 介绍** + 进度条(`done/base`,`base=0`→100%)+ 文件**文件管理器样式**(复用 A 面包屑列表,数据=任务文件子集;每文件显示 基线/当前未译/完成度;点文件进编辑器)+ `管理任务`(manager)/`在此任务翻译` 入口。 -- **`TaskManageView`**(manager):标题输入;**Markdown 介绍**编辑器(源 + 实时预览);**文件勾选**——复用 A 面包屑文件浏览器加勾选框,勾=加入(保存时对新增项快照基线)、勾文件夹=其下文件整体加入、取消=移出;`保存`(PUT)/`删除任务`。 -- **`api` tasksApi**:list/create/get/update(含 file_ids)/remove。 -- **依赖**:`markdown-it` + `dompurify`(轻量、标准)用于介绍渲染;封装 `` / `` 组件(术语 D 若需也可复用)。 -- i18n 双语;样式少圆角、状态全称;进度条单色(完成比例,非 5 状态)。 +- `GET /projects/{id}/tasks`:键集列表,返回标题、介绍摘要、有效分母、完成数、百分比、文件数和 capabilities。 +- `POST /projects/{id}/tasks`:创建任务;owner/manager。 +- `GET /projects/{id}/tasks/{task_id}`:详情、净化所需源 Markdown、统计和当前文件集合。 +- `PUT /projects/{id}/tasks/{task_id}`:原子保存标题、Markdown 与期望 file_ids;新增文件建立 snapshot,既有文件保留 snapshot,缺席文件移除。 +- `DELETE /projects/{id}/tasks/{task_id}`:删除任务与 snapshot;写 audit。 -## 6. 性能 +所有端点进入 Swagger,使用稳定错误码与 Accept-Language 本地化消息。前端不从 role 推断管理权。 -- 列表进度一条聚合 SQL(按 `entries(project_id)` + `file_id` 分组);任务数量级小,可接受,无需物化。 -- 详情每文件进度同口径;文件浏览器前端过滤(任务文件子集,规模小)。 -- `set_files` 协调在单事务内完成(新增快照 + 删除)。 +## 5. 当前任务搜索 -## 7. 测试 +“在此任务翻译”进入编辑器并传 task_id。E 发送 `{ "type": "current_task", "task_id": ... }`,项目 search route 验证 task 属于 URL project 且 task/project 对 caller 可见,再使用任务当前 active `task_files` 搜索 effective-visible entries: -- **单元**:进度公式(含 `base=0`→100%、`current>baseline` 钳位 0);成员协调(新增快照/移除/既有保留基线)。 -- **db-test**:建任务、PUT 改 meta + file_ids(新增项 baseline = 当时未翻译数、移除生效、既有基线不变)、列表进度 SQL 正确、删除级联、权限门(非 manage → 403)、文件删除→task_files 级联。 -- **前端**:CI build/lint;Markdown 渲染净化(XSS 输入被清理)。 +- 不限于 snapshot IDs; +- `include_hidden` 只覆盖 hidden;entry/file/folder deletion 始终排除; +- 支持多状态、结构化条件和 vector=false 默认规则; +- 文件移出任务后立即不再属于该 scope。 -## 8. 涉及文件 +## 6. 前端 -迁移 `0008_tasks.sql`;`prts-db/tasks.rs`(+`lib`);`prts-api/routes/tasks.rs`(5 端点)、`mod.rs`、Swagger;前端(`TaskListView`/`TaskDetailView`/`TaskManageView`、`MarkdownView`/`MarkdownEditor`、复用文件浏览器(勾选变体)、`api` tasksApi、`router`、`i18n`、依赖 `markdown-it`/`dompurify`);`docs/architecture.md`(补任务)。 +- `TaskListView`:标题、净化摘要、完成/分母、进度和文件数;零基线显示“无需处理”。 +- `TaskDetailView`:净化 Markdown、进度、共享文件浏览器、“在此任务翻译”和 capability 控制的管理入口。 +- `TaskManageView`:标题、Markdown 编辑/预览、文件勾选。勾文件夹只展开为保存时的后代 file ids。 +- 点任务文件进入全屏 editor,携带 file 与 task 参数。 +- zh-CN/en、Quasar、MDI、小圆角与浅/深主题全部沿用共享基础。 -## 9. 红线 / 未决 +## 7. 验收 -- Markdown 介绍**必须净化**(DOMPurify)防 XSS;渲染只在前端,存源文。 -- 进度查询避免 N+1(列表一条聚合 SQL);不实时全表 COUNT 热路径。 -- 权限:写操作过 `project.manage`;查看随项目可见性。 -- **未决(实现时)**:`base=0`(加入时文件已全译)UI 呈现(100% 还是「—」,mockup 用「—」于单文件、任务总体用 100%);「在此任务翻译」的过滤在 E 落地。 +- 数据库测试覆盖 snapshot 条件、隐藏/删除离开、恢复返回、完成/回退、新 entry 排除、移除重加和零基线。 +- 随机变更后 task_stats 等于离线 snapshot join 校验;列表 SQL 不做项目 entries 全表实时聚合。 +- 权限测试覆盖 owner/manager 写、reviewer/translator/游客只读、私有非成员拒绝。 +- Markdown XSS、文件集合协调、当前任务搜索范围和前端 capability 均有测试。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-01-terminology-design.md b/docs/superpowers/specs/2026-07-01-terminology-design.md index 7fcdbc4..d97b9ea 100644 --- a/docs/superpowers/specs/2026-07-01-terminology-design.md +++ b/docs/superpowers/specs/2026-07-01-terminology-design.md @@ -1,104 +1,103 @@ -# 术语(Terminology)· 工作流 D — 设计 Spec +# 术语(Terminology)· 工作流 D — 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **D**;前置 **A**(`primary_source_lang` 主源语言字段);平台后台 POS 管理与 F 同处 | -| 基线 | `master` @ `1cc1a33`(C spec 提交后) | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | A/B/C spec、CLAUDE.md | +| 历史阶段 | 2026-07-01 大改造工作流 D | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含可视化 mockup):**术语项目级**——原文(**主源语言**)→ 翻译(目标语言)+ 备注 + 词性;**词性 = 平台全局预设**(内置一套完整默认集,平台后台增删 + CSV/JSON 导入导出);**术语与词性都支持 CSV+JSON 导入导出**;术语维护权限 = **owner/manager/校对**(新增节点 `project.term.manage`),其余成员只读;术语列表**键集分页 + 后端搜索**(注意翻页,术语可上千);词性 UI 用文字描述放平台后台;**编辑器术语匹配/高亮属 E**(本阶段仅提供数据)。 +> 2026-07-01 版本确定了项目级术语、平台 POS、CSV/JSON 与 reviewer 管理权。本文件保留术语工作流细节;主源切换的精确发布门和 lexical/embedding 状态只以规范总纲 §4 为准。 ---- +## 1. 范围与权限 -## 1. 范围 +- 术语属于项目,保存真实源语言,不假定英语。 +- owner/manager/reviewer 通过 `manage_terms` capability 创建、修改、删除、导入和迁移;其它项目可见者只读/导出。 +- 全局 POS 只有平台管理员可管理;maintainer、项目 owner 和普通成员均不可写。 +- 所有 mutation 写追加式 audit;私有项目术语遵循项目可见性。 -**做**:项目级术语表(CRUD + 键集分页 + 搜索 + CSV/JSON 导入导出);平台全局词性预设(默认集 + 平台后台管理 + CSV/JSON 导入导出);术语分区 UI + 平台后台词性管理 UI。 +## 2. 数据模型 -**不做(后续/别处)**:编辑器内**术语匹配高亮 / 建议**(属 E,D 仅提供术语数据与查询);词性**双语名**(暂单名 `name`,见 §10);术语与译文的自动一致性校验/告警。 +计划迁移 `0012_terminology.sql`: -## 2. 决策要点 +```text +pos_presets + id, name_zh_cn, name_en, sort_order, created_at, updated_at + CHECK(name_zh_cn 非空 OR name_en 非空) -1. 词性预设 = **平台全局**(无 `project_id`),迁移内置默认集;平台管理员(`platform.settings`)增删改 + CSV/JSON 导入导出。 -2. 术语 = 项目级:`source_text`(主源语言)、`translation`(目标语言)、`notes`、`pos_id?`(引用全局预设)。允许**同形异性**(同原文不同词性并存)。 -3. 权限:查看/导出 = 可查看项目者;增/改/删/导入 = 新节点 **`project.term.manage`**(owner/manager/reviewer)。 -4. 列表 **键集分页**(`after` 游标 + `limit`)+ 后端搜索(原文/翻译 `ILIKE`/trgm)。 -5. 导入导出:术语与词性均支持 **CSV 与 JSON**;术语导入按 `(project_id, source_text, pos_id)` **upsert**(命中更新译文/备注,否则插入),返回 created/updated 计数。 +terms + id, project_id, source_lang, source_text, translation, notes, + pos_id, archived_at, created_by, created_at, updated_at +``` -## 3. 数据模型 · 迁移 `0009_terminology.sql` +唯一键为: +```sql +UNIQUE NULLS NOT DISTINCT (project_id, source_lang, source_text, pos_id) ``` -pos_presets( -- 平台全局词性预设 - id BIGINT IDENTITY PK, - name TEXT NOT NULL UNIQUE, - sort_order INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -) --- 迁移内置默认集(按 sort_order):名词/动词/形容词/副词/代词/数词/量词/ --- 介词/连词/助词/叹词/专有名词/短语/习语/拟声词/其他 -terms( - id BIGINT IDENTITY PK, - project_id BIGINT NOT NULL REFERENCES projects ON DELETE CASCADE, - source_text TEXT NOT NULL, -- 主源语言原文 - translation TEXT NOT NULL DEFAULT '', -- 目标语言译文 - notes TEXT NOT NULL DEFAULT '', - pos_id BIGINT REFERENCES pos_presets ON DELETE SET NULL, - created_by BIGINT REFERENCES users ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -) -索引:terms(project_id, source_text, id)(键集 + 排序);GIN trgm on terms.source_text(搜索 + E 匹配);可选 GIN trgm on translation。 -``` -## 4. 后端(`prts-api` + `prts-db`,进 Swagger) +因此同语言、同原文、同 POS 只能一条,`pos_id=NULL` 也不会重复;不同 POS 允许同形术语。 + +POS 响应按 `Accept-Language` 优先返回 zh-CN 或 en 名称,缺少当前语言时回退到另一名称。内置默认 POS 同时提供两种名称。 + +## 3. active、归档与迁移 + +- 任意合法 canonical BCP-47 `source_lang` 都可存储,不要求属于项目 `source_langs`。 +- active set = `source_lang = projects.primary_source_lang AND archived_at IS NULL`。非当前主源语言术语只能 archived;创建/更新若请求 `archived=false` 且 source_lang 不是当前 primary,返回稳定校验错误,不得静默改成 archived。 +- 主源变化事务中,旧主源 active terms 设置 archived_at;新主源已有归档术语取消归档。legacy old-primary 术语继续保留为 archived/migration-ready。 +- 归档术语停止搜索匹配和编辑器建议,但仍可在术语区查看、导出和人工迁移。 +- “迁移术语”复制/映射到当前主源并按唯一键 upsert,不篡改旧归档记录;源文本变化必须由用户在预览中确认。 +- 混合列表/导出包含 current + archived,始终显式返回 `source_lang` 和 `archived`。 + +## 4. API + +### 项目术语 -**术语(项目级)** -- `GET /projects/{id}/terms?q=&after=&limit=` — 键集分页 + 搜索(原文/译文)。可查看项目者。 -- `POST /projects/{id}/terms` `{source_text, translation, notes, pos_id?}` — 建。`project.term.manage`。 -- `PUT /projects/{id}/terms/{term_id}` — 改。`project.term.manage`。 -- `DELETE /projects/{id}/terms/{term_id}` — 删。`project.term.manage`。 -- `POST /projects/{id}/terms/import?format=csv|json` — multipart 上传,upsert(按 `(source_text,pos_id)`),未知词性名→`pos_id=null` + 计入告警;返回 created/updated/warnings。`project.term.manage`。 -- `GET /projects/{id}/terms/export?format=csv|json` — 全量导出。可查看项目者。 +- `GET /projects/{id}/terms?q=&set=current|archived|mixed&after=&limit=`:键集分页,原文/译文搜索。 +- `POST /projects/{id}/terms`:创建,必须带 BCP-47 `source_lang`。 +- `PUT /projects/{id}/terms/{term_id}`:修改。 +- `DELETE /projects/{id}/terms/{term_id}`:删除。 +- `GET /projects/{id}/terms/matches?entry_id=`:只返回当前主源 active matches,供 E 使用。 +- `POST /projects/{id}/terms/migrate`:把选定归档术语预览并 upsert 到当前主源。 +- `GET /projects/{id}/terms/export?format=csv|json&set=...`:流式导出。 -**词性预设(平台全局)** -- `GET /pos-presets` — 列表(任意登录者;术语编辑下拉用)。 -- `POST/PUT/DELETE /admin/pos-presets[/{pos_id}]` — 平台管理(`platform.settings`)。 -- `POST /admin/pos-presets/import?format=csv|json`、`GET /admin/pos-presets/export?format=csv|json` — 平台管理。 +### POS -**权限**:新增 `project.term.manage` 节点,加入 owner/manager/reviewer 的默认节点集(`prts-core/permission.rs`)。POS 管理走平台 `platform.settings`。 +- `GET /pos-presets`:按 locale 返回名称与两种原始名称。 +- `POST/PUT/DELETE /admin/pos-presets[/{id}]`:仅平台管理员。 +- POS CSV/JSON 同样使用预览确认导入与流式导出。 -## 5. 前端 +全部端点进入 Swagger,错误返回 code + 本地化 message。 -- **`ProjectGlossaryView`(术语分区)**:表格(原文/翻译/词性/备注)+ 顶部搜索(后端)+ **键集分页**(滚动加载或分页器)+ `新建/编辑`对话框(原文/翻译/词性下拉[GET /pos-presets]/备注)+ 删除 + `导入▾`/`导出▾`(CSV·JSON)。写操作/导入按 `project.term.manage` 显隐;导出对可查看者开放;翻译成员只读。 -- **平台后台(`AdminView`)词性管理**:全局词性列表(增删改 + 拖拽排序)+ CSV/JSON 导入导出。仅平台管理员。 -- **`api`**:`termsApi`(list/create/update/delete/import/export)、`posPresetsApi`(list;admin:manage/import/export)。 -- 信息页「术语数」统计(A 暂隐)本阶段可点亮(GET terms 计数)。 -- i18n 双语;样式少圆角、状态全称。 +所有 term CRUD/migrate/import 的 `source_lang` 先调用共享 `language-tags` canonicalizer;language 小写、script Titlecase、region 大写,variant/extension/private-use 按 parser 输出。无效 tag 与 canonicalization 后重复拒绝,但合法 canonical tag 不受项目 `source_langs` 限制。`archived=false` 只允许当前 primary;非主源 active 请求稳定失败。`needs_language_resolution` 项目禁用普通 term mutation,不能借术语端点绕过 owner resolution。 -## 6. 导入导出格式 +## 5. CSV/JSON 预览确认 -- **术语 CSV**:表头 `source_text,translation,pos,notes`(`pos`=词性名,导入时映射到 `pos_id`,未知→空 + 告警)。**术语 JSON**:`[{source_text, translation, pos, notes}]`。 -- **词性 CSV**:`name,sort_order`。**词性 JSON**:`[{name, sort_order}]`。 -- 术语导入 upsert(`(project_id, source_text, pos_id)`);词性导入 upsert(`name`)。导出为当前全量。 +### 5.1 格式 -## 7. 性能 +- 术语 CSV:`source_lang,source_text,translation,pos,notes,archived`。 +- 术语 JSON:对象数组,字段同 CSV。 +- POS CSV:`name_zh_cn,name_en,sort_order`。 +- POS JSON:对象数组,字段同 CSV。 -- 术语列表**键集分页**(`(source_text, id)` 游标),禁用大 OFFSET;搜索走 trgm GIN。 -- 导入分批事务(沿用上传批处理思路);导出流式/分页拉取避免大内存。 -- 术语规模通常百~千级,索引足够;E 的编辑器匹配复用 `source_text` trgm 索引。 +### 5.2 两阶段导入 -## 8. 测试 +1. `POST .../imports/preview` 解析文件,先 canonicalize source_lang,再返回行预览、created/updated 数、错误、警告和一次性 token,不写业务表。token 由 CSPRNG 生成、熵至少 128 bit、TTL 15 分钟,绑定 `actor_id + project_id + import_kind(term|pos) + canonical content digest`。 +2. `POST .../imports/{token}/confirm` 原子校验并一次性消费 token,重新检查当前 permission 与项目状态,然后在事务内按唯一键 upsert并写 audit。actor/project/kind/digest 不匹配、过期、重放、并发二次消费或权限撤销全部拒绝且不写业务表。 -- **单元**:CSV/JSON 解析与序列化(术语、词性);导入 upsert 语义(命中更新/新插/未知词性告警);键集游标。 -- **db-test**:术语 CRUD + 键集分页 + 搜索;导入导出往返一致;权限门(非 `term.manage` 增删导入→403、非成员私有项目查看→403);词性全局 CRUD + 平台权限门;`pos_id` 删除置空(ON DELETE SET NULL)。 -- **前端**:CI build/lint。 +未知 POS 置 `pos_id=NULL` 并返回带行号的 warning,不拒绝其它合法行。无效 BCP-47、缺少 source_text、重复输入唯一键冲突,以及 `archived=false` 的非主源行都在预览中明确标出;合法非项目 source-set tag 的 archived 行允许通过。 -## 9. 涉及文件 +## 6. 前端与编辑器联动 -迁移 `0009_terminology.sql`(建表 + 默认词性种子);`prts-core/permission.rs`(`project.term.manage` 节点 + 角色集);`prts-db/terms.rs`、`prts-db/pos.rs`;`prts-api/routes/terms.rs`、`routes/pos.rs`(或并入 admin)、`mod.rs`、Swagger;前端(`ProjectGlossaryView`、术语对话框、`AdminView` 词性管理、`api` termsApi/posPresetsApi、`router`、`i18n`);`docs/architecture.md`(补术语)。 +- `ProjectTermsView` 提供 current/archived/mixed、键集加载、搜索、CRUD、迁移、预览导入与 CSV/JSON 导出。 +- POS 下拉按 locale 显示并回退;未知 POS 的预览行显示警告。 +- E 必须实现 active term 高亮与建议,不再作为可选范围:匹配当前主源文本;点击时替换 selection,无 selection 时插入 cursor;只改本地 draft,不保存、不改 state。 +- 前端完全依据 capabilities 控制写操作,覆盖 zh-CN/en、浅/深主题、MDI 和小圆角。 -## 10. 红线 / 未决 +## 7. 性能与验收 -- 导入严格校验列/字段;参数化 SQL;键集分页不深翻;导入分批事务。 -- 权限:写/导入过 `project.term.manage`,词性过 `platform.settings`;私有项目术语按可见性鉴权。 -- **未决(实现时)**:词性**双语名**(当前单 `name`,默认集用中文;若英文界面需本地化再加 `name_en` 或 i18n key);术语导入的“覆盖 vs 追加”是否需要用户可选(默认 upsert);编辑器术语匹配在 E 落地(可能加 `GET /projects/{id}/terms/match?entry_id=` 或前端本地匹配)。 +- 术语列表使用 `(source_text,id)` 或稳定 id cursor,禁止大 OFFSET;搜索走 trgm 索引。 +- 导入分批解析、确认时事务 upsert;导出流式响应,避免全量内存拼接。 +- 测试覆盖 NULL POS 唯一、同形异性、任意合法 canonical source_lang 可存、非主源 active 稳定拒绝、主源归档/激活、legacy old-primary archived/migration-ready、canonical duplicate 拒绝、混合导出、未知 POS warning、双语 fallback、token 绑定/15 分钟过期/重放/并发消费/权限撤销、权限和私有可见性。 +- CSV/JSON preview→confirm→export 往返保持 source_lang、archived、POS 与备注。 +- E 验收必须包含 active-only 匹配、selection/cursor 插入和不改变状态。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md b/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md index 91a591a..26bfa04 100644 --- a/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md +++ b/docs/superpowers/specs/2026-07-01-uploads-file-management-design.md @@ -1,75 +1,117 @@ -# 上传改造 + 文件管理 · 工作流 B — 设计 Spec +# 上传改造 + 文件管理 · 工作流 B — 校准版设计 Spec | 项 | 值 | | --- | --- | -| 阶段 | 大改造 6 工作流之 **B**;前置 **A**(管理分区外壳 + 文件列表组件) | -| 基线 | `master` @ `12101b5`(A spec 提交后) | -| 日期 | 2026-07-01 · 作者 ZengXiaoPi · 设计协作 Claude | -| 前置 | A spec(`2026-07-01-project-page-restructure-design.md`)、CLAUDE.md、蓝图 §14 上传下载 / §5.2 性能 | +| 历史阶段 | 2026-07-01 大改造工作流 B | +| 校准日期 | 2026-07-10 | +| 规范总纲 | [`2026-07-10-project-workspace-overhaul-design.md`](./2026-07-10-project-workspace-overhaul-design.md) | -> 已与作者确认(含可视化 mockup):**上传文件 = PRTS 词条数组现格式**(`[{key, original:{lang:text}, translation?, state?}]`,无需格式转换,只把「粘贴」换「传文件」);支持**多文件 + 整文件夹上传**(保留相对子路径);文件落**当前所在目录**,路径 = 目录 + 文件名,**重名 = 重传**(沿用现「保留旧译文 + 变动置未翻译 + 记 diff」);文件管理 = **建夹 / 上传 / 移动 / 重命名 / 删除**(移动用目录选择器);**文件历史推迟到 P5**(依赖 `audit_log`,蓝图 §8,现仅到 P4);性能用 **UNNEST** 优化 `bulk_upsert`。 - ---- +> 2026-07-01 版本确定了多文件/文件夹上传、相对路径和文件维护方向。本文件保留上传/文件 UI 细节;精确 batch/attempt 状态、删除继承、恢复和历史保留真值以规范总纲 §3、§5 为准。 ## 1. 范围 -**做**:把上传从「文本框粘贴 JSON」改为「传文件」(多文件 + 整文件夹);在 **管理 → 文件管理** 子标签实现文件维护(建夹 / 上传 / 移动 / 重命名 / 删除);后端补建夹、移动/重命名端点,并用 UNNEST 优化批量入库。 +工作流 B 完成原始 JSON 文件/文件夹上传、持久化 batch 进度、同路径完整替换、建夹/移动/重命名/软删除/恢复、成员可读历史、owner/manager 回滚,以及删除保留期清理。 + +最终 UI 不提供粘贴 JSON 文本框。旧 `/projects/{id}/upload` 在新链路稳定后仍保留一个兼容周期并标 deprecated;旧控件只在替代 UI 可用后移除。 + +生命周期边界:`0008` 只预建 nullable deletion columns,foundation 的 legacy delete 仍硬删除、维护 stats、deletion_change_set_id 全 NULL且无 restore/history。`0010` 先断言全 NULL,再创建完整 history/FK 并与 soft-delete writer 同 release 原子切换;不得伪造 backfill payload。 + +## 2. 上传会话 + +### 2.1 运行时限制与路径 + +- 每 batch 500 文件、每文件 100MB、每 batch 2GB、客户端/浏览器并发 3 四项均为平台数据库运行时设置及默认值。`GET/PUT /admin/settings/upload` 读写四项,普通上传客户端通过 `GET /meta/upload-config` 的只读 `UploadConfigDto` 获取当前值;并发不能是前端固定常量。 +- 浏览器不读取或解析内容,只上传原始 `File` 流。文件夹上传保留相对路径,并以用户当前文件夹为根。 +- 服务端规范化路径,拒绝绝对路径、`..` 越界、空段、保留段和同项目 path 冲突。 +- V1 不支持 Range/offset 续传。断流或失败后在同一 logical batch file 下创建新 attempt,并从 byte zero 重新上传;旧 attempt/error 保留。 +- 原始文件写入 upload temp 持久卷;成功处理后立即清理,取消/失败/过期由 durable cleanup 幂等清理。未完成或 abandoned batch 默认 24 小时过期(可运行时配置);原始文件不进入项目历史或导出。 + +### 2.2 batch API + +1. `POST /projects/{id}/upload-batches` 声明目标目录、相对路径和字节数,校验三项限制。 +2. `PUT /projects/{id}/upload-batches/{batch_id}/files/{file_id}/attempts/{attempt_id}` 从 byte zero 流式接收单文件,不把全文件装入内存,也不接受 Range/offset。 +3. `POST /projects/{id}/upload-batches/{batch_id}/complete` 校验全部已声明文件并为每文件排持久化 job。 +4. `GET /projects/{id}/upload-batches/{batch_id}` 返回 batch 与逐文件阶段/进度/结果。 +5. `POST .../files/{file_id}/retry` 复用 logical processing job,但创建新的 per-file attempt 并返回 byte-zero PUT 目标。 +6. `POST /projects/{id}/upload-batches/{batch_id}/cancel` 进入 `cancelling`,取消 queued/temp items;已进入单文件事务的处理允许原子完成或回滚,全部 active attempts 终止后进入 `cancelled`。 + +batch 状态使用 `draft|uploading|queued|processing|cancelling|cancelled|partially_succeeded|succeeded|failed|expired`;attempt 记录上传/排队/处理/成功/失败/取消/过期及错误历史。每文件独立原子,batch 允许部分成功;取消不撤销已成功文件。 + +### 2.3 解析与重复 key + +- 后台从 temp 文件流式解析 PRTS JSON 数组,按批写事务临时表,再以集合 SQL 应用替换。 +- 临时表保存数组 ordinal 并对 key 唯一。重复 key 拒绝该文件,错误返回 key、首次位置、重复位置,以及 parser 可提供的行列。 +- 每个 `original` JSON key 通过共享 `language-tags` canonicalizer;无效 BCP-47、规范化后重复 key(无论值是否相同)或不属于项目 canonical source set 都拒绝该文件并返回位置。语法、结构、state 或空 key 错误同样只失败该文件。所有错误使用稳定 code 和本地化 message。 + +## 3. 同路径完整替换 + +replacement 真值由 `prts-core::upload_replacement` 产生 typed transition plan;DB adapter 执行 plan,API worker 只编排。translation preserve、source-change reset、tombstone/restore 与 stats/history delta 不得重复实现于 SQL/handler。 + +- 同一路径定位同一个平台 file;上传语义是完整 replacement,不是出现 key 的增量 patch。 +- 上传缺失的旧 key 标记 `deleted_at`,可从历史恢复;再次出现时恢复同一 entry 身份与历史。 +- 已存在 entry 的平台 translation、locked、hidden 与历史保留。 +- original 变化时保留 translation,但 state 重置 `untranslated`;original 未变时保留当前 state。 +- 上传体的 translation/state 只 seed 平台从未存在过的新 key。软删除后恢复的旧 key 不被上传值覆盖。 +- 单文件成功事务同时写 entry/file delta、物化统计、文件版本和 audit;任何一步失败整文件回滚。 + +## 4. 文件与文件夹管理 + +- owner/manager 可建夹、移动、重命名、删除、恢复;前端依据 `manage_files` capability,而不是角色字符串。 +- 文件夹移动禁止移入自身或后代,事务内重算全部后代 path,并在写入前检查目标冲突。 +- 删除文件夹时,同一事务把 active folder subtree 与 descendant files 标记为同一 `deletion_change_set_id`;恢复只清除此操作 ID 标记的行,不能恢复此前已删除后代。 +- 删除使 descendant files 从项目/任务 exposure、普通搜索与导出退出,但不修改 entry tombstone。默认保留 30 天,平台可配置;保留期内只加回实际恢复 files 的物化统计。 +- 上传、建夹、移动、重命名、软删除、恢复、purge 均写追加式 audit。 -**不做(后续/推迟)**:**文件历史 / 上传批次记录 / 全操作审计 / 按时间清除**(均属 P5 `audit_log`,本阶段仅在 UI 留位、不实现);上传其它文件格式(PO/CSV/properties——蓝图 §20 未决,暂仅 JSON 词条数组);拖拽移动(用目录选择器替代)。删除词条 `context` 属 E,不在此。 +保留期内 restore 锁定 change set/tree,只清匹配 operation 的 `deleted_at/deleted_by/purge_after/deletion_change_set_id`。成功恢复后 change set 继续作为普通历史保留,不能因清除 marker 就删掉历史 payload。 -## 2. 决策要点 +## 5. 行为完整历史 -1. 上传体不变(词条数组现格式);前端**去掉 JSON 文本框**,改文件选择/拖拽。 -2. 多文件:前端逐文件(顺序或有限并发)POST 现有 `/upload`,逐条显示进度。 -3. 整文件夹:`webkitdirectory` 取目录,按 `webkitRelativePath` 保留相对路径,逐文件带相对路径上传(`ensure_file_at_path` 自动建嵌套夹)。 -4. 归属:落当前目录,`path = 当前目录 + 文件名`;重名走现有 upsert(保留旧译文、变动置未翻译)。 -5. 文件管理操作门 = `project.file.upload`(owner/manager);删除端点由 `project.manage` **统一改为** `project.file.upload`(二者同为 owner/manager,行为不变)。 -6. 移动/重命名 = 改 `folder_id`/`parent_id` + `name`,**重算 path**;移动文件夹**级联重写**所有后代 `folders`/`files` 的 path 前缀;防环(不可移入自身子孙);`(project_id, path)` 冲突 → 409。 -7. 文件历史 UI 位(行 ⋯ 菜单)**本阶段不放**,待 P5。 +move/delete/restore/rollback 由 `prts-core::file_history` 纯规则生成 typed plan;repository port 隔离 DB,handler 不拥有领域真值。 -## 3. 数据模型 +### 5.1 模型 -**无新迁移**。复用现有 `folders(id, project_id, parent_id?, name, path, UNIQUE(project_id,path))` 与 `files(id, project_id, folder_id?, name, path, entry_count, UNIQUE(project_id,path))`。移动/重命名仅改 `parent_id/folder_id/name/path`,`entries` 引用 `file_id` 不受影响。 +- `file_change_sets`:`id UUID`(与 deletion operation id 同型)、project、nullable file/folder、递增版本、kind、actor、来源 change set、时间。 +- `file_change_items`:entity type/id、稳定顺序、before JSONB、after JSONB。entry payload 从首次发布即严格 allowlist `key/original/translation/state/locked/hidden/deleted_at`,不得捕获 `context`;结构实体使用独立明确 allowlist。 +- 上传 replacement 记录新增、恢复、源文变化和缺失删除;结构操作记录 path/tree 前后状态。 -## 4. 后端(`prts-api` + `prts-db`,全部进 Swagger) +行为 delta 在文件/文件夹存活期间以及软删除保留期内保存,不保存原始上传文件。精确关系为 `files/folders.deletion_change_set_id REFERENCES file_change_sets(id) ON DELETE RESTRICT`,而 `file_change_sets.file_id/folder_id` 可空并分别 `ON DELETE SET NULL`。因此仍有可恢复业务行时数据库会阻止先删 restoration payload。 -**上传性能**:重写 `prts_db::entries::bulk_upsert` 用 **UNNEST**——将一批 `key/original/...` 拆成数组参数,一条 `INSERT ... SELECT * FROM unnest(...) ON CONFLICT (file_id,key) DO UPDATE`(分 500–1000 一块,事务内),显著减少往返;保持返回 `created/updated/unchanged` 语义(用 `xmax=0` 或 RETURNING 判定新旧)与「原文变→置未翻译 + 记 `entry_versions`」逻辑。`/upload` 端点签名不变。 +到期永久 purge 锁定 change set/tree 和所有以待清除实体为 target 的 restoration-bearing sets,按叶到根删除 descendant entries、files、folders;target FK 自动置 NULL 且 relationship references 消失后,显式删除这些 sets 的 `file_change_items`,最后删除对应 `file_change_sets`。此后不能 rollback/restore;audit 仅按其 retention/项目策略保留无恢复正文的元数据。项目成员可查看历史;只有 owner/manager 可回滚或恢复。 -**新端点** -- `POST /projects/{id}/folders` `{parent_id?: i64|null, name}` → 建夹:`path = parent.path + "/" + name`(或根级 `name`);`(project_id,path)` 查重(409);门 `project.file.upload`。 -- `PATCH /projects/{id}/files/{file_id}` `{folder_id?: i64|null, name?}` → 移动/重命名:重算 `path`;查重(409);门 `project.file.upload`。 -- `PATCH /projects/{id}/folders/{folder_id}` `{parent_id?: i64|null, name?}` → 移动/重命名文件夹:**事务内**改本夹 `parent_id/name/path` + 前缀重写所有后代(`UPDATE folders/files SET path = new || substring(path from len(old)+1) WHERE project_id=$ AND (path=old OR path LIKE old||'/%')`);**防环**(`parent_id` 不得为自身或后代);查重(409);门 `project.file.upload`。 -- 复用 `DELETE .../files/{id}`、`DELETE .../folders/{id}`(级联删词条),门统一为 `project.file.upload`。 +### 5.2 回滚 -**`prts-db/files.rs`**:`create_folder`、`move_or_rename_file`、`move_or_rename_folder`(含级联 path 重写 + 防环);复用 `refresh_entry_count`。 +- 服务端把选定历史版本物化为目标状态,再生成 current → target 的新 change set。 +- 回滚本身成为新可逆版本,不覆盖、删除或重排旧历史。 +- 回滚/恢复固定 0 CP,不追回旧 CP;所有写入仍执行 path 冲突、权限、审计和统计维护。 -## 5. 前端 +## 6. 数据与实现边界 -**`ProjectManageView` → 文件管理子标签**(owner/manager 可见): -- 复用 A 的**面包屑文件列表组件**,加:勾选多选;工具栏 `新建文件夹` / `上传文件`;选中后 `移动`/`删除`;行尾 ⋯ 菜单 `移动 / 重命名 / 删除`。 -- **上传对话框**:多文件选择 + 拖拽 + `整文件夹上传`(webkitdirectory);提示「上传到:<当前目录>」;预览列表按现有/新增标「新增 / 将更新」;点上传后逐文件进度(顺序或并发上限 3~4)+ 总进度;完成汇总 created/updated/unchanged。 -- **新建文件夹对话框**(名称,落当前目录);**移动对话框**(目录选择器树,选目标夹);**删除**二次确认(普通确认即可,非删项目那种数学门)。 -- **`api`**:新增 `createFolder / moveFile / renameFile / moveFolder / renameFolder`;复用 `upload / deleteFile / deleteFolder`;多文件上传封装一个带进度回调的编排函数。 -- i18n 双语;样式少圆角、状态全称。旧 `ProjectDetailView` 的文本框上传随 A 退役移除。 +计划迁移 `0010_upload_file_history.sql` 一次性增加: -## 6. 性能 +实现迁移前先写整阶段 schema contract RED,覆盖全部表、CHECK、FK、retention/purge indexes 与 deletion ids 全 NULL前置;后续行为任务不得回改冻结迁移。 -- UNNEST 批量 upsert(减少 20w 词条上传往返);多文件顺序/有限并发编排。 -- 文件夹移动的后代 path 重写:单事务两条前缀 `UPDATE`(folders/files),走 `(project_id,path)` 索引。 -- verify:20w 词条单文件上传基准(UNNEST 前后对比);整文件夹(多文件)上传总耗时。 +- `upload_batches`、`upload_batch_files`、持久化 `upload_file_attempts`(保存每次 byte-zero retry 的 attempt_no、阶段、字节数、temp key、错误与时间/结果历史); +- `file_change_sets`、`file_change_items`; +- files/folders 复用 foundation 已建立的 `deleted_at/deleted_by/deletion_change_set_id`,本迁移补 `purge_after`;为 deletion_change_set_id 增加指向 `file_change_sets(id)` 的 RESTRICT/NO ACTION FK,为 `file_change_sets.file_id/folder_id` 增加 nullable `ON DELETE SET NULL` FK; +- 支撑 active path、待清理扫描和历史版本的索引。 -## 7. 测试 +worker 使用 `FOR UPDATE SKIP LOCKED` 与租约;重启从同一 job 恢复。文件进入项目待删除状态时,upload 与普通 purge 暂停。 -- **单元**:path 重算(移动/重命名/建夹);文件夹移动防环判定;path 前缀重写正确性;上传冲突计数(created/updated/unchanged)不因 UNNEST 改写而变。 -- **db-test**:建夹(重名 409);移动文件(改 folder_id/path、重名 409);移动文件夹(级联后代 path、防环拒绝、重名 409);重命名;删除级联;各操作权限门(非 `file.upload` → 403);`bulk_upsert` UNNEST 版与旧版结果一致(含原文变置未翻译 + entry_versions)。 -- **前端**:CI build/lint。 +上传配置使用类型化 `UploadConfig`/`UploadConfigDto` 贯穿 settings 存储、管理端 GET/PUT、只读 meta API 与前端上传 composable;服务端与浏览器消费同一份当前配置。 -## 8. 涉及文件 +## 7. 前端 -`prts-db/files.rs`(create_folder / move_or_rename_file / move_or_rename_folder+级联+防环)、`prts-db/entries.rs`(bulk_upsert 改 UNNEST);`prts-api/routes/files.rs`(POST folders、PATCH files、PATCH folders、删除门统一)、`mod.rs`、Swagger;前端(`ProjectManageView` 文件管理子标签、上传/新建夹/移动对话框、多文件上传编排、复用文件列表、`api`、`i18n`);`docs/architecture.md`(补文件管理)。 +- `UploadBatchDialog` 支持多文件、拖拽和 `webkitdirectory`;只持有元数据/File handle,不调用 `File.text()` 或 `JSON.parse()`。 +- 显示总进度及每 file attempt 的上传、排队、解析、提交、成功、失败/取消/过期;失败文件可从 byte zero 单独重试,batch 可取消。 +- 文件管理工具栏与行菜单提供新建、移动、重命名、删除、恢复、历史和回滚。 +- 删除说明明确 30 天恢复期;回滚说明明确会产生新版本且不影响历史 CP。 +- 全部文案覆盖 zh-CN/en,使用 Quasar/MDI、小圆角和共享 capability。 -## 9. 红线 / 未决 +## 8. 验收 -- 移动**防环**必检;`(project_id, path)` 唯一冲突返 409;所有维护操作过 `project.file.upload` 门 + 审计留位(P5 补)。 -- 上传严格校验 JSON 结构与非空 key;分块事务,避免长事务与内存峰值。 -- **未决(实现时定)**:整文件夹/多文件上传的**单次文件数与单文件大小上限**阈值;多文件上传的并发上限取值。文件历史依赖 P5,本阶段不实现。 +- 限制边界、路径越界、无 offset resume、byte-zero retry、cancel race、24h expiry/cleanup、部分成功、重复 key 位置均有 API 测试。 +- replacement 矩阵覆盖新 key、源文不变/变化、缺失、恢复、上传 seed、hidden/locked 保留。 +- 文件夹移动覆盖防环、全后代 path、冲突回滚;删除/恢复覆盖 30 天、restore 后 change-set 保留、RESTRICT 防误删 payload、叶到根 purge 顺序和统计口径。 +- 回滚到任意历史版本后再回滚仍可恢复,且每次产生新 change set、0 CP、完整 audit。 +- 100MB 文件与 20 万词条 verify 证明不做浏览器解析、不整文件载入后端内存、单文件失败不污染线上状态。 +- 阶段结束执行测试、verify、Conventional Commit、推 master、等待 CI 与 GHCR。 diff --git a/docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md b/docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md new file mode 100644 index 0000000..3d19702 --- /dev/null +++ b/docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md @@ -0,0 +1,287 @@ +# PRTS 项目工作区大改造(2026-07-10)— 规范总纲 + +| 项 | 值 | +| --- | --- | +| 状态 | 已批准,A–F 工作流的生命周期、真值表与冲突裁决唯一规范源 | +| 日期 | 2026-07-10 | +| 范围 | 项目工作区、上传与历史、任务、术语、编辑器与搜索、平台管理与删除 | +| 实施计划 | [`../plans/2026-07-10-project-workspace-overhaul.md`](../plans/2026-07-10-project-workspace-overhaul.md) | + +## 1. 优先级与适用方式 + +本文件记录作者截至 2026-07-10 的直接决定。发生冲突时,按以下顺序解释: + +1. 作者最新的直接决定与本文件; +2. 经本轮同步后的 [`plan/26-06-28-init_system.md`](../../../plan/26-06-28-init_system.md) 与 [`docs/architecture.md`](../../architecture.md); +3. 2026-07-01 的 A–F 工作流规格; +4. 更早的设计、计划与当前代码行为。 + +本文件是精确生命周期、状态矩阵、可见性谓词与 API 数据形状的规范源。架构、蓝图和 A–F 文件保留职责划分、交互与工作流细节,但不得另行定义相冲突的真值表。迁移号、API 名和文件名是实施契约,不表示当前分支已经具备对应能力;实施时必须以数据库中下一个可用迁移号为准,同时保持本文给出的依赖与发布边界。 + +## 2. 全局产品与技术约束 + +### 2.1 前端、主题、字体与国际化 + +- 交互界面只使用 Vue 3 + Quasar 2;保留浅色与深色主题,图标统一为 MDI。 +- 普通控件采用方角或 2–4px 小圆角。头像等天然圆形元素不受此限制。 +- 所有中文界面文字使用 `Noto Sans SC` 同类无衬线字体。`JetBrains Mono` 只用于代码、键名和数字,并在字体链中以 CJK 无衬线字体承接中文。界面不使用思源宋体、SimSun 或其它衬线字体。 +- 前端文案完整覆盖 zh-CN 与 en;API 客户端发送当前 locale 的 `Accept-Language`,后端以稳定错误码加本地化消息响应。 +- 项目介绍和任务介绍保存 Markdown 源文,渲染前必须净化;服务端不接收或持久化前端生成的可信 HTML。 + +### 2.2 权限与能力下发 + +- 权限判定仍以权限节点为底座,但 API 必须返回当前主体的 `capabilities`。前端只消费能力,不从 `owner`、`manager` 等角色字符串推断权限。 +- `projects.owner_id` 是项目唯一拥有者。平台管理员不能替代拥有者更改主源语言或删除项目。 +- 升级迁移把 `owner_id` 之外的 `owner` 成员关系降为 `manager`,并写审计与通知;同时补齐唯一拥有者的成员关系。 +- 拥有者只能授予 `manager`、`reviewer`、`translator`;管理只能授予 `reviewer`、`translator`。任何人都不能直接授予 `owner`,本轮不提供拥有者转让。 +- 术语、任务、历史与隐藏数据的能力按各自章节执行;所有服务端写端点再次校验能力,不能依赖前端隐藏控件。 + +### 2.3 审计安全与失败语义 + +- 登录、登出、认证失败、成功认证/令牌签发以及所有业务 mutation 写入追加式 `audit_log`。普通读取不审计;敏感管理员导出、清除和其它明确标注的敏感读取例外。 +- 每种 `action` 使用服务端定义的 payload allowlist。审计只保存必要的目标 ID、计数、允许公开的路径、结果/错误码、actor、IP、时间和已脱敏元数据;禁止保存密码或 hash、OAuth code/verifier/token、refresh/access/API key、challenge 答案、原始上传文件体、完整源文/译文以及其它秘密或内容正文。 +- 成功 mutation 的业务写与审计必须在同一 PostgreSQL 事务提交。route 只编排事务与领域调用;所有 repository writer 提供接受 `&mut PgConnection` 的 `_tx` 版本(或等价 transaction-scoped executor),不得由 route 以裸 SQL 绕过审计事务。 +- `0007` 建立 DB-authoritative auth session 状态机和 durable intent/outbox。表只保存 refresh hash、opaque session/family handle、状态 `pending|active|rotating|revoked|expired`、expiry、前后继与 intent lease/retry;任何 DB/job/audit payload 都不保存 raw token。refresh/rotation/revoke 每次先查并锁 DB authoritative state,Redis 只作 cache/pending material;DB 非 active 时,即使 Redis 残留也不可认证。 +- issuance/rotation 的 active/revoked 状态转换与 allowlisted audit 同一 DB 事务提交,提交后才返回 raw token;Redis populate/invalidate 失败由 outbox 重放,不回滚 DB authority。logout/revoke/改密撤销在 DB commit 后立即失效,Redis DEL 失败不恢复有效性。crash worker 清理未完成 intent、重放 cache 操作;stale Redis token 始终受 DB state 否决。 +- 失败认证尝试同步写脱敏事件;若该审计也无法持久化,返回通用 503,而不是返回一个未审计的认证成功或失败结论。不得以易丢失的旁路日志、异步队列或“稍后补写”替代。 + +### 2.4 持久化任务通则 + +- `jobs` 具备 `queued|running|paused|succeeded|failed|cancelled` 状态、阶段、进度、租约、错误、重试和恢复。同一逻辑阶段重试复用同一 job id;上传文件的每次传输/处理尝试另以 attempt 记录保留,见 §5.1。 +- `jobs.project_id` 是可空外键并使用 `ON DELETE SET NULL`。job payload 在创建时复制永久清除后仍需使用的不可变 `project_id`、slug、媒体/temp keys 和删除截止时间,不能依赖项目行存活。 +- 项目进入待删除状态后,除该项目的清除任务外,其余任务暂停;取消删除后仅恢复可恢复任务。 +- 新上传链路和新编辑器控件可用前,不移除旧入口。旧 JSON 上传 API 与旧 `GET /projects/{id}/search` 在替代接口上线后保留一个兼容周期,并在 OpenAPI 与响应头中标为 deprecated。 + +### 2.5 BCP-47 规范化与旧数据修复 + +- 后端统一使用小型 `language-tags` Rust 依赖解析与校验 BCP-47,并由共享 canonicalizer 输出稳定大小写:language 小写、script Titlecase、region 大写,variant/extension/private-use 按解析器的规范序列化归一。普通数据库/API 写路径只保存 canonical form;legacy unresolved 原值只能隔离在 repair 状态/issue 流程中,不能进入普通读取与搜索。 +- 每个入口都先规范化再校验:项目 create/update 的 `source_langs`、`primary_source_lang`、`target_lang`,上传 `original` JSON keys,术语 import/CRUD 的 `source_lang`,搜索 `source:` field selector,以及用户语言偏好。无效 tag 或 canonicalization 后重复一律拒绝;不得以原字符串大小写绕过唯一性。 +- `0008` 增加 project language repair 状态与诊断关系,并排队一次性 durable、按 users/projects/entries/terms stage 与 keyset cursor 分批的 legacy repair job。它规范化项目语言数组/target/primary、entry `original` keys、现有 term `source_lang` 和用户语言偏好;所有既有项目完成 repair 或被明确 gated 前不得开放其主源 UI/API。 +- legacy `original` keys 规范化后若合并且值完全相同,可安全折叠为一个 canonical key;值冲突、任一项目所属 tag 无效、项目语言集合无法满足 primary/target 约束或 term tag 无法修复时,项目标记 `needs_language_resolution`,保存逐实体 issue metadata,不静默丢弃内容。无效 user preference 进入 user-level issue 并从 active order 隔离,不错误标记无关项目。 +- `needs_language_resolution` 项目禁用 search、普通语言设置、上传/术语等受歧义影响的语言写入,并返回稳定错误码。唯一 owner 使用 `GET /projects/{id}/language-resolution` 与 `POST /projects/{id}/language-resolution/resolve` 显式映射 tag、选择冲突值并确认 source/primary/target;平台管理员只有 metadata-only `GET /admin/language-resolutions` 与 retry endpoint,不得读取私有正文或替 owner 选择主源。resolve/retry 全部写 allowlisted audit,并在解决后排 canonical search reconciliation。 +- `0009` 的 primary-source trigger、exact JSON lookup 与 search backfill 只能处理 `language_repair_state=ready` 的项目;repair 未完成或需要人工 resolution 时不得运行会把缺失 exact key 写成空索引的 backfill。foundation readiness 要求 repair 队列清空且没有 unresolved project,或明确保持这些项目 gated。 + +## 3. 有效可见性、删除继承与统计真值 + +### 3.1 规范谓词 + +对 `entries`、所属 `files` 和文件所在 folder ancestor-or-self 集合,定义: + +```text +effective_active(entries, files) = + entries.deleted_at IS NULL + AND files.deleted_at IS NULL + AND NOT EXISTS(folder ancestor-or-self WHERE folder.deleted_at IS NOT NULL) + +effective_visible(entries, files, include_hidden) = + effective_active(entries, files) + AND (entries.hidden = false OR include_hidden = true) +``` + +- 普通列表、统计、搜索、导出、任务快照和任务进度使用 `include_hidden=false`。 +- `include_hidden=true` 只是 owner/manager 显式授权的隐藏覆盖;它绝不包含 entry tombstone、已删除文件或已删除文件夹树。 +- search、export、task 与 stats 必须复用相同谓词/数据库 helper;不得各自实现简化版本。 + +### 3.2 文件夹删除与恢复 + +- 生命周期边界固定:`0008` 只预建 nullable deletion columns 和支持 effective-visible 的读取/统计;foundation 期间 legacy file/folder delete writer 继续当前硬删除语义并同事务维护物化统计,所有 `deletion_change_set_id IS NULL`,不暴露 soft delete、restore 或 history。以下 soft-delete/restore 规则从 `0010` 创建完整 change-set schema并原子切换 writer 后才生效;不得为 foundation 硬删除伪造 restoration payload/backfill。 +- 为避免热读路径递归判断,文件夹删除事务把 folder subtree 及其 descendant files 一并软删除。folders/files 均记录本次操作的 `deletion_change_set_id`;事务只标记此前 active 的行。 +- 恢复某次删除只清除 `deletion_change_set_id` 等于该操作 ID 的 folder/file 行。删除前已被其它操作删除的后代保持删除,不能被祖先恢复误带回来。 +- 同路径重传产生的 `entries.deleted_at` 是独立 entry tombstone。文件/文件夹删除与恢复不得写入、清除或改造这些 tombstone。 + +### 3.3 物化统计 + +- `0008_workspace_meta_stats.sql` 以 `effective_visible(..., false)` 回填 `file_stats`、`project_stats` 和后续任务统计所需基础;项目与文件的五状态计数及可见总数由写事务集合增量维护,正常读取不实时 `COUNT(*)`/全项目 `GROUP BY`。 +- `file_stats` 保留文件内 active、非 hidden entry 的物化计数。删除文件/文件夹时,从 `project_stats` 与受影响 task exposure 中减去 descendant files 的物化统计,不修改 entry tombstone;恢复时只为实际恢复的 files 加回。 +- reconciliation/verify 直接按规范 `effective_visible` 谓词重算并与物化值比较。文件夹 UI 聚合后代 active files;空文件夹最近时间使用自身创建时间。 +- 可见词条总数为零时,项目、文件和文件夹进度显示“—”。任务零基线规则见 §6。 + +## 4. 工作流 A:项目工作区与项目元信息 + +### 4.1 外壳与页面 + +- 项目工作区默认进入“信息”,固定分区顺序为:信息、文件、任务、术语、排行榜、下载、管理。 +- 编辑器是独立全屏路由,不嵌入项目外壳;从文件或任务中的文件入口进入。 +- 信息页只读;项目名称、slug、简介、可见性、语言和头像等修改集中在“管理”。 +- 排行榜本轮仅显示明确的功能占位;不渲染虚假的 0 CP 榜单。任务与术语在各自阶段完成后才开放。 +- 公开项目允许游客打开 active file 并进入只读编辑器。游客不能保存、改状态、锁定、隐藏、加入 presence、poke 或私信;私有项目仍按项目可见性鉴权。 + +### 4.2 主源语言发布边界 + +- ready 项目的 `source_langs`、`primary_source_lang` 与 `target_lang` 使用 §2.5 的 canonical BCP-47,且 primary 始终属于去重后的 `source_langs`。单源项目自动选中唯一源语言;多源项目创建时必须显式选择主源语言。legacy unresolved 行只在 `language_repair_state!=ready` 的条件约束下暂存 raw/nullable 值,不能进入普通项目/搜索路径。 +- `0008_workspace_meta_stats.sql` 与 `0009_primary_source_search.sql` 必须属于同一个 foundation release。在该 release 部署完成前,任何 API/UI 都不得接受非首个源语言作为主源,也不得暴露已有项目主源更新。 +- route/feature exposure 的硬门槛是:两个迁移均已应用;legacy language repair 已完成或 unresolved 项目被显式 gated;读取 canonical `primary_source_lang` 的 search trigger/function 已生效;既有 search rows 已 backfill/reconcile;词法 backfill worker 已部署并通过 readiness。任何可独立推送的阶段都不得在 trigger/search 仍读取 `source_langs[1]` 或 repair 未建立边界时暴露 `primary_source_lang` 能力。 +- 已有项目只有 `projects.owner_id` 指向的用户可以更改主源。相同值是无副作用成功,不触发冷却或 job。 +- 真正变化从请求接受时起计算 7 天冷却。下一次真正变化要求冷却结束,且不存在 active 或 unresolved failed 的 lexical/embedding job;失败阶段只能先重试原 job。`degraded/skipped` 不属于失败阻塞。 +- 同一保存中若移除当前主源,必须同时提交替代主源。项目已有任何词条后不得更改目标语言。 + +### 4.3 主源重建状态矩阵 + +主源变化使用两个明确 job:`primary_source_lexical_reindex` 与 `primary_source_embedding_backfill`。项目状态为: + +| 维度 | 状态 | 语义 | +| --- | --- | --- | +| lexical | `ready` | FTS/trgm 可用 | +| lexical | `rebuilding` | 词法 job queued/running,search/TM 返回重建中 | +| lexical | `failed` | 词法 job 重试耗尽;search/TM 不可用,须手动重试同 job | +| embedding | `pending` | 等待 lexical 成功 | +| embedding | `running` | 已配置 provider 正在 backfill | +| embedding | `ready` | vector 与 embedding TM 可用 | +| embedding | `degraded` | provider 禁用/未配置,job 以 `outcome=skipped` 成功;词法 search 与 trgm TM 可用 | +| embedding | `failed` | 已配置 provider 重试耗尽;词法 search 与 trgm TM 仍可用,须手动重试同 embedding job | + +- 接受变化的事务立即写新主源、归档旧主源术语、设置 `lexical=rebuilding`/`embedding=pending`、创建 lexical job 并写审计。该时刻起 search/TM 暂停。 +- lexical job 按键集批次重算 `source_text/source_tsv`;成功时原子设 `lexical=ready` 并创建/排队 embedding job,立即恢复 FTS/trgm。 +- provider 禁用或未配置时,不调用 provider;embedding job 记录 `succeeded + outcome=skipped + reason`,项目设 `degraded`。已配置 provider 的失败使用同一 embedding job 做有界指数退避;耗尽后项目/job 标 failed,API 提供手动重试同 job,不得重新调用整个主源变化。 +- 项目 DTO/`GET /jobs/{id}` 暴露 `primary_source_changed_at`、`cooldown_until`,以及 lexical/embedding 各自的 `state`、`job_id`、`progress_current`、`progress_total`、`attempts`、`max_attempts`、`next_retry_at`、稳定 `error_code`/`degraded_reason` 和 `manual_retry_allowed`。前端按该结构展示,不从一个笼统“重建中”字段猜阶段。 + +### 4.4 头像、简介与工作区统计 + +- 头像使用 `MediaStore` 抽象的本地实现,文件放入 Docker 持久卷。项目永久清除后按 §9.3 删除媒体,不把二进制写入数据库。 +- 前端用 Quasar 对话框和浏览器原生 canvas 做 1:1 裁剪,目标输出 256×256 WebP。服务端校验真实文件签名和可解码内容,并同时要求正方形、宽高各 `<=1024`、总像素 `<=1,048,576`、编码体积 `<=512KB`。 +- 公开项目头像公开读取;私有项目头像遵循项目可见性与认证,前端通过带鉴权的 blob 请求展示。 +- 项目简介按净化后的 Markdown 展示;信息页只读取 §3 的物化统计。 + +## 5. 工作流 B:上传、文件管理与历史 + +### 5.1 上传批次、尝试与清理 + +- 最终体验只接受原始 JSON 文件/文件夹,不提供粘贴文本框。浏览器不解析内容,只保留相对路径并流式上传。 +- 每批 500 文件、每文件 100MB、每批 2GB、浏览器并发 3 是数据库运行时设置及默认值;服务端在声明、接收和提交阶段校验,前端从配置 DTO 读取。 +- V1 不提供 HTTP Range、offset 或分块续传协议。失败/断流重试必须从 byte zero 开始,在同一 logical batch file 下创建新的 `upload_file_attempt`;旧 attempt 的阶段、字节数、错误码和时间保留到 batch/文件生命周期清理。处理 job id 复用,attempt id 递增。 +- batch 状态为 `draft|uploading|queued|processing|cancelling|cancelled|partially_succeeded|succeeded|failed|expired`;file/attempt 至少区分 `uploading|queued|processing|succeeded|failed|cancelled|expired`。`POST /projects/{id}/upload-batches/{batch_id}/cancel` 把 batch 置 `cancelling`,取消 queued jobs 与未处理/temp attempts。已进入单文件数据库事务的 worker 可原子完成或回滚,不在半事务中断;全部 active attempts 终止后 batch 置 `cancelled`,已成功文件保持成功。 +- 未完成或 abandoned batch 默认 24 小时过期(可由运行时设置调整),由 durable cleanup job 标 `expired` 并清理 temp。成功处理的 raw temp 立即删除;取消、失败/断流和过期 attempt 的 temp 也由幂等 durable cleanup 清除。 +- 文件夹路径规范化后拒绝越界、空段、保留段和冲突。单文件解析、重复键校验和替换提交原子;某文件失败不回滚同批其它文件。 +- 每个上传对象的 `original` keys 在解析阶段使用 §2.5 canonicalizer;无效 tag、canonicalization 后重复 key(无论值是否相同)或不属于项目最终 `source_langs` 的 key 都拒绝该文件,不能把歧义带入线上数据。 + +### 5.2 同路径重传 + +- replacement/restore/tombstone/state reset 与 history rollback 的领域真值由 prts-core typed transition plans 产生;DB adapter只执行,API/worker只编排,SQL不得另定义第二套规则。 +- 同一路径代表对该平台文件的完整 replacement,而不是 patch。 +- 上传中缺失的旧 key 形成独立 entry tombstone;重新出现时恢复同一平台记录。 +- 已存在 key 的平台译文、locked、hidden 与历史保留。源文变化时保留译文但把状态重置为 `untranslated`;源文未变时保留当前状态。 +- 上传 `translation/state` 只 seed 从未存在的新 key;恢复旧 key 不覆盖平台译文和状态。 +- 每个成功文件生成一个 change set,记录新增、源文变化、entry 恢复和缺失 tombstone,并按 §3 增量更新统计。 + +### 5.3 文件操作、历史与保留期 + +- owner/manager 可创建、移动、重命名、删除与恢复文件/文件夹;删除继承和恢复严格使用 §3.2 的 `deletion_change_set_id`。 +- 文件历史对项目成员可见;只有 owner/manager 可回滚或恢复。回滚生成 current→target 新 change set,0 CP,不改写旧历史。 +- 从文件历史功能首次发布起,entry change-item 的 `before/after` JSON 只允许 `key, original, translation, state, locked, hidden, deleted_at`;禁止用通用实体序列化捕获 `context`。file/folder 结构项使用各自的明确 allowlist,也不得保存正文外的任意字段。 +- `files.deletion_change_set_id` 与 `folders.deletion_change_set_id` 均引用 `file_change_sets(id) ON DELETE RESTRICT`(或默认 NO ACTION),防止仍有可恢复业务行时删除 restoration payload;`file_change_sets.file_id/folder_id` 可空并分别 `ON DELETE SET NULL`,允许先物理清除业务树。 +- 30 天内 restore 锁定 change set/tree,只清除匹配 operation 的 `deleted_at/deleted_by/purge_after/deletion_change_set_id`;删除前已删除的后代和 entry tombstone 不变。restore 后该 change set 继续作为正常历史保留,直到对象未来真正 purge 或命中另行定义的历史保留策略。 +- 到期 purge 固定顺序为:锁定 deletion change set、整棵待清除树及所有以待清除实体为 target 的 restoration-bearing change sets;按叶到根物理删除 descendant entries、files、folders,使 change-set target FK 变为 NULL 且所有 `deletion_change_set_id` 引用消失;显式删除这些 sets 的 `file_change_items`;最后删除对应 `file_change_sets`。change set/delta restoration payload 至此消失,rollback/restore 永久不可能;allowlisted audit metadata 按 retention/项目策略保留且没有恢复正文。 +- 跨域 FK 必须显式:task live file/entry refs 与 language-issue live refs `ON DELETE SET NULL` 并保留 immutable snapshot ID;entry-derived search/vector/entry_versions 与 file_stats 使用声明的 `ON DELETE CASCADE`;upload attempts/jobs 对业务 file 使用 nullable target `ON DELETE SET NULL`,保留 attempt 历史到 batch retention。file purge 先 detach/null + 重算 stats,再删派生行和业务树,最后删 history payload;不得依赖默认 RESTRICT。 +- 原始上传文件不进入历史或导出。 + +## 6. 工作流 C:任务 + +- 任务包含标题、净化 Markdown 介绍和多对多 active file 关系。owner/manager 管理,其它项目可见者只读。 +- 文件加入任务时,快照该文件当时 `effective_visible(..., false) AND state=untranslated` 的词条 ID;不能只保存数量。 +- 有效分母是 snapshot IDs 中当前仍满足 `effective_visible(..., false)` 的词条;完成数是其中 state 非 `untranslated` 的数量。entry tombstone、文件/文件夹删除或 hidden 使其离开,符合规则的恢复/取消隐藏使其返回。 +- 文件移除再加入建立新快照;加入后的新词条不进入旧快照。有效基线为零显示 100% 和“无需处理”。 +- `current_task` 搜索覆盖 task 当前 active files 的当前 effective-visible entries,不限 snapshot;scope 的任务与可见性验证按 §8.2。 +- `task_files` 保存 immutable file_id snapshot + nullable live file FK `ON DELETE SET NULL`;`task_baseline_entries` 保存 immutable entry_id snapshot + nullable live entry FK `ON DELETE SET NULL`,task_file 删除才 CASCADE 其 baseline。永久 file/entry purge 后 live ref 为 NULL、退出分母,但历史基线仍可解释。 + +## 7. 工作流 D:术语 + +- 项目术语保存真实 `source_lang`、`source_text`、目标译文、备注、POS 和归档状态,不能假定源语言是英语。 +- 任意合法 canonical BCP-47 source_lang 的术语都可存,不要求属于项目 source_langs;但 active set 严格为当前 primary 的未归档术语。非主源语言 term 只能 archived,请求 active 必须稳定失败而非静默改写。主源变化时旧主源术语归档,新主源已有归档术语恢复 active;legacy old-primary 保持 archived/migration-ready,可人工迁移。 +- 混合导出显式输出 `source_lang` 与 `archived`。POS 保存 zh-CN/en 名称并回退;只有平台管理员管理 POS。 +- 导入先 preview,再按 `(project_id, source_lang, source_text, pos_id)` 和 `NULLS NOT DISTINCT` upsert。`source_lang` 先执行 §2.5 规范化;未知 POS 置 NULL 并警告。 +- preview token 至少 128-bit entropy、TTL 15 分钟,绑定 `actor_id + project_id + import_kind + canonical content digest`,在 confirm 时原子一次性消费。confirm 必须重新检查当前权限;actor/project/kind/digest 不匹配、过期、已使用或权限已撤销均拒绝且不写业务表。 +- owner/manager/reviewer 管理术语;编辑器只匹配 active set,建议点击只改本地 draft。 + +## 8. 工作流 E:编辑器、结构化搜索与 context 清理 + +### 8.1 编辑器动作 + +- 从数据库、上传 DTO、API、前端类型、界面、历史描述和权威文档中删除词条 `context`;兼容期上传体若仍带字段则忽略。 +- `0013_editor_search.sql` 必须完成三件事:删除 entries.context;创建/更新 POST 搜索所需 metadata、indexes 和 functions;从既有 `file_change_items.before/after` JSONB entry payload 中 scrub `context` key。迁移前已有历史也不得残留该 key。 +- 右下恰好保留状态下拉 + 一个智能按钮。脏且未翻译→翻译;其它脏状态→保存且状态不变;clean translated/checked 且有 review capability→检查/审核;presence conflict 且有 force capability→强制保存,但仍校验版本;其它禁用。 +- 公开游客只读,不建立可写 presence,不显示 mutation/协作动作。 + +### 8.2 搜索 scope DTO 与验证 + +`SearchScope` 是以 `type` 为 discriminator 的封闭 tagged union: + +```rust +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum SearchScope { + All, + Path { path: String }, + File { file_id: i64 }, + CurrentFile { file_id: i64 }, + CurrentTask { task_id: i64 }, +} + +struct StructuredSearchRequest { + query: Option, + conditions: Vec, + scope: SearchScope, + states: Vec, + #[serde(default)] + include_hidden: bool, + #[serde(default)] + vector: bool, + after: Option, + limit: u16, +} +``` + +对应 JSON 请求片段示例(数组中的每个元素分别代表一个请求): + +```json +[ + { "scope": { "type": "all" } }, + { "scope": { "type": "path", "path": "chapter/01" } }, + { "scope": { "type": "file", "file_id": 41 } }, + { "scope": { "type": "current_file", "file_id": 41 } }, + { "scope": { "type": "current_task", "task_id": 73 } } +] +``` + +- 项目 search route 先用共享 file-path canonicalizer 规范化 path,再验证 path/file/task 属于 URL 中的项目且对 caller 可见;`current_task` 还必须验证 task 本身与 task project 可见。已删除 file/folder、deleted ancestor 下的 path/file、已删除/不可见 task 均返回稳定的 not-found/forbidden,不进入搜索。 +- path resolution 必须按 segment boundary:canonical path 精确命中 active file 时只搜索该 file;命中 active folder 时搜索该 folder 的 active descendant files,条件为 exact folder path 或 `folder_path + '/'` subtree,禁止 naive string prefix(如 `dir2` 命中 `dir`)。歧义、跨项目、deleted ancestor 返回稳定 400/404/403。 +- `file` 与 `current_file` 都必须带数据库 `BIGINT`/Rust `i64` 的 file_id;后者只是明确表达编辑器当前上下文,服务端绝不从 session/query 猜测。`current_task` 同理必须带 `BIGINT`/`i64` task_id。path 仍为字符串,不进行 UUID ID 迁移。 +- tagged union 拒绝未知字段;例如 `{ "type": "all", "file_id": 41 }`、`path` variant 多带 task_id、缺 payload 或未知 type 都返回 400,不能被 serde 静默忽略。 +- 旧 GET 适配器只有两种映射:存在 `file_id` 时映射为 `{ "type": "file", "file_id": 41 }`,否则 `{ "type": "all" }`;它绝不制造 `current_file` 或 `current_task`。 + +### 8.3 搜索语义 + +- 快捷搜索在列表上方;IME composing 不触发。Enter 发送 `all`,Shift+Enter 发送带当前 file_id 的 `current_file`。 +- POST conditions 仅 AND;字段为 `source:|source_any|translation|key`,操作符为 `contains|not_contains|starts_with|ends_with|equals`,不支持 regex。`source:` 在构造数据库 selector 前按 §2.5 规范化;无效 tag 或与项目 canonical source set 不匹配时拒绝。 +- 支持多状态、显式 `include_hidden` 和默认 false 的 `vector`。所有召回/fetch 使用 §3 谓词;include_hidden 越权返回 403。 +- lexical rebuilding/failed 返回稳定状态与 lexical job 引用;lexical ready 时即恢复 FTS/trgm,不等待 embedding。GET 兼容适配使用同一 service/SQL。 +- 类型、OpenAPI 和测试必须覆盖 union 每个 variant 的成功形状、缺少 payload/未知 type 的 400、跨项目或不可见 path/file/task、deleted ancestor 排除,以及 GET 的 file/all 两种映射。 +- 唯一默认排序为 `(rrf_score DESC, entry_id ASC)`;任何新增 sort 必须定义稳定 tie-break。limit 默认 50、范围 1..=100。响应为 `{items,next_after}`。 +- `after` 是 opaque versioned cursor,至少绑定 `v=1`、URL `project_id`、query+conditions+states+scope+include_hidden+vector 的 canonical fingerprint、最后 `rrf_score` 与 `entry_id`;服务端签名/验证后按 keyset 继续。格式错误、版本未知、签名错误或跨 project/query/filter/scope 重用一律 400,不能退回首页或猜测。 + +## 9. 工作流 F:平台管理、CP 与项目删除 + +### 9.1 用户与成员管理 + +- 平台用户列表使用键集分页;管理员可用用户名 + 初始密码建号。持久化改密提醒不阻止使用。 +- 平台严格按秩授权;项目严格执行 §2.2 的 owner/manager 范围。前端只消费 capability。 + +### 9.2 CP + +- 本轮只准备未来兼容的精确十分之一单位整数存储:`users.cp_tenths BIGINT NOT NULL DEFAULT 0` 与 `memberships.cp_tenths BIGINT NOT NULL DEFAULT 0`。一单位代表 0.1 CP;不增加任何十进制数据库/Rust 类型或 sqlx decimal feature。 +- 本轮不评分、不生成真实排行榜、不增加全 0 CP 列。未来排序直接按 `cp_tenths`;UI 需要展示时再把 exact tenths 四舍五入为整数。回滚与恢复未来仍固定 0 CP,不追扣历史得分。 + +### 9.3 项目删除与清除顺序 + +- `ProjectDeleteDialog` 固定三阶段:第一次展示不可逆后果、24 小时等待期与待删除期间只读状态,并要求 owner 显式继续;第二次要求输入完整项目 slug 且精确匹配;两关通过后才请求 server-side 数学 challenge。两次确认只是前端交互门槛,不能替代后端鉴权。 +- 只有 `projects.owner_id` 可领取/提交绑定 user+project、短 TTL、一次性消费的 Redis 整数 challenge;后端最终再次校验 owner 与答案。正确答案安排 24 小时后清除并返回 202;待删除项目从普通列表消失、只读、其它 jobs 暂停。 +- `projects.deletion_job_id` 是可空、非级联关系,使用 `ON DELETE SET NULL` 或等价约束;purge job 不能因项目删除而删除。 +- 安排事务固定为:创建/排队 purge job(payload 写不可变 project id/slug/media keys/deadline)→写项目 pending 字段与 `deletion_job_id`→写审计→提交。 +- 到期 worker 固定顺序为:锁 job/project→审计→detach/cancel jobs/uploads→逐树 NULL task/language live refs并清 stats/search/vector/versions→叶到根删业务树→删 project-scoped history items/sets→按声明 FK 删除 task snapshots/tasks、terms/POS links、stats、upload metadata、language issues、memberships→删 project并提交 external cleanup stage。只使用明确 CASCADE/SET NULL;不得依赖默认 RESTRICT或模糊 project cascade。purge job 以 jobs.project_id SET NULL 保留。 +- 外部清理失败时数据库项目保持已删除,不得复活;同一 purge job 保持/回到可重试 external-cleanup stage,使用 payload keys 幂等重试。审计只保留标识与清除元数据,不含 restoration payload。 + +## 10. Foundation release、发布与验收 + +- 阶段顺序固定为:基线/文档 → foundation release(`0007`,以及同一 release 的 `0008+0009`、stats/primary trigger/backfill worker、能力基础)→ A route/UI exposure → B → C → D → E → F → 全量验证与发布。 +- foundation release 的 readiness 必须证明 `0008/0009` 均已应用、BCP-47 repair worker 已完成可自动修复项目且 unresolved 项目保持 gated、search trigger/function 不再读取 `source_langs[1]`、canonical primary exact-key backfill/reconciliation 完成且 worker 可领取 lexical job;之后才可开放非首主源创建或已有项目主源更新。 +- 任一阶段不得回退现有登录、项目、上传、编辑器、实时、通知、私信、搜索或导出。替代链路通过兼容适配或 feature gate 渐进启用。 +- 每阶段必须完成单元/集成/前端测试、verify、Conventional Commit、推送 master、等待 CI 成功,并构建推送 GHCR 镜像后再进入下一阶段。 +- 20 万词条验证至少覆盖:effective-visible stats reconciliation、主源 lexical backfill、单文件 replacement、批量上传/cancel/expiry、结构化 search 五种 scope 与 task progress。 diff --git a/plan/26-06-28-init_system.md b/plan/26-06-28-init_system.md index 221ee37..7edbcec 100644 --- a/plan/26-06-28-init_system.md +++ b/plan/26-06-28-init_system.md @@ -2,6 +2,7 @@ > 本文件由 `26-06-28-init_system_raw.md` 精修而来,是 PRTS 项目的**权威蓝图**。 > 原始草稿保留于 `plan/26-06-28-init_system_raw.md` 作为历史。 +> 2026-07-10 项目工作区大改造的直接作者决定见 [`docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md`](../docs/superpowers/specs/2026-07-10-project-workspace-overhaul-design.md);该总纲是精确生命周期、状态矩阵、可见性谓词和 API truth table 的唯一规范源,对本蓝图相关摘要具有更高优先级。 | 项 | 值 | | --- | --- | @@ -134,28 +135,32 @@ PRTS/ ### 5.1 核心实体 -- `user`:`id`、`username`、`email`、`password_hash?`、`avatar_url`、`description`、`uid`(展示用)、`joined_at`、`cp`(累计贡献分)。 +- `user`:`id`、`username`、`email`、`password_hash?`、`password_change_required`、`avatar_url`、`description`、`uid`(展示用)、`joined_at`、`cp_tenths`(未来贡献分的精确 0.1 单位,`BIGINT`)。 - `external_account`:`user_id`、`provider`(github/zoot…)、`external_id`、`raw`(JSONB);用于"关联账号"。 - `api_key`:`user_id`、`name`、`key_hash`、`scopes`、`created_at`、`last_used_at`(明文仅创建时展示一次)。 -- `project`:`id`、`slug`、`name`、`visibility`(public/private)、`source_langs`(BCP-47 数组)、`target_lang`(BCP-47)、`owner_id`、`created_at`。 -- `folder`:`id`、`project_id`、`parent_id?`(自引用树)、`name`、`path`。 -- `file`:`id`、`project_id`、`folder_id?`、`name`、`format`、`stats`(词条数/各状态计数,物化)。 +- `project`:`id`、`slug`、`name`、`visibility`(public/private)、`source_langs`(BCP-47 数组)、`primary_source_lang`、`target_lang`(BCP-47)、唯一 `owner_id`、搜索重建状态、头像 key、待删除时间、`created_at`。 +- `folder`:`id`、`project_id`、`parent_id?`(自引用树)、`name`、`path`、软删除与 `deletion_change_set_id`。 +- `file`:`id`、`project_id`、`folder_id?`、`name`、`format`、软删除与 `deletion_change_set_id`、`stats`(可见词条数/各状态计数,物化)。 - `entry`(最小翻译单位): - - `id`、`file_id`、`key`、`original`(JSONB:`{ "": "源文本" }`)、`context`、`translation`、 - - `state`(工作流枚举,见 §6)、`locked`(bool)、`hidden`(bool)、 + - `id`、`file_id`、`key`、`original`(JSONB:`{ "": "源文本" }`)、`translation`、 + - `state`(工作流枚举,见 §6)、`locked`(bool)、`hidden`(bool)、`deleted_at?`、 - `version`(乐观锁递增)、`updated_by`、`updated_at`。 - `entry_version`:每次改动的快照(`entry_id`、`version`、`translation`、`state`、`editor_id`、`diff`、`created_at`)。 -- `audit_log`:追加式全操作历史(见 §8)。 -- `membership`:`project_id`、`user_id`、`role`(owner/manager/reviewer/translator)。 +- `audit_log`:追加式、action allowlisted 的安全审计(见 §8)。 +- `membership`:`project_id`、`user_id`、`role`(owner/manager/reviewer/translator)、`cp_tenths BIGINT NOT NULL DEFAULT 0`(本轮只建未来基础,不评分)。 - `platform_role`:`user_id`、`role`(super_admin/admin/maintainer)。 -- `setting`:平台配置(SMTP、注册开关、OAuth 模式、Embedding 开关等),分类存储。 +- `setting`:平台运行时配置(SMTP、注册开关、OAuth 模式、Embedding 开关、上传的文件数/单文件大小/批次大小/浏览器并发等),分类存储。 +- `job`:上传、主源 lexical、Embedding、文件清理与项目清除的持久化状态/阶段/进度/租约/重试;`project_id` 可空并 `ON DELETE SET NULL`,删除后所需 project/media snapshot 存 payload。 +- `task` / `task_file` / `task_baseline_entry`:immutable file/entry snapshot IDs + nullable live FKs(永久删除 SET NULL),历史基线可解释且 NULL live ref 退出分母。 +- `term` / `pos_preset`:带任意合法 canonical `source_lang`/归档状态的项目术语与 zh-CN/en 全局词性;非当前主源语言只能 archived,legacy old-primary 保持 migration-ready。 +- `project_stats` / `file_stats`:排除 hidden 与 soft-deleted 的可见状态计数。 ### 5.2 应对单项目 20w+ 词条的性能策略 - **键集分页(keyset / cursor)**:列表按 `(file_id, id)` 游标翻页,避免大 `OFFSET`。 - **索引**:`entry(file_id, state)`、`entry(key)`、`original/translation` 的 `tsvector` GIN、`pg_trgm` GIN、`pgvector` 的 IVFFlat/HNSW。 -- **统计物化**:文件/项目各状态计数增量维护(触发器或应用层),避免实时 `COUNT(*)`。 -- **批量上传**:分批事务 + `COPY`;解析与入库分离,超大文件后台任务化。 +- **统计物化**:文件/项目按总纲 §3 的 effective visibility 集合增量维护;hidden overlay 不包含 entry tombstone、deleted file 或 deleted ancestor folder;正常读路径不实时 `COUNT(*)`/`GROUP BY entries`。 +- **批量上传**:原始 JSON 文件流式进入后端临时卷,持久化 batch/job 后逐文件原子解析与完整替换;浏览器不解析内容。 - **只读浏览缓存**:公开项目的列表/统计走 Redis 缓存。 --- @@ -189,33 +194,37 @@ PRTS/ | 角色 | 能力 | | --- | --- | -| 总管理员 super_admin | 全部;可任免管理员 | -| 管理员 admin | 创建/删除/管理所有项目;平台设置 | +| 总管理员 super_admin | 全部平台级能力;可任免管理员,并按平台管理能力跨项目操作;但不能冒充项目拥有者执行主源变化或项目删除 | +| 管理员 admin | 创建项目、按平台管理能力管理所有项目、管理平台设置;但不能冒充项目拥有者执行主源变化或项目删除 | | 维护者 maintainer | 创建项目 | ### 7.2 项目级 | 角色 | 能力 | | --- | --- | -| 拥有者 owner | 项目内全部,含删除项目、改被锁词条 | -| 管理 manager | 同拥有者,但**不可删除项目**;可改被锁词条 | +| 拥有者 owner | 项目内全部,含主源变化、删除项目、改被锁词条;本轮不提供拥有者转让 | +| 管理 manager | 项目内管理能力,含改被锁词条;**不含主源变化、项目删除或拥有者转让** | | 校对 reviewer | 设 `已检查/已审核`、修改已审核文本;翻译能力 | | 翻译 translator | 编辑 `未翻译/已翻译/有疑问` 词条并设这三种状态;**不可**设 `已检查/已审核`,不可改被锁词条 | > 补全了草稿中被截断的「翻译」角色定义。 +> +> 2026-07-10 增补:`projects.owner_id` 是唯一拥有者。拥有者只可授予 manager/reviewer/translator,管理只可授予 reviewer/translator;任何端点不直接授予 owner,本轮不提供 owner 转让。API 返回 capabilities,前端不得从角色字符串自行推导。主源变化与项目删除只认 `owner_id`,平台管理员不能替代。 ### 7.3 权限节点示例 -`platform.admin.grant`、`platform.project.create`、`project.manage`、`project.delete`、`project.member.manage`、`project.entry.edit`、`project.entry.review`、`project.entry.lock`、`project.entry.hide`、`project.file.upload`、`project.download`、`project.history.purge` … +`platform.admin.grant`、`platform.project.create`、`project.manage`、`project.delete`、`project.member.manage`、`project.entry.edit`、`project.entry.review`、`project.entry.lock`、`project.entry.hide`、`project.file.upload`、`project.download`、`project.history.view`、`project.history.rollback` … --- ## 8. 历史与审计 -- **所有操作**(登录、上传、编辑、状态变更、权限变更、删除等)写入追加式 `audit_log`:`actor_id`、`action`、`target`(project/file/entry…)、`payload`(JSONB)、`ip`、`created_at`。 +- 登录/登出/认证失败、成功令牌签发与所有业务 mutation 写入追加式 `audit_log`;普通读取不审计,敏感管理员导出/清除除外。 - 词条改动额外写 `entry_version`,展示**变更与差异(diff)**。 -- 管理后台可**按时间段 + 按项目**清除操作历史(受 `project.history.purge` / 平台权限约束)。 -- 设计为高写入:分区表(按月)或批量写入队列,避免阻塞主流程。 +- 文件操作与重传写行为完整、字段 allowlisted 的 change set/delta;entry payload 永不捕获 context。成员可查看,owner/manager 可回滚。恢复 payload 只保留到 deleted file/folder 永久清除,之后不可恢复;audit 元数据无恢复正文。 +- `audit_log` 保持追加式,不因项目清除而级联删除;项目清除后保留必要目标快照元数据。 +- 审计 payload 按 action allowlist 脱敏,不存密码/hash、OAuth/token/API key、challenge answer、raw upload 或完整源文/译文。所有 repository writer 提供 transaction-scoped 接口,route 不写裸 SQL;业务 mutation 与审计同 PostgreSQL 事务。`0007` 建 DB-authoritative auth session/intents/outbox,只存 refresh hash/opaque handle;refresh/rotation/revoke 先查 DB state,Redis 仅缓存。issuance/rotation commit 后才返回 token,logout/revoke DB commit 即失效,Redis 清理失败由 durable worker 重试。 +- 上传、主源重建、Embedding、文件保留期清理与项目清除使用持久化 `job`,记录阶段、进度、租约、错误和同一 job 重试。 --- @@ -243,31 +252,36 @@ trait AuthProvider { ### 9.2 注册与会话 - 开放注册;**管理后台可配置 SMTP,并可开关"是否要求邮箱验证"**(未配 SMTP 即跳过验证)。 -- 会话:自签 **JWT access**(短期)+ **不透明 refresh**(存 Redis,可吊销)。 +- 会话:自签 **JWT access**(短期)+ **不透明 refresh**;refresh hash/state 以 PostgreSQL 为权威,Redis 仅缓存/加速,可吊销且 crash 后由 durable intent/outbox 收敛。 - 密码哈希用 Argon2id。 ### 9.3 用户页面 -- 自身:描述、API-Key(创建/吊销,明文仅一次)、翻译语言偏好。 -- 展示:参与的项目、关联账号(先支持 GitHub)、UID、加入时间、CP。 +- 自身:描述、API-Key(创建/吊销,明文仅一次)、翻译语言偏好、修改密码。 +- 管理员直接建号带持久化初始密码提醒;提醒不阻止使用,成功修改密码后清除。 +- 展示:参与的项目、关联账号(先支持 GitHub)、UID、加入时间。CP 在真实计分阶段上线前不显示虚假 0 列/榜单。 --- ## 10. CP(贡献分) -- 触发:所有翻译、编辑、校对操作。 -- 公式:`CP_gain = weight × levenshtein(prev_translation, new_translation)` +- 长期公式保持:`CP_gain = weight × levenshtein(prev_translation, new_translation)` - 首次翻译:`prev` 视为空串,`d = len(new_translation)`。 - 权重:翻译/编辑 `1.0`,校对 `0.3`。 - **不设任何抗刷分机制**(无封顶、无短时合并、无每日上限)—— 按作者要求保持纯线性。 -- 用户 `cp` 字段累加;提供项目贡献榜。 +- 2026-07-10 大改造本轮只把平台/项目 CP 存储统一为 exact tenths integer:`users.cp_tenths BIGINT` 与 `memberships.cp_tenths BIGINT NOT NULL DEFAULT 0`,一单位代表 0.1 CP;不接入评分、不生成真实排行榜、不增加全为 0 的管理列,也不引入十进制 crate/sqlx decimal feature。 +- 未来排序直接使用 `cp_tenths`,UI 显示时四舍五入为整数。回滚与恢复固定 0 CP,不追扣旧 CP。 --- ## 11. 翻译语言 - 一个项目:**多源语言 + 单一目标语言**(如 `en, ja, ko -> zh-Hans`,或 `en -> zh-Hans`)。 -- 语言码采用 **BCP-47**(`en` / `ja` / `ko` / `zh-Hans` / `zh-Hant` …),界面显示本地化名称,天然区分简繁与地区,并支持其他主流语言。 +- 语言码采用 **BCP-47**(`en` / `ja` / `ko` / `zh-Hans` / `zh-Hant` …),后端以 `language-tags` 统一校验/规范化:language 小写、script Titlecase、region 大写,variant/extension/private-use 按 parser 规范序列化;界面显示本地化名称。 +- 项目 create/update 的 source/primary/target、上传 original JSON keys、term import/CRUD、search source selector 和用户语言偏好全部在入口规范化;invalid 或 canonical duplicate 拒绝。foundation durable repair 按批规范化 legacy project/entry/term/user 数据;冲突或 invalid 数据标 `needs_language_resolution`,禁用 search/普通语言 edits,只有 owner resolution UI/API 可显式处理,平台 admin 只有无正文诊断/retry。 +- 每个 repair-ready 项目有非空 canonical `primary_source_lang` 且属于 `source_langs`;legacy unresolved 行只在条件约束下暂存且不进入普通 search/语言管理。`0008+0009`、language repair、primary search trigger/backfill 与 lexical worker 必须同一 foundation release 完成;`0009` exact JSON lookup 只处理 repair-ready 项目,之后才开放非首主源或已有项目更新。 +- 已有项目只有唯一 `owner_id` 可改主源,真正变化 7 天冷却;相同值无副作用。lexical 与 embedding 使用独立 job/state;lexical ready 即恢复 FTS/trgm,provider 未配置标 degraded/skipped,配置 provider 失败只重试原 embedding stage。精确矩阵见总纲 §4.3。 +- 移除当前主源必须同请求给替代值;项目已有词条后目标语言不可改。 - 用户可设置个人翻译语言偏好,翻译面板按偏好顺序展示源语言。 --- @@ -279,15 +293,20 @@ trait AuthProvider { 2. `pg_trgm` 模糊匹配(容错/子串) 3. `pgvector` 语义检索 - 向量化:`EmbeddingProvider` 抽象,默认 **Qwen 云 API**(密钥仅服务端;不可用时自动降级为 FTS+trgm)。 -- **高级筛选**:所在文件/目录、词条状态、源/译文关键字、键名;多条件组合 + 多种排序方式。 +- 主接口为结构化 `POST /projects/{id}/search`;旧 GET 适配同一 service 并保留一个兼容周期。 +- 默认稳定分页 `(rrf_score DESC, entry_id ASC)`,opaque versioned cursor 绑定 URL project_id + query/filter/scope fingerprint + score/id;错误、跨项目或跨查询 cursor 400,limit 1..100,响应含 next_after。path 精确解析 file 或 segment-boundary folder subtree,禁 naive prefix。 +- **高级筛选**:AND 条件;字段和操作符同既定范围;scope 使用总纲 §8.2 的 `type` tagged union(path/file/current_file/current_task 均带必需 payload),项目 route 校验归属/可见性/deletion;vector 默认 false。 +- 普通搜索使用总纲 §3 effective visibility;owner/manager 的 include_hidden 只覆盖 hidden,绝不包含 entry/file/folder deletion。主源 lexical 重建中暂停,lexical ready 后先恢复 FTS+trgm。 --- ## 13. 实时翻译编辑器(WebSocket) -- 布局:左侧词条列表(键集分页 + 搜索/筛选),中间翻译面板(按用户偏好显示源语言、上下文、键值)。 +- 布局:左侧词条列表(键集分页 + 搜索/筛选),中间翻译面板(按用户偏好显示源语言与键值);词条模型不保留 context。 - 实时:WebSocket + Redis pub/sub 跨实例广播 —— 在线状态、"他人正在编辑此词条"、词条变更实时刷新。 - 并发安全:保存时**乐观锁版本校验**(`version` 不匹配则提示冲突);`locked` 词条对非管理/拥有者只读。 +- 右下恰好为状态下拉 + 一个智能按钮;服务端给 owner/manager 下发 `force_save_presence` capability,按钮只检查该 capability;强制保存仅越过 presence 占用提示,仍校验版本。 +- 公开项目游客可进入只读编辑器,不保存、改状态、加入可写 presence、poke 或私信。 --- @@ -300,25 +319,33 @@ trait AuthProvider { { "key": "1234", "original": { "ko": "韩文原文", "ja": "日文原文" }, - "context": "可选上下文", "translation": "", "state": "untranslated" } ] ``` -- 至少需 `key` + `original`(建议含 `context`)。 -- **key 相同 → 覆盖原文、`state` 重置为 `untranslated`,并在词条历史记录更改与差异(diff)。** +- 最终 UI 上传原始 JSON 文件/文件夹,不提供粘贴文本框;浏览器不解析内容。上传四项限制由设置 API 下发。V1 不支持 Range/offset 续传;重试在同 logical file 新建 attempt 并从 byte zero 开始,batch 可取消,未完成 batch 默认 24h 过期并由 durable cleanup 清 temp。 +- 同路径重传是完整 replacement:缺失旧 key 可恢复软删除;既有平台译文保留;源文变化时状态重置未翻译;上传 translation/state 只 seed 从未存在的新 key。 +- 每文件流式解析并原子提交,batch 允许部分成功;重复 key 拒绝该文件并返回位置。原始上传文件只作临时任务输入,不进入历史。 +- `0008` foundation 仅预建 nullable deletion columns;legacy delete 仍硬删除并维护 stats,deletion_change_set_id 全 NULL,不提供恢复。`0010` 断言全 NULL并创建历史/FK后才切换:文件夹删除以同一 change-set 标记 subtree/descendant files,restore 只清本操作标记且不清 entry tombstone。FK 使用 RESTRICT/SET NULL;软删除默认 30 天,到期按业务行叶到根→items→sets 清除。 ### 14.2 下载 - 项目详情页下载 zip:**保留文件夹结构**,每文件输出 JSON,仅含 `key` / `original` / `translation`。 +- 普通导出使用总纲 §3 effective visibility;owner/manager 的 include_hidden 也不穿透 entry/file/folder deletion。 + +### 14.3 项目删除 + +- 删除 UI 先确认不可逆后果/24h/待删除只读,再要求完整项目 slug 精确匹配,之后才请求数学 challenge;两次确认只是前端门槛。仅唯一 `owner_id` 可通过绑定 user+project、短 TTL、一次性消费的 Redis 整数 challenge 安排删除;平台设置选择标准整数高数题或简单整数算术题,后端最终校验 owner 与答案。 +- 正确答案安排 24 小时后的持久化 purge,不立即级联。待删除项目从列表消失、只读、普通 jobs 暂停;唯一 owner 可看倒计时并取消。 +- 到期先在 DB 事务写 audit、detach/cancel 其它 jobs,按文件 30 天 purge 的兼容顺序显式清理全部 file history/tree,再清 tasks/terms/memberships 等关系并删除项目;不得依赖含糊 cascade 穿过 RESTRICT FK。purge job 以 nullable project_id 存活。提交后幂等清 media/temp,外部失败只重试同 job 且不复活项目;audit 保留无恢复正文的删除元数据。 --- ## 15. 配置与安全 -- **分层配置**:环境变量(密钥、连接串)+ 数据库存储的运行时设置(SMTP、注册开关、OAuth 模式、Embedding 开关等),并分类管理。 +- **分层配置**:环境变量(密钥、连接串、媒体/临时卷路径)+ 数据库存储的运行时设置(SMTP、注册开关、OAuth 模式、Embedding、上传限制、文件保留期、删除题型等),并分类管理。 - 密钥(DB/Redis/JWT/Qwen/OAuth secret)仅经环境变量注入,**绝不下发前端**,`.gitignore` 完全覆盖 `.env`。 - 安全:全程 HTTPS、CSRF/CORS 策略、输入校验、SQL 参数化(sqlx)、限流、最小权限、审计留痕。 - 写 **verify 验证代码**(见 §17)。 @@ -335,6 +362,7 @@ trait AuthProvider { ## 17. 工程规范 - **代码全注释且符合规范**:后端 `cargo fmt` + `clippy`;前端 ESLint + Prettier;前端文案面向用户。 +- **界面约束**:交互 UI 只用 Vue 3 + Quasar;浅/深主题、MDI、方角/2–4px;中文用 Noto Sans SC 同类 sans,JetBrains Mono 仅 code/key/number 且以 CJK sans fallback,不使用 SimSun/serif UI。 - **提交规范**:Conventional Commits(`feat:` / `fix:` / `docs:` / `refactor:` / `chore:` …)。 - **测试 + verify**:单元/集成测试;关键路径写验证脚本(含 20w 词条性能验证)。 - **`.gitignore` 完全**:Rust `target/`、Node `node_modules/`、构建产物、`.env`、IDE、日志等。 @@ -344,7 +372,7 @@ trait AuthProvider { ## 18. 部署(Docker / GHCR) -- `docker-compose`:`backend` + `frontend(nginx)` + `postgres(含扩展)` + `redis`;另有 `docker-compose.dev.yml` 供本地开发。 +- `docker-compose`:`backend` + `frontend(nginx)` + `postgres(含扩展)` + `redis`,并挂载 media/upload-temp 持久卷;另有 `docker-compose.dev.yml` 供本地开发。 - 各服务独立 Dockerfile(后端多阶段构建静态二进制;前端构建后 nginx 托管)。 - **每完成一个阶段:测试 → verify → 提交(规范) → 推 GitHub → 构建并推 GHCR 镜像**,供生产(`prts.zeroasso.top`)拉取。 @@ -353,6 +381,8 @@ trait AuthProvider { ## 19. 实施路线图 > 每阶段验收闭环:通过测试与 verify → 规范提交 → 推 GitHub → 推 GHCR 镜像。 +> +> 原始 P0–P7 用于系统首轮建设。2026-07-10 工作区大改造按 [`docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md`](../docs/superpowers/plans/2026-07-10-project-workspace-overhaul.md) 执行:基线/文档 → foundation(含不可拆分 `0008+0009`、trigger/backfill worker)→ A route/UI exposure → B → C → D → E → F → 全量发布。 | 阶段 | 内容 | | --- | --- | @@ -361,18 +391,18 @@ trait AuthProvider { | **P2 项目与文件系统** | 项目/文件夹/文件/词条 模型、上传(解析 + key 覆盖 + 历史 diff)、下载导出、词条 CRUD、状态机 + locked/hidden | | **P3 实时编辑器** | 词条列表(键集分页 + 搜索)、翻译面板、WebSocket 实时协作与在线状态、乐观锁 | | **P4 混合搜索** | FTS + pg_trgm + pgvector(Qwen Embedding 可插拔)+ RRF + 高级筛选 | -| **P5 历史与审计** | 全操作审计日志、按时间/项目清除、词条历史与差异 | -| **P6 CP 与贡献** | 编辑距离计分、用户 CP、项目贡献榜 | +| **P5 历史与审计** | 追加式安全审计、词条历史、文件行为 change set/delta 与保留期内可逆回滚 | +| **P6 CP 与贡献** | 未来按既定编辑距离公式计分并上线项目贡献榜;工作区大改造本轮仅准备精确存储 | | **P7 完善** | i18n 全量、文档(README 中英 + docs)、安全加固、20w 词条性能压测、verify | --- -## 20. 未决细节(实现时再与作者确认) +## 20. 后续范围边界 -- 权限节点的**完整清单**与各角色默认集合的最终勾选。 -- 贡献榜的展示口径(时间范围、按项目/全平台)。 -- 上传支持的**文件格式集合**(先 JSON;是否需要 i18n properties / PO / CSV 等导入器)。 -- 邮件模板与多语言;通知体系是否纳入 v1。 -- `pgvector` 索引类型(IVFFlat vs HNSW)与维度,依所选 Qwen 模型确定。 +- 权限节点以服务端 capabilities 和 2026-07-10 owner/manager/terms/tasks/history 规则为当前基线;扩大能力须另作作者决定。 +- 排行榜本轮只显示明确功能占位;时间范围、项目/平台口径在真实 CP 计分阶段单独设计。 +- 工作区上传本轮只支持 PRTS JSON 文件;properties/PO/其它格式属于独立导入器范围。 +- SMTP 邮件模板与更广泛的通知策略不属于本轮工作区改造;现有通知/私信功能不得回退。 +- 当前搜索使用 HNSW 与 1024 维 Qwen 配置;更换索引或维度须迁移数据并单列 ADR。 > 遵循草稿要求:实现细节有不确定的,一律先询问作者。 From 19b179338de44796500d4c58e383c7eee618cd49 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Fri, 10 Jul 2026 21:19:51 +0800 Subject: [PATCH 10/45] test: define audit and jobs foundation contracts --- .../crates/prts-api/tests/db_integration.rs | 776 ++++++++++++++++++ 1 file changed, 776 insertions(+) diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index 4348749..7056d6c 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -17,6 +17,782 @@ async fn pool() -> prts_db::Db { pool } +/// `0007` 必须同时建立追加式审计与持久化任务两项基础设施。 +/// +/// 该测试先于迁移编写,用于证明旧 schema 会明确报缺表,而不是因测试装配错误失败。 +#[tokio::test] +async fn audit_jobs_tables_exist_after_migration() { + let pool = pool().await; + + sqlx::query("SELECT id FROM audit_log LIMIT 0") + .execute(&pool) + .await + .expect("0007 应创建 audit_log"); + sqlx::query("SELECT id FROM jobs LIMIT 0") + .execute(&pool) + .await + .expect("0007 应创建 jobs"); +} + +/// 生成不会与并行 CI 或上次失败运行冲突的测试标识。 +fn audit_jobs_unique(prefix: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("系统时间应晚于 UNIX epoch") + .as_nanos(); + format!("{prefix}-{nanos}") +} + +/// 创建任务外键测试所需的最小用户与项目。 +async fn audit_jobs_project(pool: &prts_db::Db, prefix: &str) -> (i64, i64, String) { + let suffix = audit_jobs_unique(prefix); + let username = format!("u-{suffix}"); + let slug = format!("p-{suffix}"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(pool) + .await + .unwrap(); + let project_id: i64 = sqlx::query_scalar( + "INSERT INTO projects (slug, name, source_langs, target_lang, owner_id) + VALUES ($1, $1, ARRAY['en'], 'zh-Hans', $2) RETURNING id", + ) + .bind(&slug) + .bind(user_id) + .fetch_one(pool) + .await + .unwrap(); + (user_id, project_id, slug) +} + +/// 用与仓储契约相同的 `SKIP LOCKED` 语义领取一个任务。 +async fn audit_jobs_claim<'e, E>( + executor: E, + worker_id: &str, + pending_project_ids: &[i64], + eligible_kinds: &[String], +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + sqlx::query_scalar( + "WITH candidate AS ( + SELECT id + FROM jobs + WHERE ( + (state = 'queued' AND run_after <= now()) + OR (state = 'running' AND lease_until <= now()) + ) + AND kind = ANY($3::TEXT[]) + AND ( + project_id IS NULL + OR kind = 'project_purge' + OR NOT (project_id = ANY($2::BIGINT[])) + ) + ORDER BY run_after, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE jobs AS job + SET state = 'running', + worker_id = $1, + lease_until = now() + interval '5 minutes', + attempts = job.attempts + 1, + started_at = COALESCE(job.started_at, now()), + updated_at = now() + FROM candidate + WHERE job.id = candidate.id + RETURNING job.id", + ) + .bind(worker_id) + .bind(pending_project_ids) + .bind(eligible_kinds) + .fetch_optional(executor) + .await +} + +/// 审计记录只能追加,数据库层必须拒绝篡改与删除。 +#[tokio::test] +async fn audit_jobs_audit_log_is_append_only_and_secret_payloads_are_rejected() { + let pool = pool().await; + let target = audit_jobs_unique("audit-target"); + let audit_id: i64 = sqlx::query_scalar( + "INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, + project_id_snapshot, payload, ip + ) + VALUES (NULL, 'system', 'job.created', 'job', $1, NULL, $2, '127.0.0.1') + RETURNING id", + ) + .bind(&target) + .bind(serde_json::json!({"kind": "test_job"})) + .fetch_one(&pool) + .await + .unwrap(); + + let update_error = sqlx::query("UPDATE audit_log SET action = 'tampered' WHERE id = $1") + .bind(audit_id) + .execute(&pool) + .await + .expect_err("audit_log UPDATE 必须由数据库拒绝"); + assert_eq!( + update_error + .as_database_error() + .and_then(|error| error.code()), + Some(std::borrow::Cow::Borrowed("55000")) + ); + + let delete_error = sqlx::query("DELETE FROM audit_log WHERE id = $1") + .bind(audit_id) + .execute(&pool) + .await + .expect_err("audit_log DELETE 必须由数据库拒绝"); + assert_eq!( + delete_error + .as_database_error() + .and_then(|error| error.code()), + Some(std::borrow::Cow::Borrowed("55000")) + ); + + let secret_error = sqlx::query( + "INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, payload + ) VALUES (NULL, 'system', 'auth.failed', 'session', $1, $2)", + ) + .bind(audit_jobs_unique("secret-audit")) + .bind(serde_json::json!({"refresh_token": "raw-refresh-token"})) + .execute(&pool) + .await + .expect_err("审计 payload 不得接收 raw token 字段"); + assert!(secret_error.as_database_error().is_some()); +} + +/// 审计使用非级联 snapshot,用户和项目删除后仍能解释历史目标。 +#[tokio::test] +async fn audit_jobs_audit_actor_and_project_snapshots_survive_source_deletion() { + let pool = pool().await; + let (user_id, project_id, slug) = audit_jobs_project(&pool, "audit-snapshot").await; + let target = audit_jobs_unique("deleted-project"); + let audit_id: i64 = sqlx::query_scalar( + "INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, + project_id_snapshot, payload + ) VALUES ($1, 'user', 'project.deleted', 'project', $2, $3, $4) + RETURNING id", + ) + .bind(user_id) + .bind(&target) + .bind(project_id) + .bind(serde_json::json!({"slug": slug})) + .fetch_one(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); + + let snapshots: (Option, Option, String) = sqlx::query_as( + "SELECT actor_id, project_id_snapshot, payload->>'slug' + FROM audit_log WHERE id = $1", + ) + .bind(audit_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(snapshots, (Some(user_id), Some(project_id), slug)); +} + +/// 两个 worker 并发领取时,`SKIP LOCKED` 必须给出不同任务。 +#[tokio::test] +async fn audit_jobs_workers_claim_distinct_rows_concurrently() { + let pool = pool().await; + let marker = audit_jobs_unique("claim"); + let first: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, payload) VALUES ($1, '{}'), ($1, '{}') RETURNING id", + ) + .bind(&marker) + .fetch_one(&pool) + .await + .unwrap(); + + let mut tx_a = pool.begin().await.unwrap(); + let eligible = vec![marker.clone()]; + let claimed_a = audit_jobs_claim(&mut *tx_a, "worker-a", &[], &eligible) + .await + .unwrap() + .unwrap(); + let mut tx_b = pool.begin().await.unwrap(); + let claimed_b = audit_jobs_claim(&mut *tx_b, "worker-b", &[], &eligible) + .await + .unwrap() + .unwrap(); + + assert_ne!(claimed_a, claimed_b); + assert!(claimed_a >= first || claimed_b >= first); + tx_a.commit().await.unwrap(); + tx_b.commit().await.unwrap(); + + sqlx::query("DELETE FROM jobs WHERE kind = $1") + .bind(marker) + .execute(&pool) + .await + .unwrap(); +} + +/// 过期租约可由另一 worker 接管,未过期租约不可被抢占。 +#[tokio::test] +async fn audit_jobs_expired_lease_is_taken_over_and_active_lease_is_preserved() { + let pool = pool().await; + let expired_kind = audit_jobs_unique("expired"); + let active_kind = audit_jobs_unique("active"); + let expired_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, state, payload, worker_id, lease_until) + VALUES ($1, 'running', '{}', 'dead-worker', now() - interval '1 minute') + RETURNING id", + ) + .bind(&expired_kind) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO jobs (kind, state, payload, worker_id, lease_until) + VALUES ($1, 'running', '{}', 'live-worker', now() + interval '1 hour')", + ) + .bind(&active_kind) + .execute(&pool) + .await + .unwrap(); + + let eligible = vec![expired_kind.clone(), active_kind.clone()]; + let claimed = audit_jobs_claim(&pool, "replacement-worker", &[], &eligible) + .await + .unwrap(); + assert_eq!(claimed, Some(expired_id)); + let owner: String = sqlx::query_scalar("SELECT worker_id FROM jobs WHERE id = $1") + .bind(expired_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(owner, "replacement-worker"); + + sqlx::query("DELETE FROM jobs WHERE kind IN ($1, $2)") + .bind(expired_kind) + .bind(active_kind) + .execute(&pool) + .await + .unwrap(); +} + +/// 只有当前持有者能续租;旧 worker 在接管后不能延长新租约。 +#[tokio::test] +async fn audit_jobs_lease_renewal_requires_current_worker() { + let pool = pool().await; + let kind = audit_jobs_unique("renew"); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, state, payload, worker_id, lease_until) + VALUES ($1, 'running', '{}', 'current-worker', now() + interval '1 minute') + RETURNING id", + ) + .bind(&kind) + .fetch_one(&pool) + .await + .unwrap(); + + let stale_rows = sqlx::query( + "UPDATE jobs + SET lease_until = now() + interval '5 minutes', updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2", + ) + .bind(job_id) + .bind("stale-worker") + .execute(&pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(stale_rows, 0); + + let renewed_rows = sqlx::query( + "UPDATE jobs + SET lease_until = now() + interval '5 minutes', updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2", + ) + .bind(job_id) + .bind("current-worker") + .execute(&pool) + .await + .unwrap() + .rows_affected(); + assert_eq!(renewed_rows, 1); + + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 手动重试复用稳定 job id,并清理上次执行的租约与错误。 +#[tokio::test] +async fn audit_jobs_manual_retry_reuses_same_id_and_increments_attempts() { + let pool = pool().await; + let kind = audit_jobs_unique("retry"); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs ( + kind, state, payload, attempts, max_attempts, worker_id, lease_until, + last_error_code, last_error_message + ) + VALUES ($1, 'failed', '{}', 3, 3, 'failed-worker', now(), 'TEMP', 'temporary') + RETURNING id", + ) + .bind(&kind) + .fetch_one(&pool) + .await + .unwrap(); + + let (retried_id, attempts): (i64, i32) = sqlx::query_as( + "UPDATE jobs + SET state = 'queued', attempts = attempts + 1, run_after = now(), + lease_until = NULL, worker_id = NULL, + last_error_code = NULL, last_error_message = NULL, updated_at = now() + WHERE id = $1 AND state = 'failed' + RETURNING id, attempts", + ) + .bind(job_id) + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(retried_id, job_id); + assert_eq!(attempts, 4); + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .unwrap(); +} + +/// `0014` 前不引用未来列;调用者显式传入待删除项目集合完成暂停过滤。 +#[tokio::test] +async fn audit_jobs_pending_deletion_filter_pauses_non_purge_jobs() { + let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "pending-delete").await; + let normal_kind = audit_jobs_unique("normal"); + let normal_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) VALUES ($1, $2, '{}') RETURNING id", + ) + .bind(&normal_kind) + .bind(project_id) + .fetch_one(&pool) + .await + .unwrap(); + let purge_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) + VALUES ('project_purge', $1, $2) RETURNING id", + ) + .bind(project_id) + .bind(serde_json::json!({"project_id": project_id, "slug": "snapshot"})) + .fetch_one(&pool) + .await + .unwrap(); + + sqlx::query( + "UPDATE jobs + SET state = 'paused', lease_until = NULL, worker_id = NULL, updated_at = now() + WHERE project_id = ANY($1::BIGINT[]) + AND kind <> 'project_purge' + AND state IN ('queued', 'running')", + ) + .bind(vec![project_id]) + .execute(&pool) + .await + .unwrap(); + let eligible = vec![normal_kind.clone(), "project_purge".to_string()]; + let claimed = audit_jobs_claim(&pool, "purge-worker", &[project_id], &eligible) + .await + .unwrap(); + + assert_eq!(claimed, Some(purge_id)); + let normal_state: String = sqlx::query_scalar("SELECT state FROM jobs WHERE id = $1") + .bind(normal_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(normal_state, "paused"); + + sqlx::query("DELETE FROM jobs WHERE project_id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 任务进度可在运行中持久化,且不可超过声明总量。 +#[tokio::test] +async fn audit_jobs_progress_updates_are_bounded() { + let pool = pool().await; + let kind = audit_jobs_unique("progress"); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, state, payload, progress_current, progress_total) + VALUES ($1, 'running', '{}', 0, 10) RETURNING id", + ) + .bind(&kind) + .fetch_one(&pool) + .await + .unwrap(); + + let progress: (i64, Option) = sqlx::query_as( + "UPDATE jobs SET progress_current = 6, updated_at = now() + WHERE id = $1 RETURNING progress_current, progress_total", + ) + .bind(job_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(progress, (6, Some(10))); + + sqlx::query("UPDATE jobs SET progress_current = 11 WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .expect_err("progress_current 不得超过 progress_total"); + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 项目删除后 job 保留且外键置空;purge 仅凭不可变 payload snapshot 继续。 +#[tokio::test] +async fn audit_jobs_project_delete_sets_null_and_purge_snapshot_survives() { + let pool = pool().await; + let (user_id, project_id, slug) = audit_jobs_project(&pool, "purge-snapshot").await; + let deadline = "2026-07-11T00:00:00Z"; + let payload = serde_json::json!({ + "project_id": project_id, + "slug": slug, + "media_keys": ["projects/avatar.webp"], + "temp_keys": ["uploads/pending.json"], + "deadline": deadline + }); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) + VALUES ('project_purge', $1, $2) RETURNING id", + ) + .bind(project_id) + .bind(&payload) + .fetch_one(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + let (live_project_id, stored_payload): (Option, serde_json::Value) = + sqlx::query_as("SELECT project_id, payload FROM jobs WHERE id = $1") + .bind(job_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(live_project_id, None); + assert_eq!(stored_payload, payload); + + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 会话状态由 PostgreSQL 权威保存,支持签发、轮换、吊销和过期全生命周期。 +#[tokio::test] +async fn audit_jobs_auth_session_state_machine_and_token_storage_contract() { + let pool = pool().await; + let username = audit_jobs_unique("session-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let session_handle = audit_jobs_unique("session-handle"); + let family_handle = audit_jobs_unique("family-handle"); + let refresh_hash = audit_jobs_unique("refresh-hash"); + let session_id: i64 = sqlx::query_scalar( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, 'pending', now() + interval '1 day') RETURNING id", + ) + .bind(&session_handle) + .bind(&family_handle) + .bind(user_id) + .bind(&refresh_hash) + .fetch_one(&pool) + .await + .unwrap(); + + for state in ["active", "rotating", "revoked"] { + let stored: String = sqlx::query_scalar( + "UPDATE auth_sessions SET state = $2, updated_at = now() WHERE id = $1 RETURNING state", + ) + .bind(session_id) + .bind(state) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored, state); + } + let expired_handle = audit_jobs_unique("expired-handle"); + sqlx::query( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, 'expired', now() - interval '1 second')", + ) + .bind(&expired_handle) + .bind(&family_handle) + .bind(user_id) + .bind(audit_jobs_unique("expired-refresh-hash")) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("UPDATE auth_sessions SET state = 'unknown' WHERE id = $1") + .bind(session_id) + .execute(&pool) + .await + .expect_err("未知 auth session 状态必须由约束拒绝"); + + let forbidden_columns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name IN ('auth_sessions', 'auth_session_intents', 'jobs', 'audit_log') + AND column_name IN ('access_token', 'refresh_token', 'raw_access_token', 'raw_refresh_token')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(forbidden_columns, 0); + + let serialized_row: String = + sqlx::query_scalar("SELECT row_to_json(s)::TEXT FROM auth_sessions AS s WHERE id = $1") + .bind(session_id) + .fetch_one(&pool) + .await + .unwrap(); + assert!(serialized_row.contains(&refresh_hash)); + assert!(!serialized_row.contains("raw-access-token")); + assert!(!serialized_row.contains("raw-refresh-token")); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// rotation 的前后继关系必须可追踪,且删除后继时不会破坏历史会话行。 +#[tokio::test] +async fn audit_jobs_auth_session_rotation_links_predecessor_and_successor() { + let pool = pool().await; + let username = audit_jobs_unique("rotation-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let family = audit_jobs_unique("rotation-family"); + let predecessor_id: i64 = sqlx::query_scalar( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, 'rotating', now() + interval '1 day') RETURNING id", + ) + .bind(audit_jobs_unique("predecessor")) + .bind(&family) + .bind(user_id) + .bind(audit_jobs_unique("predecessor-hash")) + .fetch_one(&pool) + .await + .unwrap(); + let successor_id: i64 = sqlx::query_scalar( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, + expires_at, predecessor_id + ) VALUES ($1, $2, $3, $4, 'pending', now() + interval '1 day', $5) RETURNING id", + ) + .bind(audit_jobs_unique("successor")) + .bind(&family) + .bind(user_id) + .bind(audit_jobs_unique("successor-hash")) + .bind(predecessor_id) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("UPDATE auth_sessions SET successor_id = $2 WHERE id = $1") + .bind(predecessor_id) + .bind(successor_id) + .execute(&pool) + .await + .unwrap(); + + let links: (Option, Option) = sqlx::query_as( + "SELECT + (SELECT successor_id FROM auth_sessions WHERE id = $1), + (SELECT predecessor_id FROM auth_sessions WHERE id = $2)", + ) + .bind(predecessor_id) + .bind(successor_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(links, (Some(successor_id), Some(predecessor_id))); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// auth intent/outbox 使用租约领取,租约过期后复用同一 intent 行重试。 +#[tokio::test] +async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { + let pool = pool().await; + let username = audit_jobs_unique("intent-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let session_id: i64 = sqlx::query_scalar( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, 'active', now() + interval '1 day') RETURNING id", + ) + .bind(audit_jobs_unique("intent-session")) + .bind(audit_jobs_unique("intent-family")) + .bind(user_id) + .bind(audit_jobs_unique("intent-refresh-hash")) + .fetch_one(&pool) + .await + .unwrap(); + let intent_id: i64 = sqlx::query_scalar( + "INSERT INTO auth_session_intents ( + session_id, kind, state, payload, attempts, max_attempts, + worker_id, lease_until + ) VALUES ( + $1, 'redis_invalidate', 'running', $2, 1, 5, + 'dead-worker', now() - interval '1 minute' + ) RETURNING id", + ) + .bind(session_id) + .bind(serde_json::json!({"session_handle": "opaque-only"})) + .fetch_one(&pool) + .await + .unwrap(); + + let claimed: i64 = sqlx::query_scalar( + "WITH candidate AS ( + SELECT id FROM auth_session_intents + WHERE (state = 'queued' AND run_after <= now()) + OR (state = 'running' AND lease_until <= now()) + ORDER BY run_after, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE auth_session_intents AS intent + SET state = 'running', worker_id = 'replacement-worker', + lease_until = now() + interval '5 minutes', + attempts = intent.attempts + 1, updated_at = now() + FROM candidate + WHERE intent.id = candidate.id + RETURNING intent.id", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(claimed, intent_id); + + let (retried_id, attempts): (i64, i32) = sqlx::query_as( + "UPDATE auth_session_intents + SET state = 'queued', attempts = attempts + 1, run_after = now(), + worker_id = NULL, lease_until = NULL, last_error_code = NULL, + last_error_message = NULL, updated_at = now() + WHERE id = $1 + RETURNING id, attempts", + ) + .bind(intent_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(retried_id, intent_id); + assert_eq!(attempts, 3); + + sqlx::query( + "INSERT INTO auth_session_intents (session_id, kind, payload) + VALUES ($1, 'redis_populate', $2)", + ) + .bind(session_id) + .bind(serde_json::json!({"access_token": "raw-access-token"})) + .execute(&pool) + .await + .expect_err("auth intent payload 不得接收 raw token 字段"); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// job payload 同样必须拒绝 raw token 字段,避免异步链路泄密。 +#[tokio::test] +async fn audit_jobs_job_payload_rejects_raw_tokens() { + let pool = pool().await; + sqlx::query("INSERT INTO jobs (kind, payload) VALUES ('unsafe-test', $1)") + .bind(serde_json::json!({"refresh_token": "raw-refresh-token"})) + .execute(&pool) + .await + .expect_err("job payload 不得接收 raw refresh token 字段"); + sqlx::query("INSERT INTO jobs (kind, payload) VALUES ('unsafe-test', $1)") + .bind(serde_json::json!({"access_token": "raw-access-token"})) + .execute(&pool) + .await + .expect_err("job payload 不得接收 raw access token 字段"); +} + #[tokio::test] async fn users_api_keys_settings_oauth_roundtrip() { let pool = pool().await; From 9dd57a9f82ceda1f1a6eca55701b52da579a04ba Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Fri, 10 Jul 2026 22:17:45 +0800 Subject: [PATCH 11/45] feat: add durable audit and job foundation --- .env.example | 10 +- .github/workflows/ci.yml | 12 +- README.en.md | 8 + README.md | 6 + backend/config/default.toml | 6 +- backend/crates/prts-api/src/auth/extract.rs | 77 +- backend/crates/prts-api/src/error.rs | 50 +- backend/crates/prts-api/src/job_worker.rs | 197 +++ backend/crates/prts-api/src/jobs/mod.rs | 116 ++ backend/crates/prts-api/src/main.rs | 60 +- backend/crates/prts-api/src/openapi.rs | 1 + backend/crates/prts-api/src/routes/jobs.rs | 822 ++++++++++ backend/crates/prts-api/src/routes/mod.rs | 33 + backend/crates/prts-api/src/state.rs | 2 + .../crates/prts-api/tests/db_integration.rs | 1376 +++++++++++++---- backend/crates/prts-api/tests/search_perf.rs | 18 +- backend/crates/prts-common/src/config.rs | 19 +- backend/crates/prts-core/src/jobs.rs | 227 +++ backend/crates/prts-core/src/lib.rs | 2 + backend/crates/prts-db/src/audit.rs | 176 +++ backend/crates/prts-db/src/auth_sessions.rs | 668 ++++++++ backend/crates/prts-db/src/jobs.rs | 457 ++++++ backend/crates/prts-db/src/lib.rs | 147 +- backend/crates/prts-db/src/models.rs | 41 + backend/migrations/0007_audit_jobs.sql | 335 ++++ deploy/docker-compose.yml | 40 +- deploy/postgres-init/10-runtime-role.sh | 25 + deploy/postgres.Dockerfile | 4 + 28 files changed, 4563 insertions(+), 372 deletions(-) create mode 100644 backend/crates/prts-api/src/job_worker.rs create mode 100644 backend/crates/prts-api/src/jobs/mod.rs create mode 100644 backend/crates/prts-api/src/routes/jobs.rs create mode 100644 backend/crates/prts-core/src/jobs.rs create mode 100644 backend/crates/prts-db/src/audit.rs create mode 100644 backend/crates/prts-db/src/auth_sessions.rs create mode 100644 backend/crates/prts-db/src/jobs.rs create mode 100644 backend/migrations/0007_audit_jobs.sql create mode 100644 deploy/postgres-init/10-runtime-role.sh diff --git a/.env.example b/.env.example index ff7283f..57e4516 100644 --- a/.env.example +++ b/.env.example @@ -12,10 +12,14 @@ PRTS__SERVER__HOST=0.0.0.0 PRTS__SERVER__PORT=3000 # --- PostgreSQL(compose 内服务名为 postgres)--- -POSTGRES_USER=prts -POSTGRES_PASSWORD=prts +POSTGRES_MIGRATION_USER=prts_migrator +POSTGRES_MIGRATION_PASSWORD=dev-migrator-change-me +POSTGRES_RUNTIME_USER=prts_runtime +POSTGRES_RUNTIME_PASSWORD=dev-runtime-change-me POSTGRES_DB=prts -PRTS__DATABASE__URL=postgres://prts:prts@postgres:5432/prts +PRTS__DATABASE__URL=postgres://prts_runtime:dev-runtime-change-me@postgres:5432/prts +PRTS__DATABASE__MIGRATION_URL=postgres://prts_migrator:dev-migrator-change-me@postgres:5432/prts +PRTS__DATABASE__RUNTIME_ROLE=prts_runtime PRTS__DATABASE__MAX_CONNECTIONS=10 # --- Redis(compose 内服务名为 redis)--- diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6ef564..c126d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,15 +36,19 @@ jobs: - name: Start Postgres + Redis run: | docker run -d --name pg -p 5432:5432 \ - -e POSTGRES_USER=prts -e POSTGRES_PASSWORD=prts -e POSTGRES_DB=prts_test \ + -e POSTGRES_USER=prts_migrator -e POSTGRES_PASSWORD=migrator-ci \ + -e POSTGRES_RUNTIME_USER=prts_runtime -e POSTGRES_RUNTIME_PASSWORD=runtime-ci \ + -e POSTGRES_DB=prts_test \ prts-postgres:ci docker run -d --name redis -p 6379:6379 redis:7-alpine - for i in $(seq 1 30); do docker exec pg pg_isready -U prts && break; sleep 2; done - docker exec pg pg_isready -U prts || { echo "Postgres not ready after 60s"; docker logs pg; exit 1; } + for i in $(seq 1 30); do docker exec pg pg_isready -U prts_migrator && break; sleep 2; done + docker exec pg pg_isready -U prts_migrator || { echo "Postgres not ready after 60s"; docker logs pg; exit 1; } - name: Integration tests (Postgres) run: cargo test -p prts-api --features db-tests env: - DATABASE_URL: postgres://prts:prts@localhost:5432/prts_test + MIGRATION_DATABASE_URL: postgres://prts_migrator:migrator-ci@localhost:5432/prts_test + DATABASE_URL: postgres://prts_runtime:runtime-ci@localhost:5432/prts_test + PRTS_TEST_RUNTIME_ROLE: prts_runtime PRTS__REDIS__URL: redis://localhost:6379 frontend: diff --git a/README.en.md b/README.en.md index ccae751..6f2ee20 100644 --- a/README.en.md +++ b/README.en.md @@ -46,6 +46,14 @@ cp .env.example .env # fill in DB / Redis / JWT / OAuth / Qwen, etc. docker compose -f deploy/docker-compose.yml up -d ``` +The migration account and application runtime account must be separate. On a fresh volume, +Compose creates the runtime role from `POSTGRES_MIGRATION_*` / `POSTGRES_RUNTIME_*` and runs +migrations in the one-shot `migrate` service; the backend receives only the runtime URL. When +upgrading an existing PostgreSQL volume, first have a database administrator create the matching +non-superuser runtime login role, then configure both URLs as shown in `.env.example`. Startup +fails closed if the roles are the same or the runtime role still owns tables. Keep real credentials +only in local environment variables and never commit them. + Then: - Frontend: `http://localhost:8080` diff --git a/README.md b/README.md index edab055..482d974 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,12 @@ cp .env.example .env # 按需填写数据库 / Redis / JWT / OAuth / Qwen docker compose -f deploy/docker-compose.yml up -d ``` +数据库迁移账号与应用 runtime 账号必须分离。新空卷会按 `.env` 中的 +`POSTGRES_MIGRATION_*` / `POSTGRES_RUNTIME_*` 自动创建 runtime role,并由一次性 +`migrate` 服务执行迁移;backend 只接收 runtime URL。升级已有 PostgreSQL 卷时,需先由 +数据库管理员创建同名的非 superuser runtime login role,再按 `.env.example` 设置两组 URL; +两者相同或 runtime 仍拥有表时,服务会 fail closed。真实凭据只写本地环境变量,不提交仓库。 + 启动后: - 前端:`http://localhost:8080` diff --git a/backend/config/default.toml b/backend/config/default.toml index 743f155..1a54535 100644 --- a/backend/config/default.toml +++ b/backend/config/default.toml @@ -12,8 +12,10 @@ host = "0.0.0.0" port = 3000 [database] -# 生产环境务必用 PRTS__DATABASE__URL 覆盖为真实连接串。 -url = "postgres://prts:prts@localhost:5432/prts" +# 生产环境务必用 PRTS__DATABASE__URL 覆盖为 runtime 连接串。 +# migration owner URL 仅经 PRTS__DATABASE__MIGRATION_URL 注入 `prts-api migrate` 进程。 +url = "postgres://prts_runtime:prts_runtime@localhost:5432/prts" +runtime_role = "prts_runtime" max_connections = 10 [redis] diff --git a/backend/crates/prts-api/src/auth/extract.rs b/backend/crates/prts-api/src/auth/extract.rs index 22cbf38..613aa20 100644 --- a/backend/crates/prts-api/src/auth/extract.rs +++ b/backend/crates/prts-api/src/auth/extract.rs @@ -40,44 +40,53 @@ impl FromRequestParts for CurrentUser { parts: &mut Parts, state: &AppState, ) -> Result { - let token = parts - .headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - .map(str::trim) - .filter(|t| !t.is_empty()) - .ok_or(Error::Unauthorized)?; - - let user = if token.starts_with(prts_auth::token::API_KEY_PREFIX) { - let hash = prts_auth::token::sha256_hex(token); - let user = prts_db::api_keys::find_user_by_key_hash(&state.db, &hash) - .await - .map_err(|e| Error::internal(format!("db error: {e}")))? + let locale = parts + .extensions + .get::() + .copied() + .unwrap_or_default(); + let result: Result = async { + let token = parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|t| !t.is_empty()) .ok_or(Error::Unauthorized)?; - // 记录最近使用(忽略失败)。 - let _ = prts_db::api_keys::touch_last_used(&state.db, &hash).await; - user - } else { - let claims = prts_auth::jwt::decode(token, state.jwt_secret()) - .map_err(|_| Error::Unauthorized)?; - if !claims.is_valid_at(chrono::Utc::now().timestamp()) { - return Err(Error::Unauthorized.into()); + + let user = if token.starts_with(prts_auth::token::API_KEY_PREFIX) { + let hash = prts_auth::token::sha256_hex(token); + let user = prts_db::api_keys::find_user_by_key_hash(&state.db, &hash) + .await + .map_err(|e| Error::internal(format!("db error: {e}")))? + .ok_or(Error::Unauthorized)?; + // 记录最近使用(忽略失败)。 + let _ = prts_db::api_keys::touch_last_used(&state.db, &hash).await; + user + } else { + let claims = prts_auth::jwt::decode(token, state.jwt_secret()) + .map_err(|_| Error::Unauthorized)?; + if !claims.is_valid_at(chrono::Utc::now().timestamp()) { + return Err(Error::Unauthorized.into()); + } + prts_db::users::find_by_id(&state.db, claims.sub) + .await + .map_err(|e| Error::internal(format!("db error: {e}")))? + .ok_or(Error::Unauthorized)? + }; + + if user.status != "active" { + return Err(Error::Forbidden.into()); } - prts_db::users::find_by_id(&state.db, claims.sub) - .await - .map_err(|e| Error::internal(format!("db error: {e}")))? - .ok_or(Error::Unauthorized)? - }; - if user.status != "active" { - return Err(Error::Forbidden.into()); + Ok(CurrentUser { + id: user.id, + platform_role: user.platform_role.as_deref().and_then(PlatformRole::parse), + }) } - - Ok(CurrentUser { - id: user.id, - platform_role: user.platform_role.as_deref().and_then(PlatformRole::parse), - }) + .await; + result.map_err(|error| error.with_locale(locale)) } } diff --git a/backend/crates/prts-api/src/error.rs b/backend/crates/prts-api/src/error.rs index f50972a..248b931 100644 --- a/backend/crates/prts-api/src/error.rs +++ b/backend/crates/prts-api/src/error.rs @@ -3,12 +3,14 @@ //! 把 [`prts_common::Error`] 映射为 HTTP 状态码 + JSON `{ code, message }`, //! 其中 `message` 经 [`prts_common::i18n`] 按请求语言本地化。 -use axum::http::StatusCode; +use axum::extract::FromRequestParts; +use axum::http::{request::Parts, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use prts_common::i18n::{localize, Locale}; use prts_common::Error; use serde::Serialize; +use utoipa::ToSchema; /// API 错误响应包装:携带底层错误与目标语言。 pub struct ApiError { @@ -26,13 +28,43 @@ impl From for ApiError { } } -/// 线上线错误响应体。 -#[derive(Serialize)] -struct ErrorBody { +impl ApiError { + /// 在路由边界覆盖 locale,保留稳定错误码。 + pub fn with_locale(mut self, locale: Locale) -> Self { + self.locale = locale; + self + } +} + +/// 共享错误响应体;所有 OpenAPI error response 复用该 schema。 +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { /// 稳定错误码(前端据此分支与本地化)。 - code: &'static str, + pub code: String, /// 已本地化的人类可读消息。 - message: &'static str, + pub message: String, +} + +/// 从 `Accept-Language` 提取请求语言,并写入 extensions 供鉴权 extractor 复用。 +#[derive(Debug, Clone, Copy)] +pub struct RequestLocale(pub Locale); + +impl FromRequestParts for RequestLocale +where + S: Send + Sync, +{ + type Rejection = std::convert::Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let locale = parts + .headers + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) + .map(Locale::from_accept_language) + .unwrap_or_default(); + parts.extensions.insert(locale); + Ok(Self(locale)) + } } fn status_for(code: &str) -> StatusCode { @@ -49,9 +81,9 @@ fn status_for(code: &str) -> StatusCode { impl IntoResponse for ApiError { fn into_response(self) -> Response { let code = self.error.code(); - let body = ErrorBody { - code, - message: localize(code, self.locale), + let body = ErrorResponse { + code: code.to_string(), + message: localize(code, self.locale).to_string(), }; (status_for(code), Json(body)).into_response() } diff --git a/backend/crates/prts-api/src/job_worker.rs b/backend/crates/prts-api/src/job_worker.rs new file mode 100644 index 0000000..5aebd29 --- /dev/null +++ b/backend/crates/prts-api/src/job_worker.rs @@ -0,0 +1,197 @@ +//! 持久化任务 worker:受控领取、租约续期、崩溃接管与结果持久化。 + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::future::BoxFuture; +use tokio::sync::Notify; +use tokio::time::{Instant, MissedTickBehavior}; + +use crate::jobs::{JobErrorCode, JobExecutionError, JobRegistry}; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const LEASE_SECONDS: i64 = 30; +const RENEW_INTERVAL: Duration = Duration::from_secs(10); + +/// 待删除项目提供方。`0014` 可替换实现;foundation 默认实现不查询未来列。 +pub trait PendingDeletionSource: Send + Sync { + fn project_ids(&self) -> BoxFuture<'_, Result, sqlx::Error>>; +} + +/// `0014` 前的 dormant gate:当前 schema 尚无待删除项目。 +pub struct NoPendingDeletions; + +impl PendingDeletionSource for NoPendingDeletions { + fn project_ids(&self) -> BoxFuture<'_, Result, sqlx::Error>> { + Box::pin(async { Ok(Vec::new()) }) + } +} + +/// worker 唤醒句柄。新建/重试任务提交后可减少轮询等待,不暴露 worker 内部状态。 +#[derive(Clone)] +pub struct JobWorkerControl { + notify: Arc, +} + +impl JobWorkerControl { + /// 通知 worker 尽快检查队列。 + pub fn wake(&self) { + self.notify.notify_one(); + } + + async fn wait(&self) { + tokio::select! { + _ = tokio::time::sleep(POLL_INTERVAL) => {} + _ = self.notify.notified() => {} + } + } +} + +/// 启动持久化 worker。空 handler 注册表时只休眠,不会误领未知任务。 +pub fn spawn( + db: prts_db::Db, + registry: JobRegistry, + pending_deletions: Arc, +) -> JobWorkerControl { + let control = JobWorkerControl { + notify: Arc::new(Notify::new()), + }; + let loop_control = control.clone(); + let worker_id = format!( + "job-worker-{}", + prts_auth::token::random_token(12).to_lowercase() + ); + tokio::spawn(async move { + loop { + if registry.kinds().is_empty() { + loop_control.wait().await; + continue; + } + if let Err(error) = + run_once(&db, ®istry, pending_deletions.as_ref(), &worker_id).await + { + tracing::error!(%error, "durable job worker iteration failed"); + } + loop_control.wait().await; + } + }); + control +} + +async fn run_once( + db: &prts_db::Db, + registry: &JobRegistry, + pending_deletions: &dyn PendingDeletionSource, + worker_id: &str, +) -> anyhow::Result<()> { + let kinds = registry.kinds(); + if kinds.is_empty() { + return Ok(()); + } + let pending_project_ids = pending_deletions.project_ids().await?; + let Some(job) = prts_db::jobs::claim_next_for_kinds( + db, + worker_id, + LEASE_SECONDS, + &pending_project_ids, + &kinds, + ) + .await? + else { + return Ok(()); + }; + + let Some(handler) = registry.get(&job.kind) else { + // 注册表在 claim 后发生替换时安全释放为可人工处理的失败,而不是静默成功。 + persist_failure( + db, + &job, + worker_id, + JobExecutionError { + code: JobErrorCode::HandlerUnavailable, + message: "registered job handler disappeared".to_string(), + retryable: false, + }, + ) + .await?; + return Ok(()); + }; + + let execution = handler.execute(&job); + tokio::pin!(execution); + let mut renew = tokio::time::interval_at(Instant::now() + RENEW_INTERVAL, RENEW_INTERVAL); + renew.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + tokio::select! { + outcome = &mut execution => { + match outcome { + Ok(result) => { + let updated = prts_db::jobs::complete(db, job.id, worker_id, result).await?; + if updated.is_none() { + tracing::warn!(job_id = job.id, "job completion lost its lease"); + } + } + Err(error) => persist_failure(db, &job, worker_id, error).await?, + } + return Ok(()); + } + _ = renew.tick() => { + if !prts_db::jobs::renew_lease(db, job.id, worker_id, LEASE_SECONDS).await? { + tracing::warn!(job_id = job.id, "job worker lost lease during execution"); + return Ok(()); + } + } + } + } +} + +async fn persist_failure( + db: &prts_db::Db, + job: &prts_db::models::Job, + worker_id: &str, + error: JobExecutionError, +) -> Result<(), sqlx::Error> { + let retry_after = prts_core::jobs::retry_backoff_seconds(job.attempts); + let retryable = error.retryable; + let (error_code, error_message) = normalize_execution_error(error); + prts_db::jobs::fail_attempt( + db, + job.id, + worker_id, + error_code, + &error_message, + retryable, + retry_after, + ) + .await?; + Ok(()) +} + +/// 把 handler 任意错误收敛为稳定 code 与固定、短小、脱敏的内部消息。 +fn normalize_execution_error(error: JobExecutionError) -> (&'static str, String) { + let JobExecutionError { + code, + message, + retryable: _, + } = error; + drop(message); + (code.as_str(), code.redacted_message().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handler_errors_are_stable_and_redacted_before_persistence() { + let (code, message) = normalize_execution_error(JobExecutionError { + code: JobErrorCode::HandlerUnavailable, + message: "stack trace Authorization: Bearer raw-access-token".to_string(), + retryable: true, + }); + assert_eq!(code, "job_handler_unavailable"); + assert!(message.len() <= 128); + assert!(!message.contains("raw-access-token")); + assert!(!message.contains("Authorization")); + } +} diff --git a/backend/crates/prts-api/src/jobs/mod.rs b/backend/crates/prts-api/src/jobs/mod.rs new file mode 100644 index 0000000..53fac1f --- /dev/null +++ b/backend/crates/prts-api/src/jobs/mod.rs @@ -0,0 +1,116 @@ +//! 可扩展任务 handler 注册表。 +//! +//! foundation 阶段不提前实现后续上传/重建/purge 业务 handler;worker 只会领取已注册 +//! kind,因此空注册表是安全的 dormant 状态,后续阶段可逐项注册执行器。 + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use futures_util::future::BoxFuture; + +pub use prts_db::jobs::JobResult; + +/// foundation worker 当前实际产生的稳定错误码;后续 handler 按需扩展。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobErrorCode { + HandlerUnavailable, +} + +impl JobErrorCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::HandlerUnavailable => "job_handler_unavailable", + } + } + + pub const fn redacted_message(self) -> &'static str { + match self { + Self::HandlerUnavailable => "job handler is not available", + } + } +} + +/// 任务执行失败的稳定信息。 +#[derive(Debug, Clone)] +pub struct JobExecutionError { + pub code: JobErrorCode, + /// handler 原始错误只用于进程内诊断,不得持久化或进入 API。 + pub message: String, + pub retryable: bool, +} + +/// 单一任务 kind 的执行器。 +pub trait JobHandler: Send + Sync { + /// 与 `jobs.kind` 对应的稳定标识。 + fn kind(&self) -> &'static str; + + /// 执行已租用任务。handler 通过仓储更新阶段/进度,不直接操作路由状态。 + fn execute<'a>( + &'a self, + job: &'a prts_db::models::Job, + ) -> BoxFuture<'a, Result>; +} + +/// 线程安全的 handler 注册表。 +#[derive(Clone)] +pub struct JobRegistry { + handlers: Arc>>>, +} + +impl JobRegistry { + /// 从当前 release 提供的 handler 构造注册表;空列表表示安全 dormant worker。 + pub fn new(handlers: Vec>) -> Self { + let handlers = handlers + .into_iter() + .map(|handler| (handler.kind().to_string(), handler)) + .collect(); + Self { + handlers: Arc::new(RwLock::new(handlers)), + } + } + + /// 当前可安全领取的任务 kind。 + pub fn kinds(&self) -> Vec { + self.handlers + .read() + .expect("job registry lock poisoned") + .keys() + .cloned() + .collect() + } + + /// 获取执行器快照。 + pub fn get(&self, kind: &str) -> Option> { + self.handlers + .read() + .expect("job registry lock poisoned") + .get(kind) + .cloned() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TypedResultHandler; + + impl JobHandler for TypedResultHandler { + fn kind(&self) -> &'static str { + "upload_cleanup" + } + + fn execute<'a>( + &'a self, + _job: &'a prts_db::models::Job, + ) -> BoxFuture<'a, Result> { + Box::pin(async { Ok(JobResult::Completed) }) + } + } + + #[test] + fn handlers_return_allowlisted_results_instead_of_arbitrary_json() { + let registry = JobRegistry::new(vec![Arc::new(TypedResultHandler)]); + assert!(registry.get("upload_cleanup").is_some()); + } +} diff --git a/backend/crates/prts-api/src/main.rs b/backend/crates/prts-api/src/main.rs index 8626667..d2dc998 100644 --- a/backend/crates/prts-api/src/main.rs +++ b/backend/crates/prts-api/src/main.rs @@ -1,13 +1,16 @@ //! PRTS API 服务入口。 //! //! 启动流程:加载 `.env`(开发) → 初始化日志 → 加载分层配置 → 连接 PostgreSQL/Redis → -//! 执行迁移 → bootstrap 管理员 → 装配路由 → 监听并优雅停机。 +//! runtime 角色校验 → bootstrap 管理员 → 装配路由 → 监听并优雅停机。 +//! 数据库迁移由独立的 `prts-api migrate` 进程使用 migration owner 执行。 mod appsettings; mod auth; mod dto; mod embed_worker; mod error; +mod job_worker; +mod jobs; mod openapi; mod routes; mod state; @@ -29,21 +32,33 @@ async fn main() -> anyhow::Result<()> { prts_common::logging::init(); let settings = Settings::load()?; + + match std::env::args().nth(1).as_deref() { + Some("migrate") => return run_migration_command(&settings).await, + Some(other) => anyhow::bail!("unknown command: {other}"), + None => {} + } + if settings + .database + .migration_url + .as_deref() + .is_some_and(|url| !url.trim().is_empty()) + { + anyhow::bail!("runtime process must not receive database.migration_url"); + } tracing::info!(addr = %settings.server.addr(), "starting PRTS API"); // 连接依赖。P0:失败即快速退出并给出清晰错误。 let db = prts_db::connect_postgres(&settings.database.url, settings.database.max_connections) .await .map_err(|e| anyhow::anyhow!("connect postgres failed: {e}"))?; + prts_db::verify_runtime_role(&db, &settings.database.runtime_role) + .await + .map_err(|e| anyhow::anyhow!("runtime database role verification failed: {e}"))?; let cache = prts_db::connect_redis(&settings.redis.url) .await .map_err(|e| anyhow::anyhow!("connect redis failed: {e}"))?; - // 执行嵌入式迁移。 - prts_db::run_migrations(&db) - .await - .map_err(|e| anyhow::anyhow!("run migrations failed: {e}"))?; - // bootstrap:把配置的用户名提升为 super_admin(若已存在且尚无平台角色)。 bootstrap_admin(&db, &settings).await; @@ -73,6 +88,14 @@ async fn main() -> anyhow::Result<()> { // 启动后台嵌入 sweep(clones cheap: pool 引用计数,Arc 指针)。 crate::embed_worker::spawn(db.clone(), embedder.clone(), search_rt.clone()); + // 持久化任务 worker。foundation 注册表为空时保持 dormant,后续阶段逐类注册 handler。 + let job_registry = crate::jobs::JobRegistry::new(Vec::new()); + let job_worker = crate::job_worker::spawn( + db.clone(), + job_registry.clone(), + Arc::new(crate::job_worker::NoPendingDeletions), + ); + let addr = settings.server.addr(); let state = AppState { db, @@ -82,6 +105,7 @@ async fn main() -> anyhow::Result<()> { realtime, embedder, search_rt, + job_worker, }; let app = routes::app(state); @@ -94,6 +118,30 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// 用独立 migration owner 执行迁移后立即销毁连接;不会构造 AppState 或启动服务。 +async fn run_migration_command(settings: &Settings) -> anyhow::Result<()> { + let migration_url = settings + .database + .migration_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + .ok_or_else(|| anyhow::anyhow!("database.migration_url is required for migrate"))?; + let migration_pool = prts_db::connect_postgres(migration_url, 1) + .await + .map_err(|error| anyhow::anyhow!("connect migration postgres failed: {error}"))?; + let mut connection = migration_pool + .acquire() + .await + .map_err(|error| anyhow::anyhow!("acquire migration connection failed: {error}"))?; + prts_db::run_migrations(&mut connection, &settings.database.runtime_role) + .await + .map_err(|error| anyhow::anyhow!("run migrations failed: {error}"))?; + drop(connection); + migration_pool.close().await; + tracing::info!(runtime_role = %settings.database.runtime_role, "database migrations complete"); + Ok(()) +} + /// 依配置构造 ZOOT OAuth provider(未配置则 None)。 fn build_zoot_provider(settings: &Settings) -> Option { let z = &settings.auth.zoot; diff --git a/backend/crates/prts-api/src/openapi.rs b/backend/crates/prts-api/src/openapi.rs index 8e6081d..92fea2a 100644 --- a/backend/crates/prts-api/src/openapi.rs +++ b/backend/crates/prts-api/src/openapi.rs @@ -20,6 +20,7 @@ use utoipa::OpenApi; (name = "user", description = "用户资料 / 关联账号 / API Key"), (name = "admin", description = "平台设置与角色任免"), (name = "project", description = "项目与成员"), + (name = "job", description = "持久化任务进度与受控重试"), (name = "file", description = "文件夹 / 文件树"), (name = "entry", description = "上传 / 词条 / 历史 / 导出"), (name = "search", description = "混合搜索(FTS + pg_trgm + RRF 融合)"), diff --git a/backend/crates/prts-api/src/routes/jobs.rs b/backend/crates/prts-api/src/routes/jobs.rs new file mode 100644 index 0000000..5659506 --- /dev/null +++ b/backend/crates/prts-api/src/routes/jobs.rs @@ -0,0 +1,822 @@ +//! 持久化任务进度与受控手动重试端点。 +//! +//! handler 只做协议、资源鉴权与事务编排;所有持久化 mutation 位于 `prts-db`。 + +use axum::extract::{Path, Query, State}; +use axum::Json; +use prts_common::Error; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::auth::{project as paccess, CurrentUser}; +use crate::db_err; +use crate::error::{ApiError, ErrorResponse, RequestLocale}; +use crate::state::AppState; + +/// 对外任务进度。安全起见不暴露 payload/result、worker id 或内部租约。 +#[derive(Debug, Serialize, ToSchema)] +pub struct JobDto { + pub id: i64, + pub kind: String, + pub project_id: Option, + pub state: String, + pub stage: String, + pub progress_current: i64, + pub progress_total: Option, + pub attempts: i32, + pub max_attempts: i32, + pub next_retry_at: Option, + pub last_error_code: Option, + pub manual_retry_allowed: bool, + pub created_at: String, + pub started_at: Option, + pub finished_at: Option, + pub updated_at: String, +} + +impl JobDto { + fn from_job(job: prts_db::models::Job, manual_retry_allowed: bool) -> Self { + let next_retry_at = + (job.state == "queued" && job.attempts > 0).then(|| job.run_after.to_rfc3339()); + Self { + id: job.id, + kind: job.kind, + project_id: job.project_id, + state: job.state, + stage: job.stage, + progress_current: job.progress_current, + progress_total: job.progress_total, + attempts: job.attempts, + max_attempts: job.max_attempts, + next_retry_at, + last_error_code: job.last_error_code, + manual_retry_allowed, + created_at: job.created_at.to_rfc3339(), + started_at: job.started_at.map(|value| value.to_rfc3339()), + finished_at: job.finished_at.map(|value| value.to_rfc3339()), + updated_at: job.updated_at.to_rfc3339(), + } + } +} + +/// 项目任务键集查询参数。 +#[derive(Debug, Deserialize)] +pub struct ListJobsQuery { + pub after_id: Option, + pub kind: Option, + pub state: Option, + pub limit: Option, +} + +/// 项目任务键集列表。 +#[derive(Debug, Serialize, ToSchema)] +pub struct JobListDto { + pub items: Vec, + pub next_after: Option, +} + +/// 对外分页上限严格为 1..=100;非法值返回 400,不能静默改写用户请求。 +fn validated_limit(limit: Option) -> Result { + let limit = limit.unwrap_or(50); + if (1..=100).contains(&limit) { + Ok(limit) + } else { + Err(Error::bad_request("limit must be between 1 and 100")) + } +} + +/// 按稳定 id 查询任务进度。必须先通过任务所属项目的现有可见性鉴权。 +#[utoipa::path( + get, + path = "/jobs/{id}", + tag = "job", + params(("id" = i64, Path, description = "稳定任务 ID")), + responses( + (status = 200, description = "任务进度", body = JobDto), + (status = 401, description = "必须登录后按所属资源鉴权", body = ErrorResponse), + (status = 403, description = "缺少该任务 kind 的业务 capability", body = ErrorResponse), + (status = 404, description = "任务、所属资源或未知 kind 不可见", body = ErrorResponse) + ) +)] +pub async fn get_job( + State(state): State, + RequestLocale(locale): RequestLocale, + user: CurrentUser, + Path(id): Path, +) -> Result, ApiError> { + let result: Result, ApiError> = async { + let job = prts_db::jobs::find_by_id(&state.db, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let retry_allowed = authorize_job_view(&state, &user, &job).await?; + Ok(Json(JobDto::from_job(job, retry_allowed))) + } + .await; + result.map_err(|error| error.with_locale(locale)) +} + +/// 按所属项目过滤任务进度,使用 `id DESC` 键集分页。 +#[utoipa::path( + get, + path = "/projects/{project_id}/jobs", + tag = "job", + params( + ("project_id" = i64, Path, description = "所属项目 ID"), + ("after_id" = Option, Query, description = "上一页末尾任务 ID"), + ("kind" = Option, Query, description = "任务 kind 精确筛选"), + ("state" = Option, Query, description = "任务状态精确筛选"), + ("limit" = Option, Query, description = "1..100,默认 50") + ), + responses( + (status = 200, description = "项目任务进度", body = JobListDto), + (status = 400, description = "筛选参数无效", body = ErrorResponse), + (status = 401, description = "必须登录", body = ErrorResponse), + (status = 403, description = "缺少任务 kind 的业务 capability", body = ErrorResponse), + (status = 404, description = "项目、kind 或 capability 不可见", body = ErrorResponse) + ) +)] +pub async fn list_project_jobs( + State(state): State, + RequestLocale(locale): RequestLocale, + user: CurrentUser, + Path(project_id): Path, + Query(query): Query, +) -> Result, ApiError> { + let result: Result, ApiError> = async { + let access = paccess::load(&state, Some(&user), project_id).await?; + access.require_view()?; + if let Some(state_filter) = query.state.as_deref() { + if prts_core::JobState::parse(state_filter).is_none() { + return Err(Error::bad_request("invalid job state").into()); + } + } + let allowed_kinds: Vec = if let Some(kind) = query.kind.as_deref() { + prts_core::jobs::job_view_policy(kind).ok_or(Error::NotFound)?; + if !can_view_job(&access, &user, kind) { + return Err(Error::NotFound.into()); + } + vec![kind.to_string()] + } else { + prts_core::jobs::KNOWN_JOB_KINDS + .iter() + .copied() + .filter(|kind| can_view_job(&access, &user, kind)) + .map(str::to_string) + .collect() + }; + let limit = validated_limit(query.limit)?; + let mut jobs = prts_db::jobs::list_by_project( + &state.db, + project_id, + query.after_id, + &allowed_kinds, + query.kind.as_deref(), + query.state.as_deref(), + limit + 1, + ) + .await + .map_err(db_err)?; + let has_more = jobs.len() > limit as usize; + jobs.truncate(limit as usize); + let next_after = has_more.then(|| jobs.last().expect("non-empty visible page").id); + let items = jobs + .into_iter() + .map(|job| { + let retry_allowed = + prts_core::jobs::manual_retry_policy(&job.kind).is_some_and(|policy| { + prts_core::JobState::parse(&job.state) + .is_some_and(prts_core::JobState::manual_retry_allowed) + && (!policy.owner_only || user.id == access.project.owner_id) + && access.has_node(policy.permission_node) + }); + JobDto::from_job(job, retry_allowed) + }) + .collect(); + Ok(Json(JobListDto { items, next_after })) + } + .await; + result.map_err(|error| error.with_locale(locale)) +} + +/// 手动重试失败任务。复用原 job id,并在同一事务追加 allowlisted 审计。 +#[utoipa::path( + post, + path = "/jobs/{id}/retry", + tag = "job", + params(("id" = i64, Path, description = "稳定任务 ID")), + responses( + (status = 200, description = "已重新排队", body = JobDto), + (status = 400, description = "当前状态不可重试", body = ErrorResponse), + (status = 401, description = "必须登录", body = ErrorResponse), + (status = 403, description = "缺少任务所属资源的业务权限", body = ErrorResponse), + (status = 404, description = "任务或所属资源不存在", body = ErrorResponse) + ) +)] +pub async fn retry_job( + State(state): State, + RequestLocale(locale): RequestLocale, + user: CurrentUser, + Path(id): Path, +) -> Result, ApiError> { + let result: Result, ApiError> = async { + let mut tx = state.db.begin().await.map_err(db_err)?; + let current = prts_db::jobs::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + prts_core::jobs::job_view_policy(¤t.kind).ok_or(Error::NotFound)?; + let project_id = current.project_id.ok_or(Error::NotFound)?; + let access = paccess::load(&state, Some(&user), project_id).await?; + access.require_view()?; + if !can_view_job(&access, &user, ¤t.kind) { + return Err(Error::Forbidden.into()); + } + let retry_policy = prts_core::jobs::manual_retry_policy(¤t.kind) + .ok_or_else(|| Error::bad_request("manual retry is not supported for this job kind"))?; + if retry_policy.owner_only && access.project.owner_id != user.id { + return Err(Error::Forbidden.into()); + } + access.require_node(retry_policy.permission_node)?; + let state_value = prts_core::JobState::parse(¤t.state) + .ok_or_else(|| Error::internal("unknown job state persisted"))?; + if !state_value.manual_retry_allowed() { + return Err(Error::bad_request("job is not manually retryable").into()); + } + let updated = prts_db::jobs::manual_retry_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or_else(|| Error::bad_request("job state changed; retry rejected"))?; + prts_db::audit::append_job_retried_tx( + &mut tx, + prts_db::audit::AuditActor { + id: Some(user.id), + kind: prts_db::audit::AuditActorKind::User, + ip: None, + }, + updated.id, + updated.project_id, + &updated.kind, + current.attempts, + updated.attempts, + ) + .await + .map_err(db_err)?; + tx.commit().await.map_err(db_err)?; + state.job_worker.wake(); + + Ok(Json(JobDto::from_job(updated, false))) + } + .await; + result.map_err(|error| error.with_locale(locale)) +} + +/// 返回当前主体是否还具备手动重试能力;读取本身始终按所属资源鉴权。 +async fn authorize_job_view( + state: &AppState, + user: &CurrentUser, + job: &prts_db::models::Job, +) -> Result { + let project_id = job.project_id.ok_or(Error::NotFound)?; + let access = paccess::load(state, Some(user), project_id).await?; + access.require_view()?; + if !can_view_job(&access, user, &job.kind) { + return Err(Error::NotFound.into()); + } + let retry_allowed = prts_core::jobs::manual_retry_policy(&job.kind).is_some_and(|policy| { + prts_core::JobState::parse(&job.state) + .is_some_and(prts_core::JobState::manual_retry_allowed) + && (!policy.owner_only || user.id == access.project.owner_id) + && access.has_node(policy.permission_node) + }); + Ok(retry_allowed) +} + +/// 按 allowlisted kind 与现有项目 capability 判定可见性。 +fn can_view_job(access: &paccess::ProjectAccess, user: &CurrentUser, kind: &str) -> bool { + match prts_core::jobs::job_view_policy(kind) { + Some(policy) if policy.owner_only => access.project.owner_id == user.id, + Some(policy) => access.has_node(policy.permission_node), + None => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn list_limit_is_strictly_bounded_instead_of_clamped() { + assert_eq!(validated_limit(None).unwrap(), 50); + assert_eq!(validated_limit(Some(1)).unwrap(), 1); + assert_eq!(validated_limit(Some(100)).unwrap(), 100); + assert!(validated_limit(Some(0)).is_err()); + assert!(validated_limit(Some(101)).is_err()); + } + + #[test] + fn job_dto_never_serializes_internal_error_message() { + let now = chrono::Utc::now(); + let job = prts_db::models::Job { + id: 1, + kind: "upload_process".to_string(), + project_id: Some(2), + state: "failed".to_string(), + pause_reason: None, + stage: "processing".to_string(), + payload: serde_json::json!({}), + result: None, + progress_current: 0, + progress_total: Some(1), + attempts: 1, + max_attempts: 3, + run_after: now, + lease_until: None, + worker_id: None, + last_error_code: Some("provider_unavailable".to_string()), + last_error_message: Some("stack trace raw-refresh-token".to_string()), + created_at: now, + started_at: Some(now), + finished_at: Some(now), + updated_at: now, + }; + let json = serde_json::to_value(JobDto::from_job(job, true)).unwrap(); + assert_eq!(json["last_error_code"], "provider_unavailable"); + assert!(json.get("last_error_message").is_none()); + assert!(!json.to_string().contains("raw-refresh-token")); + } +} + +#[cfg(all(test, feature = "db-tests"))] +mod db_tests { + use std::sync::Arc; + + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use serde_json::Value; + use tower::ServiceExt; + + use super::*; + + static MIGRATED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); + + fn unique(prefix: &str) -> String { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + format!("{prefix}-{nanos}") + } + + async fn test_state() -> AppState { + MIGRATED + .get_or_init(|| async { + let migration_url = + std::env::var("MIGRATION_DATABASE_URL").expect("MIGRATION_DATABASE_URL 未设置"); + let migration_pool = prts_db::connect_postgres(&migration_url, 1).await.unwrap(); + let mut connection = migration_pool.acquire().await.unwrap(); + prts_db::run_migrations(&mut connection, "prts_runtime") + .await + .unwrap(); + drop(connection); + migration_pool.close().await; + }) + .await; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); + let redis_url = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); + let db = prts_db::connect_postgres(&database_url, 10).await.unwrap(); + prts_db::verify_runtime_role(&db, "prts_runtime") + .await + .unwrap(); + let cache = prts_db::connect_redis(&redis_url).await.unwrap(); + let realtime = prts_realtime::Hub::new(&redis_url).await.unwrap(); + let settings = Arc::new( + prts_common::config::Settings::load_from("__audit_jobs_missing_config__").unwrap(), + ); + let registry = crate::jobs::JobRegistry::new(Vec::new()); + let job_worker = crate::job_worker::spawn( + db.clone(), + registry, + Arc::new(crate::job_worker::NoPendingDeletions), + ); + AppState { + db, + cache, + settings, + zoot: Arc::new(None), + realtime, + embedder: Arc::new(None), + search_rt: Arc::new(tokio::sync::RwLock::new(Default::default())), + job_worker, + } + } + + fn bearer(user_id: i64, state: &AppState) -> String { + let now = chrono::Utc::now().timestamp(); + let token = prts_auth::jwt::encode( + &prts_auth::jwt::Claims { + sub: user_id, + iat: now, + exp: now + 600, + typ: "access".to_string(), + }, + state.jwt_secret(), + ); + format!("Bearer {token}") + } + + async fn body_json(response: axum::response::Response) -> Value { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + serde_json::from_slice(&body).unwrap() + } + + async fn create_failed_job(state: &AppState, project_id: i64, kind: &str) -> i64 { + let job_id = if let Some(kind) = prts_db::jobs::JobKind::parse(kind) { + let mut tx = state.db.begin().await.unwrap(); + let job = prts_db::jobs::create_tx( + &mut tx, + prts_db::jobs::NewJob { + kind, + project_id: Some(project_id), + stage: "queued".to_string(), + progress_total: None, + max_attempts: 1, + run_after: chrono::Utc::now(), + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + job.id + } else { + sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) VALUES ($1, $2, '{}') RETURNING id", + ) + .bind(kind) + .bind(project_id) + .fetch_one(&state.db) + .await + .unwrap() + }; + sqlx::query( + "UPDATE jobs + SET state = 'running', attempts = 1, worker_id = 'http-test-worker', + lease_until = now() + interval '5 minutes' + WHERE id = $1", + ) + .bind(job_id) + .execute(&state.db) + .await + .unwrap(); + prts_db::jobs::fail_attempt( + &state.db, + job_id, + "http-test-worker", + "test_failure", + "redacted test failure", + false, + 0, + ) + .await + .unwrap() + .unwrap() + .id + } + + #[tokio::test] + async fn audit_jobs_http_authorization_and_filtering_contract() { + let state = test_state().await; + let owner = prts_db::users::create_password_user( + &state.db, + &unique("job-owner"), + None, + "test-hash", + "active", + ) + .await + .unwrap(); + let manager = prts_db::users::create_password_user( + &state.db, + &unique("job-manager"), + None, + "test-hash", + "active", + ) + .await + .unwrap(); + let admin = prts_db::users::create_password_user( + &state.db, + &unique("job-admin"), + None, + "test-hash", + "active", + ) + .await + .unwrap(); + let outsider = prts_db::users::create_password_user( + &state.db, + &unique("job-outsider"), + None, + "test-hash", + "active", + ) + .await + .unwrap(); + prts_db::users::set_platform_role(&state.db, admin.id, Some("admin")) + .await + .unwrap(); + let slug = unique("job-http-project"); + let project = prts_db::projects::create( + &state.db, + &slug, + &slug, + "", + "public", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + prts_db::memberships::upsert(&state.db, project.id, owner.id, "owner") + .await + .unwrap(); + prts_db::memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + let upload_id = create_failed_job(&state, project.id, "upload_process").await; + let primary_id = + create_failed_job(&state, project.id, "primary_source_lexical_reindex").await; + let unknown_id = create_failed_job(&state, project.id, "unknown_internal_job").await; + let private_slug = unique("job-private-project"); + let private_project = prts_db::projects::create( + &state.db, + &private_slug, + &private_slug, + "", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + prts_db::memberships::upsert(&state.db, private_project.id, owner.id, "owner") + .await + .unwrap(); + let private_job_id = create_failed_job(&state, private_project.id, "upload_process").await; + let app = crate::routes::app(state.clone()); + + let anonymous = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/jobs/{upload_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + + let anonymous_en = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/jobs/{upload_id}")) + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_en.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + body_json(anonymous_en).await, + serde_json::json!({"code": "unauthorized", "message": "Unauthorized"}) + ); + + let missing_zh = app + .clone() + .oneshot( + Request::builder() + .uri("/jobs/9223372036854770000") + .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::ACCEPT_LANGUAGE, "zh-CN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_zh.status(), StatusCode::NOT_FOUND); + assert_eq!( + body_json(missing_zh).await, + serde_json::json!({"code": "not_found", "message": "资源不存在"}) + ); + + let unknown = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/jobs/{unknown_id}")) + .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + + let unknown_retry = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/jobs/{unknown_id}/retry")) + .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let missing_retry = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/jobs/9223372036854770000/retry") + .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown_retry.status(), StatusCode::NOT_FOUND); + assert_eq!(missing_retry.status(), StatusCode::NOT_FOUND); + assert_eq!( + body_json(unknown_retry).await, + body_json(missing_retry).await + ); + + let listed = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/projects/{}/jobs", project.id)) + .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::OK); + let listed = body_json(listed).await; + let ids: Vec = listed["items"] + .as_array() + .unwrap() + .iter() + .map(|item| item["id"].as_i64().unwrap()) + .collect(); + assert!(ids.contains(&upload_id)); + assert!(ids.contains(&primary_id)); + assert!(!ids.contains(&unknown_id)); + + let explicit_forbidden_kind = app + .clone() + .oneshot( + Request::builder() + .uri(format!( + "/projects/{}/jobs?kind=primary_source_lexical_reindex", + project.id + )) + .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(explicit_forbidden_kind.status(), StatusCode::NOT_FOUND); + + let visible_after_hidden = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/projects/{}/jobs?limit=1", project.id)) + .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(visible_after_hidden.status(), StatusCode::OK); + let visible_after_hidden = body_json(visible_after_hidden).await; + assert_eq!(visible_after_hidden["items"].as_array().unwrap().len(), 1); + assert_eq!(visible_after_hidden["items"][0]["id"], upload_id); + assert!(visible_after_hidden["next_after"].is_null()); + + let zero_limit_en = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/projects/{}/jobs?limit=0", project.id)) + .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::ACCEPT_LANGUAGE, "en") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(zero_limit_en.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(zero_limit_en).await, + serde_json::json!({"code": "bad_request", "message": "Bad request"}) + ); + + let oversized_limit_zh = app + .clone() + .oneshot( + Request::builder() + .uri(format!("/projects/{}/jobs?limit=101", project.id)) + .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::ACCEPT_LANGUAGE, "zh-CN") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized_limit_zh.status(), StatusCode::BAD_REQUEST); + assert_eq!( + body_json(oversized_limit_zh).await, + serde_json::json!({"code": "bad_request", "message": "请求参数有误"}) + ); + + for uri in [ + format!("/jobs/{private_job_id}"), + format!("/projects/{}/jobs", private_project.id), + ] { + let hidden_private = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header(header::AUTHORIZATION, bearer(outsider.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(hidden_private.status(), StatusCode::NOT_FOUND); + } + + let admin_retry = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/jobs/{primary_id}/retry")) + .header(header::AUTHORIZATION, bearer(admin.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(admin_retry.status(), StatusCode::FORBIDDEN); + + let manager_retry = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/jobs/{upload_id}/retry")) + .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(manager_retry.status(), StatusCode::OK); + + sqlx::query("DELETE FROM jobs WHERE project_id IN ($1, $2)") + .bind(project.id) + .bind(private_project.id) + .execute(&state.db) + .await + .unwrap(); + sqlx::query("DELETE FROM projects WHERE id IN ($1, $2)") + .bind(project.id) + .bind(private_project.id) + .execute(&state.db) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id IN ($1, $2, $3, $4)") + .bind(owner.id) + .bind(manager.id) + .bind(admin.id) + .bind(outsider.id) + .execute(&state.db) + .await + .unwrap(); + } +} diff --git a/backend/crates/prts-api/src/routes/mod.rs b/backend/crates/prts-api/src/routes/mod.rs index 8ed2400..4adf155 100644 --- a/backend/crates/prts-api/src/routes/mod.rs +++ b/backend/crates/prts-api/src/routes/mod.rs @@ -6,6 +6,7 @@ pub mod auth; pub mod entries; pub mod files; pub mod health; +pub mod jobs; pub mod messages; pub mod meta; pub mod notifications; @@ -87,6 +88,10 @@ fn api_router() -> OpenApiRouter { )) .routes(routes!(projects::list_members, projects::add_member)) .routes(routes!(projects::remove_member)) + // 持久化任务进度与受控重试 + .routes(routes!(jobs::get_job)) + .routes(routes!(jobs::retry_job)) + .routes(routes!(jobs::list_project_jobs)) // 文件树 .routes(routes!(files::get_tree)) .routes(routes!(files::delete_file)) @@ -210,4 +215,32 @@ mod tests { fn full_router_assembles_without_route_conflicts() { let _ = api_router().split_for_parts(); } + + #[test] + fn jobs_openapi_errors_share_code_message_schema() { + let (_, api) = api_router().split_for_parts(); + let document = serde_json::to_value(api).unwrap(); + for (path, method, statuses) in [ + ("/jobs/{id}", "get", &["401", "403", "404"][..]), + ( + "/projects/{project_id}/jobs", + "get", + &["400", "401", "403", "404"][..], + ), + ( + "/jobs/{id}/retry", + "post", + &["400", "401", "403", "404"][..], + ), + ] { + for status in statuses { + assert_eq!( + document["paths"][path][method]["responses"][status]["content"] + ["application/json"]["schema"]["$ref"], + "#/components/schemas/ErrorResponse", + "{method} {path} 的 {status} 应复用稳定错误 schema" + ); + } + } + } } diff --git a/backend/crates/prts-api/src/state.rs b/backend/crates/prts-api/src/state.rs index 12c88d4..998f122 100644 --- a/backend/crates/prts-api/src/state.rs +++ b/backend/crates/prts-api/src/state.rs @@ -23,6 +23,8 @@ pub struct AppState { pub embedder: Arc>, /// 搜索运行时配置(管理后台可热改;worker / 查询每次读快照)。 pub search_rt: Arc>, + /// durable worker 唤醒控制;业务事务提交后通知 worker 检查队列。 + pub job_worker: crate::job_worker::JobWorkerControl, } impl AppState { diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index 7056d6c..1346777 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -1,19 +1,48 @@ //! DB 集成测试:对真实 PostgreSQL 跑通仓储层 SQL 与迁移。 //! -//! 仅在 `db-tests` 特性下编译,并需要环境变量 `DATABASE_URL`。 +//! 仅在 `db-tests` 特性下编译,并需要 runtime `DATABASE_URL` 与 owner +//! `MIGRATION_DATABASE_URL`。迁移和所有合同 DML 使用不同数据库角色。 //! 本地无 DB 时默认不编译;CI 会起 Postgres 服务后执行(见 .github/workflows/ci.yml)。 #![cfg(feature = "db-tests")] use prts_db::{ - api_keys, entries, files, memberships, messages, notifications, projects, settings, users, + api_keys, auth_sessions, entries, files, jobs, memberships, messages, notifications, projects, + settings, users, }; +static MIGRATED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); + +fn runtime_role() -> String { + std::env::var("PRTS_TEST_RUNTIME_ROLE").unwrap_or_else(|_| "prts_runtime".to_string()) +} + +async fn ensure_migrated() { + MIGRATED + .get_or_init(|| async { + let url = + std::env::var("MIGRATION_DATABASE_URL").expect("MIGRATION_DATABASE_URL 未设置"); + let migration_pool = prts_db::connect_postgres(&url, 1) + .await + .expect("以 migration owner 连接 Postgres"); + let mut migration_conn = migration_pool.acquire().await.expect("获取 migration 连接"); + prts_db::run_migrations(&mut migration_conn, &runtime_role()) + .await + .expect("以 migration owner 执行迁移"); + drop(migration_conn); + migration_pool.close().await; + }) + .await; +} + async fn pool() -> prts_db::Db { + ensure_migrated().await; let url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); let pool = prts_db::connect_postgres(&url, 5) .await - .expect("连接 Postgres"); - prts_db::run_migrations(&pool).await.expect("执行迁移"); + .expect("以 runtime role 连接 Postgres"); + prts_db::verify_runtime_role(&pool, &runtime_role()) + .await + .expect("runtime role 必须与 migration owner 分离"); pool } @@ -67,56 +96,48 @@ async fn audit_jobs_project(pool: &prts_db::Db, prefix: &str) -> (i64, i64, Stri (user_id, project_id, slug) } -/// 用与仓储契约相同的 `SKIP LOCKED` 语义领取一个任务。 -async fn audit_jobs_claim<'e, E>( - executor: E, - worker_id: &str, - pending_project_ids: &[i64], - eligible_kinds: &[String], -) -> Result, sqlx::Error> -where - E: sqlx::PgExecutor<'e>, -{ - sqlx::query_scalar( - "WITH candidate AS ( - SELECT id - FROM jobs - WHERE ( - (state = 'queued' AND run_after <= now()) - OR (state = 'running' AND lease_until <= now()) - ) - AND kind = ANY($3::TEXT[]) - AND ( - project_id IS NULL - OR kind = 'project_purge' - OR NOT (project_id = ANY($2::BIGINT[])) - ) - ORDER BY run_after, id - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - UPDATE jobs AS job - SET state = 'running', - worker_id = $1, - lease_until = now() + interval '5 minutes', - attempts = job.attempts + 1, - started_at = COALESCE(job.started_at, now()), - updated_at = now() - FROM candidate - WHERE job.id = candidate.id - RETURNING job.id", - ) - .bind(worker_id) - .bind(pending_project_ids) - .bind(eligible_kinds) - .fetch_optional(executor) - .await -} - /// 审计记录只能追加,数据库层必须拒绝篡改与删除。 #[tokio::test] async fn audit_jobs_audit_log_is_append_only_and_secret_payloads_are_rejected() { let pool = pool().await; + let (current_role, table_owner): (String, String) = sqlx::query_as( + "SELECT current_user::TEXT, + pg_get_userbyid(class.relowner)::TEXT + FROM pg_class AS class + JOIN pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' AND class.relname = 'audit_log'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(current_role, runtime_role()); + assert_ne!(current_role, table_owner, "runtime 不得拥有 audit_log"); + let role_safety: (bool, bool, bool, bool, bool) = sqlx::query_as( + "SELECT NOT role.rolcreatedb, + NOT role.rolcreaterole, + NOT role.rolreplication, + NOT role.rolbypassrls, + NOT has_schema_privilege(current_user, 'public', 'CREATE') + FROM pg_roles AS role + WHERE role.rolname = current_user", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(role_safety, (true, true, true, true, true)); + let privileges: (bool, bool, bool, bool, bool) = sqlx::query_as( + "SELECT + has_table_privilege(current_user, 'audit_log', 'SELECT'), + has_table_privilege(current_user, 'audit_log', 'INSERT'), + has_table_privilege(current_user, 'audit_log', 'UPDATE'), + has_table_privilege(current_user, 'audit_log', 'DELETE'), + has_table_privilege(current_user, 'audit_log', 'TRUNCATE')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(privileges, (true, true, false, false, false)); + let target = audit_jobs_unique("audit-target"); let audit_id: i64 = sqlx::query_scalar( "INSERT INTO audit_log ( @@ -132,41 +153,46 @@ async fn audit_jobs_audit_log_is_append_only_and_secret_payloads_are_rejected() .await .unwrap(); - let update_error = sqlx::query("UPDATE audit_log SET action = 'tampered' WHERE id = $1") + sqlx::query("UPDATE audit_log SET action = 'tampered' WHERE id = $1") .bind(audit_id) .execute(&pool) .await .expect_err("audit_log UPDATE 必须由数据库拒绝"); - assert_eq!( - update_error - .as_database_error() - .and_then(|error| error.code()), - Some(std::borrow::Cow::Borrowed("55000")) - ); - let delete_error = sqlx::query("DELETE FROM audit_log WHERE id = $1") + sqlx::query("TRUNCATE audit_log") + .execute(&pool) + .await + .expect_err("audit_log TRUNCATE 必须由 ACL 或数据库 trigger 拒绝"); + + sqlx::query("DELETE FROM audit_log WHERE id = $1") .bind(audit_id) .execute(&pool) .await .expect_err("audit_log DELETE 必须由数据库拒绝"); - assert_eq!( - delete_error - .as_database_error() - .and_then(|error| error.code()), - Some(std::borrow::Cow::Borrowed("55000")) - ); - let secret_error = sqlx::query( - "INSERT INTO audit_log ( - actor_id, actor_kind, action, target_type, target_id, payload - ) VALUES (NULL, 'system', 'auth.failed', 'session', $1, $2)", - ) - .bind(audit_jobs_unique("secret-audit")) - .bind(serde_json::json!({"refresh_token": "raw-refresh-token"})) - .execute(&pool) - .await - .expect_err("审计 payload 不得接收 raw token 字段"); - assert!(secret_error.as_database_error().is_some()); + for payload in [ + serde_json::json!({"token": "raw"}), + serde_json::json!({"refreshToken": "raw"}), + serde_json::json!({"access_token": "raw"}), + serde_json::json!({"apiKey": "raw"}), + serde_json::json!({"password": "raw"}), + serde_json::json!({"secret": "raw"}), + serde_json::json!({"verifier": "raw"}), + serde_json::json!({"code": "raw"}), + serde_json::json!({"nested": [{"clientSecret": "raw"}]}), + ] { + let secret_error = sqlx::query( + "INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, payload + ) VALUES (NULL, 'system', 'auth.failed', 'session', $1, $2)", + ) + .bind(audit_jobs_unique("secret-audit")) + .bind(payload) + .execute(&pool) + .await + .expect_err("大小写与命名风格变化不得绕过 secret-key 检查"); + assert!(secret_error.as_database_error().is_some()); + } } /// 审计使用非级联 snapshot,用户和项目删除后仍能解释历史目标。 @@ -216,68 +242,88 @@ async fn audit_jobs_audit_actor_and_project_snapshots_survive_source_deletion() #[tokio::test] async fn audit_jobs_workers_claim_distinct_rows_concurrently() { let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "claim-project").await; let marker = audit_jobs_unique("claim"); - let first: i64 = sqlx::query_scalar( - "INSERT INTO jobs (kind, payload) VALUES ($1, '{}'), ($1, '{}') RETURNING id", + let job_ids: Vec = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) + VALUES ($1, $2, '{}'), ($1, $2, '{}') RETURNING id", ) .bind(&marker) - .fetch_one(&pool) + .bind(project_id) + .fetch_all(&pool) .await .unwrap(); - let mut tx_a = pool.begin().await.unwrap(); let eligible = vec![marker.clone()]; - let claimed_a = audit_jobs_claim(&mut *tx_a, "worker-a", &[], &eligible) - .await - .unwrap() - .unwrap(); - let mut tx_b = pool.begin().await.unwrap(); - let claimed_b = audit_jobs_claim(&mut *tx_b, "worker-b", &[], &eligible) - .await - .unwrap() - .unwrap(); + let (claimed_a, claimed_b) = tokio::join!( + jobs::claim_next_for_ids_and_kinds(&pool, "worker-a", 300, &[], &eligible, &job_ids,), + jobs::claim_next_for_ids_and_kinds(&pool, "worker-b", 300, &[], &eligible, &job_ids,) + ); + let claimed_a = claimed_a.unwrap().unwrap().id; + let claimed_b = claimed_b.unwrap().unwrap().id; assert_ne!(claimed_a, claimed_b); - assert!(claimed_a >= first || claimed_b >= first); - tx_a.commit().await.unwrap(); - tx_b.commit().await.unwrap(); + assert!(job_ids.contains(&claimed_a)); + assert!(job_ids.contains(&claimed_b)); sqlx::query("DELETE FROM jobs WHERE kind = $1") .bind(marker) .execute(&pool) .await .unwrap(); + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); } /// 过期租约可由另一 worker 接管,未过期租约不可被抢占。 #[tokio::test] async fn audit_jobs_expired_lease_is_taken_over_and_active_lease_is_preserved() { let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "lease-project").await; let expired_kind = audit_jobs_unique("expired"); let active_kind = audit_jobs_unique("active"); let expired_id: i64 = sqlx::query_scalar( - "INSERT INTO jobs (kind, state, payload, worker_id, lease_until) - VALUES ($1, 'running', '{}', 'dead-worker', now() - interval '1 minute') + "INSERT INTO jobs (kind, project_id, state, payload, worker_id, lease_until) + VALUES ($1, $2, 'running', '{}', 'dead-worker', now() - interval '1 minute') RETURNING id", ) .bind(&expired_kind) + .bind(project_id) .fetch_one(&pool) .await .unwrap(); - sqlx::query( - "INSERT INTO jobs (kind, state, payload, worker_id, lease_until) - VALUES ($1, 'running', '{}', 'live-worker', now() + interval '1 hour')", + let active_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, state, payload, worker_id, lease_until) + VALUES ($1, $2, 'running', '{}', 'live-worker', now() + interval '1 hour') + RETURNING id", ) .bind(&active_kind) - .execute(&pool) + .bind(project_id) + .fetch_one(&pool) .await .unwrap(); let eligible = vec![expired_kind.clone(), active_kind.clone()]; - let claimed = audit_jobs_claim(&pool, "replacement-worker", &[], &eligible) - .await - .unwrap(); - assert_eq!(claimed, Some(expired_id)); + let claimed = jobs::claim_next_for_ids_and_kinds( + &pool, + "replacement-worker", + 300, + &[], + &eligible, + &[expired_id, active_id], + ) + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.id, expired_id); let owner: String = sqlx::query_scalar("SELECT worker_id FROM jobs WHERE id = $1") .bind(expired_id) .fetch_one(&pool) @@ -291,6 +337,88 @@ async fn audit_jobs_expired_lease_is_taken_over_and_active_lease_is_preserved() .execute(&pool) .await .unwrap(); + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 最后一次允许的执行若在外部副作用后崩溃,过期租约只能被终结,不能再次执行。 +#[tokio::test] +async fn audit_jobs_last_attempt_crash_is_failed_without_reclaim() { + let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "last-attempt").await; + let kind = audit_jobs_unique("last-attempt-kind"); + let exhausted_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs ( + kind, project_id, state, payload, attempts, max_attempts, worker_id, lease_until + ) VALUES ($1, $2, 'running', '{}', 1, 1, 'crashed-worker', + now() - interval '1 minute') + RETURNING id", + ) + .bind(&kind) + .bind(project_id) + .fetch_one(&pool) + .await + .unwrap(); + + let claimed = jobs::claim_next_for_ids_and_kinds( + &pool, + "replacement-worker", + 300, + &[], + std::slice::from_ref(&kind), + &[exhausted_id], + ) + .await + .unwrap(); + assert!(claimed.is_none(), "耗尽任务不得重复外部副作用"); + let exhausted = jobs::find_by_id(&pool, exhausted_id) + .await + .unwrap() + .unwrap(); + assert_eq!(exhausted.state, "failed"); + assert_eq!(exhausted.attempts, 1); + assert_eq!( + exhausted.last_error_code.as_deref(), + Some("job_attempts_exhausted") + ); + assert!(exhausted.worker_id.is_none()); + assert!(exhausted.lease_until.is_none()); + + assert!(jobs::claim_next_for_ids_and_kinds( + &pool, + "third-worker", + 300, + &[], + std::slice::from_ref(&kind), + &[exhausted_id], + ) + .await + .unwrap() + .is_none()); + + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(exhausted_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); } /// 只有当前持有者能续租;旧 worker 在接管后不能延长新租约。 @@ -308,31 +436,24 @@ async fn audit_jobs_lease_renewal_requires_current_worker() { .await .unwrap(); - let stale_rows = sqlx::query( - "UPDATE jobs - SET lease_until = now() + interval '5 minutes', updated_at = now() - WHERE id = $1 AND state = 'running' AND worker_id = $2", - ) - .bind(job_id) - .bind("stale-worker") - .execute(&pool) - .await - .unwrap() - .rows_affected(); - assert_eq!(stale_rows, 0); + assert!(!jobs::renew_lease(&pool, job_id, "stale-worker", 300) + .await + .unwrap()); + assert!(jobs::renew_lease(&pool, job_id, "current-worker", 300) + .await + .unwrap()); - let renewed_rows = sqlx::query( - "UPDATE jobs - SET lease_until = now() + interval '5 minutes', updated_at = now() - WHERE id = $1 AND state = 'running' AND worker_id = $2", - ) - .bind(job_id) - .bind("current-worker") - .execute(&pool) - .await - .unwrap() - .rows_affected(); - assert_eq!(renewed_rows, 1); + sqlx::query("UPDATE jobs SET lease_until = now() - interval '1 second' WHERE id = $1") + .bind(job_id) + .execute(&pool) + .await + .unwrap(); + assert!( + !jobs::renew_lease(&pool, job_id, "current-worker", 300) + .await + .unwrap(), + "过期 lease 不得被原 worker 复活" + ); sqlx::query("DELETE FROM jobs WHERE id = $1") .bind(job_id) @@ -345,40 +466,57 @@ async fn audit_jobs_lease_renewal_requires_current_worker() { #[tokio::test] async fn audit_jobs_manual_retry_reuses_same_id_and_increments_attempts() { let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "retry-project").await; let kind = audit_jobs_unique("retry"); let job_id: i64 = sqlx::query_scalar( "INSERT INTO jobs ( - kind, state, payload, attempts, max_attempts, worker_id, lease_until, + kind, project_id, state, payload, attempts, max_attempts, worker_id, lease_until, last_error_code, last_error_message ) - VALUES ($1, 'failed', '{}', 3, 3, 'failed-worker', now(), 'TEMP', 'temporary') + VALUES ($1, $2, 'failed', '{}', 3, 3, 'failed-worker', now(), 'TEMP', 'temporary') RETURNING id", ) .bind(&kind) + .bind(project_id) .fetch_one(&pool) .await .unwrap(); - let (retried_id, attempts): (i64, i32) = sqlx::query_as( - "UPDATE jobs - SET state = 'queued', attempts = attempts + 1, run_after = now(), - lease_until = NULL, worker_id = NULL, - last_error_code = NULL, last_error_message = NULL, updated_at = now() - WHERE id = $1 AND state = 'failed' - RETURNING id, attempts", - ) - .bind(job_id) - .fetch_one(&pool) - .await - .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let locked = jobs::find_by_id_for_update_tx(&mut tx, job_id) + .await + .unwrap() + .unwrap(); + let delete_pool = pool.clone(); + let mut delete_task = + tokio::spawn(async move { projects::delete(&delete_pool, project_id).await.unwrap() }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut delete_task) + .await + .is_err(), + "project delete 应等待已锁定 job,避免 retry TOCTOU" + ); + let retried = jobs::manual_retry_tx(&mut tx, locked.id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); + assert!(delete_task.await.unwrap()); - assert_eq!(retried_id, job_id); - assert_eq!(attempts, 4); + assert_eq!(retried.id, job_id); + assert_eq!(retried.attempts, 4); + let detached = jobs::find_by_id(&pool, job_id).await.unwrap().unwrap(); + assert_eq!(detached.project_id, None); sqlx::query("DELETE FROM jobs WHERE id = $1") .bind(job_id) .execute(&pool) .await .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); } /// `0014` 前不引用未来列;调用者显式传入待删除项目集合完成暂停过滤。 @@ -405,23 +543,20 @@ async fn audit_jobs_pending_deletion_filter_pauses_non_purge_jobs() { .await .unwrap(); - sqlx::query( - "UPDATE jobs - SET state = 'paused', lease_until = NULL, worker_id = NULL, updated_at = now() - WHERE project_id = ANY($1::BIGINT[]) - AND kind <> 'project_purge' - AND state IN ('queued', 'running')", + let eligible = vec![normal_kind.clone(), "project_purge".to_string()]; + let claimed = jobs::claim_next_for_ids_and_kinds( + &pool, + "purge-worker", + 300, + &[project_id], + &eligible, + &[normal_id, purge_id], ) - .bind(vec![project_id]) - .execute(&pool) .await + .unwrap() .unwrap(); - let eligible = vec![normal_kind.clone(), "project_purge".to_string()]; - let claimed = audit_jobs_claim(&pool, "purge-worker", &[project_id], &eligible) - .await - .unwrap(); - assert_eq!(claimed, Some(purge_id)); + assert_eq!(claimed.id, purge_id); let normal_state: String = sqlx::query_scalar("SELECT state FROM jobs WHERE id = $1") .bind(normal_id) .fetch_one(&pool) @@ -429,6 +564,41 @@ async fn audit_jobs_pending_deletion_filter_pauses_non_purge_jobs() { .unwrap(); assert_eq!(normal_state, "paused"); + let pause_reason: Option = + sqlx::query_scalar("SELECT pause_reason FROM jobs WHERE id = $1") + .bind(normal_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(pause_reason.as_deref(), Some("project_pending_deletion")); + + let manually_paused_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, state, pause_reason, payload) + VALUES ($1, $2, 'paused', 'manual', '{}') RETURNING id", + ) + .bind(audit_jobs_unique("manual-pause")) + .bind(project_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + jobs::resume_project_jobs(&pool, project_id).await.unwrap(), + 1 + ); + let states: Vec<(i64, String, Option)> = + sqlx::query_as("SELECT id, state, pause_reason FROM jobs WHERE id IN ($1, $2) ORDER BY id") + .bind(normal_id) + .bind(manually_paused_id) + .fetch_all(&pool) + .await + .unwrap(); + assert!(states.contains(&(normal_id, "queued".to_string(), None))); + assert!(states.contains(&( + manually_paused_id, + "paused".to_string(), + Some("manual".to_string()) + ))); + sqlx::query("DELETE FROM jobs WHERE project_id = $1") .bind(project_id) .execute(&pool) @@ -460,15 +630,24 @@ async fn audit_jobs_progress_updates_are_bounded() { .await .unwrap(); - let progress: (i64, Option) = sqlx::query_as( - "UPDATE jobs SET progress_current = 6, updated_at = now() - WHERE id = $1 RETURNING progress_current, progress_total", + sqlx::query( + "UPDATE jobs + SET worker_id = 'progress-worker', lease_until = now() + interval '5 minutes' + WHERE id = $1", ) .bind(job_id) - .fetch_one(&pool) + .execute(&pool) .await .unwrap(); - assert_eq!(progress, (6, Some(10))); + let progress = + jobs::update_progress(&pool, job_id, "progress-worker", 6, Some(10), "processing") + .await + .unwrap() + .unwrap(); + assert_eq!( + (progress.progress_current, progress.progress_total), + (6, Some(10)) + ); sqlx::query("UPDATE jobs SET progress_current = 11 WHERE id = $1") .bind(job_id) @@ -531,76 +710,214 @@ async fn audit_jobs_project_delete_sets_null_and_purge_snapshot_survives() { .unwrap(); } -/// 会话状态由 PostgreSQL 权威保存,支持签发、轮换、吊销和过期全生命周期。 +/// 项目删除后 detached non-purge 不可领取/重试,只有 purge worker 可依 snapshot 继续。 #[tokio::test] -async fn audit_jobs_auth_session_state_machine_and_token_storage_contract() { +async fn audit_jobs_detached_non_purge_is_not_claimed_or_retried() { let pool = pool().await; - let username = audit_jobs_unique("session-user"); - let user_id: i64 = sqlx::query_scalar( - "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + let (user_id, project_id, slug) = audit_jobs_project(&pool, "detached-job").await; + let normal_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) + VALUES ('upload_process', $1, '{}') RETURNING id", ) - .bind(&username) + .bind(project_id) .fetch_one(&pool) .await .unwrap(); - let session_handle = audit_jobs_unique("session-handle"); - let family_handle = audit_jobs_unique("family-handle"); - let refresh_hash = audit_jobs_unique("refresh-hash"); - let session_id: i64 = sqlx::query_scalar( - "INSERT INTO auth_sessions ( - session_handle, family_handle, user_id, refresh_token_hash, state, expires_at - ) VALUES ($1, $2, $3, $4, 'pending', now() + interval '1 day') RETURNING id", + let failed_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, state, payload) + VALUES ('upload_cleanup', $1, 'failed', '{}') RETURNING id", ) - .bind(&session_handle) - .bind(&family_handle) - .bind(user_id) - .bind(&refresh_hash) + .bind(project_id) .fetch_one(&pool) .await .unwrap(); - - for state in ["active", "rotating", "revoked"] { - let stored: String = sqlx::query_scalar( - "UPDATE auth_sessions SET state = $2, updated_at = now() WHERE id = $1 RETURNING state", - ) - .bind(session_id) - .bind(state) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(stored, state); - } - let expired_handle = audit_jobs_unique("expired-handle"); - sqlx::query( - "INSERT INTO auth_sessions ( - session_handle, family_handle, user_id, refresh_token_hash, state, expires_at - ) VALUES ($1, $2, $3, $4, 'expired', now() - interval '1 second')", + let purge_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, payload) + VALUES ('project_purge', $1, $2) RETURNING id", ) - .bind(&expired_handle) - .bind(&family_handle) - .bind(user_id) - .bind(audit_jobs_unique("expired-refresh-hash")) - .execute(&pool) + .bind(project_id) + .bind(serde_json::json!({ + "project_id": project_id, + "slug": slug, + "media_keys": [], + "temp_keys": [], + "deadline": "2026-07-11T00:00:00Z" + })) + .fetch_one(&pool) .await .unwrap(); - sqlx::query("UPDATE auth_sessions SET state = 'unknown' WHERE id = $1") - .bind(session_id) + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) .execute(&pool) .await - .expect_err("未知 auth session 状态必须由约束拒绝"); - - let forbidden_columns: i64 = sqlx::query_scalar( - "SELECT COUNT(*) - FROM information_schema.columns - WHERE table_schema = 'public' - AND table_name IN ('auth_sessions', 'auth_session_intents', 'jobs', 'audit_log') - AND column_name IN ('access_token', 'refresh_token', 'raw_access_token', 'raw_refresh_token')", - ) - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(forbidden_columns, 0); + .unwrap(); + + let mut tx = pool.begin().await.unwrap(); + let locked = jobs::find_by_id_for_update_tx(&mut tx, failed_id) + .await + .unwrap() + .unwrap(); + assert_eq!(locked.project_id, None); + assert!(jobs::manual_retry_tx(&mut tx, failed_id) + .await + .unwrap() + .is_none()); + tx.commit().await.unwrap(); + + let kinds = vec![ + "upload_process".to_string(), + "upload_cleanup".to_string(), + "project_purge".to_string(), + ]; + let claimed = jobs::claim_next_for_ids_and_kinds( + &pool, + "detached-worker", + 300, + &[], + &kinds, + &[normal_id, failed_id, purge_id], + ) + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.id, purge_id); + let normal_state: String = sqlx::query_scalar("SELECT state FROM jobs WHERE id = $1") + .bind(normal_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(normal_state, "queued"); + + sqlx::query("DELETE FROM jobs WHERE id IN ($1, $2, $3)") + .bind(normal_id) + .bind(failed_id) + .bind(purge_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 会话状态由 PostgreSQL 权威保存,支持签发、轮换、吊销和过期全生命周期。 +#[tokio::test] +async fn audit_jobs_auth_session_state_machine_and_token_storage_contract() { + let pool = pool().await; + let username = audit_jobs_unique("session-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let session_handle = audit_jobs_unique("session-handle"); + let family_handle = audit_jobs_unique("family-handle"); + let refresh_hash_text = prts_auth::token::sha256_hex("raw-refresh-token"); + let refresh_hash = auth_sessions::RefreshTokenHash::parse(refresh_hash_text.clone()).unwrap(); + assert!(auth_sessions::RefreshTokenHash::parse("raw-refresh-token".to_string()).is_err()); + assert!( + auth_sessions::RefreshTokenHash::parse("cmF3LXJlZnJlc2gtdG9rZW4=".to_string()).is_err() + ); + let mut tx = pool.begin().await.unwrap(); + let session = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: session_handle.clone(), + family_handle: family_handle.clone(), + user_id, + refresh_token_hash: refresh_hash.clone(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + assert!( + auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &refresh_hash) + .await + .unwrap() + .is_none(), + "pending session 不得作为权威认证结果" + ); + let session = auth_sessions::activate_pending_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + let locked = auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &refresh_hash) + .await + .unwrap() + .unwrap(); + assert_eq!(locked.id, session.id); + let session = auth_sessions::begin_rotation_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + let session = auth_sessions::revoke_unexpired_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); + let session_id = session.id; + + let expired_hash = auth_sessions::RefreshTokenHash::parse(prts_auth::token::sha256_hex( + "expired-raw-refresh-token", + )) + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let expired = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("expired-handle"), + family_handle: family_handle.clone(), + user_id, + refresh_token_hash: expired_hash, + expires_at: chrono::Utc::now() - chrono::Duration::seconds(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + auth_sessions::expire_due_tx(&mut tx, expired.id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); + + sqlx::query( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, 'pending', now() + interval '1 day')", + ) + .bind(audit_jobs_unique("raw-hash-rejected")) + .bind(audit_jobs_unique("raw-family")) + .bind(user_id) + .bind("cmF3LXJlZnJlc2gtdG9rZW4=") + .execute(&pool) + .await + .expect_err("数据库必须拒绝非 SHA-256 hex refresh hash"); + + sqlx::query("UPDATE auth_sessions SET state = 'unknown' WHERE id = $1") + .bind(session_id) + .execute(&pool) + .await + .expect_err("未知 auth session 状态必须由约束拒绝"); + + let forbidden_columns: i64 = sqlx::query_scalar( + "SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name IN ('auth_sessions', 'auth_session_intents', 'jobs', 'audit_log') + AND column_name IN ('access_token', 'refresh_token', 'raw_access_token', 'raw_refresh_token')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(forbidden_columns, 0); let serialized_row: String = sqlx::query_scalar("SELECT row_to_json(s)::TEXT FROM auth_sessions AS s WHERE id = $1") @@ -608,7 +925,7 @@ async fn audit_jobs_auth_session_state_machine_and_token_storage_contract() { .fetch_one(&pool) .await .unwrap(); - assert!(serialized_row.contains(&refresh_hash)); + assert!(serialized_row.contains(&refresh_hash_text)); assert!(!serialized_row.contains("raw-access-token")); assert!(!serialized_row.contains("raw-refresh-token")); @@ -632,50 +949,327 @@ async fn audit_jobs_auth_session_rotation_links_predecessor_and_successor() { .await .unwrap(); let family = audit_jobs_unique("rotation-family"); - let predecessor_id: i64 = sqlx::query_scalar( - "INSERT INTO auth_sessions ( - session_handle, family_handle, user_id, refresh_token_hash, state, expires_at - ) VALUES ($1, $2, $3, $4, 'rotating', now() + interval '1 day') RETURNING id", + let mut tx = pool.begin().await.unwrap(); + let predecessor = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("predecessor"), + family_handle: family.clone(), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("predecessor-raw-token"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: None, + }, ) - .bind(audit_jobs_unique("predecessor")) - .bind(&family) - .bind(user_id) - .bind(audit_jobs_unique("predecessor-hash")) - .fetch_one(&pool) .await .unwrap(); - let successor_id: i64 = sqlx::query_scalar( - "INSERT INTO auth_sessions ( - session_handle, family_handle, user_id, refresh_token_hash, state, - expires_at, predecessor_id - ) VALUES ($1, $2, $3, $4, 'pending', now() + interval '1 day', $5) RETURNING id", + let predecessor = auth_sessions::activate_pending_tx(&mut tx, predecessor.id) + .await + .unwrap() + .unwrap(); + let predecessor = auth_sessions::begin_rotation_tx(&mut tx, predecessor.id) + .await + .unwrap() + .unwrap(); + let successor = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("successor"), + family_handle: family, + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("successor-raw-token"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: Some(predecessor.id), + }, ) - .bind(audit_jobs_unique("successor")) - .bind(&family) - .bind(user_id) - .bind(audit_jobs_unique("successor-hash")) - .bind(predecessor_id) + .await + .unwrap(); + let (predecessor, successor) = + auth_sessions::complete_rotation_tx(&mut tx, predecessor.id, successor.id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); + assert_eq!(predecessor.successor_id, Some(successor.id)); + assert_eq!(successor.predecessor_id, Some(predecessor.id)); + assert_eq!(predecessor.state, auth_sessions::AuthSessionState::Revoked); + assert_eq!(successor.state, auth_sessions::AuthSessionState::Active); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + +/// 每个 user/family 最多一个 active,会话激活与轮换必须走窄接口。 +#[tokio::test] +async fn audit_jobs_auth_session_rejects_double_active_and_wrong_rotation_chain() { + let pool = pool().await; + let username = audit_jobs_unique("rotation-chain-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) .fetch_one(&pool) .await .unwrap(); - sqlx::query("UPDATE auth_sessions SET successor_id = $2 WHERE id = $1") - .bind(predecessor_id) - .bind(successor_id) + let family = audit_jobs_unique("rotation-chain-family"); + let expires_at = chrono::Utc::now() + chrono::Duration::days(1); + + let mut tx = pool.begin().await.unwrap(); + let first = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("rotation-chain-first"), + family_handle: family.clone(), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("rotation-chain-first-token"), + ) + .unwrap(), + expires_at, + predecessor_id: None, + }, + ) + .await + .unwrap(); + let _first = auth_sessions::activate_pending_tx(&mut tx, first.id) + .await + .unwrap() + .unwrap(); + + let duplicate = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("rotation-chain-duplicate"), + family_handle: family.clone(), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("rotation-chain-duplicate-token"), + ) + .unwrap(), + expires_at, + predecessor_id: None, + }, + ) + .await + .unwrap(); + assert!( + auth_sessions::activate_pending_tx(&mut tx, duplicate.id) + .await + .is_err(), + "partial unique index 必须拒绝同 family 双 active" + ); + tx.rollback().await.unwrap(); + + // 使用新事务构造两个 rotating predecessor;successor 精确链接 first,不能由 second 接管。 + let mut tx = pool.begin().await.unwrap(); + let first = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("wrong-chain-first"), + family_handle: family.clone(), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("wrong-chain-first-token"), + ) + .unwrap(), + expires_at, + predecessor_id: None, + }, + ) + .await + .unwrap(); + let first = auth_sessions::activate_pending_tx(&mut tx, first.id) + .await + .unwrap() + .unwrap(); + let first = auth_sessions::begin_rotation_tx(&mut tx, first.id) + .await + .unwrap() + .unwrap(); + let second = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("wrong-chain-second"), + family_handle: family.clone(), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("wrong-chain-second-token"), + ) + .unwrap(), + expires_at, + predecessor_id: None, + }, + ) + .await + .unwrap(); + let second = auth_sessions::activate_pending_tx(&mut tx, second.id) + .await + .unwrap() + .unwrap(); + let second = auth_sessions::begin_rotation_tx(&mut tx, second.id) + .await + .unwrap() + .unwrap(); + let successor = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("wrong-chain-successor"), + family_handle: family, + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("wrong-chain-successor-token"), + ) + .unwrap(), + expires_at, + predecessor_id: Some(first.id), + }, + ) + .await + .unwrap(); + sqlx::query("SAVEPOINT linked_activation_bypass") + .execute(&mut *tx) + .await + .unwrap(); + sqlx::query( + "UPDATE auth_sessions + SET predecessor_id = NULL, state = 'active' + WHERE id = $1", + ) + .bind(successor.id) + .execute(&mut *tx) + .await + .expect_err("linked pending 不得通过清空 predecessor 绕过 complete_rotation"); + sqlx::query("ROLLBACK TO SAVEPOINT linked_activation_bypass") + .execute(&mut *tx) + .await + .unwrap(); + assert!( + auth_sessions::activate_pending_tx(&mut tx, successor.id) + .await + .unwrap() + .is_none(), + "带 predecessor 的 pending 不能走普通激活" + ); + assert!( + auth_sessions::complete_rotation_tx(&mut tx, second.id, successor.id) + .await + .unwrap() + .is_none(), + "successor.predecessor_id 不匹配时不得串链" + ); + let unchanged: Vec<(i64, String, Option)> = sqlx::query_as( + "SELECT id, state, successor_id FROM auth_sessions WHERE id IN ($1, $2) ORDER BY id", + ) + .bind(second.id) + .bind(successor.id) + .fetch_all(&mut *tx) + .await + .unwrap(); + assert!(unchanged.contains(&(second.id, "rotating".to_string(), None))); + assert!(unchanged.contains(&(successor.id, "pending".to_string(), None))); + tx.rollback().await.unwrap(); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) .execute(&pool) .await .unwrap(); +} - let links: (Option, Option) = sqlx::query_as( - "SELECT - (SELECT successor_id FROM auth_sessions WHERE id = $1), - (SELECT predecessor_id FROM auth_sessions WHERE id = $2)", +/// 权威 lookup/lock 只返回 active 且尚未过期的会话,并且公开值不携带 refresh hash。 +#[tokio::test] +async fn audit_jobs_auth_authoritative_lookup_rejects_non_active_and_expired_rows() { + let pool = pool().await; + let username = audit_jobs_unique("lookup-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", ) - .bind(predecessor_id) - .bind(successor_id) + .bind(&username) .fetch_one(&pool) .await .unwrap(); - assert_eq!(links, (Some(successor_id), Some(predecessor_id))); + + let mut expected_active = None; + let mut rejected = Vec::new(); + for (label, state, expired) in [ + ("pending", "pending", false), + ("active", "active", false), + ("rotating", "rotating", false), + ("revoked", "revoked", false), + ("expired-state", "expired", false), + ("expired-time", "active", true), + ] { + let handle = audit_jobs_unique(&format!("lookup-{label}")); + let hash = auth_sessions::RefreshTokenHash::parse(prts_auth::token::sha256_hex(&format!( + "lookup-{label}-raw-token" + ))) + .unwrap(); + let id: i64 = sqlx::query_scalar( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, state, expires_at + ) VALUES ($1, $2, $3, $4, $5, + CASE WHEN $6 THEN now() - interval '1 second' + ELSE now() + interval '1 day' END) + RETURNING id", + ) + .bind(&handle) + .bind(audit_jobs_unique(&format!("lookup-family-{label}"))) + .bind(user_id) + .bind(hash.as_str()) + .bind(state) + .bind(expired) + .fetch_one(&pool) + .await + .unwrap(); + if label == "active" { + expected_active = Some((id, handle, hash)); + } else { + rejected.push((handle, hash)); + } + } + + let (active_id, active_handle, active_hash) = expected_active.unwrap(); + let active = auth_sessions::find_active_unexpired_by_handle(&pool, &active_handle) + .await + .unwrap() + .unwrap(); + assert_eq!(active.id, active_id); + assert_eq!(active.state, auth_sessions::AuthSessionState::Active); + assert!(!format!("{active:?}").contains(active_hash.as_str())); + + let mut tx = pool.begin().await.unwrap(); + assert_eq!( + auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &active_hash) + .await + .unwrap() + .unwrap() + .id, + active_id + ); + for (handle, hash) in rejected { + assert!( + auth_sessions::find_active_unexpired_by_handle(&pool, &handle) + .await + .unwrap() + .is_none() + ); + assert!( + auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &hash) + .await + .unwrap() + .is_none() + ); + } + tx.rollback().await.unwrap(); sqlx::query("DELETE FROM users WHERE id = $1") .bind(user_id) @@ -696,69 +1290,93 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { .fetch_one(&pool) .await .unwrap(); - let session_id: i64 = sqlx::query_scalar( - "INSERT INTO auth_sessions ( - session_handle, family_handle, user_id, refresh_token_hash, state, expires_at - ) VALUES ($1, $2, $3, $4, 'active', now() + interval '1 day') RETURNING id", + let mut tx = pool.begin().await.unwrap(); + let session = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("intent-session"), + family_handle: audit_jobs_unique("intent-family"), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("intent-raw-refresh-token"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: None, + }, ) - .bind(audit_jobs_unique("intent-session")) - .bind(audit_jobs_unique("intent-family")) - .bind(user_id) - .bind(audit_jobs_unique("intent-refresh-hash")) - .fetch_one(&pool) .await .unwrap(); - let intent_id: i64 = sqlx::query_scalar( - "INSERT INTO auth_session_intents ( - session_id, kind, state, payload, attempts, max_attempts, - worker_id, lease_until - ) VALUES ( - $1, 'redis_invalidate', 'running', $2, 1, 5, - 'dead-worker', now() - interval '1 minute' - ) RETURNING id", + let session = auth_sessions::activate_pending_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + let intent = auth_sessions::enqueue_intent_tx( + &mut tx, + session.id, + auth_sessions::AuthIntentPayload::RedisInvalidate { + session_handle: session.session_handle.clone(), + }, + 2, ) - .bind(session_id) - .bind(serde_json::json!({"session_handle": "opaque-only"})) - .fetch_one(&pool) .await .unwrap(); - - let claimed: i64 = sqlx::query_scalar( - "WITH candidate AS ( - SELECT id FROM auth_session_intents - WHERE (state = 'queued' AND run_after <= now()) - OR (state = 'running' AND lease_until <= now()) - ORDER BY run_after, id - FOR UPDATE SKIP LOCKED - LIMIT 1 - ) - UPDATE auth_session_intents AS intent - SET state = 'running', worker_id = 'replacement-worker', - lease_until = now() + interval '5 minutes', - attempts = intent.attempts + 1, updated_at = now() - FROM candidate - WHERE intent.id = candidate.id - RETURNING intent.id", + tx.commit().await.unwrap(); + let session_id = session.id; + let intent_id = intent.id; + sqlx::query( + "UPDATE auth_session_intents + SET state = 'running', attempts = 1, worker_id = 'dead-worker', + lease_until = now() - interval '1 minute' + WHERE id = $1", ) - .fetch_one(&pool) + .bind(intent_id) + .execute(&pool) .await .unwrap(); - assert_eq!(claimed, intent_id); - let (retried_id, attempts): (i64, i32) = sqlx::query_as( - "UPDATE auth_session_intents - SET state = 'queued', attempts = attempts + 1, run_after = now(), - worker_id = NULL, lease_until = NULL, last_error_code = NULL, - last_error_message = NULL, updated_at = now() - WHERE id = $1 - RETURNING id, attempts", + assert!( + !auth_sessions::renew_intent_lease(&pool, intent_id, "dead-worker", 300) + .await + .unwrap(), + "过期 intent lease 不得被原 worker 复活" + ); + let claimed = + auth_sessions::claim_intent_for_session(&pool, "replacement-worker", 300, session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.id, intent_id); + assert!( + !auth_sessions::renew_intent_lease(&pool, intent_id, "dead-worker", 300) + .await + .unwrap() + ); + assert!( + auth_sessions::renew_intent_lease(&pool, intent_id, "replacement-worker", 300) + .await + .unwrap() + ); + let failed = auth_sessions::fail_intent( + &pool, + intent_id, + "replacement-worker", + "cache_unavailable", + "redacted cache failure", + 1, ) - .bind(intent_id) - .fetch_one(&pool) .await + .unwrap() .unwrap(); - assert_eq!(retried_id, intent_id); - assert_eq!(attempts, 3); + assert_eq!(failed.state, auth_sessions::AuthIntentState::Failed); + let mut tx = pool.begin().await.unwrap(); + let retried = auth_sessions::retry_intent_tx(&mut tx, intent_id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); + assert_eq!(retried.id, intent_id); + assert_eq!(retried.attempts, 3); sqlx::query( "INSERT INTO auth_session_intents (session_id, kind, payload) @@ -777,20 +1395,180 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { .unwrap(); } +/// auth intent 的最后一次尝试崩溃后必须稳定失败,不能重复 Redis 外部副作用。 +#[tokio::test] +async fn audit_jobs_auth_intent_last_attempt_crash_is_failed_without_reclaim() { + let pool = pool().await; + let username = audit_jobs_unique("intent-exhausted-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let session = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("intent-exhausted-session"), + family_handle: audit_jobs_unique("intent-exhausted-family"), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("intent-exhausted-raw-token"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + let session = auth_sessions::activate_pending_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + let intent = auth_sessions::enqueue_intent_tx( + &mut tx, + session.id, + auth_sessions::AuthIntentPayload::RedisInvalidate { + session_handle: session.session_handle.clone(), + }, + 1, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + sqlx::query( + "UPDATE auth_session_intents + SET state = 'running', attempts = 1, worker_id = 'crashed-intent-worker', + lease_until = now() - interval '1 minute' + WHERE id = $1", + ) + .bind(intent.id) + .execute(&pool) + .await + .unwrap(); + assert!(auth_sessions::claim_intent_for_session( + &pool, + "replacement-intent-worker", + 300, + session.id, + ) + .await + .unwrap() + .is_none()); + let persisted: ( + String, + i32, + Option, + Option, + Option>, + ) = sqlx::query_as( + "SELECT state, attempts, worker_id, last_error_code, lease_until + FROM auth_session_intents WHERE id = $1", + ) + .bind(intent.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(persisted.0, "failed"); + assert_eq!(persisted.1, 1); + assert!(persisted.2.is_none()); + assert_eq!( + persisted.3.as_deref(), + Some("auth_intent_attempts_exhausted") + ); + assert!(persisted.4.is_none()); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + /// job payload 同样必须拒绝 raw token 字段,避免异步链路泄密。 #[tokio::test] async fn audit_jobs_job_payload_rejects_raw_tokens() { let pool = pool().await; - sqlx::query("INSERT INTO jobs (kind, payload) VALUES ('unsafe-test', $1)") - .bind(serde_json::json!({"refresh_token": "raw-refresh-token"})) + for payload in [ + serde_json::json!({"token": "raw"}), + serde_json::json!({"refreshToken": "raw"}), + serde_json::json!({"access_token": "raw"}), + serde_json::json!({"apiKey": "raw"}), + serde_json::json!({"password": "raw"}), + serde_json::json!({"secret": "raw"}), + serde_json::json!({"verifier": "raw"}), + serde_json::json!({"code": "raw"}), + ] { + sqlx::query("INSERT INTO jobs (kind, payload) VALUES ('unsafe-test', $1)") + .bind(payload) + .execute(&pool) + .await + .expect_err("job payload 不得被命名风格绕过 secret-key 检查"); + } +} + +/// repository 创建与完成边界只接受 kind-specific payload/result 类型。 +#[tokio::test] +async fn audit_jobs_repository_uses_typed_payload_and_result() { + let pool = pool().await; + let (user_id, project_id, _) = audit_jobs_project(&pool, "typed-job").await; + let mut tx = pool.begin().await.unwrap(); + let created = jobs::create_tx( + &mut tx, + jobs::NewJob { + kind: jobs::JobKind::UploadCleanup, + project_id: Some(project_id), + stage: "queued".to_string(), + progress_total: None, + max_attempts: 1, + run_after: chrono::Utc::now(), + }, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + let claimed = jobs::claim_next_for_ids_and_kinds( + &pool, + "typed-job-worker", + 300, + &[], + &["upload_cleanup".to_string()], + &[created.id], + ) + .await + .unwrap() + .unwrap(); + assert_eq!(claimed.id, created.id); + let completed = jobs::complete( + &pool, + created.id, + "typed-job-worker", + jobs::JobResult::Completed, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(completed.result, Some(serde_json::json!({}))); + + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(created.id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM projects WHERE id = $1") + .bind(project_id) .execute(&pool) .await - .expect_err("job payload 不得接收 raw refresh token 字段"); - sqlx::query("INSERT INTO jobs (kind, payload) VALUES ('unsafe-test', $1)") - .bind(serde_json::json!({"access_token": "raw-access-token"})) + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) .execute(&pool) .await - .expect_err("job payload 不得接收 raw access token 字段"); + .unwrap(); } #[tokio::test] diff --git a/backend/crates/prts-api/tests/search_perf.rs b/backend/crates/prts-api/tests/search_perf.rs index 883416e..7882635 100644 --- a/backend/crates/prts-api/tests/search_perf.rs +++ b/backend/crates/prts-api/tests/search_perf.rs @@ -12,11 +12,25 @@ use prts_search::orchestrator::{run, OrchestratorInput}; use prts_search::SortBy; async fn pool() -> prts_db::Db { + let migration_url = + std::env::var("MIGRATION_DATABASE_URL").expect("MIGRATION_DATABASE_URL 未设置"); + let migration_pool = prts_db::connect_postgres(&migration_url, 1) + .await + .expect("连接 migration owner"); + let mut connection = migration_pool.acquire().await.expect("获取 migration 连接"); + prts_db::run_migrations(&mut connection, "prts_runtime") + .await + .expect("执行迁移"); + drop(connection); + migration_pool.close().await; + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); let pool = prts_db::connect_postgres(&url, 5) .await - .expect("连接 Postgres"); - prts_db::run_migrations(&pool).await.expect("执行迁移"); + .expect("连接 runtime Postgres"); + prts_db::verify_runtime_role(&pool, "prts_runtime") + .await + .expect("验证 runtime role"); pool } diff --git a/backend/crates/prts-common/src/config.rs b/backend/crates/prts-common/src/config.rs index 6f731b8..39f49db 100644 --- a/backend/crates/prts-common/src/config.rs +++ b/backend/crates/prts-common/src/config.rs @@ -143,13 +143,24 @@ pub struct ServerSettings { pub port: u16, } -/// PostgreSQL 配置。`url` 在生产环境务必经环境变量覆盖。 +/// PostgreSQL 配置。连接串只经环境变量注入;runtime 与 migration owner 必须分离。 #[derive(Debug, Clone, Deserialize)] pub struct DatabaseSettings { + /// 应用运行时连接串。该角色不得拥有 schema/table。 pub url: String, + /// 独立 migration owner 连接串,仅 `prts-api migrate` 子命令读取。 + #[serde(default)] + pub migration_url: Option, + /// 迁移授予最小业务权限的 runtime role 名称(非密钥)。 + #[serde(default = "default_runtime_role")] + pub runtime_role: String, pub max_connections: u32, } +fn default_runtime_role() -> String { + "prts_runtime".to_string() +} + /// Redis 配置。 #[derive(Debug, Clone, Deserialize)] pub struct RedisSettings { @@ -168,7 +179,9 @@ impl Default for ServerSettings { impl Default for DatabaseSettings { fn default() -> Self { Self { - url: "postgres://prts:prts@localhost:5432/prts".to_string(), + url: "postgres://prts_runtime:prts_runtime@localhost:5432/prts".to_string(), + migration_url: None, + runtime_role: default_runtime_role(), max_connections: 10, } } @@ -219,6 +232,8 @@ mod tests { assert_eq!(s.server.host, "0.0.0.0"); assert_eq!(s.server.addr(), "0.0.0.0:3000"); assert_eq!(s.database.max_connections, 10); + assert_eq!(s.database.runtime_role, "prts_runtime"); + assert!(s.database.migration_url.is_none()); assert!(s.redis.url.starts_with("redis://")); assert_eq!(s.embedding.qwen.dimensions, 1024); assert!(s.embedding.qwen.api_key.is_empty()); diff --git a/backend/crates/prts-core/src/jobs.rs b/backend/crates/prts-core/src/jobs.rs new file mode 100644 index 0000000..82ae693 --- /dev/null +++ b/backend/crates/prts-core/src/jobs.rs @@ -0,0 +1,227 @@ +//! 持久化任务的领域状态规则。 +//! +//! 本模块不依赖 Web 或数据库;仓储和 worker 复用这些状态/权限规则,避免在 handler +//! 或 SQL 中各自定义第二套任务生命周期。 + +use serde::{Deserialize, Serialize}; + +use crate::permission::nodes; + +/// 持久化任务状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobState { + /// 等待 worker。 + Queued, + /// 已被 worker 租用。 + Running, + /// 因所属资源状态暂停。 + Paused, + /// 成功结束。 + Succeeded, + /// 重试耗尽或不可恢复失败。 + Failed, + /// 用户或系统取消。 + Cancelled, +} + +impl JobState { + /// 解析数据库状态字符串。 + pub fn parse(value: &str) -> Option { + match value { + "queued" => Some(Self::Queued), + "running" => Some(Self::Running), + "paused" => Some(Self::Paused), + "succeeded" => Some(Self::Succeeded), + "failed" => Some(Self::Failed), + "cancelled" => Some(Self::Cancelled), + _ => None, + } + } + + /// 数据库存储值。 + pub const fn as_str(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Running => "running", + Self::Paused => "paused", + Self::Succeeded => "succeeded", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + } + } + + /// 当前状态是否允许受控手动重试。 + pub const fn manual_retry_allowed(self) -> bool { + matches!(self, Self::Failed) + } + + /// 是否已终止,不应再被 worker 领取。 + pub const fn is_terminal(self) -> bool { + matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled) + } +} + +/// 项目待删除期间是否仍允许执行此任务。 +pub fn may_run_while_project_pending_deletion(kind: &str) -> bool { + kind == "project_purge" +} + +/// 某个 allowlisted job kind 的手动重试策略。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JobRetryPolicy { + /// 必须具备的现有项目权限节点。 + pub permission_node: &'static str, + /// 是否还必须精确匹配 `projects.owner_id`,平台管理员不能替代。 + pub owner_only: bool, +} + +/// 某个 allowlisted job kind 的读取策略。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JobViewPolicy { + /// 必须具备的现有项目权限节点。 + pub permission_node: &'static str, + /// 是否仅唯一 owner 可见,平台管理员不能替代。 + pub owner_only: bool, +} + +/// 当前 release 对外可识别的 job kind;新增 kind 必须同时定义 view/retry policy。 +pub const KNOWN_JOB_KINDS: &[&str] = &[ + "project_purge", + "primary_source_lexical_reindex", + "primary_source_embedding_backfill", + "upload_process", + "upload_cleanup", + "file_purge", + "file_retention_cleanup", +]; + +/// 返回 allowlisted job kind 的读取策略;未知 kind 不得通过 ID 或列表泄露。 +pub fn job_view_policy(kind: &str) -> Option { + match kind { + "project_purge" + | "primary_source_lexical_reindex" + | "primary_source_embedding_backfill" => Some(JobViewPolicy { + permission_node: nodes::PROJECT_DELETE, + owner_only: true, + }), + "upload_process" | "upload_cleanup" => Some(JobViewPolicy { + permission_node: nodes::PROJECT_FILE_UPLOAD, + owner_only: false, + }), + "file_purge" | "file_retention_cleanup" => Some(JobViewPolicy { + permission_node: nodes::PROJECT_MANAGE, + owner_only: false, + }), + _ => None, + } +} + +/// 返回 allowlisted job kind 的手动重试策略;未知 kind fail closed。 +pub fn manual_retry_policy(kind: &str) -> Option { + match kind { + "project_purge" + | "primary_source_lexical_reindex" + | "primary_source_embedding_backfill" => Some(JobRetryPolicy { + permission_node: nodes::PROJECT_DELETE, + owner_only: true, + }), + "upload_process" | "upload_cleanup" => Some(JobRetryPolicy { + permission_node: nodes::PROJECT_FILE_UPLOAD, + owner_only: false, + }), + "file_purge" | "file_retention_cleanup" => Some(JobRetryPolicy { + permission_node: nodes::PROJECT_MANAGE, + owner_only: false, + }), + _ => None, + } +} + +/// 自动重试退避秒数,按 attempt 指数增长并封顶五分钟。 +pub fn retry_backoff_seconds(attempts: i32) -> i64 { + let exponent = attempts.clamp(0, 8) as u32; + (1_i64 << exponent).min(300) +} + +/// 验证任务进度,不允许负值或超过总量。 +pub fn progress_is_valid(current: i64, total: Option) -> bool { + current >= 0 + && match total { + Some(total) => total >= 0 && current <= total, + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn job_states_roundtrip_and_only_failed_is_manually_retryable() { + for state in [ + JobState::Queued, + JobState::Running, + JobState::Paused, + JobState::Succeeded, + JobState::Failed, + JobState::Cancelled, + ] { + assert_eq!(JobState::parse(state.as_str()), Some(state)); + } + assert!(JobState::Failed.manual_retry_allowed()); + assert!(!JobState::Running.manual_retry_allowed()); + } + + #[test] + fn only_project_purge_runs_during_pending_deletion() { + assert!(may_run_while_project_pending_deletion("project_purge")); + assert!(!may_run_while_project_pending_deletion("upload_process")); + assert_eq!( + manual_retry_policy("project_purge"), + Some(JobRetryPolicy { + permission_node: nodes::PROJECT_DELETE, + owner_only: true, + }) + ); + assert_eq!( + manual_retry_policy("upload_process"), + Some(JobRetryPolicy { + permission_node: nodes::PROJECT_FILE_UPLOAD, + owner_only: false, + }) + ); + assert_eq!(manual_retry_policy("unknown"), None); + } + + #[test] + fn progress_and_backoff_are_bounded() { + assert!(progress_is_valid(6, Some(10))); + assert!(!progress_is_valid(11, Some(10))); + assert!(!progress_is_valid(-1, None)); + assert_eq!(retry_backoff_seconds(0), 1); + assert_eq!(retry_backoff_seconds(20), 256); + } + + #[test] + fn view_policy_is_allowlisted_and_unknown_kinds_fail_closed() { + assert!(KNOWN_JOB_KINDS + .iter() + .all(|kind| job_view_policy(kind).is_some())); + assert_eq!( + job_view_policy("project_purge"), + Some(JobViewPolicy { + permission_node: nodes::PROJECT_DELETE, + owner_only: true, + }) + ); + assert_eq!( + job_view_policy("upload_process"), + Some(JobViewPolicy { + permission_node: nodes::PROJECT_FILE_UPLOAD, + owner_only: false, + }) + ); + assert_eq!(job_view_policy("unknown"), None); + } +} diff --git a/backend/crates/prts-core/src/lib.rs b/backend/crates/prts-core/src/lib.rs index eb993e4..afba3dc 100644 --- a/backend/crates/prts-core/src/lib.rs +++ b/backend/crates/prts-core/src/lib.rs @@ -8,7 +8,9 @@ //! - `locked` / `hidden` 是**正交标志位**([`EntryFlags`]),独立于工作流。 pub mod entry; +pub mod jobs; pub mod permission; pub use entry::{EntryFlags, EntryState}; +pub use jobs::JobState; pub use permission::{PlatformRole, ProjectRole}; diff --git a/backend/crates/prts-db/src/audit.rs b/backend/crates/prts-db/src/audit.rs new file mode 100644 index 0000000..50fcb1e --- /dev/null +++ b/backend/crates/prts-db/src/audit.rs @@ -0,0 +1,176 @@ +//! 追加式安全审计仓储。 +//! +//! writer 只接受 action-specific DTO,并要求调用方传入事务连接,确保业务 mutation +//! 与审计同一次 PostgreSQL 提交。通用 JSON/实体序列化不会暴露为公开写接口。 + +use serde::Serialize; +use sqlx::PgConnection; + +use crate::models::AuditLog; + +/// 审计主体种类。 +#[derive(Debug, Clone, Copy)] +pub enum AuditActorKind { + /// 已登录用户。 + User, + /// API Key 调用。 + ApiKey, + /// 后台 worker/系统进程。 + System, + /// 未认证请求。 + Anonymous, +} + +impl AuditActorKind { + fn as_str(self) -> &'static str { + match self { + Self::User => "user", + Self::ApiKey => "api_key", + Self::System => "system", + Self::Anonymous => "anonymous", + } + } +} + +/// 审计主体与请求来源。 +#[derive(Debug, Clone, Copy)] +pub struct AuditActor<'a> { + /// 删除用户后仍保留的 actor id snapshot。 + pub id: Option, + /// 主体种类。 + pub kind: AuditActorKind, + /// 可选客户端 IP 文本;PostgreSQL 负责 INET 校验。 + pub ip: Option<&'a str>, +} + +/// `job.retried` 的字段 allowlist。 +#[derive(Debug, Serialize)] +struct JobRetriedPayload<'a> { + kind: &'a str, + previous_attempts: i32, + new_attempts: i32, +} + +/// `job.created` 的字段 allowlist。 +#[derive(Debug, Serialize)] +struct JobCreatedPayload<'a> { + kind: &'a str, + stage: &'a str, +} + +/// `auth.session_state_changed` 的字段 allowlist。 +#[derive(Debug, Serialize)] +struct AuthSessionStatePayload<'a> { + session_handle: &'a str, + previous_state: &'a str, + new_state: &'a str, +} + +/// 追加任务创建审计。调用方必须与任务 INSERT 共用同一事务连接。 +pub async fn append_job_created_tx( + conn: &mut PgConnection, + actor: AuditActor<'_>, + job_id: i64, + project_id_snapshot: Option, + kind: &str, + stage: &str, +) -> Result { + let payload = serde_json::to_value(JobCreatedPayload { kind, stage }) + .expect("allowlisted audit DTO must serialize"); + append_tx( + conn, + actor, + "job.created", + "job", + &job_id.to_string(), + project_id_snapshot, + payload, + ) + .await +} + +/// 追加手动重试审计。payload 只包含 job kind 与计数,不包含 job 通用 payload。 +pub async fn append_job_retried_tx( + conn: &mut PgConnection, + actor: AuditActor<'_>, + job_id: i64, + project_id_snapshot: Option, + kind: &str, + previous_attempts: i32, + new_attempts: i32, +) -> Result { + let payload = serde_json::to_value(JobRetriedPayload { + kind, + previous_attempts, + new_attempts, + }) + .expect("allowlisted audit DTO must serialize"); + append_tx( + conn, + actor, + "job.retried", + "job", + &job_id.to_string(), + project_id_snapshot, + payload, + ) + .await +} + +/// 追加认证会话状态变化审计;不接受 refresh hash 或 raw token。 +pub async fn append_auth_session_state_tx( + conn: &mut PgConnection, + actor: AuditActor<'_>, + session_id: i64, + session_handle: &str, + previous_state: &str, + new_state: &str, +) -> Result { + let payload = serde_json::to_value(AuthSessionStatePayload { + session_handle, + previous_state, + new_state, + }) + .expect("allowlisted audit DTO must serialize"); + append_tx( + conn, + actor, + "auth.session_state_changed", + "auth_session", + &session_id.to_string(), + None, + payload, + ) + .await +} + +/// 唯一底层 INSERT;保持私有以阻止调用方绕过 action-specific allowlist DTO。 +async fn append_tx( + conn: &mut PgConnection, + actor: AuditActor<'_>, + action: &str, + target_type: &str, + target_id: &str, + project_id_snapshot: Option, + payload: serde_json::Value, +) -> Result { + sqlx::query_as( + "INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, + project_id_snapshot, payload, ip + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::INET) + RETURNING id, actor_id, actor_kind, action, target_type, target_id, + project_id_snapshot, payload, host(ip)::TEXT AS ip, created_at", + ) + .bind(actor.id) + .bind(actor.kind.as_str()) + .bind(action) + .bind(target_type) + .bind(target_id) + .bind(project_id_snapshot) + .bind(payload) + .bind(actor.ip) + .fetch_one(conn) + .await +} diff --git a/backend/crates/prts-db/src/auth_sessions.rs b/backend/crates/prts-db/src/auth_sessions.rs new file mode 100644 index 0000000..bea99aa --- /dev/null +++ b/backend/crates/prts-db/src/auth_sessions.rs @@ -0,0 +1,668 @@ +//! PostgreSQL 权威认证会话与 durable cache intent/outbox 仓储。 +//! +//! 公开接口只接收强类型 refresh hash,并且永不返回持久化 hash。权威读取、锁定、激活 +//! 与 rotation SQL 自身都要求 `expires_at > now()`;Redis 只能缓存这里确认过的 active 行。 + +use chrono::{DateTime, Utc}; +use sqlx::{FromRow, PgConnection, PgPool}; + +/// 已验证的 SHA-256 小写十六进制 refresh token hash。 +#[derive(Clone, PartialEq, Eq)] +pub struct RefreshTokenHash(String); + +impl std::fmt::Debug for RefreshTokenHash { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RefreshTokenHash([REDACTED])") + } +} + +/// refresh hash 格式错误;raw/base64 token 不得进入 repository。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidRefreshTokenHash; + +impl std::fmt::Display for InvalidRefreshTokenHash { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("refresh token hash must be 64 lowercase hexadecimal characters") + } +} + +impl std::error::Error for InvalidRefreshTokenHash {} + +impl RefreshTokenHash { + /// 验证并包装 hash。构造失败时调用方必须拒绝,而不能回退存 raw token。 + pub fn parse(value: String) -> Result { + if value.len() == 64 + && value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + Ok(Self(value)) + } else { + Err(InvalidRefreshTokenHash) + } + } + + /// 参数化 SQL 绑定使用的已验证文本。 + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// 权威认证会话状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthSessionState { + Pending, + Active, + Rotating, + Revoked, + Expired, +} + +impl AuthSessionState { + fn parse(value: &str) -> Option { + match value { + "pending" => Some(Self::Pending), + "active" => Some(Self::Active), + "rotating" => Some(Self::Rotating), + "revoked" => Some(Self::Revoked), + "expired" => Some(Self::Expired), + _ => None, + } + } +} + +/// 对上层公开的会话快照。持久化 refresh hash 刻意不在该类型中。 +#[derive(Debug, Clone)] +pub struct AuthSession { + pub id: i64, + pub session_handle: String, + pub family_handle: String, + pub user_id: i64, + pub state: AuthSessionState, + pub expires_at: DateTime, + pub predecessor_id: Option, + pub successor_id: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// 仅仓储内部可见的完整持久化行。 +#[derive(Debug, FromRow)] +struct AuthSessionRow { + id: i64, + session_handle: String, + family_handle: String, + user_id: i64, + refresh_token_hash: String, + state: String, + expires_at: DateTime, + predecessor_id: Option, + successor_id: Option, + created_at: DateTime, + updated_at: DateTime, +} + +impl TryFrom for AuthSession { + type Error = sqlx::Error; + + fn try_from(row: AuthSessionRow) -> Result { + let state = AuthSessionState::parse(&row.state) + .ok_or_else(|| sqlx::Error::Protocol("unknown auth session state".to_string()))?; + // 明确消费但不复制 hash,防止未来自动转换意外将其暴露。 + drop(row.refresh_token_hash); + Ok(Self { + id: row.id, + session_handle: row.session_handle, + family_handle: row.family_handle, + user_id: row.user_id, + state, + expires_at: row.expires_at, + predecessor_id: row.predecessor_id, + successor_id: row.successor_id, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } +} + +fn into_session(row: Option) -> Result, sqlx::Error> { + row.map(AuthSession::try_from).transpose() +} + +/// 新建 pending 会话所需的非秘密字段。 +#[derive(Debug, Clone)] +pub struct NewAuthSession { + pub session_handle: String, + pub family_handle: String, + pub user_id: i64, + pub refresh_token_hash: RefreshTokenHash, + pub expires_at: DateTime, + pub predecessor_id: Option, +} + +/// 创建不可认证的 pending 会话;调用方提交 active 状态后才可返回 raw token。 +pub async fn create_pending_tx( + conn: &mut PgConnection, + input: NewAuthSession, +) -> Result { + let row: AuthSessionRow = sqlx::query_as( + "INSERT INTO auth_sessions ( + session_handle, family_handle, user_id, refresh_token_hash, + state, expires_at, predecessor_id + ) VALUES ($1, $2, $3, $4, 'pending', $5, $6) + RETURNING *", + ) + .bind(input.session_handle) + .bind(input.family_handle) + .bind(input.user_id) + .bind(input.refresh_token_hash.as_str()) + .bind(input.expires_at) + .bind(input.predecessor_id) + .fetch_one(conn) + .await?; + row.try_into() +} + +/// 按 refresh hash 锁定 active 且未过期的权威会话。 +pub async fn lock_active_unexpired_by_refresh_hash_tx( + conn: &mut PgConnection, + refresh_token_hash: &RefreshTokenHash, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "SELECT * FROM auth_sessions + WHERE refresh_token_hash = $1 AND state = 'active' AND expires_at > now() + FOR UPDATE", + ) + .bind(refresh_token_hash.as_str()) + .fetch_optional(conn) + .await?; + into_session(row) +} + +/// Redis cache miss 只可回源 active 且未过期的 opaque handle。 +pub async fn find_active_unexpired_by_handle( + pool: &PgPool, + session_handle: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "SELECT * FROM auth_sessions + WHERE session_handle = $1 AND state = 'active' AND expires_at > now()", + ) + .bind(session_handle) + .fetch_optional(pool) + .await?; + into_session(row) +} + +/// 激活没有 predecessor 的普通 pending 会话;rotation successor 必须走 complete_rotation。 +pub async fn activate_pending_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'active', updated_at = now() + WHERE id = $1 AND state = 'pending' AND predecessor_id IS NULL + AND expires_at > now() + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await?; + into_session(row) +} + +/// 将 active 且未过期的会话置为 rotating。 +pub async fn begin_rotation_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'rotating', updated_at = now() + WHERE id = $1 AND state = 'active' AND expires_at > now() + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await?; + into_session(row) +} + +/// 吊销尚未过期的 pending/active/rotating 会话。 +pub async fn revoke_unexpired_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'revoked', updated_at = now() + WHERE id = $1 AND state IN ('pending', 'active', 'rotating') + AND expires_at > now() + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await?; + into_session(row) +} + +/// 把已经到期的非终态会话物化为 expired;未到期行不会被误终结。 +pub async fn expire_due_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'expired', updated_at = now() + WHERE id = $1 AND state IN ('pending', 'active', 'rotating') + AND expires_at <= now() + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await?; + into_session(row) +} + +/// 原子完成 rotation:锁定并验证两行,先撤销 predecessor,再激活精确 successor。 +pub async fn complete_rotation_tx( + conn: &mut PgConnection, + predecessor_id: i64, + successor_id: i64, +) -> Result, sqlx::Error> { + if predecessor_id == successor_id { + return Ok(None); + } + let ids = [predecessor_id, successor_id]; + let rows: Vec = sqlx::query_as( + "SELECT * FROM auth_sessions + WHERE id = ANY($1::BIGINT[]) AND expires_at > now() + ORDER BY id + FOR UPDATE", + ) + .bind(&ids[..]) + .fetch_all(&mut *conn) + .await?; + if rows.len() != 2 { + return Ok(None); + } + let mut predecessor = None; + let mut successor = None; + for row in rows { + if row.id == predecessor_id { + predecessor = Some(row); + } else if row.id == successor_id { + successor = Some(row); + } + } + let (predecessor, successor) = match (predecessor, successor) { + (Some(predecessor), Some(successor)) + if predecessor.state == "rotating" + && successor.state == "pending" + && successor.predecessor_id == Some(predecessor.id) + && predecessor + .successor_id + .is_none_or(|linked_id| linked_id == successor.id) + && predecessor.user_id == successor.user_id + && predecessor.family_handle == successor.family_handle => + { + (predecessor, successor) + } + _ => return Ok(None), + }; + + let predecessor = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'revoked', successor_id = $2, updated_at = now() + WHERE id = $1 AND state = 'rotating' AND expires_at > now() + AND (successor_id IS NULL OR successor_id = $2) + RETURNING *", + ) + .bind(predecessor.id) + .bind(successor.id) + .fetch_optional(&mut *conn) + .await?; + let Some(predecessor) = predecessor else { + return Ok(None); + }; + let successor = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'active', updated_at = now() + WHERE id = $1 AND state = 'pending' AND predecessor_id = $2 + AND expires_at > now() + RETURNING *", + ) + .bind(successor.id) + .bind(predecessor.id) + .fetch_optional(conn) + .await?; + let Some(successor) = successor else { + return Ok(None); + }; + Ok(Some((predecessor.try_into()?, successor.try_into()?))) +} + +/// auth intent 的允许载荷;只包含 opaque handle 与公开过期时间。 +#[derive(Debug, Clone)] +pub enum AuthIntentPayload { + RedisPopulate { + session_handle: String, + expires_at: DateTime, + }, + RedisInvalidate { + session_handle: String, + }, +} + +impl AuthIntentPayload { + fn kind(&self) -> &'static str { + match self { + Self::RedisPopulate { .. } => "redis_populate", + Self::RedisInvalidate { .. } => "redis_invalidate", + } + } + + fn to_json(&self) -> serde_json::Value { + match self { + Self::RedisPopulate { + session_handle, + expires_at, + } => serde_json::json!({ + "session_handle": session_handle, + "expires_at": expires_at, + }), + Self::RedisInvalidate { session_handle } => { + serde_json::json!({"session_handle": session_handle}) + } + } + } +} + +/// Durable intent 状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthIntentState { + Queued, + Running, + Succeeded, + Failed, + Cancelled, +} + +impl AuthIntentState { + fn parse(value: &str) -> Option { + match value { + "queued" => Some(Self::Queued), + "running" => Some(Self::Running), + "succeeded" => Some(Self::Succeeded), + "failed" => Some(Self::Failed), + "cancelled" => Some(Self::Cancelled), + _ => None, + } + } +} + +/// 对 worker 公开的 intent 快照;持久化 row/state 字符串保持模块私有。 +#[derive(Debug, Clone)] +pub struct AuthSessionIntent { + pub id: i64, + pub session_id: i64, + pub kind: String, + pub state: AuthIntentState, + pub payload: serde_json::Value, + pub attempts: i32, + pub max_attempts: i32, + pub run_after: DateTime, + pub lease_until: Option>, + pub worker_id: Option, + pub last_error_code: Option, + pub last_error_message: Option, + pub created_at: DateTime, + pub completed_at: Option>, + pub updated_at: DateTime, +} + +#[derive(Debug, FromRow)] +struct AuthSessionIntentRow { + id: i64, + session_id: i64, + kind: String, + state: String, + payload: serde_json::Value, + attempts: i32, + max_attempts: i32, + run_after: DateTime, + lease_until: Option>, + worker_id: Option, + last_error_code: Option, + last_error_message: Option, + created_at: DateTime, + completed_at: Option>, + updated_at: DateTime, +} + +impl TryFrom for AuthSessionIntent { + type Error = sqlx::Error; + + fn try_from(row: AuthSessionIntentRow) -> Result { + let state = AuthIntentState::parse(&row.state) + .ok_or_else(|| sqlx::Error::Protocol("unknown auth intent state".to_string()))?; + Ok(Self { + id: row.id, + session_id: row.session_id, + kind: row.kind, + state, + payload: row.payload, + attempts: row.attempts, + max_attempts: row.max_attempts, + run_after: row.run_after, + lease_until: row.lease_until, + worker_id: row.worker_id, + last_error_code: row.last_error_code, + last_error_message: row.last_error_message, + created_at: row.created_at, + completed_at: row.completed_at, + updated_at: row.updated_at, + }) + } +} + +fn into_intent( + row: Option, +) -> Result, sqlx::Error> { + row.map(AuthSessionIntent::try_from).transpose() +} + +/// 在业务事务内写入 durable cache intent/outbox。 +pub async fn enqueue_intent_tx( + conn: &mut PgConnection, + session_id: i64, + payload: AuthIntentPayload, + max_attempts: i32, +) -> Result { + let row: AuthSessionIntentRow = sqlx::query_as( + "INSERT INTO auth_session_intents (session_id, kind, payload, max_attempts) + VALUES ($1, $2, $3, $4) + RETURNING *", + ) + .bind(session_id) + .bind(payload.kind()) + .bind(payload.to_json()) + .bind(max_attempts) + .fetch_one(conn) + .await?; + row.try_into() +} + +/// 领取任意到期或租约过期的 intent;并发实例通过 `SKIP LOCKED` 隔离。 +pub async fn claim_intent( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, +) -> Result, sqlx::Error> { + claim_intent_inner(pool, worker_id, lease_seconds, None).await +} + +/// 将领取限定到单一 session,供分片 worker 与互不抢占的合同测试复用。 +pub async fn claim_intent_for_session( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, + session_id: i64, +) -> Result, sqlx::Error> { + claim_intent_inner(pool, worker_id, lease_seconds, Some(session_id)).await +} + +async fn claim_intent_inner( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, + session_id: Option, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionIntentRow>( + "WITH exhausted AS ( + UPDATE auth_session_intents + SET state = 'failed', worker_id = NULL, lease_until = NULL, + last_error_code = 'auth_intent_attempts_exhausted', + last_error_message = 'auth intent exhausted all attempts', + completed_at = now(), updated_at = now() + WHERE state = 'running' AND lease_until <= now() + AND attempts >= max_attempts + AND ($3::BIGINT IS NULL OR session_id = $3) + RETURNING id + ), candidate AS ( + SELECT id + FROM auth_session_intents + WHERE attempts < max_attempts + AND ($3::BIGINT IS NULL OR session_id = $3) + AND ( + (state = 'queued' AND run_after <= now()) + OR (state = 'running' AND lease_until <= now()) + ) + ORDER BY run_after, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE auth_session_intents AS intent + SET state = 'running', worker_id = $1, + lease_until = now() + make_interval(secs => $2), + attempts = intent.attempts + 1, updated_at = now() + FROM candidate + WHERE intent.id = candidate.id + RETURNING intent.*", + ) + .bind(worker_id) + .bind(lease_seconds.max(1) as f64) + .bind(session_id) + .fetch_optional(pool) + .await?; + into_intent(row) +} + +/// 仅当前持有者可续租 intent,过期 lease 不能被复活。 +pub async fn renew_intent_lease( + pool: &PgPool, + id: i64, + worker_id: &str, + lease_seconds: i64, +) -> Result { + sqlx::query( + "UPDATE auth_session_intents + SET lease_until = now() + make_interval(secs => $3), updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now()", + ) + .bind(id) + .bind(worker_id) + .bind(lease_seconds.max(1) as f64) + .execute(pool) + .await + .map(|result| result.rows_affected() == 1) +} + +/// 完成 intent;Redis 操作成功后保留历史结果行。 +pub async fn complete_intent( + pool: &PgPool, + id: i64, + worker_id: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionIntentRow>( + "UPDATE auth_session_intents + SET state = 'succeeded', worker_id = NULL, lease_until = NULL, + completed_at = now(), updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .fetch_optional(pool) + .await?; + into_intent(row) +} + +/// 记录 intent 错误并按剩余 attempts 进入 queued 或 failed。 +pub async fn fail_intent( + pool: &PgPool, + id: i64, + worker_id: &str, + error_code: &str, + error_message: &str, + retry_after_seconds: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionIntentRow>( + "UPDATE auth_session_intents + SET state = CASE WHEN attempts < max_attempts THEN 'queued' ELSE 'failed' END, + run_after = CASE WHEN attempts < max_attempts + THEN now() + make_interval(secs => $5) ELSE run_after END, + worker_id = NULL, lease_until = NULL, + last_error_code = $3, last_error_message = $4, + completed_at = CASE WHEN attempts < max_attempts THEN NULL ELSE now() END, + updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .bind(error_code) + .bind(error_message) + .bind(retry_after_seconds.max(0) as f64) + .fetch_optional(pool) + .await?; + into_intent(row) +} + +/// 手动重试复用同一 intent id;保留单调 attempts,并显式增加一次可执行预算。 +pub async fn retry_intent_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionIntentRow>( + "UPDATE auth_session_intents + SET state = 'queued', attempts = attempts + 1, + max_attempts = GREATEST(max_attempts, attempts + 2), run_after = now(), + worker_id = NULL, lease_until = NULL, + last_error_code = NULL, last_error_message = NULL, + completed_at = NULL, updated_at = now() + WHERE id = $1 AND state = 'failed' + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await?; + into_intent(row) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refresh_hash_is_strongly_validated_and_debug_redacted() { + let hash = RefreshTokenHash::parse("a".repeat(64)).unwrap(); + assert_eq!(format!("{hash:?}"), "RefreshTokenHash([REDACTED])"); + assert!(RefreshTokenHash::parse("raw-token".to_string()).is_err()); + } +} diff --git a/backend/crates/prts-db/src/jobs.rs b/backend/crates/prts-db/src/jobs.rs new file mode 100644 index 0000000..a9838e5 --- /dev/null +++ b/backend/crates/prts-db/src/jobs.rs @@ -0,0 +1,457 @@ +//! 持久化任务仓储:类型化创建/完成、可见性键集读取、租约领取与同一行重试。 +//! +//! 所有 SQL 均参数化。待删除项目集合由调用方显式传入,因此 foundation 不硬引用 +//! `0014` 才会创建的项目删除列。 + +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::{PgConnection, PgPool}; + +use crate::models::Job; + +/// 项目永久清除在项目行删除后仍需使用的不可变 snapshot。 +#[derive(Debug, Clone, Serialize)] +pub struct ProjectPurgeSnapshot { + pub project_id: i64, + pub slug: String, + pub media_keys: Vec, + pub temp_keys: Vec, + pub deadline: DateTime, +} + +/// kind 与 payload 被同一 enum 绑定;调用方无法把任意 JSON 或错误 payload 配给 kind。 +#[derive(Debug, Clone)] +pub enum JobKind { + ProjectPurge(ProjectPurgeSnapshot), + PrimarySourceLexicalReindex, + PrimarySourceEmbeddingBackfill, + UploadProcess, + UploadCleanup, + FilePurge, + FileRetentionCleanup, +} + +impl JobKind { + /// 解析不需要 payload 的 allowlisted kind。purge 必须显式提供 snapshot,故不从字符串构造。 + pub fn parse(value: &str) -> Option { + match value { + "primary_source_lexical_reindex" => Some(Self::PrimarySourceLexicalReindex), + "primary_source_embedding_backfill" => Some(Self::PrimarySourceEmbeddingBackfill), + "upload_process" => Some(Self::UploadProcess), + "upload_cleanup" => Some(Self::UploadCleanup), + "file_purge" => Some(Self::FilePurge), + "file_retention_cleanup" => Some(Self::FileRetentionCleanup), + _ => None, + } + } + + pub const fn as_str(&self) -> &'static str { + match self { + Self::ProjectPurge(_) => "project_purge", + Self::PrimarySourceLexicalReindex => "primary_source_lexical_reindex", + Self::PrimarySourceEmbeddingBackfill => "primary_source_embedding_backfill", + Self::UploadProcess => "upload_process", + Self::UploadCleanup => "upload_cleanup", + Self::FilePurge => "file_purge", + Self::FileRetentionCleanup => "file_retention_cleanup", + } + } + + fn into_parts(self) -> (&'static str, serde_json::Value) { + match self { + Self::ProjectPurge(snapshot) => ( + "project_purge", + serde_json::to_value(snapshot).expect("allowlisted purge snapshot must serialize"), + ), + other => (other.as_str(), serde_json::json!({})), + } + } +} + +/// allowlisted worker 结果。foundation 不提前定义未来业务 handler 的结果字段。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JobResult { + Completed, +} + +impl JobResult { + fn into_json(self) -> serde_json::Value { + match self { + Self::Completed => serde_json::json!({}), + } + } +} + +/// 新建任务参数;payload 只能由 `kind` variant 产生。 +#[derive(Debug, Clone)] +pub struct NewJob { + pub kind: JobKind, + pub project_id: Option, + pub stage: String, + pub progress_total: Option, + pub max_attempts: i32, + pub run_after: DateTime, +} + +/// 在现有事务连接内创建任务,供业务写与审计原子提交。 +pub async fn create_tx(conn: &mut PgConnection, input: NewJob) -> Result { + if input.project_id.is_none() { + return Err(sqlx::Error::Protocol( + "new project-scoped job requires project_id".to_string(), + )); + } + let (kind, payload) = input.kind.into_parts(); + sqlx::query_as( + "INSERT INTO jobs ( + kind, project_id, stage, payload, progress_total, max_attempts, run_after + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING *", + ) + .bind(kind) + .bind(input.project_id) + .bind(input.stage) + .bind(payload) + .bind(input.progress_total) + .bind(input.max_attempts) + .bind(input.run_after) + .fetch_one(conn) + .await +} + +/// 按稳定 id 读取任务。route 仍必须在返回前执行 kind policy 与项目可见性检查。 +pub async fn find_by_id(pool: &PgPool, id: i64) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM jobs WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await +} + +/// 在事务内锁定任务,供 mutation 编排。 +pub async fn find_by_id_for_update_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as("SELECT * FROM jobs WHERE id = $1 FOR UPDATE") + .bind(id) + .fetch_optional(conn) + .await +} + +/// 按项目、允许 kind 集合和筛选条件键集列出任务;权限过滤在 LIMIT 之前下推。 +pub async fn list_by_project( + pool: &PgPool, + project_id: i64, + after_id: Option, + allowed_kinds: &[String], + kind: Option<&str>, + state: Option<&str>, + limit: i64, +) -> Result, sqlx::Error> { + if allowed_kinds.is_empty() { + return Ok(Vec::new()); + } + sqlx::query_as( + "SELECT * FROM jobs + WHERE project_id = $1 + AND ($2::BIGINT IS NULL OR id < $2) + AND kind = ANY($3::TEXT[]) + AND ($4::TEXT IS NULL OR kind = $4) + AND ($5::TEXT IS NULL OR state = $5) + ORDER BY id DESC + LIMIT $6", + ) + .bind(project_id) + .bind(after_id) + .bind(allowed_kinds) + .bind(kind) + .bind(state) + .bind(limit) + .fetch_all(pool) + .await +} + +/// 只领取当前进程已注册 handler 的任务,避免未知 kind 被错误消费。 +/// +/// 同一 SQL 先把已耗尽的到期行稳定终结,再从 `attempts < max_attempts` 中领取候选; +/// 最后一次尝试崩溃后不会再次执行外部副作用。 +pub async fn claim_next_for_kinds( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, + pending_project_ids: &[i64], + kinds: &[String], +) -> Result, sqlx::Error> { + claim_next_inner( + pool, + worker_id, + lease_seconds, + pending_project_ids, + kinds, + false, + &[], + ) + .await +} + +/// 在精确 job id 范围内领取,供分片/定向恢复 worker 与隔离的合同测试使用。 +pub async fn claim_next_for_ids_and_kinds( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, + pending_project_ids: &[i64], + kinds: &[String], + job_ids: &[i64], +) -> Result, sqlx::Error> { + if job_ids.is_empty() { + return Ok(None); + } + claim_next_inner( + pool, + worker_id, + lease_seconds, + pending_project_ids, + kinds, + true, + job_ids, + ) + .await +} + +async fn claim_next_inner( + pool: &PgPool, + worker_id: &str, + lease_seconds: i64, + pending_project_ids: &[i64], + kinds: &[String], + restrict_to_ids: bool, + job_ids: &[i64], +) -> Result, sqlx::Error> { + if kinds.is_empty() { + return Ok(None); + } + let mut tx = pool.begin().await?; + pause_for_pending_projects_tx(&mut tx, pending_project_ids).await?; + let job = sqlx::query_as( + "WITH exhausted AS ( + UPDATE jobs + SET state = 'failed', worker_id = NULL, lease_until = NULL, + last_error_code = 'job_attempts_exhausted', + last_error_message = 'job exhausted all attempts', + finished_at = now(), updated_at = now() + WHERE attempts >= max_attempts + AND kind = ANY($4::TEXT[]) + AND (NOT $5 OR id = ANY($6::BIGINT[])) + AND ( + (state = 'queued' AND run_after <= now()) + OR (state = 'running' AND lease_until <= now()) + ) + RETURNING id + ), candidate AS ( + SELECT id + FROM jobs + WHERE attempts < max_attempts + AND kind = ANY($4::TEXT[]) + AND (NOT $5 OR id = ANY($6::BIGINT[])) + AND ( + (state = 'queued' AND run_after <= now()) + OR (state = 'running' AND lease_until <= now()) + ) + AND ( + kind = 'project_purge' + OR ( + project_id IS NOT NULL + AND NOT (project_id = ANY($3::BIGINT[])) + ) + ) + ORDER BY run_after, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE jobs AS job + SET state = 'running', pause_reason = NULL, worker_id = $1, + lease_until = now() + make_interval(secs => $2), + attempts = job.attempts + 1, + started_at = COALESCE(job.started_at, now()), + finished_at = NULL, updated_at = now() + FROM candidate + WHERE job.id = candidate.id + RETURNING job.*", + ) + .bind(worker_id) + .bind(lease_seconds.max(1) as f64) + .bind(pending_project_ids) + .bind(kinds) + .bind(restrict_to_ids) + .bind(job_ids) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + Ok(job) +} + +/// 将待删除项目的非 purge 活跃任务暂停;调用方传空集合时是无操作。 +pub async fn pause_for_pending_projects_tx( + conn: &mut PgConnection, + project_ids: &[i64], +) -> Result { + if project_ids.is_empty() { + return Ok(0); + } + sqlx::query( + "UPDATE jobs + SET state = 'paused', pause_reason = 'project_pending_deletion', + worker_id = NULL, lease_until = NULL, updated_at = now() + WHERE project_id = ANY($1::BIGINT[]) + AND kind <> 'project_purge' + AND state IN ('queued', 'running')", + ) + .bind(project_ids) + .execute(conn) + .await + .map(|result| result.rows_affected()) +} + +/// 取消删除后只恢复由 pending-deletion gate 暂停的任务。 +pub async fn resume_project_jobs(pool: &PgPool, project_id: i64) -> Result { + sqlx::query( + "UPDATE jobs + SET state = 'queued', pause_reason = NULL, run_after = now(), updated_at = now() + WHERE project_id = $1 AND state = 'paused' + AND pause_reason = 'project_pending_deletion'", + ) + .bind(project_id) + .execute(pool) + .await + .map(|result| result.rows_affected()) +} + +/// 仅当前且尚未过期的 lease 持有者可以续租。 +pub async fn renew_lease( + pool: &PgPool, + id: i64, + worker_id: &str, + lease_seconds: i64, +) -> Result { + sqlx::query( + "UPDATE jobs + SET lease_until = now() + make_interval(secs => $3), updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now()", + ) + .bind(id) + .bind(worker_id) + .bind(lease_seconds.max(1) as f64) + .execute(pool) + .await + .map(|result| result.rows_affected() == 1) +} + +/// 更新运行中且 lease 有效的任务进度。 +pub async fn update_progress( + pool: &PgPool, + id: i64, + worker_id: &str, + current: i64, + total: Option, + stage: &str, +) -> Result, sqlx::Error> { + sqlx::query_as( + "UPDATE jobs + SET progress_current = $3, + progress_total = COALESCE($4, progress_total), + stage = $5, + updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .bind(current) + .bind(total) + .bind(stage) + .fetch_optional(pool) + .await +} + +/// 标记当前 worker 的任务成功;repository 只接受 allowlisted result 类型。 +pub async fn complete( + pool: &PgPool, + id: i64, + worker_id: &str, + result: JobResult, +) -> Result, sqlx::Error> { + sqlx::query_as( + "UPDATE jobs + SET state = 'succeeded', result = $3, worker_id = NULL, lease_until = NULL, + progress_current = COALESCE(progress_total, progress_current), + finished_at = now(), updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .bind(result.into_json()) + .fetch_optional(pool) + .await +} + +/// 记录执行错误;可重试且未耗尽时回到 queued,否则进入 failed。 +pub async fn fail_attempt( + pool: &PgPool, + id: i64, + worker_id: &str, + error_code: &str, + error_message: &str, + retryable: bool, + retry_after_seconds: i64, +) -> Result, sqlx::Error> { + sqlx::query_as( + "UPDATE jobs + SET state = CASE + WHEN $5 AND attempts < max_attempts THEN 'queued' + ELSE 'failed' + END, + run_after = CASE + WHEN $5 AND attempts < max_attempts + THEN now() + make_interval(secs => $6) + ELSE run_after + END, + last_error_code = $3, last_error_message = $4, + worker_id = NULL, lease_until = NULL, + finished_at = CASE + WHEN $5 AND attempts < max_attempts THEN NULL + ELSE now() + END, + updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .bind(error_code) + .bind(error_message) + .bind(retryable) + .bind(retry_after_seconds.max(0) as f64) + .fetch_optional(pool) + .await +} + +/// 在现有事务中手动重试失败任务;保留单调 attempts 并增加一次执行预算。 +pub async fn manual_retry_tx(conn: &mut PgConnection, id: i64) -> Result, sqlx::Error> { + sqlx::query_as( + "UPDATE jobs + SET state = 'queued', pause_reason = NULL, attempts = attempts + 1, + max_attempts = GREATEST(max_attempts, attempts + 2), run_after = now(), + lease_until = NULL, worker_id = NULL, + last_error_code = NULL, last_error_message = NULL, + finished_at = NULL, updated_at = now() + WHERE id = $1 AND state = 'failed' AND project_id IS NOT NULL + RETURNING *", + ) + .bind(id) + .fetch_optional(conn) + .await +} diff --git a/backend/crates/prts-db/src/lib.rs b/backend/crates/prts-db/src/lib.rs index feea89a..158edad 100644 --- a/backend/crates/prts-db/src/lib.rs +++ b/backend/crates/prts-db/src/lib.rs @@ -4,8 +4,11 @@ //! 以及账号 / API Key / 设置等数据访问([`models`] + 各仓储模块)。 pub mod api_keys; +pub mod audit; +pub mod auth_sessions; pub mod entries; pub mod files; +pub mod jobs; pub mod memberships; pub mod messages; pub mod models; @@ -21,7 +24,7 @@ pub use sqlx::Error as DbError; use redis::aio::ConnectionManager; use sqlx::postgres::PgPoolOptions; -use sqlx::PgPool; +use sqlx::{FromRow, PgConnection, PgPool}; /// PostgreSQL 连接池别名。 pub type Db = PgPool; @@ -42,9 +45,95 @@ pub async fn connect_redis(url: &str) -> Result Result<(), sqlx::migrate::MigrateError> { - sqlx::migrate!("../../migrations").run(pool).await +/// 在独立 owner 连接上运行嵌入式迁移,并把 runtime role 安全传给迁移。 +/// +/// `set_config` 与 migrator 必须使用同一连接;迁移 SQL 只通过 +/// `current_setting('prts.runtime_role')` + `format('%I', ...)` 引用标识符。 +pub async fn run_migrations( + conn: &mut PgConnection, + runtime_role: &str, +) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::query("SELECT set_config('prts.runtime_role', $1, false)") + .bind(runtime_role) + .execute(&mut *conn) + .await + .map_err(sqlx::migrate::MigrateError::Execute)?; + sqlx::migrate!("../../migrations").run(&mut *conn).await +} + +/// 验证应用池确实使用无 owner 权限的预期 runtime role。 +/// +/// 缺表、角色不匹配、superuser、持有 public 对象,或 audit ACL 过宽都会 fail closed。 +#[derive(Debug, Clone, FromRow)] +struct RuntimeRoleChecks { + current_role: String, + matches_expected: bool, + not_superuser: bool, + not_createdb: bool, + not_createrole: bool, + not_replication: bool, + not_bypassrls: bool, + owns_no_public_objects: bool, + cannot_create_in_public: bool, + audit_select: bool, + audit_insert: bool, + audit_no_update: bool, + audit_no_delete: bool, +} + +impl RuntimeRoleChecks { + fn is_safe(&self) -> bool { + self.matches_expected + && self.not_superuser + && self.not_createdb + && self.not_createrole + && self.not_replication + && self.not_bypassrls + && self.owns_no_public_objects + && self.cannot_create_in_public + && self.audit_select + && self.audit_insert + && self.audit_no_update + && self.audit_no_delete + } +} + +pub async fn verify_runtime_role(pool: &PgPool, expected_role: &str) -> Result<(), sqlx::Error> { + let checks: RuntimeRoleChecks = sqlx::query_as( + "SELECT current_user::TEXT AS current_role, + current_user = $1 AS matches_expected, + NOT role.rolsuper AS not_superuser, + NOT role.rolcreatedb AS not_createdb, + NOT role.rolcreaterole AS not_createrole, + NOT role.rolreplication AS not_replication, + NOT role.rolbypassrls AS not_bypassrls, + NOT EXISTS ( + SELECT 1 + FROM pg_class AS class + JOIN pg_namespace AS namespace ON namespace.oid = class.relnamespace + WHERE namespace.nspname = 'public' + AND class.relowner = role.oid + ) AS owns_no_public_objects, + NOT has_schema_privilege(current_user, 'public', 'CREATE') + AS cannot_create_in_public, + has_table_privilege(current_user, 'audit_log', 'SELECT') AS audit_select, + has_table_privilege(current_user, 'audit_log', 'INSERT') AS audit_insert, + NOT has_table_privilege(current_user, 'audit_log', 'UPDATE') AS audit_no_update, + NOT has_table_privilege(current_user, 'audit_log', 'DELETE') AS audit_no_delete + FROM pg_roles AS role + WHERE role.rolname = current_user", + ) + .bind(expected_role) + .fetch_one(pool) + .await?; + if checks.is_safe() { + Ok(()) + } else { + Err(sqlx::Error::Protocol(format!( + "database role separation check failed for runtime role {}", + checks.current_role + ))) + } } /// 探测 PostgreSQL 可用性(就绪检查)。 @@ -58,3 +147,53 @@ pub async fn ping_redis(cache: &ConnectionManager) -> Result<(), redis::RedisErr let _: String = redis::cmd("PING").query_async(&mut conn).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runtime_role_checks_fail_closed_for_every_privileged_attribute() { + let safe = RuntimeRoleChecks { + current_role: "prts_runtime".to_string(), + matches_expected: true, + not_superuser: true, + not_createdb: true, + not_createrole: true, + not_replication: true, + not_bypassrls: true, + owns_no_public_objects: true, + cannot_create_in_public: true, + audit_select: true, + audit_insert: true, + audit_no_update: true, + audit_no_delete: true, + }; + assert!(safe.is_safe()); + + for unsafe_checks in [ + RuntimeRoleChecks { + not_createdb: false, + ..safe.clone() + }, + RuntimeRoleChecks { + not_createrole: false, + ..safe.clone() + }, + RuntimeRoleChecks { + not_replication: false, + ..safe.clone() + }, + RuntimeRoleChecks { + not_bypassrls: false, + ..safe.clone() + }, + RuntimeRoleChecks { + cannot_create_in_public: false, + ..safe.clone() + }, + ] { + assert!(!unsafe_checks.is_safe()); + } + } +} diff --git a/backend/crates/prts-db/src/models.rs b/backend/crates/prts-db/src/models.rs index 8549f06..8916ec9 100644 --- a/backend/crates/prts-db/src/models.rs +++ b/backend/crates/prts-db/src/models.rs @@ -194,3 +194,44 @@ pub struct ConversationThread { /// 我方在该会话中的未读消息数。 pub unread: i64, } + +/// 追加式审计行。IP 在查询时规范化为文本,避免向上层暴露数据库专用网络类型。 +#[derive(Debug, Clone, FromRow)] +pub struct AuditLog { + pub id: i64, + pub actor_id: Option, + pub actor_kind: String, + pub action: String, + pub target_type: String, + pub target_id: String, + pub project_id_snapshot: Option, + pub payload: serde_json::Value, + pub ip: Option, + pub created_at: DateTime, +} + +/// 持久化任务行。payload/result 只供受控 worker 使用,不直接序列化到 API。 +#[derive(Debug, Clone, FromRow)] +pub struct Job { + pub id: i64, + pub kind: String, + pub project_id: Option, + pub state: String, + pub pause_reason: Option, + pub stage: String, + pub payload: serde_json::Value, + pub result: Option, + pub progress_current: i64, + pub progress_total: Option, + pub attempts: i32, + pub max_attempts: i32, + pub run_after: DateTime, + pub lease_until: Option>, + pub worker_id: Option, + pub last_error_code: Option, + pub last_error_message: Option, + pub created_at: DateTime, + pub started_at: Option>, + pub finished_at: Option>, + pub updated_at: DateTime, +} diff --git a/backend/migrations/0007_audit_jobs.sql b/backend/migrations/0007_audit_jobs.sql new file mode 100644 index 0000000..0149494 --- /dev/null +++ b/backend/migrations/0007_audit_jobs.sql @@ -0,0 +1,335 @@ +-- Foundation:追加式审计、可恢复任务,以及 PostgreSQL 权威认证会话/outbox。 +-- 本迁移发布后冻结;后续修正必须使用新的迁移号。 + +-- 递归检查 JSONB 对象键,阻止 token、密码和其它秘密进入审计/任务载荷。 +-- 哈希只允许进入专用列(例如 auth_sessions.refresh_token_hash),不允许混入通用 payload。 +CREATE OR REPLACE FUNCTION prts_jsonb_contains_secret_key(document JSONB) +RETURNS BOOLEAN +LANGUAGE plpgsql +IMMUTABLE +STRICT +AS $$ +DECLARE + item RECORD; + child JSONB; + normalized_key TEXT; +BEGIN + CASE jsonb_typeof(document) + WHEN 'object' THEN + FOR item IN SELECT key, value FROM jsonb_each(document) + LOOP + -- 先移除 snake/kebab/空格等分隔并统一小写,camelCase 也归一为同一文本。 + normalized_key := regexp_replace(lower(item.key), '[^a-z0-9]', '', 'g'); + IF normalized_key = ANY (ARRAY[ + 'token', 'accesstoken', 'refreshtoken', 'rawaccesstoken', 'rawrefreshtoken', + 'authorization', 'password', 'passwordhash', 'secret', 'clientsecret', + 'apikey', 'apikeyhash', 'code', 'oauthcode', 'verifier', 'codeverifier', + 'challengeanswer' + ]) THEN + RETURN TRUE; + END IF; + IF prts_jsonb_contains_secret_key(item.value) THEN + RETURN TRUE; + END IF; + END LOOP; + WHEN 'array' THEN + FOR child IN SELECT value FROM jsonb_array_elements(document) + LOOP + IF prts_jsonb_contains_secret_key(child) THEN + RETURN TRUE; + END IF; + END LOOP; + ELSE + NULL; + END CASE; + RETURN FALSE; +END; +$$; + +-- 安全审计元数据。actor/project 字段是历史 snapshot,故不声明可级联外键。 +CREATE TABLE audit_log ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + actor_id BIGINT, + actor_kind TEXT NOT NULL, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + project_id_snapshot BIGINT, + payload JSONB NOT NULL DEFAULT '{}', + ip INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT audit_log_actor_kind_chk + CHECK (actor_kind IN ('user', 'api_key', 'system', 'anonymous')), + CONSTRAINT audit_log_payload_object_chk CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT audit_log_payload_secret_chk CHECK (NOT prts_jsonb_contains_secret_key(payload)) +); +CREATE INDEX audit_log_actor_created_idx ON audit_log (actor_id, created_at DESC); +CREATE INDEX audit_log_project_created_idx + ON audit_log (project_id_snapshot, created_at DESC) + WHERE project_id_snapshot IS NOT NULL; +CREATE INDEX audit_log_target_idx ON audit_log (target_type, target_id, created_at DESC); + +-- 即使应用角色被误授 UPDATE/DELETE,数据库也必须 fail closed。 +CREATE OR REPLACE FUNCTION reject_audit_log_mutation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'audit_log is append-only' USING ERRCODE = '55000'; +END; +$$; + +CREATE TRIGGER audit_log_reject_update_delete + BEFORE UPDATE OR DELETE ON audit_log + FOR EACH ROW EXECUTE FUNCTION reject_audit_log_mutation(); + +CREATE TRIGGER audit_log_reject_truncate + BEFORE TRUNCATE ON audit_log + FOR EACH STATEMENT EXECUTE FUNCTION reject_audit_log_mutation(); + +REVOKE UPDATE, DELETE, TRUNCATE ON audit_log FROM PUBLIC; + +-- 持久化任务。project_id 在项目永久删除后置空,payload 保存清理所需 snapshot。 +CREATE TABLE jobs ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + kind TEXT NOT NULL, + project_id BIGINT REFERENCES projects (id) ON DELETE SET NULL, + state TEXT NOT NULL DEFAULT 'queued', + pause_reason TEXT, + stage TEXT NOT NULL DEFAULT 'queued', + payload JSONB NOT NULL DEFAULT '{}', + result JSONB, + progress_current BIGINT NOT NULL DEFAULT 0, + progress_total BIGINT, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 5, + run_after TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + worker_id TEXT, + last_error_code TEXT, + last_error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT jobs_state_chk + CHECK (state IN ('queued', 'running', 'paused', 'succeeded', 'failed', 'cancelled')), + CONSTRAINT jobs_pause_reason_chk CHECK ( + (state = 'paused' AND pause_reason IN ('project_pending_deletion', 'manual', 'other')) + OR (state <> 'paused' AND pause_reason IS NULL) + ), + CONSTRAINT jobs_payload_object_chk CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT jobs_payload_secret_chk CHECK (NOT prts_jsonb_contains_secret_key(payload)), + CONSTRAINT jobs_result_object_chk CHECK (result IS NULL OR jsonb_typeof(result) = 'object'), + CONSTRAINT jobs_result_secret_chk + CHECK (result IS NULL OR NOT prts_jsonb_contains_secret_key(result)), + CONSTRAINT jobs_progress_chk CHECK ( + progress_current >= 0 + AND (progress_total IS NULL OR progress_total >= 0) + AND (progress_total IS NULL OR progress_current <= progress_total) + ), + CONSTRAINT jobs_attempts_chk CHECK (attempts >= 0 AND max_attempts >= 1) +); +CREATE INDEX jobs_claim_idx ON jobs (run_after, id) + WHERE state = 'queued'; +CREATE INDEX jobs_expired_lease_idx ON jobs (lease_until, id) + WHERE state = 'running'; +CREATE INDEX jobs_project_idx ON jobs (project_id, id DESC) + WHERE project_id IS NOT NULL; +CREATE TRIGGER jobs_set_updated_at + BEFORE UPDATE ON jobs FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- PostgreSQL 权威会话状态;Redis 只能缓存 active lookup 或承载不可认证 pending material。 +CREATE TABLE auth_sessions ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_handle TEXT NOT NULL UNIQUE, + family_handle TEXT NOT NULL, + user_id BIGINT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + refresh_token_hash TEXT NOT NULL UNIQUE, + state TEXT NOT NULL DEFAULT 'pending', + expires_at TIMESTAMPTZ NOT NULL, + predecessor_id BIGINT UNIQUE REFERENCES auth_sessions (id) ON DELETE SET NULL, + successor_id BIGINT UNIQUE REFERENCES auth_sessions (id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT auth_sessions_state_chk + CHECK (state IN ('pending', 'active', 'rotating', 'revoked', 'expired')), + CONSTRAINT auth_sessions_handles_chk + CHECK (length(session_handle) >= 16 AND length(family_handle) >= 16), + CONSTRAINT auth_sessions_refresh_hash_chk + CHECK (refresh_token_hash ~ '^[0-9a-f]{64}$'), + CONSTRAINT auth_sessions_not_self_linked_chk + CHECK (predecessor_id IS DISTINCT FROM id AND successor_id IS DISTINCT FROM id) +); +CREATE INDEX auth_sessions_user_family_idx ON auth_sessions (user_id, family_handle); +CREATE UNIQUE INDEX auth_sessions_one_active_family_idx + ON auth_sessions (user_id, family_handle) + WHERE state = 'active'; +CREATE INDEX auth_sessions_expiry_idx ON auth_sessions (expires_at) + WHERE state IN ('pending', 'active', 'rotating'); + +-- 权威状态机在数据库边界同样 fail closed,不能靠绕过仓储复活 revoked/expired 会话。 +CREATE OR REPLACE FUNCTION enforce_auth_session_transition() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + -- 带 predecessor 的 pending successor 不得通过清空链接伪装成普通签发。 + IF OLD.state = 'pending' + AND OLD.predecessor_id IS NOT NULL + AND NEW.predecessor_id IS DISTINCT FROM OLD.predecessor_id THEN + RAISE EXCEPTION 'pending auth session predecessor linkage is immutable' + USING ERRCODE = '23514'; + END IF; + -- complete_rotation 设置反向链接时,successor 必须已精确指回当前 predecessor。 + IF NEW.successor_id IS DISTINCT FROM OLD.successor_id + AND NEW.successor_id IS NOT NULL + AND NOT ( + OLD.state = 'rotating' + AND NEW.state = 'revoked' + AND EXISTS ( + SELECT 1 + FROM auth_sessions AS successor + WHERE successor.id = NEW.successor_id + AND successor.predecessor_id = OLD.id + AND successor.user_id = OLD.user_id + AND successor.family_handle = OLD.family_handle + AND successor.state = 'pending' + AND successor.expires_at > now() + ) + ) THEN + RAISE EXCEPTION 'invalid auth session successor linkage' USING ERRCODE = '23514'; + END IF; + IF NEW.state = OLD.state THEN + RETURN NEW; + END IF; + IF OLD.state = 'pending' AND NEW.state = 'active' THEN + IF NEW.expires_at <= now() THEN + RAISE EXCEPTION 'expired auth session cannot become active' USING ERRCODE = '23514'; + END IF; + IF NEW.predecessor_id IS NULL THEN + RETURN NEW; + END IF; + -- Rotation 必须先撤销精确 predecessor 并建立反向链接,再激活 successor。 + IF EXISTS ( + SELECT 1 + FROM auth_sessions AS predecessor + WHERE predecessor.id = NEW.predecessor_id + AND predecessor.successor_id = NEW.id + AND predecessor.user_id = NEW.user_id + AND predecessor.family_handle = NEW.family_handle + AND predecessor.state = 'revoked' + AND predecessor.expires_at > now() + ) THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'linked pending auth session requires completed rotation' + USING ERRCODE = '23514'; + END IF; + IF (OLD.state = 'pending' AND NEW.state IN ('revoked', 'expired')) + OR (OLD.state = 'active' AND NEW.state IN ('rotating', 'revoked', 'expired')) + OR (OLD.state = 'rotating' AND NEW.state IN ('revoked', 'expired')) THEN + RETURN NEW; + END IF; + RAISE EXCEPTION 'invalid auth session transition: % -> %', OLD.state, NEW.state + USING ERRCODE = '23514'; +END; +$$; + +CREATE TRIGGER auth_sessions_enforce_transition + BEFORE UPDATE OF state ON auth_sessions + FOR EACH ROW EXECUTE FUNCTION enforce_auth_session_transition(); +CREATE TRIGGER auth_sessions_set_updated_at + BEFORE UPDATE ON auth_sessions FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Durable auth intent/outbox。只携带 opaque handle;任何 raw token 均由 JSON 约束拒绝。 +CREATE TABLE auth_session_intents ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + session_id BIGINT NOT NULL REFERENCES auth_sessions (id) ON DELETE CASCADE, + kind TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'queued', + payload JSONB NOT NULL DEFAULT '{}', + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 5, + run_after TIMESTAMPTZ NOT NULL DEFAULT now(), + lease_until TIMESTAMPTZ, + worker_id TEXT, + last_error_code TEXT, + last_error_message TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT auth_session_intents_state_chk + CHECK (state IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')), + CONSTRAINT auth_session_intents_payload_object_chk CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT auth_session_intents_payload_secret_chk + CHECK (NOT prts_jsonb_contains_secret_key(payload)), + CONSTRAINT auth_session_intents_attempts_chk CHECK (attempts >= 0 AND max_attempts >= 1) +); +CREATE INDEX auth_session_intents_claim_idx ON auth_session_intents (run_after, id) + WHERE state = 'queued'; +CREATE INDEX auth_session_intents_expired_lease_idx + ON auth_session_intents (lease_until, id) + WHERE state = 'running'; +CREATE INDEX auth_session_intents_session_idx ON auth_session_intents (session_id, id); +CREATE TRIGGER auth_session_intents_set_updated_at + BEFORE UPDATE ON auth_session_intents FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Migration owner 与 runtime role 是真实安全边界。runtime role 名由 migrator 在同一连接 +-- 通过 set_config 传入;动态标识符只能经 format('%I') 引用,禁止拼接未转义 SQL。 +DO $$ +DECLARE + runtime_role TEXT := current_setting('prts.runtime_role', true); + migration_role TEXT := current_user; +BEGIN + IF runtime_role IS NULL OR btrim(runtime_role) = '' THEN + RAISE EXCEPTION 'prts.runtime_role must be set by the migration runner'; + END IF; + IF runtime_role = migration_role THEN + RAISE EXCEPTION 'migration owner and runtime role must be distinct'; + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = runtime_role) THEN + RAISE EXCEPTION 'runtime role % does not exist', runtime_role; + END IF; + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = runtime_role AND rolsuper) THEN + RAISE EXCEPTION 'runtime role % must not be superuser', runtime_role; + END IF; + + REVOKE CREATE ON SCHEMA public FROM PUBLIC; + EXECUTE format('REVOKE CREATE ON SCHEMA public FROM %I', runtime_role); + EXECUTE format('GRANT USAGE ON SCHEMA public TO %I', runtime_role); + + -- 既有业务仓储需要 CRUD,但不授予 TRUNCATE/REFERENCES/TRIGGER/DDL。 + EXECUTE format( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO %I', + runtime_role + ); + EXECUTE format( + 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', + runtime_role + ); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public ' + 'GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO %I', + migration_role, + runtime_role + ); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public ' + 'GRANT USAGE, SELECT ON SEQUENCES TO %I', + migration_role, + runtime_role + ); + + IF to_regclass('public._sqlx_migrations') IS NOT NULL THEN + EXECUTE format( + 'REVOKE ALL PRIVILEGES ON TABLE public._sqlx_migrations FROM %I', + runtime_role + ); + END IF; + + -- audit_log 是特例:owner/trigger 仍属于 migrator,runtime 只能追加和读取。 + EXECUTE format('REVOKE ALL PRIVILEGES ON TABLE audit_log FROM %I', runtime_role); + EXECUTE format('GRANT SELECT, INSERT ON TABLE audit_log TO %I', runtime_role); +END; +$$; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index b5e7ed3..24d261e 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -13,13 +13,19 @@ services: # 如需从源码构建:docker build -f deploy/postgres.Dockerfile -t ghcr.io/localizelimbuscompany/prts-postgres:latest .. image: ghcr.io/localizelimbuscompany/prts-postgres:latest environment: - POSTGRES_USER: ${POSTGRES_USER:-prts} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-prts} + POSTGRES_USER: ${POSTGRES_MIGRATION_USER:-prts_migrator} + POSTGRES_PASSWORD: ${POSTGRES_MIGRATION_PASSWORD:-dev-migrator-change-me} POSTGRES_DB: ${POSTGRES_DB:-prts} + POSTGRES_RUNTIME_USER: ${POSTGRES_RUNTIME_USER:-prts_runtime} + POSTGRES_RUNTIME_PASSWORD: ${POSTGRES_RUNTIME_PASSWORD:-dev-runtime-change-me} volumes: - pgdata:/var/lib/postgresql/data healthcheck: - test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-prts} -d ${POSTGRES_DB:-prts}'] + test: + [ + 'CMD-SHELL', + 'pg_isready -U ${POSTGRES_MIGRATION_USER:-prts_migrator} -d ${POSTGRES_DB:-prts}', + ] interval: 10s timeout: 5s retries: 5 @@ -37,27 +43,47 @@ services: retries: 5 restart: unless-stopped + migrate: + build: + context: .. + dockerfile: deploy/backend.Dockerfile + image: ghcr.io/localizelimbuscompany/prts-backend:latest + env_file: + - path: ../.env + required: false + environment: + PRTS__DATABASE__MIGRATION_URL: postgres://${POSTGRES_MIGRATION_USER:-prts_migrator}:${POSTGRES_MIGRATION_PASSWORD:-dev-migrator-change-me}@postgres:5432/${POSTGRES_DB:-prts} + PRTS__DATABASE__RUNTIME_ROLE: ${POSTGRES_RUNTIME_USER:-prts_runtime} + command: ['prts-api', 'migrate'] + depends_on: + postgres: + condition: service_healthy + restart: 'no' + backend: build: context: .. dockerfile: deploy/backend.Dockerfile image: ghcr.io/localizelimbuscompany/prts-backend:latest - # 从根目录 .env 注入认证等 PRTS__* 配置(缺失则忽略)。 + # 从根目录 .env 注入认证等 PRTS__* 配置;migration URL 被显式清空,runtime + # 进程不会持有 owner 凭据。 env_file: - path: ../.env required: false environment: PRTS__SERVER__HOST: 0.0.0.0 PRTS__SERVER__PORT: '3000' - PRTS__DATABASE__URL: postgres://${POSTGRES_USER:-prts}:${POSTGRES_PASSWORD:-prts}@postgres:5432/${POSTGRES_DB:-prts} + PRTS__DATABASE__URL: postgres://${POSTGRES_RUNTIME_USER:-prts_runtime}:${POSTGRES_RUNTIME_PASSWORD:-dev-runtime-change-me}@postgres:5432/${POSTGRES_DB:-prts} + PRTS__DATABASE__MIGRATION_URL: '' + PRTS__DATABASE__RUNTIME_ROLE: ${POSTGRES_RUNTIME_USER:-prts_runtime} PRTS__REDIS__URL: redis://redis:6379 RUST_LOG: ${RUST_LOG:-info,sqlx=warn} ports: # 宿主机端口可经 .env 的 BACKEND_PORT 调整(默认 3000 被占用时改这里) - '${BACKEND_PORT:-3000}:3000' # 暴露以便访问 Swagger UI(/swagger-ui);生产可仅内网 depends_on: - postgres: - condition: service_healthy + migrate: + condition: service_completed_successfully redis: condition: service_healthy restart: unless-stopped diff --git a/deploy/postgres-init/10-runtime-role.sh b/deploy/postgres-init/10-runtime-role.sh new file mode 100644 index 0000000..7051d8c --- /dev/null +++ b/deploy/postgres-init/10-runtime-role.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${POSTGRES_RUNTIME_USER:?POSTGRES_RUNTIME_USER must be set}" +: "${POSTGRES_RUNTIME_PASSWORD:?POSTGRES_RUNTIME_PASSWORD must be set}" + +if [[ "${POSTGRES_RUNTIME_USER}" == "${POSTGRES_USER}" ]]; then + echo "runtime role must differ from migration owner" >&2 + exit 1 +fi + +# psql variables are rendered with format(%I/%L); neither role nor password is concatenated as SQL. +psql --set=ON_ERROR_STOP=1 \ + --username "${POSTGRES_USER}" \ + --dbname "${POSTGRES_DB}" \ + --set=runtime_user="${POSTGRES_RUNTIME_USER}" \ + --set=runtime_password="${POSTGRES_RUNTIME_PASSWORD}" <<'SQL' +SELECT format( + 'CREATE ROLE %I LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION PASSWORD %L', + :'runtime_user', + :'runtime_password' +) +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'runtime_user') +\gexec +SQL diff --git a/deploy/postgres.Dockerfile b/deploy/postgres.Dockerfile index f17b1f4..306f312 100644 --- a/deploy/postgres.Dockerfile +++ b/deploy/postgres.Dockerfile @@ -5,6 +5,10 @@ # GitHub 源码不含预生成的 configure,故需 autotools 引导。 FROM pgvector/pgvector:pg16 +# 首次初始化时创建与 migration owner 分离的 runtime login role;密码仅来自环境变量。 +COPY deploy/postgres-init/10-runtime-role.sh /docker-entrypoint-initdb.d/10-runtime-role.sh +RUN chmod 0755 /docker-entrypoint-initdb.d/10-runtime-role.sh + RUN set -eux; \ apt-get update; \ apt-get install -y --no-install-recommends \ From 82ebec8c58c9b798240427407c06b325b7e9e4e8 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Sat, 11 Jul 2026 14:26:52 +0800 Subject: [PATCH 12/45] test: define fail-closed audit contracts --- .../crates/prts-api/tests/db_integration.rs | 1944 ++++++++++++++++- 1 file changed, 1918 insertions(+), 26 deletions(-) diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index 1346777..418bbe1 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -4,10 +4,67 @@ //! `MIGRATION_DATABASE_URL`。迁移和所有合同 DML 使用不同数据库角色。 //! 本地无 DB 时默认不编译;CI 会起 Postgres 服务后执行(见 .github/workflows/ci.yml)。 #![cfg(feature = "db-tests")] +// binary-only production modules are imported selectively below; unused endpoints remain intentional. +#![allow(dead_code)] + +// 直接复用二进制 crate 的真实 handler/composition 模块,使集成测试能够在不复制 +// route/repository 实现的前提下验证 HTTP 边界。PRTS 当前只有 binary target;这些 +// `path` 模块在 Task 1.2 完成前提供测试入口,后续若抽出 library target 可直接删去。 +#[path = "../src/appsettings.rs"] +mod appsettings; +#[path = "../src/auth/mod.rs"] +mod auth; +#[path = "../src/dto.rs"] +mod dto; +#[path = "../src/error.rs"] +mod error; +#[path = "../src/job_worker.rs"] +mod job_worker; +#[path = "../src/jobs/mod.rs"] +mod jobs; +#[path = "../src/state.rs"] +mod state; + +#[path = "../src/routes/admin.rs"] +mod admin_routes; +#[path = "../src/routes/admin_settings.rs"] +mod admin_settings_routes; +#[path = "../src/routes/auth.rs"] +mod auth_routes; +#[path = "../src/routes/entries.rs"] +mod entries_routes; +#[path = "../src/routes/files.rs"] +mod files_routes; +#[path = "../src/routes/messages.rs"] +mod messages_routes; +#[path = "../src/routes/notifications.rs"] +mod notifications_routes; +#[path = "../src/routes/projects.rs"] +mod projects_routes; +#[path = "../src/routes/users.rs"] +mod users_routes; + +/// 将数据库错误映射为真实 handler 使用的统一 API 错误。 +fn db_err(error: prts_db::DbError) -> error::ApiError { + prts_common::Error::internal(format!("db error: {error}")).into() +} + +/// `entries` handler 的兄弟模块依赖;保持与生产 route 组合函数相同的解析语义。 +fn parse_states(value: Option<&str>) -> Vec { + value + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|state| prts_core::EntryState::parse(state).is_some()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} use prts_db::{ - api_keys, auth_sessions, entries, files, jobs, memberships, messages, notifications, projects, - settings, users, + api_keys, auth_sessions, entries, files, jobs as db_jobs, memberships, messages, notifications, + projects, settings, users, }; static MIGRATED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); @@ -28,6 +85,34 @@ async fn ensure_migrated() { prts_db::run_migrations(&mut migration_conn, &runtime_role()) .await .expect("以 migration owner 执行迁移"); + // 测试专用故障注入:trigger 只在当前连接显式设置 GUC 时拒绝 audit INSERT。 + // 因此专用 failure pool 可稳定触发 fail-closed,其他并行测试连接完全不受影响。 + sqlx::query( + "CREATE OR REPLACE FUNCTION prts_test_reject_audit_insert() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF current_setting('prts.test_fail_audit', true) = 'on' THEN + RAISE EXCEPTION 'injected audit insert failure' USING ERRCODE = '57014'; + END IF; + RETURN NEW; + END; + $$", + ) + .execute(&mut *migration_conn) + .await + .expect("安装连接隔离的审计故障函数"); + sqlx::query("DROP TRIGGER IF EXISTS prts_test_reject_audit_insert ON audit_log") + .execute(&mut *migration_conn) + .await + .expect("重置审计故障 trigger"); + sqlx::query( + "CREATE TRIGGER prts_test_reject_audit_insert + BEFORE INSERT ON audit_log FOR EACH ROW + EXECUTE FUNCTION prts_test_reject_audit_insert()", + ) + .execute(&mut *migration_conn) + .await + .expect("安装审计故障 trigger"); drop(migration_conn); migration_pool.close().await; }) @@ -46,6 +131,1811 @@ async fn pool() -> prts_db::Db { pool } +// ======================== Task 1.2 audit contracts ======================== + +/// 一个现有 route/auth 写入口及其必须提交的审计 action。 +/// +/// 此清单是 Task 1.2 的唯一测试 inventory:内部统计 writer 归属于触发它的业务 action, +/// 普通读取则列在 `UNAUDITED_READS`,不会因“覆盖所有 writer”而误加读取审计。 +#[derive(Debug, Clone, Copy)] +struct AuditedEntrypoint { + entrypoint: &'static str, + action: &'static str, + allowed_payload_keys: &'static [&'static str], +} + +const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ + AuditedEntrypoint { + entrypoint: "routes::auth::register", + action: "auth.registered", + allowed_payload_keys: &["method", "status"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::login.success", + action: "auth.login_succeeded", + allowed_payload_keys: &["method"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::login.failure", + action: "auth.login_failed", + allowed_payload_keys: &["method", "reason_code"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::refresh", + action: "auth.refresh_rotated", + allowed_payload_keys: &["session_handle", "predecessor_handle", "expires_at"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::logout", + action: "auth.logged_out", + allowed_payload_keys: &["session_handle", "revoked_sessions"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::oauth_callback.success", + action: "auth.oauth_succeeded", + allowed_payload_keys: &["provider", "new_user"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::oauth_callback.failure", + action: "auth.oauth_failed", + allowed_payload_keys: &["provider", "reason_code"], + }, + AuditedEntrypoint { + entrypoint: "auth::session::issue", + action: "auth.token_issued", + allowed_payload_keys: &["session_handle", "method", "expires_at"], + }, + AuditedEntrypoint { + entrypoint: "main/auth::maybe_bootstrap_super_admin", + action: "auth.bootstrap_role_granted", + allowed_payload_keys: &["role"], + }, + AuditedEntrypoint { + entrypoint: "routes::users::update_me", + action: "user.profile_updated", + allowed_payload_keys: &["changed_fields", "translation_lang_count"], + }, + AuditedEntrypoint { + entrypoint: "routes::users::create_api_key", + action: "api_key.created", + allowed_payload_keys: &["name", "prefix"], + }, + AuditedEntrypoint { + entrypoint: "auth::extract::api_key_touch", + action: "api_key.used", + allowed_payload_keys: &["prefix"], + }, + AuditedEntrypoint { + entrypoint: "routes::users::revoke_api_key", + action: "api_key.revoked", + allowed_payload_keys: &["prefix"], + }, + AuditedEntrypoint { + entrypoint: "routes::admin::update_settings", + action: "settings.updated", + allowed_payload_keys: &["keys", "count"], + }, + AuditedEntrypoint { + entrypoint: "routes::admin_settings::put_search_settings", + action: "search_settings.updated", + allowed_payload_keys: &["changed_fields"], + }, + AuditedEntrypoint { + entrypoint: "routes::admin::grant_role", + action: "user.platform_role_changed", + allowed_payload_keys: &["previous_role", "new_role"], + }, + AuditedEntrypoint { + entrypoint: "routes::projects::create_project", + action: "project.created", + allowed_payload_keys: &["slug", "visibility", "source_langs", "target_lang"], + }, + AuditedEntrypoint { + entrypoint: "routes::projects::update_project", + action: "project.updated", + allowed_payload_keys: &["changed_fields", "visibility"], + }, + AuditedEntrypoint { + entrypoint: "routes::projects::delete_project", + action: "project.deleted", + allowed_payload_keys: &["slug"], + }, + AuditedEntrypoint { + entrypoint: "routes::projects::add_member", + action: "membership.upserted", + allowed_payload_keys: &["member_id", "previous_role", "new_role"], + }, + AuditedEntrypoint { + entrypoint: "routes::projects::remove_member", + action: "membership.removed", + allowed_payload_keys: &["member_id", "previous_role"], + }, + AuditedEntrypoint { + entrypoint: "routes::entries::upload", + action: "entries.uploaded", + allowed_payload_keys: &["file_id", "path", "created", "updated", "unchanged"], + }, + AuditedEntrypoint { + entrypoint: "routes::files::delete_file", + action: "file.deleted", + allowed_payload_keys: &["path", "entry_count"], + }, + AuditedEntrypoint { + entrypoint: "routes::files::delete_folder", + action: "folder.deleted", + allowed_payload_keys: &["path", "file_count", "entry_count"], + }, + AuditedEntrypoint { + entrypoint: "routes::entries::update_entry", + action: "entry.updated", + allowed_payload_keys: &[ + "previous_version", + "new_version", + "previous_state", + "new_state", + ], + }, + AuditedEntrypoint { + entrypoint: "routes::entries::set_entry_flags", + action: "entry.flags_updated", + allowed_payload_keys: &["locked", "hidden"], + }, + AuditedEntrypoint { + entrypoint: "routes::entries::export_project", + action: "project.exported", + allowed_payload_keys: &["file_count", "entry_count", "include_hidden"], + }, + AuditedEntrypoint { + entrypoint: "routes::notifications::mark_read", + action: "notification.marked_read", + allowed_payload_keys: &["notification_ids", "count", "all"], + }, + AuditedEntrypoint { + entrypoint: "routes::notifications::poke", + action: "poke.sent", + allowed_payload_keys: &[ + "recipient_id", + "project_id", + "notification_id", + "text_length", + ], + }, + AuditedEntrypoint { + entrypoint: "routes::messages::send", + action: "message.sent", + allowed_payload_keys: &["recipient_id", "message_id", "content_length"], + }, + AuditedEntrypoint { + entrypoint: "routes::messages::mark_read", + action: "message.marked_read", + allowed_payload_keys: &["other_user_id", "count"], + }, +]; + +/// 现有被审计 repository writer。每项在 GREEN 后必须有 `&mut PgConnection`/`*_tx` +/// 入口;这里保留公共函数级清单,避免遗漏不直接暴露 route 的邮箱验证与内部统计写入。 +const REPOSITORY_WRITERS: &[&str] = &[ + "users::create_password_user", + "users::update_profile", + "users::set_platform_role", + "users::mark_email_verified", + "users::create_oauth_user", + "projects::create", + "projects::update", + "projects::delete", + "files::ensure_file_at_path", + "files::get_or_create_folder", + "files::delete_file", + "files::delete_folder", + "files::refresh_entry_count", + "entries::bulk_upsert", + "entries::update_translation", + "entries::set_flags", + "memberships::upsert", + "memberships::remove", + "settings::set", + "search_settings::set", + "api_keys::create", + "api_keys::touch_last_used", + "api_keys::revoke", + "notifications::create", + "notifications::mark_read", + "messages::create", + "messages::mark_read", +]; + +/// DB-authoritative session 与 durable intent/outbox 的全部现有写边界。 +const AUTH_SESSION_WRITERS: &[&str] = &[ + "session::issue", + "session::refresh", + "session::revoke", + "auth_sessions::create_pending_tx", + "auth_sessions::activate_pending_tx", + "auth_sessions::begin_rotation_tx", + "auth_sessions::revoke_unexpired_tx", + "auth_sessions::expire_due_tx", + "auth_sessions::complete_rotation_tx", + "auth_sessions::enqueue_intent_tx", + "auth_sessions::claim_intent", + "auth_sessions::renew_intent_lease", + "auth_sessions::complete_intent", + "auth_sessions::fail_intent", + "auth_sessions::retry_intent_tx", +]; + +/// 普通读取必须保持零审计;敏感项目导出不在此列。 +const UNAUDITED_READS: &[&str] = &[ + "users::me", + "users::get_user", + "users::my_accounts", + "users::list_api_keys", + "admin::get_settings", + "admin_settings::get_search_settings", + "projects::list_projects", + "projects::get_project", + "projects::list_members", + "files::get_tree", + "entries::list_entries", + "entries::get_entry", + "entries::entry_history", + "notifications::list", + "notifications::unread_count", + "messages::list_threads", + "messages::conversation", + "messages::unread_count", +]; + +#[test] +fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { + use std::collections::HashSet; + + assert_eq!( + REPOSITORY_WRITERS.len(), + 27, + "repository writer inventory 发生漂移" + ); + assert_eq!( + AUTH_SESSION_WRITERS.len(), + 15, + "auth/session writer inventory 发生漂移" + ); + assert_eq!(UNAUDITED_READS.len(), 18, "普通读取 inventory 发生漂移"); + assert_eq!(AUDITED_ENTRYPOINTS.len(), 31, "审计入口 inventory 发生漂移"); + + let writers: HashSet<_> = REPOSITORY_WRITERS.iter().copied().collect(); + assert_eq!(writers.len(), REPOSITORY_WRITERS.len(), "writer 不得重复"); + let entrypoints: HashSet<_> = AUDITED_ENTRYPOINTS + .iter() + .map(|entry| entry.entrypoint) + .collect(); + assert_eq!(entrypoints.len(), AUDITED_ENTRYPOINTS.len()); + let auth_writers: HashSet<_> = AUTH_SESSION_WRITERS.iter().copied().collect(); + assert_eq!(auth_writers.len(), AUTH_SESSION_WRITERS.len()); + + for entry in AUDITED_ENTRYPOINTS { + assert!( + !entry.allowed_payload_keys.is_empty(), + "{} 缺 typed allowlist", + entry.action + ); + for key in entry.allowed_payload_keys { + assert!( + !audit_contract_key_is_sensitive(key), + "{} allowlist 含敏感键 {key}", + entry.action + ); + } + } +} + +#[test] +fn audit_contract_unavailable_error_is_stable_and_bilingual() { + use prts_common::i18n::{localize, Locale}; + + assert_eq!( + localize("AUDIT_UNAVAILABLE", Locale::ZhCn), + "审计服务暂不可用" + ); + assert_eq!( + localize("AUDIT_UNAVAILABLE", Locale::En), + "Audit service unavailable" + ); +} + +/// 规范化 payload key 后检查全部秘密与正文禁区。 +fn audit_contract_key_is_sensitive(key: &str) -> bool { + let normalized: String = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + [ + "token", + "accesstoken", + "refreshtoken", + "authorization", + "password", + "passwordhash", + "hash", + "secret", + "clientsecret", + "apikey", + "apikeyhash", + "code", + "oauthcode", + "verifier", + "codeverifier", + "challengeanswer", + "raw", + "rawbody", + "body", + "original", + "translation", + "sourcetext", + "content", + "context", + "text", + ] + .iter() + .any(|forbidden| normalized == *forbidden) +} + +/// 真实 handler 测试共用的应用状态;DB/Redis 都使用 CI 提供的真实服务。 +async fn audit_contract_state() -> state::AppState { + ensure_migrated().await; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); + let db = prts_db::connect_postgres(&database_url, 8) + .await + .expect("连接 runtime PostgreSQL"); + audit_contract_state_with_db(db).await +} + +/// 每条新连接都启用 test-only GUC;只有该 pool 的 audit INSERT 会被 fixture trigger 拒绝。 +async fn audit_contract_failing_audit_db() -> prts_db::Db { + ensure_migrated().await; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(8) + .after_connect(|connection, _metadata| { + Box::pin(async move { + sqlx::query("SELECT set_config('prts.test_fail_audit', 'on', false)") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("连接审计故障专用 runtime pool"); + prts_db::verify_runtime_role(&db, &runtime_role()) + .await + .expect("故障 pool 仍必须使用 runtime role"); + db +} + +/// 创建隔离 Redis ACL 用户,仅拒绝指定命令;用于真实 commit/Redis 边界故障。 +async fn audit_contract_restricted_redis( + admin_cache: &prts_db::Cache, + denied_command: &str, +) -> (prts_db::Cache, String) { + let suffix = audit_jobs_unique("acl") + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::(); + let username = format!("audit{suffix}"); + let password = format!("Pass{suffix}"); + let mut admin = admin_cache.clone(); + let _: () = redis::cmd("ACL") + .arg("SETUSER") + .arg(&username) + .arg("reset") + .arg("on") + .arg(format!(">{password}")) + .arg("~*") + .arg("+@all") + .arg(format!("-{denied_command}")) + .query_async(&mut admin) + .await + .expect("创建隔离 Redis ACL 用户"); + let base = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); + let authority = base + .strip_prefix("redis://") + .expect("CI Redis URL 使用 redis://"); + let url = format!("redis://{username}:{password}@{authority}"); + let cache = prts_db::connect_redis(&url) + .await + .expect("连接受限 Redis ACL 用户"); + (cache, username) +} + +async fn audit_contract_delete_redis_acl_user(admin_cache: &prts_db::Cache, username: &str) { + let mut admin = admin_cache.clone(); + let _: i64 = redis::cmd("ACL") + .arg("DELUSER") + .arg(username) + .query_async(&mut admin) + .await + .expect("清理隔离 Redis ACL 用户"); +} + +async fn audit_contract_state_with_db(db: prts_db::Db) -> state::AppState { + use std::sync::Arc; + + let redis_url = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); + let cache = prts_db::connect_redis(&redis_url) + .await + .expect("连接真实 Redis"); + let realtime = prts_realtime::Hub::new(&redis_url) + .await + .expect("构造真实 realtime hub"); + let settings = Arc::new( + prts_common::config::Settings::load_from("__audit_contract_missing_config__") + .expect("加载测试配置"), + ); + let job_worker = job_worker::spawn( + db.clone(), + jobs::JobRegistry::new(Vec::new()), + Arc::new(job_worker::NoPendingDeletions), + ); + state::AppState { + db, + cache, + settings, + zoot: Arc::new(None), + realtime, + embedder: Arc::new(None), + search_rt: Arc::new(tokio::sync::RwLock::new(Default::default())), + job_worker, + } +} + +async fn audit_contract_create_user( + db: &prts_db::Db, + prefix: &str, + platform_role: Option<&str>, +) -> prts_db::models::User { + let username = format!("u-{}", audit_jobs_unique(prefix)); + let hash = + prts_auth::password::hash_password("audit-contract-password").expect("测试密码可哈希"); + let user = users::create_password_user(db, &username, None, &hash, "active") + .await + .expect("创建隔离测试用户"); + if let Some(role) = platform_role { + users::set_platform_role(db, user.id, Some(role)) + .await + .expect("设置测试平台角色"); + users::find_by_id(db, user.id) + .await + .unwrap() + .expect("测试用户仍存在") + } else { + user + } +} + +fn audit_contract_current_user(user: &prts_db::models::User) -> auth::CurrentUser { + auth::CurrentUser { + id: user.id, + platform_role: user + .platform_role + .as_deref() + .and_then(prts_core::PlatformRole::parse), + } +} + +/// `ApiError` 刻意不暴露内部细节且没有 `Debug`;测试只需报告调用边界名称。 +trait AuditContractApiResultExt { + fn expect_api(self, message: &str) -> T; + fn expect_err_api(self, message: &str) -> error::ApiError; +} + +impl AuditContractApiResultExt for Result { + fn expect_api(self, message: &str) -> T { + match self { + Ok(value) => value, + Err(_) => panic!("{message}"), + } + } + + fn expect_err_api(self, message: &str) -> error::ApiError { + match self { + Ok(_) => panic!("{message}"), + Err(error) => error, + } + } +} + +#[derive(Debug)] +struct ObservedAudit { + action: String, + target_type: String, + target_id: String, + project_id: Option, + payload: serde_json::Value, +} + +async fn audit_contract_rows_for_actor(db: &prts_db::Db, actor_id: i64) -> Vec { + sqlx::query_as::<_, (String, String, String, Option, serde_json::Value)>( + "SELECT action, target_type, target_id, project_id_snapshot, payload + FROM audit_log WHERE actor_id = $1 ORDER BY id", + ) + .bind(actor_id) + .fetch_all(db) + .await + .expect("读取测试 actor 的审计") + .into_iter() + .map( + |(action, target_type, target_id, project_id, payload)| ObservedAudit { + action, + target_type, + target_id, + project_id, + payload, + }, + ) + .collect() +} + +async fn audit_contract_rows_for_action_target( + db: &prts_db::Db, + action: &str, + target_id: &str, +) -> Vec { + sqlx::query_as::<_, (String, String, String, Option, serde_json::Value)>( + "SELECT action, target_type, target_id, project_id_snapshot, payload + FROM audit_log WHERE action = $1 AND target_id = $2 ORDER BY id", + ) + .bind(action) + .bind(target_id) + .fetch_all(db) + .await + .expect("读取 action/target 审计") + .into_iter() + .map( + |(action, target_type, target_id, project_id, payload)| ObservedAudit { + action, + target_type, + target_id, + project_id, + payload, + }, + ) + .collect() +} + +fn audit_contract_assert_actions(rows: &[ObservedAudit], expected: &[&str]) { + let observed: std::collections::HashSet<_> = + rows.iter().map(|row| row.action.as_str()).collect(); + for action in expected { + assert!( + observed.contains(action), + "缺少审计 action {action};实际={observed:?}" + ); + } +} + +fn audit_contract_assert_payloads_are_typed_and_redacted( + rows: &[ObservedAudit], + forbidden_values: &[&str], +) { + for row in rows { + let inventory = AUDITED_ENTRYPOINTS + .iter() + .find(|entry| entry.action == row.action) + .unwrap_or_else(|| panic!("action {} 没有 typed inventory", row.action)); + let object = row + .payload + .as_object() + .unwrap_or_else(|| panic!("{} payload 必须是 object", row.action)); + for key in object.keys() { + assert!( + inventory.allowed_payload_keys.contains(&key.as_str()), + "{} payload 出现未 allowlist 字段 {key}", + row.action + ); + } + audit_contract_assert_json_has_no_sensitive_keys(&row.payload); + let serialized = row.payload.to_string(); + for forbidden in forbidden_values { + assert!( + !serialized.contains(forbidden), + "{} payload 泄露秘密或正文 marker {forbidden}", + row.action + ); + } + } +} + +fn audit_contract_assert_json_has_no_sensitive_keys(value: &serde_json::Value) { + match value { + serde_json::Value::Object(object) => { + for (key, child) in object { + assert!( + !audit_contract_key_is_sensitive(key), + "审计 payload 出现秘密/正文键 {key}" + ); + audit_contract_assert_json_has_no_sensitive_keys(child); + } + } + serde_json::Value::Array(items) => { + for item in items { + audit_contract_assert_json_has_no_sensitive_keys(item); + } + } + _ => {} + } +} + +async fn audit_contract_assert_unavailable(error: error::ApiError) { + use axum::http::StatusCode; + use axum::response::IntoResponse; + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("读取错误响应体"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("错误响应为 JSON"); + assert_eq!(body["code"], "AUDIT_UNAVAILABLE"); + assert_eq!(body["message"], "审计服务暂不可用"); +} + +/// 认证注册把用户、active session、审计与 Redis outbox 作为一个 DB 提交;审计失败不返回 token。 +#[tokio::test] +async fn audit_contract_registration_rolls_back_user_and_token_issuance_when_audit_fails() { + use axum::extract::State; + use axum::Json; + + let failing_db = audit_contract_failing_audit_db().await; + let state = audit_contract_state_with_db(failing_db).await; + let username = format!("u-{}", audit_jobs_unique("audit-register-rollback")); + let password_marker = "REGISTER_PASSWORD_MUST_NEVER_ENTER_AUDIT"; + let result = auth_routes::register( + State(state.clone()), + Json(auth_routes::RegisterReq { + username: username.clone(), + email: Some(format!("{username}@example.invalid")), + password: password_marker.to_string(), + }), + ) + .await; + let error = match result { + Ok(_) => panic!("审计失败时注册不得激活用户或返回 token"), + Err(error) => error, + }; + audit_contract_assert_unavailable(error).await; + + let observation_db = pool().await; + assert!( + users::find_by_username(&observation_db, &username) + .await + .unwrap() + .is_none(), + "审计失败必须回滚 password 用户落库" + ); + let leaked: bool = sqlx::query_scalar( + "SELECT EXISTS( + SELECT 1 FROM audit_log + WHERE payload::TEXT LIKE '%' || $1 || '%' + )", + ) + .bind(password_marker) + .fetch_one(&observation_db) + .await + .unwrap(); + assert!(!leaked, "失败审计也不得泄露 password"); +} + +/// 普通业务 mutation 同样 fail closed;项目与 owner membership 不能部分提交。 +#[tokio::test] +async fn audit_contract_project_creation_rolls_back_business_rows_when_audit_fails() { + use axum::extract::State; + use axum::Json; + + let failing_db = audit_contract_failing_audit_db().await; + let state = audit_contract_state_with_db(failing_db).await; + let actor = + audit_contract_create_user(&state.db, "audit-project-rollback", Some("maintainer")).await; + let slug = format!("audit-project-rollback-{}", actor.id); + let result = projects_routes::create_project( + State(state.clone()), + audit_contract_current_user(&actor), + Json(projects_routes::CreateProjectReq { + name: "Audit rollback project".to_string(), + slug: Some(slug.clone()), + description: Some("BUSINESS_BODY_MUST_ROLL_BACK".to_string()), + visibility: Some("private".to_string()), + source_langs: vec!["en".to_string()], + target_lang: "zh-Hans".to_string(), + }), + ) + .await; + let error = match result { + Ok(_) => panic!("审计失败时项目 handler 不得返回成功"), + Err(error) => error, + }; + audit_contract_assert_unavailable(error).await; + + let observation_db = pool().await; + assert!( + projects::find_by_slug(&observation_db, &slug) + .await + .unwrap() + .is_none(), + "project 与 owner membership 必须随 audit 一起回滚" + ); +} + +/// 成功登录必须先提交 DB-authoritative active session、审计与 cache-populate outbox 才返回 raw token。 +#[tokio::test] +async fn audit_contract_login_commits_active_session_audit_and_outbox_before_returning_tokens() { + use axum::extract::State; + use axum::Json; + + let state = audit_contract_state().await; + let username = format!("u-{}", audit_jobs_unique("audit-login-success")); + let password_marker = "LOGIN_PASSWORD_MUST_NOT_ENTER_AUDIT"; + let password_hash = prts_auth::password::hash_password(password_marker).unwrap(); + let user = users::create_password_user(&state.db, &username, None, &password_hash, "active") + .await + .unwrap(); + + let Json(tokens) = auth_routes::login( + State(state.clone()), + Json(auth_routes::LoginReq { + username, + password: password_marker.to_string(), + }), + ) + .await + .expect_api("正确密码登录成功"); + + let refresh_hash = prts_auth::token::sha256_hex(&tokens.refresh_token); + let sessions: Vec<(i64, String, String, String)> = sqlx::query_as( + "SELECT id, state, session_handle, family_handle + FROM auth_sessions WHERE refresh_token_hash = $1", + ) + .bind(&refresh_hash) + .fetch_all(&state.db) + .await + .unwrap(); + assert_eq!(sessions.len(), 1, "返回 refresh 前必须持久化唯一 session"); + assert_eq!(sessions[0].1, "active"); + assert!(sessions[0].2.len() >= 16); + assert!(sessions[0].3.len() >= 16); + + let queued_populate: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth_session_intents + WHERE session_id = $1 AND kind = 'redis_populate' AND state = 'queued'", + ) + .bind(sessions[0].0) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + queued_populate, 1, + "active session 必须带 durable cache outbox" + ); + + let rows = audit_contract_rows_for_actor(&state.db, user.id).await; + audit_contract_assert_actions(&rows, &["auth.login_succeeded", "auth.token_issued"]); + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + password_marker, + &password_hash, + &tokens.access_token, + &tokens.refresh_token, + &refresh_hash, + ], + ); +} + +/// 成功登录的 audit INSERT 失败时,既不能激活 session,也不能把 token 返回调用方。 +#[tokio::test] +async fn audit_contract_login_returns_503_without_active_session_when_audit_fails() { + use axum::extract::State; + use axum::Json; + + let failing_db = audit_contract_failing_audit_db().await; + let state = audit_contract_state_with_db(failing_db).await; + let username = format!("u-{}", audit_jobs_unique("audit-login-fail")); + let password = "valid-login-password"; + let password_hash = prts_auth::password::hash_password(password).unwrap(); + let user = users::create_password_user(&state.db, &username, None, &password_hash, "active") + .await + .unwrap(); + let result = auth_routes::login( + State(state.clone()), + Json(auth_routes::LoginReq { + username, + password: password.to_string(), + }), + ) + .await; + let error = match result { + Ok(_) => panic!("审计失败时成功登录不得返回 token"), + Err(error) => error, + }; + audit_contract_assert_unavailable(error).await; + let active: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM auth_sessions WHERE user_id = $1 AND state = 'active'", + ) + .bind(user.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(active, 0); +} + +/// 失败凭证也同步写脱敏 audit;只有审计本身失败时才把原 401 收敛为通用 503。 +#[tokio::test] +async fn audit_contract_failed_password_login_is_audited_and_audit_failure_hides_auth_result() { + use axum::extract::State; + use axum::response::IntoResponse; + use axum::Json; + + let state = audit_contract_state().await; + let username = format!("u-{}", audit_jobs_unique("audit-login-denied")); + let password_hash = prts_auth::password::hash_password("correct-password").unwrap(); + let user = users::create_password_user(&state.db, &username, None, &password_hash, "active") + .await + .unwrap(); + let wrong_password = "WRONG_PASSWORD_MUST_NOT_ENTER_AUDIT"; + let denied = auth_routes::login( + State(state.clone()), + Json(auth_routes::LoginReq { + username: username.clone(), + password: wrong_password.to_string(), + }), + ) + .await + .expect_err_api("错误密码必须拒绝"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); + let rows = + audit_contract_rows_for_action_target(&state.db, "auth.login_failed", &user.id.to_string()) + .await; + assert_eq!(rows.len(), 1, "每次失败认证必须同步审计一次"); + audit_contract_assert_payloads_are_typed_and_redacted(&rows, &[wrong_password, &password_hash]); + + let failing_db = audit_contract_failing_audit_db().await; + let failing_state = audit_contract_state_with_db(failing_db).await; + let denied = auth_routes::login( + State(failing_state), + Json(auth_routes::LoginReq { + username, + password: wrong_password.to_string(), + }), + ) + .await; + let error = match denied { + Ok(_) => panic!("错误密码不得认证成功"), + Err(error) => error, + }; + audit_contract_assert_unavailable(error).await; +} + +async fn audit_contract_login_fixture( + state: &state::AppState, + prefix: &str, +) -> (prts_db::models::User, auth_routes::TokenResponse) { + use axum::extract::State; + use axum::Json; + + let username = format!("u-{}", audit_jobs_unique(prefix)); + let password = "fixture-login-password"; + let hash = prts_auth::password::hash_password(password).unwrap(); + let user = users::create_password_user(&state.db, &username, None, &hash, "active") + .await + .unwrap(); + let Json(tokens) = auth_routes::login( + State(state.clone()), + Json(auth_routes::LoginReq { + username, + password: password.to_string(), + }), + ) + .await + .expect_api("fixture 登录成功"); + (user, tokens) +} + +/// refresh rotation 必须在一个事务中撤销 predecessor、激活 successor,并写 audit/outbox。 +#[tokio::test] +async fn audit_contract_refresh_rotation_has_one_active_successor_and_redacted_outbox() { + use axum::extract::State; + use axum::Json; + + let state = audit_contract_state().await; + let (user, first) = audit_contract_login_fixture(&state, "audit-refresh").await; + let old_hash = prts_auth::token::sha256_hex(&first.refresh_token); + let Json(second) = auth_routes::refresh( + State(state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: first.refresh_token.clone(), + }), + ) + .await + .expect_api("refresh rotation 成功"); + let new_hash = prts_auth::token::sha256_hex(&second.refresh_token); + + let sessions: Vec<(i64, String, String, Option, Option)> = sqlx::query_as( + "SELECT id, refresh_token_hash, state, predecessor_id, successor_id + FROM auth_sessions WHERE user_id = $1 ORDER BY id", + ) + .bind(user.id) + .fetch_all(&state.db) + .await + .unwrap(); + assert_eq!(sessions.len(), 2, "rotation 只建立一个 successor"); + let predecessor = sessions + .iter() + .find(|row| row.1 == old_hash) + .expect("predecessor hash 存在"); + let successor = sessions + .iter() + .find(|row| row.1 == new_hash) + .expect("successor hash 存在"); + assert_eq!(predecessor.2, "revoked"); + assert_eq!(successor.2, "active"); + assert_eq!(predecessor.4, Some(successor.0)); + assert_eq!(successor.3, Some(predecessor.0)); + assert_eq!( + sessions.iter().filter(|row| row.2 == "active").count(), + 1, + "同 family 不得双 active" + ); + + let intents: Vec<(String, serde_json::Value)> = sqlx::query_as( + "SELECT kind, payload FROM auth_session_intents + WHERE session_id = ANY($1::BIGINT[]) ORDER BY id", + ) + .bind(&[predecessor.0, successor.0][..]) + .fetch_all(&state.db) + .await + .unwrap(); + assert!(intents.iter().any(|intent| intent.0 == "redis_invalidate")); + assert!(intents.iter().any(|intent| intent.0 == "redis_populate")); + let serialized_intents = serde_json::to_string(&intents).unwrap(); + for forbidden in [ + first.refresh_token.as_str(), + second.refresh_token.as_str(), + first.access_token.as_str(), + second.access_token.as_str(), + old_hash.as_str(), + new_hash.as_str(), + ] { + assert!(!serialized_intents.contains(forbidden)); + } + + let rows = audit_contract_rows_for_actor(&state.db, user.id).await; + audit_contract_assert_actions(&rows, &["auth.refresh_rotated"]); + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + &first.refresh_token, + &second.refresh_token, + &first.access_token, + &second.access_token, + &old_hash, + &new_hash, + ], + ); +} + +/// 并发复用同一 predecessor 只能一个 refresh 成功,且 DB 最终只有一个 active successor。 +#[tokio::test] +async fn audit_contract_concurrent_refresh_never_creates_two_active_successors() { + use axum::extract::State; + use axum::Json; + + let state = audit_contract_state().await; + let (user, first) = audit_contract_login_fixture(&state, "audit-refresh-race").await; + let raw = first.refresh_token; + let (left, right) = tokio::join!( + auth_routes::refresh( + State(state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: raw.clone(), + }), + ), + auth_routes::refresh( + State(state.clone()), + Json(auth_routes::RefreshReq { refresh_token: raw }), + ) + ); + assert_eq!( + usize::from(left.is_ok()) + usize::from(right.is_ok()), + 1, + "同一个 refresh predecessor 必须恰有一个 rotation 成功" + ); + let (active, total): (i64, i64) = sqlx::query_as( + "SELECT COUNT(*) FILTER (WHERE state = 'active'), COUNT(*) + FROM auth_sessions WHERE user_id = $1", + ) + .bind(user.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(active, 1); + assert_eq!(total, 2, "失败竞争者不得留下 pending/rotating 垃圾行"); +} + +/// rotation audit 失败时不得返回 successor,predecessor 必须仍是唯一 active。 +#[tokio::test] +async fn audit_contract_refresh_audit_failure_keeps_predecessor_active_and_returns_503() { + use axum::extract::State; + use axum::Json; + + let normal_state = audit_contract_state().await; + let (user, first) = audit_contract_login_fixture(&normal_state, "audit-refresh-fail").await; + let old_hash = prts_auth::token::sha256_hex(&first.refresh_token); + let failing_db = audit_contract_failing_audit_db().await; + let failing_state = audit_contract_state_with_db(failing_db).await; + let result = auth_routes::refresh( + State(failing_state), + Json(auth_routes::RefreshReq { + refresh_token: first.refresh_token, + }), + ) + .await; + let error = match result { + Ok(_) => panic!("rotation audit 失败不得返回 successor token"), + Err(error) => error, + }; + audit_contract_assert_unavailable(error).await; + + let sessions: Vec<(String, String)> = + sqlx::query_as("SELECT refresh_token_hash, state FROM auth_sessions WHERE user_id = $1") + .bind(user.id) + .fetch_all(&normal_state.db) + .await + .unwrap(); + assert_eq!(sessions, vec![(old_hash, "active".to_string())]); +} + +/// 即使 Redis 仍残留旧 refresh,DB revoked 状态也必须否决认证。 +#[tokio::test] +async fn audit_contract_stale_redis_refresh_cannot_bypass_revoked_db_session() { + use axum::extract::State; + use axum::response::IntoResponse; + use axum::Json; + + let state = audit_contract_state().await; + let (user, tokens) = audit_contract_login_fixture(&state, "audit-stale-redis").await; + let refresh_hash = + auth_sessions::RefreshTokenHash::parse(prts_auth::token::sha256_hex(&tokens.refresh_token)) + .unwrap(); + let mut tx = state.db.begin().await.unwrap(); + let session = + match auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &refresh_hash) + .await + .unwrap() + { + Some(session) => session, + None => { + // 让旧实现也能抵达 stale-cache 断言;GREEN 后会直接复用 login 创建的 session。 + let pending = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: format!("session-handle-{}", audit_jobs_unique("stale")), + family_handle: format!("family-handle-{}", audit_jobs_unique("stale")), + user_id: user.id, + refresh_token_hash: refresh_hash.clone(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + auth_sessions::activate_pending_tx(&mut tx, pending.id) + .await + .unwrap() + .unwrap() + } + }; + auth_sessions::revoke_unexpired_tx(&mut tx, session.id) + .await + .unwrap() + .expect("权威 session 可吊销"); + tx.commit().await.unwrap(); + + // 故意不 DEL Redis,模拟 invalidate worker crash/stale cache。 + let denied = auth_routes::refresh( + State(state), + Json(auth_routes::RefreshReq { + refresh_token: tokens.refresh_token, + }), + ) + .await + .expect_err_api("revoked DB session 必须拒绝 stale Redis"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); +} + +/// DB commit 已成功时,Redis populate 失败由 outbox 重放,不能撤销 session 或吞掉 raw token。 +#[tokio::test] +async fn audit_contract_token_issuance_survives_redis_populate_failure_via_durable_outbox() { + use axum::extract::State; + use axum::Json; + + let mut state = audit_contract_state().await; + let admin_cache = state.cache.clone(); + let (restricted_cache, acl_user) = audit_contract_restricted_redis(&admin_cache, "set").await; + state.cache = restricted_cache; + let username = format!("u-{}", audit_jobs_unique("audit-redis-set-fail")); + let password = "redis-set-failure-login"; + let hash = prts_auth::password::hash_password(password).unwrap(); + let user = users::create_password_user(&state.db, &username, None, &hash, "active") + .await + .unwrap(); + let result = auth_routes::login( + State(state.clone()), + Json(auth_routes::LoginReq { + username, + password: password.to_string(), + }), + ) + .await; + audit_contract_delete_redis_acl_user(&admin_cache, &acl_user).await; + let Json(tokens) = result.expect_api("Redis populate 失败不应推翻已提交的 token issuance"); + assert!(!tokens.access_token.is_empty()); + assert!(!tokens.refresh_token.is_empty()); + + let (active, queued): (i64, i64) = sqlx::query_as( + "SELECT + (SELECT COUNT(*) FROM auth_sessions + WHERE user_id = $1 AND state = 'active'), + (SELECT COUNT(*) FROM auth_session_intents AS intent + JOIN auth_sessions AS session ON session.id = intent.session_id + WHERE session.user_id = $1 AND intent.kind = 'redis_populate' + AND intent.state = 'queued')", + ) + .bind(user.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!((active, queued), (1, 1)); +} + +/// logout 的 DB revoke 一经提交立即生效;Redis DEL 失败只留下 durable invalidate retry。 +#[tokio::test] +async fn audit_contract_logout_remains_immediate_when_redis_invalidate_fails() { + use axum::extract::State; + use axum::response::IntoResponse; + use axum::Json; + + let normal_state = audit_contract_state().await; + let admin_cache = normal_state.cache.clone(); + let (user, tokens) = + audit_contract_login_fixture(&normal_state, "audit-logout-redis-fail").await; + let (restricted_cache, acl_user) = audit_contract_restricted_redis(&admin_cache, "del").await; + let mut logout_state = audit_contract_state_with_db(normal_state.db.clone()).await; + logout_state.cache = restricted_cache; + let logout_result = auth_routes::logout( + State(logout_state), + Json(auth_routes::RefreshReq { + refresh_token: tokens.refresh_token.clone(), + }), + ) + .await; + audit_contract_delete_redis_acl_user(&admin_cache, &acl_user).await; + logout_result.expect_api("Redis DEL 失败不应推翻 DB logout commit"); + + let refresh_hash = prts_auth::token::sha256_hex(&tokens.refresh_token); + let (state_name, invalidate_count): (String, i64) = sqlx::query_as( + "SELECT session.state, + (SELECT COUNT(*) FROM auth_session_intents AS intent + WHERE intent.session_id = session.id + AND intent.kind = 'redis_invalidate' + AND intent.state = 'queued') + FROM auth_sessions AS session WHERE session.refresh_token_hash = $1", + ) + .bind(refresh_hash) + .fetch_one(&normal_state.db) + .await + .unwrap(); + assert_eq!(state_name, "revoked"); + assert_eq!(invalidate_count, 1); + + // 即使 DEL 失败后缓存仍残留,DB revoked 必须立刻否决 refresh。 + let denied = auth_routes::refresh( + State(normal_state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: tokens.refresh_token, + }), + ) + .await + .expect_err_api("logout DB commit 后 refresh 必须立即失效"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); + + let rows = audit_contract_rows_for_actor(&normal_state.db, user.id).await; + audit_contract_assert_actions(&rows, &["auth.logged_out"]); +} + +/// 普通 read 跨用户/项目/文件/词条/通知/私信均保持零审计;敏感 export 已单独覆盖。 +#[tokio::test] +async fn audit_contract_ordinary_reads_do_not_append_audit_rows() { + use axum::extract::{Path, Query, State}; + + let state = audit_contract_state().await; + let actor = + audit_contract_create_user(&state.db, "audit-read-actor", Some("super_admin")).await; + let other = audit_contract_create_user(&state.db, "audit-read-other", None).await; + let project = projects::create( + &state.db, + &format!("audit-read-project-{}", actor.id), + "Audit Read Project", + "", + "private", + &["en".to_string()], + "zh-Hans", + actor.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, actor.id, "owner") + .await + .unwrap(); + memberships::upsert(&state.db, project.id, other.id, "translator") + .await + .unwrap(); + let file = files::ensure_file_at_path(&state.db, project.id, "read/item.json") + .await + .unwrap(); + entries::bulk_upsert( + &state.db, + file.id, + project.id, + &[entries::UploadEntry { + key: "read-key".to_string(), + original: serde_json::json!({"en": "read fixture"}), + context: None, + translation: None, + state: None, + }], + Some(actor.id), + ) + .await + .unwrap(); + let entry = entries::list( + &state.db, + project.id, + &entries::EntryFilter::default(), + None, + 1, + ) + .await + .unwrap() + .remove(0); + notifications::create(&state.db, actor.id, "read-fixture", &serde_json::json!({})) + .await + .unwrap(); + messages::create(&state.db, other.id, actor.id, "read fixture") + .await + .unwrap(); + let before: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log WHERE actor_id = $1") + .bind(actor.id) + .fetch_one(&state.db) + .await + .unwrap(); + + let _ = users_routes::me(State(state.clone()), audit_contract_current_user(&actor)) + .await + .expect_api("读取当前用户成功"); + let _ = admin_routes::get_settings(State(state.clone()), audit_contract_current_user(&actor)) + .await + .expect_api("读取平台设置成功"); + let _ = projects_routes::get_project( + State(state.clone()), + auth::MaybeUser(Some(audit_contract_current_user(&actor))), + Path(project.id), + ) + .await + .expect_api("读取项目成功"); + let _ = files_routes::get_tree( + State(state.clone()), + auth::MaybeUser(Some(audit_contract_current_user(&actor))), + Path(project.id), + ) + .await + .expect_api("读取文件树成功"); + let _ = entries_routes::get_entry( + State(state.clone()), + auth::MaybeUser(Some(audit_contract_current_user(&actor))), + Path((project.id, entry.id)), + ) + .await + .expect_api("读取词条成功"); + let _ = notifications_routes::list( + State(state.clone()), + audit_contract_current_user(&actor), + Query(notifications_routes::ListQuery { + before: None, + limit: Some(10), + }), + ) + .await + .expect_api("读取通知成功"); + let _ = messages_routes::conversation( + State(state.clone()), + audit_contract_current_user(&actor), + Path(other.id), + Query(messages_routes::ConversationQuery { + before: None, + limit: Some(10), + }), + ) + .await + .expect_api("读取私信成功"); + + let after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM audit_log WHERE actor_id = $1") + .bind(actor.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(after, before, "普通 read 不得追加 audit_log"); +} + +/// 用户、自助 API key、平台设置与角色写入必须由真实 handler 产生同 actor 审计。 +#[tokio::test] +async fn audit_contract_users_admin_settings_and_api_keys_are_audited_and_redacted() { + use axum::extract::{FromRequestParts, Path, State}; + use axum::http::{header, Request}; + use axum::Json; + use std::collections::HashMap; + + let state = audit_contract_state().await; + let actor = audit_contract_create_user(&state.db, "audit-admin", Some("super_admin")).await; + let target = audit_contract_create_user(&state.db, "audit-role-target", None).await; + let profile_marker = "FULL_PROFILE_CONTENT_MUST_NOT_ENTER_AUDIT"; + let setting_marker = "FULL_SETTING_VALUE_MUST_NOT_ENTER_AUDIT"; + let endpoint_marker = "AUDIT_EMBEDDING_ENDPOINT_VALUE"; + + let _ = users_routes::update_me( + State(state.clone()), + audit_contract_current_user(&actor), + Json(users_routes::UpdateMeReq { + description: Some(profile_marker.to_string()), + avatar_url: Some("https://example.invalid/avatar.png".to_string()), + translation_langs: Some(vec!["en".to_string(), "zh-Hans".to_string()]), + }), + ) + .await + .expect_api("更新个人资料成功"); + + let Json(created_key) = users_routes::create_api_key( + State(state.clone()), + audit_contract_current_user(&actor), + Json(users_routes::CreateApiKeyReq { + name: "audit-contract-key".to_string(), + }), + ) + .await + .expect_api("创建 API key 成功"); + + // 走真实 CurrentUser extractor,覆盖 API-key touch writer,而不是直接调用 mock。 + let (mut parts, _) = Request::builder() + .header(header::AUTHORIZATION, format!("Bearer {}", created_key.key)) + .body(()) + .unwrap() + .into_parts(); + let extracted = auth::CurrentUser::from_request_parts(&mut parts, &state) + .await + .expect_api("API key 可认证"); + assert_eq!(extracted.id, actor.id); + + let mut changed_settings = HashMap::new(); + changed_settings.insert( + format!("audit.contract.{}", actor.id), + serde_json::json!({"enabled": true, "value": setting_marker}), + ); + admin_routes::update_settings( + State(state.clone()), + audit_contract_current_user(&actor), + Json(admin_routes::UpdateSettingsReq { + settings: changed_settings, + }), + ) + .await + .expect_api("更新平台设置成功"); + + let _ = admin_settings_routes::put_search_settings( + State(state.clone()), + audit_contract_current_user(&actor), + Json(admin_settings_routes::SearchConfigDto { + embedding_enabled: false, + embedding_model: "text-embedding-v4".to_string(), + embedding_base_url: format!("https://{endpoint_marker}.invalid/v1"), + embedding_batch: 3, + tm_enabled: true, + tm_min_similarity: 0.4, + tm_top_n: 2, + }), + ) + .await + .expect_api("更新搜索设置成功"); + + admin_routes::grant_role( + State(state.clone()), + audit_contract_current_user(&actor), + Path(target.id), + Json(admin_routes::GrantRoleReq { + role: Some("maintainer".to_string()), + }), + ) + .await + .expect_api("授予平台角色成功"); + + users_routes::revoke_api_key( + State(state.clone()), + audit_contract_current_user(&actor), + Path(created_key.id), + ) + .await + .expect_api("吊销 API key 成功"); + + let rows = audit_contract_rows_for_actor(&state.db, actor.id).await; + audit_contract_assert_actions( + &rows, + &[ + "user.profile_updated", + "api_key.created", + "api_key.used", + "settings.updated", + "search_settings.updated", + "user.platform_role_changed", + "api_key.revoked", + ], + ); + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + profile_marker, + setting_marker, + endpoint_marker, + &created_key.key, + ], + ); +} + +/// 项目、成员、旧上传、词条编辑/flags、文件删除与敏感导出必须走真实事务审计。 +#[tokio::test] +async fn audit_contract_projects_files_entries_memberships_and_export_are_audited_and_redacted() { + use axum::extract::{Path, State}; + use axum::Json; + + let state = audit_contract_state().await; + let actor = + audit_contract_create_user(&state.db, "audit-project-owner", Some("maintainer")).await; + let member = audit_contract_create_user(&state.db, "audit-project-member", None).await; + let original_marker = "FULL_ORIGINAL_SOURCE_TEXT_MUST_NOT_ENTER_AUDIT"; + let seeded_translation_marker = "FULL_SEEDED_TRANSLATION_MUST_NOT_ENTER_AUDIT"; + let updated_translation_marker = "FULL_UPDATED_TRANSLATION_MUST_NOT_ENTER_AUDIT"; + let context_marker = "FULL_ENTRY_CONTEXT_MUST_NOT_ENTER_AUDIT"; + let description_marker = "FULL_PROJECT_DESCRIPTION_MUST_NOT_ENTER_AUDIT"; + let slug = format!("audit-project-{}", actor.id); + + let Json(project) = projects_routes::create_project( + State(state.clone()), + audit_contract_current_user(&actor), + Json(projects_routes::CreateProjectReq { + name: format!("Audit Project {}", actor.id), + slug: Some(slug.clone()), + description: Some(description_marker.to_string()), + visibility: Some("private".to_string()), + source_langs: vec!["en".to_string()], + target_lang: "zh-Hans".to_string(), + }), + ) + .await + .expect_api("创建项目成功"); + + projects_routes::add_member( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(projects_routes::AddMemberReq { + username: member.username.clone(), + role: "translator".to_string(), + }), + ) + .await + .expect_api("添加项目成员成功"); + + let Json(uploaded) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(entries_routes::UploadReq { + path: "dialog/main.json".to_string(), + entries: vec![entries_routes::UploadEntryDto { + key: "audit-key".to_string(), + original: serde_json::json!({"en": original_marker}), + context: Some(context_marker.to_string()), + translation: Some(seeded_translation_marker.to_string()), + state: Some("translated".to_string()), + }], + }), + ) + .await + .expect_api("旧上传入口成功"); + + let entry = entries::list( + &state.db, + project.id, + &entries::EntryFilter { + file_id: Some(uploaded.file_id), + ..Default::default() + }, + None, + 10, + ) + .await + .unwrap() + .into_iter() + .next() + .expect("上传产生词条"); + + let _ = entries_routes::update_entry( + State(state.clone()), + audit_contract_current_user(&actor), + Path((project.id, entry.id)), + Json(entries_routes::UpdateEntryReq { + translation: updated_translation_marker.to_string(), + state: "translated".to_string(), + version: entry.version, + }), + ) + .await + .expect_api("编辑词条成功"); + let _ = entries_routes::set_entry_flags( + State(state.clone()), + audit_contract_current_user(&actor), + Path((project.id, entry.id)), + Json(entries_routes::SetFlagsReq { + locked: Some(true), + hidden: Some(true), + }), + ) + .await + .expect_api("设置词条 flags 成功"); + + // 敏感读取例外:项目导出必须审计,普通列表/详情读取在另一合同中断言零审计。 + entries_routes::export_project( + State(state.clone()), + auth::MaybeUser(Some(audit_contract_current_user(&actor))), + Path(project.id), + ) + .await + .expect_api("项目导出成功"); + + let Json(file_to_delete) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(entries_routes::UploadReq { + path: "delete-file/item.json".to_string(), + entries: vec![entries_routes::UploadEntryDto { + key: "delete-file-key".to_string(), + original: serde_json::json!({"en": original_marker}), + context: None, + translation: None, + state: None, + }], + }), + ) + .await + .expect_api("创建待删文件成功"); + files_routes::delete_file( + State(state.clone()), + audit_contract_current_user(&actor), + Path((project.id, file_to_delete.file_id)), + ) + .await + .expect_api("删除文件成功"); + + let _ = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(entries_routes::UploadReq { + path: "delete-folder/nested/item.json".to_string(), + entries: vec![entries_routes::UploadEntryDto { + key: "delete-folder-key".to_string(), + original: serde_json::json!({"en": original_marker}), + context: None, + translation: None, + state: None, + }], + }), + ) + .await + .expect_api("创建待删文件夹成功"); + let folder_id = files::list_folders(&state.db, project.id) + .await + .unwrap() + .into_iter() + .find(|folder| folder.path == "delete-folder") + .expect("顶层待删文件夹存在") + .id; + files_routes::delete_folder( + State(state.clone()), + audit_contract_current_user(&actor), + Path((project.id, folder_id)), + ) + .await + .expect_api("删除文件夹成功"); + + let _ = projects_routes::update_project( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(projects_routes::UpdateProjectReq { + name: Some(format!("Updated Audit Project {}", actor.id)), + description: Some(description_marker.to_string()), + visibility: Some("public".to_string()), + source_langs: None, + target_lang: None, + }), + ) + .await + .expect_api("更新项目成功"); + projects_routes::remove_member( + State(state.clone()), + audit_contract_current_user(&actor), + Path((project.id, member.id)), + ) + .await + .expect_api("移除项目成员成功"); + projects_routes::delete_project( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + ) + .await + .expect_api("删除项目成功"); + + let rows = audit_contract_rows_for_actor(&state.db, actor.id).await; + audit_contract_assert_actions( + &rows, + &[ + "project.created", + "membership.upserted", + "entries.uploaded", + "entry.updated", + "entry.flags_updated", + "project.exported", + "file.deleted", + "folder.deleted", + "project.updated", + "membership.removed", + "project.deleted", + ], + ); + for row in &rows { + if row.project_id.is_some() { + assert_eq!(row.project_id, Some(project.id)); + } + assert!(!row.target_type.is_empty()); + assert!(!row.target_id.is_empty()); + } + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + original_marker, + seeded_translation_marker, + updated_translation_marker, + context_marker, + description_marker, + ], + ); +} + +/// 通知 mark-read、poke、私信发送/已读都属于成功 mutation;正文只能进入业务表,不能进 audit。 +#[tokio::test] +async fn audit_contract_notifications_poke_and_messages_are_audited_without_content() { + use axum::extract::{Path, State}; + use axum::Json; + + let state = audit_contract_state().await; + let sender = audit_contract_create_user(&state.db, "audit-message-sender", None).await; + let recipient = audit_contract_create_user(&state.db, "audit-message-recipient", None).await; + let project = projects::create( + &state.db, + &format!("audit-message-project-{}", sender.id), + "Audit Message Project", + "", + "private", + &["en".to_string()], + "zh-Hans", + sender.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, sender.id, "owner") + .await + .unwrap(); + memberships::upsert(&state.db, project.id, recipient.id, "translator") + .await + .unwrap(); + + let notification = notifications::create( + &state.db, + sender.id, + "audit-contract", + &serde_json::json!({"fixture": true}), + ) + .await + .unwrap(); + notifications_routes::mark_read( + State(state.clone()), + audit_contract_current_user(&sender), + Json(notifications_routes::MarkReadReq { + ids: Some(vec![notification.id]), + }), + ) + .await + .expect_api("标记通知已读成功"); + + let poke_marker = "FULL_POKE_TEXT_MUST_NOT_ENTER_AUDIT"; + notifications_routes::poke( + State(state.clone()), + audit_contract_current_user(&sender), + Path(project.id), + Json(notifications_routes::PokeReq { + to_user_id: recipient.id, + text: poke_marker.to_string(), + }), + ) + .await + .expect_api("poke 发送成功"); + + let message_marker = "FULL_PRIVATE_MESSAGE_MUST_NOT_ENTER_AUDIT"; + let _ = messages_routes::send( + State(state.clone()), + audit_contract_current_user(&sender), + Json(messages_routes::SendReq { + to_user_id: recipient.id, + content: message_marker.to_string(), + }), + ) + .await + .expect_api("私信发送成功"); + messages_routes::mark_read( + State(state.clone()), + audit_contract_current_user(&recipient), + Path(sender.id), + ) + .await + .expect_api("私信标记已读成功"); + + let sender_rows = audit_contract_rows_for_actor(&state.db, sender.id).await; + audit_contract_assert_actions( + &sender_rows, + &["notification.marked_read", "poke.sent", "message.sent"], + ); + audit_contract_assert_payloads_are_typed_and_redacted( + &sender_rows, + &[poke_marker, message_marker], + ); + + let recipient_rows = audit_contract_rows_for_actor(&state.db, recipient.id).await; + audit_contract_assert_actions(&recipient_rows, &["message.marked_read"]); + audit_contract_assert_payloads_are_typed_and_redacted( + &recipient_rows, + &[poke_marker, message_marker], + ); +} + /// `0007` 必须同时建立追加式审计与持久化任务两项基础设施。 /// /// 该测试先于迁移编写,用于证明旧 schema 会明确报缺表,而不是因测试装配错误失败。 @@ -256,8 +2146,8 @@ async fn audit_jobs_workers_claim_distinct_rows_concurrently() { let eligible = vec![marker.clone()]; let (claimed_a, claimed_b) = tokio::join!( - jobs::claim_next_for_ids_and_kinds(&pool, "worker-a", 300, &[], &eligible, &job_ids,), - jobs::claim_next_for_ids_and_kinds(&pool, "worker-b", 300, &[], &eligible, &job_ids,) + db_jobs::claim_next_for_ids_and_kinds(&pool, "worker-a", 300, &[], &eligible, &job_ids,), + db_jobs::claim_next_for_ids_and_kinds(&pool, "worker-b", 300, &[], &eligible, &job_ids,) ); let claimed_a = claimed_a.unwrap().unwrap().id; let claimed_b = claimed_b.unwrap().unwrap().id; @@ -312,7 +2202,7 @@ async fn audit_jobs_expired_lease_is_taken_over_and_active_lease_is_preserved() .unwrap(); let eligible = vec![expired_kind.clone(), active_kind.clone()]; - let claimed = jobs::claim_next_for_ids_and_kinds( + let claimed = db_jobs::claim_next_for_ids_and_kinds( &pool, "replacement-worker", 300, @@ -368,7 +2258,7 @@ async fn audit_jobs_last_attempt_crash_is_failed_without_reclaim() { .await .unwrap(); - let claimed = jobs::claim_next_for_ids_and_kinds( + let claimed = db_jobs::claim_next_for_ids_and_kinds( &pool, "replacement-worker", 300, @@ -379,7 +2269,7 @@ async fn audit_jobs_last_attempt_crash_is_failed_without_reclaim() { .await .unwrap(); assert!(claimed.is_none(), "耗尽任务不得重复外部副作用"); - let exhausted = jobs::find_by_id(&pool, exhausted_id) + let exhausted = db_jobs::find_by_id(&pool, exhausted_id) .await .unwrap() .unwrap(); @@ -392,7 +2282,7 @@ async fn audit_jobs_last_attempt_crash_is_failed_without_reclaim() { assert!(exhausted.worker_id.is_none()); assert!(exhausted.lease_until.is_none()); - assert!(jobs::claim_next_for_ids_and_kinds( + assert!(db_jobs::claim_next_for_ids_and_kinds( &pool, "third-worker", 300, @@ -436,10 +2326,10 @@ async fn audit_jobs_lease_renewal_requires_current_worker() { .await .unwrap(); - assert!(!jobs::renew_lease(&pool, job_id, "stale-worker", 300) + assert!(!db_jobs::renew_lease(&pool, job_id, "stale-worker", 300) .await .unwrap()); - assert!(jobs::renew_lease(&pool, job_id, "current-worker", 300) + assert!(db_jobs::renew_lease(&pool, job_id, "current-worker", 300) .await .unwrap()); @@ -449,7 +2339,7 @@ async fn audit_jobs_lease_renewal_requires_current_worker() { .await .unwrap(); assert!( - !jobs::renew_lease(&pool, job_id, "current-worker", 300) + !db_jobs::renew_lease(&pool, job_id, "current-worker", 300) .await .unwrap(), "过期 lease 不得被原 worker 复活" @@ -483,7 +2373,7 @@ async fn audit_jobs_manual_retry_reuses_same_id_and_increments_attempts() { .unwrap(); let mut tx = pool.begin().await.unwrap(); - let locked = jobs::find_by_id_for_update_tx(&mut tx, job_id) + let locked = db_jobs::find_by_id_for_update_tx(&mut tx, job_id) .await .unwrap() .unwrap(); @@ -496,7 +2386,7 @@ async fn audit_jobs_manual_retry_reuses_same_id_and_increments_attempts() { .is_err(), "project delete 应等待已锁定 job,避免 retry TOCTOU" ); - let retried = jobs::manual_retry_tx(&mut tx, locked.id) + let retried = db_jobs::manual_retry_tx(&mut tx, locked.id) .await .unwrap() .unwrap(); @@ -505,7 +2395,7 @@ async fn audit_jobs_manual_retry_reuses_same_id_and_increments_attempts() { assert_eq!(retried.id, job_id); assert_eq!(retried.attempts, 4); - let detached = jobs::find_by_id(&pool, job_id).await.unwrap().unwrap(); + let detached = db_jobs::find_by_id(&pool, job_id).await.unwrap().unwrap(); assert_eq!(detached.project_id, None); sqlx::query("DELETE FROM jobs WHERE id = $1") .bind(job_id) @@ -544,7 +2434,7 @@ async fn audit_jobs_pending_deletion_filter_pauses_non_purge_jobs() { .unwrap(); let eligible = vec![normal_kind.clone(), "project_purge".to_string()]; - let claimed = jobs::claim_next_for_ids_and_kinds( + let claimed = db_jobs::claim_next_for_ids_and_kinds( &pool, "purge-worker", 300, @@ -582,7 +2472,9 @@ async fn audit_jobs_pending_deletion_filter_pauses_non_purge_jobs() { .await .unwrap(); assert_eq!( - jobs::resume_project_jobs(&pool, project_id).await.unwrap(), + db_jobs::resume_project_jobs(&pool, project_id) + .await + .unwrap(), 1 ); let states: Vec<(i64, String, Option)> = @@ -640,7 +2532,7 @@ async fn audit_jobs_progress_updates_are_bounded() { .await .unwrap(); let progress = - jobs::update_progress(&pool, job_id, "progress-worker", 6, Some(10), "processing") + db_jobs::update_progress(&pool, job_id, "progress-worker", 6, Some(10), "processing") .await .unwrap() .unwrap(); @@ -754,12 +2646,12 @@ async fn audit_jobs_detached_non_purge_is_not_claimed_or_retried() { .unwrap(); let mut tx = pool.begin().await.unwrap(); - let locked = jobs::find_by_id_for_update_tx(&mut tx, failed_id) + let locked = db_jobs::find_by_id_for_update_tx(&mut tx, failed_id) .await .unwrap() .unwrap(); assert_eq!(locked.project_id, None); - assert!(jobs::manual_retry_tx(&mut tx, failed_id) + assert!(db_jobs::manual_retry_tx(&mut tx, failed_id) .await .unwrap() .is_none()); @@ -770,7 +2662,7 @@ async fn audit_jobs_detached_non_purge_is_not_claimed_or_retried() { "upload_cleanup".to_string(), "project_purge".to_string(), ]; - let claimed = jobs::claim_next_for_ids_and_kinds( + let claimed = db_jobs::claim_next_for_ids_and_kinds( &pool, "detached-worker", 300, @@ -1517,10 +3409,10 @@ async fn audit_jobs_repository_uses_typed_payload_and_result() { let pool = pool().await; let (user_id, project_id, _) = audit_jobs_project(&pool, "typed-job").await; let mut tx = pool.begin().await.unwrap(); - let created = jobs::create_tx( + let created = db_jobs::create_tx( &mut tx, - jobs::NewJob { - kind: jobs::JobKind::UploadCleanup, + db_jobs::NewJob { + kind: db_jobs::JobKind::UploadCleanup, project_id: Some(project_id), stage: "queued".to_string(), progress_total: None, @@ -1531,7 +3423,7 @@ async fn audit_jobs_repository_uses_typed_payload_and_result() { .await .unwrap(); tx.commit().await.unwrap(); - let claimed = jobs::claim_next_for_ids_and_kinds( + let claimed = db_jobs::claim_next_for_ids_and_kinds( &pool, "typed-job-worker", 300, @@ -1543,11 +3435,11 @@ async fn audit_jobs_repository_uses_typed_payload_and_result() { .unwrap() .unwrap(); assert_eq!(claimed.id, created.id); - let completed = jobs::complete( + let completed = db_jobs::complete( &pool, created.id, "typed-job-worker", - jobs::JobResult::Completed, + db_jobs::JobResult::Completed, ) .await .unwrap() From 733e3d71f4c009839bdc54eb494167cc6e22975e Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Sat, 11 Jul 2026 16:02:21 +0800 Subject: [PATCH 13/45] feat: audit authentication and mutations --- README.en.md | 34 + README.md | 30 + backend/crates/prts-api/src/auth/extract.rs | 219 +- backend/crates/prts-api/src/auth/project.rs | 32 + backend/crates/prts-api/src/auth/session.rs | 703 ++++- backend/crates/prts-api/src/error.rs | 93 +- backend/crates/prts-api/src/job_retry.rs | 85 + backend/crates/prts-api/src/job_worker.rs | 14 +- backend/crates/prts-api/src/main.rs | 189 +- backend/crates/prts-api/src/routes/admin.rs | 59 +- .../prts-api/src/routes/admin_settings.rs | 45 +- backend/crates/prts-api/src/routes/auth.rs | 208 +- backend/crates/prts-api/src/routes/entries.rs | 202 +- backend/crates/prts-api/src/routes/files.rs | 129 +- backend/crates/prts-api/src/routes/jobs.rs | 115 +- .../crates/prts-api/src/routes/messages.rs | 43 +- backend/crates/prts-api/src/routes/mod.rs | 47 + .../prts-api/src/routes/notifications.rs | 83 +- .../crates/prts-api/src/routes/projects.rs | 220 +- backend/crates/prts-api/src/routes/users.rs | 108 +- .../prts-api/src/search_settings_worker.rs | 166 + backend/crates/prts-api/src/state.rs | 2 + .../crates/prts-api/tests/db_integration.rs | 2776 +++++++++++++++-- backend/crates/prts-auth/src/jwt.rs | 4 + backend/crates/prts-common/src/error.rs | 6 + backend/crates/prts-common/src/i18n.rs | 2 + backend/crates/prts-db/src/api_keys.rs | 70 +- backend/crates/prts-db/src/audit.rs | 660 ++++ backend/crates/prts-db/src/auth_sessions.rs | 120 +- backend/crates/prts-db/src/entries.rs | 86 +- backend/crates/prts-db/src/files.rs | 132 +- backend/crates/prts-db/src/memberships.rs | 56 +- backend/crates/prts-db/src/messages.rs | 31 +- backend/crates/prts-db/src/notifications.rs | 41 +- backend/crates/prts-db/src/projects.rs | 75 +- backend/crates/prts-db/src/search_settings.rs | 47 +- backend/crates/prts-db/src/settings.rs | 15 +- backend/crates/prts-db/src/users.rs | 100 +- 38 files changed, 6440 insertions(+), 607 deletions(-) create mode 100644 backend/crates/prts-api/src/job_retry.rs create mode 100644 backend/crates/prts-api/src/search_settings_worker.rs diff --git a/README.en.md b/README.en.md index 6f2ee20..a64bcff 100644 --- a/README.en.md +++ b/README.en.md @@ -54,6 +54,40 @@ non-superuser runtime login role, then configure both URLs as shown in `.env.exa fails closed if the roles are the same or the runtime role still owns tables. Keep real credentials only in local environment variables and never commit them. +**An existing volume does not rerun PostgreSQL init scripts.** The database and table owner in an +older PRTS volume is usually `prts`; changing `.env` to the new default `prts_migrator` does not +create that role. Choose one upgrade path: + +- Prefer reusing the real existing owner. Check `pg_database` / `pg_tables` first (the owner is + usually `prts`), then point `POSTGRES_MIGRATION_USER`, `POSTGRES_MIGRATION_PASSWORD`, and + `PRTS__DATABASE__MIGRATION_URL` at that **existing account which owns the old objects**. Keep the + runtime URL on the newly created `prts_runtime` role only. +- If a dedicated `prts_migrator` is required, connect to the target database as the old owner or a + database administrator, create only missing roles, set local passwords interactively with + `psql`'s `\password`, and transfer all ownership. Replace `prts` below if the actual old owner is + different: + + ```psql + CREATE ROLE prts_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + \password prts_migrator + CREATE ROLE prts_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + \password prts_runtime + REASSIGN OWNED BY prts TO prts_migrator; + ALTER DATABASE prts OWNER TO prts_migrator; + ALTER SCHEMA public OWNER TO prts_migrator; + ``` + +Only after that should the migration URL use `prts_migrator`. Never place real passwords in shell +history, either README, Compose files, or the repository; `\password` prompts interactively, and +the actual credentials belong only in the local `.env` / secret manager. + +### Authentication session upgrade notice + +This release rejects legacy access JWTs without a `sid` and makes PostgreSQL authoritative for +refresh sessions. Upgrading therefore invalidates all existing sessions, effectively signing +everyone out. Schedule a maintenance window and notify users before a production rollout. +Access is restored by signing in again after the upgrade; no account or password reset is needed. + Then: - Frontend: `http://localhost:8080` diff --git a/README.md b/README.md index 482d974..d515939 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,36 @@ docker compose -f deploy/docker-compose.yml up -d 数据库管理员创建同名的非 superuser runtime login role,再按 `.env.example` 设置两组 URL; 两者相同或 runtime 仍拥有表时,服务会 fail closed。真实凭据只写本地环境变量,不提交仓库。 +**已有卷不会重新执行 PostgreSQL init 脚本。** 旧版 PRTS 卷的数据库/表 owner 通常是 +`prts`,不能只把 `.env` 改成默认 `prts_migrator` 后指望该角色自动出现。升级时请选择一种: + +- 推荐就地沿用真实旧 owner:先查询 `pg_database` / `pg_tables` 确认 owner(通常为 `prts`), + 再把 `POSTGRES_MIGRATION_USER`、`POSTGRES_MIGRATION_PASSWORD` 和 + `PRTS__DATABASE__MIGRATION_URL` 指向这个**已存在且拥有旧对象**的账号;runtime URL 仍只使用 + 新建的 `prts_runtime`。 +- 如必须改为专用 `prts_migrator`,请以旧 owner 或数据库管理员连接目标数据库,只创建缺失 + role,使用 `psql` 的交互式 `\password` 设置本地密码,然后转移所有权(旧 owner 不是 + `prts` 时替换下列名称): + + ```psql + CREATE ROLE prts_migrator LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + \password prts_migrator + CREATE ROLE prts_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION; + \password prts_runtime + REASSIGN OWNED BY prts TO prts_migrator; + ALTER DATABASE prts OWNER TO prts_migrator; + ALTER SCHEMA public OWNER TO prts_migrator; + ``` + +完成后再让 migration URL 使用 `prts_migrator`。不要在 shell 历史、README、compose 文件或 +仓库内写真实密码;`\password` 会交互提示,实际凭据只保存在本地 `.env` / secret manager。 + +### 认证会话升级提示 + +本版本会拒绝旧版签发、未携带 `sid` 的 access JWT,且 refresh 会话改由 PostgreSQL 作为权威来源。 +因此升级会让所有现有会话失效,相当于一次全量登出。生产部署必须安排维护窗口并提前通知用户; +升级完成后,用户重新登录即可建立新会话并恢复访问,无需重置账号或密码。 + 启动后: - 前端:`http://localhost:8080` diff --git a/backend/crates/prts-api/src/auth/extract.rs b/backend/crates/prts-api/src/auth/extract.rs index 613aa20..5bb94bc 100644 --- a/backend/crates/prts-api/src/auth/extract.rs +++ b/backend/crates/prts-api/src/auth/extract.rs @@ -7,6 +7,9 @@ use axum::extract::FromRequestParts; use axum::http::request::Parts; use prts_common::Error; use prts_core::PlatformRole; +use prts_db::audit::{ + AuditActor, AuditActorKind, AuditEvent, AuthFailureMethod, AuthFailureReason, +}; use crate::error::ApiError; use crate::state::AppState; @@ -33,6 +36,18 @@ impl CurrentUser { } } +/// 已提供凭证的失败判定必须先完成脱敏审计,再返回原 401/403。 +async fn reject_presented_credential( + state: &AppState, + user_id: i64, + method: AuthFailureMethod, + reason: AuthFailureReason, + rejection: Error, +) -> Result { + crate::auth::session::record_auth_failure(state, user_id, method, reason).await?; + Err(rejection.into()) +} + impl FromRequestParts for CurrentUser { type Rejection = ApiError; @@ -46,40 +61,188 @@ impl FromRequestParts for CurrentUser { .copied() .unwrap_or_default(); let result: Result = async { - let token = parts - .headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) + let Some(authorization) = parts.headers.get(axum::http::header::AUTHORIZATION) else { + // 真正未提供凭证不是一次失败认证;MaybeUser 可安全降级为游客。 + return Err(Error::Unauthorized.into()); + }; + let token = match authorization + .to_str() + .ok() + .and_then(|header| header.strip_prefix("Bearer ")) .map(str::trim) - .filter(|t| !t.is_empty()) - .ok_or(Error::Unauthorized)?; + .filter(|token| !token.is_empty()) + { + Some(token) => token, + None => { + return reject_presented_credential( + state, + 0, + AuthFailureMethod::Authorization, + AuthFailureReason::InvalidCredential, + Error::Unauthorized, + ) + .await; + } + }; let user = if token.starts_with(prts_auth::token::API_KEY_PREFIX) { let hash = prts_auth::token::sha256_hex(token); let user = prts_db::api_keys::find_user_by_key_hash(&state.db, &hash) .await - .map_err(|e| Error::internal(format!("db error: {e}")))? - .ok_or(Error::Unauthorized)?; - // 记录最近使用(忽略失败)。 - let _ = prts_db::api_keys::touch_last_used(&state.db, &hash).await; + .map_err(|e| Error::internal(format!("db error: {e}")))?; + let Some(user) = user else { + return reject_presented_credential( + state, + 0, + AuthFailureMethod::ApiKey, + AuthFailureReason::InvalidCredential, + Error::Unauthorized, + ) + .await; + }; + if user.status != "active" { + return reject_presented_credential( + state, + user.id, + AuthFailureMethod::ApiKey, + AuthFailureReason::AccountInactive, + Error::Forbidden, + ) + .await; + } + let mut tx = state + .db + .begin() + .await + .map_err(|_| Error::AuditUnavailable)?; + let key = prts_db::api_keys::touch_last_used_tx(&mut tx, &hash) + .await + .map_err(|e| Error::internal(format!("db error: {e}")))?; + let Some(key) = key else { + tx.rollback() + .await + .map_err(|e| Error::internal(format!("db error: {e}")))?; + return reject_presented_credential( + state, + user.id, + AuthFailureMethod::ApiKey, + AuthFailureReason::InvalidCredential, + Error::Unauthorized, + ) + .await; + }; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::ApiKey, + ip: None, + }, + AuditEvent::ApiKeyUsed { + key_id: key.id, + prefix: &key.prefix, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(|_| Error::AuditUnavailable)?; user } else { - let claims = prts_auth::jwt::decode(token, state.jwt_secret()) - .map_err(|_| Error::Unauthorized)?; + let claims = match prts_auth::jwt::decode(token, state.jwt_secret()) { + Ok(claims) => claims, + Err(_) => { + return reject_presented_credential( + state, + 0, + AuthFailureMethod::Jwt, + AuthFailureReason::InvalidCredential, + Error::Unauthorized, + ) + .await; + } + }; + if claims.typ != "access" { + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::InvalidTokenType, + Error::Unauthorized, + ) + .await; + } if !claims.is_valid_at(chrono::Utc::now().timestamp()) { - return Err(Error::Unauthorized.into()); + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::TokenExpired, + Error::Unauthorized, + ) + .await; } - prts_db::users::find_by_id(&state.db, claims.sub) + let Some(session_handle) = claims.sid.as_deref() else { + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::MissingSession, + Error::Unauthorized, + ) + .await; + }; + let session = prts_db::auth_sessions::find_active_unexpired_by_handle( + &state.db, + session_handle, + ) + .await + .map_err(|e| Error::internal(format!("db error: {e}")))?; + let Some(session) = session else { + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::SessionInactive, + Error::Unauthorized, + ) + .await; + }; + if session.user_id != claims.sub { + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::UserMismatch, + Error::Unauthorized, + ) + .await; + } + let user = prts_db::users::find_by_id(&state.db, claims.sub) .await - .map_err(|e| Error::internal(format!("db error: {e}")))? - .ok_or(Error::Unauthorized)? + .map_err(|e| Error::internal(format!("db error: {e}")))?; + let Some(user) = user else { + return reject_presented_credential( + state, + claims.sub, + AuthFailureMethod::Jwt, + AuthFailureReason::UserNotFound, + Error::Unauthorized, + ) + .await; + }; + if user.status != "active" { + return reject_presented_credential( + state, + user.id, + AuthFailureMethod::Jwt, + AuthFailureReason::AccountInactive, + Error::Forbidden, + ) + .await; + } + user }; - if user.status != "active" { - return Err(Error::Forbidden.into()); - } - Ok(CurrentUser { id: user.id, platform_role: user.platform_role.as_deref().and_then(PlatformRole::parse), @@ -94,14 +257,20 @@ impl FromRequestParts for CurrentUser { pub struct MaybeUser(pub Option); impl FromRequestParts for MaybeUser { - type Rejection = std::convert::Infallible; + type Rejection = ApiError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { - Ok(MaybeUser( - CurrentUser::from_request_parts(parts, state).await.ok(), - )) + match CurrentUser::from_request_parts(parts, state).await { + Ok(user) => Ok(MaybeUser(Some(user))), + // 公开读取保持既有语义:缺少/无效凭证以及非 active 账号按游客处理。 + Err(error) if matches!(error.code(), "unauthorized" | "forbidden") => { + Ok(MaybeUser(None)) + } + // DB 与审计错误绝不能被吞掉;尤其有效 API key 的 touch + audit 必须 fail closed。 + Err(error) => Err(error), + } } } diff --git a/backend/crates/prts-api/src/auth/project.rs b/backend/crates/prts-api/src/auth/project.rs index 61ebe15..3e86936 100644 --- a/backend/crates/prts-api/src/auth/project.rs +++ b/backend/crates/prts-api/src/auth/project.rs @@ -3,6 +3,7 @@ use prts_common::Error; use prts_core::permission::nodes; use prts_core::{PlatformRole, ProjectRole}; +use sqlx::PgConnection; use crate::auth::CurrentUser; use crate::db_err; @@ -89,3 +90,34 @@ pub async fn load( platform_role, }) } + +/// 在已锁定项目行的调用方事务内重新加载授权快照。 +/// +/// 项目 mutation 可先在事务外做快速拒绝,再以本函数取得锁后的最新成员/平台角色, +/// 防止并发撤权后继续使用陈旧的 [`ProjectAccess`]。 +pub async fn load_locked_tx( + conn: &mut PgConnection, + user: &CurrentUser, + project: prts_db::models::Project, +) -> Result { + let current_user = prts_db::users::find_by_id_for_update_tx(conn, user.id) + .await + .map_err(db_err)? + .ok_or(Error::Unauthorized)?; + let project_role = prts_db::memberships::find_role_tx(conn, project.id, user.id) + .await + .map_err(db_err)? + .as_deref() + .and_then(ProjectRole::parse); + let platform_role = current_user + .platform_role + .as_deref() + .and_then(PlatformRole::parse); + + Ok(ProjectAccess { + project, + user_id: Some(user.id), + project_role, + platform_role, + }) +} diff --git a/backend/crates/prts-api/src/auth/session.rs b/backend/crates/prts-api/src/auth/session.rs index 94758eb..2d18b64 100644 --- a/backend/crates/prts-api/src/auth/session.rs +++ b/backend/crates/prts-api/src/auth/session.rs @@ -1,34 +1,83 @@ -//! 会话令牌:签发 access(JWT) + refresh(不透明,存 Redis,可吊销、刷新时轮换)。 +//! DB-authoritative 会话令牌与 durable Redis cache outbox。 use prts_auth::{jwt, token}; use prts_common::Error; +use prts_db::audit::{ + AuditActor, AuditActorKind, AuditEvent, AuthFailureMethod, AuthFailureReason, +}; +use prts_db::auth_sessions::{ + self, AuthIntentPayload, AuthSessionState, NewAuthSession, RefreshTokenHash, +}; +use sqlx::PgConnection; +use crate::dto::UserDto; use crate::state::AppState; +const AUTH_INTENT_LEASE_DURATION: std::time::Duration = std::time::Duration::from_secs(30); +const REDIS_OPERATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); +const AUTH_INTENT_MAX_ATTEMPTS: i32 = i32::MAX; + /// 一次签发的令牌对。 pub struct IssuedTokens { pub access_token: String, pub refresh_token: String, /// access token 剩余有效秒数。 pub access_expires_in: i64, - /// 令牌所属用户 id。 - pub user_id: i64, } -fn refresh_key(refresh_token: &str) -> String { - format!("refresh:{}", token::sha256_hex(refresh_token)) +/// Refresh rotation 的完整提交结果;用户字段已转换为无密对外 DTO。 +pub struct RefreshedSession { + pub tokens: IssuedTokens, + pub user: UserDto, +} + +/// 令牌签发来源,用于 action-specific 审计。 +#[derive(Debug, Clone, Copy)] +pub enum IssueKind<'a> { + Register, + Login, + OAuth { provider: &'a str, new_user: bool }, +} + +fn session_cache_key(session_handle: &str) -> String { + format!("auth_session:{session_handle}") } fn now() -> i64 { chrono::Utc::now().timestamp() } -fn redis_err(e: redis::RedisError) -> Error { - Error::internal(format!("redis error: {e}")) +fn database_error(error: sqlx::Error) -> Error { + Error::internal(format!("db error: {error}")) +} + +fn redis_err(error: redis::RedisError) -> Error { + Error::internal(format!("redis error: {error}")) +} + +fn auth_outbox_db_error_category(error: &sqlx::Error) -> &'static str { + match error { + sqlx::Error::Database(_) => "database", + sqlx::Error::Io(_) => "io", + sqlx::Error::Tls(_) => "tls", + sqlx::Error::Protocol(_) => "protocol", + sqlx::Error::PoolTimedOut => "pool_timeout", + sqlx::Error::PoolClosed => "pool_closed", + _ => "other", + } +} + +fn refresh_hash(raw_refresh_token: &str) -> RefreshTokenHash { + RefreshTokenHash::parse(token::sha256_hex(raw_refresh_token)) + .expect("sha256_hex always returns validated lowercase SHA-256") } -/// 为用户签发新的令牌对,并把 refresh 哈希写入 Redis(带 TTL)。 -pub async fn issue(state: &AppState, user_id: i64) -> Result { +fn build_tokens( + state: &AppState, + user_id: i64, + session_handle: String, + refresh_token: String, +) -> IssuedTokens { let auth = &state.settings.auth; let iat = now(); let claims = jwt::Claims { @@ -36,61 +85,585 @@ pub async fn issue(state: &AppState, user_id: i64) -> Result Result { - let key = refresh_key(refresh_token); - let mut conn = state.cache.clone(); +/// 在独立事务中签发令牌;DB commit 后才把 raw token 返回调用方。 +/// +/// 当前注册、登录和 OAuth route 为了把用户 mutation 纳入同一事务而调用 +/// [`issue_tx`];保留本入口供无需组合其它 mutation 的签发调用方复用。 +#[allow(dead_code)] +pub async fn issue( + state: &AppState, + user_id: i64, + kind: IssueKind<'_>, +) -> Result { + let mut tx = state.db.begin().await.map_err(database_error)?; + let tokens = issue_tx(&mut tx, state, user_id, kind).await?; + tx.commit().await.map_err(database_error)?; + Ok(tokens) +} - let user_id: Option = redis::cmd("GET") - .arg(&key) - .query_async(&mut conn) +/// 把 pending→active session、认证结果、token-issued audit 与 Redis outbox 写入调用方事务。 +pub async fn issue_tx( + conn: &mut PgConnection, + state: &AppState, + user_id: i64, + kind: IssueKind<'_>, +) -> Result { + let session_handle = token::random_token(24); + let family_handle = token::random_token(24); + let raw_refresh_token = format!("{session_handle}.{}", token::random_token(48)); + let expires_at = + chrono::Utc::now() + chrono::Duration::seconds(state.settings.auth.refresh_ttl_secs.max(1)); + let pending = auth_sessions::create_pending_tx( + &mut *conn, + NewAuthSession { + session_handle: session_handle.clone(), + family_handle, + user_id, + refresh_token_hash: refresh_hash(&raw_refresh_token), + expires_at, + predecessor_id: None, + }, + ) + .await + .map_err(database_error)?; + let active = auth_sessions::activate_pending_tx(&mut *conn, pending.id) .await - .map_err(redis_err)?; - let user_id = user_id.ok_or(Error::Unauthorized)?; + .map_err(database_error)? + .ok_or_else(|| Error::internal("failed to activate pending auth session"))?; - // 轮换:删除旧 refresh,签发新的。 - let _: () = redis::cmd("DEL") - .arg(&key) - .query_async(&mut conn) - .await - .map_err(redis_err)?; + let actor = AuditActor { + id: Some(user_id), + kind: AuditActorKind::User, + ip: None, + }; + match kind { + IssueKind::Register => { + prts_db::audit::append_event_tx( + &mut *conn, + actor, + AuditEvent::AuthRegistered { + user_id, + method: "password", + status: "active", + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + } + IssueKind::Login => { + prts_db::audit::append_event_tx( + &mut *conn, + actor, + AuditEvent::AuthLoginSucceeded { + user_id, + method: "password", + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + } + IssueKind::OAuth { provider, new_user } => { + prts_db::audit::append_event_tx( + &mut *conn, + actor, + AuditEvent::AuthOAuthSucceeded { + user_id, + provider, + new_user, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + } + } + let method = match kind { + IssueKind::Register | IssueKind::Login => "password", + IssueKind::OAuth { provider, .. } => provider, + }; + prts_db::audit::append_event_tx( + &mut *conn, + actor, + AuditEvent::AuthTokenIssued { + session_id: active.id, + session_handle: &active.session_handle, + method, + expires_at: active.expires_at, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + auth_sessions::enqueue_intent_tx( + conn, + active.id, + AuthIntentPayload::RedisPopulate { + session_handle: active.session_handle.clone(), + expires_at: active.expires_at, + }, + AUTH_INTENT_MAX_ATTEMPTS, + ) + .await + .map_err(database_error)?; - issue(state, user_id).await + Ok(build_tokens( + state, + user_id, + active.session_handle, + raw_refresh_token, + )) } -/// 吊销 refresh token(登出)。幂等。 +/// 用 refresh token 轮换出新令牌对;Redis 只作 cache hint,权威校验始终锁 DB hash。 +pub async fn refresh(state: &AppState, refresh_token: &str) -> Result { + let predecessor_hash = refresh_hash(refresh_token); + let known_user_id = auth_sessions::find_user_id_by_refresh_hash(&state.db, &predecessor_hash) + .await + .map_err(database_error)?; + let mut tx = state.db.begin().await.map_err(database_error)?; + let Some(predecessor) = + auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &predecessor_hash) + .await + .map_err(database_error)? + else { + tx.rollback().await.map_err(database_error)?; + record_auth_failure( + state, + known_user_id.unwrap_or(0), + AuthFailureMethod::Refresh, + AuthFailureReason::InvalidRefresh, + ) + .await?; + return Err(Error::Unauthorized); + }; + let rotating = auth_sessions::begin_rotation_tx(&mut tx, predecessor.id) + .await + .map_err(database_error)? + .ok_or_else(|| Error::internal("locked auth session could not enter rotation"))?; + let successor_handle = token::random_token(24); + let raw_successor = format!("{successor_handle}.{}", token::random_token(48)); + let expires_at = + chrono::Utc::now() + chrono::Duration::seconds(state.settings.auth.refresh_ttl_secs.max(1)); + let successor = auth_sessions::create_pending_tx( + &mut tx, + NewAuthSession { + session_handle: successor_handle, + family_handle: rotating.family_handle.clone(), + user_id: rotating.user_id, + refresh_token_hash: refresh_hash(&raw_successor), + expires_at, + predecessor_id: Some(rotating.id), + }, + ) + .await + .map_err(database_error)?; + let (revoked, active) = auth_sessions::complete_rotation_tx(&mut tx, rotating.id, successor.id) + .await + .map_err(database_error)? + .ok_or_else(|| Error::internal("auth session rotation invariant failed"))?; + let user = prts_db::users::find_by_id_for_update_tx(&mut tx, active.user_id) + .await + .map_err(database_error)? + .ok_or_else(|| Error::internal("issued auth session user no longer exists"))?; + let user = UserDto::from(&user); + let actor = AuditActor { + id: Some(active.user_id), + kind: AuditActorKind::User, + ip: None, + }; + prts_db::audit::append_event_tx( + &mut tx, + actor, + AuditEvent::AuthRefreshRotated { + session_id: active.id, + session_handle: &active.session_handle, + predecessor_handle: &revoked.session_handle, + expires_at: active.expires_at, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + prts_db::audit::append_event_tx( + &mut tx, + actor, + AuditEvent::AuthTokenIssued { + session_id: active.id, + session_handle: &active.session_handle, + method: "refresh", + expires_at: active.expires_at, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + auth_sessions::enqueue_intent_tx( + &mut tx, + revoked.id, + AuthIntentPayload::RedisInvalidate { + session_handle: revoked.session_handle.clone(), + }, + AUTH_INTENT_MAX_ATTEMPTS, + ) + .await + .map_err(database_error)?; + auth_sessions::enqueue_intent_tx( + &mut tx, + active.id, + AuthIntentPayload::RedisPopulate { + session_handle: active.session_handle.clone(), + expires_at: active.expires_at, + }, + AUTH_INTENT_MAX_ATTEMPTS, + ) + .await + .map_err(database_error)?; + tx.commit().await.map_err(database_error)?; + Ok(RefreshedSession { + tokens: build_tokens(state, active.user_id, active.session_handle, raw_successor), + user, + }) +} + +/// 吊销 refresh token。DB commit 即失效;Redis invalidate 由 durable outbox 重放。 pub async fn revoke(state: &AppState, refresh_token: &str) -> Result<(), Error> { - let mut conn = state.cache.clone(); - let _: () = redis::cmd("DEL") - .arg(refresh_key(refresh_token)) - .query_async(&mut conn) + let hash = refresh_hash(refresh_token); + let known_user_id = auth_sessions::find_user_id_by_refresh_hash(&state.db, &hash) .await - .map_err(redis_err)?; + .map_err(database_error)?; + let mut tx = state.db.begin().await.map_err(database_error)?; + let Some(active) = auth_sessions::lock_active_unexpired_by_refresh_hash_tx(&mut tx, &hash) + .await + .map_err(database_error)? + else { + tx.rollback().await.map_err(database_error)?; + record_auth_failure( + state, + known_user_id.unwrap_or(0), + AuthFailureMethod::Refresh, + AuthFailureReason::InvalidRefresh, + ) + .await?; + return Ok(()); + }; + let revoked = auth_sessions::revoke_unexpired_tx(&mut tx, active.id) + .await + .map_err(database_error)? + .ok_or_else(|| Error::internal("failed to revoke active auth session"))?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(revoked.user_id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::AuthLoggedOut { + session_id: revoked.id, + session_handle: &revoked.session_handle, + revoked_sessions: 1, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + auth_sessions::enqueue_intent_tx( + &mut tx, + revoked.id, + AuthIntentPayload::RedisInvalidate { + session_handle: revoked.session_handle, + }, + AUTH_INTENT_MAX_ATTEMPTS, + ) + .await + .map_err(database_error)?; + tx.commit().await.map_err(database_error)?; Ok(()) } +/// 同步记录失败认证;审计本身失败时返回 `AuditUnavailable`,不得暴露原认证结论。 +pub async fn record_failed_authentication( + state: &AppState, + user_id: i64, + method: &str, + reason_code: &str, +) -> Result<(), Error> { + let mut tx = state + .db + .begin() + .await + .map_err(|_| Error::AuditUnavailable)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: None, + kind: AuditActorKind::Anonymous, + ip: None, + }, + AuditEvent::AuthLoginFailed { + user_id, + method, + reason_code, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(|_| Error::AuditUnavailable) +} + +/// 同步记录已提供凭证的通用认证失败;审计链路任一步失败都优先返回 503。 +pub async fn record_auth_failure( + state: &AppState, + user_id: i64, + method: AuthFailureMethod, + reason: AuthFailureReason, +) -> Result<(), Error> { + let mut tx = state + .db + .begin() + .await + .map_err(|_| Error::AuditUnavailable)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: None, + kind: AuditActorKind::Anonymous, + ip: None, + }, + AuditEvent::AuthFailed { + user_id, + method, + reason, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(|_| Error::AuditUnavailable) +} + +/// 启动 durable Redis cache outbox worker;租约过期的 intent 会由其它实例接管。 +pub fn spawn_outbox_worker(db: prts_db::Db, cache: prts_db::Cache) -> tokio::task::JoinHandle<()> { + let worker_id = format!("auth-outbox-{}", token::random_token(12).to_lowercase()); + tokio::spawn(async move { + loop { + match process_one_outbox_intent(&db, &cache, &worker_id, None).await { + Ok(OutboxProcessOutcome::Idle) => {} + Ok(OutboxProcessOutcome::LostLease) => { + tracing::warn!(worker = %worker_id, "auth outbox worker lost intent lease"); + continue; + } + Ok( + OutboxProcessOutcome::Completed + | OutboxProcessOutcome::Rescheduled + | OutboxProcessOutcome::PermanentlyFailed, + ) => continue, + Err(error) => tracing::error!( + worker = %worker_id, + category = auth_outbox_db_error_category(&error), + "auth outbox iteration failed" + ), + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }) +} + +/// 单次 outbox 处理结果;任何 fencing 更新失败都显式报告 LostLease。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum OutboxProcessOutcome { + Idle, + Completed, + Rescheduled, + PermanentlyFailed, + LostLease, +} + +/// 处理一个 durable cache intent;可选 session 限定供分片 worker 与确定性恢复验证复用。 +pub(crate) async fn process_one_outbox_intent( + db: &prts_db::Db, + cache: &prts_db::Cache, + worker_id: &str, + session_id: Option, +) -> Result { + let claimed = match session_id { + Some(session_id) => { + auth_sessions::claim_intent_for_session( + db, + worker_id, + AUTH_INTENT_LEASE_DURATION.as_secs() as i64, + session_id, + ) + .await? + } + None => { + auth_sessions::claim_intent(db, worker_id, AUTH_INTENT_LEASE_DURATION.as_secs() as i64) + .await? + } + }; + let Some(intent) = claimed else { + return Ok(OutboxProcessOutcome::Idle); + }; + let Some(session_handle) = intent + .payload + .get("session_handle") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + else { + let updated = auth_sessions::fail_intent_permanently( + db, + intent.id, + worker_id, + "invalid_auth_intent", + "auth intent payload is invalid", + ) + .await?; + return Ok(if updated.is_some() { + OutboxProcessOutcome::PermanentlyFailed + } else { + OutboxProcessOutcome::LostLease + }); + }; + if !matches!(intent.kind.as_str(), "redis_populate" | "redis_invalidate") { + let updated = auth_sessions::fail_intent_permanently( + db, + intent.id, + worker_id, + "invalid_auth_intent", + "auth intent kind is invalid", + ) + .await?; + return Ok(if updated.is_some() { + OutboxProcessOutcome::PermanentlyFailed + } else { + OutboxProcessOutcome::LostLease + }); + } + + recover_incomplete_session(db, intent.session_id).await?; + let session = auth_sessions::find_by_id(db, intent.session_id).await?; + let redis_operation = async { + match intent.kind.as_str() { + "redis_populate" => { + if let Some(session) = session.filter(|session| { + session.state == AuthSessionState::Active + && session.expires_at > chrono::Utc::now() + }) { + let ttl = (session.expires_at - chrono::Utc::now()) + .num_seconds() + .max(1); + let mut connection = cache.clone(); + redis::cmd("SET") + .arg(session_cache_key(&session_handle)) + .arg(session.user_id) + .arg("EX") + .arg(ttl) + .query_async::<()>(&mut connection) + .await + } else { + let mut connection = cache.clone(); + redis::cmd("DEL") + .arg(session_cache_key(&session_handle)) + .query_async::<()>(&mut connection) + .await + } + } + "redis_invalidate" => { + let mut connection = cache.clone(); + redis::cmd("DEL") + .arg(session_cache_key(&session_handle)) + .query_async::<()>(&mut connection) + .await + } + _ => unreachable!("intent kind validated before Redis operation"), + } + }; + let redis_result = tokio::time::timeout(REDIS_OPERATION_TIMEOUT, redis_operation).await; + match redis_result { + Ok(Ok(())) => { + let updated = auth_sessions::complete_intent(db, intent.id, worker_id).await?; + Ok(if updated.is_some() { + OutboxProcessOutcome::Completed + } else { + OutboxProcessOutcome::LostLease + }) + } + Ok(Err(_)) | Err(_) => { + if intent.attempts >= intent.max_attempts { + let updated = auth_sessions::fail_intent_permanently( + db, + intent.id, + worker_id, + "auth_intent_attempts_exhausted", + "auth intent exhausted all attempts", + ) + .await?; + Ok(if updated.is_some() { + OutboxProcessOutcome::PermanentlyFailed + } else { + OutboxProcessOutcome::LostLease + }) + } else { + let updated = auth_sessions::reschedule_intent( + db, + intent.id, + worker_id, + "redis_unavailable", + "redis cache operation failed or timed out", + prts_core::jobs::retry_backoff_seconds(intent.attempts), + ) + .await?; + Ok(if updated.is_some() { + OutboxProcessOutcome::Rescheduled + } else { + OutboxProcessOutcome::LostLease + }) + } + } + } +} + +/// crash 后若发现持久化 pending/rotating,会话先 fail closed 到终态再处理 cache。 +async fn recover_incomplete_session(db: &prts_db::Db, session_id: i64) -> Result<(), sqlx::Error> { + let mut tx = db.begin().await?; + let Some(session) = auth_sessions::find_by_id_for_update_tx(&mut tx, session_id).await? else { + tx.rollback().await?; + return Ok(()); + }; + if !matches!( + session.state, + AuthSessionState::Pending | AuthSessionState::Rotating + ) { + tx.rollback().await?; + return Ok(()); + } + let previous_state = session.state.as_str(); + let recovered = if session.expires_at <= chrono::Utc::now() { + auth_sessions::expire_due_tx(&mut tx, session.id).await? + } else { + auth_sessions::revoke_unexpired_tx(&mut tx, session.id).await? + }; + if let Some(recovered) = recovered { + prts_db::audit::append_auth_session_state_tx( + &mut tx, + AuditActor { + id: None, + kind: AuditActorKind::System, + ip: None, + }, + recovered.id, + &recovered.session_handle, + previous_state, + recovered.state.as_str(), + ) + .await?; + } + tx.commit().await +} + /// 暂存 OAuth 流程的 `state → code_verifier`(带 TTL,默认 10 分钟)。 pub async fn store_oauth_state( state: &AppState, @@ -123,3 +696,41 @@ pub async fn take_oauth_state( .map_err(redis_err)?; Ok(verifier) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn outbox_worker_exposes_its_join_handle_for_supervision() { + let _spawn: fn(prts_db::Db, prts_db::Cache) -> tokio::task::JoinHandle<()> = + spawn_outbox_worker; + } + + #[test] + fn redis_timeout_is_strictly_shorter_than_the_intent_lease() { + assert!(REDIS_OPERATION_TIMEOUT < AUTH_INTENT_LEASE_DURATION); + } + + #[test] + fn outbox_database_errors_are_classified_without_source_text() { + let secret = "Authorization: Bearer RAW_SECRET"; + let error = sqlx::Error::Protocol(secret.to_string()); + let category = auth_outbox_db_error_category(&error); + assert_eq!(category, "protocol"); + assert!(!category.contains(secret)); + } + + #[test] + fn outbox_worker_log_does_not_format_raw_database_errors() { + let source = include_str!("session.rs"); + let worker = source + .split_once("pub fn spawn_outbox_worker") + .expect("worker exists") + .1 + .split_once("pub(crate) enum OutboxProcessOutcome") + .expect("worker block ends") + .0; + assert!(!worker.contains("%error")); + } +} diff --git a/backend/crates/prts-api/src/error.rs b/backend/crates/prts-api/src/error.rs index 248b931..a8a375c 100644 --- a/backend/crates/prts-api/src/error.rs +++ b/backend/crates/prts-api/src/error.rs @@ -4,7 +4,9 @@ //! 其中 `message` 经 [`prts_common::i18n`] 按请求语言本地化。 use axum::extract::FromRequestParts; +use axum::extract::Request; use axum::http::{request::Parts, StatusCode}; +use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::Json; use prts_common::i18n::{localize, Locale}; @@ -29,6 +31,11 @@ impl From for ApiError { } impl ApiError { + /// 稳定错误码,供同一 HTTP 边界内决定可否安全降级。 + pub(crate) fn code(&self) -> &'static str { + self.error.code() + } + /// 在路由边界覆盖 locale,保留稳定错误码。 pub fn with_locale(mut self, locale: Locale) -> Self { self.locale = locale; @@ -45,6 +52,12 @@ pub struct ErrorResponse { pub message: String, } +/// 传递给 route-boundary middleware 的稳定错误码。 +/// +/// 响应扩展不会写入网络;它只避免 middleware 反序列化已经构造好的 JSON body。 +#[derive(Debug, Clone, Copy)] +struct ApiErrorCode(&'static str); + /// 从 `Accept-Language` 提取请求语言,并写入 extensions 供鉴权 extractor 复用。 #[derive(Debug, Clone, Copy)] pub struct RequestLocale(pub Locale); @@ -74,6 +87,7 @@ fn status_for(code: &str) -> StatusCode { "forbidden" => StatusCode::FORBIDDEN, "not_found" => StatusCode::NOT_FOUND, "conflict" => StatusCode::CONFLICT, + "AUDIT_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE, _ => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -85,18 +99,95 @@ impl IntoResponse for ApiError { code: code.to_string(), message: localize(code, self.locale).to_string(), }; - (status_for(code), Json(body)).into_response() + let mut response = (status_for(code), Json(body)).into_response(); + response.extensions_mut().insert(ApiErrorCode(code)); + response + } +} + +/// 在 HTTP route 边界本地化 Task 1.2 的 fail-closed 审计错误。 +/// +/// 现有 handler 仍可直接被 DB 合同测试调用;真实 HTTP 请求则在这里统一读取 +/// `Accept-Language`。同时把 locale 放进 request extensions,让认证 extractor 无需 +/// 重复解析 header。除 `AUDIT_UNAVAILABLE` 外不重写其它 handler 响应。 +pub async fn localize_audit_errors(mut request: Request, next: Next) -> Response { + let locale = request + .headers() + .get(axum::http::header::ACCEPT_LANGUAGE) + .and_then(|value| value.to_str().ok()) + .map(Locale::from_accept_language) + .unwrap_or_default(); + request.extensions_mut().insert(locale); + + let response = next.run(request).await; + let is_audit_unavailable = response + .extensions() + .get::() + .is_some_and(|code| code.0 == "AUDIT_UNAVAILABLE"); + if !is_audit_unavailable { + return response; } + + let mut localized = ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ErrorResponse { + code: "AUDIT_UNAVAILABLE".to_string(), + message: localize("AUDIT_UNAVAILABLE", locale).to_string(), + }), + ) + .into_response(); + localized + .extensions_mut() + .insert(ApiErrorCode("AUDIT_UNAVAILABLE")); + localized } #[cfg(test)] mod tests { use super::*; + use axum::body::Body; + use axum::http::{header, Request}; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; #[test] fn maps_codes_to_status() { assert_eq!(status_for("not_found"), StatusCode::NOT_FOUND); assert_eq!(status_for("conflict"), StatusCode::CONFLICT); + assert_eq!( + status_for("AUDIT_UNAVAILABLE"), + StatusCode::SERVICE_UNAVAILABLE + ); assert_eq!(status_for("whatever"), StatusCode::INTERNAL_SERVER_ERROR); } + + /// 审计失败是本任务新增的 fail-closed 边界,必须遵循请求语言而非固定中文。 + #[tokio::test] + async fn audit_unavailable_uses_accept_language_at_route_boundary() { + async fn unavailable() -> ApiError { + Error::AuditUnavailable.into() + } + + let response = Router::new() + .route("/", get(unavailable)) + .layer(axum::middleware::from_fn(localize_audit_errors)) + .oneshot( + Request::builder() + .uri("/") + .header(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["code"], "AUDIT_UNAVAILABLE"); + assert_eq!(body["message"], "Audit service unavailable"); + } } diff --git a/backend/crates/prts-api/src/job_retry.rs b/backend/crates/prts-api/src/job_retry.rs new file mode 100644 index 0000000..6b4ad46 --- /dev/null +++ b/backend/crates/prts-api/src/job_retry.rs @@ -0,0 +1,85 @@ +//! 手动重试任务的事务编排。 +//! +//! 该模块固定 project→job 锁序,并在锁内重新加载权限;HTTP route 只负责协议映射。 + +use prts_common::Error; + +use crate::auth::{project as paccess, CurrentUser}; +use crate::db_err; +use crate::error::ApiError; +use crate::state::AppState; + +/// 手动重试失败任务,并将业务状态与 allowlisted 审计原子提交。 +pub(crate) async fn retry_job( + state: &AppState, + user: &CurrentUser, + id: i64, +) -> Result { + let snapshot = prts_db::jobs::find_by_id(&state.db, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + prts_core::jobs::job_view_policy(&snapshot.kind).ok_or(Error::NotFound)?; + let project_id = snapshot.project_id.ok_or(Error::NotFound)?; + + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, project_id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let access = paccess::load_locked_tx(&mut tx, user, project).await?; + let current = prts_db::jobs::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + if current.project_id != Some(project_id) + || prts_core::jobs::job_view_policy(¤t.kind).is_none() + { + return Err(Error::NotFound.into()); + } + access.require_view()?; + if !can_view_job(&access, user, ¤t.kind) { + return Err(Error::Forbidden.into()); + } + let retry_policy = prts_core::jobs::manual_retry_policy(¤t.kind) + .ok_or_else(|| Error::bad_request("manual retry is not supported for this job kind"))?; + if retry_policy.owner_only && access.project.owner_id != user.id { + return Err(Error::Forbidden.into()); + } + access.require_node(retry_policy.permission_node)?; + let state_value = prts_core::JobState::parse(¤t.state) + .ok_or_else(|| Error::internal("unknown job state persisted"))?; + if !state_value.manual_retry_allowed() { + return Err(Error::bad_request("job is not manually retryable").into()); + } + let updated = prts_db::jobs::manual_retry_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or_else(|| Error::bad_request("job state changed; retry rejected"))?; + prts_db::audit::append_job_retried_tx( + &mut tx, + prts_db::audit::AuditActor { + id: Some(user.id), + kind: prts_db::audit::AuditActorKind::User, + ip: None, + }, + updated.id, + updated.project_id, + &updated.kind, + current.attempts, + updated.attempts, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + state.job_worker.wake(); + Ok(updated) +} + +fn can_view_job(access: &paccess::ProjectAccess, user: &CurrentUser, kind: &str) -> bool { + match prts_core::jobs::job_view_policy(kind) { + Some(policy) if policy.owner_only => access.project.owner_id == user.id, + Some(policy) => access.has_node(policy.permission_node), + None => false, + } +} diff --git a/backend/crates/prts-api/src/job_worker.rs b/backend/crates/prts-api/src/job_worker.rs index 5aebd29..f594b6e 100644 --- a/backend/crates/prts-api/src/job_worker.rs +++ b/backend/crates/prts-api/src/job_worker.rs @@ -52,7 +52,7 @@ pub fn spawn( db: prts_db::Db, registry: JobRegistry, pending_deletions: Arc, -) -> JobWorkerControl { +) -> (JobWorkerControl, tokio::task::JoinHandle<()>) { let control = JobWorkerControl { notify: Arc::new(Notify::new()), }; @@ -61,7 +61,7 @@ pub fn spawn( "job-worker-{}", prts_auth::token::random_token(12).to_lowercase() ); - tokio::spawn(async move { + let handle = tokio::spawn(async move { loop { if registry.kinds().is_empty() { loop_control.wait().await; @@ -75,7 +75,7 @@ pub fn spawn( loop_control.wait().await; } }); - control + (control, handle) } async fn run_once( @@ -182,6 +182,14 @@ fn normalize_execution_error(error: JobExecutionError) -> (&'static str, String) mod tests { use super::*; + #[test] + fn spawn_exposes_control_and_join_handle_for_supervision() { + type PendingSource = Arc; + type SpawnedWorker = (JobWorkerControl, tokio::task::JoinHandle<()>); + type SpawnFn = fn(prts_db::Db, JobRegistry, PendingSource) -> SpawnedWorker; + let _spawn: SpawnFn = spawn; + } + #[test] fn handler_errors_are_stable_and_redacted_before_persistence() { let (code, message) = normalize_execution_error(JobExecutionError { diff --git a/backend/crates/prts-api/src/main.rs b/backend/crates/prts-api/src/main.rs index d2dc998..d98bd0f 100644 --- a/backend/crates/prts-api/src/main.rs +++ b/backend/crates/prts-api/src/main.rs @@ -9,10 +9,12 @@ mod auth; mod dto; mod embed_worker; mod error; +mod job_retry; mod job_worker; mod jobs; mod openapi; mod routes; +mod search_settings_worker; mod state; use std::sync::Arc; @@ -59,6 +61,10 @@ async fn main() -> anyhow::Result<()> { .await .map_err(|e| anyhow::anyhow!("connect redis failed: {e}"))?; + // DB 是会话权威;后台 outbox 只负责把 committed state 收敛到 Redis cache。 + let mut auth_outbox_worker = + crate::auth::session::spawn_outbox_worker(db.clone(), cache.clone()); + // bootstrap:把配置的用户名提升为 super_admin(若已存在且尚无平台角色)。 bootstrap_admin(&db, &settings).await; @@ -79,6 +85,9 @@ async fn main() -> anyhow::Result<()> { // 搜索运行时配置(从 settings 表加载,缺省默认)。 let search_cfg = prts_db::search_settings::get(&db).await.unwrap_or_default(); let search_rt = std::sync::Arc::new(tokio::sync::RwLock::new(search_cfg)); + let (search_settings_updater, mut search_settings_worker) = + crate::search_settings_worker::spawn(db.clone(), search_rt.clone()); + let search_settings_shutdown = search_settings_updater.clone(); // 实时协作 hub(启动 Redis 订阅中继)。 let realtime = prts_realtime::Hub::new(&settings.redis.url) @@ -90,7 +99,7 @@ async fn main() -> anyhow::Result<()> { // 持久化任务 worker。foundation 注册表为空时保持 dormant,后续阶段逐类注册 handler。 let job_registry = crate::jobs::JobRegistry::new(Vec::new()); - let job_worker = crate::job_worker::spawn( + let (job_worker, mut job_worker_task) = crate::job_worker::spawn( db.clone(), job_registry.clone(), Arc::new(crate::job_worker::NoPendingDeletions), @@ -105,17 +114,129 @@ async fn main() -> anyhow::Result<()> { realtime, embedder, search_rt, + search_settings_updater, job_worker, }; let app = routes::app(state); let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!("listening on http://{addr} (Swagger UI: /swagger-ui)"); - axum::serve(listener, app) - .with_graceful_shutdown(shutdown_signal()) - .await?; + let (server_shutdown_tx, server_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let mut server_shutdown_tx = Some(server_shutdown_tx); + let server = std::future::IntoFuture::into_future( + axum::serve(listener, app).with_graceful_shutdown(async move { + let _ = server_shutdown_rx.await; + }), + ); + tokio::pin!(server); - Ok(()) + tokio::select! { + result = &mut server => { + cancel_worker("auth outbox", &mut auth_outbox_worker).await; + cancel_worker("durable job", &mut job_worker_task).await; + drain_search_settings_worker( + &search_settings_shutdown, + &mut search_settings_worker, + ).await; + result?; + Ok(()) + } + _ = shutdown_signal() => { + if let Some(sender) = server_shutdown_tx.take() { + let _ = sender.send(()); + } + cancel_worker("auth outbox", &mut auth_outbox_worker).await; + cancel_worker("durable job", &mut job_worker_task).await; + server.as_mut().await?; + drain_search_settings_worker( + &search_settings_shutdown, + &mut search_settings_worker, + ).await; + Ok(()) + } + outcome = &mut auth_outbox_worker => { + let worker_error = worker_termination_error("auth outbox", outcome); + if let Some(sender) = server_shutdown_tx.take() { + let _ = sender.send(()); + } + cancel_worker("durable job", &mut job_worker_task).await; + if let Err(error) = server.as_mut().await { + tracing::error!(%error, "server shutdown after auth outbox failure failed"); + } + drain_search_settings_worker( + &search_settings_shutdown, + &mut search_settings_worker, + ).await; + Err(worker_error) + } + outcome = &mut job_worker_task => { + let worker_error = worker_termination_error("durable job", outcome); + if let Some(sender) = server_shutdown_tx.take() { + let _ = sender.send(()); + } + cancel_worker("auth outbox", &mut auth_outbox_worker).await; + if let Err(error) = server.as_mut().await { + tracing::error!(%error, "server shutdown after durable job failure failed"); + } + drain_search_settings_worker( + &search_settings_shutdown, + &mut search_settings_worker, + ).await; + Err(worker_error) + } + outcome = &mut search_settings_worker => { + let worker_error = worker_termination_error("search settings", outcome); + if let Some(sender) = server_shutdown_tx.take() { + let _ = sender.send(()); + } + cancel_worker("auth outbox", &mut auth_outbox_worker).await; + cancel_worker("durable job", &mut job_worker_task).await; + if let Err(error) = server.as_mut().await { + tracing::error!(%error, "server shutdown after search settings worker failure failed"); + } + Err(worker_error) + } + } +} + +/// 取消仍在运行的 worker 并等待其 JoinHandle 收敛,避免停机时遗留 detached task。 +async fn cancel_worker(name: &str, handle: &mut tokio::task::JoinHandle<()>) { + handle.abort(); + match handle.await { + Ok(()) => tracing::warn!( + worker = name, + "background worker exited before cancellation" + ), + Err(error) if error.is_cancelled() => {} + Err(error) => tracing::error!(worker = name, %error, "background worker join failed"), + } +} + +/// 停止接收新搜索设置更新,并排空所有已入队事务后再完成停机。 +async fn drain_search_settings_worker( + updater: &crate::search_settings_worker::SearchSettingsUpdater, + handle: &mut tokio::task::JoinHandle<()>, +) { + updater.shutdown(); + match handle.await { + Ok(()) => {} + Err(error) => tracing::error!( + worker = "search settings", + %error, + "background worker join failed" + ), + } +} + +fn worker_termination_error( + name: &str, + outcome: Result<(), tokio::task::JoinError>, +) -> anyhow::Error { + match outcome { + Ok(()) => anyhow::anyhow!("{name} worker exited unexpectedly"), + Err(error) if error.is_panic() => anyhow::anyhow!("{name} worker panicked: {error}"), + Err(error) => anyhow::anyhow!("{name} worker stopped unexpectedly: {error}"), + } } /// 用独立 migration owner 执行迁移后立即销毁连接;不会构造 AppState 或启动服务。 @@ -167,15 +288,41 @@ async fn bootstrap_admin(db: &prts_db::Db, settings: &Settings) { if target.is_empty() { return; } - match prts_db::users::find_by_username(db, target).await { - Ok(Some(u)) if u.platform_role.is_none() => { - if let Err(e) = prts_db::users::set_platform_role(db, u.id, Some("super_admin")).await { - tracing::warn!("bootstrap admin failed: {e}"); - } else { - tracing::info!(user = %u.username, "granted super_admin to bootstrap admin"); - } + let result: Result, sqlx::Error> = async { + let mut tx = db.begin().await?; + let Some(user) = prts_db::users::find_by_username_for_update_tx(&mut tx, target).await? + else { + tx.rollback().await?; + return Ok(None); + }; + if user.platform_role.is_some() { + tx.rollback().await?; + return Ok(None); + } + prts_db::users::set_platform_role_tx(&mut tx, user.id, Some("super_admin")).await?; + prts_db::audit::append_event_tx( + &mut tx, + prts_db::audit::AuditActor { + id: None, + kind: prts_db::audit::AuditActorKind::System, + ip: None, + }, + prts_db::audit::AuditEvent::AuthBootstrapRoleGranted { + user_id: user.id, + role: "super_admin", + }, + ) + .await?; + tx.commit().await?; + Ok(Some(user.username)) + } + .await; + match result { + Ok(Some(username)) => { + tracing::info!(user = %username, "granted super_admin to bootstrap admin"); } - _ => {} + Ok(None) => {} + Err(error) => tracing::warn!(%error, "bootstrap admin failed"), } } @@ -203,3 +350,19 @@ async fn shutdown_signal() { } tracing::info!("shutdown signal received"); } + +#[cfg(test)] +mod tests { + #[test] + fn session_breaking_rollout_is_documented_in_both_languages() { + let zh = include_str!("../../../../README.md"); + assert!(zh.contains("维护窗口")); + assert!(zh.contains("现有会话")); + assert!(zh.contains("重新登录")); + + let en = include_str!("../../../../README.en.md"); + assert!(en.contains("maintenance window")); + assert!(en.contains("existing sessions")); + assert!(en.contains("signing in again")); + } +} diff --git a/backend/crates/prts-api/src/routes/admin.rs b/backend/crates/prts-api/src/routes/admin.rs index b8b073e..76db9dd 100644 --- a/backend/crates/prts-api/src/routes/admin.rs +++ b/backend/crates/prts-api/src/routes/admin.rs @@ -11,10 +11,11 @@ use utoipa::ToSchema; use prts_common::Error; use prts_core::permission::nodes; use prts_core::PlatformRole; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::CurrentUser; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; /// 读取全部平台设置(key → 值)。 @@ -41,18 +42,38 @@ pub struct UpdateSettingsReq { /// 批量写入平台设置。 #[utoipa::path(put, path = "/admin/settings", tag = "admin", request_body = UpdateSettingsReq, - responses((status = 204), (status = 403)))] + responses( + (status = 204), + (status = 403), + (status = 503, description = "审计服务不可用,平台设置未更新", body = ErrorResponse) + ))] pub async fn update_settings( State(state): State, user: CurrentUser, Json(req): Json, ) -> Result { user.require_platform(nodes::PLATFORM_SETTINGS)?; - for (key, value) in req.settings { - prts_db::settings::set(&state.db, &key, &value, Some(user.id)) + let mut settings: Vec<_> = req.settings.into_iter().collect(); + settings.sort_by(|left, right| left.0.cmp(&right.0)); + let keys: Vec = settings.iter().map(|(key, _)| key.clone()).collect(); + let mut tx = state.db.begin().await.map_err(db_err)?; + for (key, value) in &settings { + prts_db::settings::set_tx(&mut tx, key, value, Some(user.id)) .await .map_err(db_err)?; } + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::SettingsUpdated { keys: &keys }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::NO_CONTENT) } @@ -64,7 +85,12 @@ pub struct GrantRoleReq { /// 任免平台角色(仅总管理员)。 #[utoipa::path(post, path = "/admin/users/{id}/role", tag = "admin", request_body = GrantRoleReq, - responses((status = 204), (status = 400), (status = 403)))] + responses( + (status = 204), + (status = 400), + (status = 403), + (status = 503, description = "审计服务不可用,平台角色未更新", body = ErrorResponse) + ))] pub async fn grant_role( State(state): State, user: CurrentUser, @@ -77,8 +103,29 @@ pub async fn grant_role( return Err(Error::bad_request("role 必须是 super_admin|admin|maintainer").into()); } } - prts_db::users::set_platform_role(&state.db, id, req.role.as_deref()) + let mut tx = state.db.begin().await.map_err(db_err)?; + let target = prts_db::users::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + prts_db::users::set_platform_role_tx(&mut tx, id, req.role.as_deref()) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::UserPlatformRoleChanged { + user_id: id, + previous_role: target.platform_role.as_deref(), + new_role: req.role.as_deref(), + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::NO_CONTENT) } diff --git a/backend/crates/prts-api/src/routes/admin_settings.rs b/backend/crates/prts-api/src/routes/admin_settings.rs index 5a41fbf..bd3c707 100644 --- a/backend/crates/prts-api/src/routes/admin_settings.rs +++ b/backend/crates/prts-api/src/routes/admin_settings.rs @@ -12,8 +12,7 @@ use prts_core::permission::nodes; use prts_db::search_settings::SearchConfig; use crate::auth::CurrentUser; -use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; // ============================= DTO ============================= @@ -120,7 +119,8 @@ pub async fn get_search_settings( (status = 200, description = "更新后的搜索配置", body = SearchSettingsDto), (status = 400, description = "请求体格式错误"), (status = 401, description = "未认证"), - (status = 403, description = "权限不足") + (status = 403, description = "权限不足"), + (status = 503, description = "审计服务不可用,搜索设置未更新", body = ErrorResponse) ) )] pub async fn put_search_settings( @@ -129,21 +129,30 @@ pub async fn put_search_settings( Json(body): Json, ) -> Result, ApiError> { user.require_platform(nodes::PLATFORM_SETTINGS)?; - - prts_db::search_settings::set(&state.db, body.into(), Some(user.id)) - .await - .map_err(db_err)?; - - // 重新读取(已规范化)并更新内存快照 - let fresh = prts_db::search_settings::get(&state.db) - .await - .map_err(db_err)?; - *state.search_rt.write().await = fresh.clone(); - - let embedding_key_present = state.settings.embedding.qwen.is_configured(); - + let updated = state + .search_settings_updater + .update(user.id, body.into()) + .await?; Ok(Json(SearchSettingsDto { - config: fresh.into(), - embedding_key_present, + config: updated.into(), + embedding_key_present: state.settings.embedding.qwen.is_configured(), })) } + +#[cfg(test)] +mod tests { + #[test] + fn put_search_settings_delegates_to_cancel_safe_worker() { + let source = include_str!("admin_settings.rs"); + let route = source + .split_once("pub async fn put_search_settings(") + .expect("PUT handler exists") + .1 + .split_once("#[cfg(test)]") + .expect("PUT handler ends before tests") + .0; + assert!(route.contains("search_settings_updater")); + assert!(!route.contains("state.db.begin")); + assert!(!route.contains("search_rt.write")); + } +} diff --git a/backend/crates/prts-api/src/routes/auth.rs b/backend/crates/prts-api/src/routes/auth.rs index 387bf81..b9cc73e 100644 --- a/backend/crates/prts-api/src/routes/auth.rs +++ b/backend/crates/prts-api/src/routes/auth.rs @@ -8,10 +8,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use prts_common::Error; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; +use sqlx::PgConnection; -use crate::auth::session::{self, IssuedTokens}; +use crate::auth::session::{self, IssueKind, IssuedTokens}; use crate::dto::UserDto; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; use crate::{appsettings, db_err}; @@ -28,13 +30,13 @@ pub struct TokenResponse { } impl TokenResponse { - fn build(tokens: IssuedTokens, user: &prts_db::models::User) -> Self { + fn build(tokens: IssuedTokens, user: UserDto) -> Self { Self { access_token: tokens.access_token, refresh_token: tokens.refresh_token, token_type: "Bearer".to_string(), expires_in: tokens.access_expires_in, - user: user.into(), + user, } } } @@ -57,6 +59,7 @@ pub struct RegisterReq { (status = 400, description = "参数错误 / 仅 OAuth 模式"), (status = 403, description = "注册已关闭"), (status = 409, description = "用户名或邮箱已存在"), + (status = 503, description = "审计服务不可用,注册与令牌签发均未提交", body = ErrorResponse), ) )] pub async fn register( @@ -101,13 +104,15 @@ pub async fn register( let hash = prts_auth::password::hash_password(&req.password) .map_err(|e| Error::internal(format!("hash error: {e}")))?; // P1:邮箱投递尚未接入,统一创建为 active(require_email_verification 的实际拦截待 SMTP 接入)。 - let user = prts_db::users::create_password_user(&state.db, username, email, &hash, "active") + let mut tx = state.db.begin().await.map_err(db_err)?; + let user = prts_db::users::create_password_user_tx(&mut tx, username, email, &hash, "active") .await .map_err(db_err)?; - let user = maybe_bootstrap_super_admin(&state, user).await?; + let user = maybe_bootstrap_super_admin_tx(&mut tx, &state, user).await?; - let tokens = session::issue(&state, user.id).await?; - Ok(Json(TokenResponse::build(tokens, &user))) + let tokens = session::issue_tx(&mut tx, &state, user.id, IssueKind::Register).await?; + tx.commit().await.map_err(db_err)?; + Ok(Json(TokenResponse::build(tokens, (&user).into()))) } /// 登录请求(`username` 可为用户名或邮箱)。 @@ -125,6 +130,7 @@ pub struct LoginReq { (status = 200, description = "登录成功", body = TokenResponse), (status = 401, description = "凭证错误"), (status = 403, description = "账号被禁用"), + (status = 503, description = "审计服务不可用,未返回认证结论或令牌", body = ErrorResponse), ) )] pub async fn login( @@ -136,22 +142,38 @@ pub async fn login( .map_err(db_err)? { Some(u) => u, - None => prts_db::users::find_by_email(&state.db, &req.username) + None => match prts_db::users::find_by_email(&state.db, &req.username) .await .map_err(db_err)? - .ok_or(Error::Unauthorized)?, + { + Some(user) => user, + None => { + session::record_failed_authentication(&state, 0, "password", "invalid_credentials") + .await?; + return Err(Error::Unauthorized.into()); + } + }, }; - let hash = user.password_hash.as_deref().ok_or(Error::Unauthorized)?; - if !prts_auth::password::verify_password(&req.password, hash) { + let password_matches = user + .password_hash + .as_deref() + .is_some_and(|hash| prts_auth::password::verify_password(&req.password, hash)); + if !password_matches { + session::record_failed_authentication(&state, user.id, "password", "invalid_credentials") + .await?; return Err(Error::Unauthorized.into()); } if user.status != "active" { + session::record_failed_authentication(&state, user.id, "password", "account_inactive") + .await?; return Err(Error::Forbidden.into()); } - let tokens = session::issue(&state, user.id).await?; - Ok(Json(TokenResponse::build(tokens, &user))) + let mut tx = state.db.begin().await.map_err(db_err)?; + let tokens = session::issue_tx(&mut tx, &state, user.id, IssueKind::Login).await?; + tx.commit().await.map_err(db_err)?; + Ok(Json(TokenResponse::build(tokens, (&user).into()))) } /// 刷新令牌请求。 @@ -167,25 +189,25 @@ pub struct RefreshReq { responses( (status = 200, description = "刷新成功", body = TokenResponse), (status = 401, description = "refresh 无效或已过期"), + (status = 503, description = "审计服务不可用,令牌轮换未提交", body = ErrorResponse), ) )] pub async fn refresh( State(state): State, Json(req): Json, ) -> Result, ApiError> { - let tokens = session::refresh(&state, &req.refresh_token).await?; - let user = prts_db::users::find_by_id(&state.db, tokens.user_id) - .await - .map_err(db_err)? - .ok_or(Error::Unauthorized)?; - Ok(Json(TokenResponse::build(tokens, &user))) + let refreshed = session::refresh(&state, &req.refresh_token).await?; + Ok(Json(TokenResponse::build(refreshed.tokens, refreshed.user))) } /// 登出(吊销 refresh token)。 #[utoipa::path( post, path = "/auth/logout", tag = "auth", request_body = RefreshReq, - responses((status = 204, description = "已登出")) + responses( + (status = 204, description = "已登出"), + (status = 503, description = "审计服务不可用,有效会话未被吊销", body = ErrorResponse) + ) )] pub async fn logout( State(state): State, @@ -244,7 +266,10 @@ pub struct OAuthCallbackQuery { /// 第三方登录回调:换取身份、登录或创建用户,并跳转回前端(令牌经 URL fragment 传递)。 #[utoipa::path( get, path = "/auth/oauth/{provider}/callback", tag = "auth", - responses((status = 303, description = "跳转回前端")) + responses( + (status = 303, description = "跳转回前端"), + (status = 503, description = "审计服务不可用,未返回 OAuth 认证结论或令牌", body = ErrorResponse) + ) )] pub async fn oauth_callback( State(state): State, @@ -256,53 +281,79 @@ pub async fn oauth_callback( return Err(Error::NotFound.into()); } if let Some(err) = q.error.as_deref() { + audit_oauth_failure(&state, &provider, "provider_error").await?; return Ok(Redirect::to(&format!("{base}/#/login?error={err}"))); } - let code = q.code.ok_or_else(|| Error::bad_request("缺少 code"))?; - let st = q.state.ok_or_else(|| Error::bad_request("缺少 state"))?; - let verifier = session::take_oauth_state(&state, &st) - .await? - .ok_or_else(|| Error::bad_request("state 无效或已过期"))?; + let Some(code) = q.code else { + audit_oauth_failure(&state, &provider, "missing_code").await?; + return Err(Error::bad_request("缺少 code").into()); + }; + let Some(st) = q.state else { + audit_oauth_failure(&state, &provider, "missing_state").await?; + return Err(Error::bad_request("缺少 state").into()); + }; + let Some(verifier) = session::take_oauth_state(&state, &st).await? else { + audit_oauth_failure(&state, &provider, "invalid_state").await?; + return Err(Error::bad_request("state 无效或已过期").into()); + }; - let zoot = state - .zoot_provider() - .ok_or_else(|| Error::bad_request("ZOOT 登录未配置"))?; - let identity = zoot - .complete(&code, &verifier) - .await - .map_err(|e| Error::internal(format!("oauth complete error: {e}")))?; + let Some(zoot) = state.zoot_provider() else { + audit_oauth_failure(&state, &provider, "provider_unavailable").await?; + return Err(Error::bad_request("ZOOT 登录未配置").into()); + }; + let identity = match zoot.complete(&code, &verifier).await { + Ok(identity) => identity, + Err(error) => { + audit_oauth_failure(&state, &provider, "exchange_failed").await?; + return Err(Error::internal(format!("oauth complete error: {error}")).into()); + } + }; - let user = match prts_db::users::find_by_external( - &state.db, - &identity.provider, - &identity.external_id, - ) - .await - .map_err(db_err)? - { - Some(u) => u, + let existing = + prts_db::users::find_by_external(&state.db, &identity.provider, &identity.external_id) + .await + .map_err(db_err)?; + let username = if existing.is_none() { + Some(unique_username(&state, &identity.username, &identity.external_id).await?) + } else { + None + }; + let mut tx = state.db.begin().await.map_err(db_err)?; + let (user, new_user) = match existing { + Some(user) => (user, false), None => { - let username = - unique_username(&state, &identity.username, &identity.external_id).await?; - prts_db::users::create_oauth_user( - &state.db, - &username, + let user = prts_db::users::create_oauth_user_tx( + &mut tx, + username.as_deref().expect("new OAuth user has username"), identity.avatar_url.as_deref(), &identity.provider, &identity.external_id, &identity.extra, ) .await - .map_err(db_err)? + .map_err(db_err)?; + (user, true) } }; if user.status != "active" { + tx.rollback().await.map_err(db_err)?; + audit_oauth_failure(&state, &provider, "account_inactive").await?; return Err(Error::Forbidden.into()); } - let tokens = session::issue(&state, user.id).await?; + let tokens = session::issue_tx( + &mut tx, + &state, + user.id, + IssueKind::OAuth { + provider: &identity.provider, + new_user, + }, + ) + .await?; + tx.commit().await.map_err(db_err)?; let redirect = format!( "{base}/#/oauth?access_token={}&refresh_token={}&expires_in={}", tokens.access_token, tokens.refresh_token, tokens.access_expires_in @@ -311,25 +362,64 @@ pub async fn oauth_callback( } /// 若用户名与配置的 bootstrap_admin 一致,则授予 super_admin 并返回更新后的用户。 -async fn maybe_bootstrap_super_admin( +async fn maybe_bootstrap_super_admin_tx( + conn: &mut PgConnection, state: &AppState, - user: prts_db::models::User, + mut user: prts_db::models::User, ) -> Result { let target = state.settings.auth.bootstrap_admin.trim(); if !target.is_empty() && user.username == target && user.platform_role.is_none() { - prts_db::users::set_platform_role(&state.db, user.id, Some("super_admin")) + prts_db::users::set_platform_role_tx(&mut *conn, user.id, Some("super_admin")) .await .map_err(db_err)?; - if let Some(updated) = prts_db::users::find_by_id(&state.db, user.id) - .await - .map_err(db_err)? - { - return Ok(updated); - } + prts_db::audit::append_event_tx( + conn, + AuditActor { + id: None, + kind: AuditActorKind::System, + ip: None, + }, + AuditEvent::AuthBootstrapRoleGranted { + user_id: user.id, + role: "super_admin", + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + user.platform_role = Some("super_admin".to_string()); } Ok(user) } +async fn audit_oauth_failure( + state: &AppState, + provider: &str, + reason_code: &str, +) -> Result<(), ApiError> { + let mut tx = state + .db + .begin() + .await + .map_err(|_| Error::AuditUnavailable)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: None, + kind: AuditActorKind::Anonymous, + ip: None, + }, + AuditEvent::AuthOAuthFailed { + target_id: provider, + provider, + reason_code, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(|_| Error::AuditUnavailable)?; + Ok(()) +} + /// 为 OAuth 新用户找一个未占用的用户名。 async fn unique_username( state: &AppState, diff --git a/backend/crates/prts-api/src/routes/entries.rs b/backend/crates/prts-api/src/routes/entries.rs index abcf162..d4441ff 100644 --- a/backend/crates/prts-api/src/routes/entries.rs +++ b/backend/crates/prts-api/src/routes/entries.rs @@ -14,10 +14,11 @@ use zip::write::SimpleFileOptions; use prts_common::Error; use prts_core::permission::{node_for_state, nodes}; use prts_core::EntryState; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::{project as paccess, CurrentUser, MaybeUser}; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; // ============================= 上传 ============================= @@ -67,7 +68,13 @@ pub struct UploadResult { /// 上传词条到项目(按路径自动建文件夹/文件)。需项目「上传」权限。 #[utoipa::path(post, path = "/projects/{id}/upload", tag = "entry", request_body = UploadReq, - responses((status = 200, body = UploadResult), (status = 400), (status = 403), (status = 404)))] + responses( + (status = 200, body = UploadResult), + (status = 400), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,上传未提交", body = ErrorResponse) + ))] pub async fn upload( State(state): State, user: CurrentUser, @@ -94,16 +101,43 @@ pub async fn upload( } } - let file = prts_db::files::ensure_file_at_path(&state.db, id, path) + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_node(nodes::PROJECT_FILE_UPLOAD)?; + let file = prts_db::files::ensure_file_at_path_tx(&mut tx, id, path) .await .map_err(db_err)?; let stats = - prts_db::entries::bulk_upsert(&state.db, file.id, id, &upload_entries, Some(user.id)) + prts_db::entries::bulk_upsert_tx(&mut tx, file.id, id, &upload_entries, Some(user.id)) .await .map_err(db_err)?; - prts_db::files::refresh_entry_count(&state.db, file.id) + prts_db::files::refresh_entry_count_tx(&mut tx, file.id) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::EntriesUploaded { + project_id: id, + file_id: file.id, + path: &file.path, + created: stats.created, + updated: stats.updated, + unchanged: stats.unchanged, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(Json(UploadResult { file_id: file.id, @@ -113,6 +147,25 @@ pub async fn upload( })) } +#[cfg(test)] +mod mutation_lock_tests { + #[test] + fn upload_locks_project_before_touching_file_tree() { + let source = include_str!("entries.rs"); + let upload = source + .split("// ============================= 词条") + .next() + .expect("upload section exists"); + let project_lock = upload + .find("projects::find_by_id_for_update_tx") + .expect("upload must lock its project row"); + let file_tree_write = upload + .find("files::ensure_file_at_path_tx") + .expect("upload file-tree write exists"); + assert!(project_lock < file_tree_write); + } +} + // ============================= 词条 ============================= /// 词条对外表示。 @@ -219,7 +272,13 @@ pub struct UpdateEntryReq { /// 更新词条译文与状态。按目标状态校验权限;锁定词条仅管理/拥有者可改;版本冲突返回 409。 #[utoipa::path(put, path = "/projects/{id}/entries/{entry_id}", tag = "entry", request_body = UpdateEntryReq, - responses((status = 200, body = EntryDto), (status = 403), (status = 404), (status = 409)))] + responses( + (status = 200, body = EntryDto), + (status = 403), + (status = 404), + (status = 409), + (status = 503, description = "审计服务不可用,词条更新未提交", body = ErrorResponse) + ))] pub async fn update_entry( State(state): State, user: CurrentUser, @@ -231,28 +290,31 @@ pub async fn update_entry( EntryState::parse(&req.state).ok_or_else(|| Error::bad_request("非法的目标状态"))?; access.require_node(node_for_state(target))?; - let entry = prts_db::entries::get(&state.db, id, entry_id) - .await - .map_err(db_err)? - .ok_or(Error::NotFound)?; - - if entry.locked { - let can = access - .effective_role() - .map(|r| r.can_edit_locked()) - .unwrap_or(false); - if !can { - return Err(Error::Forbidden.into()); - } - } - let kind = if matches!(target, EntryState::Checked | EntryState::Reviewed) { "review" } else { "edit" }; - let updated = prts_db::entries::update_translation( - &state.db, + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let access = paccess::load_locked_tx(&mut tx, &user, project).await?; + access.require_node(node_for_state(target))?; + let entry = prts_db::entries::get_for_update_tx(&mut tx, id, entry_id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + if entry.locked + && !access + .effective_role() + .is_some_and(|role| role.can_edit_locked()) + { + return Err(Error::Forbidden.into()); + } + let updated = prts_db::entries::update_translation_tx( + &mut tx, entry_id, req.version, &req.translation, @@ -265,6 +327,25 @@ pub async fn update_entry( match updated { Some(e) => { + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::EntryUpdated { + project_id: id, + entry_id: e.id, + previous_version: entry.version, + new_version: e.version, + previous_state: &entry.state, + new_state: &e.state, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; state .realtime .publish( @@ -291,7 +372,12 @@ pub struct SetFlagsReq { /// 设置词条锁定/隐藏。锁定需 `entry.lock`,隐藏需 `entry.hide`。 #[utoipa::path(patch, path = "/projects/{id}/entries/{entry_id}/flags", tag = "entry", request_body = SetFlagsReq, - responses((status = 200, body = EntryDto), (status = 403), (status = 404)))] + responses( + (status = 200, body = EntryDto), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,词条标志未更新", body = ErrorResponse) + ))] pub async fn set_entry_flags( State(state): State, user: CurrentUser, @@ -305,10 +391,43 @@ pub async fn set_entry_flags( if req.hidden.is_some() { access.require_node(nodes::PROJECT_ENTRY_HIDE)?; } - let updated = prts_db::entries::set_flags(&state.db, id, entry_id, req.locked, req.hidden) + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let access = paccess::load_locked_tx(&mut tx, &user, project).await?; + if req.locked.is_some() { + access.require_node(nodes::PROJECT_ENTRY_LOCK)?; + } + if req.hidden.is_some() { + access.require_node(nodes::PROJECT_ENTRY_HIDE)?; + } + prts_db::entries::get_for_update_tx(&mut tx, id, entry_id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let updated = prts_db::entries::set_flags_tx(&mut tx, id, entry_id, req.locked, req.hidden) .await .map_err(db_err)? .ok_or(Error::NotFound)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::EntryFlagsUpdated { + project_id: id, + entry_id, + locked: updated.locked, + hidden: updated.hidden, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; state .realtime .publish( @@ -373,7 +492,11 @@ pub async fn entry_history( /// 下载项目导出包(zip,保留目录结构,每文件含 key/original/translation)。 #[utoipa::path(get, path = "/projects/{id}/export", tag = "entry", - responses((status = 200, description = "application/zip"), (status = 404)))] + responses( + (status = 200, description = "application/zip"), + (status = 404), + (status = 503, description = "审计服务不可用,敏感导出未生成", body = ErrorResponse) + ))] pub async fn export_project( State(state): State, MaybeUser(user): MaybeUser, @@ -419,6 +542,33 @@ pub async fn export_project( let cursor = zw.finish().map_err(|e| Error::internal(e.to_string()))?; let bytes = cursor.into_inner(); + let audit_actor = match user.as_ref() { + Some(user) => AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + None => AuditActor { + id: None, + kind: AuditActorKind::Anonymous, + ip: None, + }, + }; + let mut tx = state.db.begin().await.map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + audit_actor, + AuditEvent::ProjectExported { + project_id: id, + file_count: files.len(), + entry_count: entries.len(), + include_hidden: false, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + let filename = format!("{}.zip", access.project.slug); let headers = [ (header::CONTENT_TYPE, "application/zip".to_string()), diff --git a/backend/crates/prts-api/src/routes/files.rs b/backend/crates/prts-api/src/routes/files.rs index ea8c8a8..1464ac6 100644 --- a/backend/crates/prts-api/src/routes/files.rs +++ b/backend/crates/prts-api/src/routes/files.rs @@ -7,10 +7,11 @@ use serde::Serialize; use utoipa::ToSchema; use prts_core::permission::nodes; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::{project as paccess, CurrentUser, MaybeUser}; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; /// 文件夹对外表示。 @@ -82,7 +83,12 @@ pub async fn get_tree( /// 删除文件(含其词条)。需项目「管理」权限。 #[utoipa::path(delete, path = "/projects/{id}/files/{file_id}", tag = "file", - responses((status = 204), (status = 403), (status = 404)))] + responses( + (status = 204), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,文件未删除", body = ErrorResponse) + ))] pub async fn delete_file( State(state): State, user: CurrentUser, @@ -90,19 +96,52 @@ pub async fn delete_file( ) -> Result { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_MANAGE)?; - if prts_db::files::delete_file(&state.db, id, file_id) + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(prts_common::Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_node(nodes::PROJECT_MANAGE)?; + let file = prts_db::files::find_file_for_update_tx(&mut tx, id, file_id) + .await + .map_err(db_err)? + .ok_or(prts_common::Error::NotFound)?; + if !prts_db::files::delete_file_tx(&mut tx, id, file_id) .await .map_err(db_err)? { - Ok(StatusCode::NO_CONTENT) - } else { - Err(prts_common::Error::NotFound.into()) + return Err(prts_common::Error::NotFound.into()); } + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::FileDeleted { + project_id: id, + file_id, + path: &file.path, + entry_count: file.entry_count, + }, + ) + .await + .map_err(|_| prts_common::Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + Ok(StatusCode::NO_CONTENT) } /// 删除文件夹(含其子项与词条)。需项目「管理」权限。 #[utoipa::path(delete, path = "/projects/{id}/folders/{folder_id}", tag = "file", - responses((status = 204), (status = 403), (status = 404)))] + responses( + (status = 204), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,文件夹未删除", body = ErrorResponse) + ))] pub async fn delete_folder( State(state): State, user: CurrentUser, @@ -110,12 +149,80 @@ pub async fn delete_folder( ) -> Result { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_MANAGE)?; - if prts_db::files::delete_folder(&state.db, id, folder_id) + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(prts_common::Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_node(nodes::PROJECT_MANAGE)?; + let folder = prts_db::files::find_folder_for_update_tx(&mut tx, id, folder_id) + .await + .map_err(db_err)? + .ok_or(prts_common::Error::NotFound)?; + let (file_count, entry_count) = + prts_db::files::folder_tree_counts_tx(&mut tx, id, &folder.path) + .await + .map_err(db_err)?; + if !prts_db::files::delete_folder_tx(&mut tx, id, folder_id) .await .map_err(db_err)? { - Ok(StatusCode::NO_CONTENT) - } else { - Err(prts_common::Error::NotFound.into()) + return Err(prts_common::Error::NotFound.into()); + } + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::FolderDeleted { + project_id: id, + folder_id, + path: &folder.path, + file_count, + entry_count, + }, + ) + .await + .map_err(|_| prts_common::Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod mutation_lock_tests { + fn function_body<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + source + .split_once(start) + .expect("function start exists") + .1 + .split_once(end) + .expect("function end exists") + .0 + } + + #[test] + fn file_and_folder_delete_lock_project_before_child_snapshots() { + let source = include_str!("files.rs"); + let file_delete = function_body( + source, + "pub async fn delete_file(", + "pub async fn delete_folder(", + ); + let folder_delete = function_body(source, "pub async fn delete_folder(", "#[cfg(test)]"); + + for (body, child_snapshot) in [ + (file_delete, "files::find_file_for_update_tx"), + (folder_delete, "files::find_folder_for_update_tx"), + ] { + let project_lock = body + .find("projects::find_by_id_for_update_tx") + .expect("delete must lock its project row"); + let child_lock = body.find(child_snapshot).expect("child snapshot exists"); + assert!(project_lock < child_lock); + } } } diff --git a/backend/crates/prts-api/src/routes/jobs.rs b/backend/crates/prts-api/src/routes/jobs.rs index 5659506..e85123e 100644 --- a/backend/crates/prts-api/src/routes/jobs.rs +++ b/backend/crates/prts-api/src/routes/jobs.rs @@ -210,7 +210,8 @@ pub async fn list_project_jobs( (status = 400, description = "当前状态不可重试", body = ErrorResponse), (status = 401, description = "必须登录", body = ErrorResponse), (status = 403, description = "缺少任务所属资源的业务权限", body = ErrorResponse), - (status = 404, description = "任务或所属资源不存在", body = ErrorResponse) + (status = 404, description = "任务或所属资源不存在", body = ErrorResponse), + (status = 503, description = "审计服务不可用,任务未重新排队", body = ErrorResponse) ) )] pub async fn retry_job( @@ -219,56 +220,10 @@ pub async fn retry_job( user: CurrentUser, Path(id): Path, ) -> Result, ApiError> { - let result: Result, ApiError> = async { - let mut tx = state.db.begin().await.map_err(db_err)?; - let current = prts_db::jobs::find_by_id_for_update_tx(&mut tx, id) - .await - .map_err(db_err)? - .ok_or(Error::NotFound)?; - prts_core::jobs::job_view_policy(¤t.kind).ok_or(Error::NotFound)?; - let project_id = current.project_id.ok_or(Error::NotFound)?; - let access = paccess::load(&state, Some(&user), project_id).await?; - access.require_view()?; - if !can_view_job(&access, &user, ¤t.kind) { - return Err(Error::Forbidden.into()); - } - let retry_policy = prts_core::jobs::manual_retry_policy(¤t.kind) - .ok_or_else(|| Error::bad_request("manual retry is not supported for this job kind"))?; - if retry_policy.owner_only && access.project.owner_id != user.id { - return Err(Error::Forbidden.into()); - } - access.require_node(retry_policy.permission_node)?; - let state_value = prts_core::JobState::parse(¤t.state) - .ok_or_else(|| Error::internal("unknown job state persisted"))?; - if !state_value.manual_retry_allowed() { - return Err(Error::bad_request("job is not manually retryable").into()); - } - let updated = prts_db::jobs::manual_retry_tx(&mut tx, id) - .await - .map_err(db_err)? - .ok_or_else(|| Error::bad_request("job state changed; retry rejected"))?; - prts_db::audit::append_job_retried_tx( - &mut tx, - prts_db::audit::AuditActor { - id: Some(user.id), - kind: prts_db::audit::AuditActorKind::User, - ip: None, - }, - updated.id, - updated.project_id, - &updated.kind, - current.attempts, - updated.attempts, - ) + crate::job_retry::retry_job(&state, &user, id) .await - .map_err(db_err)?; - tx.commit().await.map_err(db_err)?; - state.job_worker.wake(); - - Ok(Json(JobDto::from_job(updated, false))) - } - .await; - result.map_err(|error| error.with_locale(locale)) + .map(|job| Json(JobDto::from_job(job, false))) + .map_err(|error| error.with_locale(locale)) } /// 返回当前主体是否还具备手动重试能力;读取本身始终按所属资源鉴权。 @@ -394,11 +349,14 @@ mod db_tests { prts_common::config::Settings::load_from("__audit_jobs_missing_config__").unwrap(), ); let registry = crate::jobs::JobRegistry::new(Vec::new()); - let job_worker = crate::job_worker::spawn( + let (job_worker, _job_worker_handle) = crate::job_worker::spawn( db.clone(), registry, Arc::new(crate::job_worker::NoPendingDeletions), ); + let search_rt = Arc::new(tokio::sync::RwLock::new(Default::default())); + let (search_settings_updater, _search_settings_worker) = + crate::search_settings_worker::spawn(db.clone(), search_rt.clone()); AppState { db, cache, @@ -406,12 +364,38 @@ mod db_tests { zoot: Arc::new(None), realtime, embedder: Arc::new(None), - search_rt: Arc::new(tokio::sync::RwLock::new(Default::default())), + search_rt, + search_settings_updater, job_worker, } } - fn bearer(user_id: i64, state: &AppState) -> String { + /// 为 HTTP 路由测试创建真实 active DB session;sid=None 已是 presented-invalid credential。 + async fn bearer(user_id: i64, state: &AppState) -> String { + let session_handle = prts_auth::token::random_token(24); + let raw_refresh = format!("{session_handle}.{}", prts_auth::token::random_token(48)); + let mut tx = state.db.begin().await.unwrap(); + let pending = prts_db::auth_sessions::create_pending_tx( + &mut tx, + prts_db::auth_sessions::NewAuthSession { + session_handle: session_handle.clone(), + family_handle: prts_auth::token::random_token(24), + user_id, + refresh_token_hash: prts_db::auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex(&raw_refresh), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + prts_db::auth_sessions::activate_pending_tx(&mut tx, pending.id) + .await + .unwrap() + .unwrap(); + tx.commit().await.unwrap(); let now = chrono::Utc::now().timestamp(); let token = prts_auth::jwt::encode( &prts_auth::jwt::Claims { @@ -419,6 +403,7 @@ mod db_tests { iat: now, exp: now + 600, typ: "access".to_string(), + sid: Some(session_handle), }, state.jwt_secret(), ); @@ -603,7 +588,7 @@ mod db_tests { .oneshot( Request::builder() .uri("/jobs/9223372036854770000") - .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::AUTHORIZATION, bearer(owner.id, &state).await) .header(header::ACCEPT_LANGUAGE, "zh-CN") .body(Body::empty()) .unwrap(), @@ -621,7 +606,7 @@ mod db_tests { .oneshot( Request::builder() .uri(format!("/jobs/{unknown_id}")) - .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::AUTHORIZATION, bearer(owner.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -635,7 +620,7 @@ mod db_tests { Request::builder() .method("POST") .uri(format!("/jobs/{unknown_id}/retry")) - .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::AUTHORIZATION, bearer(owner.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -647,7 +632,7 @@ mod db_tests { Request::builder() .method("POST") .uri("/jobs/9223372036854770000/retry") - .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::AUTHORIZATION, bearer(owner.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -665,7 +650,7 @@ mod db_tests { .oneshot( Request::builder() .uri(format!("/projects/{}/jobs", project.id)) - .header(header::AUTHORIZATION, bearer(owner.id, &state)) + .header(header::AUTHORIZATION, bearer(owner.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -691,7 +676,7 @@ mod db_tests { "/projects/{}/jobs?kind=primary_source_lexical_reindex", project.id )) - .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::AUTHORIZATION, bearer(manager.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -704,7 +689,7 @@ mod db_tests { .oneshot( Request::builder() .uri(format!("/projects/{}/jobs?limit=1", project.id)) - .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::AUTHORIZATION, bearer(manager.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -721,7 +706,7 @@ mod db_tests { .oneshot( Request::builder() .uri(format!("/projects/{}/jobs?limit=0", project.id)) - .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::AUTHORIZATION, bearer(manager.id, &state).await) .header(header::ACCEPT_LANGUAGE, "en") .body(Body::empty()) .unwrap(), @@ -739,7 +724,7 @@ mod db_tests { .oneshot( Request::builder() .uri(format!("/projects/{}/jobs?limit=101", project.id)) - .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::AUTHORIZATION, bearer(manager.id, &state).await) .header(header::ACCEPT_LANGUAGE, "zh-CN") .body(Body::empty()) .unwrap(), @@ -761,7 +746,7 @@ mod db_tests { .oneshot( Request::builder() .uri(uri) - .header(header::AUTHORIZATION, bearer(outsider.id, &state)) + .header(header::AUTHORIZATION, bearer(outsider.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -776,7 +761,7 @@ mod db_tests { Request::builder() .method("POST") .uri(format!("/jobs/{primary_id}/retry")) - .header(header::AUTHORIZATION, bearer(admin.id, &state)) + .header(header::AUTHORIZATION, bearer(admin.id, &state).await) .body(Body::empty()) .unwrap(), ) @@ -790,7 +775,7 @@ mod db_tests { Request::builder() .method("POST") .uri(format!("/jobs/{upload_id}/retry")) - .header(header::AUTHORIZATION, bearer(manager.id, &state)) + .header(header::AUTHORIZATION, bearer(manager.id, &state).await) .body(Body::empty()) .unwrap(), ) diff --git a/backend/crates/prts-api/src/routes/messages.rs b/backend/crates/prts-api/src/routes/messages.rs index 8f6944c..fdeef00 100644 --- a/backend/crates/prts-api/src/routes/messages.rs +++ b/backend/crates/prts-api/src/routes/messages.rs @@ -21,11 +21,12 @@ use sqlx::PgPool; use utoipa::ToSchema; use prts_common::Error; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use prts_realtime::UserEvent; use crate::auth::CurrentUser; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; // ============================= DTO ============================= @@ -225,6 +226,7 @@ pub async fn conversation( (status = 400, description = "正文不合规或收件人为自己"), (status = 401, description = "未认证"), (status = 403, description = "与收件人无共享项目"), + (status = 503, description = "审计服务不可用,私信未发送", body = ErrorResponse), ) )] pub async fn send( @@ -255,9 +257,27 @@ pub async fn send( } // 落库。 - let m = prts_db::messages::create(&state.db, user.id, body.to_user_id, &content) + let content_length = content.chars().count(); + let mut tx = state.db.begin().await.map_err(db_err)?; + let m = prts_db::messages::create_tx(&mut tx, user.id, body.to_user_id, &content) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::MessageSent { + message_id: m.id, + recipient_id: body.to_user_id, + content_length, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; // 实时推送给收件人(跨实例经 Redis)。 state @@ -287,6 +307,7 @@ pub async fn send( responses( (status = 200, description = "标记成功"), (status = 401, description = "未认证"), + (status = 503, description = "审计服务不可用,会话状态未更新", body = ErrorResponse), ) )] pub async fn mark_read( @@ -294,8 +315,24 @@ pub async fn mark_read( user: CurrentUser, Path(other): Path, ) -> Result { - prts_db::messages::mark_read(&state.db, user.id, other) + let mut tx = state.db.begin().await.map_err(db_err)?; + let count = prts_db::messages::mark_read_tx(&mut tx, user.id, other) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::MessageMarkedRead { + other_user_id: other, + count, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::OK) } diff --git a/backend/crates/prts-api/src/routes/mod.rs b/backend/crates/prts-api/src/routes/mod.rs index 4adf155..8ec3da9 100644 --- a/backend/crates/prts-api/src/routes/mod.rs +++ b/backend/crates/prts-api/src/routes/mod.rs @@ -136,6 +136,10 @@ pub fn app(state: AppState) -> Router { // 用户通知流 WebSocket(不在 OpenAPI 文档内) .route("/ws/user", get(ws::user_ws_handler)) .fallback(handler_404) + // 仅统一 Task 1.2 的审计失败本地化,并向认证 extractor 提供请求 locale。 + .layer(axum::middleware::from_fn( + crate::error::localize_audit_errors, + )) // P0:CORS 放开便于联调;生产环境按需收紧(见 plan §15)。 .layer(CorsLayer::permissive()) .layer(CompressionLayer::new()) @@ -243,4 +247,47 @@ mod tests { } } } + + /// Task 1.2 的认证与受审计 mutation 都公开稳定的 fail-closed 503 schema。 + #[test] + fn audited_mutations_document_audit_unavailable() { + let (_, api) = api_router().split_for_parts(); + let document = serde_json::to_value(api).unwrap(); + for (path, method) in [ + ("/auth/register", "post"), + ("/auth/login", "post"), + ("/auth/refresh", "post"), + ("/auth/logout", "post"), + ("/auth/oauth/{provider}/callback", "get"), + ("/me", "put"), + ("/me/api-keys", "post"), + ("/me/api-keys/{id}", "delete"), + ("/admin/settings", "put"), + ("/admin/users/{id}/role", "post"), + ("/admin/settings/search", "put"), + ("/projects", "post"), + ("/projects/{id}", "put"), + ("/projects/{id}", "delete"), + ("/projects/{id}/members", "post"), + ("/projects/{id}/members/{user_id}", "delete"), + ("/projects/{id}/files/{file_id}", "delete"), + ("/projects/{id}/folders/{folder_id}", "delete"), + ("/projects/{id}/upload", "post"), + ("/projects/{id}/entries/{entry_id}", "put"), + ("/projects/{id}/entries/{entry_id}/flags", "patch"), + ("/projects/{id}/export", "get"), + ("/notifications/read", "post"), + ("/projects/{id}/poke", "post"), + ("/messages", "post"), + ("/messages/{user_id}/read", "post"), + ("/jobs/{id}/retry", "post"), + ] { + assert_eq!( + document["paths"][path][method]["responses"]["503"]["content"]["application/json"] + ["schema"]["$ref"], + "#/components/schemas/ErrorResponse", + "{method} {path} 应公开 AUDIT_UNAVAILABLE 的稳定错误 schema" + ); + } + } } diff --git a/backend/crates/prts-api/src/routes/notifications.rs b/backend/crates/prts-api/src/routes/notifications.rs index 0f3f096..d794333 100644 --- a/backend/crates/prts-api/src/routes/notifications.rs +++ b/backend/crates/prts-api/src/routes/notifications.rs @@ -13,11 +13,12 @@ use serde_json::json; use utoipa::ToSchema; use prts_common::Error; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use prts_realtime::UserEvent; use crate::auth::{project as paccess, CurrentUser}; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; // ============================= DTO ============================= @@ -137,6 +138,7 @@ pub struct MarkReadReq { responses( (status = 200, description = "标记成功"), (status = 401, description = "未认证"), + (status = 503, description = "审计服务不可用,通知状态未更新", body = ErrorResponse), ) )] pub async fn mark_read( @@ -145,9 +147,27 @@ pub async fn mark_read( Json(body): Json, ) -> Result { let ids = body.ids.unwrap_or_default(); - prts_db::notifications::mark_read(&state.db, user.id, &ids) + let mut tx = state.db.begin().await.map_err(db_err)?; + let count = prts_db::notifications::mark_read_tx(&mut tx, user.id, &ids) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::NotificationMarkedRead { + user_id: user.id, + notification_ids: &ids, + count, + all: ids.is_empty(), + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::OK) } @@ -177,6 +197,7 @@ pub struct PokeReq { (status = 401, description = "未认证"), (status = 403, description = "无权访问该项目"), (status = 404, description = "项目不存在"), + (status = 503, description = "审计服务不可用,poke 未发送", body = ErrorResponse), ) )] pub async fn poke( @@ -189,22 +210,6 @@ pub async fn poke( let access = paccess::load(&state, Some(&user), id).await?; access.require_view()?; - // 额外显式校验:发送者必须是项目成员(`require_view` 对公开项目不要求成员资格)。 - let sender_role = prts_db::memberships::find_role(&state.db, id, user.id) - .await - .map_err(db_err)?; - if sender_role.is_none() { - return Err(Error::bad_request("发送者不是该项目成员").into()); - } - - // 收件人必须是项目成员。 - let recipient_role = prts_db::memberships::find_role(&state.db, id, body.to_user_id) - .await - .map_err(db_err)?; - if recipient_role.is_none() { - return Err(Error::bad_request("收件人不是该项目成员").into()); - } - // 验证文本。 let text = body.text.trim().to_string(); if text.is_empty() { @@ -227,9 +232,49 @@ pub async fn poke( "project_id": id, "text": text, }); - let n = prts_db::notifications::create(&state.db, body.to_user_id, "poke", &payload) + let text_length = text.chars().count(); + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_view()?; + if prts_db::memberships::find_role_tx(&mut tx, id, user.id) + .await + .map_err(db_err)? + .is_none() + { + return Err(Error::bad_request("发送者不是该项目成员").into()); + } + if prts_db::memberships::find_role_tx(&mut tx, id, body.to_user_id) + .await + .map_err(db_err)? + .is_none() + { + return Err(Error::bad_request("收件人不是该项目成员").into()); + } + let n = prts_db::notifications::create_tx(&mut tx, body.to_user_id, "poke", &payload) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::PokeSent { + project_id: id, + recipient_id: body.to_user_id, + notification_id: n.id, + text_length, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; // 实时推送给收件人(跨实例经 Redis)。 state diff --git a/backend/crates/prts-api/src/routes/projects.rs b/backend/crates/prts-api/src/routes/projects.rs index 3d263ef..aa8606d 100644 --- a/backend/crates/prts-api/src/routes/projects.rs +++ b/backend/crates/prts-api/src/routes/projects.rs @@ -11,10 +11,11 @@ use utoipa::ToSchema; use prts_common::Error; use prts_core::permission::nodes; use prts_core::ProjectRole; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::{project as paccess, CurrentUser, MaybeUser}; use crate::db_err; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; /// 项目对外表示。 @@ -112,7 +113,12 @@ pub struct CreateProjectReq { /// 创建项目(创建者成为拥有者)。需平台「创建项目」权限。 #[utoipa::path(post, path = "/projects", tag = "project", request_body = CreateProjectReq, - responses((status = 200, body = ProjectDto), (status = 400), (status = 403)))] + responses( + (status = 200, body = ProjectDto), + (status = 400), + (status = 403), + (status = 503, description = "审计服务不可用,项目未创建", body = ErrorResponse) + ))] pub async fn create_project( State(state): State, user: CurrentUser, @@ -140,8 +146,9 @@ pub async fn create_project( _ => "public", }; - let project = prts_db::projects::create( - &state.db, + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::create_tx( + &mut tx, &slug, name, req.description.as_deref().unwrap_or(""), @@ -152,9 +159,27 @@ pub async fn create_project( ) .await .map_err(db_err)?; - prts_db::memberships::upsert(&state.db, project.id, user.id, "owner") + prts_db::memberships::upsert_tx(&mut tx, project.id, user.id, "owner") .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ProjectCreated { + project_id: project.id, + slug: &project.slug, + visibility: &project.visibility, + source_langs: &project.source_langs, + target_lang: &project.target_lang, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(Json((&project).into())) } @@ -233,7 +258,12 @@ pub struct UpdateProjectReq { /// 更新项目元信息。需项目「管理」权限。 #[utoipa::path(put, path = "/projects/{id}", tag = "project", request_body = UpdateProjectReq, - responses((status = 200, body = ProjectDto), (status = 403), (status = 404)))] + responses( + (status = 200, body = ProjectDto), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,项目未更新", body = ErrorResponse) + ))] pub async fn update_project( State(state): State, user: CurrentUser, @@ -242,30 +272,84 @@ pub async fn update_project( ) -> Result, ApiError> { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_MANAGE)?; - let p = &access.project; + let mut tx = state.db.begin().await.map_err(db_err)?; + let before = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, before.clone()) + .await? + .require_node(nodes::PROJECT_MANAGE)?; let visibility = match req.visibility.as_deref() { Some("private") => "private".to_string(), Some("public") => "public".to_string(), - _ => p.visibility.clone(), + _ => before.visibility.clone(), }; - let updated = prts_db::projects::update( - &state.db, + let name = req.name.unwrap_or_else(|| before.name.clone()); + let description = req + .description + .unwrap_or_else(|| before.description.clone()); + let source_langs = req + .source_langs + .unwrap_or_else(|| before.source_langs.clone()); + let target_lang = req + .target_lang + .unwrap_or_else(|| before.target_lang.clone()); + let mut changed_fields = Vec::with_capacity(5); + if name != before.name { + changed_fields.push("name"); + } + if description != before.description { + changed_fields.push("description"); + } + if visibility != before.visibility { + changed_fields.push("visibility"); + } + if source_langs != before.source_langs { + changed_fields.push("source_langs"); + } + if target_lang != before.target_lang { + changed_fields.push("target_lang"); + } + let updated = prts_db::projects::update_tx( + &mut tx, id, - req.name.as_deref().unwrap_or(&p.name), - req.description.as_deref().unwrap_or(&p.description), + &name, + &description, &visibility, - req.source_langs.as_deref().unwrap_or(&p.source_langs), - req.target_lang.as_deref().unwrap_or(&p.target_lang), + &source_langs, + &target_lang, ) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ProjectUpdated { + project_id: id, + changed_fields: &changed_fields, + visibility: &updated.visibility, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(Json((&updated).into())) } /// 删除项目。需项目「删除」权限。 #[utoipa::path(delete, path = "/projects/{id}", tag = "project", - responses((status = 204), (status = 403), (status = 404)))] + responses( + (status = 204), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,项目未删除", body = ErrorResponse) + ))] pub async fn delete_project( State(state): State, user: CurrentUser, @@ -273,9 +357,32 @@ pub async fn delete_project( ) -> Result { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_DELETE)?; - prts_db::projects::delete(&state.db, id) + let mut tx = state.db.begin().await.map_err(db_err)?; + let before = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, before.clone()) + .await? + .require_node(nodes::PROJECT_DELETE)?; + prts_db::projects::delete_tx(&mut tx, id) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ProjectDeleted { + project_id: id, + slug: &before.slug, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::NO_CONTENT) } @@ -326,7 +433,13 @@ pub struct AddMemberReq { /// 添加或更新项目成员。需项目「成员管理」权限。 #[utoipa::path(post, path = "/projects/{id}/members", tag = "project", request_body = AddMemberReq, - responses((status = 204), (status = 400), (status = 403), (status = 404)))] + responses( + (status = 204), + (status = 400), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,成员关系未更新", body = ErrorResponse) + ))] pub async fn add_member( State(state): State, user: CurrentUser, @@ -342,15 +455,49 @@ pub async fn add_member( .await .map_err(db_err)? .ok_or(Error::NotFound)?; - prts_db::memberships::upsert(&state.db, id, target.id, &req.role) + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_node(nodes::PROJECT_MEMBER_MANAGE)?; + let previous_role = prts_db::memberships::find_role_tx(&mut tx, id, target.id) + .await + .map_err(db_err)?; + prts_db::memberships::upsert_tx(&mut tx, id, target.id, &req.role) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::MembershipUpserted { + project_id: id, + member_id: target.id, + previous_role: previous_role.as_deref(), + new_role: &req.role, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::NO_CONTENT) } /// 移除项目成员。需项目「成员管理」权限。不可移除最后一个拥有者。 #[utoipa::path(delete, path = "/projects/{id}/members/{user_id}", tag = "project", - responses((status = 204), (status = 400), (status = 403), (status = 404)))] + responses( + (status = 204), + (status = 400), + (status = 403), + (status = 404), + (status = 503, description = "审计服务不可用,成员关系未移除", body = ErrorResponse) + ))] pub async fn remove_member( State(state): State, user: CurrentUser, @@ -359,13 +506,21 @@ pub async fn remove_member( let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_MEMBER_MANAGE)?; - // 不可移除最后一个 owner - if let Some(role) = prts_db::memberships::find_role(&state.db, id, user_id) + // 锁定项目会串行化 add/remove;即使目标 membership 尚不存在,也不会发生 gap race。 + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) .await .map_err(db_err)? - { + .ok_or(Error::NotFound)?; + paccess::load_locked_tx(&mut tx, &user, project) + .await? + .require_node(nodes::PROJECT_MEMBER_MANAGE)?; + let previous_role = prts_db::memberships::find_role_tx(&mut tx, id, user_id) + .await + .map_err(db_err)?; + if let Some(role) = previous_role.as_deref() { if role == "owner" - && prts_db::memberships::count_role(&state.db, id, "owner") + && prts_db::memberships::count_role_tx(&mut tx, id, "owner") .await .map_err(db_err)? <= 1 @@ -373,10 +528,27 @@ pub async fn remove_member( return Err(Error::bad_request("不能移除最后一个拥有者").into()); } } - if prts_db::memberships::remove(&state.db, id, user_id) + let previous_role = previous_role.ok_or(Error::NotFound)?; + if prts_db::memberships::remove_tx(&mut tx, id, user_id) .await .map_err(db_err)? { + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::MembershipRemoved { + project_id: id, + member_id: user_id, + previous_role: &previous_role, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(StatusCode::NO_CONTENT) } else { Err(Error::NotFound.into()) diff --git a/backend/crates/prts-api/src/routes/users.rs b/backend/crates/prts-api/src/routes/users.rs index bdf05aa..34795a1 100644 --- a/backend/crates/prts-api/src/routes/users.rs +++ b/backend/crates/prts-api/src/routes/users.rs @@ -7,11 +7,12 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use prts_common::Error; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::CurrentUser; use crate::db_err; use crate::dto::UserDto; -use crate::error::ApiError; +use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; /// 当前用户资料。 @@ -38,23 +39,42 @@ pub struct UpdateMeReq { /// 更新当前用户资料。 #[utoipa::path(put, path = "/me", tag = "user", request_body = UpdateMeReq, - responses((status = 200, body = UserDto), (status = 401)))] + responses( + (status = 200, body = UserDto), + (status = 401), + (status = 503, description = "审计服务不可用,资料更新未提交", body = ErrorResponse) + ))] pub async fn update_me( State(state): State, user: CurrentUser, Json(req): Json, ) -> Result, ApiError> { - let current = prts_db::users::find_by_id(&state.db, user.id) + let mut tx = state.db.begin().await.map_err(db_err)?; + let current = prts_db::users::find_by_id_for_update_tx(&mut tx, user.id) .await .map_err(db_err)? .ok_or(Error::Unauthorized)?; - let description = req.description.unwrap_or(current.description); - let avatar_url = req.avatar_url.or(current.avatar_url); - let langs = req.translation_langs.unwrap_or(current.translation_langs); + let description = req + .description + .unwrap_or_else(|| current.description.clone()); + let avatar_url = req.avatar_url.or_else(|| current.avatar_url.clone()); + let langs = req + .translation_langs + .unwrap_or_else(|| current.translation_langs.clone()); + let mut changed_fields = Vec::with_capacity(3); + if description != current.description { + changed_fields.push("description"); + } + if avatar_url != current.avatar_url { + changed_fields.push("avatar_url"); + } + if langs != current.translation_langs { + changed_fields.push("translation_langs"); + } - let updated = prts_db::users::update_profile( - &state.db, + let updated = prts_db::users::update_profile_tx( + &mut tx, user.id, &description, avatar_url.as_deref(), @@ -62,6 +82,22 @@ pub async fn update_me( ) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::UserProfileUpdated { + user_id: user.id, + changed_fields: &changed_fields, + translation_lang_count: langs.len(), + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(Json((&updated).into())) } @@ -143,7 +179,11 @@ pub struct CreatedApiKey { /// 创建 API Key。 #[utoipa::path(post, path = "/me/api-keys", tag = "user", request_body = CreateApiKeyReq, - responses((status = 200, body = CreatedApiKey), (status = 400)))] + responses( + (status = 200, body = CreatedApiKey), + (status = 400), + (status = 503, description = "审计服务不可用,API Key 未创建", body = ErrorResponse) + ))] pub async fn create_api_key( State(state): State, user: CurrentUser, @@ -154,9 +194,26 @@ pub async fn create_api_key( return Err(Error::bad_request("名称需为 1–64 字符").into()); } let key = prts_auth::token::generate_api_key(); - let rec = prts_db::api_keys::create(&state.db, user.id, name, &key.hash, &key.display_prefix) + let mut tx = state.db.begin().await.map_err(db_err)?; + let rec = prts_db::api_keys::create_tx(&mut tx, user.id, name, &key.hash, &key.display_prefix) .await .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ApiKeyCreated { + key_id: rec.id, + name: &rec.name, + prefix: &rec.prefix, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; Ok(Json(CreatedApiKey { id: rec.id, name: rec.name, @@ -191,18 +248,35 @@ pub async fn list_api_keys( /// 吊销一条 API Key。 #[utoipa::path(delete, path = "/me/api-keys/{id}", tag = "user", - responses((status = 204), (status = 404)))] + responses( + (status = 204), + (status = 404), + (status = 503, description = "审计服务不可用,API Key 未吊销", body = ErrorResponse) + ))] pub async fn revoke_api_key( State(state): State, user: CurrentUser, Path(id): Path, ) -> Result { - if prts_db::api_keys::revoke(&state.db, user.id, id) + let mut tx = state.db.begin().await.map_err(db_err)?; + let record = prts_db::api_keys::revoke_tx(&mut tx, user.id, id) .await .map_err(db_err)? - { - Ok(StatusCode::NO_CONTENT) - } else { - Err(Error::NotFound.into()) - } + .ok_or(Error::NotFound)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ApiKeyRevoked { + key_id: record.id, + prefix: &record.prefix, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + Ok(StatusCode::NO_CONTENT) } diff --git a/backend/crates/prts-api/src/search_settings_worker.rs b/backend/crates/prts-api/src/search_settings_worker.rs new file mode 100644 index 0000000..89bfebb --- /dev/null +++ b/backend/crates/prts-api/src/search_settings_worker.rs @@ -0,0 +1,166 @@ +//! 搜索运行时设置的单写者队列。 +//! +//! HTTP 请求只负责入队并等待结果。请求 future 即使被取消,worker 仍会完成已经接收的 +//! 数据库事务、审计与 runtime 发布,避免 commit 边界取消造成持久化与内存分叉。 + +use std::sync::Arc; + +use prts_common::Error; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; +use prts_db::search_settings::SearchConfig; + +#[derive(Clone)] +pub struct SearchSettingsUpdater { + sender: tokio::sync::mpsc::Sender, + shutdown: Arc>>>, +} + +struct UpdateRequest { + actor_id: i64, + config: SearchConfig, + reply: tokio::sync::oneshot::Sender>, +} + +impl SearchSettingsUpdater { + /// 将更新交给受监督的单写者 worker,并返回仅承载响应的接收端。 + /// + /// `send` 成功即表示 worker 拥有该更新;之后丢弃接收端不会取消更新。 + pub(crate) async fn enqueue( + &self, + actor_id: i64, + config: SearchConfig, + ) -> Result>, Error> { + let (reply, response) = tokio::sync::oneshot::channel(); + self.sender + .send(UpdateRequest { + actor_id, + config, + reply, + }) + .await + .map_err(|_| Error::internal("search settings worker unavailable"))?; + Ok(response) + } + + /// 入队并等待结果;请求取消仅丢弃响应接收端,不会撤回已入队更新。 + pub async fn update(&self, actor_id: i64, config: SearchConfig) -> Result { + self.enqueue(actor_id, config) + .await? + .await + .map_err(|_| Error::internal("search settings worker stopped"))? + } + + /// 停止接收新请求;worker 会继续排空已经成功入队的更新。 + pub(crate) fn shutdown(&self) { + if let Some(sender) = self + .shutdown + .lock() + .expect("search settings shutdown mutex poisoned") + .take() + { + let _ = sender.send(()); + } + } +} + +/// 启动搜索设置单写者;返回控制句柄与供主进程监督的任务句柄。 +pub fn spawn( + db: prts_db::Db, + runtime: Arc>, +) -> (SearchSettingsUpdater, tokio::task::JoinHandle<()>) { + let (sender, mut receiver) = tokio::sync::mpsc::channel::(32); + let (shutdown, mut shutdown_requested) = tokio::sync::oneshot::channel(); + let updater = SearchSettingsUpdater { + sender, + shutdown: Arc::new(std::sync::Mutex::new(Some(shutdown))), + }; + let handle = tokio::spawn(async move { + loop { + tokio::select! { + request = receiver.recv() => { + let Some(request) = request else { + break; + }; + process_request(&db, &runtime, request).await; + } + _ = &mut shutdown_requested => { + receiver.close(); + while let Some(request) = receiver.recv().await { + process_request(&db, &runtime, request).await; + } + break; + } + } + } + }); + (updater, handle) +} + +async fn process_request( + db: &prts_db::Db, + runtime: &tokio::sync::RwLock, + request: UpdateRequest, +) { + let result = apply_update(db, runtime, request.actor_id, request.config).await; + let _ = request.reply.send(result); +} + +async fn apply_update( + db: &prts_db::Db, + runtime: &tokio::sync::RwLock, + actor_id: i64, + config: SearchConfig, +) -> Result { + let mut tx = db.begin().await.map_err(database_error)?; + let change = prts_db::search_settings::set_tx(&mut tx, config, Some(actor_id)) + .await + .map_err(database_error)?; + let changed_fields = changed_fields(&change.before, &change.after); + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(actor_id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::SearchSettingsUpdated { + changed_fields: &changed_fields, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(database_error)?; + let mut runtime_config = runtime.write().await; + *runtime_config = change.after.clone(); + Ok(change.after) +} + +fn changed_fields(before: &SearchConfig, after: &SearchConfig) -> Vec<&'static str> { + let mut changed = Vec::with_capacity(7); + if before.embedding_enabled != after.embedding_enabled { + changed.push("embedding_enabled"); + } + if before.embedding_model != after.embedding_model { + changed.push("embedding_model"); + } + if before.embedding_base_url != after.embedding_base_url { + changed.push("embedding_base_url"); + } + if before.embedding_batch != after.embedding_batch { + changed.push("embedding_batch"); + } + if before.tm_enabled != after.tm_enabled { + changed.push("tm_enabled"); + } + if before.tm_min_similarity != after.tm_min_similarity { + changed.push("tm_min_similarity"); + } + if before.tm_top_n != after.tm_top_n { + changed.push("tm_top_n"); + } + changed +} + +fn database_error(error: sqlx::Error) -> Error { + Error::internal(format!("db error: {error}")) +} diff --git a/backend/crates/prts-api/src/state.rs b/backend/crates/prts-api/src/state.rs index 998f122..fa4d321 100644 --- a/backend/crates/prts-api/src/state.rs +++ b/backend/crates/prts-api/src/state.rs @@ -23,6 +23,8 @@ pub struct AppState { pub embedder: Arc>, /// 搜索运行时配置(管理后台可热改;worker / 查询每次读快照)。 pub search_rt: Arc>, + /// 受监督的搜索设置单写者;已入队更新不随 HTTP 请求取消而丢失。 + pub search_settings_updater: crate::search_settings_worker::SearchSettingsUpdater, /// durable worker 唤醒控制;业务事务提交后通知 worker 检查队列。 pub job_worker: crate::job_worker::JobWorkerControl, } diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index 418bbe1..ab1c65a 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -18,10 +18,14 @@ mod auth; mod dto; #[path = "../src/error.rs"] mod error; +#[path = "../src/job_retry.rs"] +mod job_retry; #[path = "../src/job_worker.rs"] mod job_worker; #[path = "../src/jobs/mod.rs"] mod jobs; +#[path = "../src/search_settings_worker.rs"] +mod search_settings_worker; #[path = "../src/state.rs"] mod state; @@ -68,6 +72,7 @@ use prts_db::{ }; static MIGRATED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); +static SEARCH_SETTINGS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); fn runtime_role() -> String { std::env::var("PRTS_TEST_RUNTIME_ROLE").unwrap_or_else(|_| "prts_runtime".to_string()) @@ -144,6 +149,32 @@ struct AuditedEntrypoint { allowed_payload_keys: &'static [&'static str], } +/// 每个稳定 action 的目标合同;与 route entrypoint 清单分离,且 action 必须唯一。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetIdPolicy { + Numeric, + CompositeNumeric, + Constant(&'static str), + OpaqueNonEmpty, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProjectSnapshotPolicy { + None, + SameAsNumericTarget, + Required, +} + +#[derive(Debug, Clone, Copy)] +struct AuditActionContract { + action: &'static str, + target_type: &'static str, + target_id_policy: TargetIdPolicy, + project_snapshot_policy: ProjectSnapshotPolicy, + /// 有多少 route/auth entrypoint 合法地产生该 action;用于显式容纳 `auth.failed` 多入口。 + expected_count: usize, +} + const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ AuditedEntrypoint { entrypoint: "routes::auth::register", @@ -160,16 +191,36 @@ const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ action: "auth.login_failed", allowed_payload_keys: &["method", "reason_code"], }, + AuditedEntrypoint { + entrypoint: "auth::extract::presented_failure", + action: "auth.failed", + allowed_payload_keys: &["method", "reason_code"], + }, + AuditedEntrypoint { + entrypoint: "routes::auth::refresh.failure", + action: "auth.failed", + allowed_payload_keys: &["method", "reason_code"], + }, AuditedEntrypoint { entrypoint: "routes::auth::refresh", action: "auth.refresh_rotated", allowed_payload_keys: &["session_handle", "predecessor_handle", "expires_at"], }, + AuditedEntrypoint { + entrypoint: "auth::session::refresh.token_issue", + action: "auth.token_issued", + allowed_payload_keys: &["session_handle", "method", "expires_at"], + }, AuditedEntrypoint { entrypoint: "routes::auth::logout", action: "auth.logged_out", allowed_payload_keys: &["session_handle", "revoked_sessions"], }, + AuditedEntrypoint { + entrypoint: "routes::auth::logout.noop", + action: "auth.failed", + allowed_payload_keys: &["method", "reason_code"], + }, AuditedEntrypoint { entrypoint: "routes::auth::oauth_callback.success", action: "auth.oauth_succeeded", @@ -310,6 +361,245 @@ const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ action: "message.marked_read", allowed_payload_keys: &["other_user_id", "count"], }, + AuditedEntrypoint { + entrypoint: "routes::jobs::retry_job", + action: "job.retried", + allowed_payload_keys: &["kind", "previous_attempts", "new_attempts"], + }, +]; + +const AUDIT_ACTION_CONTRACTS: &[AuditActionContract] = &[ + AuditActionContract { + action: "auth.registered", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.login_succeeded", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.login_failed", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.failed", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 3, + }, + AuditActionContract { + action: "auth.refresh_rotated", + target_type: "auth_session", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.logged_out", + target_type: "auth_session", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.oauth_succeeded", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.oauth_failed", + target_type: "oauth_identity", + target_id_policy: TargetIdPolicy::OpaqueNonEmpty, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "auth.token_issued", + target_type: "auth_session", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 2, + }, + AuditActionContract { + action: "auth.bootstrap_role_granted", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "user.profile_updated", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "api_key.created", + target_type: "api_key", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "api_key.used", + target_type: "api_key", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "api_key.revoked", + target_type: "api_key", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "settings.updated", + target_type: "settings", + target_id_policy: TargetIdPolicy::Constant("platform"), + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "search_settings.updated", + target_type: "settings", + target_id_policy: TargetIdPolicy::Constant("search.config"), + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "user.platform_role_changed", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "project.created", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, + AuditActionContract { + action: "project.updated", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, + AuditActionContract { + action: "project.deleted", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, + AuditActionContract { + action: "membership.upserted", + target_type: "membership", + target_id_policy: TargetIdPolicy::CompositeNumeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "membership.removed", + target_type: "membership", + target_id_policy: TargetIdPolicy::CompositeNumeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "entries.uploaded", + target_type: "file", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "file.deleted", + target_type: "file", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "folder.deleted", + target_type: "folder", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "entry.updated", + target_type: "entry", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "entry.flags_updated", + target_type: "entry", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "project.exported", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, + AuditActionContract { + action: "notification.marked_read", + target_type: "user", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "poke.sent", + target_type: "notification", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, + AuditActionContract { + action: "message.sent", + target_type: "message", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "message.marked_read", + target_type: "conversation", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, + AuditActionContract { + action: "job.retried", + target_type: "job", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::Required, + expected_count: 1, + }, ]; /// 现有被审计 repository writer。每项在 GREEN 后必须有 `&mut PgConnection`/`*_tx` @@ -342,6 +632,7 @@ const REPOSITORY_WRITERS: &[&str] = &[ "notifications::mark_read", "messages::create", "messages::mark_read", + "jobs::manual_retry", ]; /// DB-authoritative session 与 durable intent/outbox 的全部现有写边界。 @@ -359,7 +650,8 @@ const AUTH_SESSION_WRITERS: &[&str] = &[ "auth_sessions::claim_intent", "auth_sessions::renew_intent_lease", "auth_sessions::complete_intent", - "auth_sessions::fail_intent", + "auth_sessions::reschedule_intent", + "auth_sessions::fail_intent_permanently", "auth_sessions::retry_intent_tx", ]; @@ -391,16 +683,21 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { assert_eq!( REPOSITORY_WRITERS.len(), - 27, + 28, "repository writer inventory 发生漂移" ); assert_eq!( AUTH_SESSION_WRITERS.len(), - 15, + 16, "auth/session writer inventory 发生漂移" ); assert_eq!(UNAUDITED_READS.len(), 18, "普通读取 inventory 发生漂移"); - assert_eq!(AUDITED_ENTRYPOINTS.len(), 31, "审计入口 inventory 发生漂移"); + assert_eq!(AUDITED_ENTRYPOINTS.len(), 36, "审计入口 inventory 发生漂移"); + assert_eq!( + AUDIT_ACTION_CONTRACTS.len(), + 33, + "action 合同 inventory 发生漂移" + ); let writers: HashSet<_> = REPOSITORY_WRITERS.iter().copied().collect(); assert_eq!(writers.len(), REPOSITORY_WRITERS.len(), "writer 不得重复"); @@ -411,6 +708,27 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { assert_eq!(entrypoints.len(), AUDITED_ENTRYPOINTS.len()); let auth_writers: HashSet<_> = AUTH_SESSION_WRITERS.iter().copied().collect(); assert_eq!(auth_writers.len(), AUTH_SESSION_WRITERS.len()); + let actions: HashSet<_> = AUDIT_ACTION_CONTRACTS + .iter() + .map(|contract| contract.action) + .collect(); + assert_eq!( + actions.len(), + AUDIT_ACTION_CONTRACTS.len(), + "每个 action 必须只有一个目标合同" + ); + + for contract in AUDIT_ACTION_CONTRACTS { + let entrypoint_count = AUDITED_ENTRYPOINTS + .iter() + .filter(|entry| entry.action == contract.action) + .count(); + assert_eq!( + entrypoint_count, contract.expected_count, + "{} 的 entrypoint 数量与合同不符", + contract.action + ); + } for entry in AUDITED_ENTRYPOINTS { assert!( @@ -428,155 +746,1025 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { } } -#[test] -fn audit_contract_unavailable_error_is_stable_and_bilingual() { - use prts_common::i18n::{localize, Locale}; - - assert_eq!( - localize("AUDIT_UNAVAILABLE", Locale::ZhCn), - "审计服务暂不可用" - ); - assert_eq!( - localize("AUDIT_UNAVAILABLE", Locale::En), - "Audit service unavailable" - ); -} - -/// 规范化 payload key 后检查全部秘密与正文禁区。 -fn audit_contract_key_is_sensitive(key: &str) -> bool { - let normalized: String = key - .chars() - .filter(|character| character.is_ascii_alphanumeric()) - .flat_map(char::to_lowercase) - .collect(); - [ - "token", - "accesstoken", - "refreshtoken", - "authorization", - "password", - "passwordhash", - "hash", - "secret", - "clientsecret", - "apikey", - "apikeyhash", - "code", - "oauthcode", - "verifier", - "codeverifier", - "challengeanswer", - "raw", - "rawbody", - "body", - "original", - "translation", - "sourcetext", - "content", - "context", - "text", - ] - .iter() - .any(|forbidden| normalized == *forbidden) -} +/// 所有 audit before snapshot 都必须可在调用方事务内加锁读取,避免 route 先读后写竞态。 +#[tokio::test] +async fn audit_contract_repositories_expose_transaction_local_locked_snapshots() { + let state = audit_contract_state().await; + let mut tx = state.db.begin().await.unwrap(); -/// 真实 handler 测试共用的应用状态;DB/Redis 都使用 CI 提供的真实服务。 -async fn audit_contract_state() -> state::AppState { - ensure_migrated().await; - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); - let db = prts_db::connect_postgres(&database_url, 8) + let _ = users::find_by_id_for_update_tx(&mut tx, 0).await.unwrap(); + let _ = users::find_by_username_for_update_tx(&mut tx, "missing") .await - .expect("连接 runtime PostgreSQL"); - audit_contract_state_with_db(db).await -} - -/// 每条新连接都启用 test-only GUC;只有该 pool 的 audit INSERT 会被 fixture trigger 拒绝。 -async fn audit_contract_failing_audit_db() -> prts_db::Db { - ensure_migrated().await; - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); - let db = sqlx::postgres::PgPoolOptions::new() - .max_connections(8) - .after_connect(|connection, _metadata| { - Box::pin(async move { - sqlx::query("SELECT set_config('prts.test_fail_audit', 'on', false)") - .execute(connection) - .await?; - Ok(()) - }) - }) - .connect(&database_url) + .unwrap(); + let _ = projects::find_by_id_for_update_tx(&mut tx, 0) .await - .expect("连接审计故障专用 runtime pool"); - prts_db::verify_runtime_role(&db, &runtime_role()) + .unwrap(); + let _ = entries::get_for_update_tx(&mut tx, 0, 0).await.unwrap(); + let _ = memberships::find_role_tx(&mut tx, 0, 0).await.unwrap(); + let _ = memberships::count_role_tx(&mut tx, 0, "owner") .await - .expect("故障 pool 仍必须使用 runtime role"); - db -} - -/// 创建隔离 Redis ACL 用户,仅拒绝指定命令;用于真实 commit/Redis 边界故障。 -async fn audit_contract_restricted_redis( - admin_cache: &prts_db::Cache, - denied_command: &str, -) -> (prts_db::Cache, String) { - let suffix = audit_jobs_unique("acl") - .chars() - .filter(char::is_ascii_alphanumeric) - .collect::(); - let username = format!("audit{suffix}"); - let password = format!("Pass{suffix}"); - let mut admin = admin_cache.clone(); - let _: () = redis::cmd("ACL") - .arg("SETUSER") - .arg(&username) - .arg("reset") - .arg("on") - .arg(format!(">{password}")) - .arg("~*") - .arg("+@all") - .arg(format!("-{denied_command}")) - .query_async(&mut admin) + .unwrap(); + let _ = files::find_file_for_update_tx(&mut tx, 0, 0).await.unwrap(); + let _ = files::find_folder_for_update_tx(&mut tx, 0, 0) .await - .expect("创建隔离 Redis ACL 用户"); - let base = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); - let authority = base - .strip_prefix("redis://") - .expect("CI Redis URL 使用 redis://"); - let url = format!("redis://{username}:{password}@{authority}"); - let cache = prts_db::connect_redis(&url) + .unwrap(); + let _ = files::folder_tree_counts_tx(&mut tx, 0, "/").await.unwrap(); + let _ = prts_db::search_settings::get_for_update_tx(&mut tx) .await - .expect("连接受限 Redis ACL 用户"); - (cache, username) -} + .unwrap(); -async fn audit_contract_delete_redis_acl_user(admin_cache: &prts_db::Cache, username: &str) { - let mut admin = admin_cache.clone(); - let _: i64 = redis::cmd("ACL") - .arg("DELUSER") - .arg(username) - .query_async(&mut admin) - .await - .expect("清理隔离 Redis ACL 用户"); + tx.rollback().await.unwrap(); } -async fn audit_contract_state_with_db(db: prts_db::Db) -> state::AppState { - use std::sync::Arc; +/// 项目行锁必须串行化成员管理,即使目标 membership 尚不存在也不能绕过锁。 +#[tokio::test] +async fn audit_contract_project_lock_serializes_membership_upserts_for_missing_rows() { + let state = audit_contract_state().await; + let actor = audit_contract_create_user(&state.db, "audit-member-lock-owner", None).await; + let member = audit_contract_create_user(&state.db, "audit-member-lock-target", None).await; + let project = projects::create( + &state.db, + &format!("audit-member-lock-{}", actor.id), + "Audit member lock", + "", + "private", + &["en".to_string()], + "zh-Hans", + actor.id, + ) + .await + .unwrap(); - let redis_url = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); - let cache = prts_db::connect_redis(&redis_url) + let mut first = state.db.begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *first) .await - .expect("连接真实 Redis"); - let realtime = prts_realtime::Hub::new(&redis_url) + .unwrap(); + projects::find_by_id_for_update_tx(&mut first, project.id) .await - .expect("构造真实 realtime hub"); - let settings = Arc::new( - prts_common::config::Settings::load_from("__audit_contract_missing_config__") - .expect("加载测试配置"), - ); - let job_worker = job_worker::spawn( - db.clone(), - jobs::JobRegistry::new(Vec::new()), - Arc::new(job_worker::NoPendingDeletions), + .unwrap() + .unwrap(); + let second_pool = state.db.clone(); + let project_id = project.id; + let member_id = member.id; + let mut second = tokio::spawn(async move { + let mut tx = second_pool.begin().await.unwrap(); + projects::find_by_id_for_update_tx(&mut tx, project_id) + .await + .unwrap() + .unwrap(); + memberships::upsert_tx(&mut tx, project_id, member_id, "translator") + .await + .unwrap(); + tx.commit().await.unwrap(); + }); + audit_contract_wait_for_postgres_block( + &state.db, + blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &second, + "第二个成员写事务必须等待同一项目行锁", + ) + .await; + first.rollback().await.unwrap(); + second.await.unwrap(); + + projects::delete(&state.db, project.id).await.unwrap(); + sqlx::query("DELETE FROM users WHERE id = ANY($1::BIGINT[])") + .bind(&[actor.id, member.id][..]) + .execute(&state.db) + .await + .unwrap(); +} + +/// 两个真实搜索设置 PUT 必须按进入顺序提交并发布,最终 DB/runtime 同为后一次更新。 +#[tokio::test] +async fn audit_contract_concurrent_search_puts_keep_db_and_runtime_in_commit_order() { + use axum::extract::State; + use axum::Json; + use std::time::Duration; + + let _search_settings_guard = SEARCH_SETTINGS_TEST_LOCK.lock().await; + let state = audit_contract_state().await; + let actor = audit_contract_create_user( + &state.db, + "audit-search-settings-order", + Some("super_admin"), + ) + .await; + let previous = prts_db::search_settings::get(&state.db).await.unwrap(); + let first = prts_db::search_settings::SearchConfig { + embedding_enabled: false, + embedding_model: format!("audit-search-first-{}", actor.id), + embedding_base_url: "https://first.invalid/v1".to_string(), + embedding_batch: 4, + tm_enabled: true, + tm_min_similarity: 0.41, + tm_top_n: 1, + }; + let second = prts_db::search_settings::SearchConfig { + embedding_enabled: false, + embedding_model: format!("audit-search-second-{}", actor.id), + embedding_base_url: "https://second.invalid/v1".to_string(), + embedding_batch: 5, + tm_enabled: false, + tm_min_similarity: 0.52, + tm_top_n: 2, + }; + + let mut commit_blocker = state.db.begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *commit_blocker) + .await + .unwrap(); + prts_db::search_settings::get_for_update_tx(&mut commit_blocker) + .await + .unwrap(); + let first_put = { + let state = state.clone(); + let user = audit_contract_current_user(&actor); + let body = admin_settings_routes::SearchConfigDto { + embedding_enabled: first.embedding_enabled, + embedding_model: first.embedding_model.clone(), + embedding_base_url: first.embedding_base_url.clone(), + embedding_batch: first.embedding_batch, + tm_enabled: first.tm_enabled, + tm_min_similarity: first.tm_min_similarity, + tm_top_n: first.tm_top_n, + }; + tokio::spawn(async move { + admin_settings_routes::put_search_settings(State(state), user, Json(body)).await + }) + }; + audit_contract_wait_for_postgres_block( + &state.db, + blocker_pid, + "SELECT pg_advisory_xact_lock($1)", + &first_put, + "第一次 PUT 应等待 DB advisory lock", + ) + .await; + let second_put = tokio::spawn(admin_settings_routes::put_search_settings( + State(state.clone()), + audit_contract_current_user(&actor), + Json(admin_settings_routes::SearchConfigDto { + embedding_enabled: second.embedding_enabled, + embedding_model: second.embedding_model.clone(), + embedding_base_url: second.embedding_base_url.clone(), + embedding_batch: second.embedding_batch, + tm_enabled: second.tm_enabled, + tm_min_similarity: second.tm_min_similarity, + tm_top_n: second.tm_top_n, + }), + )); + commit_blocker.rollback().await.unwrap(); + + let Json(first_response) = tokio::time::timeout(Duration::from_secs(5), first_put) + .await + .expect("释放 advisory lock 后第一次 PUT 应完成") + .unwrap() + .expect_api("第一次搜索设置 PUT 成功"); + let Json(second_response) = tokio::time::timeout(Duration::from_secs(5), second_put) + .await + .expect("第一次发布完成后第二次 PUT 应完成") + .unwrap() + .expect_api("第二次搜索设置 PUT 成功"); + assert_eq!(first_response.config.embedding_model, first.embedding_model); + assert_eq!( + second_response.config.embedding_model, + second.embedding_model + ); + assert_eq!( + prts_db::search_settings::get(&state.db).await.unwrap(), + second + ); + assert_eq!(*state.search_rt.read().await, second); + + prts_db::search_settings::set(&state.db, previous.clone(), Some(actor.id)) + .await + .unwrap(); + *state.search_rt.write().await = previous; + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(actor.id) + .execute(&state.db) + .await + .unwrap(); +} + +/// 请求在 worker 已进入 DB 事务后取消,已入队更新仍必须完成 DB/audit/runtime 收敛。 +#[tokio::test] +async fn audit_contract_cancelled_search_put_still_converges_db_runtime_and_audit() { + use axum::extract::State; + use axum::Json; + use std::time::Duration; + + let _search_settings_guard = SEARCH_SETTINGS_TEST_LOCK.lock().await; + let state = audit_contract_state().await; + let actor = audit_contract_create_user( + &state.db, + "audit-search-settings-cancel", + Some("super_admin"), + ) + .await; + let before_db = prts_db::search_settings::get(&state.db).await.unwrap(); + let before_audit: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log + WHERE actor_id = $1 AND action = 'search_settings.updated'", + ) + .bind(actor.id) + .fetch_one(&state.db) + .await + .unwrap(); + + let expected = prts_db::search_settings::SearchConfig { + embedding_enabled: false, + embedding_model: format!("cancelled-search-{}", actor.id), + embedding_base_url: "https://cancelled.invalid/v1".to_string(), + embedding_batch: 2, + tm_enabled: false, + tm_min_similarity: 0.77, + tm_top_n: 1, + }; + let mut commit_blocker = state.db.begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *commit_blocker) + .await + .unwrap(); + prts_db::search_settings::get_for_update_tx(&mut commit_blocker) + .await + .unwrap(); + let response = state + .search_settings_updater + .enqueue(actor.id, expected.clone()) + .await + .expect("更新成功入队"); + let response_waiter = tokio::spawn(response); + audit_contract_wait_for_postgres_block( + &state.db, + blocker_pid, + "SELECT pg_advisory_xact_lock($1)", + &response_waiter, + "搜索设置 worker 应已进入 DB 事务并等待 advisory lock", + ) + .await; + let runtime_read = tokio::time::timeout(Duration::from_millis(100), state.search_rt.read()) + .await + .expect("DB 锁竞争期间 runtime 读取不得被搜索设置事务阻塞"); + drop(runtime_read); + response_waiter.abort(); + let waiter_error = response_waiter.await.expect_err("模拟请求等待方必须已取消"); + assert!(waiter_error.is_cancelled()); + commit_blocker.rollback().await.unwrap(); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if prts_db::search_settings::get(&state.db).await.unwrap() == expected + && *state.search_rt.read().await == expected + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("接收端丢弃后 worker 仍应完成 DB/runtime 发布"); + let after_audit: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_log + WHERE actor_id = $1 AND action = 'search_settings.updated'", + ) + .bind(actor.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(after_audit, before_audit + 1); + + prts_db::search_settings::set(&state.db, before_db.clone(), Some(actor.id)) + .await + .unwrap(); + *state.search_rt.write().await = before_db; + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(actor.id) + .execute(&state.db) + .await + .unwrap(); +} + +/// 所有现有项目业务 mutation 都必须在项目锁后、业务写前重新加载授权快照。 +#[test] +fn audit_contract_project_mutations_reauthorize_inside_locked_transaction() { + fn function_body<'a>(source: &'a str, start: &str, end: Option<&str>) -> &'a str { + let tail = source + .split_once(start) + .unwrap_or_else(|| panic!("缺少函数 {start}")) + .1; + end.map_or(tail, |end| { + tail.split_once(end) + .unwrap_or_else(|| panic!("缺少函数结束标记 {end}")) + .0 + }) + } + + let projects_source = include_str!("../src/routes/projects.rs"); + let entries_source = include_str!("../src/routes/entries.rs"); + let files_source = include_str!("../src/routes/files.rs"); + let notifications_source = include_str!("../src/routes/notifications.rs"); + let job_retry_source = include_str!("../src/job_retry.rs"); + for (body, writer) in [ + ( + function_body( + entries_source, + "pub async fn upload(", + Some("// ============================= 词条"), + ), + "entries::bulk_upsert_tx", + ), + ( + function_body( + projects_source, + "pub async fn update_project(", + Some("/// 删除项目"), + ), + "projects::update_tx", + ), + ( + function_body( + projects_source, + "pub async fn delete_project(", + Some("/// 成员对外表示"), + ), + "projects::delete_tx", + ), + ( + function_body( + projects_source, + "pub async fn add_member(", + Some("/// 移除项目成员"), + ), + "memberships::upsert_tx", + ), + ( + function_body(projects_source, "pub async fn remove_member(", None), + "memberships::remove_tx", + ), + ( + function_body( + entries_source, + "pub async fn update_entry(", + Some("/// 设置标志请求"), + ), + "entries::update_translation_tx", + ), + ( + function_body( + entries_source, + "pub async fn set_entry_flags(", + Some("/// 词条历史项"), + ), + "entries::set_flags_tx", + ), + ( + function_body( + files_source, + "pub async fn delete_file(", + Some("/// 删除文件夹"), + ), + "files::delete_file_tx", + ), + ( + function_body( + files_source, + "pub async fn delete_folder(", + Some("#[cfg(test)]"), + ), + "files::delete_folder_tx", + ), + ( + function_body(notifications_source, "pub async fn poke(", None), + "notifications::create_tx", + ), + ( + function_body(job_retry_source, "pub(crate) async fn retry_job(", None), + "jobs::manual_retry_tx", + ), + ] { + let project_lock = body + .find("projects::find_by_id_for_update_tx") + .expect("项目 mutation 必须先锁项目"); + let reauthorization = body + .find("paccess::load_locked_tx") + .expect("项目 mutation 必须在锁后重验权限"); + let business_write = body.find(writer).expect("项目 mutation 业务 writer 存在"); + assert!(project_lock < reauthorization); + assert!(reauthorization < business_write); + } +} + +/// 上传、文件删除与文件夹删除都必须等待项目行锁;文件夹审计计数对应实际级联子树。 +#[tokio::test] +async fn audit_contract_file_tree_routes_share_project_lock_and_count_deleted_subtree() { + use axum::extract::{Path, State}; + use axum::Json; + + let state = audit_contract_state().await; + let actor = + audit_contract_create_user(&state.db, "audit-file-tree-lock", Some("maintainer")).await; + let Json(project) = projects_routes::create_project( + State(state.clone()), + audit_contract_current_user(&actor), + Json(projects_routes::CreateProjectReq { + name: format!("Audit file tree lock {}", actor.id), + slug: Some(format!("audit-file-tree-lock-{}", actor.id)), + description: None, + visibility: Some("private".to_string()), + source_langs: vec!["en".to_string()], + target_lang: "zh-Hans".to_string(), + }), + ) + .await + .expect_api("创建文件树锁测试项目"); + + let mut upload_blocker = state.db.begin().await.unwrap(); + let upload_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *upload_blocker) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut upload_blocker, project.id) + .await + .unwrap() + .unwrap(); + let blocked_upload = { + let state = state.clone(); + let user = audit_contract_current_user(&actor); + tokio::spawn(async move { + entries_routes::upload( + State(state), + user, + Path(project.id), + Json(audit_contract_upload_req( + "lock/file.json", + &[("file-entry", "one")], + )), + ) + .await + }) + }; + audit_contract_wait_for_postgres_block( + &state.db, + upload_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_upload, + "上传必须等待项目行锁", + ) + .await; + upload_blocker.rollback().await.unwrap(); + let Json(file_to_delete) = blocked_upload + .await + .unwrap() + .expect_api("释放项目锁后上传成功"); + + let mut file_delete_blocker = state.db.begin().await.unwrap(); + let file_delete_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *file_delete_blocker) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut file_delete_blocker, project.id) + .await + .unwrap() + .unwrap(); + let blocked_file_delete = { + let state = state.clone(); + let user = audit_contract_current_user(&actor); + tokio::spawn(async move { + files_routes::delete_file( + State(state), + user, + Path((project.id, file_to_delete.file_id)), + ) + .await + }) + }; + audit_contract_wait_for_postgres_block( + &state.db, + file_delete_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_file_delete, + "文件删除必须等待项目行锁", + ) + .await; + file_delete_blocker.rollback().await.unwrap(); + blocked_file_delete + .await + .unwrap() + .expect_api("释放项目锁后文件删除成功"); + assert!( + files::find_file(&state.db, project.id, file_to_delete.file_id) + .await + .unwrap() + .is_none() + ); + + let Json(first_subtree_file) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(audit_contract_upload_req( + "cascade/one.json", + &[("one", "one")], + )), + ) + .await + .expect_api("创建第一个待级联文件"); + let Json(second_subtree_file) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&actor), + Path(project.id), + Json(audit_contract_upload_req( + "cascade/nested/two.json", + &[("two-a", "two a"), ("two-b", "two b")], + )), + ) + .await + .expect_api("创建第二个待级联文件"); + let folder_id = files::list_folders(&state.db, project.id) + .await + .unwrap() + .into_iter() + .find(|folder| folder.path == "cascade") + .expect("待级联顶层文件夹存在") + .id; + + let mut folder_delete_blocker = state.db.begin().await.unwrap(); + let folder_delete_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *folder_delete_blocker) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut folder_delete_blocker, project.id) + .await + .unwrap() + .unwrap(); + let blocked_folder_delete = { + let state = state.clone(); + let user = audit_contract_current_user(&actor); + tokio::spawn(async move { + files_routes::delete_folder(State(state), user, Path((project.id, folder_id))).await + }) + }; + audit_contract_wait_for_postgres_block( + &state.db, + folder_delete_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_folder_delete, + "文件夹删除必须等待项目行锁", + ) + .await; + folder_delete_blocker.rollback().await.unwrap(); + blocked_folder_delete + .await + .unwrap() + .expect_api("释放项目锁后文件夹删除成功"); + + let remaining_file_ids: Vec = files::list_files(&state.db, project.id) + .await + .unwrap() + .into_iter() + .map(|file| file.id) + .collect(); + assert!(!remaining_file_ids.contains(&first_subtree_file.file_id)); + assert!(!remaining_file_ids.contains(&second_subtree_file.file_id)); + assert!(files::list_folders(&state.db, project.id) + .await + .unwrap() + .into_iter() + .all(|folder| folder.path != "cascade" && !folder.path.starts_with("cascade/"))); + + let folder_audit = + audit_contract_rows_for_action_target(&state.db, "folder.deleted", &folder_id.to_string()) + .await; + assert_eq!(folder_audit.len(), 1); + assert_eq!(folder_audit[0].project_id, Some(project.id)); + assert_eq!(folder_audit[0].payload["file_count"], 2); + assert_eq!(folder_audit[0].payload["entry_count"], 3); + + projects::delete(&state.db, project.id).await.unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(actor.id) + .execute(&state.db) + .await + .unwrap(); +} + +/// 文件树 mutation 等待项目锁期间被撤权时,锁内授权快照必须拒绝陈旧权限。 +#[tokio::test] +async fn audit_contract_upload_revalidates_permission_after_project_lock() { + use axum::extract::{Path, State}; + use axum::response::IntoResponse; + use axum::Json; + + let state = audit_contract_state().await; + let owner = audit_contract_create_user(&state.db, "audit-lock-revoke-owner", None).await; + let manager = audit_contract_create_user(&state.db, "audit-lock-revoke-manager", None).await; + let project = projects::create( + &state.db, + &format!("audit-lock-revoke-{}", owner.id), + "Audit lock revoke", + "", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, owner.id, "owner") + .await + .unwrap(); + memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + + let mut revocation = state.db.begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *revocation) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut revocation, project.id) + .await + .unwrap() + .unwrap(); + memberships::remove_tx(&mut revocation, project.id, manager.id) + .await + .unwrap(); + + let blocked_upload = tokio::spawn(entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&manager), + Path(project.id), + Json(audit_contract_upload_req( + "revoked/file.json", + &[("entry", "source")], + )), + )); + audit_contract_wait_for_postgres_block( + &state.db, + blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_upload, + "上传应在锁后重新读取最新权限", + ) + .await; + revocation.commit().await.unwrap(); + + let denied = blocked_upload + .await + .unwrap() + .expect_err_api("撤权提交后不得继续上传"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::FORBIDDEN + ); + assert!(files::list_files(&state.db, project.id) + .await + .unwrap() + .is_empty()); + + memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + let mut project_revocation = state.db.begin().await.unwrap(); + let project_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *project_revocation) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut project_revocation, project.id) + .await + .unwrap() + .unwrap(); + memberships::remove_tx(&mut project_revocation, project.id, manager.id) + .await + .unwrap(); + let denied_name = format!("Revoked manager update {}", manager.id); + let blocked_project_update = tokio::spawn(projects_routes::update_project( + State(state.clone()), + audit_contract_current_user(&manager), + Path(project.id), + Json(projects_routes::UpdateProjectReq { + name: Some(denied_name.clone()), + description: None, + visibility: None, + source_langs: None, + target_lang: None, + }), + )); + audit_contract_wait_for_postgres_block( + &state.db, + project_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_project_update, + "项目更新应在锁后重新读取最新权限", + ) + .await; + project_revocation.commit().await.unwrap(); + assert_eq!( + blocked_project_update + .await + .unwrap() + .expect_err_api("撤权提交后不得更新项目") + .into_response() + .status(), + axum::http::StatusCode::FORBIDDEN + ); + assert_ne!( + projects::find_by_id(&state.db, project.id) + .await + .unwrap() + .unwrap() + .name, + denied_name + ); + + let target = audit_contract_create_user(&state.db, "audit-lock-revoke-target", None).await; + memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + let mut member_revocation = state.db.begin().await.unwrap(); + let member_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *member_revocation) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut member_revocation, project.id) + .await + .unwrap() + .unwrap(); + memberships::remove_tx(&mut member_revocation, project.id, manager.id) + .await + .unwrap(); + let blocked_member_add = tokio::spawn(projects_routes::add_member( + State(state.clone()), + audit_contract_current_user(&manager), + Path(project.id), + Json(projects_routes::AddMemberReq { + username: target.username.clone(), + role: "translator".to_string(), + }), + )); + audit_contract_wait_for_postgres_block( + &state.db, + member_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_member_add, + "成员添加应在锁后重新读取最新权限", + ) + .await; + member_revocation.commit().await.unwrap(); + assert_eq!( + blocked_member_add + .await + .unwrap() + .expect_err_api("撤权提交后不得添加成员") + .into_response() + .status(), + axum::http::StatusCode::FORBIDDEN + ); + assert!(memberships::find_role(&state.db, project.id, target.id) + .await + .unwrap() + .is_none()); + + let Json(uploaded) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&owner), + Path(project.id), + Json(audit_contract_upload_req( + "revoked/entry.json", + &[("entry", "source")], + )), + ) + .await + .expect_api("拥有者创建撤权词条夹具"); + let entry: prts_db::models::Entry = sqlx::query_as( + "SELECT * FROM entries WHERE project_id = $1 AND file_id = $2 AND key = 'entry'", + ) + .bind(project.id) + .bind(uploaded.file_id) + .fetch_one(&state.db) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + let mut entry_revocation = state.db.begin().await.unwrap(); + let entry_blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *entry_revocation) + .await + .unwrap(); + projects::find_by_id_for_update_tx(&mut entry_revocation, project.id) + .await + .unwrap() + .unwrap(); + memberships::remove_tx(&mut entry_revocation, project.id, manager.id) + .await + .unwrap(); + let blocked_entry_update = tokio::spawn(entries_routes::update_entry( + State(state.clone()), + audit_contract_current_user(&manager), + Path((project.id, entry.id)), + Json(entries_routes::UpdateEntryReq { + translation: "revoked translation".to_string(), + state: "translated".to_string(), + version: entry.version, + }), + )); + audit_contract_wait_for_postgres_block( + &state.db, + entry_blocker_pid, + "SELECT * FROM projects WHERE id = $1 FOR UPDATE", + &blocked_entry_update, + "词条更新应在锁后重新读取最新权限", + ) + .await; + entry_revocation.commit().await.unwrap(); + assert_eq!( + blocked_entry_update + .await + .unwrap() + .expect_err_api("撤权提交后不得更新词条") + .into_response() + .status(), + axum::http::StatusCode::FORBIDDEN + ); + assert_ne!( + entries::get(&state.db, project.id, entry.id) + .await + .unwrap() + .unwrap() + .translation, + "revoked translation" + ); + + projects::delete(&state.db, project.id).await.unwrap(); + sqlx::query("DELETE FROM users WHERE id = ANY($1::BIGINT[])") + .bind(&[owner.id, manager.id, target.id][..]) + .execute(&state.db) + .await + .unwrap(); +} + +#[test] +fn audit_contract_unavailable_error_is_stable_and_bilingual() { + use prts_common::i18n::{localize, Locale}; + + assert_eq!( + localize("AUDIT_UNAVAILABLE", Locale::ZhCn), + "审计服务暂不可用" + ); + assert_eq!( + localize("AUDIT_UNAVAILABLE", Locale::En), + "Audit service unavailable" + ); +} + +/// 规范化 payload key 后检查全部秘密与正文禁区。 +fn audit_contract_key_is_sensitive(key: &str) -> bool { + let normalized: String = key + .chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + [ + "token", + "accesstoken", + "refreshtoken", + "authorization", + "password", + "passwordhash", + "hash", + "secret", + "clientsecret", + "apikey", + "apikeyhash", + "code", + "oauthcode", + "verifier", + "codeverifier", + "challengeanswer", + "raw", + "rawbody", + "body", + "original", + "translation", + "sourcetext", + "content", + "context", + "text", + ] + .iter() + .any(|forbidden| normalized == *forbidden) +} + +/// 真实 handler 测试共用的应用状态;DB/Redis 都使用 CI 提供的真实服务。 +async fn audit_contract_state() -> state::AppState { + ensure_migrated().await; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); + let db = prts_db::connect_postgres(&database_url, 8) + .await + .expect("连接 runtime PostgreSQL"); + audit_contract_state_with_db(db).await +} + +/// 每条新连接都启用 test-only GUC;只有该 pool 的 audit INSERT 会被 fixture trigger 拒绝。 +async fn audit_contract_failing_audit_db() -> prts_db::Db { + ensure_migrated().await; + let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL 未设置"); + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(8) + .after_connect(|connection, _metadata| { + Box::pin(async move { + sqlx::query("SELECT set_config('prts.test_fail_audit', 'on', false)") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect(&database_url) + .await + .expect("连接审计故障专用 runtime pool"); + prts_db::verify_runtime_role(&db, &runtime_role()) + .await + .expect("故障 pool 仍必须使用 runtime role"); + db +} + +/// 创建隔离 Redis ACL 用户,仅拒绝指定命令;用于真实 commit/Redis 边界故障。 +async fn audit_contract_restricted_redis( + admin_cache: &prts_db::Cache, + denied_command: &str, +) -> (prts_db::Cache, String) { + let suffix = audit_jobs_unique("acl") + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::(); + let username = format!("audit{suffix}"); + let password = format!("Pass{suffix}"); + let mut admin = admin_cache.clone(); + let _: () = redis::cmd("ACL") + .arg("SETUSER") + .arg(&username) + .arg("reset") + .arg("on") + .arg(format!(">{password}")) + .arg("~*") + .arg("+@all") + .arg(format!("-{denied_command}")) + .query_async(&mut admin) + .await + .expect("创建隔离 Redis ACL 用户"); + let base = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); + let authority = base + .strip_prefix("redis://") + .expect("CI Redis URL 使用 redis://"); + let url = format!("redis://{username}:{password}@{authority}"); + let cache = prts_db::connect_redis(&url) + .await + .expect("连接受限 Redis ACL 用户"); + (cache, username) +} + +async fn audit_contract_delete_redis_acl_user(admin_cache: &prts_db::Cache, username: &str) { + let mut admin = admin_cache.clone(); + let _: i64 = redis::cmd("ACL") + .arg("DELUSER") + .arg(username) + .query_async(&mut admin) + .await + .expect("清理隔离 Redis ACL 用户"); +} + +async fn audit_contract_state_with_db(db: prts_db::Db) -> state::AppState { + use std::sync::Arc; + + let redis_url = std::env::var("PRTS__REDIS__URL").expect("PRTS__REDIS__URL 未设置"); + let cache = prts_db::connect_redis(&redis_url) + .await + .expect("连接真实 Redis"); + let realtime = prts_realtime::Hub::new(&redis_url) + .await + .expect("构造真实 realtime hub"); + let settings = Arc::new( + prts_common::config::Settings::load_from("__audit_contract_missing_config__") + .expect("加载测试配置"), + ); + let (job_worker, _job_worker_handle) = job_worker::spawn( + db.clone(), + jobs::JobRegistry::new(Vec::new()), + Arc::new(job_worker::NoPendingDeletions), ); + let search_config = prts_db::search_settings::get(&db) + .await + .expect("加载当前搜索运行时配置"); + let search_rt = Arc::new(tokio::sync::RwLock::new(search_config)); + let (search_settings_updater, _search_settings_worker) = + search_settings_worker::spawn(db.clone(), search_rt.clone()); state::AppState { db, cache, @@ -584,7 +1772,8 @@ async fn audit_contract_state_with_db(db: prts_db::Db) -> state::AppState { zoot: Arc::new(None), realtime, embedder: Arc::new(None), - search_rt: Arc::new(tokio::sync::RwLock::new(Default::default())), + search_rt, + search_settings_updater, job_worker, } } @@ -613,6 +1802,25 @@ async fn audit_contract_create_user( } } +fn audit_contract_upload_req( + path: impl Into, + entries: &[(&str, &str)], +) -> entries_routes::UploadReq { + entries_routes::UploadReq { + path: path.into(), + entries: entries + .iter() + .map(|(key, original)| entries_routes::UploadEntryDto { + key: (*key).to_string(), + original: serde_json::json!({"en": original}), + context: None, + translation: None, + state: None, + }) + .collect(), + } +} + fn audit_contract_current_user(user: &prts_db::models::User) -> auth::CurrentUser { auth::CurrentUser { id: user.id, @@ -623,6 +1831,43 @@ fn audit_contract_current_user(user: &prts_db::models::User) -> auth::CurrentUse } } +async fn audit_contract_wait_for_postgres_block( + db: &prts_db::Db, + blocker_pid: i32, + expected_query: &str, + task: &tokio::task::JoinHandle, + message: &str, +) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let blocked_pid: Option = sqlx::query_scalar( + "SELECT activity.pid + FROM pg_stat_activity AS activity + WHERE activity.datname = current_database() + AND activity.pid <> $1 + AND activity.wait_event_type = 'Lock' + AND $1 = ANY(pg_blocking_pids(activity.pid)) + AND activity.query LIKE '%' || $2 || '%' + ORDER BY activity.query_start + LIMIT 1", + ) + .bind(blocker_pid) + .bind(expected_query) + .fetch_optional(db) + .await + .unwrap(); + if blocked_pid.is_some() { + assert!(!task.is_finished(), "{message}"); + break; + } + assert!(!task.is_finished(), "{message}: mutation 提前结束"); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("{message}: 未观察到 PostgreSQL 阻塞证据")); +} + /// `ApiError` 刻意不暴露内部细节且没有 `Debug`;测试只需报告调用边界名称。 trait AuditContractApiResultExt { fn expect_api(self, message: &str) -> T; @@ -704,16 +1949,48 @@ async fn audit_contract_rows_for_action_target( } fn audit_contract_assert_actions(rows: &[ObservedAudit], expected: &[&str]) { - let observed: std::collections::HashSet<_> = - rows.iter().map(|row| row.action.as_str()).collect(); + let mut expected_counts = std::collections::HashMap::new(); for action in expected { - assert!( - observed.contains(action), - "缺少审计 action {action};实际={observed:?}" + *expected_counts.entry(*action).or_insert(0_usize) += 1; + } + for (action, expected_count) in expected_counts { + let observed_count = rows.iter().filter(|row| row.action == action).count(); + assert_eq!( + observed_count, expected_count, + "审计 action {action} 数量不符" + ); + } +} + +fn audit_contract_assert_exact_targets(rows: &[ObservedAudit], expected: &[(&str, String)]) { + for (action, target_id) in expected { + let matching: Vec<_> = rows + .iter() + .filter(|row| row.action == *action && row.target_id == *target_id) + .collect(); + assert_eq!( + matching.len(), + 1, + "action {action} 必须恰好指向 target {target_id}" ); } } +#[test] +fn audit_contract_exact_targets_reject_numeric_but_wrong_ids() { + let rows = vec![ObservedAudit { + action: "message.sent".to_string(), + target_type: "message".to_string(), + target_id: "41".to_string(), + project_id: None, + payload: serde_json::json!({}), + }]; + let rejected = std::panic::catch_unwind(|| { + audit_contract_assert_exact_targets(&rows, &[("message.sent", "42".to_string())]); + }); + assert!(rejected.is_err(), "数值合法但错误的 target id 必须被拒绝"); +} + fn audit_contract_assert_payloads_are_typed_and_redacted( rows: &[ObservedAudit], forbidden_values: &[&str], @@ -734,6 +2011,44 @@ fn audit_contract_assert_payloads_are_typed_and_redacted( row.action ); } + let contract = AUDIT_ACTION_CONTRACTS + .iter() + .find(|contract| contract.action == row.action) + .unwrap_or_else(|| panic!("action {} 没有目标合同", row.action)); + assert_eq!( + row.target_type, contract.target_type, + "{} target_type", + row.action + ); + match contract.target_id_policy { + TargetIdPolicy::Numeric => { + row.target_id + .parse::() + .unwrap_or_else(|_| panic!("{} target_id 必须为整数", row.action)); + } + TargetIdPolicy::CompositeNumeric => { + let parts: Vec<_> = row.target_id.split(':').collect(); + assert_eq!(parts.len(), 2, "{} target_id 必须是 a:b", row.action); + for part in parts { + part.parse::() + .unwrap_or_else(|_| panic!("{} composite target 必须为整数", row.action)); + } + } + TargetIdPolicy::Constant(expected) => assert_eq!(row.target_id, expected), + TargetIdPolicy::OpaqueNonEmpty => assert!(!row.target_id.is_empty()), + } + match contract.project_snapshot_policy { + ProjectSnapshotPolicy::None => assert!(row.project_id.is_none()), + ProjectSnapshotPolicy::SameAsNumericTarget => assert_eq!( + row.project_id, + Some( + row.target_id + .parse::() + .expect("project target 必须为整数") + ) + ), + ProjectSnapshotPolicy::Required => assert!(row.project_id.is_some()), + } audit_contract_assert_json_has_no_sensitive_keys(&row.payload); let serialized = row.payload.to_string(); for forbidden in forbidden_values { @@ -786,9 +2101,26 @@ async fn audit_contract_registration_rolls_back_user_and_token_issuance_when_aud use axum::extract::State; use axum::Json; + let settings_db = pool().await; + settings::set( + &settings_db, + appsettings::AUTH_OAUTH_ONLY, + &serde_json::json!(false), + None, + ) + .await + .unwrap(); + settings::set( + &settings_db, + appsettings::AUTH_REGISTRATION_OPEN, + &serde_json::json!(true), + None, + ) + .await + .unwrap(); let failing_db = audit_contract_failing_audit_db().await; let state = audit_contract_state_with_db(failing_db).await; - let username = format!("u-{}", audit_jobs_unique("audit-register-rollback")); + let username = format!("u-{}", audit_jobs_unique("reg")); let password_marker = "REGISTER_PASSWORD_MUST_NEVER_ENTER_AUDIT"; let result = auth_routes::register( State(state.clone()), @@ -824,6 +2156,16 @@ async fn audit_contract_registration_rolls_back_user_and_token_issuance_when_aud .await .unwrap(); assert!(!leaked, "失败审计也不得泄露 password"); + sqlx::query("DELETE FROM settings WHERE key = ANY($1::TEXT[])") + .bind( + &[ + appsettings::AUTH_OAUTH_ONLY, + appsettings::AUTH_REGISTRATION_OPEN, + ][..], + ) + .execute(&settings_db) + .await + .unwrap(); } /// 普通业务 mutation 同样 fail closed;项目与 owner membership 不能部分提交。 @@ -919,6 +2261,13 @@ async fn audit_contract_login_commits_active_session_audit_and_outbox_before_ret let rows = audit_contract_rows_for_actor(&state.db, user.id).await; audit_contract_assert_actions(&rows, &["auth.login_succeeded", "auth.token_issued"]); + audit_contract_assert_exact_targets( + &rows, + &[ + ("auth.login_succeeded", user.id.to_string()), + ("auth.token_issued", sessions[0].0.to_string()), + ], + ); audit_contract_assert_payloads_are_typed_and_redacted( &rows, &[ @@ -1112,7 +2461,21 @@ async fn audit_contract_refresh_rotation_has_one_active_successor_and_redacted_o } let rows = audit_contract_rows_for_actor(&state.db, user.id).await; - audit_contract_assert_actions(&rows, &["auth.refresh_rotated"]); + audit_contract_assert_actions( + &rows, + &[ + "auth.refresh_rotated", + "auth.token_issued", + "auth.token_issued", + ], + ); + audit_contract_assert_exact_targets( + &rows, + &[ + ("auth.refresh_rotated", successor.0.to_string()), + ("auth.token_issued", successor.0.to_string()), + ], + ); audit_contract_assert_payloads_are_typed_and_redacted( &rows, &[ @@ -1126,6 +2489,26 @@ async fn audit_contract_refresh_rotation_has_one_active_successor_and_redacted_o ); } +/// Refresh 必须在 rotation 事务内取得对外用户快照,提交后不得再依赖可失败的 DB 查询。 +#[tokio::test] +async fn audit_contract_refresh_returns_transactional_secret_free_user_snapshot() { + let state = audit_contract_state().await; + let (user, first) = audit_contract_login_fixture(&state, "audit-refresh-snapshot").await; + + let refreshed = auth::session::refresh(&state, &first.refresh_token) + .await + .expect("refresh rotation 成功"); + + assert_eq!(refreshed.user.id, user.id); + assert_eq!(refreshed.user.username, user.username); + assert!(!refreshed.tokens.access_token.is_empty()); + let serialized = serde_json::to_value(&refreshed.user).expect("用户快照可序列化"); + let object = serialized.as_object().expect("用户快照必须是 object"); + assert!(!object.contains_key("password_hash")); + assert!(!object.contains_key("email_verified")); + assert!(!object.contains_key("status")); +} + /// 并发复用同一 predecessor 只能一个 refresh 成功,且 DB 最终只有一个 active successor。 #[tokio::test] async fn audit_contract_concurrent_refresh_never_creates_two_active_successors() { @@ -1240,22 +2623,360 @@ async fn audit_contract_stale_redis_refresh_cannot_bypass_revoked_db_session() { auth_sessions::revoke_unexpired_tx(&mut tx, session.id) .await .unwrap() - .expect("权威 session 可吊销"); + .expect("权威 session 可吊销"); + tx.commit().await.unwrap(); + + // 故意不 DEL Redis,模拟 invalidate worker crash/stale cache。 + let denied = auth_routes::refresh( + State(state), + Json(auth_routes::RefreshReq { + refresh_token: tokens.refresh_token, + }), + ) + .await + .expect_err_api("revoked DB session 必须拒绝 stale Redis"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); +} + +/// 新签发 access JWT 必须绑定 DB session;logout/revoke 后不能继续靠 JWT 签名通过。 +#[tokio::test] +async fn audit_contract_revoked_db_session_immediately_denies_bound_access_jwt() { + use axum::extract::FromRequestParts; + use axum::http::{header, Request}; + use axum::response::IntoResponse; + + let state = audit_contract_state().await; + let (user, tokens) = audit_contract_login_fixture(&state, "audit-access-session-revoke").await; + let claims = prts_auth::jwt::decode(&tokens.access_token, state.jwt_secret()).unwrap(); + let session_handle = claims.sid.expect("新签发 access JWT 必须携带 sid"); + let session = auth_sessions::find_active_unexpired_by_handle(&state.db, &session_handle) + .await + .unwrap() + .expect("login 已提交 active DB session"); + assert_eq!(session.user_id, user.id); + + let mut tx = state.db.begin().await.unwrap(); + auth_sessions::revoke_unexpired_tx(&mut tx, session.id) + .await + .unwrap() + .expect("active session 可立即吊销"); + tx.commit().await.unwrap(); + + let (mut parts, _) = Request::builder() + .header( + header::AUTHORIZATION, + format!("Bearer {}", tokens.access_token), + ) + .body(()) + .unwrap() + .into_parts(); + let denied = auth::CurrentUser::from_request_parts(&mut parts, &state) + .await + .expect_err_api("revoked DB session 必须否决仍未过期的绑定 access JWT"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); +} + +/// 已提供但无效的凭证必须先写脱敏 `auth.failed`;无凭证仍可按公开游客处理。 +#[tokio::test] +async fn audit_contract_presented_invalid_credentials_are_audited_before_denial_or_guest() { + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use std::collections::HashSet; + use tower::ServiceExt; + + async fn protected(_user: auth::CurrentUser) -> StatusCode { + StatusCode::OK + } + + async fn public(auth::MaybeUser(user): auth::MaybeUser) -> StatusCode { + if user.is_some() { + StatusCode::OK + } else { + StatusCode::NO_CONTENT + } + } + + // 使用“任何 audit INSERT 都失败”的连接池证明真正无凭证路径根本不会触发审计; + // 不读取全局计数,避免并行测试插入同一 action 造成竞态。 + let no_audit_state = + audit_contract_state_with_db(audit_contract_failing_audit_db().await).await; + let no_audit_app = Router::new() + .route("/public", get(public)) + .layer(axum::middleware::from_fn(error::localize_audit_errors)) + .with_state(no_audit_state); + let missing = no_audit_app + .oneshot( + Request::builder() + .uri("/public") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NO_CONTENT); + + let state = audit_contract_state().await; + let app = Router::new() + .route("/protected", get(protected)) + .route("/public", get(public)) + .layer(axum::middleware::from_fn(error::localize_audit_errors)) + .with_state(state.clone()); + let started_at = chrono::Utc::now(); + + let (active_user, active_tokens) = + audit_contract_login_fixture(&state, "audit-invalid-jwt").await; + let base_claims = + prts_auth::jwt::decode(&active_tokens.access_token, state.jwt_secret()).unwrap(); + let sid_none = prts_auth::jwt::encode( + &prts_auth::jwt::Claims { + sid: None, + ..base_claims.clone() + }, + state.jwt_secret(), + ); + let expired = prts_auth::jwt::encode( + &prts_auth::jwt::Claims { + exp: chrono::Utc::now().timestamp() - 120, + ..base_claims.clone() + }, + state.jwt_secret(), + ); + let other = audit_contract_create_user(&state.db, "audit-jwt-mismatch", None).await; + let mismatch = prts_auth::jwt::encode( + &prts_auth::jwt::Claims { + sub: other.id, + ..base_claims.clone() + }, + state.jwt_secret(), + ); + + let (revoked_user, revoked_tokens) = + audit_contract_login_fixture(&state, "audit-jwt-revoked").await; + let revoked_claims = + prts_auth::jwt::decode(&revoked_tokens.access_token, state.jwt_secret()).unwrap(); + let revoked_handle = revoked_claims.sid.clone().unwrap(); + let revoked_session = + auth_sessions::find_active_unexpired_by_handle(&state.db, &revoked_handle) + .await + .unwrap() + .unwrap(); + let mut tx = state.db.begin().await.unwrap(); + auth_sessions::revoke_unexpired_tx(&mut tx, revoked_session.id) + .await + .unwrap() + .unwrap(); tx.commit().await.unwrap(); - // 故意不 DEL Redis,模拟 invalidate worker crash/stale cache。 - let denied = auth_routes::refresh( - State(state), - Json(auth_routes::RefreshReq { - refresh_token: tokens.refresh_token, - }), + let (inactive_user, inactive_tokens) = + audit_contract_login_fixture(&state, "audit-jwt-inactive").await; + sqlx::query("UPDATE users SET status = 'disabled' WHERE id = $1") + .bind(inactive_user.id) + .execute(&state.db) + .await + .unwrap(); + + let invalid_jwt = "INVALID_JWT_MUST_NOT_ENTER_AUDIT".to_string(); + let invalid_api_key = "prts_INVALID_API_KEY_MUST_NOT_ENTER_AUDIT".to_string(); + for (credential, expected_status) in [ + (invalid_jwt.clone(), StatusCode::UNAUTHORIZED), + (invalid_api_key.clone(), StatusCode::UNAUTHORIZED), + (sid_none.clone(), StatusCode::UNAUTHORIZED), + (expired.clone(), StatusCode::UNAUTHORIZED), + (mismatch.clone(), StatusCode::UNAUTHORIZED), + ( + revoked_tokens.access_token.clone(), + StatusCode::UNAUTHORIZED, + ), + (inactive_tokens.access_token.clone(), StatusCode::FORBIDDEN), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/protected") + .header(header::AUTHORIZATION, format!("Bearer {credential}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), expected_status); + } + + let optional_invalid = app + .oneshot( + Request::builder() + .uri("/public") + .header(header::AUTHORIZATION, "Bearer prts_OPTIONAL_INVALID") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(optional_invalid.status(), StatusCode::NO_CONTENT); + + let rows = sqlx::query_as::<_, (String, String, String, Option, serde_json::Value)>( + "SELECT action, target_type, target_id, project_id_snapshot, payload + FROM audit_log + WHERE action = 'auth.failed' AND created_at >= $1 + ORDER BY id", ) + .bind(started_at) + .fetch_all(&state.db) .await - .expect_err_api("revoked DB session 必须拒绝 stale Redis"); - assert_eq!( - denied.into_response().status(), - axum::http::StatusCode::UNAUTHORIZED + .unwrap() + .into_iter() + .map( + |(action, target_type, target_id, project_id, payload)| ObservedAudit { + action, + target_type, + target_id, + project_id, + payload, + }, + ) + .collect::>(); + let observed: HashSet<_> = rows + .iter() + .map(|row| { + ( + row.payload["method"].as_str().unwrap().to_string(), + row.payload["reason_code"].as_str().unwrap().to_string(), + ) + }) + .collect(); + for expected in [ + ("jwt", "invalid_credential"), + ("api_key", "invalid_credential"), + ("jwt", "missing_session"), + ("jwt", "token_expired"), + ("jwt", "user_mismatch"), + ("jwt", "session_inactive"), + ("jwt", "account_inactive"), + ] { + assert!( + observed.contains(&(expected.0.to_string(), expected.1.to_string())), + "缺少失败认证审计 {expected:?};实际={observed:?}" + ); + } + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + &invalid_jwt, + &invalid_api_key, + &sid_none, + &expired, + &mismatch, + &revoked_tokens.access_token, + &inactive_tokens.access_token, + ], + ); + assert!(rows + .iter() + .any(|row| row.target_id == active_user.id.to_string())); + assert!(rows.iter().any(|row| row.target_id == other.id.to_string())); + assert!(rows + .iter() + .any(|row| row.target_id == revoked_user.id.to_string())); + assert!(rows + .iter() + .any(|row| row.target_id == inactive_user.id.to_string())); +} + +/// 失败审计不可持久化时必须覆盖原 401/guest,并按请求语言返回通用 503。 +#[tokio::test] +async fn audit_contract_presented_invalid_credential_audit_failure_is_bilingual_503() { + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; + + async fn protected(_user: auth::CurrentUser) -> StatusCode { + StatusCode::OK + } + + async fn public(auth::MaybeUser(user): auth::MaybeUser) -> StatusCode { + if user.is_some() { + StatusCode::OK + } else { + StatusCode::NO_CONTENT + } + } + + let failing_db = audit_contract_failing_audit_db().await; + let state = audit_contract_state_with_db(failing_db).await; + let user = audit_contract_create_user(&state.db, "audit-failed-sid-none", None).await; + let now = chrono::Utc::now().timestamp(); + let sid_none = prts_auth::jwt::encode( + &prts_auth::jwt::Claims { + sub: user.id, + iat: now, + exp: now + 600, + typ: "access".to_string(), + sid: None, + }, + state.jwt_secret(), ); + let app = Router::new() + .route("/protected", get(protected)) + .route("/public", get(public)) + .layer(axum::middleware::from_fn(error::localize_audit_errors)) + .with_state(state); + + let missing = app + .clone() + .oneshot( + Request::builder() + .uri("/public") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NO_CONTENT); + + for (credential, locale, expected_message) in [ + ( + "prts_INVALID_AUDIT_FAILURE".to_string(), + "en", + "Audit service unavailable", + ), + ( + "INVALID_JWT_AUDIT_FAILURE".to_string(), + "zh-CN", + "审计服务暂不可用", + ), + (sid_none, "en-US", "Audit service unavailable"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/protected") + .header(header::AUTHORIZATION, format!("Bearer {credential}")) + .header(header::ACCEPT_LANGUAGE, locale) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["code"], "AUDIT_UNAVAILABLE"); + assert_eq!(body["message"], expected_message); + } } /// DB commit 已成功时,Redis populate 失败由 outbox 重放,不能撤销 session 或吞掉 raw token。 @@ -1303,6 +3024,217 @@ async fn audit_contract_token_issuance_survives_redis_populate_failure_via_durab assert_eq!((active, queued), (1, 1)); } +/// Redis 暂时不可用时,同一 intent 必须保持 queued 并按有界退避重试,不进入 failed 扫描恢复。 +#[tokio::test] +async fn audit_contract_transient_auth_outbox_requeues_same_intent_until_redis_returns() { + let state = audit_contract_state().await; + let user = audit_contract_create_user(&state.db, "audit-outbox-recovery", None).await; + let raw_refresh = "OUTBOX_RAW_REFRESH_MUST_NOT_ENTER_PAYLOAD"; + let refresh_hash = prts_auth::token::sha256_hex(raw_refresh); + let session_handle = format!("outbox-session-{}", audit_jobs_unique("recover")); + let mut tx = state.db.begin().await.unwrap(); + let pending = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: session_handle.clone(), + family_handle: audit_jobs_unique("outbox-family"), + user_id: user.id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse(refresh_hash.clone()) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + let active = auth_sessions::activate_pending_tx(&mut tx, pending.id) + .await + .unwrap() + .unwrap(); + let intent = auth_sessions::enqueue_intent_tx( + &mut tx, + active.id, + auth_sessions::AuthIntentPayload::RedisPopulate { + session_handle: session_handle.clone(), + expires_at: active.expires_at, + }, + i32::MAX, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let admin_cache = state.cache.clone(); + let (restricted_cache, acl_user) = audit_contract_restricted_redis(&admin_cache, "set").await; + assert_eq!( + auth::session::process_one_outbox_intent( + &state.db, + &restricted_cache, + "outbox-recovery-worker-1", + Some(active.id), + ) + .await + .unwrap(), + auth::session::OutboxProcessOutcome::Rescheduled + ); + audit_contract_delete_redis_acl_user(&admin_cache, &acl_user).await; + + let queued: ( + i64, + String, + i32, + chrono::DateTime, + Option, + serde_json::Value, + ) = sqlx::query_as( + "SELECT id, state, attempts, run_after, last_error_code, payload + FROM auth_session_intents WHERE id = $1", + ) + .bind(intent.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(queued.0, intent.id); + assert_eq!(queued.1, "queued"); + assert_eq!(queued.2, 1); + assert!( + queued.3 > chrono::Utc::now(), + "暂时失败后必须持久化有界退避" + ); + assert_eq!(queued.4.as_deref(), Some("redis_unavailable")); + let serialized_payload = queued.5.to_string(); + assert!(!serialized_payload.contains(raw_refresh)); + assert!(!serialized_payload.contains(&refresh_hash)); + + assert!( + auth::session::process_one_outbox_intent( + &state.db, + &admin_cache, + "outbox-recovery-worker-2", + Some(active.id), + ) + .await + .unwrap() + == auth::session::OutboxProcessOutcome::Idle, + "cooldown 未到不得 busy-loop 重领" + ); + sqlx::query( + "UPDATE auth_session_intents SET run_after = now() - interval '1 second' WHERE id = $1", + ) + .bind(intent.id) + .execute(&state.db) + .await + .unwrap(); + assert_eq!( + auth::session::process_one_outbox_intent( + &state.db, + &admin_cache, + "outbox-recovery-worker-3", + Some(active.id), + ) + .await + .unwrap(), + auth::session::OutboxProcessOutcome::Completed + ); + + let recovered: (i64, String, i32, Option) = sqlx::query_as( + "SELECT id, state, attempts, last_error_code + FROM auth_session_intents WHERE id = $1", + ) + .bind(intent.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(recovered, (intent.id, "succeeded".to_string(), 2, None)); + let mut cache = admin_cache.clone(); + let cached_user: Option = redis::cmd("GET") + .arg(format!("auth_session:{session_handle}")) + .query_async(&mut cache) + .await + .unwrap(); + assert_eq!(cached_user, Some(user.id)); + let _: i64 = redis::cmd("DEL") + .arg(format!("auth_session:{session_handle}")) + .query_async(&mut cache) + .await + .unwrap(); +} + +/// Redis 在最后一次预算内仍失败时,intent 必须进入 failed,不能永久留在 queued。 +#[tokio::test] +async fn audit_contract_auth_outbox_last_redis_failure_exhausts_intent() { + let state = audit_contract_state().await; + let user = audit_contract_create_user(&state.db, "audit-outbox-exhausted", None).await; + let session_handle = format!("outbox-exhausted-{}", audit_jobs_unique("session")); + let mut tx = state.db.begin().await.unwrap(); + let pending = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: session_handle.clone(), + family_handle: audit_jobs_unique("outbox-exhausted-family"), + user_id: user.id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("outbox-exhausted-refresh"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::hours(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + let active = auth_sessions::activate_pending_tx(&mut tx, pending.id) + .await + .unwrap() + .unwrap(); + let intent = auth_sessions::enqueue_intent_tx( + &mut tx, + active.id, + auth_sessions::AuthIntentPayload::RedisPopulate { + session_handle, + expires_at: active.expires_at, + }, + 1, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let (restricted_cache, acl_user) = audit_contract_restricted_redis(&state.cache, "set").await; + assert_eq!( + auth::session::process_one_outbox_intent( + &state.db, + &restricted_cache, + "outbox-exhausted-worker", + Some(active.id), + ) + .await + .unwrap(), + auth::session::OutboxProcessOutcome::PermanentlyFailed + ); + audit_contract_delete_redis_acl_user(&state.cache, &acl_user).await; + + let persisted: (String, i32, Option) = sqlx::query_as( + "SELECT state, attempts, last_error_code + FROM auth_session_intents WHERE id = $1", + ) + .bind(intent.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(persisted.0, "failed"); + assert_eq!(persisted.1, 1); + assert_eq!( + persisted.2.as_deref(), + Some("auth_intent_attempts_exhausted") + ); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user.id) + .execute(&state.db) + .await + .unwrap(); +} + /// logout 的 DB revoke 一经提交立即生效;Redis DEL 失败只留下 durable invalidate retry。 #[tokio::test] async fn audit_contract_logout_remains_immediate_when_redis_invalidate_fails() { @@ -1328,37 +3260,142 @@ async fn audit_contract_logout_remains_immediate_when_redis_invalidate_fails() { logout_result.expect_api("Redis DEL 失败不应推翻 DB logout commit"); let refresh_hash = prts_auth::token::sha256_hex(&tokens.refresh_token); - let (state_name, invalidate_count): (String, i64) = sqlx::query_as( - "SELECT session.state, + let (session_id, state_name, invalidate_count): (i64, String, i64) = sqlx::query_as( + "SELECT session.id, session.state, (SELECT COUNT(*) FROM auth_session_intents AS intent WHERE intent.session_id = session.id AND intent.kind = 'redis_invalidate' AND intent.state = 'queued') FROM auth_sessions AS session WHERE session.refresh_token_hash = $1", ) - .bind(refresh_hash) - .fetch_one(&normal_state.db) + .bind(refresh_hash) + .fetch_one(&normal_state.db) + .await + .unwrap(); + assert_eq!(state_name, "revoked"); + assert_eq!(invalidate_count, 1); + + // 即使 DEL 失败后缓存仍残留,DB revoked 必须立刻否决 refresh。 + let denied = auth_routes::refresh( + State(normal_state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: tokens.refresh_token, + }), + ) + .await + .expect_err_api("logout DB commit 后 refresh 必须立即失效"); + assert_eq!( + denied.into_response().status(), + axum::http::StatusCode::UNAUTHORIZED + ); + + let rows = audit_contract_rows_for_actor(&normal_state.db, user.id).await; + audit_contract_assert_actions(&rows, &["auth.logged_out"]); + audit_contract_assert_exact_targets(&rows, &[("auth.logged_out", session_id.to_string())]); +} + +/// 幂等 logout 的 unknown/revoked/expired refresh 仍是一次已提供但失败的认证并须 fail closed。 +#[tokio::test] +async fn audit_contract_logout_noop_is_audited_and_audit_failure_returns_503() { + use axum::extract::State; + use axum::Json; + + let state = audit_contract_state().await; + let started_at = chrono::Utc::now(); + let unknown = "UNKNOWN_REFRESH_MUST_NOT_ENTER_AUDIT".to_string(); + auth_routes::logout( + State(state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: unknown.clone(), + }), + ) + .await + .expect_api("unknown refresh logout 保持幂等 204"); + + let (_, revoked_tokens) = audit_contract_login_fixture(&state, "audit-logout-noop").await; + auth_routes::logout( + State(state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: revoked_tokens.refresh_token.clone(), + }), + ) + .await + .expect_api("首次 logout 吊销 active session"); + auth_routes::logout( + State(state.clone()), + Json(auth_routes::RefreshReq { + refresh_token: revoked_tokens.refresh_token.clone(), + }), + ) .await - .unwrap(); - assert_eq!(state_name, "revoked"); - assert_eq!(invalidate_count, 1); + .expect_api("revoked refresh logout 保持幂等 204"); - // 即使 DEL 失败后缓存仍残留,DB revoked 必须立刻否决 refresh。 - let denied = auth_routes::refresh( - State(normal_state.clone()), + let (_, expired_tokens) = audit_contract_login_fixture(&state, "audit-logout-expired").await; + let expired_hash = prts_auth::token::sha256_hex(&expired_tokens.refresh_token); + sqlx::query( + "UPDATE auth_sessions SET expires_at = now() - interval '1 second' + WHERE refresh_token_hash = $1", + ) + .bind(&expired_hash) + .execute(&state.db) + .await + .unwrap(); + auth_routes::logout( + State(state.clone()), Json(auth_routes::RefreshReq { - refresh_token: tokens.refresh_token, + refresh_token: expired_tokens.refresh_token.clone(), }), ) .await - .expect_err_api("logout DB commit 后 refresh 必须立即失效"); - assert_eq!( - denied.into_response().status(), - axum::http::StatusCode::UNAUTHORIZED + .expect_api("expired refresh logout 保持幂等 204"); + + let rows = sqlx::query_as::<_, (String, String, String, Option, serde_json::Value)>( + "SELECT action, target_type, target_id, project_id_snapshot, payload + FROM audit_log + WHERE action = 'auth.failed' AND created_at >= $1 + AND payload->>'method' = 'refresh' + ORDER BY id", + ) + .bind(started_at) + .fetch_all(&state.db) + .await + .unwrap() + .into_iter() + .map( + |(action, target_type, target_id, project_id, payload)| ObservedAudit { + action, + target_type, + target_id, + project_id, + payload, + }, + ) + .collect::>(); + assert!(rows.len() >= 3, "unknown/revoked/expired 均须写失败审计"); + assert!(rows + .iter() + .all(|row| row.payload["reason_code"] == "invalid_refresh")); + audit_contract_assert_payloads_are_typed_and_redacted( + &rows, + &[ + &unknown, + &revoked_tokens.refresh_token, + &expired_tokens.refresh_token, + &expired_hash, + ], ); - let rows = audit_contract_rows_for_actor(&normal_state.db, user.id).await; - audit_contract_assert_actions(&rows, &["auth.logged_out"]); + let failing_db = audit_contract_failing_audit_db().await; + let failing_state = audit_contract_state_with_db(failing_db).await; + let error = auth_routes::logout( + State(failing_state), + Json(auth_routes::RefreshReq { + refresh_token: "UNKNOWN_REFRESH_AUDIT_FAILURE".to_string(), + }), + ) + .await + .expect_err_api("noop logout 的审计失败必须返回 503"); + audit_contract_assert_unavailable(error).await; } /// 普通 read 跨用户/项目/文件/词条/通知/私信均保持零审计;敏感 export 已单独覆盖。 @@ -1493,14 +3530,16 @@ async fn audit_contract_users_admin_settings_and_api_keys_are_audited_and_redact use axum::Json; use std::collections::HashMap; + let _search_settings_guard = SEARCH_SETTINGS_TEST_LOCK.lock().await; let state = audit_contract_state().await; let actor = audit_contract_create_user(&state.db, "audit-admin", Some("super_admin")).await; let target = audit_contract_create_user(&state.db, "audit-role-target", None).await; + let previous_search = prts_db::search_settings::get(&state.db).await.unwrap(); let profile_marker = "FULL_PROFILE_CONTENT_MUST_NOT_ENTER_AUDIT"; let setting_marker = "FULL_SETTING_VALUE_MUST_NOT_ENTER_AUDIT"; let endpoint_marker = "AUDIT_EMBEDDING_ENDPOINT_VALUE"; - let _ = users_routes::update_me( + let Json(updated_profile) = users_routes::update_me( State(state.clone()), audit_contract_current_user(&actor), Json(users_routes::UpdateMeReq { @@ -1596,6 +3635,18 @@ async fn audit_contract_users_admin_settings_and_api_keys_are_audited_and_redact "api_key.revoked", ], ); + audit_contract_assert_exact_targets( + &rows, + &[ + ("user.profile_updated", updated_profile.id.to_string()), + ("api_key.created", created_key.id.to_string()), + ("api_key.used", created_key.id.to_string()), + ("api_key.revoked", created_key.id.to_string()), + ("user.platform_role_changed", target.id.to_string()), + ("settings.updated", "platform".to_string()), + ("search_settings.updated", "search.config".to_string()), + ], + ); audit_contract_assert_payloads_are_typed_and_redacted( &rows, &[ @@ -1605,6 +3656,165 @@ async fn audit_contract_users_admin_settings_and_api_keys_are_audited_and_redact &created_key.key, ], ); + prts_db::search_settings::set(&state.db, previous_search.clone(), Some(actor.id)) + .await + .unwrap(); + *state.search_rt.write().await = previous_search; +} + +/// 可选认证只能吞掉无凭证/无效凭证,不能把 API-key touch 的审计故障降级成游客。 +#[tokio::test] +async fn audit_contract_optional_auth_propagates_api_key_audit_unavailable() { + use axum::body::Body; + use axum::http::{header, Request, StatusCode}; + use axum::routing::get; + use axum::Router; + use tower::ServiceExt; + + async fn public_endpoint(auth::MaybeUser(user): auth::MaybeUser) -> StatusCode { + if user.is_some() { + StatusCode::OK + } else { + StatusCode::NO_CONTENT + } + } + + let normal_state = audit_contract_state().await; + let actor = audit_contract_create_user(&normal_state.db, "audit-maybe-user", None).await; + let generated = prts_auth::token::generate_api_key(); + api_keys::create( + &normal_state.db, + actor.id, + "optional-auth-audit-failure", + &generated.hash, + &generated.display_prefix, + ) + .await + .unwrap(); + + let failing_db = audit_contract_failing_audit_db().await; + let failing_state = audit_contract_state_with_db(failing_db).await; + let app = Router::new() + .route("/public", get(public_endpoint)) + .layer(axum::middleware::from_fn(error::localize_audit_errors)) + .with_state(failing_state); + let anonymous = app + .clone() + .oneshot( + Request::builder() + .uri("/public") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous.status(), StatusCode::NO_CONTENT); + let invalid = app + .clone() + .oneshot( + Request::builder() + .uri("/public") + .header(header::AUTHORIZATION, "Bearer prts_invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::SERVICE_UNAVAILABLE); + + let response = app + .oneshot( + Request::builder() + .uri("/public") + .header( + header::AUTHORIZATION, + format!("Bearer {}", generated.plaintext), + ) + .header(header::ACCEPT_LANGUAGE, "en") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["code"], "AUDIT_UNAVAILABLE"); + assert_eq!(body["message"], "Audit service unavailable"); +} + +/// 手动重试的任务状态与审计必须同事务;审计失败返回稳定 503 且不消耗重试预算。 +#[tokio::test] +async fn audit_contract_job_retry_audit_failure_rolls_back_and_returns_503() { + let normal_state = audit_contract_state().await; + let owner = audit_contract_create_user(&normal_state.db, "audit-job-retry-owner", None).await; + let project = projects::create( + &normal_state.db, + &format!("audit-job-retry-{}", owner.id), + "Audit job retry", + "", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + memberships::upsert(&normal_state.db, project.id, owner.id, "owner") + .await + .unwrap(); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs ( + kind, project_id, state, stage, payload, attempts, max_attempts, + last_error_code, last_error_message, finished_at + ) VALUES ( + 'upload_process', $1, 'failed', 'processing', '{}', 2, 2, + 'provider_unavailable', 'redacted test failure', now() + ) RETURNING id", + ) + .bind(project.id) + .fetch_one(&normal_state.db) + .await + .unwrap(); + + let failing_state = audit_contract_state_with_db(audit_contract_failing_audit_db().await).await; + let error = job_retry::retry_job(&failing_state, &audit_contract_current_user(&owner), job_id) + .await + .expect_err_api("审计失败不得重新排队任务"); + audit_contract_assert_unavailable(error).await; + + let persisted: (String, i32, i32, Option) = sqlx::query_as( + "SELECT state, attempts, max_attempts, last_error_code FROM jobs WHERE id = $1", + ) + .bind(job_id) + .fetch_one(&normal_state.db) + .await + .unwrap(); + assert_eq!( + persisted, + ( + "failed".to_string(), + 2, + 2, + Some("provider_unavailable".to_string()) + ) + ); + + sqlx::query("DELETE FROM jobs WHERE id = $1") + .bind(job_id) + .execute(&normal_state.db) + .await + .unwrap(); + projects::delete(&normal_state.db, project.id) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(owner.id) + .execute(&normal_state.db) + .await + .unwrap(); } /// 项目、成员、旧上传、词条编辑/flags、文件删除与敏感导出必须走真实事务审计。 @@ -1743,7 +3953,7 @@ async fn audit_contract_projects_files_entries_memberships_and_export_are_audite .await .expect_api("删除文件成功"); - let _ = entries_routes::upload( + let Json(folder_upload) = entries_routes::upload( State(state.clone()), audit_contract_current_user(&actor), Path(project.id), @@ -1811,6 +4021,8 @@ async fn audit_contract_projects_files_entries_memberships_and_export_are_audite "project.created", "membership.upserted", "entries.uploaded", + "entries.uploaded", + "entries.uploaded", "entry.updated", "entry.flags_updated", "project.exported", @@ -1821,6 +4033,30 @@ async fn audit_contract_projects_files_entries_memberships_and_export_are_audite "project.deleted", ], ); + audit_contract_assert_exact_targets( + &rows, + &[ + ("project.created", project.id.to_string()), + ( + "membership.upserted", + format!("{}:{}", project.id, member.id), + ), + ("entries.uploaded", uploaded.file_id.to_string()), + ("entries.uploaded", file_to_delete.file_id.to_string()), + ("entries.uploaded", folder_upload.file_id.to_string()), + ("entry.updated", entry.id.to_string()), + ("entry.flags_updated", entry.id.to_string()), + ("project.exported", project.id.to_string()), + ("file.deleted", file_to_delete.file_id.to_string()), + ("folder.deleted", folder_id.to_string()), + ("project.updated", project.id.to_string()), + ( + "membership.removed", + format!("{}:{}", project.id, member.id), + ), + ("project.deleted", project.id.to_string()), + ], + ); for row in &rows { if row.project_id.is_some() { assert_eq!(row.project_id, Some(project.id)); @@ -1898,9 +4134,15 @@ async fn audit_contract_notifications_poke_and_messages_are_audited_without_cont ) .await .expect_api("poke 发送成功"); + let poke_notification = notifications::list(&state.db, recipient.id, None, 10) + .await + .unwrap() + .into_iter() + .find(|item| item.kind == "poke") + .expect("poke 通知存在"); let message_marker = "FULL_PRIVATE_MESSAGE_MUST_NOT_ENTER_AUDIT"; - let _ = messages_routes::send( + let Json(sent_message) = messages_routes::send( State(state.clone()), audit_contract_current_user(&sender), Json(messages_routes::SendReq { @@ -1923,6 +4165,14 @@ async fn audit_contract_notifications_poke_and_messages_are_audited_without_cont &sender_rows, &["notification.marked_read", "poke.sent", "message.sent"], ); + audit_contract_assert_exact_targets( + &sender_rows, + &[ + ("notification.marked_read", sender.id.to_string()), + ("poke.sent", poke_notification.id.to_string()), + ("message.sent", sent_message.id.to_string()), + ], + ); audit_contract_assert_payloads_are_typed_and_redacted( &sender_rows, &[poke_marker, message_marker], @@ -1930,6 +4180,10 @@ async fn audit_contract_notifications_poke_and_messages_are_audited_without_cont let recipient_rows = audit_contract_rows_for_actor(&state.db, recipient.id).await; audit_contract_assert_actions(&recipient_rows, &["message.marked_read"]); + audit_contract_assert_exact_targets( + &recipient_rows, + &[("message.marked_read", sender.id.to_string())], + ); audit_contract_assert_payloads_are_typed_and_redacted( &recipient_rows, &[poke_marker, message_marker], @@ -3209,7 +5463,7 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { auth_sessions::AuthIntentPayload::RedisInvalidate { session_handle: session.session_handle.clone(), }, - 2, + i32::MAX, ) .await .unwrap(); @@ -3249,7 +5503,42 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { .await .unwrap() ); - let failed = auth_sessions::fail_intent( + assert!( + auth_sessions::complete_intent(&pool, intent_id, "dead-worker") + .await + .unwrap() + .is_none(), + "旧 worker 不得完成新持有者的 intent" + ); + assert!( + auth_sessions::reschedule_intent( + &pool, + intent_id, + "dead-worker", + "cache_unavailable", + "redacted cache failure", + 1, + ) + .await + .unwrap() + .is_none(), + "旧 worker 不得重排新持有者的 intent" + ); + assert!( + auth_sessions::fail_intent_permanently( + &pool, + intent_id, + "dead-worker", + "invalid_auth_intent", + "invalid intent", + ) + .await + .unwrap() + .is_none(), + "旧 worker 不得永久失败新持有者的 intent" + ); + + let rescheduled = auth_sessions::reschedule_intent( &pool, intent_id, "replacement-worker", @@ -3260,6 +5549,30 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { .await .unwrap() .unwrap(); + assert_eq!(rescheduled.id, intent_id); + assert_eq!(rescheduled.state, auth_sessions::AuthIntentState::Queued); + sqlx::query( + "UPDATE auth_session_intents SET run_after = now() - interval '1 second' WHERE id = $1", + ) + .bind(intent_id) + .execute(&pool) + .await + .unwrap(); + let reclaimed = auth_sessions::claim_intent_for_session(&pool, "final-worker", 300, session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(reclaimed.id, intent_id); + let failed = auth_sessions::fail_intent_permanently( + &pool, + intent_id, + "final-worker", + "invalid_auth_intent", + "invalid intent", + ) + .await + .unwrap() + .unwrap(); assert_eq!(failed.state, auth_sessions::AuthIntentState::Failed); let mut tx = pool.begin().await.unwrap(); let retried = auth_sessions::retry_intent_tx(&mut tx, intent_id) @@ -3268,7 +5581,7 @@ async fn audit_jobs_auth_intent_lease_takeover_and_retry_reuse_same_id() { .unwrap(); tx.commit().await.unwrap(); assert_eq!(retried.id, intent_id); - assert_eq!(retried.attempts, 3); + assert_eq!(retried.attempts, 4); sqlx::query( "INSERT INTO auth_session_intents (session_id, kind, payload) @@ -3381,6 +5694,92 @@ async fn audit_jobs_auth_intent_last_attempt_crash_is_failed_without_reclaim() { .unwrap(); } +/// 旧版本遗留的已耗尽 queued intent 必须在领取扫描时主动收敛到 failed。 +#[tokio::test] +async fn audit_jobs_exhausted_queued_auth_intent_is_failed_without_reclaim() { + let pool = pool().await; + let username = audit_jobs_unique("intent-queued-exhausted-user"); + let user_id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash) VALUES ($1, 'test-only-hash') RETURNING id", + ) + .bind(&username) + .fetch_one(&pool) + .await + .unwrap(); + let mut tx = pool.begin().await.unwrap(); + let session = auth_sessions::create_pending_tx( + &mut tx, + auth_sessions::NewAuthSession { + session_handle: audit_jobs_unique("intent-queued-exhausted-session"), + family_handle: audit_jobs_unique("intent-queued-exhausted-family"), + user_id, + refresh_token_hash: auth_sessions::RefreshTokenHash::parse( + prts_auth::token::sha256_hex("intent-queued-exhausted-refresh"), + ) + .unwrap(), + expires_at: chrono::Utc::now() + chrono::Duration::days(1), + predecessor_id: None, + }, + ) + .await + .unwrap(); + let session = auth_sessions::activate_pending_tx(&mut tx, session.id) + .await + .unwrap() + .unwrap(); + let intent = auth_sessions::enqueue_intent_tx( + &mut tx, + session.id, + auth_sessions::AuthIntentPayload::RedisInvalidate { + session_handle: session.session_handle.clone(), + }, + 1, + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + sqlx::query( + "UPDATE auth_session_intents + SET state = 'queued', attempts = max_attempts, + run_after = now() - interval '1 minute' + WHERE id = $1", + ) + .bind(intent.id) + .execute(&pool) + .await + .unwrap(); + + assert!(auth_sessions::claim_intent_for_session( + &pool, + "queued-exhausted-worker", + 300, + session.id, + ) + .await + .unwrap() + .is_none()); + let persisted: (String, i32, Option) = sqlx::query_as( + "SELECT state, attempts, last_error_code + FROM auth_session_intents WHERE id = $1", + ) + .bind(intent.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(persisted.0, "failed"); + assert_eq!(persisted.1, 1); + assert_eq!( + persisted.2.as_deref(), + Some("auth_intent_attempts_exhausted") + ); + + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&pool) + .await + .unwrap(); +} + /// job payload 同样必须拒绝 raw token 字段,避免异步链路泄密。 #[tokio::test] async fn audit_jobs_job_payload_rejects_raw_tokens() { @@ -4181,13 +6580,10 @@ async fn vector_search_returns_nearest_first() { async fn search_settings_set_and_get_normalizes_values() { use prts_db::search_settings::{self, SearchConfig}; + let _search_settings_guard = SEARCH_SETTINGS_TEST_LOCK.lock().await; let pool = pool().await; - // 清理上次残留(key 固定为 "search.config") - sqlx::query("DELETE FROM settings WHERE key = 'search.config'") - .execute(&pool) - .await - .unwrap(); + let previous = search_settings::get(&pool).await.unwrap(); // 写入超范围值 let cfg = SearchConfig { @@ -4205,11 +6601,7 @@ async fn search_settings_set_and_get_normalizes_values() { ); assert_eq!(got.tm_top_n, 3, "tm_top_n 应被 clamp 到上限 3"); - // 清理 - sqlx::query("DELETE FROM settings WHERE key = 'search.config'") - .execute(&pool) - .await - .unwrap(); + search_settings::set(&pool, previous, None).await.unwrap(); } /// 验证 prts-db::notifications 仓储圆环(迁移 0005): diff --git a/backend/crates/prts-auth/src/jwt.rs b/backend/crates/prts-auth/src/jwt.rs index 5274750..9ea2650 100644 --- a/backend/crates/prts-auth/src/jwt.rs +++ b/backend/crates/prts-auth/src/jwt.rs @@ -22,6 +22,9 @@ pub struct Claims { pub exp: i64, /// 令牌类型,固定 `"access"`(刷新令牌是不透明串,不走 JWT)。 pub typ: String, + /// DB-authoritative session handle;`None` 仅用于识别旧 token,认证边界必须拒绝。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sid: Option, } impl Claims { @@ -113,6 +116,7 @@ mod tests { iat: 1_000, exp: 2_000, typ: "access".to_string(), + sid: Some("session-handle-123456".to_string()), } } diff --git a/backend/crates/prts-common/src/error.rs b/backend/crates/prts-common/src/error.rs index c55f15f..63e363f 100644 --- a/backend/crates/prts-common/src/error.rs +++ b/backend/crates/prts-common/src/error.rs @@ -33,6 +33,10 @@ pub enum Error { #[error("version conflict")] Conflict, + /// 审计记录无法持久化;所有受审计操作必须 fail closed。 + #[error("audit unavailable")] + AuditUnavailable, + /// 数据库错误。 #[error("database error")] Database(#[source] sqlx_error::SqlxError), @@ -51,6 +55,7 @@ impl Error { Error::Forbidden => "forbidden", Error::NotFound => "not_found", Error::Conflict => "conflict", + Error::AuditUnavailable => "AUDIT_UNAVAILABLE", Error::Database(_) => "internal", Error::Internal(_) => "internal", } @@ -86,5 +91,6 @@ mod tests { assert_eq!(Error::Unauthorized.code(), "unauthorized"); assert_eq!(Error::bad_request("x").code(), "bad_request"); assert_eq!(Error::Conflict.code(), "conflict"); + assert_eq!(Error::AuditUnavailable.code(), "AUDIT_UNAVAILABLE"); } } diff --git a/backend/crates/prts-common/src/i18n.rs b/backend/crates/prts-common/src/i18n.rs index 8a19ec2..8426416 100644 --- a/backend/crates/prts-common/src/i18n.rs +++ b/backend/crates/prts-common/src/i18n.rs @@ -55,6 +55,8 @@ fn message_for(code: &str, locale: Locale) -> Option<&'static str> { ("not_found", Locale::En) => "Resource not found", ("conflict", Locale::ZhCn) => "版本冲突,请刷新后重试", ("conflict", Locale::En) => "Version conflict, please refresh and retry", + ("AUDIT_UNAVAILABLE", Locale::ZhCn) => "审计服务暂不可用", + ("AUDIT_UNAVAILABLE", Locale::En) => "Audit service unavailable", ("internal", Locale::ZhCn) => "服务器内部错误", ("internal", Locale::En) => "Internal server error", _ => return None, diff --git a/backend/crates/prts-db/src/api_keys.rs b/backend/crates/prts-db/src/api_keys.rs index fd51c6b..a075148 100644 --- a/backend/crates/prts-db/src/api_keys.rs +++ b/backend/crates/prts-db/src/api_keys.rs @@ -1,6 +1,6 @@ //! API Key 数据访问。库中仅存哈希。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::{ApiKeyRecord, User}; @@ -11,6 +11,18 @@ pub async fn create( name: &str, key_hash: &str, prefix: &str, +) -> Result { + let mut connection = pool.acquire().await?; + create_tx(&mut connection, user_id, name, key_hash, prefix).await +} + +/// 在调用方事务内创建 API Key 记录。 +pub async fn create_tx( + conn: &mut PgConnection, + user_id: i64, + name: &str, + key_hash: &str, + prefix: &str, ) -> Result { sqlx::query_as::<_, ApiKeyRecord>( "INSERT INTO api_keys (user_id, name, key_hash, prefix) @@ -20,7 +32,7 @@ pub async fn create( .bind(name) .bind(key_hash) .bind(prefix) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -49,21 +61,57 @@ pub async fn find_user_by_key_hash( .await } +/// 按 hash 读取 API Key 记录(不返回明文)。 +pub async fn find_by_key_hash( + pool: &PgPool, + key_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiKeyRecord>("SELECT * FROM api_keys WHERE key_hash = $1") + .bind(key_hash) + .fetch_optional(pool) + .await +} + /// 更新 Key 的最近使用时间(鉴权成功后调用,best-effort)。 pub async fn touch_last_used(pool: &PgPool, key_hash: &str) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE key_hash = $1") - .bind(key_hash) - .execute(pool) + let mut connection = pool.acquire().await?; + touch_last_used_tx(&mut connection, key_hash) .await .map(|_| ()) } +/// 在调用方事务内更新最近使用时间,并返回被更新的安全记录。 +pub async fn touch_last_used_tx( + conn: &mut PgConnection, + key_hash: &str, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiKeyRecord>( + "UPDATE api_keys SET last_used_at = now() WHERE key_hash = $1 RETURNING *", + ) + .bind(key_hash) + .fetch_optional(conn) + .await +} + /// 吊销(删除)某用户的一条 Key。返回是否确有删除。 pub async fn revoke(pool: &PgPool, user_id: i64, id: i64) -> Result { - let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2") - .bind(id) - .bind(user_id) - .execute(pool) - .await?; - Ok(res.rows_affected() > 0) + let mut connection = pool.acquire().await?; + revoke_tx(&mut connection, user_id, id) + .await + .map(|record| record.is_some()) +} + +/// 在调用方事务内吊销 API Key,并返回仅含 hash/prefix 的数据库记录供安全审计取 prefix。 +pub async fn revoke_tx( + conn: &mut PgConnection, + user_id: i64, + id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, ApiKeyRecord>( + "DELETE FROM api_keys WHERE id = $1 AND user_id = $2 RETURNING *", + ) + .bind(id) + .bind(user_id) + .fetch_optional(conn) + .await } diff --git a/backend/crates/prts-db/src/audit.rs b/backend/crates/prts-db/src/audit.rs index 50fcb1e..2bb9b25 100644 --- a/backend/crates/prts-db/src/audit.rs +++ b/backend/crates/prts-db/src/audit.rs @@ -43,6 +43,666 @@ pub struct AuditActor<'a> { pub ip: Option<&'a str>, } +/// 已提供凭证的认证方式 allowlist;不得把凭证本身或任意客户端字符串写入审计。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailureMethod { + /// 无法解析出具体 bearer 类型的 Authorization 凭证。 + Authorization, + /// PRTS API Key。 + ApiKey, + /// 自签 access JWT。 + Jwt, + /// 不透明 refresh token。 + Refresh, +} + +impl AuthFailureMethod { + /// 稳定审计 wire value。 + pub const fn as_str(self) -> &'static str { + match self { + Self::Authorization => "authorization", + Self::ApiKey => "api_key", + Self::Jwt => "jwt", + Self::Refresh => "refresh", + } + } +} + +/// 认证失败原因 allowlist;每个值只描述判定类别,不包含凭证或用户内容。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthFailureReason { + InvalidCredential, + InvalidTokenType, + TokenExpired, + MissingSession, + SessionInactive, + UserMismatch, + UserNotFound, + AccountInactive, + InvalidRefresh, +} + +impl AuthFailureReason { + /// 稳定审计 reason code。 + pub const fn as_str(self) -> &'static str { + match self { + Self::InvalidCredential => "invalid_credential", + Self::InvalidTokenType => "invalid_token_type", + Self::TokenExpired => "token_expired", + Self::MissingSession => "missing_session", + Self::SessionInactive => "session_inactive", + Self::UserMismatch => "user_mismatch", + Self::UserNotFound => "user_not_found", + Self::AccountInactive => "account_inactive", + Self::InvalidRefresh => "invalid_refresh", + } + } +} + +/// 业务审计事件的封闭 allowlist。 +/// +/// 每个 variant 精确声明该 action 可以进入 payload 的字段;调用方无法传入通用 JSON、 +/// 完整实体或任意 action,从类型边界阻止密码、token、正文与其它秘密被序列化。 +#[derive(Debug)] +pub enum AuditEvent<'a> { + AuthRegistered { + user_id: i64, + method: &'a str, + status: &'a str, + }, + AuthLoginSucceeded { + user_id: i64, + method: &'a str, + }, + AuthLoginFailed { + user_id: i64, + method: &'a str, + reason_code: &'a str, + }, + AuthFailed { + user_id: i64, + method: AuthFailureMethod, + reason: AuthFailureReason, + }, + AuthOAuthSucceeded { + user_id: i64, + provider: &'a str, + new_user: bool, + }, + AuthOAuthFailed { + target_id: &'a str, + provider: &'a str, + reason_code: &'a str, + }, + AuthTokenIssued { + session_id: i64, + session_handle: &'a str, + method: &'a str, + expires_at: chrono::DateTime, + }, + AuthRefreshRotated { + session_id: i64, + session_handle: &'a str, + predecessor_handle: &'a str, + expires_at: chrono::DateTime, + }, + AuthLoggedOut { + session_id: i64, + session_handle: &'a str, + revoked_sessions: i64, + }, + AuthBootstrapRoleGranted { + user_id: i64, + role: &'a str, + }, + UserProfileUpdated { + user_id: i64, + changed_fields: &'a [&'a str], + translation_lang_count: usize, + }, + ApiKeyCreated { + key_id: i64, + name: &'a str, + prefix: &'a str, + }, + ApiKeyUsed { + key_id: i64, + prefix: &'a str, + }, + ApiKeyRevoked { + key_id: i64, + prefix: &'a str, + }, + SettingsUpdated { + keys: &'a [String], + }, + SearchSettingsUpdated { + changed_fields: &'a [&'a str], + }, + UserPlatformRoleChanged { + user_id: i64, + previous_role: Option<&'a str>, + new_role: Option<&'a str>, + }, + ProjectCreated { + project_id: i64, + slug: &'a str, + visibility: &'a str, + source_langs: &'a [String], + target_lang: &'a str, + }, + ProjectUpdated { + project_id: i64, + changed_fields: &'a [&'a str], + visibility: &'a str, + }, + ProjectDeleted { + project_id: i64, + slug: &'a str, + }, + MembershipUpserted { + project_id: i64, + member_id: i64, + previous_role: Option<&'a str>, + new_role: &'a str, + }, + MembershipRemoved { + project_id: i64, + member_id: i64, + previous_role: &'a str, + }, + EntriesUploaded { + project_id: i64, + file_id: i64, + path: &'a str, + created: usize, + updated: usize, + unchanged: usize, + }, + FileDeleted { + project_id: i64, + file_id: i64, + path: &'a str, + entry_count: i32, + }, + FolderDeleted { + project_id: i64, + folder_id: i64, + path: &'a str, + file_count: i64, + entry_count: i64, + }, + EntryUpdated { + project_id: i64, + entry_id: i64, + previous_version: i64, + new_version: i64, + previous_state: &'a str, + new_state: &'a str, + }, + EntryFlagsUpdated { + project_id: i64, + entry_id: i64, + locked: bool, + hidden: bool, + }, + ProjectExported { + project_id: i64, + file_count: usize, + entry_count: usize, + include_hidden: bool, + }, + NotificationMarkedRead { + user_id: i64, + notification_ids: &'a [i64], + count: u64, + all: bool, + }, + PokeSent { + project_id: i64, + recipient_id: i64, + notification_id: i64, + text_length: usize, + }, + MessageSent { + message_id: i64, + recipient_id: i64, + content_length: usize, + }, + MessageMarkedRead { + other_user_id: i64, + count: u64, + }, +} + +/// 追加一个强类型业务审计事件;业务写与本函数必须复用同一事务连接。 +pub async fn append_event_tx( + conn: &mut PgConnection, + actor: AuditActor<'_>, + event: AuditEvent<'_>, +) -> Result { + let (action, target_type, target_id, project_id_snapshot, payload) = match event { + AuditEvent::AuthRegistered { + user_id, + method, + status, + } => ( + "auth.registered", + "user", + user_id.to_string(), + None, + serde_json::json!({"method": method, "status": status}), + ), + AuditEvent::AuthLoginSucceeded { user_id, method } => ( + "auth.login_succeeded", + "user", + user_id.to_string(), + None, + serde_json::json!({"method": method}), + ), + AuditEvent::AuthLoginFailed { + user_id, + method, + reason_code, + } => ( + "auth.login_failed", + "user", + user_id.to_string(), + None, + serde_json::json!({"method": method, "reason_code": reason_code}), + ), + AuditEvent::AuthFailed { + user_id, + method, + reason, + } => ( + "auth.failed", + "user", + user_id.to_string(), + None, + serde_json::json!({ + "method": method.as_str(), + "reason_code": reason.as_str(), + }), + ), + AuditEvent::AuthOAuthSucceeded { + user_id, + provider, + new_user, + } => ( + "auth.oauth_succeeded", + "user", + user_id.to_string(), + None, + serde_json::json!({"provider": provider, "new_user": new_user}), + ), + AuditEvent::AuthOAuthFailed { + target_id, + provider, + reason_code, + } => ( + "auth.oauth_failed", + "oauth_identity", + target_id.to_string(), + None, + serde_json::json!({"provider": provider, "reason_code": reason_code}), + ), + AuditEvent::AuthTokenIssued { + session_id, + session_handle, + method, + expires_at, + } => ( + "auth.token_issued", + "auth_session", + session_id.to_string(), + None, + serde_json::json!({ + "session_handle": session_handle, + "method": method, + "expires_at": expires_at, + }), + ), + AuditEvent::AuthRefreshRotated { + session_id, + session_handle, + predecessor_handle, + expires_at, + } => ( + "auth.refresh_rotated", + "auth_session", + session_id.to_string(), + None, + serde_json::json!({ + "session_handle": session_handle, + "predecessor_handle": predecessor_handle, + "expires_at": expires_at, + }), + ), + AuditEvent::AuthLoggedOut { + session_id, + session_handle, + revoked_sessions, + } => ( + "auth.logged_out", + "auth_session", + session_id.to_string(), + None, + serde_json::json!({ + "session_handle": session_handle, + "revoked_sessions": revoked_sessions, + }), + ), + AuditEvent::AuthBootstrapRoleGranted { user_id, role } => ( + "auth.bootstrap_role_granted", + "user", + user_id.to_string(), + None, + serde_json::json!({"role": role}), + ), + AuditEvent::UserProfileUpdated { + user_id, + changed_fields, + translation_lang_count, + } => ( + "user.profile_updated", + "user", + user_id.to_string(), + None, + serde_json::json!({ + "changed_fields": changed_fields, + "translation_lang_count": translation_lang_count, + }), + ), + AuditEvent::ApiKeyCreated { + key_id, + name, + prefix, + } => ( + "api_key.created", + "api_key", + key_id.to_string(), + None, + serde_json::json!({"name": name, "prefix": prefix}), + ), + AuditEvent::ApiKeyUsed { key_id, prefix } => ( + "api_key.used", + "api_key", + key_id.to_string(), + None, + serde_json::json!({"prefix": prefix}), + ), + AuditEvent::ApiKeyRevoked { key_id, prefix } => ( + "api_key.revoked", + "api_key", + key_id.to_string(), + None, + serde_json::json!({"prefix": prefix}), + ), + AuditEvent::SettingsUpdated { keys } => ( + "settings.updated", + "settings", + "platform".to_string(), + None, + serde_json::json!({"keys": keys, "count": keys.len()}), + ), + AuditEvent::SearchSettingsUpdated { changed_fields } => ( + "search_settings.updated", + "settings", + "search.config".to_string(), + None, + serde_json::json!({"changed_fields": changed_fields}), + ), + AuditEvent::UserPlatformRoleChanged { + user_id, + previous_role, + new_role, + } => ( + "user.platform_role_changed", + "user", + user_id.to_string(), + None, + serde_json::json!({ + "previous_role": previous_role, + "new_role": new_role, + }), + ), + AuditEvent::ProjectCreated { + project_id, + slug, + visibility, + source_langs, + target_lang, + } => ( + "project.created", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({ + "slug": slug, + "visibility": visibility, + "source_langs": source_langs, + "target_lang": target_lang, + }), + ), + AuditEvent::ProjectUpdated { + project_id, + changed_fields, + visibility, + } => ( + "project.updated", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({ + "changed_fields": changed_fields, + "visibility": visibility, + }), + ), + AuditEvent::ProjectDeleted { project_id, slug } => ( + "project.deleted", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({"slug": slug}), + ), + AuditEvent::MembershipUpserted { + project_id, + member_id, + previous_role, + new_role, + } => ( + "membership.upserted", + "membership", + format!("{project_id}:{member_id}"), + Some(project_id), + serde_json::json!({ + "member_id": member_id, + "previous_role": previous_role, + "new_role": new_role, + }), + ), + AuditEvent::MembershipRemoved { + project_id, + member_id, + previous_role, + } => ( + "membership.removed", + "membership", + format!("{project_id}:{member_id}"), + Some(project_id), + serde_json::json!({ + "member_id": member_id, + "previous_role": previous_role, + }), + ), + AuditEvent::EntriesUploaded { + project_id, + file_id, + path, + created, + updated, + unchanged, + } => ( + "entries.uploaded", + "file", + file_id.to_string(), + Some(project_id), + serde_json::json!({ + "file_id": file_id, + "path": path, + "created": created, + "updated": updated, + "unchanged": unchanged, + }), + ), + AuditEvent::FileDeleted { + project_id, + file_id, + path, + entry_count, + } => ( + "file.deleted", + "file", + file_id.to_string(), + Some(project_id), + serde_json::json!({"path": path, "entry_count": entry_count}), + ), + AuditEvent::FolderDeleted { + project_id, + folder_id, + path, + file_count, + entry_count, + } => ( + "folder.deleted", + "folder", + folder_id.to_string(), + Some(project_id), + serde_json::json!({ + "path": path, + "file_count": file_count, + "entry_count": entry_count, + }), + ), + AuditEvent::EntryUpdated { + project_id, + entry_id, + previous_version, + new_version, + previous_state, + new_state, + } => ( + "entry.updated", + "entry", + entry_id.to_string(), + Some(project_id), + serde_json::json!({ + "previous_version": previous_version, + "new_version": new_version, + "previous_state": previous_state, + "new_state": new_state, + }), + ), + AuditEvent::EntryFlagsUpdated { + project_id, + entry_id, + locked, + hidden, + } => ( + "entry.flags_updated", + "entry", + entry_id.to_string(), + Some(project_id), + serde_json::json!({"locked": locked, "hidden": hidden}), + ), + AuditEvent::ProjectExported { + project_id, + file_count, + entry_count, + include_hidden, + } => ( + "project.exported", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({ + "file_count": file_count, + "entry_count": entry_count, + "include_hidden": include_hidden, + }), + ), + AuditEvent::NotificationMarkedRead { + user_id, + notification_ids, + count, + all, + } => ( + "notification.marked_read", + "user", + user_id.to_string(), + None, + serde_json::json!({ + "notification_ids": notification_ids, + "count": count, + "all": all, + }), + ), + AuditEvent::PokeSent { + project_id, + recipient_id, + notification_id, + text_length, + } => ( + "poke.sent", + "notification", + notification_id.to_string(), + Some(project_id), + serde_json::json!({ + "recipient_id": recipient_id, + "project_id": project_id, + "notification_id": notification_id, + "text_length": text_length, + }), + ), + AuditEvent::MessageSent { + message_id, + recipient_id, + content_length, + } => ( + "message.sent", + "message", + message_id.to_string(), + None, + serde_json::json!({ + "recipient_id": recipient_id, + "message_id": message_id, + "content_length": content_length, + }), + ), + AuditEvent::MessageMarkedRead { + other_user_id, + count, + } => ( + "message.marked_read", + "conversation", + other_user_id.to_string(), + None, + serde_json::json!({"other_user_id": other_user_id, "count": count}), + ), + }; + append_tx( + conn, + actor, + action, + target_type, + &target_id, + project_id_snapshot, + payload, + ) + .await +} + /// `job.retried` 的字段 allowlist。 #[derive(Debug, Serialize)] struct JobRetriedPayload<'a> { diff --git a/backend/crates/prts-db/src/auth_sessions.rs b/backend/crates/prts-db/src/auth_sessions.rs index bea99aa..6b6c7e4 100644 --- a/backend/crates/prts-db/src/auth_sessions.rs +++ b/backend/crates/prts-db/src/auth_sessions.rs @@ -70,6 +70,17 @@ impl AuthSessionState { _ => None, } } + + /// 稳定数据库状态字符串,供审计与 worker 编排使用。 + pub const fn as_str(self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Active => "active", + Self::Rotating => "rotating", + Self::Revoked => "revoked", + Self::Expired => "expired", + } + } } /// 对上层公开的会话快照。持久化 refresh hash 刻意不在该类型中。 @@ -130,6 +141,28 @@ fn into_session(row: Option) -> Result, sqlx row.map(AuthSession::try_from).transpose() } +/// 按 id 读取任意状态的会话,供 durable outbox worker 判定恢复动作。 +pub async fn find_by_id(pool: &PgPool, id: i64) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionRow>("SELECT * FROM auth_sessions WHERE id = $1") + .bind(id) + .fetch_optional(pool) + .await?; + into_session(row) +} + +/// 在调用方事务内锁定任意状态的会话。 +pub async fn find_by_id_for_update_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + let row = + sqlx::query_as::<_, AuthSessionRow>("SELECT * FROM auth_sessions WHERE id = $1 FOR UPDATE") + .bind(id) + .fetch_optional(conn) + .await?; + into_session(row) +} + /// 新建 pending 会话所需的非秘密字段。 #[derive(Debug, Clone)] pub struct NewAuthSession { @@ -180,6 +213,17 @@ pub async fn lock_active_unexpired_by_refresh_hash_tx( into_session(row) } +/// 仅返回 refresh hash 对应的用户 id,供失败认证写脱敏 target;不暴露持久化 hash。 +pub async fn find_user_id_by_refresh_hash( + pool: &PgPool, + refresh_token_hash: &RefreshTokenHash, +) -> Result, sqlx::Error> { + sqlx::query_scalar("SELECT user_id FROM auth_sessions WHERE refresh_token_hash = $1") + .bind(refresh_token_hash.as_str()) + .fetch_optional(pool) + .await +} + /// Redis cache miss 只可回源 active 且未过期的 opaque handle。 pub async fn find_active_unexpired_by_handle( pool: &PgPool, @@ -248,6 +292,24 @@ pub async fn revoke_unexpired_tx( into_session(row) } +/// 在改密/封禁等安全操作中批量吊销用户全部未过期会话。 +pub async fn revoke_all_unexpired_for_user_tx( + conn: &mut PgConnection, + user_id: i64, +) -> Result, sqlx::Error> { + let rows = sqlx::query_as::<_, AuthSessionRow>( + "UPDATE auth_sessions + SET state = 'revoked', updated_at = now() + WHERE user_id = $1 AND state IN ('pending', 'active', 'rotating') + AND expires_at > now() + RETURNING *", + ) + .bind(user_id) + .fetch_all(conn) + .await?; + rows.into_iter().map(AuthSession::try_from).collect() +} + /// 把已经到期的非终态会话物化为 expired;未到期行不会被误终结。 pub async fn expire_due_tx( conn: &mut PgConnection, @@ -390,6 +452,9 @@ pub enum AuthIntentState { Cancelled, } +/// Redis 暂时失败的重试延迟上限,防止异常调用方写入无界 interval。 +const AUTH_INTENT_MAX_RETRY_DELAY_SECONDS: i64 = 300; + impl AuthIntentState { fn parse(value: &str) -> Option { match value { @@ -520,6 +585,7 @@ async fn claim_intent_inner( lease_seconds: i64, session_id: Option, ) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; let row = sqlx::query_as::<_, AuthSessionIntentRow>( "WITH exhausted AS ( UPDATE auth_session_intents @@ -531,6 +597,16 @@ async fn claim_intent_inner( AND attempts >= max_attempts AND ($3::BIGINT IS NULL OR session_id = $3) RETURNING id + ), exhausted_queued AS ( + UPDATE auth_session_intents + SET state = 'failed', worker_id = NULL, lease_until = NULL, + last_error_code = 'auth_intent_attempts_exhausted', + last_error_message = 'auth intent exhausted all attempts', + completed_at = now(), updated_at = now() + WHERE state = 'queued' AND run_after <= now() + AND attempts >= max_attempts + AND ($3::BIGINT IS NULL OR session_id = $3) + RETURNING id ), candidate AS ( SELECT id FROM auth_session_intents @@ -555,8 +631,9 @@ async fn claim_intent_inner( .bind(worker_id) .bind(lease_seconds.max(1) as f64) .bind(session_id) - .fetch_optional(pool) + .fetch_optional(&mut *tx) .await?; + tx.commit().await?; into_intent(row) } @@ -590,6 +667,7 @@ pub async fn complete_intent( let row = sqlx::query_as::<_, AuthSessionIntentRow>( "UPDATE auth_session_intents SET state = 'succeeded', worker_id = NULL, lease_until = NULL, + last_error_code = NULL, last_error_message = NULL, completed_at = now(), updated_at = now() WHERE id = $1 AND state = 'running' AND worker_id = $2 AND lease_until > now() @@ -602,8 +680,8 @@ pub async fn complete_intent( into_intent(row) } -/// 记录 intent 错误并按剩余 attempts 进入 queued 或 failed。 -pub async fn fail_intent( +/// 记录暂时错误并始终重排同一 intent;仅当前有效 lease 持有者可更新。 +pub async fn reschedule_intent( pool: &PgPool, id: i64, worker_id: &str, @@ -613,13 +691,38 @@ pub async fn fail_intent( ) -> Result, sqlx::Error> { let row = sqlx::query_as::<_, AuthSessionIntentRow>( "UPDATE auth_session_intents - SET state = CASE WHEN attempts < max_attempts THEN 'queued' ELSE 'failed' END, - run_after = CASE WHEN attempts < max_attempts - THEN now() + make_interval(secs => $5) ELSE run_after END, + SET state = 'queued', + run_after = now() + make_interval(secs => $5), worker_id = NULL, lease_until = NULL, last_error_code = $3, last_error_message = $4, - completed_at = CASE WHEN attempts < max_attempts THEN NULL ELSE now() END, - updated_at = now() + completed_at = NULL, updated_at = now() + WHERE id = $1 AND state = 'running' AND worker_id = $2 + AND lease_until > now() + RETURNING *", + ) + .bind(id) + .bind(worker_id) + .bind(error_code) + .bind(error_message) + .bind(retry_after_seconds.clamp(0, AUTH_INTENT_MAX_RETRY_DELAY_SECONDS) as f64) + .fetch_optional(pool) + .await?; + into_intent(row) +} + +/// 将 kind/payload 等永久数据错误置为 failed;暂时 Redis 错误不得调用本入口。 +pub async fn fail_intent_permanently( + pool: &PgPool, + id: i64, + worker_id: &str, + error_code: &str, + error_message: &str, +) -> Result, sqlx::Error> { + let row = sqlx::query_as::<_, AuthSessionIntentRow>( + "UPDATE auth_session_intents + SET state = 'failed', worker_id = NULL, lease_until = NULL, + last_error_code = $3, last_error_message = $4, + completed_at = now(), updated_at = now() WHERE id = $1 AND state = 'running' AND worker_id = $2 AND lease_until > now() RETURNING *", @@ -628,7 +731,6 @@ pub async fn fail_intent( .bind(worker_id) .bind(error_code) .bind(error_message) - .bind(retry_after_seconds.max(0) as f64) .fetch_optional(pool) .await?; into_intent(row) diff --git a/backend/crates/prts-db/src/entries.rs b/backend/crates/prts-db/src/entries.rs index 75aa183..66a5823 100644 --- a/backend/crates/prts-db/src/entries.rs +++ b/backend/crates/prts-db/src/entries.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use serde::Deserialize; -use sqlx::{PgPool, Postgres, QueryBuilder}; +use sqlx::{PgConnection, PgPool, Postgres, QueryBuilder}; use crate::models::{Entry, EntryVersion}; @@ -62,19 +62,31 @@ pub async fn bulk_upsert( project_id: i64, entries: &[UploadEntry], editor_id: Option, +) -> Result { + let mut tx = pool.begin().await?; + let stats = bulk_upsert_tx(&mut tx, file_id, project_id, entries, editor_id).await?; + tx.commit().await?; + Ok(stats) +} + +/// 在调用方事务内批量 upsert 词条;所有 chunk 与最终审计共用一次提交。 +pub async fn bulk_upsert_tx( + conn: &mut PgConnection, + file_id: i64, + project_id: i64, + entries: &[UploadEntry], + editor_id: Option, ) -> Result { let mut stats = UpsertStats::default(); for chunk in entries.chunks(500) { - let mut tx = pool.begin().await?; - let keys: Vec = chunk.iter().map(|e| e.key.clone()).collect(); let existing_rows: Vec<(i64, String, serde_json::Value)> = sqlx::query_as( "SELECT id, key, original FROM entries WHERE file_id = $1 AND key = ANY($2)", ) .bind(file_id) .bind(&keys) - .fetch_all(&mut *tx) + .fetch_all(&mut *conn) .await?; let mut existing: HashMap = HashMap::new(); @@ -95,7 +107,7 @@ pub async fn bulk_upsert( ) .bind(id) .bind(editor_id) - .execute(&mut *tx) + .execute(&mut *conn) .await?; // 覆盖源文、置未翻译、版本+1(保留 translation) sqlx::query( @@ -106,7 +118,7 @@ pub async fn bulk_upsert( .bind(&e.original) .bind(e.context.clone().unwrap_or_default()) .bind(editor_id) - .execute(&mut *tx) + .execute(&mut *conn) .await?; stats.updated += 1; } else { @@ -129,11 +141,9 @@ pub async fn bulk_upsert( .push_bind(e.translation.clone().unwrap_or_default()) .push_bind(normalize_state(e.state.as_deref())); }); - qb.build().execute(&mut *tx).await?; + qb.build().execute(&mut *conn).await?; stats.created += to_insert.len(); } - - tx.commit().await?; } Ok(stats) @@ -195,6 +205,19 @@ pub async fn get( .await } +/// 在调用方事务内锁定项目所属词条并返回一致快照。 +pub async fn get_for_update_tx( + conn: &mut PgConnection, + project_id: i64, + entry_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Entry>("SELECT * FROM entries WHERE id = $1 AND project_id = $2 FOR UPDATE") + .bind(entry_id) + .bind(project_id) + .fetch_optional(conn) + .await +} + /// 乐观锁更新译文与状态:仅当 `expected_version` 匹配才更新。 /// 返回 `Ok(None)` 表示版本冲突(已被他人修改)。同时记录一条历史。 pub async fn update_translation( @@ -207,6 +230,31 @@ pub async fn update_translation( editor_id: Option, ) -> Result, sqlx::Error> { let mut tx = pool.begin().await?; + let updated = update_translation_tx( + &mut tx, + entry_id, + expected_version, + translation, + state, + kind, + editor_id, + ) + .await?; + tx.commit().await?; + Ok(updated) +} + +/// 在调用方事务内乐观锁更新译文/状态并写 entry version。 +#[allow(clippy::too_many_arguments)] +pub async fn update_translation_tx( + conn: &mut PgConnection, + entry_id: i64, + expected_version: i64, + translation: &str, + state: &str, + kind: &str, + editor_id: Option, +) -> Result, sqlx::Error> { let updated: Option = sqlx::query_as::<_, Entry>( "UPDATE entries SET translation = $3, state = $4, version = version + 1, updated_by = $5 WHERE id = $1 AND version = $2 RETURNING *", @@ -216,7 +264,7 @@ pub async fn update_translation( .bind(translation) .bind(state) .bind(editor_id) - .fetch_optional(&mut *tx) + .fetch_optional(&mut *conn) .await?; if let Some(ref e) = updated { @@ -230,11 +278,9 @@ pub async fn update_translation( .bind(&e.translation) .bind(&e.state) .bind(editor_id) - .execute(&mut *tx) + .execute(&mut *conn) .await?; } - - tx.commit().await?; Ok(updated) } @@ -245,6 +291,18 @@ pub async fn set_flags( entry_id: i64, locked: Option, hidden: Option, +) -> Result, sqlx::Error> { + let mut connection = pool.acquire().await?; + set_flags_tx(&mut connection, project_id, entry_id, locked, hidden).await +} + +/// 在调用方事务内设置词条正交 flags。 +pub async fn set_flags_tx( + conn: &mut PgConnection, + project_id: i64, + entry_id: i64, + locked: Option, + hidden: Option, ) -> Result, sqlx::Error> { sqlx::query_as::<_, Entry>( "UPDATE entries SET locked = COALESCE($3, locked), hidden = COALESCE($4, hidden) @@ -254,7 +312,7 @@ pub async fn set_flags( .bind(project_id) .bind(locked) .bind(hidden) - .fetch_optional(pool) + .fetch_optional(conn) .await } diff --git a/backend/crates/prts-db/src/files.rs b/backend/crates/prts-db/src/files.rs index 4dcfa5e..bc02c37 100644 --- a/backend/crates/prts-db/src/files.rs +++ b/backend/crates/prts-db/src/files.rs @@ -1,12 +1,12 @@ //! 文件夹与文件数据访问。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::{File, Folder}; /// 取得或创建某路径的文件夹,返回其 id。 async fn get_or_create_folder( - pool: &PgPool, + conn: &mut PgConnection, project_id: i64, parent_id: Option, name: &str, @@ -21,7 +21,7 @@ async fn get_or_create_folder( .bind(parent_id) .bind(name) .bind(path) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -31,6 +31,18 @@ pub async fn ensure_file_at_path( pool: &PgPool, project_id: i64, full_path: &str, +) -> Result { + let mut tx = pool.begin().await?; + let file = ensure_file_at_path_tx(&mut tx, project_id, full_path).await?; + tx.commit().await?; + Ok(file) +} + +/// 在调用方事务内沿路径创建文件夹并确保文件存在。 +pub async fn ensure_file_at_path_tx( + conn: &mut PgConnection, + project_id: i64, + full_path: &str, ) -> Result { let parts: Vec<&str> = full_path.split('/').filter(|s| !s.is_empty()).collect(); let (file_name, folder_parts) = parts @@ -45,7 +57,7 @@ pub async fn ensure_file_at_path( } else { format!("{cur_path}/{seg}") }; - let id = get_or_create_folder(pool, project_id, parent_id, seg, &cur_path).await?; + let id = get_or_create_folder(&mut *conn, project_id, parent_id, seg, &cur_path).await?; parent_id = Some(id); } @@ -58,7 +70,7 @@ pub async fn ensure_file_at_path( .bind(parent_id) .bind(file_name) .bind(full_path) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -75,6 +87,47 @@ pub async fn find_file( .await } +/// 在调用方事务内锁定文件并取得删除审计快照。 +pub async fn find_file_for_update_tx( + conn: &mut PgConnection, + project_id: i64, + file_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, File>("SELECT * FROM files WHERE id = $1 AND project_id = $2 FOR UPDATE") + .bind(file_id) + .bind(project_id) + .fetch_optional(conn) + .await +} + +/// 按 id 查文件夹(限定项目)。 +pub async fn find_folder( + pool: &PgPool, + project_id: i64, + folder_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Folder>("SELECT * FROM folders WHERE id = $1 AND project_id = $2") + .bind(folder_id) + .bind(project_id) + .fetch_optional(pool) + .await +} + +/// 在调用方事务内锁定文件夹并取得删除审计快照。 +pub async fn find_folder_for_update_tx( + conn: &mut PgConnection, + project_id: i64, + folder_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Folder>( + "SELECT * FROM folders WHERE id = $1 AND project_id = $2 FOR UPDATE", + ) + .bind(folder_id) + .bind(project_id) + .fetch_optional(conn) + .await +} + /// 列出项目的全部文件夹。 pub async fn list_folders(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, Folder>("SELECT * FROM folders WHERE project_id = $1 ORDER BY path") @@ -91,16 +144,60 @@ pub async fn list_files(pool: &PgPool, project_id: i64) -> Result, sql .await } +/// 统计指定文件夹子树内的文件数与物化词条数,供删除审计保存无正文元数据。 +pub async fn folder_tree_counts( + pool: &PgPool, + project_id: i64, + folder_path: &str, +) -> Result<(i64, i64), sqlx::Error> { + sqlx::query_as( + "SELECT COUNT(*), COALESCE(SUM(entry_count), 0)::BIGINT + FROM files + WHERE project_id = $1 AND path LIKE $2 || '/%'", + ) + .bind(project_id) + .bind(folder_path) + .fetch_one(pool) + .await +} + +/// 在调用方事务内统计已锁定文件夹子树,确保计数与随后删除属于同一快照。 +pub async fn folder_tree_counts_tx( + conn: &mut PgConnection, + project_id: i64, + folder_path: &str, +) -> Result<(i64, i64), sqlx::Error> { + sqlx::query_as( + "SELECT COUNT(*), COALESCE(SUM(entry_count), 0)::BIGINT + FROM files + WHERE project_id = $1 AND path LIKE $2 || '/%'", + ) + .bind(project_id) + .bind(folder_path) + .fetch_one(conn) + .await +} + /// 删除文件(级联词条)。 pub async fn delete_file( pool: &PgPool, project_id: i64, file_id: i64, +) -> Result { + let mut connection = pool.acquire().await?; + delete_file_tx(&mut connection, project_id, file_id).await +} + +/// 在调用方事务内删除文件及其级联词条。 +pub async fn delete_file_tx( + conn: &mut PgConnection, + project_id: i64, + file_id: i64, ) -> Result { let res = sqlx::query("DELETE FROM files WHERE id = $1 AND project_id = $2") .bind(file_id) .bind(project_id) - .execute(pool) + .execute(conn) .await?; Ok(res.rows_affected() > 0) } @@ -110,22 +207,41 @@ pub async fn delete_folder( pool: &PgPool, project_id: i64, folder_id: i64, +) -> Result { + let mut connection = pool.acquire().await?; + delete_folder_tx(&mut connection, project_id, folder_id).await +} + +/// 在调用方事务内删除文件夹及其级联子项。 +pub async fn delete_folder_tx( + conn: &mut PgConnection, + project_id: i64, + folder_id: i64, ) -> Result { let res = sqlx::query("DELETE FROM folders WHERE id = $1 AND project_id = $2") .bind(folder_id) .bind(project_id) - .execute(pool) + .execute(conn) .await?; Ok(res.rows_affected() > 0) } /// 重算并更新文件的词条计数。 pub async fn refresh_entry_count(pool: &PgPool, file_id: i64) -> Result<(), sqlx::Error> { + let mut connection = pool.acquire().await?; + refresh_entry_count_tx(&mut connection, file_id).await +} + +/// 在调用方事务内刷新文件词条计数。 +pub async fn refresh_entry_count_tx( + conn: &mut PgConnection, + file_id: i64, +) -> Result<(), sqlx::Error> { sqlx::query( "UPDATE files SET entry_count = (SELECT COUNT(*) FROM entries WHERE file_id = $1) WHERE id = $1", ) .bind(file_id) - .execute(pool) + .execute(conn) .await .map(|_| ()) } diff --git a/backend/crates/prts-db/src/memberships.rs b/backend/crates/prts-db/src/memberships.rs index 155d64d..e6c3f1a 100644 --- a/backend/crates/prts-db/src/memberships.rs +++ b/backend/crates/prts-db/src/memberships.rs @@ -1,6 +1,6 @@ //! 项目成员数据访问。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::MemberInfo; @@ -10,6 +10,17 @@ pub async fn upsert( project_id: i64, user_id: i64, role: &str, +) -> Result<(), sqlx::Error> { + let mut connection = pool.acquire().await?; + upsert_tx(&mut connection, project_id, user_id, role).await +} + +/// 在调用方事务内新增或更新成员角色。 +pub async fn upsert_tx( + conn: &mut PgConnection, + project_id: i64, + user_id: i64, + role: &str, ) -> Result<(), sqlx::Error> { sqlx::query( "INSERT INTO memberships (project_id, user_id, role) VALUES ($1, $2, $3) @@ -18,7 +29,7 @@ pub async fn upsert( .bind(project_id) .bind(user_id) .bind(role) - .execute(pool) + .execute(conn) .await .map(|_| ()) } @@ -38,6 +49,22 @@ pub async fn find_role( Ok(row.map(|(r,)| r)) } +/// 在调用方事务内读取成员角色;调用方应先锁定项目行以覆盖成员不存在的 upsert 情形。 +pub async fn find_role_tx( + conn: &mut PgConnection, + project_id: i64, + user_id: i64, +) -> Result, sqlx::Error> { + let row: Option<(String,)> = sqlx::query_as( + "SELECT role FROM memberships WHERE project_id = $1 AND user_id = $2 FOR UPDATE", + ) + .bind(project_id) + .bind(user_id) + .fetch_optional(conn) + .await?; + Ok(row.map(|(role,)| role)) +} + /// 列出项目成员(含用户名/头像)。 pub async fn list(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { sqlx::query_as::<_, MemberInfo>( @@ -53,10 +80,20 @@ pub async fn list(pool: &PgPool, project_id: i64) -> Result, sql /// 移除成员。 pub async fn remove(pool: &PgPool, project_id: i64, user_id: i64) -> Result { + let mut connection = pool.acquire().await?; + remove_tx(&mut connection, project_id, user_id).await +} + +/// 在调用方事务内移除成员。 +pub async fn remove_tx( + conn: &mut PgConnection, + project_id: i64, + user_id: i64, +) -> Result { let res = sqlx::query("DELETE FROM memberships WHERE project_id = $1 AND user_id = $2") .bind(project_id) .bind(user_id) - .execute(pool) + .execute(conn) .await?; Ok(res.rows_affected() > 0) } @@ -71,3 +108,16 @@ pub async fn count_role(pool: &PgPool, project_id: i64, role: &str) -> Result Result { + sqlx::query_scalar("SELECT COUNT(*) FROM memberships WHERE project_id = $1 AND role = $2") + .bind(project_id) + .bind(role) + .fetch_one(conn) + .await +} diff --git a/backend/crates/prts-db/src/messages.rs b/backend/crates/prts-db/src/messages.rs index be2b3a1..8a8f6f8 100644 --- a/backend/crates/prts-db/src/messages.rs +++ b/backend/crates/prts-db/src/messages.rs @@ -4,7 +4,7 @@ //! `list_threads` 聚合出每个对话方的最后一条消息与我方未读数。 //! 收发双方须共享 ≥1 项目的门限校验在应用层(`prts-api`)完成,本层只管存取。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::{ConversationThread, Message}; @@ -14,6 +14,17 @@ pub async fn create( sender_id: i64, recipient_id: i64, content: &str, +) -> Result { + let mut connection = pool.acquire().await?; + create_tx(&mut connection, sender_id, recipient_id, content).await +} + +/// 在调用方事务内创建私信。 +pub async fn create_tx( + conn: &mut PgConnection, + sender_id: i64, + recipient_id: i64, + content: &str, ) -> Result { sqlx::query_as::<_, Message>( "INSERT INTO messages (sender_id, recipient_id, content) VALUES ($1, $2, $3) @@ -22,7 +33,7 @@ pub async fn create( .bind(sender_id) .bind(recipient_id) .bind(content) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -93,15 +104,25 @@ pub async fn list_threads(pool: &PgPool, me: i64) -> Result Result<(), sqlx::Error> { - sqlx::query( + let mut connection = pool.acquire().await?; + mark_read_tx(&mut connection, me, other).await.map(|_| ()) +} + +/// 在调用方事务内标记私信已读,并返回实际更新数供审计。 +pub async fn mark_read_tx( + conn: &mut PgConnection, + me: i64, + other: i64, +) -> Result { + let result = sqlx::query( "UPDATE messages SET read_at = now() WHERE recipient_id = $1 AND sender_id = $2 AND read_at IS NULL", ) .bind(me) .bind(other) - .execute(pool) + .execute(conn) .await?; - Ok(()) + Ok(result.rows_affected()) } /// 我方(`me`)全部未读私信总数(跨所有会话)。 diff --git a/backend/crates/prts-db/src/notifications.rs b/backend/crates/prts-db/src/notifications.rs index f09c48b..797785a 100644 --- a/backend/crates/prts-db/src/notifications.rs +++ b/backend/crates/prts-db/src/notifications.rs @@ -1,6 +1,6 @@ //! 通知仓储:收件人维度的通知增查改(键集分页)。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::Notification; @@ -10,6 +10,17 @@ pub async fn create( user_id: i64, kind: &str, payload: &serde_json::Value, +) -> Result { + let mut connection = pool.acquire().await?; + create_tx(&mut connection, user_id, kind, payload).await +} + +/// 在调用方事务内创建通知。 +pub async fn create_tx( + conn: &mut PgConnection, + user_id: i64, + kind: &str, + payload: &serde_json::Value, ) -> Result { sqlx::query_as::<_, Notification>( "INSERT INTO notifications (user_id, type, payload) VALUES ($1, $2, $3) @@ -18,7 +29,7 @@ pub async fn create( .bind(user_id) .bind(kind) .bind(payload) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -67,13 +78,25 @@ pub async fn unread_count(pool: &PgPool, user_id: i64) -> Result Result<(), sqlx::Error> { - if ids.is_empty() { + let mut connection = pool.acquire().await?; + mark_read_tx(&mut connection, user_id, ids) + .await + .map(|_| ()) +} + +/// 在调用方事务内标记通知已读,并返回实际更新数供审计。 +pub async fn mark_read_tx( + conn: &mut PgConnection, + user_id: i64, + ids: &[i64], +) -> Result { + let result = if ids.is_empty() { sqlx::query( "UPDATE notifications SET read_at = now() WHERE user_id = $1 AND read_at IS NULL", ) .bind(user_id) - .execute(pool) - .await?; + .execute(&mut *conn) + .await? } else { sqlx::query( "UPDATE notifications SET read_at = now() @@ -81,8 +104,8 @@ pub async fn mark_read(pool: &PgPool, user_id: i64, ids: &[i64]) -> Result<(), s ) .bind(user_id) .bind(ids) - .execute(pool) - .await?; - } - Ok(()) + .execute(conn) + .await? + }; + Ok(result.rows_affected()) } diff --git a/backend/crates/prts-db/src/projects.rs b/backend/crates/prts-db/src/projects.rs index 236ed6d..52e5ba9 100644 --- a/backend/crates/prts-db/src/projects.rs +++ b/backend/crates/prts-db/src/projects.rs @@ -1,6 +1,6 @@ //! 项目数据访问。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::Project; @@ -15,6 +15,32 @@ pub async fn create( source_langs: &[String], target_lang: &str, owner_id: i64, +) -> Result { + let mut connection = pool.acquire().await?; + create_tx( + &mut connection, + slug, + name, + description, + visibility, + source_langs, + target_lang, + owner_id, + ) + .await +} + +/// 在调用方事务内创建项目。 +#[allow(clippy::too_many_arguments)] +pub async fn create_tx( + conn: &mut PgConnection, + slug: &str, + name: &str, + description: &str, + visibility: &str, + source_langs: &[String], + target_lang: &str, + owner_id: i64, ) -> Result { sqlx::query_as::<_, Project>( "INSERT INTO projects (slug, name, description, visibility, source_langs, target_lang, owner_id) @@ -27,7 +53,7 @@ pub async fn create( .bind(source_langs) .bind(target_lang) .bind(owner_id) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -39,6 +65,17 @@ pub async fn find_by_id(pool: &PgPool, id: i64) -> Result, sqlx: .await } +/// 在调用方事务内锁定项目并返回一致快照。 +pub async fn find_by_id_for_update_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE id = $1 FOR UPDATE") + .bind(id) + .fetch_optional(conn) + .await +} + /// 按 slug 查找。 pub async fn find_by_slug(pool: &PgPool, slug: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE slug = $1") @@ -94,6 +131,30 @@ pub async fn update( visibility: &str, source_langs: &[String], target_lang: &str, +) -> Result { + let mut connection = pool.acquire().await?; + update_tx( + &mut connection, + id, + name, + description, + visibility, + source_langs, + target_lang, + ) + .await +} + +/// 在调用方事务内更新项目元信息。 +#[allow(clippy::too_many_arguments)] +pub async fn update_tx( + conn: &mut PgConnection, + id: i64, + name: &str, + description: &str, + visibility: &str, + source_langs: &[String], + target_lang: &str, ) -> Result { sqlx::query_as::<_, Project>( "UPDATE projects SET name = $2, description = $3, visibility = $4, @@ -106,15 +167,21 @@ pub async fn update( .bind(visibility) .bind(source_langs) .bind(target_lang) - .fetch_one(pool) + .fetch_one(conn) .await } /// 删除项目(级联文件夹/文件/词条/成员)。 pub async fn delete(pool: &PgPool, id: i64) -> Result { + let mut connection = pool.acquire().await?; + delete_tx(&mut connection, id).await +} + +/// 在调用方事务内删除项目。 +pub async fn delete_tx(conn: &mut PgConnection, id: i64) -> Result { let res = sqlx::query("DELETE FROM projects WHERE id = $1") .bind(id) - .execute(pool) + .execute(conn) .await?; Ok(res.rows_affected() > 0) } diff --git a/backend/crates/prts-db/src/search_settings.rs b/backend/crates/prts-db/src/search_settings.rs index b8ad078..0b07cd1 100644 --- a/backend/crates/prts-db/src/search_settings.rs +++ b/backend/crates/prts-db/src/search_settings.rs @@ -1,8 +1,8 @@ //! 搜索/向量化运行时配置(存 settings 表,管理后台可改)。serde-only(不耦合 utoipa)。 use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SearchConfig { pub embedding_enabled: bool, pub embedding_model: String, @@ -28,6 +28,14 @@ impl Default for SearchConfig { } const KEY: &str = "search.config"; +// Advisory-lock namespace `PRTSSEAR`:固定 64-bit key,仅串行化 search.config 写事务。 +const SEARCH_CONFIG_LOCK_KEY: i64 = 0x5052_5453_5345_4152; + +#[derive(Debug, Clone)] +pub struct SearchConfigChange { + pub before: SearchConfig, + pub after: SearchConfig, +} /// 规范化:clamp 危险字段到安全区间(纯函数,便于单测)。 pub fn normalize(mut cfg: SearchConfig) -> SearchConfig { @@ -47,9 +55,38 @@ pub async fn get(pool: &PgPool) -> Result { /// 写入(规范化后)。 pub async fn set(pool: &PgPool, cfg: SearchConfig, by: Option) -> Result<(), sqlx::Error> { - let cfg = normalize(cfg); - let v = serde_json::to_value(&cfg).expect("SearchConfig serializes"); - crate::settings::set(pool, KEY, &v, by).await + let mut tx = pool.begin().await?; + set_tx(&mut tx, cfg, by).await?; + tx.commit().await +} + +/// 取得事务级 advisory lock 后读取配置;缺失仍保持“默认但不落库”的既有语义。 +pub async fn get_for_update_tx(conn: &mut PgConnection) -> Result { + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(SEARCH_CONFIG_LOCK_KEY) + .execute(&mut *conn) + .await?; + let value: Option = + sqlx::query_scalar("SELECT value FROM settings WHERE key = $1") + .bind(KEY) + .fetch_optional(conn) + .await?; + Ok(value + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default()) +} + +/// 在调用方事务内写入规范化后的搜索设置。 +pub async fn set_tx( + conn: &mut PgConnection, + cfg: SearchConfig, + by: Option, +) -> Result { + let before = get_for_update_tx(&mut *conn).await?; + let after = normalize(cfg); + let value = serde_json::to_value(&after).expect("SearchConfig serializes"); + crate::settings::set_tx(conn, KEY, &value, by).await?; + Ok(SearchConfigChange { before, after }) } #[cfg(test)] diff --git a/backend/crates/prts-db/src/settings.rs b/backend/crates/prts-db/src/settings.rs index 1d25262..dd551c3 100644 --- a/backend/crates/prts-db/src/settings.rs +++ b/backend/crates/prts-db/src/settings.rs @@ -1,6 +1,6 @@ //! 平台设置数据访问(key → JSONB)。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::Setting; @@ -20,6 +20,17 @@ pub async fn set( key: &str, value: &serde_json::Value, updated_by: Option, +) -> Result<(), sqlx::Error> { + let mut connection = pool.acquire().await?; + set_tx(&mut connection, key, value, updated_by).await +} + +/// 在调用方事务内写入或更新设置。 +pub async fn set_tx( + conn: &mut PgConnection, + key: &str, + value: &serde_json::Value, + updated_by: Option, ) -> Result<(), sqlx::Error> { sqlx::query( "INSERT INTO settings (key, value, updated_by) @@ -31,7 +42,7 @@ pub async fn set( .bind(key) .bind(value) .bind(updated_by) - .execute(pool) + .execute(conn) .await .map(|_| ()) } diff --git a/backend/crates/prts-db/src/users.rs b/backend/crates/prts-db/src/users.rs index 40fa2db..d6b01ad 100644 --- a/backend/crates/prts-db/src/users.rs +++ b/backend/crates/prts-db/src/users.rs @@ -1,6 +1,6 @@ //! 用户与外部账号的数据访问。 -use sqlx::PgPool; +use sqlx::{PgConnection, PgPool}; use crate::models::{ExternalAccount, User}; @@ -12,6 +12,17 @@ pub async fn find_by_id(pool: &PgPool, id: i64) -> Result, sqlx::Er .await } +/// 在调用方事务内锁定用户并返回一致快照。 +pub async fn find_by_id_for_update_tx( + conn: &mut PgConnection, + id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1 FOR UPDATE") + .bind(id) + .fetch_optional(conn) + .await +} + /// 按用户名查找。 pub async fn find_by_username(pool: &PgPool, username: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1") @@ -20,6 +31,17 @@ pub async fn find_by_username(pool: &PgPool, username: &str) -> Result Result, sqlx::Error> { + sqlx::query_as::<_, User>("SELECT * FROM users WHERE username = $1 FOR UPDATE") + .bind(username) + .fetch_optional(conn) + .await +} + /// 按邮箱查找。 pub async fn find_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, User>("SELECT * FROM users WHERE email = $1") @@ -45,6 +67,18 @@ pub async fn create_password_user( email: Option<&str>, password_hash: &str, status: &str, +) -> Result { + let mut connection = pool.acquire().await?; + create_password_user_tx(&mut connection, username, email, password_hash, status).await +} + +/// 在调用方事务内创建账号密码用户。 +pub async fn create_password_user_tx( + conn: &mut PgConnection, + username: &str, + email: Option<&str>, + password_hash: &str, + status: &str, ) -> Result { sqlx::query_as::<_, User>( "INSERT INTO users (username, email, password_hash, status) @@ -54,7 +88,7 @@ pub async fn create_password_user( .bind(email) .bind(password_hash) .bind(status) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -65,6 +99,25 @@ pub async fn update_profile( description: &str, avatar_url: Option<&str>, translation_langs: &[String], +) -> Result { + let mut connection = pool.acquire().await?; + update_profile_tx( + &mut connection, + id, + description, + avatar_url, + translation_langs, + ) + .await +} + +/// 在调用方事务内更新个人资料。 +pub async fn update_profile_tx( + conn: &mut PgConnection, + id: i64, + description: &str, + avatar_url: Option<&str>, + translation_langs: &[String], ) -> Result { sqlx::query_as::<_, User>( "UPDATE users SET description = $2, avatar_url = $3, translation_langs = $4 @@ -74,7 +127,7 @@ pub async fn update_profile( .bind(description) .bind(avatar_url) .bind(translation_langs) - .fetch_one(pool) + .fetch_one(conn) .await } @@ -83,20 +136,36 @@ pub async fn set_platform_role( pool: &PgPool, id: i64, role: Option<&str>, +) -> Result<(), sqlx::Error> { + let mut connection = pool.acquire().await?; + set_platform_role_tx(&mut connection, id, role).await +} + +/// 在调用方事务内设置或清除平台角色。 +pub async fn set_platform_role_tx( + conn: &mut PgConnection, + id: i64, + role: Option<&str>, ) -> Result<(), sqlx::Error> { sqlx::query("UPDATE users SET platform_role = $2 WHERE id = $1") .bind(id) .bind(role) - .execute(pool) + .execute(conn) .await .map(|_| ()) } /// 标记邮箱已验证并激活账号。 pub async fn mark_email_verified(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> { + let mut connection = pool.acquire().await?; + mark_email_verified_tx(&mut connection, id).await +} + +/// 在调用方事务内验证邮箱并激活账号。 +pub async fn mark_email_verified_tx(conn: &mut PgConnection, id: i64) -> Result<(), sqlx::Error> { sqlx::query("UPDATE users SET email_verified = TRUE, status = 'active' WHERE id = $1") .bind(id) - .execute(pool) + .execute(conn) .await .map(|_| ()) } @@ -141,13 +210,28 @@ pub async fn create_oauth_user( raw: &serde_json::Value, ) -> Result { let mut tx = pool.begin().await?; + let user = + create_oauth_user_tx(&mut tx, username, avatar_url, provider, external_id, raw).await?; + tx.commit().await?; + Ok(user) +} + +/// 在调用方事务内创建 OAuth 用户与外部账号关联。 +pub async fn create_oauth_user_tx( + conn: &mut PgConnection, + username: &str, + avatar_url: Option<&str>, + provider: &str, + external_id: &str, + raw: &serde_json::Value, +) -> Result { let user = sqlx::query_as::<_, User>( "INSERT INTO users (username, avatar_url, status, email_verified) VALUES ($1, $2, 'active', TRUE) RETURNING *", ) .bind(username) .bind(avatar_url) - .fetch_one(&mut *tx) + .fetch_one(&mut *conn) .await?; sqlx::query( @@ -158,9 +242,7 @@ pub async fn create_oauth_user( .bind(provider) .bind(external_id) .bind(raw) - .execute(&mut *tx) + .execute(conn) .await?; - - tx.commit().await?; Ok(user) } From 184b810cd4a5d7603b5d9883724e2f1e91df1499 Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Sun, 12 Jul 2026 17:30:03 +0800 Subject: [PATCH 14/45] feat: add workspace primary search foundation --- backend/Cargo.lock | 9 + backend/Cargo.toml | 1 + backend/crates/prts-api/src/auth/project.rs | 22 + backend/crates/prts-api/src/dto.rs | 3 + .../crates/prts-api/src/dto/capabilities.rs | 40 ++ backend/crates/prts-api/src/error.rs | 2 + backend/crates/prts-api/src/jobs/mod.rs | 12 + .../prts-api/src/jobs/reindex_project.rs | 261 ++++++++ .../prts-api/src/jobs/repair_languages.rs | 301 ++++++++++ backend/crates/prts-api/src/main.rs | 23 +- backend/crates/prts-api/src/routes/entries.rs | 7 +- backend/crates/prts-api/src/routes/files.rs | 31 +- .../src/routes/language_resolution.rs | 397 ++++++++++++ backend/crates/prts-api/src/routes/mod.rs | 9 + .../crates/prts-api/src/routes/projects.rs | 91 ++- backend/crates/prts-api/src/routes/search.rs | 8 +- backend/crates/prts-api/src/routes/users.rs | 8 +- .../crates/prts-api/tests/db_integration.rs | 470 ++++++++++++++- backend/crates/prts-common/src/error.rs | 15 + backend/crates/prts-common/src/i18n.rs | 10 + backend/crates/prts-core/Cargo.toml | 1 + backend/crates/prts-core/src/capabilities.rs | 71 +++ backend/crates/prts-core/src/language.rs | 128 ++++ backend/crates/prts-core/src/lib.rs | 3 + backend/crates/prts-db/Cargo.toml | 1 + backend/crates/prts-db/src/audit.rs | 41 ++ backend/crates/prts-db/src/entries.rs | 52 +- backend/crates/prts-db/src/files.rs | 26 +- backend/crates/prts-db/src/jobs.rs | 12 +- .../crates/prts-db/src/language_resolution.rs | 182 ++++++ backend/crates/prts-db/src/lib.rs | 2 + backend/crates/prts-db/src/models.rs | 74 +++ backend/crates/prts-db/src/search.rs | 23 +- backend/crates/prts-db/src/stats.rs | 90 +++ .../migrations/0008_workspace_meta_stats.sql | 565 ++++++++++++++++++ .../migrations/0009_primary_source_search.sql | 101 ++++ 36 files changed, 3013 insertions(+), 79 deletions(-) create mode 100644 backend/crates/prts-api/src/dto/capabilities.rs create mode 100644 backend/crates/prts-api/src/jobs/reindex_project.rs create mode 100644 backend/crates/prts-api/src/jobs/repair_languages.rs create mode 100644 backend/crates/prts-api/src/routes/language_resolution.rs create mode 100644 backend/crates/prts-core/src/capabilities.rs create mode 100644 backend/crates/prts-core/src/language.rs create mode 100644 backend/crates/prts-db/src/language_resolution.rs create mode 100644 backend/crates/prts-db/src/stats.rs create mode 100644 backend/migrations/0008_workspace_meta_stats.sql create mode 100644 backend/migrations/0009_primary_source_search.sql diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 697306d..cfb321d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "uuid", ] [[package]] @@ -1109,6 +1110,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1600,6 +1607,7 @@ dependencies = [ name = "prts-core" version = "0.1.0" dependencies = [ + "language-tags", "prts-common", "serde", "serde_json", @@ -1617,6 +1625,7 @@ dependencies = [ "serde_json", "sqlx", "tracing", + "uuid", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f84fc90..f7a5d1e 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -63,6 +63,7 @@ dotenvy = "0.15" # —— 通用类型 —— chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } +language-tags = "0.3.2" # —— 认证 / 加密(纯 Rust,避免 C / ring 构建)—— argon2 = { version = "0.5", features = ["std"] } diff --git a/backend/crates/prts-api/src/auth/project.rs b/backend/crates/prts-api/src/auth/project.rs index 3e86936..6250b94 100644 --- a/backend/crates/prts-api/src/auth/project.rs +++ b/backend/crates/prts-api/src/auth/project.rs @@ -39,6 +39,28 @@ impl ProjectAccess { self.effective_role().map(|r| r.has(node)).unwrap_or(false) } + /// 当前主体的显式 capability;owner-only 能力只比较 `projects.owner_id`。 + pub fn capabilities( + &self, + primary_source_release_ready: bool, + ) -> prts_core::capabilities::ProjectCapabilities { + prts_core::capabilities::ProjectCapabilities::for_subject( + self.can_view(), + self.effective_role(), + self.user_id == Some(self.project.owner_id), + primary_source_release_ready, + ) + } + + /// 语言歧义项目禁止普通搜索、上传和语言写入。 + pub fn require_language_ready(&self) -> Result<(), ApiError> { + if self.project.language_repair_state == "ready" { + Ok(()) + } else { + Err(Error::ProjectLanguageResolutionRequired.into()) + } + } + /// 要求可见,否则 404(不泄露私有项目存在性)。 pub fn require_view(&self) -> Result<(), ApiError> { if self.can_view() { diff --git a/backend/crates/prts-api/src/dto.rs b/backend/crates/prts-api/src/dto.rs index b5916ed..8597f2a 100644 --- a/backend/crates/prts-api/src/dto.rs +++ b/backend/crates/prts-api/src/dto.rs @@ -5,6 +5,9 @@ use utoipa::ToSchema; use prts_db::models::User; +#[path = "dto/capabilities.rs"] +pub mod capabilities; + /// 用户对外表示。 #[derive(Debug, Serialize, ToSchema)] pub struct UserDto { diff --git a/backend/crates/prts-api/src/dto/capabilities.rs b/backend/crates/prts-api/src/dto/capabilities.rs new file mode 100644 index 0000000..a32e28e --- /dev/null +++ b/backend/crates/prts-api/src/dto/capabilities.rs @@ -0,0 +1,40 @@ +//! OpenAPI-facing capability DTO。 + +use serde::Serialize; +use utoipa::ToSchema; + +/// 当前主体在项目中的显式能力。 +#[derive(Debug, Clone, Copy, Serialize, ToSchema)] +pub struct ProjectCapabilitiesDto { + pub view_project: bool, + pub manage_project: bool, + pub manage_members: bool, + pub upload_files: bool, + pub download: bool, + pub edit_entry: bool, + pub review_entry: bool, + pub edit_locked_entry: bool, + pub force_save_presence: bool, + pub resolve_languages: bool, + pub change_primary_source: bool, + pub delete_project: bool, +} + +impl From for ProjectCapabilitiesDto { + fn from(value: prts_core::capabilities::ProjectCapabilities) -> Self { + Self { + view_project: value.view_project, + manage_project: value.manage_project, + manage_members: value.manage_members, + upload_files: value.upload_files, + download: value.download, + edit_entry: value.edit_entry, + review_entry: value.review_entry, + edit_locked_entry: value.edit_locked_entry, + force_save_presence: value.force_save_presence, + resolve_languages: value.resolve_languages, + change_primary_source: value.change_primary_source, + delete_project: value.delete_project, + } + } +} diff --git a/backend/crates/prts-api/src/error.rs b/backend/crates/prts-api/src/error.rs index a8a375c..edd3c20 100644 --- a/backend/crates/prts-api/src/error.rs +++ b/backend/crates/prts-api/src/error.rs @@ -88,6 +88,8 @@ fn status_for(code: &str) -> StatusCode { "not_found" => StatusCode::NOT_FOUND, "conflict" => StatusCode::CONFLICT, "AUDIT_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE, + "INVALID_LANGUAGE_TAG" | "DUPLICATE_LANGUAGE_TAG" => StatusCode::BAD_REQUEST, + "PROJECT_LANGUAGE_RESOLUTION_REQUIRED" => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/backend/crates/prts-api/src/jobs/mod.rs b/backend/crates/prts-api/src/jobs/mod.rs index 53fac1f..909ff3b 100644 --- a/backend/crates/prts-api/src/jobs/mod.rs +++ b/backend/crates/prts-api/src/jobs/mod.rs @@ -14,22 +14,34 @@ pub use prts_db::jobs::JobResult; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JobErrorCode { HandlerUnavailable, + InvalidPayload, + DatabaseUnavailable, + LanguageResolutionRequired, } impl JobErrorCode { pub const fn as_str(self) -> &'static str { match self { Self::HandlerUnavailable => "job_handler_unavailable", + Self::InvalidPayload => "job_invalid_payload", + Self::DatabaseUnavailable => "job_database_unavailable", + Self::LanguageResolutionRequired => "project_language_resolution_required", } } pub const fn redacted_message(self) -> &'static str { match self { Self::HandlerUnavailable => "job handler is not available", + Self::InvalidPayload => "job payload is invalid", + Self::DatabaseUnavailable => "job database operation failed", + Self::LanguageResolutionRequired => "project language resolution is required", } } } +pub mod reindex_project; +pub mod repair_languages; + /// 任务执行失败的稳定信息。 #[derive(Debug, Clone)] pub struct JobExecutionError { diff --git a/backend/crates/prts-api/src/jobs/reindex_project.rs b/backend/crates/prts-api/src/jobs/reindex_project.rs new file mode 100644 index 0000000..9db8f5c --- /dev/null +++ b/backend/crates/prts-api/src/jobs/reindex_project.rs @@ -0,0 +1,261 @@ +//! Primary-source lexical reindex and embedding-stage handlers。 + +use std::sync::Arc; + +use futures_util::future::BoxFuture; +use prts_search::qwen::QwenProvider; +use tokio::sync::RwLock; + +use super::{JobErrorCode, JobExecutionError, JobHandler, JobResult}; + +/// 按 canonical 主源 exact key 分批重建词法派生列。 +pub struct ReindexProjectHandler { + db: prts_db::Db, +} + +impl ReindexProjectHandler { + pub fn new(db: prts_db::Db) -> Self { + Self { db } + } +} + +impl JobHandler for ReindexProjectHandler { + fn kind(&self) -> &'static str { + "primary_source_lexical_reindex" + } + + fn execute<'a>( + &'a self, + job: &'a prts_db::models::Job, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let project_id = job.project_id.ok_or_else(invalid_payload)?; + let mut cursor = 0i64; + loop { + let mut tx = self.db.begin().await.map_err(|_| database_error())?; + let ids: Vec = sqlx::query_scalar( + "SELECT id FROM entries WHERE project_id = $1 AND id > $2 + ORDER BY id LIMIT 500", + ) + .bind(project_id) + .bind(cursor) + .fetch_all(&mut *tx) + .await + .map_err(|_| database_error())?; + if ids.is_empty() { + tx.rollback().await.map_err(|_| database_error())?; + break; + } + sqlx::query( + "UPDATE entries SET original = original + WHERE project_id = $1 AND id = ANY($2::BIGINT[])", + ) + .bind(project_id) + .bind(&ids) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + cursor = *ids.last().ok_or_else(invalid_payload)?; + sqlx::query( + "UPDATE jobs SET stage = 'lexical', progress_current = $2 WHERE id = $1", + ) + .bind(job.id) + .bind(cursor) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + tx.commit().await.map_err(|_| database_error())?; + } + + let mut tx = self.db.begin().await.map_err(|_| database_error())?; + let embedding_job_id: i64 = match sqlx::query_scalar( + "SELECT id FROM jobs WHERE project_id = $1 + AND kind = 'primary_source_embedding_backfill' + AND state IN ('queued', 'running', 'paused') + ORDER BY id DESC LIMIT 1 FOR UPDATE", + ) + .bind(project_id) + .fetch_optional(&mut *tx) + .await + .map_err(|_| database_error())? + { + Some(existing_id) => existing_id, + None => sqlx::query_scalar( + "INSERT INTO jobs (kind, project_id, stage, payload, max_attempts) + VALUES ('primary_source_embedding_backfill', $1, 'embedding', '{}', 5) + RETURNING id", + ) + .bind(project_id) + .fetch_one(&mut *tx) + .await + .map_err(|_| database_error())?, + }; + sqlx::query( + "UPDATE projects SET lexical_state = 'ready', embedding_state = 'pending', + embedding_job_id = $2 WHERE id = $1", + ) + .bind(project_id) + .bind(embedding_job_id) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + tx.commit().await.map_err(|_| database_error())?; + Ok(JobResult::Completed) + }) + } +} + +/// 现有 embedding sweep 负责实际向量化;本 handler 标记 release 可领取该阶段。 +pub struct EmbeddingBackfillHandler { + db: prts_db::Db, + provider: Arc>, + search_config: Arc>, +} + +impl EmbeddingBackfillHandler { + pub fn new( + db: prts_db::Db, + provider: Arc>, + search_config: Arc>, + ) -> Self { + Self { + db, + provider, + search_config, + } + } +} + +impl JobHandler for EmbeddingBackfillHandler { + fn kind(&self) -> &'static str { + "primary_source_embedding_backfill" + } + + fn execute<'a>( + &'a self, + job: &'a prts_db::models::Job, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + let project_id = job.project_id.ok_or_else(invalid_payload)?; + let config = self.search_config.read().await.clone(); + let Some(provider) = (config.embedding_enabled) + .then(|| self.provider.as_ref().as_ref()) + .flatten() + else { + let mut tx = self.db.begin().await.map_err(|_| database_error())?; + sqlx::query("UPDATE projects SET embedding_state = 'degraded' WHERE id = $1") + .bind(project_id) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + sqlx::query("UPDATE jobs SET stage = 'embedding_skipped' WHERE id = $1") + .bind(job.id) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + tx.commit().await.map_err(|_| database_error())?; + return Ok(JobResult::EmbeddingSkipped); + }; + + let mut tx = self.db.begin().await.map_err(|_| database_error())?; + sqlx::query("UPDATE projects SET embedding_state = 'running' WHERE id = $1") + .bind(project_id) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + sqlx::query("UPDATE jobs SET stage = 'embedding' WHERE id = $1") + .bind(job.id) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + tx.commit().await.map_err(|_| database_error())?; + + let mut cursor = 0i64; + loop { + let rows: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, source_text FROM entries + WHERE project_id = $1 AND id > $2 AND source_text <> '' + ORDER BY id LIMIT 50", + ) + .bind(project_id) + .bind(cursor) + .fetch_all(&self.db) + .await + .map_err(|_| database_error())?; + if rows.is_empty() { + break; + } + for chunk in rows.chunks(config.embedding_batch.clamp(1, 10) as usize) { + let texts: Vec = chunk + .iter() + .map(|(_, source_text)| source_text.clone()) + .collect(); + let vectors = provider + .embed_batch(&config.embedding_base_url, &config.embedding_model, &texts) + .await + .map_err(|_| JobExecutionError { + code: JobErrorCode::DatabaseUnavailable, + message: "embedding provider request failed".to_string(), + retryable: true, + })?; + if vectors.len() != chunk.len() { + return Err(JobExecutionError { + code: JobErrorCode::InvalidPayload, + message: "embedding provider returned mismatched batch".to_string(), + retryable: true, + }); + } + let mut tx = self.db.begin().await.map_err(|_| database_error())?; + for ((entry_id, captured_source), vector) in chunk.iter().zip(vectors) { + sqlx::query( + "UPDATE entries SET embedding = $1, embed_attempts = 0 + WHERE id = $2 AND project_id = $3 AND source_text = $4", + ) + .bind(pgvector::Vector::from(vector)) + .bind(entry_id) + .bind(project_id) + .bind(captured_source) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + } + cursor = chunk + .last() + .map(|(entry_id, _)| *entry_id) + .unwrap_or(cursor); + sqlx::query( + "UPDATE jobs SET progress_current = $2, stage = 'embedding' WHERE id = $1", + ) + .bind(job.id) + .bind(cursor) + .execute(&mut *tx) + .await + .map_err(|_| database_error())?; + tx.commit().await.map_err(|_| database_error())?; + } + } + sqlx::query("UPDATE projects SET embedding_state = 'ready' WHERE id = $1") + .bind(project_id) + .execute(&self.db) + .await + .map_err(|_| database_error())?; + Ok(JobResult::Completed) + }) + } +} + +fn invalid_payload() -> JobExecutionError { + JobExecutionError { + code: JobErrorCode::InvalidPayload, + message: "project-scoped job lacks project id".to_string(), + retryable: false, + } +} + +fn database_error() -> JobExecutionError { + JobExecutionError { + code: JobErrorCode::DatabaseUnavailable, + message: "project reindex database operation failed".to_string(), + retryable: true, + } +} diff --git a/backend/crates/prts-api/src/jobs/repair_languages.rs b/backend/crates/prts-api/src/jobs/repair_languages.rs new file mode 100644 index 0000000..ccfbbdc --- /dev/null +++ b/backend/crates/prts-api/src/jobs/repair_languages.rs @@ -0,0 +1,301 @@ +//! Durable legacy BCP-47 repair handler。 + +use std::collections::BTreeMap; + +use futures_util::future::BoxFuture; +use serde_json::{Map, Value}; + +use super::{JobErrorCode, JobExecutionError, JobHandler, JobResult}; + +/// 分批规范化 legacy users/projects/entries 的语言数据。 +pub struct RepairLanguagesHandler { + db: prts_db::Db, +} + +impl RepairLanguagesHandler { + pub fn new(db: prts_db::Db) -> Self { + Self { db } + } + + fn database_error() -> JobExecutionError { + JobExecutionError { + code: JobErrorCode::DatabaseUnavailable, + message: "language repair database operation failed".to_string(), + retryable: true, + } + } +} + +impl JobHandler for RepairLanguagesHandler { + fn kind(&self) -> &'static str { + "language_repair" + } + + fn execute<'a>( + &'a self, + job: &'a prts_db::models::Job, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + match job.project_id { + Some(project_id) => self.repair_project(job.id, project_id).await?, + None => self.repair_users(job.id).await?, + } + Ok(JobResult::Completed) + }) + } +} + +impl RepairLanguagesHandler { + async fn repair_users(&self, job_id: i64) -> Result<(), JobExecutionError> { + let mut cursor = 0i64; + loop { + let users: Vec<(i64, Vec)> = sqlx::query_as( + "SELECT id, translation_langs FROM users WHERE id > $1 ORDER BY id LIMIT 500", + ) + .bind(cursor) + .fetch_all(&self.db) + .await + .map_err(|_| Self::database_error())?; + if users.is_empty() { + break; + } + let mut tx = self.db.begin().await.map_err(|_| Self::database_error())?; + for (user_id, languages) in &users { + match prts_core::canonicalize_language_tags(languages) { + Ok(canonical) => { + sqlx::query("UPDATE users SET translation_langs = $2 WHERE id = $1") + .bind(user_id) + .bind(canonical) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + } + Err(_) => { + sqlx::query( + "INSERT INTO language_resolution_issues ( + user_id, entity_type, entity_id_snapshot, issue_kind, metadata + ) VALUES ($1, 'user', $1::TEXT, 'invalid_tag', + jsonb_build_object('language_count', $2)) + ON CONFLICT DO NOTHING", + ) + .bind(user_id) + .bind(languages.len() as i64) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + sqlx::query("UPDATE users SET translation_langs = '{}' WHERE id = $1") + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + } + } + cursor = *user_id; + } + sqlx::query("UPDATE jobs SET stage = 'users', progress_current = $2 WHERE id = $1") + .bind(job_id) + .bind(cursor) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + tx.commit().await.map_err(|_| Self::database_error())?; + } + Ok(()) + } + + async fn repair_project(&self, job_id: i64, project_id: i64) -> Result<(), JobExecutionError> { + let project = prts_db::projects::find_by_id(&self.db, project_id) + .await + .map_err(|_| Self::database_error())? + .ok_or(JobExecutionError { + code: JobErrorCode::InvalidPayload, + message: "language repair project disappeared".to_string(), + retryable: false, + })?; + if project.language_repair_state == "ready" + && project.language_repair_job_id == Some(job_id) + && project.lexical_job_id.is_some() + { + return Ok(()); + } + let (source_languages, primary_source_language, target_language) = + match prts_core::language::canonicalize_project_languages( + &project.source_langs, + project + .primary_source_lang + .as_deref() + .or_else(|| project.source_langs.first().map(String::as_str)), + &project.target_lang, + ) { + Ok(languages) => languages, + Err(error) => { + self.mark_project_unresolved(project_id, error.code()) + .await?; + return Err(JobExecutionError { + code: JobErrorCode::LanguageResolutionRequired, + message: "project language configuration requires owner resolution" + .to_string(), + retryable: false, + }); + } + }; + + let mut cursor = 0i64; + let mut unresolved = false; + loop { + let rows: Vec<(i64, Value)> = sqlx::query_as( + "SELECT id, original FROM entries + WHERE project_id = $1 AND id > $2 ORDER BY id LIMIT 500", + ) + .bind(project_id) + .bind(cursor) + .fetch_all(&self.db) + .await + .map_err(|_| Self::database_error())?; + if rows.is_empty() { + break; + } + let mut tx = self.db.begin().await.map_err(|_| Self::database_error())?; + for (entry_id, original) in &rows { + match canonicalize_original(original, &primary_source_language) { + Ok(canonical) => { + sqlx::query("UPDATE entries SET original = $2 WHERE id = $1") + .bind(entry_id) + .bind(canonical) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + } + Err(issue_kind) => { + unresolved = true; + sqlx::query( + "INSERT INTO language_resolution_issues ( + project_id, entry_id, entity_type, entity_id_snapshot, + issue_kind, metadata + ) VALUES ($1, $2, 'entry', $2::TEXT, $3, + jsonb_build_object('key_count', $4)) + ON CONFLICT DO NOTHING", + ) + .bind(project_id) + .bind(entry_id) + .bind(issue_kind) + .bind(original.as_object().map_or(0, Map::len) as i64) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + } + } + cursor = *entry_id; + } + sqlx::query("UPDATE jobs SET stage = 'entries', progress_current = $2 WHERE id = $1") + .bind(job_id) + .bind(cursor) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + tx.commit().await.map_err(|_| Self::database_error())?; + } + + if unresolved { + self.mark_project_unresolved(project_id, "entry_language_issue") + .await?; + return Err(JobExecutionError { + code: JobErrorCode::LanguageResolutionRequired, + message: "entry language keys require owner resolution".to_string(), + retryable: false, + }); + } + + let mut tx = self.db.begin().await.map_err(|_| Self::database_error())?; + let lexical_job = prts_db::jobs::create_tx( + &mut tx, + prts_db::jobs::NewJob { + kind: prts_db::jobs::JobKind::PrimarySourceLexicalReindex, + project_id: Some(project_id), + stage: "lexical".to_string(), + progress_total: None, + max_attempts: 5, + run_after: chrono::Utc::now(), + }, + ) + .await + .map_err(|_| Self::database_error())?; + sqlx::query( + "UPDATE projects SET source_langs = $2, primary_source_lang = $3, + target_lang = $4, language_repair_state = 'ready', + lexical_state = 'rebuilding', lexical_job_id = $5, + embedding_state = 'pending', embedding_job_id = NULL + WHERE id = $1", + ) + .bind(project_id) + .bind(source_languages) + .bind(&primary_source_language) + .bind(target_language) + .bind(lexical_job.id) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + sqlx::query( + "UPDATE workspace_foundation_state SET + ready_project_count = (SELECT count(*) FROM projects WHERE language_repair_state = 'ready'), + unresolved_project_count = (SELECT count(*) FROM projects WHERE language_repair_state = 'needs_language_resolution'), + updated_at = now() WHERE singleton", + ) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + tx.commit().await.map_err(|_| Self::database_error())?; + Ok(()) + } + + async fn mark_project_unresolved( + &self, + project_id: i64, + reason: &str, + ) -> Result<(), JobExecutionError> { + let mut tx = self.db.begin().await.map_err(|_| Self::database_error())?; + sqlx::query( + "UPDATE projects SET language_repair_state = 'needs_language_resolution', + lexical_state = 'pending' WHERE id = $1", + ) + .bind(project_id) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + sqlx::query( + "INSERT INTO language_resolution_issues ( + project_id, entity_type, entity_id_snapshot, issue_kind, metadata + ) VALUES ($1, 'project', $1::TEXT, 'invalid_tag', jsonb_build_object('reason', $2)) + ON CONFLICT DO NOTHING", + ) + .bind(project_id) + .bind(reason) + .execute(&mut *tx) + .await + .map_err(|_| Self::database_error())?; + tx.commit().await.map_err(|_| Self::database_error())?; + Ok(()) + } +} + +fn canonicalize_original( + original: &Value, + primary_source_language: &str, +) -> Result { + let object = original.as_object().ok_or("invalid_tag")?; + let mut canonical = BTreeMap::::new(); + for (raw_tag, value) in object { + let tag = prts_core::canonicalize_language_tag(raw_tag).map_err(|_| "invalid_tag")?; + if let Some(existing) = canonical.get(&tag) { + if existing != value { + return Err("conflicting_original_keys"); + } + } else { + canonical.insert(tag, value.clone()); + } + } + if !canonical.contains_key(primary_source_language) { + return Err("primary_not_in_sources"); + } + Ok(Value::Object(canonical.into_iter().collect())) +} diff --git a/backend/crates/prts-api/src/main.rs b/backend/crates/prts-api/src/main.rs index d98bd0f..8eaa4df 100644 --- a/backend/crates/prts-api/src/main.rs +++ b/backend/crates/prts-api/src/main.rs @@ -97,8 +97,27 @@ async fn main() -> anyhow::Result<()> { // 启动后台嵌入 sweep(clones cheap: pool 引用计数,Arc 指针)。 crate::embed_worker::spawn(db.clone(), embedder.clone(), search_rt.clone()); - // 持久化任务 worker。foundation 注册表为空时保持 dormant,后续阶段逐类注册 handler。 - let job_registry = crate::jobs::JobRegistry::new(Vec::new()); + // Foundation handlers:legacy 语言 repair 与主源两阶段重建均由同一可恢复 worker 领取。 + let job_registry = crate::jobs::JobRegistry::new(vec![ + Arc::new(crate::jobs::repair_languages::RepairLanguagesHandler::new( + db.clone(), + )), + Arc::new(crate::jobs::reindex_project::ReindexProjectHandler::new( + db.clone(), + )), + Arc::new(crate::jobs::reindex_project::EmbeddingBackfillHandler::new( + db.clone(), + embedder.clone(), + search_rt.clone(), + )), + ]); + sqlx::query( + "UPDATE workspace_foundation_state SET lexical_worker_registered = TRUE, updated_at = now() + WHERE singleton", + ) + .execute(&db) + .await + .map_err(|error| anyhow::anyhow!("workspace foundation readiness update failed: {error}"))?; let (job_worker, mut job_worker_task) = crate::job_worker::spawn( db.clone(), job_registry.clone(), diff --git a/backend/crates/prts-api/src/routes/entries.rs b/backend/crates/prts-api/src/routes/entries.rs index d4441ff..1053800 100644 --- a/backend/crates/prts-api/src/routes/entries.rs +++ b/backend/crates/prts-api/src/routes/entries.rs @@ -83,6 +83,7 @@ pub async fn upload( ) -> Result, ApiError> { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_FILE_UPLOAD)?; + access.require_language_ready()?; let path = req.path.trim().trim_matches('/'); let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); @@ -106,9 +107,9 @@ pub async fn upload( .await .map_err(db_err)? .ok_or(Error::NotFound)?; - paccess::load_locked_tx(&mut tx, &user, project) - .await? - .require_node(nodes::PROJECT_FILE_UPLOAD)?; + let locked_access = paccess::load_locked_tx(&mut tx, &user, project).await?; + locked_access.require_node(nodes::PROJECT_FILE_UPLOAD)?; + locked_access.require_language_ready()?; let file = prts_db::files::ensure_file_at_path_tx(&mut tx, id, path) .await .map_err(db_err)?; diff --git a/backend/crates/prts-api/src/routes/files.rs b/backend/crates/prts-api/src/routes/files.rs index 1464ac6..078259c 100644 --- a/backend/crates/prts-api/src/routes/files.rs +++ b/backend/crates/prts-api/src/routes/files.rs @@ -31,6 +31,7 @@ pub struct FileDto { pub name: String, pub path: String, pub entry_count: i32, + pub state_counts: std::collections::HashMap, } /// 项目文件树。 @@ -57,6 +58,12 @@ pub async fn get_tree( let files = prts_db::files::list_files(&state.db, id) .await .map_err(db_err)?; + let file_stats = prts_db::stats::files(&state.db, id) + .await + .map_err(db_err)? + .into_iter() + .map(|stats| (stats.file_id, stats)) + .collect::>(); Ok(Json(ProjectTree { folders: folders @@ -70,12 +77,24 @@ pub async fn get_tree( .collect(), files: files .into_iter() - .map(|f| FileDto { - id: f.id, - folder_id: f.folder_id, - name: f.name, - path: f.path, - entry_count: f.entry_count, + .map(|f| { + let stats = file_stats.get(&f.id); + let mut state_counts = std::collections::HashMap::new(); + if let Some(stats) = stats { + state_counts.insert("untranslated".to_string(), stats.untranslated_count); + state_counts.insert("translated".to_string(), stats.translated_count); + state_counts.insert("questioned".to_string(), stats.questioned_count); + state_counts.insert("checked".to_string(), stats.checked_count); + state_counts.insert("reviewed".to_string(), stats.reviewed_count); + } + FileDto { + id: f.id, + folder_id: f.folder_id, + name: f.name, + path: f.path, + entry_count: stats.map_or(0, |value| value.visible_total as i32), + state_counts, + } }) .collect(), })) diff --git a/backend/crates/prts-api/src/routes/language_resolution.rs b/backend/crates/prts-api/src/routes/language_resolution.rs new file mode 100644 index 0000000..a79f1fe --- /dev/null +++ b/backend/crates/prts-api/src/routes/language_resolution.rs @@ -0,0 +1,397 @@ +//! Owner language resolution 与 metadata-only 平台诊断端点。 + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::Json; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use prts_common::Error; +use prts_core::permission::nodes; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; + +use crate::auth::{project as paccess, CurrentUser}; +use crate::db_err; +use crate::error::{ApiError, ErrorResponse}; +use crate::state::AppState; + +#[derive(Debug, Serialize, ToSchema)] +pub struct LanguageIssueDto { + pub id: i64, + pub entity_type: String, + pub entity_id: String, + pub issue_kind: String, + pub raw_tag: Option, + pub canonical_tag: Option, + pub metadata: serde_json::Value, + pub current_values: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProjectLanguageResolutionDto { + pub project_id: i64, + pub source_langs: Vec, + pub primary_source_lang: Option, + pub target_lang: String, + pub state: String, + pub issues: Vec, +} + +#[utoipa::path(get, path = "/projects/{id}/language-resolution", tag = "project", + responses((status = 200, body = ProjectLanguageResolutionDto), (status = 403), (status = 404)))] +pub async fn get_project_language_resolution( + State(state): State, + user: CurrentUser, + Path(id): Path, +) -> Result, ApiError> { + let access = paccess::load(&state, Some(&user), id).await?; + access.require_view()?; + if user.id != access.project.owner_id { + return Err(Error::Forbidden.into()); + } + let issues = prts_db::language_resolution::list_project_issues(&state.db, id) + .await + .map_err(db_err)?; + Ok(Json(ProjectLanguageResolutionDto { + project_id: id, + source_langs: access.project.source_langs, + primary_source_lang: access.project.primary_source_lang, + target_lang: access.project.target_lang, + state: access.project.language_repair_state, + issues: futures_util::future::try_join_all(issues.into_iter().map(|issue| { + let db = state.db.clone(); + async move { + let current_values = match issue.entry_id { + Some(entry_id) => { + let original: Option = sqlx::query_scalar( + "SELECT original FROM entries WHERE id = $1 AND project_id = $2", + ) + .bind(entry_id) + .bind(id) + .fetch_optional(&db) + .await + .map_err(db_err)?; + original + .and_then(|value| value.as_object().cloned()) + .map(|object| { + object + .into_values() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + None => Vec::new(), + }; + Ok::<_, ApiError>(LanguageIssueDto { + id: issue.id, + entity_type: issue.entity_type, + entity_id: issue.entity_id_snapshot, + issue_kind: issue.issue_kind, + raw_tag: issue.raw_tag, + canonical_tag: issue.canonical_tag, + metadata: issue.metadata, + current_values, + }) + } + })) + .await?, + })) +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct IssueResolutionReq { + pub issue_id: i64, + #[serde(default)] + pub canonical_tag: Option, + #[serde(default)] + pub selected_value: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ResolveProjectLanguagesReq { + pub source_langs: Vec, + pub primary_source_lang: String, + pub target_lang: String, + pub issues: Vec, +} + +#[utoipa::path(post, path = "/projects/{id}/language-resolution/resolve", tag = "project", + request_body = ResolveProjectLanguagesReq, + responses( + (status = 202), (status = 400), (status = 403), (status = 404), + (status = 503, body = ErrorResponse) + ))] +pub async fn resolve_project_languages( + State(state): State, + user: CurrentUser, + Path(id): Path, + Json(request): Json, +) -> Result { + let (source_langs, primary_source_lang, target_lang) = + prts_core::language::canonicalize_project_languages( + &request.source_langs, + Some(&request.primary_source_lang), + &request.target_lang, + ) + .map_err(language_error)?; + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let locked_access = paccess::load_locked_tx(&mut tx, &user, project).await?; + if locked_access.project.owner_id != user.id { + return Err(Error::Forbidden.into()); + } + if locked_access.project.language_repair_state != "needs_language_resolution" { + return Err(Error::Conflict.into()); + } + let open_issues = prts_db::language_resolution::lock_project_issues_tx(&mut tx, id) + .await + .map_err(db_err)?; + if open_issues.is_empty() { + return Err(Error::Conflict.into()); + } + let requested_ids: std::collections::HashSet<_> = + request.issues.iter().map(|issue| issue.issue_id).collect(); + if requested_ids.len() != request.issues.len() + || requested_ids.len() != open_issues.len() + || open_issues + .iter() + .any(|issue| !requested_ids.contains(&issue.id)) + { + return Err(Error::bad_request("必须处理项目的全部未解决语言问题").into()); + } + for selection in &request.issues { + let issue = open_issues + .iter() + .find(|issue| issue.id == selection.issue_id) + .ok_or_else(|| Error::bad_request("语言问题不存在"))?; + let Some(entry_id) = issue.entry_id else { + if selection.canonical_tag.is_some() || selection.selected_value.is_some() { + return Err(Error::bad_request("项目级语言问题只使用最终语言设置解决").into()); + } + continue; + }; + let canonical_tag = selection + .canonical_tag + .as_deref() + .ok_or_else(|| Error::bad_request("词条语言问题必须指定规范语言")) + .and_then(|tag| { + prts_core::canonicalize_language_tag(tag) + .map_err(|_| Error::bad_request("词条规范语言无效")) + })?; + if !source_langs.contains(&canonical_tag) { + return Err(Error::bad_request("词条规范语言必须属于项目源语言").into()); + } + let selected_value = selection + .selected_value + .as_deref() + .ok_or_else(|| Error::bad_request("词条语言问题必须选择现有原文"))?; + let original = prts_db::language_resolution::lock_entry_original_tx(&mut tx, id, entry_id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let repaired = repair_entry_original( + &original, + &canonical_tag, + selected_value, + &primary_source_lang, + )?; + let changed = + prts_db::language_resolution::update_entry_original_tx(&mut tx, id, entry_id, repaired) + .await + .map_err(db_err)?; + if changed != 1 { + return Err(Error::Conflict.into()); + } + } + let issue_ids: Vec<_> = open_issues.iter().map(|issue| issue.id).collect(); + prts_db::language_resolution::complete_owner_resolution_tx( + &mut tx, + id, + &issue_ids, + &source_langs, + &primary_source_lang, + &target_lang, + ) + .await + .map_err(db_err)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ProjectLanguageResolutionCompleted { + project_id: id, + issue_count: issue_ids.len(), + source_language_count: source_langs.len(), + primary_source_language: &primary_source_lang, + target_language: &target_lang, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + state.job_worker.wake(); + Ok(StatusCode::ACCEPTED) +} + +fn language_error(error: prts_core::LanguageTagError) -> ApiError { + match error { + prts_core::LanguageTagError::Invalid => Error::InvalidLanguageTag.into(), + prts_core::LanguageTagError::Duplicate => Error::DuplicateLanguageTag.into(), + prts_core::LanguageTagError::EmptySourceLanguages + | prts_core::LanguageTagError::PrimaryNotInSourceLanguages => { + Error::bad_request(error.code()).into() + } + } +} + +/// 将 owner 明确的 tag mapping 与候选值合并为无歧义 canonical 对象。 +fn repair_entry_original( + original: &serde_json::Value, + mapped_tag: &str, + selected_value: &str, + primary_source_language: &str, +) -> Result { + let original = original + .as_object() + .ok_or_else(|| Error::bad_request("词条原文不是语言对象"))?; + let candidates: Vec<&str> = original + .values() + .filter_map(serde_json::Value::as_str) + .collect(); + if !candidates.contains(&selected_value) { + return Err(Error::bad_request("选择值必须来自当前冲突候选").into()); + } + let mut repaired = serde_json::Map::new(); + for (raw_tag, candidate) in original { + let tag = prts_core::canonicalize_language_tag(raw_tag) + .unwrap_or_else(|_| mapped_tag.to_string()); + match repaired.get(&tag) { + Some(existing) if existing != candidate => { + repaired.insert(tag, serde_json::Value::String(selected_value.to_string())); + } + None => { + repaired.insert(tag, candidate.clone()); + } + _ => {} + } + } + if !repaired.contains_key(primary_source_language) { + if mapped_tag != primary_source_language { + return Err(Error::bad_request("缺少主源原文时必须映射到主源语言").into()); + } + repaired.insert( + primary_source_language.to_string(), + serde_json::Value::String(selected_value.to_string()), + ); + } + Ok(serde_json::Value::Object(repaired)) +} + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct AdminResolutionQuery { + pub after_project_id: Option, + pub limit: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct AdminResolutionSummaryDto { + pub project_id: i64, + pub project_slug: String, + pub issue_count: i64, + pub repair_state: String, + pub repair_job_id: Option, +} + +#[utoipa::path(get, path = "/admin/language-resolutions", tag = "admin", + params(AdminResolutionQuery), responses((status = 200, body = [AdminResolutionSummaryDto]), (status = 403)))] +pub async fn list_admin_language_resolutions( + State(state): State, + user: CurrentUser, + Query(query): Query, +) -> Result>, ApiError> { + user.require_platform(nodes::PLATFORM_PROJECT_MANAGE_ALL)?; + let rows = prts_db::language_resolution::list_admin_summaries( + &state.db, + query.after_project_id, + query.limit.unwrap_or(50).clamp(1, 100), + ) + .await + .map_err(db_err)?; + Ok(Json( + rows.into_iter() + .map(|row| AdminResolutionSummaryDto { + project_id: row.project_id, + project_slug: row.project_slug, + issue_count: row.issue_count, + repair_state: row.repair_state, + repair_job_id: row.repair_job_id, + }) + .collect(), + )) +} + +#[utoipa::path(post, path = "/admin/language-resolutions/{project_id}/retry", tag = "admin", + responses((status = 202), (status = 403), (status = 404), (status = 503, body = ErrorResponse)))] +pub async fn retry_admin_language_repair( + State(state): State, + user: CurrentUser, + Path(project_id): Path, +) -> Result { + user.require_platform(nodes::PLATFORM_PROJECT_MANAGE_ALL)?; + let mut tx = state.db.begin().await.map_err(db_err)?; + let project = prts_db::projects::find_by_id_for_update_tx(&mut tx, project_id) + .await + .map_err(db_err)? + .ok_or(Error::NotFound)?; + let current_admin = prts_db::users::find_by_id_for_update_tx(&mut tx, user.id) + .await + .map_err(db_err)? + .ok_or(Error::Unauthorized)?; + let current_platform_role = current_admin + .platform_role + .as_deref() + .and_then(prts_core::PlatformRole::parse) + .filter(|role| role.has(nodes::PLATFORM_PROJECT_MANAGE_ALL)); + if current_platform_role.is_none() { + return Err(Error::Forbidden.into()); + } + if project.language_repair_state != "repairing" { + return Err(Error::Conflict.into()); + } + let repair_job_id = project.language_repair_job_id.ok_or(Error::Conflict)?; + let retried = prts_db::language_resolution::retry_failed_project_repair_tx( + &mut tx, + project_id, + repair_job_id, + ) + .await + .map_err(db_err)? + .ok_or(Error::Conflict)?; + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::ProjectLanguageRepairRetried { + project_id, + job_id: retried.id, + previous_state: &project.language_repair_state, + }, + ) + .await + .map_err(|_| Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + state.job_worker.wake(); + Ok(StatusCode::ACCEPTED) +} diff --git a/backend/crates/prts-api/src/routes/mod.rs b/backend/crates/prts-api/src/routes/mod.rs index 8ec3da9..608f187 100644 --- a/backend/crates/prts-api/src/routes/mod.rs +++ b/backend/crates/prts-api/src/routes/mod.rs @@ -7,6 +7,7 @@ pub mod entries; pub mod files; pub mod health; pub mod jobs; +pub mod language_resolution; pub mod messages; pub mod meta; pub mod notifications; @@ -88,6 +89,14 @@ fn api_router() -> OpenApiRouter { )) .routes(routes!(projects::list_members, projects::add_member)) .routes(routes!(projects::remove_member)) + .routes(routes!( + language_resolution::get_project_language_resolution, + language_resolution::resolve_project_languages + )) + .routes(routes!( + language_resolution::list_admin_language_resolutions + )) + .routes(routes!(language_resolution::retry_admin_language_repair)) // 持久化任务进度与受控重试 .routes(routes!(jobs::get_job)) .routes(routes!(jobs::retry_job)) diff --git a/backend/crates/prts-api/src/routes/projects.rs b/backend/crates/prts-api/src/routes/projects.rs index aa8606d..3186171 100644 --- a/backend/crates/prts-api/src/routes/projects.rs +++ b/backend/crates/prts-api/src/routes/projects.rs @@ -15,6 +15,7 @@ use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use crate::auth::{project as paccess, CurrentUser, MaybeUser}; use crate::db_err; +use crate::dto::capabilities::ProjectCapabilitiesDto; use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; @@ -27,7 +28,13 @@ pub struct ProjectDto { pub description: String, pub visibility: String, pub source_langs: Vec, + pub primary_source_lang: Option, pub target_lang: String, + pub language_repair_state: String, + pub primary_source_changed_at: Option, + pub lexical_state: String, + pub embedding_state: String, + pub avatar_url: Option, pub owner_id: i64, pub created_at: String, pub updated_at: String, @@ -42,7 +49,16 @@ impl From<&prts_db::models::Project> for ProjectDto { description: p.description.clone(), visibility: p.visibility.clone(), source_langs: p.source_langs.clone(), + primary_source_lang: p.primary_source_lang.clone(), target_lang: p.target_lang.clone(), + language_repair_state: p.language_repair_state.clone(), + primary_source_changed_at: p.primary_source_changed_at.map(|value| value.to_rfc3339()), + lexical_state: p.lexical_state.clone(), + embedding_state: p.embedding_state.clone(), + avatar_url: p + .avatar_key + .as_ref() + .map(|_| format!("/api/projects/{}/avatar", p.id)), owner_id: p.owner_id, created_at: p.created_at.to_rfc3339(), updated_at: p.updated_at.to_rfc3339(), @@ -56,6 +72,7 @@ pub struct ProjectDetailDto { pub project: ProjectDto, pub state_counts: HashMap, pub entry_count: i64, + pub capabilities: ProjectCapabilitiesDto, } fn slugify(s: &str) -> String { @@ -130,10 +147,13 @@ pub async fn create_project( if name.is_empty() || name.len() > 128 { return Err(Error::bad_request("项目名需为 1–128 字符").into()); } - let target_lang = req.target_lang.trim(); - if target_lang.is_empty() { - return Err(Error::bad_request("需指定目标语言").into()); - } + let (source_langs, _primary_source_lang, target_lang) = + prts_core::language::canonicalize_project_languages( + &req.source_langs, + req.source_langs.first().map(String::as_str), + &req.target_lang, + ) + .map_err(language_error)?; let base = req .slug .as_deref() @@ -153,8 +173,8 @@ pub async fn create_project( name, req.description.as_deref().unwrap_or(""), visibility, - &req.source_langs, - target_lang, + &source_langs, + &target_lang, user.id, ) .await @@ -229,20 +249,22 @@ pub async fn get_project( let access = paccess::load(&state, user.as_ref(), id).await?; access.require_view()?; - let counts = prts_db::entries::count_by_state(&state.db, id) + let stats = prts_db::stats::project(&state.db, id) .await .map_err(db_err)?; let mut state_counts = HashMap::new(); - let mut entry_count = 0i64; - for (s, c) in counts { - entry_count += c; - state_counts.insert(s, c); - } + state_counts.insert("untranslated".to_string(), stats.untranslated_count); + state_counts.insert("translated".to_string(), stats.translated_count); + state_counts.insert("questioned".to_string(), stats.questioned_count); + state_counts.insert("checked".to_string(), stats.checked_count); + state_counts.insert("reviewed".to_string(), stats.reviewed_count); + let capabilities = access.capabilities(false).into(); Ok(Json(ProjectDetailDto { project: (&access.project).into(), state_counts, - entry_count, + entry_count: stats.visible_total, + capabilities, })) } @@ -290,12 +312,26 @@ pub async fn update_project( let description = req .description .unwrap_or_else(|| before.description.clone()); - let source_langs = req + let requested_source_langs = req .source_langs .unwrap_or_else(|| before.source_langs.clone()); - let target_lang = req + let requested_target_lang = req .target_lang .unwrap_or_else(|| before.target_lang.clone()); + let primary_source_lang = before.primary_source_lang.as_deref(); + let (source_langs, canonical_primary, target_lang) = + prts_core::language::canonicalize_project_languages( + &requested_source_langs, + primary_source_lang, + &requested_target_lang, + ) + .map_err(language_error)?; + if before.language_repair_state != "ready" { + return Err(Error::ProjectLanguageResolutionRequired.into()); + } + if Some(canonical_primary.as_str()) != primary_source_lang { + return Err(Error::bad_request("主源语言只能通过专用重建流程更改").into()); + } let mut changed_fields = Vec::with_capacity(5); if name != before.name { changed_fields.push("name"); @@ -342,6 +378,17 @@ pub async fn update_project( Ok(Json((&updated).into())) } +fn language_error(error: prts_core::LanguageTagError) -> ApiError { + match error { + prts_core::LanguageTagError::Invalid => Error::InvalidLanguageTag.into(), + prts_core::LanguageTagError::Duplicate => Error::DuplicateLanguageTag.into(), + prts_core::LanguageTagError::EmptySourceLanguages + | prts_core::LanguageTagError::PrimaryNotInSourceLanguages => { + Error::bad_request(error.code()).into() + } + } +} + /// 删除项目。需项目「删除」权限。 #[utoipa::path(delete, path = "/projects/{id}", tag = "project", responses( @@ -427,7 +474,7 @@ pub async fn list_members( #[derive(Debug, Deserialize, ToSchema)] pub struct AddMemberReq { pub username: String, - /// owner | manager | reviewer | translator。 + /// manager | reviewer | translator;本轮不支持拥有者转让。 pub role: String, } @@ -448,8 +495,8 @@ pub async fn add_member( ) -> Result { let access = paccess::load(&state, Some(&user), id).await?; access.require_node(nodes::PROJECT_MEMBER_MANAGE)?; - if ProjectRole::parse(&req.role).is_none() { - return Err(Error::bad_request("role 必须是 owner|manager|reviewer|translator").into()); + if !matches!(req.role.as_str(), "manager" | "reviewer" | "translator") { + return Err(Error::bad_request("role 必须是 manager|reviewer|translator").into()); } let target = prts_db::users::find_by_username(&state.db, req.username.trim()) .await @@ -460,9 +507,11 @@ pub async fn add_member( .await .map_err(db_err)? .ok_or(Error::NotFound)?; - paccess::load_locked_tx(&mut tx, &user, project) - .await? - .require_node(nodes::PROJECT_MEMBER_MANAGE)?; + let locked_access = paccess::load_locked_tx(&mut tx, &user, project).await?; + locked_access.require_node(nodes::PROJECT_MEMBER_MANAGE)?; + if locked_access.effective_role() == Some(ProjectRole::Manager) && req.role == "manager" { + return Err(Error::Forbidden.into()); + } let previous_role = prts_db::memberships::find_role_tx(&mut tx, id, target.id) .await .map_err(db_err)?; diff --git a/backend/crates/prts-api/src/routes/search.rs b/backend/crates/prts-api/src/routes/search.rs index f3d9fd9..cdbc04e 100644 --- a/backend/crates/prts-api/src/routes/search.rs +++ b/backend/crates/prts-api/src/routes/search.rs @@ -83,6 +83,7 @@ pub async fn search_entries( // 1. 解析项目访问权限(私有项目对游客返回 404) let access = paccess::load(&state, user.as_ref(), id).await?; access.require_view()?; + access.require_language_ready()?; // 2. 确定主查询词;q 为空则 400 let main_q = @@ -115,10 +116,9 @@ pub async fn search_entries( // 6. 从项目结构获取源/目标语言 let src_lang = access .project - .source_langs - .first() - .map(|s| s.as_str()) - .unwrap_or(""); + .primary_source_lang + .as_deref() + .ok_or(Error::ProjectLanguageResolutionRequired)?; let tgt_lang = access.project.target_lang.as_str(); // 7. 文件过滤列表 diff --git a/backend/crates/prts-api/src/routes/users.rs b/backend/crates/prts-api/src/routes/users.rs index 34795a1..4bf94f1 100644 --- a/backend/crates/prts-api/src/routes/users.rs +++ b/backend/crates/prts-api/src/routes/users.rs @@ -59,9 +59,15 @@ pub async fn update_me( .description .unwrap_or_else(|| current.description.clone()); let avatar_url = req.avatar_url.or_else(|| current.avatar_url.clone()); - let langs = req + let requested_langs = req .translation_langs .unwrap_or_else(|| current.translation_langs.clone()); + let langs = + prts_core::canonicalize_language_tags(&requested_langs).map_err(|error| match error { + prts_core::LanguageTagError::Invalid => Error::InvalidLanguageTag, + prts_core::LanguageTagError::Duplicate => Error::DuplicateLanguageTag, + _ => Error::bad_request(error.code()), + })?; let mut changed_fields = Vec::with_capacity(3); if description != current.description { changed_fields.push("description"); diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index ab1c65a..b72362e 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -39,6 +39,8 @@ mod auth_routes; mod entries_routes; #[path = "../src/routes/files.rs"] mod files_routes; +#[path = "../src/routes/language_resolution.rs"] +mod language_resolution_routes; #[path = "../src/routes/messages.rs"] mod messages_routes; #[path = "../src/routes/notifications.rs"] @@ -286,6 +288,21 @@ const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ action: "project.updated", allowed_payload_keys: &["changed_fields", "visibility"], }, + AuditedEntrypoint { + entrypoint: "routes::language_resolution::resolve_project_languages", + action: "project.language_resolution_completed", + allowed_payload_keys: &[ + "issue_count", + "source_language_count", + "primary_source_language", + "target_language", + ], + }, + AuditedEntrypoint { + entrypoint: "routes::language_resolution::retry_admin_language_repair", + action: "project.language_repair_retried", + allowed_payload_keys: &["job_id", "previous_state"], + }, AuditedEntrypoint { entrypoint: "routes::projects::delete_project", action: "project.deleted", @@ -502,6 +519,20 @@ const AUDIT_ACTION_CONTRACTS: &[AuditActionContract] = &[ project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, expected_count: 1, }, + AuditActionContract { + action: "project.language_resolution_completed", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, + AuditActionContract { + action: "project.language_repair_retried", + target_type: "project", + target_id_policy: TargetIdPolicy::Numeric, + project_snapshot_policy: ProjectSnapshotPolicy::SameAsNumericTarget, + expected_count: 1, + }, AuditActionContract { action: "project.deleted", target_type: "project", @@ -633,6 +664,9 @@ const REPOSITORY_WRITERS: &[&str] = &[ "messages::create", "messages::mark_read", "jobs::manual_retry", + "language_resolution::complete_owner_resolution_tx", + "language_resolution::update_entry_original_tx", + "language_resolution::retry_failed_project_repair_tx", ]; /// DB-authoritative session 与 durable intent/outbox 的全部现有写边界。 @@ -675,6 +709,8 @@ const UNAUDITED_READS: &[&str] = &[ "messages::list_threads", "messages::conversation", "messages::unread_count", + "language_resolution::get_project_language_resolution", + "language_resolution::list_admin_language_resolutions", ]; #[test] @@ -683,7 +719,7 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { assert_eq!( REPOSITORY_WRITERS.len(), - 28, + 31, "repository writer inventory 发生漂移" ); assert_eq!( @@ -691,11 +727,11 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { 16, "auth/session writer inventory 发生漂移" ); - assert_eq!(UNAUDITED_READS.len(), 18, "普通读取 inventory 发生漂移"); - assert_eq!(AUDITED_ENTRYPOINTS.len(), 36, "审计入口 inventory 发生漂移"); + assert_eq!(UNAUDITED_READS.len(), 20, "普通读取 inventory 发生漂移"); + assert_eq!(AUDITED_ENTRYPOINTS.len(), 38, "审计入口 inventory 发生漂移"); assert_eq!( AUDIT_ACTION_CONTRACTS.len(), - 33, + 35, "action 合同 inventory 发生漂移" ); @@ -3817,6 +3853,408 @@ async fn audit_contract_job_retry_audit_failure_rolls_back_and_returns_503() { .unwrap(); } +/// Owner resolution 必须验证当前候选、锁后鉴权,并在审计失败时整事务回滚。 +#[tokio::test] +async fn language_resolution_owner_selection_permissions_and_audit_rollback_are_atomic() { + use axum::extract::{Path, State}; + use axum::Json; + + let state = audit_contract_state().await; + let owner = audit_contract_create_user(&state.db, "language-resolution-owner", None).await; + let manager = audit_contract_create_user(&state.db, "language-resolution-manager", None).await; + let project = projects::create( + &state.db, + &format!("language-resolution-{}", owner.id), + "Language resolution", + "", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, owner.id, "owner") + .await + .unwrap(); + memberships::upsert(&state.db, project.id, manager.id, "manager") + .await + .unwrap(); + let Json(uploaded) = entries_routes::upload( + State(state.clone()), + audit_contract_current_user(&owner), + Path(project.id), + Json(audit_contract_upload_req( + "resolution/main.json", + &[("conflict", "owner-selected-source")], + )), + ) + .await + .expect_api("创建 resolution 测试词条"); + let entry_id: i64 = + sqlx::query_scalar("SELECT id FROM entries WHERE file_id = $1 AND key = 'conflict'") + .bind(uploaded.file_id) + .fetch_one(&state.db) + .await + .unwrap(); + let unresolved_original = serde_json::json!({ + "en": "owner-selected-source", + "EN": "alternate-source" + }); + sqlx::query( + "UPDATE projects SET language_repair_state = 'needs_language_resolution' WHERE id = $1", + ) + .bind(project.id) + .execute(&state.db) + .await + .unwrap(); + sqlx::query("UPDATE entries SET original = $2 WHERE id = $1") + .bind(entry_id) + .bind(&unresolved_original) + .execute(&state.db) + .await + .unwrap(); + let issue_id: i64 = sqlx::query_scalar( + "INSERT INTO language_resolution_issues ( + project_id, entry_id, entity_type, entity_id_snapshot, issue_kind, + raw_tag, canonical_tag, metadata + ) VALUES ($1, $2, 'entry', $2::TEXT, 'conflicting_original_keys', + 'EN', 'en', jsonb_build_object('key_count', 2)) + RETURNING id", + ) + .bind(project.id) + .bind(entry_id) + .fetch_one(&state.db) + .await + .unwrap(); + + let hidden_from_manager = language_resolution_routes::get_project_language_resolution( + State(state.clone()), + audit_contract_current_user(&manager), + Path(project.id), + ) + .await + .expect_err_api("manager 不得读取 owner resolution 详情"); + assert_eq!(hidden_from_manager.code(), "forbidden"); + let Json(owner_view) = language_resolution_routes::get_project_language_resolution( + State(state.clone()), + audit_contract_current_user(&owner), + Path(project.id), + ) + .await + .expect_api("owner 可读取当前冲突候选"); + assert_eq!(owner_view.issues.len(), 1); + assert!(owner_view.issues[0] + .current_values + .contains(&"owner-selected-source".to_string())); + assert!(owner_view.issues[0] + .current_values + .contains(&"alternate-source".to_string())); + + let request = || language_resolution_routes::ResolveProjectLanguagesReq { + source_langs: vec!["en".to_string()], + primary_source_lang: "en".to_string(), + target_lang: "zh-Hans".to_string(), + issues: vec![language_resolution_routes::IssueResolutionReq { + issue_id, + canonical_tag: Some("en".to_string()), + selected_value: Some("owner-selected-source".to_string()), + }], + }; + let denied = language_resolution_routes::resolve_project_languages( + State(state.clone()), + audit_contract_current_user(&manager), + Path(project.id), + Json(request()), + ) + .await + .expect_err_api("manager 不得代替 owner 处理语言歧义"); + assert_eq!(denied.code(), "forbidden"); + + let invalid = language_resolution_routes::resolve_project_languages( + State(state.clone()), + audit_contract_current_user(&owner), + Path(project.id), + Json(language_resolution_routes::ResolveProjectLanguagesReq { + issues: vec![language_resolution_routes::IssueResolutionReq { + issue_id, + canonical_tag: Some("en".to_string()), + selected_value: Some("not-a-current-candidate".to_string()), + }], + ..request() + }), + ) + .await + .expect_err_api("owner 选择必须来自锁定后的当前候选"); + assert_eq!(invalid.code(), "bad_request"); + + let jobs_before: i64 = sqlx::query_scalar( + "SELECT count(*) FROM jobs WHERE project_id = $1 AND kind = 'language_repair'", + ) + .bind(project.id) + .fetch_one(&state.db) + .await + .unwrap(); + let failing_state = audit_contract_state_with_db(audit_contract_failing_audit_db().await).await; + let failed = language_resolution_routes::resolve_project_languages( + State(failing_state), + audit_contract_current_user(&owner), + Path(project.id), + Json(request()), + ) + .await + .expect_err_api("审计失败必须回滚完整 resolution 事务"); + audit_contract_assert_unavailable(failed).await; + let rolled_back: ( + String, + serde_json::Value, + Option>, + i64, + ) = sqlx::query_as( + "SELECT project.language_repair_state, entry.original, issue.resolved_at, + (SELECT count(*) FROM jobs + WHERE project_id = project.id AND kind = 'language_repair') + FROM projects AS project + JOIN entries AS entry ON entry.id = $2 + JOIN language_resolution_issues AS issue ON issue.id = $3 + WHERE project.id = $1", + ) + .bind(project.id) + .bind(entry_id) + .bind(issue_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(rolled_back.0, "needs_language_resolution"); + assert_eq!(rolled_back.1, unresolved_original); + assert!(rolled_back.2.is_none()); + assert_eq!(rolled_back.3, jobs_before); + + language_resolution_routes::resolve_project_languages( + State(state.clone()), + audit_contract_current_user(&owner), + Path(project.id), + Json(request()), + ) + .await + .expect_api("owner resolution 成功排入 repair job"); + let completed: (String, serde_json::Value, bool, i64) = sqlx::query_as( + "SELECT project.language_repair_state, entry.original, + issue.resolved_at IS NOT NULL, job.id + FROM projects AS project + JOIN entries AS entry ON entry.id = $2 + JOIN language_resolution_issues AS issue ON issue.id = $3 + JOIN jobs AS job ON job.id = project.language_repair_job_id + WHERE project.id = $1 AND job.kind = 'language_repair' AND job.state = 'queued'", + ) + .bind(project.id) + .bind(entry_id) + .bind(issue_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(completed.0, "repairing"); + assert_eq!( + completed.1, + serde_json::json!({"en": "owner-selected-source"}) + ); + assert!(completed.2); + assert!(completed.3 > 0); +} + +/// Admin 只能看到 metadata 摘要,并且只可重试失败的同一 repair job。 +#[tokio::test] +async fn language_resolution_admin_visibility_and_retry_are_metadata_only_and_state_safe() { + use axum::extract::{Path, Query, State}; + + let state = audit_contract_state().await; + let owner = audit_contract_create_user(&state.db, "language-admin-owner", None).await; + let admin = audit_contract_create_user(&state.db, "language-admin", Some("admin")).await; + let project = projects::create( + &state.db, + &format!("language-admin-{}", owner.id), + "Private language repair", + "private-body-marker-must-not-leak", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, project.id, owner.id, "owner") + .await + .unwrap(); + let job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs ( + kind, project_id, state, stage, payload, attempts, max_attempts, + last_error_code, last_error_message, finished_at + ) VALUES ( + 'language_repair', $1, 'failed', 'entries', '{}', 2, 2, + 'language_resolution_required', 'private-job-marker-must-not-leak', now() + ) RETURNING id", + ) + .bind(project.id) + .fetch_one(&state.db) + .await + .unwrap(); + sqlx::query( + "UPDATE projects SET language_repair_state = 'needs_language_resolution', + language_repair_job_id = $2 + WHERE id = $1", + ) + .bind(project.id) + .bind(job_id) + .execute(&state.db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO language_resolution_issues ( + project_id, entity_type, entity_id_snapshot, issue_kind, raw_tag, metadata + ) VALUES ($1, 'project', $1::TEXT, 'invalid_tag', + 'private-raw-tag-marker', jsonb_build_object('reason', 'private-reason-marker'))", + ) + .bind(project.id) + .execute(&state.db) + .await + .unwrap(); + + let axum::Json(summaries) = language_resolution_routes::list_admin_language_resolutions( + State(state.clone()), + audit_contract_current_user(&admin), + Query(language_resolution_routes::AdminResolutionQuery { + after_project_id: None, + limit: Some(100), + }), + ) + .await + .expect_api("管理员读取语言修复摘要"); + let summary = summaries + .iter() + .find(|summary| summary.project_id == project.id) + .expect("摘要包含目标项目"); + assert_eq!(summary.issue_count, 1); + let serialized = serde_json::to_string(summary).unwrap(); + for marker in [ + "private-body-marker-must-not-leak", + "private-job-marker-must-not-leak", + "private-raw-tag-marker", + "private-reason-marker", + ] { + assert!(!serialized.contains(marker)); + } + + let blocked = language_resolution_routes::retry_admin_language_repair( + State(state.clone()), + audit_contract_current_user(&admin), + Path(project.id), + ) + .await + .expect_err_api("管理员不得通过 retry 绕过 owner resolution"); + assert_eq!(blocked.code(), "conflict"); + + let retry_project = projects::create( + &state.db, + &format!("language-retry-{}", owner.id), + "Repair infrastructure retry", + "", + "private", + &["en".to_string()], + "zh-Hans", + owner.id, + ) + .await + .unwrap(); + memberships::upsert(&state.db, retry_project.id, owner.id, "owner") + .await + .unwrap(); + let retry_job_id: i64 = sqlx::query_scalar( + "INSERT INTO jobs ( + kind, project_id, state, stage, payload, attempts, max_attempts, + last_error_code, last_error_message, finished_at + ) VALUES ( + 'language_repair', $1, 'failed', 'entries', '{}', 2, 2, + 'database_unavailable', 'redacted database failure', now() + ) RETURNING id", + ) + .bind(retry_project.id) + .fetch_one(&state.db) + .await + .unwrap(); + sqlx::query( + "UPDATE projects SET language_repair_state = 'repairing', language_repair_job_id = $2 + WHERE id = $1", + ) + .bind(retry_project.id) + .bind(retry_job_id) + .execute(&state.db) + .await + .unwrap(); + + let failing_state = audit_contract_state_with_db(audit_contract_failing_audit_db().await).await; + let audit_failure = language_resolution_routes::retry_admin_language_repair( + State(failing_state), + audit_contract_current_user(&admin), + Path(retry_project.id), + ) + .await + .expect_err_api("管理员 repair retry 的审计失败必须回滚"); + audit_contract_assert_unavailable(audit_failure).await; + let still_failed: (String, i32, i32, Option) = sqlx::query_as( + "SELECT state, attempts, max_attempts, last_error_code FROM jobs WHERE id = $1", + ) + .bind(retry_job_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!( + still_failed, + ( + "failed".to_string(), + 2, + 2, + Some("database_unavailable".to_string()) + ) + ); + + language_resolution_routes::retry_admin_language_repair( + State(state.clone()), + audit_contract_current_user(&admin), + Path(retry_project.id), + ) + .await + .expect_api("管理员重试失败的 repair job"); + let retried: (i64, String, i32, i32, Option) = sqlx::query_as( + "SELECT id, state, attempts, max_attempts, last_error_code FROM jobs WHERE id = $1", + ) + .bind(retry_job_id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(retried.0, retry_job_id); + assert_eq!(retried.1, "queued"); + assert_eq!(retried.2, 3); + assert_eq!(retried.3, 4); + assert!(retried.4.is_none()); + + let duplicate = language_resolution_routes::retry_admin_language_repair( + State(state.clone()), + audit_contract_current_user(&admin), + Path(retry_project.id), + ) + .await + .expect_err_api("queued repair job 不能被伪装成再次重试成功"); + assert_eq!(duplicate.code(), "conflict"); + let job_count: i64 = sqlx::query_scalar( + "SELECT count(*) FROM jobs WHERE project_id = $1 AND kind = 'language_repair'", + ) + .bind(retry_project.id) + .fetch_one(&state.db) + .await + .unwrap(); + assert_eq!(job_count, 1); +} + /// 项目、成员、旧上传、词条编辑/flags、文件删除与敏感导出必须走真实事务审计。 #[tokio::test] async fn audit_contract_projects_files_entries_memberships_and_export_are_audited_and_redacted() { @@ -6733,7 +7171,16 @@ async fn suggestions_trgm_membership_scoped() { .await .unwrap(); - // —— 建项目 B(U 作为 translator 加入) —— + // —— 建项目 B(独立 owner,U 作为 translator 加入) —— + let owner_b = users::create_password_user( + &pool, + &audit_jobs_unique("tm-owner"), + None, + &prts_auth::password::hash_password("password123").unwrap(), + "active", + ) + .await + .unwrap(); let proj_b = projects::create( &pool, "itest-tm-b", @@ -6742,7 +7189,7 @@ async fn suggestions_trgm_membership_scoped() { "public", &["zh-Hans".to_string()], "en", - u.id, // owner 也是 U,所以 U 在 B 中 + owner_b.id, ) .await .unwrap(); @@ -6752,12 +7199,21 @@ async fn suggestions_trgm_membership_scoped() { // —— 建项目 C(U 不是成员;由另一个 owner 创建,使用直接 INSERT) —— // 为了避免引入额外用户,直接插入项目并让 U 不加入成员表 + let owner_c = users::create_password_user( + &pool, + &audit_jobs_unique("tm-private-owner"), + None, + &prts_auth::password::hash_password("password123").unwrap(), + "active", + ) + .await + .unwrap(); let proj_c: (i64,) = sqlx::query_as( "INSERT INTO projects (slug, name, description, visibility, source_langs, target_lang, owner_id) VALUES ('itest-tm-c', 'TM Test C', '', 'public', ARRAY['zh-Hans'], 'en', $1) RETURNING id", ) - .bind(u.id) + .bind(owner_c.id) .fetch_one(&pool) .await .unwrap(); diff --git a/backend/crates/prts-common/src/error.rs b/backend/crates/prts-common/src/error.rs index 63e363f..e4aa2ac 100644 --- a/backend/crates/prts-common/src/error.rs +++ b/backend/crates/prts-common/src/error.rs @@ -37,6 +37,18 @@ pub enum Error { #[error("audit unavailable")] AuditUnavailable, + /// 语言标签不是合法 canonical BCP-47。 + #[error("invalid language tag")] + InvalidLanguageTag, + + /// 语言标签规范化后重复。 + #[error("duplicate language tag")] + DuplicateLanguageTag, + + /// 项目存在需要拥有者处理的语言歧义。 + #[error("project language resolution required")] + ProjectLanguageResolutionRequired, + /// 数据库错误。 #[error("database error")] Database(#[source] sqlx_error::SqlxError), @@ -56,6 +68,9 @@ impl Error { Error::NotFound => "not_found", Error::Conflict => "conflict", Error::AuditUnavailable => "AUDIT_UNAVAILABLE", + Error::InvalidLanguageTag => "INVALID_LANGUAGE_TAG", + Error::DuplicateLanguageTag => "DUPLICATE_LANGUAGE_TAG", + Error::ProjectLanguageResolutionRequired => "PROJECT_LANGUAGE_RESOLUTION_REQUIRED", Error::Database(_) => "internal", Error::Internal(_) => "internal", } diff --git a/backend/crates/prts-common/src/i18n.rs b/backend/crates/prts-common/src/i18n.rs index 8426416..c9d3dc0 100644 --- a/backend/crates/prts-common/src/i18n.rs +++ b/backend/crates/prts-common/src/i18n.rs @@ -57,6 +57,16 @@ fn message_for(code: &str, locale: Locale) -> Option<&'static str> { ("conflict", Locale::En) => "Version conflict, please refresh and retry", ("AUDIT_UNAVAILABLE", Locale::ZhCn) => "审计服务暂不可用", ("AUDIT_UNAVAILABLE", Locale::En) => "Audit service unavailable", + ("INVALID_LANGUAGE_TAG", Locale::ZhCn) => "语言标签不是有效的 BCP-47 格式", + ("INVALID_LANGUAGE_TAG", Locale::En) => "Invalid BCP-47 language tag", + ("DUPLICATE_LANGUAGE_TAG", Locale::ZhCn) => "语言标签规范化后重复", + ("DUPLICATE_LANGUAGE_TAG", Locale::En) => "Duplicate canonical language tag", + ("PROJECT_LANGUAGE_RESOLUTION_REQUIRED", Locale::ZhCn) => { + "项目语言存在歧义,需要拥有者先完成处理" + } + ("PROJECT_LANGUAGE_RESOLUTION_REQUIRED", Locale::En) => { + "Project language resolution is required" + } ("internal", Locale::ZhCn) => "服务器内部错误", ("internal", Locale::En) => "Internal server error", _ => return None, diff --git a/backend/crates/prts-core/Cargo.toml b/backend/crates/prts-core/Cargo.toml index 9ab400b..6829055 100644 --- a/backend/crates/prts-core/Cargo.toml +++ b/backend/crates/prts-core/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true [dependencies] prts-common.workspace = true serde.workspace = true +language-tags.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/backend/crates/prts-core/src/capabilities.rs b/backend/crates/prts-core/src/capabilities.rs new file mode 100644 index 0000000..80769e7 --- /dev/null +++ b/backend/crates/prts-core/src/capabilities.rs @@ -0,0 +1,71 @@ +//! API 下发的项目能力集合,前端不得从角色名推断操作权限。 + +use serde::Serialize; + +use crate::permission::nodes; +use crate::ProjectRole; + +/// 当前主体在单个项目中的显式能力。 +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub struct ProjectCapabilities { + pub view_project: bool, + pub manage_project: bool, + pub manage_members: bool, + pub upload_files: bool, + pub download: bool, + pub edit_entry: bool, + pub review_entry: bool, + pub edit_locked_entry: bool, + pub force_save_presence: bool, + pub resolve_languages: bool, + pub change_primary_source: bool, + pub delete_project: bool, +} + +impl ProjectCapabilities { + /// 根据权限节点、唯一 owner 身份及 release gate 生成能力。 + pub fn for_subject( + can_view: bool, + role: Option, + is_owner: bool, + primary_source_release_ready: bool, + ) -> Self { + let has = |node| role.is_some_and(|project_role| project_role.has(node)); + let elevated_editor = role.is_some_and(ProjectRole::can_edit_locked); + Self { + view_project: can_view, + manage_project: has(nodes::PROJECT_MANAGE), + manage_members: has(nodes::PROJECT_MEMBER_MANAGE), + upload_files: has(nodes::PROJECT_FILE_UPLOAD), + download: has(nodes::PROJECT_DOWNLOAD), + edit_entry: has(nodes::PROJECT_ENTRY_EDIT), + review_entry: has(nodes::PROJECT_ENTRY_REVIEW), + edit_locked_entry: elevated_editor, + force_save_presence: elevated_editor, + resolve_languages: is_owner, + change_primary_source: is_owner && primary_source_release_ready, + delete_project: is_owner, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn owner_only_capabilities_ignore_platform_admin_override() { + let manager = + ProjectCapabilities::for_subject(true, Some(ProjectRole::Manager), false, true); + assert!(manager.edit_locked_entry); + assert!(manager.force_save_presence); + assert!(!manager.resolve_languages); + assert!(!manager.change_primary_source); + assert!(!manager.delete_project); + + let owner = ProjectCapabilities::for_subject(true, Some(ProjectRole::Owner), true, false); + assert!(owner.resolve_languages); + assert!(owner.delete_project); + assert!(!owner.change_primary_source); + } +} diff --git a/backend/crates/prts-core/src/language.rs b/backend/crates/prts-core/src/language.rs new file mode 100644 index 0000000..95e3c4d --- /dev/null +++ b/backend/crates/prts-core/src/language.rs @@ -0,0 +1,128 @@ +//! BCP-47 语言标签的唯一规范化入口。 + +use std::collections::HashSet; +use std::fmt; + +use language_tags::LanguageTag; + +/// 语言标签校验的稳定错误类型。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LanguageTagError { + /// 标签不是合法且已注册的 BCP-47 标签。 + Invalid, + /// 多值输入在规范化后出现重复项。 + Duplicate, + /// 项目没有源语言。 + EmptySourceLanguages, + /// 主源语言不属于源语言集合。 + PrimaryNotInSourceLanguages, +} + +impl LanguageTagError { + /// API 使用的稳定错误码。 + pub const fn code(self) -> &'static str { + match self { + Self::Invalid => "INVALID_LANGUAGE_TAG", + Self::Duplicate => "DUPLICATE_LANGUAGE_TAG", + Self::EmptySourceLanguages => "SOURCE_LANGUAGES_REQUIRED", + Self::PrimaryNotInSourceLanguages => "PRIMARY_SOURCE_LANGUAGE_INVALID", + } + } +} + +impl fmt::Display for LanguageTagError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code()) + } +} + +impl std::error::Error for LanguageTagError {} + +/// 解析、完整校验并输出稳定大小写的 BCP-47 标签。 +pub fn canonicalize_language_tag(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err(LanguageTagError::Invalid); + } + let tag = LanguageTag::parse(trimmed).map_err(|_| LanguageTagError::Invalid)?; + tag.validate().map_err(|_| LanguageTagError::Invalid)?; + Ok(tag.into_string()) +} + +/// 按输入顺序规范化标签集合,并拒绝规范化后的重复项。 +pub fn canonicalize_language_tags(inputs: &[String]) -> Result, LanguageTagError> { + let mut seen = HashSet::with_capacity(inputs.len()); + let mut canonical = Vec::with_capacity(inputs.len()); + for input in inputs { + let tag = canonicalize_language_tag(input)?; + if !seen.insert(tag.clone()) { + return Err(LanguageTagError::Duplicate); + } + canonical.push(tag); + } + Ok(canonical) +} + +/// 规范化项目语言并验证主源属于非空源语言集合。 +pub fn canonicalize_project_languages( + source_languages: &[String], + primary_source_language: Option<&str>, + target_language: &str, +) -> Result<(Vec, String, String), LanguageTagError> { + let source_languages = canonicalize_language_tags(source_languages)?; + if source_languages.is_empty() { + return Err(LanguageTagError::EmptySourceLanguages); + } + let primary_source_language = match primary_source_language { + Some(value) => canonicalize_language_tag(value)?, + None if source_languages.len() == 1 => source_languages[0].clone(), + None => return Err(LanguageTagError::PrimaryNotInSourceLanguages), + }; + if !source_languages.contains(&primary_source_language) { + return Err(LanguageTagError::PrimaryNotInSourceLanguages); + } + let target_language = canonicalize_language_tag(target_language)?; + Ok((source_languages, primary_source_language, target_language)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonicalizes_registered_bcp47_casing() { + assert_eq!( + canonicalize_language_tag(" ZH-hans-cn ").unwrap(), + "zh-Hans-CN" + ); + assert_eq!( + canonicalize_language_tag("de-de-u-co-phonebk").unwrap(), + "de-DE-u-co-phonebk" + ); + } + + #[test] + fn rejects_invalid_and_canonical_duplicates() { + assert_eq!( + canonicalize_language_tag("not_a_language"), + Err(LanguageTagError::Invalid) + ); + assert_eq!( + canonicalize_language_tags(&["zh-hans".into(), "zh-Hans".into()]), + Err(LanguageTagError::Duplicate) + ); + } + + #[test] + fn single_source_defaults_primary_but_multi_source_requires_it() { + let languages = canonicalize_project_languages(&["EN".into()], None, "zh-hans").unwrap(); + assert_eq!( + languages, + (vec!["en".into()], "en".into(), "zh-Hans".into()) + ); + assert_eq!( + canonicalize_project_languages(&["en".into(), "ja".into()], None, "zh-Hans"), + Err(LanguageTagError::PrimaryNotInSourceLanguages) + ); + } +} diff --git a/backend/crates/prts-core/src/lib.rs b/backend/crates/prts-core/src/lib.rs index afba3dc..910f3a2 100644 --- a/backend/crates/prts-core/src/lib.rs +++ b/backend/crates/prts-core/src/lib.rs @@ -7,10 +7,13 @@ //! - 翻译工作流是单值枚举 [`EntryState`]; //! - `locked` / `hidden` 是**正交标志位**([`EntryFlags`]),独立于工作流。 +pub mod capabilities; pub mod entry; pub mod jobs; +pub mod language; pub mod permission; pub use entry::{EntryFlags, EntryState}; pub use jobs::JobState; +pub use language::{canonicalize_language_tag, canonicalize_language_tags, LanguageTagError}; pub use permission::{PlatformRole, ProjectRole}; diff --git a/backend/crates/prts-db/Cargo.toml b/backend/crates/prts-db/Cargo.toml index b4a6bed..7382d85 100644 --- a/backend/crates/prts-db/Cargo.toml +++ b/backend/crates/prts-db/Cargo.toml @@ -15,3 +15,4 @@ tracing.workspace = true serde.workspace = true serde_json.workspace = true chrono.workspace = true +uuid.workspace = true diff --git a/backend/crates/prts-db/src/audit.rs b/backend/crates/prts-db/src/audit.rs index 2bb9b25..686bdbe 100644 --- a/backend/crates/prts-db/src/audit.rs +++ b/backend/crates/prts-db/src/audit.rs @@ -196,6 +196,18 @@ pub enum AuditEvent<'a> { changed_fields: &'a [&'a str], visibility: &'a str, }, + ProjectLanguageResolutionCompleted { + project_id: i64, + issue_count: usize, + source_language_count: usize, + primary_source_language: &'a str, + target_language: &'a str, + }, + ProjectLanguageRepairRetried { + project_id: i64, + job_id: i64, + previous_state: &'a str, + }, ProjectDeleted { project_id: i64, slug: &'a str, @@ -499,6 +511,35 @@ pub async fn append_event_tx( "visibility": visibility, }), ), + AuditEvent::ProjectLanguageResolutionCompleted { + project_id, + issue_count, + source_language_count, + primary_source_language, + target_language, + } => ( + "project.language_resolution_completed", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({ + "issue_count": issue_count, + "source_language_count": source_language_count, + "primary_source_language": primary_source_language, + "target_language": target_language, + }), + ), + AuditEvent::ProjectLanguageRepairRetried { + project_id, + job_id, + previous_state, + } => ( + "project.language_repair_retried", + "project", + project_id.to_string(), + Some(project_id), + serde_json::json!({"job_id": job_id, "previous_state": previous_state}), + ), AuditEvent::ProjectDeleted { project_id, slug } => ( "project.deleted", "project", diff --git a/backend/crates/prts-db/src/entries.rs b/backend/crates/prts-db/src/entries.rs index 66a5823..7efa9d9 100644 --- a/backend/crates/prts-db/src/entries.rs +++ b/backend/crates/prts-db/src/entries.rs @@ -157,36 +157,41 @@ pub async fn list( after_id: Option, limit: i64, ) -> Result, sqlx::Error> { - let mut qb = QueryBuilder::::new("SELECT * FROM entries WHERE project_id = "); + let mut qb = QueryBuilder::::new( + "SELECT entry.* FROM entries AS entry + JOIN files AS file ON file.id = entry.file_id + WHERE entry.project_id = ", + ); qb.push_bind(project_id); + qb.push(" AND entry.deleted_at IS NULL AND file.deleted_at IS NULL"); if let Some(fid) = filter.file_id { - qb.push(" AND file_id = "); + qb.push(" AND entry.file_id = "); qb.push_bind(fid); } if !filter.states.is_empty() { - qb.push(" AND state = ANY("); + qb.push(" AND entry.state = ANY("); qb.push_bind(filter.states.clone()); qb.push(")"); } if !filter.include_hidden { - qb.push(" AND hidden = FALSE"); + qb.push(" AND entry.hidden = FALSE"); } if let Some(q) = filter.query.as_deref().filter(|s| !s.is_empty()) { let pat = format!("%{q}%"); - qb.push(" AND (key ILIKE "); + qb.push(" AND (entry.key ILIKE "); qb.push_bind(pat.clone()); - qb.push(" OR translation ILIKE "); + qb.push(" OR entry.translation ILIKE "); qb.push_bind(pat.clone()); - qb.push(" OR original::text ILIKE "); + qb.push(" OR entry.original::text ILIKE "); qb.push_bind(pat); qb.push(")"); } if let Some(after) = after_id { - qb.push(" AND id > "); + qb.push(" AND entry.id > "); qb.push_bind(after); } - qb.push(" ORDER BY id LIMIT "); + qb.push(" ORDER BY entry.id LIMIT "); qb.push_bind(limit); qb.build_query_as::().fetch_all(pool).await @@ -198,11 +203,16 @@ pub async fn get( project_id: i64, entry_id: i64, ) -> Result, sqlx::Error> { - sqlx::query_as::<_, Entry>("SELECT * FROM entries WHERE id = $1 AND project_id = $2") - .bind(entry_id) - .bind(project_id) - .fetch_optional(pool) - .await + sqlx::query_as::<_, Entry>( + "SELECT entry.* FROM entries AS entry + JOIN files AS file ON file.id = entry.file_id + WHERE entry.id = $1 AND entry.project_id = $2 + AND entry.deleted_at IS NULL AND file.deleted_at IS NULL", + ) + .bind(entry_id) + .bind(project_id) + .fetch_optional(pool) + .await } /// 在调用方事务内锁定项目所属词条并返回一致快照。 @@ -346,8 +356,14 @@ pub async fn count_by_state( /// 导出用:取项目全部词条(按文件、key 排序)。 pub async fn list_for_export(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Entry>("SELECT * FROM entries WHERE project_id = $1 ORDER BY file_id, key") - .bind(project_id) - .fetch_all(pool) - .await + sqlx::query_as::<_, Entry>( + "SELECT entry.* FROM entries AS entry + JOIN files AS file ON file.id = entry.file_id + WHERE entry.project_id = $1 AND entry.deleted_at IS NULL + AND file.deleted_at IS NULL AND NOT entry.hidden + ORDER BY entry.file_id, entry.key", + ) + .bind(project_id) + .fetch_all(pool) + .await } diff --git a/backend/crates/prts-db/src/files.rs b/backend/crates/prts-db/src/files.rs index bc02c37..40f3385 100644 --- a/backend/crates/prts-db/src/files.rs +++ b/backend/crates/prts-db/src/files.rs @@ -130,18 +130,22 @@ pub async fn find_folder_for_update_tx( /// 列出项目的全部文件夹。 pub async fn list_folders(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, Folder>("SELECT * FROM folders WHERE project_id = $1 ORDER BY path") - .bind(project_id) - .fetch_all(pool) - .await + sqlx::query_as::<_, Folder>( + "SELECT * FROM folders WHERE project_id = $1 AND deleted_at IS NULL ORDER BY path", + ) + .bind(project_id) + .fetch_all(pool) + .await } /// 列出项目的全部文件。 pub async fn list_files(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { - sqlx::query_as::<_, File>("SELECT * FROM files WHERE project_id = $1 ORDER BY path") - .bind(project_id) - .fetch_all(pool) - .await + sqlx::query_as::<_, File>( + "SELECT * FROM files WHERE project_id = $1 AND deleted_at IS NULL ORDER BY path", + ) + .bind(project_id) + .fetch_all(pool) + .await } /// 统计指定文件夹子树内的文件数与物化词条数,供删除审计保存无正文元数据。 @@ -226,7 +230,7 @@ pub async fn delete_folder_tx( Ok(res.rows_affected() > 0) } -/// 重算并更新文件的词条计数。 +/// 低频修复文件计数;正常读取使用 `file_stats`。 pub async fn refresh_entry_count(pool: &PgPool, file_id: i64) -> Result<(), sqlx::Error> { let mut connection = pool.acquire().await?; refresh_entry_count_tx(&mut connection, file_id).await @@ -238,7 +242,9 @@ pub async fn refresh_entry_count_tx( file_id: i64, ) -> Result<(), sqlx::Error> { sqlx::query( - "UPDATE files SET entry_count = (SELECT COUNT(*) FROM entries WHERE file_id = $1) WHERE id = $1", + "UPDATE files SET entry_count = ( + SELECT visible_total::INTEGER FROM file_stats WHERE file_id = $1 + ) WHERE id = $1", ) .bind(file_id) .execute(conn) diff --git a/backend/crates/prts-db/src/jobs.rs b/backend/crates/prts-db/src/jobs.rs index a9838e5..191d1f8 100644 --- a/backend/crates/prts-db/src/jobs.rs +++ b/backend/crates/prts-db/src/jobs.rs @@ -25,6 +25,7 @@ pub enum JobKind { ProjectPurge(ProjectPurgeSnapshot), PrimarySourceLexicalReindex, PrimarySourceEmbeddingBackfill, + LanguageRepair, UploadProcess, UploadCleanup, FilePurge, @@ -37,6 +38,7 @@ impl JobKind { match value { "primary_source_lexical_reindex" => Some(Self::PrimarySourceLexicalReindex), "primary_source_embedding_backfill" => Some(Self::PrimarySourceEmbeddingBackfill), + "language_repair" => Some(Self::LanguageRepair), "upload_process" => Some(Self::UploadProcess), "upload_cleanup" => Some(Self::UploadCleanup), "file_purge" => Some(Self::FilePurge), @@ -50,6 +52,7 @@ impl JobKind { Self::ProjectPurge(_) => "project_purge", Self::PrimarySourceLexicalReindex => "primary_source_lexical_reindex", Self::PrimarySourceEmbeddingBackfill => "primary_source_embedding_backfill", + Self::LanguageRepair => "language_repair", Self::UploadProcess => "upload_process", Self::UploadCleanup => "upload_cleanup", Self::FilePurge => "file_purge", @@ -72,12 +75,17 @@ impl JobKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum JobResult { Completed, + EmbeddingSkipped, } impl JobResult { fn into_json(self) -> serde_json::Value { match self { Self::Completed => serde_json::json!({}), + Self::EmbeddingSkipped => serde_json::json!({ + "outcome": "skipped", + "reason": "embedding_provider_unconfigured", + }), } } } @@ -95,7 +103,7 @@ pub struct NewJob { /// 在现有事务连接内创建任务,供业务写与审计原子提交。 pub async fn create_tx(conn: &mut PgConnection, input: NewJob) -> Result { - if input.project_id.is_none() { + if input.project_id.is_none() && !matches!(&input.kind, JobKind::LanguageRepair) { return Err(sqlx::Error::Protocol( "new project-scoped job requires project_id".to_string(), )); @@ -257,7 +265,7 @@ async fn claim_next_inner( OR (state = 'running' AND lease_until <= now()) ) AND ( - kind = 'project_purge' + kind IN ('project_purge', 'language_repair') OR ( project_id IS NOT NULL AND NOT (project_id = ANY($3::BIGINT[])) diff --git a/backend/crates/prts-db/src/language_resolution.rs b/backend/crates/prts-db/src/language_resolution.rs new file mode 100644 index 0000000..b0270f0 --- /dev/null +++ b/backend/crates/prts-db/src/language_resolution.rs @@ -0,0 +1,182 @@ +//! 项目语言歧义诊断与 resolution 事务仓储。 + +use chrono::Utc; +use sqlx::{PgConnection, PgPool}; + +use crate::jobs::{JobKind, NewJob}; +use crate::models::{Job, LanguageResolutionIssue, LanguageResolutionSummary}; + +/// 唯一 owner 可读取的项目 open issues。 +pub async fn list_project_issues( + pool: &PgPool, + project_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT * FROM language_resolution_issues + WHERE project_id = $1 AND resolved_at IS NULL ORDER BY id", + ) + .bind(project_id) + .fetch_all(pool) + .await +} + +/// 在已锁项目事务中锁定全部 open issues。 +pub async fn lock_project_issues_tx( + conn: &mut PgConnection, + project_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT * FROM language_resolution_issues + WHERE project_id = $1 AND resolved_at IS NULL ORDER BY id FOR UPDATE", + ) + .bind(project_id) + .fetch_all(conn) + .await +} + +/// 锁定并读取冲突 entry 的当前候选;owner 选择必须基于该事务快照。 +pub async fn lock_entry_original_tx( + conn: &mut PgConnection, + project_id: i64, + entry_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT original FROM entries + WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL FOR UPDATE", + ) + .bind(entry_id) + .bind(project_id) + .fetch_optional(conn) + .await +} + +/// 在同一 resolution 事务中写回已验证的 canonical entry 原文对象。 +pub async fn update_entry_original_tx( + conn: &mut PgConnection, + project_id: i64, + entry_id: i64, + original: serde_json::Value, +) -> Result { + sqlx::query( + "UPDATE entries SET original = $3 + WHERE id = $1 AND project_id = $2 AND deleted_at IS NULL", + ) + .bind(entry_id) + .bind(project_id) + .bind(original) + .execute(&mut *conn) + .await + .map(|result| result.rows_affected()) +} + +/// 完成 owner 选择并原子排入下一次 canonical repair。 +pub async fn complete_owner_resolution_tx( + conn: &mut PgConnection, + project_id: i64, + issue_ids: &[i64], + source_languages: &[String], + primary_source_language: &str, + target_language: &str, +) -> Result { + let resolved = sqlx::query( + "UPDATE language_resolution_issues SET resolved_at = now() + WHERE project_id = $1 AND id = ANY($2::BIGINT[]) AND resolved_at IS NULL", + ) + .bind(project_id) + .bind(issue_ids) + .execute(&mut *conn) + .await? + .rows_affected(); + if resolved != issue_ids.len() as u64 { + return Err(sqlx::Error::Protocol( + "language resolution issue set changed during transaction".to_string(), + )); + } + + let job = crate::jobs::create_tx( + conn, + NewJob { + kind: JobKind::LanguageRepair, + project_id: Some(project_id), + stage: "projects".to_string(), + progress_total: None, + max_attempts: 5, + run_after: Utc::now(), + }, + ) + .await?; + sqlx::query( + "UPDATE projects SET source_langs = $2, primary_source_lang = $3, target_lang = $4, + language_repair_state = 'repairing', language_repair_job_id = $5, + lexical_state = 'pending', lexical_job_id = NULL, + embedding_state = 'pending', embedding_job_id = NULL + WHERE id = $1", + ) + .bind(project_id) + .bind(source_languages) + .bind(primary_source_language) + .bind(target_language) + .bind(job.id) + .execute(&mut *conn) + .await?; + Ok(job) +} + +/// 平台管理员只能重试属于该项目且已经失败的 repair job。 +pub async fn retry_failed_project_repair_tx( + conn: &mut PgConnection, + project_id: i64, + job_id: i64, +) -> Result, sqlx::Error> { + let job = sqlx::query_as::<_, Job>( + "SELECT * FROM jobs + WHERE id = $1 AND project_id = $2 AND kind = 'language_repair' FOR UPDATE", + ) + .bind(job_id) + .bind(project_id) + .fetch_optional(&mut *conn) + .await?; + let Some(job) = job else { + return Ok(None); + }; + if job.state != "failed" { + return Ok(None); + } + let retried = crate::jobs::manual_retry_tx(conn, job_id).await?; + if retried.is_some() { + sqlx::query( + "UPDATE projects SET language_repair_state = 'repairing' + WHERE id = $1 AND language_repair_job_id = $2", + ) + .bind(project_id) + .bind(job_id) + .execute(&mut *conn) + .await?; + } + Ok(retried) +} + +/// 平台管理员只读取项目标识、状态与数量,不读取 issue 详情或 entry 正文。 +pub async fn list_admin_summaries( + pool: &PgPool, + after_project_id: Option, + limit: i64, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT project.id AS project_id, project.slug AS project_slug, + count(issue.id) AS issue_count, + project.language_repair_state AS repair_state, + project.language_repair_job_id AS repair_job_id + FROM projects AS project + JOIN language_resolution_issues AS issue + ON issue.project_id = project.id AND issue.resolved_at IS NULL + WHERE ($1::BIGINT IS NULL OR project.id > $1) + GROUP BY project.id, project.slug, project.language_repair_state, + project.language_repair_job_id + ORDER BY project.id LIMIT $2", + ) + .bind(after_project_id) + .bind(limit) + .fetch_all(pool) + .await +} diff --git a/backend/crates/prts-db/src/lib.rs b/backend/crates/prts-db/src/lib.rs index 158edad..a504f6e 100644 --- a/backend/crates/prts-db/src/lib.rs +++ b/backend/crates/prts-db/src/lib.rs @@ -9,6 +9,7 @@ pub mod auth_sessions; pub mod entries; pub mod files; pub mod jobs; +pub mod language_resolution; pub mod memberships; pub mod messages; pub mod models; @@ -17,6 +18,7 @@ pub mod projects; pub mod search; pub mod search_settings; pub mod settings; +pub mod stats; pub mod users; /// 数据库错误别名,便于上层映射而无需直接依赖 sqlx。 diff --git a/backend/crates/prts-db/src/models.rs b/backend/crates/prts-db/src/models.rs index 8916ec9..3691104 100644 --- a/backend/crates/prts-db/src/models.rs +++ b/backend/crates/prts-db/src/models.rs @@ -63,8 +63,19 @@ pub struct Project { pub description: String, pub visibility: String, pub source_langs: Vec, + pub primary_source_lang: Option, pub target_lang: String, pub owner_id: i64, + pub language_repair_state: String, + pub language_repair_job_id: Option, + pub primary_source_changed_at: Option>, + pub lexical_state: String, + pub lexical_job_id: Option, + pub embedding_state: String, + pub embedding_job_id: Option, + pub avatar_key: Option, + pub avatar_content_type: Option, + pub avatar_updated_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, } @@ -96,6 +107,9 @@ pub struct Folder { pub parent_id: Option, pub name: String, pub path: String, + pub deleted_at: Option>, + pub deleted_by: Option, + pub deletion_change_set_id: Option, pub created_at: DateTime, } @@ -108,6 +122,9 @@ pub struct File { pub name: String, pub path: String, pub entry_count: i32, + pub deleted_at: Option>, + pub deleted_by: Option, + pub deletion_change_set_id: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -125,6 +142,9 @@ pub struct Entry { pub state: String, pub locked: bool, pub hidden: bool, + pub deleted_at: Option>, + pub deleted_by: Option, + pub deletion_change_set_id: Option, pub version: i64, pub updated_by: Option, pub created_at: DateTime, @@ -235,3 +255,57 @@ pub struct Job { pub finished_at: Option>, pub updated_at: DateTime, } + +/// 项目 effective-visible 物化统计。 +#[derive(Debug, Clone, FromRow)] +pub struct ProjectStats { + pub project_id: i64, + pub visible_total: i64, + pub untranslated_count: i64, + pub translated_count: i64, + pub questioned_count: i64, + pub checked_count: i64, + pub reviewed_count: i64, + pub updated_at: DateTime, +} + +/// 文件 effective-visible 物化统计。 +#[derive(Debug, Clone, FromRow)] +pub struct FileStats { + pub file_id: i64, + pub project_id: i64, + pub visible_total: i64, + pub untranslated_count: i64, + pub translated_count: i64, + pub questioned_count: i64, + pub checked_count: i64, + pub reviewed_count: i64, + pub updated_at: DateTime, +} + +/// 语言歧义诊断;metadata 禁止保存正文或秘密。 +#[derive(Debug, Clone, FromRow)] +pub struct LanguageResolutionIssue { + pub id: i64, + pub project_id: Option, + pub entry_id: Option, + pub user_id: Option, + pub entity_type: String, + pub entity_id_snapshot: String, + pub issue_kind: String, + pub raw_tag: Option, + pub canonical_tag: Option, + pub metadata: serde_json::Value, + pub resolved_at: Option>, + pub created_at: DateTime, +} + +/// 平台管理员可见的 metadata-only 项目诊断摘要。 +#[derive(Debug, Clone, FromRow)] +pub struct LanguageResolutionSummary { + pub project_id: i64, + pub project_slug: String, + pub issue_count: i64, + pub repair_state: String, + pub repair_job_id: Option, +} diff --git a/backend/crates/prts-db/src/search.rs b/backend/crates/prts-db/src/search.rs index 1b97d4e..15e2998 100644 --- a/backend/crates/prts-db/src/search.rs +++ b/backend/crates/prts-db/src/search.rs @@ -24,6 +24,11 @@ fn push_filters( if !include_hidden { qb.push(" AND hidden = FALSE"); } + qb.push(" AND deleted_at IS NULL"); + qb.push( + " AND EXISTS (SELECT 1 FROM files AS visible_file + WHERE visible_file.id = entries.file_id AND visible_file.deleted_at IS NULL)", + ); } /// FTS:源/译两列各按其语言 config 匹配;ts_rank 求和排序。 @@ -151,11 +156,13 @@ pub async fn current_search_fields( pool: &PgPool, entry_id: i64, ) -> Result>)>, sqlx::Error> { - let row: Option<(String, Option)> = - sqlx::query_as("SELECT source_text, embedding FROM entries WHERE id = $1") - .bind(entry_id) - .fetch_optional(pool) - .await?; + let row: Option<(String, Option)> = sqlx::query_as( + "SELECT source_text, embedding FROM entries + WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(entry_id) + .fetch_optional(pool) + .await?; // pgvector::Vector 转 Vec:用 as_slice().to_vec()。 Ok(row.map(|(st, emb)| (st, emb.map(|v| v.as_slice().to_vec())))) } @@ -178,11 +185,14 @@ pub async fn suggestions_vector( (1 - (e.embedding <=> $1))::float8 AS similarity FROM entries e JOIN projects p ON p.id = e.project_id + JOIN files f ON f.id = e.file_id JOIN memberships m ON m.project_id = p.id AND m.user_id = $2 WHERE p.target_lang = $3 AND e.state IN ('translated','questioned','checked','reviewed') AND e.translation <> '' AND e.source_text <> '' AND e.id <> $4 AND e.embedding IS NOT NULL + AND e.deleted_at IS NULL AND f.deleted_at IS NULL AND NOT e.hidden + AND p.language_repair_state = 'ready' AND p.lexical_state = 'ready' AND (1 - (e.embedding <=> $1)) >= $5 ORDER BY e.embedding <=> $1 LIMIT $6", @@ -214,11 +224,14 @@ pub async fn suggestions_trgm( similarity(e.source_text, $1)::float8 AS similarity FROM entries e JOIN projects p ON p.id = e.project_id + JOIN files f ON f.id = e.file_id JOIN memberships m ON m.project_id = p.id AND m.user_id = $2 WHERE p.target_lang = $3 AND e.state IN ('translated','questioned','checked','reviewed') AND e.translation <> '' AND e.source_text <> '' AND e.id <> $4 + AND e.deleted_at IS NULL AND f.deleted_at IS NULL AND NOT e.hidden + AND p.language_repair_state = 'ready' AND p.lexical_state = 'ready' AND similarity(e.source_text, $1) >= $5 ORDER BY similarity(e.source_text, $1) DESC LIMIT $6", diff --git a/backend/crates/prts-db/src/stats.rs b/backend/crates/prts-db/src/stats.rs new file mode 100644 index 0000000..f50cbbd --- /dev/null +++ b/backend/crates/prts-db/src/stats.rs @@ -0,0 +1,90 @@ +//! Effective-visible 项目与文件物化统计。 + +use sqlx::{PgConnection, PgPool}; + +use crate::models::{FileStats, ProjectStats}; + +/// 读取项目统计;迁移 trigger 保证每个项目都有一行。 +pub async fn project(pool: &PgPool, project_id: i64) -> Result { + sqlx::query_as("SELECT * FROM project_stats WHERE project_id = $1") + .bind(project_id) + .fetch_one(pool) + .await +} + +/// 读取项目全部 active 文件统计。 +pub async fn files(pool: &PgPool, project_id: i64) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT stats.* FROM file_stats AS stats + JOIN files AS file ON file.id = stats.file_id + WHERE stats.project_id = $1 AND file.deleted_at IS NULL + ORDER BY stats.file_id", + ) + .bind(project_id) + .fetch_all(pool) + .await +} + +/// 低频修复入口:按规范谓词重建单个项目统计,不用于正常读取。 +pub async fn rebuild_project_tx( + conn: &mut PgConnection, + project_id: i64, +) -> Result { + sqlx::query( + "INSERT INTO file_stats ( + file_id, project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count, updated_at + ) + SELECT file.id, file.project_id, + count(entry.id), + count(entry.id) FILTER (WHERE entry.state = 'untranslated'), + count(entry.id) FILTER (WHERE entry.state = 'translated'), + count(entry.id) FILTER (WHERE entry.state = 'questioned'), + count(entry.id) FILTER (WHERE entry.state = 'checked'), + count(entry.id) FILTER (WHERE entry.state = 'reviewed'), now() + FROM files AS file + LEFT JOIN entries AS entry + ON entry.file_id = file.id AND prts_entry_is_effectively_visible(entry) + WHERE file.project_id = $1 + GROUP BY file.id, file.project_id + ON CONFLICT (file_id) DO UPDATE SET + project_id = EXCLUDED.project_id, + visible_total = EXCLUDED.visible_total, + untranslated_count = EXCLUDED.untranslated_count, + translated_count = EXCLUDED.translated_count, + questioned_count = EXCLUDED.questioned_count, + checked_count = EXCLUDED.checked_count, + reviewed_count = EXCLUDED.reviewed_count, + updated_at = now()", + ) + .bind(project_id) + .execute(&mut *conn) + .await?; + + sqlx::query_as( + "INSERT INTO project_stats ( + project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count, updated_at + ) + SELECT $1, + COALESCE(sum(visible_total), 0), + COALESCE(sum(untranslated_count), 0), + COALESCE(sum(translated_count), 0), + COALESCE(sum(questioned_count), 0), + COALESCE(sum(checked_count), 0), + COALESCE(sum(reviewed_count), 0), now() + FROM file_stats WHERE project_id = $1 + ON CONFLICT (project_id) DO UPDATE SET + visible_total = EXCLUDED.visible_total, + untranslated_count = EXCLUDED.untranslated_count, + translated_count = EXCLUDED.translated_count, + questioned_count = EXCLUDED.questioned_count, + checked_count = EXCLUDED.checked_count, + reviewed_count = EXCLUDED.reviewed_count, + updated_at = now() + RETURNING *", + ) + .bind(project_id) + .fetch_one(conn) + .await +} diff --git a/backend/migrations/0008_workspace_meta_stats.sql b/backend/migrations/0008_workspace_meta_stats.sql new file mode 100644 index 0000000..8041907 --- /dev/null +++ b/backend/migrations/0008_workspace_meta_stats.sql @@ -0,0 +1,565 @@ +-- Workspace foundation:语言修复边界、项目元信息、唯一 owner 与物化统计。 +-- 本迁移只预建删除字段;soft-delete writer 与 history schema 必须等 0010。 + +ALTER TABLE projects + ADD COLUMN primary_source_lang TEXT, + ADD COLUMN language_repair_state TEXT NOT NULL DEFAULT 'repairing', + ADD COLUMN language_repair_job_id BIGINT REFERENCES jobs (id) ON DELETE SET NULL, + ADD COLUMN primary_source_changed_at TIMESTAMPTZ, + ADD COLUMN lexical_state TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN lexical_job_id BIGINT REFERENCES jobs (id) ON DELETE SET NULL, + ADD COLUMN embedding_state TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN embedding_job_id BIGINT REFERENCES jobs (id) ON DELETE SET NULL, + ADD COLUMN avatar_key TEXT, + ADD COLUMN avatar_content_type TEXT, + ADD COLUMN avatar_updated_at TIMESTAMPTZ, + ADD CONSTRAINT projects_language_repair_state_chk + CHECK (language_repair_state IN ('repairing', 'ready', 'needs_language_resolution')), + ADD CONSTRAINT projects_lexical_state_chk + CHECK (lexical_state IN ('pending', 'ready', 'rebuilding', 'failed')), + ADD CONSTRAINT projects_embedding_state_chk + CHECK (embedding_state IN ('pending', 'running', 'ready', 'degraded', 'failed')), + ADD CONSTRAINT projects_ready_language_chk CHECK ( + language_repair_state <> 'ready' + OR ( + primary_source_lang IS NOT NULL + AND cardinality(source_langs) > 0 + AND primary_source_lang = ANY(source_langs) + ) + ), + ADD CONSTRAINT projects_avatar_metadata_chk CHECK ( + (avatar_key IS NULL AND avatar_content_type IS NULL AND avatar_updated_at IS NULL) + OR (avatar_key IS NOT NULL AND avatar_content_type = 'image/webp' AND avatar_updated_at IS NOT NULL) + ); + +ALTER TABLE folders + ADD COLUMN deleted_at TIMESTAMPTZ, + ADD COLUMN deleted_by BIGINT REFERENCES users (id) ON DELETE SET NULL, + ADD COLUMN deletion_change_set_id UUID; + +ALTER TABLE files + ADD COLUMN deleted_at TIMESTAMPTZ, + ADD COLUMN deleted_by BIGINT REFERENCES users (id) ON DELETE SET NULL, + ADD COLUMN deletion_change_set_id UUID; + +ALTER TABLE entries + ADD COLUMN deleted_at TIMESTAMPTZ, + ADD COLUMN deleted_by BIGINT REFERENCES users (id) ON DELETE SET NULL, + ADD COLUMN deletion_change_set_id UUID; + +CREATE OR REPLACE FUNCTION initialize_project_language_foundation() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.primary_source_lang IS NULL AND cardinality(NEW.source_langs) > 0 THEN + NEW.primary_source_lang := NEW.source_langs[1]; + END IF; + IF NEW.primary_source_lang IS NOT NULL + AND NEW.primary_source_lang = ANY(NEW.source_langs) THEN + NEW.language_repair_state := 'ready'; + NEW.lexical_state := 'ready'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projects_language_foundation_init_trg + BEFORE INSERT ON projects + FOR EACH ROW EXECUTE FUNCTION initialize_project_language_foundation(); + +CREATE INDEX folders_project_active_path_idx + ON folders (project_id, path) WHERE deleted_at IS NULL; +CREATE INDEX files_project_active_path_idx + ON files (project_id, path, id) WHERE deleted_at IS NULL; +CREATE INDEX entries_project_active_id_idx + ON entries (project_id, id) WHERE deleted_at IS NULL; + +-- issue 仅保存 tag 与不可变实体标识;冲突正文仍留在原业务行,不能复制到诊断表。 +CREATE TABLE language_resolution_issues ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + project_id BIGINT REFERENCES projects (id) ON DELETE SET NULL, + entry_id BIGINT REFERENCES entries (id) ON DELETE SET NULL, + user_id BIGINT REFERENCES users (id) ON DELETE SET NULL, + entity_type TEXT NOT NULL, + entity_id_snapshot TEXT NOT NULL, + issue_kind TEXT NOT NULL, + raw_tag TEXT, + canonical_tag TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT language_resolution_entity_chk + CHECK (entity_type IN ('user', 'project', 'entry', 'term')), + CONSTRAINT language_resolution_kind_chk CHECK ( + issue_kind IN ( + 'invalid_tag', 'canonical_duplicate', 'conflicting_original_keys', + 'empty_source_languages', 'primary_not_in_sources' + ) + ), + CONSTRAINT language_resolution_metadata_chk + CHECK (jsonb_typeof(metadata) = 'object' AND NOT prts_jsonb_contains_secret_key(metadata)) +); +CREATE INDEX language_resolution_project_open_idx + ON language_resolution_issues (project_id, id) WHERE resolved_at IS NULL; +CREATE INDEX language_resolution_user_open_idx + ON language_resolution_issues (user_id, id) WHERE resolved_at IS NULL; +CREATE UNIQUE INDEX language_resolution_open_entity_kind_idx + ON language_resolution_issues (entity_type, entity_id_snapshot, issue_kind) + WHERE resolved_at IS NULL; + +-- 以 projects.owner_id 为唯一真值修复历史 membership。 +UPDATE memberships AS membership +SET role = 'manager' +FROM projects AS project +WHERE membership.project_id = project.id + AND membership.role = 'owner' + AND membership.user_id <> project.owner_id; + +INSERT INTO memberships (project_id, user_id, role) +SELECT id, owner_id, 'owner' FROM projects +ON CONFLICT (project_id, user_id) DO UPDATE SET role = 'owner'; + +INSERT INTO audit_log ( + actor_id, actor_kind, action, target_type, target_id, project_id_snapshot, payload +) +SELECT NULL, 'system', 'project.owner_membership_repaired', 'project', id::TEXT, id, + jsonb_build_object('owner_id', owner_id) +FROM projects; + +INSERT INTO notifications (user_id, type, payload) +SELECT owner_id, 'project_owner_membership_repaired', jsonb_build_object('project_id', id) +FROM projects; + +CREATE UNIQUE INDEX memberships_one_owner_idx + ON memberships (project_id) WHERE role = 'owner'; + +CREATE OR REPLACE FUNCTION create_project_owner_membership() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO memberships (project_id, user_id, role) + VALUES (NEW.id, NEW.owner_id, 'owner') + ON CONFLICT (project_id, user_id) DO UPDATE SET role = 'owner'; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projects_owner_membership_init_trg + AFTER INSERT ON projects + FOR EACH ROW EXECUTE FUNCTION create_project_owner_membership(); + +CREATE OR REPLACE FUNCTION enforce_project_owner_membership() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE expected_owner BIGINT; +BEGIN + IF TG_OP = 'DELETE' THEN + IF pg_trigger_depth() > 1 THEN + RETURN OLD; + END IF; + IF OLD.role = 'owner' THEN + SELECT owner_id INTO expected_owner FROM projects WHERE id = OLD.project_id; + IF expected_owner = OLD.user_id THEN + RAISE EXCEPTION 'project owner membership cannot be removed' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN OLD; + END IF; + IF TG_OP = 'UPDATE' AND OLD.role = 'owner' AND NEW.role <> 'owner' THEN + SELECT owner_id INTO expected_owner FROM projects WHERE id = OLD.project_id; + IF expected_owner = OLD.user_id THEN + RAISE EXCEPTION 'project owner membership cannot be downgraded' USING ERRCODE = '23514'; + END IF; + END IF; + IF NEW.role = 'owner' THEN + SELECT owner_id INTO expected_owner FROM projects WHERE id = NEW.project_id; + IF expected_owner IS DISTINCT FROM NEW.user_id THEN + RAISE EXCEPTION 'owner membership must match projects.owner_id' USING ERRCODE = '23514'; + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE CONSTRAINT TRIGGER memberships_owner_guard_trg + AFTER INSERT OR UPDATE OR DELETE ON memberships + DEFERRABLE INITIALLY IMMEDIATE + FOR EACH ROW EXECUTE FUNCTION enforce_project_owner_membership(); + +CREATE OR REPLACE FUNCTION prevent_project_owner_transfer() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.owner_id IS DISTINCT FROM OLD.owner_id THEN + RAISE EXCEPTION 'project owner transfer is not available' USING ERRCODE = '23514'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER projects_owner_transfer_guard_trg + BEFORE UPDATE OF owner_id ON projects + FOR EACH ROW EXECUTE FUNCTION prevent_project_owner_transfer(); + +-- effective-visible 统计真值。删除字段在 foundation 期间必须保持 NULL,谓词已为 0010 预留。 +CREATE TABLE project_stats ( + project_id BIGINT PRIMARY KEY REFERENCES projects (id) ON DELETE CASCADE, + visible_total BIGINT NOT NULL DEFAULT 0, + untranslated_count BIGINT NOT NULL DEFAULT 0, + translated_count BIGINT NOT NULL DEFAULT 0, + questioned_count BIGINT NOT NULL DEFAULT 0, + checked_count BIGINT NOT NULL DEFAULT 0, + reviewed_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT project_stats_nonnegative_chk CHECK ( + visible_total >= 0 AND untranslated_count >= 0 AND translated_count >= 0 + AND questioned_count >= 0 AND checked_count >= 0 AND reviewed_count >= 0 + ), + CONSTRAINT project_stats_sum_chk CHECK ( + visible_total = untranslated_count + translated_count + questioned_count + + checked_count + reviewed_count + ) +); + +CREATE TABLE file_stats ( + file_id BIGINT PRIMARY KEY REFERENCES files (id) ON DELETE CASCADE, + project_id BIGINT NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + visible_total BIGINT NOT NULL DEFAULT 0, + untranslated_count BIGINT NOT NULL DEFAULT 0, + translated_count BIGINT NOT NULL DEFAULT 0, + questioned_count BIGINT NOT NULL DEFAULT 0, + checked_count BIGINT NOT NULL DEFAULT 0, + reviewed_count BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT file_stats_nonnegative_chk CHECK ( + visible_total >= 0 AND untranslated_count >= 0 AND translated_count >= 0 + AND questioned_count >= 0 AND checked_count >= 0 AND reviewed_count >= 0 + ), + CONSTRAINT file_stats_sum_chk CHECK ( + visible_total = untranslated_count + translated_count + questioned_count + + checked_count + reviewed_count + ) +); +CREATE INDEX file_stats_project_idx ON file_stats (project_id, file_id); + +CREATE OR REPLACE FUNCTION prts_stats_state_delta(state_name TEXT, amount BIGINT) +RETURNS TABLE ( + untranslated BIGINT, translated BIGINT, questioned BIGINT, checked BIGINT, reviewed BIGINT +) +LANGUAGE sql +IMMUTABLE +STRICT +AS $$ + SELECT + CASE WHEN state_name = 'untranslated' THEN amount ELSE 0 END, + CASE WHEN state_name = 'translated' THEN amount ELSE 0 END, + CASE WHEN state_name = 'questioned' THEN amount ELSE 0 END, + CASE WHEN state_name = 'checked' THEN amount ELSE 0 END, + CASE WHEN state_name = 'reviewed' THEN amount ELSE 0 END +$$; + +CREATE OR REPLACE FUNCTION prts_apply_entry_stats_delta( + target_project_id BIGINT, target_file_id BIGINT, state_name TEXT, amount BIGINT +) +RETURNS VOID +LANGUAGE plpgsql +AS $$ +DECLARE delta RECORD; +BEGIN + SELECT * INTO delta FROM prts_stats_state_delta(state_name, amount); + + IF amount < 0 THEN + UPDATE project_stats SET + visible_total = GREATEST(0, visible_total + amount), + untranslated_count = GREATEST(0, untranslated_count + delta.untranslated), + translated_count = GREATEST(0, translated_count + delta.translated), + questioned_count = GREATEST(0, questioned_count + delta.questioned), + checked_count = GREATEST(0, checked_count + delta.checked), + reviewed_count = GREATEST(0, reviewed_count + delta.reviewed), + updated_at = now() + WHERE project_id = target_project_id; + + UPDATE file_stats SET + visible_total = GREATEST(0, visible_total + amount), + untranslated_count = GREATEST(0, untranslated_count + delta.untranslated), + translated_count = GREATEST(0, translated_count + delta.translated), + questioned_count = GREATEST(0, questioned_count + delta.questioned), + checked_count = GREATEST(0, checked_count + delta.checked), + reviewed_count = GREATEST(0, reviewed_count + delta.reviewed), + updated_at = now() + WHERE file_id = target_file_id; + RETURN; + END IF; + + INSERT INTO project_stats ( + project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count + ) VALUES ( + target_project_id, amount, delta.untranslated, delta.translated, + delta.questioned, delta.checked, delta.reviewed + ) + ON CONFLICT (project_id) DO UPDATE SET + visible_total = GREATEST(0, project_stats.visible_total + EXCLUDED.visible_total), + untranslated_count = GREATEST( + 0, project_stats.untranslated_count + EXCLUDED.untranslated_count + ), + translated_count = GREATEST( + 0, project_stats.translated_count + EXCLUDED.translated_count + ), + questioned_count = GREATEST( + 0, project_stats.questioned_count + EXCLUDED.questioned_count + ), + checked_count = GREATEST(0, project_stats.checked_count + EXCLUDED.checked_count), + reviewed_count = GREATEST(0, project_stats.reviewed_count + EXCLUDED.reviewed_count), + updated_at = now(); + + INSERT INTO file_stats ( + file_id, project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count + ) VALUES ( + target_file_id, target_project_id, amount, delta.untranslated, delta.translated, + delta.questioned, delta.checked, delta.reviewed + ) + ON CONFLICT (file_id) DO UPDATE SET + visible_total = GREATEST(0, file_stats.visible_total + EXCLUDED.visible_total), + untranslated_count = GREATEST( + 0, file_stats.untranslated_count + EXCLUDED.untranslated_count + ), + translated_count = GREATEST(0, file_stats.translated_count + EXCLUDED.translated_count), + questioned_count = GREATEST(0, file_stats.questioned_count + EXCLUDED.questioned_count), + checked_count = GREATEST(0, file_stats.checked_count + EXCLUDED.checked_count), + reviewed_count = GREATEST(0, file_stats.reviewed_count + EXCLUDED.reviewed_count), + updated_at = now(); +END; +$$; + +CREATE OR REPLACE FUNCTION prts_entry_is_effectively_visible(entry_row entries) +RETURNS BOOLEAN +LANGUAGE sql +STABLE +AS $$ + SELECT entry_row.deleted_at IS NULL + AND NOT entry_row.hidden + AND EXISTS ( + SELECT 1 FROM files AS file + WHERE file.id = entry_row.file_id AND file.deleted_at IS NULL + ) + AND NOT EXISTS ( + SELECT 1 + FROM files AS file + JOIN folders AS ancestor + ON ancestor.project_id = file.project_id + AND (file.path LIKE ancestor.path || '/%' OR file.path = ancestor.path) + WHERE file.id = entry_row.file_id AND ancestor.deleted_at IS NOT NULL + ) +$$; + +CREATE OR REPLACE FUNCTION maintain_entry_stats() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + old_visible BOOLEAN := TG_OP <> 'INSERT' AND prts_entry_is_effectively_visible(OLD); + new_visible BOOLEAN := TG_OP <> 'DELETE' AND prts_entry_is_effectively_visible(NEW); +BEGIN + IF TG_OP = 'UPDATE' + AND old_visible + AND new_visible + AND OLD.project_id = NEW.project_id + AND OLD.file_id = NEW.file_id THEN + IF OLD.state IS DISTINCT FROM NEW.state THEN + UPDATE project_stats SET + untranslated_count = untranslated_count + + CASE WHEN NEW.state = 'untranslated' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'untranslated' THEN 1 ELSE 0 END, + translated_count = translated_count + + CASE WHEN NEW.state = 'translated' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'translated' THEN 1 ELSE 0 END, + questioned_count = questioned_count + + CASE WHEN NEW.state = 'questioned' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'questioned' THEN 1 ELSE 0 END, + checked_count = checked_count + + CASE WHEN NEW.state = 'checked' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'checked' THEN 1 ELSE 0 END, + reviewed_count = reviewed_count + + CASE WHEN NEW.state = 'reviewed' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'reviewed' THEN 1 ELSE 0 END, + updated_at = now() + WHERE project_id = OLD.project_id; + UPDATE file_stats SET + untranslated_count = untranslated_count + + CASE WHEN NEW.state = 'untranslated' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'untranslated' THEN 1 ELSE 0 END, + translated_count = translated_count + + CASE WHEN NEW.state = 'translated' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'translated' THEN 1 ELSE 0 END, + questioned_count = questioned_count + + CASE WHEN NEW.state = 'questioned' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'questioned' THEN 1 ELSE 0 END, + checked_count = checked_count + + CASE WHEN NEW.state = 'checked' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'checked' THEN 1 ELSE 0 END, + reviewed_count = reviewed_count + + CASE WHEN NEW.state = 'reviewed' THEN 1 ELSE 0 END + - CASE WHEN OLD.state = 'reviewed' THEN 1 ELSE 0 END, + updated_at = now() + WHERE file_id = OLD.file_id; + END IF; + RETURN NEW; + END IF; + IF old_visible + AND ( + TG_OP <> 'DELETE' + OR EXISTS ( + SELECT 1 FROM file_stats + WHERE file_id = OLD.file_id AND visible_total > 0 + ) + ) THEN + PERFORM prts_apply_entry_stats_delta(OLD.project_id, OLD.file_id, OLD.state, -1); + END IF; + IF new_visible THEN + PERFORM prts_apply_entry_stats_delta(NEW.project_id, NEW.file_id, NEW.state, 1); + END IF; + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION maintain_project_stats_row() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO project_stats (project_id) VALUES (NEW.id) + ON CONFLICT (project_id) DO NOTHING; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION maintain_file_stats_row() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + INSERT INTO file_stats (file_id, project_id) VALUES (NEW.id, NEW.project_id) + ON CONFLICT (file_id) DO NOTHING; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION subtract_file_stats_before_delete() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE current_stats file_stats; +BEGIN + SELECT * INTO current_stats FROM file_stats WHERE file_id = OLD.id; + IF FOUND AND OLD.deleted_at IS NULL THEN + UPDATE project_stats SET + visible_total = visible_total - current_stats.visible_total, + untranslated_count = untranslated_count - current_stats.untranslated_count, + translated_count = translated_count - current_stats.translated_count, + questioned_count = questioned_count - current_stats.questioned_count, + checked_count = checked_count - current_stats.checked_count, + reviewed_count = reviewed_count - current_stats.reviewed_count, + updated_at = now() + WHERE project_id = OLD.project_id; + UPDATE file_stats SET + visible_total = 0, + untranslated_count = 0, + translated_count = 0, + questioned_count = 0, + checked_count = 0, + reviewed_count = 0, + updated_at = now() + WHERE file_id = OLD.id; + END IF; + RETURN OLD; +END; +$$; + +INSERT INTO project_stats ( + project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count +) +SELECT project.id, + count(entry.id), + count(entry.id) FILTER (WHERE entry.state = 'untranslated'), + count(entry.id) FILTER (WHERE entry.state = 'translated'), + count(entry.id) FILTER (WHERE entry.state = 'questioned'), + count(entry.id) FILTER (WHERE entry.state = 'checked'), + count(entry.id) FILTER (WHERE entry.state = 'reviewed') +FROM projects AS project +LEFT JOIN entries AS entry + ON entry.project_id = project.id AND prts_entry_is_effectively_visible(entry) +GROUP BY project.id; + +INSERT INTO file_stats ( + file_id, project_id, visible_total, untranslated_count, translated_count, + questioned_count, checked_count, reviewed_count +) +SELECT file.id, file.project_id, + count(entry.id), + count(entry.id) FILTER (WHERE entry.state = 'untranslated'), + count(entry.id) FILTER (WHERE entry.state = 'translated'), + count(entry.id) FILTER (WHERE entry.state = 'questioned'), + count(entry.id) FILTER (WHERE entry.state = 'checked'), + count(entry.id) FILTER (WHERE entry.state = 'reviewed') +FROM files AS file +LEFT JOIN entries AS entry + ON entry.file_id = file.id AND prts_entry_is_effectively_visible(entry) +GROUP BY file.id, file.project_id; + +CREATE TRIGGER entries_stats_maintain_trg + AFTER INSERT OR UPDATE OF file_id, project_id, state, hidden, deleted_at OR DELETE ON entries + FOR EACH ROW EXECUTE FUNCTION maintain_entry_stats(); + +CREATE TRIGGER projects_stats_init_trg + AFTER INSERT ON projects + FOR EACH ROW EXECUTE FUNCTION maintain_project_stats_row(); + +CREATE TRIGGER files_stats_init_trg + AFTER INSERT ON files + FOR EACH ROW EXECUTE FUNCTION maintain_file_stats_row(); + +CREATE TRIGGER files_stats_delete_trg + BEFORE DELETE ON files + FOR EACH ROW EXECUTE FUNCTION subtract_file_stats_before_delete(); + +-- 每个 legacy 项目获得可恢复 repair job。worker 负责完整 BCP-47 canonicalization 与冲突隔离。 +WITH repair_jobs AS ( + INSERT INTO jobs (kind, project_id, stage, payload, max_attempts) + SELECT 'language_repair', id, 'projects', jsonb_build_object('project_id', id), 5 + FROM projects + RETURNING id, project_id +) +UPDATE projects AS project +SET language_repair_job_id = repair_jobs.id +FROM repair_jobs +WHERE project.id = repair_jobs.project_id; + +INSERT INTO jobs (kind, project_id, stage, payload, max_attempts) +VALUES ('language_repair', NULL, 'users', '{}', 5); + +DO $$ +DECLARE runtime_role TEXT := current_setting('prts.runtime_role', true); +BEGIN + IF runtime_role IS NULL OR btrim(runtime_role) = '' THEN + RAISE EXCEPTION 'prts.runtime_role must be set by the migration runner'; + END IF; + EXECUTE format( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON language_resolution_issues, project_stats, file_stats TO %I', + runtime_role + ); + EXECUTE format( + 'GRANT USAGE, SELECT ON SEQUENCE language_resolution_issues_id_seq TO %I', + runtime_role + ); +END; +$$; diff --git a/backend/migrations/0009_primary_source_search.sql b/backend/migrations/0009_primary_source_search.sql new file mode 100644 index 0000000..5a93fb2 --- /dev/null +++ b/backend/migrations/0009_primary_source_search.sql @@ -0,0 +1,101 @@ +-- Primary-source search foundation。只能在 0008 的 repair gate 之后应用。 + +DROP TRIGGER entries_search_maintain_trg ON entries; + +CREATE OR REPLACE FUNCTION entries_search_maintain() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + source_language TEXT; + target_language TEXT; + repair_state TEXT; + next_source TEXT; +BEGIN + SELECT primary_source_lang, target_lang, language_repair_state + INTO source_language, target_language, repair_state + FROM projects + WHERE id = NEW.project_id; + + IF repair_state <> 'ready' OR source_language IS NULL THEN + NEW.source_text := ''; + NEW.source_tsv := NULL; + NEW.translation_tsv := NULL; + NEW.embedding := NULL; + NEW.embed_attempts := 0; + RETURN NEW; + END IF; + + IF NOT (NEW.original ? source_language) THEN + RAISE EXCEPTION 'entry original lacks canonical primary source language' + USING ERRCODE = '23514'; + END IF; + next_source := COALESCE(NEW.original ->> source_language, ''); + IF TG_OP = 'INSERT' OR next_source IS DISTINCT FROM OLD.source_text THEN + NEW.embedding := NULL; + NEW.embed_attempts := 0; + END IF; + NEW.source_text := next_source; + NEW.source_tsv := to_tsvector(prts_ts_config(source_language), next_source); + NEW.translation_tsv := to_tsvector( + prts_ts_config(target_language), COALESCE(NEW.translation, '') + ); + RETURN NEW; +END; +$$; + +CREATE TRIGGER entries_search_maintain_trg + BEFORE INSERT OR UPDATE ON entries + FOR EACH ROW EXECUTE FUNCTION entries_search_maintain(); + +-- 只 reconcile 已完成 canonical repair 且 exact key 存在的行。 +UPDATE entries AS entry +SET source_text = entry.original ->> project.primary_source_lang, + source_tsv = to_tsvector( + prts_ts_config(project.primary_source_lang), + entry.original ->> project.primary_source_lang + ), + translation_tsv = to_tsvector( + prts_ts_config(project.target_lang), COALESCE(entry.translation, '') + ), + embedding = NULL, + embed_attempts = 0 +FROM projects AS project +WHERE project.id = entry.project_id + AND project.language_repair_state = 'ready' + AND project.primary_source_lang IS NOT NULL + AND entry.original ? project.primary_source_lang; + +CREATE TABLE workspace_foundation_state ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + schema_revision INTEGER NOT NULL, + primary_search_revision INTEGER NOT NULL, + ready_project_count BIGINT NOT NULL DEFAULT 0, + unresolved_project_count BIGINT NOT NULL DEFAULT 0, + lexical_worker_registered BOOLEAN NOT NULL DEFAULT FALSE, + reconciled_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +INSERT INTO workspace_foundation_state ( + singleton, schema_revision, primary_search_revision, + ready_project_count, unresolved_project_count, reconciled_at +) +SELECT TRUE, 8, 9, + count(*) FILTER (WHERE language_repair_state = 'ready'), + count(*) FILTER (WHERE language_repair_state = 'needs_language_resolution'), + now() +FROM projects; + +DO $$ +DECLARE runtime_role TEXT := current_setting('prts.runtime_role', true); +BEGIN + IF runtime_role IS NULL OR btrim(runtime_role) = '' THEN + RAISE EXCEPTION 'prts.runtime_role must be set by the migration runner'; + END IF; + EXECUTE format( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON workspace_foundation_state TO %I', + runtime_role + ); +END; +$$; From 2da5091dcf2dba8f10125394192b9ce563906a4a Mon Sep 17 00:00:00 2001 From: ZengXiaoPi <1768506869@qq.com> Date: Sun, 12 Jul 2026 19:16:05 +0800 Subject: [PATCH 15/45] feat: add workspace shared foundations --- .env.example | 4 + backend/config/default.toml | 4 + backend/crates/prts-api/src/dto.rs | 2 + backend/crates/prts-api/src/dto/upload.rs | 27 + backend/crates/prts-api/src/routes/admin.rs | 6 + .../prts-api/src/routes/admin_settings.rs | 111 +++- backend/crates/prts-api/src/routes/meta.rs | 22 + backend/crates/prts-api/src/routes/mod.rs | 6 + .../crates/prts-api/tests/db_integration.rs | 165 +++++- backend/crates/prts-common/src/config.rs | 28 + backend/crates/prts-db/src/audit.rs | 10 + backend/crates/prts-db/src/lib.rs | 1 + backend/crates/prts-db/src/upload_settings.rs | 166 ++++++ deploy/docker-compose.yml | 7 + frontend/package.json | 5 +- frontend/pnpm-lock.yaml | 479 +++++++++++++++++- frontend/src/App.vue | 12 +- frontend/src/api/http.ts | 2 + frontend/src/api/index.ts | 8 + frontend/src/api/types.ts | 49 ++ frontend/src/api/uploads.ts | 7 + frontend/src/components/MarkdownEditor.vue | 79 +++ frontend/src/components/MarkdownView.vue | 50 ++ frontend/src/components/NotificationBell.vue | 2 +- frontend/src/components/SearchFilters.vue | 2 +- frontend/src/composables/useJobProgress.ts | 62 +++ frontend/src/i18n/index.ts | 21 +- frontend/src/i18n/locales/en.json | 6 + frontend/src/i18n/locales/zh-CN.json | 6 + frontend/src/lib/capabilities.spec.ts | 28 + frontend/src/lib/capabilities.ts | 9 + frontend/src/lib/markdown.spec.ts | 28 + frontend/src/lib/markdown.ts | 67 +++ frontend/src/main.ts | 6 +- frontend/src/quasar-variables.sass | 4 + frontend/src/styles/theme.scss | 20 +- frontend/src/views/AdminView.vue | 2 +- frontend/src/views/EditorView.vue | 22 +- frontend/src/views/MessageThreadView.vue | 4 +- frontend/src/views/OAuthCallbackView.vue | 2 +- frontend/src/views/ProfileView.vue | 8 +- frontend/src/views/ProjectDetailView.vue | 49 +- frontend/src/views/ProjectsView.vue | 4 +- 43 files changed, 1524 insertions(+), 78 deletions(-) create mode 100644 backend/crates/prts-api/src/dto/upload.rs create mode 100644 backend/crates/prts-db/src/upload_settings.rs create mode 100644 frontend/src/api/uploads.ts create mode 100644 frontend/src/components/MarkdownEditor.vue create mode 100644 frontend/src/components/MarkdownView.vue create mode 100644 frontend/src/composables/useJobProgress.ts create mode 100644 frontend/src/lib/capabilities.spec.ts create mode 100644 frontend/src/lib/capabilities.ts create mode 100644 frontend/src/lib/markdown.spec.ts create mode 100644 frontend/src/lib/markdown.ts diff --git a/.env.example b/.env.example index 57e4516..f7f52c5 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ PRTS__DATABASE__MAX_CONNECTIONS=10 # --- Redis(compose 内服务名为 redis)--- PRTS__REDIS__URL=redis://redis:6379 +# --- 媒体与上传临时文件(容器内持久卷路径)--- +PRTS__MEDIA__DIRECTORY=/app/data/media +PRTS__MEDIA__UPLOAD_TEMP_DIRECTORY=/app/data/upload-temp + # --- 日志 --- RUST_LOG=info,sqlx=warn # 设为 json 可输出结构化日志,便于生产采集 diff --git a/backend/config/default.toml b/backend/config/default.toml index 1a54535..a4146f8 100644 --- a/backend/config/default.toml +++ b/backend/config/default.toml @@ -20,3 +20,7 @@ max_connections = 10 [redis] url = "redis://localhost:6379" + +[media] +directory = "./data/media" +upload_temp_directory = "./data/upload-temp" diff --git a/backend/crates/prts-api/src/dto.rs b/backend/crates/prts-api/src/dto.rs index 8597f2a..e2fb1af 100644 --- a/backend/crates/prts-api/src/dto.rs +++ b/backend/crates/prts-api/src/dto.rs @@ -7,6 +7,8 @@ use prts_db::models::User; #[path = "dto/capabilities.rs"] pub mod capabilities; +#[path = "dto/upload.rs"] +pub mod upload; /// 用户对外表示。 #[derive(Debug, Serialize, ToSchema)] diff --git a/backend/crates/prts-api/src/dto/upload.rs b/backend/crates/prts-api/src/dto/upload.rs new file mode 100644 index 0000000..1ba3481 --- /dev/null +++ b/backend/crates/prts-api/src/dto/upload.rs @@ -0,0 +1,27 @@ +//! 上传设置的共享 OpenAPI DTO;普通 meta 与管理端使用同一响应结构。 + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use prts_db::upload_settings::UploadConfig; + +/// 上传客户端运行时限制;字节单位避免 MB/GB 歧义。 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UploadConfigDto { + pub max_files_per_batch: i32, + pub max_bytes_per_file: i64, + pub max_bytes_per_batch: i64, + pub client_concurrency: i32, +} + +impl From for UploadConfigDto { + fn from(config: UploadConfig) -> Self { + Self { + max_files_per_batch: config.max_files_per_batch, + max_bytes_per_file: config.max_bytes_per_file, + max_bytes_per_batch: config.max_bytes_per_batch, + client_concurrency: config.client_concurrency, + } + } +} diff --git a/backend/crates/prts-api/src/routes/admin.rs b/backend/crates/prts-api/src/routes/admin.rs index 76db9dd..fd8d9a7 100644 --- a/backend/crates/prts-api/src/routes/admin.rs +++ b/backend/crates/prts-api/src/routes/admin.rs @@ -55,6 +55,12 @@ pub async fn update_settings( user.require_platform(nodes::PLATFORM_SETTINGS)?; let mut settings: Vec<_> = req.settings.into_iter().collect(); settings.sort_by(|left, right| left.0.cmp(&right.0)); + if settings + .iter() + .any(|(key, _)| matches!(key.as_str(), "search.config" | "upload.config")) + { + return Err(Error::bad_request("保留设置必须通过对应的类型化设置端点修改").into()); + } let keys: Vec = settings.iter().map(|(key, _)| key.clone()).collect(); let mut tx = state.db.begin().await.map_err(db_err)?; for (key, value) in &settings { diff --git a/backend/crates/prts-api/src/routes/admin_settings.rs b/backend/crates/prts-api/src/routes/admin_settings.rs index bd3c707..72868f6 100644 --- a/backend/crates/prts-api/src/routes/admin_settings.rs +++ b/backend/crates/prts-api/src/routes/admin_settings.rs @@ -9,9 +9,13 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use prts_core::permission::nodes; +use prts_db::audit::{AuditActor, AuditActorKind, AuditEvent}; use prts_db::search_settings::SearchConfig; +use prts_db::upload_settings::UploadConfig; use crate::auth::CurrentUser; +use crate::db_err; +use crate::dto::upload::UploadConfigDto; use crate::error::{ApiError, ErrorResponse}; use crate::state::AppState; @@ -139,6 +143,109 @@ pub async fn put_search_settings( })) } +/// 读取平台上传限制。 +#[utoipa::path( + get, + path = "/admin/settings/upload", + tag = "admin", + responses( + (status = 200, description = "当前上传限制", body = UploadConfigDto), + (status = 401, description = "未认证"), + (status = 403, description = "权限不足") + ) +)] +pub async fn get_upload_settings( + State(state): State, + user: CurrentUser, +) -> Result, ApiError> { + user.require_platform(nodes::PLATFORM_SETTINGS)?; + let config = prts_db::upload_settings::get(&state.db) + .await + .map_err(db_err)?; + Ok(Json(config.into())) +} + +/// 更新平台上传限制;限制和审计在同一事务提交。 +#[utoipa::path( + put, + path = "/admin/settings/upload", + tag = "admin", + request_body = UploadConfigDto, + responses( + (status = 200, description = "更新后的上传限制", body = UploadConfigDto), + (status = 400, description = "上传限制超出安全边界", body = ErrorResponse), + (status = 401, description = "未认证"), + (status = 403, description = "权限不足"), + (status = 503, description = "审计服务不可用,上传设置未更新", body = ErrorResponse) + ) +)] +pub async fn put_upload_settings( + State(state): State, + user: CurrentUser, + Json(body): Json, +) -> Result, ApiError> { + user.require_platform(nodes::PLATFORM_SETTINGS)?; + let mut tx = state.db.begin().await.map_err(db_err)?; + let current_user = prts_db::users::find_by_id_for_update_tx(&mut tx, user.id) + .await + .map_err(db_err)? + .ok_or(prts_common::Error::Unauthorized)?; + let current_role = current_user + .platform_role + .as_deref() + .and_then(prts_core::PlatformRole::parse); + if !current_role.is_some_and(|role| role.has(nodes::PLATFORM_SETTINGS)) { + return Err(prts_common::Error::Forbidden.into()); + } + let current = prts_db::upload_settings::get_for_update_tx(&mut tx) + .await + .map_err(db_err)?; + let config = UploadConfig { + max_files_per_batch: body.max_files_per_batch, + max_bytes_per_file: body.max_bytes_per_file, + max_bytes_per_batch: body.max_bytes_per_batch, + client_concurrency: body.client_concurrency, + upload_batch_expiry_hours: current.upload_batch_expiry_hours, + }; + prts_db::upload_settings::validate(&config).map_err(prts_common::Error::bad_request)?; + let change = prts_db::upload_settings::set_locked_tx(&mut tx, current, &config, Some(user.id)) + .await + .map_err(db_err)?; + let changed_fields = upload_changed_fields(&change.before, &change.after); + prts_db::audit::append_event_tx( + &mut tx, + AuditActor { + id: Some(user.id), + kind: AuditActorKind::User, + ip: None, + }, + AuditEvent::UploadSettingsUpdated { + changed_fields: &changed_fields, + }, + ) + .await + .map_err(|_| prts_common::Error::AuditUnavailable)?; + tx.commit().await.map_err(db_err)?; + Ok(Json(config.into())) +} + +fn upload_changed_fields(before: &UploadConfig, after: &UploadConfig) -> Vec<&'static str> { + let mut changed = Vec::with_capacity(4); + if before.max_files_per_batch != after.max_files_per_batch { + changed.push("max_files_per_batch"); + } + if before.max_bytes_per_file != after.max_bytes_per_file { + changed.push("max_bytes_per_file"); + } + if before.max_bytes_per_batch != after.max_bytes_per_batch { + changed.push("max_bytes_per_batch"); + } + if before.client_concurrency != after.client_concurrency { + changed.push("client_concurrency"); + } + changed +} + #[cfg(test)] mod tests { #[test] @@ -148,8 +255,8 @@ mod tests { .split_once("pub async fn put_search_settings(") .expect("PUT handler exists") .1 - .split_once("#[cfg(test)]") - .expect("PUT handler ends before tests") + .split_once("/// 读取平台上传限制。") + .expect("search PUT handler ends before upload handlers") .0; assert!(route.contains("search_settings_updater")); assert!(!route.contains("state.db.begin")); diff --git a/backend/crates/prts-api/src/routes/meta.rs b/backend/crates/prts-api/src/routes/meta.rs index 81686bb..7a4fa04 100644 --- a/backend/crates/prts-api/src/routes/meta.rs +++ b/backend/crates/prts-api/src/routes/meta.rs @@ -1,9 +1,15 @@ //! 服务元信息端点。 +use axum::extract::State; use axum::Json; use serde::Serialize; use utoipa::ToSchema; +use crate::db_err; +use crate::dto::upload::UploadConfigDto; +use crate::error::ApiError; +use crate::state::AppState; + /// 服务版本信息。 #[derive(Serialize, ToSchema)] pub struct VersionInfo { @@ -26,3 +32,19 @@ pub async fn version() -> Json { version: env!("CARGO_PKG_VERSION").to_string(), }) } + +/// 返回当前上传客户端限制,不包含清理保留期或内部存储路径。 +#[utoipa::path( + get, + path = "/meta/upload-config", + tag = "meta", + responses((status = 200, description = "上传客户端运行时限制", body = UploadConfigDto)) +)] +pub async fn upload_config( + State(state): State, +) -> Result, ApiError> { + let config = prts_db::upload_settings::get(&state.db) + .await + .map_err(db_err)?; + Ok(Json(config.into())) +} diff --git a/backend/crates/prts-api/src/routes/mod.rs b/backend/crates/prts-api/src/routes/mod.rs index 608f187..6a81355 100644 --- a/backend/crates/prts-api/src/routes/mod.rs +++ b/backend/crates/prts-api/src/routes/mod.rs @@ -60,6 +60,7 @@ fn api_router() -> OpenApiRouter { .routes(routes!(health::liveness)) .routes(routes!(health::readiness)) .routes(routes!(meta::version)) + .routes(routes!(meta::upload_config)) // 认证 .routes(routes!(auth::register)) .routes(routes!(auth::login)) @@ -80,6 +81,10 @@ fn api_router() -> OpenApiRouter { admin_settings::get_search_settings, admin_settings::put_search_settings )) + .routes(routes!( + admin_settings::get_upload_settings, + admin_settings::put_upload_settings + )) // 项目 .routes(routes!(projects::create_project, projects::list_projects)) .routes(routes!( @@ -274,6 +279,7 @@ mod tests { ("/admin/settings", "put"), ("/admin/users/{id}/role", "post"), ("/admin/settings/search", "put"), + ("/admin/settings/upload", "put"), ("/projects", "post"), ("/projects/{id}", "put"), ("/projects/{id}", "delete"), diff --git a/backend/crates/prts-api/tests/db_integration.rs b/backend/crates/prts-api/tests/db_integration.rs index b72362e..e3b2edf 100644 --- a/backend/crates/prts-api/tests/db_integration.rs +++ b/backend/crates/prts-api/tests/db_integration.rs @@ -43,6 +43,8 @@ mod files_routes; mod language_resolution_routes; #[path = "../src/routes/messages.rs"] mod messages_routes; +#[path = "../src/routes/meta.rs"] +mod meta_routes; #[path = "../src/routes/notifications.rs"] mod notifications_routes; #[path = "../src/routes/projects.rs"] @@ -75,6 +77,7 @@ use prts_db::{ static MIGRATED: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); static SEARCH_SETTINGS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +static UPLOAD_SETTINGS_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); fn runtime_role() -> String { std::env::var("PRTS_TEST_RUNTIME_ROLE").unwrap_or_else(|_| "prts_runtime".to_string()) @@ -273,6 +276,11 @@ const AUDITED_ENTRYPOINTS: &[AuditedEntrypoint] = &[ action: "search_settings.updated", allowed_payload_keys: &["changed_fields"], }, + AuditedEntrypoint { + entrypoint: "routes::admin_settings::put_upload_settings", + action: "upload_settings.updated", + allowed_payload_keys: &["changed_fields"], + }, AuditedEntrypoint { entrypoint: "routes::admin::grant_role", action: "user.platform_role_changed", @@ -498,6 +506,13 @@ const AUDIT_ACTION_CONTRACTS: &[AuditActionContract] = &[ project_snapshot_policy: ProjectSnapshotPolicy::None, expected_count: 1, }, + AuditActionContract { + action: "upload_settings.updated", + target_type: "settings", + target_id_policy: TargetIdPolicy::Constant("upload.config"), + project_snapshot_policy: ProjectSnapshotPolicy::None, + expected_count: 1, + }, AuditActionContract { action: "user.platform_role_changed", target_type: "user", @@ -664,6 +679,8 @@ const REPOSITORY_WRITERS: &[&str] = &[ "messages::create", "messages::mark_read", "jobs::manual_retry", + "upload_settings::set_tx", + "upload_settings::set_locked_tx", "language_resolution::complete_owner_resolution_tx", "language_resolution::update_entry_original_tx", "language_resolution::retry_failed_project_repair_tx", @@ -697,6 +714,8 @@ const UNAUDITED_READS: &[&str] = &[ "users::list_api_keys", "admin::get_settings", "admin_settings::get_search_settings", + "admin_settings::get_upload_settings", + "meta::upload_config", "projects::list_projects", "projects::get_project", "projects::list_members", @@ -719,7 +738,7 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { assert_eq!( REPOSITORY_WRITERS.len(), - 31, + 33, "repository writer inventory 发生漂移" ); assert_eq!( @@ -727,11 +746,11 @@ fn audit_contract_inventory_covers_every_existing_writer_with_typed_payloads() { 16, "auth/session writer inventory 发生漂移" ); - assert_eq!(UNAUDITED_READS.len(), 20, "普通读取 inventory 发生漂移"); - assert_eq!(AUDITED_ENTRYPOINTS.len(), 38, "审计入口 inventory 发生漂移"); + assert_eq!(UNAUDITED_READS.len(), 22, "普通读取 inventory 发生漂移"); + assert_eq!(AUDITED_ENTRYPOINTS.len(), 39, "审计入口 inventory 发生漂移"); assert_eq!( AUDIT_ACTION_CONTRACTS.len(), - 35, + 36, "action 合同 inventory 发生漂移" ); @@ -843,7 +862,7 @@ async fn audit_contract_project_lock_serializes_membership_upserts_for_missing_r let second_pool = state.db.clone(); let project_id = project.id; let member_id = member.id; - let mut second = tokio::spawn(async move { + let second = tokio::spawn(async move { let mut tx = second_pool.begin().await.unwrap(); projects::find_by_id_for_update_tx(&mut tx, project_id) .await @@ -990,8 +1009,6 @@ async fn audit_contract_concurrent_search_puts_keep_db_and_runtime_in_commit_ord /// 请求在 worker 已进入 DB 事务后取消,已入队更新仍必须完成 DB/audit/runtime 收敛。 #[tokio::test] async fn audit_contract_cancelled_search_put_still_converges_db_runtime_and_audit() { - use axum::extract::State; - use axum::Json; use std::time::Duration; let _search_settings_guard = SEARCH_SETTINGS_TEST_LOCK.lock().await; @@ -3698,6 +3715,98 @@ async fn audit_contract_users_admin_settings_and_api_keys_are_audited_and_redact *state.search_rt.write().await = previous_search; } +/// 上传设置管理、公开只读 DTO 与 fail-closed 审计共享同一持久化真值。 +#[tokio::test] +async fn upload_settings_routes_enforce_permissions_and_audit_rollback() { + use axum::extract::State; + use axum::Json; + + let _upload_settings_guard = UPLOAD_SETTINGS_TEST_LOCK.lock().await; + let state = audit_contract_state().await; + let admin = audit_contract_create_user(&state.db, "upload-settings-admin", Some("admin")).await; + let ordinary = audit_contract_create_user(&state.db, "upload-settings-user", None).await; + let previous = prts_db::upload_settings::get(&state.db).await.unwrap(); + + let denied = admin_settings_routes::get_upload_settings( + State(state.clone()), + audit_contract_current_user(&ordinary), + ) + .await + .expect_err_api("普通用户不得读取管理端上传设置"); + assert_eq!(denied.code(), "forbidden"); + + let Json(public_config) = meta_routes::upload_config(State(state.clone())) + .await + .expect_api("公开 meta 可读取上传客户端限制"); + assert_eq!( + public_config.max_files_per_batch, + previous.max_files_per_batch + ); + assert_eq!( + public_config.max_bytes_per_file, + previous.max_bytes_per_file + ); + assert_eq!( + public_config.max_bytes_per_batch, + previous.max_bytes_per_batch + ); + assert_eq!( + public_config.client_concurrency, + previous.client_concurrency + ); + + let updated = dto::upload::UploadConfigDto { + max_files_per_batch: 640, + max_bytes_per_file: 96 * 1024 * 1024, + max_bytes_per_batch: 3 * 1024 * 1024 * 1024, + client_concurrency: 5, + }; + let failing_state = audit_contract_state_with_db(audit_contract_failing_audit_db().await).await; + let audit_failure = admin_settings_routes::put_upload_settings( + State(failing_state), + audit_contract_current_user(&admin), + Json(updated.clone()), + ) + .await + .expect_err_api("上传设置审计失败必须回滚"); + audit_contract_assert_unavailable(audit_failure).await; + assert_eq!( + prts_db::upload_settings::get(&state.db).await.unwrap(), + previous + ); + + let Json(saved) = admin_settings_routes::put_upload_settings( + State(state.clone()), + audit_contract_current_user(&admin), + Json(updated), + ) + .await + .expect_api("管理员更新上传设置"); + assert_eq!(saved.client_concurrency, 5); + let rows = audit_contract_rows_for_actor(&state.db, admin.id).await; + audit_contract_assert_actions(&rows, &["upload_settings.updated"]); + audit_contract_assert_exact_targets( + &rows, + &[("upload_settings.updated", "upload.config".to_string())], + ); + let upload_audit = rows + .iter() + .find(|row| row.action == "upload_settings.updated") + .expect("上传设置审计存在"); + let payload = upload_audit + .payload + .as_object() + .expect("上传设置审计为对象"); + assert_eq!(payload.len(), 1); + assert!(payload.contains_key("changed_fields")); + + let mut tx = state.db.begin().await.unwrap(); + prts_db::upload_settings::set_tx(&mut tx, &previous, Some(admin.id)) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + /// 可选认证只能吞掉无凭证/无效凭证,不能把 API-key touch 的审计故障降级成游客。 #[tokio::test] async fn audit_contract_optional_auth_propagates_api_key_audit_unavailable() { @@ -7042,6 +7151,48 @@ async fn search_settings_set_and_get_normalizes_values() { search_settings::set(&pool, previous, None).await.unwrap(); } +/// 上传限制只接受安全边界,并由 settings 表持久化完整字节值。 +#[tokio::test] +async fn upload_settings_defaults_validation_and_repository_roundtrip() { + use prts_db::upload_settings::{self, UploadConfig}; + + let _upload_settings_guard = UPLOAD_SETTINGS_TEST_LOCK.lock().await; + let pool = pool().await; + let previous = upload_settings::get(&pool).await.unwrap(); + let config = UploadConfig { + max_files_per_batch: 750, + max_bytes_per_file: 128 * 1024 * 1024, + max_bytes_per_batch: 3 * 1024 * 1024 * 1024, + client_concurrency: 4, + upload_batch_expiry_hours: 24, + }; + let mut tx = pool.begin().await.unwrap(); + let change = upload_settings::set_tx(&mut tx, &config, None) + .await + .unwrap(); + assert_eq!(change.before, previous); + assert_eq!(change.after, config); + tx.commit().await.unwrap(); + assert_eq!(upload_settings::get(&pool).await.unwrap(), config); + + let mut tx = pool.begin().await.unwrap(); + let invalid = UploadConfig { + client_concurrency: 0, + ..config.clone() + }; + assert!(upload_settings::set_tx(&mut tx, &invalid, None) + .await + .is_err()); + tx.rollback().await.unwrap(); + assert_eq!(upload_settings::get(&pool).await.unwrap(), config); + + let mut tx = pool.begin().await.unwrap(); + upload_settings::set_tx(&mut tx, &previous, None) + .await + .unwrap(); + tx.commit().await.unwrap(); +} + /// 验证 prts-db::notifications 仓储圆环(迁移 0005): /// - 为收件人 `create` 两条通知 → `unread_count` == 2; /// - `mark_read` 其中一条 → `unread_count` == 1; diff --git a/backend/crates/prts-common/src/config.rs b/backend/crates/prts-common/src/config.rs index 39f49db..ea884ec 100644 --- a/backend/crates/prts-common/src/config.rs +++ b/backend/crates/prts-common/src/config.rs @@ -19,6 +19,34 @@ pub struct Settings { pub auth: AuthSettings, #[serde(default)] pub embedding: EmbeddingSettings, + #[serde(default)] + pub media: MediaSettings, +} + +/// 用户上传媒体与临时上传流的持久化路径;不包含业务限制或密钥。 +#[derive(Debug, Clone, Deserialize)] +pub struct MediaSettings { + #[serde(default = "default_media_dir")] + pub directory: String, + #[serde(default = "default_upload_temp_dir")] + pub upload_temp_directory: String, +} + +fn default_media_dir() -> String { + "./data/media".to_string() +} + +fn default_upload_temp_dir() -> String { + "./data/upload-temp".to_string() +} + +impl Default for MediaSettings { + fn default() -> Self { + Self { + directory: default_media_dir(), + upload_temp_directory: default_upload_temp_dir(), + } + } } /// 认证相关配置。密钥与 OAuth 凭证经环境变量注入(`PRTS__AUTH__*`)。 diff --git a/backend/crates/prts-db/src/audit.rs b/backend/crates/prts-db/src/audit.rs index 686bdbe..3a2fab3 100644 --- a/backend/crates/prts-db/src/audit.rs +++ b/backend/crates/prts-db/src/audit.rs @@ -179,6 +179,9 @@ pub enum AuditEvent<'a> { SearchSettingsUpdated { changed_fields: &'a [&'a str], }, + UploadSettingsUpdated { + changed_fields: &'a [&'a str], + }, UserPlatformRoleChanged { user_id: i64, previous_role: Option<&'a str>, @@ -465,6 +468,13 @@ pub async fn append_event_tx( None, serde_json::json!({"changed_fields": changed_fields}), ), + AuditEvent::UploadSettingsUpdated { changed_fields } => ( + "upload_settings.updated", + "settings", + "upload.config".to_string(), + None, + serde_json::json!({"changed_fields": changed_fields}), + ), AuditEvent::UserPlatformRoleChanged { user_id, previous_role, diff --git a/backend/crates/prts-db/src/lib.rs b/backend/crates/prts-db/src/lib.rs index a504f6e..8f3d639 100644 --- a/backend/crates/prts-db/src/lib.rs +++ b/backend/crates/prts-db/src/lib.rs @@ -19,6 +19,7 @@ pub mod search; pub mod search_settings; pub mod settings; pub mod stats; +pub mod upload_settings; pub mod users; /// 数据库错误别名,便于上层映射而无需直接依赖 sqlx。 diff --git a/backend/crates/prts-db/src/upload_settings.rs b/backend/crates/prts-db/src/upload_settings.rs new file mode 100644 index 0000000..dae5114 --- /dev/null +++ b/backend/crates/prts-db/src/upload_settings.rs @@ -0,0 +1,166 @@ +//! 上传运行时限制;业务默认值、校验和 settings 持久化的唯一来源。 + +use serde::{Deserialize, Serialize}; +use sqlx::{PgConnection, PgPool}; + +const KEY: &str = "upload.config"; +const UPLOAD_CONFIG_LOCK_KEY: i64 = 0x5052_5453_5550_4C44; +const MEBIBYTE: i64 = 1024 * 1024; +const GIBIBYTE: i64 = 1024 * MEBIBYTE; + +/// 客户端可见的上传批次限制。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct UploadConfig { + pub max_files_per_batch: i32, + pub max_bytes_per_file: i64, + pub max_bytes_per_batch: i64, + pub client_concurrency: i32, + #[serde(default = "default_upload_batch_expiry_hours")] + pub upload_batch_expiry_hours: i32, +} + +fn default_upload_batch_expiry_hours() -> i32 { + 24 +} + +impl Default for UploadConfig { + fn default() -> Self { + Self { + max_files_per_batch: 500, + max_bytes_per_file: 100 * MEBIBYTE, + max_bytes_per_batch: 2 * GIBIBYTE, + client_concurrency: 3, + upload_batch_expiry_hours: default_upload_batch_expiry_hours(), + } + } +} + +#[derive(Debug, Clone)] +pub struct UploadConfigChange { + pub before: UploadConfig, + pub after: UploadConfig, +} + +/// 拒绝危险或自相矛盾的配置,不静默修改管理员输入。 +pub fn validate(config: &UploadConfig) -> Result<(), &'static str> { + if !(1..=5_000).contains(&config.max_files_per_batch) { + return Err("max_files_per_batch must be between 1 and 5000"); + } + if !(MEBIBYTE..=GIBIBYTE).contains(&config.max_bytes_per_file) { + return Err("max_bytes_per_file must be between 1 MiB and 1 GiB"); + } + if config.max_bytes_per_batch < config.max_bytes_per_file + || config.max_bytes_per_batch > 20 * GIBIBYTE + { + return Err("max_bytes_per_batch must include one file and not exceed 20 GiB"); + } + if !(1..=10).contains(&config.client_concurrency) { + return Err("client_concurrency must be between 1 and 10"); + } + if !(1..=168).contains(&config.upload_batch_expiry_hours) { + return Err("upload_batch_expiry_hours must be between 1 and 168"); + } + Ok(()) +} + +/// 读取当前限制;缺失或旧值不可解析时回退到安全默认。 +pub async fn get(pool: &PgPool) -> Result { + match crate::settings::get(pool, KEY).await? { + Some(value) => Ok(serde_json::from_value(value).unwrap_or_default()), + None => Ok(UploadConfig::default()), + } +} + +/// 串行化配置 mutation 并返回事务内当前快照。 +pub async fn get_for_update_tx(conn: &mut PgConnection) -> Result { + sqlx::query("SELECT pg_advisory_xact_lock($1)") + .bind(UPLOAD_CONFIG_LOCK_KEY) + .execute(&mut *conn) + .await?; + match sqlx::query_scalar::<_, serde_json::Value>( + "SELECT value FROM settings WHERE key = $1 FOR UPDATE", + ) + .bind(KEY) + .fetch_optional(conn) + .await? + { + Some(value) => Ok(serde_json::from_value(value).unwrap_or_default()), + None => Ok(UploadConfig::default()), + } +} + +/// 在调用方事务内校验并持久化限制。 +pub async fn set_tx( + conn: &mut PgConnection, + config: &UploadConfig, + updated_by: Option, +) -> Result { + let before = get_for_update_tx(&mut *conn).await?; + set_locked_tx(&mut *conn, before, config, updated_by).await +} + +/// 在已调用 [`get_for_update_tx`] 的事务内写入,避免重复取得快照。 +pub async fn set_locked_tx( + conn: &mut PgConnection, + before: UploadConfig, + config: &UploadConfig, + updated_by: Option, +) -> Result { + validate(config).map_err(|message| sqlx::Error::Protocol(message.to_string()))?; + let value = + serde_json::to_value(config).map_err(|error| sqlx::Error::Protocol(error.to_string()))?; + crate::settings::set_tx(conn, KEY, &value, updated_by).await?; + Ok(UploadConfigChange { + before, + after: config.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_upload_contract() { + assert_eq!( + UploadConfig::default(), + UploadConfig { + max_files_per_batch: 500, + max_bytes_per_file: 100 * MEBIBYTE, + max_bytes_per_batch: 2 * GIBIBYTE, + client_concurrency: 3, + upload_batch_expiry_hours: 24, + } + ); + } + + #[test] + fn validation_rejects_each_unsafe_boundary() { + let valid = UploadConfig::default(); + for invalid in [ + UploadConfig { + max_files_per_batch: 0, + ..valid.clone() + }, + UploadConfig { + max_bytes_per_file: MEBIBYTE - 1, + ..valid.clone() + }, + UploadConfig { + max_bytes_per_batch: valid.max_bytes_per_file - 1, + ..valid.clone() + }, + UploadConfig { + client_concurrency: 11, + ..valid.clone() + }, + UploadConfig { + upload_batch_expiry_hours: 0, + ..valid.clone() + }, + ] { + assert!(validate(&invalid).is_err()); + } + assert!(validate(&valid).is_ok()); + } +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 24d261e..50c1fba 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -77,7 +77,12 @@ services: PRTS__DATABASE__MIGRATION_URL: '' PRTS__DATABASE__RUNTIME_ROLE: ${POSTGRES_RUNTIME_USER:-prts_runtime} PRTS__REDIS__URL: redis://redis:6379 + PRTS__MEDIA__DIRECTORY: /app/data/media + PRTS__MEDIA__UPLOAD_TEMP_DIRECTORY: /app/data/upload-temp RUST_LOG: ${RUST_LOG:-info,sqlx=warn} + volumes: + - media:/app/data/media + - uploadtemp:/app/data/upload-temp ports: # 宿主机端口可经 .env 的 BACKEND_PORT 调整(默认 3000 被占用时改这里) - '${BACKEND_PORT:-3000}:3000' # 暴露以便访问 Swagger UI(/swagger-ui);生产可仅内网 @@ -103,3 +108,5 @@ services: volumes: pgdata: redisdata: + media: + uploadtemp: diff --git a/frontend/package.json b/frontend/package.json index 317fb55..50dfc54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,9 +17,10 @@ "dependencies": { "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/noto-sans-sc": "^5.2.9", - "@fontsource/noto-serif-sc": "^5.2.8", "@quasar/extras": "^1.16.0", "axios": "^1.7.0", + "dompurify": "^3.4.12", + "markdown-it": "^14.3.0", "pinia": "^2.2.0", "quasar": "^2.17.0", "vue": "^3.5.0", @@ -29,11 +30,13 @@ "devDependencies": { "@eslint/js": "^9.14.0", "@quasar/vite-plugin": "^1.8.0", + "@types/markdown-it": "^14.1.2", "@types/node": "20", "@vitejs/plugin-vue": "^5.1.0", "eslint": "^9.14.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-vue": "^9.30.0", + "jsdom": "^29.1.1", "prettier": "^3.3.0", "sass-embedded": "^1.80.0", "typescript": "^5.6.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 75f3b1e..ad4054b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -14,15 +14,18 @@ importers: '@fontsource/noto-sans-sc': specifier: ^5.2.9 version: 5.2.9 - '@fontsource/noto-serif-sc': - specifier: ^5.2.8 - version: 5.2.8 '@quasar/extras': specifier: ^1.16.0 version: 1.18.0 axios: specifier: ^1.7.0 version: 1.18.1 + dompurify: + specifier: ^3.4.12 + version: 3.4.12 + markdown-it: + specifier: ^14.3.0 + version: 14.3.0 pinia: specifier: ^2.2.0 version: 2.3.1(typescript@5.9.3)(vue@3.5.39(typescript@5.9.3)) @@ -45,6 +48,9 @@ importers: '@quasar/vite-plugin': specifier: ^1.8.0 version: 1.12.0(@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)))(quasar@2.20.1)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0))(vue@3.5.39(typescript@5.9.3)) + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 '@types/node': specifier: '20' version: 20.19.43 @@ -60,6 +66,9 @@ importers: eslint-plugin-vue: specifier: ^9.30.0 version: 9.33.0(eslint@9.39.4) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 prettier: specifier: ^3.3.0 version: 3.9.1 @@ -77,13 +86,28 @@ importers: version: 6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@20.19.43)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)) + version: 4.1.9(@types/node@20.19.43)(happy-dom@20.10.6)(jsdom@29.1.1)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)) vue-tsc: specifier: ^2.1.0 version: 2.2.12(typescript@5.9.3) packages: + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -101,9 +125,49 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@bufbuild/protobuf@2.12.1': resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -298,15 +362,21 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fontsource/jetbrains-mono@5.2.8': resolution: {integrity: sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==} '@fontsource/noto-sans-sc@5.2.9': resolution: {integrity: sha512-bTUIWGBgJDpwi5qAr+x0/lcgv80IHTB9vl6s2f6EymZEa7qYV99yNRBZuKFT+SYDKVunZrjCEhWtpxqmbXWl5Q==} - '@fontsource/noto-serif-sc@5.2.8': - resolution: {integrity: sha512-C7fAr+d1GjOAw1qIbntsnqbA3l5dkdzcmNNgeCXLC8QZ7VNubL7MTrX8UcYKHceX4mI//z8gGtXbOeeQrB6P7g==} - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -595,9 +665,27 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.62.0': resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -789,6 +877,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -802,6 +893,10 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + buffer-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -846,6 +941,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -854,6 +953,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + de-indent@1.0.2: resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} @@ -866,6 +969,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -877,14 +983,25 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1067,6 +1184,10 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1087,6 +1208,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -1118,6 +1243,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1125,6 +1253,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1141,6 +1278,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1151,13 +1291,27 @@ packages: lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1217,6 +1371,9 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1268,6 +1425,10 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1280,6 +1441,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1414,6 +1579,10 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1452,6 +1621,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + sync-child-process@1.0.2: resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} engines: {node: '>=16.0.0'} @@ -1475,6 +1647,21 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.8: + resolution: {integrity: sha512-c1P7u0EhACHj7lPy4MJm8iTFEU8+nB0LCtddH0fhP7noaVoXAqafMtOOeX+ulpuPBqnrRgRhw494RICT3mbhnw==} + + tldts@7.4.8: + resolution: {integrity: sha512-htwgN/8KRB3z3vnC0BOETVh2m499g5GmyTK9Wq5JBLX3FNz6tSBveAd+fQhzy9hkjif8vy2jwDMR1sGhLtZl2A==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1504,9 +1691,16 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1643,6 +1837,26 @@ packages: typescript: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1657,16 +1871,55 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} snapshots: + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1680,8 +1933,36 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@bufbuild/protobuf@2.12.1': {} + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1806,12 +2087,12 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@fontsource/jetbrains-mono@5.2.8': {} '@fontsource/noto-sans-sc@5.2.9': {} - '@fontsource/noto-serif-sc@5.2.8': {} - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2000,10 +2281,30 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdurl@2.0.0': {} + '@types/node@20.19.43': dependencies: undici-types: 6.21.0 + '@types/trusted-types@2.0.7': + optional: true + + '@types/whatwg-mimetype@3.0.2': + optional: true + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.43 + optional: true + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2272,6 +2573,10 @@ snapshots: balanced-match@4.0.4: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + boolbase@1.0.0: {} brace-expansion@1.1.15: @@ -2287,6 +2592,11 @@ snapshots: dependencies: balanced-match: 4.0.4 + buffer-image-size@0.6.4: + dependencies: + '@types/node': 20.19.43 + optional: true + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -2328,16 +2638,30 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + de-indent@1.0.2: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} delayed-stream@1.0.0: {} @@ -2345,14 +2669,22 @@ snapshots: detect-libc@2.1.2: optional: true + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 es-errors: 1.3.0 gopd: 1.2.0 + entities@4.5.0: {} + entities@7.0.1: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2577,6 +2909,20 @@ snapshots: gopd@1.2.0: {} + happy-dom@20.10.6: + dependencies: + '@types/node': 20.19.43 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + optional: true + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2591,6 +2937,12 @@ snapshots: he@1.2.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -2617,12 +2969,40 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + isexe@2.0.0: {} js-yaml@4.3.0: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -2638,6 +3018,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -2646,12 +3030,27 @@ snapshots: lodash@4.18.1: {} + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + mime-db@1.52.0: {} mime-types@2.1.35: @@ -2708,6 +3107,10 @@ snapshots: dependencies: callsites: 3.1.0 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -2747,6 +3150,8 @@ snapshots: proxy-from-env@2.1.0: {} + punycode.js@2.3.1: {} + punycode@2.3.1: {} quasar@2.20.1: {} @@ -2754,6 +3159,8 @@ snapshots: readdirp@5.0.0: optional: true + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} rollup@4.62.2: @@ -2887,6 +3294,10 @@ snapshots: '@parcel/watcher': 2.5.6 optional: true + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} shebang-command@2.0.0: @@ -2913,6 +3324,8 @@ snapshots: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + sync-child-process@1.0.2: dependencies: sync-message-port: 1.2.0 @@ -2930,6 +3343,20 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.8: {} + + tldts@7.4.8: + dependencies: + tldts-core: 7.4.8 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.8 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -2955,8 +3382,12 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + undici-types@6.21.0: {} + undici@7.28.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2979,7 +3410,7 @@ snapshots: sass: 1.100.0 sass-embedded: 1.100.0 - vitest@4.1.9(@types/node@20.19.43)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)): + vitest@4.1.9(@types/node@20.19.43)(happy-dom@20.10.6)(jsdom@29.1.1)(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)): dependencies: '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@6.4.3(@types/node@20.19.43)(sass-embedded@1.100.0)(sass@1.100.0)) @@ -3003,6 +3434,8 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.43 + happy-dom: 20.10.6 + jsdom: 29.1.1 transitivePeerDependencies: - msw @@ -3053,6 +3486,25 @@ snapshots: optionalDependencies: typescript: 5.9.3 + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@3.0.0: + optional: true + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 @@ -3064,6 +3516,13 @@ snapshots: word-wrap@1.2.5: {} + ws@8.21.0: + optional: true + xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yocto-queue@0.1.0: {} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 30734b4..bf1fa48 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -86,7 +86,7 @@ async function logout() { round dense class="q-mr-xs" - icon="mail" + icon="mdi-email-outline" :to="{ name: 'messages' }" > @@ -100,7 +100,7 @@ async function logout() { round dense class="q-mr-xs" - :icon="$q.dark.isActive ? 'dark_mode' : 'light_mode'" + :icon="$q.dark.isActive ? 'mdi-weather-night' : 'mdi-white-balance-sunny'" @click="toggleTheme" > 切换{{ $q.dark.isActive ? '浅色' : '深色' }}主题 @@ -117,23 +117,23 @@ async function logout() { - + 个人主页 - + 私信 {{ messagesUnread }} - + 管理后台 - + 登出 diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index ea85fdc..58bd9f1 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -1,6 +1,7 @@ import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios' import { clearTokens, getAccessToken, getRefreshToken, setTokens } from './session' +import { i18n } from '@/i18n' /** * 全局 axios 实例。baseURL `/api`,开发由 Vite 代理、生产由 nginx 反代(均剥离 /api 前缀)。 @@ -9,6 +10,7 @@ import { clearTokens, getAccessToken, getRefreshToken, setTokens } from './sessi export const http = axios.create({ baseURL: '/api', timeout: 15_000 }) http.interceptors.request.use((config) => { + config.headers['Accept-Language'] = i18n.global.locale.value const token = getAccessToken() if (token) { config.headers.Authorization = `Bearer ${token}` diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 36d3554..75a54d5 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -6,6 +6,7 @@ import type { EntryVersionDto, ExternalAccountDto, MemberDto, + JobDto, MessageDto, NotificationDto, ThreadDto, @@ -40,6 +41,13 @@ export const authApi = { }, } +/** 持久化后台任务。 */ +export const jobsApi = { + get(id: number) { + return http.get(`/jobs/${id}`).then((response) => response.data) + }, +} + /** 用户自助。 */ export const usersApi = { me() { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 488b378..ad0fccc 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -27,16 +27,38 @@ export interface ProjectDto { description: string visibility: string source_langs: string[] + primary_source_lang: string | null target_lang: string + language_repair_state: string + primary_source_changed_at: string | null + lexical_state: string + embedding_state: string + avatar_url: string | null owner_id: number created_at: string updated_at: string } +export interface ProjectCapabilities { + view_project: boolean + manage_project: boolean + manage_members: boolean + upload_files: boolean + download: boolean + edit_entry: boolean + review_entry: boolean + edit_locked_entry: boolean + force_save_presence: boolean + resolve_languages: boolean + change_primary_source: boolean + delete_project: boolean +} + export interface ProjectDetailDto { project: ProjectDto state_counts: Record entry_count: number + capabilities: ProjectCapabilities } export interface MemberDto { @@ -98,6 +120,33 @@ export interface UploadResult { unchanged: number } +/** 服务端下发的上传运行时限制;上传客户端不得复制这些默认值。 */ +export interface UploadConfigDto { + max_files_per_batch: number + max_bytes_per_file: number + max_bytes_per_batch: number + client_concurrency: number +} + +export interface JobDto { + id: number + kind: string + project_id: number | null + state: string + stage: string + progress_current: number + progress_total: number | null + attempts: number + max_attempts: number + next_retry_at: string | null + last_error_code: string | null + manual_retry_allowed: boolean + created_at: string + started_at: string | null + finished_at: string | null + updated_at: string +} + export interface ApiKeyDto { id: number name: string diff --git a/frontend/src/api/uploads.ts b/frontend/src/api/uploads.ts new file mode 100644 index 0000000..dc9ee10 --- /dev/null +++ b/frontend/src/api/uploads.ts @@ -0,0 +1,7 @@ +import { http } from './http' +import type { UploadConfigDto } from './types' + +/** 上传客户端启动前读取服务端当前限制。 */ +export function getUploadConfig(): Promise { + return http.get('/meta/upload-config').then((response) => response.data) +} diff --git a/frontend/src/components/MarkdownEditor.vue b/frontend/src/components/MarkdownEditor.vue new file mode 100644 index 0000000..97b2c82 --- /dev/null +++ b/frontend/src/components/MarkdownEditor.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/frontend/src/components/MarkdownView.vue b/frontend/src/components/MarkdownView.vue new file mode 100644 index 0000000..621e8ea --- /dev/null +++ b/frontend/src/components/MarkdownView.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/components/NotificationBell.vue b/frontend/src/components/NotificationBell.vue index 45724ec..03e57bd 100644 --- a/frontend/src/components/NotificationBell.vue +++ b/frontend/src/components/NotificationBell.vue @@ -37,7 +37,7 @@ function summary(n: NotificationDto): string {