Files
photo-live/packages/db-cn/prisma/schema.prisma
T
浅念 3681999667 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>
2026-09-20 20:01:17 +08:00

335 lines
16 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}