puzhibing
2026-09-30 e03f9a706ba46df72c1914870a0a707d6e5e676b
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TChargingCardServiceImpl.java
@@ -1,28 +1,34 @@
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.ChargingCardBalanceDTO;
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.model.TCompanyAllocateRecord;
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.account.service.TCompanyAllocateRecordService;
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.service.TokenService;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysUserClient;
import com.ruoyi.system.api.model.LoginUser;
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;
@@ -32,10 +38,19 @@
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<TChargingCardMapper, TChargingCard>
@@ -46,7 +61,6 @@
    private static final int ENABLED = 1;
    private static final int DISABLED = 2;
    private static final int BALANCE_ADD = 1;
    private static final int BALANCE_SUBTRACT = 2;
    /** 余额变动类型:3=刷卡订单充电结算扣款(V0.2 定稿,订单扣款专用) */
    private static final int BALANCE_TYPE_ORDER_DEDUCT = 3;
    /** 充电锁定标记:1=充电中(业务锁定,管理操作互斥) */
@@ -55,15 +69,40 @@
    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;
    @Autowired
    private TokenService tokenService;
    @Autowired
    private SysUserClient sysUserClient;
    /** 公司账户模式:2=分配模式(可设置初始分配额度并扣减公司可用余额) */
    private static final int ACCOUNT_MODE_ALLOCATION = 2;
    /** 公司账户模式:1=共享模式(可用余额取公司可用余额) */
    private static final int ACCOUNT_MODE_SHARED = 1;
    /** 账户状态:正常 */
    private static final int ACCOUNT_STATUS_NORMAL = 1;
    /** 账户状态:欠款 */
    private static final int ACCOUNT_STATUS_OVERDUE = 2;
    /** 金额展示精度 */
    private static final int AMOUNT_SCALE = 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;
    @Autowired
    private TCompanyAllocateRecordService companyAllocateRecordService;
    @Override
    public IPage<TChargingCard> selectCardPage(ChargingCardQueryDTO query) {
@@ -71,26 +110,51 @@
        long pageNum = safeQuery.getPageCurr() == null || safeQuery.getPageCurr() < 1 ? 1 : safeQuery.getPageCurr();
        long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1 ? 10 : safeQuery.getPageSize();
        Page<TChargingCard> page = new Page<>(pageNum, pageSize);
        return baseMapper.selectCardPage(page, safeQuery);
        IPage<TChargingCard> result = baseMapper.selectCardPage(page, safeQuery);
        List<TChargingCard> records = result == null ? null : result.getRecords();
        fillFleetNames(records);
        fillAccountInfo(records);
        return result;
    }
    @Override
    public boolean saveCard(ChargingCardSaveDTO dto) {
        validateCard(dto, false);
        TChargingCard card = new TChargingCard();
        card.setCardNo(dto.getCardNo().trim());
        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());
        if (!save(card)) {
            throw new ServiceException("新增卡片失败");
        String cardNo = dto.getCardNo().trim();
        BigDecimal quota = normalizeQuota(dto.getBalance());
        // 同卡号并发新增串行化,卡号重复校验与额度扣减在同一临界区内
        RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + "CARD_NO:" + cardNo);
        boolean locked = tryQuotaLock(lock, "该卡号正在处理中,请稍后重试");
        List<Runnable> compensations = new ArrayList<>();
        try {
            TChargingCard card = new TChargingCard();
            card.setCardNo(cardNo);
            card.setBalance(quota.signum() > 0 ? quota : BigDecimal.ZERO);
            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));
            }
            if (!save(card)) {
                throw new ServiceException("新增卡片失败");
            }
            recordCardAllocate(card.getCardNo(), card.getCompanyId(), BigDecimal.ZERO, quota,
                    TCompanyAllocateRecord.SOURCE_CREATE, "新增卡片初始分配额度");
            return true;
        } catch (RuntimeException ex) {
            compensate(compensations);
            throw ex;
        } finally {
            unlockQuota(lock, locked);
        }
        return true;
    }
    @Override
@@ -101,21 +165,77 @@
            throw new ServiceException("卡片不存在或已删除");
        }
        assertNotChargingLocked(existing, "修改卡片或重置卡密码");
        TChargingCard card = new TChargingCard();
        card.setId(dto.getId());
        card.setCardNo(dto.getCardNo().trim());
        card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus());
        // 仅当传入新密码时才重置卡密码;留空表示不修改(V0.2 定稿)
        String encodedPassword = encodePasswordIfPresent(dto.getPassword());
        if (encodedPassword != null) {
            card.setPassword(encodedPassword);
        // 同卡片并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还
        RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + dto.getId());
        boolean locked = tryQuotaLock(lock, "该卡片额度正在处理中,请稍后重试");
        List<Runnable> compensations = new ArrayList<>();
        // 分配记录延迟到卡片落库成功后写入:失败时公司余额已回补,不应留下流水
        List<Runnable> recordTasks = 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.getBalance());
            if (newQuota.signum() > 0) {
                requireAllocationCompany(card.getCompanyId());
            }
            Integer oldCompanyId = latest.getCompanyId();
            BigDecimal oldQuota = normalizeQuota(latest.getBalance());
            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));
                    recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), oldQuota, diff,
                            TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整初始分配额度"));
                } else if (diff.signum() < 0 && card.getCompanyId() != null) {
                    BigDecimal refund = diff.negate();
                    refundBalance(card.getCompanyId(), refund);
                    compensations.add(() -> deductQuietly(card.getCompanyId(), refund));
                    recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), oldQuota, diff,
                            TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调减初始分配额度"));
                } 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));
                    recordTasks.add(() -> recordCardAllocate(card.getCardNo(), oldCompanyId, oldQuota,
                            oldQuota.negate(), TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整所属公司(原公司退还)"));
                }
                if (card.getCompanyId() != null && newQuota.signum() > 0) {
                    deductBalance(card.getCompanyId(), newQuota);
                    compensations.add(() -> refundQuietly(card.getCompanyId(), newQuota));
                    recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), BigDecimal.ZERO,
                            newQuota, TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整所属公司"));
                }
            }
            card.setBalance(newQuota.signum() > 0 ? newQuota : null);
            String encodedPassword = encodePasswordIfPresent(dto.getPassword());
            if (!updateCardRecord(card, encodedPassword, latest)) {
                throw new ServiceException("修改卡片失败");
            }
            recordTasks.forEach(Runnable::run);
            return true;
        } catch (RuntimeException ex) {
            compensate(compensations);
            throw ex;
        } finally {
            unlockQuota(lock, locked);
        }
        card.setUpdateTime(LocalDateTime.now());
        card.setUpdateBy(SecurityUtils.getUserId());
        if (!updateById(card)) {
            throw new ServiceException("修改卡片失败");
        }
        return true;
    }
    @Override
@@ -133,6 +253,44 @@
            throw new ServiceException("删除卡片失败");
        }
        return true;
    }
    /**
     * 批量解绑车队:仅清空车队归属(fleet_id 置空),公司、余额、状态等不受影响;
     * 未绑定车队的选中记录忽略不计
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public int unbindFleet(List<Long> ids) {
        if (ids == null || ids.isEmpty()) {
            throw new ServiceException("请选择需要解绑车队的卡片");
        }
        List<Long> distinct = ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (distinct.isEmpty()) {
            throw new ServiceException("请选择需要解绑车队的卡片");
        }
        List<TChargingCard> records = listByIds(distinct);
        if (records.size() != distinct.size()) {
            throw new ServiceException("部分卡片不存在或已删除");
        }
        List<Long> boundIds = records.stream()
                .filter(card -> card.getFleetId() != null)
                .map(TChargingCard::getId)
                .collect(Collectors.toList());
        if (boundIds.isEmpty()) {
            return 0;
        }
        // 显式 set null 清空车队归属(MyBatis-Plus 字段策略会忽略实体 null,需走 UpdateWrapper)
        boolean updated = lambdaUpdate()
                .in(TChargingCard::getId, boundIds)
                .set(TChargingCard::getFleetId, null)
                .set(TChargingCard::getUpdateTime, LocalDateTime.now())
                .set(TChargingCard::getUpdateBy, SecurityUtils.getUserId())
                .update();
        if (!updated) {
            throw new ServiceException("卡片车队解绑失败,请重试");
        }
        return boundIds.size();
    }
    @Override
@@ -157,33 +315,92 @@
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean updateBalance(ChargingCardBalanceDTO dto) {
        validateBalanceRequest(dto);
        verifyOperationPassword(dto.getOperationPassword());
    public ChargingCardAllocateVO getAllocateOverview(Integer companyId) {
        if (companyId == null) {
            throw new ServiceException("请选择公司");
        }
        TGroupCompany company = loadCompany(companyId);
        if (company == null) {
            throw new ServiceException("公司不存在或已删除");
        }
        return buildAllocateOverview(company);
    }
        List<TChargingCard> cards = listByIds(dto.getIds());
        if (cards.size() != new HashSet<>(dto.getIds()).size()) {
            throw new ServiceException("部分卡片不存在或已删除");
    @Override
    @Transactional(rollbackFor = Exception.class)
    public ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto) {
        Map<Long, BigDecimal> 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<TChargingCard> 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<BigDecimal> 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());
                    BigDecimal beforeBalance = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
                    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());
                    recordCardAllocate(card.getCardNo(), companyId, beforeBalance, amount,
                            TCompanyAllocateRecord.SOURCE_ALLOCATE, remark);
                }
            } catch (RuntimeException ex) {
                // 卡片改动随事务回滚,公司余额为远程扣减需显式退还
                refundQuietly(companyId, total);
                throw ex;
            }
            ChargingCardAllocateVO vo = buildAllocateOverview(company);
            vo.setAvailableBalance(normalizeMoney(deductResponse.getData()));
            return vo;
        } finally {
            unlockQuota(lock, locked);
        }
        assertNotChargingLocked(cards, "调整余额");
        int updated = baseMapper.adjustBalance(dto.getIds(), dto.getAmount(), dto.getType(),
                MAX_AMOUNT, SecurityUtils.getUserId());
        if (updated != cards.size()) {
            throw new ServiceException(dto.getType() == BALANCE_SUBTRACT
                    ? "部分卡片正在充电中或余额不足,无法完成扣减"
                    : "部分卡片正在充电中或余额超过上限,无法完成增加");
        }
        for (TChargingCard card : cards) {
            baseMapper.insertBalanceRecord(card.getId(), dto.getType(), dto.getAmount(),
                    StringUtils.isEmpty(dto.getRemark()) ? null : dto.getRemark().trim(), SecurityUtils.getUserId());
        }
        return true;
    }
    @Override
    public List<ChargingCardExcelDTO> selectExportList(ChargingCardQueryDTO query) {
        return baseMapper.selectExportList(query == null ? new ChargingCardQueryDTO() : query);
        List<ChargingCardExcelDTO> items = baseMapper.selectExportList(query == null ? new ChargingCardQueryDTO() : query);
        if (items == null || items.isEmpty()) {
            return items;
        }
        fillExportFleetNames(items);
        fillExportAccountInfo(items);
        return items;
    }
    @Override
@@ -281,6 +498,32 @@
    }
    @Override
    public List<String> listDeductedOrderCodes(List<String> orderCodes) {
        if (orderCodes == null || orderCodes.isEmpty()) {
            return java.util.Collections.emptyList();
        }
        List<String> 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 List<TChargingCard> listByCompany(Integer companyId) {
        if (companyId == null) {
            return java.util.Collections.emptyList();
        }
        return lambdaQuery().eq(TChargingCard::getCompanyId, companyId)
                .orderByDesc(TChargingCard::getId)
                .list();
    }
    @Override
    public ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto) {
        validateDeductRequest(dto);
        try {
@@ -319,6 +562,543 @@
            vo.setAmount(dto.getAmount());
            vo.setDuplicated(true);
            return vo;
        }
    }
    @Override
    public BigDecimal lockBalance(Long cardId, BigDecimal amount) {
        if (cardId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("锁定金额参数错误");
        }
        // 实扣冻结:余额立即扣减并计入锁定金额,余额不足时条件更新命中 0 行
        boolean updated = lambdaUpdate()
                .eq(TChargingCard::getId, cardId)
                .ge(TChargingCard::getBalance, amount)
                .setSql("balance = IFNULL(balance, 0) - " + amount.toPlainString()
                        + ", locked_amount = IFNULL(locked_amount, 0) + " + amount.toPlainString())
                .update();
        if (!updated) {
            throw new ServiceException("卡片余额不足");
        }
        TChargingCard latest = getById(cardId);
        return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
    }
    @Override
    public ChargingCardDeductVO settleBalanceForCharging(ChargingCardDeductDTO dto) {
        validateDeductRequest(dto);
        BigDecimal locked = dto.getLockedAmount() == null ? BigDecimal.ZERO : dto.getLockedAmount();
        if (locked.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("锁定额度参数错误");
        }
        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());
                // 第二步原子结算:先释放锁定额,再按实际消费扣减,允许扣成负余额(V0.2 定稿)
                boolean updated = lambdaUpdate()
                        .eq(TChargingCard::getId, dto.getCardId())
                        .setSql("balance = IFNULL(balance, 0) + " + locked.toPlainString()
                                + " - " + dto.getAmount().toPlainString()
                                + ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - "
                                + locked.toPlainString() + ", 0)")
                        .update();
                if (!updated) {
                    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;
        }
    }
    @Override
    public BigDecimal unlockBalance(Long cardId, BigDecimal amount) {
        if (cardId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("释放金额参数错误");
        }
        // 释放锁定额:余额回补,锁定额度扣减,允许余额超过锁定额(异常数据兜底)
        boolean updated = lambdaUpdate()
                .eq(TChargingCard::getId, cardId)
                .setSql("balance = IFNULL(balance, 0) + " + amount.toPlainString()
                        + ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + amount.toPlainString() + ", 0)")
                .update();
        if (!updated) {
            throw new ServiceException("卡片不存在或已删除");
        }
        TChargingCard latest = getById(cardId);
        return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
    }
    /**
     * 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司
     */
    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<Long, BigDecimal> 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<Long, BigDecimal> 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;
    }
    /** 写入卡片分配流水(分配为正数、回收为负数;公司为空或金额为0时自动忽略) */
    private void recordCardAllocate(String cardNo, Integer companyId, BigDecimal beforeBalance, BigDecimal amount,
                                    int changeSource, String remark) {
        try {
            companyAllocateRecordService.recordAllocate(TCompanyAllocateRecord.USER_TYPE_CARD, cardNo, null, companyId,
                    changeSource, beforeBalance, amount, remark);
        } catch (RuntimeException ex) {
            // 流水为审计数据,写入失败不阻断卡片额度变更主流程,仅记录日志便于排查
            log.error("卡片{}分配流水写入失败:{}", cardNo, ex.getMessage(), ex);
        }
    }
    /** 组装额度分配流水备注(含来源公司,超长截断以适配 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<BigDecimal> response) {
        if (response == null) {
            return "公司可用余额扣减失败:服务无响应";
        }
        String msg = response.getMsg();
        if (msg != null && msg.contains("余额不足")) {
            return "公司可分配余额不足,无法完成本次分配";
        }
        return "公司可用余额扣减失败:" + msg;
    }
    private TGroupCompany loadCompany(Integer companyId) {
        R<TGroupCompany> 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<TFleet> 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<BigDecimal> 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<BigDecimal> 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<BigDecimal> 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<BigDecimal> 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<Runnable> compensations) {
        for (int i = compensations.size() - 1; i >= 0; i--) {
            try {
                compensations.get(i).run();
            } catch (Exception ex) {
                log.error("公司可用余额补偿失败,需人工核对", ex);
            }
        }
    }
    /** 列表回填车队名称(远程失败不影响列表查询) */
    private void fillFleetNames(List<TChargingCard> records) {
        if (records == null || records.isEmpty()) {
            return;
        }
        List<Integer> fleetIds = records.stream()
                .map(TChargingCard::getFleetId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        if (fleetIds.isEmpty()) {
            return;
        }
        try {
            R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(fleetIds);
            if (response == null || !R.isSuccess(response) || response.getData() == null) {
                return;
            }
            Map<Integer, String> 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 fillAccountInfo(List<TChargingCard> records) {
        if (records == null || records.isEmpty()) {
            return;
        }
        Map<Integer, TGroupCompany> companyMap = loadCompanyMap(records.stream()
                .map(TChargingCard::getCompanyId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
        for (TChargingCard record : records) {
            TGroupCompany company = companyMap.get(record.getCompanyId());
            Integer accountMode = company == null ? null : company.getAccountMode();
            BigDecimal balance = resolveAvailableBalance(accountMode, record.getBalance(),
                    company == null ? null : company.getAvailableBalance());
            record.setCompanyName(company == null ? null : company.getName());
            record.setAccountMode(accountMode);
            record.setAvailableBalance(balance);
            record.setOverrun(resolveOverrun(balance));
            record.setAccountStatus(resolveAccountStatus(balance));
        }
    }
    /** 导出回填车队名称(远程失败不影响导出) */
    private void fillExportFleetNames(List<ChargingCardExcelDTO> items) {
        List<Integer> fleetIds = items.stream()
                .map(ChargingCardExcelDTO::getFleetId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        if (fleetIds.isEmpty()) {
            return;
        }
        try {
            R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(fleetIds);
            if (response == null || !R.isSuccess(response) || response.getData() == null) {
                return;
            }
            Map<Integer, String> nameMap = new HashMap<>();
            for (TFleet fleet : response.getData()) {
                if (fleet != null && fleet.getId() != null) {
                    nameMap.put(fleet.getId(), fleet.getFleetName());
                }
            }
            items.forEach(item -> item.setFleetName(nameMap.get(item.getFleetId())));
        } catch (Exception ex) {
            log.warn("导出回填车队名称失败,fleetIds={}", fleetIds, ex);
        }
    }
    /** 导出回填所属公司名称与账户信息(与列表同口径) */
    private void fillExportAccountInfo(List<ChargingCardExcelDTO> items) {
        Map<Integer, TGroupCompany> companyMap = loadCompanyMap(items.stream()
                .map(ChargingCardExcelDTO::getCompanyId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
        for (ChargingCardExcelDTO item : items) {
            TGroupCompany company = companyMap.get(item.getCompanyId());
            Integer accountMode = company == null ? null : company.getAccountMode();
            BigDecimal balance = resolveAvailableBalance(accountMode, item.getBalance(),
                    company == null ? null : company.getAvailableBalance());
            item.setCompanyName(company == null ? null : company.getName());
            item.setAccountMode(accountMode);
            item.setAvailableBalance(balance);
            item.setOverrun(resolveOverrun(balance));
            item.setAccountStatus(resolveAccountStatus(balance));
        }
    }
    /** 按ID批量加载公司(远程失败或服务降级时返回空Map,由调用方按未知处理) */
    private Map<Integer, TGroupCompany> loadCompanyMap(List<Integer> companyIds) {
        List<Integer> distinctIds = companyIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (distinctIds.isEmpty()) {
            return new HashMap<>();
        }
        try {
            R<List<TGroupCompany>> response = groupCompanyClient.listCompaniesByIds(distinctIds);
            if (response == null || !R.isSuccess(response) || response.getData() == null) {
                return new HashMap<>();
            }
            Map<Integer, TGroupCompany> companyMap = new HashMap<>();
            for (TGroupCompany company : response.getData()) {
                if (company != null && company.getId() != null) {
                    companyMap.put(company.getId(), company);
                }
            }
            return companyMap;
        } catch (Exception ex) {
            log.warn("回填公司账户信息失败,companyIds={}", distinctIds, ex);
            return new HashMap<>();
        }
    }
    /** 计算可用余额:分配模式=卡片自身余额,共享模式=公司可用余额,账户模式未知时留空 */
    private BigDecimal resolveAvailableBalance(Integer accountMode, BigDecimal cardBalance, BigDecimal companyBalance) {
        if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(accountMode)) {
            return cardBalance;
        }
        if (Integer.valueOf(ACCOUNT_MODE_SHARED).equals(accountMode)) {
            return companyBalance;
        }
        return null;
    }
    /** 超额:可用余额为负时取其绝对值,其余情况为空 */
    private BigDecimal resolveOverrun(BigDecimal balance) {
        if (balance == null || balance.signum() >= 0) {
            return null;
        }
        return balance.abs().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
    }
    /** 账户状态:可用余额为负=欠款,非负=正常,可用余额未知时为空 */
    private Integer resolveAccountStatus(BigDecimal balance) {
        if (balance == null) {
            return null;
        }
        return balance.signum() < 0 ? ACCOUNT_STATUS_OVERDUE : ACCOUNT_STATUS_NORMAL;
    }
    /**
     * 更新卡片记录:主字段走 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;
        }
        BigDecimal newBalance = card.getBalance();
        boolean balanceChanged = newBalance != null && newBalance.signum() > 0
                && !quotaEquals(existing.getBalance(), newBalance);
        boolean relationChanged = !Objects.equals(existing.getCompanyId(), card.getCompanyId())
                || !Objects.equals(existing.getFleetId(), card.getFleetId())
                || balanceChanged;
        if (!relationChanged) {
            return true;
        }
        UpdateWrapper<TChargingCard> relationUpdate = new UpdateWrapper<>();
        relationUpdate.eq("id", card.getId())
                .set("company_id", card.getCompanyId())
                .set("fleet_id", card.getFleetId());
        if (balanceChanged) {
            relationUpdate.set("balance", newBalance);
        }
        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);
        }
    }
@@ -382,22 +1162,6 @@
        return baseMapper.countByCardNo(cardNo, excludeId) > 0;
    }
    private void validateBalanceRequest(ChargingCardBalanceDTO dto) {
        if (dto == null || dto.getIds() == null || dto.getIds().isEmpty()) {
            throw new ServiceException("请选择需要更新余额的卡片");
        }
        if (dto.getType() == null || (dto.getType() != BALANCE_ADD && dto.getType() != BALANCE_SUBTRACT)) {
            throw new ServiceException("余额调整类型错误");
        }
        validateAmount(dto.getAmount(), false, "调整金额");
        if (dto.getRemark() != null && dto.getRemark().trim().length() > 255) {
            throw new ServiceException("备注不能超过255个字符");
        }
        if (StringUtils.isEmpty(dto.getOperationPassword())) {
            throw new ServiceException("请输入操作密码");
        }
    }
    private void validateDeductRequest(ChargingCardDeductDTO dto) {
        if (dto == null) {
            throw new ServiceException("扣款参数不能为空");
@@ -439,21 +1203,6 @@
        }
        String prefix = row > 0 ? "第" + row + "行" : "";
        throw new ServiceException(prefix + "启用状态错误");
    }
    private void verifyOperationPassword(String operationPassword) {
        LoginUser loginUser = tokenService.getLoginUser();
        if (loginUser == null || loginUser.getUserid() == null) {
            throw new ServiceException("登录状态已失效,请重新登录");
        }
        R<SysUser> response = sysUserClient.getSysUser(loginUser.getUserid());
        SysUser user = response == null ? null : response.getData();
        if (user == null || StringUtils.isEmpty(user.getOperationPassword())) {
            throw new ServiceException("当前账号未设置操作密码");
        }
        if (!SecurityUtils.matchesPassword(operationPassword, user.getOperationPassword())) {
            throw new ServiceException("操作密码错误");
        }
    }
    private boolean isValidStatus(Integer status) {