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
@@ -156,26 +156,47 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
}
body, _ := io.ReadAll(c.Request.Body)
ev, err := wechat.ParseEvent(body)
if err != nil || !ev.IsLoginScan() {
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
if err != nil {
c.String(http.StatusOK, "success")
return
}
// 登录扫码(关注扫码 / 已关注再扫)→ 授权对应 ticket,让 PC 端轮询登录。
if ev.IsLoginScan() {
h.authorizeWxLogin(ctx, ev)
}
// 新关注 → 被动回复欢迎语(覆盖「扫登录码后关注」和「搜索直接关注」两种入口)。
// 被动回复无需 access_token、不受 IP 白名单限制,回执 XML 即到达用户。
if ev.IsSubscribe() {
welcome := strings.TrimSpace(cfg.Welcome)
if welcome == "" {
welcome = wechat.DefaultWelcome
}
reply := wechat.BuildTextReply(ev.FromUserName, ev.ToUserName, welcome, time.Now().Unix())
c.Header("Content-Type", "application/xml")
c.String(http.StatusOK, reply)
return
}
c.String(http.StatusOK, "success")
}
// authorizeWxLogin 处理登录扫码事件:按 openid 找/建用户并置 ticket 已授权。
// 只做副作用(Redis/DB),不写 HTTP 响应——响应由调用方按事件类型统一决定。
func (h *Handler) authorizeWxLogin(ctx context.Context, ev *wechat.Event) {
ticket, openID := ev.Scene(), ev.FromUserName
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
c.String(http.StatusOK, "success")
return
}
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
if err != nil {
c.String(http.StatusOK, "success")
return
}
if u == nil {
u, err = h.db.CreateWechatUser(ctx, openID, wechatNickname(openID))
if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
c.String(http.StatusOK, "success")
return
}
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
@@ -184,7 +205,6 @@ func (h *Handler) WxMPEvent(c *gin.Context) {
}
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
c.String(http.StatusOK, "success")
}
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
@@ -230,6 +250,7 @@ func (h *Handler) AdminGetWechatMP(c *gin.Context) {
"appid": cfg.AppID,
"token": cfg.Token,
"app_secret": cfg.AppSecret,
"welcome": cfg.Welcome,
"enabled": cfg.Enabled(),
})
}
@@ -239,6 +260,7 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
Token string `json:"token"`
Welcome string `json:"welcome"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
@@ -249,7 +271,7 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
if secret == "" {
secret = h.loadWechatMP(ctx).AppSecret
}
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token)}
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token), Welcome: strings.TrimSpace(b.Welcome)}
stored, err := cfg.EncryptedForStore()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})