// 微信扫码登录服务(PC 端扫码登录链路编排,复用两步登录②票据机制)。 // // 与 AuthService(账密两步登录)的关系: // - 本服务**复用**两步登录的"② 签 token"机制——扫码成功后生成一张 loginTicket 存入 Redis, // 前端拿到 status=success + loginTicket 后,与账密登录**完全同一套** ② /auth/issue 签 token。 // 不在此处直接签 token(不重造 ②,避免"作用域校验/菜单下发"逻辑两处漂移)。 // - 本服务只做微信扫码特有的三件事:① 生成二维码(state 落 Redis);② 微信回调 code 换 openid → // 查 t_identity(wechat) → 发 loginTicket 写回 state;③ 前端轮询读 state 返回进度。 // // 依赖方向:service → wechat.Client(第三方)+ redis.WeChatQrcodeStore/LoginTicketStore + // repository.IdentityRepository + model;不依赖 handler/main(纯业务,可纯单测: // fake wechat.Client + miniredis 双 store + mock identity repo)。 package service import ( "context" "crypto/rand" "encoding/hex" stderrors "errors" "time" "yuansiwei/unified-backend/internal/identity/model" "yuansiwei/unified-backend/internal/identity/repository" "yuansiwei/unified-backend/internal/identity/wechat" apperrors "yuansiwei/unified-backend/internal/infra/errors" appredis "yuansiwei/unified-backend/internal/infra/redis" orgmodel "yuansiwei/unified-backend/internal/org/model" ) // WeChat 扫码状态机取值(state.Status 字段;前端据状态决定是否继续轮询)。 const ( // WechatQrcodePending 已生成二维码、等待用户扫码(前端继续轮询)。 WechatQrcodePending = "pending" // WechatQrcodeSuccess 扫码成功、已绑定用户、已发 loginTicket(前端停轮询,携 ticket 走 ② issue)。 WechatQrcodeSuccess = "success" // WechatQrcodeUnbound 扫码成功但 openid 未绑定本平台账号(前端停轮询,提示"该微信号未绑定账号")。 WechatQrcodeUnbound = "unbound" // WechatQrcodeFail 微信换 openid 失败(第三方错误)(前端停轮询,提示"扫码失败,请重试")。 WechatQrcodeFail = "fail" ) // defaultWechatStateTTL 扫码会话有效期(生成二维码 → 扫码 → 轮询到 success 的窗口; // 与 loginTicketTTL 对齐 5min,给前端留足操作时间)。 const defaultWechatStateTTL = 5 * time.Minute // WechatService 微信扫码登录业务服务(状态机 + 复用两步票据)。 // // 字段(装配入口 NewWechatService 注入,均必填): // - client:微信网页授权客户端(code 换 openid;生产 *wechat.HTTPClient,单测 fake)。 // - states:扫码状态机存储(per-state 会话,Redis;双实例共享)。 // - tickets:登录票据存储(扫码成功后签发 loginTicket,复用 AuthService 同一存储)。 // - identities:登录身份仓储(查 (type=wechat, identifier=open_id) → canonical_user_id)。 // - campuses:per-campus 微信应用配置(来自 config.WeChatCfg.Campuses,决定 appid/secret)。 // - callbackURL:对外回调地址(微信重定向落点;未配置时 New 报错,不允许"无回调地址生成二维码")。 type WechatService struct { client wechat.Client states *appredis.WechatQrcodeStore tickets *appredis.LoginTicketStore identities repository.IdentityRepository memberships repository.MembershipRepository campuses []WechatCampus callbackURL string stateTTL time.Duration ticketTTL time.Duration } // WechatCampus per-campus 微信应用(从 config.WeChatCampus 转换;Service 自持避免 infra/config 泄漏进单测)。 type WechatCampus struct { CampusID int64 AppID string Secret string } // WechatConfig WechatService 装配参数(main 按此构造;全部必填,缺则 New 报错)。 type WechatConfig struct { Client wechat.Client States *appredis.WechatQrcodeStore Tickets *appredis.LoginTicketStore Identities repository.IdentityRepository Memberships repository.MembershipRepository Campuses []WechatCampus CallbackURL string // StateTTL / TicketTTL 可选:0/缺省用 5min。 StateTTL time.Duration TicketTTL time.Duration } // NewWechatService 构造微信扫码登录服务(启动装配一次,全进程共享)。 // // 入参:cfg WechatConfig:见 WechatConfig 字段说明。 // 出参: // - *WechatService:就绪服务。 // - error:必填依赖缺失(nil 检查,fail-fast)/ 无 campus 配置 / callbackURL 为空。 func NewWechatService(cfg WechatConfig) (*WechatService, error) { if cfg.Client == nil { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 微信客户端未注入") } if cfg.States == nil { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 扫码状态机存储未注入") } if cfg.Tickets == nil { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录票据存储未注入") } if cfg.Identities == nil { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 登录身份仓储未注入") } if cfg.Memberships == nil { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 成员关系仓储未注入") } if len(cfg.Campuses) == 0 { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 无 per-campus 微信应用配置(Campuses 至少 1 条)") } if cfg.CallbackURL == "" { return nil, apperrors.New(apperrors.CodeInternal, "wechat: 回调地址为空(CallbackURL 必填,且须为已登记的 https 网页授权回调域名)") } stTTL := cfg.StateTTL if stTTL <= 0 { stTTL = defaultWechatStateTTL } tkTTL := cfg.TicketTTL if tkTTL <= 0 { tkTTL = defaultWechatStateTTL } return &WechatService{ client: cfg.Client, states: cfg.States, tickets: cfg.Tickets, identities: cfg.Identities, memberships: cfg.Memberships, campuses: cfg.Campuses, callbackURL: cfg.CallbackURL, stateTTL: stTTL, ticketTTL: tkTTL, }, nil } // QRCodeResult 生成二维码出参(前端渲染 + 轮询凭据)。 type QRCodeResult struct { // State 会话标识(16 字节 hex;前端据此轮询 /auth/wechat/status?state=)。 State string `json:"state"` // QRURL 二维码内容(指向微信网页授权 URL,含 state;前端生成二维码图片)。 QRURL string `json:"qrUrl"` // CampusID 本次选定的校区(提示前端"正在登录 X 校区")。 CampusID int64 `json:"campusId"` // ExpiresIn 会话有效期(秒;前端据此展示倒计时/停止轮询)。 ExpiresIn int64 `json:"expiresIn"` } // Qrcode 生成扫码登录二维码(GET /auth/wechat/qrcode 编排)。 // // 行为:选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) → 返回 state + QR URL。 // // 出参: // - *QRCodeResult:state + 二维码 URL。 // - error:*AppError(CodeInternal:Redis 写失败——fail-closed,不放行"无 state 的二维码")。 func (s *WechatService) Qrcode(ctx context.Context) (*QRCodeResult, error) { campus := s.campuses[0] // 多 campus 选第一个(后续可加 campus 选择入参,本期 PC 统一) state, err := s.newState() if err != nil { return nil, apperrors.WithCause(apperrors.ErrInternal, err) } st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID} if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil { return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed } return &QRCodeResult{ State: state, QRURL: wechat.BuildQRConnectURL(campus.AppID, s.callbackURL, state), CampusID: campus.CampusID, ExpiresIn: int64(s.stateTTL / time.Second), }, nil } // AuthorizeResult 移动端 App 内微信授权出参(GET /auth/wechat/authorize)。 // // 与 QRCodeResult 字段同形(复用同一 state 状态机 / 同一 callback 换 openid / 同一 ② issue), // 仅 URL 形态不同: // - QRCodeResult.QRURL:微信**扫码**授权 URL(connect/qrconnect + snsapi_login + #wechat_redirect), // PC 前端把它渲染成二维码图片; // - AuthorizeResult.AuthorizeURL:微信 **webview** 授权 URL(connect/oauth2/authorize + snsapi_userinfo, // 无 #wechat_redirect),移动端 H5 跑在微信内置浏览器里,前端**整页跳转**到该 URL 完成授权。 // // 授权完成后微信重定向回同一 callbackURL,callback 换 openid 的逻辑对两个 scope 通用(同接口)。 type AuthorizeResult struct { // State 会话标识(16 字节 hex;与扫码链路同一状态机,callback 重定向回来后消费)。 State string `json:"state"` // AuthorizeURL 微信 webview 网页授权 URL(snsapi_userinfo;前端整页跳转,授权后微信重定向回 callbackURL)。 AuthorizeURL string `json:"authorizeUrl"` // CampusID 本次选定的校区(与 Qrcode 同语义:配置首个 campus)。 CampusID int64 `json:"campusId"` // ExpiresIn 会话有效期(秒;授权后须在窗口内回到 callback 才能完成换票)。 ExpiresIn int64 `json:"expiresIn"` } // Authorize 生成移动端 App 内微信授权 URL(GET /auth/wechat/authorize 编排)。 // // 行为:与 Qrcode 完全对称——选第一个配置的 campus → 生成随机 state → 写 Redis(pending, campusId) → // 返回 state + **webview 授权 URL**(snsapi_userinfo)。 // // 出参: // - *AuthorizeResult:state + 授权 URL。 // - error:*AppError(CodeInternal:Redis 写失败——fail-closed,与 Qrcode 同)。 func (s *WechatService) Authorize(ctx context.Context) (*AuthorizeResult, error) { campus := s.campuses[0] // 与 Qrcode 同:多 campus 选第一个(后续可加 campus 选择入参) state, err := s.newState() if err != nil { return nil, apperrors.WithCause(apperrors.ErrInternal, err) } st := appredis.WechatState{Status: WechatQrcodePending, CampusID: campus.CampusID} if err := s.states.SetState(ctx, state, st, s.stateTTL); err != nil { return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed } return &AuthorizeResult{ State: state, AuthorizeURL: wechat.BuildAuthorizeURL(campus.AppID, s.callbackURL, state, wechat.ScopeUserinfo), CampusID: campus.CampusID, ExpiresIn: int64(s.stateTTL / time.Second), }, nil } // StatusResult 轮询状态出参(前端据此驱动 UI:pending 继续轮 / success 携 ticket 走 ② / 终态停轮)。 type StatusResult struct { // Status 状态机取值(pending/success/unbound/fail,见 WechatQrcode* 常量)。 Status string `json:"status"` // CampusID 本次会话的校区(success/unbound 时回显,供前端提示)。 CampusID int64 `json:"campusId"` // LoginTicket success 时携带的登录票据(前端用同一套 ② /auth/issue 签 token)。 LoginTicket string `json:"loginTicket,omitempty"` // UserID success 时绑定的用户 ULID(前端可据此展示"已登录 X")。 UserID string `json:"userId,omitempty"` // Memberships success 时绑定用户的可选作用域列表(机构/校区/角色;前端据此走与账密登录 // 同一套 ② issue 分流:单条自动②、多条进作用域选择页;空 = 该用户暂无机构归属)。 Memberships []MembershipView `json:"memberships,omitempty"` // Expired true = state 不存在/已过期(前端停止轮询,提示"二维码已失效,请刷新")。 // 与 Status 互斥:Expired=true 时 Status 为空串(调用方按 Expired 优先判断)。 Expired bool `json:"expired,omitempty"` } // Status 轮询扫码状态(GET /auth/wechat/status?state= 编排;只读快照,不消费)。 // // 出参: // - *StatusResult:当前状态(见 StatusResult 字段)。 // - error:*AppError(CodeInternal/DBError:Redis 读失败——fail-closed,不放行"读不到当已失效")。 func (s *WechatService) Status(ctx context.Context, state string) (*StatusResult, error) { st, err := s.states.GetState(ctx, state) if err != nil { return nil, err // *AppError(ErrDBError)——Redis 故障,fail-closed } if st.Status == "" { // 键不存在/已过期:返回"已失效"(前端停轮询,提示刷新)——这是正常的"过期"语义,非故障。 return &StatusResult{Expired: true}, nil } res := &StatusResult{Status: st.Status, CampusID: st.CampusID} if st.Status == WechatQrcodeSuccess { res.LoginTicket = st.Ticket res.UserID = st.UserID res.Memberships = toMembershipViewsFromState(st.Memberships) } return res, nil } // Callback 微信重定向落点(GET /auth/wechat/callback?code=&state= 编排)。 // // 行为:读 state 会话 → 校验仍 pending(防重放)→ code 换 openid → 查 t_identity(wechat) → // - 命中:发 loginTicket(复用 ② 机制)写回 state=success; // - 未命中:state=unbound(提示"该微信号未绑定账号")。 // // 出参: // - *StatusResult:回调后的状态(success/unbound/fail,前端据此决定跳登录或提示)。 // - error:*AppError: // - ErrDBError(5002):Redis 读写故障(fail-closed); // - CodeThirdTimeout(4003):微信换 openid 失败(state 置 fail); // - ErrUnauth(1001):state 不存在/已过期/已终态(防重放,前端提示"二维码已失效")。 func (s *WechatService) Callback(ctx context.Context, code, state string) (*StatusResult, error) { if code == "" || state == "" { return nil, apperrors.ErrBadParam } st, err := s.states.GetState(ctx, state) if err != nil { return nil, err // Redis 故障 fail-closed } if st.Status == "" { // state 不存在/已过期:拒绝(防重放 + 防任意 state 注入) return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已失效,请重新获取二维码") } if st.Status != WechatQrcodePending { // 已终态(success/unbound/fail):拒绝二次回调(防重放) return nil, apperrors.New(apperrors.CodeUnauth, "扫码会话已结束,请重新获取二维码") } campus := s.campusByID(st.CampusID) if campus == nil { // 会话 campusId 对不上配置(配置热变更/脏数据):置 fail _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL) return &StatusResult{Status: WechatQrcodeFail, CampusID: st.CampusID}, nil } // code 换 openid(第三方;失败置 fail) openid, err := s.client.ExchangeCode(ctx, campus.AppID, campus.Secret, code, s.callbackURL) if err != nil { _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeFail, CampusID: st.CampusID}, s.stateTTL) return nil, apperrors.New(apperrors.CodeThirdTimeout, "微信扫码失败,请重试") // 4003 第三方 } // 查绑定:(type=wechat, identifier=open_id, appid) → canonical_user_id(v1.2:appid 进唯一键, // 同 openid 在不同公众号(appid)下是不同登录身份,须带 appid 精确定位) it, err := s.identities.GetByTypeIdentifierAppID(ctx, model.IdentityTypeWechat, openid, campus.AppID) if err != nil { if stderrors.Is(err, apperrors.ErrNotFound) { // 未绑定:置 unbound(前端提示"该微信号未绑定账号",引导去账密登录后绑定) _ = s.states.SetState(ctx, state, appredis.WechatState{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, s.stateTTL) return &StatusResult{Status: WechatQrcodeUnbound, CampusID: st.CampusID}, nil } return nil, err // *AppError(ErrDBError) } // 取作用域列表(供前端 ② issue 分流:单条自动②、多条进作用域选择页;与账密登录同源) mergs, merr := s.memberships.ListByCanonicalUserID(ctx, it.CanonicalUserID) if merr != nil { return nil, merr // *AppError(ErrDBError) } // 已绑定:发 loginTicket(复用 ② 机制)写回 state=success(载荷含 memberships,两条读法一致) ticket, terr := s.newTicket() if terr != nil { return nil, apperrors.WithCause(apperrors.ErrInternal, terr) } if setErr := s.tickets.SetTicket(ctx, ticket, it.CanonicalUserID, s.ticketTTL); setErr != nil { return nil, setErr // *AppError(ErrDBError)——票据写失败阻断(不放行半套) } final := appredis.WechatState{ Status: WechatQrcodeSuccess, CampusID: st.CampusID, Ticket: ticket, UserID: it.CanonicalUserID, Memberships: toMembershipInfos(mergs), } if setErr := s.states.SetState(ctx, state, final, s.stateTTL); setErr != nil { return nil, setErr } return &StatusResult{ Status: WechatQrcodeSuccess, CampusID: st.CampusID, LoginTicket: ticket, UserID: it.CanonicalUserID, Memberships: toMembershipViews(mergs), // org 域来源(auth_service.go 的 toMembershipViews) }, nil } // ---- 私有工具 ---- // campusByID 按 campusId 取配置(找不到返回 nil——调用方按"脏数据"置 fail)。 func (s *WechatService) campusByID(campusID int64) *WechatCampus { for i := range s.campuses { if s.campuses[i].CampusID == campusID { return &s.campuses[i] } } return nil } // newState 生成扫码会话 state(16 字节随机 → 32 hex)。 func (s *WechatService) newState() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // newTicket 生成登录票据(16 字节随机 → 32 hex,与 AuthService.newTicket 同语义)。 func (s *WechatService) newTicket() (string, error) { buf := make([]byte, 16) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } // toMembershipInfos org 域成员关系切片 → redis 状态机载荷切片(callback 落终态时写入 state)。 // 空切片保持非 nil(JSON 序列化为 [] 而非 null;无机构归属时前端据此提示"联系管理员")。 func toMembershipInfos(list []orgmodel.Membership) []appredis.MembershipInfo { out := make([]appredis.MembershipInfo, 0, len(list)) for _, m := range list { out = append(out, appredis.MembershipInfo{ MembershipID: m.MembershipID, InstitutionID: m.InstitutionID, CampusID: m.CampusID, Role: m.Role, Status: m.Status, }) } return out } // toMembershipViewsFromState redis 状态机载荷切片 → 对外 MembershipView(Status 轮询/callback 回显用)。 // 与 AuthService.toMembershipViews(org 域来源,同名异签名)区分:本函数源类型为 redis 载荷, // 避免同包内重名冲突(auth_service.go 已有 toMembershipViews(orgmodel.Membership))。 func toMembershipViewsFromState(list []appredis.MembershipInfo) []MembershipView { out := make([]MembershipView, 0, len(list)) for _, m := range list { out = append(out, MembershipView{ MembershipID: m.MembershipID, InstitutionID: m.InstitutionID, CampusID: m.CampusID, Role: m.Role, Status: m.Status, }) } return out }