package com.ruoyi.other.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; 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; import com.ruoyi.other.api.vo.CompanyChargingUserVO; import com.ruoyi.other.mapper.TCompanyRechargeRecordMapper; import com.ruoyi.other.mapper.TFleetMapper; import com.ruoyi.other.mapper.TGroupCompanyMapper; import com.ruoyi.other.service.TGroupCompanyService; 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; /** *

* 公司(企业)管理 服务实现类 *

* * @author mxcd * @since 2026-09-22 */ @Slf4j @Service public class TGroupCompanyServiceImpl extends ServiceImpl implements TGroupCompanyService { /** 金额保留小数位(与 t_company_recharge_record.arrival_amount 一致) */ private static final int AMOUNT_SCALE = 2; /** 公司管理员账号角色标识(内置角色 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; /** 账户模式:共享模式(新增企业未指定时按此建档) */ private static final int ACCOUNT_MODE_SHARED = 1; /** 账户模式:分配模式 */ private static final int ACCOUNT_MODE_ALLOCATION = 2; /** 充电用户类型:VIN码 */ private static final int USER_TYPE_VIN = 1; /** 充电用户类型:刷卡 */ private static final int USER_TYPE_CARD = 2; /** 充电用户类型:白名单 */ private static final int USER_TYPE_WHITELIST = 3; /** 账户状态:正常 */ private static final int ACCOUNT_STATUS_NORMAL = 1; /** 账户状态:欠款 */ 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。 * 此处注入 Mapper 而非 TFleetService:TFleetServiceImpl 已依赖 TGroupCompanyService, * 反向注入 Service 会形成循环引用(Spring Boot 2.7 默认禁止)。 */ @Resource private TFleetMapper fleetMapper; /** 公司充值记录 Mapper */ @Resource private TCompanyRechargeRecordMapper companyRechargeRecordMapper; /** 系统用户远程客户端(公司登录账号同步) */ @Resource private SysUserClient sysUserClient; /** 系统角色远程客户端(公司默认角色查询) */ @Resource private SysRoleClient sysRoleClient; /** VIN管理远程客户端(公司详情-充电用户信息聚合使用) */ @Resource private VinManagementClient vinManagementClient; /** 充电卡远程客户端(公司详情-充电用户信息聚合使用) */ @Resource private ChargingCardClient chargingCardClient; /** 白名单远程客户端(公司详情-充电用户信息聚合使用) */ @Resource private AppUserWhitelistClient appUserWhitelistClient; /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */ @Resource private CompanyAllocateRecordClient companyAllocateRecordClient; /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */ @Resource private ChargingOrderClient chargingOrderClient; @Override public TGroupCompany getDetail(Integer id) { if (id == null) { return null; } TGroupCompany company = getById(id); if (company == null) { return null; } // 车队数量:t_fleet 逻辑删除由 @TableLogic 自动过滤,统计含停用车队 company.setFleetCount(countFleet(id)); // 月充值金额:本月到账金额合计,仅覆盖响应对象,不落库 company.setMonthRechargeAmount(sumCurrentMonthRechargeAmount(id)); // 累计充值到账金额:作为公司总账户余额展示(额度分配弹窗使用),仅覆盖响应对象,不落库 company.setTotalRechargeAmount(sumRechargeAmount(id)); return company; } @Override public TGroupCompany getDetailWithChargingUsers(Integer id) { TGroupCompany company = getDetail(id); if (company == null) { return null; } List vins = fetchVins(id); // VIN码车辆数量:按公司统计未删除车辆(与 VIN 列表接口同口径),降级时返回 0 company.setVinCount(vins.size()); company.setChargingUsers(buildChargingUsers(company, vins)); return company; } /** * 聚合公司充电用户信息:VIN码、刷卡、白名单三类数据按统一结构返回。 * 仅公司详情使用;remoteGet 远程调用保持 getDetail(不聚合),避免跨服务循环调用。 */ private List buildChargingUsers(TGroupCompany company, List vins) { Integer companyId = company.getId(); Integer accountMode = company.getAccountMode(); List result = new ArrayList<>(); List cards = fetchCards(companyId); List whitelists = fetchWhitelists(companyId); List fleetIds = new ArrayList<>(); for (TVinManagement vin : vins) { if (vin.getFleetId() != null) { fleetIds.add(vin.getFleetId()); } } for (TChargingCard card : cards) { if (card.getFleetId() != null) { fleetIds.add(card.getFleetId()); } } for (TAppUserWhitelist whitelist : whitelists) { if (whitelist.getFleetId() != null) { fleetIds.add(whitelist.getFleetId()); } } Map fleetNameMap = loadFleetNameMap(fleetIds); String companyName = company.getName(); BigDecimal companyBalance = company.getAvailableBalance(); for (TVinManagement vin : vins) { result.add(buildRow(USER_TYPE_VIN + "-" + vin.getId(), USER_TYPE_VIN, vin.getVin(), companyName, fleetNameMap.get(vin.getFleetId()), accountMode, vin.getBalance(), companyBalance, vin.getStatus())); } for (TChargingCard card : cards) { result.add(buildRow(USER_TYPE_CARD + "-" + card.getId(), USER_TYPE_CARD, card.getCardNo(), companyName, fleetNameMap.get(card.getFleetId()), accountMode, card.getBalance(), companyBalance, card.getStatus())); } for (TAppUserWhitelist whitelist : whitelists) { result.add(buildRow(USER_TYPE_WHITELIST + "-" + whitelist.getId(), USER_TYPE_WHITELIST, whitelist.getPhone(), companyName, fleetNameMap.get(whitelist.getFleetId()), accountMode, whitelist.getBalance(), companyBalance, whitelist.getStatus())); } return result; } /** * 组装单行充电用户信息。可用余额:分配模式取子账户余额/初始分配额度,共享模式取公司可用余额。 * 超额/账户状态按余额正负推算:余额为负时超额=|余额|、账户状态=欠款;否则无超额、状态正常。 */ private CompanyChargingUserVO buildRow(String rowKey, Integer userType, String userInfo, String companyName, String fleetName, Integer accountMode, BigDecimal accountBalance, BigDecimal companyBalance, Integer status) { CompanyChargingUserVO vo = new CompanyChargingUserVO(); vo.setRowKey(rowKey); vo.setUserType(userType); vo.setUserInfo(userInfo); vo.setCompanyName(companyName); vo.setFleetName(fleetName); vo.setAccountMode(accountMode); vo.setBalance(resolveBalance(accountMode, accountBalance, companyBalance)); vo.setOverrun(null); vo.setAccountStatus(ACCOUNT_STATUS_NORMAL); BigDecimal balance = vo.getBalance(); if (balance != null && balance.compareTo(BigDecimal.ZERO) < 0) { vo.setOverrun(balance.abs().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP)); vo.setAccountStatus(ACCOUNT_STATUS_OVERDUE); } vo.setStatus(status); return vo; } /** * 计算可用余额:分配模式=子账户余额/初始分配额度,共享模式=公司可用余额 */ private BigDecimal resolveBalance(Integer accountMode, BigDecimal accountBalance, BigDecimal companyBalance) { if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(accountMode)) { return accountBalance; } if (Integer.valueOf(ACCOUNT_MODE_SHARED).equals(accountMode)) { return companyBalance; } return null; } /** * 远程查询公司VIN码列表(降级返回空列表,保证详情主流程不受影响) */ private List fetchVins(Integer companyId) { try { R> response = vinManagementClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } /** * 远程查询公司卡片列表(降级返回空列表) */ private List fetchCards(Integer companyId) { try { R> response = chargingCardClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } /** * 远程查询公司白名单列表(降级返回空列表) */ private List fetchWhitelists(Integer companyId) { try { R> response = appUserWhitelistClient.listByCompany(companyId); return (response != null && R.isSuccess(response) && response.getData() != null) ? response.getData() : new ArrayList<>(); } catch (Exception e) { return new ArrayList<>(); } } /** * 批量查询车队名称(本地库 t_fleet,逻辑删除由 @TableLogic 自动过滤) */ private Map loadFleetNameMap(List fleetIds) { Map map = new HashMap<>(); if (fleetIds == null || fleetIds.isEmpty()) { return map; } List distinctIds = fleetIds.stream() .filter(Objects::nonNull) .distinct() .collect(Collectors.toList()); if (distinctIds.isEmpty()) { return map; } List fleets = fleetMapper.selectList( Wrappers.lambdaQuery().in(TFleet::getId, distinctIds)); for (TFleet fleet : fleets) { map.put(fleet.getId(), fleet.getFleetName()); } return map; } /** * 统计公司未删除的车队数量 */ private Integer countFleet(Integer companyId) { Long count = fleetMapper.selectCount(Wrappers.lambdaQuery().eq(TFleet::getCompanyId, companyId)); return count == null ? 0 : count.intValue(); } /** * 统计公司本月(自然月,按充值时间 [本月1日 00:00:00, 次月1日 00:00:00))到账金额合计 */ private BigDecimal sumCurrentMonthRechargeAmount(Integer companyId) { LocalDateTime start = monthStart(LocalDate.now()); LocalDateTime end = start.plusMonths(1); List records = companyRechargeRecordMapper.selectList( Wrappers.lambdaQuery() .eq(TCompanyRechargeRecord::getCompanyId, companyId) .ge(TCompanyRechargeRecord::getRechargeTime, start) .lt(TCompanyRechargeRecord::getRechargeTime, end)); return sumArrivalAmount(records); } /** * 统计公司累计充值到账金额(全部有效充值记录,逻辑删除由 @TableLogic 自动过滤) */ private BigDecimal sumRechargeAmount(Integer companyId) { List records = companyRechargeRecordMapper.selectList( Wrappers.lambdaQuery() .eq(TCompanyRechargeRecord::getCompanyId, companyId)); return sumArrivalAmount(records); } /** * 当月起始时刻(本月1日 00:00:00) */ static LocalDateTime monthStart(LocalDate today) { return today.withDayOfMonth(1).atStartOfDay(); } /** * 汇总到账金额(空集合返回 0.00,忽略空金额) */ static BigDecimal sumArrivalAmount(List records) { if (records == null || records.isEmpty()) { return BigDecimal.ZERO.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP); } return records.stream() .map(TCompanyRechargeRecord::getArrivalAmount) .filter(Objects::nonNull) .reduce(BigDecimal.ZERO, BigDecimal::add) .setScale(AMOUNT_SCALE, RoundingMode.HALF_UP); } /** * 查询公司默认角色ID(内置角色 role_key=company_admin) */ private Long resolveCompanyRoleId() { R roleR = sysRoleClient.getRoleIdByRoleKey(COMPANY_ROLE_KEY); if (roleR == null || 200 != roleR.getCode() || roleR.getData() == null) { throw new RuntimeException("系统内置角色【" + COMPANY_ROLE_KEY + "】不存在或已停用,请先配置角色"); } return roleR.getData(); } /** * 检查登录账号在 t_group_company 中是否已存在(编辑时排除自身) */ private boolean accountExists(String account, Integer excludeId) { Long count = count(Wrappers.lambdaQuery() .eq(TGroupCompany::getAccount, account) .ne(excludeId != null, TGroupCompany::getId, excludeId)); return count != null && count > 0; } @Override @Transactional(rollbackFor = Exception.class) public AjaxResult addCompany(TGroupCompany company) { // 登录账号由系统自动生成:admin + 4位随机数字,前端不可指定、不可修改 String account = generateCompanyAccount(); company.setAccount(account); // 添加企业弹窗不再配置账户模式:未指定时按共享模式建档,后续可在“账户模式”弹窗切换 if (company.getAccountMode() == null) { company.setAccountMode(ACCOUNT_MODE_SHARED); } // 初始密码固定 123456,按登录口径 HMAC-MD5 后由 system 侧 BCrypt 落库 String password = hmacMd5(DEFAULT_INITIAL_PASSWORD, PASSWORD_HMAC_KEY); Long roleId = resolveCompanyRoleId(); SysUser user = new SysUser(); user.setUserName(account); user.setNickName(company.getName()); user.setPhonenumber(company.getContactPhone()); user.setPassword(password); user.setStatus("0"); user.setDelFlag("0"); user.setRoleId(roleId); user.setRoleType(COMPANY_ROLE_TYPE); user.setPassWordUpdate(new Date()); R r = sysUserClient.addSysUser(user); if (r == null || 200 != r.getCode()) { throw new RuntimeException(r == null ? "创建登录账号失败" : r.getMsg()); } save(company); // 回写 objectId=公司ID(账号创建时公司尚未保存) R createdR = sysUserClient.getSysUser(r.getData()); if (createdR != null && 200 == createdR.getCode() && createdR.getData() != null) { SysUser created = createdR.getData(); created.setObjectId(company.getId()); sysUserClient.updateUser(created); } // 返回生成的登录账号,供添加页回显(初始密码为固定 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 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 @Transactional(rollbackFor = Exception.class) public AjaxResult editCompany(TGroupCompany company) { TGroupCompany old = getById(company.getId()); if (old == null) { return AjaxResult.error("公司不存在"); } // 编辑支持修改登录账号:公司表唯一性校验(排除自身) if (StringUtils.hasText(company.getAccount())) { if (accountExists(company.getAccount(), company.getId())) { return AjaxResult.error("登录账号不能重复"); } } String account = StringUtils.hasText(company.getAccount()) ? company.getAccount() : old.getAccount(); if (StringUtils.hasText(account)) { String error = syncCompanyUserOnEdit(company, old.getAccount(), account); if (error != null) { return AjaxResult.error(error); } } updateById(company); return AjaxResult.success(); } /** * 编辑公司时同步登录账号:按旧账号定位用户后更新为新账号(支持改账号); * 密码有值时重置(留空不修改);用户不存在则按新账号补建 * * @return 冲突等业务错误信息,null 表示成功 */ private String syncCompanyUserOnEdit(TGroupCompany company, String oldAccount, String newAccount) { // 账号变更时校验新账号未被其他用户占用(updateSysUser 无唯一性校验,需在此前置拦截) if (StringUtils.hasText(oldAccount) && !newAccount.equals(oldAccount)) { R occupantR = sysUserClient.queryUserByUserName(newAccount); SysUser occupant = occupantR == null ? null : occupantR.getData(); if (occupant != null) { R ownerR = sysUserClient.queryUserByUserName(oldAccount); SysUser owner = ownerR == null ? null : ownerR.getData(); if (owner == null || !occupant.getUserId().equals(owner.getUserId())) { return "登录账号不能重复"; } } } // 按旧账号定位既有用户(账号未变时与新账号一致),避免改名后误走补建导致双账号 R userR = sysUserClient.queryUserByUserName(StringUtils.hasText(oldAccount) ? oldAccount : newAccount); SysUser user = userR == null ? null : userR.getData(); if (user == null) { if (!StringUtils.hasText(company.getPassword())) { throw new RuntimeException("补建登录账号时登录密码不能为空"); } Long roleId = resolveCompanyRoleId(); SysUser newUser = new SysUser(); newUser.setUserName(newAccount); newUser.setNickName(company.getName()); newUser.setPhonenumber(company.getContactPhone()); newUser.setPassword(company.getPassword()); newUser.setStatus("0"); newUser.setDelFlag("0"); newUser.setRoleId(roleId); newUser.setRoleType(COMPANY_ROLE_TYPE); newUser.setObjectId(company.getId()); newUser.setPassWordUpdate(new Date()); R addR = sysUserClient.addSysUser(newUser); if (addR == null || 200 != addR.getCode()) { throw new RuntimeException(addR == null ? "创建登录账号失败" : addR.getMsg()); } return null; } // 远程查询返回的密码字段可能是哈希或脱敏值,显式清空避免再次写回 user.setPassword(null); user.setPassWordUpdate(null); user.setUserName(newAccount); user.setNickName(company.getName()); user.setPhonenumber(company.getContactPhone()); R 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 @Transactional(rollbackFor = Exception.class) public AjaxResult delCompany(Integer id) { TGroupCompany company = getById(id); if (company == null) { return AjaxResult.error("公司不存在"); } removeById(id); if (StringUtils.hasText(company.getAccount())) { R userR = sysUserClient.queryUserByUserName(company.getAccount()); SysUser user = userR == null ? null : userR.getData(); if (user != null) { user.setDelFlag("2"); sysUserClient.updateUser(user); } } return AjaxResult.success(); } @Override @Transactional(rollbackFor = Exception.class) public AjaxResult updateAccountMode(Integer id, Integer accountMode) { if (id == null) { return AjaxResult.error("公司ID不能为空"); } if (accountMode == null || (accountMode != ACCOUNT_MODE_SHARED && accountMode != ACCOUNT_MODE_ALLOCATION)) { return AjaxResult.error("账户模式不合法"); } TGroupCompany company = getById(id); 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 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; } /** * 分配模式切换为共享模式: *
    *
  1. 校验公司与三类主体均无未释放充电锁定额
  2. *
  3. 公司余额预加回回收总额 + 切换为共享模式(本地事务)
  4. *
  5. 远程回收三类主体全部余额并写双向流水(account 侧锁内事务,幂等)
  6. *
  7. 按实际回收额与预加回额差额校准公司余额(并发分配竞态兜底)
  8. *
* 进行中充电订单校验已在 updateAccountMode 公共入口完成。 */ private AjaxResult switchAllocationToShared(Integer id, TGroupCompany company) { // 1. 公司锁定额校验(在途充电锁定未释放) BigDecimal companyLocked = company.getLockedAmount(); if (companyLocked != null && companyLocked.signum() > 0) { return AjaxResult.error("公司存在未释放的充电锁定金额,暂不允许切换为共享模式"); } // 2. 三类主体余额与锁定额统计 R 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(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 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> 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 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); } } }