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

387 lines
14 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 usersession
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
// redisKeyPrefix 是所有 user session key 的命名空間前綴。
//
// 加前綴的理由:
// - 與同一個 Redis db 內其他用途的 key 隔離(即使 RedisConfig.DB 已分 index前綴再加一層保險
// - 方便用 SCAN MATCH "usersession:*" 觀測 / 排查CleanupExpired 也靠它列出 key
const redisKeyPrefix = "usersession:"
// RedisUserSessionStore 是 Store 的 Redis 實作。
//
// 設計重點(對齊 docs/autoflow/04-architecture/database.md §2.7
//
// - **雙 TTL** 取代 InMemoryStore 的手動 CleanupExpired goroutine
// idle TTL每次 Update 續期)+ absolute TTL建立後固定上限不續期
// 做法:把 session JSON 存進一個 Redis keykey 的 Redis TTL 設為
// min(idleTTL, 距 absolute deadline 的剩餘時間)。
// 這樣 key 在「閒置超過 idle」或「建立超過 absolute」任一條件成立時都會被 Redis 自動清掉,
// 不需要 background goroutine 掃描。
//
// - **absolute deadline 精準防護**CreatedAt 存進 valueGet 時再算一次
// now - CreatedAt > absolute雙重保險即使因為時鐘 / 續期計算誤差讓 key 多活一瞬間,
// Get 仍會視為過期回 ErrNoSession 並順手刪除)。
//
// - **Extra map JSON 序列化**:整個 Session 用 encoding/json 序列化進 value
// Extra map[string]any 自然被序列化。caller 須自我約束放可 JSON 序列化的型別
// usersession.go Session.Extra 註解已載明此約束)。
//
// 並發安全:所有方法都是單一 Redis 指令或 store 內無共享 mutable statego-redis client
// 本身並發安全。
//
// 安全OIDCCodeVerifier / AccessToken / IDTokenRaw 等敏感欄位雖序列化進 Redis value
// 但不會進入任何 logstore 內不 log value
type RedisUserSessionStore struct {
client redis.UniversalClient
// idleTTL / absoluteTTL 在建構時固定(來自 UserSessionConfig.IdleTTL / AbsoluteTTL
//
// 與 InMemoryStore.CleanupExpired(ctx, idle, abs) 把 timeout 當參數傳不同Redis 版的 TTL
// 必須在「寫入 key 當下」就決定Redis TTL 是 per-key 設定),所以 store 自己持有這兩個值。
idleTTL time.Duration
absoluteTTL time.Duration
}
// 編譯期確認 RedisUserSessionStore 滿足 Store interface。
var _ Store = (*RedisUserSessionStore)(nil)
// redisSession 是寫入 Redis value 的序列化結構。
//
// 用獨立 struct而非直接序列化 Session的理由
// - 明確控制哪些欄位落地、用穩定的 JSON keySession struct 沒有 json tag
// 直接序列化會用 Go 欄位名,未來改欄位名會破壞既有 value 相容性)。
// - 時間用 UnixNano 存,跨程序 / 跨機器無時區歧義,且 Get 算 absolute deadline 直接做整數運算。
type redisSession struct {
ID string `json:"id"`
UserID string `json:"uid,omitempty"`
Email string `json:"email,omitempty"`
Name string `json:"name,omitempty"`
CreatedAtUnix int64 `json:"created_at"`
LastSeenAtUnix int64 `json:"last_seen_at"`
OIDCState string `json:"oidc_state,omitempty"`
OIDCNonce string `json:"oidc_nonce,omitempty"`
OIDCCodeVerifier string `json:"oidc_cv,omitempty"`
AccessToken string `json:"access_token,omitempty"`
IDTokenRaw string `json:"id_token_raw,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
}
// NewRedisUserSessionStore 建立 Redis-backed 的 user session store。
//
// client 為已連線的 go-redis clientmain.go 傳 db.RedisClient.Client();測試傳 miniredis client
// idleTTL / absoluteTTL 來自 cfg.UserSession.IdleTTL預設 24h/ AbsoluteTTL預設 168h
//
// idleTTL / absoluteTTL <= 0 視為「該維度不過期」:
// - idleTTL <= 0 → key 不設 idle 上限(仍受 absolute 限制)。
// - absoluteTTL <= 0 → 無 absolute 上限key 只受 idle 限制)。
// - 兩者皆 <= 0 → key 永不過期PERSIST 語意;正式環境不應如此設定,僅測試 / 特例用)。
func NewRedisUserSessionStore(client redis.UniversalClient, idleTTL, absoluteTTL time.Duration) *RedisUserSessionStore {
return &RedisUserSessionStore{
client: client,
idleTTL: idleTTL,
absoluteTTL: absoluteTTL,
}
}
func redisKey(id string) string {
return redisKeyPrefix + id
}
// effectiveTTL 計算「寫入 key 當下」應設的 Redis TTL。
//
// now = 現在時間
// createdAt = session 建立時間absolute deadline 的錨點)
//
// 回傳值語意go-redis SET 的 expiration 參數):
// - > 0 → 設這個 TTL
// - == 0 → 不過期redis.KeepTTL 不適用 SET 新值,故用特殊處理:呼叫端用 0 代表 PERSIST
//
// 計算:取 min(idleTTL, 距 absolute deadline 的剩餘)。
// - idleTTL <= 0不考慮 idle只剩 absolute remaining。
// - absoluteTTL <= 0不考慮 absolute只剩 idleTTL。
// - 兩者皆 <= 0回 0永不過期
// - 若有 absolute 上限且 absolute remaining <= 0已到 / 超過 absolute回 <=0。
//
// ⚠️ 回傳 0 有兩種語意呼叫端save**必須**搭配 s.absoluteTTL 才能區分:
// - s.absoluteTTL == 0 → 0 代表合法 PERSIST永不過期
// - s.absoluteTTL > 0 → 0或 <0代表已到 absolute deadline、應視為過期。
//
// 因此 save 用 `ttl <= 0 && s.absoluteTTL > 0` 判定過期,而非只看 `ttl < 0`
// 避免 absRemaining 恰好對齊到 0 ns 時繞過 absolute 上限。
func (s *RedisUserSessionStore) effectiveTTL(now, createdAt time.Time) time.Duration {
hasIdle := s.idleTTL > 0
hasAbs := s.absoluteTTL > 0
switch {
case !hasIdle && !hasAbs:
return 0 // 永不過期
case hasIdle && !hasAbs:
return s.idleTTL
case !hasIdle && hasAbs:
return createdAt.Add(s.absoluteTTL).Sub(now)
default:
absRemaining := createdAt.Add(s.absoluteTTL).Sub(now)
if absRemaining < s.idleTTL {
return absRemaining
}
return s.idleTTL
}
}
// save 序列化 redisSession 並用計算出的 TTL 寫入 Redis。
//
// expectExiststrue 時用 SET ... XXkey 必須已存在才寫,供 Update 防「順便建立」);
// false 時普通 SET供 Create
//
// 回傳 (false, nil) 代表 XX 條件不成立key 不存在),由 Update 翻譯成 ErrNoSession。
func (s *RedisUserSessionStore) save(ctx context.Context, rs *redisSession, now time.Time, expectExists bool) (ok bool, err error) {
ttl := s.effectiveTTL(now, time.Unix(0, rs.CreatedAtUnix))
// effectiveTTL == 0 的兩種語意必須分流,否則「有 absolute 上限但 absRemaining 恰好
// 對齊到 0 ns」會被誤判成 PERSIST讓該 key 永不過期、繞過 absolute 上限:
// - s.absoluteTTL == 0 時0 代表合法 PERSISTidle+absolute 皆停用)→ 放行寫入。
// - s.absoluteTTL > 0 時,<=0 代表已到或超過absolute deadline → 不應寫入,
// 視為過期Update 翻譯成 ErrNoSession。用 <= 同時涵蓋 <0 與 ==0。
if ttl <= 0 && s.absoluteTTL > 0 {
// 已到 / 超過 absolute deadline不應再寫入Update 視為過期)。
return false, ErrSessionExpired
}
payload, err := json.Marshal(rs)
if err != nil {
return false, fmt.Errorf("usersession: marshal session: %w", err)
}
// ttl == 0 → 永不過期redis SET 不帶 expiration
args := redis.SetArgs{}
if ttl > 0 {
args.TTL = ttl
}
if expectExists {
args.Mode = "XX"
}
// SetArgs 回傳 redis.Nil 代表 XX 不成立key 不存在)。
res, err := s.client.SetArgs(ctx, redisKey(rs.ID), payload, args).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return false, nil // XX 條件不成立
}
return false, fmt.Errorf("usersession: redis set: %w", err)
}
_ = res
return true, nil
}
// Create 實作 Store.Create。產生隨機 ID 的新 session 並寫入 RedisTTL = idle 與 absolute 取小)。
func (s *RedisUserSessionStore) Create(ctx context.Context) (*Session, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
id, err := generateSessionID()
if err != nil {
return nil, err
}
now := nowFunc()
sess := &Session{
ID: id,
CreatedAt: now,
LastSeenAt: now,
}
rs := toRedisSession(sess)
ok, err := s.save(ctx, rs, now, false)
if err != nil {
return nil, err
}
if !ok {
// 普通 SET非 XX理論上不會回 !ok保險處理為 internal error。
return nil, ErrInvalidConfig
}
return copySessionValue(sess), nil
}
// Get 實作 Store.Get。
//
// 找不到(含已被 Redis TTL 清掉)回 ErrNoSession。**不**續期 idle TTL對齊 InMemoryStore
// Get 不更新 LastSeenAt避免無條件刷新延長 idle window
//
// absolute 精準防護:取出後再算一次 now - CreatedAt > absolute超過則 Delete + 回 ErrNoSession。
func (s *RedisUserSessionStore) Get(ctx context.Context, id string) (*Session, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if id == "" {
return nil, ErrNoSession
}
val, err := s.client.Get(ctx, redisKey(id)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, ErrNoSession
}
return nil, fmt.Errorf("usersession: redis get: %w", err)
}
var rs redisSession
if err := json.Unmarshal([]byte(val), &rs); err != nil {
return nil, fmt.Errorf("usersession: unmarshal session: %w", err)
}
sess := fromRedisSession(&rs)
// absolute deadline 精準防護(雙重保險,理論上 key 早被 TTL 清掉)。
if s.absoluteTTL > 0 && nowFunc().Sub(sess.CreatedAt) > s.absoluteTTL {
_ = s.client.Del(ctx, redisKey(id)).Err()
return nil, ErrNoSession
}
return sess, nil
}
// Update 實作 Store.Update。
//
// 把 caller 改過的 Session 寫回 Redis、LastSeenAt 設 now、並續期 idle TTL同時仍受 absolute 上限)。
// 找不到 ID含已過期被清回 ErrNoSession不會「順便建立」用 SET XX 達成)。
func (s *RedisUserSessionStore) Update(ctx context.Context, sess *Session) error {
if err := ctx.Err(); err != nil {
return err
}
if sess == nil || sess.ID == "" {
return ErrNoSession
}
now := nowFunc()
// 先確認既有 key 存在並取得 CreatedAtcaller 傳進來的 CreatedAt 可能被誤改;
// 以 store 內既有值為準,對齊 InMemoryStore「Update 不改 CreatedAt」語意
existing, err := s.client.Get(ctx, redisKey(sess.ID)).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrNoSession
}
return fmt.Errorf("usersession: redis get (update): %w", err)
}
var existingRS redisSession
if err := json.Unmarshal([]byte(existing), &existingRS); err != nil {
return fmt.Errorf("usersession: unmarshal session (update): %w", err)
}
// absolute 已過 → 視為不存在(順手刪)。
createdAt := time.Unix(0, existingRS.CreatedAtUnix)
if s.absoluteTTL > 0 && now.Sub(createdAt) > s.absoluteTTL {
_ = s.client.Del(ctx, redisKey(sess.ID)).Err()
return ErrNoSession
}
rs := toRedisSession(sess)
rs.CreatedAtUnix = existingRS.CreatedAtUnix // 保留既有 CreatedAt
rs.LastSeenAtUnix = now.UnixNano() // 續期LastSeenAt = now
ok, err := s.save(ctx, rs, now, true) // XXkey 必須仍存在
if err != nil {
if errors.Is(err, ErrSessionExpired) {
// effectiveTTL <= 0 且有 absolute 上限:已到 / 超過 absolute視為不存在。
_ = s.client.Del(ctx, redisKey(sess.ID)).Err()
return ErrNoSession
}
return err
}
if !ok {
// XX 不成立key 在 Get 與 SET 之間剛好過期。
return ErrNoSession
}
// 把更新後的 LastSeenAt 反映回 caller pointer對齊 InMemoryStore.Update
sess.LastSeenAt = time.Unix(0, rs.LastSeenAtUnix)
sess.CreatedAt = createdAt
return nil
}
// Delete 實作 Store.Delete。不存在為 no-opDEL 對不存在 key 回 0不報錯
func (s *RedisUserSessionStore) Delete(ctx context.Context, id string) error {
if err := ctx.Err(); err != nil {
return err
}
if id == "" {
return nil
}
if err := s.client.Del(ctx, redisKey(id)).Err(); err != nil {
return fmt.Errorf("usersession: redis del: %w", err)
}
return nil
}
// CleanupExpired 實作 Store.CleanupExpired。
//
// Redis 版**靠 key TTL 自動過期**,不需 background 掃描;此方法在 Redis 模式下基本是 no-op
// 保留只為滿足 Store interfacein-memory fallback 仍會用到)。
//
// 回傳 (0, nil)Redis 已自動清掉過期 key無「本次清除數量」可報。
// 參數 idleTimeout / absoluteTimeout 被忽略TTL 已在寫入時依 store 持有的 idle/absolute 設定)。
func (s *RedisUserSessionStore) CleanupExpired(ctx context.Context, idleTimeout, absoluteTimeout time.Duration) (int, error) {
if err := ctx.Err(); err != nil {
return 0, err
}
// no-opRedis TTL 負責過期。
return 0, nil
}
// toRedisSession 把 Session 轉成可序列化的 redisSession時間轉 UnixNano
func toRedisSession(sess *Session) *redisSession {
return &redisSession{
ID: sess.ID,
UserID: sess.UserID,
Email: sess.Email,
Name: sess.Name,
CreatedAtUnix: sess.CreatedAt.UnixNano(),
LastSeenAtUnix: sess.LastSeenAt.UnixNano(),
OIDCState: sess.OIDCState,
OIDCNonce: sess.OIDCNonce,
OIDCCodeVerifier: sess.OIDCCodeVerifier,
AccessToken: sess.AccessToken,
IDTokenRaw: sess.IDTokenRaw,
Extra: sess.Extra,
}
}
// fromRedisSession 把序列化結構還原成 SessionUnixNano 轉 time.Time
func fromRedisSession(rs *redisSession) *Session {
return &Session{
ID: rs.ID,
UserID: rs.UserID,
Email: rs.Email,
Name: rs.Name,
CreatedAt: time.Unix(0, rs.CreatedAtUnix),
LastSeenAt: time.Unix(0, rs.LastSeenAtUnix),
OIDCState: rs.OIDCState,
OIDCNonce: rs.OIDCNonce,
OIDCCodeVerifier: rs.OIDCCodeVerifier,
AccessToken: rs.AccessToken,
IDTokenRaw: rs.IDTokenRaw,
Extra: rs.Extra,
}
}
// copySessionValue 製作 Session 的副本(含 Extra map 深一層),避免 caller 改到 store 回傳值。
//
// 與 InMemoryStore.copySession 等價,但為 free functionRedis store 無需持鎖)。
func copySessionValue(src *Session) *Session {
dst := *src
if src.Extra != nil {
dst.Extra = make(map[string]any, len(src.Extra))
for k, v := range src.Extra {
dst.Extra[k] = v
}
}
return &dst
}