bd829cfecb
owner/admin 在桌面端生成一张微信带参二维码发给团队,成员用微信扫码关注 即自动加入租户,并收到「✅ 已加入团队【X】」被动回复。与登录二维码同一微信机制, scene 加 inv_ 前缀分流;扫码入组走被动回复,不需要 access_token、不碰 IP 白名单。 - store: TenantInvite(可复用码=有效期+人数上限+可撤销三道闸);RedeemInvite 幂等入组、 同一人重复扫不重复消耗名额、复活已移除者;带单测钉死过期/撤销/满员/幂等。 - handler: 建码/列表/撤销端点(RequireTenantRole admin);WxMPEvent inv_ 分支。 - 顺手修潜在生产 bug:微信用户此前都建成空邮箱,User.Email 整列唯一索引下第二个微信 用户就撞唯一约束建号失败(登录"全通"只因当前仅一个微信用户)。改为按 openid 合成占位 邮箱 wx-<openid>@wx.local,绕开冲突且不动索引。邀请功能会批量建微信用户,非修不可。 - desktop: 顶栏「邀请成员」入口(租户 admin/owner 可见) + 二维码弹窗(生成/大图展示/撤销)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
290 lines
11 KiB
Go
290 lines
11 KiB
Go
// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
|
||
//
|
||
// 登录即引导关注公众号(涨粉),流程:
|
||
// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
|
||
// 2. PC 显示这张微信二维码
|
||
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
|
||
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
|
||
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
|
||
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
|
||
// 两种都带 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 用带参数「临时」二维码承载登录 ticket。登录与邀请共用底层 CreateSceneQR。
|
||
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
|
||
return c.CreateSceneQR(ctx, accessToken, scene, expireSec)
|
||
}
|
||
|
||
// CreateSceneQR 建一张带参数「临时」二维码,scene 为任意字符串(≤64 字符)。
|
||
// 登录用它承载 ticket、邀请用它承载 inv_<token>。expireSec 为二维码有效期。
|
||
// 返回可直接 <img> 展示的二维码图 URL。
|
||
func (c Config) CreateSceneQR(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
|
||
}
|
||
|
||
// SendCustomText 发一条「客服消息」文本给指定 openid(主动推送)。
|
||
// 约束:微信只允许在用户 48 小时内与公众号有过互动时下发(否则 45015 errcode)。
|
||
// 适合支付回执这类「用户刚操作完」的即时通知;隔天的提醒需改用模板消息。
|
||
// 需 access_token(走中控/直连均可)。
|
||
func SendCustomText(ctx context.Context, accessToken, openID, content string) error {
|
||
reqBody := map[string]any{
|
||
"touser": openID,
|
||
"msgtype": "text",
|
||
"text": map[string]any{"content": content},
|
||
}
|
||
raw, _ := json.Marshal(reqBody)
|
||
endpoint := "https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=" + url.QueryEscape(accessToken)
|
||
body, err := httpPost(ctx, endpoint, raw)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var r struct {
|
||
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 {
|
||
return fmt.Errorf("发送客服消息失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
|
||
}
|
||
return 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_<scene>;SCAN: <scene>
|
||
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(
|
||
"<xml><ToUserName><![CDATA[%s]]></ToUserName><FromUserName><![CDATA[%s]]></FromUserName>"+
|
||
"<CreateTime>%d</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[%s]]></Content></xml>",
|
||
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)
|
||
}
|