状态:已实施(阶段 0~3 完成,数据库迁移已执行,已端到端验证) 目标:为现有单机/客户端形态的服务端补上「多用户同时使用」能力。
实施要点与踩坑记录见
.workbuddy-ai/memory/2026-09-18.md。 迁移脚本sql/user.sql已在192.168.0.109:5432/zsjz-ai执行; 内置账号 admin / admin123(首次登录后请立即改密)。
现状是客户端应用设计,多处全局单例假设"服务端只服务一个人":
| 位置 | 现状 | 多人并发后果 |
|---|---|---|
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 | 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 语义让给"未登录" |
┌───────────────────────────────┐
浏览器 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) 注解一个字都不用改。
| 名称 | 含义 |
|---|---|
master |
PG,primary,永不切换 |
slave |
逻辑 key,只出现在 @DS 与 getDataSource() 参数里,不注册为真实数据源 |
case{caseId} |
物理 key(如 case12),真实注册进 dataSourceMap |
警告:物理 key 禁止含下划线。
DynamicRoutingDataSource#addGroupDataSource会把_前的部分当组名。用case_12会额外生成组case, 后续getDataSource("case")走负载均衡策略 → 随机串库,且极难排查。
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 会话 |
| 类 | 包 | 职责 | 关键方法 |
|---|---|---|---|
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 报关系不存在"的坑。
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 个,缺一不可)
/case/exit/auth/logoutSaTokenListener#doReplaced/doKicked@Scheduled 扫描孤儿案件(owner 已无有效 token)兜底释放现状 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 核数)
@DS 与 @Transactional 共存规则(写进开发约定)@DS 的 Advisor order = Ordered.HIGHEST_PRECEDENCE(最外层通知)。@DS 必须在 @Transactional 外层 —— 否则事务开启时连接已通过 AbstractRoutingDataSource#getConnection() 绑定,内层再切无效。AbstractDataLoader 手写 Appender、DmService 手取 Connection)未使用 Spring 事务,当前影响面小;新增的用户/案件接口会用到 @Transactional,须遵守上一条。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/ 目录。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,仓库不放明文密码。
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)。
@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。
前端 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 状态 = 业务码」模式不一致,属认证类异常的刻意例外,需在代码注释中写明。
前缀 /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 恢复当前案件 |
| 单例 | 现状 | 是否串 | 改造 | 优先级 |
|---|---|---|---|---|
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 |
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() 必须:
SaManager.getSaTokenContext().isValid()(→ SpringMVCUtil.isWeb());try/catch 吞掉 SaTokenException / IllegalStateException;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 —— 改为调 CaseDataSourceRegistryRocksDBCache.java:112 —— 整文件已注释,直接删事实:
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 继承。
| # | 事项 | 文件 | 要点 |
|---|---|---|---|
| 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 时不要撞名。
sql/user.sql:建 sys_user + 索引;插入内置管理员(预生成 BCrypt 哈希)case_info 加 owner_id + 索引;唯一约束改为 (owner_id, name)UPDATE case_info SET owner_id = <admin id> WHERE owner_id IS NULL → 现有 5 个案件全部归 admincase_info.db_path 已是绝对路径),无需搬迁case_info.pwd 字段保留但不再校验sys_user 属平台表,不需要登记 table_info / table_fieldsql/ 与 README,运维手工执行| 阶段 | 内容 | 验收 |
|---|---|---|
| 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)| 风险 | 影响 | 应对 |
|---|---|---|
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 |
一并清理 |
SET GLOBAL memory_limit/threads 的作用域是 database instance 还是进程级(依据 DuckDB SET GLOBAL 语义推断为实例级,未读 C++)jdbc_instance_cache=true 下「关案后能否真正释放内存/文件句柄」—— 若不能,max-open-cases 必须按"累计打开过的案件数"约束,并靠重启释放sa-token.replaced-range: ALL_DEVICE_TYPE 的键名与枚举取值(需配置绑定实测)excludePathPatterns 相对 context-path 的行为(依据 Spring UrlPathHelper 语义推断)StpUtil / RequestContextHolder / ThreadLocal 的可用性 —— 本方案用「工具收口显式 callWith(caseId)」规避DynamicDataSourcePoolMetricsAutoConfiguration 与自定义 DataSource Bean 的兼容性CaseInfoService.create 的 FileUtil.copy(BASE_CASE_DB_PATH, caseDbPath) 是否遗漏 WAL 附属文件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/getDataSourcesDynamicDataSourceAnnotationInterceptor#determineDatasourceKey、DataSourceClassResolver#computeDatasource/getMapperInterfaceClassDynamicDataSourceAutoConfiguration#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#setSaTokenDaoApplicationInfo#cutPathPrefix(context-path 自动剥离)、SaTokenConsts.SA_TOKEN_CONTEXT_FILTER_ORDER = -104DuckDB 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)