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;
|
}
|
}
|
}
|