Files
photo-live/docs/plans/progress/P4a-验收记录.md
T
浅念 17d09bbb01 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>
2026-09-20 21:21:08 +08:00

244 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)。