Skip to content

バックエンド ​

バックエンドは apps/api にあり、技術スタックは Fastify 5 + Zod + Drizzle ORM + PostgreSQL、言語は TypeScript(strict)です。このページでは、レイヤールール、API 規約、権限チェック、エラー処理、データベースマイグレーション、インポート / エクスポートについて説明します。

新機能は、まず pnpm scaffold で骨格を生成し(AI 駆動開発 を参照)、そのうえでこのページのルールに従ってビジネスロジックを補うことをおすすめします。参考実装は apps/api/src/modules/admin/users/ です。

レイヤー構成 ​

text
db/schema/<domain>/<name>.ts
  → modules/<domain>/<name>/{schema,repository,service,routes}.ts
  → modules/<domain>/router.ts
  → src/router.ts
層ファイル役割禁止事項
modeldb/schema/<domain>/<name>.tsDrizzle の pgTable(...) によるテーブル定義 + xxxToDict() によるシリアライズビジネスロジック
schemamodules/<domain>/<name>/schema.tsリクエストスキーマ、インポート / エクスポートのフィールドマッピング EXPORT_FIELD_MAP / IMPORT_HEADER_MAPデータベース操作
repositorymodules/<domain>/<name>/repository.ts純粋なデータベースの読み書き(Drizzle のクエリ)ビジネスロジック、HTTP
servicemodules/<domain>/<name>/service.tsビジネスロジック。エラー時は ServiceError をスローreply、session などの HTTP オブジェクトの使用
routesmodules/<domain>/<name>/routes.tsFastify のルート + 権限チェック + service の呼び出しSQL を直接書くこと
ドメインの組み立てmodules/<domain>/router.tsawait registerXxxRoutes(app)—
第 1 階層の組み立てsrc/router.ts + db/schema/index.ts業務ドメインの登録、テーブル定義のエクスポート—

パスエイリアス:バックエンドの @/* は apps/api/src/* を指します(例:@/common/auth)。

テーブル定義 ​

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),
  }
}

登録 ​

  • 既存のドメイン(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/validationZod の宣言でリクエストボディを宣言。.route をルートのオプションに入れ(OpenAPI チェックがドキュメントのボディと照合)、権限チェックの後に .parse(request) で検証(新規はデフォルト値を補完、編集は送られた項目のみ)。JSON の型のみ受け付け、型が違えば 400 <項目名>的值无效(<項目名> は項目の中国語の表示名)
queryString(request, key, fallback = '')@/common/httpクエリパラメーターを文字列として読み取る(複数ある場合は最初の値)
getUploadedFile(request, field = 'file')@/common/httpmultipart のフィールドでアップロードされたファイルを読み取る。ない場合は 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 からインポートします。

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))
  })
}
関数説明
loginRequiredpreHandler。未ログインの場合は 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 をスローします。

ts
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 テーブルに保存されます。

手順 ​

bash
# 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 ​

bash
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)を用意します。

bash
pnpm --filter @castorjs/api test

テスト用データベースの準備は、クイックスタート の「テストを実行する」の節を参照してください。

Released under the MIT License.