Co-Authored-By: Claude Code <noreply@anthropic.com>
35 KiB
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 开发(不确认则默认执行)
- 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: 初始化仓库与目录(完成:提交 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
Task 3: docker-compose 基础设施(cn 侧)
Files:
-
Create:
infra/docker-compose.yml,.env.example -
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: 启动并验证
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,客户端按语言映射文案)
// 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 /uploadsPATCH /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验证编译期拒绝。
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;devDepsjest 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 specbeforeAll调用——truncate 全部 cn 表 + 删除 MinIOphoto-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,devDepsprisma;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: Commitfeat(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: Commitfeat(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;错误密码→401AUTH_INVALID_CREDENTIALS;停用(sourceStatus=disabled 或 localLocked)→401(不区分文案);未激活(credentialStatus=pending)→401(A01"未激活与停用账号无法进入");refresh→滑动续期(持有有效 token 延长 expiresAt,仍为同一 token,不做旋转);logout→revokedAt 后 401AUTH_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→403AUTH_FORBIDDEN(A02);GET 列表仅返回自己参与或 admin 全量。 - Step 2: 实现(配置变更写 audit
album.update);通过;Commitfeat(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→409ALBUM_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→409ALBUM_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 服务端历史的后端前置)。
- 对象未上传→404/400
- 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→404ALBUM_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 串全链路):
- seed-admin → 登录 admin → 建 album(zh+en) + 分类 + 邀请 staffB;
- staffB 登录 → 同名文件两次上传(不同 clientTaskId)→ 两个 presign PUT 实际写入 MinIO → 各 complete(其一 complete 调两次);
- 运行 worker(测试内
sweepOnce())→ 两照片 ready+published,outbox 各一条 photo.published,无第三条(A03); - 公开 API:列表 2 张、翻页、语言回退、签名 URL 可 GET 到图片字节;
- 隐藏其中一张 → 列表剩 1、单图 404;
- 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;Commitdocs: p1a acceptance record。
执行注意
- 每任务 TDD 节奏不可跳步:先失败测试→实现→通过→提交;集成/E2E 测试统一跑在 compose 依赖上(
docker compose up -d前置)。 - e2e 隔离:每个 e2e spec
beforeAll调test/reset.ts(truncate cn 全表 + 清 MinIOphoto-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 冒烟)。