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;
|
|
/**
|
* <p>
|
* 公司(企业)管理 服务实现类
|
* </p>
|
*
|
* @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_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<TVinManagement> vins = fetchVins(id);
|
// VIN码车辆数量:按公司统计未删除车辆(与 VIN 列表接口同口径),降级时返回 0
|
company.setVinCount(vins.size());
|
company.setChargingUsers(buildChargingUsers(company, vins));
|
return company;
|
}
|
|
/**
|
* 聚合公司充电用户信息:VIN码、刷卡、白名单三类数据按统一结构返回。
|
* 仅公司详情使用;remoteGet 远程调用保持 getDetail(不聚合),避免跨服务循环调用。
|
*/
|
private List<CompanyChargingUserVO> buildChargingUsers(TGroupCompany company, List<TVinManagement> vins) {
|
Integer companyId = company.getId();
|
Integer accountMode = company.getAccountMode();
|
List<CompanyChargingUserVO> result = new ArrayList<>();
|
|
List<TChargingCard> cards = fetchCards(companyId);
|
List<TAppUserWhitelist> whitelists = fetchWhitelists(companyId);
|
|
List<Integer> 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<Integer, String> 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<TVinManagement> fetchVins(Integer companyId) {
|
try {
|
R<List<TVinManagement>> 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<TChargingCard> fetchCards(Integer companyId) {
|
try {
|
R<List<TChargingCard>> 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<TAppUserWhitelist> fetchWhitelists(Integer companyId) {
|
try {
|
R<List<TAppUserWhitelist>> 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<Integer, String> loadFleetNameMap(List<Integer> fleetIds) {
|
Map<Integer, String> map = new HashMap<>();
|
if (fleetIds == null || fleetIds.isEmpty()) {
|
return map;
|
}
|
List<Integer> distinctIds = fleetIds.stream()
|
.filter(Objects::nonNull)
|
.distinct()
|
.collect(Collectors.toList());
|
if (distinctIds.isEmpty()) {
|
return map;
|
}
|
List<TFleet> fleets = fleetMapper.selectList(
|
Wrappers.<TFleet>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.<TFleet>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<TCompanyRechargeRecord> records = companyRechargeRecordMapper.selectList(
|
Wrappers.<TCompanyRechargeRecord>lambdaQuery()
|
.eq(TCompanyRechargeRecord::getCompanyId, companyId)
|
.ge(TCompanyRechargeRecord::getRechargeTime, start)
|
.lt(TCompanyRechargeRecord::getRechargeTime, end));
|
return sumArrivalAmount(records);
|
}
|
|
/**
|
* 统计公司累计充值到账金额(全部有效充值记录,逻辑删除由 @TableLogic 自动过滤)
|
*/
|
private BigDecimal sumRechargeAmount(Integer companyId) {
|
List<TCompanyRechargeRecord> records = companyRechargeRecordMapper.selectList(
|
Wrappers.<TCompanyRechargeRecord>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<TCompanyRechargeRecord> 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<Long> 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.<TGroupCompany>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<Long> r = sysUserClient.addSysUser(user);
|
if (r == null || 200 != r.getCode()) {
|
throw new RuntimeException(r == null ? "创建登录账号失败" : r.getMsg());
|
}
|
save(company);
|
// 回写 objectId=公司ID(账号创建时公司尚未保存)
|
R<SysUser> 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<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
|
@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<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())) {
|
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<Long> 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<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
|
@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<SysUser> 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<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(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);
|
}
|
}
|
|
}
|