2026-09-17 16:13:49 +08:00
# 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。
2026-09-17 16:46:40 +08:00
**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
2026-09-17 16:13:49 +08:00
**对应验收:** 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` (同样大小写敏感)并重跑语义测试。
2026-09-17 16:50:43 +08:00
- [x] 本机环境:已验证(node v24.13.0、pnpm 10.7.1、Docker 29.2.1 + Compose v5.0.2; 3306/6379 与其他项目容器的冲突已由用户决策解决——停用 x-server-mysql/redis 并取消自启)
2026-09-17 16:13:49 +08:00
---
## 里程碑 M0 工程底座(T02)
### Task 1: git 仓库与目录骨架
**Files:**
- Create: `.gitignore` , `.editorconfig` , `.nvmrc` , `README.md` , `docs/plans/progress/.gitkeep`
2026-09-17 16:24:12 +08:00
- [x] **Step 1: 初始化仓库与目录** (完成:提交 d973c71, 2026-09-17)
2026-09-17 16:13:49 +08:00
```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`
2026-09-17 16:24:12 +08:00
- [x] **Step 2: 首次提交** (完成:d973c71,含全部 docs/,双审通过)
2026-09-17 16:13:49 +08:00
```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`
2026-09-17 16:24:12 +08:00
- 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 可审)。
2026-09-17 16:13:49 +08:00
- [ ] **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 报错。
2026-09-17 16:46:40 +08:00
- [x] **Step 3: Commit** `chore: pnpm workspace and turbo pipeline` (完成:68a7d6a,含质量审查遗留项 .gitattributes/.gitignore/.editorconfig 增补;规格审查通过)
2026-09-17 16:13:49 +08:00
### Task 3: docker-compose 基础设施(cn 侧)
**Files:**
- Create: `infra/docker-compose.yml` , `.env.example`
2026-09-17 16:46:40 +08:00
- Modify: `package.json` packageManager → `pnpm@10.7.1` (对齐实际环境,Task 2 审查遗留)
2026-09-17 16:13:49 +08:00
- [ ] **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
```
2026-09-17 16:50:43 +08:00
- [x] **Step 2: 启动并验证** (完成:全部 healthy; collation=utf8mb4_0900_bin; photo_cn 库与 photo-dev bucket 就绪;**偏差登记**: minio 镜像改用 quay.io/minio/*( Docker Hub 官方仓库已删除),P2 ov 侧沿用)
2026-09-17 16:13:49 +08:00
Expected: mysql-cn / redis-cn / minio / minio-init 均 healthy 或 exited(0)( init 为一次性任务)。
2026-09-17 16:50:43 +08:00
- [x] **Step 3: Commit** `chore(infra): cn-side mysql/redis/minio compose` (完成:538fdd4,含 packageManager→pnpm@10 .7.1 对齐)
2026-09-17 16:13:49 +08:00
---
## 里程碑 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`
2026-09-17 17:31:11 +08:00
- 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 决策)
2026-09-17 16:13:49 +08:00
2026-09-17 17:31:11 +08:00
- [ ] **Step 1: 补全 P1a 路径与 schema** (字段以技术实施方案 §4/§10 为准)。附加要求(Task 4 质量审查遗留):列表响应一律经 allOf 消费 CursorPageMeta;消费后**删除 redocly.yaml 的 no-unused-components 豁免**; auth 三条路径补 400 ErrorResponse;保留 hasMore/nextCursor 成对填充约定:
2026-09-17 16:13:49 +08:00
- `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
}
```
2026-09-17 17:31:11 +08:00
- [ ] **Step 2: 单测** :类型测试——合法 `OutboxEvent{type:'photo.published'}` 编译通过,非法 type 用 `@ts-expect-error` 验证编译期拒绝;**枚举同步测试**(Task 4 审查遗留):断言 `ErrorCode` 与 `components['schemas']['Error']['code']` 的 enum 双向可赋值。
2026-09-17 16:13:49 +08:00
```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 工具或引用)。
2026-09-17 16:50:43 +08:00
- 基础设施注意:minio 镜像统一用 `quay.io/minio/*` (Docker Hub 官方仓库已删除,P2 ov 侧同样);跑 prisma/集成测试前先确认宿主端口实际指向 photo-live 容器(`docker port photo-live-mysql-cn-1` ),Docker Desktop 重启可能拉起其他项目容器抢占 3306/6379(已对其设 restart=no,但新冲突可能来自其他来源)。
2026-09-17 16:13:49 +08:00
- 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 冒烟)。