feat(ov): ov schema, entry skeleton and compose profile

P2 Task 2 ov 数据层:
- compose ov profile:mysql-ov(mysql:8.4,127.0.0.1:3309,库级
  utf8mb4_0900_bin)+ redis-ov(6381),cn 侧默认 up 不受影响
  (3307/3308 已被其它本机项目占用,端口决策 3309)
- @photo/db-ov:9 表 schema(albums_ov/photos_ov 副本、sync_inbox/
  sync_state 同步账本(水位行同表标志位)、like_batches/album_stats/
  visit_sessions/visit_requests 互动域、outbox_stat_events 回传队列);
  迁移 20260918060102_init_ov 走固化校对手决流程,information_schema
  复核全部业务表/列 utf8mb4_0900_bin(_prisma_migrations 同 cn 先例
  保持 unicode_ci)
- generator 独立 output(包内 generated/):pnpm 将两包 @prisma/client
  提升为同一 store 实例,默认 .prisma/client 输出会被双 schema 互相
  覆盖,ov 客户端独立产出经 src/index.ts 转出口
- ov 入口骨架:main-ov.ts(PORT_OV 默认 8081)+ app-ov.module.ts
  (仅管线骨架 + /v1/health,feature modules 后续任务装配);logger
  装配选项抽取至 shared/logger-options.ts 供 cn/ov 双入口共用(行为
  不变,P1 套件守护)
- test/reset-ov.ts:ov 库 truncate-if-exists 隔离工具(无 MinIO 清理)
- 根脚本:db:generate 双库齐跑,新增 db-ov:migrate/db-ov:generate/dev:ov

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 14:11:05 +08:00
parent a4b95abd4c
commit 3d6e9ce175
19 changed files with 578 additions and 30 deletions
@@ -0,0 +1,143 @@
-- P2 Task 2:OV 库初始迁移(副本/同步账本/互动流水共 9 表)。
-- 同 db-cn 固化手决流程:Prisma 生成的 MySQL DDL 固定写 utf8mb4_unicode_ci,
-- 会覆盖库级 utf8mb4_0900_bin;publicToken/entityType 等的大小写敏感语义
-- 依赖 bin 校对,故逐表改写为 utf8mb4_0900_bin(表级默认覆盖其全部
-- VARCHAR 列;后续 ov 迁移同样需要复核 CREATE TABLE 的 collation)。
-- CreateTable
CREATE TABLE `albums_ov` (
`albumId` VARCHAR(191) NOT NULL,
`publicToken` VARCHAR(32) NOT NULL,
`status` VARCHAR(191) NOT NULL DEFAULT 'active',
`publicEnabled` BOOLEAN NOT NULL DEFAULT true,
`downloadEnabled` BOOLEAN NOT NULL DEFAULT true,
`originalDownloadEnabled` BOOLEAN NOT NULL DEFAULT false,
`emergencyDisabled` BOOLEAN NOT NULL DEFAULT false,
`translations` JSON NOT NULL,
`categories` JSON NOT NULL,
`bannerAssetKey` VARCHAR(255) NULL,
`defaultLocale` VARCHAR(16) NOT NULL,
`timezone` VARCHAR(64) NOT NULL DEFAULT 'Asia/Shanghai',
`startAt` DATETIME(6) NULL,
`endAt` DATETIME(6) NULL,
`entityVersion` INTEGER NOT NULL,
`updatedAt` DATETIME(6) NOT NULL,
UNIQUE INDEX `albums_ov_publicToken_key`(`publicToken`),
PRIMARY KEY (`albumId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `photos_ov` (
`photoId` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`categoryId` VARCHAR(64) NULL,
`width` INTEGER NULL,
`height` INTEGER NULL,
`originalObjectKey` VARCHAR(255) NOT NULL,
`thumbObjectKey` VARCHAR(255) NOT NULL,
`browseObjectKey` VARCHAR(255) NOT NULL,
`likeTotal` INTEGER NOT NULL DEFAULT 0,
`countVersion` INTEGER NOT NULL DEFAULT 0,
`visibility` VARCHAR(191) NOT NULL,
`entityVersion` INTEGER NOT NULL,
`publishedAt` DATETIME(6) NULL,
`updatedAt` DATETIME(6) NOT NULL,
INDEX `photos_ov_albumId_visibility_publishedAt_idx`(`albumId`, `visibility`, `publishedAt`),
PRIMARY KEY (`photoId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `sync_inbox` (
`eventId` VARCHAR(191) NOT NULL,
`entityType` VARCHAR(32) NOT NULL,
`entityId` VARCHAR(64) NOT NULL,
`albumId` VARCHAR(64) NULL,
`entityVersion` INTEGER NOT NULL,
`tombstone` BOOLEAN NOT NULL DEFAULT false,
`priority` VARCHAR(16) NOT NULL DEFAULT 'normal',
`payload` JSON NOT NULL,
`receivedAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`appliedAt` DATETIME(6) NULL,
INDEX `sync_inbox_receivedAt_idx`(`receivedAt`),
INDEX `sync_inbox_appliedAt_idx`(`appliedAt`),
PRIMARY KEY (`eventId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `sync_state` (
`id` VARCHAR(191) NOT NULL,
`entityType` VARCHAR(32) NOT NULL,
`entityId` VARCHAR(64) NOT NULL,
`appliedVersion` INTEGER NOT NULL,
`appliedAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`watermark` BOOLEAN NOT NULL DEFAULT false,
UNIQUE INDEX `sync_state_entityType_entityId_key`(`entityType`, `entityId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `like_batches` (
`id` VARCHAR(191) NOT NULL,
`photoId` VARCHAR(64) NOT NULL,
`sessionId` VARCHAR(64) NOT NULL,
`batchId` VARCHAR(64) NOT NULL,
`delta` INTEGER NOT NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `like_batches_photoId_idx`(`photoId`),
INDEX `like_batches_createdAt_idx`(`createdAt`),
UNIQUE INDEX `like_batches_photoId_sessionId_batchId_key`(`photoId`, `sessionId`, `batchId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `album_stats` (
`albumId` VARCHAR(64) NOT NULL,
`viewCount` INTEGER NOT NULL DEFAULT 0,
`statVersion` INTEGER NOT NULL DEFAULT 0,
`updatedAt` DATETIME(6) NOT NULL,
PRIMARY KEY (`albumId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `visit_sessions` (
`id` VARCHAR(191) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`visitorId` VARCHAR(64) NOT NULL,
`lastCountedAt` DATETIME(6) NULL,
UNIQUE INDEX `visit_sessions_albumId_visitorId_key`(`albumId`, `visitorId`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `visit_requests` (
`requestId` VARCHAR(64) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`visitorId` VARCHAR(64) NOT NULL,
`counted` BOOLEAN NOT NULL DEFAULT false,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX `visit_requests_createdAt_idx`(`createdAt`),
PRIMARY KEY (`requestId`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
-- CreateTable
CREATE TABLE `outbox_stat_events` (
`id` VARCHAR(191) NOT NULL,
`kind` VARCHAR(16) NOT NULL,
`albumId` VARCHAR(64) NOT NULL,
`photoId` VARCHAR(64) NULL,
`total` INTEGER NOT NULL,
`statVersion` INTEGER NOT NULL,
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`sentAt` DATETIME(6) NULL,
INDEX `outbox_stat_events_sentAt_idx`(`sentAt`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "mysql"
+173
View File
@@ -0,0 +1,173 @@
// 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)
emergencyDisabled Boolean @default(false)
translations Json // [{locale,title,description,venue,address}](cn 原文数组)
categories Json // [{id,sortOrder,translations:[{locale,name}]}]
bannerAssetKey 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])
}
// —— 统计回传队列: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])
}