Pārlūkot izejas kodu

更新失败后流水记录变为当时积分

liyanbo 2 nedēļas atpakaļ
vecāks
revīzija
641a043bbe

+ 4 - 1
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/mysql/point/AiPointConsumeRecordMapper.java

@@ -16,6 +16,9 @@ public interface AiPointConsumeRecordMapper extends BaseMapperX<AiPointConsumeRe
     int markRefundedIfProcessing(@Param("id") Long id, @Param("refundPoint") Integer refundPoint,
                                  @Param("reason") String reason);
 
+    @Update("UPDATE ai_point_consume_record SET balance_after = #{balanceAfter} WHERE id = #{id} AND deleted = 0")
+    int updateBalanceAfter(@Param("id") Long id, @Param("balanceAfter") Integer balanceAfter);
+
     @Update("UPDATE ai_point_consume_record SET status = 1, result_url = #{resultUrl} "
             + "WHERE id = #{id} AND status = 0 AND deleted = 0")
     int markSuccessIfProcessing(@Param("id") Long id, @Param("resultUrl") String resultUrl);
@@ -29,4 +32,4 @@ public interface AiPointConsumeRecordMapper extends BaseMapperX<AiPointConsumeRe
                 .betweenIfPresent(AiPointConsumeRecordDO::getCreateTime, reqVO.getCreateTime())
                 .orderByDesc(AiPointConsumeRecordDO::getId));
     }
-}
+}

+ 58 - 3
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/point/AiPointServiceImpl.java

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