wechat_service.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. // 微信扫码登录服务(PC 端扫码登录链路编排,复用两步登录②票据机制)。
  2. //
  3. // 与 AuthService(账密两步登录)的关系:
  4. // - 本服务**复用**两步登录的"② 签 token"机制——扫码成功后生成一张 loginTicket 存入 Redis,
  5. // 前端拿到 status=success + loginTicket 后,与账密登录**完全同一套** ② /auth/issue 签 token。
  6. // 不在此处直接签 token(不重造 ②,避免"作用域校验/菜单下发"逻辑两处漂移)。
  7. // - 本服务只做微信扫码特有的三件事:① 生成二维码(state 落 Redis);② 微信回调 code 换 openid →
  8. // 查 t_identity(wechat) → 发 loginTicket 写回 state;③ 前端轮询读 state 返回进度。
  9. //
  10. // 依赖方向:service → wechat.Client(第三方)+ redis.WeChatQrcodeStore/LoginTicketStore +
  11. // repository.IdentityRepository + model;不依赖 handler/main(纯业务,可纯单测:
  12. // fake wechat.Client + miniredis 双 store + mock identity repo)。
  13. package service
  14. import (
  15. "context"
  16. "crypto/rand"
  17. "encoding/hex"
  18. stderrors "errors"
  19. "time"
  20. "yuansiwei/unified-backend/internal/identity/model"
  21. "yuansiwei/unified-backend/internal/identity/repository"
  22. "yuansiwei/unified-backend/internal/identity/wechat"
  23. apperrors "yuansiwei/unified-backend/internal/infra/errors"
  24. appredis "yuansiwei/unified-backend/internal/infra/redis"
  25. )
  26. // WeChat 扫码状态机取值(state.Status 字段;前端据状态决定是否继续轮询)。
  27. const (
  28. // WechatQrcodePending 已生成二维码、等待用户扫码(前端继续轮询)。
  29. WechatQrcodePending = "pending"
  30. // WechatQrcodeSuccess 扫码成功、已绑定用户、已发 loginTicket(前端停轮询,携 ticket 走 ② issue)。
  31. WechatQrcodeSuccess = "success"
  32. // WechatQrcodeUnbound 扫码成功但 openid 未绑定本平台账号(前端停轮询,提示"该微信号未绑定账号")。
  33. WechatQrcodeUnbound = "unbound"
  34. // WechatQrcodeFail 微信换 openid 失败(第三方错误)(前端停轮询,提示"扫码失败,请重试")。
  35. WechatQrcodeFail = "fail"
  36. )
  37. // defaultWechatStateTTL 扫码会话有效期(生成二维码 → 扫码 → 轮询到 success 的窗口;
  38. // 与 loginTicketTTL 对齐 5min,给前端留足操作时间)。
  39. const defaultWechatStateTTL = 5 * time.Minute
  40. // WechatService 微信扫码登录业务服务(状态机 + 复用两步票据)。
  41. //
  42. // 字段(装配入口 NewWechatService 注入,均必填):
  43. // - client:微信网页授权客户端(code 换 openid;生产 *wechat.HTTPClient,单测 fake)。
  44. // - states:扫码状态机存储(per-state 会话,Redis;双实例共享)。
  45. // - tickets:登录票据存储(扫码成功后签发 loginTicket,复用 AuthService 同一存储)。
  46. // - identities:登录身份仓储(查 (type=wechat, identifier=open_id) → canonical_user_id)。
  47. // - campuses:per-campus 微信应用配置(来自 config.WeChatCfg.Campuses,决定 appid/secret)。
  48. // - callbackURL:对外回调地址(微信重定向落点;未配置时 New 报错,不允许"无回调地址生成二维码")。
  49. type WechatService struct {
  50. client wechat.Client
  51. states *appredis.WechatQrcodeStore
  52. tickets *appredis.LoginTicketStore
  53. identities repository.IdentityRepository
  54. campuses []WechatCampus
  55. callbackURL string
  56. stateTTL time.Duration
  57. ticketTTL time.Duration
  58. }
  59. // WechatCampus per-campus 微信应用(从 config.WeChatCampus 转换;Service 自持避免 infra/config 泄漏进单测)。
  60. type WechatCampus struct {
  61. CampusID int64
  62. AppID string
  63. Secret string
  64. }
  65. // WechatConfig WechatService 装配参数(main 按此构造;全部必填,缺则 New 报错)。
  66. type WechatConfig struct {
  67. Client wechat.Client
  68. States *appredis.WechatQrcodeStore
  69. Tickets *appredis.LoginTicketStore
  70. Identities repository.IdentityRepository
  71. Campuses []WechatCampus
  72. CallbackURL string
  73. // StateTTL / TicketTTL 可选:0/缺省用 5min。
  74. StateTTL time.Duration
  75. TicketTTL time.Duration
  76. }
  77. // NewWechatService 构造微信扫码登录服务(启动装配一次,全进程共享)。
  78. //
  79. // 入参:cfg WechatConfig:见 WechatConfig 字段说明。
  80. // 出参:
  81. // - *WechatService:就绪服务。
  82. // - error:必填依赖缺失(nil 检查,fail-fast)/ 无 campus 配置 / callbackURL 为空。
  83. func NewWechatService(cfg WechatConfig) (*WechatService, error) {
  84. if cfg.Client == nil {
  85. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 微信客户端未注入")
  86. }
  87. if cfg.States == nil {
  88. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 扫码状态机存储未注入")
  89. }
  90. if cfg.Tickets == nil {
  91. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录票据存储未注入")
  92. }
  93. if cfg.Identities == nil {
  94. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录身份仓储未注入")
  95. }
  96. if len(cfg.Campuses) == 0 {
  97. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 无 per-campus 微信应用配置(Campuses 至少 1 条)")
  98. }
  99. if cfg.CallbackURL == "" {
  100. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 回调地址为空(CallbackURL 必填,且须为已登记的 https 网页授权回调域名)")
  101. }
  102. stTTL := cfg.StateTTL
  103. if stTTL <= 0 {
  104. stTTL = defaultWechatStateTTL
  105. }
  106. tkTTL := cfg.TicketTTL
  107. if tkTTL <= 0 {
  108. tkTTL = defaultWechatStateTTL
  109. }
  110. return &WechatService{
  111. client: cfg.Client,
  112. states: cfg.States,
  113. tickets: cfg.Tickets,
  114. identities: cfg.Identities,
  115. campuses: cfg.Campuses,
  116. callbackURL: cfg.CallbackURL,
  117. stateTTL: stTTL,
  118. ticketTTL: tkTTL,
  119. }, nil
  120. }
  121. // QRCodeResult 生成二维码出参(前端渲染 + 轮询凭据)。
  122. type QRCodeResult struct {
  123. // State 会话标识(16 字节 hex;前端据此轮询 /auth/wechat/status?state=)。
  124. State string `json:"state"`
  125. // QRURL 二维码内容(指向微信网页授权 URL,含 state;前端生成二维码图片)。
  126. QRURL string `json:"qrUrl"`
  127. // CampusID 本次选定的校区(提示前端"正在登录 X 校区")。
  128. CampusID int64 `json:"campusId"`
  129. // ExpiresIn 会话有效期(秒;前端据此展示倒计时/停止轮询)。
  130. ExpiresIn int64 `json:"expiresIn"`
  131. }
  132. // Qrcode 生成扫码登录二维码(GET /auth/wechat/qrcode 编排)。
  133. //
  134. // 行为:选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) → 返回 state + QR URL。
  135. //
  136. // 出参:
  137. // - *QRCodeResult:state + 二维码 URL。
  138. // - error:*AppError(CodeInternal:Redis 写失败——fail-closed,不放行"无 state 的二维码")。
  139. func (s *WechatService) Qrcode(ctx context.Context) (*QRCodeResult, error) {
  140. campus := s.campuses[0] // 多 campus 选第一个(后续可加 campus 选择入参,本期 PC 统一)
  141. state, err := s.newState()
  142. if err != nil {
  143. return nil, apperrors.WithCause(apperrors.ErrInternal, err)
  144. }
  145. st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID}
  146. if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil {
  147. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  148. }
  149. return &QRCodeResult{
  150. State: state,
  151. QRURL: wechat.BuildQRConnectURL(campus.AppID, s.callbackURL, state),
  152. CampusID: campus.CampusID,
  153. ExpiresIn: int64(s.stateTTL / time.Second),
  154. }, nil
  155. }
  156. // StatusResult 轮询状态出参(前端据此驱动 UI:pending 继续轮 / success 携 ticket 走 ② / 终态停轮)。
  157. type StatusResult struct {
  158. // Status 状态机取值(pending/success/unbound/fail,见 WechatQrcode* 常量)。
  159. Status string `json:"status"`
  160. // CampusID 本次会话的校区(success/unbound 时回显,供前端提示)。
  161. CampusID int64 `json:"campusId"`
  162. // LoginTicket success 时携带的登录票据(前端用同一套 ② /auth/issue 签 token)。
  163. LoginTicket string `json:"loginTicket,omitempty"`
  164. // UserID success 时绑定的用户 ULID(前端可据此展示"已登录 X")。
  165. UserID string `json:"userId,omitempty"`
  166. // Expired true = state 不存在/已过期(前端停止轮询,提示"二维码已失效,请刷新")。
  167. // 与 Status 互斥:Expired=true 时 Status 为空串(调用方按 Expired 优先判断)。
  168. Expired bool `json:"expired,omitempty"`
  169. }
  170. // Status 轮询扫码状态(GET /auth/wechat/status?state= 编排;只读快照,不消费)。
  171. //
  172. // 出参:
  173. // - *StatusResult:当前状态(见 StatusResult 字段)。
  174. // - error:*AppError(CodeInternal/DBError:Redis 读失败——fail-closed,不放行"读不到当已失效")。
  175. func (s *WechatService) Status(ctx context.Context, state string) (*StatusResult, error) {
  176. st, err := s.states.GetState(ctx, state)
  177. if err != nil {
  178. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  179. }
  180. if st.Status == "" {
  181. // 键不存在/已过期:返回"已失效"(前端停轮询,提示刷新)——这是正常的"过期"语义,非故障。
  182. return &StatusResult{Expired: true}, nil
  183. }
  184. res := &StatusResult{Status: st.Status, CampusID: st.CampusID}
  185. if st.Status == WechatQrcodeSuccess {
  186. res.LoginTicket = st.Ticket
  187. res.UserID = st.UserID
  188. }
  189. return res, nil
  190. }
  191. // Callback 微信重定向落点(GET /auth/wechat/callback?code=&state= 编排)。
  192. //
  193. // 行为:读 state 会话 → 校验仍 pending(防重放)→ code 换 openid → 查 t_identity(wechat) →
  194. // - 命中:发 loginTicket(复用 ② 机制)写回 state=success;
  195. // - 未命中:state=unbound(提示"该微信号未绑定账号")。
  196. //
  197. // 出参:
  198. // - *StatusResult:回调后的状态(success/unbound/fail,前端据此决定跳登录或提示)。
  199. // - error:*AppError:
  200. // - ErrDBError(5002):Redis 读写故障(fail-closed);
  201. // - CodeThirdTimeout(4003):微信换 openid 失败(state 置 fail);
  202. // - ErrUnauth(1001):state 不存在/已过期/已终态(防重放,前端提示"二维码已失效")。
  203. func (s *WechatService) Callback(ctx context.Context, code, state string) (*StatusResult, error) {
  204. if code == "" || state == "" {
  205. return nil, apperrors.ErrBadParam
  206. }
  207. st, err := s.states.GetState(ctx, state)
  208. if err != nil {
  209. return nil, err // Redis 故障 fail-closed
  210. }
  211. if st.Status == "" {
  212. // state 不存在/已过期:拒绝(防重放 + 防任意 state 注入)
  213. return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已失效,请重新获取二维码")
  214. }
  215. if st.Status != WechatQrcodePending {
  216. // 已终态(success/unbound/fail):拒绝二次回调(防重放)
  217. return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已结束,请重新获取二维码")
  218. }
  219. campus := s.campusByID(st.CampusID)
  220. if campus == nil {
  221. // 会话 campusId 对不上配置(配置热变更/脏数据):置 fail
  222. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
  223. return &StatusResult{Status: WechatQrcodeFail, CampusID: st.CampusID}, nil
  224. }
  225. // code 换 openid(第三方;失败置 fail)
  226. openid, err := s.client.ExchangeCode(ctx, campus.AppID, campus.Secret, code, s.callbackURL)
  227. if err != nil {
  228. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
  229. return nil, apperrors.New(apperrors.CodeThirdTimeout, "微信扫码失败,请重试") // 4003 第三方
  230. }
  231. // 查绑定:(type=wechat, identifier=open_id) → canonical_user_id
  232. it, err := s.identities.GetByTypeIdentifier(ctx, model.IdentityTypeWechat, openid)
  233. if err != nil {
  234. if stderrors.Is(err, apperrors.ErrNotFound) {
  235. // 未绑定:置 unbound(前端提示"该微信号未绑定账号",引导去账密登录后绑定)
  236. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, s.stateTTL)
  237. return &StatusResult{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, nil
  238. }
  239. return nil, err // *AppError(ErrDBError)
  240. }
  241. // 已绑定:发 loginTicket(复用 ② 机制)写回 state=success
  242. ticket, terr := s.newTicket()
  243. if terr != nil {
  244. return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
  245. }
  246. if setErr := s.tickets.SetTicket(ctx, ticket, it.CanonicalUserID, s.ticketTTL); setErr != nil {
  247. return nil, setErr // *AppError(ErrDBError)——票据写失败阻断(不放行半套)
  248. }
  249. final := appredis.WechatState{
  250. Status: WechatQrcodeSuccess,
  251. CampusID: st.CampusID,
  252. Ticket: ticket,
  253. UserID: it.CanonicalUserID,
  254. }
  255. if setErr := s.states.SetState(ctx, state, final, s.stateTTL); setErr != nil {
  256. return nil, setErr
  257. }
  258. return &StatusResult{Status: WechatQrcodeSuccess, CampusID: st.CampusID, LoginTicket: ticket, UserID: it.CanonicalUserID}, nil
  259. }
  260. // ---- 私有工具 ----
  261. // campusByID 按 campusId 取配置(找不到返回 nil——调用方按"脏数据"置 fail)。
  262. func (s *WechatService) campusByID(campusID int64) *WechatCampus {
  263. for i := range s.campuses {
  264. if s.campuses[i].CampusID == campusID {
  265. return &s.campuses[i]
  266. }
  267. }
  268. return nil
  269. }
  270. // newState 生成扫码会话 state(16 字节随机 → 32 hex)。
  271. func (s *WechatService) newState() (string, error) {
  272. buf := make([]byte, 16)
  273. if _, err := rand.Read(buf); err != nil {
  274. return "", err
  275. }
  276. return hex.EncodeToString(buf), nil
  277. }
  278. // newTicket 生成登录票据(16 字节随机 → 32 hex,与 AuthService.newTicket 同语义)。
  279. func (s *WechatService) newTicket() (string, error) {
  280. buf := make([]byte, 16)
  281. if _, err := rand.Read(buf); err != nil {
  282. return "", err
  283. }
  284. return hex.EncodeToString(buf), nil
  285. }