# 清鉴(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` | 核心逻辑: ```java @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; } ``` ```java @Configuration public class DataSourceConfig { @Bean public DataSource dataSource(List 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()`: ```sql SET GLOBAL memory_limit='<物理内存×0.3>MiB'; SET GLOBAL threads TO ; ``` `SET GLOBAL` 是 **database instance 级**,N 个案件同时打开 ⇒ **N × 30% 内存 + N × CPU核数 线程**,直接打爆内存与 CPU。 改造:新增配置,按"最大同时开案数"分摊。 ```yaml 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 方言) ```sql 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` 新增) ```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 拦截器与白名单 ```java @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` 最稳。** ```java @ExceptionHandler(NotLoginException.class) public ResponseEntity> 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` 包装,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` | `StpUtil.logout()` + 关该用户案件数据源 + 关其 SSE | | GET | `/auth/info` | — | `Result<{userId, username, displayName, isAdmin}>` | 刷新页面时恢复 | | POST | `/auth/changePwd` | `{oldPwd, newPwd}` | `Result` | 自助改密 | **用户管理 `/user`(仅 `is_admin=1`,否则 `Result.failure(403, "无权限")`)** | 方法 | 路径 | 说明 | |---|---|---| | GET | `/user/list` | `?keyword=&page=&size=` → `Result>`(**不含 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`,方法加 `caseId` 参数 | **P0** | | `SseService` | `volatile SseEmitter` 单连接 | **是** | 改 `Map` | **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` 改造(签名不变) ```java @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 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 包裹: ```java 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 = 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`)