feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)

把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整
(PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動,
只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。

- 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate
  runner(embed)+ cmd/migrate + testcontainers 測試基礎建設
- 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、
  三維 filter(owner/chip/source)、soft-delete partial index
- 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位
- 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK
- 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine
  (tunnel session 維持 in-memory,yamux handle 不可序列化)
- 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子)
  + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error)

降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗
不自動 fallback in-memory(避免多機 session 不同步)。

DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique
index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈,
in-memory fallback 未受影響。

docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md
(409/503 錯誤碼、/healthz 新行為、device unpair cascade)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-20 18:28:04 +08:00
parent 22f329cdf3
commit 4d0b870480
56 changed files with 7267 additions and 135 deletions

View File

@ -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。持久資料相關 APImodel / device / token在 PG 不可用時回此碼,不回假資料。|
> **DB 接入後的降級策略fail-fast2026-06-20 使用者拍板)**
> - **PostgreSQL 掉** → 持久資料相關 APImodel / device / token`503 SERVICE_UNAVAILABLE`**不回假資料、不 fallback in-memory**(避免回傳過期/不一致資料)。
> - **Redis 掉** → session 驗證失敗(請求視為未認證 → `401 UNAUTHORIZED`**不自動 fallback in-memory session**(避免多機部署下各實例 session 不同步)。
> - DB unique violation → `409 CONFLICT`(而非 500讓前端能區分「衝突」與「未預期錯誤」。
---

View File

@ -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不依賴 130130 僅用於 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 存 Summaryhandle 仍留本地)。本期範圍外。 |
### 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 裡的 keymodels/{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
// FAAObjectKeyADR-017 (a) B1model 在 File Access Agent 上的 object key。
// 只有 Source=converted轉檔→promote 進 FAA類有值上傳類留空。
// JSON tag = "-"(不對前端揭露內部 storage keyADR-017 決策 2
// DB 欄位 nullable見 §42026-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 / presetSource = 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 實際的兩個 structschema 也據此分表(見 §4
```go
// internal/auth/types.go
package auth
// internal/auth/auth.go
import "time"
// PairingToken — 短期一次性配對 tokenvAc_ + 32 hex15min 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 TTLnil = 永不過期(測試用)
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 tokenvAs_ + 64 hex90 天 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 mapprocess 重啟就掉。Phase 1 考慮 Redis 存 summaryTTL + heartbeat
> **2026-06-20 補充DB 接入)**:要區分兩種 session
> - **`internal/usersession`browser cookie session→ 接 Redis**。Session struct`internal/usersession/usersession.go`)含 OIDC pending stateOIDCState/Nonce/CodeVerifier+ token snapshotAccessToken/IDTokenRaw+ `Extra map[string]any``RedisUserSessionStore` 用 Redis 雙 TTLidle = `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
-- 環境適配(塊 02026-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 indexsoft-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 interfacePairingStore / SessionTokenStore
-- 欄位與方法集不同pairing 有 used_at 一次性語意、kindsession 有 parent_token_hash、無 used_at
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL欄位語意混淆、CHECK 約束變複雜。
-- 3. 分表後各表 schema 乾淨、index 各自最佳化repository 一對一對映 Store最直觀。
-- 代價稽核「pairing→session 升級鏈」需跨表 joinsession_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 TTLNULL = 永不過期(測試用)
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 天 TTLNULL = 永不過期
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) B1nullable上傳類留 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 + modelsdevice / 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 DatabaseConfigPostgreSQL
```go
// internal/config/config.go — 掛在 Config struct 上Database DatabaseConfig
// DatabaseConfig 控制 PostgreSQL 連線持久業務資料model / device / token
//
// 啟用判定Enabled()Host / User / DBName 三者全非空才視為啟用;
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memorylocal 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 hostcredential 已取得;他人在 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-fulltestcontainers 用 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 RedisConfiguserSession
```go
// internal/config/config.go — 掛在 Config struct 上Redis RedisConfig
// RedisConfig 控制 Redis 連線(僅 userSessionbrowser 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_PASSWORDvisionA 專用實例必設密碼;禁止 commit
DB int // VISIONA_REDIS_DB預設 0db 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不依賴 130stage 收尾才接 visionA 專用 PG / Redis。

View File

@ -232,3 +232,71 @@ VISIONA_FILE_ACCESS_FAA_BASE_URL=
# download token 有效期(秒)— ADR-017 Q2 區間 60300s預設 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-memorylocal dev fallback雛形行為不變
# ⚠️ 塊 0 範圍:即使啟用,目前 repository 仍是 in-memory建池只為驗證基礎建設 + 讓 schema 就位);
# model/device/token 切到 Postgres 是塊 13。
#
# ⚠️ DB 由他人在 stage host(130) 另開 visionA 專用實例並提供連線資訊visionA 端不 provision、只接上。
# ⚠️ PASSWORD 不可 commitprod 走 Secrets Manager / Vaultlog 永遠不印密碼/DSN 全文。
# visionA 專用 PG hostcredential 已取得;他人在 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=
# sslmodestage/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僅 userSessionbrowser cookie session
# ============================================================
# 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
#
# ⚠️ 塊 0 只先把 config 鉤子留好main.go 尚未 wire等塊 4 接 RedisUserSessionStore
# 啟用判定HOST 非空即啟用;未啟用 → userSession 仍用 in-memoryprocess 重啟掉 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

View File

@ -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 ./...

View File

@ -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 adapterPOC 複製)
│ ├── converter/ # StubClientPhase 2 才實作)
│ ├── storage/ # Store interface + LocalFSStoreHMAC presigned URL
│ ├── config/ # Config + Load()12-Factor
│ ├── config/ # Config + Load()12-Factor含 DatabaseConfig / RedisConfig
│ ├── db/ # DB 接入塊 0pgxpool 連線池 + migration runner嵌入式
│ │ └── testsupport/ # testcontainers 整合測試 helper + fixture factory-tags=dbtest
│ └── logger/ # slog JSON logger wrapper
├── migrations/ # golang-migrate SQLNNNN_*.up/down.sql+ embed.go
├── docker/
│ ├── Dockerfile.api-server # multi-stagenon-roothealthcheck
│ ├── 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 runnergolang-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 是塊 13。
```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`B4api-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 接入塊 0pool ping / migrate 冪等 / up-down-up / schema 形狀 / fail-fast`-tags=dbtest`,需 Docker
---

View File

@ -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 接入塊 0DB 基礎建設) =====
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5.1。
//
// 啟用條件cfg.Database.Enabled()Host/User/DBName 全非空)。
// 未啟用 → dbPool 為 nil所有 repository 維持 in-memorylocal dev fallback雛形行為不變
//
// ⚠️ 塊 0 範圍:只建池 + 跑 migration**尚未**把 model/device/token repository 切到
// Postgres那是塊 13。因此即使 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-fastDB 設定了卻連不上應停機,而非靜默 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 為 niluserSession 維持 in-memory + cleanup goroutine雛形行為不變
//
// ⚠️ 範圍:只接 internal/usersessioncookie session。tunnel sessioninternal/session
// value 是活的 yamux Handle 不可序列化,維持 in-memorydatabase.md §2.7 已定)。
var redisClient *db.RedisClient
if cfg.Redis.Enabled() {
rc, rErr := db.NewRedisClient(context.Background(), cfg.Redis, log)
if rErr != nil {
// fail-fastRedis 設定了卻連不上應停機,與 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 TokenOIDC 之外的雛形 token store =====
pairingStore := auth.NewInMemoryPairingStore()
sessionTokenStore := auth.NewInMemorySessionTokenStore()
// DB 接入塊 3 — dbPool != nil 時切到 Postgres分表pairing_tokens + session_tokens
// 否則維持 in-memorylocal dev fallback雛形行為不變
// Store interface 不變handler / 呼叫端internal/api/pairing.go一行都不需改。
// 關鍵Postgres 版以 token_hashHashToken(plaintext))當 PKDB 不存明文;
// 呼叫端統一傳 plaintextstore 內部統一 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 SessionOB5唯一認證路徑 =====
// cfg.Validate() 已確保所有必填欄位存在,這裡可以放心 wire。
@ -102,7 +183,18 @@ func main() {
os.Exit(1)
}
userSessionStore := usersession.NewInMemoryStore()
// userSession storeDB 接入塊 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)
// ===== Repositoriesin-memory雛形 =====
deviceRepo := device.NewInMemoryRepository()
modelRepo := model.NewInMemoryRepository()
// ===== Repositories =====
// deviceDB 接入塊 2 — dbPool != nil 時切到 PostgresRepository否則維持 in-memory
// local dev fallback雛形行為不變。Repository interface 不變handler 一行都不需改。
var deviceRepo device.Repository
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascadeDeleteTx 需 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.2database.md §6 =====
// 刪 device → 同時撤銷該 device 的 pairing + session token。
// - Postgres用 db.WithTx 把三步包成單一交易device 軟刪 + 兩張 token 表撤銷),整筆原子。
// - in-memory依序執行無交易行為一致刪 device 後 token 也撤)。
// 注入 Deps.DeviceUnpairerunpair 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")
}
// modelDB 接入塊 1 — cfg.Database.Enabled() 且建池成功時切到 PostgresRepository
// 否則維持 in-memorylocal 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")
}
// ===== ConverterstubPhase 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塊 1seedDemoData 內部會先 ensure
// demo user 列並改用合法 UUID owner / idnil 時維持雛形 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 C1StaticUserID 不再注入 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 unpairPostgres tx / in-memory 依序)
Storage: storageStore,
Converter: converterClient,
Conversion: conversionService, // Phase 0.8nil 時 /api/conversion/* 回 501
@ -252,6 +401,10 @@ func main() {
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
RelayPublicURL: cfg.Server.RelayPublicURL,
// 塊 5.4/healthz 依賴 pingnil = 未啟用、略過)
HealthDBPool: healthDBPool,
HealthRedis: healthRedis,
// OIDCOB5唯一認證路徑
OIDCProvider: oidcProvider,
SessionManager: userSessionMgr,
@ -266,9 +419,14 @@ func main() {
}
// ===== User session cleanup goroutine =====
// DB 接入塊 4Redis 模式靠 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)

View File

@ -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_idUUID + 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_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。
// dbPool 為 nilin-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 的 owneruser_id塊 3 起 pairing token 也落 DBuser_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 時走 Postgresowner 對齊 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 tokenlog plaintext 方便開發 — 雛形 demo 用,生產禁用)
pt, _, err := pairings.Create(ctx, userID, 24*time.Hour)
// owner 用 pairingOwnerIDDB-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 {

View File

@ -0,0 +1,76 @@
// Command migrate 是 visionA-backend 的獨立 migration 工具。
//
// DB 接入塊 0提供「不啟動整個 api-server 也能跑 migration」的入口
// 供 CI / 部署流程 / 手動操作使用。api-server 啟動時的 auto-migrateVISIONA_DB_AUTO_MIGRATE
// 與本工具共用同一份嵌入式 migrationmigrations.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)
}
}

View File

@ -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
)

View File

@ -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=

View File

@ -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.2database.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任一失敗回 503fail-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)

View File

@ -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.2database.md §6
// - DeviceUnpairer 非 nilmain.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})

View File

@ -19,6 +19,12 @@ const (
ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE"
// ErrCodeInvalidSignature 用於 /storage/* 驗簽失敗 / URL 過期。
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
// ErrCodeConflict 對齊 HTTP 409unique 約束衝突 — 同 owner+serial 重複註冊)。
ErrCodeConflict = "CONFLICT"
// ErrCodeServiceUnavailable 對齊 HTTP 503。
// DB 接入塊 5.4 fail-fast 策略PG 連線失敗 / context 逾時 → 503讓 load balancer 知道
// 這台不健康,而非回假資料或 500500 會誤導為「程式 bug」503 才是「依賴暫時不可用」)。
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
)
// ErrorBody 是 API 錯誤回應的 envelope 結構。

View File

@ -0,0 +1,101 @@
// errors_db.go — 統一把 DBpgx錯誤映射成對外 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 + 通用 messageraw error 只進 server log含 request_id
//
// 映射規則(依優先序):
//
// | 來源 | HTTP | code |
// |----------------------------------------|------|-----------------------|
// | context 逾時 / 取消DeadlineExceeded / Canceled| 503 | SERVICE_UNAVAILABLE |
// | 連線層失敗pgconn 無 SQLSTATErefused/reset/EOF| 503 | SERVICE_UNAVAILABLE |
// | unique violationSQLSTATE 23505 | 409 | CONFLICT |
// | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR |
// | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR |
//
// ⚠️ not found 不在此映射domain 層device.ErrNotFound / auth.ErrInvalidToken 等)已把
// pgx.ErrNoRows 轉成自己的 sentinelhandler 應先用 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.PgErrorPG 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)
}

View File

@ -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/messageraw 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 downconnect 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)
}

View File

@ -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.PoolPostgres與 db.RedisClientRedis都有 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 非 nilPostgres 啟用)→ 每次 /healthz 都 ping失敗回 503。
// - Redis 非 nilRedis 啟用)→ 每次 /healthz 都 ping失敗回 503。
// - 兩者皆 nilin-memory 模式 / 未啟用)→ 略過維持「process 活著就 ok」的舊行為。
//
// 為什麼 ping 失敗回 503讓 load balancer / K8s readiness 把這台不健康的實例拉出輪替,
// 而非繼續送流量進來碰 DB 拿 503/假資料。
type HealthDeps struct {
DBPool HealthPinger // Postgres 連線池db.Poolnil = 未啟用
Redis HealthPinger // Redis clientdb.RedisClientnil = 未啟用
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
// 失敗 → 503body 標出哪個依賴不健康,但不洩漏 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})
}
}

View File

@ -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 失敗 → 503PG 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()

View File

@ -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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 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.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "get model", err)
return
}
// 第一階段 owner-onlyB 分享後續階段);非 owner 回 403。

View File

@ -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.4DB 錯誤經 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.4DB 錯誤經 errors.go 映射PG down → 503其餘 → 500收掉塊 3 M1 raw error 洩漏。
WriteDBError(c, deps.Logger, "revoke pairing token", err)
return
}

View File

@ -0,0 +1,157 @@
// unpair.go — device unpair 的 cascade 撤銷協調者DB 接入塊 5.2)。
//
// 「刪 device → 同時撤銷該 device 名下所有 pairing + session token」是一個跨 store 的一致性
// 操作database.md §6。為了讓 handlerdevices.go 的 unpair維持薄、同時支援 Postgres
// 交易)與 in-memorylocal-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.ErrNotFoundhandler 轉 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.PostgresSessionTokenStoremain.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.ErrNotFoundWithTx 會 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
}

View File

@ -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 tokendatabase.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 revokerdevice 在 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 已 MarkUsedcascade 撤銷靠 WHERE device_idtoken 須先綁 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)
}

View File

@ -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 到該 deviceMarkUsed 綁 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)
}

View File

@ -138,6 +138,31 @@ func (s *InMemoryPairingStore) List(ctx context.Context, userID string) ([]*Pair
return out, nil
}
// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 pairing tokencascade 撤銷,塊 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 分鐘)。

View File

@ -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)
// p3dev-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)
}

View File

@ -0,0 +1,323 @@
// PostgresPairingStore 是 PairingStore 的 PostgreSQL 持久層實作DB 接入塊 3
//
// 與 InMemoryPairingStore 實作相同的 PairingStore interface讓 main.go 在 dbPool != nil
// 時無痛切換、handler 與呼叫端internal/api/pairing.go一行都不需改。
//
// 對齊:
// - database.md §2.4PairingToken struct、§4pairing_tokens 表 schema
// - migrations/0003_create_token_tables.up.sqlpairing_tokens 表)
//
// ── 關鍵改動plaintext → token_hash 當 PKdatabase.md 結尾提醒、塊 3 子任務 3.3)──
//
// in-memory 版以 plaintext token 當 map keyPostgres 版改以 token_hashHashToken(plaintext)
// 當 PKDB 永不存明文 tokensecurity.md §1.3)。
//
// 所有「以 plaintext 查詢」的方法Validate / MarkUsed / Revoke一律先 HashToken(plaintext)
// 再以 hash 比對。呼叫端統一傳 plaintext 進來(已 grep 確認internal/api/pairing.go 的
// Validate / MarkUsed / Revoke 全部傳 plaintext 的 vAc_ tokenstore 內部統一 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。
// - CleanupExpiredDELETE 所有 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 tokencascade 撤銷,塊 5.2)。
//
// 在傳入的 Querierpool 或 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 綁定後才有值;未綁定的 tokendevice_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 UUIDMarkUsed 前為 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 helperspairing + session token 共用)
// ==========================================================================
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scan helper 同時服務單列查詢與 List。
type rowScanner interface {
Scan(dest ...any) error
}
// utcPtr 將 nullable 時間指標正規化為 UTCnil 維持 nil
func utcPtr(p *time.Time) *time.Time {
if p == nil {
return nil
}
u := p.UTC()
return &u
}

View File

@ -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.goCreateAndValidate、unknown token、
// MarkUsed 一次性+冪等、Revoke、CleanupExpired、List by user、Validate expired
// - 3.8 integration/真 DBhash 當 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_idFK → 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 當 PKDB 內實際存的是 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)
// 明文不該等於任何 PKplaintext != 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, "明文不應出現為 PKDB 永不存明文)")
}
// 狀態優先序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 層 raceN 併發 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 cascadeRevokeByDeviceTxpairing
// ---------------------------------------------------------------------------
// 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 兩個 tokenp1 未撤、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 的 tokendevice_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)
// p3dev2仍可用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)
}

View File

@ -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.4SessionToken struct、§4session_tokens 表 schema
// - migrations/0003_create_token_tables.up.sqlsession_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 永不存明文 tokensecurity.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。
// - CleanupExpiredDELETE 所有 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-memoryrevoked → 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 tokencascade 撤銷,塊 5.2)。
//
// 在傳入的 Querierpool 或 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
}

View File

@ -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.goCreateAndGet、NotFound、expired、
// Revoke 冪等、Revoke NotFound、CleanupExpired、NeverExpires ttl=0
// - 3.8 integration/真 DBhash 當 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 當 PKDB 內實際存的是 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-tripGet 不回 parentGet 走狀態檢查),改用直接 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 為空 → 存 NULLround-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 tokenvAs_ + 64 hex
const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// ---------------------------------------------------------------------------
// 塊 5.2 cascadeRevokeByDeviceTxsession
// ---------------------------------------------------------------------------
// 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)
}

View File

@ -141,6 +141,28 @@ func (s *InMemorySessionTokenStore) Revoke(ctx context.Context, plaintext string
return nil
}
// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 session tokencascade 撤銷,塊 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()

View File

@ -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)
}

View File

@ -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-memorylocal 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-memorylocal dev fallback
func (c DatabaseConfig) Enabled() bool {
return c.Host != "" && c.User != "" && c.DBName != ""
}
// RedisConfig 控制 Redis 連線(僅 userSessionbrowser 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_PASSWORDvisionA 專用實例必設密碼;禁止 commit
DB int // VISIONA_REDIS_DB預設 0db 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

View File

@ -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 接入塊 0PostgreSQL 連線(持久業務資料)。
// 對齊 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 不 wireRedis 連線(僅 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),
},
}
}

View File

@ -0,0 +1,147 @@
//go:build dbtest
// DB 接入塊 00.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"))
}

View File

@ -0,0 +1,89 @@
// Package db 提供 visionA-backend 的 PostgreSQL 連線池pgxpool與 migration runner。
//
// DB 接入塊 0DB 基礎建設):本套件只負責「連線池建池 + 啟動 ping + graceful shutdown +
// migration」這些跨 domain 共用的基礎設施;不含任何 store / repository 的 Postgres 實作
// (那是塊 13會新增各 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"
// 故這些參數**只**加在 BuildDSNpgxpool 路徑),不加在 baseDSNmigrate 路徑)。
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 DSNhost/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)
}

View File

@ -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:// prefixgot %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會直接 FATALSQLSTATE 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_connsgot %s", got)
assert.NotContains(t, got, "pool_min_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_min_connsgot %s", got)
// 標準 libpq 參數仍須正確帶上。
assert.Contains(t, got, "sslmode=disable", "migrateDSN 應保留 sslmodegot %s", got)
// 對照組BuildDSNpgxpool 路徑)仍應帶 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")
}

View File

@ -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 進出點。
//
// 使用嵌入式 migrationmigrations.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 進 binarygolang-migrate 用 blank import 註冊 driver
// 這裡顯式 import + 引用避免被當 unused
var _ = migratepgx.ErrNilConfig

View File

@ -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.Poolrepository 實作直接拿 .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 決定是 fatalDB 啟用時連不上應該停機)。
// 這避免「DB 設定了卻連不上」卻靜默 fallback in-memory 造成資料不一致的隱患。
//
// 逾時:建池與啟動 ping 共用 cfg.ConnTimeout預設 5s
//
// 安全log 只印 SafeTargethost:port/dbnameDSN 與密碼永遠不入 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 直接外露 DSNpgx 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()
}

View File

@ -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 接入塊 4userSession 接 Redis本型別只負責「建連線 + 啟動 ping + graceful close」
// 這些基礎設施userSession 的 store 邏輯在 internal/usersession/redis.goRedisUserSessionStore
//
// 設計對齊 pool.goPostgres Pool的風格
// - 薄包裝,不隱藏底層 clientstore 實作直接拿 .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 只印 SafeRedisTargethost:port/dbPassword 永遠不入 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 clientgraceful 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)
}

View File

@ -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_idNOT 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
}

View File

@ -0,0 +1,151 @@
//go:build dbtest
// Package testsupport 提供 DB 整合測試的共用基礎建設testcontainers-go + Postgres
//
// DB 接入塊 00.6):一次性 Postgres 容器、自動 migrate、truncate helper、fixture factory。
// 塊 13 的 *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 對齊 stagePG 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)
}

View File

@ -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 成功 → commitcommit 失敗回 commit error。
// - panic → rollback 後重新 panic不吞讓上層 recovery middleware 處理)。
//
// ctx 取消會讓 Begin / fn 內的查詢 / Commit 各自因 context 失敗而中止——交易不會半開。
//
// 注意fn 內所有 DB 操作都必須用傳入的 qtx不可再用外層 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 後又 rollbackpgx 對已結束的 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 為 nilrollback 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)

View File

@ -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)
}

View File

@ -0,0 +1,305 @@
// Package device 的 Postgres 持久層實作DB 接入塊 2
//
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface
// 讓 main.go 在 dbPool != nil 時無痛切換、handler 與呼叫端一行都不需改。
//
// 對齊:
// - database.md §2.2Device 雙狀態欄位 + paired_at、§4devices 表 schema
// partial unique index uq_devices_owner_serial_active + owner/remote_status filter index
// - migrations/0002_create_devices.up.sqldevices 表含全部欄位)
//
// 語意對齊 in-memory見 device.go
// - Get / GetBySerial / List 略過 deleted_at IS NOT NULL 的紀錄。
// - GetBySerial 以 (owner_user_id, serial_number) 查未刪除紀錄。
// - Save 為 upsert by IDexisting 且未刪除時保留原 created_atin-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 第二筆會撞 unique23505
// - 但若先把第一筆 soft-deletedeleted_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 新增或更新 deviceupsert by id
//
// 語意對齊 in-memorydevice.go ~line 187
// - 既有且未刪除deleted_at IS NULL→ 保留原 created_at
// - 不存在 / 已刪除(復活)→ 以傳入 created_atzero 時用 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_atzero 時交給 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 相同的軟刪除語意,但在傳入的 Querierpool 或 tx上執行。
//
// 塊 5.2 cascade 撤銷unpair 流程在 db.WithTx 內先呼叫本方法軟刪 device再於同一 tx 對
// pairing_tokens / session_tokens 撤銷——任一步失敗整筆 rollbackdevice 不會「已刪但 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 valuenullable TIMESTAMPTZlast_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-memorytime.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
}

View File

@ -0,0 +1,426 @@
//go:build dbtest
// PostgresRepositorydevice的真 DB 整合測試DB 接入塊 2子任務 2.52.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.goSaveAndGet、GetBySerial 跨 owner 不串、
// List by owner、soft delete、再刪回 NotFound、保留 CreatedAt、Save 需 ID
// - 2.6 integration/真 DBpartial 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)
}
// GetBySerialsoft-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 DEFAULTremote_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)、不同 idpartial 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 同 idupsert 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")
}

View File

@ -0,0 +1,328 @@
// Package model 的 Postgres 持久層實作DB 接入塊 1
//
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface
// 讓 main.go 在 cfg.Database.Enabled() 時無痛切換、handler 與呼叫端一行都不需改。
//
// 對齊:
// - database.md §2.3Model 欄位、§4models 表 schemafaa_object_key / uploaded_at +
// owner/chip/source filter index + soft-delete partial index
// - migrations/0001_create_users_models.up.sqlmodels 表已含全部欄位,塊 1 不需新 migration
//
// 語意對齊 in-memory見 model.go
// - Get / List 略過 deleted_at IS NOT NULL 的紀錄。
// - Save 為 upsert by IDexisting 且未刪除時保留原 created_atin-memory model.go ~line 209
// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。
//
// array 映射pgx v5
// - input_shape INT[] <-> []int32pgx 對 INT[] 預設 decode 成 []int32Model.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 新增或更新 Modelupsert by id
//
// 語意對齊 in-memorymodel.go ~line 209
// - 既有且未刪除deleted_at IS NULL→ 保留原 created_at
// - 不存在 / 已刪除 → 以傳入 created_atzero 時用 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_atzero 時交給 DB now()(用 NULL 觸發 COALESCE
var createdAt any
if !m.CreatedAt.IsZero() {
createdAt = m.CreatedAt.UTC()
} // else: 留 nil → COALESCE($n, now())
// input_shapeModel.InputShape 為 []intDB 為 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_idUUID, NULL以 *string 接再轉空字串;
// input_shapeNULL 或空陣列)掃成 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-memorytime.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_idUUID 欄位)轉成寫入值:
// 空字串 → nil寫 NULL避免空字串無法 cast 成 UUID 報錯);否則原樣傳入。
func nullableUUID(s string) any {
if s == "" {
return nil
}
return s
}
// toInt32Slice 把 []int 轉 []int32pgx 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
}

View File

@ -0,0 +1,350 @@
//go:build dbtest
// PostgresRepository 的真 DB 整合測試DB 接入塊 1子任務 1.51.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.goSaveAndGet、NotFound、List 三 filter、
// soft delete、Save 需 ID
// - 1.6 integration/真 DBarray 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)
// 無 filteradmin
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-tripinput_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_idUUID 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")
}

View File

@ -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 keykey 的 Redis TTL 設為
// min(idleTTL, 距 absolute deadline 的剩餘時間)。
// 這樣 key 在「閒置超過 idle」或「建立超過 absolute」任一條件成立時都會被 Redis 自動清掉,
// 不需要 background goroutine 掃描。
//
// - **absolute deadline 精準防護**CreatedAt 存進 valueGet 時再算一次
// 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 statego-redis client
// 本身並發安全。
//
// 安全OIDCCodeVerifier / AccessToken / IDTokenRaw 等敏感欄位雖序列化進 Redis value
// 但不會進入任何 logstore 內不 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 keySession 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 clientmain.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。
//
// expectExiststrue 時用 SET ... XXkey 必須已存在才寫,供 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 代表合法 PERSISTidle+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 並寫入 RedisTTL = 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 存在並取得 CreatedAtcaller 傳進來的 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) // XXkey 必須仍存在
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-opDEL 對不存在 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 interfacein-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-opRedis 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 把序列化結構還原成 SessionUnixNano 轉 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 functionRedis 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
}

View File

@ -0,0 +1,169 @@
//go:build dbtest
// DB 接入塊 44.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.gominiredis預設可跑互補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。
//
// 用很短的 idle2s讓測試在合理時間內完成不需 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 TTL2s+ 緩衝。
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 → 即使一直 Update3s 後仍應消失。
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)
}
}

View File

@ -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回傳
// - storeRedisUserSessionStoreidle / absolute TTL 由 caller 指定)
// - mrminiredis 實例(可呼叫 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
// 做增量 FastForwardFastForward 是「把所有 TTL 減去 duration」必須用增量、不能用絕對 delta
type clock struct {
base time.Time
elapsed time.Duration // 已推進的累計量
}
// advanceTo 把時間推進到 base+deltadelta 必須 >= 目前 elapsed單調遞增
//
// 同步推進兩個時鐘:
// - nowFunc給 store 算 absolute deadlineapp 端邏輯)。
// - miniredis用增量 FastForward 推相對 TTLkey 自動過期)+ 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 testCreate / 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 snapshotround-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 應為 idleidle < 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 過期:即使持續 Updateidle 永遠新),超過 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 分鐘 Updateidle 永不過期),但 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 應回 ErrNoSessionabsolute 砍掉)。
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 時 Updatekey 仍在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 無 TTLPERSIST 語意)。
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)
}
}
// 只設 absoluteidle=0key 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 testrace detector 抓 data race
// 注意:本測試不凍結 nowFunc避免與其他凍結時間的測試 racenowFunc 是 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 nsnow 恰好對齊 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 // 是否為合法 PERSISTttl==0 但 absoluteTTL==0
}{
{
// idle + absolute 皆停用 → 合法 PERSISTttl 0、不過期
name: "both_disabled_persist",
idleTTL: 0,
absoluteTTL: 0,
now: base,
createdAt: base,
wantTTL: 0,
wantExpired: false,
wantPersist: true,
},
{
// 有 absolutenow 恰好對齊 deadlineabsRemaining == 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 取較小的 idleidle < 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)
}
})
}
}

View File

@ -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;

View File

@ -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 含 usersmodels.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影響極小
-- usersPhase 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) B1nullable上傳類留 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 indexList 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;

View File

@ -0,0 +1,6 @@
-- 0002_create_devices.down.sql
--
-- 反向:刪除 devices 表index 隨 table DROP 自動移除)。
-- devices 目前無其他表以 FK 指向它token 表於 0003 才建),故可直接 DROP。
DROP TABLE IF EXISTS devices;

View File

@ -0,0 +1,44 @@
-- 0002_create_devices.up.sql
--
-- DB 接入塊 2建立 devices 表(裝置綁定身分長期保存)。
-- 對齊 docs/autoflow/04-architecture/database.md §2.2、§4、§5.1。
--
-- 接續 0001users + modelsowner_user_id 是 REFERENCES users(id) 的 FKusers 表已於 0001 建立。
--
-- 環境事實(已驗證,與 0001 相同PostgreSQL 14.23gen_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 indexList 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;

View File

@ -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;

View File

@ -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、§4token 分表段落、§5.1。
--
-- 接續 0001users + models/ 0002devices兩張 token 表的 user_id / device_id
-- 都是 REFERENCES 既有表的 FKusers 於 0001、devices 於 0002 建立)。
--
-- 環境事實(與 0001/0002 相同PostgreSQL 14.23。
--
-- ── 分表決策database.md §4──────────────────────────────────────────────
-- pairing_tokens 與 session_tokens「分表」不共表 by kind。理由
-- 1. code 中是兩個獨立 struct + 兩個 Store interfacePairingStore / SessionTokenStore
-- 欄位與方法集不同pairing 有 used_at 一次性語意、kindsession 有 parent_token_hash、無 used_at
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL欄位語意混淆。
-- 3. 分表後各表 schema 乾淨、index 各自最佳化repository 一對一對映 Store。
-- 代價稽核「pairing→session 升級鏈」需跨表 joinsession_tokens.parent_token_hash
-- → pairing_tokens.token_hash可接受查詢頻率低
-- pairing_tokens短期一次性配對 token對齊 internal/auth.PairingToken
-- PK = token_hashsha256(plaintext)):永不存明文 tokensecurity.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 TTLNULL = 永不過期(測試用)
used_at TIMESTAMPTZ, -- 一次性MarkUsed 後 Validate 失敗
revoked_at TIMESTAMPTZ
);
-- List by userUI 顯示),只索引未撤銷紀錄。
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_hashdevice_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 天 TTLNULL = 永不過期
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);

View File

@ -0,0 +1,16 @@
// Package migrations 把 visionA-backend 的 SQL migration 檔嵌入 binary
// 供 internal/db 的 migration runnergolang-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