# 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` 只做路由代理,不承载附件业务。