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:
parent
22f329cdf3
commit
4d0b870480
@ -102,6 +102,10 @@
|
||||
### POST `/api/cloud/devices/:id/rename`
|
||||
- 改雲端上的 device name
|
||||
|
||||
### DELETE `/api/cloud/devices/:id` — 解除綁定(unpair)並刪除雲端 device record
|
||||
- **DB 接入後行為(塊 5)**:刪除 device 會在**同一交易內** cascade 撤銷該 device 的所有 pairing token + session token(`pairing_tokens` + `session_tokens` 兩張表,by `device_id`,`UPDATE ... SET revoked_at = now()`)。對應 DB 層一致性定義見 `../database.md` §6。
|
||||
- 撤銷後該 device 的既有 tunnel session 將無法續用,需重新配對。
|
||||
|
||||
---
|
||||
|
||||
## 4. Models
|
||||
@ -205,6 +209,13 @@
|
||||
### GET `/api/system/info`
|
||||
- 版本資訊
|
||||
|
||||
### GET `/healthz` — liveness / readiness(給 load balancer)
|
||||
- 純基礎設施健康檢查端點(非 `/api/*` 前綴),供 LB / orchestrator probe。
|
||||
- **DB 接入後行為(塊 5)**:PostgreSQL / Redis **啟用時會 ping**,任一 ping 失敗 → 回 **503**(讓 load balancer 知道此實例不健康、停止導流)。**未啟用的依賴略過檢查**(雛形未配 DB/Redis 時,這些依賴視為 not-applicable,不影響健康判定)。
|
||||
- Response(健康):`200 { "status": "ok" }`
|
||||
- Response(不健康):`503 { "status": "unavailable", "failed": ["postgres"] }`(`failed` 列出 ping 失敗的依賴)
|
||||
- 與 `GET /api/system/health` 的差異:`/healthz` 是基礎設施 probe(含 DB/Redis ping),`/api/system/health` 是業務層健康(api-server 自身 + tunnel 連線狀態)。
|
||||
|
||||
---
|
||||
|
||||
## 8. Converter
|
||||
@ -301,11 +312,18 @@
|
||||
| `FORBIDDEN` | 403 | 權限不足 |
|
||||
| `NOT_FOUND` | 404 | 資源不存在 |
|
||||
| `VALIDATION_FAILED` | 400 | 輸入驗證失敗 |
|
||||
| `CONFLICT` | 409 | 唯一性衝突(如重複註冊 active device serial、email 已存在)。DB unique violation 映射到此碼。|
|
||||
| `TUNNEL_DISCONNECTED` | 502 | Local agent 未連線 |
|
||||
| `TUNNEL_ERROR` | 502 | Tunnel 傳輸錯誤 |
|
||||
| `NOT_IMPLEMENTED` | 501 | 雛形尚未實作 |
|
||||
| `RATE_LIMITED` | 429 | 請求過快(Phase 1)|
|
||||
| `INTERNAL_ERROR` | 500 | 未預期錯誤 |
|
||||
| `SERVICE_UNAVAILABLE` | 503 | 後端依賴(PostgreSQL / Redis)連線失敗時的 fail-fast。持久資料相關 API(model / device / token)在 PG 不可用時回此碼,不回假資料。|
|
||||
|
||||
> **DB 接入後的降級策略(fail-fast,2026-06-20 使用者拍板)**:
|
||||
> - **PostgreSQL 掉** → 持久資料相關 API(model / device / token)回 `503 SERVICE_UNAVAILABLE`,**不回假資料、不 fallback in-memory**(避免回傳過期/不一致資料)。
|
||||
> - **Redis 掉** → session 驗證失敗(請求視為未認證 → `401 UNAUTHORIZED`),**不自動 fallback in-memory session**(避免多機部署下各實例 session 不同步)。
|
||||
> - DB unique violation → `409 CONFLICT`(而非 500),讓前端能區分「衝突」與「未預期錯誤」。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -1,9 +1,47 @@
|
||||
# Database — 資料模型
|
||||
|
||||
> **雛形階段無真實 DB**(見 ADR-005)。本文件定義 Go struct + 未來 DB schema 的映射,讓雛形程式碼直接以這些 struct 操作記憶體,Phase 1 直接按這個結構建 PostgreSQL schema。
|
||||
>
|
||||
> **以 code 為準原則(2026-06-20 DB 接入規劃更新)**:本文件 §2 struct 與 §4 schema 自雛形以來與實際 code 有少數漂移。DB 接入時,**欄位定義一律以 `visionA-backend/internal/*` 的 Go struct 為準**;本次更新已將 §4 schema 對齊 code 並標註修正點(搜尋「以 code 為準」)。後續實作 PostgresRepository 時若再發現不一致,亦以 code 為準並回頭修本文件。
|
||||
|
||||
---
|
||||
|
||||
## 0. DB 接入規格摘要(2026-06-20 新增 — DB 接入前文件補齊)
|
||||
|
||||
> 本節為 DB 接入任務(visionA-backend 6 個 in-memory store → Postgres/Redis 持久化)的開發前依循,對應 `db-integration-plan.md` §10 缺口補齊。範圍 = **完整**(PG 全接 + session 接 Redis + 交易韌性)。
|
||||
|
||||
### 0.1 DB 供給前提(取代任何「130 現成可用」字眼)
|
||||
|
||||
- **PostgreSQL**:visionA 專用實例,**credential 已取得**。visionA 端**不負責 provision**(DB 機器 / database / 角色由他人在 stage host 192.168.0.130 另開),**只負責跑 migration 與接上**。
|
||||
- **Redis**:visionA 專用實例,**由使用者自行在 130 另起**(設密碼),供 `userSession` 用。visionA 端同樣不 provision、只接上。
|
||||
- 130:5432 上其他 container 一律不共用;任何「130 上已有 DB 可直接用」的舊敘述皆作廢。
|
||||
- 整合測試一律走 **testcontainers**(本機/CI 一次性 DB),不依賴 130;130 僅用於 stage 收尾驗證。
|
||||
|
||||
### 0.2 持久化分工(對齊 `db-integration-plan.md` §3)
|
||||
|
||||
| Store | package | 落地 | 理由 |
|
||||
|-------|---------|------|------|
|
||||
| `modelRepo` | `internal/model` | **Postgres** | 長期保存、List by owner/chip/source、跨重啟不掉。使用者最關心。 |
|
||||
| `deviceRepo` | `internal/device` | **Postgres** | 裝置綁定身分長期保存、owner+serial unique、關聯查詢。 |
|
||||
| `pairingStore` | `internal/auth` | **Postgres** | 可撤銷長期憑證、要稽核(used_at/revoked_at)、重啟不掉。 |
|
||||
| `sessionTokenStore` | `internal/auth` | **Postgres** | session token 90 天長效、可撤銷、查 parent token 稽核鏈。 |
|
||||
| `userSessionStore` | `internal/usersession` | **Redis** | 瀏覽器 cookie session、高頻讀、idle/absolute 雙 TTL、掉了重登即可。Redis TTL 原生支援。 |
|
||||
| remote-proxy `session` | `internal/session` | **in-memory(不變)** | 特例:value 是活的 yamux Handle,不可序列化進 Redis。單節點維持 in-memory;多節點才需 Redis 存 Summary(handle 仍留本地)。本期範圍外。 |
|
||||
|
||||
### 0.3 第一份 migration 清單(最小範圍 = 塊 0 + 塊 1)
|
||||
|
||||
第一個 migration(`migrations/0001_*`)建立**模型庫持久化的最小集合**:
|
||||
|
||||
- **`users`** — `models.owner_user_id` / `devices.owner_user_id` / token `user_id` 的 FK 目標,必須先建(Phase 1 stub,雛形固定 demo-user)。
|
||||
- **`models`** — 使用者最關心、塊 1 主體。
|
||||
|
||||
> device / pairing_tokens / session_tokens 表隨塊 2 / 塊 3 的 migration(`0002_*` / `0003_*`)建立,不塞進第一份 migration。決策理由:第一份 migration 聚焦「重啟後模型庫資料還在」的最小可驗收,降低首次上 DB 的風險面。
|
||||
|
||||
### 0.4 兩個關鍵 schema 決策(DB 接入前定案)
|
||||
|
||||
1. **token 共表(by `kind`)vs 分表** → **決策:分表(`pairing_tokens` + `session_tokens` 兩張)**。理由見 §4 token 段落。
|
||||
2. **已 soft-delete 的 device serial 能否重註冊** → **決策:能**,用 partial unique index `WHERE deleted_at IS NULL` 達成。語意見 §4 devices 段落。
|
||||
|
||||
## 1. 核心實體 ER 概念圖
|
||||
|
||||
```
|
||||
@ -95,10 +133,13 @@ type Device struct {
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
PairedAt *time.Time `json:"pairedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏(配對完成時間)
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
> **以 code 為準**(2026-06-20):上方對齊 `internal/device/device.go`,新增 `PairedAt`(配對完成時間,nullable)。`SerialNumber` 在 code 為 `omitempty`。
|
||||
|
||||
**注意**:此「Device」記錄的是**抽象身分**(綁 user)。當瀏覽器呼叫 `GET /api/devices` 時,實際「此時 USB 上接了哪些」要透過 tunnel 問 local agent 的 `/api/devices`。雲端這張表負責「我曾經綁過哪些裝置、它們的名字、擁有者」+ 雙狀態快照。
|
||||
|
||||
**更新時機**:
|
||||
@ -123,50 +164,75 @@ type Model struct {
|
||||
// 檔案資訊
|
||||
StorageKey string `json:"storageKey"` // 在 Store 裡的 key(例:models/{user_id}/{id}.nef)
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileChecksum string `json:"fileChecksum"` // sha256
|
||||
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
|
||||
|
||||
// 模型 metadata
|
||||
TargetChip string `json:"targetChip"` // kl520 / kl720
|
||||
// FAAObjectKey(ADR-017 (a) B1):model 在 File Access Agent 上的 object key。
|
||||
// 只有 Source=converted(轉檔→promote 進 FAA)類有值;上傳類留空。
|
||||
// JSON tag = "-"(不對前端揭露內部 storage key,ADR-017 決策 2)。
|
||||
// DB 欄位 nullable(見 §4,2026-06-20 補上 — 原 schema 漏此欄)。
|
||||
FAAObjectKey string `json:"-"`
|
||||
|
||||
// 模型 metadata(可選)
|
||||
TargetChip string `json:"targetChip,omitempty"` // kl520 / kl720
|
||||
InputShape []int `json:"inputShape,omitempty"`
|
||||
Classes []string `json:"classes,omitempty"`
|
||||
Framework string `json:"framework,omitempty"` // onnx / keras 等(若有)
|
||||
|
||||
// 來源
|
||||
Source string `json:"source"` // uploaded / converted / preset
|
||||
Source Source `json:"source"` // uploaded / converted / preset(Source = string 別名)
|
||||
SourceJobID string `json:"sourceJobId,omitempty"` // 若 source=converted
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UploadedAt *time.Time `json:"uploadedAt,omitempty"` // 2026-06-20 補上 — code 有此欄位、原 schema 漏
|
||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 PairingToken(見 ADR-003)
|
||||
> **以 code 為準**(2026-06-20):上方 struct 已對齊 `internal/model/model.go`。相對原雛形定義新增了 `FAAObjectKey`(ADR-017)與 `UploadedAt`,`FileChecksum` 改為 `omitempty`,`Source` 為 `type Source = string` 別名。
|
||||
|
||||
### 2.4 PairingToken / SessionToken(見 ADR-003)
|
||||
|
||||
> **以 code 為準**(2026-06-20):實際 code 中 pairing 與 session 是**兩個獨立 struct + 兩個獨立 Store**(`internal/auth/auth.go`),欄位不同。原雛形文件畫的單一 `PairingInfo`(含 `RevokedBy` / `LastSeenAt` / `ParentToken` 共用欄)**與 code 不符、已作廢**。下方改列 code 實際的兩個 struct,schema 也據此分表(見 §4)。
|
||||
|
||||
```go
|
||||
// internal/auth/types.go
|
||||
package auth
|
||||
// internal/auth/auth.go
|
||||
|
||||
import "time"
|
||||
// PairingToken — 短期一次性配對 token(vAc_ + 32 hex,15min TTL,一次性)
|
||||
type PairingToken struct {
|
||||
Plaintext string `json:"-"` // 僅建立時回傳一次
|
||||
TokenHash string `json:"-"` // sha256(Plaintext),DB PK
|
||||
UserID string `json:"userId"`
|
||||
DeviceID string `json:"deviceId,omitempty"` // MarkUsed 綁定後才有
|
||||
Kind TokenKind `json:"kind"` // "pairing"
|
||||
|
||||
type PairingInfo struct {
|
||||
TokenHash string `json:"-"` // sha256(plaintext token),DB 只存 hash
|
||||
UserID string `json:"userId"`
|
||||
DeviceID string `json:"deviceId,omitempty"` // 綁定後才有
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 15min TTL;nil = 永不過期(測試用)
|
||||
UsedAt *time.Time `json:"usedAt,omitempty"` // 一次性:MarkUsed 寫入後 Validate 失敗
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
}
|
||||
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // pairing phase 有 15min TTL;升級為 session token 後 nil 或長期
|
||||
UsedAt *time.Time `json:"usedAt,omitempty"` // 首次被 local agent 使用的時間
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
RevokedBy string `json:"revokedBy,omitempty"`
|
||||
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
|
||||
// SessionToken — 長期可撤銷 tunnel session token(vAs_ + 64 hex,90 天 TTL)
|
||||
type SessionToken struct {
|
||||
Plaintext string `json:"-"`
|
||||
TokenHash string `json:"-"` // DB PK
|
||||
UserID string `json:"userId"`
|
||||
DeviceID string `json:"deviceId"` // session token 必綁 device
|
||||
ParentTokenHash string `json:"-"` // 升級來源 pairing token 的 hash(稽核鏈)
|
||||
|
||||
// Phase 1:兩階段設計
|
||||
Kind string `json:"kind"` // "pairing" | "session"
|
||||
ParentToken string `json:"parentToken,omitempty"` // session 對應的 pairing
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"` // 90 天;nil = 永不過期
|
||||
RevokedAt *time.Time `json:"revokedAt,omitempty"`
|
||||
// 注意:SessionToken 無 UsedAt(非一次性)、無 Kind(型別本身即 session)
|
||||
}
|
||||
```
|
||||
|
||||
**關鍵差異(影響 §4 分表決策)**:
|
||||
- `PairingToken` 有 `UsedAt`(一次性語意)、`Kind`;無 `ParentTokenHash`。
|
||||
- `SessionToken` 有 `ParentTokenHash`(稽核鏈)、`DeviceID` 必填;無 `UsedAt`、無 `Kind`。
|
||||
- 兩者由**不同 Store interface** 管理(`PairingStore` / `SessionTokenStore`),方法集不同(pairing 有 `MarkUsed`、`Validate`;session 有 `Get`)。
|
||||
- code 中**不存在** `RevokedBy` / `LastSeenAt` / `ParentToken`(原雛形文件的臆測欄位)。
|
||||
|
||||
### 2.5 Cluster(從 POC 搬)
|
||||
|
||||
```go
|
||||
@ -249,6 +315,10 @@ type SessionSummary struct {
|
||||
|
||||
雛形 session 在 in-memory map,process 重啟就掉。Phase 1 考慮 Redis 存 summary(TTL + heartbeat)。
|
||||
|
||||
> **2026-06-20 補充(DB 接入)**:要區分兩種 session:
|
||||
> - **`internal/usersession`(browser cookie session)→ 接 Redis**。Session struct(見 `internal/usersession/usersession.go`)含 OIDC pending state(OIDCState/Nonce/CodeVerifier)+ token snapshot(AccessToken/IDTokenRaw)+ `Extra map[string]any`。`RedisUserSessionStore` 用 Redis 雙 TTL(idle = `IdleTTL` 預設 24h、absolute = `AbsoluteTTL` 預設 168h)取代現有手動 cleanup goroutine;`Extra` 需 JSON 序列化;`OIDCCodeVerifier` 等敏感欄位照舊不可進 log。Redis 為 visionA 專用實例(使用者在 130 另起、設密碼)。
|
||||
> - **`internal/session`(remote-proxy tunnel session)→ 維持 in-memory(不變)**。value 是活的 yamux Handle,**不可序列化進 Redis**。單節點維持 in-memory;多節點才需「Summary 放 Redis、handle 留本地」混合實作 —— **本期範圍外**。
|
||||
|
||||
---
|
||||
|
||||
## 3. Repository Interfaces
|
||||
@ -288,9 +358,14 @@ Phase 1:新增 `postgres_repository.go`,實作同 interface。
|
||||
|
||||
```sql
|
||||
-- users
|
||||
-- 環境適配(塊 0,2026-06-20 真環境驗證):目標 PG 14.23 未安裝 citext extension,
|
||||
-- email 改用 TEXT + 函式索引達成大小寫不敏感唯一,取代原 CITEXT 寫法:
|
||||
-- email TEXT NOT NULL;
|
||||
-- CREATE UNIQUE INDEX users_email_lower_uniq ON users (lower(email));
|
||||
-- 應用層查詢一律以 lower(email) 比對。若未來環境有 citext,可回退 CITEXT 簡化。
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email CITEXT UNIQUE NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL, -- 見上方環境適配註:PG 14.23 無 citext,實際以 lower(email) 唯一索引達成 CI 唯一
|
||||
name TEXT,
|
||||
password_hash TEXT,
|
||||
org_id UUID,
|
||||
@ -318,49 +393,82 @@ CREATE TABLE devices (
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
UNIQUE (owner_user_id, serial_number)
|
||||
paired_at TIMESTAMPTZ, -- 2026-06-20 補:配對完成時間(code 有、原 schema 漏)
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
-- 2026-06-20 改:table-level UNIQUE → partial unique index(soft-delete 後 serial 可重註冊)
|
||||
-- 決策:已 soft-delete 的 device serial 「能」重新註冊。
|
||||
-- 語意:唯一性只對「未刪除」紀錄成立;刪掉後同 (owner, serial) 可再 INSERT 一筆新的。
|
||||
CREATE UNIQUE INDEX uq_devices_owner_serial_active
|
||||
ON devices (owner_user_id, serial_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ON devices (owner_user_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ON devices (remote_status) WHERE deleted_at IS NULL;
|
||||
|
||||
-- pairing_tokens
|
||||
-- ── token 分表決策(2026-06-20)──────────────────────────────────────────
|
||||
-- 決策:pairing_tokens 與 session_tokens 「分表」,不共表 by kind。
|
||||
-- 理由:
|
||||
-- 1. code 中是兩個獨立 struct + 兩個 Store interface(PairingStore / SessionTokenStore),
|
||||
-- 欄位與方法集不同(pairing 有 used_at 一次性語意、kind;session 有 parent_token_hash、無 used_at)。
|
||||
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL,欄位語意混淆、CHECK 約束變複雜。
|
||||
-- 3. 分表後各表 schema 乾淨、index 各自最佳化,repository 一對一對映 Store,最直觀。
|
||||
-- 代價:稽核「pairing→session 升級鏈」需跨表 join(session_tokens.parent_token_hash → pairing_tokens.token_hash);
|
||||
-- 可接受(查詢頻率低,且 parent_token_hash 已是 hash 不需額外轉換)。
|
||||
|
||||
-- pairing_tokens(短期一次性配對 token;對齊 internal/auth.PairingToken)
|
||||
CREATE TABLE pairing_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext),永不存明文
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
device_id UUID REFERENCES devices(id),
|
||||
kind TEXT NOT NULL DEFAULT 'pairing',
|
||||
parent_token TEXT,
|
||||
device_id UUID REFERENCES devices(id), -- MarkUsed 綁定後才有
|
||||
kind TEXT NOT NULL DEFAULT 'pairing', -- 固定 'pairing'(保留欄位,便於觀測/未來擴充)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
revoked_by UUID,
|
||||
last_seen_at TIMESTAMPTZ
|
||||
expires_at TIMESTAMPTZ, -- 15min TTL;NULL = 永不過期(測試用)
|
||||
used_at TIMESTAMPTZ, -- 一次性:MarkUsed 後 Validate 失敗
|
||||
revoked_at TIMESTAMPTZ
|
||||
-- 2026-06-20 移除原 schema 的 parent_token / revoked_by / last_seen_at —— code 不存在這些欄位
|
||||
);
|
||||
CREATE INDEX ON pairing_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX ON pairing_tokens (device_id);
|
||||
|
||||
-- session_tokens(長期可撤銷 tunnel session token;對齊 internal/auth.SessionToken)
|
||||
CREATE TABLE session_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
device_id UUID NOT NULL REFERENCES devices(id), -- session token 必綁 device
|
||||
parent_token_hash TEXT, -- 升級來源 pairing token 的 hash(稽核鏈,可 join pairing_tokens.token_hash)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ, -- 90 天 TTL;NULL = 永不過期
|
||||
revoked_at TIMESTAMPTZ
|
||||
-- 注意:無 used_at(非一次性)、無 kind
|
||||
);
|
||||
CREATE INDEX ON session_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
CREATE INDEX ON session_tokens (device_id);
|
||||
CREATE INDEX ON session_tokens (parent_token_hash);
|
||||
|
||||
-- models
|
||||
CREATE TABLE models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
storage_key TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
file_checksum TEXT,
|
||||
target_chip TEXT,
|
||||
input_shape INT[],
|
||||
classes TEXT[],
|
||||
framework TEXT,
|
||||
source TEXT NOT NULL,
|
||||
source_job_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
storage_key TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
file_checksum TEXT,
|
||||
faa_object_key TEXT, -- 2026-06-20 補:ADR-017 (a) B1,nullable(上傳類留 NULL)
|
||||
target_chip TEXT,
|
||||
input_shape INT[],
|
||||
classes TEXT[],
|
||||
framework TEXT,
|
||||
source TEXT NOT NULL,
|
||||
source_job_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
uploaded_at TIMESTAMPTZ, -- 2026-06-20 補:code 有 UploadedAt、原 schema 漏
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX ON models (owner_user_id, target_chip) WHERE deleted_at IS NULL; -- List filter by chip
|
||||
CREATE INDEX ON models (owner_user_id, source) WHERE deleted_at IS NULL; -- List filter by source
|
||||
|
||||
-- clusters
|
||||
CREATE TABLE clusters (
|
||||
@ -397,10 +505,114 @@ CREATE TABLE converter_jobs (
|
||||
|
||||
## 5. Migration 策略(Phase 1)
|
||||
|
||||
- 工具:`golang-migrate`
|
||||
- 工具:`golang-migrate/v4`
|
||||
- 位置:`visionA-backend/migrations/*.sql`
|
||||
- 命名:`YYYYMMDDHHMMSS_description.up.sql` / `.down.sql`
|
||||
- 雛形:**不需要 migrations 目錄**;Phase 1 第一個 migration 建立以上所有 table
|
||||
- 命名:`NNNN_description.up.sql` / `.down.sql`(序號式,如 `0001_create_users_models.up.sql`)
|
||||
- runner:啟動時自動 migrate up(或獨立 `cmd/migrate`),由 `DatabaseConfig.Enabled()` 決定是否執行
|
||||
|
||||
### 5.1 Migration 清單(2026-06-20 補 — 對齊塊式範圍)
|
||||
|
||||
| Migration | 內容 | 對應塊 | 觸發範圍 |
|
||||
|-----------|------|--------|---------|
|
||||
| `0001_create_users_models` | `users` + `models`(含 faa_object_key / uploaded_at + 3 個 owner-scoped index) | 塊 1 | **最小範圍**(使用者最關心,重啟後模型庫還在) |
|
||||
| `0002_create_devices` | `devices`(雙狀態欄位 + paired_at + partial unique index `uq_devices_owner_serial_active`) | 塊 2 | 持久資料範圍 |
|
||||
| `0003_create_token_tables` | `pairing_tokens` + `session_tokens`(分表,見 §4 決策) | 塊 3 | 持久資料範圍 |
|
||||
| `0004_*`(如需要) | `clusters` / `converter_jobs` | — | **本期範圍外**(main.go 未 wire,見 `db-integration-plan.md` §8) |
|
||||
|
||||
> **第一份 migration(`0001`)為何含 users**:`models.owner_user_id` 是 `REFERENCES users(id)` 的 FK,必須先有 users 表。雛形 users 為 stub(固定 demo-user),但 schema 與 FK 約束第一份就要到位,否則 models 建不起來。
|
||||
>
|
||||
> 決策:第一份 migration 聚焦「最小可驗收 = 重啟後模型庫資料還在」,故只含 users + models;device / token 隨各自塊的 migration 進場,降低首次上 DB 的風險面。
|
||||
|
||||
---
|
||||
|
||||
## 5.5 連線設定規格(2026-06-20 新增 — DatabaseConfig / RedisConfig)
|
||||
|
||||
> 對齊既有 `internal/config/config.go` 慣例:每個外部依賴一個 sub-config struct、掛在 `Config` 上、env 前綴 `VISIONA_*`、有 `Enabled()` 用「必要欄位全非空」判斷是否 wire(參照既有 `ConversionConfig.Enabled()` / `FileAccessConfig.Enabled()`)。新增欄位須同步 `.env.example`。
|
||||
|
||||
### 5.5.1 DatabaseConfig(PostgreSQL)
|
||||
|
||||
```go
|
||||
// internal/config/config.go — 掛在 Config struct 上:Database DatabaseConfig
|
||||
|
||||
// DatabaseConfig 控制 PostgreSQL 連線(持久業務資料:model / device / token)。
|
||||
//
|
||||
// 啟用判定(Enabled()):Host / User / DBName 三者全非空才視為啟用;
|
||||
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memory(local dev fallback)。
|
||||
//
|
||||
// 安全:Password / DSN 永遠不印 log 全文(可印 host:port/dbname);
|
||||
// 部署走既有 secrets 機制(AWS Secrets Manager / Vault),禁止 commit 進 repo。
|
||||
type DatabaseConfig struct {
|
||||
Host string // VISIONA_DB_HOST
|
||||
Port int // VISIONA_DB_PORT,預設 5432
|
||||
User string // VISIONA_DB_USER
|
||||
Password string // VISIONA_DB_PASSWORD(禁止 commit)
|
||||
DBName string // VISIONA_DB_NAME
|
||||
SSLMode string // VISIONA_DB_SSLMODE,預設 "require"(stage/prod);本機 testcontainers 用 "disable"
|
||||
|
||||
// 連線池(pgxpool)
|
||||
MaxConns int // VISIONA_DB_MAX_CONNS,預設 10
|
||||
MinConns int // VISIONA_DB_MIN_CONNS,預設 2
|
||||
MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME,預設 1h
|
||||
ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT,預設 5s(建池/ping 逾時)
|
||||
}
|
||||
|
||||
// DSN 組裝(供 pgxpool):
|
||||
// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns}
|
||||
func (c DatabaseConfig) Enabled() bool {
|
||||
return c.Host != "" && c.User != "" && c.DBName != ""
|
||||
}
|
||||
```
|
||||
|
||||
| env | 預設 | 說明 |
|
||||
|-----|------|------|
|
||||
| `VISIONA_DB_HOST` | (空)| visionA 專用 PG host(credential 已取得;他人在 130 provision) |
|
||||
| `VISIONA_DB_PORT` | `5432` | |
|
||||
| `VISIONA_DB_USER` | (空)| app role |
|
||||
| `VISIONA_DB_PASSWORD` | (空)| 禁止 commit;走 secrets 機制 |
|
||||
| `VISIONA_DB_NAME` | (空)| visionA 專用 database |
|
||||
| `VISIONA_DB_SSLMODE` | `require` | stage/prod 用 require/verify-full;testcontainers 用 disable |
|
||||
| `VISIONA_DB_MAX_CONNS` | `10` | pgxpool 上限 |
|
||||
| `VISIONA_DB_MIN_CONNS` | `2` | pgxpool 常駐 |
|
||||
| `VISIONA_DB_MAX_CONN_LIFETIME` | `1h` | |
|
||||
| `VISIONA_DB_CONN_TIMEOUT` | `5s` | 建池 / 啟動 ping 逾時 |
|
||||
|
||||
### 5.5.2 RedisConfig(userSession)
|
||||
|
||||
```go
|
||||
// internal/config/config.go — 掛在 Config struct 上:Redis RedisConfig
|
||||
|
||||
// RedisConfig 控制 Redis 連線(僅 userSession:browser cookie session)。
|
||||
//
|
||||
// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。
|
||||
// visionA 端不 provision、只接上。
|
||||
//
|
||||
// 啟用判定(Enabled()):Host 非空才視為啟用;
|
||||
// 未啟用 → userSession 仍用 in-memory(雛形行為,process 重啟掉 session)。
|
||||
//
|
||||
// 安全:Password 永遠不印 log 全文。禁止 commit 進 repo。
|
||||
type RedisConfig struct {
|
||||
Host string // VISIONA_REDIS_HOST
|
||||
Port int // VISIONA_REDIS_PORT,預設 6379
|
||||
Password string // VISIONA_REDIS_PASSWORD(visionA 專用實例必設密碼;禁止 commit)
|
||||
DB int // VISIONA_REDIS_DB,預設 0(db index)
|
||||
|
||||
ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT,預設 5s
|
||||
}
|
||||
|
||||
func (c RedisConfig) Enabled() bool {
|
||||
return c.Host != ""
|
||||
}
|
||||
```
|
||||
|
||||
| env | 預設 | 說明 |
|
||||
|-----|------|------|
|
||||
| `VISIONA_REDIS_HOST` | (空)| visionA 專用 Redis(使用者在 130 另起) |
|
||||
| `VISIONA_REDIS_PORT` | `6379` | |
|
||||
| `VISIONA_REDIS_PASSWORD` | (空)| visionA 專用實例必設;禁止 commit |
|
||||
| `VISIONA_REDIS_DB` | `0` | db index |
|
||||
| `VISIONA_REDIS_CONN_TIMEOUT` | `5s` | 建連 / 啟動 ping 逾時 |
|
||||
|
||||
> userSession 的 idle / absolute TTL **不在 RedisConfig**,沿用既有 `UserSessionConfig.IdleTTL`(24h)/ `AbsoluteTTL`(168h);`RedisUserSessionStore` 用這兩個值設 Redis key TTL 取代手動 cleanup goroutine。
|
||||
|
||||
---
|
||||
|
||||
@ -409,13 +621,20 @@ CREATE TABLE converter_jobs (
|
||||
| 操作 | 需要的一致性 |
|
||||
|------|-------------|
|
||||
| 使用者建立 Device + PairingToken | 同一 transaction |
|
||||
| 使用者刪除 Device | cascade 撤銷所有 PairingToken |
|
||||
| 使用者刪除 Device | cascade 撤銷該 device 的所有 token(**pairing_tokens + session_tokens 兩張表**,by device_id)於同一 tx |
|
||||
| Converter job 完成 → 建立 Model | transactional upsert + webhook idempotency |
|
||||
|
||||
雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx。
|
||||
雛形 in-memory 無交易;Phase 1 用 `pgx` 的 tx(塊 5 的 `internal/db/tx.go` helper)。
|
||||
|
||||
> token 分表後(§4 決策),「刪 device cascade 撤銷 token」需在同一 tx 內對 `pairing_tokens` 與 `session_tokens` 各跑一次 `UPDATE ... SET revoked_at = now() WHERE device_id = $1 AND revoked_at IS NULL`。
|
||||
|
||||
---
|
||||
|
||||
**雛形實作 / 未來擴展**:
|
||||
- 雛形:所有 repository 用 `map + sync.RWMutex`;struct 欄位按上表定義(多出的欄位留空或 zero value)
|
||||
- 未來:實作 `Postgres*Repository`;加上 migration;處理 soft delete(`WHERE deleted_at IS NULL`)
|
||||
|
||||
**DB 接入關鍵改動提醒(2026-06-20,對齊 `db-integration-plan.md` 塊 3)**:
|
||||
- pairing / session token 兩個 in-memory store 目前**以 plaintext 當 map key**;接 Postgres 時改成 **`token_hash`(`HashToken(plaintext)`)當 PK**。`Validate` / `Get` 需先 `HashToken()` 再查;務必確認所有呼叫端傳入 plaintext、由 store 內部統一 hash(漏一個呼叫端就驗不過)。code 已有 `HashToken()`(`internal/auth/token.go`)。
|
||||
- model / device 的 `Save` 是 upsert by ID,須保留既有 `CreatedAt`(對齊 in-memory `Save` 的語意:existing 且未刪除時保留 CreatedAt)。
|
||||
- 整合測試走 testcontainers(不依賴 130);stage 收尾才接 visionA 專用 PG / Redis。
|
||||
|
||||
@ -232,3 +232,71 @@ VISIONA_FILE_ACCESS_FAA_BASE_URL=
|
||||
|
||||
# download token 有效期(秒)— ADR-017 Q2 區間 60–300s,預設 120
|
||||
VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB 接入塊 0 — PostgreSQL(持久業務資料:model / device / token)
|
||||
# ============================================================
|
||||
# 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
|
||||
#
|
||||
# 啟用判定:HOST / USER / NAME 三者全非空才啟用;任一缺 →
|
||||
# api-server 不建連線池,所有 repository 維持 in-memory(local dev fallback,雛形行為不變)。
|
||||
# ⚠️ 塊 0 範圍:即使啟用,目前 repository 仍是 in-memory(建池只為驗證基礎建設 + 讓 schema 就位);
|
||||
# model/device/token 切到 Postgres 是塊 1–3。
|
||||
#
|
||||
# ⚠️ DB 由他人在 stage host(130) 另開 visionA 專用實例並提供連線資訊;visionA 端不 provision、只接上。
|
||||
# ⚠️ PASSWORD 不可 commit;prod 走 Secrets Manager / Vault;log 永遠不印密碼/DSN 全文。
|
||||
|
||||
# visionA 專用 PG host(credential 已取得;他人在 130 provision)。留空 = 不啟用(用 in-memory)。
|
||||
VISIONA_DB_HOST=
|
||||
|
||||
# PG port,預設 5432
|
||||
VISIONA_DB_PORT=5432
|
||||
|
||||
# app role
|
||||
VISIONA_DB_USER=
|
||||
|
||||
# ⚠️ 不可 commit;走 secrets 機制
|
||||
VISIONA_DB_PASSWORD=
|
||||
|
||||
# visionA 專用 database 名稱
|
||||
VISIONA_DB_NAME=
|
||||
|
||||
# sslmode:stage/prod 用 require / verify-full;本機 testcontainers 用 disable
|
||||
VISIONA_DB_SSLMODE=require
|
||||
|
||||
# pgxpool 連線數上限 / 常駐數
|
||||
VISIONA_DB_MAX_CONNS=10
|
||||
VISIONA_DB_MIN_CONNS=2
|
||||
|
||||
# 連線生命週期 / 建池+啟動 ping 逾時
|
||||
VISIONA_DB_MAX_CONN_LIFETIME=1h
|
||||
VISIONA_DB_CONN_TIMEOUT=5s
|
||||
|
||||
# 啟動時是否自動跑 migrate up(預設 true)。設 false 改用獨立 `go run ./cmd/migrate up`。
|
||||
VISIONA_DB_AUTO_MIGRATE=true
|
||||
|
||||
|
||||
# ============================================================
|
||||
# DB 接入塊 4 鉤子 — Redis(僅 userSession:browser cookie session)
|
||||
# ============================================================
|
||||
# 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
|
||||
#
|
||||
# ⚠️ 塊 0 只先把 config 鉤子留好,main.go 尚未 wire(等塊 4 接 RedisUserSessionStore)。
|
||||
# 啟用判定:HOST 非空即啟用;未啟用 → userSession 仍用 in-memory(process 重啟掉 session)。
|
||||
# ⚠️ visionA 專用 Redis 實例由使用者在 130 另起、設密碼;PASSWORD 不可 commit、log 不印全文。
|
||||
|
||||
# visionA 專用 Redis host。留空 = 不啟用(用 in-memory)。
|
||||
VISIONA_REDIS_HOST=
|
||||
|
||||
# Redis port,預設 6379
|
||||
VISIONA_REDIS_PORT=6379
|
||||
|
||||
# ⚠️ visionA 專用實例必設密碼;不可 commit
|
||||
VISIONA_REDIS_PASSWORD=
|
||||
|
||||
# Redis db index,預設 0
|
||||
VISIONA_REDIS_DB=0
|
||||
|
||||
# 建連 / 啟動 ping 逾時
|
||||
VISIONA_REDIS_CONN_TIMEOUT=5s
|
||||
|
||||
@ -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 ./...
|
||||
|
||||
|
||||
@ -192,6 +192,7 @@ visionA-backend/
|
||||
│ │ ├── seed.go # --seed-demo-data 用的示範資料
|
||||
│ │ ├── integration_test.go # B4 端到端測試
|
||||
│ │ └── b5_integration_test.go # B5 端到端測試(含 tunnel forward + model upload)
|
||||
│ ├── migrate/ # 獨立 migration 工具(go run ./cmd/migrate up|down|version)
|
||||
│ └── remote-proxy/ # tunnel server(有狀態,持有 session in-memory)
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
@ -205,8 +206,11 @@ visionA-backend/
|
||||
│ ├── wsconn/ # WebSocket ↔ net.Conn adapter(POC 複製)
|
||||
│ ├── converter/ # StubClient(Phase 2 才實作)
|
||||
│ ├── storage/ # Store interface + LocalFSStore(HMAC presigned URL)
|
||||
│ ├── config/ # Config + Load()(12-Factor)
|
||||
│ ├── config/ # Config + Load()(12-Factor;含 DatabaseConfig / RedisConfig)
|
||||
│ ├── db/ # DB 接入塊 0:pgxpool 連線池 + migration runner(嵌入式)
|
||||
│ │ └── testsupport/ # testcontainers 整合測試 helper + fixture factory(-tags=dbtest)
|
||||
│ └── logger/ # slog JSON logger wrapper
|
||||
├── migrations/ # golang-migrate SQL(NNNN_*.up/down.sql)+ embed.go
|
||||
├── docker/
|
||||
│ ├── Dockerfile.api-server # multi-stage,non-root,healthcheck
|
||||
│ ├── Dockerfile.remote-proxy
|
||||
@ -281,15 +285,48 @@ visionA-backend/
|
||||
| `VISIONA_MODEL_MAX_SIZE_MB` | `100` | 模型上傳大小上限 |
|
||||
| `VISIONA_CORS_ALLOWED_ORIGINS` | `http://localhost:3000` | CORS 白名單(逗號分隔) |
|
||||
| `VISIONA_LOG_LEVEL` | `info` | debug / info / warn / error |
|
||||
| `VISIONA_DB_HOST` / `_USER` / `_NAME` | (空) | PostgreSQL 連線;三者全非空才啟用(否則維持 in-memory) |
|
||||
| `VISIONA_DB_AUTO_MIGRATE` | `true` | 啟動時自動跑 migrate up |
|
||||
|
||||
---
|
||||
|
||||
## 資料庫(DB 接入塊 0)
|
||||
|
||||
> 對齊 [`docs/autoflow/04-architecture/database.md`](../docs/autoflow/04-architecture/database.md) §5、§5.5。
|
||||
|
||||
塊 0 = **DB 基礎建設**:PostgreSQL 連線池(pgxpool)+ migration runner(golang-migrate,
|
||||
嵌入式 SQL)+ testcontainers 整合測試骨架。
|
||||
|
||||
**啟用模式**:`VISIONA_DB_HOST` / `VISIONA_DB_USER` / `VISIONA_DB_NAME` 三者全非空 → 建池 +
|
||||
自動 migrate;任一缺 → **不建池,所有 repository 維持 in-memory**(local dev fallback,雛形行為不變)。
|
||||
|
||||
> ⚠️ **塊 0 範圍**:即使 DB 啟用,目前 model / device / token repository **仍是 in-memory**。
|
||||
> 建池只為驗證基礎建設可用、並讓 schema 先就位。把 repository 切到 Postgres 是塊 1–3。
|
||||
|
||||
```bash
|
||||
# 設好 VISIONA_DB_* 後,手動跑 migration(或交給 api-server 啟動時 auto-migrate)
|
||||
make migrate-up
|
||||
make migrate-version # 顯示目前 schema 版本
|
||||
|
||||
# DB 整合測試(testcontainers,需本機 / CI 有 Docker daemon)
|
||||
make test-db # = go test -tags=dbtest ./internal/db/...
|
||||
```
|
||||
|
||||
migration 檔在 [`migrations/`](migrations/)(`NNNN_description.up.sql` / `.down.sql`),
|
||||
透過 `migrations/embed.go` 的 `//go:embed` 嵌入 binary,部署不需另複製。
|
||||
第一份 `0001_create_users_models` 建 `users` + `models`(對齊 database.md §5.1)。
|
||||
|
||||
---
|
||||
|
||||
## 測試
|
||||
|
||||
```bash
|
||||
# 所有單元測試 + integration test + race detector
|
||||
# 所有單元測試 + integration test + race detector(預設 tags,不需 Docker)
|
||||
make test-race
|
||||
|
||||
# DB 整合測試(testcontainers,需 Docker daemon)
|
||||
make test-db
|
||||
|
||||
# 僅 go vet / gofmt check
|
||||
make lint
|
||||
|
||||
@ -298,10 +335,12 @@ make test
|
||||
```
|
||||
|
||||
覆蓋面:
|
||||
- 單元測試:`internal/{auth,session,device,model,config,storage,api,relay,wsconn,logger,converter}`
|
||||
- 單元測試:`internal/{auth,session,device,model,config,storage,api,relay,wsconn,logger,converter,db}`
|
||||
- `internal/db`:DSN 組裝 / SafeTarget 遮蔽 / Config.Enabled(純函式,預設 tags 即跑)
|
||||
- Integration:
|
||||
- `cmd/api-server/integration_test.go`(B4:api-server → remote-proxy → fake tunnel)
|
||||
- `cmd/api-server/b5_integration_test.go`(B5:完整端到端 — login / scan / model upload / tunnel disconnect)
|
||||
- `internal/db/db_integration_test.go`(DB 接入塊 0:pool ping / migrate 冪等 / up-down-up / schema 形狀 / fail-fast;`-tags=dbtest`,需 Docker)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -23,12 +23,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/api"
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/config"
|
||||
"visiona-backend/internal/conversion"
|
||||
"visiona-backend/internal/converter"
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/logger"
|
||||
@ -80,9 +82,88 @@ func main() {
|
||||
"root", cfg.Storage.RootDir,
|
||||
"base_url", cfg.Storage.BaseURL)
|
||||
|
||||
// ===== PostgreSQL 連線池(DB 接入塊 0:DB 基礎建設) =====
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5.1。
|
||||
//
|
||||
// 啟用條件:cfg.Database.Enabled()(Host/User/DBName 全非空)。
|
||||
// 未啟用 → dbPool 為 nil,所有 repository 維持 in-memory(local dev fallback,雛形行為不變)。
|
||||
//
|
||||
// ⚠️ 塊 0 範圍:只建池 + 跑 migration,**尚未**把 model/device/token repository 切到
|
||||
// Postgres(那是塊 1–3)。因此即使 DB 啟用,目前 repository 仍是 in-memory;
|
||||
// 建池只為驗證基礎建設可用、並讓 schema 先就位。
|
||||
var dbPool *db.Pool
|
||||
if cfg.Database.Enabled() {
|
||||
pool, dbErr := db.NewPool(context.Background(), cfg.Database, log)
|
||||
if dbErr != nil {
|
||||
// fail-fast:DB 設定了卻連不上應停機,而非靜默 fallback in-memory 造成資料不一致。
|
||||
log.Error("failed to init postgres pool", "error", dbErr, "target", db.SafeTarget(cfg.Database))
|
||||
os.Exit(1)
|
||||
}
|
||||
dbPool = pool
|
||||
defer dbPool.Close()
|
||||
|
||||
if cfg.Database.AutoMigrate {
|
||||
if mErr := db.RunMigrations(cfg.Database, log); mErr != nil {
|
||||
log.Error("failed to run migrations", "error", mErr, "target", db.SafeTarget(cfg.Database))
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("migrations applied", "target", db.SafeTarget(cfg.Database))
|
||||
} else {
|
||||
log.Info("auto-migrate disabled (set VISIONA_DB_AUTO_MIGRATE=true or run cmd/migrate)")
|
||||
}
|
||||
} else {
|
||||
log.Info("postgres disabled (set VISIONA_DB_HOST + VISIONA_DB_USER + VISIONA_DB_NAME to enable); repositories use in-memory")
|
||||
}
|
||||
|
||||
// ===== Redis 連線(DB 接入塊 4:僅 userSession browser cookie session) =====
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §2.7、§5.5.2。
|
||||
//
|
||||
// 啟用條件:cfg.Redis.Enabled()(Host 非空;無密碼也算啟用,visionA 專用 Redis stage 內網可不設密碼)。
|
||||
// 未啟用 → redisClient 為 nil,userSession 維持 in-memory + cleanup goroutine(雛形行為不變)。
|
||||
//
|
||||
// ⚠️ 範圍:只接 internal/usersession(cookie session)。tunnel session(internal/session)
|
||||
// value 是活的 yamux Handle 不可序列化,維持 in-memory(database.md §2.7 已定)。
|
||||
var redisClient *db.RedisClient
|
||||
if cfg.Redis.Enabled() {
|
||||
rc, rErr := db.NewRedisClient(context.Background(), cfg.Redis, log)
|
||||
if rErr != nil {
|
||||
// fail-fast:Redis 設定了卻連不上應停機,與 Postgres 同樣不靜默 fallback。
|
||||
log.Error("failed to init redis client", "error", rErr, "target", db.SafeRedisTarget(cfg.Redis))
|
||||
os.Exit(1)
|
||||
}
|
||||
redisClient = rc
|
||||
defer redisClient.Close()
|
||||
} else {
|
||||
log.Info("redis disabled (set VISIONA_REDIS_HOST to enable); user session uses in-memory + cleanup goroutine")
|
||||
}
|
||||
|
||||
// ===== Pairing / Session Token(OIDC 之外的雛形 token store) =====
|
||||
pairingStore := auth.NewInMemoryPairingStore()
|
||||
sessionTokenStore := auth.NewInMemorySessionTokenStore()
|
||||
// DB 接入塊 3 — dbPool != nil 時切到 Postgres(分表:pairing_tokens + session_tokens);
|
||||
// 否則維持 in-memory(local dev fallback,雛形行為不變)。
|
||||
// Store interface 不變,handler / 呼叫端(internal/api/pairing.go)一行都不需改。
|
||||
// 關鍵:Postgres 版以 token_hash(HashToken(plaintext))當 PK,DB 不存明文;
|
||||
// 呼叫端統一傳 plaintext,store 內部統一 hash(見 postgres_pairing_store.go 註解)。
|
||||
var pairingStore auth.PairingStore
|
||||
var sessionTokenStore auth.SessionTokenStore
|
||||
// 保留 concrete 型別參照供塊 5.2 cascade unpair 協調者使用
|
||||
// (RevokeByDeviceTx / RevokeByDevice 不在 Store interface 上,需 concrete type)。
|
||||
var pgPairingStore *auth.PostgresPairingStore
|
||||
var pgSessionTokenStore *auth.PostgresSessionTokenStore
|
||||
var memPairingStore *auth.InMemoryPairingStore
|
||||
var memSessionTokenStore *auth.InMemorySessionTokenStore
|
||||
if dbPool != nil {
|
||||
pgPairingStore = auth.NewPostgresPairingStore(dbPool.Pool())
|
||||
pgSessionTokenStore = auth.NewPostgresSessionTokenStore(dbPool.Pool())
|
||||
pairingStore = pgPairingStore
|
||||
sessionTokenStore = pgSessionTokenStore
|
||||
log.Info("pairing/session token stores initialized", "backend", "postgres")
|
||||
} else {
|
||||
memPairingStore = auth.NewInMemoryPairingStore()
|
||||
memSessionTokenStore = auth.NewInMemorySessionTokenStore()
|
||||
pairingStore = memPairingStore
|
||||
sessionTokenStore = memSessionTokenStore
|
||||
log.Info("pairing/session token stores initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// ===== OIDC + User Session(OB5:唯一認證路徑) =====
|
||||
// cfg.Validate() 已確保所有必填欄位存在,這裡可以放心 wire。
|
||||
@ -102,7 +183,18 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
userSessionStore := usersession.NewInMemoryStore()
|
||||
// userSession store:DB 接入塊 4 — redisClient != nil 時切到 RedisUserSessionStore
|
||||
// (雙 TTL 取代手動 cleanup goroutine);否則維持 in-memory + cleanup goroutine。
|
||||
// Store interface 不變,Manager / handler 一行都不需改。
|
||||
var userSessionStore usersession.Store
|
||||
if redisClient != nil {
|
||||
userSessionStore = usersession.NewRedisUserSessionStore(
|
||||
redisClient.Client(), cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL)
|
||||
log.Info("user session store initialized", "backend", "redis")
|
||||
} else {
|
||||
userSessionStore = usersession.NewInMemoryStore()
|
||||
log.Info("user session store initialized", "backend", "in-memory")
|
||||
}
|
||||
userSessionMgr := usersession.NewManager(userSessionStore, usersession.CookieConfig{
|
||||
Name: cfg.UserSession.CookieName,
|
||||
Domain: cfg.UserSession.CookieDomain,
|
||||
@ -131,9 +223,47 @@ func main() {
|
||||
"backend", "proxy-client",
|
||||
"proxy_internal_url", cfg.Session.ProxyInternalURL)
|
||||
|
||||
// ===== Repositories(in-memory,雛形) =====
|
||||
deviceRepo := device.NewInMemoryRepository()
|
||||
modelRepo := model.NewInMemoryRepository()
|
||||
// ===== Repositories =====
|
||||
// device:DB 接入塊 2 — dbPool != nil 時切到 PostgresRepository;否則維持 in-memory
|
||||
// (local dev fallback,雛形行為不變)。Repository interface 不變,handler 一行都不需改。
|
||||
var deviceRepo device.Repository
|
||||
var pgDeviceRepo *device.PostgresRepository // 塊 5.2 cascade:DeleteTx 需 concrete type
|
||||
if dbPool != nil {
|
||||
pgDeviceRepo = device.NewPostgresRepository(dbPool.Pool())
|
||||
deviceRepo = pgDeviceRepo
|
||||
log.Info("device repository initialized", "backend", "postgres")
|
||||
} else {
|
||||
deviceRepo = device.NewInMemoryRepository()
|
||||
log.Info("device repository initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// ===== Device Unpair cascade 協調者(DB 接入塊 5.2,database.md §6) =====
|
||||
// 刪 device → 同時撤銷該 device 的 pairing + session token。
|
||||
// - Postgres:用 db.WithTx 把三步包成單一交易(device 軟刪 + 兩張 token 表撤銷),整筆原子。
|
||||
// - in-memory:依序執行(無交易),行為一致(刪 device 後 token 也撤)。
|
||||
// 注入 Deps.DeviceUnpairer;unpair handler 偵測非 nil 即走 cascade。
|
||||
var deviceUnpairer api.DeviceUnpairer
|
||||
if dbPool != nil {
|
||||
deviceUnpairer = api.NewPostgresDeviceUnpairer(
|
||||
dbPool.Pool(), pgDeviceRepo, pgPairingStore, pgSessionTokenStore, log)
|
||||
log.Info("device unpairer initialized", "backend", "postgres-tx")
|
||||
} else {
|
||||
deviceUnpairer = api.NewInMemoryDeviceUnpairer(
|
||||
deviceRepo, memPairingStore, memSessionTokenStore)
|
||||
log.Info("device unpairer initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// model:DB 接入塊 1 — cfg.Database.Enabled() 且建池成功時切到 PostgresRepository;
|
||||
// 否則維持 in-memory(local dev fallback,雛形行為不變,既有非-dbtest 測試不受影響)。
|
||||
// Repository interface 不變,所有呼叫端(handler / conversion adapter)一行都不需改。
|
||||
var modelRepo model.Repository
|
||||
if dbPool != nil {
|
||||
modelRepo = model.NewPostgresRepository(dbPool.Pool())
|
||||
log.Info("model repository initialized", "backend", "postgres")
|
||||
} else {
|
||||
modelRepo = model.NewInMemoryRepository()
|
||||
log.Info("model repository initialized", "backend", "in-memory")
|
||||
}
|
||||
|
||||
// ===== Converter(stub,Phase 2 才實作) =====
|
||||
converterClient := converter.NewStubClient()
|
||||
@ -226,11 +356,29 @@ func main() {
|
||||
|
||||
// ===== Seed demo data(可選) =====
|
||||
if cfg.Server.SeedDemoData {
|
||||
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil {
|
||||
// dbPool 非 nil 時,seed 的 model 走 Postgres(塊 1):seedDemoData 內部會先 ensure
|
||||
// demo user 列並改用合法 UUID owner / id;nil 時維持雛形 in-memory 行為。
|
||||
var seedPool *pgxpool.Pool
|
||||
if dbPool != nil {
|
||||
seedPool = dbPool.Pool()
|
||||
}
|
||||
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, seedPool, log); err != nil {
|
||||
log.Warn("seed demo data failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== /healthz 依賴 ping(塊 5.4) =====
|
||||
// 只在依賴非 nil 時注入;注意不能把 typed-nil(*db.Pool(nil))塞進 interface,
|
||||
// 否則 interface != nil 但呼叫 Ping 會對 nil pool panic。故顯式分支保證 nil pool 不注入。
|
||||
var healthDBPool api.HealthPinger
|
||||
if dbPool != nil {
|
||||
healthDBPool = dbPool
|
||||
}
|
||||
var healthRedis api.HealthPinger
|
||||
if redisClient != nil {
|
||||
healthRedis = redisClient
|
||||
}
|
||||
|
||||
// ===== API Router =====
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
// Phase 0.7 security fix C1:StaticUserID 不再注入 Deps(見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||||
@ -243,6 +391,7 @@ func main() {
|
||||
Forwarder: forwarder,
|
||||
DeviceRepo: deviceRepo,
|
||||
ModelRepo: modelRepo,
|
||||
DeviceUnpairer: deviceUnpairer, // 塊 5.2 cascade unpair(Postgres tx / in-memory 依序)
|
||||
Storage: storageStore,
|
||||
Converter: converterClient,
|
||||
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
||||
@ -252,6 +401,10 @@ func main() {
|
||||
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
RelayPublicURL: cfg.Server.RelayPublicURL,
|
||||
|
||||
// 塊 5.4:/healthz 依賴 ping(nil = 未啟用、略過)
|
||||
HealthDBPool: healthDBPool,
|
||||
HealthRedis: healthRedis,
|
||||
|
||||
// OIDC(OB5:唯一認證路徑)
|
||||
OIDCProvider: oidcProvider,
|
||||
SessionManager: userSessionMgr,
|
||||
@ -266,9 +419,14 @@ func main() {
|
||||
}
|
||||
|
||||
// ===== User session cleanup goroutine =====
|
||||
// DB 接入塊 4:Redis 模式靠 key TTL 自動過期,不需 background 掃描,故只在 in-memory 模式啟動。
|
||||
cleanupCtx, cleanupCancel := context.WithCancel(context.Background())
|
||||
defer cleanupCancel()
|
||||
go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log)
|
||||
if redisClient == nil {
|
||||
go runUserSessionCleanup(cleanupCtx, userSessionStore, cfg.UserSession.IdleTTL, cfg.UserSession.AbsoluteTTL, log)
|
||||
} else {
|
||||
log.Info("user session cleanup goroutine skipped (redis TTL handles expiry)")
|
||||
}
|
||||
|
||||
// ===== 啟動 server =====
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
@ -6,12 +6,20 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/model"
|
||||
)
|
||||
|
||||
// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。
|
||||
//
|
||||
// 雛形的 StaticUserID("demo-user")不是合法 UUID,無法當 models.owner_user_id(UUID + FK)。
|
||||
// DB-backed seed 改用此固定 UUID,並先 upsert 一筆對應 users 列以滿足 FK;
|
||||
// in-memory seed 不受影響(仍用 cfg.Auth.StaticUserID)。
|
||||
const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3"
|
||||
|
||||
// seedDemoData 在啟動時塞入示範資料,方便本機開發 / demo 不必跑完整 pairing。
|
||||
//
|
||||
// 觸發條件:VISIONA_SEED_DEMO_DATA=true
|
||||
@ -25,11 +33,16 @@ import (
|
||||
// - 失敗只 log warning,不阻擋啟動
|
||||
// - 重複呼叫會產生重複資料;本函式只該被呼叫一次(main 已保證)
|
||||
// - **不要**在生產環境啟用此 flag
|
||||
//
|
||||
// dbPool 非 nil 表 model repo 已切到 Postgres(塊 1):此時 seed 的 model 必須用合法 UUID
|
||||
// 與已存在的 owner_user_id(UUID + FK),故先 upsert demo user、改用 demoSeedUserID。
|
||||
// dbPool 為 nil(in-memory fallback)時行為與雛形完全相同。
|
||||
func seedDemoData(
|
||||
devRepo device.Repository,
|
||||
mdlRepo model.Repository,
|
||||
pairings auth.PairingStore,
|
||||
userID string,
|
||||
dbPool *pgxpool.Pool,
|
||||
log *slog.Logger,
|
||||
) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
@ -37,10 +50,36 @@ func seedDemoData(
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 1. Demo device
|
||||
// owner / id 視 backend 決定:DB-backed 需合法 UUID + 存在的 user 列。
|
||||
// 塊 2 起 device 也落 DB,故 model 與 device 的 demo owner 必須一致(同一 demoSeedUserID),
|
||||
// 否則 owner 對不上(device.owner_user_id 也是 UUID + FK)。
|
||||
modelOwnerID := userID
|
||||
deviceOwnerID := userID
|
||||
// pairing token 的 owner(user_id):塊 3 起 pairing token 也落 DB,user_id 是
|
||||
// REFERENCES users(id) 的 FK + UUID,故 DB-backed 時必須與 model/device 一致用
|
||||
// demoSeedUserID(已 upsert 的合法 user),不可用非-UUID 的 StaticUserID。
|
||||
pairingOwnerID := userID
|
||||
modelID := "demo-model-" + uuid.NewString()[:8]
|
||||
deviceID := "demo-device-" + uuid.NewString()[:8]
|
||||
if dbPool != nil {
|
||||
modelOwnerID = demoSeedUserID
|
||||
deviceOwnerID = demoSeedUserID
|
||||
pairingOwnerID = demoSeedUserID
|
||||
modelID = uuid.NewString()
|
||||
deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串
|
||||
// 先確保 owner user 存在(滿足 models / devices owner_user_id FK)。
|
||||
if _, err := dbPool.Exec(ctx,
|
||||
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil {
|
||||
log.Warn("seed: ensure demo user failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Demo device(塊 2 起 DB-backed 時走 Postgres;owner 對齊 demoSeedUserID)
|
||||
dev := &device.Device{
|
||||
ID: "demo-device-" + uuid.NewString()[:8],
|
||||
OwnerUserID: userID,
|
||||
ID: deviceID,
|
||||
OwnerUserID: deviceOwnerID,
|
||||
Name: "Demo KL520 (seeded)",
|
||||
DeviceType: "kl520",
|
||||
SerialNumber: "DEMO-SN-001",
|
||||
@ -57,13 +96,13 @@ func seedDemoData(
|
||||
|
||||
// 2. Demo model
|
||||
mdl := &model.Model{
|
||||
ID: "demo-model-" + uuid.NewString()[:8],
|
||||
OwnerUserID: userID,
|
||||
ID: modelID,
|
||||
OwnerUserID: modelOwnerID,
|
||||
Name: "YOLOv5 Face (seeded)",
|
||||
TargetChip: "kl520",
|
||||
FileSize: 1024 * 1024, // 1 MB
|
||||
Source: model.SourceUploaded,
|
||||
StorageKey: "models/" + userID + "/demo.nef",
|
||||
StorageKey: "models/" + modelOwnerID + "/demo.nef",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
UploadedAt: &now,
|
||||
@ -75,7 +114,8 @@ func seedDemoData(
|
||||
}
|
||||
|
||||
// 3. Demo pairing token(log plaintext 方便開發 — 雛形 demo 用,生產禁用)
|
||||
pt, _, err := pairings.Create(ctx, userID, 24*time.Hour)
|
||||
// owner 用 pairingOwnerID:DB-backed 時為 demoSeedUserID(滿足 user_id FK)。
|
||||
pt, _, err := pairings.Create(ctx, pairingOwnerID, 24*time.Hour)
|
||||
if err != nil {
|
||||
log.Warn("seed: pairing token create failed", "error", err)
|
||||
} else {
|
||||
|
||||
76
visionA-backend/cmd/migrate/main.go
Normal file
76
visionA-backend/cmd/migrate/main.go
Normal file
@ -0,0 +1,76 @@
|
||||
// Command migrate 是 visionA-backend 的獨立 migration 工具。
|
||||
//
|
||||
// DB 接入塊 0:提供「不啟動整個 api-server 也能跑 migration」的入口,
|
||||
// 供 CI / 部署流程 / 手動操作使用。api-server 啟動時的 auto-migrate(VISIONA_DB_AUTO_MIGRATE)
|
||||
// 與本工具共用同一份嵌入式 migration(migrations.FS)+ internal/db.Migrator,行為一致。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/migrate up # 套用所有未執行的 migration(預設)
|
||||
// go run ./cmd/migrate down # 回退一個版本
|
||||
// go run ./cmd/migrate version # 顯示目前 schema 版本
|
||||
//
|
||||
// 連線資訊一律走 VISIONA_DB_* 環境變數(與 api-server 相同),不接受密碼當 CLI 參數
|
||||
// (避免密碼出現在 shell history / process list)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"visiona-backend/internal/config"
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/logger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
log := logger.New(cfg.Logger.Level).With("service", "migrate")
|
||||
|
||||
if !cfg.Database.Enabled() {
|
||||
log.Error("database not configured",
|
||||
"hint", "set VISIONA_DB_HOST + VISIONA_DB_USER + VISIONA_DB_NAME")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cmd := "up"
|
||||
if len(os.Args) > 1 {
|
||||
cmd = os.Args[1]
|
||||
}
|
||||
|
||||
mg, err := db.NewMigrator(cfg.Database, log)
|
||||
if err != nil {
|
||||
log.Error("failed to init migrator", "error", err, "target", db.SafeTarget(cfg.Database))
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := mg.Close(); cerr != nil {
|
||||
log.Warn("migrator close error", "error", cerr)
|
||||
}
|
||||
}()
|
||||
|
||||
switch cmd {
|
||||
case "up":
|
||||
if err := mg.Up(); err != nil {
|
||||
log.Error("migrate up failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("migrate up complete", "target", db.SafeTarget(cfg.Database))
|
||||
case "down":
|
||||
if err := mg.Down(); err != nil {
|
||||
log.Error("migrate down failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("migrate down (one step) complete", "target", db.SafeTarget(cfg.Database))
|
||||
case "version":
|
||||
ver, dirty, err := mg.Version()
|
||||
if err != nil {
|
||||
log.Error("read version failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
log.Info("schema version", "version", ver, "dirty", dirty)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command %q; use one of: up | down | version\n", cmd)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -68,6 +68,12 @@ type Deps struct {
|
||||
DeviceRepo device.Repository
|
||||
ModelRepo model.Repository
|
||||
|
||||
// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子
|
||||
// (Postgres tx)或一致(in-memory 依序)操作(DB 接入塊 5.2,database.md §6)。
|
||||
// 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為
|
||||
//(見 devices.go),確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。
|
||||
DeviceUnpairer DeviceUnpairer
|
||||
|
||||
Storage storage.Store
|
||||
Converter converter.Client
|
||||
|
||||
@ -112,6 +118,13 @@ type Deps struct {
|
||||
// 由 `POST /api/pairing/exchange` 回給 agent;若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder)。
|
||||
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
|
||||
RelayPublicURL string
|
||||
|
||||
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴(DB 接入塊 5.4)。
|
||||
// 由 main.go 注入 db.Pool / db.RedisClient(皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
|
||||
// /healthz 略過不檢查(in-memory 模式維持「process 活著就 ok」)。
|
||||
// 任一非 nil 依賴 ping 失敗 → /healthz 回 503,讓 load balancer 拉出此實例。
|
||||
HealthDBPool HealthPinger
|
||||
HealthRedis HealthPinger
|
||||
}
|
||||
|
||||
// validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。
|
||||
@ -160,8 +173,13 @@ func NewRouter(deps Deps) *gin.Engine {
|
||||
r.Use(CORSMiddleware(deps.CORSAllowedOrigins))
|
||||
r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON
|
||||
|
||||
// /healthz 不需要 auth — K8s liveness/readiness 用
|
||||
r.GET("/healthz", HealthzHandler())
|
||||
// /healthz 不需要 auth — K8s liveness/readiness 用。
|
||||
// 塊 5.4:啟用的依賴(Postgres / Redis)都 ping,任一失敗回 503(fail-fast)。
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||||
DBPool: deps.HealthDBPool,
|
||||
Redis: deps.HealthRedis,
|
||||
Logger: deps.Logger,
|
||||
}))
|
||||
|
||||
// /storage/* 不走 AuthMiddleware(改用 HMAC 簽章)— 對齊 api-spec.md §10
|
||||
registerStorageRoutes(r, deps)
|
||||
|
||||
@ -92,8 +92,8 @@ func devicesListHandler(deps Deps) gin.HandlerFunc {
|
||||
|
||||
devices, err := deps.DeviceRepo.List(ctx, userID)
|
||||
if err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"list devices failed: "+err.Error(), nil)
|
||||
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "list devices", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -161,8 +161,8 @@ func devicesGetHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get device failed: "+err.Error(), nil)
|
||||
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get device", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -234,8 +234,8 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get device failed: "+err.Error(), nil)
|
||||
// DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get device", err)
|
||||
return
|
||||
}
|
||||
if d.OwnerUserID != userID {
|
||||
@ -243,11 +243,30 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// 軟刪
|
||||
if err := deps.DeviceRepo.Delete(ctx, id); err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"delete device failed: "+err.Error(), nil)
|
||||
return
|
||||
// 軟刪 + cascade 撤銷該 device 的 pairing/session token(塊 5.2,database.md §6)。
|
||||
// - DeviceUnpairer 非 nil(main.go 注入 Postgres tx 版 / in-memory 依序版)→ 走 cascade。
|
||||
// - 為 nil(最小骨架)→ fallback 只軟刪 device,不 cascade(舊行為)。
|
||||
var unpairResult UnpairResult
|
||||
if deps.DeviceUnpairer != nil {
|
||||
res, uErr := deps.DeviceUnpairer.Unpair(ctx, id)
|
||||
if uErr != nil {
|
||||
if errors.Is(uErr, device.ErrNotFound) {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||
return
|
||||
}
|
||||
WriteDBError(c, deps.Logger, "unpair device", uErr)
|
||||
return
|
||||
}
|
||||
unpairResult = res
|
||||
} else {
|
||||
if err := deps.DeviceRepo.Delete(ctx, id); err != nil {
|
||||
if errors.Is(err, device.ErrNotFound) {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||
return
|
||||
}
|
||||
WriteDBError(c, deps.Logger, "delete device", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// best-effort:關閉該 user 的 session(雛形單裝置假設)
|
||||
@ -260,6 +279,8 @@ func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
|
||||
logOrDefault(deps.Logger).Info("devices: unpaired",
|
||||
"device_id", id,
|
||||
"user_id", userID,
|
||||
"pairing_tokens_revoked", unpairResult.PairingRevoked,
|
||||
"session_tokens_revoked", unpairResult.SessionRevoked,
|
||||
"request_id", RequestIDFrom(c))
|
||||
|
||||
WriteSuccess(c, http.StatusOK, gin.H{"id": id, "unpaired": true})
|
||||
|
||||
@ -19,6 +19,12 @@ const (
|
||||
ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE"
|
||||
// ErrCodeInvalidSignature 用於 /storage/* 驗簽失敗 / URL 過期。
|
||||
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
||||
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
||||
ErrCodeConflict = "CONFLICT"
|
||||
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
||||
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
||||
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
||||
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||||
)
|
||||
|
||||
// ErrorBody 是 API 錯誤回應的 envelope 結構。
|
||||
|
||||
101
visionA-backend/internal/api/errors_db.go
Normal file
101
visionA-backend/internal/api/errors_db.go
Normal file
@ -0,0 +1,101 @@
|
||||
// errors_db.go — 統一把 DB(pgx)錯誤映射成對外 API 錯誤(DB 接入塊 5.4)。
|
||||
//
|
||||
// 兩個目的:
|
||||
// 1. **fail-fast 落地**:PG 連線失敗 / context 逾時 → 503 SERVICE_UNAVAILABLE(不回假資料、
|
||||
// 不誤報 500),讓 load balancer 把這台拉出。對齊使用者拍板的「PG 都 fail-fast、不自動降級」。
|
||||
// 2. **收掉塊 3 M1 技術債**:handler 過去把 raw DB error 字串串進 response
|
||||
// (`"... failed: "+err.Error()`)會洩漏 schema / SQL / 連線細節。本檔統一映射,
|
||||
// 對外只給穩定的 error code + 通用 message,raw error 只進 server log(含 request_id)。
|
||||
//
|
||||
// 映射規則(依優先序):
|
||||
//
|
||||
// | 來源 | HTTP | code |
|
||||
// |----------------------------------------|------|-----------------------|
|
||||
// | context 逾時 / 取消(DeadlineExceeded / Canceled)| 503 | SERVICE_UNAVAILABLE |
|
||||
// | 連線層失敗(pgconn 無 SQLSTATE:refused/reset/EOF)| 503 | SERVICE_UNAVAILABLE |
|
||||
// | unique violation(SQLSTATE 23505) | 409 | CONFLICT |
|
||||
// | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR |
|
||||
// | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR |
|
||||
//
|
||||
// ⚠️ not found 不在此映射:domain 層(device.ErrNotFound / auth.ErrInvalidToken 等)已把
|
||||
// pgx.ErrNoRows 轉成自己的 sentinel,handler 應先用 errors.Is 比對那些 sentinel 回 404,
|
||||
// **再**把剩下的「真 DB 錯誤」交給 WriteDBError。這樣「正常的 not found」絕不會被誤判成 503。
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// dbErrorClass 是 DB 錯誤分類結果。
|
||||
type dbErrorClass struct {
|
||||
status int
|
||||
code string
|
||||
message string // 對外通用訊息(不含 raw DB 細節)
|
||||
}
|
||||
|
||||
// classifyDBError 把一個 error 分類為對外的 (HTTP status, code, message)。
|
||||
//
|
||||
// 不洩漏 raw DB error:回傳的 message 是固定通用字串,raw error 由 WriteDBError 寫進 server log。
|
||||
func classifyDBError(err error) dbErrorClass {
|
||||
// 1) context 逾時 / 取消 → 503(依賴慢/不可用,fail-fast)。
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable,
|
||||
"service temporarily unavailable"}
|
||||
}
|
||||
|
||||
// 2) pgconn.PgError:PG server 端回的錯誤(有 SQLSTATE)。
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "23505": // unique_violation
|
||||
return dbErrorClass{http.StatusConflict, ErrCodeConflict, "resource already exists"}
|
||||
default:
|
||||
// 其餘 PG 錯誤(語法、約束、權限…)對外一律 500,不洩漏 SQLSTATE / 欄位名。
|
||||
return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"internal error"}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) pgconn.ConnectError 等「連不上 / 連線中斷」(無 SQLSTATE)→ 503。
|
||||
// pgxpool 連線層失敗(connection refused / reset / EOF)多半包成 *pgconn.ConnectError,
|
||||
// 或為底層 net error。用 errors.As 抓 ConnectError;抓不到再保守視為連線問題前先看下一步。
|
||||
var connErr *pgconn.ConnectError
|
||||
if errors.As(err, &connErr) {
|
||||
return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable,
|
||||
"service temporarily unavailable"}
|
||||
}
|
||||
|
||||
// 4) 其餘未知錯誤 → 500(保守,不假設是 DB down 以免把程式 bug 也報成 503)。
|
||||
return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, "internal error"}
|
||||
}
|
||||
|
||||
// WriteDBError 把一個 DB 操作錯誤映射成對外 API 錯誤並寫回 response,同時把 raw error 進 log。
|
||||
//
|
||||
// op 是操作描述(如 "get device" / "list models"),只進 log、不對外。
|
||||
// 對外只給 classifyDBError 算出的穩定 code + 通用 message(不洩漏 raw DB error)。
|
||||
//
|
||||
// 用法(handler 內,已先處理過 domain sentinel 如 ErrNotFound 後):
|
||||
//
|
||||
// if err != nil {
|
||||
// WriteDBError(c, deps.Logger, "list devices", err)
|
||||
// return
|
||||
// }
|
||||
func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) {
|
||||
cls := classifyDBError(err)
|
||||
|
||||
// raw error 只進 server log(附 request_id + 對外 code,方便對照),不進 response。
|
||||
logOrDefault(log).Error("db operation failed",
|
||||
"op", op,
|
||||
"error", err,
|
||||
"http_status", cls.status,
|
||||
"code", cls.code,
|
||||
"request_id", RequestIDFrom(c))
|
||||
|
||||
WriteError(c, cls.status, cls.code, cls.message, nil)
|
||||
}
|
||||
119
visionA-backend/internal/api/errors_db_test.go
Normal file
119
visionA-backend/internal/api/errors_db_test.go
Normal 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/message,raw error 的細節不得出現
|
||||
assert.Contains(t, body, ErrCodeInternalError)
|
||||
assert.NotContains(t, body, "syntax error")
|
||||
assert.NotContains(t, body, "secret-schema-detail")
|
||||
assert.NotContains(t, body, "internal hint")
|
||||
}
|
||||
|
||||
// TestWriteDBError_PGDown503 驗證 PG down(connect error)→ 503 + SERVICE_UNAVAILABLE。
|
||||
func TestWriteDBError_PGDown503(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
WriteDBError(c, nil, "get model", &pgconn.ConnectError{Config: &pgconn.Config{}})
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeServiceUnavailable)
|
||||
}
|
||||
@ -9,13 +9,74 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HealthzHandler 是 K8s liveness / readiness 用的最小健康檢查。
|
||||
// HealthPinger 抽象「能對某依賴跑一次連線檢查」的能力。
|
||||
//
|
||||
// 不檢查任何依賴(remote-proxy、DB),只代表 process 還活著。
|
||||
// readiness 想檢查依賴的話應該用 /api/system/health。
|
||||
func HealthzHandler() gin.HandlerFunc {
|
||||
// db.Pool(Postgres)與 db.RedisClient(Redis)都有 Ping(ctx) error,天然滿足。
|
||||
// 用窄介面而非綁 concrete type:方便 unit test 注入 fake ping(成功 / 失敗)。
|
||||
type HealthPinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
// HealthDeps 是 /healthz 要檢查的依賴(DB 接入塊 5.4)。
|
||||
//
|
||||
// 啟用語意(對齊使用者拍板的 fail-fast 策略):
|
||||
// - DBPool 非 nil(Postgres 啟用)→ 每次 /healthz 都 ping,失敗回 503。
|
||||
// - Redis 非 nil(Redis 啟用)→ 每次 /healthz 都 ping,失敗回 503。
|
||||
// - 兩者皆 nil(in-memory 模式 / 未啟用)→ 略過,維持「process 活著就 ok」的舊行為。
|
||||
//
|
||||
// 為什麼 ping 失敗回 503:讓 load balancer / K8s readiness 把這台不健康的實例拉出輪替,
|
||||
// 而非繼續送流量進來碰 DB 拿 503/假資料。
|
||||
type HealthDeps struct {
|
||||
DBPool HealthPinger // Postgres 連線池(db.Pool);nil = 未啟用
|
||||
Redis HealthPinger // Redis client(db.RedisClient);nil = 未啟用
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// healthPingTimeout 是單一依賴 ping 的逾時上限。
|
||||
// /healthz 須快速回應(load balancer 高頻打),故給短逾時——ping 卡住即視為不健康。
|
||||
const healthPingTimeout = 2 * time.Second
|
||||
|
||||
// HealthzHandler 是 K8s liveness / readiness 用的健康檢查。
|
||||
//
|
||||
// DB 接入塊 5.4:擴充為「啟用的依賴(Postgres / Redis)都 ping 一次」。任一啟用依賴 ping
|
||||
// 失敗 → 503(body 標出哪個依賴不健康,但不洩漏 raw error,只進 log);全數通過 → 200。
|
||||
//
|
||||
// 未啟用任何依賴(deps 全 nil)時退化為原本的「process 活著就 ok」最小檢查。
|
||||
func HealthzHandler(deps HealthDeps) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), healthPingTimeout)
|
||||
defer cancel()
|
||||
|
||||
checks := gin.H{}
|
||||
healthy := true
|
||||
|
||||
if deps.DBPool != nil {
|
||||
if err := deps.DBPool.Ping(ctx); err != nil {
|
||||
healthy = false
|
||||
checks["postgres"] = "down"
|
||||
logOrDefault(deps.Logger).Error("healthz: postgres ping failed",
|
||||
"error", err, "request_id", RequestIDFrom(c))
|
||||
} else {
|
||||
checks["postgres"] = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
if deps.Redis != nil {
|
||||
if err := deps.Redis.Ping(ctx); err != nil {
|
||||
healthy = false
|
||||
checks["redis"] = "down"
|
||||
logOrDefault(deps.Logger).Error("healthz: redis ping failed",
|
||||
"error", err, "request_id", RequestIDFrom(c))
|
||||
} else {
|
||||
checks["redis"] = "ok"
|
||||
}
|
||||
}
|
||||
|
||||
if !healthy {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable", "checks": checks})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "checks": checks})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@ -44,10 +45,15 @@ func (f *fakeSessionStore) CleanupExpired(context.Context, time.Duration) (int,
|
||||
panic("CleanupExpired should not be called")
|
||||
}
|
||||
|
||||
// TestHealthzHandler 驗證 /healthz 回 200 + status:ok。
|
||||
// fakePinger 是 /healthz 依賴 ping 的測試替身(純 Go,不需真 DB)。
|
||||
type fakePinger struct{ err error }
|
||||
|
||||
func (f fakePinger) Ping(context.Context) error { return f.err }
|
||||
|
||||
// TestHealthzHandler 驗證 /healthz 在「無依賴」時回 200 + status:ok(退化為最小檢查)。
|
||||
func TestHealthzHandler(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/healthz", HealthzHandler())
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
@ -55,6 +61,65 @@ func TestHealthzHandler(t *testing.T) {
|
||||
assert.Contains(t, w.Body.String(), `"status":"ok"`)
|
||||
}
|
||||
|
||||
// TestHealthz_AllDepsHealthy 驗證 PG + Redis 都 ping 成功 → 200 + checks 全 ok。
|
||||
func TestHealthz_AllDepsHealthy(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||||
DBPool: fakePinger{},
|
||||
Redis: fakePinger{},
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"postgres":"ok"`)
|
||||
assert.Contains(t, w.Body.String(), `"redis":"ok"`)
|
||||
}
|
||||
|
||||
// TestHealthz_PostgresDown 驗證 PG ping 失敗 → 503(塊 5.4 fail-fast)。
|
||||
func TestHealthz_PostgresDown(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||||
DBPool: fakePinger{err: errors.New("connection refused")},
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"postgres":"down"`)
|
||||
// 不洩漏 raw error 進 response(只進 log)
|
||||
assert.NotContains(t, w.Body.String(), "connection refused")
|
||||
}
|
||||
|
||||
// TestHealthz_RedisDown 驗證 Redis 啟用但 ping 失敗 → 503;PG ok 仍標出 redis down。
|
||||
func TestHealthz_RedisDown(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||||
DBPool: fakePinger{},
|
||||
Redis: fakePinger{err: errors.New("redis down")},
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
assert.Contains(t, w.Body.String(), `"redis":"down"`)
|
||||
assert.Contains(t, w.Body.String(), `"postgres":"ok"`)
|
||||
}
|
||||
|
||||
// TestHealthz_RedisNotEnabled 驗證 Redis 未啟用(nil)→ 不檢查、PG ok 即 200。
|
||||
func TestHealthz_RedisNotEnabled(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.GET("/healthz", HealthzHandler(HealthDeps{
|
||||
DBPool: fakePinger{},
|
||||
// Redis nil — 未啟用,略過
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.NotContains(t, w.Body.String(), "redis")
|
||||
}
|
||||
|
||||
// TestSystemHealth_TunnelDisconnected 驗證沒 session 時回 connected=false。
|
||||
func TestSystemHealth_TunnelDisconnected(t *testing.T) {
|
||||
r := gin.New()
|
||||
|
||||
@ -106,8 +106,8 @@ func modelsListHandler(deps Deps) gin.HandlerFunc {
|
||||
|
||||
models, err := deps.ModelRepo.List(ctx, model.ListFilter{OwnerUserID: userID})
|
||||
if err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"list models failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "list models", err)
|
||||
return
|
||||
}
|
||||
out := make([]ModelResponse, 0, len(models))
|
||||
@ -148,8 +148,8 @@ func modelsGetHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get model", err)
|
||||
return
|
||||
}
|
||||
if m.OwnerUserID != userID {
|
||||
@ -266,8 +266,8 @@ func modelsInitUploadHandler(deps Deps) gin.HandlerFunc {
|
||||
// UploadedAt 保持 nil 直到 finalize
|
||||
}
|
||||
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"save model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "save model", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -326,8 +326,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get model", err)
|
||||
return
|
||||
}
|
||||
if m.OwnerUserID != userID {
|
||||
@ -362,8 +362,8 @@ func modelsFinalizeHandler(deps Deps) gin.HandlerFunc {
|
||||
m.UploadedAt = &now
|
||||
m.UpdatedAt = now
|
||||
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"save model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "save model", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -412,8 +412,8 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get model", err)
|
||||
return
|
||||
}
|
||||
if m.OwnerUserID != userID {
|
||||
@ -422,8 +422,12 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := deps.ModelRepo.Delete(ctx, id); err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"delete model failed: "+err.Error(), nil)
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "delete model", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -497,8 +501,8 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||
WriteDBError(c, deps.Logger, "get model", err)
|
||||
return
|
||||
}
|
||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
||||
|
||||
@ -235,8 +235,8 @@ func pairingListTokensHandler(deps Deps) gin.HandlerFunc {
|
||||
|
||||
tokens, err := deps.PairingStore.List(ctx, userID)
|
||||
if err != nil {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"list pairing tokens failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。
|
||||
WriteDBError(c, deps.Logger, "list pairing tokens", err)
|
||||
return
|
||||
}
|
||||
out := make([]PairingTokenListItem, 0, len(tokens))
|
||||
@ -283,8 +283,8 @@ func pairingRevokeTokenHandler(deps Deps) gin.HandlerFunc {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "token not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"revoke token failed: "+err.Error(), nil)
|
||||
// 塊 5.4:DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),收掉塊 3 M1 raw error 洩漏。
|
||||
WriteDBError(c, deps.Logger, "revoke pairing token", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
157
visionA-backend/internal/api/unpair.go
Normal file
157
visionA-backend/internal/api/unpair.go
Normal file
@ -0,0 +1,157 @@
|
||||
// unpair.go — device unpair 的 cascade 撤銷協調者(DB 接入塊 5.2)。
|
||||
//
|
||||
// 「刪 device → 同時撤銷該 device 名下所有 pairing + session token」是一個跨 store 的一致性
|
||||
// 操作(database.md §6)。為了讓 handler(devices.go 的 unpair)維持薄、同時支援 Postgres(真
|
||||
// 交易)與 in-memory(local-dev fallback)兩種後端,這裡抽出 DeviceUnpairer 介面:
|
||||
//
|
||||
// - Postgres 後端:pgDeviceUnpairer 用 db.WithTx 把「device 軟刪 + 兩張 token 表撤銷」包成
|
||||
// 單一交易——任一步失敗整筆 rollback,杜絕「device 已刪但 token 沒撤」的中間狀態。
|
||||
// - in-memory 後端:memDeviceUnpairer 依序呼叫(無交易),行為一致(刪 device 後 token 也撤),
|
||||
// 對齊「DB 未啟用時 cascade 在 in-memory 也成立」的約束。
|
||||
//
|
||||
// 兩者由 main.go 依 dbPool 是否非 nil 擇一注入 Deps.DeviceUnpairer。為 nil 時 unpair handler
|
||||
// fallback 到「只軟刪 device(不 cascade)」的舊行為(見 devices.go),確保最小骨架仍可啟動。
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/device"
|
||||
)
|
||||
|
||||
// UnpairResult 回報 cascade 撤銷的結果(觀測用:撤了幾個 token)。
|
||||
type UnpairResult struct {
|
||||
PairingRevoked int // 本次撤銷的 pairing token 數
|
||||
SessionRevoked int // 本次撤銷的 session token 數
|
||||
}
|
||||
|
||||
// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 token」包成一個原子(Postgres)或
|
||||
// 一致(in-memory)操作。
|
||||
//
|
||||
// Unpair 語意:device 不存在 / 已刪除回 device.ErrNotFound(handler 轉 404);其餘 DB 錯誤
|
||||
// 原樣回傳(handler 經 errors.go 映射成 503 / 500,不洩漏 raw error)。
|
||||
type DeviceUnpairer interface {
|
||||
Unpair(ctx context.Context, deviceID string) (UnpairResult, error)
|
||||
}
|
||||
|
||||
// ── Postgres 後端介面(避免 api 直接綁 concrete type,方便測試注入) ──────────────
|
||||
|
||||
// pgDeviceDeleter 是 device 在 tx 內軟刪的能力(由 device.PostgresRepository 滿足)。
|
||||
type pgDeviceDeleter interface {
|
||||
DeleteTx(ctx context.Context, q db.Querier, id string) error
|
||||
}
|
||||
|
||||
// pgTokenRevoker 是「在 tx 內撤銷某 device 名下未撤銷 token」的能力
|
||||
// (由 auth.PostgresPairingStore / auth.PostgresSessionTokenStore 滿足)。
|
||||
type pgTokenRevoker interface {
|
||||
RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error)
|
||||
}
|
||||
|
||||
// pgDeviceUnpairer 用單一 pgx 交易完成 device 軟刪 + 兩張 token 表 cascade 撤銷。
|
||||
type pgDeviceUnpairer struct {
|
||||
pool *pgxpool.Pool
|
||||
devices pgDeviceDeleter
|
||||
pairingTok pgTokenRevoker
|
||||
sessionTok pgTokenRevoker
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewPostgresDeviceUnpairer 建立 Postgres 後端的 cascade unpair 協調者。
|
||||
//
|
||||
// 三個依賴分別來自 device.PostgresRepository / auth.PostgresPairingStore /
|
||||
// auth.PostgresSessionTokenStore(main.go 注入)。pool 用來開交易。
|
||||
func NewPostgresDeviceUnpairer(
|
||||
pool *pgxpool.Pool,
|
||||
devices pgDeviceDeleter,
|
||||
pairingTok pgTokenRevoker,
|
||||
sessionTok pgTokenRevoker,
|
||||
log *slog.Logger,
|
||||
) DeviceUnpairer {
|
||||
return &pgDeviceUnpairer{
|
||||
pool: pool,
|
||||
devices: devices,
|
||||
pairingTok: pairingTok,
|
||||
sessionTok: sessionTok,
|
||||
log: logOrDefault(log),
|
||||
}
|
||||
}
|
||||
|
||||
// Unpair 在單一交易內:軟刪 device → 撤 pairing token → 撤 session token。
|
||||
//
|
||||
// 順序:device 軟刪先做——若 device 不存在 / 已刪除(ErrNotFound)就提早回,交易內什麼都沒改、
|
||||
// rollback 無副作用。device 軟刪成功後才撤兩張 token 表;任一步失敗整筆 rollback。
|
||||
func (u *pgDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) {
|
||||
var res UnpairResult
|
||||
err := db.WithTx(ctx, u.pool, func(q db.Querier) error {
|
||||
if delErr := u.devices.DeleteTx(ctx, q, deviceID); delErr != nil {
|
||||
return delErr // 含 device.ErrNotFound;WithTx 會 rollback(此時尚無變更)
|
||||
}
|
||||
pRevoked, pErr := u.pairingTok.RevokeByDeviceTx(ctx, q, deviceID)
|
||||
if pErr != nil {
|
||||
return fmt.Errorf("cascade revoke pairing tokens: %w", pErr)
|
||||
}
|
||||
sRevoked, sErr := u.sessionTok.RevokeByDeviceTx(ctx, q, deviceID)
|
||||
if sErr != nil {
|
||||
return fmt.Errorf("cascade revoke session tokens: %w", sErr)
|
||||
}
|
||||
res.PairingRevoked = pRevoked
|
||||
res.SessionRevoked = sRevoked
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return UnpairResult{}, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ── in-memory 後端介面 ────────────────────────────────────────────────────────
|
||||
|
||||
// memTokenRevoker 是 in-memory store「撤銷某 device 名下未撤銷 token」的能力
|
||||
// (由 auth.InMemoryPairingStore / auth.InMemorySessionTokenStore 滿足)。
|
||||
type memTokenRevoker interface {
|
||||
RevokeByDevice(ctx context.Context, deviceID string) (int, error)
|
||||
}
|
||||
|
||||
// memDeviceUnpairer 依序(非交易)完成 device 軟刪 + token cascade 撤銷。
|
||||
//
|
||||
// in-memory 為單機 local-dev fallback,無跨 store 交易需求;依序執行已能保證行為一致
|
||||
// (刪 device 後 token 也撤)。device 軟刪失敗(ErrNotFound)提早回、不撤 token。
|
||||
type memDeviceUnpairer struct {
|
||||
devices device.Repository
|
||||
pairingTok memTokenRevoker
|
||||
sessionTok memTokenRevoker
|
||||
}
|
||||
|
||||
// NewInMemoryDeviceUnpairer 建立 in-memory 後端的 cascade unpair 協調者。
|
||||
func NewInMemoryDeviceUnpairer(
|
||||
devices device.Repository,
|
||||
pairingTok memTokenRevoker,
|
||||
sessionTok memTokenRevoker,
|
||||
) DeviceUnpairer {
|
||||
return &memDeviceUnpairer{
|
||||
devices: devices,
|
||||
pairingTok: pairingTok,
|
||||
sessionTok: sessionTok,
|
||||
}
|
||||
}
|
||||
|
||||
// Unpair 軟刪 device 後 cascade 撤銷其 token(依序,非交易)。
|
||||
func (u *memDeviceUnpairer) Unpair(ctx context.Context, deviceID string) (UnpairResult, error) {
|
||||
if err := u.devices.Delete(ctx, deviceID); err != nil {
|
||||
return UnpairResult{}, err // 含 device.ErrNotFound
|
||||
}
|
||||
pRevoked, err := u.pairingTok.RevokeByDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return UnpairResult{}, fmt.Errorf("cascade revoke pairing tokens: %w", err)
|
||||
}
|
||||
sRevoked, err := u.sessionTok.RevokeByDevice(ctx, deviceID)
|
||||
if err != nil {
|
||||
return UnpairResult{}, fmt.Errorf("cascade revoke session tokens: %w", err)
|
||||
}
|
||||
return UnpairResult{PairingRevoked: pRevoked, SessionRevoked: sRevoked}, nil
|
||||
}
|
||||
145
visionA-backend/internal/api/unpair_db_test.go
Normal file
145
visionA-backend/internal/api/unpair_db_test.go
Normal 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 token(database.md §6)。
|
||||
// - rollback 原子性:cascade 中途失敗 → device 軟刪也回滾(device 仍存在、token 未撤)。
|
||||
// - device 不存在 → device.ErrNotFound、不撤任何 token。
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/auth"
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
"visiona-backend/internal/device"
|
||||
)
|
||||
|
||||
// pgUnpairFixture 建一個已就緒的 Postgres 環境:device + 綁該 device 的 pairing/session token。
|
||||
func pgUnpairFixture(t *testing.T) (
|
||||
tdb *testsupport.TestDB,
|
||||
devRepo *device.PostgresRepository,
|
||||
pairing *auth.PostgresPairingStore,
|
||||
sessions *auth.PostgresSessionTokenStore,
|
||||
owner, deviceID, pairingPlain, sessionPlain string,
|
||||
) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
tdb = testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
|
||||
owner = tdb.EnsureDemoUser(t)
|
||||
|
||||
devRepo = device.NewPostgresRepository(tdb.Pool)
|
||||
pairing = auth.NewPostgresPairingStore(tdb.Pool)
|
||||
sessions = auth.NewPostgresSessionTokenStore(tdb.Pool)
|
||||
|
||||
deviceID = tdb.InsertDevice(t, "", owner)
|
||||
|
||||
pPlain, _, err := pairing.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID))
|
||||
pairingPlain = pPlain
|
||||
|
||||
sPlain, _, err := sessions.Create(ctx, owner, deviceID, "", auth.SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
sessionPlain = sPlain
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// TestPGUnpair_CascadeSuccess 驗證 cascade 成功:device 軟刪 + 兩張 token 表撤銷,單一交易落地。
|
||||
func TestPGUnpair_CascadeSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, devRepo, pairing, sessions, _, deviceID, pPlain, sPlain := pgUnpairFixture(t)
|
||||
|
||||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil)
|
||||
res, err := unpairer.Unpair(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, res.PairingRevoked)
|
||||
assert.Equal(t, 1, res.SessionRevoked)
|
||||
|
||||
// device 已軟刪
|
||||
_, err = devRepo.Get(ctx, deviceID)
|
||||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||||
// pairing token 已撤
|
||||
_, err = pairing.Validate(ctx, pPlain)
|
||||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||||
// session token 已撤
|
||||
_, err = sessions.Get(ctx, sPlain)
|
||||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||||
}
|
||||
|
||||
// failingRevoker 是會回 error 的 token revoker,用來觸發 cascade 中途失敗、驗證 rollback。
|
||||
type failingRevoker struct{}
|
||||
|
||||
func (failingRevoker) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
|
||||
return 0, errors.New("simulated revoke failure")
|
||||
}
|
||||
|
||||
// TestPGUnpair_RollbackOnCascadeFailure 驗證 cascade 中途失敗 → device 軟刪也整筆回滾。
|
||||
//
|
||||
// 用 failingRevoker 取代 session token revoker:device 在 tx 內已軟刪、pairing 已撤,但 session
|
||||
// 撤銷回 error → WithTx rollback,最終 device 仍未刪、pairing token 也未撤(整筆原子)。
|
||||
func TestPGUnpair_RollbackOnCascadeFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, devRepo, pairing, _, _, deviceID, pPlain, _ := pgUnpairFixture(t)
|
||||
|
||||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, failingRevoker{}, nil)
|
||||
_, err := unpairer.Unpair(ctx, deviceID)
|
||||
require.Error(t, err)
|
||||
|
||||
// device 仍存在(軟刪被 rollback)
|
||||
d, getErr := devRepo.Get(ctx, deviceID)
|
||||
require.NoError(t, getErr, "device 應因 rollback 仍存在")
|
||||
assert.Nil(t, d.DeletedAt)
|
||||
|
||||
// pairing token 也未撤(同一交易 rollback)。
|
||||
// fixture 已 MarkUsed(cascade 撤銷靠 WHERE device_id,token 須先綁 device),
|
||||
// 故不能用 Validate 驗——used token 必回 ErrTokenUsed。直接查 revoked_at 是否仍為 NULL。
|
||||
var pairingRevokedAt *time.Time
|
||||
qErr := tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`,
|
||||
auth.HashToken(pPlain)).Scan(&pairingRevokedAt)
|
||||
require.NoError(t, qErr)
|
||||
assert.Nil(t, pairingRevokedAt, "pairing token 應因 rollback 未被撤銷")
|
||||
}
|
||||
|
||||
// TestPGUnpair_DeviceNotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。
|
||||
func TestPGUnpair_DeviceNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tdb, devRepo, pairing, sessions, _, _, pPlain, sPlain := pgUnpairFixture(t)
|
||||
|
||||
unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil)
|
||||
_, err := unpairer.Unpair(ctx, "00000000-0000-0000-0000-0000000000ff")
|
||||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||||
|
||||
// 原 device 的 token 不受影響(沒被誤撤)。
|
||||
// pairing token fixture 已 MarkUsed,不能用 Validate(必回 ErrTokenUsed);直接查 revoked_at 仍為 NULL。
|
||||
var pairingRevokedAt *time.Time
|
||||
qErr := tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`,
|
||||
auth.HashToken(pPlain)).Scan(&pairingRevokedAt)
|
||||
require.NoError(t, qErr)
|
||||
assert.Nil(t, pairingRevokedAt, "不存在 device 的 unpair 不應誤撤 pairing token")
|
||||
// session token 無 used 概念,未撤銷時 Get 正常回 NoError,沿用原斷言。
|
||||
_, err = sessions.Get(ctx, sPlain)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
81
visionA-backend/internal/api/unpair_test.go
Normal file
81
visionA-backend/internal/api/unpair_test.go
Normal 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 到該 device(MarkUsed 綁 device)
|
||||
pPlain, _, err := pairing.Create(ctx, userID, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID))
|
||||
|
||||
// 建一個 session token 綁該 device
|
||||
sPlain, _, err := sessions.Create(ctx, userID, deviceID, "", 90*24*time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 另一個 device 的 token(不應被撤)
|
||||
const otherDevice = "dev-2"
|
||||
require.NoError(t, devRepo.Save(ctx, &device.Device{
|
||||
ID: otherDevice, OwnerUserID: userID, Name: "d2", SerialNumber: "SN2",
|
||||
}))
|
||||
oPlain, _, err := sessions.Create(ctx, userID, otherDevice, "", 90*24*time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions)
|
||||
res, err := unpairer.Unpair(ctx, deviceID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, res.PairingRevoked)
|
||||
assert.Equal(t, 1, res.SessionRevoked)
|
||||
|
||||
// device 已軟刪
|
||||
_, err = devRepo.Get(ctx, deviceID)
|
||||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||||
|
||||
// 該 device 的 session token 已撤
|
||||
_, err = sessions.Get(ctx, sPlain)
|
||||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||||
|
||||
// 該 device 的 pairing token 已撤
|
||||
_, err = pairing.Validate(ctx, pPlain)
|
||||
assert.ErrorIs(t, err, auth.ErrTokenRevoked)
|
||||
|
||||
// 另一個 device 的 token 不受影響
|
||||
_, err = sessions.Get(ctx, oPlain)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestInMemoryDeviceUnpairer_NotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。
|
||||
func TestInMemoryDeviceUnpairer_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
devRepo := device.NewInMemoryRepository()
|
||||
pairing := auth.NewInMemoryPairingStore()
|
||||
sessions := auth.NewInMemorySessionTokenStore()
|
||||
|
||||
unpairer := NewInMemoryDeviceUnpairer(devRepo, pairing, sessions)
|
||||
_, err := unpairer.Unpair(ctx, "does-not-exist")
|
||||
assert.ErrorIs(t, err, device.ErrNotFound)
|
||||
}
|
||||
@ -138,6 +138,31 @@ func (s *InMemoryPairingStore) List(ctx context.Context, userID string) ([]*Pair
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。
|
||||
//
|
||||
// in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token,
|
||||
// 回傳實際撤銷數。空 deviceID 不撤任何 token(對齊 Postgres)。
|
||||
//
|
||||
// in-memory 無交易概念:delete device 與本方法在 unpair coordinator 內依序執行,雖非原子,
|
||||
// 但 in-memory 為單機 local-dev fallback,行為一致性(刪 device 後 token 也撤)已滿足。
|
||||
func (s *InMemoryPairingStore) RevokeByDevice(ctx context.Context, deviceID string) (int, error) {
|
||||
if deviceID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
revoked := 0
|
||||
for _, info := range s.tokens {
|
||||
if info.DeviceID == deviceID && info.RevokedAt == nil {
|
||||
info.RevokedAt = &now
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
// CleanupExpired 移除所有已過 ExpiresAt 的 token;回傳移除數量。
|
||||
//
|
||||
// 通常由 background goroutine 週期性呼叫(例:每 1 分鐘)。
|
||||
|
||||
@ -130,3 +130,41 @@ func TestInMemoryPairingStore_Validate_Expired(t *testing.T) {
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenExpired)
|
||||
}
|
||||
|
||||
// TestInMemoryPairingStore_RevokeByDevice 驗證 cascade 撤銷(塊 5.2):
|
||||
// 只撤指定 device 名下未撤銷的 token,回傳撤銷數。
|
||||
func TestInMemoryPairingStore_RevokeByDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := NewInMemoryPairingStore()
|
||||
|
||||
// 綁 dev-1 的兩個 token(其中一個先撤)
|
||||
p1, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p1, "dev-1"))
|
||||
|
||||
p2, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p2, "dev-1"))
|
||||
require.NoError(t, s.Revoke(ctx, p2)) // 已撤,不應重複計數
|
||||
|
||||
// 綁 dev-2(不應被撤)
|
||||
p3, _, err := s.Create(ctx, "user-1", 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p3, "dev-2"))
|
||||
|
||||
revoked, err := s.RevokeByDevice(ctx, "dev-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, revoked, "只有 p1(未撤)被撤;p2 已撤不計")
|
||||
|
||||
// p1 已撤
|
||||
_, err = s.Validate(ctx, p1)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||||
// p3(dev-2)不受影響
|
||||
_, err = s.Validate(ctx, p3)
|
||||
assert.ErrorIs(t, err, ErrTokenUsed) // 已 used 但未 revoke
|
||||
|
||||
// 空 deviceID 不撤任何 token
|
||||
n, err := s.RevokeByDevice(ctx, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
|
||||
323
visionA-backend/internal/auth/postgres_pairing_store.go
Normal file
323
visionA-backend/internal/auth/postgres_pairing_store.go
Normal 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.4(PairingToken struct)、§4(pairing_tokens 表 schema)
|
||||
// - migrations/0003_create_token_tables.up.sql(pairing_tokens 表)
|
||||
//
|
||||
// ── 關鍵改動:plaintext → token_hash 當 PK(database.md 結尾提醒、塊 3 子任務 3.3)──
|
||||
//
|
||||
// in-memory 版以 plaintext token 當 map key;Postgres 版改以 token_hash(HashToken(plaintext))
|
||||
// 當 PK,DB 永不存明文 token(security.md §1.3)。
|
||||
//
|
||||
// 所有「以 plaintext 查詢」的方法(Validate / MarkUsed / Revoke)一律先 HashToken(plaintext)
|
||||
// 再以 hash 比對。呼叫端統一傳 plaintext 進來(已 grep 確認:internal/api/pairing.go 的
|
||||
// Validate / MarkUsed / Revoke 全部傳 plaintext 的 vAc_ token),store 內部統一 hash,
|
||||
// 不會有「漏 hash 某個呼叫端」的問題。
|
||||
//
|
||||
// 語意對齊 in-memory(見 inmemory_pairing_store.go):
|
||||
// - Validate 的狀態優先序:revoked → used → expired(與 in-memory 完全一致)。
|
||||
// - MarkUsed 一次性 + 冪等:未使用 → 寫 used_at + device_id;已使用 → no-op 回 nil(不覆寫 device_id);
|
||||
// 不存在 → ErrInvalidToken。DB 層用 `WHERE used_at IS NULL` 達成兩併發只一筆實際標記。
|
||||
// - Revoke 冪等:未撤銷 → 寫 revoked_at;已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||||
// - CleanupExpired:DELETE 所有 expires_at < now 的列,回刪除數。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// PostgresPairingStore 是 pairing token 的 PostgreSQL 持久層實作。
|
||||
type PostgresPairingStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresPairingStore 建立一個以 pgxpool 為後端的 PairingStore。
|
||||
//
|
||||
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
|
||||
func NewPostgresPairingStore(pool *pgxpool.Pool) *PostgresPairingStore {
|
||||
return &PostgresPairingStore{pool: pool}
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 PostgresPairingStore 實作 PairingStore。
|
||||
var _ PairingStore = (*PostgresPairingStore)(nil)
|
||||
|
||||
// pairingColumns 是 SELECT 共用欄位清單(順序必須與 scanPairingToken 對齊)。
|
||||
//
|
||||
// 注意:DB 不存 plaintext,故掃出的 PairingToken.Plaintext 永遠為空字串(符合預期,
|
||||
// 呼叫端在 Validate 後只用 UserID / DeviceID / TokenHash,不依賴 Plaintext)。
|
||||
const pairingColumns = `token_hash, user_id, device_id, kind,
|
||||
created_at, expires_at, used_at, revoked_at`
|
||||
|
||||
// Create 產生並保存一個新 pairing token。
|
||||
//
|
||||
// ttl <= 0 時 ExpiresAt 保持 NULL(永不過期;測試用)。
|
||||
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。
|
||||
func (s *PostgresPairingStore) Create(
|
||||
ctx context.Context, userID string, ttl time.Duration,
|
||||
) (string, *PairingToken, error) {
|
||||
plaintext, err := GeneratePairingToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
info := &PairingToken{
|
||||
Plaintext: plaintext,
|
||||
TokenHash: HashToken(plaintext),
|
||||
UserID: userID,
|
||||
Kind: KindPairing,
|
||||
CreatedAt: now,
|
||||
}
|
||||
var expiresAt any // nil → DB NULL
|
||||
if ttl > 0 {
|
||||
expires := now.Add(ttl)
|
||||
info.ExpiresAt = &expires
|
||||
expiresAt = expires
|
||||
}
|
||||
|
||||
const q = `INSERT INTO pairing_tokens
|
||||
(token_hash, user_id, kind, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)`
|
||||
if _, err := s.pool.Exec(ctx, q,
|
||||
info.TokenHash, info.UserID, string(info.Kind), info.CreatedAt, expiresAt,
|
||||
); err != nil {
|
||||
return "", nil, fmt.Errorf("auth: pg pairing Create: %w", err)
|
||||
}
|
||||
return plaintext, info, nil
|
||||
}
|
||||
|
||||
// Validate 檢查 token 是否存在且可用(未撤銷、未使用、未過期)。
|
||||
//
|
||||
// 接收 plaintext,內部 HashToken 後查詢。狀態優先序對齊 in-memory:
|
||||
// revoked → used → expired。不存在回 ErrInvalidToken。
|
||||
func (s *PostgresPairingStore) Validate(ctx context.Context, token string) (*PairingToken, error) {
|
||||
hash := HashToken(token)
|
||||
|
||||
const q = `SELECT ` + pairingColumns + `
|
||||
FROM pairing_tokens WHERE token_hash = $1`
|
||||
row := s.pool.QueryRow(ctx, q, hash)
|
||||
info, err := scanPairingToken(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: pg pairing Validate: %w", err)
|
||||
}
|
||||
|
||||
if info.IsRevoked() {
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
if info.IsUsed() {
|
||||
return nil, ErrTokenUsed
|
||||
}
|
||||
if info.IsExpired(time.Now().UTC()) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// MarkUsed 標記一次性 token 為已使用並綁定 deviceID。
|
||||
//
|
||||
// 一次性 + 冪等語意(DB 層):
|
||||
// - 以 `WHERE token_hash = $ AND used_at IS NULL` UPDATE:兩併發只一筆 RowsAffected = 1
|
||||
// (DB 行鎖保證),是「真正標記成功」的那一個。
|
||||
// - RowsAffected = 0 時需區分「已使用(冪等 no-op,回 nil、不覆寫 device_id)」與
|
||||
// 「不存在(ErrInvalidToken)」→ 再查一次存在性。
|
||||
//
|
||||
// deviceID 可為空字串(雛形 pairing 尚未綁 device 時),對齊 in-memory。
|
||||
func (s *PostgresPairingStore) MarkUsed(ctx context.Context, token, deviceID string) error {
|
||||
hash := HashToken(token)
|
||||
|
||||
// device_id 為 UUID 欄位且 nullable:空字串無法寫進 UUID 欄位,轉成 NULL。
|
||||
var deviceArg any
|
||||
if deviceID != "" {
|
||||
deviceArg = deviceID
|
||||
}
|
||||
|
||||
const q = `UPDATE pairing_tokens
|
||||
SET used_at = now(), device_id = $2
|
||||
WHERE token_hash = $1 AND used_at IS NULL`
|
||||
tag, err := s.pool.Exec(ctx, q, hash, deviceArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: pg pairing MarkUsed: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 1 {
|
||||
return nil // 本呼叫為實際標記成功者
|
||||
}
|
||||
|
||||
// RowsAffected == 0:可能已使用(冪等)或不存在。查存在性以區分。
|
||||
exists, err := s.tokenExists(ctx, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: pg pairing MarkUsed exists check: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return nil // 已使用 → 冪等 no-op
|
||||
}
|
||||
|
||||
// Revoke 撤銷一個 token(之後 Validate 回 ErrTokenRevoked)。
|
||||
//
|
||||
// 冪等:已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||||
func (s *PostgresPairingStore) Revoke(ctx context.Context, token string) error {
|
||||
hash := HashToken(token)
|
||||
|
||||
const q = `UPDATE pairing_tokens
|
||||
SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`
|
||||
tag, err := s.pool.Exec(ctx, q, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: pg pairing Revoke: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
exists, err := s.tokenExists(ctx, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: pg pairing Revoke exists check: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return nil // 已撤銷 → 冪等 no-op
|
||||
}
|
||||
|
||||
// List 回傳指定 user 的所有 pairing token(含已使用 / 撤銷),created_at DESC。
|
||||
//
|
||||
// 注意:回傳的 token Plaintext 為空(DB 不存明文);caller 不應依賴 Plaintext。
|
||||
func (s *PostgresPairingStore) List(ctx context.Context, userID string) ([]*PairingToken, error) {
|
||||
const q = `SELECT ` + pairingColumns + `
|
||||
FROM pairing_tokens WHERE user_id = $1
|
||||
ORDER BY created_at DESC`
|
||||
rows, err := s.pool.Query(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: pg pairing List query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*PairingToken, 0)
|
||||
for rows.Next() {
|
||||
info, scanErr := scanPairingToken(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("auth: pg pairing List scan: %w", scanErr)
|
||||
}
|
||||
out = append(out, info)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("auth: pg pairing List rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CleanupExpired 移除所有已過 expires_at 的 token;回傳移除數量。
|
||||
//
|
||||
// expires_at IS NULL(永不過期)不會被刪。
|
||||
func (s *PostgresPairingStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
|
||||
const q = `DELETE FROM pairing_tokens
|
||||
WHERE expires_at IS NOT NULL AND expires_at < $1`
|
||||
tag, err := s.pool.Exec(ctx, q, now.UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: pg pairing CleanupExpired: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 pairing token(cascade 撤銷,塊 5.2)。
|
||||
//
|
||||
// 在傳入的 Querier(pool 或 tx)上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1
|
||||
// AND revoked_at IS NULL`(database.md §6)。回傳實際撤銷的列數(觀測用,無撤銷對象回 0、不報錯)。
|
||||
//
|
||||
// 對齊 database.md §6:刪 device 時對 pairing_tokens + session_tokens 各跑一次此類 UPDATE,
|
||||
// 須在同一 tx 內。本方法只負責 pairing 表那一半;device 軟刪與 session 撤銷由 cascade 協調者
|
||||
// 在同一個 db.WithTx 內串起。
|
||||
//
|
||||
// 注意:pairing token 的 device_id 只有在 MarkUsed 綁定後才有值;未綁定的 token(device_id IS NULL)
|
||||
// 不屬於任何 device,自然不會被本查詢撤銷,符合語意。
|
||||
func (s *PostgresPairingStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
|
||||
if deviceID == "" {
|
||||
return 0, nil // 無 device 對象(對齊 in-memory:空 deviceID 不撤任何 token)
|
||||
}
|
||||
const sql = `UPDATE pairing_tokens
|
||||
SET revoked_at = now()
|
||||
WHERE device_id = $1 AND revoked_at IS NULL`
|
||||
tag, err := q.Exec(ctx, sql, deviceID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: pg pairing RevokeByDevice: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// tokenExists 查指定 hash 的 pairing token 是否存在(不論狀態)。
|
||||
func (s *PostgresPairingStore) tokenExists(ctx context.Context, hash string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pairing_tokens WHERE token_hash = $1)`, hash,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// scanPairingToken 從一列掃出 *PairingToken。欄位順序必須與 pairingColumns 對齊。
|
||||
//
|
||||
// device_id 為 nullable UUID(MarkUsed 前為 NULL)→ 以 *string 接、NULL 掃成空字串
|
||||
// (對齊 in-memory zero value)。時間欄位正規化為 UTC。Plaintext 留空(DB 不存)。
|
||||
func scanPairingToken(row rowScanner) (*PairingToken, error) {
|
||||
var (
|
||||
t PairingToken
|
||||
deviceID *string
|
||||
kind string
|
||||
)
|
||||
err := row.Scan(
|
||||
&t.TokenHash,
|
||||
&t.UserID,
|
||||
&deviceID,
|
||||
&kind,
|
||||
&t.CreatedAt,
|
||||
&t.ExpiresAt,
|
||||
&t.UsedAt,
|
||||
&t.RevokedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
t.Kind = TokenKind(kind)
|
||||
if deviceID != nil {
|
||||
t.DeviceID = *deviceID
|
||||
}
|
||||
|
||||
t.CreatedAt = t.CreatedAt.UTC()
|
||||
t.ExpiresAt = utcPtr(t.ExpiresAt)
|
||||
t.UsedAt = utcPtr(t.UsedAt)
|
||||
t.RevokedAt = utcPtr(t.RevokedAt)
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// shared scan helpers(pairing + session token 共用)
|
||||
// ==========================================================================
|
||||
|
||||
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scan helper 同時服務單列查詢與 List。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// utcPtr 將 nullable 時間指標正規化為 UTC(nil 維持 nil)。
|
||||
func utcPtr(p *time.Time) *time.Time {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
u := p.UTC()
|
||||
return &u
|
||||
}
|
||||
453
visionA-backend/internal/auth/postgres_pairing_store_db_test.go
Normal file
453
visionA-backend/internal/auth/postgres_pairing_store_db_test.go
Normal 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.go(CreateAndValidate、unknown token、
|
||||
// MarkUsed 一次性+冪等、Revoke、CleanupExpired、List by user、Validate expired)。
|
||||
// - 3.8 integration/真 DB:hash 當 PK 查詢正確性、TTL 過期、一次性 used 的 DB 層 race
|
||||
// (兩併發 MarkUsed 只一筆實際標記)、撤銷稽核欄位、兩表隔離(pairing 與 session 不互串)。
|
||||
// - 3.9 邊界:併發 Validate 同 token、CleanupExpired 大量資料、context cancel。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
// newPGPairingStore 啟動一次性測試 DB、truncate token/device/user 表、確保 demo user 存在,
|
||||
// 回傳 store + tdb + ownerID。
|
||||
func newPGPairingStore(t *testing.T) (*PostgresPairingStore, *testsupport.TestDB, string) {
|
||||
t.Helper()
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
|
||||
owner := tdb.EnsureDemoUser(t)
|
||||
return NewPostgresPairingStore(tdb.Pool), tdb, owner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.6 unit/邏輯(對齊 inmemory_pairing_store_test.go 的 case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPGPairing_CreateAndValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, plain)
|
||||
require.NotNil(t, info)
|
||||
|
||||
assert.True(t, IsValidPairingToken(plain))
|
||||
assert.Equal(t, owner, info.UserID)
|
||||
assert.Equal(t, KindPairing, info.Kind)
|
||||
assert.NotNil(t, info.ExpiresAt)
|
||||
assert.Nil(t, info.UsedAt)
|
||||
assert.Equal(t, HashToken(plain), info.TokenHash, "PK 應為 plaintext 的 hash")
|
||||
|
||||
got, err := s.Validate(ctx, plain)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, owner, got.UserID)
|
||||
assert.Equal(t, HashToken(plain), got.TokenHash)
|
||||
assert.Empty(t, got.Plaintext, "DB 不存明文 → 回傳的 Plaintext 應為空")
|
||||
}
|
||||
|
||||
func TestPGPairing_Validate_UnknownToken(t *testing.T) {
|
||||
s, _, _ := newPGPairingStore(t)
|
||||
_, err := s.Validate(context.Background(), "vAc_00000000000000000000000000000000")
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestPGPairing_MarkUsed_IsOneTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.MarkUsed(ctx, plain, ""))
|
||||
|
||||
// Validate 必須失敗(一次性 token 已消費)。
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenUsed)
|
||||
|
||||
// 再次 MarkUsed 應為冪等 no-op(回 nil)。
|
||||
assert.NoError(t, s.MarkUsed(ctx, plain, ""))
|
||||
}
|
||||
|
||||
func TestPGPairing_MarkUsed_UnknownToken(t *testing.T) {
|
||||
s, _, _ := newPGPairingStore(t)
|
||||
err := s.MarkUsed(context.Background(), "vAc_ffffffffffffffffffffffffffffffff", "")
|
||||
assert.ErrorIs(t, err, ErrInvalidToken, "對不存在 token MarkUsed 應回 ErrInvalidToken")
|
||||
}
|
||||
|
||||
// MarkUsed 綁定 device_id(FK → devices):驗證 device_id 正確寫入。
|
||||
func TestPGPairing_MarkUsed_BindsDevice(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner := newPGPairingStore(t)
|
||||
deviceID := tdb.InsertDevice(t, "", owner)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, plain, deviceID))
|
||||
|
||||
list, err := s.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, deviceID, list[0].DeviceID, "MarkUsed 應綁定 device_id")
|
||||
assert.NotNil(t, list[0].UsedAt)
|
||||
}
|
||||
|
||||
func TestPGPairing_Revoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||||
|
||||
// 撤銷不存在的 token → ErrInvalidToken
|
||||
assert.ErrorIs(t, s.Revoke(ctx, "vAc_abcdef00000000000000000000000000"), ErrInvalidToken)
|
||||
|
||||
// 冪等:再撤一次不報錯
|
||||
assert.NoError(t, s.Revoke(ctx, plain))
|
||||
}
|
||||
|
||||
func TestPGPairing_CleanupExpired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
expired, _, err := s.Create(ctx, owner, 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
fresh, _, err := s.Create(ctx, owner, 1*time.Hour)
|
||||
require.NoError(t, err)
|
||||
// 永不過期(ttl=0 → expires_at NULL)不應被清。
|
||||
never, _, err := s.Create(ctx, owner, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, removed)
|
||||
|
||||
_, err = s.Validate(ctx, expired)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken, "過期且已清掉 → 查不到")
|
||||
_, err = s.Validate(ctx, fresh)
|
||||
assert.NoError(t, err, "未過期不應被清")
|
||||
_, err = s.Validate(ctx, never)
|
||||
assert.NoError(t, err, "永不過期(NULL expires_at)不應被清")
|
||||
}
|
||||
|
||||
func TestPGPairing_List_ByUser(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, ownerA := newPGPairingStore(t)
|
||||
ownerB := tdb.InsertUser(t, "", "")
|
||||
|
||||
_, _, err := s.Create(ctx, ownerA, time.Hour)
|
||||
require.NoError(t, err)
|
||||
_, _, err = s.Create(ctx, ownerA, time.Hour)
|
||||
require.NoError(t, err)
|
||||
_, _, err = s.Create(ctx, ownerB, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
listA, err := s.List(ctx, ownerA)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listA, 2)
|
||||
|
||||
listB, err := s.List(ctx, ownerB)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listB, 1)
|
||||
|
||||
listNone, err := s.List(ctx, tdb.InsertUser(t, "", ""))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, listNone)
|
||||
assert.NotNil(t, listNone, "List 應回 non-nil 空 slice")
|
||||
}
|
||||
|
||||
func TestPGPairing_Validate_Expired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenExpired)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.8 integration/真 DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// hash 當 PK:DB 內實際存的是 token_hash,明文不出現在表中。
|
||||
func TestPGPairing_HashIsPK_NoPlaintextStored(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner := newPGPairingStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// DB 內以 token_hash 為 PK 存在、明文不存在。
|
||||
var stored string
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT token_hash FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&stored)
|
||||
require.NoError(t, err, "應能用 hash 查到")
|
||||
assert.Equal(t, HashToken(plain), stored)
|
||||
|
||||
// 明文不該等於任何 PK(plaintext != hash)。
|
||||
var cnt int
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM pairing_tokens WHERE token_hash = $1`, plain).Scan(&cnt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, cnt, "明文不應出現為 PK(DB 永不存明文)")
|
||||
}
|
||||
|
||||
// 狀態優先序:revoked 優先於 used 與 expired(對齊 in-memory)。
|
||||
func TestPGPairing_Validate_RevokedBeforeUsed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, plain, ""))
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked, "revoked 應優先於 used")
|
||||
}
|
||||
|
||||
// 一次性 used 的 DB 層 race:N 併發 MarkUsed 同 token,只一筆實際標記(DB 行鎖保證)。
|
||||
// 所有呼叫都回 nil(冪等);最終 used_at 恰寫一次。
|
||||
func TestPGPairing_ConcurrentMarkUsed_OnlyOneWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner := newPGPairingStore(t)
|
||||
deviceID := tdb.InsertDevice(t, "", owner)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = s.MarkUsed(ctx, plain, deviceID)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
assert.NoError(t, e, "併發 MarkUsed #%d 應回 nil(冪等)", i)
|
||||
}
|
||||
|
||||
// DB 層:used_at 恰寫一次(單一列、used_at 非 NULL)。
|
||||
var usedCount int
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM pairing_tokens WHERE token_hash = $1 AND used_at IS NOT NULL`,
|
||||
info.TokenHash).Scan(&usedCount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, usedCount, "一次性語意:恰一列被標記 used")
|
||||
|
||||
// Validate 之後必失敗。
|
||||
_, err = s.Validate(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenUsed)
|
||||
}
|
||||
|
||||
// 撤銷稽核欄位:Revoke 後 revoked_at 非 NULL 且固定(冪等不覆寫時間)。
|
||||
func TestPGPairing_Revoke_AuditField(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner := newPGPairingStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
|
||||
var revokedAt1 time.Time
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, revokedAt1.IsZero())
|
||||
|
||||
// 冪等再撤一次:revoked_at 不應被覆寫(WHERE revoked_at IS NULL 不命中)。
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
var revokedAt2 time.Time
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, revokedAt1.Equal(revokedAt2), "冪等 Revoke 不應覆寫 revoked_at")
|
||||
}
|
||||
|
||||
// 兩表隔離:pairing token 與 session token 各自獨立、互不串。
|
||||
func TestPGPairing_TwoTableIsolation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ps, tdb, owner := newPGPairingStore(t)
|
||||
ss := NewPostgresSessionTokenStore(tdb.Pool)
|
||||
deviceID := tdb.InsertDevice(t, "", owner)
|
||||
|
||||
// 同一個 plaintext 不可能跨兩表(token 各自生成),這裡驗證:
|
||||
// pairing 表的查詢看不到 session 表的列,反之亦然。
|
||||
pPlain, _, err := ps.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
sPlain, _, err := ss.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
// pairing.Validate 對 session token 查不到(不同表 + 不同 hash)。
|
||||
_, err = ps.Validate(ctx, sPlain)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
|
||||
// session.Get 對 pairing token 查不到。
|
||||
_, err = ss.Get(ctx, pPlain)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "pairing_tokens"))
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "session_tokens"))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.9 邊界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 併發 Validate 同 token:純讀、不應 panic / race,全部成功。
|
||||
func TestPGPairing_ConcurrentValidate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = s.Validate(ctx, plain)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
assert.NoError(t, e, "併發 Validate #%d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpired 大量資料:批次刪除正確(過期全清、未過期保留)。
|
||||
func TestPGPairing_CleanupExpired_Bulk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
const expiredN = 50
|
||||
for i := 0; i < expiredN; i++ {
|
||||
_, _, err := s.Create(ctx, owner, 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
const freshN = 10
|
||||
for i := 0; i < freshN; i++ {
|
||||
_, _, err := s.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expiredN, removed, "應清掉所有過期列")
|
||||
|
||||
list, err := s.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, freshN, "未過期列應保留")
|
||||
}
|
||||
|
||||
// context cancel:已取消 ctx 的操作應回 error(不 hang、不 panic)。
|
||||
func TestPGPairing_ContextCancel(t *testing.T) {
|
||||
s, _, owner := newPGPairingStore(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _, err := s.Create(ctx, owner, time.Hour)
|
||||
assert.Error(t, err, "已取消 ctx 的 Create 應回 error")
|
||||
|
||||
_, err = s.Validate(ctx, "vAc_00000000000000000000000000000000")
|
||||
assert.Error(t, err, "已取消 ctx 的 Validate 應回 error")
|
||||
|
||||
err = s.MarkUsed(ctx, "vAc_00000000000000000000000000000000", "")
|
||||
assert.Error(t, err, "已取消 ctx 的 MarkUsed 應回 error")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 塊 5.2 cascade:RevokeByDeviceTx(pairing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPGPairing_RevokeByDeviceTx 驗證撤銷某 device 名下未撤銷 pairing token,
|
||||
// 不波及他 device、已撤銷不重複計、device_id NULL(未綁定)不被撤。
|
||||
func TestPGPairing_RevokeByDeviceTx(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner := newPGPairingStore(t)
|
||||
|
||||
dev1 := tdb.InsertDevice(t, "", owner)
|
||||
dev2 := tdb.InsertDevice(t, "", owner)
|
||||
|
||||
// dev1 兩個 token:p1 未撤、p2 已撤
|
||||
p1, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p1, dev1))
|
||||
p2, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p2, dev1))
|
||||
require.NoError(t, s.Revoke(ctx, p2))
|
||||
|
||||
// dev2 一個 token(不應被撤)
|
||||
p3, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.MarkUsed(ctx, p3, dev2))
|
||||
|
||||
// 一個未綁 device 的 token(device_id IS NULL,不應被撤)
|
||||
p4, _, err := s.Create(ctx, owner, 15*time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
revoked, err := s.RevokeByDeviceTx(ctx, tdb.Pool, dev1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, revoked, "只有 p1 被撤;p2 已撤不計")
|
||||
|
||||
_, err = s.Validate(ctx, p1)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||||
// p3(dev2)仍可用(used 但未撤)
|
||||
_, err = s.Validate(ctx, p3)
|
||||
assert.ErrorIs(t, err, ErrTokenUsed)
|
||||
// p4(未綁 device)仍有效
|
||||
_, err = s.Validate(ctx, p4)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 空 deviceID 不撤
|
||||
n, err := s.RevokeByDeviceTx(ctx, tdb.Pool, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
219
visionA-backend/internal/auth/postgres_session_token_store.go
Normal file
219
visionA-backend/internal/auth/postgres_session_token_store.go
Normal 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.4(SessionToken struct)、§4(session_tokens 表 schema)
|
||||
// - migrations/0003_create_token_tables.up.sql(session_tokens 表)
|
||||
//
|
||||
// ── 關鍵改動:plaintext → token_hash 當 PK(同 PostgresPairingStore)──
|
||||
//
|
||||
// Get / Revoke 接收 plaintext,內部先 HashToken(plaintext) 再以 hash 查詢。
|
||||
// 呼叫端統一傳 plaintext(已 grep 確認:pairing.go 的 SessionTokenStore.Create 用回傳的
|
||||
// plaintext、Revoke(ctx, plaintext) 傳 plaintext;目前無其他 production Get 呼叫端)。
|
||||
// DB 永不存明文 token(security.md §1.3)。
|
||||
//
|
||||
// 語意對齊 in-memory(見 session_token.go):
|
||||
// - SessionToken 無 used_at(非一次性)、無 kind。
|
||||
// - Get 狀態優先序:revoked → expired(與 in-memory 一致);不存在回 ErrInvalidToken。
|
||||
// - Revoke 冪等:未撤銷 → 寫 revoked_at;已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||||
// - CleanupExpired:DELETE 所有 expires_at < now 的列,回刪除數。
|
||||
// - parent_token_hash 為稽核鏈欄位(升級來源 pairing token 的 hash),原樣存取。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// PostgresSessionTokenStore 是 session token 的 PostgreSQL 持久層實作。
|
||||
type PostgresSessionTokenStore struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresSessionTokenStore 建立一個以 pgxpool 為後端的 SessionTokenStore。
|
||||
func NewPostgresSessionTokenStore(pool *pgxpool.Pool) *PostgresSessionTokenStore {
|
||||
return &PostgresSessionTokenStore{pool: pool}
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 PostgresSessionTokenStore 實作 SessionTokenStore。
|
||||
var _ SessionTokenStore = (*PostgresSessionTokenStore)(nil)
|
||||
|
||||
// sessionColumns 是 SELECT 共用欄位清單(順序必須與 scanSessionToken 對齊)。
|
||||
const sessionColumns = `token_hash, user_id, device_id, parent_token_hash,
|
||||
created_at, expires_at, revoked_at`
|
||||
|
||||
// Create 產生並保存一個新 session token。
|
||||
//
|
||||
// ttl <= 0 時 ExpiresAt 保持 NULL(永不過期)。parentTokenHash 可為空(雛形 caller)。
|
||||
// 回傳的 info.Plaintext 保留原文供 caller 一次性使用(DB 不存)。
|
||||
func (s *PostgresSessionTokenStore) Create(
|
||||
ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration,
|
||||
) (string, *SessionToken, error) {
|
||||
plaintext, err := GenerateSessionToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
info := &SessionToken{
|
||||
Plaintext: plaintext,
|
||||
TokenHash: HashToken(plaintext),
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
ParentTokenHash: parentTokenHash,
|
||||
CreatedAt: now,
|
||||
}
|
||||
var expiresAt any // nil → DB NULL
|
||||
if ttl > 0 {
|
||||
expires := now.Add(ttl)
|
||||
info.ExpiresAt = &expires
|
||||
expiresAt = expires
|
||||
}
|
||||
|
||||
// device_id NOT NULL:空字串無法寫進 UUID 欄位,會在此回 DB error(符合 schema 約束)。
|
||||
// parent_token_hash nullable:空字串轉 NULL。
|
||||
var parentArg any
|
||||
if parentTokenHash != "" {
|
||||
parentArg = parentTokenHash
|
||||
}
|
||||
|
||||
const q = `INSERT INTO session_tokens
|
||||
(token_hash, user_id, device_id, parent_token_hash, created_at, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`
|
||||
if _, err := s.pool.Exec(ctx, q,
|
||||
info.TokenHash, info.UserID, info.DeviceID, parentArg, info.CreatedAt, expiresAt,
|
||||
); err != nil {
|
||||
return "", nil, fmt.Errorf("auth: pg session Create: %w", err)
|
||||
}
|
||||
return plaintext, info, nil
|
||||
}
|
||||
|
||||
// Get 依 plaintext 查詢 session token;內部 HashToken 後查。
|
||||
//
|
||||
// 狀態優先序對齊 in-memory:revoked → expired。不存在回 ErrInvalidToken。
|
||||
func (s *PostgresSessionTokenStore) Get(ctx context.Context, plaintext string) (*SessionToken, error) {
|
||||
hash := HashToken(plaintext)
|
||||
|
||||
const q = `SELECT ` + sessionColumns + `
|
||||
FROM session_tokens WHERE token_hash = $1`
|
||||
row := s.pool.QueryRow(ctx, q, hash)
|
||||
info, err := scanSessionToken(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInvalidToken
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: pg session Get: %w", err)
|
||||
}
|
||||
|
||||
if info.RevokedAt != nil {
|
||||
return nil, ErrTokenRevoked
|
||||
}
|
||||
if info.ExpiresAt != nil && time.Now().UTC().After(*info.ExpiresAt) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Revoke 撤銷 session token(之後 Get 回 ErrTokenRevoked)。
|
||||
//
|
||||
// 冪等:已撤銷 → no-op nil;不存在 → ErrInvalidToken。
|
||||
func (s *PostgresSessionTokenStore) Revoke(ctx context.Context, plaintext string) error {
|
||||
hash := HashToken(plaintext)
|
||||
|
||||
const q = `UPDATE session_tokens
|
||||
SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`
|
||||
tag, err := s.pool.Exec(ctx, q, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth: pg session Revoke: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM session_tokens WHERE token_hash = $1)`, hash,
|
||||
).Scan(&exists); err != nil {
|
||||
return fmt.Errorf("auth: pg session Revoke exists check: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return ErrInvalidToken
|
||||
}
|
||||
return nil // 已撤銷 → 冪等 no-op
|
||||
}
|
||||
|
||||
// CleanupExpired 移除所有已過 expires_at 的 token;回傳移除數量。
|
||||
//
|
||||
// expires_at IS NULL(永不過期)不會被刪。
|
||||
func (s *PostgresSessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
|
||||
const q = `DELETE FROM session_tokens
|
||||
WHERE expires_at IS NOT NULL AND expires_at < $1`
|
||||
tag, err := s.pool.Exec(ctx, q, now.UTC())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: pg session CleanupExpired: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// RevokeByDeviceTx 撤銷某 device 名下所有「尚未撤銷」的 session token(cascade 撤銷,塊 5.2)。
|
||||
//
|
||||
// 在傳入的 Querier(pool 或 tx)上跑 `UPDATE ... SET revoked_at = now() WHERE device_id = $1
|
||||
// AND revoked_at IS NULL`(database.md §6)。回傳實際撤銷的列數(觀測用,無對象回 0、不報錯)。
|
||||
//
|
||||
// session_tokens.device_id 為 NOT NULL(每個 session token 必綁 device),故同一 device 的所有
|
||||
// 未撤銷 session token 都會被撤——這正是「刪 device → 該 device 不能再被任何長效 token 觸達」的目的。
|
||||
func (s *PostgresSessionTokenStore) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) {
|
||||
if deviceID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
const sql = `UPDATE session_tokens
|
||||
SET revoked_at = now()
|
||||
WHERE device_id = $1 AND revoked_at IS NULL`
|
||||
tag, err := q.Exec(ctx, sql, deviceID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("auth: pg session RevokeByDevice: %w", err)
|
||||
}
|
||||
return int(tag.RowsAffected()), nil
|
||||
}
|
||||
|
||||
// scanSessionToken 從一列掃出 *SessionToken。欄位順序必須與 sessionColumns 對齊。
|
||||
//
|
||||
// parent_token_hash nullable → 以 *string 接、NULL 掃成空字串(對齊 in-memory zero value)。
|
||||
// 時間欄位正規化為 UTC。Plaintext 留空(DB 不存)。
|
||||
func scanSessionToken(row rowScanner) (*SessionToken, error) {
|
||||
var (
|
||||
t SessionToken
|
||||
parentHash *string
|
||||
)
|
||||
err := row.Scan(
|
||||
&t.TokenHash,
|
||||
&t.UserID,
|
||||
&t.DeviceID,
|
||||
&parentHash,
|
||||
&t.CreatedAt,
|
||||
&t.ExpiresAt,
|
||||
&t.RevokedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if parentHash != nil {
|
||||
t.ParentTokenHash = *parentHash
|
||||
}
|
||||
|
||||
t.CreatedAt = t.CreatedAt.UTC()
|
||||
t.ExpiresAt = utcPtr(t.ExpiresAt)
|
||||
t.RevokedAt = utcPtr(t.RevokedAt)
|
||||
return &t, nil
|
||||
}
|
||||
@ -0,0 +1,353 @@
|
||||
//go:build dbtest
|
||||
|
||||
// PostgresSessionTokenStore 的真 DB 整合測試(DB 接入塊 3,子任務 3.7 / 3.8 / 3.9 session 部分)。
|
||||
//
|
||||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||||
//
|
||||
// 執行:
|
||||
//
|
||||
// go test -tags=dbtest ./internal/auth/...
|
||||
//
|
||||
// 涵蓋:
|
||||
// - 3.7 unit/邏輯:對齊既有 session_token_test.go(CreateAndGet、NotFound、expired、
|
||||
// Revoke 冪等、Revoke NotFound、CleanupExpired、NeverExpires ttl=0)。
|
||||
// - 3.8 integration/真 DB:hash 當 PK、parent_token_hash 稽核鏈 round-trip、撤銷稽核欄位。
|
||||
// - 3.9 邊界:併發 Get、CleanupExpired 大量、context cancel、device_id NOT NULL 約束。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
// newPGSessionStore 啟動一次性測試 DB、truncate、確保 demo user + 一個 device 存在
|
||||
// (session_tokens.device_id NOT NULL FK),回傳 store + tdb + ownerID + deviceID。
|
||||
func newPGSessionStore(t *testing.T) (*PostgresSessionTokenStore, *testsupport.TestDB, string, string) {
|
||||
t.Helper()
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users")
|
||||
owner := tdb.EnsureDemoUser(t)
|
||||
deviceID := tdb.InsertDevice(t, "", owner)
|
||||
return NewPostgresSessionTokenStore(tdb.Pool), tdb, owner, deviceID
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.7 unit/邏輯(對齊 session_token_test.go 的 case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPGSession_CreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, deviceID, "parent-hash", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, IsValidSessionToken(plain), "產出 token 應通過格式驗證:%s", plain)
|
||||
require.NotNil(t, info)
|
||||
assert.Equal(t, owner, info.UserID)
|
||||
assert.Equal(t, deviceID, info.DeviceID)
|
||||
assert.Equal(t, "parent-hash", info.ParentTokenHash)
|
||||
require.NotNil(t, info.ExpiresAt)
|
||||
assert.WithinDuration(t, time.Now().UTC().Add(SessionTokenTTL), *info.ExpiresAt, 2*time.Second)
|
||||
assert.Equal(t, HashToken(plain), info.TokenHash, "PK 應為 plaintext 的 hash")
|
||||
|
||||
got, err := s.Get(ctx, plain)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, owner, got.UserID)
|
||||
assert.Equal(t, info.TokenHash, got.TokenHash)
|
||||
assert.Empty(t, got.Plaintext, "DB 不存明文 → 回傳 Plaintext 應為空")
|
||||
}
|
||||
|
||||
func TestPGSession_Get_NotFound(t *testing.T) {
|
||||
s, _, _, _ := newPGSessionStore(t)
|
||||
_, err := s.Get(context.Background(), "vAs_"+repeat64)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestPGSession_Get_Expired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
_, err = s.Get(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenExpired)
|
||||
}
|
||||
|
||||
func TestPGSession_Revoke(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, deviceID, "", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
|
||||
_, err = s.Get(ctx, plain)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||||
|
||||
// 冪等:再撤一次不報錯
|
||||
assert.NoError(t, s.Revoke(ctx, plain))
|
||||
}
|
||||
|
||||
func TestPGSession_Revoke_NotFound(t *testing.T) {
|
||||
s, _, _, _ := newPGSessionStore(t)
|
||||
err := s.Revoke(context.Background(), "vAs_"+repeat64)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
}
|
||||
|
||||
func TestPGSession_CleanupExpired(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
expiredTok, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
freshTok, _, err := s.Create(ctx, owner, deviceID, "", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
// ttl=0 → expires_at NULL,不應被清。
|
||||
neverTok, _, err := s.Create(ctx, owner, deviceID, "", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, removed)
|
||||
|
||||
_, err = s.Get(ctx, expiredTok)
|
||||
assert.ErrorIs(t, err, ErrInvalidToken)
|
||||
_, err = s.Get(ctx, freshTok)
|
||||
assert.NoError(t, err)
|
||||
_, err = s.Get(ctx, neverTok)
|
||||
assert.NoError(t, err, "永不過期(NULL expires_at)不應被清")
|
||||
}
|
||||
|
||||
func TestPGSession_NeverExpires(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
_, info, err := s.Create(ctx, owner, deviceID, "", 0)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, info.ExpiresAt, "ttl=0 時 ExpiresAt 應為 nil")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.8 integration/真 DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// hash 當 PK:DB 內實際存的是 token_hash,明文不存在。
|
||||
func TestPGSession_HashIsPK_NoPlaintextStored(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
var stored string
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT token_hash FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&stored)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, HashToken(plain), stored)
|
||||
|
||||
var cnt int
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM session_tokens WHERE token_hash = $1`, plain).Scan(&cnt)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, cnt, "明文不應出現為 PK")
|
||||
}
|
||||
|
||||
// parent_token_hash 稽核鏈:升級來源 pairing token 的 hash 正確 round-trip,
|
||||
// 且可 join 回 pairing_tokens.token_hash(跨表稽核鏈)。
|
||||
func TestPGSession_ParentTokenHash_AuditChain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner, deviceID := newPGSessionStore(t)
|
||||
ps := NewPostgresPairingStore(tdb.Pool)
|
||||
|
||||
// 先建一個 pairing token,取其 hash 當 session 的 parent。
|
||||
_, pInfo, err := ps.Create(ctx, owner, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, sInfo, err := s.Create(ctx, owner, deviceID, pInfo.TokenHash, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pInfo.TokenHash, sInfo.ParentTokenHash)
|
||||
|
||||
// round-trip:Get 不回 parent(Get 走狀態檢查),改用直接 join 驗證稽核鏈可查。
|
||||
var joinedUser string
|
||||
err = tdb.Pool.QueryRow(ctx, `
|
||||
SELECT p.user_id
|
||||
FROM session_tokens s
|
||||
JOIN pairing_tokens p ON p.token_hash = s.parent_token_hash
|
||||
WHERE s.token_hash = $1`, sInfo.TokenHash).Scan(&joinedUser)
|
||||
require.NoError(t, err, "應能透過 parent_token_hash join 回 pairing_tokens")
|
||||
assert.Equal(t, owner, joinedUser)
|
||||
}
|
||||
|
||||
// parent_token_hash 為空 → 存 NULL,round-trip 為空字串。
|
||||
func TestPGSession_EmptyParent_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
_, info, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parent *string
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT parent_token_hash FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&parent)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, parent, "空 parent 應存 NULL")
|
||||
}
|
||||
|
||||
// device_id NOT NULL 約束:空 device_id 應被 DB 拒絕(FK + NOT NULL)。
|
||||
func TestPGSession_RequiresDeviceID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, _ := newPGSessionStore(t)
|
||||
|
||||
_, _, err := s.Create(ctx, owner, "", "", time.Hour)
|
||||
assert.Error(t, err, "session token device_id NOT NULL,空字串應被拒")
|
||||
}
|
||||
|
||||
// 撤銷稽核欄位:Revoke 後 revoked_at 非 NULL,冪等不覆寫。
|
||||
func TestPGSession_Revoke_AuditField(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, info, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
|
||||
var revokedAt1 time.Time
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt1)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, revokedAt1.IsZero())
|
||||
|
||||
require.NoError(t, s.Revoke(ctx, plain))
|
||||
var revokedAt2 time.Time
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`SELECT revoked_at FROM session_tokens WHERE token_hash = $1`, info.TokenHash).Scan(&revokedAt2)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, revokedAt1.Equal(revokedAt2), "冪等 Revoke 不應覆寫 revoked_at")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3.9 邊界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 併發 Get 同 token:純讀、不應 panic / race,全部成功。
|
||||
func TestPGSession_ConcurrentGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
plain, _, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
const n = 30
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_, errs[i] = s.Get(ctx, plain)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
assert.NoError(t, e, "併發 Get #%d", i)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpired 大量資料:批次刪除正確。
|
||||
func TestPGSession_CleanupExpired_Bulk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
const expiredN = 50
|
||||
for i := 0; i < expiredN; i++ {
|
||||
_, _, err := s.Create(ctx, owner, deviceID, "", 1*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
const freshN = 10
|
||||
for i := 0; i < freshN; i++ {
|
||||
_, _, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
removed, err := s.CleanupExpired(ctx, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expiredN, removed)
|
||||
assert.Equal(t, freshN, tdbCountActive(ctx, t, s, owner, deviceID))
|
||||
}
|
||||
|
||||
// context cancel:已取消 ctx 操作回 error。
|
||||
func TestPGSession_ContextCancel(t *testing.T) {
|
||||
s, _, owner, deviceID := newPGSessionStore(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _, err := s.Create(ctx, owner, deviceID, "", time.Hour)
|
||||
assert.Error(t, err, "已取消 ctx 的 Create 應回 error")
|
||||
|
||||
_, err = s.Get(ctx, "vAs_"+repeat64)
|
||||
assert.Error(t, err, "已取消 ctx 的 Get 應回 error")
|
||||
|
||||
err = s.Revoke(ctx, "vAs_"+repeat64)
|
||||
assert.Error(t, err, "已取消 ctx 的 Revoke 應回 error")
|
||||
}
|
||||
|
||||
// tdbCountActive 計算某 owner 仍未過期(CleanupExpired 後殘留)的 session token 數。
|
||||
// 用 CleanupExpired 後再跑一次 0-removed 的方式間接驗證殘留數。
|
||||
func tdbCountActive(ctx context.Context, t *testing.T, s *PostgresSessionTokenStore, owner, deviceID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := s.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM session_tokens WHERE user_id = $1`, owner).Scan(&n)
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
// repeat64 是 64 個 'a',組出一個格式合法但不存在的 session token(vAs_ + 64 hex)。
|
||||
const repeat64 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 塊 5.2 cascade:RevokeByDeviceTx(session)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestPGSession_RevokeByDeviceTx 驗證撤銷某 device 名下未撤銷 session token,
|
||||
// 不波及他 device、已撤銷不重複計。
|
||||
func TestPGSession_RevokeByDeviceTx(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, tdb, owner, dev1 := newPGSessionStore(t)
|
||||
dev2 := tdb.InsertDevice(t, "", owner)
|
||||
|
||||
s1, _, err := s.Create(ctx, owner, dev1, "", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
s2, _, err := s.Create(ctx, owner, dev1, "", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, s.Revoke(ctx, s2)) // 已撤
|
||||
|
||||
o1, _, err := s.Create(ctx, owner, dev2, "", SessionTokenTTL)
|
||||
require.NoError(t, err)
|
||||
|
||||
revoked, err := s.RevokeByDeviceTx(ctx, tdb.Pool, dev1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, revoked)
|
||||
|
||||
_, err = s.Get(ctx, s1)
|
||||
assert.ErrorIs(t, err, ErrTokenRevoked)
|
||||
_, err = s.Get(ctx, o1)
|
||||
require.NoError(t, err)
|
||||
|
||||
n, err := s.RevokeByDeviceTx(ctx, tdb.Pool, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, n)
|
||||
}
|
||||
@ -141,6 +141,28 @@ func (s *InMemorySessionTokenStore) Revoke(ctx context.Context, plaintext string
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeByDevice 撤銷某 device 名下所有「尚未撤銷」的 session token(cascade 撤銷,塊 5.2)。
|
||||
//
|
||||
// in-memory 對齊 Postgres RevokeByDeviceTx 語意:撤所有 DeviceID == deviceID 且未撤銷的 token,
|
||||
// 回傳實際撤銷數。空 deviceID 不撤任何 token。
|
||||
func (s *InMemorySessionTokenStore) RevokeByDevice(ctx context.Context, deviceID string) (int, error) {
|
||||
if deviceID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now().UTC()
|
||||
revoked := 0
|
||||
for _, info := range s.tokens {
|
||||
if info.DeviceID == deviceID && info.RevokedAt == nil {
|
||||
info.RevokedAt = &now
|
||||
revoked++
|
||||
}
|
||||
}
|
||||
return revoked, nil
|
||||
}
|
||||
|
||||
// CleanupExpired 移除所有已過期(ExpiresAt < now)的 token。
|
||||
func (s *InMemorySessionTokenStore) CleanupExpired(ctx context.Context, now time.Time) (int, error) {
|
||||
s.mu.Lock()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -26,6 +26,12 @@ type Config struct {
|
||||
Conversion ConversionConfig
|
||||
// FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈(ADR-017 (a))。
|
||||
FileAccess FileAccessConfig
|
||||
// Database 控制 PostgreSQL 連線(DB 接入塊 0;持久業務資料 model / device / token)。
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
|
||||
Database DatabaseConfig
|
||||
// Redis 控制 Redis 連線(DB 接入塊 4;僅 userSession browser cookie session)。
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。塊 0 不 wire,只先把 config 鉤子留好。
|
||||
Redis RedisConfig
|
||||
}
|
||||
|
||||
// ServerConfig 控制 HTTP listener 的位址與埠號。
|
||||
@ -298,6 +304,69 @@ func (c ConversionConfig) Enabled() bool {
|
||||
return c.ConverterBaseURL != "" && c.ConverterAPIKey != ""
|
||||
}
|
||||
|
||||
// DatabaseConfig 控制 PostgreSQL 連線(持久業務資料:model / device / token)。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
|
||||
//
|
||||
// 啟用判定(Enabled()):Host / User / DBName 三者全非空才視為啟用;
|
||||
// 任一缺 → main.go 不建連線池、6 個 repository 仍用 in-memory(local dev fallback)。
|
||||
//
|
||||
// 安全:Password / DSN 永遠不印 log 全文(可印 host:port/dbname);
|
||||
// 部署走既有 secrets 機制(AWS Secrets Manager / Vault),禁止 commit 進 repo。
|
||||
type DatabaseConfig struct {
|
||||
Host string // VISIONA_DB_HOST
|
||||
Port int // VISIONA_DB_PORT,預設 5432
|
||||
User string // VISIONA_DB_USER
|
||||
Password string // VISIONA_DB_PASSWORD(禁止 commit)
|
||||
DBName string // VISIONA_DB_NAME
|
||||
SSLMode string // VISIONA_DB_SSLMODE,預設 "require"(stage/prod);本機 testcontainers 用 "disable"
|
||||
|
||||
// 連線池(pgxpool)
|
||||
MaxConns int // VISIONA_DB_MAX_CONNS,預設 10
|
||||
MinConns int // VISIONA_DB_MIN_CONNS,預設 2
|
||||
MaxConnLifetime time.Duration // VISIONA_DB_MAX_CONN_LIFETIME,預設 1h
|
||||
ConnTimeout time.Duration // VISIONA_DB_CONN_TIMEOUT,預設 5s(建池/ping 逾時)
|
||||
|
||||
// AutoMigrate 控制連線池建立後是否自動跑 migrate up。
|
||||
// 預設 true;可由 VISIONA_DB_AUTO_MIGRATE=false 關閉(例如改用獨立 cmd/migrate)。
|
||||
AutoMigrate bool // VISIONA_DB_AUTO_MIGRATE,預設 true
|
||||
}
|
||||
|
||||
// Enabled 回傳 PostgreSQL 是否啟用。
|
||||
//
|
||||
// Host / User / DBName 三者全非空才視為啟用;任一缺 →
|
||||
// main.go 不建連線池,model / device / token repository 維持 in-memory(local dev fallback)。
|
||||
func (c DatabaseConfig) Enabled() bool {
|
||||
return c.Host != "" && c.User != "" && c.DBName != ""
|
||||
}
|
||||
|
||||
// RedisConfig 控制 Redis 連線(僅 userSession:browser cookie session)。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
|
||||
//
|
||||
// ⚠️ visionA 專用 Redis 實例:由使用者自行在 stage host(130) 另起、設密碼。
|
||||
// visionA 端不 provision、只接上。
|
||||
//
|
||||
// 啟用判定(Enabled()):Host 非空才視為啟用;
|
||||
// 未啟用 → userSession 仍用 in-memory(雛形行為,process 重啟掉 session)。
|
||||
//
|
||||
// 安全:Password 永遠不印 log 全文。禁止 commit 進 repo。
|
||||
//
|
||||
// 塊 0 範圍:只先把 config 鉤子留好,main.go 尚未 wire(等塊 4 接 RedisUserSessionStore)。
|
||||
type RedisConfig struct {
|
||||
Host string // VISIONA_REDIS_HOST
|
||||
Port int // VISIONA_REDIS_PORT,預設 6379
|
||||
Password string // VISIONA_REDIS_PASSWORD(visionA 專用實例必設密碼;禁止 commit)
|
||||
DB int // VISIONA_REDIS_DB,預設 0(db index)
|
||||
|
||||
ConnTimeout time.Duration // VISIONA_REDIS_CONN_TIMEOUT,預設 5s
|
||||
}
|
||||
|
||||
// Enabled 回傳 Redis 是否啟用(Host 非空即啟用)。
|
||||
func (c RedisConfig) Enabled() bool {
|
||||
return c.Host != ""
|
||||
}
|
||||
|
||||
// CORSConfig 控制 api-server 對瀏覽器的 CORS 白名單。
|
||||
//
|
||||
// AllowedOrigins 為逗號分隔字串解析後的 slice;
|
||||
|
||||
@ -92,6 +92,31 @@ func Load() *Config {
|
||||
FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""),
|
||||
DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120),
|
||||
},
|
||||
// DB 接入塊 0:PostgreSQL 連線(持久業務資料)。
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.1。
|
||||
// 留空(Host/User/DBName 任一缺)→ Enabled()=false → main.go 不建池、維持 in-memory。
|
||||
Database: DatabaseConfig{
|
||||
Host: getEnvString("VISIONA_DB_HOST", ""),
|
||||
Port: getEnvInt("VISIONA_DB_PORT", 5432),
|
||||
User: getEnvString("VISIONA_DB_USER", ""),
|
||||
Password: getEnvString("VISIONA_DB_PASSWORD", ""),
|
||||
DBName: getEnvString("VISIONA_DB_NAME", ""),
|
||||
SSLMode: getEnvString("VISIONA_DB_SSLMODE", "require"),
|
||||
MaxConns: getEnvInt("VISIONA_DB_MAX_CONNS", 10),
|
||||
MinConns: getEnvInt("VISIONA_DB_MIN_CONNS", 2),
|
||||
MaxConnLifetime: getEnvDuration("VISIONA_DB_MAX_CONN_LIFETIME", time.Hour),
|
||||
ConnTimeout: getEnvDuration("VISIONA_DB_CONN_TIMEOUT", 5*time.Second),
|
||||
AutoMigrate: getEnvBool("VISIONA_DB_AUTO_MIGRATE", true),
|
||||
},
|
||||
// DB 接入塊 4(鉤子先留,塊 0 不 wire):Redis 連線(僅 userSession)。
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
|
||||
Redis: RedisConfig{
|
||||
Host: getEnvString("VISIONA_REDIS_HOST", ""),
|
||||
Port: getEnvInt("VISIONA_REDIS_PORT", 6379),
|
||||
Password: getEnvString("VISIONA_REDIS_PASSWORD", ""),
|
||||
DB: getEnvInt("VISIONA_REDIS_DB", 0),
|
||||
ConnTimeout: getEnvDuration("VISIONA_REDIS_CONN_TIMEOUT", 5*time.Second),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
147
visionA-backend/internal/db/db_integration_test.go
Normal file
147
visionA-backend/internal/db/db_integration_test.go
Normal file
@ -0,0 +1,147 @@
|
||||
//go:build dbtest
|
||||
|
||||
// DB 接入塊 0(0.8):連線池 / migration 本身的整合測試,走 testcontainers(真 Postgres 14.23)。
|
||||
//
|
||||
// build tag `dbtest`:需要 Docker daemon。預設 `go test ./...` 不編譯本檔。
|
||||
// 執行:go test -tags=dbtest ./internal/db/...
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/config"
|
||||
"visiona-backend/internal/db"
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
func discardLog() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
// TestPool_PingAfterSetup 驗證 setupTestDB 後連線池可 ping 通。
|
||||
func TestPool_PingAfterSetup(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
require.NoError(t, tdb.Pool.Ping(context.Background()))
|
||||
}
|
||||
|
||||
// TestMigrate_Idempotent 驗證 migrate up 冪等:跑第二次為 no-change、版本不變、not dirty。
|
||||
//
|
||||
// 不寫死版本號——冪等的本質是「再 up 一次 version 不變」,不是「version == 某固定數字」。
|
||||
// 以 SetupTestDB 跑完第一次 up 後的版本當基準,驗第二次 up 後版本與其一致。
|
||||
// 如此未來再加 0004/0005 也不會壞。
|
||||
func TestMigrate_Idempotent(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
|
||||
// SetupTestDB 已跑過一次 up,記下當下版本當基準(= 最新可用 migration 版本)。
|
||||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||
require.NoError(t, err)
|
||||
defer mg.Close()
|
||||
|
||||
baseVer, dirty, err := mg.Version()
|
||||
require.NoError(t, err)
|
||||
require.False(t, dirty, "第一次 up 後不應 dirty")
|
||||
require.Greater(t, baseVer, uint(0), "第一次 up 後版本應 > 0")
|
||||
|
||||
// 再跑一次 up 應為 no-change(不報錯)。
|
||||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()))
|
||||
|
||||
ver, dirty, err := mg.Version()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, dirty, "schema 不應處於 dirty 狀態")
|
||||
assert.Equal(t, baseVer, ver, "再 up 一次版本不應改變(冪等)")
|
||||
}
|
||||
|
||||
// TestMigrate_UpDownUp 驗證 down 後 up 仍可回到原狀態(雙向 migration 正確)。
|
||||
//
|
||||
// 注意:Migrator.Down() 實作為 m.Steps(-1),是「回退一個版本」而非「回滾全部」。
|
||||
// 因此本測試驗的是版本層級的可逆性:down 後版本 -1、再 up 後版本回到原值且 not dirty。
|
||||
// 不依賴特定 migration 建立的表名 / 版本號,未來再加 0004/0005 也不會壞。
|
||||
func TestMigrate_UpDownUp(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
|
||||
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||
require.NoError(t, err)
|
||||
defer mg.Close()
|
||||
|
||||
// SetupTestDB 已 up 到最新,記下原始版本。
|
||||
topVer, dirty, err := mg.Version()
|
||||
require.NoError(t, err)
|
||||
require.False(t, dirty, "起始不應 dirty")
|
||||
require.Greater(t, topVer, uint(0), "起始版本應 > 0")
|
||||
|
||||
// down 一步(Steps(-1):只回退最新一個 migration)。
|
||||
require.NoError(t, mg.Down())
|
||||
|
||||
downVer, dirty, err := mg.Version()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, dirty, "down 後不應 dirty")
|
||||
assert.Equal(t, topVer-1, downVer, "down 一步後版本應 -1")
|
||||
|
||||
// 再 up 一次應回到原始最新版本(雙向可逆)。
|
||||
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()))
|
||||
|
||||
upVer, dirty, err := mg.Version()
|
||||
require.NoError(t, err)
|
||||
assert.False(t, dirty, "重新 up 後不應 dirty")
|
||||
assert.Equal(t, topVer, upVer, "重新 up 後版本應回到原始最新版本")
|
||||
}
|
||||
|
||||
// TestMigrate_SchemaShape 抽查 0001 的關鍵 schema 特性(gen_random_uuid 預設、FK、partial index)。
|
||||
func TestMigrate_SchemaShape(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// users.id 預設應能用 gen_random_uuid()(PG14 內建)插入不指定 id。
|
||||
var uid string
|
||||
err := tdb.Pool.QueryRow(ctx,
|
||||
`INSERT INTO users (email) VALUES ($1) RETURNING id`, "shape@test.local").Scan(&uid)
|
||||
require.NoError(t, err, "insert user with default uuid")
|
||||
assert.NotEmpty(t, uid)
|
||||
|
||||
// models.owner_user_id FK:插入不存在的 owner 應失敗。
|
||||
_, err = tdb.Pool.Exec(ctx,
|
||||
`INSERT INTO models (owner_user_id, name, storage_key, file_size, source)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
"99999999-9999-9999-9999-999999999999", "m", "k", 1, "uploaded")
|
||||
assert.Error(t, err, "FK 違反應報錯")
|
||||
|
||||
// 用合法 owner 插入應成功,array 欄位 round-trip。
|
||||
var mid string
|
||||
err = tdb.Pool.QueryRow(ctx,
|
||||
`INSERT INTO models (owner_user_id, name, storage_key, file_size, source, classes, input_shape)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
uid, "m1", "models/x.nef", 123, "uploaded", []string{"cat", "dog"}, []int32{1, 224, 224, 3}).Scan(&mid)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, mid)
|
||||
}
|
||||
|
||||
// TestPool_FailFast 驗證連到不存在的 DB 會 fail-fast(建池/ping 回 error,不 hang)。
|
||||
func TestPool_FailFast(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: 1, // 不可能有 postgres 監聽的 port
|
||||
User: "nobody",
|
||||
Password: "nopw",
|
||||
DBName: "nodb",
|
||||
SSLMode: "disable",
|
||||
ConnTimeout: 2 * time.Second,
|
||||
}
|
||||
start := time.Now()
|
||||
_, err := db.NewPool(context.Background(), cfg, discardLog())
|
||||
require.Error(t, err, "連不上 DB 應回 error")
|
||||
assert.Less(t, time.Since(start), 10*time.Second, "應在 ConnTimeout 內 fail-fast,不 hang")
|
||||
}
|
||||
|
||||
// TestTruncate 驗證 truncate helper 清空資料。
|
||||
func TestTruncate(t *testing.T) {
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.EnsureDemoUser(t)
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "users"))
|
||||
tdb.Truncate(t, "models", "users")
|
||||
assert.Equal(t, 0, tdb.CountRows(t, "users"))
|
||||
}
|
||||
89
visionA-backend/internal/db/dsn.go
Normal file
89
visionA-backend/internal/db/dsn.go
Normal file
@ -0,0 +1,89 @@
|
||||
// Package db 提供 visionA-backend 的 PostgreSQL 連線池(pgxpool)與 migration runner。
|
||||
//
|
||||
// DB 接入塊 0(DB 基礎建設):本套件只負責「連線池建池 + 啟動 ping + graceful shutdown +
|
||||
// migration」這些跨 domain 共用的基礎設施;不含任何 store / repository 的 Postgres 實作
|
||||
// (那是塊 1–3,會新增各 domain 的 postgres_repository.go,實作既有 interface)。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5。
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"visiona-backend/internal/config"
|
||||
)
|
||||
|
||||
// BuildDSN 從 DatabaseConfig 組出 pgxpool 用的 PostgreSQL DSN。
|
||||
//
|
||||
// 格式(對齊 database.md §5.5.1):
|
||||
//
|
||||
// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns}&pool_min_conns={MinConns}
|
||||
//
|
||||
// User / Password 走 url.UserPassword 做 percent-encoding,避免密碼含特殊字元(@ : / ?)破壞 DSN。
|
||||
//
|
||||
// 安全:回傳值含明文密碼,呼叫端**不可**將其寫入 log。需要在 log 標識連線目標時,
|
||||
// 改用 SafeTarget()(只含 host:port/dbname、不含密碼)。
|
||||
func BuildDSN(c config.DatabaseConfig) string {
|
||||
u := baseURL(c)
|
||||
|
||||
q := baseQuery(c)
|
||||
// pgxpool 專屬參數:只有 pgxpool 認得,golang-migrate 的標準 database/sql 連線會被 PG
|
||||
// server 端拒絕(FATAL: unrecognized configuration parameter "pool_max_conns"),
|
||||
// 故這些參數**只**加在 BuildDSN(pgxpool 路徑),不加在 baseDSN(migrate 路徑)。
|
||||
if c.MaxConns > 0 {
|
||||
q.Set("pool_max_conns", strconv.Itoa(c.MaxConns))
|
||||
}
|
||||
if c.MinConns > 0 {
|
||||
q.Set("pool_min_conns", strconv.Itoa(c.MinConns))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// baseDSN 組「只含標準 libpq 連線參數」的 PostgreSQL DSN(host/port/user/password/dbname/sslmode),
|
||||
// 不含任何 pgxpool 專屬參數(pool_max_conns / pool_min_conns)。
|
||||
//
|
||||
// 供 golang-migrate 的 pgx5 driver 使用:它走標準 database/sql 單連線,PG server 端不認得
|
||||
// pool_* 參數會直接 FATAL,故 migrate 路徑必須走這個「乾淨」DSN。
|
||||
func baseDSN(c config.DatabaseConfig) string {
|
||||
u := baseURL(c)
|
||||
u.RawQuery = baseQuery(c).Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// baseURL 組 DSN 的 scheme / 帳密 / host / path 部分(不含 query)。
|
||||
func baseURL(c config.DatabaseConfig) url.URL {
|
||||
return url.URL{
|
||||
Scheme: "postgres",
|
||||
User: url.UserPassword(c.User, c.Password),
|
||||
Host: net_JoinHostPort(c.Host, c.Port),
|
||||
Path: "/" + c.DBName,
|
||||
}
|
||||
}
|
||||
|
||||
// baseQuery 組標準 libpq 連線參數(目前只有 sslmode)。pgxpool 與 migrate 共用這組基礎參數。
|
||||
func baseQuery(c config.DatabaseConfig) url.Values {
|
||||
q := url.Values{}
|
||||
sslMode := c.SSLMode
|
||||
if sslMode == "" {
|
||||
sslMode = "require"
|
||||
}
|
||||
q.Set("sslmode", sslMode)
|
||||
return q
|
||||
}
|
||||
|
||||
// SafeTarget 回傳「可安全寫入 log」的連線目標字串(host:port/dbname),不含使用者名稱或密碼。
|
||||
func SafeTarget(c config.DatabaseConfig) string {
|
||||
return fmt.Sprintf("%s/%s", net_JoinHostPort(c.Host, c.Port), c.DBName)
|
||||
}
|
||||
|
||||
// net_JoinHostPort 是 net.JoinHostPort 的小包裝(避免在多檔 import net 只為一個呼叫)。
|
||||
func net_JoinHostPort(host string, port int) string {
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
return host + ":" + strconv.Itoa(port)
|
||||
}
|
||||
132
visionA-backend/internal/db/dsn_test.go
Normal file
132
visionA-backend/internal/db/dsn_test.go
Normal 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:// prefix,got %s", got)
|
||||
assert.Contains(t, got, "@h:5432/d")
|
||||
}
|
||||
|
||||
// Regression(塊 0 修正):migrateDSN 絕不可帶 pgxpool 專屬參數。
|
||||
// golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端不認得
|
||||
// pool_max_conns / pool_min_conns,會直接 FATAL(SQLSTATE 42704)導致所有 migration 失敗。
|
||||
// 這個無 docker 也能跑的純函式測試,確保不會再 regression。
|
||||
func TestMigrateDSN_NoPoolParams(t *testing.T) {
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: "h", Port: 5432, User: "u", Password: "pw", DBName: "d", SSLMode: "disable",
|
||||
MaxConns: 10, MinConns: 2,
|
||||
}
|
||||
got := migrateDSN(cfg)
|
||||
|
||||
assert.NotContains(t, got, "pool_max_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_max_conns,got %s", got)
|
||||
assert.NotContains(t, got, "pool_min_conns", "migrateDSN 不可帶 pgxpool 專屬參數 pool_min_conns,got %s", got)
|
||||
// 標準 libpq 參數仍須正確帶上。
|
||||
assert.Contains(t, got, "sslmode=disable", "migrateDSN 應保留 sslmode,got %s", got)
|
||||
|
||||
// 對照組:BuildDSN(pgxpool 路徑)仍應帶 pool 參數。
|
||||
pool := BuildDSN(cfg)
|
||||
assert.Contains(t, pool, "pool_max_conns=10", "BuildDSN 應保留 pool_max_conns")
|
||||
assert.Contains(t, pool, "pool_min_conns=2", "BuildDSN 應保留 pool_min_conns")
|
||||
}
|
||||
144
visionA-backend/internal/db/migrate.go
Normal file
144
visionA-backend/internal/db/migrate.go
Normal 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 進出點。
|
||||
//
|
||||
// 使用嵌入式 migration(migrations.FS)+ pgx/v5 database driver。
|
||||
// 每個 Migrator 持有自己的 *migrate.Migrate,用完應 Close()。
|
||||
type Migrator struct {
|
||||
m *migrate.Migrate
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewMigrator 從 DatabaseConfig 建立 Migrator(連到 cfg 指定的 DB)。
|
||||
//
|
||||
// 注意:golang-migrate 自己開一條連線(與 pgxpool 分離),完成後 Close() 釋放。
|
||||
// 這是 golang-migrate 的設計(migration 需要獨佔的 advisory lock 連線)。
|
||||
func NewMigrator(cfg config.DatabaseConfig, log *slog.Logger) (*Migrator, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
src, err := iofs.New(migrations.FS, migrationsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open embedded migrations source: %w", err)
|
||||
}
|
||||
m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://"+migrateDSN(cfg))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: init migrator (target=%s): %w", SafeTarget(cfg), err)
|
||||
}
|
||||
return &Migrator{m: m, log: log}, nil
|
||||
}
|
||||
|
||||
// newMigratorFromDSN 直接以完整 DSN 建立 Migrator,供整合測試(testcontainers 給 raw DSN)使用。
|
||||
func newMigratorFromDSN(rawDSN string, log *slog.Logger) (*Migrator, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
src, err := iofs.New(migrations.FS, migrationsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: open embedded migrations source: %w", err)
|
||||
}
|
||||
m, err := migrate.NewWithSourceInstance("iofs", src, rawDSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: init migrator from dsn: %w", err)
|
||||
}
|
||||
return &Migrator{m: m, log: log}, nil
|
||||
}
|
||||
|
||||
// Up 套用所有尚未執行的 migration。
|
||||
//
|
||||
// 冪等:已是最新版(migrate.ErrNoChange)視為成功、不回 error。
|
||||
func (mg *Migrator) Up() error {
|
||||
err := mg.m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("db: migrate up: %w", err)
|
||||
}
|
||||
if errors.Is(err, migrate.ErrNoChange) {
|
||||
mg.log.Info("migrate up: no change (already at latest version)")
|
||||
} else {
|
||||
ver, _, _ := mg.m.Version()
|
||||
mg.log.Info("migrate up: applied", "version", ver)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Down 回退一個版本(給整合測試 / cmd/migrate 用,正常啟動流程不呼叫)。
|
||||
func (mg *Migrator) Down() error {
|
||||
err := mg.m.Steps(-1)
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("db: migrate down one step: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version 回傳目前 schema 版本與是否處於 dirty 狀態。
|
||||
// 尚無任何 migration 時回 (0, false, migrate.ErrNilVersion)。
|
||||
func (mg *Migrator) Version() (uint, bool, error) {
|
||||
return mg.m.Version()
|
||||
}
|
||||
|
||||
// Close 釋放 migrator 的 source 與 database 連線。
|
||||
func (mg *Migrator) Close() error {
|
||||
srcErr, dbErr := mg.m.Close()
|
||||
if srcErr != nil {
|
||||
return srcErr
|
||||
}
|
||||
return dbErr
|
||||
}
|
||||
|
||||
// RunMigrations 是「建好池後自動 migrate up」的便利函式,供 main.go 在啟動流程呼叫。
|
||||
//
|
||||
// 它自建一個 Migrator、跑 Up、再 Close,呼叫端不需管 Migrator 生命週期。
|
||||
func RunMigrations(cfg config.DatabaseConfig, log *slog.Logger) error {
|
||||
mg, err := NewMigrator(cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if cerr := mg.Close(); cerr != nil {
|
||||
log.Warn("migrator close error", "error", cerr)
|
||||
}
|
||||
}()
|
||||
return mg.Up()
|
||||
}
|
||||
|
||||
// migrateDSN 組 golang-migrate pgx5 driver 用的 DSN(不含 pgx5:// scheme prefix,
|
||||
// 由呼叫端拼上)。
|
||||
//
|
||||
// 關鍵:golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端**不認得**
|
||||
// pgxpool 專屬參數(pool_max_conns / pool_min_conns)會直接 FATAL
|
||||
// (unrecognized configuration parameter, SQLSTATE 42704)。因此這裡走 baseDSN(只含
|
||||
// 標準 libpq 參數 host/port/user/password/dbname/sslmode),**不**走 BuildDSN。
|
||||
//
|
||||
// golang-migrate pgx5 driver 接受 "pgx5://user:pass@host:port/db?sslmode=..." 形式,
|
||||
// 故把 baseDSN 的 "postgres://" scheme 去掉、由 NewMigrator 拼回 "pgx5://"。
|
||||
func migrateDSN(cfg config.DatabaseConfig) string {
|
||||
dsn := baseDSN(cfg)
|
||||
// baseDSN 回 "postgres://...", 去掉 "postgres://" 留下 "user:pass@host/db?..."。
|
||||
const prefix = "postgres://"
|
||||
if len(dsn) >= len(prefix) && dsn[:len(prefix)] == prefix {
|
||||
return dsn[len(prefix):]
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// 確保 driver 被 link 進 binary(golang-migrate 用 blank import 註冊 driver;
|
||||
// 這裡顯式 import + 引用避免被當 unused)。
|
||||
var _ = migratepgx.ErrNilConfig
|
||||
108
visionA-backend/internal/db/pool.go
Normal file
108
visionA-backend/internal/db/pool.go
Normal 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.Pool(repository 實作直接拿 .Pool() 用 pgx API)。
|
||||
// - 持有建池時的 config 供 log / health check 標識連線目標(不含密碼)。
|
||||
// - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。
|
||||
type Pool struct {
|
||||
pool *pgxpool.Pool
|
||||
cfg config.DatabaseConfig
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewPool 依 DatabaseConfig 建立 pgxpool 連線池,並在啟動時跑一次 ping 確認 DB 可達。
|
||||
//
|
||||
// fail-fast 語意:ping 失敗即回 error,由 main.go 決定是 fatal(DB 啟用時連不上應該停機)。
|
||||
// 這避免「DB 設定了卻連不上」卻靜默 fallback in-memory 造成資料不一致的隱患。
|
||||
//
|
||||
// 逾時:建池與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。
|
||||
//
|
||||
// 安全:log 只印 SafeTarget(host:port/dbname),DSN 與密碼永遠不入 log。
|
||||
func NewPool(ctx context.Context, cfg config.DatabaseConfig, log *slog.Logger) (*Pool, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
dsn := BuildDSN(cfg)
|
||||
poolCfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
// 不把 err 直接外露 DSN(pgx ParseConfig error 不含密碼,但保守起見只回固定訊息)。
|
||||
return nil, fmt.Errorf("db: parse pool config failed: %w", err)
|
||||
}
|
||||
|
||||
// 套用連線池參數(ParseConfig 已從 DSN query 解析 pool_max_conns/pool_min_conns,
|
||||
// 此處再以 config 值覆寫,確保 DSN 與 config 不一致時以 config 為準)。
|
||||
if cfg.MaxConns > 0 {
|
||||
poolCfg.MaxConns = int32(cfg.MaxConns)
|
||||
}
|
||||
if cfg.MinConns > 0 {
|
||||
poolCfg.MinConns = int32(cfg.MinConns)
|
||||
}
|
||||
if cfg.MaxConnLifetime > 0 {
|
||||
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
|
||||
}
|
||||
|
||||
connTimeout := cfg.ConnTimeout
|
||||
if connTimeout <= 0 {
|
||||
connTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
buildCtx, cancel := context.WithTimeout(ctx, connTimeout)
|
||||
defer cancel()
|
||||
|
||||
pgPool, err := pgxpool.NewWithConfig(buildCtx, poolCfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("db: create pool failed (target=%s): %w", SafeTarget(cfg), err)
|
||||
}
|
||||
|
||||
// 啟動 ping:確認 DB 真的可達(NewWithConfig 不會立即連線)。
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, connTimeout)
|
||||
defer pingCancel()
|
||||
if err := pgPool.Ping(pingCtx); err != nil {
|
||||
pgPool.Close()
|
||||
return nil, fmt.Errorf("db: ping failed (target=%s): %w", SafeTarget(cfg), err)
|
||||
}
|
||||
|
||||
log.Info("postgres pool initialized",
|
||||
"target", SafeTarget(cfg),
|
||||
"sslmode", cfg.SSLMode,
|
||||
"max_conns", poolCfg.MaxConns,
|
||||
"min_conns", poolCfg.MinConns,
|
||||
"max_conn_lifetime", poolCfg.MaxConnLifetime,
|
||||
)
|
||||
|
||||
return &Pool{pool: pgPool, cfg: cfg, log: log}, nil
|
||||
}
|
||||
|
||||
// Pool 回傳底層 *pgxpool.Pool,供 repository 實作使用 pgx API。
|
||||
func (p *Pool) Pool() *pgxpool.Pool {
|
||||
return p.pool
|
||||
}
|
||||
|
||||
// Ping 對 DB 跑一次連線檢查,供 /healthz 等 health check 用。
|
||||
func (p *Pool) Ping(ctx context.Context) error {
|
||||
return p.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// Close 關閉連線池(graceful shutdown)。等待所有 checked-out 連線歸還。
|
||||
// 重複呼叫安全(pgxpool.Close 內部冪等)。
|
||||
func (p *Pool) Close() {
|
||||
if p == nil || p.pool == nil {
|
||||
return
|
||||
}
|
||||
p.log.Info("closing postgres pool", "target", SafeTarget(p.cfg))
|
||||
p.pool.Close()
|
||||
}
|
||||
106
visionA-backend/internal/db/redis.go
Normal file
106
visionA-backend/internal/db/redis.go
Normal 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 接入塊 4(userSession 接 Redis):本型別只負責「建連線 + 啟動 ping + graceful close」
|
||||
// 這些基礎設施;userSession 的 store 邏輯在 internal/usersession/redis.go(RedisUserSessionStore)。
|
||||
//
|
||||
// 設計對齊 pool.go(Postgres Pool)的風格:
|
||||
// - 薄包裝,不隱藏底層 client(store 實作直接拿 .Client() 用 go-redis API)。
|
||||
// - 持有建連時的 config 供 log / health check 標識連線目標(不含密碼)。
|
||||
// - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/database.md §5.5.2。
|
||||
type RedisClient struct {
|
||||
client *redis.Client
|
||||
cfg config.RedisConfig
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewRedisClient 依 RedisConfig 建立 go-redis client,並在啟動時跑一次 ping 確認可達。
|
||||
//
|
||||
// fail-fast 語意:ping 失敗即回 error,由 main.go 決定是否 fatal
|
||||
// (Redis 啟用時連不上,與 Postgres 同樣應停機,而非靜默 fallback in-memory 造成行為不一致)。
|
||||
//
|
||||
// 逾時:建連與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。
|
||||
//
|
||||
// 安全:log 只印 SafeRedisTarget(host:port/db);Password 永遠不入 log。
|
||||
// visionA 專用 Redis 可能無密碼(stage 內網),Enabled() 只看 Host,故此處不強制 Password。
|
||||
func NewRedisClient(ctx context.Context, cfg config.RedisConfig, log *slog.Logger) (*RedisClient, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
|
||||
connTimeout := cfg.ConnTimeout
|
||||
if connTimeout <= 0 {
|
||||
connTimeout = 5 * time.Second
|
||||
}
|
||||
|
||||
port := cfg.Port
|
||||
if port == 0 {
|
||||
port = 6379
|
||||
}
|
||||
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: fmt.Sprintf("%s:%d", cfg.Host, port),
|
||||
Password: cfg.Password, // 空字串 = 無密碼(visionA 專用 Redis stage 內網可不設密碼)
|
||||
DB: cfg.DB,
|
||||
DialTimeout: connTimeout,
|
||||
ReadTimeout: connTimeout,
|
||||
WriteTimeout: connTimeout,
|
||||
})
|
||||
|
||||
// 啟動 ping:確認 Redis 真的可達(NewClient 不會立即連線)。
|
||||
pingCtx, cancel := context.WithTimeout(ctx, connTimeout)
|
||||
defer cancel()
|
||||
if err := client.Ping(pingCtx).Err(); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("db: redis ping failed (target=%s): %w", SafeRedisTarget(cfg), err)
|
||||
}
|
||||
|
||||
log.Info("redis client initialized",
|
||||
"target", SafeRedisTarget(cfg),
|
||||
"db", cfg.DB,
|
||||
)
|
||||
|
||||
return &RedisClient{client: client, cfg: cfg, log: log}, nil
|
||||
}
|
||||
|
||||
// Client 回傳底層 *redis.Client,供 store 實作使用 go-redis API。
|
||||
func (r *RedisClient) Client() *redis.Client {
|
||||
return r.client
|
||||
}
|
||||
|
||||
// Ping 對 Redis 跑一次連線檢查,供 /healthz 等 health check 用。
|
||||
func (r *RedisClient) Ping(ctx context.Context) error {
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Close 關閉 Redis client(graceful shutdown)。重複呼叫安全。
|
||||
func (r *RedisClient) Close() {
|
||||
if r == nil || r.client == nil {
|
||||
return
|
||||
}
|
||||
r.log.Info("closing redis client", "target", SafeRedisTarget(r.cfg))
|
||||
_ = r.client.Close()
|
||||
}
|
||||
|
||||
// SafeRedisTarget 回傳「可安全寫入 log」的 Redis 連線目標字串(host:port/db),不含密碼。
|
||||
func SafeRedisTarget(c config.RedisConfig) string {
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 6379
|
||||
}
|
||||
return fmt.Sprintf("%s:%d/%d", c.Host, port, c.DB)
|
||||
}
|
||||
71
visionA-backend/internal/db/testsupport/factory.go
Normal file
71
visionA-backend/internal/db/testsupport/factory.go
Normal 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_id(NOT NULL FK → devices(id))。
|
||||
// ownerUserID 須先存在於 users。回傳寫入的 device id(傳入 id 為空時自動產生 UUID)。
|
||||
func (tdb *TestDB) InsertDevice(t *testing.T, id, ownerUserID string) string {
|
||||
t.Helper()
|
||||
if id == "" {
|
||||
id = uuid.NewString()
|
||||
}
|
||||
_, err := tdb.Pool.Exec(context.Background(),
|
||||
`INSERT INTO devices (id, owner_user_id, name, serial_number)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO NOTHING`,
|
||||
id, ownerUserID, "fixture-device", "SN-"+id[:8])
|
||||
require.NoError(t, err, "insert device fixture")
|
||||
return id
|
||||
}
|
||||
|
||||
// CountRows 回傳指定 table 的列數(含或不含 soft-deleted 由呼叫端用 where 決定,此處為全表)。
|
||||
// 供測試斷言「插入幾筆 / truncate 後是否歸零」。
|
||||
func (tdb *TestDB) CountRows(t *testing.T, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := tdb.Pool.QueryRow(context.Background(),
|
||||
"SELECT count(*) FROM "+pgIdent(table)).Scan(&n)
|
||||
require.NoError(t, err, "count rows in %s", table)
|
||||
return n
|
||||
}
|
||||
151
visionA-backend/internal/db/testsupport/testsupport.go
Normal file
151
visionA-backend/internal/db/testsupport/testsupport.go
Normal file
@ -0,0 +1,151 @@
|
||||
//go:build dbtest
|
||||
|
||||
// Package testsupport 提供 DB 整合測試的共用基礎建設(testcontainers-go + Postgres)。
|
||||
//
|
||||
// DB 接入塊 0(0.6):一次性 Postgres 容器、自動 migrate、truncate helper、fixture factory。
|
||||
// 塊 1–3 的 *postgres_repository_test.go 都會 import 本套件,共用同一套 setupTestDB 與 builder。
|
||||
//
|
||||
// build tag `dbtest`:本套件與依賴它的測試只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker)。
|
||||
// 預設 `go test ./...`(無 Docker 環境 / CI 無 docker daemon)不會觸碰,維持綠燈。
|
||||
//
|
||||
// 執行方式:
|
||||
//
|
||||
// go test -tags=dbtest ./... # 需要本機 / CI 有可用的 Docker daemon
|
||||
// make test-db # Makefile 包裝
|
||||
package testsupport
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
|
||||
"visiona-backend/internal/config"
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// discardLogger 是丟棄輸出的 logger,避免測試噪音淹沒 go test 輸出。
|
||||
var discardLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
const (
|
||||
testDBName = "visiona_test"
|
||||
testDBUser = "visiona"
|
||||
testDBPassword = "visiona-test-pw" // 測試容器專用、非真實憑證
|
||||
testImage = "postgres:14.23-alpine"
|
||||
)
|
||||
|
||||
// TestDB 是一個已啟動 + 已 migrate 的測試用 Postgres 容器與其連線池。
|
||||
type TestDB struct {
|
||||
Pool *pgxpool.Pool
|
||||
Cfg config.DatabaseConfig
|
||||
container *tcpostgres.PostgresContainer
|
||||
}
|
||||
|
||||
// SetupTestDB 啟動一個一次性 Postgres 容器、跑 migrate up、回傳已就緒的連線池。
|
||||
//
|
||||
// 自動在 t.Cleanup 註冊容器與連線池的關閉,呼叫端不需手動 teardown。
|
||||
// 容器映像固定 postgres:14.23-alpine 對齊 stage(PG 14.23),確保 gen_random_uuid 等
|
||||
// 版本相依行為與正式環境一致。
|
||||
func SetupTestDB(t *testing.T) *TestDB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
container, err := tcpostgres.Run(ctx, testImage,
|
||||
tcpostgres.WithDatabase(testDBName),
|
||||
tcpostgres.WithUsername(testDBUser),
|
||||
tcpostgres.WithPassword(testDBPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(60*time.Second),
|
||||
),
|
||||
)
|
||||
require.NoError(t, err, "start postgres container")
|
||||
|
||||
// 用 ConnectionString 取得 ready DSN,再解析出 host/port 回填 config
|
||||
// (避免綁定特定版本的 MappedPort 回傳型別 API)。
|
||||
connStr, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
require.NoError(t, err, "container connection string")
|
||||
u, err := url.Parse(connStr)
|
||||
require.NoError(t, err, "parse connection string")
|
||||
host := u.Hostname()
|
||||
port, err := strconv.Atoi(u.Port())
|
||||
require.NoError(t, err, "parse mapped port from %q", connStr)
|
||||
|
||||
cfg := config.DatabaseConfig{
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: testDBUser,
|
||||
Password: testDBPassword,
|
||||
DBName: testDBName,
|
||||
SSLMode: "disable", // testcontainers 本機連線,無 TLS
|
||||
MaxConns: 5,
|
||||
MinConns: 1,
|
||||
ConnTimeout: 10 * time.Second,
|
||||
AutoMigrate: true,
|
||||
}
|
||||
|
||||
// 跑 migration(用與 production 相同的 RunMigrations + 嵌入式 migration)。
|
||||
require.NoError(t, db.RunMigrations(cfg, discardLogger), "run migrations")
|
||||
|
||||
pool, err := db.NewPool(ctx, cfg, discardLogger)
|
||||
require.NoError(t, err, "create pool")
|
||||
|
||||
tdb := &TestDB{Pool: pool.Pool(), Cfg: cfg, container: container}
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
// 容器 teardown 用獨立 context,避免測試 ctx 已取消。
|
||||
termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
_ = container.Terminate(termCtx)
|
||||
})
|
||||
|
||||
return tdb
|
||||
}
|
||||
|
||||
// Truncate 清空指定 table(測試間隔離用)。RESTART IDENTITY + CASCADE 確保關聯表一併清。
|
||||
//
|
||||
// 用法:在每個子測試開頭呼叫 tdb.Truncate(t, "models", "users") 以保證乾淨起點。
|
||||
// 順序無所謂(CASCADE 處理 FK)。
|
||||
func (tdb *TestDB) Truncate(t *testing.T, tables ...string) {
|
||||
t.Helper()
|
||||
if len(tables) == 0 {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
sql := "TRUNCATE TABLE "
|
||||
for i, tbl := range tables {
|
||||
if i > 0 {
|
||||
sql += ", "
|
||||
}
|
||||
sql += pgIdent(tbl)
|
||||
}
|
||||
sql += " RESTART IDENTITY CASCADE"
|
||||
_, err := tdb.Pool.Exec(ctx, sql)
|
||||
require.NoError(t, err, "truncate %v", tables)
|
||||
}
|
||||
|
||||
// pgIdent 對 SQL identifier 做最小化的雙引號 quote(測試用,table 名稱來自測試常數、非使用者輸入)。
|
||||
// 內嵌雙引號被 escape 為 "",符合 PostgreSQL identifier 規則。
|
||||
func pgIdent(name string) string {
|
||||
out := make([]byte, 0, len(name)+2)
|
||||
out = append(out, '"')
|
||||
for i := 0; i < len(name); i++ {
|
||||
if name[i] == '"' {
|
||||
out = append(out, '"')
|
||||
}
|
||||
out = append(out, name[i])
|
||||
}
|
||||
out = append(out, '"')
|
||||
return string(out)
|
||||
}
|
||||
85
visionA-backend/internal/db/tx.go
Normal file
85
visionA-backend/internal/db/tx.go
Normal 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 成功 → commit;commit 失敗回 commit error。
|
||||
// - panic → rollback 後重新 panic(不吞,讓上層 recovery middleware 處理)。
|
||||
//
|
||||
// ctx 取消會讓 Begin / fn 內的查詢 / Commit 各自因 context 失敗而中止——交易不會半開。
|
||||
//
|
||||
// 注意:fn 內所有 DB 操作都必須用傳入的 q(tx),不可再用外層 pool,否則那些操作不在交易內、
|
||||
// 失敗時不會被 rollback。
|
||||
func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (err error) {
|
||||
if pool == nil {
|
||||
return errors.New("db: WithTx requires non-nil pool")
|
||||
}
|
||||
|
||||
tx, beginErr := pool.Begin(ctx)
|
||||
if beginErr != nil {
|
||||
return fmt.Errorf("db: begin tx: %w", beginErr)
|
||||
}
|
||||
|
||||
// committed 避免 commit 後又 rollback(pgx 對已結束的 tx rollback 會回 ErrTxClosed)。
|
||||
committed := false
|
||||
defer func() {
|
||||
if committed {
|
||||
return
|
||||
}
|
||||
// Rollback 用 background context:若是 ctx 已取消才走到這裡,仍要盡力 rollback。
|
||||
if rbErr := tx.Rollback(context.Background()); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) {
|
||||
// 只有在 fn 本身沒回 error(亦即 err 為 nil)時,rollback error 才升級成回傳值;
|
||||
// 否則保留 fn 的原始 error(更有診斷價值),rollback error 只是次要訊號。
|
||||
if err == nil {
|
||||
err = fmt.Errorf("db: rollback tx: %w", rbErr)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if fnErr := fn(tx); fnErr != nil {
|
||||
return fnErr // defer 會 rollback
|
||||
}
|
||||
|
||||
if cErr := tx.Commit(ctx); cErr != nil {
|
||||
return fmt.Errorf("db: commit tx: %w", cErr)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
|
||||
//
|
||||
// pgx.Tx 是 interface,無法直接取 (pgx.Tx)(nil) 當靜態斷言對象(nil interface 沒有具體型別),
|
||||
// 故只對 *pgxpool.Pool 做編譯期斷言;pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。
|
||||
var _ Querier = (*pgxpool.Pool)(nil)
|
||||
132
visionA-backend/internal/db/tx_db_test.go
Normal file
132
visionA-backend/internal/db/tx_db_test.go
Normal 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)
|
||||
}
|
||||
305
visionA-backend/internal/device/postgres_repository.go
Normal file
305
visionA-backend/internal/device/postgres_repository.go
Normal file
@ -0,0 +1,305 @@
|
||||
// Package device 的 Postgres 持久層實作(DB 接入塊 2)。
|
||||
//
|
||||
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface,
|
||||
// 讓 main.go 在 dbPool != nil 時無痛切換、handler 與呼叫端一行都不需改。
|
||||
//
|
||||
// 對齊:
|
||||
// - database.md §2.2(Device 雙狀態欄位 + paired_at)、§4(devices 表 schema:
|
||||
// partial unique index uq_devices_owner_serial_active + owner/remote_status filter index)
|
||||
// - migrations/0002_create_devices.up.sql(devices 表含全部欄位)
|
||||
//
|
||||
// 語意對齊 in-memory(見 device.go):
|
||||
// - Get / GetBySerial / List 略過 deleted_at IS NOT NULL 的紀錄。
|
||||
// - GetBySerial 以 (owner_user_id, serial_number) 查未刪除紀錄。
|
||||
// - Save 為 upsert by ID;existing 且未刪除時保留原 created_at(in-memory device.go ~line 187)。
|
||||
// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。
|
||||
//
|
||||
// partial unique × soft-delete 語意(塊 2 子任務 2.3):
|
||||
//
|
||||
// devices 的 (owner_user_id, serial_number) 唯一性只對「未刪除」紀錄成立
|
||||
// (migration 0002 的 uq_devices_owner_serial_active WHERE deleted_at IS NULL)。
|
||||
// 因此:
|
||||
// - 同 owner 同 serial 同時存在「兩筆未刪除」→ INSERT 第二筆會撞 unique(23505)。
|
||||
// - 但若先把第一筆 soft-delete(deleted_at IS NOT NULL),它就退出 partial index 的
|
||||
// 管轄範圍,同 (owner, serial) 可再 INSERT 一筆新的(新 id)而不違反 unique。
|
||||
// 這正是「已刪除 serial 可重新註冊」的決策落地。
|
||||
// 注意:Save 是 upsert by **id**,而非 by (owner, serial)。重註冊走「新 id」路徑、
|
||||
// 不會 ON CONFLICT (id) 命中舊列;舊列保持 soft-deleted,新列為一筆全新紀錄。
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"visiona-backend/internal/db"
|
||||
)
|
||||
|
||||
// PostgresRepository 是 Device 的 PostgreSQL 持久層實作。
|
||||
type PostgresRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。
|
||||
//
|
||||
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
|
||||
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
|
||||
return &PostgresRepository{pool: pool}
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 PostgresRepository 實作 Repository。
|
||||
var _ Repository = (*PostgresRepository)(nil)
|
||||
|
||||
// deviceColumns 是 SELECT 共用的欄位清單(順序必須與 scanDevice 對齊)。
|
||||
const deviceColumns = `id, owner_user_id, name, device_type, serial_number,
|
||||
remote_status, last_seen_at, last_connected_at, status,
|
||||
created_at, updated_at, paired_at, deleted_at`
|
||||
|
||||
// Get 取得單一 device;不存在或已軟刪除回 ErrNotFound。
|
||||
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Device, error) {
|
||||
const q = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, id)
|
||||
d, err := scanDevice(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device: pg Get: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetBySerial 以 (ownerUserID, serialNumber) 查未刪除紀錄;查不到回 ErrNotFound。
|
||||
//
|
||||
// 對齊 in-memory:同一個 serial 在不同 owner 下不互相干擾(owner 過濾)。
|
||||
func (r *PostgresRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
|
||||
const q = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE owner_user_id = $1 AND serial_number = $2 AND deleted_at IS NULL`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, ownerUserID, serial)
|
||||
d, err := scanDevice(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device: pg GetBySerial: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// List 列出某 owner 的所有未刪除 device,以 created_at DESC 排序(最新在前)。
|
||||
func (r *PostgresRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) {
|
||||
const q = `SELECT ` + deviceColumns + `
|
||||
FROM devices
|
||||
WHERE owner_user_id = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, ownerUserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("device: pg List query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Device, 0)
|
||||
for rows.Next() {
|
||||
d, scanErr := scanDevice(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("device: pg List scan: %w", scanErr)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("device: pg List rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save 新增或更新 device(upsert by id)。
|
||||
//
|
||||
// 語意對齊 in-memory(device.go ~line 187):
|
||||
// - 既有且未刪除(deleted_at IS NULL)→ 保留原 created_at;
|
||||
// - 不存在 / 已刪除(復活)→ 以傳入 created_at(zero 時用 now())為準。
|
||||
//
|
||||
// updated_at 一律設為 now()。created_at 用 CASE:當 conflict 既有列未刪除時保留
|
||||
// devices.created_at,否則用 EXCLUDED.created_at。
|
||||
//
|
||||
// 重註冊(已 soft-delete 的 serial)走「新 id」→ 不會命中 ON CONFLICT (id),視為 INSERT;
|
||||
// partial unique 因舊列已 deleted 不阻擋(見 package 註解)。
|
||||
func (r *PostgresRepository) Save(ctx context.Context, d *Device) error {
|
||||
if d == nil || d.ID == "" {
|
||||
return errors.New("device: Save requires non-nil device with ID")
|
||||
}
|
||||
|
||||
// remote_status / status 帶預設值,避免空字串寫進「有預設」的 NOT NULL 欄位後語意混淆。
|
||||
remoteStatus := d.RemoteStatus
|
||||
if remoteStatus == "" {
|
||||
remoteStatus = RemoteStatusOffline
|
||||
}
|
||||
status := d.Status
|
||||
if status == "" {
|
||||
status = USBStatusUnknown
|
||||
}
|
||||
|
||||
// created_at:zero 時交給 DB now()(用 NULL 觸發 COALESCE)。
|
||||
var createdAt any
|
||||
if !d.CreatedAt.IsZero() {
|
||||
createdAt = d.CreatedAt.UTC()
|
||||
} // else: 留 nil → COALESCE($n, now())
|
||||
|
||||
const q = `
|
||||
INSERT INTO devices (
|
||||
id, owner_user_id, name, device_type, serial_number,
|
||||
remote_status, last_seen_at, last_connected_at, status,
|
||||
created_at, updated_at, paired_at, deleted_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
$6, $7, $8, $9,
|
||||
COALESCE($10, now()), now(), $11, $12
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
name = EXCLUDED.name,
|
||||
device_type = EXCLUDED.device_type,
|
||||
serial_number = EXCLUDED.serial_number,
|
||||
remote_status = EXCLUDED.remote_status,
|
||||
last_seen_at = EXCLUDED.last_seen_at,
|
||||
last_connected_at = EXCLUDED.last_connected_at,
|
||||
status = EXCLUDED.status,
|
||||
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)則用新值。
|
||||
created_at = CASE
|
||||
WHEN devices.deleted_at IS NULL THEN devices.created_at
|
||||
ELSE EXCLUDED.created_at
|
||||
END,
|
||||
updated_at = now(),
|
||||
paired_at = EXCLUDED.paired_at,
|
||||
deleted_at = EXCLUDED.deleted_at`
|
||||
|
||||
_, err := r.pool.Exec(ctx, q,
|
||||
d.ID, // $1
|
||||
d.OwnerUserID, // $2
|
||||
d.Name, // $3
|
||||
d.DeviceType, // $4
|
||||
d.SerialNumber, // $5
|
||||
remoteStatus, // $6
|
||||
d.LastSeenAt, // $7
|
||||
d.LastConnectedAt, // $8
|
||||
status, // $9
|
||||
createdAt, // $10
|
||||
d.PairedAt, // $11
|
||||
d.DeletedAt, // $12
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("device: pg Save upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。
|
||||
//
|
||||
// 直接在 pool 上跑(自動 commit)。若需與 token cascade 撤銷在同一交易內,請改用 DeleteTx。
|
||||
func (r *PostgresRepository) Delete(ctx context.Context, id string) error {
|
||||
return r.DeleteTx(ctx, r.pool, id)
|
||||
}
|
||||
|
||||
// DeleteTx 與 Delete 相同的軟刪除語意,但在傳入的 Querier(pool 或 tx)上執行。
|
||||
//
|
||||
// 塊 5.2 cascade 撤銷:unpair 流程在 db.WithTx 內先呼叫本方法軟刪 device,再於同一 tx 對
|
||||
// pairing_tokens / session_tokens 撤銷——任一步失敗整筆 rollback,device 不會「已刪但 token 沒撤」。
|
||||
//
|
||||
// q 可為 *pgxpool.Pool(自動 commit)或 pgx.Tx(隨外層交易)。語意與 Delete 一致:
|
||||
// 已刪除或不存在回 ErrNotFound(呼叫端可 errors.Is 比對)。
|
||||
func (r *PostgresRepository) DeleteTx(ctx context.Context, q db.Querier, id string) error {
|
||||
const sql = `UPDATE devices
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
tag, err := q.Exec(ctx, sql, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("device: pg Delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// scan helper
|
||||
// ==========================================================================
|
||||
|
||||
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanDevice 同時服務 Get 與 List。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanDevice 從一列掃出 *Device。欄位順序必須與 deviceColumns 對齊。
|
||||
//
|
||||
// nullable TEXT 欄位(device_type / serial_number)在 DB 為 NULL 時掃進空字串(對齊
|
||||
// in-memory zero value);nullable TIMESTAMPTZ(last_seen_at / last_connected_at /
|
||||
// paired_at / deleted_at)以 *time.Time 接,NULL → nil。
|
||||
func scanDevice(row rowScanner) (*Device, error) {
|
||||
var (
|
||||
d Device
|
||||
deviceType *string
|
||||
serialNumber *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&d.ID,
|
||||
&d.OwnerUserID,
|
||||
&d.Name,
|
||||
&deviceType,
|
||||
&serialNumber,
|
||||
&d.RemoteStatus,
|
||||
&d.LastSeenAt,
|
||||
&d.LastConnectedAt,
|
||||
&d.Status,
|
||||
&d.CreatedAt,
|
||||
&d.UpdatedAt,
|
||||
&d.PairedAt,
|
||||
&d.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.DeviceType = derefString(deviceType)
|
||||
d.SerialNumber = derefString(serialNumber)
|
||||
|
||||
// 正規化時間為 UTC,對齊 in-memory(time.Now().UTC())。
|
||||
d.CreatedAt = d.CreatedAt.UTC()
|
||||
d.UpdatedAt = d.UpdatedAt.UTC()
|
||||
if d.LastSeenAt != nil {
|
||||
t := d.LastSeenAt.UTC()
|
||||
d.LastSeenAt = &t
|
||||
}
|
||||
if d.LastConnectedAt != nil {
|
||||
t := d.LastConnectedAt.UTC()
|
||||
d.LastConnectedAt = &t
|
||||
}
|
||||
if d.PairedAt != nil {
|
||||
t := d.PairedAt.UTC()
|
||||
d.PairedAt = &t
|
||||
}
|
||||
if d.DeletedAt != nil {
|
||||
t := d.DeletedAt.UTC()
|
||||
d.DeletedAt = &t
|
||||
}
|
||||
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// derefString 解指標字串,nil 視為空字串(對齊 in-memory zero value)。
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
426
visionA-backend/internal/device/postgres_repository_db_test.go
Normal file
426
visionA-backend/internal/device/postgres_repository_db_test.go
Normal file
@ -0,0 +1,426 @@
|
||||
//go:build dbtest
|
||||
|
||||
// PostgresRepository(device)的真 DB 整合測試(DB 接入塊 2,子任務 2.5–2.7)。
|
||||
//
|
||||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||||
//
|
||||
// 執行:
|
||||
//
|
||||
// go test -tags=dbtest ./internal/device/...
|
||||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||
// go test -tags=dbtest ./internal/device/...
|
||||
//
|
||||
// 涵蓋:
|
||||
// - 2.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、GetBySerial 跨 owner 不串、
|
||||
// List by owner、soft delete、再刪回 NotFound、保留 CreatedAt、Save 需 ID)。
|
||||
// - 2.6 integration/真 DB:partial unique 衝突(兩筆未刪除同 owner+serial)、partial unique 讓
|
||||
// 已刪 serial 可重註冊、雙狀態欄位 + paired_at round-trip、upsert 保留 CreatedAt。
|
||||
// - 2.7 邊界:空 List、併發註冊同 serial、context cancel。
|
||||
package device
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
// newPGRepo 啟動一次性測試 DB、truncate、確保 demo user 存在,回傳 repo + ownerID。
|
||||
//
|
||||
// 每個測試各自呼叫一次(SetupTestDB 內含 t.Cleanup teardown)。owner 用 testsupport
|
||||
// 的固定 demo user UUID,滿足 devices.owner_user_id 的 FK。
|
||||
func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) {
|
||||
t.Helper()
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "devices", "users")
|
||||
owner := tdb.EnsureDemoUser(t)
|
||||
return NewPostgresRepository(tdb.Pool), tdb, owner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPG_SaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
d := &Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: owner,
|
||||
Name: "Lab KL520",
|
||||
DeviceType: "kl520",
|
||||
SerialNumber: "KL520-AAA",
|
||||
RemoteStatus: RemoteStatusOffline,
|
||||
Status: USBStatusUnknown,
|
||||
}
|
||||
require.NoError(t, r.Save(ctx, d))
|
||||
|
||||
got, err := r.Get(ctx, d.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Lab KL520", got.Name)
|
||||
assert.Equal(t, owner, got.OwnerUserID)
|
||||
assert.Equal(t, "kl520", got.DeviceType)
|
||||
assert.Equal(t, "KL520-AAA", got.SerialNumber)
|
||||
assert.Equal(t, RemoteStatusOffline, got.RemoteStatus)
|
||||
assert.Equal(t, USBStatusUnknown, got.Status)
|
||||
assert.False(t, got.CreatedAt.IsZero())
|
||||
assert.False(t, got.UpdatedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestPG_Get_NotFound(t *testing.T) {
|
||||
r, _, _ := newPGRepo(t)
|
||||
_, err := r.Get(context.Background(), uuid.NewString())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPG_Save_RequiresID(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
assert.Error(t, r.Save(context.Background(), &Device{Name: "no-id", OwnerUserID: owner}))
|
||||
}
|
||||
|
||||
// GetBySerial:跨 owner 同 serial 不互串(owner 過濾)。
|
||||
func TestPG_GetBySerial(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, ownerA := newPGRepo(t)
|
||||
ownerB := tdb.InsertUser(t, "", "")
|
||||
|
||||
idA, idB := uuid.NewString(), uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: idA, OwnerUserID: ownerA, Name: "a", SerialNumber: "S-1"}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: idB, OwnerUserID: ownerB, Name: "b", SerialNumber: "S-1"}))
|
||||
|
||||
gotA, err := r.GetBySerial(ctx, ownerA, "S-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, idA, gotA.ID)
|
||||
|
||||
gotB, err := r.GetBySerial(ctx, ownerB, "S-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, idB, gotB.ID)
|
||||
|
||||
// 不存在的 owner
|
||||
_, err = r.GetBySerial(ctx, uuid.NewString(), "S-1")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
// GetBySerial:soft-delete 後查不到。
|
||||
func TestPG_GetBySerial_SkipsDeleted(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-9"}))
|
||||
require.NoError(t, r.Delete(ctx, id))
|
||||
|
||||
_, err := r.GetBySerial(ctx, owner, "S-9")
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPG_List_ByOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
owner2 := tdb.InsertUser(t, "", "")
|
||||
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "a", SerialNumber: "S-A"}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "b", SerialNumber: "S-B"}))
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", SerialNumber: "S-C"}))
|
||||
|
||||
listOwner, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listOwner, 2)
|
||||
|
||||
listOwner2, err := r.List(ctx, owner2)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, listOwner2, 1)
|
||||
|
||||
// 不存在的 owner
|
||||
listNone, err := r.List(ctx, uuid.NewString())
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, listNone)
|
||||
}
|
||||
|
||||
func TestPG_Delete_SoftDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "x", SerialNumber: "S-D"}))
|
||||
require.NoError(t, r.Delete(ctx, id))
|
||||
|
||||
// Get 不到
|
||||
_, err := r.Get(ctx, id)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// List 不含
|
||||
list, _ := r.List(ctx, owner)
|
||||
assert.Empty(t, list)
|
||||
|
||||
// 重複 Delete 回 ErrNotFound
|
||||
assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2.6 integration/真 DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// partial unique 衝突:兩筆「未刪除」同 (owner, serial)、不同 id → 第二筆撞 unique(23505)。
|
||||
func TestPG_PartialUnique_ActiveConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "first", SerialNumber: "SN-DUP"}))
|
||||
|
||||
// 不同 id、同 owner+serial、皆未刪除 → 違反 uq_devices_owner_serial_active。
|
||||
err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "second", SerialNumber: "SN-DUP"})
|
||||
require.Error(t, err, "兩筆未刪除同 owner+serial 應違反 partial unique")
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
require.ErrorAs(t, err, &pgErr)
|
||||
assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation")
|
||||
assert.Equal(t, "uq_devices_owner_serial_active", pgErr.ConstraintName)
|
||||
}
|
||||
|
||||
// partial unique × soft-delete:已刪 serial 可重新註冊(核心決策 2.3)。
|
||||
func TestPG_PartialUnique_ReRegisterAfterSoftDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
id1 := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id1, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RE"}))
|
||||
require.NoError(t, r.Delete(ctx, id1))
|
||||
|
||||
// 同 owner+serial、新 id → 因舊列已 soft-delete、退出 partial index,重註冊應成功。
|
||||
id2 := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id2, OwnerUserID: owner, Name: "v2", SerialNumber: "SN-RE"}),
|
||||
"已 soft-delete 的 serial 應可重新註冊")
|
||||
|
||||
// GetBySerial 應回新註冊的那筆(未刪除)。
|
||||
got, err := r.GetBySerial(ctx, owner, "SN-RE")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, id2, got.ID)
|
||||
assert.Equal(t, "v2", got.Name)
|
||||
|
||||
// DB 共有兩列(一筆 deleted、一筆 active)。
|
||||
assert.Equal(t, 2, tdb.CountRows(t, "devices"), "重註冊後應有兩列:舊的 soft-deleted + 新的 active")
|
||||
|
||||
// List(未刪除)只回新的一筆。
|
||||
list, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, id2, list[0].ID)
|
||||
}
|
||||
|
||||
// 雙狀態欄位 + paired_at + 時間欄位 round-trip。
|
||||
func TestPG_DualStatus_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
lastSeen := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Microsecond)
|
||||
lastConnected := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Microsecond)
|
||||
paired := time.Now().Add(-1 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
|
||||
id := uuid.NewString()
|
||||
in := &Device{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "dual",
|
||||
DeviceType: "kl720",
|
||||
SerialNumber: "SN-DUAL",
|
||||
RemoteStatus: RemoteStatusReconnecting,
|
||||
LastSeenAt: &lastSeen,
|
||||
LastConnectedAt: &lastConnected,
|
||||
Status: USBStatusOnline,
|
||||
PairedAt: &paired,
|
||||
}
|
||||
require.NoError(t, r.Save(ctx, in))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, RemoteStatusReconnecting, got.RemoteStatus)
|
||||
assert.Equal(t, USBStatusOnline, got.Status)
|
||||
require.NotNil(t, got.LastSeenAt)
|
||||
require.NotNil(t, got.LastConnectedAt)
|
||||
require.NotNil(t, got.PairedAt)
|
||||
assert.True(t, lastSeen.Equal(*got.LastSeenAt), "last_seen_at round-trip")
|
||||
assert.True(t, lastConnected.Equal(*got.LastConnectedAt), "last_connected_at round-trip")
|
||||
assert.True(t, paired.Equal(*got.PairedAt), "paired_at round-trip")
|
||||
}
|
||||
|
||||
// nullable 時間欄位:不帶值寫入,讀回為 nil(對齊 in-memory omitempty 語意)。
|
||||
func TestPG_NullableTimes_RoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "no-times", SerialNumber: "SN-NT"}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got.LastSeenAt)
|
||||
assert.Nil(t, got.LastConnectedAt)
|
||||
assert.Nil(t, got.PairedAt)
|
||||
assert.Nil(t, got.DeletedAt)
|
||||
// 預設值(migration DEFAULT):remote_status='offline'、status='unknown'。
|
||||
assert.Equal(t, RemoteStatusOffline, got.RemoteStatus)
|
||||
assert.Equal(t, USBStatusUnknown, got.Status)
|
||||
}
|
||||
|
||||
// upsert 保留 CreatedAt:第二次 Save(同 id、未刪除)保留首次 created_at、更新其他欄位 + updated_at。
|
||||
func TestPG_Upsert_PreservesCreatedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-UP"}))
|
||||
first, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// 第二次 Save:帶不同(更早)的 CreatedAt,應被忽略而保留 first.CreatedAt。
|
||||
require.NoError(t, r.Save(ctx, &Device{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "v2",
|
||||
SerialNumber: "SN-UP",
|
||||
RemoteStatus: RemoteStatusOnline,
|
||||
Status: USBStatusOnline,
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour).UTC(), // 試圖覆蓋,應被忽略
|
||||
}))
|
||||
second, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "v2", second.Name)
|
||||
assert.Equal(t, RemoteStatusOnline, second.RemoteStatus)
|
||||
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
|
||||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
|
||||
}
|
||||
|
||||
// soft-delete 後再 Save 同 id(復活):採用新 created_at、deleted_at 清回 nil。
|
||||
func TestPG_Upsert_AfterSoftDelete_ResetsCreatedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
oldCreated := time.Now().Add(-100 * time.Hour).UTC()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "v1", SerialNumber: "SN-RV", CreatedAt: oldCreated}))
|
||||
require.NoError(t, r.Delete(ctx, id))
|
||||
|
||||
newCreated := time.Now().UTC()
|
||||
require.NoError(t, r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "revived", SerialNumber: "SN-RV", CreatedAt: newCreated}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "revived", got.Name)
|
||||
assert.Nil(t, got.DeletedAt, "復活後不應仍為 deleted")
|
||||
assert.WithinDuration(t, newCreated, got.CreatedAt, time.Microsecond, "復活後 created_at 應採新值")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2.7 邊界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 空 List:乾淨 DB 回 non-nil 空 slice。
|
||||
func TestPG_List_Empty(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
list, err := r.List(context.Background(), owner)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list)
|
||||
assert.NotNil(t, list, "List 應回 non-nil 空 slice")
|
||||
}
|
||||
|
||||
// 併發註冊同 (owner, serial)、不同 id:partial unique 確保至多一筆成功,其餘撞 23505。
|
||||
// 不應 panic;最終 active 列恰為一筆。
|
||||
func TestPG_ConcurrentRegisterSameSerial(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = r.Save(ctx, &Device{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: owner,
|
||||
Name: "concurrent",
|
||||
SerialNumber: "SN-RACE",
|
||||
})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var ok, conflict int
|
||||
for _, e := range errs {
|
||||
if e == nil {
|
||||
ok++
|
||||
continue
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
require.ErrorAs(t, e, &pgErr, "非 nil error 應為 PgError")
|
||||
assert.Equal(t, "23505", pgErr.Code, "衝突應為 unique_violation")
|
||||
conflict++
|
||||
}
|
||||
assert.Equal(t, 1, ok, "恰一筆成功註冊")
|
||||
assert.Equal(t, n-1, conflict, "其餘皆撞 partial unique")
|
||||
|
||||
// active(未刪除)列恰一筆。
|
||||
list, err := r.List(ctx, owner)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 1, "最終 active device 恰一筆")
|
||||
_ = tdb // tdb 保留供除錯(CountRows)
|
||||
}
|
||||
|
||||
// 併發 Save 同 id:upsert by id,不應 panic;最終單一列、最後內容。
|
||||
func TestPG_ConcurrentSaveSameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = r.Save(ctx, &Device{ID: id, OwnerUserID: owner, Name: "same-id", SerialNumber: "SN-SID"})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
assert.NoError(t, e, "併發 Save 同 id #%d", i)
|
||||
}
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "併發 upsert 同 id 應只有一列")
|
||||
}
|
||||
|
||||
// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。
|
||||
func TestPG_ContextCancel(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
err := r.Save(ctx, &Device{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", SerialNumber: "SN-CC"})
|
||||
assert.Error(t, err, "已取消 ctx 的 Save 應回 error")
|
||||
|
||||
_, err = r.Get(ctx, uuid.NewString())
|
||||
assert.Error(t, err, "已取消 ctx 的 Get 應回 error")
|
||||
|
||||
_, err = r.GetBySerial(ctx, owner, "SN-CC")
|
||||
assert.Error(t, err, "已取消 ctx 的 GetBySerial 應回 error")
|
||||
|
||||
_, err = r.List(ctx, owner)
|
||||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||||
}
|
||||
328
visionA-backend/internal/model/postgres_repository.go
Normal file
328
visionA-backend/internal/model/postgres_repository.go
Normal file
@ -0,0 +1,328 @@
|
||||
// Package model 的 Postgres 持久層實作(DB 接入塊 1)。
|
||||
//
|
||||
// PostgresRepository 實作與 InMemoryRepository 完全相同的 Repository interface,
|
||||
// 讓 main.go 在 cfg.Database.Enabled() 時無痛切換、handler 與呼叫端一行都不需改。
|
||||
//
|
||||
// 對齊:
|
||||
// - database.md §2.3(Model 欄位)、§4(models 表 schema:faa_object_key / uploaded_at +
|
||||
// owner/chip/source filter index + soft-delete partial index)
|
||||
// - migrations/0001_create_users_models.up.sql(models 表已含全部欄位,塊 1 不需新 migration)
|
||||
//
|
||||
// 語意對齊 in-memory(見 model.go):
|
||||
// - Get / List 略過 deleted_at IS NOT NULL 的紀錄。
|
||||
// - Save 為 upsert by ID;existing 且未刪除時保留原 created_at(in-memory model.go ~line 209)。
|
||||
// - Delete 為軟刪除(寫 deleted_at = now());已刪除或不存在回 ErrNotFound。
|
||||
//
|
||||
// array 映射(pgx v5):
|
||||
// - input_shape INT[] <-> []int32(pgx 對 INT[] 預設 decode 成 []int32,Model.InputShape 為 []int)
|
||||
// - classes TEXT[] <-> []string
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// PostgresRepository 是 Model 的 PostgreSQL 持久層實作。
|
||||
type PostgresRepository struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewPostgresRepository 建立一個以 pgxpool 為後端的 Repository。
|
||||
//
|
||||
// pool 由 internal/db 的 NewPool 建立並注入;本套件不持有建池 / 關閉責任。
|
||||
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
|
||||
return &PostgresRepository{pool: pool}
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 PostgresRepository 實作 Repository。
|
||||
var _ Repository = (*PostgresRepository)(nil)
|
||||
|
||||
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
||||
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at`
|
||||
|
||||
// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。
|
||||
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||
const q = `SELECT ` + modelColumns + `
|
||||
FROM models
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
row := r.pool.QueryRow(ctx, q, id)
|
||||
m, err := scanModel(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: pg Get: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// List 依 filter 列出未刪除的 Model。
|
||||
//
|
||||
// filter 三維(OwnerUserID / TargetChip / Source)皆為可選;空字串表示不過濾該維度。
|
||||
// 條件以動態 WHERE 拼接(參數化,無字串拼接使用者輸入),對齊 in-memory List 行為。
|
||||
// 結果以 created_at DESC 排序(最新在前),提供穩定且對前端友善的順序。
|
||||
func (r *PostgresRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
||||
var (
|
||||
conds = []string{"deleted_at IS NULL"}
|
||||
args []any
|
||||
)
|
||||
if filter.OwnerUserID != "" {
|
||||
args = append(args, filter.OwnerUserID)
|
||||
conds = append(conds, fmt.Sprintf("owner_user_id = $%d", len(args)))
|
||||
}
|
||||
if filter.TargetChip != "" {
|
||||
args = append(args, filter.TargetChip)
|
||||
conds = append(conds, fmt.Sprintf("target_chip = $%d", len(args)))
|
||||
}
|
||||
if filter.Source != "" {
|
||||
args = append(args, filter.Source)
|
||||
conds = append(conds, fmt.Sprintf("source = $%d", len(args)))
|
||||
}
|
||||
|
||||
q := `SELECT ` + modelColumns + `
|
||||
FROM models
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY created_at DESC`
|
||||
|
||||
rows, err := r.pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("model: pg List query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]*Model, 0)
|
||||
for rows.Next() {
|
||||
m, scanErr := scanModel(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("model: pg List scan: %w", scanErr)
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("model: pg List rows: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Save 新增或更新 Model(upsert by id)。
|
||||
//
|
||||
// 語意對齊 in-memory(model.go ~line 209):
|
||||
// - 既有且未刪除(deleted_at IS NULL)→ 保留原 created_at;
|
||||
// - 不存在 / 已刪除 → 以傳入 created_at(zero 時用 now())為準。
|
||||
//
|
||||
// updated_at 一律設為 now()。透過 ON CONFLICT (id) DO UPDATE 的 GREATEST/COALESCE 無法
|
||||
// 表達「保留原值僅當未刪除」,故 created_at 用 CASE:當 conflict 既有列未刪除時保留
|
||||
// models.created_at,否則用 EXCLUDED.created_at。
|
||||
func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
||||
if m == nil || m.ID == "" {
|
||||
return errors.New("model: Save requires non-nil model with ID")
|
||||
}
|
||||
|
||||
// created_at:zero 時交給 DB now()(用 NULL 觸發 COALESCE)。
|
||||
var createdAt any
|
||||
if !m.CreatedAt.IsZero() {
|
||||
createdAt = m.CreatedAt.UTC()
|
||||
} // else: 留 nil → COALESCE($n, now())
|
||||
|
||||
// input_shape:Model.InputShape 為 []int,DB 為 INT[]。pgx encode []int 可行;
|
||||
// 為與 decode([]int32)對稱、避免型別歧義,這裡顯式轉 []int32。
|
||||
inputShape := toInt32Slice(m.InputShape)
|
||||
|
||||
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL),
|
||||
// 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。
|
||||
const q = `
|
||||
INSERT INTO models (
|
||||
id, owner_user_id, name, description, storage_key, file_size,
|
||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11, $12,
|
||||
$13, $14, COALESCE($15, now()), now(), $16, $17
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
owner_user_id = EXCLUDED.owner_user_id,
|
||||
name = EXCLUDED.name,
|
||||
description = EXCLUDED.description,
|
||||
storage_key = EXCLUDED.storage_key,
|
||||
file_size = EXCLUDED.file_size,
|
||||
file_checksum = EXCLUDED.file_checksum,
|
||||
faa_object_key = EXCLUDED.faa_object_key,
|
||||
target_chip = EXCLUDED.target_chip,
|
||||
input_shape = EXCLUDED.input_shape,
|
||||
classes = EXCLUDED.classes,
|
||||
framework = EXCLUDED.framework,
|
||||
source = EXCLUDED.source,
|
||||
source_job_id = EXCLUDED.source_job_id,
|
||||
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
||||
created_at = CASE
|
||||
WHEN models.deleted_at IS NULL THEN models.created_at
|
||||
ELSE EXCLUDED.created_at
|
||||
END,
|
||||
updated_at = now(),
|
||||
uploaded_at = EXCLUDED.uploaded_at,
|
||||
deleted_at = EXCLUDED.deleted_at`
|
||||
|
||||
_, err := r.pool.Exec(ctx, q,
|
||||
m.ID, // $1
|
||||
m.OwnerUserID, // $2
|
||||
m.Name, // $3
|
||||
m.Description, // $4
|
||||
m.StorageKey, // $5
|
||||
m.FileSize, // $6
|
||||
m.FileChecksum, // $7
|
||||
m.FAAObjectKey, // $8
|
||||
m.TargetChip, // $9
|
||||
inputShape, // $10
|
||||
m.Classes, // $11
|
||||
m.Framework, // $12
|
||||
string(m.Source), // $13
|
||||
nullableUUID(m.SourceJobID), // $14
|
||||
createdAt, // $15
|
||||
m.UploadedAt, // $16
|
||||
m.DeletedAt, // $17
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model: pg Save upsert: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 軟刪除:寫 deleted_at = now()。已刪除或不存在回 ErrNotFound。
|
||||
func (r *PostgresRepository) Delete(ctx context.Context, id string) error {
|
||||
const q = `UPDATE models
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`
|
||||
|
||||
tag, err := r.pool.Exec(ctx, q, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("model: pg Delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// scan / 型別 helper
|
||||
// ==========================================================================
|
||||
|
||||
// rowScanner 抽象 pgx.Row 與 pgx.Rows 的共同 Scan 介面,讓 scanModel 同時服務 Get 與 List。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanModel 從一列掃出 *Model。欄位順序必須與 modelColumns 對齊。
|
||||
//
|
||||
// nullable 欄位(faa_object_key / target_chip / description / framework / file_checksum)
|
||||
// 在 DB 為 NULL 時掃進空字串;source_job_id(UUID, NULL)以 *string 接再轉空字串;
|
||||
// input_shape(NULL 或空陣列)掃成 nil/空,再轉回 []int。
|
||||
func scanModel(row rowScanner) (*Model, error) {
|
||||
var (
|
||||
m Model
|
||||
description *string
|
||||
fileChecksum *string
|
||||
faaObjectKey *string
|
||||
targetChip *string
|
||||
inputShape []int32
|
||||
framework *string
|
||||
sourceJobID *string
|
||||
)
|
||||
|
||||
err := row.Scan(
|
||||
&m.ID,
|
||||
&m.OwnerUserID,
|
||||
&m.Name,
|
||||
&description,
|
||||
&m.StorageKey,
|
||||
&m.FileSize,
|
||||
&fileChecksum,
|
||||
&faaObjectKey,
|
||||
&targetChip,
|
||||
&inputShape,
|
||||
&m.Classes,
|
||||
&framework,
|
||||
&m.Source,
|
||||
&sourceJobID,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
&m.UploadedAt,
|
||||
&m.DeletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.Description = derefString(description)
|
||||
m.FileChecksum = derefString(fileChecksum)
|
||||
m.FAAObjectKey = derefString(faaObjectKey)
|
||||
m.TargetChip = derefString(targetChip)
|
||||
m.Framework = derefString(framework)
|
||||
m.SourceJobID = derefString(sourceJobID)
|
||||
m.InputShape = toIntSlice(inputShape)
|
||||
|
||||
// 正規化時間為 UTC,對齊 in-memory(time.Now().UTC())。
|
||||
m.CreatedAt = m.CreatedAt.UTC()
|
||||
m.UpdatedAt = m.UpdatedAt.UTC()
|
||||
if m.UploadedAt != nil {
|
||||
u := m.UploadedAt.UTC()
|
||||
m.UploadedAt = &u
|
||||
}
|
||||
if m.DeletedAt != nil {
|
||||
d := m.DeletedAt.UTC()
|
||||
m.DeletedAt = &d
|
||||
}
|
||||
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// derefString 解指標字串,nil 視為空字串(對齊 in-memory zero value)。
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// nullableUUID 把可能為空的 source_job_id(UUID 欄位)轉成寫入值:
|
||||
// 空字串 → nil(寫 NULL,避免空字串無法 cast 成 UUID 報錯);否則原樣傳入。
|
||||
func nullableUUID(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toInt32Slice 把 []int 轉 []int32(pgx INT[] encode 用)。nil 維持 nil(寫 NULL)。
|
||||
func toInt32Slice(in []int) []int32 {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]int32, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = int32(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toIntSlice 把 DB decode 出的 []int32 轉回 []int。nil/空維持 nil(對齊 in-memory omitempty)。
|
||||
func toIntSlice(in []int32) []int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = int(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
350
visionA-backend/internal/model/postgres_repository_db_test.go
Normal file
350
visionA-backend/internal/model/postgres_repository_db_test.go
Normal file
@ -0,0 +1,350 @@
|
||||
//go:build dbtest
|
||||
|
||||
// PostgresRepository 的真 DB 整合測試(DB 接入塊 1,子任務 1.5–1.7)。
|
||||
//
|
||||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||||
//
|
||||
// 執行:
|
||||
//
|
||||
// go test -tags=dbtest ./internal/model/...
|
||||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||
// go test -tags=dbtest ./internal/model/...
|
||||
//
|
||||
// 涵蓋:
|
||||
// - 1.5 unit/邏輯:對齊既有 inmemory_repository_test.go(SaveAndGet、NotFound、List 三 filter、
|
||||
// soft delete、Save 需 ID)。
|
||||
// - 1.6 integration/真 DB:array round-trip、upsert 保留 CreatedAt、soft-delete 後 List 不含、
|
||||
// List filter SQL 正確性、faa_object_key nullable round-trip。
|
||||
// - 1.7 邊界:空 List、重複 Save、併發 Save 同 ID、context cancel。
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/db/testsupport"
|
||||
)
|
||||
|
||||
// newPGRepo 啟動一次性測試 DB、truncate、確保 demo user 存在,回傳 repo + ownerID。
|
||||
//
|
||||
// 每個測試各自呼叫一次(SetupTestDB 內含 t.Cleanup teardown)。owner 用 testsupport
|
||||
// 的固定 demo user UUID,滿足 models.owner_user_id 的 FK。
|
||||
func newPGRepo(t *testing.T) (*PostgresRepository, *testsupport.TestDB, string) {
|
||||
t.Helper()
|
||||
tdb := testsupport.SetupTestDB(t)
|
||||
tdb.Truncate(t, "models", "users")
|
||||
owner := tdb.EnsureDemoUser(t)
|
||||
return NewPostgresRepository(tdb.Pool), tdb, owner
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1.5 unit/邏輯(對齊 inmemory_repository_test.go 的 case)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestPG_SaveAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
m := &Model{
|
||||
ID: uuid.NewString(),
|
||||
OwnerUserID: owner,
|
||||
Name: "yolo-v5",
|
||||
StorageKey: "models/" + owner + "/m-1.nef",
|
||||
FileSize: 1024 * 1024,
|
||||
Source: SourceUploaded,
|
||||
TargetChip: "kl520",
|
||||
}
|
||||
require.NoError(t, r.Save(ctx, m))
|
||||
|
||||
got, err := r.Get(ctx, m.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "yolo-v5", got.Name)
|
||||
assert.Equal(t, owner, got.OwnerUserID)
|
||||
assert.Equal(t, int64(1024*1024), got.FileSize)
|
||||
assert.Equal(t, SourceUploaded, got.Source)
|
||||
assert.False(t, got.CreatedAt.IsZero())
|
||||
assert.False(t, got.UpdatedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestPG_Get_NotFound(t *testing.T) {
|
||||
r, _, _ := newPGRepo(t)
|
||||
_, err := r.Get(context.Background(), uuid.NewString())
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPG_List_Filter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
// 第二個 owner,驗證 owner 過濾。
|
||||
owner2 := tdb.InsertUser(t, "", "")
|
||||
|
||||
id1, id2 := uuid.NewString(), uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id1, OwnerUserID: owner, Name: "a", StorageKey: "k1", Source: SourceUploaded, TargetChip: "kl520"}))
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id2, OwnerUserID: owner, Name: "b", StorageKey: "k2", Source: SourceConverted, TargetChip: "kl720"}))
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: uuid.NewString(), OwnerUserID: owner2, Name: "c", StorageKey: "k3", Source: SourceUploaded, TargetChip: "kl520"}))
|
||||
|
||||
// 依 owner 過濾
|
||||
list, err := r.List(ctx, ListFilter{OwnerUserID: owner})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 2)
|
||||
|
||||
// 依 chip 過濾(owner+chip 複合)
|
||||
list, err = r.List(ctx, ListFilter{OwnerUserID: owner, TargetChip: "kl520"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, id1, list[0].ID)
|
||||
|
||||
// 依 source 過濾(不限 owner)
|
||||
list, err = r.List(ctx, ListFilter{Source: SourceConverted})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, id2, list[0].ID)
|
||||
|
||||
// 無 filter(admin)
|
||||
list, err = r.List(ctx, ListFilter{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, list, 3)
|
||||
|
||||
// owner + source 複合
|
||||
list, err = r.List(ctx, ListFilter{OwnerUserID: owner, Source: SourceUploaded})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, list, 1)
|
||||
assert.Equal(t, id1, list[0].ID)
|
||||
}
|
||||
|
||||
func TestPG_Delete_SoftDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "x", StorageKey: "k"}))
|
||||
require.NoError(t, r.Delete(ctx, id))
|
||||
|
||||
// Get 不到
|
||||
_, err := r.Get(ctx, id)
|
||||
assert.ErrorIs(t, err, ErrNotFound)
|
||||
|
||||
// List 不含
|
||||
list, _ := r.List(ctx, ListFilter{OwnerUserID: owner})
|
||||
assert.Empty(t, list)
|
||||
|
||||
// 重複 Delete 回 ErrNotFound
|
||||
assert.ErrorIs(t, r.Delete(ctx, id), ErrNotFound)
|
||||
}
|
||||
|
||||
func TestPG_Save_RequiresID(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
assert.Error(t, r.Save(context.Background(), &Model{Name: "no-id", OwnerUserID: owner}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1.6 integration/真 DB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// array round-trip:input_shape INT[] / classes TEXT[] 寫入後讀回相等。
|
||||
func TestPG_ArrayRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
in := &Model{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "with-arrays",
|
||||
StorageKey: "k",
|
||||
Source: SourceConverted,
|
||||
InputShape: []int{1, 3, 224, 224},
|
||||
Classes: []string{"face", "person", "car"},
|
||||
Framework: "onnx",
|
||||
}
|
||||
require.NoError(t, r.Save(ctx, in))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []int{1, 3, 224, 224}, got.InputShape)
|
||||
assert.Equal(t, []string{"face", "person", "car"}, got.Classes)
|
||||
assert.Equal(t, "onnx", got.Framework)
|
||||
}
|
||||
|
||||
// 空 / nil array:寫入 nil,讀回應為 nil(對齊 in-memory omitempty 語意)。
|
||||
func TestPG_ArrayNilRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "no-arrays", StorageKey: "k", Source: SourceUploaded}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got.InputShape)
|
||||
assert.Nil(t, got.Classes)
|
||||
}
|
||||
|
||||
// upsert 保留 CreatedAt:第二次 Save(同 ID、未刪除)保留首次 created_at、更新其他欄位 + updated_at。
|
||||
func TestPG_Upsert_PreservesCreatedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded}))
|
||||
first, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 稍候,確保 updated_at 會推進。
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// 第二次 Save:帶不同(更早 / 不同)的 CreatedAt,應被忽略而保留 first.CreatedAt。
|
||||
require.NoError(t, r.Save(ctx, &Model{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "v2",
|
||||
StorageKey: "k2",
|
||||
Source: SourceUploaded,
|
||||
CreatedAt: time.Now().Add(-72 * time.Hour).UTC(), // 試圖覆蓋,應被忽略
|
||||
}))
|
||||
second, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "v2", second.Name)
|
||||
assert.Equal(t, "k2", second.StorageKey)
|
||||
assert.WithinDuration(t, first.CreatedAt, second.CreatedAt, time.Microsecond, "created_at 應保留首次值")
|
||||
assert.True(t, second.UpdatedAt.After(first.UpdatedAt) || second.UpdatedAt.Equal(first.UpdatedAt), "updated_at 應推進")
|
||||
}
|
||||
|
||||
// soft-delete 後再 Save 同 ID(復活):應採用新 created_at(非保留已刪除的舊值)。
|
||||
func TestPG_Upsert_AfterSoftDelete_ResetsCreatedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
oldCreated := time.Now().Add(-100 * time.Hour).UTC()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "v1", StorageKey: "k", Source: SourceUploaded, CreatedAt: oldCreated}))
|
||||
require.NoError(t, r.Delete(ctx, id))
|
||||
|
||||
// 復活:deleted_at 仍在 DB(被 EXCLUDED.deleted_at=NULL 清掉),created_at 用新傳入值。
|
||||
newCreated := time.Now().UTC()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "revived", StorageKey: "k", Source: SourceUploaded, CreatedAt: newCreated}))
|
||||
|
||||
got, err := r.Get(ctx, id)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "revived", got.Name)
|
||||
assert.Nil(t, got.DeletedAt, "復活後不應仍為 deleted")
|
||||
assert.WithinDuration(t, newCreated, got.CreatedAt, time.Microsecond, "復活後 created_at 應採新值")
|
||||
}
|
||||
|
||||
// faa_object_key nullable round-trip:有值寫入讀回相等;空值讀回空字串。
|
||||
func TestPG_FAAObjectKey_NullableRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
// 有值(converted 類)
|
||||
idWith := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{
|
||||
ID: idWith, OwnerUserID: owner, Name: "converted", StorageKey: "k",
|
||||
Source: SourceConverted, FAAObjectKey: "models/" + owner + "/job-1.nef",
|
||||
}))
|
||||
gotWith, err := r.Get(ctx, idWith)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "models/"+owner+"/job-1.nef", gotWith.FAAObjectKey)
|
||||
|
||||
// 空值(uploaded 類)— 讀回空字串。
|
||||
idWithout := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "uploaded", StorageKey: "k", Source: SourceUploaded}))
|
||||
gotWithout, err := r.Get(ctx, idWithout)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", gotWithout.FAAObjectKey)
|
||||
}
|
||||
|
||||
// source_job_id(UUID nullable):有值 / 空值 round-trip。
|
||||
func TestPG_SourceJobID_NullableRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
jobID := uuid.NewString()
|
||||
idWith := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: idWith, OwnerUserID: owner, Name: "j", StorageKey: "k", Source: SourceConverted, SourceJobID: jobID}))
|
||||
gotWith, err := r.Get(ctx, idWith)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, jobID, gotWith.SourceJobID)
|
||||
|
||||
idWithout := uuid.NewString()
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: idWithout, OwnerUserID: owner, Name: "n", StorageKey: "k", Source: SourceUploaded}))
|
||||
gotWithout, err := r.Get(ctx, idWithout)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", gotWithout.SourceJobID)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1.7 邊界
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 空 List:乾淨 DB 回空 slice(非 nil error)。
|
||||
func TestPG_List_Empty(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
list, err := r.List(context.Background(), ListFilter{OwnerUserID: owner})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, list)
|
||||
assert.NotNil(t, list, "List 應回 non-nil 空 slice")
|
||||
}
|
||||
|
||||
// 重複 Save 同 ID(未刪除):不報錯,最終為單一列、最後一次內容。
|
||||
func TestPG_Save_Duplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
for i := 0; i < 5; i++ {
|
||||
require.NoError(t, r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "dup", StorageKey: "k", Source: SourceUploaded}))
|
||||
}
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "models"), "重複 upsert 同 ID 應只有一列")
|
||||
}
|
||||
|
||||
// 併發 Save 同 ID:不應 panic / FK 衝突;最終仍為單一列。
|
||||
func TestPG_Save_ConcurrentSameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
r, tdb, owner := newPGRepo(t)
|
||||
|
||||
id := uuid.NewString()
|
||||
const n = 20
|
||||
var wg sync.WaitGroup
|
||||
errs := make([]error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
errs[i] = r.Save(ctx, &Model{ID: id, OwnerUserID: owner, Name: "concurrent", StorageKey: "k", Source: SourceUploaded})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
assert.NoError(t, e, "併發 Save #%d", i)
|
||||
}
|
||||
assert.Equal(t, 1, tdb.CountRows(t, "models"), "併發 upsert 同 ID 應只有一列")
|
||||
}
|
||||
|
||||
// context cancel:已取消的 ctx 應讓操作回 error(不 hang、不 panic)。
|
||||
func TestPG_ContextCancel(t *testing.T) {
|
||||
r, _, owner := newPGRepo(t)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // 立即取消
|
||||
|
||||
err := r.Save(ctx, &Model{ID: uuid.NewString(), OwnerUserID: owner, Name: "x", StorageKey: "k", Source: SourceUploaded})
|
||||
assert.Error(t, err, "已取消 ctx 的 Save 應回 error")
|
||||
|
||||
_, err = r.Get(ctx, uuid.NewString())
|
||||
assert.Error(t, err, "已取消 ctx 的 Get 應回 error")
|
||||
|
||||
_, err = r.List(ctx, ListFilter{OwnerUserID: owner})
|
||||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||||
}
|
||||
386
visionA-backend/internal/usersession/redis.go
Normal file
386
visionA-backend/internal/usersession/redis.go
Normal 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 key,key 的 Redis TTL 設為
|
||||
// min(idleTTL, 距 absolute deadline 的剩餘時間)。
|
||||
// 這樣 key 在「閒置超過 idle」或「建立超過 absolute」任一條件成立時都會被 Redis 自動清掉,
|
||||
// 不需要 background goroutine 掃描。
|
||||
//
|
||||
// - **absolute deadline 精準防護**:CreatedAt 存進 value,Get 時再算一次
|
||||
// now - CreatedAt > absolute,雙重保險(即使因為時鐘 / 續期計算誤差讓 key 多活一瞬間,
|
||||
// Get 仍會視為過期回 ErrNoSession 並順手刪除)。
|
||||
//
|
||||
// - **Extra map JSON 序列化**:整個 Session 用 encoding/json 序列化進 value,
|
||||
// Extra map[string]any 自然被序列化。caller 須自我約束放可 JSON 序列化的型別
|
||||
// (usersession.go Session.Extra 註解已載明此約束)。
|
||||
//
|
||||
// 並發安全:所有方法都是單一 Redis 指令或 store 內無共享 mutable state,go-redis client
|
||||
// 本身並發安全。
|
||||
//
|
||||
// 安全:OIDCCodeVerifier / AccessToken / IDTokenRaw 等敏感欄位雖序列化進 Redis value,
|
||||
// 但不會進入任何 log(store 內不 log value)。
|
||||
type RedisUserSessionStore struct {
|
||||
client redis.UniversalClient
|
||||
|
||||
// idleTTL / absoluteTTL 在建構時固定(來自 UserSessionConfig.IdleTTL / AbsoluteTTL)。
|
||||
//
|
||||
// 與 InMemoryStore.CleanupExpired(ctx, idle, abs) 把 timeout 當參數傳不同:Redis 版的 TTL
|
||||
// 必須在「寫入 key 當下」就決定(Redis TTL 是 per-key 設定),所以 store 自己持有這兩個值。
|
||||
idleTTL time.Duration
|
||||
absoluteTTL time.Duration
|
||||
}
|
||||
|
||||
// 編譯期確認 RedisUserSessionStore 滿足 Store interface。
|
||||
var _ Store = (*RedisUserSessionStore)(nil)
|
||||
|
||||
// redisSession 是寫入 Redis value 的序列化結構。
|
||||
//
|
||||
// 用獨立 struct(而非直接序列化 Session)的理由:
|
||||
// - 明確控制哪些欄位落地、用穩定的 JSON key(Session struct 沒有 json tag,
|
||||
// 直接序列化會用 Go 欄位名,未來改欄位名會破壞既有 value 相容性)。
|
||||
// - 時間用 UnixNano 存,跨程序 / 跨機器無時區歧義,且 Get 算 absolute deadline 直接做整數運算。
|
||||
type redisSession struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"uid,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CreatedAtUnix int64 `json:"created_at"`
|
||||
LastSeenAtUnix int64 `json:"last_seen_at"`
|
||||
OIDCState string `json:"oidc_state,omitempty"`
|
||||
OIDCNonce string `json:"oidc_nonce,omitempty"`
|
||||
OIDCCodeVerifier string `json:"oidc_cv,omitempty"`
|
||||
AccessToken string `json:"access_token,omitempty"`
|
||||
IDTokenRaw string `json:"id_token_raw,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// NewRedisUserSessionStore 建立 Redis-backed 的 user session store。
|
||||
//
|
||||
// client 為已連線的 go-redis client(main.go 傳 db.RedisClient.Client();測試傳 miniredis client)。
|
||||
// idleTTL / absoluteTTL 來自 cfg.UserSession.IdleTTL(預設 24h)/ AbsoluteTTL(預設 168h)。
|
||||
//
|
||||
// idleTTL / absoluteTTL <= 0 視為「該維度不過期」:
|
||||
// - idleTTL <= 0 → key 不設 idle 上限(仍受 absolute 限制)。
|
||||
// - absoluteTTL <= 0 → 無 absolute 上限(key 只受 idle 限制)。
|
||||
// - 兩者皆 <= 0 → key 永不過期(PERSIST 語意;正式環境不應如此設定,僅測試 / 特例用)。
|
||||
func NewRedisUserSessionStore(client redis.UniversalClient, idleTTL, absoluteTTL time.Duration) *RedisUserSessionStore {
|
||||
return &RedisUserSessionStore{
|
||||
client: client,
|
||||
idleTTL: idleTTL,
|
||||
absoluteTTL: absoluteTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func redisKey(id string) string {
|
||||
return redisKeyPrefix + id
|
||||
}
|
||||
|
||||
// effectiveTTL 計算「寫入 key 當下」應設的 Redis TTL。
|
||||
//
|
||||
// now = 現在時間
|
||||
// createdAt = session 建立時間(absolute deadline 的錨點)
|
||||
//
|
||||
// 回傳值語意(go-redis SET 的 expiration 參數):
|
||||
// - > 0 → 設這個 TTL
|
||||
// - == 0 → 不過期(redis.KeepTTL 不適用 SET 新值,故用特殊處理:呼叫端用 0 代表 PERSIST)
|
||||
//
|
||||
// 計算:取 min(idleTTL, 距 absolute deadline 的剩餘)。
|
||||
// - idleTTL <= 0:不考慮 idle,只剩 absolute remaining。
|
||||
// - absoluteTTL <= 0:不考慮 absolute,只剩 idleTTL。
|
||||
// - 兩者皆 <= 0:回 0(永不過期)。
|
||||
// - 若有 absolute 上限且 absolute remaining <= 0(已到 / 超過 absolute):回 <=0。
|
||||
//
|
||||
// ⚠️ 回傳 0 有兩種語意,呼叫端(save)**必須**搭配 s.absoluteTTL 才能區分:
|
||||
// - s.absoluteTTL == 0 → 0 代表合法 PERSIST(永不過期)。
|
||||
// - s.absoluteTTL > 0 → 0(或 <0)代表已到 absolute deadline、應視為過期。
|
||||
//
|
||||
// 因此 save 用 `ttl <= 0 && s.absoluteTTL > 0` 判定過期,而非只看 `ttl < 0`,
|
||||
// 避免 absRemaining 恰好對齊到 0 ns 時繞過 absolute 上限。
|
||||
func (s *RedisUserSessionStore) effectiveTTL(now, createdAt time.Time) time.Duration {
|
||||
hasIdle := s.idleTTL > 0
|
||||
hasAbs := s.absoluteTTL > 0
|
||||
|
||||
switch {
|
||||
case !hasIdle && !hasAbs:
|
||||
return 0 // 永不過期
|
||||
case hasIdle && !hasAbs:
|
||||
return s.idleTTL
|
||||
case !hasIdle && hasAbs:
|
||||
return createdAt.Add(s.absoluteTTL).Sub(now)
|
||||
default:
|
||||
absRemaining := createdAt.Add(s.absoluteTTL).Sub(now)
|
||||
if absRemaining < s.idleTTL {
|
||||
return absRemaining
|
||||
}
|
||||
return s.idleTTL
|
||||
}
|
||||
}
|
||||
|
||||
// save 序列化 redisSession 並用計算出的 TTL 寫入 Redis。
|
||||
//
|
||||
// expectExists:true 時用 SET ... XX(key 必須已存在才寫,供 Update 防「順便建立」);
|
||||
// false 時普通 SET(供 Create)。
|
||||
//
|
||||
// 回傳 (false, nil) 代表 XX 條件不成立(key 不存在),由 Update 翻譯成 ErrNoSession。
|
||||
func (s *RedisUserSessionStore) save(ctx context.Context, rs *redisSession, now time.Time, expectExists bool) (ok bool, err error) {
|
||||
ttl := s.effectiveTTL(now, time.Unix(0, rs.CreatedAtUnix))
|
||||
// effectiveTTL == 0 的兩種語意必須分流,否則「有 absolute 上限但 absRemaining 恰好
|
||||
// 對齊到 0 ns」會被誤判成 PERSIST,讓該 key 永不過期、繞過 absolute 上限:
|
||||
// - s.absoluteTTL == 0 時,0 代表合法 PERSIST(idle+absolute 皆停用)→ 放行寫入。
|
||||
// - s.absoluteTTL > 0 時,<=0 代表已到(或超過)absolute deadline → 不應寫入,
|
||||
// 視為過期(Update 翻譯成 ErrNoSession)。用 <= 同時涵蓋 <0 與 ==0。
|
||||
if ttl <= 0 && s.absoluteTTL > 0 {
|
||||
// 已到 / 超過 absolute deadline,不應再寫入(Update 視為過期)。
|
||||
return false, ErrSessionExpired
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(rs)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("usersession: marshal session: %w", err)
|
||||
}
|
||||
|
||||
// ttl == 0 → 永不過期(redis SET 不帶 expiration)。
|
||||
args := redis.SetArgs{}
|
||||
if ttl > 0 {
|
||||
args.TTL = ttl
|
||||
}
|
||||
if expectExists {
|
||||
args.Mode = "XX"
|
||||
}
|
||||
|
||||
// SetArgs 回傳 redis.Nil 代表 XX 不成立(key 不存在)。
|
||||
res, err := s.client.SetArgs(ctx, redisKey(rs.ID), payload, args).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return false, nil // XX 條件不成立
|
||||
}
|
||||
return false, fmt.Errorf("usersession: redis set: %w", err)
|
||||
}
|
||||
_ = res
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Create 實作 Store.Create。產生隨機 ID 的新 session 並寫入 Redis(TTL = idle 與 absolute 取小)。
|
||||
func (s *RedisUserSessionStore) Create(ctx context.Context) (*Session, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := generateSessionID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := nowFunc()
|
||||
sess := &Session{
|
||||
ID: id,
|
||||
CreatedAt: now,
|
||||
LastSeenAt: now,
|
||||
}
|
||||
|
||||
rs := toRedisSession(sess)
|
||||
ok, err := s.save(ctx, rs, now, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
// 普通 SET(非 XX)理論上不會回 !ok;保險處理為 internal error。
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
return copySessionValue(sess), nil
|
||||
}
|
||||
|
||||
// Get 實作 Store.Get。
|
||||
//
|
||||
// 找不到(含已被 Redis TTL 清掉)回 ErrNoSession。**不**續期 idle TTL(對齊 InMemoryStore:
|
||||
// Get 不更新 LastSeenAt,避免無條件刷新延長 idle window)。
|
||||
//
|
||||
// absolute 精準防護:取出後再算一次 now - CreatedAt > absolute,超過則 Delete + 回 ErrNoSession。
|
||||
func (s *RedisUserSessionStore) Get(ctx context.Context, id string) (*Session, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id == "" {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
val, err := s.client.Get(ctx, redisKey(id)).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
return nil, fmt.Errorf("usersession: redis get: %w", err)
|
||||
}
|
||||
|
||||
var rs redisSession
|
||||
if err := json.Unmarshal([]byte(val), &rs); err != nil {
|
||||
return nil, fmt.Errorf("usersession: unmarshal session: %w", err)
|
||||
}
|
||||
sess := fromRedisSession(&rs)
|
||||
|
||||
// absolute deadline 精準防護(雙重保險,理論上 key 早被 TTL 清掉)。
|
||||
if s.absoluteTTL > 0 && nowFunc().Sub(sess.CreatedAt) > s.absoluteTTL {
|
||||
_ = s.client.Del(ctx, redisKey(id)).Err()
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// Update 實作 Store.Update。
|
||||
//
|
||||
// 把 caller 改過的 Session 寫回 Redis、LastSeenAt 設 now、並續期 idle TTL(同時仍受 absolute 上限)。
|
||||
// 找不到 ID(含已過期被清)回 ErrNoSession,不會「順便建立」(用 SET XX 達成)。
|
||||
func (s *RedisUserSessionStore) Update(ctx context.Context, sess *Session) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if sess == nil || sess.ID == "" {
|
||||
return ErrNoSession
|
||||
}
|
||||
|
||||
now := nowFunc()
|
||||
|
||||
// 先確認既有 key 存在並取得 CreatedAt(caller 傳進來的 CreatedAt 可能被誤改;
|
||||
// 以 store 內既有值為準,對齊 InMemoryStore「Update 不改 CreatedAt」語意)。
|
||||
existing, err := s.client.Get(ctx, redisKey(sess.ID)).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return ErrNoSession
|
||||
}
|
||||
return fmt.Errorf("usersession: redis get (update): %w", err)
|
||||
}
|
||||
var existingRS redisSession
|
||||
if err := json.Unmarshal([]byte(existing), &existingRS); err != nil {
|
||||
return fmt.Errorf("usersession: unmarshal session (update): %w", err)
|
||||
}
|
||||
|
||||
// absolute 已過 → 視為不存在(順手刪)。
|
||||
createdAt := time.Unix(0, existingRS.CreatedAtUnix)
|
||||
if s.absoluteTTL > 0 && now.Sub(createdAt) > s.absoluteTTL {
|
||||
_ = s.client.Del(ctx, redisKey(sess.ID)).Err()
|
||||
return ErrNoSession
|
||||
}
|
||||
|
||||
rs := toRedisSession(sess)
|
||||
rs.CreatedAtUnix = existingRS.CreatedAtUnix // 保留既有 CreatedAt
|
||||
rs.LastSeenAtUnix = now.UnixNano() // 續期:LastSeenAt = now
|
||||
|
||||
ok, err := s.save(ctx, rs, now, true) // XX:key 必須仍存在
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSessionExpired) {
|
||||
// effectiveTTL <= 0 且有 absolute 上限:已到 / 超過 absolute,視為不存在。
|
||||
_ = s.client.Del(ctx, redisKey(sess.ID)).Err()
|
||||
return ErrNoSession
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
// XX 不成立:key 在 Get 與 SET 之間剛好過期。
|
||||
return ErrNoSession
|
||||
}
|
||||
|
||||
// 把更新後的 LastSeenAt 反映回 caller pointer(對齊 InMemoryStore.Update)。
|
||||
sess.LastSeenAt = time.Unix(0, rs.LastSeenAtUnix)
|
||||
sess.CreatedAt = createdAt
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 實作 Store.Delete。不存在為 no-op(DEL 對不存在 key 回 0,不報錯)。
|
||||
func (s *RedisUserSessionStore) Delete(ctx context.Context, id string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
if err := s.client.Del(ctx, redisKey(id)).Err(); err != nil {
|
||||
return fmt.Errorf("usersession: redis del: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupExpired 實作 Store.CleanupExpired。
|
||||
//
|
||||
// Redis 版**靠 key TTL 自動過期**,不需 background 掃描;此方法在 Redis 模式下基本是 no-op,
|
||||
// 保留只為滿足 Store interface(in-memory fallback 仍會用到)。
|
||||
//
|
||||
// 回傳 (0, nil):Redis 已自動清掉過期 key,無「本次清除數量」可報。
|
||||
// 參數 idleTimeout / absoluteTimeout 被忽略(TTL 已在寫入時依 store 持有的 idle/absolute 設定)。
|
||||
func (s *RedisUserSessionStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// no-op:Redis TTL 負責過期。
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// toRedisSession 把 Session 轉成可序列化的 redisSession(時間轉 UnixNano)。
|
||||
func toRedisSession(sess *Session) *redisSession {
|
||||
return &redisSession{
|
||||
ID: sess.ID,
|
||||
UserID: sess.UserID,
|
||||
Email: sess.Email,
|
||||
Name: sess.Name,
|
||||
CreatedAtUnix: sess.CreatedAt.UnixNano(),
|
||||
LastSeenAtUnix: sess.LastSeenAt.UnixNano(),
|
||||
OIDCState: sess.OIDCState,
|
||||
OIDCNonce: sess.OIDCNonce,
|
||||
OIDCCodeVerifier: sess.OIDCCodeVerifier,
|
||||
AccessToken: sess.AccessToken,
|
||||
IDTokenRaw: sess.IDTokenRaw,
|
||||
Extra: sess.Extra,
|
||||
}
|
||||
}
|
||||
|
||||
// fromRedisSession 把序列化結構還原成 Session(UnixNano 轉 time.Time)。
|
||||
func fromRedisSession(rs *redisSession) *Session {
|
||||
return &Session{
|
||||
ID: rs.ID,
|
||||
UserID: rs.UserID,
|
||||
Email: rs.Email,
|
||||
Name: rs.Name,
|
||||
CreatedAt: time.Unix(0, rs.CreatedAtUnix),
|
||||
LastSeenAt: time.Unix(0, rs.LastSeenAtUnix),
|
||||
OIDCState: rs.OIDCState,
|
||||
OIDCNonce: rs.OIDCNonce,
|
||||
OIDCCodeVerifier: rs.OIDCCodeVerifier,
|
||||
AccessToken: rs.AccessToken,
|
||||
IDTokenRaw: rs.IDTokenRaw,
|
||||
Extra: rs.Extra,
|
||||
}
|
||||
}
|
||||
|
||||
// copySessionValue 製作 Session 的副本(含 Extra map 深一層),避免 caller 改到 store 回傳值。
|
||||
//
|
||||
// 與 InMemoryStore.copySession 等價,但為 free function(Redis store 無需持鎖)。
|
||||
func copySessionValue(src *Session) *Session {
|
||||
dst := *src
|
||||
if src.Extra != nil {
|
||||
dst.Extra = make(map[string]any, len(src.Extra))
|
||||
for k, v := range src.Extra {
|
||||
dst.Extra[k] = v
|
||||
}
|
||||
}
|
||||
return &dst
|
||||
}
|
||||
169
visionA-backend/internal/usersession/redis_integration_test.go
Normal file
169
visionA-backend/internal/usersession/redis_integration_test.go
Normal file
@ -0,0 +1,169 @@
|
||||
//go:build dbtest
|
||||
|
||||
// DB 接入塊 4(4.6 真 Redis 部分):RedisUserSessionStore 對「真 Redis」的整合測試。
|
||||
//
|
||||
// build tag `dbtest`:預設 `go test ./...` 不編譯本檔。
|
||||
// 執行方式(二擇一):
|
||||
//
|
||||
// 1. 連既有 Redis(例如 130 上的 visiona-redis,從 130 內部或經 DOCKER_HOST 轉發):
|
||||
// VISIONA_TEST_REDIS_ADDR=visiona-redis:6379 go test -tags=dbtest ./internal/usersession/...
|
||||
// (無密碼;若有密碼設 VISIONA_TEST_REDIS_PASSWORD)
|
||||
//
|
||||
// 2. 用 testcontainers 自動起一次性 Redis(需 Docker daemon;本機無 docker 時用
|
||||
// DOCKER_HOST=tcp://192.168.0.130:2375 指向 130 的 docker):
|
||||
// go test -tags=dbtest ./internal/usersession/...
|
||||
//
|
||||
// 本檔與 redis_test.go(miniredis,預設可跑)互補:miniredis 已驗雙 TTL 邏輯,
|
||||
// 本檔驗「真 Redis 真的會在 TTL 到期時清掉 key」+ 序列化 round-trip 在真 Redis 下成立。
|
||||
package usersession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
// realRedisClient 取得連到真 Redis 的 client:
|
||||
// - 若設了 VISIONA_TEST_REDIS_ADDR → 直連該位址(130 visiona-redis 補跑路徑)。
|
||||
// - 否則 → testcontainers 起一次性 redis:7-alpine。
|
||||
func realRedisClient(t *testing.T) *redis.Client {
|
||||
t.Helper()
|
||||
|
||||
if addr := os.Getenv("VISIONA_TEST_REDIS_ADDR"); addr != "" {
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: os.Getenv("VISIONA_TEST_REDIS_PASSWORD"),
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
t.Fatalf("ping VISIONA_TEST_REDIS_ADDR=%s: %v", addr, err)
|
||||
}
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
req := testcontainers.ContainerRequest{
|
||||
Image: "redis:7-alpine",
|
||||
ExposedPorts: []string{"6379/tcp"},
|
||||
WaitingFor: wait.ForListeningPort("6379/tcp").WithStartupTimeout(60 * time.Second),
|
||||
}
|
||||
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||
ContainerRequest: req,
|
||||
Started: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start redis container: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = container.Terminate(ctx) })
|
||||
|
||||
host, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("container host: %v", err)
|
||||
}
|
||||
port, err := container.MappedPort(ctx, "6379/tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("container port: %v", err)
|
||||
}
|
||||
client := redis.NewClient(&redis.Options{Addr: host + ":" + port.Port()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
// TestRealRedis_RoundTripAndIsolation 驗證所有欄位在真 Redis 下序列化 round-trip 正確,
|
||||
// 且 key 帶 prefix、互不干擾。
|
||||
func TestRealRedis_RoundTripAndIsolation(t *testing.T) {
|
||||
client := realRedisClient(t)
|
||||
store := NewRedisUserSessionStore(client, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := store.Create(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
sess.UserID = "u-real"
|
||||
sess.Email = "real@example.com"
|
||||
sess.OIDCCodeVerifier = "cv-secret"
|
||||
sess.AccessToken = "at-secret"
|
||||
sess.Extra = map[string]any{"return_to": "/x", "n": float64(7)}
|
||||
if err := store.Update(ctx, sess); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.UserID != "u-real" || got.Email != "real@example.com" ||
|
||||
got.OIDCCodeVerifier != "cv-secret" || got.AccessToken != "at-secret" {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
if got.Extra["return_to"] != "/x" || got.Extra["n"] != float64(7) {
|
||||
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
|
||||
}
|
||||
|
||||
// key 帶 prefix(用底層 client 直接驗)。
|
||||
if n, _ := client.Exists(ctx, redisKey(sess.ID)).Result(); n != 1 {
|
||||
t.Fatalf("expected prefixed key to exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealRedis_TTLExpiry 驗證真 Redis 會在短 idle TTL 到期後自動清掉 key。
|
||||
//
|
||||
// 用很短的 idle(2s)讓測試在合理時間內完成(不需 FastForward;真 Redis 用真時鐘)。
|
||||
func TestRealRedis_TTLExpiry(t *testing.T) {
|
||||
client := realRedisClient(t)
|
||||
store := NewRedisUserSessionStore(client, 2*time.Second, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := store.Create(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// 立刻拿得到。
|
||||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("Get right after create: %v", err)
|
||||
}
|
||||
|
||||
// 等超過 idle TTL(2s)+ 緩衝。
|
||||
time.Sleep(3 * time.Second)
|
||||
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("session should be TTL-expired on real redis, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealRedis_AbsoluteCapsIdle 驗證真 Redis 下 absolute 上限封頂 idle 續期:
|
||||
// idle 10s、absolute 3s → 即使一直 Update,3s 後仍應消失。
|
||||
func TestRealRedis_AbsoluteCapsIdle(t *testing.T) {
|
||||
client := realRedisClient(t)
|
||||
store := NewRedisUserSessionStore(client, 10*time.Second, 3*time.Second)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := store.Create(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// 每 1s Update 一次,共 4 次(idle 永遠新,但 absolute 3s 會砍)。
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
var lastErr error
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(1 * time.Second)
|
||||
lastErr = store.Update(ctx, sess)
|
||||
}
|
||||
// 最後一次 Update 落在 absolute 後 → ErrNoSession;或 Get 確認已清。
|
||||
if lastErr != nil && !errors.Is(lastErr, ErrNoSession) {
|
||||
t.Fatalf("unexpected Update error: %v", lastErr)
|
||||
}
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("session should be gone after absolute deadline, got %v", err)
|
||||
}
|
||||
}
|
||||
637
visionA-backend/internal/usersession/redis_test.go
Normal file
637
visionA-backend/internal/usersession/redis_test.go
Normal 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),回傳:
|
||||
// - store:RedisUserSessionStore(idle / absolute TTL 由 caller 指定)
|
||||
// - mr:miniredis 實例(可呼叫 FastForward / SetTime / TTL 模擬時間與檢查 key TTL)
|
||||
//
|
||||
// miniredis 與本 package 的 nowFunc 是「兩個時鐘」:
|
||||
// - store 算 absolute deadline 用 nowFunc()
|
||||
// - miniredis 算 key TTL 用自己的內部時鐘
|
||||
//
|
||||
// 測雙 TTL 時兩者都要同步推進(advanceTime helper 一次推兩個)。
|
||||
func setupRedisStore(t *testing.T, idle, absolute time.Duration) (*RedisUserSessionStore, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
store := NewRedisUserSessionStore(client, idle, absolute)
|
||||
return store, mr
|
||||
}
|
||||
|
||||
// clock 追蹤「目前已從 base 推進多少」,讓 advanceTime 能對 miniredis 的相對 TTL
|
||||
// 做增量 FastForward(FastForward 是「把所有 TTL 減去 duration」,必須用增量、不能用絕對 delta)。
|
||||
type clock struct {
|
||||
base time.Time
|
||||
elapsed time.Duration // 已推進的累計量
|
||||
}
|
||||
|
||||
// advanceTo 把時間推進到 base+delta(delta 必須 >= 目前 elapsed,單調遞增)。
|
||||
//
|
||||
// 同步推進兩個時鐘:
|
||||
// - nowFunc:給 store 算 absolute deadline(app 端邏輯)。
|
||||
// - miniredis:用增量 FastForward 推相對 TTL(key 自動過期)+ SetTime 對齊 EXPIREAT 基準。
|
||||
func (c *clock) advanceTo(t *testing.T, mr *miniredis.Miniredis, delta time.Duration) time.Time {
|
||||
t.Helper()
|
||||
if delta < c.elapsed {
|
||||
t.Fatalf("clock.advanceTo must be monotonic: delta=%v < elapsed=%v", delta, c.elapsed)
|
||||
}
|
||||
inc := delta - c.elapsed
|
||||
c.elapsed = delta
|
||||
newNow := c.base.Add(delta)
|
||||
nowFunc = func() time.Time { return newNow }
|
||||
mr.SetTime(newNow)
|
||||
mr.FastForward(inc) // 相對 TTL 增量推進,<=0 的 key 即過期
|
||||
return newNow
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 4.5 對齊既有 in-memory test:Create / Get / Update / Delete 基本行為
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestRedisStore_CreateAndGet(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
|
||||
sess, err := store.Create(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if sess.ID == "" {
|
||||
t.Fatalf("Create returned empty ID")
|
||||
}
|
||||
if sess.CreatedAt.IsZero() || sess.LastSeenAt.IsZero() {
|
||||
t.Fatalf("CreatedAt/LastSeenAt should be set")
|
||||
}
|
||||
if !sess.CreatedAt.Equal(sess.LastSeenAt) {
|
||||
t.Fatalf("Create: CreatedAt should == LastSeenAt initially, got %v vs %v",
|
||||
sess.CreatedAt, sess.LastSeenAt)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.ID != sess.ID {
|
||||
t.Fatalf("Get: ID mismatch want=%s got=%s", sess.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStore_Get_NotFound(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
_, err := store.Get(context.Background(), "no-such-id")
|
||||
if !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStore_Get_EmptyID(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
_, err := store.Get(context.Background(), "")
|
||||
if !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("expected ErrNoSession for empty id, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get 回傳的是副本,外部修改不影響 store 內部狀態。
|
||||
func TestRedisStore_Get_ReturnsCopy(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
got1, _ := store.Get(ctx, sess.ID)
|
||||
got1.Email = "tampered@example.com"
|
||||
got1.Extra = map[string]any{"x": "y"}
|
||||
|
||||
got2, _ := store.Get(ctx, sess.ID)
|
||||
if got2.Email == "tampered@example.com" {
|
||||
t.Fatalf("Get should return a copy; mutation leaked into store")
|
||||
}
|
||||
if got2.Extra != nil {
|
||||
t.Fatalf("Get should return a copy; Extra map mutation leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStore_Update_MovesLastSeenAt_KeepsCreatedAt(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
// 往前推 5 分鐘(同步推進 nowFunc 與 miniredis 時鐘)。
|
||||
clk := &clock{base: t0}
|
||||
t1 := clk.advanceTo(t, mr, 5*time.Minute)
|
||||
|
||||
sess.UserID = "user-123"
|
||||
sess.Email = "alice@example.com"
|
||||
if err := store.Update(ctx, sess); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if !sess.LastSeenAt.Equal(t1) {
|
||||
t.Fatalf("Update should reflect new LastSeenAt back to caller, got %v want %v",
|
||||
sess.LastSeenAt, t1)
|
||||
}
|
||||
|
||||
got, _ := store.Get(ctx, sess.ID)
|
||||
if got.UserID != "user-123" || got.Email != "alice@example.com" {
|
||||
t.Fatalf("Update did not persist user fields: %+v", got)
|
||||
}
|
||||
if !got.LastSeenAt.Equal(t1) {
|
||||
t.Fatalf("store LastSeenAt not advanced: got %v want %v", got.LastSeenAt, t1)
|
||||
}
|
||||
if !got.CreatedAt.Equal(t0) {
|
||||
t.Fatalf("Update must not change CreatedAt: got %v want %v", got.CreatedAt, t0)
|
||||
}
|
||||
}
|
||||
|
||||
// Update 不可「順便建立」不存在的 session(用 SET XX 達成)。
|
||||
func TestRedisStore_Update_NotFound(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
err := store.Update(context.Background(), &Session{ID: "ghost", CreatedAt: nowFunc(), LastSeenAt: nowFunc()})
|
||||
if !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStore_Update_NilSession(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("expected ErrNoSession for nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisStore_Delete_Idempotent(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("after Delete, Get should return ErrNoSession, got %v", err)
|
||||
}
|
||||
// 重複刪同一個 ID 應為 no-op。
|
||||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("Delete on missing should be no-op, got %v", err)
|
||||
}
|
||||
// 刪空 ID 也是 no-op。
|
||||
if err := store.Delete(ctx, ""); err != nil {
|
||||
t.Fatalf("Delete empty id should be no-op, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extra map 與所有欄位(含 OIDC pending + token snapshot)round-trip。
|
||||
func TestRedisStore_Extra_And_Fields_RoundTrip(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
sess.UserID = "u-9"
|
||||
sess.Email = "bob@example.com"
|
||||
sess.Name = "Bob"
|
||||
sess.OIDCState = "state-xyz"
|
||||
sess.OIDCNonce = "nonce-abc"
|
||||
sess.OIDCCodeVerifier = "verifier-123"
|
||||
sess.AccessToken = "at-secret"
|
||||
sess.IDTokenRaw = "idt-raw"
|
||||
sess.Extra = map[string]any{
|
||||
"return_to": "/dashboard",
|
||||
"count": float64(3), // JSON number round-trips as float64
|
||||
"flag": true,
|
||||
}
|
||||
if err := store.Update(ctx, sess); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
got, err := store.Get(ctx, sess.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got.UserID != "u-9" || got.Email != "bob@example.com" || got.Name != "Bob" {
|
||||
t.Fatalf("identity fields mismatch: %+v", got)
|
||||
}
|
||||
if got.OIDCState != "state-xyz" || got.OIDCNonce != "nonce-abc" || got.OIDCCodeVerifier != "verifier-123" {
|
||||
t.Fatalf("OIDC pending fields mismatch: %+v", got)
|
||||
}
|
||||
if got.AccessToken != "at-secret" || got.IDTokenRaw != "idt-raw" {
|
||||
t.Fatalf("token snapshot fields mismatch: %+v", got)
|
||||
}
|
||||
if got.Extra["return_to"] != "/dashboard" || got.Extra["count"] != float64(3) || got.Extra["flag"] != true {
|
||||
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
// context 取消應被尊重。
|
||||
func TestRedisStore_RespectsContext(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if _, err := store.Create(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Create should respect cancelled ctx, got %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Get should respect cancelled ctx, got %v", err)
|
||||
}
|
||||
if err := store.Update(ctx, &Session{ID: "x"}); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Update should respect cancelled ctx, got %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Delete should respect cancelled ctx, got %v", err)
|
||||
}
|
||||
if _, err := store.CleanupExpired(ctx, time.Hour, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("CleanupExpired should respect cancelled ctx, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// CleanupExpired 在 Redis 模式是 no-op(靠 key TTL)。
|
||||
func TestRedisStore_CleanupExpired_NoOp(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
_, _ = store.Create(ctx)
|
||||
removed, err := store.CleanupExpired(ctx, time.Hour, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupExpired: %v", err)
|
||||
}
|
||||
if removed != 0 {
|
||||
t.Fatalf("Redis CleanupExpired should be no-op, removed=%d", removed)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 4.6 雙 TTL:真 TTL 過期、idle vs absolute(用 miniredis FastForward 模擬時間)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
// 建立後 key 的 Redis TTL 應為 idle(idle < absolute 時)。
|
||||
func TestRedisStore_TTL_OnCreate_IsIdle(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
idle := 24 * time.Hour
|
||||
store, mr := setupRedisStore(t, idle, 168*time.Hour)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
ttl := mr.TTL(redisKey(sess.ID))
|
||||
if ttl != idle {
|
||||
t.Fatalf("create TTL should be idle(%v), got %v", idle, ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// idle 過期:閒置超過 idle → key 被 Redis 自動清掉 → Get 回 ErrNoSession。
|
||||
func TestRedisStore_IdleExpiry(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
// 閒置 2 小時(> idle 1h),不做任何 Update。
|
||||
clk := &clock{base: t0}
|
||||
clk.advanceTo(t, mr, 2*time.Hour)
|
||||
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("idle-expired session should be gone, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// idle 續期:在 idle 內持續 Update → 不會因 idle 過期。
|
||||
func TestRedisStore_IdleRenewedByUpdate(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
// 每 30 分鐘 Update 一次,共推進 2 小時 —— 每次都在 idle(1h) 內續期。
|
||||
clk := &clock{base: t0}
|
||||
for i := 1; i <= 4; i++ {
|
||||
clk.advanceTo(t, mr, time.Duration(i)*30*time.Minute)
|
||||
if err := store.Update(ctx, sess); err != nil {
|
||||
t.Fatalf("Update #%d at +%dm: %v", i, i*30, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 累計 2h 但持續活躍 → 應仍存在。
|
||||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("actively-used session should remain, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// absolute 過期:即使持續 Update(idle 永遠新),超過 absolute 後仍應失效。
|
||||
func TestRedisStore_AbsoluteExpiry_DespiteActivity(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
// idle 1h、absolute 3h。每 30 分鐘 Update(idle 永不過期),但 3h 後 absolute 應砍掉。
|
||||
store, mr := setupRedisStore(t, 1*time.Hour, 3*time.Hour)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
// 在 absolute 內持續活躍(+30m ~ +180m)。最後一次 Update 落在 absolute deadline 後。
|
||||
clk := &clock{base: t0}
|
||||
var lastUpdateErr error
|
||||
for d := 30 * time.Minute; d <= 210*time.Minute; d += 30 * time.Minute {
|
||||
clk.advanceTo(t, mr, d)
|
||||
lastUpdateErr = store.Update(ctx, sess)
|
||||
}
|
||||
// 超過 absolute(3h)後 Update 應回 ErrNoSession(absolute 砍掉)。
|
||||
if !errors.Is(lastUpdateErr, ErrNoSession) {
|
||||
t.Fatalf("Update past absolute deadline should return ErrNoSession, got %v", lastUpdateErr)
|
||||
}
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("absolute-expired session should be gone, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// absolute 上限封頂:idle 續期時 key TTL 不可超過「距 absolute deadline 的剩餘」。
|
||||
func TestRedisStore_UpdateTTL_CappedByAbsolute(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
// idle 2h、absolute 150m。在 +1h 時 Update(key 仍在,idle 未過):
|
||||
// idle 想續成 2h,但距 absolute 只剩 90m,故 TTL 應被封頂為 90m(< idle 2h)。
|
||||
store, mr := setupRedisStore(t, 2*time.Hour, 150*time.Minute)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
clk := &clock{base: t0}
|
||||
clk.advanceTo(t, mr, 1*time.Hour)
|
||||
if err := store.Update(ctx, sess); err != nil {
|
||||
t.Fatalf("Update at +1h: %v", err)
|
||||
}
|
||||
ttl := mr.TTL(redisKey(sess.ID))
|
||||
if ttl != 90*time.Minute {
|
||||
t.Fatalf("TTL at +1h should be capped to absolute remaining(90m), got %v", ttl)
|
||||
}
|
||||
}
|
||||
|
||||
// key 命名 / 隔離:不同 session 的 key 互不干擾,且都帶 prefix。
|
||||
func TestRedisStore_KeyNamingAndIsolation(t *testing.T) {
|
||||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
s1, _ := store.Create(ctx)
|
||||
s2, _ := store.Create(ctx)
|
||||
|
||||
if !mr.Exists(redisKey(s1.ID)) || !mr.Exists(redisKey(s2.ID)) {
|
||||
t.Fatalf("both session keys should exist with prefix %q", redisKeyPrefix)
|
||||
}
|
||||
// 刪 s1 不影響 s2。
|
||||
if err := store.Delete(ctx, s1.ID); err != nil {
|
||||
t.Fatalf("Delete s1: %v", err)
|
||||
}
|
||||
if mr.Exists(redisKey(s1.ID)) {
|
||||
t.Fatalf("s1 key should be gone")
|
||||
}
|
||||
if _, err := store.Get(ctx, s2.ID); err != nil {
|
||||
t.Fatalf("s2 should remain after deleting s1, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 4.7 邊界 + 併發
|
||||
// ─────────────────────────────────────────────────────────
|
||||
|
||||
// 永不過期設定:idle=0 且 absolute=0 → key 無 TTL(PERSIST 語意)。
|
||||
func TestRedisStore_NeverExpires_WhenBothZero(t *testing.T) {
|
||||
store, mr := setupRedisStore(t, 0, 0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
ttl := mr.TTL(redisKey(sess.ID))
|
||||
if ttl != 0 { // miniredis TTL=0 代表無到期時間
|
||||
t.Fatalf("with idle=abs=0, key should have no TTL, got %v", ttl)
|
||||
}
|
||||
// 推 100 天仍在。
|
||||
mr.FastForward(100 * 24 * time.Hour)
|
||||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||||
t.Fatalf("no-TTL session should remain after 100d, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 只設 absolute(idle=0):key TTL = absolute;過期後消失。
|
||||
func TestRedisStore_OnlyAbsolute(t *testing.T) {
|
||||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||||
restore := withFrozenNow(t, t0)
|
||||
defer restore()
|
||||
|
||||
store, mr := setupRedisStore(t, 0, 2*time.Hour)
|
||||
mr.SetTime(t0)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
if ttl := mr.TTL(redisKey(sess.ID)); ttl != 2*time.Hour {
|
||||
t.Fatalf("with idle=0, create TTL should be absolute(2h), got %v", ttl)
|
||||
}
|
||||
clk := &clock{base: t0}
|
||||
clk.advanceTo(t, mr, 3*time.Hour)
|
||||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatalf("session past absolute should be gone, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 連線中斷:Redis 不可達時 store 方法回非 nil error(不 panic、不靜默成功)。
|
||||
func TestRedisStore_ConnectionDown(t *testing.T) {
|
||||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
sess, _ := store.Create(ctx)
|
||||
|
||||
mr.Close() // 模擬 Redis 掛掉
|
||||
|
||||
if _, err := store.Create(ctx); err == nil {
|
||||
t.Fatalf("Create should error when redis is down")
|
||||
}
|
||||
if _, err := store.Get(ctx, sess.ID); err == nil {
|
||||
t.Fatalf("Get should error when redis is down")
|
||||
}
|
||||
if err := store.Update(ctx, sess); err == nil {
|
||||
t.Fatalf("Update should error when redis is down")
|
||||
}
|
||||
if err := store.Delete(ctx, sess.ID); err == nil {
|
||||
t.Fatalf("Delete should error when redis is down")
|
||||
}
|
||||
}
|
||||
|
||||
// 併發 smoke test(race detector 抓 data race)。
|
||||
// 注意:本測試不凍結 nowFunc(避免與其他凍結時間的測試 race;nowFunc 是 package 變數)。
|
||||
func TestRedisStore_ConcurrentAccess(t *testing.T) {
|
||||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||||
ctx := context.Background()
|
||||
const goroutines = 16
|
||||
const iterations = 30
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for i := 0; i < goroutines; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
sess, err := store.Create(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("Create: %v", err)
|
||||
return
|
||||
}
|
||||
_, _ = store.Get(ctx, sess.ID)
|
||||
sess.UserID = "u"
|
||||
_ = store.Update(ctx, sess)
|
||||
_ = store.Delete(ctx, sess.ID)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// TestRedisStore_EffectiveTTL_BoundaryDecision 直接斷言 effectiveTTL 的回傳值與
|
||||
// save 的「過期 vs PERSIST」決策邊界,**不繞 miniredis**(純決策邏輯,不需 docker)。
|
||||
//
|
||||
// 守的是 Minor-1 修正:當有 absolute 上限時,TTL 算到 0 ns(now 恰好對齊 deadline)
|
||||
// 不能被當成 PERSIST,否則 key 變永不過期、繞過 absolute 上限。
|
||||
func TestRedisStore_EffectiveTTL_BoundaryDecision(t *testing.T) {
|
||||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
const (
|
||||
idle = 24 * time.Hour
|
||||
abs = 168 * time.Hour
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
idleTTL time.Duration
|
||||
absoluteTTL time.Duration
|
||||
now time.Time
|
||||
createdAt time.Time
|
||||
wantTTL time.Duration // effectiveTTL 期望回傳
|
||||
wantExpired bool // save 是否應視為已過期(回 ErrSessionExpired)
|
||||
wantPersist bool // 是否為合法 PERSIST(ttl==0 但 absoluteTTL==0)
|
||||
}{
|
||||
{
|
||||
// idle + absolute 皆停用 → 合法 PERSIST(ttl 0、不過期)。
|
||||
name: "both_disabled_persist",
|
||||
idleTTL: 0,
|
||||
absoluteTTL: 0,
|
||||
now: base,
|
||||
createdAt: base,
|
||||
wantTTL: 0,
|
||||
wantExpired: false,
|
||||
wantPersist: true,
|
||||
},
|
||||
{
|
||||
// 有 absolute,now 恰好對齊 deadline(absRemaining == 0 ns)→ 必須視為過期,
|
||||
// 不能當 PERSIST。這是 Minor-1 的核心邊界。
|
||||
name: "absolute_now_equals_deadline",
|
||||
idleTTL: 0,
|
||||
absoluteTTL: abs,
|
||||
now: base.Add(abs),
|
||||
createdAt: base,
|
||||
wantTTL: 0,
|
||||
wantExpired: true,
|
||||
wantPersist: false,
|
||||
},
|
||||
{
|
||||
// deadline 前 1 ns → 還沒到期,TTL = 1 ns。
|
||||
name: "absolute_one_ns_before_deadline",
|
||||
idleTTL: 0,
|
||||
absoluteTTL: abs,
|
||||
now: base.Add(abs - 1),
|
||||
createdAt: base,
|
||||
wantTTL: 1,
|
||||
wantExpired: false,
|
||||
wantPersist: false,
|
||||
},
|
||||
{
|
||||
// deadline 後 1 ns → 已過期,TTL = -1 ns。
|
||||
name: "absolute_one_ns_after_deadline",
|
||||
idleTTL: 0,
|
||||
absoluteTTL: abs,
|
||||
now: base.Add(abs + 1),
|
||||
createdAt: base,
|
||||
wantTTL: -1,
|
||||
wantExpired: true,
|
||||
wantPersist: false,
|
||||
},
|
||||
{
|
||||
// idle+absolute 皆啟用、剛建立 → TTL 取較小的 idle(idle < abs remaining)。
|
||||
name: "both_enabled_takes_idle",
|
||||
idleTTL: idle,
|
||||
absoluteTTL: abs,
|
||||
now: base,
|
||||
createdAt: base,
|
||||
wantTTL: idle,
|
||||
wantExpired: false,
|
||||
wantPersist: false,
|
||||
},
|
||||
{
|
||||
// idle+absolute 皆啟用、now 對齊 absolute deadline → absRemaining 0 < idle,
|
||||
// 取 absRemaining(0) → 仍須視為過期(有 absolute 上限)。
|
||||
name: "both_enabled_now_equals_abs_deadline",
|
||||
idleTTL: idle,
|
||||
absoluteTTL: abs,
|
||||
now: base.Add(abs),
|
||||
createdAt: base,
|
||||
wantTTL: 0,
|
||||
wantExpired: true,
|
||||
wantPersist: false,
|
||||
},
|
||||
{
|
||||
// 只有 idle、無 absolute → 永遠回 idle,不受 deadline 概念影響、不過期。
|
||||
name: "only_idle",
|
||||
idleTTL: idle,
|
||||
absoluteTTL: 0,
|
||||
now: base.Add(100 * time.Hour),
|
||||
createdAt: base,
|
||||
wantTTL: idle,
|
||||
wantExpired: false,
|
||||
wantPersist: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := NewRedisUserSessionStore(nil, tc.idleTTL, tc.absoluteTTL)
|
||||
|
||||
gotTTL := s.effectiveTTL(tc.now, tc.createdAt)
|
||||
if gotTTL != tc.wantTTL {
|
||||
t.Errorf("effectiveTTL = %v, want %v", gotTTL, tc.wantTTL)
|
||||
}
|
||||
|
||||
// 重現 save 的過期決策(不需 Redis client,純比較):
|
||||
// 有 absolute 上限且 ttl <= 0 → 過期;否則放行(ttl == 0 在無 absolute 時為 PERSIST)。
|
||||
gotExpired := gotTTL <= 0 && s.absoluteTTL > 0
|
||||
if gotExpired != tc.wantExpired {
|
||||
t.Errorf("save expiry decision = %v, want %v (ttl=%v, absoluteTTL=%v)",
|
||||
gotExpired, tc.wantExpired, gotTTL, s.absoluteTTL)
|
||||
}
|
||||
|
||||
// PERSIST = ttl 0 且非過期決策(即 absoluteTTL == 0)。
|
||||
gotPersist := gotTTL == 0 && !gotExpired
|
||||
if gotPersist != tc.wantPersist {
|
||||
t.Errorf("PERSIST decision = %v, want %v", gotPersist, tc.wantPersist)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
53
visionA-backend/migrations/0001_create_users_models.up.sql
Normal file
53
visionA-backend/migrations/0001_create_users_models.up.sql
Normal 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 含 users:models.owner_user_id 是 REFERENCES users(id) 的 FK,
|
||||
-- 必須先有 users 表。雛形 users 為 stub(固定 demo-user),但 schema 與 FK 約束第一份就要到位。
|
||||
--
|
||||
-- 環境事實(已驗證):PostgreSQL 14.23,已裝 extension 只有 plpgsql。
|
||||
-- - gen_random_uuid():PG14 內建(pgcrypto 已併入核心),可直接用,不需 CREATE EXTENSION。
|
||||
-- - CITEXT:未裝。users.email 改用 TEXT,大小寫不敏感唯一改用 functional unique index
|
||||
-- ON lower(email)(塊 1 真正寫 user 落盤前,users 僅為 stub,影響極小)。
|
||||
|
||||
-- users(Phase 1 stub;雛形固定 demo-user)
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
name TEXT,
|
||||
password_hash TEXT,
|
||||
org_id UUID,
|
||||
roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
-- email 大小寫不敏感唯一(取代 CITEXT,因 PG14.23 未裝 citext extension)。
|
||||
CREATE UNIQUE INDEX uq_users_email_lower ON users (lower(email));
|
||||
|
||||
-- models(塊 1 主體;使用者最關心,重啟後模型庫資料還在)
|
||||
CREATE TABLE models (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
storage_key TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
file_checksum TEXT,
|
||||
faa_object_key TEXT, -- ADR-017 (a) B1,nullable(上傳類留 NULL)
|
||||
target_chip TEXT,
|
||||
input_shape INT[],
|
||||
classes TEXT[],
|
||||
framework TEXT,
|
||||
source TEXT NOT NULL,
|
||||
source_job_id UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
uploaded_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
-- owner-scoped index(List by owner / chip / source;只索引未刪除紀錄)。
|
||||
CREATE INDEX idx_models_owner_active ON models (owner_user_id) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_models_owner_chip_active ON models (owner_user_id, target_chip) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_models_owner_source_active ON models (owner_user_id, source) WHERE deleted_at IS NULL;
|
||||
6
visionA-backend/migrations/0002_create_devices.down.sql
Normal file
6
visionA-backend/migrations/0002_create_devices.down.sql
Normal file
@ -0,0 +1,6 @@
|
||||
-- 0002_create_devices.down.sql
|
||||
--
|
||||
-- 反向:刪除 devices 表(index 隨 table DROP 自動移除)。
|
||||
-- devices 目前無其他表以 FK 指向它(token 表於 0003 才建),故可直接 DROP。
|
||||
|
||||
DROP TABLE IF EXISTS devices;
|
||||
44
visionA-backend/migrations/0002_create_devices.up.sql
Normal file
44
visionA-backend/migrations/0002_create_devices.up.sql
Normal file
@ -0,0 +1,44 @@
|
||||
-- 0002_create_devices.up.sql
|
||||
--
|
||||
-- DB 接入塊 2:建立 devices 表(裝置綁定身分長期保存)。
|
||||
-- 對齊 docs/autoflow/04-architecture/database.md §2.2、§4、§5.1。
|
||||
--
|
||||
-- 接續 0001(users + models);owner_user_id 是 REFERENCES users(id) 的 FK,users 表已於 0001 建立。
|
||||
--
|
||||
-- 環境事實(已驗證,與 0001 相同):PostgreSQL 14.23,gen_random_uuid() PG14 內建可直接用。
|
||||
|
||||
-- devices(雙狀態模型:remote_status[tunnel-level] + status[USB-level],見 §2.2 Minor-3)
|
||||
CREATE TABLE devices (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
owner_user_id UUID NOT NULL REFERENCES users(id),
|
||||
name TEXT NOT NULL,
|
||||
device_type TEXT,
|
||||
serial_number TEXT,
|
||||
|
||||
-- tunnel-level 狀態(雲端 remote-proxy 觀察 tunnel 連線)
|
||||
remote_status TEXT NOT NULL DEFAULT 'offline', -- online | offline | reconnecting | error
|
||||
last_seen_at TIMESTAMPTZ, -- 最後一次 tunnel heartbeat 時間
|
||||
last_connected_at TIMESTAMPTZ, -- tunnel 最近一次建立時間
|
||||
|
||||
-- USB-level 狀態(local agent 上報)
|
||||
status TEXT NOT NULL DEFAULT 'unknown', -- online | offline | unknown
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
paired_at TIMESTAMPTZ, -- 配對完成時間(nullable)
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- partial unique index(取代 table-level UNIQUE(owner_user_id, serial_number))。
|
||||
-- 決策:已 soft-delete 的 device serial「能」重新註冊(見 §4 / §0.4 決策 2)。
|
||||
-- 語意:唯一性只對「未刪除」(deleted_at IS NULL)紀錄成立;
|
||||
-- 刪掉一筆後,同 (owner, serial) 可再 INSERT 一筆新的(新 id),不違反 unique。
|
||||
-- 這讓使用者把裝置刪了之後仍能用同一個實體 serial 重新註冊。
|
||||
CREATE UNIQUE INDEX uq_devices_owner_serial_active
|
||||
ON devices (owner_user_id, serial_number)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- owner-scoped index(List by owner;只索引未刪除紀錄)。
|
||||
CREATE INDEX idx_devices_owner_active ON devices (owner_user_id) WHERE deleted_at IS NULL;
|
||||
-- remote_status filter(前端優先顯示雲端連線狀態;只索引未刪除紀錄)。
|
||||
CREATE INDEX idx_devices_remote_status ON devices (remote_status) WHERE deleted_at IS NULL;
|
||||
@ -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;
|
||||
56
visionA-backend/migrations/0003_create_token_tables.up.sql
Normal file
56
visionA-backend/migrations/0003_create_token_tables.up.sql
Normal 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、§4(token 分表段落)、§5.1。
|
||||
--
|
||||
-- 接續 0001(users + models)/ 0002(devices);兩張 token 表的 user_id / device_id
|
||||
-- 都是 REFERENCES 既有表的 FK(users 於 0001、devices 於 0002 建立)。
|
||||
--
|
||||
-- 環境事實(與 0001/0002 相同):PostgreSQL 14.23。
|
||||
--
|
||||
-- ── 分表決策(database.md §4)──────────────────────────────────────────────
|
||||
-- pairing_tokens 與 session_tokens「分表」,不共表 by kind。理由:
|
||||
-- 1. code 中是兩個獨立 struct + 兩個 Store interface(PairingStore / SessionTokenStore),
|
||||
-- 欄位與方法集不同(pairing 有 used_at 一次性語意、kind;session 有 parent_token_hash、無 used_at)。
|
||||
-- 2. 共表會讓 used_at / parent_token_hash 對另一類永遠為 NULL,欄位語意混淆。
|
||||
-- 3. 分表後各表 schema 乾淨、index 各自最佳化,repository 一對一對映 Store。
|
||||
-- 代價:稽核「pairing→session 升級鏈」需跨表 join(session_tokens.parent_token_hash
|
||||
-- → pairing_tokens.token_hash);可接受(查詢頻率低)。
|
||||
|
||||
-- pairing_tokens(短期一次性配對 token;對齊 internal/auth.PairingToken)
|
||||
-- PK = token_hash(sha256(plaintext)):永不存明文 token(security.md §1.3)。
|
||||
CREATE TABLE pairing_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext),永不存明文
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
device_id UUID REFERENCES devices(id), -- MarkUsed 綁定後才有(nullable)
|
||||
kind TEXT NOT NULL DEFAULT 'pairing', -- 固定 'pairing'(保留欄位,便於觀測/未來擴充)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ, -- 15min TTL;NULL = 永不過期(測試用)
|
||||
used_at TIMESTAMPTZ, -- 一次性:MarkUsed 後 Validate 失敗
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- List by user(UI 顯示),只索引未撤銷紀錄。
|
||||
CREATE INDEX idx_pairing_tokens_user_active ON pairing_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
-- device_id 反查(cascade 撤銷 by device,塊 5)。
|
||||
CREATE INDEX idx_pairing_tokens_device ON pairing_tokens (device_id);
|
||||
|
||||
-- session_tokens(長期可撤銷 tunnel session token;對齊 internal/auth.SessionToken)
|
||||
-- PK = token_hash;device_id 必填(session token 必綁 device)。
|
||||
CREATE TABLE session_tokens (
|
||||
token_hash TEXT PRIMARY KEY, -- sha256(plaintext)
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
device_id UUID NOT NULL REFERENCES devices(id), -- session token 必綁 device
|
||||
parent_token_hash TEXT, -- 升級來源 pairing token 的 hash(稽核鏈,可 join pairing_tokens.token_hash)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ, -- 90 天 TTL;NULL = 永不過期
|
||||
revoked_at TIMESTAMPTZ
|
||||
-- 注意:無 used_at(非一次性)、無 kind
|
||||
);
|
||||
|
||||
-- List by user,只索引未撤銷紀錄。
|
||||
CREATE INDEX idx_session_tokens_user_active ON session_tokens (user_id) WHERE revoked_at IS NULL;
|
||||
-- device_id 反查(cascade 撤銷 by device,塊 5)。
|
||||
CREATE INDEX idx_session_tokens_device ON session_tokens (device_id);
|
||||
-- parent_token_hash 稽核鏈查詢(join pairing_tokens.token_hash)。
|
||||
CREATE INDEX idx_session_tokens_parent ON session_tokens (parent_token_hash);
|
||||
16
visionA-backend/migrations/embed.go
Normal file
16
visionA-backend/migrations/embed.go
Normal file
@ -0,0 +1,16 @@
|
||||
// Package migrations 把 visionA-backend 的 SQL migration 檔嵌入 binary,
|
||||
// 供 internal/db 的 migration runner(golang-migrate iofs source)使用。
|
||||
//
|
||||
// 嵌入式設計理由:
|
||||
// - binary 自帶 migration,部署時不需另外複製 migrations/ 目錄、不會有「檔案找不到」風險。
|
||||
// - testcontainers 整合測試與 cmd/migrate 共用同一份 FS,行為一致。
|
||||
//
|
||||
// 命名規範(golang-migrate 序號式):NNNN_description.up.sql / .down.sql。
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
// FS 嵌入本目錄下所有 .sql migration 檔。
|
||||
//
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
Loading…
x
Reference in New Issue
Block a user