3681999667
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>
335 lines
16 KiB
Plaintext
335 lines
16 KiB
Plaintext
// packages/db-cn/prisma/schema.prisma —— CN 侧唯一 schema(Task 8 起步:身份与审计域)。
|
||
// 约定:MySQL 8.4、库级 collation utf8mb4_0900_bin(大小写敏感,账号/token 语义依赖它);
|
||
// 时间一律 @db.DateTime(6);ID cuid;连接池上限由 DATABASE_URL_CN 的 ?connection_limit=10
|
||
// 承担,不在应用侧另行放大。
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "mysql"
|
||
url = env("DATABASE_URL_CN")
|
||
}
|
||
|
||
model users {
|
||
id String @id @default(cuid())
|
||
account String @unique @db.VarChar(64)
|
||
passwordHash String @db.VarChar(255)
|
||
displayName String @db.VarChar(128)
|
||
role String @default("staff") // staff|admin(公司角色;相册角色另表)
|
||
sourceStatus String @default("local") // local|active|disabled(P4a 同步用)
|
||
localLocked Boolean @default(false)
|
||
credentialStatus String @default("active") // active|pending
|
||
locale String? @db.VarChar(16)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
sessions sessions[]
|
||
auditLogs audit_logs[]
|
||
}
|
||
|
||
model sessions {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
tokenHash String @unique @db.VarChar(64)
|
||
// 长效会话期限(默认 30 天,AUTH_SESSION_TTL_SECONDS 可调;不做短时效
|
||
// access/refresh 双层——用户决策 2026-09-17)
|
||
expiresAt DateTime @db.DateTime(6)
|
||
revokedAt DateTime? @db.DateTime(6) // 注销/停用撤销
|
||
user users @relation(fields: [userId], references: [id])
|
||
}
|
||
|
||
model audit_logs {
|
||
id String @id @default(cuid())
|
||
actorId String?
|
||
action String @db.VarChar(64)
|
||
resourceId String? @db.VarChar(64)
|
||
metadata Json?
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
actor users? @relation(fields: [actorId], references: [id])
|
||
}
|
||
|
||
// —— 相册域(Task 9)——
|
||
// 约定:与身份域不同,相册域一律使用扁平字符串外键(albumId/ownerId/userId/
|
||
// categoryId 为纯字符串,不建 Prisma relation/FK 约束)——Task 10 的照片域同此。
|
||
// 原因:跨域删除语义(用户注销→相册归属、相册下线→照片保留)由应用层显式
|
||
// 决策,不做数据库级联;也避免 TRUNCATE 隔离工具为绕 FK 检查承担更多顺序负担。
|
||
model albums {
|
||
id String @id @default(cuid())
|
||
publicToken String @unique @db.VarChar(32)
|
||
ownerId String @db.VarChar(64)
|
||
startAt DateTime @db.DateTime(6)
|
||
endAt DateTime? @db.DateTime(6)
|
||
timezone String @default("Asia/Shanghai") @db.VarChar(64)
|
||
status String @default("active") // active|offline
|
||
defaultLocale String @db.VarChar(16)
|
||
uploadEnabled Boolean @default(true)
|
||
publicEnabled Boolean @default(true)
|
||
downloadEnabled Boolean @default(true)
|
||
originalDownloadEnabled Boolean @default(false)
|
||
// ── 水印版本化(P4a Task 3)── 双版本位语义(权威表述见 contracts watermark.ts):
|
||
// watermarkVersion = 最新「已创建」配置版本(POST 即推进,新照片发布即刻套用,
|
||
// 也就是契约 AlbumSummary.watermarkVersion / WatermarkJobCreated.configVersion);
|
||
// watermarkEnabled + watermarkEffectiveVersion = 「有效」版本位——仅当该版本的
|
||
// 历史重生成任务 done 才推进(worker 完成事务维护,禁用版同样推进以翻转
|
||
// enabled=false)。ov 副本与公开侧变体选择以有效版本为准(对象存在性保证)。
|
||
watermarkVersion Int @default(0)
|
||
watermarkEnabled Boolean @default(false)
|
||
watermarkEffectiveVersion Int @default(0)
|
||
entityVersion Int @default(1) // P2 Task 3:album.updated 事件版本(同 photos 模式)
|
||
// P2 Task 9 统计回传副本位(ov→cn):viewCountReplica 为 ov album_stats
|
||
// 权威值的替换式副本;statsVersion 是回传替换版本(仅更高 statVersion 替换,
|
||
// 绝不相加);statsSyncedAt 记最近一次**应用**时刻(A27 同步时间展示)。
|
||
viewCountReplica Int @default(0)
|
||
statsVersion Int @default(0)
|
||
statsSyncedAt DateTime? @db.DateTime(6)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||
|
||
@@index([ownerId])
|
||
@@index([status, publicEnabled])
|
||
}
|
||
|
||
model album_translations {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
locale String @db.VarChar(16)
|
||
title String @db.VarChar(255)
|
||
description String? @db.Text
|
||
venue String? @db.VarChar(255)
|
||
address String? @db.VarChar(255)
|
||
|
||
@@unique([albumId, locale])
|
||
@@index([albumId])
|
||
}
|
||
|
||
model album_members {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
userId String @db.VarChar(64)
|
||
role String @default("collaborator") // owner|collaborator
|
||
status String @default("active")
|
||
addedAt DateTime @default(now()) @db.DateTime(6)
|
||
|
||
@@unique([albumId, userId])
|
||
@@index([userId])
|
||
}
|
||
|
||
model categories {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
sortOrder Int @default(0)
|
||
entityVersion Int @default(1) // P2 Task 3:category.* 事件版本(同 photos 模式)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
|
||
@@index([albumId, sortOrder])
|
||
}
|
||
|
||
model category_translations {
|
||
id String @id @default(cuid())
|
||
categoryId String @db.VarChar(64)
|
||
locale String @db.VarChar(16)
|
||
name String @db.VarChar(128)
|
||
|
||
@@unique([categoryId, locale])
|
||
}
|
||
|
||
model album_assets {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
kind String @db.VarChar(16) // banner|cover|logo
|
||
locale String? @db.VarChar(16)
|
||
objectKey String @db.VarChar(255)
|
||
state String @default("pending") @db.VarChar(16) // pending|effective(行内一次性上传会话,P1b Task 3)
|
||
expectedBytes Int @default(0) // init 申明字节数(complete head 核验依据)
|
||
contentType String @default("") @db.VarChar(64) // init 申明 MIME(complete head 核验依据)
|
||
entityVersion Int @default(1) // P2 Task 3:asset.updated 事件版本(仅 effective 迁移自增)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
|
||
@@index([albumId, kind])
|
||
}
|
||
|
||
// —— 照片与上传域 + outbox(Task 10)——
|
||
// 同相册域约定:扁平字符串外键、无 Prisma relation/FK 约束。
|
||
// photos.objectKey 是 originals 正式键(Task 18 上传确认时写入);
|
||
// upload_sessions.objectKey 是 staging 键;outbox_events 承载领域事件
|
||
// (契约见 packages/contracts),由 worker 轮询投递。
|
||
model photos {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
uploaderId String @db.VarChar(64)
|
||
categoryId String? @db.VarChar(64)
|
||
objectKey String @unique @db.VarChar(255) // originals 正式键
|
||
bytes Int @default(0)
|
||
width Int?
|
||
height Int?
|
||
shotAt DateTime? @db.DateTime(6)
|
||
uploadedAt DateTime? @db.DateTime(6)
|
||
publishedAt DateTime? @db.DateTime(6)
|
||
processingState String @default("pending_upload") // pending_upload|uploaded|processing|ready|processing_failed
|
||
errorCode String? @db.VarChar(64)
|
||
visibility String @default("unpublished") // unpublished|published|hidden|deleted
|
||
likeCount Int @default(0) // P2 起为统计副本(ov likeTotal 替换式回传)
|
||
// P2 Task 9:回传替换版本(ov photos_ov.countVersion 的 cn 侧镜像比较位,
|
||
// 仅更高 statVersion 替换 likeCount,重复/乱序回传幂等)。
|
||
statsVersion Int @default(0)
|
||
entityVersion Int @default(1)
|
||
@@index([albumId, visibility, publishedAt, id])
|
||
@@index([albumId, uploaderId, uploadedAt])
|
||
}
|
||
model photo_variants {
|
||
photoId String @db.VarChar(64)
|
||
variant String @db.VarChar(32) // thumb|browse
|
||
objectKey String @unique @db.VarChar(255)
|
||
bytes Int
|
||
version Int @default(1)
|
||
width Int?
|
||
height Int?
|
||
@@unique([photoId, variant])
|
||
}
|
||
model upload_sessions {
|
||
id String @id @default(cuid())
|
||
userId String @db.VarChar(64)
|
||
albumId String @db.VarChar(64)
|
||
clientTaskId String @db.VarChar(128)
|
||
photoId String @unique @db.VarChar(64)
|
||
objectKey String @unique @db.VarChar(255) // staging 键
|
||
expectedBytes Int
|
||
contentType String @db.VarChar(128)
|
||
sourceDeviceId String? @db.VarChar(64) // 客户端设备标识(A19 来源语义;幂等重放保留首值)
|
||
multipartId String? @db.VarChar(255)
|
||
state String @default("pending") // pending|confirmed|cancelled|expired(对齐契约 UploadSessionState;核验失败是请求级错误不改会话态,expired 由 expiresAt 判定或 sweep 置位)
|
||
expiresAt DateTime @db.DateTime(6)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
@@unique([userId, clientTaskId]) // 幂等锚点(A03)
|
||
}
|
||
model photo_jobs {
|
||
id String @id @default(cuid())
|
||
photoId String @db.VarChar(64)
|
||
type String @default("process") @db.VarChar(32)
|
||
state String @default("pending") // pending|active|done|failed
|
||
attempt Int @default(0)
|
||
errorCode String? @db.VarChar(64)
|
||
nextRetryAt DateTime? @db.DateTime(6)
|
||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||
@@unique([photoId, type])
|
||
@@index([state, nextRetryAt])
|
||
}
|
||
// —— 水印版本化(P4a Task 3)——
|
||
// watermark_configs:配置版本账本(append-only,每 POST 一行,config 为归一化
|
||
// StoredWatermarkConfig——contracts watermark.ts 的形状,缺省已填齐);
|
||
// watermark_jobs:历史重生成任务(id 兼作对外 jobId 与 BullMQ jobId——cuid 无
|
||
// ':',满足 BullMQ 保留分隔符约束)。state 语义:pending=已入队待消费;
|
||
// running=消费中(progress/failed 随照片逐张推进);done=全部处理完成(允许
|
||
// failed>0 的部分成功——失败张数记录在案,重试=重新 POST 新任务,按照片粒度
|
||
// 幂等);failed=整任务中止(配置缺失等任务级错误,errorCode 必有)。
|
||
model watermark_configs {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
configVersion Int
|
||
config Json
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
|
||
@@unique([albumId, configVersion])
|
||
}
|
||
model watermark_jobs {
|
||
id String @id @default(cuid())
|
||
albumId String @db.VarChar(64)
|
||
configVersion Int
|
||
state String @default("pending") @db.VarChar(16) // pending|running|done|failed
|
||
progress Int @default(0)
|
||
total Int @default(0)
|
||
failed Int @default(0)
|
||
errorCode String? @db.VarChar(64)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||
|
||
@@index([albumId, createdAt])
|
||
}
|
||
model outbox_events {
|
||
id String @id @default(cuid())
|
||
type String @db.VarChar(64)
|
||
albumId String @db.VarChar(64)
|
||
photoId String? @db.VarChar(64)
|
||
entityVersion Int
|
||
priority String @default("normal") @db.VarChar(16) // normal|high(P2 Task 3:下线/隐藏/删除类 high,发送器批次内优先)
|
||
payload Json
|
||
occurredAt DateTime @default(now()) @db.DateTime(6)
|
||
deliveredAt DateTime? @db.DateTime(6)
|
||
@@index([deliveredAt, occurredAt])
|
||
}
|
||
// —— 用量阈值与告警(P4a Task 5,A23 后台侧)——
|
||
// usage_thresholds:单行配置表(固定 id,迁移内种子行;维度 null=告警停用)。
|
||
// usage_alerts:阈值告警账本——超阈写入一次直到回落(resolvedAt 置位);
|
||
// 「同 kind+albumId 活跃告警唯一」为应用级约束(MySQL 无部分唯一索引,
|
||
// evaluateUsage 以单行配置 FOR UPDATE 作串行化锚点闭合先查后写竞态,见
|
||
// packages/db-cn/src/usage.ts)。告警接收人通知渠道 P5。
|
||
model usage_thresholds {
|
||
id String @id // 固定单行 id(USAGE_THRESHOLDS_SINGLETON_ID;迁移种子)
|
||
storageBytes BigInt? // 全库存储阈值(字节);null=维度停用
|
||
perAlbumBytes BigInt? // 单相册存储阈值(字节);null=维度停用
|
||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||
}
|
||
model usage_alerts {
|
||
id String @id @default(cuid())
|
||
kind String @db.VarChar(16) // storage|perAlbum
|
||
albumId String? @db.VarChar(64) // kind=perAlbum 必有;storage 为 null
|
||
message String @db.VarChar(1024)
|
||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||
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)
|
||
}
|