diff --git a/docs/autoflow/04-architecture/api/api-spec.md b/docs/autoflow/04-architecture/api/api-spec.md index 23ed337..0bdcece 100644 --- a/docs/autoflow/04-architecture/api/api-spec.md +++ b/docs/autoflow/04-architecture/api/api-spec.md @@ -102,6 +102,10 @@ ### POST `/api/cloud/devices/:id/rename` - 改雲端上的 device name +### DELETE `/api/cloud/devices/:id` — 解除綁定(unpair)並刪除雲端 device record +- **DB 接入後行為(塊 5)**:刪除 device 會在**同一交易內** cascade 撤銷該 device 的所有 pairing token + session token(`pairing_tokens` + `session_tokens` 兩張表,by `device_id`,`UPDATE ... SET revoked_at = now()`)。對應 DB 層一致性定義見 `../database.md` §6。 +- 撤銷後該 device 的既有 tunnel session 將無法續用,需重新配對。 + --- ## 4. Models @@ -205,6 +209,13 @@ ### GET `/api/system/info` - 版本資訊 +### GET `/healthz` — liveness / readiness(給 load balancer) +- 純基礎設施健康檢查端點(非 `/api/*` 前綴),供 LB / orchestrator probe。 +- **DB 接入後行為(塊 5)**:PostgreSQL / Redis **啟用時會 ping**,任一 ping 失敗 → 回 **503**(讓 load balancer 知道此實例不健康、停止導流)。**未啟用的依賴略過檢查**(雛形未配 DB/Redis 時,這些依賴視為 not-applicable,不影響健康判定)。 +- Response(健康):`200 { "status": "ok" }` +- Response(不健康):`503 { "status": "unavailable", "failed": ["postgres"] }`(`failed` 列出 ping 失敗的依賴) +- 與 `GET /api/system/health` 的差異:`/healthz` 是基礎設施 probe(含 DB/Redis ping),`/api/system/health` 是業務層健康(api-server 自身 + tunnel 連線狀態)。 + --- ## 8. Converter @@ -301,11 +312,18 @@ | `FORBIDDEN` | 403 | 權限不足 | | `NOT_FOUND` | 404 | 資源不存在 | | `VALIDATION_FAILED` | 400 | 輸入驗證失敗 | +| `CONFLICT` | 409 | 唯一性衝突(如重複註冊 active device serial、email 已存在)。DB unique violation 映射到此碼。| | `TUNNEL_DISCONNECTED` | 502 | Local agent 未連線 | | `TUNNEL_ERROR` | 502 | Tunnel 傳輸錯誤 | | `NOT_IMPLEMENTED` | 501 | 雛形尚未實作 | | `RATE_LIMITED` | 429 | 請求過快(Phase 1)| | `INTERNAL_ERROR` | 500 | 未預期錯誤 | +| `SERVICE_UNAVAILABLE` | 503 | 後端依賴(PostgreSQL / Redis)連線失敗時的 fail-fast。持久資料相關 API(model / device / token)在 PG 不可用時回此碼,不回假資料。| + +> **DB 接入後的降級策略(fail-fast,2026-06-20 使用者拍板)**: +> - **PostgreSQL 掉** → 持久資料相關 API(model / device / token)回 `503 SERVICE_UNAVAILABLE`,**不回假資料、不 fallback in-memory**(避免回傳過期/不一致資料)。 +> - **Redis 掉** → session 驗證失敗(請求視為未認證 → `401 UNAUTHORIZED`),**不自動 fallback in-memory session**(避免多機部署下各實例 session 不同步)。 +> - DB unique violation → `409 CONFLICT`(而非 500),讓前端能區分「衝突」與「未預期錯誤」。 --- diff --git a/docs/autoflow/04-architecture/database.md b/docs/autoflow/04-architecture/database.md index 1b0cc73..9956a1a 100644 --- a/docs/autoflow/04-architecture/database.md +++ b/docs/autoflow/04-architecture/database.md @@ -1,9 +1,47 @@ # Database — 資料模型 > **雛形階段無真實 DB**(見 ADR-005)。本文件定義 Go struct + 未來 DB schema 的映射,讓雛形程式碼直接以這些 struct 操作記憶體,Phase 1 直接按這個結構建 PostgreSQL schema。 +> +> **以 code 為準原則(2026-06-20 DB 接入規劃更新)**:本文件 §2 struct 與 §4 schema 自雛形以來與實際 code 有少數漂移。DB 接入時,**欄位定義一律以 `visionA-backend/internal/*` 的 Go struct 為準**;本次更新已將 §4 schema 對齊 code 並標註修正點(搜尋「以 code 為準」)。後續實作 PostgresRepository 時若再發現不一致,亦以 code 為準並回頭修本文件。 --- +## 0. DB 接入規格摘要(2026-06-20 新增 — DB 接入前文件補齊) + +> 本節為 DB 接入任務(visionA-backend 6 個 in-memory store → Postgres/Redis 持久化)的開發前依循,對應 `db-integration-plan.md` §10 缺口補齊。範圍 = **完整**(PG 全接 + session 接 Redis + 交易韌性)。 + +### 0.1 DB 供給前提(取代任何「130 現成可用」字眼) + +- **PostgreSQL**:visionA 專用實例,**credential 已取得**。visionA 端**不負責 provision**(DB 機器 / database / 角色由他人在 stage host 192.168.0.130 另開),**只負責跑 migration 與接上**。 +- **Redis**:visionA 專用實例,**由使用者自行在 130 另起**(設密碼),供 `userSession` 用。visionA 端同樣不 provision、只接上。 +- 130:5432 上其他 container 一律不共用;任何「130 上已有 DB 可直接用」的舊敘述皆作廢。 +- 整合測試一律走 **testcontainers**(本機/CI 一次性 DB),不依賴 130;130 僅用於 stage 收尾驗證。 + +### 0.2 持久化分工(對齊 `db-integration-plan.md` §3) + +| Store | package | 落地 | 理由 | +|-------|---------|------|------| +| `modelRepo` | `internal/model` | **Postgres** | 長期保存、List by owner/chip/source、跨重啟不掉。使用者最關心。 | +| `deviceRepo` | `internal/device` | **Postgres** | 裝置綁定身分長期保存、owner+serial unique、關聯查詢。 | +| `pairingStore` | `internal/auth` | **Postgres** | 可撤銷長期憑證、要稽核(used_at/revoked_at)、重啟不掉。 | +| `sessionTokenStore` | `internal/auth` | **Postgres** | session token 90 天長效、可撤銷、查 parent token 稽核鏈。 | +| `userSessionStore` | `internal/usersession` | **Redis** | 瀏覽器 cookie session、高頻讀、idle/absolute 雙 TTL、掉了重登即可。Redis TTL 原生支援。 | +| remote-proxy `session` | `internal/session` | **in-memory(不變)** | 特例:value 是活的 yamux Handle,不可序列化進 Redis。單節點維持 in-memory;多節點才需 Redis 存 Summary(handle 仍留本地)。本期範圍外。 | + +### 0.3 第一份 migration 清單(最小範圍 = 塊 0 + 塊 1) + +第一個 migration(`migrations/0001_*`)建立**模型庫持久化的最小集合**: + +- **`users`** — `models.owner_user_id` / `devices.owner_user_id` / token `user_id` 的 FK 目標,必須先建(Phase 1 stub,雛形固定 demo-user)。 +- **`models`** — 使用者最關心、塊 1 主體。 + +> device / pairing_tokens / session_tokens 表隨塊 2 / 塊 3 的 migration(`0002_*` / `0003_*`)建立,不塞進第一份 migration。決策理由:第一份 migration 聚焦「重啟後模型庫資料還在」的最小可驗收,降低首次上 DB 的風險面。 + +### 0.4 兩個關鍵 schema 決策(DB 接入前定案) + +1. **token 共表(by `kind`)vs 分表** → **決策:分表(`pairing_tokens` + `session_tokens` 兩張)**。理由見 §4 token 段落。 +2. **已 soft-delete 的 device serial 能否重註冊** → **決策:能**,用 partial unique index `WHERE deleted_at IS NULL` 達成。語意見 §4 devices 段落。 + ## 1. 核心實體 ER 概念圖 ``` @@ -95,10 +133,13 @@ type Device struct { CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` + PairedAt *time.Time `json:"pairedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏(配對完成時間) DeletedAt *time.Time `json:"deletedAt,omitempty"` } ``` +> **以 code 為準**(2026-06-20):上方對齊 `internal/device/device.go`,新增 `PairedAt`(配對完成時間,nullable)。`SerialNumber` 在 code 為 `omitempty`。 + **注意**:此「Device」記錄的是**抽象身分**(綁 user)。當瀏覽器呼叫 `GET /api/devices` 時,實際「此時 USB 上接了哪些」要透過 tunnel 問 local agent 的 `/api/devices`。雲端這張表負責「我曾經綁過哪些裝置、它們的名字、擁有者」+ 雙狀態快照。 **更新時機**: @@ -123,50 +164,75 @@ type Model struct { // 檔案資訊 StorageKey string `json:"storageKey"` // 在 Store 裡的 key(例:models/{user_id}/{id}.nef) FileSize int64 `json:"fileSize"` - FileChecksum string `json:"fileChecksum"` // sha256 + FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex - // 模型 metadata - TargetChip string `json:"targetChip"` // kl520 / kl720 + // FAAObjectKey(ADR-017 (a) B1):model 在 File Access Agent 上的 object key。 + // 只有 Source=converted(轉檔→promote 進 FAA)類有值;上傳類留空。 + // JSON tag = "-"(不對前端揭露內部 storage key,ADR-017 決策 2)。 + // DB 欄位 nullable(見 §4,2026-06-20 補上 — 原 schema 漏此欄)。 + FAAObjectKey string `json:"-"` + + // 模型 metadata(可選) + TargetChip string `json:"targetChip,omitempty"` // kl520 / kl720 InputShape []int `json:"inputShape,omitempty"` Classes []string `json:"classes,omitempty"` Framework string `json:"framework,omitempty"` // onnx / keras 等(若有) // 來源 - Source string `json:"source"` // uploaded / converted / preset + Source Source `json:"source"` // uploaded / converted / preset(Source = string 別名) SourceJobID string `json:"sourceJobId,omitempty"` // 若 source=converted - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt *time.Time `json:"deletedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + UploadedAt *time.Time `json:"uploadedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏 + DeletedAt *time.Time `json:"deletedAt,omitempty"` } ``` -### 2.4 PairingToken(見 ADR-003) +> **以 code 為準**(2026-06-20):上方 struct 已對齊 `internal/model/model.go`。相對原雛形定義新增了 `FAAObjectKey`(ADR-017)與 `UploadedAt`,`FileChecksum` 改為 `omitempty`,`Source` 為 `type Source = string` 別名。 + +### 2.4 PairingToken / SessionToken(見 ADR-003) + +> **以 code 為準**(2026-06-20):實際 code 中 pairing 與 session 是**兩個獨立 struct + 兩個獨立 Store**(`internal/auth/auth.go`),欄位不同。原雛形文件畫的單一 `PairingInfo`(含 `RevokedBy` / `LastSeenAt` / `ParentToken` 共用欄)**與 code 不符、已作廢**。下方改列 code 實際的兩個 struct,schema 也據此分表(見 §4)。 ```go -// internal/auth/types.go -package auth +// internal/auth/auth.go -import "time" +// PairingToken — 短期一次性配對 token(vAc_ + 32 hex,15min TTL,一次性) +type PairingToken struct { + Plaintext string `json:"-"` // 僅建立時回傳一次 + TokenHash string `json:"-"` // sha256(Plaintext),DB PK + UserID string `json:"userId"` + DeviceID string `json:"deviceId,omitempty"` // MarkUsed 綁定後才有 + Kind TokenKind `json:"kind"` // "pairing" -type PairingInfo struct { - TokenHash string `json:"-"` // sha256(plaintext token),DB 只存 hash - UserID string `json:"userId"` - DeviceID string `json:"deviceId,omitempty"` // 綁定後才有 + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 15min TTL;nil = 永不過期(測試用) + UsedAt *time.Time `json:"usedAt,omitempty"` // 一次性:MarkUsed 寫入後 Validate 失敗 + RevokedAt *time.Time `json:"revokedAt,omitempty"` +} - CreatedAt time.Time `json:"createdAt"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` // pairing phase 有 15min TTL;升級為 session token 後 nil 或長期 - UsedAt *time.Time `json:"usedAt,omitempty"` // 首次被 local agent 使用的時間 - RevokedAt *time.Time `json:"revokedAt,omitempty"` - RevokedBy string `json:"revokedBy,omitempty"` - LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` +// SessionToken — 長期可撤銷 tunnel session token(vAs_ + 64 hex,90 天 TTL) +type SessionToken struct { + Plaintext string `json:"-"` + TokenHash string `json:"-"` // DB PK + UserID string `json:"userId"` + DeviceID string `json:"deviceId"` // session token 必綁 device + ParentTokenHash string `json:"-"` // 升級來源 pairing token 的 hash(稽核鏈) - // Phase 1:兩階段設計 - Kind string `json:"kind"` // "pairing" | "session" - ParentToken string `json:"parentToken,omitempty"` // session 對應的 pairing + CreatedAt time.Time `json:"createdAt"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 90 天;nil = 永不過期 + RevokedAt *time.Time `json:"revokedAt,omitempty"` + // 注意:SessionToken 無 UsedAt(非一次性)、無 Kind(型別本身即 session) } ``` +**關鍵差異(影響 §4 分表決策)**: +- `PairingToken` 有 `UsedAt`(一次性語意)、`Kind`;無 `ParentTokenHash`。 +- `SessionToken` 有 `ParentTokenHash`(稽核鏈)、`DeviceID` 必填;無 `UsedAt`、無 `Kind`。 +- 兩者由**不同 Store interface** 管理(`PairingStore` / `SessionTokenStore`),方法集不同(pairing 有 `MarkUsed`、`Validate`;session 有 `Get`)。 +- code 中**不存在** `RevokedBy` / `LastSeenAt` / `ParentToken`(原雛形文件的臆測欄位)。 + ### 2.5 Cluster(從 POC 搬) ```go @@ -249,6 +315,10 @@ type SessionSummary struct { 雛形 session 在 in-memory map,process 重啟就掉。Phase 1 考慮 Redis 存 summary(TTL + heartbeat)。 +> **2026-06-20 補充(DB 接入)**:要區分兩種 session: +> - **`internal/usersession`(browser cookie session)→ 接 Redis**。Session struct(見 `internal/usersession/usersession.go`)含 OIDC pending state(OIDCState/Nonce/CodeVerifier)+ token snapshot(AccessToken/IDTokenRaw)+ `Extra map[string]any`。`RedisUserSessionStore` 用 Redis 雙 TTL(idle = `IdleTTL` 預設 24h、absolute = `AbsoluteTTL` 預設 168h)取代現有手動 cleanup goroutine;`Extra` 需 JSON 序列化;`OIDCCodeVerifier` 等敏感欄位照舊不可進 log。Redis 為 visionA 專用實例(使用者在 130 另起、設密碼)。 +> - **`internal/session`(remote-proxy tunnel session)→ 維持 in-memory(不變)**。value 是活的 yamux Handle,**不可序列化進 Redis**。單節點維持 in-memory;多節點才需「Summary 放 Redis、handle 留本地」混合實作 —— **本期範圍外**。 + --- ## 3. Repository Interfaces @@ -288,9 +358,14 @@ Phase 1:新增 `postgres_repository.go`,實作同 interface。 ```sql -- users +-- 環境適配(塊 0,2026-06-20 真環境驗證):目標 PG 14.23 未安裝 citext extension, +-- email 改用 TEXT + 函式索引達成大小寫不敏感唯一,取代原 CITEXT 寫法: +-- email TEXT NOT NULL; +-- CREATE UNIQUE INDEX users_email_lower_uniq ON users (lower(email)); +-- 應用層查詢一律以 lower(email) 比對。若未來環境有 citext,可回退 CITEXT 簡化。 CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email CITEXT UNIQUE NOT NULL, + email TEXT UNIQUE NOT NULL, -- 見上方環境適配註:PG 14.23 無 citext,實際以 lower(email) 唯一索引達成 CI 唯一 name TEXT, password_hash TEXT, org_id UUID, @@ -318,49 +393,82 @@ CREATE TABLE devices ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted_at TIMESTAMPTZ, - UNIQUE (owner_user_id, serial_number) + paired_at TIMESTAMPTZ, -- 2026-06-20 補:配對完成時間(code 有、原 schema 漏) + deleted_at TIMESTAMPTZ ); +-- 2026-06-20 改:table-level UNIQUE → partial unique index(soft-delete 後 serial 可重註冊) +-- 決策:已 soft-delete 的 device serial 「能」重新註冊。 +-- 語意:唯一性只對「未刪除」紀錄成立;刪掉後同 (owner, serial) 可再 INSERT 一筆新的。 +CREATE UNIQUE INDEX uq_devices_owner_serial_active + ON devices (owner_user_id, serial_number) + WHERE deleted_at IS NULL; CREATE INDEX ON devices (owner_user_id) WHERE deleted_at IS NULL; CREATE INDEX ON devices (remote_status) WHERE deleted_at IS NULL; --- pairing_tokens +-- ── token 分表決策(2026-06-20)────────────────────────────────────────── +-- 決策: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,欄位語意混淆、CHECK 約束變複雜。 +-- 3. 分表後各表 schema 乾淨、index 各自最佳化,repository 一對一對映 Store,最直觀。 +-- 代價:稽核「pairing→session 升級鏈」需跨表 join(session_tokens.parent_token_hash → pairing_tokens.token_hash); +-- 可接受(查詢頻率低,且 parent_token_hash 已是 hash 不需額外轉換)。 + +-- pairing_tokens(短期一次性配對 token;對齊 internal/auth.PairingToken) CREATE TABLE pairing_tokens ( - token_hash TEXT PRIMARY KEY, -- sha256(plaintext) + token_hash TEXT PRIMARY KEY, -- sha256(plaintext),永不存明文 user_id UUID NOT NULL REFERENCES users(id), - device_id UUID REFERENCES devices(id), - kind TEXT NOT NULL DEFAULT 'pairing', - parent_token TEXT, + device_id UUID REFERENCES devices(id), -- MarkUsed 綁定後才有 + kind TEXT NOT NULL DEFAULT 'pairing', -- 固定 'pairing'(保留欄位,便於觀測/未來擴充) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - expires_at TIMESTAMPTZ, - used_at TIMESTAMPTZ, - revoked_at TIMESTAMPTZ, - revoked_by UUID, - last_seen_at TIMESTAMPTZ + expires_at TIMESTAMPTZ, -- 15min TTL;NULL = 永不過期(測試用) + used_at TIMESTAMPTZ, -- 一次性:MarkUsed 後 Validate 失敗 + revoked_at TIMESTAMPTZ + -- 2026-06-20 移除原 schema 的 parent_token / revoked_by / last_seen_at —— code 不存在這些欄位 ); CREATE INDEX ON pairing_tokens (user_id) WHERE revoked_at IS NULL; CREATE INDEX ON pairing_tokens (device_id); +-- session_tokens(長期可撤銷 tunnel session token;對齊 internal/auth.SessionToken) +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 +); +CREATE INDEX ON session_tokens (user_id) WHERE revoked_at IS NULL; +CREATE INDEX ON session_tokens (device_id); +CREATE INDEX ON session_tokens (parent_token_hash); + -- models CREATE TABLE models ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - owner_user_id UUID NOT NULL REFERENCES users(id), - name TEXT NOT NULL, - description TEXT, - storage_key TEXT NOT NULL, - file_size BIGINT NOT NULL, - file_checksum TEXT, - target_chip TEXT, - input_shape INT[], - classes TEXT[], - framework TEXT, - source TEXT NOT NULL, - source_job_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - deleted_at TIMESTAMPTZ + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + description TEXT, + storage_key TEXT NOT NULL, + file_size BIGINT NOT NULL, + file_checksum TEXT, + faa_object_key TEXT, -- 2026-06-20 補:ADR-017 (a) B1,nullable(上傳類留 NULL) + target_chip TEXT, + input_shape INT[], + classes TEXT[], + framework TEXT, + source TEXT NOT NULL, + source_job_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + uploaded_at TIMESTAMPTZ, -- 2026-06-20 補:code 有 UploadedAt、原 schema 漏 + deleted_at TIMESTAMPTZ ); CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL; +CREATE INDEX ON models (owner_user_id, target_chip) WHERE deleted_at IS NULL; -- List filter by chip +CREATE INDEX ON models (owner_user_id, source) WHERE deleted_at IS NULL; -- List filter by source -- clusters CREATE TABLE clusters ( @@ -397,10 +505,114 @@ CREATE TABLE converter_jobs ( ## 5. Migration 策略(Phase 1) -- 工具:`golang-migrate` +- 工具:`golang-migrate/v4` - 位置:`visionA-backend/migrations/*.sql` -- 命名:`YYYYMMDDHHMMSS_description.up.sql` / `.down.sql` -- 雛形:**不需要 migrations 目錄**;Phase 1 第一個 migration 建立以上所有 table +- 命名:`NNNN_description.up.sql` / `.down.sql`(序號式,如 `0001_create_users_models.up.sql`) +- runner:啟動時自動 migrate up(或獨立 `cmd/migrate`),由 `DatabaseConfig.Enabled()` 決定是否執行 + +### 5.1 Migration 清單(2026-06-20 補 — 對齊塊式範圍) + +| Migration | 內容 | 對應塊 | 觸發範圍 | +|-----------|------|--------|---------| +| `0001_create_users_models` | `users` + `models`(含 faa_object_key / uploaded_at + 3 個 owner-scoped index) | 塊 1 | **最小範圍**(使用者最關心,重啟後模型庫還在) | +| `0002_create_devices` | `devices`(雙狀態欄位 + paired_at + partial unique index `uq_devices_owner_serial_active`) | 塊 2 | 持久資料範圍 | +| `0003_create_token_tables` | `pairing_tokens` + `session_tokens`(分表,見 §4 決策) | 塊 3 | 持久資料範圍 | +| `0004_*`(如需要) | `clusters` / `converter_jobs` | — | **本期範圍外**(main.go 未 wire,見 `db-integration-plan.md` §8) | + +> **第一份 migration(`0001`)為何含 users**:`models.owner_user_id` 是 `REFERENCES users(id)` 的 FK,必須先有 users 表。雛形 users 為 stub(固定 demo-user),但 schema 與 FK 約束第一份就要到位,否則 models 建不起來。 +> +> 決策:第一份 migration 聚焦「最小可驗收 = 重啟後模型庫資料還在」,故只含 users + models;device / token 隨各自塊的 migration 進場,降低首次上 DB 的風險面。 + +--- + +## 5.5 連線設定規格(2026-06-20 新增 — DatabaseConfig / RedisConfig) + +> 對齊既有 `internal/config/config.go` 慣例:每個外部依賴一個 sub-config struct、掛在 `Config` 上、env 前綴 `VISIONA_*`、有 `Enabled()` 用「必要欄位全非空」判斷是否 wire(參照既有 `ConversionConfig.Enabled()` / `FileAccessConfig.Enabled()`)。新增欄位須同步 `.env.example`。 + +### 5.5.1 DatabaseConfig(PostgreSQL) + +```go +// internal/config/config.go — 掛在 Config struct 上:Database DatabaseConfig + +// DatabaseConfig 控制 PostgreSQL 連線(持久業務資料:model / device / token)。 +// +// 啟用判定(Enabled()):Host / User / DBName 三者全非空才視為啟用; +// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memory(local dev fallback)。 +// +// 安全:Password / DSN 永遠不印 log 全文(可印 host:port/dbname); +// 部署走既有 secrets 機制(AWS Secrets Manager / Vault),禁止 commit 進 repo。 +type DatabaseConfig struct { + Host string // VISIONA_DB_HOST + Port int // VISIONA_DB_PORT,預設 5432 + User string // VISIONA_DB_USER + Password string // VISIONA_DB_PASSWORD(禁止 commit) + DBName string // VISIONA_DB_NAME + SSLMode string // VISIONA_DB_SSLMODE,預設 "require"(stage/prod);本機 testcontainers 用 "disable" + + // 連線池(pgxpool) + MaxConns int // VISIONA_DB_MAX_CONNS,預設 10 + MinConns int // VISIONA_DB_MIN_CONNS,預設 2 + MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME,預設 1h + ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT,預設 5s(建池/ping 逾時) +} + +// DSN 組裝(供 pgxpool): +// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns} +func (c DatabaseConfig) Enabled() bool { + return c.Host != "" && c.User != "" && c.DBName != "" +} +``` + +| env | 預設 | 說明 | +|-----|------|------| +| `VISIONA_DB_HOST` | (空)| visionA 專用 PG host(credential 已取得;他人在 130 provision) | +| `VISIONA_DB_PORT` | `5432` | | +| `VISIONA_DB_USER` | (空)| app role | +| `VISIONA_DB_PASSWORD` | (空)| 禁止 commit;走 secrets 機制 | +| `VISIONA_DB_NAME` | (空)| visionA 專用 database | +| `VISIONA_DB_SSLMODE` | `require` | stage/prod 用 require/verify-full;testcontainers 用 disable | +| `VISIONA_DB_MAX_CONNS` | `10` | pgxpool 上限 | +| `VISIONA_DB_MIN_CONNS` | `2` | pgxpool 常駐 | +| `VISIONA_DB_MAX_CONN_LIFETIME` | `1h` | | +| `VISIONA_DB_CONN_TIMEOUT` | `5s` | 建池 / 啟動 ping 逾時 | + +### 5.5.2 RedisConfig(userSession) + +```go +// internal/config/config.go — 掛在 Config struct 上:Redis RedisConfig + +// RedisConfig 控制 Redis 連線(僅 userSession:browser cookie session)。 +// +// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。 +// visionA 端不 provision、只接上。 +// +// 啟用判定(Enabled()):Host 非空才視為啟用; +// 未啟用 → userSession 仍用 in-memory(雛形行為,process 重啟掉 session)。 +// +// 安全:Password 永遠不印 log 全文。禁止 commit 進 repo。 +type RedisConfig struct { + Host string // VISIONA_REDIS_HOST + Port int // VISIONA_REDIS_PORT,預設 6379 + Password string // VISIONA_REDIS_PASSWORD(visionA 專用實例必設密碼;禁止 commit) + DB int // VISIONA_REDIS_DB,預設 0(db index) + + ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT,預設 5s +} + +func (c RedisConfig) Enabled() bool { + return c.Host != "" +} +``` + +| env | 預設 | 說明 | +|-----|------|------| +| `VISIONA_REDIS_HOST` | (空)| visionA 專用 Redis(使用者在 130 另起) | +| `VISIONA_REDIS_PORT` | `6379` | | +| `VISIONA_REDIS_PASSWORD` | (空)| visionA 專用實例必設;禁止 commit | +| `VISIONA_REDIS_DB` | `0` | db index | +| `VISIONA_REDIS_CONN_TIMEOUT` | `5s` | 建連 / 啟動 ping 逾時 | + +> userSession 的 idle / absolute TTL **不在 RedisConfig**,沿用既有 `UserSessionConfig.IdleTTL`(24h)/ `AbsoluteTTL`(168h);`RedisUserSessionStore` 用這兩個值設 Redis key TTL 取代手動 cleanup goroutine。 --- @@ -409,13 +621,20 @@ CREATE TABLE converter_jobs ( | 操作 | 需要的一致性 | |------|-------------| | 使用者建立 Device + PairingToken | 同一 transaction | -| 使用者刪除 Device | cascade 撤銷所有 PairingToken | +| 使用者刪除 Device | cascade 撤銷該 device 的所有 token(**pairing_tokens + session_tokens 兩張表**,by device_id)於同一 tx | | Converter job 完成 → 建立 Model | transactional upsert + webhook idempotency | -雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx。 +雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx(塊 5 的 `internal/db/tx.go` helper)。 + +> token 分表後(§4 決策),「刪 device cascade 撤銷 token」需在同一 tx 內對 `pairing_tokens` 與 `session_tokens` 各跑一次 `UPDATE ... SET revoked_at = now() WHERE device_id = $1 AND revoked_at IS NULL`。 --- **雛形實作 / 未來擴展**: - 雛形:所有 repository 用 `map + sync.RWMutex`;struct 欄位按上表定義(多出的欄位留空或 zero value) - 未來:實作 `Postgres*Repository`;加上 migration;處理 soft delete(`WHERE deleted_at IS NULL`) + +**DB 接入關鍵改動提醒(2026-06-20,對齊 `db-integration-plan.md` 塊 3)**: +- pairing / session token 兩個 in-memory store 目前**以 plaintext 當 map key**;接 Postgres 時改成 **`token_hash`(`HashToken(plaintext)`)當 PK**。`Validate` / `Get` 需先 `HashToken()` 再查;務必確認所有呼叫端傳入 plaintext、由 store 內部統一 hash(漏一個呼叫端就驗不過)。code 已有 `HashToken()`(`internal/auth/token.go`)。 +- model / device 的 `Save` 是 upsert by ID,須保留既有 `CreatedAt`(對齊 in-memory `Save` 的語意:existing 且未刪除時保留 CreatedAt)。 +- 整合測試走 testcontainers(不依賴 130);stage 收尾才接 visionA 專用 PG / Redis。 diff --git a/visionA-backend/.env.example b/visionA-backend/.env.example index ca9574c..7cc8d57 100644 --- a/visionA-backend/.env.example +++ b/visionA-backend/.env.example @@ -232,3 +232,71 @@ VISIONA_FILE_ACCESS_FAA_BASE_URL= # download token 有效期(秒)— ADR-017 Q2 區間 60–300s,預設 120 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120 + + +# ============================================================ +# DB 接入塊 0 — PostgreSQL(持久業務資料:model / device / token) +# ============================================================ +# 對齊 docs/autoflow/04-architecture/database.md §5.5.1。 +# +# 啟用判定:HOST / USER / NAME 三者全非空才啟用;任一缺 → +# api-server 不建連線池,所有 repository 維持 in-memory(local dev fallback,雛形行為不變)。 +# ⚠️ 塊 0 範圍:即使啟用,目前 repository 仍是 in-memory(建池只為驗證基礎建設 + 讓 schema 就位); +# model/device/token 切到 Postgres 是塊 1–3。 +# +# ⚠️ DB 由他人在 stage host(130) 另開 visionA 專用實例並提供連線資訊;visionA 端不 provision、只接上。 +# ⚠️ PASSWORD 不可 commit;prod 走 Secrets Manager / Vault;log 永遠不印密碼/DSN 全文。 + +# visionA 專用 PG host(credential 已取得;他人在 130 provision)。留空 = 不啟用(用 in-memory)。 +VISIONA_DB_HOST= + +# PG port,預設 5432 +VISIONA_DB_PORT=5432 + +# app role +VISIONA_DB_USER= + +# ⚠️ 不可 commit;走 secrets 機制 +VISIONA_DB_PASSWORD= + +# visionA 專用 database 名稱 +VISIONA_DB_NAME= + +# sslmode:stage/prod 用 require / verify-full;本機 testcontainers 用 disable +VISIONA_DB_SSLMODE=require + +# pgxpool 連線數上限 / 常駐數 +VISIONA_DB_MAX_CONNS=10 +VISIONA_DB_MIN_CONNS=2 + +# 連線生命週期 / 建池+啟動 ping 逾時 +VISIONA_DB_MAX_CONN_LIFETIME=1h +VISIONA_DB_CONN_TIMEOUT=5s + +# 啟動時是否自動跑 migrate up(預設 true)。設 false 改用獨立 `go run ./cmd/migrate up`。 +VISIONA_DB_AUTO_MIGRATE=true + + +# ============================================================ +# DB 接入塊 4 鉤子 — Redis(僅 userSession:browser cookie session) +# ============================================================ +# 對齊 docs/autoflow/04-architecture/database.md §5.5.2。 +# +# ⚠️ 塊 0 只先把 config 鉤子留好,main.go 尚未 wire(等塊 4 接 RedisUserSessionStore)。 +# 啟用判定:HOST 非空即啟用;未啟用 → userSession 仍用 in-memory(process 重啟掉 session)。 +# ⚠️ visionA 專用 Redis 實例由使用者在 130 另起、設密碼;PASSWORD 不可 commit、log 不印全文。 + +# visionA 專用 Redis host。留空 = 不啟用(用 in-memory)。 +VISIONA_REDIS_HOST= + +# Redis port,預設 6379 +VISIONA_REDIS_PORT=6379 + +# ⚠️ visionA 專用實例必設密碼;不可 commit +VISIONA_REDIS_PASSWORD= + +# Redis db index,預設 0 +VISIONA_REDIS_DB=0 + +# 建連 / 啟動 ping 逾時 +VISIONA_REDIS_CONN_TIMEOUT=5s diff --git a/visionA-backend/Makefile b/visionA-backend/Makefile index 3e5ae49..4a1b676 100644 --- a/visionA-backend/Makefile +++ b/visionA-backend/Makefile @@ -60,7 +60,7 @@ dev: build ## 本機開發:平行跑 remote-proxy + api-server(非交付物 wait # ---- Test / Lint --------------------------------------------------------- -.PHONY: test test-race fmt vet lint +.PHONY: test test-race test-db migrate-up migrate-version fmt vet lint test: ## 執行單元測試(詳細輸出) $(GO) test ./... -v @@ -68,6 +68,15 @@ test: ## 執行單元測試(詳細輸出) test-race: ## 執行單元 + 整合測試(race detector + coverage) $(GO) test -race -coverprofile=coverage.out ./... +test-db: ## 執行 DB 整合測試(testcontainers,需 Docker daemon;-tags=dbtest) + $(GO) test -tags=dbtest ./internal/db/... -v + +migrate-up: ## 跑 DB migration up(需設好 VISIONA_DB_* 環境變數) + $(GO) run ./cmd/migrate up + +migrate-version: ## 顯示目前 DB schema 版本 + $(GO) run ./cmd/migrate version + fmt: ## gofmt 格式化 $(GO) fmt ./... diff --git a/visionA-backend/README.md b/visionA-backend/README.md index 35493ec..09b8e46 100644 --- a/visionA-backend/README.md +++ b/visionA-backend/README.md @@ -192,6 +192,7 @@ visionA-backend/ │ │ ├── seed.go # --seed-demo-data 用的示範資料 │ │ ├── integration_test.go # B4 端到端測試 │ │ └── b5_integration_test.go # B5 端到端測試(含 tunnel forward + model upload) +│ ├── migrate/ # 獨立 migration 工具(go run ./cmd/migrate up|down|version) │ └── remote-proxy/ # tunnel server(有狀態,持有 session in-memory) │ └── main.go ├── internal/ @@ -205,8 +206,11 @@ visionA-backend/ │ ├── wsconn/ # WebSocket ↔ net.Conn adapter(POC 複製) │ ├── converter/ # StubClient(Phase 2 才實作) │ ├── storage/ # Store interface + LocalFSStore(HMAC presigned URL) -│ ├── config/ # Config + Load()(12-Factor) +│ ├── config/ # Config + Load()(12-Factor;含 DatabaseConfig / RedisConfig) +│ ├── db/ # DB 接入塊 0:pgxpool 連線池 + migration runner(嵌入式) +│ │ └── testsupport/ # testcontainers 整合測試 helper + fixture factory(-tags=dbtest) │ └── logger/ # slog JSON logger wrapper +├── migrations/ # golang-migrate SQL(NNNN_*.up/down.sql)+ embed.go ├── docker/ │ ├── Dockerfile.api-server # multi-stage,non-root,healthcheck │ ├── Dockerfile.remote-proxy @@ -281,15 +285,48 @@ visionA-backend/ | `VISIONA_MODEL_MAX_SIZE_MB` | `100` | 模型上傳大小上限 | | `VISIONA_CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | CORS 白名單(逗號分隔) | | `VISIONA_LOG_LEVEL` | `info` | debug / info / warn / error | +| `VISIONA_DB_HOST` / `_USER` / `_NAME` | (空) | PostgreSQL 連線;三者全非空才啟用(否則維持 in-memory) | +| `VISIONA_DB_AUTO_MIGRATE` | `true` | 啟動時自動跑 migrate up | + +--- + +## 資料庫(DB 接入塊 0) + +> 對齊 [`docs/autoflow/04-architecture/database.md`](../docs/autoflow/04-architecture/database.md) §5、§5.5。 + +塊 0 = **DB 基礎建設**:PostgreSQL 連線池(pgxpool)+ migration runner(golang-migrate, +嵌入式 SQL)+ testcontainers 整合測試骨架。 + +**啟用模式**:`VISIONA_DB_HOST` / `VISIONA_DB_USER` / `VISIONA_DB_NAME` 三者全非空 → 建池 + +自動 migrate;任一缺 → **不建池,所有 repository 維持 in-memory**(local dev fallback,雛形行為不變)。 + +> ⚠️ **塊 0 範圍**:即使 DB 啟用,目前 model / device / token repository **仍是 in-memory**。 +> 建池只為驗證基礎建設可用、並讓 schema 先就位。把 repository 切到 Postgres 是塊 1–3。 + +```bash +# 設好 VISIONA_DB_* 後,手動跑 migration(或交給 api-server 啟動時 auto-migrate) +make migrate-up +make migrate-version # 顯示目前 schema 版本 + +# DB 整合測試(testcontainers,需本機 / CI 有 Docker daemon) +make test-db # = go test -tags=dbtest ./internal/db/... +``` + +migration 檔在 [`migrations/`](migrations/)(`NNNN_description.up.sql` / `.down.sql`), +透過 `migrations/embed.go` 的 `//go:embed` 嵌入 binary,部署不需另複製。 +第一份 `0001_create_users_models` 建 `users` + `models`(對齊 database.md §5.1)。 --- ## 測試 ```bash -# 所有單元測試 + integration test + race detector +# 所有單元測試 + integration test + race detector(預設 tags,不需 Docker) make test-race +# DB 整合測試(testcontainers,需 Docker daemon) +make test-db + # 僅 go vet / gofmt check make lint @@ -298,10 +335,12 @@ make test ``` 覆蓋面: -- 單元測試:`internal/{auth,session,device,model,config,storage,api,relay,wsconn,logger,converter}` +- 單元測試:`internal/{auth,session,device,model,config,storage,api,relay,wsconn,logger,converter,db}` + - `internal/db`:DSN 組裝 / SafeTarget 遮蔽 / Config.Enabled(純函式,預設 tags 即跑) - Integration: - `cmd/api-server/integration_test.go`(B4:api-server → remote-proxy → fake tunnel) - `cmd/api-server/b5_integration_test.go`(B5:完整端到端 — login / scan / model upload / tunnel disconnect) + - `internal/db/db_integration_test.go`(DB 接入塊 0:pool ping / migrate 冪等 / up-down-up / schema 形狀 / fail-fast;`-tags=dbtest`,需 Docker) --- diff --git a/visionA-backend/cmd/api-server/main.go b/visionA-backend/cmd/api-server/main.go index 6f5380c..e8ab085 100644 --- a/visionA-backend/cmd/api-server/main.go +++ b/visionA-backend/cmd/api-server/main.go @@ -23,12 +23,14 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/api" "visiona-backend/internal/auth" "visiona-backend/internal/config" "visiona-backend/internal/conversion" "visiona-backend/internal/converter" + "visiona-backend/internal/db" "visiona-backend/internal/device" "visiona-backend/internal/fileaccess" "visiona-backend/internal/logger" @@ -80,9 +82,88 @@ func main() { "root", cfg.Storage.RootDir, "base_url", cfg.Storage.BaseURL) + // ===== PostgreSQL 連線池(DB 接入塊 0:DB 基礎建設) ===== + // 對齊 docs/autoflow/04-architecture/database.md §5、§5.5.1。 + // + // 啟用條件:cfg.Database.Enabled()(Host/User/DBName 全非空)。 + // 未啟用 → dbPool 為 nil,所有 repository 維持 in-memory(local dev fallback,雛形行為不變)。 + // + // ⚠️ 塊 0 範圍:只建池 + 跑 migration,**尚未**把 model/device/token repository 切到 + // Postgres(那是塊 1–3)。因此即使 DB 啟用,目前 repository 仍是 in-memory; + // 建池只為驗證基礎建設可用、並讓 schema 先就位。 + var dbPool *db.Pool + if cfg.Database.Enabled() { + pool, dbErr := db.NewPool(context.Background(), cfg.Database, log) + if dbErr != nil { + // fail-fast:DB 設定了卻連不上應停機,而非靜默 fallback in-memory 造成資料不一致。 + log.Error("failed to init postgres pool", "error", dbErr, "target", db.SafeTarget(cfg.Database)) + os.Exit(1) + } + dbPool = pool + defer dbPool.Close() + + if cfg.Database.AutoMigrate { + if mErr := db.RunMigrations(cfg.Database, log); mErr != nil { + log.Error("failed to run migrations", "error", mErr, "target", db.SafeTarget(cfg.Database)) + os.Exit(1) + } + log.Info("migrations applied", "target", db.SafeTarget(cfg.Database)) + } else { + log.Info("auto-migrate disabled (set VISIONA_DB_AUTO_MIGRATE=true or run cmd/migrate)") + } + } else { + log.Info("postgres disabled (set VISIONA_DB_HOST + VISIONA_DB_USER + VISIONA_DB_NAME to enable); repositories use in-memory") + } + + // ===== Redis 連線(DB 接入塊 4:僅 userSession browser cookie session) ===== + // 對齊 docs/autoflow/04-architecture/database.md §2.7、§5.5.2。 + // + // 啟用條件:cfg.Redis.Enabled()(Host 非空;無密碼也算啟用,visionA 專用 Redis stage 內網可不設密碼)。 + // 未啟用 → redisClient 為 nil,userSession 維持 in-memory + cleanup goroutine(雛形行為不變)。 + // + // ⚠️ 範圍:只接 internal/usersession(cookie session)。tunnel session(internal/session) + // value 是活的 yamux Handle 不可序列化,維持 in-memory(database.md §2.7 已定)。 + var redisClient *db.RedisClient + if cfg.Redis.Enabled() { + rc, rErr := db.NewRedisClient(context.Background(), cfg.Redis, log) + if rErr != nil { + // fail-fast:Redis 設定了卻連不上應停機,與 Postgres 同樣不靜默 fallback。 + log.Error("failed to init redis client", "error", rErr, "target", db.SafeRedisTarget(cfg.Redis)) + os.Exit(1) + } + redisClient = rc + defer redisClient.Close() + } else { + log.Info("redis disabled (set VISIONA_REDIS_HOST to enable); user session uses in-memory + cleanup goroutine") + } + // ===== Pairing / Session Token(OIDC 之外的雛形 token store) ===== - pairingStore := auth.NewInMemoryPairingStore() - sessionTokenStore := auth.NewInMemorySessionTokenStore() + // DB 接入塊 3 — dbPool != nil 時切到 Postgres(分表:pairing_tokens + session_tokens); + // 否則維持 in-memory(local dev fallback,雛形行為不變)。 + // Store interface 不變,handler / 呼叫端(internal/api/pairing.go)一行都不需改。 + // 關鍵:Postgres 版以 token_hash(HashToken(plaintext))當 PK,DB 不存明文; + // 呼叫端統一傳 plaintext,store 內部統一 hash(見 postgres_pairing_store.go 註解)。 + var pairingStore auth.PairingStore + var sessionTokenStore auth.SessionTokenStore + // 保留 concrete 型別參照供塊 5.2 cascade unpair 協調者使用 + // (RevokeByDeviceTx / RevokeByDevice 不在 Store interface 上,需 concrete type)。 + var pgPairingStore *auth.PostgresPairingStore + var pgSessionTokenStore *auth.PostgresSessionTokenStore + var memPairingStore *auth.InMemoryPairingStore + var memSessionTokenStore *auth.InMemorySessionTokenStore + if dbPool != nil { + pgPairingStore = auth.NewPostgresPairingStore(dbPool.Pool()) + pgSessionTokenStore = auth.NewPostgresSessionTokenStore(dbPool.Pool()) + pairingStore = pgPairingStore + sessionTokenStore = pgSessionTokenStore + log.Info("pairing/session token stores initialized", "backend", "postgres") + } else { + memPairingStore = auth.NewInMemoryPairingStore() + memSessionTokenStore = auth.NewInMemorySessionTokenStore() + pairingStore = memPairingStore + sessionTokenStore = memSessionTokenStore + log.Info("pairing/session token stores initialized", "backend", "in-memory") + } // ===== OIDC + User Session(OB5:唯一認證路徑) ===== // cfg.Validate() 已確保所有必填欄位存在,這裡可以放心 wire。 @@ -102,7 +183,18 @@ func main() { os.Exit(1) } - userSessionStore := usersession.NewInMemoryStore() + // userSession store:DB 接入塊 4 — redisClient != nil 時切到 RedisUserSessionStore + // (雙 TTL 取代手動 cleanup goroutine);否則維持 in-memory + cleanup goroutine。 + // Store interface 不變,Manager / handler 一行都不需改。 + var userSessionStore usersession.Store + if redisClient != nil { + userSessionStore = usersession.NewRedisUserSessionStore( + redisClient.Client(), cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL) + log.Info("user session store initialized", "backend", "redis") + } else { + userSessionStore = usersession.NewInMemoryStore() + log.Info("user session store initialized", "backend", "in-memory") + } userSessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{ Name: cfg.UserSession.CookieName, Domain: cfg.UserSession.CookieDomain, @@ -131,9 +223,47 @@ func main() { "backend", "proxy-client", "proxy_internal_url", cfg.Session.ProxyInternalURL) - // ===== Repositories(in-memory,雛形) ===== - deviceRepo := device.NewInMemoryRepository() - modelRepo := model.NewInMemoryRepository() + // ===== Repositories ===== + // device:DB 接入塊 2 — dbPool != nil 時切到 PostgresRepository;否則維持 in-memory + // (local dev fallback,雛形行為不變)。Repository interface 不變,handler 一行都不需改。 + var deviceRepo device.Repository + var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascade:DeleteTx 需 concrete type + if dbPool != nil { + pgDeviceRepo = device.NewPostgresRepository(dbPool.Pool()) + deviceRepo = pgDeviceRepo + log.Info("device repository initialized", "backend", "postgres") + } else { + deviceRepo = device.NewInMemoryRepository() + log.Info("device repository initialized", "backend", "in-memory") + } + + // ===== Device Unpair cascade 協調者(DB 接入塊 5.2,database.md §6) ===== + // 刪 device → 同時撤銷該 device 的 pairing + session token。 + // - Postgres:用 db.WithTx 把三步包成單一交易(device 軟刪 + 兩張 token 表撤銷),整筆原子。 + // - in-memory:依序執行(無交易),行為一致(刪 device 後 token 也撤)。 + // 注入 Deps.DeviceUnpairer;unpair handler 偵測非 nil 即走 cascade。 + var deviceUnpairer api.DeviceUnpairer + if dbPool != nil { + deviceUnpairer = api.NewPostgresDeviceUnpairer( + dbPool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, log) + log.Info("device unpairer initialized", "backend", "postgres-tx") + } else { + deviceUnpairer = api.NewInMemoryDeviceUnpairer( + deviceRepo, memPairingStore, memSessionTokenStore) + log.Info("device unpairer initialized", "backend", "in-memory") + } + + // model:DB 接入塊 1 — cfg.Database.Enabled() 且建池成功時切到 PostgresRepository; + // 否則維持 in-memory(local dev fallback,雛形行為不變,既有非-dbtest 測試不受影響)。 + // Repository interface 不變,所有呼叫端(handler / conversion adapter)一行都不需改。 + var modelRepo model.Repository + if dbPool != nil { + modelRepo = model.NewPostgresRepository(dbPool.Pool()) + log.Info("model repository initialized", "backend", "postgres") + } else { + modelRepo = model.NewInMemoryRepository() + log.Info("model repository initialized", "backend", "in-memory") + } // ===== Converter(stub,Phase 2 才實作) ===== converterClient := converter.NewStubClient() @@ -226,11 +356,29 @@ func main() { // ===== Seed demo data(可選) ===== if cfg.Server.SeedDemoData { - if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil { + // dbPool 非 nil 時,seed 的 model 走 Postgres(塊 1):seedDemoData 內部會先 ensure + // demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。 + var seedPool *pgxpool.Pool + if dbPool != nil { + seedPool = dbPool.Pool() + } + if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil { log.Warn("seed demo data failed", "error", err) } } + // ===== /healthz 依賴 ping(塊 5.4) ===== + // 只在依賴非 nil 時注入;注意不能把 typed-nil(*db.Pool(nil))塞進 interface, + // 否則 interface != nil 但呼叫 Ping 會對 nil pool panic。故顯式分支保證 nil pool 不注入。 + var healthDBPool api.HealthPinger + if dbPool != nil { + healthDBPool = dbPool + } + var healthRedis api.HealthPinger + if redisClient != nil { + healthRedis = redisClient + } + // ===== API Router ===== gin.SetMode(gin.ReleaseMode) // Phase 0.7 security fix C1:StaticUserID 不再注入 Deps(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md) @@ -243,6 +391,7 @@ func main() { Forwarder: forwarder, DeviceRepo: deviceRepo, ModelRepo: modelRepo, + DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序) Storage: storageStore, Converter: converterClient, Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501) @@ -252,6 +401,10 @@ func main() { CORSAllowedOrigins: cfg.CORS.AllowedOrigins, RelayPublicURL: cfg.Server.RelayPublicURL, + // 塊 5.4:/healthz 依賴 ping(nil = 未啟用、略過) + HealthDBPool: healthDBPool, + HealthRedis: healthRedis, + // OIDC(OB5:唯一認證路徑) OIDCProvider: oidcProvider, SessionManager: userSessionMgr, @@ -266,9 +419,14 @@ func main() { } // ===== User session cleanup goroutine ===== + // DB 接入塊 4:Redis 模式靠 key TTL 自動過期,不需 background 掃描,故只在 in-memory 模式啟動。 cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) defer cleanupCancel() - go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log) + if redisClient == nil { + go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log) + } else { + log.Info("user session cleanup goroutine skipped (redis TTL handles expiry)") + } // ===== 啟動 server ===== errCh := make(chan error, 1) diff --git a/visionA-backend/cmd/api-server/seed.go b/visionA-backend/cmd/api-server/seed.go index 4895122..376dbae 100644 --- a/visionA-backend/cmd/api-server/seed.go +++ b/visionA-backend/cmd/api-server/seed.go @@ -6,12 +6,20 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" "visiona-backend/internal/auth" "visiona-backend/internal/device" "visiona-backend/internal/model" ) +// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。 +// +// 雛形的 StaticUserID("demo-user")不是合法 UUID,無法當 models.owner_user_id(UUID + FK)。 +// DB-backed seed 改用此固定 UUID,並先 upsert 一筆對應 users 列以滿足 FK; +// in-memory seed 不受影響(仍用 cfg.Auth.StaticUserID)。 +const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3" + // seedDemoData 在啟動時塞入示範資料,方便本機開發 / demo 不必跑完整 pairing。 // // 觸發條件:VISIONA_SEED_DEMO_DATA=true @@ -25,11 +33,16 @@ import ( // - 失敗只 log warning,不阻擋啟動 // - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證) // - **不要**在生產環境啟用此 flag +// +// dbPool 非 nil 表 model repo 已切到 Postgres(塊 1):此時 seed 的 model 必須用合法 UUID +// 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。 +// dbPool 為 nil(in-memory fallback)時行為與雛形完全相同。 func seedDemoData( devRepo device.Repository, mdlRepo model.Repository, pairings auth.PairingStore, userID string, + dbPool *pgxpool.Pool, log *slog.Logger, ) error { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -37,10 +50,36 @@ func seedDemoData( now := time.Now().UTC() - // 1. Demo device + // owner / id 視 backend 決定:DB-backed 需合法 UUID + 存在的 user 列。 + // 塊 2 起 device 也落 DB,故 model 與 device 的 demo owner 必須一致(同一 demoSeedUserID), + // 否則 owner 對不上(device.owner_user_id 也是 UUID + FK)。 + modelOwnerID := userID + deviceOwnerID := userID + // pairing token 的 owner(user_id):塊 3 起 pairing token 也落 DB,user_id 是 + // REFERENCES users(id) 的 FK + UUID,故 DB-backed 時必須與 model/device 一致用 + // demoSeedUserID(已 upsert 的合法 user),不可用非-UUID 的 StaticUserID。 + pairingOwnerID := userID + modelID := "demo-model-" + uuid.NewString()[:8] + deviceID := "demo-device-" + uuid.NewString()[:8] + if dbPool != nil { + modelOwnerID = demoSeedUserID + deviceOwnerID = demoSeedUserID + pairingOwnerID = demoSeedUserID + modelID = uuid.NewString() + deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串 + // 先確保 owner user 存在(滿足 models / devices owner_user_id FK)。 + if _, err := dbPool.Exec(ctx, + `INSERT INTO users (id, email, name) VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING`, + demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil { + log.Warn("seed: ensure demo user failed", "error", err) + } + } + + // 1. Demo device(塊 2 起 DB-backed 時走 Postgres;owner 對齊 demoSeedUserID) dev := &device.Device{ - ID: "demo-device-" + uuid.NewString()[:8], - OwnerUserID: userID, + ID: deviceID, + OwnerUserID: deviceOwnerID, Name: "Demo KL520 (seeded)", DeviceType: "kl520", SerialNumber: "DEMO-SN-001", @@ -57,13 +96,13 @@ func seedDemoData( // 2. Demo model mdl := &model.Model{ - ID: "demo-model-" + uuid.NewString()[:8], - OwnerUserID: userID, + ID: modelID, + OwnerUserID: modelOwnerID, Name: "YOLOv5 Face (seeded)", TargetChip: "kl520", FileSize: 1024 * 1024, // 1 MB Source: model.SourceUploaded, - StorageKey: "models/" + userID + "/demo.nef", + StorageKey: "models/" + modelOwnerID + "/demo.nef", CreatedAt: now, UpdatedAt: now, UploadedAt: &now, @@ -75,7 +114,8 @@ func seedDemoData( } // 3. Demo pairing token(log plaintext 方便開發 — 雛形 demo 用,生產禁用) - pt, _, err := pairings.Create(ctx, userID, 24*time.Hour) + // owner 用 pairingOwnerID:DB-backed 時為 demoSeedUserID(滿足 user_id FK)。 + pt, _, err := pairings.Create(ctx, pairingOwnerID, 24*time.Hour) if err != nil { log.Warn("seed: pairing token create failed", "error", err) } else { diff --git a/visionA-backend/cmd/migrate/main.go b/visionA-backend/cmd/migrate/main.go new file mode 100644 index 0000000..9eb916a --- /dev/null +++ b/visionA-backend/cmd/migrate/main.go @@ -0,0 +1,76 @@ +// Command migrate 是 visionA-backend 的獨立 migration 工具。 +// +// DB 接入塊 0:提供「不啟動整個 api-server 也能跑 migration」的入口, +// 供 CI / 部署流程 / 手動操作使用。api-server 啟動時的 auto-migrate(VISIONA_DB_AUTO_MIGRATE) +// 與本工具共用同一份嵌入式 migration(migrations.FS)+ internal/db.Migrator,行為一致。 +// +// 用法: +// +// go run ./cmd/migrate up # 套用所有未執行的 migration(預設) +// go run ./cmd/migrate down # 回退一個版本 +// go run ./cmd/migrate version # 顯示目前 schema 版本 +// +// 連線資訊一律走 VISIONA_DB_* 環境變數(與 api-server 相同),不接受密碼當 CLI 參數 +// (避免密碼出現在 shell history / process list)。 +package main + +import ( + "fmt" + "os" + + "visiona-backend/internal/config" + "visiona-backend/internal/db" + "visiona-backend/internal/logger" +) + +func main() { + cfg := config.Load() + log := logger.New(cfg.Logger.Level).With("service", "migrate") + + if !cfg.Database.Enabled() { + log.Error("database not configured", + "hint", "set VISIONA_DB_HOST + VISIONA_DB_USER + VISIONA_DB_NAME") + os.Exit(1) + } + + cmd := "up" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + + mg, err := db.NewMigrator(cfg.Database, log) + if err != nil { + log.Error("failed to init migrator", "error", err, "target", db.SafeTarget(cfg.Database)) + os.Exit(1) + } + defer func() { + if cerr := mg.Close(); cerr != nil { + log.Warn("migrator close error", "error", cerr) + } + }() + + switch cmd { + case "up": + if err := mg.Up(); err != nil { + log.Error("migrate up failed", "error", err) + os.Exit(1) + } + log.Info("migrate up complete", "target", db.SafeTarget(cfg.Database)) + case "down": + if err := mg.Down(); err != nil { + log.Error("migrate down failed", "error", err) + os.Exit(1) + } + log.Info("migrate down (one step) complete", "target", db.SafeTarget(cfg.Database)) + case "version": + ver, dirty, err := mg.Version() + if err != nil { + log.Error("read version failed", "error", err) + os.Exit(1) + } + log.Info("schema version", "version", ver, "dirty", dirty) + default: + fmt.Fprintf(os.Stderr, "unknown command %q; use one of: up | down | version\n", cmd) + os.Exit(2) + } +} diff --git a/visionA-backend/go.mod b/visionA-backend/go.mod index 7ca0379..78a20fb 100644 --- a/visionA-backend/go.mod +++ b/visionA-backend/go.mod @@ -11,48 +11,105 @@ go 1.26 // - github.com/aws/aws-sdk-go-v2 (可選,S3 儲存層) require ( + github.com/alicebob/miniredis/v2 v2.33.0 github.com/coreos/go-oidc/v3 v3.18.0 github.com/gin-contrib/cors v1.7.7 github.com/gin-gonic/gin v1.12.0 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/yamux v0.1.2 + github.com/jackc/pgx/v5 v5.10.0 + github.com/redis/go-redis/v9 v9.7.0 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.43.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 golang.org/x/oauth2 v0.36.0 ) require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.10.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.2.0 // indirect + github.com/moby/moby/api v1.54.2 // indirect + github.com/moby/moby/client v0.4.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect + github.com/shirou/gopsutil/v4 v4.26.5 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/tklauser/numcpus v0.11.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/visionA-backend/go.sum b/visionA-backend/go.sum index f8ae8c3..e7c9bd6 100644 --- a/visionA-backend/go.sum +++ b/visionA-backend/go.sum @@ -1,16 +1,65 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= +github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q= @@ -21,6 +70,13 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -33,6 +89,11 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -42,8 +103,20 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -52,55 +125,135 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg= +github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw= +github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shirou/gopsutil/v4 v4.26.5 h1:RPcBXkpz7kOj9PqGFQOlBPZHsyaPvPVQc098y9RmCNM= +github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.43.0 h1:oEQx5MW2DGd9z3AeEQfB2lPM0eLs7ztyaGRu75bFo5A= +github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0 h1:ShNOFYAF4lKHvdIG258hi69bSxC88uXnxJkJvNs/IVs= +github.com/testcontainers/testcontainers-go/modules/postgres v0.43.0/go.mod h1:vdq5/RqmGfWeefzyfcVI/pID1rzmc1TDvqXa15bPJks= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= +go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= +go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= +go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -109,3 +262,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 635160f..6f7ce51 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -68,6 +68,12 @@ type Deps struct { DeviceRepo device.Repository ModelRepo model.Repository + // DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子 + // (Postgres tx)或一致(in-memory 依序)操作(DB 接入塊 5.2,database.md §6)。 + // 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為 + //(見 devices.go),確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。 + DeviceUnpairer DeviceUnpairer + Storage storage.Store Converter converter.Client @@ -112,6 +118,13 @@ type Deps struct { // 由 `POST /api/pairing/exchange` 回給 agent;若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder)。 // 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。 RelayPublicURL string + + // HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。 + // 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、 + // /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。 + // 任一非 nil 依賴 ping 失敗 → /healthz 回 503,讓 load balancer 拉出此實例。 + HealthDBPool HealthPinger + HealthRedis HealthPinger } // validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。 @@ -160,8 +173,13 @@ func NewRouter(deps Deps) *gin.Engine { r.Use(CORSMiddleware(deps.CORSAllowedOrigins)) r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON - // /healthz 不需要 auth — K8s liveness/readiness 用 - r.GET("/healthz", HealthzHandler()) + // /healthz 不需要 auth — K8s liveness/readiness 用。 + // 塊 5.4:啟用的依賴(Postgres / Redis)都 ping,任一失敗回 503(fail-fast)。 + r.GET("/healthz", HealthzHandler(HealthDeps{ + DBPool: deps.HealthDBPool, + Redis: deps.HealthRedis, + Logger: deps.Logger, + })) // /storage/* 不走 AuthMiddleware(改用 HMAC 簽章)— 對齊 api-spec.md §10 registerStorageRoutes(r, deps) diff --git a/visionA-backend/internal/api/devices.go b/visionA-backend/internal/api/devices.go index 83d927f..3ab4e11 100644 --- a/visionA-backend/internal/api/devices.go +++ b/visionA-backend/internal/api/devices.go @@ -92,8 +92,8 @@ func devicesListHandler(deps Deps) gin.HandlerFunc { devices, err := deps.DeviceRepo.List(ctx, userID) if err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "list devices failed: "+err.Error(), nil) + // DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "list devices", err) return } @@ -161,8 +161,8 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get device failed: "+err.Error(), nil) + // DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get device", err) return } @@ -234,8 +234,8 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get device failed: "+err.Error(), nil) + // DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get device", err) return } if d.OwnerUserID != userID { @@ -243,11 +243,30 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc { return } - // 軟刪 - if err := deps.DeviceRepo.Delete(ctx, id); err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "delete device failed: "+err.Error(), nil) - return + // 軟刪 + cascade 撤銷該 device 的 pairing/session token(塊 5.2,database.md §6)。 + // - DeviceUnpairer 非 nil(main.go 注入 Postgres tx 版 / in-memory 依序版)→ 走 cascade。 + // - 為 nil(最小骨架)→ fallback 只軟刪 device,不 cascade(舊行為)。 + var unpairResult UnpairResult + if deps.DeviceUnpairer != nil { + res, uErr := deps.DeviceUnpairer.Unpair(ctx, id) + if uErr != nil { + if errors.Is(uErr, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return + } + WriteDBError(c, deps.Logger, "unpair device", uErr) + return + } + unpairResult = res + } else { + if err := deps.DeviceRepo.Delete(ctx, id); err != nil { + if errors.Is(err, device.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil) + return + } + WriteDBError(c, deps.Logger, "delete device", err) + return + } } // best-effort:關閉該 user 的 session(雛形單裝置假設) @@ -260,6 +279,8 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc { logOrDefault(deps.Logger).Info("devices: unpaired", "device_id", id, "user_id", userID, + "pairing_tokens_revoked", unpairResult.PairingRevoked, + "session_tokens_revoked", unpairResult.SessionRevoked, "request_id", RequestIDFrom(c)) WriteSuccess(c, http.StatusOK, gin.H{"id": id, "unpaired": true}) diff --git a/visionA-backend/internal/api/errors.go b/visionA-backend/internal/api/errors.go index b15be2d..514873c 100644 --- a/visionA-backend/internal/api/errors.go +++ b/visionA-backend/internal/api/errors.go @@ -19,6 +19,12 @@ const ( ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE" // ErrCodeInvalidSignature 用於 /storage/* 驗簽失敗 / URL 過期。 ErrCodeInvalidSignature = "INVALID_SIGNATURE" + // ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。 + ErrCodeConflict = "CONFLICT" + // ErrCodeServiceUnavailable 對齊 HTTP 503。 + // DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道 + // 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。 + ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE" ) // ErrorBody 是 API 錯誤回應的 envelope 結構。 diff --git a/visionA-backend/internal/api/errors_db.go b/visionA-backend/internal/api/errors_db.go new file mode 100644 index 0000000..a42adbd --- /dev/null +++ b/visionA-backend/internal/api/errors_db.go @@ -0,0 +1,101 @@ +// errors_db.go — 統一把 DB(pgx)錯誤映射成對外 API 錯誤(DB 接入塊 5.4)。 +// +// 兩個目的: +// 1. **fail-fast 落地**:PG 連線失敗 / context 逾時 → 503 SERVICE_UNAVAILABLE(不回假資料、 +// 不誤報 500),讓 load balancer 把這台拉出。對齊使用者拍板的「PG 都 fail-fast、不自動降級」。 +// 2. **收掉塊 3 M1 技術債**:handler 過去把 raw DB error 字串串進 response +// (`"... failed: "+err.Error()`)會洩漏 schema / SQL / 連線細節。本檔統一映射, +// 對外只給穩定的 error code + 通用 message,raw error 只進 server log(含 request_id)。 +// +// 映射規則(依優先序): +// +// | 來源 | HTTP | code | +// |----------------------------------------|------|-----------------------| +// | context 逾時 / 取消(DeadlineExceeded / Canceled)| 503 | SERVICE_UNAVAILABLE | +// | 連線層失敗(pgconn 無 SQLSTATE:refused/reset/EOF)| 503 | SERVICE_UNAVAILABLE | +// | unique violation(SQLSTATE 23505) | 409 | CONFLICT | +// | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR | +// | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR | +// +// ⚠️ not found 不在此映射:domain 層(device.ErrNotFound / auth.ErrInvalidToken 等)已把 +// pgx.ErrNoRows 轉成自己的 sentinel,handler 應先用 errors.Is 比對那些 sentinel 回 404, +// **再**把剩下的「真 DB 錯誤」交給 WriteDBError。這樣「正常的 not found」絕不會被誤判成 503。 +package api + +import ( + "context" + "errors" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgconn" +) + +// dbErrorClass 是 DB 錯誤分類結果。 +type dbErrorClass struct { + status int + code string + message string // 對外通用訊息(不含 raw DB 細節) +} + +// classifyDBError 把一個 error 分類為對外的 (HTTP status, code, message)。 +// +// 不洩漏 raw DB error:回傳的 message 是固定通用字串,raw error 由 WriteDBError 寫進 server log。 +func classifyDBError(err error) dbErrorClass { + // 1) context 逾時 / 取消 → 503(依賴慢/不可用,fail-fast)。 + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable, + "service temporarily unavailable"} + } + + // 2) pgconn.PgError:PG server 端回的錯誤(有 SQLSTATE)。 + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + switch pgErr.Code { + case "23505": // unique_violation + return dbErrorClass{http.StatusConflict, ErrCodeConflict, "resource already exists"} + default: + // 其餘 PG 錯誤(語法、約束、權限…)對外一律 500,不洩漏 SQLSTATE / 欄位名。 + return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, + "internal error"} + } + } + + // 3) pgconn.ConnectError 等「連不上 / 連線中斷」(無 SQLSTATE)→ 503。 + // pgxpool 連線層失敗(connection refused / reset / EOF)多半包成 *pgconn.ConnectError, + // 或為底層 net error。用 errors.As 抓 ConnectError;抓不到再保守視為連線問題前先看下一步。 + var connErr *pgconn.ConnectError + if errors.As(err, &connErr) { + return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable, + "service temporarily unavailable"} + } + + // 4) 其餘未知錯誤 → 500(保守,不假設是 DB down 以免把程式 bug 也報成 503)。 + return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, "internal error"} +} + +// WriteDBError 把一個 DB 操作錯誤映射成對外 API 錯誤並寫回 response,同時把 raw error 進 log。 +// +// op 是操作描述(如 "get device" / "list models"),只進 log、不對外。 +// 對外只給 classifyDBError 算出的穩定 code + 通用 message(不洩漏 raw DB error)。 +// +// 用法(handler 內,已先處理過 domain sentinel 如 ErrNotFound 後): +// +// if err != nil { +// WriteDBError(c, deps.Logger, "list devices", err) +// return +// } +func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) { + cls := classifyDBError(err) + + // raw error 只進 server log(附 request_id + 對外 code,方便對照),不進 response。 + logOrDefault(log).Error("db operation failed", + "op", op, + "error", err, + "http_status", cls.status, + "code", cls.code, + "request_id", RequestIDFrom(c)) + + WriteError(c, cls.status, cls.code, cls.message, nil) +} diff --git a/visionA-backend/internal/api/errors_db_test.go b/visionA-backend/internal/api/errors_db_test.go new file mode 100644 index 0000000..258f99a --- /dev/null +++ b/visionA-backend/internal/api/errors_db_test.go @@ -0,0 +1,119 @@ +package api + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestClassifyDBError 驗證 pgx error → (status, code) 映射(塊 5.4)。 +func TestClassifyDBError(t *testing.T) { + cases := []struct { + name string + err error + wantStatus int + wantCode string + }{ + { + name: "context deadline → 503", + err: context.DeadlineExceeded, + wantStatus: http.StatusServiceUnavailable, + wantCode: ErrCodeServiceUnavailable, + }, + { + name: "context canceled → 503", + err: context.Canceled, + wantStatus: http.StatusServiceUnavailable, + wantCode: ErrCodeServiceUnavailable, + }, + { + name: "wrapped deadline → 503", + err: fmt.Errorf("device: pg List: %w", context.DeadlineExceeded), + wantStatus: http.StatusServiceUnavailable, + wantCode: ErrCodeServiceUnavailable, + }, + { + name: "unique violation 23505 → 409", + err: &pgconn.PgError{Code: "23505", Message: "duplicate key value"}, + wantStatus: http.StatusConflict, + wantCode: ErrCodeConflict, + }, + { + name: "wrapped unique violation → 409", + err: fmt.Errorf("device: pg Save: %w", &pgconn.PgError{Code: "23505"}), + wantStatus: http.StatusConflict, + wantCode: ErrCodeConflict, + }, + { + name: "other PG error (syntax) → 500", + err: &pgconn.PgError{Code: "42601", Message: "syntax error"}, + wantStatus: http.StatusInternalServerError, + wantCode: ErrCodeInternalError, + }, + { + name: "connect error → 503", + err: &pgconn.ConnectError{Config: &pgconn.Config{}}, + wantStatus: http.StatusServiceUnavailable, + wantCode: ErrCodeServiceUnavailable, + }, + { + name: "unknown error → 500", + err: errors.New("something unexpected"), + wantStatus: http.StatusInternalServerError, + wantCode: ErrCodeInternalError, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cls := classifyDBError(tc.err) + assert.Equal(t, tc.wantStatus, cls.status) + assert.Equal(t, tc.wantCode, cls.code) + // message 一律是通用字串,不含 raw error 內容 + assert.NotEmpty(t, cls.message) + }) + } +} + +// TestWriteDBError_NoRawLeak 驗證 WriteDBError 不把 raw DB error 寫進 response body(收塊 3 M1)。 +func TestWriteDBError_NoRawLeak(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + + rawErr := &pgconn.PgError{Code: "42601", + Message: "syntax error at or near SELECT", + Detail: "secret-schema-detail", + Hint: "internal hint that should not leak", + } + WriteDBError(c, nil, "list devices", rawErr) + + require.Equal(t, http.StatusInternalServerError, w.Code) + body := w.Body.String() + // 對外只有通用 code/message,raw error 的細節不得出現 + assert.Contains(t, body, ErrCodeInternalError) + assert.NotContains(t, body, "syntax error") + assert.NotContains(t, body, "secret-schema-detail") + assert.NotContains(t, body, "internal hint") +} + +// TestWriteDBError_PGDown503 驗證 PG down(connect error)→ 503 + SERVICE_UNAVAILABLE。 +func TestWriteDBError_PGDown503(t *testing.T) { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodGet, "/", nil) + + WriteDBError(c, nil, "get model", &pgconn.ConnectError{Config: &pgconn.Config{}}) + + require.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), ErrCodeServiceUnavailable) +} diff --git a/visionA-backend/internal/api/health.go b/visionA-backend/internal/api/health.go index 17d1d92..c6db614 100644 --- a/visionA-backend/internal/api/health.go +++ b/visionA-backend/internal/api/health.go @@ -9,13 +9,74 @@ import ( "github.com/gin-gonic/gin" ) -// HealthzHandler 是 K8s liveness / readiness 用的最小健康檢查。 +// HealthPinger 抽象「能對某依賴跑一次連線檢查」的能力。 // -// 不檢查任何依賴(remote-proxy、DB),只代表 process 還活著。 -// readiness 想檢查依賴的話應該用 /api/system/health。 -func HealthzHandler() gin.HandlerFunc { +// db.Pool(Postgres)與 db.RedisClient(Redis)都有 Ping(ctx) error,天然滿足。 +// 用窄介面而非綁 concrete type:方便 unit test 注入 fake ping(成功 / 失敗)。 +type HealthPinger interface { + Ping(ctx context.Context) error +} + +// HealthDeps 是 /healthz 要檢查的依賴(DB 接入塊 5.4)。 +// +// 啟用語意(對齊使用者拍板的 fail-fast 策略): +// - DBPool 非 nil(Postgres 啟用)→ 每次 /healthz 都 ping,失敗回 503。 +// - Redis 非 nil(Redis 啟用)→ 每次 /healthz 都 ping,失敗回 503。 +// - 兩者皆 nil(in-memory 模式 / 未啟用)→ 略過,維持「process 活著就 ok」的舊行為。 +// +// 為什麼 ping 失敗回 503:讓 load balancer / K8s readiness 把這台不健康的實例拉出輪替, +// 而非繼續送流量進來碰 DB 拿 503/假資料。 +type HealthDeps struct { + DBPool HealthPinger // Postgres 連線池(db.Pool);nil = 未啟用 + Redis HealthPinger // Redis client(db.RedisClient);nil = 未啟用 + Logger *slog.Logger +} + +// healthPingTimeout 是單一依賴 ping 的逾時上限。 +// /healthz 須快速回應(load balancer 高頻打),故給短逾時——ping 卡住即視為不健康。 +const healthPingTimeout = 2 * time.Second + +// HealthzHandler 是 K8s liveness / readiness 用的健康檢查。 +// +// DB 接入塊 5.4:擴充為「啟用的依賴(Postgres / Redis)都 ping 一次」。任一啟用依賴 ping +// 失敗 → 503(body 標出哪個依賴不健康,但不洩漏 raw error,只進 log);全數通過 → 200。 +// +// 未啟用任何依賴(deps 全 nil)時退化為原本的「process 活著就 ok」最小檢查。 +func HealthzHandler(deps HealthDeps) gin.HandlerFunc { return func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + ctx, cancel := context.WithTimeout(c.Request.Context(), healthPingTimeout) + defer cancel() + + checks := gin.H{} + healthy := true + + if deps.DBPool != nil { + if err := deps.DBPool.Ping(ctx); err != nil { + healthy = false + checks["postgres"] = "down" + logOrDefault(deps.Logger).Error("healthz: postgres ping failed", + "error", err, "request_id", RequestIDFrom(c)) + } else { + checks["postgres"] = "ok" + } + } + + if deps.Redis != nil { + if err := deps.Redis.Ping(ctx); err != nil { + healthy = false + checks["redis"] = "down" + logOrDefault(deps.Logger).Error("healthz: redis ping failed", + "error", err, "request_id", RequestIDFrom(c)) + } else { + checks["redis"] = "ok" + } + } + + if !healthy { + c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable", "checks": checks}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "checks": checks}) } } diff --git a/visionA-backend/internal/api/health_test.go b/visionA-backend/internal/api/health_test.go index 344cd7d..a31fa52 100644 --- a/visionA-backend/internal/api/health_test.go +++ b/visionA-backend/internal/api/health_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -44,10 +45,15 @@ func (f *fakeSessionStore) CleanupExpired(context.Context, time.Duration) (int, panic("CleanupExpired should not be called") } -// TestHealthzHandler 驗證 /healthz 回 200 + status:ok。 +// fakePinger 是 /healthz 依賴 ping 的測試替身(純 Go,不需真 DB)。 +type fakePinger struct{ err error } + +func (f fakePinger) Ping(context.Context) error { return f.err } + +// TestHealthzHandler 驗證 /healthz 在「無依賴」時回 200 + status:ok(退化為最小檢查)。 func TestHealthzHandler(t *testing.T) { r := gin.New() - r.GET("/healthz", HealthzHandler()) + r.GET("/healthz", HealthzHandler(HealthDeps{})) w := httptest.NewRecorder() r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) @@ -55,6 +61,65 @@ func TestHealthzHandler(t *testing.T) { assert.Contains(t, w.Body.String(), `"status":"ok"`) } +// TestHealthz_AllDepsHealthy 驗證 PG + Redis 都 ping 成功 → 200 + checks 全 ok。 +func TestHealthz_AllDepsHealthy(t *testing.T) { + r := gin.New() + r.GET("/healthz", HealthzHandler(HealthDeps{ + DBPool: fakePinger{}, + Redis: fakePinger{}, + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Body.String(), `"postgres":"ok"`) + assert.Contains(t, w.Body.String(), `"redis":"ok"`) +} + +// TestHealthz_PostgresDown 驗證 PG ping 失敗 → 503(塊 5.4 fail-fast)。 +func TestHealthz_PostgresDown(t *testing.T) { + r := gin.New() + r.GET("/healthz", HealthzHandler(HealthDeps{ + DBPool: fakePinger{err: errors.New("connection refused")}, + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), `"postgres":"down"`) + // 不洩漏 raw error 進 response(只進 log) + assert.NotContains(t, w.Body.String(), "connection refused") +} + +// TestHealthz_RedisDown 驗證 Redis 啟用但 ping 失敗 → 503;PG ok 仍標出 redis down。 +func TestHealthz_RedisDown(t *testing.T) { + r := gin.New() + r.GET("/healthz", HealthzHandler(HealthDeps{ + DBPool: fakePinger{}, + Redis: fakePinger{err: errors.New("redis down")}, + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + assert.Contains(t, w.Body.String(), `"redis":"down"`) + assert.Contains(t, w.Body.String(), `"postgres":"ok"`) +} + +// TestHealthz_RedisNotEnabled 驗證 Redis 未啟用(nil)→ 不檢查、PG ok 即 200。 +func TestHealthz_RedisNotEnabled(t *testing.T) { + r := gin.New() + r.GET("/healthz", HealthzHandler(HealthDeps{ + DBPool: fakePinger{}, + // Redis nil — 未啟用,略過 + })) + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + assert.Equal(t, http.StatusOK, w.Code) + assert.NotContains(t, w.Body.String(), "redis") +} + // TestSystemHealth_TunnelDisconnected 驗證沒 session 時回 connected=false。 func TestSystemHealth_TunnelDisconnected(t *testing.T) { r := gin.New() diff --git a/visionA-backend/internal/api/models.go b/visionA-backend/internal/api/models.go index f7ea9f0..9e90cb6 100644 --- a/visionA-backend/internal/api/models.go +++ b/visionA-backend/internal/api/models.go @@ -106,8 +106,8 @@ func modelsListHandler(deps Deps) gin.HandlerFunc { models, err := deps.ModelRepo.List(ctx, model.ListFilter{OwnerUserID: userID}) if err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "list models failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "list models", err) return } out := make([]ModelResponse, 0, len(models)) @@ -148,8 +148,8 @@ func modelsGetHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { @@ -266,8 +266,8 @@ func modelsInitUploadHandler(deps Deps) gin.HandlerFunc { // UploadedAt 保持 nil 直到 finalize } if err := deps.ModelRepo.Save(ctx, m); err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "save model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "save model", err) return } @@ -326,8 +326,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { @@ -362,8 +362,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc { m.UploadedAt = &now m.UpdatedAt = now if err := deps.ModelRepo.Save(ctx, m); err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "save model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "save model", err) return } @@ -412,8 +412,8 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get model", err) return } if m.OwnerUserID != userID { @@ -422,8 +422,12 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc { } if err := deps.ModelRepo.Delete(ctx, id); err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "delete model failed: "+err.Error(), nil) + if errors.Is(err, model.ErrNotFound) { + WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) + return + } + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "delete model", err) return } @@ -497,8 +501,8 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "get model failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。 + WriteDBError(c, deps.Logger, "get model", err) return } // 第一階段 owner-only(B 分享後續階段);非 owner 回 403。 diff --git a/visionA-backend/internal/api/pairing.go b/visionA-backend/internal/api/pairing.go index 2226f5b..2d3e5d7 100644 --- a/visionA-backend/internal/api/pairing.go +++ b/visionA-backend/internal/api/pairing.go @@ -235,8 +235,8 @@ func pairingListTokensHandler(deps Deps) gin.HandlerFunc { tokens, err := deps.PairingStore.List(ctx, userID) if err != nil { - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "list pairing tokens failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。 + WriteDBError(c, deps.Logger, "list pairing tokens", err) return } out := make([]PairingTokenListItem, 0, len(tokens)) @@ -283,8 +283,8 @@ func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc { WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil) return } - WriteError(c, http.StatusInternalServerError, ErrCodeInternalError, - "revoke token failed: "+err.Error(), nil) + // 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。 + WriteDBError(c, deps.Logger, "revoke pairing token", err) return } diff --git a/visionA-backend/internal/api/unpair.go b/visionA-backend/internal/api/unpair.go new file mode 100644 index 0000000..26d7d7e --- /dev/null +++ b/visionA-backend/internal/api/unpair.go @@ -0,0 +1,157 @@ +// unpair.go — device unpair 的 cascade 撤銷協調者(DB 接入塊 5.2)。 +// +// 「刪 device → 同時撤銷該 device 名下所有 pairing + session token」是一個跨 store 的一致性 +// 操作(database.md §6)。為了讓 handler(devices.go 的 unpair)維持薄、同時支援 Postgres(真 +// 交易)與 in-memory(local-dev fallback)兩種後端,這裡抽出 DeviceUnpairer 介面: +// +// - Postgres 後端:pgDeviceUnpairer 用 db.WithTx 把「device 軟刪 + 兩張 token 表撤銷」包成 +// 單一交易——任一步失敗整筆 rollback,杜絕「device 已刪但 token 沒撤」的中間狀態。 +// - in-memory 後端:memDeviceUnpairer 依序呼叫(無交易),行為一致(刪 device 後 token 也撤), +// 對齊「DB 未啟用時 cascade 在 in-memory 也成立」的約束。 +// +// 兩者由 main.go 依 dbPool 是否非 nil 擇一注入 Deps.DeviceUnpairer。為 nil 時 unpair handler +// fallback 到「只軟刪 device(不 cascade)」的舊行為(見 devices.go),確保最小骨架仍可啟動。 +package api + +import ( + "context" + "fmt" + "log/slog" + + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/db" + "visiona-backend/internal/device" +) + +// UnpairResult 回報 cascade 撤銷的結果(觀測用:撤了幾個 token)。 +type UnpairResult struct { + PairingRevoked int // 本次撤銷的 pairing token 數 + SessionRevoked int // 本次撤銷的 session token 數 +} + +// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 token」包成一個原子(Postgres)或 +// 一致(in-memory)操作。 +// +// Unpair 語意:device 不存在 / 已刪除回 device.ErrNotFound(handler 轉 404);其餘 DB 錯誤 +// 原樣回傳(handler 經 errors.go 映射成 503 / 500,不洩漏 raw error)。 +type DeviceUnpairer interface { + Unpair(ctx context.Context, deviceID string) (UnpairResult, error) +} + +// ── Postgres 後端介面(避免 api 直接綁 concrete type,方便測試注入) ────────────── + +// pgDeviceDeleter 是 device 在 tx 內軟刪的能力(由 device.PostgresRepository 滿足)。 +type pgDeviceDeleter interface { + DeleteTx(ctx context.Context, q db.Querier, id string) error +} + +// pgTokenRevoker 是「在 tx 內撤銷某 device 名下未撤銷 token」的能力 +// (由 auth.PostgresPairingStore / auth.PostgresSessionTokenStore 滿足)。 +type pgTokenRevoker interface { + RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) +} + +// pgDeviceUnpairer 用單一 pgx 交易完成 device 軟刪 + 兩張 token 表 cascade 撤銷。 +type pgDeviceUnpairer struct { + pool *pgxpool.Pool + devices pgDeviceDeleter + pairingTok pgTokenRevoker + sessionTok pgTokenRevoker + log *slog.Logger +} + +// NewPostgresDeviceUnpairer 建立 Postgres 後端的 cascade unpair 協調者。 +// +// 三個依賴分別來自 device.PostgresRepository / auth.PostgresPairingStore / +// auth.PostgresSessionTokenStore(main.go 注入)。pool 用來開交易。 +func NewPostgresDeviceUnpairer( + pool *pgxpool.Pool, + devices pgDeviceDeleter, + pairingTok pgTokenRevoker, + sessionTok pgTokenRevoker, + log *slog.Logger, +) DeviceUnpairer { + return &pgDeviceUnpairer{ + pool: pool, + devices: devices, + pairingTok: pairingTok, + sessionTok: sessionTok, + log: logOrDefault(log), + } +} + +// Unpair 在單一交易內:軟刪 device → 撤 pairing token → 撤 session token。 +// +// 順序:device 軟刪先做——若 device 不存在 / 已刪除(ErrNotFound)就提早回,交易內什麼都沒改、 +// rollback 無副作用。device 軟刪成功後才撤兩張 token 表;任一步失敗整筆 rollback。 +func (u *pgDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) { + var res UnpairResult + err := db.WithTx(ctx, u.pool, func(q db.Querier) error { + if delErr := u.devices.DeleteTx(ctx, q, deviceID); delErr != nil { + return delErr // 含 device.ErrNotFound;WithTx 會 rollback(此時尚無變更) + } + pRevoked, pErr := u.pairingTok.RevokeByDeviceTx(ctx, q, deviceID) + if pErr != nil { + return fmt.Errorf("cascade revoke pairing tokens: %w", pErr) + } + sRevoked, sErr := u.sessionTok.RevokeByDeviceTx(ctx, q, deviceID) + if sErr != nil { + return fmt.Errorf("cascade revoke session tokens: %w", sErr) + } + res.PairingRevoked = pRevoked + res.SessionRevoked = sRevoked + return nil + }) + if err != nil { + return UnpairResult{}, err + } + return res, nil +} + +// ── in-memory 後端介面 ──────────────────────────────────────────────────────── + +// memTokenRevoker 是 in-memory store「撤銷某 device 名下未撤銷 token」的能力 +// (由 auth.InMemoryPairingStore / auth.InMemorySessionTokenStore 滿足)。 +type memTokenRevoker interface { + RevokeByDevice(ctx context.Context, deviceID string) (int, error) +} + +// memDeviceUnpairer 依序(非交易)完成 device 軟刪 + token cascade 撤銷。 +// +// in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致 +// (刪 device 後 token 也撤)。device 軟刪失敗(ErrNotFound)提早回、不撤 token。 +type memDeviceUnpairer struct { + devices device.Repository + pairingTok memTokenRevoker + sessionTok memTokenRevoker +} + +// NewInMemoryDeviceUnpairer 建立 in-memory 後端的 cascade unpair 協調者。 +func NewInMemoryDeviceUnpairer( + devices device.Repository, + pairingTok memTokenRevoker, + sessionTok memTokenRevoker, +) DeviceUnpairer { + return &memDeviceUnpairer{ + devices: devices, + pairingTok: pairingTok, + sessionTok: sessionTok, + } +} + +// Unpair 軟刪 device 後 cascade 撤銷其 token(依序,非交易)。 +func (u *memDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) { + if err := u.devices.Delete(ctx, deviceID); err != nil { + return UnpairResult{}, err // 含 device.ErrNotFound + } + pRevoked, err := u.pairingTok.RevokeByDevice(ctx, deviceID) + if err != nil { + return UnpairResult{}, fmt.Errorf("cascade revoke pairing tokens: %w", err) + } + sRevoked, err := u.sessionTok.RevokeByDevice(ctx, deviceID) + if err != nil { + return UnpairResult{}, fmt.Errorf("cascade revoke session tokens: %w", err) + } + return UnpairResult{PairingRevoked: pRevoked, SessionRevoked: sRevoked}, nil +} diff --git a/visionA-backend/internal/api/unpair_db_test.go b/visionA-backend/internal/api/unpair_db_test.go new file mode 100644 index 0000000..a9cbb99 --- /dev/null +++ b/visionA-backend/internal/api/unpair_db_test.go @@ -0,0 +1,145 @@ +//go:build dbtest + +// Postgres cascade unpair 的真 DB 整合測試(DB 接入塊 5.2 / 5.5)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 +// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/api/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/api/... +// +// 涵蓋: +// - cascade 成功:刪 device → 同 tx 撤 pairing + session token(database.md §6)。 +// - rollback 原子性:cascade 中途失敗 → device 軟刪也回滾(device 仍存在、token 未撤)。 +// - device 不存在 → device.ErrNotFound、不撤任何 token。 +package api + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" + "visiona-backend/internal/device" +) + +// pgUnpairFixture 建一個已就緒的 Postgres 環境:device + 綁該 device 的 pairing/session token。 +func pgUnpairFixture(t *testing.T) ( + tdb *testsupport.TestDB, + devRepo *device.PostgresRepository, + pairing *auth.PostgresPairingStore, + sessions *auth.PostgresSessionTokenStore, + owner, deviceID, pairingPlain, sessionPlain string, +) { + t.Helper() + ctx := context.Background() + tdb = testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") + owner = tdb.EnsureDemoUser(t) + + devRepo = device.NewPostgresRepository(tdb.Pool) + pairing = auth.NewPostgresPairingStore(tdb.Pool) + sessions = auth.NewPostgresSessionTokenStore(tdb.Pool) + + deviceID = tdb.InsertDevice(t, "", owner) + + pPlain, _, err := pairing.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID)) + pairingPlain = pPlain + + sPlain, _, err := sessions.Create(ctx, owner, deviceID, "", auth.SessionTokenTTL) + require.NoError(t, err) + sessionPlain = sPlain + + return +} + +// TestPGUnpair_CascadeSuccess 驗證 cascade 成功:device 軟刪 + 兩張 token 表撤銷,單一交易落地。 +func TestPGUnpair_CascadeSuccess(t *testing.T) { + ctx := context.Background() + tdb, devRepo, pairing, sessions, _, deviceID, pPlain, sPlain := pgUnpairFixture(t) + + unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil) + res, err := unpairer.Unpair(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, 1, res.PairingRevoked) + assert.Equal(t, 1, res.SessionRevoked) + + // device 已軟刪 + _, err = devRepo.Get(ctx, deviceID) + assert.ErrorIs(t, err, device.ErrNotFound) + // pairing token 已撤 + _, err = pairing.Validate(ctx, pPlain) + assert.ErrorIs(t, err, auth.ErrTokenRevoked) + // session token 已撤 + _, err = sessions.Get(ctx, sPlain) + assert.ErrorIs(t, err, auth.ErrTokenRevoked) +} + +// failingRevoker 是會回 error 的 token revoker,用來觸發 cascade 中途失敗、驗證 rollback。 +type failingRevoker struct{} + +func (failingRevoker) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) { + return 0, errors.New("simulated revoke failure") +} + +// TestPGUnpair_RollbackOnCascadeFailure 驗證 cascade 中途失敗 → device 軟刪也整筆回滾。 +// +// 用 failingRevoker 取代 session token revoker:device 在 tx 內已軟刪、pairing 已撤,但 session +// 撤銷回 error → WithTx rollback,最終 device 仍未刪、pairing token 也未撤(整筆原子)。 +func TestPGUnpair_RollbackOnCascadeFailure(t *testing.T) { + ctx := context.Background() + tdb, devRepo, pairing, _, _, deviceID, pPlain, _ := pgUnpairFixture(t) + + unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, failingRevoker{}, nil) + _, err := unpairer.Unpair(ctx, deviceID) + require.Error(t, err) + + // device 仍存在(軟刪被 rollback) + d, getErr := devRepo.Get(ctx, deviceID) + require.NoError(t, getErr, "device 應因 rollback 仍存在") + assert.Nil(t, d.DeletedAt) + + // pairing token 也未撤(同一交易 rollback)。 + // fixture 已 MarkUsed(cascade 撤銷靠 WHERE device_id,token 須先綁 device), + // 故不能用 Validate 驗——used token 必回 ErrTokenUsed。直接查 revoked_at 是否仍為 NULL。 + var pairingRevokedAt *time.Time + qErr := tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, + auth.HashToken(pPlain)).Scan(&pairingRevokedAt) + require.NoError(t, qErr) + assert.Nil(t, pairingRevokedAt, "pairing token 應因 rollback 未被撤銷") +} + +// TestPGUnpair_DeviceNotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。 +func TestPGUnpair_DeviceNotFound(t *testing.T) { + ctx := context.Background() + tdb, devRepo, pairing, sessions, _, _, pPlain, sPlain := pgUnpairFixture(t) + + unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil) + _, err := unpairer.Unpair(ctx, "00000000-0000-0000-0000-0000000000ff") + assert.ErrorIs(t, err, device.ErrNotFound) + + // 原 device 的 token 不受影響(沒被誤撤)。 + // pairing token fixture 已 MarkUsed,不能用 Validate(必回 ErrTokenUsed);直接查 revoked_at 仍為 NULL。 + var pairingRevokedAt *time.Time + qErr := tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, + auth.HashToken(pPlain)).Scan(&pairingRevokedAt) + require.NoError(t, qErr) + assert.Nil(t, pairingRevokedAt, "不存在 device 的 unpair 不應誤撤 pairing token") + // session token 無 used 概念,未撤銷時 Get 正常回 NoError,沿用原斷言。 + _, err = sessions.Get(ctx, sPlain) + require.NoError(t, err) +} diff --git a/visionA-backend/internal/api/unpair_test.go b/visionA-backend/internal/api/unpair_test.go new file mode 100644 index 0000000..6a40a89 --- /dev/null +++ b/visionA-backend/internal/api/unpair_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/auth" + "visiona-backend/internal/device" +) + +// TestInMemoryDeviceUnpairer_Cascade 驗證 in-memory 模式刪 device 時 cascade 撤銷其 token +// (塊 5.2 約束:DB 未啟用時 cascade 在 in-memory 也成立)。 +func TestInMemoryDeviceUnpairer_Cascade(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + pairing := auth.NewInMemoryPairingStore() + sessions := auth.NewInMemorySessionTokenStore() + + const userID = "user-1" + const deviceID = "dev-1" + + // 建一個 device + require.NoError(t, devRepo.Save(ctx, &device.Device{ + ID: deviceID, OwnerUserID: userID, Name: "d", SerialNumber: "SN1", + })) + + // 建並綁定一個 pairing token 到該 device(MarkUsed 綁 device) + pPlain, _, err := pairing.Create(ctx, userID, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID)) + + // 建一個 session token 綁該 device + sPlain, _, err := sessions.Create(ctx, userID, deviceID, "", 90*24*time.Hour) + require.NoError(t, err) + + // 另一個 device 的 token(不應被撤) + const otherDevice = "dev-2" + require.NoError(t, devRepo.Save(ctx, &device.Device{ + ID: otherDevice, OwnerUserID: userID, Name: "d2", SerialNumber: "SN2", + })) + oPlain, _, err := sessions.Create(ctx, userID, otherDevice, "", 90*24*time.Hour) + require.NoError(t, err) + + unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions) + res, err := unpairer.Unpair(ctx, deviceID) + require.NoError(t, err) + assert.Equal(t, 1, res.PairingRevoked) + assert.Equal(t, 1, res.SessionRevoked) + + // device 已軟刪 + _, err = devRepo.Get(ctx, deviceID) + assert.ErrorIs(t, err, device.ErrNotFound) + + // 該 device 的 session token 已撤 + _, err = sessions.Get(ctx, sPlain) + assert.ErrorIs(t, err, auth.ErrTokenRevoked) + + // 該 device 的 pairing token 已撤 + _, err = pairing.Validate(ctx, pPlain) + assert.ErrorIs(t, err, auth.ErrTokenRevoked) + + // 另一個 device 的 token 不受影響 + _, err = sessions.Get(ctx, oPlain) + assert.NoError(t, err) +} + +// TestInMemoryDeviceUnpairer_NotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。 +func TestInMemoryDeviceUnpairer_NotFound(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + pairing := auth.NewInMemoryPairingStore() + sessions := auth.NewInMemorySessionTokenStore() + + unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions) + _, err := unpairer.Unpair(ctx, "does-not-exist") + assert.ErrorIs(t, err, device.ErrNotFound) +} diff --git a/visionA-backend/internal/auth/inmemory_pairing_store.go b/visionA-backend/internal/auth/inmemory_pairing_store.go index 1655e9f..7ebbb1c 100644 --- a/visionA-backend/internal/auth/inmemory_pairing_store.go +++ b/visionA-backend/internal/auth/inmemory_pairing_store.go @@ -138,6 +138,31 @@ func (s *InMemoryPairingStore) List(ctx context.Context, userID string) ([]*Pair return out, nil } +// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。 +// +// in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token, +// 回傳實際撤銷數。空 deviceID 不撤任何 token(對齊 Postgres)。 +// +// in-memory 無交易概念:delete device 與本方法在 unpair coordinator 內依序執行,雖非原子, +// 但 in-memory 為單機 local-dev fallback,行為一致性(刪 device 後 token 也撤)已滿足。 +func (s *InMemoryPairingStore) RevokeByDevice(ctx context.Context, deviceID string) (int, error) { + if deviceID == "" { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + revoked := 0 + for _, info := range s.tokens { + if info.DeviceID == deviceID && info.RevokedAt == nil { + info.RevokedAt = &now + revoked++ + } + } + return revoked, nil +} + // CleanupExpired 移除所有已過 ExpiresAt 的 token;回傳移除數量。 // // 通常由 background goroutine 週期性呼叫(例:每 1 分鐘)。 diff --git a/visionA-backend/internal/auth/inmemory_pairing_store_test.go b/visionA-backend/internal/auth/inmemory_pairing_store_test.go index bb9b077..de44f58 100644 --- a/visionA-backend/internal/auth/inmemory_pairing_store_test.go +++ b/visionA-backend/internal/auth/inmemory_pairing_store_test.go @@ -130,3 +130,41 @@ func TestInMemoryPairingStore_Validate_Expired(t *testing.T) { _, err = s.Validate(ctx, plain) assert.ErrorIs(t, err, ErrTokenExpired) } + +// TestInMemoryPairingStore_RevokeByDevice 驗證 cascade 撤銷(塊 5.2): +// 只撤指定 device 名下未撤銷的 token,回傳撤銷數。 +func TestInMemoryPairingStore_RevokeByDevice(t *testing.T) { + ctx := context.Background() + s := NewInMemoryPairingStore() + + // 綁 dev-1 的兩個 token(其中一個先撤) + p1, _, err := s.Create(ctx, "user-1", 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p1, "dev-1")) + + p2, _, err := s.Create(ctx, "user-1", 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p2, "dev-1")) + require.NoError(t, s.Revoke(ctx, p2)) // 已撤,不應重複計數 + + // 綁 dev-2(不應被撤) + p3, _, err := s.Create(ctx, "user-1", 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p3, "dev-2")) + + revoked, err := s.RevokeByDevice(ctx, "dev-1") + require.NoError(t, err) + assert.Equal(t, 1, revoked, "只有 p1(未撤)被撤;p2 已撤不計") + + // p1 已撤 + _, err = s.Validate(ctx, p1) + assert.ErrorIs(t, err, ErrTokenRevoked) + // p3(dev-2)不受影響 + _, err = s.Validate(ctx, p3) + assert.ErrorIs(t, err, ErrTokenUsed) // 已 used 但未 revoke + + // 空 deviceID 不撤任何 token + n, err := s.RevokeByDevice(ctx, "") + require.NoError(t, err) + assert.Equal(t, 0, n) +} diff --git a/visionA-backend/internal/auth/postgres_pairing_store.go b/visionA-backend/internal/auth/postgres_pairing_store.go new file mode 100644 index 0000000..31783c8 --- /dev/null +++ b/visionA-backend/internal/auth/postgres_pairing_store.go @@ -0,0 +1,323 @@ +// PostgresPairingStore 是 PairingStore 的 PostgreSQL 持久層實作(DB 接入塊 3)。 +// +// 與 InMemoryPairingStore 實作相同的 PairingStore interface,讓 main.go 在 dbPool != nil +// 時無痛切換、handler 與呼叫端(internal/api/pairing.go)一行都不需改。 +// +// 對齊: +// - database.md §2.4(PairingToken struct)、§4(pairing_tokens 表 schema) +// - migrations/0003_create_token_tables.up.sql(pairing_tokens 表) +// +// ── 關鍵改動:plaintext → token_hash 當 PK(database.md 結尾提醒、塊 3 子任務 3.3)── +// +// in-memory 版以 plaintext token 當 map key;Postgres 版改以 token_hash(HashToken(plaintext)) +// 當 PK,DB 永不存明文 token(security.md §1.3)。 +// +// 所有「以 plaintext 查詢」的方法(Validate / MarkUsed / Revoke)一律先 HashToken(plaintext) +// 再以 hash 比對。呼叫端統一傳 plaintext 進來(已 grep 確認:internal/api/pairing.go 的 +// Validate / MarkUsed / Revoke 全部傳 plaintext 的 vAc_ token),store 內部統一 hash, +// 不會有「漏 hash 某個呼叫端」的問題。 +// +// 語意對齊 in-memory(見 inmemory_pairing_store.go): +// - Validate 的狀態優先序:revoked → used → expired(與 in-memory 完全一致)。 +// - MarkUsed 一次性 + 冪等:未使用 → 寫 used_at + device_id;已使用 → no-op 回 nil(不覆寫 device_id); +// 不存在 → ErrInvalidToken。DB 層用 `WHERE used_at IS NULL` 達成兩併發只一筆實際標記。 +// - Revoke 冪等:未撤銷 → 寫 revoked_at;已撤銷 → no-op nil;不存在 → ErrInvalidToken。 +// - CleanupExpired:DELETE 所有 expires_at < now 的列,回刪除數。 +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/db" +) + +// PostgresPairingStore 是 pairing token 的 PostgreSQL 持久層實作。 +type PostgresPairingStore struct { + pool *pgxpool.Pool +} + +// NewPostgresPairingStore 建立一個以 pgxpool 為後端的 PairingStore。 +// +// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。 +func NewPostgresPairingStore(pool *pgxpool.Pool) *PostgresPairingStore { + return &PostgresPairingStore{pool: pool} +} + +// 編譯時檢查:確保 PostgresPairingStore 實作 PairingStore。 +var _ PairingStore = (*PostgresPairingStore)(nil) + +// pairingColumns 是 SELECT 共用欄位清單(順序必須與 scanPairingToken 對齊)。 +// +// 注意:DB 不存 plaintext,故掃出的 PairingToken.Plaintext 永遠為空字串(符合預期, +// 呼叫端在 Validate 後只用 UserID / DeviceID / TokenHash,不依賴 Plaintext)。 +const pairingColumns = `token_hash, user_id, device_id, kind, + created_at, expires_at, used_at, revoked_at` + +// Create 產生並保存一個新 pairing token。 +// +// ttl <= 0 時 ExpiresAt 保持 NULL(永不過期;測試用)。 +// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。 +func (s *PostgresPairingStore) Create( + ctx context.Context, userID string, ttl time.Duration, +) (string, *PairingToken, error) { + plaintext, err := GeneratePairingToken() + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + info := &PairingToken{ + Plaintext: plaintext, + TokenHash: HashToken(plaintext), + UserID: userID, + Kind: KindPairing, + CreatedAt: now, + } + var expiresAt any // nil → DB NULL + if ttl > 0 { + expires := now.Add(ttl) + info.ExpiresAt = &expires + expiresAt = expires + } + + const q = `INSERT INTO pairing_tokens + (token_hash, user_id, kind, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5)` + if _, err := s.pool.Exec(ctx, q, + info.TokenHash, info.UserID, string(info.Kind), info.CreatedAt, expiresAt, + ); err != nil { + return "", nil, fmt.Errorf("auth: pg pairing Create: %w", err) + } + return plaintext, info, nil +} + +// Validate 檢查 token 是否存在且可用(未撤銷、未使用、未過期)。 +// +// 接收 plaintext,內部 HashToken 後查詢。狀態優先序對齊 in-memory: +// revoked → used → expired。不存在回 ErrInvalidToken。 +func (s *PostgresPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) { + hash := HashToken(token) + + const q = `SELECT ` + pairingColumns + ` + FROM pairing_tokens WHERE token_hash = $1` + row := s.pool.QueryRow(ctx, q, hash) + info, err := scanPairingToken(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidToken + } + if err != nil { + return nil, fmt.Errorf("auth: pg pairing Validate: %w", err) + } + + if info.IsRevoked() { + return nil, ErrTokenRevoked + } + if info.IsUsed() { + return nil, ErrTokenUsed + } + if info.IsExpired(time.Now().UTC()) { + return nil, ErrTokenExpired + } + return info, nil +} + +// MarkUsed 標記一次性 token 為已使用並綁定 deviceID。 +// +// 一次性 + 冪等語意(DB 層): +// - 以 `WHERE token_hash = $ AND used_at IS NULL` UPDATE:兩併發只一筆 RowsAffected = 1 +// (DB 行鎖保證),是「真正標記成功」的那一個。 +// - RowsAffected = 0 時需區分「已使用(冪等 no-op,回 nil、不覆寫 device_id)」與 +// 「不存在(ErrInvalidToken)」→ 再查一次存在性。 +// +// deviceID 可為空字串(雛形 pairing 尚未綁 device 時),對齊 in-memory。 +func (s *PostgresPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error { + hash := HashToken(token) + + // device_id 為 UUID 欄位且 nullable:空字串無法寫進 UUID 欄位,轉成 NULL。 + var deviceArg any + if deviceID != "" { + deviceArg = deviceID + } + + const q = `UPDATE pairing_tokens + SET used_at = now(), device_id = $2 + WHERE token_hash = $1 AND used_at IS NULL` + tag, err := s.pool.Exec(ctx, q, hash, deviceArg) + if err != nil { + return fmt.Errorf("auth: pg pairing MarkUsed: %w", err) + } + if tag.RowsAffected() == 1 { + return nil // 本呼叫為實際標記成功者 + } + + // RowsAffected == 0:可能已使用(冪等)或不存在。查存在性以區分。 + exists, err := s.tokenExists(ctx, hash) + if err != nil { + return fmt.Errorf("auth: pg pairing MarkUsed exists check: %w", err) + } + if !exists { + return ErrInvalidToken + } + return nil // 已使用 → 冪等 no-op +} + +// Revoke 撤銷一個 token(之後 Validate 回 ErrTokenRevoked)。 +// +// 冪等:已撤銷 → no-op nil;不存在 → ErrInvalidToken。 +func (s *PostgresPairingStore) Revoke(ctx context.Context, token string) error { + hash := HashToken(token) + + const q = `UPDATE pairing_tokens + SET revoked_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL` + tag, err := s.pool.Exec(ctx, q, hash) + if err != nil { + return fmt.Errorf("auth: pg pairing Revoke: %w", err) + } + if tag.RowsAffected() == 1 { + return nil + } + + exists, err := s.tokenExists(ctx, hash) + if err != nil { + return fmt.Errorf("auth: pg pairing Revoke exists check: %w", err) + } + if !exists { + return ErrInvalidToken + } + return nil // 已撤銷 → 冪等 no-op +} + +// List 回傳指定 user 的所有 pairing token(含已使用 / 撤銷),created_at DESC。 +// +// 注意:回傳的 token Plaintext 為空(DB 不存明文);caller 不應依賴 Plaintext。 +func (s *PostgresPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) { + const q = `SELECT ` + pairingColumns + ` + FROM pairing_tokens WHERE user_id = $1 + ORDER BY created_at DESC` + rows, err := s.pool.Query(ctx, q, userID) + if err != nil { + return nil, fmt.Errorf("auth: pg pairing List query: %w", err) + } + defer rows.Close() + + out := make([]*PairingToken, 0) + for rows.Next() { + info, scanErr := scanPairingToken(rows) + if scanErr != nil { + return nil, fmt.Errorf("auth: pg pairing List scan: %w", scanErr) + } + out = append(out, info) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("auth: pg pairing List rows: %w", err) + } + return out, nil +} + +// CleanupExpired 移除所有已過 expires_at 的 token;回傳移除數量。 +// +// expires_at IS NULL(永不過期)不會被刪。 +func (s *PostgresPairingStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) { + const q = `DELETE FROM pairing_tokens + WHERE expires_at IS NOT NULL AND expires_at < $1` + tag, err := s.pool.Exec(ctx, q, now.UTC()) + if err != nil { + return 0, fmt.Errorf("auth: pg pairing CleanupExpired: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。 +// +// 在傳入的 Querier(pool 或 tx)上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1 +// AND revoked_at IS NULL`(database.md §6)。回傳實際撤銷的列數(觀測用,無撤銷對象回 0、不報錯)。 +// +// 對齊 database.md §6:刪 device 時對 pairing_tokens + session_tokens 各跑一次此類 UPDATE, +// 須在同一 tx 內。本方法只負責 pairing 表那一半;device 軟刪與 session 撤銷由 cascade 協調者 +// 在同一個 db.WithTx 內串起。 +// +// 注意:pairing token 的 device_id 只有在 MarkUsed 綁定後才有值;未綁定的 token(device_id IS NULL) +// 不屬於任何 device,自然不會被本查詢撤銷,符合語意。 +func (s *PostgresPairingStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) { + if deviceID == "" { + return 0, nil // 無 device 對象(對齊 in-memory:空 deviceID 不撤任何 token) + } + const sql = `UPDATE pairing_tokens + SET revoked_at = now() + WHERE device_id = $1 AND revoked_at IS NULL` + tag, err := q.Exec(ctx, sql, deviceID) + if err != nil { + return 0, fmt.Errorf("auth: pg pairing RevokeByDevice: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// tokenExists 查指定 hash 的 pairing token 是否存在(不論狀態)。 +func (s *PostgresPairingStore) tokenExists(ctx context.Context, hash string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM pairing_tokens WHERE token_hash = $1)`, hash, + ).Scan(&exists) + return exists, err +} + +// scanPairingToken 從一列掃出 *PairingToken。欄位順序必須與 pairingColumns 對齊。 +// +// device_id 為 nullable UUID(MarkUsed 前為 NULL)→ 以 *string 接、NULL 掃成空字串 +// (對齊 in-memory zero value)。時間欄位正規化為 UTC。Plaintext 留空(DB 不存)。 +func scanPairingToken(row rowScanner) (*PairingToken, error) { + var ( + t PairingToken + deviceID *string + kind string + ) + err := row.Scan( + &t.TokenHash, + &t.UserID, + &deviceID, + &kind, + &t.CreatedAt, + &t.ExpiresAt, + &t.UsedAt, + &t.RevokedAt, + ) + if err != nil { + return nil, err + } + + t.Kind = TokenKind(kind) + if deviceID != nil { + t.DeviceID = *deviceID + } + + t.CreatedAt = t.CreatedAt.UTC() + t.ExpiresAt = utcPtr(t.ExpiresAt) + t.UsedAt = utcPtr(t.UsedAt) + t.RevokedAt = utcPtr(t.RevokedAt) + return &t, nil +} + +// ========================================================================== +// shared scan helpers(pairing + session token 共用) +// ========================================================================== + +// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scan helper 同時服務單列查詢與 List。 +type rowScanner interface { + Scan(dest ...any) error +} + +// utcPtr 將 nullable 時間指標正規化為 UTC(nil 維持 nil)。 +func utcPtr(p *time.Time) *time.Time { + if p == nil { + return nil + } + u := p.UTC() + return &u +} diff --git a/visionA-backend/internal/auth/postgres_pairing_store_db_test.go b/visionA-backend/internal/auth/postgres_pairing_store_db_test.go new file mode 100644 index 0000000..77d9734 --- /dev/null +++ b/visionA-backend/internal/auth/postgres_pairing_store_db_test.go @@ -0,0 +1,453 @@ +//go:build dbtest + +// PostgresPairingStore 的真 DB 整合測試(DB 接入塊 3,子任務 3.6 / 3.8 / 3.9 pairing 部分)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/auth/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/auth/... +// +// 涵蓋: +// - 3.6 unit/邏輯:對齊既有 inmemory_pairing_store_test.go(CreateAndValidate、unknown token、 +// MarkUsed 一次性+冪等、Revoke、CleanupExpired、List by user、Validate expired)。 +// - 3.8 integration/真 DB:hash 當 PK 查詢正確性、TTL 過期、一次性 used 的 DB 層 race +// (兩併發 MarkUsed 只一筆實際標記)、撤銷稽核欄位、兩表隔離(pairing 與 session 不互串)。 +// - 3.9 邊界:併發 Validate 同 token、CleanupExpired 大量資料、context cancel。 +package auth + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// newPGPairingStore 啟動一次性測試 DB、truncate token/device/user 表、確保 demo user 存在, +// 回傳 store + tdb + ownerID。 +func newPGPairingStore(t *testing.T) (*PostgresPairingStore, *testsupport.TestDB, string) { + t.Helper() + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") + owner := tdb.EnsureDemoUser(t) + return NewPostgresPairingStore(tdb.Pool), tdb, owner +} + +// --------------------------------------------------------------------------- +// 3.6 unit/邏輯(對齊 inmemory_pairing_store_test.go 的 case) +// --------------------------------------------------------------------------- + +func TestPGPairing_CreateAndValidate(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, info, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NotEmpty(t, plain) + require.NotNil(t, info) + + assert.True(t, IsValidPairingToken(plain)) + assert.Equal(t, owner, info.UserID) + assert.Equal(t, KindPairing, info.Kind) + assert.NotNil(t, info.ExpiresAt) + assert.Nil(t, info.UsedAt) + assert.Equal(t, HashToken(plain), info.TokenHash, "PK 應為 plaintext 的 hash") + + got, err := s.Validate(ctx, plain) + require.NoError(t, err) + assert.Equal(t, owner, got.UserID) + assert.Equal(t, HashToken(plain), got.TokenHash) + assert.Empty(t, got.Plaintext, "DB 不存明文 → 回傳的 Plaintext 應為空") +} + +func TestPGPairing_Validate_UnknownToken(t *testing.T) { + s, _, _ := newPGPairingStore(t) + _, err := s.Validate(context.Background(), "vAc_00000000000000000000000000000000") + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestPGPairing_MarkUsed_IsOneTime(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + + require.NoError(t, s.MarkUsed(ctx, plain, "")) + + // Validate 必須失敗(一次性 token 已消費)。 + _, err = s.Validate(ctx, plain) + assert.ErrorIs(t, err, ErrTokenUsed) + + // 再次 MarkUsed 應為冪等 no-op(回 nil)。 + assert.NoError(t, s.MarkUsed(ctx, plain, "")) +} + +func TestPGPairing_MarkUsed_UnknownToken(t *testing.T) { + s, _, _ := newPGPairingStore(t) + err := s.MarkUsed(context.Background(), "vAc_ffffffffffffffffffffffffffffffff", "") + assert.ErrorIs(t, err, ErrInvalidToken, "對不存在 token MarkUsed 應回 ErrInvalidToken") +} + +// MarkUsed 綁定 device_id(FK → devices):驗證 device_id 正確寫入。 +func TestPGPairing_MarkUsed_BindsDevice(t *testing.T) { + ctx := context.Background() + s, tdb, owner := newPGPairingStore(t) + deviceID := tdb.InsertDevice(t, "", owner) + + plain, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, plain, deviceID)) + + list, err := s.List(ctx, owner) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, deviceID, list[0].DeviceID, "MarkUsed 應綁定 device_id") + assert.NotNil(t, list[0].UsedAt) +} + +func TestPGPairing_Revoke(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + + require.NoError(t, s.Revoke(ctx, plain)) + + _, err = s.Validate(ctx, plain) + assert.ErrorIs(t, err, ErrTokenRevoked) + + // 撤銷不存在的 token → ErrInvalidToken + assert.ErrorIs(t, s.Revoke(ctx, "vAc_abcdef00000000000000000000000000"), ErrInvalidToken) + + // 冪等:再撤一次不報錯 + assert.NoError(t, s.Revoke(ctx, plain)) +} + +func TestPGPairing_CleanupExpired(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + expired, _, err := s.Create(ctx, owner, 1*time.Millisecond) + require.NoError(t, err) + fresh, _, err := s.Create(ctx, owner, 1*time.Hour) + require.NoError(t, err) + // 永不過期(ttl=0 → expires_at NULL)不應被清。 + never, _, err := s.Create(ctx, owner, 0) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + removed, err := s.CleanupExpired(ctx, time.Now().UTC()) + require.NoError(t, err) + assert.Equal(t, 1, removed) + + _, err = s.Validate(ctx, expired) + assert.ErrorIs(t, err, ErrInvalidToken, "過期且已清掉 → 查不到") + _, err = s.Validate(ctx, fresh) + assert.NoError(t, err, "未過期不應被清") + _, err = s.Validate(ctx, never) + assert.NoError(t, err, "永不過期(NULL expires_at)不應被清") +} + +func TestPGPairing_List_ByUser(t *testing.T) { + ctx := context.Background() + s, tdb, ownerA := newPGPairingStore(t) + ownerB := tdb.InsertUser(t, "", "") + + _, _, err := s.Create(ctx, ownerA, time.Hour) + require.NoError(t, err) + _, _, err = s.Create(ctx, ownerA, time.Hour) + require.NoError(t, err) + _, _, err = s.Create(ctx, ownerB, time.Hour) + require.NoError(t, err) + + listA, err := s.List(ctx, ownerA) + require.NoError(t, err) + assert.Len(t, listA, 2) + + listB, err := s.List(ctx, ownerB) + require.NoError(t, err) + assert.Len(t, listB, 1) + + listNone, err := s.List(ctx, tdb.InsertUser(t, "", "")) + require.NoError(t, err) + assert.Empty(t, listNone) + assert.NotNil(t, listNone, "List 應回 non-nil 空 slice") +} + +func TestPGPairing_Validate_Expired(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, _, err := s.Create(ctx, owner, 1*time.Millisecond) + require.NoError(t, err) + + time.Sleep(5 * time.Millisecond) + + _, err = s.Validate(ctx, plain) + assert.ErrorIs(t, err, ErrTokenExpired) +} + +// --------------------------------------------------------------------------- +// 3.8 integration/真 DB +// --------------------------------------------------------------------------- + +// hash 當 PK:DB 內實際存的是 token_hash,明文不出現在表中。 +func TestPGPairing_HashIsPK_NoPlaintextStored(t *testing.T) { + ctx := context.Background() + s, tdb, owner := newPGPairingStore(t) + + plain, info, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + + // DB 內以 token_hash 為 PK 存在、明文不存在。 + var stored string + err = tdb.Pool.QueryRow(ctx, + `SELECT token_hash FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&stored) + require.NoError(t, err, "應能用 hash 查到") + assert.Equal(t, HashToken(plain), stored) + + // 明文不該等於任何 PK(plaintext != hash)。 + var cnt int + err = tdb.Pool.QueryRow(ctx, + `SELECT count(*) FROM pairing_tokens WHERE token_hash = $1`, plain).Scan(&cnt) + require.NoError(t, err) + assert.Equal(t, 0, cnt, "明文不應出現為 PK(DB 永不存明文)") +} + +// 狀態優先序:revoked 優先於 used 與 expired(對齊 in-memory)。 +func TestPGPairing_Validate_RevokedBeforeUsed(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, _, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, plain, "")) + require.NoError(t, s.Revoke(ctx, plain)) + + _, err = s.Validate(ctx, plain) + assert.ErrorIs(t, err, ErrTokenRevoked, "revoked 應優先於 used") +} + +// 一次性 used 的 DB 層 race:N 併發 MarkUsed 同 token,只一筆實際標記(DB 行鎖保證)。 +// 所有呼叫都回 nil(冪等);最終 used_at 恰寫一次。 +func TestPGPairing_ConcurrentMarkUsed_OnlyOneWins(t *testing.T) { + ctx := context.Background() + s, tdb, owner := newPGPairingStore(t) + deviceID := tdb.InsertDevice(t, "", owner) + + plain, info, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + + const n = 30 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = s.MarkUsed(ctx, plain, deviceID) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "併發 MarkUsed #%d 應回 nil(冪等)", i) + } + + // DB 層:used_at 恰寫一次(單一列、used_at 非 NULL)。 + var usedCount int + err = tdb.Pool.QueryRow(ctx, + `SELECT count(*) FROM pairing_tokens WHERE token_hash = $1 AND used_at IS NOT NULL`, + info.TokenHash).Scan(&usedCount) + require.NoError(t, err) + assert.Equal(t, 1, usedCount, "一次性語意:恰一列被標記 used") + + // Validate 之後必失敗。 + _, err = s.Validate(ctx, plain) + assert.ErrorIs(t, err, ErrTokenUsed) +} + +// 撤銷稽核欄位:Revoke 後 revoked_at 非 NULL 且固定(冪等不覆寫時間)。 +func TestPGPairing_Revoke_AuditField(t *testing.T) { + ctx := context.Background() + s, tdb, owner := newPGPairingStore(t) + + plain, info, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + require.NoError(t, s.Revoke(ctx, plain)) + + var revokedAt1 time.Time + err = tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt1) + require.NoError(t, err) + assert.False(t, revokedAt1.IsZero()) + + // 冪等再撤一次:revoked_at 不應被覆寫(WHERE revoked_at IS NULL 不命中)。 + require.NoError(t, s.Revoke(ctx, plain)) + var revokedAt2 time.Time + err = tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt2) + require.NoError(t, err) + assert.True(t, revokedAt1.Equal(revokedAt2), "冪等 Revoke 不應覆寫 revoked_at") +} + +// 兩表隔離:pairing token 與 session token 各自獨立、互不串。 +func TestPGPairing_TwoTableIsolation(t *testing.T) { + ctx := context.Background() + ps, tdb, owner := newPGPairingStore(t) + ss := NewPostgresSessionTokenStore(tdb.Pool) + deviceID := tdb.InsertDevice(t, "", owner) + + // 同一個 plaintext 不可能跨兩表(token 各自生成),這裡驗證: + // pairing 表的查詢看不到 session 表的列,反之亦然。 + pPlain, _, err := ps.Create(ctx, owner, time.Hour) + require.NoError(t, err) + sPlain, _, err := ss.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + + // pairing.Validate 對 session token 查不到(不同表 + 不同 hash)。 + _, err = ps.Validate(ctx, sPlain) + assert.ErrorIs(t, err, ErrInvalidToken) + + // session.Get 對 pairing token 查不到。 + _, err = ss.Get(ctx, pPlain) + assert.ErrorIs(t, err, ErrInvalidToken) + + assert.Equal(t, 1, tdb.CountRows(t, "pairing_tokens")) + assert.Equal(t, 1, tdb.CountRows(t, "session_tokens")) +} + +// --------------------------------------------------------------------------- +// 3.9 邊界 +// --------------------------------------------------------------------------- + +// 併發 Validate 同 token:純讀、不應 panic / race,全部成功。 +func TestPGPairing_ConcurrentValidate(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + plain, _, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + + const n = 30 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = s.Validate(ctx, plain) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "併發 Validate #%d", i) + } +} + +// CleanupExpired 大量資料:批次刪除正確(過期全清、未過期保留)。 +func TestPGPairing_CleanupExpired_Bulk(t *testing.T) { + ctx := context.Background() + s, _, owner := newPGPairingStore(t) + + const expiredN = 50 + for i := 0; i < expiredN; i++ { + _, _, err := s.Create(ctx, owner, 1*time.Millisecond) + require.NoError(t, err) + } + const freshN = 10 + for i := 0; i < freshN; i++ { + _, _, err := s.Create(ctx, owner, time.Hour) + require.NoError(t, err) + } + + time.Sleep(20 * time.Millisecond) + + removed, err := s.CleanupExpired(ctx, time.Now().UTC()) + require.NoError(t, err) + assert.Equal(t, expiredN, removed, "應清掉所有過期列") + + list, err := s.List(ctx, owner) + require.NoError(t, err) + assert.Len(t, list, freshN, "未過期列應保留") +} + +// context cancel:已取消 ctx 的操作應回 error(不 hang、不 panic)。 +func TestPGPairing_ContextCancel(t *testing.T) { + s, _, owner := newPGPairingStore(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := s.Create(ctx, owner, time.Hour) + assert.Error(t, err, "已取消 ctx 的 Create 應回 error") + + _, err = s.Validate(ctx, "vAc_00000000000000000000000000000000") + assert.Error(t, err, "已取消 ctx 的 Validate 應回 error") + + err = s.MarkUsed(ctx, "vAc_00000000000000000000000000000000", "") + assert.Error(t, err, "已取消 ctx 的 MarkUsed 應回 error") +} + +// --------------------------------------------------------------------------- +// 塊 5.2 cascade:RevokeByDeviceTx(pairing) +// --------------------------------------------------------------------------- + +// TestPGPairing_RevokeByDeviceTx 驗證撤銷某 device 名下未撤銷 pairing token, +// 不波及他 device、已撤銷不重複計、device_id NULL(未綁定)不被撤。 +func TestPGPairing_RevokeByDeviceTx(t *testing.T) { + ctx := context.Background() + s, tdb, owner := newPGPairingStore(t) + + dev1 := tdb.InsertDevice(t, "", owner) + dev2 := tdb.InsertDevice(t, "", owner) + + // dev1 兩個 token:p1 未撤、p2 已撤 + p1, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p1, dev1)) + p2, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p2, dev1)) + require.NoError(t, s.Revoke(ctx, p2)) + + // dev2 一個 token(不應被撤) + p3, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + require.NoError(t, s.MarkUsed(ctx, p3, dev2)) + + // 一個未綁 device 的 token(device_id IS NULL,不應被撤) + p4, _, err := s.Create(ctx, owner, 15*time.Minute) + require.NoError(t, err) + + revoked, err := s.RevokeByDeviceTx(ctx, tdb.Pool, dev1) + require.NoError(t, err) + assert.Equal(t, 1, revoked, "只有 p1 被撤;p2 已撤不計") + + _, err = s.Validate(ctx, p1) + assert.ErrorIs(t, err, ErrTokenRevoked) + // p3(dev2)仍可用(used 但未撤) + _, err = s.Validate(ctx, p3) + assert.ErrorIs(t, err, ErrTokenUsed) + // p4(未綁 device)仍有效 + _, err = s.Validate(ctx, p4) + require.NoError(t, err) + + // 空 deviceID 不撤 + n, err := s.RevokeByDeviceTx(ctx, tdb.Pool, "") + require.NoError(t, err) + assert.Equal(t, 0, n) +} diff --git a/visionA-backend/internal/auth/postgres_session_token_store.go b/visionA-backend/internal/auth/postgres_session_token_store.go new file mode 100644 index 0000000..d14d902 --- /dev/null +++ b/visionA-backend/internal/auth/postgres_session_token_store.go @@ -0,0 +1,219 @@ +// PostgresSessionTokenStore 是 SessionTokenStore 的 PostgreSQL 持久層實作(DB 接入塊 3)。 +// +// 與 InMemorySessionTokenStore 實作相同的 SessionTokenStore interface,讓 main.go 在 +// dbPool != nil 時無痛切換、呼叫端(internal/api/pairing.go 的 Create / Revoke)一行都不需改。 +// +// 對齊: +// - database.md §2.4(SessionToken struct)、§4(session_tokens 表 schema) +// - migrations/0003_create_token_tables.up.sql(session_tokens 表) +// +// ── 關鍵改動:plaintext → token_hash 當 PK(同 PostgresPairingStore)── +// +// Get / Revoke 接收 plaintext,內部先 HashToken(plaintext) 再以 hash 查詢。 +// 呼叫端統一傳 plaintext(已 grep 確認:pairing.go 的 SessionTokenStore.Create 用回傳的 +// plaintext、Revoke(ctx, plaintext) 傳 plaintext;目前無其他 production Get 呼叫端)。 +// DB 永不存明文 token(security.md §1.3)。 +// +// 語意對齊 in-memory(見 session_token.go): +// - SessionToken 無 used_at(非一次性)、無 kind。 +// - Get 狀態優先序:revoked → expired(與 in-memory 一致);不存在回 ErrInvalidToken。 +// - Revoke 冪等:未撤銷 → 寫 revoked_at;已撤銷 → no-op nil;不存在 → ErrInvalidToken。 +// - CleanupExpired:DELETE 所有 expires_at < now 的列,回刪除數。 +// - parent_token_hash 為稽核鏈欄位(升級來源 pairing token 的 hash),原樣存取。 +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/db" +) + +// PostgresSessionTokenStore 是 session token 的 PostgreSQL 持久層實作。 +type PostgresSessionTokenStore struct { + pool *pgxpool.Pool +} + +// NewPostgresSessionTokenStore 建立一個以 pgxpool 為後端的 SessionTokenStore。 +func NewPostgresSessionTokenStore(pool *pgxpool.Pool) *PostgresSessionTokenStore { + return &PostgresSessionTokenStore{pool: pool} +} + +// 編譯時檢查:確保 PostgresSessionTokenStore 實作 SessionTokenStore。 +var _ SessionTokenStore = (*PostgresSessionTokenStore)(nil) + +// sessionColumns 是 SELECT 共用欄位清單(順序必須與 scanSessionToken 對齊)。 +const sessionColumns = `token_hash, user_id, device_id, parent_token_hash, + created_at, expires_at, revoked_at` + +// Create 產生並保存一個新 session token。 +// +// ttl <= 0 時 ExpiresAt 保持 NULL(永不過期)。parentTokenHash 可為空(雛形 caller)。 +// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。 +func (s *PostgresSessionTokenStore) Create( + ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration, +) (string, *SessionToken, error) { + plaintext, err := GenerateSessionToken() + if err != nil { + return "", nil, err + } + + now := time.Now().UTC() + info := &SessionToken{ + Plaintext: plaintext, + TokenHash: HashToken(plaintext), + UserID: userID, + DeviceID: deviceID, + ParentTokenHash: parentTokenHash, + CreatedAt: now, + } + var expiresAt any // nil → DB NULL + if ttl > 0 { + expires := now.Add(ttl) + info.ExpiresAt = &expires + expiresAt = expires + } + + // device_id NOT NULL:空字串無法寫進 UUID 欄位,會在此回 DB error(符合 schema 約束)。 + // parent_token_hash nullable:空字串轉 NULL。 + var parentArg any + if parentTokenHash != "" { + parentArg = parentTokenHash + } + + const q = `INSERT INTO session_tokens + (token_hash, user_id, device_id, parent_token_hash, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6)` + if _, err := s.pool.Exec(ctx, q, + info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt, + ); err != nil { + return "", nil, fmt.Errorf("auth: pg session Create: %w", err) + } + return plaintext, info, nil +} + +// Get 依 plaintext 查詢 session token;內部 HashToken 後查。 +// +// 狀態優先序對齊 in-memory:revoked → expired。不存在回 ErrInvalidToken。 +func (s *PostgresSessionTokenStore) Get(ctx context.Context, plaintext string) (*SessionToken, error) { + hash := HashToken(plaintext) + + const q = `SELECT ` + sessionColumns + ` + FROM session_tokens WHERE token_hash = $1` + row := s.pool.QueryRow(ctx, q, hash) + info, err := scanSessionToken(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrInvalidToken + } + if err != nil { + return nil, fmt.Errorf("auth: pg session Get: %w", err) + } + + if info.RevokedAt != nil { + return nil, ErrTokenRevoked + } + if info.ExpiresAt != nil && time.Now().UTC().After(*info.ExpiresAt) { + return nil, ErrTokenExpired + } + return info, nil +} + +// Revoke 撤銷 session token(之後 Get 回 ErrTokenRevoked)。 +// +// 冪等:已撤銷 → no-op nil;不存在 → ErrInvalidToken。 +func (s *PostgresSessionTokenStore) Revoke(ctx context.Context, plaintext string) error { + hash := HashToken(plaintext) + + const q = `UPDATE session_tokens + SET revoked_at = now() + WHERE token_hash = $1 AND revoked_at IS NULL` + tag, err := s.pool.Exec(ctx, q, hash) + if err != nil { + return fmt.Errorf("auth: pg session Revoke: %w", err) + } + if tag.RowsAffected() == 1 { + return nil + } + + var exists bool + if err := s.pool.QueryRow(ctx, + `SELECT EXISTS(SELECT 1 FROM session_tokens WHERE token_hash = $1)`, hash, + ).Scan(&exists); err != nil { + return fmt.Errorf("auth: pg session Revoke exists check: %w", err) + } + if !exists { + return ErrInvalidToken + } + return nil // 已撤銷 → 冪等 no-op +} + +// CleanupExpired 移除所有已過 expires_at 的 token;回傳移除數量。 +// +// expires_at IS NULL(永不過期)不會被刪。 +func (s *PostgresSessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) { + const q = `DELETE FROM session_tokens + WHERE expires_at IS NOT NULL AND expires_at < $1` + tag, err := s.pool.Exec(ctx, q, now.UTC()) + if err != nil { + return 0, fmt.Errorf("auth: pg session CleanupExpired: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 session token(cascade 撤銷,塊 5.2)。 +// +// 在傳入的 Querier(pool 或 tx)上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1 +// AND revoked_at IS NULL`(database.md §6)。回傳實際撤銷的列數(觀測用,無對象回 0、不報錯)。 +// +// session_tokens.device_id 為 NOT NULL(每個 session token 必綁 device),故同一 device 的所有 +// 未撤銷 session token 都會被撤——這正是「刪 device → 該 device 不能再被任何長效 token 觸達」的目的。 +func (s *PostgresSessionTokenStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) { + if deviceID == "" { + return 0, nil + } + const sql = `UPDATE session_tokens + SET revoked_at = now() + WHERE device_id = $1 AND revoked_at IS NULL` + tag, err := q.Exec(ctx, sql, deviceID) + if err != nil { + return 0, fmt.Errorf("auth: pg session RevokeByDevice: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// scanSessionToken 從一列掃出 *SessionToken。欄位順序必須與 sessionColumns 對齊。 +// +// parent_token_hash nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value)。 +// 時間欄位正規化為 UTC。Plaintext 留空(DB 不存)。 +func scanSessionToken(row rowScanner) (*SessionToken, error) { + var ( + t SessionToken + parentHash *string + ) + err := row.Scan( + &t.TokenHash, + &t.UserID, + &t.DeviceID, + &parentHash, + &t.CreatedAt, + &t.ExpiresAt, + &t.RevokedAt, + ) + if err != nil { + return nil, err + } + + if parentHash != nil { + t.ParentTokenHash = *parentHash + } + + t.CreatedAt = t.CreatedAt.UTC() + t.ExpiresAt = utcPtr(t.ExpiresAt) + t.RevokedAt = utcPtr(t.RevokedAt) + return &t, nil +} diff --git a/visionA-backend/internal/auth/postgres_session_token_store_db_test.go b/visionA-backend/internal/auth/postgres_session_token_store_db_test.go new file mode 100644 index 0000000..30fd9e7 --- /dev/null +++ b/visionA-backend/internal/auth/postgres_session_token_store_db_test.go @@ -0,0 +1,353 @@ +//go:build dbtest + +// PostgresSessionTokenStore 的真 DB 整合測試(DB 接入塊 3,子任務 3.7 / 3.8 / 3.9 session 部分)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/auth/... +// +// 涵蓋: +// - 3.7 unit/邏輯:對齊既有 session_token_test.go(CreateAndGet、NotFound、expired、 +// Revoke 冪等、Revoke NotFound、CleanupExpired、NeverExpires ttl=0)。 +// - 3.8 integration/真 DB:hash 當 PK、parent_token_hash 稽核鏈 round-trip、撤銷稽核欄位。 +// - 3.9 邊界:併發 Get、CleanupExpired 大量、context cancel、device_id NOT NULL 約束。 +package auth + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// newPGSessionStore 啟動一次性測試 DB、truncate、確保 demo user + 一個 device 存在 +// (session_tokens.device_id NOT NULL FK),回傳 store + tdb + ownerID + deviceID。 +func newPGSessionStore(t *testing.T) (*PostgresSessionTokenStore, *testsupport.TestDB, string, string) { + t.Helper() + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") + owner := tdb.EnsureDemoUser(t) + deviceID := tdb.InsertDevice(t, "", owner) + return NewPostgresSessionTokenStore(tdb.Pool), tdb, owner, deviceID +} + +// --------------------------------------------------------------------------- +// 3.7 unit/邏輯(對齊 session_token_test.go 的 case) +// --------------------------------------------------------------------------- + +func TestPGSession_CreateAndGet(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + plain, info, err := s.Create(ctx, owner, deviceID, "parent-hash", SessionTokenTTL) + require.NoError(t, err) + assert.True(t, IsValidSessionToken(plain), "產出 token 應通過格式驗證:%s", plain) + require.NotNil(t, info) + assert.Equal(t, owner, info.UserID) + assert.Equal(t, deviceID, info.DeviceID) + assert.Equal(t, "parent-hash", info.ParentTokenHash) + require.NotNil(t, info.ExpiresAt) + assert.WithinDuration(t, time.Now().UTC().Add(SessionTokenTTL), *info.ExpiresAt, 2*time.Second) + assert.Equal(t, HashToken(plain), info.TokenHash, "PK 應為 plaintext 的 hash") + + got, err := s.Get(ctx, plain) + require.NoError(t, err) + assert.Equal(t, owner, got.UserID) + assert.Equal(t, info.TokenHash, got.TokenHash) + assert.Empty(t, got.Plaintext, "DB 不存明文 → 回傳 Plaintext 應為空") +} + +func TestPGSession_Get_NotFound(t *testing.T) { + s, _, _, _ := newPGSessionStore(t) + _, err := s.Get(context.Background(), "vAs_"+repeat64) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestPGSession_Get_Expired(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + plain, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond) + require.NoError(t, err) + + time.Sleep(5 * time.Millisecond) + _, err = s.Get(ctx, plain) + assert.ErrorIs(t, err, ErrTokenExpired) +} + +func TestPGSession_Revoke(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + plain, _, err := s.Create(ctx, owner, deviceID, "", SessionTokenTTL) + require.NoError(t, err) + require.NoError(t, s.Revoke(ctx, plain)) + + _, err = s.Get(ctx, plain) + assert.ErrorIs(t, err, ErrTokenRevoked) + + // 冪等:再撤一次不報錯 + assert.NoError(t, s.Revoke(ctx, plain)) +} + +func TestPGSession_Revoke_NotFound(t *testing.T) { + s, _, _, _ := newPGSessionStore(t) + err := s.Revoke(context.Background(), "vAs_"+repeat64) + assert.ErrorIs(t, err, ErrInvalidToken) +} + +func TestPGSession_CleanupExpired(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + expiredTok, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond) + require.NoError(t, err) + freshTok, _, err := s.Create(ctx, owner, deviceID, "", SessionTokenTTL) + require.NoError(t, err) + // ttl=0 → expires_at NULL,不應被清。 + neverTok, _, err := s.Create(ctx, owner, deviceID, "", 0) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + removed, err := s.CleanupExpired(ctx, time.Now().UTC()) + require.NoError(t, err) + assert.Equal(t, 1, removed) + + _, err = s.Get(ctx, expiredTok) + assert.ErrorIs(t, err, ErrInvalidToken) + _, err = s.Get(ctx, freshTok) + assert.NoError(t, err) + _, err = s.Get(ctx, neverTok) + assert.NoError(t, err, "永不過期(NULL expires_at)不應被清") +} + +func TestPGSession_NeverExpires(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + _, info, err := s.Create(ctx, owner, deviceID, "", 0) + require.NoError(t, err) + assert.Nil(t, info.ExpiresAt, "ttl=0 時 ExpiresAt 應為 nil") +} + +// --------------------------------------------------------------------------- +// 3.8 integration/真 DB +// --------------------------------------------------------------------------- + +// hash 當 PK:DB 內實際存的是 token_hash,明文不存在。 +func TestPGSession_HashIsPK_NoPlaintextStored(t *testing.T) { + ctx := context.Background() + s, tdb, owner, deviceID := newPGSessionStore(t) + + plain, info, err := s.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + + var stored string + err = tdb.Pool.QueryRow(ctx, + `SELECT token_hash FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&stored) + require.NoError(t, err) + assert.Equal(t, HashToken(plain), stored) + + var cnt int + err = tdb.Pool.QueryRow(ctx, + `SELECT count(*) FROM session_tokens WHERE token_hash = $1`, plain).Scan(&cnt) + require.NoError(t, err) + assert.Equal(t, 0, cnt, "明文不應出現為 PK") +} + +// parent_token_hash 稽核鏈:升級來源 pairing token 的 hash 正確 round-trip, +// 且可 join 回 pairing_tokens.token_hash(跨表稽核鏈)。 +func TestPGSession_ParentTokenHash_AuditChain(t *testing.T) { + ctx := context.Background() + s, tdb, owner, deviceID := newPGSessionStore(t) + ps := NewPostgresPairingStore(tdb.Pool) + + // 先建一個 pairing token,取其 hash 當 session 的 parent。 + _, pInfo, err := ps.Create(ctx, owner, time.Hour) + require.NoError(t, err) + + _, sInfo, err := s.Create(ctx, owner, deviceID, pInfo.TokenHash, time.Hour) + require.NoError(t, err) + assert.Equal(t, pInfo.TokenHash, sInfo.ParentTokenHash) + + // round-trip:Get 不回 parent(Get 走狀態檢查),改用直接 join 驗證稽核鏈可查。 + var joinedUser string + err = tdb.Pool.QueryRow(ctx, ` + SELECT p.user_id + FROM session_tokens s + JOIN pairing_tokens p ON p.token_hash = s.parent_token_hash + WHERE s.token_hash = $1`, sInfo.TokenHash).Scan(&joinedUser) + require.NoError(t, err, "應能透過 parent_token_hash join 回 pairing_tokens") + assert.Equal(t, owner, joinedUser) +} + +// parent_token_hash 為空 → 存 NULL,round-trip 為空字串。 +func TestPGSession_EmptyParent_RoundTrip(t *testing.T) { + ctx := context.Background() + s, tdb, owner, deviceID := newPGSessionStore(t) + + _, info, err := s.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + + var parent *string + err = tdb.Pool.QueryRow(ctx, + `SELECT parent_token_hash FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&parent) + require.NoError(t, err) + assert.Nil(t, parent, "空 parent 應存 NULL") +} + +// device_id NOT NULL 約束:空 device_id 應被 DB 拒絕(FK + NOT NULL)。 +func TestPGSession_RequiresDeviceID(t *testing.T) { + ctx := context.Background() + s, _, owner, _ := newPGSessionStore(t) + + _, _, err := s.Create(ctx, owner, "", "", time.Hour) + assert.Error(t, err, "session token device_id NOT NULL,空字串應被拒") +} + +// 撤銷稽核欄位:Revoke 後 revoked_at 非 NULL,冪等不覆寫。 +func TestPGSession_Revoke_AuditField(t *testing.T) { + ctx := context.Background() + s, tdb, owner, deviceID := newPGSessionStore(t) + + plain, info, err := s.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + require.NoError(t, s.Revoke(ctx, plain)) + + var revokedAt1 time.Time + err = tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt1) + require.NoError(t, err) + assert.False(t, revokedAt1.IsZero()) + + require.NoError(t, s.Revoke(ctx, plain)) + var revokedAt2 time.Time + err = tdb.Pool.QueryRow(ctx, + `SELECT revoked_at FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt2) + require.NoError(t, err) + assert.True(t, revokedAt1.Equal(revokedAt2), "冪等 Revoke 不應覆寫 revoked_at") +} + +// --------------------------------------------------------------------------- +// 3.9 邊界 +// --------------------------------------------------------------------------- + +// 併發 Get 同 token:純讀、不應 panic / race,全部成功。 +func TestPGSession_ConcurrentGet(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + plain, _, err := s.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + + const n = 30 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = s.Get(ctx, plain) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "併發 Get #%d", i) + } +} + +// CleanupExpired 大量資料:批次刪除正確。 +func TestPGSession_CleanupExpired_Bulk(t *testing.T) { + ctx := context.Background() + s, _, owner, deviceID := newPGSessionStore(t) + + const expiredN = 50 + for i := 0; i < expiredN; i++ { + _, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond) + require.NoError(t, err) + } + const freshN = 10 + for i := 0; i < freshN; i++ { + _, _, err := s.Create(ctx, owner, deviceID, "", time.Hour) + require.NoError(t, err) + } + + time.Sleep(20 * time.Millisecond) + removed, err := s.CleanupExpired(ctx, time.Now().UTC()) + require.NoError(t, err) + assert.Equal(t, expiredN, removed) + assert.Equal(t, freshN, tdbCountActive(ctx, t, s, owner, deviceID)) +} + +// context cancel:已取消 ctx 操作回 error。 +func TestPGSession_ContextCancel(t *testing.T) { + s, _, owner, deviceID := newPGSessionStore(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, _, err := s.Create(ctx, owner, deviceID, "", time.Hour) + assert.Error(t, err, "已取消 ctx 的 Create 應回 error") + + _, err = s.Get(ctx, "vAs_"+repeat64) + assert.Error(t, err, "已取消 ctx 的 Get 應回 error") + + err = s.Revoke(ctx, "vAs_"+repeat64) + assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error") +} + +// tdbCountActive 計算某 owner 仍未過期(CleanupExpired 後殘留)的 session token 數。 +// 用 CleanupExpired 後再跑一次 0-removed 的方式間接驗證殘留數。 +func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int { + t.Helper() + var n int + err := s.pool.QueryRow(ctx, + `SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n) + require.NoError(t, err) + return n +} + +// repeat64 是 64 個 'a',組出一個格式合法但不存在的 session token(vAs_ + 64 hex)。 +const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// --------------------------------------------------------------------------- +// 塊 5.2 cascade:RevokeByDeviceTx(session) +// --------------------------------------------------------------------------- + +// TestPGSession_RevokeByDeviceTx 驗證撤銷某 device 名下未撤銷 session token, +// 不波及他 device、已撤銷不重複計。 +func TestPGSession_RevokeByDeviceTx(t *testing.T) { + ctx := context.Background() + s, tdb, owner, dev1 := newPGSessionStore(t) + dev2 := tdb.InsertDevice(t, "", owner) + + s1, _, err := s.Create(ctx, owner, dev1, "", SessionTokenTTL) + require.NoError(t, err) + s2, _, err := s.Create(ctx, owner, dev1, "", SessionTokenTTL) + require.NoError(t, err) + require.NoError(t, s.Revoke(ctx, s2)) // 已撤 + + o1, _, err := s.Create(ctx, owner, dev2, "", SessionTokenTTL) + require.NoError(t, err) + + revoked, err := s.RevokeByDeviceTx(ctx, tdb.Pool, dev1) + require.NoError(t, err) + assert.Equal(t, 1, revoked) + + _, err = s.Get(ctx, s1) + assert.ErrorIs(t, err, ErrTokenRevoked) + _, err = s.Get(ctx, o1) + require.NoError(t, err) + + n, err := s.RevokeByDeviceTx(ctx, tdb.Pool, "") + require.NoError(t, err) + assert.Equal(t, 0, n) +} diff --git a/visionA-backend/internal/auth/session_token.go b/visionA-backend/internal/auth/session_token.go index 0d2359d..87e927c 100644 --- a/visionA-backend/internal/auth/session_token.go +++ b/visionA-backend/internal/auth/session_token.go @@ -141,6 +141,28 @@ func (s *InMemorySessionTokenStore) Revoke(ctx context.Context, plaintext string return nil } +// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 session token(cascade 撤銷,塊 5.2)。 +// +// in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token, +// 回傳實際撤銷數。空 deviceID 不撤任何 token。 +func (s *InMemorySessionTokenStore) RevokeByDevice(ctx context.Context, deviceID string) (int, error) { + if deviceID == "" { + return 0, nil + } + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now().UTC() + revoked := 0 + for _, info := range s.tokens { + if info.DeviceID == deviceID && info.RevokedAt == nil { + info.RevokedAt = &now + revoked++ + } + } + return revoked, nil +} + // CleanupExpired 移除所有已過期(ExpiresAt < now)的 token。 func (s *InMemorySessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) { s.mu.Lock() diff --git a/visionA-backend/internal/auth/session_token_test.go b/visionA-backend/internal/auth/session_token_test.go index 51f7143..ee1d956 100644 --- a/visionA-backend/internal/auth/session_token_test.go +++ b/visionA-backend/internal/auth/session_token_test.go @@ -107,3 +107,33 @@ func TestInMemorySessionTokenStore_NeverExpires(t *testing.T) { require.NoError(t, err) assert.Nil(t, info.ExpiresAt, "ttl=0 時 ExpiresAt 應為 nil") } + +// TestInMemorySessionTokenStore_RevokeByDevice 驗證 cascade 撤銷(塊 5.2): +// 只撤指定 device 名下未撤銷的 session token,回傳撤銷數。 +func TestInMemorySessionTokenStore_RevokeByDevice(t *testing.T) { + ctx := context.Background() + s := NewInMemorySessionTokenStore() + + s1, _, err := s.Create(ctx, "user-1", "dev-1", "", SessionTokenTTL) + require.NoError(t, err) + s2, _, err := s.Create(ctx, "user-1", "dev-1", "", SessionTokenTTL) + require.NoError(t, err) + require.NoError(t, s.Revoke(ctx, s2)) // 已撤,不重複計 + + // dev-2 不應被撤 + o1, _, err := s.Create(ctx, "user-1", "dev-2", "", SessionTokenTTL) + require.NoError(t, err) + + revoked, err := s.RevokeByDevice(ctx, "dev-1") + require.NoError(t, err) + assert.Equal(t, 1, revoked) + + _, err = s.Get(ctx, s1) + assert.ErrorIs(t, err, ErrTokenRevoked) + _, err = s.Get(ctx, o1) + assert.NoError(t, err) + + n, err := s.RevokeByDevice(ctx, "") + require.NoError(t, err) + assert.Equal(t, 0, n) +} diff --git a/visionA-backend/internal/config/config.go b/visionA-backend/internal/config/config.go index f324bbd..2b8f407 100644 --- a/visionA-backend/internal/config/config.go +++ b/visionA-backend/internal/config/config.go @@ -26,6 +26,12 @@ type Config struct { Conversion ConversionConfig // FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈(ADR-017 (a))。 FileAccess FileAccessConfig + // Database 控制 PostgreSQL 連線(DB 接入塊 0;持久業務資料 model / device / token)。 + // 對齊 docs/autoflow/04-architecture/database.md §5.5.1。 + Database DatabaseConfig + // Redis 控制 Redis 連線(DB 接入塊 4;僅 userSession browser cookie session)。 + // 對齊 docs/autoflow/04-architecture/database.md §5.5.2。塊 0 不 wire,只先把 config 鉤子留好。 + Redis RedisConfig } // ServerConfig 控制 HTTP listener 的位址與埠號。 @@ -298,6 +304,69 @@ func (c ConversionConfig) Enabled() bool { return c.ConverterBaseURL != "" && c.ConverterAPIKey != "" } +// DatabaseConfig 控制 PostgreSQL 連線(持久業務資料:model / device / token)。 +// +// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。 +// +// 啟用判定(Enabled()):Host / User / DBName 三者全非空才視為啟用; +// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memory(local dev fallback)。 +// +// 安全:Password / DSN 永遠不印 log 全文(可印 host:port/dbname); +// 部署走既有 secrets 機制(AWS Secrets Manager / Vault),禁止 commit 進 repo。 +type DatabaseConfig struct { + Host string // VISIONA_DB_HOST + Port int // VISIONA_DB_PORT,預設 5432 + User string // VISIONA_DB_USER + Password string // VISIONA_DB_PASSWORD(禁止 commit) + DBName string // VISIONA_DB_NAME + SSLMode string // VISIONA_DB_SSLMODE,預設 "require"(stage/prod);本機 testcontainers 用 "disable" + + // 連線池(pgxpool) + MaxConns int // VISIONA_DB_MAX_CONNS,預設 10 + MinConns int // VISIONA_DB_MIN_CONNS,預設 2 + MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME,預設 1h + ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT,預設 5s(建池/ping 逾時) + + // AutoMigrate 控制連線池建立後是否自動跑 migrate up。 + // 預設 true;可由 VISIONA_DB_AUTO_MIGRATE=false 關閉(例如改用獨立 cmd/migrate)。 + AutoMigrate bool // VISIONA_DB_AUTO_MIGRATE,預設 true +} + +// Enabled 回傳 PostgreSQL 是否啟用。 +// +// Host / User / DBName 三者全非空才視為啟用;任一缺 → +// main.go 不建連線池,model / device / token repository 維持 in-memory(local dev fallback)。 +func (c DatabaseConfig) Enabled() bool { + return c.Host != "" && c.User != "" && c.DBName != "" +} + +// RedisConfig 控制 Redis 連線(僅 userSession:browser cookie session)。 +// +// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。 +// +// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。 +// visionA 端不 provision、只接上。 +// +// 啟用判定(Enabled()):Host 非空才視為啟用; +// 未啟用 → userSession 仍用 in-memory(雛形行為,process 重啟掉 session)。 +// +// 安全:Password 永遠不印 log 全文。禁止 commit 進 repo。 +// +// 塊 0 範圍:只先把 config 鉤子留好,main.go 尚未 wire(等塊 4 接 RedisUserSessionStore)。 +type RedisConfig struct { + Host string // VISIONA_REDIS_HOST + Port int // VISIONA_REDIS_PORT,預設 6379 + Password string // VISIONA_REDIS_PASSWORD(visionA 專用實例必設密碼;禁止 commit) + DB int // VISIONA_REDIS_DB,預設 0(db index) + + ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT,預設 5s +} + +// Enabled 回傳 Redis 是否啟用(Host 非空即啟用)。 +func (c RedisConfig) Enabled() bool { + return c.Host != "" +} + // CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。 // // AllowedOrigins 為逗號分隔字串解析後的 slice; diff --git a/visionA-backend/internal/config/load.go b/visionA-backend/internal/config/load.go index fbcaaab..b21dd0b 100644 --- a/visionA-backend/internal/config/load.go +++ b/visionA-backend/internal/config/load.go @@ -92,6 +92,31 @@ func Load() *Config { FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""), DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120), }, + // DB 接入塊 0:PostgreSQL 連線(持久業務資料)。 + // 對齊 docs/autoflow/04-architecture/database.md §5.5.1。 + // 留空(Host/User/DBName 任一缺)→ Enabled()=false → main.go 不建池、維持 in-memory。 + Database: DatabaseConfig{ + Host: getEnvString("VISIONA_DB_HOST", ""), + Port: getEnvInt("VISIONA_DB_PORT", 5432), + User: getEnvString("VISIONA_DB_USER", ""), + Password: getEnvString("VISIONA_DB_PASSWORD", ""), + DBName: getEnvString("VISIONA_DB_NAME", ""), + SSLMode: getEnvString("VISIONA_DB_SSLMODE", "require"), + MaxConns: getEnvInt("VISIONA_DB_MAX_CONNS", 10), + MinConns: getEnvInt("VISIONA_DB_MIN_CONNS", 2), + MaxConnLifetime: getEnvDuration("VISIONA_DB_MAX_CONN_LIFETIME", time.Hour), + ConnTimeout: getEnvDuration("VISIONA_DB_CONN_TIMEOUT", 5*time.Second), + AutoMigrate: getEnvBool("VISIONA_DB_AUTO_MIGRATE", true), + }, + // DB 接入塊 4(鉤子先留,塊 0 不 wire):Redis 連線(僅 userSession)。 + // 對齊 docs/autoflow/04-architecture/database.md §5.5.2。 + Redis: RedisConfig{ + Host: getEnvString("VISIONA_REDIS_HOST", ""), + Port: getEnvInt("VISIONA_REDIS_PORT", 6379), + Password: getEnvString("VISIONA_REDIS_PASSWORD", ""), + DB: getEnvInt("VISIONA_REDIS_DB", 0), + ConnTimeout: getEnvDuration("VISIONA_REDIS_CONN_TIMEOUT", 5*time.Second), + }, } } diff --git a/visionA-backend/internal/db/db_integration_test.go b/visionA-backend/internal/db/db_integration_test.go new file mode 100644 index 0000000..0adad0a --- /dev/null +++ b/visionA-backend/internal/db/db_integration_test.go @@ -0,0 +1,147 @@ +//go:build dbtest + +// DB 接入塊 0(0.8):連線池 / migration 本身的整合測試,走 testcontainers(真 Postgres 14.23)。 +// +// build tag `dbtest`:需要 Docker daemon。預設 `go test ./...` 不編譯本檔。 +// 執行:go test -tags=dbtest ./internal/db/... +package db_test + +import ( + "context" + "io" + "log/slog" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/config" + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" +) + +func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } + +// TestPool_PingAfterSetup 驗證 setupTestDB 後連線池可 ping 通。 +func TestPool_PingAfterSetup(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + require.NoError(t, tdb.Pool.Ping(context.Background())) +} + +// TestMigrate_Idempotent 驗證 migrate up 冪等:跑第二次為 no-change、版本不變、not dirty。 +// +// 不寫死版本號——冪等的本質是「再 up 一次 version 不變」,不是「version == 某固定數字」。 +// 以 SetupTestDB 跑完第一次 up 後的版本當基準,驗第二次 up 後版本與其一致。 +// 如此未來再加 0004/0005 也不會壞。 +func TestMigrate_Idempotent(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + // SetupTestDB 已跑過一次 up,記下當下版本當基準(= 最新可用 migration 版本)。 + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + baseVer, dirty, err := mg.Version() + require.NoError(t, err) + require.False(t, dirty, "第一次 up 後不應 dirty") + require.Greater(t, baseVer, uint(0), "第一次 up 後版本應 > 0") + + // 再跑一次 up 應為 no-change(不報錯)。 + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog())) + + ver, dirty, err := mg.Version() + require.NoError(t, err) + assert.False(t, dirty, "schema 不應處於 dirty 狀態") + assert.Equal(t, baseVer, ver, "再 up 一次版本不應改變(冪等)") +} + +// TestMigrate_UpDownUp 驗證 down 後 up 仍可回到原狀態(雙向 migration 正確)。 +// +// 注意:Migrator.Down() 實作為 m.Steps(-1),是「回退一個版本」而非「回滾全部」。 +// 因此本測試驗的是版本層級的可逆性:down 後版本 -1、再 up 後版本回到原值且 not dirty。 +// 不依賴特定 migration 建立的表名 / 版本號,未來再加 0004/0005 也不會壞。 +func TestMigrate_UpDownUp(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + + mg, err := db.NewMigrator(tdb.Cfg, discardLog()) + require.NoError(t, err) + defer mg.Close() + + // SetupTestDB 已 up 到最新,記下原始版本。 + topVer, dirty, err := mg.Version() + require.NoError(t, err) + require.False(t, dirty, "起始不應 dirty") + require.Greater(t, topVer, uint(0), "起始版本應 > 0") + + // down 一步(Steps(-1):只回退最新一個 migration)。 + require.NoError(t, mg.Down()) + + downVer, dirty, err := mg.Version() + require.NoError(t, err) + assert.False(t, dirty, "down 後不應 dirty") + assert.Equal(t, topVer-1, downVer, "down 一步後版本應 -1") + + // 再 up 一次應回到原始最新版本(雙向可逆)。 + require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog())) + + upVer, dirty, err := mg.Version() + require.NoError(t, err) + assert.False(t, dirty, "重新 up 後不應 dirty") + assert.Equal(t, topVer, upVer, "重新 up 後版本應回到原始最新版本") +} + +// TestMigrate_SchemaShape 抽查 0001 的關鍵 schema 特性(gen_random_uuid 預設、FK、partial index)。 +func TestMigrate_SchemaShape(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + ctx := context.Background() + + // users.id 預設應能用 gen_random_uuid()(PG14 內建)插入不指定 id。 + var uid string + err := tdb.Pool.QueryRow(ctx, + `INSERT INTO users (email) VALUES ($1) RETURNING id`, "shape@test.local").Scan(&uid) + require.NoError(t, err, "insert user with default uuid") + assert.NotEmpty(t, uid) + + // models.owner_user_id FK:插入不存在的 owner 應失敗。 + _, err = tdb.Pool.Exec(ctx, + `INSERT INTO models (owner_user_id, name, storage_key, file_size, source) + VALUES ($1, $2, $3, $4, $5)`, + "99999999-9999-9999-9999-999999999999", "m", "k", 1, "uploaded") + assert.Error(t, err, "FK 違反應報錯") + + // 用合法 owner 插入應成功,array 欄位 round-trip。 + var mid string + err = tdb.Pool.QueryRow(ctx, + `INSERT INTO models (owner_user_id, name, storage_key, file_size, source, classes, input_shape) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, + uid, "m1", "models/x.nef", 123, "uploaded", []string{"cat", "dog"}, []int32{1, 224, 224, 3}).Scan(&mid) + require.NoError(t, err) + assert.NotEmpty(t, mid) +} + +// TestPool_FailFast 驗證連到不存在的 DB 會 fail-fast(建池/ping 回 error,不 hang)。 +func TestPool_FailFast(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "127.0.0.1", + Port: 1, // 不可能有 postgres 監聽的 port + User: "nobody", + Password: "nopw", + DBName: "nodb", + SSLMode: "disable", + ConnTimeout: 2 * time.Second, + } + start := time.Now() + _, err := db.NewPool(context.Background(), cfg, discardLog()) + require.Error(t, err, "連不上 DB 應回 error") + assert.Less(t, time.Since(start), 10*time.Second, "應在 ConnTimeout 內 fail-fast,不 hang") +} + +// TestTruncate 驗證 truncate helper 清空資料。 +func TestTruncate(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.EnsureDemoUser(t) + assert.Equal(t, 1, tdb.CountRows(t, "users")) + tdb.Truncate(t, "models", "users") + assert.Equal(t, 0, tdb.CountRows(t, "users")) +} diff --git a/visionA-backend/internal/db/dsn.go b/visionA-backend/internal/db/dsn.go new file mode 100644 index 0000000..1d02c52 --- /dev/null +++ b/visionA-backend/internal/db/dsn.go @@ -0,0 +1,89 @@ +// Package db 提供 visionA-backend 的 PostgreSQL 連線池(pgxpool)與 migration runner。 +// +// DB 接入塊 0(DB 基礎建設):本套件只負責「連線池建池 + 啟動 ping + graceful shutdown + +// migration」這些跨 domain 共用的基礎設施;不含任何 store / repository 的 Postgres 實作 +// (那是塊 1–3,會新增各 domain 的 postgres_repository.go,實作既有 interface)。 +// +// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5。 +package db + +import ( + "fmt" + "net/url" + "strconv" + + "visiona-backend/internal/config" +) + +// BuildDSN 從 DatabaseConfig 組出 pgxpool 用的 PostgreSQL DSN。 +// +// 格式(對齊 database.md §5.5.1): +// +// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns}&pool_min_conns={MinConns} +// +// User / Password 走 url.UserPassword 做 percent-encoding,避免密碼含特殊字元(@ : / ?)破壞 DSN。 +// +// 安全:回傳值含明文密碼,呼叫端**不可**將其寫入 log。需要在 log 標識連線目標時, +// 改用 SafeTarget()(只含 host:port/dbname、不含密碼)。 +func BuildDSN(c config.DatabaseConfig) string { + u := baseURL(c) + + q := baseQuery(c) + // pgxpool 專屬參數:只有 pgxpool 認得,golang-migrate 的標準 database/sql 連線會被 PG + // server 端拒絕(FATAL: unrecognized configuration parameter "pool_max_conns"), + // 故這些參數**只**加在 BuildDSN(pgxpool 路徑),不加在 baseDSN(migrate 路徑)。 + if c.MaxConns > 0 { + q.Set("pool_max_conns", strconv.Itoa(c.MaxConns)) + } + if c.MinConns > 0 { + q.Set("pool_min_conns", strconv.Itoa(c.MinConns)) + } + u.RawQuery = q.Encode() + + return u.String() +} + +// baseDSN 組「只含標準 libpq 連線參數」的 PostgreSQL DSN(host/port/user/password/dbname/sslmode), +// 不含任何 pgxpool 專屬參數(pool_max_conns / pool_min_conns)。 +// +// 供 golang-migrate 的 pgx5 driver 使用:它走標準 database/sql 單連線,PG server 端不認得 +// pool_* 參數會直接 FATAL,故 migrate 路徑必須走這個「乾淨」DSN。 +func baseDSN(c config.DatabaseConfig) string { + u := baseURL(c) + u.RawQuery = baseQuery(c).Encode() + return u.String() +} + +// baseURL 組 DSN 的 scheme / 帳密 / host / path 部分(不含 query)。 +func baseURL(c config.DatabaseConfig) url.URL { + return url.URL{ + Scheme: "postgres", + User: url.UserPassword(c.User, c.Password), + Host: net_JoinHostPort(c.Host, c.Port), + Path: "/" + c.DBName, + } +} + +// baseQuery 組標準 libpq 連線參數(目前只有 sslmode)。pgxpool 與 migrate 共用這組基礎參數。 +func baseQuery(c config.DatabaseConfig) url.Values { + q := url.Values{} + sslMode := c.SSLMode + if sslMode == "" { + sslMode = "require" + } + q.Set("sslmode", sslMode) + return q +} + +// SafeTarget 回傳「可安全寫入 log」的連線目標字串(host:port/dbname),不含使用者名稱或密碼。 +func SafeTarget(c config.DatabaseConfig) string { + return fmt.Sprintf("%s/%s", net_JoinHostPort(c.Host, c.Port), c.DBName) +} + +// net_JoinHostPort 是 net.JoinHostPort 的小包裝(避免在多檔 import net 只為一個呼叫)。 +func net_JoinHostPort(host string, port int) string { + if port == 0 { + port = 5432 + } + return host + ":" + strconv.Itoa(port) +} diff --git a/visionA-backend/internal/db/dsn_test.go b/visionA-backend/internal/db/dsn_test.go new file mode 100644 index 0000000..6962009 --- /dev/null +++ b/visionA-backend/internal/db/dsn_test.go @@ -0,0 +1,132 @@ +package db + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "visiona-backend/internal/config" +) + +// DSN / SafeTarget 為純函式,不需 DB,故不帶 dbtest tag,預設 go test 即執行。 + +func TestBuildDSN_Basic(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "db.example.com", + Port: 5432, + User: "appuser", + Password: "s3cret", + DBName: "visiona", + SSLMode: "require", + MaxConns: 10, + MinConns: 2, + } + dsn := BuildDSN(cfg) + + assert.True(t, strings.HasPrefix(dsn, "postgres://"), "scheme 應為 postgres://,got %s", dsn) + assert.Contains(t, dsn, "appuser:s3cret@db.example.com:5432/visiona") + assert.Contains(t, dsn, "sslmode=require") + assert.Contains(t, dsn, "pool_max_conns=10") + assert.Contains(t, dsn, "pool_min_conns=2") +} + +func TestBuildDSN_PasswordWithSpecialChars(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "localhost", + Port: 5432, + User: "user@org", + Password: "p@ss:w/rd?x", + DBName: "db", + SSLMode: "disable", + } + dsn := BuildDSN(cfg) + + // 特殊字元應被 percent-encode,不破壞 DSN 結構(仍能被解析)。 + assert.NotContains(t, dsn, "p@ss:w/rd?x", "密碼特殊字元應被 encode") + assert.Contains(t, dsn, "sslmode=disable") +} + +func TestBuildDSN_DefaultSSLMode(t *testing.T) { + cfg := config.DatabaseConfig{Host: "h", Port: 5432, User: "u", DBName: "d"} + dsn := BuildDSN(cfg) + assert.Contains(t, dsn, "sslmode=require", "SSLMode 留空應預設 require") +} + +func TestSafeTarget_NoCredentials(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "db.example.com", + Port: 5432, + User: "appuser", + Password: "topsecret", + DBName: "visiona", + } + target := SafeTarget(cfg) + + assert.Equal(t, "db.example.com:5432/visiona", target) + assert.NotContains(t, target, "appuser", "SafeTarget 不應含 user") + assert.NotContains(t, target, "topsecret", "SafeTarget 不應含密碼") +} + +func TestSafeTarget_DefaultPort(t *testing.T) { + cfg := config.DatabaseConfig{Host: "h", DBName: "d"} + assert.Equal(t, "h:5432/d", SafeTarget(cfg)) +} + +func TestDatabaseConfig_Enabled(t *testing.T) { + cases := []struct { + name string + cfg config.DatabaseConfig + want bool + }{ + {"all set", config.DatabaseConfig{Host: "h", User: "u", DBName: "d"}, true}, + {"missing host", config.DatabaseConfig{User: "u", DBName: "d"}, false}, + {"missing user", config.DatabaseConfig{Host: "h", DBName: "d"}, false}, + {"missing dbname", config.DatabaseConfig{Host: "h", User: "u"}, false}, + {"empty", config.DatabaseConfig{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.cfg.Enabled()) + }) + } +} + +func TestRedisConfig_Enabled(t *testing.T) { + assert.True(t, config.RedisConfig{Host: "h"}.Enabled()) + assert.False(t, config.RedisConfig{}.Enabled()) +} + +// migrateDSN 應把 postgres:// scheme 去掉(golang-migrate pgx5 driver 期望的形式)。 +func TestMigrateDSN_StripsScheme(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "h", Port: 5432, User: "u", Password: "pw", DBName: "d", SSLMode: "disable", + ConnTimeout: 5 * time.Second, + } + got := migrateDSN(cfg) + assert.False(t, strings.HasPrefix(got, "postgres://"), "migrateDSN 不應保留 postgres:// prefix,got %s", got) + assert.Contains(t, got, "@h:5432/d") +} + +// Regression(塊 0 修正):migrateDSN 絕不可帶 pgxpool 專屬參數。 +// golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端不認得 +// pool_max_conns / pool_min_conns,會直接 FATAL(SQLSTATE 42704)導致所有 migration 失敗。 +// 這個無 docker 也能跑的純函式測試,確保不會再 regression。 +func TestMigrateDSN_NoPoolParams(t *testing.T) { + cfg := config.DatabaseConfig{ + Host: "h", Port: 5432, User: "u", Password: "pw", DBName: "d", SSLMode: "disable", + MaxConns: 10, MinConns: 2, + } + got := migrateDSN(cfg) + + assert.NotContains(t, got, "pool_max_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_max_conns,got %s", got) + assert.NotContains(t, got, "pool_min_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_min_conns,got %s", got) + // 標準 libpq 參數仍須正確帶上。 + assert.Contains(t, got, "sslmode=disable", "migrateDSN 應保留 sslmode,got %s", got) + + // 對照組:BuildDSN(pgxpool 路徑)仍應帶 pool 參數。 + pool := BuildDSN(cfg) + assert.Contains(t, pool, "pool_max_conns=10", "BuildDSN 應保留 pool_max_conns") + assert.Contains(t, pool, "pool_min_conns=2", "BuildDSN 應保留 pool_min_conns") +} diff --git a/visionA-backend/internal/db/migrate.go b/visionA-backend/internal/db/migrate.go new file mode 100644 index 0000000..126614c --- /dev/null +++ b/visionA-backend/internal/db/migrate.go @@ -0,0 +1,144 @@ +package db + +import ( + "errors" + "fmt" + "io/fs" + "log/slog" + + "github.com/golang-migrate/migrate/v4" + migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + + "visiona-backend/internal/config" + "visiona-backend/migrations" +) + +// migrationsPath 是 iofs source 內的子路徑。embed FS 以 migrations 套件目錄為根, +// SQL 檔直接在根(embed.go 的 `//go:embed *.sql`),故路徑為 "."。 +const migrationsPath = "." + +// Migrator 包裝 golang-migrate,提供 visionA-backend 統一的 migration 進出點。 +// +// 使用嵌入式 migration(migrations.FS)+ pgx/v5 database driver。 +// 每個 Migrator 持有自己的 *migrate.Migrate,用完應 Close()。 +type Migrator struct { + m *migrate.Migrate + log *slog.Logger +} + +// NewMigrator 從 DatabaseConfig 建立 Migrator(連到 cfg 指定的 DB)。 +// +// 注意:golang-migrate 自己開一條連線(與 pgxpool 分離),完成後 Close() 釋放。 +// 這是 golang-migrate 的設計(migration 需要獨佔的 advisory lock 連線)。 +func NewMigrator(cfg config.DatabaseConfig, log *slog.Logger) (*Migrator, error) { + if log == nil { + log = slog.Default() + } + src, err := iofs.New(migrations.FS, migrationsPath) + if err != nil { + return nil, fmt.Errorf("db: open embedded migrations source: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://"+migrateDSN(cfg)) + if err != nil { + return nil, fmt.Errorf("db: init migrator (target=%s): %w", SafeTarget(cfg), err) + } + return &Migrator{m: m, log: log}, nil +} + +// newMigratorFromDSN 直接以完整 DSN 建立 Migrator,供整合測試(testcontainers 給 raw DSN)使用。 +func newMigratorFromDSN(rawDSN string, log *slog.Logger) (*Migrator, error) { + if log == nil { + log = slog.Default() + } + src, err := iofs.New(migrations.FS, migrationsPath) + if err != nil { + return nil, fmt.Errorf("db: open embedded migrations source: %w", err) + } + m, err := migrate.NewWithSourceInstance("iofs", src, rawDSN) + if err != nil { + return nil, fmt.Errorf("db: init migrator from dsn: %w", err) + } + return &Migrator{m: m, log: log}, nil +} + +// Up 套用所有尚未執行的 migration。 +// +// 冪等:已是最新版(migrate.ErrNoChange)視為成功、不回 error。 +func (mg *Migrator) Up() error { + err := mg.m.Up() + if err != nil && !errors.Is(err, migrate.ErrNoChange) { + return fmt.Errorf("db: migrate up: %w", err) + } + if errors.Is(err, migrate.ErrNoChange) { + mg.log.Info("migrate up: no change (already at latest version)") + } else { + ver, _, _ := mg.m.Version() + mg.log.Info("migrate up: applied", "version", ver) + } + return nil +} + +// Down 回退一個版本(給整合測試 / cmd/migrate 用,正常啟動流程不呼叫)。 +func (mg *Migrator) Down() error { + err := mg.m.Steps(-1) + if err != nil && !errors.Is(err, migrate.ErrNoChange) && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("db: migrate down one step: %w", err) + } + return nil +} + +// Version 回傳目前 schema 版本與是否處於 dirty 狀態。 +// 尚無任何 migration 時回 (0, false, migrate.ErrNilVersion)。 +func (mg *Migrator) Version() (uint, bool, error) { + return mg.m.Version() +} + +// Close 釋放 migrator 的 source 與 database 連線。 +func (mg *Migrator) Close() error { + srcErr, dbErr := mg.m.Close() + if srcErr != nil { + return srcErr + } + return dbErr +} + +// RunMigrations 是「建好池後自動 migrate up」的便利函式,供 main.go 在啟動流程呼叫。 +// +// 它自建一個 Migrator、跑 Up、再 Close,呼叫端不需管 Migrator 生命週期。 +func RunMigrations(cfg config.DatabaseConfig, log *slog.Logger) error { + mg, err := NewMigrator(cfg, log) + if err != nil { + return err + } + defer func() { + if cerr := mg.Close(); cerr != nil { + log.Warn("migrator close error", "error", cerr) + } + }() + return mg.Up() +} + +// migrateDSN 組 golang-migrate pgx5 driver 用的 DSN(不含 pgx5:// scheme prefix, +// 由呼叫端拼上)。 +// +// 關鍵:golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端**不認得** +// pgxpool 專屬參數(pool_max_conns / pool_min_conns)會直接 FATAL +// (unrecognized configuration parameter, SQLSTATE 42704)。因此這裡走 baseDSN(只含 +// 標準 libpq 參數 host/port/user/password/dbname/sslmode),**不**走 BuildDSN。 +// +// golang-migrate pgx5 driver 接受 "pgx5://user:pass@host:port/db?sslmode=..." 形式, +// 故把 baseDSN 的 "postgres://" scheme 去掉、由 NewMigrator 拼回 "pgx5://"。 +func migrateDSN(cfg config.DatabaseConfig) string { + dsn := baseDSN(cfg) + // baseDSN 回 "postgres://...", 去掉 "postgres://" 留下 "user:pass@host/db?..."。 + const prefix = "postgres://" + if len(dsn) >= len(prefix) && dsn[:len(prefix)] == prefix { + return dsn[len(prefix):] + } + return dsn +} + +// 確保 driver 被 link 進 binary(golang-migrate 用 blank import 註冊 driver; +// 這裡顯式 import + 引用避免被當 unused)。 +var _ = migratepgx.ErrNilConfig diff --git a/visionA-backend/internal/db/pool.go b/visionA-backend/internal/db/pool.go new file mode 100644 index 0000000..25ef20c --- /dev/null +++ b/visionA-backend/internal/db/pool.go @@ -0,0 +1,108 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/config" +) + +// Pool 包裝 pgxpool.Pool,提供 visionA-backend 統一的連線池進出點。 +// +// 設計: +// - 薄包裝,不隱藏 *pgxpool.Pool(repository 實作直接拿 .Pool() 用 pgx API)。 +// - 持有建池時的 config 供 log / health check 標識連線目標(不含密碼)。 +// - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。 +type Pool struct { + pool *pgxpool.Pool + cfg config.DatabaseConfig + log *slog.Logger +} + +// NewPool 依 DatabaseConfig 建立 pgxpool 連線池,並在啟動時跑一次 ping 確認 DB 可達。 +// +// fail-fast 語意:ping 失敗即回 error,由 main.go 決定是 fatal(DB 啟用時連不上應該停機)。 +// 這避免「DB 設定了卻連不上」卻靜默 fallback in-memory 造成資料不一致的隱患。 +// +// 逾時:建池與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。 +// +// 安全:log 只印 SafeTarget(host:port/dbname),DSN 與密碼永遠不入 log。 +func NewPool(ctx context.Context, cfg config.DatabaseConfig, log *slog.Logger) (*Pool, error) { + if log == nil { + log = slog.Default() + } + + dsn := BuildDSN(cfg) + poolCfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + // 不把 err 直接外露 DSN(pgx ParseConfig error 不含密碼,但保守起見只回固定訊息)。 + return nil, fmt.Errorf("db: parse pool config failed: %w", err) + } + + // 套用連線池參數(ParseConfig 已從 DSN query 解析 pool_max_conns/pool_min_conns, + // 此處再以 config 值覆寫,確保 DSN 與 config 不一致時以 config 為準)。 + if cfg.MaxConns > 0 { + poolCfg.MaxConns = int32(cfg.MaxConns) + } + if cfg.MinConns > 0 { + poolCfg.MinConns = int32(cfg.MinConns) + } + if cfg.MaxConnLifetime > 0 { + poolCfg.MaxConnLifetime = cfg.MaxConnLifetime + } + + connTimeout := cfg.ConnTimeout + if connTimeout <= 0 { + connTimeout = 5 * time.Second + } + + buildCtx, cancel := context.WithTimeout(ctx, connTimeout) + defer cancel() + + pgPool, err := pgxpool.NewWithConfig(buildCtx, poolCfg) + if err != nil { + return nil, fmt.Errorf("db: create pool failed (target=%s): %w", SafeTarget(cfg), err) + } + + // 啟動 ping:確認 DB 真的可達(NewWithConfig 不會立即連線)。 + pingCtx, pingCancel := context.WithTimeout(ctx, connTimeout) + defer pingCancel() + if err := pgPool.Ping(pingCtx); err != nil { + pgPool.Close() + return nil, fmt.Errorf("db: ping failed (target=%s): %w", SafeTarget(cfg), err) + } + + log.Info("postgres pool initialized", + "target", SafeTarget(cfg), + "sslmode", cfg.SSLMode, + "max_conns", poolCfg.MaxConns, + "min_conns", poolCfg.MinConns, + "max_conn_lifetime", poolCfg.MaxConnLifetime, + ) + + return &Pool{pool: pgPool, cfg: cfg, log: log}, nil +} + +// Pool 回傳底層 *pgxpool.Pool,供 repository 實作使用 pgx API。 +func (p *Pool) Pool() *pgxpool.Pool { + return p.pool +} + +// Ping 對 DB 跑一次連線檢查,供 /healthz 等 health check 用。 +func (p *Pool) Ping(ctx context.Context) error { + return p.pool.Ping(ctx) +} + +// Close 關閉連線池(graceful shutdown)。等待所有 checked-out 連線歸還。 +// 重複呼叫安全(pgxpool.Close 內部冪等)。 +func (p *Pool) Close() { + if p == nil || p.pool == nil { + return + } + p.log.Info("closing postgres pool", "target", SafeTarget(p.cfg)) + p.pool.Close() +} diff --git a/visionA-backend/internal/db/redis.go b/visionA-backend/internal/db/redis.go new file mode 100644 index 0000000..8928d7f --- /dev/null +++ b/visionA-backend/internal/db/redis.go @@ -0,0 +1,106 @@ +package db + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/redis/go-redis/v9" + + "visiona-backend/internal/config" +) + +// RedisClient 包裝 go-redis 的 *redis.Client,提供 visionA-backend 統一的 Redis 進出點。 +// +// DB 接入塊 4(userSession 接 Redis):本型別只負責「建連線 + 啟動 ping + graceful close」 +// 這些基礎設施;userSession 的 store 邏輯在 internal/usersession/redis.go(RedisUserSessionStore)。 +// +// 設計對齊 pool.go(Postgres Pool)的風格: +// - 薄包裝,不隱藏底層 client(store 實作直接拿 .Client() 用 go-redis API)。 +// - 持有建連時的 config 供 log / health check 標識連線目標(不含密碼)。 +// - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。 +// +// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。 +type RedisClient struct { + client *redis.Client + cfg config.RedisConfig + log *slog.Logger +} + +// NewRedisClient 依 RedisConfig 建立 go-redis client,並在啟動時跑一次 ping 確認可達。 +// +// fail-fast 語意:ping 失敗即回 error,由 main.go 決定是否 fatal +// (Redis 啟用時連不上,與 Postgres 同樣應停機,而非靜默 fallback in-memory 造成行為不一致)。 +// +// 逾時:建連與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。 +// +// 安全:log 只印 SafeRedisTarget(host:port/db);Password 永遠不入 log。 +// visionA 專用 Redis 可能無密碼(stage 內網),Enabled() 只看 Host,故此處不強制 Password。 +func NewRedisClient(ctx context.Context, cfg config.RedisConfig, log *slog.Logger) (*RedisClient, error) { + if log == nil { + log = slog.Default() + } + + connTimeout := cfg.ConnTimeout + if connTimeout <= 0 { + connTimeout = 5 * time.Second + } + + port := cfg.Port + if port == 0 { + port = 6379 + } + + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.Host, port), + Password: cfg.Password, // 空字串 = 無密碼(visionA 專用 Redis stage 內網可不設密碼) + DB: cfg.DB, + DialTimeout: connTimeout, + ReadTimeout: connTimeout, + WriteTimeout: connTimeout, + }) + + // 啟動 ping:確認 Redis 真的可達(NewClient 不會立即連線)。 + pingCtx, cancel := context.WithTimeout(ctx, connTimeout) + defer cancel() + if err := client.Ping(pingCtx).Err(); err != nil { + _ = client.Close() + return nil, fmt.Errorf("db: redis ping failed (target=%s): %w", SafeRedisTarget(cfg), err) + } + + log.Info("redis client initialized", + "target", SafeRedisTarget(cfg), + "db", cfg.DB, + ) + + return &RedisClient{client: client, cfg: cfg, log: log}, nil +} + +// Client 回傳底層 *redis.Client,供 store 實作使用 go-redis API。 +func (r *RedisClient) Client() *redis.Client { + return r.client +} + +// Ping 對 Redis 跑一次連線檢查,供 /healthz 等 health check 用。 +func (r *RedisClient) Ping(ctx context.Context) error { + return r.client.Ping(ctx).Err() +} + +// Close 關閉 Redis client(graceful shutdown)。重複呼叫安全。 +func (r *RedisClient) Close() { + if r == nil || r.client == nil { + return + } + r.log.Info("closing redis client", "target", SafeRedisTarget(r.cfg)) + _ = r.client.Close() +} + +// SafeRedisTarget 回傳「可安全寫入 log」的 Redis 連線目標字串(host:port/db),不含密碼。 +func SafeRedisTarget(c config.RedisConfig) string { + port := c.Port + if port == 0 { + port = 6379 + } + return fmt.Sprintf("%s:%d/%d", c.Host, port, c.DB) +} diff --git a/visionA-backend/internal/db/testsupport/factory.go b/visionA-backend/internal/db/testsupport/factory.go new file mode 100644 index 0000000..74006dd --- /dev/null +++ b/visionA-backend/internal/db/testsupport/factory.go @@ -0,0 +1,71 @@ +//go:build dbtest + +package testsupport + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// DemoUserID 是測試共用的固定 user id,對齊雛形 demo-user 概念。 +// 因 schema 的 users.id 是 UUID,這裡用一個固定 UUID 當測試 owner。 +const DemoUserID = "00000000-0000-0000-0000-000000000001" + +// InsertUser 直接寫入一筆 users(測試 fixture)。 +// +// 塊 1+ 的 model / device / token repository 測試需要一個存在的 owner_user_id 來滿足 FK。 +// 回傳寫入的 user id(傳入 id 為空時自動產生 UUID)。 +func (tdb *TestDB) InsertUser(t *testing.T, id, email string) string { + t.Helper() + if id == "" { + id = uuid.NewString() + } + if email == "" { + email = id + "@test.local" + } + _, err := tdb.Pool.Exec(context.Background(), + `INSERT INTO users (id, email) VALUES ($1, $2) + ON CONFLICT (id) DO NOTHING`, + id, email) + require.NoError(t, err, "insert user fixture") + return id +} + +// EnsureDemoUser 確保 DemoUserID 這筆 user 存在,回傳其 id。 +// 供「只需要一個合法 owner、不在意是誰」的 model / device 測試使用。 +func (tdb *TestDB) EnsureDemoUser(t *testing.T) string { + t.Helper() + return tdb.InsertUser(t, DemoUserID, "demo@visiona.local") +} + +// InsertDevice 直接寫入一筆 devices(測試 fixture)。 +// +// 塊 3 的 session_tokens 測試需要一個存在的 device_id(NOT NULL FK → devices(id))。 +// ownerUserID 須先存在於 users。回傳寫入的 device id(傳入 id 為空時自動產生 UUID)。 +func (tdb *TestDB) InsertDevice(t *testing.T, id, ownerUserID string) string { + t.Helper() + if id == "" { + id = uuid.NewString() + } + _, err := tdb.Pool.Exec(context.Background(), + `INSERT INTO devices (id, owner_user_id, name, serial_number) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO NOTHING`, + id, ownerUserID, "fixture-device", "SN-"+id[:8]) + require.NoError(t, err, "insert device fixture") + return id +} + +// CountRows 回傳指定 table 的列數(含或不含 soft-deleted 由呼叫端用 where 決定,此處為全表)。 +// 供測試斷言「插入幾筆 / truncate 後是否歸零」。 +func (tdb *TestDB) CountRows(t *testing.T, table string) int { + t.Helper() + var n int + err := tdb.Pool.QueryRow(context.Background(), + "SELECT count(*) FROM "+pgIdent(table)).Scan(&n) + require.NoError(t, err, "count rows in %s", table) + return n +} diff --git a/visionA-backend/internal/db/testsupport/testsupport.go b/visionA-backend/internal/db/testsupport/testsupport.go new file mode 100644 index 0000000..0cdef99 --- /dev/null +++ b/visionA-backend/internal/db/testsupport/testsupport.go @@ -0,0 +1,151 @@ +//go:build dbtest + +// Package testsupport 提供 DB 整合測試的共用基礎建設(testcontainers-go + Postgres)。 +// +// DB 接入塊 0(0.6):一次性 Postgres 容器、自動 migrate、truncate helper、fixture factory。 +// 塊 1–3 的 *postgres_repository_test.go 都會 import 本套件,共用同一套 setupTestDB 與 builder。 +// +// build tag `dbtest`:本套件與依賴它的測試只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker)。 +// 預設 `go test ./...`(無 Docker 環境 / CI 無 docker daemon)不會觸碰,維持綠燈。 +// +// 執行方式: +// +// go test -tags=dbtest ./... # 需要本機 / CI 有可用的 Docker daemon +// make test-db # Makefile 包裝 +package testsupport + +import ( + "context" + "io" + "log/slog" + "net/url" + "strconv" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" + + "visiona-backend/internal/config" + "visiona-backend/internal/db" +) + +// discardLogger 是丟棄輸出的 logger,避免測試噪音淹沒 go test 輸出。 +var discardLogger = slog.New(slog.NewTextHandler(io.Discard, nil)) + +const ( + testDBName = "visiona_test" + testDBUser = "visiona" + testDBPassword = "visiona-test-pw" // 測試容器專用、非真實憑證 + testImage = "postgres:14.23-alpine" +) + +// TestDB 是一個已啟動 + 已 migrate 的測試用 Postgres 容器與其連線池。 +type TestDB struct { + Pool *pgxpool.Pool + Cfg config.DatabaseConfig + container *tcpostgres.PostgresContainer +} + +// SetupTestDB 啟動一個一次性 Postgres 容器、跑 migrate up、回傳已就緒的連線池。 +// +// 自動在 t.Cleanup 註冊容器與連線池的關閉,呼叫端不需手動 teardown。 +// 容器映像固定 postgres:14.23-alpine 對齊 stage(PG 14.23),確保 gen_random_uuid 等 +// 版本相依行為與正式環境一致。 +func SetupTestDB(t *testing.T) *TestDB { + t.Helper() + ctx := context.Background() + + container, err := tcpostgres.Run(ctx, testImage, + tcpostgres.WithDatabase(testDBName), + tcpostgres.WithUsername(testDBUser), + tcpostgres.WithPassword(testDBPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(60*time.Second), + ), + ) + require.NoError(t, err, "start postgres container") + + // 用 ConnectionString 取得 ready DSN,再解析出 host/port 回填 config + // (避免綁定特定版本的 MappedPort 回傳型別 API)。 + connStr, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err, "container connection string") + u, err := url.Parse(connStr) + require.NoError(t, err, "parse connection string") + host := u.Hostname() + port, err := strconv.Atoi(u.Port()) + require.NoError(t, err, "parse mapped port from %q", connStr) + + cfg := config.DatabaseConfig{ + Host: host, + Port: port, + User: testDBUser, + Password: testDBPassword, + DBName: testDBName, + SSLMode: "disable", // testcontainers 本機連線,無 TLS + MaxConns: 5, + MinConns: 1, + ConnTimeout: 10 * time.Second, + AutoMigrate: true, + } + + // 跑 migration(用與 production 相同的 RunMigrations + 嵌入式 migration)。 + require.NoError(t, db.RunMigrations(cfg, discardLogger), "run migrations") + + pool, err := db.NewPool(ctx, cfg, discardLogger) + require.NoError(t, err, "create pool") + + tdb := &TestDB{Pool: pool.Pool(), Cfg: cfg, container: container} + + t.Cleanup(func() { + pool.Close() + // 容器 teardown 用獨立 context,避免測試 ctx 已取消。 + termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = container.Terminate(termCtx) + }) + + return tdb +} + +// Truncate 清空指定 table(測試間隔離用)。RESTART IDENTITY + CASCADE 確保關聯表一併清。 +// +// 用法:在每個子測試開頭呼叫 tdb.Truncate(t, "models", "users") 以保證乾淨起點。 +// 順序無所謂(CASCADE 處理 FK)。 +func (tdb *TestDB) Truncate(t *testing.T, tables ...string) { + t.Helper() + if len(tables) == 0 { + return + } + ctx := context.Background() + sql := "TRUNCATE TABLE " + for i, tbl := range tables { + if i > 0 { + sql += ", " + } + sql += pgIdent(tbl) + } + sql += " RESTART IDENTITY CASCADE" + _, err := tdb.Pool.Exec(ctx, sql) + require.NoError(t, err, "truncate %v", tables) +} + +// pgIdent 對 SQL identifier 做最小化的雙引號 quote(測試用,table 名稱來自測試常數、非使用者輸入)。 +// 內嵌雙引號被 escape 為 "",符合 PostgreSQL identifier 規則。 +func pgIdent(name string) string { + out := make([]byte, 0, len(name)+2) + out = append(out, '"') + for i := 0; i < len(name); i++ { + if name[i] == '"' { + out = append(out, '"') + } + out = append(out, name[i]) + } + out = append(out, '"') + return string(out) +} diff --git a/visionA-backend/internal/db/tx.go b/visionA-backend/internal/db/tx.go new file mode 100644 index 0000000..1be6c5c --- /dev/null +++ b/visionA-backend/internal/db/tx.go @@ -0,0 +1,85 @@ +package db + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Querier 抽象「能跑查詢的東西」——同時被 *pgxpool.Pool 與 pgx.Tx 滿足。 +// +// 設計目的(DB 接入塊 5.2 跨 store 交易): +// +// repository 方法收 Querier 而非寫死 *pgxpool.Pool,就能在「池上直接跑(自動 commit)」 +// 或「在某個 tx 內跑(隨 tx commit/rollback)」之間自由切換,而呼叫端 / handler 一行不改。 +// cascade 撤銷(刪 device → 同 tx 撤 pairing + session token)正是靠這個介面,讓 device repo +// 與 token store 在同一個 pgx.Tx 下操作、達成「整筆成功或整筆回滾」。 +// +// 方法集刻意只取 repository 實際用到的三個(Exec / Query / QueryRow),不暴露 Begin/Commit +// 等交易控制——交易邊界由 WithTx 統一掌控,repository 不該自行 commit/rollback。 +// +// pgxpool.Pool 與 pgx.Tx 都已實作這三個方法(簽章完全相符),故下方兩個編譯期斷言成立。 +type Querier interface { + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// WithTx 在一個 pgx 交易內執行 fn,成功則 commit、失敗(fn 回 error 或 panic)則 rollback。 +// +// 語意(塊 5.1): +// - Begin 失敗 → 直接回 error(連線層問題,fail-fast)。 +// - fn 回 error → rollback 並回 fn 的 error(保留原因,呼叫端可 errors.Is 比對 domain error)。 +// - fn 成功 → commit;commit 失敗回 commit error。 +// - panic → rollback 後重新 panic(不吞,讓上層 recovery middleware 處理)。 +// +// ctx 取消會讓 Begin / fn 內的查詢 / Commit 各自因 context 失敗而中止——交易不會半開。 +// +// 注意:fn 內所有 DB 操作都必須用傳入的 q(tx),不可再用外層 pool,否則那些操作不在交易內、 +// 失敗時不會被 rollback。 +func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (err error) { + if pool == nil { + return errors.New("db: WithTx requires non-nil pool") + } + + tx, beginErr := pool.Begin(ctx) + if beginErr != nil { + return fmt.Errorf("db: begin tx: %w", beginErr) + } + + // committed 避免 commit 後又 rollback(pgx 對已結束的 tx rollback 會回 ErrTxClosed)。 + committed := false + defer func() { + if committed { + return + } + // Rollback 用 background context:若是 ctx 已取消才走到這裡,仍要盡力 rollback。 + if rbErr := tx.Rollback(context.Background()); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) { + // 只有在 fn 本身沒回 error(亦即 err 為 nil)時,rollback error 才升級成回傳值; + // 否則保留 fn 的原始 error(更有診斷價值),rollback error 只是次要訊號。 + if err == nil { + err = fmt.Errorf("db: rollback tx: %w", rbErr) + } + } + }() + + if fnErr := fn(tx); fnErr != nil { + return fnErr // defer 會 rollback + } + + if cErr := tx.Commit(ctx); cErr != nil { + return fmt.Errorf("db: commit tx: %w", cErr) + } + committed = true + return nil +} + +// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。 +// +// pgx.Tx 是 interface,無法直接取 (pgx.Tx)(nil) 當靜態斷言對象(nil interface 沒有具體型別), +// 故只對 *pgxpool.Pool 做編譯期斷言;pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。 +var _ Querier = (*pgxpool.Pool)(nil) diff --git a/visionA-backend/internal/db/tx_db_test.go b/visionA-backend/internal/db/tx_db_test.go new file mode 100644 index 0000000..7d62aef --- /dev/null +++ b/visionA-backend/internal/db/tx_db_test.go @@ -0,0 +1,132 @@ +//go:build dbtest + +// WithTx 交易 helper 的真 DB 整合測試(DB 接入塊 5.1 / 5.5)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 +// 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。 +// +// 用 package db_test(外部測試包)避免 testsupport → db 的 import cycle(對齊 db_integration_test.go)。 +// +// 執行: +// +// go test -tags=dbtest ./internal/db/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/db/... +// +// 涵蓋:commit 落地、fn 回 error 整筆 rollback、DB 錯誤 rollback、panic rollback、context 取消、nil pool。 +package db_test + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db" + "visiona-backend/internal/db/testsupport" +) + +func countUsersTx(t *testing.T, tdb *testsupport.TestDB) int { + t.Helper() + var n int + require.NoError(t, tdb.Pool.QueryRow(context.Background(), `SELECT count(*) FROM users`).Scan(&n)) + return n +} + +func insertUserTx(ctx context.Context, q db.Querier, id, email string) error { + _, err := q.Exec(ctx, `INSERT INTO users (id, email) VALUES ($1, $2)`, id, email) + return err +} + +// TestWithTx_CommitsOnSuccess 驗證 fn 成功時變更落地(commit)。 +func TestWithTx_CommitsOnSuccess(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + + id := uuid.NewString() + err := db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error { + return insertUserTx(context.Background(), q, id, id+"@t.local") + }) + require.NoError(t, err) + assert.Equal(t, 1, countUsersTx(t, tdb), "commit 後該列應存在") +} + +// TestWithTx_RollbackOnError 驗證 fn 回 error 時整筆 rollback(中途已寫入的列也回滾)。 +func TestWithTx_RollbackOnError(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + + sentinel := errors.New("boom") + err := db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error { + if e := insertUserTx(context.Background(), q, uuid.NewString(), "a@t.local"); e != nil { + return e + } + return sentinel + }) + require.ErrorIs(t, err, sentinel, "WithTx 應原樣回傳 fn 的 error") + assert.Equal(t, 0, countUsersTx(t, tdb), "rollback 後第一列也不該存在(原子性)") +} + +// TestWithTx_RollbackOnDBError 驗證 fn 內 DB 錯誤(unique violation)→ rollback。 +func TestWithTx_RollbackOnDBError(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + + email := "dup@t.local" + _, err := tdb.Pool.Exec(context.Background(), + `INSERT INTO users (id, email) VALUES ($1, $2)`, uuid.NewString(), email) + require.NoError(t, err) + + err = db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error { + if e := insertUserTx(context.Background(), q, uuid.NewString(), "ok@t.local"); e != nil { + return e + } + return insertUserTx(context.Background(), q, uuid.NewString(), email) // 撞 unique + }) + require.Error(t, err) + assert.Equal(t, 1, countUsersTx(t, tdb), "交易外那筆仍在,交易內兩筆 rollback") + var okCount int + require.NoError(t, tdb.Pool.QueryRow(context.Background(), + `SELECT count(*) FROM users WHERE email = 'ok@t.local'`).Scan(&okCount)) + assert.Equal(t, 0, okCount, "交易內第一列也應 rollback") +} + +// TestWithTx_PanicRollsBackAndRepanics 驗證 fn panic 時 rollback 並重新 panic(不吞)。 +func TestWithTx_PanicRollsBackAndRepanics(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + + assert.Panics(t, func() { + _ = db.WithTx(context.Background(), tdb.Pool, func(q db.Querier) error { + _ = insertUserTx(context.Background(), q, uuid.NewString(), "panic@t.local") + panic("kaboom") + }) + }) + assert.Equal(t, 0, countUsersTx(t, tdb), "panic 後該列應 rollback") +} + +// TestWithTx_ContextCanceled 驗證已取消的 context → Begin / 查詢失敗、無半開交易。 +func TestWithTx_ContextCanceled(t *testing.T) { + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "users") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := db.WithTx(ctx, tdb.Pool, func(q db.Querier) error { + return insertUserTx(ctx, q, uuid.NewString(), "cancel@t.local") + }) + require.Error(t, err) + assert.Equal(t, 0, countUsersTx(t, tdb)) +} + +// TestWithTx_NilPool 驗證 nil pool 回 error(不 panic)。此測試不需真 DB,但置於 dbtest 檔 +// 共用 build tag;純驗 nil 防護。 +func TestWithTx_NilPool(t *testing.T) { + err := db.WithTx(context.Background(), nil, func(q db.Querier) error { return nil }) + require.Error(t, err) +} diff --git a/visionA-backend/internal/device/postgres_repository.go b/visionA-backend/internal/device/postgres_repository.go new file mode 100644 index 0000000..13868e6 --- /dev/null +++ b/visionA-backend/internal/device/postgres_repository.go @@ -0,0 +1,305 @@ +// Package device 的 Postgres 持久層實作(DB 接入塊 2)。 +// +// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface, +// 讓 main.go 在 dbPool != nil 時無痛切換、handler 與呼叫端一行都不需改。 +// +// 對齊: +// - database.md §2.2(Device 雙狀態欄位 + paired_at)、§4(devices 表 schema: +// partial unique index uq_devices_owner_serial_active + owner/remote_status filter index) +// - migrations/0002_create_devices.up.sql(devices 表含全部欄位) +// +// 語意對齊 in-memory(見 device.go): +// - Get / GetBySerial / List 略過 deleted_at IS NOT NULL 的紀錄。 +// - GetBySerial 以 (owner_user_id, serial_number) 查未刪除紀錄。 +// - Save 為 upsert by ID;existing 且未刪除時保留原 created_at(in-memory device.go ~line 187)。 +// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。 +// +// partial unique × soft-delete 語意(塊 2 子任務 2.3): +// +// devices 的 (owner_user_id, serial_number) 唯一性只對「未刪除」紀錄成立 +// (migration 0002 的 uq_devices_owner_serial_active WHERE deleted_at IS NULL)。 +// 因此: +// - 同 owner 同 serial 同時存在「兩筆未刪除」→ INSERT 第二筆會撞 unique(23505)。 +// - 但若先把第一筆 soft-delete(deleted_at IS NOT NULL),它就退出 partial index 的 +// 管轄範圍,同 (owner, serial) 可再 INSERT 一筆新的(新 id)而不違反 unique。 +// 這正是「已刪除 serial 可重新註冊」的決策落地。 +// 注意:Save 是 upsert by **id**,而非 by (owner, serial)。重註冊走「新 id」路徑、 +// 不會 ON CONFLICT (id) 命中舊列;舊列保持 soft-deleted,新列為一筆全新紀錄。 +package device + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "visiona-backend/internal/db" +) + +// PostgresRepository 是 Device 的 PostgreSQL 持久層實作。 +type PostgresRepository struct { + pool *pgxpool.Pool +} + +// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。 +// +// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。 +func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository { + return &PostgresRepository{pool: pool} +} + +// 編譯時檢查:確保 PostgresRepository 實作 Repository。 +var _ Repository = (*PostgresRepository)(nil) + +// deviceColumns 是 SELECT 共用的欄位清單(順序必須與 scanDevice 對齊)。 +const deviceColumns = `id, owner_user_id, name, device_type, serial_number, + remote_status, last_seen_at, last_connected_at, status, + created_at, updated_at, paired_at, deleted_at` + +// Get 取得單一 device;不存在或已軟刪除回 ErrNotFound。 +func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error) { + const q = `SELECT ` + deviceColumns + ` + FROM devices + WHERE id = $1 AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, q, id) + d, err := scanDevice(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("device: pg Get: %w", err) + } + return d, nil +} + +// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。 +// +// 對齊 in-memory:同一個 serial 在不同 owner 下不互相干擾(owner 過濾)。 +func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) { + const q = `SELECT ` + deviceColumns + ` + FROM devices + WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, q, ownerUserID, serial) + d, err := scanDevice(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("device: pg GetBySerial: %w", err) + } + return d, nil +} + +// List 列出某 owner 的所有未刪除 device,以 created_at DESC 排序(最新在前)。 +func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) { + const q = `SELECT ` + deviceColumns + ` + FROM devices + WHERE owner_user_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC` + + rows, err := r.pool.Query(ctx, q, ownerUserID) + if err != nil { + return nil, fmt.Errorf("device: pg List query: %w", err) + } + defer rows.Close() + + out := make([]*Device, 0) + for rows.Next() { + d, scanErr := scanDevice(rows) + if scanErr != nil { + return nil, fmt.Errorf("device: pg List scan: %w", scanErr) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("device: pg List rows: %w", err) + } + return out, nil +} + +// Save 新增或更新 device(upsert by id)。 +// +// 語意對齊 in-memory(device.go ~line 187): +// - 既有且未刪除(deleted_at IS NULL)→ 保留原 created_at; +// - 不存在 / 已刪除(復活)→ 以傳入 created_at(zero 時用 now())為準。 +// +// updated_at 一律設為 now()。created_at 用 CASE:當 conflict 既有列未刪除時保留 +// devices.created_at,否則用 EXCLUDED.created_at。 +// +// 重註冊(已 soft-delete 的 serial)走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT; +// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。 +func (r *PostgresRepository) Save(ctx context.Context, d *Device) error { + if d == nil || d.ID == "" { + return errors.New("device: Save requires non-nil device with ID") + } + + // remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。 + remoteStatus := d.RemoteStatus + if remoteStatus == "" { + remoteStatus = RemoteStatusOffline + } + status := d.Status + if status == "" { + status = USBStatusUnknown + } + + // created_at:zero 時交給 DB now()(用 NULL 觸發 COALESCE)。 + var createdAt any + if !d.CreatedAt.IsZero() { + createdAt = d.CreatedAt.UTC() + } // else: 留 nil → COALESCE($n, now()) + + const q = ` + INSERT INTO devices ( + id, owner_user_id, name, device_type, serial_number, + remote_status, last_seen_at, last_connected_at, status, + created_at, updated_at, paired_at, deleted_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, + COALESCE($10, now()), now(), $11, $12 + ) + ON CONFLICT (id) DO UPDATE SET + owner_user_id = EXCLUDED.owner_user_id, + name = EXCLUDED.name, + device_type = EXCLUDED.device_type, + serial_number = EXCLUDED.serial_number, + remote_status = EXCLUDED.remote_status, + last_seen_at = EXCLUDED.last_seen_at, + last_connected_at = EXCLUDED.last_connected_at, + status = EXCLUDED.status, + -- 保留原 created_at 僅當既有列未刪除;已刪除(復活)則用新值。 + created_at = CASE + WHEN devices.deleted_at IS NULL THEN devices.created_at + ELSE EXCLUDED.created_at + END, + updated_at = now(), + paired_at = EXCLUDED.paired_at, + deleted_at = EXCLUDED.deleted_at` + + _, err := r.pool.Exec(ctx, q, + d.ID, // $1 + d.OwnerUserID, // $2 + d.Name, // $3 + d.DeviceType, // $4 + d.SerialNumber, // $5 + remoteStatus, // $6 + d.LastSeenAt, // $7 + d.LastConnectedAt, // $8 + status, // $9 + createdAt, // $10 + d.PairedAt, // $11 + d.DeletedAt, // $12 + ) + if err != nil { + return fmt.Errorf("device: pg Save upsert: %w", err) + } + return nil +} + +// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。 +// +// 直接在 pool 上跑(自動 commit)。若需與 token cascade 撤銷在同一交易內,請改用 DeleteTx。 +func (r *PostgresRepository) Delete(ctx context.Context, id string) error { + return r.DeleteTx(ctx, r.pool, id) +} + +// DeleteTx 與 Delete 相同的軟刪除語意,但在傳入的 Querier(pool 或 tx)上執行。 +// +// 塊 5.2 cascade 撤銷:unpair 流程在 db.WithTx 內先呼叫本方法軟刪 device,再於同一 tx 對 +// pairing_tokens / session_tokens 撤銷——任一步失敗整筆 rollback,device 不會「已刪但 token 沒撤」。 +// +// q 可為 *pgxpool.Pool(自動 commit)或 pgx.Tx(隨外層交易)。語意與 Delete 一致: +// 已刪除或不存在回 ErrNotFound(呼叫端可 errors.Is 比對)。 +func (r *PostgresRepository) DeleteTx(ctx context.Context, q db.Querier, id string) error { + const sql = `UPDATE devices + SET deleted_at = now(), updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + + tag, err := q.Exec(ctx, sql, id) + if err != nil { + return fmt.Errorf("device: pg Delete: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ========================================================================== +// scan helper +// ========================================================================== + +// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanDevice 同時服務 Get 與 List。 +type rowScanner interface { + Scan(dest ...any) error +} + +// scanDevice 從一列掃出 *Device。欄位順序必須與 deviceColumns 對齊。 +// +// nullable TEXT 欄位(device_type / serial_number)在 DB 為 NULL 時掃進空字串(對齊 +// in-memory zero value);nullable TIMESTAMPTZ(last_seen_at / last_connected_at / +// paired_at / deleted_at)以 *time.Time 接,NULL → nil。 +func scanDevice(row rowScanner) (*Device, error) { + var ( + d Device + deviceType *string + serialNumber *string + ) + + err := row.Scan( + &d.ID, + &d.OwnerUserID, + &d.Name, + &deviceType, + &serialNumber, + &d.RemoteStatus, + &d.LastSeenAt, + &d.LastConnectedAt, + &d.Status, + &d.CreatedAt, + &d.UpdatedAt, + &d.PairedAt, + &d.DeletedAt, + ) + if err != nil { + return nil, err + } + + d.DeviceType = derefString(deviceType) + d.SerialNumber = derefString(serialNumber) + + // 正規化時間為 UTC,對齊 in-memory(time.Now().UTC())。 + d.CreatedAt = d.CreatedAt.UTC() + d.UpdatedAt = d.UpdatedAt.UTC() + if d.LastSeenAt != nil { + t := d.LastSeenAt.UTC() + d.LastSeenAt = &t + } + if d.LastConnectedAt != nil { + t := d.LastConnectedAt.UTC() + d.LastConnectedAt = &t + } + if d.PairedAt != nil { + t := d.PairedAt.UTC() + d.PairedAt = &t + } + if d.DeletedAt != nil { + t := d.DeletedAt.UTC() + d.DeletedAt = &t + } + + return &d, nil +} + +// derefString 解指標字串,nil 視為空字串(對齊 in-memory zero value)。 +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/visionA-backend/internal/device/postgres_repository_db_test.go b/visionA-backend/internal/device/postgres_repository_db_test.go new file mode 100644 index 0000000..d938e92 --- /dev/null +++ b/visionA-backend/internal/device/postgres_repository_db_test.go @@ -0,0 +1,426 @@ +//go:build dbtest + +// PostgresRepository(device)的真 DB 整合測試(DB 接入塊 2,子任務 2.5–2.7)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/device/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/device/... +// +// 涵蓋: +// - 2.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、GetBySerial 跨 owner 不串、 +// List by owner、soft delete、再刪回 NotFound、保留 CreatedAt、Save 需 ID)。 +// - 2.6 integration/真 DB:partial unique 衝突(兩筆未刪除同 owner+serial)、partial unique 讓 +// 已刪 serial 可重註冊、雙狀態欄位 + paired_at round-trip、upsert 保留 CreatedAt。 +// - 2.7 邊界:空 List、併發註冊同 serial、context cancel。 +package device + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// newPGRepo 啟動一次性測試 DB、truncate、確保 demo user 存在,回傳 repo + ownerID。 +// +// 每個測試各自呼叫一次(SetupTestDB 內含 t.Cleanup teardown)。owner 用 testsupport +// 的固定 demo user UUID,滿足 devices.owner_user_id 的 FK。 +func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) { + t.Helper() + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "devices", "users") + owner := tdb.EnsureDemoUser(t) + return NewPostgresRepository(tdb.Pool), tdb, owner +} + +// --------------------------------------------------------------------------- +// 2.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case) +// --------------------------------------------------------------------------- + +func TestPG_SaveAndGet(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + d := &Device{ + ID: uuid.NewString(), + OwnerUserID: owner, + Name: "Lab KL520", + DeviceType: "kl520", + SerialNumber: "KL520-AAA", + RemoteStatus: RemoteStatusOffline, + Status: USBStatusUnknown, + } + require.NoError(t, r.Save(ctx, d)) + + got, err := r.Get(ctx, d.ID) + require.NoError(t, err) + assert.Equal(t, "Lab KL520", got.Name) + assert.Equal(t, owner, got.OwnerUserID) + assert.Equal(t, "kl520", got.DeviceType) + assert.Equal(t, "KL520-AAA", got.SerialNumber) + assert.Equal(t, RemoteStatusOffline, got.RemoteStatus) + assert.Equal(t, USBStatusUnknown, got.Status) + assert.False(t, got.CreatedAt.IsZero()) + assert.False(t, got.UpdatedAt.IsZero()) +} + +func TestPG_Get_NotFound(t *testing.T) { + r, _, _ := newPGRepo(t) + _, err := r.Get(context.Background(), uuid.NewString()) + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestPG_Save_RequiresID(t *testing.T) { + r, _, owner := newPGRepo(t) + assert.Error(t, r.Save(context.Background(), &Device{Name: "no-id", OwnerUserID: owner})) +} + +// GetBySerial:跨 owner 同 serial 不互串(owner 過濾)。 +func TestPG_GetBySerial(t *testing.T) { + ctx := context.Background() + r, tdb, ownerA := newPGRepo(t) + ownerB := tdb.InsertUser(t, "", "") + + idA, idB := uuid.NewString(), uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: idA, OwnerUserID: ownerA, Name: "a", SerialNumber: "S-1"})) + require.NoError(t, r.Save(ctx, &Device{ID: idB, OwnerUserID: ownerB, Name: "b", SerialNumber: "S-1"})) + + gotA, err := r.GetBySerial(ctx, ownerA, "S-1") + require.NoError(t, err) + assert.Equal(t, idA, gotA.ID) + + gotB, err := r.GetBySerial(ctx, ownerB, "S-1") + require.NoError(t, err) + assert.Equal(t, idB, gotB.ID) + + // 不存在的 owner + _, err = r.GetBySerial(ctx, uuid.NewString(), "S-1") + assert.ErrorIs(t, err, ErrNotFound) +} + +// GetBySerial:soft-delete 後查不到。 +func TestPG_GetBySerial_SkipsDeleted(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-9"})) + require.NoError(t, r.Delete(ctx, id)) + + _, err := r.GetBySerial(ctx, owner, "S-9") + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestPG_List_ByOwner(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + owner2 := tdb.InsertUser(t, "", "") + + require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "a", SerialNumber: "S-A"})) + require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "b", SerialNumber: "S-B"})) + require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", SerialNumber: "S-C"})) + + listOwner, err := r.List(ctx, owner) + require.NoError(t, err) + assert.Len(t, listOwner, 2) + + listOwner2, err := r.List(ctx, owner2) + require.NoError(t, err) + assert.Len(t, listOwner2, 1) + + // 不存在的 owner + listNone, err := r.List(ctx, uuid.NewString()) + require.NoError(t, err) + assert.Empty(t, listNone) +} + +func TestPG_Delete_SoftDelete(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-D"})) + require.NoError(t, r.Delete(ctx, id)) + + // Get 不到 + _, err := r.Get(ctx, id) + assert.ErrorIs(t, err, ErrNotFound) + + // List 不含 + list, _ := r.List(ctx, owner) + assert.Empty(t, list) + + // 重複 Delete 回 ErrNotFound + assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound) +} + +// --------------------------------------------------------------------------- +// 2.6 integration/真 DB +// --------------------------------------------------------------------------- + +// partial unique 衝突:兩筆「未刪除」同 (owner, serial)、不同 id → 第二筆撞 unique(23505)。 +func TestPG_PartialUnique_ActiveConflict(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "first", SerialNumber: "SN-DUP"})) + + // 不同 id、同 owner+serial、皆未刪除 → 違反 uq_devices_owner_serial_active。 + err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "second", SerialNumber: "SN-DUP"}) + require.Error(t, err, "兩筆未刪除同 owner+serial 應違反 partial unique") + + var pgErr *pgconn.PgError + require.ErrorAs(t, err, &pgErr) + assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation") + assert.Equal(t, "uq_devices_owner_serial_active", pgErr.ConstraintName) +} + +// partial unique × soft-delete:已刪 serial 可重新註冊(核心決策 2.3)。 +func TestPG_PartialUnique_ReRegisterAfterSoftDelete(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + id1 := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id1, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RE"})) + require.NoError(t, r.Delete(ctx, id1)) + + // 同 owner+serial、新 id → 因舊列已 soft-delete、退出 partial index,重註冊應成功。 + id2 := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id2, OwnerUserID: owner, Name: "v2", SerialNumber: "SN-RE"}), + "已 soft-delete 的 serial 應可重新註冊") + + // GetBySerial 應回新註冊的那筆(未刪除)。 + got, err := r.GetBySerial(ctx, owner, "SN-RE") + require.NoError(t, err) + assert.Equal(t, id2, got.ID) + assert.Equal(t, "v2", got.Name) + + // DB 共有兩列(一筆 deleted、一筆 active)。 + assert.Equal(t, 2, tdb.CountRows(t, "devices"), "重註冊後應有兩列:舊的 soft-deleted + 新的 active") + + // List(未刪除)只回新的一筆。 + list, err := r.List(ctx, owner) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, id2, list[0].ID) +} + +// 雙狀態欄位 + paired_at + 時間欄位 round-trip。 +func TestPG_DualStatus_RoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + lastSeen := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Microsecond) + lastConnected := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond) + paired := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond) + + id := uuid.NewString() + in := &Device{ + ID: id, + OwnerUserID: owner, + Name: "dual", + DeviceType: "kl720", + SerialNumber: "SN-DUAL", + RemoteStatus: RemoteStatusReconnecting, + LastSeenAt: &lastSeen, + LastConnectedAt: &lastConnected, + Status: USBStatusOnline, + PairedAt: &paired, + } + require.NoError(t, r.Save(ctx, in)) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, RemoteStatusReconnecting, got.RemoteStatus) + assert.Equal(t, USBStatusOnline, got.Status) + require.NotNil(t, got.LastSeenAt) + require.NotNil(t, got.LastConnectedAt) + require.NotNil(t, got.PairedAt) + assert.True(t, lastSeen.Equal(*got.LastSeenAt), "last_seen_at round-trip") + assert.True(t, lastConnected.Equal(*got.LastConnectedAt), "last_connected_at round-trip") + assert.True(t, paired.Equal(*got.PairedAt), "paired_at round-trip") +} + +// nullable 時間欄位:不帶值寫入,讀回為 nil(對齊 in-memory omitempty 語意)。 +func TestPG_NullableTimes_RoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "no-times", SerialNumber: "SN-NT"})) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Nil(t, got.LastSeenAt) + assert.Nil(t, got.LastConnectedAt) + assert.Nil(t, got.PairedAt) + assert.Nil(t, got.DeletedAt) + // 預設值(migration DEFAULT):remote_status='offline'、status='unknown'。 + assert.Equal(t, RemoteStatusOffline, got.RemoteStatus) + assert.Equal(t, USBStatusUnknown, got.Status) +} + +// upsert 保留 CreatedAt:第二次 Save(同 id、未刪除)保留首次 created_at、更新其他欄位 + updated_at。 +func TestPG_Upsert_PreservesCreatedAt(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-UP"})) + first, err := r.Get(ctx, id) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + // 第二次 Save:帶不同(更早)的 CreatedAt,應被忽略而保留 first.CreatedAt。 + require.NoError(t, r.Save(ctx, &Device{ + ID: id, + OwnerUserID: owner, + Name: "v2", + SerialNumber: "SN-UP", + RemoteStatus: RemoteStatusOnline, + Status: USBStatusOnline, + CreatedAt: time.Now().Add(-72 * time.Hour).UTC(), // 試圖覆蓋,應被忽略 + })) + second, err := r.Get(ctx, id) + require.NoError(t, err) + + assert.Equal(t, "v2", second.Name) + assert.Equal(t, RemoteStatusOnline, second.RemoteStatus) + assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值") + assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進") +} + +// soft-delete 後再 Save 同 id(復活):採用新 created_at、deleted_at 清回 nil。 +func TestPG_Upsert_AfterSoftDelete_ResetsCreatedAt(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + oldCreated := time.Now().Add(-100 * time.Hour).UTC() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RV", CreatedAt: oldCreated})) + require.NoError(t, r.Delete(ctx, id)) + + newCreated := time.Now().UTC() + require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "revived", SerialNumber: "SN-RV", CreatedAt: newCreated})) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, "revived", got.Name) + assert.Nil(t, got.DeletedAt, "復活後不應仍為 deleted") + assert.WithinDuration(t, newCreated, got.CreatedAt, time.Microsecond, "復活後 created_at 應採新值") +} + +// --------------------------------------------------------------------------- +// 2.7 邊界 +// --------------------------------------------------------------------------- + +// 空 List:乾淨 DB 回 non-nil 空 slice。 +func TestPG_List_Empty(t *testing.T) { + r, _, owner := newPGRepo(t) + list, err := r.List(context.Background(), owner) + require.NoError(t, err) + assert.Empty(t, list) + assert.NotNil(t, list, "List 應回 non-nil 空 slice") +} + +// 併發註冊同 (owner, serial)、不同 id:partial unique 確保至多一筆成功,其餘撞 23505。 +// 不應 panic;最終 active 列恰為一筆。 +func TestPG_ConcurrentRegisterSameSerial(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + const n = 20 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = r.Save(ctx, &Device{ + ID: uuid.NewString(), + OwnerUserID: owner, + Name: "concurrent", + SerialNumber: "SN-RACE", + }) + }(i) + } + wg.Wait() + + var ok, conflict int + for _, e := range errs { + if e == nil { + ok++ + continue + } + var pgErr *pgconn.PgError + require.ErrorAs(t, e, &pgErr, "非 nil error 應為 PgError") + assert.Equal(t, "23505", pgErr.Code, "衝突應為 unique_violation") + conflict++ + } + assert.Equal(t, 1, ok, "恰一筆成功註冊") + assert.Equal(t, n-1, conflict, "其餘皆撞 partial unique") + + // active(未刪除)列恰一筆。 + list, err := r.List(ctx, owner) + require.NoError(t, err) + assert.Len(t, list, 1, "最終 active device 恰一筆") + _ = tdb // tdb 保留供除錯(CountRows) +} + +// 併發 Save 同 id:upsert by id,不應 panic;最終單一列、最後內容。 +func TestPG_ConcurrentSaveSameID(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + id := uuid.NewString() + const n = 20 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "same-id", SerialNumber: "SN-SID"}) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "併發 Save 同 id #%d", i) + } + assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 upsert 同 id 應只有一列") +} + +// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。 +func TestPG_ContextCancel(t *testing.T) { + r, _, owner := newPGRepo(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", SerialNumber: "SN-CC"}) + assert.Error(t, err, "已取消 ctx 的 Save 應回 error") + + _, err = r.Get(ctx, uuid.NewString()) + assert.Error(t, err, "已取消 ctx 的 Get 應回 error") + + _, err = r.GetBySerial(ctx, owner, "SN-CC") + assert.Error(t, err, "已取消 ctx 的 GetBySerial 應回 error") + + _, err = r.List(ctx, owner) + assert.Error(t, err, "已取消 ctx 的 List 應回 error") +} diff --git a/visionA-backend/internal/model/postgres_repository.go b/visionA-backend/internal/model/postgres_repository.go new file mode 100644 index 0000000..9c8cb16 --- /dev/null +++ b/visionA-backend/internal/model/postgres_repository.go @@ -0,0 +1,328 @@ +// Package model 的 Postgres 持久層實作(DB 接入塊 1)。 +// +// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface, +// 讓 main.go 在 cfg.Database.Enabled() 時無痛切換、handler 與呼叫端一行都不需改。 +// +// 對齊: +// - database.md §2.3(Model 欄位)、§4(models 表 schema:faa_object_key / uploaded_at + +// owner/chip/source filter index + soft-delete partial index) +// - migrations/0001_create_users_models.up.sql(models 表已含全部欄位,塊 1 不需新 migration) +// +// 語意對齊 in-memory(見 model.go): +// - Get / List 略過 deleted_at IS NOT NULL 的紀錄。 +// - Save 為 upsert by ID;existing 且未刪除時保留原 created_at(in-memory model.go ~line 209)。 +// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。 +// +// array 映射(pgx v5): +// - input_shape INT[] <-> []int32(pgx 對 INT[] 預設 decode 成 []int32,Model.InputShape 為 []int) +// - classes TEXT[] <-> []string +package model + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgresRepository 是 Model 的 PostgreSQL 持久層實作。 +type PostgresRepository struct { + pool *pgxpool.Pool +} + +// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。 +// +// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。 +func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository { + return &PostgresRepository{pool: pool} +} + +// 編譯時檢查:確保 PostgresRepository 實作 Repository。 +var _ Repository = (*PostgresRepository)(nil) + +// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。 +const modelColumns = `id, owner_user_id, name, description, storage_key, file_size, + file_checksum, faa_object_key, target_chip, input_shape, classes, framework, + source, source_job_id, created_at, updated_at, uploaded_at, deleted_at` + +// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。 +func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) { + const q = `SELECT ` + modelColumns + ` + FROM models + WHERE id = $1 AND deleted_at IS NULL` + + row := r.pool.QueryRow(ctx, q, id) + m, err := scanModel(row) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("model: pg Get: %w", err) + } + return m, nil +} + +// List 依 filter 列出未刪除的 Model。 +// +// filter 三維(OwnerUserID / TargetChip / Source)皆為可選;空字串表示不過濾該維度。 +// 條件以動態 WHERE 拼接(參數化,無字串拼接使用者輸入),對齊 in-memory List 行為。 +// 結果以 created_at DESC 排序(最新在前),提供穩定且對前端友善的順序。 +func (r *PostgresRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) { + var ( + conds = []string{"deleted_at IS NULL"} + args []any + ) + if filter.OwnerUserID != "" { + args = append(args, filter.OwnerUserID) + conds = append(conds, fmt.Sprintf("owner_user_id = $%d", len(args))) + } + if filter.TargetChip != "" { + args = append(args, filter.TargetChip) + conds = append(conds, fmt.Sprintf("target_chip = $%d", len(args))) + } + if filter.Source != "" { + args = append(args, filter.Source) + conds = append(conds, fmt.Sprintf("source = $%d", len(args))) + } + + q := `SELECT ` + modelColumns + ` + FROM models + WHERE ` + strings.Join(conds, " AND ") + ` + ORDER BY created_at DESC` + + rows, err := r.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("model: pg List query: %w", err) + } + defer rows.Close() + + out := make([]*Model, 0) + for rows.Next() { + m, scanErr := scanModel(rows) + if scanErr != nil { + return nil, fmt.Errorf("model: pg List scan: %w", scanErr) + } + out = append(out, m) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("model: pg List rows: %w", err) + } + return out, nil +} + +// Save 新增或更新 Model(upsert by id)。 +// +// 語意對齊 in-memory(model.go ~line 209): +// - 既有且未刪除(deleted_at IS NULL)→ 保留原 created_at; +// - 不存在 / 已刪除 → 以傳入 created_at(zero 時用 now())為準。 +// +// updated_at 一律設為 now()。透過 ON CONFLICT (id) DO UPDATE 的 GREATEST/COALESCE 無法 +// 表達「保留原值僅當未刪除」,故 created_at 用 CASE:當 conflict 既有列未刪除時保留 +// models.created_at,否則用 EXCLUDED.created_at。 +func (r *PostgresRepository) Save(ctx context.Context, m *Model) error { + if m == nil || m.ID == "" { + return errors.New("model: Save requires non-nil model with ID") + } + + // created_at:zero 時交給 DB now()(用 NULL 觸發 COALESCE)。 + var createdAt any + if !m.CreatedAt.IsZero() { + createdAt = m.CreatedAt.UTC() + } // else: 留 nil → COALESCE($n, now()) + + // input_shape:Model.InputShape 為 []int,DB 為 INT[]。pgx encode []int 可行; + // 為與 decode([]int32)對稱、避免型別歧義,這裡顯式轉 []int32。 + inputShape := toInt32Slice(m.InputShape) + + // nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL), + // 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。 + const q = ` + INSERT INTO models ( + id, owner_user_id, name, description, storage_key, file_size, + file_checksum, faa_object_key, target_chip, input_shape, classes, framework, + source, source_job_id, created_at, updated_at, uploaded_at, deleted_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, $12, + $13, $14, COALESCE($15, now()), now(), $16, $17 + ) + ON CONFLICT (id) DO UPDATE SET + owner_user_id = EXCLUDED.owner_user_id, + name = EXCLUDED.name, + description = EXCLUDED.description, + storage_key = EXCLUDED.storage_key, + file_size = EXCLUDED.file_size, + file_checksum = EXCLUDED.file_checksum, + faa_object_key = EXCLUDED.faa_object_key, + target_chip = EXCLUDED.target_chip, + input_shape = EXCLUDED.input_shape, + classes = EXCLUDED.classes, + framework = EXCLUDED.framework, + source = EXCLUDED.source, + source_job_id = EXCLUDED.source_job_id, + -- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。 + created_at = CASE + WHEN models.deleted_at IS NULL THEN models.created_at + ELSE EXCLUDED.created_at + END, + updated_at = now(), + uploaded_at = EXCLUDED.uploaded_at, + deleted_at = EXCLUDED.deleted_at` + + _, err := r.pool.Exec(ctx, q, + m.ID, // $1 + m.OwnerUserID, // $2 + m.Name, // $3 + m.Description, // $4 + m.StorageKey, // $5 + m.FileSize, // $6 + m.FileChecksum, // $7 + m.FAAObjectKey, // $8 + m.TargetChip, // $9 + inputShape, // $10 + m.Classes, // $11 + m.Framework, // $12 + string(m.Source), // $13 + nullableUUID(m.SourceJobID), // $14 + createdAt, // $15 + m.UploadedAt, // $16 + m.DeletedAt, // $17 + ) + if err != nil { + return fmt.Errorf("model: pg Save upsert: %w", err) + } + return nil +} + +// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。 +func (r *PostgresRepository) Delete(ctx context.Context, id string) error { + const q = `UPDATE models + SET deleted_at = now(), updated_at = now() + WHERE id = $1 AND deleted_at IS NULL` + + tag, err := r.pool.Exec(ctx, q, id) + if err != nil { + return fmt.Errorf("model: pg Delete: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// ========================================================================== +// scan / 型別 helper +// ========================================================================== + +// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanModel 同時服務 Get 與 List。 +type rowScanner interface { + Scan(dest ...any) error +} + +// scanModel 從一列掃出 *Model。欄位順序必須與 modelColumns 對齊。 +// +// nullable 欄位(faa_object_key / target_chip / description / framework / file_checksum) +// 在 DB 為 NULL 時掃進空字串;source_job_id(UUID, NULL)以 *string 接再轉空字串; +// input_shape(NULL 或空陣列)掃成 nil/空,再轉回 []int。 +func scanModel(row rowScanner) (*Model, error) { + var ( + m Model + description *string + fileChecksum *string + faaObjectKey *string + targetChip *string + inputShape []int32 + framework *string + sourceJobID *string + ) + + err := row.Scan( + &m.ID, + &m.OwnerUserID, + &m.Name, + &description, + &m.StorageKey, + &m.FileSize, + &fileChecksum, + &faaObjectKey, + &targetChip, + &inputShape, + &m.Classes, + &framework, + &m.Source, + &sourceJobID, + &m.CreatedAt, + &m.UpdatedAt, + &m.UploadedAt, + &m.DeletedAt, + ) + if err != nil { + return nil, err + } + + m.Description = derefString(description) + m.FileChecksum = derefString(fileChecksum) + m.FAAObjectKey = derefString(faaObjectKey) + m.TargetChip = derefString(targetChip) + m.Framework = derefString(framework) + m.SourceJobID = derefString(sourceJobID) + m.InputShape = toIntSlice(inputShape) + + // 正規化時間為 UTC,對齊 in-memory(time.Now().UTC())。 + m.CreatedAt = m.CreatedAt.UTC() + m.UpdatedAt = m.UpdatedAt.UTC() + if m.UploadedAt != nil { + u := m.UploadedAt.UTC() + m.UploadedAt = &u + } + if m.DeletedAt != nil { + d := m.DeletedAt.UTC() + m.DeletedAt = &d + } + + return &m, nil +} + +// derefString 解指標字串,nil 視為空字串(對齊 in-memory zero value)。 +func derefString(s *string) string { + if s == nil { + return "" + } + return *s +} + +// nullableUUID 把可能為空的 source_job_id(UUID 欄位)轉成寫入值: +// 空字串 → nil(寫 NULL,避免空字串無法 cast 成 UUID 報錯);否則原樣傳入。 +func nullableUUID(s string) any { + if s == "" { + return nil + } + return s +} + +// toInt32Slice 把 []int 轉 []int32(pgx INT[] encode 用)。nil 維持 nil(寫 NULL)。 +func toInt32Slice(in []int) []int32 { + if in == nil { + return nil + } + out := make([]int32, len(in)) + for i, v := range in { + out[i] = int32(v) + } + return out +} + +// toIntSlice 把 DB decode 出的 []int32 轉回 []int。nil/空維持 nil(對齊 in-memory omitempty)。 +func toIntSlice(in []int32) []int { + if len(in) == 0 { + return nil + } + out := make([]int, len(in)) + for i, v := range in { + out[i] = int(v) + } + return out +} diff --git a/visionA-backend/internal/model/postgres_repository_db_test.go b/visionA-backend/internal/model/postgres_repository_db_test.go new file mode 100644 index 0000000..4223d23 --- /dev/null +++ b/visionA-backend/internal/model/postgres_repository_db_test.go @@ -0,0 +1,350 @@ +//go:build dbtest + +// PostgresRepository 的真 DB 整合測試(DB 接入塊 1,子任務 1.5–1.7)。 +// +// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。 +// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。 +// +// 執行: +// +// go test -tags=dbtest ./internal/model/... +// # 無本機 Docker 時,Orchestrator 在 130 補跑: +// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ +// go test -tags=dbtest ./internal/model/... +// +// 涵蓋: +// - 1.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、NotFound、List 三 filter、 +// soft delete、Save 需 ID)。 +// - 1.6 integration/真 DB:array round-trip、upsert 保留 CreatedAt、soft-delete 後 List 不含、 +// List filter SQL 正確性、faa_object_key nullable round-trip。 +// - 1.7 邊界:空 List、重複 Save、併發 Save 同 ID、context cancel。 +package model + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "visiona-backend/internal/db/testsupport" +) + +// newPGRepo 啟動一次性測試 DB、truncate、確保 demo user 存在,回傳 repo + ownerID。 +// +// 每個測試各自呼叫一次(SetupTestDB 內含 t.Cleanup teardown)。owner 用 testsupport +// 的固定 demo user UUID,滿足 models.owner_user_id 的 FK。 +func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) { + t.Helper() + tdb := testsupport.SetupTestDB(t) + tdb.Truncate(t, "models", "users") + owner := tdb.EnsureDemoUser(t) + return NewPostgresRepository(tdb.Pool), tdb, owner +} + +// --------------------------------------------------------------------------- +// 1.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case) +// --------------------------------------------------------------------------- + +func TestPG_SaveAndGet(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + m := &Model{ + ID: uuid.NewString(), + OwnerUserID: owner, + Name: "yolo-v5", + StorageKey: "models/" + owner + "/m-1.nef", + FileSize: 1024 * 1024, + Source: SourceUploaded, + TargetChip: "kl520", + } + require.NoError(t, r.Save(ctx, m)) + + got, err := r.Get(ctx, m.ID) + require.NoError(t, err) + assert.Equal(t, "yolo-v5", got.Name) + assert.Equal(t, owner, got.OwnerUserID) + assert.Equal(t, int64(1024*1024), got.FileSize) + assert.Equal(t, SourceUploaded, got.Source) + assert.False(t, got.CreatedAt.IsZero()) + assert.False(t, got.UpdatedAt.IsZero()) +} + +func TestPG_Get_NotFound(t *testing.T) { + r, _, _ := newPGRepo(t) + _, err := r.Get(context.Background(), uuid.NewString()) + assert.ErrorIs(t, err, ErrNotFound) +} + +func TestPG_List_Filter(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + // 第二個 owner,驗證 owner 過濾。 + owner2 := tdb.InsertUser(t, "", "") + + id1, id2 := uuid.NewString(), uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: id1, OwnerUserID: owner, Name: "a", StorageKey: "k1", Source: SourceUploaded, TargetChip: "kl520"})) + require.NoError(t, r.Save(ctx, &Model{ID: id2, OwnerUserID: owner, Name: "b", StorageKey: "k2", Source: SourceConverted, TargetChip: "kl720"})) + require.NoError(t, r.Save(ctx, &Model{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", StorageKey: "k3", Source: SourceUploaded, TargetChip: "kl520"})) + + // 依 owner 過濾 + list, err := r.List(ctx, ListFilter{OwnerUserID: owner}) + require.NoError(t, err) + assert.Len(t, list, 2) + + // 依 chip 過濾(owner+chip 複合) + list, err = r.List(ctx, ListFilter{OwnerUserID: owner, TargetChip: "kl520"}) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, id1, list[0].ID) + + // 依 source 過濾(不限 owner) + list, err = r.List(ctx, ListFilter{Source: SourceConverted}) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, id2, list[0].ID) + + // 無 filter(admin) + list, err = r.List(ctx, ListFilter{}) + require.NoError(t, err) + assert.Len(t, list, 3) + + // owner + source 複合 + list, err = r.List(ctx, ListFilter{OwnerUserID: owner, Source: SourceUploaded}) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, id1, list[0].ID) +} + +func TestPG_Delete_SoftDelete(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "x", StorageKey: "k"})) + require.NoError(t, r.Delete(ctx, id)) + + // Get 不到 + _, err := r.Get(ctx, id) + assert.ErrorIs(t, err, ErrNotFound) + + // List 不含 + list, _ := r.List(ctx, ListFilter{OwnerUserID: owner}) + assert.Empty(t, list) + + // 重複 Delete 回 ErrNotFound + assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound) +} + +func TestPG_Save_RequiresID(t *testing.T) { + r, _, owner := newPGRepo(t) + assert.Error(t, r.Save(context.Background(), &Model{Name: "no-id", OwnerUserID: owner})) +} + +// --------------------------------------------------------------------------- +// 1.6 integration/真 DB +// --------------------------------------------------------------------------- + +// array round-trip:input_shape INT[] / classes TEXT[] 寫入後讀回相等。 +func TestPG_ArrayRoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + in := &Model{ + ID: id, + OwnerUserID: owner, + Name: "with-arrays", + StorageKey: "k", + Source: SourceConverted, + InputShape: []int{1, 3, 224, 224}, + Classes: []string{"face", "person", "car"}, + Framework: "onnx", + } + require.NoError(t, r.Save(ctx, in)) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape) + assert.Equal(t, []string{"face", "person", "car"}, got.Classes) + assert.Equal(t, "onnx", got.Framework) +} + +// 空 / nil array:寫入 nil,讀回應為 nil(對齊 in-memory omitempty 語意)。 +func TestPG_ArrayNilRoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "no-arrays", StorageKey: "k", Source: SourceUploaded})) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Nil(t, got.InputShape) + assert.Nil(t, got.Classes) +} + +// upsert 保留 CreatedAt:第二次 Save(同 ID、未刪除)保留首次 created_at、更新其他欄位 + updated_at。 +func TestPG_Upsert_PreservesCreatedAt(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded})) + first, err := r.Get(ctx, id) + require.NoError(t, err) + + // 稍候,確保 updated_at 會推進。 + time.Sleep(10 * time.Millisecond) + + // 第二次 Save:帶不同(更早 / 不同)的 CreatedAt,應被忽略而保留 first.CreatedAt。 + require.NoError(t, r.Save(ctx, &Model{ + ID: id, + OwnerUserID: owner, + Name: "v2", + StorageKey: "k2", + Source: SourceUploaded, + CreatedAt: time.Now().Add(-72 * time.Hour).UTC(), // 試圖覆蓋,應被忽略 + })) + second, err := r.Get(ctx, id) + require.NoError(t, err) + + assert.Equal(t, "v2", second.Name) + assert.Equal(t, "k2", second.StorageKey) + assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值") + assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進") +} + +// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。 +func TestPG_Upsert_AfterSoftDelete_ResetsCreatedAt(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + id := uuid.NewString() + oldCreated := time.Now().Add(-100 * time.Hour).UTC() + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded, CreatedAt: oldCreated})) + require.NoError(t, r.Delete(ctx, id)) + + // 復活:deleted_at 仍在 DB(被 EXCLUDED.deleted_at=NULL 清掉),created_at 用新傳入值。 + newCreated := time.Now().UTC() + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "revived", StorageKey: "k", Source: SourceUploaded, CreatedAt: newCreated})) + + got, err := r.Get(ctx, id) + require.NoError(t, err) + assert.Equal(t, "revived", got.Name) + assert.Nil(t, got.DeletedAt, "復活後不應仍為 deleted") + assert.WithinDuration(t, newCreated, got.CreatedAt, time.Microsecond, "復活後 created_at 應採新值") +} + +// faa_object_key nullable round-trip:有值寫入讀回相等;空值讀回空字串。 +func TestPG_FAAObjectKey_NullableRoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + // 有值(converted 類) + idWith := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ + ID: idWith, OwnerUserID: owner, Name: "converted", StorageKey: "k", + Source: SourceConverted, FAAObjectKey: "models/" + owner + "/job-1.nef", + })) + gotWith, err := r.Get(ctx, idWith) + require.NoError(t, err) + assert.Equal(t, "models/"+owner+"/job-1.nef", gotWith.FAAObjectKey) + + // 空值(uploaded 類)— 讀回空字串。 + idWithout := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "uploaded", StorageKey: "k", Source: SourceUploaded})) + gotWithout, err := r.Get(ctx, idWithout) + require.NoError(t, err) + assert.Equal(t, "", gotWithout.FAAObjectKey) +} + +// source_job_id(UUID nullable):有值 / 空值 round-trip。 +func TestPG_SourceJobID_NullableRoundTrip(t *testing.T) { + ctx := context.Background() + r, _, owner := newPGRepo(t) + + jobID := uuid.NewString() + idWith := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: idWith, OwnerUserID: owner, Name: "j", StorageKey: "k", Source: SourceConverted, SourceJobID: jobID})) + gotWith, err := r.Get(ctx, idWith) + require.NoError(t, err) + assert.Equal(t, jobID, gotWith.SourceJobID) + + idWithout := uuid.NewString() + require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "n", StorageKey: "k", Source: SourceUploaded})) + gotWithout, err := r.Get(ctx, idWithout) + require.NoError(t, err) + assert.Equal(t, "", gotWithout.SourceJobID) +} + +// --------------------------------------------------------------------------- +// 1.7 邊界 +// --------------------------------------------------------------------------- + +// 空 List:乾淨 DB 回空 slice(非 nil error)。 +func TestPG_List_Empty(t *testing.T) { + r, _, owner := newPGRepo(t) + list, err := r.List(context.Background(), ListFilter{OwnerUserID: owner}) + require.NoError(t, err) + assert.Empty(t, list) + assert.NotNil(t, list, "List 應回 non-nil 空 slice") +} + +// 重複 Save 同 ID(未刪除):不報錯,最終為單一列、最後一次內容。 +func TestPG_Save_Duplicate(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + id := uuid.NewString() + for i := 0; i < 5; i++ { + require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "dup", StorageKey: "k", Source: SourceUploaded})) + } + assert.Equal(t, 1, tdb.CountRows(t, "models"), "重複 upsert 同 ID 應只有一列") +} + +// 併發 Save 同 ID:不應 panic / FK 衝突;最終仍為單一列。 +func TestPG_Save_ConcurrentSameID(t *testing.T) { + ctx := context.Background() + r, tdb, owner := newPGRepo(t) + + id := uuid.NewString() + const n = 20 + var wg sync.WaitGroup + errs := make([]error, n) + for i := 0; i < n; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "concurrent", StorageKey: "k", Source: SourceUploaded}) + }(i) + } + wg.Wait() + + for i, e := range errs { + assert.NoError(t, e, "併發 Save #%d", i) + } + assert.Equal(t, 1, tdb.CountRows(t, "models"), "併發 upsert 同 ID 應只有一列") +} + +// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。 +func TestPG_ContextCancel(t *testing.T) { + r, _, owner := newPGRepo(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // 立即取消 + + err := r.Save(ctx, &Model{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", StorageKey: "k", Source: SourceUploaded}) + assert.Error(t, err, "已取消 ctx 的 Save 應回 error") + + _, err = r.Get(ctx, uuid.NewString()) + assert.Error(t, err, "已取消 ctx 的 Get 應回 error") + + _, err = r.List(ctx, ListFilter{OwnerUserID: owner}) + assert.Error(t, err, "已取消 ctx 的 List 應回 error") +} diff --git a/visionA-backend/internal/usersession/redis.go b/visionA-backend/internal/usersession/redis.go new file mode 100644 index 0000000..6e4f6ec --- /dev/null +++ b/visionA-backend/internal/usersession/redis.go @@ -0,0 +1,386 @@ +package usersession + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/redis/go-redis/v9" +) + +// redisKeyPrefix 是所有 user session key 的命名空間前綴。 +// +// 加前綴的理由: +// - 與同一個 Redis db 內其他用途的 key 隔離(即使 RedisConfig.DB 已分 index,前綴再加一層保險)。 +// - 方便用 SCAN MATCH "usersession:*" 觀測 / 排查(CleanupExpired 也靠它列出 key)。 +const redisKeyPrefix = "usersession:" + +// RedisUserSessionStore 是 Store 的 Redis 實作。 +// +// 設計重點(對齊 docs/autoflow/04-architecture/database.md §2.7): +// +// - **雙 TTL** 取代 InMemoryStore 的手動 CleanupExpired goroutine: +// idle TTL(每次 Update 續期)+ absolute TTL(建立後固定上限,不續期)。 +// 做法:把 session JSON 存進一個 Redis key,key 的 Redis TTL 設為 +// min(idleTTL, 距 absolute deadline 的剩餘時間)。 +// 這樣 key 在「閒置超過 idle」或「建立超過 absolute」任一條件成立時都會被 Redis 自動清掉, +// 不需要 background goroutine 掃描。 +// +// - **absolute deadline 精準防護**:CreatedAt 存進 value,Get 時再算一次 +// now - CreatedAt > absolute,雙重保險(即使因為時鐘 / 續期計算誤差讓 key 多活一瞬間, +// Get 仍會視為過期回 ErrNoSession 並順手刪除)。 +// +// - **Extra map JSON 序列化**:整個 Session 用 encoding/json 序列化進 value, +// Extra map[string]any 自然被序列化。caller 須自我約束放可 JSON 序列化的型別 +// (usersession.go Session.Extra 註解已載明此約束)。 +// +// 並發安全:所有方法都是單一 Redis 指令或 store 內無共享 mutable state,go-redis client +// 本身並發安全。 +// +// 安全:OIDCCodeVerifier / AccessToken / IDTokenRaw 等敏感欄位雖序列化進 Redis value, +// 但不會進入任何 log(store 內不 log value)。 +type RedisUserSessionStore struct { + client redis.UniversalClient + + // idleTTL / absoluteTTL 在建構時固定(來自 UserSessionConfig.IdleTTL / AbsoluteTTL)。 + // + // 與 InMemoryStore.CleanupExpired(ctx, idle, abs) 把 timeout 當參數傳不同:Redis 版的 TTL + // 必須在「寫入 key 當下」就決定(Redis TTL 是 per-key 設定),所以 store 自己持有這兩個值。 + idleTTL time.Duration + absoluteTTL time.Duration +} + +// 編譯期確認 RedisUserSessionStore 滿足 Store interface。 +var _ Store = (*RedisUserSessionStore)(nil) + +// redisSession 是寫入 Redis value 的序列化結構。 +// +// 用獨立 struct(而非直接序列化 Session)的理由: +// - 明確控制哪些欄位落地、用穩定的 JSON key(Session struct 沒有 json tag, +// 直接序列化會用 Go 欄位名,未來改欄位名會破壞既有 value 相容性)。 +// - 時間用 UnixNano 存,跨程序 / 跨機器無時區歧義,且 Get 算 absolute deadline 直接做整數運算。 +type redisSession struct { + ID string `json:"id"` + UserID string `json:"uid,omitempty"` + Email string `json:"email,omitempty"` + Name string `json:"name,omitempty"` + CreatedAtUnix int64 `json:"created_at"` + LastSeenAtUnix int64 `json:"last_seen_at"` + OIDCState string `json:"oidc_state,omitempty"` + OIDCNonce string `json:"oidc_nonce,omitempty"` + OIDCCodeVerifier string `json:"oidc_cv,omitempty"` + AccessToken string `json:"access_token,omitempty"` + IDTokenRaw string `json:"id_token_raw,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +// NewRedisUserSessionStore 建立 Redis-backed 的 user session store。 +// +// client 為已連線的 go-redis client(main.go 傳 db.RedisClient.Client();測試傳 miniredis client)。 +// idleTTL / absoluteTTL 來自 cfg.UserSession.IdleTTL(預設 24h)/ AbsoluteTTL(預設 168h)。 +// +// idleTTL / absoluteTTL <= 0 視為「該維度不過期」: +// - idleTTL <= 0 → key 不設 idle 上限(仍受 absolute 限制)。 +// - absoluteTTL <= 0 → 無 absolute 上限(key 只受 idle 限制)。 +// - 兩者皆 <= 0 → key 永不過期(PERSIST 語意;正式環境不應如此設定,僅測試 / 特例用)。 +func NewRedisUserSessionStore(client redis.UniversalClient, idleTTL, absoluteTTL time.Duration) *RedisUserSessionStore { + return &RedisUserSessionStore{ + client: client, + idleTTL: idleTTL, + absoluteTTL: absoluteTTL, + } +} + +func redisKey(id string) string { + return redisKeyPrefix + id +} + +// effectiveTTL 計算「寫入 key 當下」應設的 Redis TTL。 +// +// now = 現在時間 +// createdAt = session 建立時間(absolute deadline 的錨點) +// +// 回傳值語意(go-redis SET 的 expiration 參數): +// - > 0 → 設這個 TTL +// - == 0 → 不過期(redis.KeepTTL 不適用 SET 新值,故用特殊處理:呼叫端用 0 代表 PERSIST) +// +// 計算:取 min(idleTTL, 距 absolute deadline 的剩餘)。 +// - idleTTL <= 0:不考慮 idle,只剩 absolute remaining。 +// - absoluteTTL <= 0:不考慮 absolute,只剩 idleTTL。 +// - 兩者皆 <= 0:回 0(永不過期)。 +// - 若有 absolute 上限且 absolute remaining <= 0(已到 / 超過 absolute):回 <=0。 +// +// ⚠️ 回傳 0 有兩種語意,呼叫端(save)**必須**搭配 s.absoluteTTL 才能區分: +// - s.absoluteTTL == 0 → 0 代表合法 PERSIST(永不過期)。 +// - s.absoluteTTL > 0 → 0(或 <0)代表已到 absolute deadline、應視為過期。 +// +// 因此 save 用 `ttl <= 0 && s.absoluteTTL > 0` 判定過期,而非只看 `ttl < 0`, +// 避免 absRemaining 恰好對齊到 0 ns 時繞過 absolute 上限。 +func (s *RedisUserSessionStore) effectiveTTL(now, createdAt time.Time) time.Duration { + hasIdle := s.idleTTL > 0 + hasAbs := s.absoluteTTL > 0 + + switch { + case !hasIdle && !hasAbs: + return 0 // 永不過期 + case hasIdle && !hasAbs: + return s.idleTTL + case !hasIdle && hasAbs: + return createdAt.Add(s.absoluteTTL).Sub(now) + default: + absRemaining := createdAt.Add(s.absoluteTTL).Sub(now) + if absRemaining < s.idleTTL { + return absRemaining + } + return s.idleTTL + } +} + +// save 序列化 redisSession 並用計算出的 TTL 寫入 Redis。 +// +// expectExists:true 時用 SET ... XX(key 必須已存在才寫,供 Update 防「順便建立」); +// false 時普通 SET(供 Create)。 +// +// 回傳 (false, nil) 代表 XX 條件不成立(key 不存在),由 Update 翻譯成 ErrNoSession。 +func (s *RedisUserSessionStore) save(ctx context.Context, rs *redisSession, now time.Time, expectExists bool) (ok bool, err error) { + ttl := s.effectiveTTL(now, time.Unix(0, rs.CreatedAtUnix)) + // effectiveTTL == 0 的兩種語意必須分流,否則「有 absolute 上限但 absRemaining 恰好 + // 對齊到 0 ns」會被誤判成 PERSIST,讓該 key 永不過期、繞過 absolute 上限: + // - s.absoluteTTL == 0 時,0 代表合法 PERSIST(idle+absolute 皆停用)→ 放行寫入。 + // - s.absoluteTTL > 0 時,<=0 代表已到(或超過)absolute deadline → 不應寫入, + // 視為過期(Update 翻譯成 ErrNoSession)。用 <= 同時涵蓋 <0 與 ==0。 + if ttl <= 0 && s.absoluteTTL > 0 { + // 已到 / 超過 absolute deadline,不應再寫入(Update 視為過期)。 + return false, ErrSessionExpired + } + + payload, err := json.Marshal(rs) + if err != nil { + return false, fmt.Errorf("usersession: marshal session: %w", err) + } + + // ttl == 0 → 永不過期(redis SET 不帶 expiration)。 + args := redis.SetArgs{} + if ttl > 0 { + args.TTL = ttl + } + if expectExists { + args.Mode = "XX" + } + + // SetArgs 回傳 redis.Nil 代表 XX 不成立(key 不存在)。 + res, err := s.client.SetArgs(ctx, redisKey(rs.ID), payload, args).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return false, nil // XX 條件不成立 + } + return false, fmt.Errorf("usersession: redis set: %w", err) + } + _ = res + return true, nil +} + +// Create 實作 Store.Create。產生隨機 ID 的新 session 並寫入 Redis(TTL = idle 與 absolute 取小)。 +func (s *RedisUserSessionStore) Create(ctx context.Context) (*Session, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + id, err := generateSessionID() + if err != nil { + return nil, err + } + now := nowFunc() + sess := &Session{ + ID: id, + CreatedAt: now, + LastSeenAt: now, + } + + rs := toRedisSession(sess) + ok, err := s.save(ctx, rs, now, false) + if err != nil { + return nil, err + } + if !ok { + // 普通 SET(非 XX)理論上不會回 !ok;保險處理為 internal error。 + return nil, ErrInvalidConfig + } + return copySessionValue(sess), nil +} + +// Get 實作 Store.Get。 +// +// 找不到(含已被 Redis TTL 清掉)回 ErrNoSession。**不**續期 idle TTL(對齊 InMemoryStore: +// Get 不更新 LastSeenAt,避免無條件刷新延長 idle window)。 +// +// absolute 精準防護:取出後再算一次 now - CreatedAt > absolute,超過則 Delete + 回 ErrNoSession。 +func (s *RedisUserSessionStore) Get(ctx context.Context, id string) (*Session, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if id == "" { + return nil, ErrNoSession + } + + val, err := s.client.Get(ctx, redisKey(id)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return nil, ErrNoSession + } + return nil, fmt.Errorf("usersession: redis get: %w", err) + } + + var rs redisSession + if err := json.Unmarshal([]byte(val), &rs); err != nil { + return nil, fmt.Errorf("usersession: unmarshal session: %w", err) + } + sess := fromRedisSession(&rs) + + // absolute deadline 精準防護(雙重保險,理論上 key 早被 TTL 清掉)。 + if s.absoluteTTL > 0 && nowFunc().Sub(sess.CreatedAt) > s.absoluteTTL { + _ = s.client.Del(ctx, redisKey(id)).Err() + return nil, ErrNoSession + } + + return sess, nil +} + +// Update 實作 Store.Update。 +// +// 把 caller 改過的 Session 寫回 Redis、LastSeenAt 設 now、並續期 idle TTL(同時仍受 absolute 上限)。 +// 找不到 ID(含已過期被清)回 ErrNoSession,不會「順便建立」(用 SET XX 達成)。 +func (s *RedisUserSessionStore) Update(ctx context.Context, sess *Session) error { + if err := ctx.Err(); err != nil { + return err + } + if sess == nil || sess.ID == "" { + return ErrNoSession + } + + now := nowFunc() + + // 先確認既有 key 存在並取得 CreatedAt(caller 傳進來的 CreatedAt 可能被誤改; + // 以 store 內既有值為準,對齊 InMemoryStore「Update 不改 CreatedAt」語意)。 + existing, err := s.client.Get(ctx, redisKey(sess.ID)).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return ErrNoSession + } + return fmt.Errorf("usersession: redis get (update): %w", err) + } + var existingRS redisSession + if err := json.Unmarshal([]byte(existing), &existingRS); err != nil { + return fmt.Errorf("usersession: unmarshal session (update): %w", err) + } + + // absolute 已過 → 視為不存在(順手刪)。 + createdAt := time.Unix(0, existingRS.CreatedAtUnix) + if s.absoluteTTL > 0 && now.Sub(createdAt) > s.absoluteTTL { + _ = s.client.Del(ctx, redisKey(sess.ID)).Err() + return ErrNoSession + } + + rs := toRedisSession(sess) + rs.CreatedAtUnix = existingRS.CreatedAtUnix // 保留既有 CreatedAt + rs.LastSeenAtUnix = now.UnixNano() // 續期:LastSeenAt = now + + ok, err := s.save(ctx, rs, now, true) // XX:key 必須仍存在 + if err != nil { + if errors.Is(err, ErrSessionExpired) { + // effectiveTTL <= 0 且有 absolute 上限:已到 / 超過 absolute,視為不存在。 + _ = s.client.Del(ctx, redisKey(sess.ID)).Err() + return ErrNoSession + } + return err + } + if !ok { + // XX 不成立:key 在 Get 與 SET 之間剛好過期。 + return ErrNoSession + } + + // 把更新後的 LastSeenAt 反映回 caller pointer(對齊 InMemoryStore.Update)。 + sess.LastSeenAt = time.Unix(0, rs.LastSeenAtUnix) + sess.CreatedAt = createdAt + return nil +} + +// Delete 實作 Store.Delete。不存在為 no-op(DEL 對不存在 key 回 0,不報錯)。 +func (s *RedisUserSessionStore) Delete(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + if id == "" { + return nil + } + if err := s.client.Del(ctx, redisKey(id)).Err(); err != nil { + return fmt.Errorf("usersession: redis del: %w", err) + } + return nil +} + +// CleanupExpired 實作 Store.CleanupExpired。 +// +// Redis 版**靠 key TTL 自動過期**,不需 background 掃描;此方法在 Redis 模式下基本是 no-op, +// 保留只為滿足 Store interface(in-memory fallback 仍會用到)。 +// +// 回傳 (0, nil):Redis 已自動清掉過期 key,無「本次清除數量」可報。 +// 參數 idleTimeout / absoluteTimeout 被忽略(TTL 已在寫入時依 store 持有的 idle/absolute 設定)。 +func (s *RedisUserSessionStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } + // no-op:Redis TTL 負責過期。 + return 0, nil +} + +// toRedisSession 把 Session 轉成可序列化的 redisSession(時間轉 UnixNano)。 +func toRedisSession(sess *Session) *redisSession { + return &redisSession{ + ID: sess.ID, + UserID: sess.UserID, + Email: sess.Email, + Name: sess.Name, + CreatedAtUnix: sess.CreatedAt.UnixNano(), + LastSeenAtUnix: sess.LastSeenAt.UnixNano(), + OIDCState: sess.OIDCState, + OIDCNonce: sess.OIDCNonce, + OIDCCodeVerifier: sess.OIDCCodeVerifier, + AccessToken: sess.AccessToken, + IDTokenRaw: sess.IDTokenRaw, + Extra: sess.Extra, + } +} + +// fromRedisSession 把序列化結構還原成 Session(UnixNano 轉 time.Time)。 +func fromRedisSession(rs *redisSession) *Session { + return &Session{ + ID: rs.ID, + UserID: rs.UserID, + Email: rs.Email, + Name: rs.Name, + CreatedAt: time.Unix(0, rs.CreatedAtUnix), + LastSeenAt: time.Unix(0, rs.LastSeenAtUnix), + OIDCState: rs.OIDCState, + OIDCNonce: rs.OIDCNonce, + OIDCCodeVerifier: rs.OIDCCodeVerifier, + AccessToken: rs.AccessToken, + IDTokenRaw: rs.IDTokenRaw, + Extra: rs.Extra, + } +} + +// copySessionValue 製作 Session 的副本(含 Extra map 深一層),避免 caller 改到 store 回傳值。 +// +// 與 InMemoryStore.copySession 等價,但為 free function(Redis store 無需持鎖)。 +func copySessionValue(src *Session) *Session { + dst := *src + if src.Extra != nil { + dst.Extra = make(map[string]any, len(src.Extra)) + for k, v := range src.Extra { + dst.Extra[k] = v + } + } + return &dst +} diff --git a/visionA-backend/internal/usersession/redis_integration_test.go b/visionA-backend/internal/usersession/redis_integration_test.go new file mode 100644 index 0000000..2527c21 --- /dev/null +++ b/visionA-backend/internal/usersession/redis_integration_test.go @@ -0,0 +1,169 @@ +//go:build dbtest + +// DB 接入塊 4(4.6 真 Redis 部分):RedisUserSessionStore 對「真 Redis」的整合測試。 +// +// build tag `dbtest`:預設 `go test ./...` 不編譯本檔。 +// 執行方式(二擇一): +// +// 1. 連既有 Redis(例如 130 上的 visiona-redis,從 130 內部或經 DOCKER_HOST 轉發): +// VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/usersession/... +// (無密碼;若有密碼設 VISIONA_TEST_REDIS_PASSWORD) +// +// 2. 用 testcontainers 自動起一次性 Redis(需 Docker daemon;本機無 docker 時用 +// DOCKER_HOST=tcp://192.168.0.130:2375 指向 130 的 docker): +// go test -tags=dbtest ./internal/usersession/... +// +// 本檔與 redis_test.go(miniredis,預設可跑)互補:miniredis 已驗雙 TTL 邏輯, +// 本檔驗「真 Redis 真的會在 TTL 到期時清掉 key」+ 序列化 round-trip 在真 Redis 下成立。 +package usersession + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/redis/go-redis/v9" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +// realRedisClient 取得連到真 Redis 的 client: +// - 若設了 VISIONA_TEST_REDIS_ADDR → 直連該位址(130 visiona-redis 補跑路徑)。 +// - 否則 → testcontainers 起一次性 redis:7-alpine。 +func realRedisClient(t *testing.T) *redis.Client { + t.Helper() + + if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" { + client := redis.NewClient(&redis.Options{ + Addr: addr, + Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"), + }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + t.Fatalf("ping VISIONA_TEST_REDIS_ADDR=%s: %v", addr, err) + } + t.Cleanup(func() { _ = client.Close() }) + return client + } + + ctx := context.Background() + req := testcontainers.ContainerRequest{ + Image: "redis:7-alpine", + ExposedPorts: []string{"6379/tcp"}, + WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second), + } + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("start redis container: %v", err) + } + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + host, err := container.Host(ctx) + if err != nil { + t.Fatalf("container host: %v", err) + } + port, err := container.MappedPort(ctx, "6379/tcp") + if err != nil { + t.Fatalf("container port: %v", err) + } + client := redis.NewClient(&redis.Options{Addr: host + ":" + port.Port()}) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +// TestRealRedis_RoundTripAndIsolation 驗證所有欄位在真 Redis 下序列化 round-trip 正確, +// 且 key 帶 prefix、互不干擾。 +func TestRealRedis_RoundTripAndIsolation(t *testing.T) { + client := realRedisClient(t) + store := NewRedisUserSessionStore(client, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + + sess, err := store.Create(ctx) + if err != nil { + t.Fatalf("Create: %v", err) + } + sess.UserID = "u-real" + sess.Email = "real@example.com" + sess.OIDCCodeVerifier = "cv-secret" + sess.AccessToken = "at-secret" + sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)} + if err := store.Update(ctx, sess); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.UserID != "u-real" || got.Email != "real@example.com" || + got.OIDCCodeVerifier != "cv-secret" || got.AccessToken != "at-secret" { + t.Fatalf("round-trip mismatch: %+v", got) + } + if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) { + t.Fatalf("Extra round-trip mismatch: %+v", got.Extra) + } + + // key 帶 prefix(用底層 client 直接驗)。 + if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 { + t.Fatalf("expected prefixed key to exist") + } +} + +// TestRealRedis_TTLExpiry 驗證真 Redis 會在短 idle TTL 到期後自動清掉 key。 +// +// 用很短的 idle(2s)讓測試在合理時間內完成(不需 FastForward;真 Redis 用真時鐘)。 +func TestRealRedis_TTLExpiry(t *testing.T) { + client := realRedisClient(t) + store := NewRedisUserSessionStore(client, 2*time.Second, 168*time.Hour) + ctx := context.Background() + + sess, err := store.Create(ctx) + if err != nil { + t.Fatalf("Create: %v", err) + } + // 立刻拿得到。 + if _, err := store.Get(ctx, sess.ID); err != nil { + t.Fatalf("Get right after create: %v", err) + } + + // 等超過 idle TTL(2s)+ 緩衝。 + time.Sleep(3 * time.Second) + + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("session should be TTL-expired on real redis, got %v", err) + } +} + +// TestRealRedis_AbsoluteCapsIdle 驗證真 Redis 下 absolute 上限封頂 idle 續期: +// idle 10s、absolute 3s → 即使一直 Update,3s 後仍應消失。 +func TestRealRedis_AbsoluteCapsIdle(t *testing.T) { + client := realRedisClient(t) + store := NewRedisUserSessionStore(client, 10*time.Second, 3*time.Second) + ctx := context.Background() + + sess, err := store.Create(ctx) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // 每 1s Update 一次,共 4 次(idle 永遠新,但 absolute 3s 會砍)。 + deadline := time.Now().Add(4 * time.Second) + var lastErr error + for time.Now().Before(deadline) { + time.Sleep(1 * time.Second) + lastErr = store.Update(ctx, sess) + } + // 最後一次 Update 落在 absolute 後 → ErrNoSession;或 Get 確認已清。 + if lastErr != nil && !errors.Is(lastErr, ErrNoSession) { + t.Fatalf("unexpected Update error: %v", lastErr) + } + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("session should be gone after absolute deadline, got %v", err) + } +} diff --git a/visionA-backend/internal/usersession/redis_test.go b/visionA-backend/internal/usersession/redis_test.go new file mode 100644 index 0000000..9349b18 --- /dev/null +++ b/visionA-backend/internal/usersession/redis_test.go @@ -0,0 +1,637 @@ +package usersession + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" +) + +// setupRedisStore 起一個 in-process miniredis(純 Go,不需 docker),回傳: +// - store:RedisUserSessionStore(idle / absolute TTL 由 caller 指定) +// - mr:miniredis 實例(可呼叫 FastForward / SetTime / TTL 模擬時間與檢查 key TTL) +// +// miniredis 與本 package 的 nowFunc 是「兩個時鐘」: +// - store 算 absolute deadline 用 nowFunc() +// - miniredis 算 key TTL 用自己的內部時鐘 +// +// 測雙 TTL 時兩者都要同步推進(advanceTime helper 一次推兩個)。 +func setupRedisStore(t *testing.T, idle, absolute time.Duration) (*RedisUserSessionStore, *miniredis.Miniredis) { + t.Helper() + mr := miniredis.RunT(t) + client := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = client.Close() }) + store := NewRedisUserSessionStore(client, idle, absolute) + return store, mr +} + +// clock 追蹤「目前已從 base 推進多少」,讓 advanceTime 能對 miniredis 的相對 TTL +// 做增量 FastForward(FastForward 是「把所有 TTL 減去 duration」,必須用增量、不能用絕對 delta)。 +type clock struct { + base time.Time + elapsed time.Duration // 已推進的累計量 +} + +// advanceTo 把時間推進到 base+delta(delta 必須 >= 目前 elapsed,單調遞增)。 +// +// 同步推進兩個時鐘: +// - nowFunc:給 store 算 absolute deadline(app 端邏輯)。 +// - miniredis:用增量 FastForward 推相對 TTL(key 自動過期)+ SetTime 對齊 EXPIREAT 基準。 +func (c *clock) advanceTo(t *testing.T, mr *miniredis.Miniredis, delta time.Duration) time.Time { + t.Helper() + if delta < c.elapsed { + t.Fatalf("clock.advanceTo must be monotonic: delta=%v < elapsed=%v", delta, c.elapsed) + } + inc := delta - c.elapsed + c.elapsed = delta + newNow := c.base.Add(delta) + nowFunc = func() time.Time { return newNow } + mr.SetTime(newNow) + mr.FastForward(inc) // 相對 TTL 增量推進,<=0 的 key 即過期 + return newNow +} + +// ───────────────────────────────────────────────────────── +// 4.5 對齊既有 in-memory test:Create / Get / Update / Delete 基本行為 +// ───────────────────────────────────────────────────────── + +func TestRedisStore_CreateAndGet(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + + sess, err := store.Create(ctx) + if err != nil { + t.Fatalf("Create: %v", err) + } + if sess.ID == "" { + t.Fatalf("Create returned empty ID") + } + if sess.CreatedAt.IsZero() || sess.LastSeenAt.IsZero() { + t.Fatalf("CreatedAt/LastSeenAt should be set") + } + if !sess.CreatedAt.Equal(sess.LastSeenAt) { + t.Fatalf("Create: CreatedAt should == LastSeenAt initially, got %v vs %v", + sess.CreatedAt, sess.LastSeenAt) + } + + got, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ID != sess.ID { + t.Fatalf("Get: ID mismatch want=%s got=%s", sess.ID, got.ID) + } +} + +func TestRedisStore_Get_NotFound(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + _, err := store.Get(context.Background(), "no-such-id") + if !errors.Is(err, ErrNoSession) { + t.Fatalf("expected ErrNoSession, got %v", err) + } +} + +func TestRedisStore_Get_EmptyID(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + _, err := store.Get(context.Background(), "") + if !errors.Is(err, ErrNoSession) { + t.Fatalf("expected ErrNoSession for empty id, got %v", err) + } +} + +// Get 回傳的是副本,外部修改不影響 store 內部狀態。 +func TestRedisStore_Get_ReturnsCopy(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + sess, _ := store.Create(ctx) + + got1, _ := store.Get(ctx, sess.ID) + got1.Email = "tampered@example.com" + got1.Extra = map[string]any{"x": "y"} + + got2, _ := store.Get(ctx, sess.ID) + if got2.Email == "tampered@example.com" { + t.Fatalf("Get should return a copy; mutation leaked into store") + } + if got2.Extra != nil { + t.Fatalf("Get should return a copy; Extra map mutation leaked") + } +} + +func TestRedisStore_Update_MovesLastSeenAt_KeepsCreatedAt(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + sess, _ := store.Create(ctx) + + // 往前推 5 分鐘(同步推進 nowFunc 與 miniredis 時鐘)。 + clk := &clock{base: t0} + t1 := clk.advanceTo(t, mr, 5*time.Minute) + + sess.UserID = "user-123" + sess.Email = "alice@example.com" + if err := store.Update(ctx, sess); err != nil { + t.Fatalf("Update: %v", err) + } + if !sess.LastSeenAt.Equal(t1) { + t.Fatalf("Update should reflect new LastSeenAt back to caller, got %v want %v", + sess.LastSeenAt, t1) + } + + got, _ := store.Get(ctx, sess.ID) + if got.UserID != "user-123" || got.Email != "alice@example.com" { + t.Fatalf("Update did not persist user fields: %+v", got) + } + if !got.LastSeenAt.Equal(t1) { + t.Fatalf("store LastSeenAt not advanced: got %v want %v", got.LastSeenAt, t1) + } + if !got.CreatedAt.Equal(t0) { + t.Fatalf("Update must not change CreatedAt: got %v want %v", got.CreatedAt, t0) + } +} + +// Update 不可「順便建立」不存在的 session(用 SET XX 達成)。 +func TestRedisStore_Update_NotFound(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + err := store.Update(context.Background(), &Session{ID: "ghost", CreatedAt: nowFunc(), LastSeenAt: nowFunc()}) + if !errors.Is(err, ErrNoSession) { + t.Fatalf("expected ErrNoSession, got %v", err) + } +} + +func TestRedisStore_Update_NilSession(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) { + t.Fatalf("expected ErrNoSession for nil, got %v", err) + } +} + +func TestRedisStore_Delete_Idempotent(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + sess, _ := store.Create(ctx) + + if err := store.Delete(ctx, sess.ID); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("after Delete, Get should return ErrNoSession, got %v", err) + } + // 重複刪同一個 ID 應為 no-op。 + if err := store.Delete(ctx, sess.ID); err != nil { + t.Fatalf("Delete on missing should be no-op, got %v", err) + } + // 刪空 ID 也是 no-op。 + if err := store.Delete(ctx, ""); err != nil { + t.Fatalf("Delete empty id should be no-op, got %v", err) + } +} + +// Extra map 與所有欄位(含 OIDC pending + token snapshot)round-trip。 +func TestRedisStore_Extra_And_Fields_RoundTrip(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + sess, _ := store.Create(ctx) + + sess.UserID = "u-9" + sess.Email = "bob@example.com" + sess.Name = "Bob" + sess.OIDCState = "state-xyz" + sess.OIDCNonce = "nonce-abc" + sess.OIDCCodeVerifier = "verifier-123" + sess.AccessToken = "at-secret" + sess.IDTokenRaw = "idt-raw" + sess.Extra = map[string]any{ + "return_to": "/dashboard", + "count": float64(3), // JSON number round-trips as float64 + "flag": true, + } + if err := store.Update(ctx, sess); err != nil { + t.Fatalf("Update: %v", err) + } + + got, err := store.Get(ctx, sess.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.UserID != "u-9" || got.Email != "bob@example.com" || got.Name != "Bob" { + t.Fatalf("identity fields mismatch: %+v", got) + } + if got.OIDCState != "state-xyz" || got.OIDCNonce != "nonce-abc" || got.OIDCCodeVerifier != "verifier-123" { + t.Fatalf("OIDC pending fields mismatch: %+v", got) + } + if got.AccessToken != "at-secret" || got.IDTokenRaw != "idt-raw" { + t.Fatalf("token snapshot fields mismatch: %+v", got) + } + if got.Extra["return_to"] != "/dashboard" || got.Extra["count"] != float64(3) || got.Extra["flag"] != true { + t.Fatalf("Extra round-trip mismatch: %+v", got.Extra) + } +} + +// context 取消應被尊重。 +func TestRedisStore_RespectsContext(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if _, err := store.Create(ctx); !errors.Is(err, context.Canceled) { + t.Fatalf("Create should respect cancelled ctx, got %v", err) + } + if _, err := store.Get(ctx, "x"); !errors.Is(err, context.Canceled) { + t.Fatalf("Get should respect cancelled ctx, got %v", err) + } + if err := store.Update(ctx, &Session{ID: "x"}); !errors.Is(err, context.Canceled) { + t.Fatalf("Update should respect cancelled ctx, got %v", err) + } + if err := store.Delete(ctx, "x"); !errors.Is(err, context.Canceled) { + t.Fatalf("Delete should respect cancelled ctx, got %v", err) + } + if _, err := store.CleanupExpired(ctx, time.Hour, time.Hour); !errors.Is(err, context.Canceled) { + t.Fatalf("CleanupExpired should respect cancelled ctx, got %v", err) + } +} + +// CleanupExpired 在 Redis 模式是 no-op(靠 key TTL)。 +func TestRedisStore_CleanupExpired_NoOp(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + _, _ = store.Create(ctx) + removed, err := store.CleanupExpired(ctx, time.Hour, time.Hour) + if err != nil { + t.Fatalf("CleanupExpired: %v", err) + } + if removed != 0 { + t.Fatalf("Redis CleanupExpired should be no-op, removed=%d", removed) + } +} + +// ───────────────────────────────────────────────────────── +// 4.6 雙 TTL:真 TTL 過期、idle vs absolute(用 miniredis FastForward 模擬時間) +// ───────────────────────────────────────────────────────── + +// 建立後 key 的 Redis TTL 應為 idle(idle < absolute 時)。 +func TestRedisStore_TTL_OnCreate_IsIdle(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + idle := 24 * time.Hour + store, mr := setupRedisStore(t, idle, 168*time.Hour) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + ttl := mr.TTL(redisKey(sess.ID)) + if ttl != idle { + t.Fatalf("create TTL should be idle(%v), got %v", idle, ttl) + } +} + +// idle 過期:閒置超過 idle → key 被 Redis 自動清掉 → Get 回 ErrNoSession。 +func TestRedisStore_IdleExpiry(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + // 閒置 2 小時(> idle 1h),不做任何 Update。 + clk := &clock{base: t0} + clk.advanceTo(t, mr, 2*time.Hour) + + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("idle-expired session should be gone, got %v", err) + } +} + +// idle 續期:在 idle 內持續 Update → 不會因 idle 過期。 +func TestRedisStore_IdleRenewedByUpdate(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + // 每 30 分鐘 Update 一次,共推進 2 小時 —— 每次都在 idle(1h) 內續期。 + clk := &clock{base: t0} + for i := 1; i <= 4; i++ { + clk.advanceTo(t, mr, time.Duration(i)*30*time.Minute) + if err := store.Update(ctx, sess); err != nil { + t.Fatalf("Update #%d at +%dm: %v", i, i*30, err) + } + } + + // 累計 2h 但持續活躍 → 應仍存在。 + if _, err := store.Get(ctx, sess.ID); err != nil { + t.Fatalf("actively-used session should remain, got %v", err) + } +} + +// absolute 過期:即使持續 Update(idle 永遠新),超過 absolute 後仍應失效。 +func TestRedisStore_AbsoluteExpiry_DespiteActivity(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + // idle 1h、absolute 3h。每 30 分鐘 Update(idle 永不過期),但 3h 後 absolute 應砍掉。 + store, mr := setupRedisStore(t, 1*time.Hour, 3*time.Hour) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + // 在 absolute 內持續活躍(+30m ~ +180m)。最後一次 Update 落在 absolute deadline 後。 + clk := &clock{base: t0} + var lastUpdateErr error + for d := 30 * time.Minute; d <= 210*time.Minute; d += 30 * time.Minute { + clk.advanceTo(t, mr, d) + lastUpdateErr = store.Update(ctx, sess) + } + // 超過 absolute(3h)後 Update 應回 ErrNoSession(absolute 砍掉)。 + if !errors.Is(lastUpdateErr, ErrNoSession) { + t.Fatalf("Update past absolute deadline should return ErrNoSession, got %v", lastUpdateErr) + } + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("absolute-expired session should be gone, got %v", err) + } +} + +// absolute 上限封頂:idle 續期時 key TTL 不可超過「距 absolute deadline 的剩餘」。 +func TestRedisStore_UpdateTTL_CappedByAbsolute(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + // idle 2h、absolute 150m。在 +1h 時 Update(key 仍在,idle 未過): + // idle 想續成 2h,但距 absolute 只剩 90m,故 TTL 應被封頂為 90m(< idle 2h)。 + store, mr := setupRedisStore(t, 2*time.Hour, 150*time.Minute) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + clk := &clock{base: t0} + clk.advanceTo(t, mr, 1*time.Hour) + if err := store.Update(ctx, sess); err != nil { + t.Fatalf("Update at +1h: %v", err) + } + ttl := mr.TTL(redisKey(sess.ID)) + if ttl != 90*time.Minute { + t.Fatalf("TTL at +1h should be capped to absolute remaining(90m), got %v", ttl) + } +} + +// key 命名 / 隔離:不同 session 的 key 互不干擾,且都帶 prefix。 +func TestRedisStore_KeyNamingAndIsolation(t *testing.T) { + store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + s1, _ := store.Create(ctx) + s2, _ := store.Create(ctx) + + if !mr.Exists(redisKey(s1.ID)) || !mr.Exists(redisKey(s2.ID)) { + t.Fatalf("both session keys should exist with prefix %q", redisKeyPrefix) + } + // 刪 s1 不影響 s2。 + if err := store.Delete(ctx, s1.ID); err != nil { + t.Fatalf("Delete s1: %v", err) + } + if mr.Exists(redisKey(s1.ID)) { + t.Fatalf("s1 key should be gone") + } + if _, err := store.Get(ctx, s2.ID); err != nil { + t.Fatalf("s2 should remain after deleting s1, got %v", err) + } +} + +// ───────────────────────────────────────────────────────── +// 4.7 邊界 + 併發 +// ───────────────────────────────────────────────────────── + +// 永不過期設定:idle=0 且 absolute=0 → key 無 TTL(PERSIST 語意)。 +func TestRedisStore_NeverExpires_WhenBothZero(t *testing.T) { + store, mr := setupRedisStore(t, 0, 0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + ttl := mr.TTL(redisKey(sess.ID)) + if ttl != 0 { // miniredis TTL=0 代表無到期時間 + t.Fatalf("with idle=abs=0, key should have no TTL, got %v", ttl) + } + // 推 100 天仍在。 + mr.FastForward(100 * 24 * time.Hour) + if _, err := store.Get(ctx, sess.ID); err != nil { + t.Fatalf("no-TTL session should remain after 100d, got %v", err) + } +} + +// 只設 absolute(idle=0):key TTL = absolute;過期後消失。 +func TestRedisStore_OnlyAbsolute(t *testing.T) { + t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) + restore := withFrozenNow(t, t0) + defer restore() + + store, mr := setupRedisStore(t, 0, 2*time.Hour) + mr.SetTime(t0) + ctx := context.Background() + sess, _ := store.Create(ctx) + + if ttl := mr.TTL(redisKey(sess.ID)); ttl != 2*time.Hour { + t.Fatalf("with idle=0, create TTL should be absolute(2h), got %v", ttl) + } + clk := &clock{base: t0} + clk.advanceTo(t, mr, 3*time.Hour) + if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) { + t.Fatalf("session past absolute should be gone, got %v", err) + } +} + +// 連線中斷:Redis 不可達時 store 方法回非 nil error(不 panic、不靜默成功)。 +func TestRedisStore_ConnectionDown(t *testing.T) { + store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + sess, _ := store.Create(ctx) + + mr.Close() // 模擬 Redis 掛掉 + + if _, err := store.Create(ctx); err == nil { + t.Fatalf("Create should error when redis is down") + } + if _, err := store.Get(ctx, sess.ID); err == nil { + t.Fatalf("Get should error when redis is down") + } + if err := store.Update(ctx, sess); err == nil { + t.Fatalf("Update should error when redis is down") + } + if err := store.Delete(ctx, sess.ID); err == nil { + t.Fatalf("Delete should error when redis is down") + } +} + +// 併發 smoke test(race detector 抓 data race)。 +// 注意:本測試不凍結 nowFunc(避免與其他凍結時間的測試 race;nowFunc 是 package 變數)。 +func TestRedisStore_ConcurrentAccess(t *testing.T) { + store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour) + ctx := context.Background() + const goroutines = 16 + const iterations = 30 + + var wg sync.WaitGroup + wg.Add(goroutines) + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < iterations; j++ { + sess, err := store.Create(ctx) + if err != nil { + t.Errorf("Create: %v", err) + return + } + _, _ = store.Get(ctx, sess.ID) + sess.UserID = "u" + _ = store.Update(ctx, sess) + _ = store.Delete(ctx, sess.ID) + } + }() + } + wg.Wait() +} + +// TestRedisStore_EffectiveTTL_BoundaryDecision 直接斷言 effectiveTTL 的回傳值與 +// save 的「過期 vs PERSIST」決策邊界,**不繞 miniredis**(純決策邏輯,不需 docker)。 +// +// 守的是 Minor-1 修正:當有 absolute 上限時,TTL 算到 0 ns(now 恰好對齊 deadline) +// 不能被當成 PERSIST,否則 key 變永不過期、繞過 absolute 上限。 +func TestRedisStore_EffectiveTTL_BoundaryDecision(t *testing.T) { + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + const ( + idle = 24 * time.Hour + abs = 168 * time.Hour + ) + + cases := []struct { + name string + idleTTL time.Duration + absoluteTTL time.Duration + now time.Time + createdAt time.Time + wantTTL time.Duration // effectiveTTL 期望回傳 + wantExpired bool // save 是否應視為已過期(回 ErrSessionExpired) + wantPersist bool // 是否為合法 PERSIST(ttl==0 但 absoluteTTL==0) + }{ + { + // idle + absolute 皆停用 → 合法 PERSIST(ttl 0、不過期)。 + name: "both_disabled_persist", + idleTTL: 0, + absoluteTTL: 0, + now: base, + createdAt: base, + wantTTL: 0, + wantExpired: false, + wantPersist: true, + }, + { + // 有 absolute,now 恰好對齊 deadline(absRemaining == 0 ns)→ 必須視為過期, + // 不能當 PERSIST。這是 Minor-1 的核心邊界。 + name: "absolute_now_equals_deadline", + idleTTL: 0, + absoluteTTL: abs, + now: base.Add(abs), + createdAt: base, + wantTTL: 0, + wantExpired: true, + wantPersist: false, + }, + { + // deadline 前 1 ns → 還沒到期,TTL = 1 ns。 + name: "absolute_one_ns_before_deadline", + idleTTL: 0, + absoluteTTL: abs, + now: base.Add(abs - 1), + createdAt: base, + wantTTL: 1, + wantExpired: false, + wantPersist: false, + }, + { + // deadline 後 1 ns → 已過期,TTL = -1 ns。 + name: "absolute_one_ns_after_deadline", + idleTTL: 0, + absoluteTTL: abs, + now: base.Add(abs + 1), + createdAt: base, + wantTTL: -1, + wantExpired: true, + wantPersist: false, + }, + { + // idle+absolute 皆啟用、剛建立 → TTL 取較小的 idle(idle < abs remaining)。 + name: "both_enabled_takes_idle", + idleTTL: idle, + absoluteTTL: abs, + now: base, + createdAt: base, + wantTTL: idle, + wantExpired: false, + wantPersist: false, + }, + { + // idle+absolute 皆啟用、now 對齊 absolute deadline → absRemaining 0 < idle, + // 取 absRemaining(0) → 仍須視為過期(有 absolute 上限)。 + name: "both_enabled_now_equals_abs_deadline", + idleTTL: idle, + absoluteTTL: abs, + now: base.Add(abs), + createdAt: base, + wantTTL: 0, + wantExpired: true, + wantPersist: false, + }, + { + // 只有 idle、無 absolute → 永遠回 idle,不受 deadline 概念影響、不過期。 + name: "only_idle", + idleTTL: idle, + absoluteTTL: 0, + now: base.Add(100 * time.Hour), + createdAt: base, + wantTTL: idle, + wantExpired: false, + wantPersist: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := NewRedisUserSessionStore(nil, tc.idleTTL, tc.absoluteTTL) + + gotTTL := s.effectiveTTL(tc.now, tc.createdAt) + if gotTTL != tc.wantTTL { + t.Errorf("effectiveTTL = %v, want %v", gotTTL, tc.wantTTL) + } + + // 重現 save 的過期決策(不需 Redis client,純比較): + // 有 absolute 上限且 ttl <= 0 → 過期;否則放行(ttl == 0 在無 absolute 時為 PERSIST)。 + gotExpired := gotTTL <= 0 && s.absoluteTTL > 0 + if gotExpired != tc.wantExpired { + t.Errorf("save expiry decision = %v, want %v (ttl=%v, absoluteTTL=%v)", + gotExpired, tc.wantExpired, gotTTL, s.absoluteTTL) + } + + // PERSIST = ttl 0 且非過期決策(即 absoluteTTL == 0)。 + gotPersist := gotTTL == 0 && !gotExpired + if gotPersist != tc.wantPersist { + t.Errorf("PERSIST decision = %v, want %v", gotPersist, tc.wantPersist) + } + }) + } +} diff --git a/visionA-backend/migrations/0001_create_users_models.down.sql b/visionA-backend/migrations/0001_create_users_models.down.sql new file mode 100644 index 0000000..b760b76 --- /dev/null +++ b/visionA-backend/migrations/0001_create_users_models.down.sql @@ -0,0 +1,7 @@ +-- 0001_create_users_models.down.sql +-- +-- 反向:先刪 models(有 FK 指向 users),再刪 users。 +-- index 隨 table DROP 自動移除,不需個別 DROP INDEX。 + +DROP TABLE IF EXISTS models; +DROP TABLE IF EXISTS users; diff --git a/visionA-backend/migrations/0001_create_users_models.up.sql b/visionA-backend/migrations/0001_create_users_models.up.sql new file mode 100644 index 0000000..a344780 --- /dev/null +++ b/visionA-backend/migrations/0001_create_users_models.up.sql @@ -0,0 +1,53 @@ +-- 0001_create_users_models.up.sql +-- +-- DB 接入塊 0/塊 1:建立模型庫持久化的最小集合(users + models)。 +-- 對齊 docs/autoflow/04-architecture/database.md §0.3、§4、§5.1。 +-- +-- 為何第一份 migration 含 users:models.owner_user_id 是 REFERENCES users(id) 的 FK, +-- 必須先有 users 表。雛形 users 為 stub(固定 demo-user),但 schema 與 FK 約束第一份就要到位。 +-- +-- 環境事實(已驗證):PostgreSQL 14.23,已裝 extension 只有 plpgsql。 +-- - gen_random_uuid():PG14 內建(pgcrypto 已併入核心),可直接用,不需 CREATE EXTENSION。 +-- - CITEXT:未裝。users.email 改用 TEXT,大小寫不敏感唯一改用 functional unique index +-- ON lower(email)(塊 1 真正寫 user 落盤前,users 僅為 stub,影響極小)。 + +-- users(Phase 1 stub;雛形固定 demo-user) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL, + name TEXT, + password_hash TEXT, + org_id UUID, + roles TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +-- email 大小寫不敏感唯一(取代 CITEXT,因 PG14.23 未裝 citext extension)。 +CREATE UNIQUE INDEX uq_users_email_lower ON users (lower(email)); + +-- models(塊 1 主體;使用者最關心,重啟後模型庫資料還在) +CREATE TABLE models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + description TEXT, + storage_key TEXT NOT NULL, + file_size BIGINT NOT NULL, + file_checksum TEXT, + faa_object_key TEXT, -- ADR-017 (a) B1,nullable(上傳類留 NULL) + target_chip TEXT, + input_shape INT[], + classes TEXT[], + framework TEXT, + source TEXT NOT NULL, + source_job_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + uploaded_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); +-- owner-scoped index(List by owner / chip / source;只索引未刪除紀錄)。 +CREATE INDEX idx_models_owner_active ON models (owner_user_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_models_owner_chip_active ON models (owner_user_id, target_chip) WHERE deleted_at IS NULL; +CREATE INDEX idx_models_owner_source_active ON models (owner_user_id, source) WHERE deleted_at IS NULL; diff --git a/visionA-backend/migrations/0002_create_devices.down.sql b/visionA-backend/migrations/0002_create_devices.down.sql new file mode 100644 index 0000000..da71cf6 --- /dev/null +++ b/visionA-backend/migrations/0002_create_devices.down.sql @@ -0,0 +1,6 @@ +-- 0002_create_devices.down.sql +-- +-- 反向:刪除 devices 表(index 隨 table DROP 自動移除)。 +-- devices 目前無其他表以 FK 指向它(token 表於 0003 才建),故可直接 DROP。 + +DROP TABLE IF EXISTS devices; diff --git a/visionA-backend/migrations/0002_create_devices.up.sql b/visionA-backend/migrations/0002_create_devices.up.sql new file mode 100644 index 0000000..96a3cf0 --- /dev/null +++ b/visionA-backend/migrations/0002_create_devices.up.sql @@ -0,0 +1,44 @@ +-- 0002_create_devices.up.sql +-- +-- DB 接入塊 2:建立 devices 表(裝置綁定身分長期保存)。 +-- 對齊 docs/autoflow/04-architecture/database.md §2.2、§4、§5.1。 +-- +-- 接續 0001(users + models);owner_user_id 是 REFERENCES users(id) 的 FK,users 表已於 0001 建立。 +-- +-- 環境事實(已驗證,與 0001 相同):PostgreSQL 14.23,gen_random_uuid() PG14 內建可直接用。 + +-- devices(雙狀態模型:remote_status[tunnel-level] + status[USB-level],見 §2.2 Minor-3) +CREATE TABLE devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id UUID NOT NULL REFERENCES users(id), + name TEXT NOT NULL, + device_type TEXT, + serial_number TEXT, + + -- tunnel-level 狀態(雲端 remote-proxy 觀察 tunnel 連線) + remote_status TEXT NOT NULL DEFAULT 'offline', -- online | offline | reconnecting | error + last_seen_at TIMESTAMPTZ, -- 最後一次 tunnel heartbeat 時間 + last_connected_at TIMESTAMPTZ, -- tunnel 最近一次建立時間 + + -- USB-level 狀態(local agent 上報) + status TEXT NOT NULL DEFAULT 'unknown', -- online | offline | unknown + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + paired_at TIMESTAMPTZ, -- 配對完成時間(nullable) + deleted_at TIMESTAMPTZ +); + +-- partial unique index(取代 table-level UNIQUE(owner_user_id, serial_number))。 +-- 決策:已 soft-delete 的 device serial「能」重新註冊(見 §4 / §0.4 決策 2)。 +-- 語意:唯一性只對「未刪除」(deleted_at IS NULL)紀錄成立; +-- 刪掉一筆後,同 (owner, serial) 可再 INSERT 一筆新的(新 id),不違反 unique。 +-- 這讓使用者把裝置刪了之後仍能用同一個實體 serial 重新註冊。 +CREATE UNIQUE INDEX uq_devices_owner_serial_active + ON devices (owner_user_id, serial_number) + WHERE deleted_at IS NULL; + +-- owner-scoped index(List by owner;只索引未刪除紀錄)。 +CREATE INDEX idx_devices_owner_active ON devices (owner_user_id) WHERE deleted_at IS NULL; +-- remote_status filter(前端優先顯示雲端連線狀態;只索引未刪除紀錄)。 +CREATE INDEX idx_devices_remote_status ON devices (remote_status) WHERE deleted_at IS NULL; diff --git a/visionA-backend/migrations/0003_create_token_tables.down.sql b/visionA-backend/migrations/0003_create_token_tables.down.sql new file mode 100644 index 0000000..411db53 --- /dev/null +++ b/visionA-backend/migrations/0003_create_token_tables.down.sql @@ -0,0 +1,8 @@ +-- 0003_create_token_tables.down.sql +-- +-- 反向:刪除 session_tokens + pairing_tokens 兩張表(index 隨 table DROP 自動移除)。 +-- 兩表互不以 FK 互指(parent_token_hash 是純 TEXT 稽核欄位、非 FK 約束), +-- 故 DROP 順序無相依;先 session_tokens 後 pairing_tokens 僅為對稱閱讀。 + +DROP TABLE IF EXISTS session_tokens; +DROP TABLE IF EXISTS pairing_tokens; diff --git a/visionA-backend/migrations/0003_create_token_tables.up.sql b/visionA-backend/migrations/0003_create_token_tables.up.sql new file mode 100644 index 0000000..97c2f79 --- /dev/null +++ b/visionA-backend/migrations/0003_create_token_tables.up.sql @@ -0,0 +1,56 @@ +-- 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); diff --git a/visionA-backend/migrations/embed.go b/visionA-backend/migrations/embed.go new file mode 100644 index 0000000..cbde5ed --- /dev/null +++ b/visionA-backend/migrations/embed.go @@ -0,0 +1,16 @@ +// Package migrations 把 visionA-backend 的 SQL migration 檔嵌入 binary, +// 供 internal/db 的 migration runner(golang-migrate iofs source)使用。 +// +// 嵌入式設計理由: +// - binary 自帶 migration,部署時不需另外複製 migrations/ 目錄、不會有「檔案找不到」風險。 +// - testcontainers 整合測試與 cmd/migrate 共用同一份 FS,行為一致。 +// +// 命名規範(golang-migrate 序號式):NNNN_description.up.sql / .down.sql。 +package migrations + +import "embed" + +// FS 嵌入本目錄下所有 .sql migration 檔。 +// +//go:embed *.sql +var FS embed.FS