Эх сурвалжийг харах

feat(service): SpecialPriceService 核心服务与状态机测试

Sisyphus Agent 5 өдөр өмнө
parent
commit
90921632b4

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/SpecialPriceDealMapper.java

@@ -20,4 +20,21 @@ public interface SpecialPriceDealMapper extends BaseMapper<SpecialPriceDeal> {
 
 
     /** 统计用户已购件数(pending/paid 等占用额度;仅排除 cancelled/refunded 终态) */
     /** 统计用户已购件数(pending/paid 等占用额度;仅排除 cancelled/refunded 终态) */
     int userPurchased(@Param("dealId") Long dealId, @Param("buyerId") Long buyerId);
     int userPurchased(@Param("dealId") Long dealId, @Param("buyerId") Long buyerId);
+
+    /** 分页列表(JOIN products 取商品名/原价),返回字段含 productName/productPrice。
+     *  runStatus: ACTIVE/NOT_STARTED/SOLD_OUT/ENDED(SQL 推导)或 draft/disabled(按存储状态),空=全部 */
+    java.util.List<java.util.Map<String, Object>> listWithProductName(
+            @Param("keyword") String keyword,
+            @Param("runStatus") String runStatus,
+            @Param("limit") int limit,
+            @Param("offset") int offset);
+
+    /** 分页总数(与 listWithProductName 同 where 条件) */
+    long countByKeyword(@Param("keyword") String keyword, @Param("runStatus") String runStatus);
+
+    /** 同商品进行中(status=active 且在时间窗内)的活动数,用于互斥校验 */
+    int countActiveByProduct(@Param("productId") Long productId);
+
+    /** 该特价活动已产生的订单数 */
+    int countOrdersByDeal(@Param("dealId") Long dealId);
 }
 }

+ 153 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/SpecialPriceService.java

@@ -0,0 +1,153 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.entity.SpecialPriceDeal;
+import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.mapper.SpecialPriceDealMapper;
+import com.etotem.cfc.mapper.ProductMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.*;
+
+@Service
+public class SpecialPriceService {
+
+    @Resource
+    private SpecialPriceDealMapper specialPriceDealMapper;
+
+    @Resource
+    private ProductMapper productMapper;
+
+    /**
+     * 状态机:从存储状态 + 时间窗 + 已售量推导出运行状态(纯逻辑,不访问 DB)。
+     * 用于详情与列表接口,避免每次跑 SQL。
+     */
+    public static String computeRunStatus(String status,
+                                          java.util.Date startTime,
+                                          java.util.Date endTime,
+                                          int soldQuantity,
+                                          int totalQuantity) {
+        if (status == null || status.isEmpty()) return "DRAFT";
+        switch (status) {
+            case "draft":           return "DRAFT";
+            case "disabled":        return "DISABLED";
+            case "active": {
+                java.util.Date now = new java.util.Date();
+                if (startTime != null && now.before(startTime)) return "NOT_STARTED";
+                if (endTime != null && now.after(endTime))     return "ENDED";
+                if (soldQuantity >= totalQuantity) return "SOLD_OUT";
+                return "ACTIVE";
+            }
+            default: return "DISABLED";
+        }
+    }
+
+    public Map<String, Object> list(int page, int size, String keyword, String runStatus) {
+        int offset = (page - 1) * size;
+        java.util.List<java.util.Map<String, Object>> records =
+            specialPriceDealMapper.listWithProductName(keyword, runStatus, size, offset);
+        long total = specialPriceDealMapper.countByKeyword(keyword, runStatus);
+        Map<String, Object> data = new HashMap<>();
+        data.put("records", records);
+        data.put("total", total);
+        return data;
+    }
+
+    public SpecialPriceDeal getById(Long id) {
+        return specialPriceDealMapper.selectById(id);
+    }
+
+    public SpecialPriceDeal create(SpecialPriceDeal deal) {
+        // 校验:特价必须低于商品原价
+        Product product = productMapper.selectById(deal.getProductId());
+        if (product == null) throw new IllegalArgumentException("商品不存在");
+        int productPrice = product.getPrice() != null ? product.getPrice() : 0;
+        if (deal.getSpecialPrice() == null || deal.getSpecialPrice() >= productPrice) {
+            throw new IllegalArgumentException("特价必须低于商品原价");
+        }
+        // 校验时间
+        if (deal.getStartTime() != null && deal.getEndTime() != null
+            && deal.getStartTime().after(deal.getEndTime())) {
+            throw new IllegalArgumentException("开始时间必须早于结束时间");
+        }
+        // 校验同商品互斥 active
+        int activeCount = specialPriceDealMapper.countActiveByProduct(deal.getProductId());
+        if (activeCount > 0) {
+            throw new IllegalStateException("该商品已有进行中的特价活动,无法新建/启用");
+        }
+        deal.setStatus("draft");
+        deal.setCreatedAt(new java.util.Date());
+        deal.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.insert(deal);
+        return deal;
+    }
+
+    public void update(SpecialPriceDeal deal) {
+        // 仅 draft/disabled 可修改
+        SpecialPriceDeal old = getById(deal.getId());
+        if (old == null) throw new IllegalArgumentException("特价活动不存在");
+        if (!"draft".equals(old.getStatus()) && !"disabled".equals(old.getStatus())) {
+            throw new IllegalStateException("仅草稿/已停用可修改");
+        }
+        // 修改前校验特价<原价
+        Product product = productMapper.selectById(deal.getProductId());
+        if (product != null) {
+            int productPrice = product.getPrice() != null ? product.getPrice() : 0;
+            if (deal.getSpecialPrice() == null || deal.getSpecialPrice() >= productPrice) {
+                throw new IllegalArgumentException("特价必须低于商品原价");
+            }
+        }
+        deal.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.updateById(deal);
+    }
+
+    public void updateStatus(Long id, String newStatus) {
+        SpecialPriceDeal d = getById(id);
+        if (d == null) throw new IllegalArgumentException("特价活动不存在");
+        if (!"draft".equals(d.getStatus()) && !"disabled".equals(d.getStatus())) {
+            throw new IllegalStateException("仅草稿/已停用状态可启用或停用");
+        }
+        if ("active".equals(newStatus)) {
+            // 再次检查互斥(并发安全:DB 唯一索引更好,此处双重检查)
+            int activeCount = specialPriceDealMapper.countActiveByProduct(d.getProductId());
+            if (activeCount > 0) {
+                throw new IllegalStateException("该商品已有进行中的特价活动,无法启用");
+            }
+        }
+        d.setStatus(newStatus);
+        d.setUpdatedAt(new java.util.Date());
+        specialPriceDealMapper.updateById(d);
+    }
+
+    public void delete(Long id) {
+        SpecialPriceDeal d = getById(id);
+        if (d == null) throw new IllegalArgumentException("特价活动不存在");
+        // 检查是否已有特价订单
+        int orderCount = specialPriceDealMapper.countOrdersByDeal(id);
+        if (orderCount > 0) {
+            throw new IllegalStateException("该活动已有 " + orderCount + " 笔特价订单,不可删除(请改为停用)");
+        }
+        specialPriceDealMapper.deleteById(id);
+    }
+
+    /**
+     * 原子扣量:影响行数=1 成功;=0 失败(已售罄 / 非活动期 / 非 active)。
+     */
+    public boolean consumeQuota(Long dealId, int qty) {
+        int affected = specialPriceDealMapper.consumeQuota(dealId, qty);
+        if (affected == 0) throw new IllegalStateException("已售罄或活动不可用");
+        return true;
+    }
+
+    /**
+     * 回滚释放:用于取消 / 退款 / 超时取消。sold_quantity 不跌到 < 0。
+     */
+    public void releaseQuota(Long dealId, int qty) {
+        if (dealId == null) return;
+        specialPriceDealMapper.releaseQuota(dealId, qty);
+    }
+
+    public int getUserPurchased(Long dealId, Long buyerId) {
+        return specialPriceDealMapper.userPurchased(dealId, buyerId);
+    }
+}

+ 65 - 0
cfc-backend/src/main/resources/mapper/SpecialPriceDealMapper.xml

@@ -30,4 +30,69 @@
           AND status NOT IN ('cancelled', 'refunded')
           AND status NOT IN ('cancelled', 'refunded')
     </select>
     </select>
 
 
+    <!-- 管理端运行状态过滤:ACTIVE/NOT_STARTED/SOLD_OUT/ENDED 由 NOW() 推导;draft/disabled 按存储状态 -->
+    <sql id="runStatusCondition">
+        <if test="runStatus != null and runStatus != ''">
+            <choose>
+                <when test="runStatus == 'ACTIVE'">
+                    AND d.status = 'active' AND d.start_time &lt;= NOW() AND NOW() &lt;= d.end_time
+                    AND d.sold_quantity &lt; d.total_quantity
+                </when>
+                <when test="runStatus == 'NOT_STARTED'">
+                    AND d.status = 'active' AND d.start_time &gt; NOW()
+                </when>
+                <when test="runStatus == 'SOLD_OUT'">
+                    AND d.status = 'active' AND d.start_time &lt;= NOW() AND NOW() &lt;= d.end_time
+                    AND d.sold_quantity &gt;= d.total_quantity
+                </when>
+                <when test="runStatus == 'ENDED'">
+                    AND d.status = 'active' AND (d.end_time IS NULL OR d.end_time &lt; NOW())
+                </when>
+                <otherwise>
+                    AND d.status = #{runStatus}
+                </otherwise>
+            </choose>
+        </if>
+    </sql>
+
+    <select id="listWithProductName" resultType="java.util.Map">
+        SELECT d.id, d.product_id AS productId, p.name AS productName, p.price AS productPrice,
+               d.name, d.special_price AS specialPrice, d.start_time AS startTime, d.end_time AS endTime,
+               d.total_quantity AS totalQuantity, d.sold_quantity AS soldQuantity,
+               d.per_user_limit AS perUserLimit, d.allow_coupon AS allowCoupon, d.status
+        FROM special_price_deals d
+        LEFT JOIN products p ON p.id = d.product_id
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND d.name LIKE CONCAT('%', #{keyword}, '%')
+            </if>
+            <include refid="runStatusCondition"/>
+        </where>
+        ORDER BY d.created_at DESC, d.id DESC
+        LIMIT #{limit} OFFSET #{offset}
+    </select>
+
+    <select id="countByKeyword" resultType="long">
+        SELECT COUNT(*) FROM special_price_deals d
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND d.name LIKE CONCAT('%', #{keyword}, '%')
+            </if>
+            <include refid="runStatusCondition"/>
+        </where>
+    </select>
+
+    <select id="countActiveByProduct" resultType="int">
+        SELECT COUNT(*) FROM special_price_deals
+        WHERE product_id = #{productId}
+          AND status = 'active'
+          AND start_time &lt;= NOW()
+          AND NOW() &lt;= end_time
+    </select>
+
+    <select id="countOrdersByDeal" resultType="int">
+        SELECT COUNT(*) FROM product_orders
+        WHERE special_deal_id = #{dealId}
+    </select>
+
 </mapper>
 </mapper>

+ 79 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/SpecialPriceServiceTest.java

@@ -0,0 +1,79 @@
+package com.etotem.cfc.service;
+
+import com.etotem.cfc.mapper.SpecialPriceDealMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.Date;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+public class SpecialPriceServiceTest {
+
+    private SpecialPriceService service;
+    private SpecialPriceDealMapper mapper;
+
+    @BeforeEach
+    public void setUp() {
+        service = new SpecialPriceService();
+        mapper = mock(SpecialPriceDealMapper.class);
+        ReflectionTestUtils.setField(service, "specialPriceDealMapper", mapper);
+    }
+
+    @Test
+    public void computeRunStatus_draft_returnsDRAFT() {
+        assertEquals("DRAFT", SpecialPriceService.computeRunStatus("draft", null, null, 0, 0));
+    }
+
+    @Test
+    public void computeRunStatus_disabled_returnsDISABLED() {
+        assertEquals("DISABLED", SpecialPriceService.computeRunStatus("disabled", null, null, 0, 0));
+    }
+
+    @Test
+    public void computeRunStatus_active_not_started_returnsNOT_STARTED() {
+        Date future = new Date(System.currentTimeMillis() + 86400_000L);
+        assertEquals("NOT_STARTED", SpecialPriceService.computeRunStatus("active", future, null, 0, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_within_window_sold_out_returnsSOLD_OUT() {
+        Date now = new Date();
+        Date tomorrow = new Date(now.getTime() + 86400_000L);
+        assertEquals("SOLD_OUT", SpecialPriceService.computeRunStatus("active", now, tomorrow, 100, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_within_window_remaining_returnsACTIVE() {
+        Date now = new Date();
+        Date tomorrow = new Date(now.getTime() + 86400_000L);
+        assertEquals("ACTIVE", SpecialPriceService.computeRunStatus("active", now, tomorrow, 5, 100));
+    }
+
+    @Test
+    public void computeRunStatus_active_past_end_returnsENDED() {
+        Date past = new Date(System.currentTimeMillis() - 86400_000L);
+        assertEquals("ENDED", SpecialPriceService.computeRunStatus("active", past, past, 5, 100));
+    }
+
+    @Test
+    public void consumeQuota_callsMapperWithCorrectIdQty() {
+        when(mapper.consumeQuota(1L, 2)).thenReturn(1);
+        assertTrue(service.consumeQuota(1L, 2));
+        verify(mapper).consumeQuota(1L, 2);
+    }
+
+    @Test
+    public void consumeQuota_whenZeroRows_throwsInsufficientException() {
+        when(mapper.consumeQuota(1L, 2)).thenReturn(0);
+        assertThrows(IllegalStateException.class, () -> service.consumeQuota(1L, 2));
+    }
+
+    @Test
+    public void releaseQuota_callsMapperGreatest() {
+        service.releaseQuota(1L, 3);
+        verify(mapper).releaseQuota(1L, 3);
+    }
+}