Просмотр исходного кода

fix(admin): B-4 修复管理端列表 envelope 契约不一致 + commission config 更新 400

F1(butler 列表 envelope 统一为 data.records):
- AdminButlerController 的 user-list / list / service-records / settlements 4 个列表
  由 data.put("list") 改为 data.put("records"),与活动列表等其他管理端列表契约对齐;
  assignments 本身已返回 records,无需改。
- 前端消费点 cfc-web ButlerAudit.vue(读 /butler/user-list)同步由 data.list 改为 data.records;
  ButlerAssignments.vue 读 data.records 本就一致。grep 确认仓库内无其他 .data.list 消费点。
- 新增契约单测 AdminButlerControllerEnvelopeTest(4 case 断言 records 非空、list 键不存在)。

F2(POST /api/admin/commission/config/update 400 根因与修复):
- 根因:E2E payload({percent,ratio,label,status}) 与实体字段(name/rate/rateType/remark)不匹配,
  id=null 走 insert 时 name(表 NOT NULL)缺失触发数据完整性异常返回 400。
- 修复:updateConfig 先做参数校验——空 body/缺 name 返回 400 并给出明确 message(非 500),
  合法 body 才走 insert/updateById 并返回 200,更新真实落库。
- E2E payload 修正为实体字段;新增契约单测 AdminCommissionConfigControllerTest(5 case:
  合法 insert/update → 200+落库验证;缺 name/空 body → 400+message)。

验证:mvn compile 通过;mvn test(2 新测试类 9/9)通过;本地实跑确认
butler 5 个列表全返回 records、commission 合法 200/缺字段 400 且更新后回查新值;
cfc-web npm run build 通过。
Sisyphus 1 неделя назад
Родитель
Сommit
c6acdbd370

+ 4 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminButlerController.java

@@ -77,7 +77,7 @@ public class AdminButlerController {
             list.add(m);
             list.add(m);
         }
         }
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
-        data.put("list", list);
+        data.put("records", list);
         data.put("total", result.getTotal());
         data.put("total", result.getTotal());
         return Result.success(data);
         return Result.success(data);
     }
     }
@@ -109,7 +109,7 @@ public class AdminButlerController {
         Page<ButlerProfile> result = butlerProfileMapper.selectPage(pageParam, wrapper);
         Page<ButlerProfile> result = butlerProfileMapper.selectPage(pageParam, wrapper);
 
 
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
-        data.put("list", result.getRecords());
+        data.put("records", result.getRecords());
         data.put("total", result.getTotal());
         data.put("total", result.getTotal());
         data.put("page", page);
         data.put("page", page);
         data.put("size", size);
         data.put("size", size);
@@ -181,7 +181,7 @@ public class AdminButlerController {
         Page<ButlerServiceRecord> result = butlerServiceRecordMapper.selectPage(pageParam, wrapper);
         Page<ButlerServiceRecord> result = butlerServiceRecordMapper.selectPage(pageParam, wrapper);
 
 
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
-        data.put("list", result.getRecords());
+        data.put("records", result.getRecords());
         data.put("total", result.getTotal());
         data.put("total", result.getTotal());
         data.put("page", page);
         data.put("page", page);
         data.put("size", size);
         data.put("size", size);
@@ -211,7 +211,7 @@ public class AdminButlerController {
         Page<ButlerCommissionSettlement> result = butlerCommissionSettlementMapper.selectPage(pageParam, wrapper);
         Page<ButlerCommissionSettlement> result = butlerCommissionSettlementMapper.selectPage(pageParam, wrapper);
 
 
         Map<String, Object> data = new HashMap<>();
         Map<String, Object> data = new HashMap<>();
-        data.put("list", result.getRecords());
+        data.put("records", result.getRecords());
         data.put("total", result.getTotal());
         data.put("total", result.getTotal());
         data.put("page", page);
         data.put("page", page);
         data.put("size", size);
         data.put("size", size);

+ 9 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminCommissionConfigController.java

@@ -30,7 +30,15 @@ public class AdminCommissionConfigController {
 
 
     @Operation(summary = "更新佣金配置")
     @Operation(summary = "更新佣金配置")
     @PostMapping("/config/update")
     @PostMapping("/config/update")
-    public Result<String> updateConfig(@RequestBody RebateCommissionConfig config) {
+    public Result<String> updateConfig(@RequestBody(required = false) RebateCommissionConfig config) {
+        // B-4/F2:空 body 与必填字段缺失属于参数校验 → 明确 400 业务提示,绝不 500;
+        // name 为表 NOT NULL 约束字段(rebate_commission_config.name),缺失时先于 DB 层拦截。
+        if (config == null) {
+            return Result.error(400, "请求体不能为空");
+        }
+        if (config.getName() == null || config.getName().trim().isEmpty()) {
+            return Result.error(400, "配置名称(name)不能为空");
+        }
         if (config.getId() != null) {
         if (config.getId() != null) {
             config.setUpdatedAt(new Date());
             config.setUpdatedAt(new Date());
             rebateCommissionConfigMapper.updateById(config);
             rebateCommissionConfigMapper.updateById(config);

+ 139 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminButlerControllerEnvelopeTest.java

@@ -0,0 +1,139 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.ButlerCommissionSettlement;
+import com.etotem.cfc.entity.ButlerProfile;
+import com.etotem.cfc.entity.ButlerServiceRecord;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ButlerCommissionSettlementMapper;
+import com.etotem.cfc.mapper.ButlerProfileMapper;
+import com.etotem.cfc.mapper.ButlerServiceRecordMapper;
+import com.etotem.cfc.mapper.UserMapper;
+import com.etotem.cfc.service.ButlerService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * B-4/F1 契约级单测:管理端管家列表 envelope 必须为 data.records(与其他管理端列表统一),
+ * 不再返回 data.list。覆盖 user-list / list / service-records / settlements 四个列表方法。
+ *
+ * 背景:自动化测试断言 j.data.records,butler 接口此前返回 data.list 导致取空崩溃(F1)。
+ */
+public class AdminButlerControllerEnvelopeTest {
+
+    private AdminButlerController controller;
+    private ButlerService butlerService;
+    private ButlerProfileMapper butlerProfileMapper;
+    private ButlerServiceRecordMapper butlerServiceRecordMapper;
+    private ButlerCommissionSettlementMapper butlerCommissionSettlementMapper;
+    private UserMapper userMapper;
+
+    @BeforeEach
+    public void setUp() {
+        controller = new AdminButlerController();
+        butlerService = mock(ButlerService.class);
+        butlerProfileMapper = mock(ButlerProfileMapper.class);
+        butlerServiceRecordMapper = mock(ButlerServiceRecordMapper.class);
+        butlerCommissionSettlementMapper = mock(ButlerCommissionSettlementMapper.class);
+        userMapper = mock(UserMapper.class);
+        ReflectionTestUtils.setField(controller, "butlerService", butlerService);
+        ReflectionTestUtils.setField(controller, "butlerProfileMapper", butlerProfileMapper);
+        ReflectionTestUtils.setField(controller, "butlerServiceRecordMapper", butlerServiceRecordMapper);
+        ReflectionTestUtils.setField(controller, "butlerCommissionSettlementMapper", butlerCommissionSettlementMapper);
+        ReflectionTestUtils.setField(controller, "userMapper", userMapper);
+    }
+
+    private Map<String, Object> baseParams() {
+        Map<String, Object> p = new HashMap<>();
+        p.put("page", 1);
+        p.put("size", 10);
+        return p;
+    }
+
+    private void assertRecordsEnvelope(Result<Map<String, Object>> r, int expectTotal) {
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData(), "data 不能为 null");
+        assertNotNull(r.getData().get("records"), "列表 envelope 必须包含 data.records");
+        assertNull(r.getData().get("list"), "列表 envelope 不得再返回 data.list(契约统一为 records)");
+        assertEquals(expectTotal, ((Number) r.getData().get("total")).longValue());
+    }
+
+    @Test
+    public void userList_returnsRecordsEnvelope() {
+        Page<User> page = new Page<>(1, 10);
+        User u = new User();
+        u.setId(1001L);
+        u.setNickname("管家A");
+        u.setButlerStatus("pending");
+        page.setRecords(java.util.Collections.singletonList(u));
+        page.setTotal(1);
+        when(userMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Map<String, Object> params = baseParams();
+        params.put("status", "pending");
+        Result<Map<String, Object>> r = controller.userList(params, "admin");
+        assertRecordsEnvelope(r, 1);
+    }
+
+    @Test
+    public void list_returnsRecordsEnvelope() {
+        Page<ButlerProfile> page = new Page<>(1, 10);
+        ButlerProfile bp = new ButlerProfile();
+        bp.setId(2001L);
+        bp.setUserId(1001L);
+        bp.setTier("L1");
+        bp.setStatus("pending");
+        page.setRecords(java.util.Collections.singletonList(bp));
+        page.setTotal(1);
+        when(butlerProfileMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Map<String, Object> params = baseParams();
+        params.put("status", "pending");
+        Result<Map<String, Object>> r = controller.list(params, "admin");
+        assertRecordsEnvelope(r, 1);
+        assertEquals(1, ((Number) r.getData().get("page")).intValue());
+        assertEquals(10, ((Number) r.getData().get("size")).intValue());
+    }
+
+    @Test
+    public void serviceRecords_returnsRecordsEnvelope() {
+        Page<ButlerServiceRecord> page = new Page<>(1, 10);
+        ButlerServiceRecord rec = new ButlerServiceRecord();
+        rec.setId(3001L);
+        rec.setServiceType("annual");
+        rec.setCreatedAt(new Date());
+        page.setRecords(java.util.Collections.singletonList(rec));
+        page.setTotal(1);
+        when(butlerServiceRecordMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> r = controller.serviceRecords(baseParams(), "admin");
+        assertRecordsEnvelope(r, 1);
+    }
+
+    @Test
+    public void settlements_returnsRecordsEnvelope() {
+        Page<ButlerCommissionSettlement> page = new Page<>(1, 10);
+        ButlerCommissionSettlement s = new ButlerCommissionSettlement();
+        s.setId(4001L);
+        s.setStatus("settled");
+        page.setRecords(java.util.Collections.singletonList(s));
+        page.setTotal(1);
+        when(butlerCommissionSettlementMapper.selectPage(any(Page.class), any())).thenReturn(page);
+
+        Result<Map<String, Object>> r = controller.settlements(baseParams(), "admin");
+        assertRecordsEnvelope(r, 1);
+    }
+}

+ 128 - 0
cfc-backend/src/test/java/com/etotem/cfc/controller/admin/AdminCommissionConfigControllerTest.java

@@ -0,0 +1,128 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.RebateCommissionConfig;
+import com.etotem.cfc.mapper.RebateCommissionConfigMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * B-4/F2 契约级单测:POST /api/admin/commission/config/update
+ *  - 合法 body(必填 name 齐全)→ 200 且真实落库(insert/updateById 被调用、时间戳写入)
+ *  - 缺必填(name 缺失/空白)→ 400 且 message 有解释(绝不 500)
+ *  - 空请求体 → 400 且 message 有解释
+ *
+ * 背景:E2E 曾返回 400 —— payload 字段与实体不匹配(percent/ratio/label/status 非实体字段),
+ * 导致 name(表 NOT NULL)缺失 → 插入触发数据完整性异常返回 400。修复后:
+ * 先明确校验必填字段给出业务 400;合法 body 才走 insert/updateById。
+ */
+public class AdminCommissionConfigControllerTest {
+
+    private AdminCommissionConfigController controller;
+    private RebateCommissionConfigMapper rebateCommissionConfigMapper;
+
+    @BeforeEach
+    public void setUp() {
+        controller = new AdminCommissionConfigController();
+        rebateCommissionConfigMapper = mock(RebateCommissionConfigMapper.class);
+        ReflectionTestUtils.setField(controller, "rebateCommissionConfigMapper", rebateCommissionConfigMapper);
+    }
+
+    @Test
+    public void updateConfig_validBodyWithoutId_insertsAndReturns200() {
+        RebateCommissionConfig config = new RebateCommissionConfig();
+        config.setName("e2e-佣金配置");
+        config.setRate(10);
+        config.setRateType(1);
+        config.setRemark("up");
+
+        Result<String> r = controller.updateConfig(config);
+
+        assertEquals(200, r.getCode());
+        ArgumentCaptor<RebateCommissionConfig> captor = ArgumentCaptor.forClass(RebateCommissionConfig.class);
+        verify(rebateCommissionConfigMapper, times(1)).insert(captor.capture());
+        assertEquals("e2e-佣金配置", captor.getValue().getName());
+        assertEquals(10, captor.getValue().getRate());
+        assertNotNull(captor.getValue().getCreatedAt(), "insert 必须写入 createdAt");
+        assertNotNull(captor.getValue().getUpdatedAt(), "insert 必须写入 updatedAt");
+        verify(rebateCommissionConfigMapper, never()).updateById(any());
+    }
+
+    @Test
+    public void updateConfig_validBodyWithId_updatesAndReturns200() {
+        RebateCommissionConfig config = new RebateCommissionConfig();
+        config.setId(42L);
+        config.setName("e2e-更新配置");
+        config.setRate(15);
+
+        Result<String> r = controller.updateConfig(config);
+
+        assertEquals(200, r.getCode());
+        ArgumentCaptor<RebateCommissionConfig> captor = ArgumentCaptor.forClass(RebateCommissionConfig.class);
+        verify(rebateCommissionConfigMapper, times(1)).updateById(captor.capture());
+        assertEquals(42L, captor.getValue().getId());
+        assertEquals(15, captor.getValue().getRate());
+        assertNotNull(captor.getValue().getUpdatedAt(), "update 必须写入 updatedAt");
+        verify(rebateCommissionConfigMapper, never()).insert(any());
+    }
+
+    @Test
+    public void updateConfig_missingRequiredName_returns400WithMessage() {
+        // id 为空 → insert 分支;name 为必填缺失 → 400 业务提示,绝不可 500
+        RebateCommissionConfig config = new RebateCommissionConfig();
+        Result<String> r = controller.updateConfig(config);
+
+        assertEquals(400, r.getCode());
+        assertNotNull(r.getMessage());
+        assertFalse(r.getMessage().isEmpty());
+        verify(rebateCommissionConfigMapper, never()).insert(any());
+        verify(rebateCommissionConfigMapper, never()).updateById(any());
+
+        // 空白 name 同样拒绝
+        RebateCommissionConfig blank = new RebateCommissionConfig();
+        blank.setName("   ");
+        Result<String> r2 = controller.updateConfig(blank);
+        assertEquals(400, r2.getCode());
+        assertNotNull(r2.getMessage());
+        assertFalse(r2.getMessage().isEmpty());
+        verify(rebateCommissionConfigMapper, never()).insert(any());
+        verify(rebateCommissionConfigMapper, never()).updateById(any());
+    }
+
+    @Test
+    public void updateConfig_nullBody_returns400WithMessage() {
+        Result<String> r = controller.updateConfig(null);
+
+        assertEquals(400, r.getCode());
+        assertNotNull(r.getMessage());
+        assertFalse(r.getMessage().isEmpty());
+        verify(rebateCommissionConfigMapper, never()).insert(any());
+        verify(rebateCommissionConfigMapper, never()).updateById(any());
+    }
+
+    @Test
+    public void updateConfig_ignoredUnknownFields_reverseContractPinned() {
+        // 固定反面契约:与实体不匹配的历史 payload(label/percent 等)序列化进实体时
+        // 只映射实体字段,其余被忽略;name 缺失仍被业务校验拦截为 400(这正是线上 400 的根因形态)。
+        RebateCommissionConfig config = new RebateCommissionConfig();
+        config.setRate(10);
+        config.setRemark("up");
+        // 模拟旧 E2E payload 只填 remark/rate、缺 name 的情况
+        Result<String> r = controller.updateConfig(config);
+        assertEquals(400, r.getCode());
+        assertTrue(r.getMessage().contains("name"), "message 应指明缺失的必填字段 name");
+        verify(rebateCommissionConfigMapper, never()).insert(any());
+    }
+}

+ 1 - 1
cfc-web/src/views/admin/review/ButlerAudit.vue

@@ -171,7 +171,7 @@ export default {
           getButlerUserList({ status: this.statusFilter || undefined, page: 1, size: 500 }),
           getButlerUserList({ status: this.statusFilter || undefined, page: 1, size: 500 }),
           listServiceRoleApplications({ status: this.serviceRoleStatusParam(), role: 'butler' })
           listServiceRoleApplications({ status: this.serviceRoleStatusParam(), role: 'butler' })
         ])
         ])
-        const records = (userRes && userRes.data && userRes.data.list) || []
+        const records = (userRes && userRes.data && userRes.data.records) || []
         this.userRows = records.map(r => {
         this.userRows = records.map(r => {
           const info = this.parseButlerInfo(r.butlerInfo)
           const info = this.parseButlerInfo(r.butlerInfo)
           return {
           return {

+ 3 - 1
tests/e2e/admin-commission-config.spec.js

@@ -22,8 +22,10 @@ test.describe('commission config', () => {
   });
   });
   test('update config', async ({ page }) => {
   test('update config', async ({ page }) => {
     const t = await login(page);
     const t = await login(page);
+    // B-4/F2:实体字段为 name/rate/rateType/remark(rebate_commission_config 表 name NOT NULL)。
+    // 旧 payload({percent,ratio,label,status}) 与实体不匹配 → name 缺失 → 插入违反非空约束返回 400。
     const res = await page.request.post(base() + '/api/admin/commission/config/update', {
     const res = await page.request.post(base() + '/api/admin/commission/config/update', {
-      data: { id: null, percent: 10, ratio: 1, label: 'e2e-' + Date.now(), status: 1, remark: 'up' },
+      data: { id: null, name: 'e2e-' + Date.now(), rate: 10, rateType: 1, remark: 'up' },
       headers: auth(t)
       headers: auth(t)
     });
     });
     expect(res.ok()).toBeTruthy();
     expect(res.ok()).toBeTruthy();