implementation-plan-execution.md 17 KB

实施执行计划

基于文档一致性修正(已完成)后的编码实现计划
状态: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

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.javainitSeedData()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=不限制)"));

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 补充以下字段

// === 新增字段(加在现有字段后)===

// 提现通道: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)
新增方法:

// 查询待批处理提现记录
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);

2.3 提现 Service 重构 — 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) 更新最终状态

2.4 提现 Controller 增强 — WithdrawController.java

现状: apply, list, balance
新增端点:

// 管理后台:提现列表(全部)
@PostMapping("/admin/list")      → Page<Withdraw>

// 管理后台:审批通过
@PostMapping("/admin/approve")

// 管理后台:驳回
@PostMapping("/admin/reject")

// 管理后台:冻结
@PostMapping("/admin/freeze")

// 用户侧:提现详情
@PostMapping("/detail")

2.5 sys_config 种子数据

// 提现配置
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:

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

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<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

3.3 sys_config 种子数据

// 分红配置
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)