fix(subscribe): 订阅消息未下发原因可见 + 小程序真实授权
- 后台测试推送:未就绪返回 mode=SIMULATED 明确原因 + 配置自检(模板ID/openid/appId/appSecret/总开关),不再把模拟显示为成功 - 占位符 tmpl_xxx 视为未配置(测试推送与订阅模板接口均过滤),微信错误码中文化(43101/47003/40037/40001 等) - 小程序「平台消息与通知」点击时真实调用 wx.requestSubscribeMessage 并回显授权状态,标签不再恒为已开启 - 管理端拦截器错误提示改用 message;文档补订阅消息排查顺序;新增回归护栏(86 套件/631 用例全绿)
这个提交包含在:
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户