feat(currency): 添加货币转换功能和国家地址配置

- 创建CalculateCurrencyConversionRequestDTO用于货币转换请求
- 实现CountryAddressConfig工具类支持多国地址格式配置
- 添加CreatePayPalOrderResponseDTO和CurrencyConversionDTO响应对象
- 创建ExchangeRateService接口及其实现类提供实时汇率服务
- 集成ExchangeRate-API实现24小时缓存的汇率获取功能
- 添加HttpGet和MD5工具类支持HTTP请求和加密计算
This commit is contained in:
2025-12-25 09:24:01 +08:00
parent 01789ff148
commit b321750d63
8 changed files with 735 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
package com.mtkj.mtpay.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 计算货币转换请求DTO
*/
@Data
public class CalculateCurrencyConversionRequestDTO implements Serializable {
/**
* 订单号
*/
@NotBlank(message = "订单号不能为空")
private String orderNo;
/**
* 原始货币代码
*/
@NotBlank(message = "原始货币代码不能为空")
private String originalCurrency;
/**
* 原始金额
*/
@NotNull(message = "原始金额不能为空")
private BigDecimal originalAmount;
}

View File

@@ -0,0 +1,29 @@
package com.mtkj.mtpay.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 创建PayPal订单响应DTO包含货币转换信息
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CreatePayPalOrderResponseDTO implements Serializable {
/**
* PayPal订单信息
*/
private PayPalOrderResponseDTO paypalOrder;
/**
* 货币转换信息
*/
private CurrencyConversionDTO currencyConversion;
}

View File

@@ -0,0 +1,61 @@
package com.mtkj.mtpay.dto.response;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 货币转换信息DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CurrencyConversionDTO implements Serializable {
/**
* 原始货币代码
*/
private String originalCurrency;
/**
* 原始金额
*/
private BigDecimal originalAmount;
/**
* 支付货币代码
*/
private String paymentCurrency;
/**
* 支付金额
*/
private BigDecimal paymentAmount;
/**
* 使用的汇率
*/
private BigDecimal exchangeRate;
/**
* 汇率锁定时间
*/
private LocalDateTime rateLockedAt;
/**
* 是否需要货币转换
*/
private Boolean conversionRequired;
/**
* 汇率说明文本
*/
private String rateDescription;
}

View File

@@ -0,0 +1,25 @@
package com.mtkj.mtpay.service;
/**
* 汇率服务接口
*/
public interface ExchangeRateService {
/**
* 获取汇率(从源货币转换为目标货币)
* @param fromCurrency 源货币代码
* @param toCurrency 目标货币代码
* @return 汇率1单位源货币 = ?单位目标货币)
*/
Double getExchangeRate(String fromCurrency, String toCurrency);
/**
* 转换货币金额
* @param amount 原始金额
* @param fromCurrency 源货币代码
* @param toCurrency 目标货币代码
* @return 转换后的金额
*/
Double convertAmount(Double amount, String fromCurrency, String toCurrency);
}

View File

@@ -0,0 +1,157 @@
package com.mtkj.mtpay.service.impl;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mtkj.mtpay.service.ExchangeRateService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 汇率服务实现类
* 使用 ExchangeRate-API 获取实时汇率
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ExchangeRateServiceImpl implements ExchangeRateService {
private static final String API_KEY = "f0a6cdaf139b53113ee2eb22";
private static final String API_URL = "https://v6.exchangerate-api.com/v6/" + API_KEY + "/latest/USD";
// 汇率缓存key: 货币代码, value: 相对于USD的汇率
private final Map<String, Double> rateCache = new ConcurrentHashMap<>();
private long cacheExpireTime = 0;
private static final long CACHE_DURATION = 24 * 60 * 60 * 1000L; // 24小时缓存
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 获取汇率(从源货币转换为目标货币)
*/
@Override
public Double getExchangeRate(String fromCurrency, String toCurrency) {
// 如果相同货币返回1.0
if (fromCurrency.equals(toCurrency)) {
return 1.0;
}
// 刷新缓存(如果需要)
refreshCacheIfNeeded();
// 如果目标货币是USD直接返回缓存中的汇率
if ("USD".equals(toCurrency)) {
Double rate = rateCache.get(fromCurrency);
if (rate != null) {
return 1.0 / rate; // 转换为USD的汇率
}
log.warn("货币 {} 的汇率未找到,使用默认汇率 1.0", fromCurrency);
return 1.0;
}
// 如果源货币是USD直接返回缓存中的汇率
if ("USD".equals(fromCurrency)) {
Double rate = rateCache.get(toCurrency);
if (rate != null) {
return rate; // USD转换为目标货币的汇率
}
log.warn("货币 {} 的汇率未找到,使用默认汇率 1.0", toCurrency);
return 1.0;
}
// 其他情况通过USD中转
// fromCurrency -> USD -> toCurrency
Double fromRate = rateCache.get(fromCurrency);
Double toRate = rateCache.get(toCurrency);
if (fromRate != null && toRate != null) {
return toRate / fromRate; // (1 USD / fromRate) * toRate = toRate / fromRate
}
log.warn("货币 {} 或 {} 的汇率未找到,使用默认汇率 1.0", fromCurrency, toCurrency);
return 1.0;
}
/**
* 转换货币金额
*/
@Override
public Double convertAmount(Double amount, String fromCurrency, String toCurrency) {
Double rate = getExchangeRate(fromCurrency, toCurrency);
return amount * rate;
}
/**
* 刷新汇率缓存
*/
private void refreshCacheIfNeeded() {
long currentTime = System.currentTimeMillis();
// 如果缓存未过期,直接返回
if (currentTime < cacheExpireTime && !rateCache.isEmpty()) {
return;
}
log.info("刷新汇率缓存...");
try {
ResponseEntity<Map> response = restTemplate.getForEntity(API_URL, Map.class);
if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
Map<String, Object> responseBody = response.getBody();
// 检查响应状态
String result = (String) responseBody.get("result");
if (!"success".equals(result)) {
log.error("汇率API返回失败: {}", responseBody);
return;
}
// 获取汇率数据
@SuppressWarnings("unchecked")
Map<String, Object> conversionRates = (Map<String, Object>) responseBody.get("conversion_rates");
if (conversionRates != null) {
// 清空旧缓存
rateCache.clear();
// 填充新缓存
conversionRates.forEach((currency, rate) -> {
if (rate instanceof Number) {
rateCache.put(currency, ((Number) rate).doubleValue());
}
});
// 设置缓存过期时间24小时后
cacheExpireTime = currentTime + CACHE_DURATION;
log.info("汇率缓存刷新成功,共 {} 种货币", rateCache.size());
log.debug("示例汇率 - MYR: {}, CNY: {}, EUR: {}",
rateCache.get("MYR"), rateCache.get("CNY"), rateCache.get("EUR"));
} else {
log.error("汇率API响应中未找到conversion_rates字段");
}
} else {
log.error("获取汇率失败,状态码: {}", response.getStatusCode());
}
} catch (Exception e) {
log.error("刷新汇率缓存异常", e);
// 如果API调用失败使用默认汇率不更新缓存
if (rateCache.isEmpty()) {
log.warn("汇率API调用失败使用默认汇率");
// 设置一些常用货币的默认汇率
rateCache.put("MYR", 4.078);
rateCache.put("CNY", 7.0465);
rateCache.put("EUR", 0.851);
rateCache.put("GBP", 0.7434);
cacheExpireTime = currentTime + 60 * 60 * 1000L; // 1小时后重试
}
}
}
}

View File

@@ -0,0 +1,195 @@
package com.mtkj.mtpay.util;
import lombok.Data;
import java.util.*;
/**
* 国家地址配置工具类
* 定义各国地址字段规则和邮编格式
*/
public class CountryAddressConfig {
/**
* 国家地址配置
*/
@Data
public static class CountryConfig {
private String countryCode; // 国家代码
private String countryName; // 国家名称
private String countryNameEn; // 国家名称(英文)
private int postcodeLength; // 邮编长度
private String postcodePattern; // 邮编格式(正则表达式)
private String phoneCode; // 国际区号(如 +65, +66
private List<String> requiredFields; // 必填字段列表
private List<String> specialFields; // 特殊字段列表
private Map<String, String> fieldLabels; // 字段标签(支持多语言)
private String addressFormat; // 地址格式说明
}
/**
* 获取国家配置
*/
public static CountryConfig getCountryConfig(String countryCode) {
return COUNTRY_CONFIGS.getOrDefault(countryCode.toUpperCase(), DEFAULT_CONFIG);
}
/**
* 获取所有支持的国家
*/
public static Set<String> getSupportedCountries() {
return COUNTRY_CONFIGS.keySet();
}
/**
* 验证邮编格式
*/
public static boolean validatePostcode(String countryCode, String postcode) {
if (postcode == null || postcode.trim().isEmpty()) {
return false;
}
CountryConfig config = getCountryConfig(countryCode);
if (config.getPostcodePattern() == null) {
return postcode.length() == config.getPostcodeLength();
}
return postcode.matches(config.getPostcodePattern());
}
/**
* 获取必填字段
*/
public static List<String> getRequiredFields(String countryCode) {
CountryConfig config = getCountryConfig(countryCode);
return config.getRequiredFields() != null ? new ArrayList<>(config.getRequiredFields()) : new ArrayList<>();
}
/**
* 获取特殊字段
*/
public static List<String> getSpecialFields(String countryCode) {
CountryConfig config = getCountryConfig(countryCode);
return config.getSpecialFields() != null ? new ArrayList<>(config.getSpecialFields()) : new ArrayList<>();
}
// 默认配置
private static final CountryConfig DEFAULT_CONFIG = new CountryConfig();
static {
DEFAULT_CONFIG.setCountryCode("DEFAULT");
DEFAULT_CONFIG.setCountryName("默认");
DEFAULT_CONFIG.setCountryNameEn("Default");
DEFAULT_CONFIG.setPostcodeLength(6);
DEFAULT_CONFIG.setPostcodePattern(null);
DEFAULT_CONFIG.setPhoneCode("+86");
DEFAULT_CONFIG.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingCity", "shippingAddressLine1"));
DEFAULT_CONFIG.setSpecialFields(new ArrayList<>());
}
// 国家配置映射
private static final Map<String, CountryConfig> COUNTRY_CONFIGS = new HashMap<>();
static {
// 新加坡配置
CountryConfig sg = new CountryConfig();
sg.setCountryCode("SG");
sg.setCountryName("新加坡");
sg.setCountryNameEn("Singapore");
sg.setPostcodeLength(6);
sg.setPostcodePattern("^\\d{6}$");
sg.setPhoneCode("+65");
sg.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingCity", "shippingAddressLine1", "shippingBlockNumber", "shippingUnitNumber", "shippingPostcode"));
sg.setSpecialFields(Arrays.asList("shippingBlockNumber", "shippingUnitNumber"));
Map<String, String> sgLabels = new HashMap<>();
sgLabels.put("shippingBlockNumber", "组屋号 (Block Number)");
sgLabels.put("shippingUnitNumber", "单元号 (Unit Number)");
sgLabels.put("shippingAddressLine1", "详细地址1 (Address Line 1)");
sgLabels.put("shippingAddressLine2", "详细地址2 (Address Line 2)");
sg.setFieldLabels(sgLabels);
sg.setAddressFormat("Blk 123 Jurong West St 41 #12-345, Singapore 640123");
COUNTRY_CONFIGS.put("SG", sg);
// 马来西亚配置
CountryConfig my = new CountryConfig();
my.setCountryCode("MY");
my.setCountryName("马来西亚");
my.setCountryNameEn("Malaysia");
my.setPostcodeLength(5);
my.setPostcodePattern("^\\d{5}$");
my.setPhoneCode("+60");
my.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingCity", "shippingStateMalaysia", "shippingAddressLine1", "shippingPostcode"));
my.setSpecialFields(Arrays.asList("shippingStateMalaysia"));
Map<String, String> myLabels = new HashMap<>();
myLabels.put("shippingStateMalaysia", "州属 (State)");
myLabels.put("shippingAddressLine1", "详细地址1 (Address Line 1)");
myLabels.put("shippingAddressLine2", "详细地址2 (Address Line 2)");
my.setFieldLabels(myLabels);
my.setAddressFormat("123 Jalan Abdullah, 05-01 Menara A, Kuala Lumpur, Selangor 50300");
COUNTRY_CONFIGS.put("MY", my);
// 菲律宾配置
CountryConfig ph = new CountryConfig();
ph.setCountryCode("PH");
ph.setCountryName("菲律宾");
ph.setCountryNameEn("Philippines");
ph.setPostcodeLength(4);
ph.setPostcodePattern("^\\d{4}$");
ph.setPhoneCode("+63");
ph.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingCity", "shippingState", "shippingBarangay", "shippingAddressLine1", "shippingPostcode"));
ph.setSpecialFields(Arrays.asList("shippingBarangay"));
Map<String, String> phLabels = new HashMap<>();
phLabels.put("shippingBarangay", "Barangay社区编号");
phLabels.put("shippingState", "省 (Province)");
phLabels.put("shippingCity", "市 (City)");
phLabels.put("shippingAddressLine1", "详细地址1 (Address Line 1)");
phLabels.put("shippingAddressLine2", "详细地址2 (Address Line 2)");
ph.setFieldLabels(phLabels);
ph.setAddressFormat("123 Main St, Barangay 12, Manila, Metro Manila 1000");
COUNTRY_CONFIGS.put("PH", ph);
// 泰国配置
CountryConfig th = new CountryConfig();
th.setCountryCode("TH");
th.setCountryName("泰国");
th.setCountryNameEn("Thailand");
th.setPostcodeLength(5);
th.setPostcodePattern("^\\d{5}$");
th.setPhoneCode("+66");
th.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingCity", "shippingState", "shippingAddressLine1", "shippingPostcode"));
th.setSpecialFields(Arrays.asList("shippingAddressThai"));
Map<String, String> thLabels = new HashMap<>();
thLabels.put("shippingAddressThai", "泰文地址 (Thai Address)");
thLabels.put("shippingAddressLine1", "英文地址 (English Address)");
thLabels.put("shippingAddressLine2", "详细地址2 (Address Line 2)");
thLabels.put("shippingState", "府 (Changwat)");
thLabels.put("shippingCity", "县 (Amphoe)");
thLabels.put("shippingAdministrativeArea", "区 (Tambon)");
th.setFieldLabels(thLabels);
th.setAddressFormat("123 Soi Sukhumvit 101, Khlong Toei, Bangkok 10110");
COUNTRY_CONFIGS.put("TH", th);
// 越南配置
CountryConfig vn = new CountryConfig();
vn.setCountryCode("VN");
vn.setCountryName("越南");
vn.setCountryNameEn("Vietnam");
vn.setPostcodeLength(5);
vn.setPostcodePattern("^\\d{5}$");
vn.setPhoneCode("+84");
vn.setRequiredFields(Arrays.asList("shippingName", "shippingPhone", "shippingCountry",
"shippingProvince", "shippingDistrict", "shippingWard", "shippingAddressLine1", "shippingPostcode"));
vn.setSpecialFields(Arrays.asList("shippingProvince", "shippingDistrict", "shippingWard"));
Map<String, String> vnLabels = new HashMap<>();
vnLabels.put("shippingProvince", "省 (Tỉnh)");
vnLabels.put("shippingDistrict", "市/郡 (Thành phố/Huyện)");
vnLabels.put("shippingWard", "区/坊 (Quận/Phường)");
vnLabels.put("shippingAddressLine1", "详细地址1 (Địa chỉ chi tiết 1)");
vnLabels.put("shippingAddressLine2", "详细地址2 (Địa chỉ chi tiết 2)");
vn.setFieldLabels(vnLabels);
vn.setAddressFormat("123 Đường Nguyễn Huệ, Phường Bến Nghé, Quận 1, Thành phố Hồ Chí Minh 70000");
COUNTRY_CONFIGS.put("VN", vn);
}
}

View File

@@ -0,0 +1,146 @@
package com.mtkj.mtpay.util;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLEncoder;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Map;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.KeyManager;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
/**
* HTTP GET请求工具类
*/
class HttpGet {
protected static final int SOCKET_TIMEOUT = 10000;
protected static final String GET = "GET";
private static TrustManager myX509TrustManager = new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
};
public static String get(String host, Map<String, String> params) {
try {
SSLContext sslcontext = SSLContext.getInstance("TLS");
sslcontext.init((KeyManager[])null, new TrustManager[]{myX509TrustManager}, (SecureRandom)null);
String sendUrl = getUrlWithQueryString(host, params);
URL uri = new URL(sendUrl);
HttpURLConnection conn = (HttpURLConnection)uri.openConnection();
if (conn instanceof HttpsURLConnection) {
((HttpsURLConnection)conn).setSSLSocketFactory(sslcontext.getSocketFactory());
}
conn.setConnectTimeout(10000);
conn.setRequestMethod("GET");
int statusCode = conn.getResponseCode();
if (statusCode != 200) {
System.out.println("Http错误码" + statusCode);
}
InputStream is = conn.getInputStream();
BufferedReader br = new BufferedReader(new InputStreamReader(is));
StringBuilder builder = new StringBuilder();
String line = null;
while((line = br.readLine()) != null) {
builder.append(line);
}
String text = builder.toString();
close(br);
close(is);
conn.disconnect();
return text;
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (KeyManagementException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return null;
}
public static String getUrlWithQueryString(String url, Map<String, String> params) {
if (url == null || url.trim().isEmpty()) {
throw new IllegalArgumentException("URL cannot be null or empty");
}
if (params == null) {
return url;
} else {
StringBuilder builder = new StringBuilder(url);
if (url.contains("?")) {
builder.append("&");
} else {
builder.append("?");
}
int i = 0;
for(String key : params.keySet()) {
String value = (String)params.get(key);
if (value != null) {
if (i != 0) {
builder.append('&');
}
builder.append(key);
builder.append('=');
builder.append(encode(value));
++i;
}
}
return builder.toString();
}
}
protected static void close(Closeable closeable) {
if (closeable != null) {
try {
closeable.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static String encode(String input) {
if (input == null) {
return "";
} else {
try {
return URLEncoder.encode(input, "utf-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return input;
}
}
}
}

View File

@@ -0,0 +1,88 @@
package com.mtkj.mtpay.util;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* MD5工具类
*/
public class MD5 {
private static final char[] hexDigits = new char[]{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};
public static String md5(String input) {
if (input == null) {
return null;
}
try {
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] inputByteArray = input.getBytes();
messageDigest.update(inputByteArray);
byte[] resultByteArray = messageDigest.digest();
return byteArrayToHex(resultByteArray);
} catch (NoSuchAlgorithmException e) {
return null;
}
}
public static String md5(File file) {
try {
if (!file.isFile()) {
System.err.println("文件" + file.getAbsolutePath() + "不存在或者不是文件");
return null;
}
FileInputStream in = new FileInputStream(file);
String result = md5((InputStream)in);
in.close();
return result;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public static String md5(InputStream in) {
try {
MessageDigest messagedigest = MessageDigest.getInstance("MD5");
byte[] buffer = new byte[1024];
int read = 0;
while((read = in.read(buffer)) != -1) {
messagedigest.update(buffer, 0, read);
}
in.close();
String result = byteArrayToHex(messagedigest.digest());
return result;
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
private static String byteArrayToHex(byte[] byteArray) {
char[] resultCharArray = new char[byteArray.length * 2];
int index = 0;
for(byte b : byteArray) {
resultCharArray[index++] = hexDigits[b >>> 4 & 15];
resultCharArray[index++] = hexDigits[b & 15];
}
return new String(resultCharArray);
}
}