Skip to content

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 ​

text
db/schema/<domain>/<name>.ts
  → modules/<domain>/<name>/{schema,repository,service,routes}.ts
  → modules/<domain>/router.ts
  → src/router.ts
LayerFileResponsibilityNot allowed
modeldb/schema/<domain>/<name>.tsDrizzle pgTable(...) table definition + xxxToDict() serializerBusiness logic
schemamodules/<domain>/<name>/schema.tsRequest schemas, import/export field maps EXPORT_FIELD_MAP / IMPORT_HEADER_MAPDatabase access
repositorymodules/<domain>/<name>/repository.tsPure database reads and writes (Drizzle queries)Business logic, HTTP
servicemodules/<domain>/<name>/service.tsBusiness logic; throws ServiceError on errorsUsing HTTP objects such as reply or session
routesmodules/<domain>/<name>/routes.tsFastify routes + permission checks + service callsWriting SQL directly
Domain wiringmodules/<domain>/router.tsawait registerXxxRoutes(app)—
Top-level wiringsrc/router.ts + db/schema/index.tsRegisters business domains, exports table definitions—

Path alias: on the backend, @/* points to apps/api/src/*, e.g. @/common/auth.

Table definitions ​

ts
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 scaffold registers it in db/schema/index.ts and modules/<domain>/router.ts automatically.
  • When you add a new business domain, do it by hand: call the domain's register function in src/router.ts, and add export * from './<domain>/<name>' to db/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.

MethodPathDescription
GET/api/admin/<resource>sList; parameters page, per_page, search
POST/api/admin/<resource>sCreate; 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/exportExport
GET/api/admin/<resource>s/templateDownload the import template; parameter file_type=csv|xlsx
POST/api/admin/<resource>s/importImport; 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's index.html

Request helpers ​

HelperSourcePurpose
intParam('item_id')@/common/httpBuilds a path parameter that only matches digits
parseIntParam(value)@/common/httpParses a path parameter; an id outside the PostgreSQL integer range is a 404
routeBody(schema, 'create' | 'patch' | 'array') + field.*@/common/validationDeclares 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/httpReads a query parameter as a string (the first value when repeated)
getUploadedFile(request, field = 'file')@/common/httpReads the uploaded file of a multipart field; null when there is none
parsePagination(query)@/common/paginationPagination parameters; default 20 per page, max 200

Cross-cutting conventions ​

  • Time: timestamp / date columns are read as text and never pass through a JS Date. Always output them with toIso(): 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 the X-Time-Zone request header (the web app sends the browser's zone): export columns use formatDateTime(), and import cells go through withZoneOffset() first. Never use Date#toISOString() (milliseconds only).
  • Numbers: numeric columns are output as strings (e.g. "12.50"); don't convert them to numbers in toDict().
  • Request body validation: declare the body in schema.ts with field.* from @/common/validation; the route declares it with routeBody(schema, mode) and calls .parse(request) after the permission check; pnpm openapi:generate -- --strict checks 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 by pnpm scaffold are declared the same way; import rows are turned into the body shape by rowToBody and checked by the same declaration.
  • Operation logs: a global onResponse hook registered by the logs module writes to operation_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 an X-CSRF-Token header. The frontend's request.ts handles 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:

ts
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))
  })
}
FunctionDescription
loginRequiredpreHandler; 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 hasPermission function in a routes file: the no_local_has_permission check of pnpm verify catches 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:

ts
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:

CaseResponse
Zod request validation fails400; 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 request400, see below
Unknown exception500, "服务器内部错误,请稍后重试" ("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 path405 { 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 codeMessage returned (English UI)
23505 unique constraintThat 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 constraintThe 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 constraintThe related record doesn't exist, or this record is still used by other records. Check the related records and try again.
23514 check constraintA value isn't in the allowed range. Check it and save again.
22001A field is too long. Shorten it and save again.
22003A number is out of the allowed range. Check it and save again.
22007A date or time is in the wrong format. Check it and save again.
22008A date or time is out of the allowed range. Check it and save again.
22P02A 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 ​

bash
# 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_chain check of pnpm verify).
  • Migrations must actually be applied and confirmed with psql \d; the migration_applied check of pnpm verify compares 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 (or pnpm 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) ​

FunctionDescription
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 ​

bash
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 Apifox

docs/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).

bash
pnpm --filter @castorjs/api test

To set up the test database, see the "Run tests" step in Quick start.

Released under the MIT License.