初始化

Spring Initializr

Spring Initializr
Maven + Java 21 + 4.0.0
Lombok + Spring Web +Spring Boot DevTools + Spring Configuration Processor + MySQL Driver + MyBatis Framework + Spring Data Elasticsearch

Spring AOP

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>4.0.0-M2</version>
</dependency>

MyBatis-plus

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.baomidou/mybatis-plus-spring-boot3-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.15</version>
</dependency>
1
2
3
4
5
6
<!-- mybatis-plus 分页插件 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>3.5.15</version>
</dependency>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 删除这个 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>4.0.0</version>
</dependency>

<!-- 删除这个(测试用的也不需要) -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>4.0.0</version>
<scope>test</scope>
</dependency>

commons-lang3

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.20.0</version>
</dependency>

hutool

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/cn.hutool/hutool-all -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.42</version>
</dependency>

腾讯云cos

1
2
3
4
5
6
<!--https://search.maven.org/artifact/com.qcloud/cos_api-->
<dependency>
<groupId>com.qcloud</groupId>
<artifactId>cos_api</artifactId>
<version>5.6.259</version>
</dependency>

微信公众号

1
2
3
4
5
6
<!-- https://github.com/binarywang/WxJava -->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>4.7.0</version>
</dependency>
1
2
3
4
5
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>wx-java-mp-spring-boot-starter</artifactId>
<version>4.7.0</version>
</dependency>

freemarker 加载模板、填充数据并生成输出文本

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.freemarker/freemarker -->
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.34</version>
</dependency>

Elasticsearch

1
2
3
4
5
6
<!-- https://www.elastic.co/docs/reference/elasticsearch/clients/java/getting-started -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>9.2.2</version>
</dependency>
1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-elasticsearch -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
<version>4.0.0</version>
</dependency>

SpringDoc

springdoc
springdoc-openapi-starter-webmvc-ui

1
2
3
4
5
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>3.0.0</version>
</dependency>

Swagger UI 界面 http://localhost:8101/api/swagger-ui.html
OpenAPI JSON 文档 http://localhost:8101/api/v3/api-docs

fast excel

fast excel
Install-Package FastExcel

数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
-- 创建库
create database if not exists omni_db;

-- 切换库
use omni_db;

-- 用户表
create table if not exists user
(
id bigint auto_increment comment 'id' primary key,
user_account varchar(256) not null comment '账号',
user_password varchar(512) not null comment '密码',
union_id varchar(256) null comment '微信开放平台id',
mpOpen_id varchar(256) null comment '公众号openId',
user_name varchar(256) null comment '用户昵称',
user_avatar varchar(1024) null comment '用户头像',
user_profile varchar(512) null comment '用户简介',
user_role varchar(256) default 'user' not null comment '用户角色:user/admin/ban',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
is_delete tinyint default 0 not null comment '是否删除',
index idx_unionId (union_id)
) comment '用户' collate = utf8mb4_unicode_ci;

-- 帖子表
create table if not exists post
(
id bigint auto_increment comment 'id' primary key,
title varchar(512) null comment '标题',
content text null comment '内容',
tags varchar(1024) null comment '标签列表(json 数组)',
thumb_num int default 0 not null comment '点赞数',
favour_num int default 0 not null comment '收藏数',
user_id bigint not null comment '创建用户 id',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
is_delete tinyint default 0 not null comment '是否删除',
index idx_userId (user_id)
) comment '帖子' collate = utf8mb4_unicode_ci;

-- 帖子点赞表(硬删除)
create table if not exists post_thumb
(
id bigint auto_increment comment 'id' primary key,
post_id bigint not null comment '帖子 id',
user_id bigint not null comment '创建用户 id',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
index idx_postId (post_id),
index idx_userId (user_id)
) comment '帖子点赞';

-- 帖子收藏表(硬删除)
create table if not exists post_favour
(
id bigint auto_increment comment 'id' primary key,
post_id bigint not null comment '帖子 id',
user_id bigint not null comment '创建用户 id',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
index idx_postId (post_id),
index idx_userId (user_id)
) comment '帖子收藏';

主程序

1
2
3
4
5
6
7
8
9
10
11
// todo 如需开启 Redis,须移除 exclude 中的内容 RedisAutoConfiguration.class
@SpringBootApplication
@MapperScan("fun.wgfun.Omni.mapper")
@EnableScheduling
@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
public class OmniApplication {

public static void main(String[] args) {
SpringApplication.run(OmniApplication.class, args);
}
}

@EnableScheduling:启用 Spring 定时任务
告诉 Spring 容器:“请开启对 @Scheduled 注解的支持”,从而允许在任意被 Spring 管理的 Bean 中,通过 @Scheduled 注解定义定时执行的方法。
@Scheduled(fixedRate = 5000)5s执行一次

@EnableAspectJAutoProxy(proxyTargetClass = true, exposeProxy = true)
用于开启 Spring AOP 的核心注解,特别针对 @Aspect 切面 和 @Transactional、@Cacheable 等代理型注解。
proxyTargetClass = true 强制 Spring 使用 CGLIB 代理,而不是 JDK 动态代理
exposeProxy = true 将当前代理对象暴露到 ThreadLocal 中,允许在目标方法内部通过 AopContext.currentProxy() 获取代理对象。

权限效验注解 AuthCheck

1
2
3
4
5
6
7
8
9
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthCheck {
/**
* 必须有角色 role:user
* @return ""
*/
String mustRole() default "";
}

@Target(ElementType.METHOD) 表示该注解用于方法上

@Retention(RetentionPolicy.RUNTIME) 表示该注解在运行时生效

  • SOURCE:只在源码中存在,编译后丢弃(如 @Override)
  • CLASS:保留在字节码中,但运行时不可见(默认值)
  • RUNTIME:保留在字节码 + 运行时可通过反射获取(本例所需)

定义名为 AuthCheck 的注解。
有一个属性(成员):mustRole(),类型为 String。
默认值是空字符串 “”,表示“如果没有指定角色,则不强制校验”或“允许任何人访问”。
配合 AOP 实现权限控制(典型用法)

AOP 切面

AuthInterceptor 权限校验AOP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 权限校验 AOP
*/
@Aspect
@Component
public class AuthInterceptor {
// 使用userService
@Resource
private UserService userService;
/**
* 执行拦截
*
* @param joinPoint
* @param authCheck
* @return
*/
@Around("@annotation(authCheck)")
public Object doInterceptor(ProceedingJoinPoint joinPoint, AuthCheck authCheck) throws Throwable {
String mustRole = authCheck.mustRole();
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
// 当前登录用户
User loginUser = userService.getLoginUser(request);
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
// 不需要权限,放行
if (mustRoleEnum == null) {
return joinPoint.proceed();
}
// 必须有该权限才通过
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUser_role());
if (userRoleEnum == null) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 如果被封号,直接拒绝
if (UserRoleEnum.BAN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 必须有管理员权限
if (UserRoleEnum.ADMIN.equals(mustRoleEnum)) {
// 用户没有管理员权限,拒绝
if (!UserRoleEnum.ADMIN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
// 通过权限校验,放行
return joinPoint.proceed();
}
}

整体作用

基于 Spring AOP 的权限校验切面(Aspect)

  • 目标:通过 @AuthCheck 注解实现 方法级别的权限控制
  • 机制:使用 @Around 环绕通知,在目标方法执行前后插入权限校验逻辑。
  • 效果:开发者只需在需要保护的方法上加注解,无需重复写权限判断代码。
    实现了:
  • 基于角色的访问控制(RBAC)
  • 封号用户拦截
  • 无侵入式权限管理

主注解

@Aspect 表示该类是一个切面类,用于定义切面逻辑。
@Component 让 Spring 容器自动扫描并注册为 Bean。

依赖注入

  • 注入 UserService,用于获取当前登录用户信息。
  • 使用 @Resource(按名称注入)而非 @Autowired

切点表达式

@Around("@annotation(authCheck)")

  • 拦截所有被 @AuthCheck 注解修饰的方法。
  • authCheck 参数会自动绑定到该注解实例。

    标准的注解驱动 AOP 写法

获取当前请求与用户

1
2
3
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
User loginUser = userService.getLoginUser(request);
  • RequestContextHolder 中获取当前 HTTP 请求。
  • 调用 userService.getLoginUser(request) 解析登录用户(通常从 Token 或 Session 中提取)。

    前提:必须运行在 Web 环境中,否则 currentRequestAttributes() 会抛异常。

权限校验主逻辑

如果未指定角色(mustRole = ""

1
2
3
4
UserRoleEnum mustRoleEnum = UserRoleEnum.getEnumByValue(mustRole);
if (mustRoleEnum == null) {
return joinPoint.proceed(); // 放行
}
  • @AuthCheck 未设置 mustRole(默认空字符串),则跳过权限校验。
  • 合理设计:允许仅“需要登录”而不指定角色的场景。

用户角色为空或被封号

1
2
3
4
UserRoleEnum userRoleEnum = UserRoleEnum.getEnumByValue(loginUser.getUser_role());
if (userRoleEnum == null || UserRoleEnum.BAN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
  • 用户角色无效(如数据库存了非法值)或已被封号(BAN),直接拒绝。

管理员权限校验

1
2
3
4
5
if (UserRoleEnum.ADMIN.equals(mustRoleEnum)) {
if (!UserRoleEnum.ADMIN.equals(userRoleEnum)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
}
  • 如果方法要求 admin 权限,则只有 admin 用户可通过。
  • 注意:这里 没有处理普通用户(user)的情况 —— 实际上,只要不是 admin 要求,普通用户都能通过。

    符合 RBAC(基于角色的访问控制)模型。

LogInterceptor 请求响应日志AOP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 请求响应日志 AOP
**/
@Aspect
@Component
@Slf4j
public class LogInterceptor {
/**
* 执行拦截
*/
@Around("execution(* fun.wgfun.Omni.controller.*.*(..))")
public Object doInterceptor(ProceedingJoinPoint point) throws Throwable {
// 计时
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// 获取请求路径
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest httpServletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();
// 生成请求唯一 id
String requestId = UUID.randomUUID().toString();
String url = httpServletRequest.getRequestURI();
// 获取请求参数
Object[] args = point.getArgs();
String reqParam = "[" + StringUtils.join(args, ", ") + "]";
// 输出请求日志
log.info("request start,id: {}, path: {}, ip: {}, params: {}", requestId, url,
httpServletRequest.getRemoteHost(), reqParam);
// 执行原方法
Object result = point.proceed();
// 输出响应日志
stopWatch.stop();
long totalTimeMillis = stopWatch.getTotalTimeMillis();
log.info("request end, id: {}, cost: {}ms", requestId, totalTimeMillis);
return result;
}
}

基于 Spring AOP 的请求日志拦截器(LogInterceptor)
用于在 Web 请求进入 Controller 时自动记录:

  • 请求唯一 ID
  • 请求路径(URL)
  • 客户端 IP
  • 请求参数
  • 方法执行耗时

核心功能

功能 说明
请求追踪 通过 requestId 唯一标识一次请求,便于日志关联排查问题
性能监控 使用 StopWatch 精确统计方法执行时间(毫秒级)
入参记录 记录所有 Controller 方法的入参,方便调试和审计
无侵入性 业务代码无需手动打日志,AOP 自动完成

关键代码解析

切点表达式

@Around("execution(* fun.wgfun.Omni.controller.*.*(..))")

  • 拦截 fun.wgfun.Omni.controller 包下所有类的所有方法。
  • * 表示任意返回类型,(..) 表示任意参数。

    覆盖所有 Controller,适合统一日志记录。

获取请求上下文

1
2
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
HttpServletRequest httpServletRequest = ((ServletRequestAttributes) requestAttributes).getRequest();
  • RequestContextHolder 中获取当前 HTTP 请求。
  • 前提:必须运行在 Web 环境(如 Spring MVC),否则会抛 IllegalStateException

生成请求 ID

String requestId = UUID.randomUUID().toString();

  • 每次请求生成唯一 ID,可用于:
    • 日志链路追踪(结合 MDC 可传递到子线程)
    • 排查特定请求的问题

      建议:将 requestId 放入 MDC(Mapped Diagnostic Context),实现全链路日志标记。

记录请求参数

1
2
Object[] args = point.getArgs();
String reqParam = "[" + StringUtils.join(args, ", ") + "]";
  • 使用 StringUtils.join(来自 Apache Commons Lang 或 Spring)拼接入参。
  • 对象会调用 toString(),若未重写,可能输出 User@1a2b3c 这类无意义内容。

    风险:如果参数包含敏感信息(如密码、Token),会直接打印到日志中!

性能计时

1
2
3
4
5
StopWatch stopWatch = new StopWatch();
stopWatch.start();
// ... proceed ...
stopWatch.stop();
long totalTimeMillis = stopWatch.getTotalTimeMillis();
  • StopWatch 是 Spring 提供的轻量级计时工具,精度高、开销小。
  • 记录的是 Controller 方法执行时间(不包括网络传输、Filter 等)。

通用类 common

BaseResponse 通用返回类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 通用返回类
* @param <T>
*/
@Data
public class BaseResponse<T> implements Serializable {
private int code;
private T data;
private String message;
// 构造器1:完整参数
public BaseResponse(int code, T data, String message) {
this.code = code;
this.data = data;
this.message = message;
}
// 构造器2:无 message(成功场景常用)
public BaseResponse(int code, T data) {
this(code, data, "");
}
// 构造器3:直接传 ErrorCode(失败场景常用)
public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
}

通用的 API 响应封装类 BaseResponse<T>
用于统一后端接口的返回格式。它是构建 RESTful API 时的最佳实践之一,能显著提升前端对接体验和系统可维护性。
下面从设计目的、代码结构、使用方式、潜在问题及优化建议几个方面进行详细解析:

为什么需要 BaseResponse

  • 统一返回格式:避免有的接口返回 {data: ...},有的返回 {result: ..., msg: ...}
  • 便于前端处理:前端只需按固定结构(code/data/message)解析响应。
  • 错误标准化:结合 ErrorCode 枚举,实现错误码与提示语的集中管理。
  • 类型安全:通过泛型 <T> 支持任意数据类型(User、PageResult、List 等)。

关键点说明:

特性 说明
@Data Lombok 注解,自动生成 getter/setter/toString/equals/hashCode
Serializable 支持序列化(如存入 Session、缓存、RPC 传输)
泛型 <T> data 字段可容纳任意类型,类型安全
三个构造器 覆盖常见使用场景

典型使用方式(配合工具类)

通常会搭配一个ResultUtils 工具类使用:

1
2
3
4
5
6
7
8
9
public class ResultUtils {
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(200, data);
}

public static BaseResponse<Void> error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}
}

在 Controller 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping("/user/{id}")
public BaseResponse<User> getUser(@PathVariable Long id) {
User user = userService.getById(id);
return ResultUtils.success(user); // 自动封装为 {code:200, data:user}
}

@PostMapping("/login")
public BaseResponse<String> login(String username, String password) {
if (invalid) {
return ResultUtils.error(ErrorCode.PARAMS_ERROR); // {code:400, message:"参数错误"}
}
return ResultUtils.success(token);
}

缺少空安全(Null Safety)

  • 如果 T datanull,某些 JSON 序列化库(如 Jackson)可能不输出 data 字段,导致前端报错。
    建议:确保 JSON 序列化配置包含 null 字段,或在前端做兼容。
1
2
3
4
// Jackson 配置(application.yml)
spring:
jackson:
default-property-inclusion: always

最佳实践总结

实践 说明
始终返回 BaseResponse 所有 Controller 方法统一返回类型
成功用 ResultUtils.success() 避免手动 new,提高一致性
失败用 ResultUtils.error(ErrorCode.XXX) 错误码集中管理
不要在 data 中放错误信息 错误信息只出现在 message
前端根据 code === 200 判断成功 而非 HTTP 状态码

DeleteRequest 删除请求

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 删除请求
*/
@Data
public class DeleteRequest implements Serializable {
/**
* id
*/
private Long id;

@Serial
private static final long serialVersionUID = 1L;
}

通用删除请求 DTO(Data Transfer Object)
用于在前后端交互中安全、清晰地传递“要删除的资源 ID”。

为什么需要 DeleteRequest

  • 封装删除操作的输入参数:避免直接在 URL 或方法参数中暴露 ID(尤其在 POST/DELETE 请求中)。
  • 统一接口风格:所有删除操作都使用端调用和后端校验。
  • 支持未来扩展:如果将来需要增加“删除原因”、“操作人 ID”等字段,可直接在该类中添加,无需修改接口签名。

代码逐行解析

implements Serializable:标记该类可序列化,适用于:

  • 存入 Session
  • 缓存(如 Redis)
  • RPC 调用(如 Dubbo、Feign)

private Long id;

  • 使用 Long 而非 long:允许 null 值,便于参数校验(如 @NotNull)。
  • 字段名 id 是通用命名,符合 RESTful 和数据库主键惯例。
1
2
@Serial
private static final long serialVersionUID = 1L;
  • serialVersionUID:用于 Java 序列化时的版本控制。
  • @Serial(Java 17+ 新增注解):标记该字段是序列化相关的,提升代码可读性。

    如果你使用的是 Java < 17,可省略 @Serial,仅保留 serialVersionUID

是否需要批量删除?

  • 当前只支持单个 ID 删除。
  • 如需批量,可另建 BatchDeleteRequest
    1
    2
    3
    public class BatchDeleteRequest {
    private List<Long> ids;
    }

ErrorCode 自定义错误码枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 自定义错误码
*/
@Getter
public enum ErrorCode {
SUCCESS(0, "ok"),
// 客户端错误:40000 - 49999
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN_ERROR(40100, "未登录"),
NO_AUTH_ERROR(40101, "无权限"),
NOT_FOUND_ERROR(40400, "请求数据不存在"),
FORBIDDEN_ERROR(40300, "禁止访问"),
// 服务端错误:50000 - 59999
SYSTEM_ERROR(50000, "系统内部异常"),
OPERATION_ERROR(50001, "操作失败");

/**
* 状态码,信息
*/
private final int code;
private final String message;

ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
}

自定义错误码枚举类 ErrorCode
用于在项目中统一管理业务异常的状态码和提示信息。

为什么需要自定义错误码?

  1. 统一错误格式:避免不同开发者随意抛出 "error""fail" 等不一致的提示。
  2. 前后端协作清晰:前端通过 code 判断错误类型(如 40100 跳登录页),通过 message 展示用户友好提示。
  3. 便于日志追踪与监控:错误码可作为指标(如“今日 NO_AUTH_ERROR 发生 100 次”)。
  4. 解耦 HTTP 状态码:业务错误仍返回 HTTP 200,由 code 字段表达业务状态(现代 API 常见做法)。
    典型响应示例:
1
2
3
4
5
{
"code": 40100,
"data": null,
"message": "未登录"
}

关键点说明:

特性 说明
@Getter Lombok 注解,为 codemessage 自动生成 getter 方法
final 字段 确保枚举值不可变,线程安全
构造器私有 枚举天然单例,防止外部实例化
错误码设计 采用 5 位数字,前 3 位模拟 HTTP 状态码语义(如 401xx 表示认证问题)

抛出业务异常

1
2
3
if (user == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}

BaseResponse 中直接使用

1
2
3
4
5
6
// BaseResponse 的构造器支持直接传 ErrorCode
public BaseResponse(ErrorCode errorCode) {
this(errorCode.getCode(), null, errorCode.getMessage());
}
// Controller 中
return new BaseResponse<>(ErrorCode.NO_AUTH_ERROR);

PageRequest 分页请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 分页请求
*/
@Data
public class PageRequest {

/**
* 当前页号
*/
private int current = 1;

/**
* 页面大小
*/
private int pageSize = 10;

/**
* 排序字段
*/
private String sortField;

/**
* 排序顺序(默认升序)
*/
private String sortOrder = CommonConstant.SORT_ORDER_ASC;
}

通用分页请求参数类 PageRequest
用于在前后端交互中标准化分页查询的输入。

为什么需要 PageRequest

  • 统一前端分页参数格式:避免每个接口重复定义 pageNumpageSize 等字段。
  • 支持动态排序:允许前端指定按哪个字段排序(如按“创建时间”倒序)。
  • 默认值兜底:防止因未传参导致空指针或非法分页(如 pageSize=0)。
  • 与 MyBatis-Plus / PageHelper 无缝集成:可直接转换为分页插件所需的参数。
    典型请求示例:
1
2
3
4
5
6
{
"current": 2,
"pageSize": 20,
"sortField": "createTime",
"sortOrder": "desc"
}

字段详解

字段 类型 默认值 说明
current int 1 当前页码(从 1 开始),避免前端传 0 导致异常
pageSize int 10 每页记录数,防止过大(如 10000)导致数据库压力
sortField String null 要排序的数据库字段名(如 "id", "createTime"
sortOrder String "asc" 排序方向,通常为 "asc""desc"

💡 CommonConstant.SORT_ORDER_ASC 很可能定义为:

1
2
3
4
public class CommonConstant {
public static final String SORT_ORDER_ASC = "asc";
public static final String SORT_ORDER_DESC = "desc";
}

1. Controller 层接收

1
2
3
4
@PostMapping("/list")
public BaseResponse<PageResult<PostVO>> listPosts(@Valid @RequestBody PageRequest pageRequest) {
return postService.listPosts(pageRequest);
}

2. Service 层转换为 MyBatis-Plus 分页对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public PageResult<PostVO> listPosts(PageRequest pageRequest) {
// 1. 校验 pageSize(防止过大)
int pageSize = Math.min(pageRequest.getPageSize(), 50); // 最大50条/页

// 2. 构建 MP 分页对象
Page<Post> page = new Page<>(pageRequest.getCurrent(), pageSize);

// 3. 处理排序(关键!需防 SQL 注入)
String sortField = pageRequest.getSortField();
String sortOrder = pageRequest.getSortOrder();

if (StringUtils.isNotBlank(sortField) && isValidSortField(sortField)) {
if (CommonConstant.SORT_ORDER_DESC.equals(sortOrder)) {
page.addOrder(OrderItem.desc(sortField));
} else {
page.addOrder(OrderItem.asc(sortField));
}
} else {
page.addOrder(OrderItem.desc("createTime")); // 默认按时间倒序
}

// 4. 执行查询
Page<Post> postPage = postMapper.selectPage(page, null);

// 5. 转换为 VO 并返回
return new PageResult<>(postPage.getRecords().stream().map(PostVO::new).collect(Collectors.toList()),
postPage.getTotal());
}

危险操作

如果直接将前端传来的 sortField 拼接到 SQL 中:

1
2
// 绝对禁止!
String sql = "SELECT * FROM post ORDER BY " + sortField;

攻击者可传入:

1
{ "sortField": "id; DROP TABLE user--" }

导致 数据库被删除

安全解决方案

方法 1:白名单校验(推荐)

1
2
3
4
5
6
private static final Set<String> ALLOWED_SORT_FIELDS = 
Set.of("id", "createTime", "updateTime", "likeCount");

private boolean isValidSortField(String field) {
return ALLOWED_SORT_FIELDS.contains(field);
}

方法 2:使用 ORM 安全 API(如 MyBatis-Plus 的 OrderItem

  • MyBatis-Plus 的 OrderItem.asc("field") 会对字段名做 关键字转义(但依然建议配合白名单)。
  • 注意:MP 并非 100% 防注入,白名单是最可靠的方式

增加参数校验注解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public class PageRequest {
@Min(value = 1, message = "页码不能小于1")
private int current = 1;

@Min(value = 1, message = "每页大小至少为1")
@Max(value = 50, message = "每页最多50条")
private int pageSize = 10;

private String sortField;

@Pattern(regexp = "^(asc|desc)$", message = "排序顺序必须是 asc 或 desc")
private String sortOrder = CommonConstant.SORT_ORDER_ASC;
}

并在 Controller 启用校验:

1
public BaseResponse<?> list(@Valid @RequestBody PageRequest request)

2. 支持多字段排序(高级需求)

如果未来需要按多个字段排序(如先按类型,再按时间),可扩展为:

1
private List<SortItem> sorts; // SortItem { field, order }

3. 默认排序策略

  • 建议在业务层设置 合理的默认排序(如帖子按 createTime DESC),而非依赖前端传参。
  • 避免无排序导致结果不稳定(数据库不保证无 ORDER BY 时的返回顺序)。

最佳实践总结

实践 说明
始终校验 sortField 白名单 防止 SQL 注入(最重要!)
限制 pageSize 上限 避免 pageSize=999999 拖垮数据库
页码从 1 开始 符合用户习惯(区别于程序从 0 开始)
默认排序兜底 即使前端不传排序,也要有合理默认值
使用 DTO 而非原始参数 保持接口整洁,便于扩展

PageRequest 是一个 设计良好、实用性强 的分页参数封装类,但在实际使用中必须注意:

排序字段的安全校验是重中之重!
只要配合 白名单机制 + 参数校验 + 合理默认值,它就能成为一个安全、高效、可复用的分页基础设施,完全满足生产环境需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 返回工具类
*/
public class ResultUtils {

/**
* 成功
*
* @param data
* @param <T>
* @return
*/
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
//return new BaseResponse<>(ErrorCode.SUCCESS.getCode(), data, ErrorCode.SUCCESS.getMessage());
//这样能保证与 `ErrorCode` 枚举完全一致,避免魔法值。
}
//有时操作成功但无需返回数据(如删除操作)
public static BaseResponse<Void> success() {
return success(null);
}

/**
* 失败
*
* @param errorCode
* @return
*/
public static BaseResponse<?> error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}

/**
* 失败
*
* @param code
* @param message
* @return
*/
public static BaseResponse<?> error(int code, String message) {
return new BaseResponse(code, null, message);
}

/**
* 失败
*
* @param errorCode
* @return
*/
public static BaseResponse<?> error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
}

通用响应构造工具类 ResultUtils
用于快速、统一地构建 BaseResponse<T> 对象。

核心作用

  • 封装响应创建逻辑:避免在 Controller 中到处写 new BaseResponse<>(...)
  • 统一成功/失败格式:确保所有接口返回结构一致。
  • 解耦错误码与响应构造:通过 ErrorCode 枚举实现“一处定义,处处使用”。
    典型使用:
1
2
3
4
5
6
// 成功
return ResultUtils.success(user);
// 失败(标准错误)
return ResultUtils.error(ErrorCode.NO_AUTH_ERROR);
// 失败(自定义消息)
return ResultUtils.error(40001, "用户名已存在");

success(T data)

1
2
3
public static <T> BaseResponse<T> success(T data) {
return new BaseResponse<>(0, data, "ok");
}
  • 用途:返回成功结果。
  • 硬编码问题:直接写死 code=0message="ok"
    缺点:如果未来 SUCCESS 的 code 或 message 变更(如改为 200),需同步修改此处。

error(ErrorCode errorCode)

1
2
3
public static BaseResponse error(ErrorCode errorCode) {
return new BaseResponse<>(errorCode);
}
  • 用途:最常用的失败返回方式。
  • 优点:简洁、类型安全、集中管理错误信息。

error(int code, String message)

1
2
3
public static BaseResponse error(int code, String message) {
return new BaseResponse(code, null, message);
}
  • 用途:临时性或动态错误(如第三方接口返回的特定错误)。
  • 风险:绕过 ErrorCode 枚举,可能导致错误码不统一。
  • 建议:仅在确实无法预定义错误码时使用(如对接外部系统)。

error(ErrorCode errorCode, String message)

1
2
3
public static BaseResponse error(ErrorCode errorCode, String message) {
return new BaseResponse(errorCode.getCode(), null, message);
}
  • 用途:使用标准错误码,但覆盖默认提示信息
  • 典型场景
    ResultUtils.error(ErrorCode.PARAMS_ERROR, "手机号格式不正确");

最佳实践总结

实践 说明
优先使用 error(ErrorCode) 保证错误码标准化
动态提示用 error(ErrorCode, customMsg) 兼顾分类与用户体验
避免硬编码 0"ok" 改用 ErrorCode.SUCCESS
补充无参 success() 用于无返回值的成功操作
保持方法简洁 不要在此做复杂逻辑

配置类 config

全局跨域配置 CorsConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 全局跨域配置
*/
@Configuration
public class CorsConfig implements WebMvcConfigurer {

// @Value("${cors.allowed-origins:*}")
// private String[] allowedOrigins;

@Override
public void addCorsMappings(CorsRegistry registry) {
// 覆盖所有请求
registry.addMapping("/**")
// 允许发送 Cookie
.allowCredentials(true)
// 放行哪些域名(必须用 patterns,否则 * 会和 allowCredentials 冲突)
.allowedOriginPatterns("*") // .allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
// .allowedHeaders("Authorization", "Content-Type", "X-Requested-With")
// .exposedHeaders("X-Total-Count", "X-Trace-Id");
.allowedHeaders("*")
.exposedHeaders("*");
}
}

Spring Boot 项目中的全局跨域(CORS)配置类
用于解决前后端分离开发中常见的“跨域请求被浏览器拦截”问题。

代码逐行解析

@Configuration:标记为 Spring 配置类。
WebMvcConfigurer:Spring MVC 的扩展接口,用于自定义 Web 行为(如 CORS、拦截器、静态资源等)。

1
2
3
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
  • addMapping("/**"):对 所有路径/api/**, /user/** 等)启用 CORS。

关键配置项:

配置 说明
.allowCredentials(true) 允许携带 Cookie(如 Session、Token)
此时 allowedOrigins("*") 会失效!必须用 allowedOriginPatterns("*")
.allowedOriginPatterns("*") 允许任意来源的请求(支持通配符)
Spring Boot 2.4+ 推荐方式,兼容 allowCredentials=true
.allowedMethods(...) 允许的 HTTP 方法(覆盖 RESTful 常用方法)
.allowedHeaders("*") 允许客户端发送任意请求头(如 Authorization, Content-Type
.exposedHeaders("*") 允许前端读取响应头中的任意字段(如自定义 X-Trace-Id

关键安全与兼容性问题

  1. allowCredentials(true) + allowedOriginPatterns("*") 是否安全?
  • 在生产环境中,allowedOriginPatterns("*") 是高风险配置!
  • 攻击者可伪造任意域名发起请求,并携带用户 Cookie(如登录态),导致 CSRF(跨站请求伪造)

生产环境正确做法

1
2
3
.allowedOriginPatterns("https://your-frontend.com", "https://admin.your-frontend.com")
// 或使用配置文件
.allowedOriginPatterns(env.getProperty("cors.allowed-origins", String[].class))

开发环境可用 "*",但上线前务必限制为可信域名

  1. 为什么不用 allowedOrigins("*")
  • allowCredentials(true) 时,浏览器禁止 Access-Control-Allow-Origin: *
  • Spring 会报错:
    1
    When allowCredentials is true, allowedOrigins cannot contain the special value "*"
  • allowedOriginPatterns("*") 是 Spring Boot 2.4+ 引入的解决方案,支持通配符且兼容 credentials。
  1. exposedHeaders("*") 的作用
  • 默认情况下,前端 JS 只能读取以下响应头:
    • Cache-Control
    • Content-Language
    • Content-Type
    • Expires
    • Last-Modified
    • Pragma
  • 如果你的 API 返回了自定义头(如 X-RateLimit-Limit),必须通过 exposedHeaders 暴露,否则前端无法读取。

    设为 "*" 虽方便,但生产环境建议显式列出所需头,减少信息泄露。

区分环境配置s

application.yml

1
2
3
4
5
6
7
8
9
# 开发环境
cors:
allowed-origins: "*"

# 生产环境
cors:
allowed-origins:
- "https://your-app.com"
- "https://admin.your-app.com"

限制 allowedHeaders

避免开放所有头,只允许必要字段:

1
.allowedHeaders("Authorization", "Content-Type", "X-Requested-With", "Accept")

考虑使用网关统一处理 CORS

  • 在微服务架构中,CORS 应由 API 网关(如 Spring Cloud Gateway、Nginx) 统一处理。
  • 后端服务可关闭 CORS,减少重复配置。

最终建议:

  • 开发阶段:保留当前配置,提升开发效率。
  • 上线前
    1. allowedOriginPatterns("*") 替换为具体域名列表
    2. 限制 allowedHeadersexposedHeaders
    3. 通过配置文件管理,支持多环境

腾讯云对象存储客户端 CosClientConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
* 腾讯云对象存储客户端
*/
@Validated //JSR-303 校验
@Configuration //配置文件
@ConfigurationProperties(prefix = "cos.client")
@Data
public class CosClientConfig {
/**
* accessKey
*/
@NotBlank(message = "COS accessKey 不能为空")
private String accessKey;
/**
* secretKey
*/
@NotBlank(message = "COS secretKey 不能为空")
private String secretKey;
/**
* 区域
*/
@NotBlank(message = "COS region 不能为空")
private String region;
/**
* 桶名
*/
@NotBlank(message = "COS bucket 不能为空")
private String bucket;

@Bean
public COSClient cosClient() {
// 初始化用户身份信息(secretId, secretKey)
COSCredentials cred = new BasicCOSCredentials(accessKey, secretKey);
// 设置bucket的区域, COS地域的简称请参照 https://www.qcloud.com/document/product/436/6224
ClientConfig clientConfig = new ClientConfig(new Region(region));

// 设置 HTTP 超时(单位:毫秒)
clientConfig.setConnectionTimeout(5000); // 连接超时
clientConfig.setSocketTimeout(30000); // 读取超时

// 启用失败重试(默认已开启,可调整次数)
clientConfig.setMaxErrorRetry(3);

// 生成cos客户端
return new COSClient(cred, clientConfig);
}
}

腾讯云对象存储(COS)客户端的自动配置类 CosClientConfig
用于在 Spring Boot 项目中通过配置文件注入 COS 凭据并初始化 COSClient Bean。

核心功能

  • 自动读取配置:通过 @ConfigurationProperties(prefix = "cos.client")application.yml 加载 COS 配置。
  • 创建 COS 客户端:使用 accessKey/secretKey 初始化 COSClient,供业务层上传/下载文件。
  • Spring 管理 Bean:通过 @BeanCOSClient 注册为单例 Bean,避免重复创建。

代码亮点

优点 说明
配置外置化 凭据不硬编码在代码中,符合 12-Factor 原则
自动装配 利用 Spring Boot 自动配置机制,开箱即用
类型安全 @Data + @ConfigurationProperties 提供 IDE 自动提示和校验

敏感信息明文存储(高危!)

  • accessKeysecretKey最高权限凭证,直接写在配置文件中存在严重安全风险:

正确做法

  • 开发环境:使用 .env 文件 + @ConfigurationProperties,并加入 .gitignore
  • 生产环境:使用 云平台 IAM 角色密钥管理服务(KMS)

未注册 Shutdown Hook(资源泄漏风险)

COSClient 内部使用线程池和 HTTP 连接池,应用关闭时需手动释放资源,否则可能导致:

  • 线程无法退出
  • 连接未关闭
  • 内存泄漏

正确做法:注册销毁方法

1
2
3
4
5
@Bean(destroyMethod = "shutdown")
public COSClient cosClient() {
// ... 初始化 ...
return new COSClient(cred, clientConfig);
}

或显式调用:

1
2
3
4
5
6
7
@Bean
public COSClient cosClient() {
COSClient client = new COSClient(cred, clientConfig);
// 应用关闭时自动 shutdown
Runtime.getRuntime().addShutdownHook(new Thread(client::shutdown));
return client;
}

destroyMethod = "shutdown" 是 Spring 推荐方式,会在容器关闭时自动调用。

最终建议:

  1. 立即移除配置文件中的 AK/SK,改用环境变量或 STS 临时密钥
  2. 确保 destroyMethod = "shutdown" 避免资源泄漏
  3. 上线前验证 Region 和 Bucket 名称是否正确

    只要解决敏感信息管理资源释放两个核心问题,该配置类即可安全用于生产环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* Spring MVC JSON 配置:解决 Long 类型精度丢失问题
*/
@Configuration
public class JsonConfig {

/**
* 自定义 ObjectMapper(纯 Jackson 原生 API,无依赖问题)
* - 核心:Long/long 序列化为字符串,避免前端 JS 精度丢失
* - 兼容:自动处理 LocalDateTime 等时间类型
*/
@Bean
public ObjectMapper objectMapper() {
// 1. 创建自定义模块,注册 Long 序列化器(核心逻辑)
SimpleModule longToStringModule = new SimpleModule();
longToStringModule.addSerializer(Long.class, ToStringSerializer.instance); // 包装类 Long
longToStringModule.addSerializer(Long.TYPE, ToStringSerializer.instance); // 基本类型 long

// 2. 用 Jackson 原生 Builder 构建 ObjectMapper(Spring 7.0 推荐)
ObjectMapper objectMapper = com.fasterxml.jackson.databind.json.JsonMapper.builder()
// 注册时间模块(处理 LocalDateTime/LocalDate,可选但推荐)
.addModule(new JavaTimeModule())
// 注册自定义 Long 序列化模块(核心)
.addModule(longToStringModule)
// 可选:关闭时间戳序列化(让时间类型返回字符串,如 "2025-12-06 10:00:00")
.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();

return objectMapper;
}
}

Spring Boot 项目中用于解决 JavaScript 精度丢失问题的 JSON 配置类
核心目标:将 Java 中的 Long 类型在序列化为 JSON 时转为字符串而非数字
从而避免前端因 JS Number 精度限制(最大安全整数为 2^53 - 1 = 9007199254740991)导致 ID 错乱

为什么需要这个配置?—— JS 精度问题

  • 数据库主键使用 BIGINT(如雪花 ID:1823456789012345678
  • 后端返回 JSON:
    1
    { "id": 1823456789012345678 }
  • 前端 JS 解析后:
    1
    console.log(data.id); // 输出 1823456789012345600(精度丢失!)
    根本原因:
  • JavaScript 的 Number 类型是 双精度浮点数,只能安全表示 -(2^53 - 1)2^53 - 1 之间的整数。
  • 超出范围的整数会被四舍五入,导致 ID 不唯一或错误。
    解决方案:后端将 Long 序列化为 字符串,前端用 string 处理 ID。

所有 Long 类型字段(包括 ID、时间戳、计数器等)都会被转为字符串。
可能副作用:

  • 前端需明确区分哪些字段是“数字字符串”(如 ID),哪些是“纯字符串”。
  • 如果某些 Long 字段确实需要作为数字使用(如统计数值),则会被错误转为字符串。
    解决方案(按需定制)
  • 使用 @JsonSerialize(using = ToStringSerializer.class) 仅标注需要转字符串的字段(如 id):
    1
    2
    3
    4
    5
    public class User {
    @JsonSerialize(using = ToStringSerializer.class)
    private Long id; // 只有 id 转字符串
    private Long viewCount; // 仍为数字
    }
  • 或创建自定义注解(如 @JsonLongAsString)提升可读性。

    建议

    • 如果项目中 所有 Long 都是 ID 或大数 → 全局配置
    • 如果 Long 有不同类型语义 → 字段级注解更安全

代码实现解析

特性 作用
ToStringSerializer.instance Jackson 内置的序列化器,将任意对象转为 toString() 结果
Long.class + Long.TYPE 同时覆盖包装类 Long 和基本类型 long
JavaTimeModule 支持 LocalDateTimeLocalDate 等 Java 8 时间类型
WRITE_DATES_AS_TIMESTAMPS=false 时间类型输出为 "2025-12-12T10:00:00" 而非时间戳 1700000000000

此配置通过 @Bean 替换 Spring Boot 默认的 ObjectMapper,全局生效。

反序列化(前端传 Long 字符串)是否支持?

当前配置只处理序列化(Java → JSON),未处理反序列化(JSON → Java)。
场景:
前端传:

1
{ "id": "1823456789012345678" }

后端能否正确绑定到 Long id默认可以!
Jackson 在反序列化时会自动将 数字字符串 转为 Long(只要格式合法)。
但若前端传的是纯字符串且包含非数字字符,则会报错。

无需额外配置,除非有特殊需求。

最佳实践建议

场景 推荐方案
新项目,所有 Long 都是大 ID 保留当前全局配置
已有项目,部分 Long 需保持数字 改用字段级 @JsonSerialize
需要自定义时间格式 添加 .defaultDateFormat(...)@JsonFormat
追求极致安全 结合 @JsonFilter 动态控制序列化

MyBatis Plus 配置 MyBatisPlusConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* MyBatis Plus 配置
*/
@Configuration
public class MyBatisPlusConfig {
/**
* 拦截器配置
*
* @return
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 分页插件
// interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 限制最大单页大小
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(50L); // 单页最多 50 条(建议值:`20~100`,根据业务调整)
interceptor.addInnerInterceptor(paginationInterceptor);
// 安全防护:禁止全表更新/删除
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
}

MyBatis-Plus 的标准分页插件配置
用于在 Spring Boot 项目中启用基于 Page<T> 的自动分页功能。

核心作用:

MyBatis-Plus 默认不开启分页功能
通过注册 MybatisPlusInterceptor 并添加 PaginationInnerInterceptor,可实现:

1
2
3
// Service 中直接使用
Page<User> page = new Page<>(1, 10);
userMapper.selectPage(page, null);

MyBatis-Plus 会自动将 SQL 改写为:

1
SELECT * FROM user LIMIT 0, 10  -- MySQL 分页

无需手写 LIMIT,避免重复造轮子。
指定数据库类型DbType.MYSQL 确保生成正确的分页 SQL(如 LIMIT 而非 ROWNUM)。

未处理溢出(overflow)行为

当请求页码过大(如 current=999999999),MP 默认仍会执行查询,可能返回空但消耗资源。
建议开启溢出控制(MyBatis-Plus 3.4.0+):

1
2
3
paginationInterceptor.setOverflow(true); // 超过总页数时返回最后一页
// 或
paginationInterceptor.setOverflow(false); // 超过总页数直接返回空(默认)

通常保持默认(false)即可,配合前端合理分页。

缺少其他常用拦截器(扩展性)

MyBatis-Plus 提供多个实用拦截器,可按需添加:

拦截器 作用
OptimisticLockerInnerInterceptor 乐观锁(防并发更新覆盖)
BlockAttackInnerInterceptor 阻断全表更新/删除(防误操作)
TenantLineInnerInterceptor 多租户数据隔离

示例:增加安全防护
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());

BlockAttackInnerInterceptor 会在执行 UPDATE user SET name='x'(无 WHERE)时抛出异常,防止灾难性操作。

微信开放平台配置 WxOpenConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 微信开放平台配置
*/
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "wx.open")
@Data
public class WxOpenConfig {
private String appId;
private String appSecret;
private WxMpService wxMpService;

/**
* 单例模式(不用 @Bean 是为了防止和公众号的 service 冲突)
*/
public WxMpService getWxMpService() {
if (wxMpService != null) {
return wxMpService;
}
synchronized (this) {
if (wxMpService != null) {
return wxMpService;
}
WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl();
config.setAppId(appId);
config.setSecret(appSecret);
WxMpService service = new WxMpServiceImpl();
service.setWxMpConfigStorage(config);
wxMpService = service;
return wxMpService;
}
}
}

这段代码试图通过 @ConfigurationProperties + 手动单例 的方式配置微信开放平台(公众号)的 WxMpService
初衷是避免与项目中其他微信服务(如小程序、开放平台)的 Bean 冲突。
但该实现存在 严重设计缺陷和线程安全风险不推荐在生产环境中使用

1. 违反 Spring 容器管理原则

  • 你手动实现了“单例”(synchronized + 双重检查),但 Spring 本身就是一个 IoC 容器,天生支持单例 Bean。
  • 后果
    • 配置类 WxOpenConfig 被 Spring 管理(@Configuration),但其内部字段 wxMpService 却绕过容器自行创建。
    • 无法享受 Spring 的生命周期管理(如 @PostConstructDisposableBean)。
    • 难以被其他 Bean 注入或测试。

      正确做法:让 Spring 管理 WxMpService 的创建和单例

2. 线程安全问题(看似安全,实则隐患)

虽然用了 synchronized (this),但:

  • thisWxOpenConfig 实例,在 Spring 中默认是单例,所以锁对象唯一 ✅
  • 但是:如果未来 WxOpenConfig 被设为 @Scope("prototype"),则每个实例有自己的锁,双重检查失效

更严重的是:

  • wxMpService 字段未加 volatile,可能导致其他线程看到未完全初始化的对象(指令重排序问题)。

正确双重检查写法需 volatile

1
private volatile WxMpService wxMpService;

但——根本不需要手写单例!

3. 配置属性绑定时机不确定

  • appId / appSecret 是通过 @ConfigurationProperties 绑定的。
  • 但在 getWxMpService() 被调用时,不能保证配置已注入完成(尤其在 Bean 初始化早期)。
  • 可能导致 config.setAppId(null),后续调用微信 API 报错。

Spring 提供 @PostConstructInitializingBean 确保配置就绪后再初始化依赖。


4. 无法与其他微信服务共存(反而更容易冲突)

你说“不用 @Bean 是为了防止和公众号的 service 冲突”,但:

  • 如果项目有多个微信服务(如公众号 + 小程序),正确做法是使用 @Qualifier 区分 Bean 名称
  • 手动管理 WxMpService 反而导致:
    • 无法通过 @Autowired 注入
    • 测试时难以 Mock
    • 代码耦合度高

正确做法:使用 @Bean + 命名区分

方案:定义带名称的 @Bean

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Slf4j
@Configuration
@ConfigurationProperties(prefix = "wx.open")
@Data
public class WxOpenConfig {

private String appId;
private String appSecret;

/**
* 微信公众号 Service(命名为 wxOpenMpService,避免与其他微信服务冲突)
*/
@Bean("wxOpenMpService") // 指定 Bean 名称
public WxMpService wxOpenMpService() {
WxMpDefaultConfigImpl config = new WxMpDefaultConfigImpl();
config.setAppId(appId);
config.setSecret(appSecret);

WxMpService service = new WxMpServiceImpl();
service.setWxMpConfigStorage(config);
return service;
}
}

在其他地方注入时指定名称:

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class WechatService {

// 使用 @Qualifier 指定注入哪个 WxMpService
@Resource(name = "wxOpenMpService")
private WxMpService wxMpService;

// 或
@Autowired
@Qualifier("wxOpenMpService")
private WxMpService wxMpService;
}

这样既利用了 Spring 的单例管理,又通过命名避免了冲突。

增强建议(生产级)

1. 添加配置校验

1
2
3
4
5
6
7
8
9
@Validated
@ConfigurationProperties(prefix = "wx.open")
@Data
public class WxOpenConfig {
@NotBlank
private String appId;
@NotBlank
private String appSecret;
}

2. 支持多环境(如测试/生产不同 AppId)

通过 application.yml 配置:

1
2
3
4
wx:
open:
app-id: ${WX_OPEN_APP_ID}
app-secret: ${WX_OPEN_APP_SECRET}

配合环境变量或配置中心,避免密钥硬编码。

3. 考虑使用 WxJava 的自动配置(高级)

如果你使用 WeChat Java SDK (WxJava),它已提供 Spring Boot Starter:

1
2
3
4
5
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp-spring-boot-starter</artifactId>
<version>4.7.0</version>
</dependency>

然后直接配置:

1
2
3
4
wx:
mp:
appId: xxx
secret: yyy

无需手写 @Bean

但如果你需要多个公众号实例,仍需自定义配置类(参考上文命名方案)。

问题 你的实现 推荐做法
单例管理 手动 synchronized 交给 Spring (@Bean)
线程安全 缺少 volatile 无需关心(Spring 单例线程安全)
配置注入时机 不确定 Spring 保证 @ConfigurationProperties 先于 @Bean
多服务共存 试图避免冲突但方式错误 @Bean("name") + @Qualifier
可测试性 差(无法 Mock) 好(标准 Spring Bean)

最终结论:

删除 getWxMpService() 方法,改用 @Bean("wxOpenMpService")
这是 Spring 生态的标准做法,安全、简洁、可维护。

你的初衷(避免冲突)是对的,但实现方式背离了 Spring 的设计哲学。让容器做它擅长的事!

常量类 constant

通用常量 CommonConstant

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 通用常量
*/
public final class CommonConstant {
/**
* 升序
*/
public static final String SORT_ORDER_ASC = "asc";
/**
* 降序
*/
public static final String SORT_ORDER_DESC = "desc";
}

文件常量 FileConstant

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 文件常量
*/
public final class FileConstant {
/**
* COS 访问地址
* todo 需替换配置
*/
public static String COS_HOST = "https://wgfun-1322866712.cos.ap-guangzhou.myqcloud.com";
}


用户常量 UserConstant

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 用户常量
*/
public final class UserConstant {

/**
* 用户登录态键
*/
public static final String USER_LOGIN_STATE = "user_login";

// region 权限

/**
* 默认角色
*/
public static final String DEFAULT_ROLE = "user";

/**
* 管理员角色
*/
public static final String ADMIN_ROLE = "admin";

/**
* 被封号
*/
public static final String BAN_ROLE = "ban";

// endregion
}

接口类 controller

文件接口 FileController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
* 文件接口
*/
@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {

@Resource
private UserService userService;

@Resource
private CosManager cosManager;

/**
* 文件上传
*
* @param multipartFile
* @param uploadFileRequest
* @param request
* @return
*/
@PostMapping("/upload")
public BaseResponse<String> uploadFile(@RequestPart("file") MultipartFile multipartFile,
UploadFileRequest uploadFileRequest, HttpServletRequest request) {
String biz = uploadFileRequest.getBiz();
FileUploadBizEnum fileUploadBizEnum = FileUploadBizEnum.getEnumByValue(biz);
if (fileUploadBizEnum == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
validFile(multipartFile, fileUploadBizEnum);
User loginUser = userService.getLoginUser(request);
// 文件目录:根据业务、用户来划分
String uuid = RandomStringUtils.randomAlphanumeric(8);
String filename = uuid + "-" + multipartFile.getOriginalFilename();
String filepath = String.format("/%s/%s/%s", fileUploadBizEnum.getValue(), loginUser.getId(), filename);
File file = null;
try {
// 上传文件
file = File.createTempFile(filepath, null);
multipartFile.transferTo(file);
cosManager.putObject(filepath, file);
// 返回可访问地址
return ResultUtils.success(FileConstant.COS_HOST + filepath);
} catch (Exception e) {
log.error("file upload error, filepath = " + filepath, e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
} finally {
if (file != null) {
// 删除临时文件
boolean delete = file.delete();
if (!delete) {
log.error("file delete error, filepath = {}", filepath);
}
}
}
}

/**
* 校验文件
*
* @param multipartFile
* @param fileUploadBizEnum 业务类型
*/
private void validFile(MultipartFile multipartFile, FileUploadBizEnum fileUploadBizEnum) {
// 文件大小
long fileSize = multipartFile.getSize();
// 文件后缀
String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());
final long ONE_M = 1024 * 1024L;
if (FileUploadBizEnum.USER_AVATAR.equals(fileUploadBizEnum)) {
if (fileSize > ONE_M) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小不能超过 1M");
}
if (!Arrays.asList("jpeg", "jpg", "svg", "png", "webp").contains(fileSuffix)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件类型错误");
}
}
}
}

文件上传接口控制器(FileController)
用于处理用户通过 HTTP POST 请求上传文件的逻辑,并将文件上传到腾讯云 COS(对象存储服务)。
这个 FileController 实现了一个安全可控的文件上传接口,支持按业务类型和用户隔离存储,并上传至云存储。
目前主要针对用户头像场景做了校验,后续可扩展其他业务类型的校验规则。

1
2
3
4
@RestController
@RequestMapping("/file")
@Slf4j
public class FileController {
  • @RestController:表示这是一个 RESTful 风格的控制器,所有返回值会自动序列化为 JSON。
  • @RequestMapping("/file"):该控制器下所有接口路径都以 /file 开头。
  • @Slf4j:Lombok 注解,自动生成日志对象 log

依赖注入

1
2
3
4
5
@Resource
private UserService userService;

@Resource
private CosManager cosManager;
  • UserService:用于获取当前登录用户信息。
  • CosManager:封装了与腾讯云 COS 交互的逻辑(如上传、下载等)。

文件上传接口

1
2
3
4
5
@PostMapping("/upload")
public BaseResponse<String> uploadFile(
@RequestPart("file") MultipartFile multipartFile,
UploadFileRequest uploadFileRequest,
HttpServletRequest request) {
  • 接口路径:POST /file/upload
  • 参数说明:
    • multipartFile:前端传来的文件(字段名为 "file")。
    • uploadFileRequest:包含业务类型(如头像、附件等)的请求参数。
    • request:HTTP 请求对象,用于获取当前用户。

校验业务类型

1
2
3
4
5
String biz = uploadFileRequest.getBiz();
FileUploadBizEnum fileUploadBizEnum = FileUploadBizEnum.getEnumByValue(biz);
if (fileUploadBizEnum == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
  • 从请求中获取 biz 字段(例如 "user_avatar")。
  • 通过枚举 FileUploadBizEnum 验证是否是合法的业务类型。
  • 如果非法,抛出参数错误异常。

校验文件合法性

validFile(multipartFile, fileUploadBizEnum);
调用私有方法 validFile 对文件进行校验(见下文详解)。

获取当前用户

User loginUser = userService.getLoginUser(request);

  • 从请求中解析出当前登录用户(通常通过 Token 或 Session)。

生成唯一文件路径

1
2
3
4
5
6
String uuid = RandomStringUtils.randomAlphanumeric(8);
String filename = uuid + "-" + multipartFile.getOriginalFilename();
String filepath = String.format("/%s/%s/%s",
fileUploadBizEnum.getValue(),
loginUser.getId(),
filename);
  • 使用 8 位随机字符串防止文件名冲突。
  • 文件路径格式:/业务类型/用户ID/随机串-原文件名
    • 例如:/user_avatar/123/abc12345-avatar.png

保存临时文件并上传到 COS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
File file = null;
try {
file = File.createTempFile(filepath, null); // 创建临时文件
multipartFile.transferTo(file); // 将上传内容写入临时文件
cosManager.putObject(filepath, file); // 上传到 COS
return ResultUtils.success(FileConstant.COS_HOST + filepath); // 返回可访问 URL
} catch (Exception e) {
log.error("file upload error, filepath = " + filepath, e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "上传失败");
} finally {
if (file != null) {
boolean delete = file.delete(); // 删除临时文件
if (!delete) {
log.error("file delete error, filepath = {}", filepath);
}
}
}
  • 关键流程
    1. 创建一个临时文件(在服务器本地)。
    2. 将前端上传的文件内容写入该临时文件。
    3. 调用 cosManager.putObject 将临时文件上传到腾讯云 COS。
    4. 返回拼接好的公开访问 URL(如 https://example.cos.ap-beijing.myqcloud.com/user_avatar/123/...)。
  • 异常处理:任何错误都记录日志并抛出系统错误。
  • 资源清理:无论成功与否,都会尝试删除临时文件,避免磁盘堆积。

文件校验方法 validFile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void validFile(MultipartFile multipartFile, FileUploadBizEnum fileUploadBizEnum) {
long fileSize = multipartFile.getSize();
String fileSuffix = FileUtil.getSuffix(multipartFile.getOriginalFilename());
final long ONE_M = 1024 * 1024L;

if (FileUploadBizEnum.USER_AVATAR.equals(fileUploadBizEnum)) {
if (fileSize > ONE_M) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件大小不能超过 1M");
}
if (!Arrays.asList("jpeg", "jpg", "svg", "png", "webp").contains(fileSuffix)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "文件类型错误");
}
}
}
  • 仅对头像(USER_AVATAR)做校验(其他业务类型暂未实现校验)。
  • 限制
    • 大小 ≤ 1MB
    • 后缀必须是:jpeg, jpg, svg, png, webp
  • 使用 FileUtil.getSuffix() 提取文件后缀(注意:该方法应忽略大小写,否则 .JPG 会校验失败)。

    潜在问题:当前只校验了后缀名,未校验文件真实 MIME 类型,存在安全风险(如上传伪装成图片的脚本文件)。

返回结果

  • 成功时返回:BaseResponse<String>,其中 data 字段为文件的公网访问 URL。
  • 失败时抛出 BusinessException,由全局异常处理器统一返回错误信息。

安全与优化建议

  1. 文件类型校验加强
    • 使用 Tika 或读取文件头(Magic Number)判断真实类型。
  2. 路径遍历防护
    • 确保 multipartFile.getOriginalFilename() 不含 ../ 等危险字符(Spring 的 MultipartFile 通常已处理,但需确认)。
  3. COS 权限控制
    • 确保 COS 存储桶配置合理(如禁止公开写,按需设置读权限)。
  4. 异步上传(可选):
    • 若文件较大,可考虑异步处理,避免阻塞 HTTP 线程。
  5. 临时文件目录
    • createTempFile 默认使用系统临时目录,确保磁盘空间充足。

帖子接口 PostController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
/**
* 帖子接口
*/
@RestController
@RequestMapping("/post")
@Slf4j
public class PostController {

@Resource
private PostService postService;

@Resource
private UserService userService;

// region 增删改查

/**
* 创建
*
* @param postAddRequest
* @param request
* @return
*/
@PostMapping("/add")
public BaseResponse<Long> addPost(@RequestBody PostAddRequest postAddRequest, HttpServletRequest request) {
if (postAddRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Post post = new Post();
BeanUtils.copyProperties(postAddRequest, post);
List<String> tags = postAddRequest.getTags();
if (tags != null) {
post.setTags(JSONUtil.toJsonStr(tags));
}
postService.validPost(post, true);
User loginUser = userService.getLoginUser(request);
post.setUser_id(loginUser.getId());
post.setFavour_num(0);
post.setThumb_num(0);
boolean result = postService.save(post);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
long newPostId = post.getId();
return ResultUtils.success(newPostId);
}

/**
* 删除
*
* @param deleteRequest
* @param request
* @return
*/
@PostMapping("/delete")
public BaseResponse<Boolean> deletePost(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
if (deleteRequest == null || deleteRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User user = userService.getLoginUser(request);
long id = deleteRequest.getId();
// 判断是否存在
Post oldPost = postService.getById(id);
ThrowUtils.throwIf(oldPost == null, ErrorCode.NOT_FOUND_ERROR);
// 仅本人或管理员可删除
if (!oldPost.getUser_id().equals(user.getId()) && !userService.isAdmin(request)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
boolean b = postService.removeById(id);
return ResultUtils.success(b);
}

/**
* 更新(仅管理员)
*
* @param postUpdateRequest
* @return
*/
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updatePost(@RequestBody PostUpdateRequest postUpdateRequest) {
if (postUpdateRequest == null || postUpdateRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Post post = new Post();
BeanUtils.copyProperties(postUpdateRequest, post);
List<String> tags = postUpdateRequest.getTags();
if (tags != null) {
post.setTags(JSONUtil.toJsonStr(tags));
}
// 参数校验
postService.validPost(post, false);
long id = postUpdateRequest.getId();
// 判断是否存在
Post oldPost = postService.getById(id);
ThrowUtils.throwIf(oldPost == null, ErrorCode.NOT_FOUND_ERROR);
boolean result = postService.updateById(post);
return ResultUtils.success(result);
}

/**
* 根据 id 获取
*
* @param id
* @return
*/
@GetMapping("/get/vo")
public BaseResponse<PostVO> getPostVOById(long id, HttpServletRequest request) {
if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Post post = postService.getById(id);
if (post == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
return ResultUtils.success(postService.getPostVO(post, request));
}

/**
* 分页获取列表(仅管理员)
*
* @param postQueryRequest
* @return
*/
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<Post>> listPostByPage(@RequestBody PostQueryRequest postQueryRequest) {
long current = postQueryRequest.getCurrent();
long size = postQueryRequest.getPageSize();
Page<Post> postPage = postService.page(new Page<>(current, size),
postService.getQueryWrapper(postQueryRequest));
return ResultUtils.success(postPage);
}

/**
* 分页获取列表(封装类)
*
* @param postQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo")
public BaseResponse<Page<PostVO>> listPostVOByPage(@RequestBody PostQueryRequest postQueryRequest,
HttpServletRequest request) {
long current = postQueryRequest.getCurrent();
long size = postQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Post> postPage = postService.page(new Page<>(current, size),
postService.getQueryWrapper(postQueryRequest));
return ResultUtils.success(postService.getPostVOPage(postPage, request));
}

/**
* 分页获取当前用户创建的资源列表
*
* @param postQueryRequest
* @param request
* @return
*/
@PostMapping("/my/list/page/vo")
public BaseResponse<Page<PostVO>> listMyPostVOByPage(@RequestBody PostQueryRequest postQueryRequest,
HttpServletRequest request) {
if (postQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
postQueryRequest.setUserId(loginUser.getId());
long current = postQueryRequest.getCurrent();
long size = postQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Post> postPage = postService.page(new Page<>(current, size),
postService.getQueryWrapper(postQueryRequest));
return ResultUtils.success(postService.getPostVOPage(postPage, request));
}

// endregion

/**
* 分页搜索(从 ES 查询,封装类)
*
* @param postQueryRequest
* @param request
* @return
*/
@PostMapping("/search/page/vo")
public BaseResponse<Page<PostVO>> searchPostVOByPage(@RequestBody PostQueryRequest postQueryRequest,
HttpServletRequest request) {
long size = postQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Post> postPage = postService.searchFromEs(postQueryRequest);
return ResultUtils.success(postService.getPostVOPage(postPage, request));
}

/**
* 编辑(用户)
*
* @param postEditRequest
* @param request
* @return
*/
@PostMapping("/edit")
public BaseResponse<Boolean> editPost(@RequestBody PostEditRequest postEditRequest, HttpServletRequest request) {
if (postEditRequest == null || postEditRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
Post post = new Post();
BeanUtils.copyProperties(postEditRequest, post);
List<String> tags = postEditRequest.getTags();
if (tags != null) {
post.setTags(JSONUtil.toJsonStr(tags));
}
// 参数校验
postService.validPost(post, false);
User loginUser = userService.getLoginUser(request);
long id = postEditRequest.getId();
// 判断是否存在
Post oldPost = postService.getById(id);
ThrowUtils.throwIf(oldPost == null, ErrorCode.NOT_FOUND_ERROR);
// 仅本人或管理员可编辑
if (!oldPost.getUser_id().equals(loginUser.getId()) && !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
boolean result = postService.updateById(post);
return ResultUtils.success(result);
}
}

Spring Boot 的 RESTful 控制器(PostController)
用于管理“帖子”(Post)资源的增删改查、分页查询、搜索和权限控制等操作。

  • @RestController:返回 JSON 数据。
  • 所有接口路径以 /post 开头。
  • @Slf4j:自动生成日志对象 log

增删改查(CRUD)

创建帖子(/add

1
2
@PostMapping("/add")
public BaseResponse<Long> addPost(@RequestBody PostAddRequest postAddRequest, HttpServletRequest request)
  • 参数校验postAddRequest 不能为空。
  • 属性拷贝:使用 BeanUtils.copyProperties 将 DTO 转为实体。
  • 标签处理tags 列表转为 JSON 字符串存入数据库(常见做法,但不利于查询)。
  • 校验:调用 postService.validPost(post, true)true 表示是新增)。
  • 设置用户信息
    • user_id = 当前登录用户 ID
    • 初始化 favour_num(点赞数)、thumb_num(收藏数)为 0。
  • 保存并返回 ID

    注意post.getId() 能获取到 ID,说明 postService.save() 使用了 MyBatis-Plus 的 save() 方法(会自动回填主键)。

删除帖子(/delete

1
2
@PostMapping("/delete")
public BaseResponse<Boolean> deletePost(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request)
  • 权限控制
    • 必须是 帖子作者管理员 才能删除。
  • 存在性校验:帖子必须存在。
  • 调用 postService.removeById(id) 删除。

    🔒 安全性良好:防止越权删除。

更新帖子(仅管理员)(/update

1
2
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
  • 使用自定义注解 @AuthCheck 强制要求管理员角色。
  • 更新时也做参数校验(validPost(post, false)false 表示非新增)。
  • 不允许普通用户调用此接口。

    注意:这个“更新”是全量更新(覆盖所有字段),通常用于后台管理;而用户编辑走的是 /edit 接口。

根据 ID 获取帖子(VO 封装)(/get/vo

1
2
@GetMapping("/get/vo")
public BaseResponse<PostVO> getPostVOById(long id, HttpServletRequest request)
  • 返回 PostVO(View Object),通常包含脱敏信息、额外字段(如是否已点赞)。
  • 调用 postService.getPostVO(post, request) 进行转换。

    ✅ VO 模式:避免直接暴露数据库实体,提升安全性和灵活性。

分页查询

管理员分页列表(原始实体)(/list/page

1
2
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
  • 仅管理员可用。
  • 返回 Page<Post>(MyBatis-Plus 分页对象)。
  • 使用 postService.getQueryWrapper(...) 构建查询条件。

普通用户分页列表(VO 封装)(/list/page/vo

1
2
@PostMapping("/list/page/vo")
public BaseResponse<Page<PostVO>> listPostVOByPage(...)
  • 防爬虫:限制每页最多 20 条(size > 20 报错)。
  • 查询后通过 getPostVOPage 转换为 VO 列表。

我的帖子分页(/my/list/page/vo

1
@PostMapping("/my/list/page/vo")
  • 自动设置 userId = 当前用户ID,确保只能查自己的帖子。
  • 同样限制每页 ≤20 条。

搜索功能(基于 Elasticsearch)

ES 分页搜索(/search/page/vo

1
2
@PostMapping("/search/page/vo")
public BaseResponse<Page<PostVO>> searchPostVOByPage(...)
  • 调用 postService.searchFromEs(postQueryRequest) 从 Elasticsearch 查询。
  • 同样限制每页大小。
  • 最终也转为 PostVO 返回。

    ✅ 优势:支持全文检索、高亮、相关性排序等,比数据库 LIKE 更高效。

用户编辑帖子(/edit

1
2
@PostMapping("/edit")
public BaseResponse<Boolean> editPost(...)
  • 权限控制:仅本人或管理员可编辑。
  • 类似 /update,但面向普通用户。
  • 也做参数校验。
  • 允许用户修改自己的帖子内容(如标题、内容、标签等)。

    💡 与 /update 的区别:

    • /edit:用户自助编辑,可能只允许修改部分字段(由 PostEditRequest 控制)。
    • /update:管理员强制修改,可能包含审核状态等敏感字段。

1. 标签存储为 JSON 字符串

1
post.setTags(JSONUtil.toJsonStr(tags));
  • 缺点:无法高效按标签查询(需 LIKE '%tag%',性能差)。
  • 建议:建立 post_tag 关联表,支持标签筛选、统计。

2. 缺少字段级更新控制

  • /edit/update 都是全量覆盖,若前端传了 user_idfavour_num,可能被恶意篡改。
  • 建议
    • PostEditRequest不包含敏感字段(如 user_id, favour_num)。
    • 或在服务层只更新允许的字段(使用 UpdateWrapper 指定字段)。

3. ES 与 DB 数据一致性

  • 如果帖子更新后未同步到 ES,搜索结果会滞后。
  • 建议:在 postService.updateById 后,异步更新 ES 索引。

4. 重复代码

  • /update/edit 逻辑高度相似。
  • 建议:提取公共方法,减少冗余。

5. 防爬限制可配置化

  • size > 20 写死,建议改为配置项(如 app.post.max-page-size=20)。

帖子收藏接口 PostFavourController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/**
* 帖子收藏接口
*/
@RestController
@RequestMapping("/post_favour")
@Slf4j
public class PostFavourController {

@Resource
private PostFavourService postFavourService;

@Resource
private PostService postService;

@Resource
private UserService userService;

/**
* 收藏 / 取消收藏
*
* @param postFavourAddRequest
* @param request
* @return resultNum 收藏变化数
*/
@PostMapping("/")
public BaseResponse<Integer> doPostFavour(@RequestBody PostFavourAddRequest postFavourAddRequest,
HttpServletRequest request) {
if (postFavourAddRequest == null || postFavourAddRequest.getPostId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 登录才能操作
final User loginUser = userService.getLoginUser(request);
long postId = postFavourAddRequest.getPostId();
int result = postFavourService.doPostFavour(postId, loginUser);
return ResultUtils.success(result);
}

/**
* 获取我收藏的帖子列表
*
* @param postQueryRequest
* @param request
*/
@PostMapping("/my/list/page")
public BaseResponse<Page<PostVO>> listMyFavourPostByPage(@RequestBody PostQueryRequest postQueryRequest,
HttpServletRequest request) {
if (postQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
long current = postQueryRequest.getCurrent();
long size = postQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<Post> postPage = postFavourService.listFavourPostByPage(new Page<>(current, size),
postService.getQueryWrapper(postQueryRequest), loginUser.getId());
return ResultUtils.success(postService.getPostVOPage(postPage, request));
}

/**
* 获取用户收藏的帖子列表
*
* @param postFavourQueryRequest
* @param request
*/
@PostMapping("/list/page")
public BaseResponse<Page<PostVO>> listFavourPostByPage(@RequestBody PostFavourQueryRequest postFavourQueryRequest,
HttpServletRequest request) {
if (postFavourQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
long current = postFavourQueryRequest.getCurrent();
long size = postFavourQueryRequest.getPageSize();
Long userId = postFavourQueryRequest.getUserId();
// 限制爬虫
ThrowUtils.throwIf(size > 20 || userId == null, ErrorCode.PARAMS_ERROR);
Page<Post> postPage = postFavourService.listFavourPostByPage(new Page<>(current, size),
postService.getQueryWrapper(postFavourQueryRequest.getPostQueryRequest()), userId);
return ResultUtils.success(postService.getPostVOPage(postPage, request));
}
}

帖子收藏(Favour)功能的控制器(PostFavourController)
实现了“收藏/取消收藏”以及“分页查询用户收藏的帖子列表”的核心逻辑。

  • 接口路径前缀:/post_favour
  • 使用 Lombok 的 @Slf4j 自动生成日志对象。
  • 注入了三个关键服务:
    • PostFavourService:处理收藏业务逻辑(增删查)
    • PostService:用于获取帖子 VO、构建查询条件等
    • UserService:获取当前登录用户

核心功能一:收藏 / 取消收藏

1
2
3
4
@PostMapping("/")
public BaseResponse<Integer> doPostFavour(
@RequestBody PostFavourAddRequest postFavourAddRequest,
HttpServletRequest request)

功能说明

  • 幂等操作:调用一次表示“切换状态”——如果已收藏则取消,未收藏则添加。
  • 返回值 result收藏数的变化量
    • +1:成功收藏
    • -1:成功取消收藏
    • 0:无变化(理论上不应发生)

安全控制

  • 必须登录:通过 userService.getLoginUser(request) 获取用户,未登录会抛异常。
  • 参数校验postId 必须 > 0。

注意:实际是否幂等,取决于 postFavourService.doPostFavour() 的实现(应基于唯一索引去重)。

核心功能二:获取“我的”收藏列表

1
2
@PostMapping("/my/list/page")
public BaseResponse<Page<PostVO>> listMyFavourPostByPage(...)

功能说明

  • 查询当前登录用户收藏的帖子。
  • 使用 PostQueryRequest 支持额外筛选条件(如关键词、分类等)。
  • 分页限制:每页最多 20 条(防爬虫)。

安全控制

  • 自动绑定 userId = loginUser.getId()无法越权查看他人收藏

数据流

  1. 构建分页对象 Page<>(current, size)
  2. 调用 postFavourService.listFavourPostByPage(..., userId)
    • 内部应通过 关联查询(如 post_favour 表 JOIN post 表)获取帖子
  3. Page<Post> 转为 Page<PostVO>(脱敏、补充是否已收藏等信息)

核心功能三:获取任意用户的收藏列表(公开)

1
2
@PostMapping("/list/page")
public BaseResponse<Page<PostVO>> listFavourPostByPage(...)

功能说明

  • 允许查看其他用户的收藏列表(如个人主页展示)。
  • 需要传入 userId(通过 PostFavourQueryRequest 包装)。

安全与校验

  • userId 不能为空
  • 同样限制每页 ≤20 条
  • 未做隐私限制:假设所有用户收藏列表都是公开的(若需私有化,需加权限判断)

请求结构

1
2
3
4
5
class PostFavourQueryRequest {
private Long userId;
private PostQueryRequest postQueryRequest; // 帖子筛选条件
// ...
}
  • 支持在“某人的收藏中”再按帖子属性筛选(如只看收藏的“技术类”帖子)

服务层协作关系

控制器方法 调用的服务方法 作用
doPostFavour postFavourService.doPostFavour(postId, user) 执行收藏/取消
listMyFavour... postFavourService.listFavourPostByPage(page, queryWrapper, userId) 查询收藏的帖子(分页)
listFavour... 同上 同上

💡 postService.getQueryWrapper(...) 用于构建帖子的查询条件(如标题模糊匹配、标签等),说明收藏列表支持二次过滤

潜在问题与优化建议

  1. 缺少对 postId 存在性的校验
1
2
long postId = postFavourAddRequest.getPostId();
// ❌ 未检查 postId 对应的帖子是否存在
  • 风险:用户可能收藏一个不存在的帖子 ID,导致数据库冗余或后续查询异常。
  • 建议:在 doPostFavour 中增加:
    1
    2
    Post post = postService.getById(postId);
    ThrowUtils.throwIf(post == null, ErrorCode.NOT_FOUND_ERROR);
  1. 收藏数量变更未同步到帖子实体
  • 帖子表中有 favour_num 字段(见 PostController.addPost),但收藏操作后未更新该字段
  • 后果:帖子详情页显示的收藏数不准确。
  • 解决方案
    • postFavourService.doPostFavour 中,根据 result 值:
      1
      2
      3
      4
      5
      if (result == 1) {
      postService.incrementFavourNum(postId, 1);
      } else if (result == -1) {
      postService.incrementFavourNum(postId, -1);
      }
    • 使用数据库原子操作(如 UPDATE post SET favour_num = favour_num + 1 WHERE id = ?
  1. 未处理高并发收藏
  • 多人同时收藏同一帖子,可能导致 favour_num 不一致(如果靠应用层计算)。
  • 建议favour_num 的增减必须通过数据库原子更新,而非先查后改。
  1. 两个列表接口逻辑重复
  • /my/list/page/list/page 底层都调用同一个 service 方法。
  • 可考虑合并为一个接口,通过 userId 是否为空判断是否为“自己”:
    1
    2
    3
    if (userId == null) {
    userId = getLoginUser(request).getId();
    }
    但当前设计更清晰(权限意图明确),也可接受。

帖子点赞接口 PostThumbController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* 帖子点赞接口
*/
@RestController
@RequestMapping("/post_thumb")
@Slf4j
public class PostThumbController {

@Resource
private PostThumbService postThumbService;

@Resource
private UserService userService;

/**
* 点赞 / 取消点赞
*
* @param postThumbAddRequest
* @param request
* @return resultNum 本次点赞变化数
*/
@PostMapping("/")
public BaseResponse<Integer> doThumb(@RequestBody PostThumbAddRequest postThumbAddRequest,
HttpServletRequest request) {
if (postThumbAddRequest == null || postThumbAddRequest.getPostId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 登录才能点赞
final User loginUser = userService.getLoginUser(request);
long postId = postThumbAddRequest.getPostId();
int result = postThumbService.doPostThumb(postId, loginUser);
return ResultUtils.success(result);
}
}

🔍 功能说明

  • 实现 点赞 / 取消点赞 的切换操作。
  • 返回值 result 表示本次操作对点赞数的影响:
    • 1:成功点赞
    • -1:成功取消点赞
    • 0:无变化(如重复操作)

安全控制

  • 强制登录:未登录用户无法点赞。
  • 参数校验postId > 0

潜在问题(与收藏接口类似)

  1. 未校验 postId 是否存在
    • 应调用 postService.getById(postId) 确保帖子真实存在。
  2. 未同步更新 thumb_num 字段
    • 帖子实体中有 thumb_num(见 PostController.addPost),但点赞后未更新。
    • 高并发下会导致数据不一致。
  3. 幂等性依赖 Service 层实现
    • 需确保 postThumbService.doPostThumb 使用数据库唯一索引(如 (user_id, post_id))防止重复点赞。

建议改进

1
2
3
4
5
6
7
8
9
10
// 在 doThumb 方法中增加:
Post post = postService.getById(postId);
ThrowUtils.throwIf(post == null, ErrorCode.NOT_FOUND_ERROR);

int result = postThumbService.doPostThumb(postId, loginUser);

// 同步更新 thumb_num(由 service 层原子操作)
if (result != 0) {
postService.updateThumbNum(postId, result);
}

用户接口 UserController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
/**
* 用户接口
*/
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {

@Resource
private UserService userService;

@Resource
private WxOpenConfig wxOpenConfig;

// region 登录相关

/**
* 用户注册
*
* @param userRegisterRequest
* @return
*/
@PostMapping("/register")
public BaseResponse<Long> userRegister(@RequestBody UserRegisterRequest userRegisterRequest) {
if (userRegisterRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String userAccount = userRegisterRequest.getUserAccount();
String userPassword = userRegisterRequest.getUserPassword();
String checkPassword = userRegisterRequest.getCheckPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
return throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号或密码不能为空");
//return null;
}
long result = userService.userRegister(userAccount, userPassword, checkPassword);
return ResultUtils.success(result);
}

/**
* 用户登录
*
* @param userLoginRequest
* @param request
* @return LoginUserVO
*/
@PostMapping("/login")
public BaseResponse<LoginUserVO> userLogin(@RequestBody UserLoginRequest userLoginRequest, HttpServletRequest request) {
if (userLoginRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String userAccount = userLoginRequest.getUserAccount();
String userPassword = userLoginRequest.getUserPassword();
if (StringUtils.isAnyBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
LoginUserVO loginUserVO = userService.userLogin(userAccount, userPassword, request);
return ResultUtils.success(loginUserVO);
}

/**
* 用户登录(微信开放平台)
*/
@GetMapping("/login/wx_open")
public BaseResponse<LoginUserVO> userLoginByWxOpen(HttpServletRequest request, HttpServletResponse response,
@RequestParam("code") String code) {
WxOAuth2AccessToken accessToken;
try {
WxMpService wxService = wxOpenConfig.getWxMpService();
accessToken = wxService.getOAuth2Service().getAccessToken(code);
WxOAuth2UserInfo userInfo = wxService.getOAuth2Service().getUserInfo(accessToken, code);
String unionId = userInfo.getUnionId();
String mpOpenId = userInfo.getOpenid();
if (StringUtils.isAnyBlank(unionId, mpOpenId)) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "登录失败,系统错误");
}
return ResultUtils.success(userService.userLoginByMpOpen(userInfo, request));
} catch (Exception e) {
log.error("userLoginByWxOpen error", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "登录失败,系统错误");
}
}

/**
* 用户注销
*
* @param request
* @return
*/
@PostMapping("/logout")
public BaseResponse<Boolean> userLogout(HttpServletRequest request) {
if (request == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
boolean result = userService.userLogout(request);
return ResultUtils.success(result);
}

/**
* 获取当前登录用户
*
* @param request
* @return
*/
@GetMapping("/get/login")
public BaseResponse<LoginUserVO> getLoginUser(HttpServletRequest request) {
User user = userService.getLoginUser(request);
return ResultUtils.success(userService.getLoginUserVO(user));
}

// endregion

// region 增删改查

/**
* 创建用户
*
* @param userAddRequest
* @param request
* @return
*/
@PostMapping("/add")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Long> addUser(@RequestBody UserAddRequest userAddRequest, HttpServletRequest request) {
if (userAddRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User user = new User();
BeanUtils.copyProperties(userAddRequest, user);
// 默认密码 12345678
String defaultPassword = "12345678";
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + defaultPassword).getBytes());
user.setUser_password(encryptPassword);
boolean result = userService.save(user);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(user.getId());
}

/**
* 删除用户
*
* @param deleteRequest
* @param request
* @return
*/
@PostMapping("/delete")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> deleteUser(@RequestBody DeleteRequest deleteRequest, HttpServletRequest request) {
if (deleteRequest == null || deleteRequest.getId() <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
boolean b = userService.removeById(deleteRequest.getId());
return ResultUtils.success(b);
}

/**
* 更新用户
*
* @param userUpdateRequest
* @param request
* @return
*/
@PostMapping("/update")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Boolean> updateUser(@RequestBody UserUpdateRequest userUpdateRequest,
HttpServletRequest request) {
if (userUpdateRequest == null || userUpdateRequest.getId() == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User user = new User();
BeanUtils.copyProperties(userUpdateRequest, user);
boolean result = userService.updateById(user);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(true);
}

/**
* 根据 id 获取用户(仅管理员)
*
* @param id
* @param request
* @return
*/
@GetMapping("/get")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<User> getUserById(long id, HttpServletRequest request) {
if (id <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User user = userService.getById(id);
ThrowUtils.throwIf(user == null, ErrorCode.NOT_FOUND_ERROR);
return ResultUtils.success(user);
}

/**
* 根据 id 获取包装类
*
* @param id
* @param request
* @return
*/
@GetMapping("/get/vo")
public BaseResponse<UserVO> getUserVOById(long id, HttpServletRequest request) {
BaseResponse<User> response = getUserById(id, request);
User user = response.getData();
return ResultUtils.success(userService.getUserVO(user));
}

/**
* 分页获取用户列表(仅管理员)
*
* @param userQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<Page<User>> listUserByPage(@RequestBody UserQueryRequest userQueryRequest,
HttpServletRequest request) {
long current = userQueryRequest.getCurrent();
long size = userQueryRequest.getPageSize();
Page<User> userPage = userService.page(new Page<>(current, size),
userService.getQueryWrapper(userQueryRequest));
return ResultUtils.success(userPage);
}

/**
* 分页获取用户封装列表
*
* @param userQueryRequest
* @param request
* @return
*/
@PostMapping("/list/page/vo")
public BaseResponse<Page<UserVO>> listUserVOByPage(@RequestBody UserQueryRequest userQueryRequest,
HttpServletRequest request) {
if (userQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
long current = userQueryRequest.getCurrent();
long size = userQueryRequest.getPageSize();
// 限制爬虫
ThrowUtils.throwIf(size > 20, ErrorCode.PARAMS_ERROR);
Page<User> userPage = userService.page(new Page<>(current, size),
userService.getQueryWrapper(userQueryRequest));
Page<UserVO> userVOPage = new Page<>(current, size, userPage.getTotal());
List<UserVO> userVO = userService.getUserVO(userPage.getRecords());
userVOPage.setRecords(userVO);
return ResultUtils.success(userVOPage);
}

// endregion

/**
* 更新个人信息
*
* @param userUpdateMyRequest
* @param request
* @return
*/
@PostMapping("/update/my")
public BaseResponse<Boolean> updateMyUser(@RequestBody UserUpdateMyRequest userUpdateMyRequest,
HttpServletRequest request) {
if (userUpdateMyRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
User loginUser = userService.getLoginUser(request);
User user = new User();
BeanUtils.copyProperties(userUpdateMyRequest, user);
user.setId(loginUser.getId());
boolean result = userService.updateById(user);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
return ResultUtils.success(true);
}
}

这是一个完整的用户管理控制器
涵盖 注册、登录(账号 + 微信)、注销、个人信息管理、管理员 CRUD 等功能。

管理员专属操作(@AuthCheck(mustRole = ADMIN)

接口 功能
/add 创建用户(默认密码 12345678,MD5 加盐加密)
/delete 删除用户
/update 更新用户信息
/get 获取原始 User 实体
/list/page 分页查询用户列表

公共查询 & 个人操作

(1) /get/vo:根据 ID 获取用户 VO

  • 先调用 getUserById(需管理员权限),再转 VO。
  • 问题:普通用户无法通过此接口查他人信息(因为 getUserById@AuthCheck 保护)。
  • 若想支持“查看他人主页”,应新增一个无需权限的接口,或调整权限逻辑。

(2) /list/page/vo:分页获取用户 VO 列表

  • 所有用户可访问(如社区成员列表)。
  • 限制每页 ≤20 条(防爬)。
  • 正确转换 Page<User>Page<UserVO>

(3) /update/my:更新个人信息

  • 仅能更新自己的信息(user.setId(loginUser.getId()))。
  • 安全:防止越权修改他人资料。

安全设计亮点

功能 安全措施
登录 密码加盐 MD5(虽弱于 BCrypt,但优于明文)
微信登录 使用官方 SDK,校验 code
权限控制 @AuthCheck 注解隔离管理员操作
个人信息更新 强制绑定当前用户 ID
防爬虫 分页 size ≤ 20
参数校验 全面使用 ThrowUtils / BusinessException

主要问题总结

模块 问题 建议
PostThumbController 未校验帖子是否存在 增加 postService.getById 校验
PostThumbController 未更新 thumb_num 在 service 层原子更新计数
UserController.register 参数为空返回 null 改为抛出 PARAMS_ERROR
UserController.get/vo 依赖管理员接口查用户 新增公开的用户详情接口(如 /user/profile/{id}
密码加密 使用 MD5(已不安全) 升级为 BCryptPasswordEncoder

优化建议(进阶)

  1. 统一异常处理

    • 确保所有非法参数都抛 BusinessException,而非返回 null 或静默失败。
  2. 敏感操作日志

    • 记录用户注册、登录、删除等操作日志,便于审计。
  3. 微信登录健壮性

    • 处理 unionId 为空的情况(未绑定开放平台账号),可降级使用 openId
  4. VO 转换复用

    • listUserVOByPage 中手动构建 Page<UserVO>,可封装为工具方法。
  5. 防刷机制

    • /register/login 增加验证码或 IP 限流。

微信公众号相关接口 WxMpController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/**
* 微信公众号相关接口
**/
@RestController
@RequestMapping("/")
@Slf4j
public class WxMpController {

@Resource
private WxMpService wxMpService;

@Resource
private WxMpMessageRouter router;

@PostMapping("/")
public void receiveMessage(HttpServletRequest request, HttpServletResponse response)
throws IOException {
response.setContentType("text/html;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
// 校验消息签名,判断是否为公众平台发的消息
String signature = request.getParameter("signature");
String nonce = request.getParameter("nonce");
String timestamp = request.getParameter("timestamp");
if (!wxMpService.checkSignature(timestamp, nonce, signature)) {
response.getWriter().println("非法请求");
}
// 加密类型
String encryptType = StringUtils.isBlank(request.getParameter("encrypt_type")) ? "raw"
: request.getParameter("encrypt_type");
// 明文消息
if ("raw".equals(encryptType)) {
return;
}
// aes 加密消息
if ("aes".equals(encryptType)) {
// 解密消息
String msgSignature = request.getParameter("msg_signature");
WxMpXmlMessage inMessage = WxMpXmlMessage
.fromEncryptedXml(request.getInputStream(), wxMpService.getWxMpConfigStorage(), timestamp,
nonce,
msgSignature);
log.info("message content = {}", inMessage.getContent());
// 路由消息并处理
WxMpXmlOutMessage outMessage = router.route(inMessage);
if (outMessage == null) {
response.getWriter().write("");
} else {
response.getWriter().write(outMessage.toEncryptedXml(wxMpService.getWxMpConfigStorage()));
}
return;
}
response.getWriter().println("不可识别的加密类型");
}

@GetMapping("/")
public String check(String timestamp, String nonce, String signature, String echostr) {
log.info("check");
if (wxMpService.checkSignature(timestamp, nonce, signature)) {
return echostr;
} else {
return "";
}
}

/**
* 设置公众号菜单
*
* @return
* @throws WxErrorException
*/
@GetMapping("/setMenu")
public String setMenu() throws WxErrorException {
log.info("setMenu");
WxMenu wxMenu = new WxMenu();
// 菜单一
WxMenuButton wxMenuButton1 = new WxMenuButton();
wxMenuButton1.setType(MenuButtonType.VIEW);
wxMenuButton1.setName("主菜单一");
// 子菜单
WxMenuButton wxMenuButton1SubButton1 = new WxMenuButton();
wxMenuButton1SubButton1.setType(MenuButtonType.VIEW);
wxMenuButton1SubButton1.setName("跳转页面");
wxMenuButton1SubButton1.setUrl(
"https://yupi.icu");
wxMenuButton1.setSubButtons(Collections.singletonList(wxMenuButton1SubButton1));

// 菜单二
WxMenuButton wxMenuButton2 = new WxMenuButton();
wxMenuButton2.setType(MenuButtonType.CLICK);
wxMenuButton2.setName("点击事件");
wxMenuButton2.setKey(WxMpConstant.CLICK_MENU_KEY);

// 菜单三
WxMenuButton wxMenuButton3 = new WxMenuButton();
wxMenuButton3.setType(MenuButtonType.VIEW);
wxMenuButton3.setName("主菜单三");
WxMenuButton wxMenuButton3SubButton1 = new WxMenuButton();
wxMenuButton3SubButton1.setType(MenuButtonType.VIEW);
wxMenuButton3SubButton1.setName("编程学习");
wxMenuButton3SubButton1.setUrl("https://yupi.icu");
wxMenuButton3.setSubButtons(Collections.singletonList(wxMenuButton3SubButton1));

// 设置主菜单
wxMenu.setButtons(Arrays.asList(wxMenuButton1, wxMenuButton2, wxMenuButton3));
wxMpService.getMenuService().menuCreate(wxMenu);
return "ok";
}
}

微信公众号(WeChat Official Account)的后端接入控制器 WxMpController
主要实现了以下三大功能:

  1. 公众号服务器配置验证(GET 请求)
  2. 接收并处理用户消息(POST 请求)
  3. 动态创建自定义菜单(GET /setMenu
  • 路径为根路径 /,符合微信公众号要求(需在微信公众平台配置“服务器地址”为 https://your-domain/)。
  • 使用 WxJava(原 Weixin-java-tools)SDK 处理微信逻辑。
  • router 用于路由不同类型的用户消息(文本、事件等)到对应处理器。

GET 请求:服务器配置验证(/

1
2
3
4
5
6
7
8
@GetMapping("/")
public String check(String timestamp, String nonce, String signature, String echostr) {
if (wxMpService.checkSignature(timestamp, nonce, signature)) {
return echostr; // 微信要求原样返回 echostr
} else {
return "";
}
}
  • 作用:微信公众平台首次配置服务器 URL 时,会发送 GET 请求验证你的服务是否可信。
  • 原理:微信用 token + timestamp + nonce 按字典序拼接后 SHA1,与你传的 signature 比对。

    安全提示token 应配置在 WxMpConfigStorage 中(如 application.yml),不可硬编码

POST 请求:接收用户消息(/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@PostMapping("/")
public void receiveMessage(HttpServletRequest request, HttpServletResponse response) throws IOException {
// 1. 验签
if (!wxMpService.checkSignature(...)) {
response.getWriter().println("非法请求");
return;
}

// 2. 判断加密类型
String encryptType = ...;

// 3. 明文模式(raw)
if ("raw".equals(encryptType)) {
// 问题:直接 return,未读取 body,也未响应
return;
}

// 4. AES 加密模式(aes)
if ("aes".equals(encryptType)) {
// 解密消息
WxMpXmlMessage inMessage = WxMpXmlMessage.fromEncryptedXml(...);
// 路由处理
WxMpXmlOutMessage outMessage = router.route(inMessage);
// 加密响应
response.getWriter().write(outMessage.toEncryptedXml(...));
}
}

功能说明

  • 支持 明文(raw)AES 安全模式(aes)
  • 使用 WxMpMessageRouter 将消息分发给不同处理器(如文本回复、菜单点击事件等)。

严重问题:明文模式未处理消息!

1
2
3
if ("raw".equals(encryptType)) {
return; // ❌ 直接返回,未读取消息体,也未响应
}
  • 后果
    • 用户发送消息后,公众号无任何回复。
    • 微信服务器可能因超时或空响应而重试,甚至断开连接。
  • 正确做法:即使明文模式,也要读取 XML 消息并处理。

修复建议

1
2
3
4
5
6
7
8
9
10
11
if ("raw".equals(encryptType)) {
WxMpXmlMessage inMessage = WxMpXmlMessage.fromXml(request.getInputStream());
log.info("明文消息 content = {}", inMessage.getContent());
WxMpXmlOutMessage outMessage = router.route(inMessage);
if (outMessage != null) {
response.getWriter().write(outMessage.toXml());
} else {
response.getWriter().write(""); // 或默认回复
}
return;
}

GET /setMenu:动态创建公众号菜单

1
2
3
4
5
6
7
8
@GetMapping("/setMenu")
public String setMenu() throws WxErrorException {
WxMenu menu = new WxMenu();
// 构建三个主菜单(含子菜单)
wxMenu.setButtons(Arrays.asList(btn1, btn2, btn3));
wxMpService.getMenuService().menuCreate(menu);
return "ok";
}
  • 使用 WxJavamenuCreate API 正确创建菜单。
  • 菜单类型包括:
    • VIEW:跳转网页(需配置 JS 安全域名)
    • CLICK:触发点击事件(需在 router 中监听 WxMpConstant.CLICK_MENU_KEY

安全风险:接口未加权限控制!

  • 任何人访问 https://your-domain/setMenu 都能重置菜单。
  • 建议
    • 添加管理员权限校验(如 @AuthCheck(mustRole = ADMIN)
    • 或增加 token 验证(如 ?token=SECRET
    • 或仅在开发环境开放

改进示例

1
2
3
4
5
6
@GetMapping("/setMenu")
@AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
public BaseResponse<String> setMenu() {
// ... 创建菜单
return ResultUtils.success("菜单设置成功");
}

统一异常处理

当前 receiveMessage 若抛异常,会返回 500,微信可能重试。建议:

1
2
3
4
5
6
try {
// 原有逻辑
} catch (Exception e) {
log.error("处理微信消息失败", e);
response.getWriter().write(""); // 安静失败 or 返回错误提示
}

支持更多消息类型

确保 router 已注册:

  • 文本消息
  • 关注/取消关注事件
  • 菜单点击事件(CLICK_MENU_KEY
  • 扫码事件等

菜单 URL 安全

  • https://yupi.icu 需在公众号后台配置为 JS 安全域名网页授权域名,否则在微信内打不开。

esdao

帖子ES操作 PostEsDao

1
2
3
4
5
6
/**
* 帖子 ES 操作
*/
public interface PostEsDao extends ElasticsearchRepository<PostEsDTO, Long> {
List<PostEsDTO> findByUserId(Long userId);
}

基于 Spring Data Elasticsearch 的 DAO 接口
用于对帖子(Post)在 Elasticsearch 中进行操作。

功能说明

  • 继承 ElasticsearchRepository<PostEsDTO, Long>
    表示这是一个 Spring Data Elasticsearch 的 Repository,管理的文档类型是 PostEsDTO,主键类型为 Long(对应帖子 ID)。

  • 自定义查询方法:findByUserId(Long userId)
    Spring Data 会自动根据方法名生成 ES 查询语句,等价于:

    1
    2
    3
    4
    5
    6
    7
    {
    "query": {
    "term": {
    "userId": userId
    }
    }
    }

    用于查找某个用户发布的所有帖子(在 ES 中)。

1. PostEsDTO 是什么?

  • 它应该是 Post 实体的 Elasticsearch 映射 DTO,通常包含:
    • id(帖子 ID)
    • userId(作者 ID)
    • titlecontent(用于全文搜索)
    • tagscategory(用于过滤)
    • createTime(用于排序)
  • 字段上应有 @Field 注解指定类型(如 text / keyword / date):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Document(indexName = "post")
public class PostEsDTO {
@Id
private Long id;

@Field(type = FieldType.Long)
private Long userId;

@Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
private String title;

@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String content;

@Field(type = FieldType.Keyword)
private List<String> tags;

@Field(type = FieldType.Date)
private Date createTime;
}

若未正确配置 @Field,可能导致搜索不生效(如用 text 字段做精确匹配)。

2. findByUserId 的使用场景

  • 用于 “用户主页”展示其发布的所有帖子(走 ES 而非 DB)
  • 优势:可结合 ES 的分页、高亮、相关性排序等能力
  • 但注意:ES 不是强一致数据库,若对实时性要求高(如刚发布立刻可见),需确保同步机制可靠

问题 1:方法命名隐含“精确匹配”,但字段类型可能不支持

  • 如果 userIdPostEsDTO 中被定义为 text 类型(错误!),则 term 查询会失败。
  • 必须确保 userIdFieldType.LongFieldType.Keyword(数值或不分词字符串)。
    建议:显式使用 @Query 避免歧义(尤其复杂查询时)
1
2
@Query("{\"bool\": {\"must\": [{\"term\": {\"userId\": \"?0\"}}]}}")
Page<PostEsDTO> findByUserId(Long userId, Pageable pageable);

问题 2:缺少分页支持

当前方法返回 List<PostEsDTO>无法分页,大数据量下会 OOM。
改进:支持分页

1
Page<PostEsDTO> findByUserId(Long userId, Pageable pageable);

调用时

1
2
Pageable page = PageRequest.of(0, 10);
Page<PostEsDTO> posts = postEsDao.findByUserId(userId, page);

问题 3:未处理“软删除”或“审核状态”

如果帖子有 isDeletereviewStatus 等状态字段,ES 中应同步这些字段,并在查询时过滤:

1
2
// 更安全的查询(假设已同步 isDelete = false)
List<PostEsDTO> findByUserIdAndIsDelete(Long userId, Boolean isDelete);

否则可能把已删除/未审核的帖子暴露给用户。

问题 4:ES 与 DB 数据一致性风险

  • 帖子在 MySQL 中增删改后,必须同步到 ES(通过 MQ 或监听 binlog)。
  • 否则 findByUserId 可能查到脏数据或缺失数据。
    建议架构
1
MySQL → Canal / Binlog Listener → Kafka → ES Sync Service → Elasticsearch

或简单版:

1
2
3
4
5
@PostMapping("/add")
public void addPost(Post post) {
postService.save(post);
postEsService.saveToEs(post); // 异步 or 同步?
}

⚠️ 同步写 ES 会影响性能,建议异步(但牺牲一致性);关键业务可同步 + 重试机制。

最佳实践总结

项目 建议
DTO 字段类型 userId 必须为 Long / Keyword,不可为 Text
分页 使用 Page<PostEsDTO> 而非 List
状态过滤 查询时排除已删除/未审核帖子
数据同步 确保 MySQL → ES 同步可靠(最终一致性)
索引设计 title/contentik 分词器支持中文搜索
方法命名 复杂查询优先用 @Query 避免歧义

扩展:常见 ES 查询方法示例

1
2
3
4
5
6
7
8
9
// 全文搜索 + 用户过滤
List<PostEsDTO> findByUserIdAndTitleContainingOrContentContaining(
Long userId, String keyword1, String keyword2);

// 按标签查询
List<PostEsDTO> findByTagsContains(String tag);

// 时间范围 + 分页
Page<PostEsDTO> findByCreateTimeAfter(Date date, Pageable pageable);

注意:Containing 对应 match 查询,Contains(集合)对应 terms 查询。

总结

你的 PostEsDao 接口简洁有效,体现了 Spring Data Elasticsearch 的便利性。
只需注意以下三点,即可安全用于生产:

  1. 确保 userId 字段类型正确(非 text)
  2. 增加分页支持
  3. 保证 ES 与 DB 数据同步

异常处理类 exception

自定义异常类 BusinessException

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 自定义异常类
*/
@Getter
public class BusinessException extends RuntimeException {

/**
* 错误码
*/
private final int code;

public BusinessException(int code, String message) {
super(message);
this.code = code;
}

public BusinessException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.code = errorCode.getCode();
}

public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.code = errorCode.getCode();
}
}

全局异常处理器 GlobalExceptionHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 全局异常处理器
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("BusinessException", e);
return ResultUtils.error(e.getCode(), e.getMessage());
}

@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
}
}

抛异常工具类 ThrowUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 抛异常工具类
*/
public class ThrowUtils {

/**
* 条件成立则抛异常
*
* @param condition
* @param runtimeException
*/
public static void throwIf(boolean condition, RuntimeException runtimeException) {
if (condition) {
throw runtimeException;
}
}

/**
* 条件成立则抛异常
*
* @param condition
* @param errorCode
*/
public static void throwIf(boolean condition, ErrorCode errorCode) {
throwIf(condition, new BusinessException(errorCode));
}

/**
* 条件成立则抛异常
*
* @param condition
* @param errorCode
* @param message
*/
public static void throwIf(boolean condition, ErrorCode errorCode, String message) {
throwIf(condition, new BusinessException(errorCode, message));
}
}

整体架构:三层异常处理机制

组件 职责 优点
BusinessException 自定义业务异常,携带错误码和消息 区分业务错误 vs 系统错误
ThrowUtils 提供简洁的“条件抛异常”工具 减少 if-throw 冗余代码,提升可读性
GlobalExceptionHandler 全局捕获异常并统一返回 BaseResponse 避免异常堆栈暴露,保证 API 响应格式一致
这是一个典型的 “主动抛出 + 统一拦截 + 标准化响应” 模式,非常推荐!

BusinessException:自定义业务异常

1
2
3
4
5
6
7
8
@Getter
public class BusinessException extends RuntimeException {
private final int code;

public BusinessException(int code, String message) { ... }
public BusinessException(ErrorCode errorCode) { ... }
public BusinessException(ErrorCode errorCode, String message) { ... }
}

✅ 优点:

  • 继承 RuntimeException无需强制 try-catch,适合业务流程中断。
  • 使用 @Getter(Lombok)自动提供 getCode()
  • 支持直接传 ErrorCode 枚举,解耦错误码与硬编码数字
  • 提供重载构造函数,灵活使用。

建议增强(可选):

  • 添加 serialVersionUID(若需序列化):
    1
    private static final long serialVersionUID = 1L;
  • 若未来支持多语言,可增加 errorCode 字段(而非仅 code)。

ThrowUtils:优雅的断言工具

1
2
3
4
5
public class ThrowUtils {
public static void throwIf(boolean condition, RuntimeException e) { ... }
public static void throwIf(boolean condition, ErrorCode errorCode) { ... }
public static void throwIf(boolean condition, ErrorCode errorCode, String message) { ... }
}

优点:

  • 语义清晰throwIf(user == null, ErrorCode.NOT_LOGIN)if (user == null) throw ... 更简洁。
  • 减少样板代码,提升开发效率。
  • BusinessException 无缝集成。

使用示例(对比):
传统写法:

1
2
3
if (postId <= 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "帖子ID无效");
}

使用 ThrowUtils

1
ThrowUtils.throwIf(postId <= 0, ErrorCode.PARAMS_ERROR, "帖子ID无效");

这是 防御式编程 的最佳实践!

GlobalExceptionHandler:全局兜底

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

@ExceptionHandler(BusinessException.class)
public BaseResponse<?> businessExceptionHandler(BusinessException e) {
log.error("BusinessException", e);
return ResultUtils.error(e.getCode(), e.getMessage());
}

@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统错误");
}
}

优点:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody自动序列化为 JSON
  • 分层处理
    • BusinessException → 返回具体业务错误(如“参数错误”)
    • 其他 RuntimeException → 统一返回“系统错误”,避免敏感信息泄露
  • 所有异常记录日志,便于排查。

潜在问题 & 改进建议:
问题 1:RuntimeException 处理太宽泛

  • 所有未被捕获的运行时异常(包括 NPE、数组越界等)都返回“系统错误”,不利于调试
  • 开发阶段可能掩盖真实问题。
    建议:在 开发环境 打印完整堆栈,或区分已知/未知异常。
1
2
3
4
5
6
7
8
9
10
11
@ExceptionHandler(RuntimeException.class)
public BaseResponse<?> runtimeExceptionHandler(RuntimeException e) {
// 开发环境可返回详细信息(谨慎!)
if ("dev".equals(profile)) {
log.error("RuntimeException", e);
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, e.getMessage());
} else {
log.error("Unexpected system error", e); // 生产环境不暴露细节
return ResultUtils.error(ErrorCode.SYSTEM_ERROR, "系统繁忙,请稍后再试");
}
}

问题 2:缺少对 MethodArgumentNotValidException 等 Spring 异常的处理

  • 如果使用 @Valid 参数校验,校验失败会抛 MethodArgumentNotValidException,当前未处理,会落入 RuntimeException 分支。
    建议补充
1
2
3
4
5
@ExceptionHandler(MethodArgumentNotValidException.class)
public BaseResponse<?> validationExceptionHandler(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldError().getDefaultMessage();
return ResultUtils.error(ErrorCode.PARAMS_ERROR, message);
}

问题 3:日志记录方式

  • log.error("BusinessException", e) 会打印完整堆栈,但业务异常通常不需要堆栈(因为是预期错误)。
    优化
1
2
3
4
5
// 业务异常:只记录 message
log.warn("BusinessException: code={}, message={}", e.getCode(), e.getMessage());

// 系统异常:记录完整堆栈
log.error("System error occurred", e);

总结:这套异常体系的优点

特性 说明
统一响应格式 所有接口返回 BaseResponse<T>,前端处理简单
错误码标准化 通过 ErrorCode 枚举管理,避免魔法数字
代码简洁 ThrowUtils.throwIf(...) 替代冗长 if 判断
安全兜底 未知异常不暴露堆栈,防止信息泄露
日志可追溯 所有异常记录日志,便于监控告警

最终优化建议清单

  1. 补充常见 Spring 异常处理(如参数校验、HTTP 404)
  2. 区分业务异常与系统异常的日志级别(warn vs error)
  3. 生产环境隐藏系统异常细节
  4. 确保 ErrorCode 枚举覆盖所有业务场景
  5. 在文档中列出所有错误码含义(供前端/客户端查阅)

代码生成模板 generate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/**
* 代码生成器
*/
public class CodeGenerator {

/**
* 用法:修改生成参数和生成路径,注释掉不需要的生成逻辑,然后运行即可
*
* @param args
* @throws TemplateException
* @throws IOException
*/
public static void main(String[] args) throws TemplateException, IOException {
// 指定生成参数
String packageName = "com.yupi.springbootinit";
String dataName = "用户评论";
String dataKey = "UserComment";
String upperDataKey = "UserComment";

// 封装生成参数
Map<String, Object> dataModel = new HashMap<>();
dataModel.put("packageName", packageName);
dataModel.put("dataName", dataName);
dataModel.put("dataKey", dataKey);
dataModel.put("upperDataKey", upperDataKey);

// 生成路径默认值
String projectPath = System.getProperty("user.dir");
// 参考路径,可以自己调整下面的 outputPath
String inputPath = projectPath + File.separator + "src/main/resources/templates/模板名称.java.ftl";
String outputPath = String.format("%s/generator/包名/%s类后缀.java", projectPath, upperDataKey);

// 1、生成 Controller
// 指定生成路径
inputPath = projectPath + File.separator + "src/main/resources/templates/TemplateController.java.ftl";
outputPath = String.format("%s/generator/controller/%sController.java", projectPath, upperDataKey);
// 生成
doGenerate(inputPath, outputPath, dataModel);
System.out.println("生成 Controller 成功,文件路径:" + outputPath);

// 2、生成 Service 接口和实现类
// 生成 Service 接口
inputPath = projectPath + File.separator + "src/main/resources/templates/TemplateService.java.ftl";
outputPath = String.format("%s/generator/service/%sService.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
System.out.println("生成 Service 接口成功,文件路径:" + outputPath);
// 生成 Service 实现类
inputPath = projectPath + File.separator + "src/main/resources/templates/TemplateServiceImpl.java.ftl";
outputPath = String.format("%s/generator/service/impl/%sServiceImpl.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
System.out.println("生成 Service 实现类成功,文件路径:" + outputPath);

// 3、生成数据模型封装类(包括 DTO 和 VO)
// 生成 DTO
inputPath = projectPath + File.separator + "src/main/resources/templates/model/TemplateAddRequest.java.ftl";
outputPath = String.format("%s/generator/model/dto/%sAddRequest.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
inputPath = projectPath + File.separator + "src/main/resources/templates/model/TemplateQueryRequest.java.ftl";
outputPath = String.format("%s/generator/model/dto/%sQueryRequest.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
inputPath = projectPath + File.separator + "src/main/resources/templates/model/TemplateEditRequest.java.ftl";
outputPath = String.format("%s/generator/model/dto/%sEditRequest.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
inputPath = projectPath + File.separator + "src/main/resources/templates/model/TemplateUpdateRequest.java.ftl";
outputPath = String.format("%s/generator/model/dto/%sUpdateRequest.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
System.out.println("生成 DTO 成功,文件路径:" + outputPath);
// 生成 VO
inputPath = projectPath + File.separator + "src/main/resources/templates/model/TemplateVO.java.ftl";
outputPath = String.format("%s/generator/model/vo/%sVO.java", projectPath, upperDataKey);
doGenerate(inputPath, outputPath, dataModel);
System.out.println("生成 VO 成功,文件路径:" + outputPath);
}

/**
* 生成文件
*
* @param inputPath 模板文件输入路径
* @param outputPath 输出路径
* @param model 数据模型
* @throws IOException
* @throws TemplateException
*/
public static void doGenerate(String inputPath, String outputPath, Object model) throws IOException, TemplateException {
// new 出 Configuration 对象,参数为 FreeMarker 版本号
Configuration configuration = new Configuration(Configuration.VERSION_2_3_31);

// 指定模板文件所在的路径
File templateDir = new File(inputPath).getParentFile();
configuration.setDirectoryForTemplateLoading(templateDir);

// 设置模板文件使用的字符集
configuration.setDefaultEncoding("utf-8");

// 创建模板对象,加载指定模板
String templateName = new File(inputPath).getName();
Template template = configuration.getTemplate(templateName);

// 文件不存在则创建文件和父目录
if (!FileUtil.exist(outputPath)) {
FileUtil.touch(outputPath);
}

// 生成
Writer out = new FileWriter(outputPath);
template.process(model, out);

// 生成文件后别忘了关闭哦
out.close();
}
}

定时任务 job

cycle

增量同步帖子到 es IncSyncPostToEs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 增量同步帖子到 es
*/
// todo 取消注释开启任务
@Component
@Slf4j
public class IncSyncPostToEs {

@Resource
private PostMapper postMapper;

@Resource
private PostEsDao postEsDao;

/**
* 每分钟执行一次
*/
@Scheduled(fixedRate = 60 * 1000)
public void run() {
// 查询近 5 分钟内的数据
Date fiveMinutesAgoDate = new Date(new Date().getTime() - 5 * 60 * 1000L);
List<Post> postList = postMapper.listPostWithDelete(fiveMinutesAgoDate);
if (CollUtil.isEmpty(postList)) {
log.info("no inc post");
return;
}
List<PostEsDTO> postEsDTOList = postList.stream()
.map(PostEsDTO::objToDto)
.collect(Collectors.toList());
final int pageSize = 500;
int total = postEsDTOList.size();
log.info("IncSyncPostToEs start, total {}", total);
for (int i = 0; i < total; i += pageSize) {
int end = Math.min(i + pageSize, total);
log.info("sync from {} to {}", i, end);
postEsDao.saveAll(postEsDTOList.subList(i, end));
}
log.info("IncSyncPostToEs end, total {}", total);
}
}

once

全量同步帖子到 es FullSyncPostToEs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 全量同步帖子到 es
*/
// todo 取消注释开启任务
@Component
@Slf4j
public class FullSyncPostToEs implements CommandLineRunner {

@Resource
private PostService postService;

@Resource
private PostEsDao postEsDao;

@Override
public void run(String... args) {
List<Post> postList = postService.list();
if (CollUtil.isEmpty(postList)) {
return;
}
List<PostEsDTO> postEsDTOList = postList.stream().map(PostEsDTO::objToDto).collect(Collectors.toList());
final int pageSize = 500;
int total = postEsDTOList.size();
log.info("FullSyncPostToEs start, total {}", total);
for (int i = 0; i < total; i += pageSize) {
int end = Math.min(i + pageSize, total);
log.info("sync from {} to {}", i, end);
postEsDao.saveAll(postEsDTOList.subList(i, end));
}
log.info("FullSyncPostToEs end, total {}", total);
}
}

manger

Cos 对象存储操作 CosManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* Cos 对象存储操作
*/
@Component
public class CosManager {
@Resource
private CosClientConfig cosClientConfig;
@Resource
private COSClient cosClient;

/**
* 上传对象
*
* @param key 唯一键
* @param localFilePath 本地文件路径
* @return
*/
public PutObjectResult putObject(String key, String localFilePath) {
PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key,
new File(localFilePath));
return cosClient.putObject(putObjectRequest);
}

/**
* 上传对象
*
* @param key 唯一键
* @param file 文件
* @return
*/
public PutObjectResult putObject(String key, File file) {
PutObjectRequest putObjectRequest = new PutObjectRequest(cosClientConfig.getBucket(), key,
file);
return cosClient.putObject(putObjectRequest);
}
}

增量同步帖子到 Elasticsearch(ES)全量同步帖子到 ES 的定时任务,整体设计合理、结构清晰。

整体评价

项目 评价
目标明确 增量(每分钟) + 全量(启动时)同步,覆盖常见场景
分页处理 使用 pageSize = 500 批量写入,避免 OOM
日志完善 关键步骤打日志,便于监控和排查
工具类使用 CollUtil.isEmptystream().map() 等提升可读性
这是一套生产可用的基础同步方案

增量同步 IncSyncPostToEs

1
2
3
4
5
6
@Scheduled(fixedRate = 60 * 1000)
public void run() {
Date fiveMinutesAgoDate = new Date(new Date().getTime() - 5 * 60 * 1000L);
List<Post> postList = postMapper.listPostWithDelete(fiveMinutesAgoDate);
// ... 转 DTO 并批量 saveAll
}

优点:

  • 时间窗口合理:查近 5 分钟数据,容忍任务延迟(如 GC 导致错过 1 分钟)
  • 包含已删除帖子listPostWithDelete 表明能同步 isDelete=true 的记录,便于 ES 中标记/删除

潜在问题:
问题 1:未处理“删除”操作

  • 当前逻辑只做 saveAll,但若帖子被物理删除软删除(isDelete=1),ES 中应删除文档或更新状态。
  • 否则 ES 会保留已删除的帖子,导致搜索结果不一致。

解决方案

  • PostEsDTO 中保留 isDelete 字段
  • 查询时包含已删除帖子
  • 同步时:
    • isDelete == true → 调用 postEsDao.deleteById(id)
    • 否则 → save
1
2
3
4
5
6
7
8
for (PostEsDTO dto : batch) {
if (dto.getIsDelete()) {
postEsDao.deleteById(dto.getId());
} else {
postEsDao.save(dto);
}
}
// 或使用 bulk processor 提升性能(见下文)

💡 更优方案:使用 ES 的 _delete_by_querybulk API 统一处理增删改。

问题 2:时间窗口存在重叠/遗漏风险

  • 使用 new Date() 获取当前时间,但任务执行期间系统时间可能跳变(如 NTP 校准)
  • 多实例部署时,多个节点同时执行会导致重复同步

改进方案

  • 使用数据库记录最后同步时间戳(而非依赖本地时间)
  • 或使用分布式锁(如 Redis)确保单实例执行
1
2
3
4
5
// 示例:记录 lastSyncTime 到 db 或 redis
Date lastSyncTime = getLastSyncTime();
Date now = new Date();
List<Post> posts = postMapper.listUpdatedAfter(lastSyncTime);
updateLastSyncTime(now);

问题 3:未处理同步失败

  • saveAll 若部分失败(如网络抖动),整个批次回滚?还是继续?
  • Spring Data ES 默认不支持事务,失败可能导致数据不一致

建议

  • 捕获异常并记录失败 ID,支持重试
  • 或改用 ES Bulk API + 异步回调

全量同步 FullSyncPostToEs

1
2
3
4
5
6
7
8
@Component
public class FullSyncPostToEs implements CommandLineRunner {
@Override
public void run(String... args) {
List<Post> postList = postService.list(); // ⚠️ 危险!
// ... 分页 saveAll
}
}

严重问题:postService.list() 可能 OOM

  • 如果帖子表有 100 万条数据list() 会一次性加载到内存 → 内存溢出
  • 即使分页写入 ES,但读取阶段已崩溃
    必须改为分页读取!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public void run(String... args) {
final int pageSize = 500;
long current = 0;
int totalSynced = 0;

while (true) {
Page<Post> page = postService.page(new Page<>(current, pageSize));
List<Post> records = page.getRecords();
if (records.isEmpty()) break;

List<PostEsDTO> dtos = records.stream()
.map(PostEsDTO::objToDto)
.collect(Collectors.toList());

postEsDao.saveAll(dtos);
totalSynced += dtos.size();
log.info("Synced {} posts", totalSynced);

current++;
}
log.info("Full sync completed, total: {}", totalSynced);
}

🔥 这是全量同步的核心原则:流式读取,避免全量加载!

高级优化建议

  1. 使用 ES Bulk API 提升性能
    Spring Data 的 saveAll 内部虽用 bulk,但不如直接控制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Autowired
private ElasticsearchRestTemplate elasticsearchTemplate;

// 构建 BulkRequest
BulkRequest bulkRequest = new BulkRequest();
for (PostEsDTO dto : batch) {
if (dto.getIsDelete()) {
bulkRequest.add(new DeleteRequest("post").id(dto.getId().toString()));
} else {
bulkRequest.add(new IndexRequest("post").id(dto.getId().toString())
.source(JSON.toJSONString(dto), XContentType.JSON));
}
}
elasticsearchTemplate.execute(client -> client.bulk(bulkRequest, RequestOptions.DEFAULT));
  1. 增加同步状态监控
  • 记录每次同步的起止时间、数量、耗时
  • 暴露指标(如 Micrometer)供 Prometheus 抓取
  1. 支持手动触发
  • 通过接口(如 /admin/sync/full)触发全量/增量同步,方便运维
  1. 幂等性设计
  • 确保同一条数据多次同步结果一致(靠 ES 的 id 覆盖)

mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 帖子收藏数据库操作
*/
public interface PostFavourMapper extends BaseMapper<PostFavour> {
/**
* 分页查询收藏帖子列表
*
* @param page
* @param queryWrapper
* @param favourUserId
* @return
*/
Page<Post> listFavourPostByPage(IPage<Post> page, @Param(Constants.WRAPPER) Wrapper<Post> queryWrapper,
long favourUserId);
}
1
2
3
4
5
6
7
8
9
/**
* 帖子数据库操作
*/
public interface PostMapper extends BaseMapper<Post> {
/**
* 查询帖子列表(包括已被删除的数据)
*/
List<Post> listPostWithDelete(Date minUpdateTime);
}
1
2
3
4
5
/**
* 帖子点赞数据库操作
*/
public interface PostThumbMapper extends BaseMapper<PostThumb> {
}
1
2
3
4
5
/**
* 用户数据库操作
*/
public interface UserMapper extends BaseMapper<User> {
}

MyBatis-Plus(MP) Mapper 接口
用于操作帖子、收藏、点赞、用户等数据表。

Mapper 功能 评价
PostFavourMapper 查询某用户收藏的帖子(分页) ✅ 高频需求,设计合理
PostMapper 增量同步用:查近 N 分钟更新的帖子(含已删除) ✅ 支持软删除同步
PostThumbMapper 点赞记录 CRUD ⚠️ 目前仅继承 BaseMapper,无自定义方法
UserMapper 用户基础操作 ✅ 标准用法
整体符合 “核心业务定制 + 通用操作继承” 的 MP 使用范式。

PostFavourMapper:查询用户收藏的帖子(分页)

1
2
3
4
5
Page<Post> listFavourPostByPage(
IPage<Post> page,
@Param(Constants.WRAPPER) Wrapper<Post> queryWrapper,
long favourUserId
);

设计亮点:

  • 关联查询:从 post_favour 表关联 post 表,返回 Post 列表(而非 PostFavour
  • 支持动态条件:通过 Wrapper<Post> 传入帖子筛选条件(如标签、分类)
  • 分页集成:直接返回 IPage<Post>,与 MP 分页插件无缝配合
    对应 XML 应类似:
1
2
3
4
5
6
7
8
9
10
<select id="listFavourPostByPage" resultType="Post">
SELECT p.*
FROM post_favour pf
JOIN post p ON pf.postId = p.id
WHERE pf.userId = #{favourUserId}
AND p.isDelete = 0 <!-- 通常不展示已删除帖子 -->
<if test="ew != null">
${ew.customSqlSegment}
</if>
</select>

注意:@Param(Constants.WRAPPER) 中的 Constants.WRAPPER 默认值为 "ew",XML 中需用 ${ew.customSqlSegment} 拼接条件。

潜在问题:

  • N+1 查询风险? → 不会,这是单条 JOIN 查询。
  • 性能瓶颈:若用户收藏量极大(>10万),分页 deep paging 性能差。
    优化建议
  • post_favour(userId, postId) 建联合索引
  • 考虑用 游标分页(cursor-based pagination) 替代 LIMIT offset, size

PostMapper:增量同步用(含已删除数据)

1
List<Post> listPostWithDelete(Date minUpdateTime);

设计目的明确:

  • 用于 ES 增量同步,需包含 isDelete = 1 的记录,以便在 ES 中删除或标记
    对应 XML:
1
2
3
4
5
<select id="listPostWithDelete" resultType="Post">
SELECT *
FROM post
WHERE updateTime >= #{minUpdateTime}
</select>

注意:必须确保 updateTime 字段在 新增、修改、软删除时都会更新

关键要求:

  • updateTime 必须是 真正反映数据变更的时间(包括软删除)
  • 若使用 MP 的 @TableField(fill = FieldFill.UPDATE),需确认软删除时是否触发填充
    验证建议
1
2
-- 手动测试:软删除一条帖子,检查 updateTime 是否更新
UPDATE post SET isDelete = 1, updateTime = NOW() WHERE id = 123;

PostThumbMapper & UserMapper:基础 CRUD

1
2
public interface PostThumbMapper extends BaseMapper<PostThumb> {}
public interface UserMapper extends BaseMapper<User> {}

合理之处:

  • 点赞、用户目前只需基础增删改查,无需自定义 SQL
  • MP 的 BaseMapper 已提供 insert, deleteById, selectById, update 等方法
    未来可能扩展:
    场景 建议方法
    查某用户是否点赞某帖子 boolean existsByUserIdAndPostId(Long userId, Long postId)
    批量查用户信息 List<User> selectBatchIds(@Param("ids") Collection<Long> ids)(MP 已内置)
    按用户名模糊查询 自定义 selectByUsernameLike(String username)

    MP 的 selectBatchIdsselectByMap 已能满足大部分批量查询需求。

修改建议

  1. 统一软删除策略
  • 确保所有 Post 相关查询(除同步外)默认过滤 isDelete = 0
  • 可通过 MP 的 全局配置 自动追加条件:
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusPropertiesCustomizer mybatisPlusPropertiesCustomizer() {
return properties -> {
properties.getGlobalConfig().getDbConfig()
.setLogicDeleteValue(1) // 已删除
.setLogicNotDeleteValue(0); // 未删除
};
}
}

然后实体类加注解:

1
2
3
4
5
@TableName(value = "post", autoResultMap = true)
public class Post {
@TableLogic
private Integer isDelete;
}

这样 postMapper.selectList() 会自动加 AND isDelete = 0,而 listPostWithDelete 需用 @Select 绕过逻辑删除。

  1. 防止全表扫描
  • listPostWithDelete 必须在 updateTime 上有索引
    1
    ALTER TABLE post ADD INDEX idx_update_time (updateTime);
  1. 命名规范一致性
  • 方法名 listFavourPostByPage → 建议改为 pageFavourPost(更符合 MP 风格)
  • 但当前命名也清晰,非强制修改

model

dto

file

文件上传请求 UploadFileRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 文件上传请求
*/
@Data
public class UploadFileRequest implements Serializable {

/**
* 业务
*/
private String biz;

@Serial
private static final long serialVersionUID = 1L;
}

post

创建请求 PostAddRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 创建请求
*/
@Data
public class PostAddRequest implements Serializable {

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 标签列表
*/
private List<String> tags;

@Serial
private static final long serialVersionUID = 1L;
}

编辑请求 PostEditRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 编辑请求
*/
@Data
public class PostEditRequest implements Serializable {

/**
* id
*/
private Long id;

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 标签列表
*/
private List<String> tags;

@Serial
private static final long serialVersionUID = 1L;
}

帖子 ES 数据传输对象 PostEsDTO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/**
* 帖子 ES 数据传输对象(适配 Elasticsearch Java API Client)
* 注意:不再使用 Spring Data Elasticsearch 注解
*/
@Data
public class PostEsDTO implements Serializable {

@Serial
private static final long serialVersionUID = 1L;

/**
* id
*/
@JsonProperty("id")
private Long id;

/**
* 标题
*/
@JsonProperty("title")
private String title;

/**
* 内容
*/
@JsonProperty("content")
private String content;

/**
* 标签列表
*/
@JsonProperty("tags")
private List<String> tags;

/**
* 点赞数
*/
@JsonProperty("thumbNum")
private Integer thumbNum;

/**
* 收藏数
*/
@JsonProperty("favourNum")
private Integer favourNum;

/**
* 创建用户 id
*/
@JsonProperty("userId")
private Long userId;

/**
* 创建时间(ES 中为 date 类型,格式 yyyy-MM-dd'T'HH:mm:ss.SSS'Z')
*/
@JsonProperty("createTime")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
private Date createTime;

/**
* 更新时间
*/
@JsonProperty("updateTime")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", timezone = "UTC")
private Date updateTime;

/**
* 是否删除(0-未删除,1-已删除)
*/
@JsonProperty("isDelete")
private Integer isDelete;

/**
* 对象转包装类
*/
public static PostEsDTO objToDto(Post post) {
if (post == null) {
return null;
}
PostEsDTO postEsDTO = new PostEsDTO();
BeanUtils.copyProperties(post, postEsDTO);
String tagsStr = post.getTags();
if (StringUtils.isNotBlank(tagsStr)) {
postEsDTO.setTags(JSONUtil.toList(tagsStr, String.class));
}
return postEsDTO;
}

/**
* 包装类转对象
*/
public static Post dtoToObj(PostEsDTO postEsDTO) {
if (postEsDTO == null) {
return null;
}
Post post = new Post();
BeanUtils.copyProperties(postEsDTO, post);
List<String> tagList = postEsDTO.getTags();
if (CollUtil.isNotEmpty(tagList)) {
post.setTags(JSONUtil.toJsonStr(tagList));
}
return post;
}
}

查询请求 PostQueryRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 查询请求
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class PostQueryRequest extends PageRequest implements Serializable {

/**
* id
*/
private Long id;

/**
* id
*/
private Long notId;

/**
* 搜索词
*/
private String searchText;

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 标签列表
*/
private List<String> tags;

/**
* 至少有一个标签
*/
private List<String> orTags;

/**
* 创建用户 id
*/
private Long userId;

/**
* 收藏用户 id
*/
private Long favourUserId;

@Serial
private static final long serialVersionUID = 1L;
}

更新请求 PostUpdateRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 更新请求
*/
@Data
public class PostUpdateRequest implements Serializable {

/**
* id
*/
private Long id;

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 标签列表
*/
private List<String> tags;

@Serial
private static final long serialVersionUID = 1L;
}

postfavour

帖子收藏添加请求 PostFavourAddRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 帖子收藏 / 取消收藏请求
*/
@Data
public class PostFavourAddRequest implements Serializable {

/**
* 帖子 id
*/
private Long postId;

@Serial
private static final long serialVersionUID = 1L;
}

帖子收藏查询请求 PostFavourQueryRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 帖子收藏查询请求
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class PostFavourQueryRequest extends PageRequest implements Serializable {

/**
* 帖子查询请求
*/
private PostQueryRequest postQueryRequest;

/**
* 用户 id
*/
private Long userId;

@Serial
private static final long serialVersionUID = 1L;
}

postthumb

帖子点赞添加请求 PostThumbAddRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 帖子点赞请求
*/
@Data
public class PostThumbAddRequest implements Serializable {

/**
* 帖子 id
*/
private Long postId;

@Serial
private static final long serialVersionUID = 1L;
}

user

用户创建请求 UserAddRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 用户创建请求
*/
@Data
public class UserAddRequest implements Serializable {

/**
* 用户昵称
*/
private String userName;

/**
* 账号
*/
private String userAccount;

/**
* 用户头像
*/
private String userAvatar;

/**
* 用户角色: user, admin
*/
private String userRole;

@Serial
private static final long serialVersionUID = 1L;
}

用户登录请求 UserLoginRequest

1
2
3
4
5
6
7
8
9
10
/**
* 用户登录请求
*/
@Data
public class UserLoginRequest implements Serializable {
@Serial
private static final long serialVersionUID = 3191241716373120793L;
private String userAccount;
private String userPassword;
}

用户查询请求 UserQueryRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 用户查询请求
*/
@EqualsAndHashCode(callSuper = true)
@Data
public class UserQueryRequest extends PageRequest implements Serializable {
/**
* id
*/
private Long id;

/**
* 开放平台id
*/
private String unionId;

/**
* 公众号openId
*/
private String mpOpenId;

/**
* 用户昵称
*/
private String userName;

/**
* 简介
*/
private String userProfile;

/**
* 用户角色:user/admin/ban
*/
private String userRole;

@Serial
private static final long serialVersionUID = 1L;
}

用户注册请求 UserRegisterRequest

1
2
3
4
5
6
7
8
9
10
11
/**
* 用户注册请求体
*/
@Data
public class UserRegisterRequest implements Serializable {
@Serial
private static final long serialVersionUID = 3191241716373120793L;
private String userAccount;
private String userPassword;
private String checkPassword;
}

用户更新个人信息请求 UserUpdateMyRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 用户更新个人信息请求
*/
@Data
public class UserUpdateMyRequest implements Serializable {

/**
* 用户昵称
*/
private String userName;

/**
* 用户头像
*/
private String userAvatar;

/**
* 简介
*/
private String userProfile;

@Serial
private static final long serialVersionUID = 1L;
}

用户更新请求 UserUpdateRequest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
* 用户更新请求
*/
@Data
public class UserUpdateRequest implements Serializable {
/**
* id
*/
private Long id;

/**
* 用户昵称
*/
private String userName;

/**
* 用户头像
*/
private String userAvatar;

/**
* 简介
*/
private String userProfile;

/**
* 用户角色:user/admin/ban
*/
private String userRole;

@Serial
private static final long serialVersionUID = 1L;
}

entity

帖子 Post

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/**
* 帖子
*/
@TableName(value = "post")
@Data
public class Post implements Serializable {

/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 标签列表 json
*/
private String tags;

/**
* 点赞数
*/
private Integer thumbNum;

/**
* 收藏数
*/
private Integer favourNum;

/**
* 创建用户 id
*/
private Long userId;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

/**
* 是否删除
*/
@TableLogic
private Integer isDelete;

@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

帖子收藏 PostFavour

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 帖子收藏
**/
@TableName(value = "post_favour")
@Data
public class PostFavour implements Serializable {

/**
* id
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 帖子 id
*/
private Long postId;

/**
* 创建用户 id
*/
private Long userId;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

帖子点赞 PostThumb

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 帖子点赞
*/
@TableName(value = "post_thumb")
@Data
public class PostThumb implements Serializable {

/**
* id
*/
@TableId(type = IdType.AUTO)
private Long id;

/**
* 帖子 id
*/
private Long postId;

/**
* 创建用户 id
*/
private Long userId;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

用户 User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
* 用户
*/
@TableName(value = "user")
@Data
public class User implements Serializable {

/**
* id
*/
@TableId(type = IdType.ASSIGN_ID)
private Long id;

/**
* 用户账号
*/
private String userAccount;

/**
* 用户密码
*/
private String userPassword;

/**
* 开放平台id
*/
private String unionId;

/**
* 公众号openId
*/
private String mpOpenId;

/**
* 用户昵称
*/
private String userName;

/**
* 用户头像
*/
private String userAvatar;

/**
* 用户简介
*/
private String userProfile;

/**
* 用户角色:user/admin/ban
*/
private String userRole;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

/**
* 是否删除
*/
@TableLogic
private Integer isDelete;

@Serial
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}

enums

文件上传业务类型枚举 FileUploadBizEnum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 文件上传业务类型枚举
*/
public enum FileUploadBizEnum {

USER_AVATAR("用户头像", "user_avatar");

private final String text;

private final String value;

FileUploadBizEnum(String text, String value) {
this.text = text;
this.value = value;
}

/**
* 获取值列表
*
* @return 值列表
*/
public static List<String> getValues() {
return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
}

/**
* 根据 value 获取枚举
*
* @param value
* @return 枚举
*/
public static FileUploadBizEnum getEnumByValue(String value) {
if (ObjectUtils.isEmpty(value)) {
return null;
}
for (FileUploadBizEnum anEnum : FileUploadBizEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}

public String getValue() {
return value;
}

public String getText() {
return text;
}
}

用户角色枚举 UserRoleEnum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* 用户角色枚举
*/
public enum UserRoleEnum {

USER("用户", "user"),
ADMIN("管理员", "admin"),
BAN("被封号", "ban");

private final String text;

private final String value;

UserRoleEnum(String text, String value) {
this.text = text;
this.value = value;
}

/**
* 获取值列表
*
* @return 值列表
*/
public static List<String> getValues() {
return Arrays.stream(values()).map(item -> item.value).collect(Collectors.toList());
}

/**
* 根据 value 获取枚举
*
* @param value
* @return 枚举
*/
public static UserRoleEnum getEnumByValue(String value) {
if (ObjectUtils.isEmpty(value)) {
return null;
}
for (UserRoleEnum anEnum : UserRoleEnum.values()) {
if (anEnum.value.equals(value)) {
return anEnum;
}
}
return null;
}

public String getValue() {
return value;
}

public String getText() {
return text;
}
}

vo

已登录用户视图 LoginUserVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 已登录用户视图(脱敏)
**/
@Data
public class LoginUserVO implements Serializable {

/**
* 用户 id
*/
private Long id;

/**
* 用户昵称
*/
private String userName;

/**
* 用户头像
*/
private String userAvatar;

/**
* 用户简介
*/
private String userProfile;

/**
* 用户角色:user/admin/ban
*/
private String userRole;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

@Serial
private static final long serialVersionUID = 1L;
}

帖子视图 PostVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
* 帖子视图
*/
@Data
public class PostVO implements Serializable {

/**
* id
*/
private Long id;

/**
* 标题
*/
private String title;

/**
* 内容
*/
private String content;

/**
* 点赞数
*/
private Integer thumbNum;

/**
* 收藏数
*/
private Integer favourNum;

/**
* 创建用户 id
*/
private Long userId;

/**
* 创建时间
*/
private Date createTime;

/**
* 更新时间
*/
private Date updateTime;

/**
* 标签列表
*/
private List<String> tagList;

/**
* 创建人信息
*/
private UserVO user;

/**
* 是否已点赞
*/
private Boolean hasThumb;

/**
* 是否已收藏
*/
private Boolean hasFavour;

/**
* 包装类转对象
*
* @param postVO
* @return Post
*/
public static Post voToObj(PostVO postVO) {
if (postVO == null) {
return null;
}
Post post = new Post();
BeanUtils.copyProperties(postVO, post);
List<String> tagList = postVO.getTagList();
post.setTags(JSONUtil.toJsonStr(tagList));
return post;
}

/**
* 对象转包装类
*
* @param post
* @return
*/
public static PostVO objToVo(Post post) {
if (post == null) {
return null;
}
PostVO postVO = new PostVO();
BeanUtils.copyProperties(post, postVO);
postVO.setTagList(JSONUtil.toList(post.getTags(), String.class));
return postVO;
}
}

用户视图 UserVO

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 用户视图(脱敏)
*/
@Data
public class UserVO implements Serializable {

/**
* id
*/
private Long id;

/**
* 用户昵称
*/
private String userName;

/**
* 用户头像
*/
private String userAvatar;

/**
* 用户简介
*/
private String userProfile;

/**
* 用户角色:user/admin/ban
*/
private String userRole;

/**
* 创建时间
*/
private Date createTime;

@Serial
private static final long serialVersionUID = 1L;
}

Model 层代码(DTO / Entity / Enum / VO)

模块 优点
分层明确 DTO(外部接口)、Entity(数据库)、VO(前端视图)分离,符合 Clean Architecture
命名规范 *Request / *VO / *DTO 后缀清晰表达用途
序列化安全 所有类实现 Serializable + serialVersionUID
枚举管理 业务类型(如文件上传、用户角色)用枚举封装,避免魔法字符串
ES 适配 PostEsDTO 使用 @JsonProperty + @JsonFormat 兼容 Elasticsearch 客户端

这是一套生产级可用的 Model 设计!

Entity(数据库实体)

优点:

  • 使用 MyBatis-Plus 注解:@TableName, @TableId(IdType.ASSIGN_ID), @TableLogic
  • 字段命名采用 下划线风格user_id, create_time),与数据库一致
  • 软删除字段 is_delete 配合

DTO / Request(接口传输对象)

优点:

  • PostQueryRequest extends PageRequest → 天然支持分页
  • orTags 字段支持“任一标签匹配”,满足复杂搜索
  • notId 支持“排除某帖子”(如推荐时排除已看)

问题:
UploadFileRequest.biz 未校验合法性

  • 应限制 biz 必须是 FileUploadBizEnum 中的值

改进

1
2
3
4
5
6
7
8
9
10
11
public class UploadFileRequest {
@NotBlank
private String biz;

// 在 Service 层校验
public void validate() {
if (FileUploadBizEnum.getEnumByValue(biz) == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "无效的业务类型");
}
}
}

VO(视图对象)

优点:

  • PostVO 包含关联信息(UserVO user)和状态(hasThumb, hasFavour
  • 提供 objToVo / voToObj 工具方法

问题:
PostVO.objToVo() 未处理 user 字段

  • BeanUtils.copyProperties 无法自动填充 user(需额外查询)
  • 当前 objToVo 仅转换 PostPostVO 基础字段,user 为 null

这是正常现象user 应由 Service 层组装:

1
2
3
PostVO vo = PostVO.objToVo(post);
UserVO userVO = userMapper.selectById(post.getUserId());
vo.setUser(userVO);

所以 objToVo 不需要处理 user,设计合理。

PostEsDTO:ES 同步核心

优点:

  • 使用 @JsonFormat(timezone = "UTC") 确保 ES 时间格式正确
  • 提供 objToDto / dtoToObj 双向转换
  • 显式处理 tags(String ↔ List

问题:
BeanUtils.copyProperties 在字段名不一致时失效(同 Entity 问题)

  • 如果 Post 字段是 thumb_num,而 PostEsDTOthumbNum,拷贝后为 null
    必须确保 Entity 字段为驼峰(见上文建议)

枚举设计

优点:

  • 提供 getEnumByValue()getValues(),方便校验和前端展示
  • 枚举值(value)用于存储/传输,文本(text)用于展示

建议增强:

  • 添加 @JsonValue 注解,使 Jackson 序列化时直接输出 value
1
2
3
4
5
6
7
8
9
10
11
12
public enum UserRoleEnum {
USER("用户", "user"),
ADMIN("管理员", "admin");

private final String text;
private final String value;

@JsonValue
public String getValue() {
return value;
}
}

这样 userRole 字段在 JSON 中直接是 "user",而非整个枚举对象。

一致性检查(跨模块)

检查项 状态 说明
Post.tags 存储格式 统一为 JSON 字符串("[\"Java\",\"Spring\"]"
软删除字段 Post.is_delete, User.is_delete 均用 @TableLogic
ID 生成策略 Post.id, User.idIdType.ASSIGN_ID(雪花算法)
时间字段 create_time, update_time 全局统一
角色值 UserRoleEnumUser.user_role 值一致

最终建议:立即行动项

  1. 【高优】PostUser 等 Entity 的字段名改为 驼峰命名thumbNum, userId 等)
  2. 【中优】PostAddRequestUserRegisterRequest 等添加 JSR-303 校验注解
  3. 【低优】 为枚举的 getValue() 添加 @JsonValue
  4. 【文档】PostEsDTO 注释中说明:要求 Entity 字段为驼峰

service

帖子收藏服务 PostFavourService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 帖子收藏服务
*/
public interface PostFavourService extends IService<PostFavour> {

/**
* 帖子收藏
*
* @param postId
* @param loginUser
* @return
*/
int doPostFavour(long postId, User loginUser);

/**
* 分页获取用户收藏的帖子列表
*
* @param page
* @param queryWrapper
* @param favourUserId
* @return
*/
Page<Post> listFavourPostByPage(IPage<Post> page, Wrapper<Post> queryWrapper, long favourUserId);

/**
* 帖子收藏(内部服务)
*
* @param userId
* @param postId
* @return
*/
int doPostFavourInner(long userId, long postId);
}

帖子服务 PostService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 帖子服务
*/
public interface PostService extends IService<Post> {

/**
* 校验
*
* @param post
* @param add
*/
void validPost(Post post, boolean add);

/**
* 获取查询条件
*
* @param postQueryRequest
* @return
*/
QueryWrapper<Post> getQueryWrapper(PostQueryRequest postQueryRequest);

/**
* 从 ES 查询
*
* @param postQueryRequest
* @return
*/
Page<Post> searchFromEs(PostQueryRequest postQueryRequest);

/**
* 获取帖子封装
*
* @param post
* @param request
* @return
*/
PostVO getPostVO(Post post, HttpServletRequest request);

/**
* 分页获取帖子封装
*
* @param postPage
* @param request
* @return
*/
Page<PostVO> getPostVOPage(Page<Post> postPage, HttpServletRequest request);
}

帖子点赞服务 PostThumbService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 帖子点赞服务
*/
public interface PostThumbService extends IService<PostThumb> {

/**
* 点赞
*
* @param postId
* @param loginUser
* @return
*/
int doPostThumb(long postId, User loginUser);

/**
* 帖子点赞(内部服务)
*
* @param userId
* @param postId
* @return
*/
int doPostThumbInner(long userId, long postId);
}

用户服务 UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
/**
* 用户服务
*/
public interface UserService extends IService<User> {

/**
* 用户注册
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param checkPassword 校验密码
* @return 新用户 id
*/
long userRegister(String userAccount, String userPassword, String checkPassword);

/**
* 用户登录
*
* @param userAccount 用户账户
* @param userPassword 用户密码
* @param request
* @return 脱敏后的用户信息
*/
LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request);

/**
* 用户登录(微信开放平台)
*
* @param wxOAuth2UserInfo 从微信获取的用户信息
* @param request
* @return 脱敏后的用户信息
*/
LoginUserVO userLoginByMpOpen(WxOAuth2UserInfo wxOAuth2UserInfo, HttpServletRequest request);

/**
* 获取当前登录用户
*
* @param request
* @return
*/
User getLoginUser(HttpServletRequest request);

/**
* 获取当前登录用户(允许未登录)
*
* @param request
* @return
*/
User getLoginUserPermitNull(HttpServletRequest request);

/**
* 是否为管理员
*
* @param request
* @return
*/
boolean isAdmin(HttpServletRequest request);

/**
* 是否为管理员
*
* @param user
* @return
*/
boolean isAdmin(User user);

/**
* 用户注销
*
* @param request
* @return
*/
boolean userLogout(HttpServletRequest request);

/**
* 获取脱敏的已登录用户信息
*
* @return
*/
LoginUserVO getLoginUserVO(User user);

/**
* 获取脱敏的用户信息
*
* @param user
* @return
*/
UserVO getUserVO(User user);

/**
* 获取脱敏的用户信息
*
* @param userList
* @return
*/
List<UserVO> getUserVO(List<User> userList);

/**
* 获取查询条件
*
* @param userQueryRequest
* @return
*/
QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest);

}

impl

帖子收藏服务实现 PostFavourServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
/**
* 帖子收藏服务实现
*/
@Service
public class PostFavourServiceImpl extends ServiceImpl<PostFavourMapper, PostFavour>
implements PostFavourService {

@Resource
private PostService postService;

/**
* 帖子收藏
*
* @param postId
* @param loginUser
* @return
*/
@Override
public int doPostFavour(long postId, User loginUser) {
// 判断是否存在
Post post = postService.getById(postId);
if (post == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 是否已帖子收藏
long userId = loginUser.getId();
// 每个用户串行帖子收藏
// 锁必须要包裹住事务方法
PostFavourService postFavourService = (PostFavourService) AopContext.currentProxy();
synchronized (String.valueOf(userId).intern()) {
return postFavourService.doPostFavourInner(userId, postId);
}
}

@Override
public Page<Post> listFavourPostByPage(IPage<Post> page, Wrapper<Post> queryWrapper, long favourUserId) {
if (favourUserId <= 0) {
return new Page<>();
}
return baseMapper.listFavourPostByPage(page, queryWrapper, favourUserId);
}

/**
* 封装了事务的方法
*
* @param userId
* @param postId
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int doPostFavourInner(long userId, long postId) {
PostFavour postFavour = new PostFavour();
postFavour.setUser_id(userId);
postFavour.setPost_id(postId);
QueryWrapper<PostFavour> postFavourQueryWrapper = new QueryWrapper<>(postFavour);
PostFavour oldPostFavour = this.getOne(postFavourQueryWrapper);
boolean result;
// 已收藏
if (oldPostFavour != null) {
result = this.remove(postFavourQueryWrapper);
if (result) {
// 帖子收藏数 - 1
result = postService.update()
.eq("id", postId)
.gt("favourNum", 0)
.setSql("favourNum = favourNum - 1")
.update();
return result ? -1 : 0;
} else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
} else {
// 未帖子收藏
result = this.save(postFavour);
if (result) {
// 帖子收藏数 + 1
result = postService.update()
.eq("id", postId)
.setSql("favourNum = favourNum + 1")
.update();
return result ? 1 : 0;
} else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
}
}
}

帖子服务实现 PostServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
/**
* 帖子服务实现(适配 Elasticsearch Java API Client)
*/
@Service
@Slf4j
public class PostServiceImpl extends ServiceImpl<PostMapper, Post> implements PostService {

@Resource
private UserService userService;

@Resource
private PostThumbMapper postThumbMapper;

@Resource
private PostFavourMapper postFavourMapper;

// 使用新版 Elasticsearch Java API Client
@Resource
private ElasticsearchClient elasticsearchClient;

@Override
public void validPost(Post post, boolean add) {
if (post == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
String title = post.getTitle();
String content = post.getContent();
String tags = post.getTags();
if (add) {
ThrowUtils.throwIf(StringUtils.isAnyBlank(title, content, tags), ErrorCode.PARAMS_ERROR);
}
if (StringUtils.isNotBlank(title) && title.length() > 80) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "标题过长");
}
if (StringUtils.isNotBlank(content) && content.length() > 8192) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "内容过长");
}
}

@Override
public QueryWrapper<Post> getQueryWrapper(PostQueryRequest postQueryRequest) {
QueryWrapper<Post> queryWrapper = new QueryWrapper<>();
if (postQueryRequest == null) {
return queryWrapper;
}
String searchText = postQueryRequest.getSearchText();
String sortField = postQueryRequest.getSortField();
String sortOrder = postQueryRequest.getSortOrder();
Long id = postQueryRequest.getId();
String title = postQueryRequest.getTitle();
String content = postQueryRequest.getContent();
List<String> tagList = postQueryRequest.getTags();
Long userId = postQueryRequest.getUserId();
Long notId = postQueryRequest.getNotId();

if (StringUtils.isNotBlank(searchText)) {
queryWrapper.and(qw -> qw.like("title", searchText).or().like("content", searchText));
}
queryWrapper.like(StringUtils.isNotBlank(title), "title", title);
queryWrapper.like(StringUtils.isNotBlank(content), "content", content);
if (CollUtil.isNotEmpty(tagList)) {
for (String tag : tagList) {
queryWrapper.like("tags", "\"" + tag + "\"");
}
}
queryWrapper.ne(ObjectUtils.isNotEmpty(notId), "id", notId);
queryWrapper.eq(ObjectUtils.isNotEmpty(id), "id", id);
queryWrapper.eq(ObjectUtils.isNotEmpty(userId), "userId", userId);
queryWrapper.orderBy(SqlUtils.validSortField(sortField), sortOrder.equals(CommonConstant.SORT_ORDER_ASC),
sortField);
return queryWrapper;
}

@Override
public Page<Post> searchFromEs(PostQueryRequest postQueryRequest) {
Long id = postQueryRequest.getId();
Long notId = postQueryRequest.getNotId();
String searchText = postQueryRequest.getSearchText();
String title = postQueryRequest.getTitle();
String content = postQueryRequest.getContent();
List<String> tagList = postQueryRequest.getTags();
List<String> orTagList = postQueryRequest.getOrTags();
Long userId = postQueryRequest.getUserId();
long current = postQueryRequest.getCurrent() - 1;
long pageSize = postQueryRequest.getPageSize();
String sortField = postQueryRequest.getSortField();
String sortOrder = postQueryRequest.getSortOrder();

BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder();

// isDelete = 0
boolQueryBuilder.filter(TermQuery.of(t -> t.field("isDelete").value(0))._toQuery());

if (id != null) {
boolQueryBuilder.filter(TermQuery.of(t -> t.field("id").value(id))._toQuery());
}
if (notId != null) {
boolQueryBuilder.mustNot(TermQuery.of(t -> t.field("id").value(notId))._toQuery());
}
if (userId != null) {
boolQueryBuilder.filter(TermQuery.of(t -> t.field("userId").value(userId))._toQuery());
}

// 必须包含所有标签(AND)
if (CollUtil.isNotEmpty(tagList)) {
for (String tag : tagList) {
boolQueryBuilder.filter(TermQuery.of(t -> t.field("tags").value(tag))._toQuery());
}
}

// 包含任一标签(OR)
if (CollUtil.isNotEmpty(orTagList)) {
BoolQuery.Builder orTagBuilder = new BoolQuery.Builder();
for (String tag : orTagList) {
orTagBuilder.should(TermQuery.of(t -> t.field("tags").value(tag))._toQuery());
}
orTagBuilder.minimumShouldMatch("1");
boolQueryBuilder.filter(orTagBuilder.build()._toQuery());
}

// 关键词全文搜索(title, description, content)
if (StringUtils.isNotBlank(searchText)) {
BoolQuery.Builder shouldBuilder = new BoolQuery.Builder();
shouldBuilder.should(MatchQuery.of(m -> m.field("title").query(searchText))._toQuery());
shouldBuilder.should(MatchQuery.of(m -> m.field("description").query(searchText))._toQuery());
shouldBuilder.should(MatchQuery.of(m -> m.field("content").query(searchText))._toQuery());
shouldBuilder.minimumShouldMatch("1");
boolQueryBuilder.should(shouldBuilder.build()._toQuery());
}

// 单独按 title 搜索
if (StringUtils.isNotBlank(title)) {
boolQueryBuilder.should(MatchQuery.of(m -> m.field("title").query(title))._toQuery());
}

// 单独按 content 搜索
if (StringUtils.isNotBlank(content)) {
boolQueryBuilder.should(MatchQuery.of(m -> m.field("content").query(content))._toQuery());
}

// 设置 minimum_should_match 仅在有 should 时生效
if (StringUtils.isNotBlank(searchText) || StringUtils.isNotBlank(title) || StringUtils.isNotBlank(content)) {
boolQueryBuilder.minimumShouldMatch("1");
}

// 构建排序
co.elastic.clients.elasticsearch._types.SortOptions sortOption;
if (StringUtils.isNotBlank(sortField)) {
sortOption = co.elastic.clients.elasticsearch._types.SortOptions.of(s ->
s.field(f -> f.field(sortField)
.order(CommonConstant.SORT_ORDER_ASC.equals(sortOrder) ? SortOrder.Asc : SortOrder.Desc))
);
} else {
// 默认按 _score 降序(相关性)
sortOption = co.elastic.clients.elasticsearch._types.SortOptions.of(s -> s.score(ScoreSort.of(sc -> sc.order(SortOrder.Desc))));
}

// 分页
int from = (int) (current * pageSize);
int size = (int) pageSize;

SearchRequest searchRequest = SearchRequest.of(s -> s
.index("post") // 请确保索引名正确,建议从配置注入
.query(boolQueryBuilder.build()._toQuery())
.from(from)
.size(size)
.sort(sortOption)
);

SearchResponse<PostEsDTO> response;
try {
response = elasticsearchClient.search(searchRequest, PostEsDTO.class);
} catch (IOException e) {
log.error("Elasticsearch 查询失败", e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "搜索服务异常");
}

Page<Post> page = new Page<>();
long total = response.hits().total().value();
page.setTotal(total);

List<Post> resourceList = new ArrayList<>();
if (!response.hits().hits().isEmpty()) {
List<Long> postIdList = response.hits().hits().stream()
.map(Hit::source)
.map(PostEsDTO::getId)
.collect(Collectors.toList());

// 从数据库获取最新数据(如点赞数)
List<Post> postList = baseMapper.selectBatchIds(postIdList);
if (postList != null) {
Map<Long, Post> idPostMap = postList.stream()
.collect(Collectors.toMap(Post::getId, p -> p));

for (Long postId : postIdList) {
Post post = idPostMap.get(postId);
if (post != null) {
resourceList.add(post);
} else {
// ES 中存在但 DB 已删除,清理 ES 数据
try {
elasticsearchClient.delete(d -> d.index("post").id(String.valueOf(postId)));
log.info("清理已删除帖子 ES 数据: {}", postId);
} catch (IOException ex) {
log.warn("清理 ES 数据失败: {}", postId, ex);
}
}
}
}
}

page.setRecords(resourceList);
page.setCurrent(postQueryRequest.getCurrent());
page.setSize(pageSize);
return page;
}

// ====== 以下方法保持不变(VO 转换等) ======

@Override
public PostVO getPostVO(Post post, HttpServletRequest request) {
PostVO postVO = PostVO.objToVo(post);
long postId = post.getId();
Long userId = post.getUser_id();
User user = null;
if (userId != null && userId > 0) {
user = userService.getById(userId);
}
UserVO userVO = userService.getUserVO(user);
postVO.setUser(userVO);

User loginUser = userService.getLoginUserPermitNull(request);
if (loginUser != null) {
QueryWrapper<PostThumb> thumbQw = new QueryWrapper<>();
thumbQw.eq("postId", postId).eq("userId", loginUser.getId());
PostThumb thumb = postThumbMapper.selectOne(thumbQw);
postVO.setHasThumb(thumb != null);

QueryWrapper<PostFavour> favQw = new QueryWrapper<>();
favQw.eq("postId", postId).eq("userId", loginUser.getId());
PostFavour favour = postFavourMapper.selectOne(favQw);
postVO.setHasFavour(favour != null);
}
return postVO;
}

@Override
public Page<PostVO> getPostVOPage(Page<Post> postPage, HttpServletRequest request) {
List<Post> postList = postPage.getRecords();
Page<PostVO> postVOPage = new Page<>(postPage.getCurrent(), postPage.getSize(), postPage.getTotal());
if (CollUtil.isEmpty(postList)) {
return postVOPage;
}

Set<Long> userIdSet = postList.stream().map(Post::getUser_id).collect(Collectors.toSet());
Map<Long, User> userMap = userService.listByIds(userIdSet).stream()
.collect(Collectors.toMap(User::getId, u -> u));

Map<Long, Boolean> postIdHasThumbMap = new HashMap<>();
Map<Long, Boolean> postIdHasFavourMap = new HashMap<>();

User loginUser = userService.getLoginUserPermitNull(request);
if (loginUser != null) {
Set<Long> postIdSet = postList.stream().map(Post::getId).collect(Collectors.toSet());
loginUser = userService.getLoginUser(request);

QueryWrapper<PostThumb> thumbQw = new QueryWrapper<>();
thumbQw.in("postId", postIdSet).eq("userId", loginUser.getId());
List<PostThumb> thumbs = postThumbMapper.selectList(thumbQw);
thumbs.forEach(t -> postIdHasThumbMap.put(t.getPost_id(), true));

QueryWrapper<PostFavour> favQw = new QueryWrapper<>();
favQw.in("postId", postIdSet).eq("userId", loginUser.getId());
List<PostFavour> favs = postFavourMapper.selectList(favQw);
favs.forEach(f -> postIdHasFavourMap.put(f.getPost_id(), true));
}

List<PostVO> postVOList = postList.stream().map(post -> {
PostVO vo = PostVO.objToVo(post);
vo.setUser(userService.getUserVO(userMap.get(post.getUser_id())));
vo.setHasThumb(postIdHasThumbMap.getOrDefault(post.getId(), false));
vo.setHasFavour(postIdHasFavourMap.getOrDefault(post.getId(), false));
return vo;
}).collect(Collectors.toList());

postVOPage.setRecords(postVOList);
return postVOPage;
}
}

帖子点赞服务实现 PostThumbServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/**
* 帖子点赞服务实现
*/
@Service
public class PostThumbServiceImpl extends ServiceImpl<PostThumbMapper, PostThumb>
implements PostThumbService {

@Resource
private PostService postService;

/**
* 点赞
*
* @param postId
* @param loginUser
* @return
*/
@Override
public int doPostThumb(long postId, User loginUser) {
// 判断实体是否存在,根据类别获取实体
Post post = postService.getById(postId);
if (post == null) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR);
}
// 是否已点赞
long userId = loginUser.getId();
// 每个用户串行点赞
// 锁必须要包裹住事务方法
PostThumbService postThumbService = (PostThumbService) AopContext.currentProxy();
synchronized (String.valueOf(userId).intern()) {
return postThumbService.doPostThumbInner(userId, postId);
}
}

/**
* 封装了事务的方法
*
* @param userId
* @param postId
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int doPostThumbInner(long userId, long postId) {
PostThumb postThumb = new PostThumb();
postThumb.setUser_id(userId);
postThumb.setPost_id(postId);
QueryWrapper<PostThumb> thumbQueryWrapper = new QueryWrapper<>(postThumb);
PostThumb oldPostThumb = this.getOne(thumbQueryWrapper);
boolean result;
// 已点赞
if (oldPostThumb != null) {
result = this.remove(thumbQueryWrapper);
if (result) {
// 点赞数 - 1
result = postService.update()
.eq("id", postId)
.gt("thumbNum", 0)
.setSql("thumbNum = thumbNum - 1")
.update();
return result ? -1 : 0;
} else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
} else {
// 未点赞
result = this.save(postThumb);
if (result) {
// 点赞数 + 1
result = postService.update()
.eq("id", postId)
.setSql("thumbNum = thumbNum + 1")
.update();
return result ? 1 : 0;
} else {
throw new BusinessException(ErrorCode.SYSTEM_ERROR);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
/**
* 用户服务实现
*/
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {

/**
* 盐值,混淆密码
*/
public static final String SALT = "yupi";

@Override
public long userRegister(String userAccount, String userPassword, String checkPassword) {
// 1. 校验
if (StringUtils.isAnyBlank(userAccount, userPassword, checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户账号过短");
}
if (userPassword.length() < 8 || checkPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户密码过短");
}
// 密码和校验密码相同
if (!userPassword.equals(checkPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "两次输入的密码不一致");
}
synchronized (userAccount.intern()) {
// 账户不能重复
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
long count = this.baseMapper.selectCount(queryWrapper);
if (count > 0) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号重复");
}
// 2. 加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
// 3. 插入数据
User user = new User();
user.setUser_account(userAccount);
user.setUser_password(encryptPassword);
boolean saveResult = this.save(user);
if (!saveResult) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "注册失败,数据库错误");
}
return user.getId();
}
}

@Override
public LoginUserVO userLogin(String userAccount, String userPassword, HttpServletRequest request) {
// 1. 校验
if (StringUtils.isAnyBlank(userAccount, userPassword)) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
}
if (userAccount.length() < 4) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
}
if (userPassword.length() < 8) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
}
// 2. 加密
String encryptPassword = DigestUtils.md5DigestAsHex((SALT + userPassword).getBytes());
// 查询用户是否存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("userAccount", userAccount);
queryWrapper.eq("userPassword", encryptPassword);
User user = this.baseMapper.selectOne(queryWrapper);
// 用户不存在
if (user == null) {
log.info("user login failed, userAccount cannot match userPassword");
throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
}
// 3. 记录用户的登录态
request.getSession().setAttribute(USER_LOGIN_STATE, user);
return this.getLoginUserVO(user);
}

@Override
public LoginUserVO userLoginByMpOpen(WxOAuth2UserInfo wxOAuth2UserInfo, HttpServletRequest request) {
String unionId = wxOAuth2UserInfo.getUnionId();
String mpOpenId = wxOAuth2UserInfo.getOpenid();
// 单机锁
synchronized (unionId.intern()) {
// 查询用户是否已存在
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("unionId", unionId);
User user = this.getOne(queryWrapper);
// 被封号,禁止登录
if (user != null && UserRoleEnum.BAN.getValue().equals(user.getUser_role())) {
throw new BusinessException(ErrorCode.FORBIDDEN_ERROR, "该用户已被封,禁止登录");
}
// 用户不存在则创建
if (user == null) {
user = new User();
user.setUnion_id(unionId);
user.setMpOpen_id(mpOpenId);
user.setUser_avatar(wxOAuth2UserInfo.getHeadImgUrl());
user.setUser_name(wxOAuth2UserInfo.getNickname());
boolean result = this.save(user);
if (!result) {
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "登录失败");
}
}
// 记录用户的登录态
request.getSession().setAttribute(USER_LOGIN_STATE, user);
return getLoginUserVO(user);
}
}

/**
* 获取当前登录用户
*
* @param request
* @return
*/
@Override
public User getLoginUser(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
// 从数据库查询(追求性能的话可以注释,直接走缓存)
long userId = currentUser.getId();
currentUser = this.getById(userId);
if (currentUser == null) {
throw new BusinessException(ErrorCode.NOT_LOGIN_ERROR);
}
return currentUser;
}

/**
* 获取当前登录用户(允许未登录)
*
* @param request
* @return
*/
@Override
public User getLoginUserPermitNull(HttpServletRequest request) {
// 先判断是否已登录
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User currentUser = (User) userObj;
if (currentUser == null || currentUser.getId() == null) {
return null;
}
// 从数据库查询(追求性能的话可以注释,直接走缓存)
long userId = currentUser.getId();
return this.getById(userId);
}

/**
* 是否为管理员
*
* @param request
* @return
*/
@Override
public boolean isAdmin(HttpServletRequest request) {
// 仅管理员可查询
Object userObj = request.getSession().getAttribute(USER_LOGIN_STATE);
User user = (User) userObj;
return isAdmin(user);
}

@Override
public boolean isAdmin(User user) {
return user != null && UserRoleEnum.ADMIN.getValue().equals(user.getUser_role());
}

/**
* 用户注销
*
* @param request
*/
@Override
public boolean userLogout(HttpServletRequest request) {
if (request.getSession().getAttribute(USER_LOGIN_STATE) == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录");
}
// 移除登录态
request.getSession().removeAttribute(USER_LOGIN_STATE);
return true;
}

@Override
public LoginUserVO getLoginUserVO(User user) {
if (user == null) {
return null;
}
LoginUserVO loginUserVO = new LoginUserVO();
BeanUtils.copyProperties(user, loginUserVO);
return loginUserVO;
}

@Override
public UserVO getUserVO(User user) {
if (user == null) {
return null;
}
UserVO userVO = new UserVO();
BeanUtils.copyProperties(user, userVO);
return userVO;
}

@Override
public List<UserVO> getUserVO(List<User> userList) {
if (CollUtil.isEmpty(userList)) {
return new ArrayList<>();
}
return userList.stream().map(this::getUserVO).collect(Collectors.toList());
}

@Override
public QueryWrapper<User> getQueryWrapper(UserQueryRequest userQueryRequest) {
if (userQueryRequest == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求参数为空");
}
Long id = userQueryRequest.getId();
String unionId = userQueryRequest.getUnionId();
String mpOpenId = userQueryRequest.getMpOpenId();
String userName = userQueryRequest.getUserName();
String userProfile = userQueryRequest.getUserProfile();
String userRole = userQueryRequest.getUserRole();
String sortField = userQueryRequest.getSortField();
String sortOrder = userQueryRequest.getSortOrder();
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq(id != null, "id", id);
queryWrapper.eq(StringUtils.isNotBlank(unionId), "unionId", unionId);
queryWrapper.eq(StringUtils.isNotBlank(mpOpenId), "mpOpenId", mpOpenId);
queryWrapper.eq(StringUtils.isNotBlank(userRole), "userRole", userRole);
queryWrapper.like(StringUtils.isNotBlank(userProfile), "userProfile", userProfile);
queryWrapper.like(StringUtils.isNotBlank(userName), "userName", userName);
queryWrapper.orderBy(SqlUtils.validSortField(sortField), sortOrder.equals(CommonConstant.SORT_ORDER_ASC),
sortField);
return queryWrapper;
}
}

工具类 utils

网络工具类 NetUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* 网络工具类
*/
public class NetUtils {

/**
* 获取客户端 IP 地址
*
* @param request
* @return
*/
public static String getIpAddress(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
if (ip.equals("127.0.0.1")) {
// 根据网卡取本机配置的 IP
InetAddress inet = null;
try {
inet = InetAddress.getLocalHost();
} catch (Exception e) {
e.printStackTrace();
}
if (inet != null) {
ip = inet.getHostAddress();
}
}
}
// 多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
if (ip != null && ip.length() > 15) {
if (ip.indexOf(",") > 0) {
ip = ip.substring(0, ip.indexOf(","));
}
}
if (ip == null) {
return "127.0.0.1";
}
return ip;
}
}

NetUtils.getIpAddress():获取客户端 IP

功能目标

从 HTTP 请求中提取真实客户端 IP(考虑代理、Nginx、负载均衡等)。

存在的问题

  1. 无法正确处理 IPv6 地址
  • 判断 ip.length() > 15 是基于 IPv4 最大长度(如 192.168.100.100 共 15 字符)
  • IPv6 地址(如 2001:db8::1)远超 15 字符,会被错误截断或忽略
  1. “unknown” 判断不全面
  • 某些代理返回 "UNKNOWN"(全大写)或带空格(如 " unknown "),当前逻辑无法识别
  1. 本机 IP 获取有风险
  • InetAddress.getLocalHost() 在容器化环境(Docker/K8s)中可能返回 127.0.0.1 或内网 IP,不代表真实出口 IP
  1. 未过滤私有/保留 IP
  • 可能返回 10.x.x.x192.168.x.x 等内网 IP,若用于风控或日志追踪,会失真

优化建议(生产级)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
public class NetUtils {

private static final String UNKNOWN = "unknown";
private static final List<String> LOCAL_IPS = Arrays.asList("127.0.0.1", "0:0:0:0:0:0:0:1");

public static String getIpAddress(HttpServletRequest request) {
if (request == null) {
return "127.0.0.1";
}

String ip = request.getHeader("x-forwarded-for");
if (isInvalidIp(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (isInvalidIp(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (isInvalidIp(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (isInvalidIp(ip)) {
ip = request.getRemoteAddr();
}

// 处理多 IP(取第一个非 unknown)
if (StringUtils.isNotBlank(ip) && ip.contains(",")) {
for (String candidate : ip.split(",")) {
candidate = candidate.trim();
if (!isInvalidIp(candidate)) {
ip = candidate;
break;
}
}
}

// 过滤无效或本地回环
if (isInvalidIp(ip) || LOCAL_IPS.contains(ip)) {
return "127.0.0.1";
}

return ip;
}

private static boolean isInvalidIp(String ip) {
return StringUtils.isBlank(ip)
|| UNKNOWN.equalsIgnoreCase(ip.trim())
|| "localhost".equalsIgnoreCase(ip.trim());
}
}

改进点:

  • 支持 IPv6(不再依赖长度判断)
  • 增加 X-Real-IP(Nginx 常用)
  • 更健壮的 unknown 判断
  • 避免在容器中强行获取本机 IP(通常无意义)

Spring 上下文获取工具 SpringContextUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/**
* Spring 上下文获取工具
*/
@Component
public class SpringContextUtils implements ApplicationContextAware {

private static ApplicationContext applicationContext;

@Override
public void setApplicationContext(@NotNull ApplicationContext applicationContext) throws BeansException {
SpringContextUtils.applicationContext = applicationContext;
}

/**
* 通过名称获取 Bean
*
* @param beanName
* @return
*/
public static Object getBean(String beanName) {
return applicationContext.getBean(beanName);
}

/**
* 通过 class 获取 Bean
*
* @param beanClass
* @param <T>
* @return
*/
public static <T> T getBean(Class<T> beanClass) {
return applicationContext.getBean(beanClass);
}

/**
* 通过名称和类型获取 Bean
*
* @param beanName
* @param beanClass
* @param <T>
* @return
*/
public static <T> T getBean(String beanName, Class<T> beanClass) {
return applicationContext.getBean(beanName, beanClass);
}
}

SpringContextUtils:Spring 上下文工具
静态方式获取 Spring Bean(常用于工具类、监听器等无法注入的场景)。

存在的问题

  1. 线程安全问题(低风险但存在)
  • applicationContextstatic,但在 setApplicationContext 中赋值,初始化顺序依赖 Spring 启动流程
  • 若在 Spring 初始化完成前调用 getBean(),会 NPE
  1. 违反依赖注入原则
  • 过度使用此类会导致 代码难以测试、耦合度高
  • 应优先通过构造函数或 @Autowired 注入
  1. 缺少空指针保护
  • applicationContext 为 null 时直接调用 .getBean() 会抛 NPE,无友好提示

优化建议

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class SpringContextUtils implements ApplicationContextAware {

private static ApplicationContext applicationContext;

@Override
public void setApplicationContext(@NotNull ApplicationContext applicationContext) {
SpringContextUtils.applicationContext = applicationContext;
}

public static <T> T getBean(Class<T> requiredType) {
if (applicationContext == null) {
throw new IllegalStateException("SpringContextUtils 尚未初始化,请确保在 Spring 容器启动后使用");
}
return applicationContext.getBean(requiredType);
}

public static Object getBean(String name) {
if (applicationContext == null) {
throw new IllegalStateException("SpringContextUtils 尚未初始化...");
}
return applicationContext.getBean(name);
}

// 可选:提供是否已初始化的判断
public static boolean isInitialized() {
return applicationContext != null;
}
}

改进点:

  • 增加 null 检查 + 明确异常信息
  • 保留必要性,但仅限于无法注入的场景(如 @Async 工具类、定时任务等)
    最佳实践:尽量避免使用!优先用 @Service + @Autowired

SQL 工具类 SqlUtils

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* SQL 工具
*/
public class SqlUtils {

/**
* 校验排序字段是否合法(防止 SQL 注入)
*
* @param sortField
* @return
*/
public static boolean validSortField(String sortField) {
if (StringUtils.isBlank(sortField)) {
return false;
}
return !StringUtils.containsAny(sortField, "=", "(", ")", " ");
}
}

SqlUtils.validSortField():SQL 排序字段校验
防止前端传入恶意 ORDER BY 字段导致 SQL 注入。

严重问题

  1. 校验逻辑过于宽松,仍可被绕过
  • 仅检查是否包含 =, (, ), 空格 —— 但排序字段本身不应包含这些字符
  • 真正安全的做法是:白名单校验

    举例:攻击者传入 id; DROP TABLE post--,虽然含空格会被拦截,但如果传 id/**/DESC(注释绕过)或利用数据库特性,仍可能注入。

  1. 未限制字段名合法性
  • 允许任意字符串(如 1+1sleep(10)),只要不含那几个字符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class SqlUtils {

// 定义允许排序的字段白名单(与数据库列名一致)
private static final Set<String> ALLOWED_SORT_FIELDS = Set.of(
"id", "create_time", "update_time", "thumb_num", "favour_num"
);

/**
* 校验排序字段是否合法(白名单)
*/
public static boolean validSortField(String sortField) {
if (StringUtils.isBlank(sortField)) {
return false;
}
// 移除可能的排序方向(如 "id ASC" → "id")
String field = sortField.split("\\s+")[0];
return ALLOWED_SORT_FIELDS.contains(field);
}

/**
* 安全获取排序字段(自动过滤非法值)
*/
public static String safeSortField(String sortField, String defaultField) {
if (validSortField(sortField)) {
return sortField;
}
return defaultField;
}
}

✅ 改进点:

  • 只允许预定义字段排序
  • 自动剥离 ASC/DESC(避免 id ASC; DROP...
  • 提供安全兜底方法
    更佳方案:使用 MyBatis-Plus 的 LambdaQueryWrapper.orderByDesc(Post::getCreateTime)完全避免字符串拼接

最终推荐

  1. 立即修复 SqlUtils —— 这是安全红线!
  2. 升级 NetUtils —— 避免在 IPv6 或容器环境下出错
  3. 限制 SpringContextUtils 使用范围 —— 仅用于万不得已的场景

如果使用 MyBatis-Plus,强烈建议在 Service 层用 Lambda 表达式构建查询,彻底规避 SQL 注入:

1
2
3
queryWrapper
.like(StringUtils.isNotBlank(title), Post::getTitle, title)
.orderByDesc(Post::getCreateTime);

这样连 SqlUtils 都不需要了!

微信模块 wxmp

公众号常量 WxMpConstant

1
2
3
4
5
6
7
8
9
/**
* 微信公众号相关常量
**/
public class WxMpConstant {
/**
* 点击菜单 key
*/
public static final String CLICK_MENU_KEY = "CLICK_MENU_KEY";
}

问题:

  • 目前只定义了一个菜单 key,但实际项目中会有多个菜单项(如 MENU_ABOUT, MENU_HELP 等)
  • 缺少对 事件类型、消息类型 的常量封装(虽然 WxJava 已提供 EventType,但自定义 key 仍需管理)

建议:

1
2
3
4
5
6
7
8
9
public class WxMpConstant {
// 菜单事件 Key
public static final String MENU_KEY_ABOUT = "MENU_ABOUT";
public static final String MENU_KEY_CONTACT = "MENU_CONTACT";
public static final String MENU_KEY_BIND = "MENU_BIND";

// 自定义事件标识(可选)
public static final String EVENT_SUBSCRIBE_SCENE_QR = "qrscene_"; // 带参数二维码关注
}

后续新增菜单时,只需在此扩展,避免硬编码。

微信公众号路由 WxMpMsgRouter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 微信公众号路由
*/
@Configuration
public class WxMpMsgRouter {

@Resource
private WxMpService wxMpService;

@Resource
private EventHandler eventHandler;

@Resource
private MessageHandler messageHandler;

@Resource
private SubscribeHandler subscribeHandler;

@Bean
public WxMpMessageRouter getWxMsgRouter() {
WxMpMessageRouter router = new WxMpMessageRouter(wxMpService);
// 消息
router.rule()
.async(false)
.msgType(XmlMsgType.TEXT)
.handler(messageHandler)
.end();
// 关注
router.rule()
.async(false)
.msgType(XmlMsgType.EVENT)
.event(EventType.SUBSCRIBE)
.handler(subscribeHandler)
.end();
// 点击按钮
router.rule()
.async(false)
.msgType(XmlMsgType.EVENT)
.event(EventType.CLICK)
.eventKey(WxMpConstant.CLICK_MENU_KEY)
.handler(eventHandler)
.end();
return router;
}
}

问题:

  1. 未处理默认兜底逻辑
  • 如果用户发送图片、语音、位置等未注册的消息类型,公众号无响应,体验差
  1. 未启用异步(async=false)可能阻塞主线程
  • 虽然简单场景影响不大,但若 Handler 中调用外部 API(如发短信、查数据库),会拖慢微信服务器回调
  1. 缺少日志 & 异常捕获
  • Handler 抛异常会导致整个路由中断,微信端收不到回复(可能触发重试)

优化建议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@Bean
public WxMpMessageRouter getWxMsgRouter() {
WxMpMessageRouter router = new WxMpMessageRouter(wxMpService);

// 文本消息
router.rule()
.async(true) // 启用异步(注意:异步下不能直接返回消息,需主动推送)
.msgType(XmlMsgType.TEXT)
.handler(messageHandler)
.end();

// 关注事件
router.rule()
.async(false)
.msgType(XmlMsgType.EVENT)
.event(EventType.SUBSCRIBE)
.handler(subscribeHandler)
.end();

// 点击菜单
router.rule()
.async(false)
.msgType(XmlMsgType.EVENT)
.event(EventType.CLICK)
.eventKey(WxMpConstant.MENU_KEY_ABOUT)
.handler(eventHandler)
.end();

// 【新增】兜底规则:处理所有未匹配的消息
router.rule()
.async(false)
.handler((wxMessage, context, service, sessionManager) ->
WxMpXmlOutMessage.TEXT()
.content("抱歉,暂不支持该类型消息 😢")
.fromUser(wxMessage.getToUser())
.toUser(wxMessage.getFromUser())
.build()
)
.end();

return router;
}

注意:async(true) 时不能直接返回 WxMpXmlOutMessage
正确做法是:在 Handler 内部调用 wxMpService.getKefuService().sendKefuMsg(...) 主动推送。

处理器 handler

共性问题:

  1. 缺少日志记录
  • 无法追踪用户行为、排查问题
  1. 未校验来源(防伪造请求)
  • 微信消息应通过 WxMpService.checkSignature() 验签(通常由 Controller 层完成,但需确认)
  1. 硬编码回复内容
  • 不利于多语言、动态配置(如“感谢关注”应可后台配置)
  1. 未关联用户身份
  • 无法根据 openid 查询用户信息、做个性化回复

改进示例(以 SubscribeHandler 为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Component
@Slf4j
public class SubscribeHandler implements WxMpMessageHandler {

@Resource
private UserService userService;

@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMessage, Map<String, Object> context,
WxMpService wxMpService, WxSessionManager sessionManager) {
String fromOpenId = wxMessage.getFromUser();
log.info("用户关注公众号,openid: {}", fromOpenId);

try {
// 可选:保存或更新用户信息
User user = userService.getUserByMpOpenId(fromOpenId);
if (user == null) {
// 调用微信 API 获取用户详情(需 access_token)
WxMpUser wxUser = wxMpService.getUserService().userInfo(fromOpenId);
userService.saveWxUser(wxUser);
}

String content = "🎉 欢迎关注!回复「帮助」查看功能列表~";
return WxMpXmlOutMessage.TEXT()
.content(content)
.fromUser(wxMessage.getToUser())
.toUser(fromOpenId)
.build();

} catch (Exception e) {
log.error("处理关注事件异常,openid: {}", fromOpenId, e);
return WxMpXmlOutMessage.TEXT()
.content("欢迎关注!")
.fromUser(wxMessage.getToUser())
.toUser(fromOpenId)
.build();
}
}
}

改进点:

  • 添加 @Slf4j 日志
  • 关联业务逻辑(用户绑定)
  • 异常兜底
  • 动态内容(未来可接入配置中心)

事件处理器 EventHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 事件处理器
**/
@Component
public class EventHandler implements WxMpMessageHandler {
@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMpXmlMessage, Map<String, Object> map, WxMpService wxMpService,
WxSessionManager wxSessionManager) throws WxErrorException {
final String content = "您点击了菜单";
// 调用接口,返回验证码
return WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMpXmlMessage.getToUser())
.toUser(wxMpXmlMessage.getFromUser())
.build();
}
}

消息处理器 MessageHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 消息处理器
**/
@Component
public class MessageHandler implements WxMpMessageHandler {

@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMpXmlMessage, Map<String, Object> map,
WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
String content = "我是复读机:" + wxMpXmlMessage.getContent();
return WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMpXmlMessage.getToUser())
.toUser(wxMpXmlMessage.getFromUser())
.build();
}
}

关注处理器 SubscribeHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 关注处理器
*/
@Component
public class SubscribeHandler implements WxMpMessageHandler {

@Override
public WxMpXmlOutMessage handle(WxMpXmlMessage wxMpXmlMessage, Map<String, Object> map,
WxMpService wxMpService, WxSessionManager wxSessionManager) throws WxErrorException {
final String content = "感谢关注";
// 调用接口,返回验证码
return WxMpXmlOutMessage.TEXT().content(content)
.fromUser(wxMpXmlMessage.getToUser())
.toUser(wxMpXmlMessage.getFromUser())
.build();
}
}

使用了 WxJava(WeChat Java SDK) 的标准模式:
通过 WxMpMessageRouter 路由不同消息类型到对应的处理器(Handler),符合事件驱动的设计思想。

当前设计亮点

优点 说明
职责分离 不同事件(关注、点击、文本)由独立 Handler 处理,便于维护
路由配置集中 WxMpMsgRouter 统一管理规则,避免散落在各处
使用 WxJava 标准接口 基于 WxMpMessageHandler,兼容性好
常量抽离 WxMpConstant.CLICK_MENU_KEY 避免魔法字符串

全与健壮性补充

  1. 必须确保验签(Signature Check)
    在接收微信消息的 Controller 中,必须调用
1
2
3
if (!wxMpService.checkSignature(timestamp, nonce, signature)) {
throw new IllegalArgumentException("非法请求");
}

否则任何人都可伪造公众号消息!

  1. 敏感操作需二次确认
  • 如“点击菜单返回验证码”,应校验用户是否已绑定手机号
  • 避免直接暴露内部逻辑
  1. 考虑限流与防刷
  • 对高频文本消息(如机器人攻击)做频率限制

✅ 最终推荐架构

1
2
3
4
5
6
7
Controller(验签 + 接收消息)

WxMpMessageRouter(路由分发)

Handler(处理逻辑 + 日志 + 业务调用)

Service(用户绑定、数据查询等)

核心原则:Handler 只负责“消息解析与响应构建”,业务逻辑下沉到 Service