// 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) // 幂等比较位:仅更高版本应用 profile Json? // 同步源档案快照(工号/邮箱/公司/头像)——纯展示,不参与同步决策(users 表零改动红线) 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) } // —— 展示位域(P4b Task 2,悬浮轮播组件挂载点)—— // cn 权威实体:admin 建槽/绑定相册,经 outbox slot.updated 事件同步 ov 副本 // (embed_slots_ov,载荷全量快照)。扁平字符串外键(albumId 无 relation/FK, // 同相册域约定)。双版本位:configVersion 是「嵌入端可见」的配置版本(任一 // 管理面变更递增,公开端 ETag 组成部分);entityVersion 是同步版本(同相册 // 模式,事务内自增)。无删除端点:enabled=false 即下线(软停用)。 // embed_slot_members:授权成员表**建而未用**(P4b 权限简化为 admin-only—— // 需求「创建者只操作被授权展示位」的授权面登记偏差,P5 按需出 API/UI)。 model embed_slots { id String @id @default(cuid()) publicToken String @unique @db.VarChar(32) // 嵌入代码 data-slot 引用(24 字符 [0-9a-z],同相册令牌口径) name String @db.VarChar(64) enabled Boolean @default(true) albumId String? @db.VarChar(64) // 绑定相册;null=未绑定(公开端 404) configVersion Int @default(1) // 任一管理面变更递增(嵌入端整体重置渲染依据) settings Json // EmbedSlotSettings 归一化全集(字段恒齐全) entityVersion Int @default(1) // slot.updated 事件版本(同 photos 模式) deletedAt DateTime? @db.DateTime(6) // 软删除(deleteEmbedSlot):列表隐藏;ov 经 enabled=false 快照下线 createdAt DateTime @default(now()) @db.DateTime(6) updatedAt DateTime @updatedAt @db.DateTime(6) } model embed_slot_members { id String @id @default(cuid()) slotId String @db.VarChar(64) userId String @db.VarChar(64) permission String @db.VarChar(32) // manage|view(P4b 未用,P5 授权面按需启用) createdAt DateTime @default(now()) @db.DateTime(6) @@unique([slotId, userId]) @@index([userId]) }