把 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>
387 lines
14 KiB
Go
387 lines
14 KiB
Go
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
|
||
}
|