Spring Boot 微服务中"调用第三方接口 → 数据加工 → 分接口返回"的完整架构实践
一、技术全景
核心技术和设计模式:
| 技术/模式 | 在接口中的应用 |
|---|---|
| DTO 分层隔离 | 前端 Form、第三方请求 DTO、返回 VO 三层分离 |
| 条件策略计算 | 根据客户类型+字段组合决定计算公式 |
| 接口拆分(读写分离思想) | 数据查询和 URL 获取拆成两个接口(解决时效性) |
| 黑名单过滤 | 数据库配置表动态过滤返回数据 |
| 异常隔离 | 辅助操作(入库)不影响主流程 |
| 枚举映射 | 前端编码和第三方编码的转换适配 |
| 签名认证 | MD5 + 时间戳保护接口调用安全 |
| 分组聚合 | 扁平数据按维度分组转嵌套结构 |
| 前置校验快速失败 | 关键参数缺失时直接返回而非继续执行 |
二、核心设计模式详解
1. 接口拆分 ------ 解决 URL 时效性问题
问题: 第三方返回的预览 URL 带签名有效期,如果在查询接口中就生成最终 URL,用户过一段时间再点击查看时 URL 可能已过期。
解决方案: 拆成两个接口:
-
接口 A(查询):返回原始参数 + 类型标识
-
接口 B(预览):用户点击时实时生成带时效的 URL
接口A 返回:{ sysType: "1", rawUrl: "https://xxx?id=123" }
↓ 用户点击时
接口B 传入:{ sysType: "1", rawParam: "https://xxx?id=123" }
接口B 返回:"https://xxx?id=123×tamp=170400&sign=abc123"(实时生成)
2. 条件策略计算 ------ 多维度组合决定公式
根据"客户类型 × 是否还原直扣 × 直扣点位是否有值"三个维度组合,选择不同的计算公式:
大D + isReback=1 → 价格1 × 点位
大D + isReback=0 + 有直扣 → (1-直扣) × 价格1 × 点位
大D + isReback=0 + 无直扣 → 价格1 × 点位
小D + isReback=1 → 价格2 × 点位
小D + isReback=0 + 有直扣 → (1-直扣) × 价格2 × 点位
小D + 无开单价 → isShow=false(不展示)
3. 前置校验快速失败
在执行复杂逻辑之前,先检查关键条件是否满足:
java
// 大D没传价格1 或 小D没传价格2 → 直接返回不展示
if (calcPrice == null || calcPrice.compareTo(BigDecimal.ZERO) == 0) {
return emptyResult(false);
}
避免后续的远程调用、数据库查询等资源浪费。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
三、完整代码示例
场景:电商平台"商品优惠计算 + 优惠券预览"
一个电商平台需要:
- 接口 A:根据用户身份(VIP/普通)和商品信息,调用促销系统获取优惠列表,按规则计算最终优惠金额
- 接口 B:用户点击"查看优惠券详情"时,实时生成带时效的优惠券预览链接
3.1 数据结构
java
// ===== 前端请求参数 =====
@Data
@ApiModel("商品优惠查询入参")
public class ProductDiscountQueryForm {
@NotBlank(message = "用户ID不能为空")
private String userId;
@NotBlank(message = "商品编码不能为空")
private String productId;
@NotNull(message = "商品原价不能为空")
private BigDecimal originalPrice;
/** 会员折扣价(VIP用户传入) */
private BigDecimal vipPrice;
/** 用户类型标识(用于判断VIP/普通) */
@NotBlank(message = "用户等级不能为空")
private String userLevel; // "VIP" 或 "NORMAL"
/** 额外折扣率(如员工内购折扣 0.05 表示5%) */
private BigDecimal extraDiscountRate;
}
// ===== 调用第三方促销系统的请求DTO =====
@Data
public class PromotionQueryDto {
private String userId;
private String productId;
private BigDecimal price;
private String channel; // 渠道:APP/PC/MINI
}
// ===== 第三方促销系统返回结构 =====
@Data
public class PromotionResponse {
private Integer code;
private String message;
private List<PromotionItem> data;
@Data
public static class PromotionItem {
private String couponId; // 优惠券ID
private String couponName; // 优惠券名称
private String discountRate; // 折扣率(如 "0.15" 表示85折)
private String couponUrl; // 优惠券详情原始URL
private String urlType; // URL类型:INTERNAL内部/EXTERNAL外部
private String isStackable; // 是否可叠加:"1"可叠加 "0"不可叠加
}
}
// ===== 返回给前端的VO =====
@Data
@Builder
public class ProductDiscountResultVO {
private Boolean isShow; // 是否展示优惠信息
private BigDecimal totalDiscount; // 最高可享优惠总金额
private List<CouponGroupVO> couponGroups; // 按活动分组的优惠列表
}
@Data
public class CouponGroupVO {
private String groupName; // 活动名称
private BigDecimal groupDiscount; // 该活动总优惠
private String couponUrl; // 优惠券URL原始参数
private String urlType; // URL类型
private List<CouponDetailVO> details; // 子优惠明细(单优惠时为空)
}
@Data
public class CouponDetailVO {
private String couponId;
private String couponName;
private BigDecimal discount;
private String couponUrl;
private String urlType;
}
// ===== 预览URL请求参数 =====
@Data
public class CouponPreviewForm {
@NotBlank(message = "URL类型不能为空")
private String urlType; // INTERNAL / EXTERNAL
@NotBlank(message = "原始参数不能为空")
private String rawParam; // 原始URL或外部系统的token
}
3.2 Feign 接口
java
@FeignClient(name = "promotionFeign", url = "${feign.promotion.url}")
public interface PromotionFeign {
@PostMapping(value = "/api/v1/discount/query", headers = {"Authorization=${feign.promotion.api-key}"})
String queryDiscount(@RequestBody PromotionQueryDto queryDto);
}
@FeignClient(name = "couponFeign", url = "${feign.coupon.url}")
public interface CouponFeign {
@GetMapping(value = "/api/v1/coupon/preview")
String getCouponPreviewUrl(
@RequestParam("token") String token,
@RequestHeader("x-timestamp") long timestamp,
@RequestHeader("x-signature") String signature);
}
3.3 Service 实现
java
@Slf4j
@Service
public class ProductDiscountServiceImpl {
private final PromotionFeign promotionFeign;
private final CouponFeign couponFeign;
private final CouponBlacklistMapper blacklistMapper;
private final DiscountQueryLogMapper queryLogMapper;
@Value("${feign.coupon.secret-key}")
private String couponSecretKey;
// ===== 接口A:查询商品优惠 =====
public ProductDiscountResultVO queryProductDiscount(ProductDiscountQueryForm form) {
// 0. 异常隔离的辅助操作:保存查询日志
saveQueryLog(form);
// 1. 前置校验:根据用户类型确定计算用的价格
BigDecimal calcPrice = determineCalcPrice(form);
if (calcPrice == null) {
return ProductDiscountResultVO.builder()
.isShow(false)
.totalDiscount(BigDecimal.ZERO)
.couponGroups(Collections.emptyList())
.build();
}
// 2. 构建第三方请求参数(DTO分层隔离)
PromotionQueryDto queryDto = buildPromotionRequest(form);
// 3. 调用第三方促销系统
List<PromotionResponse.PromotionItem> dataList;
try {
String result = promotionFeign.queryDiscount(queryDto);
PromotionResponse response = JSONUtil.toBean(result, PromotionResponse.class);
if (response == null || response.getCode() != 200 || CollUtil.isEmpty(response.getData())) {
return ProductDiscountResultVO.builder()
.isShow(false)
.totalDiscount(BigDecimal.ZERO)
.couponGroups(Collections.emptyList())
.build();
}
dataList = response.getData();
} catch (Exception e) {
log.error("调用促销系统失败: {}", e.getMessage(), e);
return ProductDiscountResultVO.builder()
.isShow(false)
.totalDiscount(BigDecimal.ZERO)
.couponGroups(Collections.emptyList())
.build();
}
// 4. 黑名单过滤(数据库配置哪些优惠券不展示)
dataList = filterByBlacklist(dataList);
if (CollUtil.isEmpty(dataList)) {
return ProductDiscountResultVO.builder()
.isShow(false)
.totalDiscount(BigDecimal.ZERO)
.couponGroups(Collections.emptyList())
.build();
}
// 5. 条件策略计算 + 分组聚合
BigDecimal extraRate = form.getExtraDiscountRate();
List<CouponGroupVO> groups = buildCouponGroups(dataList, calcPrice, extraRate);
BigDecimal totalDiscount = groups.stream()
.map(CouponGroupVO::getGroupDiscount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return ProductDiscountResultVO.builder()
.isShow(true)
.totalDiscount(totalDiscount)
.couponGroups(groups)
.build();
}
// ===== 接口B:获取优惠券预览URL(实时生成) =====
public String getCouponPreviewUrl(CouponPreviewForm form) {
if ("INTERNAL".equals(form.getUrlType())) {
// 内部系统:MD5签名拼接
String rawUrl = form.getRawParam();
long timestamp = System.currentTimeMillis();
String sign = DigestUtils.md5Hex(rawUrl.split("=")[1] + "PROMO" + timestamp);
return rawUrl + "×tamp=" + timestamp + "&sign=" + sign;
} else if ("EXTERNAL".equals(form.getUrlType())) {
// 外部系统:调用接口获取
long timestamp = System.currentTimeMillis();
String signature = DigestUtils.md5Hex(couponSecretKey + timestamp + ":" + form.getRawParam());
String result = couponFeign.getCouponPreviewUrl(form.getRawParam(), timestamp, signature);
JSONObject json = JSONUtil.parseObj(result);
if (!"200".equals(String.valueOf(json.get("code")))) {
throw new BusinessException("获取优惠券预览链接失败");
}
return json.getStr("data");
} else {
throw new BusinessException("不支持的URL类型:" + form.getUrlType());
}
}
// ===== 前置校验:确定计算用的价格 =====
private BigDecimal determineCalcPrice(ProductDiscountQueryForm form) {
if ("VIP".equals(form.getUserLevel())) {
// VIP用户用会员价
return form.getVipPrice();
} else {
// 普通用户用原价
return form.getOriginalPrice();
}
}
// ===== DTO分层:构建第三方请求参数 =====
private PromotionQueryDto buildPromotionRequest(ProductDiscountQueryForm form) {
PromotionQueryDto dto = new PromotionQueryDto();
dto.setUserId(form.getUserId());
dto.setProductId(form.getProductId());
dto.setPrice(form.getOriginalPrice());
dto.setChannel("APP");
return dto;
}
// ===== 黑名单过滤 =====
private List<PromotionResponse.PromotionItem> filterByBlacklist(
List<PromotionResponse.PromotionItem> dataList) {
List<String> couponIds = dataList.stream()
.map(PromotionResponse.PromotionItem::getCouponId)
.filter(StrUtil::isNotBlank)
.distinct()
.collect(Collectors.toList());
if (CollUtil.isEmpty(couponIds)) return dataList;
// 查询黑名单中不展示的优惠券
LambdaQueryWrapper<CouponBlacklistEntity> qw = new LambdaQueryWrapper<>();
qw.in(CouponBlacklistEntity::getCouponId, couponIds);
qw.eq(CouponBlacklistEntity::getIsVisible, 0);
List<CouponBlacklistEntity> blacklist = blacklistMapper.selectList(qw);
if (CollUtil.isEmpty(blacklist)) return dataList;
Set<String> excludeIds = blacklist.stream()
.map(CouponBlacklistEntity::getCouponId)
.collect(Collectors.toSet());
return dataList.stream()
.filter(item -> !excludeIds.contains(item.getCouponId()))
.collect(Collectors.toList());
}
// ===== 条件策略计算 =====
private BigDecimal calculateDiscount(PromotionResponse.PromotionItem item,
BigDecimal calcPrice,
BigDecimal extraRate) {
BigDecimal discountRate = StrUtil.isBlank(item.getDiscountRate()) ? BigDecimal.ZERO
: new BigDecimal(item.getDiscountRate());
if (discountRate.compareTo(BigDecimal.ZERO) == 0) {
return BigDecimal.ZERO;
}
// 额外折扣为空或为0时,直接用 价格 × 折扣率
if (extraRate == null || extraRate.compareTo(BigDecimal.ZERO) == 0) {
return calcPrice.multiply(discountRate).setScale(2, BigDecimal.ROUND_HALF_UP);
}
// 可叠加:(1 - 额外折扣率) × 价格 × 折扣率
if ("1".equals(item.getIsStackable())) {
return BigDecimal.ONE.subtract(extraRate)
.multiply(calcPrice)
.multiply(discountRate)
.setScale(2, BigDecimal.ROUND_HALF_UP);
}
// 不可叠加:价格 × 折扣率
return calcPrice.multiply(discountRate).setScale(2, BigDecimal.ROUND_HALF_UP);
}
// ===== 分组聚合 =====
private List<CouponGroupVO> buildCouponGroups(List<PromotionResponse.PromotionItem> dataList,
BigDecimal calcPrice, BigDecimal extraRate) {
// 按 couponName 前缀分组(模拟按活动分组)
Map<String, List<PromotionResponse.PromotionItem>> groupMap =
dataList.stream().collect(Collectors.groupingBy(PromotionResponse.PromotionItem::getCouponName));
List<CouponGroupVO> result = new ArrayList<>();
for (Map.Entry<String, List<PromotionResponse.PromotionItem>> entry : groupMap.entrySet()) {
List<PromotionResponse.PromotionItem> items = entry.getValue();
CouponGroupVO group = new CouponGroupVO();
group.setGroupName(entry.getKey());
group.setCouponUrl(items.get(0).getCouponUrl());
group.setUrlType(items.get(0).getUrlType());
// 计算该组总优惠
BigDecimal groupTotal = BigDecimal.ZERO;
for (PromotionResponse.PromotionItem item : items) {
groupTotal = groupTotal.add(calculateDiscount(item, calcPrice, extraRate));
}
group.setGroupDiscount(groupTotal);
// 单优惠时 details 为空,多优惠时展示明细
if (items.size() == 1) {
group.setDetails(Collections.emptyList());
} else {
List<CouponDetailVO> details = items.stream().map(item -> {
CouponDetailVO detail = new CouponDetailVO();
detail.setCouponId(item.getCouponId());
detail.setCouponName(item.getCouponName());
detail.setDiscount(calculateDiscount(item, calcPrice, extraRate));
detail.setCouponUrl(item.getCouponUrl());
detail.setUrlType(item.getUrlType());
return detail;
}).collect(Collectors.toList());
group.setDetails(details);
}
result.add(group);
}
return result;
}
// ===== 异常隔离的辅助操作 =====
private void saveQueryLog(ProductDiscountQueryForm form) {
try {
DiscountQueryLogEntity entity = new DiscountQueryLogEntity();
entity.setUserId(form.getUserId());
entity.setProductId(form.getProductId());
entity.setUserLevel(form.getUserLevel());
entity.setOriginalPrice(form.getOriginalPrice());
queryLogMapper.insertOrUpdate(entity);
} catch (Exception e) {
log.error("保存查询日志失败,不影响主流程: {}", e.getMessage());
}
}
}
3.4 Controller
java
@Api(tags = "商品优惠")
@RestController
@RequestMapping("/discount")
public class ProductDiscountController {
private final ProductDiscountServiceImpl discountService;
public ProductDiscountController(ProductDiscountServiceImpl discountService) {
this.discountService = discountService;
}
@ApiOperation("查询商品可享优惠")
@PostMapping("/query")
public R<ProductDiscountResultVO> queryDiscount(@RequestBody @Validated ProductDiscountQueryForm form) {
return new R<>(discountService.queryProductDiscount(form));
}
@ApiOperation("获取优惠券预览URL")
@PostMapping("/previewUrl")
public R<String> getPreviewUrl(@RequestBody @Validated CouponPreviewForm form) {
return new R<>(discountService.getCouponPreviewUrl(form));
}
}
四、架构流程图
┌─────────────────────────────────────────────────────┐
│ 接口A:查询优惠 │
├─────────────────────────────────────────────────────┤
│ │
│ 1. saveQueryLog() ← 异常隔离,不影响主流程 │
│ 2. determineCalcPrice() ← 前置校验快速失败 │
│ 3. buildPromotionRequest()← DTO分层隔离 │
│ 4. promotionFeign.query() ← Feign远程调用+签名认证 │
│ 5. filterByBlacklist() ← 黑名单过滤 │
│ 6. calculateDiscount() ← 条件策略计算 │
│ 7. buildCouponGroups() ← 分组聚合 │
│ 8. 返回 { isShow, total, groups(含rawUrl+urlType) } │
│ │
└─────────────────────────────────────────────────────┘
↓
前端展示优惠列表
↓
用户点击"查看详情"
↓
┌─────────────────────────────────────────────────────┐
│ 接口B:获取预览URL │
├─────────────────────────────────────────────────────┤
│ │
│ 传入 { urlType, rawParam } │
│ ├── INTERNAL → MD5签名拼接(本地计算) │
│ └── EXTERNAL → 调用外部接口获取(带签名头) │
│ 返回实时生成的带时效URL │
│ │
└─────────────────────────────────────────────────────┘
五、设计模式总结
| 模式 | 本示例中的体现 | 核心思想 |
|---|---|---|
| DTO 分层 | Form → QueryDto → Response → VO | 各层数据结构独立,变化互不影响 |
| 策略模式(简化版) | VIP/普通用不同价格,可叠加/不可叠加用不同公式 | 用条件分支替代 if-else 嵌套 |
| 接口拆分 | 查询接口返回原始参数,预览接口实时生成URL | 解决带时效资源的过期问题 |
| 模板方法 | 主方法是固定流程骨架,每步调用不同私有方法 | 流程清晰,细节封装 |
| 快速失败 | 价格为空直接返回 isShow=false | 减少无效计算和远程调用 |
| 防腐层 | Feign 接口 + DTO 隔离第三方系统 | 第三方变化不影响业务逻辑 |
| 观察者(弱化版) | saveQueryLog 记录行为不影响主流程 | 主流程和辅助流程解耦 |
六、适用场景
这套架构适用于所有"调用外部系统获取数据 → 本地加工计算 → 分阶段返回给前端"的场景:
- 电商:商品价格计算(促销系统 + 会员折扣 + 优惠券)
- 金融:贷款利率试算(征信系统 + 风控规则 + 产品配置)
- 物流:运费计算(多家物流公司接口 + 距离计算 + 优惠规则)
- 保险:保费试算(精算系统 + 客户画像 + 核保规则)
核心架构:外部数据获取 → 规则过滤 → 条件计算 → 分组聚合 → 分接口返回。