feat(api): scheduled user sync with mock source (T30 semantics)

P4a Task 7 —— cn 侧用户定期同步(mock 源先行,G4 真实接口到位后仅替换
UserSourcePort provider,引擎/契约/表不变)。

数据模型(db-cn 迁移 20260920114149,utf8mb4_0900_bin 逐表校对):
- user_sync_runs:批次账本(trigger cron|manual、state running|success|
  partial|failed、cursor 水位、counts JSON、起止时间与错误摘要);
- user_sync_errors:单批次坏行明细(runId 索引);
- external_user_mappings:本地↔外部 ID 映射(@@unique([sourceSystem,
  externalUserId]),sourceVersion 逐行高水位);
- user_sync_state:单行状态(lastRunAt/lastSuccessAt/cursor/runCount)。

同步引擎(UserSyncEngine):
- Redis SETNX 分布式互斥(TTL 10min,锁值=runId,Lua CAS 释放);
  定时 tick 与手动触发共用,运行中批次复用 runId(202);
- 游标增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS 默认 10);
- 逐页单事务提交(run counts+cursor / state cursor),分页中断→failed
  且水位不前进、会话不动;
- 红线遵守:sync 不写 password_hash/role/album_members/localLocked;
  空/部分页不误伤停用;上游恢复不解除本地锁定;
- 新用户按 pending 建(随机不可用哨兵哈希,激活+重置后方可登录);
- 停用即撤销该用户全部会话;本地预建同名账号走关联而非重建。

Admin API(/admin/user-sync):GET status|runs|errors?runId=、POST runs
(202 {runId});runs 为 startedAt+ID 倒序游标分页。

auth.service:登录对哨兵/损坏哈希加固(等代价 dummy verify,
401 而非 500,时序平坦)。

测试:user-sync.e2e-spec 12 用例(首轮全量/改名幂等/停用撤会话/
恢复不解锁/新增/空页/分页中断/部分无效/手动互斥/增量+对账/API 形状
与权限/pending 登录门);api e2e 22 套件 239 用例全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 20:01:17 +08:00
parent f4a1ba7a14
commit 3681999667
14 changed files with 2004 additions and 1 deletions
+14
View File
@@ -17,6 +17,7 @@ import {
import {
PrismaClient,
USAGE_THRESHOLDS_SINGLETON_ID,
USER_SYNC_STATE_SINGLETON_ID,
} from '@photo/db-cn';
export const RESET_TABLES: readonly string[] = [
@@ -27,6 +28,14 @@ export const RESET_TABLES: readonly string[] = [
'photo_variants',
'upload_sessions',
'photos',
// 用户同步域(P4a Task 7):坏行明细先于批次账本;单行状态重种在
// resetDb 尾部;映射表为扁平字符串外键(无 FK),先于 users 仅为逻辑可读。
// 注意:Redis 互斥锁(user-sync:lock)不在本函数清理范围——e2e spec 自行
// 清(仅用户同步 spec 触碰触发路径)。
'user_sync_errors',
'user_sync_runs',
'user_sync_state',
'external_user_mappings',
// 用量域(P4a Task 5):告警账本先于阈值单行(重种在 resetDb 尾部)。
'usage_alerts',
'usage_thresholds',
@@ -120,6 +129,11 @@ export async function resetDb(): Promise<void> {
// 视作双维停用,跨 spec 的阈值残留则被清除)。
await prisma.$executeRaw`INSERT INTO usage_thresholds (id, storageBytes, perAlbumBytes, updatedAt)
VALUES (${USAGE_THRESHOLDS_SINGLETON_ID}, NULL, NULL, CURRENT_TIMESTAMP(6))`;
// 用户同步单行状态同理重种(status 端点直读本行;缺行 = 从未运行)。
// 列名恒反引号:`cursor` 是 MySQL 保留字(`trigger` 同理,Prisma 客户端
// 查询自带反引号,raw SQL 必须显式)。
await prisma.$executeRaw`INSERT INTO user_sync_state (\`id\`, \`lastRunAt\`, \`lastSuccessAt\`, \`cursor\`, \`runCount\`, \`updatedAt\`)
VALUES (${USER_SYNC_STATE_SINGLETON_ID}, NULL, NULL, NULL, 0, CURRENT_TIMESTAMP(6))`;
} finally {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
await prisma.$disconnect();