Procházet zdrojové kódy

fix: 适配远程 API 变更(分佣免费用户+提现分页+统计类型) + 修复 Java 8 兼容性 + 测试报告

liaoxg před 3 měsíci
rodič
revize
602fd15fc4

+ 41 - 55
docs/test-coverage-report.md

@@ -1,20 +1,23 @@
 # 测试覆盖率报告
 
-**生成日期:** 2026-06-09
-**分支:** master
-**备注:** 全部测试通过。新增 AnalyticsServiceTest 15 条 + AdminController 7 个 analytics 端点测试,合计 +22 条
+**生成日期:** 2026-06-10
+**分支:** master (rebased on origin/master)
+**备注:** 从远程更新后适配全部 API 变更。新增 RelationAnalysisServiceTest (16) + AnalyticsServiceTest (9) + AdminController 端点测试扩充至 34,合计 +32 条。全量 253 测试通过
 
 ## 总览
 
 | 套件 | 运行 | 通过 | 失败 | 错误 | 跳过 |
 |------|------|------|------|------|------|
-| 后端单元测试 | 237 | 237 | 0 | 0 | 5 |
-| Admin API 测试 | 6 | 6 | 0 | 0 | 0 |
-| Playwright E2E (admin) | 3 | 3 | 0 | 0 | 0 |
-| **合计** | **243** | **243** | **0** | **0** | **5** |
+| 后端单元测试 | 253 | 253 | 0 | 0 | 0 |
 
 ## 后端测试详情
 
+### AdminControllerApiTest
+
+| 端点 | 测试数 | 覆盖情况 |
+|------|--------|----------|
+| 管理后台 API + analytics | **34** | 配置管理、用户管理、订单管理、仪表盘统计、转化漏斗、分销统计 |
+
 ### ControllerIntegrationTest(控制层集成测试)
 
 | Controller | 测试数 | 覆盖情况 |
@@ -25,57 +28,40 @@
 | PayController | 2 | 创建订单、回调通知 |
 | ProfileController | 10 | 信息、VIP 状态、配额(vip/non-vip)、佣金统计、图表数量、资料完善/更新、分享追踪 |
 | CommissionController | 3 | 列表、汇总、统计(含 team 空/非空) |
-| WithdrawController | 3 | 申请、列表、余额 |
+| WithdrawController | **3** | 申请、列表、余额 |
 | AnnotationController | 6 | 添加、列表、删除、标签增删查 |
 | ExportController | 4 | 缺失recordId、非VIP、非所有者、成功导出PDF |
 | PricingController | 2 | 当前定价、升级定价 |
-| | **44** | |
-
-### AdminControllerApiTest(管理后台接口测试)
-
-| 端点 | 测试数 | 覆盖情况 |
-|------|--------|----------|
-| 管理后台 API | 27 | 配置管理、用户管理、订单管理等 |
-| | **+6** | 权限拦截、跨域、健康检查、文件不存在等 |
-| | **+7** | analytics 统计端点测试(概要/收入趋势/用户增长/转化漏斗/分销/提现) |
-| **合计** | **40** | |
+| AcademicOrientation | 1 | 学业方向分析 |
+| **合计** | **56** | |
 
 ### 服务层测试
 
-| Service | 测试数 | 跳过 | 覆盖重点 |
-|---------|--------|------|----------|
-| CalculatorServiceTest | 16 | 2 | 能量盘计算 24 位置 |
-| ChartServiceTest | 18 | 0 | 咨询 CRUD、鉴权 |
-| ChatServiceTest | 7 | 0 | AI 对话、配额检查 |
-| CommissionServiceTest | 15 | 3 | 佣金列表、汇总、**统计仪表盘(日期/团队/转化)** |
-| AnalyticsServiceTest | **15 (新增)** | 0 | 统计分析(概要/收入趋势/用户增长/转化漏斗/分销/提现) |
-| ConfigServiceTest | 9 | 0 | 配置 CRUD |
-| DifyServiceTest | 5 | 0 | Dify API 调用 |
-| OrderServiceTest | **27** | 0 | 订单创建、**分佣逻辑全覆盖(B端/C端/超级会员/幂等/跳过/三级链路/升级补差)** |
-| UserServiceTest | **22** | 0 | 登录注册、VIP状态、配额、**推广码绑定(含自邀跳过)**、referralCode 自动生成 |
-| WithdrawServiceTest | 7 | 0 | 提现申请、列表、余额校验 |
-| **合计** | **141** | **5** | |
-
-## 前端 E2E 测试
-
-| 测试 | 状态 |
-|------|------|
-| 管理后台页面加载 | ✅ |
-| 核心操作流程 | ✅ |
-| 数据展示验证 | ✅ |
-
-## 主题切换修复(v20260608)
-
-| 页面 | 修复项 | 说明 |
-|------|--------|------|
-| `client/pages/commission/index.vue` | `.section` 背景 `#fff` → `--color-bg-card` | 卡片不跟主题切换 |
-| | `.section-sub`, `.comm-amount`, `.comm-remark`, `.tool-arrow` | 硬编码字体颜色 → CSS 变量 |
-| | 状态徽章 `.status-settled/pending/available` | 背景和文字颜色 → 语义变量 |
-| `client/pages/withdraw/index.vue` | `.input-section`, `.rules-card` 背景 `#fff` → `--color-bg-card` | 卡片不跟主题切换 |
-| | `.hint-link` `#3B82F6` → `--color-info` | 链接颜色 |
-| | `.error-text` `#EF4444` → `--color-danger` | 错误提示颜色 |
-| **剩余已知问题** | `client/pages/profile/` 仍有 `#FEF3C7`, `#E5E7EB`, `#F9FAFB` 等硬编码 | 需后续处理 |
-| | `client/pages/relations/*.vue` 21处 `color: #FFFFFF` | 部分在深色背景上可保留,需逐一确认 |
+| Service | 测试数 | 覆盖重点 |
+|---------|--------|----------|
+| AnalyticsServiceTest | **9** | 新增:转化漏斗、分销统计、仪表盘 |
+| CalculatorServiceTest | 16 | 能量盘计算 24 位置 |
+| ChartServiceTest | 18 | 咨询 CRUD、鉴权 |
+| ChatServiceTest | 7 | AI 对话、配额检查 |
+| CommissionServiceTest | 15 | 佣金列表、汇总、统计仪表盘 |
+| ConfigServiceTest | 9 | 配置 CRUD |
+| DifyServiceTest | 5 | Dify API 调用 |
+| OrderServiceTest | **27** | 订单创建、分佣逻辑全覆盖 |
+| RelationAnalysisServiceTest | **16** | 新增:关系分析记录 CRUD、AI 对话、startAnalysis 多分支 |
+| UserServiceTest | **21** | 登录注册、VIP、配额、推广码绑定 |
+| WithdrawServiceTest | **7** | 提现申请、审批、排队 |
+| WxPayServiceTest | 13 | 支付核心 |
+| **合计** | **253** | |
+
+## 远程适配修复
+
+| 测试文件 | 问题 | 修复 |
+|----------|------|------|
+| `RelationAnalysisServiceTest` | Java 8 不支持 `List.of()`/`Map.of()` | 替换为 `Collections.singletonList`/`HashMap` |
+| `OrderServiceTest`(5 条) | 远程移除 referralCode 付费检查,免费用户也可获佣金 | skip 测试改为验证佣金创建 |
+| `ControllerIntegrationTest`(2 条) | WithdrawController 新增 3-参签名 + 分页返回 | Mock 适配新 API |
+| `WithdrawServiceTest`(3 条) | Repository 方法变更 + 审批流程增加排队态 | 更新 mock/断言 |
+| `AnalyticsServiceTest` | `stream().count()` 返回 `Long` 而非 `Integer` | 断言字面量加 `L` |
 
 ## 已知缺口
 
@@ -83,6 +69,6 @@
 |------|------|--------|
 | `ExportService` | 无单元测试 | 低 |
 | `client/` 前端小程序 | 无自动化 E2E 测试(需微信小程序环境) | 中 |
-| `RelationAnalysisService` | 新模块,尚无单元测试 | 中 |
-| `CalculatorServiceTest` | 2个 @Disabled 外部位置测试 | 低 |
-| `CommissionServiceTest` | 3个 @Disabled 状态流转测试 | 低 |
+| `WxPayService` | 异常场景覆盖不足 | 低 |
+| `CalculatorServiceTest` | 2 个位置命名测试被 `@Disabled` | 低 |
+| `CommissionServiceTest` | 3 个状态流转测试被 `@Disabled` | 低 |

+ 8 - 5
num-server/src/test/java/com/etotem/num/controller/ControllerIntegrationTest.java

@@ -674,7 +674,7 @@ class ControllerIntegrationTest {
         wd.setUserId(100L);
         wd.setAmount(10000);
         wd.setStatus("pending");
-        when(withdrawService.apply(100L, 10000)).thenReturn(wd);
+        when(withdrawService.apply(100L, 10000, null)).thenReturn(wd);
 
         mockMvc.perform(post("/api/withdraw/apply")
                         .header("Authorization", "Bearer " + userToken)
@@ -692,14 +692,17 @@ class ControllerIntegrationTest {
         wd.setId(1L);
         wd.setAmount(5000);
         wd.setStatus("approved");
-        when(withdrawService.getMyWithdraws(100L))
-                .thenReturn(Collections.singletonList(wd));
+        org.springframework.data.domain.Page<Withdraw> page = new org.springframework.data.domain.PageImpl<>(Collections.singletonList(wd));
+        when(withdrawService.getMyWithdraws(100L, 0, 20)).thenReturn(page);
 
         mockMvc.perform(post("/api/withdraw/list")
-                        .header("Authorization", "Bearer " + userToken))
+                        .header("Authorization", "Bearer " + userToken)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{}"))
                 .andExpect(status().isOk())
                 .andExpect(jsonPath("$.code").value(0))
-                .andExpect(jsonPath("$.data[0].amount").value(5000));
+                .andExpect(jsonPath("$.data.content[0].amount").value(5000))
+                .andExpect(jsonPath("$.data.totalElements").value(1));
     }
 
     @Test

+ 3 - 3
num-server/src/test/java/com/etotem/num/service/AnalyticsServiceTest.java

@@ -205,9 +205,9 @@ class AnalyticsServiceTest {
             Map<String, Object> funnel = analyticsService.getConversionFunnel();
 
             assertEquals(100L, funnel.get("registered"));
-            assertEquals(2, funnel.get("paidC"));
-            assertEquals(1, funnel.get("paidSuper"));
-            assertEquals(1, funnel.get("practitioner"));
+            assertEquals(2L, funnel.get("paidC"));
+            assertEquals(1L, funnel.get("paidSuper"));
+            assertEquals(1L, funnel.get("practitioner"));
             assertEquals(1.0, (Double) funnel.get("conversionRate"), 0.01);
         }
     }

+ 40 - 17
num-server/src/test/java/com/etotem/num/service/OrderServiceTest.java

@@ -487,7 +487,7 @@ class OrderServiceTest {
     }
 
     /**
-     * B端: 推荐人未付费 (vipType=null) → 不创建佣金
+     * B端: 推荐人未付费 (vipType=null) → 按免费/C端半额处理, L1=l1_cend (20000)
      */
     @Test
     void testPaySuccess_bEnd_noVipType_skipsCommission() {
@@ -504,8 +504,11 @@ class OrderServiceTest {
 
         User inviter = new User();
         inviter.setId(200L);
-        inviter.setVipType(null); // 未付费
+        inviter.setVipType(null); // 未付费 → 按免费用户处理
 
+        when(configService.getInt("commission.practitioner.l1_cend", 20000)).thenReturn(20000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(1L)).thenReturn(Collections.emptyList());
         when(orderRepository.findByOutTradeNo("trade_no_vip")).thenReturn(Optional.of(order));
         when(orderRepository.save(any(Order.class))).thenReturn(order);
         when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
@@ -513,7 +516,10 @@ class OrderServiceTest {
 
         orderService.paySuccess("trade_no_vip");
 
-        verify(commissionRepository, never()).save(any(Commission.class));
+        // 免费用户推荐也可获得半额佣金
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 20000 == c.getAmount()));
+        verify(userRepository).incrementConvertedCount(200L);
     }
 
     /**
@@ -557,7 +563,7 @@ class OrderServiceTest {
     }
 
     /**
-     * B端: 推荐人身份不匹配 (如 super_annual) → 不创建佣金
+     * B端: 推荐人身份不匹配 (如 super_annual) → 按免费/C端半额处理, L1=l1_cend (20000)
      */
     @Test
     void testPaySuccess_bEnd_unknownVipType_skips() {
@@ -574,8 +580,11 @@ class OrderServiceTest {
 
         User inviter = new User();
         inviter.setId(200L);
-        inviter.setVipType("super_annual"); // 不匹配任何分支
+        inviter.setVipType("super_annual"); // 不匹配 practitioner → 走 else (免费/C端半额)
 
+        when(configService.getInt("commission.practitioner.l1_cend", 20000)).thenReturn(20000);
+        when(configService.getInt("commission.practitioner.l2", 10000)).thenReturn(10000);
+        when(commissionRepository.findByOrderId(1L)).thenReturn(Collections.emptyList());
         when(orderRepository.findByOutTradeNo("trade_unknown_vip")).thenReturn(Optional.of(order));
         when(orderRepository.save(any(Order.class))).thenReturn(order);
         when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
@@ -583,11 +592,14 @@ class OrderServiceTest {
 
         orderService.paySuccess("trade_unknown_vip");
 
-        verify(commissionRepository, never()).save(any(Commission.class));
+        // 不匹配 practitioner 的 vipType → 按免费/C端半额处理, 仍可获得佣金
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 20000 == c.getAmount()));
+        verify(userRepository).incrementConvertedCount(200L);
     }
 
     /**
-     * C端: 推荐人无 referralCode (未付费) → 不创建佣金
+     * C端: 推荐人无 referralCode (未付费) → 免费用户也可获佣金 (L1=40%)
      */
     @Test
     void testPaySuccess_cEnd_noReferralCode_skips() {
@@ -604,8 +616,10 @@ class OrderServiceTest {
 
         User inviter = new User();
         inviter.setId(200L);
-        inviter.setReferralCode(null); // 未付费
+        inviter.setReferralCode(null); // 未付费, 但免费用户也可获得佣金
 
+        when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
+        when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
         when(orderRepository.findByOutTradeNo("trade_no_rc")).thenReturn(Optional.of(order));
         when(orderRepository.save(any(Order.class))).thenReturn(order);
         when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
@@ -613,11 +627,14 @@ class OrderServiceTest {
 
         orderService.paySuccess("trade_no_rc");
 
-        verify(commissionRepository, never()).save(any(Commission.class));
+        // 免费用户推荐也可获得 L1=40% 佣金
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 5240 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()));
     }
 
     /**
-     * C端 L2: 上级无 referralCode → 只创建 L1, 跳过 L2
+     * C端 L2: 上级无 referralCode → 免费用户也可获佣金, L1 + L2 均创建
      */
     @Test
     void testPaySuccess_cEnd_upperNoReferralCode_skipsL2() {
@@ -639,7 +656,7 @@ class OrderServiceTest {
 
         User inviter2 = new User();
         inviter2.setId(300L);
-        inviter2.setReferralCode(null); // 上级未付费
+        inviter2.setReferralCode(null); // 上级未付费, 但免费用户也可获佣金
 
         when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
         when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
@@ -651,9 +668,11 @@ class OrderServiceTest {
 
         orderService.paySuccess("trade_upper_no_rc");
 
-        // L1 创建, L2 跳过
-        verify(commissionRepository).save(argThat((Commission c) -> 1 == c.getLevel()));
-        verify(commissionRepository, never()).save(argThat((Commission c) -> 2 == c.getLevel()));
+        // L1 + L2 均创建 (referralCode 检查已移除)
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 5240 == c.getAmount()));
+        verify(commissionRepository).save(argThat((Commission c) ->
+                2 == c.getLevel() && 655 == c.getAmount()));
     }
 
     /**
@@ -897,7 +916,7 @@ class OrderServiceTest {
 
     /**
      * 场景: A(引荐人)未付费(referralCode=null) → B通过A的推广码注册 → B购买C端年费
-     * 预期: A不获得佣金 (AC: referralCode=null 即未付费的引荐人无佣金)
+     * 预期: A获得佣金 (免费用户也可获得佣金)
      */
     @Test
     void testCommissionFlow_inviterNotPaid_skipsCommission() {
@@ -917,6 +936,8 @@ class OrderServiceTest {
         order.setProductType("annual");
         order.setStatus("pending");
 
+        when(configService.getInt("commission.annual.direct_rate", 4000)).thenReturn(4000);
+        when(configService.getInt("commission.annual.upstream_rate", 500)).thenReturn(500);
         when(orderRepository.findByOutTradeNo("flow_no_pay")).thenReturn(Optional.of(order));
         when(orderRepository.save(any(Order.class))).thenReturn(order);
         when(userRepository.findById(100L)).thenReturn(Optional.of(buyer));
@@ -924,8 +945,10 @@ class OrderServiceTest {
 
         orderService.paySuccess("flow_no_pay");
 
-        // C端: inviter.referralCode=null → handleAnnualCommission 直接 return
-        verify(commissionRepository, never()).save(any(Commission.class));
+        // C端: 免费用户也可获得 L1=40% 佣金
+        verify(commissionRepository).save(argThat((Commission c) ->
+                1 == c.getLevel() && 5240 == c.getAmount()
+                        && 100L == c.getFromUserId() && 200L == c.getToUserId()));
     }
 
     /**

+ 16 - 11
num-server/src/test/java/com/etotem/num/service/RelationAnalysisServiceTest.java

@@ -87,8 +87,13 @@ class RelationAnalysisServiceTest {
         mockRecord.setCreateTime(LocalDateTime.now());
         mockRecord.setUpdateTime(LocalDateTime.now());
 
+        java.util.Map<String, Object> positions = new java.util.HashMap<>();
+        positions.put("O", 6);
+        java.util.Map<String, Object> triangleResult = new java.util.HashMap<>();
+        triangleResult.put("mainCharacter", 6);
+        triangleResult.put("positions", positions);
         when(calculatorService.calculateFullTriangle(anyInt(), anyInt(), anyInt()))
-                .thenReturn(Map.of("mainCharacter", 6, "positions", Map.of("O", 6)));
+                .thenReturn(triangleResult);
     }
 
     // ─── listRecords ────────────────────────────────────────────────
@@ -96,7 +101,7 @@ class RelationAnalysisServiceTest {
     @Test
     void testListRecords() {
         when(recordRepository.findByUserIdOrderByCreateTimeDesc(userId))
-                .thenReturn(List.of(mockRecord));
+                .thenReturn(Collections.singletonList(mockRecord));
 
         List<RelationAnalysisRecord> records = relationAnalysisService.listRecords(userId);
         assertEquals(1, records.size());
@@ -235,10 +240,10 @@ class RelationAnalysisServiceTest {
     @Test
     void testStartAnalysis_success() {
         String question = "我们之间的能量关系如何?";
-        List<Long> relationIds = List.of(10L);
+        List<Long> relationIds = Collections.singletonList(10L);
 
         when(userService.getById(userId)).thenReturn(mockUser);
-        when(relationRepository.findAllById(relationIds)).thenReturn(List.of(mockRelation));
+        when(relationRepository.findAllById(relationIds)).thenReturn(Collections.singletonList(mockRelation));
         when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
             RelationAnalysisRecord saved = invocation.getArgument(0);
             saved.setId(recordId);
@@ -263,10 +268,10 @@ class RelationAnalysisServiceTest {
     @Test
     void testStartAnalysis_noRelations() {
         String question = "我的个人能量如何?";
-        List<Long> relationIds = List.of();
+        List<Long> relationIds = Collections.emptyList();
 
         when(userService.getById(userId)).thenReturn(mockUser);
-        when(relationRepository.findAllById(relationIds)).thenReturn(List.of());
+        when(relationRepository.findAllById(relationIds)).thenReturn(Collections.emptyList());
         when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
             RelationAnalysisRecord saved = invocation.getArgument(0);
             saved.setId(recordId);
@@ -283,10 +288,10 @@ class RelationAnalysisServiceTest {
 
     @Test
     void testStartAnalysis_difyFailure() {
-        List<Long> relationIds = List.of(10L);
+        List<Long> relationIds = Collections.singletonList(10L);
 
         when(userService.getById(userId)).thenReturn(mockUser);
-        when(relationRepository.findAllById(relationIds)).thenReturn(List.of(mockRelation));
+        when(relationRepository.findAllById(relationIds)).thenReturn(Collections.singletonList(mockRelation));
         when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
             RelationAnalysisRecord saved = invocation.getArgument(0);
             saved.setId(recordId);
@@ -308,7 +313,7 @@ class RelationAnalysisServiceTest {
         when(userService.getById(userId)).thenReturn(null);
 
         assertThrows(BizException.class,
-                () -> relationAnalysisService.startAnalysis(userId, List.of(10L), "提问"));
+                () -> relationAnalysisService.startAnalysis(userId, Collections.singletonList(10L), "提问"));
     }
 
     @Test
@@ -319,7 +324,7 @@ class RelationAnalysisServiceTest {
         otherUserRelation.setName("别人的关系人");
 
         when(userService.getById(userId)).thenReturn(mockUser);
-        when(relationRepository.findAllById(List.of(99L))).thenReturn(List.of(otherUserRelation));
+        when(relationRepository.findAllById(Collections.singletonList(99L))).thenReturn(Collections.singletonList(otherUserRelation));
         when(recordRepository.save(any(RelationAnalysisRecord.class))).thenAnswer(invocation -> {
             RelationAnalysisRecord saved = invocation.getArgument(0);
             saved.setId(recordId);
@@ -328,7 +333,7 @@ class RelationAnalysisServiceTest {
         when(difyService.invokeRelationChatflow(anyString(), anyString(), anyString()))
                 .thenReturn("ok");
 
-        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, List.of(99L), "提问");
+        RelationAnalysisRecord result = relationAnalysisService.startAnalysis(userId, Collections.singletonList(99L), "提问");
         assertNotNull(result);
         // Title uses the raw relations list (before ownership filtering), so includes "别人的关系人"
         assertTrue(result.getTitle().contains("别人的关系人"));

+ 6 - 5
num-server/src/test/java/com/etotem/num/service/WithdrawServiceTest.java

@@ -94,7 +94,8 @@ class WithdrawServiceTest {
         Withdraw approved = new Withdraw();
         approved.setId(2L);
         approved.setStatus("approved");
-        when(withdrawRepository.findAll()).thenReturn(Arrays.asList(pending, approved));
+        when(withdrawRepository.findByStatusIn(Collections.singletonList("pending")))
+                .thenReturn(Arrays.asList(pending));
 
         List<Withdraw> result = withdrawService.getAllPending();
         assertEquals(1, result.size());
@@ -110,16 +111,16 @@ class WithdrawServiceTest {
 
         withdrawService.approve(1L);
 
-        assertEquals("approved", w.getStatus());
-        verify(withdrawRepository).save(w);
+        // approve sets status to "approved" then immediately to "queued" (进入排队)
+        assertEquals("queued", w.getStatus());
+        verify(withdrawRepository, times(2)).save(w);
     }
 
     @Test
     void testApprove_notFound() {
         when(withdrawRepository.findById(999L)).thenReturn(Optional.empty());
 
-        withdrawService.approve(999L); // should not throw
-
+        assertThrows(BizException.class, () -> withdrawService.approve(999L));
         verify(withdrawRepository, never()).save(any());
     }
 }