From c58449ddaee757d156aecb793ece8a06b97fbb79 Mon Sep 17 00:00:00 2001 From: xcbyy <2578898740@qq.com> Date: Mon, 19 Oct 2026 11:18:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(subscribe):=20=E6=A8=A1=E6=9D=BFID=E6=8C=89?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E5=AD=97=E7=AC=A6=E9=9B=86=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E6=A0=B9=E6=B2=BB=E3=80=8C=E7=9C=8B=E8=B5=B7?= =?UTF-8?q?=E6=9D=A5=E4=B8=80=E6=A0=B7=E5=8D=B4=E6=AF=94=E5=AF=B9=E4=B8=8D?= =?UTF-8?q?=E4=B8=8A/40037=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 normalizeTemplateId:仅保留 [A-Za-z0-9_-],剔除零宽空格(U+200B)等 trim() 无法清除的不可见字符 - 模板比对、真实下发(notifyBySubscribe)、测试推送、下发小程序的模板ID统一走规范化 - 后台保存 message_template_rules 时落库前规范化,避免脏值再次进入 - 回归 87 套件/638 用例全绿;冒烟 5/5 验证:脏值落库被清洗、下发值无不可见字符 --- server/src/index.ts | 51 +++++++++++++++---- .../subscription-message-diagnose.spec.ts | 10 +++- 2 files changed, 48 insertions(+), 13 deletions(-) diff --git a/server/src/index.ts b/server/src/index.ts index d9c4f7a..a4a7b6e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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: { diff --git a/server/tests/subscription-message-diagnose.spec.ts b/server/tests/subscription-message-diagnose.spec.ts index ee3d821..2cf4116 100644 --- a/server/tests/subscription-message-diagnose.spec.ts +++ b/server/tests/subscription-message-diagnose.spec.ts @@ -17,7 +17,7 @@ describe('第七十批订阅消息原因可见与真实授权', () => { const userCode = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8'); it('后台占位符模板ID不会被当成已配置,并返回可诊断信息', () => { - expect(indexCode).toContain('/^tmpl_/i.test(rawTemplateId)'); + expect(indexCode).toContain('/^tmpl_/i.test(normalizedTemplateId)'); expect(indexCode).toContain('diagnostics'); expect(indexCode).toContain('没有 wechat_openid'); expect(indexCode).toContain('订阅消息总开关未开启'); @@ -46,10 +46,16 @@ describe('第七十批订阅消息原因可见与真实授权', () => { it('模板ID匹配以后端为准并做 trim,避免误报不匹配', () => { 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'); }); + it('模板ID按微信字符集规范化,剔除零宽/不可见字符', () => { + expect(indexCode).toContain('function normalizeTemplateId('); + expect(indexCode).toContain(".replace(/[^A-Za-z0-9_-]/g, '')"); + expect(indexCode).toContain('const normId = (v: any)'); + }); + it('小程序真正调用订阅授权并过滤占位符', () => { expect(subscribeCode).toContain('requestSubscribeAll'); expect(subscribeCode).toContain("startsWith('tmpl_')");