// Package wechat 实现微信公众号(服务号)网页授权登录。 // // 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」, // 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配 // 「网页授权域名」,已认证服务号默认具备,副作用最小。 // // 登录流程(PC 端): // 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t= // 2. 用户微信扫码 → 微信内置浏览器打开该 URL → 后端 302 到微信 OAuth 授权页(state=ticket) // 3. 用户「允许」→ 微信回调 /api/v1/wx/mp/callback?code&state → 用 code 换 openid // 4. openid 找/建用户 → ticket 置 authorized(userID) // 5. PC 端轮询 ticket → authorized → 签发 JWT // // 配置(appid/secret)后台可改、密钥加密入库,与微信支付同一套 secrets。 package wechat import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "github.com/sundynix/sundynix-shared/secrets" ) // Config 是公众号网页授权所需配置。AppSecret 加密入库,只写不回显(同微信支付 APIv3 密钥)。 type Config struct { AppID string `json:"appid"` AppSecret string `json:"app_secret"` // 授权回调基地址,如 https://agent.sundynix.cn。留空则用请求 Host 推断。 // 显式配置更稳:微信要求回调域名与「网页授权域名」完全一致,靠 Host 推断在反代下易错。 BaseURL string `json:"base_url"` } // Enabled 报告配置是否完整到可用。 func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" } // EncryptedForStore 返回一份 AppSecret 已加密的副本,用于落库。 func (c Config) EncryptedForStore() (Config, error) { if c.AppSecret == "" { return c, nil } enc, err := secrets.Encrypt(c.AppSecret) if err != nil { return c, err } c.AppSecret = enc return c, nil } // DecryptFromStore 把库内密文 AppSecret 还原为明文(无前缀的历史明文透传)。 func (c Config) DecryptFromStore() Config { if c.AppSecret != "" { if plain, err := secrets.Decrypt(c.AppSecret); err == nil { c.AppSecret = plain } } return c } // AuthorizeURL 构造微信 OAuth 授权跳转地址。 // scope=snsapi_base:只拿 openid,不弹授权页、用户无感(登录只需要唯一标识,够用了)。 // state 回传我们的 ticket,用于把回调关联回发起登录的那个 PC 会话。 func (c Config) AuthorizeURL(redirectURI, state string) string { q := url.Values{} q.Set("appid", c.AppID) q.Set("redirect_uri", redirectURI) q.Set("response_type", "code") q.Set("scope", "snsapi_base") q.Set("state", state) // #wechat_redirect 是微信要求的锚点,缺了不跳转 return "https://open.weixin.qq.com/connect/oauth2/authorize?" + q.Encode() + "#wechat_redirect" } // UserInfo 是 code 换取的结果(snsapi_base 下只有 openid)。 type UserInfo struct { OpenID string } // ExchangeCode 用授权 code 换 openid(网页授权专用接口,不占 access_token 的 IP 白名单?—— // 实际上仍走 api.weixin.qq.com,出网 IP 必须在白名单里,否则报 40164)。 func (c Config) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) { q := url.Values{} q.Set("appid", c.AppID) q.Set("secret", c.AppSecret) q.Set("code", code) q.Set("grant_type", "authorization_code") endpoint := "https://api.weixin.qq.com/sns/oauth2/access_token?" + q.Encode() reqCtx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("请求微信换 openid 失败: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) // 微信无论成败都返回 200 + JSON;errcode 非 0 才是失败。 var r struct { OpenID string `json:"openid"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &r); err != nil { return nil, fmt.Errorf("解析微信响应失败: %s", strings.TrimSpace(string(body))) } if r.ErrCode != 0 || r.OpenID == "" { // 40163=code 已使用,40029=code 无效,40164=IP 不在白名单——原样带出便于排查 return nil, fmt.Errorf("微信换 openid 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) } return &UserInfo{OpenID: r.OpenID}, nil }