diff --git a/docs/plans/2026-09-17-实施总纲.md b/docs/plans/2026-09-17-实施总纲.md index 0dd4d32..25e4a2b 100644 --- a/docs/plans/2026-09-17-实施总纲.md +++ b/docs/plans/2026-09-17-实施总纲.md @@ -80,6 +80,8 @@ Schema 所有权:国内库表(users、albums、photos、upload_sessions、ou > **进度:P3 代码 + 模拟器级完成,2026-09-20,tag `p3`**。验收记录见 docs/plans/progress/P3-验收记录.md(A04/A05 模拟器级、A15/A19、A01/A02 App 联动证据落档;移动端 309 测试 + 设备集成 12 腿/2 场景全绿,web 侧 453 不降级,契约增补 password-change + sourceDeviceId 已登记)。**G2(强制关口)待用户真机执行**——最小验证集:真机装 APK 登录 → 批量 100 张真实样本上传中锁屏 ≥15min + 切后台 + 断网恢复 → 厂商后台清理/强杀重启恢复 → HEIC 真机样本 → expedited 配额与 POST_NOTIFICATIONS 授权 UX(全量清单见验收记录)。**D03 分发方式仍待确认**;G3 iOS 编译清单已就绪(apps/mobile/ios/README-ios.md)。 | P4a 后台完备 + 账号治理 | Art Design Pro 全功能后台:相册/照片/用量管理与告警、上传公开开关、批量隐藏、负责人交接、水印版本与历史重生成、下载两级控制、后台语言切换与账号管理(激活停用/重置密码/角色)、用户定期同步任务与管理页(mock 源) | T10 T20 T22 T12 T30 T04(mock 部分) | A16 A17 A18 A20 A23 A29(mock 级) | 回归全绿 + 验收记录;D06 前置 | G4(真实接口) | + +> **进度:P4a 完成,2026-09-20,tag `p4a`**。验收记录见 docs/plans/progress/P4a-验收记录.md(A16/A17/A18/A20/A23/A29 mock 级证据落档,web 侧 589 测试全绿 +136、mobile 280/280 零漂移,契约增补批次 36→51 operations + DOWNLOAD_DISABLED 已登记)。**D06 已登记工作默认**(downloadEnabled=true / originalDownloadEnabled=false / 水印关闭,水印开启为相册级显式操作——用户可随时改)。**G4/D08 待真实接口**:UserSourcePort 替换点与 D08 字段确认清单见验收记录(引擎/契约/管理页不变,仅换 provider)。 | P4b 传播组件 | embed-loader + 展示位管理 + 轮播 iframe:configVersion 校验、旧响应丢弃、不可见暂停、postMessage 白名单、data-lang 语言配置 | T14 T23 T15 | A11 A12 A22;A23 组件部分 | 回归全绿 + 验收记录 + 全端语言验收(T15/A12) | 无 | | P5 真实环境接入 | 真 OSS/STS 适配器验证、海外部署与域名、全球 CDN 接入与失效策略、用户接口真实对接、跨地域故障演练、容量压测、试运行与分发交接 | T16 T17 T25(真实)T28 T29 T18 | A24 A25 A28 + 全量 A 回归 + 容量报告 | 发布门槛(对齐项目计划 7.3) | G5、G6 | diff --git a/docs/plans/2026-09-20-P4a-后台完备与账号治理.md b/docs/plans/2026-09-20-P4a-后台完备与账号治理.md index 2be46a8..d0b4f18 100644 --- a/docs/plans/2026-09-20-P4a-后台完备与账号治理.md +++ b/docs/plans/2026-09-20-P4a-后台完备与账号治理.md @@ -1,5 +1,7 @@ # P4a 后台完备 + 账号治理 实施计划 +> **✅ 阶段已完成(2026-09-20,tag `p4a`,基线 45d4651..HEAD)。** 完成状态的权威记录是 [docs/plans/progress/P4a-验收记录.md](./progress/P4a-验收记录.md);Task 1-10 全部交付、勾选已回填。偏差(用量实时 SUM、SVG 文字水印 + G5 字体、mock 源 department 忽略、下载 window.open 策略、/admin/users 双模式等 14 项)与 G4 待真实接口清单(UserSourcePort 替换点 + D08 字段确认)见验收记录。 + > **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:** 管理后台达到运营完备:照片批量管理(逐项结果)、负责人交接、水印版本化与历史重生成、下载两级控制(含 viewer 下载 UI 与微信引导)、用量统计与阈值告警、账号管理(激活/停用/重置密码/角色)、用户定期同步任务(mock 源先行)与同步管理页。 @@ -20,7 +22,7 @@ **Files:** Modify `packages/contracts/openapi.yaml`(+gen) -- [ ] 新增路径(遵循既有风格): +- [x] 新增路径(遵循既有风格): - `POST /albums/{albumId}/photos/hide-batch`:{photoIds: string[] (1..200)} → 200 {results: [{photoId, ok, code?}]}(逐项结果,A16);403/404 同管理列表 - `POST /admin/albums/{albumId}/transfer`:{targetUserId, keepAsCollaborator: boolean} → 200 {album}(A17:owner 转移、历史上传人不变) - `POST /albums/{albumId}/watermark-jobs`:{config: {enabled, position?, opacity?, scalePercent?, text?{content?, fontSize?}}} → 202 {jobId, configVersion};`GET /albums/{albumId}/watermark-jobs` → {current: {configVersion, config}, jobs: [{jobId, configVersion, state, progress, total, failed, errorCode?, createdAt}]}(A18/A20) @@ -28,70 +30,70 @@ - 账号管理(tag admin):`GET /admin/users?query=&status=&cursor=`(管理列表:id/account/displayName/role/sourceStatus/localLocked/credentialStatus/locale/createdAt);`PATCH /admin/users/{id}` {role?, localLocked?, displayName?};`POST /admin/users/{id}/reset-password` {newPassword} → 200 {initialPassword?}(管理员重置:直接设新密码返回给管理员,或生成随机——决策:body 可选 newPassword,缺省服务端生成随机返回,e2e 两种都测);`POST /admin/users/{id}/activate` / `deactivate`(deactivate=localLocked=true+撤销该用户全部会话) - 用户同步管理(tag admin):`GET /admin/user-sync/status` → {lastRunAt, lastSuccessAt, state, sourceVersion, counts: {added,updated,disabled,failed}};`GET /admin/user-sync/runs?cursor=`(历史批次);`GET /admin/user-sync/errors?runId=`(失败明细);`POST /admin/user-sync/runs`(手动补拉触发 → 202 {runId}) - 用量(tag admin):`GET /admin/usage/summary` → {albums: [{albumId, title, photoCount, originalBytes, variantBytes, totalBytes}], totals, thresholds: {storageBytes?, perAlbumBytes?}, alerts: [{id, kind, albumId?, message, createdAt, resolvedAt?}]};`PUT /admin/usage/thresholds` {storageBytes?, perAlbumBytes?}(告警接收人 P5 通知渠道,本阶段落库+列表,A23 后台侧) -- [ ] gen + lint + 全回归绿;⏸ 快速确认点(要点摘要给用户) -- [ ] Commit `feat(contracts): p4a admin governance, watermark, download and usage endpoints` +- [x] gen + lint + 全回归绿;⏸ 快速确认点(要点摘要给用户) +- [x] Commit `feat(contracts): p4a admin governance, watermark, download and usage endpoints` ## Task 2: 批量隐藏 + 负责人交接(后端) -- [ ] e2e(先行):批量隐藏 3 张(2 成功 1 已删→ok:false code PHOTO_NOT_PUBLIC)逐项结果+audit(photo.hide×2);outbox photo.hidden×2 高优先级;ov 端同步不可见(复用 sync 测试基建)。交接:admin 转移给 staffB、keepAsCollaborator=true/false 两例→owner 变更+成员角色正确+历史上传人不变(photos.uploaderId 不动)+audit album.transfer;非 admin 403;目标无效账号 400 -- [ ] 实现(album 域事务 + PermissionsService:仅 admin 可交接) -- [ ] Commit `feat(api): batch hide with per-item results and owner transfer` +- [x] e2e(先行):批量隐藏 3 张(2 成功 1 已删→ok:false code PHOTO_NOT_PUBLIC)逐项结果+audit(photo.hide×2);outbox photo.hidden×2 高优先级;ov 端同步不可见(复用 sync 测试基建)。交接:admin 转移给 staffB、keepAsCollaborator=true/false 两例→owner 变更+成员角色正确+历史上传人不变(photos.uploaderId 不动)+audit album.transfer;非 admin 403;目标无效账号 400 +- [x] 实现(album 域事务 + PermissionsService:仅 admin 可交接) +- [x] Commit `feat(api): batch hide with per-item results and owner transfer` ## Task 3: 水印版本化 + 历史重生成(后端+worker) -- [ ] cn 迁移(校对手决):albums 已有 watermarkVersion;新增 `watermark_configs`(albumId+configVersion 唯一, config Json, createdAt)与 `watermark_jobs`(jobId, albumId, configVersion, state pending/running/done/failed, progress/total/failed 计数, errorCode, timestamps) -- [ ] worker 新消费者(QUEUE_WATERMARK,cn Redis):逐张:读原图→按 config 合成新水印图(sharp composite:position/opacity/scale/text 基本实现,文字水印用 SVG render——sharp 无原生 text,登记实现方式)→ 写 variants 新键 `{version}/watermark.jpg`→upsert photo_variants(variant=watermark, version=新)→**原子**:全部成功才切换(job 级聚合,失败照片保留旧浏览图,A20)→ 完成时 outbox album.updated(新 watermarkVersion)→ 失败可重试(job 重入按 photo 粒度幂等) -- [ ] 设置 API:POST watermark-jobs(创建 config 版本+job 入队,仅创建者/admin);GET 状态 -- [ ] e2e:开启水印→传 1 新照(发布图含水印变体)→发起历史重生成(3 张)→进度推进→done→GET 列表照片 browse URL 指向水印版(bytes 变化/内容校验)→原图 objectKey 不变(A20 核心);中途 1 张注入失败→job failed→重试→done;关闭水印→新照片无水印 -- [ ] Commit `feat(worker): watermark versioning with atomic history regeneration` +- [x] cn 迁移(校对手决):albums 已有 watermarkVersion;新增 `watermark_configs`(albumId+configVersion 唯一, config Json, createdAt)与 `watermark_jobs`(jobId, albumId, configVersion, state pending/running/done/failed, progress/total/failed 计数, errorCode, timestamps) +- [x] worker 新消费者(QUEUE_WATERMARK,cn Redis):逐张:读原图→按 config 合成新水印图(sharp composite:position/opacity/scale/text 基本实现,文字水印用 SVG render——sharp 无原生 text,登记实现方式)→ 写 variants 新键 `{version}/watermark.jpg`→upsert photo_variants(variant=watermark, version=新)→**原子**:全部成功才切换(job 级聚合,失败照片保留旧浏览图,A20)→ 完成时 outbox album.updated(新 watermarkVersion)→ 失败可重试(job 重入按 photo 粒度幂等) +- [x] 设置 API:POST watermark-jobs(创建 config 版本+job 入队,仅创建者/admin);GET 状态 +- [x] e2e:开启水印→传 1 新照(发布图含水印变体)→发起历史重生成(3 张)→进度推进→done→GET 列表照片 browse URL 指向水印版(bytes 变化/内容校验)→原图 objectKey 不变(A20 核心);中途 1 张注入失败→job failed→重试→done;关闭水印→新照片无水印 +- [x] Commit `feat(worker): watermark versioning with atomic history regeneration` ## Task 4: 下载端点 + viewer 下载 UI(T12 收口) -- [ ] 后端:GET download(ov 侧:读 photos_ov + albums_ov 开关副本;签名 URL 短期;variant 校验:original 需 originalDownloadEnabled,browse 需 downloadEnabled;关闭→403 DOWNLOAD_DISABLED;日志不记完整签名)——注意此端点在 ov 域(观众用),契约放 public tag -- [ ] viewer:大图工具栏下载按钮(按 album 开关显示:两级都关→无按钮;browse→下载当前浏览图;original 也开→下拉两项);微信内置浏览器检测(UA)→引导浮层(保存图片/复制链接文案,l10n);下载失败(403/过期)重取一次 -- [ ] e2e(ov):组合矩阵(开+开/开+关/关+关)×variant;过期 URL 刷新;Playwright:viewer 大图下载按钮出现性随相册配置、微信 UA mock 引导浮层 -- [ ] Commit `feat(ov,viewer): two-tier download with wechat guidance` +- [x] 后端:GET download(ov 侧:读 photos_ov + albums_ov 开关副本;签名 URL 短期;variant 校验:original 需 originalDownloadEnabled,browse 需 downloadEnabled;关闭→403 DOWNLOAD_DISABLED;日志不记完整签名)——注意此端点在 ov 域(观众用),契约放 public tag +- [x] viewer:大图工具栏下载按钮(按 album 开关显示:两级都关→无按钮;browse→下载当前浏览图;original 也开→下拉两项);微信内置浏览器检测(UA)→引导浮层(保存图片/复制链接文案,l10n);下载失败(403/过期)重取一次 +- [x] e2e(ov):组合矩阵(开+开/开+关/关+关)×variant;过期 URL 刷新;Playwright:viewer 大图下载按钮出现性随相册配置、微信 UA mock 引导浮层 +- [x] Commit `feat(ov,viewer): two-tier download with wechat guidance` ## Task 5: 用量统计与阈值告警(后端 + A23 后台侧) -- [ ] 统计查询:albums 聚合(photos bytes + photo_variants bytes per album)实时 SUM(P1 规模够用;P5 换物化/OSS 用量,登记);thresholds 表(单行配置)+ alerts 表(超阈写入一次直到回落,重复告警去重:kind+albumId 活跃告警唯一) -- [ ] 检查点:照片发布/删除时、阈值修改时评估(worker 发布路径与 photos PATCH 钩子);A23 断言:超阈产生 alert→后台 summary 可见→阈值上调→旧 alert resolvedAt 标记 -- [ ] e2e:造假 bytes(直改 DB)→ 告警生成/去重/解除全链 -- [ ] Commit `feat(api): usage summary with threshold alerts` +- [x] 统计查询:albums 聚合(photos bytes + photo_variants bytes per album)实时 SUM(P1 规模够用;P5 换物化/OSS 用量,登记);thresholds 表(单行配置)+ alerts 表(超阈写入一次直到回落,重复告警去重:kind+albumId 活跃告警唯一) +- [x] 检查点:照片发布/删除时、阈值修改时评估(worker 发布路径与 photos PATCH 钩子);A23 断言:超阈产生 alert→后台 summary 可见→阈值上调→旧 alert resolvedAt 标记 +- [x] e2e:造假 bytes(直改 DB)→ 告警生成/去重/解除全链 +- [x] Commit `feat(api): usage summary with threshold alerts` ## Task 6: 账号管理(后端) -- [ ] 管理列表(游标+过滤 query/status)、PATCH(role/localLocked/displayName;**不越权改 sourceStatus——只读展示**)、reset-password(指定或生成随机;撤销其他会话;audit)、deactivate(localLocked=true + 撤销全部会话 + audit)/activate(**§10 语义:credentialStatus pending→active + 经 reset-password 机制出初始凭据**——闭环 Task 7 同步创建的待激活用户) -- [ ] e2e:矩阵(停用→该用户登录 401+已有 token 401;重置→旧密码失效新密码可登;role 改变生效;非 admin 全 403;audit 全程;**Task 7 联动:同步创建的 pending 用户登录 401 → activate+重置 → 可登录**) -- [ ] Commit `feat(api): admin account governance endpoints` +- [x] 管理列表(游标+过滤 query/status)、PATCH(role/localLocked/displayName;**不越权改 sourceStatus——只读展示**)、reset-password(指定或生成随机;撤销其他会话;audit)、deactivate(localLocked=true + 撤销全部会话 + audit)/activate(**§10 语义:credentialStatus pending→active + 经 reset-password 机制出初始凭据**——闭环 Task 7 同步创建的待激活用户) +- [x] e2e:矩阵(停用→该用户登录 401+已有 token 401;重置→旧密码失效新密码可登;role 改变生效;非 admin 全 403;audit 全程;**Task 7 联动:同步创建的 pending 用户登录 401 → activate+重置 → 可登录**) +- [x] Commit `feat(api): admin account governance endpoints` ## Task 7: 用户定期同步(mock 源,T30 语义全量) -- [ ] cn 迁移:user_sync_runs(id, trigger cron|manual, state running|success|partial|failed, cursor 水位, counts Json, startedAt/finishedAt, error)、user_sync_errors(runId, externalId, code, detail)、external_user_mappings(已有?P1 无——建:userId+sourceSystem+externalUserId 唯一, sourceVersion)、users 增 sourceSystem?(用 mappings 表即可,不改 users) -- [ ] MockUserSource provider:可配置 fixtures(分页/增量版本/改名/停用/新增/空页/超时失败模式),接口:listPage(cursor), 与真实 D08 对齐的方法签名(适配器接口 UserSourcePort) -- [ ] 同步引擎(api 内定时 SYNC_USERS_INTERVAL 默认 5min + 手动触发共用):互斥锁(Redis SETNX cn)→ 读水位→分页拉取→逐条:externalId 映射存在→幂等更新(displayName,sourceVersion 比较);不存在→建待激活用户(credentialStatus=pending + 随机初始密码不入库不可登,等管理员激活)→ 显式停用字段→localLocked? NO:sourceStatus=disabled + 撤会话;空响应/分页中断→**不动任何用户**(记录 failed run)→ 提交水位 -- [ ] 状态/历史/失败明细/手动补拉 API(Task 1 契约) -- [ ] e2e(mock 全场景):首轮全量(3 用户)→改名幂等→停用撤会话→新增待激活→手动补拉与定时互斥→空响应不误伤→失败批次水位不进→**部分无效记录(state=partial,坏行入 errors 其余照常)→增量模式下周期全量对账→上游恢复(disabled→active)不解除 localLocked(技术方案 3.1A:本地锁定独立)** -- [ ] Commit `feat(api): scheduled user sync with mock source (T30 semantics)` +- [x] cn 迁移:user_sync_runs(id, trigger cron|manual, state running|success|partial|failed, cursor 水位, counts Json, startedAt/finishedAt, error)、user_sync_errors(runId, externalId, code, detail)、external_user_mappings(已有?P1 无——建:userId+sourceSystem+externalUserId 唯一, sourceVersion)、users 增 sourceSystem?(用 mappings 表即可,不改 users) +- [x] MockUserSource provider:可配置 fixtures(分页/增量版本/改名/停用/新增/空页/超时失败模式),接口:listPage(cursor), 与真实 D08 对齐的方法签名(适配器接口 UserSourcePort) +- [x] 同步引擎(api 内定时 SYNC_USERS_INTERVAL 默认 5min + 手动触发共用):互斥锁(Redis SETNX cn)→ 读水位→分页拉取→逐条:externalId 映射存在→幂等更新(displayName,sourceVersion 比较);不存在→建待激活用户(credentialStatus=pending + 随机初始密码不入库不可登,等管理员激活)→ 显式停用字段→localLocked? NO:sourceStatus=disabled + 撤会话;空响应/分页中断→**不动任何用户**(记录 failed run)→ 提交水位 +- [x] 状态/历史/失败明细/手动补拉 API(Task 1 契约) +- [x] e2e(mock 全场景):首轮全量(3 用户)→改名幂等→停用撤会话→新增待激活→手动补拉与定时互斥→空响应不误伤→失败批次水位不进→**部分无效记录(state=partial,坏行入 errors 其余照常)→增量模式下周期全量对账→上游恢复(disabled→active)不解除 localLocked(技术方案 3.1A:本地锁定独立)** +- [x] Commit `feat(api): scheduled user sync with mock source (T30 semantics)` ## Task 8: admin 账号治理 + 用户同步页 -- [ ] 账号管理页:列表(过滤/分页)、行操作(激活/停用/重置密码弹窗显示生成密码一次性/角色编辑);同步管理页:状态卡(上次成功/水位/计数)、运行历史、失败明细抽屉、手动补拉按钮(防抖+运行中禁用) -- [ ] Playwright(真实后端+mock 源注入:admin e2e 已有 harness 模式):账号全流程(停用→列表状态→重置→用新密码登录 viewer 无关/admin 登录验证);同步页(触发补拉→状态刷新→失败明细可见) -- [ ] Commit `feat(admin): account governance and user sync pages` +- [x] 账号管理页:列表(过滤/分页)、行操作(激活/停用/重置密码弹窗显示生成密码一次性/角色编辑);同步管理页:状态卡(上次成功/水位/计数)、运行历史、失败明细抽屉、手动补拉按钮(防抖+运行中禁用) +- [x] Playwright(真实后端+mock 源注入:admin e2e 已有 harness 模式):账号全流程(停用→列表状态→重置→用新密码登录 viewer 无关/admin 登录验证);同步页(触发补拉→状态刷新→失败明细可见) +- [x] Commit `feat(admin): account governance and user sync pages` ## Task 9: admin 相册治理增量(批量隐藏/交接/水印/下载设置/用量页/分享码) -- [ ] 照片管理页:多选+批量隐藏(逐项结果 toast 汇总 成功x/失败y+失败列表)、交接对话框(选目标账号+是否保留协作者)、行内展示 P2 回传的**统计副本与同步时间**(statsSyncedAt/likeCount 副本列);相册详情:水印卡(开关+参数表单+版本历史+发起重生成+进度条轮询)、下载开关组已有(P1b 编辑表单)——补"测试水印"预览(生成 1 张水印示例展示)、**分享链接与二维码卡**(评审缺口:viewer URL(VITE_VIEWER_BASE)+ qrcode 库渲染 + 复制——需新增 qrcode 依赖,修正"无新依赖"表述) -- [ ] 用量页:汇总表(相册容量/照片数)+阈值设置表单+告警列表(未解决/已解决 tab) -- [ ] Playwright:批量隐藏旅程(3 选 2 隐藏→逐项结果→viewer 侧 2 消失)、交接旅程、水印重生成进度到 done、用量阈值触发告警可见、分享二维码渲染(canvas/dataURL 断言) -- [ ] Commit `feat(admin): batch ops, transfer, watermark, usage and qr share pages` +- [x] 照片管理页:多选+批量隐藏(逐项结果 toast 汇总 成功x/失败y+失败列表)、交接对话框(选目标账号+是否保留协作者)、行内展示 P2 回传的**统计副本与同步时间**(statsSyncedAt/likeCount 副本列);相册详情:水印卡(开关+参数表单+版本历史+发起重生成+进度条轮询)、下载开关组已有(P1b 编辑表单)——补"测试水印"预览(生成 1 张水印示例展示)、**分享链接与二维码卡**(评审缺口:viewer URL(VITE_VIEWER_BASE)+ qrcode 库渲染 + 复制——需新增 qrcode 依赖,修正"无新依赖"表述) +- [x] 用量页:汇总表(相册容量/照片数)+阈值设置表单+告警列表(未解决/已解决 tab) +- [x] Playwright:批量隐藏旅程(3 选 2 隐藏→逐项结果→viewer 侧 2 消失)、交接旅程、水印重生成进度到 done、用量阈值触发告警可见、分享二维码渲染(canvas/dataURL 断言) +- [x] Commit `feat(admin): batch ops, transfer, watermark, usage and qr share pages` ## Task 10: 回归门 + P4a 验收记录 -- [ ] 全仓回归绿(web+mobile analyze/test 不降级)+ 记录数字 -- [ ] `docs/plans/progress/P4a-验收记录.md`(A16/A17/A18/A20/A23/A29 mock 级证据、D06 工作默认登记、偏差表[用量实时 SUM、文字水印 SVG 实现、下载端点在 ov 域、mock 源方法签名、A23 接收人 P5]、G4 待真实接口清单、移交 P4b/P5) -- [ ] 总纲 P4a 进度注记;`git tag p4a`;Commit `docs: p4a acceptance record` +- [x] 全仓回归绿(web+mobile analyze/test 不降级)+ 记录数字 +- [x] `docs/plans/progress/P4a-验收记录.md`(A16/A17/A18/A20/A23/A29 mock 级证据、D06 工作默认登记、偏差表[用量实时 SUM、文字水印 SVG 实现、下载端点在 ov 域、mock 源方法签名、A23 接收人 P5]、G4 待真实接口清单、移交 P4b/P5) +- [x] 总纲 P4a 进度注记;`git tag p4a`;Commit `docs: p4a acceptance record` --- diff --git a/docs/plans/progress/P4a-验收记录.md b/docs/plans/progress/P4a-验收记录.md new file mode 100644 index 0000000..2a83ae9 --- /dev/null +++ b/docs/plans/progress/P4a-验收记录.md @@ -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 ``(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)。