會員交易備份前端交接
本文件供 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 建立可見的錯誤狀態與測試。