package com.ruoyi.account.service.impl;
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.ruoyi.account.api.dto.ChargingCardAllocateDTO;
|
import com.ruoyi.account.api.dto.ChargingCardAllocateItemDTO;
|
import com.ruoyi.account.api.dto.ChargingCardAuthDTO;
|
import com.ruoyi.account.api.dto.ChargingCardDeductDTO;
|
import com.ruoyi.account.api.dto.ChargingCardExcelDTO;
|
import com.ruoyi.account.api.dto.ChargingCardQueryDTO;
|
import com.ruoyi.account.api.dto.ChargingCardSaveDTO;
|
import com.ruoyi.account.api.dto.ChargingCardStatusDTO;
|
import com.ruoyi.account.api.model.TChargingCard;
|
import com.ruoyi.account.api.model.TCompanyAllocateRecord;
|
import com.ruoyi.account.api.vo.ChargingCardAllocateVO;
|
import com.ruoyi.account.api.vo.ChargingCardDeductVO;
|
import com.ruoyi.account.mapper.TChargingCardMapper;
|
import com.ruoyi.account.service.PartnerAppUserAccessService;
|
import com.ruoyi.account.service.TChargingCardService;
|
import com.ruoyi.account.service.TCompanyAllocateRecordService;
|
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.security.utils.SecurityUtils;
|
import com.ruoyi.other.api.domain.TFleet;
|
import com.ruoyi.other.api.domain.TGroupCompany;
|
import com.ruoyi.other.api.feignClient.GroupCompanyClient;
|
import org.redisson.api.RLock;
|
import org.redisson.api.RedissonClient;
|
import org.slf4j.Logger;
|
import org.slf4j.LoggerFactory;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.support.TransactionTemplate;
|
|
import java.math.BigDecimal;
|
import java.math.RoundingMode;
|
import java.time.LocalDateTime;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.HashMap;
|
import java.util.HashSet;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.concurrent.TimeUnit;
|
import java.util.stream.Collectors;
|
|
@Service
|
public class TChargingCardServiceImpl extends ServiceImpl<TChargingCardMapper, TChargingCard>
|
implements TChargingCardService {
|
|
private static final Logger log = LoggerFactory.getLogger(TChargingCardServiceImpl.class);
|
|
private static final int ENABLED = 1;
|
private static final int DISABLED = 2;
|
private static final int BALANCE_ADD = 1;
|
/** 余额变动类型:3=刷卡订单充电结算扣款(V0.2 定稿,订单扣款专用) */
|
private static final int BALANCE_TYPE_ORDER_DEDUCT = 3;
|
/** 充电锁定标记:1=充电中(业务锁定,管理操作互斥) */
|
private static final int CHARGING_FLAG_LOCKED = 1;
|
/** 设备链路无登录操作人,流水统一记 0 */
|
private static final long SYSTEM_OPERATOR = 0L;
|
private static final BigDecimal MAX_AMOUNT = new BigDecimal("9999999999.99");
|
private static final int PASSWORD_MAX_LENGTH = 64;
|
/** 公司账户模式:2=分配模式(可设置初始分配额度并扣减公司可用余额) */
|
private static final int ACCOUNT_MODE_ALLOCATION = 2;
|
/** 公司账户模式:1=共享模式(可用余额取公司可用余额) */
|
private static final int ACCOUNT_MODE_SHARED = 1;
|
/** 账户状态:正常 */
|
private static final int ACCOUNT_STATUS_NORMAL = 1;
|
/** 账户状态:欠款 */
|
private static final int ACCOUNT_STATUS_OVERDUE = 2;
|
/** 金额展示精度 */
|
private static final int AMOUNT_SCALE = 2;
|
/** 初始分配额度上限(对应 decimal(12,2)) */
|
private static final BigDecimal MAX_INIT_QUOTA = new BigDecimal("99999999.99");
|
/** 额度分配:单次最多分配的卡片数量,防止单次请求过大 */
|
private static final int MAX_ALLOCATE_CARD_COUNT = 200;
|
/** 卡片额度并发锁前缀:按卡片ID/卡号串行,防止同卡并发编辑重复扣减或退还公司余额 */
|
private static final String QUOTA_LOCK_PREFIX = "CHARGING_CARD_QUOTA:";
|
/** 额度分配并发锁前缀:按公司串行,防止同公司并发分配重复占用可分配余额 */
|
private static final String ALLOCATE_LOCK_PREFIX = QUOTA_LOCK_PREFIX + "COMPANY:";
|
/** 获取锁最长等待时间(秒) */
|
private static final long LOCK_WAIT_SECONDS = 5L;
|
/** 锁自动释放时间(秒),兜住异常未解锁的场景 */
|
private static final long LOCK_LEASE_SECONDS = 30L;
|
|
@Autowired
|
private TransactionTemplate transactionTemplate;
|
|
@Autowired
|
private GroupCompanyClient groupCompanyClient;
|
|
@Autowired
|
private RedissonClient redissonClient;
|
|
@Autowired
|
private TCompanyAllocateRecordService companyAllocateRecordService;
|
|
@Autowired
|
private PartnerAppUserAccessService partnerAppUserAccessService;
|
|
@Override
|
public IPage<TChargingCard> selectCardPage(ChargingCardQueryDTO query) {
|
ChargingCardQueryDTO safeQuery = query == null ? new ChargingCardQueryDTO() : query;
|
// 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
|
safeQuery.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safeQuery.getCompanyId()));
|
long pageNum = safeQuery.getPageCurr() == null || safeQuery.getPageCurr() < 1 ? 1 : safeQuery.getPageCurr();
|
long pageSize = safeQuery.getPageSize() == null || safeQuery.getPageSize() < 1 ? 10 : safeQuery.getPageSize();
|
Page<TChargingCard> page = new Page<>(pageNum, pageSize);
|
IPage<TChargingCard> result = baseMapper.selectCardPage(page, safeQuery);
|
List<TChargingCard> records = result == null ? null : result.getRecords();
|
fillFleetNames(records);
|
fillAccountInfo(records);
|
return result;
|
}
|
|
@Override
|
public boolean saveCard(ChargingCardSaveDTO dto) {
|
validateCard(dto, false);
|
String cardNo = dto.getCardNo().trim();
|
BigDecimal quota = normalizeQuota(dto.getBalance());
|
// 同卡号并发新增串行化,卡号重复校验与额度扣减在同一临界区内
|
RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + "CARD_NO:" + cardNo);
|
boolean locked = tryQuotaLock(lock, "该卡号正在处理中,请稍后重试");
|
List<Runnable> compensations = new ArrayList<>();
|
try {
|
TChargingCard card = new TChargingCard();
|
card.setCardNo(cardNo);
|
card.setBalance(quota.signum() > 0 ? quota : BigDecimal.ZERO);
|
card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus());
|
card.setPassword(encodePasswordIfPresent(dto.getPassword()));
|
card.setChargingFlag(0);
|
card.setChargingOrderCode(null);
|
card.setDelFlag(0);
|
card.setCreateTime(LocalDateTime.now());
|
card.setCreateBy(SecurityUtils.getUserId());
|
applyRelation(card, dto);
|
if (quota.signum() > 0) {
|
requireAllocationCompany(card.getCompanyId());
|
deductBalance(card.getCompanyId(), quota);
|
compensations.add(() -> refundQuietly(card.getCompanyId(), quota));
|
}
|
if (!save(card)) {
|
throw new ServiceException("新增卡片失败");
|
}
|
recordCardAllocate(card.getCardNo(), card.getCompanyId(), BigDecimal.ZERO, quota,
|
TCompanyAllocateRecord.SOURCE_CREATE, "新增卡片初始分配额度");
|
return true;
|
} catch (RuntimeException ex) {
|
compensate(compensations);
|
throw ex;
|
} finally {
|
unlockQuota(lock, locked);
|
}
|
}
|
|
@Override
|
public boolean updateCard(ChargingCardSaveDTO dto) {
|
validateCard(dto, true);
|
TChargingCard existing = getById(dto.getId());
|
if (existing == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
// 公司账号越权校验:只能编辑本公司数据
|
partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
|
assertNotChargingLocked(existing, "修改卡片或重置卡密码");
|
// 同卡片并发编辑串行化:额度差额计算、公司余额扣减/退还必须在临界区内,防止重复扣减或重复退还
|
RLock lock = redissonClient.getLock(QUOTA_LOCK_PREFIX + dto.getId());
|
boolean locked = tryQuotaLock(lock, "该卡片额度正在处理中,请稍后重试");
|
List<Runnable> compensations = new ArrayList<>();
|
// 分配记录延迟到卡片落库成功后写入:失败时公司余额已回补,不应留下流水
|
List<Runnable> recordTasks = new ArrayList<>();
|
try {
|
TChargingCard latest = getById(dto.getId());
|
if (latest == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
assertNotChargingLocked(latest, "修改卡片或重置卡密码");
|
TChargingCard card = new TChargingCard();
|
card.setId(dto.getId());
|
card.setCardNo(dto.getCardNo().trim());
|
card.setStatus(dto.getStatus() == null ? ENABLED : dto.getStatus());
|
card.setUpdateTime(LocalDateTime.now());
|
card.setUpdateBy(SecurityUtils.getUserId());
|
applyRelation(card, dto);
|
BigDecimal newQuota = normalizeQuota(dto.getBalance());
|
if (newQuota.signum() > 0) {
|
requireAllocationCompany(card.getCompanyId());
|
}
|
Integer oldCompanyId = latest.getCompanyId();
|
BigDecimal oldQuota = normalizeQuota(latest.getBalance());
|
if (Objects.equals(oldCompanyId, card.getCompanyId())) {
|
// 同一公司按差额调整:调增扣差额,调减退差额
|
BigDecimal diff = newQuota.subtract(oldQuota);
|
if (diff.signum() > 0 && card.getCompanyId() != null) {
|
deductBalance(card.getCompanyId(), diff);
|
compensations.add(() -> refundQuietly(card.getCompanyId(), diff));
|
recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), oldQuota, diff,
|
TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整初始分配额度"));
|
} else if (diff.signum() < 0 && card.getCompanyId() != null) {
|
BigDecimal refund = diff.negate();
|
refundBalance(card.getCompanyId(), refund);
|
compensations.add(() -> deductQuietly(card.getCompanyId(), refund));
|
recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), oldQuota, diff,
|
TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调减初始分配额度"));
|
} else if (diff.signum() != 0) {
|
log.warn("卡片{}额度变化但未关联公司,跳过公司余额处理,oldQuota={}, newQuota={}",
|
dto.getId(), oldQuota, newQuota);
|
}
|
} else {
|
// 跨公司:旧公司全额退还,新公司全额扣减
|
if (oldCompanyId != null && oldQuota.signum() > 0) {
|
refundBalance(oldCompanyId, oldQuota);
|
compensations.add(() -> deductQuietly(oldCompanyId, oldQuota));
|
recordTasks.add(() -> recordCardAllocate(card.getCardNo(), oldCompanyId, oldQuota,
|
oldQuota.negate(), TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整所属公司(原公司退还)"));
|
}
|
if (card.getCompanyId() != null && newQuota.signum() > 0) {
|
deductBalance(card.getCompanyId(), newQuota);
|
compensations.add(() -> refundQuietly(card.getCompanyId(), newQuota));
|
recordTasks.add(() -> recordCardAllocate(card.getCardNo(), card.getCompanyId(), BigDecimal.ZERO,
|
newQuota, TCompanyAllocateRecord.SOURCE_EDIT, "编辑卡片调整所属公司"));
|
}
|
}
|
card.setBalance(newQuota.signum() > 0 ? newQuota : null);
|
String encodedPassword = encodePasswordIfPresent(dto.getPassword());
|
if (!updateCardRecord(card, encodedPassword, latest)) {
|
throw new ServiceException("修改卡片失败");
|
}
|
recordTasks.forEach(Runnable::run);
|
return true;
|
} catch (RuntimeException ex) {
|
compensate(compensations);
|
throw ex;
|
} finally {
|
unlockQuota(lock, locked);
|
}
|
}
|
|
@Override
|
public boolean deleteCards(List<Long> ids) {
|
if (ids == null || ids.isEmpty()) {
|
throw new ServiceException("请选择需要删除的卡片");
|
}
|
List<Long> distinctIds = new java.util.ArrayList<>(new HashSet<>(ids));
|
List<TChargingCard> cards = listByIds(distinctIds);
|
if (cards.size() != distinctIds.size()) {
|
throw new ServiceException("部分卡片不存在或已删除");
|
}
|
// 公司账号越权校验:只能删除本公司数据
|
for (TChargingCard card : cards) {
|
partnerAppUserAccessService.assertCompanyVisible(card.getCompanyId());
|
}
|
assertNotChargingLocked(cards, "删除卡片");
|
if (!removeByIds(distinctIds)) {
|
throw new ServiceException("删除卡片失败");
|
}
|
return true;
|
}
|
|
/**
|
* 批量解绑车队:仅清空车队归属(fleet_id 置空),公司、余额、状态等不受影响;
|
* 未绑定车队的选中记录忽略不计
|
*/
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public int unbindFleet(List<Long> ids) {
|
if (ids == null || ids.isEmpty()) {
|
throw new ServiceException("请选择需要解绑车队的卡片");
|
}
|
List<Long> distinct = ids.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
if (distinct.isEmpty()) {
|
throw new ServiceException("请选择需要解绑车队的卡片");
|
}
|
List<TChargingCard> records = listByIds(distinct);
|
if (records.size() != distinct.size()) {
|
throw new ServiceException("部分卡片不存在或已删除");
|
}
|
// 公司账号越权校验:只能解绑本公司数据
|
for (TChargingCard record : records) {
|
partnerAppUserAccessService.assertCompanyVisible(record.getCompanyId());
|
}
|
List<Long> boundIds = records.stream()
|
.filter(card -> card.getFleetId() != null)
|
.map(TChargingCard::getId)
|
.collect(Collectors.toList());
|
if (boundIds.isEmpty()) {
|
return 0;
|
}
|
// 显式 set null 清空车队归属(MyBatis-Plus 字段策略会忽略实体 null,需走 UpdateWrapper)
|
boolean updated = lambdaUpdate()
|
.in(TChargingCard::getId, boundIds)
|
.set(TChargingCard::getFleetId, null)
|
.set(TChargingCard::getUpdateTime, LocalDateTime.now())
|
.set(TChargingCard::getUpdateBy, SecurityUtils.getUserId())
|
.update();
|
if (!updated) {
|
throw new ServiceException("卡片车队解绑失败,请重试");
|
}
|
return boundIds.size();
|
}
|
|
@Override
|
public boolean updateStatus(ChargingCardStatusDTO dto) {
|
if (dto == null || dto.getId() == null || !isValidStatus(dto.getStatus())) {
|
throw new ServiceException("卡片状态参数错误");
|
}
|
TChargingCard existing = getById(dto.getId());
|
if (existing == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
// 公司账号越权校验:只能操作本公司数据
|
partnerAppUserAccessService.assertCompanyVisible(existing.getCompanyId());
|
assertNotChargingLocked(existing, "启用/禁用卡片");
|
TChargingCard card = new TChargingCard();
|
card.setId(dto.getId());
|
card.setStatus(dto.getStatus());
|
card.setUpdateTime(LocalDateTime.now());
|
card.setUpdateBy(SecurityUtils.getUserId());
|
if (!updateById(card)) {
|
throw new ServiceException("更新卡片状态失败");
|
}
|
return true;
|
}
|
|
@Override
|
public ChargingCardAllocateVO getAllocateOverview(Integer companyId) {
|
// 公司账号只能查看本公司额度概览,覆盖前端传入的公司
|
companyId = partnerAppUserAccessService.resolveCompanyFilter(companyId);
|
if (companyId == null) {
|
throw new ServiceException("请选择公司");
|
}
|
TGroupCompany company = loadCompany(companyId);
|
if (company == null) {
|
throw new ServiceException("公司不存在或已删除");
|
}
|
return buildAllocateOverview(company);
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public ChargingCardAllocateVO allocateQuota(ChargingCardAllocateDTO dto) {
|
// 公司账号只能给本公司分配额度,覆盖前端传入的公司
|
dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
|
Map<Long, BigDecimal> amounts = validateAllocateRequest(dto);
|
Integer companyId = dto.getCompanyId();
|
// 公司必须存在、启用且为分配模式(共享模式不支持额度分配)
|
TGroupCompany company = loadAllocationCompany(companyId);
|
BigDecimal total = amounts.values().stream()
|
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
.setScale(2, RoundingMode.HALF_UP);
|
// 同公司分配串行化,避免并发下重复占用可分配余额(卡片侧另有条件更新兜底)
|
RLock lock = redissonClient.getLock(ALLOCATE_LOCK_PREFIX + companyId);
|
boolean locked = tryQuotaLock(lock, "该公司额度分配正在处理中,请稍后重试");
|
try {
|
List<TChargingCard> cards = listByIds(amounts.keySet());
|
if (cards.size() != amounts.size()) {
|
throw new ServiceException("部分卡片不存在或已删除");
|
}
|
for (TChargingCard card : cards) {
|
BigDecimal amount = amounts.get(card.getId());
|
if (!companyId.equals(card.getCompanyId())) {
|
throw new ServiceException("卡片【" + card.getCardNo() + "】不属于所选公司,无法分配额度");
|
}
|
if (!Integer.valueOf(ENABLED).equals(card.getStatus())) {
|
throw new ServiceException("卡片【" + card.getCardNo() + "】已禁用,禁止分配额度");
|
}
|
assertNotChargingLocked(card, "分配额度");
|
BigDecimal balance = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
|
if (balance.add(amount).compareTo(MAX_AMOUNT) > 0) {
|
throw new ServiceException("卡片【" + card.getCardNo() + "】分配后余额超过上限"
|
+ MAX_AMOUNT.toPlainString());
|
}
|
}
|
String remark = buildAllocateRemark(company, dto.getRemark());
|
// 先扣公司可分配余额(条件更新,余额不足直接失败),失败无需补偿
|
R<BigDecimal> deductResponse = groupCompanyClient.deductBalance(companyId, total);
|
if (deductResponse == null || !R.isSuccess(deductResponse)) {
|
throw new ServiceException(buildDeductFailureMessage(deductResponse));
|
}
|
try {
|
for (TChargingCard card : cards) {
|
BigDecimal amount = amounts.get(card.getId());
|
BigDecimal beforeBalance = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
|
int updated = baseMapper.adjustBalance(Collections.singletonList(card.getId()), amount,
|
BALANCE_ADD, MAX_AMOUNT, SecurityUtils.getUserId());
|
if (updated != 1) {
|
throw new ServiceException("卡片【" + card.getCardNo() + "】余额分配失败,请重试");
|
}
|
baseMapper.insertBalanceRecord(card.getId(), BALANCE_ADD, amount, remark,
|
SecurityUtils.getUserId());
|
recordCardAllocate(card.getCardNo(), companyId, beforeBalance, amount,
|
TCompanyAllocateRecord.SOURCE_ALLOCATE, remark);
|
}
|
} catch (RuntimeException ex) {
|
// 卡片改动随事务回滚,公司余额为远程扣减需显式退还
|
refundQuietly(companyId, total);
|
throw ex;
|
}
|
ChargingCardAllocateVO vo = buildAllocateOverview(company);
|
vo.setAvailableBalance(normalizeMoney(deductResponse.getData()));
|
return vo;
|
} finally {
|
unlockQuota(lock, locked);
|
}
|
}
|
|
@Override
|
public List<ChargingCardExcelDTO> selectExportList(ChargingCardQueryDTO query) {
|
ChargingCardQueryDTO safe = query == null ? new ChargingCardQueryDTO() : query;
|
// 公司账号强制按所属公司过滤,覆盖前端传入的公司筛选
|
safe.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(safe.getCompanyId()));
|
List<ChargingCardExcelDTO> items = baseMapper.selectExportList(safe);
|
if (items == null || items.isEmpty()) {
|
return items;
|
}
|
fillExportFleetNames(items);
|
fillExportAccountInfo(items);
|
return items;
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public String importCards(List<ChargingCardExcelDTO> cards) {
|
if (cards == null || cards.isEmpty()) {
|
throw new ServiceException("导入数据不能为空");
|
}
|
Set<String> importCardNos = new HashSet<>();
|
int row = 1;
|
for (ChargingCardExcelDTO item : cards) {
|
row++;
|
if (item == null || StringUtils.isEmpty(item.getCardNo())) {
|
throw new ServiceException("第" + row + "行卡号不能为空");
|
}
|
String cardNo = item.getCardNo().trim();
|
if (cardNo.length() > 64) {
|
throw new ServiceException("第" + row + "行卡号不能超过64个字符");
|
}
|
if (!importCardNos.add(cardNo)) {
|
throw new ServiceException("第" + row + "行卡号在文件中重复");
|
}
|
if (existsCardNo(cardNo, null)) {
|
throw new ServiceException("第" + row + "行卡号已存在");
|
}
|
if (item.getBalance() != null) {
|
validateAmount(item.getBalance(), true, "第" + row + "行卡余额");
|
}
|
parseExcelStatus(item.getStatus(), row);
|
}
|
|
Long userId = SecurityUtils.getUserId();
|
LocalDateTime now = LocalDateTime.now();
|
// 公司账号导入卡片时强制归属本公司
|
Integer lockedCompanyId = partnerAppUserAccessService.getCurrentCompanyId();
|
for (ChargingCardExcelDTO item : cards) {
|
TChargingCard card = new TChargingCard();
|
card.setCardNo(item.getCardNo().trim());
|
if (lockedCompanyId != null) {
|
card.setCompanyId(lockedCompanyId);
|
}
|
card.setBalance(item.getBalance() == null ? BigDecimal.ZERO : item.getBalance());
|
card.setStatus(parseExcelStatus(item.getStatus(), 0));
|
card.setChargingFlag(0);
|
card.setDelFlag(0);
|
card.setCreateTime(now);
|
card.setCreateBy(userId);
|
save(card);
|
}
|
return "成功导入" + cards.size() + "条卡片数据";
|
}
|
|
@Override
|
public TChargingCard verifyCardForCharging(ChargingCardAuthDTO dto) {
|
if (dto == null || StringUtils.isEmpty(dto.getCardNo())) {
|
throw new ServiceException("卡号不能为空");
|
}
|
TChargingCard card = getOne(new LambdaQueryWrapper<TChargingCard>()
|
.eq(TChargingCard::getCardNo, dto.getCardNo().trim()));
|
if (card == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
if (card.getStatus() == null || card.getStatus() != ENABLED) {
|
throw new ServiceException("卡片已禁用,无法启动充电");
|
}
|
// 条件密码校验(V0.2 定稿):卡片配置了密码才校验;未配置密码不要求、不校验
|
if (StringUtils.isNotEmpty(card.getPassword())) {
|
if (StringUtils.isEmpty(dto.getPassword())) {
|
throw new ServiceException("卡片已配置密码,请输入卡密码");
|
}
|
if (!SecurityUtils.matchesPassword(dto.getPassword(), card.getPassword())) {
|
throw new ServiceException("卡密码错误");
|
}
|
}
|
return card;
|
}
|
|
@Override
|
public boolean lockCardForCharging(Long cardId, String orderCode) {
|
if (cardId == null || StringUtils.isEmpty(orderCode)) {
|
throw new ServiceException("卡片ID与订单号不能为空");
|
}
|
return baseMapper.lockCardForCharging(cardId, orderCode.trim()) == 1;
|
}
|
|
@Override
|
public boolean unlockCardForCharging(Long cardId, String orderCode) {
|
if (cardId == null || StringUtils.isEmpty(orderCode)) {
|
throw new ServiceException("卡片ID与订单号不能为空");
|
}
|
if (baseMapper.unlockCardForCharging(cardId, orderCode.trim()) == 1) {
|
return true;
|
}
|
TChargingCard card = getById(cardId);
|
if (card == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
// 已解锁(订单号已被清空)视为幂等成功;仍被其他订单占用则返回 false,防止误释放
|
return card.getChargingOrderCode() == null || orderCode.trim().equals(card.getChargingOrderCode());
|
}
|
|
@Override
|
public List<String> listDeductedOrderCodes(List<String> orderCodes) {
|
if (orderCodes == null || orderCodes.isEmpty()) {
|
return java.util.Collections.emptyList();
|
}
|
List<String> normalizedCodes = orderCodes.stream()
|
.filter(StringUtils::isNotEmpty)
|
.map(String::trim)
|
.filter(code -> !code.isEmpty())
|
.distinct()
|
.collect(java.util.stream.Collectors.toList());
|
return normalizedCodes.isEmpty()
|
? java.util.Collections.emptyList()
|
: baseMapper.selectDeductedOrderCodes(normalizedCodes);
|
}
|
|
@Override
|
public List<TChargingCard> listByCompany(Integer companyId) {
|
if (companyId == null) {
|
return java.util.Collections.emptyList();
|
}
|
return lambdaQuery().eq(TChargingCard::getCompanyId, companyId)
|
.orderByDesc(TChargingCard::getId)
|
.list();
|
}
|
|
@Override
|
public ChargingCardDeductVO deductBalanceForCharging(ChargingCardDeductDTO dto) {
|
validateDeductRequest(dto);
|
try {
|
return transactionTemplate.execute(status -> {
|
TChargingCard card = getById(dto.getCardId());
|
if (card == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
BigDecimal before = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
|
// 第一步先占扣款幂等键:同一订单并发结算时,唯一索引 uk_order_code 只允许一个事务插入成功
|
baseMapper.insertOrderDeductRecord(dto.getCardId(), dto.getAmount(),
|
dto.getOrderCode(), dto.getOperatorId() == null ? SYSTEM_OPERATOR : dto.getOperatorId());
|
// 第二步原子扣减:允许扣成负余额,禁止携带 balance >= amount 下限条件(V0.2 定稿)
|
int updated = baseMapper.deductBalanceByCardId(dto.getCardId(), dto.getAmount());
|
if (updated != 1) {
|
throw new ServiceException("卡片不存在或已删除,扣款失败");
|
}
|
TChargingCard afterCard = getById(dto.getCardId());
|
BigDecimal after = afterCard == null || afterCard.getBalance() == null
|
? BigDecimal.ZERO : afterCard.getBalance();
|
ChargingCardDeductVO vo = new ChargingCardDeductVO();
|
vo.setCardId(dto.getCardId());
|
vo.setOrderCode(dto.getOrderCode());
|
vo.setAmount(dto.getAmount());
|
vo.setBeforeBalance(before);
|
vo.setAfterBalance(after);
|
vo.setDuplicated(false);
|
return vo;
|
});
|
} catch (DuplicateKeyException e) {
|
// 唯一键冲突:该订单此前已扣款成功(含并发下慢事务先提交),按幂等重复返回,不重复扣款
|
log.warn("刷卡订单结算扣款幂等命中,不重复扣款。订单号={}, 卡片ID={}", dto.getOrderCode(), dto.getCardId());
|
ChargingCardDeductVO vo = new ChargingCardDeductVO();
|
vo.setCardId(dto.getCardId());
|
vo.setOrderCode(dto.getOrderCode());
|
vo.setAmount(dto.getAmount());
|
vo.setDuplicated(true);
|
return vo;
|
}
|
}
|
|
@Override
|
public BigDecimal lockBalance(Long cardId, BigDecimal amount) {
|
if (cardId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
throw new ServiceException("锁定金额参数错误");
|
}
|
// 实扣冻结:余额立即扣减并计入锁定金额,余额不足时条件更新命中 0 行
|
boolean updated = lambdaUpdate()
|
.eq(TChargingCard::getId, cardId)
|
.ge(TChargingCard::getBalance, amount)
|
.setSql("balance = IFNULL(balance, 0) - " + amount.toPlainString()
|
+ ", locked_amount = IFNULL(locked_amount, 0) + " + amount.toPlainString())
|
.update();
|
if (!updated) {
|
throw new ServiceException("卡片余额不足");
|
}
|
TChargingCard latest = getById(cardId);
|
return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
|
}
|
|
@Override
|
public ChargingCardDeductVO settleBalanceForCharging(ChargingCardDeductDTO dto) {
|
validateDeductRequest(dto);
|
BigDecimal locked = dto.getLockedAmount() == null ? BigDecimal.ZERO : dto.getLockedAmount();
|
if (locked.compareTo(BigDecimal.ZERO) < 0) {
|
throw new ServiceException("锁定额度参数错误");
|
}
|
try {
|
return transactionTemplate.execute(status -> {
|
TChargingCard card = getById(dto.getCardId());
|
if (card == null) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
BigDecimal before = card.getBalance() == null ? BigDecimal.ZERO : card.getBalance();
|
// 第一步先占扣款幂等键:与旧扣款接口共用流水唯一索引 uk_order_code,
|
// 同一订单并发结算时只允许一个事务插入成功
|
baseMapper.insertOrderDeductRecord(dto.getCardId(), dto.getAmount(),
|
dto.getOrderCode(), dto.getOperatorId() == null ? SYSTEM_OPERATOR : dto.getOperatorId());
|
// 第二步原子结算:先释放锁定额,再按实际消费扣减,允许扣成负余额(V0.2 定稿)
|
boolean updated = lambdaUpdate()
|
.eq(TChargingCard::getId, dto.getCardId())
|
.setSql("balance = IFNULL(balance, 0) + " + locked.toPlainString()
|
+ " - " + dto.getAmount().toPlainString()
|
+ ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - "
|
+ locked.toPlainString() + ", 0)")
|
.update();
|
if (!updated) {
|
throw new ServiceException("卡片不存在或已删除,结算失败");
|
}
|
TChargingCard afterCard = getById(dto.getCardId());
|
BigDecimal after = afterCard == null || afterCard.getBalance() == null
|
? BigDecimal.ZERO : afterCard.getBalance();
|
ChargingCardDeductVO vo = new ChargingCardDeductVO();
|
vo.setCardId(dto.getCardId());
|
vo.setOrderCode(dto.getOrderCode());
|
vo.setAmount(dto.getAmount());
|
vo.setBeforeBalance(before);
|
vo.setAfterBalance(after);
|
vo.setDuplicated(false);
|
return vo;
|
});
|
} catch (DuplicateKeyException e) {
|
// 唯一键冲突:该订单此前已结算成功(含并发下慢事务先提交),按幂等重复返回,不重复结算
|
log.warn("刷卡订单结算幂等命中,不重复结算。订单号={}, 卡片ID={}", dto.getOrderCode(), dto.getCardId());
|
ChargingCardDeductVO vo = new ChargingCardDeductVO();
|
vo.setCardId(dto.getCardId());
|
vo.setOrderCode(dto.getOrderCode());
|
vo.setAmount(dto.getAmount());
|
vo.setDuplicated(true);
|
return vo;
|
}
|
}
|
|
@Override
|
public BigDecimal unlockBalance(Long cardId, BigDecimal amount) {
|
if (cardId == null || amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
throw new ServiceException("释放金额参数错误");
|
}
|
// 释放锁定额:余额回补,锁定额度扣减,允许余额超过锁定额(异常数据兜底)
|
boolean updated = lambdaUpdate()
|
.eq(TChargingCard::getId, cardId)
|
.setSql("balance = IFNULL(balance, 0) + " + amount.toPlainString()
|
+ ", locked_amount = GREATEST(IFNULL(locked_amount, 0) - " + amount.toPlainString() + ", 0)")
|
.update();
|
if (!updated) {
|
throw new ServiceException("卡片不存在或已删除");
|
}
|
TChargingCard latest = getById(cardId);
|
return latest == null || latest.getBalance() == null ? BigDecimal.ZERO : latest.getBalance();
|
}
|
|
/**
|
* 校验并回填所属公司与车队:车队必须归属所选公司;未选公司时按车队自动带出公司
|
*/
|
private void applyRelation(TChargingCard card, ChargingCardSaveDTO dto) {
|
// 公司账号新增/编辑卡片时强制归属本公司,覆盖前端传入的公司
|
dto.setCompanyId(partnerAppUserAccessService.resolveCompanyFilter(dto.getCompanyId()));
|
Integer companyId = dto.getCompanyId();
|
if (companyId != null && loadCompany(companyId) == null) {
|
throw new ServiceException("所属公司不存在或已删除");
|
}
|
Integer fleetId = dto.getFleetId();
|
if (fleetId != null) {
|
TFleet fleet = loadFleet(fleetId);
|
if (fleet == null) {
|
throw new ServiceException("所属车队不存在或已删除");
|
}
|
if (companyId == null) {
|
companyId = fleet.getCompanyId();
|
} else if (!companyId.equals(fleet.getCompanyId())) {
|
throw new ServiceException("所属车队不属于所选公司");
|
}
|
}
|
card.setCompanyId(companyId);
|
card.setFleetId(fleetId);
|
}
|
|
/** 规范化初始分配额度:null 视为 0,不允许负数、超过2位小数或超出上限 */
|
private BigDecimal normalizeQuota(BigDecimal quota) {
|
if (quota == null) {
|
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
}
|
if (quota.signum() < 0) {
|
throw new ServiceException("初始分配额度不能小于0");
|
}
|
if (quota.stripTrailingZeros().scale() > 2) {
|
throw new ServiceException("初始分配额度最多保留2位小数");
|
}
|
BigDecimal normalized = quota.setScale(2, RoundingMode.HALF_UP);
|
if (normalized.compareTo(MAX_INIT_QUOTA) > 0) {
|
throw new ServiceException("初始分配额度不能超过" + MAX_INIT_QUOTA.toPlainString());
|
}
|
return normalized;
|
}
|
|
/** 校验公司为启用且分配模式(可设置初始分配额度) */
|
private void requireAllocationCompany(Integer companyId) {
|
if (companyId == null) {
|
throw new ServiceException("请选择所属公司后再填写初始分配额度");
|
}
|
TGroupCompany company = loadCompany(companyId);
|
if (company == null) {
|
throw new ServiceException("所属公司不存在或已删除");
|
}
|
if (!Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode())) {
|
throw new ServiceException("仅分配模式的公司可填写初始分配额度");
|
}
|
if (!Integer.valueOf(1).equals(company.getStatus())) {
|
throw new ServiceException("所属公司已停用,无法填写初始分配额度");
|
}
|
}
|
|
/**
|
* 额度分配:加载公司并校验为启用且分配模式。
|
* 与初始分配额度校验分开,便于按场景返回更准确的提示,且不影响既有链路。
|
*/
|
private TGroupCompany loadAllocationCompany(Integer companyId) {
|
if (companyId == null) {
|
throw new ServiceException("请选择公司");
|
}
|
TGroupCompany company = loadCompany(companyId);
|
if (company == null) {
|
throw new ServiceException("公司不存在或已删除");
|
}
|
if (!Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(company.getAccountMode())) {
|
throw new ServiceException("该公司为共享模式,不支持额度分配");
|
}
|
if (!Integer.valueOf(1).equals(company.getStatus())) {
|
throw new ServiceException("公司已停用,无法分配额度");
|
}
|
return company;
|
}
|
|
/** 组装额度分配概览(可分配余额、总账户余额) */
|
private ChargingCardAllocateVO buildAllocateOverview(TGroupCompany company) {
|
ChargingCardAllocateVO vo = new ChargingCardAllocateVO();
|
vo.setCompanyId(company.getId());
|
vo.setCompanyName(company.getName());
|
vo.setAccountMode(company.getAccountMode());
|
vo.setAvailableBalance(normalizeMoney(company.getAvailableBalance()));
|
vo.setTotalRechargeAmount(normalizeMoney(company.getTotalRechargeAmount()));
|
return vo;
|
}
|
|
/** 金额归一化:null 视为 0,统一保留2位小数 */
|
private BigDecimal normalizeMoney(BigDecimal amount) {
|
return (amount == null ? BigDecimal.ZERO : amount).setScale(2, RoundingMode.HALF_UP);
|
}
|
|
/** 校验额度分配请求并返回 卡片ID → 本次分配金额(保持提交顺序) */
|
private Map<Long, BigDecimal> validateAllocateRequest(ChargingCardAllocateDTO dto) {
|
if (dto == null) {
|
throw new ServiceException("分配参数不能为空");
|
}
|
if (dto.getCompanyId() == null) {
|
throw new ServiceException("请选择公司");
|
}
|
if (dto.getItems() == null || dto.getItems().isEmpty()) {
|
throw new ServiceException("请至少为一张卡片填写本次分配金额");
|
}
|
if (dto.getItems().size() > MAX_ALLOCATE_CARD_COUNT) {
|
throw new ServiceException("单次最多分配" + MAX_ALLOCATE_CARD_COUNT + "张卡片");
|
}
|
if (dto.getRemark() != null && dto.getRemark().trim().length() > 200) {
|
throw new ServiceException("备注不能超过200个字符");
|
}
|
Map<Long, BigDecimal> amounts = new LinkedHashMap<>();
|
for (ChargingCardAllocateItemDTO item : dto.getItems()) {
|
if (item == null || item.getCardId() == null) {
|
throw new ServiceException("分配明细中存在无效卡片");
|
}
|
if (amounts.containsKey(item.getCardId())) {
|
throw new ServiceException("分配明细中存在重复卡片");
|
}
|
validateAmount(item.getAmount(), false, "分配金额");
|
if (item.getAmount().compareTo(MAX_INIT_QUOTA) > 0) {
|
throw new ServiceException("单张卡片分配金额不能超过" + MAX_INIT_QUOTA.toPlainString());
|
}
|
amounts.put(item.getCardId(), item.getAmount().setScale(2, RoundingMode.HALF_UP));
|
}
|
return amounts;
|
}
|
|
/** 写入卡片分配流水(分配为正数、回收为负数;公司为空或金额为0时自动忽略) */
|
private void recordCardAllocate(String cardNo, Integer companyId, BigDecimal beforeBalance, BigDecimal amount,
|
int changeSource, String remark) {
|
try {
|
companyAllocateRecordService.recordAllocate(TCompanyAllocateRecord.USER_TYPE_CARD, cardNo, null, companyId,
|
changeSource, beforeBalance, amount, remark);
|
} catch (RuntimeException ex) {
|
// 流水为审计数据,写入失败不阻断卡片额度变更主流程,仅记录日志便于排查
|
log.error("卡片{}分配流水写入失败:{}", cardNo, ex.getMessage(), ex);
|
}
|
}
|
|
/** 组装额度分配流水备注(含来源公司,超长截断以适配 varchar(255)) */
|
private String buildAllocateRemark(TGroupCompany company, String userRemark) {
|
String companyName = company == null || company.getName() == null ? "" : company.getName();
|
String base = "额度分配(公司:" + companyName + ")";
|
String remark = StringUtils.isEmpty(userRemark) ? base : base + userRemark.trim();
|
return remark.length() > 255 ? remark.substring(0, 255) : remark;
|
}
|
|
/** 公司余额扣减失败提示:余额不足场景给出可读文案 */
|
private String buildDeductFailureMessage(R<BigDecimal> response) {
|
if (response == null) {
|
return "公司可用余额扣减失败:服务无响应";
|
}
|
String msg = response.getMsg();
|
if (msg != null && msg.contains("余额不足")) {
|
return "公司可分配余额不足,无法完成本次分配";
|
}
|
return "公司可用余额扣减失败:" + msg;
|
}
|
|
private TGroupCompany loadCompany(Integer companyId) {
|
R<TGroupCompany> response = groupCompanyClient.getCompanyById(companyId);
|
if (response == null || !R.isSuccess(response)) {
|
throw new ServiceException("公司信息查询失败:" + (response == null ? "服务无响应" : response.getMsg()));
|
}
|
return response.getData();
|
}
|
|
private TFleet loadFleet(Integer fleetId) {
|
R<TFleet> response = groupCompanyClient.getFleetById(fleetId);
|
if (response == null || !R.isSuccess(response)) {
|
throw new ServiceException("车队信息查询失败:" + (response == null ? "服务无响应" : response.getMsg()));
|
}
|
return response.getData();
|
}
|
|
/** 扣减公司可用余额(余额不足或服务异常时抛出业务异常) */
|
private void deductBalance(Integer companyId, BigDecimal amount) {
|
R<BigDecimal> response = groupCompanyClient.deductBalance(companyId, amount);
|
if (response == null || !R.isSuccess(response)) {
|
throw new ServiceException("公司可用余额扣减失败:" + (response == null ? "服务无响应" : response.getMsg()));
|
}
|
}
|
|
/** 退还公司可用余额(失败时抛出业务异常) */
|
private void refundBalance(Integer companyId, BigDecimal amount) {
|
R<BigDecimal> response = groupCompanyClient.refundBalance(companyId, amount);
|
if (response == null || !R.isSuccess(response)) {
|
throw new ServiceException("公司可用余额退还失败:" + (response == null ? "服务无响应" : response.getMsg()));
|
}
|
}
|
|
/** 静默退还(补偿场景使用,失败仅记录日志) */
|
private void refundQuietly(Integer companyId, BigDecimal amount) {
|
try {
|
R<BigDecimal> response = groupCompanyClient.refundBalance(companyId, amount);
|
if (response == null || !R.isSuccess(response)) {
|
log.error("公司可用余额退还失败,需人工核对,companyId={}, amount={}, msg={}",
|
companyId, amount, response == null ? "服务无响应" : response.getMsg());
|
}
|
} catch (Exception ex) {
|
log.error("公司可用余额退还异常,需人工核对,companyId={}, amount={}", companyId, amount, ex);
|
}
|
}
|
|
/** 静默扣减(补偿场景使用,失败仅记录日志) */
|
private void deductQuietly(Integer companyId, BigDecimal amount) {
|
try {
|
R<BigDecimal> response = groupCompanyClient.deductBalance(companyId, amount);
|
if (response == null || !R.isSuccess(response)) {
|
log.error("公司可用余额回滚扣减失败,需人工核对,companyId={}, amount={}, msg={}",
|
companyId, amount, response == null ? "服务无响应" : response.getMsg());
|
}
|
} catch (Exception ex) {
|
log.error("公司可用余额回滚扣减异常,需人工核对,companyId={}, amount={}", companyId, amount, ex);
|
}
|
}
|
|
/** 保存/更新失败时反向补偿已执行的余额操作(倒序执行) */
|
private void compensate(List<Runnable> compensations) {
|
for (int i = compensations.size() - 1; i >= 0; i--) {
|
try {
|
compensations.get(i).run();
|
} catch (Exception ex) {
|
log.error("公司可用余额补偿失败,需人工核对", ex);
|
}
|
}
|
}
|
|
/** 列表回填车队名称(远程失败不影响列表查询) */
|
private void fillFleetNames(List<TChargingCard> records) {
|
if (records == null || records.isEmpty()) {
|
return;
|
}
|
List<Integer> fleetIds = records.stream()
|
.map(TChargingCard::getFleetId)
|
.filter(Objects::nonNull)
|
.distinct()
|
.collect(Collectors.toList());
|
if (fleetIds.isEmpty()) {
|
return;
|
}
|
try {
|
R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(fleetIds);
|
if (response == null || !R.isSuccess(response) || response.getData() == null) {
|
return;
|
}
|
Map<Integer, String> nameMap = new HashMap<>();
|
for (TFleet fleet : response.getData()) {
|
if (fleet != null && fleet.getId() != null) {
|
nameMap.put(fleet.getId(), fleet.getFleetName());
|
}
|
}
|
records.forEach(record -> record.setFleetName(nameMap.get(record.getFleetId())));
|
} catch (Exception ex) {
|
log.warn("回填车队名称失败,fleetIds={}", fleetIds, ex);
|
}
|
}
|
|
/** 列表回填所属公司名称与账户信息:账户模式、可用余额、超额、账户状态(远程失败仅留空,不影响列表查询) */
|
private void fillAccountInfo(List<TChargingCard> records) {
|
if (records == null || records.isEmpty()) {
|
return;
|
}
|
Map<Integer, TGroupCompany> companyMap = loadCompanyMap(records.stream()
|
.map(TChargingCard::getCompanyId)
|
.filter(Objects::nonNull)
|
.collect(Collectors.toList()));
|
for (TChargingCard record : records) {
|
TGroupCompany company = companyMap.get(record.getCompanyId());
|
Integer accountMode = company == null ? null : company.getAccountMode();
|
BigDecimal balance = resolveAvailableBalance(accountMode, record.getBalance(),
|
company == null ? null : company.getAvailableBalance());
|
record.setCompanyName(company == null ? null : company.getName());
|
record.setAccountMode(accountMode);
|
record.setAvailableBalance(balance);
|
record.setOverrun(resolveOverrun(balance));
|
record.setAccountStatus(resolveAccountStatus(balance));
|
}
|
}
|
|
/** 导出回填车队名称(远程失败不影响导出) */
|
private void fillExportFleetNames(List<ChargingCardExcelDTO> items) {
|
List<Integer> fleetIds = items.stream()
|
.map(ChargingCardExcelDTO::getFleetId)
|
.filter(Objects::nonNull)
|
.distinct()
|
.collect(Collectors.toList());
|
if (fleetIds.isEmpty()) {
|
return;
|
}
|
try {
|
R<List<TFleet>> response = groupCompanyClient.listFleetsByIds(fleetIds);
|
if (response == null || !R.isSuccess(response) || response.getData() == null) {
|
return;
|
}
|
Map<Integer, String> nameMap = new HashMap<>();
|
for (TFleet fleet : response.getData()) {
|
if (fleet != null && fleet.getId() != null) {
|
nameMap.put(fleet.getId(), fleet.getFleetName());
|
}
|
}
|
items.forEach(item -> item.setFleetName(nameMap.get(item.getFleetId())));
|
} catch (Exception ex) {
|
log.warn("导出回填车队名称失败,fleetIds={}", fleetIds, ex);
|
}
|
}
|
|
/** 导出回填所属公司名称与账户信息(与列表同口径) */
|
private void fillExportAccountInfo(List<ChargingCardExcelDTO> items) {
|
Map<Integer, TGroupCompany> companyMap = loadCompanyMap(items.stream()
|
.map(ChargingCardExcelDTO::getCompanyId)
|
.filter(Objects::nonNull)
|
.collect(Collectors.toList()));
|
for (ChargingCardExcelDTO item : items) {
|
TGroupCompany company = companyMap.get(item.getCompanyId());
|
Integer accountMode = company == null ? null : company.getAccountMode();
|
BigDecimal balance = resolveAvailableBalance(accountMode, item.getBalance(),
|
company == null ? null : company.getAvailableBalance());
|
item.setCompanyName(company == null ? null : company.getName());
|
item.setAccountMode(accountMode);
|
item.setAvailableBalance(balance);
|
item.setOverrun(resolveOverrun(balance));
|
item.setAccountStatus(resolveAccountStatus(balance));
|
}
|
}
|
|
/** 按ID批量加载公司(远程失败或服务降级时返回空Map,由调用方按未知处理) */
|
private Map<Integer, TGroupCompany> loadCompanyMap(List<Integer> companyIds) {
|
List<Integer> distinctIds = companyIds.stream().filter(Objects::nonNull).distinct().collect(Collectors.toList());
|
if (distinctIds.isEmpty()) {
|
return new HashMap<>();
|
}
|
try {
|
R<List<TGroupCompany>> response = groupCompanyClient.listCompaniesByIds(distinctIds);
|
if (response == null || !R.isSuccess(response) || response.getData() == null) {
|
return new HashMap<>();
|
}
|
Map<Integer, TGroupCompany> companyMap = new HashMap<>();
|
for (TGroupCompany company : response.getData()) {
|
if (company != null && company.getId() != null) {
|
companyMap.put(company.getId(), company);
|
}
|
}
|
return companyMap;
|
} catch (Exception ex) {
|
log.warn("回填公司账户信息失败,companyIds={}", distinctIds, ex);
|
return new HashMap<>();
|
}
|
}
|
|
/** 计算可用余额:分配模式=卡片自身余额,共享模式=公司可用余额,账户模式未知时留空 */
|
private BigDecimal resolveAvailableBalance(Integer accountMode, BigDecimal cardBalance, BigDecimal companyBalance) {
|
if (Integer.valueOf(ACCOUNT_MODE_ALLOCATION).equals(accountMode)) {
|
return cardBalance;
|
}
|
if (Integer.valueOf(ACCOUNT_MODE_SHARED).equals(accountMode)) {
|
return companyBalance;
|
}
|
return null;
|
}
|
|
/** 超额:可用余额为负时取其绝对值,其余情况为空 */
|
private BigDecimal resolveOverrun(BigDecimal balance) {
|
if (balance == null || balance.signum() >= 0) {
|
return null;
|
}
|
return balance.abs().setScale(AMOUNT_SCALE, RoundingMode.HALF_UP);
|
}
|
|
/** 账户状态:可用余额为负=欠款,非负=正常,可用余额未知时为空 */
|
private Integer resolveAccountStatus(BigDecimal balance) {
|
if (balance == null) {
|
return null;
|
}
|
return balance.signum() < 0 ? ACCOUNT_STATUS_OVERDUE : ACCOUNT_STATUS_NORMAL;
|
}
|
|
/**
|
* 更新卡片记录:主字段走 updateById(保持既有行为),
|
* 公司/车队/额度发生变化时补充显式更新,保证三者可被清空
|
*/
|
private boolean updateCardRecord(TChargingCard card, String encodedPassword, TChargingCard existing) {
|
TChargingCard update = new TChargingCard();
|
update.setId(card.getId());
|
update.setCardNo(card.getCardNo());
|
update.setStatus(card.getStatus());
|
update.setPassword(encodedPassword);
|
update.setUpdateTime(card.getUpdateTime());
|
update.setUpdateBy(card.getUpdateBy());
|
if (!updateById(update)) {
|
return false;
|
}
|
BigDecimal newBalance = card.getBalance();
|
boolean balanceChanged = newBalance != null && newBalance.signum() > 0
|
&& !quotaEquals(existing.getBalance(), newBalance);
|
boolean relationChanged = !Objects.equals(existing.getCompanyId(), card.getCompanyId())
|
|| !Objects.equals(existing.getFleetId(), card.getFleetId())
|
|| balanceChanged;
|
if (!relationChanged) {
|
return true;
|
}
|
UpdateWrapper<TChargingCard> relationUpdate = new UpdateWrapper<>();
|
relationUpdate.eq("id", card.getId())
|
.set("company_id", card.getCompanyId())
|
.set("fleet_id", card.getFleetId());
|
if (balanceChanged) {
|
relationUpdate.set("balance", newBalance);
|
}
|
return update(null, relationUpdate);
|
}
|
|
/** 额度比较:null 与 0 视为等价 */
|
private boolean quotaEquals(BigDecimal left, BigDecimal right) {
|
BigDecimal leftValue = left == null ? BigDecimal.ZERO : left;
|
BigDecimal rightValue = right == null ? BigDecimal.ZERO : right;
|
return leftValue.compareTo(rightValue) == 0;
|
}
|
|
/** 抢占并发锁,抢占失败抛出业务异常 */
|
private boolean tryQuotaLock(RLock lock, String busyMessage) {
|
try {
|
if (!lock.tryLock(LOCK_WAIT_SECONDS, LOCK_LEASE_SECONDS, TimeUnit.SECONDS)) {
|
throw new ServiceException(busyMessage);
|
}
|
return true;
|
} catch (InterruptedException ex) {
|
Thread.currentThread().interrupt();
|
throw new ServiceException(busyMessage);
|
}
|
}
|
|
private void unlockQuota(RLock lock, boolean locked) {
|
if (!locked) {
|
return;
|
}
|
try {
|
if (lock.isHeldByCurrentThread()) {
|
lock.unlock();
|
}
|
} catch (Exception ex) {
|
log.warn("释放卡片额度并发锁失败", ex);
|
}
|
}
|
|
private void validateCard(ChargingCardSaveDTO dto, boolean update) {
|
if (dto == null || StringUtils.isEmpty(dto.getCardNo())) {
|
throw new ServiceException("卡号不能为空");
|
}
|
if (update && dto.getId() == null) {
|
throw new ServiceException("卡片ID不能为空");
|
}
|
String cardNo = dto.getCardNo().trim();
|
if (cardNo.length() > 64) {
|
throw new ServiceException("卡号不能超过64个字符");
|
}
|
if (dto.getPassword() != null && !dto.getPassword().trim().isEmpty()) {
|
if (dto.getPassword().length() > PASSWORD_MAX_LENGTH) {
|
throw new ServiceException("卡密码不能超过" + PASSWORD_MAX_LENGTH + "个字符");
|
}
|
}
|
if (!update && dto.getBalance() != null) {
|
validateAmount(dto.getBalance(), true, "卡余额");
|
}
|
if (dto.getStatus() != null && !isValidStatus(dto.getStatus())) {
|
throw new ServiceException("卡片状态参数错误");
|
}
|
if (existsCardNo(cardNo, dto.getId())) {
|
throw new ServiceException("卡号已存在");
|
}
|
}
|
|
private String encodePasswordIfPresent(String rawPassword) {
|
if (rawPassword == null || rawPassword.trim().isEmpty()) {
|
return null;
|
}
|
return SecurityUtils.encryptPassword(rawPassword.trim());
|
}
|
|
/**
|
* 卡片处于充电锁定状态时拒绝管理操作(V0.2 定稿)。
|
*
|
* @param cards 待操作的卡片
|
* @param action 操作描述,用于异常提示
|
*/
|
private void assertNotChargingLocked(List<TChargingCard> cards, String action) {
|
if (cards == null) {
|
return;
|
}
|
for (TChargingCard card : cards) {
|
assertNotChargingLocked(card, action);
|
}
|
}
|
|
private void assertNotChargingLocked(TChargingCard card, String action) {
|
if (card.getChargingFlag() != null && card.getChargingFlag() == CHARGING_FLAG_LOCKED) {
|
throw new ServiceException("卡片【" + card.getCardNo() + "】正在充电中,禁止" + action
|
+ ",充电结束后可重试");
|
}
|
}
|
|
private boolean existsCardNo(String cardNo, Long excludeId) {
|
return baseMapper.countByCardNo(cardNo, excludeId) > 0;
|
}
|
|
private void validateDeductRequest(ChargingCardDeductDTO dto) {
|
if (dto == null) {
|
throw new ServiceException("扣款参数不能为空");
|
}
|
if (dto.getCardId() == null) {
|
throw new ServiceException("卡片ID不能为空");
|
}
|
if (StringUtils.isEmpty(dto.getOrderCode())) {
|
throw new ServiceException("订单号不能为空");
|
}
|
if (dto.getOrderCode().length() > 64) {
|
throw new ServiceException("订单号不能超过64个字符");
|
}
|
validateAmount(dto.getAmount(), false, "扣款金额");
|
}
|
|
private void validateAmount(BigDecimal amount, boolean allowZero, String fieldName) {
|
if (amount == null || (allowZero ? amount.compareTo(BigDecimal.ZERO) < 0 : amount.compareTo(BigDecimal.ZERO) <= 0)) {
|
throw new ServiceException(fieldName + (allowZero ? "不能小于0" : "必须大于0"));
|
}
|
if (amount.scale() > 2) {
|
throw new ServiceException(fieldName + "最多保留2位小数");
|
}
|
if (amount.compareTo(MAX_AMOUNT) > 0) {
|
throw new ServiceException(fieldName + "不能超过" + MAX_AMOUNT.toPlainString());
|
}
|
}
|
|
private Integer parseExcelStatus(String status, int row) {
|
if (StringUtils.isEmpty(status)) {
|
return ENABLED;
|
}
|
String value = status.trim();
|
if ("1".equals(value) || "启用".equals(value)) {
|
return ENABLED;
|
}
|
if ("2".equals(value) || "禁用".equals(value)) {
|
return DISABLED;
|
}
|
String prefix = row > 0 ? "第" + row + "行" : "";
|
throw new ServiceException(prefix + "启用状态错误");
|
}
|
|
private boolean isValidStatus(Integer status) {
|
return status != null && (status == ENABLED || status == DISABLED);
|
}
|
}
|