# Ragent 项目自定义异常设计笔记

一.自定义异常

这套自定义异常设计我写进笔记中,不是因为它“花哨”,而是因为它把一件很容易写乱的事情做得很克制:异常有分类、错误码有归属、响应有统一出口、日志也有明确策略。最终效果是,业务代码写起来顺手,接口层返回也稳定。


 1. 先看整体结构

这个项目的异常体系可以概括成一条很清晰的链路:

`业务代码抛异常 -> 异常携带错误码和消息 -> 全局异常处理器统一拦截 -> 返回 Result`

对应到代码,核心角色有这几类:

- 异常抽象基类:`AbstractException`

- 三个一等异常类型:`ClientException`、`ServiceException`、`RemoteException`

- 错误码抽象:`IErrorCode`

- 基础错误码枚举:`BaseErrorCode`

- 全局异常处理器:`GlobalExceptionHandler`

- 统一响应构造器:`Results`


2. 核心抽象:异常不仅有 message,还有 code

项目里最关键的设计点,在于基类 `AbstractException`:

/**
 * 抽象项目中三类异常体系,客户端异常、服务端异常以及远程服务调用异常
 */
@Getter
public abstract class AbstractException extends RuntimeException {

    public final String errorCode;

    public final String errorMessage;

    public AbstractException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable);
        this.errorCode = errorCode.code();
        this.errorMessage = Optional.ofNullable(StringUtils.hasLength(message) ? message : null).orElse(errorCode.message());
    }
}

这里有三个很重要的点:

1. 它继承的是 `RuntimeException`

这意味着业务层不需要到处 `throws`,异常传播路径更自然,适合 Web 项目里的业务异常表达。

2. 它强制每个异常都带有 `errorCode`

很多项目只抛一句 `"参数错误"` 或 `"系统异常"`,前端能看到字,但系统层面很难做归类、统计和治理。这里直接把错误码做成异常的一部分,后面统一返回就很稳。

3. 它同时保留 `errorMessage`

如果调用方没手动传 message,就自动回退到错误码默认文案。也就是说:

- 想复用默认文案,可以只传错误码

- 想表达更具体的业务上下文,可以覆盖 message

这个细节很实用,减少了“要么写死字符串,要么只能用模板话术”的割裂感。


3. 三类异常分工非常清楚

3.1 `ClientException`

`ClientException` 用来表示客户端侧问题,最常见的是:

- 参数为空

- 参数非法

- 请求对象不存在

- 当前操作不允许

它默认对应 `BaseErrorCode.CLIENT_ERROR`,也就是 A 类错误码。

构造方式如下:

/**
 * 客户端异常
 * 用户发起调用请求后因客户端提交参数或其他客户端问题导致的异常
 */
public class ClientException extends AbstractException {

    public ClientException(IErrorCode errorCode) {
        this(null, null, errorCode);
    }

    public ClientException(String message) {
        this(message, null, BaseErrorCode.CLIENT_ERROR);
    }

    public ClientException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public ClientException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable, errorCode);
    }

    @Override
    public String toString() {
        return "ClientException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}

这个异常在项目里用得非常多,典型写法像这样:

Assert.notNull(request, () -> new ClientException("请求不能为空"));

Assert.notNull(pipeline, () -> new ClientException("未找到流水线"));

throw new ClientException("流水线名称已存在");

这种代码读起来很像在写业务规则,而不是在“处理 Java 异常语法”。

3.2 `ServiceException`

`ServiceException` 用来表示服务端执行过程中的异常,重点不在“谁传错了参数”,而在“系统执行时出现了不符合预期的情况”。

比如:

- 文档解析失败

- 向量结果数量不匹配

- 资源创建失败

- 某个业务过程内部状态异常

它默认对应 `BaseErrorCode.SERVICE_ERROR`,也就是 B 类错误码。

/**
 * 服务端运行异常
 * 请求运行过程中出现的不符合业务预期的异常
 */
public class ServiceException extends AbstractException {

    public ServiceException(String message) {
        this(message, null, BaseErrorCode.SERVICE_ERROR);
    }

    public ServiceException(IErrorCode errorCode) {
        this(null, errorCode);
    }

    public ServiceException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public ServiceException(String message, Throwable throwable, IErrorCode errorCode) {
        super(Optional.ofNullable(message).orElse(errorCode.message()), throwable, errorCode);
    }

    @Override
    public String toString() {
        return "ServiceException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}

项目里的使用也很典型:

throw new ServiceException("知识库名称已存在:" + requestParam.getName());

throw new ServiceException("存储桶名称已被占用:" + bucketName);

throw new ServiceException("文档解析失败: " + e.getMessage());

这里表达的是:接口调用方式没问题,但系统在执行过程中失败了。

3.3 `RemoteException`

`RemoteException` 表示远程服务调用异常,对应 C 类错误码。

这个分类很值得学习,因为它把“本系统出错”和“外部系统出错”分开了。后续无论是监控、告警、重试,还是问题归因,这种边界都非常重要。

/**
 * 远程服务调用异常
 * 比如订单调用支付失败,向上抛出的异常应该是远程服务调用异常
 */
public class RemoteException extends AbstractException {

    public RemoteException(String message) {
        this(message, null, BaseErrorCode.REMOTE_ERROR);
    }

    public RemoteException(String message, IErrorCode errorCode) {
        this(message, null, errorCode);
    }

    public RemoteException(String message, Throwable throwable, IErrorCode errorCode) {
        super(message, throwable, errorCode);
    }

    @Override
    public String toString() {
        return "RemoteException{" +
                "code='" + errorCode + "'," +
                "message='" + errorMessage + "'" +
                '}';
    }
}

4. 错误码设计让异常“可治理”

异常体系之所以成立,不只是因为有三个异常类,更因为它们背后挂着统一的错误码系统。

4.1 错误码接口

/**
 * 平台错误码
 * 定义错误码抽象接口,由各错误码类实现接口方法
 */
public interface IErrorCode {

    /**
     * 错误码
     */
    String code();

    /**
     * 错误信息
     */
    String message();
}

这个接口很简单,但它把“错误的机器标识”和“错误的人类可读文案”绑定到了一起。

4.2 基础错误码枚举

项目里的 `BaseErrorCode` 采用 A / B / C 三类分层:

- A 类:客户端错误

- B 类:服务端执行错误

- C 类:第三方服务错误

CLIENT_ERROR("A000001", "客户端错误"),

SERVICE_ERROR("B000001", "系统执行出错"),

REMOTE_ERROR("C000001", "调用第三方服务出错");

这一层的价值在于:

- 前端看到的是稳的 code + message

- 后端日志和监控可以按错误码聚合

- 后续如果要补更细的业务错误码,不需要推翻原结构

 5. 它不是只定义异常,而是把“返回出口”也统一了

项目中异常定义和统一响应是打通的。

5.1 统一响应对象

/**
 * 全局统一返回结果对象
 *
 * <p>
 * 用于规范化所有 API 接口的返回格式,确保前后端交互的一致性
 * 所有接口返回都应使用此对象包装,避免不同开发人员定义不一致的返回结构
 * </p>
 *
 * @param <T> 响应数据的类型
 */
@Data
@Accessors(chain = true)
public class Result<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = 5679018624309023727L;

    /**
     * 成功状态码
     * <p>
     * 当接口请求成功时,返回此状态码
     * </p>
     */
    public static final String SUCCESS_CODE = "0";

    /**
     * 状态码
     * <p>
     * 标识请求的处理结果,{@code "0"} 表示成功,其他值表示各类错误或异常情况
     * </p>
     */
    private String code;

    /**
     * 响应消息
     * <p>
     * 对本次请求结果的文字描述,成功时可为成功提示,失败时为错误原因说明
     * </p>
     */
    private String message;

    /**
     * 响应数据
     * <p>
     * 接口返回的业务数据,类型由泛型 T 指定。请求失败时可能为 {@code null}
     * </p>
     */
    private T data;

    /**
     * 请求追踪 ID
     * <p>
     * 用于链路追踪和问题排查,每个请求具有唯一的标识符
     * </p>
     */
    private String requestId;

    /**
     * 判断请求是否成功
     *
     * @return 如果状态码为 {@link #SUCCESS_CODE},返回 {@code true};否则返回 {@code false}
     */
    public boolean isSuccess() {
        return SUCCESS_CODE.equals(code);
    }
}

这意味着无论成功失败,接口层都有统一结构,而不是有的接口返回字符串,有的接口返回 map,有的接口直接抛默认异常页面。

5.2 统一构造

`Results` 类专门负责构造返回值,其中最关键的是这个方法:

/**
 * 构建全局返回对象构造器,方便开发者构建全局返回对象
 */
public final class Results {

    /**
     * 构造成功响应
     */
    public static Result<Void> success() {
        return new Result<Void>()
                .setCode(Result.SUCCESS_CODE);
    }

    /**
     * 构造带返回数据的成功响应
     */
    public static <T> Result<T> success(T data) {
        return new Result<T>()
                .setCode(Result.SUCCESS_CODE)
                .setData(data);
    }

    /**
     * 构建服务端失败响应
     */
    public static Result<Void> failure() {
        return new Result<Void>()
                .setCode(BaseErrorCode.SERVICE_ERROR.code())
                .setMessage(BaseErrorCode.SERVICE_ERROR.message());
    }

    /**
     * 通过 {@link AbstractException} 构建失败响应
     */
    static Result<Void> failure(AbstractException abstractException) {
        String errorCode = Optional.ofNullable(abstractException.getErrorCode())
                .orElse(BaseErrorCode.SERVICE_ERROR.code());
        String errorMessage = Optional.ofNullable(abstractException.getErrorMessage())
                .orElse(BaseErrorCode.SERVICE_ERROR.message());
        return new Result<Void>()
                .setCode(errorCode)
                .setMessage(errorMessage);
    }

    /**
     * 通过 errorCode、errorMessage 构建失败响应
     */
    static Result<Void> failure(String errorCode, String errorMessage) {
        return new Result<Void>()
                .setCode(errorCode)
                .setMessage(errorMessage);
    }
}

也就是说,业务层只负责“抛对异常”,Web 层会自动把它翻译成统一响应。

6. `GlobalExceptionHandler` 把整个异常链路闭环了

如果说前面的异常类是在“定义语言”,那 `GlobalExceptionHandler` 就是在“统一出口”

它主要处理了五类情况

1. 参数校验异常

2. 项目自定义异常 `AbstractException`

3. 未登录异常

4. 权限异常

5. 兜底的 `Throwable`

其中最关键的是这段:

@ExceptionHandler(value = {AbstractException.class})

public Result<Void> abstractException(HttpServletRequest request, AbstractException ex) {

    if (ex.getCause() != null) {

        log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex, ex.getCause());

        return Results.failure(ex);

    }

    ...

    return Results.failure(ex);

}

这段代码体现了两个成熟点:

- 自定义异常会被统一映射,不会漏到前端变成杂乱的 500 页面

- 日志不是瞎打一整屏,而是区分有 cause 和无 cause 的情况做记录

- 它还单独处理了认证和上传大小限制等常见 Web 级异常

7. 这套设计好的原因

7.1 语义是清楚的

同样是失败,下面三种含义完全不同:

- `ClientException`:请求不合法,或者当前操作不允许

- `ServiceException`:系统内部执行失败

- `RemoteException`:外部依赖调用失败

很多项目把这些都混成 `RuntimeException("出错了")`,后面定位问题会很痛苦。

7.2 业务代码很干净

因为异常语义清楚,业务代码可以直接写规则:

if (count > 0) {

    throw new ServiceException("知识库名称已存在:" + requestParam.getName());

}

或者:

Assert.notNull(request, () -> new ClientException("请求不能为空"));

这种写法没有大量样板代码,阅读成本低。

7.3 前后端协作成本低

前端拿到的始终是统一结构:

- `code`

- `message`

- `data`

- `requestId`

这对接口消费方很友好,尤其适合后台管理系统和统一弹窗提示场景。

8.总结

这类自定义异常可以成四句话:

1. 先定义异常基类,让异常天然带 `errorCode` 和 `errorMessage`

2. 用 `ClientException / ServiceException / RemoteException` 建立顶层分类

3. 用 `@RestControllerAdvice` 统一把异常转成标准 `Result`

4. 只在有明确业务语义时,再补领域异常子类

这四步一落地,项目的异常体系基本就会从“能跑”提升到“能维护”。

这套自定义异常设计,核心不是它定义了多少异常类,而是它完成了下面这件事:

它把“错误发生”这件事,从业务层一直管理到了接口返回层。

具体来说:

- 异常有抽象,不是散装字符串

- 异常有分类,不是所有问题都叫系统异常

- 异常有错误码,不只是给人看,也能给系统看

- 异常有统一出口,不会把控制权交给默认框架行为

- 异常允许领域扩展,但不会过度设计

这类代码很适合写进自己的工程方法论里,因为它既不复杂,也足够实战。


# Ragent 项目mybaitsplus自动填充设计笔记

.MyBatis-Plus 自动填充、逻辑删除

这块内容是结合当前项目的实际写法,梳理项目的三块能力:

1. 实体注解声明式映射

2. `insert / update` 自动填充

3. 主键 ID 自动生成

如果把它们串起来看,这个项目的持久层设计非常顺手:业务层只管构造对象和调用 `Mapper`,公共字段和值生成交给 MyBatis-Plus 统一处理。

1. 项目里 MyBatis-Plus 是怎么接进来的

直接引入了 MyBatis-Plus 相关依赖

<dependency>

    <groupId>com.baomidou</groupId>

    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>

</dependency>

项目启动类还通过 `@MapperScan` 扫描 Mapper:

这意味着:

1. 实体类负责描述表结构映射

2. `Mapper` 接口继承 `BaseMapper<T>` 后即可拥有通用 CRUD

3. 配置类负责把分页、自动填充等扩展能力注册进 Spring

 2. 统一配置类:把 MyBatis-Plus 的能力接上

项目的核心配置类是:

/**
 * 数据库持久层配置类
 * 配置 MyBatis-Plus 相关分页插件等
 */
@Configuration
public class DataBaseConfiguration {

    /**
     * MyBatis-Plus PostgreSQL 分页插件
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
        return interceptor;
    }

    /**
     * MyBatis-Plus 源数据自动填充类
     */
    @Bean
    public MetaObjectHandler myMetaObjectHandler() {
        return new MyMetaObjectHandler();
    }
}

这里做了两件很重要的事。

2.1 分页插件注册

`MybatisPlusInterceptor` 中加入了 `PaginationInnerInterceptor(DbType.POSTGRE_SQL)`,说明项目数据库方言按 PostgreSQL 处理。

因此像下面这种代码就能直接分页:

Page<UserDO> page = new Page<>(requestParam.getCurrent(), requestParam.getSize());

IPage<UserDO> result = userMapper.selectPage(page, queryWrapper);

2.2 自动填充处理器注册

`MetaObjectHandler` 被声明为 Spring Bean,实际实现类是 `MyMetaObjectHandler`。

这一步非常关键。只有注册成功后,实体字段上的 `fill = ...` 才会真正生效。

3. 实体注解是这套设计的入口

这个项目的实体类风格非常统一,通常都会出现下面几类注解:

1. `@TableName`

2. `@TableId`

3. `@TableField(fill = ...)`

4. `@TableLogic`

以 `UserDO` 为例:

核心代码如下:

@TableName("t_user")

public class UserDO {


    @TableId(type = IdType.ASSIGN_ID)

    private String id;


    @TableField(fill = FieldFill.INSERT)

    private Date createTime;


    @TableField(fill = FieldFill.INSERT_UPDATE)

    private Date updateTime;


    @TableLogic

    private Integer deleted;

}

这个写法很有代表性。

3.1 `@TableName`

作用是指定实体类映射到哪张表。

例如:

- `UserDO -> t_user`

- `ConversationDO -> t_conversation`

- `KnowledgeBaseDO -> t_knowledge_base`

- `KnowledgeDocumentDO -> t_knowledge_document`

3.2 `@TableId(type = IdType.ASSIGN_ID)`

作用是告诉 MyBatis-Plus:这个字段是主键,并且主键值由框架在插入时分配。

这也是后面“自定义 ID 生成器”能够接管 ID 分配的前提之一。

3.3 `@TableField(fill = ...)`

这是自动填充真正落地的地方。

项目约定非常统一:

@TableField(fill = FieldFill.INSERT)

private Date createTime;


@TableField(fill = FieldFill.INSERT_UPDATE)

private Date updateTime;

含义分别是:

1. `createTime` 只在插入时自动赋值

2. `updateTime` 在插入和更新时都自动赋值

这套注解只是“声明要填充”,真正填什么值,仍然要看 `MetaObjectHandler`。

3.4 `@TableLogic`

`deleted` 字段被标记为逻辑删除字段:

@TableLogic

private Integer deleted;

它和自动填充器配合得很好,因为项目会在插入时把 `deleted` 统一填成 `0`。

这样做有两个好处:

1. 新增数据时不用每次手动写 `deleted = 0`

2. 删除语义统一,业务代码读起来更清爽

例如业务查询里经常显式补上:

.eq(UserDO::getDeleted, 0)

4. 自动插入、自动更新到底是怎么实现的

真正的核心在这里:

/**
 * MyBatis-Plus 源数据自动填充类
 */
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        strictInsertFill(metaObject, "createTime", Date::new, Date.class);
        strictInsertFill(metaObject, "updateTime", Date::new, Date.class);
        strictInsertFill(metaObject, "deleted", () -> 0, Integer.class);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        strictUpdateFill(metaObject, "updateTime", Date::new, Date.class);
    }
}

4.1 `insertFill`

在执行插入时,统一做三件事:

1. 给 `createTime` 赋当前时间

2. 给 `updateTime` 赋当前时间

3. 给 `deleted` 赋默认值 `0`

这就意味着业务代码在新增对象时,不需要关心这些公共字段。

例如创建用户时,代码只关心业务字段:

UserDO record = UserDO.builder()

        .username(username)

        .password(password)

        .role(role)

        .avatar(StrUtil.trimToNull(requestParam.getAvatar()))

        .build();

userMapper.insert(record);

也就是说,`record` 里没有手动设置 `id/createTime/updateTime/deleted`,但插入后这些字段仍然可以完整落库。

4.2 `updateFill`

在执行更新时,统一刷新:

1. `updateTime`

所以像下面这类更新代码也很省心:

record.setPassword(next);

userMapper.updateById(record);

再比如会话更新时间:

existing.setLastTime(request.getLastTime());

conversationMapper.updateById(existing);

业务层只更新真正关心的业务字段,`updateTime` 自动刷新。

4.3 为什么这里用 `strictInsertFill` / `strictUpdateFill`

这两个 API 的好处是“更克制”。

可以把它理解为:

1. 字段存在才填

2. 填充值类型匹配才填

3. 更符合 MyBatis-Plus 推荐用法

也就是说,这不是简单粗暴地反射覆盖,而是在框架约束下进行更安全的统一填充。

5. 这样写的好处

5.1 新增时不用手动补公共字段

以前传统写法常常是:

record.setId(xxx);

record.setCreateTime(new Date());

record.setUpdateTime(new Date());

record.setDeleted(0);

mapper.insert(record);

而现在业务层只写业务字段即可。

5.2 更新时不用手动改更新时间

以前每次都得补:

record.setUpdateTime(new Date());

mapper.updateById(record);

现在由 `updateFill` 统一接管。

5.3 删除语义更统一

实体上 `@TableLogic` + 插入时默认 `deleted = 0`,让“未删除数据”的数据形态始终一致。

5.4 Mapper 层非常薄

项目里大量 Mapper 都是这种形式:

public interface UserMapper extends BaseMapper<UserDO> {

}

说明通用 CRUD 已经足够覆盖大部分场景。  

这也是 MyBatis-Plus 真正让人舒服的地方:能把很多样板代码直接吃掉。

6. 这套设计的运行链路

可以把当前项目里的执行过程理解成下面这条链:

1. 业务层创建实体对象

2. 调用 `mapper.insert(...)` 或 `mapper.updateById(...)`

3. MyBatis-Plus 识别实体上的注解

4. 插入时触发 `IdentifierGenerator` 生成主键

5. 插入/更新时触发 `MetaObjectHandler` 自动填充公共字段

6. 最终生成 SQL 并落库

“创建用户”为例,真实链路非常清晰:

1. `UserServiceImpl.create(...)` 构造 `UserDO`

2. 没有手动设置 `id/createTime/updateTime/deleted`

3. 执行 `userMapper.insert(record)`

4. MyBatis-Plus 根据 `@TableId(type = IdType.ASSIGN_ID)` 生成主键

5. 根据 `@TableField(fill = ...)` 调用 `insertFill`

6. 根据 `@TableName("t_user")` 映射到 `t_user`

7. 数据成功写入后,`record.getId()` 还能直接拿到生成后的主键

7.总结

7.1 声明式非常强

实体类上几个注解一贴,表名、主键策略、自动填充、逻辑删除全部表达清楚。  

读实体就能知道这张表的持久化约定。

7.2 公共规则集中管理

`createTime / updateTime / deleted / id` 这些典型横切字段,没有散落在各个 Service 里,而是统一下沉到框架层。  

这能明显减少重复代码和漏改风险。

7.3 业务代码更接近“业务本身”

`UserServiceImpl`、`ConversationServiceImpl` 里看到的几乎都是业务逻辑,而不是“顺手补公共字段”的机械代码。  

这会让 Service 层更干净,也更好维护。

所以:

当前项目中,MyBatis-Plus 最值得学习的不是“用了它”,而是“用得很克制、很统一”。

具体体现在:

1. 用 `@TableName / @TableId / @TableField / @TableLogic` 把实体声明清楚

2. 用 `MetaObjectHandler` 托管插入和更新时的公共字段填充

3. 用 `IdentifierGenerator` 托管主键生成

4. 用 `BaseMapper` 让 CRUD 足够轻量

最后形成的效果是:

业务开发时,开发者只需要关注真正的业务值;  

表结构约定、时间字段维护、逻辑删除默认值、主键生成,全部由框架层兜底。


# Ragent 项目用户上下文容器设计笔记

三.用户上下文容器:基于 TTL 传递当前登录用户

这套实现是一类后端基础设施:把“当前登录用户”收口成一个统一上下文容器,然后通过 `TTL` 在需要的线程切换场景里继续可见。这样业务代码不必一层层传 `userId`、`username`,但又比随手乱用 `ThreadLocal` 更稳,因为它明确考虑了线程池复用和异步执行。

1. 先说这套设计的结论

它同时解决了 4 个问题

1. “当前用户”有统一入口,业务层直接拿,不用每层方法都传参。

2. 上下文对象不是零散字段,而是一个明确的 `LoginUser` 快照。

3. 它不是普通 `ThreadLocal`,而是 `TransmittableThreadLocal`,考虑了线程池场景。

4. 它不仅处理 Web 请求,还照顾到了 MQ、定时任务这类非请求入口。

一句话概括就是:

认证在入口完成,上下文在入口注入,业务在中间直接读,线程结束或任务结束后及时清理。

2. 核心模型:`LoginUser` + `UserContext`

`LoginUser` 很克制,就是一个“当前登录用户快照”:

/*
/**
 * 当前登录用户的上下文快照
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginUser {

    /**
     * 用户 ID
     */
    private String userId;

    /**
     * 用户名
     */
    private String username;

    /**
     * 角色(如 admin/user)
     */
    private String role;

    /**
     * 用户头像
     */
    private String avatar;
}

这里做得好的点在于,它没有把数据库实体 `UserDO` 直接塞进上下文,而是单独定义了一个轻量对象。这样上下文表达的是“当前会话关心的信息”,而不是“整张用户表”

真正的容器是 `UserContext`:

/**
 * 用户上下文容器(基于 TTL 传递当前线程的登录用户)
 */
public final class UserContext {

    private static final TransmittableThreadLocal<LoginUser> CONTEXT = new TransmittableThreadLocal<>();

    /**
     * 设置当前线程的用户上下文
     */
    public static void set(LoginUser user) {
        CONTEXT.set(user);
    }

    /**
     * 获取当前线程的用户上下文
     */
    public static LoginUser get() {
        return CONTEXT.get();
    }

    /**
     * 获取当前线程用户,若不存在则抛异常
     */
    public static LoginUser requireUser() {
        LoginUser user = CONTEXT.get();
        if (user == null) {
            throw new ClientException("未获取到当前登录用户");
        }
        return user;
    }

    /**
     * 获取当前用户 ID(未登录返回 null)
     */
    public static String getUserId() {
        LoginUser user = CONTEXT.get();
        return user == null ? null : user.getUserId();
    }

    /**
     * 获取当前用户名(未登录返回 null)
     */
    public static String getUsername() {
        LoginUser user = CONTEXT.get();
        return user == null ? null : user.getUsername();
    }

    /**
     * 获取当前角色(未登录返回 null)
     */
    public static String getRole() {
        LoginUser user = CONTEXT.get();
        return user == null ? null : user.getRole();
    }

    /**
     * 获取当前头像(未登录返回 null)
     */
    public static String getAvatar() {
        LoginUser user = CONTEXT.get();
        return user == null ? null : user.getAvatar();
    }

    /**
     * 清理当前线程的用户上下文
     */
    public static void clear() {
        CONTEXT.remove();
    }

    /**
     * 判断是否已存在用户上下文
     */
    public static boolean hasUser() {
        return CONTEXT.get() != null;
    }
}

这里最关键的一行不是 `set/get`,而是:

 private static final TransmittableThreadLocal<LoginUser> CONTEXT = new TransmittableThreadLocal<>();

如果这里只是普通 `ThreadLocal`,那在以下场景里就容易掉上下文:

- 线程池复用

- `CompletableFuture`

- 手动切换执行器

- 流式输出、并行检索、异步回调

TTL 的价值不在“存”,而在“传”。

3. Web 入口:请求开始时注入,请求结束时清理

这个项目没有把“当前用户放入上下文”散落在 controller 里,而是放在拦截器里统一处理,这一步很关键。

`UserContextInterceptor` 的主体逻辑很清楚:

/**
 * 用户上下文拦截器
 *
 * <p>该拦截器用于在请求处理前从 SaToken 中获取登录用户信息,并设置到 UserContext 中,
 * 方便后续业务逻辑使用。在请求完成后清理 UserContext,避免内存泄漏。
 *
 * <p>主要功能:
 * <ul>
 *   <li>在请求前置处理时,从 SaToken 获取登录用户 ID</li>
 *   <li>根据用户 ID 查询数据库获取完整用户信息</li>
 *   <li>将用户信息封装成 LoginUser 对象并设置到 UserContext 线程上下文中</li>
 *   <li>在请求完成后清理 UserContext,防止线程复用时的数据污染</li>
 *   <li>跳过异步调度请求(如 SSE 完成回调),避免 SaToken 上下文丢失问题</li>
 * </ul>
 *
 */
@Component
@RequiredArgsConstructor
public class UserContextInterceptor implements HandlerInterceptor {

    private static final String DEFAULT_AVATAR_URL = "https://avatars.githubusercontent.com/u/583231?v=4";

    private final UserMapper userMapper;

    @Override
    public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
        // 异步调度请求跳过(SSE 完成回调会触发 asyncDispatch,此时 SaToken 上下文已丢失)
        if (request.getDispatcherType() == DispatcherType.ASYNC) {
            return true;
        }
        // 预检请求放行,避免 CORS 阻断
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        String loginId = StpUtil.getLoginIdAsString();
        UserDO user = userMapper.selectById(loginId);

        UserContext.set(
                LoginUser.builder()
                        .userId(user.getId().toString())
                        .username(user.getUsername())
                        .role(user.getRole())
                        .avatar(StrUtil.isBlank(user.getAvatar()) ? DEFAULT_AVATAR_URL : user.getAvatar())
                        .build()
        );
        return true;
    }

    @Override
    public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) {
        UserContext.clear();
    }
}

这段代码背后的思路可以记成一句话:

认证系统负责证明“你是谁”,上下文拦截器负责把“你是谁”变成业务层随时可读的运行时上下文。

这里有 3 个细节值得记:

3.1 为什么不是 controller 里手动 `UserContext.set`

因为那样会有大量重复代码,而且很容易漏。

拦截器统一注入后,controller/service/aspect 都能直接读取:

LoginUser user = UserContext.requireUser();

String userId = UserContext.getUserId();

String username = UserContext.getUsername();

3.2 为什么要在 `afterCompletion` 里 `clear`

因为服务器线程会复用。

如果只 `set` 不 `clear`,当前线程跑完请求后,残留的用户信息就可能污染下一个请求。这是所有 `ThreadLocal` 方案里最不能妥协的一条纪律。

3.3 为什么跳过 `ASYNC` 和 `OPTIONS`

`OPTIONS` 是典型 CORS 预检请求,不应该强依赖登录上下文。

`ASYNC` 是一个很实战的处理。项目注释里已经点明:像 SSE 完成回调这类异步分发阶段,Sa-Token 上下文可能已经不在原始状态,这时再强行取登录态,容易误伤。

4. 认证链路:先校验登录,再注入用户上下文

`SaTokenConfig` 里注册了 3 个拦截器:

1. `SaInterceptor`:先做登录校验

2. `DemoModeInterceptor`:演示环境只读控制

3. `UserContextInterceptor`:把当前用户注入 `UserContext`

顺序很重要,因为它表达的是:

1. 先确认请求是否合法

2. 再准备业务运行所需的上下文

也就是说,`UserContextInterceptor` 默认不是“鉴权器”,它是“上下文装配器”。

职责分离:

- 鉴权负责“能不能进”

- 上下文负责“进来之后业务如何方便地拿数据”

5. TTL 不是摆设:项目在线程池层做了包装

很多项目会写一个 `TransmittableThreadLocal`,但线程池没有包 TTL,最后仍然传不过去。这个项目没有停在“定义一个 TTL 变量”,而是继续把执行器做了 TTL 包装。

`ThreadPoolExecutorConfig` 里是这样的:

@Bean

public Executor ragContextExecutor() {

    ThreadPoolExecutor executor = new ThreadPoolExecutor(

            CPU_COUNT,

            CPU_COUNT << 1,

            60,

            TimeUnit.SECONDS,

            new SynchronousQueue<>(),

            ThreadFactoryBuilder.create()

                    .setNamePrefix("rag_context_executor_")

                    .build(),

            new ThreadPoolExecutor.CallerRunsPolicy()

    );

    return TtlExecutors.getTtlExecutor(executor);

}

几乎所有关键执行器都走了同样的包装:

- `mcpBatchExecutor`

- `ragContextExecutor`

- `ragRetrievalExecutor`

- `innerRetrievalExecutor`

- `intentClassifyExecutor`

- `memorySummaryExecutor`

- `modelStreamExecutor`

- `chatEntryExecutor`

- `knowledgeChunkExecutor`

- `memoryLoadExecutor`

这意味着一件很重要的事:

只要任务是通过这些执行器提交出去的,父线程里的 `UserContext` / trace 上下文就有机会透传到子线程。

这才是 TTL 方案真正落地,而不是“类名里有 TTL”。

6. 业务层怎么用

比如当前用户接口:

@GetMapping("/user/me")

public Result<CurrentUserVO> currentUser() {

    LoginUser user = UserContext.requireUser();

    return Results.success(new CurrentUserVO(

            user.getUserId(),

            user.getUsername(),

            user.getRole(),

            user.getAvatar()

    ));

}

比如很多创建/更新逻辑直接拿用户名做审计字段:

.createdBy(UserContext.getUsername())

.updatedBy(UserContext.getUsername())

比如幂等切面把“当前用户 ID”拼进锁键:

private String getCurrentUserId() {

    return UserContext.getUserId();

}

比如 RAG 对话里直接把当前用户注入会话上下文:

StreamChatContext ctx = StreamChatContext.builder()

        .question(question)

        .conversationId(actualConversationId)

        .taskId(taskId)

        .deepThinking(Boolean.TRUE.equals(deepThinking))

        .userId(UserContext.getUserId())

        .callback(traceAware)

        .build();

这种“随取随用”的体验,本质上就是前面那套上下文基础设施换来的。

7.总结

这里把系统任务伪装成一个明确的“操作者” `system`,非常适合审计字段、操作来源识别、日志排查。

这个设计的启发是:

 用户上下文不一定只能代表真实登录用户,它也可以代表“当前动作的责任主体”。

它适合放:

- 当前用户

- traceId

- taskId

- 租户信息

不适合放:

- 大对象

- 可随意修改的复杂状态

- 请求体全文

- 生命周期很长的缓存数据

链路:

1. 先用认证框架确认当前请求是谁。

2. 再把这个人转成一个轻量 `LoginUser` 快照,放进 `UserContext`。

3. `UserContext` 基于 `TransmittableThreadLocal`,并配合 TTL 包装执行器,保证线程切换后仍可读。

4. 业务层不关心认证细节,只需要 `UserContext.getUserId()` / `getUsername()`。

5. 请求结束、任务结束后必须 `clear()`,避免线程复用污染。

这就是一种很典型、很实用的“基础设施把复杂度收口,业务代码保持轻量”的写法。

        

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐