跳轉到

會員交易備份前端交接

本文件供 Cortex Management 實作管理員的會員交易帳本備份與還原流程。三個 endpoint 都需要 admin Bearer token。範例中的 UUID、email、digest 與 URL 都是示意值;以下 curl 不代表已對任何環境執行,也不應放入真實會員資料或憑證。

流程與行為

操作 Method / path 前端行為 成功結果 不可省略的條件
匯出 GET /api/admin/members/{member_id}/transactions/backup 將原始 response body 直接下載成 JSON 檔;不得解析後再序列化 200、application/json、Content-Disposition 附件、Cache-Control: no-store 保留 decimal 字串與 _sell_action / _buy_action 原始鍵名
預覽還原 POST .../restore/preview 使用使用者選擇的完整備份檔作為 backup,呈現 incoming/current counts、warnings 與目標會員 200,取得 expected_state_sha256 只允許管理員確認來源與目標均為同一位會員後進入下一步
套用還原 POST .../restore 將同一份未改寫的 backup、preview digest、path 相同的 confirm_member_id 一次送出 200,restored 或 unchanged 每次套用前都必須重新 preview;digest 是目前目標狀態的 optimistic-lock token

還原會覆蓋目標會員的券商、交易與 lot closure 資料。管理畫面應在預覽後、套用前要求操作者再次確認目標會員 ID。後端每次以新鮮的 market relation manifest 判斷可用市場,不使用 TTL 快取;前端不應自行假設固定市場集合。

還原會保留 archive 內仍存在的 broker ID;只有 archive 移除的 broker 才會使 legacy public.transactions 對該 broker 的關聯依 FK 行為設為 NULL。此副作用不影響前端傳輸格式。

備份檔欄位

所有 decimal/numeric 欄位都是 fixed-scale string,數量是 integer,UUID 是 string。transaction_date 是沒有 offset 的 ISO 8601;created_at、updated_at 與 exported_at 是 canonical UTC ISO 8601。nullable 欄位保留 null,不可改成空字串或省略。markets 僅能使用 taiwan、us,且依後端順序排列。

路徑 型別 必填 說明
format_version 1 是 檔案格式版本。
schema_version "portfolio-transaction-backup.v1" 是 檔案 schema 版本。
exported_at string 是 匯出 UTC 時間。
source_member object 是 id、email、display_name。
markets ("taiwan" \| "us")[] 是 匯出時可用的完整市場集合。
brokers BackupBroker[] 是 完整 persisted broker rows。
transactions BackupTransaction[] 是 完整 persisted transaction rows,另有 country。
closures BackupClosure[] 是 完整 persisted lot-closure rows,另有 country。
content_sha256 64-char lowercase hex string 是 排除本欄位後,canonical JSON 的 SHA-256。前端不可重新計算或修改。
陣列元素 欄位
brokers[] id, member_id, name, fee_rate, fee_discount, note, is_default, created_at, updated_at
transactions[] country, id, member_id, stock_code, action, sell_type, price_per_share, quantity, fee, tax, realized_pnl, broker_id, transaction_date, note, created_at, updated_at, day_trade_pair_id, day_trade_direction, listing_type
closures[] country, id, sell_transaction_id, buy_transaction_id, member_id, stock_code, _sell_action, _buy_action, qty, created_at, is_day_trade, listing_type

Preview 與 restore response 欄位

欄位 型別 說明
source_member / target_member { id, email, display_name } 預覽顯示用會員資訊。
incoming / current / counts TransactionBackupCounts brokers、transactions、closures、day_trade_pairs、四種 action count、date_from、date_to。空帳本的 dates 為 null。
expected_state_sha256 64-char lowercase hex string preview 回傳;restore 必須原樣帶回。
warnings string[] 目前可能說明覆蓋行為;即使空陣列也要可顯示。
status "restored" \| "unchanged" unchanged 表示目標已是同一個帳本,視為成功。
state_sha256 64-char lowercase hex string restore 後帳本狀態的 digest。

Cortex Management TypeScript 契約差異

Management 已有 src/types/member-transaction-backup.ts 的 preview/restore 型別與 src/services/member-transaction-backup.service.ts 的 transport service。匯出的 archive 因需原封不動保存,backup 維持 unknown;不要在 UI 建立會遺失 _sell_action、decimal scale 或 null 的 partial archive interface。

diff --git a/src/types/member-transaction-backup.ts b/src/types/member-transaction-backup.ts
+export interface TransactionBackupMember {
+  id: string;
+  email: string;
+  display_name: string | null;
+}
+
+export interface TransactionBackupCounts {
+  brokers: number;
+  transactions: number;
+  closures: number;
+  day_trade_pairs: number;
+  actions: { buy: number; sell: number; stock_dividend: number; cash_dividend: number };
+  date_from: string | null;
+  date_to: string | null;
+}
+
+export interface TransactionRestorePreview {
+  source_member: TransactionBackupMember;
+  target_member: TransactionBackupMember;
+  exported_at: string;
+  format_version: number;
+  incoming: TransactionBackupCounts;
+  current: TransactionBackupCounts;
+  expected_state_sha256: string;
+  warnings: string[];
+}
+
+export interface TransactionRestoreRequest {
+  backup: unknown;
+  expected_state_sha256: string;
+  confirm_member_id: string;
+}
+
+export interface TransactionRestoreResult {
+  status: 'restored' | 'unchanged';
+  member_id: string;
+  counts: TransactionBackupCounts;
+  state_sha256: string;
+}

既有 memberTransactionBackupService 已以集中管理的 API_ROUTES 提供 exportBackup(memberId): Promise<Blob>、previewRestore(memberId, backup) 與 restore(memberId, payload);component 應使用這些方法,避免直接 hardcode path。

示意 curl 與 JSON

先設定示意環境變數。實際操作請以部署環境的安全設定提供 token。

API_BASE='https://api.example.invalid'
MEMBER_ID='11111111-1111-4111-8111-111111111111'
ADMIN_TOKEN='replace-with-admin-token'
BACKUP_FILE='./portfolio-backup.json'

1. 匯出成功(200)

本節的空帳本與第 2 至 4 節的非空帳本是獨立示意情境;它們不是同一輪 export/preview/restore 的實際輸出。

curl --fail-with-body --request GET \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/backup" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Accept: application/json' \
  --output "$BACKUP_FILE"

示意 response body:

{
  "format_version": 1,
  "schema_version": "portfolio-transaction-backup.v1",
  "exported_at": "2026-09-27T08:00:00+00:00",
  "source_member": {"id": "11111111-1111-4111-8111-111111111111", "email": "member@example.invalid", "display_name": null},
  "markets": ["taiwan"],
  "brokers": [],
  "transactions": [],
  "closures": [],
  "content_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}

2. 預覽成功(200)

將完整 archive 作為 backup,不是只傳 metadata;以下使用 jq 組 request body 僅為示意,實際前端應直接保留上傳檔的 JSON 結構。

jq -n --slurpfile backup "$BACKUP_FILE" '{backup: $backup[0]}' > ./restore-preview-request.json
curl --fail-with-body --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore/preview" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-preview-request.json

示意 response body:

{
  "source_member": {"id": "11111111-1111-4111-8111-111111111111", "email": "member@example.invalid", "display_name": null},
  "target_member": {"id": "11111111-1111-4111-8111-111111111111", "email": "member@example.invalid", "display_name": null},
  "exported_at": "2026-09-27T08:00:00+00:00",
  "format_version": 1,
  "incoming": {"brokers": 1, "transactions": 2, "closures": 1, "day_trade_pairs": 0, "actions": {"buy": 1, "sell": 1, "stock_dividend": 0, "cash_dividend": 0}, "date_from": "2026-01-02T00:00:00", "date_to": "2026-01-03T00:00:00"},
  "current": {"brokers": 0, "transactions": 0, "closures": 0, "day_trade_pairs": 0, "actions": {"buy": 0, "sell": 0, "stock_dividend": 0, "cash_dividend": 0}, "date_from": null, "date_to": null},
  "expected_state_sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
  "warnings": ["重建會覆蓋此會員目前的券商、交易與 lot closure 資料"]
}

3. 套用還原成功(200, restored)

jq -n --slurpfile backup "$BACKUP_FILE" \
  --arg digest 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
  --arg member "$MEMBER_ID" \
  '{backup: $backup[0], expected_state_sha256: $digest, confirm_member_id: $member}' \
  > ./restore-request.json
curl --fail-with-body --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-request.json

示意 response body:

{
  "status": "restored",
  "member_id": "11111111-1111-4111-8111-111111111111",
  "counts": {"brokers": 1, "transactions": 2, "closures": 1, "day_trade_pairs": 0, "actions": {"buy": 1, "sell": 1, "stock_dividend": 0, "cash_dividend": 0}, "date_from": "2026-01-02T00:00:00", "date_to": "2026-01-03T00:00:00"},
  "state_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}

4. 相同帳本重送(200, unchanged)

同一份 archive 在重新 preview 後再套用,可能回傳以下成功結果;UI 應顯示「帳本已是相同內容」並結束流程,不要當作失敗重試。

{
  "status": "unchanged",
  "member_id": "11111111-1111-4111-8111-111111111111",
  "counts": {"brokers": 1, "transactions": 2, "closures": 1, "day_trade_pairs": 0, "actions": {"buy": 1, "sell": 1, "stock_dividend": 0, "cash_dividend": 0}, "date_from": "2026-01-02T00:00:00", "date_to": "2026-01-03T00:00:00"},
  "state_sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}

5. 預覽後狀態改變(409 PORTFOLIO_BACKUP_STALE)

以下是以舊 preview digest 套用時的示意 command 與 response body。UI 應清除舊 preview digest,提示重新預覽;不得以舊 digest 自動重送。

curl --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-request.json
{
  "error": {
    "code": "PORTFOLIO_BACKUP_STALE",
    "message": "會員資料已變更,請重新預檢",
    "details": {
      "code": "PORTFOLIO_BACKUP_STALE",
      "message": "會員資料已變更,請重新預檢"
    }
  }
}

6. ID 與其他會員衝突(409 PORTFOLIO_BACKUP_CONFLICT)

以下示意用 preview 取得衝突;前端不應為了繞過衝突修改 archive ID。

curl --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore/preview" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-preview-request.json
{
  "error": {
    "code": "PORTFOLIO_BACKUP_CONFLICT",
    "message": "備份 ID 已由其他會員使用",
    "details": {
      "code": "PORTFOLIO_BACKUP_CONFLICT",
      "message": "備份 ID 已由其他會員使用"
    }
  }
}

UI 應停止流程並顯示不可自動解決的資料衝突;不可改寫 archive ID 後重送。

7. 鎖定或暫時不可用(409/503 PORTFOLIO_BACKUP_BUSY)

下列 request 在鎖定逾時時可回 409;資料庫或服務暫時不可用時可回 503。

curl --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-request.json
{
  "error": {
    "code": "PORTFOLIO_BACKUP_BUSY",
    "message": "交易資料正在更新,請稍後再試",
    "details": {
      "code": "PORTFOLIO_BACKUP_BUSY",
      "message": "交易資料正在更新,請稍後再試"
    }
  }
}

對 409 可提示稍後由操作者重新 preview;503 表示服務暫時不可用,同樣不得背景無限重試或使用舊 preview。

8. 備份無效(422)與會員不存在(404)

對 request body 格式無效的檔案,preview request 會回 422:

curl --request POST \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/restore/preview" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Content-Type: application/json' \
  --data @./restore-preview-request.json

此處為 request body 格式無效時的 sensitive-path validation response;後端不會反射 archive body。

{
  "error": {
    "code": "PORTFOLIO_BACKUP_INVALID",
    "message": "備份或重建請求格式無效",
    "details": {}
  }
}

checksum、member mismatch、尺寸超限與關聯驗證則同樣使用 error.code、error.message、error.details 的 HTTPException envelope;PORTFOLIO_BACKUP_MEMBER_MISMATCH、PORTFOLIO_BACKUP_TOO_LARGE 與 PORTFOLIO_BACKUP_MARKET_UNAVAILABLE 也可能是 422。目標會員不存在時:

curl --request GET \
  "$API_BASE/api/admin/members/$MEMBER_ID/transactions/backup" \
  --header "Authorization: Bearer $ADMIN_TOKEN" \
  --header 'Accept: application/json'
{
  "error": {
    "code": "MEMBER_NOT_FOUND",
    "message": "會員不存在",
    "details": {
      "code": "MEMBER_NOT_FOUND",
      "message": "會員不存在"
    }
  }
}

收到 404 後關閉還原 dialog,重新載入會員列表。

錯誤處理對照

HTTP / code 顯示與下一步
401 / 403 依既有 admin auth 流程重新驗證;不要保留或重送 archive。
404 MEMBER_NOT_FOUND 關閉 dialog,刷新會員列表。
409 PORTFOLIO_BACKUP_STALE 清除 digest,要求重新 preview。
409 PORTFOLIO_BACKUP_CONFLICT 顯示資料衝突,停止流程。
409 PORTFOLIO_BACKUP_BUSY 提示稍後由操作者重新 preview。
503 PORTFOLIO_BACKUP_BUSY 顯示暫時不可用,停止自動重試與舊 preview。
503 PORTFOLIO_BACKUP_MARKET_UNAVAILABLE 顯示市場資料表暫時不可用,停止流程。
422 PORTFOLIO_BACKUP_INVALID / PORTFOLIO_BACKUP_MEMBER_MISMATCH / PORTFOLIO_BACKUP_TOO_LARGE / PORTFOLIO_BACKUP_MARKET_UNAVAILABLE 顯示後端 message,停在選檔/確認步驟;不可修改內容後自動重送。
422 path member_id 非 UUID FastAPI 原生回 detail validation list;只顯示 path UUID 錯誤。若同時有 body errors,body 內容不會出現在回應。

驗證界線

上述 domain-error envelope 已由 investment-agent/backend/tests/test_admin_member_transaction_backup.py 的五個 404/409/422 HTTP boundary cases 驗證;資料庫還原語意由 PostgreSQL integration suite 驗證。本文的 curl 僅為操作模板,未宣稱曾對任何環境實際執行。

前端遷移 checklist

  • [ ] 在 member detail 加入僅限管理員的「匯出交易備份」與「還原交易備份」入口。
  • [ ] 將 download 實作為 blob/file download,並保留 response 的附件語意。
  • [ ] 上傳後只解析成 JSON transport payload;不格式化、過濾、重算 hash 或轉換 decimal 字串。
  • [ ] preview 畫面同時顯示 source、target、incoming、current、warnings,且確認 ID 必須與 path UUID 完全一致。
  • [ ] restore 僅使用同一輪 preview 的 expected_state_sha256;任何錯誤或使用者重新選檔後都失效。
  • [ ] 將 restored 與 unchanged 都列為成功,並刷新會員 detail。
  • [ ] 依上表為 404、STALE、CONFLICT、BUSY 與 422 建立可見的錯誤狀態與測試。