2 Commits

Author SHA1 Message Date
wanghanlin 0838dca61a fix(auth): 修复后台会话长期不失效,JWT 时间配置改用可读单位 1 month ago
wanghanlin 2001dc3a0f fix(doc): 修复重新处理文档会按 2000 字截断预览重建导致内容丢失 1 month ago
  1. 17
      CLAUDE.md
  2. 2
      README.md
  3. 9
      frontend/src/App.vue
  4. 2
      frontend/src/views/DocDetail.vue
  5. 13
      src/main/java/com/wok/supportbot/auth/AuthController.java
  6. 2
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  7. 12
      src/main/java/com/wok/supportbot/controller/AuthController.java
  8. 31
      src/main/java/com/wok/supportbot/controller/DocumentController.java
  9. 8
      src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java
  10. 21
      src/main/java/com/wok/supportbot/security/JwtTokenProvider.java
  11. 28
      src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java
  12. 87
      src/main/java/com/wok/supportbot/service/DocumentService.java
  13. 15
      src/main/resources/application.yml
  14. 2
      src/main/resources/init-database.sql
  15. 2
      src/main/resources/knowledge-base.sql

17
CLAUDE.md

@ -71,6 +71,18 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
**向量化加固**(`DocumentProcessingService`):逐批 try-catch 隔离,失败批只记录缺失区间后继续,已入库块保留;`chunk_count` 记实际入库块数,`error_message` 聚合"已入库 x/y 块 + 缺失区间 + 原因";文档级失败自动整体重试至多 2 次(仅瞬时/限流/超时类错误,4xx 不空转),重试前先清残留向量再重建。**无逐块 AI 关键词提取环节**(`MyKeywordEnricher` 已移除,其产出 `excerpt_keywords` 全库无检索消费点)。
**`content` 列存全文 + 重新处理的数据源规则**(`DocumentService`,修复了「重跑丢内容」缺陷):
| 项 | 规则 |
|---|---|
| 存储 | `knowledge_document.content` 存**原文全文**(不再截断),并写 `extra_config.contentComplete = true` 作为完整性标记 |
| 序列化 | 实体 `content` 标 `@JsonIgnore`,**任何接口都不返回全文**(否则列表接口每行都带整篇正文)。仅文档详情接口(`DocumentController.toDetailMap`)返回 2000 字预览(键名仍是 `content`)+ `contentTruncated` 布尔标志 |
| 重新处理的数据源 | ① 有 `contentComplete` 标记 → 用库内全文(语义最忠实,如 JSON 的 fields/pointer 模式)② 历史遗留文档(无标记,content 是 2000 字截断预览)→ 按 `fileType` 从原始文件重解析,成功后**回填全文并打标记**(此后不再依赖文件)③ 两者都不可用 → **明确报错拒绝** |
**踩坑记录**:`reprocessDocument` 原先直接 `simpleStringDocumentReader.read(doc.getContent())`,而 `content` 是 2000 字截断预览,且 `DocumentProcessingService` 以 `cleanBeforeFirstAttempt=true` 运行(**先删光旧向量**)—— 对超过 2000 字符的文档重跑会永久丢失其余内容。修复前**不要**对存量文档执行 `POST /document/batch/reprocess`。判断遗留文档:`extra_config->>'contentComplete' IS NULL`;其中 `length(content) = 2000` 的才是真正被截断的。
**JSON 解析模式的已知降级**:JSON 的 basic/fields/pointer 三种模式上传时未持久化,历史 JSON 文档从文件重解析只能按 basic 还原(不丢数据,仅抽取口径可能变化,日志有 WARN)。新文档走库内全文分支,语义不变。
## 关键配置
@ -78,6 +90,11 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- **模型名称、温度、最大 Token 等参数已全部迁移到前端「AI 大模型配置管理」页面**,通过 `ai_model_config` 表管理,不再在 yml 中配置(yml 仅保留 `api-key`)
- MyBatis Plus 逻辑删除字段: `isDelete`,主键策略: `assign_id`(雪花算法)
- **雪花 ID 精度问题**: `KnowledgeDocument.id`、`categoryId` 和 `KnowledgeCategory.id`、`parentId` 已添加 `@JsonSerialize(using = ToStringSerializer.class)`,序列化为字符串避免前端 JS 精度丢失。新增 Long ID 字段时务必加上此注解
- **JWT 时间配置统一用 Duration 可读格式**: `jwt.expiration` / `jwt.refresh-expiration` / `jwt.sdk-expiration` 均绑定为 `java.time.Duration`(`@Value` 直绑),值写成 `15m` / `8h` / `900s` 等可读形式,**纯数字仍按毫秒解析**(向后兼容旧配置)。项目同类先例:`storage.sftp.connect-timeout: 10s`(`StorageProperties`)。新增时间类配置项时照此办理,不要写裸毫秒。
- **管理后台会话有效期**: refresh token 有效期由 `jwt.refresh-expiration` **唯一**驱动(`application.yml`,当前 `8h`)。该配置**全环境统一生效**,不在 `application-dev/prod.yml` 中覆盖;`JwtTokenProvider` 构造器上的 `:8h` 仅为代码级兜底。**refresh Cookie 的 Max-Age 必须经 `jwtTokenProvider.getRefreshExpirationSeconds()` 取值,禁止硬编码**(`AuthController` 登录与刷新两处),否则「改配置不改 Cookie」会造成有效期漂移。
- 语义为**滚动续期**:access token 15 分钟过期后前端静默调 `/auth/refresh`,服务端重签并重置窗口。因此**页面持续活跃的用户不会掉线**,只有闲置超过该时长(含关闭页面超过该时长后重开,refresh Cookie 已过期)才需重新登录。
- 相关前端链路:`App.vue onMounted` 启动自检(`/auth/me` → 失败则 `tryRefreshToken` → **再校验一次 `/auth/me`**,仍失败即登出)+ `api/request.ts` 的 401 自动刷新重试(single-flight + 重试上限 `_retry`)。
- **SDK Token 有效期**: `jwt.sdk-expiration`(当前 `2h`)作为 SDK 换 Token 接口(`POST /open-api/auth/token`,SDK 版 `controller/AuthController`)**未指定 ttl 时的默认值**,由 `SdkJwtTokenProvider.getDefaultExpirationMillis()` 提供。有效期边界 `[5min, 24h]` 只在 `SdkJwtTokenProvider` 的 `MIN_EXPIRATION` / `MAX_EXPIRATION` 两处常量定义,控制器通过 `clampExpirationMillis()` 复用,**不得在控制器内重复写毫秒魔数**。注意 SDK 对外的 `ttl` 请求参数与 `expiresIn` 响应字段单位是**秒**(见 `SDK-INTEGRATION.md`),与内部毫秒配置是两套单位,勿混淆。
- PostgreSQL JSONB 字段使用自定义 `PostgresJsonTypeHandler`(期望 JSON 对象 `'{}'`,非数组 `'[]'`)
- **向量维度**: 由 `knowledge.vector.dimension` 配置(默认 1024)。修改后需执行 `DROP TABLE IF EXISTS vector_store CASCADE` 重建向量表,并重新上传知识库文档。距离类型: COSINE_DISTANCE,索引: HNSW
- **分块配置**: `knowledge.chunk.*` 配置项(`ChunkConfig`),默认 chunkSize=200, overlap=100, minChunkSizeChars=10, maxNumChunks=5000, keepSeparator=true

2
README.md

@ -94,7 +94,7 @@ CREATE TABLE knowledge_document (
source_name VARCHAR(500), -- 原始文件名
file_type VARCHAR(20) NOT NULL, -- 文件类型
file_size BIGINT DEFAULT 0, -- 文件大小(字节)
content TEXT, -- 原文内容(截断预览)
content TEXT, -- 原文全文(重处理用;接口仅返回 2000 字预览)
category_id BIGINT DEFAULT 0, -- 所属分类ID
tags JSONB DEFAULT '{}', -- 标签(JSON对象)
chunk_count INTEGER DEFAULT 0, -- 分块数量

9
frontend/src/App.vue

@ -33,10 +33,15 @@ onMounted(async () => {
if (isLoggedIn()) {
const ok = await auth.fetchUser()
if (!ok) {
// 尝试刷新 Token
// access token 已过期,尝试用 refresh cookie 换取新 token
const refreshed = await auth.tryRefreshToken()
if (refreshed) {
await auth.fetchUser().catch(() => {})
// 刷新成功后必须再校验一次用户信息:/auth/me 仍失败说明账号已被禁用等,
// 此时不能放行进后台(否则 currentUser 为空、权限判断全部失效)
const okAfterRefresh = await auth.fetchUser()
if (!okAfterRefresh) {
await auth.doLogout()
}
} else {
await auth.doLogout()
}

2
frontend/src/views/DocDetail.vue

@ -26,7 +26,7 @@
<div v-if="doc.content" class="raw-content">
<div class="raw-header">
<span class="raw-title">{{ doc.sourceName || doc.title || '原文' }}</span>
<t-tag v-if="doc.content && doc.content.length >= 2000" size="small" theme="warning" variant="light">内容已截断(仅显示前2000字符)</t-tag>
<t-tag v-if="doc.contentTruncated" size="small" theme="warning" variant="light">内容已截断(仅显示前2000字符)</t-tag>
</div>
<pre class="raw-body">{{ doc.content }}</pre>
</div>

13
src/main/java/com/wok/supportbot/auth/AuthController.java

@ -103,7 +103,7 @@ public class AuthController {
loginAttemptService.recordSuccess(username);
Map<String, Object> userInfo = buildUserInfo(user, roleKeys);
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, 7 * 24 * 60 * 60L, request.isSecure());
ResponseCookie refreshCookie = buildRefreshCookie(refreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
log.info("用户登录成功: {}", username);
return ResponseEntity.ok()
@ -164,7 +164,7 @@ public class AuthController {
String newAccessToken = jwtTokenProvider.generateToken(username, roleKeys, currentVer);
String newRefreshToken = jwtTokenProvider.generateRefreshToken(username, currentVer);
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, 7 * 24 * 60 * 60L, request.isSecure());
ResponseCookie refreshCookie = buildRefreshCookie(newRefreshToken, refreshCookieMaxAgeSeconds(), request.isSecure());
return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
@ -279,6 +279,15 @@ public class AuthController {
return userInfo;
}
/**
* refresh cookie 的 Max-Age(秒)
* 直接取自 jwt.refresh-expiration 配置,保证 Cookie 生命周期与 refresh token 完全一致,
* 避免两处硬编码后「改配置不改 Cookie」导致的有效期漂移
*/
private long refreshCookieMaxAgeSeconds() {
return jwtTokenProvider.getRefreshExpirationSeconds();
}
/**
* 构建 refresh token Cookie。
* Secure 属性按「配置开关 && 当前请求是否 HTTPS」动态决定:

2
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -1500,7 +1500,7 @@ public class DatabaseInitConfig {
executeComment("COLUMN knowledge_document.source_name", "原始文件名");
executeComment("COLUMN knowledge_document.file_type", "文件类型: pdf / md / json / txt / word / excel 等");
executeComment("COLUMN knowledge_document.file_size", "文件大小(字节)");
executeComment("COLUMN knowledge_document.content", "原文内容(截断预览)");
executeComment("COLUMN knowledge_document.content", "原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)");
executeComment("COLUMN knowledge_document.category_id", "所属分类 ID(0 表示未分类)");
executeComment("COLUMN knowledge_document.folder_id", "所属目录 ID(0 表示未指定目录,直接挂分类根)");
executeComment("COLUMN knowledge_document.tags", "标签(JSON 格式)");

12
src/main/java/com/wok/supportbot/controller/AuthController.java

@ -88,12 +88,12 @@ public class AuthController {
.map(RoleBrief::id)
.collect(Collectors.toList());
// 4. 计算过期时间(默认 2 小时,钳制到 [5min, 24h])
long ttlMs = 7200000L;
if (body != null && body.ttl() != null) {
ttlMs = Math.max(body.ttl() * 1000L, 300000L);
ttlMs = Math.min(ttlMs, 86400000L);
}
// 4. 计算过期时间:调用方指定 ttl(秒)则用指定值,否则取 jwt.sdk-expiration 配置的默认值
// 再统一按 [5min, 24h] 钳制,保证返回的 expiresIn 与 token 实际有效期一致
long ttlMs = (body != null && body.ttl() != null)
? body.ttl() * 1000L
: sdkJwtTokenProvider.getDefaultExpirationMillis();
ttlMs = sdkJwtTokenProvider.clampExpirationMillis(ttlMs);
// 5. 签发 SDK JWT(subject = apiKeyId)
String token = sdkJwtTokenProvider.generateToken(

31
src/main/java/com/wok/supportbot/controller/DocumentController.java

@ -1,5 +1,7 @@
package com.wok.supportbot.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.entity.CategoryNode;
import com.wok.supportbot.entity.KnowledgeCategory;
import com.wok.supportbot.entity.KnowledgeDocument;
@ -30,6 +32,11 @@ public class DocumentController {
private DocumentService documentService;
@Autowired
private CategoryFilter categoryFilter;
@Autowired
private ObjectMapper objectMapper;
/** 文档详情接口返回的原文预览上限(字符)。仅影响传输展示,不影响存库与重新处理 */
private static final int CONTENT_PREVIEW_LIMIT = 2000;
// ==================== 上传校验常量 ====================
@ -384,7 +391,7 @@ public class DocumentController {
}
return ResponseEntity.ok(Map.of(
"success", true,
"data", doc
"data", toDetailMap(doc)
));
} catch (Exception e) {
return ResponseEntity.status(500).body(Map.of(
@ -394,6 +401,28 @@ public class DocumentController {
}
}
/**
* 组装文档详情响应。
* <p>
* 实体上的 {@code content} 带 {@code @JsonIgnore}(存的是全文,可达数十万字符,不能直接返回给前端),
* 此处显式补回键名仍为 {@code content} 的**截断预览**,并附 {@code contentTruncated} 标志供前端提示。
* 预览仅用于展示,与存库内容、与重新处理的数据源无关。
*/
private Map<String, Object> toDetailMap(KnowledgeDocument doc) {
Map<String, Object> data = objectMapper.convertValue(doc, new TypeReference<Map<String, Object>>() {});
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 历史遗留文档没有 contentComplete 标记,其 content 是 2000 字截断预览;
// 长度恰好等于上限时也必须提示已截断,否则会把截断预览误报成完整内容
boolean truncated = content != null
&& (content.length() > CONTENT_PREVIEW_LIMIT
|| (content.length() == CONTENT_PREVIEW_LIMIT && !contentComplete));
data.put("content", truncated ? content.substring(0, CONTENT_PREVIEW_LIMIT) : content);
data.put("contentTruncated", truncated);
return data;
}
/**
* 获取文档的所有分块
*/

8
src/main/java/com/wok/supportbot/entity/KnowledgeDocument.java

@ -1,6 +1,7 @@
package com.wok.supportbot.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.wok.supportbot.handler.PostgresJsonTypeHandler;
@ -63,9 +64,14 @@ public class KnowledgeDocument implements Serializable {
private String filePath;
/**
* 原文内容(截断预览)
* 原文全文(用于重新处理时重建分块)。
* <p>
* {@code @JsonIgnore}:全文可达数十万字符,**任何接口都不序列化**,避免列表/回显接口携带整篇正文。
* 文档详情接口(DocumentController.getDocumentDetail)单独组装响应,返回截断后的预览
* (键名仍为 {@code content})与 {@code contentTruncated} 标志。
*/
@TableField("content")
@JsonIgnore
private String content;
/**

21
src/main/java/com/wok/supportbot/security/JwtTokenProvider.java

@ -10,6 +10,7 @@ import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
@ -25,14 +26,14 @@ public class JwtTokenProvider {
public static final String DEFAULT_SECRET = "support-bot-jwt-secret-key-2026-please-change-in-production";
private final SecretKey key;
private final long expiration;
private final long refreshExpiration;
private final Duration expiration;
private final Duration refreshExpiration;
private final boolean failIfDefaultSecret;
public JwtTokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.expiration:900000}") long expiration,
@Value("${jwt.refresh-expiration:604800000}") long refreshExpiration,
@Value("${jwt.expiration:15m}") Duration expiration,
@Value("${jwt.refresh-expiration:8h}") Duration refreshExpiration,
@Value("${jwt.fail-if-default-secret:false}") boolean failIfDefaultSecret) {
if (DEFAULT_SECRET.equals(secret)) {
String message = "⚠️ 管理后台 JWT 使用了默认密钥,存在安全风险!请在 application.yml 中配置 jwt.secret 为强随机字符串(建议通过环境变量 JWT_SECRET 注入)";
@ -63,7 +64,7 @@ public class JwtTokenProvider {
.claim("type", "access")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + expiration))
.expiration(new Date(now.getTime() + expiration.toMillis()))
.signWith(key)
.compact();
}
@ -78,11 +79,19 @@ public class JwtTokenProvider {
.claim("type", "refresh")
.claim("ver", tokenVersion)
.issuedAt(now)
.expiration(new Date(now.getTime() + refreshExpiration))
.expiration(new Date(now.getTime() + refreshExpiration.toMillis()))
.signWith(key)
.compact();
}
/**
* 刷新令牌有效期(秒)
* 供 refresh Cookie 的 Max-Age 复用,保证 Cookie 与 refresh token 的生命周期始终一致
*/
public long getRefreshExpirationSeconds() {
return refreshExpiration.toSeconds();
}
/**
* 验证 access token 有效性(校验签名、过期时间,且 type 必须为 access)
*/

28
src/main/java/com/wok/supportbot/security/SdkJwtTokenProvider.java

@ -12,6 +12,7 @@ import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Date;
import java.util.List;
import java.util.Set;
@ -31,19 +32,19 @@ import java.util.stream.Collectors;
public class SdkJwtTokenProvider {
private final SecretKey key;
private final long defaultExpiration;
private final Duration defaultExpiration;
/** 24 小时上限 */
private static final long MAX_EXPIRATION = 86400000L;
private static final Duration MAX_EXPIRATION = Duration.ofDays(1);
/** 5 分钟下限 */
private static final long MIN_EXPIRATION = 300000L;
private static final Duration MIN_EXPIRATION = Duration.ofMinutes(5);
/** 默认密钥标识(禁止使用),长度 ≥64 字符以兼容 HS256/HS384/HS512,且必须与 application.yml 默认值一致 */
private static final String DEFAULT_SECRET = "support-bot-sdk-jwt-secret-2026-please-change";
public SdkJwtTokenProvider(
@Value("${jwt.sdk-secret:support-bot-sdk-jwt-secret-2026-please-change}") String sdkSecret,
@Value("${jwt.sdk-expiration:7200000}") long defaultExpiration,
@Value("${jwt.sdk-expiration:2h}") Duration defaultExpiration,
@Value("${jwt.fail-if-default-sdk-secret:false}") boolean failIfDefaultSdkSecret) {
// 空值校验:密钥为空/空白时启动失败并给出明确提示,避免 Keys.hmacShaKeyFor 抛出晦涩异常
if (!StringUtils.hasText(sdkSecret)) {
@ -70,7 +71,7 @@ public class SdkJwtTokenProvider {
* @return JWT Token 字符串
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds, long expirationMs) {
long clampedExpiration = clampExpiration(expirationMs);
long clampedExpiration = clampExpirationMillis(expirationMs);
Date now = new Date();
return Jwts.builder()
.subject(apiKeyId)
@ -86,7 +87,15 @@ public class SdkJwtTokenProvider {
* 使用默认过期时间签发 SDK JWT Token
*/
public String generateToken(String apiKeyId, String maskedApiKey, List<Long> roleIds) {
return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration);
return generateToken(apiKeyId, maskedApiKey, roleIds, defaultExpiration.toMillis());
}
/**
* 默认有效期(毫秒),取自 jwt.sdk-expiration 配置
* 供 SDK 版 AuthController 在调用方未指定 ttl 时作为默认值使用
*/
public long getDefaultExpirationMillis() {
return defaultExpiration.toMillis();
}
/**
@ -151,9 +160,10 @@ public class SdkJwtTokenProvider {
}
/**
* 将过期时间钳制到 [5min, 24h] 范围内
* 将过期时间钳制到 [5min, 24h] 范围内(毫秒)
* 公开供 SDK 版 AuthController 复用,使钳制边界只有一处定义,避免响应值与 token 实际有效期不一致
*/
private long clampExpiration(long expirationMs) {
return Math.max(MIN_EXPIRATION, Math.min(expirationMs, MAX_EXPIRATION));
public long clampExpirationMillis(long expirationMs) {
return Math.max(MIN_EXPIRATION.toMillis(), Math.min(expirationMs, MAX_EXPIRATION.toMillis()));
}
}

87
src/main/java/com/wok/supportbot/service/DocumentService.java

@ -22,6 +22,7 @@ import org.springframework.core.io.FileSystemResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -122,6 +123,9 @@ public class DocumentService {
Map<String, Object> extraConfig = new HashMap<>();
if (chunkSize != null) extraConfig.put("chunkSize", chunkSize);
if (overlap != null) extraConfig.put("overlap", overlap);
// 完整性标记:本字段起 content 存全文(不再截断),可安全用于 reprocess。
// 历史数据无此标记(其 content 是 2000 字截断预览),reprocess 据此走「从原始文件重解析」或明确拒绝。
extraConfig.put("contentComplete", true);
// 2. 创建文档记录(状态 PROCESSING)
KnowledgeDocument docRecord = KnowledgeDocument.builder()
@ -130,13 +134,13 @@ public class DocumentService {
.fileType(fileType)
.fileSize(fileSize != null ? fileSize : 0L)
.filePath(filePath)
.content(content != null && content.length() > 2000 ? content.substring(0, 2000) : content)
.content(content)
.categoryId(categoryId != null ? categoryId : 0L)
.folderId(folderId != null ? folderId : 0L)
.tags(tags != null ? Map.of("tags", tags) : null)
.contentHash(contentHash)
.enabled(true)
.extraConfig(extraConfig.isEmpty() ? null : extraConfig)
.extraConfig(extraConfig)
.status("PROCESSING")
.chunkCount(0)
.build();
@ -663,6 +667,9 @@ public class DocumentService {
/**
* 重新处理文档(异步:先标记为 PROCESSING,后台重新分块 + 向量化)
* <p>
* 数据源解析见 {@link #loadSourceDocuments}:优先用库内全文,历史遗留文档回退到原始文件重解析,
* 两者都不可用时明确报错拒绝 —— 绝不静默按截断预览重建(会先删旧向量,属于不可逆的数据丢失)。
*/
@Transactional(rollbackFor = Exception.class)
public KnowledgeDocument reprocessDocument(Long id) {
@ -670,8 +677,20 @@ public class DocumentService {
if (doc == null) {
throw new RuntimeException("文档不存在");
}
if (doc.getContent() == null || doc.getContent().isEmpty()) {
throw new RuntimeException("文档无内容,无法重新处理");
// 先解析数据源:无法完整还原时直接抛错(事务回滚),不会破坏已有向量与文档状态
SourceLoad loaded = loadSourceDocuments(doc);
List<Document> documents = loaded.documents();
// 历史遗留文档从原始文件重解析成功后,把全文回填进 content 并打上完整性标记:
// 该文档此后不再依赖原始文件(文件丢失也能重跑),详情接口的 contentTruncated 也恢复准确
if (loaded.reparsedFromFile()) {
doc.setContent(documents.stream().map(Document::getText).collect(Collectors.joining("\n")));
Map<String, Object> extra = doc.getExtraConfig() != null
? new HashMap<>(doc.getExtraConfig())
: new HashMap<>();
extra.put("contentComplete", true);
doc.setExtraConfig(extra);
}
// 标记为 PROCESSING,清空旧状态
@ -680,14 +699,70 @@ public class DocumentService {
doc.setErrorMessage(null);
documentMapper.updateById(doc);
// 解析内容并触发异步处理
List<Document> documents = simpleStringDocumentReader.read(doc.getContent());
// 触发异步处理
documentProcessingService.reprocessDocumentAsync(id, documents);
log.info("文档重新处理已提交: id={}, title={}", doc.getId(), doc.getTitle());
return doc;
}
/** 重新处理的数据源解析结果 */
private record SourceLoad(List<Document> documents, boolean reparsedFromFile) {}
/**
* 解析重新处理所需的数据源。
* <ol>
* <li><b>库内全文优先</b>:新文档 content 存的是全文(含 extraConfig.contentComplete 标记),
* 用它可保持与首次上传完全一致的抽取语义(例如 JSON 的 fields/pointer 模式),且不依赖文件是否还在。</li>
* <li><b>回退原始文件</b>:历史遗留文档的 content 是 2000 字截断预览,此时按 fileType 重新解析原始文件。</li>
* <li><b>明确拒绝</b>:两者都不可用时抛错,绝不静默用截断预览重建(reprocess 会先删旧向量,属不可逆丢失)。</li>
* </ol>
*/
private SourceLoad loadSourceDocuments(KnowledgeDocument doc) {
String content = doc.getContent();
boolean contentComplete = doc.getExtraConfig() != null
&& Boolean.TRUE.equals(doc.getExtraConfig().get("contentComplete"));
// 1) 库内全文优先
if (contentComplete && StringUtils.hasText(content)) {
return new SourceLoad(simpleStringDocumentReader.read(content), false);
}
// 2) 历史遗留文档:从原始文件按 fileType 重解析
if (StringUtils.hasText(doc.getFilePath())) {
java.io.File file = fileStorageConfig.getFilePath(doc.getFilePath()).toFile();
if (file.exists()) {
log.warn("库内 content 为历史截断数据,改为从原始文件重解析: id={}, fileType={}, path={}",
doc.getId(), doc.getFileType(), doc.getFilePath());
return new SourceLoad(parseFromFile(doc.getFileType(), file), true);
}
log.warn("原始文件不存在,无法完整重解析: id={}, path={}", doc.getId(), doc.getFilePath());
}
// 3) 无法完整还原
throw new RuntimeException("该文档为历史遗留数据(库内仅存截断预览,原始文件也已丢失),"
+ "无法完整重新处理,请重新上传该文档");
}
/**
* 按上传时的文件类型选择读取器,与各 uploadXxx 保持同一条解析路径。
* <p>
* 注意 fileType 是原始扩展名(pdf/docx/csv/html/…),故用 default 兜到 Tika,仅 md/json 单独分支。
* 已知降级:JSON 的三种解析模式(basic/fields/pointer)上传时未持久化,历史文档只能按 basic 还原
* (不丢数据,仅抽取口径可能变化),日志会 WARN 提示。
*/
private List<Document> parseFromFile(String fileType, java.io.File file) {
String type = fileType == null ? "" : fileType.toLowerCase();
return switch (type) {
case "md", "markdown" -> markdownDocumentLoader.loadMarkdownFromFile(file);
case "json" -> {
log.warn("历史 JSON 文档未持久化解析模式(basic/fields/pointer),按 basic 重解析: {}", file.getName());
yield jsonDocumentLoader.loadBasicJsonFromFile(file);
}
default -> new TikaDocumentReader(new FileSystemResource(file)).get();
};
}
/**
* 更新文档元信息
*/

15
src/main/resources/application.yml

@ -131,10 +131,13 @@ logging:
jwt:
# 签名密钥(生产环境务必通过环境变量 JWT_SECRET 覆盖)
secret: ${JWT_SECRET:support-bot-jwt-secret-key-2026-please-change-in-production}
# 访问令牌有效期:15分钟(毫秒),降低 XSS 窃取后的利用窗口
expiration: 900000
# 刷新令牌有效期:7天(毫秒)
refresh-expiration: 604800000
# 访问令牌有效期:15分钟,降低 XSS 窃取后的利用窗口
# 单位写法:支持 15m / 900s / 900000ms 等可读格式;纯数字按毫秒解析(向后兼容旧配置)
expiration: 15m
# 刷新令牌有效期:8小时。关闭页面超过该时长后重新打开需重新登录。
# 这是会话时长的唯一配置项,所有环境统一生效;refresh Cookie 的 Max-Age 由 AuthController 读取本项自动跟随,不得硬编码。
# 注意:保留滚动续期 —— 页面持续活跃的用户每 15 分钟会静默刷新一次,窗口随之重置,不会掉线。
refresh-expiration: 8h
# refresh token cookie 是否标记 Secure(作为总开关;实际 Secure 属性由 AuthController 按请求是否 HTTPS 动态决定,HTTP 自动降级为 false)
refresh-cookie-secure: true
# 使用默认密钥时是否直接启动失败(生产建议 true)
@ -144,8 +147,8 @@ jwt:
sdk-secret: ${JWT_SDK_SECRET:support-bot-sdk-jwt-secret-2026-please-change}
# 使用默认 SDK 密钥时是否直接启动失败(生产建议 true)
fail-if-default-sdk-secret: false
# SDK Token 默认有效期:2小时(毫秒)
sdk-expiration: 7200000
# SDK Token 默认有效期:2小时,作为 SDK 换 Token 接口未指定 ttl 时的默认值
sdk-expiration: 2h
# ==================== 登录安全配置 ====================
security:

2
src/main/resources/init-database.sql

@ -154,7 +154,7 @@ COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.file_path IS '原始文件存储路径(相对路径)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID';
COMMENT ON COLUMN knowledge_document.folder_id IS '所属目录ID(0 表示未指定目录,直接挂分类根)';
COMMENT ON COLUMN knowledge_document.tags IS '标签(JSON)';

2
src/main/resources/knowledge-base.sql

@ -64,7 +64,7 @@ COMMENT ON COLUMN knowledge_document.title IS '文档标题';
COMMENT ON COLUMN knowledge_document.source_name IS '原始文件名';
COMMENT ON COLUMN knowledge_document.file_type IS '文件类型 - pdf/md/json/txt/word/excel 等';
COMMENT ON COLUMN knowledge_document.file_size IS '文件大小(字节)';
COMMENT ON COLUMN knowledge_document.content IS '原文内容(截断预览)';
COMMENT ON COLUMN knowledge_document.content IS '原文全文(用于重新处理时重建分块;接口仅返回 2000 字预览)';
COMMENT ON COLUMN knowledge_document.category_id IS '所属分类ID - 0表示未分类';
COMMENT ON COLUMN knowledge_document.tags IS '标签列表(JSON数组)';
COMMENT ON COLUMN knowledge_document.chunk_count IS '分块数量';

Loading…
Cancel
Save