| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- // 微信扫码登录服务(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) → canonical_user_id
- it, err := s.identities.GetByTypeIdentifier(ctx, model.IdentityTypeWechat, openid)
- 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
- }
|