Backend
The backend lives in apps/api and is built with Fastify 5 + Zod + Drizzle ORM + PostgreSQL, written in TypeScript (strict). This page covers the layering rules, API conventions, permission checks, error handling, database migrations and import/export.
For a new feature, start by generating the scaffold with pnpm scaffold (see AI-driven workflow), then fill in the business logic following the rules on this page. The reference implementation is apps/api/src/modules/admin/users/.
Layers
db/schema/<domain>/<name>.ts
→ modules/<domain>/<name>/{schema,repository,service,routes}.ts
→ modules/<domain>/router.ts
→ src/router.ts| Layer | File | Responsibility | Not allowed |
|---|---|---|---|
| model | db/schema/<domain>/<name>.ts | Drizzle pgTable(...) table definition + xxxToDict() serializer | Business logic |
| schema | modules/<domain>/<name>/schema.ts | Request schemas, import/export field maps EXPORT_FIELD_MAP / IMPORT_HEADER_MAP | Database access |
| repository | modules/<domain>/<name>/repository.ts | Pure database reads and writes (Drizzle queries) | Business logic, HTTP |
| service | modules/<domain>/<name>/service.ts | Business logic; throws ServiceError on errors | Using HTTP objects such as reply or session |
| routes | modules/<domain>/<name>/routes.ts | Fastify routes + permission checks + service calls | Writing SQL directly |
| Domain wiring | modules/<domain>/router.ts | await registerXxxRoutes(app) | — |
| Top-level wiring | src/router.ts + db/schema/index.ts | Registers business domains, exports table definitions | — |
Path alias: on the backend, @/* points to apps/api/src/*, e.g. @/common/auth.
Table definitions
import { pgTable, serial, varchar } from 'drizzle-orm/pg-core'
import { toIso } from '@/common/serialize'
import { createdAt, updatedAt } from '../columns'
export const customers = pgTable('customers', {
id: serial().primaryKey().notNull(),
name: varchar({ length: 100 }).notNull(),
created_at: createdAt(),
updated_at: updatedAt(),
})
export type Customer = typeof customers.$inferSelect
export function customerToDict(item: Customer) {
return {
id: item.id,
name: item.name,
created_at: toIso(item.created_at),
updated_at: toIso(item.updated_at),
}
}Registration
- When you add a module to an existing domain (
admin,component_center),pnpm scaffoldregisters it indb/schema/index.tsandmodules/<domain>/router.tsautomatically. - When you add a new business domain, do it by hand: call the domain's register function in
src/router.ts, and addexport * from './<domain>/<name>'todb/schema/index.ts.
API conventions
All business APIs are mounted under /api/admin/. Resource names are hyphenated and plural; for example, customer_order maps to /api/admin/customer-orders. Modules in the component_center domain use the gallery prefix instead: /api/admin/component-center/<resource>s.
| Method | Path | Description |
|---|---|---|
GET | /api/admin/<resource>s | List; parameters page, per_page, search |
POST | /api/admin/<resource>s | Create; returns 201 |
GET | /api/admin/<resource>s/<id> | Detail (when needed) |
PUT | /api/admin/<resource>s/<id> | Update |
DELETE | /api/admin/<resource>s/<id> | Delete |
POST | /api/admin/<resource>s/export | Export |
GET | /api/admin/<resource>s/template | Download the import template; parameter file_type=csv|xlsx |
POST | /api/admin/<resource>s/import | Import; multipart/form-data, field name file |
Response format
- List:
{ items, total, page, per_page } - Error:
{ error: string, ...payload } - Every 5xx returns "服务器内部错误,请稍后重试" ("Internal server error. Please try again later.") without leaking internal details; the stack trace goes to the log
- 404, 405 and 500 under
/api/*all return JSON and never fall through to the frontend'sindex.html
Request helpers
| Helper | Source | Purpose |
|---|---|---|
intParam('item_id') | @/common/http | Builds a path parameter that only matches digits |
parseIntParam(value) | @/common/http | Parses a path parameter; an id outside the PostgreSQL integer range is a 404 |
routeBody(schema, 'create' | 'patch' | 'array') + field.* | @/common/validation | Declares the body against a Zod declaration: .route goes into the route options (the OpenAPI check compares the schema with the documented body), .parse(request) validates it after the permission check (create fills defaults / update keeps only the fields sent); JSON types only, a wrong type → 400 <label>的值无效 (<label> is the field's Chinese label) |
queryString(request, key, fallback = '') | @/common/http | Reads a query parameter as a string (the first value when repeated) |
getUploadedFile(request, field = 'file') | @/common/http | Reads the uploaded file of a multipart field; null when there is none |
parsePagination(query) | @/common/pagination | Pagination parameters; default 20 per page, max 200 |
Cross-cutting conventions
- Time:
timestamp/datecolumns are read as text and never pass through a JSDate. Always output them withtoIso(): ISO 8601 in UTC,YYYY-MM-DDTHH:mm:ss.ffffffZ. Times in requests are converted to UTC when they carry an offset and taken as UTC when they don't; the web app shows them in the browser's time zone. Times in exported and imported files and the dashboard's days follow theX-Time-Zonerequest header (the web app sends the browser's zone): export columns useformatDateTime(), and import cells go throughwithZoneOffset()first. Never useDate#toISOString()(milliseconds only). - Numbers:
numericcolumns are output as strings (e.g."12.50"); don't convert them to numbers intoDict(). - Request body validation: declare the body in
schema.tswithfield.*from@/common/validation; the route declares it withrouteBody(schema, mode)and calls.parse(request)after the permission check;pnpm openapi:generate -- --strictchecks the documented request body against the same declaration. JSON types only (text is a trimmed string, integers are numbers, booleans are true / false); extra fields are ignored and a wrong type is a 400. Modules generated bypnpm scaffoldare declared the same way; import rows are turned into the body shape byrowToBodyand checked by the same declaration. - Operation logs: a global
onResponsehook registered by the logs module writes tooperation_logs; don't write logs by hand in services. - CSRF: write requests (
POST/PUT/PATCH/DELETE) under/api/*made with a session cookie must send anX-CSRF-Tokenheader. The frontend'srequest.tshandles this automatically. The login endpoint is exempt, and requests authenticated with an API token (Authorization: Bearer …) skip the check.
Permission checks
Always import the permission functions from @/common/auth:
import type { FastifyInstance } from 'fastify'
import { hasMenuPermission, loginRequired } from '@/common/auth'
import { intParam, parseIntParam } from '@/common/http'
import { routeBody } from '@/common/validation'
import { customerBody } from './schema'
import { CustomerService } from './service'
export async function registerCustomerRoutes(app: FastifyInstance): Promise<void> {
const service = new CustomerService(app.db)
const opts = { preHandler: loginRequired }
const create = routeBody(customerBody, 'create')
app.post('/api/admin/customers', { ...opts, ...create.route }, async (request, reply) => {
if (!(await hasMenuPermission(request, 'system_customer_add'))) {
return reply.status(403).send({ error: '无权限' })
}
return reply.status(201).send(await service.createItem(create.parse(request)))
})
const update = routeBody(customerBody, 'patch')
app.put(`/api/admin/customers/${intParam('item_id')}`, { ...opts, ...update.route }, async (request, reply) => {
// Check the permission first (403), then look up the record (404): no permission, no probing of ids
if (!(await hasMenuPermission(request, 'system_customer_edit'))) {
return reply.status(403).send({ error: '无权限' })
}
const item = await service.getOr404(parseIntParam((request.params as { item_id: string }).item_id))
return service.updateItem(item, update.parse(request))
})
}| Function | Description |
|---|---|
loginRequired | preHandler; returns 401 when not signed in |
hasMenuPermission(request, code) | Whether the user has a given menu or button permission. Async, so you must await it |
hasAnyMenuPermission(request, ...codes) | Passes if any of the codes match |
menuPermissionRequired(code) | preHandler form: { preHandler: [loginRequired, menuPermissionRequired('system_customer')] } |
Common mistakes
- Forgetting to
await hasMenuPermission(...): a Promise is always truthy, so the permission check does nothing. - Defining your own
hasPermissionfunction in a routes file: theno_local_has_permissioncheck ofpnpm verifycatches this.
For permission code rules and menu setup, see Permissions (RBAC).
Error handling
When the service layer hits a business error, it throws a ServiceError:
import { ServiceError } from '@/common/errors'
throw new ServiceError('客户名称已存在', 400)
throw new ServiceError('导入失败,存在错误数据', 400, { error_rows, error_count })The global error handler turns it into { error: message, ...payload }, using the second argument as the status code (default 400). For status codes ≥ 500, the message sent to the frontend is replaced with a generic server error message.
Other errors:
| Case | Response |
|---|---|
| Zod request validation fails | 400; error is the first validation message |
A body field of the wrong type (modules declare their bodies with apps/api/src/common/validation.ts) | 400, <field>的值无效 (e.g. "排序的值无效", translated for en-US / ja-JP requests) |
A request value of the wrong shape (the service throws invalidInput(), see apps/api/src/common/errors.ts) | 400, "请求参数格式不正确" ("Invalid request parameters") |
| The database rejects a value from the request | 400, see below |
| Unknown exception | 500, "服务器内部错误,请稍后重试" ("Internal server error. Please try again later.") |
Unmatched GET / HEAD / OPTIONS request under /api/* | 404 { error: '资源不存在' } ("Resource not found") |
| Unmatched request with any other method, on any path | 405 { error: '请求方法不允许' } ("Method not allowed") |
Write error messages in Chinese; the backend translates them into English or Japanese based on the Accept-Language request header (a request without a supported language, such as an API-token client, gets English). New messages need registered translations; see Internationalization.
Database constraint error mapping
The global error handler calls dbConstraintError() from apps/api/src/common/db-errors.ts, which turns database errors caused by the request's values into a 400; services that catch a failed write throw writeError(err) (business errors as they are, input the database rejects → 400, anything else → 500), and real server failures use internalError(err) — never a hand-written new ServiceError(…, 500). The scaffolded services and the docs/templates/backend/service.ts template already do this, and test/conventions.test.ts checks it. The rule: the caller's input problems are 4xx, only the server's own problems are 500:
| PostgreSQL error code | Message returned (English UI) |
|---|---|
23505 unique constraint | That code is already in use. Choose a different one and save again. (names the column from pg's detail; without it: Another record already uses that value. Change it and save again.) |
23502 not-null constraint | The required field name is empty. Fill it in and save again. (names the column; without it: A required field is empty. Fill it in and save again.) |
23503 foreign key constraint | The related record doesn't exist, or this record is still used by other records. Check the related records and try again. |
23514 check constraint | A value isn't in the allowed range. Check it and save again. |
22001 | A field is too long. Shorten it and save again. |
22003 | A number is out of the allowed range. Check it and save again. |
22007 | A date or time is in the wrong format. Check it and save again. |
22008 | A date or time is out of the allowed range. Check it and save again. |
22P02 | A field is in the wrong format (for example, text in a number field). Check it and save again. |
Other database errors are treated as 500. This means that once you add .notNull() or .unique() to a table definition, you get a sensible 400 message with no extra code. Unique and not-null violations name the database column; when the message should use the field's label instead (such as "客户编码已存在", "customer code already exists"), check for duplicates in the service before writing.
Database migrations
Table definitions live in apps/api/src/db/schema/**. drizzle-kit generates migrations into apps/api/drizzle/, and applied migrations are recorded in the drizzle.__drizzle_migrations table in the database.
Workflow
# 1. After changing a table definition in db/schema, generate a migration
pnpm db:generate --name add_customer_phone
# 2. Review the newly generated SQL under apps/api/drizzle/
# 3. Apply the migration
pnpm db:migrate
# 4. Confirm the table structure is really in the database (database name per DEV_DATABASE_URL in apps/api/.env.development)
psql -d castor_kit -c '\d customers'No -- after pnpm db:generate
pnpm db:generate --name <description> passes its arguments straight to drizzle-kit, which doesn't understand --. For Castor's own scripts (scaffold, verify, seed:rbac, openapi:generate), the -- before arguments is optional.
Rules
- Don't write migration SQL by hand; it breaks the journal chain (checked by the
migration_chaincheck ofpnpm verify). - Migrations must actually be applied and confirmed with
psql \d; themigration_appliedcheck ofpnpm verifycompares the journal with the database records. - scaffold generates the migration for a new table automatically. For later schema changes, generate an incremental migration with
pnpm db:generate --name <description>. - When deploying to another environment, run
pnpm db:migrate && pnpm seed:rbac -- --incremental(orpnpm setup-once, which also creates the AI SQL read-only account). With Docker, the container does this automatically on start; see the Deployment guide.
Import and export
Import and export support csv and xlsx only. Uploading an .xls file returns 400 with a message asking you to save it as .xlsx.
Helpers (@/common/tabular)
| Function | Description |
|---|---|
buildTable(headers, rows, baseFilename, fileType) | Builds the table file payload (async); csv includes a BOM, and an unknown fileType falls back to csv |
sendTable(reply, table) | Sets Content-Type and Content-Disposition and sends the file |
readTableFile(file) | Reads an uploaded file and returns { fieldnames, rows, fileType }; 5MB limit; each row is [row number, values]. An invalid file throws TableFileError, which the service rethrows as ServiceError(err.message, 400) |
normalizeTableFileType(raw, fallback) | Normalizes the file type |
sanitizeFormula() | Formula-injection protection |
Field maps
Define them in the module's schema.ts:
EXPORT_FIELD_MAP: field → Chinese column header. When the value needs converting, use[Chinese header, getter function], for example to display an enum code as Chinese.IMPORT_HEADER_MAP: Chinese column header → field.
Column headers in import/export files stay in Chinese regardless of the UI language.
Import transactions
A whole import batch runs in a single transaction. If any row has errors, it throws ServiceError('导入失败,存在错误数据', 400, { error_rows, error_count }) (error_rows holds at most the first 500 rows) and the whole batch is rolled back. The frontend import dialog shows the error rows and lets you download them.
Permissions
Export is gated by the <perm>_export button permission; downloading the import template and importing are both gated by <perm>_import. Don't reuse the view permission or _edit for them. For the frontend components, see Frontend.
OpenAPI
pnpm openapi:generate # Add skeletons for undocumented routes + methods, check the rules, regenerate the frontend's API types
pnpm openapi:generate -- --strict # List every operation that breaks the rules and why; non-zero exit if any (add --dry-run to skip writing)
pnpm openapi:apifox # Push to Apifoxdocs/apifox-full.openapi.json is the one description of the API: external callers, Apifox and the AI assistant all rely on it, so every registered /api operation must be complete: a Chinese summary, a description (required permission, data scope, notable behavior), one tag and Apifox folder, path and query parameters, request body fields ("x-no-body": true when there is no body), and the success response structure plus possible error codes. The full rules are in the repository's AGENTS.md ("OpenAPI writing rules"); the API tests and pnpm verify enforce them. pnpm scaffold writes compliant entries for a new module's endpoints; openapi:generate only adds skeletons for undocumented operations, and a skeleton fails the check until it is written up from the code. Without --dry-run, it also regenerates the frontend's API types (apps/web/src/shared/api/openapi.d.ts) from the document. Pushing to Apifox requires APIFOX_PROJECT_ID and APIFOX_ACCESS_TOKEN; see Configuration.
Testing
Backend tests use Vitest against a real PostgreSQL test database. Route tests send requests through app.inject(), with one test file per module (admin-*.test.ts, cc-*.test.ts).
pnpm --filter @castorjs/api testTo set up the test database, see the "Run tests" step in Quick start.
