docs: p4a acceptance record

P4a Task 10 收口:回归门全绿(web lint/typecheck/build 9/9 包任务,
单测 271 + e2e 318 = 589;mobile analyze 0 issue + 280/280 零漂移)。
验收记录含 A16/A17/A18/A20/A23/A29 证据、D06 工作默认登记、契约
增补汇总(36→51 operations + DOWNLOAD_DISABLED)、偏差 14 项、
G4 待真实接口清单(UserSourcePort 替换点 + D08 字段确认)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 21:21:08 +08:00
parent f08d68bebf
commit 17d09bbb01
3 changed files with 285 additions and 38 deletions
+243
View File
@@ -0,0 +1,243 @@
# P4a 验收记录
- **阶段**:P4a 后台完备 + 账号治理(计划 `docs/plans/2026-09-20-P4a-后台完备与账号治理.md`,Task 1–10)
- **日期**:2026-09-20(同日完成契约→后端→worker→viewer→admin 全链)
- **基线 commits**:`45d4651..HEAD`(tag `p3` → `e484aa5`,其后 P3 计划回填 `45d4651`;共 11 个提交 = 1 计划评审 + 9 实施 + 1 既有测试笔误修复,不含本记录提交)
- **执行模式**:子代理驱动(每任务先写失败测试→实现→通过→提交;契约批次先行 gen + redocly 门);Task 10 回归门与本记录由主代理执行
- **回归门**:web 侧 `pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm e2e` 全绿(lint 9/9、typecheck 9/9、build 9/9 包任务;数字见文末统计表);mobile 侧 `flutter analyze` **No issues found!** + `flutter test` **280/280**(P4a 未触碰 mobile——Task 1 契约增补对 App 手工模型为超集兼容,验证零漂移)
- **基础设施**:`infra/docker-compose.yml --profile ov`(mysql/redis/minio 双地域)healthy;e2e 均为进程内真实 Nest 应用 + 真实 MinIO/Redis/MySQL + 真实 BullMQ worker + 真实 outbox→ov 传播
---
## 任务清单与提交
| Task | 内容 | Commit |
|------|------|--------|
| 1 | 契约扩展 P4a 批次(36 → 51 operations;gen/redocly/枚举同步守护绿) | `8a8ad8a` |
| 2 | 批量隐藏(逐项结果)+ 负责人交接(后端) | `a125fca`(+`8e3e19a` 既有 e2e 笔误修复) |
| 3 | 水印版本化 + 历史重生成(后端 + worker,原子切换) | `243b2f8` |
| 4 | 下载端点(ov 域)+ viewer 下载 UI 与微信引导 | `2bb6f29` |
| 5 | 用量统计与阈值告警(后端 + A23 后台侧) | `4937eee` |
| 6 | 账号管理端点(列表/PATCH/重置/激活/停用) | `f4a1ba7` |
| 7 | 用户定期同步(mock 源,T30 语义全量) | `3681999` |
| 8 | admin 账号治理页 + 用户同步管理页 | `618d1d7` |
| 9 | admin 相册治理增量(批量/交接/水印/用量页/分享二维码) | `f08d68b` |
| 10 | 回归门 + 本记录 | 本提交 |
---
## 验收证据(按 A 编号)
### A16 批量管理逐项结果
api e2e `services/api/test/batch-ops.e2e-spec.ts`「批量隐藏 hidePhotosBatch」4 例:
- ① owner 批量 `[p1,p2,p3,ghost]` → 200 逐项结果(**同序**回传);3 张 DB hidden + 版本 +1;outbox photo.hidden 高优先级 ×3;audit photo.hide ×3;幽灵项 ok:false PHOTO_NOT_PUBLIC
- ② **幂等 no-op**:`[已隐藏, 已删除]` → 已隐藏 ok:true 且无新事件/审计/版本;已删除 ok:false PHOTO_NOT_PUBLIC
- ③ 权限与入参:协作者 403;外人员工 404;幽灵相册 404;空数组/超 200/空串成员 → 400
- ④ **ov 传播**:隐藏事件排空后 photos_ov=hidden,ov 公开列表剔除该照片
admin 侧:Playwright `apps/admin/e2e/albums-governance.spec.ts`「批量隐藏:多选 2 → 汇总 toast + 行原位已隐藏 + 公开列表仅剩 1」;vitest `src/utils/albums/batchHide.spec.ts` 4 例(成功 x/失败 y 汇总与失败清单归并)。
### A17 负责人交接
api e2e `services/api/test/batch-ops.e2e-spec.ts`「负责人交接 transferAlbum」4 例:
- ⑤ admin 转移 A → staffB(**keep=true**):owner 变更、角色互换(A 降为 collaborator)、**历史上传人不变**(photos.uploaderId 不动)、相册版本 +outbox、audit album.transfer
- ⑥ 权限与目标校验:非 admin 403;幽灵/停用/待激活目标 400;自交接 400;幽灵相册 404
- ⑦ **交接后新 owner 可管**:新 owner 管理 200;oldOwner(保留协作者)管理 403、上传 201
- ⑧ **keep=false**:oldOwner 成员行删除;非成员目标 upsert 为 owner
admin 侧:Playwright「负责人交接:搜索 staff → 交接 → ownerId 变更(API + 详情回显)」(`AlbumTransferDialog.vue`:目标账号搜索 + 保留协作者开关)。
### A18 下载两级控制(T12 收口)
**ov 域端点**(进程内 cn + ov 双真实应用 + 真实 outbox 传播,`services/api/test/downloads.e2e-spec.ts` 7 例):
- ① on/on:双 variant 200、variant/expiresAt 正确、URL 真实字节(**original == 上传原图**)
- ② on/off:browse 200;original **403 DOWNLOAD_DISABLED**
- ③ off/off 与 off/on(级联:original 开启隐含 browse):双 variant 均 403 DOWNLOAD_DISABLED
- ④ **水印相册**:browse 下载 = 模式键 watermark.jpg,字节 == 水印变体对象
- ⑤ 可见性与参数:隐藏 404 / 幽灵 token 404 / 幽灵照片 404 / variant 非法 400
- ⑥ 基础限速:同 IP 窗口超额 → 429 RATE_LIMITED
- ⑦ 口径:下载不计数——visit_requests / album_stats 全程零行
**viewer 侧按钮矩阵与微信引导**:
- vitest `apps/viewer/src/__tests__/download.spec.ts` 9 例:微信 UA 检测(iOS/Android/Windows 三系 + 放行其他)、expiresAt 过期判定、**控制器开关矩阵**(off/on 级联:original implies browse)、**工具栏三态**(两级关 → 无入口;on/off → 单按钮;on/on → split 下拉)、split 选 original 走 original 端点、主按钮下载 browse、**过期凭证恰一次重取**、新鲜凭证直达不发第二次请求、失败可重试提示、微信 webview 点击开引导浮层(不打端点)、复制链接写入当前照片深链
- Playwright `apps/viewer/e2e/download.spec.ts` 3 例:`download entry visibility follows album two-tier switches` / `WeChat webview UA shows guidance overlay instead of downloading` / `real click downloads browse via signed url from the endpoint`
### A20 水印版本化与历史重生成
**api e2e** `services/api/test/watermark.e2e-spec.ts` 9 例:
- ① POST(enabled)→ 202 {jobId, configVersion};GET 状态形状(缺省填齐/pending)
- ② 新照片发布即套用最新版:POST 后发布 → watermark 变体对象已产且 ≠ browse
- ③ 历史重生成 → done;cn 公开列表服务水印字节
- ④ **A20 核心:原图零触碰**(objectKey 不变,仅变体键切换)
- ⑤ ov 模式键同步(公开侧变体选择以有效版本为准)
- ⑥ **失败注入**:删 1 张原图 → 新版本 done 且 failed=1;失败张两侧保留旧图;恢复后 re-POST 收敛
- ⑦ **关闭版重生成**:两侧回落 browse 键;水印变体行删除、**对象保留**
- ⑧ 空相册:POST 即建即 done,有效位当场推进并同步 ov
- ⑨ 权限:协作者 403 / 圈外人 404 / admin 可管;未认证 401
- ⑩ 校验:enabled=true 缺 text.content / 越界值 / 非法 position → 400;GET jobs 全史倒序且字段完整
**worker e2e** `services/worker/test/watermark.e2e-spec.ts`(新 BullMQ QUEUE_WATERMARK 消费者,4 + 2 例):
- 重生成(enabled):变体/进度/有效位推进/outbox,**A20 原图与 browse 零触碰**
- 失败注入:单张原图缺失 → failed=1 仍 done,失败张无行 + **旧图拷贝到目标键**(保证列表一致性)
- 关闭版重生成:水印行删除(对象保留)、有效位 enabled=false、outbox 同步
- 任务级中止:配置行缺失 → failed + WATERMARK_CONFIG_MISSING,零处理零推进
- **真实 BullMQ Worker**:真实入队 → 消费 → done(有效位推进);行不存在的幽灵投递 → skipped,不产生任何行/对象痕迹
**单元**:worker `src/watermark.spec.ts` 6 例(SVG 文字层渲染/trim/缩放/composite 参数);contracts `src/__tests__/watermark.test.ts` 7 例(**版本语义权威表述**的契约级固化)。
**双版本语义**(`packages/contracts/src/watermark.ts` 为权威,实现注释摘要):
- `albums.watermarkVersion` = 最新「已创建」配置版本——POST 即推进,新照片发布即套用
- `albums.watermarkEnabled + watermarkEffectiveVersion` = 「有效」版本——仅当该版本任务 done 才由 worker 完成事务推进(0 张照片的退化任务在创建事务内即建即 done、当场推进);ov 副本与公开侧变体选择以有效版本为准
### A23 用量统计与阈值告警(后台侧)
api e2e `services/api/test/usage.e2e-spec.ts` 6 例:
- ① 汇总形状与数学:相册聚合/totals/降序/标题/缺省阈值/空告警
- ② 访问控制:staff 403;未认证 401
- ③ 入参校验:负值/小数/字符串/超 2^53 → 400;空体 = 全保持
- ④ perAlbum 告警:超阈产生(含字节数/标题)→ 去重 → 上调解除(resolvedAt)
- ⑤ storage 告警:totals 超阈产生(albumId=null)→ null 清除维度解除
- ⑥ 口径与检查点:hidden 计入(PATCH 后不变);deleted 不计(软删后回落)
admin 侧:Playwright「用量:阈值调小触发告警 → 上调解除;合计与相册表可见」;vitest `src/utils/albums/usageStorage.spec.ts` 5 例(字节格式化/GB 换算)。告警列表未解决/已解决区分(row-class 锚点)。「组件不可见暂停」P2 viewer 已落地,本阶段仅补后台证据链。
### A29 用户定期同步(mock 级,T30 语义全量)
api e2e `services/api/test/user-sync.e2e-spec.ts` **12 场景全列**(mock 源场景经 `app.get(MockUserSource)` 编程注入):
- ① 首轮全量:1 account 关联 + 2 新增待激活;counts/水位/映射/**红线(不动凭据角色会话)**
- ② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过
- ③ 上游停用与恢复:disabled → sourceStatus + 撤全部会话 + 登录 401;恢复 → 原密码可登录
- ④ **红线:上游恢复不解除 localLocked**(本地锁定独立维度,技术方案 3.1A)
- ⑤ 增量新增第 4 用户:mock 收到已提交水位游标,仅新行入账
- ⑥ **空响应:items=[] + nextCursor=null → 正常结束不误伤,水位不进**
- ⑦ 分页中断:page2 抛错 → failed、水位停 page1、未拉到的行不出现;修复后增量补齐
- ⑧ 部分无效记录:1 坏行 → partial + errors 明细,其余照常;lastSuccessAt 不推进
- ⑨ **手动补拉与定时互斥**:运行中批次复用 runId;cron tick 撞锁跳过
- ⑩ **增量 + 周期全量对账**(每 10 批强制 cursor=null):低版本行新变更增量不可见,对账批应用;**缺席用户绝不停用**
- ⑪ §10 闭环:pending 登录 401(哨兵哈希不 500)→ activate → 仍 401 → reset-password → 可登录
- ⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 权限矩阵
调度:api 进程内 `SYNC_USERS_INTERVAL_MS`(缺省 5min)+ Redis SETNX 互斥;补拉 API 复用进行中批次。admin 侧:Playwright `apps/admin/e2e/users.spec.ts`「同步页:手动补拉 → 终态计数刷新 → 运行历史行点击打开失败明细抽屉」;vitest `syncPolling.spec.ts` 6 例(轮询启停/运行中禁用)+ `useSyncErrors.spec.ts` 3 例。
### 账号治理(Task 6,A29 闭环支撑)
api e2e `services/api/test/admin-users.e2e-spec.ts` 13 例(摘要):管理列表全量 + query/status 过滤 + 游标;双模式访问策略(staff 无前缀 403 / 带前缀 200);PATCH role/displayName/localLocked(sourceStatus 只读 400、自我保护 400、幂等 no-op 不重复审计);reset-password 指定与生成两态(目标会话撤销、执行方不受影响、audit 无密码明文);activate(pending→active,§10 闭环)与 deactivate(锁定 + 双会话撤销 + 登录 401);非 admin 全端点 403 / 幽灵 404。admin Playwright `users.spec.ts` 2 例:账号页种子可见/前缀过滤/自我保护;重置(生成)→ 新密码可登录 → 停用 → 既有 token 401 → 状态过滤联动。
---
## D06 工作默认登记
下载/水印默认值(P4a 前置决策,计划头部登记为**工作默认**,用户可随时改):
- `downloadEnabled = true`、`originalDownloadEnabled = false`(沿用 P1 建表默认)
- 水印默认关闭;**水印开启为相册级显式操作**(admin 水印卡开关 + 参数表单 → POST watermark-jobs)
- 契约 `AlbumCreateRequest` 默认值注记同步(openapi.yaml albums POST description)
## 契约增补汇总(P4a 唯一增补批次,`8a8ad8a`)
**36 → 51 operations(+15 个新 operationId,1 个重塑)**:
| 域 | operationId | 语义要点 |
|----|-------------|----------|
| photos | `hidePhotosBatch` | photoIds 1..200,逐项 BatchHideResult {photoId, ok, code?},部分成功语义(A16) |
| admin | `transferAlbum` | owner 交接、历史上传人不动、keepAsCollaborator(A17) |
| albums | `createWatermarkJob` / `getWatermarkJobs` | WatermarkConfig 版本化 + 任务 state/progress/total/failed;原子切换、原图永不覆盖(A18/A20) |
| interactions | `downloadPublicPhoto` | variant=browse\|original 两级开关校验;**新错误码 DOWNLOAD_DISABLED**(errors.ts 与 Error enum 两侧同步,A18) |
| admin | `updateAdminUser` / `resetAdminUserPassword` / `activateAdminUser` / `deactivateAdminUser` | 账号治理四端点(sourceStatus 只读、自我保护、会话撤销、审计) |
| admin | `getUserSyncStatus` / `listUserSyncRuns` / `triggerUserSyncRun` / `listUserSyncErrors` | 用户同步状态/历史/手动补拉(互斥复用进行中批次,A29) |
| admin | `getUsageSummary` / `updateUsageThresholds` | 用量汇总 + 阈值(null 清除、即刻重评估,A23) |
| admin(重塑) | `listAdminUsers` | `/admin/users` 统一双模式(见偏差表 #8) |
**Schema 增补**:`PublicAlbum` 增 `downloadEnabled` / `originalDownloadEnabled` 两开关必填字段(viewer 按钮矩阵的契约依据);`AlbumCreateRequest` / `AlbumUpdateRequest` / `AlbumSummary` 同步两开关(AlbumSummary 入 required)。
## 偏差与发现登记
### 计划内(计划/代码注释明示)
| # | 事项 | 说明 |
|---|------|------|
| 1 | 用量实时 SUM | P1 规模够用;P5 换物化视图/OSS 用量 API(计划明示) |
| 2 | SVG 文字水印 | sharp 无原生文字渲染,经 librsvg 渲染 SVG `<text>`(font-family=sans-serif 走系统字体链,名义渲染 → trim → 缩放 → composite);**本机 CJK 可用**(e2e 以「输出非空且与无水印图不同」断言,精确字形不可断言);环境缺 CJK 字体时渲染为替代框——**字体打包登记 G5**,英文水印为兜底 |
| 3 | 告警接收人渠道 P5 | 本阶段告警落库 + 后台列表;通知渠道(邮件/IM)P5 |
| 4 | mock 源 department 忽略 | 端口保留字段以对齐真实源自形状;users 表无此列,引擎忽略(`user-source.port.ts` 注释登记) |
| 5 | 水印 created/effective 双版本语义细化 | 计划单版本号细化为「已创建」(watermarkVersion,POST 即推进、新照片即套用)与「有效」(watermarkEnabled + watermarkEffectiveVersion,任务 done 才推进)两轨——ov 只在对象存在性有保证后看到新版本号 |
| 6 | 禁用重生成 = 变体行删除、对象保留 | 水印变体行删除使列表回落 browse 键;对象存储不即时回收(生命周期清理随 P5) |
| 7 | 下载 window.open 新标签策略 | 点击 → GET 端点取短期签名凭证 → `window.open(url, '_blank', 'noopener,noreferrer')` 新标签直达对象存储;弹窗拦截(返回 null)→ 引导提示(`useDownload.ts`) |
| 8 | `/admin/users` 统一双模式 | P1b `lookupUsersByAccount` 路径与 P4a 管理列表冲突 → 统一 `listAdminUsers`:前缀模式沿用 P1b 语义(任何已认证工作人员);管理列表模式(query/status 过滤 + 游标 + 仅 admin);响应统一 AdminUserList(AdminUser 字段超集,游标元信息随行)——Task 1 报告要点,消费方(admin 邀请搜索/api 过渡实现)已同步 |
| 9 | qrcode 依赖 | admin 端新增 `qrcode ^1.5.4`(+ `@types/qrcode`)——修正计划「唯一新依赖」表述(计划 Task 9 已自带修正) |
| 10 | 「测试水印」预览为 CSS 叠加示意 | 非服务端 sharp 实际渲染,页面明确标注(`AlbumWatermark.vue` 头注);真实渲染效果由 e2e 字节级断言保证 |
### 计划外(本记录补登)
| # | 事项 | 影响/处理 |
|---|------|-----------|
| 11 | users-lookup e2e 键序修复(`8e3e19a`) | `Object.keys().sort()` 是码点序(大小写敏感):'localLocked' 排在 'locale' 之前;原断言按不区分大小写字母序书写导致确定性失败(Task 2 全量回归暴露的既有笔误,与 HEAD 行为无关) |
| 12 | full-chain e2e 竞态加固(`f4a1ba7`) | photo ready(发布事务提交)与 photo_jobs done(其后独立 updateMany)是两次落库,只轮询 photo 状态会在两写之间闯入断言窗口——轮询条件补齐 job 终态(Task 6 全套件时序下命中) |
| 13 | Element Plus 两坑 | ① `ElInputNumber` 只在 mount 时写内层 input 的 aria-disabled,prop 后续变化不重写 → `:key` 随开关重挂保持无障碍一致(`ThresholdsDialog.vue`);② `ElTable` row-class-name 回调载荷是 `{row, rowIndex}` → 解构取行,直接收行会把整个载荷当行判态(`usage/index.vue`) |
| 14 | mobile 手工模型超集兼容性说明 | App 邀请流仍按旧 3 字段投影消费 AdminUserList(超集,运行时兼容;mobile IT 不经该路径)——Task 1 偏差②跟进项结论:**零破坏,不改动**(本次 mobile 回归 280/280 零漂移佐证) |
## G4 待真实接口清单(UserSourcePort 替换点)
**替换点**:`services/api/src/user-sync/user-source.port.ts` 的 `USER_SOURCE` DI token——`user-sync.module.ts` 装配的 `MockUserSource` 换真实适配器即可,**引擎/契约/管理页/e2e 场景语义全部不变**。
**端口形状**(G4 后需与真实源逐项对齐):
- `listPage(cursor?: string): Promise<{ items: ExternalUserRecord[]; nextCursor: string | null }>`
- cursor 语义:`null` = 全量快照(首轮 + 周期对账共用);`"W"`(已提交源水位)= 增量(仅版本高于 W 的行;mock 语义,真实源为变更游标);页内续翻游标由源自编码(mock 用 `"W#P"`)
**D08 需确认的字段/语义清单**(`ExternalUserRecord`):
- `externalUserId`:稳定不变 ID(映射键,users 绝不重建)
- `loginAccount?`:登录账号;缺省时引擎回退用 externalUserId 作账号
- `displayName`:展示名(幂等更新字段)
- `department?`:端口保留、引擎忽略(users 无此列——若 D08 要求落库需迁移)
- `sourceStatus: active | disabled`:disabled → sourceStatus + 撤全部会话(不写 localLocked)
- `sourceVersion: number`:单调递增幂等比较位(仅高于映射行版本的行被应用)
- `updatedAt: Date`
- 另需:真实源鉴权方式、增量变更游标语义(对齐 "W")、分页页大小、失败重试约定
**引擎侧不变量(已 e2e 固化,替换 provider 后必须仍成立)**:空响应绝不停用任何用户;listPage 抛错 = 分页中断(批次 failed、水位不进、不动任何用户);每 `RECONCILE_EVERY_N_RUNS`(缺省 10)批强制全量对账;同步绝不写 password_hash / role / album_members(技术方案 3.1A 红线)。
## 移交 P4b / P5
- **P4b**:A23 组件部分(展示位/轮播 iframe 不可见暂停)、A11/A12/A22、T15 全端语言验收(P4a 新页文案已走模板 i18n zh/en,查漏译归 P4b)
- **P5**:用量物化/OSS 用量 API、告警接收人渠道、水印字体打包(G5)、G4 真实接口替换(上节清单)、D08 正式确认
- **仍待用户**:G2 真机验证(P3 清单)、D03 分发方式、D05/D10 阈值与并发目标
---
## 测试统计汇总(2026-09-20 回归门实测)
**Web 侧**(P3 出口 453 → 589,+136)
| 包 | 单测 suites(files)/tests | e2e suites(tests) |
|----|--------------------------|-------------------|
| @photo/contracts | 4 / 29(+7:watermark 版本语义) | – |
| @photo/storage | 1 / 6 | – |
| @photo/api-cn | 7 / 51 | **22 / 239**(P3 184 → +55:batch-ops 8 + watermark 9 + downloads 7 + usage 6 + admin-users 13 + user-sync 12) |
| @photo/worker | 2 / 14(+6) | 2 / 13(+6:watermark regen 真实队列) |
| @photo/sync | –(passWithNoTests) | 4 / 27 |
| @photo/viewer | vitest 17 / 114(+9:download 控制器/UA/凭证) | playwright 7 spec / 24(+1 spec/+3:download.spec) |
| @photo/admin | vitest 12 / 57(P3 15 → +42) | playwright 4 spec / 15(+2 spec/+8:albums-governance 5 + users 3) |
| **web 合计** | **43 / 271**(jest 14 suites / 100 + vitest 29 files / 171) | **39 / 318**(jest 28 suites / 279 + playwright 11 spec / 39) |
- 附加门:lint 9/9、typecheck 9/9、build 9/9 包任务全绿。
**移动端**(P4a 未触碰,验证零漂移)
| 套件 | 结果 |
|------|------|
| `flutter analyze` | **No issues found!**(0 issue,9.1s) |
| `flutter test` | **280 / 280 全过**(`00:15 +280: All tests passed!`) |
**总量**:web 271 + 318 = 589(P3 出口 453 → **+136**:单测 +64、e2e +72);mobile flutter 280(零漂移)。本次回归门合计 **869**;另有 Android `:app:testDebugUnitTest` 29/29(P3 实测存档,P4a 无 Android 原生改动未重跑——叠加口径 898)。