user-auth-datasource-plan.md 33 KB

清鉴(zsjz-ai)用户体系 + 数据源管理重构 · 设计文档

状态:已实施(阶段 0~3 完成,数据库迁移已执行,已端到端验证) 目标:为现有单机/客户端形态的服务端补上「多用户同时使用」能力。

实施要点与踩坑记录见 .workbuddy-ai/memory/2026-09-18.md。 迁移脚本 sql/user.sql 已在 192.168.0.109:5432/zsjz-ai 执行; 内置账号 admin / admin123(首次登录后请立即改密)。

0. 问题背景

现状是客户端应用设计,多处全局单例假设"服务端只服务一个人":

位置 现状 多人并发后果
common/utils/StateManager.java:19 JVM 级单例 private CaseInfo caseInfo,未开案返回哨兵 888888 A 开案覆盖 B 的当前案件,串案
slave 数据源 key 全局唯一,CaseInfoService 开案时 removeDataSource("slave") + addDataSource("slave", ds) B 开案会把 A 的数据源整个换掉,串库
common/utils/LuceneManager.java:36,51-54 static IndexWriter + if (writer != null) return B 开案复用 A 的索引目录
module/plat/service/SseService.java:31 volatile SseEmitter sseEmitter 单连接,新连接踢旧 B 连上踢掉 A 的进度推送
module/agent/python/PythonExecutor.java:95 在 Reactor 线程读 StateManager.instance().getCaseId() P0 串案
CaseInfoService.exit() 退出后统一切到全局共享的 PathConst.TEMP_DB_PATH 多人共用同一个 temp 文件

同时全仓无用户表、无登录接口、无拦截器层(sa-token 依赖已声明但代码零使用)。


1. 设计决策(已确认)

# 决策 影响
1 sa-token 做认证,不做权限体系 只有 is_admin 一个布尔标志
2 DuckDB 一案件一 .duckdb 文件 见 §3
3 每用户同时只打开一个案件;同一案件不会被多个用户同时打开 数据源按 caseId + owner 记账,无需复杂引用计数
4 案件归属创建者,仅自己可见 case_info 加 owner_id,列表按 owner 过滤
5 账号由管理员创建,无自助注册;配前端弹框式用户管理页 内置 admin + /user/** 接口
6 同一账号不允许多端登录(后登录踢前登录) is-concurrent: false
7 token 仅走 Header(x-token),不用 Cookie is-read-cookie: false
8 token 存 localStorage 关浏览器仍保持登录
9 取消案件密码校验(case_info.pwd 字段保留但不校验) /case/open 不再要 pwd
10 允许不同用户同名案件 唯一索引改为 (owner_id, name)
11 /govern/sse 由 EventSource 改为 fetch 流式 原生 EventSource 无法带 Header,阻断项
12 license 授权失败码从 401 改为 40301 把 401 语义让给"未登录"

2. 总体架构

                    ┌───────────────────────────────┐
   浏览器 A ─x-token─▶│  SaInterceptor(checkLogin)  │
   浏览器 B ─x-token─▶│  CaseContextInterceptor       │ ← 从 token session 取 caseId → ThreadLocal
                    └──────────────┬────────────────┘
                                   ▼
                    ┌───────────────────────────────┐
                    │  CaseRoutingDataSource         │ ← 覆写 getDataSource("slave")
                    │  slave ──▶ case{caseId}        │
                    └──────┬────────────────┬───────┘
                           ▼                ▼
                    ┌──────────┐     ┌──────────┐
                    │ case12   │     │ case13   │  ← 每案件一个 DuckdbUnpooledDataSource
                    │(案件A库) │     │(案件B库) │
                    └──────────┘     └──────────┘
                           master (PG) 常驻 primary

关键收益:71 个 @DS(StrConsts.DS_KEY_SLAVE) 注解一个字都不用改。


3. 数据源管理重构(本次核心)

3.1 命名约定

名称 含义
master PG,primary,永不切换
slave 逻辑 key,只出现在 @DS 与 getDataSource() 参数里,不注册为真实数据源
case{caseId} 物理 key(如 case12),真实注册进 dataSourceMap

警告:物理 key 禁止含下划线。 DynamicRoutingDataSource#addGroupDataSource 会把 _ 前的部分当组名。用 case_12 会额外生成组 case, 后续 getDataSource("case") 走负载均衡策略 → 随机串库,且极难排查。

3.2 方案选型:覆写 getDataSource(String)

为什么这一处覆写就够:

  • 注解路径:DynamicDataSourceAnnotationInterceptor push 字面量 "slave" → AbstractRoutingDataSource#getConnection() → determineDataSource() → getDataSource("slave")
  • 直连路径:DmService:224-227、AbstractDataLoader:23-26 显式 getDataSource("slave")

两条路径汇聚到同一个虚方法,覆写它即可全覆盖。

方案 做法 结论
A. 自定义 DsProcessor(@DS("#case")) 实现 DsProcessor#matches/doDetermineDatasource 否 —— 要改 71 处注解,回归面大
B. 覆写 getDataSource(String) CaseRoutingDataSource extends DynamicRoutingDataSource 采用 —— 注解零改动
C. @DS("#session.xxx") SpEL 依赖 Servlet session 否 —— 仍需改 71 处,且 DsJakartaSessionProcessor 取的是 Servlet session 不是 sa-token 会话

3.3 新增类清单

类 包 职责 关键方法
CaseRoutingDataSource common.datasource 覆写 getDataSource:slave → case{caseId} getDataSource(String)
DataSourceConfig common.config @Bean DataSource 替换自动配置(@ConditionalOnMissingBean 会让位) dataSource(providers, props)
CaseDataSourceRegistry common.datasource 案件数据源生命周期 + owner 记账 open/close/isOpen/ownerOf/openedCaseIds
CaseContextHolder common.context 当前线程案件上下文,供异步链路显式包裹 set/get/clear/currentCaseId/callWith
CaseContextInterceptor common.interceptor 从 sa-token Token-Session 取 caseId 写入 ThreadLocal preHandle/afterCompletion

核心逻辑:

@Override
public DataSource getDataSource(String ds) {
    if (!StrConsts.DS_KEY_SLAVE.equals(ds)) return super.getDataSource(ds);   // master 等走原逻辑
    Integer caseId = CaseContextHolder.currentCaseId();
    if (caseId == null) throw ServerException.spe("请先打开案件后再操作数据");
    DataSource physical = getDataSources().get(CaseDataSourceRegistry.physicalKey(caseId));
    if (physical == null) throw ServerException.spe("当前案件数据源未打开或已释放,请重新打开案件");
    return physical;
}
@Configuration
public class DataSourceConfig {
    @Bean
    public DataSource dataSource(List<DynamicDataSourceProvider> providers, DynamicDataSourceProperties props) {
        CaseRoutingDataSource ds = new CaseRoutingDataSource(providers);
        ds.setPrimary(props.getPrimary());
        ds.setStrict(props.getStrict());
        ds.setStrategy(props.getStrategy());
        ds.setP6spy(props.getP6spy());
        ds.setSeata(props.getSeata());
        ds.setGraceDestroy(props.getGraceDestroy());
        return ds;
    }
}

必须把 primary/strict/strategy/p6spy/seata/graceDestroy 全部手工搬过来,否则丢失 spring.datasource.dynamic.* 配置语义。 strict 保持 false(避免影响其它 key 的容错),由上面的显式抛错替代原来"静默回退 PG 报关系不存在"的坑。

3.4 数据源生命周期

open(caseId, dbPath, ownerUserId):
    if opened.containsKey(caseId):
        if owner != ownerUserId -> 抛"案件已被其他用户打开"(防御)
        return  // 幂等
    if opened.size() >= maxOpenCases -> 抛"同时打开的案件数已达上限,请先关闭其他案件"
    ds = new DuckdbUnpooledDataSource("jdbc:duckdb:" + dbPath, budget)
    routing.addDataSource("case" + caseId, ds)
    opened.put(caseId, Entry(ds, ownerUserId, now))

close(caseId):
    e = opened.remove(caseId); if (e == null) return
    routing.removeDataSource("case" + caseId)
    e.ds.close()

释放触发点(4 个,缺一不可)

  1. 用户主动「退出案件」→ /case/exit
  2. 用户登出 → /auth/logout
  3. 用户被顶下线 → SaTokenListener#doReplaced/doKicked
  4. 会话自然过期 → @Scheduled 扫描孤儿案件(owner 已无有效 token)兜底释放

3.5 内存/线程预算(必须做,否则多人开案必爆)

现状 DuckdbUnpooledDataSource#configureDuckDB():

SET GLOBAL memory_limit='<物理内存×0.3>MiB'; SET GLOBAL threads TO <CPU核数>;

SET GLOBAL 是 database instance 级,N 个案件同时打开 ⇒ N × 30% 内存 + N × CPU核数 线程,直接打爆内存与 CPU。

改造:新增配置,按"最大同时开案数"分摊。

zsjz:
  duckdb:
    max-open-cases: 8          # 硬上限,超出拒绝开案
    memory-ratio: 0.6          # 所有案件实例合计占用物理内存比例
    threads-per-instance: 2    # 每实例线程数(不再用 CPU 核数)

3.6 @DS 与 @Transactional 共存规则(写进开发约定)

  • @DS 的 Advisor order = Ordered.HIGHEST_PRECEDENCE(最外层通知)。@DS 必须在 @Transactional 外层 —— 否则事务开启时连接已通过 AbstractRoutingDataSource#getConnection() 绑定,内层再切无效。
  • 禁止在事务中途切数据源(master ↔ slave 混用同一事务必然错乱)。
  • 现有写入路径(AbstractDataLoader 手写 Appender、DmService 手取 Connection)未使用 Spring 事务,当前影响面小;新增的用户/案件接口会用到 @Transactional,须遵守上一条。

3.7 DuckDB 并发事实(已核实)

  • 同 JVM 同路径多次 DriverManager.getConnection() → 默认复用同一个 native database instance(jdbc_instance_cache 默认 true)。重复开同一案件不会真正开第二个实例。
  • duplicate() 返回独立连接对象(独立 autoCommit/事务/pendingQueries),共享同一 instance。驱动内部已有 connRefLock,项目里那层 synchronized 冗余但无害,保留并加注释。
  • DuckDBAppender 线程封闭(构造时绑定 ownerThreadId),跨线程使用直接抛 SQLException。⇒ 必须审计所有 Loader 实例,禁止把 AbstractDataLoader 放进静态缓存/共享 Map。
  • 跨进程打开同一 .duckdb 会因文件锁失败 ⇒ 部署约束:ai-server 单实例,不可水平扩容共享同一份 QingJian/ 目录。

4. 用户体系设计

4.1 表结构(新增 sql/user.sql,PostgreSQL 方言)

CREATE TABLE IF NOT EXISTS sys_user (
    id              BIGSERIAL    PRIMARY KEY,
    username        VARCHAR(64)  NOT NULL,
    pwd_hash        VARCHAR(100) NOT NULL,           -- BCrypt
    display_name    VARCHAR(64),
    is_admin        SMALLINT     NOT NULL DEFAULT 0, -- 1=管理员 0=普通用户
    status          SMALLINT     NOT NULL DEFAULT 1, -- 1=启用 0=停用
    create_time     TIMESTAMP    NOT NULL DEFAULT now(),
    update_time     TIMESTAMP,
    last_login_time TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_user_username ON sys_user (lower(username));

ALTER TABLE case_info ADD COLUMN IF NOT EXISTS owner_id BIGINT;
CREATE INDEX IF NOT EXISTS idx_case_info_owner ON case_info (owner_id);
-- 去掉原全局唯一约束,改为按 owner 唯一
ALTER TABLE case_info DROP CONSTRAINT IF EXISTS <现有唯一约束名>;
CREATE UNIQUE INDEX IF NOT EXISTS uk_case_info_owner_name ON case_info (owner_id, name);

「不引入权限体系」如何体现(避免后人误解):

  • 只有 is_admin 一个标志,作用域仅限「能否调 /user/** + 看到"用户管理"入口」;
  • 不建 sys_role / sys_menu / sys_permission / sys_user_role;
  • 不实现 StpInterface,StpUtil.hasRole/checkPermission 一律不用;
  • 前端 permission.ts 继续用本地静态 menu.json 生成路由,permCodeList 保持空;
  • 鉴权只有两层:① StpUtil.checkLogin() ② 数据归属校验(owner_id / caseId)。

密码:BCryptPasswordEncoder(spring-security-crypto 已在 ai-server/pom.xml:161-165)。 需手工定义 Bean(项目无 Spring Security 自动配置,不会自动注册)。

内置管理员:用预生成的 BCrypt 哈希写进 SQL,仓库不放明文密码。

4.2 sa-token 配置(application-dev.yaml 新增)

sa-token:
  token-name: x-token            # 与前端 authenticationHeader 严格一致
  timeout: 2592000               # 30 天
  active-timeout: -1             # 关闭活跃超时(长时间研判不会被登出)
  is-concurrent: false           # ★ 同账号不允许并发登录
  is-share: false
  token-style: uuid
  is-log: false
  is-print: false
  is-read-header: true           # ★ 仅 Header
  is-read-cookie: false          # ★ 关闭 Cookie
  is-read-body: false
  is-write-header: false
  is-lasting-cookie: false
  auto-renew: true
  replaced-login-exit-mode: OLD_DEVICE
  replaced-range: ALL_DEVICE_TYPE   # 默认 CURR_DEVICE_TYPE 只顶同设备类型,需改全设备
  max-login-count: -1

Redis 成为登录硬依赖:sa-token-redis-jackson 装配后,Redis 不可用则所有 StpUtil.* 抛异常,无降级实现。运维需保证 Redis 可用性(现有 192.168.0.109:6379)。

4.3 拦截器与白名单

@Configuration
public class SaTokenWebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new SaInterceptor(h -> StpUtil.checkLogin()).isAnnotation(false))
                .addPathPatterns("/**")
                .excludePathPatterns("/auth/login", "/sys/health", "/sys/checkAuth",
                                     "/sys/info", "/sys/code", "/sys/noNet", "/error");
        registry.addInterceptor(new CaseContextInterceptor()).addPathPatterns("/**");
    }
}

白名单写相对 context-path 的路径(Spring 匹配的是已剥离 /js/a 的 lookup path)。 /sys/health、/sys/checkAuth 必须在白名单 —— main.ts 在登录前就调用它们(waitForBackendReady / resolveStartupRoute)。 isAnnotation(false):本项目不用 @SaCheckLogin 注解,关掉省一次注解扫描。 执行顺序:sa-token Filter(order -104) → LicenseFilter(order MAX) → SaInterceptor → Controller。

4.4 未登录 / 被踢的响应契约(必须 HTTP 200 + body.code=401)

前端 transformRequestHook 只在 body.code === 401 时触发跳转;返回 HTTP 401 会走 axios catch 分支, 且 /chat/** 这类 isTransformResponse:false 的裸响应路径处理不同。统一 HTTP 200 + code=401 最稳。

@ExceptionHandler(NotLoginException.class)
public ResponseEntity<Result<Void>> handleNotLogin(NotLoginException e) {
    String msg = switch (e.getType()) {
        case NotLoginException.NOT_TOKEN, NotLoginException.INVALID_TOKEN -> "未登录或登录已失效,请重新登录";
        case NotLoginException.TOKEN_TIMEOUT -> "登录已过期,请重新登录";
        case NotLoginException.BE_REPLACED, NotLoginException.KICK_OUT -> "账号已在其他客户端登录,请重新登录";
        default -> "未登录,请先登录";
    };
    log.info("未登录访问: type={}, uri={}", e.getType(), e.getLoginType());
    return ResponseEntity.ok(Result.failure(401, msg));   // ★ 刻意 HTTP 200
}

这与现有 handleBusinessException 的「HTTP 状态 = 业务码」模式不一致,属认证类异常的刻意例外,需在代码注释中写明。

4.5 接口清单

前缀 /js/a,除标注外均为 Result<T> 包装,token 走 x-token Header。

认证 /auth

方法 路径 入参 出参 说明
POST /auth/login {username, password} Result<{token, userId, username, displayName, isAdmin}> 白名单;StpUtil.login(userId) 后返回 StpUtil.getTokenValue()
POST /auth/logout — Result<Void> StpUtil.logout() + 关该用户案件数据源 + 关其 SSE
GET /auth/info — Result<{userId, username, displayName, isAdmin}> 刷新页面时恢复
POST /auth/changePwd {oldPwd, newPwd} Result<Void> 自助改密

用户管理 /user(仅 is_admin=1,否则 Result.failure(403, "无权限"))

方法 路径 说明
GET /user/list ?keyword=&page=&size= → Result<Page<UserVO>>(不含 pwd_hash)
POST /user/create {username, password, displayName, isAdmin}
POST /user/update {id, displayName, isAdmin, status}
POST /user/resetPwd {id, newPassword}
POST /user/delete 禁止删自己 / 最后一个管理员
POST /user/kickout StpUtil.kickout(id)

案件 /case(改造)

方法 变化
GET /case/list 按 owner_id = 当前用户 过滤
POST /case/create 写 owner_id;重名校验限定本人范围
POST /case/open 去掉 pwd 校验;校验 owner_id;registry.open() + LuceneManager.createWriter(caseId,...) + CaseDataCache.initCache(caseId) + setCaseInfo();不再全局 removeDataSource("slave")
GET /case/exit registry.close(caseId) + cleanCase() + 关该用户 SSE + LuceneManager.close(caseId) + CaseDataCache.cleanCache(caseId);删除"退出后切 TEMP_DB_PATH"的共享逻辑
POST /case/delete 校验 owner_id;删文件前先 registry.close()
GET /case/current 新增:刷新页面用 token session 恢复当前案件

5. 全局单例改造清单

单例 现状 是否串 改造 优先级
StateManager JVM 单例 CaseInfo(StateManager.java:19) 是 门面保留(签名不变 ⇒ 13 个调用点零改动)+ 内部 ThreadLocal 优先 + sa-token Token-Session 兜底 + 显式 set/clear P0
LuceneManager static IndexWriter + if (writer != null) return 是 改 Map<Integer, LuceneHolder>,方法加 caseId 参数 P0
SseService volatile SseEmitter 单连接 是 改 Map<Long userId, SseEmitter> P0
CaseDataCache 全静态,数据源是案件库 person_lib_no 是 按 caseId 隔离:initCache(caseId) / cleanCache(caseId) P0
PythonExecutor StateManager.instance().getCaseId() 在 Reactor 线程执行 是 改显式入参 execute(Integer caseId, String code) P0
SqlResultStore 进程级 LRU 50,注释明写"不做归属隔离" 是 条目加 ownerUserId + caseId,get 校验归属 P1
Agent 会话/消息 chat_session.case_id 已有,但接口不校验归属 部分 入口校验 case.ownerId == 当前用户(防越权传别人 caseId) P1
GlobalCache 平台元数据(table_info/table_field),全案件相同 否 不改 P2
RogueBaseDataCache / GlobalPool / SystemConfManager / PathConst 系统级无状态 否 不改 P2
RocksDBCache 整文件已被注释(死代码) 否 删除文件 P2

5.1 StateManager 改造(签名不变)

@Data
public class StateManager {
    private static final int NO_CASE = 888888;
    private static class Holder { private static final StateManager INSTANCE = new StateManager(); }
    public static StateManager instance() { return Holder.INSTANCE; }

    private final ThreadLocal<CaseInfo> local = new ThreadLocal<>();

    public Integer getCaseId() {
        CaseInfo ci = local.get();
        if (ci != null && ci.getId() != null) return ci.getId();
        Integer fromSession = CaseSessionUtil.caseIdFromTokenSession();   // try/catch + SaHolder 可用性判断
        return fromSession != null ? fromSession : NO_CASE;
    }
    public boolean isCaseOpened() { return local.get() != null || CaseSessionUtil.caseIdFromTokenSession() != null; }

    public void setCaseInfo(CaseInfo ci) {
        local.set(ci);
        CaseSessionUtil.bindCaseIdToTokenSession(ci == null ? null : ci.getId());
    }
    public void cleanCase() {
        local.remove();
        CaseSessionUtil.bindCaseIdToTokenSession(null);
    }
    /** 供异步线程显式绑定(不写 sa-token,避免非请求线程报错) */
    public void bindLocal(CaseInfo ci) { local.set(ci); }
    public void unbindLocal() { local.remove(); }
}

CaseSessionUtil.caseIdFromTokenSession() 必须:

  1. 先判 SaManager.getSaTokenContext().isValid()(→ SpringMVCUtil.isWeb());
  2. 整体 try/catch 吞掉 SaTokenException / IllegalStateException;
  3. 异步线程返回 null,由 CaseContextHolder 的 ThreadLocal 兜底。

调用点处理(已 grep 全量确认 13 处):

  • StrConsts.java:13,17、DmService.java:311,314,717,718、PersonBasicInfoService.java:92 —— 不改代码(请求线程,自动受益)
  • CallStatStationQuery.java:15、CallOftenTop10Query.java:18 的 cacheKey() —— 不改代码。注意:它们是方法(非字段初始化),实时取 caseId;且其 @Cache 注解已被注释 ⇒ 目前是死代码,无实际串案后果
  • PythonExecutor.java:95 —— 必须改(见下)
  • CaseInfoService.java:145,170 —— 改为调 CaseDataSourceRegistry
  • RocksDBCache.java:112 —— 整文件已注释,直接删

5.2 Agent/异步链路的 caseId 传播(最需落地的细节)

事实:

  • AgentChatServiceImpl.doStream 里 caseId 来源是 DB 会话(session.getCaseId()),链路起点已知;
  • 但 FollowupMiddleware:74、InsightServiceImpl:440,462 的 subscribeOn(Schedulers.boundedElastic()) 会切线程 ⇒ ThreadLocal 与 RequestContextHolder 都丢;
  • SqlQueryMapper(@DS("slave"))与 77 个业务工具服务都在 Agent 线程上执行。

方案:在 AgentToolRegistry(77 个工具的唯一收口)与 PythonAnalysisTool 用构建时捕获的 caseId 包裹:

return CaseContextHolder.callWith(agentCaseId, () -> originalTool.call(args));

这样 77 个工具与 SqlQueryMapper 全部无需改动,且不依赖 ThreadLocal 继承。


6. 前端改造清单

# 事项 文件 要点
1 登录页 src/core/layouts/views/login/index.vue(目录已存在但为空) 表单 + 成功后跳 /case(或 query.redirect)
2 登录路由 src/core/router/routes/index.ts 新增 LoginRoute(PageEnum.BASE_LOGIN='/login' 常量已预留)
3 认证 API 新建 src/core/api/auth/*.ts adminPath + '/auth/...'、'/user/...'
4 user store 新建 src/core/store/modules/user.ts(id 用 app-user-info,不复用 app-user) 复用 setAuthCache(TOKEN_KEY/USER_INFO_KEY)
5 token 注入 src/core/utils/http/axios/index.ts:183-194 恢复被注释的注入,authenticationHeader 保持 x-token
6 token 存储 src/core/utils/auth/index.ts、src/core/enums/cacheEnum.ts TOKEN_KEY / USER_INFO_KEY 走 localStorage
7 401 跳转 src/core/utils/http/axios/unauthorized.ts 401 → /login;license 失败改 40301 → /authorization
8 路由守卫 permissionGuard.ts 放行 /login;stateGuard.ts 进入 /login 清用户态 /case 现有 reset 行为保留
9 启动流程 src/main.ts license 检查之后加登录门禁:无 token → /login;/auth/info 401 → /login
10 顶栏用户菜单 新增 header/components/UserDropdown.vue,注册进 header/index.vue 头像 + 用户名 +「修改密码」+「用户管理」(仅 admin) +「退出登录」;LogoutButton(退出当前案件)语义不变
11 用户管理弹框 新增 src/plat/views/user/UserManageModal.vue 复用 BasicModal + BasicTable;不做菜单/角色配置
12 案件列表 src/plat/views/case/case.vue 后端已按 owner 过滤;该页无 Layout Header,需补「退出登录」入口;去掉密码弹窗
13 /chat/** 裸响应 src/ai/api/http.ts、src/ai/api/sse.ts:131-145 RAW 路径也要识别 code===401;streamSse 的 fetch 必须补 'x-token': getToken()(现只有 credentials:'include',与"仅 Header"冲突)
14 /govern/sse src/case/views/data/cleanProgress.vue:648、governApi.ts:44 阻断项:EventSource 无法带 Header ⇒ 改 fetch + ReadableStream(复用 ai/api/sse.ts 思路)
15 死代码清理 layouts/views/account/userInfo.vue、.../WorkbenchHeader.vue、.../LockModal.vue 引用不存在的 userStore.getUserInfo/setUserInfo → 适配或删除

注意:src/core/store/modules/case.ts 的 store id 是历史遗留的 'app-user',新建 user store 时不要撞名。


7. 存量数据迁移

  1. 执行 sql/user.sql:建 sys_user + 索引;插入内置管理员(预生成 BCrypt 哈希)
  2. case_info 加 owner_id + 索引;唯一约束改为 (owner_id, name)
  3. UPDATE case_info SET owner_id = <admin id> WHERE owner_id IS NULL → 现有 5 个案件全部归 admin
  4. 案件库文件路径不变(case_info.db_path 已是绝对路径),无需搬迁
  5. case_info.pwd 字段保留但不再校验
  6. sys_user 属平台表,不需要登记 table_info / table_field
  7. 无 Flyway/Liquibase ⇒ 迁移脚本 + 执行步骤写入 sql/ 与 README,运维手工执行

8. 实施步骤

阶段 内容 验收
0. 数据源地基 CaseContextHolder、CaseRoutingDataSource、DataSourceConfig、CaseDataSourceRegistry、内存/线程预算配置 编译通过;两用户各开一案,getDataSource("slave") 返回不同实例
1. 用户体系 sys_user 表、sa-token 配置、SaTokenWebConfig、CaseContextInterceptor、NotLoginException 处理器、/auth/** + /user/**、SaTokenListener curl 登录拿到 token;无 token 访问返回 {code:401};重复登录踢掉旧 token
2. 案件改造 CaseInfoService.open/exit/create/list 重写、owner_id 过滤、LuceneManager/SseService/CaseDataCache/PythonExecutor/SqlResultStore 改造、AgentToolRegistry 注入 caseId 用户 A 开案 1、用户 B 开案 2 互不干扰;A 退出后 B 正常
3. 前端 登录页、user store、token 注入、401 跳转、顶栏用户菜单、用户管理弹框、案件页去密码、/govern/sse 改 fetch 登录 → 建案 → 开案 → 清洗 → AI 对话全链路
4. 迁移 + 回归 执行 SQL 迁移;回归 71 个 @DS 类涉及的业务域 逐域冒烟(call/trans/track/person/graph/otg/dm/agent)

验证手段(沿用项目既有套路)

  • 后端纯逻辑:target/classes + dependency:build-classpath 跑 main 探针,验证 CaseRoutingDataSource 路由与 CaseDataSourceRegistry 记账(无需 Spring / DB)
  • 前端纯逻辑:esbuild 打包成 CJS 跑 Node 断言(token 注入、401 处理)
  • 端到端:两个浏览器上下文分别登录不同账号,交叉验证案件隔离
  • 注意:8980 上是用户 IDEA Debug 启动的实例,不能 kill;后端改完需提示用户在 IDEA 重启

9. 风险与待验证项

风险

风险 影响 应对
71 个 @DS("slave") 类 零注解改动,但无案件上下文时(启动预热、定时任务)会抛新异常 AppLoadEndEventListener 已刻意不预热 CaseDataCache;SystemService:293-295 用 DriverManager 直连不受影响;异常文案要可读
多案件并存内存/线程 SET GLOBAL 按实例 ⇒ N× 叠加 §3.5 分摊 + max-open-cases 硬上限
跨进程文件锁 同 .duckdb 两进程打开会失败 部署约束:ai-server 单实例,不可水平扩容
Redis 硬依赖 Redis 挂 ⇒ 全站无法登录 运维保证 Redis 可用性
401 语义冲突 license 与登录都用 401 已拆分:license → 40301
AI_AGENT.md §8.4 写的是"鉴权靠 Sa-Token Cookie,前端不注入任何 token 头" 与新决策矛盾 本次改造必须同步更新 AI_AGENT.md §8.4 与 §4.5
顺手修 Result.java:3 脏 import io.milvus.param.R;;SUCCEED_CODE/FAILURE_CODE 非 final 一并清理

待验证(实现阶段实测,不要当成已确认)

  1. SET GLOBAL memory_limit/threads 的作用域是 database instance 还是进程级(依据 DuckDB SET GLOBAL 语义推断为实例级,未读 C++)
  2. jdbc_instance_cache=true 下「关案后能否真正释放内存/文件句柄」—— 若不能,max-open-cases 必须按"累计打开过的案件数"约束,并靠重启释放
  3. sa-token.replaced-range: ALL_DEVICE_TYPE 的键名与枚举取值(需配置绑定实测)
  4. excludePathPatterns 相对 context-path 的行为(依据 Spring UrlPathHelper 语义推断)
  5. Agent/Reactor 线程上 StpUtil / RequestContextHolder / ThreadLocal 的可用性 —— 本方案用「工具收口显式 callWith(caseId)」规避
  6. DynamicDataSourcePoolMetricsAutoConfiguration 与自定义 DataSource Bean 的兼容性
  7. CaseInfoService.create 的 FileUtil.copy(BASE_CASE_DB_PATH, caseDbPath) 是否遗漏 WAL 附属文件
  8. DuckDB 跨进程文件锁的确切报错文案(native 库在 85MB jar 内,未读 C++ 字符串)

10. 关键文件索引

后端(新增)

ai-server/src/main/java/com/zsjz/ai/common/datasource/CaseRoutingDataSource.java
ai-server/src/main/java/com/zsjz/ai/common/datasource/CaseDataSourceRegistry.java
ai-server/src/main/java/com/zsjz/ai/common/context/CaseContextHolder.java
ai-server/src/main/java/com/zsjz/ai/common/config/DataSourceConfig.java
ai-server/src/main/java/com/zsjz/ai/common/config/SaTokenWebConfig.java
ai-server/src/main/java/com/zsjz/ai/common/interceptor/CaseContextInterceptor.java
ai-server/src/main/java/com/zsjz/ai/module/plat/{controller,service}/Auth*.java、User*.java
ai-server/src/main/java/com/zsjz/ai/common/model/plat/{entity,vo,dto}/SysUser*.java
sql/user.sql

后端(改造)

common/utils/StateManager.java、common/utils/LuceneManager.java
common/config/DuckdbUnpooledDataSource.java          (内存/线程预算)
common/exception/GlobalExceptionHandler.java         (NotLoginException)
module/plat/service/CaseInfoService.java             (open/exit/create/list)
module/plat/service/SseService.java、module/plat/controller/CaseInfoController.java
common/cache/CaseDataCache.java、module/agent/sql/SqlResultStore.java
module/agent/python/PythonExecutor.java、module/agent/tools/AgentToolRegistry.java
src/main/resources/application-dev.yaml、application.yaml
AI_AGENT.md                                          (§4.5 / §8.4)

前端(新增)

src/core/layouts/views/login/index.vue
src/core/store/modules/user.ts
src/core/api/auth/*.ts
src/core/layouts/default/header/components/UserDropdown.vue
src/plat/views/user/UserManageModal.vue

前端(改造)

src/core/utils/http/axios/index.ts                   (恢复 token 注入)
src/core/utils/http/axios/unauthorized.ts            (401 → /login)
src/core/router/routes/index.ts、src/core/router/guard/{permissionGuard,stateGuard}.ts
src/main.ts、src/ai/api/sse.ts、src/case/views/data/cleanProgress.vue
src/plat/views/case/case.vue                         (去密码弹窗)
src/core/layouts/default/header/index.vue

附录:关键技术结论的证据索引

dynamic-datasource 4.5.0(D:\soft\repository\com\baomidou\)

  • DynamicRoutingDataSource#getDataSource/determineDataSource/addDataSource/removeDataSource/addGroupDataSource/getDataSources
  • DynamicDataSourceAnnotationInterceptor#determineDatasourceKey、DataSourceClassResolver#computeDatasource/getMapperInterfaceClass
  • DynamicDataSourceAutoConfiguration#dataSource(@ConditionalOnMissingBean)
  • DynamicDatasourceAopProperties(order = Ordered.HIGHEST_PRECEDENCE)
  • DsJakartaSessionProcessor(取 Servlet session,不是 sa-token 会话)

Spring Framework 6.2.19

  • AbstractAutowireCapableBeanFactory#postProcessObjectFromFactoryBean(第 1944 行,证明 FactoryBean 产物会被自动代理 ⇒ MyBatis Mapper 上的 @DS 生效)

sa-token 1.46.0(D:\soft\repository\cn\dev33\)

  • SaTokenConfig(默认值)、NotLoginException(type 常量 -1…-7)
  • SaTokenDaoForRedisTemplate#init、SaBeanInject#setSaTokenDao
  • ApplicationInfo#cutPathPrefix(context-path 自动剥离)、SaTokenConsts.SA_TOKEN_CONTEXT_FILTER_ORDER = -104

DuckDB 1.5.5.1(D:\soft\repository\org\duckdb\duckdb_jdbc\1.5.5.1\)

  • DuckDBDriver#JDBC_INSTANCE_CACHE、DuckDBConnection#newConnection(cache_instance 默认 true)
  • DuckDBConnection#duplicate(connRefLock)、getTransactionIsolation(TRANSACTION_REPEATABLE_READ)
  • DuckDBAppender#ownerThreadId(线程封闭)、flush()(duckdb_appender_flush)