yanderong
4 天以前 7617d46abee2950b7336e3379f8c7a564939cb54
ruoyi-service/ruoyi-other/src/main/java/com/ruoyi/other/service/impl/TGroupCompanyServiceImpl.java
@@ -4,13 +4,19 @@
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.ruoyi.account.api.feignClient.AppUserWhitelistClient;
import com.ruoyi.account.api.feignClient.ChargingCardClient;
import com.ruoyi.account.api.feignClient.CompanyAllocateRecordClient;
import com.ruoyi.account.api.feignClient.VinManagementClient;
import com.ruoyi.account.api.model.TAppUserWhitelist;
import com.ruoyi.account.api.model.TChargingCard;
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
import com.ruoyi.account.api.model.TVinManagement;
import com.ruoyi.account.api.vo.CompanyAllocateSummaryVO;
import com.ruoyi.common.core.domain.R;
import com.ruoyi.common.core.exception.ServiceException;
import com.ruoyi.common.core.utils.StringUtils;
import com.ruoyi.common.core.web.domain.AjaxResult;
import com.ruoyi.common.security.utils.SecurityUtils;
import com.ruoyi.order.api.feignClient.ChargingOrderClient;
import com.ruoyi.other.api.domain.TFleet;
import com.ruoyi.other.api.domain.TGroupCompany;
import com.ruoyi.other.api.model.TCompanyRechargeRecord;
@@ -22,20 +28,26 @@
import com.ruoyi.system.api.domain.SysUser;
import com.ruoyi.system.api.feignClient.SysRoleClient;
import com.ruoyi.system.api.feignClient.SysUserClient;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
@@ -46,14 +58,15 @@
 * @author mxcd
 * @since 2026-09-22
 */
@Slf4j
@Service
public class TGroupCompanyServiceImpl extends ServiceImpl<TGroupCompanyMapper, TGroupCompany> implements TGroupCompanyService {
    /** 金额保留小数位(与 t_company_recharge_record.arrival_amount 一致) */
    private static final int AMOUNT_SCALE = 2;
    /** 公司账号角色标识(内置角色 role_key=company) */
    private static final String COMPANY_ROLE_KEY = "company";
    /** 公司管理员账号角色标识(内置角色 role_key=company_admin) */
    private static final String COMPANY_ROLE_KEY = "company_admin";
    /** 公司账号角色类型(sys_user.roleType=5) */
    private static final int COMPANY_ROLE_TYPE = 5;
@@ -78,6 +91,21 @@
    /** 账户状态:欠款 */
    private static final int ACCOUNT_STATUS_OVERDUE = 2;
    /** 公司登录账号前缀:admin */
    private static final String ACCOUNT_PREFIX = "admin";
    /** 公司登录账号随机数字位数 */
    private static final int ACCOUNT_RANDOM_DIGITS = 4;
    /** 账号生成查重重试上限 */
    private static final int ACCOUNT_GENERATE_MAX_ATTEMPTS = 20;
    /** 新增公司初始密码(明文,落库前按登录口径 HMAC-MD5 处理) */
    private static final String DEFAULT_INITIAL_PASSWORD = "123456";
    /** 密码传输 HMAC 密钥(与前端 CryptoJS.HmacMD5(password, 'password') 口径一致) */
    private static final String PASSWORD_HMAC_KEY = "password";
    /**
     * 车队 Mapper。
@@ -111,6 +139,14 @@
    @Resource
    private AppUserWhitelistClient appUserWhitelistClient;
    /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */
    @Resource
    private CompanyAllocateRecordClient companyAllocateRecordClient;
    /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */
    @Resource
    private ChargingOrderClient chargingOrderClient;
    @Override
    public TGroupCompany getDetail(Integer id) {
        if (id == null) {
@@ -135,7 +171,10 @@
        if (company == null) {
            return null;
        }
        company.setChargingUsers(buildChargingUsers(company));
        List<TVinManagement> vins = fetchVins(id);
        // VIN码车辆数量:按公司统计未删除车辆(与 VIN 列表接口同口径),降级时返回 0
        company.setVinCount(vins.size());
        company.setChargingUsers(buildChargingUsers(company, vins));
        return company;
    }
@@ -143,12 +182,11 @@
     * 聚合公司充电用户信息:VIN码、刷卡、白名单三类数据按统一结构返回。
     * 仅公司详情使用;remoteGet 远程调用保持 getDetail(不聚合),避免跨服务循环调用。
     */
    private List<CompanyChargingUserVO> buildChargingUsers(TGroupCompany company) {
    private List<CompanyChargingUserVO> buildChargingUsers(TGroupCompany company, List<TVinManagement> vins) {
        Integer companyId = company.getId();
        Integer accountMode = company.getAccountMode();
        List<CompanyChargingUserVO> result = new ArrayList<>();
        List<TVinManagement> vins = fetchVins(companyId);
        List<TChargingCard> cards = fetchCards(companyId);
        List<TAppUserWhitelist> whitelists = fetchWhitelists(companyId);
@@ -175,7 +213,7 @@
        for (TVinManagement vin : vins) {
            result.add(buildRow(USER_TYPE_VIN + "-" + vin.getId(), USER_TYPE_VIN, vin.getVin(),
                    companyName, fleetNameMap.get(vin.getFleetId()), accountMode,
                    vin.getInitQuota(), companyBalance, vin.getStatus()));
                    vin.getBalance(), companyBalance, vin.getStatus()));
        }
        for (TChargingCard card : cards) {
            result.add(buildRow(USER_TYPE_CARD + "-" + card.getId(), USER_TYPE_CARD, card.getCardNo(),
@@ -185,7 +223,7 @@
        for (TAppUserWhitelist whitelist : whitelists) {
            result.add(buildRow(USER_TYPE_WHITELIST + "-" + whitelist.getId(), USER_TYPE_WHITELIST, whitelist.getPhone(),
                    companyName, fleetNameMap.get(whitelist.getFleetId()), accountMode,
                    whitelist.getInitQuota(), companyBalance, whitelist.getStatus()));
                    whitelist.getBalance(), companyBalance, whitelist.getStatus()));
        }
        return result;
    }
@@ -345,7 +383,7 @@
    }
    /**
     * 查询公司默认角色ID(内置角色 role_key=company)
     * 查询公司默认角色ID(内置角色 role_key=company_admin)
     */
    private Long resolveCompanyRoleId() {
        R<Long> roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY);
@@ -368,30 +406,21 @@
    @Override
    @Transactional(rollbackFor = Exception.class)
    public AjaxResult addCompany(TGroupCompany company) {
        if (!StringUtils.hasText(company.getAccount())) {
            return AjaxResult.error("登录账号不能为空");
        }
        // 登录账号由系统自动生成:admin + 4位随机数字,前端不可指定、不可修改
        String account = generateCompanyAccount();
        company.setAccount(account);
        // 添加企业弹窗不再配置账户模式:未指定时按共享模式建档,后续可在“账户模式”弹窗切换
        if (company.getAccountMode() == null) {
            company.setAccountMode(ACCOUNT_MODE_SHARED);
        }
        if (!StringUtils.hasText(company.getPassword())) {
            return AjaxResult.error("登录密码不能为空");
        }
        if (accountExists(company.getAccount(), null)) {
            return AjaxResult.error("登录账号不能重复");
        }
        R<SysUser> existR = sysUserClient.queryUserByUserName(company.getAccount());
        SysUser existUser = existR == null ? null : existR.getData();
        if (existUser != null) {
            return AjaxResult.error("登录账号不能重复");
        }
        // 初始密码固定 123456,按登录口径 HMAC-MD5 后由 system 侧 BCrypt 落库
        String password = hmacMd5(DEFAULT_INITIAL_PASSWORD, PASSWORD_HMAC_KEY);
        Long roleId = resolveCompanyRoleId();
        SysUser user = new SysUser();
        user.setUserName(company.getAccount());
        user.setUserName(account);
        user.setNickName(company.getName());
        user.setPhonenumber(company.getContactPhone());
        user.setPassword(company.getPassword());
        user.setPassword(password);
        user.setStatus("0");
        user.setDelFlag("0");
        user.setRoleId(roleId);
@@ -409,7 +438,47 @@
            created.setObjectId(company.getId());
            sysUserClient.updateUser(created);
        }
        return AjaxResult.success();
        // 返回生成的登录账号,供添加页回显(初始密码为固定 123456)
        return AjaxResult.success("操作成功", account);
    }
    /**
     * 生成公司登录账号:admin + 4位随机数字,公司表与 sys_user 双查重,冲突重试
     */
    private String generateCompanyAccount() {
        int start = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS - 1);
        int bound = (int) Math.pow(10, ACCOUNT_RANDOM_DIGITS) - start;
        for (int i = 0; i < ACCOUNT_GENERATE_MAX_ATTEMPTS; i++) {
            String candidate = ACCOUNT_PREFIX + (start + ThreadLocalRandom.current().nextInt(bound));
            if (accountExists(candidate, null)) {
                continue;
            }
            R<SysUser> existR = sysUserClient.queryUserByUserName(candidate);
            SysUser existUser = existR == null ? null : existR.getData();
            if (existUser == null) {
                return candidate;
            }
        }
        throw new ServiceException("登录账号生成失败,请稍后重试");
    }
    /**
     * HMAC-MD5(小写 Hex),与前端 CryptoJS.HmacMD5(text, key) 口径一致
     */
    private String hmacMd5(String text, String key) {
        try {
            Mac mac = Mac.getInstance("HmacMD5");
            mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacMD5"));
            byte[] bytes = mac.doFinal(text.getBytes(StandardCharsets.UTF_8));
            StringBuilder hex = new StringBuilder(bytes.length * 2);
            for (byte b : bytes) {
                hex.append(Character.forDigit((b >> 4) & 0xF, 16));
                hex.append(Character.forDigit(b & 0xF, 16));
            }
            return hex.toString();
        } catch (Exception e) {
            throw new ServiceException("密码处理失败: " + e.getMessage());
        }
    }
    @Override
@@ -419,7 +488,7 @@
        if (old == null) {
            return AjaxResult.error("公司不存在");
        }
        // 编辑时账号只读(前端禁用),此处兼容外部调用方传入账号的场景:唯一性校验(排除自身)
        // 编辑支持修改登录账号:公司表唯一性校验(排除自身)
        if (StringUtils.hasText(company.getAccount())) {
            if (accountExists(company.getAccount(), company.getId())) {
                return AjaxResult.error("登录账号不能重复");
@@ -427,17 +496,36 @@
        }
        String account = StringUtils.hasText(company.getAccount()) ? company.getAccount() : old.getAccount();
        if (StringUtils.hasText(account)) {
            syncCompanyUserOnEdit(company, account);
            String error = syncCompanyUserOnEdit(company, old.getAccount(), account);
            if (error != null) {
                return AjaxResult.error(error);
            }
        }
        updateById(company);
        return AjaxResult.success();
    }
    /**
     * 编辑公司时同步登录账号:存在则仅更新账号资料(不修改密码),不存在则补建
     * 编辑公司时同步登录账号:按旧账号定位用户后更新为新账号(支持改账号);
     * 密码有值时重置(留空不修改);用户不存在则按新账号补建
     *
     * @return 冲突等业务错误信息,null 表示成功
     */
    private void syncCompanyUserOnEdit(TGroupCompany company, String account) {
        R<SysUser> userR = sysUserClient.queryUserByUserName(account);
    private String syncCompanyUserOnEdit(TGroupCompany company, String oldAccount, String newAccount) {
        // 账号变更时校验新账号未被其他用户占用(updateSysUser 无唯一性校验,需在此前置拦截)
        if (StringUtils.hasText(oldAccount) && !newAccount.equals(oldAccount)) {
            R<SysUser> occupantR = sysUserClient.queryUserByUserName(newAccount);
            SysUser occupant = occupantR == null ? null : occupantR.getData();
            if (occupant != null) {
                R<SysUser> ownerR = sysUserClient.queryUserByUserName(oldAccount);
                SysUser owner = ownerR == null ? null : ownerR.getData();
                if (owner == null || !occupant.getUserId().equals(owner.getUserId())) {
                    return "登录账号不能重复";
                }
            }
        }
        // 按旧账号定位既有用户(账号未变时与新账号一致),避免改名后误走补建导致双账号
        R<SysUser> userR = sysUserClient.queryUserByUserName(StringUtils.hasText(oldAccount) ? oldAccount : newAccount);
        SysUser user = userR == null ? null : userR.getData();
        if (user == null) {
            if (!StringUtils.hasText(company.getPassword())) {
@@ -445,7 +533,7 @@
            }
            Long roleId = resolveCompanyRoleId();
            SysUser newUser = new SysUser();
            newUser.setUserName(account);
            newUser.setUserName(newAccount);
            newUser.setNickName(company.getName());
            newUser.setPhonenumber(company.getContactPhone());
            newUser.setPassword(company.getPassword());
@@ -455,22 +543,33 @@
            newUser.setRoleType(COMPANY_ROLE_TYPE);
            newUser.setObjectId(company.getId());
            newUser.setPassWordUpdate(new Date());
            R<Long> r = sysUserClient.addSysUser(newUser);
            if (r == null || 200 != r.getCode()) {
                throw new RuntimeException(r == null ? "创建登录账号失败" : r.getMsg());
            R<Long> addR = sysUserClient.addSysUser(newUser);
            if (addR == null || 200 != addR.getCode()) {
                throw new RuntimeException(addR == null ? "创建登录账号失败" : addR.getMsg());
            }
            return;
            return null;
        }
        // 远程查询返回的密码字段可能是哈希或脱敏值,显式清空避免再次写回
        user.setPassword(null);
        user.setPassWordUpdate(null);
        user.setUserName(account);
        user.setUserName(newAccount);
        user.setNickName(company.getName());
        user.setPhonenumber(company.getContactPhone());
        R<Boolean> r = sysUserClient.updateSysUser(user);
        if (r == null || 200 != r.getCode()) {
            throw new RuntimeException(r == null ? "更新登录账号失败" : r.getMsg());
        R<Boolean> updateR = sysUserClient.updateSysUser(user);
        if (updateR == null || 200 != updateR.getCode()) {
            throw new RuntimeException(updateR == null ? "更新登录账号失败" : updateR.getMsg());
        }
        // 密码有值时重置(前端已 HMAC-MD5,由 system 侧 BCrypt 落库,与新增链路一致)
        if (StringUtils.hasText(company.getPassword())) {
            SysUser pwdUser = new SysUser();
            pwdUser.setUserId(user.getUserId());
            pwdUser.setPassword(company.getPassword());
            R pwdR = sysUserClient.resetPassword(pwdUser);
            if (pwdR == null || 200 != pwdR.getCode()) {
                throw new RuntimeException(pwdR == null ? "重置登录密码失败" : pwdR.getMsg());
            }
        }
        return null;
    }
    @Override
@@ -506,11 +605,244 @@
        if (company == null) {
            return AjaxResult.error("公司不存在");
        }
        // 目标模式与当前一致:幂等直接返回
        if (Objects.equals(company.getAccountMode(), accountMode)) {
            return AjaxResult.success();
        }
        // 统一校验:无论切换方向,存在进行中充电订单均拒绝切换
        AjaxResult chargingCheck = ensureNoActiveChargingOrder(id);
        if (chargingCheck != null) {
            return chargingCheck;
        }
        // 共享 → 分配:仅切换模式,不涉及资金回退
        if (accountMode == ACCOUNT_MODE_ALLOCATION) {
            TGroupCompany update = new TGroupCompany();
            update.setId(id);
            update.setAccountMode(accountMode);
            updateById(update);
            return AjaxResult.success();
        }
        // 分配 → 共享:校验在途业务后回收全部分配余额
        return switchAllocationToShared(id, company);
    }
    /**
     * 校验公司无进行中充电订单(三表合计,状态 2=启动中/3=充电中/4=停止中)。
     * 通过返回 null;不通过返回可直接回给前端的错误结果。
     * 账户模式双向切换均须先通过本校验。
     */
    private AjaxResult ensureNoActiveChargingOrder(Integer id) {
        R<Integer> activeCountResult;
        try {
            activeCountResult = chargingOrderClient.countActiveByCompany(id);
        } catch (RuntimeException ex) {
            log.error("[模式切换] 统计进行中充电订单异常 companyId={}", id, ex);
            return AjaxResult.error("校验充电状态失败,请稍后重试");
        }
        if (activeCountResult == null || R.SUCCESS != activeCountResult.getCode()) {
            return AjaxResult.error("校验充电状态失败,请稍后重试");
        }
        Integer activeCount = activeCountResult.getData();
        if (activeCount != null && activeCount > 0) {
            return AjaxResult.error("该公司存在正在充电的订单,暂不允许切换账户模式");
        }
        return null;
    }
    /**
     * 分配模式切换为共享模式:
     * <ol>
     *   <li>校验公司与三类主体均无未释放充电锁定额</li>
     *   <li>公司余额预加回回收总额 + 切换为共享模式(本地事务)</li>
     *   <li>远程回收三类主体全部余额并写双向流水(account 侧锁内事务,幂等)</li>
     *   <li>按实际回收额与预加回额差额校准公司余额(并发分配竞态兜底)</li>
     * </ol>
     * 进行中充电订单校验已在 updateAccountMode 公共入口完成。
     */
    private AjaxResult switchAllocationToShared(Integer id, TGroupCompany company) {
        // 1. 公司锁定额校验(在途充电锁定未释放)
        BigDecimal companyLocked = company.getLockedAmount();
        if (companyLocked != null && companyLocked.signum() > 0) {
            return AjaxResult.error("公司存在未释放的充电锁定金额,暂不允许切换为共享模式");
        }
        // 2. 三类主体余额与锁定额统计
        R<CompanyAllocateSummaryVO> summaryResult;
        try {
            summaryResult = companyAllocateRecordClient.sumAllocatedBalance(id);
        } catch (RuntimeException ex) {
            log.error("[模式切换] 统计分配余额异常 companyId={}", id, ex);
            return AjaxResult.error("查询分配余额失败,请稍后重试");
        }
        if (summaryResult == null || R.SUCCESS != summaryResult.getCode() || summaryResult.getData() == null) {
            return AjaxResult.error("查询分配余额失败,请稍后重试");
        }
        CompanyAllocateSummaryVO summary = summaryResult.getData();
        if (summary.getTotalLocked() != null && summary.getTotalLocked().signum() > 0) {
            return AjaxResult.error("存在充电锁定金额未释放,暂不允许切换为共享模式");
        }
        BigDecimal total = summary.getTotalBalance() == null ? BigDecimal.ZERO : summary.getTotalBalance();
        BigDecimal companyBalanceBefore = company.getAvailableBalance() == null
                ? BigDecimal.ZERO : company.getAvailableBalance();
        // 3. 公司余额加回 + 模式切换(本地事务)
        if (!updateCompanyToShared(id, total)) {
            throw new ServiceException("公司余额更新失败");
        }
        // 4. 远程回收三类主体余额(account 侧三把公司级锁串行、事务、幂等),失败回滚公司余额与模式
        BigDecimal actual;
        try {
            actual = recycleAllocatedBalanceRemote(id, company, companyBalanceBefore);
        } catch (RuntimeException ex) {
            log.error("[模式切换] 远程回收分配余额异常 companyId={}", id, ex);
            rollbackModeSwitch(id, total);
            throw new ServiceException("回收分配余额失败,请稍后重试");
        }
        // 5. 差额调整:回收与统计间存在并发分配时,按实际回收额校准公司余额
        BigDecimal diff = actual.subtract(total);
        if (diff.signum() != 0) {
            boolean adjusted = lambdaUpdate()
                    .eq(TGroupCompany::getId, id)
                    .setSql("available_balance = IFNULL(available_balance, 0) + (" + diff.toPlainString() + ")")
                    .update();
            if (!adjusted) {
                log.error("[模式切换] 公司余额差额调整失败 companyId={} diff={}", id, diff.toPlainString());
            }
        }
        log.info("[模式切换] 分配→共享成功 companyId={} 回收总额={}", id, actual.toPlainString());
        return AjaxResult.success("账户模式已切换为共享模式,回收分配余额 " + actual.toPlainString() + " 元");
    }
    /** 更新公司余额(加回回收额)并切换为共享模式,返回是否更新成功 */
    private boolean updateCompanyToShared(Integer id, BigDecimal total) {
        if (total.signum() > 0) {
            return lambdaUpdate()
                    .eq(TGroupCompany::getId, id)
                    .setSql("available_balance = IFNULL(available_balance, 0) + " + total.toPlainString())
                    .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
                    .update();
        }
        TGroupCompany update = new TGroupCompany();
        update.setId(id);
        update.setAccountMode(accountMode);
        updateById(update);
        return AjaxResult.success();
        update.setAccountMode(ACCOUNT_MODE_SHARED);
        return updateById(update);
    }
    /** 远程回收三类主体分配余额,失败(远程失败码/返回体异常)抛出 ServiceException 由调用方补偿 */
    private BigDecimal recycleAllocatedBalanceRemote(Integer id, TGroupCompany company,
                                                     BigDecimal companyBalanceBefore) {
        String operatorName = null;
        try {
            operatorName = SecurityUtils.getLoginUser().getUsername();
        } catch (Exception ignored) {
            // 无安全上下文时(内部调用)操作人留空
        }
        R<BigDecimal> recycleResult = companyAllocateRecordClient.recycleAllocatedBalance(id, operatorName,
                company.getName(), companyBalanceBefore);
        if (recycleResult == null || R.SUCCESS != recycleResult.getCode() || recycleResult.getData() == null) {
            String msg = recycleResult == null ? "远程调用失败" : recycleResult.getMsg();
            log.error("[模式切换] 回收分配余额失败 companyId={} msg={}", id, msg);
            throw new ServiceException("回收分配余额失败:" + msg);
        }
        return recycleResult.getData();
    }
    /**
     * 回收失败补偿:公司余额减回预加回额、模式恢复为分配模式。
     * 仅当模式仍为共享时执行恢复,避免覆盖后续并发操作;恢复失败仅记录日志(下轮对账/重试兜底)。
     */
    private void rollbackModeSwitch(Integer id, BigDecimal total) {
        try {
            boolean rollbacked = lambdaUpdate()
                    .eq(TGroupCompany::getId, id)
                    .eq(TGroupCompany::getAccountMode, ACCOUNT_MODE_SHARED)
                    .setSql("available_balance = IFNULL(available_balance, 0) - " + total.toPlainString())
                    .set(TGroupCompany::getAccountMode, ACCOUNT_MODE_ALLOCATION)
                    .update();
            log.warn("[模式切换] 回收失败已回滚 companyId={} rollbacked={}", id, rollbacked);
        } catch (RuntimeException ex) {
            log.error("[模式切换] 回收失败回滚异常 companyId={}", id, ex);
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public BigDecimal settleBalance(Integer companyId, BigDecimal lockedAmount, BigDecimal actualAmount, String orderCode,
                                    String userInfo, String userName, String remark) {
        if (companyId == null) {
            throw new ServiceException("公司ID不能为空");
        }
        BigDecimal lock = lockedAmount == null ? BigDecimal.ZERO : lockedAmount;
        BigDecimal actual = actualAmount == null ? BigDecimal.ZERO : actualAmount;
        if (lock.compareTo(BigDecimal.ZERO) < 0 || actual.compareTo(BigDecimal.ZERO) < 0) {
            throw new ServiceException("锁定金额与实际消费金额不能为负数");
        }
        boolean hasOrderCode = StringUtils.isNotEmpty(orderCode);
        // 幂等:账户侧已落该订单消费流水 → 已结算过,直接返回当前余额(结算失败回退订单状态后的补偿重试不会重复结算)
        if (hasOrderCode && isOrderConsumed(orderCode)) {
            TGroupCompany existed = getById(companyId);
            log.info("[公司结算] 订单已结算,幂等跳过:orderCode={}, companyId={}", orderCode, companyId);
            return existed == null || existed.getAvailableBalance() == null ? BigDecimal.ZERO
                    : existed.getAvailableBalance().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
        }
        boolean updated = lambdaUpdate()
                .eq(TGroupCompany::getId, companyId)
                .setSql("locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + lock.toPlainString() + ", 0)"
                        + ", available_balance = IFNULL(available_balance, 0) + " + lock.toPlainString()
                        + " - " + actual.toPlainString())
                .update();
        if (!updated) {
            throw new ServiceException("公司不存在或已删除");
        }
        TGroupCompany latest = getById(companyId);
        BigDecimal after = latest == null || latest.getAvailableBalance() == null ? BigDecimal.ZERO
                : latest.getAvailableBalance().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
        if (hasOrderCode) {
            // 公司余额在公司库、消费流水在账户库,无法同事务:流水尽力写入,写入成功即作为该订单的结算幂等标记
            BigDecimal before = after.add(actual).subtract(lock);
            writeConsumeQuietly(latest, orderCode, userInfo, userName, before, actual.negate(), remark);
        }
        return after;
    }
    /**
     * 判定账户侧是否已存在该订单的消费流水(查询异常按「未结算」处理,交由订单侧兜底扫描兜住)
     */
    private boolean isOrderConsumed(String orderCode) {
        try {
            R<List<String>> result = companyAllocateRecordClient.listConsumedOrderCodes(
                    Collections.singletonList(orderCode));
            return result != null && R.SUCCESS == result.getCode() && result.getData() != null
                    && result.getData().contains(orderCode);
        } catch (Exception ex) {
            log.error("[公司结算] 查询消费流水失败,按未结算处理:orderCode={}", orderCode, ex);
            return false;
        }
    }
    /**
     * 写企业额度分配记录-消费流水(共享模式记企业账户)。失败只记日志,不影响公司余额结算主流程
     */
    private void writeConsumeQuietly(TGroupCompany company, String orderCode, String userInfo, String userName,
                                     BigDecimal before, BigDecimal amount, String remark) {
        if (company == null || company.getId() == null) {
            log.error("[公司结算] 缺少公司信息,无法写消费流水:orderCode={}", orderCode);
            return;
        }
        String userInfoValue = StringUtils.isNotEmpty(userInfo) ? userInfo : company.getName();
        if (StringUtils.isEmpty(userInfoValue)) {
            userInfoValue = String.valueOf(company.getId());
        }
        String userNameValue = StringUtils.isNotEmpty(userName) ? userName : company.getName();
        try {
            R<Boolean> result = companyAllocateRecordClient.recordConsume(company.getId(),
                    TCompanyAllocateRecord.USER_TYPE_COMPANY, userInfoValue, userNameValue, before, amount,
                    orderCode, remark);
            if (result == null || R.SUCCESS != result.getCode() || !Boolean.TRUE.equals(result.getData())) {
                log.error("[公司结算] 消费流水写入失败:orderCode={}, companyId={}, msg={}", orderCode, company.getId(),
                        result == null ? "null" : result.getMsg());
            }
        } catch (Exception ex) {
            log.error("[公司结算] 消费流水写入异常:orderCode={}, companyId={}", orderCode, company.getId(), ex);
        }
    }
}