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

37 KiB
Raw Blame History

P1a 后端核心链路 实施计划

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 开发(不确认则默认执行)
  • 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(同样大小写敏感)并重跑语义测试。
  • 本机环境:已验证(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

  • Step 1: 初始化仓库与目录(完成:提交 d973c71,2026-09-17)

cd /d/project/photo
git init -b main
mkdir -p apps packages services infra docs/plans/progress

.gitignore:

node_modules/
dist/
.turbo/
coverage/
.env
.env.*
!.env.example
*.log
.DS_Store

.nvmrc:20

  • Step 2: 首次提交(完成:d973c71,含全部 docs/,双审通过)
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:

packages:
  - apps/*
  - packages/*
  - services/*

package.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:

{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
    "lint": {},
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["^build"] },
    "test:e2e": { "dependsOn": ["^build"] }
  }
}
  • Step 2: 验证 workspace
pnpm install

Expected: lockfile 生成,无 workspace 报错。

  • 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)

# 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(仓库根):

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
  • 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 为一次性任务)。

  • 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,客户端按语言映射文案)
// 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 过期后的刷新入口)
  • Step 2: 生成 + lint 通过(同 Task 4 命令)

  • Step 3: Commit feat(contracts): p1a endpoint contracts

Task 6: 事件契约 + 用户确认点

Files:

  • Create: packages/contracts/src/events.ts

  • Step 1: 事件类型(对齐技术实施方案 §8.2;P1a 无消费者,事件仅持久化到 outbox,实际会产生 photo.published / photo.hidden / photo.deleted 三类)

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
}
  • Step 2: 单测:类型测试——合法 OutboxEvent{type:'photo.published'} 编译通过,非法 type 用 @ts-expect-error 验证编译期拒绝;枚举同步测试(Task 4 审查遗留):断言 ErrorCode 与 components['schemas']['Error']['code'] 的 enum 双向可赋值。
pnpm --filter @photo/contracts test

Expected: PASS。

  • Step 3: ⏸ 用户确认点:请用户过目 openapi.yaml(接口形态、字段命名、分页方式)后再进入 M2。
  • Step 4: Commit feat(contracts): outbox event contract types

里程碑 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

  • 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。

  • Step 2: 依赖与入口:deps @nestjs/common @nestjs/core @nestjs/platform-express nest-pino pino-http;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'}。

  • 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 负向断言生效。

  • Step 4: 启动验证

cp .env.example .env
pnpm lint && pnpm typecheck
pnpm dev:api &
until curl -s localhost:8080/v1/health; do sleep 1; done   # 等待启动 → {"status":"ok"}
  • 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 不另行放大):

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])
}
  • Step 2: 迁移
pnpm db:migrate -- --name init_identity

Expected: migration SQL 生成并应用。

  • Step 3: 写语义复核 E2E 测试(TDD:先写测试)——UTC 往返(写入+读回同为 UTC 瞬间)、utf8mb4_0900_bin 下 public_token 大小写敏感查询、audit 插入。运行 pnpm --filter @photo/api-cn test:e2e,Expected: PASS。
  • Step 4: Commit feat(api): identity and audit schema with mysql semantics tests

Task 9: Prisma cn schema —— 相册域

Files:

  • Modify: packages/db-cn/prisma/schema.prisma

  • Step 1: 模型: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)。

  • Step 2: 迁移 --name album_domain;Step 3: Commit feat(api): album domain schema

Task 10: Prisma cn schema —— 照片与上传域 + outbox

Files:

  • Modify: packages/db-cn/prisma/schema.prisma

  • Step 1: 模型:

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_upload") // pending_upload|uploaded|confirmed|failed
  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: 迁移 --name photo_upload_domain;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 验证差异)

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)、目标不一致抛错。
  • Step 3: 运行 pnpm --filter @photo/storage test(compose 已起 MinIO)。Expected: PASS。
  • Step 4: Commit feat(storage): storage port and minio adapter with forbid-overwrite semantics

里程碑 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

  • Step 1: 失败测试:hash/verify(@node-rs/argon2,memoryCost 默认)、错误口令 verify=false。

  • Step 2: 实现 + seed 脚本(读 env ADMIN_ACCOUNT/ADMIN_PASSWORD,不存在则插入 admin 角色,存在则跳过——幂等)。

  • Step 3: 测试通过;Commit feat(api): argon2 password and idempotent admin seed

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 → 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/worker/test 与 packages/storage/test 复制同约定 load-env.ts——两者到仓库根同为三级向上,路径一致);新增 env 变量必须同步 .env.example;密钥不入库。
  • 发现契约需变更时:先改 packages/contracts 并重新生成类型、在任务提交信息中登记,再改消费端(契约单一事实源)。
  • 与总纲冲突处以总纲为准并回写修订;HEIC、MySQL 行值比较等 D12 复核项结论写入验收记录。
  • P1a 完成后:编写 P1b 计划(viewer 基础页 + admin 最小页 + 宣传图/封面/Logo 上传链路(album_assets 的 presign+确认,复用照片上传机制,R09 依赖) + Playwright 冒烟)。