puzhibing
2026-09-30 e03f9a706ba46df72c1914870a0a707d6e5e676b
ruoyi-service/ruoyi-account/src/main/java/com/ruoyi/account/service/impl/TAppUserWhitelistServiceImpl.java
@@ -3,13 +3,20 @@
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.baomidou.mybatisplus.extension.conditions.update.LambdaUpdateChainWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.dto.AppUserWhitelistAllocateDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistAllocateItemDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistExcelDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistImportDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistQueryDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistSaveDTO;
import com.ruoyi.account.api.dto.AppUserWhitelistStatusDTO;
import com.ruoyi.account.api.model.TAppUser;
import com.ruoyi.account.api.model.TAppUserWhitelist;
import com.ruoyi.account.api.model.TAppUserWhitelistSite;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.account.api.vo.AppUserWhitelistAllocateVO;
import com.ruoyi.account.api.vo.AppUserWhitelistMatchVO;
import com.ruoyi.account.api.vo.AppUserWhitelistImportErrorVO;
import com.ruoyi.account.api.vo.AppUserWhitelistImportResultVO;
@@ -17,6 +24,7 @@
import com.ruoyi.account.service.TAppUserService;
import com.ruoyi.account.service.TAppUserWhitelistService;
import com.ruoyi.account.service.TAppUserWhitelistSiteService;
import com.ruoyi.account.service.TCompanyAllocateRecordService;
import com.ruoyi.chargingPile.api.domain.SiteMenu;
import com.ruoyi.chargingPile.api.feignClient.PartnerClient;
import com.ruoyi.chargingPile.api.feignClient.SiteClient;
@@ -78,9 +86,6 @@
    private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$");
    private static final String IMPORT_HEADER_PHONE = "用户手机号";
    private static final int BENEFIT_ALL_FREE = 1;
    private static final int BENEFIT_ELECTRIC_FREE = 2;
    private static final int BENEFIT_SERVICE_FREE = 3;
    private static final int SCOPE_ALL_SITE = 1;
    private static final int SCOPE_SELECTED_SITE = 2;
    private static final int SCOPE_PARTNER = 3;
@@ -89,12 +94,25 @@
    /** 姓名最大长度(对应 varchar(64)) */
    private static final int MAX_NAME_LENGTH = 64;
    /** 共享模式(公司账户模式 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;
    /** 金额小数位(元,对应 decimal(12,2)) */
    private static final int AMOUNT_SCALE = 2;
    /** 公司账户模式:2=分配模式(可设置初始分配额度并扣减公司可用余额) */
    private static final int ACCOUNT_MODE_ALLOCATION = 2;
    /** 初始分配额度上限(对应 decimal(12,2)) */
    private static final BigDecimal MAX_INIT_QUOTA = new BigDecimal("99999999.99");
    /** 初始分配额度并发锁前缀:新增按手机号、编辑按ID串行,防止并发下重复扣减或退还公司余额 */
    private static final String QUOTA_LOCK_PREFIX = "APP_USER_WHITELIST_QUOTA:";
    /** 额度分配:单次最多分配的白名单数量,防止单次请求过大 */
    private static final int MAX_ALLOCATE_WHITELIST_COUNT = 200;
    /** 白名单额度分配并发锁前缀:按公司串行,防止同公司并发分配重复占用可分配余额 */
    private static final String ALLOCATE_LOCK_PREFIX = "APP_USER_WHITELIST_QUOTA:COMPANY:";
    /** 获取锁最长等待时间(秒) */
    private static final long LOCK_WAIT_SECONDS = 5L;
    /** 锁自动释放时间(秒),兜住异常未解锁的场景 */
@@ -119,19 +137,33 @@
    @Autowired
    private RedissonClient redissonClient;
    @Resource
    private TCompanyAllocateRecordService companyAllocateRecordService;
    @Override
    public Page<TAppUserWhitelist> pageList(AppUserWhitelistQueryDTO queryDTO) {
        if (queryDTO == null) {
            queryDTO = new AppUserWhitelistQueryDTO();
        }
        AppUserWhitelistQueryDTO safeQuery = queryDTO == null ? new AppUserWhitelistQueryDTO() : queryDTO;
        Page<TAppUserWhitelist> page = buildListQuery(safeQuery)
                .orderByDesc(BasePojo::getCreateTime)
                .page(Page.of(safeQuery.getPageCurr(), safeQuery.getPageSize()));
        fillDisplay(page.getRecords());
        return page;
    }
    /**
     * 列表与导出共用的查询条件(含合作商数据范围):条件为空则不参与过滤;
     * 合作商账号只能看到自身合作商录入或本人创建的白名单数据
     */
    private LambdaQueryChainWrapper<TAppUserWhitelist> buildListQuery(AppUserWhitelistQueryDTO queryDTO) {
        AppUserWhitelistQueryDTO safeQuery = queryDTO == null ? new AppUserWhitelistQueryDTO() : queryDTO;
        Integer currentPartnerId = getCurrentPartnerId();
        String username = getUsername();
        LambdaQueryChainWrapper<TAppUserWhitelist> query = this.lambdaQuery()
                .like(StringUtils.isNotBlank(queryDTO.getPhone()), TAppUserWhitelist::getPhone, queryDTO.getPhone())
                .like(StringUtils.isNotBlank(queryDTO.getName()), TAppUserWhitelist::getName, queryDTO.getName())
                .eq(queryDTO.getCompanyId() != null, TAppUserWhitelist::getCompanyId, queryDTO.getCompanyId())
                .eq(queryDTO.getFleetId() != null, TAppUserWhitelist::getFleetId, queryDTO.getFleetId())
                .eq(queryDTO.getStatus() != null, TAppUserWhitelist::getStatus, queryDTO.getStatus())
                .like(StringUtils.isNotBlank(safeQuery.getPhone()), TAppUserWhitelist::getPhone, safeQuery.getPhone())
                .like(StringUtils.isNotBlank(safeQuery.getName()), TAppUserWhitelist::getName, safeQuery.getName())
                .eq(safeQuery.getCompanyId() != null, TAppUserWhitelist::getCompanyId, safeQuery.getCompanyId())
                .eq(safeQuery.getFleetId() != null, TAppUserWhitelist::getFleetId, safeQuery.getFleetId())
                .eq(safeQuery.getStatus() != null, TAppUserWhitelist::getStatus, safeQuery.getStatus())
                .eq(TAppUserWhitelist::getDelFlag, false);
        if (currentPartnerId != null) {
            if (StringUtils.isNotBlank(username)) {
@@ -142,10 +174,77 @@
                query.eq(TAppUserWhitelist::getPartnerId, currentPartnerId);
            }
        }
        Page<TAppUserWhitelist> page = query.orderByDesc(BasePojo::getCreateTime)
                .page(Page.of(queryDTO.getPageCurr(), queryDTO.getPageSize()));
        fillDisplay(page.getRecords());
        return page;
        return query;
    }
    @Override
    public List<AppUserWhitelistExcelDTO> selectExportList(AppUserWhitelistQueryDTO queryDTO) {
        List<TAppUserWhitelist> records = buildListQuery(queryDTO)
                .orderByDesc(BasePojo::getCreateTime)
                .list();
        if (records == null || records.isEmpty()) {
            return Collections.emptyList();
        }
        Map<Integer, String> fleetNameMap = loadFleetNameMap(records.stream()
                .map(TAppUserWhitelist::getFleetId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
        Map<Integer, TGroupCompany> companyMap = loadCompanyMap(records.stream()
                .map(TAppUserWhitelist::getCompanyId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
        List<AppUserWhitelistExcelDTO> items = new ArrayList<>(records.size());
        for (TAppUserWhitelist 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());
            AppUserWhitelistExcelDTO item = new AppUserWhitelistExcelDTO();
            item.setPhone(record.getPhone());
            item.setName(record.getName());
            item.setCompanyName(company == null ? null : company.getName());
            item.setFleetName(fleetNameMap.get(record.getFleetId()));
            item.setAccountMode(accountMode);
            item.setAvailableBalance(balance);
            item.setOverrun(resolveOverrun(balance));
            item.setAccountStatus(resolveAccountStatus(balance));
            item.setStatus(record.getStatus());
            item.setCompanyId(record.getCompanyId());
            item.setFleetId(record.getFleetId());
            item.setBalance(record.getBalance());
            items.add(item);
        }
        return items;
    }
    @Override
    public boolean updateStatus(AppUserWhitelistStatusDTO statusDTO) {
        if (statusDTO == null || statusDTO.getId() == null
                || (!Objects.equals(statusDTO.getStatus(), STATUS_ENABLED)
                && !Objects.equals(statusDTO.getStatus(), STATUS_DISABLED))) {
            throw new ServiceException("白名单用户状态参数错误");
        }
        TAppUserWhitelist existing = this.getById(statusDTO.getId());
        if (existing == null) {
            throw new ServiceException("白名单用户不存在或已删除");
        }
        assertPartnerVisible(existing);
        if (Objects.equals(existing.getStatus(), statusDTO.getStatus())) {
            // 状态未变化视为幂等成功,避免无意义的更新人与更新时间变更
            return true;
        }
        LambdaUpdateChainWrapper<TAppUserWhitelist> updateWrapper = lambdaUpdate()
                .eq(TAppUserWhitelist::getId, statusDTO.getId())
                .set(TAppUserWhitelist::getStatus, statusDTO.getStatus())
                .setSql("update_time = now()");
        String username = getUsername();
        if (StringUtils.isNotBlank(username)) {
            updateWrapper.set(TAppUserWhitelist::getUpdateBy, username);
        }
        if (!updateWrapper.update()) {
            throw new ServiceException("白名单用户状态更新失败,请重试");
        }
        return true;
    }
    @Override
@@ -190,6 +289,47 @@
            whitelistSiteService.remove(Wrappers.lambdaQuery(TAppUserWhitelistSite.class)
                    .eq(TAppUserWhitelistSite::getWhitelistId, whitelistId));
        }
    }
    /**
     * 批量解绑车队:仅清空车队归属(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<TAppUserWhitelist> records = listByIds(distinct);
        if (records.size() != distinct.size()) {
            throw new ServiceException("部分白名单用户不存在或已删除");
        }
        for (TAppUserWhitelist record : records) {
            assertPartnerVisible(record);
        }
        List<Long> boundIds = records.stream()
                .filter(record -> record.getFleetId() != null)
                .map(TAppUserWhitelist::getId)
                .collect(Collectors.toList());
        if (boundIds.isEmpty()) {
            return 0;
        }
        // 显式 set null 清空车队归属(MyBatis-Plus 字段策略会忽略实体 null,需走 UpdateWrapper)
        boolean updated = lambdaUpdate()
                .in(TAppUserWhitelist::getId, boundIds)
                .set(TAppUserWhitelist::getFleetId, null)
                .set(TAppUserWhitelist::getUpdateBy, getUsername())
                .setSql("update_time = now()")
                .update();
        if (!updated) {
            throw new ServiceException("白名单用户车队解绑失败,请重试");
        }
        return boundIds.size();
    }
    @Override
@@ -293,10 +433,167 @@
        AppUserWhitelistMatchVO result = new AppUserWhitelistMatchVO();
        result.setMatched(true);
        result.setWhitelistId(whitelist.getId());
        result.setBenefitType(whitelist.getBenefitType());
        // 白名单权益已下线(充电完全按企业优惠结算),命中结果不再返回权益类型与名称
        result.setScopeType(whitelist.getScopeType());
        result.setBenefitName(getBenefitName(whitelist.getBenefitType()));
        result.setCompanyId(whitelist.getCompanyId());
        result.setFleetId(whitelist.getFleetId());
        //企业账户分配模式:返回白名单账户可用余额,供建单侧计算下发金额与锁定额度
        result.setBalance(whitelist.getBalance());
        return result;
    }
    @Override
    public List<TAppUserWhitelist> listByCompany(Integer companyId) {
        if (companyId == null) {
            return Collections.emptyList();
        }
        return lambdaQuery()
                .eq(TAppUserWhitelist::getCompanyId, companyId)
                .eq(TAppUserWhitelist::getDelFlag, false)
                .orderByDesc(BasePojo::getCreateTime)
                .list();
    }
    /**
     * 锁定白名单账户余额(企业账户流程-分配模式,实扣冻结):
     * 余额立即扣减并计入 locked_amount,余额不足时条件更新命中 0 行直接失败;
     * 与 VIN/刷卡账户锁定口径一致(T VinManagementServiceImpl/TChargingCardServiceImpl)。
     */
    @Override
    public BigDecimal lockBalance(Long whitelistId, BigDecimal amount) {
        if (whitelistId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("锁定金额参数错误");
        }
        boolean updated = lambdaUpdate()
                .eq(TAppUserWhitelist::getId, whitelistId)
                .eq(TAppUserWhitelist::getDelFlag, false)
                .ge(TAppUserWhitelist::getBalance, amount)
                .setSql("balance = IFNULL(balance, 0) - " + amount.toPlainString()
                        + ", locked_amount = IFNULL(locked_amount, 0) + " + amount.toPlainString())
                .update();
        if (!updated) {
            throw new ServiceException("白名单账户余额不足");
        }
        TAppUserWhitelist latest = getById(whitelistId);
        return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
    }
    /**
     * 结算白名单账户余额:先释放锁定额,再按实际消费扣减,允许扣成负余额
     * (与 VIN 结算口径一致:balance = balance + locked - actual,locked_amount 归零下限保护)。
     */
    @Override
    public BigDecimal settleBalance(Long whitelistId, BigDecimal lockedAmount, BigDecimal actualAmount) {
        BigDecimal lock = lockedAmount == null ? BigDecimal.ZERO : lockedAmount;
        BigDecimal actual = actualAmount == null ? BigDecimal.ZERO : actualAmount;
        if (whitelistId == null || lock.compareTo(BigDecimal.ZERO) < 0 || actual.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("结算金额参数错误");
        }
        boolean updated = lambdaUpdate()
                .eq(TAppUserWhitelist::getId, whitelistId)
                .setSql("balance = IFNULL(balance, 0) + " + lock.toPlainString() + " - " + actual.toPlainString()
                        + ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + lock.toPlainString() + ", 0)")
                .update();
        if (!updated) {
            throw new ServiceException("白名单用户不存在或已删除");
        }
        TAppUserWhitelist latest = getById(whitelistId);
        return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
    }
    /**
     * 释放白名单账户锁定金额:余额回补、锁定额度扣减(未产生消费/订单回滚时使用)。
     */
    @Override
    public BigDecimal unlockBalance(Long whitelistId, BigDecimal amount) {
        if (whitelistId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new ServiceException("释放金额参数错误");
        }
        boolean updated = lambdaUpdate()
                .eq(TAppUserWhitelist::getId, whitelistId)
                .setSql("balance = IFNULL(balance, 0) + " + amount.toPlainString()
                        + ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + amount.toPlainString() + ", 0)")
                .update();
        if (!updated) {
            throw new ServiceException("白名单用户不存在或已删除");
        }
        TAppUserWhitelist latest = getById(whitelistId);
        return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
    }
    @Override
    public AppUserWhitelistAllocateVO 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 AppUserWhitelistAllocateVO allocateQuota(AppUserWhitelistAllocateDTO allocateDTO) {
        Map<Long, BigDecimal> amounts = validateAllocateRequest(allocateDTO);
        Integer companyId = allocateDTO.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<TAppUserWhitelist> whitelists = listByIds(amounts.keySet());
            if (whitelists == null || whitelists.size() != amounts.size()) {
                throw new ServiceException("部分白名单用户不存在或已删除");
            }
            for (TAppUserWhitelist whitelist : whitelists) {
                // 合作商账号仅能分配自身可见的白名单记录
                assertPartnerVisible(whitelist);
                BigDecimal amount = amounts.get(whitelist.getId());
                if (!companyId.equals(whitelist.getCompanyId())) {
                    throw new ServiceException("白名单用户【" + whitelist.getPhone() + "】不属于所选公司,无法分配额度");
                }
                if (!Integer.valueOf(STATUS_ENABLED).equals(whitelist.getStatus())) {
                    throw new ServiceException("白名单用户【" + whitelist.getPhone() + "】已停用,禁止分配额度");
                }
                BigDecimal balance = normalizeQuota(whitelist.getBalance());
                if (balance.add(amount).compareTo(MAX_INIT_QUOTA) > 0) {
                    throw new ServiceException("白名单用户【" + whitelist.getPhone() + "】分配后余额超过上限"
                            + MAX_INIT_QUOTA.toPlainString());
                }
            }
            // 先扣公司可分配余额(条件更新,余额不足直接失败),失败无需补偿
            R<BigDecimal> deductResponse = groupCompanyClient.deductBalance(companyId, total);
            if (deductResponse == null || !R.isSuccess(deductResponse)) {
                throw new ServiceException(buildDeductFailureMessage(deductResponse));
            }
            try {
                String operator = getUsername();
                for (TAppUserWhitelist whitelist : whitelists) {
                    BigDecimal amount = amounts.get(whitelist.getId());
                    BigDecimal before = normalizeQuota(whitelist.getBalance());
                    if (baseMapper.addBalance(whitelist.getId(), amount, operator) != 1) {
                        throw new ServiceException("白名单用户【" + whitelist.getPhone() + "】额度分配失败,请重试");
                    }
                    recordWhitelistAllocate(whitelist.getPhone(), whitelist.getName(), companyId, before, amount,
                            TCompanyAllocateRecord.SOURCE_ALLOCATE, buildWhitelistAllocateRemark(company));
                }
            } catch (RuntimeException ex) {
                // 白名单余额变更随事务回滚,公司余额为远程扣减需显式退还
                refundQuietly(companyId, total);
                throw ex;
            }
            AppUserWhitelistAllocateVO vo = buildAllocateOverview(company);
            vo.setAvailableBalance(normalizeMoney(deductResponse.getData()));
            return vo;
        } finally {
            unlockQuota(lock, locked);
        }
    }
    /**
@@ -370,12 +667,8 @@
            throw new ServiceException("请选择正确的状态");
        }
        saveDTO.setStatus(status);
        // 享受权益不传默认全部免费,传了仍按原规则校验
        Integer benefitType = saveDTO.getBenefitType() == null ? BENEFIT_ALL_FREE : saveDTO.getBenefitType();
        if (!isValidBenefit(benefitType)) {
            throw new ServiceException("请选择正确的享受权益");
        }
        saveDTO.setBenefitType(benefitType);
        // 白名单权益已下线:白名单用户充电完全按企业优惠结算,忽略入参中的享受权益
        saveDTO.setBenefitType(null);
        // 白名单电站不传默认全部电站,传了仍按原规则校验
        Integer scopeType = saveDTO.getScopeType() == null ? SCOPE_ALL_SITE : saveDTO.getScopeType();
        if (!isValidScope(scopeType)) {
@@ -411,7 +704,7 @@
            if (getByPhone(saveDTO.getPhone()) != null) {
                throw new ServiceException("该手机号已存在白名单配置");
            }
            BigDecimal quota = normalizeQuota(saveDTO.getInitQuota());
            BigDecimal quota = normalizeQuota(saveDTO.getBalance());
            Integer companyId = resolveRelationCompanyId(saveDTO);
            if (quota.signum() > 0) {
                requireAllocationCompany(companyId);
@@ -427,12 +720,11 @@
            whitelist.setAppUserId(appUser == null ? null : appUser.getId());
            whitelist.setPhone(saveDTO.getPhone());
            whitelist.setName(saveDTO.getName());
            whitelist.setBenefitType(saveDTO.getBenefitType());
            whitelist.setScopeType(saveDTO.getScopeType());
            whitelist.setPartnerId(resolveWhitelistPartnerId(saveDTO));
            whitelist.setCompanyId(companyId);
            whitelist.setFleetId(saveDTO.getFleetId());
            whitelist.setInitQuota(quota.signum() > 0 ? quota : null);
            whitelist.setBalance(quota.signum() > 0 ? quota : null);
            whitelist.setStatus(saveDTO.getStatus());
            whitelist.setUpdateBy(getUsername());
            whitelist.setRemark(saveDTO.getRemark());
@@ -458,6 +750,8 @@
                    whitelistSiteService.saveBatch(sites);
                }
            }
            recordWhitelistAllocate(whitelist.getPhone(), whitelist.getName(), companyId, BigDecimal.ZERO, quota,
                    TCompanyAllocateRecord.SOURCE_CREATE, "新增白名单初始分配额度");
        } catch (RuntimeException ex) {
            compensate(compensations);
            throw ex;
@@ -476,39 +770,50 @@
        RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + saveDTO.getId());
        boolean locked = tryQuotaLock(lock, "该白名单额度正在处理中,请稍后重试");
        List<Runnable> compensations = new ArrayList<>();
        // 分配记录延迟到白名单落库成功后写入:失败时公司余额已回补,不应留下流水
        List<Runnable> recordTasks = new ArrayList<>();
        try {
            TAppUserWhitelist whitelist = this.getById(saveDTO.getId());
            if (whitelist == null) {
                throw new ServiceException("白名单用户不存在");
            }
            assertPartnerVisible(whitelist);
            BigDecimal newQuota = normalizeQuota(saveDTO.getInitQuota());
            BigDecimal newQuota = normalizeQuota(saveDTO.getBalance());
            Integer newCompanyId = resolveRelationCompanyId(saveDTO);
            if (newQuota.signum() > 0) {
                requireAllocationCompany(newCompanyId);
            }
            Integer oldCompanyId = whitelist.getCompanyId();
            BigDecimal oldQuota = normalizeQuota(whitelist.getInitQuota());
            BigDecimal oldQuota = normalizeQuota(whitelist.getBalance());
            if (Objects.equals(oldCompanyId, newCompanyId)) {
                // 同一公司按差额调整:调增扣差额,调减退差额
                BigDecimal diff = newQuota.subtract(oldQuota);
                if (diff.signum() > 0 && newCompanyId != null) {
                    deductBalance(newCompanyId, diff);
                    compensations.add(() -> refundQuietly(newCompanyId, diff));
                    recordTasks.add(() -> recordWhitelistAllocate(saveDTO.getPhone(), saveDTO.getName(), newCompanyId,
                            oldQuota, diff, TCompanyAllocateRecord.SOURCE_EDIT, "编辑白名单调整初始分配额度"));
                } else if (diff.signum() < 0 && newCompanyId != null) {
                    BigDecimal refund = diff.negate();
                    refundBalance(newCompanyId, refund);
                    compensations.add(() -> deductQuietly(newCompanyId, refund));
                    recordTasks.add(() -> recordWhitelistAllocate(saveDTO.getPhone(), saveDTO.getName(), newCompanyId,
                            oldQuota, diff, TCompanyAllocateRecord.SOURCE_EDIT, "编辑白名单调减初始分配额度"));
                }
            } else {
                // 跨公司:旧公司全额退还,新公司全额扣减
                if (oldCompanyId != null && oldQuota.signum() > 0) {
                    refundBalance(oldCompanyId, oldQuota);
                    compensations.add(() -> deductQuietly(oldCompanyId, oldQuota));
                    recordTasks.add(() -> recordWhitelistAllocate(saveDTO.getPhone(), saveDTO.getName(), oldCompanyId,
                            oldQuota, oldQuota.negate(), TCompanyAllocateRecord.SOURCE_EDIT,
                            "编辑白名单调整所属公司(原公司退还)"));
                }
                if (newCompanyId != null && newQuota.signum() > 0) {
                    deductBalance(newCompanyId, newQuota);
                    compensations.add(() -> refundQuietly(newCompanyId, newQuota));
                    recordTasks.add(() -> recordWhitelistAllocate(saveDTO.getPhone(), saveDTO.getName(), newCompanyId,
                            BigDecimal.ZERO, newQuota, TCompanyAllocateRecord.SOURCE_EDIT, "编辑白名单调整所属公司"));
                }
            }
            TAppUser appUser = appUserService.lambdaQuery()
@@ -518,12 +823,11 @@
            whitelist.setAppUserId(appUser == null ? null : appUser.getId());
            whitelist.setPhone(saveDTO.getPhone());
            whitelist.setName(saveDTO.getName());
            whitelist.setBenefitType(saveDTO.getBenefitType());
            whitelist.setScopeType(saveDTO.getScopeType());
            whitelist.setPartnerId(resolveWhitelistPartnerId(saveDTO));
            whitelist.setCompanyId(newCompanyId);
            whitelist.setFleetId(saveDTO.getFleetId());
            whitelist.setInitQuota(newQuota.signum() > 0 ? newQuota : null);
            whitelist.setBalance(newQuota.signum() > 0 ? newQuota : null);
            whitelist.setStatus(saveDTO.getStatus());
            whitelist.setUpdateBy(getUsername());
            whitelist.setRemark(saveDTO.getRemark());
@@ -547,6 +851,7 @@
                    whitelistSiteService.saveBatch(sites);
                }
            }
            recordTasks.forEach(Runnable::run);
        } catch (RuntimeException ex) {
            compensate(compensations);
            throw ex;
@@ -565,6 +870,18 @@
        return this.baseMapper.selectValidByPhone(phone);
    }
    /** 写入白名单分配流水(分配为正数、回收为负数;公司为空或金额为0时自动忽略) */
    private void recordWhitelistAllocate(String phone, String name, Integer companyId, BigDecimal beforeBalance,
                                         BigDecimal amount, int changeSource, String remark) {
        try {
            companyAllocateRecordService.recordAllocate(TCompanyAllocateRecord.USER_TYPE_WHITELIST, phone, name,
                    companyId, changeSource, beforeBalance, amount, remark);
        } catch (RuntimeException ex) {
            // 流水为审计数据,写入失败不阻断白名单额度变更主流程,仅记录日志便于排查
            log.error("白名单{}分配流水写入失败:{}", phone, ex.getMessage(), ex);
        }
    }
    private List<String> validateImportRow(AppUserWhitelistImportDTO row) {
        List<String> errors = new ArrayList<>();
        if (row == null) {
@@ -578,13 +895,13 @@
        return errors;
    }
    /** 导入数据统一按默认值处理:全部免费 + 全部电站 */
    /** 导入数据统一按默认值处理:全部电站 + 启用(权益已下线,不再写入享受权益) */
    private AppUserWhitelistSaveDTO buildSaveDTO(AppUserWhitelistImportDTO row) {
        AppUserWhitelistSaveDTO dto = new AppUserWhitelistSaveDTO();
        dto.setPhone(normalize(row.getPhone()));
        dto.setBenefitType(BENEFIT_ALL_FREE);
        dto.setScopeType(SCOPE_ALL_SITE);
        dto.setSiteIds(Collections.emptyList());
        dto.setStatus(STATUS_ENABLED);
        return dto;
    }
@@ -596,7 +913,7 @@
        Map<Integer, Partner> partnerMap = getPartnerMap(records, siteMap);
        for (TAppUserWhitelist record : records) {
            record.setUid(record.getId() == null ? null : record.getId().toString());
            record.setBenefitName(getBenefitName(record.getBenefitType()));
            // 白名单权益已下线,列表不再填充享受权益名称
            record.setScopeName(getScopeName(record.getScopeType()));
            if (record.getPartnerId() != null) {
                Partner partner = partnerMap.get(record.getPartnerId());
@@ -620,48 +937,102 @@
            record.setSiteList(sites);
            record.setSiteIds(sites.stream().map(TAppUserWhitelistSite::getSiteId).collect(Collectors.toList()));
        }
        fillCompanyNames(records);
        fillCompanyInfo(records);
        fillFleetNames(records);
    }
    /** 列表回填所属公司名称(远程失败仅记录日志,不影响列表查询) */
    private void fillCompanyNames(List<TAppUserWhitelist> records) {
        Map<Integer, String> nameMap = new HashMap<>();
    /**
     * 列表回填所属公司名称与账户信息(账户模式、可用余额、超额、账户状态)。
     * 按公司ID去重后一次批量远程查询,远程失败仅记录日志并留空,不影响列表查询
     */
    private void fillCompanyInfo(List<TAppUserWhitelist> records) {
        Map<Integer, TGroupCompany> companyMap = loadCompanyMap(records.stream()
                .map(TAppUserWhitelist::getCompanyId)
                .filter(Objects::nonNull)
                .collect(Collectors.toList()));
        for (TAppUserWhitelist 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);
            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));
        }
    }
    /** 按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 whitelistBalance, BigDecimal companyBalance) {
        if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(accountMode)) {
            return whitelistBalance;
        }
        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;
    }
    /** 列表回填所属车队名称(批量查询,远程失败仅记录日志,不影响列表查询) */
    private void fillFleetNames(List<TAppUserWhitelist> records) {
        List<Integer> fleetIds = records.stream()
        Map<Integer, String> nameMap = loadFleetNameMap(records.stream()
                .map(TAppUserWhitelist::getFleetId)
                .filter(Objects::nonNull)
                .distinct()
                .collect(Collectors.toList());
        if (fleetIds.isEmpty()) {
            return;
                .collect(Collectors.toList()));
        records.forEach(record -> record.setFleetName(nameMap.get(record.getFleetId())));
    }
    /** 按ID批量加载车队名称(远程失败返回空Map,由调用方按未知处理) */
    private Map<Integer, String> loadFleetNameMap(List<Integer> fleetIds) {
        List<Integer> distinctIds = fleetIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
        if (distinctIds.isEmpty()) {
            return new HashMap<>();
        }
        try {
            R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(fleetIds);
            R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(distinctIds);
            if (response == null || !R.isSuccess(response) || response.getData() == null) {
                return;
                return new HashMap<>();
            }
            Map<Integer, String> nameMap = new HashMap<>();
            for (TFleet fleet : response.getData()) {
@@ -669,9 +1040,10 @@
                    nameMap.put(fleet.getId(), fleet.getFleetName());
                }
            }
            records.forEach(record -> record.setFleetName(nameMap.get(record.getFleetId())));
            return nameMap;
        } catch (Exception ex) {
            log.warn("回填车队名称失败,fleetIds={}", fleetIds, ex);
            log.warn("回填车队名称失败,fleetIds={}", distinctIds, ex);
            return new HashMap<>();
        }
    }
@@ -823,6 +1195,106 @@
        return response.getData();
    }
    /**
     * 额度分配:加载公司并校验为启用且分配模式。
     * 与初始分配额度校验分开,便于按场景返回更准确的提示,且不影响既有链路。
     */
    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 AppUserWhitelistAllocateVO buildAllocateOverview(TGroupCompany company) {
        AppUserWhitelistAllocateVO vo = new AppUserWhitelistAllocateVO();
        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(AppUserWhitelistAllocateDTO 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_WHITELIST_COUNT) {
            throw new ServiceException("单次最多分配" + MAX_ALLOCATE_WHITELIST_COUNT + "条白名单用户");
        }
        Map<Long, BigDecimal> amounts = new LinkedHashMap<>();
        for (AppUserWhitelistAllocateItemDTO item : dto.getItems()) {
            if (item == null || item.getWhitelistId() == null) {
                throw new ServiceException("分配明细中存在无效白名单用户");
            }
            if (amounts.containsKey(item.getWhitelistId())) {
                throw new ServiceException("分配明细中存在重复白名单用户");
            }
            validateAmount(item.getAmount(), "分配金额");
            if (item.getAmount().compareTo(MAX_INIT_QUOTA) > 0) {
                throw new ServiceException("单条白名单用户分配金额不能超过" + MAX_INIT_QUOTA.toPlainString());
            }
            amounts.put(item.getWhitelistId(), item.getAmount().setScale(2, RoundingMode.HALF_UP));
        }
        return amounts;
    }
    /** 校验金额:必填、大于0、最多2位小数 */
    private void validateAmount(BigDecimal amount, String fieldName) {
        if (amount == null) {
            throw new ServiceException(fieldName + "不能为空");
        }
        if (amount.signum() <= 0) {
            throw new ServiceException(fieldName + "必须大于0");
        }
        if (amount.stripTrailingZeros().scale() > 2) {
            throw new ServiceException(fieldName + "最多保留2位小数");
        }
    }
    /** 公司余额扣减失败提示:余额不足场景给出可读文案 */
    private String buildDeductFailureMessage(R<BigDecimal> response) {
        if (response == null) {
            return "公司可用余额扣减失败:服务无响应";
        }
        String msg = response.getMsg();
        if (msg != null && msg.contains("余额不足")) {
            return "公司可分配余额不足,无法完成本次分配";
        }
        return "公司可用余额扣减失败:" + msg;
    }
    /** 组装额度分配流水备注(含来源公司) */
    private String buildWhitelistAllocateRemark(TGroupCompany company) {
        String companyName = company == null || company.getName() == null ? "" : company.getName();
        String remark = "额度分配(公司:" + companyName + ")";
        return remark.length() > 255 ? remark.substring(0, 255) : remark;
    }
    private TFleet loadFleet(Integer fleetId) {
        R<TFleet> response = groupCompanyClient.getFleetById(fleetId);
        if (response == null || !R.isSuccess(response)) {
@@ -966,12 +1438,6 @@
        return StringUtils.isNotBlank(phone) && PHONE_PATTERN.matcher(phone).matches();
    }
    private boolean isValidBenefit(Integer benefitType) {
        return Objects.equals(benefitType, BENEFIT_ALL_FREE)
                || Objects.equals(benefitType, BENEFIT_ELECTRIC_FREE)
                || Objects.equals(benefitType, BENEFIT_SERVICE_FREE);
    }
    private boolean isValidScope(Integer scopeType) {
        return Objects.equals(scopeType, SCOPE_ALL_SITE)
                || Objects.equals(scopeType, SCOPE_SELECTED_SITE)
@@ -980,19 +1446,6 @@
    private String normalize(String value) {
        return value == null ? null : value.trim();
    }
    private String getBenefitName(Integer benefitType) {
        if (Objects.equals(benefitType, BENEFIT_ALL_FREE)) {
            return "全部免费";
        }
        if (Objects.equals(benefitType, BENEFIT_ELECTRIC_FREE)) {
            return "免电费";
        }
        if (Objects.equals(benefitType, BENEFIT_SERVICE_FREE)) {
            return "免服务费";
        }
        return "";
    }
    private String getScopeName(Integer scopeType) {