31 KiB
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,并通过AUTHservice binding 调用cfw-auth。cfw-gateway:只新增/api/attachments/*路由代理到cfw-attachment,不承载附件业务。
cfw-attachment/wrangler.jsonc 必须包含:
"services": [
{
"binding": "AUTH",
"service": "cfw-auth"
}
]
文件结构
- 新建
package.json:定义cfw-attachment包、脚本和依赖。 - 新建
tsconfig.json、vitest.config.ts、.gitignore:TypeScript 和测试配置。 - 新建
wrangler.jsonc:Worker 名称、D1、R2、AUTHservice 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:新增ATTACHMENTbinding 类型。 - 修改
../cfw-gateway/src/route-policy.ts:新增/api/attachments/路由。 - 修改
../cfw-gateway/src/index.ts:代理附件服务并纳入 OpenAPI 聚合。 - 修改
../cfw-gateway/wrangler.jsonc:新增ATTACHMENTservice 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
{
"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.id,fake 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_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:
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"
任务 5:实现分类、标签和分页 search
文件:
-
修改:
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
预期:typecheck 和 test 全部通过。
- 步骤 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通过AUTHservice binding 获取当前登录用户。attachments.user_id、分类、标签、业务关联和审计都关联当前登录用户。- search 接口支持分页、关键词、分类、标签和状态过滤。
- CRUD、上传、finalize、下载、软删除都有测试。
cfw-gateway只做路由代理,不承载附件业务。