把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
57 lines
3.7 KiB
SQL
57 lines
3.7 KiB
SQL
-- 0003_create_token_tables.up.sql
|
||
--
|
||
-- DB 接入塊 3:建立 pairing_tokens + session_tokens 兩張表(token 分表決策)。
|
||
-- 對齊 docs/autoflow/04-architecture/database.md §2.4、§4(token 分表段落)、§5.1。
|
||
--
|
||
-- 接續 0001(users + models)/ 0002(devices);兩張 token 表的 user_id / device_id
|
||
-- 都是 REFERENCES 既有表的 FK(users 於 0001、devices 於 0002 建立)。
|
||
--
|
||
-- 環境事實(與 0001/0002 相同):PostgreSQL 14.23。
|
||
--
|
||
-- ── 分表決策(database.md §4)──────────────────────────────────────────────
|
||
-- pairing_tokens 與 session_tokens「分表」,不共表 by kind。理由:
|
||
-- 1. code 中是兩個獨立 struct + 兩個 Store interface(PairingStore / SessionTokenStore),
|
||
-- 欄位與方法集不同(pairing 有 used_at 一次性語意、kind;session 有 parent_token_hash、無 used_at)。
|
||
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL,欄位語意混淆。
|
||
-- 3. 分表後各表 schema 乾淨、index 各自最佳化,repository 一對一對映 Store。
|
||
-- 代價:稽核「pairing→session 升級鏈」需跨表 join(session_tokens.parent_token_hash
|
||
-- → pairing_tokens.token_hash);可接受(查詢頻率低)。
|
||
|
||
-- pairing_tokens(短期一次性配對 token;對齊 internal/auth.PairingToken)
|
||
-- PK = token_hash(sha256(plaintext)):永不存明文 token(security.md §1.3)。
|
||
CREATE TABLE pairing_tokens (
|
||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext),永不存明文
|
||
user_id UUID NOT NULL REFERENCES users(id),
|
||
device_id UUID REFERENCES devices(id), -- MarkUsed 綁定後才有(nullable)
|
||
kind TEXT NOT NULL DEFAULT 'pairing', -- 固定 'pairing'(保留欄位,便於觀測/未來擴充)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
expires_at TIMESTAMPTZ, -- 15min TTL;NULL = 永不過期(測試用)
|
||
used_at TIMESTAMPTZ, -- 一次性:MarkUsed 後 Validate 失敗
|
||
revoked_at TIMESTAMPTZ
|
||
);
|
||
|
||
-- List by user(UI 顯示),只索引未撤銷紀錄。
|
||
CREATE INDEX idx_pairing_tokens_user_active ON pairing_tokens (user_id) WHERE revoked_at IS NULL;
|
||
-- device_id 反查(cascade 撤銷 by device,塊 5)。
|
||
CREATE INDEX idx_pairing_tokens_device ON pairing_tokens (device_id);
|
||
|
||
-- session_tokens(長期可撤銷 tunnel session token;對齊 internal/auth.SessionToken)
|
||
-- PK = token_hash;device_id 必填(session token 必綁 device)。
|
||
CREATE TABLE session_tokens (
|
||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
|
||
user_id UUID NOT NULL REFERENCES users(id),
|
||
device_id UUID NOT NULL REFERENCES devices(id), -- session token 必綁 device
|
||
parent_token_hash TEXT, -- 升級來源 pairing token 的 hash(稽核鏈,可 join pairing_tokens.token_hash)
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
expires_at TIMESTAMPTZ, -- 90 天 TTL;NULL = 永不過期
|
||
revoked_at TIMESTAMPTZ
|
||
-- 注意:無 used_at(非一次性)、無 kind
|
||
);
|
||
|
||
-- List by user,只索引未撤銷紀錄。
|
||
CREATE INDEX idx_session_tokens_user_active ON session_tokens (user_id) WHERE revoked_at IS NULL;
|
||
-- device_id 反查(cascade 撤銷 by device,塊 5)。
|
||
CREATE INDEX idx_session_tokens_device ON session_tokens (device_id);
|
||
-- parent_token_hash 稽核鏈查詢(join pairing_tokens.token_hash)。
|
||
CREATE INDEX idx_session_tokens_parent ON session_tokens (parent_token_hash);
|