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 次删除

查看文件

@@ -42,7 +42,7 @@ axios.interceptors.response.use(
// if the custom code is not 20000, it is judged as an error. // if the custom code is not 20000, it is judged as an error.
if (res.code !== 20000) { if (res.code !== 20000) {
Message.error({ Message.error({
content: res.msg || 'Error', content: res.message || res.msg || 'Error',
duration: 5 * 1000, duration: 5 * 1000,
}); });
// 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired; // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
@@ -63,7 +63,7 @@ axios.interceptors.response.use(
}, },
}); });
} }
return Promise.reject(new Error(res.msg || 'Error')); return Promise.reject(new Error(res.message || res.msg || 'Error'));
} }
return res; return res;
}, },

查看文件

@@ -697,8 +697,23 @@
</div> </div>
<div v-if="testResult" style="margin-top: 16px;"> <div v-if="testResult" style="margin-top: 16px;">
<a-alert type="success" :title="`测试推送发送成功 [消息ID: ${testResult.wechatMsgId}]`"> <a-alert
推送时间: {{ testResult.sendTime }} | 目标UID: {{ testResult.targetUserId }} | 状态: {{ testResult.status }} :type="testResult.mode === 'REAL' ? 'success' : (testResult.mode === 'SIMULATED' ? 'warning' : 'error')"
:title="testResult.mode === 'REAL'
? `微信订阅消息已真实下发 [消息ID: ${testResult.wechatMsgId}]`
: (testResult.mode === 'SIMULATED'
? `未真实下发(模拟返回):${testResult.reason}`
: `下发失败:${testResult.reason || '未知错误'}`)"
>
<div>推送时间: {{ testResult.sendTime }} | 目标UID: {{ testResult.targetUserId }} | 状态: {{ testResult.status || testResult.mode }}</div>
<div v-if="testResult.diagnostics">
配置自检:模板ID {{ testResult.diagnostics.hasTemplateId ? '已配置' : '缺失' }},
目标用户 openid {{ testResult.diagnostics.hasOpenid ? '已具备' : '缺失' }},
appId {{ testResult.diagnostics.hasAppId ? '已配置' : '缺失' }},
appSecret {{ testResult.diagnostics.hasAppSecret ? '已配置' : '缺失' }},
总开关 {{ testResult.diagnostics.enablePush ? '已开启' : '未开启' }}
</div>
<div v-if="testResult.result">微信返回:[{{ testResult.result.errcode }}] {{ testResult.result.errmsg }}</div>
</a-alert> </a-alert>
</div> </div>
</a-form> </a-form>
@@ -1460,8 +1475,15 @@ const sendTestMessage = async () => {
const res: any = await axios.post('/api/admin/message/test-send', payload); const res: any = await axios.post('/api/admin/message/test-send', payload);
const data = res?.data?.data || res?.data || res; const data = res?.data?.data || res?.data || res;
testResult.value = data; testResult.value = data;
Message.success('测试服务通知推送成功'); if (data?.mode === 'REAL') {
Message.success('微信订阅消息已真实下发');
} else if (data?.mode === 'SIMULATED') {
Message.warning(`未真实下发:${data?.reason || '配置未就绪'}`);
} else {
Message.error(data?.reason || '推送失败');
}
} catch (err: any) { } catch (err: any) {
// 真实下发失败时后端返回 code 40000,此处展示后端给出的中文原因
Message.error(err.response?.data?.message || err.message || '测试推送异常'); Message.error(err.response?.data?.message || err.message || '测试推送异常');
} finally { } finally {
testSending.value = false; testSending.value = false;

查看文件

@@ -125,10 +125,17 @@ MYSQL_DATABASE=peiwan
接入说明: 接入说明:
- **模板创建**:在微信公众平台「订阅消息」创建 4 个模板,把真实模板ID填入后台。各模板的字段(如 `phrase1`/`thing2`/`time3`/`character_string1`/`amount1`)**需与代码下发时的 data 键一致**(不一致会返回 47003 等错误,日志可见)。若你的模板字段名不同,把字段占位符发我,我按模板对齐 `data` 键。 - **模板创建**:在微信公众平台「订阅消息」创建 4 个模板,把真实模板ID填入后台。各模板的字段(如 `phrase1`/`thing2`/`time3`/`character_string1`/`amount1`)**需与代码下发时的 data 键一致**(不一致会返回 47003 等错误,日志可见)。若你的模板字段名不同,把字段占位符发我,我按模板对齐 `data` 键。
- **触发点**:① 订单进入"履约中"→ 给买家下发订单状态 + 4位核销码通知;② 组局报名成功 → 给报名者下发通知;③ 提现"打款成功"(受理/回调确认到账)→ 给提现大神下达到账通知。 - **触发点**:① 订单进入"履约中"→ 给买家下发订单状态 + 4位核销码通知;② 组局报名成功 → 给报名者下发通知;③ 提现"打款成功"(受理/回调确认到账)→ 给提现大神下达到账通知。
- **就绪条件**:`enablePush=1` + 对应模板ID已填 + 小程序 `appId/appSecret` 已配置 + 目标用户有 `openid`。任一缺失则静默跳过(不阻塞业务)。 - **就绪条件**:`enablePush=1` + 对应模板ID已填(**不能是 `tmpl_xxx` 占位符**)+ 小程序 `appId/appSecret` 已配置 + 目标用户有 `openid` + 用户已授权该模板订阅。任一缺失即不下发。
- **后台测试推送**:管理端"测试推送"在就绪时**真实下发**;未就绪时返回模拟结果并说明原因(mode=REAL/SIMULATED)。目标用户填其 UID,且该用户须有 openid。 - **后台测试推送**:管理端"测试推送"在就绪时**真实下发**(`mode=REAL`);未就绪返回 `mode=SIMULATED` 并给出**明确原因**与**配置自检**(模板ID/openid/appId/appSecret/总开关),不再显示"发送成功"。
- **小程序授权**:设置页开启通知时用**真实模板ID** 调 `wx.requestSubscribeMessage` 收集授权。订阅为**一次性**,需在关键动作前再次请求(后续可在下单/报名/提现前补请求)。 - **小程序授权**:「平台消息与通知」点击时会用**真实模板ID** 调 `wx.requestSubscribeMessage` 收集授权,并在页面标签显示实际状态。订阅为**一次性**(每条授权仅可下发 1 条),已在下单/报名/提现前再次请求。
- **启动自检**:不额外打印;如需可在后台测试推送处验证。
### 收不到推送时的排查顺序(对照后台测试推送的"配置自检")
1. `模板ID 缺失` → 后台把该类型模板的**真实模板ID**填入并保存(占位符 `tmpl_xxx` 视为未配置)。
2. `总开关 未开启` → 后台打开"开启微信通知"总开关。
3. `目标用户 openid 缺失` → 该用户必须通过**微信登录**过小程序(REST 走 `/api/auth/wechat-login` 换 openid)。
4. `appId/appSecret 缺失` → 在微信支付/小程序凭证配置里填写真实 `appId` 与 32 位 `appSecret`。
5. 以上都 ✅ 但仍失败 → 看后台提示的**微信返回码**:`43101` 用户未授权(让用户在小程序内重新点「平台消息与通知」授权)、`47003` 模板字段名/长度与模板不一致、`40037` 模板ID无效。
6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。
## 八、内容安全(可选) ## 八、内容安全(可选)

查看文件

@@ -49,3 +49,42 @@ export function requestSubscribeFor(scene) {
// 授权失败不影响主流程 // 授权失败不影响主流程
} }
} }
/** 全部已配置的真实模板ID(过滤空值与 tmpl_ 占位符) */
function allTmplIds() {
if (!templates || templates.enablePush === false) return [];
return ['orderStatusTemplateId', 'activityJoinTemplateId', 'withdrawSuccessTemplateId', 'verifyCodeTemplateId']
.map((k) => templates[k])
.filter((x) => x && !String(x).startsWith('tmpl_'));
}
/**
* 主动请求全部订阅授权(用于「平台消息与通知」入口)。
* 注意:wx.requestSubscribeMessage 必须在用户点击手势中同步调用;本函数不 await 任何网络请求。
* 返回每项授权结果,便于界面给出真实提示(订阅消息为一次性订阅,每条授权仅可下发 1 条)。
*/
export function requestSubscribeAll() {
return new Promise((resolve) => {
try {
if (typeof uni.requestSubscribeMessage !== 'function') {
return resolve({ supported: false, tmplIds: [], accepted: [], rejected: [] });
}
const tmplIds = allTmplIds();
if (tmplIds.length === 0) {
return resolve({ supported: true, tmplIds: [], accepted: [], rejected: [], noTemplates: true });
}
uni.requestSubscribeMessage({
tmplIds,
success: (res) => {
const accepted = tmplIds.filter((id) => res && res[id] === 'accept');
const rejected = tmplIds.filter((id) => !res || res[id] !== 'accept');
resolve({ supported: true, tmplIds, accepted, rejected, raw: res });
},
fail: (err) => resolve({ supported: true, tmplIds, accepted: [], rejected: tmplIds, error: err }),
complete: () => {}
});
} catch (e) {
resolve({ supported: true, tmplIds: [], accepted: [], rejected: [], error: e });
}
});
}

查看文件

@@ -152,7 +152,7 @@
<view class="list-row btn-active" @click="openNotice"> <view class="list-row btn-active" @click="openNotice">
<image class="row-svg" :src="ICONS.bell" mode="aspectFit" /> <image class="row-svg" :src="ICONS.bell" mode="aspectFit" />
<text class="row-label">平台消息与通知</text> <text class="row-label">平台消息与通知</text>
<text class="row-sub-tag">{{ noticeSubscribe ? '提醒开启' : '已静音' }}</text> <text class="row-sub-tag">{{ noticeSubscribe ? '已开启提醒' : '点击开启' }}</text>
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" /> <image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
</view> </view>
<view class="list-row btn-active" @click="openMyReportsModal"> <view class="list-row btn-active" @click="openMyReportsModal">
@@ -352,6 +352,7 @@ import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '
import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js'; import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
import { getNavBarInfo } from '@/common/nav.js'; import { getNavBarInfo } from '@/common/nav.js';
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js'; import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js';
import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscribe.js';
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null; const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null;
@@ -382,7 +383,7 @@ const openMyReportsModal = () => {
// 系统与隐私设置 // 系统与隐私设置
const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1'); const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1');
const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') !== '0'); const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') === '1');
const cacheSizeText = ref('1.25 MB'); const cacheSizeText = ref('1.25 MB');
// 分销推广管理 // 分销推广管理
@@ -628,6 +629,8 @@ onMounted(() => {
loadUserProfile(); loadUserProfile();
loadPlatformConfigs(); loadPlatformConfigs();
loadMyReports(); loadMyReports();
// 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势)
preloadSubscribeTemplates();
}); });
onShow(() => { onShow(() => {
@@ -639,7 +642,7 @@ onShow(() => {
loadUserProfile(); loadUserProfile();
loadPlatformConfigs(); loadPlatformConfigs();
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') !== '0'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
if (isLoggedIn.value) { if (isLoggedIn.value) {
loadFavorites(); loadFavorites();
loadMyReports(); loadMyReports();
@@ -1163,6 +1166,9 @@ const contactService = () => {
}; };
const openNotice = () => { const openNotice = () => {
// 同步触发微信订阅授权(必须保留点击手势,不能先 await 网络);再展示平台须知。
const subResult = typeof uni.requestSubscribeMessage === 'function' ? requestSubscribeAll() : null;
const notice = platformConfigs.value.safety_notice || {}; const notice = platformConfigs.value.safety_notice || {};
const title = notice.title || '平台官方公告与指引'; const title = notice.title || '平台官方公告与指引';
let content = '【安全履约须知】\n1. 为保障搭子与陪伴师人身与财产安全,线下约局须在合规公开商业街区进行。\n2. 现场碰面后请出示 4 位数字核销码进行签到确认。\n3. 平台提供 7x24 小时争议仲裁与资金托管保障服务。'; let content = '【安全履约须知】\n1. 为保障搭子与陪伴师人身与财产安全,线下约局须在合规公开商业街区进行。\n2. 现场碰面后请出示 4 位数字核销码进行签到确认。\n3. 平台提供 7x24 小时争议仲裁与资金托管保障服务。';
@@ -1177,6 +1183,20 @@ const openNotice = () => {
confirmText: '我知道了', confirmText: '我知道了',
confirmColor: '#6366F1' confirmColor: '#6366F1'
}); });
if (subResult) {
subResult.then((r) => {
if (!r || !r.supported) return;
if (r.noTemplates || !r.tmplIds || r.tmplIds.length === 0) {
uni.showToast({ title: '后台未配置订阅消息模板', icon: 'none' });
return;
}
const accepted = (r.accepted || []).length > 0;
noticeSubscribe.value = accepted;
uni.setStorageSync('notice_subscribe', accepted ? '1' : '0');
uni.showToast({ title: accepted ? '已开启消息提醒' : '未获得提醒授权', icon: 'none' });
});
}
}; };
const openFaq = () => { const openFaq = () => {

查看文件

@@ -612,14 +612,19 @@ 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去请求授权
const clean = (v: any) => {
const s = String(v == null ? '' : v).trim();
return /^tmpl_/i.test(s) ? '' : s;
};
res.json({ res.json({
code: 20000, code: 20000,
data: { data: {
enablePush: Number(rules.enablePush) === 1, enablePush: Number(rules.enablePush) === 1,
orderStatusTemplateId: String(rules.orderStatusTemplateId || ''), orderStatusTemplateId: clean(rules.orderStatusTemplateId),
activityJoinTemplateId: String(rules.activityJoinTemplateId || ''), activityJoinTemplateId: clean(rules.activityJoinTemplateId),
withdrawSuccessTemplateId: String(rules.withdrawSuccessTemplateId || ''), withdrawSuccessTemplateId: clean(rules.withdrawSuccessTemplateId),
verifyCodeTemplateId: String(rules.verifyCodeTemplateId || '') verifyCodeTemplateId: clean(rules.verifyCodeTemplateId)
} }
}); });
} catch (err: any) { } 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) => { app.post('/api/admin/message/test-send', async (req: Request, res: Response) => {
try { try {
const { templateType, targetUserId, previewData, data, page } = req.body || {}; 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', withdrawSuccess: 'withdrawSuccessTemplateId',
verifyCode: 'verifyCodeTemplateId', 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 cfg = await resolveWechatPayRuntimeConfig();
const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(targetUserId) || 0]); 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 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) { 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) // 与所选模板字段对齐的示例数据(字段名必须与模板一致,否则 47003)
const sampleData: Record<string, Record<string, string>> = { const sampleData: Record<string, Record<string, string>> = {
orderStatus: { character_string1: 'ORD20261017001', phrase2: '已开始服务', thing3: '同城陪伴服务', thing4: '核销码8392,请出示' }, 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; const ok = !result || !result.errcode || result.errcode === 0;
return res.json({ return res.json({
code: ok ? 20000 : 40000, code: ok ? 20000 : 40000,
data: { mode: 'REAL', templateType: tKey, templateId, targetUserId, result }, data: {
message: ok ? '微信订阅消息已真实下发' : `微信订阅消息下发失败: [${result.errcode}] ${result.errmsg}` 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 const typeLabels: Record<string, string> = {
? '未配置该类型模板ID' orderStatus: '订单履约状态变动',
: (!openid ? '目标用户无 openid' : '小程序 appId/appSecret 未配置'); activityJoin: '组局搭子报名与成局',
withdrawSuccess: '大神佣金提现打款到账',
verifyCode: '现场4位核销码签到提醒',
};
const reason = !enablePush
? '订阅消息总开关未开启'
: (!templateId
? `未配置「${typeLabels[tKey] || tKey}」模板ID${rawTemplateId ? `(当前值为占位符 ${rawTemplateId})` : ''}`
: (!openid
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
: '小程序 appId/appSecret 未配置'));
const result = { const result = {
templateType: tKey, templateType: tKey,
targetUserId: targetUserId || 1001, targetUserId: targetUserId || 1001,
@@ -3816,7 +3879,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
wechatMsgId: `wx_msg_${Date.now()}`, wechatMsgId: `wx_msg_${Date.now()}`,
previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' } 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) { } catch (err: any) {
res.status(400).json({ code: 40000, message: err.message }); res.status(400).json({ code: 40000, message: err.message });
} }

查看文件

@@ -44,11 +44,15 @@ describe('第五十七批微信订阅消息闸门', () => {
expect(indexCode).toContain('time3: toCnTimeOrNow(act?.startTime)'); expect(indexCode).toContain('time3: toCnTimeOrNow(act?.startTime)');
}); });
it('管理端测试推送在就绪时真实下发', () => { it('管理端测试推送在就绪时真实下发,未就绪时明确标注模拟原因', () => {
const start = indexCode.indexOf("app.post('/api/admin/message/test-send'"); const start = indexCode.indexOf("app.post('/api/admin/message/test-send'");
const block = indexCode.slice(start, start + 3400); const nextApp = indexCode.indexOf('\napp.', start + 10);
expect(block).toContain("mode: 'REAL'"); const block = indexCode.slice(start, nextApp > 0 ? nextApp : start + 8000);
expect(block).toContain("mode: ok ? 'REAL' : 'REAL_FAILED'");
expect(block).toContain("mode: 'SIMULATED'"); expect(block).toContain("mode: 'SIMULATED'");
// 未下发时必须返回明确原因与配置自检,避免被误解为"已发送成功"
expect(block).toContain('diagnostics');
expect(block).toContain('reason');
}); });
it('小程序用真实模板ID 请求订阅授权', () => { it('小程序用真实模板ID 请求订阅授权', () => {

查看文件

@@ -0,0 +1,45 @@
// 已读取AGENTS.md
import * as fs from 'fs';
import * as path from 'path';
/**
* 第七十批回归闸门:订阅消息「未下发原因可见」+ 小程序真实授权。
* 背景:后台曾把 SIMULATED(未调微信)也显示为"发送成功",且小程序从未调用 requestSubscribeMessage。
*/
describe('第七十批订阅消息原因可见与真实授权', () => {
const root = path.resolve(__dirname, '../../');
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
const adminCode = fs.readFileSync(
path.join(root, 'admin/arco-design-pro-vite/src/views/safety/platform-config/index.vue'),
'utf-8'
);
const subscribeCode = fs.readFileSync(path.join(root, 'pwuniapp/common/subscribe.js'), 'utf-8');
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('diagnostics');
expect(indexCode).toContain('没有 wechat_openid');
expect(indexCode).toContain('订阅消息总开关未开启');
});
it('订阅模板接口下发给小程序前过滤占位符', () => {
expect(indexCode).toContain('const clean = (v: any)');
expect(indexCode).toContain('orderStatusTemplateId: clean(');
expect(indexCode).toContain('verifyCodeTemplateId: clean(');
});
it('后台不再把 SIMULATED 显示为发送成功', () => {
expect(adminCode).not.toContain("Message.success('测试服务通知推送成功')");
expect(adminCode).toContain("data?.mode === 'SIMULATED'");
expect(adminCode).toContain('未真实下发');
expect(adminCode).toContain('diagnostics.hasTemplateId');
});
it('小程序真正调用订阅授权并过滤占位符', () => {
expect(subscribeCode).toContain('requestSubscribeAll');
expect(subscribeCode).toContain("startsWith('tmpl_')");
expect(userCode).toContain('requestSubscribeAll');
expect(userCode).toContain('uni.requestSubscribeMessage');
});
});