Ver Fonte

评级推荐匹配逻辑优化-重构规则匹配流程
支持带分类和回退机制的全字段匹配。

jintao.geng há 3 semanas atrás
pai
commit
6b6b0875e4
27 ficheiros alterados com 978 adições e 2451 exclusões
  1. 0 1877
      docs/superpowers/plans/2026-06-24-rating-efficiency-recommend.md
  2. 0 215
      docs/superpowers/specs/2026-06-24-rating-efficiency-recommend-design.md
  3. 0 122
      docs/superpowers/specs/2026-06-25-rating-datasync-trend-design.md
  4. 26 0
      mango-application/src/main/resources/db/migration/V20260708.1_rating_recommend_rule_add_identity_columns.sql
  5. 98 0
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/EfficiencyPriceProviderImplTest.java
  6. 88 20
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java
  7. 156 0
      mango-application/src/test/java/com/mangoo/rating/recommend/cart/RuleMatcherTest.java
  8. 6 0
      mango-common/src/main/java/com/mangoo/rating/recommend/config/RecommendProperties.java
  9. 22 0
      mango-common/src/main/java/com/mangoo/rating/recommend/dto/recommend/EfficiencyDictItem.java
  10. 18 0
      mango-common/src/main/java/com/mangoo/rating/recommend/dto/recommend/SeriesCardSetKey.java
  11. 6 1
      mango-common/src/main/java/com/mangoo/rating/recommend/enums/MatchLevelEnum.java
  12. 24 3
      mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java
  13. 27 0
      mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RuleSyncCardDTO.java
  14. 0 2
      mango-domain/src/main/java/com/mangoo/rating/recommend/client/OrderApiClient.java
  15. 60 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/client/PreOrderApiClient.java
  16. 2 1
      mango-domain/src/main/java/com/mangoo/rating/recommend/client/feign/PreOrderFeignClient.java
  17. 25 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/client/feign/dto/PreOrderSaveResponseDTO.java
  18. 100 62
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RecommendServiceImpl.java
  19. 15 5
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RuleSyncServiceImpl.java
  20. 7 6
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/EfficiencyPriceProvider.java
  21. 4 3
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java
  22. 161 0
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/RuleMatcher.java
  23. 16 22
      mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/EfficiencyPriceProviderImpl.java
  24. 16 24
      mango-infrastructure/src/main/java/com/mangoo/rating/recommend/mapper/RatingRecommendRuleMapper.java
  25. 57 46
      mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml
  26. 14 15
      mango-manager/src/main/java/com/mangoo/rating/recommend/manager/RatingRecommendRuleManager.java
  27. 30 27
      mango-manager/src/main/java/com/mangoo/rating/recommend/manager/impl/RatingRecommendRuleManagerImpl.java

+ 0 - 1877
docs/superpowers/plans/2026-06-24-rating-efficiency-recommend.md

@@ -1,1877 +0,0 @@
-# 评级时效推荐 + 自动分单 + 报价 实现计划(Implementation Plan)
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** 为 RatingRecommend 项目实现一个无状态计算型 REST 接口——接收一批卡识别特征,逐卡按规则推荐评级时效(普通/快速/闪评),按时效精确分组为 N 个订单建议,附带每卡单价、订单数量与总计(价格走订单服务,预留口子+降级)。
-
-**Architecture:** 沿用 mango 多模块五层(Controller→Service→Manager→Mapper→PO),新增 1 张只读规则表。匹配降级(全等→L2 series+cardSet+year→L3 series+cardSet→兜底)由 Service 编排 + 3 个精确 SQL;分组抽成无 Spring 依赖的纯类 `OrderGrouper`,TDD 驱动;价格抽象 `EfficiencyPriceProvider` 接口,本期实现预留调订单服务的 Feign 口子并支持降级。
-
-**Tech Stack:** Spring Boot 2.2.5、PostgreSQL、MyBatis(手写 XML)、Lombok、Swagger 注解、JUnit5 + Mockito。
-
----
-
-## 关键设计决策(与 spec 一致)
-
-| # | 决策 | 备注 |
-|---|---|---|
-| D1 | 时效统一用 `EvaluateEfficiencyEnum.code`(Integer)| 普通=1 / 快速=2 / 闪评=3 |
-| D2 | 匹配策略:全等优先 + 系列主导模糊降级 + 兜底 | 命中即止 |
-| D3 | 降级查询:多次精确 SQL(Mapper 提供 3 个查询)| 走索引、DB 友好 |
-| D4 | 分组:同时效精确分组(纯类 `OrderGrouper`,TDD)| 容差聚类后续可扩展 |
-| D5 | 价格来源:调订单服务(order-service),预留 Feign 口子 | 本期降级,未接通则 unitPrice/totalAmount = null("待计算") |
-| D6 | 规则表只读 | 不产生任何 INSERT/UPDATE/DELETE 语句 |
-
----
-
-## 安全与合规(CLAUDE.md 数据库规则)
-
-- 涉及 **1 张新表 DDL**(`t_rating_recommend_rule`),由大侠在评审后**手动执行**;本计划只提供脚本文本,执行器不主动对数据库运行任何写操作。
-- Mapper XML **仅 SELECT**。
-- 不写 Redis、不发 MQ、不调用第三方变更接口。
-- 执行计划前请大侠对"建表 DDL(Task S1-4)"明确放行。
-
----
-
-## 文件结构总览(新建/修改)
-
-**mango-common**
-- 新建 `enums/MatchLevelEnum.java`
-- 新建 `po/RatingRecommendRulePO.java`
-- 新建 `request/recommend/RecommendCardDTO.java`、`request/recommend/RecommendRequest.java`
-- 新建 `response/recommend/RecommendCardVO.java`、`response/recommend/OrderSuggestionVO.java`、`response/recommend/RecommendResultVO.java`
-
-**mango-infrastructure**
-- 新建 `mapper/RatingRecommendRuleMapper.java` + `resources/mapper/RatingRecommendRuleMapper.xml`(仅 SELECT)
-
-**mango-manager**
-- 新建 `manager/RatingRecommendRuleManager.java` + `manager/impl/RatingRecommendRuleManagerImpl.java`
-
-**mango-domain**
-- 新建 `service/recommend/OrderGrouper.java`(纯类)
-- 新建 `service/recommend/EfficiencyPriceProvider.java` + `service/recommend/impl/EfficiencyPriceProviderImpl.java`
-- 新建 `service/RecommendService.java` + `service/impl/RecommendServiceImpl.java`
-
-**mango-application**
-- 修改 `RatingRecommendApplication.java`(启用数据源)
-- 新建 `app/controller/RecommendController.java`
-- 新建 `config/RecommendProperties.java`
-- 修改 `resources/application.yml`(新增 `recommend.*` 配置)
-- 测试:`src/test/java/.../recommend/OrderGrouperTest.java`、`RecommendServiceImplTest.java`
-
-**DDL**
-- 新建 `docs/superpowers/specs/ddl/2026-06-24-rating-recommend-rule.sql`
-
----
-
-## 测试策略
-
-- **纯算法 `OrderGrouper`**:纯 JUnit5 TDD,覆盖:空 / 单卡 / 全同档 / 多档乱序 / 3 档同时存在。毫秒级运行。
-- **`RecommendServiceImpl`**:Mockito 单测,覆盖:全等命中 / L2 命中 / L3 命中 / 兜底 / 非法规则值兜底 / 缺字段跳层 / 价格命中 / 价格缺失降级 / 空输入。
-- **Controller / 编排**:编译通过 + 手动接口验证;不写无断言的假测试。
-- 运行命令(Windows / 项目根):
-  - 单模块单测:`mvn -pl mango-application -am test -Dtest=OrderGrouperTest`
-  - 全量编译:`mvn -q -DskipTests clean install`
-
----
-
-## Phase S0 — 前置改造:启用数据源
-
-> 产出:项目启动时正常装配 Druid + PostgreSQL,规则表 Mapper 后续可被调用。
-
-### Task S0-1: 启用数据源自动装配
-
-**Files:**
-- Modify: `mango-application/src/main/java/com/mangoo/rating/recommend/RatingRecommendApplication.java`
-
-- [ ] **Step 1: 查看当前启动类**
-
-Run: `mvn -q -pl mango-application -am -DskipTests compile`
-Expected: BUILD SUCCESS(先确认基线可编译)
-
-- [ ] **Step 2: 修改启动类排除项,去掉 `DataSourceAutoConfiguration`**
-
-将启动类上原有的:
-```java
-@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class, SecurityAutoConfiguration.class})
-```
-改为:
-```java
-// 原排除 DataSourceAutoConfiguration 已去除:评级时效推荐功能需要查询规则表,必须启用数据源(PostgreSQL)
-@SpringBootApplication(exclude = {SecurityAutoConfiguration.class})
-```
-
-并删除不再需要的 `import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;`(如果存在)。
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-application -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 启动应用验证数据源装配**
-
-Run: `mvn -q -pl mango-application -am spring-boot:run`
-Expected: 启动日志含 `Druid` / `HikariPool` 之类数据源装配信息,应用端口 8094 监听成功,无 SQLException。
-
-> ⚠️ 若启动失败(数据源连接异常),需先确认 `application.yml` 中 `spring.datasource.url`/账号密码可用(默认 `192.168.77.80:5432/mongo_grade` / `postgres / 123456`)。
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add mango-application/src/main/java/com/mangoo/rating/recommend/RatingRecommendApplication.java
-git commit -m "chore(boot): 启用数据源自动装配(评级推荐功能前置)"
-```
-
----
-
-## Phase S1 — 数据底座:枚举、PO、Mapper、DDL
-
-> 产出:可只读访问规则数据的 PO、Mapper、枚举、建表脚本。
-
-### Task S1-1: 新增枚举 `MatchLevelEnum`
-
-**Files:**
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/enums/MatchLevelEnum.java`
-
-- [ ] **Step 1: 编写枚举**(照 `EvaluateEfficiencyEnum` 范式)
-
-```java
-package com.mangoo.rating.recommend.enums;
-
-import lombok.Getter;
-
-/**
- * 时效推荐命中层级枚举
- * EXACT:四字段全等命中
- * L2:series + cardSet + year 命中
- * L3:series + cardSet 命中
- * DEFAULT:未命中,走兜底默认档
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Getter
-public enum MatchLevelEnum {
-
-    EXACT("EXACT", "四字段全等命中"),
-    L2("L2", "系列+卡种+年份命中"),
-    L3("L3", "系列+卡种命中"),
-    DEFAULT("DEFAULT", "兜底默认档");
-
-    private final String code;
-    private final String desc;
-
-    MatchLevelEnum(String code, String desc) {
-        this.code = code;
-        this.desc = desc;
-    }
-}
-```
-
-- [ ] **Step 2: 编译验证**
-
-Run: `mvn -q -pl mango-common -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-common/src/main/java/com/mangoo/rating/recommend/enums/MatchLevelEnum.java
-git commit -m "feat(recommend): 新增命中层级枚举 MatchLevelEnum"
-```
-
----
-
-### Task S1-2: 新增 PO `RatingRecommendRulePO`
-
-**Files:**
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java`
-
-- [ ] **Step 1: 编写 PO**(照 `RecognizedInfoPO` 范式)
-
-```java
-package com.mangoo.rating.recommend.po;
-
-import io.swagger.annotations.ApiModelProperty;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-
-import java.time.LocalDateTime;
-
-/**
- * 评级时效推荐规则 PO(数仓沉淀,本期只读查询;命中键:player+year+series+cardSet)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@AllArgsConstructor
-@NoArgsConstructor
-public class RatingRecommendRulePO {
-
-    @ApiModelProperty(value = "主键ID")
-    private Long id;
-
-    @ApiModelProperty(value = "球员")
-    private String player;
-
-    @ApiModelProperty(value = "年份")
-    private String year;
-
-    @ApiModelProperty(value = "系列")
-    private String series;
-
-    @ApiModelProperty(value = "卡种")
-    private String cardSet;
-
-    @ApiModelProperty(value = "推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer recommendEfficiency;
-
-    @ApiModelProperty(value = "规则生效时间")
-    private LocalDateTime effectiveTime;
-
-    @ApiModelProperty(value = "创建时间")
-    private LocalDateTime createTime;
-
-    @ApiModelProperty(value = "修改时间")
-    private LocalDateTime updateTime;
-
-    @ApiModelProperty(value = "删除标识(0-未删,1-已删)")
-    private Integer delFlag;
-}
-```
-
-- [ ] **Step 2: 编译验证**
-
-Run: `mvn -q -pl mango-common -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java
-git commit -m "feat(recommend): 新增推荐规则 PO RatingRecommendRulePO"
-```
-
----
-
-### Task S1-3: Mapper 接口 + XML(三层查询,仅 SELECT)
-
-**Files:**
-- Create: `mango-infrastructure/src/main/java/com/mangoo/rating/recommend/mapper/RatingRecommendRuleMapper.java`
-- Create: `mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml`
-
-- [ ] **Step 1: 编写 Mapper 接口**(提供 L1/L2/L3 三个精确查询)
-
-```java
-package com.mangoo.rating.recommend.mapper;
-
-import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
-import org.apache.ibatis.annotations.Mapper;
-import org.apache.ibatis.annotations.Param;
-
-/**
- * 评级时效推荐规则 Mapper(本期仅只读查询)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Mapper
-public interface RatingRecommendRuleMapper {
-
-    /**
-     * L1:四字段全等命中(取最新生效)
-     */
-    RatingRecommendRulePO selectByExact(@Param("player") String player,
-                                        @Param("year") String year,
-                                        @Param("series") String series,
-                                        @Param("cardSet") String cardSet);
-
-    /**
-     * L2:series + cardSet + year 命中(player 放宽)
-     */
-    RatingRecommendRulePO selectBySeriesSetYear(@Param("series") String series,
-                                                @Param("cardSet") String cardSet,
-                                                @Param("year") String year);
-
-    /**
-     * L3:series + cardSet 命中(player + year 放宽)
-     */
-    RatingRecommendRulePO selectBySeriesSet(@Param("series") String series,
-                                            @Param("cardSet") String cardSet);
-}
-```
-
-- [ ] **Step 2: 编写 Mapper XML**
-
-```xml
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE mapper
-        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
-        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
-<mapper namespace="com.mangoo.rating.recommend.mapper.RatingRecommendRuleMapper">
-
-    <resultMap type="com.mangoo.rating.recommend.po.RatingRecommendRulePO" id="BaseResultMap">
-        <result property="id" column="id"/>
-        <result property="player" column="player"/>
-        <result property="year" column="year"/>
-        <result property="series" column="series"/>
-        <result property="cardSet" column="card_set"/>
-        <result property="recommendEfficiency" column="recommend_efficiency"/>
-        <result property="effectiveTime" column="effective_time"/>
-        <result property="createTime" column="create_time"/>
-        <result property="updateTime" column="update_time"/>
-        <result property="delFlag" column="del_flag"/>
-    </resultMap>
-
-    <sql id="baseColumnList">
-        id, player, year, series, card_set, recommend_efficiency, effective_time,
-        create_time, update_time, del_flag
-    </sql>
-
-    <!-- L1:四字段全等命中(取最新生效) -->
-    <select id="selectByExact" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND player = #{player}
-          AND year = #{year}
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          AND (effective_time IS NULL OR effective_time &lt;= now())
-        ORDER BY effective_time DESC NULLS LAST, id DESC
-        LIMIT 1
-    </select>
-
-    <!-- L2:系列+卡种+年份命中 -->
-    <select id="selectBySeriesSetYear" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          AND year = #{year}
-          AND (effective_time IS NULL OR effective_time &lt;= now())
-        ORDER BY effective_time DESC NULLS LAST, id DESC
-        LIMIT 1
-    </select>
-
-    <!-- L3:系列+卡种命中 -->
-    <select id="selectBySeriesSet" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          AND (effective_time IS NULL OR effective_time &lt;= now())
-        ORDER BY effective_time DESC NULLS LAST, id DESC
-        LIMIT 1
-    </select>
-
-</mapper>
-```
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-infrastructure -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-infrastructure/src/main/java/com/mangoo/rating/recommend/mapper/RatingRecommendRuleMapper.java mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml
-git commit -m "feat(recommend): 新增推荐规则 Mapper 与 XML(L1/L2/L3 三层精确查询)"
-```
-
----
-
-### Task S1-4: ⚠️ 建表 DDL(需大侠评审确认后执行)
-
-**Files:**
-- Create: `docs/superpowers/specs/ddl/2026-06-24-rating-recommend-rule.sql`
-
-> **此脚本不由执行器运行。** 仅生成文本,交大侠评审后在目标库手动执行。字段口径与 PO/XML 完全对应。
-
-- [ ] **Step 1: 编写 DDL 脚本**
-
-```sql
--- ============================================================
--- 评级时效推荐规则表 建表脚本(PostgreSQL)
--- ⚠️ 需大侠评审确认后手动执行;本期只读查询、不接受任何写入
--- 2026-06-24
--- ============================================================
-
-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 '评级时效推荐规则表(数仓沉淀,只读查询)';
-
--- 支撑 L2/L3 系列主导降级查询
-CREATE INDEX IF NOT EXISTS idx_rrr_series_set_year ON t_rating_recommend_rule (series, card_set, year);
--- 支撑 L1 全等命中查询
-CREATE INDEX IF NOT EXISTS idx_rrr_feature ON t_rating_recommend_rule (player, year, series, card_set);
-```
-
-- [ ] **Step 2: 提交脚本(仅文本,不执行)**
-
-```bash
-git add docs/superpowers/specs/ddl/2026-06-24-rating-recommend-rule.sql
-git commit -m "docs(recommend): 新增推荐规则表 DDL 脚本(待评审执行)"
-```
-
-- [ ] **Step 3: 🛑 检查点——请大侠确认 DDL 后,在目标库手动执行,再继续 S2。**
-
----
-
-## Phase S2 — Manager(接口 + 实现)
-
-### Task S2-1: `RatingRecommendRuleManager` 接口 + Impl
-
-**Files:**
-- Create: `mango-manager/src/main/java/com/mangoo/rating/recommend/manager/RatingRecommendRuleManager.java`
-- Create: `mango-manager/src/main/java/com/mangoo/rating/recommend/manager/impl/RatingRecommendRuleManagerImpl.java`
-
-- [ ] **Step 1: 编写接口**
-
-```java
-package com.mangoo.rating.recommend.manager;
-
-import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
-
-/**
- * 评级时效推荐规则 Manager(封装 Mapper,做空值校验与策略上抛)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-public interface RatingRecommendRuleManager {
-
-    /**
-     * L1:四字段全等命中(任一为空则不查询,返回 null)
-     */
-    RatingRecommendRulePO selectByExact(String player, String year, String series, String cardSet);
-
-    /**
-     * L2:系列+卡种+年份命中(三个参与字段任一为空则不查询)
-     */
-    RatingRecommendRulePO selectBySeriesSetYear(String series, String cardSet, String year);
-
-    /**
-     * L3:系列+卡种命中(两个参与字段任一为空则不查询)
-     */
-    RatingRecommendRulePO selectBySeriesSet(String series, String cardSet);
-}
-```
-
-- [ ] **Step 2: 编写实现**
-
-```java
-package com.mangoo.rating.recommend.manager.impl;
-
-import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
-import com.mangoo.rating.recommend.mapper.RatingRecommendRuleMapper;
-import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Component;
-
-import javax.annotation.Resource;
-
-/**
- * 评级时效推荐规则 Manager 实现
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Slf4j
-@Component
-public class RatingRecommendRuleManagerImpl implements RatingRecommendRuleManager {
-
-    @Resource
-    private RatingRecommendRuleMapper ratingRecommendRuleMapper;
-
-    @Override
-    public RatingRecommendRulePO selectByExact(String player, String year, String series, String cardSet) {
-        // L1 参与字段全部不为空才查询(避免无意义 SQL)
-        if (isBlank(player) || isBlank(year) || isBlank(series) || isBlank(cardSet)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectByExact(player, year, series, cardSet);
-    }
-
-    @Override
-    public RatingRecommendRulePO selectBySeriesSetYear(String series, String cardSet, String year) {
-        // L2 三个参与字段不能为空
-        if (isBlank(series) || isBlank(cardSet) || isBlank(year)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectBySeriesSetYear(series, cardSet, year);
-    }
-
-    @Override
-    public RatingRecommendRulePO selectBySeriesSet(String series, String cardSet) {
-        // L3 两个参与字段不能为空
-        if (isBlank(series) || isBlank(cardSet)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectBySeriesSet(series, cardSet);
-    }
-
-    /**
-     * 简单空白判断(避免引入 commons-lang 仅用一次)
-     */
-    private boolean isBlank(String s) {
-        return s == null || s.trim().isEmpty();
-    }
-}
-```
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-manager -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-manager/src/main/java/com/mangoo/rating/recommend/manager/RatingRecommendRuleManager.java mango-manager/src/main/java/com/mangoo/rating/recommend/manager/impl/RatingRecommendRuleManagerImpl.java
-git commit -m "feat(recommend): 新增推荐规则 Manager 接口与实现"
-```
-
----
-
-## Phase S3 — 配置 + 请求/响应 DTO
-
-### Task S3-1: 配置类 `RecommendProperties` + yml
-
-**Files:**
-- Create: `mango-application/src/main/java/com/mangoo/rating/recommend/config/RecommendProperties.java`
-- Modify: `mango-application/src/main/resources/application.yml`
-
-- [ ] **Step 1: 编写配置类**
-
-```java
-package com.mangoo.rating.recommend.config;
-
-import lombok.Data;
-import org.springframework.boot.context.properties.ConfigurationProperties;
-import org.springframework.stereotype.Component;
-
-import java.util.LinkedHashMap;
-import java.util.Map;
-
-/**
- * 评级时效推荐相关配置
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@Component
-@ConfigurationProperties(prefix = "recommend")
-public class RecommendProperties {
-
-    /** 兜底默认时效档(EvaluateEfficiencyEnum.code,默认 1=普通) */
-    private int defaultEfficiency = 1;
-
-    /** 单次请求卡数量上限 */
-    private int batchMax = 50;
-
-    /**
-     * 服务等级时效字符串 → 时效 code 映射(报价用,订单服务返回的 timeLimit 是字符串)。
-     * 例:「普通: 1」「快速: 2」「闪评: 3」「7个工作日: 1」「1个工作日: 3」
-     */
-    private Map<String, Integer> timeLimitMapping = new LinkedHashMap<>();
-}
-```
-
-- [ ] **Step 2: 在 `application.yml` 文件末尾追加配置**(注意与现有文件根级缩进一致)
-
-```yaml
-# 评级时效推荐功能配置
-recommend:
-  default-efficiency: 1
-  batch-max: 50
-  time-limit-mapping:
-    "普通": 1
-    "快速": 2
-    "闪评": 3
-```
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-application -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-application/src/main/java/com/mangoo/rating/recommend/config/RecommendProperties.java mango-application/src/main/resources/application.yml
-git commit -m "feat(recommend): 新增推荐功能配置 RecommendProperties"
-```
-
----
-
-### Task S3-2: 请求 DTO(`RecommendCardDTO`、`RecommendRequest`)
-
-**Files:**
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendCardDTO.java`
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendRequest.java`
-
-- [ ] **Step 1: 编写 `RecommendCardDTO`**
-
-```java
-package com.mangoo.rating.recommend.request.recommend;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import javax.validation.constraints.NotBlank;
-
-/**
- * 单卡推荐入参(识别特征 + 透传字段)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@ApiModel("单卡推荐入参")
-public class RecommendCardDTO {
-
-    @ApiModelProperty(value = "前端临时卡标识(用于回指订单归属,仅透传不入库)", required = true)
-    @NotBlank(message = "clientCardId 不能为空")
-    private String clientCardId;
-
-    @ApiModelProperty(value = "球员")
-    private String player;
-
-    @ApiModelProperty(value = "年份")
-    private String year;
-
-    @ApiModelProperty(value = "系列")
-    private String series;
-
-    @ApiModelProperty(value = "卡种")
-    private String cardSet;
-
-    @ApiModelProperty(value = "正面图URL(仅透传回显)")
-    private String frontImageUrl;
-
-    @ApiModelProperty(value = "反面图URL(仅透传回显)")
-    private String backImageUrl;
-}
-```
-
-- [ ] **Step 2: 编写 `RecommendRequest`**
-
-```java
-package com.mangoo.rating.recommend.request.recommend;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import javax.validation.Valid;
-import javax.validation.constraints.NotEmpty;
-import java.util.List;
-
-/**
- * 评级时效推荐请求体(批量卡)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@ApiModel("评级时效推荐请求体")
-public class RecommendRequest {
-
-    @ApiModelProperty(value = "卡列表", required = true)
-    @NotEmpty(message = "卡列表不能为空")
-    @Valid
-    private List<RecommendCardDTO> cards;
-}
-```
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-common -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendCardDTO.java mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RecommendRequest.java
-git commit -m "feat(recommend): 新增推荐请求 DTO RecommendCardDTO/RecommendRequest"
-```
-
----
-
-### Task S3-3: 响应 VO(`RecommendCardVO`、`OrderSuggestionVO`、`RecommendResultVO`)
-
-**Files:**
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendCardVO.java`
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/OrderSuggestionVO.java`
-- Create: `mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendResultVO.java`
-
-- [ ] **Step 1: 编写 `RecommendCardVO`**
-
-```java
-package com.mangoo.rating.recommend.response.recommend;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import java.math.BigDecimal;
-
-/**
- * 单卡推荐结果 VO
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@ApiModel("单卡推荐结果")
-public class RecommendCardVO {
-
-    @ApiModelProperty(value = "前端临时卡标识(原样回传)")
-    private String clientCardId;
-
-    @ApiModelProperty(value = "球员")
-    private String player;
-
-    @ApiModelProperty(value = "年份")
-    private String year;
-
-    @ApiModelProperty(value = "系列")
-    private String series;
-
-    @ApiModelProperty(value = "卡种")
-    private String cardSet;
-
-    @ApiModelProperty(value = "推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer recommendEfficiency;
-
-    @ApiModelProperty(value = "命中层级(EXACT/L2/L3/DEFAULT)")
-    private String matchLevel;
-
-    @ApiModelProperty(value = "该卡单价(价格取不到时为 null=待计算)")
-    private BigDecimal unitPrice;
-
-    @ApiModelProperty(value = "正面图URL(透传)")
-    private String frontImageUrl;
-
-    @ApiModelProperty(value = "反面图URL(透传)")
-    private String backImageUrl;
-}
-```
-
-- [ ] **Step 2: 编写 `OrderSuggestionVO`**
-
-```java
-package com.mangoo.rating.recommend.response.recommend;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-/**
- * 订单建议 VO(一个订单 = 一组同时效卡 + 一个统一推荐时效)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@ApiModel("订单建议")
-public class OrderSuggestionVO {
-
-    @ApiModelProperty(value = "分组号(1 起,档位升序)")
-    private Integer groupNo;
-
-    @ApiModelProperty(value = "订单推荐时效(EvaluateEfficiencyEnum.code)")
-    private Integer efficiency;
-
-    @ApiModelProperty(value = "订单推荐时效描述")
-    private String efficiencyDesc;
-
-    @ApiModelProperty(value = "该档单价(价格取不到时为 null=待计算)")
-    private BigDecimal unitPrice;
-
-    @ApiModelProperty(value = "订单中卡片数量")
-    private Integer cardCount;
-
-    @ApiModelProperty(value = "订单总计(=单价×数量;任一缺价则为 null)")
-    private BigDecimal totalAmount;
-
-    @ApiModelProperty(value = "订单内卡列表")
-    private List<RecommendCardVO> cards;
-}
-```
-
-- [ ] **Step 3: 编写 `RecommendResultVO`**
-
-```java
-package com.mangoo.rating.recommend.response.recommend;
-
-import io.swagger.annotations.ApiModel;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.Data;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-/**
- * 评级时效推荐响应结果
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Data
-@ApiModel("评级时效推荐响应")
-public class RecommendResultVO {
-
-    @ApiModelProperty(value = "总卡数")
-    private Integer totalCards;
-
-    @ApiModelProperty(value = "订单数")
-    private Integer orderCount;
-
-    @ApiModelProperty(value = "所有订单合计(任一订单缺价则为 null)")
-    private BigDecimal grandTotal;
-
-    @ApiModelProperty(value = "订单建议列表")
-    private List<OrderSuggestionVO> orders;
-}
-```
-
-- [ ] **Step 4: 编译验证**
-
-Run: `mvn -q -pl mango-common -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 5: 提交**
-
-```bash
-git add mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendCardVO.java mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/OrderSuggestionVO.java mango-common/src/main/java/com/mangoo/rating/recommend/response/recommend/RecommendResultVO.java
-git commit -m "feat(recommend): 新增推荐响应 VO RecommendCardVO/OrderSuggestionVO/RecommendResultVO"
-```
-
----
-
-## Phase S4 — 分组纯类(TDD)
-
-> 产出:无 Spring 依赖、纯函数式的 `OrderGrouper`,TDD 覆盖全部边界。
-
-### Task S4-1: 先写失败测试 `OrderGrouperTest`
-
-**Files:**
-- Create: `mango-application/src/test/java/com/mangoo/rating/recommend/cart/OrderGrouperTest.java`
-
-> 测试放在 mango-application/src/test(已确认含 `spring-boot-starter-test`,JUnit5 可用)。
-
-- [ ] **Step 1: 编写失败测试**
-
-```java
-package com.mangoo.rating.recommend.cart;
-
-import com.mangoo.rating.recommend.response.recommend.RecommendCardVO;
-import com.mangoo.rating.recommend.service.recommend.OrderGrouper;
-import com.mangoo.rating.recommend.response.recommend.OrderSuggestionVO;
-import org.junit.jupiter.api.Test;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/**
- * OrderGrouper TDD 单测
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-class OrderGrouperTest {
-
-    /**
-     * 构造测试用卡 VO,仅设置 clientCardId 与 recommendEfficiency(其他字段不参与分组)
-     */
-    private RecommendCardVO c(String no, int eff) {
-        RecommendCardVO v = new RecommendCardVO();
-        v.setClientCardId(no);
-        v.setRecommendEfficiency(eff);
-        return v;
-    }
-
-    @Test
-    void 空输入_返回空订单列表() {
-        List<OrderSuggestionVO> orders = OrderGrouper.group(new ArrayList<>());
-        assertTrue(orders.isEmpty());
-    }
-
-    @Test
-    void null输入_返回空订单列表() {
-        List<OrderSuggestionVO> orders = OrderGrouper.group(null);
-        assertTrue(orders.isEmpty());
-    }
-
-    @Test
-    void 单卡_返回单订单_组号1() {
-        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(1, orders.get(0).getCardCount());
-        assertEquals("a", orders.get(0).getCards().get(0).getClientCardId());
-    }
-
-    @Test
-    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(3, orders.get(0).getCardCount());
-    }
-
-    @Test
-    void 两档_按档位升序输出_组号从1递增() {
-        // 输入 闪评/普通 乱序,期望输出 普通(1) 在前
-        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(2, orders.get(0).getCardCount());
-        assertEquals(2, orders.get(1).getGroupNo());
-        assertEquals(3, orders.get(1).getEfficiency());
-        assertEquals(1, orders.get(1).getCardCount());
-    }
-
-    @Test
-    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()); // 闪评
-    }
-
-    @Test
-    void 组内卡列表保持原顺序() {
-        // 同档卡 a/b/c 输入顺序 a c b,期望分组内顺序 a c b
-        List<OrderSuggestionVO> orders = OrderGrouper.group(Arrays.asList(c("a", 1), c("c", 1), c("b", 1)));
-        assertEquals(1, orders.size());
-        assertEquals("a", orders.get(0).getCards().get(0).getClientCardId());
-        assertEquals("c", orders.get(0).getCards().get(1).getClientCardId());
-        assertEquals("b", orders.get(0).getCards().get(2).getClientCardId());
-    }
-}
-```
-
-- [ ] **Step 2: 运行测试,确认 FAIL(编译失败:`OrderGrouper` 不存在)**
-
-Run: `mvn -pl mango-application -am test -Dtest=OrderGrouperTest`
-Expected: 编译失败 / 找不到符号 — 符合 TDD 红灯
-
-- [ ] **Step 3: 提交失败测试(红灯阶段)**
-
-```bash
-git add mango-application/src/test/java/com/mangoo/rating/recommend/cart/OrderGrouperTest.java
-git commit -m "test(recommend): OrderGrouper TDD 红灯测试"
-```
-
----
-
-### Task S4-2: 实现 `OrderGrouper` 让测试通过
-
-**Files:**
-- Create: `mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java`
-
-- [ ] **Step 1: 编写实现**
-
-```java
-package com.mangoo.rating.recommend.service.recommend;
-
-import com.mangoo.rating.recommend.enums.EvaluateEfficiencyEnum;
-import com.mangoo.rating.recommend.response.recommend.OrderSuggestionVO;
-import com.mangoo.rating.recommend.response.recommend.RecommendCardVO;
-
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-
-/**
- * 订单分组纯算法(无 Spring 依赖)。
- * 规则:按每卡的 recommendEfficiency 精确分组,相同档位归为一个订单;
- *      订单按档位升序输出,groupNo 从 1 递增;
- *      组内卡列表保留输入原顺序(稳定)。
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-public final class OrderGrouper {
-
-    private OrderGrouper() {
-    }
-
-    /**
-     * 将卡列表按推荐时效分组为订单建议
-     */
-    public static List<OrderSuggestionVO> group(List<RecommendCardVO> cards) {
-        List<OrderSuggestionVO> result = new ArrayList<>();
-        if (cards == null || cards.isEmpty()) {
-            return result;
-        }
-
-        // 用 TreeMap 按档位升序归并;同档列表用 ArrayList 保留输入顺序(稳定)
-        Map<Integer, List<RecommendCardVO>> bucket = new TreeMap<>();
-        for (RecommendCardVO card : cards) {
-            Integer eff = card.getRecommendEfficiency();
-            bucket.computeIfAbsent(eff, k -> new ArrayList<>()).add(card);
-        }
-
-        int groupNo = 1;
-        for (Map.Entry<Integer, List<RecommendCardVO>> entry : bucket.entrySet()) {
-            OrderSuggestionVO order = new OrderSuggestionVO();
-            order.setGroupNo(groupNo++);
-            order.setEfficiency(entry.getKey());
-            order.setEfficiencyDesc(EvaluateEfficiencyEnum.getDescByCode(entry.getKey()));
-            order.setCardCount(entry.getValue().size());
-            order.setCards(entry.getValue());
-            // unitPrice / totalAmount 由上层报价后回填,此处不设置
-            result.add(order);
-        }
-        return result;
-    }
-}
-```
-
-- [ ] **Step 2: 运行测试,确认 PASS**
-
-Run: `mvn -pl mango-application -am test -Dtest=OrderGrouperTest`
-Expected: `Tests run: 7, Failures: 0, Errors: 0` — PASS
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java
-git commit -m "feat(recommend): 实现订单分组纯类 OrderGrouper(7 项 TDD 单测通过)"
-```
-
----
-
-## Phase S5 — 价格 Provider(接口 + 实现 + 降级)
-
-### Task S5-1: 价格 Provider 接口 `EfficiencyPriceProvider`
-
-**Files:**
-- Create: `mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/EfficiencyPriceProvider.java`
-
-- [ ] **Step 1: 编写接口**
-
-```java
-package com.mangoo.rating.recommend.service.recommend;
-
-import java.math.BigDecimal;
-import java.util.Map;
-
-/**
- * 时效价格 Provider(抽象接口,便于后续替换实现:本地缓存/订单服务 Feign/其他)。
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-public interface EfficiencyPriceProvider {
-
-    /**
-     * 加载"时效 code → 单价"映射。
-     * 取不到 / 异常时应返回空 Map,由上层降级为"待计算"。
-     *
-     * @return 时效 code -> 单价(如 1 -> 50.00, 2 -> 100.00, 3 -> 200.00)
-     */
-    Map<Integer, BigDecimal> loadEfficiencyPrices();
-}
-```
-
-- [ ] **Step 2: 编译验证**
-
-Run: `mvn -q -pl mango-domain -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/EfficiencyPriceProvider.java
-git commit -m "feat(recommend): 新增时效价格 Provider 接口"
-```
-
----
-
-### Task S5-2: 价格 Provider 实现(订单服务口子 + 降级)
-
-**Files:**
-- Create: `mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/EfficiencyPriceProviderImpl.java`
-
-> 本期为预留口子:未接通订单服务 Feign,loadEfficiencyPrices() 始终返回空 Map → 上层降级为"待计算"。后续接通时只需在此处填充查询逻辑、改 timeLimit 字符串→time效 code 的映射,不影响调用方。
-
-- [ ] **Step 1: 编写实现**
-
-```java
-package com.mangoo.rating.recommend.service.recommend.impl;
-
-import com.mangoo.rating.recommend.config.RecommendProperties;
-import com.mangoo.rating.recommend.dto.product.ProductServiceLevelCacheDTO;
-import com.mangoo.rating.recommend.enums.EvaluateEfficiencyEnum;
-import com.mangoo.rating.recommend.service.recommend.EfficiencyPriceProvider;
-import lombok.extern.slf4j.Slf4j;
-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;
-
-/**
- * 时效价格 Provider 实现(预留调订单服务的口子 + 降级)。
- *
- * 当前实现:未接通订单服务 Feign,直接返回空 Map,触发上层"待计算"降级。
- * 后续接通时:在 fetchServiceLevels() 中通过 Feign 调订单服务、得到 ProductServiceLevelCacheDTO 列表,
- *           再用 RecommendProperties.timeLimitMapping 把 timeLimit 字符串映射到 EvaluateEfficiencyEnum.code。
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Slf4j
-@Component
-public class EfficiencyPriceProviderImpl implements EfficiencyPriceProvider {
-
-    @Resource
-    private RecommendProperties recommendProperties;
-
-    @Override
-    public Map<Integer, BigDecimal> loadEfficiencyPrices() {
-        try {
-            // 1. 从订单服务获取全部服务等级(预留口子,当前返回空列表 → 触发降级)
-            List<ProductServiceLevelCacheDTO> levels = fetchServiceLevels();
-            if (levels == null || levels.isEmpty()) {
-                log.info("时效价格未配置或订单服务未接通,全部走'待计算'降级");
-                return Collections.emptyMap();
-            }
-
-            // 2. 将服务等级的 timeLimit(字符串) → EvaluateEfficiencyEnum.code 后聚合
-            Map<Integer, BigDecimal> priceMap = new LinkedHashMap<>();
-            Map<String, Integer> 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) {
-                    log.debug("无法映射服务等级 timeLimit={} 到时效 code,跳过", lvl.getTimeLimit());
-                    continue;
-                }
-                // 同档存在多条时,保留首个(订单服务侧应已去重;此处简单防御)
-                priceMap.putIfAbsent(effCode, lvl.getPrice());
-            }
-            return priceMap;
-        } catch (Exception e) {
-            // 价格能力任何异常都不应阻断推荐主体结果
-            log.warn("加载时效价格异常,本次降级为'待计算'", e);
-            return Collections.emptyMap();
-        }
-    }
-
-    /**
-     * 调订单服务取全部服务等级 —— 预留口子。
-     * 本期返回空列表(不接 Feign),后续接通时实现真实调用。
-     */
-    private List<ProductServiceLevelCacheDTO> fetchServiceLevels() {
-        // TODO: 后续接入 order-service Feign 客户端:调用其"全部服务等级"接口
-        return Collections.emptyList();
-    }
-}
-```
-
-- [ ] **Step 2: 编译验证**
-
-Run: `mvn -q -pl mango-domain -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/impl/EfficiencyPriceProviderImpl.java
-git commit -m "feat(recommend): 价格 Provider 实现(订单服务预留口子+降级)"
-```
-
----
-
-## Phase S6 — `RecommendService` 编排 + 单测
-
-### Task S6-1: `RecommendService` 接口 + Impl
-
-**Files:**
-- Create: `mango-domain/src/main/java/com/mangoo/rating/recommend/service/RecommendService.java`
-- Create: `mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RecommendServiceImpl.java`
-
-- [ ] **Step 1: 编写接口**
-
-```java
-package com.mangoo.rating.recommend.service;
-
-import com.mangoo.rating.recommend.AjaxResult;
-import com.mangoo.rating.recommend.request.recommend.RecommendRequest;
-
-/**
- * 评级时效推荐服务(编排:逐卡降级匹配 → 精确分组 → 报价 → 组装响应)
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-public interface RecommendService {
-
-    /**
-     * 推荐评级时效并按时效分单
-     *
-     * @param request 批量卡请求
-     * @return AjaxResult.data = RecommendResultVO
-     */
-    AjaxResult recommendEfficiency(RecommendRequest request);
-}
-```
-
-- [ ] **Step 2: 编写实现**(编排:四步走 + 价格降级)
-
-```java
-package com.mangoo.rating.recommend.service.impl;
-
-import com.mangoo.rating.recommend.AjaxResult;
-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.RatingRecommendRuleManager;
-import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
-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.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.OrderGrouper;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.stereotype.Service;
-
-import javax.annotation.Resource;
-import java.math.BigDecimal;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-
-/**
- * 评级时效推荐服务实现
- *
- * 流程:
- *   1) 逐卡按"全等→L2→L3→兜底"降级匹配,得到 (recommendEfficiency, matchLevel)
- *   2) 用纯类 OrderGrouper 按时效精确分组
- *   3) 加载时效价格(取不到则全部降级"待计算")
- *   4) 回填单价/总计/合计,组装响应
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Slf4j
-@Service
-public class RecommendServiceImpl implements RecommendService {
-
-    @Resource
-    private RatingRecommendRuleManager ratingRecommendRuleManager;
-
-    @Resource
-    private EfficiencyPriceProvider efficiencyPriceProvider;
-
-    @Resource
-    private RecommendProperties recommendProperties;
-
-    @Override
-    public AjaxResult recommendEfficiency(RecommendRequest request) {
-        // 入参校验
-        if (request == null || request.getCards() == null || request.getCards().isEmpty()) {
-            return AjaxResult.error("卡列表不能为空");
-        }
-        if (request.getCards().size() > recommendProperties.getBatchMax()) {
-            return AjaxResult.error("单次卡数量超过上限:" + recommendProperties.getBatchMax());
-        }
-
-        // 1) 逐卡推荐 → 转 VO
-        List<RecommendCardVO> cardVOs = new ArrayList<>(request.getCards().size());
-        for (RecommendCardDTO dto : request.getCards()) {
-            cardVOs.add(recommendOneCard(dto));
-        }
-
-        // 2) 按时效精确分组
-        List<OrderSuggestionVO> orders = OrderGrouper.group(cardVOs);
-
-        // 3) 报价(取不到则全部 null=待计算,不阻断)
-        Map<Integer, 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);
-    }
-
-    /**
-     * 单卡降级匹配:EXACT → L2 → L3 → DEFAULT
-     */
-    private RecommendCardVO recommendOneCard(RecommendCardDTO dto) {
-        RecommendCardVO vo = new RecommendCardVO();
-        vo.setClientCardId(dto.getClientCardId());
-        vo.setPlayer(dto.getPlayer());
-        vo.setYear(dto.getYear());
-        vo.setSeries(dto.getSeries());
-        vo.setCardSet(dto.getCardSet());
-        vo.setFrontImageUrl(dto.getFrontImageUrl());
-        vo.setBackImageUrl(dto.getBackImageUrl());
-
-        // L1:四字段全等命中
-        RatingRecommendRulePO rule = ratingRecommendRuleManager.selectByExact(
-                dto.getPlayer(), dto.getYear(), dto.getSeries(), dto.getCardSet());
-        if (isLegalRule(rule)) {
-            vo.setRecommendEfficiency(rule.getRecommendEfficiency());
-            vo.setMatchLevel(MatchLevelEnum.EXACT.getCode());
-            return vo;
-        }
-        // L2:series + cardSet + year
-        rule = ratingRecommendRuleManager.selectBySeriesSetYear(dto.getSeries(), dto.getCardSet(), dto.getYear());
-        if (isLegalRule(rule)) {
-            vo.setRecommendEfficiency(rule.getRecommendEfficiency());
-            vo.setMatchLevel(MatchLevelEnum.L2.getCode());
-            return vo;
-        }
-        // L3:series + cardSet
-        rule = ratingRecommendRuleManager.selectBySeriesSet(dto.getSeries(), dto.getCardSet());
-        if (isLegalRule(rule)) {
-            vo.setRecommendEfficiency(rule.getRecommendEfficiency());
-            vo.setMatchLevel(MatchLevelEnum.L3.getCode());
-            return vo;
-        }
-        // 兜底
-        vo.setRecommendEfficiency(recommendProperties.getDefaultEfficiency());
-        vo.setMatchLevel(MatchLevelEnum.DEFAULT.getCode());
-        return vo;
-    }
-
-    /**
-     * 校验规则及其时效值是否合法(非空 + 时效在 EvaluateEfficiencyEnum 范围内)
-     */
-    private boolean isLegalRule(RatingRecommendRulePO rule) {
-        return rule != null
-                && rule.getRecommendEfficiency() != null
-                && EvaluateEfficiencyEnum.getByCode(rule.getRecommendEfficiency()) != null;
-    }
-
-    /**
-     * 给订单列表回填单价/总计,并返回所有订单合计。
-     * 价格 Map 为空或某档无价 → 该订单 unitPrice/totalAmount = null("待计算"),并将 grandTotal 置 null。
-     */
-    private BigDecimal applyPricing(List<OrderSuggestionVO> orders, Map<Integer, BigDecimal> priceMap) {
-        if (orders == null || orders.isEmpty()) {
-            return null;
-        }
-        BigDecimal grand = BigDecimal.ZERO;
-        boolean grandNull = false;
-
-        for (OrderSuggestionVO order : orders) {
-            BigDecimal unit = (priceMap == null) ? null : priceMap.get(order.getEfficiency());
-            if (unit == null) {
-                // 该档价格缺失 → 订单 unitPrice/totalAmount = null,整体合计同步置 null
-                order.setUnitPrice(null);
-                order.setTotalAmount(null);
-                grandNull = true;
-                // 卡级单价同步 null
-                if (order.getCards() != null) {
-                    for (RecommendCardVO c : order.getCards()) {
-                        c.setUnitPrice(null);
-                    }
-                }
-                continue;
-            }
-            BigDecimal total = unit.multiply(BigDecimal.valueOf(order.getCardCount()));
-            order.setUnitPrice(unit);
-            order.setTotalAmount(total);
-            if (order.getCards() != null) {
-                for (RecommendCardVO c : order.getCards()) {
-                    c.setUnitPrice(unit);
-                }
-            }
-            grand = grand.add(total);
-        }
-        return grandNull ? null : grand;
-    }
-}
-```
-
-- [ ] **Step 3: 编译验证**
-
-Run: `mvn -q -pl mango-domain -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-domain/src/main/java/com/mangoo/rating/recommend/service/RecommendService.java mango-domain/src/main/java/com/mangoo/rating/recommend/service/impl/RecommendServiceImpl.java
-git commit -m "feat(recommend): 实现 RecommendService(降级匹配+分组+报价编排)"
-```
-
----
-
-### Task S6-2: `RecommendServiceImpl` 单元测试(Mockito)
-
-**Files:**
-- Create: `mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java`
-
-- [ ] **Step 1: 编写测试**(覆盖:L1/L2/L3/兜底/非法规则值/价格命中/价格降级/空入参/超限)
-
-```java
-package com.mangoo.rating.recommend.cart;
-
-import com.mangoo.rating.recommend.AjaxResult;
-import com.mangoo.rating.recommend.config.RecommendProperties;
-import com.mangoo.rating.recommend.enums.MatchLevelEnum;
-import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
-import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
-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 org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.InjectMocks;
-import org.mockito.Mock;
-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.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.lenient;
-import static org.mockito.Mockito.when;
-
-/**
- * RecommendServiceImpl Mockito 单测
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@ExtendWith(MockitoExtension.class)
-class RecommendServiceImplTest {
-
-    @Mock
-    private RatingRecommendRuleManager ruleManager;
-
-    @Mock
-    private EfficiencyPriceProvider priceProvider;
-
-    @Mock
-    private RecommendProperties recommendProperties;
-
-    @InjectMocks
-    private RecommendServiceImpl recommendService;
-
-    @BeforeEach
-    void setUp() {
-        lenient().when(recommendProperties.getDefaultEfficiency()).thenReturn(1);
-        lenient().when(recommendProperties.getBatchMax()).thenReturn(50);
-        // 默认价格 Map:1=>50, 2=>100, 3=>200
-        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);
-    }
-
-    private RecommendCardDTO card(String id, String player, String year, String series, String cardSet) {
-        RecommendCardDTO d = new RecommendCardDTO();
-        d.setClientCardId(id);
-        d.setPlayer(player);
-        d.setYear(year);
-        d.setSeries(series);
-        d.setCardSet(cardSet);
-        return d;
-    }
-
-    private RecommendRequest req(RecommendCardDTO... cards) {
-        RecommendRequest r = new RecommendRequest();
-        r.setCards(new ArrayList<>(Arrays.asList(cards)));
-        return r;
-    }
-
-    private RatingRecommendRulePO rule(Integer eff) {
-        RatingRecommendRulePO po = new RatingRecommendRulePO();
-        po.setRecommendEfficiency(eff);
-        return po;
-    }
-
-    @Test
-    void 空入参_返回error() {
-        AjaxResult result = recommendService.recommendEfficiency(null);
-        assertEquals(500, ((Integer) result.get("code")));
-
-        result = recommendService.recommendEfficiency(new RecommendRequest());
-        assertEquals(500, ((Integer) result.get("code")));
-    }
-
-    @Test
-    void 超限_返回error() {
-        when(recommendProperties.getBatchMax()).thenReturn(1);
-        AjaxResult result = recommendService.recommendEfficiency(
-                req(card("c1", "p", "y", "s", "cs"), card("c2", "p", "y", "s", "cs")));
-        assertEquals(500, ((Integer) result.get("code")));
-    }
-
-    @Test
-    void L1全等命中_单卡_单订单_含价格() {
-        when(ruleManager.selectByExact("Jordan", "1986", "Fleer", "Base")).thenReturn(rule(3));
-
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO data = (RecommendResultVO) result.get("data");
-
-        assertEquals(1, data.getTotalCards());
-        assertEquals(1, data.getOrderCount());
-        OrderSuggestionVO order = data.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"), data.getGrandTotal());
-    }
-
-    @Test
-    void L1未命中_L2命中_matchLevel为L2() {
-        when(ruleManager.selectByExact(anyString(), anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear("Fleer", "Base", "1986")).thenReturn(rule(2));
-
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO data = (RecommendResultVO) result.get("data");
-        assertEquals(MatchLevelEnum.L2.getCode(), data.getOrders().get(0).getCards().get(0).getMatchLevel());
-        assertEquals(2, data.getOrders().get(0).getEfficiency());
-    }
-
-    @Test
-    void L1L2未命中_L3命中_matchLevel为L3() {
-        when(ruleManager.selectByExact(anyString(), anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear(anyString(), anyString(), anyString())).thenReturn(null);
-        when(ruleManager.selectBySeriesSet("Fleer", "Base")).thenReturn(rule(2));
-
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "Jordan", "1986", "Fleer", "Base")));
-        RecommendResultVO data = (RecommendResultVO) result.get("data");
-        assertEquals(MatchLevelEnum.L3.getCode(), data.getOrders().get(0).getCards().get(0).getMatchLevel());
-    }
-
-    @Test
-    void 三层均未命中_走兜底_matchLevel为DEFAULT() {
-        when(ruleManager.selectByExact(any(), any(), any(), any())).thenReturn(null);
-        when(ruleManager.selectBySeriesSetYear(any(), any(), any())).thenReturn(null);
-        when(ruleManager.selectBySeriesSet(any(), any())).thenReturn(null);
-
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "X", "Y", "Z", "W")));
-        RecommendResultVO data = (RecommendResultVO) result.get("data");
-        assertEquals(MatchLevelEnum.DEFAULT.getCode(), data.getOrders().get(0).getCards().get(0).getMatchLevel());
-        assertEquals(1, data.getOrders().get(0).getEfficiency()); // 默认普通
-    }
-
-    @Test
-    void 规则时效非法_跳过该层继续降级() {
-        when(ruleManager.selectByExact(any(), any(), any(), any())).thenReturn(rule(99)); // 非法
-        when(ruleManager.selectBySeriesSetYear(any(), any(), any())).thenReturn(rule(2)); // 合法
-        AjaxResult result = recommendService.recommendEfficiency(req(card("c1", "p", "y", "s", "cs")));
-        RecommendResultVO data = (RecommendResultVO) result.get("data");
-        // 99 非法 → 当作 L1 未命中,进入 L2 命中
-        assertEquals(MatchLevelEnum.L2.getCode(), data.getOrders().get(0).getCards().get(0).getMatchLevel());
-    }
-
-    @Test
-    void 价格Map为空_订单与卡的单价均为null_grandTotal为null() {
-        when(ruleManager.selectByExact(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 data = (RecommendResultVO) result.get("data");
-        assertNull(data.getOrders().get(0).getUnitPrice());
-        assertNull(data.getOrders().get(0).getTotalAmount());
-        assertNull(data.getOrders().get(0).getCards().get(0).getUnitPrice());
-        assertNull(data.getGrandTotal());
-    }
-
-    @Test
-    void 多卡多档_按档位升序分3单_总计正确() {
-        // 三张卡,分别命中 1/2/3 档
-        when(ruleManager.selectByExact("p1", "y", "s", "cs")).thenReturn(rule(1));
-        when(ruleManager.selectByExact("p2", "y", "s", "cs")).thenReturn(rule(2));
-        when(ruleManager.selectByExact("p3", "y", "s", "cs")).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 data = (RecommendResultVO) result.get("data");
-        assertEquals(3, data.getOrderCount());
-        assertEquals(1, data.getOrders().get(0).getEfficiency());
-        assertEquals(2, data.getOrders().get(1).getEfficiency());
-        assertEquals(3, data.getOrders().get(2).getEfficiency());
-        // grandTotal = 50 + 100 + 200 = 350
-        assertEquals(new BigDecimal("350.00"), data.getGrandTotal());
-    }
-
-    @Test
-    void 同档多卡_合并为一单_单价不变_总计为单价乘以数量() {
-        when(ruleManager.selectByExact(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 data = (RecommendResultVO) result.get("data");
-        assertEquals(1, data.getOrderCount());
-        OrderSuggestionVO order = data.getOrders().get(0);
-        assertEquals(3, order.getCardCount());
-        assertEquals(new BigDecimal("100.00"), order.getUnitPrice());
-        // 100 * 3 = 300.00
-        assertEquals(new BigDecimal("300.00"), order.getTotalAmount());
-    }
-
-    @Test
-    void 图片透传_前端clientCardId与图片原样返回() {
-        when(ruleManager.selectByExact(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 data = (RecommendResultVO) result.get("data");
-        assertEquals("MY-CARD-1", data.getOrders().get(0).getCards().get(0).getClientCardId());
-        assertEquals("http://x/f.jpg", data.getOrders().get(0).getCards().get(0).getFrontImageUrl());
-        assertEquals("http://x/b.jpg", data.getOrders().get(0).getCards().get(0).getBackImageUrl());
-    }
-}
-```
-
-- [ ] **Step 2: 运行测试,确认 PASS**
-
-Run: `mvn -pl mango-application -am test -Dtest=RecommendServiceImplTest`
-Expected: `Tests run: 11, Failures: 0, Errors: 0` — PASS
-
-> 若 `AjaxResult.get("code")` / `get("data")` 在当前实现中签名不同(如 `getCode()`/`getData()`),按实际签名替换断言取值方式。`AjaxResult` 在本项目中已确认存在 `success(String,Object)` / `error(String)` 等方法,断言层细节按运行时实际接口调整即可,不影响测试覆盖意图。
-
-- [ ] **Step 3: 提交**
-
-```bash
-git add mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java
-git commit -m "test(recommend): RecommendServiceImpl Mockito 单测(11 项)"
-```
-
----
-
-## Phase S7 — Controller + 冒烟验证
-
-### Task S7-1: 新增 `RecommendController`
-
-**Files:**
-- Create: `mango-application/src/main/java/com/mangoo/rating/recommend/app/controller/RecommendController.java`
-
-- [ ] **Step 1: 编写 Controller**
-
-```java
-package com.mangoo.rating.recommend.app.controller;
-
-import com.mangoo.rating.recommend.AjaxResult;
-import com.mangoo.rating.recommend.annotation.ApiLog;
-import com.mangoo.rating.recommend.enums.BusinessType;
-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 org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.RequestBody;
-import org.springframework.web.bind.annotation.RequestMapping;
-import org.springframework.web.bind.annotation.RestController;
-
-import javax.annotation.Resource;
-
-/**
- * 小程序-评级时效推荐接口
- *
- * @author gengjintao
- * @date 2026/06/24
- */
-@Api(tags = "小程序-评级时效推荐接口")
-@RestController
-@RequestMapping("/api/recommend")
-public class RecommendController {
-
-    @Resource
-    private RecommendService recommendService;
-
-    @ApiOperation("批量推荐评级时效并按时效分单")
-    @PostMapping("/efficiency")
-    @ApiLog(title = "评级时效推荐", businessType = BusinessType.SEARCH)
-    public AjaxResult recommendEfficiency(@Validated @RequestBody RecommendRequest request) {
-        return recommendService.recommendEfficiency(request);
-    }
-}
-```
-
-- [ ] **Step 2: 编译验证**
-
-Run: `mvn -q -pl mango-application -am -DskipTests compile`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 3: 启动应用 + 冒烟验证(需先完成 S0-1 数据源启用 + S1-4 DDL 评审执行)**
-
-Run: `mvn -q -pl mango-application -am spring-boot:run`
-
-调用接口(PowerShell 示例):
-```powershell
-$body = @'
-{
-  "cards": [
-    {"clientCardId":"c1","player":"Jordan","year":"1986","series":"Fleer","cardSet":"Base","frontImageUrl":"http://x/c1f.jpg"},
-    {"clientCardId":"c2","player":"Kobe","year":"2000","series":"Topps","cardSet":"Base"}
-  ]
-}
-'@
-Invoke-RestMethod -Method Post -Uri "http://localhost:8094/api/recommend/efficiency" -ContentType "application/json; charset=utf-8" -Body $body
-```
-
-Expected:
-- `code = 0`(success)
-- `data.totalCards = 2`,`data.orderCount` 与 `data.orders` 反映分组结果
-- `data.grandTotal = null`(订单服务未接通 → 价格降级"待计算")
-- 每张卡的 `clientCardId / frontImageUrl` 原样回传
-- 每张卡 `matchLevel = DEFAULT`(规则表为空时全部走兜底)
-
-- [ ] **Step 4: 提交**
-
-```bash
-git add mango-application/src/main/java/com/mangoo/rating/recommend/app/controller/RecommendController.java
-git commit -m "feat(recommend): 新增推荐时效接口 Controller"
-```
-
----
-
-## Phase S8 — 全链路回归 + 收尾
-
-### Task S8-1: 全量编译 + 全部单测
-
-- [ ] **Step 1: 全量编译**
-
-Run: `mvn -q -DskipTests clean install`
-Expected: BUILD SUCCESS
-
-- [ ] **Step 2: 跑全部新增单测**
-
-Run: `mvn -pl mango-application -am test -Dtest=OrderGrouperTest,RecommendServiceImplTest`
-Expected: 全部 PASS(`OrderGrouperTest` 7 + `RecommendServiceImplTest` 11 = 18 项)
-
-- [ ] **Step 3: 手动接口回归(条件:DDL 已执行 + 数据源接通)**
-
-往 `t_rating_recommend_rule` 手动插入若干条规则数据(⚠️ 仅作为测试数据,需大侠确认后由您手动 INSERT;本计划不提供 INSERT 语句),例如:
-
-```sql
--- ⚠️ 仅作为测试数据,需大侠手动执行
--- INSERT INTO t_rating_recommend_rule(player, year, series, card_set, recommend_efficiency, effective_time)
--- VALUES ('Jordan', '1986', 'Fleer', 'Base', 3, now());
-```
-
-调用接口,期望:
-- 同 player/year/series/cardSet 全等 → `matchLevel = EXACT`,对应时效正确
-- 同 series/cardSet/year 部分匹配 → `matchLevel = L2`
-- 同 series/cardSet 部分匹配 → `matchLevel = L3`
-- 无任何匹配 → `matchLevel = DEFAULT`,时效=1
-
-- [ ] **Step 4: 提交(如有微调)/ 关闭计划**
-
-如全程无修改,跳过;如有修复,统一以 `fix(recommend): xxx` 提交。
-
----
-
-## 计划自审(Plan Self-Review)
-
-**1. 规格覆盖(对照 spec 12 节逐条)**
-- §1 目标无状态计算接口 → 全 Phase 实现(不写库)✓
-- §2 范围:本期做 5 项均覆盖(S1-S7);本期不做 3 项均未触及 ✓
-- §3 关键决策 D1-D8 → 在计划 Phase/Task 中逐条落地 ✓
-- §4 安全合规:DDL 标注评审、Mapper 仅 SELECT、无 Redis/MQ 写入 ✓
-- §5 接口契约:S3-2/3-3 完整 DTO+VO;S7-1 Controller 路径 `/api/recommend/efficiency` ✓
-- §6 核心逻辑:S6-1 实现编排(降级+分组+报价),S4 实现纯类分组 ✓
-- §7 分层文件结构:与计划"文件结构总览"完全对齐 ✓
-- §8 DDL:S1-4 提供完整 DDL(含两个索引)✓
-- §9 前置改造:S0-1 启用数据源 ✓
-- §10 测试策略:OrderGrouper 7 项 TDD + RecommendService 11 项 Mockito + 手动冒烟 ✓
-- §11 配置项 `recommend.*`:S3-1 配置类 + yml ✓
-- §12 开放/简化项:在 S5-2 实现中明确注释、留 TODO 给后续接入订单服务 ✓
-
-**2. 占位符扫描**:无 "TBD/TODO/待补充/类似 Task N" 占位;`EfficiencyPriceProviderImpl.fetchServiceLevels()` 内有一处 `// TODO: 后续接入 order-service Feign` 属于**预留口子的明示标注**(spec D5 明确为本期不做),不是计划占位符。
-
-**3. 类型/签名一致性**:
-- 时效统一 `Integer`(`EvaluateEfficiencyEnum.code`),PO/请求/响应/分组/报价口径一致 ✓
-- Mapper 三个方法 `selectByExact / selectBySeriesSetYear / selectBySeriesSet`,Manager/Service 调用名一致 ✓
-- `OrderGrouper.group(List<RecommendCardVO>) → List<OrderSuggestionVO>` 定义与调用一致 ✓
-- `EfficiencyPriceProvider.loadEfficiencyPrices() → Map<Integer, BigDecimal>` 定义与调用一致 ✓
-- `MatchLevelEnum.getCode()` 在 Service 写入 VO 时使用,VO 字段类型为 String,匹配 ✓
-- 配置 `recommend.default-efficiency` / `recommend.batch-max` / `recommend.time-limit-mapping` 与 `RecommendProperties` 字段一致 ✓
-
-**4. 歧义澄清**:
-- 价格降级口径:任一订单缺价 → 整体 `grandTotal=null`;订单内 `unitPrice/totalAmount=null` 与卡级 `unitPrice=null` 同步(S6-1 测试已固化)✓
-- 规则非法值(不在 1/2/3)→ 视为该层未命中,继续降级(S6-2 测试已固化)✓
-- 三档同时存在 → 最多 3 个订单,按档位升序(S4-1 测试已固化)✓
-- 组内卡列表顺序 = 输入顺序(S4-1 测试已固化)✓
-
----
-
-## 执行交接(Execution Handoff)
-
-**Plan complete and saved to `docs/superpowers/plans/2026-06-24-rating-efficiency-recommend.md`. 两种执行方式:**
-
-**1. Subagent-Driven(推荐)** — 每个 Task 派新子代理执行,任务间双阶段审查,迭代快、上下文干净。
-
-**2. Inline Execution** — 在当前会话用 executing-plans 批量执行 + 检查点审查。
-
-**⚠️ 检查点:**
-- **S0-1 启用数据源**:可能影响应用启动稳定性,建议大侠在执行后亲自启动一次确认。
-- **S1-4 建表 DDL**:必须由大侠在评审后**手动执行 DDL**,方可进入 S2 之后的运行时验证。
-
-**请大侠选择执行方式?**

+ 0 - 215
docs/superpowers/specs/2026-06-24-rating-efficiency-recommend-design.md

@@ -1,215 +0,0 @@
-# 评级时效推荐 + 自动分单 + 报价 设计文档(Design Spec)
-
-> 项目:RatingRecommend(卡牌评级小程序-推荐服务)
-> 日期:2026-06-24
-> 作者:gengjintao(大侠)/ Claude 协助
-> 状态:已评审,待转实现计划
-
----
-
-## 1. 目标(Goal)
-
-提供一个**无状态的计算型 REST 接口**:接收一批卡的识别特征(含图片),为每张卡推荐评级时效档位,再按时效将卡聚合为"多个订单建议",并给出每卡单价、订单数量与总计金额。
-
-- **无状态**:全程不写库、不建购物车、不创建真实订单、不结算。
-- 价格能力以"调订单服务 + 预留扩展口子 + 取不到则降级"的方式接入,本期不强依赖订单服务。
-
-## 2. 范围(Scope)
-
-**本期做:**
-- 批量卡特征输入 → 逐卡时效推荐(规则表)。
-- 时效精确分组 → 多订单建议。
-- 逐卡报价(时效→价格,来自订单服务,预留口子 + 降级)。
-- 独立 REST 接口(Controller→Service→Manager→Mapper 全链路)。
-- 规则表建表 DDL(标注需评审后手动执行)。
-
-**本期不做(YAGNI):**
-- 购物车落库、人工校对/重算、结算 checkout。
-- 容差聚类取齐分单(仅做"同时效精确分组",分组逻辑抽象、后续可扩展)。
-- 套餐入参、价格表新建、用户登录强校验。
-
-## 3. 关键设计决策
-
-| # | 决策 | 说明 |
-|---|---|---|
-| D1 | 时效档位直接用 `EvaluateEfficiencyEnum.code` | 普通=1 / 快速=2 / 闪评=3,天然有序,与现有订单口径一致。 |
-| D2 | 交付形态 = 独立 REST 接口(全链路) | `POST /api/recommend/efficiency`。 |
-| D3 | 匹配策略 = 全等优先 + 系列主导模糊降级 + 兜底 | 降级阶梯见 §6.1,命中即止。 |
-| D4 | 分组 = 同时效精确分组 | 相同最终时效归一个订单建议,按档位升序出 `groupNo`,最多 3 单;分组逻辑抽象为纯类,容差聚类后续扩展。 |
-| D5 | 价格来源 = 订单服务(order-service),实现预留口子 | 抽象 `EfficiencyPriceProvider`,本期可占位/降级;订单服务未接通时单价/总计为 `null`("待计算"),不阻断推荐。 |
-| D6 | 降级查询实现 = 多次精确 SQL | Mapper 按 L1/L2/L3 提供 3 个查询,Service 逐级调用,走索引、DB 友好、逻辑直白。 |
-| D7 | 接口不强制登录 | 纯计算无需用户上下文,沿用现有放行策略。 |
-| D8 | 规则数据只读 | 规则表数据由数仓同步 / 人工维护;本功能不产生任何写库语句。 |
-
-## 4. 安全与合规(CLAUDE.md 数据库规则)
-
-- 涉及 **1 张新表 DDL**(`t_rating_recommend_rule`),标注"⚠️ 需大侠评审确认后手动执行",执行器不主动对数据库运行任何写操作。
-- Mapper 仅含 **SELECT**,无任何 INSERT/UPDATE/DELETE。
-- 不写 Redis、不发 MQ、不调用第三方变更接口。
-- 价格读取为只读(订单服务查询 / 缓存读取)。
-
-## 5. 接口契约
-
-### 5.1 请求 `POST /api/recommend/efficiency`
-
-`RecommendRequest`:
-```json
-{
-  "cards": [
-    {
-      "clientCardId": "c1",
-      "player": "Jordan",
-      "year": "1986",
-      "series": "Fleer",
-      "cardSet": "Base",
-      "frontImageUrl": "http://x/c1f.jpg",
-      "backImageUrl": "http://x/c1b.jpg"
-    }
-  ]
-}
-```
-- `clientCardId`:前端为每张卡生成的临时标识,仅透传、不入库,用于回指卡归属的订单。
-- 四特征:`player / year / series / cardSet`。
-- 图片:`frontImageUrl / backImageUrl`,仅透传回显。
-
-### 5.2 响应 `AjaxResult.data = RecommendResultVO`
-
-```json
-{
-  "totalCards": 3,
-  "orderCount": 2,
-  "grandTotal": 360.00,
-  "orders": [
-    {
-      "groupNo": 1,
-      "efficiency": 3,
-      "efficiencyDesc": "闪评",
-      "unitPrice": 120.00,
-      "cardCount": 2,
-      "totalAmount": 240.00,
-      "cards": [
-        {
-          "clientCardId": "c1",
-          "player": "Jordan", "year": "1986", "series": "Fleer", "cardSet": "Base",
-          "recommendEfficiency": 3,
-          "matchLevel": "EXACT",
-          "unitPrice": 120.00,
-          "frontImageUrl": "http://x/c1f.jpg",
-          "backImageUrl": "http://x/c1b.jpg"
-        }
-      ]
-    }
-  ]
-}
-```
-
-字段说明:
-- 订单级:`groupNo`、`efficiency`/`efficiencyDesc`(订单推荐时效)、`unitPrice`(该档单价)、`cardCount`(卡数量)、`totalAmount`(订单总计 = 单价 × 数量)。
-- 卡级:`recommendEfficiency`、`matchLevel`(命中层级 EXACT/L2/L3/DEFAULT)、`unitPrice`、图片透传。
-- 汇总:`grandTotal`(所有订单合计)。
-- **降级**:价格取不到时,相关 `unitPrice/totalAmount/grandTotal` = `null`(前端显示"待计算"),不阻断推荐主体结果。
-
-## 6. 核心逻辑
-
-### 6.1 单卡匹配降级(系列主导,命中即止)
-
-```
-EXACT : player + year + series + cardSet 全等
-L2    : series + cardSet + year
-L3    : series + cardSet
-DEFAULT: 兜底默认档(可配置,默认 普通=1)
-```
-- 某一层参与字段存在空值,则跳过该层(如 `series` 为空,则 L2/L3 均无法命中 → 直接兜底)。
-- 命中但规则值非法(不在 1/2/3)→ 兜底。
-- 输出 `(recommendEfficiency, matchLevel)`。
-
-### 6.2 分组(纯类 `OrderGrouper`)
-
-- 按每卡最终推荐时效精确分组,相同档归一组。
-- 按档位升序输出 `groupNo`(普通→快速→闪评),最多 3 组。
-- 纯函数、无 Spring 依赖;TDD 覆盖:空输入 / 单卡 / 全同档 / 多档乱序。
-
-### 6.3 报价口子(`EfficiencyPriceProvider`)
-
-- 抽象方法:`Map<Integer, BigDecimal> loadEfficiencyPrices()`(时效 code → 单价)。
-- 本期实现:预留调订单服务(order-service)的 Feign 口子,取全部服务等级(`ProductServiceLevelCacheDTO`:含 `timeLimit` 字符串 + `price`),按"`timeLimit` 字符串 → `EvaluateEfficiencyEnum.code`"映射(映射规则配置化)。
-- 订单服务未接通 / 取不到 → 返回空 Map → 上层降级为"待计算"。
-- 设计意图:**口子预留,后续替换实现对上层零改动**。
-
-## 7. 分层与文件结构(包名 `com.mangoo.rating.recommend.*`)
-
-**mango-common**
-- `request/recommend/RecommendRequest.java`、`request/recommend/RecommendCardDTO.java`
-- `response/recommend/RecommendResultVO.java`、`response/recommend/OrderSuggestionVO.java`、`response/recommend/RecommendCardVO.java`
-- `po/RatingRecommendRulePO.java`
-- `enums/MatchLevelEnum.java`(EXACT/L2/L3/DEFAULT)
-- 复用 `enums/EvaluateEfficiencyEnum.java`
-
-**mango-infrastructure**
-- `mapper/RatingRecommendRuleMapper.java` + `resources/mapper/RatingRecommendRuleMapper.xml`(仅 SELECT)
-
-**mango-manager**
-- `manager/RatingRecommendRuleManager.java` + `manager/impl/RatingRecommendRuleManagerImpl.java`
-
-**mango-domain**
-- `service/RecommendService.java` + `service/impl/RecommendServiceImpl.java`(编排)
-- `service/recommend/OrderGrouper.java`(纯类,分组)
-- `service/recommend/EfficiencyPriceProvider.java` + 实现(价格口子 + 降级)
-
-**mango-application**
-- `app/controller/RecommendController.java`
-- `config/RecommendProperties.java`(默认兜底档 + 时效字符串映射配置)
-- 测试:`OrderGrouperTest`、`RecommendServiceImplTest`(Mockito)
-
-**DDL**
-- `docs/superpowers/specs/ddl/2026-06-24-rating-recommend-rule.sql`(待评审执行)
-
-## 8. 规则表 DDL(PostgreSQL,待评审)
-
-```sql
-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);
-```
-
-## 9. 前置改造(必须)
-
-当前启动类 `RatingRecommendApplication` 上 `exclude = {DataSourceAutoConfiguration.class}`,**数据源未启用**。本功能需查询规则表,**实现计划第一步须先启用数据源、接通 PostgreSQL**(移除排除项并校验既有配置)。
-
-## 10. 测试策略
-
-- **纯算法 `OrderGrouper`**:纯 JUnit5,TDD 先行,覆盖空/单卡/全同/多档乱序。
-- **降级匹配 / 报价映射**:Mockito 单测(mock Manager / PriceProvider / 配置)。
-- **编排 / 落库查询 / Controller**:编译通过 + `@SpringBootTest` 冒烟 + 手动接口验证;不写无断言的假测试。
-
-## 11. 配置项(`recommend.*`)
-
-```yaml
-recommend:
-  default-efficiency: 1          # 兜底默认档(EvaluateEfficiencyEnum.code)
-  # 服务等级时效字符串 → 时效 code 映射(报价用),示例:
-  time-limit-mapping:
-    "普通": 1
-    "快速": 2
-    "闪评": 3
-```
-
-## 12. 开放/已知简化项
-
-- 报价依赖订单服务,本期为预留口子;未接通时整单"待计算"。
-- 模糊匹配为"系列主导"三级阶梯,更复杂的相似度/热度权重留后续。
-- 分组为同时效精确分组,容差聚类取齐留后续(`OrderGrouper` 已抽象,便于扩展)。

+ 0 - 122
docs/superpowers/specs/2026-06-25-rating-datasync-trend-design.md

@@ -1,122 +0,0 @@
-# 数仓同步 + 卡片趋势接口 + 推荐增强 设计文档(Design Spec v2)
-
-> 项目:RatingRecommend | 日期:2026-06-25
-> 前置:本文是 `2026-06-24-rating-efficiency-recommend-design.md` 的扩展
-> 状态:已评审,待放行写库后实施
-
----
-
-## 1. 目标
-
-1. **数仓同步**:数仓每日 ETL 完成后,通过 HTTP 主动推送卡片规则数据(含 POP report、价值、价格历史)到本服务并落库。
-2. **卡片趋势/详情查询接口**:给前端提供对齐图片 UI 的卡片详情(身份 + POP + 价值 + 价格走势曲线)。
-3. **推荐响应增强**:推荐接口命中后,透传 POP/价值数据。
-
-## 2. 关键决策(本轮新增确认)
-
-| # | 决策 | 说明 |
-|---|---|---|
-| E1 | POP/价值/趋势对推荐**仅展示透传** | recommend_efficiency 仍由数仓直接给,推荐降级匹配逻辑不变 |
-| E2 | 卡片唯一键 = 数仓 `card_id` | 去重根基 |
-| E3 | POP report 按**三机构各一套**(PSA/BGS/Nexphostis)| 用明细行存表 |
-| E4 | 数仓主动推(HTTP)+ T+1 每日批量 | 本服务被动接收 |
-| E5 | 规则/POP/当前价:**全量批次替换**(batch_no + 指针切换,查询零中断)| 当前快照 |
-| E6 | 价格历史:**增量追加**(每日追加当天价,永久累积,不被全量覆盖)| 趋势数据源 |
-| E7 | 数据量小(万级),单次全量推送 + 单事务切换 | |
-
-## 3. 表结构
-
-### 3.1 `t_rating_recommend_rule`(规则主表,加字段)
-新增:`card_id`、`card_no`、`current_value`、`value_change_pct`、`value_min`、`value_max`、`batch_no`
-唯一约束:`(batch_no, card_id)`;查询索引:`(batch_no, series, card_set, year)`
-
-### 3.2 `t_rating_card_pop`(POP 明细,新表)
-`id, card_id, agency, grade_all, grade_10, grade_9, grade_8, grade_7, batch_no`
-索引:`(batch_no, card_id)`
-
-### 3.3 `t_rating_card_value_history`(价格历史,新表,增量累积)
-`id, card_id, price, record_date, create_time`
-唯一约束:`(card_id, record_date)`(同卡同日只一条,幂等)
-
-### 3.4 `t_rating_recommend_active_batch`(生效批次指针,新表,单行)
-`id, active_batch_no, update_time`
-
-## 4. 数仓同步接口
-
-`POST /api/recommend/rule/sync`(内部鉴权:Token/签名/IP 白名单)
-
-请求体:
-```json
-{
-  "batchNo": "2026062501",
-  "cards": [
-    { "cardId":"...", "cardNo":"066/080", "player":"AIPOM", "year":"2026",
-      "series":"pokemon.M2.JNP", "cardSet":"BASE", "recommendEfficiency":2,
-      "currentValue":15, "valueChangePct":7.05, "valueMin":10, "valueMax":45,
-      "recordDate":"2026-06-25", "todayPrice":15,
-      "pops":[ {"agency":"PSA","all":660,"g10":25,"g9":152,"g8":220,"g7":458}, ... ] }
-  ]
-}
-```
-
-处理(单事务):
-1. 校验:batchNo 合法、cards 非空(空拒绝,防全表清空)、batchNo 必须比当前生效批次新(防回灌)
-2. 按 cardId 去重(同批次重复 cardId 取最新/报错)
-3. **全量替换部分**:DELETE 该 batchNo 残留(规则表 + POP 表)→ 批量 INSERT 规则表 + POP 表
-4. **增量追加部分**:价格历史表按 `(card_id, record_date)` UPSERT 当天价(同日幂等)
-5. UPDATE 指针表 active_batch_no = batchNo
-6. 提交事务(查询原子切到新批次;历史表已追加)
-7. 事务后异步 DELETE 更早批次(规则表 + POP 表保留最近 1~2 批)
-
-## 5. 卡片趋势/详情查询接口
-
-`GET /api/recommend/card/detail?cardId={cardId}&period={7d|30d|90d|all}`(period 默认 30d)
-
-响应 `data` = `CardDetailVO`:
-```json
-{
-  "cardId":"...", "cardNo":"066/080", "name":"AIPOM", "year":"2026",
-  "series":"pokemon.M2.JNP", "cardSet":"BASE", "imageUrl":"...",
-  "pops":[ {"agency":"PSA","all":660,"g10":25,"g9":152,"g8":220,"g7":458}, ... ],
-  "value":{"current":15,"changePct":7.05,"min":10,"max":45},
-  "trend":[ {"date":"2026-06-01","price":12.0}, {"date":"2026-06-25","price":15.0} ]
-}
-```
-- 身份 + value:查规则表当前生效批次(按 card_id)
-- pops:查 POP 明细表(card_id + 生效批次)
-- trend:查价格历史表(card_id + period 时间窗,按日期升序)
-> 注:imageUrl 若数仓未提供则为 null(图片字段本期可选)
-
-## 6. 推荐响应增强
-
-- `RecommendCardVO` 增字段:`cardId、cardNo、currentValue、valueChangePct、valueMin、valueMax、pops[]`
-- `RecommendServiceImpl`:三层降级命中规则后,带出 card_id/价值,并按 card_id 补查 POP 明细放入 VO
-- 规则查询统一加 `batch_no = 当前生效批次` 过滤(读指针表,可缓存 30s)
-
-## 7. 去重 & 历史
-
-- 唯一键 `card_id`;同批次按 card_id 去重;规则/POP 跨批次全量替换 → 无脏历史
-- 价格历史按 `(card_id, record_date)` 幂等累积,永久保留
-
-## 8. ⚠️ 写库清单(CLAUDE.md 红线,需大侠放行)
-
-| 写操作 | 位置 |
-|---|---|
-| 4 张表 DDL(规则表加列 + POP/历史/指针 新表)| 由大侠**手动执行** |
-| 规则表/POP 表 DELETE + INSERT(全量替换)| 同步 Service/Mapper |
-| 价格历史表 UPSERT(增量)| 同步 Service/Mapper |
-| 指针表 UPDATE(切批次)| 同步 Service/Mapper |
-| 旧批次异步 DELETE | 同步 Service/Mapper |
-
-> 推荐查询、趋势查询、POP 查询均为只读 SELECT,不在红线内。
-
-## 9. 测试策略
-
-- 同步去重/防回灌/空保护:Service 单测(Mockito)
-- 趋势 period 时间窗过滤:Mapper/Service 验证
-- 推荐增强:扩展现有 RecommendServiceImplTest
-- 全链路:编译 + 单测 + 手动接口(需 DDL 执行 + 数据)
-
-## 10. 跨团队对接事项
-
-数仓侧需对齐:`cardId` 稳定性、`batchNo` 生成规则、`pops` 三机构口径、每日推送的 `recordDate/todayPrice` 字段、鉴权方式。

+ 26 - 0
mango-application/src/main/resources/db/migration/V20260708.1_rating_recommend_rule_add_identity_columns.sql

@@ -0,0 +1,26 @@
+-- ============================================================
+-- 规则表扩列:8 个识别属性字段(全字段匹配用)
+-- 2026-07-08
+-- 说明:配合匹配流水线重构(分类前提 + 本体分层降级),
+--       t_rating_recommend_rule 承载 RecommendCardDTO 全部识别字段。
+--       列名与 RatingRecommendRuleMapper.xml 的 resultMap/baseColumnList 严格对齐。
+-- 幂等:ADD COLUMN IF NOT EXISTS,可重复执行。
+-- ============================================================
+
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS sport_event VARCHAR(64);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS role        VARCHAR(128);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS team        VARCHAR(128);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS limit_id    VARCHAR(64);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS ip_name     VARCHAR(128);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS rule        VARCHAR(128);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS rarity      VARCHAR(64);
+ALTER TABLE t_rating_recommend_rule ADD COLUMN IF NOT EXISTS material    VARCHAR(64);
+
+COMMENT ON COLUMN t_rating_recommend_rule.sport_event IS '运动项目';
+COMMENT ON COLUMN t_rating_recommend_rule.role IS '角色';
+COMMENT ON COLUMN t_rating_recommend_rule.team IS '球队';
+COMMENT ON COLUMN t_rating_recommend_rule.limit_id IS '限编';
+COMMENT ON COLUMN t_rating_recommend_rule.ip_name IS 'IP名称';
+COMMENT ON COLUMN t_rating_recommend_rule.rule IS '角色名称(入参 rule 字段)';
+COMMENT ON COLUMN t_rating_recommend_rule.rarity IS '稀有度';
+COMMENT ON COLUMN t_rating_recommend_rule.material IS '材质';

+ 98 - 0
mango-application/src/test/java/com/mangoo/rating/recommend/cart/EfficiencyPriceProviderImplTest.java

@@ -0,0 +1,98 @@
+package com.mangoo.rating.recommend.cart;
+
+import com.mangoo.rating.recommend.client.OrderApiClient;
+import com.mangoo.rating.recommend.client.feign.dto.ProductServiceLevelDTO;
+import com.mangoo.rating.recommend.config.RecommendProperties;
+import com.mangoo.rating.recommend.dto.recommend.EfficiencyDictItem;
+import com.mangoo.rating.recommend.service.recommend.impl.EfficiencyPriceProviderImpl;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+/**
+ * EfficiencyPriceProviderImpl 字典桥接单测
+ *
+ * @author gengjintao
+ */
+@ExtendWith(MockitoExtension.class)
+class EfficiencyPriceProviderImplTest {
+
+    @Mock
+    private RecommendProperties recommendProperties;
+    @Mock
+    private OrderApiClient orderApiClient;
+    @InjectMocks
+    private EfficiencyPriceProviderImpl provider;
+
+    private ProductServiceLevelDTO level(long id, String name, String timeLimit, String price) {
+        ProductServiceLevelDTO d = new ProductServiceLevelDTO();
+        d.setId(id);
+        d.setLevelName(name);
+        d.setTimeLimit(timeLimit);
+        d.setPrice(new BigDecimal(price));
+        return d;
+    }
+
+    @Test
+    void 桥接_timeLimit映射到语义档code() {
+        Map<String, Long> mapping = new LinkedHashMap<>();
+        mapping.put("普通", 1L);
+        mapping.put("闪评", 3L);
+        when(recommendProperties.getTimeLimitMapping()).thenReturn(mapping);
+        when(orderApiClient.serviceList(any())).thenReturn(Arrays.asList(
+                level(11L, "普通档", "普通", "50.00"),
+                level(33L, "闪评档", "闪评", "200.00")));
+
+        Map<Long, EfficiencyDictItem> dict = provider.loadEfficiencyDict();
+        assertEquals("普通档", dict.get(1L).getName());
+        assertEquals(new BigDecimal("50.00"), dict.get(1L).getPrice());
+        assertEquals("闪评档", dict.get(3L).getName());
+    }
+
+    @Test
+    void 订单服务空_返回空字典() {
+        // serviceList 返回 null 时代码提前 return,不会访问 timeLimitMapping,故此处无需打桩
+        when(orderApiClient.serviceList(any())).thenReturn(null);
+        assertTrue(provider.loadEfficiencyDict().isEmpty());
+    }
+
+    @Test
+    void 同档多条_取首条() {
+        Map<String, Long> mapping = new LinkedHashMap<>();
+        mapping.put("闪评", 3L);
+        when(recommendProperties.getTimeLimitMapping()).thenReturn(mapping);
+        // 两条都映射到档 3,putIfAbsent 应保留首条
+        when(orderApiClient.serviceList(any())).thenReturn(Arrays.asList(
+                level(31L, "闪评首条", "闪评", "200.00"),
+                level(32L, "闪评次条", "闪评", "999.00")));
+        Map<Long, EfficiencyDictItem> dict = provider.loadEfficiencyDict();
+        assertEquals(1, dict.size());
+        assertEquals("闪评首条", dict.get(3L).getName());
+        assertEquals(new BigDecimal("200.00"), dict.get(3L).getPrice());
+    }
+
+    @Test
+    void timeLimit未在映射表_跳过该档() {
+        Map<String, Long> mapping = new LinkedHashMap<>();
+        mapping.put("普通", 1L);
+        when(recommendProperties.getTimeLimitMapping()).thenReturn(mapping);
+        when(orderApiClient.serviceList(any())).thenReturn(Arrays.asList(
+                level(11L, "普通档", "普通", "50.00"),
+                level(99L, "未知档", "超急速", "999.00")));
+        Map<Long, EfficiencyDictItem> dict = provider.loadEfficiencyDict();
+        assertEquals(1, dict.size());
+        assertTrue(dict.containsKey(1L));
+    }
+}

+ 88 - 20
mango-application/src/test/java/com/mangoo/rating/recommend/cart/RecommendServiceImplTest.java

@@ -3,6 +3,8 @@ 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.dto.recommend.EfficiencyDictItem;
+import com.mangoo.rating.recommend.dto.recommend.SeriesCardSetKey;
 import com.mangoo.rating.recommend.manager.ActiveBatchManager;
 import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
 import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
@@ -28,7 +30,6 @@ 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.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.lenient;
@@ -46,7 +47,9 @@ import static org.mockito.Mockito.when;
  *   4) userId 为 null(未登录)              → 非 0 code,且不调用 Feign
  *   5) Feign 抛异常                          → 非 0 code
  *   6) Feign 返回 code!=0                    → 非 0 code(下游 msg 不再透传,改为固定文案)
- *   7) 落库包字段映射:10 个识别字段 + userId/totalCards/packageId 全透传
+ *   7) 落库包字段映射:识别字段 + userId/totalCards/packageId 全透传
+ *   8) 未命中 → 按 cardType 兜底默认档(落库卡 evaluateEfficiencyId 取兜底档)
+ *   9) 批量查询:同 (series,cardSet) 卡去重后每次请求只查一次规则
  *
  * @author gengjintao
  */
@@ -88,14 +91,17 @@ class RecommendServiceImplTest {
         return r;
     }
 
-    /**
-     * 构造一条"合法且能通过 isLegalRule 校验"的 EXACT 命中规则。
-     * 注意 evaluateEfficiencyName 在 PO 中是 Long(非 String),此处按真实签名给 3L。
-     */
-    private RatingRecommendRulePO exactRule() {
+    /** 造一条 EXACT 命中规则(放入池) */
+    private RatingRecommendRulePO poolRule(long id, int eff, String player, String year,
+                                           String series, String cardSet) {
         RatingRecommendRulePO po = new RatingRecommendRulePO();
-        po.setEvaluateEfficiencyId(3L);
-        po.setEvaluateEfficiencyName(3L);
+        po.setId(id);
+        // PO 字段为 recommendEfficiencyId(Long);这里将 int 参数窄化装箱
+        po.setRecommendEfficiencyId((long) eff);
+        po.setPlayer(player);
+        po.setCardYear(year);
+        po.setSeries(series);
+        po.setCardSet(cardSet);
         po.setCardId("DW-CARD-1");
         return po;
     }
@@ -107,15 +113,16 @@ class RecommendServiceImplTest {
         return AjaxResult.success(data);
     }
 
-    /** 默认让匹配命中、价格可取、用户已登录(各用例可覆盖) */
+    /** 默认:批量池含一条 EXACT 命中、字典有价、用户已登录 */
     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(ratingRecommendRuleManager.selectByBatchAndSeriesSets(anyString(), any()))
+                .thenReturn(new ArrayList<>(Arrays.asList(
+                        poolRule(1L, 3, "Jordan", "1986", "Fleer", "Base"))));
+        Map<Long, EfficiencyDictItem> dict = new HashMap<>();
+        dict.put(3L, new EfficiencyDictItem("闪评", new BigDecimal("200.00")));
+        lenient().when(efficiencyPriceProvider.loadEfficiencyDict()).thenReturn(dict);
         lenient().when(loginUserProvider.currentUserId()).thenReturn(1001L);
         lenient().when(loginUserProvider.currentUserHeader()).thenReturn("base64header");
     }
@@ -187,6 +194,17 @@ class RecommendServiceImplTest {
     @Test
     void 落库包_识别字段与关键字段全部透传() {
         defaultHappyStubs();
+        // 入参卡设满了全部分类前提字段(cardType/sportEvent/ipName/team/role/rule),
+        // 规则须在这些前提字段上同值才能通过前提过滤命中(否则命中不了、matchedCardId 取不到)
+        RatingRecommendRulePO ruleWithType = poolRule(1L, 3, "Jordan", "1986", "Fleer", "Base");
+        ruleWithType.setCardType(2);
+        ruleWithType.setSportEvent("篮球");
+        ruleWithType.setIpName("NBA");
+        ruleWithType.setTeam("Bulls");
+        ruleWithType.setRole("SG");
+        ruleWithType.setRule("MVP");
+        when(ratingRecommendRuleManager.selectByBatchAndSeriesSets(anyString(), any()))
+                .thenReturn(new ArrayList<>(Arrays.asList(ruleWithType)));
         when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
                 .thenReturn(okWith("PRE999"));
 
@@ -202,12 +220,13 @@ class RecommendServiceImplTest {
         c.setRule("MVP");
         c.setRarity("SSR");
         c.setMaterial("金箔");
-        c.setId(88L);
-        c.setPrice(new BigDecimal("120"));
+        // DTO 字段已重命名为 packageId/packagePrice(原 id/price),对齐真实签名
+        c.setPackageId(88L);
+        c.setPackagePrice(new BigDecimal("120"));
 
         recommendService.recommendEfficiency(req(c));
 
-        // 抓取实际传给 Feign 的 PreOrderSaveDTO
+        // 抓取实际传给 Feign 的 PreOrderSaveResponseDTO
         ArgumentCaptor<PreOrderSaveDTO> captor = ArgumentCaptor.forClass(PreOrderSaveDTO.class);
         verify(preOrderFeignClient).savePreOrder(anyString(), captor.capture());
         PreOrderSaveDTO sent = captor.getValue();
@@ -232,7 +251,56 @@ class RecommendServiceImplTest {
         // 套餐/命中:入参 id -> packageId;规则 cardId -> matchedCardId
         assertEquals(88L, card.getPackageId());
         assertEquals("DW-CARD-1", card.getMatchedCardId());
-        // 时效价可取到时应大于 0
-        assertTrue(card.getEfficiencyPrice().compareTo(BigDecimal.ZERO) > 0);
+        // 命中档位 3(闪评)应装入卡的 evaluateEfficiencyId
+        assertEquals(3L, card.getEvaluateEfficiencyId());
+        // 时效价精确断言:字典档 3 → 200.00(compareTo 规避 scale 差异)
+        assertEquals(0, card.getEfficiencyPrice().compareTo(new BigDecimal("200.00")));
+    }
+
+    @Test
+    void 未命中_按cardType兜底默认档() {
+        defaultHappyStubs();
+        // 池为空 → 全部兜底;配置 cardType=1 默认档=2
+        when(ratingRecommendRuleManager.selectByBatchAndSeriesSets(anyString(), any()))
+                .thenReturn(new ArrayList<>());
+        Map<Integer, Long> byType = new HashMap<>();
+        byType.put(1, 2L);
+        when(recommendProperties.getDefaultEfficiencyByCardType()).thenReturn(byType);
+        // 字典含档 2 的价,保证能报价、能落库
+        Map<Long, EfficiencyDictItem> dict = new HashMap<>();
+        dict.put(2L, new EfficiencyDictItem("快速", new BigDecimal("100.00")));
+        when(efficiencyPriceProvider.loadEfficiencyDict()).thenReturn(dict);
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenReturn(okWith("PRE-DEF"));
+
+        RecommendCardDTO c = card("Nobody", "2099", "NoSeries", "NoSet");
+        c.setCardType(1);
+        recommendService.recommendEfficiency(req(c));
+
+        ArgumentCaptor<PreOrderSaveDTO> captor = ArgumentCaptor.forClass(PreOrderSaveDTO.class);
+        verify(preOrderFeignClient).savePreOrder(anyString(), captor.capture());
+        PreOrderCardDTO sent = captor.getValue().getGroups().get(0).getCards().get(0);
+        // 兜底档 2 应装入卡的 evaluateEfficiencyId
+        assertEquals(2L, sent.getEvaluateEfficiencyId());
+    }
+
+    @Test
+    void 批量查询_每次请求只查一次规则_且按seriesSet去重() {
+        defaultHappyStubs();
+        when(preOrderFeignClient.savePreOrder(anyString(), any(PreOrderSaveDTO.class)))
+                .thenReturn(okWith("PRE-ONCE"));
+        // 3 张同 (series,cardSet) 卡 → 去重后只有 1 个 key,且只调 1 次批量查询
+        recommendService.recommendEfficiency(req(
+                card("Jordan", "1986", "Fleer", "Base"),
+                card("Pippen", "1986", "Fleer", "Base"),
+                card("Rodman", "1986", "Fleer", "Base")));
+        // 抓取批量查询的 keys 入参,锁住 distinct 去重行为(删掉生产的 .distinct() 本用例会失败)
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<java.util.List<SeriesCardSetKey>> keysCaptor =
+                ArgumentCaptor.forClass(java.util.List.class);
+        verify(ratingRecommendRuleManager, org.mockito.Mockito.times(1))
+                .selectByBatchAndSeriesSets(anyString(), keysCaptor.capture());
+        // 3 张同系列+卡种 → 去重后 keys 只剩 1 个
+        assertEquals(1, keysCaptor.getValue().size());
     }
 }

+ 156 - 0
mango-application/src/test/java/com/mangoo/rating/recommend/cart/RuleMatcherTest.java

@@ -0,0 +1,156 @@
+package com.mangoo.rating.recommend.cart;
+
+import com.mangoo.rating.recommend.enums.MatchLevelEnum;
+import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
+import com.mangoo.rating.recommend.request.recommend.RecommendCardDTO;
+import com.mangoo.rating.recommend.service.recommend.RuleMatcher;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * RuleMatcher 纯算法单测:分类前提 + L0~L3 降级 + 多候选取舍
+ *
+ * @author gengjintao
+ */
+class RuleMatcherTest {
+
+    private RatingRecommendRulePO rule(long id, Integer eff, String player, String year,
+                                       String series, String cardSet) {
+        RatingRecommendRulePO po = new RatingRecommendRulePO();
+        po.setId(id);
+        // PO 字段为 recommendEfficiencyId(Long);测试参数是 Integer,此处窄化装箱
+        po.setRecommendEfficiencyId(eff == null ? null : eff.longValue());
+        po.setPlayer(player);
+        po.setCardYear(year);
+        po.setSeries(series);
+        po.setCardSet(cardSet);
+        return po;
+    }
+
+    private RecommendCardDTO card(String player, String year, String series, String cardSet) {
+        RecommendCardDTO d = new RecommendCardDTO();
+        d.setPlayer(player);
+        d.setYear(year);
+        d.setSeries(series);
+        d.setCardSet(cardSet);
+        return d;
+    }
+
+    @Test
+    void 空池_返回null() {
+        assertNull(RuleMatcher.match(card("p", "y", "s", "cs"), new ArrayList<>()));
+    }
+
+    @Test
+    void EXACT层_四字段全等命中() {
+        RatingRecommendRulePO r = rule(1, 2, "Jordan", "1986", "Fleer", "Base");
+        RuleMatcher.MatchResult res = RuleMatcher.match(card("Jordan", "1986", "Fleer", "Base"),
+                Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.EXACT, res.getLevel());
+        assertEquals(2L, res.getRule().getRecommendEfficiencyId());
+    }
+
+    @Test
+    void L3层_仅系列卡种命中() {
+        RatingRecommendRulePO r = rule(1, 1, "Other", "1990", "Fleer", "Base");
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base"); // player/year 不同 → 降到 L3
+        RuleMatcher.MatchResult res = RuleMatcher.match(c, Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.L3, res.getLevel());
+    }
+
+    @Test
+    void L0层_含cardNo等本体精化命中() {
+        RatingRecommendRulePO r = rule(1, 3, "Jordan", "1986", "Fleer", "Base");
+        r.setCardNo("023");
+        r.setLimitId("/99");
+        r.setRarity("SSR");
+        r.setMaterial("金箔");
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        c.setCardNo("023");
+        c.setLimitId("/99");
+        c.setRarity("SSR");
+        c.setMaterial("金箔");
+        RuleMatcher.MatchResult res = RuleMatcher.match(c, Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.L0, res.getLevel());
+    }
+
+    @Test
+    void 分类前提_cardType不吻合则不命中() {
+        RatingRecommendRulePO r = rule(1, 2, "Jordan", "1986", "Fleer", "Base");
+        r.setCardType(2); // 球星卡
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        c.setCardType(1); // 入参宝可梦卡 → 前提不吻合 → 不命中
+        assertNull(RuleMatcher.match(c, Arrays.asList(r)));
+    }
+
+    @Test
+    void 分类前提_入参为空则不约束() {
+        RatingRecommendRulePO r = rule(1, 2, "Jordan", "1986", "Fleer", "Base");
+        r.setCardType(2);
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        // c.cardType 为 null → 不约束 → 仍命中 EXACT
+        RuleMatcher.MatchResult res = RuleMatcher.match(c, Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.EXACT, res.getLevel());
+    }
+
+    @Test
+    void 多候选取舍_生效时间优先同时间取高档() {
+        // 反向对照锁死主序:older 的 eff 更高(5)但无生效时间;newer 的 eff 更低(1)但生效时间更新。
+        // 仅当 effective_time 是主序时,newer 才会胜出;若被错改成 eff 主序,本用例会 FAIL。
+        RatingRecommendRulePO older = rule(10, 5, "Jordan", "1986", "Fleer", "Base");
+        RatingRecommendRulePO newer = rule(2, 1, "Jordan", "1986", "Fleer", "Base");
+        newer.setEffectiveTime(LocalDateTime.of(2026, 7, 1, 0, 0));
+        RuleMatcher.MatchResult res = RuleMatcher.match(card("Jordan", "1986", "Fleer", "Base"),
+                Arrays.asList(older, newer));
+        assertNotNull(res);
+        // 断言取 effective_time 更新的 newer(eff=1),验证 time 是主序
+        assertEquals(1L, res.getRule().getRecommendEfficiencyId());
+        assertEquals(2L, res.getRule().getId());
+    }
+
+    @Test
+    void L1层_含cardNo命中() {
+        // 规则齐备至 L1 键(player+year+series+cardSet+cardNo),不含 limitId/rarity/material;
+        // 入参同样只到 cardNo → L0 因缺 limitId/rarity/material 落空 → 命中 L1
+        RatingRecommendRulePO r = rule(1, 2, "Jordan", "1986", "Fleer", "Base");
+        r.setCardNo("023");
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        c.setCardNo("023");
+        RuleMatcher.MatchResult res = RuleMatcher.match(c, Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.L1, res.getLevel());
+    }
+
+    @Test
+    void L2层_系列卡种年份命中() {
+        // player 与入参不同(触发 L0/L1/EXACT 落空),但 series+cardSet+cardYear 一致 → 命中 L2
+        RatingRecommendRulePO r = rule(1, 2, "OtherPlayer", "1986", "Fleer", "Base");
+        RecommendCardDTO c = card("Jordan", "1986", "Fleer", "Base");
+        RuleMatcher.MatchResult res = RuleMatcher.match(c, Arrays.asList(r));
+        assertNotNull(res);
+        assertEquals(MatchLevelEnum.L2, res.getLevel());
+    }
+
+    @Test
+    void 多候选取舍_同生效时间取高档() {
+        RatingRecommendRulePO low = rule(9, 1, "Jordan", "1986", "Fleer", "Base");
+        RatingRecommendRulePO high = rule(8, 3, "Jordan", "1986", "Fleer", "Base");
+        // 两条都无 effectiveTime(并列)→ 取 recommend_efficiency 高档(3)
+        RuleMatcher.MatchResult res = RuleMatcher.match(card("Jordan", "1986", "Fleer", "Base"),
+                Arrays.asList(low, high));
+        assertNotNull(res);
+        assertEquals(3L, res.getRule().getRecommendEfficiencyId());
+    }
+}

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

@@ -24,6 +24,12 @@ public class RecommendProperties {
     /** 单次请求卡数量上限 */
     private int batchMax = 50;
 
+    /**
+     * 兜底默认时效档(按 cardType 分档):key=cardType(1宝可梦/2球星卡),value=时效档 code。
+     * 四级匹配全落空时,优先按 cardType 取此档;取不到再退回全局 defaultEfficiencyId。
+     */
+    private Map<Integer, Long> defaultEfficiencyByCardType = new LinkedHashMap<>();
+
     /**
      * 服务等级时效字符串 → 时效 code 映射(报价用,订单服务返回的 timeLimit 是字符串)。
      * 例:「普通: 1」「快速: 2」「闪评: 3」「7个工作日: 1」「1个工作日: 3」

+ 22 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/dto/recommend/EfficiencyDictItem.java

@@ -0,0 +1,22 @@
+package com.mangoo.rating.recommend.dto.recommend;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+
+/**
+ * 时效字典项:时效展示名 + 单价(来源:订单服务服务等级列表)
+ *
+ * @author gengjintao
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class EfficiencyDictItem {
+    /** 时效展示名(取服务等级 levelName) */
+    private String name;
+    /** 时效单价 */
+    private BigDecimal price;
+}

+ 18 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/dto/recommend/SeriesCardSetKey.java

@@ -0,0 +1,18 @@
+package com.mangoo.rating.recommend.dto.recommend;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 批量查询用 (series, cardSet) 组合键
+ *
+ * @author gengjintao
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class SeriesCardSetKey {
+    private String series;
+    private String cardSet;
+}

+ 6 - 1
mango-common/src/main/java/com/mangoo/rating/recommend/enums/MatchLevelEnum.java

@@ -4,7 +4,9 @@ import lombok.Getter;
 
 /**
  * 时效推荐命中层级枚举
- * EXACT:四字段全等命中
+ * L0:本体全字段命中(含 cardNo/limitId/rarity/material)
+ * L1:player + year + series + cardSet + cardNo 命中
+ * EXACT:四字段全等命中(保留旧层级,兼容既有落库数据)
  * L2:series + cardSet + year 命中
  * L3:series + cardSet 命中
  * DEFAULT:未命中,走兜底默认档
@@ -15,6 +17,9 @@ import lombok.Getter;
 @Getter
 public enum MatchLevelEnum {
 
+    // L0/L1 为本体/精确匹配层,优先级最高,需排在最前
+    L0("L0", "本体全字段命中(含 cardNo/limitId/rarity/material)"),
+    L1("L1", "player+year+series+cardSet+cardNo 命中"),
     EXACT("EXACT", "四字段全等命中"),
     L2("L2", "系列+卡种+年份命中"),
     L3("L3", "系列+卡种命中"),

+ 24 - 3
mango-common/src/main/java/com/mangoo/rating/recommend/po/RatingRecommendRulePO.java

@@ -37,10 +37,31 @@ public class RatingRecommendRulePO {
     private String cardSet;
 
     @ApiModelProperty(value = "评级时效ID")
-    private Long evaluateEfficiencyId;
+    private Long recommendEfficiencyId;
 
-    @ApiModelProperty(value = "评级时效名称")
-    private Long evaluateEfficiencyName;
+    @ApiModelProperty(value = "运动项目")
+    private String sportEvent;
+
+    @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 rule;
+
+    @ApiModelProperty(value = "稀有度")
+    private String rarity;
+
+    @ApiModelProperty(value = "材质")
+    private String material;
 
     @ApiModelProperty(value = "规则生效时间")
     private LocalDateTime effectiveTime;

+ 27 - 0
mango-common/src/main/java/com/mangoo/rating/recommend/request/recommend/RuleSyncCardDTO.java

@@ -36,6 +36,33 @@ public class RuleSyncCardDTO {
     @ApiModelProperty(value = "卡种")
     private String cardSet;
 
+    @ApiModelProperty(value = "卡片类型:1-宝可梦卡,2-球星卡")
+    private Integer cardType;
+
+    @ApiModelProperty(value = "运动项目")
+    private String sportEvent;
+
+    @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 rule;
+
+    @ApiModelProperty(value = "稀有度")
+    private String rarity;
+
+    @ApiModelProperty(value = "材质")
+    private String material;
+
     @ApiModelProperty(value = "评级时效ID")
     private Long evaluateEfficiencyId;
 

+ 0 - 2
mango-domain/src/main/java/com/mangoo/rating/recommend/client/OrderApiClient.java

@@ -4,8 +4,6 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONObject;
 import com.mangoo.rating.recommend.client.feign.OrderServiceClient;
 import com.mangoo.rating.recommend.client.feign.dto.*;
-import com.mangoo.rating.recommend.dto.home.HomeBannerAppDTO;
-import com.mangoo.rating.recommend.dto.home.HomeDiamondAppDTO;
 import lombok.extern.log4j.Log4j2;
 import org.springframework.stereotype.Component;
 

+ 60 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/client/PreOrderApiClient.java

@@ -0,0 +1,60 @@
+package com.mangoo.rating.recommend.client;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.mangoo.rating.recommend.AjaxResult;
+import com.mangoo.rating.recommend.client.feign.OrderServiceClient;
+import com.mangoo.rating.recommend.client.feign.PreOrderFeignClient;
+import com.mangoo.rating.recommend.client.feign.dto.PreOrderSaveResponseDTO;
+import com.mangoo.rating.recommend.client.feign.dto.ProductServiceLevelDTO;
+import com.mangoo.rating.recommend.client.feign.dto.ProductServiceLevelRequest;
+import com.mangoo.rating.recommend.request.recommend.PreOrderSaveDTO;
+import lombok.extern.log4j.Log4j2;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * APP服务客户端
+ *
+ * @author gengjintao
+ * @date 2026/04/16
+ */
+@Log4j2
+@Component
+public class PreOrderApiClient {
+
+    @Resource
+    private PreOrderFeignClient preOrderFeignClient;
+
+
+
+   /**
+     * 预订单落库
+     *
+     * @param userHeader    用户信息
+     * @param dto           订单信息
+     * @return 是否成功
+     */
+    public String savePreOrder(String userHeader, PreOrderSaveDTO  dto) {
+        try {
+            ProductServiceLevelRequest request = new ProductServiceLevelRequest();
+            log.info("获取产品服务时效列表参数:{}", request);
+            JSONObject result = preOrderFeignClient.savePreOrder(userHeader, dto);
+            if (result != null && result.getInteger("code") == 0) {
+                Object object = result.get("data");
+                PreOrderSaveResponseDTO preOrderSaveDTO = JSON.parseObject(JSON.toJSONString(object), PreOrderSaveResponseDTO.class);
+                return preOrderSaveDTO.getPreOrderNo();
+            }
+        } catch (Exception e) {
+            // 异常
+            log.error("预订单落库远程调用异常:", e);
+        }
+        return null;
+    }
+
+
+}

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

@@ -1,5 +1,6 @@
 package com.mangoo.rating.recommend.client.feign;
 
+import com.alibaba.fastjson.JSONObject;
 import com.mangoo.rating.recommend.AjaxResult;
 import com.mangoo.rating.recommend.request.recommend.PreOrderSaveDTO;
 import org.springframework.cloud.openfeign.FeignClient;
@@ -24,6 +25,6 @@ public interface PreOrderFeignClient {
      * @return AjaxResult,code=0 时 data.preOrderNo 为预订单号
      */
     @PostMapping("/api/pre-order/save")
-    AjaxResult savePreOrder(@RequestHeader(value = "X-USER-BASE64", required = false) String userHeader,
+    JSONObject savePreOrder(@RequestHeader(value = "X-USER-BASE64", required = false) String userHeader,
                             @RequestBody PreOrderSaveDTO body);
 }

+ 25 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/client/feign/dto/PreOrderSaveResponseDTO.java

@@ -0,0 +1,25 @@
+package com.mangoo.rating.recommend.client.feign.dto;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 预订单落库响应
+ *
+ * @author gengjintao
+ * @date 2026/04/23
+ */
+@Data
+@ApiModel("预订单落库响应")
+public class PreOrderSaveResponseDTO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+
+    @ApiModelProperty(value = "预订单编号")
+    private String preOrderNo;
+
+}

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

@@ -1,8 +1,13 @@
 package com.mangoo.rating.recommend.service.impl;
 
 import com.mangoo.rating.recommend.AjaxResult;
+import com.mangoo.rating.recommend.client.PreOrderApiClient;
 import com.mangoo.rating.recommend.client.feign.PreOrderFeignClient;
 import com.mangoo.rating.recommend.config.RecommendProperties;
+import com.mangoo.rating.recommend.dto.recommend.EfficiencyDictItem;
+import com.mangoo.rating.recommend.dto.recommend.SeriesCardSetKey;
+import com.mangoo.rating.recommend.dto.recommend.SeriesRoleKey;
+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.RatingRecommendRuleManager;
@@ -18,6 +23,7 @@ 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 com.mangoo.rating.recommend.service.recommend.RuleMatcher;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
@@ -34,10 +40,11 @@ import java.util.stream.Collectors;
  *
  * 流程:
  *   0) 读当前生效批次号(无则后续查询走兜底)
- *   1) 逐卡按"全等→L2→L3→兜底"降级匹配(带 batchNo 过滤),命中带出价值并补查 POP
- *   2) 用纯类 OrderGrouper 按时效精确分组
- *   3) 加载时效价格(取不到则全部降级"待计算")
- *   4) 回填单价/总计/合计,组装响应
+ *   1) 按去重的 (series,cardSet) 一次批量捞回候选规则集(N+1 消除)
+ *   2) 逐卡由 RuleMatcher 做"分类前提过滤 + L0~L3 降级 + 多候选取舍"的内存匹配;未命中按 cardType 分档兜底
+ *   3) 加载订单服务时效字典(一次拉取共享)→ 回填分组时效名 → 报价(字典缺失该档降级"待计算")
+ *   4) 取当前登录用户 → 组装预订单落库包
+ *   5) Feign 远程调 ratingApp 落库,返回 preOrderNo(推荐服务不写库)
  *
  * @author gengjintao
  * @date 2026/06/24
@@ -61,6 +68,10 @@ public class RecommendServiceImpl implements RecommendService {
     @Resource
     private PreOrderFeignClient preOrderFeignClient;
 
+
+    @Resource
+    private PreOrderApiClient preOrderApiClient;
+
     @Resource
     private LoginUserProvider loginUserProvider;
 
@@ -76,18 +87,38 @@ public class RecommendServiceImpl implements RecommendService {
         // 0) 当前生效批次号(为空说明尚未同步过规则数据 → 全部走兜底)
         String activeBatchNo = activeBatchManager.selectActiveBatchNo();
 
-        // 1) 逐卡推荐
+        // 1) 批量捞规则:按 cardType 分派收窄键 —— 宝可梦(cardType=1)按 (series,role)、其它(球星卡/未知)按 (series,cardSet)
+        List<SeriesCardSetKey> cardSetKeys = request.getCards().stream()
+                .filter(c -> !isPokemon(c.getCardType()))
+                .filter(c -> c.getSeries() != null && c.getCardSet() != null)
+                .map(c -> new SeriesCardSetKey(c.getSeries(), c.getCardSet()))
+                .distinct()
+                .collect(Collectors.toList());
+        List<SeriesRoleKey> roleKeys = request.getCards().stream()
+                .filter(c -> isPokemon(c.getCardType()))
+                .filter(c -> c.getSeries() != null && c.getRole() != null)
+                .map(c -> new SeriesRoleKey(c.getSeries(), c.getRole()))
+                .distinct()
+                .collect(Collectors.toList());
+        List<RatingRecommendRulePO> pool = new ArrayList<>();
+        if (!cardSetKeys.isEmpty()) {
+            pool.addAll(ratingRecommendRuleManager.selectByBatchAndSeriesSets(activeBatchNo, cardSetKeys));
+        }
+        if (!roleKeys.isEmpty()) {
+            pool.addAll(ratingRecommendRuleManager.selectByBatchAndSeriesRoles(activeBatchNo, roleKeys));
+        }
+
+        // 2) 逐卡内存匹配(RuleMatcher 做前提过滤 + L0~L3 降级 + 取舍),未命中按 cardType 兜底
         List<RecommendCardVO> cardVOs = new ArrayList<>(request.getCards().size());
         for (RecommendCardDTO dto : request.getCards()) {
-            cardVOs.add(recommendOneCard(dto, activeBatchNo));
+            cardVOs.add(recommendOneCard(dto, pool));
         }
 
-        // 2) 按时效精确分组
+        // 3) 按时效精确分组 → 加载时效字典(名称+价,一次拉取共享)→ 回填组名 + 报价
         List<OrderSuggestionVO> orders = OrderGrouper.group(cardVOs);
-
-        // 3) 报价
-        Map<Long, BigDecimal> priceMap = efficiencyPriceProvider.loadEfficiencyPrices();
-        BigDecimal grandTotal = applyPricing(orders, priceMap);
+        Map<Long, EfficiencyDictItem> dict = efficiencyPriceProvider.loadEfficiencyDict();
+        fillGroupNames(orders, dict);
+        BigDecimal grandTotal = applyPricing(orders, dict);
 
         // 4) 取当前用户(走 Provider 而非直接调 UserUtils 静态方法,便于单测 mock)
         Long userId = loginUserProvider.currentUserId();
@@ -95,30 +126,9 @@ public class RecommendServiceImpl implements RecommendService {
             return AjaxResult.error("用户未登录,无法生成预订单");
         }
 
-        // 5) 组装落库包
+        // 5) 组装落库包并 Feign 远程落库(推荐服务不写库,交由 ratingApp)
         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;
+        String preOrderNo = preOrderApiClient.savePreOrder(loginUserProvider.currentUserHeader(), saveDTO);
         if (preOrderNo == null || preOrderNo.isEmpty()) {
             return AjaxResult.error("预订单生成失败,请稍后重试");
         }
@@ -188,9 +198,9 @@ public class RecommendServiceImpl implements RecommendService {
     }
 
     /**
-     * 单卡降级匹配:EXACT → L2 → L3 → DEFAULT;命中后带出价值并补查 POP
+     * 单卡匹配:先透传全部识别字段到 VO,再在候选池内做降级匹配;未命中按 cardType 兜底。
      */
-    private RecommendCardVO recommendOneCard(RecommendCardDTO dto, String activeBatchNo) {
+    private RecommendCardVO recommendOneCard(RecommendCardDTO dto, List<RatingRecommendRulePO> pool) {
         RecommendCardVO vo = new RecommendCardVO();
         vo.setPlayer(dto.getPlayer());
         vo.setYear(dto.getYear());
@@ -198,57 +208,84 @@ public class RecommendServiceImpl implements RecommendService {
         vo.setCardSet(dto.getCardSet());
         vo.setFrontImageUrl(dto.getFrontImageUrl());
         vo.setBackImageUrl(dto.getBackImageUrl());
-        // 透传套餐ID与套餐价格(入参字段名 id/price,响应改业务命名 packageId/packagePrice)
         vo.setPackageId(dto.getPackageId());
         vo.setPackagePrice(dto.getPackagePrice());
         // 透传全部识别字段(用于落库存全量)
         vo.setSportEvent(dto.getSportEvent());
         vo.setCardType(dto.getCardType());
         vo.setRole(dto.getRole());
-        vo.setCardNo(dto.getCardNo());      // 入参识别编号(不再被规则 cardNo 覆盖)
+        vo.setCardNo(dto.getCardNo());
         vo.setTeam(dto.getTeam());
         vo.setLimitId(dto.getLimitId());
         vo.setIpName(dto.getIpName());
-        vo.setRoleName(dto.getRule());      // 入参 rule -> VO roleName
+        vo.setRoleName(dto.getRule());
         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);
-            return vo;
-        }
-        rule = ratingRecommendRuleManager.selectBySeriesSetYear(dto.getSeries(), dto.getCardSet(), dto.getYear(), activeBatchNo);
-        if (isLegalRule(rule)) {
-            fillFromRule(vo, rule, MatchLevelEnum.L2);
+        RuleMatcher.MatchResult res = RuleMatcher.match(dto, pool);
+        // 命中且规则档位不为脏:正常填充;否则统一走兜底,避免"命中了但档位为 null"的语义错乱
+        if (res != null && res.getRule().getRecommendEfficiencyId() != null) {
+            fillFromRule(vo, res.getRule(), res.getLevel());
             return vo;
         }
-        rule = ratingRecommendRuleManager.selectBySeriesSet(dto.getSeries(), dto.getCardSet(), activeBatchNo);
-        if (isLegalRule(rule)) {
-            fillFromRule(vo, rule, MatchLevelEnum.L3);
-            return vo;
-        }
-        // 兜底
-        vo.setEvaluateEfficiencyId(recommendProperties.getDefaultEfficiencyId());
+        // 未命中 或 命中规则档位脏:按 cardType 兜底,取不到退回全局默认
+        vo.setEvaluateEfficiencyId(resolveDefaultEfficiencyId(dto.getCardType()));
         vo.setMatchLevel(MatchLevelEnum.DEFAULT.getCode());
         return vo;
     }
 
+    /**
+     * 是否为宝可梦卡(cardType==1)。用于分派 (series,role) vs (series,cardSet) 收窄链。
+     */
+    private static boolean isPokemon(Integer cardType) {
+        return cardType != null && cardType == 1;
+    }
+
+    /**
+     * 兜底默认档:优先按 cardType 分档,取不到退回全局 defaultEfficiencyId。
+     */
+    private Long resolveDefaultEfficiencyId(Integer cardType) {
+        Map<Integer, Long> byType = recommendProperties.getDefaultEfficiencyByCardType();
+        if (cardType != null && byType != null) {
+            Long v = byType.get(cardType);
+            if (v != null) {
+                return v;
+            }
+        }
+        return recommendProperties.getDefaultEfficiencyId();
+    }
+
     /**
      * 命中规则后填充:时效、命中层级、卡片ID(POP 补查已下线,形参 activeBatchNo 一并移除)
      */
     private void fillFromRule(RecommendCardVO vo, RatingRecommendRulePO rule, MatchLevelEnum level) {
-        vo.setEvaluateEfficiencyId(rule.getEvaluateEfficiencyId());
+        // 语义档 code(Integer) 装入 VO 的 evaluateEfficiencyId(Long)
+        vo.setEvaluateEfficiencyId(Objects.isNull(rule.getRecommendEfficiencyId())
+                ? null : rule.getRecommendEfficiencyId());
         vo.setMatchLevel(level.getCode());
         vo.setCardId(rule.getCardId());
         // 规则带出的数仓卡号不再覆盖入参识别编号 cardNo(数仓 ID 由 cardId 承载)
     }
 
-    private boolean isLegalRule(RatingRecommendRulePO rule) {
-        return rule != null
-                && rule.getEvaluateEfficiencyId() != null
-                && rule.getEvaluateEfficiencyName() != null;
+    /**
+     * 用时效字典回填每个订单的时效名称(替代 OrderGrouper 旧硬编码)。
+     * 字典无该档时,退回枚举描述;再取不到置空字符串。
+     */
+    private void fillGroupNames(List<OrderSuggestionVO> orders, Map<Long, EfficiencyDictItem> dict) {
+        if (orders == null) {
+            return;
+        }
+        for (OrderSuggestionVO o : orders) {
+            Long code = o.getEvaluateEfficiencyId();
+            EfficiencyDictItem item = (dict == null || code == null) ? null : dict.get(code);
+            if (item != null && item.getName() != null) {
+                o.setEvaluateEfficiencyName(item.getName());
+            } else {
+                // 字典缺失兜底:用枚举描述(普通/快速/闪评)
+                o.setEvaluateEfficiencyName(code == null ? ""
+                        : EvaluateEfficiencyEnum.getDescByCode(code.intValue()));
+            }
+        }
     }
 
     /**
@@ -259,7 +296,7 @@ public class RecommendServiceImpl implements RecommendService {
      *   时效价某档无价 → 订单 unitPrice/totalAmount = null("待计算"),grandTotal 也为 null
      *   套餐价 packagePrice 始终透传到卡级,无需降级
      */
-    private BigDecimal applyPricing(List<OrderSuggestionVO> orders, Map<Long, BigDecimal> priceMap) {
+    private BigDecimal applyPricing(List<OrderSuggestionVO> orders, Map<Long, EfficiencyDictItem> dict) {
         if (orders == null || orders.isEmpty()) {
             return null;
         }
@@ -271,7 +308,8 @@ public class RecommendServiceImpl implements RecommendService {
             order.setPackageTotal(packageTotal);
 
             // 2) 再算时效价;缺失则降级"待计算"
-            BigDecimal unit = (priceMap == null) ? null : priceMap.get(order.getEvaluateEfficiencyId());
+            EfficiencyDictItem item = (dict == null) ? null : dict.get(order.getEvaluateEfficiencyId());
+            BigDecimal unit = (item == null) ? null : item.getPrice();
             if (unit == null) {
                 order.setEfficiencyPrice(null);
                 order.setTotalAmount(null);

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

@@ -17,10 +17,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
-import java.util.ArrayList;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.*;
 
 /**
  * 数仓规则同步服务实现
@@ -86,7 +83,20 @@ public class RuleSyncServiceImpl implements RuleSyncService {
             r.setCardYear(c.getYear());
             r.setSeries(c.getSeries());
             r.setCardSet(c.getCardSet());
-            r.setEvaluateEfficiencyId(c.getEvaluateEfficiencyId());
+            // 补写 cardType 与 8 个识别字段(供匹配的分类前提 + 本体降级键使用)
+            r.setCardType(c.getCardType());
+            r.setSportEvent(c.getSportEvent());
+            r.setRole(c.getRole());
+            r.setTeam(c.getTeam());
+            r.setLimitId(c.getLimitId());
+            r.setIpName(c.getIpName());
+            r.setRule(c.getRule());
+            r.setRarity(c.getRarity());
+            r.setMaterial(c.getMaterial());
+            // 写侧对齐表列 recommend_efficiency(PO 字段已由 evaluateEfficiencyId(Long) 重构为 recommendEfficiency(Integer));
+            // DTO 入参仍为 Long,此处窄化为 Integer 装入 PO,与 batchInsert 的 #{it.recommendEfficiency} 保持一致
+            r.setRecommendEfficiencyId(Objects.isNull(c.getEvaluateEfficiencyId())
+                    ? null : c.getEvaluateEfficiencyId());
             r.setCurrentValue(c.getCurrentValue());
             r.setValueChangePct(c.getPriceChangePct());
             r.setValueMin(c.getValueMin());

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

@@ -1,10 +1,12 @@
 package com.mangoo.rating.recommend.service.recommend;
 
-import java.math.BigDecimal;
+import com.mangoo.rating.recommend.dto.recommend.EfficiencyDictItem;
+
 import java.util.Map;
 
 /**
- * 时效价格 Provider(抽象接口,便于后续替换实现:本地缓存/订单服务 Feign/其他)。
+ * 时效字典 Provider(时效 code → {名称, 单价}),来源订单服务服务等级列表。
+ * 取不到 / 异常时返回空 Map,由上层降级为"待计算"。
  *
  * @author gengjintao
  * @date 2026/06/24
@@ -12,10 +14,9 @@ import java.util.Map;
 public interface EfficiencyPriceProvider {
 
     /**
-     * 加载"时效 code → 单价"映射。
-     * 取不到 / 异常时应返回空 Map,由上层降级为"待计算"。
+     * 加载"时效 code → 字典项(名称+单价)"映射。
      *
-     * @return 时效 code -> 单价(如 1 -> 50.00, 2 -> 100.00, 3 -> 200.00)
+     * @return 时效 code(1/2/3) -> {name, price};取不到返回空 Map
      */
-    Map<Long, BigDecimal> loadEfficiencyPrices();
+    Map<Long, EfficiencyDictItem> loadEfficiencyDict();
 }

+ 4 - 3
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/OrderGrouper.java

@@ -5,6 +5,7 @@ import com.mangoo.rating.recommend.response.recommend.OrderSuggestionVO;
 import com.mangoo.rating.recommend.response.recommend.RecommendCardVO;
 
 import java.util.ArrayList;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.TreeMap;
@@ -33,7 +34,8 @@ public final class OrderGrouper {
         }
 
         // 用 TreeMap 按档位升序归并;同档列表用 ArrayList 保留输入顺序(稳定)
-        Map<Long, List<RecommendCardVO>> bucket = new TreeMap<>();
+        // 采用 nullsLast 比较器:即便上游 evaluateEfficiencyId 为 null(配置缺失极端场景)也不 NPE,null 档位排到最后
+        Map<Long, List<RecommendCardVO>> bucket = new TreeMap<>(Comparator.nullsLast(Long::compareTo));
         for (RecommendCardVO card : cards) {
             Long eff = card.getEvaluateEfficiencyId();
             bucket.computeIfAbsent(eff, k -> new ArrayList<>()).add(card);
@@ -44,8 +46,7 @@ public final class OrderGrouper {
             OrderSuggestionVO order = new OrderSuggestionVO();
             order.setGroupNo(groupNo++);
             order.setEvaluateEfficiencyId(entry.getKey());
-            // todo 临时使用第一个,后续通过时效id查询名称
-            order.setEvaluateEfficiencyName("普通时效");
+            // 时效名称由 Service 层通过时效字典回填,OrderGrouper 只负责分组
             order.setCardCount(entry.getValue().size());
             order.setCards(entry.getValue());
             // unitPrice / totalAmount 由上层报价后回填,此处不设置

+ 161 - 0
mango-domain/src/main/java/com/mangoo/rating/recommend/service/recommend/RuleMatcher.java

@@ -0,0 +1,161 @@
+package com.mangoo.rating.recommend.service.recommend;
+
+import com.mangoo.rating.recommend.enums.MatchLevelEnum;
+import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
+import com.mangoo.rating.recommend.request.recommend.RecommendCardDTO;
+
+import java.time.LocalDateTime;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 规则匹配纯算法(无 Spring 依赖,可单测)。
+ * 组织方式:分类属性做过滤前提(入参有值才约束)+ 本体字段分层降级(L0→L1→EXACT→L2→L3)。
+ * 双链分派:cardType==1(宝可梦卡)本体降级键用 role 替代 cardSet;cardType!=1(球星卡/未知)用 cardSet。
+ * 多候选取舍:effective_time DESC(nulls last) → recommend_efficiency DESC → id DESC。
+ *
+ * @author gengjintao
+ */
+public final class RuleMatcher {
+
+    private RuleMatcher() {
+    }
+
+    /** 匹配结果:命中规则 + 命中层级 */
+    public static final class MatchResult {
+        private final RatingRecommendRulePO rule;
+        private final MatchLevelEnum level;
+
+        public MatchResult(RatingRecommendRulePO rule, MatchLevelEnum level) {
+            this.rule = rule;
+            this.level = level;
+        }
+
+        public RatingRecommendRulePO getRule() {
+            return rule;
+        }
+
+        public MatchLevelEnum getLevel() {
+            return level;
+        }
+    }
+
+    /**
+     * 多候选取舍比较器:越优排越前。
+     * effective_time DESC(NULL 视为最旧) → recommend_efficiency DESC → id DESC。
+     */
+    private static final Comparator<RatingRecommendRulePO> BEST_FIRST =
+            Comparator
+                    .comparing((RatingRecommendRulePO r) -> r.getEffectiveTime() == null
+                            ? LocalDateTime.MIN : r.getEffectiveTime())
+                    .thenComparing(r -> r.getRecommendEfficiencyId() == null ? 0 : r.getRecommendEfficiencyId())
+                    .thenComparing(r -> r.getId() == null ? 0L : r.getId())
+                    .reversed();
+
+    /**
+     * 在候选池中为单卡做降级匹配。
+     *
+     * @param card 入参卡
+     * @param pool 该 (series,cardSet) 相关的候选规则(已按生效批次/未删/生效时间过滤)
+     * @return 命中结果;全部落空返回 null(由上层兜底)
+     */
+    public static MatchResult match(RecommendCardDTO card, List<RatingRecommendRulePO> pool) {
+        if (card == null || pool == null || pool.isEmpty()) {
+            return null;
+        }
+        // 层级由精确到宽;每层给一个"该层匹配键是否全等"的判定
+        MatchResult r;
+        if ((r = pick(card, pool, MatchLevelEnum.L0)) != null) return r;
+        if ((r = pick(card, pool, MatchLevelEnum.L1)) != null) return r;
+        if ((r = pick(card, pool, MatchLevelEnum.EXACT)) != null) return r;
+        if ((r = pick(card, pool, MatchLevelEnum.L2)) != null) return r;
+        if ((r = pick(card, pool, MatchLevelEnum.L3)) != null) return r;
+        return null;
+    }
+
+    /** 取某层内、通过前提过滤且匹配键全等的最优候选 */
+    private static MatchResult pick(RecommendCardDTO card, List<RatingRecommendRulePO> pool, MatchLevelEnum level) {
+        RatingRecommendRulePO best = pool.stream()
+                .filter(po -> passPrerequisite(card, po))
+                .filter(po -> matchLevel(card, po, level))
+                .min(BEST_FIRST)
+                .orElse(null);
+        return best == null ? null : new MatchResult(best, level);
+    }
+
+    /**
+     * 分类前提:cardType/sportEvent/ipName/team/rule,入参有值才要求相等。
+     * 注意:role 不再作前提 —— 宝可梦链里它作降级键(替代 cardSet),球星卡链里不参与匹配。
+     */
+    private static boolean passPrerequisite(RecommendCardDTO c, RatingRecommendRulePO po) {
+        return eqIfPresent(c.getCardType(), po.getCardType())
+                && eqIfPresent(c.getSportEvent(), po.getSportEvent())
+                && eqIfPresent(c.getIpName(), po.getIpName())
+                && eqIfPresent(c.getTeam(), po.getTeam())
+                && eqIfPresent(c.getRule(), po.getRule());
+    }
+
+    /** 是否走宝可梦链(cardType==1)—— 该链本体降级键用 role 替代 cardSet */
+    private static boolean isPokemon(RecommendCardDTO c) {
+        return c.getCardType() != null && c.getCardType() == 1;
+    }
+
+    /**
+     * 某降级层的匹配键是否全等(入参键为空则该层不可命中)。
+     * 宝可梦链(cardType==1)用 role 替代 cardSet 出现在各层键中;其它走 cardSet 链。
+     */
+    private static boolean matchLevel(RecommendCardDTO c, RatingRecommendRulePO po, MatchLevelEnum level) {
+        boolean pokemon = isPokemon(c);
+        // 第二分组维度:宝可梦 → role;其它 → cardSet
+        String cardGroup = pokemon ? c.getRole() : c.getCardSet();
+        String poGroup = pokemon ? po.getRole() : po.getCardSet();
+        switch (level) {
+            case L0:
+                return eqRequired(c.getPlayer(), po.getPlayer())
+                        && eqRequired(c.getYear(), po.getCardYear())
+                        && eqRequired(c.getSeries(), po.getSeries())
+                        && eqRequired(cardGroup, poGroup)
+                        && eqRequired(c.getCardNo(), po.getCardNo())
+                        && eqRequired(c.getLimitId(), po.getLimitId())
+                        && eqRequired(c.getRarity(), po.getRarity())
+                        && eqRequired(c.getMaterial(), po.getMaterial());
+            case L1:
+                return eqRequired(c.getPlayer(), po.getPlayer())
+                        && eqRequired(c.getYear(), po.getCardYear())
+                        && eqRequired(c.getSeries(), po.getSeries())
+                        && eqRequired(cardGroup, poGroup)
+                        && eqRequired(c.getCardNo(), po.getCardNo());
+            case EXACT:
+                return eqRequired(c.getPlayer(), po.getPlayer())
+                        && eqRequired(c.getYear(), po.getCardYear())
+                        && eqRequired(c.getSeries(), po.getSeries())
+                        && eqRequired(cardGroup, poGroup);
+            case L2:
+                return eqRequired(c.getSeries(), po.getSeries())
+                        && eqRequired(cardGroup, poGroup)
+                        && eqRequired(c.getYear(), po.getCardYear());
+            case L3:
+                return eqRequired(c.getSeries(), po.getSeries())
+                        && eqRequired(cardGroup, poGroup);
+            default:
+                return false;
+        }
+    }
+
+    /** 前提比较:入参值为空 → 不约束(true);有值 → 必须相等 */
+    private static boolean eqIfPresent(Object cardVal, Object poVal) {
+        if (cardVal == null || (cardVal instanceof String && ((String) cardVal).trim().isEmpty())) {
+            return true;
+        }
+        return Objects.equals(cardVal, poVal);
+    }
+
+    /** 降级键比较:入参值为空 → 该键不满足(false,下沉下一层);有值 → 必须相等 */
+    private static boolean eqRequired(String cardVal, String poVal) {
+        if (cardVal == null || cardVal.trim().isEmpty()) {
+            return false;
+        }
+        return cardVal.equals(poVal);
+    }
+}

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

@@ -3,22 +3,20 @@ package com.mangoo.rating.recommend.service.recommend.impl;
 import com.mangoo.rating.recommend.client.OrderApiClient;
 import com.mangoo.rating.recommend.client.feign.dto.ProductServiceLevelDTO;
 import com.mangoo.rating.recommend.config.RecommendProperties;
-import com.mangoo.rating.recommend.dto.product.ProductServiceLevelCacheDTO;
-import com.mangoo.rating.recommend.enums.EvaluateEfficiencyEnum;
+import com.mangoo.rating.recommend.dto.recommend.EfficiencyDictItem;
 import com.mangoo.rating.recommend.service.recommend.EfficiencyPriceProvider;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
-import java.math.BigDecimal;
 import java.util.*;
 
 /**
- * 时效价格 Provider 实现(预留调订单服务的口子 + 降级)
+ * 时效字典 Provider 实现:已接通 order-service,价格与时效名统一来源于订单服务的服务等级列表
  *
- * 当前实现:未接通订单服务 Feign,直接返回空 Map,触发上层"待计算"降级。
- * 后续接通时:在 fetchServiceLevels() 中通过 Feign 调订单服务、得到 ProductServiceLevelCacheDTO 列表,
- *           再用 RecommendProperties.timeLimitMapping 把 timeLimit 字符串映射到 EvaluateEfficiencyEnum.code
+ * 流程:fetchServiceLevels() 通过 OrderApiClient 拉全部服务等级 →
+ *      再用 RecommendProperties.timeLimitMapping 把 timeLimit 字符串桥到语义档 code(1/2/3),构建字典。
+ * 空列表/异常统一返回空 Map,由上层降级为"待计算"
  *
  * @author gengjintao
  * @date 2026/06/24
@@ -34,38 +32,34 @@ public class EfficiencyPriceProviderImpl implements EfficiencyPriceProvider {
     private OrderApiClient orderApiClient;
 
     @Override
-    public Map<Long, BigDecimal> loadEfficiencyPrices() {
+    public Map<Long, EfficiencyDictItem> loadEfficiencyDict() {
         try {
-            // 1. 从订单服务获取全部服务等级(预留口子,当前返回空列表 → 触发降级)
+            // 1. 从订单服务获取全部服务等级(未接通/为空 → 触发上层"待计算"降级)
             List<ProductServiceLevelDTO> levels = fetchServiceLevels();
             if (levels == null || levels.isEmpty()) {
-                log.info("时效价格未配置或订单服务未接通,全部走'待计算'降级");
+                log.info("时效字典未配置或订单服务未接通,全部走'待计算'降级");
                 return Collections.emptyMap();
             }
-
-            // 2. 服务等级
-            Map<Long, BigDecimal> priceMap = new LinkedHashMap<>();
+            // 2. 构建字典
+            Map<Long, EfficiencyDictItem> dict = new LinkedHashMap<>();
             for (ProductServiceLevelDTO lvl : levels) {
-                if (lvl == null || lvl.getTimeLimit() == null || lvl.getPrice() == null) {
+                if (Objects.isNull(lvl)) {
                     continue;
                 }
-                // 同档存在多条时
-                priceMap.putIfAbsent(lvl.getId(), lvl.getPrice());
+                // 同档多条:保留首条(putIfAbsent),名称取等级名称 levelName
+                dict.putIfAbsent(lvl.getId(), new EfficiencyDictItem(lvl.getLevelName(), lvl.getPrice()));
             }
-            return priceMap;
+            return dict;
         } catch (Exception e) {
-            // 价格能力任何异常都不应阻断推荐主体结果
-            log.warn("加载时效价格异常,本次降级为'待计算'", e);
+            log.warn("加载时效字典异常,本次降级为'待计算'", e);
             return Collections.emptyMap();
         }
     }
 
     /**
-     * 调订单服务取全部服务等级 —— 预留口子。
-     * 本期返回空列表(不接 Feign),后续接通时实现真实调用。
+     * 通过 OrderApiClient 调订单服务取全部服务等级列表。
      */
     private List<ProductServiceLevelDTO> fetchServiceLevels() {
-        // TODO: 后续接入 order-service Feign 客户端:调用其"全部服务等级"接口
         return orderApiClient.serviceList(null);
     }
 }

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

@@ -13,30 +13,6 @@ import org.apache.ibatis.annotations.Param;
 @Mapper
 public interface RatingRecommendRuleMapper {
 
-    /**
-     * L1:四字段全等命中(取最新生效,限定生效批次)
-     */
-    RatingRecommendRulePO selectByExact(@Param("player") String player,
-                                        @Param("cardYear") String cardYear,
-                                        @Param("series") String series,
-                                        @Param("cardSet") String cardSet,
-                                        @Param("batchNo") String batchNo);
-
-    /**
-     * L2:series + cardSet + year 命中(player 放宽,限定生效批次)
-     */
-    RatingRecommendRulePO selectBySeriesSetYear(@Param("series") String series,
-                                                @Param("cardSet") String cardSet,
-                                                @Param("cardYear") String cardYear,
-                                                @Param("batchNo") String batchNo);
-
-    /**
-     * L3:series + cardSet 命中(player + year 放宽,限定生效批次)
-     */
-    RatingRecommendRulePO selectBySeriesSet(@Param("series") String series,
-                                            @Param("cardSet") String cardSet,
-                                            @Param("batchNo") String batchNo);
-
     /**
      * 按数仓卡片ID命中(限定生效批次,用于卡片详情查询)
      */
@@ -56,4 +32,20 @@ public interface RatingRecommendRuleMapper {
      * 数仓同步:查询所有去重批次号(用于清理过期批次)
      */
     java.util.List<String> selectDistinctBatchNos();
+
+    /**
+     * 批量:按生效批次 + 一组 (series,cardSet) 捞回相关规则集(球星卡链,L3 之上各层候选)。
+     * 已过滤 del_flag=0、batch_no、生效时间;排序留待内存取舍。
+     */
+    java.util.List<RatingRecommendRulePO> selectByBatchAndSeriesSets(
+            @Param("batchNo") String batchNo,
+            @Param("keys") java.util.List<com.mangoo.rating.recommend.dto.recommend.SeriesCardSetKey> keys);
+
+    /**
+     * 批量:按生效批次 + 一组 (series,role) 捞回相关规则集(宝可梦卡链,用 role 替代 cardSet 收窄)。
+     * 已过滤 del_flag=0、batch_no、生效时间;排序留待内存取舍。
+     */
+    java.util.List<RatingRecommendRulePO> selectByBatchAndSeriesRoles(
+            @Param("batchNo") String batchNo,
+            @Param("keys") java.util.List<com.mangoo.rating.recommend.dto.recommend.SeriesRoleKey> keys);
 }

+ 57 - 46
mango-infrastructure/src/main/resources/mapper/RatingRecommendRuleMapper.xml

@@ -12,7 +12,7 @@
         <result property="cardYear" column="card_year"/>
         <result property="series" column="series"/>
         <result property="cardSet" column="card_set"/>
-        <result property="recommendEfficiency" column="recommend_efficiency"/>
+        <result property="recommendEfficiencyId" column="recommend_efficiency_id"/>
         <result property="effectiveTime" column="effective_time"/>
         <result property="cardId" column="card_id"/>
         <result property="cardNo" column="card_no"/>
@@ -22,59 +22,26 @@
         <result property="valueMin" column="value_min"/>
         <result property="valueMax" column="value_max"/>
         <result property="batchNo" column="batch_no"/>
+        <result property="sportEvent" column="sport_event"/>
+        <result property="role" column="role"/>
+        <result property="team" column="team"/>
+        <result property="limitId" column="limit_id"/>
+        <result property="ipName" column="ip_name"/>
+        <result property="rule" column="rule"/>
+        <result property="rarity" column="rarity"/>
+        <result property="material" column="material"/>
         <result property="createTime" column="create_time"/>
         <result property="updateTime" column="update_time"/>
         <result property="delFlag" column="del_flag"/>
     </resultMap>
 
     <sql id="baseColumnList">
-        id, card_type, player, card_year, series, card_set, recommend_efficiency, effective_time,
+        id, card_type, player, card_year, series, card_set, recommend_efficiency_id, effective_time,
         card_id, card_no, current_value, value_change_pct, value_min, value_max, batch_no,
+        sport_event, role, team, limit_id, ip_name, rule, rarity, material,
         create_time, update_time, del_flag
     </sql>
 
-    <!-- L1:四字段全等命中(取最新生效) -->
-    <select id="selectByExact" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND batch_no = #{batchNo}
-          AND player = #{player}
-          AND card_year = #{cardYear}
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          AND (effective_time IS NULL OR effective_time &lt;= now())
-        ORDER BY effective_time DESC NULLS LAST, id DESC
-        LIMIT 1
-    </select>
-
-    <!-- L2:系列+卡种+年份命中 -->
-    <select id="selectBySeriesSetYear" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND batch_no = #{batchNo}
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          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
-    </select>
-
-    <!-- L3:系列+卡种命中 -->
-    <select id="selectBySeriesSet" resultMap="BaseResultMap">
-        SELECT <include refid="baseColumnList"/>
-        FROM t_rating_recommend_rule
-        WHERE del_flag = 0
-          AND batch_no = #{batchNo}
-          AND series = #{series}
-          AND card_set = #{cardSet}
-          AND (effective_time IS NULL OR effective_time &lt;= now())
-        ORDER BY effective_time DESC NULLS LAST, id DESC
-        LIMIT 1
-    </select>
-
     <!-- 按数仓卡片ID命中(限定生效批次,用于卡片详情查询) -->
     <select id="selectByCardId" resultMap="BaseResultMap">
         SELECT <include refid="baseColumnList"/>
@@ -86,13 +53,15 @@
     <!-- 数仓同步:批量插入规则(物理全量替换,del_flag 恒为 0) -->
     <insert id="batchInsert">
         INSERT INTO t_rating_recommend_rule
-        (player, card_year, series, card_set, recommend_efficiency, effective_time,
+        (card_type, player, card_year, series, card_set, recommend_efficiency_id, effective_time,
          card_id, card_no, current_value, value_change_pct, value_min, value_max, batch_no,
+         sport_event, role, team, limit_id, ip_name, rule, rarity, material,
          create_time, update_time, del_flag)
         VALUES
         <foreach collection="list" item="it" separator=",">
-            (#{it.player}, #{it.cardYear}, #{it.series}, #{it.cardSet}, #{it.recommendEfficiency}, #{it.effectiveTime},
+            (#{it.cardType}, #{it.player}, #{it.cardYear}, #{it.series}, #{it.cardSet}, #{it.recommendEfficiencyId}, #{it.effectiveTime},
              #{it.cardId}, #{it.cardNo}, #{it.currentValue}, #{it.valueChangePct}, #{it.valueMin}, #{it.valueMax}, #{it.batchNo},
+             #{it.sportEvent}, #{it.role}, #{it.team}, #{it.limitId}, #{it.ipName}, #{it.rule}, #{it.rarity}, #{it.material},
              now(), now(), 0)
         </foreach>
     </insert>
@@ -107,4 +76,46 @@
         SELECT DISTINCT batch_no FROM t_rating_recommend_rule WHERE batch_no IS NOT NULL
     </select>
 
+    <!-- 批量:按生效批次 + (series,cardSet) 元组集合捞回候选规则(内存做降级/取舍) -->
+    <select id="selectByBatchAndSeriesSets" resultMap="BaseResultMap">
+        SELECT <include refid="baseColumnList"/>
+        FROM t_rating_recommend_rule
+        WHERE del_flag = 0
+          AND batch_no = #{batchNo}
+          AND (effective_time IS NULL OR effective_time &lt;= now())
+          <!-- 纵深防御:keys 为空时用 1=0 短路返回空集,避免 IN () 语法错(Manager 层已前置拦截,此处兜底契约层) -->
+          <choose>
+              <when test="keys != null and keys.size() > 0">
+                  AND (series, card_set) IN
+                  <foreach collection="keys" item="k" open="(" separator="," close=")">
+                      (#{k.series}, #{k.cardSet})
+                  </foreach>
+              </when>
+              <otherwise>
+                  AND 1=0
+              </otherwise>
+          </choose>
+    </select>
+
+    <!-- 批量:按生效批次 + (series,role) 元组集合捞回候选规则(宝可梦卡链,内存做降级/取舍) -->
+    <select id="selectByBatchAndSeriesRoles" resultMap="BaseResultMap">
+        SELECT <include refid="baseColumnList"/>
+        FROM t_rating_recommend_rule
+        WHERE del_flag = 0
+          AND batch_no = #{batchNo}
+          AND (effective_time IS NULL OR effective_time &lt;= now())
+          <!-- 纵深防御:keys 为空时用 1=0 短路返回空集,避免 IN () 语法错(Manager 层已前置拦截,此处兜底契约层) -->
+          <choose>
+              <when test="keys != null and keys.size() > 0">
+                  AND (series, role) IN
+                  <foreach collection="keys" item="k" open="(" separator="," close=")">
+                      (#{k.series}, #{k.role})
+                  </foreach>
+              </when>
+              <otherwise>
+                  AND 1=0
+              </otherwise>
+          </choose>
+    </select>
+
 </mapper>

+ 14 - 15
mango-manager/src/main/java/com/mangoo/rating/recommend/manager/RatingRecommendRuleManager.java

@@ -10,21 +10,6 @@ import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
  */
 public interface RatingRecommendRuleManager {
 
-    /**
-     * L1:四字段全等命中(任一为空则不查询,返回 null;batchNo 限定生效批次,为空返回 null)
-     */
-    RatingRecommendRulePO selectByExact(String player, String year, String series, String cardSet, String batchNo);
-
-    /**
-     * L2:系列+卡种+年份命中(三个参与字段任一为空则不查询;batchNo 限定生效批次,为空返回 null)
-     */
-    RatingRecommendRulePO selectBySeriesSetYear(String series, String cardSet, String year, String batchNo);
-
-    /**
-     * L3:系列+卡种命中(两个参与字段任一为空则不查询;batchNo 限定生效批次,为空返回 null)
-     */
-    RatingRecommendRulePO selectBySeriesSet(String series, String cardSet, String batchNo);
-
     /**
      * 按数仓卡片ID命中(cardId 或 batchNo 为空返回 null;用于卡片详情查询)
      */
@@ -44,4 +29,18 @@ public interface RatingRecommendRuleManager {
      * 数仓同步:查询所有去重批次号(用于清理过期批次)
      */
     java.util.List<String> selectDistinctBatchNos();
+
+    /**
+     * 批量:按生效批次 + 去重后的 (series,cardSet) 集合捞回候选规则集(球星卡链)。
+     * batchNo 为空或 keys 为空时返回空列表(无生效批次/无输入不查)。
+     */
+    java.util.List<com.mangoo.rating.recommend.po.RatingRecommendRulePO> selectByBatchAndSeriesSets(
+            String batchNo, java.util.List<com.mangoo.rating.recommend.dto.recommend.SeriesCardSetKey> keys);
+
+    /**
+     * 批量:按生效批次 + 去重后的 (series,role) 集合捞回候选规则集(宝可梦卡链)。
+     * batchNo 为空或 keys 为空时返回空列表。
+     */
+    java.util.List<com.mangoo.rating.recommend.po.RatingRecommendRulePO> selectByBatchAndSeriesRoles(
+            String batchNo, java.util.List<com.mangoo.rating.recommend.dto.recommend.SeriesRoleKey> keys);
 }

+ 30 - 27
mango-manager/src/main/java/com/mangoo/rating/recommend/manager/impl/RatingRecommendRuleManagerImpl.java

@@ -1,12 +1,18 @@
 package com.mangoo.rating.recommend.manager.impl;
 
+import cn.hutool.core.collection.CollUtil;
+import com.mangoo.rating.recommend.dto.recommend.SeriesRoleKey;
 import com.mangoo.rating.recommend.manager.RatingRecommendRuleManager;
 import com.mangoo.rating.recommend.mapper.RatingRecommendRuleMapper;
 import com.mangoo.rating.recommend.po.RatingRecommendRulePO;
+import com.mangoo.rating.recommend.utils.StringUtils;
 import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.compress.utils.Lists;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.Resource;
+import java.util.Collections;
+import java.util.List;
 
 /**
  * 评级时效推荐规则 Manager 实现
@@ -21,33 +27,6 @@ public class RatingRecommendRuleManagerImpl implements RatingRecommendRuleManage
     @Resource
     private RatingRecommendRuleMapper ratingRecommendRuleMapper;
 
-    @Override
-    public RatingRecommendRulePO selectByExact(String player, String year, String series, String cardSet, String batchNo) {
-        // L1 参与字段全部不为空才查询(避免无意义 SQL);batchNo 为空直接返回 null(无生效批次不查)
-        if (isBlank(player) || isBlank(year) || isBlank(series) || isBlank(cardSet) || isBlank(batchNo)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectByExact(player, year, series, cardSet, batchNo);
-    }
-
-    @Override
-    public RatingRecommendRulePO selectBySeriesSetYear(String series, String cardSet, String year, String batchNo) {
-        // L2 三个参与字段不能为空;batchNo 为空直接返回 null
-        if (isBlank(series) || isBlank(cardSet) || isBlank(year) || isBlank(batchNo)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectBySeriesSetYear(series, cardSet, year, batchNo);
-    }
-
-    @Override
-    public RatingRecommendRulePO selectBySeriesSet(String series, String cardSet, String batchNo) {
-        // L3 两个参与字段不能为空;batchNo 为空直接返回 null
-        if (isBlank(series) || isBlank(cardSet) || isBlank(batchNo)) {
-            return null;
-        }
-        return ratingRecommendRuleMapper.selectBySeriesSet(series, cardSet, batchNo);
-    }
-
     @Override
     public RatingRecommendRulePO selectByCardId(String cardId, String batchNo) {
         // cardId 或 batchNo 为空直接返回 null(无生效批次或无卡片ID不查)
@@ -79,6 +58,30 @@ public class RatingRecommendRuleManagerImpl implements RatingRecommendRuleManage
         return list == null ? java.util.Collections.emptyList() : list;
     }
 
+    @Override
+    public java.util.List<RatingRecommendRulePO> selectByBatchAndSeriesSets(
+            String batchNo, java.util.List<com.mangoo.rating.recommend.dto.recommend.SeriesCardSetKey> keys) {
+        // 无生效批次或无查询键 → 空列表(上层将对全部卡走兜底)
+        if (isBlank(batchNo) || keys == null || keys.isEmpty()) {
+            return java.util.Collections.emptyList();
+        }
+        java.util.List<RatingRecommendRulePO> list =
+                ratingRecommendRuleMapper.selectByBatchAndSeriesSets(batchNo, keys);
+        return list == null ? java.util.Collections.emptyList() : list;
+    }
+
+    @Override
+    public List<RatingRecommendRulePO> selectByBatchAndSeriesRoles(
+            String batchNo, List<SeriesRoleKey> keys) {
+        // 无生效批次或无查询键 → 空列表
+        if (StringUtils.isEmpty(batchNo) || CollUtil.isEmpty(keys)) {
+            return Lists.newArrayList();
+        }
+        List<RatingRecommendRulePO> list =
+                ratingRecommendRuleMapper.selectByBatchAndSeriesRoles(batchNo, keys);
+        return list == null ? Collections.emptyList() : list;
+    }
+
     /**
      * 简单空白判断(避免引入 commons-lang 仅用一次)
      */