全局异常处理
论文里是一个简单版的异常处理器,而实际项目用了一个更强大的“全能型”处理器。
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
@RestControllerAdvice public class AGlobalExceptionHandlerController extends ABaseController {
private static final Logger logger = LoggerFactory.getLogger(AGlobalExceptionHandlerController.class); private static final String STATUC_ERROR = "error";
@ExceptionHandler(value = Exception.class) Object handleException(Exception e, HttpServletRequest request) { logger.error("请求错误,请求地址{}, 错误信息:", request.getRequestURL(), e);
ResponseVO ajaxResponse = new ResponseVO();
if (e instanceof NoHandlerFoundException) { ajaxResponse.setCode(ResponseCodeEnum.CODE_404.getCode()); ajaxResponse.setInfo(ResponseCodeEnum.CODE_404.getMsg()); ajaxResponse.setStatus(STATUC_ERROR); } else if (e instanceof BusinessException) { BusinessException biz = (BusinessException) e; 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) { ajaxResponse.setCode(ResponseCodeEnum.CODE_600.getCode()); ajaxResponse.setInfo(ResponseCodeEnum.CODE_600.getMsg()); ajaxResponse.setStatus(STATUC_ERROR); } else if (e instanceof DuplicateKeyException) { ajaxResponse.setCode(ResponseCodeEnum.CODE_601.getCode()); ajaxResponse.setInfo(ResponseCodeEnum.CODE_601.getMsg()); ajaxResponse.setStatus(STATUC_ERROR); } else { ajaxResponse.setCode(ResponseCodeEnum.CODE_500.getCode()); ajaxResponse.setInfo(ResponseCodeEnum.CODE_500.getMsg()); ajaxResponse.setStatus(STATUC_ERROR); }
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
|
@Override public TokenUserInfoDTO login(String email, String password, String ip) { UserInfo userInfo = this.userInfoMapper.selectByEmail(email);
if (null == userInfo || !userInfo.getPassword().equals(password)) { throw new BusinessException("账号或者密码错误"); }
if (UserStatusEnum.DISABLE.getStatus().equals(userInfo.getStatus())) { throw new BusinessException("账号已禁用"); }
UserInfo updateInfo = new UserInfo(); updateInfo.setLastLoginTime(new Date()); updateInfo.setLastLoginIp(ip); this.userInfoMapper.updateByUserId(updateInfo, userInfo.getUserId());
TokenUserInfoDTO tokenUserInfoDto = CopyTools.copy(userInfo, TokenUserInfoDTO.class);
redisComponent.saveTokenInfo(tokenUserInfoDto);
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
|
@Component @Slf4j public class EsSearchComponent {
@Resource private ElasticsearchOperations elasticsearchOperations;
@Resource private ProductInfoMapper<ProductInfo, ProductInfoQuery> productInfoMapper;
@PostConstruct public void createIndexWithIK() { try { IndexOperations indexOps = elasticsearchOperations.indexOps(ProductInfoDTO.class); if (indexOps.exists()) { return; }
String json = """ { "analysis": { "analyzer": { "ik_max_word": { "type": "custom", "tokenizer": "ik_max_word" }, "ik_smart": { "type": "custom", "tokenizer": "ik_smart" } } } } """;
indexOps.create(JsonUtils.convertJson2Obj(json, Map.class));
Document mapping = indexOps.createMapping(ProductInfoDTO.class); indexOps.putMapping(mapping);
log.info("索引创建成功,已应用IK分词器"); } catch (Exception e) { log.error("创建索引失败", e); throw new RuntimeException("创建索引失败", e); } }
public PaginationResultVO<ProductInfoDTO> searchProducts(String keyWords, BigDecimal priceFrom, BigDecimal priceTo, String sortType, String sortField, Integer pageNo) { try { pageNo = pageNo == null ? 1 : pageNo; pageNo = pageNo - 1; int pageSize = PageSize.SIZE15.getSize();
Criteria criteria = new Criteria();
if (keyWords.length() <= 2) { Criteria nameCriteria = new Criteria(); nameCriteria = nameCriteria.or(new Criteria("productName").contains(keyWords)); nameCriteria = nameCriteria.or(new Criteria("productName").expression("*" + keyWords + "*")); nameCriteria = nameCriteria.or(new Criteria("productName").matches(keyWords)); criteria = criteria.and(nameCriteria); } else { criteria = criteria.and("productName").contains(keyWords); }
if (priceFrom != null || priceTo != null) { Criteria priceCriteria = new Criteria(); priceCriteria = priceCriteria.and("minPrice"); if (priceFrom != null) { priceCriteria = priceCriteria.greaterThanEqual(priceFrom); } if (priceTo != null) { priceCriteria = priceCriteria.lessThanEqual(priceTo); } criteria = criteria.and(priceCriteria); }
SearchSortTypeEnum sortTypeEnum = SearchSortTypeEnum.getByType(sortType); sortTypeEnum = sortTypeEnum == null ? SearchSortTypeEnum.DESC : sortTypeEnum;
SearchFieldTypeEnum fieldTypeEnum = SearchFieldTypeEnum.getByFieldType(sortField); fieldTypeEnum = fieldTypeEnum == null ? SearchFieldTypeEnum.COMPOSITE : fieldTypeEnum;
Sort sort = Sort.by(sortTypeEnum.getDirection(), fieldTypeEnum.getField()); Pageable pageable = PageRequest.of(pageNo, pageSize, sort);
CriteriaQuery query = new CriteriaQuery(criteria); query.setPageable(pageable); SearchHits<ProductInfoDTO> searchHits = elasticsearchOperations.search(query, ProductInfoDTO.class);
List<ProductInfoDTO> products = searchHits.getSearchHits().stream() .map(SearchHit::getContent) .collect(Collectors.toList()); long totalHits = searchHits.getTotalHits(); int totalPages = (int) Math.ceil((double) totalHits / pageSize);
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<>()); } }
public void saveProduct(String productId) { ProductInfo product = productInfoMapper.selectByProductId(productId); ProductInfoDTO productInfoDTO = CopyTools.copy(product, ProductInfoDTO.class);
if (ProductStatusEnum.ON_SALE.getStatus().equals(product.getStatus())) { elasticsearchOperations.save(productInfoDTO); } else { 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
|
@Transactional(rollbackFor = Exception.class) public PayInfoDTO postOrder(String userId, PostOrderDTO dto) {
UserAddress address = userAddressMapper.selectByAddressId(dto.getAddressId()); if (address == null || !address.getUserId().equals(userId)) { throw new BusinessException("收货地址不存在"); }
List<StockUpdateItem> stockUpdateList = buildStockUpdateList(dto); Integer updateCount = productSkuMapper.updateStockBatch(stockUpdateList); if (updateCount < stockUpdateList.size()) { throw new BusinessException("库存不足"); }
OrderInfo orderInfo = buildOrder(userId, dto, address); orderInfoMapper.insert(orderInfo);
orderItemMapper.insertBatch(buildItems(orderInfo, dto)); orderLogisticsInfoMapper.insert(buildLogistics(orderInfo, address));
productCartMapper.deleteByCartIds(dto.getCartIds());
redisComponent.addOrderDelayQueue(orderInfo.getOrderId());
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
|
@Configuration public class SpringAIConfiguration {
@Bean public ChatClient chatClient(OpenAiChatModel openAiChatModel) { Map<String, Object> extraBody = new HashMap<>(); extraBody.put("enable_thinking", false);
OpenAiChatOptions options = OpenAiChatOptions.builder() .extraBody(extraBody) .build();
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 base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 chat: options: model: qwen-plus 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
|
private void saveData2VectorDB4Product(String productId) { ProductInfo productInfo = productInfoMapper.selectByProductId(productId);
if (!ProductStatusEnum.ON_SALE.getStatus().equals(productInfo.getStatus())) { vectorStore.delete(RagDataTypeEnum.PRODUCT.getType() + productId); return; }
List<Document> list = new ArrayList<>();
Map<String, Object> metaData = new HashMap<>(); metaData.put("dataType", RagDataTypeEnum.PRODUCT.getType()); metaData.put("productId", productInfo.getProductId()); metaData.put("productName", productInfo.getProductName());
list.add(new Document(productInfo.getProductId(), productInfo.getProductName(), metaData));
for (ProductSku sku : allSkuList) { StringBuilder content = new StringBuilder(productInfo.getProductName()).append(" "); appendSkuAttributes(content, sku);
Map<String, Object> skuMeta = new HashMap<>(metaData); skuMeta.put("skuId", sku.getSkuId()); skuMeta.put("price", sku.getPrice()); skuMeta.put("stock", sku.getStock());
String skuDocId = productInfo.getProductId() + "_" + sku.getPropertyValueIdHash(); list.add(new Document(skuDocId, content.toString(), skuMeta));
if (list.size() >= 10) { vectorStore.add(list); list.clear(); } }
if (!list.isEmpty()) { vectorStore.add(list); } }
|
关键设计说明
| 设计点 |
解释 |
异步执行 (RagDataTask) |
向量化调用外部 API 存在网络延迟,通过异步任务避免拖慢商品上架的接口响应时间。 |
| 1 个基础文档 + N 个 SKU 文档 |
基础文档用于泛化搜索(如搜“手机”返回该商品),SKU 文档用于精准匹配(如搜“黑色 128GB 手机”)。两者结合提高召回准确率。 |
| Document ID 设计 |
采用 dataType + productId 和 dataType + 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
|
@Service @Slf4j public class OrderService { @Resource private OrderInfoService orderInfoService; @Resource private OrderLogisticsInfoService orderLogisticsInfoService; @Resource private OrderCommentService orderCommentService;
@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) { return e.getMessage(); } return "退款成功"; }
@Tool(name = "cancelOrder", description = "取消订单") public String cancelOrder(@ToolParam(description = "用户ID") String userId, @ToolParam(description = "订单ID") String orderId) { orderInfoService.cancelOrder(userId, orderId, OrderStatusEnum.CANCELLED); return "订单取消成功"; }
@Tool(name = "queryOrderLogistics", description = "查询订单物流信息") public String queryOrderLogistics(@ToolParam(description = "用户ID") String userId, @ToolParam(description = "订单ID") String orderId) { OrderLogisticsInfo info = orderLogisticsInfoService.getOrderLogisticsRecords(userId, orderId); 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 会执行以下流程:
- 从会话上下文中获取
userId = "10001";
- 解析用户意图并提取
orderId = "1234";
- 调用 MCP 工具
queryOrderLogistics("10001", "1234");
- 获得 JSON 格式物流数据,提取关键状态;
- 生成自然语言回复:”您的订单已到达【杭州转运中心】,预计今日下午派送。”
❌ 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
|
private void chat(PromptTypeEnum type, AgentMessage agentMessage) { MessageSendDTO dto = new MessageSendDTO(); dto.setUserId(agentMessage.getUserId());
List<String> chatMessage = new ArrayList<>();
getChatClientRequestSpec() .toolCallbacks(toolCallbackProvider) .messages(getHistoryMessage(agentMessage.getUserId(), prompt)) .stream().chatResponse() .doOnNext(response -> { String token = response.getResults().get(0).getOutput().getText(); if (!StringTools.isEmpty(token)) { dto.setOutPutType(MessageOutPutTypeEnum.OUTPUTTING.getType()); dto.setAssistantMessage(token); dto.setMessageId(agentMessage.getMessageId());
messageHandler.sendMessage(dto);
chatMessage.add(token); } }) .doOnComplete(() -> { dto.setOutPutType(MessageOutPutTypeEnum.DONE.getType()); messageHandler.sendMessage(dto);
agentMessageService.completeMessage( agentMessage.getMessageId(), UserIntentEnum.CHAT.getKey(), String.join("", chatMessage), null ); }) .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 ); }) .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
|
@Override public PayInfoDTO getPayUrl(PayChannelEnum channel, String payOrderId, String subject, BigDecimal amount) { try { AlipayClient alipayClient = new DefaultAlipayClient(getAlipayConfig());
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
AlipayTradePagePayModel model = new AlipayTradePagePayModel(); model.setOutTradeNo(payOrderId); model.setTotalAmount(amount.toString()); model.setSubject(subject); 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;
if (channel == PayChannelEnum.ALIPAY_PC) { model.setProductCode("FAST_INSTANT_TRADE_PAY");
AlipayTradePagePayResponse response = alipayClient.pageExecute(request);
if (!response.isSuccess()) { throw new BusinessException("获取支付信息失败"); }
payInfo = response.getBody(); }
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 {
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 { @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 后可直接写入页面,浏览器自动提交表单跳转到支付宝收银台,无需后端重定向。 |