From 891f60d5a5edeb67466b60ed7057e713ec9f6254 Mon Sep 17 00:00:00 2001 From: wangzhiwei Date: Tue, 21 Apr 2026 09:32:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(account):=20=E6=B7=BB=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E4=BA=A4=E6=98=93=E8=AE=B0=E5=BD=95=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增充值记录、消费记录和赠送记录的分页查询接口 - 实现消费记录按callId分组查询功能 - 添加账户交易记录DTO和分组查询DTO定义 - 在AccountController中添加日志注解和相关API端点 - 优化AccountServiceImpl中的数据库操作方法调用 - 修改AccountTransactionMapper.xml增加相应的SQL查询语句 - 添加创建时间范围筛选功能到交易记录查询中 - 实现CMS内容管理的路径更新功能和标签查询优化 --- db/alter_sys_log_table.sql | 46 ++ .../java/com/kexue/skills/annotation/Log.java | 34 ++ .../kexue/skills/config/LogConfiguration.java | 40 ++ .../skills/controller/AccountController.java | 43 ++ .../controller/AccountFrozenController.java | 2 + .../controller/CmsContentController.java | 21 + .../skills/controller/LoginController.java | 2 + .../skills/controller/SysUserController.java | 45 ++ .../com/kexue/skills/entity/LogRecord.java | 120 +++++ .../java/com/kexue/skills/entity/SysLog.java | 62 ++- .../java/com/kexue/skills/entity/SysUser.java | 3 + .../entity/dto/AccountTransactionDto.java | 6 + .../skills/entity/dto/CmsContentDto.java | 15 + .../entity/dto/ConsumptionGroupedDto.java | 101 +++++ .../skills/interceptor/CachedBodyFilter.java | 52 +++ .../CachedBodyHttpServletRequest.java | 88 ++++ .../CachedBodyHttpServletResponse.java | 94 ++++ .../skills/interceptor/LogInterceptor.java | 412 ++++++++++++++++++ .../mapper/AccountTransactionMapper.java | 33 ++ .../kexue/skills/service/AccountService.java | 27 ++ .../skills/service/CmsContentService.java | 9 + .../kexue/skills/service/SysUserService.java | 21 + .../service/impl/AccountServiceImpl.java | 193 ++++++-- .../service/impl/CmsContentServiceImpl.java | 269 ++++++++---- .../service/impl/SysUserServiceImpl.java | 78 +++- .../mapper/AccountTransactionMapper.xml | 226 ++++++++++ .../resources/mapper/CmsContentMapper.xml | 34 +- src/main/resources/mapper/SysLogMapper.xml | 192 +++++--- src/main/resources/mapper/SysUserMapper.xml | 24 +- 29 files changed, 2070 insertions(+), 222 deletions(-) create mode 100644 db/alter_sys_log_table.sql create mode 100644 src/main/java/com/kexue/skills/annotation/Log.java create mode 100644 src/main/java/com/kexue/skills/config/LogConfiguration.java create mode 100644 src/main/java/com/kexue/skills/entity/LogRecord.java create mode 100644 src/main/java/com/kexue/skills/entity/dto/ConsumptionGroupedDto.java create mode 100644 src/main/java/com/kexue/skills/interceptor/CachedBodyFilter.java create mode 100644 src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletRequest.java create mode 100644 src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletResponse.java create mode 100644 src/main/java/com/kexue/skills/interceptor/LogInterceptor.java diff --git a/db/alter_sys_log_table.sql b/db/alter_sys_log_table.sql new file mode 100644 index 0000000..20d2cbd --- /dev/null +++ b/db/alter_sys_log_table.sql @@ -0,0 +1,46 @@ +-- 更新 sys_log 表结构以支持新的日志功能 +-- 作者: 王志维 +-- 创建时间: 2026-04-14 + +-- 备份旧数据(可选) +-- CREATE TABLE sys_log_backup AS SELECT * FROM sys_log; + +-- 删除旧表(如果存在) +DROP TABLE IF EXISTS `sys_log`; + +-- 创建新表 +CREATE TABLE `sys_log` ( + `log_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `trace_id` varchar(255) DEFAULT NULL COMMENT '链路ID', + `description` varchar(255) DEFAULT NULL COMMENT '日志描述', + `module` varchar(50) DEFAULT NULL COMMENT '所属模块', + `request_url` varchar(512) DEFAULT NULL COMMENT '请求URL', + `request_method` varchar(10) DEFAULT NULL COMMENT '请求方式', + `request_headers` text COMMENT '请求头', + `request_body` text COMMENT '请求体', + `status_code` int(11) DEFAULT NULL COMMENT '状态码', + `response_headers` text COMMENT '响应头', + `response_body` mediumtext COMMENT '响应体', + `time_taken` bigint(20) DEFAULT NULL COMMENT '耗时(ms)', + `ip` varchar(100) DEFAULT NULL COMMENT 'IP', + `address` varchar(255) DEFAULT NULL COMMENT 'IP归属地', + `browser` varchar(100) DEFAULT NULL COMMENT '浏览器', + `os` varchar(100) DEFAULT NULL COMMENT '操作系统', + `status` tinyint(1) DEFAULT '1' COMMENT '状态(1:成功;2:失败)', + `error_msg` text COMMENT '错误信息', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `delete_flag` tinyint(1) DEFAULT '0' COMMENT '是否删除 :0 未删除,1已删除', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + PRIMARY KEY (`log_id`) USING BTREE, + KEY `idx_module` (`module`) USING BTREE COMMENT '模块查询优化', + KEY `idx_ip` (`ip`) USING BTREE COMMENT 'IP查询优化', + KEY `idx_create_time` (`create_time`) USING BTREE COMMENT '时间范围查询优化', + KEY `idx_status` (`status`) USING BTREE COMMENT '状态查询优化' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='系统操作日志表'; + +-- 插入测试数据(可选) +-- INSERT INTO `sys_log` (`description`, `module`, `request_url`, `request_method`, `status_code`, `time_taken`, `ip`, `status`, `create_time`) +-- VALUES ('用户登录', '登录认证', '/api/login/accountLogin', 'POST', 200, 150, '127.0.0.1', 1, NOW()); diff --git a/src/main/java/com/kexue/skills/annotation/Log.java b/src/main/java/com/kexue/skills/annotation/Log.java new file mode 100644 index 0000000..c1792cd --- /dev/null +++ b/src/main/java/com/kexue/skills/annotation/Log.java @@ -0,0 +1,34 @@ +package com.kexue.skills.annotation; + +import java.lang.annotation.*; + +/** + * 操作日志注解 + * 用于记录Controller层方法的请求和响应信息 + * + * @author 王志维 + * @since 2026-04-14 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface Log { + + /** + * 模块名称 + * 例如:用户管理、登录认证、内容管理等 + */ + String module() default ""; + + /** + * 日志描述 + * 如果不填写,将根据方法签名自动生成 + */ + String description() default ""; + + /** + * 是否忽略记录日志 + * 默认false,设置为true时不记录该接口的日志 + */ + boolean ignore() default false; +} diff --git a/src/main/java/com/kexue/skills/config/LogConfiguration.java b/src/main/java/com/kexue/skills/config/LogConfiguration.java new file mode 100644 index 0000000..922e614 --- /dev/null +++ b/src/main/java/com/kexue/skills/config/LogConfiguration.java @@ -0,0 +1,40 @@ +package com.kexue.skills.config; + +import com.kexue.skills.interceptor.LogInterceptor; +import com.kexue.skills.mapper.SysLogMapper; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import javax.annotation.Resource; + +/** + * 日志配置类 + * 注册日志拦截器,启用异步支持 + * + * @author 王志维 + * @since 2026-04-14 + */ +@Configuration +@EnableAsync // 启用异步支持 +public class LogConfiguration implements WebMvcConfigurer { + + @Resource + private SysLogMapper sysLogMapper; + + /** + * 注册拦截器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + LogInterceptor logInterceptor = new LogInterceptor(); + logInterceptor.setSysLogMapper(sysLogMapper); + registry.addInterceptor(logInterceptor) + .addPathPatterns("/api/**") // 拦截所有 API 请求 + .excludePathPatterns( + "/api/login/validateToken", // 排除 token 验证接口 + "/api/captcha/**" // 排除验证码接口 + ); + } +} diff --git a/src/main/java/com/kexue/skills/controller/AccountController.java b/src/main/java/com/kexue/skills/controller/AccountController.java index be7e394..7c556b5 100644 --- a/src/main/java/com/kexue/skills/controller/AccountController.java +++ b/src/main/java/com/kexue/skills/controller/AccountController.java @@ -2,6 +2,7 @@ package com.kexue.skills.controller; import cn.dev33.satoken.stp.StpUtil; import com.github.pagehelper.PageInfo; +import com.kexue.skills.annotation.Log; import com.kexue.skills.annotation.RequireAuth; import com.kexue.skills.annotation.RequireRole; import com.kexue.skills.common.CommonResult; @@ -10,6 +11,8 @@ import com.kexue.skills.entity.Account; import com.kexue.skills.entity.dto.AccountDto; import com.kexue.skills.entity.dto.TokenConsumptionDto; import com.kexue.skills.entity.dto.GiftBalanceDto; +import com.kexue.skills.entity.dto.AccountTransactionDto; +import com.kexue.skills.entity.dto.ConsumptionGroupedDto; import com.kexue.skills.service.AccountService; import com.kexue.skills.service.SysUserService; import com.kexue.skills.entity.SysUser; @@ -32,6 +35,7 @@ import java.util.List; * @author 王志维 * @since 2025-02-21 23:01:48 */ +@Log(module = "账户管理") @Tag(name = "账户管理 api") @RestController @RequestMapping("/api/account") @@ -163,6 +167,45 @@ public class AccountController { Long userId = Long.parseLong(StpUtil.getLoginId().toString()); return CommonResult.success(this.accountService.getTransactions(userId)); } + + /** + * 分页查询充值记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Operation(summary = "分页查询充值记录", description = "分页查询所有充值记录,默认根据时间倒序") + @PostMapping("/getRechargePageList") + @RequireAuth + public CommonResult> getRechargePageList(@RequestBody AccountTransactionDto queryDto) { + return CommonResult.success(this.accountService.getRechargePageList(queryDto)); + } + + /** + * 分页查询消费记录(按callId分组) + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Operation(summary = "分页查询消费记录", description = "分页查询所有消费记录,按callId分组,question取最早入库的记录,默认根据时间倒序") + @PostMapping("/getConsumptionGroupedPageList") + @RequireAuth + public CommonResult> getConsumptionGroupedPageList(@RequestBody AccountTransactionDto queryDto) { + return CommonResult.success(this.accountService.getConsumptionGroupedPageList(queryDto)); + } + + /** + * 分页查询赠送记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Operation(summary = "分页查询赠送记录", description = "分页查询所有赠送记录,默认根据时间倒序") + @PostMapping("/getGiftPageList") + @RequireAuth + public CommonResult> getGiftPageList(@RequestBody AccountTransactionDto queryDto) { + return CommonResult.success(this.accountService.getGiftPageList(queryDto)); + } } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/controller/AccountFrozenController.java b/src/main/java/com/kexue/skills/controller/AccountFrozenController.java index a300777..55b4668 100644 --- a/src/main/java/com/kexue/skills/controller/AccountFrozenController.java +++ b/src/main/java/com/kexue/skills/controller/AccountFrozenController.java @@ -1,5 +1,6 @@ package com.kexue.skills.controller; +import com.kexue.skills.annotation.Log; import com.kexue.skills.common.CommonResult; import com.kexue.skills.entity.AccountFrozen; import com.kexue.skills.entity.dto.AccountFrozenDto; @@ -22,6 +23,7 @@ import java.io.IOException; * @author 系统生成 * @since 2026-04-11 */ +@Log(module = "账户冻结") @RestController @RequestMapping("/api/accountFrozen") @CrossOrigin(origins = "*") diff --git a/src/main/java/com/kexue/skills/controller/CmsContentController.java b/src/main/java/com/kexue/skills/controller/CmsContentController.java index bb893a6..f7ef7c5 100644 --- a/src/main/java/com/kexue/skills/controller/CmsContentController.java +++ b/src/main/java/com/kexue/skills/controller/CmsContentController.java @@ -1,6 +1,7 @@ package com.kexue.skills.controller; import com.github.pagehelper.PageInfo; +import com.kexue.skills.annotation.Log; import com.kexue.skills.annotation.RequireAuth; import com.kexue.skills.common.Assert; import com.kexue.skills.common.CommonResult; @@ -354,4 +355,24 @@ public class CmsContentController { return CommonResult.failed("导入失败:" + e.getMessage()); } } + + /** + * 从指定目录读取Excel数据并更新CmsContent + * + * @param importPathDto 导入路径请求参数 + * @param updateBy 更新人 + * @return 更新结果 + */ + @PostMapping("/updateFromPath") + @Operation(summary = "从目录更新Excel数据", description = "从指定目录读取Excel数据并更新CmsContent") + @RequireAuth + public CommonResult updateFromPath(@RequestBody ImportPathDto importPathDto, @RequestParam("updateBy") String updateBy) { + try { + int successCount = cmsContentService.updateFromPath(importPathDto, updateBy); + return CommonResult.success(successCount); + } catch (Exception e) { + e.printStackTrace(); + return CommonResult.failed("更新失败:" + e.getMessage()); + } + } } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/controller/LoginController.java b/src/main/java/com/kexue/skills/controller/LoginController.java index 9e8af50..858936f 100644 --- a/src/main/java/com/kexue/skills/controller/LoginController.java +++ b/src/main/java/com/kexue/skills/controller/LoginController.java @@ -1,5 +1,6 @@ package com.kexue.skills.controller; +import com.kexue.skills.annotation.Log; import com.kexue.skills.annotation.PreventDuplicateSubmission; import com.kexue.skills.common.CacheManager; import com.kexue.skills.common.CommonResult; @@ -22,6 +23,7 @@ import static cn.dev33.satoken.SaManager.log; * @author 王志维 * @since 2024-04-13 01:25:22 */ +@Log(module = "登录认证") @RestController @RequestMapping("api/login") @CrossOrigin(origins = "*") diff --git a/src/main/java/com/kexue/skills/controller/SysUserController.java b/src/main/java/com/kexue/skills/controller/SysUserController.java index f5eedc6..f5621ff 100644 --- a/src/main/java/com/kexue/skills/controller/SysUserController.java +++ b/src/main/java/com/kexue/skills/controller/SysUserController.java @@ -16,6 +16,11 @@ import com.github.pagehelper.PageInfo; import com.kexue.skills.common.CommonResult; import com.kexue.skills.entity.base.IdDto; import org.redisson.api.RedissonClient; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.IOException; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; /** * (SysUser)表控制层 @@ -222,4 +227,44 @@ public class SysUserController { return CommonResult.success(loginUserDto); } + + /** + * 上传用户头像 + * + * @param file 头像文件 + * @param request HTTP请求 + * @return 上传结果 + */ + @PostMapping("/uploadAvatar") + @Operation(summary = "上传用户头像", description = "上传用户头像并更新用户信息") + @RequireAuth + public CommonResult uploadAvatar(@RequestParam("file") MultipartFile file, HttpServletRequest request) { + // 从请求头中获取token + String token = request.getHeader("Authorization"); + if (token == null || token.isEmpty()) { + throw new BizException("请先登录认证后操作"); + } + + // 从Redis中获取当前登录用户信息 + String loginUserJson = (String)redissonClient.getBucket("loginUser:" + token).get(); + if (loginUserJson == null || loginUserJson.isEmpty()) { + throw new BizException("无效的token,请重新登录"); + } + + // 解析JSON字符串为LoginUser对象 + com.kexue.skills.entity.request.LoginUser loginUser = cn.hutool.json.JSONUtil.toBean(loginUserJson, com.kexue.skills.entity.request.LoginUser.class); + if (loginUser == null || loginUser.getUserInfo() == null) { + throw new BizException("无效的token,请重新登录"); + } + + SysUser user = loginUser.getUserInfo(); + if (user == null) { + throw new BizException("用户不存在"); + } + + // 调用服务层方法上传头像 + String fileName = sysUserService.uploadAvatar(file, user.getUserId(), token); + + return CommonResult.success(fileName); + } } diff --git a/src/main/java/com/kexue/skills/entity/LogRecord.java b/src/main/java/com/kexue/skills/entity/LogRecord.java new file mode 100644 index 0000000..01fea27 --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/LogRecord.java @@ -0,0 +1,120 @@ +package com.kexue.skills.entity; + +import lombok.Data; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Map; + +/** + * 日志记录对象 + * 用于在拦截器和持久层之间传递日志数据 + * + * @author 王志维 + * @since 2026-04-14 + */ +@Data +public class LogRecord implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 模块名称 + */ + private String module; + + /** + * 日志描述 + */ + private String description; + + /** + * 请求信息 + */ + private LogRequest request; + + /** + * 响应信息 + */ + private LogResponse response; + + /** + * 执行耗时(毫秒) + */ + private Long timeTaken; + + /** + * 时间戳 + */ + private Instant timestamp; + + /** + * 日志请求对象 + */ + @Data + public static class LogRequest implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 请求方法(GET、POST等) + */ + private String method; + + /** + * 请求URL + */ + private String url; + + /** + * 请求头 + */ + private Map headers; + + /** + * 请求体 + */ + private String body; + + /** + * 客户端IP + */ + private String ip; + + /** + * IP归属地 + */ + private String address; + + /** + * 浏览器信息 + */ + private String browser; + + /** + * 操作系统 + */ + private String os; + } + + /** + * 日志响应对象 + */ + @Data + public static class LogResponse implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 响应头 + */ + private Map headers; + + /** + * 响应体 + */ + private String body; + + /** + * 状态码 + */ + private Integer status; + } +} diff --git a/src/main/java/com/kexue/skills/entity/SysLog.java b/src/main/java/com/kexue/skills/entity/SysLog.java index 69050bf..c47f3a2 100644 --- a/src/main/java/com/kexue/skills/entity/SysLog.java +++ b/src/main/java/com/kexue/skills/entity/SysLog.java @@ -22,29 +22,59 @@ public class SysLog extends BaseEntity implements Serializable { @Schema(description ="主键ID") private Long logId; - @Schema(description ="用户ID") - private String userId; + @Schema(description ="链路ID") + private String traceId; - @Schema(description ="用户名称") - private String userName; + @Schema(description ="日志描述") + private String description; - @Schema(description ="日志类型") - private String logType; + @Schema(description ="所属模块") + private String module; - @Schema(description ="日志类容") - private String logContent; + @Schema(description ="请求URL") + private String requestUrl; - @Schema(description ="服务端IP") - private String serverIp; + @Schema(description ="请求方式") + private String requestMethod; - @Schema(description ="客户端IP") - private String clientIp; + @Schema(description ="请求头") + private String requestHeaders; - @Schema(description ="yyyyMMddHHmmss") - private String logTime; + @Schema(description ="请求体") + private String requestBody; - @Schema(description ="备注") - private String note; + @Schema(description ="状态码") + private Integer statusCode; + + @Schema(description ="响应头") + private String responseHeaders; + + @Schema(description ="响应体") + private String responseBody; + + @Schema(description ="耗时(ms)") + private Long timeTaken; + + @Schema(description ="IP") + private String ip; + + @Schema(description ="IP归属地") + private String address; + + @Schema(description ="浏览器") + private String browser; + + @Schema(description ="操作系统") + private String os; + + @Schema(description ="状态(1:成功;2:失败)") + private Integer status; + + @Schema(description ="错误信息") + private String errorMsg; + + @Schema(description ="创建人") + private Long createUser; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") @Schema(description ="创建时间") diff --git a/src/main/java/com/kexue/skills/entity/SysUser.java b/src/main/java/com/kexue/skills/entity/SysUser.java index cb336e0..2ea5d7e 100644 --- a/src/main/java/com/kexue/skills/entity/SysUser.java +++ b/src/main/java/com/kexue/skills/entity/SysUser.java @@ -75,4 +75,7 @@ public class SysUser extends BaseEntity implements Serializable { @Schema(description ="邀请人用户ID(邀请我注册的用户ID)") private Long invitedBy; + @Schema(description ="用户头像") + private String userIcon = "defaultUserIcon.png"; + } diff --git a/src/main/java/com/kexue/skills/entity/dto/AccountTransactionDto.java b/src/main/java/com/kexue/skills/entity/dto/AccountTransactionDto.java index 1f4c988..9e5ed25 100644 --- a/src/main/java/com/kexue/skills/entity/dto/AccountTransactionDto.java +++ b/src/main/java/com/kexue/skills/entity/dto/AccountTransactionDto.java @@ -3,6 +3,8 @@ package com.kexue.skills.entity.dto; import com.kexue.skills.entity.base.BaseQueryDto; import lombok.Data; +import java.util.Date; + /** * (AccountTransaction)查询DTO类 * @@ -32,4 +34,8 @@ public class AccountTransactionDto extends BaseQueryDto { private Integer deleteFlag; + private Date createTimeStart; + + private Date createTimeEnd; + } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/entity/dto/CmsContentDto.java b/src/main/java/com/kexue/skills/entity/dto/CmsContentDto.java index dd4dbd4..faa0431 100644 --- a/src/main/java/com/kexue/skills/entity/dto/CmsContentDto.java +++ b/src/main/java/com/kexue/skills/entity/dto/CmsContentDto.java @@ -55,4 +55,19 @@ public class CmsContentDto extends BaseQueryDto { */ private String keyword; + /** + * 来源 + */ + private String origin; + + /** + * 标签 + */ + private String tags; + + /** + * 图标 + */ + private String icon; + } diff --git a/src/main/java/com/kexue/skills/entity/dto/ConsumptionGroupedDto.java b/src/main/java/com/kexue/skills/entity/dto/ConsumptionGroupedDto.java new file mode 100644 index 0000000..7caa7f4 --- /dev/null +++ b/src/main/java/com/kexue/skills/entity/dto/ConsumptionGroupedDto.java @@ -0,0 +1,101 @@ +package com.kexue.skills.entity.dto; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 消费记录分组DTO类(按callId分组) + * + * @author 王志维 + * @since 2025-04-15 + */ +@Data +public class ConsumptionGroupedDto implements Serializable { + private static final long serialVersionUID = 1L; + + @Schema(description ="主键ID(取最早记录的ID)") + private Long transactionId; + + @Schema(description ="用户ID") + private Long userId; + + @Schema(description ="用户名") + private String userName; + + @Schema(description ="交易类型:1.充值 2.提现 3.购买内容 4.退款 5.签到奖励 6.赠送 7.其他") + private Integer transactionType; + + @Schema(description ="交易金额") + private BigDecimal amount; + + @Schema(description ="交易前余额") + private BigDecimal beforeBalance; + + @Schema(description ="交易后余额") + private BigDecimal afterBalance; + + @Schema(description ="交易状态:1.成功 2.失败 3.处理中") + private Integer status; + + @Schema(description ="交易单号") + private String transactionNo; + + @Schema(description ="支付方式:1.微信 2.支付宝 3.余额支付") + private Integer payType; + + @Schema(description ="关联业务ID") + private Long businessId; + + @Schema(description ="业务类型") + private String businessType; + + @Schema(description ="调用ID,关联冻结单") + private String callId; + + @Schema(description ="交易备注") + private String remark; + + @Schema(description ="是否支出:1.是 0.否") + private Integer isExpense; + + @Schema(description ="输入token") + private Integer inputToken; + + @Schema(description ="输出token") + private Integer outputToken; + + @Schema(description ="合计tokens") + private Integer totalTokens; + + @Schema(description ="处理的模型名称") + private String modelName; + + @Schema(description ="对应回答的问题或需求(取最早入库的question)") + private String question; + + @Schema(description ="收入类型:recharge(充值)、sign_in(签到奖励)") + private String incomeType; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Schema(description ="创建时间(取最早记录的创建时间)") + private Date createTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + @Schema(description ="更新时间") + private Date updateTime; + + @Schema(description ="创建人") + private String createBy; + + @Schema(description ="更新人") + private String updateBy; + + @Schema(description ="是否删除 :0 未删除,1已删除") + private Integer deleteFlag; + +} diff --git a/src/main/java/com/kexue/skills/interceptor/CachedBodyFilter.java b/src/main/java/com/kexue/skills/interceptor/CachedBodyFilter.java new file mode 100644 index 0000000..82be7c2 --- /dev/null +++ b/src/main/java/com/kexue/skills/interceptor/CachedBodyFilter.java @@ -0,0 +1,52 @@ +package com.kexue.skills.interceptor; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * 请求响应体缓存过滤器 + * 用于包装 HttpServletRequest 和 HttpServletResponse,使其可以重复读取请求体和捕获响应体 + * + * @author 王志维 + * @since 2026-04-14 + */ +@Component +@Order(1) // 确保在最前面执行 +public class CachedBodyFilter implements Filter { + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) { + HttpServletRequest httpRequest = (HttpServletRequest) request; + HttpServletResponse httpResponse = (HttpServletResponse) response; + + // 跳过对 multipart/form-data 格式请求的包装,因为文件上传请求不能被重复读取 + String contentType = httpRequest.getContentType(); + if (contentType != null && contentType.startsWith("multipart/")) { + chain.doFilter(request, response); + return; + } + + // 包装请求,使其可以重复读取请求体 + CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(httpRequest); + + // 包装响应,使其可以捕获响应体 + CachedBodyHttpServletResponse cachedResponse = new CachedBodyHttpServletResponse(httpResponse); + + // 继续过滤链 + chain.doFilter(cachedRequest, cachedResponse); + + // 将缓存的响应体写入真实响应 + cachedResponse.flushBuffer(); + } else { + chain.doFilter(request, response); + } + } +} diff --git a/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletRequest.java b/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..a839e6a --- /dev/null +++ b/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletRequest.java @@ -0,0 +1,88 @@ +package com.kexue.skills.interceptor; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.*; + +/** + * 可重复读取请求体的 HttpServletRequest 包装类 + * + * @author 王志维 + * @since 2026-04-14 + */ +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + // 读取并缓存请求体 + this.cachedBody = readBytes(request.getInputStream()); + } + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(this.cachedBody); + } + + @Override + public BufferedReader getReader() { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody); + return new BufferedReader(new InputStreamReader(byteArrayInputStream)); + } + + /** + * 获取缓存的请求体字符串 + */ + public String getCachedBodyString() { + return new String(cachedBody); + } + + /** + * 从输入流读取字节数组 + */ + private byte[] readBytes(InputStream inputStream) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int nRead; + byte[] data = new byte[1024]; + while ((nRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + return buffer.toByteArray(); + } + + /** + * 可重复读取的 ServletInputStream + */ + private static class CachedBodyServletInputStream extends ServletInputStream { + + private final ByteArrayInputStream inputStream; + + public CachedBodyServletInputStream(byte[] cachedBody) { + this.inputStream = new ByteArrayInputStream(cachedBody); + } + + @Override + public boolean isFinished() { + return inputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener listener) { + throw new UnsupportedOperationException(); + } + + @Override + public int read() { + return inputStream.read(); + } + } +} diff --git a/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletResponse.java b/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletResponse.java new file mode 100644 index 0000000..639bfcd --- /dev/null +++ b/src/main/java/com/kexue/skills/interceptor/CachedBodyHttpServletResponse.java @@ -0,0 +1,94 @@ +package com.kexue.skills.interceptor; + +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintWriter; + +/** + * 可捕获响应体的 HttpServletResponse 包装类 + */ +public class CachedBodyHttpServletResponse extends HttpServletResponseWrapper { + + private final ByteArrayOutputStream cachedBody = new ByteArrayOutputStream(); + private ServletOutputStream outputStream; + private PrintWriter writer; + + public CachedBodyHttpServletResponse(HttpServletResponse response) { + super(response); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (outputStream == null) { + outputStream = new CachedBodyServletOutputStream(super.getOutputStream(), cachedBody); + } + return outputStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (writer == null) { + writer = new PrintWriter(getOutputStream(), true); + } + return writer; + } + + @Override + public void flushBuffer() throws IOException { + if (writer != null) { + writer.flush(); + } + if (outputStream != null) { + outputStream.flush(); + } + super.flushBuffer(); + } + + public String getCachedBodyString() { + return cachedBody.toString(); + } + + private static class CachedBodyServletOutputStream extends ServletOutputStream { + + private final ServletOutputStream outputStream; + private final ByteArrayOutputStream cachedBody; + + public CachedBodyServletOutputStream(ServletOutputStream outputStream, ByteArrayOutputStream cachedBody) { + this.outputStream = outputStream; + this.cachedBody = cachedBody; + } + + @Override + public boolean isReady() { + return outputStream.isReady(); + } + + @Override + public void setWriteListener(WriteListener listener) { + outputStream.setWriteListener(listener); + } + + @Override + public void write(int b) throws IOException { + outputStream.write(b); + cachedBody.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + outputStream.write(b); + cachedBody.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + outputStream.write(b, off, len); + cachedBody.write(b, off, len); + } + } +} diff --git a/src/main/java/com/kexue/skills/interceptor/LogInterceptor.java b/src/main/java/com/kexue/skills/interceptor/LogInterceptor.java new file mode 100644 index 0000000..3b6e1fe --- /dev/null +++ b/src/main/java/com/kexue/skills/interceptor/LogInterceptor.java @@ -0,0 +1,412 @@ +package com.kexue.skills.interceptor; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.useragent.UserAgent; +import cn.hutool.http.useragent.UserAgentUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kexue.skills.annotation.Log; +import com.kexue.skills.entity.LogRecord; +import com.kexue.skills.mapper.SysLogMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.annotation.Resource; +import java.io.IOException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +/** + * 操作日志拦截器 + * 基于 HandlerInterceptor 实现,捕获请求和响应信息 + * + * @author 王志维 + * @since 2026-04-14 + */ +public class LogInterceptor implements HandlerInterceptor { + + private static final Logger logger = LoggerFactory.getLogger(LogInterceptor.class); + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private SysLogMapper sysLogMapper; + + // 使用 ThreadLocal 存储请求开始时间和日志记录 + private static final ThreadLocal START_TIME = new ThreadLocal<>(); + private static final ThreadLocal LOG_RECORD = new ThreadLocal<>(); + + /** + * 设置 SysLogMapper + */ + public void setSysLogMapper(SysLogMapper sysLogMapper) { + this.sysLogMapper = sysLogMapper; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + // 只处理方法级别的请求 + if (!(handler instanceof HandlerMethod)) { + return true; + } + + HandlerMethod handlerMethod = (HandlerMethod) handler; + + // 检查是否有 @Log 注解 + Log methodLog = handlerMethod.getMethodAnnotation(Log.class); + Log classLog = handlerMethod.getBeanType().getAnnotation(Log.class); + + // 如果方法和类都没有 @Log 注解,不记录日志 + if (methodLog == null && classLog == null) { + return true; + } + + // 如果方法级别设置了 ignore=true,不记录日志 + if (methodLog != null && methodLog.ignore()) { + return true; + } + + // 记录开始时间 + START_TIME.set(System.currentTimeMillis()); + + // 构建日志记录对象 + LogRecord logRecord = new LogRecord(); + + // 设置模块和描述 + String module = ""; + String description = ""; + if (classLog != null) { + module = classLog.module(); + } + if (methodLog != null) { + if (StrUtil.isNotBlank(methodLog.module())) { + module = methodLog.module(); + } + description = methodLog.description(); + } + logRecord.setModule(module); + logRecord.setDescription(description); + + // 设置请求信息 + LogRecord.LogRequest logRequest = new LogRecord.LogRequest(); + logRequest.setMethod(request.getMethod()); + logRequest.setUrl(request.getRequestURL().toString()); + logRequest.setHeaders(getRequestHeaders(request)); + logRequest.setIp(getClientIp(request)); + logRequest.setBrowser(getBrowserInfo(request)); + logRequest.setOs(getOsInfo(request)); + + // 读取请求体(从包装对象中获取) + if (request instanceof CachedBodyHttpServletRequest) { + CachedBodyHttpServletRequest cachedRequest = (CachedBodyHttpServletRequest) request; + String requestBody = cachedRequest.getCachedBodyString(); + // 限制请求体大小,避免过大 + if (requestBody.length() > 10000) { + requestBody = requestBody.substring(0, 10000) + "... [truncated]"; + } + logRequest.setBody(requestBody); + } + + logRecord.setRequest(logRequest); + logRecord.setTimestamp(Instant.now()); + + // 存储到 ThreadLocal + LOG_RECORD.set(logRecord); + + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, + Object handler, Exception ex) { + // 只处理方法级别的请求 + if (!(handler instanceof HandlerMethod)) { + return; + } + + // 获取日志记录 + LogRecord logRecord = LOG_RECORD.get(); + Long startTime = START_TIME.get(); + + if (logRecord == null || startTime == null) { + return; + } + + try { + // 计算耗时 + long timeTaken = System.currentTimeMillis() - startTime; + logRecord.setTimeTaken(timeTaken); + + // 设置响应信息 + LogRecord.LogResponse logResponse = new LogRecord.LogResponse(); + logResponse.setStatus(response.getStatus()); + logResponse.setHeaders(getResponseHeaders(response)); + + // 读取响应体(从包装对象中获取) + if (response instanceof CachedBodyHttpServletResponse) { + CachedBodyHttpServletResponse cachedResponse = (CachedBodyHttpServletResponse) response; + String responseBody = cachedResponse.getCachedBodyString(); + // 限制响应体大小,避免过大 + if (responseBody.length() > 10000) { + responseBody = responseBody.substring(0, 10000) + "... [truncated]"; + } + logResponse.setBody(responseBody); + } + + logRecord.setResponse(logResponse); + + // 异步保存日志 + saveLogAsync(logRecord); + + } catch (Exception e) { + logger.error("记录操作日志失败: {}", e.getMessage(), e); + } finally { + // 清理 ThreadLocal + START_TIME.remove(); + LOG_RECORD.remove(); + } + } + + /** + * 获取请求头 + */ + private Map getRequestHeaders(HttpServletRequest request) { + Map headers = new HashMap<>(); + Enumeration headerNames = request.getHeaderNames(); + while (headerNames.hasMoreElements()) { + String headerName = headerNames.nextElement(); + headers.put(headerName, request.getHeader(headerName)); + } + return headers; + } + + /** + * 获取响应头 + */ + private Map getResponseHeaders(HttpServletResponse response) { + Map headers = new HashMap<>(); + for (String headerName : response.getHeaderNames()) { + headers.put(headerName, response.getHeader(headerName)); + } + return headers; + } + + /** + * 获取客户端IP + */ + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 多个代理时,第一个IP为真实IP + if (StrUtil.isNotBlank(ip) && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } + + /** + * 获取浏览器信息 + */ + private String getBrowserInfo(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + if (StrUtil.isBlank(userAgent)) { + return "Unknown"; + } + UserAgent ua = UserAgentUtil.parse(userAgent); + String browserName = ua.getBrowser().getName(); + String version = ua.getVersion(); + // 避免显示 "Unknown null" + if (StrUtil.isBlank(version) || "null".equals(version)) { + return StrUtil.blankToDefault(browserName, "Unknown"); + } + return browserName + " " + version; + } + + /** + * 获取操作系统信息 + */ + private String getOsInfo(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + if (StrUtil.isBlank(userAgent)) { + return "Unknown"; + } + UserAgent ua = UserAgentUtil.parse(userAgent); + String osName = ua.getOs().getName(); + return StrUtil.blankToDefault(osName, "Unknown"); + } + + /** + * 异步保存日志 + */ + @Async + public void saveLogAsync(LogRecord logRecord) { + try { + // 转换为 SysLog 实体 + com.kexue.skills.entity.SysLog sysLog = new com.kexue.skills.entity.SysLog(); + + // 设置基本信息 + sysLog.setDescription(StrUtil.blankToDefault(logRecord.getDescription(), "")); + sysLog.setModule(StrUtil.blankToDefault(logRecord.getModule(), "")); + sysLog.setTimeTaken(logRecord.getTimeTaken() != null ? logRecord.getTimeTaken() : 0L); + sysLog.setCreateTime(new java.util.Date()); + sysLog.setUpdateTime(new java.util.Date()); + sysLog.setDeleteFlag(0); + + // 设置请求信息 + LogRecord.LogRequest request = logRecord.getRequest(); + if (request != null) { + sysLog.setRequestMethod(StrUtil.blankToDefault(request.getMethod(), "UNKNOWN")); + sysLog.setRequestUrl(StrUtil.blankToDefault(request.getUrl(), "")); + try { + sysLog.setRequestHeaders(objectMapper.writeValueAsString(request.getHeaders())); + } catch (Exception e) { + logger.warn("序列化请求头失败: {}", e.getMessage()); + sysLog.setRequestHeaders("{}"); + } + sysLog.setRequestBody(StrUtil.blankToDefault(request.getBody(), "")); + sysLog.setIp(StrUtil.blankToDefault(request.getIp(), "127.0.0.1")); + sysLog.setAddress(StrUtil.blankToDefault(request.getAddress(), "")); + sysLog.setBrowser(StrUtil.blankToDefault(request.getBrowser(), "Unknown")); + sysLog.setOs(StrUtil.blankToDefault(request.getOs(), "Unknown")); + } else { + // 设置默认值 + sysLog.setRequestMethod("UNKNOWN"); + sysLog.setRequestUrl(""); + sysLog.setRequestHeaders("{}"); + sysLog.setRequestBody(""); + sysLog.setIp("127.0.0.1"); + sysLog.setAddress(""); + sysLog.setBrowser("Unknown"); + sysLog.setOs("Unknown"); + } + + // 设置响应信息 + LogRecord.LogResponse response = logRecord.getResponse(); + if (response != null) { + sysLog.setStatusCode(response.getStatus() != null ? response.getStatus() : 200); + try { + sysLog.setResponseHeaders(objectMapper.writeValueAsString(response.getHeaders())); + } catch (Exception e) { + logger.warn("序列化响应头失败: {}", e.getMessage()); + sysLog.setResponseHeaders("{}"); + } + String responseBody = StrUtil.blankToDefault(response.getBody(), ""); + sysLog.setResponseBody(responseBody); + + // 判断成功/失败状态 + Integer statusCode = response.getStatus(); + if (statusCode != null) { + sysLog.setStatus(statusCode >= 400 ? 2 : 1); + } else { + sysLog.setStatus(1); + } + + // 解析响应体中的错误信息 + if (StrUtil.isNotBlank(responseBody)) { + try { + // 尝试解析 JSON 响应体 + com.fasterxml.jackson.databind.JsonNode jsonNode = objectMapper.readTree(responseBody); + + // 检查是否有错误标识(status、code、success等字段) + boolean hasError = false; + String errorMsg = ""; + + // 方式1: 检查 status 字段(如 {"status":500,"message":"密码不正确"}) + if (jsonNode.has("status")) { + int statusValue = jsonNode.get("status").asInt(); + if (statusValue >= 400 || statusValue == 0) { + hasError = true; + if (jsonNode.has("message")) { + errorMsg = jsonNode.get("message").asText(); + } else if (jsonNode.has("msg")) { + errorMsg = jsonNode.get("msg").asText(); + } else if (jsonNode.has("error")) { + errorMsg = jsonNode.get("error").asText(); + } + } + } + + // 方式2: 检查 code 字段(如 {"code":500,"message":"密码不正确"}) + if (!hasError && jsonNode.has("code")) { + int codeValue = jsonNode.get("code").asInt(); + if (codeValue != 200 && codeValue != 0) { + hasError = true; + if (jsonNode.has("message")) { + errorMsg = jsonNode.get("message").asText(); + } else if (jsonNode.has("msg")) { + errorMsg = jsonNode.get("msg").asText(); + } else if (jsonNode.has("error")) { + errorMsg = jsonNode.get("error").asText(); + } + } + } + + // 方式3: 检查 success 字段(如 {"success":false,"message":"密码不正确"}) + if (!hasError && jsonNode.has("success")) { + boolean success = jsonNode.get("success").asBoolean(); + if (!success) { + hasError = true; + if (jsonNode.has("message")) { + errorMsg = jsonNode.get("message").asText(); + } else if (jsonNode.has("msg")) { + errorMsg = jsonNode.get("msg").asText(); + } else if (jsonNode.has("error")) { + errorMsg = jsonNode.get("error").asText(); + } + } + } + + // 如果检测到错误,更新状态和错误信息 + if (hasError) { + sysLog.setStatus(2); // 失败 + sysLog.setErrorMsg(StrUtil.blankToDefault(errorMsg, "业务操作失败")); + } + + } catch (Exception e) { + // 不是 JSON 格式或解析失败,忽略 + logger.debug("响应体不是JSON格式或解析失败: {}", e.getMessage()); + } + } + } else { + // 设置默认值 + sysLog.setStatusCode(200); + sysLog.setResponseHeaders("{}"); + sysLog.setResponseBody(""); + sysLog.setStatus(1); + } + + // 设置错误信息默认值 + sysLog.setErrorMsg(""); + + // TODO: 从 Token 中解析用户ID + // 这里需要根据实际的认证框架来实现 + // sysLog.setCreateUser(userId); + sysLog.setCreateUser(null); + + // 插入数据库 + sysLogMapper.insert(sysLog); + + logger.debug("操作日志保存成功: {}", sysLog.getDescription()); + + } catch (Exception e) { + logger.error("保存操作日志失败: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/kexue/skills/mapper/AccountTransactionMapper.java b/src/main/java/com/kexue/skills/mapper/AccountTransactionMapper.java index 0e5e143..9e5fc8e 100644 --- a/src/main/java/com/kexue/skills/mapper/AccountTransactionMapper.java +++ b/src/main/java/com/kexue/skills/mapper/AccountTransactionMapper.java @@ -2,6 +2,7 @@ package com.kexue.skills.mapper; import com.kexue.skills.entity.AccountTransaction; import com.kexue.skills.entity.dto.AccountTransactionDto; +import com.kexue.skills.entity.dto.ConsumptionGroupedDto; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -78,4 +79,36 @@ public interface AccountTransactionMapper { * @return 交易记录列表 */ List queryByUserId(Long userId); + + /** + * 获取消费原始记录列表(用于内存分组) + * + * @param queryDto 筛选条件 + * @return 原始记录列表 + */ + List getConsumptionRawList(AccountTransactionDto queryDto); + + /** + * 分页查询充值记录(transactionType=1) + * + * @param queryDto 筛选条件 + * @return 查询结果 + */ + List getRechargePageList(AccountTransactionDto queryDto); + + /** + * 分页查询赠送记录(transactionType=6) + * + * @param queryDto 筛选条件 + * @return 查询结果 + */ + List getGiftPageList(AccountTransactionDto queryDto); + + /** + * 分页查询消费记录并按callId分组 + * + * @param queryDto 筛选条件 + * @return 查询结果 + */ + List getConsumptionGroupedPageList(AccountTransactionDto queryDto); } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/AccountService.java b/src/main/java/com/kexue/skills/service/AccountService.java index 4b749ba..3c22bda 100644 --- a/src/main/java/com/kexue/skills/service/AccountService.java +++ b/src/main/java/com/kexue/skills/service/AccountService.java @@ -2,7 +2,10 @@ package com.kexue.skills.service; import com.github.pagehelper.PageInfo; import com.kexue.skills.entity.Account; +import com.kexue.skills.entity.AccountTransaction; import com.kexue.skills.entity.dto.AccountDto; +import com.kexue.skills.entity.dto.AccountTransactionDto; +import com.kexue.skills.entity.dto.ConsumptionGroupedDto; import com.kexue.skills.entity.dto.TokenConsumptionDto; import java.math.BigDecimal; @@ -160,4 +163,28 @@ public interface AccountService extends BaseService { * @return 交易记录列表 */ List getTransactions(Long userId); + + /** + * 分页查询充值记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + PageInfo getRechargePageList(AccountTransactionDto queryDto); + + /** + * 分页查询消费记录(按callId分组) + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + PageInfo getConsumptionGroupedPageList(AccountTransactionDto queryDto); + + /** + * 分页查询赠送记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + PageInfo getGiftPageList(AccountTransactionDto queryDto); } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/CmsContentService.java b/src/main/java/com/kexue/skills/service/CmsContentService.java index 6874afd..4dd6274 100644 --- a/src/main/java/com/kexue/skills/service/CmsContentService.java +++ b/src/main/java/com/kexue/skills/service/CmsContentService.java @@ -210,4 +210,13 @@ public interface CmsContentService extends BaseService { * @return 成功导入的记录数 */ int importFromZip(byte[] zipFileBytes, String createBy); + + /** + * 从指定目录读取Excel数据并更新CmsContent + * + * @param importPathDto 导入路径请求参数 + * @param updateBy 更新人 + * @return 成功更新的记录数 + */ + int updateFromPath(ImportPathDto importPathDto, String updateBy); } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/SysUserService.java b/src/main/java/com/kexue/skills/service/SysUserService.java index e5fe348..cc8f92f 100644 --- a/src/main/java/com/kexue/skills/service/SysUserService.java +++ b/src/main/java/com/kexue/skills/service/SysUserService.java @@ -5,6 +5,8 @@ import com.kexue.skills.entity.SysUser; import com.kexue.skills.entity.dto.SysUserDto; import com.kexue.skills.entity.request.*; +import org.springframework.web.multipart.MultipartFile; + import java.util.List; /** @@ -137,4 +139,23 @@ public interface SysUserService extends BaseService { * @return 角色编码列表 */ List queryUserRoles(Long userId); + + /** + * 更新用户缓存 + * + * @param userId 用户ID + * @param user 更新后的用户信息 + * @param token 用户的认证token + */ + void updateUserCache(Long userId, SysUser user, String token); + + /** + * 上传用户头像 + * + * @param file 头像文件 + * @param userId 用户ID + * @param token 用户的认证token + * @return 上传成功的文件名 + */ + String uploadAvatar(MultipartFile file, Long userId, String token); } diff --git a/src/main/java/com/kexue/skills/service/impl/AccountServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/AccountServiceImpl.java index 75921a4..29a035c 100644 --- a/src/main/java/com/kexue/skills/service/impl/AccountServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/AccountServiceImpl.java @@ -6,6 +6,8 @@ import com.kexue.skills.entity.Account; import com.kexue.skills.entity.AccountTransaction; import com.kexue.skills.entity.SysUser; import com.kexue.skills.entity.dto.AccountDto; +import com.kexue.skills.entity.dto.AccountTransactionDto; +import com.kexue.skills.entity.dto.ConsumptionGroupedDto; import com.kexue.skills.common.Assert; import com.kexue.skills.entity.dto.TokenConsumptionDto; import com.kexue.skills.exception.BizException; @@ -17,13 +19,18 @@ import com.kexue.skills.service.ModelPriceService; import com.kexue.skills.service.PackageConfigService; import com.kexue.skills.entity.ModelPrice; import com.kexue.skills.entity.PackageConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Date; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; /** * (Account)表服务实现类 @@ -34,6 +41,8 @@ import java.util.List; @Service("accountService") @Transactional(rollbackFor = Exception.class) public class AccountServiceImpl implements AccountService { + private static final Logger log = LoggerFactory.getLogger(AccountServiceImpl.class); + @Resource private AccountMapper accountMapper; @Resource @@ -57,7 +66,7 @@ public class AccountServiceImpl implements AccountService { @Override public PageInfo getPageList(AccountDto queryDto) { PageHelper.startPage(queryDto.getPageNum(), queryDto.getPageSize()); - List list = this.accountMapper.getPageList(queryDto); + List list = accountMapper.getPageList(queryDto); return new PageInfo<>(list); } @@ -69,7 +78,7 @@ public class AccountServiceImpl implements AccountService { */ @Override public List getList(AccountDto queryDto) { - return this.accountMapper.getList(queryDto); + return accountMapper.getList(queryDto); } /** @@ -80,7 +89,7 @@ public class AccountServiceImpl implements AccountService { */ @Override public Account queryById(Long accountId) { - return this.accountMapper.queryById(accountId); + return accountMapper.queryById(accountId); } /** @@ -91,7 +100,7 @@ public class AccountServiceImpl implements AccountService { */ @Override public Account queryByUserId(Long userId) { - return this.accountMapper.queryByUserId(userId); + return accountMapper.queryByUserId(userId); } /** @@ -121,7 +130,7 @@ public class AccountServiceImpl implements AccountService { account.setFrozenAmount(BigDecimal.ZERO); } // 保存数据 - this.accountMapper.insert(account); + accountMapper.insert(account); return account; } @@ -136,8 +145,8 @@ public class AccountServiceImpl implements AccountService { // 设置更新时间 account.setUpdateTime(new Date()); // 更新数据 - this.accountMapper.update(account); - return this.queryById(account.getAccountId()); + accountMapper.update(account); + return queryById(account.getAccountId()); } /** @@ -155,7 +164,7 @@ public class AccountServiceImpl implements AccountService { @Override public int addBalance(Long userId, BigDecimal amount, boolean isWithdrawable, String transactionNo, Long businessId, String businessType, String remark) { // 1. 查询账户信息 - Account account = this.queryByUserId(userId); + Account account = queryByUserId(userId); if (account == null) { // 创建账户 account = new Account(); @@ -164,7 +173,7 @@ public class AccountServiceImpl implements AccountService { account.setWithdrawableBalance(BigDecimal.ZERO); account.setNonWithdrawableBalance(BigDecimal.ZERO); account.setFrozenAmount(BigDecimal.ZERO); - this.insert(account); + insert(account); } // 2. 计算积分 @@ -212,7 +221,7 @@ public class AccountServiceImpl implements AccountService { transaction.setRemark(rechargeRemark); transaction.setIsExpense(0); // 收入 transaction.setIncomeType("recharge"); // 充值 - this.accountTransactionMapper.insert(transaction); + accountTransactionMapper.insert(transaction); // 4. 更新账户余额(使用积分) if (isWithdrawable) { @@ -225,7 +234,7 @@ public class AccountServiceImpl implements AccountService { BigDecimal balance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance(); account.setBalance(balance.add(points)); account.setUpdateTime(new Date()); - this.update(account); + update(account); return 1; } @@ -259,7 +268,7 @@ public class AccountServiceImpl implements AccountService { @Override public int reduceBalance(Long userId, BigDecimal amount, String transactionNo, Long businessId, String businessType, String remark) { // 1. 查询账户信息 - Account account = this.queryByUserId(userId); + Account account = queryByUserId(userId); Assert.notNull(account, "账户不存在"); // 2. 检查余额是否足够 @@ -281,10 +290,10 @@ public class AccountServiceImpl implements AccountService { transaction.setBusinessType(businessType); transaction.setRemark(remark); transaction.setIsExpense(1); // 支出 - this.accountTransactionMapper.insert(transaction); + accountTransactionMapper.insert(transaction); // 4. 更新账户余额 - return this.accountMapper.updateBalance(userId, amount, 2); + return accountMapper.updateBalance(userId, amount, 2); } /** @@ -301,7 +310,7 @@ public class AccountServiceImpl implements AccountService { throw new BizException("会话ID不存在"); } userId = bySessionId.getUserId(); - Account account = this.queryByUserId(userId); + Account account = queryByUserId(userId); Assert.notNull(account, "账户不存在"); // 2. 查询模型价格信息 @@ -348,10 +357,10 @@ public class AccountServiceImpl implements AccountService { transaction.setTotalTokens(dto.getTotalTokens()); transaction.setModelName(dto.getModelName()); transaction.setQuestion(dto.getQuestion()); - this.accountTransactionMapper.insert(transaction); + accountTransactionMapper.insert(transaction); // 6. 更新账户余额 - this.accountMapper.updateBalance(userId, amount, 2); + accountMapper.updateBalance(userId, amount, 2); return amount; } @@ -377,7 +386,7 @@ public class AccountServiceImpl implements AccountService { @Override public int addSignInBalance(Long userId, BigDecimal amount, String transactionNo, Long businessId, String businessType, String remark) { // 1. 查询账户信息 - Account account = this.queryByUserId(userId); + Account account = queryByUserId(userId); if (account == null) { // 创建账户 account = new Account(); @@ -386,7 +395,7 @@ public class AccountServiceImpl implements AccountService { account.setWithdrawableBalance(BigDecimal.ZERO); account.setNonWithdrawableBalance(BigDecimal.ZERO); account.setFrozenAmount(BigDecimal.ZERO); - this.insert(account); + insert(account); } // 2. 保存交易记录 @@ -410,7 +419,7 @@ public class AccountServiceImpl implements AccountService { transaction.setRemark(signInRemark); transaction.setIsExpense(0); // 收入 transaction.setIncomeType("sign_in"); // 签到奖励 - this.accountTransactionMapper.insert(transaction); + accountTransactionMapper.insert(transaction); // 3. 更新账户余额(签到奖励不可提现) BigDecimal nonWithdrawableBalance = account.getNonWithdrawableBalance() == null ? BigDecimal.ZERO : account.getNonWithdrawableBalance(); @@ -418,7 +427,7 @@ public class AccountServiceImpl implements AccountService { BigDecimal balance = account.getBalance() == null ? BigDecimal.ZERO : account.getBalance(); account.setBalance(balance.add(amount)); account.setUpdateTime(new Date()); - this.update(account); + update(account); return 1; } @@ -436,7 +445,7 @@ public class AccountServiceImpl implements AccountService { @Override public int addGiftBalance(Long userId, BigDecimal amount, String transactionNo, Long businessId, String businessType, String remark) { // 1. 查询账户信息 - Account account = this.queryByUserId(userId); + Account account = queryByUserId(userId); if (account == null) { // 创建账户 account = new Account(); @@ -445,7 +454,7 @@ public class AccountServiceImpl implements AccountService { account.setWithdrawableBalance(BigDecimal.ZERO); account.setNonWithdrawableBalance(BigDecimal.ZERO); account.setFrozenAmount(BigDecimal.ZERO); - this.insert(account); + insert(account); } // 2. 保存交易记录 @@ -469,7 +478,7 @@ public class AccountServiceImpl implements AccountService { transaction.setRemark(giftRemark); transaction.setIsExpense(0); // 收入 transaction.setIncomeType("gift"); // 赠送 - this.accountTransactionMapper.insert(transaction); + accountTransactionMapper.insert(transaction); // 3. 更新账户余额(赠送积分不可提现) if (account.getNonWithdrawableBalance() == null){ @@ -481,7 +490,7 @@ public class AccountServiceImpl implements AccountService { } account.setBalance(account.getBalance().add(amount)); account.setUpdateTime(new Date()); - this.update(account); + update(account); return 1; } @@ -494,7 +503,7 @@ public class AccountServiceImpl implements AccountService { */ @Override public int logicDeleteById(Long accountId, String updateBy) { - return this.accountMapper.logicDeleteById(accountId, updateBy); + return accountMapper.logicDeleteById(accountId, updateBy); } /** @@ -505,7 +514,7 @@ public class AccountServiceImpl implements AccountService { */ @Override public int deleteById(Long accountId) { - return this.accountMapper.deleteById(accountId); + return accountMapper.deleteById(accountId); } /** @@ -516,6 +525,134 @@ public class AccountServiceImpl implements AccountService { */ @Override public List getTransactions(Long userId) { - return this.accountTransactionMapper.queryByUserId(userId); + return accountTransactionMapper.queryByUserId(userId); + } + + /** + * 分页查询充值记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Override + public PageInfo getRechargePageList(AccountTransactionDto queryDto) { + PageHelper.startPage(queryDto.getPageNum(), queryDto.getPageSize()); + List list = accountTransactionMapper.getRechargePageList(queryDto); + return new PageInfo<>(list); + } + + /** + * 分页查询消费记录(按callId分组) + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Override + public PageInfo getConsumptionGroupedPageList(AccountTransactionDto queryDto) { + int pageNum = queryDto.getPageNum() == null || queryDto.getPageNum() < 1 ? 1 : queryDto.getPageNum(); + int pageSize = queryDto.getPageSize() == null || queryDto.getPageSize() < 1 ? 10 : queryDto.getPageSize(); + + log.info("开始查询消费记录(内存分组模式),参数:pageNum={}, pageSize={}, userId={}", pageNum, pageSize, queryDto.getUserId()); + + // 1. 查询所有原始数据(不分页,获取全量) + List rawList = accountTransactionMapper.getConsumptionRawList(queryDto); + if (rawList == null || rawList.isEmpty()) { + return new PageInfo<>(new ArrayList<>()); + } + + // 2. 内存分组与聚合 + // 使用 LinkedHashMap 保持按 callId 首次出现的顺序(即按时间倒序) + Map groupedMap = new LinkedHashMap<>(); + + for (AccountTransaction tx : rawList) { + // 处理 callId 为 null 或空字符串的情况,使用 transactionId 作为唯一标识 + String callId = tx.getCallId(); + if (callId == null || callId.isEmpty()) { + callId = "tx_" + tx.getTransactionId(); + } + ConsumptionGroupedDto group = groupedMap.get(callId); + + if (group == null) { + // 第一次遇到该 callId,创建新组 + group = new ConsumptionGroupedDto(); + // 复制基础信息 + group.setTransactionId(tx.getTransactionId()); + group.setUserId(tx.getUserId()); + group.setUserName(tx.getUserName()); + group.setTransactionType(tx.getTransactionType()); + group.setBeforeBalance(tx.getBeforeBalance()); + group.setAfterBalance(tx.getAfterBalance()); + group.setStatus(tx.getStatus()); + group.setTransactionNo(tx.getTransactionNo()); + group.setPayType(tx.getPayType()); + group.setBusinessId(tx.getBusinessId()); + group.setBusinessType(tx.getBusinessType()); + group.setCallId(tx.getCallId()); + group.setRemark(tx.getRemark()); + group.setIsExpense(tx.getIsExpense()); + group.setModelName(tx.getModelName()); + group.setQuestion(tx.getQuestion()); + group.setIncomeType(tx.getIncomeType()); + group.setCreateTime(tx.getCreateTime()); + group.setUpdateTime(tx.getUpdateTime()); + group.setCreateBy(tx.getCreateBy()); + group.setUpdateBy(tx.getUpdateBy()); + group.setDeleteFlag(tx.getDeleteFlag()); + // 初始化聚合字段 + group.setAmount(BigDecimal.ZERO); + group.setInputToken(0); + group.setOutputToken(0); + group.setTotalTokens(0); + groupedMap.put(callId, group); + } + + // 累加数值字段 + if (tx.getAmount() != null) { + group.setAmount(group.getAmount().add(tx.getAmount())); + } + if (tx.getInputToken() != null) { + group.setInputToken(group.getInputToken() + tx.getInputToken()); + } + if (tx.getOutputToken() != null) { + group.setOutputToken(group.getOutputToken() + tx.getOutputToken()); + } + if (tx.getTotalTokens() != null) { + group.setTotalTokens(group.getTotalTokens() + tx.getTotalTokens()); + } + } + + List allGroupedList = new ArrayList<>(groupedMap.values()); + log.info("分组聚合完成,总组数:{}", allGroupedList.size()); + + // 3. 内存分页 + int total = allGroupedList.size(); + int fromIndex = (pageNum - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, total); + + List pageList = fromIndex < total ? allGroupedList.subList(fromIndex, toIndex) : new ArrayList<>(); + + // 4. 构建 PageInfo + PageInfo pageInfo = new PageInfo<>(pageList); + pageInfo.setTotal((long) total); + pageInfo.setPageNum(pageNum); + pageInfo.setPageSize(pageSize); + pageInfo.setPages((total + pageSize - 1) / pageSize); + + log.info("分页成功:total={}, pages={}, 当前页数据量={}", total, pageInfo.getPages(), pageList.size()); + return pageInfo; + } + + + /** + * 分页查询赠送记录 + * + * @param queryDto 查询条件 + * @return 分页结果 + */ + @Override + public PageInfo getGiftPageList(AccountTransactionDto queryDto) { + PageHelper.startPage(queryDto.getPageNum(), queryDto.getPageSize()); + List list = accountTransactionMapper.getGiftPageList(queryDto); + return new PageInfo<>(list); } } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java index a4e647b..24a9e83 100644 --- a/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/CmsContentServiceImpl.java @@ -14,6 +14,9 @@ import com.kexue.skills.entity.request.ImportPathDto; 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; @@ -51,6 +54,9 @@ public class CmsContentServiceImpl implements CmsContentService { @Resource private CmsContentLikeMapper cmsContentLikeMapper; + @Resource + private CmsTagMapper cmsTagMapper; + @Resource private LoginUserCacheUtil loginUserCacheUtil; @@ -77,94 +83,7 @@ public class CmsContentServiceImpl implements CmsContentService { queryDto.setLanguageType(0); } List list = this.cmsContentMapper.getPageList(queryDto); - PageInfo pageInfo = new PageInfo<>(list); - - - if (queryDto.getTitle() != null && !queryDto.getTitle().trim().isEmpty() && list.size() <= 3 && !list.isEmpty()) { - // 获取第一个 skill 的标签 - CmsContent firstSkill = list.get(0); - String tagsStr = firstSkill.getTags(); - if (tagsStr != null && !tagsStr.trim().isEmpty()) { - // 解析标签(逗号分隔) - String[] tags = tagsStr.split(","); - if (tags.length > 0) { - // 构建标签 ID 列表 - Set tagIdSet = new HashSet<>(); - for (String tag : tags) { - if (tag != null && !tag.trim().isEmpty()) { - try { - Long tagId = Long.parseLong(tag.trim()); - tagIdSet.add(tagId); - } catch (NumberFormatException e) { - // 忽略格式不正确的标签 - } - } - } - - //如果传入的tagId不为空,则添加到tagIdSet中 - if (queryDto.getTagId() != null) { - tagIdSet.add(queryDto.getTagId()); - } - - // 如果有有效的标签 ID,查询相关内容 - if (!tagIdSet.isEmpty()) { - // 排除已返回的 contentId - Set existingIds = list.stream() - .map(CmsContent::getContentId) - .collect(Collectors.toSet()); - - // 构建查询条件,使用 tagIdList 一次性查询 - CmsContentDto tagQueryDto = new CmsContentDto(); - tagQueryDto.setDeleteFlag(0); - tagQueryDto.setPublishStatus(2); // 已发布 - tagQueryDto.setTagIdList(new ArrayList<>(tagIdSet)); - tagQueryDto.setPageNum(queryDto.getPageNum()); - tagQueryDto.setPageSize(1000+queryDto.getPageSize()); - - // 查询包含这些标签的所有 skill - List taggedContents = this.cmsContentMapper.getPageList(tagQueryDto); - - //如果 tag 传参,则根据 tag 再次过滤 - if (queryDto.getTagId() != null) { - taggedContents = taggedContents.stream().filter(content -> content.getTags().contains(queryDto.getTagId().toString())).toList() ; - } - - // 过滤掉已返回的内容,避免重复 - List finalList = taggedContents.stream() - .filter(content -> !existingIds.contains(content.getContentId())) - .collect(Collectors.toList()); - - // 如果有相关 skill,添加到结果中 - if (!finalList.isEmpty()) { - // 将外层查询结果和内层查询结果合并 - List allContents = new ArrayList<>(list); - allContents.addAll(finalList); - - // 按 view_count 和 create_time 排序 - allContents.sort((a, b) -> { - int sortCompare = Integer.compare( - a.getViewCount() != null ? a.getViewCount() : 0, - b.getViewCount() != null ? b.getViewCount() : 0); - if (sortCompare != 0) { - return sortCompare; - } - if (a.getCreateTime() == null || b.getCreateTime() == null) { - return 0; - } - return b.getCreateTime().compareTo(a.getCreateTime()); - }); - - // 计算需要添加的数量,确保总数量不超过 pageSize - PageInfo newPageInfo = new PageInfo<>(memoryPagination(allContents, queryDto.getPageNum(), queryDto.getPageSize())); - newPageInfo.setTotal(allContents.size()); - pageInfo = newPageInfo; - } - } - } - } - } - - return pageInfo; + return new PageInfo<>(list); } /** @@ -1102,5 +1021,179 @@ public class CmsContentServiceImpl implements CmsContentService { return totalSuccessCount; } + @Override + public int updateFromPath(ImportPathDto importPathDto, String updateBy) { + int totalSuccessCount = 0; + + try { + // 检查目录是否存在 + File directory = new File(importPathDto.getFilePath()); + if (!directory.exists() || !directory.isDirectory()) { + System.err.println("目录不存在或不是有效目录: " + importPathDto.getFilePath()); + return 0; + } + + // 读取目录下所有 Excel 文件(排除 Office 临时锁定文件) + File[] files = directory.listFiles((dir, name) -> { + // 跳过以 ~$ 开头的临时锁定文件 + if (name.startsWith("~$")) { + return false; + } + return name.endsWith(".xls") || name.endsWith(".xlsx"); + }); + if (files == null || files.length == 0) { + return 0; + } + + // 记录文件总数 + int totalFiles = files.length; + System.out.println("总共发现 " + totalFiles + " 个 Excel 文件需要处理"); + + // 遍历所有 Excel 文件并处理 + for (int i = 0; i < files.length; i++) { + File file = files[i]; + System.out.println("当前处理第 " + (i + 1) + " 个文件,文件名称是:" + file.getName()); + try (FileInputStream fis = new FileInputStream(file)) { + // 读取文件内容到字节数组 + byte[] fileBytes = new byte[(int) file.length()]; + fis.read(fileBytes); + + // 调用 updateFromExcel 方法进行更新 + int successCount = updateFromExcel(fileBytes, updateBy); + totalSuccessCount += successCount; + System.out.println("第 " + (i + 1) + " 个文件处理成功,更新了 " + successCount + " 条记录"); + } catch (Exception e) { + System.err.println("处理文件失败: " + file.getAbsolutePath()); + e.printStackTrace(); + // 单个文件处理失败不影响其他文件 + continue; + } + } + System.out.println("处理完成,共处理 " + totalFiles + " 个文件,成功更新 " + totalSuccessCount + " 条记录"); + } catch (Exception e) { + System.err.println("更新操作失败: " + importPathDto.getFilePath()); + e.printStackTrace(); + } + + return totalSuccessCount; + } + + /** + * 从Excel文件读取数据并更新CmsContent + * + * @param fileBytes Excel文件字节数组 + * @param updateBy 更新人 + * @return 成功更新的记录数 + */ + private int updateFromExcel(byte[] fileBytes, String updateBy) { + int successCount = 0; + + try (InputStream inputStream = new ByteArrayInputStream(fileBytes); + ExcelReader reader = ExcelUtil.getReader(inputStream)) { + + // 获取总行数(包括标题行) + int totalRows = reader.getRowCount(); + if (totalRows <= 1) { + // 只有标题行或空文件 + return 0; + } + + Date now = new Date(); + + // 读取标题行 + List headerObjList = reader.readRow(0); + if (headerObjList == null || headerObjList.isEmpty()) { + return 0; + } + // 转换为List + List headerList = new ArrayList<>(); + for (Object obj : headerObjList) { + headerList.add(obj != null ? obj.toString() : ""); + } + + // 从第二行开始读取数据(第一行为标题行) + for (int rowIndex = 1; rowIndex < totalRows; rowIndex++) { + try { + List rowList = reader.readRow(rowIndex); + if (rowList == null || rowList.isEmpty()) { + continue; + } + + // 转换为Map + Map row = new HashMap<>(); + for (int i = 0; i < headerList.size() && i < rowList.size(); i++) { + row.put(headerList.get(i), rowList.get(i)); + } + if (row.isEmpty()) { + continue; + } + + // 读取关键字段用于查询 + String title = getStringValue(row, "title"); + String origin = getStringValue(row, "origin"); + String tags = getStringValue(row, "tags"); + // 去掉tags中的空格 + if (tags != null) { + tags = tags.replaceAll(" ", ""); + } + String icon = getStringValue(row, "icon"); + + // 根据关键字段查询记录 + CmsContentDto queryDto = new CmsContentDto(); + queryDto.setTitle(title); + queryDto.setOrigin(origin); +// queryDto.setTags(tags); +// queryDto.setIcon(icon); + queryDto.setDeleteFlag(0); + + List existingList = cmsContentMapper.getList(queryDto); + if (existingList != null && !existingList.isEmpty()) { + // 找到匹配的记录,进行更新 + CmsContent existingContent = existingList.get(0); + + // 更新字段 + existingContent.setTitle(getStringValue(row, "title")); + existingContent.setTitleEn(getStringValue(row, "title_en")); + existingContent.setOrigin(getStringValue(row, "origin")); + existingContent.setTags(tags); + existingContent.setIcon(getStringValue(row, "icon")); + existingContent.setPrice(getBigDecimalValue(row, "price")); + existingContent.setContentType(getIntegerValue(row, "content_type")); + existingContent.setContent(getStringValue(row, "content")); + existingContent.setContentEn(getStringValue(row, "content_en")); + existingContent.setAuditStatus(getIntegerValue(row, "audit_status")); + existingContent.setPublishStatus(getIntegerValue(row, "publish_status")); + existingContent.setViewCount(getIntegerValue(row, "view_count")); + existingContent.setLikeCount(getIntegerValue(row, "like_count")); + existingContent.setCommentCount(getIntegerValue(row, "comment_count")); + existingContent.setIsPaid(getIntegerValue(row, "is_paid")); + existingContent.setSupportPointsPay(getIntegerValue(row, "support_points_pay")); + existingContent.setDescription(getStringValue(row, "description")); + existingContent.setDescriptionEn(getStringValue(row, "description_en")); + existingContent.setIntroduce(getStringValue(row, "introduce")); + existingContent.setIntroduceEn(getStringValue(row, "introduce_en")); + + // 设置更新时间和更新人 + existingContent.setUpdateTime(now); + existingContent.setUpdateBy(updateBy); + + // 执行更新 + cmsContentMapper.update(existingContent); + successCount++; + } + } catch (Exception e) { + System.err.println("处理第 " + rowIndex + " 行数据时出错: " + e.getMessage()); + e.printStackTrace(); + // 跳过当前行,继续处理下一行 + continue; + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + return successCount; + } + } \ No newline at end of file diff --git a/src/main/java/com/kexue/skills/service/impl/SysUserServiceImpl.java b/src/main/java/com/kexue/skills/service/impl/SysUserServiceImpl.java index 28cfd76..52da689 100644 --- a/src/main/java/com/kexue/skills/service/impl/SysUserServiceImpl.java +++ b/src/main/java/com/kexue/skills/service/impl/SysUserServiceImpl.java @@ -34,11 +34,15 @@ import org.dromara.sms4j.api.SmsBlend; import org.dromara.sms4j.core.factory.SmsFactory; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import javax.annotation.Resource; import java.math.BigDecimal; import java.security.NoSuchAlgorithmException; import java.util.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.File; +import java.io.IOException; /** * (SysUser)表服务实现类 @@ -80,6 +84,12 @@ public class SysUserServiceImpl implements SysUserService { @Resource private AccountService accountService; + + /** + * 用户头像上传目录 + */ + @Value("${web.upload.userIconPath}") + private String userIconPath; /** * 分页查询数据 @@ -281,7 +291,9 @@ public class SysUserServiceImpl implements SysUserService { // 判断更新的用户是否是当前登录用户,如果是则更新用户缓存 Long currentUserId = loginUserCacheUtil.getCurrentUserId(); if (currentUserId != null && currentUserId.equals(sysUserUpdateDto.getUserId())) { - updateUserCache(currentUserId, sysUser); + // 获取当前登录用户的 token + String token = loginUserCacheUtil.getTokenFromRequest(); + updateUserCache(currentUserId, sysUser, token); } }else { // 如果没有用户ID,创建新用户 @@ -1153,13 +1165,14 @@ public class SysUserServiceImpl implements SysUserService { * * @param userId 用户ID * @param updatedUser 更新后的用户信息 + * @param token 用户的认证token */ - private void updateUserCache(Long userId, SysUser updatedUser) { + @Override + public void updateUserCache(Long userId, SysUser updatedUser, String token) { try { - // 获取当前登录用户的 token - String token = loginUserCacheUtil.getTokenFromRequest(); + // 验证 token 是否有效 if (token == null || token.isEmpty()) { - log.warn("无法获取当前用户的 token,跳过缓存更新"); + log.warn("无效的 token,跳过缓存更新"); return; } @@ -1191,4 +1204,59 @@ public class SysUserServiceImpl implements SysUserService { log.error("更新用户缓存失败:{}", e.getMessage(), e); } } + + /** + * 上传用户头像 + * + * @param file 头像文件 + * @param userId 用户ID + * @param token 用户的认证token + * @return 上传成功的文件名 + */ + @Override + public String uploadAvatar(MultipartFile file, Long userId, String token) { + // 检查文件是否为空 + if (file.isEmpty()) { + throw new BizException("请选择要上传的头像文件"); + } + + // 检查文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new BizException("只能上传图片文件"); + } + + // 生成唯一文件名 + String originalFilename = file.getOriginalFilename(); + String extension = originalFilename != null ? originalFilename.substring(originalFilename.lastIndexOf(".")) : ".jpg"; + String fileName = UUID.randomUUID().toString() + extension; + + // 定义上传目录 + File dir = new File(userIconPath); + if (!dir.exists()) { + dir.mkdirs(); + } + + // 保存文件 + File dest = new File(dir, fileName); + try { + file.transferTo(dest); + } catch (IOException e) { + e.printStackTrace(); + throw new BizException("上传失败:" + e.getMessage()); + } + + // 更新用户头像 + SysUser user = sysUserMapper.queryById(userId); + if (user == null) { + throw new BizException("用户不存在"); + } + user.setUserIcon(fileName); + sysUserMapper.update(user); + + // 更新缓存中的用户信息 + updateUserCache(userId, user, token); + + return fileName; + } } diff --git a/src/main/resources/mapper/AccountTransactionMapper.xml b/src/main/resources/mapper/AccountTransactionMapper.xml index eeb7f19..0ad1eaa 100644 --- a/src/main/resources/mapper/AccountTransactionMapper.xml +++ b/src/main/resources/mapper/AccountTransactionMapper.xml @@ -230,4 +230,230 @@ and delete_flag = 0 order by create_time desc + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mapper/CmsContentMapper.xml b/src/main/resources/mapper/CmsContentMapper.xml index af064b4..052b3b8 100644 --- a/src/main/resources/mapper/CmsContentMapper.xml +++ b/src/main/resources/mapper/CmsContentMapper.xml @@ -121,7 +121,7 @@ and is_official = #{isOfficial} - + and find_in_set(#{tagId}, tags) @@ -131,12 +131,15 @@ ) + + and find_in_set(#{tags}, tags) + - order by ${sortBy} ${sortDesc ? 'desc' : 'asc'} + order by ${sortBy} ${sortDesc ? 'desc' : 'asc'}, content_id desc - order by sort asc, create_time desc + order by sort asc, create_time desc, content_id desc @@ -203,10 +206,10 @@ - order by ${queryDto.sortBy} ${queryDto.sortDesc ? 'desc' : 'asc'} + order by ${queryDto.sortBy} ${queryDto.sortDesc ? 'desc' : 'asc'}, content_id desc - order by sort asc, create_time desc + order by sort asc, create_time desc, content_id desc limit #{offset}, #{limit} @@ -249,6 +252,16 @@ and find_in_set(#{tagId}, tags) + + and ( + + find_in_set(#{tagId}, tags) + + ) + + + and find_in_set(#{tags}, tags) + @@ -307,8 +320,17 @@ ) + + and origin = #{origin} + + + and tags = #{tags} + + + and icon = #{icon} + - order by sort asc, create_time desc + order by sort asc, create_time desc, content_id desc diff --git a/src/main/resources/mapper/SysLogMapper.xml b/src/main/resources/mapper/SysLogMapper.xml index 886597b..b0f0a47 100644 --- a/src/main/resources/mapper/SysLogMapper.xml +++ b/src/main/resources/mapper/SysLogMapper.xml @@ -3,21 +3,39 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + @@ -25,43 +43,44 @@ @@ -69,43 +88,45 @@ - insert into sys_log(USER_ID, USER_NAME, LOG_TYPE, LOG_CONTENT, SERVER_IP, CLIENT_IP, LOG_TIME, NOTE) - values (#{userId}, #{userName}, #{logType}, #{logContent}, #{serverIp}, #{clientIp}, #{logTime}, #{note}) + insert into sys_log( + TRACE_ID, DESCRIPTION, MODULE, REQUEST_URL, REQUEST_METHOD, + REQUEST_HEADERS, REQUEST_BODY, STATUS_CODE, RESPONSE_HEADERS, RESPONSE_BODY, + TIME_TAKEN, IP, ADDRESS, BROWSER, OS, STATUS, ERROR_MSG, CREATE_USER, + CREATE_TIME, UPDATE_TIME, DELETE_FLAG, CREATE_BY, UPDATE_BY + ) + values ( + #{traceId}, #{description}, #{module}, #{requestUrl}, #{requestMethod}, + #{requestHeaders}, #{requestBody}, #{statusCode}, #{responseHeaders}, #{responseBody}, + #{timeTaken}, #{ip}, #{address}, #{browser}, #{os}, #{status}, #{errorMsg}, #{createUser}, + #{createTime}, #{updateTime}, #{deleteFlag}, #{createBy}, #{updateBy} + ) @@ -134,29 +155,62 @@ update sys_log - - USER_ID = #{userId}, + + TRACE_ID = #{traceId}, - - USER_NAME = #{userName}, + + DESCRIPTION = #{description}, - - LOG_TYPE = #{logType}, + + MODULE = #{module}, - - LOG_CONTENT = #{logContent}, + + REQUEST_URL = #{requestUrl}, - - SERVER_IP = #{serverIp}, + + REQUEST_METHOD = #{requestMethod}, - - CLIENT_IP = #{clientIp}, + + REQUEST_HEADERS = #{requestHeaders}, - - LOG_TIME = #{logTime}, + + REQUEST_BODY = #{requestBody}, - - NOTE = #{note}, + + STATUS_CODE = #{statusCode}, + + + RESPONSE_HEADERS = #{responseHeaders}, + + + RESPONSE_BODY = #{responseBody}, + + + TIME_TAKEN = #{timeTaken}, + + + IP = #{ip}, + + + ADDRESS = #{address}, + + + BROWSER = #{browser}, + + + OS = #{os}, + + + STATUS = #{status}, + + + ERROR_MSG = #{errorMsg}, + + + CREATE_USER = #{createUser}, + + + UPDATE_BY = #{updateBy}, where LOG_ID = #{logId} diff --git a/src/main/resources/mapper/SysUserMapper.xml b/src/main/resources/mapper/SysUserMapper.xml index 3c0bc9a..a5e53b5 100644 --- a/src/main/resources/mapper/SysUserMapper.xml +++ b/src/main/resources/mapper/SysUserMapper.xml @@ -18,12 +18,13 @@ + @@ -31,7 +32,7 @@ select - user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by + user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by, user_icon from sys_user limit #{offset}, #{limit} @@ -81,7 +82,7 @@ select - user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by + user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by, user_icon from sys_user where (user_name = #{userName} or tel = #{userName}) and delete_flag = 0 @@ -214,7 +218,7 @@ select - user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by + user_id, user_name, pwd, real_name, tel, email, salt, remark, create_time, enable, delete_flag, session_id, invite_code, invited_code, invited_by, user_icon from sys_user where invite_code = #{inviteCode} and delete_flag = 0