fix(subscribe): 模板ID按微信字符集规范化,根治「看起来一样却比对不上/40037」

- 新增 normalizeTemplateId:仅保留 [A-Za-z0-9_-],剔除零宽空格(U+200B)等 trim() 无法清除的不可见字符
- 模板比对、真实下发(notifyBySubscribe)、测试推送、下发小程序的模板ID统一走规范化
- 后台保存 message_template_rules 时落库前规范化,避免脏值再次进入
- 回归 87 套件/638 用例全绿;冒烟 5/5 验证:脏值落库被清洗、下发值无不可见字符
这个提交包含在:
xcbyy
2026-10-19 11:18:12 +08:00
父节点 1c38c32bd6
当前提交 c58449ddae
共有 2 个文件被更改,包括 48 次插入 和 13 次删除

查看文件

@@ -612,9 +612,9 @@ 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去请求授权
// 过滤占位符(tmpl_xxx)与不可见字符,避免小程序拿无效模板ID去请求授权
const clean = (v: any) => {
const s = String(v == null ? '' : v).trim();
const s = normalizeTemplateId(v);
return /^tmpl_/i.test(s) ? '' : s;
};
res.json({
@@ -1760,6 +1760,18 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
}
}
// 订阅消息模板ID落库前规范化:剔除零宽字符/全角字符等不可见字符(否则原样发给微信会 40037)
if (configKey === 'message_template_rules' && valueJson && typeof valueJson === 'object') {
const normId = (v: any) => {
const s = normalizeTemplateId(v);
return /^tmpl_/i.test(s) ? '' : s;
};
valueJson.orderStatusTemplateId = normId(valueJson.orderStatusTemplateId);
valueJson.activityJoinTemplateId = normId(valueJson.activityJoinTemplateId);
valueJson.withdrawSuccessTemplateId = normId(valueJson.withdrawSuccessTemplateId);
valueJson.verifyCodeTemplateId = normId(valueJson.verifyCodeTemplateId);
}
const updated = await repo.updatePlatformConfig(configKey, {
name,
valueJson,
@@ -2338,8 +2350,8 @@ async function notifyBySubscribe(
withdrawSuccess: 'withdrawSuccessTemplateId',
verifyCode: 'verifyCodeTemplateId',
};
const templateId = String(rules[keyMap[templateKey]] || '').trim();
if (!templateId) return;
const templateId = normalizeTemplateId(rules[keyMap[templateKey]]);
if (!templateId || /^tmpl_/i.test(templateId)) return;
const payCfg = await resolveWechatPayRuntimeConfig();
const appId = String(payCfg.appId || '').trim();
@@ -3787,6 +3799,15 @@ function maskTail(s: string): string {
return v ? `${v.slice(0, 6)}****${v.slice(-4)}` : '';
}
/**
* 规范化微信模板ID:微信模板ID只由 [A-Za-z0-9_-] 组成。
* 粘贴时极易混入零宽空格(U+200B)、全角字符、换行等不可见字符——trim() 无法清除零宽字符,
* 会导致"看起来一样却比对不上",并且**原样发给微信会直接 40037**。
*/
function normalizeTemplateId(v: any): string {
return String(v == null ? '' : v).replace(/[^A-Za-z0-9_-]/g, '');
}
// 微信订阅消息错误码中文化,便于后台定位真实原因
function describeWechatSubscribeError(r: any): string {
const code = Number(r && r.errcode) || 0;
@@ -3823,9 +3844,11 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
withdrawSuccess: 'withdrawSuccessTemplateId',
verifyCode: 'verifyCodeTemplateId',
};
const rawTemplateId = String(rules[idMap[tKey]] || '').trim();
// 过滤后台占位符(tmpl_xxx):避免用无效模板ID去真调微信
const templateId = /^tmpl_/i.test(rawTemplateId) ? '' : rawTemplateId;
const rawTemplateId = String(rules[idMap[tKey]] || '');
// 规范化后判断:过滤占位符 tmpl_xxx,并剔除不可见字符
const normalizedTemplateId = normalizeTemplateId(rawTemplateId);
const isPlaceholder = /^tmpl_/i.test(normalizedTemplateId);
const templateId = isPlaceholder ? '' : normalizedTemplateId;
const enablePush = Number(rules.enablePush) === 1;
const cfg = await resolveWechatPayRuntimeConfig();
@@ -3893,10 +3916,16 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
withdrawSuccess: '大神佣金提现打款到账',
verifyCode: '现场4位核销码签到提醒',
};
const rawDisplay = String(rawTemplateId).trim();
const reasonDetail = !rawDisplay
? ''
: (isPlaceholder
? `(当前值为占位符 ${normalizedTemplateId})`
: `(当前值 ${rawDisplay.slice(0, 64)} 不是有效的微信模板ID)`);
const reason = !enablePush
? '订阅消息总开关未开启'
: (!templateId
? `未配置「${typeLabels[tKey] || tKey}」模板ID${rawTemplateId ? `(当前值为占位符 ${rawTemplateId})` : ''}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
? `未配置「${typeLabels[tKey] || tKey}」模板ID${reasonDetail}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
: (!openid
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
: '小程序 appId/appSecret 未配置'));
@@ -3932,18 +3961,18 @@ app.get('/api/admin/message/templates', async (_req: Request, res: Response) =>
const list = data && Array.isArray(data.data) ? data.data : [];
const record = await repo.getPlatformConfigByKey('message_template_rules');
const rules = (record && record.valueJson) || {};
const clean = (v: any) => String(v == null ? '' : v).trim();
const clean = (v: any) => normalizeTemplateId(v);
const configured = {
orderStatus: clean(rules.orderStatusTemplateId),
activityJoin: clean(rules.activityJoinTemplateId),
withdrawSuccess: clean(rules.withdrawSuccessTemplateId),
verifyCode: clean(rules.verifyCodeTemplateId),
};
// 以服务端为准做匹配:两侧均 trim,避免因末尾空白/换行导致的误报
// 以服务端为准做匹配:两侧均按微信ID字符集规范化,避免不可见字符导致的误报
const existing = new Set(list.map((t: any) => clean(t.priTmplId)));
const mismatched = Object.entries(configured)
.filter(([, id]) => Boolean(id) && !existing.has(id))
.map(([key, id]) => ({ key, templateId: id }));
.map(([key, id]) => ({ key, templateId: id, length: id.length }));
res.json({
code: 20000,
data: {