feat(wechat): 关注公众号后自动回复欢迎语(被动回复,可后台配置)
用户关注服务号(扫登录码后关注 或 直接搜索关注)时,在回调 HTTP 响应里 回一条文本消息(微信「被动回复」)。被动回复不需要 access_token、不受 IP 白名单 限制,永远能发。欢迎语在管理端「登录设置」可配,留空用默认。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,7 @@ export interface WechatMPConfig {
|
||||
appid: string;
|
||||
token: string; // 消息推送签名校验用(与公众平台服务器配置一致)
|
||||
app_secret: string; // 单管理员后台:明文回显
|
||||
welcome: string; // 关注后自动回复的欢迎语(空=用默认)
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -255,11 +256,11 @@ export async function getWechatMP(): Promise<WechatMPConfig> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() }));
|
||||
const d = (await res.json().catch(() => ({}))) as Partial<WechatMPConfig> & { error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`);
|
||||
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", enabled: !!d.enabled };
|
||||
return { appid: d.appid ?? "", token: d.token ?? "", app_secret: d.app_secret ?? "", welcome: d.welcome ?? "", enabled: !!d.enabled };
|
||||
}
|
||||
|
||||
// app_secret 传空串 = 沿用已保存的。
|
||||
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string }): Promise<{ enabled: boolean }> {
|
||||
export async function saveWechatMP(body: { appid: string; app_secret: string; token: string; welcome: string }): Promise<{ enabled: boolean }> {
|
||||
const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) }));
|
||||
const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string };
|
||||
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||
|
||||
@@ -8,6 +8,7 @@ export function LoginConfigPage() {
|
||||
const [appid, setAppid] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const [secret, setSecret] = useState(""); // 留空=沿用已存
|
||||
const [welcome, setWelcome] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [ok, setOk] = useState(false);
|
||||
@@ -19,6 +20,7 @@ export function LoginConfigPage() {
|
||||
setAppid(c.appid);
|
||||
setToken(c.token);
|
||||
setSecret(c.app_secret);
|
||||
setWelcome(c.welcome);
|
||||
})
|
||||
.catch((e) => setErr((e as Error).message));
|
||||
}, []);
|
||||
@@ -29,8 +31,8 @@ export function LoginConfigPage() {
|
||||
setErr("");
|
||||
setOk(false);
|
||||
try {
|
||||
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() });
|
||||
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, enabled: r.enabled } : c));
|
||||
const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim(), welcome: welcome.trim() });
|
||||
setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), app_secret: secret, welcome: welcome.trim(), enabled: r.enabled } : c));
|
||||
setOk(true);
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
@@ -77,6 +79,12 @@ export function LoginConfigPage() {
|
||||
<input value={token} onChange={(e) => setToken(e.target.value)} placeholder="自定义一串字母数字"
|
||||
className="mt-1 block w-full rounded-lg border border-gray-200 px-2.5 py-1.5 font-mono text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
<label className="text-xs text-gray-500 md:col-span-2">
|
||||
关注欢迎语(用户关注后自动回复。留空则用默认,建议写上下载链接)
|
||||
<textarea value={welcome} onChange={(e) => setWelcome(e.target.value)} rows={3}
|
||||
placeholder={"欢迎关注!\n下载客户端登录即可开始体验。\n下载地址:https://…"}
|
||||
className="mt-1 block w-full resize-y rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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 不带。
|
||||
|
||||
@@ -97,6 +97,48 @@ func TestParseEvent_IgnoresNonLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 关注事件要能识别(自动回复欢迎语),并解析出回复所需的 openid + 公众号原始 ID。
|
||||
func TestParseEvent_SubscribeReplyFields(t *testing.T) {
|
||||
sub := `<xml><ToUserName><![CDATA[gh_808ba17576cf]]></ToUserName>
|
||||
<FromUserName><![CDATA[openid_x]]></FromUserName>
|
||||
<CreateTime>1700000000</CreateTime>
|
||||
<MsgType><![CDATA[event]]></MsgType>
|
||||
<Event><![CDATA[subscribe]]></Event>
|
||||
<EventKey><![CDATA[]]></EventKey></xml>`
|
||||
ev, err := ParseEvent([]byte(sub))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ev.IsSubscribe() {
|
||||
t.Fatal("subscribe 应被识别为新关注")
|
||||
}
|
||||
if ev.ToUserName != "gh_808ba17576cf" {
|
||||
t.Fatalf("公众号原始 ID 取错:%q", ev.ToUserName)
|
||||
}
|
||||
// 无 scene 的直接关注:不是登录扫码,但仍是 subscribe(要回欢迎语)
|
||||
if ev.IsLoginScan() {
|
||||
t.Fatal("无 scene 关注不该触发登录")
|
||||
}
|
||||
}
|
||||
|
||||
// 被动回复 XML:ToUser=用户、FromUser=公众号、含文本内容,且防 CDATA 提前闭合。
|
||||
func TestBuildTextReply(t *testing.T) {
|
||||
xml := BuildTextReply("openid_user", "gh_pub", "你好]]>危险", 1700000000)
|
||||
for _, want := range []string{
|
||||
"<ToUserName><![CDATA[openid_user]]>",
|
||||
"<FromUserName><![CDATA[gh_pub]]>",
|
||||
"<MsgType><![CDATA[text]]>",
|
||||
"<CreateTime>1700000000</CreateTime>",
|
||||
} {
|
||||
if !strings.Contains(xml, want) {
|
||||
t.Fatalf("回复 XML 缺 %q:\n%s", want, xml)
|
||||
}
|
||||
}
|
||||
if strings.Contains(xml, "你好]]>危险") {
|
||||
t.Fatal("内容里的 ]]> 应被转义,避免 CDATA 提前闭合")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_SecretRoundTrip(t *testing.T) {
|
||||
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
||||
stored, err := c.EncryptedForStore()
|
||||
|
||||
Reference in New Issue
Block a user