// Package wechat 微信公众号"网页授权扫码登录"第三方客户端封装(PC 端扫码登录用)。 // // 职责边界(架构规格 §3.2 三层之"依赖倒置"的第三方消费点): // - 本包只做**与微信服务端通信**这一件事:拿授权 code 换 openid(网页授权 snsapi_login); // 不做登录票据、不做作用域、不碰 DB/Redis(那些归 identity/service 的 WechatService 编排)。 // - 消费方 identity/service 依赖本包定义的 Client 接口(生产注入 *HTTPClient,单测注入 fake), // 使"换 openid"这一外部 I/O 可被替换/打桩——纯单测不连微信。 // // 为什么用 GET 而非 SDK:微信网页授权换 token 是单一 GET 调用(无签名/无长连接), // 标准库 net/http 足够,不引入重量级 wechat SDK(减少依赖面 + 升级面)。 // // 安全纪律: // - secret 只出现在对微信服务端的请求参数里(内存调用栈),**禁止**进日志/响应体/二维码 URL; // - BaseURL 可注入(测试用 httptest 打桩,不真连微信);生产默认 api.weixin.qq.com。 package wechat import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) // errToken 换 openid 失败(微信返回 errcode!=0 / 网络故障 / 响应非 JSON)。 // 归一出口:调用方(WechatService)按"第三方失败"处理(state 置 fail,前端提示"扫码失败")。 type errToken struct{ msg string } func (e *errToken) Error() string { return "wechat: " + e.msg } // IsTokenError 判断是否为本包"换 openid 失败"错误(区别于"openid 未绑定"等业务分支)。 func IsTokenError(err error) bool { _, ok := err.(*errToken) return ok } // Client 网页授权"code 换 openid"抽象(依赖倒置接口,消费方 identity/service 持有)。 type Client interface { // ExchangeCode 用扫码授权 code 换 openid(网页扫码登录 snsapi_login,仅取 openid 不做用户信息拉取)。 // // 入参: // - ctx context.Context:请求上下文(超时/取消由 http.Client 接管)。 // - appID / secret string:该校区公众号 appid/appSecret(来自 config.WeChatCfg.Campuses)。 // - code string:微信重定向回带的授权 code(一次性,数秒~分钟级有效)。 // - redirectURI string:回调地址(须与发起授权时一致,微信校验)。 // // 出参: // - openid string:该用户在此公众号下的 openid(identity 层 (type=wechat, appid, openid) 唯一定位)。 // - error:*errToken(微信 errcode!=0 / 网络故障 / 响应非法),IsTokenError 可判。 ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (openid string, err error) } // HTTPClient 真实实现(调 api.weixin.qq.com/sns/oauth2/access_token)。 type HTTPClient struct { // HTTP 底层客户端(带超时;生产默认 8s,测试可注入)。 HTTP *http.Client // BaseURL 便于测试注入 mock(生产默认 https://api.weixin.qq.com,含协议头、无尾斜杠)。 BaseURL string } // NewHTTPClient 构造真实微信客户端(生产装配用;BaseURL 用默认值)。 func NewHTTPClient() *HTTPClient { return &HTTPClient{ HTTP: &http.Client{Timeout: 8 * time.Second}, BaseURL: "https://api.weixin.qq.com", } } // accessTokenResp 微信换 token 接口出参(成功/失败共用结构,errcode==0 才成功)。 type accessTokenResp struct { AccessToken string `json:"access_token"` OpenID string `json:"openid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } // ExchangeCode 实现 Client:GET 换 openid(见接口文档)。 func (c *HTTPClient) ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (string, error) { q := url.Values{} q.Set("appid", appID) q.Set("secret", secret) q.Set("code", code) q.Set("grant_type", "authorization_code") // 注:网页授权 snsapi_login 的 access_token 接口**不强制** redirect_uri(区别于 snsapi_userinfo), // 但微信文档建议带以防 code 与回调地址不匹配误用。这里带上,与 BuildQRConnectURL 的 redirect_uri 一致。 if redirectURI != "" { q.Set("redirect_uri", redirectURI) } u := c.BaseURL + "/sns/oauth2/access_token?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return "", &errToken{msg: "构造请求失败: " + err.Error()} } resp, err := c.HTTP.Do(req) if err != nil { return "", &errToken{msg: "请求微信换 openid 失败: " + err.Error()} } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) // 64KB 上限防异常大响应 if err != nil { return "", &errToken{msg: "读取微信响应失败: " + err.Error()} } if resp.StatusCode != http.StatusOK { return "", &errToken{msg: fmt.Sprintf("微信换 openid 返回 HTTP %d", resp.StatusCode)} } var ar accessTokenResp if err := json.Unmarshal(body, &ar); err != nil { return "", &errToken{msg: "解析微信响应失败(非 JSON): " + err.Error()} } if ar.ErrCode != 0 { // errcode/errmsg 是微信给的(脱敏安全),但拼接前做截断防异常长串 return "", &errToken{msg: fmt.Sprintf("微信拒绝换 openid:errcode=%d %s", ar.ErrCode, truncate(ar.ErrMsg, 128))} } if ar.OpenID == "" { return "", &errToken{msg: "微信响应缺少 openid(errcode=0 但 openid 为空)"} } return ar.OpenID, nil } // ScopeLogin 网页扫码登录 scope(PC 端扫码用;snsapi_login 仅取 openid,不拉头像昵称)。 const ScopeLogin = "snsapi_login" // ScopeUserinfo 网页授权获取用户信息 scope(移动端微信内置浏览器"App 内授权"用; // snsapi_userinfo 可拉头像昵称,但本系统登录只需 openid,拉取与否由微信侧决定,scope 仅授权)。 const ScopeUserinfo = "snsapi_userinfo" // BuildAuthorizeURL 构造**微信网页授权 URL**(通用版:PC 扫码 + 移动 webview 共用同一 host)。 // // host 固定 open.weixin.qq.com,两个场景的差异只在 endpoint + scope: // - PC 扫码(snsapi_login):connect/qrconnect —— 前端把该 URL 渲染成二维码,用户扫后在 // 微信外浏览器完成授权,#wechat_redirect 尾段微信约定必须保留; // - 移动 webview(snsapi_userinfo):connect/oauth2/authorize —— 页面跑在微信内置浏览器里, // 前端整页跳转到该 URL 完成授权,**无** #wechat_redirect 尾段(扫码专用,非扫码场景不带)。 // // 参数(两场景一致):appid / redirect_uri(回调地址,URL-encoded)/ response_type=code / // scope(snsapi_login 或 snsapi_userinfo)/ state(本系统生成的会话标识,防 CSRF)。 // // 入参:appID / redirectURI / state / scope(ScopeLogin 或 ScopeUserinfo)。 // 出参:可直接使用的完整授权 URL(扫码场景带 #wechat_redirect,webview 场景不带)。 func BuildAuthorizeURL(appID, redirectURI, state, scope string) string { q := url.Values{} q.Set("appid", appID) q.Set("redirect_uri", redirectURI) q.Set("response_type", "code") q.Set("scope", scope) q.Set("state", state) base := "https://open.weixin.qq.com/connect/" switch scope { case ScopeLogin: return base + "qrconnect?" + q.Encode() + "#wechat_redirect" default: // ScopeUserinfo(及未知 scope 兜底):走 oauth2/authorize,无 #wechat_redirect return base + "oauth2/authorize?" + q.Encode() } } // BuildQRConnectURL 构造扫码登录二维码指向的网页授权 URL(snsapi_login,PC 扫码专用)。 // 薄封装 BuildAuthorizeURL,保留原签名给 PC 扫码链路(service.Qrcode 等)。 func BuildQRConnectURL(appID, redirectURI, state string) string { return BuildAuthorizeURL(appID, redirectURI, state, ScopeLogin) } // truncate 截断字符串到 n 字节(防微信 errmsg 异常长时污染错误串/日志)。 func truncate(s string, n int) string { if len(s) <= n { return s } return strings.TrimSpace(s[:n]) + "…" }