Files
cfw-attachment/docs/superpowers/plans/2026-07-02-cfw-attachment-service.md

31 KiB
Raw Blame History

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 必须包含:

"services": [
  {
    "binding": "AUTH",
    "service": "cfw-auth"
  }
]

文件结构

  • 新建 package.json:定义 cfw-attachment 包、脚本和依赖。
  • 新建 tsconfig.jsonvitest.config.ts.gitignoreTypeScript 和测试配置。
  • 新建 wrangler.jsoncWorker 名称、D1、R2、AUTH service binding、生产域名。
  • 新建 migrations/0001_initial.sql:附件、分类、标签、业务关联、审计表结构。
  • 新建 src/env.tsCloudflare 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.tsD1 查询和写入。
  • 新建 src/object-store.tsR2 object key、上传、下载、删除。
  • 新建 src/attachment-service.ts:业务用例和权限隔离。
  • 新建 src/routes.tsHTTP 路由。
  • 新建 src/index.tsHono 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-categoriesGET /api/attachment-categoriesPATCH /api/attachment-categories/:idDELETE /api/attachment-categories/:id:分类管理。
  • POST /api/attachment-tagsGET /api/attachment-tagsPATCH /api/attachment-tags/:idDELETE /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

{
  "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

{
  "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

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
  },
});

.gitignore

node_modules/
.wrangler/
.dev.vars
dist/
coverage/
  • 步骤 3安装依赖

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm install

预期:生成 pnpm-lock.yaml,没有安装错误。

  • 步骤 4提交脚手架
git add package.json tsconfig.json vitest.config.ts .gitignore pnpm-lock.yaml
git commit -m "chore: scaffold cfw-attachment worker"

如果该目录还不是 git 仓库,先运行:

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 数据库

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm wrangler d1 create cfw-attachment

预期Wrangler 输出类似下面的 d1_databases 配置,记录其中真实的 database_id

{
  "binding": "DB",
  "database_name": "cfw-attachment",
  "database_id": "wrangler-output-database-id"
}
  • 步骤 2创建 R2 bucket

运行:

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 确认没有保留示例值。

{
  "$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

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
! rg -n "wrangler-output-database-id" wrangler.jsonc

预期:命令退出码为 0没有输出如果有输出说明还没有替换为真实 database_id

  • 步骤 5创建 src/env.ts
export interface FetcherLike {
  fetch(request: Request): Promise<Response>;
}

export interface Env {
  AUTH: FetcherLike;
  DB: D1Database;
  ATTACHMENTS: R2Bucket;
  MAX_UPLOAD_BYTES?: string;
}
  • 步骤 6创建 migrations/0001_initial.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

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm db:apply:local

预期migration 应用成功。

  • 步骤 8提交 bindings 和 schema
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先写认证测试

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运行测试确认失败

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm test tests/auth-session.test.ts

预期:失败,提示找不到 ../src/auth-session

  • 步骤 3实现 src/problem.ts
export class HttpProblem extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
    readonly detail: Record<string, unknown> = {},
  ) {
    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
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<CurrentUser> {
  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再次运行测试

运行:

cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm test tests/auth-session.test.ts

预期2 个测试通过。

  • 步骤 6提交认证边界
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 行为测试

测试必须覆盖:

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.idfake R2 用 Map<string, Uint8Array> 保存对象fake D1 可以用 wrangler d1 --local 或一个小型内存 store。优先使用 @miniflare/d1 时要同步加入依赖;如果不引入新依赖,则把 service 层对 D1 的 SQL 放到集成测试中用 wrangler dev smoke 覆盖。

  • 步骤 2实现 ID 和 JSON 辅助

src/ids.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

import { HttpProblem } from "./problem";

export async function readJsonObject(request: Request): Promise<Record<string, unknown>> {
  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<string, unknown>;
}

export function requiredString(body: Record<string, unknown>, 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<string, unknown>, 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

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<R2Object> {
  const uploaded = await bucket.put(key, request.body, {
    httpMetadata: {
      contentType,
    },
  });
  return uploaded;
}

export async function getObjectOrNull(bucket: R2Bucket, key: string): Promise<R2ObjectBody | null> {
  return bucket.get(key);
}
  • 步骤 4实现 D1 store

src/attachment-store.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<void>;
export async function findAttachmentForUser(db: D1Database, userId: string, id: string): Promise<AttachmentRecord | null>;
export async function markUploaded(db: D1Database, userId: string, id: string, objectKey: string, etag: string | null, byteSize: number | null): Promise<void>;
export async function markAvailable(db: D1Database, userId: string, id: string): Promise<void>;
export async function softDeleteAttachment(db: D1Database, userId: string, id: string): Promise<void>;

所有 SQL 必须包含 WHERE user_id = ?,删除使用 status = 'deleted'deleted_at,不物理删除 metadata。

  • 步骤 5实现 service 用例

src/attachment-service.ts 至少提供:

export async function createAttachment(env: Env, user: CurrentUser, input: CreateAttachmentInput): Promise<AttachmentRecord>;
export async function uploadAttachmentContent(env: Env, user: CurrentUser, id: string, request: Request): Promise<AttachmentRecord>;
export async function finalizeAttachment(env: Env, user: CurrentUser, id: string): Promise<AttachmentRecord>;
export async function getAttachment(env: Env, user: CurrentUser, id: string): Promise<AttachmentRecord>;
export async function deleteAttachment(env: Env, user: CurrentUser, id: string): Promise<void>;
export async function downloadAttachment(env: Env, user: CurrentUser, id: string): Promise<Response>;

业务规则:

  • 创建时写入 user_idcreated_by_user_idupdated_by_user_id,状态为 upload_pending

  • 上传时只允许 upload_pending,写入 R2 后状态为 uploaded

  • finalize 时只允许 uploaded,状态变为 available

  • 下载只允许 available,否则返回 409 object-not-available

  • 详情和删除都必须按当前用户隔离。

  • 步骤 6实现 routes 和入口

src/routes.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

import { createRoutes } from "./routes";

export default createRoutes();
  • 步骤 7运行核心测试
cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm test
pnpm typecheck

预期:测试和类型检查通过。

  • 步骤 8提交核心附件流程
git add src tests
git commit -m "feat: add attachment upload and download flow"

文件:

  • 修改:src/attachment-store.ts

  • 修改:src/attachment-service.ts

  • 修改:src/routes.ts

  • 修改:tests/attachment-worker.test.ts

  • 步骤 1先写 search 行为测试

测试必须覆盖:

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 增加:

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 必须按当前用户隔离,并返回总数:

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

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补齐路由

增加:

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 响应格式:

{
  "items": [],
  "page": 1,
  "pageSize": 20,
  "total": 0
}
  • 步骤 6运行测试和类型检查
cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm test
pnpm typecheck

预期:全部通过。

  • 步骤 7提交 search、分类、标签
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 增加断言:

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
export interface Env {
  AUTH: FetcherLike;
  OPS: FetcherLike;
  SCHEDULER: FetcherLike;
  ATTACHMENT: FetcherLike;
}
  • 步骤 3修改 route policy

RouteTarget 增加 { kind: "attachment" },并在 resolveRoute 中加入:

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

在代理分支加入:

if (target.kind === "attachment") {
  return c.env.ATTACHMENT.fetch(c.req.raw);
}

在 OpenAPI 聚合中加入附件服务:

fetchServiceOpenApiDocument(
  {
    name: "attachment",
    mountPath: "/api/attachments",
    fetcher: c.env.ATTACHMENT,
    schemaPath: "/openapi.json",
  },
  origin,
)
  • 步骤 5修改 gateway wrangler

services 中加入:

{
  "binding": "ATTACHMENT",
  "service": "cfw-attachment"
}
  • 步骤 6运行 gateway 测试
cd /Volumes/sker/resources/coding/comi-logic/cfw-gateway
pnpm test
pnpm typecheck

预期gateway 测试和类型检查通过。

  • 步骤 7提交 gateway 集成
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 必须包含:

# 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运行最终检查
cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm ready

预期:typechecktest 全部通过。

  • 步骤 3应用远端 migration
cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm db:apply:remote

预期:远端 D1 表结构应用成功。

  • 步骤 4部署服务
cd /Volumes/sker/resources/coding/comi-logic/cfw-attachment
pnpm deploy

预期:cfw-attachment 部署成功,并绑定 cfw-auth

  • 步骤 5提交 README
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 只做路由代理,不承载附件业务。