diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index eb3fb87..e513a84 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -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**:打款被拒并提示"请其重新微信登录授权"。 diff --git a/server/src/index.ts b/server/src/index.ts index 2e0c3a5..876891f 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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, diff --git a/server/tests/secure-mode-gates.spec.ts b/server/tests/secure-mode-gates.spec.ts index 08b4f69..31cdaf4 100644 --- a/server/tests/secure-mode-gates.spec.ts +++ b/server/tests/secure-mode-gates.spec.ts @@ -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)'); + }); });