feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)

用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里
回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单
限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 11:17:41 +08:00
parent 9e2ff6007f
commit 83269e067a
5 changed files with 106 additions and 12 deletions
+22 -1
View File
@@ -31,12 +31,17 @@ import (
"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)。
@@ -174,13 +179,29 @@ func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, ex
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
}
// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。
// 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 不带。