feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里 回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单 限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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()})
|
||||
|
||||
Reference in New Issue
Block a user