// 微信扫码登录状态机存储(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 的会员")。 // - Memberships:success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录 // **同一套** ② issue 分流:单条自动②、多条进作用域选择页)。载荷冗余自 t_membership, // 使"轮询 status"与"callback 落终态"两条读法都无需回查 DB(扫码会话一次性,作用域在 5min 窗口内不变)。 type WechatState struct { Status string `json:"status"` CampusID int64 `json:"campusId"` Ticket string `json:"ticket,omitempty"` UserID string `json:"userId,omitempty"` Memberships []MembershipInfo `json:"memberships,omitempty"` } // MembershipInfo 可选作用域的最小载荷(机构/校区/角色/状态;与 identity 的 MembershipView 字段对齐)。 // 在 infra/redis 定义(而非引用 identity/model),避免 infra 反向依赖 identity(依赖方向铁律); // 消费方(identity/service WechatService)负责 orgmodel.Membership → MembershipInfo 的映射。 type MembershipInfo struct { MembershipID int64 `json:"membershipId"` InstitutionID int64 `json:"institutionId"` CampusID int64 `json:"campusId"` Role string `json:"role"` Status string `json:"status"` } // 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 }