• #tax-assistant
  • #マルチクライアント
  • #FastAPI
  • #Vue.js
  • #Pinia
  • #Claude Code
開発tax-assistantメモ

概要

tax-assistantのマルチクライアント対応を、設計フェーズから実装完了まで一気に進めた。 全10タスクを順番に消化し、バックエンド・フロントエンドの両方を改修。 その後、2つ目のクライアント(クライアントB)を追加した途端に隠れていたバグが5件表面化し、それらも全て修正した。

実装の全体像

1. dependencies.pyでクライアントパス管理を集約

マルチクライアント対応の中核となる依存関数 get_client_paths() を新設した。

  • リクエストヘッダーの X-Client-Id からクライアントIDを取得
  • クライアント固有のDBパス・データパスを解決して返す
  • パストラバーサル攻撃を防ぐセキュリティチェックを組み込み
# dependencies.py の概要
def get_client_paths(request: Request) -> ClientPaths:
    client_id = request.headers.get("X-Client-Id", "client_001")
    # パストラバーサル防止
    if ".." in client_id or "/" in client_id:
        raise HTTPException(status_code=400, detail="Invalid client ID")
    return ClientPaths(
        db_path=BASE_DIR / "clients" / client_id / "main.db",
        data_path=BASE_DIR / "clients" / client_id / "data",
    )

この設計により、各ルーターはパス解決を意識せず db_path を受け取るだけでよくなった。

2. クライアント一覧API + main.pyの修正

  • /api/clients エンドポイントを追加。clients/ ディレクトリ配下のサブディレクトリ一覧を返す
  • main.pyの lifespan からDB初期化ロジックを削除し、依存関数側に移行

これにより、アプリ起動時に特定クライアントのDBをハードコードで初期化する必要がなくなった。

3. 全ルーターの移行(10ファイル)

ルーター移行は2段階で進めた。

単純なルーター(6ファイル):

  • square
  • creditcard
  • comments
  • validations
  • account_categories
  • shiwake_rules

これらは DB_PATH のハードコードを db_path: Path = Depends(get_client_paths) に置き換えるだけで済んだ。

複雑なルーター(4ファイル):

  • receipts(ファイルパス参照あり)
  • batches(バッチ処理でDB接続を複数回開く)
  • journal(仕訳生成ロジックが長い)
  • document_types(マスターデータ参照の依存関係)

複雑なルーターは、DB接続だけでなくデータディレクトリの参照もクライアント別にする必要があった。

4. フロントエンド対応

api.ts: useApi()にヘッダー自動注入

// api.ts
export const useApi = () => {
  const clientStore = useClientStore()
  const headers = computed(() => ({
    "X-Client-Id": clientStore.currentClientId,
  }))
  // ... fetch wrapper with headers
}

Piniaストア: useClientStore

  • クライアント一覧の取得・キャッシュ
  • 選択中のクライアントIDを localStorage で永続化
  • クライアント切替時にデータ再取得をトリガー

ClientSelectorコンポーネント

ヘッダー左上にドロップダウンを配置。選択を変えると即座にクライアントが切り替わる。

2つ目のクライアント追加で表面化したバグ

クライアントA(既存)だけの状態では気づけなかった問題が、クライアントBを追加した途端に5件発覚した。

バグ1: ネイティブfetch()によるヘッダー未送信

仕訳ルール関連の5コンポーネントが useApi() ではなくネイティブの fetch() を直接使っていた。 useApi() 経由でないと X-Client-Id ヘッダーが付与されないため、常にデフォルトのクライアントAのデータを返していた。

修正: 該当5コンポーネントを全て useApi() 経由に統一。

バグ2: 新クライアントの勘定科目マスターが0件

クライアントBのDBを新規作成した際、テーブルは作られるがマスターデータが空だった。 勘定科目が0件だと仕訳生成が全く動作しない。

修正: クライアントAの勘定科目マスター174件をクライアントBにコピーする初期化処理を追加。

バグ3: 仕訳ビューで税区分が表示されない

account_categories の検索ロジックに問題があった。勘定科目コードの完全一致ではなく前方一致で検索していたため、クライアントBのデータ構造で正しくヒットしなかった。

修正: 検索ロジックを完全一致に修正。

バグ4: レシートなしのクライアントでルールマッチング全スキップ

レシートが存在しないクライアントでは、仕訳ルールのマッチング処理が丸ごとスキップされていた。 ルールマッチングはレシート以外のデータ(クレジットカード明細など)にも適用すべきだった。

修正: unchecked ステータスのレコードにもマッチング処理を適用するよう変更。結果、全534件中214件がルールマッチに成功。

バグ5: fetch()とuseApi()の混在箇所の洗い出し

バグ1の発見を受けて全コンポーネントを横断検索したところ、仕訳ルール関連以外にも数箇所ネイティブ fetch() が残っていた。

修正: 全箇所を useApi() に統一。

学んだこと

2つ目のクライアントを追加すると隠れた問題が表面化する。 1クライアントだけだとハードコードされたパスやデフォルト値が「たまたま動く」状態になりがちで、マルチテナント化の際に初めて問題が顕在化する。

ネイティブfetch()とラッパー関数の混在は危険。 ヘッダー注入・認証トークン付与など、ラッパーに集約した共通処理が漏れる原因になる。lintルールやコードレビューで fetch() 直接呼び出しを検知する仕組みがあるとよい。

クライアント初回セットアップ時のマスターデータコピーは忘れがち。 テーブル作成(マイグレーション)だけでなく、初期データの投入(シーディング)まで自動化しておく必要がある。

まとめ

項目数値
移行したルーター10ファイル
対応エンドポイント数59
発見・修正したバグ5件
ルールマッチ結果534件中214件

設計が済んでいたおかげで、実装自体はスムーズに進んだ。ただし「動くようになった」と思った後に2つ目のクライアントを追加してからが本番だった。マルチテナント対応は実装よりも検証フェーズで問題が出やすいということを改めて実感した。