wechat_qrcode_store.go 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119
  1. // 微信扫码登录状态机存储(Redis,per-state 一次性会话)。
  2. //
  3. // 键空间:wxqrcode:{state} → 序列化后的 WechatState(JSON)。
  4. // TTL 由调用方传入(Service 层固定 5min,覆盖"生成二维码 → 用户扫码 → 前端轮询到 success"的窗口)。
  5. //
  6. // 设计要点:
  7. // - state 是 16 字节随机 hex(32 字符,不含 ":",无需转义),由 Service 生成后经 Set 写入;
  8. // - 双实例(ADR-015)共享同一 Redis:state 落共享键空间,实例 A 生成的二维码在实例 B 轮询/
  9. // 回调时也能命中——无需 per-instance 命名空间(与登录票据 loginticket: 前缀对称设计);
  10. // - state 记录**可变**(pending → success/unbound),故用 GET/SET 而非 GETDEL:
  11. // 前端轮询是"只读快照"(不消费),只有 callback 写入终态(success/unbound);
  12. // - 终态后不再变更:success 的 ticket 由 ② /auth/issue 经 LoginTicketStore.GETDEL 原子消费,
  13. // 本 store 只负责"承载 ticket + 作用域提示",不承担一次性语义(一次性在票据层保证)。
  14. //
  15. // 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。
  16. package redis
  17. import (
  18. "context"
  19. "encoding/json"
  20. "fmt"
  21. "time"
  22. gredis "github.com/redis/go-redis/v9"
  23. "yuansiwei/unified-backend/internal/infra/errors"
  24. )
  25. // wechatStateKeyPrefix 键前缀(硬编码,与 loginticket: / refresh: 对称设计)。
  26. const wechatStateKeyPrefix = "wxqrcode:"
  27. // WechatState 扫码状态机的持久化载荷(一个 state 对应的一次扫码会话)。
  28. //
  29. // 字段:
  30. // - Status:状态机取值(见 WechatQrcodeStatus* 常量)。
  31. // - CampusID:本次扫码选定的校区(决定用哪个 appid 换 openid + 提示前端作用域)。
  32. // - Ticket:success 时的登录票据(② /auth/issue 消费;pending 时空)。
  33. // - UserID:success 时绑定用户的 ULID(前端轮询到 success 后用于展示"已登录 X 的会员")。
  34. type WechatState struct {
  35. Status string `json:"status"`
  36. CampusID int64 `json:"campusId"`
  37. Ticket string `json:"ticket,omitempty"`
  38. UserID string `json:"userId,omitempty"`
  39. }
  40. // WechatQrcodeStore 扫码状态机存储(*Client 直接实现,装配时构造)。
  41. type WechatQrcodeStore struct {
  42. *Client
  43. }
  44. // NewWechatQrcodeStore 构造扫码状态机存储。
  45. //
  46. // 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
  47. // 出参:*WechatQrcodeStore:就绪存储。
  48. func NewWechatQrcodeStore(rc *Client) *WechatQrcodeStore {
  49. return &WechatQrcodeStore{Client: rc}
  50. }
  51. // stateKey 拼键:wxqrcode:{state}。
  52. func (s *WechatQrcodeStore) stateKey(state string) string {
  53. return wechatStateKeyPrefix + state
  54. }
  55. // SetState 写入/更新一个 state 的载荷(callback 写终态、qrcode 写初态)。
  56. //
  57. // 入参:
  58. // - ctx context.Context:请求上下文。
  59. // - state string:会话标识(16 字节 hex)。
  60. // - st WechatState:要持久化的载荷。
  61. // - ttl time.Duration:有效期(过期自动删除,未扫码的会话自然失效)。
  62. //
  63. // 出参:error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
  64. func (s *WechatQrcodeStore) SetState(ctx context.Context, state string, st WechatState, ttl time.Duration) error {
  65. raw, err := json.Marshal(st)
  66. if err != nil {
  67. // WechatState 全是基础类型,Marshal 实际不会失败;防御性兜底(fail-closed)
  68. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 序列化扫码状态 state=%s 失败: %w", state, err))
  69. }
  70. if err := s.Client.Set(ctx, s.stateKey(state), raw, ttl).Err(); err != nil {
  71. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写扫码状态 state=%s 失败: %w", state, err))
  72. }
  73. return nil
  74. }
  75. // GetState 读取一个 state 的载荷(前端轮询只读快照;不消费、不改写)。
  76. //
  77. // 入参:
  78. // - ctx context.Context:请求上下文。
  79. // - state string:会话标识。
  80. //
  81. // 出参:
  82. // - WechatState:当前载荷;state 不存在/已过期时返回 zero 值 + err=nil(调用方按"已失效"处理)。
  83. // - error:Redis 故障(连接失败/超时)——**调用方必须阻断**,fail-closed(不把存储故障降级为"状态未知")。
  84. func (s *WechatQrcodeStore) GetState(ctx context.Context, state string) (WechatState, error) {
  85. var out WechatState
  86. raw, err := s.Client.Get(ctx, s.stateKey(state)).Result()
  87. switch {
  88. case err == nil:
  89. if uerr := json.Unmarshal([]byte(raw), &out); uerr != nil {
  90. return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 反序列化扫码状态 state=%s 失败: %w", state, uerr))
  91. }
  92. return out, nil
  93. case err == gredis.Nil:
  94. return out, nil // 未命中:state 不存在/已过期(调用方按"扫码已失效,请重新获取"处理)
  95. default:
  96. return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 读扫码状态 state=%s 失败: %w", state, err))
  97. }
  98. }
  99. // ClearState 删除一个 state(登出/失效时主动清理;幂等)。
  100. //
  101. // 入参:ctx context.Context;state string:会话标识。
  102. // 出参:error:Redis DEL 失败时返回 errors.ErrDBError(调用方按需告警,不阻断主链路)。
  103. func (s *WechatQrcodeStore) ClearState(ctx context.Context, state string) error {
  104. if err := s.Client.Del(ctx, s.stateKey(state)).Err(); err != nil {
  105. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 删扫码状态 state=%s 失败: %w", state, err))
  106. }
  107. return nil
  108. }