// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。 // // 登录即引导关注公众号(涨粉),流程: // 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图 // 2. PC 显示这张微信二维码 // 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」 // 4. 微信把事件推到我们服务器(消息推送/服务器配置): // - 未关注用户 → subscribe 事件,EventKey=qrscene_ // - 已关注用户 → SCAN 事件,EventKey= // 两种都带 openid(FromUserName) // 5. 后端按 openid 找/建用户 → ticket 置 authorized // 6. PC 轮询 → 登录完成 // // 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。 package wechat import ( "context" "crypto/sha1" "encoding/hex" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/sundynix/sundynix-shared/secrets" ) // DefaultWelcome 是关注后自动回复的默认欢迎语(管理端可覆盖,建议填上下载链接)。 const DefaultWelcome = "欢迎关注!\n下载客户端并扫码登录,即可开始体验。" // Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。 type Config struct { AppID string `json:"appid"` AppSecret string `json:"app_secret"` // Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。 Token string `json:"token"` // Welcome:用户关注后自动回复的欢迎语(被动回复)。空则用 DefaultWelcome。非密文。 Welcome string `json:"welcome"` } // Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。 func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" } // 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 } // CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))。 // 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它。 func (c Config) CheckSignature(signature, timestamp, nonce string) bool { if c.Token == "" { return false } arr := []string{c.Token, timestamp, nonce} sort.Strings(arr) h := sha1.Sum([]byte(strings.Join(arr, ""))) return hex.EncodeToString(h[:]) == signature } // FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。 // 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。 func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) { q := url.Values{} q.Set("grant_type", "client_credential") q.Set("appid", c.AppID) q.Set("secret", c.AppSecret) endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode() body, err := httpGet(ctx, endpoint) if err != nil { return "", 0, err } var r struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &r); err != nil { return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body))) } if r.ErrCode != 0 || r.AccessToken == "" { return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) } return r.AccessToken, r.ExpiresIn, nil } // PullToken 从「中控服务器」拉取 access_token(微信官方推荐架构:单点取 token、多点用)。 // 中控(腾讯云静态 IP)负责换 token 并 serve,这样微信 IP 白名单只需填中控 IP; // gateway 出网 IP(本地动态宽带)不再参与换 token,避免 IP 一变就 40164。 // tokenURL serve 的 JSON 形如 {"access_token":"...","expires_in":7200};secret 走 Bearer 头鉴权。 func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error) { rctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() req, err := http.NewRequestWithContext(rctx, http.MethodGet, tokenURL, nil) if err != nil { return "", 0, err } if secret != "" { req.Header.Set("Authorization", "Bearer "+secret) } resp, err := http.DefaultClient.Do(req) if err != nil { return "", 0, fmt.Errorf("请求中控 token 失败: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != http.StatusOK { return "", 0, fmt.Errorf("中控 token 返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) } var r struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` } if err := json.Unmarshal(body, &r); err != nil { return "", 0, fmt.Errorf("解析中控 token 失败: %s", strings.TrimSpace(string(body))) } if r.AccessToken == "" { return "", 0, fmt.Errorf("中控返回空 token: %s", strings.TrimSpace(string(body))) } if r.ExpiresIn <= 0 { r.ExpiresIn = 7200 // 中控没给有效期时按微信默认兜底 } return r.AccessToken, r.ExpiresIn, nil } // CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。 // expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 展示的二维码图 URL。 func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) { reqBody := map[string]any{ "expire_seconds": expireSec, "action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket "action_info": map[string]any{"scene": map[string]any{"scene_str": scene}}, } raw, _ := json.Marshal(reqBody) endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken) body, err := httpPost(ctx, endpoint, raw) if err != nil { return "", err } var r struct { Ticket string `json:"ticket"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &r); err != nil { return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body))) } if r.ErrCode != 0 || r.Ticket == "" { return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) } // showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码 return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil } // Event 是微信推送的事件(明文 XML)。只取登录/回复需要的字段。 type Event struct { XMLName xml.Name `xml:"xml"` MsgType string `xml:"MsgType"` // event Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ... EventKey string `xml:"EventKey"` // subscribe: qrscene_;SCAN: FromUserName string `xml:"FromUserName"` // 用户 openid ToUserName string `xml:"ToUserName"` // 公众号原始 ID(回复时作 FromUserName) CreateTime int64 `xml:"CreateTime"` // 事件时间(秒) } // IsSubscribe 报告是否是「新关注」事件(用于自动回复欢迎语)。 func (e Event) IsSubscribe() bool { return e.MsgType == "event" && e.Event == "subscribe" } // BuildTextReply 组装「被动回复」的文本消息 XML(回调 HTTP 响应体)。 // 被动回复不需要 access_token、不受 IP 白名单限制,微信收到即转发给用户。 // toUser=用户 openid(=事件的 FromUserName);fromUser=公众号原始 ID(=事件的 ToUserName)。 func BuildTextReply(toUser, fromUser, content string, createTime int64) string { content = strings.ReplaceAll(content, "]]>", "]] >") // 防 CDATA 提前闭合 return fmt.Sprintf( ""+ "%d", toUser, fromUser, createTime, content) } // Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。 func (e Event) Scene() string { return strings.TrimPrefix(e.EventKey, "qrscene_") } // IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。 func (e Event) IsLoginScan() bool { if e.MsgType != "event" { return false } return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != "" } // ParseEvent 解析明文事件 XML。 func ParseEvent(body []byte) (*Event, error) { var e Event if err := xml.Unmarshal(body, &e); err != nil { return nil, err } return &e, nil } // ---- HTTP 小工具 ---- func httpGet(ctx context.Context, endpoint string) ([]byte, error) { rctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("请求微信失败: %w", err) } defer resp.Body.Close() return io.ReadAll(resp.Body) } func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) { rctx, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body))) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("请求微信失败: %w", err) } defer resp.Body.Close() return io.ReadAll(resp.Body) }