feat(security): 三端闭环加固 + 微信提现打款/订阅消息落地
后端: 身份与归属鉴权收口; 资金安全(结算闸门/支付防篡改/报名费真实扣款/赠金/结算唯一入账/提现打款); 数据泄露与隐私; 登录与密钥安全(默认密钥闸门/登录限流); 可观测性; 启动自检. 微信: 商家转账到零钱提现打款闭环(含回调/查单/失败退回佣金); 订阅消息真实下发闭环与后台模板字段对齐. 后台: 财务/微信支付配置读取失败锁定; 看板/待办/走势去假数据; 打款与测试推送文案修正. 小程序: 请求层错误透传与登录过期提示; 多页失败反馈与乐观回滚; 帮助中心FAQ页面化. 测试: 新增 40+ 回归护栏与真实 MySQL 冒烟(75 套件/599 用例全绿).
这个提交包含在:
@@ -0,0 +1,66 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 微信小程序「订阅消息」(服务通知) 下发客户端。
|
||||
* 说明:订阅消息为一次性订阅,用户每次授权仅可下发一条;调用需有效 access_token。
|
||||
* 该模块为纯函数/无状态,便于离线单元校验与复用。
|
||||
*/
|
||||
import https from 'https';
|
||||
|
||||
export interface SubscribeMessagePayload {
|
||||
touser: string;
|
||||
template_id: string;
|
||||
page?: string;
|
||||
data: Record<string, { value: string }>;
|
||||
miniprogram_state?: 'developer' | 'trial' | 'formal';
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装微信订阅消息 data 字段:入参为 { 字段名: 值 },输出 { 字段名: { value } }。
|
||||
* 微信各字段有长度限制(thing 类通常 ≤20 字符),此处统一截断到 20,避免 47003 参数错误。
|
||||
*/
|
||||
export function buildTemplateData(input: Record<string, string | number | undefined | null>): Record<string, { value: string }> {
|
||||
const out: Record<string, { value: string }> = {};
|
||||
for (const [key, val] of Object.entries(input || {})) {
|
||||
if (val === undefined || val === null) continue;
|
||||
out[key] = { value: String(val).slice(0, 20) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 调用微信 cgi-bin/message/subscribe/send 真实下发 */
|
||||
export function sendSubscribeMessageHttp(
|
||||
accessToken: string,
|
||||
payload: SubscribeMessagePayload
|
||||
): Promise<{ errcode: number; errmsg: string }> {
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(accessToken)}`;
|
||||
const body = JSON.stringify(payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(resp) => {
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -168,6 +168,99 @@ export async function queryOrderByOutTradeNo(
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA-OAEP(SHA-1) 加密敏感信息。
|
||||
* 微信支付「商家转账到零钱」在部分转账场景下要求收款人真实姓名(user_name)以平台公钥加密后提交。
|
||||
*/
|
||||
export function encryptSensitiveInfo(platformPublicKey: string, plain: string): string {
|
||||
const key = resolvePlatformKey(platformPublicKey);
|
||||
if (!key) {
|
||||
throw new Error('缺少有效的微信支付平台公钥,无法加密敏感信息(收款人姓名)');
|
||||
}
|
||||
const encrypted = crypto.publicEncrypt(
|
||||
{ key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
|
||||
Buffer.from(String(plain), 'utf8')
|
||||
);
|
||||
return encrypted.toString('base64');
|
||||
}
|
||||
|
||||
export interface TransferParams {
|
||||
/** 商户侧转账单号(全局唯一,建议直接用提现单号,天然幂等) */
|
||||
outBillNo: string;
|
||||
openid: string;
|
||||
amountCents: number;
|
||||
/** 转账场景ID(商户平台「商家转账到零钱」配置的转账场景) */
|
||||
sceneId: string;
|
||||
remark: string;
|
||||
notifyUrl?: string;
|
||||
/** 收款人真实姓名(明文,调用内部会按平台公钥加密) */
|
||||
userName?: string;
|
||||
/** 部分转账场景必填的场景信息(如岗位类型/报酬说明) */
|
||||
sceneReportInfos?: Array<{ info_type: string; info_content: string }>;
|
||||
}
|
||||
|
||||
/** 商家转账到零钱(V3):向指定 openid 发起转账。成功受理返回 { out_bill_no, state, ... } */
|
||||
export async function transferToWallet(cfg: WechatPayConfig, p: TransferParams): Promise<any> {
|
||||
const urlPath = '/v3/fund-app/mch-transfer/transfer-bills';
|
||||
const bodyObj: any = {
|
||||
appid: cfg.appId,
|
||||
out_bill_no: p.outBillNo,
|
||||
transfer_scene_id: p.sceneId,
|
||||
openid: p.openid,
|
||||
transfer_amount: Math.round(Number(p.amountCents) || 0),
|
||||
transfer_remark: String(p.remark || '').slice(0, 32),
|
||||
};
|
||||
if (p.notifyUrl) bodyObj.notify_url = p.notifyUrl;
|
||||
if (p.userName && cfg.platformPublicKey) {
|
||||
bodyObj.user_name = encryptSensitiveInfo(cfg.platformPublicKey, p.userName);
|
||||
}
|
||||
if (Array.isArray(p.sceneReportInfos) && p.sceneReportInfos.length > 0) {
|
||||
bodyObj.transfer_scene_report_infos = p.sceneReportInfos;
|
||||
}
|
||||
const body = JSON.stringify(bodyObj);
|
||||
const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'POST',
|
||||
urlPath,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
body,
|
||||
});
|
||||
let parsed: any = {};
|
||||
try {
|
||||
parsed = JSON.parse(resp.body || '{}');
|
||||
} catch (_) {
|
||||
parsed = {};
|
||||
}
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信商家转账失败 [${resp.statusCode}]: ${parsed.message || resp.body}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 查询商家转账单结果(按商户转账单号)。state: ACCEPTED/PROCESSING/SUCCESS/FAIL/CANCELING/CANCELLED */
|
||||
export async function queryTransferByOutBillNo(cfg: WechatPayConfig, outBillNo: string): Promise<any> {
|
||||
const urlPath = `/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`;
|
||||
const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'GET',
|
||||
urlPath,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
});
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信商家转账查单失败 [${resp.statusCode}]: ${resp.body}`);
|
||||
}
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */
|
||||
export function verifyNotifySignature(params: {
|
||||
cfg: WechatPayConfig;
|
||||
|
||||
在新工单中引用
屏蔽一个用户