Update backend code with new features and bug fixes
This commit is contained in:
parent
a8faaebc79
commit
394459e7c4
339
LOG_ANNOTATION_GUIDE.md
Normal file
339
LOG_ANNOTATION_GUIDE.md
Normal 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
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
70
src/main/java/com/kexue/skills/entity/ExchangeCode.java
Normal file
70
src/main/java/com/kexue/skills/entity/ExchangeCode.java
Normal 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;
|
||||
}
|
||||
@ -1,28 +1,26 @@
|
||||
package com.kexue.skills.entity.base;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 基础查询DTO
|
||||
*/
|
||||
@Data
|
||||
@ApiModel
|
||||
public class BaseQueryDto implements Serializable {
|
||||
@Schema(description = "基础查询参数")
|
||||
public class BaseQueryDto {
|
||||
|
||||
public static final Integer DEFAULT_PAGE_SIZE = 10;
|
||||
public static final Integer DEFAULT_PAGE_SIZE = 12;
|
||||
public static final Integer DEFAULT_CURRENT_PAGE = 1;
|
||||
@Schema(description = "页码,默认1")
|
||||
private Integer pageNum = 1;
|
||||
|
||||
@Schema(description ="当前页")
|
||||
private Integer pageNum;
|
||||
@Schema(description = "每页数量,默认20")
|
||||
private Integer pageSize = 20;
|
||||
|
||||
@Schema(description ="每页数量")
|
||||
private Integer pageSize;
|
||||
|
||||
@Schema(description ="排序字段")
|
||||
@Schema(description = "排序字段")
|
||||
private String sortBy;
|
||||
|
||||
@Schema(description ="是否降序排序")
|
||||
private Boolean sortDesc;
|
||||
|
||||
@Schema(description = "是否降序")
|
||||
private Boolean sortDesc = true;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -17,6 +17,7 @@ public class GlobalExceptionHandler {
|
||||
if (e == null) {
|
||||
return CommonResult.success("未知错误");
|
||||
}
|
||||
e.printStackTrace();
|
||||
return CommonResult.success(e.getErrorCode(), e.getMessage());
|
||||
}
|
||||
|
||||
|
||||
115
src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java
Normal file
115
src/main/java/com/kexue/skills/mapper/ExchangeCodeMapper.java
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -29,6 +29,8 @@ import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import static cn.dev33.satoken.SaManager.log;
|
||||
|
||||
/**
|
||||
* 账户冻结单服务实现
|
||||
*
|
||||
@ -72,6 +74,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
* @return 冻结单信息
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AccountFrozen createFrozen(AccountFrozenDto accountFrozenDto) {
|
||||
// 1. 验证创建冻结单参数
|
||||
validateCreateFrozenParams(accountFrozenDto);
|
||||
@ -189,24 +192,67 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
* @return 冻结金额(单位:积分)
|
||||
*/
|
||||
private BigDecimal calculateTokenFrozenAmount(AccountFrozenDto accountFrozenDto) {
|
||||
LOG.debug("开始计算token类型冻结金额 - sessionId: {}, modelName: {}, estimatedInputTokens: {}, estimatedOutputTokens: {}",
|
||||
accountFrozenDto.getSessionId(), accountFrozenDto.getModelName(),
|
||||
accountFrozenDto.getEstimatedInputTokens(), accountFrozenDto.getEstimatedOutputTokens());
|
||||
|
||||
// 1. 验证必要参数
|
||||
if (accountFrozenDto.getEstimatedInputTokens() == null ||
|
||||
accountFrozenDto.getEstimatedOutputTokens() == null ||
|
||||
accountFrozenDto.getModelName() == null) {
|
||||
LOG.warn("token冻结金额计算缺少必要参数,返回原始冻结金额 - sessionId: {}, frozenAmount: {}",
|
||||
accountFrozenDto.getSessionId(), accountFrozenDto.getFrozenAmount());
|
||||
return accountFrozenDto.getFrozenAmount();
|
||||
}
|
||||
|
||||
// 2. 标准化模型名称
|
||||
String modelName = normalizeModelName(accountFrozenDto.getModelName());
|
||||
LOG.debug("标准化模型名称 - 原始名称: {}, 标准化后: {}", accountFrozenDto.getModelName(), modelName);
|
||||
|
||||
// 3. 获取模型价格列表
|
||||
List<ModelPrice> modelPriceList = getModelPriceList(modelName);
|
||||
LOG.debug("获取模型价格列表 - modelName: {}, 价格规则数量: {}", modelName, modelPriceList.size());
|
||||
|
||||
if (modelPriceList.isEmpty()) {
|
||||
LOG.warn("模型价格列表为空,请检查模型价格表: {}", modelName);
|
||||
return accountFrozenDto.getFrozenAmount();
|
||||
}
|
||||
|
||||
// 4. 查找输入模型价格
|
||||
ModelPrice inputModelPrice = findInputModelPrice(modelPriceList, accountFrozenDto.getEstimatedInputTokens());
|
||||
LOG.debug("查找输入模型价格 - estimatedInputTokens: {}, 匹配结果: {}",
|
||||
accountFrozenDto.getEstimatedInputTokens(),
|
||||
inputModelPrice != null ? "inputPerCent=" + inputModelPrice.getInputPerCent() : "未找到匹配");
|
||||
|
||||
// 5. 查找输出模型价格
|
||||
ModelPrice outputModelPrice = findOutputModelPrice(modelPriceList, accountFrozenDto.getEstimatedOutputTokens());
|
||||
LOG.debug("查找输出模型价格 - estimatedOutputTokens: {}, 匹配结果: {}",
|
||||
accountFrozenDto.getEstimatedOutputTokens(),
|
||||
outputModelPrice != null ? "outputPerCent=" + outputModelPrice.getOutputPerCent() : "未找到匹配");
|
||||
|
||||
if (inputModelPrice == null || outputModelPrice == null) {
|
||||
LOG.warn("未找到匹配的模型价格规则 - inputModelPrice: {}, outputModelPrice: {}",
|
||||
inputModelPrice != null ? "已找到" : "未找到",
|
||||
outputModelPrice != null ? "已找到" : "未找到");
|
||||
return accountFrozenDto.getFrozenAmount();
|
||||
}
|
||||
|
||||
// 6. 计算总的token费用
|
||||
long totalFee = calculateTotalTokenFee(accountFrozenDto, inputModelPrice, outputModelPrice);
|
||||
LOG.debug("计算token总费用 - inputFee: {}, outputFee: {}, totalFee: {}",
|
||||
calculateTokenFee(accountFrozenDto.getEstimatedInputTokens(), inputModelPrice.getInputPerCent()),
|
||||
calculateTokenFee(accountFrozenDto.getEstimatedOutputTokens(), outputModelPrice.getOutputPerCent()),
|
||||
totalFee);
|
||||
|
||||
// 7. 应用扣费系数
|
||||
BigDecimal baseAmount = BigDecimal.valueOf(totalFee);
|
||||
return baseAmount.multiply(accountDeductionProperties.getCoefficient());
|
||||
BigDecimal coefficient = accountDeductionProperties.getCoefficient();
|
||||
BigDecimal finalAmount = baseAmount.multiply(coefficient);
|
||||
|
||||
LOG.info("token冻结金额计算完成 - sessionId: {}, 基础金额: {}分, 扣费系数: {}, 最终冻结金额: {}积分",
|
||||
accountFrozenDto.getSessionId(), totalFee, coefficient, finalAmount);
|
||||
|
||||
return finalAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -380,6 +426,7 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
* @return 冻结单信息
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AccountFrozen releaseFrozen(AccountReleaseDto accountReleaseDto) {
|
||||
// 1. 验证释放冻结单参数
|
||||
validateReleaseParams(accountReleaseDto);
|
||||
@ -490,7 +537,8 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
accountFrozen.getModelName() == null) {
|
||||
return finalAmount;
|
||||
}
|
||||
List<ModelPrice> modelPriceList = getModelPriceList(accountFrozen.getModelName());
|
||||
String modelName = normalizeModelName(accountFrozen.getModelName());
|
||||
List<ModelPrice> modelPriceList = getModelPriceList(modelName);
|
||||
if (modelPriceList.isEmpty()) {
|
||||
return finalAmount;
|
||||
}
|
||||
@ -564,9 +612,6 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
* @param finalAmount 最终扣减金额
|
||||
*/
|
||||
private void doCreateTransaction(AccountFrozen accountFrozen, Account account, BigDecimal finalAmount) {
|
||||
if (finalAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return;
|
||||
}
|
||||
BigDecimal beforeBalance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance();
|
||||
AccountTransaction transaction = buildTransaction(accountFrozen, account, finalAmount, beforeBalance);
|
||||
accountTransactionMapper.insert(transaction);
|
||||
@ -589,7 +634,11 @@ public class AccountFrozenServiceImpl implements AccountFrozenService {
|
||||
transaction.setTransactionType(3);
|
||||
transaction.setAmount(finalAmount);
|
||||
transaction.setBeforeBalance(beforeBalance);
|
||||
transaction.setAfterBalance(account.getBalance());
|
||||
BigDecimal afterBalance = beforeBalance.subtract(finalAmount);
|
||||
if (afterBalance.compareTo(BigDecimal.ZERO) < 0) {
|
||||
afterBalance = BigDecimal.ZERO;
|
||||
}
|
||||
transaction.setAfterBalance(afterBalance);
|
||||
transaction.setStatus(1);
|
||||
transaction.setTransactionNo(IDUtils.getSnowflakeIdStr());
|
||||
transaction.setPayType(3);
|
||||
|
||||
@ -1,25 +1,23 @@
|
||||
package com.kexue.skills.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.poi.excel.ExcelReader;
|
||||
import cn.hutool.poi.excel.ExcelUtil;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.github.pagehelper.util.StringUtil;
|
||||
import com.kexue.skills.common.Assert;
|
||||
import com.kexue.skills.common.LoginUserCacheUtil;
|
||||
import com.kexue.skills.entity.CmsContent;
|
||||
import com.kexue.skills.entity.CmsContentView;
|
||||
import com.kexue.skills.entity.CmsContentLike;
|
||||
import com.kexue.skills.entity.CmsContentView;
|
||||
import com.kexue.skills.entity.base.BaseQueryDto;
|
||||
import com.kexue.skills.entity.dto.CmsContentDto;
|
||||
import com.kexue.skills.entity.request.ImportPathDto;
|
||||
import com.kexue.skills.mapper.CmsContentLikeMapper;
|
||||
import com.kexue.skills.mapper.CmsContentMapper;
|
||||
import com.kexue.skills.mapper.CmsContentViewMapper;
|
||||
import com.kexue.skills.mapper.CmsContentLikeMapper;
|
||||
import com.kexue.skills.mapper.CmsTagMapper;
|
||||
import com.kexue.skills.entity.CmsTag;
|
||||
import com.kexue.skills.entity.dto.CmsTagDto;
|
||||
import com.kexue.skills.service.CmsContentService;
|
||||
import cn.hutool.poi.excel.ExcelReader;
|
||||
import cn.hutool.poi.excel.ExcelUtil;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@ -30,12 +28,9 @@ import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
|
||||
/**
|
||||
* (CmsContent)表服务实现类
|
||||
*
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -428,7 +428,7 @@ public class SkillGenServiceImpl implements SkillGenService {
|
||||
log.info("根据技能描述生成技能介绍请求: {}", description);
|
||||
String url = deepSeekConfig.getBaseUrl() + "/v1/chat/completions";
|
||||
|
||||
String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个skill的描述,请你基于这个描述,生成一段详细的技能介绍,包括技能的作用、能够解决的问题、使用场景等,输出一段完整的描述文本";
|
||||
String systemContent = "你是一个专业的AI技能设计助手。我会给你提供一个skill的描述,请你基于这个描述,生成一段详细的技能介绍,包括技能的作用、能够解决的问题、使用场景等,输出一段完整的描述文本,请务必严格要找要求输出,绝对不允许输出与要求无关的内容";
|
||||
SkillRequest skillRequest = new SkillRequest(true, deepSeekConfig.getChat().getModel(), systemContent, description, 0.3, 500, "text");
|
||||
String deepseekResponse = "";
|
||||
try {
|
||||
|
||||
@ -109,7 +109,7 @@
|
||||
<if test="updateBy != null">update_by = #{updateBy},</if>
|
||||
<if test="deleteFlag != null">delete_flag = #{deleteFlag},</if>
|
||||
</set>
|
||||
where account_id = #{accountId}
|
||||
where user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<!--更新账户余额-->
|
||||
|
||||
164
src/main/resources/mapper/ExchangeCodeMapper.xml
Normal file
164
src/main/resources/mapper/ExchangeCodeMapper.xml
Normal 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>
|
||||
20
src/main/resources/schema/exchange_code.sql
Normal file
20
src/main/resources/schema/exchange_code.sql
Normal 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);
|
||||
579
账户交易记录查询接口文档.md
Normal file
579
账户交易记录查询接口文档.md
Normal 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 | 王志维 | 初始版本,创建三个查询接口 |
|
||||
|
||||
---
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系开发团队。
|
||||
555
账户冻结扣费计算逻辑说明.md
Normal file
555
账户冻结扣费计算逻辑说明.md
Normal 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: 引入积分单位和扣费系数,完整说明当前逻辑
|
||||
Loading…
Reference in New Issue
Block a user