状态: Draft v1
适用范围: 超级会员、能量师(所有有分销佣金收益的用户角色)
提现方式: 微信「企业付款到零钱」(原「企业红包」API)
税务处理: 预扣代缴 + 年度汇总
用户申请提现
│
▼
后端校验(余额、身份、频率、首笔退费资格)
│
▼
创建提现记录 → 状态:QUEUED(排队中)
│
▼
前端提示:「申请已提交,预计今晚到账」
│
▼
┌─ 每日定时任务(23:30,微信日结前)────────────────────┐
│ │
│ Step 1: 拉取当日所有 QUEUED 记录 │
│ Step 2: 检查微信商户 Master 余额 │
│ Step 3: 按优先级排序: │
│ ① 首笔退费(退款通道,扣退款余额) │
│ ② 常规微信直付(≤¥800,免个税) │
│ ③ 第三方通道(>¥800,代扣个税) │
│ Step 4: 逐笔执行 │
│ ├─ 足够余额 → 调用对应通道 API → SUCCESS │
│ └─ 余额不足 → 跳过,保持 QUEUED,次日重试 │
│ │
│ Step 5: 更新微信 Master 余额缓存 │
│ Step 6: 推送通知(成功/失败) │
└─────────────────────────────────────────────────────────┘
│
▼
超过 3 天未处理 → 告警运维 → 人工介入
当前 4 条产品线 → 全部提取至统一可提现余额:
| 佣金类型 | 税率级次 | 是否可提现 |
|---|---|---|
| B端佣金(能量师推荐,固定金额) | 劳务报酬 | ✅ |
| C端佣金(40%+5%,百分比) | 劳务报酬 | ✅ |
| 超级会员佣金(30%+5%,百分比) | 劳务报酬 | ✅ |
| 人工方案佣金(平台留存后返还) | 劳务报酬 | ✅ |
| 提现退还(失败回滚) | — | 退回可提现余额 |
结论: 所有分销佣金合并为一个「可提现余额」,不做科目隔离。
| 参数 | 默认值 | 说明 |
|---|---|---|
withdraw.min_amount |
¥100 | 单次最低提现金额(分) |
withdraw.max_amount_per_time |
¥10,000 | 单次最高提现金额 |
withdraw.max_per_week |
1次 | 每人每周限提次数 |
withdraw.service_fee_rate |
0.6% | 微信企业付款手续费(平台承担) |
withdraw.week_reset_day |
周一 00:00 | 周频次重置时间 |
withdraw.min_balance_keep |
¥0 | 提现后保留的最低余额 |
withdraw.auto_approve |
true | 是否自动审核(≤¥500 免审) |
withdraw.fail_limit_per_day |
3次 | 日失败上限,超限冻结提现 24h |
withdraw.commission_expiry_days |
0(永不过期) | 佣金过期天数,0=永久有效 |
withdraw.refund_period_days |
30 | 首笔提现退费窗口期(付费后30天内) |
withdraw.third_party_threshold |
¥800 | 超过此金额使用第三方结算通道(分) |
withdraw.third_party_channel |
wechat_settle |
第三方通道类型:wechat_settle / lianlian / huifu |
withdraw.refund_reason_tpl |
推广佣金退款-{user_id}-{order_no} |
退费备注模板 |
用户提交申请
│
▼
INIT → QUEUED(等待夜间批处理)
│
▼ [定时任务 23:30 触发]
BATCH_PROCESSING(正在处理)
│
├─ 余额足够 → 通道调用
│ ├─ 退费(首笔)→ 微信退款 API → SUCCESS
│ ├─ 微信直付 → 企业付款 API → SUCCESS
│ └─ 第三方 → 渠道 API → SUCCESS
│
├─ 余额不足 → 跳过,保持 QUEUED → 次日重试
│
└─ 通道报错 → FAILED(可重新发起)
│
└─ 超过 3 天未成功 → FROZEN(告警运维)
状态说明:
| 状态 | 说明 | 可见用户 |
|---|---|---|
QUEUED |
提交成功,等待夜间批处理 | ✅ 显示「预计今晚到账」 |
BATCH_PROCESSING |
定时任务正在处理(通常 23:30-00:30) | ✅ 显示「处理中」 |
SUCCESS |
已到账 | ✅ 显示「已到账」+ 到账金额 |
FAILED |
通道失败(余额不足/网络错误) | ✅ 显示「失败」+ 原因 +「重新申请」按钮 |
REJECTED |
风控不通过 | ✅ 显示「未通过审核」+ 原因 |
FROZEN |
超过 3 天未处理,需人工介入 | ✅ 显示「请联系客服」 |
所有提现(含退费、直付、第三方)不实时调用微信 API,而是由定时任务统一处理。
触发时机: 每日 23:30(微信商户平台通常在 00:00 前完成日结,确保当日余额可 Master)
处理流程:
@Scheduled(cron = "0 30 23 * * ?") // 每日 23:30
public void nightlyWithdrawBatch() {
// 1. 拉取当日 QUEUED 记录
List<WithdrawRecord> queue = recordRepo.findByStatusOrderByCreatedAt("QUEUED");
// 2. 查询实时 Master 余额(调用微信「查询商户余额」API 或本地缓存)
long masterBalance = queryWechatMasterBalance();
long refundBalance = queryWechatRefundBalance();
// 3. 按优先级排序
queue.sort(Comparator
.comparing((r) -> !r.isEligibleRefund()) // 退费优先
.thenComparing(r -> r.getAmountFen())); // 小额优先(更容易成功)
for (WithdrawRecord record : queue) {
// 4. 判断通道
WithdrawChannel channel = resolveChannel(record, masterBalance, refundBalance);
if (channel == null) {
// 余额不足,跳过,等待下一批
record.setRetryCount(record.getRetryCount() + 1);
recordRepo.save(record);
continue;
}
// 5. 预扣税务(非退费通道)
TaxResult tax = channel.isRefund()
? TaxResult.zero() // 退费不免税
: TaxCalculator.calc(record.getAmountFen());
// 6. 扣减冻结余额
walletService.debitFrozen(record.getUserId(), record.getAmountFen());
// 7. 调用对应通道
record.setStatus("BATCH_PROCESSING");
recordRepo.save(record);
try {
switch (channel) {
case WECHAT_REFUND -> executeRefund(record, refundBalance);
case WECHAT_TRANSFER -> executeWechatTransfer(record, masterBalance, tax);
case THIRD_PARTY -> executeThirdParty(record, tax);
}
record.setStatus("SUCCESS");
record.setProcessedAt(LocalDateTime.now());
} catch (InsufficientBalanceException e) {
// 余额幻觉(并发场景下余额被其他批次扣走)
walletService.releaseFrozen(record.getUserId(), record.getAmountFen());
record.setStatus("QUEUED");
record.setRetryCount(record.getRetryCount() + 1);
} catch (Exception e) {
record.setStatus("FAILED");
record.setStatusRemark(e.getMessage());
walletService.releaseFrozen(record.getUserId(), record.getAmountFen());
}
recordRepo.save(record);
// 8. 更新本地余额缓存
masterBalance = queryWechatMasterBalance();
refundBalance = queryWechatRefundBalance();
}
// 9. 推送通知(微信模板消息)
notifyBatchResults(queue);
}
用户支付 ¥1,314 → 微信商户账户 +¥1,314
│
├─ 微信先收手续费 0.6%(约 ¥7.88)
│
└─ 微信商户账户实际可 Master 余额 ≈ ¥1,306.12
│
├─ 企业付款提现给用户 → 从此余额扣款
│
└─ 退费(退款)→ 扣「退款余额」(独立于 Master 余额)
核心影响:
| 场景 | 约束 | 系统处理 |
|---|---|---|
| 商户 Master 余额充足 | 按正常流程处理 | 无需特殊处理 |
| 商户 Master 余额不足 | 微信接口报错 NOT_ENOUGH |
前端提示「余额不足,暂无法提现」,并提供「预约提现」(余额到账后自动处理) |
| 首笔退费(退款通道) | 走微信「退款」API,扣「退款余额」;退款余额为 0 时不可用 | 系统降级为:标准微信直付 + 预扣个税,用户不再享受首笔退费资格 |
| 退款余额为 0 | 退费通道不可用 | 降级为标准提现,首笔退费资格作废 |
运维要求(平台侧):
新增 sys_config 参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
withdraw.wechat_master_balance_alert |
500000 |
Master 余额告警阈值(分,¥5,000) |
withdraw.queue_retry_minutes |
15 |
排队重试间隔(分钟) |
withdraw.queue_max_wait_minutes |
1440 |
最大排队等待(分钟,24h) |
withdraw.refund_balance_fallback |
true |
退款余额不足时是否降级为标准提现 |
withdraw.auto_topup_threshold |
100000 |
Master 余额低于此值时触发告警通知运营充值(分,¥1,000) |
根据《个人所得税法》,个人推广佣金属于 「劳务报酬所得」,适用比例税率,由支付方(平台)预扣代缴。
劳务报酬预扣率表:
| 每次收入(税后估计) | taxable income 计算 | 税率 | 速算扣除 |
|---|---|---|---|
| ≤ ¥800 | 免征 | 0% | — |
| ¥800 – ¥4,000 | (收入 – ¥800) × 20% | 20% | 0 |
| ¥4,000 – ¥25,000 | 收入 × 80% × 20% – ¥1,050 | 20% | ¥1,050 |
| ¥25,000 – ¥62,500 | 收入 × 80% × 30% – ¥4,050 | 30% | ¥4,050 |
| > ¥62,500 | 收入 × 80% × 40% – ¥7,050 | 40% | ¥7,050 |
| 模式 | 描述 | 优劣 |
|---|---|---|
| A:每次提现预扣(推荐) | 每次发钱时直接按劳务报酬税率计算个税 | 合规,平台无连带责任;用户到手金额透明 |
| B:年度汇算 | 全年预扣,次年3-6月用户自行汇算 | 复杂,平台需提供年度明细 |
系统采用模式 A(预扣代缴),并提供年度明细(模式 B 的简化版)。
def calc_withholding_tax(amount_fen: int) -> dict:
amount_yuan = amount_fen / 100
if amount_yuan <= 800:
tax_yuan = 0
taxable_yuan = 0
rate = 0
if amount_yuan <= 4000:
taxable_yuan = amount_yuan - 800
tax_yuan = taxable_yuan * 0.20
rate = 0.20
else:
taxable_yuan = amount_yuan * 0.80
if taxable_yuan <= 20000:
tax_yuan = taxable_yuan * 0.20 - 1050
rate = 0.20
elif taxable_yuan <= 50000:
tax_yuan = taxable_yuan * 0.30 - 4050
rate = 0.30
else:
tax_yuan = taxable_yuan * 0.40 - 7050
rate = 0.40
return {
"gross_amount_fen": amount_fen,
"taxable_amount_fen": int(taxable_yuan * 100),
"tax_rate": rate,
"withholding_tax_fen": max(0, int(tax_yuan * 100)),
"net_amount_fen": amount_fen - max(0, int(tax_yuan * 100)),
"service_fee_fen": int(amount_fen * 0.006), # 微信手续费
}
系统侧(自动):
周限提策略 – 鼓励用户合并小额提现,减少单次税率浪费
年累计预扣优化(年度报表)
800元以下小额免收割策略
若用户账户余额 ≤ ¥1500,建议用户先不提现,累计到¥800+:
建议小程序前端提示:
"当前提现 ¥550,扣除个税后仅到账 ¥440(低于免征额 ¥800)。
建议再积累 ¥300 以上佣金,可享受个税免征。"
用户侧(引导):
CREATE TABLE `withdraw_records` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`amount_fen` INT NOT NULL COMMENT '申请提现金额(分)',
-- 税务
`withholding_tax_fen` INT NOT NULL DEFAULT 0 COMMENT '预扣个税(分)',
`tax_rate` DECIMAL(4,2) NOT NULL DEFAULT 0 COMMENT '适用税率',
`taxable_amount_fen` INT NOT NULL DEFAULT 0 COMMENT '应纳税所得额(分)',
-- 微信
`service_fee_fen` INT NOT NULL DEFAULT 0 COMMENT '微信手续费(分)',
`wechat_transfer_id` VARCHAR(64) COMMENT '微信企业付款单号',
`wechat_resp` JSON COMMENT '微信返回原始响应',
-- 风控
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/SUCCESS/FAILED',
`status_remark` VARCHAR(256) COMMENT '状态说明(失败原因等)',
`retry_count` INT NOT NULL DEFAULT 0,
`risk_pass` TINYINT NOT NULL DEFAULT 0 COMMENT '风控是否通过',
`risk_reason` VARCHAR(256) COMMENT '风控不通过原因',
-- 审计
`year` INT NOT NULL COMMENT '所属年度(汇算用)',
`tax_year_amount_fen` INT NOT NULL DEFAULT 0 COMMENT '该年度已提现总额(分)',
`tax_year_withheld_fen` INT NOT NULL DEFAULT 0 COMMENT '该年度已扣个税额(分)',
`applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed_at` DATETIME COMMENT '处理完成时间',
INDEX `idx_user_status` (`user_id`, `status`),
INDEX `idx_user_year` (`user_id`, `year`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `user_wallet` (
`user_id` BIGINT PRIMARY KEY,
`available_balance_fen` INT NOT NULL DEFAULT 0 COMMENT '可提现余额',
`frozen_balance_fen` INT NOT NULL DEFAULT 0 COMMENT '冻结中余额(提现申请后)',
`total_withdrawn_fen` INT NOT NULL DEFAULT 0 COMMENT '累计已提现',
`total_withholding_fen` INT NOT NULL DEFAULT 0 COMMENT '累计已扣个税',
`annual_withdrawn_fen` INT NOT NULL DEFAULT 0 COMMENT '本年度已提',
`annual_withholding_fen` INT NOT NULL DEFAULT 0 COMMENT '本年度已扣税',
`last_withdraw_at` DATETIME COMMENT '上次提现时间',
`week_withdraw_count` INT NOT NULL DEFAULT 0 COMMENT '本周次数',
`week_reset_at` DATE COMMENT '周重置日',
`day_fail_count` INT NOT NULL DEFAULT 0 COMMENT '今日失败次数',
`day_fail_date` DATE COMMENT '失败计数日期',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
ALTER TABLE `commissions` ADD COLUMN `wallet_locked` TINYINT NOT NULL DEFAULT 0 COMMENT '是否已入钱包锁';
ALTER TABLE `commissions` ADD COLUMN `withdraw_record_id` BIGINT COMMENT '关联提现ID';
INSERT INTO sys_config (`key`, `value`, `desc`, `value_type`) VALUES
('withdraw.min_amount', '10000', '最低提现金额(分)', 'amount'),
('withdraw.max_amount_time', '1000000', '单次最高提现(分)', 'amount'),
('withdraw.max_per_week', '1', '每周限提次数', 'number'),
('withdraw.auto_approve_limit', '50000', '免审阈值(分)', 'amount'),
('withdraw.fail_limit_day', '3', '日失败上限', 'number'),
('withdraw.fail_lock_hours', '24', '超限冻结小时', 'number'),
('withdraw.service_fee_rate', '6', '微信手续费万分比(6=0.06%)', 'percent'),
('withdraw.week_reset_day', '1', '周重置日(0=周日)', 'number'),
('withdraw.tax.labor_rate_1', '800', '劳务报酬免征额(元)', 'amount'),
('withdraw.tax.deduction_2', '2000', '$4,000档速算扣除(分,对应¥20+1050)', 'amount'),
('withdraw.tax.deduction_3', '40500', '$25,000档速算扣除(分,对应¥20,000*3%-4050)', 'amount'),
('withdraw.tax.deduction_4', '70500', '>$62,500档速算扣除(分)', 'amount'),
('withdraw.annual_reconcile', '1', '是否开启年度汇算', 'boolean');
| 端点 | 方法 | 说明 | 权限 |
|---|---|---|---|
POST /api/withdraw/apply |
申请提现 | 输入 amount_fen(可选,默认全部可提) | 已登录 |
POST /api/withdraw/records |
提现记录列表 | 分页,含状态+金额+到账时间 | 已登录 |
GET /api/withdraw/detail/:id |
提现详情 | 单条明细(含税务明细) | 本人 |
GET /api/withdraw/summary |
提现汇总 | 累计提现/累计个税/本年度汇总 | 已登录 |
GET /api/withdraw/tax-preview |
税务预览 | 输入金额 → 返回预计扣税+到手 | 已登录 |
POST /api/withdraw/cancel/:id |
撤销提现 | PENDING 状态可撤销,解冻金额 | 本人 |
GET /api/withdraw/annual-report |
年度明细 PDF | 下载含扣税明细的年度 PDF | 已登录 |
// WithdrawApplyRequest.java
@Data
public class WithdrawApplyRequest {
@NotNull(message = "提现金额不能为空")
@Min(value = 10000, message = "最低提现 ¥100") // amount in fen
@Max(value = 1000000, message = "单次最高 ¥10,000")
private Integer amountFen;
// Optional: override full withdrawal
private Boolean all = false;
}
// WithdrawApplyResponse.java
@Data
public class WithdrawApplyResponse {
private Long recordId;
private Integer grossAmountFen;
private Integer withholdingTaxFen;
private Integer serviceFeeFen;
private Integer netAmountFen; // 实际到账
private BigDecimal taxRate;
private String status; // PENDING
private String estimatedArrival; // 1-3工作日
}
// WithdrawService.java - 核心流程
@Transactional
public WithdrawApplyResponse apply(Long userId, WithdrawApplyRequest req) {
// 1. 校验余额
Wallet wallet = walletRepo.findByUserId(userId)
.orElseThrow(() -> new BizException(404, "钱包不存在"));
// 2. 校验周限次
if (wallet.getWeekWithdrawCount() >= getMaxPerWeek()) {
throw new BizException(400, "本周提现次数已用完");
}
// 3. 风控校验
RiskResult risk = riskCheck(userId, req.getAmountFen());
if (!risk.passed()) {
return reject(risk);
}
// 4. 冻结金额
int amount = req.getAll() ? wallet.getAvailableBalance() : req.getAmountFen();
if (wallet.getAvailableBalance() < amount) {
throw new BizException(400, "可提现余额不足");
}
wallet.freeze(amount);
walletRepo.save(wallet);
// 5. 税务计算
TaxResult tax = TaxCalculator.calc(amount);
// 6. 创建提现记录
WithdrawRecord record = new WithdrawRecord();
record.setUserId(userId);
record.setAmountFen(amount);
record.setWithholdingTaxFen(tax.getWithholding());
record.setTaxRate(tax.getRate());
record.setStatus("PENDING");
record.setYear(Year.now().getValue());
walletRepo.save(wallet);
// 7. 已通过自动审核(免审):直接调用微信付款
if (shouldAutoApprove(amount)) {
processWechatPayment(userId, record, tax);
}
return toResponse(record, tax);
}
POST https://api.mch.weixin.qq.com/mmpaymkttransfers/promotion/transfers
必要参数:
mch_appid — 小程序 AppIDmchid — 商户号partner_trade_no — 商户订单号(唯一)openid — 用户 openidcheck_name — NO_CHECK(不校验真实姓名,按公众号绑定)amount — 金额(分,实际打款金额 = amount - 服务费)desc — 企业付款说明spbill_create_ip — 服务器 IP签名校验: 使用商户 API 证书(apiclient_key.pem)双向证书签名
| 费用项 | 承担方 | 费率 |
|---|---|---|
| 微信企业付款手续费 | 平台 | 0.6%(最低 ¥0.1) |
| 个税(劳务报酬) | 从用户提现金额中扣除 | 累进 0%-40% |
| 代开发票(可选) | 用户 | 约 2.5%-3% |
// WithdrawSecurity.kt (概念)
@Scheduled(fixedRate = 60000)
fun watchPending() {
// 检查超过 5 分钟未处理的 PENDING 记录
// 超时 → 解冻 + 记录告警
}
@Scheduled(fixedRate = 86400000)
func dailyReconcile() {
// 与微信账单对账,确保平台记录与微信到账一致
}
┌─────────────────────────────┐
│ ← 提现 │
│ │
│ 可提现余额 │ 大字 + 闪烁动画
│ ¥ 1,380.00 │
│ ───────────────────────── │
│ 输入提现金额 │
│ ┌─────────────────────┐ │
│ │ ¥ 500.00 │ │ 数字键盘
│ └─────────────────────┘ │
│ 快捷金额: ¥100 ¥500 ¥全部 │
│ │
│ ┌─────────────────────┐ │ 浅灰蓝卡片 F7F8FC
│ │ 税务预览 │ │
│ │ 提现金额 ¥ 500.00 │ │
│ │ 预扣个税 ¥ 30.00 │ │ 20% 税率
│ │ 微信手续费 ¥ 3.00 │ │ 0.6% 平台承担
│ │ 实际到账 ¥ 467.00 │ │ 大号强调
│ └─────────────────────┘ │
│ │
│ ⚠️ 提示:本次提现低于 ¥800 │
│ 免征额,建议累计至 ¥800+ │
│ │
│ [ 确认提现 ] │ 深色品牌按钮
│ │
│ 本周已提:0/1次 | 预计1-3日到账 │ 小字灰色
└─────────────────────────────┘
┌─────────────────────────────┐
│ 提现记录 │
├─────────────────────────────┤
│ ┌─────────────────────┐ │
│ │ 11月20日 15:30 │ │
│ │ 申请 ¥500.00 │ │
│ │ 个税 ¥30 + 到账 ¥467 │ │
│ │ 状态:✅ 已到账 │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ 11月15日 09:00 │ │
│ │ 申请 ¥200.00 │ │
│ │ 个税 ¥0(免征) │ │
│ │ 状态:⏳ 处理中 │ │
│ └─────────────────────┘ │
│ │
│ [下载年度税务明细 PDF] │
└─────────────────────────────┘
| 表名 | 关键字段 | 说明 |
|---|---|---|
user_wallet |
available_balance_fen | 可提现余额 |
user_wallet |
frozen_balance_fen | 申请中冻结余额 |
withdraw_records |
status | INIT / PENDING / SUCCESS / FAILED |
withdraw_records |
withholding_tax_fen | 预扣个税金额 |
withdraw_records |
tax_rate | 适用税率(0.20 等) |
withdraw_records |
wechat_transfer_id | 微信付款单号 |
sys_config |
withdraw.* | 所有可配置参数 |
sys_config 支持快速更新税率,年审时直接在后台调整| 优先级 | 策略 | 用户价值 | 系统改动 |
|---|---|---|---|
| P0 | 预扣代缴 + 税务预览弹窗 | 透明化 | ✅ 已覆盖 |
| P0 | ¥800免征额智能提示 | 直接省钱 | ✅ 前端提示 |
| P1 | 年度汇算退补机制 | 合规+体验 | 需年审触发器 |
| P2 | 小额合并提现建议 | 减少税率浪费 | 前端算法 |
| P3 | 代开发票引导(>¥30,000/年) | 合法降税 | 静态链接 |
| P3 | 个体户注册指引 | 长期节税 | 外部跳转 |