chore: init repository skeleton

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 16:13:49 +08:00
commit d973c714f0
23 changed files with 2666 additions and 0 deletions
@@ -0,0 +1,649 @@
# 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 冒烟)。
+179
View File
@@ -0,0 +1,179 @@
# 图片直播项目实施总纲
版本:V1.0
日期:2026-09-17
执行模式:Claude Code 代理开发 + 显式人工关口
关联文档:[需求说明书](../01-需求说明书.md) · [项目计划](../02-项目计划.md) · [技术实施方案](../03-技术实施方案.md) · [优化方案](../04-优化方案.md)
本总纲把 V1.3 文档基线拆解为代理可执行的实施阶段序列,定义工程结构、技术选型锁定、阶段边界、人工关口与决策挂起清单。它不替代上述四份文档:需求以 R01 至 R31 为准,验收以 A01 至 A29 为准,工作包编号 T01 至 T30 沿用项目计划。各阶段的详细可执行计划在进入该阶段时另册编写(见 docs/plans/ 目录)。
## 1 执行模式与技术栈锁定
开发主体为 Claude Code 代理,按"每阶段交付可在本地完整运行、可自动化验证的端到端增量"推进;无法由代理完成的验证(真机、真实云资源、外部接口)显式登记为人工关口,不阻塞代理开发,但在对应阶段出口必须闭环。
D02 技术栈决策本次锁定如下,后续变更需评估返工范围后修订本总纲:
| 层 | 选型 | 说明 |
|---|---|---|
| App | Flutter,原生桥接(iOS background URLSession / Android 用户发起传输任务 + 前台服务) | P3 创建,双平台 |
| 管理后台 | Art Design Pro 模板(Vue 3 + Vite + TypeScript + Element Plus) | 锁定模板版本;P1 尾部接入 |
| 观众页 | Vue 3 + Vite + TypeScript 轻量入口 | 独立于后台 |
| 嵌入组件 | TypeScript 加载器 + 项目域内 iframe 轮播页 | P4b |
| API | NestJS + TypeScript,单代码库双部署入口 cn-app / ov-app | 模块按部署角色组织 |
| ORM | Prisma(MySQL 方言),cn 与 ov 各自独立 schema | 去重窗口清理等以原生 SQL / 调度任务实现 |
| 数据库 | MySQL 8 ×2:生产复用国内与海外 ECS 上 1Panel 既有 MySQL 容器,各建独立库与独立账号;本地开发用同版本 MySQL 容器保持方言一致 | 双库结构不同、互不直连,符合 A24 |
| 队列与缓存 | BullMQ + Redis ×2 | cn 承任务队列;ov 承限速与实时辅助(独立实例与命名空间) |
| 图片处理 | sharp(libvips) | HEIC 解码为验证关口,见 G2 |
| 对象存储 | StoragePort 适配层:开发环境 MinIO,生产阿里云 OSS | 见第 3 节 |
| 实时 | NestJS WebSocket(ws 适配器),部署于 ov-app | P2 |
| 测试 | 服务端 Jest + supertest;Web 端 Vitest;E2E Playwright | 基础设施依赖 docker-compose 固定版本 |
| Monorepo | pnpm workspaces + Turborepo | Flutter 子树用原生工具链 |
## 2 工程结构
```text
photo/
docs/
plans/ 实施总纲、各阶段详细计划与验收记录
apps/
mobile/ Flutter App(P3 创建)
admin/ 管理后台(Art Design Pro)
viewer/ 观众页
packages/
contracts/ OpenAPI 契约 + 生成 TS 类型 + 事件契约 + 稳定错误码
locales/ Web 公共翻译词条
embed-loader/ JS 悬浮组件加载器(P4b)
storage/ StoragePort 接口与 MinIO / OSS 适配器
services/
api/ NestJS:cn-app 与 ov-app 两个部署入口 + 共享领域模块
worker/ 图片处理 worker(BullMQ 消费者 + sharp)
sync/ outbox 发送 / inbox 接收 / 统计回传(P2)
infra/
docker-compose.yml MySQL×2、Redis×2、MinIO(P1 仅启用 cn 侧,P2 起以 profiles 启用 ov 侧;版本对齐生产 1Panel 环境)
migrations/ 原生 SQL 与清理任务(去重窗口等)
deploy/ 部署配置(P5)
```
Schema 所有权:国内库表(users、albums、photos、upload_sessions、outbox_events 等)归 `services/api` 的 cn schema 管理;海外库表(展示副本、inbox、点赞批次、浏览去重、统计权威)归 ov schema 管理。两端共享的数据形状只经 `packages/contracts` 传递,禁止互相引用对方 schema 生成物。
契约优先:`packages/contracts` 是接口与事件的单一事实源,三端类型由其生成;契约任务完成处设用户确认点后再开发消费端。
## 3 代理执行的核心工程决策
1. 本地双地域仿真:docker-compose 启动两套独立 MySQL 与 Redis;cn-api(:8080) 与 ov-api(:8081) 双进程;sync 服务经 HTTP 互推。跨境同步、墓碑、乱序去重、统计回传全部在集成测试中自动化验证,不依赖真实海外资源(真实部署见 P5)。
2. 存储适配层:`packages/storage` 定义 StoragePort(签发临时上传凭证、head 核验、服务端受控复制、禁止覆盖语义)。开发环境用 MinIO(presigned);生产用阿里云 OSS 适配器。生产适配器优先返回签名 PUT URL(与 MinIO 形态统一,App 保持单一 PUT 上传代码路径);STS/分片作为大文件增强备选,经 G1 验证后决定。凭证形态差异在接口上归一化,适配器各自的特殊语义用独立测试覆盖;真实 OSS 差异集中在 G1/G2 关口验证。
3. 用户源接口 Mock:按技术实施方案 3.1A 假设字段实现 mock provider 与适配器接口,同步调度、水位、幂等、失败不停用等逻辑先行开发测试;真实接口契约(D08)到位后仅替换适配器。配套 fixtures 覆盖改名、停用、分页中断、空响应、增量等场景。
横切关注点自 P1 内建:结构化日志贯穿三端并以 requestId / uploadId / photoId 关联;稳定错误码 + 本地化文案映射;audit_logs 记录管理与权限操作;/health 端点;密钥经环境注入且不入库。
## 4 阶段划分与交付物
| 阶段 | 可运行交付物 | T 映射 | A 映射 | 出口门 | 人工关口 |
|---|---|---|---|---|---|
| P1 底座 + 国内核心链路 | compose 一键起环境;密码登录→建相册→上传会话→核验→处理→自动发布→观众页瀑布流/大图/短期签名 URL 刷新(读 cn 公开 API);可见性状态机与单张隐藏/软删除;seed 与脚本化 E2E;admin 最小页(后段) | T02 T05(后端部分)T06 T07,T11 部分 | A01(API 与后台级,App 端随 P3)A02 A03 A06 A13(凭证设计级,OSS 生命周期全量核验随 G1/P5);A07/A08 基础级 | 回归全绿 + 验收记录;可选 G1 | G1(可选) |
| P2 双地域仿真 + 互动 | ov-app + outbox/inbox 同步(版本、墓碑、高优先级下线、补漏对账);点赞批次幂等;WebSocket 气泡(自身批次过滤、后台暂停);浏览统计窗口去重;统计快照回传;观众页切换至 ov-api 并新增最新/热门排序 | T25(仿真部分)T26 T13 T19 T27 T24,T11 完成 | A09 A10 A14 A21 A26 A27(仿真级) | 回归全绿 + 验收记录;D07/D11 初值确认 | 无 |
| P3 Flutter App | 登录、相册配置、协作、持久化上传队列(准备阶段状态机、账号隔离、跨设备服务端历史)、连拍入队、原生后台传输桥接 | T03 T08 T09 T21 | A04 A05 A15 A19(代码 + 模拟器级) | 回归全绿 + 验收记录 | G2(强制)、G3(资源) |
| P4a 后台完备 + 账号治理 | Art Design Pro 全功能后台:相册/照片/用量管理与告警、上传公开开关、批量隐藏、负责人交接、水印版本与历史重生成、下载两级控制、后台语言切换与账号管理(激活停用/重置密码/角色)、用户定期同步任务与管理页(mock 源) | T10 T20 T22 T12 T30 T04(mock 部分) | A16 A17 A18 A20 A23 A29(mock 级) | 回归全绿 + 验收记录;D06 前置 | G4(真实接口) |
| P4b 传播组件 | embed-loader + 展示位管理 + 轮播 iframe:configVersion 校验、旧响应丢弃、不可见暂停、postMessage 白名单、data-lang 语言配置 | T14 T23 T15 | A11 A12 A22;A23 组件部分 | 回归全绿 + 验收记录 + 全端语言验收(T15/A12) | 无 |
| P5 真实环境接入 | 真 OSS/STS 适配器验证、海外部署与域名、全球 CDN 接入与失效策略、用户接口真实对接、跨地域故障演练、容量压测、试运行与分发交接 | T16 T17 T25(真实)T28 T29 T18 | A24 A25 A28 + 全量 A 回归 + 容量报告 | 发布门槛(对齐项目计划 7.3) | G5、G6 |
阶段顺序 P1 → P2 → P3 → P4a → P4b → P5。P3 与 P4a 在 P2 契约稳定后可并行;P4b 依赖 P2 的公开列表契约与同步通道(展示位事件扩展),其 T15 全端语言验收还需 P3 完成;P5 依赖全部前序。
每阶段出口执行统一回归门:lint + typecheck + 单元 + 集成 + E2E 全绿,并产出《阶段验收记录》(docs/plans/progress/,逐项对照 A 编号登记证据与未尽事项)。
## 5 决策挂起清单
| 决策 | 内容 | 最晚确认时点 | 本次状态 |
|---|---|---|---|
| D01 | 首发语言(建议 zh-CN、en) | P1 开始前 | 待确认,P1 按建议值开发词条结构 |
| D02 | 后端与 Web 技术栈 | — | 已锁定(本总纲第 1 节) |
| D03 | iOS/Android 分发方式 | P3 开始前(资源自首周准备) | 待确认 |
| D04 | 删除回收策略 | 永久清理规则 P4a 前 | 软删除基线已随 V4 纳入 P1 |
| D05 | 数量并发与网络目标 | P5 压测计划前 | 待确认,按项目计划 L1/L2 阶梯准备 |
| D06 | 下载水印与原图默认值 | P4a 开始前 | 待确认 |
| D07 | 浏览统计窗口(建议 30 分钟)与主办字段 | P2 验收前给初值 | 待确认 |
| D08 | 用户接口契约(稳定 ID、字段、鉴权、停启用语义) | P4a 开始前 | 待确认,mock 先行 |
| D09 | 海外 ECS 地域规格确认与测试点 | P5 开始前 | 海外 ECS 已就绪(1Panel + Docker + MySQL),规格与地域待登记 |
| D10 | 缓存失效窗口、轮询周期、用量阈值 | 对应模块(P4b/P5)验收前 | 待确认 |
| D11 | 跨境同步参数(补漏周期、回传周期、中断策略) | P2 验收前给初值,P5 实测冻结 | 待确认 |
| D12 | 两台 ECS 的 MySQL 版本、字符集与配置确认 | P1 开始前 | 已确认为 8.0+(2026-09-17),本地容器用 8.4 对齐 |
| D13 | 代码托管平台与 CI 接入 | P5 镜像构建链路前(建议 P2 前) | 待确认,本地 git + 脚本先行 |
## 6 人工关口与外部资源
| 关口 | 内容 | 时点 | 阻塞范围 |
|---|---|---|---|
| G1 | 真实 OSS 冒烟:presign/STS、head 核验、禁止覆盖、staging→originals 受控复制、跨域、现有 Bucket 生命周期规则对项目前缀的影响检查 | P1 出口(有凭证即执行,无凭证显式跳过并登记) | OSS 适配器定稿 |
| G2 | 真机双平台 + 真 OSS 组合验证:锁屏、切后台、厂商后台管理、凭证过期恢复、HEIC 真机样本解码(对齐技术验证用例 6.4) | P3 出口(强制) | App 上传链路发布 |
| G3 | macOS/Xcode 构建链、签名与分发资质 | P3 资源(自 P1 起准备) | iOS 构建 |
| G4 | 用户接口真实契约与测试凭据 | P4a | T30 真实对接 |
| G5 | 海外 ECS(已装 1Panel + Docker + MySQL)规格与地域确认、域名、全球 CDN 权限 | P5 | 海外部署与 CDN |
| G6 | 试运行真实活动与业务验收 | P5 末 | 发布 |
## 7 工程治理
- 版本库:项目当前未初始化 git。P1 第一个任务执行 `git init`,trunk-based 单主干,约定式提交信息,每任务至少一次提交,阶段完成打 tag(p1、p2…)。
- 分支与回退:阶段 tag 作为回退基线;数据库迁移保持向前兼容,不做破坏性逆迁移(对齐技术实施方案 12.4)。
- 密钥:全部经 .env 注入,仓库只提交 .env.example;不提交任何 OSS、接口、签名凭据。
- 验收留痕:每阶段验收记录对照 A 编号登记;代理执行的关键决策(如契约变更)记录在阶段计划文档内。
- 代码托管与 CI:本地 git 起步即满足回归门;仓库托管平台与 CI 接入为待确认项(D13),确认前以本地脚本保证质量,P5 镜像构建发布链路随之补齐。
- T01 原型的替代:纸面原型由"契约评审(用户确认点)+ P1 即可运行的观众页与后台最小页"替代,页面字段与权限以 R 编号与契约为准。
## 8 各阶段范围明细
### P1 底座 + 国内核心链路
包含:
- git 初始化、monorepo 脚手架(pnpm + Turborepo)、compose(MySQL/Redis/MinIO,cn 侧,版本对齐生产)、基础 lint/typecheck/test 脚本。
- packages/contracts:P1 范围 OpenAPI 契约(auth、albums、members、uploads、photos、public read)+ 事件契约(photo.published 等)+ 错误码;完成后设用户确认点。
- Prisma cn schema 与迁移(MySQL 方言):users、sessions、albums、album_translations、album_members、categories、category_translations、album_assets、photos、photo_variants、upload_sessions、photo_jobs、outbox_events、audit_logs。
- 数据模型 PG→MySQL 语义复核清单:唯一约束与 upsert 行为、时间精度与 UTC 存储、排序与游标稳定性、token 大小写敏感(显式 binary 校对或受限字符集)、连接池上限。
- cn-api:密码登录/刷新/注销(成熟密码哈希、会话可撤销、登录失败基础限速);相册 CRUD 与多语言字段、成员与分类;上传会话幂等初始化(clientTaskId 唯一)、临时凭证签发(StoragePort)、续签、完成核验(head + 字节数 + 完整性)、staging→originals 受控复制(禁止覆盖);照片可见性状态机与单张隐藏/软删除;公开读接口(相册信息按语言回退、游标分页稳定排序、单图定位);图片短期签名 URL 签发与过期刷新;outbox 事务写入;CORS 来源按环境配置;/health。
- worker:BullMQ 消费者 + sharp 流水线(真实类型校验、解码、方向校正、缩略图/浏览图、EXIF 清理、尺寸记录),事务切换公开状态并写 photo.published 事件;失败归类与有限重试。
- viewer 基础页:活动信息区(宣传图、多语言回退、浏览次数占位、活动时间按相册时区展示)、双列瀑布流(宽高预留、按需渲染/懒加载)、大图基础交互(左右切换、双指缩放、关闭回原位)、单图分享定位路由、空相册本地化空态、新照片提示(轮询)、分类筛选、zh-CN/en 切换;最新/热门排序切换依赖点赞数,随 P2 提供。
- admin 最小页(后段低优先级):Art Design Pro 登录对接本地会话、相册列表/创建/编辑、成员管理。
- 宣传图/封面/Logo 上传接口与 viewer 展示(album_assets 的 presign+确认,复用照片上传机制)随 P1b 交付(R09 依赖,P1a 先建表不出接口)。
- E2E:脚本上传(多文件、同名、重复完成通知)→ worker 处理 → 公开 API 可见 → Playwright 观众页冒烟。
不包含:海外同步与互动(P2)、Flutter(P3)、批量隐藏/交接/水印/下载控制/用量(P4a)、展示位与组件(P4b)、真实 OSS 常态接入(G1/P5)。
P1 内部再切 1a(后端链路,API 级 E2E 先行)与 1b(观众页与 admin 界面);admin 最小页如遇模板接入风险可顺延至 P4a,不阻塞 P1 出口。
### P2 双地域仿真 + 互动
包含:ov schema(展示副本、inbox、水位、点赞批次、浏览会话与请求去重、统计权威)与 like_batches 去重窗口方案(MySQL 表分区要求分区键进入全部唯一键、会破坏批次唯一约束,故采用索引 + 定时清理任务 + 过期批次拒绝重放替代分区,清理任务纳入 infra/migrations 与调度);sync 服务(outbox 发送器签名鉴权、inbox 幂等消费、版本与墓碑、高优先级下线队列、定时补漏对账);海外紧急停用入口(独立于同步链路的 ov 侧运维端点,跨境中断时可达:仅允许更严屏蔽、不修改国内权威数据、不因普通同步事件自动解除——技术方案 §2A);ov-app 公开 API + WebSocket(相册隔离订阅、点赞批次幂等、合并广播、自身批次过滤、断线降级轮询);浏览统计(跨标签页共享匿名标识、窗口去重、requestId 幂等、并发首访唯一、排除规则:分页请求/图片资源请求/轮播/后台预览不计入,后台预览走独立路由);统计快照回传(statVersion 单调替换);观众页切换 ov-api 并接入实时更新,新增最新/热门排序切换(热门按点赞数版本化短期快照)。D07、D11 初值在验收前确认。
### P3 Flutter App
包含:登录与账号隔离、修改密码;App 内语言切换(zh-CN/en,跟随设备与手动选择);相册配置与协作页面(含相册分享链接与二维码入口);本地任务库与状态机(preparing/preparation_failed/queued/uploading/…);准备阶段(源文件获取、空间检查);连续拍摄自动入队;原生后台上传桥接(iOS URLSession background、Android 用户发起传输任务/前台服务)与任务映射恢复;凭证过期续签(单文件 PUT 为上传基线,分片上传作为后续大文件验证项);跨设备服务端历史。出口强制 G2 真机验证:未通过不标志 P3 完成、不阻塞 P4a/P4b 推进,但阻塞 P5 试运行并触发重估。
### P4a 后台完备 + 账号治理
包含:后台全功能(相册/照片管理、筛选、批量隐藏逐项结果、上传公开独立开关、负责人转移、用量统计与阈值告警(含统计数字与同步时间后台展示)、相册分享链接与二维码展示、后台语言切换、账号管理:激活停用/重置密码/角色);水印配置版本化与历史重生成任务(进度、可重试、不覆盖原图、原子切换引用);下载两级控制与微信等浏览器引导;用户定期同步任务(调度锁、水位、幂等、失败记录、手动补拉、状态页)接 mock 源。G4 后替换真实适配器。
### P4b 传播组件
包含:embed-loader(版本化地址、防重复初始化、postMessage 白名单、data-lang 语言配置);展示位管理与权限(展示位实体与 configVersion 纳入 cn→ov 内容同步,海外公开组件 API 读取本地副本,对应 R28);轮播 iframe(configVersion、旧响应丢弃、近期照片队列、不可见暂停、空态);接入样例站点与宿主 CSP 说明;出口执行 T15 全端语言验收与文案补齐(App、后台、观众页、组件四端切换、回退与漏译检查,对应 A12)。
### P5 真实环境接入
包含:OSS 真实适配器与 STS 验证;国内与海外两台 ECS(均已装 1Panel + Docker + 既有 MySQL 容器)上的部署:镜像与 compose 编排交由 1Panel 容器管理、域名与 TLS 经 1Panel OpenResty 反代、复用既有 MySQL 容器各建独立库与账号、Redis 以容器部署、1Panel 计划备份并演练恢复、数据库迁移由发布流程执行(不手工进容器操作);海外首次全量快照与增量追赶;全球 CDN 回源、URL 鉴权、缓存失效与预热策略;用户接口真实对接;跨地域故障演练(断网重放、乱序、重复回传、紧急停用、恢复);容量压测(L1/L2)与实测报告;监控告警、回滚演练;试运行与 App 分发交接(服务端保持必要的旧客户端兼容)。
## 9 风险与应对(实施总纲层)
| 风险 | 影响 | 应对 |
|---|---|---|
| MinIO 与 OSS 语义差异(STS 形态、禁止覆盖、分片) | 适配器返工 | StoragePort 归一化 + G1 前置冒烟 + G2 真机组合验证 |
| 数据模型从 PG 假设迁移到 MySQL 的语义差异 | 去重、并发、排序行为偏差 | P1 首批任务含语义复核清单(唯一约束、upsert、UTC 时间精度、token 校对、游标排序);去重窗口替代分区,P2 用真实并发事务测试验证 |
| 复用 1Panel 既有 MySQL 容器 | 与其他业务争用资源、备份耦合 | 独立库 + 独立账号 + 连接池上限;纳入 1Panel 计划备份并演练恢复;用量告警(A23)覆盖数据库资源 |
| 生产 MySQL 实际版本低于 8.0 | 迁移与去重 SQL 兼容性问题 | D12 于 P1 开始前确认;必要时在 1Panel 新建 8.x 容器专用本项目,原容器不动 |
| sharp HEIC 解码在 Windows 预编译不可用 | iPhone 照片失败 | P1 用样本 HEIC 验证;不可用则换 libheif 路线并登记 |
| Art Design Pro 模板升级或裁剪成本 | 后台进度受阻 | 锁定模板版本;接入置于 P1 尾部不阻塞主线 |
| 本地仿真与真实跨境网络差异 | P5 问题集中暴露 | 同步语义全部版本化/幂等;P5 演练清单对齐项目计划 7.3 |
| 决策挂起未按时确认 | 阶段出口阻塞 | 第 5 节清单随阶段计划再次提醒,未确认按建议值开发并标记 |
| 代理长周期执行上下文漂移 | 实现偏离文档 | 每阶段独立计划 + 验收记录 + 契约单一事实源 |
## 10 与项目计划(S1-S6 / 岗位人日)的关系
本总纲面向代理执行重新纵切,阶段与 S1-S6 不一一对应:S1 需求基线由本总纲 + 契约评审替代;S2 技术验证拆入 G1(P1 出口可选)与 G2/G3(P3)前置;S3 对应 P1;S4 对应 P2 + P3 + P4a 主体;S5 对应 P4a/P4b + P5 前段;S6 对应 P5 末段。T01-T30 全覆盖(T01 由契约评审与早可用页面替代;T04 由 P4a 的 mock 契约先行 + G4/P5 真实接口验证替代;T15 落在 P4b 出口的全端语言验收)。原岗位人日估算(156 至 231)作为规模参照,代理执行的实际节奏以阶段验收记录为准;对外承诺的日历工期仍以项目计划 W1-W16 基线管理,两者并行不悖。
View File