diff --git a/.env.example b/.env.example index 93ce703..2929cd4 100644 --- a/.env.example +++ b/.env.example @@ -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+ 落地(当前为前瞻登记)。 diff --git a/.gitignore b/.gitignore index 0223e66..59e2bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/plans/2026-09-18-P2-双地域仿真与互动.md b/docs/plans/2026-09-18-P2-双地域仿真与互动.md index 841d5da..6835e41 100644 --- a/docs/plans/2026-09-18-P2-双地域仿真与互动.md +++ b/docs/plans/2026-09-18-P2-双地域仿真与互动.md @@ -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) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 081deee..f20f394 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -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: diff --git a/package.json b/package.json index 308aab8..6431157 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/packages/db-ov/package.json b/packages/db-ov/package.json new file mode 100644 index 0000000..542eccc --- /dev/null +++ b/packages/db-ov/package.json @@ -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" + } +} diff --git a/packages/db-ov/prisma/migrations/20260918060102_init_ov/migration.sql b/packages/db-ov/prisma/migrations/20260918060102_init_ov/migration.sql new file mode 100644 index 0000000..b2ef5f2 --- /dev/null +++ b/packages/db-ov/prisma/migrations/20260918060102_init_ov/migration.sql @@ -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; diff --git a/packages/db-ov/prisma/migrations/migration_lock.toml b/packages/db-ov/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..e5a788a --- /dev/null +++ b/packages/db-ov/prisma/migrations/migration_lock.toml @@ -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" \ No newline at end of file diff --git a/packages/db-ov/prisma/schema.prisma b/packages/db-ov/prisma/schema.prisma new file mode 100644 index 0000000..f9544fe --- /dev/null +++ b/packages/db-ov/prisma/schema.prisma @@ -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]) +} diff --git a/packages/db-ov/src/index.ts b/packages/db-ov/src/index.ts new file mode 100644 index 0000000..d789082 --- /dev/null +++ b/packages/db-ov/src/index.ts @@ -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'; diff --git a/packages/db-ov/tsconfig.json b/packages/db-ov/tsconfig.json new file mode 100644 index 0000000..8e9c91c --- /dev/null +++ b/packages/db-ov/tsconfig.json @@ -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"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c78ce72..ab0b376 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/services/api/jest.config.ts b/services/api/jest.config.ts index b9b675b..071f7c2 100644 --- a/services/api/jest.config.ts +++ b/services/api/jest.config.ts @@ -27,6 +27,8 @@ const config: Config = { moduleNameMapper: { '^@photo/contracts$': '/../../packages/contracts/src/index.ts', '^@photo/db-cn$': '/../../packages/db-cn/src/index.ts', + // P2 Task 2 起:@photo/db-ov 同样映射(test/reset-ov.ts 与后续 ov 模块)。 + '^@photo/db-ov$': '/../../packages/db-ov/src/index.ts', // Task 18 起:@photo/storage 同样映射到 TS 源码(其依赖 @aws-sdk/* 与 // @photo/contracts 分别从 storage 包 node_modules / 上面的映射解析)。 '^@photo/storage$': '/../../packages/storage/src/index.ts', diff --git a/services/api/package.json b/services/api/package.json index 9f15bc1..810257a 100644 --- a/services/api/package.json +++ b/services/api/package.json @@ -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", diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 9124618..4b5bf5d 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -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: [ diff --git a/services/api/src/app-ov.module.ts b/services/api/src/app-ov.module.ts new file mode 100644 index 0000000..af17fb4 --- /dev/null +++ b/services/api/src/app-ov.module.ts @@ -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('*'); + } +} diff --git a/services/api/src/main-ov.ts b/services/api/src/main-ov.ts new file mode 100644 index 0000000..3d174ef --- /dev/null +++ b/services/api/src/main-ov.ts @@ -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 { + const app = await NestFactory.create(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(); diff --git a/services/api/src/shared/logger-options.ts b/services/api/src/shared/logger-options.ts new file mode 100644 index 0000000..444236f --- /dev/null +++ b/services/api/src/shared/logger-options.ts @@ -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), + }, + }; +} diff --git a/services/api/test/reset-ov.ts b/services/api/test/reset-ov.ts new file mode 100644 index 0000000..368ef1b --- /dev/null +++ b/services/api/test/reset-ov.ts @@ -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 { + 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(); + } +}