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:
@@ -0,0 +1,70 @@
|
||||
-- P4a Task 7:用户同步域(A29/T30 语义,mock 源先行)。
|
||||
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,逐表改写为库级约定的
|
||||
-- utf8mb4_0900_bin(sourceSystem/externalUserId/code 等字符串列的大小写
|
||||
-- 敏感唯一/比较语义依赖它)。
|
||||
-- · user_sync_runs.cursor 为 VarChar(128):G4 真实源可能出现非数值游标;
|
||||
-- mock 源恒为数值串(源版本高水位),API 层投影为契约 int64。
|
||||
-- · external_user_mappings.userId 为扁平字符串外键(无 FK,同相册域约定:
|
||||
-- 用户注销等跨域删除语义由应用层决策,且减轻 TRUNCATE 隔离负担)。
|
||||
-- · user_sync_state 单行状态表:迁移内种子行(固定 id 见
|
||||
-- USER_SYNC_STATE_SINGLETON_ID;reset.ts TRUNCATE 后重种同值)。
|
||||
-- `trigger` 为 MySQL 保留字:Prisma 查询恒反引号标识符,本迁移同样反引号。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_sync_runs` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`trigger` VARCHAR(16) NOT NULL,
|
||||
`state` VARCHAR(16) NOT NULL DEFAULT 'running',
|
||||
`cursor` VARCHAR(128) NULL,
|
||||
`counts` JSON NOT NULL,
|
||||
`startedAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`finishedAt` DATETIME(6) NULL,
|
||||
`error` VARCHAR(1024) NULL,
|
||||
|
||||
INDEX `user_sync_runs_startedAt_idx`(`startedAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_sync_errors` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`runId` VARCHAR(64) NOT NULL,
|
||||
`externalUserId` VARCHAR(64) NULL,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`detail` VARCHAR(1024) NOT NULL,
|
||||
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
|
||||
INDEX `user_sync_errors_runId_idx`(`runId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `external_user_mappings` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`userId` VARCHAR(64) NOT NULL,
|
||||
`sourceSystem` VARCHAR(32) NOT NULL DEFAULT 'mock',
|
||||
`externalUserId` VARCHAR(64) NOT NULL,
|
||||
`sourceVersion` INTEGER NOT NULL DEFAULT 0,
|
||||
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
|
||||
INDEX `external_user_mappings_userId_idx`(`userId`),
|
||||
UNIQUE INDEX `external_user_mappings_sourceSystem_externalUserId_key`(`sourceSystem`, `externalUserId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `user_sync_state` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`lastRunAt` DATETIME(6) NULL,
|
||||
`lastSuccessAt` DATETIME(6) NULL,
|
||||
`cursor` VARCHAR(128) NULL,
|
||||
`runCount` INTEGER NOT NULL DEFAULT 0,
|
||||
`updatedAt` DATETIME(6) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- 单行种子(引擎 upsert 兜底,但迁移种子保证常规路径下不变量成立——
|
||||
-- status 端点直接读本行,缺行视作「从未运行」)。
|
||||
INSERT INTO `user_sync_state` (`id`, `lastRunAt`, `lastSuccessAt`, `cursor`, `runCount`, `updatedAt`)
|
||||
VALUES ('user-sync-state-single-row', NULL, NULL, NULL, 0, CURRENT_TIMESTAMP(6));
|
||||
@@ -277,3 +277,58 @@ model usage_alerts {
|
||||
resolvedAt DateTime? @db.DateTime(6) // 非空=已解除(阈值上调/用量回落/维度停用)
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
// —— 用户同步域(P4a Task 7,A29/T30 语义,mock 源先行)——
|
||||
// 三条红线(技术方案 3.1A):同步绝不写 password_hash/role/album_members/
|
||||
// localLocked;空响应/部分页绝不停用任何人;上游恢复(disabled→active)
|
||||
// 不解除本地锁定。外部身份经 external_user_mappings 隔离(稳定外部 ID 锚点,
|
||||
// 不在 users 上加列)。
|
||||
// · user_sync_runs:批次账本。cursor = 已提交源水位(VarChar 承载 G4 真实
|
||||
// 源可能出现的非数值游标;mock 源恒为数值串,API 层投影为契约 int),
|
||||
// 逐页提交(分页中断时停在最后成功页);counts Json = {added,updated,
|
||||
// disabled,failed};state partial=有坏行但其余照常应用。
|
||||
// · user_sync_errors:坏行明细(partial/failed 批次的证据链)。
|
||||
// · external_user_mappings:本地用户 ↔ 外部身份。userId 为扁平字符串外键
|
||||
// (同相册域约定:跨域删除语义由应用层决策,且减轻 TRUNCATE 隔离负担)。
|
||||
// · user_sync_state:单行引擎状态(lastRunAt/lastSuccessAt/已提交水位/
|
||||
// runCount——runCount 驱动「每 N 批强制全量对账」节奏)。
|
||||
model user_sync_runs {
|
||||
id String @id @default(cuid())
|
||||
trigger String @db.VarChar(16) // cron|manual(MySQL 保留字,Prisma 恒反引号)
|
||||
state String @default("running") @db.VarChar(16) // running|success|partial|failed
|
||||
cursor String? @db.VarChar(128) // 已提交源水位;未提交为 null
|
||||
counts Json // {added,updated,disabled,failed}
|
||||
startedAt DateTime @default(now()) @db.DateTime(6)
|
||||
finishedAt DateTime? @db.DateTime(6)
|
||||
error String? @db.VarChar(1024) // 批次级失败摘要(分页中断等)
|
||||
|
||||
@@index([startedAt])
|
||||
}
|
||||
model user_sync_errors {
|
||||
id String @id @default(cuid())
|
||||
runId String @db.VarChar(64)
|
||||
externalUserId String? @db.VarChar(64) // 坏行可能连外部 ID 都不合规 → 可空
|
||||
code String @db.VarChar(64) // invalid_record|apply_failed|...
|
||||
detail String @db.VarChar(1024)
|
||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||
|
||||
@@index([runId])
|
||||
}
|
||||
model external_user_mappings {
|
||||
id String @id @default(cuid())
|
||||
userId String @db.VarChar(64) // 扁平字符串外键(无 relation/FK,同相册域)
|
||||
sourceSystem String @default("mock") @db.VarChar(32) // G4 后换真实源标识
|
||||
externalUserId String @db.VarChar(64)
|
||||
sourceVersion Int @default(0) // 幂等比较位:仅更高版本应用
|
||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||
|
||||
@@unique([sourceSystem, externalUserId])
|
||||
@@index([userId])
|
||||
}
|
||||
model user_sync_state {
|
||||
id String @id // 固定单行 id(USER_SYNC_STATE_SINGLETON_ID;迁移种子)
|
||||
lastRunAt DateTime? @db.DateTime(6)
|
||||
lastSuccessAt DateTime? @db.DateTime(6) // 仅 state=success 批次推进(partial 不算完全成功)
|
||||
cursor String? @db.VarChar(128) // 已提交源水位(与 runs.cursor 同形)
|
||||
runCount Int @default(0) // 已启动批次数(含失败),驱动周期全量对账
|
||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||
}
|
||||
|
||||
@@ -17,3 +17,6 @@ export type {
|
||||
UsageAggregates,
|
||||
UsageAlertKind,
|
||||
} from './usage';
|
||||
// P4a Task 7:用户同步域单行状态表固定 id(迁移种子;reset.ts TRUNCATE 后
|
||||
// 重种同值。引擎与 e2e 共用,避免字面量漂移)。
|
||||
export const USER_SYNC_STATE_SINGLETON_ID = 'user-sync-state-single-row';
|
||||
|
||||
Reference in New Issue
Block a user