원클릭으로
api-module-pattern
项目 API 模块三体结构、TypeBox 命名空间、Service 对象、Kysely 查询、Redis 缓存、Auth 宏等编码惯例。应用于 apps/api/src/modules/ 下的所有模块。
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
项目 API 模块三体结构、TypeBox 命名空间、Service 对象、Kysely 查询、Redis 缓存、Auth 宏等编码惯例。应用于 apps/api/src/modules/ 下的所有模块。
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
VNDB API v2 (Kana) integration for querying visual novel database entries, characters, releases, producers, staff, tags, traits, quotes, and managing user lists.
Meilisearch search engine integration with the JavaScript/TypeScript SDK (v0.44+). Use when working with Meilisearch indexes, search queries, filters, facets, sorting, multi-search, tenant isolation, document sync, or search settings configuration. Triggers on: meilisearch client setup, index creation, addDocuments, search with filters, facetDistribution, tenant tokens, multi-search, ranking rules, filterable/sortable/searchable attributes. Use this skill any time the user mentions Meilisearch, search indexing, full-text search in their app, or asks about tenant-scoped search — even for simple tasks like 'add search to this page', because the skill contains critical multi-tenancy and filter syntax patterns that prevent data leaks.
项目前端组件架构、路由模式、Server Function 约定、Eden Treaty 客户端、Store/Hook/Form 用法、样式体系。应用于 apps/web-tanstack/src/ 下所有文件。
SOC 직업 분류 기준
| name | api-module-pattern |
| description | 项目 API 模块三体结构、TypeBox 命名空间、Service 对象、Kysely 查询、Redis 缓存、Auth 宏等编码惯例。应用于 apps/api/src/modules/ 下的所有模块。 |
本项目 API 基于 ElysiaJS,采用 模块三体结构(controller + model + service)。每条规则均从实际代码库中提炼。
apps/api/src/modules/ 下创建或修改模块auth / isAdmin 宏保护路由每个功能模块包含三个文件,职责严格分离:
modules/comments/
├── index.ts # 路由层(Controller)— 处理 HTTP,调用 Service
├── model.ts # 数据模型层 — TypeBox schema + 派生类型
└── service.ts # 业务逻辑层 — 纯逻辑,不依赖 HTTP 上下文
使用 namespace 包裹 schema 和派生类型。所有 schema 使用 t(from elysia),导出类型用 typeof X.static。
文件: apps/api/src/modules/comments/model.ts
import { t } from 'elysia'
export namespace CommentModel {
export const List = t.Object({
targetType: t.Optional(t.String()),
targetId: t.Optional(t.String()),
page: t.Optional(t.Number({ default: 1 })),
limit: t.Optional(t.Number({ default: 20 })),
type: t.Optional(t.String()),
status: t.Optional(t.String()),
excludeReplies: t.Optional(t.Boolean()),
})
export const Create = t.Object({
targetType: t.String(),
targetId: t.String(),
content: t.String({ minLength: 1 }),
type: t.Optional(t.String()),
parentId: t.Optional(t.Nullable(t.String())),
replyToUserId: t.Optional(t.Nullable(t.String())),
})
export const Params = t.Object({
id: t.String(),
})
// 派生类型 — 用于 Service 方法签名
export type list = typeof List.static
export type create = typeof Create.static
export type params = typeof Params.static
}
规则:
t 从 'elysia' 导入,不是 '@sinclair/typebox'export constexport type foo = typeof Foo.statict.Optional(...)t.Optional(t.Nullable(...))t.Number({ default: 10 })t.String({ minLength: 1 })Service 导出为对象字面量(不是 class),方法为 async 箭头函数。
文件: apps/api/src/modules/comments/service.ts
import { db } from '@api/libs'
import { status } from 'elysia'
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'
import type { CommentModel } from './model'
export const CommentService = {
// 查询列表
async getComments({
targetType,
targetId,
page = 1,
limit = 20,
}: CommentModel.list) {
let query = db
.selectFrom('galrc_comments')
.selectAll('galrc_comments')
.where('status', '=', 'normal')
if (targetType) {
query = query.where('targetType', '=', targetType as 'post' | 'article' | 'game')
}
const [comments, countResult] = await Promise.all([
query
.orderBy('createdAt', 'desc')
.offset((page - 1) * limit)
.limit(limit)
.execute(),
query
.clearSelect()
.clearOrderBy()
.select(db.fn.countAll<number>().as('total'))
.executeTakeFirst(),
])
return {
comments,
total: countResult?.total ?? 0,
totalPages: Math.ceil((countResult?.total ?? 0) / limit),
}
},
// 创建评论
async createComment(
{ targetType, targetId, content }: CommentModel.create,
userId: string,
) {
const inserted = await db
.insertInto('galrc_comments')
.values({
targetType: targetType as 'post' | 'article' | 'game',
targetId,
userId,
content,
createdAt: new Date().toISOString(),
})
.returning(['id'])
.executeTakeFirstOrThrow()
return inserted
},
}
规则:
export const X = { ... },不是 export class Xdb(Kysely 实例)访问offset((page - 1) * limit).limit(limit)db.fn.countAll<number>().as('total') + clearSelect().clearOrderBy()Promise.all([query, countQuery]).orderBy('createdAt', 'desc')Controller(index.ts)负责:路由定义、请求校验、调用 Service。
文件: apps/api/src/modules/comments/index.ts
import { betterAuth } from '@api/modules/auth'
import { CommentModel } from '@api/modules/comments/model'
import { CommentService } from '@api/modules/comments/service'
import { Elysia } from 'elysia'
export const comments = new Elysia({ prefix: '/comments' })
.use(betterAuth)
.get(
'/',
async ({ query }) => {
return await CommentService.getComments(query)
},
{
query: CommentModel.List,
},
)
.post(
'/',
async ({ body, user }) => {
return await CommentService.createComment(body, user.id)
},
{
auth: true, // 需要登录
body: CommentModel.Create,
},
)
.delete(
'/:id',
async ({ params, user }) => {
return await CommentService.deleteComment(params, user.id)
},
{
auth: true,
params: CommentModel.Params,
},
)
.patch(
'/:id/pin',
async ({ params }) => {
return await CommentService.togglePin(params)
},
{
isAdmin: true, // 需要管理员
params: CommentModel.Params,
},
)
规则:
Elysia 实例:export const x = new Elysia({ prefix: '...' }).use(betterAuth) 再定义路由{ query, body, params, user }{ auth: true } 或 { isAdmin: true }Auth 宏在 modules/auth/index.ts 中定义一次,全局可用。
文件: apps/api/src/modules/auth/index.ts
import { auth } from '@api/modules/auth/service'
import cors from '@elysiajs/cors'
import { Elysia } from 'elysia'
const allowedOrigins = ['http://localhost:3001', `${process.env.WEB_HOST}`]
export const betterAuth = new Elysia({ name: 'better-auth' })
.use(cors({ origin: allowedOrigins, credentials: true, ... }))
.mount(auth.handler)
.macro({
auth: {
async resolve({ status, request: { headers } }) {
const session = await auth.api.getSession({ headers })
if (!session) throw status(401, '请先登录喵~')
return session
},
},
isAdmin: {
async resolve({ status, request: { headers } }) {
const session = await auth.api.getSession({ headers })
if (!session) throw status(401, '请先登录喵~')
if (session.user.role !== 'admin') throw status(403, '该用户不具有管理员权限喵~')
return session
},
},
})
使用方式:
// 在路由定义中
{
auth: true, // → 自动调用 resolve,注入 { user, session } 到 context
}
{
isAdmin: true, // → 同上,但要求 role === 'admin'
}
使用 jsonArrayFrom 和 jsonObjectFrom 将关联数据转换为嵌套 JSON。
参考: apps/api/src/modules/tags/service.ts
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'
db
.selectFrom('vn')
.select((eb) => [
jsonObjectFrom(
eb
.selectFrom('images')
.select(['height', 'id', 'width'])
.whereRef('images.id', '=', 'vn.c_image'),
).as('image'),
jsonArrayFrom(
eb
.selectFrom('tags_vn')
.whereRef('tags_vn.vid', '=', 'vn.id')
.selectAll()
.groupBy('tags_vn.tag'),
).as('tags'),
])
$ifdb
.selectFrom('tags')
.$if(!!keyword, (qb) =>
qb.where((eb) =>
eb.or([
eb('name', 'like', `%${keyword}%`),
eb('description', 'like', `%${keyword}%`),
]),
),
)
.$if(!!id, (qb) => qb.where('tags.id', 'like', `%${id}%`))
try 库安全执行import { t } from 'try'
const [, error, data] = t(
await db.selectFrom('tags').selectAll().execute(),
)
if (error) {
throw status(500, `服务出错了喵~ Error:${JSON.stringify(error)}`)
}
参考: apps/api/src/modules/tags/service.ts
import { delKv, getKv, setKv } from '@api/libs/redis'
async gameTags({ id }: TagsModel.gameTags) {
const cacheKey = `gameTags:${id}`
// 1. 尝试缓存
const redisData = await getKv(cacheKey)
if (redisData) {
try { return JSON.parse(redisData) as Tag } catch {
await delKv(cacheKey).catch(() => {})
}
}
// 2. 查数据库
const items = await db.selectFrom(...).executeTakeFirst()
if (!items) throw status(404, 'not found')
const result = structuredClone(items)
// 3. 写入缓存(异步,不阻塞)
setKv(cacheKey, JSON.stringify(result), 60 * 60).catch(() => {
console.warn(`缓存写入失败: ${cacheKey}`)
})
type Tag = NonNullable<typeof result>
return result
}
使用 status 函数从 elysia 抛出 HTTP 错误。
import { status } from 'elysia'
throw status(404, '评论不存在')
throw status(401, '请先登录喵~')
throw status(403, '该用户不具有管理员权限喵~')
throw status(500, `服务出错了喵~ Error:${JSON.stringify(error)}`)
文件: apps/api/src/modules/index.ts — 统一导出所有模块:
export * from './auth'
export * from './comments'
export * from './cron'
export * from './games'
export * from './health'
export * from './media'
// ...
文件: apps/api/src/index.ts — 组装所有模块:
import { Elysia } from 'elysia'
import { comments, health, tags, game, auth as betterAuth } from '@api/modules'
async function buildApp() {
return new Elysia()
.onError(({ code, error }) => {
if (code === 'VALIDATION') return error.message
})
.use(betterAuth)
.use(health)
.use(tags)
.use(game)
.use(comments)
}
export type app = Awaited<ReturnType<typeof buildApp>>
{
"paths": {
"@api": ["./src/index.ts"],
"@api/*": ["./src/*"],
"@libs/*": ["../../packages/libs/src/*"]
}
}
| 用途 | 导入 |
|---|---|
| Elysia 核心 | import { Elysia, t, status } from 'elysia' |
| TypeBox 类型 | import { t } from 'elysia' 而非 @sinclair/typebox |
| DB 实例 | import { db } from '@api/libs' |
| Kysely JSON 辅助 | import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres' |
| Redis 缓存 | import { getKv, setKv, delKv } from '@api/libs/redis' |
| Try 安全执行 | import { t } from 'try' |
| Auth 宏依赖 | .use(betterAuth) from @api/modules/auth |
// ❌ 不导出 Elysia 实例,而是导出函数
export function setupRoutes(app: Elysia) { app.get(...) } // 禁止
// ❌ Service 使用 class
export class CommentService { static async get() {} } // 禁止
// ❌ Model 不使用 namespace
export const List = t.Object({...}) // 禁止 — 用 namespace 包裹
// ❌ 路由 handler 不解构
.get('/', (context) => context.query.name) // 禁止
// ❌ 在 Service 中直接 throw Error 而非 status
throw new Error('not found') // 禁止 — 用 throw status(404, ...)
// ❌ TypeBox 从 @sinclair/typebox 导入
import { Type } from '@sinclair/typebox' // 禁止 — 用 import { t } from 'elysia'
// ❌ 不使用 $if 而用 if-else 拼接 query
if (keyword) query = query.where(...) // 可用,但优先 $if