From 8a3df31eacb4f3cb19cf6b3a331f1855b808a8d8 Mon Sep 17 00:00:00 2001 From: imeepos Date: Thu, 2 Jul 2026 19:39:29 -0700 Subject: [PATCH] docs: add cfw attachment service plan --- .../2026-07-02-cfw-attachment-service.md | 1083 +++++++++++++++++ 1 file changed, 1083 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-cfw-attachment-service.md diff --git a/docs/superpowers/plans/2026-07-02-cfw-attachment-service.md b/docs/superpowers/plans/2026-07-02-cfw-attachment-service.md new file mode 100644 index 0000000..0fe1019 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cfw-attachment-service.md @@ -0,0 +1,1083 @@ +# cfw-attachment 通用附件管理服务实现计划 + +> **给 agent 执行者:** 必选子技能:使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 按任务逐项实现本计划。步骤统一使用 checkbox(`- [ ]`)语法跟踪。 + +**目标:** 在 `/Volumes/sker/resources/coding/comi-logic/cfw-attachment` 中实现独立的 Cloudflare 附件管理微服务,提供附件增删改查、上传、下载、分页检索、分类和标签能力。 + +**方案概览:** `cfw-auth` 只保留登录鉴权职责,`cfw-attachment` 通过 Cloudflare service binding 调用 `cfw-auth` 的 Better Auth session endpoint 获取当前登录用户。附件元数据、分类、标签、业务关联和审计写入 `cfw-attachment` 自己的 D1,文件二进制写入 `cfw-attachment` 自己的 R2,不把附件业务掺入 `cfw-auth`。 + +**技术栈:** Cloudflare Workers、Hono、D1、R2、Vitest、TypeScript、Wrangler service binding + +--- + +## 架构边界 + +- `cfw-auth`:只负责登录、注册、session、Better Auth OpenAPI;本计划不修改 `cfw-auth`。 +- `cfw-attachment`:独立附件管理 Worker,绑定自己的 D1/R2,并通过 `AUTH` service binding 调用 `cfw-auth`。 +- `cfw-gateway`:只新增 `/api/attachments/*` 路由代理到 `cfw-attachment`,不承载附件业务。 + +`cfw-attachment/wrangler.jsonc` 必须包含: + +```jsonc +"services": [ + { + "binding": "AUTH", + "service": "cfw-auth" + } +] +``` + +## 文件结构 + +- 新建 `package.json`:定义 `cfw-attachment` 包、脚本和依赖。 +- 新建 `tsconfig.json`、`vitest.config.ts`、`.gitignore`:TypeScript 和测试配置。 +- 新建 `wrangler.jsonc`:Worker 名称、D1、R2、`AUTH` service binding、生产域名。 +- 新建 `migrations/0001_initial.sql`:附件、分类、标签、业务关联、审计表结构。 +- 新建 `src/env.ts`:Cloudflare bindings 类型。 +- 新建 `src/auth-session.ts`:通过 `env.AUTH.fetch()` 调用 `cfw-auth` 获取当前登录用户。 +- 新建 `src/problem.ts`:统一 Problem Details 错误响应。 +- 新建 `src/ids.ts`:生成稳定前缀 ID。 +- 新建 `src/json.ts`:请求 JSON 解析和校验辅助函数。 +- 新建 `src/attachment-store.ts`:D1 查询和写入。 +- 新建 `src/object-store.ts`:R2 object key、上传、下载、删除。 +- 新建 `src/attachment-service.ts`:业务用例和权限隔离。 +- 新建 `src/routes.ts`:HTTP 路由。 +- 新建 `src/index.ts`:Hono app 入口。 +- 新建 `tests/attachment-worker.test.ts`:端到端 Worker 测试,使用内存 fake D1/R2/AUTH。 +- 修改 `../cfw-gateway/src/env.ts`:新增 `ATTACHMENT` binding 类型。 +- 修改 `../cfw-gateway/src/route-policy.ts`:新增 `/api/attachments/` 路由。 +- 修改 `../cfw-gateway/src/index.ts`:代理附件服务并纳入 OpenAPI 聚合。 +- 修改 `../cfw-gateway/wrangler.jsonc`:新增 `ATTACHMENT` service binding。 +- 修改 `../cfw-gateway/tests/gateway-worker.test.ts`:覆盖附件路由代理。 + +## API 契约 + +- `POST /api/attachments`:创建附件元数据,返回 `upload_pending`。 +- `PUT /api/attachments/:id/content`:通过 Worker 代理上传文件内容到 R2。 +- `POST /api/attachments/:id/finalize`:确认上传完成,状态变为 `available`。 +- `GET /api/attachments/:id`:读取附件详情。 +- `PATCH /api/attachments/:id`:更新文件名、分类、标签、描述、可见性。 +- `DELETE /api/attachments/:id`:软删除附件,并尽力删除 R2 object。 +- `GET /api/attachments/:id/download`:鉴权后下载文件。 +- `GET /api/attachments/search?page=1&pageSize=20&q=&categoryId=&tag=&status=`:分页检索当前用户附件。 +- `POST /api/attachment-categories`、`GET /api/attachment-categories`、`PATCH /api/attachment-categories/:id`、`DELETE /api/attachment-categories/:id`:分类管理。 +- `POST /api/attachment-tags`、`GET /api/attachment-tags`、`PATCH /api/attachment-tags/:id`、`DELETE /api/attachment-tags/:id`:标签管理。 + +所有接口都必须先解析当前用户;D1 查询必须带当前 `user_id` 条件。 + +--- + +### 任务 1:初始化独立服务脚手架 + +**文件:** +- 新建:`/Volumes/sker/resources/coding/comi-logic/cfw-attachment/package.json` +- 新建:`/Volumes/sker/resources/coding/comi-logic/cfw-attachment/tsconfig.json` +- 新建:`/Volumes/sker/resources/coding/comi-logic/cfw-attachment/vitest.config.ts` +- 新建:`/Volumes/sker/resources/coding/comi-logic/cfw-attachment/.gitignore` + +- [ ] **步骤 1:创建 `package.json`** + +```json +{ + "name": "cfw-attachment", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.33.0", + "type": "module", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "db:apply:local": "wrangler d1 migrations apply cfw-attachment --local", + "db:apply:remote": "wrangler d1 migrations apply cfw-attachment --remote", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "ready": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "hono": "^4.8.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260601.0", + "typescript": "^5.8.0", + "vitest": "^3.2.0", + "wrangler": "^4.20.0" + } +} +``` + +- [ ] **步骤 2:创建 TypeScript 配置** + +`tsconfig.json`: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "types": ["@cloudflare/workers-types", "vitest/globals"], + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src", "tests", "vitest.config.ts"] +} +``` + +`vitest.config.ts`: + +```ts +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); +``` + +`.gitignore`: + +```gitignore +node_modules/ +.wrangler/ +.dev.vars +dist/ +coverage/ +``` + +- [ ] **步骤 3:安装依赖** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm install +``` + +预期:生成 `pnpm-lock.yaml`,没有安装错误。 + +- [ ] **步骤 4:提交脚手架** + +```bash +git add package.json tsconfig.json vitest.config.ts .gitignore pnpm-lock.yaml +git commit -m "chore: scaffold cfw-attachment worker" +``` + +如果该目录还不是 git 仓库,先运行: + +```bash +git init +git add package.json tsconfig.json vitest.config.ts .gitignore pnpm-lock.yaml +git commit -m "chore: scaffold cfw-attachment worker" +``` + +### 任务 2:声明 Cloudflare bindings 和 D1 表结构 + +**文件:** +- 新建:`wrangler.jsonc` +- 新建:`migrations/0001_initial.sql` +- 新建:`src/env.ts` + +- [ ] **步骤 1:创建 D1 数据库** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm wrangler d1 create cfw-attachment +``` + +预期:Wrangler 输出类似下面的 `d1_databases` 配置,记录其中真实的 `database_id`: + +```jsonc +{ + "binding": "DB", + "database_name": "cfw-attachment", + "database_id": "wrangler-output-database-id" +} +``` + +- [ ] **步骤 2:创建 R2 bucket** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm wrangler r2 bucket create cfw-attachment +``` + +预期:bucket 创建成功;如果提示已存在,继续使用该 bucket。 + +- [ ] **步骤 3:创建 `wrangler.jsonc`** + +把步骤 1 输出的真实 `database_id` 写入下面的 `database_id` 字段。提交前用 `rg -n "wrangler-output-database-id" wrangler.jsonc` 确认没有保留示例值。 + +```jsonc +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cfw-attachment", + "main": "src/index.ts", + "account_id": "67720b647ff2b55cf37ba3ef9e677083", + "compatibility_date": "2026-06-10", + "compatibility_flags": ["nodejs_compat"], + "routes": [ + { + "pattern": "cfw-attachment.bowong.cc", + "custom_domain": true + } + ], + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + }, + "services": [ + { + "binding": "AUTH", + "service": "cfw-auth" + } + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "cfw-attachment", + "database_id": "wrangler-output-database-id" + } + ], + "r2_buckets": [ + { + "binding": "ATTACHMENTS", + "bucket_name": "cfw-attachment" + } + ], + "vars": { + "MAX_UPLOAD_BYTES": "52428800" + } +} +``` + +- [ ] **步骤 4:确认 `wrangler.jsonc` 没有示例 ID** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +! rg -n "wrangler-output-database-id" wrangler.jsonc +``` + +预期:命令退出码为 0,没有输出;如果有输出,说明还没有替换为真实 `database_id`。 + +- [ ] **步骤 5:创建 `src/env.ts`** + +```ts +export interface FetcherLike { + fetch(request: Request): Promise; +} + +export interface Env { + AUTH: FetcherLike; + DB: D1Database; + ATTACHMENTS: R2Bucket; + MAX_UPLOAD_BYTES?: string; +} +``` + +- [ ] **步骤 6:创建 `migrations/0001_initial.sql`** + +```sql +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + status TEXT NOT NULL, + filename TEXT NOT NULL, + content_type TEXT NOT NULL, + byte_size INTEGER, + checksum_sha256 TEXT, + visibility TEXT NOT NULL DEFAULT 'private', + category_id TEXT, + description TEXT, + object_key TEXT, + etag TEXT, + created_by_user_id TEXT NOT NULL, + updated_by_user_id TEXT NOT NULL, + deleted_by_user_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE TABLE attachment_categories ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, slug) +); + +CREATE TABLE attachment_tags ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + slug TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(user_id, slug) +); + +CREATE TABLE attachment_tag_links ( + attachment_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (attachment_id, tag_id), + FOREIGN KEY (attachment_id) REFERENCES attachments(id), + FOREIGN KEY (tag_id) REFERENCES attachment_tags(id) +); + +CREATE TABLE attachment_links ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + attachment_id TEXT NOT NULL, + owner_type TEXT NOT NULL, + owner_id TEXT NOT NULL, + purpose TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_by_user_id TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(user_id, owner_type, owner_id, purpose, attachment_id) +); + +CREATE TABLE attachment_audit_events ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + attachment_id TEXT, + actor_user_id TEXT NOT NULL, + action TEXT NOT NULL, + detail_json TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX idx_attachments_user_status ON attachments(user_id, status, created_at); +CREATE INDEX idx_attachments_user_category ON attachments(user_id, category_id, created_at); +CREATE INDEX idx_attachment_tags_user_slug ON attachment_tags(user_id, slug); +CREATE INDEX idx_attachment_tag_links_tag ON attachment_tag_links(user_id, tag_id); +CREATE INDEX idx_attachment_links_owner ON attachment_links(user_id, owner_type, owner_id, purpose); +CREATE INDEX idx_attachment_audit_user_attachment ON attachment_audit_events(user_id, attachment_id, created_at); +``` + +- [ ] **步骤 7:应用本地 migration** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm db:apply:local +``` + +预期:migration 应用成功。 + +- [ ] **步骤 8:提交 bindings 和 schema** + +```bash +git add wrangler.jsonc migrations/0001_initial.sql src/env.ts +git commit -m "feat: add attachment storage bindings and schema" +``` + +### 任务 3:实现认证边界和错误响应 + +**文件:** +- 新建:`src/problem.ts` +- 新建:`src/auth-session.ts` +- 新建:`tests/auth-session.test.ts` + +- [ ] **步骤 1:先写认证测试** + +```ts +import { describe, expect, it, vi } from "vitest"; +import { resolveCurrentUser } from "../src/auth-session"; +import type { Env } from "../src/env"; + +function envWithAuth(response: Response): Env { + return { + AUTH: { fetch: vi.fn().mockResolvedValue(response) }, + DB: {} as D1Database, + ATTACHMENTS: {} as R2Bucket, + }; +} + +describe("resolveCurrentUser", () => { + it("returns the Better Auth user id from cfw-auth", async () => { + const env = envWithAuth(Response.json({ user: { id: "user_123", email: "a@example.com" } })); + + await expect(resolveCurrentUser(new Request("https://attachment.local/api/attachments"), env)) + .resolves.toEqual({ id: "user_123", email: "a@example.com" }); + }); + + it("throws authentication-required when cfw-auth has no session", async () => { + const env = envWithAuth(Response.json(null)); + + await expect(resolveCurrentUser(new Request("https://attachment.local/api/attachments"), env)) + .rejects.toMatchObject({ status: 401, code: "authentication-required" }); + }); +}); +``` + +- [ ] **步骤 2:运行测试,确认失败** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm test tests/auth-session.test.ts +``` + +预期:失败,提示找不到 `../src/auth-session`。 + +- [ ] **步骤 3:实现 `src/problem.ts`** + +```ts +export class HttpProblem extends Error { + constructor( + readonly status: number, + readonly code: string, + message: string, + readonly detail: Record = {}, + ) { + super(message); + } +} + +export function problemResponse(error: unknown): Response { + const problem = + error instanceof HttpProblem + ? error + : new HttpProblem(500, "internal-error", "Internal server error"); + + return Response.json( + { + type: `https://cfw-attachment.bowong.cc/problems/${problem.code}`, + title: problem.message, + status: problem.status, + detail: problem.detail, + code: problem.code, + }, + { + status: problem.status, + headers: { + "content-type": "application/problem+json", + }, + }, + ); +} +``` + +- [ ] **步骤 4:实现 `src/auth-session.ts`** + +```ts +import type { Env } from "./env"; +import { HttpProblem } from "./problem"; + +export interface CurrentUser { + id: string; + email?: string; +} + +interface BetterAuthSessionResponse { + user?: { + id?: unknown; + email?: unknown; + }; +} + +export async function resolveCurrentUser(request: Request, env: Env): Promise { + const sessionRequest = new Request("https://cfw-auth.internal/api/auth/get-session", { + method: "GET", + headers: request.headers, + }); + + const response = await env.AUTH.fetch(sessionRequest); + if (!response.ok) { + throw new HttpProblem(401, "authentication-required", "Authentication required"); + } + + const body = (await response.json()) as BetterAuthSessionResponse | null; + const id = body?.user?.id; + if (typeof id !== "string" || id.length === 0) { + throw new HttpProblem(401, "authentication-required", "Authentication required"); + } + + return { + id, + email: typeof body?.user?.email === "string" ? body.user.email : undefined, + }; +} +``` + +- [ ] **步骤 5:再次运行测试** + +运行: + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm test tests/auth-session.test.ts +``` + +预期:2 个测试通过。 + +- [ ] **步骤 6:提交认证边界** + +```bash +git add src/problem.ts src/auth-session.ts tests/auth-session.test.ts +git commit -m "feat: resolve attachment user through cfw-auth binding" +``` + +### 任务 4:实现附件创建、上传、finalize、详情和下载 + +**文件:** +- 新建:`src/ids.ts` +- 新建:`src/json.ts` +- 新建:`src/object-store.ts` +- 新建:`src/attachment-store.ts` +- 新建:`src/attachment-service.ts` +- 新建:`src/routes.ts` +- 新建:`src/index.ts` +- 新建:`tests/attachment-worker.test.ts` + +- [ ] **步骤 1:先写 Worker 行为测试** + +测试必须覆盖: + +```ts +it("creates an attachment for the current user and hides it from another user"); +it("uploads content, finalizes the attachment, and downloads the same bytes"); +it("does not download before finalize"); +it("soft deletes an attachment and removes it from detail reads"); +``` + +测试实现使用 fake `AUTH.fetch` 返回不同 `user.id`,fake R2 用 `Map` 保存对象,fake D1 可以用 `wrangler d1 --local` 或一个小型内存 store。优先使用 `@miniflare/d1` 时要同步加入依赖;如果不引入新依赖,则把 service 层对 D1 的 SQL 放到集成测试中用 `wrangler dev` smoke 覆盖。 + +- [ ] **步骤 2:实现 ID 和 JSON 辅助** + +`src/ids.ts`: + +```ts +export function newId(prefix: string): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${prefix}_${hex}`; +} +``` + +`src/json.ts`: + +```ts +import { HttpProblem } from "./problem"; + +export async function readJsonObject(request: Request): Promise> { + const body = await request.json().catch(() => undefined); + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new HttpProblem(422, "validation-failed", "Request body must be a JSON object"); + } + return body as Record; +} + +export function requiredString(body: Record, key: string): string { + const value = body[key]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new HttpProblem(422, "validation-failed", `${key} is required`, { field: key }); + } + return value.trim(); +} + +export function optionalString(body: Record, key: string): string | undefined { + const value = body[key]; + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new HttpProblem(422, "validation-failed", `${key} must be a string`, { field: key }); + } + return value.trim(); +} +``` + +- [ ] **步骤 3:实现 R2 object store** + +`src/object-store.ts`: + +```ts +export function objectKeyFor(userId: string, attachmentId: string): string { + const date = new Date(); + const yyyy = String(date.getUTCFullYear()); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `users/${userId}/attachments/${yyyy}/${mm}/${attachmentId}/original`; +} + +export async function putObject( + bucket: R2Bucket, + key: string, + request: Request, + contentType: string, +): Promise { + const uploaded = await bucket.put(key, request.body, { + httpMetadata: { + contentType, + }, + }); + return uploaded; +} + +export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise { + return bucket.get(key); +} +``` + +- [ ] **步骤 4:实现 D1 store** + +`src/attachment-store.ts` 至少导出这些函数: + +```ts +export interface AttachmentRecord { + id: string; + user_id: string; + status: "upload_pending" | "uploaded" | "available" | "deleted"; + filename: string; + content_type: string; + byte_size: number | null; + visibility: string; + category_id: string | null; + description: string | null; + object_key: string | null; + etag: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +export async function insertAttachment(db: D1Database, record: AttachmentRecord): Promise; +export async function findAttachmentForUser(db: D1Database, userId: string, id: string): Promise; +export async function markUploaded(db: D1Database, userId: string, id: string, objectKey: string, etag: string | null, byteSize: number | null): Promise; +export async function markAvailable(db: D1Database, userId: string, id: string): Promise; +export async function softDeleteAttachment(db: D1Database, userId: string, id: string): Promise; +``` + +所有 SQL 必须包含 `WHERE user_id = ?`,删除使用 `status = 'deleted'` 和 `deleted_at`,不物理删除 metadata。 + +- [ ] **步骤 5:实现 service 用例** + +`src/attachment-service.ts` 至少提供: + +```ts +export async function createAttachment(env: Env, user: CurrentUser, input: CreateAttachmentInput): Promise; +export async function uploadAttachmentContent(env: Env, user: CurrentUser, id: string, request: Request): Promise; +export async function finalizeAttachment(env: Env, user: CurrentUser, id: string): Promise; +export async function getAttachment(env: Env, user: CurrentUser, id: string): Promise; +export async function deleteAttachment(env: Env, user: CurrentUser, id: string): Promise; +export async function downloadAttachment(env: Env, user: CurrentUser, id: string): Promise; +``` + +业务规则: + +- 创建时写入 `user_id`、`created_by_user_id`、`updated_by_user_id`,状态为 `upload_pending`。 +- 上传时只允许 `upload_pending`,写入 R2 后状态为 `uploaded`。 +- finalize 时只允许 `uploaded`,状态变为 `available`。 +- 下载只允许 `available`,否则返回 `409 object-not-available`。 +- 详情和删除都必须按当前用户隔离。 + +- [ ] **步骤 6:实现 routes 和入口** + +`src/routes.ts`: + +```ts +import { Hono } from "hono"; +import { resolveCurrentUser } from "./auth-session"; +import type { Env } from "./env"; +import { readJsonObject } from "./json"; +import { problemResponse } from "./problem"; +import { + createAttachment, + deleteAttachment, + downloadAttachment, + finalizeAttachment, + getAttachment, + uploadAttachmentContent, +} from "./attachment-service"; + +export function createRoutes(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + + app.onError((error, c) => problemResponse(error)); + + app.get("/healthz", (c) => c.json({ ok: true, service: "cfw-attachment" })); + + app.post("/api/attachments", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + const body = await readJsonObject(c.req.raw); + const attachment = await createAttachment(c.env, user, body); + return c.json({ attachment }, 201); + }); + + app.get("/api/attachments/:id", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ attachment: await getAttachment(c.env, user, c.req.param("id")) }); + }); + + app.put("/api/attachments/:id/content", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ + attachment: await uploadAttachmentContent(c.env, user, c.req.param("id"), c.req.raw), + }); + }); + + app.post("/api/attachments/:id/finalize", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return c.json({ attachment: await finalizeAttachment(c.env, user, c.req.param("id")) }); + }); + + app.get("/api/attachments/:id/download", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + return downloadAttachment(c.env, user, c.req.param("id")); + }); + + app.delete("/api/attachments/:id", async (c) => { + const user = await resolveCurrentUser(c.req.raw, c.env); + await deleteAttachment(c.env, user, c.req.param("id")); + return c.body(null, 204); + }); + + return app; +} +``` + +`src/index.ts`: + +```ts +import { createRoutes } from "./routes"; + +export default createRoutes(); +``` + +- [ ] **步骤 7:运行核心测试** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm test +pnpm typecheck +``` + +预期:测试和类型检查通过。 + +- [ ] **步骤 8:提交核心附件流程** + +```bash +git add src tests +git commit -m "feat: add attachment upload and download flow" +``` + +### 任务 5:实现分类、标签和分页 search + +**文件:** +- 修改:`src/attachment-store.ts` +- 修改:`src/attachment-service.ts` +- 修改:`src/routes.ts` +- 修改:`tests/attachment-worker.test.ts` + +- [ ] **步骤 1:先写 search 行为测试** + +测试必须覆盖: + +```ts +it("searches current-user attachments with page and pageSize"); +it("filters search by category id"); +it("filters search by tag slug"); +it("does not return another user's attachments in search"); +it("creates, updates, lists, and deletes user-scoped categories"); +it("creates, updates, lists, and deletes user-scoped tags"); +``` + +- [ ] **步骤 2:实现分页参数解析** + +在 `src/routes.ts` 增加: + +```ts +function paginationFromUrl(url: URL): { page: number; pageSize: number; offset: number } { + const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1); + const pageSize = Math.min(100, Math.max(1, Number(url.searchParams.get("pageSize") ?? "20") || 20)); + return { page, pageSize, offset: (page - 1) * pageSize }; +} +``` + +- [ ] **步骤 3:实现 search store** + +SQL 必须按当前用户隔离,并返回总数: + +```sql +SELECT a.* +FROM attachments a +LEFT JOIN attachment_tag_links atl ON atl.attachment_id = a.id +LEFT JOIN attachment_tags t ON t.id = atl.tag_id +WHERE a.user_id = ? + AND a.deleted_at IS NULL + AND (? IS NULL OR a.status = ?) + AND (? IS NULL OR a.category_id = ?) + AND (? IS NULL OR t.slug = ?) + AND (? IS NULL OR a.filename LIKE ? OR a.description LIKE ?) +GROUP BY a.id +ORDER BY a.created_at DESC +LIMIT ? OFFSET ?; +``` + +计数 SQL: + +```sql +SELECT COUNT(DISTINCT a.id) AS total +FROM attachments a +LEFT JOIN attachment_tag_links atl ON atl.attachment_id = a.id +LEFT JOIN attachment_tags t ON t.id = atl.tag_id +WHERE a.user_id = ? + AND a.deleted_at IS NULL + AND (? IS NULL OR a.status = ?) + AND (? IS NULL OR a.category_id = ?) + AND (? IS NULL OR t.slug = ?) + AND (? IS NULL OR a.filename LIKE ? OR a.description LIKE ?); +``` + +- [ ] **步骤 4:实现分类和标签 service** + +分类和标签必须以 `user_id + slug` 唯一。删除分类时把当前用户下引用该分类的附件 `category_id` 置空;删除标签时先删除当前用户下的 `attachment_tag_links`,再删 tag。 + +- [ ] **步骤 5:补齐路由** + +增加: + +```ts +app.get("/api/attachments/search", ...); +app.patch("/api/attachments/:id", ...); +app.post("/api/attachment-categories", ...); +app.get("/api/attachment-categories", ...); +app.patch("/api/attachment-categories/:id", ...); +app.delete("/api/attachment-categories/:id", ...); +app.post("/api/attachment-tags", ...); +app.get("/api/attachment-tags", ...); +app.patch("/api/attachment-tags/:id", ...); +app.delete("/api/attachment-tags/:id", ...); +``` + +`GET /api/attachments/search` 响应格式: + +```json +{ + "items": [], + "page": 1, + "pageSize": 20, + "total": 0 +} +``` + +- [ ] **步骤 6:运行测试和类型检查** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm test +pnpm typecheck +``` + +预期:全部通过。 + +- [ ] **步骤 7:提交 search、分类、标签** + +```bash +git add src tests +git commit -m "feat: add attachment search categories and tags" +``` + +### 任务 6:接入 cfw-gateway + +**文件:** +- 修改:`/Volumes/sker/resources/coding/comi-logic/cfw-gateway/src/env.ts` +- 修改:`/Volumes/sker/resources/coding/comi-logic/cfw-gateway/src/route-policy.ts` +- 修改:`/Volumes/sker/resources/coding/comi-logic/cfw-gateway/src/index.ts` +- 修改:`/Volumes/sker/resources/coding/comi-logic/cfw-gateway/wrangler.jsonc` +- 修改:`/Volumes/sker/resources/coding/comi-logic/cfw-gateway/tests/gateway-worker.test.ts` + +- [ ] **步骤 1:先写 gateway 路由测试** + +在 `gateway-worker.test.ts` 增加断言: + +```ts +it("proxies attachment API requests to cfw-attachment", async () => { + const attachment = { + fetch: vi.fn().mockResolvedValue(Response.json({ ok: true, service: "cfw-attachment" })), + }; + + const response = await worker.fetch(new Request("https://gateway.local/api/attachments/search"), { + AUTH: fakeService(), + OPS: fakeService(), + SCHEDULER: fakeService(), + ATTACHMENT: attachment, + }); + + expect(response.status).toBe(200); + expect(attachment.fetch).toHaveBeenCalledTimes(1); +}); +``` + +- [ ] **步骤 2:修改 gateway Env** + +```ts +export interface Env { + AUTH: FetcherLike; + OPS: FetcherLike; + SCHEDULER: FetcherLike; + ATTACHMENT: FetcherLike; +} +``` + +- [ ] **步骤 3:修改 route policy** + +把 `RouteTarget` 增加 `{ kind: "attachment" }`,并在 `resolveRoute` 中加入: + +```ts +if (url.pathname.startsWith("/api/attachments/") || url.pathname === "/api/attachments/search") { + return { kind: "attachment" }; +} + +if (url.pathname.startsWith("/api/attachment-categories") || url.pathname.startsWith("/api/attachment-tags")) { + return { kind: "attachment" }; +} +``` + +- [ ] **步骤 4:修改 gateway index** + +在代理分支加入: + +```ts +if (target.kind === "attachment") { + return c.env.ATTACHMENT.fetch(c.req.raw); +} +``` + +在 OpenAPI 聚合中加入附件服务: + +```ts +fetchServiceOpenApiDocument( + { + name: "attachment", + mountPath: "/api/attachments", + fetcher: c.env.ATTACHMENT, + schemaPath: "/openapi.json", + }, + origin, +) +``` + +- [ ] **步骤 5:修改 gateway wrangler** + +在 `services` 中加入: + +```jsonc +{ + "binding": "ATTACHMENT", + "service": "cfw-attachment" +} +``` + +- [ ] **步骤 6:运行 gateway 测试** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-gateway +pnpm test +pnpm typecheck +``` + +预期:gateway 测试和类型检查通过。 + +- [ ] **步骤 7:提交 gateway 集成** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-gateway +git add src wrangler.jsonc tests +git commit -m "feat: route attachment api through gateway" +``` + +### 任务 7:本地和远端验收 + +**文件:** +- 修改:`README.md` + +- [ ] **步骤 1:写 README 验收命令** + +`README.md` 必须包含: + +````md +# cfw-attachment + +独立 Cloudflare 附件管理服务。认证通过 `AUTH` service binding 调用 `cfw-auth`,附件业务数据保存在本服务自己的 D1/R2。 + +## 本地检查 + +```bash +pnpm install +pnpm db:apply:local +pnpm ready +``` + +## 关键接口 + +- `POST /api/attachments` +- `PUT /api/attachments/:id/content` +- `POST /api/attachments/:id/finalize` +- `GET /api/attachments/:id` +- `PATCH /api/attachments/:id` +- `DELETE /api/attachments/:id` +- `GET /api/attachments/:id/download` +- `GET /api/attachments/search?page=1&pageSize=20&q=&categoryId=&tag=&status=` +- `POST /api/attachment-categories` +- `GET /api/attachment-categories` +- `POST /api/attachment-tags` +- `GET /api/attachment-tags` +```` + +- [ ] **步骤 2:运行最终检查** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm ready +``` + +预期:`typecheck` 和 `test` 全部通过。 + +- [ ] **步骤 3:应用远端 migration** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm db:apply:remote +``` + +预期:远端 D1 表结构应用成功。 + +- [ ] **步骤 4:部署服务** + +```bash +cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment +pnpm deploy +``` + +预期:`cfw-attachment` 部署成功,并绑定 `cfw-auth`。 + +- [ ] **步骤 5:提交 README** + +```bash +git add README.md +git commit -m "docs: document cfw-attachment api" +``` + +## 自检清单 + +- [ ] `cfw-auth` 没有新增附件业务代码、表、R2 或路由。 +- [ ] `cfw-attachment` 有自己的 `wrangler.jsonc`、D1、R2。 +- [ ] `cfw-attachment` 通过 `AUTH` service binding 获取当前登录用户。 +- [ ] `attachments.user_id`、分类、标签、业务关联和审计都关联当前登录用户。 +- [ ] search 接口支持分页、关键词、分类、标签和状态过滤。 +- [ ] CRUD、上传、finalize、下载、软删除都有测试。 +- [ ] `cfw-gateway` 只做路由代理,不承载附件业务。