Files
photo-live/packages/db-ov/prisma/schema.prisma
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

201 lines
10 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-ov/prisma/schema.prisma —— OV(海外观众)侧唯一 schema(P2 Task 2)。
// 约定:与 db-cn 同库级约定——MySQL 8.4、utf8mb4_0900_bin(publicToken/对象键的
// 大小写敏感语义依赖它;Prisma 生成 DDL 固定 unicode_ci,迁移走「--create-only →
// 手工改 COLLATE → apply → information_schema 复核」固化流程);时间一律
// @db.DateTime(6);全库扁平字符串外键(albumId/photoId/categoryId 为纯字符串,
// 不建 relation/FK 约束——与 cn 相册域同因:删除/墓碑语义由应用层决策)。
//
// 与 cn schema 的本质差异:本库几乎全是「副本 + 同步账本 + 互动流水」三类表——
// · 副本表(albums_ov/photos_ov):以 cn 的 id 为键(albumId/photoId),展示
// 字段反规范化为 JSON(translations/categories 直接落 sync payload 原文),
// entityVersion 是 cn 侧权威版本(ov 只比较、从不自增);
// · 同步账本(sync_inbox/sync_state):eventId 幂等 + entityVersion 单调应用,
// 水位行与实体行同表(见 sync_state 注释);
// · 互动流水(like_batches/visit_sessions/visit_requests/outbox_stat_events):
// 幂等锚点全部唯一约束承担,窗口清理走 createdAt 索引。
// generator 输出到包内 generated/(不进 git):与 db-cn 关键差异——pnpm 将两个包
// 的 @prisma/client 提升为同一 store 实例,默认输出 node_modules/.prisma/client
// 会被两份 schema 互相覆盖(Task 2 实测 EPERM/串库),故 ov 客户端独立产出,
// 由 src/index.ts 转出口;消费方只见 @photo/db-ov。
generator client {
provider = "prisma-client-js"
output = "../generated/client"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL_OV")
}
// —— 副本域:albums_ov(cn albums 的展示投影)——
// emergency_disabled 是唯一 ov 本地可写位(Task 5 紧急停用入口直写),独立于
// 同步版本链:任何后续同步事件不得覆盖/解除它,解除仅经显式 enable 端点。
// startAt/endAt 可空:副本以事件到达为准,不复制 cn 的 NOT NULL 约束。
model albums_ov {
albumId String @id
publicToken String @unique @db.VarChar(32)
status String @default("active") // active|offline(cn 副本值)
publicEnabled Boolean @default(true)
downloadEnabled Boolean @default(true)
originalDownloadEnabled Boolean @default(false)
// P4a Task 3 水印副本位(album.updated 载荷投影;语义见 contracts watermark.ts):
// 有效版本(存在 done 重生成任务)——watermarkEnabled=true 时 ov 公开侧浏览键
// 用模式键 `variants/{photoId}/{watermarkConfigVersion}/watermark.jpg`(确定性
// 推导,无逐照片同步;对象存在性由有效版本保证)。
watermarkEnabled Boolean @default(false)
watermarkConfigVersion Int @default(0)
emergencyDisabled Boolean @default(false)
translations Json // [{locale,title,description,venue,address}](cn 原文数组)
categories Json // [{id,sortOrder,translations:[{locale,name}]}]
// 宣传图/封面投影键(asset.updated 事件按 kind 分列存储)。展示层双向回退
// (用户决策 2026-09-23「宣传图和封面用一个」):banner 缺失时公开侧回退
// cover 键——需求 §4.2「封面可复用宣传图」基线的反向补充。
bannerAssetKey String? @db.VarChar(255)
coverAssetKey String? @db.VarChar(255)
defaultLocale String @db.VarChar(16)
timezone String @default("Asia/Shanghai") @db.VarChar(64)
startAt DateTime? @db.DateTime(6)
endAt DateTime? @db.DateTime(6)
entityVersion Int // cn 权威版本(应用写入,ov 不自增)
updatedAt DateTime @updatedAt @db.DateTime(6)
}
// —— 副本域:photos_ov(cn photos + photo_variants 的展示投影)——
// 三个 objectKey 直接内联(cn 侧 thumb/browse 在变体表):ov 只读同 MinIO,
// 无上传/处理域,无需变体表。likeTotal/countVersion 是 ov 权威计数(Task 7
// 点赞事务维护,回传 cn 做副本替换)。
model photos_ov {
photoId String @id
albumId String @db.VarChar(64)
categoryId String? @db.VarChar(64)
width Int?
height Int?
originalObjectKey String @db.VarChar(255)
thumbObjectKey String @db.VarChar(255)
browseObjectKey String @db.VarChar(255)
likeTotal Int @default(0)
countVersion Int @default(0) // 点赞乐观锁/回传版本(Task 7/9)
visibility String // published|hidden|deleted|unpublished(cn 副本值)
entityVersion Int // cn 权威版本
publishedAt DateTime? @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@index([albumId, visibility, publishedAt])
}
// —— 同步账本:sync_inbox(cn→ov 事件收件箱,Task 4 inbox 模块消费)——
// eventId 即 cn outbox_events.id(幂等锚点);appliedAt 为空 = 未应用(依赖
// 缺失暂存 / 待补应用 / 待对账),receivedAt 供积压监控与重试排序。
model sync_inbox {
eventId String @id
entityType String @db.VarChar(32) // album|category|photo|asset|slot-state
entityId String @db.VarChar(64)
albumId String? @db.VarChar(64)
entityVersion Int
tombstone Boolean @default(false)
priority String @default("normal") @db.VarChar(16) // normal|high
payload Json
receivedAt DateTime @default(now()) @db.DateTime(6)
appliedAt DateTime? @db.DateTime(6)
@@index([receivedAt])
@@index([appliedAt])
}
// —— 同步账本:sync_state(实体应用版本 + per source 水位,同表两用)——
// 设计取舍(Task 2 决策):不另建水位表——watermark=false 为实体行
// (entityType+entityId 唯一 → appliedVersion,Task 4 应用前比较、旧版本忽略);
// watermark=true 为水位行(entityId 记 source 名如 'cn',appliedVersion 为该
// source 已确认的事件序水位,对账/断点续传用)。单表少一次 join,语义靠
// 唯一约束与标志位保住,P4a 管理页可直接一表读出延迟/积压。
model sync_state {
id String @id @default(cuid())
entityType String @db.VarChar(32)
entityId String @db.VarChar(64)
appliedVersion Int
appliedAt DateTime @default(now()) @db.DateTime(6)
watermark Boolean @default(false)
@@unique([entityType, entityId])
}
// —— 互动域:like_batches(Task 7 点赞批次幂等锚点)——
// photoId+sessionId+batchId 唯一:重复 batchId 命中唯一冲突 → 返回已确认结果,
// 不加计。createdAt 索引服务 Task 8 清理调度(LIKE_BATCH_WINDOW 默认 24h;
// 窗口外重放拒绝——累计总数在 photos_ov.likeTotal 长期保留,此表仅幂等账本)。
model like_batches {
id String @id @default(cuid())
photoId String @db.VarChar(64)
sessionId String @db.VarChar(64)
batchId String @db.VarChar(64)
delta Int
createdAt DateTime @default(now()) @db.DateTime(6)
@@unique([photoId, sessionId, batchId])
@@index([photoId])
@@index([createdAt])
}
// —— 互动域:album_stats(Task 8 浏览计数权威位,ov 本地权威、回传 cn 副本)——
model album_stats {
albumId String @id @db.VarChar(64)
viewCount Int @default(0)
statVersion Int @default(0) // 回传替换版本(仅更高版本替换,绝不相加)
updatedAt DateTime @updatedAt @db.DateTime(6)
}
// —— 互动域:visit_sessions(albumId+visitorId 窗口去重锚点,Task 8)——
// lastCountedAt 距今超过 VISIT_WINDOW_MINUTES(D07 默认 30 分钟)才允许再计。
model visit_sessions {
id String @id @default(cuid())
albumId String @db.VarChar(64)
visitorId String @db.VarChar(64)
lastCountedAt DateTime? @db.DateTime(6)
@@unique([albumId, visitorId])
}
// —— 互动域:visit_requests(requestId 幂等 + 重试应答,Task 8)——
model visit_requests {
requestId String @id @db.VarChar(64)
albumId String @db.VarChar(64)
visitorId String @db.VarChar(64)
counted Boolean @default(false)
createdAt DateTime @default(now()) @db.DateTime(6)
@@index([createdAt])
}
// —— 副本域:embed_slots_ov(cn embed_slots 的展示投影,P4b Task 2)——
// slotToken 即 cn embed_slots.publicToken(公开端点按它寻址,稳定不变);
// albumPublicToken 为绑定相册的公开令牌(null=未绑定 → 公开端 404)——副本
// 外键落公开令牌而非 cn id:嵌入侧只认 token(A22 换绑不认相册身份)。
// entityVersion 是 cn 权威版本(应用写入,ov 不自增);settings 为载荷原文
// (当前公开响应不含设置——loader 经 data-* 配置,副本位为对账/后续使用保留)。
// 无墓碑:下线是 enabled=false 的全量快照替换(cn 无删除端点)。
model embed_slots_ov {
slotToken String @id @db.VarChar(32)
enabled Boolean @default(true)
albumPublicToken String? @db.VarChar(32)
configVersion Int @default(1)
settings Json
entityVersion Int
updatedAt DateTime @updatedAt @db.DateTime(6)
}
// —— 统计回传队列:outbox_stat_events(ov→cn,Task 9 消费)——
// 与 cn outbox_events 同构的发送位:sentAt 为空即重发(中断恢复),statVersion
// 让 cn 侧「仅更高版本替换」幂等。total 恒为权威快照值(非增量)。
model outbox_stat_events {
id String @id @default(cuid())
kind String @db.VarChar(16) // likes|views
albumId String @db.VarChar(64)
photoId String? @db.VarChar(64) // kind=likes 时携带
total Int
statVersion Int
createdAt DateTime @default(now()) @db.DateTime(6)
sentAt DateTime? @db.DateTime(6)
@@index([sentAt])
}