バックエンド
バックエンドは apps/api にあり、技術スタックは Fastify 5 + Zod + Drizzle ORM + PostgreSQL、言語は TypeScript(strict)です。このページでは、レイヤールール、API 規約、権限チェック、エラー処理、データベースマイグレーション、インポート / エクスポートについて説明します。
新機能は、まず pnpm scaffold で骨格を生成し(AI 駆動開発 を参照)、そのうえでこのページのルールに従ってビジネスロジックを補うことをおすすめします。参考実装は apps/api/src/modules/admin/users/ です。
レイヤー構成
db/schema/<domain>/<name>.ts
→ modules/<domain>/<name>/{schema,repository,service,routes}.ts
→ modules/<domain>/router.ts
→ src/router.ts| 層 | ファイル | 役割 | 禁止事項 |
|---|---|---|---|
| model | db/schema/<domain>/<name>.ts | Drizzle の pgTable(...) によるテーブル定義 + xxxToDict() によるシリアライズ | ビジネスロジック |
| schema | modules/<domain>/<name>/schema.ts | リクエストスキーマ、インポート / エクスポートのフィールドマッピング EXPORT_FIELD_MAP / IMPORT_HEADER_MAP | データベース操作 |
| repository | modules/<domain>/<name>/repository.ts | 純粋なデータベースの読み書き(Drizzle のクエリ) | ビジネスロジック、HTTP |
| service | modules/<domain>/<name>/service.ts | ビジネスロジック。エラー時は ServiceError をスロー | reply、session などの HTTP オブジェクトの使用 |
| routes | modules/<domain>/<name>/routes.ts | Fastify のルート + 権限チェック + service の呼び出し | SQL を直接書くこと |
| ドメインの組み立て | modules/<domain>/router.ts | await registerXxxRoutes(app) | — |
| 第 1 階層の組み立て | src/router.ts + db/schema/index.ts | 業務ドメインの登録、テーブル定義のエクスポート | — |
パスエイリアス:バックエンドの @/* は apps/api/src/* を指します(例:@/common/auth)。
テーブル定義
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),
}
}登録
- 既存のドメイン(
admin、component_center)にモジュールを追加する場合は、pnpm scaffoldがdb/schema/index.tsとmodules/<domain>/router.tsに自動で登録します。 - 新しい業務ドメインを追加する場合は、
src/router.tsでそのドメインの登録関数を呼び出し、db/schema/index.tsにexport * from './<domain>/<name>'を追加する作業を手動で行います。
API 規約
業務 API はすべて /api/admin/ の下に置きます。リソース名はハイフン区切りの複数形にします。たとえば customer_order は /api/admin/customer-orders に対応します。component_center ドメインのモジュールは、コンポーネントギャラリーのプレフィックス /api/admin/component-center/<resource>s を使います。
| メソッド | パス | 説明 |
|---|---|---|
GET | /api/admin/<resource>s | 一覧。パラメーターは page、per_page、search |
POST | /api/admin/<resource>s | 新規作成。201 を返す |
GET | /api/admin/<resource>s/<id> | 詳細(必要に応じて) |
PUT | /api/admin/<resource>s/<id> | 編集 |
DELETE | /api/admin/<resource>s/<id> | 削除 |
POST | /api/admin/<resource>s/export | エクスポート |
GET | /api/admin/<resource>s/template | インポートテンプレートのダウンロード。パラメーターは file_type=csv|xlsx |
POST | /api/admin/<resource>s/import | インポート。multipart/form-data、フィールド名は file |
レスポンス形式
- 一覧:
{ items, total, page, per_page } - エラー:
{ error: string, ...payload } - 5xx はすべて「服务器内部错误,请稍后重试」(日本語 UI では「サーバー内部エラーが発生しました。しばらくしてから再度お試しください。」)を返し、内部情報は漏らしません。スタックトレースはログに書き込みます
/api/*配下の 404、405、500 はすべて JSON を返し、フロントエンドのindex.htmlにフォールバックすることはありません
リクエスト処理ユーティリティ
| ユーティリティ | インポート元 | 用途 |
|---|---|---|
intParam('item_id') | @/common/http | 数字だけにマッチするパスパラメーターを生成 |
parseIntParam(value) | @/common/http | パスパラメーターを解析。PostgreSQL の integer の範囲外の id は 404 |
routeBody(schema, 'create' | 'patch' | 'array') + field.* | @/common/validation | Zod の宣言でリクエストボディを宣言。.route をルートのオプションに入れ(OpenAPI チェックがドキュメントのボディと照合)、権限チェックの後に .parse(request) で検証(新規はデフォルト値を補完、編集は送られた項目のみ)。JSON の型のみ受け付け、型が違えば 400 <項目名>的值无效(<項目名> は項目の中国語の表示名) |
queryString(request, key, fallback = '') | @/common/http | クエリパラメーターを文字列として読み取る(複数ある場合は最初の値) |
getUploadedFile(request, field = 'file') | @/common/http | multipart のフィールドでアップロードされたファイルを読み取る。ない場合は null |
parsePagination(query) | @/common/pagination | ページングパラメーター。デフォルトは 20 件、上限は 200 件 |
横断的な規約
- 日時:
timestamp/date列はテキストとして読み取り、JS のDateを経由しません。出力には必ずtoIso()を使います。形式は ISO 8601 の UTC 時刻YYYY-MM-DDTHH:mm:ss.ffffffZです。リクエストの日時はタイムゾーン付きなら UTC に換算し、タイムゾーンなしなら UTC として扱います。フロントエンドはブラウザのタイムゾーンで表示します。エクスポート / インポートするファイルの日時とダッシュボードの日付は、リクエストヘッダーX-Time-Zone(フロントエンドがブラウザのタイムゾーンを自動で付けます)に従います。エクスポート列はformatDateTime()、インポートのセルは先にwithZoneOffset()を通します。Date#toISOString()(ミリ秒まで)の使用は禁止です。 - 数値:
numeric列は文字列のまま出力します(例:"12.50")。toDict()の中で数値に変換しないでください。 - リクエストボディの検証:
schema.tsで@/common/validationのfield.*を使ってボディを宣言し、ルートはrouteBody(schema, mode)で宣言し、権限チェックの後に.parse(request)を呼びます。pnpm openapi:generate -- --strictはドキュメントのリクエストボディを同じ宣言と照合します。JSON の型のみ受け付け(テキストは前後の空白を除いた文字列、整数は number、真偽値は true / false)、余分なフィールドは無視し、型が違えば 400 を返します。pnpm scaffoldで生成したモジュールも同じ書き方で、インポート行はrowToBodyでボディの形に変換され、同じ宣言で検証されます。 - 操作ログ:logs モジュールが登録するグローバルな
onResponseフックがoperation_logsにまとめて書き込むので、service の中で手書きしないでください。 - CSRF:
/api/*配下でセッション Cookie を使う書き込みリクエスト(POST/PUT/PATCH/DELETE)にはX-CSRF-Tokenヘッダーが必要です。フロントエンドのrequest.tsが自動で処理します。ログイン API は対象外で、API トークン(Authorization: Bearer …)で認証されたリクエストもこのチェックを受けません。
権限チェック
権限関数はすべて @/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))
})
}| 関数 | 説明 |
|---|---|
loginRequired | preHandler。未ログインの場合は 401 を返す |
hasMenuPermission(request, code) | あるメニューまたはボタンの権限を持っているか。非同期なので必ず await する |
hasAnyMenuPermission(request, ...codes) | いずれか 1 つのコードを満たせばよい |
menuPermissionRequired(code) | preHandler 形式:{ preHandler: [loginRequired, menuPermissionRequired('system_customer')] } |
よくある間違い
await hasMenuPermission(...)のawaitを忘れる:Promise は常に truthy なので、権限チェックが機能しなくなります。- routes ファイルで
hasPermission関数を独自に定義する:pnpm verifyのno_local_has_permissionでブロックされます。
権限コードのルールとメニューの設定は 権限(RBAC) を参照してください。
エラー処理
service 層で業務エラーが発生した場合は ServiceError をスローします。
import { ServiceError } from '@/common/errors'
throw new ServiceError('客户名称已存在', 400)
throw new ServiceError('导入失败,存在错误数据', 400, { error_rows, error_count })グローバルのエラーハンドラーがこれを { error: message, ...payload } に変換し、ステータスコードには第 2 引数(デフォルトは 400)を使います。ステータスコードが 500 以上の場合、フロントエンドに返す文言は汎用的なサーバーエラーのメッセージに置き換えられます。
そのほかのエラー:
| 状況 | レスポンス |
|---|---|
| Zod によるリクエスト検証の失敗 | 400。error は最初の検証メッセージ |
リクエストボディの項目の型が正しくない(各モジュールは apps/api/src/common/validation.ts でボディを宣言) | 400、<項目>的值无效(例:「排序的值无效」。en-US / ja-JP のリクエストでは翻訳される) |
構造が正しくないリクエストの値(service が invalidInput() を投げる。apps/api/src/common/errors.ts を参照) | 400。「请求参数格式不正确」(リクエストパラメーターの形式が正しくありません) |
| リクエストの値がデータベースに拒否された | 400。下記を参照 |
| 未知の例外 | 500。「服务器内部错误,请稍后重试」(サーバー内部エラーが発生しました。しばらくしてから再度お試しください。) |
/api/* 配下でマッチしない GET / HEAD / OPTIONS リクエスト | 404 { error: '资源不存在' }(リソースが見つかりません) |
| 任意のパスでマッチしない、その他のメソッドのリクエスト | 405 { error: '请求方法不允许' }(許可されていないリクエストメソッドです) |
エラーメッセージは中国語で書くだけでかまいません。バックエンドがリクエストヘッダー Accept-Language に応じて英語または日本語に翻訳します(対応する言語を指定していないリクエスト、たとえば API トークンのクライアントには英語で返します)。新しい文言は翻訳を登録する必要があります。多言語対応 を参照してください。
データベース制約エラーのマッピング
グローバルなエラーハンドラーは apps/api/src/common/db-errors.ts の dbConstraintError() を呼び出し、リクエストの値に起因するデータベースエラーを 400 に変換します。トランザクション内で書き込みの失敗を捕捉する service は writeError(err) を投げ(業務エラーはそのまま、データベースに拒否された入力は 400、それ以外は 500)、本当のサーバーエラーには internalError(err) を使います。new ServiceError(…, 500) を手書きしないでください。scaffold が生成する service と docs/templates/backend/service.ts テンプレートは対応済みで、test/conventions.test.ts がチェックします。原則として、呼び出し側の入力の問題は 4xx、サーバー自身の問題だけが 500 です。
| PostgreSQL のエラーコード | 返される文言 |
|---|---|
23505 一意制約 | 字段「code」的值已被使用,请换一个值后再保存(「code」の値は既に使われています。別の値にしてから保存してください。pg の detail から列名を取得。取得できない場合:已有记录使用了相同的值,请换一个值后再保存) |
23502 NOT NULL 制約 | 必填字段「name」没有填写,请补全后再保存(必須項目「name」が未入力です。入力してから保存してください。列名がない場合:有必填字段没有填写,请补全后再保存) |
23503 外部キー制約 | 关联的数据不存在,或这条数据仍被其他数据使用,请检查关联后重试(関連するデータが存在しないか、このデータが他のデータから使われています。関連を確認してから再度お試しください。) |
23514 CHECK 制約 | 有字段的值不在允许的范围内,请检查后再保存(許可されていない値の項目があります。確認してから保存してください。) |
22001 | 有字段超出了长度上限,请缩短后再保存(長さの上限を超えている項目があります。短くしてから保存してください。) |
22003 | 有数字超出了允许的范围,请检查后再保存(許可された範囲を超えている数値があります。確認してから保存してください。) |
22007 | 有日期时间的格式不正确,请检查后再保存(日時の形式が正しくない項目があります。確認してから保存してください。) |
22008 | 有日期时间超出了允许的范围,请检查后再保存(許可された範囲を超えている日時があります。確認してから保存してください。) |
22P02 | 有字段的格式不正确(例如数字字段里填了文字),请检查后再保存(形式が正しくない項目があります(数値の項目に文字が入っているなど)。確認してから保存してください。) |
そのほかのデータベースエラーは 500 として扱われます。つまり、テーブル定義に .notNull() や .unique() を追加すれば、追加のコードなしで妥当な 400 のメッセージが得られます。一意制約と NOT NULL 制約のメッセージにはデータベースの列名が入ります。項目の表示名を使いたい場合(例:「客户编码已存在」=顧客コードは既に存在します)は、service で書き込み前に重複チェックを行ってください。
データベースマイグレーション
テーブル定義は apps/api/src/db/schema/** にあり、マイグレーションは drizzle-kit が apps/api/drizzle/ に生成します。実行履歴はデータベースの drizzle.__drizzle_migrations テーブルに保存されます。
手順
# 1. db/schema のテーブル定義を変更したら、マイグレーションを生成する
pnpm db:generate --name add_customer_phone
# 2. apps/api/drizzle/ に新しく生成された SQL をレビューする
# 3. マイグレーションを適用する
pnpm db:migrate
# 4. テーブル構造が実際に DB に反映されたことを確認する(データベース名は apps/api/.env.development の DEV_DATABASE_URL に従う)
psql -d castor_kit -c '\d customers'pnpm db:generate の後ろに -- を書かないこと
pnpm db:generate --name <説明> は引数をそのまま drizzle-kit に渡しますが、drizzle-kit は -- を認識しません。Castor 独自のスクリプト(scaffold、verify、seed:rbac、openapi:generate)では、引数の前の -- はあってもなくてもかまいません。
ルール
- マイグレーション SQL を手書きしないでください。journal のチェーンが壊れます(
pnpm verifyのmigration_chainがチェックします)。 - マイグレーションは必ず実際に実行し、
psql \dで確認してください。pnpm verifyのmigration_appliedが journal とデータベースの記録を照合します。 - 新しいテーブルのマイグレーションは scaffold が自動で生成します。その後にテーブル構造を変更する場合は、
pnpm db:generate --name <説明>で差分のマイグレーションを生成してください。 - ほかの環境にデプロイするときは
pnpm db:migrate && pnpm seed:rbac -- --incremental(または、AI SQL 用の読み取り専用アカウントも作成するpnpm setup-once)を実行します。Docker でデプロイする場合はコンテナの起動時に自動で行われます。デプロイガイド を参照してください。
インポート / エクスポート
インポート / エクスポートは csv と xlsx のみに対応しています。.xls をアップロードすると 400 が返り、.xlsx で保存し直すよう案内されます。
ユーティリティ関数(@/common/tabular)
| 関数 | 説明 |
|---|---|
buildTable(headers, rows, baseFilename, fileType) | 表ファイルのペイロードを構築(非同期)。csv は BOM 付きで、認識できない fileType は csv として扱う |
sendTable(reply, table) | Content-Type、Content-Disposition を設定して送信 |
readTableFile(file) | アップロードされたファイルを読み取り、{ fieldnames, rows, fileType } を返す。上限は 5MB、各行は [行番号, 値]。不正なファイルでは TableFileError を投げ、service がそれを ServiceError(err.message, 400) として投げ直す |
normalizeTableFileType(raw, fallback) | ファイル形式を正規化 |
sanitizeFormula() | 数式インジェクション対策 |
フィールドマッピング
モジュールの schema.ts で定義します。
EXPORT_FIELD_MAP:フィールド → 中国語の列見出し。変換が必要な場合は[中国語の列見出し, 値を取り出す関数]の形で書きます。たとえば列挙型のコードを中国語で表示する場合などです。IMPORT_HEADER_MAP:中国語の列見出し → フィールド。
インポート / エクスポートするファイルの列見出しは中国語のままで、UI の言語によって変わりません。
インポートのトランザクション
インポートはバッチ全体を 1 つのトランザクションで処理します。エラー行がある場合は ServiceError('导入失败,存在错误数据', 400, { error_rows, error_count }) をスローし(error_rows には先頭の最大 500 行が入ります)、バッチ全体をロールバックします。フロントエンドのインポートダイアログはエラー行を表示し、ダウンロードすることもできます。
権限
エクスポートはボタン権限 <perm>_export、インポート用テンプレートのダウンロードとインポートはどちらも <perm>_import で判定します。閲覧権限や _edit で代用しないでください。フロントエンドのコンポーネントは フロントエンド を参照してください。
OpenAPI
pnpm openapi:generate # ドキュメントのないルート + メソッドに骨格を追加し、規約をチェックして、フロントエンドの API 型を再生成
pnpm openapi:generate -- --strict # 規約に合わない API と理由を一覧表示し、あれば 0 以外で終了(--dry-run で書き戻さない)
pnpm openapi:apifox # Apifox にプッシュdocs/apifox-full.openapi.json は API の唯一の説明書で、外部の呼び出し側、Apifox、AI アシスタント はすべてこれを頼りにします。そのため登録済みの /api の API はすべて完全に書く必要があります:中国語の summary、description(必要な権限、データ権限、重要な動作)、タグ 1 つと Apifox フォルダー、パスとクエリのパラメーター、リクエストボディのフィールド(ボディを読まない場合は "x-no-body": true)、成功レスポンスの構造と起こりうるエラーコード。規則の全文はリポジトリの AGENTS.md の「OpenAPI writing rules」の節にあり、API テストと pnpm verify で強制されます。pnpm scaffold は新しいモジュールの API を規約どおりの内容で書き込みます。openapi:generate はドキュメントのない API に骨格を追加するだけで、骨格はコードに沿って書き上げるまでチェックを通りません。--dry-run を付けない場合は、ドキュメントからフロントエンドの API 型(apps/web/src/shared/api/openapi.d.ts)も再生成します。Apifox へのプッシュには APIFOX_PROJECT_ID と APIFOX_ACCESS_TOKEN が必要です。設定 を参照してください。
テスト
バックエンドのテストは Vitest を使い、実際の PostgreSQL テスト用データベースに接続します。ルートのテストは app.inject() でリクエストを送り、モジュールごとに 1 つのテストファイル(admin-*.test.ts、cc-*.test.ts)を用意します。
pnpm --filter @castorjs/api testテスト用データベースの準備は、クイックスタート の「テストを実行する」の節を参照してください。
