把 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>
129 lines
4.5 KiB
Go
129 lines
4.5 KiB
Go
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_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
|
||
//
|
||
// 內容:
|
||
// - 一個示範 device(KL520)
|
||
// - 一個示範 model(YOLOv5 Face)
|
||
// - 一個示範 pairing token(log 出來方便手動 copy)
|
||
//
|
||
// 注意:
|
||
// - 失敗只 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)
|
||
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 的 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: 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 token(log plaintext 方便開發 — 雛形 demo 用,生產禁用)
|
||
// 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 {
|
||
log.Info("seed: demo pairing token created (use for local-tool tunnel)",
|
||
"token", pt,
|
||
"ttl", "24h")
|
||
}
|
||
|
||
return nil
|
||
}
|