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

21 KiB
Raw Blame History

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)。