| | |
| | | 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; |
| | | |
| | | /** |
| | | * 公司额度分配记录服务实现:对外提供分页查询与统一流水写入。 |
| | |
| | | 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) { |
| | |
| | | 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); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 消费流水写入公共实现 |
| | | * |