阿奇索自动发货接入接口
Some checks failed
Java CI with Maven / build (11) (push) Has been cancelled
Java CI with Maven / build (17) (push) Has been cancelled
Java CI with Maven / build (8) (push) Has been cancelled
yudao-ui-admin CI / build (14.x) (push) Has been cancelled
yudao-ui-admin CI / build (16.x) (push) Has been cancelled

This commit is contained in:
sqlicong
2025-08-07 15:25:08 +08:00
parent 4060aadb85
commit aacca16683
11 changed files with 338 additions and 41 deletions

View File

@@ -6,7 +6,7 @@ import cn.iocoder.yudao.framework.common.exception.ErrorCode;
* 全局错误码枚举
* 0-999 系统异常编码保留
*
* 一般情况下,使用 HTTP 响应状态码 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status
* 一般情况下,使用 HTTP 响应状态码 <a href="https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status">...</a>
* 虽然说HTTP 响应状态码作为业务使用表达能力偏弱,但是使用在系统层面还是非常不错的
* 比较特殊的是,因为之前一直使用 0 作为成功,就不使用 200 啦。
*

View File

@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.kfc.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Data
@ConfigurationProperties(prefix = "agiso")
@Configuration
public class AgisoConfig {
private String appid;
private String appsecret;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.kfc.config;
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.TimeUnit;
/**
OkHttp 配置类,用于创建并注册 OkHttpClient 实例到 Spring 容器
*/
@Configuration
public class OkHttpConfig {
/**
定义 OkHttpClient 的 bean设置超时时间和连接池等参数
*/
@Bean
public OkHttpClient okHttpClient () {
// 连接池配置:最大空闲连接数 5 个,连接空闲超时 5 分钟
ConnectionPool connectionPool = new ConnectionPool (5, 5, TimeUnit.MINUTES);
return new OkHttpClient.Builder ()
.connectTimeout (30, TimeUnit.SECONDS)
.readTimeout (30, TimeUnit.SECONDS)
.writeTimeout (30, TimeUnit.SECONDS)
.connectionPool (connectionPool)
.retryOnConnectionFailure (true)
.build ();
}
}

View File

@@ -44,7 +44,6 @@ public class AgisoAuthCallbackController {
@GetMapping("/get/code-url")
@Operation(summary = "生成阿奇索code认证url")
@PermitAll
public CommonResult<String> getCodeUrl() {
return success(agisoAuthService.generateAuthUrl());
}

View File

@@ -7,7 +7,7 @@ import lombok.Data;
* 授权成功后返回的AccessToken相关数据实体类
*/
@Data
public class AccessTokenData {
public class AgisoData {
/**
* 平台标识

View File

@@ -7,7 +7,7 @@ import lombok.Data;
* 换取AccessToken的完整响应实体类
*/
@Data
public class AccessTokenResponse {
public class AgisoResponse {
/**
* 操作是否成功
@@ -31,5 +31,5 @@ public class AccessTokenResponse {
* 详细数据成功时返回
*/
@JsonProperty("Data")
private AccessTokenData data;
private AgisoData data;
}

View File

@@ -53,4 +53,7 @@ public interface ErrorCodeConstants {
ErrorCode RESPONSE_IS_EMPTY = new ErrorCode(2_001_013_000,"响应体为空");
ErrorCode TOKEN_IS_EMPTY = new ErrorCode(2_001_013_001,"响应数据中未包含有效的Token");
ErrorCode STATE_NOT_VALID = new ErrorCode(2_001_013_002,"回调失败state参数无效或已过期");
ErrorCode TOKEN_AUTH_FAILED = new ErrorCode(2_001_013_003,"认证失败token令牌无效或已过期");
ErrorCode USER_AUTH_FAILED = new ErrorCode(2_001_013_004,"用户授权失败");
}

View File

@@ -1,9 +1,26 @@
package cn.iocoder.yudao.module.kfc.service.agiso;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AgisoResponse;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Map;
public interface AgisoAuthService {
String generateAuthUrl();
String authAndGetAccessToken(String code, String state, String error);
AgisoResponse execute(String url, String method, Map<String, String> params, String sellerOpenUid) throws IOException, NoSuchAlgorithmException;
AgisoResponse doPost(String url, Map<String, String> params, String sellerOpenUid) throws NoSuchAlgorithmException;
AgisoResponse doGet(String url, Map<String, String> params, String sellerOpenUid) throws NoSuchAlgorithmException;
AgisoResponse autoShipment(List<String> tids, String sellerOpenUid) throws NoSuchAlgorithmException;
String sign(Map<String, String> params, String appSecret)
throws NoSuchAlgorithmException;
}

View File

@@ -1,40 +1,41 @@
package cn.iocoder.yudao.module.kfc.service.agiso;
import cn.hutool.core.util.RandomUtil;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AccessTokenData;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AccessTokenResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import cn.iocoder.yudao.module.kfc.config.AgisoConfig;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AgisoData;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AgisoResponse;
import cn.iocoder.yudao.module.kfc.utils.HttpUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.springframework.beans.factory.annotation.Value;
import okhttp3.*;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.io.IOException;
import java.util.Objects;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.kfc.enums.ErrorCodeConstants.RESPONSE_IS_EMPTY;
import static cn.iocoder.yudao.module.kfc.enums.ErrorCodeConstants.TOKEN_IS_EMPTY;
import static cn.iocoder.yudao.module.kfc.enums.ErrorCodeConstants.*;
import static software.amazon.awssdk.http.HttpStatusCode.BAD_REQUEST;
@Service
@Slf4j
public class AgisoAuthServiceImpl implements AgisoAuthService {
@Value("${agiso.appid}")
private String APPID;
@Value("${agiso.appsecret}")
private String APPSECRET;
@Resource
private AgisoConfig agisoConfig;
// Redis中存储state的前缀过期时间10分钟(授权流程通常较短)
// Redis中存储state的前缀过期时间10分钟
private static final String STATE_REDIS_KEY_PREFIX = "agiso:auth:state:";
private static final String TOKEN_REDIS_KEY_PREFIX = "agiso:token:";
private static final long STATE_EXPIRE_MINUTES = 10;
private static final String AUTO_SHIPMENTS_URL = "http://gw.api.agiso.com/alds/Trade/AldsProcessTrades";
@Resource
private RedisTemplate<String, String> redisTemplate;
@@ -46,7 +47,7 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
private ObjectMapper objectMapper;
/**
* 生成授权链接(新增方法,用于发起授权)
* 生成授权链接
*/
@Override
public String generateAuthUrl() {
@@ -56,7 +57,7 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
redisTemplate.opsForValue()
.set(STATE_REDIS_KEY_PREFIX + state, "VALID", STATE_EXPIRE_MINUTES, TimeUnit.MINUTES);
// 3. 拼接授权URL
return "https://alds.agiso.com/authorize.aspx?appId=" + APPID + "&state=" + state;
return "https://alds.agiso.com/authorize.aspx?appId=" + agisoConfig.getAppid() + "&state=" + state;
}
/**
@@ -68,7 +69,7 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
String redisKey = STATE_REDIS_KEY_PREFIX + state;
if (!redisTemplate.hasKey(redisKey)) {
log.warn("授权回调失败state校验不通过state={}", state);
return "回调失败state参数无效或已过期";
throw exception(STATE_NOT_VALID);
}
// 校验通过后删除state防止重复使用
redisTemplate.delete(redisKey);
@@ -76,33 +77,34 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
// 2. 处理授权失败场景
if (error != null) {
log.warn("用户授权失败error={}", error);
return "授权失败:" + error;
throw exception(USER_AUTH_FAILED);
}
// 3. 处理授权成功场景
if (code != null) {
try {
String accessToken = getAccessToken(code, state);
log.info("授权成功AccessToken已存储到redis");
return "success";
} catch (Exception e) {
log.error("换取AccessToken失败code={}", code, e);
return "换取AccessToken失败" + e.getMessage();
}
}
return "err:回调参数异常";
//异步执行,不阻塞回调线程
CompletableFuture.runAsync(() -> {
try {
String accessToken = generateAccessToken(code, state);
log.info("授权成功AccessToken已存储到redis");
} catch (Exception e) {
log.error("换取AccessToken失败code={}", code, e);
}
});
}
return "success";
}
/**
* 用授权码code换取AccessToken
*/
private String getAccessToken(String code, String state) throws IOException {
private String generateAccessToken(String code, String state) throws IOException {
// 1. 构建请求URL
HttpUrl url = Objects.requireNonNull(HttpUrl.parse("https://alds.agiso.com/auth/token")).newBuilder()
.addQueryParameter("code", code)
.addQueryParameter("appId", APPID)
.addQueryParameter("secret", APPSECRET)
.addQueryParameter("appId", agisoConfig.getAppid())
.addQueryParameter("secret", agisoConfig.getAppsecret())
.addQueryParameter("state", state) // 使用回调的state保持一致
.build();
log.debug("请求AccessToken的URL{}", url);
@@ -128,7 +130,7 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
}
// 6. 解析响应
AccessTokenResponse tokenResponse = objectMapper.readValue(responseBody, AccessTokenResponse.class);
AgisoResponse tokenResponse = objectMapper.readValue(responseBody, AgisoResponse.class);
if (!tokenResponse.isSuccess()) {
throw new RuntimeException(String.format(
"接口返回失败,错误码:%d错误信息%s",
@@ -138,13 +140,13 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
}
// 7. 提取Token和相关信息
AccessTokenData data = tokenResponse.getData();
AgisoData data = tokenResponse.getData();
if (data == null || data.getToken() == null || data.getToken().isEmpty()) {
throw exception(TOKEN_IS_EMPTY);
}
// 8. 缓存Token
String cacheKey = "agiso:token:" + data.getSellerOpenUid();
String cacheKey = TOKEN_REDIS_KEY_PREFIX + data.getSellerOpenUid();
redisTemplate.opsForValue()
.set(cacheKey, data.getToken(), data.getExpiresIn(), TimeUnit.SECONDS);
log.info("AccessToken缓存成功商家ID{},过期时间:{}秒", data.getSellerOpenUid(), data.getExpiresIn());
@@ -152,4 +154,105 @@ public class AgisoAuthServiceImpl implements AgisoAuthService {
return data.getToken();
}
}
@Override
public AgisoResponse execute(String url, String method, Map<String, String> params, String sellerOpenUid) throws NoSuchAlgorithmException {
if (params == null) params = new HashMap<>();
//从redis获取accessToken
String accessToken = redisTemplate.opsForValue().get(TOKEN_REDIS_KEY_PREFIX + sellerOpenUid);
if (accessToken == null) throw exception(TOKEN_AUTH_FAILED);
//构建公共请求参数
long timestamp = System.currentTimeMillis() / 1000;
params.put("timestamp", Long.toString(timestamp));
params.put("sign",sign(params,agisoConfig.getAppsecret()));
//构建公共请求头
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + accessToken);
headers.put("ApiVersion", "1");
//执行请求
return HttpUtils.execute(url,method, headers, params);
}
@Override
public AgisoResponse doPost(String url, Map<String, String> params, String sellerOpenUid) throws NoSuchAlgorithmException {
return execute(url,"POST",params,sellerOpenUid);
}
@Override
public AgisoResponse doGet(String url, Map<String, String> params, String sellerOpenUid) throws NoSuchAlgorithmException {
return execute(url,"GET",params,sellerOpenUid);
}
@Override
public AgisoResponse autoShipment(List<String> tids, String sellerOpenUid) throws NoSuchAlgorithmException {
// 校验参数
if (tids == null || tids.isEmpty()) {
log.warn("自动发货失败订单ID列表为空");
AgisoResponse emptyResponse = new AgisoResponse();
emptyResponse.setSuccess(false);
emptyResponse.setErrorCode(BAD_REQUEST);
emptyResponse.setErrorMsg("订单ID列表不能为空");
return emptyResponse;
}
// 拼接订单ID字符串去除最后一个逗号
String tidsStr = String.join(",", tids);
log.debug("自动发货处理订单ID列表{},商家标识:{}", tidsStr, sellerOpenUid);
// 构建请求参数
Map<String, String> params = new HashMap<>();
params.put("tids", tidsStr);
// 调用POST请求方法
return doPost(AUTO_SHIPMENTS_URL, params, sellerOpenUid);
}
/**
* 阿奇索签名算法
* @param params 业务参数
* @param appSecret appsecret
* @return 签名字符串
* @throws NoSuchAlgorithmException 未找到算法异常
*/
@Override
public String sign(Map<String, String> params, String appSecret) throws NoSuchAlgorithmException {
String[] keys = params.keySet().toArray(new String[0]);
Arrays.sort(keys);
StringBuilder query = new StringBuilder();
query.append(appSecret);
for (String key : keys) {
String value = params.get(key);
query.append(key).append(value);
}
query.append(appSecret);
byte[] md5byte = encryptMD5(query.toString());
return byte2hex(md5byte);
}
// byte数组转成16进制字符串
private String byte2hex(byte[] bytes) {
StringBuilder sign = new StringBuilder();
for (byte aByte : bytes) {
String hex = Integer.toHexString(aByte & 0xFF);
if (hex.length() == 1) {
sign.append("0");
}
sign.append(hex.toLowerCase());
}
return sign.toString();
}
// Md5摘要
private byte[] encryptMD5(String data) throws NoSuchAlgorithmException {
MessageDigest md5 = MessageDigest.getInstance("MD5");
return md5.digest(data.getBytes(StandardCharsets.UTF_8));
}
}

View File

@@ -0,0 +1,131 @@
package cn.iocoder.yudao.module.kfc.utils;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AgisoData;
import cn.iocoder.yudao.module.kfc.controller.admin.agisoproxy.vo.AgisoResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import okhttp3.*;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
public class HttpUtils {
// 单例OkHttpClient实例
private static final OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
// 用于JSON反序列化的ObjectMapper
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 通用HTTP请求执行方法返回结构化的AgisoResponse
* @param url 请求URL
* @param method 请求方法GET, POST, PUT, DELETE等
* @param headers 请求头
* @param params 请求参数
* @return 结构化的AgisoResponse响应
*/
public static AgisoResponse execute(String url, String method, Map<String, String> headers, Map<String, String> params) {
// 参数处理
if (params == null) {
params = new HashMap<>();
}
if (headers == null) {
headers = new HashMap<>();
}
// 构建请求体
RequestBody requestBody = null;
if ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method)) {
FormBody.Builder formBuilder = new FormBody.Builder();
for (Map.Entry<String, String> entry : params.entrySet()) {
formBuilder.add(entry.getKey(), entry.getValue());
}
requestBody = formBuilder.build();
}
// GET请求参数拼接
else if ("GET".equalsIgnoreCase(method) && !params.isEmpty()) {
url = url + (url.contains("?") ? "&" : "?") + buildQueryString(params);
}
// 构建请求
Request.Builder requestBuilder = new Request.Builder()
.url(url);
// 添加请求头
for (Map.Entry<String, String> entry : headers.entrySet()) {
requestBuilder.addHeader(entry.getKey(), entry.getValue());
}
// 设置请求方法
Request request;
if ("GET".equalsIgnoreCase(method)) {
request = requestBuilder.get().build();
} else if ("DELETE".equalsIgnoreCase(method)) {
request = requestBuilder.delete().build();
} else {
request = requestBuilder.method(method, requestBody).build();
}
// 执行请求并处理响应
try (Response response = client.newCall(request).execute()) {
String responseBody = response.body() != null ? response.body().string() : "";
log.debug("HTTP请求响应内容: {}", responseBody);
// 尝试将响应体反序列化为AgisoResponse
if (!responseBody.isEmpty()) {
return objectMapper.readValue(responseBody, AgisoResponse.class);
}
// 响应体为空时构建默认失败响应
AgisoResponse emptyResponse = new AgisoResponse();
emptyResponse.setSuccess(false);
emptyResponse.setErrorCode(response.code());
emptyResponse.setErrorMsg("响应体为空");
return emptyResponse;
} catch (IOException e) {
log.error("请求执行异常:{}", e.getMessage(), e);
// 异常时构建失败响应
AgisoResponse errorResponse = new AgisoResponse();
errorResponse.setSuccess(false);
errorResponse.setErrorCode(500);
errorResponse.setErrorMsg("请求异常:" + e.getMessage());
return errorResponse;
}
}
/**
* 简化的POST请求方法返回结构化响应
*/
public static AgisoResponse post(String url, Map<String, String> headers, Map<String, String> params) {
return execute(url, "POST", headers, params);
}
/**
* 简化的GET请求方法返回结构化响应
*/
public static AgisoResponse get(String url, Map<String, String> headers, Map<String, String> params) {
return execute(url, "GET", headers, params);
}
/**
* 构建查询字符串
*/
private static String buildQueryString(Map<String, String> params) {
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (sb.length() > 0) {
sb.append("&");
}
sb.append(entry.getKey()).append("=").append(entry.getValue());
}
return sb.toString();
}
}

View File

@@ -272,6 +272,7 @@ pf4j:
# pluginsDir: /tmp/
pluginsDir: ../plugins
# 阿奇索的配置
agiso:
appid: appid
appsecret: appsecret