Jelajahi Sumber

增加积分机制,跟租户绑定,图片100、视频500

liyanbo 4 minggu lalu
induk
melakukan
49440500cf
22 mengubah file dengan 606 tambahan dan 12 penghapusan
  1. 1 0
      .gitignore
  2. 5 2
      byzs-framework/byzs-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/byzs/framework/tenant/core/security/TenantDataSecurityFilter.java
  3. 74 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/AiPointController.java
  4. 10 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointAccountPageReqVO.java
  5. 10 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointBalanceRespVO.java
  6. 15 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointRechargeReqVO.java
  7. 15 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointRecordPageReqVO.java
  8. 28 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/dataobject/point/AiPointConsumeRecordDO.java
  9. 22 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/dataobject/point/TenantAiPointDO.java
  10. 32 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/mysql/point/AiPointConsumeRecordMapper.java
  11. 36 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/mysql/point/TenantAiPointMapper.java
  12. 5 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/ErrorCodeConstants.java
  13. 17 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/point/AiPointBizTypeEnum.java
  14. 13 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/point/AiPointRecordStatusEnum.java
  15. 21 4
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/image/AiImageServiceImpl.java
  16. 18 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/point/AiPointService.java
  17. 138 0
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/point/AiPointServiceImpl.java
  18. 1 1
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/video/AiVideoService.java
  19. 25 4
      byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/video/AiVideoServiceImpl.java
  20. 8 1
      byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/user/WebUserInfoController.java
  21. 3 0
      byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/user/vo/WebUserVO.java
  22. 109 0
      sql/mysql/20261010_tenant_ai_point.sql

+ 1 - 0
.gitignore

@@ -8,6 +8,7 @@
 target/
 out/
 *.class
+.flattened-pom.xml
 
 # 日志文件
 *.log

+ 5 - 2
byzs-framework/byzs-spring-boot-starter-biz-tenant/src/main/java/cn/iocoder/byzs/framework/tenant/core/security/TenantDataSecurityFilter.java

@@ -39,7 +39,10 @@ public class TenantDataSecurityFilter extends OncePerRequestFilter {
             "/ai/chat/conversation/page",//对话列表
             "/ai/image/page",//绘画列表
             "/ai/write/page",//创作列表
-            "/ai/mind-map/page"//思维导图列表
+            "/ai/mind-map/page",//思维导图列表
+
+            // AI 积分账户由具备后台权限的管理员跨租户管理,不能被 data-tenant-id 预检查阻断
+            "/ai/point/"
     ));
 
     @Override
@@ -97,4 +100,4 @@ public class TenantDataSecurityFilter extends OncePerRequestFilter {
         }
         return EXCLUDED_METHOD_PATHS.stream().anyMatch(path -> requestURI.contains(path));
     }
-}
+}

+ 74 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/AiPointController.java

@@ -0,0 +1,74 @@
+package cn.iocoder.byzs.module.ai.controller.admin.point;
+
+import cn.iocoder.byzs.framework.common.pojo.CommonResult;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.framework.security.core.util.SecurityFrameworkUtils;
+import cn.iocoder.byzs.framework.tenant.core.context.TenantContextHolder;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointBalanceRespVO;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointRechargeReqVO;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointAccountPageReqVO;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointRecordPageReqVO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import cn.iocoder.byzs.module.ai.dal.mysql.point.TenantAiPointMapper;
+import cn.iocoder.byzs.module.ai.dal.mysql.point.AiPointConsumeRecordMapper;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.TenantAiPointDO;
+import cn.iocoder.byzs.module.ai.service.point.AiPointService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+
+import static cn.iocoder.byzs.framework.common.pojo.CommonResult.success;
+
+@Tag(name = "管理后台 - AI 积分")
+@RestController
+@RequestMapping("/ai/point")
+public class AiPointController {
+    @Resource
+    private AiPointService pointService;
+    @Resource
+    private TenantAiPointMapper accountMapper;
+    @Resource
+    private AiPointConsumeRecordMapper recordMapper;
+
+    @GetMapping("/my-balance")
+    @Operation(summary = "获取当前租户 AI 积分余额")
+    public CommonResult<AiPointBalanceRespVO> getMyBalance() {
+        TenantAiPointDO account = pointService.getOrCreateAccount(TenantContextHolder.getRequiredTenantId());
+        AiPointBalanceRespVO respVO = new AiPointBalanceRespVO();
+        respVO.setBalance(account.getBalance());
+        return success(respVO);
+    }
+
+    @GetMapping("/get")
+    @Operation(summary = "获取指定租户的 AI 积分账户")
+    @PreAuthorize("@ss.hasPermission('system:tenant-ai-point:query')")
+    public CommonResult<TenantAiPointDO> get(@RequestParam("tenantId") Long tenantId) {
+        return success(pointService.getOrCreateAccount(tenantId));
+    }
+
+    @GetMapping("/account-page")
+    @Operation(summary = "分页查询租户 AI 积分账户")
+    @PreAuthorize("@ss.hasPermission('system:tenant-ai-point:query')")
+    public CommonResult<PageResult<TenantAiPointDO>> getAccountPage(@Valid AiPointAccountPageReqVO reqVO) {
+        return success(accountMapper.selectPage(reqVO));
+    }
+
+    @GetMapping("/record-page")
+    @Operation(summary = "分页查询 AI 积分消耗流水")
+    @PreAuthorize("@ss.hasPermission('system:ai-point-record:query')")
+    public CommonResult<PageResult<AiPointConsumeRecordDO>> getRecordPage(@Valid AiPointRecordPageReqVO reqVO) {
+        return success(recordMapper.selectPage(reqVO));
+    }
+
+    @PostMapping("/recharge")
+    @Operation(summary = "为租户充值 AI 积分")
+    @PreAuthorize("@ss.hasPermission('system:tenant-ai-point:update')")
+    public CommonResult<Boolean> recharge(@Valid @RequestBody AiPointRechargeReqVO reqVO) {
+        pointService.recharge(reqVO.getTenantId(), reqVO.getPoint(), reqVO.getRemark(),
+                SecurityFrameworkUtils.getLoginUserId(), SecurityFrameworkUtils.getLoginUserNickname());
+        return success(true);
+    }
+}

+ 10 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointAccountPageReqVO.java

@@ -0,0 +1,10 @@
+package cn.iocoder.byzs.module.ai.controller.admin.point.vo;
+
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+import lombok.Data;
+
+@Data
+public class AiPointAccountPageReqVO extends PageParam {
+    private Long tenantId;
+    private Integer status;
+}

+ 10 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointBalanceRespVO.java

@@ -0,0 +1,10 @@
+package cn.iocoder.byzs.module.ai.controller.admin.point.vo;
+
+import lombok.Data;
+
+@Data
+public class AiPointBalanceRespVO {
+    private Integer balance;
+    private Integer imageCost = 100;
+    private Integer videoCost = 500;
+}

+ 15 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointRechargeReqVO.java

@@ -0,0 +1,15 @@
+package cn.iocoder.byzs.module.ai.controller.admin.point.vo;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class AiPointRechargeReqVO {
+    @NotNull(message = "租户编号不能为空")
+    private Long tenantId;
+    @NotNull(message = "充值积分不能为空")
+    private Integer point;
+    @Size(max = 500)
+    private String remark;
+}

+ 15 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/controller/admin/point/vo/AiPointRecordPageReqVO.java

@@ -0,0 +1,15 @@
+package cn.iocoder.byzs.module.ai.controller.admin.point.vo;
+
+import cn.iocoder.byzs.framework.common.pojo.PageParam;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+public class AiPointRecordPageReqVO extends PageParam {
+    private Long tenantId;
+    private Long userId;
+    private String bizType;
+    private Integer status;
+    private LocalDateTime[] createTime;
+}

+ 28 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/dataobject/point/AiPointConsumeRecordDO.java

@@ -0,0 +1,28 @@
+package cn.iocoder.byzs.module.ai.dal.dataobject.point;
+
+import cn.iocoder.byzs.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.byzs.framework.tenant.core.aop.TenantIgnore;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TenantIgnore
+@TableName("ai_point_consume_record")
+public class AiPointConsumeRecordDO extends BaseDO {
+    @TableId
+    private Long id;
+    private String requestNo;
+    private Long tenantId;
+    private Long userId;
+    private String userNickname;
+    private String bizType;
+    private Long bizId;
+    private Integer consumePoint;
+    private Integer balanceBefore;
+    private Integer balanceAfter;
+    private Integer status;
+    private String resultUrl;
+    private String failureReason;
+    private Integer refundPoint;
+}

+ 22 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/dataobject/point/TenantAiPointDO.java

@@ -0,0 +1,22 @@
+package cn.iocoder.byzs.module.ai.dal.dataobject.point;
+
+import cn.iocoder.byzs.framework.mybatis.core.dataobject.BaseDO;
+import cn.iocoder.byzs.framework.tenant.core.aop.TenantIgnore;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/** 租户共享 AI 积分账户。显式按 tenantId 查询,避免平台管理员被租户插件过滤。 */
+@Data
+@TenantIgnore
+@TableName("system_tenant_ai_point")
+public class TenantAiPointDO extends BaseDO {
+    @TableId
+    private Long id;
+    private Long tenantId;
+    private Integer balance;
+    private Integer totalRecharge;
+    private Integer totalConsume;
+    private Integer status;
+    private Integer version;
+}

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

@@ -0,0 +1,32 @@
+package cn.iocoder.byzs.module.ai.dal.mysql.point;
+
+import cn.iocoder.byzs.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.byzs.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointRecordPageReqVO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+@Mapper
+public interface AiPointConsumeRecordMapper extends BaseMapperX<AiPointConsumeRecordDO> {
+    @Update("UPDATE ai_point_consume_record SET status = 2, refund_point = #{refundPoint}, failure_reason = #{reason} "
+            + "WHERE id = #{id} AND status = 0 AND deleted = 0")
+    int markRefundedIfProcessing(@Param("id") Long id, @Param("refundPoint") Integer refundPoint,
+                                 @Param("reason") String reason);
+
+    @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);
+
+    default PageResult<AiPointConsumeRecordDO> selectPage(AiPointRecordPageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<AiPointConsumeRecordDO>()
+                .eqIfPresent(AiPointConsumeRecordDO::getTenantId, reqVO.getTenantId())
+                .eqIfPresent(AiPointConsumeRecordDO::getUserId, reqVO.getUserId())
+                .eqIfPresent(AiPointConsumeRecordDO::getBizType, reqVO.getBizType())
+                .eqIfPresent(AiPointConsumeRecordDO::getStatus, reqVO.getStatus())
+                .betweenIfPresent(AiPointConsumeRecordDO::getCreateTime, reqVO.getCreateTime())
+                .orderByDesc(AiPointConsumeRecordDO::getId));
+    }
+}

+ 36 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/dal/mysql/point/TenantAiPointMapper.java

@@ -0,0 +1,36 @@
+package cn.iocoder.byzs.module.ai.dal.mysql.point;
+
+import cn.iocoder.byzs.framework.mybatis.core.mapper.BaseMapperX;
+import cn.iocoder.byzs.framework.mybatis.core.query.LambdaQueryWrapperX;
+import cn.iocoder.byzs.framework.common.pojo.PageResult;
+import cn.iocoder.byzs.module.ai.controller.admin.point.vo.AiPointAccountPageReqVO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.TenantAiPointDO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+@Mapper
+public interface TenantAiPointMapper extends BaseMapperX<TenantAiPointDO> {
+    default TenantAiPointDO selectByTenantId(Long tenantId) {
+        return selectOne(TenantAiPointDO::getTenantId, tenantId);
+    }
+
+    default PageResult<TenantAiPointDO> selectPage(AiPointAccountPageReqVO reqVO) {
+        return selectPage(reqVO, new LambdaQueryWrapperX<TenantAiPointDO>()
+                .eqIfPresent(TenantAiPointDO::getTenantId, reqVO.getTenantId())
+                .eqIfPresent(TenantAiPointDO::getStatus, reqVO.getStatus())
+                .orderByDesc(TenantAiPointDO::getId));
+    }
+
+    @Update("UPDATE system_tenant_ai_point SET balance = balance - #{point}, total_consume = total_consume + #{point}, version = version + 1 "
+            + "WHERE tenant_id = #{tenantId} AND status = 1 AND balance >= #{point} AND deleted = 0")
+    int deductIfEnough(@Param("tenantId") Long tenantId, @Param("point") Integer point);
+
+    @Update("UPDATE system_tenant_ai_point SET balance = balance + #{point}, total_consume = total_consume - #{point}, version = version + 1 "
+            + "WHERE tenant_id = #{tenantId} AND deleted = 0")
+    int refund(@Param("tenantId") Long tenantId, @Param("point") Integer point);
+
+    @Update("UPDATE system_tenant_ai_point SET balance = balance + #{point}, total_recharge = total_recharge + #{point}, version = version + 1 "
+            + "WHERE tenant_id = #{tenantId} AND status = 1 AND deleted = 0")
+    int recharge(@Param("tenantId") Long tenantId, @Param("point") Integer point);
+}

+ 5 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/ErrorCodeConstants.java

@@ -72,6 +72,11 @@ public interface ErrorCodeConstants {
     // ========== API 视频 1-040-012-000 ==========
     ErrorCode VIDEO_NOT_EXISTS = new ErrorCode(1_040_012_000, "视频不存在!");
 
+    // ========== AI 积分 1-040-013-000 ==========
+    ErrorCode AI_POINT_NOT_ENOUGH = new ErrorCode(1_040_013_000, "当前租户 AI 积分不足");
+    ErrorCode AI_POINT_ACCOUNT_NOT_EXISTS = new ErrorCode(1_040_013_001, "租户 AI 积分账户不存在");
+    ErrorCode AI_POINT_ADJUST_POINT_INVALID = new ErrorCode(1_040_013_002, "调整积分必须大于 0");
+
      // ========== API 虚拟实验室 1-050-013-000 ==========
     ErrorCode VIRTUAL_DEVICE_NOT_EXISTS = new ErrorCode(1_050_001_000, "虚拟实验室不存在!");
 

+ 17 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/point/AiPointBizTypeEnum.java

@@ -0,0 +1,17 @@
+package cn.iocoder.byzs.module.ai.enums.point;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum AiPointBizTypeEnum {
+    IMAGE("IMAGE", "图片生成", 100),
+    VIDEO("VIDEO", "视频生成", 500),
+    MANUAL_RECHARGE("MANUAL_RECHARGE", "人工充值", 0),
+    MANUAL_DEDUCT("MANUAL_DEDUCT", "人工扣减", 0);
+
+    private final String type;
+    private final String name;
+    private final int point;
+}

+ 13 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/enums/point/AiPointRecordStatusEnum.java

@@ -0,0 +1,13 @@
+package cn.iocoder.byzs.module.ai.enums.point;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum AiPointRecordStatusEnum {
+    PROCESSING(0, "生成中"), SUCCESS(1, "成功"), FAILED_REFUNDED(2, "失败已退款"), MANUAL(3, "人工调整");
+
+    private final Integer status;
+    private final String name;
+}

+ 21 - 4
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/image/AiImageServiceImpl.java

@@ -29,6 +29,10 @@ import cn.iocoder.byzs.module.ai.enums.image.AiImageStatusEnum;
 import cn.iocoder.byzs.module.ai.service.model.AiApiKeyService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelServiceImpl;
+import cn.iocoder.byzs.module.ai.service.point.AiPointService;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import cn.iocoder.byzs.module.ai.enums.point.AiPointBizTypeEnum;
+import cn.iocoder.byzs.framework.tenant.core.context.TenantContextHolder;
 import cn.iocoder.byzs.module.infra.api.file.FileApi;
 import com.alibaba.cloud.ai.dashscope.image.DashScopeImageOptions;
 import com.volcengine.ark.runtime.model.images.generation.GenerateImagesRequest;
@@ -82,6 +86,8 @@ public class AiImageServiceImpl implements AiImageService {
     private AiApiKeyService apiKeyService;
     @Resource
     private AiModelServiceImpl aiModelService;
+    @Resource
+    private AiPointService pointService;
 
     @Override
     public PageResult<AiImageDO> getImagePageMy(Long userId, AiImagePageReqVO pageReqVO) {
@@ -118,13 +124,22 @@ public class AiImageServiceImpl implements AiImageService {
                 .setPublicStatus(false).setStatus(AiImageStatusEnum.IN_PROGRESS.getStatus());
         imageMapper.insert(image);
 
-        // 3. 异步绘制,后续前端通过返回的 id 进行轮询结果
-        getSelf().executeDrawImage(image, drawReqVO, model);
+        // 3. 任务提交时预扣积分;异步生成失败时自动退款
+        AiPointConsumeRecordDO pointRecord;
+        try {
+            pointRecord = pointService.reserve(TenantContextHolder.getRequiredTenantId(), userId, null,
+                    AiPointBizTypeEnum.IMAGE, image.getId());
+        } catch (RuntimeException ex) {
+            imageMapper.deleteById(image.getId());
+            throw ex;
+        }
+        // 4. 异步绘制,后续前端通过返回的 id 进行轮询结果
+        getSelf().executeDrawImage(image, drawReqVO, model, pointRecord.getId());
         return image.getId();
     }
 
     @Async
-    public void executeDrawImage(AiImageDO image, AiImageDrawReqVO reqVO, AiModelDO model) {
+    public void executeDrawImage(AiImageDO image, AiImageDrawReqVO reqVO, AiModelDO model, Long pointRecordId) {
         try {
             // 1.1 构建请求
             ImageOptions request = buildImageOptions(reqVO, model);
@@ -178,11 +193,13 @@ public class AiImageServiceImpl implements AiImageService {
             // 3. 更新数据库
             imageMapper.updateById(new AiImageDO().setId(image.getId()).setStatus(AiImageStatusEnum.SUCCESS.getStatus())
                     .setPicUrl(filePath).setFinishTime(LocalDateTime.now()));
+            pointService.confirm(pointRecordId, filePath);
         } catch (Exception ex) {
             log.error("[executeDrawImage][image({}) 生成异常]", image, ex);
             imageMapper.updateById(new AiImageDO().setId(image.getId())
                     .setStatus(AiImageStatusEnum.FAIL.getStatus())
                     .setErrorMessage(ex.getMessage()).setFinishTime(LocalDateTime.now()));
+            pointService.refund(pointRecordId, ex.getMessage());
         }
     }
 
@@ -428,4 +445,4 @@ public class AiImageServiceImpl implements AiImageService {
         return SpringUtil.getBean(getClass());
     }
 
-}
+}

+ 18 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/point/AiPointService.java

@@ -0,0 +1,18 @@
+package cn.iocoder.byzs.module.ai.service.point;
+
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.TenantAiPointDO;
+import cn.iocoder.byzs.module.ai.enums.point.AiPointBizTypeEnum;
+
+public interface AiPointService {
+    TenantAiPointDO getOrCreateAccount(Long tenantId);
+
+    AiPointConsumeRecordDO reserve(Long tenantId, Long userId, String userNickname,
+                                   AiPointBizTypeEnum bizType, Long bizId);
+
+    void confirm(Long recordId, String resultUrl);
+
+    void refund(Long recordId, String failureReason);
+
+    void recharge(Long tenantId, Integer point, String remark, Long userId, String userNickname);
+}

+ 138 - 0
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/point/AiPointServiceImpl.java

@@ -0,0 +1,138 @@
+package cn.iocoder.byzs.module.ai.service.point;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.TenantAiPointDO;
+import cn.iocoder.byzs.module.ai.dal.mysql.point.AiPointConsumeRecordMapper;
+import cn.iocoder.byzs.module.ai.dal.mysql.point.TenantAiPointMapper;
+import cn.iocoder.byzs.module.ai.enums.point.AiPointBizTypeEnum;
+import cn.iocoder.byzs.module.ai.enums.point.AiPointRecordStatusEnum;
+import cn.iocoder.byzs.module.system.api.user.AdminUserApi;
+import cn.iocoder.byzs.module.system.api.user.dto.AdminUserRespDTO;
+import jakarta.annotation.Resource;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import static cn.iocoder.byzs.framework.common.exception.util.ServiceExceptionUtil.exception;
+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;
+
+/** 积分的所有余额变更都经由本服务,禁止由前端或业务代码直接更新余额。 */
+@Service
+public class AiPointServiceImpl implements AiPointService {
+
+    @Resource
+    private TenantAiPointMapper accountMapper;
+    @Resource
+    private AiPointConsumeRecordMapper recordMapper;
+    @Resource
+    private AdminUserApi adminUserApi;
+
+    @Override
+    public TenantAiPointDO getOrCreateAccount(Long tenantId) {
+        TenantAiPointDO account = accountMapper.selectByTenantId(tenantId);
+        if (account != null) {
+            return account;
+        }
+        try {
+            account = new TenantAiPointDO();
+            account.setTenantId(tenantId);
+            account.setBalance(0);
+            account.setTotalRecharge(0);
+            account.setTotalConsume(0);
+            account.setStatus(1);
+            account.setVersion(0);
+            accountMapper.insert(account);
+            return account;
+        } catch (DuplicateKeyException ignored) {
+            return accountMapper.selectByTenantId(tenantId);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public AiPointConsumeRecordDO reserve(Long tenantId, Long userId, String userNickname,
+                                          AiPointBizTypeEnum bizType, Long bizId) {
+        getOrCreateAccount(tenantId);
+        int point = bizType.getPoint();
+        if (accountMapper.deductIfEnough(tenantId, point) == 0) {
+            throw exception(AI_POINT_NOT_ENOUGH);
+        }
+        TenantAiPointDO afterAccount = accountMapper.selectByTenantId(tenantId);
+        AiPointConsumeRecordDO record = new AiPointConsumeRecordDO();
+        record.setRequestNo(IdUtil.fastSimpleUUID());
+        record.setTenantId(tenantId);
+        record.setUserId(userId);
+        record.setUserNickname(resolveUserNickname(userId, userNickname));
+        record.setBizType(bizType.getType());
+        record.setBizId(bizId);
+        record.setConsumePoint(point);
+        record.setBalanceAfter(afterAccount.getBalance());
+        record.setBalanceBefore(afterAccount.getBalance() + point);
+        record.setStatus(AiPointRecordStatusEnum.PROCESSING.getStatus());
+        record.setRefundPoint(0);
+        recordMapper.insert(record);
+        return record;
+    }
+
+    @Override
+    public void confirm(Long recordId, String resultUrl) {
+        recordMapper.markSuccessIfProcessing(recordId, resultUrl);
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void refund(Long recordId, String failureReason) {
+        AiPointConsumeRecordDO record = recordMapper.selectById(recordId);
+        if (record == null || !AiPointRecordStatusEnum.PROCESSING.getStatus().equals(record.getStatus())) {
+            return;
+        }
+        if (recordMapper.markRefundedIfProcessing(recordId, record.getConsumePoint(), limit(failureReason)) == 0) {
+            return;
+        }
+        accountMapper.refund(record.getTenantId(), record.getConsumePoint());
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void recharge(Long tenantId, Integer point, String remark, Long userId, String userNickname) {
+        if (point == null || point <= 0) {
+            throw exception(AI_POINT_ADJUST_POINT_INVALID);
+        }
+        getOrCreateAccount(tenantId);
+        accountMapper.recharge(tenantId, point);
+        TenantAiPointDO afterAccount = accountMapper.selectByTenantId(tenantId);
+        AiPointConsumeRecordDO record = new AiPointConsumeRecordDO();
+        record.setRequestNo(IdUtil.fastSimpleUUID());
+        record.setTenantId(tenantId);
+        record.setUserId(userId);
+        // Security 上下文中昵称可能为空,兜底从用户服务读取,确保管理员充值流水可追溯。
+        record.setUserNickname(resolveUserNickname(userId, userNickname));
+        record.setBizType(AiPointBizTypeEnum.MANUAL_RECHARGE.getType());
+        record.setConsumePoint(-point);
+        record.setBalanceAfter(afterAccount.getBalance());
+        record.setBalanceBefore(afterAccount.getBalance() - point);
+        // 人工充值属于人工调整,保持约定状态 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;
+    }
+
+    private String resolveUserNickname(Long userId, String userNickname) {
+        if (StrUtil.isNotBlank(userNickname)) {
+            return userNickname;
+        }
+        if (userId == null) {
+            return null;
+        }
+        AdminUserRespDTO user = adminUserApi.getUser(userId);
+        return user != null ? StrUtil.blankToDefault(user.getNickname(), "用户" + userId) : "用户" + userId;
+    }
+}

+ 1 - 1
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/video/AiVideoService.java

@@ -27,7 +27,7 @@ public interface AiVideoService {
      */
     PageResult<AiVideoDO> getVideoPageMy(Long userId, AiVideoPageReqVO pageReqVO);
 
-    void executeDrawVideo(AiVideoDO video, AiVideoDrawReqVO reqVO, AiModelDO model);
+    void executeDrawVideo(AiVideoDO video, AiVideoDrawReqVO reqVO, AiModelDO model, Long pointRecordId);
     /**
      * 获取公开的绘图分页
      *

+ 25 - 4
byzs-module-ai/src/main/java/cn/iocoder/byzs/module/ai/service/video/AiVideoServiceImpl.java

@@ -18,6 +18,10 @@ import cn.iocoder.byzs.module.ai.dal.mysql.video.AiVideoMapper;
 import cn.iocoder.byzs.module.ai.enums.video.AiVideoStatusEnum;
 import cn.iocoder.byzs.module.ai.service.model.AiApiKeyService;
 import cn.iocoder.byzs.module.ai.service.model.AiModelService;
+import cn.iocoder.byzs.module.ai.service.point.AiPointService;
+import cn.iocoder.byzs.module.ai.dal.dataobject.point.AiPointConsumeRecordDO;
+import cn.iocoder.byzs.module.ai.enums.point.AiPointBizTypeEnum;
+import cn.iocoder.byzs.framework.tenant.core.context.TenantContextHolder;
 import cn.iocoder.byzs.module.infra.api.file.FileApi;
 import com.volcengine.ark.runtime.model.content.generation.CreateContentGenerationTaskRequest;
 import com.volcengine.ark.runtime.model.content.generation.CreateContentGenerationTaskRequest.Content;
@@ -60,6 +64,8 @@ public class AiVideoServiceImpl implements AiVideoService {
     private FileApi fileApi;
     @Resource
     private AiApiKeyService apiKeyService;
+    @Resource
+    private AiPointService pointService;
 
     @Override
     public PageResult<AiVideoDO> getVideoPageMy(Long userId, AiVideoPageReqVO pageReqVO) {
@@ -100,13 +106,22 @@ public class AiVideoServiceImpl implements AiVideoService {
                 .setPublicStatus(false).setStatus(AiVideoStatusEnum.IN_PROGRESS.getStatus());
         videoMapper.insert(video);
 
-        // 3. 异步绘制,后续前端通过返回的 id 进行轮询结果
-        getSelf().executeDrawVideo(video, drawReqVO, model);
+        // 3. 任务提交时预扣积分;异步生成失败时自动退款
+        AiPointConsumeRecordDO pointRecord;
+        try {
+            pointRecord = pointService.reserve(TenantContextHolder.getRequiredTenantId(), userId, null,
+                    AiPointBizTypeEnum.VIDEO, video.getId());
+        } catch (RuntimeException ex) {
+            videoMapper.deleteById(video.getId());
+            throw ex;
+        }
+        // 4. 异步绘制,后续前端通过返回的 id 进行轮询结果
+        getSelf().executeDrawVideo(video, drawReqVO, model, pointRecord.getId());
         return video.getId();
     }
     @Async
     @Override
-    public void executeDrawVideo(AiVideoDO video, AiVideoDrawReqVO reqVO, AiModelDO model) {
+    public void executeDrawVideo(AiVideoDO video, AiVideoDrawReqVO reqVO, AiModelDO model, Long pointRecordId) {
         String filePath = "";
 
         try {
@@ -202,6 +217,11 @@ public class AiVideoServiceImpl implements AiVideoService {
             // 3. 更新数据库
             videoMapper.updateById(new AiVideoDO().setId(video.getId()).setStatus(videoSstatus)
                     .setVideoUrl(filePath).setFinishTime(LocalDateTime.now()));
+            if (AiVideoStatusEnum.SUCCESS.getStatus().equals(videoSstatus)) {
+                pointService.confirm(pointRecordId, filePath);
+            } else {
+                pointService.refund(pointRecordId, "视频生成失败");
+            }
 
             // shutdown service after all requests is finished
             service.shutdownExecutor();
@@ -211,6 +231,7 @@ public class AiVideoServiceImpl implements AiVideoService {
             videoMapper.updateById(new AiVideoDO().setId(video.getId())
                     .setStatus(AiVideoStatusEnum.FAIL.getStatus())
                     .setErrorMessage(ex.getMessage()).setFinishTime(LocalDateTime.now()));
+            pointService.refund(pointRecordId, ex.getMessage());
         }
     }
 
@@ -263,4 +284,4 @@ public class AiVideoServiceImpl implements AiVideoService {
     private AiVideoService getSelf() {
         return SpringUtil.getBean(AiVideoService.class);
     }
-}
+}

+ 8 - 1
byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/user/WebUserInfoController.java

@@ -4,6 +4,8 @@ import cn.iocoder.byzs.framework.common.pojo.CommonResult;
 import cn.iocoder.byzs.framework.web.core.util.WebFrameworkUtils;
 import cn.iocoder.byzs.module.web.controller.admin.user.vo.WebUserVO;
 import cn.iocoder.byzs.module.web.service.user.WebUserInfoServiceImpl;
+import cn.iocoder.byzs.module.ai.service.point.AiPointService;
+import cn.iocoder.byzs.framework.tenant.core.context.TenantContextHolder;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import jakarta.annotation.Resource;
@@ -22,6 +24,8 @@ public class WebUserInfoController {
 
     @Resource
     private WebUserInfoServiceImpl userInfoServiceImpl;
+    @Resource
+    private AiPointService aiPointService;
 
     @PostMapping("/activateInviteCode")
     @Operation(summary = "激活邀请码")
@@ -39,7 +43,10 @@ public class WebUserInfoController {
             return error(null,"您没有权限查询其他用户信息");
         }
 
-        return success(userInfoServiceImpl.getUserInfo(userId));
+        WebUserVO user = userInfoServiceImpl.getUserInfo(userId);
+        user.setTenantAiPointBalance(aiPointService
+                .getOrCreateAccount(TenantContextHolder.getRequiredTenantId()).getBalance());
+        return success(user);
     }
 
 }

+ 3 - 0
byzs-web/src/main/java/cn/iocoder/byzs/module/web/controller/admin/user/vo/WebUserVO.java

@@ -82,4 +82,7 @@ public class WebUserVO {
     //AI诗词权限过期时间
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private LocalDateTime aiPoetryExpireTime;
+
+    /** 当前租户共享的可用 AI 积分 */
+    private Integer tenantAiPointBalance;
 }

+ 109 - 0
sql/mysql/20261010_tenant_ai_point.sql

@@ -0,0 +1,109 @@
+-- 租户共享 AI 积分:MySQL 8.0+
+-- 执行前请备份数据库;默认给所有现有租户创建 0 积分账户。
+
+CREATE TABLE IF NOT EXISTS `system_tenant_ai_point` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
+  `tenant_id` bigint NOT NULL COMMENT '租户编号',
+  `balance` int NOT NULL DEFAULT 0 COMMENT '当前可用积分',
+  `total_recharge` int NOT NULL DEFAULT 0 COMMENT '累计充值积分',
+  `total_consume` int NOT NULL DEFAULT 0 COMMENT '累计成功消耗积分',
+  `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0禁用 1启用',
+  `version` int NOT NULL DEFAULT 0 COMMENT '并发版本号',
+  `creator` varchar(64) NOT NULL DEFAULT '' COMMENT '创建者',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater` varchar(64) NOT NULL DEFAULT '' COMMENT '更新者',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_tenant_id` (`tenant_id`),
+  KEY `idx_status` (`status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户 AI 积分账户';
+
+CREATE TABLE IF NOT EXISTS `ai_point_consume_record` (
+  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
+  `request_no` varchar(64) NOT NULL COMMENT '唯一请求号',
+  `tenant_id` bigint NOT NULL COMMENT '租户编号',
+  `user_id` bigint DEFAULT NULL COMMENT '消耗用户编号',
+  `user_nickname` varchar(64) DEFAULT NULL COMMENT '用户昵称快照',
+  `biz_type` varchar(32) NOT NULL COMMENT 'IMAGE/VIDEO/MANUAL_RECHARGE',
+  `biz_id` bigint DEFAULT NULL COMMENT '图片或视频任务编号',
+  `consume_point` int NOT NULL COMMENT '消耗积分;人工充值记录为负数',
+  `balance_before` int NOT NULL COMMENT '变动前余额',
+  `balance_after` int NOT NULL COMMENT '变动后余额',
+  `status` tinyint NOT NULL COMMENT '0生成中 1成功 2失败已退款 3人工调整',
+  `result_url` varchar(2048) DEFAULT NULL COMMENT '生成结果路径',
+  `failure_reason` varchar(500) DEFAULT NULL COMMENT '失败或调整说明',
+  `refund_point` int NOT NULL DEFAULT 0 COMMENT '退款积分',
+  `creator` varchar(64) NOT NULL DEFAULT '' COMMENT '创建者',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updater` varchar(64) NOT NULL DEFAULT '' COMMENT '更新者',
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_request_no` (`request_no`),
+  KEY `idx_tenant_create_time` (`tenant_id`,`create_time`),
+  KEY `idx_user_create_time` (`user_id`,`create_time`),
+  KEY `idx_biz` (`biz_type`,`biz_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI 积分消耗流水';
+
+-- 为已有租户初始化积分账户:默认 0 分。将 0 改为目标初始积分再执行即可批量发放。
+INSERT INTO `system_tenant_ai_point`
+  (`tenant_id`,`balance`,`total_recharge`,`total_consume`,`status`,`version`,`creator`,`updater`)
+SELECT t.`id`, 0, 0, 0, 1, 0, 'system', 'system'
+FROM `system_tenant` t
+WHERE t.`deleted` = b'0'
+ON DUPLICATE KEY UPDATE `tenant_id` = VALUES(`tenant_id`);
+
+-- 历史人工充值流水在旧版本中未保存昵称时,补充为通用标识。
+-- 无法从积分表可靠还原实际操作人,因此不虚构 user_id;新产生的人工充值会记录实际管理员 ID 与昵称。
+UPDATE `ai_point_consume_record`
+SET `user_nickname` = '管理员'
+WHERE `biz_type` = 'MANUAL_RECHARGE'
+  AND (`user_nickname` IS NULL OR `user_nickname` = '')
+  AND `deleted` = b'0';
+
+-- 管理端菜单(父菜单为“系统管理” id=1;如你的库中 id 不同,请替换 parent_id)。
+-- 该段可重复执行:已执行过旧版 SQL 时,会把原来的两个一级菜单移动到 AI积分管理目录下。
+INSERT INTO `system_menu`
+(`id`,`name`,`permission`,`type`,`sort`,`parent_id`,`path`,`icon`,`component`,`component_name`,`status`,`visible`,`keep_alive`,`always_show`,`creator`,`updater`,`deleted`)
+SELECT 9000,'AI积分管理','',1,9,1,'ai-point-management','ep:coin',NULL,NULL,0,b'1',b'1',b'1','admin','admin',b'0'
+WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id`=9000);
+
+UPDATE `system_menu`
+SET `name` = 'AI积分管理', `permission` = '', `type` = 1, `sort` = 9, `parent_id` = 1,
+    `path` = 'ai-point-management', `icon` = 'ep:coin', `component` = NULL, `component_name` = NULL,
+    `status` = 0, `visible` = b'1', `keep_alive` = b'1', `always_show` = b'1', `deleted` = b'0'
+WHERE `id` = 9000;
+
+INSERT INTO `system_menu`
+(`id`,`name`,`permission`,`type`,`sort`,`parent_id`,`path`,`icon`,`component`,`component_name`,`status`,`visible`,`keep_alive`,`always_show`,`creator`,`updater`,`deleted`)
+SELECT 9001,'租户AI积分','system:tenant-ai-point:query',2,1,9000,'tenant-point','ep:coin','system/aiPoint/index','SystemAiPoint',0,b'1',b'1',b'1','admin','admin',b'0'
+WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id`=9001);
+
+UPDATE `system_menu`
+SET `name` = '租户AI积分', `permission` = 'system:tenant-ai-point:query', `type` = 2, `sort` = 1, `parent_id` = 9000,
+    `path` = 'tenant-point', `icon` = 'ep:coin', `component` = 'system/aiPoint/index', `component_name` = 'SystemAiPoint',
+    `status` = 0, `visible` = b'1', `keep_alive` = b'1', `always_show` = b'1', `deleted` = b'0'
+WHERE `id` = 9001;
+
+INSERT INTO `system_menu`
+(`id`,`name`,`permission`,`type`,`sort`,`parent_id`,`path`,`icon`,`component`,`component_name`,`status`,`visible`,`keep_alive`,`always_show`,`creator`,`updater`,`deleted`)
+SELECT 9002,'AI积分消耗历史','system:ai-point-record:query',2,2,9000,'consume-history','ep:document','system/aiPointRecord/index','SystemAiPointRecord',0,b'1',b'1',b'1','admin','admin',b'0'
+WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id`=9002);
+
+UPDATE `system_menu`
+SET `name` = 'AI积分消耗历史', `permission` = 'system:ai-point-record:query', `type` = 2, `sort` = 2, `parent_id` = 9000,
+    `path` = 'consume-history', `icon` = 'ep:document', `component` = 'system/aiPointRecord/index', `component_name` = 'SystemAiPointRecord',
+    `status` = 0, `visible` = b'1', `keep_alive` = b'1', `always_show` = b'1', `deleted` = b'0'
+WHERE `id` = 9002;
+
+INSERT INTO `system_menu`
+(`id`,`name`,`permission`,`type`,`sort`,`parent_id`,`path`,`icon`,`component`,`component_name`,`status`,`visible`,`keep_alive`,`always_show`,`creator`,`updater`,`deleted`)
+SELECT 9003,'AI积分充值','system:tenant-ai-point:update',3,1,9001,'','','',NULL,0,b'1',b'1',b'1','admin','admin',b'0'
+WHERE NOT EXISTS (SELECT 1 FROM `system_menu` WHERE `id`=9003);
+
+-- 给超级管理员角色授权。若管理员角色不是 id=1,请替换为实际 role_id。
+INSERT INTO `system_role_menu` (`role_id`,`menu_id`) SELECT 1,9001 WHERE NOT EXISTS (SELECT 1 FROM `system_role_menu` WHERE `role_id`=1 AND `menu_id`=9001);
+INSERT INTO `system_role_menu` (`role_id`,`menu_id`) SELECT 1,9002 WHERE NOT EXISTS (SELECT 1 FROM `system_role_menu` WHERE `role_id`=1 AND `menu_id`=9002);
+INSERT INTO `system_role_menu` (`role_id`,`menu_id`) SELECT 1,9003 WHERE NOT EXISTS (SELECT 1 FROM `system_role_menu` WHERE `role_id`=1 AND `menu_id`=9003);
+INSERT INTO `system_role_menu` (`role_id`,`menu_id`) SELECT 1,9000 WHERE NOT EXISTS (SELECT 1 FROM `system_role_menu` WHERE `role_id`=1 AND `menu_id`=9000);