# 评级时效推荐 + 自动分单 + 报价 实现计划(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 id, player, year, series, card_set, recommend_efficiency, effective_time, create_time, update_time, del_flag ``` - [ ] **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 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 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 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 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 orders = OrderGrouper.group(new ArrayList<>()); assertTrue(orders.isEmpty()); } @Test void null输入_返回空订单列表() { List orders = OrderGrouper.group(null); assertTrue(orders.isEmpty()); } @Test void 单卡_返回单订单_组号1() { List 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 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 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 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 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 group(List cards) { List result = new ArrayList<>(); if (cards == null || cards.isEmpty()) { return result; } // 用 TreeMap 按档位升序归并;同档列表用 ArrayList 保留输入顺序(稳定) Map> 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> 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 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 loadEfficiencyPrices() { try { // 1. 从订单服务获取全部服务等级(预留口子,当前返回空列表 → 触发降级) List levels = fetchServiceLevels(); if (levels == null || levels.isEmpty()) { log.info("时效价格未配置或订单服务未接通,全部走'待计算'降级"); return Collections.emptyMap(); } // 2. 将服务等级的 timeLimit(字符串) → EvaluateEfficiencyEnum.code 后聚合 Map priceMap = new LinkedHashMap<>(); Map 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 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 cardVOs = new ArrayList<>(request.getCards().size()); for (RecommendCardDTO dto : request.getCards()) { cardVOs.add(recommendOneCard(dto)); } // 2) 按时效精确分组 List orders = OrderGrouper.group(cardVOs); // 3) 报价(取不到则全部 null=待计算,不阻断) Map 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 orders, Map 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 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) → List` 定义与调用一致 ✓ - `EfficiencyPriceProvider.loadEfficiencyPrices() → Map` 定义与调用一致 ✓ - `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 之后的运行时验证。 **请大侠选择执行方式?**