|
@@ -19,7 +19,17 @@ import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUt
|
|
|
import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.AI_POINT_ADJUST_POINT_INVALID;
|
|
import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.AI_POINT_ADJUST_POINT_INVALID;
|
|
|
import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.AI_POINT_NOT_ENOUGH;
|
|
import static cn.iocoder.byzs.module.ai.enums.ErrorCodeConstants.AI_POINT_NOT_ENOUGH;
|
|
|
|
|
|
|
|
-/** 积分的所有余额变更都经由本服务,禁止由前端或业务代码直接更新余额。 */
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * AI 积分服务实现
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 设计核心:所有余额变更(扣减 / 退款 / 充值)都经由本服务统一入口,
|
|
|
|
|
+ * 余额只能通过数据库层乐观锁/条件更新变更,禁止业务代码直接 update balance,
|
|
|
|
|
+ * 防止并发超扣。
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 标准流水状态机:PROCESSING(处理中) → SUCCESS(成功) / MANUAL(人工调整) / REFUNDED(已退款)
|
|
|
|
|
+ *
|
|
|
|
|
+ * @author byzs
|
|
|
|
|
+ */
|
|
|
@Service
|
|
@Service
|
|
|
public class AiPointServiceImpl implements AiPointService {
|
|
public class AiPointServiceImpl implements AiPointService {
|
|
|
|
|
|
|
@@ -30,6 +40,12 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
@Resource
|
|
@Resource
|
|
|
private AdminUserApi adminUserApi;
|
|
private AdminUserApi adminUserApi;
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取租户积分账户,不存在则自动创建
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * catch DuplicateKeyException 是为了兜住并发场景下多条请求同时触发初始化,
|
|
|
|
|
+ * 唯一由 DB 唯一索引保证,捕获后回查即可。
|
|
|
|
|
+ */
|
|
|
@Override
|
|
@Override
|
|
|
public TenantAiPointDO getOrCreateAccount(Long tenantId) {
|
|
public TenantAiPointDO getOrCreateAccount(Long tenantId) {
|
|
|
TenantAiPointDO account = accountMapper.selectByTenantId(tenantId);
|
|
TenantAiPointDO account = accountMapper.selectByTenantId(tenantId);
|
|
@@ -51,12 +67,20 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 预扣积分(核心入口)
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 先通过 SQL 条件扣减(WHERE balance >= point)保证原子性,
|
|
|
|
|
+ * 再写入一条 PROCESSING 状态的消费流水,后续由 confirm / refund 决定最终去向。
|
|
|
|
|
+ * 整条事务保证"扣余额 + 写流水"要么同时成功同时回滚。
|
|
|
|
|
+ */
|
|
|
@Override
|
|
@Override
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
public AiPointConsumeRecordDO reserve(Long tenantId, Long userId, String userNickname,
|
|
public AiPointConsumeRecordDO reserve(Long tenantId, Long userId, String userNickname,
|
|
|
AiPointBizTypeEnum bizType, Long bizId) {
|
|
AiPointBizTypeEnum bizType, Long bizId) {
|
|
|
getOrCreateAccount(tenantId);
|
|
getOrCreateAccount(tenantId);
|
|
|
int point = bizType.getPoint();
|
|
int point = bizType.getPoint();
|
|
|
|
|
+ // deductIfEnough 是条件 UPDATE,返回影响行数;为 0 说明余额不足
|
|
|
if (accountMapper.deductIfEnough(tenantId, point) == 0) {
|
|
if (accountMapper.deductIfEnough(tenantId, point) == 0) {
|
|
|
throw exception(AI_POINT_NOT_ENOUGH);
|
|
throw exception(AI_POINT_NOT_ENOUGH);
|
|
|
}
|
|
}
|
|
@@ -77,11 +101,25 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
return record;
|
|
return record;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 确认消费成功
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 仅 PROCESSING 状态的流水会被更新,markSuccessIfProcessing 返回影响行数 = 0 说明
|
|
|
|
|
+ * 已被 refund 或非预期状态,静默忽略避免重复处理。
|
|
|
|
|
+ */
|
|
|
@Override
|
|
@Override
|
|
|
public void confirm(Long recordId, String resultUrl) {
|
|
public void confirm(Long recordId, String resultUrl) {
|
|
|
recordMapper.markSuccessIfProcessing(recordId, resultUrl);
|
|
recordMapper.markSuccessIfProcessing(recordId, resultUrl);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 退款
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 三步顺序(都在同一事务内保证一致性):
|
|
|
|
|
+ * 1. markRefundedIfProcessing —— 有条件更新做幂等闸门,返回 0 说明已退款或非 PROCESSING 状态,直接返回
|
|
|
|
|
+ * 2. accountMapper.refund —— 账户余额加回
|
|
|
|
|
+ * 3. updateBalanceAfter —— 流水 balance_after 同步更新为退款后的实际余额
|
|
|
|
|
+ */
|
|
|
@Override
|
|
@Override
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
public void refund(Long recordId, String failureReason) {
|
|
public void refund(Long recordId, String failureReason) {
|
|
@@ -89,12 +127,22 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
if (record == null || !AiPointRecordStatusEnum.PROCESSING.getStatus().equals(record.getStatus())) {
|
|
if (record == null || !AiPointRecordStatusEnum.PROCESSING.getStatus().equals(record.getStatus())) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ // 1. 幂等闸门:仅 PROCESSING 状态允许更新
|
|
|
if (recordMapper.markRefundedIfProcessing(recordId, record.getConsumePoint(), limit(failureReason)) == 0) {
|
|
if (recordMapper.markRefundedIfProcessing(recordId, record.getConsumePoint(), limit(failureReason)) == 0) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
|
|
+ // 2. 账户余额加回
|
|
|
accountMapper.refund(record.getTenantId(), record.getConsumePoint());
|
|
accountMapper.refund(record.getTenantId(), record.getConsumePoint());
|
|
|
|
|
+ // 3. 流水 balance_after 同步为退款后的最新余额
|
|
|
|
|
+ TenantAiPointDO afterAccount = accountMapper.selectByTenantId(record.getTenantId());
|
|
|
|
|
+ recordMapper.updateBalanceAfter(recordId, afterAccount.getBalance());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 人工充值 / 积分调整
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 流水消费点记为负值(consumePoint = -point),用于与消费流水在统计时统一处理。
|
|
|
|
|
+ */
|
|
|
@Override
|
|
@Override
|
|
|
@Transactional(rollbackFor = Exception.class)
|
|
@Transactional(rollbackFor = Exception.class)
|
|
|
public void recharge(Long tenantId, Integer point, String remark, Long userId, String userNickname) {
|
|
public void recharge(Long tenantId, Integer point, String remark, Long userId, String userNickname) {
|
|
@@ -114,17 +162,24 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
record.setConsumePoint(-point);
|
|
record.setConsumePoint(-point);
|
|
|
record.setBalanceAfter(afterAccount.getBalance());
|
|
record.setBalanceAfter(afterAccount.getBalance());
|
|
|
record.setBalanceBefore(afterAccount.getBalance() - point);
|
|
record.setBalanceBefore(afterAccount.getBalance() - point);
|
|
|
- // 人工充值属于人工调整,保持约定状态 3;页面将其展示为“人工调整”。
|
|
|
|
|
|
|
+ // 人工充值属于人工调整,保持约定状态 3;页面将其展示为"人工调整"。
|
|
|
record.setStatus(AiPointRecordStatusEnum.MANUAL.getStatus());
|
|
record.setStatus(AiPointRecordStatusEnum.MANUAL.getStatus());
|
|
|
record.setFailureReason(limit(remark));
|
|
record.setFailureReason(limit(remark));
|
|
|
record.setRefundPoint(0);
|
|
record.setRefundPoint(0);
|
|
|
recordMapper.insert(record);
|
|
recordMapper.insert(record);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** 字符串截断,防止长文本写库溢出 */
|
|
|
private String limit(String value) {
|
|
private String limit(String value) {
|
|
|
return value != null && value.length() > 500 ? value.substring(0, 500) : value;
|
|
return value != null && value.length() > 500 ? value.substring(0, 500) : value;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析用户昵称
|
|
|
|
|
+ * <p>
|
|
|
|
|
+ * 优先使用调用方传入值(避免 RPC),兜底走 AdminUserApi 远程查询;
|
|
|
|
|
+ * 最终兜底 fallback 到 "用户{userId}" 保证流水一定可追溯。
|
|
|
|
|
+ */
|
|
|
private String resolveUserNickname(Long userId, String userNickname) {
|
|
private String resolveUserNickname(Long userId, String userNickname) {
|
|
|
if (StrUtil.isNotBlank(userNickname)) {
|
|
if (StrUtil.isNotBlank(userNickname)) {
|
|
|
return userNickname;
|
|
return userNickname;
|
|
@@ -135,4 +190,4 @@ public class AiPointServiceImpl implements AiPointService {
|
|
|
AdminUserRespDTO user = adminUserApi.getUser(userId);
|
|
AdminUserRespDTO user = adminUserApi.getUser(userId);
|
|
|
return user != null ? StrUtil.blankToDefault(user.getNickname(), "用户" + userId) : "用户" + userId;
|
|
return user != null ? StrUtil.blankToDefault(user.getNickname(), "用户" + userId) : "用户" + userId;
|
|
|
}
|
|
}
|
|
|
-}
|
|
|
|
|
|
|
+}
|