Browse Source

feat(cf): F-7a 新增 C 端 CF 值余额/流水/家庭券查询接口

Sisyphus 2 weeks ago
parent
commit
76230e2f4

+ 119 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/CfBalanceController.java

@@ -0,0 +1,119 @@
+package com.etotem.cfc.controller;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.dto.CfBalanceLogVO;
+import com.etotem.cfc.dto.CfBalanceView;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.CfBalanceService;
+import com.etotem.cfc.service.CouponService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * C 端 CF 值查询接口(只读)。
+ *
+ * <p>个人 CF 值账本为成员维度(family_members.id),本控制器把「登录 userId → memberId」
+ * 的解析口径与 {@code CfEarnDispatchService}(F-3a)保持一致:按 user_id 在家庭内匹配,
+ * 取最小 id 的一条(LIMIT 1);找不到对应家庭成员时返回明确错误码,不返回空数据。</p>
+ *
+ * <p>仅提供三个只读接口:余额快照 / 流水分页 / 家庭共享优惠券列表,不提供任何写接口。
+ * 余额与流水走统一账本 {@link CfBalanceService};家庭券复用 {@link CouponService#listMyCoupons}
+ * (N3 家庭化口径:family_coupon + coupon 联合,家庭内成员共享可见)。</p>
+ *
+ * <p>接口约定见 docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §七 F-7a。</p>
+ */
+@RestController
+@RequestMapping("/api/cf")
+public class CfBalanceController {
+
+    @Resource
+    private CfBalanceService cfBalanceService;
+
+    @Resource
+    private CouponService couponService;
+
+    @Resource
+    private FamilyMemberMapper familyMemberMapper;
+
+    /**
+     * 我的 CF 值快照:可用余额 / 冻结 / 累计获得 / 已提现 / 已兑换消费。
+     */
+    @GetMapping("/balance")
+    public Result<CfBalanceView> balance(@RequestAttribute("userId") Long userId) {
+        Long memberId = resolveMemberId(userId);
+        if (memberId == null) {
+            return Result.error(404, "未找到当前用户对应的家庭成员,无法查询 CF 值");
+        }
+        return Result.success(cfBalanceService.getBalance(memberId));
+    }
+
+    /**
+     * 我的 CF 流水分页(按时间倒序),列表项含 amount / balanceAfter / type / refType / remark / createdAt。
+     */
+    @GetMapping("/logs")
+    public Result<Page<CfBalanceLogVO>> logs(@RequestAttribute("userId") Long userId,
+                                             @RequestParam(defaultValue = "1") Integer page,
+                                             @RequestParam(defaultValue = "20") Integer size) {
+        Long memberId = resolveMemberId(userId);
+        if (memberId == null) {
+            return Result.error(404, "未找到当前用户对应的家庭成员,无法查询 CF 流水");
+        }
+        Page<CfBalanceLog> raw = cfBalanceService.getLogs(memberId, page, size);
+        List<CfBalanceLog> records = raw.getRecords() == null ? Collections.emptyList() : raw.getRecords();
+        Page<CfBalanceLogVO> vo = new Page<>(raw.getCurrent(), raw.getSize());
+        vo.setTotal(raw.getTotal());
+        vo.setPages(raw.getPages());
+        vo.setRecords(records.stream().map(this::toLogVO).collect(Collectors.toList()));
+        return Result.success(vo);
+    }
+
+    /**
+     * 家庭共享优惠券列表(N3 口径):按登录用户所属家庭查券实例 + 券模板,
+     * 返回券名 / 状态 / 有效期 / 抵扣面额等展示字段,家庭内成员共享可见。
+     */
+    @GetMapping("/family-coupons")
+    public Result<List<Map<String, Object>>> familyCoupons(@RequestAttribute("userId") Long userId) {
+        return Result.success(couponService.listMyCoupons(userId));
+    }
+
+    private CfBalanceLogVO toLogVO(CfBalanceLog log) {
+        CfBalanceLogVO vo = new CfBalanceLogVO();
+        vo.setId(log.getId());
+        vo.setType(log.getType());
+        vo.setRefType(log.getRefType());
+        vo.setAmount(log.getAmount());
+        vo.setBalanceAfter(log.getBalanceAfter());
+        vo.setRemark(log.getRemark());
+        vo.setCreatedAt(log.getCreatedAt());
+        return vo;
+    }
+
+    /**
+     * 登录 userId → 家庭成员(family_members.id)。
+     * 口径与 {@code CfEarnDispatchService} 的 resolveMemberId 一致:user_id 匹配 +
+     * 按 id 升序取第一条(LIMIT 1),未命中返回 null。
+     */
+    private Long resolveMemberId(Long userId) {
+        if (userId == null || userId <= 0) {
+            return null;
+        }
+        FamilyMember member = familyMemberMapper.selectOne(new LambdaQueryWrapper<FamilyMember>()
+                .eq(FamilyMember::getUserId, userId)
+                .orderByAsc(FamilyMember::getId)
+                .last("LIMIT 1"));
+        return member == null ? null : member.getId();
+    }
+}

+ 41 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/CfBalanceLogVO.java

@@ -0,0 +1,41 @@
+package com.etotem.cfc.dto;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * CF 值流水只读视图(C 端展示用)。
+ *
+ * <p>由 {@code cf_balance_log} 实体转出,字段对齐前端 wealth-cf 页展示口径:
+ * amount(带符号变动)/ balanceAfter(变动后可用余额)/ type(earn/spend/freeze/unfreeze/
+ * withdraw/adjust/skip)/ refType(规则编码或迁移标识)/ remark(备注)/ createdAt(发生时间)。</p>
+ *
+ * <p>只读 DTO,不参与任何写操作;口径见
+ * docs/superpowers/plans/2026-10-24-cf-value-unification-plan.md §5.2。</p>
+ */
+@Data
+public class CfBalanceLogVO implements Serializable {
+
+    /** 流水ID */
+    private Long id;
+
+    /** earn/spend/freeze/unfreeze/withdraw/adjust/skip */
+    private String type;
+
+    /** 规则编码或迁移标识(F02~F24 / migration_*) */
+    private String refType;
+
+    /** 变动数量(带符号:正=可用余额增加,负=可用余额减少) */
+    private Integer amount;
+
+    /** 变动后可用余额 */
+    private Integer balanceAfter;
+
+    /** 备注 */
+    private String remark;
+
+    /** 发生时间(ISO 8601) */
+    private Date createdAt;
+}

+ 234 - 0
cfc-backend/src/test/java/com/etotem/cfc/unit/CfBalanceControllerTest.java

@@ -0,0 +1,234 @@
+package com.etotem.cfc.unit;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.controller.CfBalanceController;
+import com.etotem.cfc.dto.CfBalanceLogVO;
+import com.etotem.cfc.dto.CfBalanceView;
+import com.etotem.cfc.entity.CfBalanceLog;
+import com.etotem.cfc.entity.FamilyMember;
+import com.etotem.cfc.mapper.FamilyMemberMapper;
+import com.etotem.cfc.service.CfBalanceService;
+import com.etotem.cfc.service.CouponService;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+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.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * C 端 CF 查询接口单测(F-7a)。
+ *
+ * <p>覆盖:登录 userId → memberId 解析(命中/未命中/查询口径 LIMIT 1)、
+ * balance / logs 响应结构、家庭券列表透传。</p>
+ */
+class CfBalanceControllerTest {
+
+    private CfBalanceController controller;
+    private CfBalanceService cfBalanceService;
+    private CouponService couponService;
+    private FamilyMemberMapper familyMemberMapper;
+
+    @BeforeAll
+    static void initMybatisPlusTableInfo() {
+        // 初始化 MP TableInfo 缓存,使 LambdaQueryWrapper.getSqlSegment() 在无 Spring 上下文中可用
+        TableInfoHelper.initTableInfo(
+                new MapperBuilderAssistant(new MybatisConfiguration(), ""), FamilyMember.class);
+    }
+
+    @BeforeEach
+    void setUp() throws Exception {
+        controller = new CfBalanceController();
+        cfBalanceService = mock(CfBalanceService.class);
+        couponService = mock(CouponService.class);
+        familyMemberMapper = mock(FamilyMemberMapper.class);
+        inject(controller, "cfBalanceService", cfBalanceService);
+        inject(controller, "couponService", couponService);
+        inject(controller, "familyMemberMapper", familyMemberMapper);
+    }
+
+    private static void inject(Object target, String field, Object value) throws Exception {
+        Field f = target.getClass().getDeclaredField(field);
+        f.setAccessible(true);
+        f.set(target, value);
+    }
+
+    private FamilyMember member(Long id, Long userId, Long familyId) {
+        FamilyMember m = new FamilyMember();
+        m.setId(id);
+        m.setUserId(userId);
+        m.setFamilyId(familyId);
+        return m;
+    }
+
+    // ==================== memberId 解析(命中/未命中/口径) ====================
+
+    @Test
+    void resolveMemberId_命中_按user_id取最小id() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 7L, 9L));
+        CfBalanceView view = new CfBalanceView();
+        view.setMemberId(42L);
+        when(cfBalanceService.getBalance(42L)).thenReturn(view);
+
+        Result<CfBalanceView> r = controller.balance(7L);
+
+        assertEquals(200, r.getCode());
+        assertEquals(42L, r.getData().getMemberId());
+
+        // 口径与 CfEarnDispatchService.resolveMemberId 一致:user_id 匹配 + LIMIT 1
+        ArgumentCaptor<LambdaQueryWrapper<FamilyMember>> captor =
+                ArgumentCaptor.forClass(LambdaQueryWrapper.class);
+        verify(familyMemberMapper).selectOne(captor.capture());
+        String sql = captor.getValue().getSqlSegment();
+        assertTrue(sql.contains("user_id"), "应按 user_id 匹配,实际: " + sql);
+        assertTrue(sql.toUpperCase().contains("LIMIT 1"), "应带 LIMIT 1,实际: " + sql);
+    }
+
+    @Test
+    void resolveMemberId_未命中_返回404且不查账本() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        Result<CfBalanceView> r = controller.balance(99L);
+
+        assertEquals(404, r.getCode());
+        verify(cfBalanceService, never()).getBalance(anyLong());
+    }
+
+    @Test
+    void resolveMemberId_userId非法_返回404() {
+        Result<CfBalanceView> r = controller.balance(-1L);
+        assertEquals(404, r.getCode());
+        verify(familyMemberMapper, never()).selectOne(any());
+    }
+
+    // ==================== balance 响应结构 ====================
+
+    @Test
+    void balance_命中_快照字段透传() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 7L, 9L));
+        CfBalanceView view = new CfBalanceView();
+        view.setMemberId(42L);
+        view.setAvailable(100);
+        view.setFrozen(10);
+        view.setTotalEarned(500);
+        view.setTotalWithdrawn(20);
+        view.setExchanged(370);
+        when(cfBalanceService.getBalance(42L)).thenReturn(view);
+
+        Result<CfBalanceView> r = controller.balance(7L);
+        CfBalanceView data = r.getData();
+
+        assertEquals(200, r.getCode());
+        assertEquals(42L, data.getMemberId());
+        assertEquals(100, data.getAvailable());
+        assertEquals(10, data.getFrozen());
+        assertEquals(500, data.getTotalEarned());
+        assertEquals(20, data.getTotalWithdrawn());
+        assertEquals(370, data.getExchanged());
+    }
+
+    // ==================== logs 响应结构 ====================
+
+    @Test
+    void logs_命中_分页与流水字段映射() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 7L, 9L));
+        CfBalanceLog log = new CfBalanceLog();
+        log.setId(1L);
+        log.setType("earn");
+        log.setRefType("F02");
+        log.setAmount(100);
+        log.setBalanceAfter(200);
+        log.setRemark("消费返CF");
+        log.setCreatedAt(new Date());
+        Page<CfBalanceLog> raw = new Page<>(1, 20, 1L);
+        raw.setRecords(Collections.singletonList(log));
+        when(cfBalanceService.getLogs(42L, 1, 20)).thenReturn(raw);
+
+        Result<Page<CfBalanceLogVO>> r = controller.logs(7L, 1, 20);
+
+        assertEquals(200, r.getCode());
+        Page<CfBalanceLogVO> data = r.getData();
+        assertEquals(1L, data.getTotal());
+        assertEquals(1, data.getRecords().size());
+        CfBalanceLogVO vo = data.getRecords().get(0);
+        assertEquals(1L, vo.getId());
+        assertEquals("earn", vo.getType());
+        assertEquals("F02", vo.getRefType());
+        assertEquals(100, vo.getAmount());
+        assertEquals(200, vo.getBalanceAfter());
+        assertEquals("消费返CF", vo.getRemark());
+        assertNotNull(vo.getCreatedAt());
+    }
+
+    @Test
+    void logs_未命中_返回404且不查流水() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(null);
+
+        Result<Page<CfBalanceLogVO>> r = controller.logs(99L, 1, 20);
+
+        assertEquals(404, r.getCode());
+        verify(cfBalanceService, never()).getLogs(anyLong(), anyInt(), anyInt());
+    }
+
+    @Test
+    void logs_空流水_返回空列表() {
+        when(familyMemberMapper.selectOne(any())).thenReturn(member(42L, 7L, 9L));
+        Page<CfBalanceLog> raw = new Page<>(1, 20, 0L);
+        raw.setRecords(Collections.emptyList());
+        when(cfBalanceService.getLogs(42L, 1, 20)).thenReturn(raw);
+
+        Result<Page<CfBalanceLogVO>> r = controller.logs(7L, 1, 20);
+
+        assertEquals(200, r.getCode());
+        assertNotNull(r.getData().getRecords());
+        assertEquals(0, r.getData().getRecords().size());
+    }
+
+    // ==================== family-coupons ====================
+
+    @Test
+    void familyCoupons_返回家庭共享券列表() {
+        Map<String, Object> coupon1 = new HashMap<>();
+        coupon1.put("id", 11L);
+        coupon1.put("name", "满100减10");
+        coupon1.put("status", "AVAILABLE");
+        coupon1.put("value", 1000);
+        coupon1.put("validUntil", new Date());
+        Map<String, Object> coupon2 = new HashMap<>();
+        coupon2.put("id", 12L);
+        coupon2.put("name", "9折券");
+        coupon2.put("status", "USED");
+        when(couponService.listMyCoupons(7L))
+                .thenReturn(Arrays.asList(coupon1, coupon2));
+
+        Result<List<Map<String, Object>>> r = controller.familyCoupons(7L);
+
+        assertEquals(200, r.getCode());
+        assertEquals(2, r.getData().size());
+        assertEquals("满100减10", r.getData().get(0).get("name"));
+        assertEquals("USED", r.getData().get(1).get("status"));
+        verify(couponService).listMyCoupons(7L);
+    }
+}

+ 19 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -71,6 +71,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `StatsController` | `/api/stats` | 统计 | — |
 | `CfCommissionController` | `/api/commission/cf` | CF 分佣(团队规模/比例/钱包/流水) | — |
 | `CfTransferController` | `/api/cf/transfer` | CF 成员间转让 | — |
+| `CfBalanceController` | `/api/cf` | C 端 CF 查询(余额快照/流水/家庭券,只读) | F-7a |
 | `AdminCfRateTierController` | `/api/admin/cf-rate-tier` | CF 返佣阶梯配置(admin) | — |
 | `ContactController` | `/api/contact` | 联系人 CRUD/互动/邀请入家庭 | — |
 | `ContactMatchController` | `/api/contact/match` | 联系人对接(send/accept/reject/列表) | — |
@@ -2152,3 +2153,21 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 - `cf_rate_tier` 表保留只读,仅供等级展示(`PromotionTierService` / 管理端档位配置)使用。
 
 *文档更新:2026-10-24(CF 值体系统一 F-1 数据层 + W0.1 配置归口:8 张新表/迁移407-412 + service_config 与 cf_earn_rule 管理端接口);同日补登 W0.2 双配置中心合并(service_config 收编 product_config:迁移413 + product_key 维度 + product-config 接口 410 Gone + 配置中心统一入口);同日补登 F-6 存量迁移(迁移417 + 三套旧账本幂等迁入 cf_balance);2026-10-25 补登 F-4 分佣收口(迁移418/420 + 两级销售奖档位 + /rate 扩展字段 + CommissionDistService.distribute 410 Gone)*
+
+### 6.6 C 端 CF 查询接口(F-7a,只读)
+
+> 口径:计划 §七 F-7a。个人 CF 值账本为成员维度(`family_members.id`),登录 `userId → memberId`
+> 解析与 `CfEarnDispatchService.resolveMemberId`(F-3a)口径一致:`user_id` 匹配 + 按 `id` 升序
+> `LIMIT 1`;未找到对应家庭成员时返回 `Result.error(404, "未找到当前用户对应的家庭成员,...")`,不返回空数据。
+>
+> 控制器:`CfBalanceController`,前缀 `/api/cf`(**GET,需登录**;注:本项目接口惯例为 POST,本组
+> 只读接口按 F-7a 任务要求采用 GET,若团队统一要求 POST 可一行改回)。
+
+| 路径 | 说明 |
+|---|---|
+| `GET /api/cf/balance` | 我的 CF 值快照。按登录用户解析 memberId 后读 `cf_balance`,返回 `CfBalanceView`:`memberId` / `available`(可用) / `frozen`(冻结) / `totalEarned`(累计获得) / `totalWithdrawn`(已提现) / `exchanged`(已兑换消费)。无 `cf_balance` 记录时返回全 0 |
+| `GET /api/cf/logs?page=1&size=20` | 我的 CF 流水分页(时间倒序),复用 `CfBalanceService.getLogs(memberId, page, size)`,输出转 `CfBalanceLogVO`:`id` / `type`(earn/spend/freeze/unfreeze/withdraw/adjust/skip) / `refType`(规则编码或迁移标识) / `amount`(带符号变动) / `balanceAfter`(变动后可用余额) / `remark` / `createdAt`(ISO 8601)。返回 MyBatis-Plus `Page` 结构(current/size/total/pages/records) |
+| `GET /api/cf/family-coupons` | 家庭共享优惠券列表(N3 口径)。复用 `CouponService.listMyCoupons(userId)`:按登录用户所属家庭查 `family_coupon` + `coupon` 联合数据(券家庭化全链路已切换至 `family_coupon`,`user_coupon` 为只读历史表),返回 `userCouponId` / `couponId` / `name`(券名) / `type` / `value`(面额,分) / `discountRate` / `minSpend` / `applicableTo` / `status`(AVAILABLE/USED/EXPIRED/DISABLED) / `validFrom` / `validUntil`(有效期) / `receivedAt` / `usedAt` / `orderId` / `productId`,家庭内成员共享可见 |
+
+- 实现走统一账本 `CfBalanceService` / `CouponService`,本控制器不直接写库、不提供任何写接口。
+- 流水/余额响应结构单测:`cfc-backend/src/test/java/com/etotem/cfc/unit/CfBalanceControllerTest.java`(memberId 解析命中/未命中 + balance/logs 字段映射)。