Jelajahi Sumber

重构评级时效推荐功能并新增预订单落库

jintao.geng 4 minggu lalu
induk
melakukan
183de46ed1
30 mengubah file dengan 767 tambahan dan 327 penghapusan
  1. 6 1
      mango-application/src/main/java/com/mangoo/rating/recommend/app/controller/RecommendController.java
  2. 33 0
      mango-application/src/main/resources/db/migration/V20260706.1_rating_recommend_rule_create.sql
  3. 73 0
      mango-application/src/main/resources/db/migration/V20260706.2_rating_datasync_trend_create.sql
  4. 0 18
      mango-application/src/main/resources/db/migration/V20260706_rating_recommend_rule_create.sql
  5. 1 1
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/CardDetailServiceImplTest.java
  6. 11 10
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/OrderGrouperTest.java
  7. 140 181
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java
  8. 2 1
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/RuleSyncServiceImplTest.java
  9. 7 7
      mango-common/src/main/java/com/mangoo/rating/recommend/config/RecommendProperties.java
  10. 10 4
      mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java
  11. 61 0
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderCardDTO.java
  12. 34 0
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderGroupDTO.java
  13. 30 0
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderSaveDTO.java
  14. 60 3
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendCardDTO.java
  15. 7 4
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RuleSyncCardDTO.java
  16. 6 6
      mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/OrderSuggestionVO.java
  17. 33 6
      mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendCardVO.java
  18. 1 1
      mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendResultVO.java
  19. 8 6
      mango-common/src/test/java/com/mangoo/rating/recommend/config/RecommendPropertiesTest.java
  20. 29 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/client/feign/PreOrderFeignClient.java
  21. 1 1
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/CardDetailServiceImpl.java
  22. 135 51
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RecommendServiceImpl.java
  23. 3 3
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RuleSyncServiceImpl.java
  24. 1 1
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/EfficiencyPriceProvider.java
  25. 19 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/LoginUserProvider.java
  26. 6 5
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java
  27. 6 9
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/EfficiencyPriceProviderImpl.java
  28. 33 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/LoginUserProviderImpl.java
  29. 2 2
      mango-infrastructure/src/main/java/com/mangoo/rating/recommend/mapper/RatingRecommendRuleMapper.java
  30. 9 6
      mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml

+ 6 - 1
mango-application/src/main/java/com/mangoo/rating/recommend/app/controller/RecommendController.java

@@ -7,7 +7,8 @@ import com.mangoo.rating.recommend.request.recommend.RecommendRequest;
 import com.mangoo.rating.recommend.service.RecommendService;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
-import org.springframework.validation.annotation.Validated;
+import io.swagger.annotations.ApiResponse;
+import io.swagger.annotations.ApiResponses;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
@@ -32,7 +33,11 @@ public class RecommendController {
     @ApiOperation("批量推荐评级时效并按时效分单")
     @PostMapping("/efficiency")
     @ApiLog(title = "评级时效推荐", businessType = BusinessType.SEARCH)
+    @ApiResponses({
+            @ApiResponse(code = 0, message = "请求成功", response = String.class)
+    })
     public AjaxResult recommendEfficiency(@RequestBody RecommendRequest request) {
         return recommendService.recommendEfficiency(request);
     }
+
 }

+ 33 - 0
mango-application/src/main/resources/db/migration/V20260706.1_rating_recommend_rule_create.sql

@@ -0,0 +1,33 @@
+-- 规则表 DDL
+CREATE TABLE IF NOT EXISTS t_rating_recommend_rule (
+    id                   BIGSERIAL PRIMARY KEY,
+    card_type            int4 DEFAULT NULL,
+    player               VARCHAR(255) NOT NULL,
+    card_no              VARCHAR(64)  NOT NULL,
+    card_year            VARCHAR(64)  NOT NULL,
+    series               VARCHAR(255) NOT NULL,
+    card_set             VARCHAR(255) NOT NULL,
+    recommend_efficiency SMALLINT     NOT NULL,
+    -- 规则生效时间:可空,NULL 表示永久生效(三级命中按此列排序取最新生效规则)
+    effective_time       TIMESTAMP    DEFAULT NULL,
+    create_time          TIMESTAMP    NOT NULL DEFAULT now(),
+    update_time          TIMESTAMP    NOT NULL DEFAULT now(),
+    del_flag             SMALLINT     NOT NULL DEFAULT 0
+);
+COMMENT ON TABLE t_rating_recommend_rule IS '评级时效推荐规则表';
+COMMENT ON COLUMN t_rating_recommend_rule.id IS '主键ID';
+COMMENT ON COLUMN t_rating_recommend_rule.card_type IS '卡片类型:1-宝可梦卡,2-球星卡';
+COMMENT ON COLUMN t_rating_recommend_rule.player IS '球员名称';
+COMMENT ON COLUMN t_rating_recommend_rule.card_no IS '卡片编号(如 066/080)';
+COMMENT ON COLUMN t_rating_recommend_rule.card_year IS '卡片年份';
+COMMENT ON COLUMN t_rating_recommend_rule.series IS '卡片系列';
+COMMENT ON COLUMN t_rating_recommend_rule.card_set IS '卡种';
+COMMENT ON COLUMN t_rating_recommend_rule.recommend_efficiency IS '推荐评级时效(1普通/2快速/3闪评)';
+COMMENT ON COLUMN t_rating_recommend_rule.effective_time IS '规则生效时间,NULL表示永久生效';
+COMMENT ON COLUMN t_rating_recommend_rule.create_time IS '创建时间';
+COMMENT ON COLUMN t_rating_recommend_rule.update_time IS '更新时间';
+COMMENT ON COLUMN t_rating_recommend_rule.del_flag IS '删除标识(0未删除/1已删除)';
+-- 支撑系列主导降级查询
+CREATE INDEX IF NOT EXISTS idx_rrr_series_set_card_year ON t_rating_recommend_rule (card_year, series, card_set);
+-- 支撑全等命中查询
+CREATE INDEX IF NOT EXISTS idx_rrr_feature ON t_rating_recommend_rule (player, card_no, card_year, series, card_set);

+ 73 - 0
mango-application/src/main/resources/db/migration/V20260706.2_rating_datasync_trend_create.sql

@@ -0,0 +1,73 @@
+-- ============================================================
+-- 数仓同步 + 卡片趋势 扩展建表/改表脚本(PostgreSQL)
+-- 2026-06-25
+-- ============================================================
+
+-- ------------------------------------------------------------
+-- 1. 规则主表加列:卡片唯一ID/卡号/价值/批次号
+-- ------------------------------------------------------------
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS card_id          VARCHAR(64);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS current_value    NUMERIC(12,2);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS value_change_pct NUMERIC(8,4);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS value_min        NUMERIC(12,2);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS value_max        NUMERIC(12,2);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS batch_no         VARCHAR(32);
+
+COMMENT ON COLUMN t_rating_recommend_rule.card_id IS '数仓卡片唯一ID';
+COMMENT ON COLUMN t_rating_recommend_rule.current_value IS '当前价';
+COMMENT ON COLUMN t_rating_recommend_rule.value_change_pct IS '涨跌幅(百分数,如 7.05 表示 +7.05%)';
+COMMENT ON COLUMN t_rating_recommend_rule.value_min IS '价格区间下限';
+COMMENT ON COLUMN t_rating_recommend_rule.value_max IS '价格区间上限';
+COMMENT ON COLUMN t_rating_recommend_rule.batch_no IS '同步批次号(如 2026062501)';
+
+-- 批次内卡片唯一
+CREATE UNIQUE INDEX IF NOT EXISTS uk_rrr_batch_card ON t_rating_recommend_rule (batch_no, card_id);
+-- 带批次的特征降级查询索引
+CREATE INDEX IF NOT EXISTS idx_rrr_batch_feature ON t_rating_recommend_rule (batch_no, series, card_set, card_year);
+
+-- ------------------------------------------------------------
+-- 2. POP 明细表(按机构,一卡多行)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS t_rating_card_pop (
+                                                 id          BIGSERIAL PRIMARY KEY,
+                                                 card_id     VARCHAR(64) NOT NULL,
+    agency      VARCHAR(32) NOT NULL,
+    grade_all   INT,
+    grade_10    INT,
+    grade_9     INT,
+    grade_8     INT,
+    grade_7     INT,
+    batch_no    VARCHAR(32) NOT NULL,
+    create_time TIMESTAMP   NOT NULL DEFAULT now()
+    );
+COMMENT ON TABLE t_rating_card_pop IS '卡片 POP report 明细(按评级机构 PSA/BGS/Nexphostis)';
+CREATE INDEX IF NOT EXISTS idx_rcp_batch_card ON t_rating_card_pop (batch_no, card_id);
+
+-- ------------------------------------------------------------
+-- 3. 价格历史表(增量累积,趋势曲线数据源)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS t_rating_card_value_history (
+                                                           id          BIGSERIAL PRIMARY KEY,
+                                                           card_id     VARCHAR(64)  NOT NULL,
+    price       NUMERIC(12,2) NOT NULL,
+    record_date DATE         NOT NULL,
+    create_time TIMESTAMP    NOT NULL DEFAULT now()
+    );
+COMMENT ON TABLE t_rating_card_value_history IS '卡片价格历史(每日增量累积,趋势曲线用)';
+-- 同卡同日只一条,保证每日推送幂等
+CREATE UNIQUE INDEX IF NOT EXISTS uk_rcvh_card_date ON t_rating_card_value_history (card_id, record_date);
+
+-- ------------------------------------------------------------
+-- 4. 生效批次指针表(单行,记录当前对外生效的批次号)
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS t_rating_recommend_active_batch (
+                                                               id              BIGSERIAL PRIMARY KEY,
+                                                               active_batch_no VARCHAR(32),
+    update_time     TIMESTAMP NOT NULL DEFAULT now()
+    );
+COMMENT ON TABLE t_rating_recommend_active_batch IS '推荐规则生效批次指针(单行)';
+
+-- 初始化一行空指针(首次同步前查询将无生效批次→走兜底,符合预期)
+INSERT INTO t_rating_recommend_active_batch (active_batch_no)
+SELECT NULL
+    WHERE NOT EXISTS (SELECT 1 FROM t_rating_recommend_active_batch);

+ 0 - 18
mango-application/src/main/resources/db/migration/V20260706_rating_recommend_rule_create.sql

@@ -1,18 +0,0 @@
--- 规则表 DDL
-CREATE TABLE IF NOT EXISTS t_rating_recommend_rule (
-                                                       id                   BIGSERIAL PRIMARY KEY,
-                                                       player               VARCHAR(255) NOT NULL,
-    year                 VARCHAR(64)  NOT NULL,
-    series               VARCHAR(255) NOT NULL,
-    card_set             VARCHAR(255) NOT NULL,
-    recommend_efficiency SMALLINT     NOT NULL,
-    effective_time       TIMESTAMP,
-    create_time          TIMESTAMP    NOT NULL DEFAULT now(),
-    update_time          TIMESTAMP    NOT NULL DEFAULT now(),
-    del_flag             SMALLINT     NOT NULL DEFAULT 0
-    );
-COMMENT ON TABLE t_rating_recommend_rule IS '评级时效推荐规则表(数仓沉淀,只读查询)';
--- 支撑系列主导降级查询
-CREATE INDEX IF NOT EXISTS idx_rrr_series_set_year ON t_rating_recommend_rule (series, card_set, year);
--- 支撑全等命中查询
-CREATE INDEX IF NOT EXISTS idx_rrr_feature ON t_rating_recommend_rule (player, year, series, card_set);

+ 1 - 1
mango-application/src/test/java/com/mangoo/rating/recommend/cart/CardDetailServiceImplTest.java

@@ -71,7 +71,7 @@ class CardDetailServiceImplTest {
         r.setCardId("C1");
         r.setCardNo("066/080");
         r.setPlayer("AIPOM");
-        r.setYear("2026");
+        r.setCardYear("2026");
         r.setSeries("pokemon.M2.JNP");
         r.setCardSet("BASE");
         r.setCurrentValue(new BigDecimal("15"));

+ 11 - 10
mango-application/src/test/java/com/mangoo/rating/recommend/cart/OrderGrouperTest.java

@@ -21,12 +21,13 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 class OrderGrouperTest {
 
     /**
-     * 构造测试用卡 VO,仅设置 clientCardId 与 recommendEfficiency(其他字段不参与分组)
+     * 构造测试用卡 VO,仅设置 clientCardId 与 evaluateEfficiencyId(其他字段不参与分组)
+     * 说明:VO 已由旧 recommendEfficiency(int) 迁移到 evaluateEfficiencyId(Long)。
      */
-    private RecommendCardVO c(String no, int eff) {
+    private RecommendCardVO c(String no, long eff) {
         RecommendCardVO v = new RecommendCardVO();
         v.setClientCardId(no);
-        v.setRecommendEfficiency(eff);
+        v.setEvaluateEfficiencyId(eff);
         return v;
     }
 
@@ -47,7 +48,7 @@ class OrderGrouperTest {
         List<OrderSuggestionVO> orders = OrderGrouper.group(Arrays.asList(c("a", 2)));
         assertEquals(1, orders.size());
         assertEquals(1, orders.get(0).getGroupNo());
-        assertEquals(2, orders.get(0).getEfficiency());
+        assertEquals(2, orders.get(0).getEvaluateEfficiencyId());
         assertEquals(1, orders.get(0).getCardCount());
         assertEquals("a", orders.get(0).getCards().get(0).getClientCardId());
     }
@@ -56,7 +57,7 @@ class OrderGrouperTest {
     void 全同档_合并为一单() {
         List<OrderSuggestionVO> orders = OrderGrouper.group(Arrays.asList(c("a", 1), c("b", 1), c("c", 1)));
         assertEquals(1, orders.size());
-        assertEquals(1, orders.get(0).getEfficiency());
+        assertEquals(1, orders.get(0).getEvaluateEfficiencyId());
         assertEquals(3, orders.get(0).getCardCount());
     }
 
@@ -66,10 +67,10 @@ class OrderGrouperTest {
         List<OrderSuggestionVO> orders = OrderGrouper.group(Arrays.asList(c("x", 3), c("y", 1), c("z", 1)));
         assertEquals(2, orders.size());
         assertEquals(1, orders.get(0).getGroupNo());
-        assertEquals(1, orders.get(0).getEfficiency());
+        assertEquals(1, orders.get(0).getEvaluateEfficiencyId());
         assertEquals(2, orders.get(0).getCardCount());
         assertEquals(2, orders.get(1).getGroupNo());
-        assertEquals(3, orders.get(1).getEfficiency());
+        assertEquals(3, orders.get(1).getEvaluateEfficiencyId());
         assertEquals(1, orders.get(1).getCardCount());
     }
 
@@ -77,9 +78,9 @@ class OrderGrouperTest {
     void 三档同时存在_最多3个订单_按档位升序() {
         List<OrderSuggestionVO> orders = OrderGrouper.group(Arrays.asList(c("a", 2), c("b", 3), c("c", 1)));
         assertEquals(3, orders.size());
-        assertEquals(1, orders.get(0).getEfficiency()); // 普通
-        assertEquals(2, orders.get(1).getEfficiency()); // 快速
-        assertEquals(3, orders.get(2).getEfficiency()); // 闪评
+        assertEquals(1, orders.get(0).getEvaluateEfficiencyId()); // 普通
+        assertEquals(2, orders.get(1).getEvaluateEfficiencyId()); // 快速
+        assertEquals(3, orders.get(2).getEvaluateEfficiencyId()); // 闪评
     }
 
     @Test

+ 140 - 181
mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java

@@ -1,21 +1,21 @@
 package com.mangoo.rating.recommend.cart;
 
 import com.mangoo.rating.recommend.AjaxResult;
+import com.mangoo.rating.recommend.client.feign.PreOrderFeignClient;
 import com.mangoo.rating.recommend.config.RecommendProperties;
-import com.mangoo.rating.recommend.enums.MatchLevelEnum;
 import com.mangoo.rating.recommend.manager.ActiveBatchManager;
-import com.mangoo.rating.recommend.manager.CardPopManager;
 import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
 import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
+import com.mangoo.rating.recommend.request.recommend.PreOrderCardDTO;
+import com.mangoo.rating.recommend.request.recommend.PreOrderSaveDTO;
 import com.mangoo.rating.recommend.request.recommend.RecommendCardDTO;
 import com.mangoo.rating.recommend.request.recommend.RecommendRequest;
-import com.mangoo.rating.recommend.response.recommend.OrderSuggestionVO;
-import com.mangoo.rating.recommend.response.recommend.RecommendResultVO;
 import com.mangoo.rating.recommend.service.impl.RecommendServiceImpl;
 import com.mangoo.rating.recommend.service.recommend.EfficiencyPriceProvider;
-import org.junit.jupiter.api.BeforeEach;
+import com.mangoo.rating.recommend.service.recommend.LoginUserProvider;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
@@ -23,58 +23,57 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 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.assertNotEquals;
-import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 /**
- * RecommendServiceImpl Mockito 单测(适配 batch_no 过滤 + POP/价值增强)
+ * RecommendServiceImpl 单测(预订单落库改造后:返回 preOrderNo + Feign 落库)
+ *
+ * 覆盖的目标场景:
+ *   1) 空入参 / null                        → 非 0 code
+ *   2) 超上限                                → 非 0 code
+ *   3) 正常,Feign 返回 preOrderNo          → code=0,data=preOrderNo
+ *   4) userId 为 null(未登录)              → 非 0 code,且不调用 Feign
+ *   5) Feign 抛异常                          → 非 0 code
+ *   6) Feign 返回 code!=0                    → 非 0 code(下游 msg 不再透传,改为固定文案)
+ *   7) 落库包字段映射:10 个识别字段 + userId/totalCards/packageId 全透传
  *
  * @author gengjintao
- * @date 2026/06/25
  */
 @ExtendWith(MockitoExtension.class)
 class RecommendServiceImplTest {
 
     @Mock
-    private RatingRecommendRuleManager ruleManager;
+    private RatingRecommendRuleManager ratingRecommendRuleManager;
     @Mock
-    private EfficiencyPriceProvider priceProvider;
+    private EfficiencyPriceProvider efficiencyPriceProvider;
     @Mock
     private RecommendProperties recommendProperties;
     @Mock
     private ActiveBatchManager activeBatchManager;
     @Mock
-    private CardPopManager cardPopManager;
+    private PreOrderFeignClient preOrderFeignClient;
+    @Mock
+    private LoginUserProvider loginUserProvider;
     @InjectMocks
     private RecommendServiceImpl recommendService;
 
-    @BeforeEach
-    void setUp() {
-        lenient().when(recommendProperties.getDefaultEfficiency()).thenReturn(1);
-        lenient().when(recommendProperties.getBatchMax()).thenReturn(50);
-        lenient().when(activeBatchManager.selectActiveBatchNo()).thenReturn("B1");
-        lenient().when(cardPopManager.selectByCardId(anyString(), anyString())).thenReturn(Collections.emptyList());
-        Map<Integer, BigDecimal> defaultPrices = new HashMap<>();
-        defaultPrices.put(1, new BigDecimal("50.00"));
-        defaultPrices.put(2, new BigDecimal("100.00"));
-        defaultPrices.put(3, new BigDecimal("200.00"));
-        lenient().when(priceProvider.loadEfficiencyPrices()).thenReturn(defaultPrices);
-    }
+    /** 落库失败对外统一文案(生产代码不再透传下游 msg) */
+    private static final String FAIL_MSG = "预订单生成失败,请稍后重试";
 
-    private RecommendCardDTO card(String id, String player, String year, String series, String cardSet) {
+    /** 造最简卡入参:仅带识别 4 字段 */
+    private RecommendCardDTO card(String player, String year, String series, String cardSet) {
         RecommendCardDTO d = new RecommendCardDTO();
-        d.setClientCardId(id);
         d.setPlayer(player);
         d.setYear(year);
         d.setSeries(series);
@@ -82,198 +81,158 @@ class RecommendServiceImplTest {
         return d;
     }
 
+    /** 造一个 request(浅拷贝入参列表,避免测试之间共享同一 List) */
     private RecommendRequest req(RecommendCardDTO... cards) {
         RecommendRequest r = new RecommendRequest();
         r.setCards(new ArrayList<>(Arrays.asList(cards)));
         return r;
     }
 
-    private RatingRecommendRulePO rule(Integer eff) {
+    /**
+     * 构造一条"合法且能通过 isLegalRule 校验"的 EXACT 命中规则。
+     * 注意 evaluateEfficiencyName 在 PO 中是 Long(非 String),此处按真实签名给 3L。
+     */
+    private RatingRecommendRulePO exactRule() {
         RatingRecommendRulePO po = new RatingRecommendRulePO();
-        po.setRecommendEfficiency(eff);
+        po.setEvaluateEfficiencyId(3L);
+        po.setEvaluateEfficiencyName(3L);
+        po.setCardId("DW-CARD-1");
         return po;
     }
 
-    private int code(AjaxResult r) {
-        return ((Number) r.get("code")).intValue();
+    /** 构造 Feign 成功返回:data 里带 preOrderNo */
+    private AjaxResult okWith(String preOrderNo) {
+        Map<String, Object> data = new HashMap<>();
+        data.put("preOrderNo", preOrderNo);
+        return AjaxResult.success(data);
+    }
+
+    /** 默认让匹配命中、价格可取、用户已登录(各用例可覆盖) */
+    private void defaultHappyStubs() {
+        lenient().when(recommendProperties.getBatchMax()).thenReturn(50);
+        lenient().when(activeBatchManager.selectActiveBatchNo()).thenReturn("B1");
+        lenient().when(ratingRecommendRuleManager.selectByExact(any(), any(), any(), any(), any()))
+                .thenReturn(exactRule());
+        Map<Long, BigDecimal> prices = new HashMap<>();
+        prices.put(3L, new BigDecimal("200.00"));
+        lenient().when(efficiencyPriceProvider.loadEfficiencyPrices()).thenReturn(prices);
+        lenient().when(loginUserProvider.currentUserId()).thenReturn(1001L);
+        lenient().when(loginUserProvider.currentUserHeader()).thenReturn("base64header");
     }
 
-    private RecommendResultVO data(AjaxResult r) {
-        return (RecommendResultVO) r.get("data");
+    private int code(AjaxResult r) {
+        return r.getCode();
     }
 
     @Test
     void 空入参_返回error() {
+        // null / 空列表:直接拦截,不调用 Feign
         assertNotEquals(0, code(recommendService.recommendEfficiency(null)));
         assertNotEquals(0, code(recommendService.recommendEfficiency(new RecommendRequest())));
     }
 
     @Test
     void 超限_返回error() {
+        // 上限=1,传 2 张 → 拦截
         when(recommendProperties.getBatchMax()).thenReturn(1);
         AjaxResult result = recommendService.recommendEfficiency(
-                req(card("c1", "p", "y", "s", "cs"), card("c2", "p", "y", "s", "cs")));
+                req(card("p", "y", "s", "cs"), card("p", "y", "s", "cs")));
         assertNotEquals(0, code(result));
     }
 
     @Test
-    void L1全等命中_单卡_单订单_含价格() {
-        when(ruleManager.selectByExact("Jordan", "1986", "Fleer", "Base", "B1")).thenReturn(rule(3));
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO d = data(result);
-        assertEquals(1, d.getTotalCards());
-        assertEquals(1, d.getOrderCount());
-        OrderSuggestionVO order = d.getOrders().get(0);
-        assertEquals(3, order.getEfficiency());
-        assertEquals(MatchLevelEnum.EXACT.getCode(), order.getCards().get(0).getMatchLevel());
-        assertEquals(new BigDecimal("200.00"), order.getUnitPrice());
-        assertEquals(new BigDecimal("200.00"), order.getTotalAmount());
-        assertEquals(new BigDecimal("200.00"), d.getGrandTotal());
-    }
-
-    @Test
-    void L1未命中_L2命中_matchLevel为L2() {
-        when(ruleManager.selectByExact(anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear("Fleer", "Base", "1986", "B1")).thenReturn(rule(2));
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO d = data(result);
-        assertEquals(MatchLevelEnum.L2.getCode(), d.getOrders().get(0).getCards().get(0).getMatchLevel());
-        assertEquals(2, d.getOrders().get(0).getEfficiency());
-    }
-
-    @Test
-    void L1L2未命中_L3命中_matchLevel为L3() {
-        when(ruleManager.selectByExact(anyString(), anyString(), anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear(anyString(), anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSet("Fleer", "Base", "B1")).thenReturn(rule(2));
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO d = data(result);
-        assertEquals(MatchLevelEnum.L3.getCode(), d.getOrders().get(0).getCards().get(0).getMatchLevel());
-    }
-
-    @Test
-    void 三层均未命中_走兜底_matchLevel为DEFAULT() {
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear(any(), any(), any(), any())).thenReturn(null);
-        when(ruleManager.selectBySeriesSet(any(), any(), any())).thenReturn(null);
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "X", "Y", "Z", "W")));
-        RecommendResultVO d = data(result);
-        assertEquals(MatchLevelEnum.DEFAULT.getCode(), d.getOrders().get(0).getCards().get(0).getMatchLevel());
-        assertEquals(1, d.getOrders().get(0).getEfficiency());
-    }
-
-    @Test
-    void 规则时效非法_跳过该层继续降级() {
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(99));
-        when(ruleManager.selectBySeriesSetYear(any(), any(), any(), any())).thenReturn(rule(2));
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "p", "y", "s", "cs")));
-        RecommendResultVO d = data(result);
-        assertEquals(MatchLevelEnum.L2.getCode(), d.getOrders().get(0).getCards().get(0).getMatchLevel());
-    }
-
-    @Test
-    void 价格Map为空_单价均为null_grandTotal为null() {
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(3));
-        when(priceProvider.loadEfficiencyPrices()).thenReturn(Collections.emptyMap());
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "p", "y", "s", "cs")));
-        RecommendResultVO d = data(result);
-        assertNull(d.getOrders().get(0).getUnitPrice());
-        assertNull(d.getOrders().get(0).getTotalAmount());
-        assertNull(d.getOrders().get(0).getCards().get(0).getUnitPrice());
-        assertNull(d.getGrandTotal());
-    }
-
-    @Test
-    void 多卡多档_按档位升序分3单_总计正确() {
-        when(ruleManager.selectByExact("p1", "y", "s", "cs", "B1")).thenReturn(rule(1));
-        when(ruleManager.selectByExact("p2", "y", "s", "cs", "B1")).thenReturn(rule(2));
-        when(ruleManager.selectByExact("p3", "y", "s", "cs", "B1")).thenReturn(rule(3));
-        AjaxResult result = recommendService.recommendEfficiency(req(
-                card("c1", "p1", "y", "s", "cs"),
-                card("c2", "p2", "y", "s", "cs"),
-                card("c3", "p3", "y", "s", "cs")));
-        RecommendResultVO d = data(result);
-        assertEquals(3, d.getOrderCount());
-        assertEquals(1, d.getOrders().get(0).getEfficiency());
-        assertEquals(2, d.getOrders().get(1).getEfficiency());
-        assertEquals(3, d.getOrders().get(2).getEfficiency());
-        assertEquals(new BigDecimal("350.00"), d.getGrandTotal());
+    void 正常_落库成功_返回preOrderNo() {
+        defaultHappyStubs();
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenReturn(okWith("PRE123456"));
+        AjaxResult result = recommendService.recommendEfficiency(req(card("Jordan", "1986", "Fleer", "Base")));
+        assertEquals(0, code(result));
+        // 生产代码:成功时 data 为 String preOrderNo
+        assertEquals("PRE123456", result.get("data"));
     }
 
     @Test
-    void 同档多卡_合并为一单_无套餐价时总计等于时效单价() {
-        // 新公式:totalAmount = packageTotal × 卡数 + 时效单价;无套餐价 → 0×3 + 100 = 100
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(2));
-        AjaxResult result = recommendService.recommendEfficiency(req(
-                card("c1", "p", "y", "s", "cs"),
-                card("c2", "p", "y", "s", "cs"),
-                card("c3", "p", "y", "s", "cs")));
-        RecommendResultVO d = data(result);
-        assertEquals(1, d.getOrderCount());
-        OrderSuggestionVO order = d.getOrders().get(0);
-        assertEquals(3, order.getCardCount());
-        assertEquals(new BigDecimal("100.00"), order.getUnitPrice());
-        assertEquals(new BigDecimal("100.00"), order.getTotalAmount());
+    void 用户未登录_不调用Feign_返回error() {
+        defaultHappyStubs();
+        // 覆盖 defaultHappyStubs 里的登录用户
+        when(loginUserProvider.currentUserId()).thenReturn(null);
+        AjaxResult result = recommendService.recommendEfficiency(req(card("p", "y", "s", "cs")));
+        assertNotEquals(0, code(result));
+        // 关键:未登录时不得触发远程调用
+        verify(preOrderFeignClient, never()).savePreOrder(any(), any());
     }
 
     @Test
-    void 图片透传_clientCardId与图片原样返回() {
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(1));
-        RecommendCardDTO c = card("MY-CARD-1", "p", "y", "s", "cs");
-        c.setFrontImageUrl("http://x/f.jpg");
-        c.setBackImageUrl("http://x/b.jpg");
-        AjaxResult result = recommendService.recommendEfficiency(req(c));
-        RecommendResultVO d = data(result);
-        assertEquals("MY-CARD-1", d.getOrders().get(0).getCards().get(0).getClientCardId());
-        assertEquals("http://x/f.jpg", d.getOrders().get(0).getCards().get(0).getFrontImageUrl());
-        assertEquals("http://x/b.jpg", d.getOrders().get(0).getCards().get(0).getBackImageUrl());
+    void Feign抛异常_返回生成失败() {
+        defaultHappyStubs();
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenThrow(new RuntimeException("timeout"));
+        AjaxResult result = recommendService.recommendEfficiency(req(card("p", "y", "s", "cs")));
+        assertNotEquals(0, code(result));
+        assertEquals(FAIL_MSG, result.getMsg());
     }
 
     @Test
-    void 套餐价生效_订单总额包含套餐价合计_且卡级透传packageId与packagePrice() {
-        // 同档 2 卡,时效价 100,每卡套餐价 30 / 50
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(2));
-        RecommendCardDTO c1 = card("c1", "p", "y", "s", "cs");
-        c1.setId(101L);
-        c1.setPrice(new BigDecimal("30"));
-        RecommendCardDTO c2 = card("c2", "p", "y", "s", "cs");
-        c2.setId(102L);
-        c2.setPrice(new BigDecimal("50"));
-
-        AjaxResult result = recommendService.recommendEfficiency(req(c1, c2));
-        RecommendResultVO d = data(result);
-        OrderSuggestionVO order = d.getOrders().get(0);
-
-        // 时效价单价 100
-        assertEquals(new BigDecimal("100.00"), order.getUnitPrice());
-        // 套餐价合计 = 30 + 50 = 80
-        assertEquals(new BigDecimal("80"), order.getPackageTotal());
-        // 订单总计 = 套餐合计 × 卡数 + 时效单价 = 80×2 + 100 = 260(scale=2 因为时效价 100.00 传染)
-        assertEquals(new BigDecimal("260.00"), order.getTotalAmount());
-        // grandTotal 与订单总计一致
-        assertEquals(new BigDecimal("260.00"), d.getGrandTotal());
-        // 卡级透传 packageId / packagePrice
-        assertEquals(101L, order.getCards().get(0).getPackageId());
-        assertEquals(new BigDecimal("30"), order.getCards().get(0).getPackagePrice());
-        assertEquals(102L, order.getCards().get(1).getPackageId());
-        assertEquals(new BigDecimal("50"), order.getCards().get(1).getPackagePrice());
+    void Feign返回非0_透传错误() {
+        defaultHappyStubs();
+        // 下游返回 error(含具体 msg "下游库满"),生产代码经质量审查后已不再透传下游 msg,
+        // 对外统一为固定文案 FAIL_MSG,避免下游信息泄露。故只断言 code 非 0 + 固定文案。
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenReturn(AjaxResult.error("下游库满"));
+        AjaxResult result = recommendService.recommendEfficiency(req(card("p", "y", "s", "cs")));
+        assertNotEquals(0, code(result));
+        assertEquals(FAIL_MSG, result.getMsg());
     }
 
     @Test
-    void 套餐价部分为null_当0处理_不阻断() {
-        // 同档 2 卡,时效价 100,一卡套餐价 40 另一卡 null
-        when(ruleManager.selectByExact(any(), any(), any(), any(), any())).thenReturn(rule(2));
-        RecommendCardDTO c1 = card("c1", "p", "y", "s", "cs");
-        c1.setPrice(new BigDecimal("40"));
-        RecommendCardDTO c2 = card("c2", "p", "y", "s", "cs");
-        // c2.price 不设 → null 当 0
-
-        AjaxResult result = recommendService.recommendEfficiency(req(c1, c2));
-        RecommendResultVO d = data(result);
-        OrderSuggestionVO order = d.getOrders().get(0);
-
-        // 套餐合计 = 40 + 0 = 40;订单总计 = 40×2 + 100 = 180(新公式:合计×卡数+时效单价;scale=2)
-        assertEquals(new BigDecimal("40"), order.getPackageTotal());
-        assertEquals(new BigDecimal("180.00"), order.getTotalAmount());
+    void 落库包_识别字段与关键字段全部透传() {
+        defaultHappyStubs();
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenReturn(okWith("PRE999"));
+
+        // 构造含 10 个补齐识别字段 + id/price 的入参卡
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        c.setSportEvent("篮球");
+        c.setCardType(2);
+        c.setRole("SG");
+        c.setCardNo("NO-1");
+        c.setTeam("Bulls");
+        c.setLimitId("/99");
+        c.setIpName("NBA");
+        c.setRule("MVP");
+        c.setRarity("SSR");
+        c.setMaterial("金箔");
+        c.setId(88L);
+        c.setPrice(new BigDecimal("120"));
+
+        recommendService.recommendEfficiency(req(c));
+
+        // 抓取实际传给 Feign 的 PreOrderSaveDTO
+        ArgumentCaptor<PreOrderSaveDTO> captor = ArgumentCaptor.forClass(PreOrderSaveDTO.class);
+        verify(preOrderFeignClient).savePreOrder(anyString(), captor.capture());
+        PreOrderSaveDTO sent = captor.getValue();
+
+        // 主表汇总字段
+        assertEquals(1001L, sent.getUserId());
+        assertEquals(1, sent.getTotalCards());
+
+        // 卡片明细:10 识别字段全部透传(sportEvent/cardType/role/cardNo/team/limitId/ipName/rule/rarity/material)
+        PreOrderCardDTO card = sent.getGroups().get(0).getCards().get(0);
+        assertEquals("篮球", card.getSportEvent());
+        assertEquals(2, card.getCardType());
+        assertEquals("SG", card.getRole());
+        assertEquals("NO-1", card.getCardNo());
+        assertEquals("Bulls", card.getTeam());
+        assertEquals("/99", card.getLimitId());
+        assertEquals("NBA", card.getIpName());
+        // 入参 rule -> VO roleName -> PreOrderCardDTO.rule(往返映射)
+        assertEquals("MVP", card.getRule());
+        assertEquals("SSR", card.getRarity());
+        assertEquals("金箔", card.getMaterial());
+        // 套餐/命中:入参 id -> packageId;规则 cardId -> matchedCardId
+        assertEquals(88L, card.getPackageId());
+        assertEquals("DW-CARD-1", card.getMatchedCardId());
+        // 时效价可取到时应大于 0
+        assertTrue(card.getEfficiencyPrice().compareTo(BigDecimal.ZERO) > 0);
     }
 }

+ 2 - 1
mango-application/src/test/java/com/mangoo/rating/recommend/cart/RuleSyncServiceImplTest.java

@@ -67,7 +67,8 @@ class RuleSyncServiceImplTest {
         c.setYear("2026");
         c.setSeries("s");
         c.setCardSet("BASE");
-        c.setRecommendEfficiency(2);
+        // 评级时效字段已重构:旧 recommendEfficiency(int) -> 新 evaluateEfficiencyId(Long)
+        c.setEvaluateEfficiencyId(2L);
         return c;
     }
 

+ 7 - 7
mango-common/src/main/java/com/mangoo/rating/recommend/config/RecommendProperties.java

@@ -19,7 +19,7 @@ import java.util.Map;
 public class RecommendProperties {
 
     /** 兜底默认时效档(EvaluateEfficiencyEnum.code,默认 1=普通) */
-    private int defaultEfficiency = 1;
+    private Long defaultEfficiencyId = 1L;
 
     /** 单次请求卡数量上限 */
     private int batchMax = 50;
@@ -28,7 +28,7 @@ public class RecommendProperties {
      * 服务等级时效字符串 → 时效 code 映射(报价用,订单服务返回的 timeLimit 是字符串)。
      * 例:「普通: 1」「快速: 2」「闪评: 3」「7个工作日: 1」「1个工作日: 3」
      */
-    private Map<String, Integer> timeLimitMapping = defaultTimeLimitMapping();
+    private Map<String, Long> timeLimitMapping = defaultTimeLimitMapping();
 
     /** 数仓同步接口鉴权 Token(请求头 X-Sync-Token) */
     private String syncToken = "change-me-sync-token";
@@ -36,11 +36,11 @@ public class RecommendProperties {
     /**
      * 默认服务时效映射,防止外部配置把 time-limit-mapping 错配成标量时应用启动失败。
      */
-    private static Map<String, Integer> defaultTimeLimitMapping() {
-        Map<String, Integer> mapping = new LinkedHashMap<>();
-        mapping.put("普通", 1);
-        mapping.put("快速", 2);
-        mapping.put("闪评", 3);
+    private static Map<String, Long> defaultTimeLimitMapping() {
+        Map<String, Long> mapping = new LinkedHashMap<>();
+        mapping.put("普通", 1L);
+        mapping.put("快速", 2L);
+        mapping.put("闪评", 3L);
         return mapping;
     }
 }

+ 10 - 4
mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java

@@ -21,11 +21,14 @@ public class RatingRecommendRulePO {
     @ApiModelProperty(value = "主键ID")
     private Long id;
 
+    @ApiModelProperty(value = "卡片类型:1-宝可梦卡,2-球星卡")
+    private Integer cardType;
+
     @ApiModelProperty(value = "球员")
     private String player;
 
     @ApiModelProperty(value = "年份")
-    private String year;
+    private String cardYear;
 
     @ApiModelProperty(value = "系列")
     private String series;
@@ -33,8 +36,11 @@ public class RatingRecommendRulePO {
     @ApiModelProperty(value = "卡种")
     private String cardSet;
 
-    @ApiModelProperty(value = "推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer recommendEfficiency;
+    @ApiModelProperty(value = "评级时效ID")
+    private Long evaluateEfficiencyId;
+
+    @ApiModelProperty(value = "评级时效名称")
+    private Long evaluateEfficiencyName;
 
     @ApiModelProperty(value = "规则生效时间")
     private LocalDateTime effectiveTime;
@@ -54,7 +60,7 @@ public class RatingRecommendRulePO {
     @ApiModelProperty(value = "当前价")
     private java.math.BigDecimal currentValue;
 
-    @ApiModelProperty(value = "涨跌幅(百分数,如 7.05 表示 +7.05%)")
+    @ApiModelProperty(value = "价格涨跌幅(百分数,如 7.05 表示 +7.05%)")
     private java.math.BigDecimal valueChangePct;
 
     @ApiModelProperty(value = "价格区间下限")

+ 61 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderCardDTO.java

@@ -0,0 +1,61 @@
+package com.mangoo.rating.recommend.request.recommend;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 预订单落库-单卡入参(全部 18 识别字段 + 匹配/时效/套餐 计算字段)
+ *
+ * @author gengjintao
+ */
+@Data
+@ApiModel("预订单落库-单卡")
+public class PreOrderCardDTO {
+    @ApiModelProperty("球员")
+    private String player;
+    @ApiModelProperty("年份")
+    private String year;
+    @ApiModelProperty("系列")
+    private String series;
+    @ApiModelProperty("卡种")
+    private String cardSet;
+    @ApiModelProperty("运动项目")
+    private String sportEvent;
+    @ApiModelProperty("卡片类型:1-宝可梦卡,2-球星卡")
+    private Integer cardType;
+    @ApiModelProperty("角色")
+    private String role;
+    @ApiModelProperty("卡片编号(入参识别字段)")
+    private String cardNo;
+    @ApiModelProperty("球队")
+    private String team;
+    @ApiModelProperty("限编")
+    private String limitId;
+    @ApiModelProperty("IP名称")
+    private String ipName;
+    @ApiModelProperty("角色名称(入参 rule 字段)")
+    private String rule;
+    @ApiModelProperty("稀有度")
+    private String rarity;
+    @ApiModelProperty("材质")
+    private String material;
+    @ApiModelProperty("正面图URL")
+    private String frontImageUrl;
+    @ApiModelProperty("反面图URL")
+    private String backImageUrl;
+    @ApiModelProperty("命中层级 EXACT/L2/L3/DEFAULT")
+    private String matchLevel;
+    @ApiModelProperty("评级时效ID")
+    private Long evaluateEfficiencyId;
+    @ApiModelProperty("评级时效价格")
+    private BigDecimal efficiencyPrice;
+    @ApiModelProperty("命中规则带出的数仓卡片ID")
+    private String matchedCardId;
+    @ApiModelProperty("套餐ID")
+    private Long packageId;
+    @ApiModelProperty("套餐价格")
+    private BigDecimal packagePrice;
+}

+ 34 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderGroupDTO.java

@@ -0,0 +1,34 @@
+package com.mangoo.rating.recommend.request.recommend;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订单落库-时效分组入参
+ *
+ * @author gengjintao
+ */
+@Data
+@ApiModel("预订单落库-时效分组")
+public class PreOrderGroupDTO {
+    @ApiModelProperty("分组号(1 起,档位升序)")
+    private Integer groupNo;
+    @ApiModelProperty("评级时效ID")
+    private Long evaluateEfficiencyId;
+    @ApiModelProperty("时效描述")
+    private String evaluateEfficiencyName;
+    @ApiModelProperty("时效单价(可空)")
+    private BigDecimal efficiencyPrice;
+    @ApiModelProperty("组内卡片数")
+    private Integer cardCount;
+    @ApiModelProperty("套餐价合计")
+    private BigDecimal packageTotal;
+    @ApiModelProperty("订单总计(可空)")
+    private BigDecimal totalAmount;
+    @ApiModelProperty("组内卡片列表")
+    private List<PreOrderCardDTO> cards;
+}

+ 30 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/PreOrderSaveDTO.java

@@ -0,0 +1,30 @@
+package com.mangoo.rating.recommend.request.recommend;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 预订单落库-整包入参(推荐服务组装,Feign 传给 ratingApp)
+ *
+ * @author gengjintao
+ */
+@Data
+@ApiModel("预订单落库-整包")
+public class PreOrderSaveDTO {
+    @ApiModelProperty("归属用户ID(推荐服务解析 X-USER-BASE64 得到)")
+    private Long userId;
+    @ApiModelProperty("总卡数")
+    private Integer totalCards;
+    @ApiModelProperty("订单(时效分组)数")
+    private Integer orderCount;
+    @ApiModelProperty("所有订单合计(可空=待计算)")
+    private BigDecimal grandTotal;
+    @ApiModelProperty("生成时生效批次号(可空)")
+    private String activeBatchNo;
+    @ApiModelProperty("分组列表")
+    private List<PreOrderGroupDTO> groups;
+}

+ 60 - 3
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendCardDTO.java

@@ -17,9 +17,6 @@ import java.math.BigDecimal;
 @ApiModel("单卡推荐入参")
 public class RecommendCardDTO {
 
-    @ApiModelProperty(value = "前端临时卡标识(用于回指订单归属,仅透传不入库)", required = true)
-    @NotBlank(message = "clientCardId 不能为空")
-    private String clientCardId;
 
     @ApiModelProperty(value = "球员")
     private String player;
@@ -45,5 +42,65 @@ public class RecommendCardDTO {
     @ApiModelProperty(value = "套餐价格")
     private BigDecimal price;
 
+    /**
+     * 运动项目
+     */
+    @ApiModelProperty(value = "运动项目")
+    private String sportEvent;
+
+    @ApiModelProperty(value = "卡片类型:1-宝可梦卡,2-球星卡")
+    private Integer cardType;
+
+    /**
+     * 角色
+     */
+    @ApiModelProperty(value = "角色")
+    private String role;
+
+    /**
+     * 卡片编号
+     */
+    @ApiModelProperty(value = "卡片编号")
+    private String cardNo;
+
+    /** 球队 */
+    @ApiModelProperty(value = "球队")
+    private String team;
+
+    /**
+     * 限编
+     */
+    @ApiModelProperty(value = "限编")
+    private String limitId;
+
+
+    /**
+     * IP名称
+     */
+    @ApiModelProperty(value = "IP名称")
+    private String ipName;
+
+
+    /**
+     * 角色名称
+     */
+    @ApiModelProperty(value = "角色名称")
+    private String rule;
+
+    /**
+     * 稀有度
+     */
+    @ApiModelProperty(value = "稀有度")
+    private String rarity;
+
+
+    /**
+     * 材质
+     */
+    @ApiModelProperty(value = "材质")
+    private String material;
+
+
+
 
 }

+ 7 - 4
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RuleSyncCardDTO.java

@@ -36,14 +36,14 @@ public class RuleSyncCardDTO {
     @ApiModelProperty(value = "卡种")
     private String cardSet;
 
-    @ApiModelProperty(value = "推荐时效(数仓直接给,EvaluateEfficiencyEnum.code)")
-    private Integer recommendEfficiency;
+    @ApiModelProperty(value = "评级时效ID")
+    private Long evaluateEfficiencyId;
 
     @ApiModelProperty(value = "当前价")
     private BigDecimal currentValue;
 
-    @ApiModelProperty(value = "涨跌幅(百分数,如 7.05)")
-    private BigDecimal valueChangePct;
+    @ApiModelProperty(value = "价格涨跌幅(百分数,如 7.05)")
+    private BigDecimal priceChangePct;
 
     @ApiModelProperty(value = "价格区间下限")
     private BigDecimal valueMin;
@@ -51,6 +51,9 @@ public class RuleSyncCardDTO {
     @ApiModelProperty(value = "价格区间上限")
     private BigDecimal valueMax;
 
+    @ApiModelProperty(value = "规则备注")
+    private String remark;
+
     @ApiModelProperty(value = "当日价格记录日期(趋势增量用)")
     private LocalDate recordDate;
 

+ 6 - 6
mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/OrderSuggestionVO.java

@@ -20,19 +20,19 @@ public class OrderSuggestionVO {
     @ApiModelProperty(value = "分组号(1 起,档位升序)")
     private Integer groupNo;
 
-    @ApiModelProperty(value = "订单推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer efficiency;
+    @ApiModelProperty(value = "评级时效ID")
+    private Long evaluateEfficiencyId;
 
     @ApiModelProperty(value = "订单推荐时效描述")
-    private String efficiencyDesc;
+    private String evaluateEfficiencyName;
 
-    @ApiModelProperty(value = "该档单价(价格取不到时为 null=待计算)")
-    private BigDecimal unitPrice;
+    @ApiModelProperty(value = "评级时效单价(价格取不到时为 null=待计算)")
+    private BigDecimal efficiencyPrice;
 
     @ApiModelProperty(value = "订单中卡片数量")
     private Integer cardCount;
 
-    @ApiModelProperty(value = "订单内套餐价合计(Σ各卡 packagePrice,null 当 0)")
+    @ApiModelProperty(value = "订单内套餐价合计")
     private BigDecimal packageTotal;
 
     @ApiModelProperty(value = "订单总计(=套餐价合计×卡数 + 时效单价;时效价缺失则为 null)")

+ 33 - 6
mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendCardVO.java

@@ -31,14 +31,14 @@ public class RecommendCardVO {
     @ApiModelProperty(value = "卡种")
     private String cardSet;
 
-    @ApiModelProperty(value = "推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer recommendEfficiency;
+    @ApiModelProperty(value = "评级时效ID")
+    private Long evaluateEfficiencyId;
 
     @ApiModelProperty(value = "命中层级(EXACT/L2/L3/DEFAULT)")
     private String matchLevel;
 
-    @ApiModelProperty(value = "该卡单价(价格取不到时为 null=待计算)")
-    private BigDecimal unitPrice;
+    @ApiModelProperty(value = "评级时效价格")
+    private BigDecimal efficiencyPrice;
 
     @ApiModelProperty(value = "正面图URL(透传)")
     private String frontImageUrl;
@@ -46,6 +46,33 @@ public class RecommendCardVO {
     @ApiModelProperty(value = "反面图URL(透传)")
     private String backImageUrl;
 
+    @ApiModelProperty(value = "运动项目")
+    private String sportEvent;
+
+    @ApiModelProperty(value = "卡片类型:1-宝可梦卡,2-球星卡")
+    private Integer cardType;
+
+    @ApiModelProperty(value = "角色")
+    private String role;
+
+    @ApiModelProperty(value = "球队")
+    private String team;
+
+    @ApiModelProperty(value = "限编")
+    private String limitId;
+
+    @ApiModelProperty(value = "IP名称")
+    private String ipName;
+
+    @ApiModelProperty(value = "角色名称(入参 rule 字段)")
+    private String roleName;
+
+    @ApiModelProperty(value = "稀有度")
+    private String rarity;
+
+    @ApiModelProperty(value = "材质")
+    private String material;
+
     @ApiModelProperty(value = "数仓卡片唯一ID")
     private String cardId;
 
@@ -61,8 +88,8 @@ public class RecommendCardVO {
 //    @ApiModelProperty(value = "当前价")
 //    private java.math.BigDecimal currentValue;
 //
-//    @ApiModelProperty(value = "涨跌幅(百分数)")
-//    private java.math.BigDecimal valueChangePct;
+//    @ApiModelProperty(value = "价格涨跌幅(百分数)")
+//    private java.math.BigDecimal priceChangePct;
 //
 //    @ApiModelProperty(value = "价格区间下限")
 //    private java.math.BigDecimal valueMin;

+ 1 - 1
mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendResultVO.java

@@ -23,7 +23,7 @@ public class RecommendResultVO {
     @ApiModelProperty(value = "订单数")
     private Integer orderCount;
 
-    @ApiModelProperty(value = "所有订单合计(任一订单缺价则为 null)")
+    @ApiModelProperty(value = "所有订单合计")
     private BigDecimal grandTotal;
 
     @ApiModelProperty(value = "订单建议列表")

+ 8 - 6
mango-common/src/test/java/com/mangoo/rating/recommend/config/RecommendPropertiesTest.java

@@ -25,10 +25,11 @@ class RecommendPropertiesTest {
                 .run(context -> {
                     assertThat(context).hasNotFailed();
                     RecommendProperties properties = context.getBean(RecommendProperties.class);
+                    // timeLimitMapping 已由 Map<String,Integer> 迁移到 Map<String,Long>,值需为 Long
                     assertThat(properties.getTimeLimitMapping())
-                            .containsEntry("普通", 1)
-                            .containsEntry("快速", 2)
-                            .containsEntry("闪评", 3);
+                            .containsEntry("普通", 1L)
+                            .containsEntry("快速", 2L)
+                            .containsEntry("闪评", 3L);
                 });
     }
 
@@ -39,10 +40,11 @@ class RecommendPropertiesTest {
                 .run(context -> {
                     assertThat(context).hasNotFailed();
                     RecommendProperties properties = context.getBean(RecommendProperties.class);
+                    // 标量绑定不合法时保留默认 Map<String,Long>
                     assertThat(properties.getTimeLimitMapping())
-                            .containsEntry("普通", 1)
-                            .containsEntry("快速", 2)
-                            .containsEntry("闪评", 3);
+                            .containsEntry("普通", 1L)
+                            .containsEntry("快速", 2L)
+                            .containsEntry("闪评", 3L);
                 });
     }
 

+ 29 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/client/feign/PreOrderFeignClient.java

@@ -0,0 +1,29 @@
+package com.mangoo.rating.recommend.client.feign;
+
+import com.mangoo.rating.recommend.AjaxResult;
+import com.mangoo.rating.recommend.request.recommend.PreOrderSaveDTO;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+/**
+ * 调 ratingApp 的预订单落库接口。
+ * 复用现有 ${rating-app.url};X-USER-BASE64 透传做用户隔离,userId 另在 body 显式携带。
+ *
+ * @author gengjintao
+ */
+@FeignClient(name = "rating-app-preorder", url = "${rating-app.url}")
+public interface PreOrderFeignClient {
+
+    /**
+     * 预订单落库:整包写入,成功返回 data={"preOrderNo":"PRE..."}
+     *
+     * @param userHeader X-USER-BASE64 原始值(透传)
+     * @param body       预订单整包
+     * @return AjaxResult,code=0 时 data.preOrderNo 为预订单号
+     */
+    @PostMapping("/api/preorder/save")
+    AjaxResult savePreOrder(@RequestHeader(value = "X-USER-BASE64", required = false) String userHeader,
+                            @RequestBody PreOrderSaveDTO body);
+}

+ 1 - 1
mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/CardDetailServiceImpl.java

@@ -62,7 +62,7 @@ public class CardDetailServiceImpl implements CardDetailService {
         vo.setCardId(rule.getCardId());
         vo.setCardNo(rule.getCardNo());
         vo.setName(rule.getPlayer());
-        vo.setYear(rule.getYear());
+        vo.setYear(rule.getCardYear());
         vo.setSeries(rule.getSeries());
         vo.setCardSet(rule.getCardSet());
         vo.setImageUrl(null); // 本期规则表未存卡图,留 null

+ 135 - 51
mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RecommendServiceImpl.java

@@ -1,22 +1,22 @@
 package com.mangoo.rating.recommend.service.impl;
 
 import com.mangoo.rating.recommend.AjaxResult;
+import com.mangoo.rating.recommend.client.feign.PreOrderFeignClient;
 import com.mangoo.rating.recommend.config.RecommendProperties;
-import com.mangoo.rating.recommend.enums.EvaluateEfficiencyEnum;
 import com.mangoo.rating.recommend.enums.MatchLevelEnum;
 import com.mangoo.rating.recommend.manager.ActiveBatchManager;
-import com.mangoo.rating.recommend.manager.CardPopManager;
 import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
-import com.mangoo.rating.recommend.po.CardPopPO;
 import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
+import com.mangoo.rating.recommend.request.recommend.PreOrderCardDTO;
+import com.mangoo.rating.recommend.request.recommend.PreOrderGroupDTO;
+import com.mangoo.rating.recommend.request.recommend.PreOrderSaveDTO;
 import com.mangoo.rating.recommend.request.recommend.RecommendCardDTO;
 import com.mangoo.rating.recommend.request.recommend.RecommendRequest;
-import com.mangoo.rating.recommend.response.recommend.CardPopVO;
 import com.mangoo.rating.recommend.response.recommend.OrderSuggestionVO;
 import com.mangoo.rating.recommend.response.recommend.RecommendCardVO;
-import com.mangoo.rating.recommend.response.recommend.RecommendResultVO;
 import com.mangoo.rating.recommend.service.RecommendService;
 import com.mangoo.rating.recommend.service.recommend.EfficiencyPriceProvider;
+import com.mangoo.rating.recommend.service.recommend.LoginUserProvider;
 import com.mangoo.rating.recommend.service.recommend.OrderGrouper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -26,6 +26,8 @@ import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * 评级时效推荐服务实现
@@ -57,7 +59,10 @@ public class RecommendServiceImpl implements RecommendService {
     private ActiveBatchManager activeBatchManager;
 
     @Resource
-    private CardPopManager cardPopManager;
+    private PreOrderFeignClient preOrderFeignClient;
+
+    @Resource
+    private LoginUserProvider loginUserProvider;
 
     @Override
     public AjaxResult recommendEfficiency(RecommendRequest request) {
@@ -81,16 +86,105 @@ public class RecommendServiceImpl implements RecommendService {
         List<OrderSuggestionVO> orders = OrderGrouper.group(cardVOs);
 
         // 3) 报价
-        Map<Integer, BigDecimal> priceMap = efficiencyPriceProvider.loadEfficiencyPrices();
+        Map<Long, BigDecimal> priceMap = efficiencyPriceProvider.loadEfficiencyPrices();
         BigDecimal grandTotal = applyPricing(orders, priceMap);
 
-        // 4) 组装响应
-        RecommendResultVO result = new RecommendResultVO();
-        result.setTotalCards(request.getCards().size());
-        result.setOrderCount(orders.size());
-        result.setGrandTotal(grandTotal);
-        result.setOrders(orders);
-        return AjaxResult.success("推荐成功", result);
+        // 4) 取当前用户(走 Provider 而非直接调 UserUtils 静态方法,便于单测 mock)
+        Long userId = loginUserProvider.currentUserId();
+        if (userId == null) {
+            return AjaxResult.error("用户未登录,无法生成预订单");
+        }
+
+        // 5) 组装落库包
+        PreOrderSaveDTO saveDTO = buildSaveDTO(request, orders, grandTotal, activeBatchNo, userId);
+
+        // 6) 远程落库(推荐服务不写库,交由 ratingApp)
+        AjaxResult resp;
+        try {
+            resp = preOrderFeignClient.savePreOrder(loginUserProvider.currentUserHeader(), saveDTO);
+        } catch (Exception e) {
+            // 异常分支:补上下文(userId + totalCards),方便定位;对外仍返回固定文案避免信息泄露
+            log.error("预订单落库远程调用异常, userId={}, totalCards={}", userId, request.getCards().size(), e);
+            return AjaxResult.error("预订单生成失败,请稍后重试");
+        }
+        // 下游返回 null 或非成功码时,仅记录 code/msg 到日志,不透传给终端用户
+        if (resp == null) {
+            log.error("预订单落库远程调用返回 null, userId={}", userId);
+            return AjaxResult.error("预订单生成失败,请稍后重试");
+        }
+        if (resp.getCode() != 0) {
+            log.error("预订单落库远程调用失败, userId={}, code={}, msg={}", userId, resp.getCode(), resp.getMsg());
+            return AjaxResult.error("预订单生成失败,请稍后重试");
+        }
+        Object data = resp.get(AjaxResult.DATA_TAG);
+        String preOrderNo = (data instanceof Map)
+                ? Objects.toString(((Map<?, ?>) data).get("preOrderNo"), null) : null;
+        if (preOrderNo == null || preOrderNo.isEmpty()) {
+            return AjaxResult.error("预订单生成失败,请稍后重试");
+        }
+        // 显式走双参 success(msg,data):单参 success(String) 会被重载解析成 success(msg),导致 preOrderNo 落进 msg 而 data=null。
+        return AjaxResult.success("操作成功", preOrderNo);
+    }
+
+    /**
+     * 组装预订单落库整包(主表汇总 + 分组 + 卡片明细)。
+     */
+    private PreOrderSaveDTO buildSaveDTO(RecommendRequest request, List<OrderSuggestionVO> orders,
+                                         BigDecimal grandTotal, String activeBatchNo, Long userId) {
+        PreOrderSaveDTO dto = new PreOrderSaveDTO();
+        dto.setUserId(userId);
+        dto.setTotalCards(request.getCards().size());
+        dto.setOrderCount(orders == null ? 0 : orders.size());
+        dto.setGrandTotal(grandTotal);
+        dto.setActiveBatchNo(activeBatchNo);
+        List<PreOrderGroupDTO> groups = new ArrayList<>();
+        if (orders != null) {
+            for (OrderSuggestionVO o : orders) {
+                PreOrderGroupDTO g = new PreOrderGroupDTO();
+                g.setGroupNo(o.getGroupNo());
+                g.setEvaluateEfficiencyId(o.getEvaluateEfficiencyId());
+                g.setEvaluateEfficiencyName(o.getEvaluateEfficiencyName());
+                g.setEfficiencyPrice(o.getEfficiencyPrice());
+                g.setCardCount(o.getCardCount());
+                g.setPackageTotal(o.getPackageTotal());
+                g.setTotalAmount(o.getTotalAmount());
+                g.setCards(o.getCards() == null ? new ArrayList<>()
+                        : o.getCards().stream().map(this::toCardDTO).collect(Collectors.toList()));
+                groups.add(g);
+            }
+        }
+        dto.setGroups(groups);
+        return dto;
+    }
+
+    /**
+     * RecommendCardVO -> PreOrderCardDTO(含全部 18 识别字段 + 计算字段)。
+     */
+    private PreOrderCardDTO toCardDTO(RecommendCardVO vo) {
+        PreOrderCardDTO c = new PreOrderCardDTO();
+        c.setPlayer(vo.getPlayer());
+        c.setYear(vo.getYear());
+        c.setSeries(vo.getSeries());
+        c.setCardSet(vo.getCardSet());
+        c.setSportEvent(vo.getSportEvent());
+        c.setCardType(vo.getCardType());
+        c.setRole(vo.getRole());
+        c.setCardNo(vo.getCardNo());
+        c.setTeam(vo.getTeam());
+        c.setLimitId(vo.getLimitId());
+        c.setIpName(vo.getIpName());
+        c.setRule(vo.getRoleName());
+        c.setRarity(vo.getRarity());
+        c.setMaterial(vo.getMaterial());
+        c.setFrontImageUrl(vo.getFrontImageUrl());
+        c.setBackImageUrl(vo.getBackImageUrl());
+        c.setMatchLevel(vo.getMatchLevel());
+        c.setEvaluateEfficiencyId(vo.getEvaluateEfficiencyId());
+        c.setEfficiencyPrice(vo.getEfficiencyPrice());
+        c.setMatchedCardId(vo.getCardId());
+        c.setPackageId(vo.getPackageId());
+        c.setPackagePrice(vo.getPackagePrice());
+        return c;
     }
 
     /**
@@ -98,7 +192,6 @@ public class RecommendServiceImpl implements RecommendService {
      */
     private RecommendCardVO recommendOneCard(RecommendCardDTO dto, String activeBatchNo) {
         RecommendCardVO vo = new RecommendCardVO();
-        vo.setClientCardId(dto.getClientCardId());
         vo.setPlayer(dto.getPlayer());
         vo.setYear(dto.getYear());
         vo.setSeries(dto.getSeries());
@@ -108,63 +201,54 @@ public class RecommendServiceImpl implements RecommendService {
         // 透传套餐ID与套餐价格(入参字段名 id/price,响应改业务命名 packageId/packagePrice)
         vo.setPackageId(dto.getId());
         vo.setPackagePrice(dto.getPrice());
+        // 透传全部识别字段(用于落库存全量)
+        vo.setSportEvent(dto.getSportEvent());
+        vo.setCardType(dto.getCardType());
+        vo.setRole(dto.getRole());
+        vo.setCardNo(dto.getCardNo());      // 入参识别编号(不再被规则 cardNo 覆盖)
+        vo.setTeam(dto.getTeam());
+        vo.setLimitId(dto.getLimitId());
+        vo.setIpName(dto.getIpName());
+        vo.setRoleName(dto.getRule());      // 入参 rule -> VO roleName
+        vo.setRarity(dto.getRarity());
+        vo.setMaterial(dto.getMaterial());
 
         RatingRecommendRulePO rule = ratingRecommendRuleManager.selectByExact(
                 dto.getPlayer(), dto.getYear(), dto.getSeries(), dto.getCardSet(), activeBatchNo);
         if (isLegalRule(rule)) {
-            fillFromRule(vo, rule, MatchLevelEnum.EXACT, activeBatchNo);
+            fillFromRule(vo, rule, MatchLevelEnum.EXACT);
             return vo;
         }
         rule = ratingRecommendRuleManager.selectBySeriesSetYear(dto.getSeries(), dto.getCardSet(), dto.getYear(), activeBatchNo);
         if (isLegalRule(rule)) {
-            fillFromRule(vo, rule, MatchLevelEnum.L2, activeBatchNo);
+            fillFromRule(vo, rule, MatchLevelEnum.L2);
             return vo;
         }
         rule = ratingRecommendRuleManager.selectBySeriesSet(dto.getSeries(), dto.getCardSet(), activeBatchNo);
         if (isLegalRule(rule)) {
-            fillFromRule(vo, rule, MatchLevelEnum.L3, activeBatchNo);
+            fillFromRule(vo, rule, MatchLevelEnum.L3);
             return vo;
         }
         // 兜底
-        vo.setRecommendEfficiency(recommendProperties.getDefaultEfficiency());
+        vo.setEvaluateEfficiencyId(recommendProperties.getDefaultEfficiencyId());
         vo.setMatchLevel(MatchLevelEnum.DEFAULT.getCode());
         return vo;
     }
 
     /**
-     * 命中规则后填充:时效、命中层级、卡片ID/卡号/价值,并补查 POP 明细
+     * 命中规则后填充:时效、命中层级、卡片ID(POP 补查已下线,形参 activeBatchNo 一并移除)
      */
-    private void fillFromRule(RecommendCardVO vo, RatingRecommendRulePO rule, MatchLevelEnum level, String activeBatchNo) {
-        vo.setRecommendEfficiency(rule.getRecommendEfficiency());
+    private void fillFromRule(RecommendCardVO vo, RatingRecommendRulePO rule, MatchLevelEnum level) {
+        vo.setEvaluateEfficiencyId(rule.getEvaluateEfficiencyId());
         vo.setMatchLevel(level.getCode());
         vo.setCardId(rule.getCardId());
-        vo.setCardNo(rule.getCardNo());
-//        vo.setCurrentValue(rule.getCurrentValue());
-//        vo.setValueChangePct(rule.getValueChangePct());
-//        vo.setValueMin(rule.getValueMin());
-//        vo.setValueMax(rule.getValueMax());
-        // 补查 POP(三机构)
-//        if (rule.getCardId() != null && activeBatchNo != null) {
-//            List<CardPopPO> pops = cardPopManager.selectByCardId(rule.getCardId(), activeBatchNo);
-//            List<CardPopVO> popVOs = new ArrayList<>();
-//            for (CardPopPO p : pops) {
-//                CardPopVO pv = new CardPopVO();
-//                pv.setAgency(p.getAgency());
-//                pv.setAll(p.getGradeAll());
-//                pv.setG10(p.getGrade10());
-//                pv.setG9(p.getGrade9());
-//                pv.setG8(p.getGrade8());
-//                pv.setG7(p.getGrade7());
-//                popVOs.add(pv);
-//            }
-//            vo.setPops(popVOs);
-//        }
+        // 规则带出的数仓卡号不再覆盖入参识别编号 cardNo(数仓 ID 由 cardId 承载)
     }
 
     private boolean isLegalRule(RatingRecommendRulePO rule) {
         return rule != null
-                && rule.getRecommendEfficiency() != null
-                && EvaluateEfficiencyEnum.getByCode(rule.getRecommendEfficiency()) != null;
+                && rule.getEvaluateEfficiencyId() != null
+                && rule.getEvaluateEfficiencyName() != null;
     }
 
     /**
@@ -175,7 +259,7 @@ public class RecommendServiceImpl implements RecommendService {
      *   时效价某档无价 → 订单 unitPrice/totalAmount = null("待计算"),grandTotal 也为 null
      *   套餐价 packagePrice 始终透传到卡级,无需降级
      */
-    private BigDecimal applyPricing(List<OrderSuggestionVO> orders, Map<Integer, BigDecimal> priceMap) {
+    private BigDecimal applyPricing(List<OrderSuggestionVO> orders, Map<Long, BigDecimal> priceMap) {
         if (orders == null || orders.isEmpty()) {
             return null;
         }
@@ -187,14 +271,14 @@ public class RecommendServiceImpl implements RecommendService {
             order.setPackageTotal(packageTotal);
 
             // 2) 再算时效价;缺失则降级"待计算"
-            BigDecimal unit = (priceMap == null) ? null : priceMap.get(order.getEfficiency());
+            BigDecimal unit = (priceMap == null) ? null : priceMap.get(order.getEvaluateEfficiencyId());
             if (unit == null) {
-                order.setUnitPrice(null);
+                order.setEfficiencyPrice(null);
                 order.setTotalAmount(null);
                 grandNull = true;
                 if (order.getCards() != null) {
                     for (RecommendCardVO c : order.getCards()) {
-                        c.setUnitPrice(null);
+                        c.setEfficiencyPrice(null);
                     }
                 }
                 continue;
@@ -202,11 +286,11 @@ public class RecommendServiceImpl implements RecommendService {
             // 3) 订单总计 = 套餐价合计 × 卡数 + 时效单价
             BigDecimal packageSubtotal = packageTotal.multiply(BigDecimal.valueOf(order.getCardCount()));
             BigDecimal total = packageSubtotal.add(unit);
-            order.setUnitPrice(unit);
+            order.setEfficiencyPrice(unit);
             order.setTotalAmount(total);
             if (order.getCards() != null) {
                 for (RecommendCardVO c : order.getCards()) {
-                    c.setUnitPrice(unit);
+                    c.setEfficiencyPrice(unit);
                 }
             }
             grand = grand.add(total);

+ 3 - 3
mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RuleSyncServiceImpl.java

@@ -83,12 +83,12 @@ public class RuleSyncServiceImpl implements RuleSyncService {
             r.setCardId(c.getCardId());
             r.setCardNo(c.getCardNo());
             r.setPlayer(c.getPlayer());
-            r.setYear(c.getYear());
+            r.setCardYear(c.getYear());
             r.setSeries(c.getSeries());
             r.setCardSet(c.getCardSet());
-            r.setRecommendEfficiency(c.getRecommendEfficiency());
+            r.setEvaluateEfficiencyId(c.getEvaluateEfficiencyId());
             r.setCurrentValue(c.getCurrentValue());
-            r.setValueChangePct(c.getValueChangePct());
+            r.setValueChangePct(c.getPriceChangePct());
             r.setValueMin(c.getValueMin());
             r.setValueMax(c.getValueMax());
             r.setBatchNo(batchNo);

+ 1 - 1
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/EfficiencyPriceProvider.java

@@ -17,5 +17,5 @@ public interface EfficiencyPriceProvider {
      *
      * @return 时效 code -> 单价(如 1 -> 50.00, 2 -> 100.00, 3 -> 200.00)
      */
-    Map<Integer, BigDecimal> loadEfficiencyPrices();
+    Map<Long, BigDecimal> loadEfficiencyPrices();
 }

+ 19 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/LoginUserProvider.java

@@ -0,0 +1,19 @@
+package com.mangoo.rating.recommend.service.recommend;
+
+/**
+ * 当前登录用户信息提供者(便于单测替身)
+ *
+ * @author gengjintao
+ */
+public interface LoginUserProvider {
+
+    /**
+     * 当前登录用户 ID;取不到返回 null。
+     */
+    Long currentUserId();
+
+    /**
+     * 当前请求的 X-USER-BASE64 原始头值;无则返回 null(用于 Feign 透传)。
+     */
+    String currentUserHeader();
+}

+ 6 - 5
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java

@@ -33,18 +33,19 @@ public final class OrderGrouper {
         }
 
         // 用 TreeMap 按档位升序归并;同档列表用 ArrayList 保留输入顺序(稳定)
-        Map<Integer, List<RecommendCardVO>> bucket = new TreeMap<>();
+        Map<Long, List<RecommendCardVO>> bucket = new TreeMap<>();
         for (RecommendCardVO card : cards) {
-            Integer eff = card.getRecommendEfficiency();
+            Long eff = card.getEvaluateEfficiencyId();
             bucket.computeIfAbsent(eff, k -> new ArrayList<>()).add(card);
         }
 
         int groupNo = 1;
-        for (Map.Entry<Integer, List<RecommendCardVO>> entry : bucket.entrySet()) {
+        for (Map.Entry<Long, List<RecommendCardVO>> entry : bucket.entrySet()) {
             OrderSuggestionVO order = new OrderSuggestionVO();
             order.setGroupNo(groupNo++);
-            order.setEfficiency(entry.getKey());
-            order.setEfficiencyDesc(EvaluateEfficiencyEnum.getDescByCode(entry.getKey()));
+            order.setEvaluateEfficiencyId(entry.getKey());
+            // todo 临时使用第一个,后续通过时效id查询名称
+            order.setEvaluateEfficiencyName("普通时效");
             order.setCardCount(entry.getValue().size());
             order.setCards(entry.getValue());
             // unitPrice / totalAmount 由上层报价后回填,此处不设置

+ 6 - 9
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/EfficiencyPriceProviderImpl.java

@@ -9,10 +9,7 @@ import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
 import java.math.BigDecimal;
-import java.util.Collections;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 /**
  * 时效价格 Provider 实现(预留调订单服务的口子 + 降级)。
@@ -32,7 +29,7 @@ public class EfficiencyPriceProviderImpl implements EfficiencyPriceProvider {
     private RecommendProperties recommendProperties;
 
     @Override
-    public Map<Integer, BigDecimal> loadEfficiencyPrices() {
+    public Map<Long, BigDecimal> loadEfficiencyPrices() {
         try {
             // 1. 从订单服务获取全部服务等级(预留口子,当前返回空列表 → 触发降级)
             List<ProductServiceLevelCacheDTO> levels = fetchServiceLevels();
@@ -42,14 +39,14 @@ public class EfficiencyPriceProviderImpl implements EfficiencyPriceProvider {
             }
 
             // 2. 将服务等级的 timeLimit(字符串) → EvaluateEfficiencyEnum.code 后聚合
-            Map<Integer, BigDecimal> priceMap = new LinkedHashMap<>();
-            Map<String, Integer> mapping = recommendProperties.getTimeLimitMapping();
+            Map<Long, BigDecimal> priceMap = new LinkedHashMap<>();
+            Map<String, Long> mapping = recommendProperties.getTimeLimitMapping();
             for (ProductServiceLevelCacheDTO lvl : levels) {
                 if (lvl == null || lvl.getTimeLimit() == null || lvl.getPrice() == null) {
                     continue;
                 }
-                Integer effCode = mapping == null ? null : mapping.get(lvl.getTimeLimit().trim());
-                if (effCode == null || EvaluateEfficiencyEnum.getByCode(effCode) == null) {
+                Long effCode = mapping == null ? null : mapping.get(lvl.getTimeLimit().trim());
+                if (Objects.isNull(effCode)) {
                     log.debug("无法映射服务等级 timeLimit={} 到时效 code,跳过", lvl.getTimeLimit());
                     continue;
                 }

+ 33 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/LoginUserProviderImpl.java

@@ -0,0 +1,33 @@
+package com.mangoo.rating.recommend.service.recommend.impl;
+
+import com.mangoo.rating.recommend.dto.UserInfo;
+import com.mangoo.rating.recommend.service.recommend.LoginUserProvider;
+import com.mangoo.rating.recommend.utils.ServletUtils;
+import com.mangoo.rating.recommend.utils.UserUtils;
+import org.springframework.stereotype.Component;
+
+/**
+ * 委托 UserUtils 解析 X-USER-BASE64 获取当前用户。
+ *
+ * @author gengjintao
+ */
+@Component
+public class LoginUserProviderImpl implements LoginUserProvider {
+
+    @Override
+    public Long currentUserId() {
+        UserInfo userInfo = UserUtils.getSimpleUserInfo();
+        return userInfo == null ? null : userInfo.getId();
+    }
+
+    @Override
+    public String currentUserHeader() {
+        // 无 web 请求上下文(异步/定时任务)时 ServletUtils.getRequest() 可能 NPE,做防御性兜底
+        try {
+            javax.servlet.http.HttpServletRequest req = ServletUtils.getRequest();
+            return req == null ? null : req.getHeader("X-USER-BASE64");
+        } catch (Exception e) {
+            return null;
+        }
+    }
+}

+ 2 - 2
mango-infrastructure/src/main/java/com/mangoo/rating/recommend/mapper/RatingRecommendRuleMapper.java

@@ -17,7 +17,7 @@ public interface RatingRecommendRuleMapper {
      * L1:四字段全等命中(取最新生效,限定生效批次)
      */
     RatingRecommendRulePO selectByExact(@Param("player") String player,
-                                        @Param("year") String year,
+                                        @Param("cardYear") String cardYear,
                                         @Param("series") String series,
                                         @Param("cardSet") String cardSet,
                                         @Param("batchNo") String batchNo);
@@ -27,7 +27,7 @@ public interface RatingRecommendRuleMapper {
      */
     RatingRecommendRulePO selectBySeriesSetYear(@Param("series") String series,
                                                 @Param("cardSet") String cardSet,
-                                                @Param("year") String year,
+                                                @Param("cardYear") String cardYear,
                                                 @Param("batchNo") String batchNo);
 
     /**

+ 9 - 6
mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml

@@ -6,8 +6,10 @@
 
     <resultMap type="com.mangoo.rating.recommend.po.RatingRecommendRulePO" id="BaseResultMap">
         <result property="id" column="id"/>
+        <result property="cardType" column="card_type"/>
         <result property="player" column="player"/>
-        <result property="year" column="year"/>
+        <!-- SQL 列名 card_year(year 为 SQL 保留字故加前缀),Java 属性对齐为 cardYear -->
+        <result property="cardYear" column="card_year"/>
         <result property="series" column="series"/>
         <result property="cardSet" column="card_set"/>
         <result property="recommendEfficiency" column="recommend_efficiency"/>
@@ -15,6 +17,7 @@
         <result property="cardId" column="card_id"/>
         <result property="cardNo" column="card_no"/>
         <result property="currentValue" column="current_value"/>
+        <!-- SQL 列名 value_change_pct,Java 属性对齐为 valueChangePct -->
         <result property="valueChangePct" column="value_change_pct"/>
         <result property="valueMin" column="value_min"/>
         <result property="valueMax" column="value_max"/>
@@ -25,7 +28,7 @@
     </resultMap>
 
     <sql id="baseColumnList">
-        id, player, year, series, card_set, recommend_efficiency, effective_time,
+        id, card_type, player, card_year, series, card_set, recommend_efficiency, effective_time,
         card_id, card_no, current_value, value_change_pct, value_min, value_max, batch_no,
         create_time, update_time, del_flag
     </sql>
@@ -37,7 +40,7 @@
         WHERE del_flag = 0
           AND batch_no = #{batchNo}
           AND player = #{player}
-          AND year = #{year}
+          AND card_year = #{cardYear}
           AND series = #{series}
           AND card_set = #{cardSet}
           AND (effective_time IS NULL OR effective_time &lt;= now())
@@ -53,7 +56,7 @@
           AND batch_no = #{batchNo}
           AND series = #{series}
           AND card_set = #{cardSet}
-          AND year = #{year}
+          AND card_year = #{cardYear}
           AND (effective_time IS NULL OR effective_time &lt;= now())
         ORDER BY effective_time DESC NULLS LAST, id DESC
         LIMIT 1
@@ -83,12 +86,12 @@
     <!-- 数仓同步:批量插入规则(物理全量替换,del_flag 恒为 0) -->
     <insert id="batchInsert">
         INSERT INTO t_rating_recommend_rule
-        (player, year, series, card_set, recommend_efficiency, effective_time,
+        (player, card_year, series, card_set, recommend_efficiency, effective_time,
          card_id, card_no, current_value, value_change_pct, value_min, value_max, batch_no,
          create_time, update_time, del_flag)
         VALUES
         <foreach collection="list" item="it" separator=",">
-            (#{it.player}, #{it.year}, #{it.series}, #{it.cardSet}, #{it.recommendEfficiency}, #{it.effectiveTime},
+            (#{it.player}, #{it.cardYear}, #{it.series}, #{it.cardSet}, #{it.recommendEfficiency}, #{it.effectiveTime},
              #{it.cardId}, #{it.cardNo}, #{it.currentValue}, #{it.valueChangePct}, #{it.valueMin}, #{it.valueMax}, #{it.batchNo},
              now(), now(), 0)
         </foreach>