feat: 实现无交易记录自动结算功能

核心功能:
- 配置开关:11个配置项,支持灰度发布
- 定时任务:Quartz调度,可配置执行间隔
- 幂等保护:Redis分布式锁 + 数据库乐观锁
- 数据校验:充电桩在线检测、数据新鲜度、金额异常告警
- 结算逻辑:实时数据转换为结算数据,自动处理退款
- 单元测试:6个测试用例,覆盖主要场景

新增文件:
- AutoSettleConfig.java - 配置属性类
- SettlementDataConverter.java - 数据转换工具
- AutoSettleOrdersWithoutTransactionTest.java - 单元测试

修改文件:
- OrderBasicInfoService.java - 添加自动结算接口
- OrderBasicInfoServiceImpl.java - 实现自动结算逻辑
- OrderBasicInfoMapper.java - 添加查询和乐观锁更新方法
- OrderBasicInfoMapper.xml - 添加SQL实现
- JsowellTask.java - 添加定时任务方法
- application.yml - 添加配置项示例

详细设计:见 docs/feature-tracker-无交易记录自动结算.md
This commit is contained in:
jsowell
2026-08-11 16:40:35 +08:00
parent 33b48f2ad4
commit 121b357a20
10 changed files with 1416 additions and 23 deletions

View File

@@ -214,3 +214,27 @@ sms:
enable: false
host: 127.0.0.1
port: 8080
# 无交易记录自动结算配置
auto-settle:
# 总开关,默认关闭
enabled: false
# 停止充电超时阈值(分钟),超过此时间未收到交易记录则触发自动结算
timeout-minutes: 10
# 定时任务扫描周期Cron表达式每10分钟扫描一次
interval: '0 */10 * * * ?'
# 灰度站点白名单站点ID列表仅对这些站点启用自动结算
grayscale-station-ids: []
# 金额异常阈值倍数chargingAmount > payAmount * 此值时告警跳过
amount-threshold-ratio: 1.5
# 单次扫描订单数量上限,防止一次查询过多订单
batch-size: 100
# 实时数据新鲜度阈值(分钟),数据超过此时间未更新视为桩假在线
data-freshness-minutes: 30
# 告警开关
alert-enabled: true
# Redis分布式锁超时时间
lock-timeout-seconds: 60
# 失败是否重试建议false等待下一轮扫描
retry-on-failure: false

View File

@@ -0,0 +1,208 @@
package com.jsowell;
import com.jsowell.pile.config.AutoSettleConfig;
import com.jsowell.pile.domain.OrderBasicInfo;
import com.jsowell.pile.mapper.OrderBasicInfoMapper;
import com.jsowell.pile.service.OrderBasicInfoService;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;
/**
* 无交易记录自动结算功能测试
*
* @author jsowell
* @date 2026-08-11
*/
@RunWith(SpringRunner.class)
@SpringBootTest
public class AutoSettleOrdersWithoutTransactionTest {
@Autowired
private OrderBasicInfoService orderBasicInfoService;
@Autowired
private OrderBasicInfoMapper orderBasicInfoMapper;
@Autowired
private AutoSettleConfig autoSettleConfig;
@Before
public void setUp() {
System.out.println("=== 无交易记录自动结算测试开始 ===");
System.out.println("配置信息:");
System.out.println(" - 功能开关:" + autoSettleConfig.getEnabled());
System.out.println(" - 超时阈值:" + autoSettleConfig.getTimeoutMinutes() + " 分钟");
System.out.println(" - 批次大小:" + autoSettleConfig.getBatchSize());
System.out.println(" - 金额阈值倍数:" + autoSettleConfig.getAmountThresholdRatio());
System.out.println(" - 数据新鲜度:" + autoSettleConfig.getDataFreshnessMinutes() + " 分钟");
System.out.println(" - 灰度站点:" + autoSettleConfig.getGrayscaleStationIds());
}
/**
* 测试1查询待结算订单
*/
@Test
public void testSelectPendingAutoSettleOrders() {
System.out.println("\n【测试1】查询待结算订单");
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(autoSettleConfig.getTimeoutMinutes());
List<OrderBasicInfo> orders = orderBasicInfoMapper.selectPendingAutoSettleOrders(
cutoffTime,
autoSettleConfig.getGrayscaleStationIds(),
autoSettleConfig.getBatchSize()
);
System.out.println("查询结果:");
System.out.println(" - 截止时间:" + cutoffTime);
System.out.println(" - 查询数量:" + orders.size());
if (!orders.isEmpty()) {
System.out.println(" - 样例订单:");
OrderBasicInfo sample = orders.get(0);
System.out.println(" 订单号:" + sample.getOrderCode());
System.out.println(" 站点ID" + sample.getStationId());
System.out.println(" 充电桩:" + sample.getPileSn());
System.out.println(" 结束时间:" + sample.getChargeEndTime());
System.out.println(" 支付金额:" + sample.getPayAmount());
}
assert orders.size() <= autoSettleConfig.getBatchSize() : "查询结果超过批次大小限制";
}
/**
* 测试2灰度配置测试
*/
@Test
public void testGrayscaleConfiguration() {
System.out.println("\n【测试2】灰度配置测试");
boolean grayscaleEnabled = autoSettleConfig.isGrayscaleEnabled();
System.out.println("灰度是否启用:" + grayscaleEnabled);
if (grayscaleEnabled) {
List<Long> stationIds = autoSettleConfig.getGrayscaleStationIds();
System.out.println("灰度站点数量:" + stationIds.size());
System.out.println("灰度站点列表:" + stationIds);
// 测试站点判断
Long testStationId = stationIds.get(0);
boolean inGrayscale = autoSettleConfig.isStationInGrayscale(testStationId);
System.out.println("站点 " + testStationId + " 在灰度范围内:" + inGrayscale);
assert inGrayscale : "灰度站点判断错误";
boolean notInGrayscale = autoSettleConfig.isStationInGrayscale(999999L);
System.out.println("站点 999999 在灰度范围内:" + notInGrayscale);
assert !notInGrayscale : "非灰度站点判断错误";
} else {
System.out.println("未启用灰度,全量上线模式");
boolean allInGrayscale = autoSettleConfig.isStationInGrayscale(1L);
assert allInGrayscale : "全量模式下所有站点应该在灰度范围内";
}
}
/**
* 测试3金额阈值检测
*/
@Test
public void testAmountThresholdValidation() {
System.out.println("\n【测试3】金额阈值检测");
BigDecimal payAmount = new BigDecimal("10.00");
BigDecimal threshold = payAmount.multiply(BigDecimal.valueOf(autoSettleConfig.getAmountThresholdRatio()));
System.out.println("已支付金额:" + payAmount + "");
System.out.println("阈值倍数:" + autoSettleConfig.getAmountThresholdRatio());
System.out.println("告警阈值:" + threshold + "");
// 正常金额
BigDecimal normalAmount = new BigDecimal("8.00");
boolean normalPass = normalAmount.compareTo(threshold) <= 0;
System.out.println("充电金额 " + normalAmount + " 元 <= 阈值:" + normalPass);
assert normalPass : "正常金额应该通过检测";
// 异常金额
BigDecimal abnormalAmount = new BigDecimal("16.00");
boolean abnormalPass = abnormalAmount.compareTo(threshold) <= 0;
System.out.println("充电金额 " + abnormalAmount + " 元 <= 阈值:" + abnormalPass);
assert !abnormalPass : "异常金额应该被告警";
}
/**
* 测试4配置开关测试
*/
@Test
public void testFeatureToggle() {
System.out.println("\n【测试4】配置开关测试");
Boolean enabled = autoSettleConfig.getEnabled();
System.out.println("功能总开关:" + enabled);
if (!enabled) {
System.out.println("功能已关闭,自动结算不会执行");
System.out.println("如需测试,请在配置文件中设置 auto-settle.enabled=true");
} else {
System.out.println("功能已开启");
}
Boolean alertEnabled = autoSettleConfig.getAlertEnabled();
System.out.println("告警开关:" + alertEnabled);
}
/**
* 测试5完整流程测试需要功能开关打开
*/
@Test
public void testAutoSettleMainFlow() {
System.out.println("\n【测试5】完整流程测试");
if (!autoSettleConfig.getEnabled()) {
System.out.println("功能未开启,跳过完整流程测试");
System.out.println("如需测试,请在配置文件中设置 auto-settle.enabled=true");
return;
}
try {
// 执行自动结算
orderBasicInfoService.autoSettleOrdersWithoutTransactionRecord();
System.out.println("自动结算执行完成,请查看日志输出");
} catch (Exception e) {
System.err.println("自动结算执行失败:" + e.getMessage());
e.printStackTrace();
throw e;
}
}
/**
* 测试6数据新鲜度判断
*/
@Test
public void testDataFreshness() {
System.out.println("\n【测试6】数据新鲜度判断");
Integer freshnessMinutes = autoSettleConfig.getDataFreshnessMinutes();
System.out.println("数据新鲜度阈值:" + freshnessMinutes + " 分钟");
// 模拟新鲜数据
LocalDateTime freshTime = LocalDateTime.now().minusMinutes(10);
long freshMinutes = java.time.Duration.between(freshTime, LocalDateTime.now()).toMinutes();
boolean isFresh = freshMinutes <= freshnessMinutes;
System.out.println("数据时间距今 " + freshMinutes + " 分钟,是否新鲜:" + isFresh);
assert isFresh : "10分钟前的数据应该是新鲜的";
// 模拟陈旧数据
LocalDateTime staleTime = LocalDateTime.now().minusMinutes(60);
long staleMinutes = java.time.Duration.between(staleTime, LocalDateTime.now()).toMinutes();
boolean isStale = staleMinutes > freshnessMinutes;
System.out.println("数据时间距今 " + staleMinutes + " 分钟,是否陈旧:" + isStale);
assert isStale : "60分钟前的数据应该是陈旧的";
}
}