feat(subscribe): 新增拉取小程序真实模板列表诊断(定位40037)
- 新增 GET /api/admin/message/templates:调用 wxaapi/newtmpl/gettemplate 返回当前 appId(脱敏) 下真实模板列表,并标出后台已填但不在列表中的ID - 测试推送 diagnostics 增加 appIdMasked,便于核对 appId - 后台新增「拉取小程序模板列表(核对ID)」按钮与不匹配高亮提示 - 文档补充 40037 排查步骤;回归 86 套件/632 用例全绿
这个提交包含在:
@@ -650,9 +650,44 @@
|
||||
</a-row>
|
||||
|
||||
<div style="margin-top: 16px">
|
||||
<a-button type="primary" :loading="saving" @click="saveMessageTemplateConfig">
|
||||
保存微信模板消息配置
|
||||
</a-button>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="saving" @click="saveMessageTemplateConfig">
|
||||
保存微信模板消息配置
|
||||
</a-button>
|
||||
<a-button :loading="loadingTemplates" @click="fetchWechatTemplates">
|
||||
拉取小程序模板列表(核对ID)
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div v-if="wechatTemplates" style="margin-top: 16px">
|
||||
<a-alert
|
||||
:type="mismatchedTemplateIds.length > 0 ? 'error' : 'success'"
|
||||
:title="mismatchedTemplateIds.length > 0
|
||||
? `有 ${mismatchedTemplateIds.length} 个已填模板ID不属于该小程序(会导致 40037 invalid template_id)`
|
||||
: `已填模板ID均匹配该小程序(appId: ${wechatTemplates.appIdMasked || '-'})`"
|
||||
>
|
||||
<div>小程序模板总数:{{ wechatTemplates.count ?? 0 }}</div>
|
||||
<div v-if="mismatchedTemplateIds.length > 0">
|
||||
不在列表中的已填ID:{{ mismatchedTemplateIds.join(';') }}
|
||||
</div>
|
||||
<div v-if="wechatTemplates.errcode">微信返回:[{{ wechatTemplates.errcode }}] {{ wechatTemplates.errmsg }}</div>
|
||||
</a-alert>
|
||||
|
||||
<a-table
|
||||
v-if="Array.isArray(wechatTemplates.templates) && wechatTemplates.templates.length"
|
||||
style="margin-top: 12px"
|
||||
:data="wechatTemplates.templates"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:scroll="{ y: 260 }"
|
||||
>
|
||||
<template #columns>
|
||||
<a-table-column title="模板标题" data-index="title" :width="180" />
|
||||
<a-table-column title="模板ID" data-index="templateId" :width="320" />
|
||||
<a-table-column title="类型" data-index="type" :width="90" />
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-form>
|
||||
</a-card>
|
||||
@@ -1031,7 +1066,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import axios from 'axios';
|
||||
|
||||
@@ -1339,6 +1374,8 @@ const messageTemplateForm = reactive({
|
||||
|
||||
const testSending = ref(false);
|
||||
const testResult = ref<any>(null);
|
||||
const loadingTemplates = ref(false);
|
||||
const wechatTemplates = ref<any>(null);
|
||||
const testMsgForm = reactive({
|
||||
templateType: 'order_status',
|
||||
targetUserId: 1001,
|
||||
@@ -1346,6 +1383,37 @@ const testMsgForm = reactive({
|
||||
previewDetail: '您的同城生活陪伴订单已开始服务,请出示4位核销码完成现场确认。'
|
||||
});
|
||||
|
||||
// 拉取当前小程序真实存在的订阅消息模板,用于核对后台填写的模板ID是否属于该 appId
|
||||
const fetchWechatTemplates = async () => {
|
||||
loadingTemplates.value = true;
|
||||
try {
|
||||
const res: any = await axios.get('/api/admin/message/templates');
|
||||
const data = res?.data?.data || res?.data || res;
|
||||
wechatTemplates.value = data;
|
||||
if (data?.errcode && data.errcode !== 0) {
|
||||
Message.error(`微信返回 [${data.errcode}] ${data.errmsg}`);
|
||||
} else {
|
||||
Message.success(`已拉取到 ${data?.count ?? 0} 个模板(appId: ${data?.appIdMasked || '-'})`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
Message.error(err.response?.data?.message || err.message || '拉取模板列表失败');
|
||||
} finally {
|
||||
loadingTemplates.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 后台填写的4个模板ID中,哪些不在小程序模板列表里(即为 40037 的元凶)
|
||||
const mismatchedTemplateIds = computed(() => {
|
||||
const t = wechatTemplates.value;
|
||||
if (!t || !Array.isArray(t.templates)) return [];
|
||||
const existing = new Set(t.templates.map((x: any) => String(x.templateId)));
|
||||
const cfg = t.configured || {};
|
||||
return Object.entries(cfg)
|
||||
.filter(([, id]) => id && !existing.has(String(id)))
|
||||
.map(([key, id]) => `${key}: ${id}`);
|
||||
});
|
||||
|
||||
|
||||
const saveMessageTemplateConfig = () => {
|
||||
saveConfig('message_template_rules', messageTemplateForm);
|
||||
};
|
||||
|
||||
@@ -134,7 +134,8 @@ MYSQL_DATABASE=peiwan
|
||||
2. `总开关 未开启` → 后台打开"开启微信通知"总开关。
|
||||
3. `目标用户 openid 缺失` → 该用户必须通过**微信登录**过小程序(REST 走 `/api/auth/wechat-login` 换 openid)。
|
||||
4. `appId/appSecret 缺失` → 在微信支付/小程序凭证配置里填写真实 `appId` 与 32 位 `appSecret`。
|
||||
5. 以上都 ✅ 但仍失败 → 看后台提示的**微信返回码**:`43101` 用户未授权(让用户在小程序内重新点「平台消息与通知」授权)、`47003` 模板字段名/长度与模板不一致、`40037` 模板ID无效。
|
||||
5. 以上都 ✅ 但仍失败 → 看后台提示的**微信返回码**:`43101` 用户未授权(让用户在小程序内重新点「平台消息与通知」授权)、`47003` 模板字段名/长度与模板不一致、`40037` 模板ID无效(**最常见:该模板ID不属于当前 appId**)。
|
||||
- `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。
|
||||
6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。
|
||||
|
||||
## 八、内容安全(可选)
|
||||
|
||||
@@ -3759,6 +3759,33 @@ app.delete('/api/admin/posts/comments/:commentId', async (req: Request, res: Res
|
||||
}
|
||||
});
|
||||
|
||||
// 微信 GET 接口(返回 JSON)
|
||||
function wechatHttpGetJson(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
https
|
||||
.get(url, (resp) => {
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
})
|
||||
.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/** 脱敏展示 appId(用于自检,避免完整明文) */
|
||||
function maskTail(s: string): string {
|
||||
const v = String(s || '');
|
||||
return v ? `${v.slice(0, 6)}****${v.slice(-4)}` : '';
|
||||
}
|
||||
|
||||
// 微信订阅消息错误码中文化,便于后台定位真实原因
|
||||
function describeWechatSubscribeError(r: any): string {
|
||||
const code = Number(r && r.errcode) || 0;
|
||||
@@ -3810,6 +3837,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
|
||||
hasOpenid: Boolean(openid),
|
||||
hasAppId: Boolean(cfg.appId),
|
||||
hasAppSecret: Boolean(cfg.appSecret),
|
||||
appIdMasked: maskTail(cfg.appId),
|
||||
};
|
||||
|
||||
if (templateId && openid && cfg.appId && cfg.appSecret) {
|
||||
@@ -3885,6 +3913,48 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
// 管理端:拉取当前小程序已存在的「订阅消息」模板列表
|
||||
// 用于核对后台填写的模板ID是否属于当前 appId(40037 invalid template_id 的根因排查)
|
||||
app.get('/api/admin/message/templates', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const cfg = await resolveWechatPayRuntimeConfig();
|
||||
if (!cfg.appId || !cfg.appSecret) {
|
||||
return res.json({ code: 40000, message: '小程序 appId/appSecret 未配置' });
|
||||
}
|
||||
const accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret);
|
||||
const data = await wechatHttpGetJson(
|
||||
`https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate?access_token=${encodeURIComponent(accessToken)}`
|
||||
);
|
||||
const list = data && Array.isArray(data.data) ? data.data : [];
|
||||
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
||||
const rules = (record && record.valueJson) || {};
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
appIdMasked: maskTail(cfg.appId),
|
||||
errcode: Number(data && data.errcode) || 0,
|
||||
errmsg: (data && data.errmsg) || 'ok',
|
||||
count: list.length,
|
||||
templates: list.map((t: any) => ({
|
||||
templateId: t.priTmplId,
|
||||
title: t.title,
|
||||
type: t.type,
|
||||
content: t.content,
|
||||
example: t.example,
|
||||
})),
|
||||
configured: {
|
||||
orderStatus: String(rules.orderStatusTemplateId || ''),
|
||||
activityJoin: String(rules.activityJoinTemplateId || ''),
|
||||
withdrawSuccess: String(rules.withdrawSuccessTemplateId || ''),
|
||||
verifyCode: String(rules.verifyCodeTemplateId || ''),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ code: 40000, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/posts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { content, userId } = req.body || {};
|
||||
|
||||
@@ -34,6 +34,14 @@ describe('第七十批订阅消息原因可见与真实授权', () => {
|
||||
expect(adminCode).toContain("data?.mode === 'SIMULATED'");
|
||||
expect(adminCode).toContain('未真实下发');
|
||||
expect(adminCode).toContain('diagnostics.hasTemplateId');
|
||||
expect(adminCode).toContain('fetchWechatTemplates');
|
||||
expect(adminCode).toContain('mismatchedTemplateIds');
|
||||
});
|
||||
|
||||
it('提供拉取小程序真实模板列表的诊断接口', () => {
|
||||
expect(indexCode).toContain("app.get('/api/admin/message/templates'");
|
||||
expect(indexCode).toContain('wxaapi/newtmpl/gettemplate');
|
||||
expect(indexCode).toContain('appIdMasked');
|
||||
});
|
||||
|
||||
it('小程序真正调用订阅授权并过滤占位符', () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户