8e674a902e
契约 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>
201 lines
10 KiB
Plaintext
201 lines
10 KiB
Plaintext
// 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])
|
||
}
|