fix(subscribe): 模板ID按微信字符集规范化,根治「看起来一样却比对不上/40037」
- 新增 normalizeTemplateId:仅保留 [A-Za-z0-9_-],剔除零宽空格(U+200B)等 trim() 无法清除的不可见字符 - 模板比对、真实下发(notifyBySubscribe)、测试推送、下发小程序的模板ID统一走规范化 - 后台保存 message_template_rules 时落库前规范化,避免脏值再次进入 - 回归 87 套件/638 用例全绿;冒烟 5/5 验证:脏值落库被清洗、下发值无不可见字符
这个提交包含在:
@@ -612,9 +612,9 @@ app.get('/api/message/subscribe-templates', async (_req: Request, res: Response)
|
|||||||
try {
|
try {
|
||||||
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
||||||
const rules = (record && record.valueJson) || {};
|
const rules = (record && record.valueJson) || {};
|
||||||
// 过滤占位符(tmpl_xxx),避免小程序拿无效模板ID去请求授权
|
// 过滤占位符(tmpl_xxx)与不可见字符,避免小程序拿无效模板ID去请求授权
|
||||||
const clean = (v: any) => {
|
const clean = (v: any) => {
|
||||||
const s = String(v == null ? '' : v).trim();
|
const s = normalizeTemplateId(v);
|
||||||
return /^tmpl_/i.test(s) ? '' : s;
|
return /^tmpl_/i.test(s) ? '' : s;
|
||||||
};
|
};
|
||||||
res.json({
|
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, {
|
const updated = await repo.updatePlatformConfig(configKey, {
|
||||||
name,
|
name,
|
||||||
valueJson,
|
valueJson,
|
||||||
@@ -2338,8 +2350,8 @@ async function notifyBySubscribe(
|
|||||||
withdrawSuccess: 'withdrawSuccessTemplateId',
|
withdrawSuccess: 'withdrawSuccessTemplateId',
|
||||||
verifyCode: 'verifyCodeTemplateId',
|
verifyCode: 'verifyCodeTemplateId',
|
||||||
};
|
};
|
||||||
const templateId = String(rules[keyMap[templateKey]] || '').trim();
|
const templateId = normalizeTemplateId(rules[keyMap[templateKey]]);
|
||||||
if (!templateId) return;
|
if (!templateId || /^tmpl_/i.test(templateId)) return;
|
||||||
|
|
||||||
const payCfg = await resolveWechatPayRuntimeConfig();
|
const payCfg = await resolveWechatPayRuntimeConfig();
|
||||||
const appId = String(payCfg.appId || '').trim();
|
const appId = String(payCfg.appId || '').trim();
|
||||||
@@ -3787,6 +3799,15 @@ function maskTail(s: string): string {
|
|||||||
return v ? `${v.slice(0, 6)}****${v.slice(-4)}` : '';
|
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 {
|
function describeWechatSubscribeError(r: any): string {
|
||||||
const code = Number(r && r.errcode) || 0;
|
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',
|
withdrawSuccess: 'withdrawSuccessTemplateId',
|
||||||
verifyCode: 'verifyCodeTemplateId',
|
verifyCode: 'verifyCodeTemplateId',
|
||||||
};
|
};
|
||||||
const rawTemplateId = String(rules[idMap[tKey]] || '').trim();
|
const rawTemplateId = String(rules[idMap[tKey]] || '');
|
||||||
// 过滤后台占位符(tmpl_xxx):避免用无效模板ID去真调微信
|
// 规范化后判断:过滤占位符 tmpl_xxx,并剔除不可见字符
|
||||||
const templateId = /^tmpl_/i.test(rawTemplateId) ? '' : rawTemplateId;
|
const normalizedTemplateId = normalizeTemplateId(rawTemplateId);
|
||||||
|
const isPlaceholder = /^tmpl_/i.test(normalizedTemplateId);
|
||||||
|
const templateId = isPlaceholder ? '' : normalizedTemplateId;
|
||||||
const enablePush = Number(rules.enablePush) === 1;
|
const enablePush = Number(rules.enablePush) === 1;
|
||||||
|
|
||||||
const cfg = await resolveWechatPayRuntimeConfig();
|
const cfg = await resolveWechatPayRuntimeConfig();
|
||||||
@@ -3893,10 +3916,16 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
|
|||||||
withdrawSuccess: '大神佣金提现打款到账',
|
withdrawSuccess: '大神佣金提现打款到账',
|
||||||
verifyCode: '现场4位核销码签到提醒',
|
verifyCode: '现场4位核销码签到提醒',
|
||||||
};
|
};
|
||||||
|
const rawDisplay = String(rawTemplateId).trim();
|
||||||
|
const reasonDetail = !rawDisplay
|
||||||
|
? ''
|
||||||
|
: (isPlaceholder
|
||||||
|
? `(当前值为占位符 ${normalizedTemplateId})`
|
||||||
|
: `(当前值 ${rawDisplay.slice(0, 64)} 不是有效的微信模板ID)`);
|
||||||
const reason = !enablePush
|
const reason = !enablePush
|
||||||
? '订阅消息总开关未开启'
|
? '订阅消息总开关未开启'
|
||||||
: (!templateId
|
: (!templateId
|
||||||
? `未配置「${typeLabels[tKey] || tKey}」模板ID${rawTemplateId ? `(当前值为占位符 ${rawTemplateId})` : ''}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
|
? `未配置「${typeLabels[tKey] || tKey}」模板ID${reasonDetail}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
|
||||||
: (!openid
|
: (!openid
|
||||||
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
|
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
|
||||||
: '小程序 appId/appSecret 未配置'));
|
: '小程序 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 list = data && Array.isArray(data.data) ? data.data : [];
|
||||||
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
||||||
const rules = (record && record.valueJson) || {};
|
const rules = (record && record.valueJson) || {};
|
||||||
const clean = (v: any) => String(v == null ? '' : v).trim();
|
const clean = (v: any) => normalizeTemplateId(v);
|
||||||
const configured = {
|
const configured = {
|
||||||
orderStatus: clean(rules.orderStatusTemplateId),
|
orderStatus: clean(rules.orderStatusTemplateId),
|
||||||
activityJoin: clean(rules.activityJoinTemplateId),
|
activityJoin: clean(rules.activityJoinTemplateId),
|
||||||
withdrawSuccess: clean(rules.withdrawSuccessTemplateId),
|
withdrawSuccess: clean(rules.withdrawSuccessTemplateId),
|
||||||
verifyCode: clean(rules.verifyCodeTemplateId),
|
verifyCode: clean(rules.verifyCodeTemplateId),
|
||||||
};
|
};
|
||||||
// 以服务端为准做匹配:两侧均 trim,避免因末尾空白/换行导致的误报
|
// 以服务端为准做匹配:两侧均按微信ID字符集规范化,避免不可见字符导致的误报
|
||||||
const existing = new Set(list.map((t: any) => clean(t.priTmplId)));
|
const existing = new Set(list.map((t: any) => clean(t.priTmplId)));
|
||||||
const mismatched = Object.entries(configured)
|
const mismatched = Object.entries(configured)
|
||||||
.filter(([, id]) => Boolean(id) && !existing.has(id))
|
.filter(([, id]) => Boolean(id) && !existing.has(id))
|
||||||
.map(([key, id]) => ({ key, templateId: id }));
|
.map(([key, id]) => ({ key, templateId: id, length: id.length }));
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('第七十批订阅消息原因可见与真实授权', () => {
|
|||||||
const userCode = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8');
|
const userCode = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8');
|
||||||
|
|
||||||
it('后台占位符模板ID不会被当成已配置,并返回可诊断信息', () => {
|
it('后台占位符模板ID不会被当成已配置,并返回可诊断信息', () => {
|
||||||
expect(indexCode).toContain('/^tmpl_/i.test(rawTemplateId)');
|
expect(indexCode).toContain('/^tmpl_/i.test(normalizedTemplateId)');
|
||||||
expect(indexCode).toContain('diagnostics');
|
expect(indexCode).toContain('diagnostics');
|
||||||
expect(indexCode).toContain('没有 wechat_openid');
|
expect(indexCode).toContain('没有 wechat_openid');
|
||||||
expect(indexCode).toContain('订阅消息总开关未开启');
|
expect(indexCode).toContain('订阅消息总开关未开启');
|
||||||
@@ -46,10 +46,16 @@ describe('第七十批订阅消息原因可见与真实授权', () => {
|
|||||||
|
|
||||||
it('模板ID匹配以后端为准并做 trim,避免误报不匹配', () => {
|
it('模板ID匹配以后端为准并做 trim,避免误报不匹配', () => {
|
||||||
expect(indexCode).toContain('const mismatched = Object.entries(configured)');
|
expect(indexCode).toContain('const mismatched = Object.entries(configured)');
|
||||||
expect(indexCode).toContain('const clean = (v: any) => String(v == null');
|
expect(indexCode).toContain('const clean = (v: any) => normalizeTemplateId(v)');
|
||||||
expect(adminCode).toContain('t.mismatched');
|
expect(adminCode).toContain('t.mismatched');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('模板ID按微信字符集规范化,剔除零宽/不可见字符', () => {
|
||||||
|
expect(indexCode).toContain('function normalizeTemplateId(');
|
||||||
|
expect(indexCode).toContain(".replace(/[^A-Za-z0-9_-]/g, '')");
|
||||||
|
expect(indexCode).toContain('const normId = (v: any)');
|
||||||
|
});
|
||||||
|
|
||||||
it('小程序真正调用订阅授权并过滤占位符', () => {
|
it('小程序真正调用订阅授权并过滤占位符', () => {
|
||||||
expect(subscribeCode).toContain('requestSubscribeAll');
|
expect(subscribeCode).toContain('requestSubscribeAll');
|
||||||
expect(subscribeCode).toContain("startsWith('tmpl_')");
|
expect(subscribeCode).toContain("startsWith('tmpl_')");
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户