puzhibing
4 天以前 a0e03b646f2c444b0f09687d24e9995112b99017
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
package com.ruoyi.account.service.impl;
 
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.dto.CompanyAllocateRecordQueryDTO;
import com.ruoyi.account.api.model.TAppUserWhitelist;
import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.account.mapper.TAppUserWhitelistMapper;
import com.ruoyi.account.mapper.TChargingCardMapper;
import com.ruoyi.account.mapper.TCompanyAllocateRecordMapper;
import com.ruoyi.account.mapper.TVinManagementMapper;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.security.utils.SecurityUtils;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
 
/**
 * 公司额度分配记录服务实现:对外提供分页查询与统一流水写入。
 * 写入方法被 VIN码/卡片/白名单的额度变更流程调用,与该次余额变更保持同一事务(或紧随其后的落库顺序)。
 */
@Service
public class TCompanyAllocateRecordServiceImpl
        extends ServiceImpl<TCompanyAllocateRecordMapper, TCompanyAllocateRecord>
        implements TCompanyAllocateRecordService {
 
    private static final Logger log = LoggerFactory.getLogger(TCompanyAllocateRecordServiceImpl.class);
 
    /** user_info / user_name 列长度(varchar(64)) */
    private static final int MAX_USER_TEXT_LENGTH = 64;
    /** remark 列长度(varchar(255)) */
    private static final int MAX_REMARK_LENGTH = 255;
    /** charging_order_code 列长度(varchar(64)) */
    private static final int MAX_ORDER_CODE_LENGTH = 64;
 
    /** 模式切换回收锁:与额度分配同前缀、按公司串行,防止切换与分配并发导致资金不一致 */
    private static final String RECYCLE_LOCK_PREFIX_VIN = "VIN_QUOTA:COMPANY:";
    private static final String RECYCLE_LOCK_PREFIX_CARD = "CHARGING_CARD_QUOTA:COMPANY:";
    private static final String RECYCLE_LOCK_PREFIX_WHITELIST = "APP_USER_WHITELIST_QUOTA:COMPANY:";
    /** 获取锁最长等待时间(秒) */
    private static final long LOCK_WAIT_SECONDS = 5L;
    /** 锁自动释放时间(秒),兜住异常未解锁的场景 */
    private static final long LOCK_LEASE_SECONDS = 30L;
 
    @Resource
    private RedissonClient redissonClient;
 
    @Resource
    private TVinManagementMapper vinManagementMapper;
 
    @Resource
    private TChargingCardMapper chargingCardMapper;
 
    @Resource
    private TAppUserWhitelistMapper appUserWhitelistMapper;
 
    @Override
    public IPage<TCompanyAllocateRecord> selectRecordPage(CompanyAllocateRecordQueryDTO query) {
        CompanyAllocateRecordQueryDTO safe = query == null ? new CompanyAllocateRecordQueryDTO() : query;
        long pageNum = safe.getPageCurr() == null || safe.getPageCurr() < 1 ? 1 : safe.getPageCurr();
        long pageSize = safe.getPageSize() == null || safe.getPageSize() < 1 ? 10 : safe.getPageSize();
        return baseMapper.selectRecordPage(new Page<>(pageNum, pageSize), safe);
    }
 
    @Override
    public boolean recordAllocate(int userType, String userInfo, String userName, Integer companyId, int changeSource,
                                  BigDecimal beforeBalance, BigDecimal amount, String remark) {
        BigDecimal change = normalizeMoney(amount);
        if (companyId == null || change.signum() == 0) {
            return false;
        }
        BigDecimal before = normalizeMoney(beforeBalance);
        TCompanyAllocateRecord record = new TCompanyAllocateRecord();
        record.setCompanyId(companyId);
        record.setUserType(userType);
        record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH));
        record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH));
        record.setChangeSource(changeSource);
        record.setBeforeBalance(before);
        record.setAmount(change);
        record.setAfterBalance(before.add(change));
        record.setOperatorId(currentUserId());
        record.setOperatorName(currentUsername());
        record.setOperateTime(LocalDateTime.now());
        record.setRemark(truncate(remark, MAX_REMARK_LENGTH));
        record.setCreateTime(record.getOperateTime());
        return save(record);
    }
 
    @Override
    public boolean recordConsume(Integer companyId, Integer userType, String userInfo, String userName,
                                 BigDecimal beforeBalance, BigDecimal amount, String chargingOrderCode, String remark) {
        return doRecordConsume(companyId, userType, userInfo, userName, beforeBalance, amount, chargingOrderCode,
                remark, false);
    }
 
    @Override
    public boolean recordSettleConsume(Integer companyId, Integer userType, String userInfo, String userName,
                                       BigDecimal beforeBalance, BigDecimal amount, String chargingOrderCode,
                                       String remark) {
        return doRecordConsume(companyId, userType, userInfo, userName, beforeBalance, amount, chargingOrderCode,
                remark, true);
    }
 
    @Override
    public boolean existsConsumeRecord(String chargingOrderCode) {
        if (StringUtils.isBlank(chargingOrderCode)) {
            return false;
        }
        String orderCode = truncate(chargingOrderCode, MAX_ORDER_CODE_LENGTH);
        TCompanyAllocateRecord exists = lambdaQuery()
                .eq(TCompanyAllocateRecord::getChargingOrderCode, orderCode)
                .eq(TCompanyAllocateRecord::getChangeType, TCompanyAllocateRecord.CHANGE_TYPE_CONSUME)
                .last("limit 1")
                .one();
        return exists != null;
    }
 
    @Override
    public java.util.List<String> listConsumedOrderCodes(java.util.List<String> orderCodes) {
        if (orderCodes == null || orderCodes.isEmpty()) {
            return new java.util.ArrayList<>();
        }
        java.util.List<String> codes = new java.util.ArrayList<>();
        for (String orderCode : orderCodes) {
            if (StringUtils.isNotBlank(orderCode)) {
                String code = truncate(orderCode, MAX_ORDER_CODE_LENGTH);
                if (!codes.contains(code)) {
                    codes.add(code);
                }
            }
        }
        if (codes.isEmpty()) {
            return new java.util.ArrayList<>();
        }
        java.util.List<TCompanyAllocateRecord> records = lambdaQuery()
                .select(TCompanyAllocateRecord::getChargingOrderCode)
                .eq(TCompanyAllocateRecord::getChangeType, TCompanyAllocateRecord.CHANGE_TYPE_CONSUME)
                .in(TCompanyAllocateRecord::getChargingOrderCode, codes)
                .list();
        java.util.List<String> consumed = new java.util.ArrayList<>();
        if (records != null) {
            for (TCompanyAllocateRecord record : records) {
                if (record != null && StringUtils.isNotBlank(record.getChargingOrderCode())
                        && !consumed.contains(record.getChargingOrderCode())) {
                    consumed.add(record.getChargingOrderCode());
                }
            }
        }
        return consumed;
    }
 
    @Override
    public CompanyAllocateSummaryVO sumAllocatedBalance(Integer companyId) {
        CompanyAllocateSummaryVO summary = new CompanyAllocateSummaryVO();
        summary.setCompanyId(companyId);
        BigDecimal totalBalance = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        BigDecimal totalLocked = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        int balanceUserCount = 0;
        if (companyId == null) {
            return summary;
        }
        // VIN码(delFlag 逻辑删除由 @TableLogic 自动过滤)
        List<TVinManagement> vins = vinManagementMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
                        .eq(TVinManagement::getCompanyId, companyId)
                        .gt(TVinManagement::getBalance, BigDecimal.ZERO));
        if (vins != null) {
            for (TVinManagement vin : vins) {
                totalBalance = totalBalance.add(normalizeMoney(vin.getBalance()));
                totalLocked = totalLocked.add(normalizeMoney(vin.getLockedAmount()));
                balanceUserCount++;
            }
        }
        // 刷卡卡
        List<TChargingCard> cards = chargingCardMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
                        .eq(TChargingCard::getCompanyId, companyId)
                        .gt(TChargingCard::getBalance, BigDecimal.ZERO));
        if (cards != null) {
            for (TChargingCard card : cards) {
                totalBalance = totalBalance.add(normalizeMoney(card.getBalance()));
                totalLocked = totalLocked.add(normalizeMoney(card.getLockedAmount()));
                balanceUserCount++;
            }
        }
        // 白名单手机号
        List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
                        .eq(TAppUserWhitelist::getCompanyId, companyId)
                        .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
        if (whitelists != null) {
            for (TAppUserWhitelist whitelist : whitelists) {
                totalBalance = totalBalance.add(normalizeMoney(whitelist.getBalance()));
                totalLocked = totalLocked.add(normalizeMoney(whitelist.getLockedAmount()));
                balanceUserCount++;
            }
        }
        summary.setTotalBalance(totalBalance);
        summary.setTotalLocked(totalLocked);
        summary.setBalanceUserCount(balanceUserCount);
        return summary;
    }
 
    @Override
    @Transactional(rollbackFor = Exception.class)
    public BigDecimal recycleAllocatedBalance(Integer companyId, String operatorName, String companyName,
                                              BigDecimal companyBalanceBefore) {
        if (companyId == null) {
            throw new ServiceException("公司ID不能为空");
        }
        String operator = truncate(operatorName, MAX_USER_TEXT_LENGTH);
        // 按固定顺序抢占三把公司级锁(与三类额度分配操作互斥,顺序固定避免死锁)
        RLock vinLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_VIN + companyId);
        RLock cardLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_CARD + companyId);
        RLock whitelistLock = redissonClient.getLock(RECYCLE_LOCK_PREFIX_WHITELIST + companyId);
        boolean vinLocked = tryRecycleLock(vinLock);
        boolean cardLocked = false;
        boolean whitelistLocked = false;
        try {
            cardLocked = tryRecycleLock(cardLock);
            try {
                whitelistLocked = tryRecycleLock(whitelistLock);
                return doRecycleAllocatedBalance(companyId, operator, companyName, companyBalanceBefore);
            } finally {
                unlockRecycle(whitelistLock, whitelistLocked);
            }
        } finally {
            unlockRecycle(cardLock, cardLocked);
            unlockRecycle(vinLock, vinLocked);
        }
    }
 
    /**
     * 锁内回收实现:清零三类主体余额并写流水(与余额清零同事务,异常整体回滚),返回实际回收总额。
     */
    private BigDecimal doRecycleAllocatedBalance(Integer companyId, String operator, String companyName,
                                                 BigDecimal companyBalanceBefore) {
        Long operatorId = currentUserId();
        BigDecimal total = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
        String remark = "账户模式切换为共享模式,回收分配余额至公司账户";
        // VIN码
        List<TVinManagement> vins = vinManagementMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TVinManagement>()
                        .eq(TVinManagement::getCompanyId, companyId)
                        .gt(TVinManagement::getBalance, BigDecimal.ZERO));
        if (vins != null) {
            for (TVinManagement vin : vins) {
                BigDecimal before = normalizeMoney(vin.getBalance());
                if (vinManagementMapper.clearBalance(vin.getId(), operatorId) == 1) {
                    total = total.add(before);
                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_VIN, vin.getVin(),
                            vin.getPlateNumber(), operator, operatorId, before, before.negate(), remark);
                }
            }
        }
        // 刷卡卡
        List<TChargingCard> cards = chargingCardMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TChargingCard>()
                        .eq(TChargingCard::getCompanyId, companyId)
                        .gt(TChargingCard::getBalance, BigDecimal.ZERO));
        if (cards != null) {
            for (TChargingCard card : cards) {
                BigDecimal before = normalizeMoney(card.getBalance());
                if (chargingCardMapper.clearBalance(card.getId(), operatorId) == 1) {
                    total = total.add(before);
                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_CARD, card.getCardNo(),
                            null, operator, operatorId, before, before.negate(), remark);
                }
            }
        }
        // 白名单手机号
        List<TAppUserWhitelist> whitelists = appUserWhitelistMapper.selectList(
                new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<TAppUserWhitelist>()
                        .eq(TAppUserWhitelist::getCompanyId, companyId)
                        .gt(TAppUserWhitelist::getBalance, BigDecimal.ZERO));
        if (whitelists != null) {
            for (TAppUserWhitelist whitelist : whitelists) {
                BigDecimal before = normalizeMoney(whitelist.getBalance());
                if (appUserWhitelistMapper.clearBalance(whitelist.getId(), operator) == 1) {
                    total = total.add(before);
                    recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_WHITELIST,
                            whitelist.getPhone(), whitelist.getName(), operator, operatorId, before,
                            before.negate(), remark);
                }
            }
        }
        // 企业账户正数流水:公司余额加回留痕(与用户负流水同事务)
        if (total.signum() > 0) {
            String companyInfo = StringUtils.isNotBlank(companyName) ? companyName : "企业账户";
            recordModeSwitchRecord(companyId, TCompanyAllocateRecord.USER_TYPE_COMPANY, companyInfo, null,
                    operator, operatorId, normalizeMoney(companyBalanceBefore), total, remark);
        }
        return total;
    }
 
    /** 写入一条模式切换回退流水(change_source=4),写入失败抛出异常由事务回滚保证与余额清零一致 */
    private void recordModeSwitchRecord(Integer companyId, int userType, String userInfo, String userName,
                                        String operator, Long operatorId, BigDecimal beforeBalance,
                                        BigDecimal amount, String remark) {
        BigDecimal before = normalizeMoney(beforeBalance);
        BigDecimal change = normalizeMoney(amount);
        TCompanyAllocateRecord record = new TCompanyAllocateRecord();
        record.setCompanyId(companyId);
        record.setUserType(userType);
        record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH));
        record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH));
        record.setChangeSource(TCompanyAllocateRecord.SOURCE_MODE_SWITCH);
        record.setChangeType(TCompanyAllocateRecord.CHANGE_TYPE_ALLOCATE);
        record.setBeforeBalance(before);
        record.setAmount(change);
        record.setAfterBalance(before.add(change));
        record.setOperatorId(operatorId);
        record.setOperatorName(operator);
        record.setOperateTime(LocalDateTime.now());
        record.setRemark(truncate(remark, MAX_REMARK_LENGTH));
        record.setCreateTime(record.getOperateTime());
        if (!save(record)) {
            throw new ServiceException("模式切换回退流水写入失败");
        }
    }
 
    /** 抢占回收并发锁,抢占失败抛出业务异常 */
    private boolean tryRecycleLock(RLock lock) {
        try {
            if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) {
                throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
            }
            return true;
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new ServiceException("公司额度变更操作正在处理中,请稍后重试");
        }
    }
 
    /** 释放回收并发锁(仅释放当前线程持有的锁,异常仅记录日志) */
    private void unlockRecycle(RLock lock, boolean locked) {
        if (!locked) {
            return;
        }
        try {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        } catch (Exception ex) {
            log.warn("模式切换回收锁释放失败,lockName={}", lock.getName(), ex);
        }
    }
 
    /**
     * 消费流水写入公共实现
     *
     * @param allowZeroAmount 是否允许 0 金额写流水(结算幂等标记场景为 true)
     */
    private boolean doRecordConsume(Integer companyId, Integer userType, String userInfo, String userName,
                                    BigDecimal beforeBalance, BigDecimal amount, String chargingOrderCode,
                                    String remark, boolean allowZeroAmount) {
        if (companyId == null || userType == null || StringUtils.isBlank(userInfo)
                || StringUtils.isBlank(chargingOrderCode) || amount == null
                || (!allowZeroAmount && amount.signum() == 0)) {
            log.warn("消费流水参数不完整,跳过写入:companyId={}, userType={}, userInfo={}, orderCode={}",
                    companyId, userType, userInfo, chargingOrderCode);
            return false;
        }
        String orderCode = truncate(chargingOrderCode, MAX_ORDER_CODE_LENGTH);
        TCompanyAllocateRecord exists = lambdaQuery()
                .eq(TCompanyAllocateRecord::getChargingOrderCode, orderCode)
                .eq(TCompanyAllocateRecord::getChangeType, TCompanyAllocateRecord.CHANGE_TYPE_CONSUME)
                .last("limit 1")
                .one();
        if (exists != null) {
            return true;
        }
        BigDecimal change = normalizeMoney(amount);
        BigDecimal before = normalizeMoney(beforeBalance);
        TCompanyAllocateRecord record = new TCompanyAllocateRecord();
        record.setCompanyId(companyId);
        record.setUserType(userType);
        record.setUserInfo(truncate(userInfo, MAX_USER_TEXT_LENGTH));
        record.setUserName(truncate(userName, MAX_USER_TEXT_LENGTH));
        record.setChangeSource(TCompanyAllocateRecord.SOURCE_ALLOCATE);
        record.setChangeType(TCompanyAllocateRecord.CHANGE_TYPE_CONSUME);
        record.setChargingOrderCode(orderCode);
        record.setBeforeBalance(before);
        record.setAmount(change);
        record.setAfterBalance(before.add(change));
        record.setOperatorId(currentUserId());
        record.setOperatorName(currentUsername());
        record.setOperateTime(LocalDateTime.now());
        record.setRemark(truncate(remark, MAX_REMARK_LENGTH));
        record.setCreateTime(record.getOperateTime());
        try {
            return save(record);
        } catch (org.springframework.dao.DuplicateKeyException ex) {
            log.warn("消费流水已存在(订单号{}),忽略重复写入", orderCode);
            return true;
        }
    }
 
    /** 金额统一保留 2 位小数,null 视为 0 */
    private BigDecimal normalizeMoney(BigDecimal amount) {
        return amount == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
                : amount.setScale(2, RoundingMode.HALF_UP);
    }
 
    private String truncate(String value, int maxLength) {
        if (value == null) {
            return null;
        }
        String trimmed = value.trim();
        return trimmed.length() > maxLength ? trimmed.substring(0, maxLength) : trimmed;
    }
 
    /** 操作人ID:无登录上下文(如设备链路)时记为空,不阻断业务 */
    private Long currentUserId() {
        try {
            Long userId = SecurityUtils.getUserId();
            return userId == null || userId == 0L ? null : userId;
        } catch (RuntimeException ex) {
            log.warn("分配记录获取登录用户ID失败,操作人ID记为空:{}", ex.getMessage());
            return null;
        }
    }
 
    /** 操作人:取登录账号 */
    private String currentUsername() {
        try {
            String username = SecurityUtils.getUsername();
            return StringUtils.isBlank(username) ? null : truncate(username, MAX_USER_TEXT_LENGTH);
        } catch (RuntimeException ex) {
            log.warn("分配记录获取登录账号失败,操作人记为空:{}", ex.getMessage());
            return null;
        }
    }
}