# 实施执行计划 > 基于文档一致性修正(已完成)后的编码实现计划 > 状态:v1.0 Draft,待确认后执行 --- ## 前置条件 - ✅ 文档一致性修正(创始人码/三重条件清理)已完成 - ✅ `@EnableScheduling` 已在 `NumApplication.java` 启用 - ✅ `ChatService.sendMessage()` 已调用 `quotaService.checkAndConsume()` - ✅ `QuotaService`(含 `resetDailyQuota`)已编码完成 - ❌ 新增 `sys_config` 种子数据尚未注册到 `ConfigService.java` --- ## 总体计划(4 个 Sprint) | Sprint | 内容 | 预估工作量 | 风险 | |--------|------|-----------|------| | 1 | 定时任务基础设施 + 配额重置 | 1 文件 + 5 行 seed 数据 | 低 | | 2 | 提现系统重构(通道/税务/批处理) | 5 文件(Entity+Service+SQL+Config) | 中 | | 3 | 分红结算定时任务 | 2 文件(SQL+Service) | 高(递归算法+资金池精度) | | 4 | 参数入库 + 整体对接 | 1 文件(ConfigService种子更新) | 低 | --- ## Sprint 1:定时任务基础设施 + 配额重置 ### 1.1 新增 `ScheduledTasks.java` ```java package 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` ### 1.2 ConfigService 新增种子数据 在 `ConfigService.java` 的 `initSeedData()` 和 `getAllSeeds()` 中追加: ```java // 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=不限制)")); ``` ### 1.3 验证点 - [ ] `resetDailyQuota()` cron 定时执行且日志正确 - [ ] 跨天后 `chat_count_today` 归零 - [ ] `quota.person_limit.normal=3` 限制正确生效 --- ## Sprint 2:提现系统重构 ### 2.1 提现 Entity 重构 — `Withdraw.java` **现状:** 只有 `id, userId, amount, status, createdAt, updatedAt` **目标:** 按 `docs/withdrawal-design.md` 补充以下字段 ```java // === 新增字段(加在现有字段后)=== // 提现通道: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; ``` ### 2.2 提现 Repository — `WithdrawRepository.java` **现状:** 标准 JPA(findByUserIdOrderByCreatedAtDesc, findAll) **新增方法:** ```java // 查询待批处理提现记录 List findByStatusIn(List statuses); // 按批次查询 List 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 findFirstWithdrawTime(@Param("userId") Long userId, Pageable pageable); ``` ### 2.3 提现 Service 重构 — `WithdrawService.java` **核心方法清单:** ```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 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) 更新最终状态 ``` ### 2.4 提现 Controller 增强 — `WithdrawController.java` **现状:** `apply`, `list`, `balance` **新增端点:** ```java // 管理后台:提现列表(全部) @PostMapping("/admin/list") → Page // 管理后台:审批通过 @PostMapping("/admin/approve") // 管理后台:驳回 @PostMapping("/admin/reject") // 管理后台:冻结 @PostMapping("/admin/freeze") // 用户侧:提现详情 @PostMapping("/detail") ``` ### 2.5 sys_config 种子数据 ```java // 提现配置 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", "通道失败时是否自动冻结")); ``` ### 2.6 提现状态机 ``` apply() → pending ──→ approve() ──→ queued ──→ batchProcess() ──→ processing ──→ success ↕ ↕ reject() failed ──→ frozen(自动) ↕ ↕ frozen ←──→ unfreeze() manual_review ``` ### 2.7 验证点 - [ ] 用户提现申请时通道自动选择正确(首笔30天退费/≤¥800微信/>¥800第三方) - [ ] 税务计算正确(¥800免征,超出部分按20%预扣) - [ ] 夜间批处理 23:30 准时触发 - [ ] 商户余额不足时正确跳过并记录日志 - [ ] 多次失败后自动冻结 --- ## Sprint 3:分红结算定时任务 ### 3.1 monthly_bonus_records DDL 将以下 SQL 放入 `num-server/src/main/resources/schema.sql` 或 Flyway migration: ```sql 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='能量师月度分红结算表'; ``` ### 3.2 新增 `BonusSettlementService.java` ```java package 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 // 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`) ### 3.3 sys_config 种子数据 ```java // 分红配置 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),不足滚入下月")); ``` ### 3.4 验证点 - [ ] 每月 1 日 00:00 准时触发结算 - [ ] 个人/团队各 5% 资金池正确划出 - [ ] 递归团队计算在无销售成员处截断 - [ ] 资金池总额 = 两个 5% 池之和 - [ ] 每个能量师的占比之和 = 100%(验证算术精度) - [ ] 不合格能量师(当月无销售)不参与分配 - [ ] 分红 < ¥100 的记录标记 `rollover` 不下发 --- ## Sprint 4:参数入库 + 整体对接 ### 4.1 ConfigService 种子数据全部追加 一次性在 `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 | ### 4.2 交叉验证清单 - [ ] `ChatService` 配额拦截:免费用户咨询第 4 人时被阻止(`quota.person_limit.normal=3`) - [ ] `ChatService` 配额拦截:免费用户对同一人第 4 次聊天被阻止(`quota.chat_limit.normal=3`) - [ ] C端会员可咨询 9 人、无限聊天 - [ ] 能量师不限人数、不限聊天 - [ ] 次日 00:00 配额自动重置 - [ ] `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) |