Update backend code with new features and bug fixes

This commit is contained in:
wangzhiwei 2026-05-15 17:18:17 +08:00
parent a8faaebc79
commit 394459e7c4
18 changed files with 2610 additions and 142 deletions

339
LOG_ANNOTATION_GUIDE.md Normal file
View File

@ -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

View File

@ -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<PageInfo<ExchangeCode>> getPageList(ExchangeCodeQueryDto queryDto) {
return CommonResult.success(exchangeCodeService.getPageList(queryDto));
}
@GetMapping("/list")
@Operation(summary = "查询列表")
public CommonResult<List<ExchangeCode>> getList(ExchangeCodeQueryDto queryDto) {
return CommonResult.success(exchangeCodeService.getList(queryDto));
}
@GetMapping("/{id}")
@Operation(summary = "通过ID查询")
public CommonResult<ExchangeCode> queryById(@Parameter(description = "主键ID") @PathVariable Long id) {
return CommonResult.success(exchangeCodeService.queryById(id));
}
@GetMapping("/code/{code}")
@Operation(summary = "通过兑换码查询")
public CommonResult<ExchangeCode> queryByCode(@Parameter(description = "兑换码") @PathVariable String code) {
return CommonResult.success(exchangeCodeService.queryByCode(code));
}
@PostMapping
@Operation(summary = "新增兑换码")
public CommonResult<ExchangeCode> insert(@RequestBody ExchangeCode exchangeCode) {
return CommonResult.success(exchangeCodeService.insert(exchangeCode));
}
@PutMapping
@Operation(summary = "更新兑换码")
public CommonResult<ExchangeCode> update(@RequestBody ExchangeCode exchangeCode) {
return CommonResult.success(exchangeCodeService.update(exchangeCode));
}
@DeleteMapping("/{id}")
@Operation(summary = "物理删除")
public CommonResult<Integer> deleteById(@Parameter(description = "主键ID") @PathVariable Long id) {
return CommonResult.success(exchangeCodeService.deleteById(id));
}
@PutMapping("/{id}/logicDelete")
@Operation(summary = "逻辑删除")
public CommonResult<Integer> 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<Map<String, Object>> exchange(@RequestBody ExchangeRequestDto requestDto) {
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Integer>> getStatistics() {
return CommonResult.success(exchangeCodeService.getStatistics());
}
@GetMapping("/countByPrice")
@Operation(summary = "根据套餐价格查询可用兑换码数量")
public CommonResult<Integer> countAvailableByPrice(@Parameter(description = "套餐价格") @RequestParam BigDecimal packagePrice) {
return CommonResult.success(exchangeCodeService.countAvailableByPrice(packagePrice));
}
}

View File

@ -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;
}

View File

@ -1,28 +1,26 @@
package com.kexue.skills.entity.base; package com.kexue.skills.entity.base;
import io.swagger.annotations.ApiModel;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
import java.io.Serializable; /**
* 基础查询DTO
*/
@Data @Data
@ApiModel @Schema(description = "基础查询参数")
public class BaseQueryDto implements Serializable { 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; public static final Integer DEFAULT_CURRENT_PAGE = 1;
@Schema(description = "页码默认1")
private Integer pageNum = 1;
@Schema(description ="当前页") @Schema(description = "每页数量默认20")
private Integer pageNum; private Integer pageSize = 20;
@Schema(description ="每页数量") @Schema(description = "排序字段")
private Integer pageSize;
@Schema(description ="排序字段")
private String sortBy; private String sortBy;
@Schema(description ="是否降序排序") @Schema(description = "是否降序")
private Boolean sortDesc; private Boolean sortDesc = true;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -17,6 +17,7 @@ public class GlobalExceptionHandler {
if (e == null) { if (e == null) {
return CommonResult.success("未知错误"); return CommonResult.success("未知错误");
} }
e.printStackTrace();
return CommonResult.success(e.getErrorCode(), e.getMessage()); return CommonResult.success(e.getErrorCode(), e.getMessage());
} }

View File

@ -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<ExchangeCode> getPageList(ExchangeCode exchangeCode);
/**
* 查询列表
*
* @param exchangeCode 查询条件
* @return 结果列表
*/
List<ExchangeCode> getList(ExchangeCode exchangeCode);
/**
* 新增数据
*
* @param exchangeCode 实例对象
* @return 影响行数
*/
int insert(ExchangeCode exchangeCode);
/**
* 批量新增数据
*
* @param list 实例对象列表
* @return 影响行数
*/
int batchInsert(@Param("list") List<ExchangeCode> 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<ExchangeCode> countByStatus();
/**
* 根据套餐价格查询可用兑换码数量
*
* @param packagePrice 套餐价格
* @return 数量
*/
int countAvailableByPrice(BigDecimal packagePrice);
}

View File

@ -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<ExchangeCode> getPageList(ExchangeCodeQueryDto queryDto);
List<ExchangeCode> 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<String, Object> exchange(ExchangeRequestDto requestDto);
Map<String, Object> importExcel(MultipartFile file);
void exportExcel(ExchangeCodeQueryDto queryDto, HttpServletResponse response) throws Exception;
Map<String, Integer> getStatistics();
int countAvailableByPrice(BigDecimal packagePrice);
}

View File

@ -29,6 +29,8 @@ import java.util.Date;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import static cn.dev33.satoken.SaManager.log;
/** /**
* 账户冻结单服务实现 * 账户冻结单服务实现
* *
@ -72,6 +74,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
* @return 冻结单信息 * @return 冻结单信息
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public AccountFrozen createFrozen(AccountFrozenDto accountFrozenDto) { public AccountFrozen createFrozen(AccountFrozenDto accountFrozenDto) {
// 1. 验证创建冻结单参数 // 1. 验证创建冻结单参数
validateCreateFrozenParams(accountFrozenDto); validateCreateFrozenParams(accountFrozenDto);
@ -189,24 +192,67 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
* @return 冻结金额单位积分 * @return 冻结金额单位积分
*/ */
private BigDecimal calculateTokenFrozenAmount(AccountFrozenDto accountFrozenDto) { 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 || if (accountFrozenDto.getEstimatedInputTokens() == null ||
accountFrozenDto.getEstimatedOutputTokens() == null || accountFrozenDto.getEstimatedOutputTokens() == null ||
accountFrozenDto.getModelName() == null) { accountFrozenDto.getModelName() == null) {
LOG.warn("token冻结金额计算缺少必要参数返回原始冻结金额 - sessionId: {}, frozenAmount: {}",
accountFrozenDto.getSessionId(), accountFrozenDto.getFrozenAmount());
return accountFrozenDto.getFrozenAmount(); return accountFrozenDto.getFrozenAmount();
} }
// 2. 标准化模型名称
String modelName = normalizeModelName(accountFrozenDto.getModelName()); String modelName = normalizeModelName(accountFrozenDto.getModelName());
LOG.debug("标准化模型名称 - 原始名称: {}, 标准化后: {}", accountFrozenDto.getModelName(), modelName);
// 3. 获取模型价格列表
List<ModelPrice> modelPriceList = getModelPriceList(modelName); List<ModelPrice> modelPriceList = getModelPriceList(modelName);
LOG.debug("获取模型价格列表 - modelName: {}, 价格规则数量: {}", modelName, modelPriceList.size());
if (modelPriceList.isEmpty()) { if (modelPriceList.isEmpty()) {
LOG.warn("模型价格列表为空,请检查模型价格表: {}", modelName);
return accountFrozenDto.getFrozenAmount(); return accountFrozenDto.getFrozenAmount();
} }
// 4. 查找输入模型价格
ModelPrice inputModelPrice = findInputModelPrice(modelPriceList, accountFrozenDto.getEstimatedInputTokens()); ModelPrice inputModelPrice = findInputModelPrice(modelPriceList, accountFrozenDto.getEstimatedInputTokens());
LOG.debug("查找输入模型价格 - estimatedInputTokens: {}, 匹配结果: {}",
accountFrozenDto.getEstimatedInputTokens(),
inputModelPrice != null ? "inputPerCent=" + inputModelPrice.getInputPerCent() : "未找到匹配");
// 5. 查找输出模型价格
ModelPrice outputModelPrice = findOutputModelPrice(modelPriceList, accountFrozenDto.getEstimatedOutputTokens()); ModelPrice outputModelPrice = findOutputModelPrice(modelPriceList, accountFrozenDto.getEstimatedOutputTokens());
LOG.debug("查找输出模型价格 - estimatedOutputTokens: {}, 匹配结果: {}",
accountFrozenDto.getEstimatedOutputTokens(),
outputModelPrice != null ? "outputPerCent=" + outputModelPrice.getOutputPerCent() : "未找到匹配");
if (inputModelPrice == null || outputModelPrice == null) { if (inputModelPrice == null || outputModelPrice == null) {
LOG.warn("未找到匹配的模型价格规则 - inputModelPrice: {}, outputModelPrice: {}",
inputModelPrice != null ? "已找到" : "未找到",
outputModelPrice != null ? "已找到" : "未找到");
return accountFrozenDto.getFrozenAmount(); return accountFrozenDto.getFrozenAmount();
} }
// 6. 计算总的token费用
long totalFee = calculateTotalTokenFee(accountFrozenDto, inputModelPrice, outputModelPrice); 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); 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 冻结单信息 * @return 冻结单信息
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class)
public AccountFrozen releaseFrozen(AccountReleaseDto accountReleaseDto) { public AccountFrozen releaseFrozen(AccountReleaseDto accountReleaseDto) {
// 1. 验证释放冻结单参数 // 1. 验证释放冻结单参数
validateReleaseParams(accountReleaseDto); validateReleaseParams(accountReleaseDto);
@ -490,7 +537,8 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
accountFrozen.getModelName() == null) { accountFrozen.getModelName() == null) {
return finalAmount; return finalAmount;
} }
List<ModelPrice> modelPriceList = getModelPriceList(accountFrozen.getModelName()); String modelName = normalizeModelName(accountFrozen.getModelName());
List<ModelPrice> modelPriceList = getModelPriceList(modelName);
if (modelPriceList.isEmpty()) { if (modelPriceList.isEmpty()) {
return finalAmount; return finalAmount;
} }
@ -564,9 +612,6 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
* @param finalAmount 最终扣减金额 * @param finalAmount 最终扣减金额
*/ */
private void doCreateTransaction(AccountFrozen accountFrozen, Account account, BigDecimal 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(); BigDecimal beforeBalance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance();
AccountTransaction transaction = buildTransaction(accountFrozen, account, finalAmount, beforeBalance); AccountTransaction transaction = buildTransaction(accountFrozen, account, finalAmount, beforeBalance);
accountTransactionMapper.insert(transaction); accountTransactionMapper.insert(transaction);
@ -589,7 +634,11 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
transaction.setTransactionType(3); transaction.setTransactionType(3);
transaction.setAmount(finalAmount); transaction.setAmount(finalAmount);
transaction.setBeforeBalance(beforeBalance); 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.setStatus(1);
transaction.setTransactionNo(IDUtils.getSnowflakeIdStr()); transaction.setTransactionNo(IDUtils.getSnowflakeIdStr());
transaction.setPayType(3); transaction.setPayType(3);

View File

@ -1,25 +1,23 @@
package com.kexue.skills.service.impl; 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.PageHelper;
import com.github.pagehelper.PageInfo; import com.github.pagehelper.PageInfo;
import com.github.pagehelper.util.StringUtil;
import com.kexue.skills.common.Assert; import com.kexue.skills.common.Assert;
import com.kexue.skills.common.LoginUserCacheUtil; import com.kexue.skills.common.LoginUserCacheUtil;
import com.kexue.skills.entity.CmsContent; import com.kexue.skills.entity.CmsContent;
import com.kexue.skills.entity.CmsContentView;
import com.kexue.skills.entity.CmsContentLike; import com.kexue.skills.entity.CmsContentLike;
import com.kexue.skills.entity.CmsContentView;
import com.kexue.skills.entity.base.BaseQueryDto; import com.kexue.skills.entity.base.BaseQueryDto;
import com.kexue.skills.entity.dto.CmsContentDto; import com.kexue.skills.entity.dto.CmsContentDto;
import com.kexue.skills.entity.request.ImportPathDto; import com.kexue.skills.entity.request.ImportPathDto;
import com.kexue.skills.mapper.CmsContentLikeMapper;
import com.kexue.skills.mapper.CmsContentMapper; import com.kexue.skills.mapper.CmsContentMapper;
import com.kexue.skills.mapper.CmsContentViewMapper; import com.kexue.skills.mapper.CmsContentViewMapper;
import com.kexue.skills.mapper.CmsContentLikeMapper;
import com.kexue.skills.mapper.CmsTagMapper; 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 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.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@ -30,12 +28,9 @@ import java.io.FileInputStream;
import java.io.InputStream; import java.io.InputStream;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
import cn.dev33.satoken.stp.StpUtil;
/** /**
* (CmsContent)表服务实现类 * (CmsContent)表服务实现类
* *
@ -47,16 +42,16 @@ import cn.dev33.satoken.stp.StpUtil;
public class CmsContentServiceImpl implements CmsContentService { public class CmsContentServiceImpl implements CmsContentService {
@Resource @Resource
private CmsContentMapper cmsContentMapper; private CmsContentMapper cmsContentMapper;
@Resource @Resource
private CmsContentViewMapper cmsContentViewMapper; private CmsContentViewMapper cmsContentViewMapper;
@Resource @Resource
private CmsContentLikeMapper cmsContentLikeMapper; private CmsContentLikeMapper cmsContentLikeMapper;
@Resource @Resource
private CmsTagMapper cmsTagMapper; private CmsTagMapper cmsTagMapper;
@Resource @Resource
private LoginUserCacheUtil loginUserCacheUtil; private LoginUserCacheUtil loginUserCacheUtil;
@ -150,7 +145,7 @@ public class CmsContentServiceImpl implements CmsContentService {
if (content == null) { if (content == null) {
return null; return null;
} }
try { try {
// 尝试获取当前登录用户ID // 尝试获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
@ -181,7 +176,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} catch (Exception e) { } catch (Exception e) {
// 未登录用户不记录查看历史 // 未登录用户不记录查看历史
} }
return content; return content;
} }
@ -198,13 +193,13 @@ public class CmsContentServiceImpl implements CmsContentService {
if (content == null) { if (content == null) {
return null; return null;
} }
// 如果是付费内容检查用户是否购买过 // 如果是付费内容检查用户是否购买过
if (content.getIsPaid() == 1) { if (content.getIsPaid() == 1) {
// 这里需要调用ContentPurchaseService检查权限但为了避免循环依赖我们返回完整内容 // 这里需要调用ContentPurchaseService检查权限但为了避免循环依赖我们返回完整内容
// 权限检查将在Controller层进行 // 权限检查将在Controller层进行
} }
return content; return content;
} }
@ -311,7 +306,7 @@ public class CmsContentServiceImpl implements CmsContentService {
Assert.notNull(contentId, "内容ID不能为空"); Assert.notNull(contentId, "内容ID不能为空");
// 增加阅读量 // 增加阅读量
int result = this.cmsContentMapper.increaseViewCount(contentId); int result = this.cmsContentMapper.increaseViewCount(contentId);
try { try {
// 尝试获取当前登录用户ID // 尝试获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
@ -320,7 +315,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} catch (Exception e) { } catch (Exception e) {
// 未登录用户不记录查看历史 // 未登录用户不记录查看历史
} }
return result; return result;
} }
@ -362,7 +357,7 @@ public class CmsContentServiceImpl implements CmsContentService {
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 获取当前登录用户名 // 获取当前登录用户名
String userName = StpUtil.getLoginIdAsString(); String userName = StpUtil.getLoginIdAsString();
// 检查内容是否存在 // 检查内容是否存在
CmsContent content = this.cmsContentMapper.queryById(contentId); CmsContent content = this.cmsContentMapper.queryById(contentId);
if (content == null) { if (content == null) {
@ -371,15 +366,15 @@ public class CmsContentServiceImpl implements CmsContentService {
if(Objects.isNull(content.getLikeCount())){ if(Objects.isNull(content.getLikeCount())){
content.setLikeCount(0); content.setLikeCount(0);
} }
// 检查用户是否已经收藏过该内容 // 检查用户是否已经收藏过该内容
CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId); CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId);
int result = 0; int result = 0;
if (existingLike != null) { if (existingLike != null) {
// 已经收藏过执行取消收藏操作 // 已经收藏过执行取消收藏操作
result = cmsContentLikeMapper.deleteById(existingLike.getLikeId()); result = cmsContentLikeMapper.deleteById(existingLike.getLikeId());
// 减少内容的点赞数 // 减少内容的点赞数
if (content != null && content.getLikeCount() > 0) { if (content != null && content.getLikeCount() > 0) {
content.setLikeCount(content.getLikeCount() - 1); content.setLikeCount(content.getLikeCount() - 1);
@ -394,14 +389,14 @@ public class CmsContentServiceImpl implements CmsContentService {
likeRecord.setContentTitle(content.getTitle()); likeRecord.setContentTitle(content.getTitle());
likeRecord.setLikeTime(new Date()); likeRecord.setLikeTime(new Date());
likeRecord.setDeleteFlag(0); likeRecord.setDeleteFlag(0);
// 增加内容的点赞数 // 增加内容的点赞数
content.setLikeCount(content.getLikeCount() + 1); content.setLikeCount(content.getLikeCount() + 1);
this.cmsContentMapper.update(content); this.cmsContentMapper.update(content);
result = cmsContentLikeMapper.insert(likeRecord); result = cmsContentLikeMapper.insert(likeRecord);
} }
// 更新Redis中的LoginUser对象 // 更新Redis中的LoginUser对象
if (result > 0) { if (result > 0) {
String token = loginUserCacheUtil.getTokenFromRequest(); String token = loginUserCacheUtil.getTokenFromRequest();
@ -409,7 +404,7 @@ public class CmsContentServiceImpl implements CmsContentService {
loginUserCacheUtil.updateFavorites(token, userId); loginUserCacheUtil.updateFavorites(token, userId);
} }
} }
return result; return result;
} }
@ -424,23 +419,23 @@ public class CmsContentServiceImpl implements CmsContentService {
Assert.notNull(contentId, "内容ID不能为空"); Assert.notNull(contentId, "内容ID不能为空");
// 获取当前登录用户ID // 获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 检查用户是否已经收藏过该内容 // 检查用户是否已经收藏过该内容
CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId); CmsContentLike existingLike = cmsContentLikeMapper.queryByUserIdAndContentId(userId, contentId);
if (existingLike == null) { if (existingLike == null) {
return 0; // 没有收藏过无需取消 return 0; // 没有收藏过无需取消
} }
// 删除收藏记录 // 删除收藏记录
int result = cmsContentLikeMapper.deleteById(existingLike.getLikeId()); int result = cmsContentLikeMapper.deleteById(existingLike.getLikeId());
// 减少内容的点赞数 // 减少内容的点赞数
CmsContent content = this.cmsContentMapper.queryById(contentId); CmsContent content = this.cmsContentMapper.queryById(contentId);
if (content != null && content.getLikeCount() > 0) { if (content != null && content.getLikeCount() > 0) {
content.setLikeCount(content.getLikeCount() - 1); content.setLikeCount(content.getLikeCount() - 1);
this.cmsContentMapper.update(content); this.cmsContentMapper.update(content);
} }
// 更新Redis中的LoginUser对象 // 更新Redis中的LoginUser对象
if (result > 0) { if (result > 0) {
String token = loginUserCacheUtil.getTokenFromRequest(); String token = loginUserCacheUtil.getTokenFromRequest();
@ -448,7 +443,7 @@ public class CmsContentServiceImpl implements CmsContentService {
loginUserCacheUtil.updateFavorites(token, userId); loginUserCacheUtil.updateFavorites(token, userId);
} }
} }
return result; return result;
} }
@ -486,13 +481,13 @@ public class CmsContentServiceImpl implements CmsContentService {
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 获取当前登录用户名 // 获取当前登录用户名
String userName = StpUtil.getLoginIdAsString(); String userName = StpUtil.getLoginIdAsString();
// 检查内容是否存在 // 检查内容是否存在
CmsContent content = this.cmsContentMapper.queryById(contentId); CmsContent content = this.cmsContentMapper.queryById(contentId);
if (content == null) { if (content == null) {
return 0; return 0;
} }
// 检查用户是否已经查看过该内容5分钟内 // 检查用户是否已经查看过该内容5分钟内
CmsContentView existingView = cmsContentViewMapper.queryByUserIdAndContentId(userId, contentId); CmsContentView existingView = cmsContentViewMapper.queryByUserIdAndContentId(userId, contentId);
int result = 0; int result = 0;
@ -519,7 +514,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
// 5分钟内已查看过不重复记录 // 5分钟内已查看过不重复记录
} }
// 更新Redis中的LoginUser对象 // 更新Redis中的LoginUser对象
if (result > 0) { if (result > 0) {
String token = loginUserCacheUtil.getTokenFromRequest(); String token = loginUserCacheUtil.getTokenFromRequest();
@ -527,7 +522,7 @@ public class CmsContentServiceImpl implements CmsContentService {
loginUserCacheUtil.updateHistory(token, userId); loginUserCacheUtil.updateHistory(token, userId);
} }
} }
return result; return result;
} catch (Exception e) { } catch (Exception e) {
// 未登录用户不记录查看历史 // 未登录用户不记录查看历史
@ -539,7 +534,7 @@ public class CmsContentServiceImpl implements CmsContentService {
public PageInfo<CmsContent> getPageListByUserHistory(CmsContentDto queryDto) { public PageInfo<CmsContent> getPageListByUserHistory(CmsContentDto queryDto) {
// 获取当前登录用户ID // 获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 添加参数校验确保分页参数有效 // 添加参数校验确保分页参数有效
if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) {
queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE);
@ -547,22 +542,22 @@ public class CmsContentServiceImpl implements CmsContentService {
if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) {
queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE);
} }
// 计算偏移量 // 计算偏移量
int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize();
int limit = queryDto.getPageSize(); int limit = queryDto.getPageSize();
// 查询数据 // 查询数据
List<CmsContent> list = this.cmsContentMapper.getPageListByUserHistory(userId, offset, limit); List<CmsContent> list = this.cmsContentMapper.getPageListByUserHistory(userId, offset, limit);
int total = this.cmsContentMapper.getPageListByUserHistoryCount(userId); int total = this.cmsContentMapper.getPageListByUserHistoryCount(userId);
// 构建分页结果 // 构建分页结果
PageInfo<CmsContent> pageInfo = new PageInfo<>(list); PageInfo<CmsContent> pageInfo = new PageInfo<>(list);
pageInfo.setTotal(total); pageInfo.setTotal(total);
pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageNum(queryDto.getPageNum());
pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPageSize(queryDto.getPageSize());
pageInfo.setPages((total + limit - 1) / limit); pageInfo.setPages((total + limit - 1) / limit);
return pageInfo; return pageInfo;
} }
@ -570,7 +565,7 @@ public class CmsContentServiceImpl implements CmsContentService {
public PageInfo<CmsContent> getPageListByUserFavorites(CmsContentDto queryDto) { public PageInfo<CmsContent> getPageListByUserFavorites(CmsContentDto queryDto) {
// 获取当前登录用户ID // 获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 添加参数校验确保分页参数有效 // 添加参数校验确保分页参数有效
if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) {
queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE);
@ -578,22 +573,22 @@ public class CmsContentServiceImpl implements CmsContentService {
if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) {
queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE);
} }
// 计算偏移量 // 计算偏移量
int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize();
int limit = queryDto.getPageSize(); int limit = queryDto.getPageSize();
// 查询数据 // 查询数据
List<CmsContent> list = this.cmsContentMapper.getPageListByUserFavorites(userId, offset, limit); List<CmsContent> list = this.cmsContentMapper.getPageListByUserFavorites(userId, offset, limit);
int total = this.cmsContentMapper.getPageListByUserFavoritesCount(userId); int total = this.cmsContentMapper.getPageListByUserFavoritesCount(userId);
// 构建分页结果 // 构建分页结果
PageInfo<CmsContent> pageInfo = new PageInfo<>(list); PageInfo<CmsContent> pageInfo = new PageInfo<>(list);
pageInfo.setTotal(total); pageInfo.setTotal(total);
pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageNum(queryDto.getPageNum());
pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPageSize(queryDto.getPageSize());
pageInfo.setPages((total + limit - 1) / limit); pageInfo.setPages((total + limit - 1) / limit);
return pageInfo; return pageInfo;
} }
@ -601,7 +596,7 @@ public class CmsContentServiceImpl implements CmsContentService {
public PageInfo<CmsContent> getPageListByUserPurchases(CmsContentDto queryDto) { public PageInfo<CmsContent> getPageListByUserPurchases(CmsContentDto queryDto) {
// 获取当前登录用户ID // 获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 添加参数校验确保分页参数有效 // 添加参数校验确保分页参数有效
if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) {
queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE);
@ -609,22 +604,22 @@ public class CmsContentServiceImpl implements CmsContentService {
if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) {
queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE);
} }
// 计算偏移量 // 计算偏移量
int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize();
int limit = queryDto.getPageSize(); int limit = queryDto.getPageSize();
// 查询数据 // 查询数据
List<CmsContent> list = this.cmsContentMapper.getPageListByUserPurchases(userId, offset, limit); List<CmsContent> list = this.cmsContentMapper.getPageListByUserPurchases(userId, offset, limit);
int total = this.cmsContentMapper.getPageListByUserPurchasesCount(userId); int total = this.cmsContentMapper.getPageListByUserPurchasesCount(userId);
// 构建分页结果 // 构建分页结果
PageInfo<CmsContent> pageInfo = new PageInfo<>(list); PageInfo<CmsContent> pageInfo = new PageInfo<>(list);
pageInfo.setTotal(total); pageInfo.setTotal(total);
pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageNum(queryDto.getPageNum());
pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPageSize(queryDto.getPageSize());
pageInfo.setPages((total + limit - 1) / limit); pageInfo.setPages((total + limit - 1) / limit);
return pageInfo; return pageInfo;
} }
@ -632,7 +627,7 @@ public class CmsContentServiceImpl implements CmsContentService {
public PageInfo<CmsContent> getPageListByUserCreated(CmsContentDto queryDto) { public PageInfo<CmsContent> getPageListByUserCreated(CmsContentDto queryDto) {
// 获取当前登录用户ID // 获取当前登录用户ID
Long userId = Long.parseLong(StpUtil.getLoginId().toString()); Long userId = Long.parseLong(StpUtil.getLoginId().toString());
// 添加参数校验确保分页参数有效 // 添加参数校验确保分页参数有效
if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) { if (queryDto.getPageNum() == null || queryDto.getPageNum() < 1) {
queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE); queryDto.setPageNum(BaseQueryDto.DEFAULT_CURRENT_PAGE);
@ -640,22 +635,22 @@ public class CmsContentServiceImpl implements CmsContentService {
if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) { if (queryDto.getPageSize() == null || queryDto.getPageSize() < 1) {
queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE); queryDto.setPageSize(BaseQueryDto.DEFAULT_PAGE_SIZE);
} }
// 计算偏移量 // 计算偏移量
int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize(); int offset = (queryDto.getPageNum() - 1) * queryDto.getPageSize();
int limit = queryDto.getPageSize(); int limit = queryDto.getPageSize();
// 查询数据 // 查询数据
List<CmsContent> list = this.cmsContentMapper.getPageListByUserCreated(userId, queryDto.getPublishStatus(), offset, limit); List<CmsContent> list = this.cmsContentMapper.getPageListByUserCreated(userId, queryDto.getPublishStatus(), offset, limit);
int total = this.cmsContentMapper.getPageListByUserCreatedCount(userId, queryDto.getPublishStatus()); int total = this.cmsContentMapper.getPageListByUserCreatedCount(userId, queryDto.getPublishStatus());
// 构建分页结果 // 构建分页结果
PageInfo<CmsContent> pageInfo = new PageInfo<>(list); PageInfo<CmsContent> pageInfo = new PageInfo<>(list);
pageInfo.setTotal(total); pageInfo.setTotal(total);
pageInfo.setPageNum(queryDto.getPageNum()); pageInfo.setPageNum(queryDto.getPageNum());
pageInfo.setPageSize(queryDto.getPageSize()); pageInfo.setPageSize(queryDto.getPageSize());
pageInfo.setPages((total + limit - 1) / limit); pageInfo.setPages((total + limit - 1) / limit);
return pageInfo; return pageInfo;
} }
@ -663,20 +658,20 @@ public class CmsContentServiceImpl implements CmsContentService {
public int importFromExcel(byte[] fileBytes, String createBy) { public int importFromExcel(byte[] fileBytes, String createBy) {
int successCount = 0; int successCount = 0;
final int BATCH_SIZE = 10; // 批量处理大小 final int BATCH_SIZE = 10; // 批量处理大小
try (InputStream inputStream = new ByteArrayInputStream(fileBytes); try (InputStream inputStream = new ByteArrayInputStream(fileBytes);
ExcelReader reader = ExcelUtil.getReader(inputStream)) { ExcelReader reader = ExcelUtil.getReader(inputStream)) {
// 获取总行数包括标题行 // 获取总行数包括标题行
int totalRows = reader.getRowCount(); int totalRows = reader.getRowCount();
if (totalRows <= 1) { if (totalRows <= 1) {
// 只有标题行或空文件 // 只有标题行或空文件
return 0; return 0;
} }
Date now = new Date(); Date now = new Date();
List<CmsContent> batchList = new ArrayList<>(BATCH_SIZE); List<CmsContent> batchList = new ArrayList<>(BATCH_SIZE);
// 读取标题行 // 读取标题行
List<Object> headerObjList = reader.readRow(0); List<Object> headerObjList = reader.readRow(0);
if (headerObjList == null || headerObjList.isEmpty()) { if (headerObjList == null || headerObjList.isEmpty()) {
@ -687,7 +682,7 @@ public class CmsContentServiceImpl implements CmsContentService {
for (Object obj : headerObjList) { for (Object obj : headerObjList) {
headerList.add(obj != null ? obj.toString() : ""); headerList.add(obj != null ? obj.toString() : "");
} }
// 从第二行开始读取数据第一行为标题行 // 从第二行开始读取数据第一行为标题行
for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) { for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) {
try { try {
@ -695,7 +690,7 @@ public class CmsContentServiceImpl implements CmsContentService {
if (rowList == null || rowList.isEmpty()) { if (rowList == null || rowList.isEmpty()) {
continue; continue;
} }
// 转换为Map<String, Object> // 转换为Map<String, Object>
Map<String, Object> row = new HashMap<>(); Map<String, Object> row = new HashMap<>();
@ -712,13 +707,13 @@ public class CmsContentServiceImpl implements CmsContentService {
System.out.println("映射后的数据:" + row); System.out.println("映射后的数据:" + row);
CmsContent cmsContent = new CmsContent(); CmsContent cmsContent = new CmsContent();
// 设置创建时间和更新时间 // 设置创建时间和更新时间
cmsContent.setCreateTime(now); cmsContent.setCreateTime(now);
cmsContent.setUpdateTime(now); cmsContent.setUpdateTime(now);
cmsContent.setCreateBy(createBy); cmsContent.setCreateBy(createBy);
cmsContent.setUpdateBy(createBy); cmsContent.setUpdateBy(createBy);
// 设置默认值 // 设置默认值
cmsContent.setDeleteFlag(0); cmsContent.setDeleteFlag(0);
cmsContent.setAuditStatus(1); // 默认草稿状态 cmsContent.setAuditStatus(1); // 默认草稿状态
@ -728,13 +723,13 @@ public class CmsContentServiceImpl implements CmsContentService {
cmsContent.setCommentCount(0); cmsContent.setCommentCount(0);
cmsContent.setSort(0); cmsContent.setSort(0);
cmsContent.setIsOfficial(true); // 固定设置为1 cmsContent.setIsOfficial(true); // 固定设置为1
// 读取Excel中的字段 // 读取Excel中的字段
// content_id - 数据库自动生成不需要读取 // content_id - 数据库自动生成不需要读取
cmsContent.setTitle(getStringValue(row, "title")); cmsContent.setTitle(getStringValue(row, "title"));
cmsContent.setTitleEn(getStringValue(row, "title_en")); cmsContent.setTitleEn(getStringValue(row, "title_en"));
cmsContent.setOrigin(getStringValue(row, "origin")); cmsContent.setOrigin(getStringValue(row, "origin"));
// 处理tags字段避免NullPointerException // 处理tags字段避免NullPointerException
String tags = getStringValue(row, "tags"); String tags = getStringValue(row, "tags");
if (tags == null) { if (tags == null) {
@ -742,7 +737,7 @@ public class CmsContentServiceImpl implements CmsContentService {
continue; continue;
} }
cmsContent.setTags(tags.replaceAll(" ","")); cmsContent.setTags(tags.replaceAll(" ",""));
cmsContent.setIcon(getStringValue(row, "icon")); cmsContent.setIcon(getStringValue(row, "icon"));
cmsContent.setIsOfficial(true); // 固定设置为1 cmsContent.setIsOfficial(true); // 固定设置为1
cmsContent.setPrice(getBigDecimalValue(row, "price")); cmsContent.setPrice(getBigDecimalValue(row, "price"));
@ -765,9 +760,9 @@ public class CmsContentServiceImpl implements CmsContentService {
// create_time - 由系统生成不需要读取 // create_time - 由系统生成不需要读取
// update_time - 由系统生成不需要读取 // update_time - 由系统生成不需要读取
cmsContent.setDeleteFlag(getIntegerValue(row, "delete_flag")); cmsContent.setDeleteFlag(getIntegerValue(row, "delete_flag"));
batchList.add(cmsContent); batchList.add(cmsContent);
// 达到批量大小或最后一行时执行批量插入 // 达到批量大小或最后一行时执行批量插入
if (batchList.size() >= BATCH_SIZE) { if (batchList.size() >= BATCH_SIZE) {
if (!batchList.isEmpty()) { if (!batchList.isEmpty()) {
@ -784,7 +779,7 @@ public class CmsContentServiceImpl implements CmsContentService {
continue; continue;
} }
} }
// 处理最后一批不足BATCH_SIZE的数据 // 处理最后一批不足BATCH_SIZE的数据
if (!batchList.isEmpty()) { if (!batchList.isEmpty()) {
this.cmsContentMapper.batchInsert(batchList); this.cmsContentMapper.batchInsert(batchList);
@ -794,10 +789,10 @@ public class CmsContentServiceImpl implements CmsContentService {
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
return successCount; return successCount;
} }
/** /**
* 从Map中获取字符串值 * 从Map中获取字符串值
*/ */
@ -805,7 +800,7 @@ public class CmsContentServiceImpl implements CmsContentService {
Object value = row.get(key); Object value = row.get(key);
return value != null ? value.toString() : null; return value != null ? value.toString() : null;
} }
/** /**
* 从Map中获取布尔值 * 从Map中获取布尔值
*/ */
@ -822,7 +817,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
return null; return null;
} }
/** /**
* 从Map中获取整数值 * 从Map中获取整数值
*/ */
@ -843,7 +838,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
return null; return null;
} }
/** /**
* 从Map中获取BigDecimal值 * 从Map中获取BigDecimal值
*/ */
@ -864,7 +859,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
return null; return null;
} }
/** /**
* 从Map中获取日期值 * 从Map中获取日期值
*/ */
@ -878,7 +873,7 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
return null; return null;
} }
@Override @Override
public String getContent(Long contentId, Integer languageType) { public String getContent(Long contentId, Integer languageType) {
Assert.notNull(contentId, "内容ID不能为空"); Assert.notNull(contentId, "内容ID不能为空");
@ -886,7 +881,7 @@ public class CmsContentServiceImpl implements CmsContentService {
if (cmsContent == null) { if (cmsContent == null) {
return null; return null;
} }
// 当languageType为1时返回contentEn否则返回content // 当languageType为1时返回contentEn否则返回content
if (languageType != null && languageType == 1) { if (languageType != null && languageType == 1) {
return cmsContent.getContentEn(); return cmsContent.getContentEn();
@ -894,7 +889,7 @@ public class CmsContentServiceImpl implements CmsContentService {
return cmsContent.getContent(); return cmsContent.getContent();
} }
} }
@Override @Override
public String getTitle(Long contentId) { public String getTitle(Long contentId) {
Assert.notNull(contentId, "内容ID不能为空"); Assert.notNull(contentId, "内容ID不能为空");
@ -904,11 +899,11 @@ public class CmsContentServiceImpl implements CmsContentService {
} }
return cmsContent.getTitle(); return cmsContent.getTitle();
} }
@Override @Override
public int importFromPath(ImportPathDto importPathDto, String createBy) { public int importFromPath(ImportPathDto importPathDto, String createBy) {
int totalSuccessCount = 0; int totalSuccessCount = 0;
try { try {
// 检查目录是否存在 // 检查目录是否存在
File directory = new File(importPathDto.getFilePath()); File directory = new File(importPathDto.getFilePath());
@ -916,12 +911,12 @@ public class CmsContentServiceImpl implements CmsContentService {
System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath()); System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath());
return 0; return 0;
} }
// 如果不是追加模式清空表 // 如果不是追加模式清空表
if (!importPathDto.isAppend()) { if (!importPathDto.isAppend()) {
cmsContentMapper.truncateTable(); cmsContentMapper.truncateTable();
} }
// 读取目录下所有 Excel 文件排除 Office 临时锁定文件 // 读取目录下所有 Excel 文件排除 Office 临时锁定文件
File[] files = directory.listFiles((dir, name) -> { File[] files = directory.listFiles((dir, name) -> {
// 跳过以 ~$ 开头的临时锁定文件 // 跳过以 ~$ 开头的临时锁定文件
@ -933,11 +928,11 @@ public class CmsContentServiceImpl implements CmsContentService {
if (files == null || files.length == 0) { if (files == null || files.length == 0) {
return 0; return 0;
} }
// 记录文件总数 // 记录文件总数
int totalFiles = files.length; int totalFiles = files.length;
System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要导入"); System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要导入");
// 遍历所有 Excel 文件并导入 // 遍历所有 Excel 文件并导入
for (int i = 0; i < files.length; i++) { for (int i = 0; i < files.length; i++) {
File file = files[i]; File file = files[i];
@ -946,7 +941,7 @@ public class CmsContentServiceImpl implements CmsContentService {
// 读取文件内容到字节数组 // 读取文件内容到字节数组
byte[] fileBytes = new byte[(int) file.length()]; byte[] fileBytes = new byte[(int) file.length()];
fis.read(fileBytes); fis.read(fileBytes);
// 调用现有的 importFromExcel 方法进行导入 // 调用现有的 importFromExcel 方法进行导入
int successCount = importFromExcel(fileBytes, createBy); int successCount = importFromExcel(fileBytes, createBy);
totalSuccessCount += successCount; totalSuccessCount += successCount;
@ -963,7 +958,7 @@ public class CmsContentServiceImpl implements CmsContentService {
System.err.println("导入操作失败: " + importPathDto.getFilePath()); System.err.println("导入操作失败: " + importPathDto.getFilePath());
e.printStackTrace(); e.printStackTrace();
} }
return totalSuccessCount; return totalSuccessCount;
} }
@ -971,34 +966,34 @@ public class CmsContentServiceImpl implements CmsContentService {
public int importFromZip(byte[] zipFileBytes, String createBy) { public int importFromZip(byte[] zipFileBytes, String createBy) {
int totalSuccessCount = 0; int totalSuccessCount = 0;
int totalFiles = 0; int totalFiles = 0;
try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipFileBytes))) { try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(zipFileBytes))) {
ZipEntry entry; ZipEntry entry;
// 遍历ZIP文件中的所有条目 // 遍历ZIP文件中的所有条目
while ((entry = zipInputStream.getNextEntry()) != null) { while ((entry = zipInputStream.getNextEntry()) != null) {
String fileName = entry.getName(); String fileName = entry.getName();
// 跳过目录和非Excel文件 // 跳过目录和非Excel文件
if (entry.isDirectory() || (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx"))) { if (entry.isDirectory() || (!fileName.endsWith(".xls") && !fileName.endsWith(".xlsx"))) {
zipInputStream.closeEntry(); zipInputStream.closeEntry();
continue; continue;
} }
// 跳过Office临时锁定文件 // 跳过Office临时锁定文件
String simpleName = new File(fileName).getName(); String simpleName = new File(fileName).getName();
if (simpleName.startsWith("~$")) { if (simpleName.startsWith("~$")) {
zipInputStream.closeEntry(); zipInputStream.closeEntry();
continue; continue;
} }
totalFiles++; totalFiles++;
System.out.println("当前处理第 " + totalFiles + " 个文件,文件名称是:" + fileName); System.out.println("当前处理第 " + totalFiles + " 个文件,文件名称是:" + fileName);
try { try {
// 读取Excel文件内容到字节数组 // 读取Excel文件内容到字节数组
byte[] fileBytes = zipInputStream.readAllBytes(); byte[] fileBytes = zipInputStream.readAllBytes();
// 调用现有的 importFromExcel 方法进行导入 // 调用现有的 importFromExcel 方法进行导入
int successCount = importFromExcel(fileBytes, createBy); int successCount = importFromExcel(fileBytes, createBy);
totalSuccessCount += successCount; totalSuccessCount += successCount;
@ -1011,20 +1006,20 @@ public class CmsContentServiceImpl implements CmsContentService {
zipInputStream.closeEntry(); zipInputStream.closeEntry();
} }
} }
System.out.println("导入完成,共处理 " + totalFiles + " 个文件,成功导入 " + totalSuccessCount + " 条记录"); System.out.println("导入完成,共处理 " + totalFiles + " 个文件,成功导入 " + totalSuccessCount + " 条记录");
} catch (Exception e) { } catch (Exception e) {
System.err.println("ZIP文件导入操作失败"); System.err.println("ZIP文件导入操作失败");
e.printStackTrace(); e.printStackTrace();
} }
return totalSuccessCount; return totalSuccessCount;
} }
@Override @Override
public int updateFromPath(ImportPathDto importPathDto, String updateBy) { public int updateFromPath(ImportPathDto importPathDto, String updateBy) {
int totalSuccessCount = 0; int totalSuccessCount = 0;
try { try {
// 检查目录是否存在 // 检查目录是否存在
File directory = new File(importPathDto.getFilePath()); File directory = new File(importPathDto.getFilePath());
@ -1032,7 +1027,7 @@ public class CmsContentServiceImpl implements CmsContentService {
System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath()); System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath());
return 0; return 0;
} }
// 读取目录下所有 Excel 文件排除 Office 临时锁定文件 // 读取目录下所有 Excel 文件排除 Office 临时锁定文件
File[] files = directory.listFiles((dir, name) -> { File[] files = directory.listFiles((dir, name) -> {
// 跳过以 ~$ 开头的临时锁定文件 // 跳过以 ~$ 开头的临时锁定文件
@ -1044,11 +1039,11 @@ public class CmsContentServiceImpl implements CmsContentService {
if (files == null || files.length == 0) { if (files == null || files.length == 0) {
return 0; return 0;
} }
// 记录文件总数 // 记录文件总数
int totalFiles = files.length; int totalFiles = files.length;
System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要处理"); System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要处理");
// 遍历所有 Excel 文件并处理 // 遍历所有 Excel 文件并处理
for (int i = 0; i < files.length; i++) { for (int i = 0; i < files.length; i++) {
File file = files[i]; File file = files[i];
@ -1057,7 +1052,7 @@ public class CmsContentServiceImpl implements CmsContentService {
// 读取文件内容到字节数组 // 读取文件内容到字节数组
byte[] fileBytes = new byte[(int) file.length()]; byte[] fileBytes = new byte[(int) file.length()];
fis.read(fileBytes); fis.read(fileBytes);
// 调用 updateFromExcel 方法进行更新 // 调用 updateFromExcel 方法进行更新
int successCount = updateFromExcel(fileBytes, updateBy); int successCount = updateFromExcel(fileBytes, updateBy);
totalSuccessCount += successCount; totalSuccessCount += successCount;
@ -1074,10 +1069,10 @@ public class CmsContentServiceImpl implements CmsContentService {
System.err.println("更新操作失败: " + importPathDto.getFilePath()); System.err.println("更新操作失败: " + importPathDto.getFilePath());
e.printStackTrace(); e.printStackTrace();
} }
return totalSuccessCount; return totalSuccessCount;
} }
/** /**
* 从Excel文件读取数据并更新CmsContent * 从Excel文件读取数据并更新CmsContent
* *
@ -1087,19 +1082,19 @@ public class CmsContentServiceImpl implements CmsContentService {
*/ */
private int updateFromExcel(byte[] fileBytes, String updateBy) { private int updateFromExcel(byte[] fileBytes, String updateBy) {
int successCount = 0; int successCount = 0;
try (InputStream inputStream = new ByteArrayInputStream(fileBytes); try (InputStream inputStream = new ByteArrayInputStream(fileBytes);
ExcelReader reader = ExcelUtil.getReader(inputStream)) { ExcelReader reader = ExcelUtil.getReader(inputStream)) {
// 获取总行数包括标题行 // 获取总行数包括标题行
int totalRows = reader.getRowCount(); int totalRows = reader.getRowCount();
if (totalRows <= 1) { if (totalRows <= 1) {
// 只有标题行或空文件 // 只有标题行或空文件
return 0; return 0;
} }
Date now = new Date(); Date now = new Date();
// 读取标题行 // 读取标题行
List<Object> headerObjList = reader.readRow(0); List<Object> headerObjList = reader.readRow(0);
if (headerObjList == null || headerObjList.isEmpty()) { if (headerObjList == null || headerObjList.isEmpty()) {
@ -1110,7 +1105,7 @@ public class CmsContentServiceImpl implements CmsContentService {
for (Object obj : headerObjList) { for (Object obj : headerObjList) {
headerList.add(obj != null ? obj.toString() : ""); headerList.add(obj != null ? obj.toString() : "");
} }
// 从第二行开始读取数据第一行为标题行 // 从第二行开始读取数据第一行为标题行
for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) { for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) {
try { try {
@ -1118,7 +1113,7 @@ public class CmsContentServiceImpl implements CmsContentService {
if (rowList == null || rowList.isEmpty()) { if (rowList == null || rowList.isEmpty()) {
continue; continue;
} }
// 转换为Map<String, Object> // 转换为Map<String, Object>
Map<String, Object> row = new HashMap<>(); Map<String, Object> row = new HashMap<>();
for (int i = 0; i < headerList.size() && i < rowList.size(); i++) { for (int i = 0; i < headerList.size() && i < rowList.size(); i++) {
@ -1127,7 +1122,7 @@ public class CmsContentServiceImpl implements CmsContentService {
if (row.isEmpty()) { if (row.isEmpty()) {
continue; continue;
} }
// 读取关键字段用于查询 // 读取关键字段用于查询
String title = getStringValue(row, "title"); String title = getStringValue(row, "title");
String origin = getStringValue(row, "origin"); String origin = getStringValue(row, "origin");
@ -1137,7 +1132,7 @@ public class CmsContentServiceImpl implements CmsContentService {
tags = tags.replaceAll(" ", ""); tags = tags.replaceAll(" ", "");
} }
String icon = getStringValue(row, "icon"); String icon = getStringValue(row, "icon");
// 根据关键字段查询记录 // 根据关键字段查询记录
CmsContentDto queryDto = new CmsContentDto(); CmsContentDto queryDto = new CmsContentDto();
queryDto.setTitle(title); queryDto.setTitle(title);
@ -1145,12 +1140,12 @@ public class CmsContentServiceImpl implements CmsContentService {
// queryDto.setTags(tags); // queryDto.setTags(tags);
// queryDto.setIcon(icon); // queryDto.setIcon(icon);
queryDto.setDeleteFlag(0); queryDto.setDeleteFlag(0);
List<CmsContent> existingList = cmsContentMapper.getList(queryDto); List<CmsContent> existingList = cmsContentMapper.getList(queryDto);
if (existingList != null && !existingList.isEmpty()) { if (existingList != null && !existingList.isEmpty()) {
// 找到匹配的记录进行更新 // 找到匹配的记录进行更新
CmsContent existingContent = existingList.get(0); CmsContent existingContent = existingList.get(0);
// 更新字段 // 更新字段
existingContent.setTitle(getStringValue(row, "title")); existingContent.setTitle(getStringValue(row, "title"));
existingContent.setTitleEn(getStringValue(row, "title_en")); existingContent.setTitleEn(getStringValue(row, "title_en"));
@ -1172,11 +1167,11 @@ public class CmsContentServiceImpl implements CmsContentService {
existingContent.setDescriptionEn(getStringValue(row, "description_en")); existingContent.setDescriptionEn(getStringValue(row, "description_en"));
existingContent.setIntroduce(getStringValue(row, "introduce")); existingContent.setIntroduce(getStringValue(row, "introduce"));
existingContent.setIntroduceEn(getStringValue(row, "introduce_en")); existingContent.setIntroduceEn(getStringValue(row, "introduce_en"));
// 设置更新时间和更新人 // 设置更新时间和更新人
existingContent.setUpdateTime(now); existingContent.setUpdateTime(now);
existingContent.setUpdateBy(updateBy); existingContent.setUpdateBy(updateBy);
// 执行更新 // 执行更新
cmsContentMapper.update(existingContent); cmsContentMapper.update(existingContent);
successCount++; successCount++;
@ -1191,9 +1186,9 @@ public class CmsContentServiceImpl implements CmsContentService {
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
} }
return successCount; return successCount;
} }
} }

View File

@ -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<ExchangeCode> getPageList(ExchangeCodeQueryDto queryDto) {
int pageNum = queryDto.getPageNum() != null ? queryDto.getPageNum() : 1;
int pageSize = queryDto.getPageSize() != null ? queryDto.getPageSize() : 20;
PageHelper.startPage(pageNum, pageSize);
List<ExchangeCode> list = exchangeCodeMapper.getPageList(buildQueryEntity(queryDto));
return new PageInfo<>(list);
}
@Override
public List<ExchangeCode> 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<String, Object> exchange(ExchangeRequestDto requestDto) {
String code = requestDto.getCode();
Long userId = requestDto.getUserId();
log.info("[兑换码兑换] 开始处理兑换请求, code={}, userId={}", code, userId);
Map<String, Object> 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<String, Object> importExcel(MultipartFile file) {
log.info("[兑换码导入] 开始导入Excel文件, fileName={}, size={}bytes", file.getOriginalFilename(), file.getSize());
Map<String, Object> result = new HashMap<>();
int successCount = 0;
int failCount = 0;
List<String> failMessages = new ArrayList<>();
try (ExcelReader reader = ExcelUtil.getReader(file.getInputStream())) {
List<String> 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<List<Object>> rows = reader.read();
if (rows == null || rows.isEmpty()) {
log.info("[兑换码导入] sheet为空, sheetName={}", sheetName);
continue;
}
List<ExchangeCode> codeList = new ArrayList<>();
Date now = new Date();
for (int i = 0; i < rows.size(); i++) {
List<Object> 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<ExchangeCode> 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<String, Integer> getStatistics() {
Map<String, Integer> statistics = new HashMap<>();
statistics.put("total", 0);
statistics.put("unused", 0);
statistics.put("used", 0);
statistics.put("expired", 0);
List<ExchangeCode> 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<PackageConfig> 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));
}
}

View File

@ -428,7 +428,7 @@ public class SkillGenServiceImpl implements SkillGenService {
log.info("根据技能描述生成技能介绍请求: {}", description); log.info("根据技能描述生成技能介绍请求: {}", description);
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions"; 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"); SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, description, 0.3, 500, "text");
String deepseekResponse = ""; String deepseekResponse = "";
try { try {

View File

@ -109,7 +109,7 @@
<if test="updateBy != null">update_by = #{updateBy},</if> <if test="updateBy != null">update_by = #{updateBy},</if>
<if test="deleteFlag != null">delete_flag = #{deleteFlag},</if> <if test="deleteFlag != null">delete_flag = #{deleteFlag},</if>
</set> </set>
where account_id = #{accountId} where user_id = #{userId}
</update> </update>
<!--更新账户余额--> <!--更新账户余额-->

View File

@ -0,0 +1,164 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.kexue.skills.mapper.ExchangeCodeMapper">
<resultMap type="com.kexue.skills.entity.ExchangeCode" id="ExchangeCodeMap">
<result property="id" column="id" jdbcType="BIGINT"/>
<result property="code" column="code" jdbcType="VARCHAR"/>
<result property="packagePrice" column="package_price" jdbcType="DECIMAL"/>
<result property="packageId" column="package_id" jdbcType="BIGINT"/>
<result property="status" column="status" jdbcType="INTEGER"/>
<result property="usedUserId" column="used_user_id" jdbcType="BIGINT"/>
<result property="usedTime" column="used_time" jdbcType="TIMESTAMP"/>
<result property="expireTime" column="expire_time" jdbcType="TIMESTAMP"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>
<result property="updateTime" column="update_time" jdbcType="TIMESTAMP"/>
<result property="createBy" column="create_by" jdbcType="VARCHAR"/>
<result property="updateBy" column="update_by" jdbcType="VARCHAR"/>
<result property="deleteFlag" column="delete_flag" jdbcType="INTEGER"/>
</resultMap>
<select id="queryById" resultMap="ExchangeCodeMap">
select
id, code, package_price, package_id, status, used_user_id, used_time,
expire_time, create_time, update_time, create_by, update_by, delete_flag
from exchange_code
where id = #{id} and delete_flag = 0
</select>
<select id="queryByCode" resultMap="ExchangeCodeMap">
select
id, code, package_price, package_id, status, used_user_id, used_time,
expire_time, create_time, update_time, create_by, update_by, delete_flag
from exchange_code
where code = #{code} and delete_flag = 0
</select>
<select id="getPageList" resultMap="ExchangeCodeMap">
select
id, code, package_price, package_id, status, used_user_id, used_time,
expire_time, create_time, update_time, create_by, update_by, delete_flag
from exchange_code
where delete_flag = 0
<if test="code != null and code != ''">
and code like concat('%', #{code}, '%')
</if>
<if test="packagePrice != null">
and package_price = #{packagePrice}
</if>
<if test="status != null">
and status = #{status}
</if>
order by create_time desc
</select>
<select id="getList" resultMap="ExchangeCodeMap">
select
id, code, package_price, package_id, status, used_user_id, used_time,
expire_time, create_time, update_time, create_by, update_by, delete_flag
from exchange_code
where delete_flag = 0
<if test="code != null and code != ''">
and code like concat('%', #{code}, '%')
</if>
<if test="packagePrice != null">
and package_price = #{packagePrice}
</if>
<if test="status != null">
and status = #{status}
</if>
order by create_time desc
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
insert into exchange_code
<trim prefix="(" suffix=")" suffixOverrides=",">
<if test="code != null">code,</if>
<if test="packagePrice != null">package_price,</if>
<if test="packageId != null">package_id,</if>
<if test="status != null">status,</if>
<if test="expireTime != null">expire_time,</if>
<if test="createTime != null">create_time,</if>
<if test="updateTime != null">update_time,</if>
<if test="createBy != null">create_by,</if>
<if test="updateBy != null">update_by,</if>
<if test="deleteFlag != null">delete_flag,</if>
</trim>
<trim prefix="values (" suffix=")" suffixOverrides=",">
<if test="code != null">#{code},</if>
<if test="packagePrice != null">#{packagePrice},</if>
<if test="packageId != null">#{packageId},</if>
<if test="status != null">#{status},</if>
<if test="expireTime != null">#{expireTime},</if>
<if test="createTime != null">#{createTime},</if>
<if test="updateTime != null">#{updateTime},</if>
<if test="createBy != null">#{createBy},</if>
<if test="updateBy != null">#{updateBy},</if>
<if test="deleteFlag != null">#{deleteFlag},</if>
</trim>
</insert>
<insert id="batchInsert">
insert into exchange_code (code, package_price, package_id, status, expire_time, create_time, update_time, delete_flag)
values
<foreach collection="list" item="item" separator=",">
(#{item.code}, #{item.packagePrice}, #{item.packageId}, #{item.status}, #{item.expireTime}, #{item.createTime}, #{item.updateTime}, #{item.deleteFlag})
</foreach>
</insert>
<update id="update">
update exchange_code
<set>
<if test="code != null">code = #{code},</if>
<if test="packagePrice != null">package_price = #{packagePrice},</if>
<if test="packageId != null">package_id = #{packageId},</if>
<if test="status != null">status = #{status},</if>
<if test="usedUserId != null">used_user_id = #{usedUserId},</if>
<if test="usedTime != null">used_time = #{usedTime},</if>
<if test="expireTime != null">expire_time = #{expireTime},</if>
<if test="updateTime != null">update_time = #{updateTime},</if>
<if test="updateBy != null">update_by = #{updateBy},</if>
</set>
where id = #{id}
</update>
<update id="useCode">
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
</update>
<delete id="deleteById">
delete from exchange_code where id = #{id}
</delete>
<update id="logicDeleteById">
update exchange_code
set delete_flag = 1,
update_time = now(),
update_by = #{updateBy}
where id = #{id}
</update>
<select id="countByStatus" resultType="com.kexue.skills.entity.ExchangeCode">
select
status,
count(*) as total
from exchange_code
where delete_flag = 0
group by status
</select>
<select id="countAvailableByPrice" resultType="java.lang.Integer">
select count(*)
from exchange_code
where package_price = #{packagePrice}
and status = 0
and delete_flag = 0
and (expire_time is null or expire_time > now())
</select>
</mapper>

View File

@ -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);

View File

@ -0,0 +1,579 @@
# 账户交易记录查询接口文档
## 概述
本文档描述了账户交易记录查询相关的三个接口,包括充值记录、消费记录和赠送记录的查询功能。
**基础信息:**
- 基础路径:`/api/account`
- 请求方式:全部使用 POST
- 认证要求:所有接口都需要登录认证(@RequireAuth
- 响应格式:统一使用 `CommonResult<T>` 包装
---
## 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<AccountTransaction>>`
**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<ConsumptionGroupedDto>>`
**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<AccountTransaction>>`
**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 | 王志维 | 初始版本,创建三个查询接口 |
---
## 联系方式
如有问题,请联系开发团队。

View File

@ -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: 引入积分单位和扣费系数,完整说明当前逻辑