diff --git a/docs/feature-tracker-无交易记录自动结算.md b/docs/feature-tracker-无交易记录自动结算.md index acbcba0eb..f2d8dd047 100644 --- a/docs/feature-tracker-无交易记录自动结算.md +++ b/docs/feature-tracker-无交易记录自动结算.md @@ -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 | 配置开关关闭 | 保持现有行为(不自动结算) | \ No newline at end of file +> **状态说明**:`⬜ 待回答` / `✅ 已解决` / `🔴 阻塞中` + +### 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查询(当前方案) | 实现简单 | 性能问题 | 最低 | + +**回答**: +``` +状态:✅ 已解决 + +决策:优先使用方案C(Redis缓存),失败时降级到方案D(JOIN查询) +理由: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 +-- 方案A:UPDATE语句带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; +} + +-- 方案B:SELECT 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(); +} +``` + +**回答**: +``` +状态:✅ 已解决 + +决策:采用方案A(UPDATE语句带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 | \ No newline at end of file diff --git a/jsowell-admin/src/main/resources/application.yml b/jsowell-admin/src/main/resources/application.yml index 65159b138..d28c8cc5e 100644 --- a/jsowell-admin/src/main/resources/application.yml +++ b/jsowell-admin/src/main/resources/application.yml @@ -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 + diff --git a/jsowell-admin/src/test/java/com/jsowell/AutoSettleOrdersWithoutTransactionTest.java b/jsowell-admin/src/test/java/com/jsowell/AutoSettleOrdersWithoutTransactionTest.java new file mode 100644 index 000000000..83e2593d6 --- /dev/null +++ b/jsowell-admin/src/test/java/com/jsowell/AutoSettleOrdersWithoutTransactionTest.java @@ -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 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 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分钟前的数据应该是陈旧的"; + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java b/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java new file mode 100644 index 000000000..55a480874 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/config/AutoSettleConfig.java @@ -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 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); + } +} diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java index d028e4d87..8f6472638 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/mapper/OrderBasicInfoMapper.java @@ -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 selectPendingAutoSettleOrders(@Param("cutoffTime") LocalDateTime cutoffTime, + @Param("stationIds") List 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 * diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java index 150bfddb5..40689830b 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/OrderBasicInfoService.java @@ -753,10 +753,16 @@ public interface OrderBasicInfoService{ List merchantIdList, List stationIdList); /** - * 大数据平台-今日充电电量(Java汇总) + * 大数据平台-今日充电电量(Java汇总) * - * @return 今日已完成订单的充电电量合计(kWh) + * @return 今日已完成订单的充电电量合计(kWh) */ java.math.BigDecimal getTodayElectricity(String startTime, String endTime, List merchantIdList, List stationIdList); + + /** + * 无交易记录自动结算 + * 扫描停止超时的待结算订单,如果有实时数据但无交易记录,则自动结算 + */ + void autoSettleOrdersWithoutTransactionRecord(); } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java index c28a33cc4..45dd649a4 100644 --- a/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java +++ b/jsowell-pile/src/main/java/com/jsowell/pile/service/impl/OrderBasicInfoServiceImpl.java @@ -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 grayscaleStationIds = autoSettleConfig.isGrayscaleEnabled() ? + autoSettleConfig.getGrayscaleStationIds() : null; + + // 4. 查询待结算订单 + List 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 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); + } + } } diff --git a/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java b/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java new file mode 100644 index 000000000..480d45d83 --- /dev/null +++ b/jsowell-pile/src/main/java/com/jsowell/pile/util/SettlementDataConverter.java @@ -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; + } +} diff --git a/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml b/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml index 3669f9eeb..31e493a09 100644 --- a/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml +++ b/jsowell-pile/src/main/resources/mapper/pile/OrderBasicInfoMapper.xml @@ -3769,4 +3769,50 @@ + + + + + + + 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} + + + AND settlement_time IS NULL + + + AND settlement_time = #{expectedSettlementTime} + + + diff --git a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java index fec2aabf9..98119e136 100644 --- a/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java +++ b/jsowell-quartz/src/main/java/com/jsowell/quartz/task/JsowellTask.java @@ -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); + } + } + }