jim800121chen 4d0b870480 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>
2026-06-20 18:28:04 +08:00

129 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/auth"
"visiona-backend/internal/device"
"visiona-backend/internal/model"
)
// demoSeedUserID 是 DB 啟用時 seed 用的固定 demo user UUID。
//
// 雛形的 StaticUserID"demo-user")不是合法 UUID無法當 models.owner_user_idUUID + FK
// DB-backed seed 改用此固定 UUID並先 upsert 一筆對應 users 列以滿足 FK
// in-memory seed 不受影響(仍用 cfg.Auth.StaticUserID
const demoSeedUserID = "00000000-0000-0000-0000-0000000000d3"
// seedDemoData 在啟動時塞入示範資料,方便本機開發 / demo 不必跑完整 pairing。
//
// 觸發條件VISIONA_SEED_DEMO_DATA=true
//
// 內容:
// - 一個示範 deviceKL520
// - 一個示範 modelYOLOv5 Face
// - 一個示範 pairing tokenlog 出來方便手動 copy
//
// 注意:
// - 失敗只 log warning不阻擋啟動
// - 重複呼叫會產生重複資料本函式只該被呼叫一次main 已保證)
// - **不要**在生產環境啟用此 flag
//
// dbPool 非 nil 表 model repo 已切到 Postgres塊 1此時 seed 的 model 必須用合法 UUID
// 與已存在的 owner_user_idUUID + FK故先 upsert demo user、改用 demoSeedUserID。
// dbPool 為 nilin-memory fallback時行為與雛形完全相同。
func seedDemoData(
devRepo device.Repository,
mdlRepo model.Repository,
pairings auth.PairingStore,
userID string,
dbPool *pgxpool.Pool,
log *slog.Logger,
) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
now := time.Now().UTC()
// owner / id 視 backend 決定DB-backed 需合法 UUID + 存在的 user 列。
// 塊 2 起 device 也落 DB故 model 與 device 的 demo owner 必須一致(同一 demoSeedUserID
// 否則 owner 對不上device.owner_user_id 也是 UUID + FK
modelOwnerID := userID
deviceOwnerID := userID
// pairing token 的 owneruser_id塊 3 起 pairing token 也落 DBuser_id 是
// REFERENCES users(id) 的 FK + UUID故 DB-backed 時必須與 model/device 一致用
// demoSeedUserID已 upsert 的合法 user不可用非-UUID 的 StaticUserID。
pairingOwnerID := userID
modelID := "demo-model-" + uuid.NewString()[:8]
deviceID := "demo-device-" + uuid.NewString()[:8]
if dbPool != nil {
modelOwnerID = demoSeedUserID
deviceOwnerID = demoSeedUserID
pairingOwnerID = demoSeedUserID
modelID = uuid.NewString()
deviceID = uuid.NewString() // device.id 為 UUID + FK 對齊;不可用非-UUID 字串
// 先確保 owner user 存在(滿足 models / devices owner_user_id FK
if _, err := dbPool.Exec(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
demoSeedUserID, "demo@visiona.local", "Demo User (seeded)"); err != nil {
log.Warn("seed: ensure demo user failed", "error", err)
}
}
// 1. Demo device塊 2 起 DB-backed 時走 Postgresowner 對齊 demoSeedUserID
dev := &device.Device{
ID: deviceID,
OwnerUserID: deviceOwnerID,
Name: "Demo KL520 (seeded)",
DeviceType: "kl520",
SerialNumber: "DEMO-SN-001",
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
CreatedAt: now,
UpdatedAt: now,
}
if err := devRepo.Save(ctx, dev); err != nil {
log.Warn("seed: device save failed", "error", err)
} else {
log.Info("seed: demo device created", "id", dev.ID, "name", dev.Name)
}
// 2. Demo model
mdl := &model.Model{
ID: modelID,
OwnerUserID: modelOwnerID,
Name: "YOLOv5 Face (seeded)",
TargetChip: "kl520",
FileSize: 1024 * 1024, // 1 MB
Source: model.SourceUploaded,
StorageKey: "models/" + modelOwnerID + "/demo.nef",
CreatedAt: now,
UpdatedAt: now,
UploadedAt: &now,
}
if err := mdlRepo.Save(ctx, mdl); err != nil {
log.Warn("seed: model save failed", "error", err)
} else {
log.Info("seed: demo model created", "id", mdl.ID, "name", mdl.Name)
}
// 3. Demo pairing tokenlog plaintext 方便開發 — 雛形 demo 用,生產禁用)
// owner 用 pairingOwnerIDDB-backed 時為 demoSeedUserID滿足 user_id FK
pt, _, err := pairings.Create(ctx, pairingOwnerID, 24*time.Hour)
if err != nil {
log.Warn("seed: pairing token create failed", "error", err)
} else {
log.Info("seed: demo pairing token created (use for local-tool tunnel)",
"token", pt,
"ttl", "24h")
}
return nil
}