| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119 |
- // 微信扫码登录状态机存储(Redis,per-state 一次性会话)。
- //
- // 键空间:wxqrcode:{state} → 序列化后的 WechatState(JSON)。
- // TTL 由调用方传入(Service 层固定 5min,覆盖"生成二维码 → 用户扫码 → 前端轮询到 success"的窗口)。
- //
- // 设计要点:
- // - state 是 16 字节随机 hex(32 字符,不含 ":",无需转义),由 Service 生成后经 Set 写入;
- // - 双实例(ADR-015)共享同一 Redis:state 落共享键空间,实例 A 生成的二维码在实例 B 轮询/
- // 回调时也能命中——无需 per-instance 命名空间(与登录票据 loginticket: 前缀对称设计);
- // - state 记录**可变**(pending → success/unbound),故用 GET/SET 而非 GETDEL:
- // 前端轮询是"只读快照"(不消费),只有 callback 写入终态(success/unbound);
- // - 终态后不再变更:success 的 ticket 由 ② /auth/issue 经 LoginTicketStore.GETDEL 原子消费,
- // 本 store 只负责"承载 ticket + 作用域提示",不承担一次性语义(一次性在票据层保证)。
- //
- // 依赖方向:本包属 infra/redis,不依赖 identity(依赖方向铁律)。
- package redis
- import (
- "context"
- "encoding/json"
- "fmt"
- "time"
- gredis "github.com/redis/go-redis/v9"
- "yuansiwei/unified-backend/internal/infra/errors"
- )
- // wechatStateKeyPrefix 键前缀(硬编码,与 loginticket: / refresh: 对称设计)。
- const wechatStateKeyPrefix = "wxqrcode:"
- // WechatState 扫码状态机的持久化载荷(一个 state 对应的一次扫码会话)。
- //
- // 字段:
- // - Status:状态机取值(见 WechatQrcodeStatus* 常量)。
- // - CampusID:本次扫码选定的校区(决定用哪个 appid 换 openid + 提示前端作用域)。
- // - Ticket:success 时的登录票据(② /auth/issue 消费;pending 时空)。
- // - UserID:success 时绑定用户的 ULID(前端轮询到 success 后用于展示"已登录 X 的会员")。
- type WechatState struct {
- Status string `json:"status"`
- CampusID int64 `json:"campusId"`
- Ticket string `json:"ticket,omitempty"`
- UserID string `json:"userId,omitempty"`
- }
- // WechatQrcodeStore 扫码状态机存储(*Client 直接实现,装配时构造)。
- type WechatQrcodeStore struct {
- *Client
- }
- // NewWechatQrcodeStore 构造扫码状态机存储。
- //
- // 入参:rc *Client:Redis 客户端句柄(必填,NewClient 探活成功后的实例)。
- // 出参:*WechatQrcodeStore:就绪存储。
- func NewWechatQrcodeStore(rc *Client) *WechatQrcodeStore {
- return &WechatQrcodeStore{Client: rc}
- }
- // stateKey 拼键:wxqrcode:{state}。
- func (s *WechatQrcodeStore) stateKey(state string) string {
- return wechatStateKeyPrefix + state
- }
- // SetState 写入/更新一个 state 的载荷(callback 写终态、qrcode 写初态)。
- //
- // 入参:
- // - ctx context.Context:请求上下文。
- // - state string:会话标识(16 字节 hex)。
- // - st WechatState:要持久化的载荷。
- // - ttl time.Duration:有效期(过期自动删除,未扫码的会话自然失效)。
- //
- // 出参:error:Redis SET 失败(连接故障/超时)时返回 errors.ErrDBError 包装错误。
- func (s *WechatQrcodeStore) SetState(ctx context.Context, state string, st WechatState, ttl time.Duration) error {
- raw, err := json.Marshal(st)
- if err != nil {
- // WechatState 全是基础类型,Marshal 实际不会失败;防御性兜底(fail-closed)
- return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 序列化扫码状态 state=%s 失败: %w", state, err))
- }
- if err := s.Client.Set(ctx, s.stateKey(state), raw, ttl).Err(); err != nil {
- return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 写扫码状态 state=%s 失败: %w", state, err))
- }
- return nil
- }
- // GetState 读取一个 state 的载荷(前端轮询只读快照;不消费、不改写)。
- //
- // 入参:
- // - ctx context.Context:请求上下文。
- // - state string:会话标识。
- //
- // 出参:
- // - WechatState:当前载荷;state 不存在/已过期时返回 zero 值 + err=nil(调用方按"已失效"处理)。
- // - error:Redis 故障(连接失败/超时)——**调用方必须阻断**,fail-closed(不把存储故障降级为"状态未知")。
- func (s *WechatQrcodeStore) GetState(ctx context.Context, state string) (WechatState, error) {
- var out WechatState
- raw, err := s.Client.Get(ctx, s.stateKey(state)).Result()
- switch {
- case err == nil:
- if uerr := json.Unmarshal([]byte(raw), &out); uerr != nil {
- return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 反序列化扫码状态 state=%s 失败: %w", state, uerr))
- }
- return out, nil
- case err == gredis.Nil:
- return out, nil // 未命中:state 不存在/已过期(调用方按"扫码已失效,请重新获取"处理)
- default:
- return out, errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 读扫码状态 state=%s 失败: %w", state, err))
- }
- }
- // ClearState 删除一个 state(登出/失效时主动清理;幂等)。
- //
- // 入参:ctx context.Context;state string:会话标识。
- // 出参:error:Redis DEL 失败时返回 errors.ErrDBError(调用方按需告警,不阻断主链路)。
- func (s *WechatQrcodeStore) ClearState(ctx context.Context, state string) error {
- if err := s.Client.Del(ctx, s.stateKey(state)).Err(); err != nil {
- return errors.WithCause(errors.ErrDBError, fmt.Errorf("redis: 删扫码状态 state=%s 失败: %w", state, err))
- }
- return nil
- }
|