感谢你考虑为本项目做出贡献!
- Node.js 20+
- Bun 1.3+
# 克隆仓库
git clone https://github.com/du2333/flare-stack-blog.git
cd flare-stack-blog
# 安装依赖
bun install
# 配置本地环境变量
cp .dev.vars.example .dev.vars
# 编辑 .dev.vars 填入必要的配置
# 启动开发服务器
bun dev访问 http://localhost:3000 查看应用。
每次提交前,确保通过以下检查:
bun check # 类型检查 + Lint + 格式化
bun run test # 运行测试| 分支类型 | 命名规范 | 用途 |
|---|---|---|
main |
- | 生产分支,受保护 |
feature/* |
feature/add-rss |
新功能开发 |
fix/* |
fix/login-error |
Bug 修复 |
refactor/* |
refactor/cache-layer |
代码重构 |
使用清晰的提交信息:
feat: 添加 RSS 订阅功能
fix: 修复登录状态丢失问题
docs: 更新 API 文档
refactor: 重构缓存层
每个功能模块遵循三层架构:
features/<name>/
├── data/ # 数据层:纯 Drizzle 查询,无业务逻辑
├── <name>.service.ts # 服务层:业务逻辑 + 缓存编排
├── <name>.schema.ts # Zod schemas + 缓存 key 工厂
└── api/ # API 层:Server Functions 入口
数据层示例:
// posts.data.ts
export const PostRepo = {
findPostById: (db: DB, id: number) =>
db.select().from(posts).where(eq(posts.id, id)).get(),
};服务层示例:
// posts.service.ts
export async function findPostBySlug(
context: DbContext & { executionCtx: ExecutionContext },
data: { slug: string },
) {
const fetcher = () => PostRepo.findPostBySlug(context.db, data.slug);
const version = await CacheService.getVersion(context, "posts:detail");
return CacheService.get(
context,
POSTS_CACHE_KEYS.detail(version, data.slug),
PostSchema,
fetcher,
);
}服务层返回 Result<T, { reason: string }> 而不是抛出异常:
import { ok, err } from "@/lib/error";
// 服务层
export async function createTag(context: DbContext, name: string) {
const exists = await TagRepo.nameExists(context.db, name);
if (exists) return err({ reason: "TAG_NAME_ALREADY_EXISTS" });
const tag = await TagRepo.insert(context.db, { name });
return ok(tag);
}
// 调用方
const result = await TagService.createTag(context, "React");
if (result.error) {
switch (result.error.reason) {
case "TAG_NAME_ALREADY_EXISTS":
throw new Error("标签已存在");
default:
result.error.reason satisfies never; // 穷尽检查
}
}TanStack Start 中间件按顺序注入依赖:
dbMiddleware → sessionMiddleware → authMiddleware → adminMiddleware
使用示例:
// 公开接口 + 限流
export const createCommentFn = createServerFn()
.middleware([
createRateLimitMiddleware({
capacity: 10,
interval: "1m",
key: "comments:create",
}),
])
.handler(({ data, context }) => CommentService.createComment(context, data));
// 公开接口(仅需数据库)
export const getPostsFn = createServerFn()
.middleware([dbMiddleware])
.handler(({ context }) => PostService.getPosts(context));
// 管理接口(需要认证 + 管理员权限)
export const updatePostFn = createServerFn()
.middleware([adminMiddleware]) // 自动包含 db + session + auth 检查
.handler(({ data, context }) => PostService.updatePost(context, data));双层缓存架构:
| 层 | 技术 | 用途 |
|---|---|---|
| CDN | Cache-Control headers | 边缘缓存,通过页面 headers 或 Hono 路由设置 |
| KV | 版本化 key | 服务端缓存,通过 CacheService 管理 |
失效模式:
// 批量失效:递增版本号
await CacheService.bumpVersion(context, "posts:list");
// 单条失效:删除特定 key
const version = await CacheService.getVersion(context, "posts:detail");
await CacheService.deleteKey(context, POSTS_CACHE_KEYS.detail(version, slug));Query Key 工厂:
export const POSTS_KEYS = {
all: ["posts"] as const,
lists: ["posts", "list"] as const, // 父 key(静态,用于批量失效)
list: (
filters?: { tag?: string }, // 子 key(函数,用于具体查询)
) => ["posts", "list", filters] as const,
};在路由 loader 中使用 ensureQueryData 或 prefetchQuery 预加载数据:
// routes/_public/post/$slug.tsx
export const Route = createFileRoute("/_public/post/$slug")({
loader: async ({ context, params }) => {
// ensureQueryData: 获取并缓存,如果已有数据则不重新请求
const post = await context.queryClient.ensureQueryData(
postBySlugQuery(params.slug),
);
if (!post) throw notFound();
// prefetchQuery: 后台预加载(不阻塞渲染)
void context.queryClient.prefetchQuery(relatedPostsQuery(params.slug));
return post;
},
component: PostPage,
});useSuspenseQuery:配合 loader 使用,数据已预加载,渲染同步useQuery:纯客户端获取,无预加载
// SSR 场景(loader 已预加载)
function PostPage() {
const { slug } = Route.useParams();
const { data: post } = useSuspenseQuery(postBySlugQuery(slug)); // 同步获取
return <article>{post.content}</article>;
}
// 纯客户端场景
function RelatedPosts({ slug }: { slug: string }) {
const { data } = useQuery(relatedPostsQuery(slug)); // 可能显示 loading
// ...
}// 批量失效
queryClient.invalidateQueries({ queryKey: POSTS_KEYS.lists });
// 精确失效
queryClient.invalidateQueries({ queryKey: POSTS_KEYS.list({ tag: "React" }) });使用结构化 JSON 日志,便于在 Workers Observability 中搜索过滤:
// ✅ Good
console.log(JSON.stringify({ message: "cache hit", key: serializedKey }));
console.error(
JSON.stringify({
message: "image transform failed",
key,
error: String(error),
}),
);
// 🔴 Bad
console.log(`[Cache] HIT: ${serializedKey}`);
console.error("Image transform failed:", error);关键业务日志(请求入口、错误、重要事件)使用结构化格式,开发调试日志可保持原样。
| 类型 | 规范 | 示例 |
|---|---|---|
| 组件文件 | kebab-case | post-item.tsx |
| 服务文件 | <name>.service.ts |
posts.service.ts |
| 数据文件 | <name>.data.ts |
posts.data.ts |
| Server Functions | camelCase + Fn |
getPostsFn |
| React 组件 | PascalCase | PostItem |
| 变量/函数 | camelCase | getPosts |
| 类型/接口 | PascalCase | PostItemProps |
| 常量 | SCREAMING_SNAKE_CASE | CACHE_CONTROL |
# 运行所有测试
bun run test
# 运行特定测试
bun run test posts
# 运行单个文件
bun run test src/features/posts/posts.service.test.tsimport {
createAdminTestContext,
seedUser,
waitForBackgroundTasks,
testRequest,
} from "tests/test-utils";
// 创建上下文
const context = createAdminTestContext();
await seedUser(context.db, context.session.user);
// 等待后台任务
await waitForBackgroundTasks(context.executionCtx);
// 测试 Hono 路由
const response = await testRequest(app, "/api/posts");提交 PR 前,确保:
- 通过
bun check(类型检查 + Lint + 格式化) - 通过
bun run test - 新功能有对应的测试覆盖
- 遵循现有的代码模式和命名规范
如有疑问,可以:
- 在 GitHub Discussions 中提问
- 在 Telegram 群组中提问
- 参考
.agent/skills/目录下的开发指南
感谢你的贡献!