第三方服务集成(物流 / 地图 / 短信)
1. 物流对接
1.1 快递鸟 / 快递100 电商物流查询接口对接
1.1.1 即时查询接口
快递鸟即时查询
java
public class KdniaoTracker {
private static final String API_URL = "https://api.kdniao.com/Ebusiness/EbusinessOrderHandle.aspx";
private static final String REQ_TYPE = "1002"; // 即时查询
public TrackResponse query(String expCode, String expNo, KdniaoConfig config) {
String requestData = buildRequestData(expCode, expNo);
String dataSign = encryptSign(requestData, config.getApiKey());
Map<String, String> params = new HashMap<>();
params.put("RequestData", requestData);
params.put("EBusinessID", config.getEBusinessId());
params.put("RequestType", REQ_TYPE);
params.put("DataSign", dataSign);
params.put("DataType", "2");
String response = HttpClient.post(API_URL, params);
return JSON.parseObject(response, TrackResponse.class);
}
private String buildRequestData(String expCode, String expNo) {
Map<String, Object> data = new HashMap<>();
data.put("OrderCode", "");
data.put("ShipperCode", expCode);
data.put("LogisticCode", expNo);
return JSON.toJSONString(data);
}
private String encryptSign(String data, String apiKey) {
String base64 = Base64.encode(MD5.digest(data + apiKey));
return java.net.URLEncoder.encode(base64, "UTF-8");
}
}快递100即时查询
java
public class Kuaidi100Tracker {
private static final String API_URL = "https://api.kuaidi100.com/query";
public QueryResponse query(String expCode, String expNo, Kuaidi100Config config) {
String param = String.format(
"{\"com\":\"%s\",\"num\":\"%s\"}", expCode, expNo
);
String sign = MD5.md5(param + config.getKey() + config.getCustomer());
Map<String, String> params = new HashMap<>();
params.put("customer", config.getCustomer());
params.put("sign", sign);
params.put("param", param);
String response = HttpClient.get(API_URL, params);
return JSON.parseObject(response, QueryResponse.class);
}
}1.1.2 订阅推送(物流状态变更回调)
java
public class LogisticsWebhookService {
/**
* 快递鸟订阅接口
*/
public SubscribeResponse subscribe(KdniaoConfig config, SubscribeRequest req) {
// RequestType = 1008 为订阅接口
String requestData = JSON.toJSONString(Map.of(
"ShipperCode", req.getExpCode(),
"LogisticCode", req.getExpNo(),
"CallBack", req.getCallbackUrl()
));
// ... 签名与发送逻辑同即时查询
return doRequest(requestData, "1008", config);
}
/**
* 快递鸟回调接收
*/
public TrackUpdate handleCallback(String requestData, String dataSign, String eBusinessId) {
// 1. 验证签名
if (!verifySign(requestData, dataSign, config.getApiKey())) {
throw new SecurityException("Invalid signature");
}
// 2. 解析轨迹数据
PushData pushData = JSON.parseObject(requestData, PushData.class);
// 3. 更新物流状态
logisticsService.updateTrack(pushData.getShipperCode(),
pushData.getLogisticCode(),
pushData.getTraces());
return TrackUpdate.success();
}
/**
* 快递100 回调接收
*/
public String handleKuaidi100Callback(CallbackBody body) {
// 快递100 通过 POST JSON 推送
// 验证 sign(body.param 的 MD5 + key + body.lastResult)
String expectedSign = MD5.md5(body.getParam() + config.getKey() + body.getLastResult());
if (!expectedSign.equals(body.getSign())) {
return "fail";
}
// 更新轨迹
logisticsService.updateTrack(body.getCom(), body.getNu(), body.getLastResult());
return "ok"; // 必须返回 "ok",否则快递100会重试
}
}1.2 电子面单打印
1.2.1 面单模板管理
java
public class ElectronicWaybillService {
/**
* 快递鸟电子面单接口
* RequestType = 1007
*/
public WaybillResponse createWaybill(WaybillRequest request, KdniaoConfig config) {
Map<String, Object> data = new HashMap<>();
data.put("OrderCode", request.getOrderCode());
data.put("ShipperCode", request.getExpCode());
data.put("PayType", request.getPayType());
data.put("ExpType", request.getExpType());
data.put("Cost", request.getCost());
data.put("Receiver", Map.of(
"Company", request.getReceiver().getCompany(),
"Name", request.getReceiver().getName(),
"Tel", request.getReceiver().getTel(),
"Address", request.getReceiver().getAddress()
));
data.put("Sender", Map.of(
"Company", request.getSender().getCompany(),
"Name", request.getSender().getName(),
"Tel", request.getSender().getTel(),
"Address", request.getSender().getAddress()
));
data.put("IsReturnPrintTemplate", "1"); // 返回打印模板
String response = doRequest(JSON.toJSONString(data), "1007", config);
WaybillResponse waybill = JSON.parseObject(response, WaybillResponse.class);
// 保存面单模板 HTML
waybill.setPrintTemplate(waybill.getPrintTemplate());
return waybill;
}
}1.2.2 打印机配置与面单打印
常见的对接方式包括:
- 模板渲染 + 浏览器打印:后端返回面单 HTML 模板,前端通过
window.print()调用浏览器打印 - LODOP 控件打印:适用于 Windows 环境,调用 LODOP Web 打印控件渲染面单
- 云打印:快递鸟/快递100 提供的云打印服务,对接蓝牙/热敏打印机
java
@Service
public class WaybillPrintService {
public void printWaybill(String orderId) {
WaybillVO waybill = waybillService.getWaybill(orderId);
String template = waybill.getPrintTemplate();
// 渲染模板中的变量
String html = renderTemplate(template, waybill);
// 发送到打印队列(Redis List 做异步缓冲)
printQueue.add(new PrintTask(orderId, html, waybill.getPrinterDevice()));
}
/**
* 云打印(快递鸟/快递100 云打印服务)
* 无需客户端渲染,直接发送打印机设备码
*/
public void cloudPrint(String deviceCode, String waybillData) {
// POST 到云打印网关
HttpClient.post("https://api.kdniao.com/print/cloud", Map.of(
"DeviceCode", deviceCode,
"WaybillData", waybillData
));
}
}1.3 物流轨迹跟踪(Webhook 回调)
| 阶段 | 事件类型 | 处理逻辑 |
|---|---|---|
| 已揽收 | collected | 更新订单状态为"已发货",通知买家 |
| 运输中 | in_transit | 更新轨迹节点,前端地图展示 |
| 派送中 | delivering | 发送派送提醒短信/推送 |
| 已签收 | signed | 自动确认收货(若满足条件) |
| 异常 | exception | 标记异常工单,人工介入 |
java
@Component
public class TrackWebhookHandler {
@PostMapping("/api/logistics/callback/kdniao")
public ResponseEntity<String> handleKdniao(@RequestBody String body,
HttpServletRequest request) {
// 验签
KdniaoCallback callback = parseAndVerify(body, request);
// 根据最新轨迹节点分发处理
String acceptStation = callback.getLatestTrace().getAcceptStation();
if (acceptStation.contains("已签收")) {
orderService.autoConfirm(callback.getOrderCode());
notificationService.notifySeller(callback.getOrderCode(), "signed");
} else if (acceptStation.contains("派送")) {
notificationService.notifyBuyer(callback.getOrderCode(), "delivering");
}
return ResponseEntity.ok("{\"EBusinessID\":\"...\",\"UpdateTime\":\"...\"}");
}
}1.4 菜鸟物流云接入流程
菜鸟物流云(Cainiao Logistics Cloud)提供标准化的电子面单、轨迹查询、运单配送等能力。
接入步骤:
入驻与认证
- 在 菜鸟物流云开放平台 注册开发者账号
- 创建应用,获取 AppKey / AppSecret
- 签署物流服务协议
获取授权
- 商家授权(OAuth2.0):获取商家授权
access_token - 通过
taobao.logistics.waybill.get获取电子面单
- 商家授权(OAuth2.0):获取商家授权
电子面单打印
- 调用
taobao.logistics.waybill.get获取面单数据 - 使用菜鸟提供的打印组件 SDK(C# / Java)进行面单打印
- 调用
轨迹推送
- 订阅
cainiao.logistics.trace.push消息 - 通过 RocketMQ 消费轨迹变更消息
- 订阅
java
// 菜鸟 SDK 依赖(pom.xml)
// <dependency>
// <groupId>com.taobao</groupId>
// <artifactId>taobao-sdk-java</artifactId>
// <version>20231201</version>
// </dependency>
public class CainiaoWaybillService {
private static final String API_URL = "https://gw.api.taobao.com/router/rest";
public WaybillApplyResponse applyWaybill(WaybillApplyRequest req, String accessToken) {
TaobaoClient client = new DefaultTaobaoClient(API_URL, appKey, appSecret);
LogisticsWaybillGetRequest request = new LogisticsWaybillGetRequest();
request.setTradeId(req.getTradeId());
request.setCpCode(req.getCpCode()); // 快递公司编码
request.setSender(req.getSender());
request.setReceiver(req.getReceiver());
LogisticsWaybillGetResponse response = client.execute(request, accessToken);
if (!response.isSuccess()) {
throw new BusinessException("面单申请失败: " + response.getMsg());
}
return response.getWaybillApplyNewInfo();
}
}2. 地图服务
2.1 高德地图 / 百度地图 Web API
2.1.1 地理编码 / 逆地理编码
高德地图
java
@Service
public class AmapGeocodeService {
private static final String GEOCODE_URL = "https://restapi.amap.com/v3/geocode/geo";
private static final String REGEO_URL = "https://restapi.amap.com/v3/geocode/regeo";
/**
* 地理编码:地址 → 经纬度
*/
public GeoPoint geocode(String address, String city, String apiKey) {
Map<String, String> params = new HashMap<>();
params.put("key", apiKey);
params.put("address", address);
params.put("city", city);
String response = HttpClient.get(GEOCODE_URL, params);
GeoResponse geo = JSON.parseObject(response, GeoResponse.class);
if (!"1".equals(geo.getStatus())) {
throw new BusinessException("地理编码失败: " + geo.getInfo());
}
// 返回格式 "116.397428,39.90923"
String[] loc = geo.getGeocodes().get(0).getLocation().split(",");
return new GeoPoint(Double.parseDouble(loc[1]), Double.parseDouble(loc[0]));
}
/**
* 逆地理编码:经纬度 → 地址
*/
public AddressInfo reverseGeocode(double lng, double lat, String apiKey) {
Map<String, String> params = new HashMap<>();
params.put("key", apiKey);
params.put("location", lng + "," + lat);
params.put("extensions", "all");
String response = HttpClient.get(REGEO_URL, params);
RegeoResponse regeo = JSON.parseObject(response, RegeoResponse.class);
return regeo.getRegeocode().getAddressComponent();
}
}百度地图
java
@Service
public class BaiduGeocodeService {
private static final String GEOCODING_URL = "https://api.map.baidu.com/geocoding/v3";
private static final String REGEO_URL = "https://api.map.baidu.com/reverse_geocoding/v3";
public BaiduGeoPoint geocode(String address, String city, String ak) {
Map<String, String> params = new HashMap<>();
params.put("ak", ak);
params.put("address", address);
params.put("city", city);
params.put("output", "json");
String response = HttpClient.get(GEOCODING_URL, params);
BaiduGeoResponse geo = JSON.parseObject(response, BaiduGeoResponse.class);
return geo.getResult().getLocation(); // {lat, lng}
}
}2.1.2 POI 搜索
java
/**
* 高德地图 POI 搜索
*/
public class AmapPoiSearchService {
private static final String POI_URL = "https://restapi.amap.com/v3/place/text";
public PoiSearchResult search(PoiQuery query, String apiKey) {
Map<String, String> params = new HashMap<>();
params.put("key", apiKey);
params.put("keywords", query.getKeywords());
params.put("types", query.getTypes()); // 类别,如 "060000" 为餐饮
params.put("city", query.getCity());
params.put("offset", String.valueOf(query.getPageSize()));
params.put("page", String.valueOf(query.getPageNum()));
params.put("extensions", "all");
String response = HttpClient.get(POI_URL, params);
return JSON.parseObject(response, PoiSearchResult.class);
}
}2.1.3 路径规划 / 驾车距离矩阵
高德驾车路径规划
java
public class AmapDirectionService {
private static final String DIRECTION_URL = "https://restapi.amap.com/v3/direction/driving";
public DrivingResult drivingPlan(GeoPoint origin, GeoPoint destination, String apiKey) {
Map<String, String> params = new HashMap<>();
params.put("key", apiKey);
params.put("origin", origin.getLng() + "," + origin.getLat());
params.put("destination", destination.getLng() + "," + destination.getLat());
params.put("strategy", "0"); // 0-速度优先
params.put("extensions", "all"); // 返回详细路径
String response = HttpClient.get(DIRECTION_URL, params);
return JSON.parseObject(response, DrivingResult.class);
}
}
/**
* 高德驾车距离矩阵(批量算路)
* 用于配送调度、骑手派单等场景
*/
public class AmapDistanceMatrixService {
private static final String MATRIX_URL = "https://restapi.amap.com/v3/distance";
public DistanceMatrixResult matrix(DistanceMatrixRequest request, String apiKey) {
// type: 0-直线距离, 1-驾车导航距离
String origins = request.getOrigins().stream()
.map(p -> p.getLng() + "," + p.getLat())
.collect(Collectors.joining(";"));
String dests = request.getDestinations().stream()
.map(p -> p.getLng() + "," + p.getLat())
.collect(Collectors.joining(";"));
Map<String, String> params = new HashMap<>();
params.put("key", apiKey);
params.put("type", "1");
params.put("strategy", "0");
params.put("origins", origins);
params.put("destination", dests);
String response = HttpClient.get(MATRIX_URL, params);
return JSON.parseObject(response, DistanceMatrixResult.class);
}
}2.2 地图 SDK 前端集成
2.2.1 高德地图 JS API 基础示例
html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, width=device-width" />
<title>高德地图示例</title>
<style>
#container { width: 100%; height: 500px; }
</style>
</head>
<body>
<div id="container"></div>
<script src="https://webapi.amap.com/maps?v=2.0&key=YOUR_KEY"></script>
<script>
// 初始化地图
const map = new AMap.Map('container', {
zoom: 13,
center: [116.397428, 39.90923],
viewMode: '2D'
});
// 添加标记
const marker = new AMap.Marker({
position: [116.397428, 39.90923],
title: '北京'
});
map.add(marker);
// 添加信息窗体
const info = new AMap.InfoWindow({
content: '<div><h4>北京</h4><p>中国首都</p></div>'
});
marker.on('click', () => info.open(map, marker.getPosition()));
</script>
</body>
</html>2.2.2 Vue 组件封装示例
vue
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
const props = defineProps({
center: { type: Array, default: () => [116.397428, 39.90923] },
zoom: { type: Number, default: 13 },
markers: { type: Array, default: () => [] },
mapType: { type: String, default: 'amap' } // amap | baidu
});
const mapContainer = ref(null);
let map = null;
let markerInstances = [];
function initAmap() {
return new Promise((resolve) => {
// 动态加载高德地图 SDK
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${import.meta.env.VITE_AMAP_KEY}&callback=initMap`;
script.async = true;
window.initMap = () => {
map = new AMap.Map(mapContainer.value, {
zoom: props.zoom,
center: props.center,
viewMode: '2D'
});
resolve(map);
};
document.head.appendChild(script);
});
}
function renderMarkers() {
if (!map) return;
markerInstances.forEach(m => map.remove(m));
markerInstances = props.markers.map(m => {
const marker = new AMap.Marker({
position: m.position,
title: m.title,
content: m.content
});
map.add(marker);
return marker;
});
}
onMounted(async () => {
await initAmap();
renderMarkers();
});
onUnmounted(() => {
map?.destroy();
});
</script>2.3 坐标转换(GCJ-02 / BD-09 / WGS-84)
中国地区使用的坐标系:
| 坐标系 | 说明 | 适用场景 |
|---|---|---|
| WGS-84 | 国际标准 GPS 坐标 | GPS 设备、Google 地图(国外) |
| GCJ-02 | 国测局坐标(火星坐标) | 高德地图、腾讯地图 |
| BD-09 | 百度坐标(火星坐标二次加密) | 百度地图 |
java
public class CoordinateConverter {
private static final double PI = Math.PI;
private static final double X_PI = PI * 3000.0 / 180.0;
private static final double A = 6378245.0; // 长半轴
private static final double EE = 0.00669342162296594323; // 偏心率平方
/**
* WGS-84 → GCJ-02(GPS 转火星坐标)
*/
public static GeoPoint wgs84ToGcj02(double lat, double lng) {
double dLat = transformLat(lng - 105.0, lat - 35.0);
double dLng = transformLng(lng - 105.0, lat - 35.0);
double radLat = lat / 180.0 * PI;
double magic = Math.sin(radLat);
magic = 1 - EE * magic * magic;
double sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((A * (1 - EE)) / (magic * sqrtMagic) * PI);
dLng = (dLng * 180.0) / (A / sqrtMagic * Math.cos(radLat) * PI);
return new GeoPoint(lat + dLat, lng + dLng);
}
/**
* GCJ-02 → WGS-84(火星坐标转 GPS)
*/
public static GeoPoint gcj02ToWgs84(double lat, double lng) {
GeoPoint gps = wgs84ToGcj02(lat, lng);
return new GeoPoint(2 * lat - gps.getLat(), 2 * lng - gps.getLng());
}
/**
* GCJ-02 → BD-09(火星坐标转百度坐标)
*/
public static GeoPoint gcj02ToBd09(double lat, double lng) {
double z = Math.sqrt(lng * lng + lat * lat) + 0.00002 * Math.sin(lat * X_PI);
double theta = Math.atan2(lat, lng) + 0.000003 * Math.cos(lng * X_PI);
return new GeoPoint(z * Math.sin(theta) + 0.006, z * Math.cos(theta) + 0.0065);
}
/**
* BD-09 → GCJ-02(百度坐标转火星坐标)
*/
public static GeoPoint bd09ToGcj02(double lat, double lng) {
double x = lng - 0.0065, y = lat - 0.006;
double z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI);
double theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI);
return new GeoPoint(z * Math.sin(theta), z * Math.cos(theta));
}
private static double transformLat(double x, double y) {
double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320.0 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
private static double transformLng(double x, double y) {
double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
}2.4 地理围栏(Geofencing)业务实现
java
@Service
public class GeofenceService {
/**
* 判断点是否在圆形围栏内
*/
public boolean inCircleFence(GeoPoint center, double radiusKm, GeoPoint target) {
double distance = calculateDistance(center, target);
return distance <= radiusKm;
}
/**
* 判断点是否在多边形围栏内(射线法)
*/
public boolean inPolygonFence(List<GeoPoint> polygon, GeoPoint point) {
int n = polygon.size();
boolean inside = false;
for (int i = 0, j = n - 1; i < n; j = i++) {
GeoPoint pi = polygon.get(i);
GeoPoint pj = polygon.get(j);
if ((pi.getLat() > point.getLat()) != (pj.getLat() > point.getLat()) &&
point.getLng() < (pj.getLng() - pi.getLng()) *
(point.getLat() - pi.getLat()) / (pj.getLat() - pi.getLat()) + pi.getLng()) {
inside = !inside;
}
}
return inside;
}
/**
* Haversine 公式计算两点间距离(km)
*/
public double calculateDistance(GeoPoint p1, GeoPoint p2) {
double dLat = Math.toRadians(p2.getLat() - p1.getLat());
double dLng = Math.toRadians(p2.getLng() - p1.getLng());
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(Math.toRadians(p1.getLat()))
* Math.cos(Math.toRadians(p2.getLat()))
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
return 6371.0 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* 业务场景:骑手到店提醒
*/
@EventListener
public void onDriverLocationUpdate(DriverLocationEvent event) {
GeoPoint driverPos = event.getLocation();
GeoPoint shopPos = shopService.getPosition(event.getShopId());
if (inCircleFence(shopPos, 0.5, driverPos)) {
// 骑手进入 500 米围栏,触发到店通知
notificationService.notifyShop(event.getShopId(),
"driver_arriving", event.getDriverId());
}
}
}3. 短信服务
3.1 阿里云短信 / 腾讯云短信 SDK 集成
3.1.1 阿里云短信
xml
<!-- pom.xml -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
<version>2.0.24</version>
</dependency>java
@Service
public class AliyunSmsService {
@Autowired
private AliyunSmsConfig config;
private com.aliyun.dysmsapi20170525.Client createClient() {
com.aliyun.teaopenapi.models.Config teaConfig = new com.aliyun.teaopenapi.models.Config()
.setAccessKeyId(config.getAccessKeyId())
.setAccessKeySecret(config.getAccessKeySecret());
teaConfig.setEndpoint("dysmsapi.aliyuncs.com");
return new com.aliyun.dysmsapi20170525.Client(teaConfig);
}
/**
* 发送单条短信
*/
public SendSmsResponse sendSms(SmsRequest request) {
com.aliyun.dysmsapi20170525.Client client = createClient();
com.aliyun.dysmsapi20170525.models.SendSmsRequest req =
new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
.setPhoneNumbers(request.getPhone())
.setSignName(config.getSignName())
.setTemplateCode(request.getTemplateCode())
.setTemplateParam(JSON.toJSONString(request.getTemplateParams()));
try {
SendSmsResponse resp = client.sendSms(req);
if (!"OK".equals(resp.getBody().getCode())) {
throw new SmsException("发送失败: " + resp.getBody().getMessage());
}
return resp;
} catch (TeaException e) {
throw new SmsException("阿里云短信异常: " + e.getMessage());
}
}
/**
* 批量发送短信
*/
public SendBatchSmsResponse sendBatchSms(List<SmsRequest> requests) {
com.aliyun.dysmsapi20170525.Client client = createClient();
SendBatchSmsRequest req = new SendBatchSmsRequest()
.setPhoneNumberJson(JSON.toJSONString(
requests.stream().map(SmsRequest::getPhone).collect(Collectors.toList())))
.setSignNameJson(JSON.toJSONString(
requests.stream().map(r -> config.getSignName()).collect(Collectors.toList())))
.setTemplateCode(requests.get(0).getTemplateCode())
.setTemplateParamJson(JSON.toJSONString(
requests.stream().map(r -> JSON.toJSONString(r.getTemplateParams()))
.collect(Collectors.toList())));
return client.sendBatchSms(req);
}
}3.1.2 腾讯云短信
xml
<!-- pom.xml -->
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-sms</artifactId>
<version>3.1.100</version>
</dependency>java
@Service
public class TencentSmsService {
@Autowired
private TencentSmsConfig config;
private SmsClient createClient() {
Credential cred = new Credential(config.getSecretId(), config.getSecretKey());
ClientProfile profile = new ClientProfile();
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("sms.tencentcloudapi.com");
profile.setHttpProfile(httpProfile);
return new SmsClient(cred, "ap-guangzhou", profile);
}
/**
* 发送单条短信
*/
public SendSmsResponse sendSms(SmsRequest request) {
SmsClient client = createClient();
SendSmsRequest req = new SendSmsRequest();
req.setPhoneNumberSet(new String[]{"+86" + request.getPhone()});
req.setSignName(config.getSignName());
req.setTemplateId(request.getTemplateCode());
req.setTemplateParamSet(request.getTemplateParams().values().toArray(new String[0]));
req.setSmsSdkAppId(config.getAppId());
try {
SendSmsResponse resp = client.SendSms(req);
SendSmsResponse.SendStatus status = resp.getSendStatusSet()[0];
if (!"Ok".equals(status.getCode())) {
throw new SmsException("发送失败: " + status.getMessage());
}
return resp;
} catch (TencentCloudSDKException e) {
throw new SmsException("腾讯云短信异常: " + e.getMessage());
}
}
/**
* 批量发送
*/
public SendSmsResponse sendBatchSms(SmsBatchRequest request) {
SmsClient client = createClient();
SendSmsRequest req = new SendSmsRequest();
req.setPhoneNumberSet(
request.getPhones().stream().map(p -> "+86" + p).toArray(String[]::new));
req.setSignName(config.getSignName());
req.setTemplateId(request.getTemplateCode());
req.setTemplateParamSet(request.getTemplateParams().values().toArray(new String[0]));
req.setSmsSdkAppId(config.getAppId());
return client.SendSms(req);
}
}3.2 短信模板管理
java
/**
* 短信模板配置
*/
@Component
public class SmsTemplateConfig {
/**
* 模板映射表(业务类型 → 模板 code)
* key: 业务场景枚举
* value: {templateCode, params顺序}
*/
private static final Map<SmsBizType, TemplateDefinition> TEMPLATES = new HashMap<>();
static {
// 阿里云模板
TEMPLATES.put(SmsBizType.VERIFY_CODE, new TemplateDefinition(
"SMS_123456789", // templateCode
List.of("code", "minute"), // 参数名
SmsProvider.ALIYUN
));
TEMPLATES.put(SmsBizType.LOGIN_ALERT, new TemplateDefinition(
"SMS_987654321",
List.of("time", "ip"),
SmsProvider.ALIYUN
));
// 腾讯云模板
TEMPLATES.put(SmsBizType.ORDER_NOTIFY, new TemplateDefinition(
"1876543", // templateId
List.of("orderId", "status"),
SmsProvider.TENCENT
));
}
public TemplateDefinition getTemplate(SmsBizType bizType) {
TemplateDefinition def = TEMPLATES.get(bizType);
if (def == null) {
throw new BusinessException("未配置短信模板: " + bizType);
}
return def;
}
}
public enum SmsBizType {
VERIFY_CODE, // 验证码
LOGIN_ALERT, // 登录提醒
ORDER_NOTIFY, // 订单通知
DELIVERY_NOTIFY, // 发货通知
PAYMENT_SUCCESS, // 支付成功
PROMOTION // 营销推广
}
@Data
public class TemplateDefinition {
private String templateCode;
private List<String> paramKeys;
private SmsProvider provider;
}3.3 验证码逻辑(生成 → 发送 → 校验 → 失效)
java
@Service
public class VerifyCodeService {
private static final long CODE_TTL_SECONDS = 300; // 5 分钟有效
private static final int CODE_LENGTH = 6;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private SmsStrategyFactory smsFactory;
/**
* 发送验证码
*/
public void sendCode(String phone, SmsBizType bizType) {
String codeKey = buildCodeKey(phone, bizType);
// 1. 频率校验(60秒内不能重复发送)
String lastSentKey = buildRateLimitKey(phone, bizType);
String lastSent = redisTemplate.opsForValue().get(lastSentKey);
if (lastSent != null) {
long remainingTtl = redisTemplate.getExpire(lastSentKey);
throw new BusinessException("请 " + remainingTtl + " 秒后再试");
}
// 2. 生成验证码
String code = generateCode();
// 3. 存储验证码
redisTemplate.opsForValue().set(codeKey, code, CODE_TTL_SECONDS, TimeUnit.SECONDS);
// 4. 记录发送频率(60 秒限制)
redisTemplate.opsForValue().set(lastSentKey, "1", 60, TimeUnit.SECONDS);
// 5. 发送短信
SmsRequest smsRequest = new SmsRequest();
smsRequest.setPhone(phone);
smsRequest.setBizType(bizType);
smsRequest.setTemplateParam(Map.of("code", code, "minute", "5"));
smsFactory.getStrategy(SmsProvider.ALIYUN).sendSms(smsRequest);
}
/**
* 校验验证码
*/
public boolean verifyCode(String phone, String code, SmsBizType bizType) {
String codeKey = buildCodeKey(phone, bizType);
String storedCode = redisTemplate.opsForValue().get(codeKey);
if (storedCode == null) {
return false; // 已过期或未发送
}
// 校验后立即失效(一次性)
if (storedCode.equals(code)) {
redisTemplate.delete(codeKey);
return true;
}
return false;
}
private String generateCode() {
Random random = new Random();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < CODE_LENGTH; i++) {
sb.append(random.nextInt(10));
}
return sb.toString();
}
private String buildCodeKey(String phone, SmsBizType bizType) {
return "sms:code:" + bizType.name() + ":" + phone;
}
private String buildRateLimitKey(String phone, SmsBizType bizType) {
return "sms:ratelimit:" + bizType.name() + ":" + phone;
}
}3.4 发送频率控制
java
@Component
public class SmsRateLimiter {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 频率控制:同一手机号 60 秒内仅允许发送 1 条
*/
public boolean allowSend(String phone, SmsBizType bizType) {
String key = "sms:ratelimit:" + bizType.name() + ":" + phone;
Boolean exists = redisTemplate.hasKey(key);
if (Boolean.TRUE.equals(exists)) {
return false;
}
redisTemplate.opsForValue().set(key, "1", 60, TimeUnit.SECONDS);
return true;
}
/**
* 每日限额:同一手机号每天最多 N 条
*/
public boolean checkDailyLimit(String phone, int maxPerDay) {
String key = "sms:dailylimit:" + LocalDate.now() + ":" + phone;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.DAYS);
}
return count <= maxPerDay;
}
/**
* 全局频率:全平台每秒最多 M 条
*/
public boolean checkGlobalRate() {
// 使用 Redis 滑动窗口或令牌桶实现
String key = "sms:global:qps:" + System.currentTimeMillis() / 1000;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 2, TimeUnit.SECONDS);
}
return count <= 50; // 每秒最多 50 条
}
}3.5 签名审核注意事项
| 注意事项 | 说明 |
|---|---|
| 签名来源 | 企业/个人认证后提交,需提供营业执照、授权书等材料 |
| 签名规范 | 2-8 个字符,须与公司名称/品牌/网站关联,不支持纯数字或字母 |
| 审核周期 | 通常 1-3 个工作日,审核失败需按要求修改后重新提交 |
| 签名类型 | 验证码类使用"某某科技",通知类使用"某某通知",营销类需额外申请 |
| 备用签名 | 建议准备 2-3 个签名,防止某个签名审核不通过或被驳回 |
| 签名与模板匹配 | 发送时 SignName 必须与 TemplateCode 所属签名一致 |
| 签名使用规范 | 禁止发送与签名无关的内容,违反将封禁账号 |
| 运营商要求 | 三大运营商(移动/联通/电信)对短信内容有严格审核,涉及金融、贷款、赌博等内容会被拦截 |
4. 统一对接抽象
4.1 三方服务策略模式设计
java
/**
* 短信服务策略接口
*/
public interface SmsStrategy {
SmsProvider getProvider();
SendSmsResponse sendSms(SmsRequest request);
SendBatchSmsResponse sendBatchSms(List<SmsRequest> requests);
default <T> T executeWithRetry(Supplier<T> supplier) {
return RetryHelper.doWithRetry(supplier, 3, 1000, 2.0);
}
}
/**
* 物流查询策略接口
*/
public interface LogisticsQueryStrategy {
LogisticsProvider getProvider();
TrackResponse query(String expCode, String expNo);
SubscribeResponse subscribe(String expCode, String expNo, String callbackUrl);
}
/**
* 地图服务策略接口
*/
public interface MapServiceStrategy {
MapProvider getProvider();
GeoPoint geocode(String address);
AddressInfo reverseGeocode(double lng, double lat);
DistanceMatrixResult distanceMatrix(DistanceMatrixRequest request);
}java
/**
* 短信策略工厂
*/
@Component
public class SmsStrategyFactory implements InitializingBean {
@Autowired
private List<SmsStrategy> strategyList;
private final Map<SmsProvider, SmsStrategy> strategyMap = new HashMap<>();
@Override
public void afterPropertiesSet() {
for (SmsStrategy strategy : strategyList) {
strategyMap.put(strategy.getProvider(), strategy);
}
}
public SmsStrategy getStrategy(SmsProvider provider) {
SmsStrategy strategy = strategyMap.get(provider);
if (strategy == null) {
throw new BusinessException("不支持的短信服务商: " + provider);
}
return strategy;
}
}
/**
* 物流策略工厂
*/
@Component
public class LogisticsStrategyFactory implements InitializingBean {
@Autowired
private List<LogisticsQueryStrategy> strategyList;
private final Map<LogisticsProvider, LogisticsQueryStrategy> strategyMap = new HashMap<>();
@Override
public void afterPropertiesSet() {
for (LogisticsQueryStrategy strategy : strategyList) {
strategyMap.put(strategy.getProvider(), strategy);
}
}
public LogisticsQueryStrategy getStrategy(LogisticsProvider provider) {
LogisticsQueryStrategy strategy = strategyMap.get(provider);
if (strategy == null) {
throw new BusinessException("不支持的物流服务商: " + provider);
}
return strategy;
}
}4.2 重试机制(指数退避)
java
@Component
public class RetryHelper {
/**
* 指数退避重试
*
* @param supplier 执行的业务逻辑
* @param maxRetries 最大重试次数
* @param initialDelayMs 初始延迟(毫秒)
* @param multiplier 退避倍数
*/
public static <T> T doWithRetry(Supplier<T> supplier, int maxRetries,
long initialDelayMs, double multiplier) {
long delay = initialDelayMs;
for (int i = 0; i <= maxRetries; i++) {
try {
return supplier.get();
} catch (Exception e) {
if (i == maxRetries) {
throw new RetryExhaustedException("重试耗尽 (" + maxRetries + " 次)", e);
}
if (isNonRetryable(e)) {
throw new NonRetryableException("不可重试的异常", e);
}
log.warn("操作失败,第 {} 次重试,延迟 {}ms: {}", i + 1, delay, e.getMessage());
try { Thread.sleep(delay); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RetryExhaustedException("重试中断", ie);
}
delay = (long) (delay * multiplier);
}
}
throw new RetryExhaustedException("重试耗尽");
}
/**
* 判断是否属于不可重试的异常
* 例如:参数错误、签名错误、余额不足等无需重试
*/
private static boolean isNonRetryable(Throwable t) {
return t instanceof IllegalArgumentException
|| t instanceof SecurityException
|| (t.getMessage() != null && (
t.getMessage().contains("invalid sign")
|| t.getMessage().contains("insufficient balance")
|| t.getMessage().contains("invalid parameter")
));
}
}4.3 熔断隔离(Sentinel 资源定义)
java
@Configuration
public class SentinelResourceConfig {
/**
* 定义 Sentinel 资源:短信发送
* 在控制台配置熔断规则:异常比例 > 50% 时熔断 30 秒
*/
public static final String RESOURCE_SMS_SEND = "thirdparty:sms:send";
/**
* 定义 Sentinel 资源:物流查询
*/
public static final String RESOURCE_LOGISTICS_QUERY = "thirdparty:logistics:query";
/**
* 定义 Sentinel 资源:地图地理编码
*/
public static final String RESOURCE_MAP_GEOCODE = "thirdparty:map:geocode";
/**
* 定义 Sentinel 资源:地图距离矩阵
*/
public static final String RESOURCE_MAP_DISTANCE = "thirdparty:map:distance";
}
@Service
public class SmsServiceWithSentinel {
@Autowired
private SmsStrategyFactory strategyFactory;
/**
* 使用 Sentinel 熔断保护
*/
public SendSmsResponse sendSms(SmsRequest request) {
try (Entry entry = SphU.entry(RESOURCE_SMS_SEND)) {
SmsStrategy strategy = strategyFactory.getStrategy(
SmsProvider.valueOf(request.getProvider()));
return strategy.sendSms(request);
} catch (BlockException e) {
// 熔断降级,走备用通道或异步重试
log.warn("短信服务熔断,切换到备用通道");
return fallbackSmsSend(request);
}
}
/**
* 备用策略:切换短信服务商
*/
private SendSmsResponse fallbackSmsSend(SmsRequest request) {
SmsStrategy fallbackStrategy = strategyFactory.getStrategy(SmsProvider.TENCENT);
return fallbackStrategy.sendSms(request);
}
}Sentinel 熔断规则配置(application.yml):
yaml
# sentinel 规则配置(可推送到 Nacos 配置中心动态下发)
sentinel:
datasource:
ds1:
nacos:
server-addr: ${NACOS_ADDR}
dataId: sentinel-rules
groupId: SENTINEL_GROUP
rule-type: degrade
# Nacos 中 sentinel-rules 配置内容示例(JSON):
# [
# {
# "resource": "thirdparty:sms:send",
# "count": 0.5,
# "timeWindow": 30,
# "grade": 0,
# "minRequestAmount": 5
# },
# {
# "resource": "thirdparty:logistics:query",
# "count": 0.5,
# "timeWindow": 15,
# "grade": 0,
# "minRequestAmount": 10
# }
# ]5. API 密钥管理
5.1 AK/SK 加密存储
java
/**
* 密钥配置实体(数据库存储)
*/
@Data
@TableName("third_party_credential")
public class ThirdPartyCredential {
@TableId(type = IdType.AUTO)
private Long id;
private String provider; // 服务商:aliyun_sms / tencent_sms / kdniao / amap ...
private String appKey; // 密文存储
private String appSecret; // 密文存储
private String extra; // 额外参数(JSON,密文存储)
private String envTag; // dev / staging / prod
private Integer status; // 0-禁用, 1-启用
private LocalDateTime rotateAt; // 上次轮换时间
private LocalDateTime expireAt; // 过期时间
}java
@Component
public class CredentialCipher {
@Value("${credential.encrypt.secret-key}")
private String secretKey;
@Value("${credential.encrypt.iv}")
private String iv;
/**
* AES-256-GCM 加密
*/
public String encrypt(String plainText) {
try {
SecretKeySpec key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, ivSpec);
byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encrypted);
} catch (Exception e) {
throw new SecurityException("密钥加密失败", e);
}
}
/**
* AES-256-GCM 解密
*/
public String decrypt(String cipherText) {
try {
SecretKeySpec key = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "AES");
IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(StandardCharsets.UTF_8));
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, ivSpec);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new SecurityException("密钥解密失败", e);
}
}
}java
/**
* 密钥加载器:从数据库读取并解密,缓存到本地内存
*/
@Component
public class CredentialLoader {
@Autowired
private ThirdPartyCredentialMapper credentialMapper;
@Autowired
private CredentialCipher cipher;
private final Map<String, ThirdPartyCredential> credentialCache = new ConcurrentHashMap<>();
@PostConstruct
public void loadCredentials() {
String env = System.getProperty("spring.profiles.active");
List<ThirdPartyCredential> credentials = credentialMapper.selectList(
new LambdaQueryWrapper<ThirdPartyCredential>()
.eq(ThirdPartyCredential::getEnvTag, env)
.eq(ThirdPartyCredential::getStatus, 1));
for (ThirdPartyCredential cred : credentials) {
cred.setAppKey(cipher.decrypt(cred.getAppKey()));
cred.setAppSecret(cipher.decrypt(cred.getAppSecret()));
credentialCache.put(cred.getProvider() + ":" + env, cred);
}
}
public ThirdPartyCredential getCredential(String provider) {
String env = System.getProperty("spring.profiles.active");
ThirdPartyCredential cred = credentialCache.get(provider + ":" + env);
if (cred == null) {
throw new BusinessException("未找到密钥配置: " + provider + " [" + env + "]");
}
return cred;
}
}5.2 定期轮换
java
@Component
public class CredentialRotationJob {
@Autowired
private ThirdPartyCredentialMapper credentialMapper;
@Autowired
private CredentialCipher cipher;
/**
* 每天凌晨 3 点检查密钥是否需要轮换
*/
@Scheduled(cron = "0 0 3 * * ?")
public void rotateExpiredCredentials() {
List<ThirdPartyCredential> expiredList = credentialMapper.selectList(
new LambdaQueryWrapper<ThirdPartyCredential>()
.lt(ThirdPartyCredential::getExpireAt, LocalDateTime.now())
.eq(ThirdPartyCredential::getStatus, 1));
for (ThirdPartyCredential cred : expiredList) {
try {
doRotate(cred);
} catch (Exception e) {
log.error("密钥轮换失败: provider={}, id={}", cred.getProvider(), cred.getId(), e);
// 发送告警通知
alertService.sendAlert("密钥轮换失败", cred.getProvider());
}
}
}
private void doRotate(ThirdPartyCredential oldCred) {
// 1. 通过开放平台 API 生成新的密钥
// 不同服务商 API 不同,下面以阿里云为例
CreateAccessKeyResponse newKey = aliyunRamService.createAccessKey(oldCred.getAppKey());
// 2. 加密新密钥
ThirdPartyCredential newCred = new ThirdPartyCredential();
newCred.setProvider(oldCred.getProvider());
newCred.setEnvTag(oldCred.getEnvTag());
newCred.setAppKey(cipher.encrypt(newKey.getAccessKeyId()));
newCred.setAppSecret(cipher.encrypt(newKey.getAccessKeySecret()));
newCred.setRotateAt(LocalDateTime.now());
newCred.setExpireAt(LocalDateTime.now().plusMonths(3));
newCred.setStatus(1);
credentialMapper.insert(newCred);
// 3. 旧密钥标记为禁用(保留一段时间用于灰度切换)
oldCred.setStatus(0);
credentialMapper.updateById(oldCred);
}
}5.3 多环境隔离
yaml
# application-dev.yml
third-party:
credential:
encrypt:
secret-key: ${DEV_CREDENTIAL_KEY}
iv: ${DEV_CREDENTIAL_IV}
amap:
key: ${DEV_AMAP_KEY}
aliyun:
sms:
sign-name: 某某科技(测试)
template-verify-code: SMS_DEV_001
# application-prod.yml
third-party:
credential:
encrypt:
secret-key: ${PROD_CREDENTIAL_KEY}
iv: ${PROD_CREDENTIAL_IV}
amap:
key: ${PROD_AMAP_KEY}
aliyun:
sms:
sign-name: 某某科技
template-verify-code: SMS_PROD_001最佳实践总结:
| 实践 | 说明 |
|---|---|
| 加密存储 | 数据库存储的 AK/SK 使用 AES-256-GCM 加密,加密密钥通过环境变量注入 |
| 内存缓存 | 解密后的密钥缓存到本地内存,避免每次请求都解密数据库 |
| 环境隔离 | dev/staging/prod 使用独立的密钥和签名,通过 spring.profiles.active 隔离 |
| 定期轮换 | 密钥设置 90 天有效期,到期自动轮换,旧密钥保留 7 天用于过渡 |
| 最小权限 | 第三方 API 密钥按需分配最小权限,例如短信服务只分配发送权限 |
| 审计日志 | 所有密钥的增删改查操作记录审计日志 |
| 敏感信息不落地 | 代码仓库中不出现任何真实密钥,统一使用 $ {VAR} 环境变量引用 |