总结2

全局异常处理

论文里是一个简单版的异常处理器,而实际项目用了一个更强大的“全能型”处理器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
下面是对第一段异常处理器代码的详细注释版,完全仿照你提供的注释风格和结构进行解释说明。

```java
/**
* 全局异常处理器
* 作用:统一拦截整个项目中所有抛出的异常,并返回给前端一个标准格式的错误信息。
* 解释:如果没有这个类,后端出错时,前端会收到一大段乱糟糟的报错页面或HTML,
* 有了它,前端总能收到一个结构清晰的JSON对象,方便统一处理。
*/
@RestControllerAdvice // 这个注解告诉Spring:这是一个专门处理异常的切面类,并且返回的数据会自动转成JSON格式
public class AGlobalExceptionHandlerController extends ABaseController {

// 创建一个SLF4J日志记录器实例,专门给AGlobalExceptionHandlerController这个类使用
// 使用static final确保日志对象在类生命周期中只有一个实例,且不可变,节省资源并保证线程安全
private static final Logger logger = LoggerFactory.getLogger(AGlobalExceptionHandlerController.class);
private static final String STATUC_ERROR = "error"; // 统一错误状态码,避免代码中到处写死"error"字符串,便于维护

/**
* 处理所有类型的异常 (Exception.class是根类)
* 解释:这个方法就像一个总闸,不管项目里抛出什么类型的异常,都会流到这里。
*
* @param e 捕获到的异常对象,包含了异常的具体类型和堆栈信息
* @param request 当前HTTP请求对象,主要用来打印出错的URL地址,方便排查是哪个接口出了问题
* @return 返回给前端的统一错误响应对象 (ResponseVO)
*/
@ExceptionHandler(value = Exception.class)
Object handleException(Exception e, HttpServletRequest request) {
// 1. 记录日志:告诉开发者哪个请求出错了,方便查Bug
// logger.error会记录完整的异常堆栈信息,通过request.getRequestURL()可以精准定位出错的接口地址
logger.error("请求错误,请求地址{}, 错误信息:", request.getRequestURL(), e);

// 2. 创建一个空白的响应对象,用于承载统一的错误返回结构
ResponseVO ajaxResponse = new ResponseVO();

// 3. 根据不同的异常类型,填充不同的错误码和提示信息 (核心逻辑)
// instanceof关键字意思是:检查e是不是某一种特定类型的异常
if (e instanceof NoHandlerFoundException) {
// 情况A:用户访问了一个不存在的地址 (404)
// NoHandlerFoundException是Spring MVC抛出的异常,表示没有找到对应的Controller处理器
ajaxResponse.setCode(ResponseCodeEnum.CODE_404.getCode());
ajaxResponse.setInfo(ResponseCodeEnum.CODE_404.getMsg());
ajaxResponse.setStatus(STATUC_ERROR);
} else if (e instanceof BusinessException) {
// 情况B:业务逻辑异常 (比如密码错误、库存不足、权限校验失败等)
// 这是我们自己手动抛出的业务异常,里面通常封装了具体的业务错误码和用户友好的提示信息
BusinessException biz = (BusinessException) e;
// 如果业务异常中指定了自定义错误码,则使用它;否则回退到默认的600业务错误码
Integer code = biz.getCode() == null ? ResponseCodeEnum.CODE_600.getCode() : biz.getCode();
ajaxResponse.setCode(code);
ajaxResponse.setInfo(biz.getMessage()); // 把异常里的提示文字直接返回给用户看
ajaxResponse.setStatus(STATUC_ERROR);
} else if (e instanceof BindException || e instanceof MethodArgumentTypeMismatchException || e instanceof ConstraintViolationException) {
// 情况C:参数校验或类型转换失败
// BindException:请求参数绑定到Java对象时发生错误(如@Valid校验失败)
// MethodArgumentTypeMismatchException:方法参数类型不匹配(如期望数字却传了字母)
// ConstraintViolationException:违反了Bean Validation约束(如@NotNull校验失败)
// 这类错误统一返回业务错误码600,前端可根据code提示用户检查输入参数
ajaxResponse.setCode(ResponseCodeEnum.CODE_600.getCode());
ajaxResponse.setInfo(ResponseCodeEnum.CODE_600.getMsg());
ajaxResponse.setStatus(STATUC_ERROR);
} else if (e instanceof DuplicateKeyException) {
// 情况D:数据库主键或唯一索引冲突
// DuplicateKeyException通常由Spring的DataIntegrityViolationException包装,
// 表示尝试插入或更新的数据违反了数据库的唯一约束
ajaxResponse.setCode(ResponseCodeEnum.CODE_601.getCode());
ajaxResponse.setInfo(ResponseCodeEnum.CODE_601.getMsg());
ajaxResponse.setStatus(STATUC_ERROR);
} else {
// 情况E:未知的系统内部错误 (比如空指针异常NullPointerException、数组越界等未预期异常)
// 这种错误不能把具体的报错细节给用户看,只说"服务器内部错误"即可,避免暴露敏感信息
ajaxResponse.setCode(ResponseCodeEnum.CODE_500.getCode());
ajaxResponse.setInfo(ResponseCodeEnum.CODE_500.getMsg());
ajaxResponse.setStatus(STATUC_ERROR);
}

// 4. 返回组装好的统一错误响应对象,Spring会将其自动转换为JSON格式发送给前端
return ajaxResponse;
}
}

补充说明

为什么参数是 .class 而不是字符串?
也可以写成 LoggerFactory.getLogger(“AGlobalExceptionHandlerController”),但强烈推荐用 .class,原因是:

重构安全:如果类名被重命名,IDE 会自动修改这里的引用;用字符串则容易遗漏导致日志标识失效。避免拼写错误:编译期即可检查。

登录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 登录核心逻辑
* 作用:验证用户凭据,更新登录信息,生成并缓存用户令牌数据。
* 解释:用户登录时,系统需要做三件事:
* 1. 校验账号密码是否正确;
* 2. 检查账号状态是否正常;
* 3. 记录本次登录的痕迹并颁发登录凭证(Token)。
*
* @param email 用户输入的登录邮箱(账号)
* @param password 用户输入的明文密码(生产环境建议使用加密比对,此处为示例)
* @param ip 用户发起登录请求的IP地址,用于安全审计
* @return 封装了用户基本信息和令牌的传输对象(TokenUserInfoDTO),后续会返回给前端
*/
@Override
public TokenUserInfoDTO login(String email, String password, String ip) {
// 1. 根据邮箱查询数据库中的用户记录
// 调用 MyBatis Mapper 接口执行 SQL 查询,不存在则返回 null
UserInfo userInfo = this.userInfoMapper.selectByEmail(email);

// 2. 校验账号是否存在以及密码是否匹配
// 注意:真实项目绝不能直接比较明文密码,必须使用 BCrypt 等加密算法比对
if (null == userInfo || !userInfo.getPassword().equals(password)) {
// 抛出业务异常,由全局异常处理器统一捕获并返回友好提示给前端
throw new BusinessException("账号或者密码错误");
}

// 3. 检查账号状态是否已被禁用
// 使用枚举常量 DISABLE 的状态值与数据库字段比对,避免魔法值
if (UserStatusEnum.DISABLE.getStatus().equals(userInfo.getStatus())) {
throw new BusinessException("账号已禁用");
}

// 4. 登录校验通过,更新用户的最后登录信息(时间和IP)
// 这一步属于写操作,通常不需要等待结果,但此处为同步更新
UserInfo updateInfo = new UserInfo();
updateInfo.setLastLoginTime(new Date()); // 记录当前服务器时间
updateInfo.setLastLoginIp(ip); // 记录请求来源IP,用于安全风控
// 根据用户ID更新上述两个字段,保证只修改目标记录
this.userInfoMapper.updateByUserId(updateInfo, userInfo.getUserId());

// 5. 生成令牌数据对象(DTO)
// 使用工具类 CopyTools 将数据库实体 UserInfo 中的必要字段拷贝到 TokenUserInfoDTO 中
TokenUserInfoDTO tokenUserInfoDto = CopyTools.copy(userInfo, TokenUserInfoDTO.class);

// 6. 将用户令牌信息存入 Redis 缓存
// 通常包含 token 作为 key,用户信息作为 value,并设置过期时间
// 后续请求通过携带 token 即可从 Redis 中取出用户信息,实现无状态认证
redisComponent.saveTokenInfo(tokenUserInfoDto);

// 7. 返回令牌信息给上层调用方(Controller),最终会序列化为 JSON 发送给前端
return tokenUserInfoDto;
}

实际代码中密码比较是明文的(注:这通常是不安全的,但这里忠实还原实际项目),并且使用了封装的 Redis 工具类。

商品ES搜索 5.2.2

论文用的是低级API构建JSON,实际项目用的是 Spring Data Elasticsearch 的 Criteria 对象查询,更像是写 SQL Where 条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190

/**
* Elasticsearch 搜索组件
* 作用:封装与 Elasticsearch 交互的所有操作,包括索引创建、商品搜索、数据同步。
* 解释:项目中引入 Elasticsearch 是为了实现高效、灵活的商品全文搜索。
* 本组件使用 Spring Data Elasticsearch 提供的 ElasticsearchOperations 模板类,
* 负责在应用启动时自动创建带 IK 中文分词器的索引,提供带价格过滤、排序的搜索方法,
* 并在商品数据变更时同步更新 ES 中的文档。
*/
@Component // 声明为 Spring 管理的 Bean,可被其他组件注入使用
@Slf4j // Lombok 注解,自动生成名为 'log' 的日志对象,替代手动创建 Logger
public class EsSearchComponent {

// 注入 Spring Data Elasticsearch 提供的核心操作接口,用于执行索引管理和文档 CRUD
@Resource
private ElasticsearchOperations elasticsearchOperations;

// 注入自定义的 MyBatis Plus 风格的 Mapper,用于查询数据库中的商品信息
@Resource
private ProductInfoMapper<ProductInfo, ProductInfoQuery> productInfoMapper;

/**
* 应用启动后自动执行:创建带 IK 中文分词器的索引
* 解释:@PostConstruct 保证此方法在依赖注入完成后立即执行。
* 如果索引不存在,则创建并配置自定义分析器(IK 分词器),否则跳过。
* 这是为了让 ES 能够正确地对中文商品名称进行分词,提高搜索准确度。
*/
@PostConstruct
public void createIndexWithIK() {
try {
// 获取 ProductInfoDTO 类对应的索引操作对象
IndexOperations indexOps = elasticsearchOperations.indexOps(ProductInfoDTO.class);
// 检查索引是否已存在,若存在则无需重复创建
if (indexOps.exists()) {
return;
}

// 定义索引的 Settings 配置 JSON 字符串(使用 Java 15+ 文本块特性)
// 核心是配置两个自定义分析器:
// - ik_max_word:会将文本做最细粒度的拆分,适合建立倒排索引时使用
// - ik_smart:会做最粗粒度的拆分,适合搜索时对查询词的分词
String json = """
{
"analysis": {
"analyzer": {
"ik_max_word": {
"type": "custom",
"tokenizer": "ik_max_word"
},
"ik_smart": {
"type": "custom",
"tokenizer": "ik_smart"
}
}
}
}
""";

// 将 JSON 字符串转换为 Map 对象,并以此设置创建索引
indexOps.create(JsonUtils.convertJson2Obj(json, Map.class));

// 创建映射(Mapping):会根据 ProductInfoDTO 类上的 @Field 注解自动生成字段类型定义
Document mapping = indexOps.createMapping(ProductInfoDTO.class);
// 将映射应用到索引上
indexOps.putMapping(mapping);

log.info("索引创建成功,已应用IK分词器");
} catch (Exception e) {
log.error("创建索引失败", e);
// 索引创建失败通常是环境配置问题,抛出运行时异常阻止应用启动
throw new RuntimeException("创建索引失败", e);
}
}

/**
* 商品搜索核心方法
* 作用:根据关键词、价格区间、排序规则在 Elasticsearch 中搜索商品。
* 解释:本方法处理了短词搜索的召回率优化、价格范围过滤、动态排序以及分页逻辑。
*
* @param keyWords 搜索关键词(商品名称模糊匹配)
* @param priceFrom 最低价格(包含),为 null 表示不限制下限
* @param priceTo 最高价格(包含),为 null 表示不限制上限
* @param sortType 排序类型:"asc" 升序 / "desc" 降序
* @param sortField 排序字段:"price" 按价格 / "composite" 综合排序(默认按相关度得分)
* @param pageNo 当前页码(从1开始),为空时默认为第1页
* @return 分页结果对象,包含总记录数、当前页数据等;若发生异常则返回空结果
*/
public PaginationResultVO<ProductInfoDTO> searchProducts(String keyWords, BigDecimal priceFrom, BigDecimal priceTo,
String sortType, String sortField, Integer pageNo) {
try {
// ---- 1. 参数预处理 ----
pageNo = pageNo == null ? 1 : pageNo;
// Elasticsearch 的分页起始页是 0,所以需要减 1
pageNo = pageNo - 1;
int pageSize = PageSize.SIZE15.getSize(); // 每页显示15条商品

// ---- 2. 构建关键词查询条件 (Criteria) ----
Criteria criteria = new Criteria();

// 针对短词的优化策略:当关键词长度 <= 2 时,使用多种匹配方式提高召回率
// (因为短词容易被 IK 分词器忽略或匹配太少结果)
if (keyWords.length() <= 2) {
Criteria nameCriteria = new Criteria();
// 方式1:普通分词匹配,例如 "苹果" -> "苹果"
nameCriteria = nameCriteria.or(new Criteria("productName").contains(keyWords));
// 方式2:通配符匹配,例如 *苹果*,可以匹配 "红苹果手机"
nameCriteria = nameCriteria.or(new Criteria("productName").expression("*" + keyWords + "*"));
// 方式3:短语匹配,例如 "苹果",要求精确包含该短语
nameCriteria = nameCriteria.or(new Criteria("productName").matches(keyWords));
criteria = criteria.and(nameCriteria);
} else {
// 长词直接使用分词后的包含查询
criteria = criteria.and("productName").contains(keyWords);
}

// ---- 3. 构建价格区间过滤条件 ----
if (priceFrom != null || priceTo != null) {
Criteria priceCriteria = new Criteria();
// 指定过滤字段为 minPrice(商品最低价字段)
priceCriteria = priceCriteria.and("minPrice");
if (priceFrom != null) {
priceCriteria = priceCriteria.greaterThanEqual(priceFrom);
}
if (priceTo != null) {
priceCriteria = priceCriteria.lessThanEqual(priceTo);
}
// 将价格条件与关键词条件进行 AND 连接
criteria = criteria.and(priceCriteria);
}

// ---- 4. 解析排序参数 ----
// 根据传入的字符串获取对应的排序枚举
SearchSortTypeEnum sortTypeEnum = SearchSortTypeEnum.getByType(sortType);
sortTypeEnum = sortTypeEnum == null ? SearchSortTypeEnum.DESC : sortTypeEnum; // 默认降序

SearchFieldTypeEnum fieldTypeEnum = SearchFieldTypeEnum.getByFieldType(sortField);
fieldTypeEnum = fieldTypeEnum == null ? SearchFieldTypeEnum.COMPOSITE : fieldTypeEnum; // 默认综合排序(相关度得分)

// 构建 Spring Data 的 Sort 对象,指定排序方向和排序字段
Sort sort = Sort.by(sortTypeEnum.getDirection(), fieldTypeEnum.getField());
// 构建分页请求对象(页码、每页条数、排序)
Pageable pageable = PageRequest.of(pageNo, pageSize, sort);

// ---- 5. 执行 Elasticsearch 查询 ----
CriteriaQuery query = new CriteriaQuery(criteria);
query.setPageable(pageable);
SearchHits<ProductInfoDTO> searchHits = elasticsearchOperations.search(query, ProductInfoDTO.class);

// ---- 6. 解析查询结果并封装为分页对象 ----
List<ProductInfoDTO> products = searchHits.getSearchHits().stream()
.map(SearchHit::getContent) // 提取命中的文档内容
.collect(Collectors.toList());
long totalHits = searchHits.getTotalHits();
int totalPages = (int) Math.ceil((double) totalHits / pageSize);

// 注意:pageNo 在方法内部被减了1,返回给前端时需要加回来,保持页码语义一致
return new PaginationResultVO((int) totalHits, pageSize, pageNo + 1, totalPages, products);
} catch (Exception e) {
log.error("搜索失败", e);
// 发生异常时返回空结果,避免影响前端展示,同时记录错误日志供排查
return new PaginationResultVO<>(0, PageSize.SIZE15.getSize(),
pageNo != null ? pageNo : 0, 0, new ArrayList<>());
}
}

/**
* 同步单条商品数据至 Elasticsearch
* 作用:当数据库中商品信息发生变更(上架、下架、修改)时,调用此方法更新 ES 索引。
* 解释:该方法根据商品状态决定是保存(索引)还是删除文档。
* - 上架商品:将最新的商品信息存入 ES,供用户搜索。
* - 下架商品:从 ES 中移除,避免被搜索到。
*
* @param productId 商品ID,用于从数据库查询最新的商品信息
*/
public void saveProduct(String productId) {
// 1. 从数据库获取最新的商品信息
ProductInfo product = productInfoMapper.selectByProductId(productId);
// 2. 将数据库实体对象转换为 ES 的 DTO 对象(字段映射由 CopyTools 处理)
ProductInfoDTO productInfoDTO = CopyTools.copy(product, ProductInfoDTO.class);

// 3. 根据商品状态执行相应操作
if (ProductStatusEnum.ON_SALE.getStatus().equals(product.getStatus())) {
// 上架状态:调用 save 方法,ES 会自动判断是新增还是更新(根据 @Id 字段)
elasticsearchOperations.save(productInfoDTO);
} else {
// 下架状态:从 ES 中删除对应 ID 的文档,确保搜索结果不包含已下架商品
elasticsearchOperations.delete(productId, ProductInfoDTO.class);
}
}
}

购物车提交/订单创建(5.2.4节)- 实际代码

重要提示:论文里写了 Redisson 分布式锁,但实际项目完全没有使用,而是依靠数据库事务 + 批量更新SQL来保证数据一致性。
根据你提供的业务描述和核心代码片段,以下是对购物车加购逻辑订单提交事务方法的详细注释。注释风格延续之前的面向学习、逐行解释的方式。


1. 购物车加购逻辑(幂等性实现示例)

虽然没有提供完整代码,但根据你的描述“使用 INSERT … ON DUPLICATE KEY UPDATE 实现幂等性”,此处补充一段典型实现的注释说明,以便理解业务背景。

2. 订单提交方法 postOrder 详细注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* 提交订单(核心交易流程)
* 作用:处理用户从购物车或商品详情页发起的下单请求,完成库存扣减、订单数据持久化、
* 购物车清理、延时支付取消队列注册,并返回支付链接。
* 解释:本方法是整个交易链路的核心,涉及多张表的写操作和外部支付调用。
* 通过 Spring 声明式事务保证数据强一致性,任何步骤失败均回滚所有数据库变更。
*
* @param userId 当前登录用户的唯一标识
* @param dto 下单请求参数对象,包含收货地址ID、购物车ID列表、支付渠道等信息
* @return 支付信息传输对象,内含支付链接或凭证,供前端跳转收银台
*/
@Transactional(rollbackFor = Exception.class) // 声明式事务:任何异常(包括 RuntimeException 和自定义异常)均触发回滚
public PayInfoDTO postOrder(String userId, PostOrderDTO dto) {

// --- 1. 校验收货地址有效性 ---
// 根据前端传入的地址ID查询数据库,确保该地址属于当前用户且未被删除
UserAddress address = userAddressMapper.selectByAddressId(dto.getAddressId());
if (address == null || !address.getUserId().equals(userId)) {
throw new BusinessException("收货地址不存在");
}

// --- 2. 构建库存扣减列表并执行乐观锁批量更新 ---
// 根据购物车商品或直接购买的商品构建 SKU 与扣减数量的映射列表
List<StockUpdateItem> stockUpdateList = buildStockUpdateList(dto);

// 【核心】调用 Mapper 的批量更新方法,SQL 中使用乐观锁条件(如 WHERE stock >= quantity)
// 该方法返回实际被更新的行数(即满足库存条件的 SKU 个数)
Integer updateCount = productSkuMapper.updateStockBatch(stockUpdateList);

// 若实际更新的行数少于预期需要扣减的 SKU 种类数,说明至少有一种商品库存不足
if (updateCount < stockUpdateList.size()) {
// 抛出业务异常,事务管理器将回滚此方法中已执行的所有数据库操作
throw new BusinessException("库存不足");
}

// --- 3. 构建并持久化订单核心数据 ---
// 组装订单主表对象 OrderInfo(包含订单号、总金额、状态等)
OrderInfo orderInfo = buildOrder(userId, dto, address);
orderInfoMapper.insert(orderInfo); // 插入订单主表

// 批量插入订单明细表(商品快照信息)
orderItemMapper.insertBatch(buildItems(orderInfo, dto));

// 插入订单物流信息表(收货人、电话、详细地址等)
orderLogisticsInfoMapper.insert(buildLogistics(orderInfo, address));

// --- 4. 下单成功后的清理与异步处理 ---
// 从购物车表中删除已转化为订单的记录(cartIds 由前端传入,避免误删)
productCartMapper.deleteByCartIds(dto.getCartIds());

// 将订单ID加入 Redis 延时队列,用于实现“超时未支付自动取消”功能
// (延时队列通常基于 Redis 的 ZSet 或 Redisson 的 DelayedQueue 实现)
redisComponent.addOrderDelayQueue(orderInfo.getOrderId());

// --- 5. 调用支付渠道获取支付链接 ---
// 根据用户选择的支付渠道(微信、支付宝等)获取对应的支付通道实现类
PayChannelEnum channel = PayChannelEnum.getByChannel(dto.getPayChannel());

// 调用策略模式封装的支付接口,生成预支付订单并返回支付链接/凭据
// 参数通常包含:支付渠道、订单号、订单标题、金额等
return payChannel.getPayUrl(channel, orderInfo.getOrderId(),
orderInfo.getOrderId(), orderInfo.getAmount());
}

3. 关键设计要点说明

技术点 注释解释
@Transactional(rollbackFor = Exception.class) 确保方法内所有数据库操作原子性。rollbackFor 指定即使抛出的异常是受检异常也回滚(默认仅回滚运行时异常),配合自定义 BusinessException 使用更可靠。
updateStockBatch 乐观锁 通过在 UPDATE 语句的 WHERE 条件中增加 stock >= #{quantity} 来避免超卖。更新返回的影响行数若小于预期,则意味着有商品的库存发生并发修改导致不满足条件。
延时队列注册 redisComponent.addOrderDelayQueue(orderInfo.getOrderId()) 是为了处理订单超时未支付场景。订单创建后进入队列,设置过期时间(如 30 分钟),到期后检查订单状态若仍为待支付则自动关闭。
支付策略模式 payChannel.getPayUrl(...) 使用枚举与工厂/策略模式,将不同支付渠道的对接细节隔离,便于扩展新支付方式。

4. 业务流程图示参考

结合你提到的“购物车页面与订单确认页面效果”,注释中特意强调了:

  • 收货地址校验
  • 库存乐观扣减
  • 购物车清理时机
  • 支付链接生成

6. 动态ChatClient构建(5.3.1节)- 实际代码

实际项目用的是静态配置,而不是论文里写的”动态读取Redis切换Key”。

以下是对 SpringAIConfiguration 配置类的详细注释,解释了大模型接入的核心原理与参数细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
* Spring AI 大模型接入配置类
* 作用:将 Spring AI 框架提供的 ChatClient 声明为 Spring 容器管理的 Bean,
* 并针对阿里百炼 DashScope 平台的特殊参数进行定制化配置。
* 解释:Spring AI 是一套统一的 Java AI 应用开发框架,它对不同的大模型厂商(OpenAI、Azure、阿里百炼等)
* 提供了统一的 API 抽象。本配置类通过声明 ChatClient Bean,使得项目其他地方只需注入该 Bean
* 即可发起对话请求,无需关心底层 HTTP 调用细节。
*/
@Configuration // 标识这是一个 Spring 配置类,启动时会自动加载并执行其中的 @Bean 方法
public class SpringAIConfiguration {

/**
* 创建并配置 ChatClient Bean
* 解释:ChatClient 是 Spring AI 提供的对话客户端门面接口,它封装了与模型交互的完整流程,
* 包括请求构建、参数设置、响应解析等。通过构建器模式可以灵活设置默认选项。
*
* @param openAiChatModel Spring AI 自动装配的 OpenAI 兼容聊天模型实例
* 该 Bean 由 Spring AI 的自动配置类根据 application.yml 中的
* spring.ai.openai.* 配置项自动创建。
* @return 配置完成的 ChatClient 实例,供业务层直接调用
*/
@Bean
public ChatClient chatClient(OpenAiChatModel openAiChatModel) {
// 1. 准备扩展请求体参数(extraBody)
// 阿里百炼 DashScope 兼容 OpenAI API 的同时,提供了一些专属参数,
// 这些参数需要放在请求体的根级别下,而非嵌套在标准字段中。
Map<String, Object> extraBody = new HashMap<>();

// 2. 关闭 Qwen3 推理模型的“思考过程输出”
// 背景:Qwen3 系列模型具备深度思考能力,开启 enable_thinking 后,
// 模型会在正式回复前输出一段包含推理过程的文本(类似 OpenAI o1 的思维链)。
// 但在面向普通用户的对话场景中,这段思考内容会污染最终的回答文本,
// 导致前端展示异常。因此显式设置为 false 以屏蔽思考内容。
extraBody.put("enable_thinking", false);

// 3. 构建 OpenAiChatOptions 并设置扩展参数
// OpenAiChatOptions 是 Spring AI 为 OpenAI 协议模型提供的请求选项对象。
// 通过 extraBody() 方法可将自定义的 Map 结构合并到最终的 JSON 请求体中。
OpenAiChatOptions options = OpenAiChatOptions.builder()
.extraBody(extraBody) // 将 enable_thinking: false 注入请求体
.build();

// 4. 使用构建器模式创建 ChatClient 并注入默认选项
// .defaultOptions(...) 设置的参数将在每次调用时自动携带,无需业务代码重复设置。
// 业务代码仍可以在具体调用时通过 withOptions() 覆盖或追加参数。
return ChatClient.builder(openAiChatModel)
.defaultOptions(options)
.build();
}
}

关联配置文件说明(application.yml 示例)

为了让以上配置正常工作,application.yml 中需要包含类似如下的配置项,这些配置会被 Spring AI 自动解析并用于创建 OpenAiChatModel Bean:

1
2
3
4
5
6
7
8
9
spring:
ai:
openai:
api-key: sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx # 阿里百炼 DashScope 提供的 API Key
base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 百炼 OpenAI 兼容接口地址
chat:
options:
model: qwen-plus # 或 qwen-max、qwen-turbo 等具体模型名称
temperature: 0.7 # 可选,控制回答随机性

关键知识点补充

概念 解释
Spring AI Spring 官方推出的 AI 应用开发框架,旨在为 Java 开发者提供与 Python 生态 LangChain 类似的工具链。它统一了 ChatClient、EmbeddingClient、Function Calling 等 API。
OpenAiChatModel Spring AI 中实现 OpenAI 协议的具体模型客户端。由于阿里百炼 DashScope 提供了 OpenAI 兼容接口,因此可以直接复用该实现,无需额外开发适配器。
extraBody Spring AI 设计的一个扩展点,允许开发者向最终的 HTTP 请求体中注入非标准字段,以满足不同厂商的特有参数需求。
enable_thinking = false 阿里百炼平台针对 Qwen3 推理模型的专属参数。关闭后模型不再输出思考链,保证 getContent() 拿到的就是最终回复文本。

商品向量索引(5.3.2节)- 实际代码

论文里有两个错误:1. Document构造方式2. Embedding维度。实际使用的是阿里百炼的 text-embedding-v4 模型,维度是 1024,不是 1536。
以下是对商品向量化入库代码片段的详细注释,解释了从数据库读取、文档构建、分批提交到向量存储的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* 商品向量化入库(由异步任务触发)
* 作用:将上架商品的基础信息及每个 SKU 的详情信息转化为向量文档,存入 Elasticsearch 向量数据库,
* 为后续的 RAG(检索增强生成)智能搜索提供语义召回能力。
* 解释:商品上架后,系统通过异步任务 {@link RagDataTask} 调用本方法,避免阻塞主业务流程。
* 每个商品会被拆分为 1 个基础文档 + N 个 SKU 文档,分别向量化后存入 ES。
* 文档的向量化由 Spring AI 的 ElasticsearchVectorStore 委托给阿里百炼 text-embedding-v4 模型完成。
*
* @param productId 待向量化的商品ID
*/
private void saveData2VectorDB4Product(String productId) {
// 1. 查询商品基础信息(含状态、名称等)
ProductInfo productInfo = productInfoMapper.selectByProductId(productId);

// 2. 若商品已下架,则从向量库中删除该商品相关的所有向量文档
// 删除操作同样通过 vectorStore.delete() 完成,传入文档ID前缀即可批量删除。
// 数据格式约定:基础文档ID = dataType + productId,SKU文档ID = dataType + productId + "_" + hash
if (!ProductStatusEnum.ON_SALE.getStatus().equals(productInfo.getStatus())) {
vectorStore.delete(RagDataTypeEnum.PRODUCT.getType() + productId);
return; // 下架商品无需继续向量化
}

// 3. 准备文档容器,用于累积待提交的 Document 对象
List<Document> list = new ArrayList<>();

// --- 3.1 构建商品基础文档(仅包含商品名称,用于大类搜索)---
Map<String, Object> metaData = new HashMap<>();
metaData.put("dataType", RagDataTypeEnum.PRODUCT.getType()); // 数据类型标识,用于后续过滤(如只搜商品)
metaData.put("productId", productInfo.getProductId()); // 原始业务ID,检索后可反查出详情
metaData.put("productName", productInfo.getProductName()); // 冗余存储,便于前端高亮展示

// 创建 Document 对象:参数1为文档唯一ID,参数2为待向量化的文本内容,参数3为元数据
// 基础文档ID = 类型前缀 + 商品ID,确保与删除操作的 ID 规则一致
list.add(new Document(productInfo.getProductId(), productInfo.getProductName(), metaData));

// --- 3.2 构建每个 SKU 的详情文档(用于精细匹配)---
for (ProductSku sku : allSkuList) {
// 拼接文档内容:商品名称 + 空格 + 属性组合描述
// 例如:"iPhone 15 黑色 128GB"
StringBuilder content = new StringBuilder(productInfo.getProductName()).append(" ");
// 根据 sku 的 propertyValueIds 拼接具体的属性名和属性值(如 "颜色:黑色 容量:128GB")
appendSkuAttributes(content, sku);

// 构建 SKU 专属元数据(包含 SKU ID、价格、库存等扩展信息)
Map<String, Object> skuMeta = new HashMap<>(metaData); // 继承基础元数据
skuMeta.put("skuId", sku.getSkuId());
skuMeta.put("price", sku.getPrice());
skuMeta.put("stock", sku.getStock());
// 可继续添加其他检索过滤所需字段...

// SKU 文档ID:商品ID + 下划线 + 属性值哈希(保证唯一性)
String skuDocId = productInfo.getProductId() + "_" + sku.getPropertyValueIdHash();
list.add(new Document(skuDocId, content.toString(), skuMeta));

// --- 3.3 控制单次提交批次大小,适配阿里百炼 API 限制 ---
// 阿里百炼 DashScope 文本向量化接口单次请求最多接受 10 条文本,
// 因此当累积达到 10 条时,立即调用 vectorStore.add() 提交一批,并清空容器。
if (list.size() >= 10) {
vectorStore.add(list); // 底层会并行调用 text-embedding-v4 模型,返回 1024 维向量并存入 ES
list.clear(); // 清空以继续处理剩余 SKU
}
}

// 4. 提交最后一批不足 10 条的文档
if (!list.isEmpty()) {
vectorStore.add(list);
}
}

关键设计说明

设计点 解释
异步执行 (RagDataTask) 向量化调用外部 API 存在网络延迟,通过异步任务避免拖慢商品上架的接口响应时间。
1 个基础文档 + N 个 SKU 文档 基础文档用于泛化搜索(如搜“手机”返回该商品),SKU 文档用于精准匹配(如搜“黑色 128GB 手机”)。两者结合提高召回准确率。
Document ID 设计 采用 dataType + productIddataType + productId + "_" + hash 的格式,便于按商品维度进行精准删除(如下架时清理所有相关向量)。
MetaData 存储业务字段 向量检索返回的仅是 Document ID 和相似度分数,具体的商品详情仍需回查数据库。MetaData 中冗余存储关键字段(如名称、价格)可用于前端直接展示,减少二次查询。
批次大小限制 (10 条) 阿里百炼 text-embedding-v4 模型的官方文档限制单次请求最多 10 条文本。代码通过 list.size() >= 10 自动分批,符合最佳实践。
vectorStore.add() 底层行为 Spring AI 的 ElasticsearchVectorStore 在调用 add() 时会自动:
1. 提取 Document.content 字段;
2. 调用配置好的 Embedding 模型生成 1024 维向量;
3. 将向量与元数据一并存入 ES 的 dense_vector 字段。

向量检索时的使用示例

当用户通过自然语言提问时,ChatComponent 可先调用 vectorStore.similaritySearch(query) 召回相关商品文档,再将文档内容作为上下文注入大模型提示词,实现“基于自有商品库的智能问答”。

MCP工具类(5.3.3节)- 实际代码

以下是对 MCP 工具服务代码片段的详细注释,涵盖模块架构、注解含义、参数设计及返回值策略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
/**
* 订单相关 MCP 工具服务
* 作用:将订单业务能力封装为 MCP(Model Context Protocol)工具,供大语言模型(LLM)在对话中调用。
* 解释:本服务部署在独立的 easymall-mcp 模块(Reactive + Streamable HTTP,端口 8084),
* 通过 Spring AI 的 @Tool 注解将方法暴露为 LLM 可识别的 Function Call。
* 每个工具方法都要求显式传入 userId 参数,由 AI 从会话上下文中提取并传入,
* 从而确保多用户场景下的数据隔离与权限校验。
*/
@Service
@Slf4j
public class OrderService {
@Resource
private OrderInfoService orderInfoService; // 订单核心业务服务
@Resource
private OrderLogisticsInfoService orderLogisticsInfoService; // 物流信息服务
@Resource
private OrderCommentService orderCommentService; // 评价服务(此处未使用,仅为展示服务注入)

/**
* 退款申请工具
* 解释:LLM 在理解用户退款意图后,会调用此工具,并自动填入从会话中提取的 userId 和用户提供的订单号。
* 方法内部调用业务层执行退款逻辑,若发生业务异常(如订单状态不允许退款),
* 则捕获异常并将友好的中文提示直接返回给 LLM,由 LLM 组织自然语言回复用户。
*
* @param userId 当前会话用户的唯一标识(由 AI 从上下文注入)
* @param orderId 待退款的订单号(由 AI 从用户对话中解析)
* @return 统一的中文结果字符串,便于 LLM 直接用于生成回复
*/
@Tool(name = "refund", description = "退款")
public String refund(@ToolParam(description = "用户ID") String userId,
@ToolParam(description = "订单ID") String orderId) {
try {
// 调用业务层执行退款,业务层内部会校验订单归属、状态等
orderInfoService.refundByOrderId(userId, orderId);
} catch (BusinessException e) {
// 业务异常时,返回异常消息本身(如"订单已完成,无法退款"),让 LLM 原样告知用户
return e.getMessage();
}
// 执行成功返回固定成功文案
return "退款成功";
}

/**
* 取消订单工具
* 解释:用户要求取消未付款或未发货的订单时,LLM 调用此工具。
* 与退款工具不同,取消操作通常发生在支付前,因此调用不同的业务方法。
*
* @param userId 当前会话用户的唯一标识
* @param orderId 待取消的订单号
* @return 执行结果中文描述
*/
@Tool(name = "cancelOrder", description = "取消订单")
public String cancelOrder(@ToolParam(description = "用户ID") String userId,
@ToolParam(description = "订单ID") String orderId) {
// 调用业务层取消订单,并指定目标状态为 CANCELLED
orderInfoService.cancelOrder(userId, orderId, OrderStatusEnum.CANCELLED);
return "订单取消成功";
}

/**
* 物流查询工具
* 解释:用户询问"我的快递到哪了"时,LLM 调用此工具获取结构化的物流轨迹数据。
* 返回值为 JSON 格式字符串,包含物流公司、运单号、各节点时间及状态。
* 尽管 LLM 具备理解 JSON 的能力,但业务层也可进一步优化为纯文本描述。
*
* @param userId 当前会话用户的唯一标识
* @param orderId 待查询的订单号
* @return 物流信息的 JSON 字符串
*/
@Tool(name = "queryOrderLogistics", description = "查询订单物流信息")
public String queryOrderLogistics(@ToolParam(description = "用户ID") String userId,
@ToolParam(description = "订单ID") String orderId) {
// 查询物流记录,业务层会校验订单是否属于该用户
OrderLogisticsInfo info = orderLogisticsInfoService.getOrderLogisticsRecords(userId, orderId);
// 将对象序列化为 JSON 字符串返回,LLM 可从中提取关键字段(如"最新状态:已签收")
return JsonUtils.convertObj2Json(info);
}
}

MCP 工具服务设计要点说明

设计点 解释
独立模块部署 MCP Server 独立于主业务应用,使用 Reactive 编程模型和 Streamable HTTP 协议,提升并发处理能力,避免 AI 工具调用阻塞主服务。
@Tool@ToolParam 注解 Spring AI 提供的标准化注解,在应用启动时会被扫描并生成 JSON Schema 格式的工具定义,通过 MCP 协议暴露给 LLM 客户端(如 Claude Desktop、自建 Agent)。
显式 userId 参数 多租户场景下的安全基石。LLM 调用工具时必须提供当前会话的用户标识,后端据此进行数据权限校验,防止用户 A 通过 AI 查询到用户 B 的订单。
返回中文自然语言友好字符串 工具返回值将作为 LLM 上下文的一部分,直接返回”退款成功”或异常原因,LLM 可无加工地嵌入到回复中(如:”好的,已经为您申请退款,系统提示:退款成功”)。
异常捕获返回友好信息 业务层抛出的 BusinessException 包含面向用户可读的错误描述(如”该订单已完成,无法申请退款”),工具层直接捕获并返回,避免向 LLM 暴露技术堆栈。

配套的 MCP 配置示例(application-mcp.yml)

1
2
3
4
5
6
7
8
9
10
11
spring:
ai:
mcp:
server:
name: easymall-mcp-server
version: 1.0.0
type: SYNC
tools:
enabled: true
server:
port: 8084

在 LLM 客户端中的使用效果

当用户在聊天界面输入”帮我查一下尾号 1234 的订单到哪了”,AI Agent 会执行以下流程:

  1. 从会话上下文中获取 userId = "10001"
  2. 解析用户意图并提取 orderId = "1234"
  3. 调用 MCP 工具 queryOrderLogistics("10001", "1234")
  4. 获得 JSON 格式物流数据,提取关键状态;
  5. 生成自然语言回复:”您的订单已到达【杭州转运中心】,预计今日下午派送。”

❌ 9. 流式响应实现(5.3.4节)- 实际代码

实际项目中的流式响应通过 Redis Pub/Sub (发布订阅) 进行了中转,而不是直接写 Netty 通道。这样可以支持分布式多节点部署。
以下是对 AI 流式回复处理核心代码的详细注释,涵盖了响应式流处理、Redis 消息中转、Netty WebSocket 推送以及持久化逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
* AI 流式对话核心处理方法
* 作用:接收用户发送的 AI 对话请求,通过 Spring AI 的流式接口逐字获取大模型输出,
* 并将每个生成片段(Token)通过 Redis 消息队列广播到所有网关实例,
* 最终由与用户建立 WebSocket 连接的 Netty 网关实例推送到前端。
* 解释:本方法的设计解决了多实例 Netty 网关的分布式推送问题。
* AI 生成内容是一个持续的过程,可能由实例 A 触发,但用户的长连接在实例 B 上。
* 借助 Redis 的 Pub/Sub 机制,任何实例收到的 Token 都能广播给所有实例,
* 再由各实例检查目标用户是否连接到本地 Channel,从而实现精准推送。
*
* @param type 对话类型(如普通问答、订单查询、商品推荐),用于构建不同的提示词模板
* @param agentMessage 封装了用户ID、消息ID、原始问题等信息的消息对象
*/
private void chat(PromptTypeEnum type, AgentMessage agentMessage) {
// 1. 准备消息推送的 DTO 对象,用于 Redis 中转和 WebSocket 推送
MessageSendDTO dto = new MessageSendDTO();
dto.setUserId(agentMessage.getUserId()); // 指定接收用户

// 2. 用于本地收集 AI 输出的完整文本,以便对话结束后持久化到数据库
List<String> chatMessage = new ArrayList<>();

// 3. 构建 ChatClient 请求规格(RequestSpec),并开启流式调用
getChatClientRequestSpec()
// 注册工具回调提供者,使 LLM 能够调用本地的 MCP 工具(如查询订单、退款等)
.toolCallbacks(toolCallbackProvider)
// 加载历史对话消息,构造包含上下文的完整 Prompt
.messages(getHistoryMessage(agentMessage.getUserId(), prompt))
// 调用 stream().chatResponse() 获取响应式的 Flux<ChatResponse> 流
.stream().chatResponse()
// ==================== 流式事件处理 ====================
// 4. 处理每一个 Token 到达事件
.doOnNext(response -> {
// 从 ChatResponse 中提取当前生成的文本片段(Token)
String token = response.getResults().get(0).getOutput().getText();
if (!StringTools.isEmpty(token)) {
// 设置消息类型为"输出中",告诉前端这是流式内容的一部分
dto.setOutPutType(MessageOutPutTypeEnum.OUTPUTTING.getType());
dto.setAssistantMessage(token); // 携带本次 Token
dto.setMessageId(agentMessage.getMessageId()); // 关联前端生成的消息ID,用于界面更新

// 【核心】通过 Redis 的 Pub/Sub 机制将 Token 广播给所有网关实例
// 所有订阅了同一频道的网关实例都会收到此消息,然后检查目标用户是否连接在本地,
// 若是则通过 Netty 的 TextWebSocketFrame 推送给前端浏览器。
messageHandler.sendMessage(dto);

// 将 Token 暂存到本地列表,用于流结束后拼接完整回答并入库
chatMessage.add(token);
}
})
// 5. 流正常结束时的处理
.doOnComplete(() -> {
// 设置消息类型为"完成",前端收到后可停止加载动画
dto.setOutPutType(MessageOutPutTypeEnum.DONE.getType());
messageHandler.sendMessage(dto);

// 将本次对话的完整内容持久化到数据库(聊天记录表)
// 参数:消息ID、意图类型(对话)、完整回答文本、附加元数据(此处为 null)
agentMessageService.completeMessage(
agentMessage.getMessageId(),
UserIntentEnum.CHAT.getKey(),
String.join("", chatMessage), // 拼接所有 Token 为完整回答
null
);
})
// 6. 流发生异常时的处理(如网络中断、模型超时、工具调用失败等)
.doOnError(err -> {
log.error("AI 流式对话异常, userId={}, msgId={}",
agentMessage.getUserId(), agentMessage.getMessageId(), err);
// 发送错误类型消息给前端,提示用户稍后重试
dto.setOutPutType(MessageOutPutTypeEnum.ERROR.getType());
dto.setAssistantMessage("抱歉,服务暂时不可用,请稍后重试");
messageHandler.sendMessage(dto);
// 持久化错误信息(可选)
agentMessageService.completeMessage(
agentMessage.getMessageId(),
UserIntentEnum.CHAT.getKey(),
"[ERROR] " + err.getMessage(),
null
);
})
// 7. 【关键】订阅响应式流,使整个流程真正开始执行
// 因为 Reactor 流是惰性的,不调用 subscribe() 则不会触发任何操作。
.subscribe();
}

流式链路全景图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
用户输入(前端 WebSocket)

Netty 网关实例 A 收到消息 → 转发给后端业务服务

ChatComponent.chat() 构建 Flux<ChatResponse>

┌───────────────────────────────────────────┐
│ Spring AI 调用阿里百炼 DashScope │
│ (HTTP SSE 流式响应) │
└───────────────────────────────────────────┘
↓ (Token 逐个到达)
doOnNext → messageHandler.sendMessage(dto)

┌───────────────────────────────────────────┐
│ Redis Pub/Sub (MESSAGE_TOPIC) │
│ 将 DTO 广播至所有订阅此频道的网关实例 │
└───────────────────────────────────────────┘
↓ (广播)
Netty 网关实例 A ──┐
Netty 网关实例 B ──┼→ 收到广播消息 → 查找本地 userId 对应的 Channel
Netty 网关实例 C ──┘
↓ (若查找到)
通过 Channel.writeAndFlush(new TextWebSocketFrame(token))

前端浏览器实时逐字展示 AI 回复

关键设计说明

设计点 解释
响应式流 Flux<ChatResponse> Spring AI 基于 Project Reactor 提供的响应式流接口,能够以非阻塞的方式逐 Token 处理 LLM 的 SSE 输出,避免线程阻塞。
doOnNext 处理每个 Token 每个 Token 到达时立即通过 Redis 广播,延迟极低,给用户“打字机”般的实时反馈体验。
Redis Pub/Sub 中转 解决分布式多实例部署下,用户 WebSocket 连接与 AI 处理实例不在同一机器的问题。任何实例收到 Token 后发布到 Redis,所有订阅实例收到消息后检查本地连接并推送。
messageHandler.sendMessage(dto) 封装了 Redis 发布逻辑,底层使用 StringRedisTemplate.convertAndSend() 或类似的异步发送。
ChannelContextUtils 订阅与推送 网关模块中的工具类,在应用启动时订阅 Redis 频道,收到消息后调用 NettyWebSocketSessionHolder.getChannel(userId) 获取对应的 Channel,再写入 TextWebSocketFrame
doOnComplete 持久化 流结束后将完整回答保存到数据库,用于历史记录展示。由于流式过程中 Token 可能被分割,此处使用 List<String> 暂存并最终拼接。
subscribe() 触发执行 Reactor 编程模型的关键一步,不调用 subscribe() 整个流定义仅为“装配”,不会实际发起网络请求。

支付宝支付(5.5.1节)- 实际代码

以下是对支付宝 PC 网页支付实现类的详细注释,延续之前的风格,涵盖策略模式设计、支付宝 SDK 配置、请求参数对象化设置以及响应处理逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* 支付宝 PC 网页支付实现
* 通过 AlipayTradePagePayModel 构建请求参数,使用 AlipayClient 获取支付 HTML 表单
*/
@Override
public PayInfoDTO getPayUrl(PayChannelEnum channel, String payOrderId, String subject, BigDecimal amount) {
try {
// 1. 初始化支付宝客户端
// 从配置中读取 appId、私钥、公钥、网关、签名类型等
AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());

// 2. 创建页面支付请求对象
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();

// 3. 构建业务参数模型(对象化配置)
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(payOrderId); // 商户订单号,需保证唯一
model.setTotalAmount(amount.toString()); // 支付金额,字符串格式(精确到分)
model.setSubject(subject); // 订单标题,显示在支付宝收银台
// 设置订单超时时间,格式:yyyy-MM-dd HH:mm:ss
model.setTimeExpire(DateUtil.getMinAfter(
appConfig.getOrderExpireMinute(),
DateTimePatternEnum.YYYY_MM_DD_HH_MM_SS.getPattern()
));

request.setBizModel(model); // 将业务参数装入请求
// 设置异步回调地址(拼接项目域名 + 固定回调路径)
request.setNotifyUrl(appConfig.getProjectDomain() + NOTIFY_URL);

String payInfo = null;

// 4. 根据渠道执行相应操作(当前仅处理 ALIPAY_PC)
if (channel == PayChannelEnum.ALIPAY_PC) {
// PC 网页支付需指定产品码为 FAST_INSTANT_TRADE_PAY
model.setProductCode("FAST_INSTANT_TRADE_PAY");

// 发起页面支付请求,获取响应
AlipayTradePagePayResponse response = alipayClient.pageExecute(request);

// 判断请求是否成功
if (!response.isSuccess()) {
throw new BusinessException("获取支付信息失败");
}

// 获取支付页面 HTML 表单字符串
// 此表单提交后会跳转到支付宝收银台页面
payInfo = response.getBody();
}

// 5. 封装统一返回对象
// payInfo:HTML 表单,前端直接输出即可实现跳转
// payOrderId:内部订单号,用于后续关联
// amount:支付金额,用于前端展示
return new PayInfoDTO(payInfo, payOrderId, amount);

} catch (Exception e) {
log.error("支付宝支付获取支付信息失败", e);
throw new BusinessException("获取支付信息失败");
}
}

策略模式相关支撑代码说明

1. PayChannel 接口定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 支付通道统一接口
* 作用:定义获取支付链接的标准行为,所有具体支付渠道(支付宝、微信等)必须实现此接口。
*/
public interface PayChannel {
/**
* 获取支付链接/支付凭证
* @param channel 支付渠道枚举
* @param payOrderId 订单号
* @param subject 订单标题
* @param amount 金额
* @return 支付信息 DTO
*/
PayInfoDTO getPayUrl(PayChannelEnum channel, String payOrderId, String subject, BigDecimal amount);
}

2. 策略工厂(或使用 Map 自动注入)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 支付通道工厂
* 作用:根据支付渠道枚举返回对应的支付实现类实例。
*/
@Component
public class PayChannelFactory {
// Spring 会自动将所有实现 PayChannel 接口的 Bean 注入到该 Map 中,
// key 为 Bean 的名称,可通过实现类上标注的 @Service("alipay") 指定
@Resource
private Map<String, PayChannel> payChannelMap;

public PayChannel getPayChannel(PayChannelEnum channel) {
return payChannelMap.get(channel.getBeanName());
}
}

关键设计说明

设计点 解释
策略模式封装 将不同支付渠道的差异隔离在各自的实现类中,便于扩展(如新增微信支付时只需新增一个类实现 PayChannel 接口),符合开闭原则。
对象化参数模型 使用 AlipayTradePagePayModel 代替手动拼接 Map,避免字段名拼写错误,提高代码可读性。
绝对超时时间设置 setTimeExpire() 用于支付宝侧自动关单,与系统内的订单过期时间保持一致,防止用户长时间不支付导致库存被锁定。
异步通知 URL request.setNotifyUrl() 配置的是后端接收支付结果回调的地址,用于幂等更新订单状态,必须保证外网可达。
异常统一处理 支付宝 SDK 抛出的异常被捕获后包装为 BusinessException,交由全局异常处理器返回友好 JSON 给前端,同时确保 @Transactional 事务回滚。
返回 HTML 表单字符串 PC 网页支付的标准做法,前端获取 payInfo 后可直接写入页面,浏览器自动提交表单跳转到支付宝收银台,无需后端重定向。