Files
photo-live/docs/plans/2026-09-17-P1a-后端核心链路.md
T
浅念 d973c714f0 chore: init repository skeleton
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 16:13:49 +08:00

650 lines
35 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 后端核心链路 实施计划
> **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 · pnpm 9 · Turborepo · NestJS 10 · Prisma ^5.18 (MySQL 8.4 容器) · 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`(同样大小写敏感)并重跑语义测试。
- [ ] 本机环境:`node -v` ≥ 20、`pnpm -v` ≥ 9、`docker -v` 与 `docker compose version` 可用(Windows + Docker Desktop)
---
## 里程碑 M0 工程底座(T02)
### Task 1: git 仓库与目录骨架
**Files:**
- Create: `.gitignore`, `.editorconfig`, `.nvmrc`, `README.md`, `docs/plans/progress/.gitkeep`
- [ ] **Step 1: 初始化仓库与目录**
```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`
- [ ] **Step 2: 首次提交**
```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`
- [ ] **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 报错。
- [ ] **Step 3: Commit** `chore: pnpm workspace and turbo pipeline`
### Task 3: docker-compose 基础设施(cn 侧)
**Files:**
- Create: `infra/docker-compose.yml`, `.env.example`
- [ ] **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
```
- [ ] **Step 2: 启动并验证**
```bash
cd infra && docker compose up -d && docker compose ps
```
Expected: mysql-cn / redis-cn / minio / minio-init 均 healthy 或 exited(0)(init 为一次性任务)。
- [ ] **Step 3: Commit** `chore(infra): cn-side mysql/redis/minio compose`
---
## 里程碑 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`
- [ ] **Step 1: 补全 P1a 路径与 schema**(字段以技术实施方案 §4/§10 为准):
- `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 三类)
```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
}
```
- [ ] **Step 2: 单测**:类型测试——合法 `OutboxEvent{type:'photo.published'}` 编译通过,非法 type 用 `@ts-expect-error` 验证编译期拒绝。
```bash
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: 启动验证**
```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"}
```
- [ ] **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])
}
```
- [ ] **Step 2: 迁移**
```bash
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: 模型**:
```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_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 验证差异)
```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)、目标不一致抛错。
- [ ] **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 工具或引用)。
- 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 冒烟)。