wechat_service.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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. orgmodel "yuansiwei/unified-backend/internal/org/model"
  26. )
  27. // WeChat 扫码状态机取值(state.Status 字段;前端据状态决定是否继续轮询)。
  28. const (
  29. // WechatQrcodePending 已生成二维码、等待用户扫码(前端继续轮询)。
  30. WechatQrcodePending = "pending"
  31. // WechatQrcodeSuccess 扫码成功、已绑定用户、已发 loginTicket(前端停轮询,携 ticket 走 ② issue)。
  32. WechatQrcodeSuccess = "success"
  33. // WechatQrcodeUnbound 扫码成功但 openid 未绑定本平台账号(前端停轮询,提示"该微信号未绑定账号")。
  34. WechatQrcodeUnbound = "unbound"
  35. // WechatQrcodeFail 微信换 openid 失败(第三方错误)(前端停轮询,提示"扫码失败,请重试")。
  36. WechatQrcodeFail = "fail"
  37. )
  38. // defaultWechatStateTTL 扫码会话有效期(生成二维码 → 扫码 → 轮询到 success 的窗口;
  39. // 与 loginTicketTTL 对齐 5min,给前端留足操作时间)。
  40. const defaultWechatStateTTL = 5 * time.Minute
  41. // WechatService 微信扫码登录业务服务(状态机 + 复用两步票据)。
  42. //
  43. // 字段(装配入口 NewWechatService 注入,均必填):
  44. // - client:微信网页授权客户端(code 换 openid;生产 *wechat.HTTPClient,单测 fake)。
  45. // - states:扫码状态机存储(per-state 会话,Redis;双实例共享)。
  46. // - tickets:登录票据存储(扫码成功后签发 loginTicket,复用 AuthService 同一存储)。
  47. // - identities:登录身份仓储(查 (type=wechat, identifier=open_id) → canonical_user_id)。
  48. // - campuses:per-campus 微信应用配置(来自 config.WeChatCfg.Campuses,决定 appid/secret)。
  49. // - callbackURL:对外回调地址(微信重定向落点;未配置时 New 报错,不允许"无回调地址生成二维码")。
  50. type WechatService struct {
  51. client wechat.Client
  52. states *appredis.WechatQrcodeStore
  53. tickets *appredis.LoginTicketStore
  54. identities repository.IdentityRepository
  55. memberships repository.MembershipRepository
  56. campuses []WechatCampus
  57. callbackURL string
  58. stateTTL time.Duration
  59. ticketTTL time.Duration
  60. }
  61. // WechatCampus per-campus 微信应用(从 config.WeChatCampus 转换;Service 自持避免 infra/config 泄漏进单测)。
  62. type WechatCampus struct {
  63. CampusID int64
  64. AppID string
  65. Secret string
  66. }
  67. // WechatConfig WechatService 装配参数(main 按此构造;全部必填,缺则 New 报错)。
  68. type WechatConfig struct {
  69. Client wechat.Client
  70. States *appredis.WechatQrcodeStore
  71. Tickets *appredis.LoginTicketStore
  72. Identities repository.IdentityRepository
  73. Memberships repository.MembershipRepository
  74. Campuses []WechatCampus
  75. CallbackURL string
  76. // StateTTL / TicketTTL 可选:0/缺省用 5min。
  77. StateTTL time.Duration
  78. TicketTTL time.Duration
  79. }
  80. // NewWechatService 构造微信扫码登录服务(启动装配一次,全进程共享)。
  81. //
  82. // 入参:cfg WechatConfig:见 WechatConfig 字段说明。
  83. // 出参:
  84. // - *WechatService:就绪服务。
  85. // - error:必填依赖缺失(nil 检查,fail-fast)/ 无 campus 配置 / callbackURL 为空。
  86. func NewWechatService(cfg WechatConfig) (*WechatService, error) {
  87. if cfg.Client == nil {
  88. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 微信客户端未注入")
  89. }
  90. if cfg.States == nil {
  91. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 扫码状态机存储未注入")
  92. }
  93. if cfg.Tickets == nil {
  94. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录票据存储未注入")
  95. }
  96. if cfg.Identities == nil {
  97. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录身份仓储未注入")
  98. }
  99. if cfg.Memberships == nil {
  100. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 成员关系仓储未注入")
  101. }
  102. if len(cfg.Campuses) == 0 {
  103. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 无 per-campus 微信应用配置(Campuses 至少 1 条)")
  104. }
  105. if cfg.CallbackURL == "" {
  106. return nil, apperrors.New(apperrors.CodeInternal, "wechat: 回调地址为空(CallbackURL 必填,且须为已登记的 https 网页授权回调域名)")
  107. }
  108. stTTL := cfg.StateTTL
  109. if stTTL <= 0 {
  110. stTTL = defaultWechatStateTTL
  111. }
  112. tkTTL := cfg.TicketTTL
  113. if tkTTL <= 0 {
  114. tkTTL = defaultWechatStateTTL
  115. }
  116. return &WechatService{
  117. client: cfg.Client,
  118. states: cfg.States,
  119. tickets: cfg.Tickets,
  120. identities: cfg.Identities,
  121. memberships: cfg.Memberships,
  122. campuses: cfg.Campuses,
  123. callbackURL: cfg.CallbackURL,
  124. stateTTL: stTTL,
  125. ticketTTL: tkTTL,
  126. }, nil
  127. }
  128. // QRCodeResult 生成二维码出参(前端渲染 + 轮询凭据)。
  129. type QRCodeResult struct {
  130. // State 会话标识(16 字节 hex;前端据此轮询 /auth/wechat/status?state=)。
  131. State string `json:"state"`
  132. // QRURL 二维码内容(指向微信网页授权 URL,含 state;前端生成二维码图片)。
  133. QRURL string `json:"qrUrl"`
  134. // CampusID 本次选定的校区(提示前端"正在登录 X 校区")。
  135. CampusID int64 `json:"campusId"`
  136. // ExpiresIn 会话有效期(秒;前端据此展示倒计时/停止轮询)。
  137. ExpiresIn int64 `json:"expiresIn"`
  138. }
  139. // Qrcode 生成扫码登录二维码(GET /auth/wechat/qrcode 编排)。
  140. //
  141. // 行为:选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) → 返回 state + QR URL。
  142. //
  143. // 出参:
  144. // - *QRCodeResult:state + 二维码 URL。
  145. // - error:*AppError(CodeInternal:Redis 写失败——fail-closed,不放行"无 state 的二维码")。
  146. func (s *WechatService) Qrcode(ctx context.Context) (*QRCodeResult, error) {
  147. campus := s.campuses[0] // 多 campus 选第一个(后续可加 campus 选择入参,本期 PC 统一)
  148. state, err := s.newState()
  149. if err != nil {
  150. return nil, apperrors.WithCause(apperrors.ErrInternal, err)
  151. }
  152. st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID}
  153. if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil {
  154. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  155. }
  156. return &QRCodeResult{
  157. State: state,
  158. QRURL: wechat.BuildQRConnectURL(campus.AppID, s.callbackURL, state),
  159. CampusID: campus.CampusID,
  160. ExpiresIn: int64(s.stateTTL / time.Second),
  161. }, nil
  162. }
  163. // AuthorizeResult 移动端 App 内微信授权出参(GET /auth/wechat/authorize)。
  164. //
  165. // 与 QRCodeResult 字段同形(复用同一 state 状态机 / 同一 callback 换 openid / 同一 ② issue),
  166. // 仅 URL 形态不同:
  167. // - QRCodeResult.QRURL:微信**扫码**授权 URL(connect/qrconnect + snsapi_login + #wechat_redirect),
  168. // PC 前端把它渲染成二维码图片;
  169. // - AuthorizeResult.AuthorizeURL:微信 **webview** 授权 URL(connect/oauth2/authorize + snsapi_userinfo,
  170. // 无 #wechat_redirect),移动端 H5 跑在微信内置浏览器里,前端**整页跳转**到该 URL 完成授权。
  171. //
  172. // 授权完成后微信重定向回同一 callbackURL,callback 换 openid 的逻辑对两个 scope 通用(同接口)。
  173. type AuthorizeResult struct {
  174. // State 会话标识(16 字节 hex;与扫码链路同一状态机,callback 重定向回来后消费)。
  175. State string `json:"state"`
  176. // AuthorizeURL 微信 webview 网页授权 URL(snsapi_userinfo;前端整页跳转,授权后微信重定向回 callbackURL)。
  177. AuthorizeURL string `json:"authorizeUrl"`
  178. // CampusID 本次选定的校区(与 Qrcode 同语义:配置首个 campus)。
  179. CampusID int64 `json:"campusId"`
  180. // ExpiresIn 会话有效期(秒;授权后须在窗口内回到 callback 才能完成换票)。
  181. ExpiresIn int64 `json:"expiresIn"`
  182. }
  183. // Authorize 生成移动端 App 内微信授权 URL(GET /auth/wechat/authorize 编排)。
  184. //
  185. // 行为:与 Qrcode 完全对称——选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) →
  186. // 返回 state + **webview 授权 URL**(snsapi_userinfo)。
  187. //
  188. // 出参:
  189. // - *AuthorizeResult:state + 授权 URL。
  190. // - error:*AppError(CodeInternal:Redis 写失败——fail-closed,与 Qrcode 同)。
  191. func (s *WechatService) Authorize(ctx context.Context) (*AuthorizeResult, error) {
  192. campus := s.campuses[0] // 与 Qrcode 同:多 campus 选第一个(后续可加 campus 选择入参)
  193. state, err := s.newState()
  194. if err != nil {
  195. return nil, apperrors.WithCause(apperrors.ErrInternal, err)
  196. }
  197. st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID}
  198. if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil {
  199. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  200. }
  201. return &AuthorizeResult{
  202. State: state,
  203. AuthorizeURL: wechat.BuildAuthorizeURL(campus.AppID, s.callbackURL, state, wechat.ScopeUserinfo),
  204. CampusID: campus.CampusID,
  205. ExpiresIn: int64(s.stateTTL / time.Second),
  206. }, nil
  207. }
  208. // StatusResult 轮询状态出参(前端据此驱动 UI:pending 继续轮 / success 携 ticket 走 ② / 终态停轮)。
  209. type StatusResult struct {
  210. // Status 状态机取值(pending/success/unbound/fail,见 WechatQrcode* 常量)。
  211. Status string `json:"status"`
  212. // CampusID 本次会话的校区(success/unbound 时回显,供前端提示)。
  213. CampusID int64 `json:"campusId"`
  214. // LoginTicket success 时携带的登录票据(前端用同一套 ② /auth/issue 签 token)。
  215. LoginTicket string `json:"loginTicket,omitempty"`
  216. // UserID success 时绑定的用户 ULID(前端可据此展示"已登录 X")。
  217. UserID string `json:"userId,omitempty"`
  218. // Memberships success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录
  219. // 同一套 ② issue 分流:单条自动②、多条进作用域选择页;空 = 该用户暂无机构归属)。
  220. Memberships []MembershipView `json:"memberships,omitempty"`
  221. // Expired true = state 不存在/已过期(前端停止轮询,提示"二维码已失效,请刷新")。
  222. // 与 Status 互斥:Expired=true 时 Status 为空串(调用方按 Expired 优先判断)。
  223. Expired bool `json:"expired,omitempty"`
  224. }
  225. // Status 轮询扫码状态(GET /auth/wechat/status?state= 编排;只读快照,不消费)。
  226. //
  227. // 出参:
  228. // - *StatusResult:当前状态(见 StatusResult 字段)。
  229. // - error:*AppError(CodeInternal/DBError:Redis 读失败——fail-closed,不放行"读不到当已失效")。
  230. func (s *WechatService) Status(ctx context.Context, state string) (*StatusResult, error) {
  231. st, err := s.states.GetState(ctx, state)
  232. if err != nil {
  233. return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed
  234. }
  235. if st.Status == "" {
  236. // 键不存在/已过期:返回"已失效"(前端停轮询,提示刷新)——这是正常的"过期"语义,非故障。
  237. return &StatusResult{Expired: true}, nil
  238. }
  239. res := &StatusResult{Status: st.Status, CampusID: st.CampusID}
  240. if st.Status == WechatQrcodeSuccess {
  241. res.LoginTicket = st.Ticket
  242. res.UserID = st.UserID
  243. res.Memberships = toMembershipViewsFromState(st.Memberships)
  244. }
  245. return res, nil
  246. }
  247. // Callback 微信重定向落点(GET /auth/wechat/callback?code=&state= 编排)。
  248. //
  249. // 行为:读 state 会话 → 校验仍 pending(防重放)→ code 换 openid → 查 t_identity(wechat) →
  250. // - 命中:发 loginTicket(复用 ② 机制)写回 state=success;
  251. // - 未命中:state=unbound(提示"该微信号未绑定账号")。
  252. //
  253. // 出参:
  254. // - *StatusResult:回调后的状态(success/unbound/fail,前端据此决定跳登录或提示)。
  255. // - error:*AppError:
  256. // - ErrDBError(5002):Redis 读写故障(fail-closed);
  257. // - CodeThirdTimeout(4003):微信换 openid 失败(state 置 fail);
  258. // - ErrUnauth(1001):state 不存在/已过期/已终态(防重放,前端提示"二维码已失效")。
  259. func (s *WechatService) Callback(ctx context.Context, code, state string) (*StatusResult, error) {
  260. if code == "" || state == "" {
  261. return nil, apperrors.ErrBadParam
  262. }
  263. st, err := s.states.GetState(ctx, state)
  264. if err != nil {
  265. return nil, err // Redis 故障 fail-closed
  266. }
  267. if st.Status == "" {
  268. // state 不存在/已过期:拒绝(防重放 + 防任意 state 注入)
  269. return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已失效,请重新获取二维码")
  270. }
  271. if st.Status != WechatQrcodePending {
  272. // 已终态(success/unbound/fail):拒绝二次回调(防重放)
  273. return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已结束,请重新获取二维码")
  274. }
  275. campus := s.campusByID(st.CampusID)
  276. if campus == nil {
  277. // 会话 campusId 对不上配置(配置热变更/脏数据):置 fail
  278. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
  279. return &StatusResult{Status: WechatQrcodeFail, CampusID: st.CampusID}, nil
  280. }
  281. // code 换 openid(第三方;失败置 fail)
  282. openid, err := s.client.ExchangeCode(ctx, campus.AppID, campus.Secret, code, s.callbackURL)
  283. if err != nil {
  284. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL)
  285. return nil, apperrors.New(apperrors.CodeThirdTimeout, "微信扫码失败,请重试") // 4003 第三方
  286. }
  287. // 查绑定:(type=wechat, identifier=open_id) → canonical_user_id
  288. it, err := s.identities.GetByTypeIdentifier(ctx, model.IdentityTypeWechat, openid)
  289. if err != nil {
  290. if stderrors.Is(err, apperrors.ErrNotFound) {
  291. // 未绑定:置 unbound(前端提示"该微信号未绑定账号",引导去账密登录后绑定)
  292. _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, s.stateTTL)
  293. return &StatusResult{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, nil
  294. }
  295. return nil, err // *AppError(ErrDBError)
  296. }
  297. // 取作用域列表(供前端 ② issue 分流:单条自动②、多条进作用域选择页;与账密登录同源)
  298. mergs, merr := s.memberships.ListByCanonicalUserID(ctx, it.CanonicalUserID)
  299. if merr != nil {
  300. return nil, merr // *AppError(ErrDBError)
  301. }
  302. // 已绑定:发 loginTicket(复用 ② 机制)写回 state=success(载荷含 memberships,两条读法一致)
  303. ticket, terr := s.newTicket()
  304. if terr != nil {
  305. return nil, apperrors.WithCause(apperrors.ErrInternal, terr)
  306. }
  307. if setErr := s.tickets.SetTicket(ctx, ticket, it.CanonicalUserID, s.ticketTTL); setErr != nil {
  308. return nil, setErr // *AppError(ErrDBError)——票据写失败阻断(不放行半套)
  309. }
  310. final := appredis.WechatState{
  311. Status: WechatQrcodeSuccess,
  312. CampusID: st.CampusID,
  313. Ticket: ticket,
  314. UserID: it.CanonicalUserID,
  315. Memberships: toMembershipInfos(mergs),
  316. }
  317. if setErr := s.states.SetState(ctx, state, final, s.stateTTL); setErr != nil {
  318. return nil, setErr
  319. }
  320. return &StatusResult{
  321. Status: WechatQrcodeSuccess, CampusID: st.CampusID,
  322. LoginTicket: ticket, UserID: it.CanonicalUserID,
  323. Memberships: toMembershipViews(mergs), // org 域来源(auth_service.go 的 toMembershipViews)
  324. }, nil
  325. }
  326. // ---- 私有工具 ----
  327. // campusByID 按 campusId 取配置(找不到返回 nil——调用方按"脏数据"置 fail)。
  328. func (s *WechatService) campusByID(campusID int64) *WechatCampus {
  329. for i := range s.campuses {
  330. if s.campuses[i].CampusID == campusID {
  331. return &s.campuses[i]
  332. }
  333. }
  334. return nil
  335. }
  336. // newState 生成扫码会话 state(16 字节随机 → 32 hex)。
  337. func (s *WechatService) newState() (string, error) {
  338. buf := make([]byte, 16)
  339. if _, err := rand.Read(buf); err != nil {
  340. return "", err
  341. }
  342. return hex.EncodeToString(buf), nil
  343. }
  344. // newTicket 生成登录票据(16 字节随机 → 32 hex,与 AuthService.newTicket 同语义)。
  345. func (s *WechatService) newTicket() (string, error) {
  346. buf := make([]byte, 16)
  347. if _, err := rand.Read(buf); err != nil {
  348. return "", err
  349. }
  350. return hex.EncodeToString(buf), nil
  351. }
  352. // toMembershipInfos org 域成员关系切片 → redis 状态机载荷切片(callback 落终态时写入 state)。
  353. // 空切片保持非 nil(JSON 序列化为 [] 而非 null;无机构归属时前端据此提示"联系管理员")。
  354. func toMembershipInfos(list []orgmodel.Membership) []appredis.MembershipInfo {
  355. out := make([]appredis.MembershipInfo, 0, len(list))
  356. for _, m := range list {
  357. out = append(out, appredis.MembershipInfo{
  358. MembershipID: m.MembershipID,
  359. InstitutionID: m.InstitutionID,
  360. CampusID: m.CampusID,
  361. Role: m.Role,
  362. Status: m.Status,
  363. })
  364. }
  365. return out
  366. }
  367. // toMembershipViewsFromState redis 状态机载荷切片 → 对外 MembershipView(Status 轮询/callback 回显用)。
  368. // 与 AuthService.toMembershipViews(org 域来源,同名异签名)区分:本函数源类型为 redis 载荷,
  369. // 避免同包内重名冲突(auth_service.go 已有 toMembershipViews(orgmodel.Membership))。
  370. func toMembershipViewsFromState(list []appredis.MembershipInfo) []MembershipView {
  371. out := make([]MembershipView, 0, len(list))
  372. for _, m := range list {
  373. out = append(out, MembershipView{
  374. MembershipID: m.MembershipID,
  375. InstitutionID: m.InstitutionID,
  376. CampusID: m.CampusID,
  377. Role: m.Role,
  378. Status: m.Status,
  379. })
  380. }
  381. return out
  382. }