Files
photo-live/docs/plans/2026-09-17-P1a-后端核心链路.md
T
2026-09-18 00:14:23 +08:00

653 lines
41 KiB
Markdown
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.
# P1a 后端核心链路 实施计划
> **✅ 阶段已完成(2026-09-18,tag `p1a`,基线 d973c71..8a09001)。** 完成状态的权威记录是 [docs/plans/progress/P1a-验收记录.md](./progress/P1a-验收记录.md);本文档中 Task 13-26 的部分步骤勾选框未逐项回填(26/26 任务全部交付,195 个测试全绿),以验收记录为准。P1b 修缮清单(源自终审):generic NOT_FOUND 错误码、x-powered-by 关闭、parseCursor/parseLimit 移至 shared/、compose 端口绑定 127.0.0.1。
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 搭建 monorepo 工程底座并交付国内单地域后端核心链路:密码登录 → 建相册(多语言/成员/分类)→ 上传会话(幂等、临时凭证)→ 完成核验 → worker 图片处理 → 自动发布 → 公开读 API(游标分页、语言回退、短期签名 URL),全程 API 级 E2E 验证。
**Architecture:** NestJS 单代码库(P1a 仅 cn-app 部署入口)+ Prisma(MySQL) + BullMQ/Redis + sharp worker;对象存储经 StoragePort 接口访问 MinIO(生产替换 OSS 适配器);契约(OpenAPI + 事件 + 错误码)在 packages/contracts 单源生成类型。对齐《实施总纲》P1 与技术实施方案 §3-§7。
**Tech Stack:** Node ≥20(实际环境 v24.13.0)· pnpm 10(实际 10.7.1,packageManager pin 对齐实际环境——Task 3 修正;pnpm 10 默认拦截依赖生命周期脚本,sharp 0.33+ 预编译可选依赖不受影响,Prisma client 经显式 db:generate 生成)· Turborepo · NestJS 10 · Prisma ^5.18 (MySQL 8.4 容器;若在 Node 24 出现引擎兼容问题则升 ^6) · BullMQ 5 · Redis 7 · @aws-sdk/client-s3(MinIO S3 兼容)· sharp · @node-rs/argon2 · Jest + supertest · OpenAPI 3.1 + openapi-typescript
**对应验收:** A01(API 级)、A02、A03、A06、A13(凭证设计级);A07 后端部分。P1a 完成后另册编写 P1b(viewer + admin 最小页)计划。
---
## 前置确认(用户关口,开工前登记)
- [ ] D01 首发语言:按建议值 zh-CN、en 开发(不确认则默认执行)
- [x] D12 生产 MySQL 版本:已确认 8.0+(2026-09-17);本地容器用 MySQL 8.4 对齐,迁移仅使用 8.0 兼容特性。**注意**:库级默认校对 `utf8mb4_0900_bin` 需 MySQL ≥ 8.0.30——部署前在验收记录登记两台 ECS 的精确小版本;若低于 8.0.30 则回退 `utf8mb4_bin`(同样大小写敏感)并重跑语义测试。
- [x] 本机环境:已验证(node v24.13.0、pnpm 10.7.1、Docker 29.2.1 + Compose v5.0.2;3306/6379 与其他项目容器的冲突已由用户决策解决——停用 x-server-mysql/redis 并取消自启)
---
## 里程碑 M0 工程底座(T02)
### Task 1: git 仓库与目录骨架
**Files:**
- Create: `.gitignore`, `.editorconfig`, `.nvmrc`, `README.md`, `docs/plans/progress/.gitkeep`
- [x] **Step 1: 初始化仓库与目录**(完成:提交 d973c71,2026-09-17)
```bash
cd /d/project/photo
git init -b main
mkdir -p apps packages services infra docs/plans/progress
```
`.gitignore`:
```gitignore
node_modules/
dist/
.turbo/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store
```
`.nvmrc`:`20`
- [x] **Step 2: 首次提交**(完成:d973c71,含全部 docs/,双审通过)
```bash
git add -A && git commit -m "chore: init repository skeleton"
```
Expected: 首个提交生成,主干 main。
### Task 2: pnpm workspace + Turborepo
**Files:**
- Create: `package.json`, `pnpm-workspace.yaml`, `turbo.json`
- Create: `.gitattributes`、`.gitignore`/`.editorconfig` 增补(Task 1 质量审查遗留项:`*.tsbuildinfo` 忽略、`* text=auto eol=lf`、`[*.md] trim_trailing_whitespace=false`、`Thumbs.db`/`Desktop.ini`)
> Task 4 策略预定:`src/generated/` 生成类型**提交入库**(消费方免跑 gen,契约变更加 sha 可审)。
- [ ] **Step 1: 写入三个根配置**
`pnpm-workspace.yaml`:
```yaml
packages:
- apps/*
- packages/*
- services/*
```
`package.json`:
```json
{
"name": "photo-live",
"private": true,
"packageManager": "pnpm@9.12.0",
"engines": { "node": ">=20" },
"scripts": {
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"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:studio": "dotenv -e .env -- pnpm --filter @photo/db-cn exec prisma studio",
"dev:api": "dotenv -e .env -- pnpm --filter @photo/api-cn start",
"dev:worker": "dotenv -e .env -- pnpm --filter @photo/worker start",
"e2e": "turbo run test:e2e"
},
"devDependencies": {
"turbo": "^2.3.0",
"typescript": "^5.6.0",
"prettier": "^3.3.0",
"dotenv-cli": "^7.4.0",
"eslint": "^9.0.0",
"typescript-eslint": "^8.0.0"
}
}
```
`turbo.json`:
```json
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"lint": {},
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] },
"test:e2e": { "dependsOn": ["^build"] }
}
}
```
- [ ] **Step 2: 验证 workspace**
```bash
pnpm install
```
Expected: lockfile 生成,无 workspace 报错。
- [x] **Step 3: Commit** `chore: pnpm workspace and turbo pipeline`(完成:68a7d6a,含质量审查遗留项 .gitattributes/.gitignore/.editorconfig 增补;规格审查通过)
### Task 3: docker-compose 基础设施(cn 侧)
**Files:**
- Create: `infra/docker-compose.yml`, `.env.example`
- Modify: `package.json` packageManager → `pnpm@10.7.1`(对齐实际环境,Task 2 审查遗留)
- [ ] **Step 1: 编写 compose(P1a 仅 cn profile)**
```yaml
# infra/docker-compose.yml
name: photo-live
services:
mysql-cn:
image: mysql:8.4
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_0900_bin
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: photo_cn
ports: ["3306:3306"]
volumes: [mysql-cn-data:/var/lib/mysql]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
retries: 12
redis-cn:
image: redis:7-alpine
ports: ["6379:6379"]
healthcheck: { test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 12 }
minio:
image: minio/minio:latest
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: photo-dev
MINIO_ROOT_PASSWORD: photo-dev-secret
ports: ["9000:9000", "9001:9001"]
volumes: [minio-data:/data]
minio-init:
image: minio/mc:latest
depends_on: { minio: { condition: service_started } }
entrypoint: >
/bin/sh -c "
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;"
volumes:
mysql-cn-data:
minio-data:
```
说明:collation 取 `utf8mb4_0900_bin`(大小写敏感、二进制安全)作为**库级默认**,直接化解 token 大小写语义差异(总纲 PG→MySQL 复核项);业务文本列如需不区分大小写再按列显式设定。本机端口冲突(3306/6379/9000/9001 已被占用,Windows 常见本机 MySQL 服务)时改宿主侧映射并同步 `.env`。
`.env.example`(仓库根):
```env
DATABASE_URL_CN=mysql://root:root@localhost:3306/photo_cn?connection_limit=10
REDIS_URL_CN=redis://localhost:6379
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=photo-dev
S3_SECRET_KEY=photo-dev-secret
S3_BUCKET=photo-dev
SIGNED_URL_TTL_SECONDS=900
AUTH_SESSION_TTL_SECONDS=2592000
UPLOAD_MAX_BYTES=52428800
```
- [x] **Step 2: 启动并验证**(完成:全部 healthy;collation=utf8mb4_0900_bin;photo_cn 库与 photo-dev bucket 就绪;**偏差登记**:minio 镜像改用 quay.io/minio/*(Docker Hub 官方仓库已删除),P2 ov 侧沿用)
Expected: mysql-cn / redis-cn / minio / minio-init 均 healthy 或 exited(0)(init 为一次性任务)。
- [x] **Step 3: Commit** `chore(infra): cn-side mysql/redis/minio compose`(完成:538fdd4,含 packageManager→pnpm@10.7.1 对齐)
---
## 里程碑 M1 契约(packages/contracts)
### Task 4: contracts 包骨架 + 错误码 + OpenAPI 基座
**Files:**
- Create: `packages/contracts/package.json`, `packages/contracts/tsconfig.json`, `packages/contracts/openapi.yaml`, `packages/contracts/src/errors.ts`, `packages/contracts/src/index.ts`
- [ ] **Step 1: 包配置**
`package.json`(name `@photo/contracts`,scripts: `gen`、`lint:api`、`build`、`typecheck`、`test`)依赖 `openapi-typescript@^7`、`@redocly/cli@^1` 作为 devDeps;`src/index.ts` 导出 errors 与生成类型。
- [ ] **Step 2: 错误码(稳定 code,客户端按语言映射文案)**
```ts
// packages/contracts/src/errors.ts
export const ErrorCodes = {
AUTH_INVALID_CREDENTIALS: 'AUTH_INVALID_CREDENTIALS',
AUTH_TOKEN_EXPIRED: 'AUTH_TOKEN_EXPIRED',
AUTH_FORBIDDEN: 'AUTH_FORBIDDEN',
ALBUM_NOT_FOUND: 'ALBUM_NOT_FOUND',
ALBUM_NOT_PUBLIC: 'ALBUM_NOT_PUBLIC',
MEMBER_CONFLICT: 'MEMBER_CONFLICT',
ALBUM_UPLOAD_DISABLED: 'ALBUM_UPLOAD_DISABLED',
UPLOAD_SESSION_CONFLICT: 'UPLOAD_SESSION_CONFLICT',
UPLOAD_VERIFY_FAILED: 'UPLOAD_VERIFY_FAILED',
PHOTO_NOT_FOUND: 'PHOTO_NOT_FOUND',
PHOTO_NOT_PUBLIC: 'PHOTO_NOT_PUBLIC',
VALIDATION_FAILED: 'VALIDATION_FAILED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
} as const;
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];
```
- [ ] **Step 3: OpenAPI 基座**(3.1;`info.title=photo-live`;servers `/v1`;components: `Error{code,message,requestId}`、`CursorPage{items,nextCursor}`;securitySchemes: bearerAuth)。路径骨架先落 auth 三条 + 错误响应复用。
- [ ] **Step 4: 类型生成脚本验证**
`pnpm --filter @photo/contracts gen && pnpm --filter @photo/contracts lint:api`
Expected: `src/generated/openapi.d.ts` 生成;redocly 0 错误。
- [ ] **Step 5: Commit** `feat(contracts): openapi base, error codes, type generation`
### Task 5: 业务端点契约(albums/members/categories/uploads/photos/public)
**Files:**
- Modify: `packages/contracts/openapi.yaml`
- Modify: `packages/contracts/tsconfig.json`(exclude src/__tests__,Task 4 审查遗留)、`packages/contracts/package.json`(exports 补 `./package.json`)、根 `package.json` engines(node >=22.12)与 `.nvmrc`(22)——ESM 策略 (a):contracts ESM-only + jest 源码映射(Node 20 已 EOL,2026-09 决策)
- [ ] **Step 1: 补全 P1a 路径与 schema**(字段以技术实施方案 §4/§10 为准)。附加要求(Task 4 质量审查遗留):列表响应一律经 allOf 消费 CursorPageMeta;消费后**删除 redocly.yaml 的 no-unused-components 豁免**;auth 三条路径补 400 ErrorResponse;保留 hasMore/nextCursor 成对填充约定:
- `POST /albums`(title 多语言必填、时间、时区默认 Asia/Shanghai、地点、开关默认值 upload/public=true、download=true、original=false、defaultLocale)
- `GET /albums`、`GET /albums/{id}`、`PATCH /albums/{id}`(仅创建者/管理员可写;开关字段独立)
- `POST/GET/DELETE /albums/{id}/members`;`POST /albums/{id}/categories` + `PATCH/DELETE /categories/{id}`
- `POST /uploads`:请求 `{albumId, categoryId?, clientTaskId, filename, bytes, contentType}`,响应 `{uploadId, photoId, objectKey, credential:{mode,url,method,headers,expiresAt}}`;`POST /uploads/{id}/renew`;`POST /uploads/{id}/complete` → `{photoId, processingState}`;`GET /uploads`
- `PATCH /photos/{id}`(`{visibility: hidden|published}`)、`DELETE /photos/{id}`(软删除)
- `GET /public/albums/{token}`( translations 按请求 locale 回退、categories、assets、`viewCount: null` 占位、timezone、startAt/endAt ISO)
- `GET /public/albums/{token}/photos`(query: category、locale、cursor、limit≤60;item 含 `photoId,width,height,thumb{url,expiresAt},browse{url,expiresAt},likeCount,publishedAt`)
- `GET /public/albums/{token}/photos/{photoId}`(单图定位;同时作为签名 URL 过期后的刷新入口)
- [x] **Step 2: 生成 + lint 通过**(同 Task 4 命令)(完成:22 操作;质量审查 3 Important + 7 Minor 修复于 f6448bd/b4ee92d,复核通过)
- [x] **Step 3: Commit** `feat(contracts): p1a endpoint contracts`(完成:1dae00e + f6448bd + b4ee92d)
### Task 6: 事件契约 + 用户确认点
**Files:**
- Create: `packages/contracts/src/events.ts`
- [ ] **Step 1: 事件类型**(对齐技术实施方案 §8.2;P1a 无消费者,事件仅持久化到 outbox,实际会产生 photo.published / photo.hidden / photo.deleted 三类)
```ts
export interface OutboxEvent<T = unknown> {
eventId: string; // cuid,持久唯一
type: 'photo.published' | 'photo.hidden' | 'photo.deleted' | 'photo.likes.updated' | 'album.updated';
albumId: string;
photoId?: string;
entityVersion: number; // 单调递增,P2 inbox 消费依据
payload: T;
occurredAt: string; // ISO UTC
}
```
- [x] **Step 2: 单测**:类型测试——合法 `OutboxEvent{type:'photo.published'}` 编译通过,非法 type 用 `@ts-expect-error` 验证编译期拒绝;**枚举同步测试**(Task 4 审查遗留):断言 `ErrorCode` 与 `components['schemas']['Error']['code']` 的 enum 双向可赋值。(完成:8/8 通过,两护栏经变异验证真实生效)
```bash
pnpm --filter @photo/contracts test
```
Expected: PASS。
- [x] **Step 3: ⏸ 用户确认点:请用户过目 openapi.yaml(接口形态、字段命名、分页方式)后再进入 M2。**(完成:用户于 2026-09-17 确认"确认,继续 M2")
- [x] **Step 4: Commit** `feat(contracts): outbox event contract types`(完成:f77ab5a)
---
## 里程碑 M2 服务骨架与数据层
### Task 7: NestJS api 骨架、测试与 Lint 工具链、env 加载约定
**Files:**
- Create: `services/api/package.json`(name `@photo/api-cn`)、`tsconfig.json`、`nest-cli.json`、`src/main-cn.ts`、`src/app-cn.module.ts`、`src/health.controller.ts`、`src/request-id.middleware.ts`、`src/all-exceptions.filter.ts`、`services/api/jest.config.ts`、`services/api/test/load-env.ts`、`services/api/test/reset.ts`、根 `eslint.config.js`
- [x] **Step 1: env 加载约定(全仓库唯一机制,解决 pnpm --filter cwd 在包目录的问题)**:根 `.env` 为唯一 env 文件。开发/迁移脚本经根 `dotenv-cli` 注入(Task 2 scripts 已配置);Jest 经 `services/api/test/load-env.ts`(`dotenv.config({ path: join(__dirname, '../../../.env') })`)作为 `setupFiles` 载入;运行时代码只读 `process.env`。(完成:2026-09-17)
- [x] **Step 2: 依赖与入口**:deps `@nestjs/common @nestjs/core @nestjs/platform-express nest-pino pino-http`(实际:`nest-pino` 上游已更名 `nestjs-pino`,取 ^4.6.1 兼容 Nest 10);devDeps `jest ts-jest @types/jest supertest @types/supertest @nestjs/testing`;scripts:`test`(`*.spec.ts` 单测)、`test:e2e`(`test/*.e2e-spec.ts`,前置 compose 依赖)、`typecheck`、`lint`、`start`。`test/reset.ts` 为 e2e 共用隔离工具:每个 e2e spec `beforeAll` 调用——truncate 全部 cn 表 + 删除 MinIO `photo-live/dev` 前缀对象(防止 spec 间唯一约束冲突与脏数据)。入口监听 `PORT`(默认 8080),`app.setGlobalPrefix('v1')`,注册 request-id 中间件(无则生成 cuid,挂 `res.setHeader('x-request-id')`)、全局异常过滤器(映射 `ErrorCodes`,响应 `{code,message,requestId}`,不泄露堆栈)、`/v1/health` 返回 `{status:'ok'}`。
- [x] **Step 3: 根 ESLint flat config**(eslint + typescript-eslint,覆盖 packages/services/apps),各包(contracts、storage、db-cn、api、worker)`lint` 脚本接 `eslint .`,确保 turbo 不静默跳过。ts-jest 保持类型检查模式(非 isolatedModules),使 `@ts-expect-error` 负向断言生效。(完成:contracts 与 api 已接入,storage/db-cn/worker 待各自任务建包时接入)
- [x] **Step 4: 启动验证**(完成:curl -i 见 x-request-id 响应头;单测 6/6、e2e 3/3、根 lint/typecheck/test/build/e2e 全绿)
```bash
cp .env.example .env
pnpm lint && pnpm typecheck
pnpm dev:api &
until curl -s localhost:8080/v1/health; do sleep 1; done # 等待启动 → {"status":"ok"}
```
- [x] **Step 5: Commit** `feat(api): cn-app skeleton, toolchain, env loading convention`
### Task 8: Prisma cn schema —— 身份与审计域
**Files:**
- Create: `packages/db-cn/package.json`(name `@photo/db-cn`;deps `@prisma/client`,devDeps `prisma`;api 与 worker 共享此 client,解决 worker 访问 cn Prisma 的归属问题)、`packages/db-cn/prisma/schema.prisma`(cn schema)、`packages/db-cn/src/index.ts`(导出 PrismaClient)、`services/api/src/prisma/prisma.module.ts`(封装 @photo/db-cn 供注入)
- Test: `services/api/test/db.semantics.e2e-spec.ts`
- [ ] **Step 1: 模型**(MySQL;时间 `@db.DateTime(6)`;ID cuid;账号字段唯一;连接池上限复核项由 `DATABASE_URL_CN` 的 `?connection_limit=10` 承担,PrismaService 不另行放大):
```prisma
model users {
id String @id @default(cuid())
account String @unique @db.VarChar(64)
passwordHash String @db.VarChar(255)
displayName String @db.VarChar(128)
role String @default("staff") // staff|admin(公司角色;相册角色另表)
sourceStatus String @default("local") // local|active|disabled(P4a 同步用)
localLocked Boolean @default(false)
credentialStatus String @default("active")
locale String? @db.VarChar(16)
createdAt DateTime @default(now()) @db.DateTime(6)
sessions sessions[]
auditLogs audit_logs[]
}
model sessions {
id String @id @default(cuid())
userId String
tokenHash String @unique @db.VarChar(64)
expiresAt DateTime @db.DateTime(6) // 长效会话期限(默认 30 天,AUTH_SESSION_TTL_SECONDS 可调;不做短时效 access/refresh 双层——用户决策 2026-09-17)
revokedAt DateTime? @db.DateTime(6) // 注销/停用撤销
user users @relation(fields: [userId], references: [id])
}
model audit_logs {
id String @id @default(cuid())
actorId String?
action String @db.VarChar(64)
resourceId String? @db.VarChar(64)
metadata Json?
createdAt DateTime @default(now()) @db.DateTime(6)
actor users? @relation(fields: [actorId], references: [id])
}
```
- [x] **Step 2: 迁移**(完成:20260917114313_init_identity 已应用。**重要发现 A**:Prisma 生成的 MySQL DDL 每表硬编码 `utf8mb4_unicode_ci`,覆盖库级 `utf8mb4_0900_bin`——已手工改 migration.sql 为 bin 校对并经 information_schema 验证;**Task 9/10 每张新表必须重复:`--create-only` 生成→改 COLLATE→应用**。**重要发现 B**:`pnpm db:migrate -- --name x` 会转发字面 `--` 使 prisma 交互挂起——正确语法 `pnpm db:migrate --name xxx`)
Expected: migration SQL 生成并应用。
- [x] **Step 3: 写语义复核 E2E 测试**(完成:7/7 通过——UTC DateTime(6) 毫秒往返、bin 校对大小写敏感查找、audit Json+actor 关系、带会话删用户被 P2003 拒绝;reset.ts 已实现真实 truncate)
- [x] **Step 4: Commit** `feat(api): identity and audit schema with mysql semantics tests`(完成:2801e4f)
### Task 9: Prisma cn schema —— 相册域
**Files:**
- Modify: `packages/db-cn/prisma/schema.prisma`
- [x] **Step 1: 模型**(完成:六模型入 schema,相册域全量扁平字符串外键、无 Prisma relation;Task 10 照片域沿用):`albums`(publicToken `@unique @db.VarChar(32)`、ownerId、startAt/endAt、timezone、status active|offline、defaultLocale、uploadEnabled/publicEnabled/downloadEnabled/originalDownloadEnabled 默认 true/true/true/false、watermarkVersion 默认 0)、`album_translations`(`@@unique([albumId, locale])`,title/description/venue/address)、`album_members`(`@@unique([albumId, userId])`,role owner|collaborator,status)、`categories`(albumId、sortOrder)+ `category_translations`(`@@unique([categoryId, locale])`)、`album_assets`(kind banner|cover|logo、locale?、objectKey)。
- [x] **Step 2: 迁移**(完成:20260917120158_album_domain。流程固化:`pnpm db:migrate --create-only --name album_domain` 生成→sed 改 6 表 COLLATE→`pnpm db:migrate`(无参,非交互应用 pending)→information_schema 验证 10 张域表全 utf8mb4_0900_bin。补充发现 C:`pnpm db:migrate --create-only --name x` 参数可直传,无需进包目录)
- [x] **Step 3: 语义冒烟 + Commit**(完成:reset.ts 增 6 表(翻译/资产/成员在前、albums 在后);db.semantics.e2e-spec.ts 增相册用例——(albumId, locale) 复合唯一双向命中 + 同键重复与 publicToken 重复均 P2002;e2e 8/8、lint/typecheck/test 全绿)
### Task 10: Prisma cn schema —— 照片与上传域 + outbox
**Files:**
- Modify: `packages/db-cn/prisma/schema.prisma`
- [ ] **Step 1: 模型**:
```prisma
model photos {
id String @id @default(cuid())
albumId String
uploaderId String
categoryId String?
objectKey String @unique @db.VarChar(255) // originals 正式键
bytes Int @default(0)
width Int?
height Int?
shotAt DateTime? @db.DateTime(6)
uploadedAt DateTime? @db.DateTime(6)
publishedAt DateTime? @db.DateTime(6)
processingState String @default("pending_upload") // pending_upload|uploaded|processing|ready|processing_failed
errorCode String? @db.VarChar(64)
visibility String @default("unpublished") // unpublished|published|hidden|deleted
likeCount Int @default(0) // P2 起为统计副本
entityVersion Int @default(1)
@@index([albumId, visibility, publishedAt, id])
@@index([albumId, uploaderId, uploadedAt])
}
model photo_variants {
photoId String
variant String // thumb|browse
objectKey String @unique @db.VarChar(255)
bytes Int
version Int @default(1)
width Int?
height Int?
@@unique([photoId, variant])
}
model upload_sessions {
id String @id @default(cuid())
userId String
albumId String
clientTaskId String @db.VarChar(128)
photoId String @unique
objectKey String @unique @db.VarChar(255) // staging 键
expectedBytes Int
contentType String @db.VarChar(128)
multipartId String?
state String @default("pending") // pending|confirmed|cancelled|expired(对齐契约 UploadSessionState;核验失败是请求级错误不改会话态,expired 由 expiresAt 判定或 sweep 置位)
expiresAt DateTime @db.DateTime(6)
createdAt DateTime @default(now()) @db.DateTime(6)
@@unique([userId, clientTaskId]) // 幂等锚点(A03)
}
model photo_jobs {
id String @id @default(cuid())
photoId String
type String @default("process")
state String @default("pending") // pending|active|done|failed
attempt Int @default(0)
errorCode String? @db.VarChar(64)
nextRetryAt DateTime? @db.DateTime(6)
updatedAt DateTime @updatedAt @db.DateTime(6)
@@unique([photoId, type])
@@index([state, nextRetryAt])
}
model outbox_events {
id String @id @default(cuid())
type String @db.VarChar(64)
albumId String @db.VarChar(64)
photoId String? @db.VarChar(64)
entityVersion Int
payload Json
occurredAt DateTime @default(now()) @db.DateTime(6)
deliveredAt DateTime? @db.DateTime(6)
@@index([deliveredAt, occurredAt])
}
```
- [ ] **Step 2: 迁移** `pnpm db:migrate --name photo_upload_domain`(同 Task 9:`--create-only` → 逐表改 COLLATE `utf8mb4_0900_bin` → 应用 → information_schema 验证);Step 3: Commit `feat(api): photo, upload, outbox schema`
### Task 11: StoragePort 接口 + MinIO 适配器
**Files:**
- Create: `packages/storage/package.json`、`src/port.ts`、`src/minio.adapter.ts`、`src/index.ts`
- Test: `packages/storage/test/minio.adapter.spec.ts`(真实 MinIO 集成测试)
- [ ] **Step 1: 端口定义**(生产 OSS 适配器实现同一接口,G1 验证差异)
```ts
export interface UploadCredential { mode: 'presigned'; url: string; method: 'PUT'; headers: Record<string,string>; expiresAt: string; }
export interface StoragePort {
presignPut(objectKey: string, bytes: number, contentType: string, ttlSeconds: number): Promise<UploadCredential>;
presignGet(objectKey: string, ttlSeconds: number): Promise<string>;
head(objectKey: string): Promise<{ bytes: number; etag: string } | null>;
getStream(objectKey: string): Promise<NodeJS.ReadableStream>;
put(objectKey: string, body: Buffer, contentType: string): Promise<{ etag: string }>;
/** 复制到正式键;目标已存在时校验一致性并幂等返回(禁止覆盖语义)。 */
copyIfAbsent(src: string, dest: string, expectedBytes: number): Promise<{ created: boolean; etag: string }>;
}
```
- [ ] **Step 2: MinIO 实现**:`@aws-sdk/client-s3` + `s3-request-presigner`;`copyIfAbsent` = head(dest) 存在→比对 bytes 一致返回 `{created:false}`、不一致抛 `UPLOAD_VERIFY_FAILED`;不存在→`CopyObject` 后复核。测试覆盖:presign PUT 可写入、head 字节数、copyIfAbsent 幂等(二次调用 created:false)、目标不一致抛错。
- [x] **Step 3: 运行** `pnpm --filter @photo/storage test`(完成:6/6 通过,真实 MinIO;规格审查负向实测发现 SDK 对 content-type 不签名——已修正文档为「约定发送」,head() 增加 contentType 返回,8e870de)
- [x] **Step 4: Commit** `feat(storage): storage port and minio adapter with forbid-overwrite semantics`(完成:37f4b3e + 8e870de)
---
## 里程碑 M3 身份与权限(T05 后端)
### Task 12: 密码哈希与受控初始管理员
**Files:**
- Create: `services/api/src/auth/password.ts`、`services/api/scripts/seed-admin.ts`
- Test: `services/api/test/password.spec.ts`
> Task 7 质量审查遗留(本任务一并落地):① `ApiException extends HttpException` 携带 `{code: ErrorCode, message, httpStatus}`,异常过滤器优先识别(状态码表无法表达同状态多错误码的契约空间)+ 未映射状态分支单测;② 5xx/INTERNAL_ERROR 强制通用文案(堵住 InternalServerErrorException(message) 泄露);③ pino redact 增 cookie/set-cookie;④ x-request-id 回显加长度上限(128);⑤ LOG_LEVEL 非法值启动期明确报错;⑥ 抽出 `API_PREFIX='v1'` 与 `RequestWithId` 共享类型;⑦ jest ts-jest 改 `tsconfig: 'tsconfig.json'` 复用;⑧ 增加 `start:dev`(tsc --watch + node --watch 或 nodemon 任一)。计划文本笔误修正:request-id 生成为 UUID v4(非 cuid)。
- [x] **Step 1: 失败测试**:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。(完成:test/password.spec.ts 5 例,先红后绿)
- [x] **Step 2: 实现 + seed 脚本**(读 env `ADMIN_ACCOUNT/ADMIN_PASSWORD`,不存在则插入 admin 角色,存在则跳过——幂等)。(完成:src/auth/password.ts + src/scripts/seed-admin.ts,`pnpm --filter @photo/api-cn seed`;幂等性真实 DB 集成测试 test/seed.spec.ts,不写 audit_logs;⑧ 决策为 `build:watch` 脚本,无 concurrently 依赖)
- [x] **Step 3: 测试通过;Commit** `feat(api): argon2 password and idempotent admin seed`(完成:api 单测 35 通过 / e2e 10 通过,根 lint+typecheck+test 绿)
### Task 13: 登录 / 刷新 / 注销
**Files:**
- Create: `services/api/src/auth/auth.controller.ts`、`auth.service.ts`、`auth.module.ts`、`session.guard.ts`
- Test: `services/api/test/auth.e2e-spec.ts`
- [ ] **Step 1: 失败 E2E**:正确账号密码→200 + sessionToken(长效单 token,默认 30 天)+ expiresAt + audit `auth.login`;错误密码→401 `AUTH_INVALID_CREDENTIALS`;停用(sourceStatus=disabled 或 localLocked)→401(不区分文案);**未激活(credentialStatus=pending)→401**(A01"未激活与停用账号无法进入");refresh→滑动续期(持有有效 token 延长 expiresAt,仍为同一 token,不做旋转);logout→revokedAt 后 401 `AUTH_TOKEN_EXPIRED`。
- [ ] **Step 2: 实现**:token=32B random hex,库存 sha256;会话期限由 `AUTH_SESSION_TTL_SECONDS`(默认 30 天)控制;登录时校验 `credentialStatus='active'`;`SessionGuard` 校验 tokenHash 未撤销未过期,挂 `req.user`。
- [ ] **Step 3: E2E 通过(A01 API 级);Commit** `feat(api): password login, sliding renewal, revocable sessions`
### Task 14: 相册角色权限服务(A02 基石)
**Files:**
- Create: `services/api/src/albums/permissions.service.ts`
- Test: `services/api/test/permissions.spec.ts`
- [ ] **Step 1: 失败测试**:`canViewAlbum/canManageAlbum/canManagePhoto`——admin 全通过;创建者管本相册、不能管他人相册;协作者只能传/管自己照片;非成员拒绝;被移除成员(members 无行)拒绝(逐次查库,不信客户端声明)。
- [ ] **Step 2: 实现**(方法内部 join members+albums+photos.uploaderId 校验)。
- [ ] **Step 3: 通过;Commit** `feat(api): server-side album role permission checks`
---
## 里程碑 M4 相册与协作(T06)
### Task 15: 相册创建与多语言
**Files:**
- Create: `services/api/src/albums/albums.controller.ts`、`albums.service.ts`、`albums.module.ts`(含 translations/categories 模型引用)
- Test: `services/api/test/albums.e2e-spec.ts`
- [ ] **Step 1: 失败 E2E**:POST /albums 带 zh-CN+en translations → 201 返回 publicToken(`^[0-9a-z]{20,32}$`);title 缺失→`VALIDATION_FAILED`;endAt<startAt→400;owner 自动入 album_members(role=owner) + audit。
- [ ] **Step 2: 实现**(事务:albums + translations + members(owner) + audit)。
- [ ] **Step 3: 通过;Commit** `feat(api): album creation with translations and owner membership`
### Task 16: 相册查询 / 更新与开关
- [ ] **Step 1: 失败 E2E**:创建者 PATCH 改 title/开关→200 且 `uploadEnabled/publicEnabled` 独立变化;协作者 PATCH→403 `AUTH_FORBIDDEN`(A02);GET 列表仅返回自己参与或 admin 全量。
- [ ] **Step 2: 实现(配置变更写 audit `album.update`);通过;Commit** `feat(api): album update with independent upload/public switches`
### Task 17: 成员与分类管理
- [ ] **Step 1: 失败 E2E**:创建者邀请已存在账号→201;重复邀请→`MEMBER_CONFLICT`;移除后该成员 complete 上传→403(Task 21 回归);分类增改排序→列表按 sortOrder。
- [ ] **Step 2: 实现(成员变更写 audit);通过;Commit** `feat(api): members and categories management`
---
## 里程碑 M5 上传与自动发布(T07)
### Task 18: POST /v1/uploads 幂等初始化
**Files:**
- Create: `services/api/src/uploads/uploads.controller.ts`、`uploads.service.ts`、`uploads.module.ts`
- Test: `services/api/test/uploads.e2e-spec.ts`
- [ ] **Step 1: 失败 E2E**(A03):同 `clientTaskId` 两次调用→**同 uploadId+photoId**(upsert 语义,非 409);不同 clientTaskId 同 filename→两个会话两个 photoId;非成员→403;`uploadEnabled=false`→409 `ALBUM_UPLOAD_DISABLED`;`bytes > UPLOAD_MAX_BYTES`(默认 50MB)→`VALIDATION_FAILED`;`contentType` ∉ {image/jpeg, image/png, image/heic}(heic 解码能力待 G2 验证标注)→`VALIDATION_FAILED`;响应含有效 presign credential(用 StoragePort 实际签名)。
- [ ] **Step 2: 实现**:入参校验(bytes 上限、类型白名单、相册上传开关与成员权)→ 事务内 upsert(`@@unique([userId, clientTaskId])` 冲突→查回既有会话且校验 album/category 一致,不一致→`UPLOAD_SESSION_CONFLICT`);staging 键 = `photo-live/dev/staging/{uploadId}/{photoId}.{ext}`(会话 objectKey);photo 预创建(visibility=unpublished),其 `objectKey` 在 init 即写为最终 originals 键 `photo-live/dev/albums/{albumId}/originals/{photoId}.{ext}`(staging 与正式键分离,杜绝二义)。
- [ ] **Step 3: 通过;Commit** `feat(api): idempotent upload session init with presigned credential`
### Task 19: 续签与完成核验(含受控复制)
- [ ] **Step 1: 失败 E2E**:`renew` 返回新 presign(同 objectKey);`renew` 在 `uploadEnabled=false`→409 `ALBUM_UPLOAD_DISABLED`(§4.9 关闭上传拒绝续签);被移除成员 `renew`→403;`complete`:
- 对象未上传→404/400 `UPLOAD_VERIFY_FAILED`;
- 字节数不符→`UPLOAD_VERIFY_FAILED`;
- 成功→photo(uploadedAt、bytes) + photo_jobs(pending) + session(confirmed) 同事务;
- **重复 complete(A03)**→200 幂等,不产生第二个 photo/job/outbox;
- 被移除成员 complete→403;
- `GET /uploads` 返回本人会话历史(state 联查 photo.processingState,App 服务端历史的后端前置)。
- [ ] **Step 2: 实现**:head(staging) 校验 bytes **与 contentType(比对会话申明值——presign 对 content-type 不签名,服务端兜底,Task 11 审查发现)** → `copyIfAbsent(staging→originals:{albumId}/originals/{photoId}.ext)` → 事务更新三表 → enqueue BullMQ(队列名常量 `PHOTO_PROCESS` 由 `@photo/contracts` 导出,api 与 worker 共享;提交后入队,队列不可用不回滚,靠 Task 21 sweep 补偿)。
- [ ] **Step 3: 通过;Commit** `feat(api): upload verify, forbid-overwrite copy, idempotent complete`
### Task 20: worker 服务与 sharp 处理流水线
**Files:**
- Create: `services/worker/package.json`(name `@photo/worker`)、`src/main.ts`、`src/process.processor.ts`、`src/pipeline.ts`、`src/sweep.ts`
- Test: `services/worker/test/pipeline.spec.ts`(真实 jpeg/png 样本 + 一张损坏文件 + 一张 HEIC 样本记录结果)
- [ ] **Step 1: 失败测试**:pipeline(buffer)→输出 thumb(≤480w) 与 browse(≤1920w) 两 Buffer,EXIF 已清(无 GPS/ orientations 元数据)、宽高返回;损坏 buffer→`DECODE_FAILED`;HEIC 样本(真实 iPhone 样本置于 `services/worker/test/fixtures/sample.heic`,暂缺时该用例 skip 并登记验收记录,不阻塞)→若 sharp 可解码则通过否则标记 `UNSUPPORTED_TYPE` 并**登记到验收记录**(总纲风险项)。
- [ ] **Step 2: 实现**:worker package 依赖 `@photo/db-cn`(与 api 共享同一 Prisma client);`sharp(buf).rotate()` 自动方向 → `resize({width, withoutEnlargement:true}).jpeg({quality})`(thumb q75 / browse q82,不调用 withMetadata 即清 EXIF)。
- [ ] **Step 3: 通过;Commit** `feat(worker): sharp pipeline with orientation, variants, exif strip`
### Task 21: 处理消费者、发布事务与补偿扫描
**Files:**
- Create: `services/worker/src/publish.service.ts`
- Test: `services/worker/test/publish.e2e-spec.ts`(真实 MySQL/Redis/MinIO;worker package scripts 含 `test` 与 `test:e2e`,后者纳入根 `pnpm e2e` 的 turbo 任务)
- [ ] **Step 1: 失败 E2E**(A06):photo_jobs(pending) → 消费者处理:variants put 上传 → **单事务**:photo_variants upsert + photos(ready, published, publishedAt=now, entityVersion+1) + outbox_events(photo.published, entityVersion 同步) → 断言三表一致且事件 payload 符合 contracts/events.ts;重复投递(同 photoId)→ 幂等无二套 variants;损坏文件→3 次退避后 `processing_failed` 且不公开。
- [ ] **Step 2: 实现**:BullMQ `attempts:3, backoff:exponential`;`sweep.ts` 每 30s 认领 `state='pending' AND (nextRetryAt IS NULL OR nextRetryAt < now())`(SQL NULL 语义;含 complete 后入队失败的孤儿任务,总纲 §5.6 补偿)。
- [ ] **Step 3: 通过;Commit** `feat(worker): publish transaction with outbox event and compensating sweep`
---
## 里程碑 M6 公开读与可见性(T11 部分 + V4)
### Task 22: 相册公开信息与语言回退
**Files:**
- Create: `services/api/src/public/public.controller.ts`、`public.service.ts`、`public.module.ts`
- Test: `services/api/test/public.e2e-spec.ts`
- [ ] **Step 1: 失败 E2E**:`GET /public/albums/{token}` locale=en 但缺 en 翻译→回退 defaultLocale 且响应携带 `resolvedLocale`;offline 或 publicEnabled=false→404 `ALBUM_NOT_PUBLIC`;返回 categories(名称同样回退)、banner 签名 URL(fixture:直接经 Prisma/StoragePort 预置 album_assets 行 + MinIO 对象——上传链路在 P1b)、timezone、`viewCount:null`。
- [ ] **Step 2: 实现;通过;Commit** `feat(api): public album info with locale fallback`
### Task 23: 公开照片列表(游标分页 + 分类过滤 + 签名 URL)
- [ ] **Step 1: 失败 E2E**:seed 35 张已发布→limit=30 首页 30 条 + nextCursor;翻页得余 5 条且无重复(跨页稳定,A07 后端级);`category` 过滤正确;仅 `visibility=published`(hidden/deleted 不出现);item 含 thumb/browse 签名 URL 与 `expiresAt`;同秒发布的多张排序按 id 稳定。
- [ ] **Step 2: 实现**:cursor=base64(`{publishedAt,id}`),WHERE `(publishedAt,id) < (cursor)` 语义(MySQL 行值比较或等价 OR 条件,D12 复核项)。
- [ ] **Step 3: 通过;Commit** `feat(api): public photo feed with stable cursor pagination`
### Task 24: 单图定位 / 隐藏 / 软删除
- [ ] **Step 1: 失败 E2E**:单图路由命中已发布照片→200(并作为 URL 刷新入口返回新签名);隐藏后单图与列表均不可见(404 `PHOTO_NOT_PUBLIC`,本地化文案键);uploader 隐藏自己照片 OK、协作者隐藏他人照片→403(A02);软删除后不可见;隐藏/删除写 audit;outbox 追加 `photo.hidden`/`photo.deleted` 事件(P2 消费);管理员/创建者重新公开(hidden→published)→ 列表恢复可见(§5.5 状态机;协作者能否自行恢复属需求待确认项,P1a 默认拒绝并登记)。
- [ ] **Step 2: 实现;通过;Commit** `feat(api): photo visibility lifecycle with audit and outbox events`
---
## 里程碑 M7 端到端与验收
### Task 25: 全链路 API E2E(A01/A02/A03/A06 证据)
**Files:**
- Test: `services/api/test/e2e.full-chain.e2e-spec.ts`
- [ ] **Step 1: 失败 E2E**(一个 spec 串全链路):
1. seed-admin → 登录 admin → 建 album(zh+en) + 分类 + 邀请 staffB;
2. staffB 登录 → **同名文件两次上传**(不同 clientTaskId)→ 两个 presign PUT 实际写入 MinIO → 各 complete(其一 complete 调两次);
3. 运行 worker(测试内 `sweepOnce()`)→ 两照片 ready+published,outbox 各一条 photo.published,**无第三条**(A03);
4. 公开 API:列表 2 张、翻页、语言回退、签名 URL 可 GET 到图片字节;
5. 隐藏其中一张 → 列表剩 1、单图 404;
6. admin 软删除另一张 → 空列表;audit 全程可查。
- [ ] **Step 2: 通过;Commit** `test(api): full-chain e2e covering A01 A02 A03 A06`
### Task 26: 回归门与 P1a 验收记录
- [ ] **Step 1: 全量回归**:`pnpm lint && pnpm typecheck && pnpm test && pnpm e2e`(单测与 e2e 分列)。Expected: 全绿。
- [ ] **Step 2: 写验收记录** `docs/plans/progress/P1a-验收记录.md`:A01(API 级证据+App 端留 P3)、A02、A03、A06、A13(凭证设计级:无长期客户端密钥、签名 TTL 受 env 控制;OSS 生命周期核验随 G1/P5)、HEIC 实测结果、未尽事项。
- [ ] **Step 3: 打 tag** `git tag p1a`;Commit `docs: p1a acceptance record`。
---
## 执行注意
- 每任务 TDD 节奏不可跳步:先失败测试→实现→通过→提交;集成/E2E 测试统一跑在 compose 依赖上(`docker compose up -d` 前置)。
- e2e 隔离:每个 e2e spec `beforeAll` 调 `test/reset.ts`(truncate cn 全表 + 清 MinIO `photo-live/dev` 前缀);worker/storage 测试沿用同约定(各自包内复制 reset 工具或引用)。
- 基础设施注意:minio 镜像统一用 `quay.io/minio/*`(Docker Hub 官方仓库已删除,P2 ov 侧同样);跑 prisma/集成测试前先确认宿主端口实际指向 photo-live 容器(`docker port photo-live-mysql-cn-1`),Docker Desktop 重启可能拉起其他项目容器抢占 3306/6379(已对其设 restart=no,但新冲突可能来自其他来源)。
- env:命令一律在仓库根执行;根 `.env` 为唯一 env 文件,脚本经 dotenv-cli 注入、测试经 `test/load-env.ts` 载入(Task 7 约定;注意路径深度:`services/api/test`、`services/worker/test` 为三级向上(`../../../.env`),`packages/storage/test` 为**两级**向上(`../../.env`)——Task 11 复制时按包调整,或改为向上查找 pnpm-workspace.yaml 定位仓库根);新增 env 变量必须同步 `.env.example`;密钥不入库。
- 发现契约需变更时:先改 `packages/contracts` 并重新生成类型、在任务提交信息中登记,再改消费端(契约单一事实源)。
- 与总纲冲突处以总纲为准并回写修订;HEIC、MySQL 行值比较等 D12 复核项结论写入验收记录。
- P1a 完成后:编写 P1b 计划(viewer 基础页 + admin 最小页 + **宣传图/封面/Logo 上传链路(album_assets 的 presign+确认,复用照片上传机制,R09 依赖)** + **GET /albums/{albumId}/photos 管理端照片列表**(含未发布/隐藏态,照片管理入口)+ **用户账号查询接口**(邀请协作者时按 account 搜索)+ Playwright 冒烟)。