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 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 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 listConsumedOrderCodes(java.util.List orderCodes) { if (orderCodes == null || orderCodes.isEmpty()) { return new java.util.ArrayList<>(); } java.util.List 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 records = lambdaQuery() .select(TCompanyAllocateRecord::getChargingOrderCode) .eq(TCompanyAllocateRecord::getChangeType, TCompanyAllocateRecord.CHANGE_TYPE_CONSUME) .in(TCompanyAllocateRecord::getChargingOrderCode, codes) .list(); java.util.List 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 vins = vinManagementMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 cards = chargingCardMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 whitelists = appUserWhitelistMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 vins = vinManagementMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 cards = chargingCardMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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 whitelists = appUserWhitelistMapper.selectList( new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() .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; } } }