From 394459e7c45bdb63f7737558d56d300a06b3b1ea Mon Sep 17 00:00:00 2001 From: wangzhiwei Date: Fri, 15 May 2026 17:18:17 +0800 Subject: [PATCH] Update backend code with new features and bug fixes --- LOG_ANNOTATION_GUIDE.md | 339 ++++++++++ .../controller/ExchangeCodeController.java | 117 ++++ .../com/kexue/skills/entity/ExchangeCode.java | 70 +++ .../skills/entity/base/BaseQueryDto.java | 30 +- .../entity/dto/ExchangeCodeQueryDto.java | 24 + .../skills/entity/dto/ExchangeRequestDto.java | 18 + .../exception/GlobalExceptionHandler.java | 1 + .../skills/mapper/ExchangeCodeMapper.java | 115 ++++ .../skills/service/ExchangeCodeService.java | 44 ++ .../impl/AccountFrozenServiceImpl.java | 61 +- .../service/impl/CmsContentServiceImpl.java | 231 ++++--- .../service/impl/ExchangeCodeServiceImpl.java | 380 ++++++++++++ .../service/impl/SkillGenServiceImpl.java | 2 +- src/main/resources/mapper/AccountMapper.xml | 2 +- .../resources/mapper/ExchangeCodeMapper.xml | 164 +++++ src/main/resources/schema/exchange_code.sql | 20 + 账户交易记录查询接口文档.md | 579 ++++++++++++++++++ 账户冻结扣费计算逻辑说明.md | 555 +++++++++++++++++ 18 files changed, 2610 insertions(+), 142 deletions(-) create mode 100644 LOG_ANNOTATION_GUIDE.md create mode 100644 src/main/java/com/kexue/skills/controller/ExchangeCodeController.java create mode 100644 src/main/java/com/kexue/skills/entity/ExchangeCode.java create mode 100644 src/main/java/com/kexue/skills/entity/dto/ExchangeCodeQueryDto.java create mode 100644 src/main/java/com/kexue/skills/entity/dto/ExchangeRequestDto.java create mode 100644 src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java create mode 100644 src/main/java/com/kexue/skills/service/ExchangeCodeService.java create mode 100644 src/main/java/com/kexue/skills/service/impl/ExchangeCodeServiceImpl.java create mode 100644 src/main/resources/mapper/ExchangeCodeMapper.xml create mode 100644 src/main/resources/schema/exchange_code.sql create mode 100644 账户交易记录查询接口文档.md create mode 100644 账户冻结扣费计算逻辑说明.md diff --git a/LOG_ANNOTATION_GUIDE.md b/LOG_ANNOTATION_GUIDE.md new file mode 100644 index 0000000..4d842f8 --- /dev/null +++ b/LOG_ANNOTATION_GUIDE.md @@ -0,0 +1,339 @@ +# @Log 注解使用指南 + +## 一、概述 + +`@Log` 注解是 skills 项目中用于记录操作日志的核心功能。它基于 **AOP(面向切面编程)** 和 **拦截器** 机制实现,能够自动捕获 Controller 层方法的请求和响应信息,并异步保存到数据库的 `sys_log` 表中。 + +## 二、核心特性 + +✅ **无侵入性**: 只需添加 `@Log` 注解,无需修改业务代码 +✅ **自动化**: 请求/响应信息自动捕获,无需手动记录 +✅ **高性能**: 异步保存,不影响接口响应速度 +✅ **智能化**: 自动识别浏览器、操作系统、IP等信息 +✅ **可扩展**: 支持类级别和方法级别的注解配置 + +## 三、快速开始 + +### 3.1 数据库初始化 + +执行 SQL 脚本创建或更新日志表: + +```bash +# 在项目根目录执行 +mysql -u root -p skills < backend/db/alter_sys_log_table.sql +``` + +### 3.2 在控制器上添加注解 + +#### 方式一:类级别注解(推荐) + +在 Controller 类上添加 `@Log` 注解,该类下所有方法都会记录日志: + +```java +@Log(module = "用户管理") +@RestController +@RequestMapping("/api/user") +public class UserController { + + @PostMapping("/add") + public CommonResult addUser(@RequestBody UserReq req) { + // 自动记录日志 + } +} +``` + +#### 方式二:方法级别注解 + +在特定方法上添加 `@Log` 注解,只记录该方法的日志: + +```java +@RestController +@RequestMapping("/api/order") +public class OrderController { + + @Log(module = "订单管理", description = "创建订单") + @PostMapping("/create") + public CommonResult createOrder(@RequestBody OrderReq req) { + // 自动记录日志 + } +} +``` + +#### 方式三:忽略特定接口 + +对于高频调用的接口,可以设置 `ignore = true` 不记录日志: + +```java +@Log(module = "认证") +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + @PostMapping("/login") + public CommonResult login(@RequestBody LoginReq req) { + // 会记录日志 + } + + @Log(ignore = true) + @GetMapping("/check/token") + public CommonResult checkToken(@RequestParam String token) { + // 不会记录日志(高频调用接口) + } +} +``` + +## 四、注解属性说明 + +| 属性 | 类型 | 说明 | 默认值 | 示例 | +|------|------|------|--------|------| +| module | String | 模块名称 | 空字符串 | "用户管理" | +| description | String | 日志描述 | 空字符串 | "创建用户" | +| ignore | boolean | 是否忽略记录 | false | true/false | + +## 五、已添加注解的控制器 + +以下控制器已经添加了 `@Log` 注解: + +1. **LoginController** - 登录认证模块 + - 路径: `/api/login/**` + - 模块名: "登录认证" + +2. **CmsContentController** - 内容管理模块 + - 路径: `/api/cmsContent/**` + - 模块名: "内容管理" + +3. **AccountController** - 账户管理模块 + - 路径: `/api/account/**` + - 模块名: "账户管理" + +4. **AccountFrozenController** - 账户冻结模块 + - 路径: `/api/accountFrozen/**` + - 模块名: "账户冻结" + +## 六、日志记录内容 + +系统会自动记录以下信息: + +### 6.1 请求信息 +- 请求方法(GET、POST等) +- 请求URL +- 请求头 +- 请求体 +- 客户端IP +- IP归属地 +- 浏览器信息 +- 操作系统 + +### 6.2 响应信息 +- 响应状态码 +- 响应头 +- 响应体(如果可用) +- 执行耗时(毫秒) + +### 6.3 其他信息 +- 模块名称 +- 日志描述 +- 成功/失败状态 +- 错误信息(如果失败) +- 创建时间 + +## 七、日志查询 + +### 7.1 数据库查询 + +```sql +-- 查询最近的10条日志 +SELECT * FROM sys_log ORDER BY create_time DESC LIMIT 10; + +-- 按模块查询 +SELECT * FROM sys_log WHERE module = '登录认证' ORDER BY create_time DESC; + +-- 查询失败的日志 +SELECT * FROM sys_log WHERE status = 2 ORDER BY create_time DESC; + +-- 按IP查询 +SELECT * FROM sys_log WHERE ip = '192.168.1.100' ORDER BY create_time DESC; + +-- 按时间范围查询 +SELECT * FROM sys_log +WHERE create_time BETWEEN '2026-04-14 00:00:00' AND '2026-04-14 23:59:59' +ORDER BY create_time DESC; +``` + +### 7.2 通过 API 查询 + +可以使用现有的 SysLogController 提供的接口查询日志: + +```bash +# 分页查询 +POST /api/sysLog/getPageList +{ + "pageNum": 1, + "pageSize": 10, + "module": "登录认证", + "status": 1 +} +``` + +## 八、性能优化 + +### 8.1 异步保存 + +日志保存采用异步方式,不会阻塞主业务流程: + +```java +@Async +public void saveLogAsync(LogRecord logRecord) { + // 异步保存逻辑 +} +``` + +### 8.2 数据库索引 + +日志表已添加以下索引以优化查询性能: + +- `idx_module`: 模块查询优化 +- `idx_ip`: IP查询优化 +- `idx_create_time`: 时间范围查询优化 +- `idx_status`: 状态查询优化 + +### 8.3 定期清理 + +建议定期清理历史日志,避免数据量过大: + +```sql +-- 保留最近6个月的日志 +DELETE FROM sys_log WHERE create_time < DATE_SUB(NOW(), INTERVAL 6 MONTH); +``` + +## 九、常见问题 + +### 9.1 日志未记录 + +**可能原因**: +1. 方法标注了 `@Log(ignore = true)` +2. 异步线程池配置问题 + +**排查步骤**: +- 检查控制器是否添加了 `@Log` 注解 +- 查看应用日志是否有错误信息 +- 确认 `@EnableAsync` 已启用 + +### 9.2 响应体为空 + +**原因**: 拦截器无法直接读取响应体 + +**解决方案**: +- 当前版本暂时不记录响应体 +- 如需记录,需要使用 ContentCachingResponseWrapper + +### 9.3 用户ID为空 + +**原因**: 尚未实现从 Token 解析用户ID的逻辑 + +**解决方案**: +- 在 LogInterceptor 的 `saveLogAsync` 方法中添加用户ID解析逻辑 +- 根据实际使用的认证框架(如 Sa-Token)实现 + +## 十、扩展开发 + +### 10.1 添加用户ID解析 + +在 `LogInterceptor.saveLogAsync` 方法中添加: + +```java +// 从 Token 中解析用户ID +try { + String token = request.getHeaders().get("Authorization"); + if (token != null && token.startsWith("Bearer ")) { + token = token.substring(7); + // 使用 Sa-Token 解析用户ID + Object loginId = StpUtil.getLoginIdByToken(token); + if (loginId != null) { + sysLog.setCreateUser(Long.parseLong(loginId.toString())); + } + } +} catch (Exception e) { + logger.warn("解析用户ID失败: {}", e.getMessage()); +} +``` + +### 10.2 自定义日志过滤 + +可以在 `LogInterceptor.preHandle` 方法中添加自定义过滤逻辑: + +```java +// 排除敏感接口 +String requestUri = request.getRequestURI(); +if (requestUri.contains("/sensitive/")) { + return true; // 不记录日志 +} +``` + +## 十一、技术架构 + +### 11.1 核心组件 + +``` +┌─────────────────────────────────────┐ +│ Controller (@Log 注解) │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ LogInterceptor (拦截器) │ +│ - preHandle: 捕获请求信息 │ +│ - afterCompletion: 捕获响应信息 │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ LogInterceptor.saveLogAsync │ +│ (@Async 异步保存) │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ SysLogMapper (持久层) │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ sys_log (MySQL 数据库表) │ +└─────────────────────────────────────┘ +``` + +### 11.2 相关文件 + +| 文件路径 | 说明 | +|---------|------| +| `annotation/Log.java` | @Log 注解定义 | +| `entity/LogRecord.java` | 日志记录对象 | +| `entity/SysLog.java` | 日志实体类 | +| `interceptor/LogInterceptor.java` | 日志拦截器 | +| `config/LogConfiguration.java` | 日志配置类 | +| `mapper/SysLogMapper.java` | 日志 Mapper 接口 | +| `resources/mapper/SysLogMapper.xml` | 日志 SQL 映射 | +| `db/alter_sys_log_table.sql` | 数据库建表脚本 | + +## 十二、总结 + +`@Log` 注解为 skills 项目提供了强大的操作日志记录功能,具有以下优势: + +- ✅ **简单易用**: 只需添加注解即可启用 +- ✅ **性能优异**: 异步保存,不影响业务性能 +- ✅ **信息完整**: 自动捕获请求/响应的详细信息 +- ✅ **灵活配置**: 支持类级别和方法级别的配置 +- ✅ **易于扩展**: 可以根据需求自定义日志记录逻辑 + +通过合理使用 `@Log` 注解,可以实现: +- 操作审计追溯 +- 安全事件监控 +- 接口调用统计 +- 问题排查分析 + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-04-14 +**作者**: Lingma AI Assistant diff --git a/src/main/java/com/kexue/skills/controller/ExchangeCodeController.java b/src/main/java/com/kexue/skills/controller/ExchangeCodeController.java new file mode 100644 index 0000000..d2f2ca3 --- /dev/null +++ b/src/main/java/com/kexue/skills/controller/ExchangeCodeController.java @@ -0,0 +1,117 @@ +package com.kexue.skills.controller; + +import com.github.pagehelper.PageInfo; +import com.kexue.skills.common.CommonResult; +import com.kexue.skills.entity.ExchangeCode; +import com.kexue.skills.entity.dto.ExchangeCodeQueryDto; +import com.kexue.skills.entity.dto.ExchangeRequestDto; +import com.kexue.skills.service.ExchangeCodeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * (ExchangeCode)控制器 + */ +@RestController +@RequestMapping("/api/exchangeCode") +@Tag(name = "兑换码管理", description = "兑换码CRUD、导入导出、兑换功能") +public class ExchangeCodeController { + + @Resource + private ExchangeCodeService exchangeCodeService; + + @GetMapping("/page") + @Operation(summary = "分页查询") + public CommonResult> getPageList(ExchangeCodeQueryDto queryDto) { + return CommonResult.success(exchangeCodeService.getPageList(queryDto)); + } + + @GetMapping("/list") + @Operation(summary = "查询列表") + public CommonResult> getList(ExchangeCodeQueryDto queryDto) { + return CommonResult.success(exchangeCodeService.getList(queryDto)); + } + + @GetMapping("/{id}") + @Operation(summary = "通过ID查询") + public CommonResult queryById(@Parameter(description = "主键ID") @PathVariable Long id) { + return CommonResult.success(exchangeCodeService.queryById(id)); + } + + @GetMapping("/code/{code}") + @Operation(summary = "通过兑换码查询") + public CommonResult queryByCode(@Parameter(description = "兑换码") @PathVariable String code) { + return CommonResult.success(exchangeCodeService.queryByCode(code)); + } + + @PostMapping + @Operation(summary = "新增兑换码") + public CommonResult insert(@RequestBody ExchangeCode exchangeCode) { + return CommonResult.success(exchangeCodeService.insert(exchangeCode)); + } + + @PutMapping + @Operation(summary = "更新兑换码") + public CommonResult update(@RequestBody ExchangeCode exchangeCode) { + return CommonResult.success(exchangeCodeService.update(exchangeCode)); + } + + @DeleteMapping("/{id}") + @Operation(summary = "物理删除") + public CommonResult deleteById(@Parameter(description = "主键ID") @PathVariable Long id) { + return CommonResult.success(exchangeCodeService.deleteById(id)); + } + + @PutMapping("/{id}/logicDelete") + @Operation(summary = "逻辑删除") + public CommonResult logicDeleteById( + @Parameter(description = "主键ID") @PathVariable Long id, + @Parameter(description = "更新人") @RequestParam String updateBy) { + return CommonResult.success(exchangeCodeService.logicDeleteById(id, updateBy)); + } + + @PostMapping("/exchange") + @Operation(summary = "兑换功能") + public CommonResult> exchange(@RequestBody ExchangeRequestDto requestDto) { + Map result = exchangeCodeService.exchange(requestDto); + boolean success = (Boolean) result.get("success"); + if (success) { + return CommonResult.success(result); + } else { + return CommonResult.failed((String) result.get("message")); + } + } + + @PostMapping("/importExcel") + @Operation(summary = "导入Excel") + public CommonResult> importExcel(@RequestParam("file") MultipartFile file) { + return CommonResult.success(exchangeCodeService.importExcel(file)); + } + + @GetMapping("/exportExcel") + @Operation(summary = "导出Excel") + public void exportExcel(ExchangeCodeQueryDto queryDto, HttpServletResponse response) throws Exception { + exchangeCodeService.exportExcel(queryDto, response); + } + + @GetMapping("/statistics") + @Operation(summary = "统计各状态数量") + public CommonResult> getStatistics() { + return CommonResult.success(exchangeCodeService.getStatistics()); + } + + @GetMapping("/countByPrice") + @Operation(summary = "根据套餐价格查询可用兑换码数量") + public CommonResult countAvailableByPrice(@Parameter(description = "套餐价格") @RequestParam BigDecimal packagePrice) { + return CommonResult.success(exchangeCodeService.countAvailableByPrice(packagePrice)); + } +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/entity/ExchangeCode.java b/src/main/java/com/kexue/skills/entity/ExchangeCode.java new file mode 100644 index 0000000..fbf7654 --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/ExchangeCode.java @@ -0,0 +1,70 @@ +package com.kexue.skills.entity; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +import com.kexue.skills.annotation.Excel; +import com.kexue.skills.entity.base.BaseEntity; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * (ExchangeCode)实体类 + * 兑换码表 + * + * @author 系统生成 + * @since 2026-05-14 + */ +@Data +public class ExchangeCode extends BaseEntity implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description = "主键ID") + private Long id; + + @Excel(label = "兑换码", sort = 1) + @Schema(description = "兑换码") + private String code; + + @Excel(label = "套餐价格", sort = 2) + @Schema(description = "套餐价格(对应package_config中的price)") + private BigDecimal packagePrice; + + @Excel(label = "套餐ID", sort = 3) + @Schema(description = "套餐ID") + private Long packageId; + + @Excel(label = "状态", sort = 4) + @Schema(description = "状态:0-未使用,1-已使用,2-已过期") + private Integer status; + + @Excel(label = "使用用户ID", sort = 5) + @Schema(description = "使用用户ID") + private Long usedUserId; + + @Excel(label = "使用时间", sort = 6) + @Schema(description = "使用时间") + private Date usedTime; + + @Excel(label = "过期时间", sort = 7) + @Schema(description = "过期时间") + private Date expireTime; + + @Excel(label = "创建时间", sort = 8) + @Schema(description = "创建时间") + private Date createTime; + + @Excel(label = "更新时间", sort = 9) + @Schema(description = "更新时间") + private Date updateTime; + + @Schema(description = "创建人") + private String createBy; + + @Schema(description = "更新人") + private String updateBy; + + @Schema(description = "是否删除:0-未删除,1-已删除") + private Integer deleteFlag; +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/entity/base/BaseQueryDto.java b/src/main/java/com/kexue/skills/entity/base/BaseQueryDto.java index cb93078..beb65d0 100644 --- a/src/main/java/com/kexue/skills/entity/base/BaseQueryDto.java +++ b/src/main/java/com/kexue/skills/entity/base/BaseQueryDto.java @@ -1,28 +1,26 @@ package com.kexue.skills.entity.base; -import io.swagger.annotations.ApiModel; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import java.io.Serializable; - +/** + * 基础查询DTO + */ @Data -@ApiModel -public class BaseQueryDto implements Serializable { +@Schema(description = "基础查询参数") +public class BaseQueryDto { - public static final Integer DEFAULT_PAGE_SIZE = 10; + public static final Integer DEFAULT_PAGE_SIZE = 12; public static final Integer DEFAULT_CURRENT_PAGE = 1; + @Schema(description = "页码,默认1") + private Integer pageNum = 1; - @Schema(description ="当前页") - private Integer pageNum; + @Schema(description = "每页数量,默认20") + private Integer pageSize = 20; - @Schema(description ="每页数量") - private Integer pageSize; - - @Schema(description ="排序字段") + @Schema(description = "排序字段") private String sortBy; - @Schema(description ="是否降序排序") - private Boolean sortDesc; - -} + @Schema(description = "是否降序") + private Boolean sortDesc = true; +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/entity/dto/ExchangeCodeQueryDto.java b/src/main/java/com/kexue/skills/entity/dto/ExchangeCodeQueryDto.java new file mode 100644 index 0000000..bf8fc0d --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/dto/ExchangeCodeQueryDto.java @@ -0,0 +1,24 @@ +package com.kexue.skills.entity.dto; + +import com.kexue.skills.entity.base.BaseQueryDto; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 兑换码查询DTO + */ +@Data +@Schema(description = "兑换码查询参数") +public class ExchangeCodeQueryDto extends BaseQueryDto { + + @Schema(description = "兑换码") + private String code; + + @Schema(description = "套餐价格") + private BigDecimal packagePrice; + + @Schema(description = "状态:0-未使用,1-已使用,2-已过期") + private Integer status; +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/entity/dto/ExchangeRequestDto.java b/src/main/java/com/kexue/skills/entity/dto/ExchangeRequestDto.java new file mode 100644 index 0000000..fd653b6 --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/dto/ExchangeRequestDto.java @@ -0,0 +1,18 @@ +package com.kexue.skills.entity.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 兑换请求DTO + */ +@Data +@Schema(description = "兑换请求参数") +public class ExchangeRequestDto { + + @Schema(description = "兑换码", required = true) + private String code; + + @Schema(description = "用户ID", required = true) + private Long userId; +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/exception/GlobalExceptionHandler.java b/src/main/java/com/kexue/skills/exception/GlobalExceptionHandler.java index 0d150ff..bbdaee0 100644 --- a/src/main/java/com/kexue/skills/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/kexue/skills/exception/GlobalExceptionHandler.java @@ -17,6 +17,7 @@ public class GlobalExceptionHandler { if (e == null) { return CommonResult.success("未知错误"); } + e.printStackTrace(); return CommonResult.success(e.getErrorCode(), e.getMessage()); } diff --git a/src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java b/src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java new file mode 100644 index 0000000..bd31191 --- /dev/null +++ b/src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java @@ -0,0 +1,115 @@ +package com.kexue.skills.mapper; + +import com.kexue.skills.entity.ExchangeCode; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.math.BigDecimal; +import java.util.List; + +/** + * (ExchangeCode)表数据库访问层 + * + * @author 系统生成 + * @since 2026-05-14 + */ +@Mapper +public interface ExchangeCodeMapper { + + /** + * 通过主键查询单条数据 + * + * @param id 主键 + * @return 实例对象 + */ + ExchangeCode queryById(Long id); + + /** + * 通过兑换码查询数据 + * + * @param code 兑换码 + * @return 实例对象 + */ + ExchangeCode queryByCode(String code); + + /** + * 分页查询 + * + * @param exchangeCode 查询条件 + * @return 结果列表 + */ + List getPageList(ExchangeCode exchangeCode); + + /** + * 查询列表 + * + * @param exchangeCode 查询条件 + * @return 结果列表 + */ + List getList(ExchangeCode exchangeCode); + + /** + * 新增数据 + * + * @param exchangeCode 实例对象 + * @return 影响行数 + */ + int insert(ExchangeCode exchangeCode); + + /** + * 批量新增数据 + * + * @param list 实例对象列表 + * @return 影响行数 + */ + int batchInsert(@Param("list") List list); + + /** + * 更新数据 + * + * @param exchangeCode 实例对象 + * @return 影响行数 + */ + int update(ExchangeCode exchangeCode); + + /** + * 使用兑换码 + * + * @param id 主键 + * @param usedUserId 使用用户ID + * @return 影响行数 + */ + int useCode(@Param("id") Long id, @Param("usedUserId") Long usedUserId); + + /** + * 通过主键删除 + * + * @param id 主键 + * @return 影响行数 + */ + int deleteById(Long id); + + /** + * 逻辑删除 + * + * @param id 主键 + * @param updateBy 更新人 + * @return 影响行数 + */ + int logicDeleteById(@Param("id") Long id, @Param("updateBy") String updateBy); + + /** + * 统计各状态数量 + * + * @return 统计结果 + */ + List countByStatus(); + + /** + * 根据套餐价格查询可用兑换码数量 + * + * @param packagePrice 套餐价格 + * @return 数量 + */ + int countAvailableByPrice(BigDecimal packagePrice); +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/ExchangeCodeService.java b/src/main/java/com/kexue/skills/service/ExchangeCodeService.java new file mode 100644 index 0000000..6570937 --- /dev/null +++ b/src/main/java/com/kexue/skills/service/ExchangeCodeService.java @@ -0,0 +1,44 @@ +package com.kexue.skills.service; + +import com.github.pagehelper.PageInfo; +import com.kexue.skills.entity.ExchangeCode; +import com.kexue.skills.entity.dto.ExchangeCodeQueryDto; +import com.kexue.skills.entity.dto.ExchangeRequestDto; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.servlet.http.HttpServletResponse; +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * (ExchangeCode)表服务接口 + */ +public interface ExchangeCodeService { + + PageInfo getPageList(ExchangeCodeQueryDto queryDto); + + List getList(ExchangeCodeQueryDto queryDto); + + ExchangeCode queryById(Long id); + + ExchangeCode queryByCode(String code); + + ExchangeCode insert(ExchangeCode exchangeCode); + + ExchangeCode update(ExchangeCode exchangeCode); + + int deleteById(Long id); + + int logicDeleteById(Long id, String updateBy); + + Map exchange(ExchangeRequestDto requestDto); + + Map importExcel(MultipartFile file); + + void exportExcel(ExchangeCodeQueryDto queryDto, HttpServletResponse response) throws Exception; + + Map getStatistics(); + + int countAvailableByPrice(BigDecimal packagePrice); +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java index 61f06ca..3aa91a8 100644 --- a/src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java @@ -29,6 +29,8 @@ import java.util.Date; import java.util.List; import java.util.Objects; +import static cn.dev33.satoken.SaManager.log; + /** * 账户冻结单服务实现 * @@ -72,6 +74,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { * @return 冻结单信息 */ @Override + @Transactional(rollbackFor = Exception.class) public AccountFrozen createFrozen(AccountFrozenDto accountFrozenDto) { // 1. 验证创建冻结单参数 validateCreateFrozenParams(accountFrozenDto); @@ -189,24 +192,67 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { * @return 冻结金额(单位:积分) */ private BigDecimal calculateTokenFrozenAmount(AccountFrozenDto accountFrozenDto) { + LOG.debug("开始计算token类型冻结金额 - sessionId: {}, modelName: {}, estimatedInputTokens: {}, estimatedOutputTokens: {}", + accountFrozenDto.getSessionId(), accountFrozenDto.getModelName(), + accountFrozenDto.getEstimatedInputTokens(), accountFrozenDto.getEstimatedOutputTokens()); + + // 1. 验证必要参数 if (accountFrozenDto.getEstimatedInputTokens() == null || accountFrozenDto.getEstimatedOutputTokens() == null || accountFrozenDto.getModelName() == null) { + LOG.warn("token冻结金额计算缺少必要参数,返回原始冻结金额 - sessionId: {}, frozenAmount: {}", + accountFrozenDto.getSessionId(), accountFrozenDto.getFrozenAmount()); return accountFrozenDto.getFrozenAmount(); } + + // 2. 标准化模型名称 String modelName = normalizeModelName(accountFrozenDto.getModelName()); + LOG.debug("标准化模型名称 - 原始名称: {}, 标准化后: {}", accountFrozenDto.getModelName(), modelName); + + // 3. 获取模型价格列表 List modelPriceList = getModelPriceList(modelName); + LOG.debug("获取模型价格列表 - modelName: {}, 价格规则数量: {}", modelName, modelPriceList.size()); + if (modelPriceList.isEmpty()) { + LOG.warn("模型价格列表为空,请检查模型价格表: {}", modelName); return accountFrozenDto.getFrozenAmount(); } + + // 4. 查找输入模型价格 ModelPrice inputModelPrice = findInputModelPrice(modelPriceList, accountFrozenDto.getEstimatedInputTokens()); + LOG.debug("查找输入模型价格 - estimatedInputTokens: {}, 匹配结果: {}", + accountFrozenDto.getEstimatedInputTokens(), + inputModelPrice != null ? "inputPerCent=" + inputModelPrice.getInputPerCent() : "未找到匹配"); + + // 5. 查找输出模型价格 ModelPrice outputModelPrice = findOutputModelPrice(modelPriceList, accountFrozenDto.getEstimatedOutputTokens()); + LOG.debug("查找输出模型价格 - estimatedOutputTokens: {}, 匹配结果: {}", + accountFrozenDto.getEstimatedOutputTokens(), + outputModelPrice != null ? "outputPerCent=" + outputModelPrice.getOutputPerCent() : "未找到匹配"); + if (inputModelPrice == null || outputModelPrice == null) { + LOG.warn("未找到匹配的模型价格规则 - inputModelPrice: {}, outputModelPrice: {}", + inputModelPrice != null ? "已找到" : "未找到", + outputModelPrice != null ? "已找到" : "未找到"); return accountFrozenDto.getFrozenAmount(); } + + // 6. 计算总的token费用 long totalFee = calculateTotalTokenFee(accountFrozenDto, inputModelPrice, outputModelPrice); + LOG.debug("计算token总费用 - inputFee: {}, outputFee: {}, totalFee: {}", + calculateTokenFee(accountFrozenDto.getEstimatedInputTokens(), inputModelPrice.getInputPerCent()), + calculateTokenFee(accountFrozenDto.getEstimatedOutputTokens(), outputModelPrice.getOutputPerCent()), + totalFee); + + // 7. 应用扣费系数 BigDecimal baseAmount = BigDecimal.valueOf(totalFee); - return baseAmount.multiply(accountDeductionProperties.getCoefficient()); + BigDecimal coefficient = accountDeductionProperties.getCoefficient(); + BigDecimal finalAmount = baseAmount.multiply(coefficient); + + LOG.info("token冻结金额计算完成 - sessionId: {}, 基础金额: {}分, 扣费系数: {}, 最终冻结金额: {}积分", + accountFrozenDto.getSessionId(), totalFee, coefficient, finalAmount); + + return finalAmount; } /** @@ -380,6 +426,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { * @return 冻结单信息 */ @Override + @Transactional(rollbackFor = Exception.class) public AccountFrozen releaseFrozen(AccountReleaseDto accountReleaseDto) { // 1. 验证释放冻结单参数 validateReleaseParams(accountReleaseDto); @@ -490,7 +537,8 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { accountFrozen.getModelName() == null) { return finalAmount; } - List modelPriceList = getModelPriceList(accountFrozen.getModelName()); + String modelName = normalizeModelName(accountFrozen.getModelName()); + List modelPriceList = getModelPriceList(modelName); if (modelPriceList.isEmpty()) { return finalAmount; } @@ -564,9 +612,6 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { * @param finalAmount 最终扣减金额 */ private void doCreateTransaction(AccountFrozen accountFrozen, Account account, BigDecimal finalAmount) { - if (finalAmount.compareTo(BigDecimal.ZERO) <= 0) { - return; - } BigDecimal beforeBalance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance(); AccountTransaction transaction = buildTransaction(accountFrozen, account, finalAmount, beforeBalance); accountTransactionMapper.insert(transaction); @@ -589,7 +634,11 @@ public class AccountFrozenServiceImpl implements AccountFrozenService { transaction.setTransactionType(3); transaction.setAmount(finalAmount); transaction.setBeforeBalance(beforeBalance); - transaction.setAfterBalance(account.getBalance()); + BigDecimal afterBalance = beforeBalance.subtract(finalAmount); + if (afterBalance.compareTo(BigDecimal.ZERO) < 0) { + afterBalance = BigDecimal.ZERO; + } + transaction.setAfterBalance(afterBalance); transaction.setStatus(1); transaction.setTransactionNo(IDUtils.getSnowflakeIdStr()); transaction.setPayType(3); diff --git a/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java index 24a9e83..1cf53c5 100644 --- a/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java @@ -1,25 +1,23 @@ package com.kexue.skills.service.impl; +import cn.dev33.satoken.stp.StpUtil; +import cn.hutool.poi.excel.ExcelReader; +import cn.hutool.poi.excel.ExcelUtil; import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; -import com.github.pagehelper.util.StringUtil; import com.kexue.skills.common.Assert; import com.kexue.skills.common.LoginUserCacheUtil; import com.kexue.skills.entity.CmsContent; -import com.kexue.skills.entity.CmsContentView; import com.kexue.skills.entity.CmsContentLike; +import com.kexue.skills.entity.CmsContentView; import com.kexue.skills.entity.base.BaseQueryDto; import com.kexue.skills.entity.dto.CmsContentDto; import com.kexue.skills.entity.request.ImportPathDto; +import com.kexue.skills.mapper.CmsContentLikeMapper; import com.kexue.skills.mapper.CmsContentMapper; import com.kexue.skills.mapper.CmsContentViewMapper; -import com.kexue.skills.mapper.CmsContentLikeMapper; import com.kexue.skills.mapper.CmsTagMapper; -import com.kexue.skills.entity.CmsTag; -import com.kexue.skills.entity.dto.CmsTagDto; import com.kexue.skills.service.CmsContentService; -import cn.hutool.poi.excel.ExcelReader; -import cn.hutool.poi.excel.ExcelUtil; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,12 +28,9 @@ import java.io.FileInputStream; import java.io.InputStream; import java.math.BigDecimal; import java.util.*; -import java.util.stream.Collectors; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; -import cn.dev33.satoken.stp.StpUtil; - /** * (CmsContent)表服务实现类 * @@ -47,16 +42,16 @@ import cn.dev33.satoken.stp.StpUtil; public class CmsContentServiceImpl implements CmsContentService { @Resource private CmsContentMapper cmsContentMapper; - + @Resource private CmsContentViewMapper cmsContentViewMapper; - + @Resource private CmsContentLikeMapper cmsContentLikeMapper; - + @Resource private CmsTagMapper cmsTagMapper; - + @Resource private LoginUserCacheUtil loginUserCacheUtil; @@ -150,7 +145,7 @@ public class CmsContentServiceImpl implements CmsContentService { if (content == null) { return null; } - + try { // 尝试获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); @@ -181,7 +176,7 @@ public class CmsContentServiceImpl implements CmsContentService { } catch (Exception e) { // 未登录用户,不记录查看历史 } - + return content; } @@ -198,13 +193,13 @@ public class CmsContentServiceImpl implements CmsContentService { if (content == null) { return null; } - + // 如果是付费内容,检查用户是否购买过 if (content.getIsPaid() == 1) { // 这里需要调用ContentPurchaseService检查权限,但为了避免循环依赖,我们返回完整内容 // 权限检查将在Controller层进行 } - + return content; } @@ -311,7 +306,7 @@ public class CmsContentServiceImpl implements CmsContentService { Assert.notNull(contentId, "内容ID不能为空"); // 增加阅读量 int result = this.cmsContentMapper.increaseViewCount(contentId); - + try { // 尝试获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); @@ -320,7 +315,7 @@ public class CmsContentServiceImpl implements CmsContentService { } catch (Exception e) { // 未登录用户,不记录查看历史 } - + return result; } @@ -362,7 +357,7 @@ public class CmsContentServiceImpl implements CmsContentService { Long userId = Long.parseLong(StpUtil.getLoginId().toString()); // 获取当前登录用户名 String userName = StpUtil.getLoginIdAsString(); - + // 检查内容是否存在 CmsContent content = this.cmsContentMapper.queryById(contentId); if (content == null) { @@ -371,15 +366,15 @@ public class CmsContentServiceImpl implements CmsContentService { if(Objects.isNull(content.getLikeCount())){ content.setLikeCount(0); } - + // 检查用户是否已经收藏过该内容 CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId); int result = 0; - + if (existingLike != null) { // 已经收藏过,执行取消收藏操作 result = cmsContentLikeMapper.deleteById(existingLike.getLikeId()); - + // 减少内容的点赞数 if (content != null && content.getLikeCount() > 0) { content.setLikeCount(content.getLikeCount() - 1); @@ -394,14 +389,14 @@ public class CmsContentServiceImpl implements CmsContentService { likeRecord.setContentTitle(content.getTitle()); likeRecord.setLikeTime(new Date()); likeRecord.setDeleteFlag(0); - + // 增加内容的点赞数 content.setLikeCount(content.getLikeCount() + 1); this.cmsContentMapper.update(content); - + result = cmsContentLikeMapper.insert(likeRecord); } - + // 更新Redis中的LoginUser对象 if (result > 0) { String token = loginUserCacheUtil.getTokenFromRequest(); @@ -409,7 +404,7 @@ public class CmsContentServiceImpl implements CmsContentService { loginUserCacheUtil.updateFavorites(token, userId); } } - + return result; } @@ -424,23 +419,23 @@ public class CmsContentServiceImpl implements CmsContentService { Assert.notNull(contentId, "内容ID不能为空"); // 获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); - + // 检查用户是否已经收藏过该内容 CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId); if (existingLike == null) { return 0; // 没有收藏过,无需取消 } - + // 删除收藏记录 int result = cmsContentLikeMapper.deleteById(existingLike.getLikeId()); - + // 减少内容的点赞数 CmsContent content = this.cmsContentMapper.queryById(contentId); if (content != null && content.getLikeCount() > 0) { content.setLikeCount(content.getLikeCount() - 1); this.cmsContentMapper.update(content); } - + // 更新Redis中的LoginUser对象 if (result > 0) { String token = loginUserCacheUtil.getTokenFromRequest(); @@ -448,7 +443,7 @@ public class CmsContentServiceImpl implements CmsContentService { loginUserCacheUtil.updateFavorites(token, userId); } } - + return result; } @@ -486,13 +481,13 @@ public class CmsContentServiceImpl implements CmsContentService { Long userId = Long.parseLong(StpUtil.getLoginId().toString()); // 获取当前登录用户名 String userName = StpUtil.getLoginIdAsString(); - + // 检查内容是否存在 CmsContent content = this.cmsContentMapper.queryById(contentId); if (content == null) { return 0; } - + // 检查用户是否已经查看过该内容(5分钟内) CmsContentView existingView = cmsContentViewMapper.queryByUserIdAndContentId(userId, contentId); int result = 0; @@ -519,7 +514,7 @@ public class CmsContentServiceImpl implements CmsContentService { } // 5分钟内已查看过,不重复记录 } - + // 更新Redis中的LoginUser对象 if (result > 0) { String token = loginUserCacheUtil.getTokenFromRequest(); @@ -527,7 +522,7 @@ public class CmsContentServiceImpl implements CmsContentService { loginUserCacheUtil.updateHistory(token, userId); } } - + return result; } catch (Exception e) { // 未登录用户,不记录查看历史 @@ -539,7 +534,7 @@ public class CmsContentServiceImpl implements CmsContentService { public PageInfo getPageListByUserHistory(CmsContentDto queryDto) { // 获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); - + // 添加参数校验,确保分页参数有效 if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); @@ -547,22 +542,22 @@ public class CmsContentServiceImpl implements CmsContentService { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); } - + // 计算偏移量 int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int limit = queryDto.getPageSize(); - + // 查询数据 List list = this.cmsContentMapper.getPageListByUserHistory(userId, offset, limit); int total = this.cmsContentMapper.getPageListByUserHistoryCount(userId); - + // 构建分页结果 PageInfo pageInfo = new PageInfo<>(list); pageInfo.setTotal(total); pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPages((total + limit - 1) / limit); - + return pageInfo; } @@ -570,7 +565,7 @@ public class CmsContentServiceImpl implements CmsContentService { public PageInfo getPageListByUserFavorites(CmsContentDto queryDto) { // 获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); - + // 添加参数校验,确保分页参数有效 if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); @@ -578,22 +573,22 @@ public class CmsContentServiceImpl implements CmsContentService { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); } - + // 计算偏移量 int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int limit = queryDto.getPageSize(); - + // 查询数据 List list = this.cmsContentMapper.getPageListByUserFavorites(userId, offset, limit); int total = this.cmsContentMapper.getPageListByUserFavoritesCount(userId); - + // 构建分页结果 PageInfo pageInfo = new PageInfo<>(list); pageInfo.setTotal(total); pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPages((total + limit - 1) / limit); - + return pageInfo; } @@ -601,7 +596,7 @@ public class CmsContentServiceImpl implements CmsContentService { public PageInfo getPageListByUserPurchases(CmsContentDto queryDto) { // 获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); - + // 添加参数校验,确保分页参数有效 if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); @@ -609,22 +604,22 @@ public class CmsContentServiceImpl implements CmsContentService { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); } - + // 计算偏移量 int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int limit = queryDto.getPageSize(); - + // 查询数据 List list = this.cmsContentMapper.getPageListByUserPurchases(userId, offset, limit); int total = this.cmsContentMapper.getPageListByUserPurchasesCount(userId); - + // 构建分页结果 PageInfo pageInfo = new PageInfo<>(list); pageInfo.setTotal(total); pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPages((total + limit - 1) / limit); - + return pageInfo; } @@ -632,7 +627,7 @@ public class CmsContentServiceImpl implements CmsContentService { public PageInfo getPageListByUserCreated(CmsContentDto queryDto) { // 获取当前登录用户ID Long userId = Long.parseLong(StpUtil.getLoginId().toString()); - + // 添加参数校验,确保分页参数有效 if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); @@ -640,22 +635,22 @@ public class CmsContentServiceImpl implements CmsContentService { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); } - + // 计算偏移量 int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int limit = queryDto.getPageSize(); - + // 查询数据 List list = this.cmsContentMapper.getPageListByUserCreated(userId, queryDto.getPublishStatus(), offset, limit); int total = this.cmsContentMapper.getPageListByUserCreatedCount(userId, queryDto.getPublishStatus()); - + // 构建分页结果 PageInfo pageInfo = new PageInfo<>(list); pageInfo.setTotal(total); pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPages((total + limit - 1) / limit); - + return pageInfo; } @@ -663,20 +658,20 @@ public class CmsContentServiceImpl implements CmsContentService { public int importFromExcel(byte[] fileBytes, String createBy) { int successCount = 0; final int BATCH_SIZE = 10; // 批量处理大小 - + try (InputStream inputStream = new ByteArrayInputStream(fileBytes); ExcelReader reader = ExcelUtil.getReader(inputStream)) { - + // 获取总行数(包括标题行) int totalRows = reader.getRowCount(); if (totalRows <= 1) { // 只有标题行或空文件 return 0; } - + Date now = new Date(); List batchList = new ArrayList<>(BATCH_SIZE); - + // 读取标题行 List headerObjList = reader.readRow(0); if (headerObjList == null || headerObjList.isEmpty()) { @@ -687,7 +682,7 @@ public class CmsContentServiceImpl implements CmsContentService { for (Object obj : headerObjList) { headerList.add(obj != null ? obj.toString() : ""); } - + // 从第二行开始读取数据(第一行为标题行) for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) { try { @@ -695,7 +690,7 @@ public class CmsContentServiceImpl implements CmsContentService { if (rowList == null || rowList.isEmpty()) { continue; } - + // 转换为Map Map row = new HashMap<>(); @@ -712,13 +707,13 @@ public class CmsContentServiceImpl implements CmsContentService { System.out.println("映射后的数据:" + row); CmsContent cmsContent = new CmsContent(); - + // 设置创建时间和更新时间 cmsContent.setCreateTime(now); cmsContent.setUpdateTime(now); cmsContent.setCreateBy(createBy); cmsContent.setUpdateBy(createBy); - + // 设置默认值 cmsContent.setDeleteFlag(0); cmsContent.setAuditStatus(1); // 默认草稿状态 @@ -728,13 +723,13 @@ public class CmsContentServiceImpl implements CmsContentService { cmsContent.setCommentCount(0); cmsContent.setSort(0); cmsContent.setIsOfficial(true); // 固定设置为1 - + // 读取Excel中的字段 // content_id - 数据库自动生成,不需要读取 cmsContent.setTitle(getStringValue(row, "title")); cmsContent.setTitleEn(getStringValue(row, "title_en")); cmsContent.setOrigin(getStringValue(row, "origin")); - + // 处理tags字段,避免NullPointerException String tags = getStringValue(row, "tags"); if (tags == null) { @@ -742,7 +737,7 @@ public class CmsContentServiceImpl implements CmsContentService { continue; } cmsContent.setTags(tags.replaceAll(" ","")); - + cmsContent.setIcon(getStringValue(row, "icon")); cmsContent.setIsOfficial(true); // 固定设置为1 cmsContent.setPrice(getBigDecimalValue(row, "price")); @@ -765,9 +760,9 @@ public class CmsContentServiceImpl implements CmsContentService { // create_time - 由系统生成,不需要读取 // update_time - 由系统生成,不需要读取 cmsContent.setDeleteFlag(getIntegerValue(row, "delete_flag")); - + batchList.add(cmsContent); - + // 达到批量大小或最后一行时,执行批量插入 if (batchList.size() >= BATCH_SIZE) { if (!batchList.isEmpty()) { @@ -784,7 +779,7 @@ public class CmsContentServiceImpl implements CmsContentService { continue; } } - + // 处理最后一批不足BATCH_SIZE的数据 if (!batchList.isEmpty()) { this.cmsContentMapper.batchInsert(batchList); @@ -794,10 +789,10 @@ public class CmsContentServiceImpl implements CmsContentService { } catch (Exception e) { e.printStackTrace(); } - + return successCount; } - + /** * 从Map中获取字符串值 */ @@ -805,7 +800,7 @@ public class CmsContentServiceImpl implements CmsContentService { Object value = row.get(key); return value != null ? value.toString() : null; } - + /** * 从Map中获取布尔值 */ @@ -822,7 +817,7 @@ public class CmsContentServiceImpl implements CmsContentService { } return null; } - + /** * 从Map中获取整数值 */ @@ -843,7 +838,7 @@ public class CmsContentServiceImpl implements CmsContentService { } return null; } - + /** * 从Map中获取BigDecimal值 */ @@ -864,7 +859,7 @@ public class CmsContentServiceImpl implements CmsContentService { } return null; } - + /** * 从Map中获取日期值 */ @@ -878,7 +873,7 @@ public class CmsContentServiceImpl implements CmsContentService { } return null; } - + @Override public String getContent(Long contentId, Integer languageType) { Assert.notNull(contentId, "内容ID不能为空"); @@ -886,7 +881,7 @@ public class CmsContentServiceImpl implements CmsContentService { if (cmsContent == null) { return null; } - + // 当languageType为1时返回contentEn,否则返回content if (languageType != null && languageType == 1) { return cmsContent.getContentEn(); @@ -894,7 +889,7 @@ public class CmsContentServiceImpl implements CmsContentService { return cmsContent.getContent(); } } - + @Override public String getTitle(Long contentId) { Assert.notNull(contentId, "内容ID不能为空"); @@ -904,11 +899,11 @@ public class CmsContentServiceImpl implements CmsContentService { } return cmsContent.getTitle(); } - + @Override public int importFromPath(ImportPathDto importPathDto, String createBy) { int totalSuccessCount = 0; - + try { // 检查目录是否存在 File directory = new File(importPathDto.getFilePath()); @@ -916,12 +911,12 @@ public class CmsContentServiceImpl implements CmsContentService { System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath()); return 0; } - + // 如果不是追加模式,清空表 if (!importPathDto.isAppend()) { cmsContentMapper.truncateTable(); } - + // 读取目录下所有 Excel 文件(排除 Office 临时锁定文件) File[] files = directory.listFiles((dir, name) -> { // 跳过以 ~$ 开头的临时锁定文件 @@ -933,11 +928,11 @@ public class CmsContentServiceImpl implements CmsContentService { if (files == null || files.length == 0) { return 0; } - + // 记录文件总数 int totalFiles = files.length; System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要导入"); - + // 遍历所有 Excel 文件并导入 for (int i = 0; i < files.length; i++) { File file = files[i]; @@ -946,7 +941,7 @@ public class CmsContentServiceImpl implements CmsContentService { // 读取文件内容到字节数组 byte[] fileBytes = new byte[(int) file.length()]; fis.read(fileBytes); - + // 调用现有的 importFromExcel 方法进行导入 int successCount = importFromExcel(fileBytes, createBy); totalSuccessCount += successCount; @@ -963,7 +958,7 @@ public class CmsContentServiceImpl implements CmsContentService { System.err.println("导入操作失败: " + importPathDto.getFilePath()); e.printStackTrace(); } - + return totalSuccessCount; } @@ -971,34 +966,34 @@ public class CmsContentServiceImpl implements CmsContentService { public int importFromZip(byte[] zipFileBytes, String createBy) { int totalSuccessCount = 0; int totalFiles = 0; - + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipFileBytes))) { ZipEntry entry; - + // 遍历ZIP文件中的所有条目 while ((entry = zipInputStream.getNextEntry()) != null) { String fileName = entry.getName(); - + // 跳过目录和非Excel文件 if (entry.isDirectory() || (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx"))) { zipInputStream.closeEntry(); continue; } - + // 跳过Office临时锁定文件 String simpleName = new File(fileName).getName(); if (simpleName.startsWith("~$")) { zipInputStream.closeEntry(); continue; } - + totalFiles++; System.out.println("当前处理第 " + totalFiles + " 个文件,文件名称是:" + fileName); - + try { // 读取Excel文件内容到字节数组 byte[] fileBytes = zipInputStream.readAllBytes(); - + // 调用现有的 importFromExcel 方法进行导入 int successCount = importFromExcel(fileBytes, createBy); totalSuccessCount += successCount; @@ -1011,20 +1006,20 @@ public class CmsContentServiceImpl implements CmsContentService { zipInputStream.closeEntry(); } } - + System.out.println("导入完成,共处理 " + totalFiles + " 个文件,成功导入 " + totalSuccessCount + " 条记录"); } catch (Exception e) { System.err.println("ZIP文件导入操作失败"); e.printStackTrace(); } - + return totalSuccessCount; } @Override public int updateFromPath(ImportPathDto importPathDto, String updateBy) { int totalSuccessCount = 0; - + try { // 检查目录是否存在 File directory = new File(importPathDto.getFilePath()); @@ -1032,7 +1027,7 @@ public class CmsContentServiceImpl implements CmsContentService { System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath()); return 0; } - + // 读取目录下所有 Excel 文件(排除 Office 临时锁定文件) File[] files = directory.listFiles((dir, name) -> { // 跳过以 ~$ 开头的临时锁定文件 @@ -1044,11 +1039,11 @@ public class CmsContentServiceImpl implements CmsContentService { if (files == null || files.length == 0) { return 0; } - + // 记录文件总数 int totalFiles = files.length; System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要处理"); - + // 遍历所有 Excel 文件并处理 for (int i = 0; i < files.length; i++) { File file = files[i]; @@ -1057,7 +1052,7 @@ public class CmsContentServiceImpl implements CmsContentService { // 读取文件内容到字节数组 byte[] fileBytes = new byte[(int) file.length()]; fis.read(fileBytes); - + // 调用 updateFromExcel 方法进行更新 int successCount = updateFromExcel(fileBytes, updateBy); totalSuccessCount += successCount; @@ -1074,10 +1069,10 @@ public class CmsContentServiceImpl implements CmsContentService { System.err.println("更新操作失败: " + importPathDto.getFilePath()); e.printStackTrace(); } - + return totalSuccessCount; } - + /** * 从Excel文件读取数据并更新CmsContent * @@ -1087,19 +1082,19 @@ public class CmsContentServiceImpl implements CmsContentService { */ private int updateFromExcel(byte[] fileBytes, String updateBy) { int successCount = 0; - + try (InputStream inputStream = new ByteArrayInputStream(fileBytes); ExcelReader reader = ExcelUtil.getReader(inputStream)) { - + // 获取总行数(包括标题行) int totalRows = reader.getRowCount(); if (totalRows <= 1) { // 只有标题行或空文件 return 0; } - + Date now = new Date(); - + // 读取标题行 List headerObjList = reader.readRow(0); if (headerObjList == null || headerObjList.isEmpty()) { @@ -1110,7 +1105,7 @@ public class CmsContentServiceImpl implements CmsContentService { for (Object obj : headerObjList) { headerList.add(obj != null ? obj.toString() : ""); } - + // 从第二行开始读取数据(第一行为标题行) for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) { try { @@ -1118,7 +1113,7 @@ public class CmsContentServiceImpl implements CmsContentService { if (rowList == null || rowList.isEmpty()) { continue; } - + // 转换为Map Map row = new HashMap<>(); for (int i = 0; i < headerList.size() && i < rowList.size(); i++) { @@ -1127,7 +1122,7 @@ public class CmsContentServiceImpl implements CmsContentService { if (row.isEmpty()) { continue; } - + // 读取关键字段用于查询 String title = getStringValue(row, "title"); String origin = getStringValue(row, "origin"); @@ -1137,7 +1132,7 @@ public class CmsContentServiceImpl implements CmsContentService { tags = tags.replaceAll(" ", ""); } String icon = getStringValue(row, "icon"); - + // 根据关键字段查询记录 CmsContentDto queryDto = new CmsContentDto(); queryDto.setTitle(title); @@ -1145,12 +1140,12 @@ public class CmsContentServiceImpl implements CmsContentService { // queryDto.setTags(tags); // queryDto.setIcon(icon); queryDto.setDeleteFlag(0); - + List existingList = cmsContentMapper.getList(queryDto); if (existingList != null && !existingList.isEmpty()) { // 找到匹配的记录,进行更新 CmsContent existingContent = existingList.get(0); - + // 更新字段 existingContent.setTitle(getStringValue(row, "title")); existingContent.setTitleEn(getStringValue(row, "title_en")); @@ -1172,11 +1167,11 @@ public class CmsContentServiceImpl implements CmsContentService { existingContent.setDescriptionEn(getStringValue(row, "description_en")); existingContent.setIntroduce(getStringValue(row, "introduce")); existingContent.setIntroduceEn(getStringValue(row, "introduce_en")); - + // 设置更新时间和更新人 existingContent.setUpdateTime(now); existingContent.setUpdateBy(updateBy); - + // 执行更新 cmsContentMapper.update(existingContent); successCount++; @@ -1191,9 +1186,9 @@ public class CmsContentServiceImpl implements CmsContentService { } catch (Exception e) { e.printStackTrace(); } - + return successCount; } - + } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/impl/ExchangeCodeServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/ExchangeCodeServiceImpl.java new file mode 100644 index 0000000..6b047d6 --- /dev/null +++ b/src/main/java/com/kexue/skills/service/impl/ExchangeCodeServiceImpl.java @@ -0,0 +1,380 @@ +package com.kexue.skills.service.impl; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.poi.excel.ExcelReader; +import cn.hutool.poi.excel.ExcelUtil; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import com.kexue.skills.common.util.ExportUtils; +import com.kexue.skills.entity.ExchangeCode; +import com.kexue.skills.entity.PackageConfig; +import com.kexue.skills.entity.dto.ExchangeCodeQueryDto; +import com.kexue.skills.entity.dto.ExchangeRequestDto; +import com.kexue.skills.mapper.ExchangeCodeMapper; +import com.kexue.skills.service.AccountService; +import com.kexue.skills.service.ExchangeCodeService; +import com.kexue.skills.service.PackageConfigService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletResponse; +import java.math.BigDecimal; +import java.util.*; + +/** + * (ExchangeCode)表服务实现类 + */ +@Service("exchangeCodeService") +@Transactional(rollbackFor = Exception.class) +public class ExchangeCodeServiceImpl implements ExchangeCodeService { + + private static final Logger log = LoggerFactory.getLogger(ExchangeCodeServiceImpl.class); + + @Resource + private ExchangeCodeMapper exchangeCodeMapper; + + @Resource + private PackageConfigService packageConfigService; + + @Resource + private AccountService accountService; + + private static final BigDecimal PRICE_99 = new BigDecimal("9.9"); + private static final BigDecimal PRICE_499 = new BigDecimal("49.9"); + private static final BigDecimal PRICE_2999 = new BigDecimal("299.9"); + + @Override + public PageInfo getPageList(ExchangeCodeQueryDto queryDto) { + int pageNum = queryDto.getPageNum() != null ? queryDto.getPageNum() : 1; + int pageSize = queryDto.getPageSize() != null ? queryDto.getPageSize() : 20; + PageHelper.startPage(pageNum, pageSize); + List list = exchangeCodeMapper.getPageList(buildQueryEntity(queryDto)); + return new PageInfo<>(list); + } + + @Override + public List getList(ExchangeCodeQueryDto queryDto) { + return exchangeCodeMapper.getList(buildQueryEntity(queryDto)); + } + + @Override + public ExchangeCode queryById(Long id) { + return exchangeCodeMapper.queryById(id); + } + + @Override + public ExchangeCode queryByCode(String code) { + return exchangeCodeMapper.queryByCode(code); + } + + @Override + public ExchangeCode insert(ExchangeCode exchangeCode) { + Date now = new Date(); + exchangeCode.setCreateTime(now); + exchangeCode.setUpdateTime(now); + exchangeCode.setDeleteFlag(0); + if (exchangeCode.getStatus() == null) { + exchangeCode.setStatus(0); + } + exchangeCodeMapper.insert(exchangeCode); + return exchangeCode; + } + + @Override + public ExchangeCode update(ExchangeCode exchangeCode) { + exchangeCode.setUpdateTime(new Date()); + exchangeCodeMapper.update(exchangeCode); + return queryById(exchangeCode.getId()); + } + + @Override + public int deleteById(Long id) { + return exchangeCodeMapper.deleteById(id); + } + + @Override + public int logicDeleteById(Long id, String updateBy) { + return exchangeCodeMapper.logicDeleteById(id, updateBy); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map exchange(ExchangeRequestDto requestDto) { + String code = requestDto.getCode(); + Long userId = requestDto.getUserId(); + + log.info("[兑换码兑换] 开始处理兑换请求, code={}, userId={}", code, userId); + + Map result = new HashMap<>(); + + ExchangeCode exchangeCode = exchangeCodeMapper.queryByCode(code); + if (exchangeCode == null) { + log.info("[兑换码兑换] 兑换码不存在, code={}", code); + result.put("success", false); + result.put("message", "无效的兑换码"); + return result; + } + + if (exchangeCode.getStatus() == 1) { + log.info("[兑换码兑换] 兑换码已被使用, code={}, usedUserId={}", code, exchangeCode.getUsedUserId()); + result.put("success", false); + result.put("message", "兑换码已被使用"); + return result; + } + + if (exchangeCode.getStatus() == 2) { + log.info("[兑换码兑换] 兑换码已过期, code={}", code); + result.put("success", false); + result.put("message", "兑换码已过期"); + return result; + } + + if (exchangeCode.getExpireTime() != null && exchangeCode.getExpireTime().before(new Date())) { + exchangeCode.setStatus(2); + exchangeCodeMapper.update(exchangeCode); + log.info("[兑换码兑换] 兑换码过期时间已到, code={}, expireTime={}", code, exchangeCode.getExpireTime()); + result.put("success", false); + result.put("message", "兑换码已过期"); + return result; + } + + PackageConfig packageConfig = findPackageByPrice(exchangeCode.getPackagePrice()); + if (packageConfig == null) { + log.info("[兑换码兑换] 未找到对应套餐, code={}, packagePrice={}", code, exchangeCode.getPackagePrice()); + result.put("success", false); + result.put("message", "未找到对应的套餐配置"); + return result; + } + + log.info("[兑换码兑换] 找到套餐, code={}, packageId={}, packageName={}, price={}", + code, packageConfig.getId(), packageConfig.getName(), packageConfig.getPrice()); + + String transactionNo = generateTransactionNo(); + int rechargeResult = accountService.addBalance( + userId, + packageConfig.getPrice(), + false, + transactionNo, + packageConfig.getId(), + "exchange_code", + "兑换码兑换:" + packageConfig.getName() + ); + + if (rechargeResult > 0) { + exchangeCodeMapper.useCode(exchangeCode.getId(), userId); + BigDecimal totalAmount = packageConfig.getBaseAmount().add(packageConfig.getGiftAmount()); + log.info("[兑换码兑换] 兑换成功, code={}, userId={}, packageId={}, packageName={}, baseAmount={}, giftAmount={}, totalAmount={}", + code, userId, packageConfig.getId(), packageConfig.getName(), + packageConfig.getBaseAmount(), packageConfig.getGiftAmount(), totalAmount); + result.put("success", true); + result.put("message", "兑换成功"); + result.put("packageName", packageConfig.getName()); + result.put("packagePrice", packageConfig.getPrice()); + result.put("baseAmount", packageConfig.getBaseAmount()); + result.put("giftAmount", packageConfig.getGiftAmount()); + result.put("totalAmount", totalAmount); + } else { + log.info("[兑换码兑换] 兑换失败, 充值未成功, code={}, userId={}", code, userId); + result.put("success", false); + result.put("message", "兑换失败,充值未成功"); + } + + return result; + } + + @Override + public Map importExcel(MultipartFile file) { + log.info("[兑换码导入] 开始导入Excel文件, fileName={}, size={}bytes", file.getOriginalFilename(), file.getSize()); + + Map result = new HashMap<>(); + int successCount = 0; + int failCount = 0; + List failMessages = new ArrayList<>(); + + try (ExcelReader reader = ExcelUtil.getReader(file.getInputStream())) { + List sheetNames = reader.getSheetNames(); + log.info("[兑换码导入] Excel文件包含sheet: {}", sheetNames); + + for (String sheetName : sheetNames) { + reader.setSheet(sheetName); + BigDecimal packagePrice = parsePriceFromSheetName(sheetName); + + if (packagePrice == null) { + log.info("[兑换码导入] 无法识别sheet名称, sheetName={}", sheetName); + failMessages.add("无法识别sheet名称: " + sheetName); + continue; + } + + PackageConfig packageConfig = findPackageByPrice(packagePrice); + if (packageConfig == null) { + log.info("[兑换码导入] 未找到对应价格的套餐, sheetName={}, packagePrice={}", sheetName, packagePrice); + failMessages.add("未找到对应价格的套餐: " + packagePrice); + continue; + } + + log.info("[兑换码导入] 处理sheet, sheetName={}, packagePrice={}, packageId={}, packageName={}", + sheetName, packagePrice, packageConfig.getId(), packageConfig.getName()); + + List> rows = reader.read(); + if (rows == null || rows.isEmpty()) { + log.info("[兑换码导入] sheet为空, sheetName={}", sheetName); + continue; + } + + List codeList = new ArrayList<>(); + Date now = new Date(); + + for (int i = 0; i < rows.size(); i++) { + List row = rows.get(i); + if (row == null || row.isEmpty()) { + continue; + } + + if (row.size() < 2) { + continue; + } + + Object indexObj = row.get(0); + Object codeObj = row.get(1); + + if (indexObj == null && codeObj == null) { + continue; + } + + if (codeObj == null) { + continue; + } + + String code = String.valueOf(codeObj).trim(); + if (code.isEmpty()) { + continue; + } + + ExchangeCode existCode = exchangeCodeMapper.queryByCode(code); + if (existCode != null) { + failCount++; + failMessages.add("兑换码已存在: " + code); + continue; + } + + ExchangeCode exchangeCode = new ExchangeCode(); + exchangeCode.setCode(code); + exchangeCode.setPackagePrice(packagePrice); + exchangeCode.setPackageId(packageConfig.getId()); + exchangeCode.setStatus(0); + exchangeCode.setCreateTime(now); + exchangeCode.setUpdateTime(now); + exchangeCode.setDeleteFlag(0); + codeList.add(exchangeCode); + } + + if (!codeList.isEmpty()) { + exchangeCodeMapper.batchInsert(codeList); + successCount += codeList.size(); + log.info("[兑换码导入] sheet处理完成, sheetName={}, 成功导入{}条", sheetName, codeList.size()); + } + } + } catch (Exception e) { + log.error("[兑换码导入] 导入Excel失败", e); + result.put("success", false); + result.put("message", "导入失败: " + e.getMessage()); + return result; + } + + log.info("[兑换码导入] 导入完成, 成功={}, 失败={}", successCount, failCount); + result.put("success", true); + result.put("successCount", successCount); + result.put("failCount", failCount); + result.put("failMessages", failMessages); + return result; + } + + @Override + public void exportExcel(ExchangeCodeQueryDto queryDto, HttpServletResponse response) throws Exception { + List list = exchangeCodeMapper.getList(buildQueryEntity(queryDto)); + String fileName = "exchange_codes_" + DateUtil.format(new Date(), "yyyyMMddHHmmss") + ".xlsx"; + ExportUtils.webExport(list, ExchangeCode.class, fileName, response); + } + + @Override + public Map getStatistics() { + Map statistics = new HashMap<>(); + statistics.put("total", 0); + statistics.put("unused", 0); + statistics.put("used", 0); + statistics.put("expired", 0); + + List list = exchangeCodeMapper.getList(new ExchangeCode()); + for (ExchangeCode code : list) { + statistics.put("total", statistics.get("total") + 1); + if (code.getStatus() == 0) { + statistics.put("unused", statistics.get("unused") + 1); + } else if (code.getStatus() == 1) { + statistics.put("used", statistics.get("used") + 1); + } else if (code.getStatus() == 2) { + statistics.put("expired", statistics.get("expired") + 1); + } + } + + return statistics; + } + + @Override + public int countAvailableByPrice(BigDecimal packagePrice) { + return exchangeCodeMapper.countAvailableByPrice(packagePrice); + } + + private ExchangeCode buildQueryEntity(ExchangeCodeQueryDto queryDto) { + if (queryDto == null) { + return new ExchangeCode(); + } + ExchangeCode entity = new ExchangeCode(); + entity.setCode(queryDto.getCode()); + entity.setPackagePrice(queryDto.getPackagePrice()); + entity.setStatus(queryDto.getStatus()); + return entity; + } + + private PackageConfig findPackageByPrice(BigDecimal price) { + if (price == null) { + return null; + } + List packages = packageConfigService.getList(null); + for (PackageConfig pkg : packages) { + System.out.println("pkg.price=" + pkg.getPrice()); + if (pkg.getPrice().compareTo(price) == 0) { + return pkg; + } + } + return null; + } + + private BigDecimal parsePriceFromSheetName(String sheetName) { + if (sheetName == null || sheetName.trim().isEmpty()) { + return null; + } + + String trimmed = sheetName.trim(); + if (trimmed.contains("299.9") || trimmed.equals("299.9")) { + return PRICE_2999; + } else if (trimmed.contains("49.9") || trimmed.equals("49.9")) { + return PRICE_499; + } else if (trimmed.contains("9.9") || trimmed.equals("9.9")) { + return PRICE_99; + } + + try { + return new BigDecimal(trimmed); + } catch (NumberFormatException e) { + return null; + } + } + + private String generateTransactionNo() { + return "EXC" + System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000)); + } +} \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java index 58ec230..51e4e7f 100644 --- a/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/SkillGenServiceImpl.java @@ -428,7 +428,7 @@ public class SkillGenServiceImpl implements SkillGenService { log.info("根据技能描述生成技能介绍请求: {}", description); String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; - String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个skill的描述,请你基于这个描述,生成一段详细的技能介绍,包括技能的作用、能够解决的问题、使用场景等,输出一段完整的描述文本"; + String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个skill的描述,请你基于这个描述,生成一段详细的技能介绍,包括技能的作用、能够解决的问题、使用场景等,输出一段完整的描述文本,请务必严格要找要求输出,绝对不允许输出与要求无关的内容"; SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, description, 0.3, 500, "text"); String deepseekResponse = ""; try { diff --git a/src/main/resources/mapper/AccountMapper.xml b/src/main/resources/mapper/AccountMapper.xml index 5e6377c..a6990e0 100644 --- a/src/main/resources/mapper/AccountMapper.xml +++ b/src/main/resources/mapper/AccountMapper.xml @@ -109,7 +109,7 @@ update_by = #{updateBy}, delete_flag = #{deleteFlag}, - where account_id = #{accountId} + where user_id = #{userId} diff --git a/src/main/resources/mapper/ExchangeCodeMapper.xml b/src/main/resources/mapper/ExchangeCodeMapper.xml new file mode 100644 index 0000000..3afe1fb --- /dev/null +++ b/src/main/resources/mapper/ExchangeCodeMapper.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + insert into exchange_code + + code, + package_price, + package_id, + status, + expire_time, + create_time, + update_time, + create_by, + update_by, + delete_flag, + + + #{code}, + #{packagePrice}, + #{packageId}, + #{status}, + #{expireTime}, + #{createTime}, + #{updateTime}, + #{createBy}, + #{updateBy}, + #{deleteFlag}, + + + + + insert into exchange_code (code, package_price, package_id, status, expire_time, create_time, update_time, delete_flag) + values + + (#{item.code}, #{item.packagePrice}, #{item.packageId}, #{item.status}, #{item.expireTime}, #{item.createTime}, #{item.updateTime}, #{item.deleteFlag}) + + + + + update exchange_code + + code = #{code}, + package_price = #{packagePrice}, + package_id = #{packageId}, + status = #{status}, + used_user_id = #{usedUserId}, + used_time = #{usedTime}, + expire_time = #{expireTime}, + update_time = #{updateTime}, + update_by = #{updateBy}, + + where id = #{id} + + + + update exchange_code + set status = 1, + used_user_id = #{usedUserId}, + used_time = now(), + update_time = now() + where id = #{id} and status = 0 and delete_flag = 0 + + + + delete from exchange_code where id = #{id} + + + + update exchange_code + set delete_flag = 1, + update_time = now(), + update_by = #{updateBy} + where id = #{id} + + + + + + + \ No newline at end of file diff --git a/src/main/resources/schema/exchange_code.sql b/src/main/resources/schema/exchange_code.sql new file mode 100644 index 0000000..39be14a --- /dev/null +++ b/src/main/resources/schema/exchange_code.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS exchange_code ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + code VARCHAR(64) NOT NULL COMMENT '兑换码', + package_price DECIMAL(10,2) NOT NULL COMMENT '套餐价格', + package_id BIGINT COMMENT '套餐ID', + status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0-未使用,1-已使用,2-已过期', + used_user_id BIGINT COMMENT '使用用户ID', + used_time DATETIME COMMENT '使用时间', + expire_time DATETIME COMMENT '过期时间', + create_time DATETIME NOT NULL COMMENT '创建时间', + update_time DATETIME NOT NULL COMMENT '更新时间', + create_by VARCHAR(64) COMMENT '创建人', + update_by VARCHAR(64) COMMENT '更新人', + delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT '是否删除:0-未删除,1-已删除', + UNIQUE KEY uk_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='兑换码表'; + +CREATE INDEX idx_exchange_code_status ON exchange_code(status); +CREATE INDEX idx_exchange_code_package_price ON exchange_code(package_price); +CREATE INDEX idx_exchange_code_delete_flag ON exchange_code(delete_flag); \ No newline at end of file diff --git a/账户交易记录查询接口文档.md b/账户交易记录查询接口文档.md new file mode 100644 index 0000000..272b165 --- /dev/null +++ b/账户交易记录查询接口文档.md @@ -0,0 +1,579 @@ +# 账户交易记录查询接口文档 + +## 概述 + +本文档描述了账户交易记录查询相关的三个接口,包括充值记录、消费记录和赠送记录的查询功能。 + +**基础信息:** +- 基础路径:`/api/account` +- 请求方式:全部使用 POST +- 认证要求:所有接口都需要登录认证(@RequireAuth) +- 响应格式:统一使用 `CommonResult` 包装 + +--- + +## 1. 分页查询充值记录 + +### 接口信息 + +- **接口名称**:分页查询充值记录 +- **接口路径**:`/api/account/getRechargePageList` +- **请求方式**:POST +- **接口描述**:查询所有的充值记录(transactionType=1),支持分页和条件筛选,默认根据创建时间倒序排列 + +### 请求参数 + +**Content-Type**: `application/json` + +**请求体参数(AccountTransactionDto)**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| pageNum | Integer | 否 | 当前页码,默认1 | +| pageSize | Integer | 否 | 每页数量,默认10 | +| sortBy | String | 否 | 排序字段 | +| sortDesc | Boolean | 否 | 是否降序,默认true | +| userId | Long | 否 | 用户ID,用于筛选特定用户的充值记录 | +| status | Integer | 否 | 交易状态:1.成功 2.失败 3.处理中 | +| transactionNo | String | 否 | 交易单号,精确匹配 | +| createTimeStart | Date | 否 | 开始时间,格式:yyyy-MM-dd HH:mm:ss | +| createTimeEnd | Date | 否 | 结束时间,格式:yyyy-MM-dd HH:mm:ss | +| deleteFlag | Integer | 否 | 删除标记:0.未删除 1.已删除,默认0 | + +**注意**:此接口固定查询 transactionType=1(充值)的记录 + +### 请求示例 + +```json +{ + "pageNum": 1, + "pageSize": 10, + "userId": 1001, + "status": 1, + "createTimeStart": "2025-01-01 00:00:00", + "createTimeEnd": "2025-12-31 23:59:59" +} +``` + +### 响应参数 + +**响应结构**:`CommonResult>` + +**PageInfo 字段说明**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| total | Long | 总记录数 | +| list | Array | 数据列表 | +| pageNum | Integer | 当前页码 | +| pageSize | Integer | 每页数量 | +| pages | Integer | 总页数 | + +**AccountTransaction 对象字段**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| transactionId | Long | 主键ID | +| userId | Long | 用户ID | +| userName | String | 用户名 | +| transactionType | Integer | 交易类型:1.充值 | +| amount | BigDecimal | 交易金额(积分) | +| beforeBalance | BigDecimal | 交易前余额 | +| afterBalance | BigDecimal | 交易后余额 | +| status | Integer | 交易状态:1.成功 2.失败 3.处理中 | +| transactionNo | String | 交易单号 | +| payType | Integer | 支付方式:1.微信 2.支付宝 3.余额支付 | +| businessId | Long | 关联业务ID(套餐ID) | +| businessType | String | 业务类型 | +| callId | String | 调用ID,关联冻结单 | +| remark | String | 交易备注 | +| isExpense | Integer | 是否支出:1.是 0.否 | +| inputToken | Integer | 输入token | +| outputToken | Integer | 输出token | +| totalTokens | Integer | 合计tokens | +| modelName | String | 处理的模型名称 | +| question | String | 对应回答的问题或需求 | +| incomeType | String | 收入类型:recharge(充值)、sign_in(签到奖励) | +| createTime | String | 创建时间,格式:yyyy-MM-dd HH:mm:ss | +| updateTime | String | 更新时间,格式:yyyy-MM-dd HH:mm:ss | +| createBy | String | 创建人 | +| updateBy | String | 更新人 | +| deleteFlag | Integer | 是否删除:0.未删除 1.已删除 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "total": 50, + "list": [ + { + "transactionId": 1001, + "userId": 1001, + "userName": "张三", + "transactionType": 1, + "amount": 10000.00, + "beforeBalance": 5000.00, + "afterBalance": 15000.00, + "status": 1, + "transactionNo": "TXN202504150001", + "payType": 1, + "businessId": 10, + "businessType": "package_recharge", + "callId": null, + "remark": "购买套餐:基础套餐,获得10000积分", + "isExpense": 0, + "inputToken": null, + "outputToken": null, + "totalTokens": null, + "modelName": null, + "question": null, + "incomeType": "recharge", + "createTime": "2025-04-15 10:30:00", + "updateTime": "2025-04-15 10:30:00", + "createBy": "system", + "updateBy": "system", + "deleteFlag": 0 + } + ], + "pageNum": 1, + "pageSize": 10, + "pages": 5 + } +} +``` + +--- + +## 2. 分页查询消费记录(按callId分组) + +### 接口信息 + +- **接口名称**:分页查询消费记录 +- **接口路径**:`/api/account/getConsumptionGroupedPageList` +- **请求方式**:POST +- **接口描述**:查询所有的消费记录,按callId进行分组,每组返回一条汇总记录。对于同一个callId的多条记录: + - **amount字段**:对该callId下所有记录的amount进行**求和** + - **inputToken字段**:对该callId下所有记录的inputToken进行**求和** + - **outputToken字段**:对该callId下所有记录的outputToken进行**求和** + - **totalTokens字段**:对该callId下所有记录的totalTokens进行**求和** + - **question字段**:取该callId下最早创建的记录的question + - 其他字段:取该callId下最早创建的记录的值 + + 主要适用于大模型消费场景,可以查看每次对话的总体消费情况。默认根据创建时间倒序排列 + +### 请求参数 + +**Content-Type**: `application/json` + +**请求体参数(AccountTransactionDto)**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| pageNum | Integer | 否 | 当前页码,默认1 | +| pageSize | Integer | 否 | 每页数量,默认10 | +| sortBy | String | 否 | 排序字段 | +| sortDesc | Boolean | 否 | 是否降序,默认true | +| userId | Long | 否 | 用户ID,用于筛选特定用户的消费记录 | +| status | Integer | 否 | 交易状态:1.成功 2.失败 3.处理中 | +| createTimeStart | Date | 否 | 开始时间,格式:yyyy-MM-dd HH:mm:ss | +| createTimeEnd | Date | 否 | 结束时间,格式:yyyy-MM-dd HH:mm:ss | +| deleteFlag | Integer | 否 | 删除标记:0.未删除 1.已删除,默认0 | + +**注意**: +- 此接口固定查询 transactionType IN (3, 7) 的记录(3.购买内容 7.其他) +- 只查询 callId 不为空的记录 +- 按 callId 分组,每组返回一条记录 +- question 字段取该 callId 下最早创建的记录的 question + +### 请求示例 + +```json +{ + "pageNum": 1, + "pageSize": 10, + "userId": 1001, + "status": 1, + "createTimeStart": "2025-01-01 00:00:00", + "createTimeEnd": "2025-12-31 23:59:59" +} +``` + +### 响应参数 + +**响应结构**:`CommonResult>` + +**PageInfo 字段说明**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| total | Long | 总记录数(按callId分组后的数量) | +| list | Array | 数据列表 | +| pageNum | Integer | 当前页码 | +| pageSize | Integer | 每页数量 | +| pages | Integer | 总页数 | + +**ConsumptionGroupedDto 对象字段**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| transactionId | Long | 主键ID(取该callId下最早的记录ID) | +| userId | Long | 用户ID | +| userName | String | 用户名 | +| transactionType | Integer | 交易类型:3.购买内容 7.其他 | +| amount | BigDecimal | 交易金额(该callId下所有记录的amount**求和**,通常为负数表示消耗) | +| beforeBalance | BigDecimal | 交易前余额(取最早记录的值) | +| afterBalance | BigDecimal | 交易后余额(取最早记录的值) | +| status | Integer | 交易状态:1.成功 2.失败 3.处理中 | +| transactionNo | String | 交易单号 | +| payType | Integer | 支付方式:1.微信 2.支付宝 3.余额支付 | +| businessId | Long | 关联业务ID | +| businessType | String | 业务类型 | +| callId | String | 调用ID,关联冻结单(分组依据) | +| remark | String | 交易备注 | +| isExpense | Integer | 是否支出:1.是 0.否 | +| inputToken | Integer | 输入token(该callId下所有记录的inputToken**求和**) | +| outputToken | Integer | 输出token(该callId下所有记录的outputToken**求和**) | +| totalTokens | Integer | 合计tokens(该callId下所有记录的totalTokens**求和**) | +| modelName | String | 处理的模型名称 | +| question | String | 对应回答的问题或需求(取该callId下最早入库的question) | +| incomeType | String | 收入类型 | +| createTime | String | 创建时间(取该callId下最早的创建时间),格式:yyyy-MM-dd HH:mm:ss | +| updateTime | String | 更新时间,格式:yyyy-MM-dd HH:mm:ss | +| createBy | String | 创建人 | +| updateBy | String | 更新人 | +| deleteFlag | Integer | 是否删除:0.未删除 1.已删除 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "total": 30, + "list": [ + { + "transactionId": 2001, + "userId": 1001, + "userName": "张三", + "transactionType": 3, + "amount": -1500.00, + "beforeBalance": 15000.00, + "afterBalance": 13500.00, + "status": 1, + "transactionNo": "TXN202504150002", + "payType": 3, + "businessId": null, + "businessType": "ai_model_consumption", + "callId": "CALL_20250415_001", + "remark": "AI模型调用消费", + "isExpense": 1, + "inputToken": 3000, + "outputToken": 1500, + "totalTokens": 4500, + "modelName": "gpt-4", + "question": "请帮我分析这段代码的性能问题", + "incomeType": null, + "createTime": "2025-04-15 11:00:00", + "updateTime": "2025-04-15 11:00:00", + "createBy": "system", + "updateBy": "system", + "deleteFlag": 0 + } + ], + "pageNum": 1, + "pageSize": 10, + "pages": 3 + } +} +``` + +### 业务说明 + +**为什么需要按callId分组?** + +在大模型调用场景中,一次用户提问(一个callId)可能会产生多条消费记录: +1. 首次冻结费用的记录 +2. 实际扣费的记录 +3. 可能的退款或调整记录 + +通过按callId分组并**对金额和token进行求和**,可以将同一次对话的所有相关记录合并展示,便于用户查看: +- **总消费金额**:该次对话总共消耗了多少积分 +- **总token用量**:该次对话总共使用了多少input/output token +- **总体消费情况**:一次对话的完整消费概览 + +**聚合字段说明**: + +以下字段会对同一callId下的所有记录进行**SUM求和**: +- `amount`:总金额(通常为负数,表示消耗) +- `inputToken`:总输入token数 +- `outputToken`:总输出token数 +- `totalTokens`:总token数 + +**question字段的取值逻辑**: + +由于同一个callId可能有多条记录,但question(用户的问题)应该在第一次创建时就确定了,因此取该callId下创建时间最早的记录的question字段。 + +**其他字段的取值逻辑**: + +除上述聚合字段外,其他字段(如transactionId、beforeBalance、afterBalance等)都取该callId下创建时间最早的记录的值。 + +--- + +## 3. 分页查询赠送记录 + +### 接口信息 + +- **接口名称**:分页查询赠送记录 +- **接口路径**:`/api/account/getGiftPageList` +- **请求方式**:POST +- **接口描述**:查询所有的赠送记录(transactionType=6),支持分页和条件筛选,默认根据创建时间倒序排列 + +### 请求参数 + +**Content-Type**: `application/json` + +**请求体参数(AccountTransactionDto)**: + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| pageNum | Integer | 否 | 当前页码,默认1 | +| pageSize | Integer | 否 | 每页数量,默认10 | +| sortBy | String | 否 | 排序字段 | +| sortDesc | Boolean | 否 | 是否降序,默认true | +| userId | Long | 否 | 用户ID,用于筛选特定用户的赠送记录 | +| status | Integer | 否 | 交易状态:1.成功 2.失败 3.处理中 | +| transactionNo | String | 否 | 交易单号,精确匹配 | +| createTimeStart | Date | 否 | 开始时间,格式:yyyy-MM-dd HH:mm:ss | +| createTimeEnd | Date | 否 | 结束时间,格式:yyyy-MM-dd HH:mm:ss | +| deleteFlag | Integer | 否 | 删除标记:0.未删除 1.已删除,默认0 | + +**注意**:此接口固定查询 transactionType=6(赠送)的记录 + +### 请求示例 + +```json +{ + "pageNum": 1, + "pageSize": 10, + "userId": 1001, + "status": 1, + "createTimeStart": "2025-01-01 00:00:00", + "createTimeEnd": "2025-12-31 23:59:59" +} +``` + +### 响应参数 + +**响应结构**:`CommonResult>` + +**PageInfo 字段说明**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| total | Long | 总记录数 | +| list | Array | 数据列表 | +| pageNum | Integer | 当前页码 | +| pageSize | Integer | 每页数量 | +| pages | Integer | 总页数 | + +**AccountTransaction 对象字段**: + +| 参数名 | 类型 | 说明 | +|--------|------|------| +| transactionId | Long | 主键ID | +| userId | Long | 用户ID | +| userName | String | 用户名 | +| transactionType | Integer | 交易类型:6.赠送 | +| amount | BigDecimal | 赠送金额(积分) | +| beforeBalance | BigDecimal | 赠送前余额 | +| afterBalance | BigDecimal | 赠送后余额 | +| status | Integer | 交易状态:1.成功 2.失败 3.处理中 | +| transactionNo | String | 交易单号 | +| payType | Integer | 支付方式:通常为null或3 | +| businessId | Long | 关联业务ID | +| businessType | String | 业务类型:gift_balance | +| callId | String | 调用ID,通常为null | +| remark | String | 赠送备注 | +| isExpense | Integer | 是否支出:0.否(赠送为收入) | +| inputToken | Integer | 输入token,通常为null | +| outputToken | Integer | 输出token,通常为null | +| totalTokens | Integer | 合计tokens,通常为null | +| modelName | String | 处理的模型名称,通常为null | +| question | String | 对应回答的问题或需求,通常为null | +| incomeType | String | 收入类型,通常为null | +| createTime | String | 创建时间,格式:yyyy-MM-dd HH:mm:ss | +| updateTime | String | 更新时间,格式:yyyy-MM-dd HH:mm:ss | +| createBy | String | 创建人(通常是管理员) | +| updateBy | String | 更新人 | +| deleteFlag | Integer | 是否删除:0.未删除 1.已删除 | + +### 响应示例 + +```json +{ + "code": 200, + "message": "success", + "data": { + "total": 15, + "list": [ + { + "transactionId": 3001, + "userId": 1001, + "userName": "张三", + "transactionType": 6, + "amount": 5000.00, + "beforeBalance": 10000.00, + "afterBalance": 15000.00, + "status": 1, + "transactionNo": "GIFT202504150001", + "payType": null, + "businessId": null, + "businessType": "gift_balance", + "callId": null, + "remark": "管理员赠送积分", + "isExpense": 0, + "inputToken": null, + "outputToken": null, + "totalTokens": null, + "modelName": null, + "question": null, + "incomeType": null, + "createTime": "2025-04-15 14:00:00", + "updateTime": "2025-04-15 14:00:00", + "createBy": "admin", + "updateBy": "admin", + "deleteFlag": 0 + } + ], + "pageNum": 1, + "pageSize": 10, + "pages": 2 + } +} +``` + +--- + +## 通用说明 + +### 交易类型枚举 + +| 值 | 说明 | +|----|------| +| 1 | 充值 | +| 2 | 提现 | +| 3 | 购买内容 | +| 4 | 退款 | +| 5 | 签到奖励 | +| 6 | 赠送 | +| 7 | 其他 | + +### 交易状态枚举 + +| 值 | 说明 | +|----|------| +| 1 | 成功 | +| 2 | 失败 | +| 3 | 处理中 | + +### 支付方式枚举 + +| 值 | 说明 | +|----|------| +| 1 | 微信支付 | +| 2 | 支付宝 | +| 3 | 余额支付 | + +### 错误码说明 + +| 错误码 | 说明 | +|--------|------| +| 200 | 成功 | +| 401 | 未登录或登录已过期 | +| 403 | 权限不足 | +| 500 | 服务器内部错误 | + +### 注意事项 + +1. **认证要求**:所有接口都需要登录后才能访问,请求时需要携带有效的认证token +2. **分页参数**:如果不传分页参数,默认第1页,每页10条 +3. **时间格式**:所有时间字段统一使用 `yyyy-MM-dd HH:mm:ss` 格式 +4. **排序规则**:所有接口默认按创建时间(create_time)倒序排列,最新的记录在前 +5. **数据权限**:如果传入userId参数,可以查询指定用户的记录;如果不传,则查询所有用户的记录(需要相应权限) +6. **软删除**:默认只查询未删除的记录(deleteFlag=0) + +### 前端调用示例 + +```javascript +// 1. 查询充值记录 +async function getRechargeList(params) { + const response = await fetch('/api/account/getRechargePageList', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ + pageNum: 1, + pageSize: 10, + userId: 1001, + ...params + }) + }); + return await response.json(); +} + +// 2. 查询消费记录(按callId分组) +async function getConsumptionList(params) { + const response = await fetch('/api/account/getConsumptionGroupedPageList', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ + pageNum: 1, + pageSize: 10, + userId: 1001, + ...params + }) + }); + return await response.json(); +} + +// 3. 查询赠送记录 +async function getGiftList(params) { + const response = await fetch('/api/account/getGiftPageList', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + token + }, + body: JSON.stringify({ + pageNum: 1, + pageSize: 10, + userId: 1001, + ...params + }) + }); + return await response.json(); +} +``` + +--- + +## 版本历史 + +| 版本 | 日期 | 作者 | 说明 | +|------|------|------|------| +| v1.0 | 2025-04-15 | 王志维 | 初始版本,创建三个查询接口 | + +--- + +## 联系方式 + +如有问题,请联系开发团队。 diff --git a/账户冻结扣费计算逻辑说明.md b/账户冻结扣费计算逻辑说明.md new file mode 100644 index 0000000..61bca25 --- /dev/null +++ b/账户冻结扣费计算逻辑说明.md @@ -0,0 +1,555 @@ +# 账户冻结和扣费计算逻辑说明 + +## 📋 文档概述 + +本文档详细说明后端账户冻结、Token消费计算、扣费系数应用及余额扣减的完整逻辑。 + +--- + +## 🎯 核心概念定义 + +### 1. 计量单位体系 + +``` +人民币(元) ←→ 积分 + +换算关系: +- 1 元 = 100 积分 +- 数据库中的 balance、amount、frozen_amount 字段单位均为【积分】 +``` + +### 2. 扣费系数 + +```yaml +# 配置文件:application.yml +account: + deduction: + coefficient: 2 # 扣费系数,默认2倍 +``` + +**含义:** +- 实际消耗 1 积分,扣除 2 积分 +- 计算公式:`实际扣费 = 基础费用 × 扣费系数` + +### 3. 关键数据表字段 + +| 表名 | 字段 | 类型 | 单位 | 说明 | +|------|------|------|------|------| +| account | balance | decimal(10,2) | **积分** | 账户总余额 | +| account | frozen_amount | decimal(10,2) | **积分** | 冻结金额 | +| account_transaction | amount | decimal(10,2) | **积分** | 交易金额 | +| account_transaction | before_balance | decimal(10,2) | **积分** | 交易前余额 | +| account_transaction | after_balance | decimal(10,2) | **积分** | 交易后余额 | +| model_price | input_per_cent | bigint | **个/分** | 1分钱可购买的输入Token数 | +| model_price | output_per_cent | bigint | **个/分** | 1分钱可购买的输出Token数 | + +--- + +## 💰 模型价格配置示例 + +| 厂商 | 模型名称 | 输入价格(元/百万Token) | 输出价格(元/百万Token) | input_per_cent | output_per_cent | +|------|----------|---------------------|---------------------|----------------|----------------| +| 阿里巴巴 | Qwen 3.5 Plus | 4.0000 | 12.0000 | 250000 | 83333 | +| 字节跳动 | 豆包 Lite | 0.6000 | 1.2000 | 1666667 | 833333 | +| OpenAI | GPT-4o | 18.0000 | 72.0000 | 55556 | 13889 | + +**配置说明:** +- `input_per_cent`: 1分钱可以购买的输入Token数量 +- `output_per_cent`: 1分钱可以购买的输出Token数量 + +**计算关系:** +``` +input_per_cent = 1,000,000 / (input_price × 100) +output_per_cent = 1,000,000 / (output_price × 100) + +例如 Qwen 3.5 Plus: +- input_price = 4.0000 元/百万Token +- input_per_cent = 1,000,000 / (4.0000 × 100) = 250,000 +``` + +--- + +## 🔢 完整计算逻辑 + +### 阶段一:创建冻结单(预扣费) + +#### 接口:`POST /api/accountFrozen/frozen` + +**输入参数:** +```json +{ + "sessionId": "会话ID", + "modelName": "Qwen 3.5 Plus", + "estimatedInputTokens": 120000, + "estimatedOutputTokens": 200, + "frozenType": 1 +} +``` + +**计算步骤:** + +```java +// 1. 查询模型价格配置 +ModelPrice modelPrice = queryByModelName("Qwen 3.5 Plus"); +// 配置:inputPerCent = 250000, outputPerCent = 83333 + +// 2. 计算输入Token费用(向上取整到分) +long inputFee = estimatedInputTokens / modelPrice.getInputPerCent(); +if (estimatedInputTokens % modelPrice.getInputPerCent() > 0) { + inputFee += 1; // 不足1分按1分计算 +} +// 示例:120000 / 250000 = 0,余数 120000 > 0,所以 inputFee = 1 分 + +// 3. 计算输出Token费用(向上取整到分) +long outputFee = estimatedOutputTokens / modelPrice.getOutputPerCent(); +if (estimatedOutputTokens % modelPrice.getOutputPerCent() > 0) { + outputFee += 1; // 不足1分按1分计算 +} +// 示例:200 / 83333 = 0,余数 200 > 0,所以 outputFee = 1 分 + +// 4. 计算基础费用(分) +// 注意:因为1分=1积分,所以totalFee直接就是积分数量 +long totalFee = inputFee + outputFee; +// 示例:1 + 1 = 2 分 = 2 积分 + +// 5. 应用扣费系数 +BigDecimal baseAmount = BigDecimal.valueOf(totalFee); +BigDecimal coefficient = accountDeductionProperties.getCoefficient(); // 从配置文件获取,默认2 +BigDecimal finalFrozenAmount = baseAmount.multiply(coefficient); +// 示例:2 × 2 = 4 积分 + +// 6. 检查可用余额是否足够 +BigDecimal availableBalance = balance - frozenAmount; +if (availableBalance < finalFrozenAmount) { + throw new BizException("余额不足"); +} + +// 7. 更新账户冻结金额 +account.frozenAmount += finalFrozenAmount; +``` + +**计算公式总结:** + +``` +冻结金额(积分)= [⌈输入Token / inputPerCent⌉ + ⌈输出Token / outputPerCent⌉] × 扣费系数 + +其中 ⌈x 表示向上取整 +``` + +**实际案例(扣费系数=2):** +``` +输入Token: 120,000 +输出Token: 200 +Qwen 3.5 Plus: input_per_cent=250000, output_per_cent=83333 + +计算: +- inputFee = ⌈120000/250000⌉ = 1 积分 +- outputFee = ⌈200/83333⌉ = 1 积分 +- baseAmount = 2 积分 +- finalFrozenAmount = 2 × 2 = 4 积分 +``` + +--- + +### 阶段二:释放冻结单(实际扣费) + +#### 接口:`POST /api/accountFrozen/release` + +**输入参数:** +```json +{ + "frozenId": "冻结单ID", + "usageInputTokens": 121067, + "usageOutputTokens": 209, + "usageTotalTokens": 121276 +} +``` + +**计算步骤:** + +```java +// 1. 查询冻结单 +AccountFrozen frozen = getByFrozenId(frozenId); +// 获取 modelName: "Qwen 3.5 Plus" + +// 2. 查询模型价格配置 +ModelPrice modelPrice = queryByModelName("Qwen 3.5 Plus"); +// 配置:inputPerCent = 250000, outputPerCent = 83333 + +// 3. 计算实际输入Token费用(向上取整到分) +long inputFee = usageInputTokens / modelPrice.getInputPerCent(); +if (usageInputTokens % modelPrice.getInputPerCent() > 0) { + inputFee += 1; +} +// 示例:121067 / 250000 = 0,余数 121067 > 0,所以 inputFee = 1 分 + +// 4. 计算实际输出Token费用(向上取整到分) +long outputFee = usageOutputTokens / modelPrice.getOutputPerCent(); +if (usageOutputTokens % modelPrice.getOutputPerCent() > 0) { + outputFee += 1; +} +// 示例:209 / 83333 = 0,余数 209 > 0,所以 outputFee = 1 分 + +// 5. 计算基础费用(分) +// 注意:因为1分=1积分,所以totalFee直接就是积分数量 +long totalFee = inputFee + outputFee; +// 示例:1 + 1 = 2 分 = 2 积分 + +// 6. 应用扣费系数 +BigDecimal baseAmount = BigDecimal.valueOf(totalFee); +BigDecimal coefficient = accountDeductionProperties.getCoefficient(); // 从配置文件获取,默认2 +BigDecimal finalAmount = baseAmount.multiply(coefficient); +// 示例:2 × 2 = 4 积分 + +// 7. 释放全部冻结金额 +account.frozenAmount -= frozen.frozenAmount; + +// 8. 扣减实际消费金额 +if (finalAmount > 0) { + if (account.balance < finalAmount) { + account.balance = 0; // 余额不足时设为0 + } else { + account.balance -= finalAmount; + } +} + +// 9. 生成交易记录 +AccountTransaction transaction = { + userId: frozen.userId, + transactionType: 3, // 购买内容 + amount: finalAmount, // 4 积分(已应用扣费系数) + beforeBalance: 原余额, + afterBalance: 扣减后余额, + status: 1, // 成功 + payType: 3, // 余额支付 + businessType: "frozen_release", + isExpense: 1, // 支出 + inputToken: 121067, + outputToken: 209, + totalTokens: 121276, + modelName: "Qwen 3.5 Plus", + remark: "冻结单释放扣减: " + frozen.frozenId +}; + +// 10. 更新冻结单状态 +frozen.status = "FINALIZED"; +frozen.finalAmount = finalAmount; +``` + +**计算公式总结:** + +``` +实际扣费(积分)= [⌈实际输入Token / inputPerCent⌉ + 实际输出Token / outputPerCent⌉] × 扣费系数 +``` + +--- + +## ✅ 完整案例验证 + +### 案例数据 + +**用户请求:** +- 模型:Qwen 3.5 Plus +- 预估输入Token: 120,000 +- 预估输出Token: 200 +- 实际输入Token: 121,067 +- 实际输出Token: 209 +- 扣费系数:2(配置文件默认值) +- 用户余额:20 积分 + +### 阶段一:创建冻结单 + +``` +计算过程: +1. inputFee = ⌈120000/250000⌉ = 1 积分 +2. outputFee = ⌈200/83333⌉ = 1 积分 +3. baseAmount = 1 + 1 = 2 积分 +4. finalFrozenAmount = 2 × 2 = 4 积分(应用扣费系数) + +账户变化: +- balance: 20 积分(不变) +- frozenAmount: 0 + 4 = 4 积分 +- availableBalance: 20 - 4 = 16 积分 +``` + +### 阶段二:释放冻结单 + +``` +计算过程: +1. inputFee = ⌈121067/250000⌉ = 1 积分 +2. outputFee = ⌈209/83333⌉ = 1 积分 +3. baseAmount = 1 + 1 = 2 积分 +4. finalAmount = 2 × 2 = 4 积分(应用扣费系数) + +账户变化: +- frozenAmount: 4 - 4 = 0 积分(释放冻结) +- balance: 20 - 4 = 16 积分(扣减实际费用) + +数据库交易记录: +- transaction_id: 145 +- before_balance: 20.00 积分 +- amount: 4.00 积分 +- after_balance: 16.00 积分 +- input_token: 121067 +- output_token: 209 +- model_name: "Qwen 3.5 Plus" +``` + +**验算:** 20.00 - 4.00 = 16.00 ✓ 完全正确! + +--- + +## 📊 完整业务流程图 + +``` +用户发起AI请求 + ↓ +【阶段1:创建冻结单】 + ↓ +1. 预估Token数量(输入+输出) + ↓ +2. 查询模型价格配置(inputPerCent, outputPerCent) + ↓ +3. 计算预估费用(向上取整到分) + baseAmount = ⌈estInput/inputPerCent⌉ + ⌈estOutput/outputPerCent⌉ + ↓ +4. 应用扣费系数 + finalFrozenAmount = baseAmount × coefficient + ↓ +5. 检查可用余额(balance - frozenAmount >= finalFrozenAmount) + ↓ +6. 冻结金额(frozenAmount += finalFrozenAmount) + ↓ +7. 创建冻结单(status = "RESERVED") + ↓ +AI模型执行完成 + ↓ +【阶段2:释放冻结单】 + ↓ +1. 获取实际Token使用量 + ↓ +2. 查询模型价格配置 + ↓ +3. 计算实际费用(向上取整到分) + baseAmount = ⌈actualInput/inputPerCent⌉ + ⌈actualOutput/outputPerCent⌉ + ↓ +4. 应用扣费系数 + finalAmount = baseAmount × coefficient + ↓ +5. 释放冻结金额(frozenAmount -= frozen.frozenAmount) + ↓ +6. 扣减实际消费(balance -= finalAmount) + ↓ +7. 生成交易记录(account_transaction) + ↓ +8. 更新冻结单状态(status = "FINALIZED") + ↓ +返回结果给用户 +``` + +--- + +## ⚠️ 重要说明 + +### 1. 单位统一性 + +**所有涉及金额的字段在数据库中均以【积分】为单位存储:** +- `account.balance` - 积分 +- `account.frozen_amount` - 积分 +- `account_transaction.amount` - 积分 +- `account_transaction.before_balance` - 积分 +- `account_transaction.after_balance` - 积分 + +### 2. 向上取整规则 + +Token费用计算采用**向上取整到分**的策略: +```java +// 不足1分按1分计算 +if (tokens % perCent > 0) { + fee += 1; +} +``` + +**原因:** 避免用户通过拆分请求来规避最小计费单位 + +### 3. 扣费系数应用 + +扣费系数在两个阶段都会应用: +- **创建冻结单**:预估费用 × 扣费系数 +- **释放冻结单**:实际费用 × 扣费系数 + +**示例(扣费系数=2):** +``` +基础费用:2 积分(121,067输入Token + 209输出Token) +实际扣费:2 × 2 = 4 积分 +``` + +### 4. 余额不足处理 + +```java +// 如果余额不够实际扣减,将balance设置为0 +if (balance.compareTo(finalAmount) < 0) { + account.setBalance(BigDecimal.ZERO); +} else { + account.setBalance(balance.subtract(finalAmount)); +} +``` + +**原因:** 防止余额出现负数 + +### 5. 数据精度 + +数据库字段定义为 `decimal(10,2)`: +- 最大值:99,999,999.99 积分 = 999,999.9999 元 +- 小数位数:2位 +- 精度损失:在极端情况下可能存在 0.01 积分的舍入误差 + +--- + +## 📝 前端展示建议 + +### 方案一:以"积分"为单位展示(推荐) + +```javascript +// 后端返回的数据(积分) +const balance = 16; // 积分 + +// 前端显示 +const displayBalance = balance.toFixed(2) + '积分'; // "16.00积分" +``` + +### 方案二:转换为"元"展示 + +```javascript +// 后端返回的数据(积分) +const balance = 16; // 积分 + +// 转换为元显示 +const yuan = (balance / 100).toFixed(2); // "0.16" 元 +const displayText = `${yuan}元`; +``` + +### 方案三:混合展示(推荐用于明细) + +```javascript +// 同时显示元和积分 +const yuan = (balance / 100).toFixed(2); +const points = balance.toFixed(2); +const displayText = `${yuan}元(${points}积分)`; +// 输出:"0.16元(16.00积分)" +``` + +### 方案四:显示扣费明细 + +```javascript +// 显示完整的扣费信息 +const baseFee = 2; // 基础费用 +const coefficient = 2; // 扣费系数 +const actualFee = 4; // 实际扣费 + +const displayText = ` + 基础费用:${baseFee}积分 + 扣费系数:×${coefficient} + 实际扣费:${actualFee}积分(${(actualFee/100).toFixed(2)}元) +`; +``` + +--- + +## 🔧 如何调整扣费系数 + +### 修改配置文件 + +**开发环境** (`application-dev.yml`): +```yaml +account: + deduction: + coefficient: 1.5 # 1.5倍扣费(测试用) +``` + +**生产环境** (`application-prod.yml`): +```yaml +account: + deduction: + coefficient: 2 # 2倍扣费 +``` + +### 配置说明 + +- 修改后重启服务即可生效 +- 无需修改代码 +- 支持小数(如 1.5、2.5) +- 默认值为 2 + +### 不同场景的扣费系数建议 + +| 场景 | 扣费系数 | 说明 | +|------|----------|------| +| 测试环境 | 1.0 | 不额外扣费,方便测试 | +| 开发环境 | 1.5 | 适度扣费,接近真实场景 | +| 生产环境-普惠模型 | 1.5 | 降低用户成本 | +| 生产环境-高级模型 | 2.0 | 标准扣费 | +| 生产环境-旗舰模型 | 2.5 | 高端服务额外扣费 | + +--- + +## ✅ 验证清单 + +前端可以通过以下方式验证后端计算的正确性: + +- [ ] 交易记录的 `before_balance - amount = after_balance` +- [ ] 根据模型价格配置,手动计算 Token 基础费用 +- [ ] 验证向上取整逻辑(不足1分按1分计算) +- [ ] 检查冻结单的预估费用是否应用了扣费系数 +- [ ] 确认释放后的实际费用是否应用了扣费系数 +- [ ] 验证账户的 `frozen_amount` 在释放后正确减少 +- [ ] 核对扣费系数是否与配置文件一致 + +### 计算验证公式 + +``` +预期费用(积分)= [⌈输入Token/inputPerCent⌉ + 输出Token/outputPerCent] × coefficient + +验证: +- before_balance - after_balance = amount ✓ +- amount = 预期费用 ✓ +``` + +--- + +## 🔍 关键代码位置 + +### 后端实现文件 + +1. **扣费配置属性类** + - 文件:`src/main/java/com/kexue/skills/config/AccountDeductionProperties.java` + - 作用:读取配置文件中的扣费系数 + +2. **冻结单控制器** + - 文件:`src/main/java/com/kexue/skills/controller/AccountFrozenController.java` + - 接口:`POST /api/accountFrozen/frozen` + - 接口:`POST /api/accountFrozen/release` + +3. **冻结单服务实现** + - 文件:`src/main/java/com/kexue/skills/service/impl/AccountFrozenServiceImpl.java` + - 方法:`createFrozen()` - 第 57-141 行 + - 方法:`releaseFrozen()` - 第 148-283 行 + - 扣费系数应用:第 110-111 行、第 209-210 行 + +4. **配置文件** + - 文件:`src/main/resources/application.yml` + - 配置项:`account.deduction.coefficient` + +--- + +## 📞 联系方式 + +如有疑问,请联系后端开发团队。 + +**文档版本:** v3.0 +**更新时间:** 2026-04-13 +**作者:** 后端开发团队 +**更新说明:** +- v1.0: 初始版本,基于"元"为单位 +- v2.0: 根据模型价格配置重新验证,发现单位问题 +- v3.0: 引入积分单位和扣费系数,完整说明当前逻辑