wechat_qrcode_store.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  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. // - Memberships:success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录
  35. // **同一套** ② issue 分流:单条自动②、多条进作用域选择页)。载荷冗余自 t_membership,
  36. // 使"轮询 status"与"callback 落终态"两条读法都无需回查 DB(扫码会话一次性,作用域在 5min 窗口内不变)。
  37. type WechatState struct {
  38. Status string `json:"status"`
  39. CampusID int64 `json:"campusId"`
  40. Ticket string `json:"ticket,omitempty"`
  41. UserID string `json:"userId,omitempty"`
  42. Memberships []MembershipInfo `json:"memberships,omitempty"`
  43. }
  44. // MembershipInfo 可选作用域的最小载荷(机构/校区/角色/状态;与 identity 的 MembershipView 字段对齐)。
  45. // 在 infra/redis 定义(而非引用 identity/model),避免 infra 反向依赖 identity(依赖方向铁律);
  46. // 消费方(identity/service WechatService)负责 orgmodel.Membership → MembershipInfo 的映射。
  47. type MembershipInfo struct {
  48. MembershipID int64 `json:"membershipId"`
  49. InstitutionID int64 `json:"institutionId"`
  50. CampusID int64 `json:"campusId"`
  51. Role string `json:"role"`
  52. Status string `json:"status"`
  53. }
  54. // WechatQrcodeStore 扫码状态机存储(*Client 直接实现,装配时构造)。
  55. type WechatQrcodeStore struct {
  56. *Client
  57. }
  58. // NewWechatQrcodeStore 构造扫码状态机存储。
  59. //
  60. // 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
  61. // 出参:*WechatQrcodeStore:就绪存储。
  62. func NewWechatQrcodeStore(rc *Client) *WechatQrcodeStore {
  63. return &WechatQrcodeStore{Client: rc}
  64. }
  65. // stateKey 拼键:wxqrcode:{state}。
  66. func (s *WechatQrcodeStore) stateKey(state string) string {
  67. return wechatStateKeyPrefix + state
  68. }
  69. // SetState 写入/更新一个 state 的载荷(callback 写终态、qrcode 写初态)。
  70. //
  71. // 入参:
  72. // - ctx context.Context:请求上下文。
  73. // - state string:会话标识(16 字节 hex)。
  74. // - st WechatState:要持久化的载荷。
  75. // - ttl time.Duration:有效期(过期自动删除,未扫码的会话自然失效)。
  76. //
  77. // 出参:error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
  78. func (s *WechatQrcodeStore) SetState(ctx context.Context, state string, st WechatState, ttl time.Duration) error {
  79. raw, err := json.Marshal(st)
  80. if err != nil {
  81. // WechatState 全是基础类型,Marshal 实际不会失败;防御性兜底(fail-closed)
  82. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 序列化扫码状态 state=%s 失败: %w", state, err))
  83. }
  84. if err := s.Client.Set(ctx, s.stateKey(state), raw, ttl).Err(); err != nil {
  85. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写扫码状态 state=%s 失败: %w", state, err))
  86. }
  87. return nil
  88. }
  89. // GetState 读取一个 state 的载荷(前端轮询只读快照;不消费、不改写)。
  90. //
  91. // 入参:
  92. // - ctx context.Context:请求上下文。
  93. // - state string:会话标识。
  94. //
  95. // 出参:
  96. // - WechatState:当前载荷;state 不存在/已过期时返回 zero 值 + err=nil(调用方按"已失效"处理)。
  97. // - error:Redis 故障(连接失败/超时)——**调用方必须阻断**,fail-closed(不把存储故障降级为"状态未知")。
  98. func (s *WechatQrcodeStore) GetState(ctx context.Context, state string) (WechatState, error) {
  99. var out WechatState
  100. raw, err := s.Client.Get(ctx, s.stateKey(state)).Result()
  101. switch {
  102. case err == nil:
  103. if uerr := json.Unmarshal([]byte(raw), &out); uerr != nil {
  104. return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 反序列化扫码状态 state=%s 失败: %w", state, uerr))
  105. }
  106. return out, nil
  107. case err == gredis.Nil:
  108. return out, nil // 未命中:state 不存在/已过期(调用方按"扫码已失效,请重新获取"处理)
  109. default:
  110. return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 读扫码状态 state=%s 失败: %w", state, err))
  111. }
  112. }
  113. // ClearState 删除一个 state(登出/失效时主动清理;幂等)。
  114. //
  115. // 入参:ctx context.Context;state string:会话标识。
  116. // 出参:error:Redis DEL 失败时返回 errors.ErrDBError(调用方按需告警,不阻断主链路)。
  117. func (s *WechatQrcodeStore) ClearState(ctx context.Context, state string) error {
  118. if err := s.Client.Del(ctx, s.stateKey(state)).Err(); err != nil {
  119. return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 删扫码状态 state=%s 失败: %w", state, err))
  120. }
  121. return nil
  122. }