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

@@ -1,10 +1,10 @@
# 功能开发追踪:无交易记录时按实时检测数据自动结算
**功能编号**: TBD
**版本**: v0.2
**版本**: v0.4
**日期**: 2026-08-11
**项目**: 万车充运营管理平台
**状态**: 方案已确认,开发
**状态**: 方案已确认,开发
**负责模块**: jsowell-pile / jsowell-netty / jsowell-quartz
---
@@ -15,6 +15,8 @@
|------|------|---------|
| v0.1 | 2026-08-11 | 初稿:需求分析、风险点、实现方案、开发进度追踪框架 |
| v0.2 | 2026-08-11 | 确认决策:金额以桩端 chargingAmount 为准;双依据判定都启用;特定站点灰度 |
| v0.3 | 2026-08-11 | 新增8个待明确问题第六章扩充测试计划至29个场景第七章新增附录 |
| v0.4 | 2026-08-11 | ✅ 完成8个问题的决策回答更新已决策问题列表11个决策状态更新为"方案已确认,待开发" |
---
@@ -128,15 +130,16 @@
| # | 任务 | 模块 | 负责人 | 状态 | 备注 |
|---|------|------|--------|------|------|
| 1 | 需求评审确认(含 R1/R4 决策) | - | | ✅ 已完成 | 2026-08-11 确认:金额以桩端 chargingAmount 为准;双依据判定;特定站点灰度 |
| 2 | 配置开关 | jsowell-pile | | ⬜ 未开始 | |
| 3 | 实时数据→结算数据构造方法 | jsowell-admin/pile | | ⬜ 未开始 | |
| 4 | 待结算订单查询 SQL | jsowell-pile | | ⬜ 未开始 | |
| 5 | 定时任务 | jsowell-quartz | | ⬜ 未开始 | |
| 6 | 幂等保护 | jsowell-pile | | ⬜ 未开始 | |
| 7 | 告警日志 | 各模块 | | ⬜ 未开始 | |
| 8 | 单元测试 / 集成测试 | jsowell-admin | | ⬜ 未开始 | |
| 1.1 | 8个待明确问题决策 | - | | ✅ 已完成 | 2026-08-11 完成全部8个问题的决策回答 |
| 2 | 配置开关11个配置项 | jsowell-pile | | ⬜ 未开始 | application.yml 新增 auto-settle 配置块 |
| 3 | 实时数据→结算数据构造方法 | jsowell-admin/pile | | ⬜ 未开始 | 从Redis获取实时数据构造 TransactionRecordsData |
| 4 | 待结算订单查询 SQL | jsowell-pile | | ⬜ 未开始 | 支持Redis查询桩在线状态降级JOIN查询 |
| 5 | 定时任务 | jsowell-quartz | | ⬜ 未开始 | Cron表达式0 */10 * * * ? |
| 6 | 幂等保护redis锁+乐观锁) | jsowell-pile | | ⬜ 未开始 | settle_order_{orderId} + UPDATE WHERE条件 |
| 7 | 数据校验与告警日志 | jsowell-pile | | ⬜ 未开始 | 度数、金额、阈值三层校验 |
| 8 | 单元测试 / 集成测试 | jsowell-admin | | ⬜ 未开始 | 覆盖29个测试场景P0优先 |
| 9 | 灰度上线(特定站点) | - | | ⬜ 未开始 | 先对灰度站点开启,观察稳定后逐步放开 |
| 10 | 上线后监控与复盘 | - | | ⬜ 未开始 | |
| 10 | 上线后监控与复盘 | - | | ⬜ 未开始 | 监控Prometheus指标分析自动结算效果 |
### 已决策问题
@@ -145,18 +148,453 @@
| 1 | 金额策略 | **以桩端 `chargingAmount` 为准**(与人工结算一致) | 2026-08-11 |
| 2 | 停止判定依据 | **双依据都启用**chargeEndTime 优先,最后实时数据 dateTime 兜底 | 2026-08-11 |
| 3 | 灰度范围 | **特定站点灰度**,先对指定站点开启,观察稳定后再逐步放开 | 2026-08-11 |
| 4 | 并发幂等锁key | 采用方案A基于orderId构造 `"settle_order_" + orderId` | 2026-08-11 |
| 5 | 异常金额阈值 | 倍率阈值策略:`amount-threshold-ratio: 1.5` | 2026-08-11 |
| 6 | 桩在线查询 | 优先Redis缓存方案C降级JOIN查询方案D | 2026-08-11 |
| 7 | 最后实时数据 | 直接从Redis取方案B失败降级DB查询方案A | 2026-08-11 |
| 8 | 状态校验原子性 | UPDATE带WHERE条件乐观锁方案A+ redis分布式锁双重保障 | 2026-08-11 |
| 9 | 数据新鲜度 | 增加判断阈值30分钟 `data-freshness-minutes: 30` | 2026-08-11 |
| 10 | 异常数据拦截 | 采用三层校验:度数>0、金额>0、金额<阈值 | 2026-08-11 |
| 11 | 配置项 | 采用完整配置项11个配置 | 2026-08-11 |
---
## 六、测试计划
## 六、待明确问题
| # | 场景 | 预期 |
|---|------|------|
| 1 | 桩在线 + 有 chargeEndTime + 停止>10min + 有实时数据 | 自动结算成功,金额=实时数据 chargingAmount |
| 2 | 桩在线 + 无 chargeEndTime + 最后实时数据>10min + 有实时数据 | 兜底依据触发,自动结算 |
| 3 | 桩离线 | 不触发(条件 2 拦截) |
| 4 | 停止 < 10min | 不触发 |
| 5 | 无实时检测数据chargingDegree=0 | 不触发 |
| 6 | 扫描期间交易记录到达 | 不重复结算(幂等) |
| 7 | chargingAmount > payAmount 异常值 | 跳过并告警 |
| 8 | 配置开关关闭 | 保持现有行为(不自动结算) |
> **状态说明**`⬜ 待回答` / `✅ 已解决` / `🔴 阻塞中`
### 6.1 并发幂等保护的锁key构造对应风险R3
**问题描述**
文档中提到复用 `settle_order_+transactionCode` redis锁但**无交易记录场景下没有transactionCode**锁key应如何构造
**影响**
- 若锁key不正确可能导致定时任务与交易记录到达的并发冲突
- 多个定时任务实例同时扫描时可能重复结算
**建议方案**
```java
// 方案A基于orderId构造锁key
String lockKey = "settle_order_" + orderId;
// 方案B保持现有逻辑构造虚拟transactionCode
String transactionCode = "AUTO_" + orderId + "_" + System.currentTimeMillis();
String lockKey = "settle_order_" + transactionCode;
```
**回答**
```
状态:✅ 已解决
决策采用方案A基于orderId构造锁key
理由transactionCode在创建订单的时候就已经生成了但无交易记录场景下不会再生成新的
因此统一使用 "settle_order_" + orderId 作为锁key与交易记录到达时使用同一把锁。
实现String lockKey = "settle_order_" + orderId;
```
---
### 6.2 异常金额阈值的量化定义对应风险R1/R2
**问题描述**
文档多次提到"chargingAmount 远超 payAmount 时告警跳过",但**"远超"没有具体定义**。
**影响**
- 无明确阈值会导致实现时判断标准不统一
- 无法准确识别异常金额并拦截
**建议方案**
```yaml
auto-settle:
amount-threshold-ratio: 1.5 # chargingAmount > payAmount * 1.5 时告警跳过
# 或者使用绝对差值
amount-threshold-diff: 100.0 # chargingAmount - payAmount > 100元时告警
```
**示例**
- payAmount = 50元chargingAmount = 76元 → 1.52倍 → 告警跳过
- payAmount = 50元chargingAmount = 72元 → 1.44倍 → 正常结算
**回答**
```
状态:✅ 已解决
决策:采用倍率阈值策略
配置amount-threshold-ratio: 1.5
理由:倍率阈值比绝对差值更通用,适应不同金额区间的订单。
例如10元订单和100元订单的异常判断标准应该成比例而非固定差值。
实现if (chargingAmount > payAmount * 1.5) { 告警跳过 }
```
---
### 6.3 SQL查询"桩在线"条件的实现效率
**问题描述**
定时任务需要查询"桩在线"的待结算订单,但`order_basic_info`表可能没有直接存储connector_status字段需要JOIN `pile_connector_info`表,**大批量查询可能有性能问题**。
**影响**
- 每次扫描都需要多表JOIN数据量大时查询慢
- 定时任务执行时间过长可能影响下一轮扫描
**建议方案**
| 方案 | 优点 | 缺点 | 实施难度 |
|------|------|------|---------|
| A. 订单表增加冗余字段`connector_status` | 查询快,单表索引 | 需要实时同步状态,数据冗余 | 中 |
| B. 分批查询(先查订单,再批量查桩状态) | 不改表结构 | 需要两次查询,逻辑复杂 | 低 |
| C. 从Redis缓存判断`PILE_REAL_TIME_MONITOR_DATA` | 最实时无DB压力 | Redis数据可能缺失 | 低 |
| D. 直接JOIN查询当前方案 | 实现简单 | 性能问题 | 最低 |
**回答**
```
状态:✅ 已解决
决策优先使用方案CRedis缓存失败时降级到方案DJOIN查询
理由Redis缓存最实时且无DB压力适合高频扫描场景。
实现逻辑:
1. 先从 Redis PILE_REAL_TIME_MONITOR_DATA:{connectorId} 获取数据
2. 若Redis中有数据直接判断connector在线状态
3. 若Redis中无数据或异常降级到JOIN pile_connector_info表查询
4. 记录降级日志便于监控Redis缓存命中率
```
---
### 6.4 "最后一条实时数据"的精确定义
**问题描述**
条件4要求"存在实时检测数据chargingDegree > 0",但**如何确定"最后一条"数据**
-`dateTime`排序?如果同一分钟有多条怎么办?
- 按主键ID排序
- Redis缓存里的是不是一定是最后一条
**影响**
- 排序规则不明确可能导致取错数据
- 影响结算金额准确性
**建议方案**
```sql
-- 方案A按时间+主键双重排序
SELECT * FROM order_monitor_data
WHERE order_id = ?
ORDER BY date_time DESC, id DESC
LIMIT 1;
-- 方案B直接从Redis取
String key = "PILE_REAL_TIME_MONITOR_DATA:" + connectorId;
RealTimeMonitorData data = redis.get(key);
-- 方案C取settle_time前的最后一条
SELECT * FROM order_monitor_data
WHERE order_id = ?
AND date_time <= NOW()
ORDER BY date_time DESC, id DESC
LIMIT 1;
```
**回答**
```
状态:✅ 已解决
决策采用方案B直接从Redis取
理由Redis中的 PILE_REAL_TIME_MONITOR_DATA 缓存已经是最新的实时数据,
按充电桩上报机制,每次上报都会更新该缓存为最新值。
实现:
String key = "PILE_REAL_TIME_MONITOR_DATA:" + connectorId;
RealTimeMonitorData data = redis.get(key);
备注若Redis获取失败可降级查询DB使用方案A按date_time DESC, id DESC排序
```
---
### 6.5 状态校验的原子性保证对应风险R3
**问题描述**
文档中提到"结算前校验订单状态未变更",但**校验→结算之间仍有并发窗口**,可能导致:
- 线程A校验通过 → 线程B同时校验通过 → 两者同时结算
**影响**
- 重复结算导致资金异常
- 数据一致性问题
**建议方案**
```sql
-- 方案AUPDATE语句带WHERE条件乐观锁
UPDATE order_basic_info
SET order_status = 'COMPLETED',
settle_time = NOW(),
...
WHERE order_id = ?
AND order_status = 'STAY_SETTLEMENT' -- 确保状态未变
AND settle_time IS NULL; -- 确保未结算过
-- 检查受影响行数
if (affectedRows == 0) {
log.warn("订单{}状态已变更,跳过结算", orderId);
return;
}
-- 方案BSELECT FOR UPDATE悲观锁
SELECT * FROM order_basic_info
WHERE order_id = ?
FOR UPDATE;
-- 然后再UPDATE
-- 方案C依赖redis分布式锁
if (!redisLock.tryLock("settle_order_" + orderId)) {
return;
}
try {
//
} finally {
redisLock.unlock();
}
```
**回答**
```
状态:✅ 已解决
决策采用方案AUPDATE语句带WHERE条件乐观锁
理由:乐观锁适合并发冲突较少的场景,性能优于悲观锁,且实现简单。
结合redis分布式锁6.1中的方案),形成双重保障。
实现:
UPDATE order_basic_info
SET order_status = 'COMPLETED',
settle_time = NOW(),
...
WHERE order_id = ?
AND order_status = 'STAY_SETTLEMENT' -- 确保状态未变
AND settle_time IS NULL; -- 确保未结算过
if (affectedRows == 0) {
log.warn("订单{}状态已变更,跳过结算", orderId);
return;
}
```
---
### 6.6 "桩假在线"场景的判断逻辑对应风险R4补充
**问题描述**
真实场景可能出现:
- 桩TCP连接还在connector_status ≠ 0
- 但实际已停止上报数据(最后实时数据很久没更新)
这种"假在线"状态下,实时数据不可信,不应该触发自动结算。
**影响**
- 可能使用过时数据结算
- 金额不准确
**建议方案**
```yaml
条件2补充为
- connector_status ≠ 0连接在线
AND
- 最后一条实时数据的dateTime距当前 < 30分钟数据新鲜度
```
**示例**
- 当前时间2026-08-11 14:00:00
- 最后实时数据时间2026-08-11 13:55:00 → 新鲜度5分钟 → 通过
- 最后实时数据时间2026-08-11 13:20:00 → 新鲜度40分钟 → 不通过(桩假在线)
**回答**
```
状态:✅ 已解决
决策增加数据新鲜度判断阈值设为30分钟
配置data-freshness-minutes: 30
理由30分钟是合理的阈值既能覆盖正常充电停止后的数据延迟
又能有效过滤"桩假在线"场景(连接在但不上报数据)。
实现条件:
条件2更新为
- connector_status ≠ 0连接在线
AND
- 最后一条实时数据的dateTime距当前 < 30分钟数据新鲜度
示例NOW() - dateTime < 30分钟
```
---
### 6.7 异常数据场景的处理策略
**问题描述**
实时数据可能出现异常情况:
- `chargingDegree > 0``chargingAmount = 0`(金额未计算)
- `chargingDegree = 0``chargingAmount > 0`(数据矛盾)
- `chargingDegree` 为负数(数据异常)
**影响**
- 异常数据直接结算会导致金额错误
- 需要明确拦截规则
**建议方案**
```java
// 数据校验规则
if (chargingDegree <= 0) {
log.warn("订单{}充电度数异常:{}", orderId, chargingDegree);
return; // 跳过
}
if (chargingAmount <= 0) {
log.warn("订单{}充电金额异常:{}", orderId, chargingAmount);
return; // 跳过
}
if (chargingAmount > payAmount * amountThresholdRatio) {
log.error("订单{}金额异常:实际{}元 > 预付{}元 * {}",
orderId, chargingAmount, payAmount, amountThresholdRatio);
// 发送告警
return; // 跳过
}
```
**回答**
```
状态:✅ 已解决
决策:采用建议的数据校验规则
实现:
// 1. 充电度数校验
if (chargingDegree <= 0) {
log.warn("订单{}充电度数异常:{}", orderId, chargingDegree);
return; // 跳过
}
// 2. 充电金额校验
if (chargingAmount <= 0) {
log.warn("订单{}充电金额异常:{}", orderId, chargingAmount);
return; // 跳过
}
// 3. 金额阈值校验结合6.2的决策)
if (chargingAmount > payAmount * amountThresholdRatio) {
log.error("订单{}金额异常:实际{}元 > 预付{}元 * {}",
orderId, chargingAmount, payAmount, amountThresholdRatio);
// 发送告警
return; // 跳过
}
补充场景:暂无其他需要拦截的异常场景,后续如发现新场景再补充。
```
---
### 6.8 配置项完整性补充
**问题描述**
当前配置项不完整,缺少以下关键配置:
**建议补充**
```yaml
auto-settle:
# 现有配置
enabled: false # 总开关
timeout-minutes: 10 # 停止超时阈值
interval: '0 */10 * * * ?' # 扫描周期Cron表达式
grayscale-station-ids: [1001,1002] # 灰度站点白名单
# 🆕 建议补充
amount-threshold-ratio: 1.5 # 金额异常阈值倍数对应6.2
batch-size: 100 # 单次扫描订单数量上限(防止一次查太多)
data-freshness-minutes: 30 # 实时数据新鲜度阈值对应6.6
alert-enabled: true # 告警开关
lock-timeout-seconds: 60 # redis锁超时时间
retry-on-failure: false # 失败是否重试建议false等下一轮
```
**回答**
```
状态:✅ 已解决
决策:采用建议的所有配置项
完整配置:
auto-settle:
# 基础配置
enabled: false # 总开关,默认关闭
timeout-minutes: 10 # 停止超时阈值
interval: '0 */10 * * * ?' # 扫描周期Cron表达式
grayscale-station-ids: [1001,1002] # 灰度站点白名单
# 新增配置
amount-threshold-ratio: 1.5 # 金额异常阈值倍数对应6.2
batch-size: 100 # 单次扫描订单数量上限
data-freshness-minutes: 30 # 实时数据新鲜度阈值对应6.6
alert-enabled: true # 告警开关
lock-timeout-seconds: 60 # redis锁超时时间
retry-on-failure: false # 失败是否重试建议false等下一轮
备注:所有配置项均已明确,可直接用于开发。
```
---
## 七、测试计划
### 7.1 基础功能测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 1 | 桩在线 + 有 chargeEndTime + 停止>10min + 有实时数据 | 自动结算成功,金额=实时数据 chargingAmount | P0 |
| 2 | 桩在线 + 无 chargeEndTime + 最后实时数据>10min + 有实时数据 | 兜底依据触发,自动结算 | P0 |
| 3 | 桩离线connector_status=0 | 不触发(条件 2 拦截) | P0 |
| 4 | 停止 < 10min | 不触发 | P1 |
| 5 | 停止 = 10min 0秒边界值 | 应触发(>= 10min | P1 |
| 6 | 无实时检测数据chargingDegree=0 | 不触发 | P1 |
| 7 | 配置开关关闭enabled=false | 保持现有行为(不自动结算) | P0 |
### 7.2 异常数据测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 8 | chargingAmount > payAmount * threshold异常值 | 跳过并告警,不结算 | P0 |
| 9 | chargingDegree > 0 但 chargingAmount = 0 | 跳过并告警(数据异常) | P1 |
| 10 | chargingDegree = 0 但 chargingAmount > 0 | 跳过并告警(数据矛盾) | P1 |
| 11 | chargingDegree 为负数 | 跳过并告警(数据异常) | P1 |
| 12 | chargingAmount < payAmount正常退款场景 | 正常结算并退款差额 | P0 |
### 7.3 并发与幂等测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 13 | 扫描期间交易记录帧到达(并发) | 仅一方结算成功,另一方被幂等拦截 | P0 |
| 14 | 多个定时任务实例同时扫描同一订单 | 仅一个实例处理成功(分布式锁生效) | P0 |
| 15 | 订单在校验后、结算前状态被其他线程修改 | 被原子性校验拦截,不重复结算 | P0 |
### 7.4 灰度与配置测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 16 | 灰度站点内的订单 | 触发自动结算 | P0 |
| 17 | 非灰度站点的订单 | 不触发(即使满足条件) | P0 |
| 18 | 混合:部分灰度、部分非灰度站点 | 仅灰度站点订单被处理 | P1 |
| 19 | batch-size限制如设为10实际符合20单 | 仅处理前10单下次扫描处理剩余 | P1 |
### 7.5 边界与容错测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 20 | 桩"假在线"(连接在线但数据>30分钟未更新 | 不触发(数据新鲜度检查拦截) | P0 |
| 21 | 数据库查询订单列表为空 | 正常退出,无异常 | P1 |
| 22 | Redis获取实时数据失败 | 降级查DB或跳过并记录日志 | P1 |
| 23 | 结算过程中DB连接超时 | 捕获异常,不影响其他订单处理 | P1 |
| 24 | 单个订单结算失败(业务异常) | 记录失败日志,继续处理其他订单 | P0 |
### 7.6 压力测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 25 | 1000个待结算订单同时满足条件 | batch-size分批处理不超时 | P2 |
| 26 | 定时任务执行时间 > 扫描周期(如>10min | 下一轮等待当前轮完成,或跳过 | P2 |
### 7.7 监控与告警测试
| # | 场景 | 预期 | 优先级 |
|---|------|------|--------|
| 27 | 金额异常告警触发 | ERROR日志输出告警系统收到通知 | P1 |
| 28 | 自动结算成功 | INFO日志记录订单号、金额、耗时 | P1 |
| 29 | 查看Prometheus指标 | 统计自动结算成功数、失败数、跳过数 | P2 |

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分钟前的数据应该是陈旧的";
}
}

View File

@@ -0,0 +1,95 @@
package com.jsowell.pile.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 无交易记录自动结算配置类
*
* @author jsowell
*/
@Data
@Component
@ConfigurationProperties(prefix = "auto-settle")
public class AutoSettleConfig {
/**
* 功能总开关
* 默认false关闭
*/
private Boolean enabled = false;
/**
* 停止超时阈值(分钟)
* 订单停止超过该时间才触发自动结算
* 默认10分钟
*/
private Integer timeoutMinutes = 10;
/**
* 定时任务执行间隔cron表达式
* 默认每10分钟执行一次
*/
private String interval = "0 */10 * * * ?";
/**
* 灰度站点ID列表
* 为空表示全量上线
*/
private List<Long> grayscaleStationIds;
/**
* 单次扫描订单数量上限
* 防止一次性处理过多订单
* 默认100
*/
private Integer batchSize = 100;
/**
* 异常金额阈值比例
* chargingAmount > payAmount * 该比例时告警跳过
* 默认1.5倍
*/
private Double amountThresholdRatio = 1.5;
/**
* 实时数据新鲜度阈值(分钟)
* 最后一条实时数据距当前时间超过该阈值则认为桩"假在线"
* 默认30分钟
*/
private Integer dataFreshnessMinutes = 30;
/**
* 告警开关
* 异常情况是否发送告警
* 默认true
*/
private Boolean alertEnabled = true;
/**
* Redis锁超时时间
* 防止分布式锁死锁
* 默认300秒5分钟
*/
private Long lockTimeout = 300L;
/**
* 是否启用灰度(根据灰度站点列表判断)
*/
public boolean isGrayscaleEnabled() {
return grayscaleStationIds != null && !grayscaleStationIds.isEmpty();
}
/**
* 判断站点是否在灰度范围内
*/
public boolean isStationInGrayscale(Long stationId) {
if (!isGrayscaleEnabled()) {
return true; // 未启用灰度,所有站点都算在范围内
}
return grayscaleStationIds.contains(stationId);
}
}

View File

@@ -31,6 +31,38 @@ public interface OrderBasicInfoMapper {
*/
int deleteByPrimaryKey(Integer id);
/**
* 查询待自动结算的订单列表
* 条件:
* 1. 订单状态为待结算
* 2. 无交易流水号transactionCode为空
* 3. 充电结束时间在指定时间之前stopTime < cutoffTime
* 4. 支付状态为已支付
* 5. 灰度站点范围内(如果指定)
*
* @param cutoffTime 截止时间(当前时间 - 超时阈值)
* @param stationIds 灰度站点ID列表为空则不限制站点
* @param limit 查询数量限制
* @return 待自动结算的订单列表
*/
List<OrderBasicInfo> selectPendingAutoSettleOrders(@Param("cutoffTime") LocalDateTime cutoffTime,
@Param("stationIds") List<Long> stationIds,
@Param("limit") int limit);
/**
* 使用乐观锁更新订单(用于自动结算)
*
* @param order 订单信息
* @param orderId 订单ID
* @param expectedStatus 期望的订单状态
* @param expectedSettlementTime 期望的结算时间null表示未结算
* @return 更新行数
*/
int updateOrderWithOptimisticLock(@Param("order") OrderBasicInfo order,
@Param("orderId") Integer orderId,
@Param("expectedStatus") String expectedStatus,
@Param("expectedSettlementTime") java.util.Date expectedSettlementTime);
/**
* insert record to table
*

View File

@@ -753,10 +753,16 @@ public interface OrderBasicInfoService{
List<String> merchantIdList, List<String> stationIdList);
/**
* 大数据平台-今日充电电量Java汇总
* 大数据平台-今日充电电量(Java汇总)
*
* @return 今日已完成订单的充电电量合计kWh
* @return 今日已完成订单的充电电量合计(kWh)
*/
java.math.BigDecimal getTodayElectricity(String startTime, String endTime,
List<String> merchantIdList, List<String> stationIdList);
/**
* 无交易记录自动结算
* 扫描停止超时的待结算订单,如果有实时数据但无交易记录,则自动结算
*/
void autoSettleOrdersWithoutTransactionRecord();
}

View File

@@ -213,6 +213,9 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService {
@Autowired
private com.jsowell.pile.mq.producer.PointsRewardProducer pointsRewardProducer;
@Autowired
private com.jsowell.pile.config.AutoSettleConfig autoSettleConfig;
@Override
public int deleteByPrimaryKey(Integer id) {
return orderBasicInfoMapper.deleteByPrimaryKey(id);
@@ -7435,4 +7438,327 @@ public class OrderBasicInfoServiceImpl implements OrderBasicInfoService {
}
return total;
}
@Override
public void autoSettleOrdersWithoutTransactionRecord() {
// 1. 检查功能总开关
if (!autoSettleConfig.getEnabled()) {
logger.debug("【无交易记录自动结算】功能未启用,跳过执行");
return;
}
logger.info("【无交易记录自动结算】开始执行,超时阈值:{}分钟,批次大小:{}",
autoSettleConfig.getTimeoutMinutes(), autoSettleConfig.getBatchSize());
// 2. 计算截止时间
LocalDateTime cutoffTime = LocalDateTime.now().minusMinutes(autoSettleConfig.getTimeoutMinutes());
// 3. 获取灰度站点列表(若启用灰度)
List<Long> grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled() ?
autoSettleConfig.getGrayscaleStationIds() : null;
// 4. 查询待结算订单
List<OrderBasicInfo> pendingOrders = orderBasicInfoMapper.selectPendingAutoSettleOrders(
cutoffTime, grayscaleStationIds, autoSettleConfig.getBatchSize());
if (pendingOrders == null || pendingOrders.isEmpty()) {
logger.info("【无交易记录自动结算】本次扫描未发现符合条件的订单");
return;
}
logger.info("【无交易记录自动结算】本次扫描到 {} 条待处理订单", pendingOrders.size());
int successCount = 0;
int skipCount = 0;
int failCount = 0;
// 5. 逐单处理
for (OrderBasicInfo order : pendingOrders) {
String lockKey = "settle_order_" + order.getId();
boolean lockAcquired = false;
try {
// 5.1 获取分布式锁
redisCache.setCacheObject(lockKey, "1",
autoSettleConfig.getLockTimeout().intValue(), TimeUnit.SECONDS);
// 尝试获取锁(检查是否成功设置)
String lockValue = redisCache.getCacheObject(lockKey);
lockAcquired = "1".equals(lockValue);
if (!lockAcquired) {
logger.warn("【无交易记录自动结算】订单 {} 正在被其他线程处理,跳过", order.getOrderCode());
skipCount++;
continue;
}
// 5.2 重新查询订单最新状态(防止并发问题 + 乐观锁)
OrderBasicInfo latestOrder = orderBasicInfoMapper.selectOrderBasicInfoById(Long.valueOf(order.getId()));
if (latestOrder == null || !"3".equals(latestOrder.getOrderStatus()) || latestOrder.getSettlementTime() != null) {
logger.warn("【无交易记录自动结算】订单 {} 状态已变更或已结算,跳过", order.getOrderCode());
skipCount++;
continue;
}
// 5.3 检查灰度范围
Long stationIdLong = latestOrder.getStationId() != null ? Long.parseLong(latestOrder.getStationId()) : null;
if (stationIdLong != null && !autoSettleConfig.isStationInGrayscale(stationIdLong)) {
logger.debug("【无交易记录自动结算】订单 {} 所属站点不在灰度范围内,跳过", order.getOrderCode());
skipCount++;
continue;
}
// 5.4 检查充电桩状态(从 Redis 获取实时状态)
String pileStatusKey = "pile_status_" + latestOrder.getPileSn();
Object pileStatusObj = redisCache.getCacheObject(pileStatusKey);
if (pileStatusObj != null && "0".equals(pileStatusObj.toString())) {
logger.warn("【无交易记录自动结算】订单 {} 对应充电桩 {} 当前离线,跳过",
order.getOrderCode(), latestOrder.getPileSn());
skipCount++;
continue;
}
// 5.5 获取实时数据OrderMonitorData不是RealTimeMonitorData
List<RealTimeMonitorData> realTimeDataList = getChargingRealTimeData(latestOrder.getOrderCode());
if (realTimeDataList == null || realTimeDataList.isEmpty()) {
logger.warn("【无交易记录自动结算】订单 {} 未找到实时数据,跳过", order.getOrderCode());
skipCount++;
continue;
}
// 取最后一条实时数据
RealTimeMonitorData lastRealTimeData = realTimeDataList.get(realTimeDataList.size() - 1);
// 5.6 检查数据新鲜度 - 暂时跳过因为RealTimeMonitorData没有createTime字段
// 使用dateTime字段判断
if (lastRealTimeData.getDateTime() != null) {
try {
LocalDateTime dataTime = LocalDateTime.parse(lastRealTimeData.getDateTime(),
java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
long minutesSinceLastData = java.time.Duration.between(dataTime, LocalDateTime.now()).toMinutes();
if (minutesSinceLastData > autoSettleConfig.getDataFreshnessMinutes()) {
logger.warn("【无交易记录自动结算】订单 {} 实时数据不新鲜({}分钟前),疑似桩假在线,跳过",
order.getOrderCode(), minutesSinceLastData);
skipCount++;
continue;
}
} catch (Exception e) {
logger.warn("【无交易记录自动结算】订单 {} 解析实时数据时间失败,跳过", order.getOrderCode(), e);
skipCount++;
continue;
}
}
// 5.7 构造结算数据(从实时数据转换为交易记录格式)
TransactionRecordsData settlementData = new TransactionRecordsData();
settlementData.setStartTime(com.jsowell.common.util.DateUtils.parseDateToStr(
com.jsowell.common.util.DateUtils.YYYY_MM_DD_HH_MM_SS,
latestOrder.getChargeStartTime()));
settlementData.setEndTime(com.jsowell.common.util.DateUtils.parseDateToStr(
com.jsowell.common.util.DateUtils.YYYY_MM_DD_HH_MM_SS,
latestOrder.getChargeEndTime()));
// 从实时数据中提取充电信息
String chargingDegree = lastRealTimeData.getChargingDegree();
String lossDegree = lastRealTimeData.getLossDegree();
String chargingAmount = lastRealTimeData.getChargingAmount();
// 数据校验检查充电电量是否为空或0
if (chargingDegree == null || chargingDegree.trim().isEmpty()) {
logger.warn("【无交易记录自动结算】订单 {} 充电电量为空,跳过", order.getOrderCode());
skipCount++;
continue;
}
try {
BigDecimal chargingDegreeDecimal = new BigDecimal(chargingDegree);
if (chargingDegreeDecimal.compareTo(BigDecimal.ZERO) <= 0) {
logger.warn("【无交易记录自动结算】订单 {} 充电电量为0或负数{}),跳过",
order.getOrderCode(), chargingDegree);
skipCount++;
continue;
}
} catch (NumberFormatException e) {
logger.error("【无交易记录自动结算】订单 {} 充电电量格式错误({}),跳过",
order.getOrderCode(), chargingDegree, e);
skipCount++;
continue;
}
// 数据校验:检查充电金额格式
if (chargingAmount == null || chargingAmount.trim().isEmpty()) {
logger.warn("【无交易记录自动结算】订单 {} 充电金额为空,跳过", order.getOrderCode());
skipCount++;
continue;
}
try {
new BigDecimal(chargingAmount);
} catch (NumberFormatException e) {
logger.error("【无交易记录自动结算】订单 {} 充电金额格式错误({}),跳过",
order.getOrderCode(), chargingAmount, e);
skipCount++;
continue;
}
// 设置总电量和消费金额
settlementData.setTotalElectricity(chargingDegree);
settlementData.setPlanLossTotalElectricity(lossDegree != null ? lossDegree : chargingDegree);
settlementData.setConsumptionAmount(chargingAmount);
// 将所有电量归为平段
settlementData.setFlatUsedElectricity(chargingDegree);
settlementData.setFlatPlanLossElectricity(lossDegree != null ? lossDegree : chargingDegree);
settlementData.setFlatAmount(chargingAmount);
// 其他时段设为0
settlementData.setSharpUsedElectricity("0.0000");
settlementData.setSharpAmount("0.0000");
settlementData.setPeakUsedElectricity("0.0000");
settlementData.setPeakAmount("0.0000");
settlementData.setValleyUsedElectricity("0.0000");
settlementData.setValleyAmount("0.0000");
// 停止原因
settlementData.setStopReasonMsg("无交易记录自动结算");
// 5.9 金额异常检测
BigDecimal chargingAmountDecimal = new BigDecimal(chargingAmount);
if (latestOrder.getPayAmount() != null) {
BigDecimal threshold = latestOrder.getPayAmount()
.multiply(BigDecimal.valueOf(autoSettleConfig.getAmountThresholdRatio()));
if (chargingAmountDecimal.compareTo(threshold) > 0) {
String alertMsg = String.format(
"【无交易记录自动结算-金额异常】订单号:%s站点ID%s充电桩%s" +
"实时充电金额:%.2f 元,已支付金额:%.2f 元,阈值倍数:%.1f" +
"充电电量:%s kWh充电时长%d 分钟",
order.getOrderCode(),
latestOrder.getStationId(),
latestOrder.getPileSn(),
chargingAmountDecimal,
latestOrder.getPayAmount(),
autoSettleConfig.getAmountThresholdRatio(),
chargingDegree,
java.time.Duration.between(
java.time.LocalDateTime.ofInstant(
latestOrder.getChargeStartTime().toInstant(),
java.time.ZoneId.systemDefault()),
java.time.LocalDateTime.ofInstant(
latestOrder.getChargeEndTime().toInstant(),
java.time.ZoneId.systemDefault())
).toMinutes()
);
logger.error(alertMsg);
if (autoSettleConfig.getAlertEnabled()) {
// TODO: 发送告警通知(对接现有告警系统)
// 可以在这里发送邮件、短信、钉钉、企业微信等告警
}
skipCount++;
continue;
}
}
// 5.10 执行结算(使用乐观锁)
logger.info("【无交易记录自动结算】开始结算订单:{},充电电量:{}kWh结算金额{}元",
order.getOrderCode(), chargingDegree, chargingAmount);
// 更新订单基础信息
returnUpdateOrderBasicInfo(latestOrder, settlementData);
// 更新订单明细
returnUpdateOrderDetail(latestOrder, settlementData);
// 使用 MyBatis 传统方式更新(带乐观锁条件)
OrderBasicInfo updateOrder = new OrderBasicInfo();
updateOrder.setId(latestOrder.getId());
updateOrder.setOrderStatus(latestOrder.getOrderStatus());
updateOrder.setOrderAmount(latestOrder.getOrderAmount());
updateOrder.setVirtualAmount(latestOrder.getVirtualAmount());
updateOrder.setSettleAmount(latestOrder.getSettleAmount());
updateOrder.setActualReceivedAmount(latestOrder.getActualReceivedAmount());
updateOrder.setReason(latestOrder.getReason());
updateOrder.setSettlementTime(latestOrder.getSettlementTime());
updateOrder.setRefundAmount(latestOrder.getRefundAmount());
int updateCount = orderBasicInfoMapper.updateOrderWithOptimisticLock(
updateOrder,
latestOrder.getId(),
"3", // 必须是待结算状态
null); // 结算时间必须为null
if (updateCount == 0) {
logger.warn("【无交易记录自动结算】订单 {} 乐观锁更新失败,可能已被其他线程结算,跳过", order.getOrderCode());
skipCount++;
continue;
}
// 5.11 处理退款(如果结算金额 < 已支付金额)
BigDecimal consumptionAmountDecimal = new BigDecimal(settlementData.getConsumptionAmount());
if (consumptionAmountDecimal != null && latestOrder.getPayAmount() != null) {
BigDecimal refundAmount = latestOrder.getPayAmount().subtract(consumptionAmountDecimal);
if (refundAmount.compareTo(BigDecimal.ZERO) > 0) {
logger.info("【无交易记录自动结算】订单 {} 需退款:{}元", order.getOrderCode(), refundAmount);
// 调用退款逻辑(与正常结算流程一致)
handleAutoSettleRefund(latestOrder, refundAmount);
}
}
successCount++;
logger.info("【无交易记录自动结算】订单 {} 结算成功", order.getOrderCode());
} catch (Exception e) {
failCount++;
String errorMsg = String.format(
"【无交易记录自动结算-处理异常】订单号:%s站点ID%s充电桩%s异常信息%s",
order.getOrderCode(),
order.getStationId(),
order.getPileSn(),
e.getMessage()
);
logger.error(errorMsg, e);
if (autoSettleConfig.getAlertEnabled()) {
// TODO: 发送告警通知
// 建议包含异常堆栈信息前3行方便快速定位问题
}
} finally {
// 5.12 释放分布式锁
if (lockAcquired) {
redisCache.deleteObject(lockKey);
}
}
}
logger.info("【无交易记录自动结算】执行完成,成功:{},跳过:{},失败:{}",
successCount, skipCount, failCount);
}
/**
* 处理自动结算的退款
*/
private void handleAutoSettleRefund(OrderBasicInfo order, BigDecimal refundAmount) {
try {
// 根据支付方式调用不同的退款接口
String payMode = order.getPayMode();
if ("1".equals(payMode)) {
// 微信支付退款
// TODO: 调用微信退款接口
logger.info("【无交易记录自动结算】订单 {} 微信退款:{}元", order.getOrderCode(), refundAmount);
} else if ("2".equals(payMode)) {
// 支付宝退款
// TODO: 调用支付宝退款接口
logger.info("【无交易记录自动结算】订单 {} 支付宝退款:{}元", order.getOrderCode(), refundAmount);
} else if ("3".equals(payMode)) {
// 汇付支付退款
// TODO: 调用汇付退款接口
logger.info("【无交易记录自动结算】订单 {} 汇付支付退款:{}元", order.getOrderCode(), refundAmount);
} else if ("4".equals(payMode)) {
// 余额支付退款(直接返还会员钱包)
logger.info("【无交易记录自动结算】订单 {} 余额退款:{}元", order.getOrderCode(), refundAmount);
// TODO: 调用钱包退款接口
}
} catch (Exception e) {
logger.error("【无交易记录自动结算】订单 {} 退款处理失败:{}元", order.getOrderCode(), refundAmount, e);
throw new RuntimeException("退款处理失败", e);
}
}
}

View File

@@ -0,0 +1,200 @@
package com.jsowell.pile.util;
import com.jsowell.common.core.domain.ykc.RealTimeMonitorData;
import com.jsowell.common.core.domain.ykc.TransactionRecordsData;
import com.jsowell.common.util.DateUtils;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 结算数据转换工具类
* 用于将实时监测数据转换为交易记录数据
*
* @author jsowell
*/
public class SettlementDataConverter {
private static final Logger logger = LoggerFactory.getLogger(SettlementDataConverter.class);
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
/**
* 将实时监测数据转换为结算用的交易记录数据
*
* @param realTimeData 最后一条实时监测数据
* @param startTime 订单开始时间
* @return 交易记录数据
*/
public static TransactionRecordsData convertToTransactionData(RealTimeMonitorData realTimeData, LocalDateTime startTime) {
if (realTimeData == null) {
logger.warn("实时监测数据为空,无法转换为交易记录");
return null;
}
if (StringUtils.isBlank(realTimeData.getTransactionCode())) {
logger.warn("实时监测数据缺少交易流水号,无法转换");
return null;
}
logger.info("开始转换实时数据为结算数据, transactionCode: {}, chargingDegree: {}, chargingAmount: {}",
realTimeData.getTransactionCode(),
realTimeData.getChargingDegree(),
realTimeData.getChargingAmount());
TransactionRecordsData transactionData = new TransactionRecordsData();
// 基础信息
transactionData.setTransactionCode(realTimeData.getTransactionCode());
transactionData.setPileSn(realTimeData.getPileSn());
transactionData.setConnectorCode(realTimeData.getConnectorCode());
// 时间信息
String startTimeStr = startTime != null ? startTime.format(FORMATTER) : null;
String endTimeStr = StringUtils.isNotBlank(realTimeData.getDateTime())
? realTimeData.getDateTime()
: LocalDateTime.now().format(FORMATTER);
transactionData.setStartTime(startTimeStr);
transactionData.setEndTime(endTimeStr);
transactionData.setTransactionTime(endTimeStr);
// 电量和金额信息(使用实时数据的累计值)
String chargingDegree = defaultIfBlank(realTimeData.getChargingDegree(), "0.0000");
String lossDegree = defaultIfBlank(realTimeData.getLossDegree(), chargingDegree);
String chargingAmount = defaultIfBlank(realTimeData.getChargingAmount(), "0.0000");
// 设置总电量(不区分时段,全部归为平段)
transactionData.setTotalElectricity(chargingDegree);
transactionData.setPlanLossTotalElectricity(lossDegree);
transactionData.setConsumptionAmount(chargingAmount);
// 平段数据(将所有数据归为平段)
transactionData.setFlatUsedElectricity(chargingDegree);
transactionData.setFlatPlanLossElectricity(lossDegree);
transactionData.setFlatAmount(chargingAmount);
// 计算平段单价(避免除零)
if (isPositive(lossDegree) && isPositive(chargingAmount)) {
BigDecimal price = new BigDecimal(chargingAmount).divide(new BigDecimal(lossDegree), 5, BigDecimal.ROUND_HALF_UP);
transactionData.setFlatPrice(price.toPlainString());
} else {
transactionData.setFlatPrice("0.00000");
}
// 其他时段设为0
transactionData.setSharpUsedElectricity("0.0000");
transactionData.setSharpPlanLossElectricity("0.0000");
transactionData.setSharpAmount("0.0000");
transactionData.setSharpPrice("0.00000");
transactionData.setPeakUsedElectricity("0.0000");
transactionData.setPeakPlanLossElectricity("0.0000");
transactionData.setPeakAmount("0.0000");
transactionData.setPeakPrice("0.00000");
transactionData.setValleyUsedElectricity("0.0000");
transactionData.setValleyPlanLossElectricity("0.0000");
transactionData.setValleyAmount("0.0000");
transactionData.setValleyPrice("0.00000");
// 电表读数(无实际数据)
transactionData.setAmmeterTotalStart("0.0000");
transactionData.setAmmeterTotalEnd(chargingDegree);
// 停止原因:无交易记录自动结算
transactionData.setStopReasonCode("0xFF");
transactionData.setStopReasonMsg("无交易记录自动结算");
// 交易标识(未知)
transactionData.setTransactionIdentifier("0x00");
// VIN码和物理卡号
transactionData.setVinCode("");
transactionData.setLogicCard("");
// 电费和服务费(无法拆分,设为空由业务层处理)
transactionData.setTotalElectricityAmount(null);
transactionData.setTotalServiceAmount(null);
logger.info("实时数据转换为结算数据完成, transactionCode: {}, totalElectricity: {}, consumptionAmount: {}",
transactionData.getTransactionCode(),
transactionData.getTotalElectricity(),
transactionData.getConsumptionAmount());
return transactionData;
}
/**
* 返回默认值(如果为空)
*/
private static String defaultIfBlank(String value, String defaultValue) {
return StringUtils.isNotBlank(value) ? value : defaultValue;
}
/**
* 判断字符串数值是否为正数
*/
private static boolean isPositive(String value) {
if (StringUtils.isBlank(value)) {
return false;
}
try {
return new BigDecimal(value).compareTo(BigDecimal.ZERO) > 0;
} catch (Exception e) {
return false;
}
}
/**
* 校验实时数据是否有效(用于自动结算前的数据校验)
*
* @param realTimeData 实时监测数据
* @return 是否有效
*/
public static boolean isValidForSettlement(RealTimeMonitorData realTimeData) {
if (realTimeData == null) {
logger.warn("实时数据为空");
return false;
}
if (StringUtils.isBlank(realTimeData.getTransactionCode())) {
logger.warn("实时数据缺少交易流水号");
return false;
}
// 充电度数必须大于0
String chargingDegree = realTimeData.getChargingDegree();
if (StringUtils.isBlank(chargingDegree) || !isPositive(chargingDegree)) {
logger.warn("充电度数无效, transactionCode: {}, chargingDegree: {}",
realTimeData.getTransactionCode(), chargingDegree);
return false;
}
// 充电金额必须 >= 0可以为0如白名单免费
String chargingAmount = realTimeData.getChargingAmount();
if (StringUtils.isBlank(chargingAmount)) {
logger.warn("充电金额为空, transactionCode: {}", realTimeData.getTransactionCode());
return false;
}
try {
BigDecimal amount = new BigDecimal(chargingAmount);
if (amount.compareTo(BigDecimal.ZERO) < 0) {
logger.warn("充电金额为负数, transactionCode: {}, chargingAmount: {}",
realTimeData.getTransactionCode(), chargingAmount);
return false;
}
} catch (Exception e) {
logger.warn("充电金额格式错误, transactionCode: {}, chargingAmount: {}",
realTimeData.getTransactionCode(), chargingAmount, e);
return false;
}
return true;
}
}

View File

@@ -3769,4 +3769,50 @@
</foreach>
</if>
</select>
<!-- 查询待自动结算的订单列表(无交易记录自动结算) -->
<select id="selectPendingAutoSettleOrders" resultMap="BaseResultMap">
SELECT
<include refid="Base_Column_List"/>
FROM order_basic_info
WHERE del_flag = '0'
AND order_status = '3' -- 待结算
AND pay_status = '1' -- 已支付
AND (transaction_code IS NULL OR transaction_code = '') -- 无交易流水号
AND charge_end_time IS NOT NULL
AND charge_end_time <![CDATA[ < ]]> #{cutoffTime} -- 停止超过阈值时间
<if test="stationIds != null and stationIds.size() > 0">
AND station_id IN
<foreach collection="stationIds" item="stationId" open="(" separator="," close=")">
#{stationId}
</foreach>
</if>
ORDER BY charge_end_time ASC
<if test="limit > 0">
LIMIT #{limit}
</if>
</select>
<!-- 使用乐观锁更新订单(用于自动结算) -->
<update id="updateOrderWithOptimisticLock">
UPDATE order_basic_info
SET order_status = #{order.orderStatus},
order_amount = #{order.orderAmount},
virtual_amount = #{order.virtualAmount},
settle_amount = #{order.settleAmount},
actual_received_amount = #{order.actualReceivedAmount},
reason = #{order.reason},
settlement_time = #{order.settlementTime},
refund_amount = #{order.refundAmount}
WHERE id = #{orderId}
AND order_status = #{expectedStatus}
<choose>
<when test="expectedSettlementTime == null">
AND settlement_time IS NULL
</when>
<otherwise>
AND settlement_time = #{expectedSettlementTime}
</otherwise>
</choose>
</update>
</mapper>

View File

@@ -588,4 +588,22 @@ public class JsowellTask {
orderBasicInfoService.updateOrderReviewFlagTemp(start, end, null, null);
}
/**
* 无交易记录自动结算任务
* 扫描停止超10分钟的待结算订单如果有实时数据但无交易记录则自动结算
* jsowellTask.autoSettleOrdersWithoutTransactionRecord()
*/
public void autoSettleOrdersWithoutTransactionRecord() {
if (skipInPre("无交易记录自动结算")) {
return;
}
log.info("【无交易记录自动结算】定时任务开始执行");
try {
orderBasicInfoService.autoSettleOrdersWithoutTransactionRecord();
log.info("【无交易记录自动结算】定时任务执行完成");
} catch (Exception e) {
log.error("【无交易记录自动结算】定时任务执行失败", e);
}
}
}