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:
@@ -19,6 +19,8 @@ ADMIN_PASSWORD=admin-secret-change-me
|
||||
SYNC_INTERNAL_KEY=dev-sync-secret-change-me
|
||||
DATABASE_URL_OV=mysql://root:root@127.0.0.1:3309/photo_ov?connection_limit=10
|
||||
REDIS_URL_OV=redis://127.0.0.1:6381
|
||||
# P2 Task 2:ov 入口监听端口(main-ov.ts 读取;与 cn PORT=8080 同机双入口)。
|
||||
PORT_OV=8081
|
||||
# 以下为 P2 决策初值(D07/D11)的 env 覆盖键:默认值见
|
||||
# packages/contracts/src/constants.ts(HEARTBEAT_MS 见 src/realtime.ts),
|
||||
# 此处登记供运维可见与调整;代码读取在 Task 3+ 落地(当前为前瞻登记)。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.turbo/
|
||||
# @photo/db-ov Prisma client 工件(自定义 generator output;db-cn 走 node_modules 默认位)
|
||||
packages/db-ov/generated/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -33,9 +33,9 @@
|
||||
|
||||
**Files:** Create `packages/db-ov/`(mirror db-cn 模式:package/schema/src);`services/api` ov 入口骨架 `src/main-ov.ts` + `app-ov.module.ts`;compose ov 侧 profile(mysql-ov 3309、redis-ov 6381,127.0.0.1 绑定);`test/reset-ov.ts`(api 包共用,按连接串区分)
|
||||
|
||||
- [ ] ov schema:`albums_ov`(cn 副本:publicToken/状态/开关/翻译 JSON/分类 JSON/banner 对象键/版本)、`photos_ov`(photoId/albumId/分类/宽高/objectKeys/likeTotal/countVersion/visibility/版本/publishedAt)、`sync_inbox`(eventId 唯一、entityType/entityId/entityVersion、tombstone、payload、receivedAt/appliedAt)、`sync_state`(entityType+entityId → appliedVersion,水位表 per source)、`like_batches`(photoId+sessionId+batchId 唯一、delta、createdAt + 索引/清理窗口)、`album_stats`(albumId 主键 viewCount/updatedAt/statVersion)、`visit_sessions`(albumId+visitorId 唯一、lastCountedAt)、`visit_requests`(requestId 唯一、counted、createdAt)、`outbox_stat_events`(ov→cn 回传队列)
|
||||
- [ ] 迁移走固化校对手决流程;14→N 表全 bin 校对验证;健康检查 + /health
|
||||
- [ ] Commit `feat(ov): ov schema, entry skeleton and compose profile`
|
||||
- [x] ov schema:`albums_ov`(cn 副本:publicToken/状态/开关/翻译 JSON/分类 JSON/banner 对象键/版本)、`photos_ov`(photoId/albumId/分类/宽高/objectKeys/likeTotal/countVersion/visibility/版本/publishedAt)、`sync_inbox`(eventId 唯一、entityType/entityId/entityVersion、tombstone、payload、receivedAt/appliedAt)、`sync_state`(entityType+entityId → appliedVersion,水位表 per source)、`like_batches`(photoId+sessionId+batchId 唯一、delta、createdAt + 索引/清理窗口)、`album_stats`(albumId 主键 viewCount/updatedAt/statVersion)、`visit_sessions`(albumId+visitorId 唯一、lastCountedAt)、`visit_requests`(requestId 唯一、counted、createdAt)、`outbox_stat_events`(ov→cn 回传队列)
|
||||
- [x] 迁移走固化校对手决流程;14→N 表全 bin 校对验证;健康检查 + /health
|
||||
- [x] Commit `feat(ov): ov schema, entry skeleton and compose profile`
|
||||
|
||||
## Task 3: cn 事件补全 + sync 发送器(cn→ov)
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ services:
|
||||
until mc alias set dev http://minio:9000 photo-dev photo-dev-secret; do sleep 1; done;
|
||||
mc mb -p dev/photo-dev || true;
|
||||
mc anonymous set none dev/photo-dev;"
|
||||
# —— ov 域(P2 双地域仿真,profile 隔离:仅 `docker compose --profile ov up -d`
|
||||
# 时启动;cn 侧默认 up 不受影响)。主机端口 3309/6381:3307/3308 已被本机
|
||||
# 其它 compose 项目(yunke/hesu)占用(用户决策 2026-09-18)。
|
||||
mysql-ov:
|
||||
image: mysql:8.4
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: photo_ov
|
||||
ports: ["127.0.0.1:3309:3306"]
|
||||
volumes: [mysql-ov-data:/var/lib/mysql]
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
|
||||
interval: 5s
|
||||
retries: 12
|
||||
profiles: ["ov"]
|
||||
redis-ov:
|
||||
image: redis:7-alpine
|
||||
ports: ["127.0.0.1:6381:6379"]
|
||||
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 }
|
||||
profiles: ["ov"]
|
||||
volumes:
|
||||
mysql-cn-data:
|
||||
mysql-ov-data:
|
||||
minio-data:
|
||||
|
||||
+4
-1
@@ -10,9 +10,12 @@
|
||||
"test": "turbo run test",
|
||||
"build": "turbo run build",
|
||||
"db:migrate": "dotenv -e .env -- pnpm --filter @photo/db-cn exec prisma migrate dev",
|
||||
"db:generate": "dotenv -e .env -- pnpm --filter @photo/db-cn exec prisma generate",
|
||||
"db-ov:migrate": "dotenv -e .env -- pnpm --filter @photo/db-ov exec prisma migrate dev",
|
||||
"db:generate": "dotenv -e .env -- pnpm --filter @photo/db-cn --filter @photo/db-ov exec prisma generate",
|
||||
"db-ov:generate": "dotenv -e .env -- pnpm --filter @photo/db-ov exec prisma generate",
|
||||
"db:studio": "dotenv -e .env -- pnpm --filter @photo/db-cn exec prisma studio",
|
||||
"dev:api": "dotenv -e .env -- pnpm --filter @photo/api-cn start",
|
||||
"dev:ov": "dotenv -e .env -- pnpm --filter @photo/api-cn start:ov",
|
||||
"dev:worker": "dotenv -e .env -- pnpm --filter @photo/worker start",
|
||||
"e2e": "turbo run test:e2e"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@photo/db-ov",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "OV Prisma schema + shared client(api ov 入口与 sync 服务共用;MySQL 8.4 utf8mb4_0900_bin,迁移经根 db-ov:* 脚本注入 .env)",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "prisma generate",
|
||||
"migrate": "prisma migrate dev",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prisma": "^5.22.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "prisma/schema.prisma"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// packages/db-ov/src/index.ts —— OV 侧唯一 Prisma client 出口。
|
||||
// 与 @photo/db-cn 镜像但一处刻意不同:pnpm 把两个包的 @prisma/client 提升为
|
||||
// 同一 store 实例,db-cn 走默认输出 node_modules/.prisma/client,若 ov 也用
|
||||
// 默认输出会互相覆盖(先实测 EPERM,即便成功也是"后 generate 者串库"),故
|
||||
// ov 客户端生成到包内 ../generated/client(见 schema.prisma generator.output,
|
||||
// gitignore 工件,`pnpm db:generate` / `db-ov:generate` 产出),此处转出口。
|
||||
// 消费方(api ov 入口、sync 服务、jest 映射)只见 @photo/db-ov。commonjs 包。
|
||||
export { PrismaClient } from '../generated/client';
|
||||
export type { Prisma } from '../generated/client';
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+16
@@ -258,6 +258,19 @@ importers:
|
||||
specifier: ^5.6.0
|
||||
version: 5.9.3
|
||||
|
||||
packages/db-ov:
|
||||
dependencies:
|
||||
'@prisma/client':
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0(prisma@5.22.0)
|
||||
devDependencies:
|
||||
prisma:
|
||||
specifier: ^5.22.0
|
||||
version: 5.22.0
|
||||
typescript:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.3
|
||||
|
||||
packages/storage:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
@@ -309,6 +322,9 @@ importers:
|
||||
'@photo/db-cn':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-cn
|
||||
'@photo/db-ov':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/db-ov
|
||||
'@photo/storage':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/storage
|
||||
|
||||
@@ -27,6 +27,8 @@ const config: Config = {
|
||||
moduleNameMapper: {
|
||||
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
|
||||
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
|
||||
// P2 Task 2 起:@photo/db-ov 同样映射(test/reset-ov.ts 与后续 ov 模块)。
|
||||
'^@photo/db-ov$': '<rootDir>/../../packages/db-ov/src/index.ts',
|
||||
// Task 18 起:@photo/storage 同样映射到 TS 源码(其依赖 @aws-sdk/* 与
|
||||
// @photo/contracts 分别从 storage 包 node_modules / 上面的映射解析)。
|
||||
'^@photo/storage$': '<rootDir>/../../packages/storage/src/index.ts',
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:watch": "tsc -p tsconfig.build.json --watch",
|
||||
"start": "tsc -p tsconfig.build.json && node dist/main-cn.js",
|
||||
"start:ov": "tsc -p tsconfig.build.json && node dist/main-ov.js",
|
||||
"seed": "tsc -p tsconfig.build.json && node dist/scripts/seed-admin.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
@@ -20,6 +21,7 @@
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"@photo/contracts": "workspace:*",
|
||||
"@photo/db-cn": "workspace:*",
|
||||
"@photo/db-ov": "workspace:*",
|
||||
"@photo/storage": "workspace:^",
|
||||
"bullmq": "^6.3.6",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { AlbumsModule } from './albums/albums.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
@@ -23,10 +22,11 @@ import { HealthController } from './health.controller';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { PublicModule } from './public/public.module';
|
||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { UploadsModule } from './uploads/uploads.module';
|
||||
import { resolveLogLevel } from './shared/log-level';
|
||||
import type { ServerRequestWithId } from './shared/request-with-id';
|
||||
// P2 Task 2:logger 装配选项(genReqId/redact/level)收敛至 shared,与 ov 入口
|
||||
// 共用单一来源;此处仅消费,行为不变(P1 e2e 套件守护)。
|
||||
import { createLoggerOptions } from './shared/logger-options';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,28 +44,7 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
||||
PhotosModule,
|
||||
// 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。
|
||||
AdminModule,
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
|
||||
genReqId: (req: IncomingMessage, res: ServerResponse) =>
|
||||
resolveRequestId(req as ServerRequestWithId, res),
|
||||
redact: {
|
||||
// 授权头 + Cookie(会话凭据,Task 7 遗留项③):请求/响应两侧都打码,
|
||||
// 防止日志聚合层留存可重放的凭据。
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers.authorization',
|
||||
'res.headers.set-cookie',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
// Task 7 遗留项⑤:非法 LOG_LEVEL 在模块装配期(即启动期)快速失败,
|
||||
// 而不是让 pino 首次写日志时才炸或静默吞掉输出。
|
||||
level: resolveLogLevel(process.env.LOG_LEVEL),
|
||||
},
|
||||
}),
|
||||
LoggerModule.forRoot(createLoggerOptions()),
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// services/api/src/app-ov.module.ts —— OV(海外观众)部署根模块(P2 Task 2 骨架)。
|
||||
// 与 app-cn.module.ts 同一管线骨架(日志 + request-id + 全局异常过滤器 + 全局
|
||||
// 校验管道 + /v1/health),但**显式挑选** ov 角色模块:cn 专属域(auth/albums/
|
||||
// uploads/photos/admin——写路径与 MinIO 上传)不装配;OvPrismaService、公开读、
|
||||
// sync-inbox、likes/visits、WS 网关自 Task 3+ 起逐个加入(本任务仅骨架,
|
||||
// feature modules 在后续任务装配)。cn 模块保持原样,两入口互不影响。
|
||||
import {
|
||||
type MiddlewareConsumer,
|
||||
Module,
|
||||
type NestModule,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { HealthController } from './health.controller';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { createLoggerOptions } from './shared/logger-options';
|
||||
|
||||
@Module({
|
||||
imports: [LoggerModule.forRoot(createLoggerOptions())],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
useFactory: () => new ValidationPipe({ whitelist: true }),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppOvModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer.apply(RequestIdMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// services/api/src/main-ov.ts —— ov-app 部署入口(P2 Task 2 骨架)。与 main-cn
|
||||
// 同库双入口:同一 services/api 代码库按部署角色装配(见 app-ov.module.ts)。
|
||||
// 复用 configureApp(全局 v1 前缀 / x-powered-by 隐藏 / CORS)——契约「路径前缀
|
||||
// 不变,部署域名区分」,ov 观众端(viewer :5175,P2 Task 10 切换 VITE_API_BASE
|
||||
// 指向本入口)与 cn viewer 共用 CORS_ORIGINS env,无需 ov 专属配置。env 一律
|
||||
// 来自进程环境(根 dotenv-cli 注入)。
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AppOvModule } from './app-ov.module';
|
||||
import { configureApp } from './shared/configure-app';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||
bufferLogs: true,
|
||||
});
|
||||
app.useLogger(app.get(Logger));
|
||||
configureApp(app);
|
||||
const port = Number.parseInt(process.env.PORT_OV ?? '', 10) || 8081;
|
||||
await app.listen(port);
|
||||
app.get(Logger).log(`api-ov listening on port ${port}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,33 @@
|
||||
// services/api/src/shared/logger-options.ts —— LoggerModule 装配选项单一来源
|
||||
// (P2 Task 2 抽取:此前内联在 app-cn.module.ts,ov 入口需要完全相同的
|
||||
// request-id 解析与凭据打码配置,复制一份即两处漂移点,故收敛于此;cn 模块
|
||||
// 改为消费本函数,行为不变,由 P1 e2e 套件守护)。
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { resolveLogLevel } from './log-level';
|
||||
import { resolveRequestId } from '../request-id.middleware';
|
||||
import type { ServerRequestWithId } from './request-with-id';
|
||||
|
||||
export function createLoggerOptions() {
|
||||
return {
|
||||
pinoHttp: {
|
||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||
// 其次复用已生成的 req.id,否则生成 UUID;并回写响应头。
|
||||
genReqId: (req: IncomingMessage, res: ServerResponse) =>
|
||||
resolveRequestId(req as ServerRequestWithId, res),
|
||||
redact: {
|
||||
// 授权头 + Cookie(会话凭据,Task 7 遗留项③):请求/响应两侧都打码,
|
||||
// 防止日志聚合层留存可重放的凭据。
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'res.headers.authorization',
|
||||
'res.headers.set-cookie',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
// Task 7 遗留项⑤:非法 LOG_LEVEL 在模块装配期(即启动期)快速失败,
|
||||
// 而不是让 pino 首次写日志时才炸或静默吞掉输出。
|
||||
level: resolveLogLevel(process.env.LOG_LEVEL),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// services/api/test/reset-ov.ts —— ov 库(photo_ov)e2e 隔离工具(P2 Task 2)。
|
||||
// 与 reset.ts 镜像但独立成文件:两库连接串不同(DATABASE_URL_OV vs _CN),
|
||||
// RESET_TABLES 互不重叠,cn/ov spec 各自 beforeAll 调用,互不清对方的数据。
|
||||
// 与 reset.ts 的两点差异:
|
||||
// 1. 无 MinIO 清理——ov 侧 P2 只读对象(签名 URL 指向同一 MinIO,上传仍归
|
||||
// cn 侧 e2e 的前缀清理负责);
|
||||
// 2. truncate-if-exists——经 information_schema 取「库中实际存在的待重置表」
|
||||
// 再逐表 TRUNCATE(MySQL 无 TRUNCATE IF EXISTS):首次迁移前的空库上
|
||||
// 调用是 no-op 而非报错,ov 库生命周期独立于 cn 库(compose profile ov)。
|
||||
// ov 全库扁平字符串外键、无 FK 约束,FOREIGN_KEY_CHECKS 包裹仅沿用 reset.ts
|
||||
// 的防御性约定(后续如引入约束同样安全);表序仍按逻辑依赖排列(子表在前)。
|
||||
import { PrismaClient } from '@photo/db-ov';
|
||||
|
||||
export const RESET_TABLES_OV: readonly string[] = [
|
||||
// 统计回传队列与互动流水(Task 7-9 的幂等锚点)。
|
||||
'outbox_stat_events',
|
||||
'visit_requests',
|
||||
'visit_sessions',
|
||||
'like_batches',
|
||||
'album_stats',
|
||||
// 同步账本(Task 4 inbox 消费)。
|
||||
'sync_inbox',
|
||||
'sync_state',
|
||||
// 副本域(photos 先于 albums)。
|
||||
'photos_ov',
|
||||
'albums_ov',
|
||||
];
|
||||
|
||||
/** TRUNCATE photo_ov 中 RESET_TABLES_OV 里**实际存在**的表(空库安全)。 */
|
||||
export async function resetOvDb(): Promise<void> {
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const placeholders = RESET_TABLES_OV.map(() => '?').join(', ');
|
||||
const existing = await prisma.$queryRawUnsafe<
|
||||
{ tableName: string }[]
|
||||
>(
|
||||
`SELECT TABLE_NAME AS tableName FROM information_schema.TABLES ` +
|
||||
`WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (${placeholders})`,
|
||||
...RESET_TABLES_OV,
|
||||
);
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||||
try {
|
||||
for (const { tableName } of existing) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE \`${tableName}\``);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user