| | |
| | | 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 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.List; |
| | | import java.util.Map; |
| | | import java.util.Objects; |
| | | import java.util.concurrent.ThreadLocalRandom; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** |
| | |
| | | /** 账户状态:欠款 */ |
| | | 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, |
| | |
| | | /** 企业额度分配记录远程客户端(结算时写消费流水、幂等判定使用) */ |
| | | @Resource |
| | | private CompanyAllocateRecordClient companyAllocateRecordClient; |
| | | |
| | | /** 充电订单远程客户端(账户模式切换前进行中订单校验使用) */ |
| | | @Resource |
| | | private ChargingOrderClient chargingOrderClient; |
| | | |
| | | @Override |
| | | public TGroupCompany getDetail(Integer id) { |
| | |
| | | @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); |
| | |
| | | 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 |
| | |
| | | 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 |