فهرست منبع

docs: 编码实现执行计划——4个Sprint(配额定时+提现重构+分红结算+参数入库)

liaoxg 3 ماه پیش
والد
کامیت
f84c294f22
1فایلهای تغییر یافته به همراه457 افزوده شده و 0 حذف شده
  1. 457 0
      docs/implementation-plan-execution.md

+ 457 - 0
docs/implementation-plan-execution.md

@@ -0,0 +1,457 @@
+# 实施执行计划
+
+> 基于文档一致性修正(已完成)后的编码实现计划  
+> 状态: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<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`
+
+**核心方法清单:**
+
+```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`  
+**新增端点:**
+
+```java
+// 管理后台:提现列表(全部)
+@PostMapping("/admin/list")      → Page<Withdraw>
+
+// 管理后台:审批通过
+@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<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 种子数据
+
+```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) |