Spring Boot 微服务中“调用第三方接口 → 数据加工 → 分接口返回“的完整架构实践

Spring Boot 微服务中"调用第三方接口 → 数据加工 → 分接口返回"的完整架构实践


一、技术全景

核心技术和设计模式:

技术/模式 在接口中的应用
DTO 分层隔离 前端 Form、第三方请求 DTO、返回 VO 三层分离
条件策略计算 根据客户类型+字段组合决定计算公式
接口拆分(读写分离思想) 数据查询和 URL 获取拆成两个接口(解决时效性)
黑名单过滤 数据库配置表动态过滤返回数据
异常隔离 辅助操作(入库)不影响主流程
枚举映射 前端编码和第三方编码的转换适配
签名认证 MD5 + 时间戳保护接口调用安全
分组聚合 扁平数据按维度分组转嵌套结构
前置校验快速失败 关键参数缺失时直接返回而非继续执行

二、核心设计模式详解

1. 接口拆分 ------ 解决 URL 时效性问题

问题: 第三方返回的预览 URL 带签名有效期,如果在查询接口中就生成最终 URL,用户过一段时间再点击查看时 URL 可能已过期。

解决方案: 拆成两个接口:

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 + "&timestamp=" + 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 记录行为不影响主流程 主流程和辅助流程解耦

六、适用场景

这套架构适用于所有"调用外部系统获取数据 → 本地加工计算 → 分阶段返回给前端"的场景:

  • 电商:商品价格计算(促销系统 + 会员折扣 + 优惠券)
  • 金融:贷款利率试算(征信系统 + 风控规则 + 产品配置)
  • 物流:运费计算(多家物流公司接口 + 距离计算 + 优惠规则)
  • 保险:保费试算(精算系统 + 客户画像 + 核保规则)

核心架构:外部数据获取 → 规则过滤 → 条件计算 → 分组聚合 → 分接口返回

相关推荐
阿萨德528号1 小时前
[特殊字符] CI/CD 流水线搭建实战指南:Spring Boot + GitHub Actions → 服务器自动部署
spring boot·ci/cd·github
CodeStats1 小时前
JavaWeb 造轮者视角:Spring Boot 启动核心思想与完整链路解析
java·spring boot·后端
Java识堂10 小时前
多级负载均衡架构
运维·架构·负载均衡
阿狸猿11 小时前
论软件可靠性设计与应用
架构
心之伊始11 小时前
LangChain4j RAG 实战:Java 后端如何把本地文档接入 Embedding 检索链路
java·架构·源码分析·csdn
真实的菜12 小时前
微服务注册配置中心终极选型:2026指南
微服务·云原生·架构
HavenlonLabs14 小时前
硬件 + SaaS 产品的工程化路径:从系统架构、PCB 设计到工程样机
网络·安全·架构·系统架构·安全架构
Sam_Deep_Thinking15 小时前
Spring Boot 的启动原理是什么?
java·spring boot·后端
SamDeepThinking15 小时前
我们当年是如何真实落地BFF的?
java·后端·架构