package com.ruoyi.account.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; 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.ChargingCardAllocateDTO; import com.ruoyi.account.api.dto.ChargingCardAllocateItemDTO; import com.ruoyi.account.api.dto.ChargingCardAuthDTO; import com.ruoyi.account.api.dto.ChargingCardDeductDTO; import com.ruoyi.account.api.dto.ChargingCardExcelDTO; import com.ruoyi.account.api.dto.ChargingCardQueryDTO; import com.ruoyi.account.api.dto.ChargingCardSaveDTO; import com.ruoyi.account.api.dto.ChargingCardStatusDTO; import com.ruoyi.account.api.model.TChargingCard; import com.ruoyi.account.api.vo.ChargingCardAllocateVO; import com.ruoyi.account.api.vo.ChargingCardDeductVO; import com.ruoyi.account.mapper.TChargingCardMapper; import com.ruoyi.account.service.TChargingCardService; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.utils.StringUtils; import com.ruoyi.common.security.utils.SecurityUtils; import com.ruoyi.other.api.domain.TFleet; import com.ruoyi.other.api.domain.TGroupCompany; import com.ruoyi.other.api.feignClient.GroupCompanyClient; import org.redisson.api.RLock; import org.redisson.api.RedissonClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service public class TChargingCardServiceImpl extends ServiceImpl implements TChargingCardService { private static final Logger log = LoggerFactory.getLogger(TChargingCardServiceImpl.class); private static final int ENABLED = 1; private static final int DISABLED = 2; private static final int BALANCE_ADD = 1; /** 余额变动类型:3=刷卡订单充电结算扣款(V0.2 定稿,订单扣款专用) */ private static final int BALANCE_TYPE_ORDER_DEDUCT = 3; /** 充电锁定标记:1=充电中(业务锁定,管理操作互斥) */ private static final int CHARGING_FLAG_LOCKED = 1; /** 设备链路无登录操作人,流水统一记 0 */ private static final long SYSTEM_OPERATOR = 0L; private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999.99"); private static final int PASSWORD_MAX_LENGTH = 64; /** 公司账户模式:2=分配模式(可设置初始分配额度并扣减公司可用余额) */ private static final int ACCOUNT_MODE_ALLOCATION = 2; /** 初始分配额度上限(对应 decimal(12,2)) */ private static final BigDecimal MAX_INIT_QUOTA = new BigDecimal("99999999.99"); /** 额度分配:单次最多分配的卡片数量,防止单次请求过大 */ private static final int MAX_ALLOCATE_CARD_COUNT = 200; /** 卡片额度并发锁前缀:按卡片ID/卡号串行,防止同卡并发编辑重复扣减或退还公司余额 */ private static final String QUOTA_LOCK_PREFIX = "CHARGING_CARD_QUOTA:"; /** 额度分配并发锁前缀:按公司串行,防止同公司并发分配重复占用可分配余额 */ private static final String ALLOCATE_LOCK_PREFIX = QUOTA_LOCK_PREFIX + "COMPANY:"; /** 获取锁最长等待时间(秒) */ private static final long LOCK_WAIT_SECONDS = 5L; /** 锁自动释放时间(秒),兜住异常未解锁的场景 */ private static final long LOCK_LEASE_SECONDS = 30L; @Autowired private TransactionTemplate transactionTemplate; @Autowired private GroupCompanyClient groupCompanyClient; @Autowired private RedissonClient redissonClient; @Override public IPage selectCardPage(ChargingCardQueryDTO query) { ChargingCardQueryDTO safeQuery = query == null ? new ChargingCardQueryDTO() : query; long pageNum = safeQuery.getPageCurr() == null || safeQuery.getPageCurr() < 1 ? 1 : safeQuery.getPageCurr(); long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1 ? 10 : safeQuery.getPageSize(); Page page = new Page<>(pageNum, pageSize); IPage result = baseMapper.selectCardPage(page, safeQuery); fillRelationNames(result == null ? null : result.getRecords()); return result; } @Override public boolean saveCard(ChargingCardSaveDTO dto) { validateCard(dto, false); String cardNo = dto.getCardNo().trim(); BigDecimal quota = normalizeQuota(dto.getInitQuota()); // 同卡号并发新增串行化,卡号重复校验与额度扣减在同一临界区内 RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + "CARD_NO:" + cardNo); boolean locked = tryQuotaLock(lock, "该卡号正在处理中,请稍后重试"); List compensations = new ArrayList<>(); try { TChargingCard card = new TChargingCard(); card.setCardNo(cardNo); card.setBalance(dto.getBalance() == null ? BigDecimal.ZERO : dto.getBalance()); card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus()); card.setPassword(encodePasswordIfPresent(dto.getPassword())); card.setChargingFlag(0); card.setChargingOrderCode(null); card.setDelFlag(0); card.setCreateTime(LocalDateTime.now()); card.setCreateBy(SecurityUtils.getUserId()); applyRelation(card, dto); if (quota.signum() > 0) { requireAllocationCompany(card.getCompanyId()); deductBalance(card.getCompanyId(), quota); compensations.add(() -> refundQuietly(card.getCompanyId(), quota)); } card.setInitQuota(quota.signum() > 0 ? quota : null); if (!save(card)) { throw new ServiceException("新增卡片失败"); } return true; } catch (RuntimeException ex) { compensate(compensations); throw ex; } finally { unlockQuota(lock, locked); } } @Override public boolean updateCard(ChargingCardSaveDTO dto) { validateCard(dto, true); TChargingCard existing = getById(dto.getId()); if (existing == null) { throw new ServiceException("卡片不存在或已删除"); } assertNotChargingLocked(existing, "修改卡片或重置卡密码"); // 同卡片并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还 RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + dto.getId()); boolean locked = tryQuotaLock(lock, "该卡片额度正在处理中,请稍后重试"); List compensations = new ArrayList<>(); try { TChargingCard latest = getById(dto.getId()); if (latest == null) { throw new ServiceException("卡片不存在或已删除"); } assertNotChargingLocked(latest, "修改卡片或重置卡密码"); TChargingCard card = new TChargingCard(); card.setId(dto.getId()); card.setCardNo(dto.getCardNo().trim()); card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus()); card.setUpdateTime(LocalDateTime.now()); card.setUpdateBy(SecurityUtils.getUserId()); applyRelation(card, dto); BigDecimal newQuota = normalizeQuota(dto.getInitQuota()); if (newQuota.signum() > 0) { requireAllocationCompany(card.getCompanyId()); } Integer oldCompanyId = latest.getCompanyId(); BigDecimal oldQuota = normalizeQuota(latest.getInitQuota()); if (Objects.equals(oldCompanyId, card.getCompanyId())) { // 同一公司按差额调整:调增扣差额,调减退差额 BigDecimal diff = newQuota.subtract(oldQuota); if (diff.signum() > 0 && card.getCompanyId() != null) { deductBalance(card.getCompanyId(), diff); compensations.add(() -> refundQuietly(card.getCompanyId(), diff)); } else if (diff.signum() < 0 && card.getCompanyId() != null) { BigDecimal refund = diff.negate(); refundBalance(card.getCompanyId(), refund); compensations.add(() -> deductQuietly(card.getCompanyId(), refund)); } else if (diff.signum() != 0) { log.warn("卡片{}额度变化但未关联公司,跳过公司余额处理,oldQuota={}, newQuota={}", dto.getId(), oldQuota, newQuota); } } else { // 跨公司:旧公司全额退还,新公司全额扣减 if (oldCompanyId != null && oldQuota.signum() > 0) { refundBalance(oldCompanyId, oldQuota); compensations.add(() -> deductQuietly(oldCompanyId, oldQuota)); } if (card.getCompanyId() != null && newQuota.signum() > 0) { deductBalance(card.getCompanyId(), newQuota); compensations.add(() -> refundQuietly(card.getCompanyId(), newQuota)); } } card.setInitQuota(newQuota.signum() > 0 ? newQuota : null); String encodedPassword = encodePasswordIfPresent(dto.getPassword()); if (!updateCardRecord(card, encodedPassword, latest)) { throw new ServiceException("修改卡片失败"); } return true; } catch (RuntimeException ex) { compensate(compensations); throw ex; } finally { unlockQuota(lock, locked); } } @Override public boolean deleteCards(List ids) { if (ids == null || ids.isEmpty()) { throw new ServiceException("请选择需要删除的卡片"); } List distinctIds = new java.util.ArrayList<>(new HashSet<>(ids)); List cards = listByIds(distinctIds); if (cards.size() != distinctIds.size()) { throw new ServiceException("部分卡片不存在或已删除"); } assertNotChargingLocked(cards, "删除卡片"); if (!removeByIds(distinctIds)) { throw new ServiceException("删除卡片失败"); } return true; } @Override public boolean updateStatus(ChargingCardStatusDTO dto) { if (dto == null || dto.getId() == null || !isValidStatus(dto.getStatus())) { throw new ServiceException("卡片状态参数错误"); } TChargingCard existing = getById(dto.getId()); if (existing == null) { throw new ServiceException("卡片不存在或已删除"); } assertNotChargingLocked(existing, "启用/禁用卡片"); TChargingCard card = new TChargingCard(); card.setId(dto.getId()); card.setStatus(dto.getStatus()); card.setUpdateTime(LocalDateTime.now()); card.setUpdateBy(SecurityUtils.getUserId()); if (!updateById(card)) { throw new ServiceException("更新卡片状态失败"); } return true; } @Override public ChargingCardAllocateVO getAllocateOverview(Integer companyId) { if (companyId == null) { throw new ServiceException("请选择公司"); } TGroupCompany company = loadCompany(companyId); if (company == null) { throw new ServiceException("公司不存在或已删除"); } return buildAllocateOverview(company); } @Override @Transactional(rollbackFor = Exception.class) public ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto) { Map amounts = validateAllocateRequest(dto); Integer companyId = dto.getCompanyId(); // 公司必须存在、启用且为分配模式(共享模式不支持额度分配) TGroupCompany company = loadAllocationCompany(companyId); BigDecimal total = amounts.values().stream() .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(2, RoundingMode.HALF_UP); // 同公司分配串行化,避免并发下重复占用可分配余额(卡片侧另有条件更新兜底) RLock lock = redissonClient.getLock(ALLOCATE_LOCK_PREFIX + companyId); boolean locked = tryQuotaLock(lock, "该公司额度分配正在处理中,请稍后重试"); try { List cards = listByIds(amounts.keySet()); if (cards.size() != amounts.size()) { throw new ServiceException("部分卡片不存在或已删除"); } for (TChargingCard card : cards) { BigDecimal amount = amounts.get(card.getId()); if (!companyId.equals(card.getCompanyId())) { throw new ServiceException("卡片【" + card.getCardNo() + "】不属于所选公司,无法分配额度"); } if (!Integer.valueOf(ENABLED).equals(card.getStatus())) { throw new ServiceException("卡片【" + card.getCardNo() + "】已禁用,禁止分配额度"); } assertNotChargingLocked(card, "分配额度"); BigDecimal balance = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance(); if (balance.add(amount).compareTo(MAX_AMOUNT) > 0) { throw new ServiceException("卡片【" + card.getCardNo() + "】分配后余额超过上限" + MAX_AMOUNT.toPlainString()); } } String remark = buildAllocateRemark(company, dto.getRemark()); // 先扣公司可分配余额(条件更新,余额不足直接失败),失败无需补偿 R deductResponse = groupCompanyClient.deductBalance(companyId, total); if (deductResponse == null || !R.isSuccess(deductResponse)) { throw new ServiceException(buildDeductFailureMessage(deductResponse)); } try { for (TChargingCard card : cards) { BigDecimal amount = amounts.get(card.getId()); int updated = baseMapper.adjustBalance(Collections.singletonList(card.getId()), amount, BALANCE_ADD, MAX_AMOUNT, SecurityUtils.getUserId()); if (updated != 1) { throw new ServiceException("卡片【" + card.getCardNo() + "】余额分配失败,请重试"); } baseMapper.insertBalanceRecord(card.getId(), BALANCE_ADD, amount, remark, SecurityUtils.getUserId()); } } catch (RuntimeException ex) { // 卡片改动随事务回滚,公司余额为远程扣减需显式退还 refundQuietly(companyId, total); throw ex; } ChargingCardAllocateVO vo = buildAllocateOverview(company); vo.setAvailableBalance(normalizeMoney(deductResponse.getData())); return vo; } finally { unlockQuota(lock, locked); } } @Override public List selectExportList(ChargingCardQueryDTO query) { return baseMapper.selectExportList(query == null ? new ChargingCardQueryDTO() : query); } @Override @Transactional(rollbackFor = Exception.class) public String importCards(List cards) { if (cards == null || cards.isEmpty()) { throw new ServiceException("导入数据不能为空"); } Set importCardNos = new HashSet<>(); int row = 1; for (ChargingCardExcelDTO item : cards) { row++; if (item == null || StringUtils.isEmpty(item.getCardNo())) { throw new ServiceException("第" + row + "行卡号不能为空"); } String cardNo = item.getCardNo().trim(); if (cardNo.length() > 64) { throw new ServiceException("第" + row + "行卡号不能超过64个字符"); } if (!importCardNos.add(cardNo)) { throw new ServiceException("第" + row + "行卡号在文件中重复"); } if (existsCardNo(cardNo, null)) { throw new ServiceException("第" + row + "行卡号已存在"); } if (item.getBalance() != null) { validateAmount(item.getBalance(), true, "第" + row + "行卡余额"); } parseExcelStatus(item.getStatus(), row); } Long userId = SecurityUtils.getUserId(); LocalDateTime now = LocalDateTime.now(); for (ChargingCardExcelDTO item : cards) { TChargingCard card = new TChargingCard(); card.setCardNo(item.getCardNo().trim()); card.setBalance(item.getBalance() == null ? BigDecimal.ZERO : item.getBalance()); card.setStatus(parseExcelStatus(item.getStatus(), 0)); card.setChargingFlag(0); card.setDelFlag(0); card.setCreateTime(now); card.setCreateBy(userId); save(card); } return "成功导入" + cards.size() + "条卡片数据"; } @Override public TChargingCard verifyCardForCharging(ChargingCardAuthDTO dto) { if (dto == null || StringUtils.isEmpty(dto.getCardNo())) { throw new ServiceException("卡号不能为空"); } TChargingCard card = getOne(new LambdaQueryWrapper() .eq(TChargingCard::getCardNo, dto.getCardNo().trim())); if (card == null) { throw new ServiceException("卡片不存在或已删除"); } if (card.getStatus() == null || card.getStatus() != ENABLED) { throw new ServiceException("卡片已禁用,无法启动充电"); } // 条件密码校验(V0.2 定稿):卡片配置了密码才校验;未配置密码不要求、不校验 if (StringUtils.isNotEmpty(card.getPassword())) { if (StringUtils.isEmpty(dto.getPassword())) { throw new ServiceException("卡片已配置密码,请输入卡密码"); } if (!SecurityUtils.matchesPassword(dto.getPassword(), card.getPassword())) { throw new ServiceException("卡密码错误"); } } return card; } @Override public boolean lockCardForCharging(Long cardId, String orderCode) { if (cardId == null || StringUtils.isEmpty(orderCode)) { throw new ServiceException("卡片ID与订单号不能为空"); } return baseMapper.lockCardForCharging(cardId, orderCode.trim()) == 1; } @Override public boolean unlockCardForCharging(Long cardId, String orderCode) { if (cardId == null || StringUtils.isEmpty(orderCode)) { throw new ServiceException("卡片ID与订单号不能为空"); } if (baseMapper.unlockCardForCharging(cardId, orderCode.trim()) == 1) { return true; } TChargingCard card = getById(cardId); if (card == null) { throw new ServiceException("卡片不存在或已删除"); } // 已解锁(订单号已被清空)视为幂等成功;仍被其他订单占用则返回 false,防止误释放 return card.getChargingOrderCode() == null || orderCode.trim().equals(card.getChargingOrderCode()); } @Override public List listDeductedOrderCodes(List orderCodes) { if (orderCodes == null || orderCodes.isEmpty()) { return java.util.Collections.emptyList(); } List normalizedCodes = orderCodes.stream() .filter(StringUtils::isNotEmpty) .map(String::trim) .filter(code -> !code.isEmpty()) .distinct() .collect(java.util.stream.Collectors.toList()); return normalizedCodes.isEmpty() ? java.util.Collections.emptyList() : baseMapper.selectDeductedOrderCodes(normalizedCodes); } @Override public ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto) { validateDeductRequest(dto); try { return transactionTemplate.execute(status -> { TChargingCard card = getById(dto.getCardId()); if (card == null) { throw new ServiceException("卡片不存在或已删除"); } BigDecimal before = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance(); // 第一步先占扣款幂等键:同一订单并发结算时,唯一索引 uk_order_code 只允许一个事务插入成功 baseMapper.insertOrderDeductRecord(dto.getCardId(), dto.getAmount(), dto.getOrderCode(), dto.getOperatorId() == null ? SYSTEM_OPERATOR : dto.getOperatorId()); // 第二步原子扣减:允许扣成负余额,禁止携带 balance >= amount 下限条件(V0.2 定稿) int updated = baseMapper.deductBalanceByCardId(dto.getCardId(), dto.getAmount()); if (updated != 1) { throw new ServiceException("卡片不存在或已删除,扣款失败"); } TChargingCard afterCard = getById(dto.getCardId()); BigDecimal after = afterCard == null || afterCard.getBalance() == null ? BigDecimal.ZERO : afterCard.getBalance(); ChargingCardDeductVO vo = new ChargingCardDeductVO(); vo.setCardId(dto.getCardId()); vo.setOrderCode(dto.getOrderCode()); vo.setAmount(dto.getAmount()); vo.setBeforeBalance(before); vo.setAfterBalance(after); vo.setDuplicated(false); return vo; }); } catch (DuplicateKeyException e) { // 唯一键冲突:该订单此前已扣款成功(含并发下慢事务先提交),按幂等重复返回,不重复扣款 log.warn("刷卡订单结算扣款幂等命中,不重复扣款。订单号={}, 卡片ID={}", dto.getOrderCode(), dto.getCardId()); ChargingCardDeductVO vo = new ChargingCardDeductVO(); vo.setCardId(dto.getCardId()); vo.setOrderCode(dto.getOrderCode()); vo.setAmount(dto.getAmount()); vo.setDuplicated(true); return vo; } } /** * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司 */ private void applyRelation(TChargingCard card, ChargingCardSaveDTO dto) { Integer companyId = dto.getCompanyId(); if (companyId != null && loadCompany(companyId) == null) { throw new ServiceException("所属公司不存在或已删除"); } Integer fleetId = dto.getFleetId(); if (fleetId != null) { TFleet fleet = loadFleet(fleetId); if (fleet == null) { throw new ServiceException("所属车队不存在或已删除"); } if (companyId == null) { companyId = fleet.getCompanyId(); } else if (!companyId.equals(fleet.getCompanyId())) { throw new ServiceException("所属车队不属于所选公司"); } } card.setCompanyId(companyId); card.setFleetId(fleetId); } /** 规范化初始分配额度:null 视为 0,不允许负数、超过2位小数或超出上限 */ private BigDecimal normalizeQuota(BigDecimal quota) { if (quota == null) { return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); } if (quota.signum() < 0) { throw new ServiceException("初始分配额度不能小于0"); } if (quota.stripTrailingZeros().scale() > 2) { throw new ServiceException("初始分配额度最多保留2位小数"); } BigDecimal normalized = quota.setScale(2, RoundingMode.HALF_UP); if (normalized.compareTo(MAX_INIT_QUOTA) > 0) { throw new ServiceException("初始分配额度不能超过" + MAX_INIT_QUOTA.toPlainString()); } return normalized; } /** 校验公司为启用且分配模式(可设置初始分配额度) */ private void requireAllocationCompany(Integer companyId) { if (companyId == null) { throw new ServiceException("请选择所属公司后再填写初始分配额度"); } TGroupCompany company = loadCompany(companyId); if (company == null) { throw new ServiceException("所属公司不存在或已删除"); } if (!Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode())) { throw new ServiceException("仅分配模式的公司可填写初始分配额度"); } if (!Integer.valueOf(1).equals(company.getStatus())) { throw new ServiceException("所属公司已停用,无法填写初始分配额度"); } } /** * 额度分配:加载公司并校验为启用且分配模式。 * 与初始分配额度校验分开,便于按场景返回更准确的提示,且不影响既有链路。 */ private TGroupCompany loadAllocationCompany(Integer companyId) { if (companyId == null) { throw new ServiceException("请选择公司"); } TGroupCompany company = loadCompany(companyId); if (company == null) { throw new ServiceException("公司不存在或已删除"); } if (!Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode())) { throw new ServiceException("该公司为共享模式,不支持额度分配"); } if (!Integer.valueOf(1).equals(company.getStatus())) { throw new ServiceException("公司已停用,无法分配额度"); } return company; } /** 组装额度分配概览(可分配余额、总账户余额) */ private ChargingCardAllocateVO buildAllocateOverview(TGroupCompany company) { ChargingCardAllocateVO vo = new ChargingCardAllocateVO(); vo.setCompanyId(company.getId()); vo.setCompanyName(company.getName()); vo.setAccountMode(company.getAccountMode()); vo.setAvailableBalance(normalizeMoney(company.getAvailableBalance())); vo.setTotalRechargeAmount(normalizeMoney(company.getTotalRechargeAmount())); return vo; } /** 金额归一化:null 视为 0,统一保留2位小数 */ private BigDecimal normalizeMoney(BigDecimal amount) { return (amount == null ? BigDecimal.ZERO : amount).setScale(2, RoundingMode.HALF_UP); } /** 校验额度分配请求并返回 卡片ID → 本次分配金额(保持提交顺序) */ private Map validateAllocateRequest(ChargingCardAllocateDTO dto) { if (dto == null) { throw new ServiceException("分配参数不能为空"); } if (dto.getCompanyId() == null) { throw new ServiceException("请选择公司"); } if (dto.getItems() == null || dto.getItems().isEmpty()) { throw new ServiceException("请至少为一张卡片填写本次分配金额"); } if (dto.getItems().size() > MAX_ALLOCATE_CARD_COUNT) { throw new ServiceException("单次最多分配" + MAX_ALLOCATE_CARD_COUNT + "张卡片"); } if (dto.getRemark() != null && dto.getRemark().trim().length() > 200) { throw new ServiceException("备注不能超过200个字符"); } Map amounts = new LinkedHashMap<>(); for (ChargingCardAllocateItemDTO item : dto.getItems()) { if (item == null || item.getCardId() == null) { throw new ServiceException("分配明细中存在无效卡片"); } if (amounts.containsKey(item.getCardId())) { throw new ServiceException("分配明细中存在重复卡片"); } validateAmount(item.getAmount(), false, "分配金额"); if (item.getAmount().compareTo(MAX_INIT_QUOTA) > 0) { throw new ServiceException("单张卡片分配金额不能超过" + MAX_INIT_QUOTA.toPlainString()); } amounts.put(item.getCardId(), item.getAmount().setScale(2, RoundingMode.HALF_UP)); } return amounts; } /** 组装额度分配流水备注(含来源公司,超长截断以适配 varchar(255)) */ private String buildAllocateRemark(TGroupCompany company, String userRemark) { String companyName = company == null || company.getName() == null ? "" : company.getName(); String base = "额度分配(公司:" + companyName + ")"; String remark = StringUtils.isEmpty(userRemark) ? base : base + userRemark.trim(); return remark.length() > 255 ? remark.substring(0, 255) : remark; } /** 公司余额扣减失败提示:余额不足场景给出可读文案 */ private String buildDeductFailureMessage(R response) { if (response == null) { return "公司可用余额扣减失败:服务无响应"; } String msg = response.getMsg(); if (msg != null && msg.contains("余额不足")) { return "公司可分配余额不足,无法完成本次分配"; } return "公司可用余额扣减失败:" + msg; } private TGroupCompany loadCompany(Integer companyId) { R response = groupCompanyClient.getCompanyById(companyId); if (response == null || !R.isSuccess(response)) { throw new ServiceException("公司信息查询失败:" + (response == null ? "服务无响应" : response.getMsg())); } return response.getData(); } private TFleet loadFleet(Integer fleetId) { R response = groupCompanyClient.getFleetById(fleetId); if (response == null || !R.isSuccess(response)) { throw new ServiceException("车队信息查询失败:" + (response == null ? "服务无响应" : response.getMsg())); } return response.getData(); } /** 扣减公司可用余额(余额不足或服务异常时抛出业务异常) */ private void deductBalance(Integer companyId, BigDecimal amount) { R response = groupCompanyClient.deductBalance(companyId, amount); if (response == null || !R.isSuccess(response)) { throw new ServiceException("公司可用余额扣减失败:" + (response == null ? "服务无响应" : response.getMsg())); } } /** 退还公司可用余额(失败时抛出业务异常) */ private void refundBalance(Integer companyId, BigDecimal amount) { R response = groupCompanyClient.refundBalance(companyId, amount); if (response == null || !R.isSuccess(response)) { throw new ServiceException("公司可用余额退还失败:" + (response == null ? "服务无响应" : response.getMsg())); } } /** 静默退还(补偿场景使用,失败仅记录日志) */ private void refundQuietly(Integer companyId, BigDecimal amount) { try { R response = groupCompanyClient.refundBalance(companyId, amount); if (response == null || !R.isSuccess(response)) { log.error("公司可用余额退还失败,需人工核对,companyId={}, amount={}, msg={}", companyId, amount, response == null ? "服务无响应" : response.getMsg()); } } catch (Exception ex) { log.error("公司可用余额退还异常,需人工核对,companyId={}, amount={}", companyId, amount, ex); } } /** 静默扣减(补偿场景使用,失败仅记录日志) */ private void deductQuietly(Integer companyId, BigDecimal amount) { try { R response = groupCompanyClient.deductBalance(companyId, amount); if (response == null || !R.isSuccess(response)) { log.error("公司可用余额回滚扣减失败,需人工核对,companyId={}, amount={}, msg={}", companyId, amount, response == null ? "服务无响应" : response.getMsg()); } } catch (Exception ex) { log.error("公司可用余额回滚扣减异常,需人工核对,companyId={}, amount={}", companyId, amount, ex); } } /** 保存/更新失败时反向补偿已执行的余额操作(倒序执行) */ private void compensate(List compensations) { for (int i = compensations.size() - 1; i >= 0; i--) { try { compensations.get(i).run(); } catch (Exception ex) { log.error("公司可用余额补偿失败,需人工核对", ex); } } } /** 列表回填公司/车队名称(远程失败不影响列表查询) */ private void fillRelationNames(List records) { if (records == null || records.isEmpty()) { return; } fillFleetNames(records); fillCompanyNames(records); } private void fillFleetNames(List records) { List fleetIds = records.stream() .map(TChargingCard::getFleetId) .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); if (fleetIds.isEmpty()) { return; } try { R> response = groupCompanyClient.listFleetsByIds(fleetIds); if (response == null || !R.isSuccess(response) || response.getData() == null) { return; } Map nameMap = new HashMap<>(); for (TFleet fleet : response.getData()) { if (fleet != null && fleet.getId() != null) { nameMap.put(fleet.getId(), fleet.getFleetName()); } } records.forEach(record -> record.setFleetName(nameMap.get(record.getFleetId()))); } catch (Exception ex) { log.warn("回填车队名称失败,fleetIds={}", fleetIds, ex); } } private void fillCompanyNames(List records) { Map nameMap = new HashMap<>(); for (TChargingCard record : records) { Integer companyId = record.getCompanyId(); if (companyId == null) { continue; } if (nameMap.containsKey(companyId)) { record.setCompanyName(nameMap.get(companyId)); continue; } String companyName = null; try { TGroupCompany company = loadCompany(companyId); companyName = company == null ? null : company.getName(); } catch (Exception ex) { log.warn("回填公司名称失败,companyId={}", companyId, ex); } nameMap.put(companyId, companyName); record.setCompanyName(companyName); } } /** * 更新卡片记录:主字段走 updateById(保持既有行为), * 公司/车队/额度发生变化时补充显式更新,保证三者可被清空 */ private boolean updateCardRecord(TChargingCard card, String encodedPassword, TChargingCard existing) { TChargingCard update = new TChargingCard(); update.setId(card.getId()); update.setCardNo(card.getCardNo()); update.setStatus(card.getStatus()); update.setPassword(encodedPassword); update.setUpdateTime(card.getUpdateTime()); update.setUpdateBy(card.getUpdateBy()); if (!updateById(update)) { return false; } boolean relationChanged = !Objects.equals(existing.getCompanyId(), card.getCompanyId()) || !Objects.equals(existing.getFleetId(), card.getFleetId()) || !quotaEquals(existing.getInitQuota(), card.getInitQuota()); if (!relationChanged) { return true; } UpdateWrapper relationUpdate = new UpdateWrapper<>(); relationUpdate.eq("id", card.getId()) .set("company_id", card.getCompanyId()) .set("fleet_id", card.getFleetId()) .set("init_quota", card.getInitQuota()); return update(null, relationUpdate); } /** 额度比较:null 与 0 视为等价 */ private boolean quotaEquals(BigDecimal left, BigDecimal right) { BigDecimal leftValue = left == null ? BigDecimal.ZERO : left; BigDecimal rightValue = right == null ? BigDecimal.ZERO : right; return leftValue.compareTo(rightValue) == 0; } /** 抢占并发锁,抢占失败抛出业务异常 */ private boolean tryQuotaLock(RLock lock, String busyMessage) { try { if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) { throw new ServiceException(busyMessage); } return true; } catch (InterruptedException ex) { Thread.currentThread().interrupt(); throw new ServiceException(busyMessage); } } private void unlockQuota(RLock lock, boolean locked) { if (!locked) { return; } try { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } catch (Exception ex) { log.warn("释放卡片额度并发锁失败", ex); } } private void validateCard(ChargingCardSaveDTO dto, boolean update) { if (dto == null || StringUtils.isEmpty(dto.getCardNo())) { throw new ServiceException("卡号不能为空"); } if (update && dto.getId() == null) { throw new ServiceException("卡片ID不能为空"); } String cardNo = dto.getCardNo().trim(); if (cardNo.length() > 64) { throw new ServiceException("卡号不能超过64个字符"); } if (dto.getPassword() != null && !dto.getPassword().trim().isEmpty()) { if (dto.getPassword().length() > PASSWORD_MAX_LENGTH) { throw new ServiceException("卡密码不能超过" + PASSWORD_MAX_LENGTH + "个字符"); } } if (!update && dto.getBalance() != null) { validateAmount(dto.getBalance(), true, "卡余额"); } if (dto.getStatus() != null && !isValidStatus(dto.getStatus())) { throw new ServiceException("卡片状态参数错误"); } if (existsCardNo(cardNo, dto.getId())) { throw new ServiceException("卡号已存在"); } } private String encodePasswordIfPresent(String rawPassword) { if (rawPassword == null || rawPassword.trim().isEmpty()) { return null; } return SecurityUtils.encryptPassword(rawPassword.trim()); } /** * 卡片处于充电锁定状态时拒绝管理操作(V0.2 定稿)。 * * @param cards 待操作的卡片 * @param action 操作描述,用于异常提示 */ private void assertNotChargingLocked(List cards, String action) { if (cards == null) { return; } for (TChargingCard card : cards) { assertNotChargingLocked(card, action); } } private void assertNotChargingLocked(TChargingCard card, String action) { if (card.getChargingFlag() != null && card.getChargingFlag() == CHARGING_FLAG_LOCKED) { throw new ServiceException("卡片【" + card.getCardNo() + "】正在充电中,禁止" + action + ",充电结束后可重试"); } } private boolean existsCardNo(String cardNo, Long excludeId) { return baseMapper.countByCardNo(cardNo, excludeId) > 0; } private void validateDeductRequest(ChargingCardDeductDTO dto) { if (dto == null) { throw new ServiceException("扣款参数不能为空"); } if (dto.getCardId() == null) { throw new ServiceException("卡片ID不能为空"); } if (StringUtils.isEmpty(dto.getOrderCode())) { throw new ServiceException("订单号不能为空"); } if (dto.getOrderCode().length() > 64) { throw new ServiceException("订单号不能超过64个字符"); } validateAmount(dto.getAmount(), false, "扣款金额"); } private void validateAmount(BigDecimal amount, boolean allowZero, String fieldName) { if (amount == null || (allowZero ? amount.compareTo(BigDecimal.ZERO) < 0 : amount.compareTo(BigDecimal.ZERO) <= 0)) { throw new ServiceException(fieldName + (allowZero ? "不能小于0" : "必须大于0")); } if (amount.scale() > 2) { throw new ServiceException(fieldName + "最多保留2位小数"); } if (amount.compareTo(MAX_AMOUNT) > 0) { throw new ServiceException(fieldName + "不能超过" + MAX_AMOUNT.toPlainString()); } } private Integer parseExcelStatus(String status, int row) { if (StringUtils.isEmpty(status)) { return ENABLED; } String value = status.trim(); if ("1".equals(value) || "启用".equals(value)) { return ENABLED; } if ("2".equals(value) || "禁用".equals(value)) { return DISABLED; } String prefix = row > 0 ? "第" + row + "行" : ""; throw new ServiceException(prefix + "启用状态错误"); } private boolean isValidStatus(Integer status) { return status != null && (status == ENABLED || status == DISABLED); } }