feat(pay): 商家转账场景 1005 打通提现打款
- 转账场景报备信息按佣金报酬要求固定传「岗位类型/报酬说明」两段,否则微信参数校验拒绝 - 处理 WAIT_USER_CONFIRM(用户确认模式):推送「提现待你确认收款」,提示 24 小时内确认 - 提现申请增加单笔上限校验(finance_rules.maxWithdrawAmount,默认 200,对齐微信商家转账额度) - /api/posts 身份优先取服务端会话 token - 文档补充佣金报酬场景与用户确认模式说明(109 套件/722 用例全绿)
这个提交包含在:
@@ -111,13 +111,18 @@ MYSQL_DATABASE=peiwan
|
||||
> 1. 商户号已开通**"商家转账到零钱"**产品(微信商户平台申请,通常需 1-2 个工作日);
|
||||
> 2. 在商户平台配置**转账场景ID**(如"佣金报酬"场景);
|
||||
> 3. 收款大神需有 **openid**(通过微信登录授权产生,`users.wechat_openid`)。
|
||||
>
|
||||
> 新版「商家转账」为**用户确认模式**:转账受理后状态为 `WAIT_USER_CONFIRM`,收款人需在微信「服务通知」中确认收款(24 小时内有效),确认后才由回调置为「已打款」。
|
||||
|
||||
后台配置(财务与分销配置 → 微信支付商户号与 APIv3 证书配置)新增:
|
||||
- **转账场景ID (`transfer_scene_id`)**:必填才视为"打款就绪";
|
||||
- **转账场景ID (`transfer_scene_id`)**:必填才视为"打款就绪",佣金报酬场景固定为 `1005`;
|
||||
- **转账场景报备信息 (`transfer_scene_report_infos`)**:佣金报酬场景**必须固定传两条**——`岗位类型`(如"陪伴师")与`报酬说明`(如"平台服务佣金"),否则微信会以参数错误拒绝;
|
||||
- **转账备注 (`transfer_remark`)**:默认"佣金提现";
|
||||
- **收款人姓名加密**:部分转账场景要求提交收款人姓名,启用后按平台公钥加密提交(需已配置平台公钥);
|
||||
- **收款人姓名加密**:单笔 ≥ 2000 元时微信要求提交收款人姓名,启用后按平台公钥加密提交(需已配置平台公钥);
|
||||
- **转账结果回调地址 (`transfer_notify_url`)**:默认 `https://peiww.gynhc.com/api/pay/wechat/transfer-notify`。
|
||||
|
||||
单笔限额:以商户平台「商家转账 → 转账额度」为准(当前为 **200 元/笔**)。后台 `finance_rules.maxWithdrawAmount` 控制 App 端单笔上限(默认 200),超出会在申请时直接提示分多笔提现。
|
||||
|
||||
行为:
|
||||
- 支付模式为 **LIVE 且"打款就绪"**:审核通过即调用微信商家转账;受理后工单置"处理中",最终由**回调/查单**确认 → 成功置"已打款"、失败置"打款失败"并**自动退回预扣佣金**。
|
||||
- 若该大神**缺少 openid**:打款被拒并提示"请其重新微信登录授权"。
|
||||
|
||||
@@ -3412,6 +3412,20 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
||||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' });
|
||||
}
|
||||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state);
|
||||
if (state === 'WAIT_USER_CONFIRM') {
|
||||
pushUserNotification(
|
||||
Number(wd.companion_id) || 0,
|
||||
'WITHDRAW',
|
||||
'提现待你确认收款',
|
||||
`提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已发起微信转账,请在微信「服务通知」中确认收款(24 小时内有效,逾期自动失效)。`,
|
||||
'/pages/user/withdraw-records'
|
||||
);
|
||||
return res.json({
|
||||
code: 20000,
|
||||
data: { ...r, transfer: bill },
|
||||
message: '已发起微信转账,待收款人在微信「服务通知」中确认收款(24 小时内有效)'
|
||||
});
|
||||
}
|
||||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' });
|
||||
}
|
||||
|
||||
@@ -3898,6 +3912,18 @@ app.post('/api/users/:id/withdraw', rateLimit({ name: 'withdraw', windowMs: 60 *
|
||||
}
|
||||
await assertUserNotBanned(userId, '申请提现');
|
||||
|
||||
// 单笔上限:受微信商家转账额度约束(商户平台可调),默认 200 元,避免审核时被微信拒绝
|
||||
const financeCfg = await repo.getPlatformConfigByKey('finance_rules');
|
||||
const financeRules = (financeCfg && financeCfg.valueJson) || {};
|
||||
const maxWithdraw = Number(financeRules.maxWithdrawAmount) || 200;
|
||||
const requestAmount = Number(req.body?.amount);
|
||||
if (Number.isFinite(requestAmount) && requestAmount > maxWithdraw) {
|
||||
return res.status(400).json({
|
||||
code: 40000,
|
||||
message: `单笔提现不能超过 ¥${maxWithdraw}(微信商家转账单笔限额),请分多笔提现或联系平台调整额度`
|
||||
});
|
||||
}
|
||||
|
||||
const { amount, channel, account, realName, bankName } = req.body;
|
||||
const result = await repo.withdrawUserCommission(userId, Number(amount), channel, { account, realName, bankName });
|
||||
res.json({
|
||||
@@ -3943,7 +3969,9 @@ app.get('/api/users/:id/finance-records', async (req: Request, res: Response) =>
|
||||
app.get('/api/posts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const category = req.query.category ? String(req.query.category) : undefined;
|
||||
const userId = Number(req.query.userId || req.headers['x-user-id']) || 1001;
|
||||
// 身份优先取服务端会话,客户端声明仅作匿名浏览时的兼容
|
||||
const tokenUid = resolveRequestUserId(req);
|
||||
const userId = tokenUid || Number(req.query.userId || req.headers['x-user-id']) || 1001;
|
||||
const posts = await repo.getPosts(category, undefined, '正常', userId);
|
||||
res.json({
|
||||
code: 20000,
|
||||
|
||||
@@ -59,4 +59,18 @@ describe('第九十三批安全闸门解耦与支付 fail-closed', () => {
|
||||
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n let query = `?userId=${uid}`;');
|
||||
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n const payload = { userId: uid };');
|
||||
});
|
||||
|
||||
it('商家转账:待用户确认时必须提示收款人确认,并约束单笔额度', () => {
|
||||
const review = routeBlock("app.post('/api/finance/withdrawals/:id/review'", 6000);
|
||||
expect(review).toContain("state === 'WAIT_USER_CONFIRM'");
|
||||
expect(review).toContain('提现待你确认收款');
|
||||
const withdraw = routeBlock("app.post('/api/users/:id/withdraw'", 2500);
|
||||
expect(withdraw).toContain('maxWithdrawAmount');
|
||||
expect(withdraw).toContain('微信商家转账单笔限额');
|
||||
});
|
||||
|
||||
it('动态列表身份优先取服务端会话', () => {
|
||||
const posts = routeBlock("app.get('/api/posts'", 1200);
|
||||
expect(posts).toContain('resolveRequestUserId(req)');
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户