fix(subscribe): 订阅消息未下发原因可见 + 小程序真实授权

- 后台测试推送:未就绪返回 mode=SIMULATED 明确原因 + 配置自检(模板ID/openid/appId/appSecret/总开关),不再把模拟显示为成功

- 占位符 tmpl_xxx 视为未配置(测试推送与订阅模板接口均过滤),微信错误码中文化(43101/47003/40037/40001 等)

- 小程序「平台消息与通知」点击时真实调用 wx.requestSubscribeMessage 并回显授权状态,标签不再恒为已开启

- 管理端拦截器错误提示改用 message;文档补订阅消息排查顺序;新增回归护栏(86 套件/631 用例全绿)
这个提交包含在:
xcbyy
2026-10-19 10:02:57 +08:00
父节点 0aed11cb01
当前提交 cacc478d71
共有 8 个文件被更改,包括 229 次插入 和 29 次删除

查看文件

@@ -612,14 +612,19 @@ app.get('/api/message/subscribe-templates', async (_req: Request, res: Response)
try {
const record = await repo.getPlatformConfigByKey('message_template_rules');
const rules = (record && record.valueJson) || {};
// 过滤占位符(tmpl_xxx),避免小程序拿无效模板ID去请求授权
const clean = (v: any) => {
const s = String(v == null ? '' : v).trim();
return /^tmpl_/i.test(s) ? '' : s;
};
res.json({
code: 20000,
data: {
enablePush: Number(rules.enablePush) === 1,
orderStatusTemplateId: String(rules.orderStatusTemplateId || ''),
activityJoinTemplateId: String(rules.activityJoinTemplateId || ''),
withdrawSuccessTemplateId: String(rules.withdrawSuccessTemplateId || ''),
verifyCodeTemplateId: String(rules.verifyCodeTemplateId || '')
orderStatusTemplateId: clean(rules.orderStatusTemplateId),
activityJoinTemplateId: clean(rules.activityJoinTemplateId),
withdrawSuccessTemplateId: clean(rules.withdrawSuccessTemplateId),
verifyCodeTemplateId: clean(rules.verifyCodeTemplateId)
}
});
} catch (err: any) {
@@ -3754,7 +3759,24 @@ app.delete('/api/admin/posts/comments/:commentId', async (req: Request, res: Res
}
});
// 管理端:微信订阅消息(服务通知)真实测试推送接口(模板ID/目标用户 openid/小程序凭证就绪时真实下发,否则模拟返回)
// 微信订阅消息错误码中文化,便于后台定位真实原因
function describeWechatSubscribeError(r: any): string {
const code = Number(r && r.errcode) || 0;
const dict: Record<number, string> = {
40003: 'touser 不是有效 openid',
40037: '模板ID无效(请在微信公众平台核对模板ID)',
41030: 'page 路径不存在',
43101: '用户未授权该模板订阅(用户需在小程序内授权;一次性订阅每条仅可下发 1 次)',
45009: '接口调用频率超限(access_token 复用不足或调用过快)',
40001: 'access_token 无效(appId 与 appSecret 不匹配)',
40125: 'appSecret 无效',
47003: '模板参数不合法(字段名或长度与模板不一致)',
};
const extra = dict[code] ? `(${dict[code]})` : '';
return `微信订阅消息下发失败: [${code}] ${(r && r.errmsg) || '未知错误'}${extra}`;
}
// 管理端:微信订阅消息(服务通知)真实测试推送接口(模板ID/目标用户 openid/小程序凭证就绪时真实下发,否则返回明确原因)
app.post('/api/admin/message/test-send', async (req: Request, res: Response) => {
try {
const { templateType, targetUserId, previewData, data, page } = req.body || {};
@@ -3773,14 +3795,34 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
withdrawSuccess: 'withdrawSuccessTemplateId',
verifyCode: 'verifyCodeTemplateId',
};
const templateId = String(rules[idMap[tKey]] || '').trim();
const rawTemplateId = String(rules[idMap[tKey]] || '').trim();
// 过滤后台占位符(tmpl_xxx):避免用无效模板ID去真调微信
const templateId = /^tmpl_/i.test(rawTemplateId) ? '' : rawTemplateId;
const enablePush = Number(rules.enablePush) === 1;
const cfg = await resolveWechatPayRuntimeConfig();
const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(targetUserId) || 0]);
const openid = uRows && uRows[0] && uRows[0].wechat_openid;
const diagnostics = {
enablePush,
hasTemplateId: Boolean(templateId),
hasOpenid: Boolean(openid),
hasAppId: Boolean(cfg.appId),
hasAppSecret: Boolean(cfg.appSecret),
};
if (templateId && openid && cfg.appId && cfg.appSecret) {
const accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret);
let accessToken = '';
try {
accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret);
} catch (e: any) {
return res.json({
code: 40000,
data: { mode: 'REAL_FAILED', templateType: tKey, templateId, targetUserId, diagnostics, error: 'ACCESS_TOKEN_FAILED' },
message: `获取微信 access_token 失败:${(e && e.message) || e}(请核对小程序 appId/appSecret)`
});
}
// 与所选模板字段对齐的示例数据(字段名必须与模板一致,否则 47003)
const sampleData: Record<string, Record<string, string>> = {
orderStatus: { character_string1: 'ORD20261017001', phrase2: '已开始服务', thing3: '同城陪伴服务', thing4: '核销码8392,请出示' },
@@ -3799,15 +3841,36 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
const ok = !result || !result.errcode || result.errcode === 0;
return res.json({
code: ok ? 20000 : 40000,
data: { mode: 'REAL', templateType: tKey, templateId, targetUserId, result },
message: ok ? '微信订阅消息已真实下发' : `微信订阅消息下发失败: [${result.errcode}] ${result.errmsg}`
data: {
mode: ok ? 'REAL' : 'REAL_FAILED',
templateType: tKey,
templateId,
targetUserId,
diagnostics,
result,
status: ok ? 'REAL' : 'REAL_FAILED',
wechatMsgId: `wx_msg_${Date.now()}`,
sendTime: new Date().toISOString(),
previewData: previewData || {}
},
message: ok ? '微信订阅消息已真实下发' : describeWechatSubscribeError(result)
});
}
// 未就绪:返回模拟结果并说明原因
const reason = !templateId
? '未配置该类型模板ID'
: (!openid ? '目标用户无 openid' : '小程序 appId/appSecret 未配置');
// 未就绪:返回模拟结果并给出明确原因(后台据此提示,避免被误解为"已发送成功")
const typeLabels: Record<string, string> = {
orderStatus: '订单履约状态变动',
activityJoin: '组局搭子报名与成局',
withdrawSuccess: '大神佣金提现打款到账',
verifyCode: '现场4位核销码签到提醒',
};
const reason = !enablePush
? '订阅消息总开关未开启'
: (!templateId
? `未配置「${typeLabels[tKey] || tKey}」模板ID${rawTemplateId ? `(当前值为占位符 ${rawTemplateId})` : ''}`
: (!openid
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
: '小程序 appId/appSecret 未配置'));
const result = {
templateType: tKey,
targetUserId: targetUserId || 1001,
@@ -3816,7 +3879,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
wechatMsgId: `wx_msg_${Date.now()}`,
previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' }
};
res.json({ code: 20000, data: { mode: 'SIMULATED', reason, ...result }, message: `未真实下发(${reason}),已按模拟推送返回` });
res.json({ code: 20000, data: { mode: 'SIMULATED', reason, diagnostics, ...result }, message: `未真实下发(${reason})` });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}