基于文档一致性修正(已完成)后的编码实现计划
状态:v1.0 Draft,待确认后执行
@EnableScheduling 已在 NumApplication.java 启用ChatService.sendMessage() 已调用 quotaService.checkAndConsume()QuotaService(含 resetDailyQuota)已编码完成sys_config 种子数据尚未注册到 ConfigService.java| Sprint | 内容 | 预估工作量 | 风险 |
|---|---|---|---|
| 1 | 定时任务基础设施 + 配额重置 | 1 文件 + 5 行 seed 数据 | 低 |
| 2 | 提现系统重构(通道/税务/批处理) | 5 文件(Entity+Service+SQL+Config) | 中 |
| 3 | 分红结算定时任务 | 2 文件(SQL+Service) | 高(递归算法+资金池精度) |
| 4 | 参数入库 + 整体对接 | 1 文件(ConfigService种子更新) | 低 |
ScheduledTasks.javapackage com.etotem.num.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 系统定时任务容器。
* 集中管理所有 @Scheduled 任务,避免分散在多个 Service 中难以维护。
*/
@Component
public class ScheduledTasks {
private static final Logger log = LoggerFactory.getLogger(ScheduledTasks.class);
private final QuotaService quotaService;
public ScheduledTasks(QuotaService quotaService) { this.quotaService = quotaService; }
/**
* 每日 00:00 重置所有用户咨询配额(chat_count_today = 0)。
*/
@Scheduled(cron = "0 0 0 * * ?")
public void resetDailyQuota() {
int affected = quotaService.resetDailyQuota();
log.info("[Scheduled] 每日配额重置完成,影响 {} 条记录", affected);
}
}
文件路径: num-server/src/main/java/com/etotem/num/service/ScheduledTasks.java
在 ConfigService.java 的 initSeedData() 和 getAllSeeds() 中追加:
// Quota limits (per-role)
seeds.put("quota.person_limit.normal", new SeedEntry("3", "int", "普通用户可咨询人数上限"));
seeds.put("quota.person_limit.annual", new SeedEntry("9", "int", "C端会员可咨询人数上限"));
seeds.put("quota.person_limit.family", new SeedEntry("9", "int", "家庭套餐可咨询人数上限"));
seeds.put("quota.person_limit.practitioner", new SeedEntry("0", "int", "能量师可咨询人数上限(0=不限制)"));
seeds.put("quota.chat_limit.normal", new SeedEntry("3", "int", "普通用户每日每人聊天次数上限"));
seeds.put("quota.chat_limit.annual", new SeedEntry("0", "int", "C端会员每日每人聊天次数上限(0=不限制)"));
seeds.put("quota.chat_limit.family", new SeedEntry("0", "int", "家庭套餐每日每人聊天次数上限(0=不限制)"));
seeds.put("quota.chat_limit.practitioner", new SeedEntry("0", "int", "能量师每日每人聊天次数上限(0=不限制)"));
resetDailyQuota() cron 定时执行且日志正确chat_count_today 归零quota.person_limit.normal=3 限制正确生效Withdraw.java现状: 只有 id, userId, amount, status, createdAt, updatedAt
目标: 按 docs/withdrawal-design.md 补充以下字段
// === 新增字段(加在现有字段后)===
// 提现通道:REFUND(退费) / WECHAT_TRANSFER(企业付款) / THIRD_PARTY(第三方)
@Column(length = 30)
private String channel;
// 状态机:pending → queued → processing → success / failed / frozen
// pending(待审批) → approved(已审批) → queued(已入列) → processing(处理中)
// → success(成功) / failed(失败) / frozen(冻结)
@Column(length = 30)
private String channelStatus; // 通道侧状态码
// 税务字段
@Column(name = "tax_fen")
private Integer taxFen = 0; // 预扣税额(分)
@Column(name = "net_amount_fen")
private Integer netAmountFen; // 实际到账金额(分)
@Column(name = "tax_rate")
private Integer taxRate = 0; // 适用税率(万分比)
// 批处理字段
@Column(name = "batch_id", length = 50)
private String batchId; // 批次号(年月日+序号,如 20260608-001)
@Column(name = "processed_at")
private LocalDateTime processedAt; // 实际处理时间
// 微信/通道侧字段
@Column(name = "channel_order_no", length = 64)
private String channelOrderNo; // 微信付款单号
@Column(name = "channel_error", length = 200)
private String channelError; // 通道错误信息
// 审批字段
@Column(name = "approved_by")
private Long approvedBy; // 审批人用户ID
@Column(name = "approved_at")
private LocalDateTime approvedAt; // 审批时间
@Column(name = "remark", length = 200)
private String remark;
WithdrawRepository.java现状: 标准 JPA(findByUserIdOrderByCreatedAtDesc, findAll)
新增方法:
// 查询待批处理提现记录
List<Withdraw> findByStatusIn(List<String> statuses);
// 按批次查询
List<Withdraw> findByBatchId(String batchId);
// 统计指定用户已提现总额
@Query("SELECT COALESCE(SUM(w.amount), 0) FROM Withdraw w WHERE w.userId = :userId AND w.status = 'success'")
long sumSuccessAmountByUserId(@Param("userId") Long userId);
// 查询用户首次提现是否在30天内
@Query("SELECT w.createdAt FROM Withdraw w WHERE w.userId = :userId ORDER BY w.createdAt ASC")
List<LocalDateTime> findFirstWithdrawTime(@Param("userId") Long userId, Pageable pageable);
WithdrawService.java核心方法清单:
// ==================== 用户侧 ====================
// 1. 申请提现(可选channel参数)
public Withdraw apply(Long userId, int amount, String channel)
// 逻辑:
// - 金额 ≥ ¥100
// - 查询可用余额(commission表)
// - 自动判断通道:
// a) 首提且订单 <30天 → REFUND(微信退费通道)
// b) 金额 > ¥800 → THIRD_PARTY
// c) 否则 → WECHAT_TRANSFER
// - 计算预扣税额(≤¥800免征,>¥800按劳务报酬预扣)
// - 创建 status='pending' 记录
// 2. 查询可用余额
public long getAvailableBalance(Long userId)
// 3. 查询提现记录(带分页)
public Page<Withdraw> getMyWithdraws(Long userId, int page, int size)
// ==================== 管理后台侧 ====================
// 4. 审批通过(入列等待夜间批量处理)
public void approve(Long id, Long adminId)
// 5. 驳回
public void reject(Long id, String reason)
// 6. 冻结/解冻
public void freeze(Long id)
public void unfreeze(Long id)
// ==================== 定时任务(夜间批处理) ====================
// 7. 夜间 23:30 批量处理
@Scheduled(cron = "0 30 23 * * ?")
public void batchProcessQueuedWithdraws()
// 逻辑:
// a) 查询所有 status='queued' 的记录
// b) 生成批次号
// c) 标记 BATCH_PROCESSING
// d) 检查微信商户平台余额(merchant_balance)
// e) 若余额不足 → 记录错误,次日重试
// f) 逐条调用对应通道 API
// g) 更新最终状态
WithdrawController.java现状: apply, list, balance
新增端点:
// 管理后台:提现列表(全部)
@PostMapping("/admin/list") → Page<Withdraw>
// 管理后台:审批通过
@PostMapping("/admin/approve")
// 管理后台:驳回
@PostMapping("/admin/reject")
// 管理后台:冻结
@PostMapping("/admin/freeze")
// 用户侧:提现详情
@PostMapping("/detail")
// 提现配置
seeds.put("withdraw.min_amount", new SeedEntry("10000", "amount", "最低提现金额(分)"));
seeds.put("withdraw.max_amount", new SeedEntry("5000000","amount", "单次最高提现金额(分)"));
seeds.put("withdraw.fee_free_threshold", new SeedEntry("80000", "amount", "免税额(分),≤¥800免征劳务报酬税"));
seeds.put("withdraw.tax_rate", new SeedEntry("2000", "percent", "劳务报酬预扣税率(万分比)"));
seeds.put("withdraw.refund_days", new SeedEntry("30", "int", "首笔可走退费通道的天数"));
seeds.put("withdraw.merchant_min_balance",new SeedEntry("5000000","amount","微信商户最低保留余额(分)"));
seeds.put("withdraw.batch_time", new SeedEntry("2330", "string", "批处理时间(HHmm)"));
seeds.put("withdraw.frozen_on_error", new SeedEntry("true", "bool", "通道失败时是否自动冻结"));
apply() → pending ──→ approve() ──→ queued ──→ batchProcess() ──→ processing ──→ success
↕ ↕
reject() failed ──→ frozen(自动)
↕ ↕
frozen ←──→ unfreeze() manual_review
将以下 SQL 放入 num-server/src/main/resources/schema.sql 或 Flyway migration:
CREATE TABLE IF NOT EXISTS `monthly_bonus_records` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`energy_master_id` BIGINT NOT NULL COMMENT '能量师用户ID',
`year_month` VARCHAR(7) NOT NULL COMMENT '结算月份(如 2026-06)',
`platform_revenue_fen` BIGINT NOT NULL DEFAULT 0,
`personal_pool_fen` BIGINT NOT NULL DEFAULT 0,
`team_pool_fen` BIGINT NOT NULL DEFAULT 0,
`personal_sales_fen` BIGINT NOT NULL DEFAULT 0,
`total_personal_sales_fen` BIGINT NOT NULL DEFAULT 0,
`personal_share_pct` INT NOT NULL DEFAULT 0,
`team_sales_fen` BIGINT NOT NULL DEFAULT 0,
`total_team_sales_fen` BIGINT NOT NULL DEFAULT 0,
`team_share_pct` INT NOT NULL DEFAULT 0,
`personal_bonus_fen` BIGINT NOT NULL DEFAULT 0,
`team_bonus_fen` BIGINT NOT NULL DEFAULT 0,
`total_bonus_fen` BIGINT NOT NULL DEFAULT 0,
`qualified` TINYINT(1) NOT NULL DEFAULT 0,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`settled_at` DATETIME,
`paid_at` DATETIME,
`remark` VARCHAR(200),
UNIQUE KEY `uk_master_month` (`energy_master_id`, `year_month`),
INDEX `idx_year_month` (`year_month`),
INDEX `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='能量师月度分红结算表';
BonusSettlementService.javapackage com.etotem.num.service;
/**
* 能量师月度分红结算服务(资金池竞争制)。
*
* 每月 1 日 00:00 执行:
* 1. 计算平台月总收入(实付金额-退款)
* 2. 划出个人分红池(5%)和团队分红池(5%)
* 3. 筛选当月有直接销售的活跃能量师
* 4. 计算各能量师的个人直接销售额和团队销售额(递归,遇无销售截断)
* 5. 按占比分配两个资金池
* 6. 写入 monthly_bonus_records
*/
@Service
public class BonusSettlementService {
// Dependencies: OrderRepository, UserRepository, ConfigService, (for write: BonusRecordRepository)
@Scheduled(cron = "0 0 1 * * ?") // 每月1日 00:00
@Transactional
public void settleMonthlyBonus() {
// 1. 计算平台当月总收入
// SELECT COALESCE(SUM(amount), 0) FROM orders
// WHERE status = 'paid' AND paid_at BETWEEN startOfMonth AND endOfMonth
// 2. 查询所有能量师
// SELECT * FROM users WHERE vipType = 'practitioner'
// 3. 统计每个能量师当月直接销售额
// Map<userId, sales>
// 4. 筛选活跃能量师(当月有直接销售额 > 0)
// 5. 计算活跃能量师的团队销售额(递归)
// calcTeamSalesRecursive(userId, yearMonth)
// - 自身直接销售 + ∑(下级有销售者递归)
// 6. 计算总分母(总个人销售额、总团队销售额)
// 7. 对每个活跃能量师计算占比和分红
// 个人分红 = (个人销售额 / 总个人销售额) × 个人分红池
// 团队分红 = (团队销售额 / 总团队销售额) × 团队分红池
// 8. 写入 monthly_bonus_records
}
// 递归方法
private long calcTeamSalesRecursive(Long userId, YearMonth yearMonth) {
// 自身直接销售 + 下级的团队销售(仅当月有销售的下级才递归)
}
// 辅助方法
private long getMonthlySales(Long userId, YearMonth yearMonth) {
// 从 orders 表查询该用户当月直接下级订单
}
}
注意: monthly_bonus_records 缺少对应的 JPA Entity + Repository。需创建:
MonthlyBonusRecord.java(JPA Entity)MonthlyBonusRecordRepository.java(JPA Repository,含 findByYearMonth)// 分红配置
seeds.put("bonus.personal.pool_rate", new SeedEntry("500", "percent", "个人分红池占总收入万分比(500=5%)"));
seeds.put("bonus.team.pool_rate", new SeedEntry("500", "percent", "团队分红池占总收入万分比(500=5%)"));
seeds.put("bonus.settlement_day", new SeedEntry("1", "int", "月度结算日"));
seeds.put("bonus.min_payout", new SeedEntry("10000","amount", "最低发放金额(分,¥100),不足滚入下月"));
rollover 不下发一次性在 initSeedData() 和 getAllSeeds() 追加所有新配置:
| 分组 | Key | 默认值 | 说明 |
|---|---|---|---|
| 配额 | quota.person_limit.* |
normal=3, annual=9, family=9, practitioner=0 | 角色咨询人数上限 |
| 配额 | quota.chat_limit.* |
normal=3, annual=0, family=0, practitioner=0 | 角色每日聊天次数上限 |
| 提现 | withdraw.min_amount |
10000 | 最低提现金额 ¥100 |
| 提现 | withdraw.max_amount |
5000000 | 最高提现金额 ¥50,000 |
| 提现 | withdraw.fee_free_threshold |
80000 | 免税额 ¥800 |
| 提现 | withdraw.tax_rate |
2000 | 劳务报酬预扣税率 20% |
| 提现 | withdraw.refund_days |
30 | 首笔退费通道天数 |
| 提现 | withdraw.merchant_min_balance |
5000000 | 商户最低保留余额 |
| 提现 | withdraw.batch_time |
2330 | 批处理时间 |
| 提现 | withdraw.frozen_on_error |
true | 失败自动冻结 |
| 分红 | bonus.personal.pool_rate |
500 | 个人分红池 5% |
| 分红 | bonus.team.pool_rate |
500 | 团队分红池 5% |
| 分红 | bonus.settlement_day |
1 | 结算日 |
| 分红 | bonus.min_payout |
10000 | 最低发放 ¥100 |
ChatService 配额拦截:免费用户咨询第 4 人时被阻止(quota.person_limit.normal=3)ChatService 配额拦截:免费用户对同一人第 4 次聊天被阻止(quota.chat_limit.normal=3)WithdrawService.batchProcessQueuedWithdraws 只在 23:30 执行BonusSettlementService.settleMonthlyBonus 只在每月 1 日执行Sprint 1: ScheduledTasks.java + ConfigService quota seeds
↓
Sprint 2: Withdraw Entity重构 → Repository新增方法 → Service重写 → Controller增强
↓ (Sprint 2&3 无依赖,可并行)
Sprint 3: monthly_bonus_records DDL + MonthlyBonusRecord Entity + Repository + BonusSettlementService
↓
Sprint 4: ConfigService 全部种子数据入库 + 整体验证
并行策略: Sprint 2(提现)和 Sprint 3(分红)无依赖,可委托两个子代理并行执行。
| Sprint | 文件 | 操作 |
|---|---|---|
| 1 | service/ScheduledTasks.java |
新建 |
| 1 | service/ConfigService.java |
编辑(追加 quota seeds) |
| 2 | entity/Withdraw.java |
重写(扩字段) |
| 2 | repository/WithdrawRepository.java |
编辑(新增查询方法) |
| 2 | service/WithdrawService.java |
重写(批处理+税务+通道选择) |
| 2 | controller/WithdrawController.java |
编辑(新增管理端点) |
| 2 | service/WeChatService.java |
可能需编辑(企业付款API) |
| 3 | resources/schema.sql 或 Flyway migration |
新建(monthly_bonus_records DDL) |
| 3 | entity/MonthlyBonusRecord.java |
新建 |
| 3 | repository/MonthlyBonusRecordRepository.java |
新建 |
| 3 | service/BonusSettlementService.java |
新建 |
| 4 | service/ConfigService.java |
编辑(追加提现+分红 seeds) |