wechat.go 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. // Package wechat 微信公众号"网页授权扫码登录"第三方客户端封装(PC 端扫码登录用)。
  2. //
  3. // 职责边界(架构规格 §3.2 三层之"依赖倒置"的第三方消费点):
  4. // - 本包只做**与微信服务端通信**这一件事:拿授权 code 换 openid(网页授权 snsapi_login);
  5. // 不做登录票据、不做作用域、不碰 DB/Redis(那些归 identity/service 的 WechatService 编排)。
  6. // - 消费方 identity/service 依赖本包定义的 Client 接口(生产注入 *HTTPClient,单测注入 fake),
  7. // 使"换 openid"这一外部 I/O 可被替换/打桩——纯单测不连微信。
  8. //
  9. // 为什么用 GET 而非 SDK:微信网页授权换 token 是单一 GET 调用(无签名/无长连接),
  10. // 标准库 net/http 足够,不引入重量级 wechat SDK(减少依赖面 + 升级面)。
  11. //
  12. // 安全纪律:
  13. // - secret 只出现在对微信服务端的请求参数里(内存调用栈),**禁止**进日志/响应体/二维码 URL;
  14. // - BaseURL 可注入(测试用 httptest 打桩,不真连微信);生产默认 api.weixin.qq.com。
  15. package wechat
  16. import (
  17. "context"
  18. "encoding/json"
  19. "fmt"
  20. "io"
  21. "net/http"
  22. "net/url"
  23. "strings"
  24. "time"
  25. )
  26. // errToken 换 openid 失败(微信返回 errcode!=0 / 网络故障 / 响应非 JSON)。
  27. // 归一出口:调用方(WechatService)按"第三方失败"处理(state 置 fail,前端提示"扫码失败")。
  28. type errToken struct{ msg string }
  29. func (e *errToken) Error() string { return "wechat: " + e.msg }
  30. // IsTokenError 判断是否为本包"换 openid 失败"错误(区别于"openid 未绑定"等业务分支)。
  31. func IsTokenError(err error) bool {
  32. _, ok := err.(*errToken)
  33. return ok
  34. }
  35. // Client 网页授权"code 换 openid"抽象(依赖倒置接口,消费方 identity/service 持有)。
  36. type Client interface {
  37. // ExchangeCode 用扫码授权 code 换 openid(网页扫码登录 snsapi_login,仅取 openid 不做用户信息拉取)。
  38. //
  39. // 入参:
  40. // - ctx context.Context:请求上下文(超时/取消由 http.Client 接管)。
  41. // - appID / secret string:该校区公众号 appid/appSecret(来自 config.WeChatCfg.Campuses)。
  42. // - code string:微信重定向回带的授权 code(一次性,数秒~分钟级有效)。
  43. // - redirectURI string:回调地址(须与发起授权时一致,微信校验)。
  44. //
  45. // 出参:
  46. // - openid string:该用户在此公众号下的 openid(identity 层 (type=wechat, appid, openid) 唯一定位)。
  47. // - error:*errToken(微信 errcode!=0 / 网络故障 / 响应非法),IsTokenError 可判。
  48. ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (openid string, err error)
  49. }
  50. // HTTPClient 真实实现(调 api.weixin.qq.com/sns/oauth2/access_token)。
  51. type HTTPClient struct {
  52. // HTTP 底层客户端(带超时;生产默认 8s,测试可注入)。
  53. HTTP *http.Client
  54. // BaseURL 便于测试注入 mock(生产默认 https://api.weixin.qq.com,含协议头、无尾斜杠)。
  55. BaseURL string
  56. }
  57. // NewHTTPClient 构造真实微信客户端(生产装配用;BaseURL 用默认值)。
  58. func NewHTTPClient() *HTTPClient {
  59. return &HTTPClient{
  60. HTTP: &http.Client{Timeout: 8 * time.Second},
  61. BaseURL: "https://api.weixin.qq.com",
  62. }
  63. }
  64. // accessTokenResp 微信换 token 接口出参(成功/失败共用结构,errcode==0 才成功)。
  65. type accessTokenResp struct {
  66. AccessToken string `json:"access_token"`
  67. OpenID string `json:"openid"`
  68. ErrCode int `json:"errcode"`
  69. ErrMsg string `json:"errmsg"`
  70. }
  71. // ExchangeCode 实现 Client:GET 换 openid(见接口文档)。
  72. func (c *HTTPClient) ExchangeCode(ctx context.Context, appID, secret, code, redirectURI string) (string, error) {
  73. q := url.Values{}
  74. q.Set("appid", appID)
  75. q.Set("secret", secret)
  76. q.Set("code", code)
  77. q.Set("grant_type", "authorization_code")
  78. // 注:网页授权 snsapi_login 的 access_token 接口**不强制** redirect_uri(区别于 snsapi_userinfo),
  79. // 但微信文档建议带以防 code 与回调地址不匹配误用。这里带上,与 BuildQRConnectURL 的 redirect_uri 一致。
  80. if redirectURI != "" {
  81. q.Set("redirect_uri", redirectURI)
  82. }
  83. u := c.BaseURL + "/sns/oauth2/access_token?" + q.Encode()
  84. req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
  85. if err != nil {
  86. return "", &errToken{msg: "构造请求失败: " + err.Error()}
  87. }
  88. resp, err := c.HTTP.Do(req)
  89. if err != nil {
  90. return "", &errToken{msg: "请求微信换 openid 失败: " + err.Error()}
  91. }
  92. defer func() { _ = resp.Body.Close() }()
  93. body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) // 64KB 上限防异常大响应
  94. if err != nil {
  95. return "", &errToken{msg: "读取微信响应失败: " + err.Error()}
  96. }
  97. if resp.StatusCode != http.StatusOK {
  98. return "", &errToken{msg: fmt.Sprintf("微信换 openid 返回 HTTP %d", resp.StatusCode)}
  99. }
  100. var ar accessTokenResp
  101. if err := json.Unmarshal(body, &ar); err != nil {
  102. return "", &errToken{msg: "解析微信响应失败(非 JSON): " + err.Error()}
  103. }
  104. if ar.ErrCode != 0 {
  105. // errcode/errmsg 是微信给的(脱敏安全),但拼接前做截断防异常长串
  106. return "", &errToken{msg: fmt.Sprintf("微信拒绝换 openid:errcode=%d %s", ar.ErrCode, truncate(ar.ErrMsg, 128))}
  107. }
  108. if ar.OpenID == "" {
  109. return "", &errToken{msg: "微信响应缺少 openid(errcode=0 但 openid 为空)"}
  110. }
  111. return ar.OpenID, nil
  112. }
  113. // BuildQRConnectURL 构造扫码登录二维码指向的**网页授权 URL**(前端把该 URL 渲染成二维码)。
  114. //
  115. // 微信网页扫码登录(snsapi_login)固定走 open.weixin.qq.com/connect/qrconnect,
  116. // 参数:appid / redirect_uri(回调地址,URL-encoded)/ response_type=code /
  117. // scope=snsapi_login(仅取 openid,不拉头像昵称)/ state(本系统生成的会话标识,防 CSRF)。
  118. //
  119. // 入参:appID / redirectURI / state(见上)。
  120. // 出参:可直接作为二维码内容的完整 URL(含 #wechat_redirect 尾段——微信约定必须保留)。
  121. func BuildQRConnectURL(appID, redirectURI, state string) string {
  122. q := url.Values{}
  123. q.Set("appid", appID)
  124. q.Set("redirect_uri", redirectURI)
  125. q.Set("response_type", "code")
  126. q.Set("scope", "snsapi_login")
  127. q.Set("state", state)
  128. return "https://open.weixin.qq.com/connect/qrconnect?" + q.Encode() + "#wechat_redirect"
  129. }
  130. // truncate 截断字符串到 n 字节(防微信 errmsg 异常长时污染错误串/日志)。
  131. func truncate(s string, n int) string {
  132. if len(s) <= n {
  133. return s
  134. }
  135. return strings.TrimSpace(s[:n]) + "…"
  136. }