fix(aihr): preserve tenant in upload queue
This commit is contained in:
+2
-2
@@ -37,7 +37,7 @@ import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.apache.tika.metadata.Metadata;
|
||||
import org.apache.tika.metadata.TikaCoreProperties;
|
||||
import org.apache.tika.parser.AutoDetectParser;
|
||||
@@ -3245,7 +3245,7 @@ public class AihrSopSeedService {
|
||||
}
|
||||
|
||||
private String tenantId() {
|
||||
return firstNonBlank(LoginHelper.getTenantId(), TENANT_ID);
|
||||
return firstNonBlank(TenantHelper.getTenantId(), TENANT_ID);
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String baseUrl) {
|
||||
|
||||
+49
-38
@@ -6,6 +6,8 @@ import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.common.tenant.helper.TenantHelper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
@@ -108,11 +110,11 @@ public class AihrUploadQueueService {
|
||||
insert into aihr_knowledge_upload_item
|
||||
(tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, 0, now(), now())
|
||||
""", TENANT_ID, batch, fileName, category == null ? "" : category.trim(), staged.toString());
|
||||
""", currentTenantId(), batch, fileName, category == null ? "" : category.trim(), staged.toString());
|
||||
// staging_path 含 UUID 全局唯一,用它反查主键,避免 last_insert_id 的跨连接问题
|
||||
Long itemId = jdbcTemplate.query("""
|
||||
select id from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?
|
||||
""", rs -> rs.next() ? rs.getLong(1) : null, TENANT_ID, staged.toString());
|
||||
""", rs -> rs.next() ? rs.getLong(1) : null, currentTenantId(), staged.toString());
|
||||
triggerProcessing();
|
||||
return new UploadEnqueueResponse(itemId, batch, fileName, "queued");
|
||||
}
|
||||
@@ -128,7 +130,7 @@ public class AihrUploadQueueService {
|
||||
where tenant_id = ?
|
||||
order by id desc
|
||||
limit %d
|
||||
""".formatted(safeLimit), this::mapItem, TENANT_ID);
|
||||
""".formatted(safeLimit), this::mapItem, currentTenantId());
|
||||
}
|
||||
return jdbcTemplate.query("""
|
||||
select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time
|
||||
@@ -136,7 +138,7 @@ public class AihrUploadQueueService {
|
||||
where tenant_id = ? and batch_id = ?
|
||||
order by id asc
|
||||
limit %d
|
||||
""".formatted(safeLimit), this::mapItem, TENANT_ID, batch);
|
||||
""".formatted(safeLimit), this::mapItem, currentTenantId(), batch);
|
||||
}
|
||||
|
||||
public UploadItemResponse retry(Long id) {
|
||||
@@ -144,13 +146,14 @@ public class AihrUploadQueueService {
|
||||
if (id == null) {
|
||||
throw new ServiceException("重试条目不存在");
|
||||
}
|
||||
String tenantId = currentTenantId();
|
||||
ItemRow row = jdbcTemplate.query("""
|
||||
select id, batch_id, file_name, category, staging_path, status
|
||||
select tenant_id, id, batch_id, file_name, category, staging_path, status
|
||||
from aihr_knowledge_upload_item
|
||||
where tenant_id = ? and id = ?
|
||||
""", rs -> rs.next()
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, TENANT_ID, id);
|
||||
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, tenantId, id);
|
||||
if (row == null) {
|
||||
throw new ServiceException("重试条目不存在");
|
||||
}
|
||||
@@ -158,14 +161,14 @@ public class AihrUploadQueueService {
|
||||
throw new ServiceException("仅失败状态的文件可重试");
|
||||
}
|
||||
if (!Files.isRegularFile(Path.of(row.stagingPath()))) {
|
||||
markFailed(id, "暂存文件已丢失,请重新上传该文件");
|
||||
markFailed(tenantId, id, "暂存文件已丢失,请重新上传该文件");
|
||||
throw new ServiceException("暂存文件已丢失,请重新上传该文件");
|
||||
}
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 0, error = null, update_time = now()
|
||||
where tenant_id = ? and id = ? and status = 3
|
||||
""", TENANT_ID, id);
|
||||
""", tenantId, id);
|
||||
if (updated == 0) {
|
||||
throw new ServiceException("仅失败状态的文件可重试");
|
||||
}
|
||||
@@ -176,7 +179,7 @@ public class AihrUploadQueueService {
|
||||
/** 领取待处理条目并在受限并发下加工;每领取一条占用一个许可,处理完释放后继续领取。 */
|
||||
private void triggerProcessing() {
|
||||
while (workerPermits.tryAcquire()) {
|
||||
Long claimed = claimNext();
|
||||
QueueCandidate claimed = claimNext();
|
||||
if (claimed == null) {
|
||||
workerPermits.release();
|
||||
return;
|
||||
@@ -192,31 +195,31 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private Long claimNext() {
|
||||
private QueueCandidate claimNext() {
|
||||
try {
|
||||
// 同名文件不并发领取:saveDocument 对同 knowledge+fileName 是替换语义,并发会撞 attach 唯一键。
|
||||
// 视频最多同时加工 1 个:单个视频要占数分钟的 ffmpeg+ASR+vision,双路并发互相拖慢。
|
||||
boolean videoBusy = hasProcessingVideo();
|
||||
List<QueueCandidate> candidates = jdbcTemplate.query("""
|
||||
select i.id, i.file_name from aihr_knowledge_upload_item i
|
||||
where i.tenant_id = ? and i.status = 0
|
||||
select i.tenant_id, i.id, i.file_name from aihr_knowledge_upload_item i
|
||||
where i.status = 0
|
||||
and (? = 0 or i.file_name not regexp '\\\\.(mp4|mov|avi|mkv|webm|m4v)$')
|
||||
and not exists (
|
||||
select 1 from (select file_name from aihr_knowledge_upload_item where tenant_id = ? and status = 1) p
|
||||
where p.file_name = i.file_name
|
||||
select 1 from aihr_knowledge_upload_item p
|
||||
where p.tenant_id = i.tenant_id and p.status = 1 and p.file_name = i.file_name
|
||||
)
|
||||
order by i.id asc
|
||||
limit 10
|
||||
""", (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)),
|
||||
TENANT_ID, videoBusy ? 1 : 0, TENANT_ID);
|
||||
""", (rs, rowNum) -> new QueueCandidate(rs.getString(1), rs.getLong(2), rs.getString(3)),
|
||||
videoBusy ? 1 : 0);
|
||||
for (QueueCandidate candidate : candidates) {
|
||||
int updated = jdbcTemplate.update("""
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 1, update_time = now()
|
||||
where tenant_id = ? and id = ? and status = 0
|
||||
""", TENANT_ID, candidate.id());
|
||||
""", candidate.tenantId(), candidate.id());
|
||||
if (updated == 1) {
|
||||
return candidate.id();
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -228,28 +231,30 @@ public class AihrUploadQueueService {
|
||||
|
||||
private boolean hasProcessingVideo() {
|
||||
List<String> processing = jdbcTemplate.query("""
|
||||
select file_name from aihr_knowledge_upload_item where tenant_id = ? and status = 1
|
||||
""", (rs, rowNum) -> rs.getString(1), TENANT_ID);
|
||||
select file_name from aihr_knowledge_upload_item where status = 1
|
||||
""", (rs, rowNum) -> rs.getString(1));
|
||||
return processing.stream().anyMatch(AihrVideoService::videoFile);
|
||||
}
|
||||
|
||||
private record QueueCandidate(Long id, String fileName) {
|
||||
private record QueueCandidate(String tenantId, Long id, String fileName) {
|
||||
}
|
||||
|
||||
private void processItem(Long id) {
|
||||
private void processItem(QueueCandidate claim) {
|
||||
String tenantId = claim.tenantId();
|
||||
Long id = claim.id();
|
||||
ItemRow row = jdbcTemplate.query("""
|
||||
select id, batch_id, file_name, category, staging_path, status
|
||||
select tenant_id, id, batch_id, file_name, category, staging_path, status
|
||||
from aihr_knowledge_upload_item
|
||||
where tenant_id = ? and id = ?
|
||||
""", rs -> rs.next()
|
||||
? new ItemRow(rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, TENANT_ID, id);
|
||||
? new ItemRow(rs.getString("tenant_id"), rs.getLong("id"), rs.getString("batch_id"), rs.getString("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
|
||||
: null, tenantId, id);
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
Path staged = Path.of(row.stagingPath());
|
||||
if (!Files.isRegularFile(staged)) {
|
||||
markFailed(id, "暂存文件已丢失,请重新上传该文件");
|
||||
markFailed(tenantId, id, "暂存文件已丢失,请重新上传该文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -259,13 +264,14 @@ public class AihrUploadQueueService {
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 2, fragment_count = ?, update_time = now()
|
||||
where tenant_id = ? and id = ? and status = 1
|
||||
""", extracted, TENANT_ID, id);
|
||||
""", extracted, tenantId, id);
|
||||
if (updated == 1) {
|
||||
deleteQuietly(staged);
|
||||
}
|
||||
return;
|
||||
}
|
||||
UploadResponse result = sopSeedService.processStagedDocument(row.fileName(), row.category(), staged);
|
||||
UploadResponse result = TenantHelper.dynamic(tenantId,
|
||||
() -> sopSeedService.processStagedDocument(row.fileName(), row.category(), staged));
|
||||
// 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因
|
||||
String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null;
|
||||
// CAS:仅当仍是本工人持有的「处理中」才写完成,防止清扫重置后被后来的工人覆盖状态
|
||||
@@ -273,7 +279,7 @@ public class AihrUploadQueueService {
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 2, error = ?, doc_id = ?, fragment_count = ?, update_time = now()
|
||||
where tenant_id = ? and id = ? and status = 1
|
||||
""", note, result.docId(), result.fragments(), TENANT_ID, id);
|
||||
""", note, result.docId(), result.fragments(), tenantId, id);
|
||||
if (updated == 1) {
|
||||
deleteQuietly(staged);
|
||||
} else {
|
||||
@@ -281,7 +287,7 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("upload item {} process failed: {}", id, e.getMessage());
|
||||
markFailed(id, e.getMessage());
|
||||
markFailed(tenantId, id, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,12 +347,12 @@ public class AihrUploadQueueService {
|
||||
insert into aihr_knowledge_upload_item
|
||||
(tenant_id, batch_id, file_name, category, staging_path, status, create_time, update_time)
|
||||
values (?, ?, ?, ?, ?, 0, now(), now())
|
||||
""", TENANT_ID, archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString());
|
||||
""", archive.tenantId(), archive.batchId(), file.fileName(), archive.category(), file.stagedPath().toString());
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
for (ExtractedFile file : files) {
|
||||
jdbcTemplate.update("delete from aihr_knowledge_upload_item where tenant_id = ? and staging_path = ?",
|
||||
TENANT_ID, file.stagedPath().toString());
|
||||
archive.tenantId(), file.stagedPath().toString());
|
||||
deleteQuietly(file.stagedPath());
|
||||
}
|
||||
throw new IOException("ZIP文件入队失败", e);
|
||||
@@ -373,7 +379,7 @@ public class AihrUploadQueueService {
|
||||
(update_time < (now() - interval %d minute) and file_name not regexp '\\\\.(mp4|mov|avi|mkv|webm|m4v)$')
|
||||
or update_time < (now() - interval %d minute)
|
||||
)
|
||||
""".formatted(STUCK_MINUTES, VIDEO_STUCK_MINUTES), TENANT_ID);
|
||||
""".formatted(STUCK_MINUTES, VIDEO_STUCK_MINUTES));
|
||||
if (reset > 0) {
|
||||
log.info("upload queue reset {} stuck items", reset);
|
||||
}
|
||||
@@ -405,14 +411,14 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private void markFailed(Long id, String message) {
|
||||
private void markFailed(String tenantId, Long id, String message) {
|
||||
String error = truncateError(message == null || message.isBlank() ? "处理失败" : message);
|
||||
// 不覆盖已被清扫重置(0)或已完成(2)的行:处理中标失败,失败态允许更新错误信息(重试路径)
|
||||
jdbcTemplate.update("""
|
||||
update aihr_knowledge_upload_item
|
||||
set status = 3, error = ?, update_time = now()
|
||||
where tenant_id = ? and id = ? and status in (1, 3)
|
||||
""", error, TENANT_ID, id);
|
||||
""", error, tenantId, id);
|
||||
}
|
||||
|
||||
private static String truncateError(String value) {
|
||||
@@ -427,7 +433,7 @@ public class AihrUploadQueueService {
|
||||
select id, batch_id, file_name, category, status, error, doc_id, fragment_count, update_time
|
||||
from aihr_knowledge_upload_item
|
||||
where tenant_id = ? and id = ?
|
||||
""", this::mapItem, TENANT_ID, id);
|
||||
""", this::mapItem, currentTenantId(), id);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
@@ -490,6 +496,11 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private String currentTenantId() {
|
||||
String tenantId = LoginHelper.getTenantId();
|
||||
return tenantId == null || tenantId.isBlank() ? TENANT_ID : tenantId;
|
||||
}
|
||||
|
||||
private void ensureTable() {
|
||||
if (tableReady) {
|
||||
return;
|
||||
@@ -521,6 +532,6 @@ public class AihrUploadQueueService {
|
||||
}
|
||||
}
|
||||
|
||||
private record ItemRow(Long id, String batchId, String fileName, String category, String stagingPath, int status) {
|
||||
private record ItemRow(String tenantId, Long id, String batchId, String fileName, String category, String stagingPath, int status) {
|
||||
}
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package org.dromara.aihr.service;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@Tag("dev")
|
||||
class AihrUploadQueueServiceTest {
|
||||
|
||||
@Test
|
||||
void queueKeepsTenantWhenWorkerLeavesLoginThread() throws Exception {
|
||||
Path source = Path.of("src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java");
|
||||
if (!Files.exists(source)) {
|
||||
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrUploadQueueService.java");
|
||||
}
|
||||
String code = Files.readString(source);
|
||||
|
||||
assertTrue(code.contains("select i.tenant_id, i.id, i.file_name"));
|
||||
assertTrue(code.contains("p.tenant_id = i.tenant_id"));
|
||||
assertTrue(code.contains("TenantHelper.dynamic(tenantId"));
|
||||
assertTrue(code.contains("new ItemRow(rs.getString(\"tenant_id\")"));
|
||||
}
|
||||
}
|
||||
@@ -97,3 +97,4 @@
|
||||
- 2026-07-14 SOP/RAG 租户边界复核:知识库搜索、知识缺口、SOP 问答评审、答案反馈、异步导入任务、文档/片段/向量写入及 Qdrant tenant filter 改为当前登录租户;无登录上下文时回退本地开发租户 `000000`。定向 AIHR 测试 48/48 通过;正式多租户检索隔离仍需在测试环境做跨租户回归,批量上传队列仍待单独审查。
|
||||
- 2026-07-14 剩余固定租户审查:`AihrPracticeSeedService` 仍有 79 处运行时 `TENANT_ID`,覆盖场景、训练记录、派题、复盘、导出和 Prompt 管理;这些路径可按同步请求上下文分批迁移。`AihrUploadQueueService` 仍有 19 处固定租户引用,但其后台 worker 会脱离登录线程并调用 SOP 加工,不能机械替换为 `LoginHelper`;下一步需让队列条目携带租户、worker 按条目租户加工,并补跨租户领取/隔离测试。
|
||||
- 2026-07-14 训练租户边界修复:`AihrPracticeSeedService` 的场景、训练记录、派题、复盘、导出、Prompt、标注和音频明细运行时查询/写入统一使用当前登录租户;无登录上下文回退本地开发租户 `000000`。定向 AIHR 测试 48/48、管理端完整打包均通过。`AihrUploadQueueService` 仍保留固定租户,原因是后台 worker 需要下一批显式携带条目租户后再调用 SOP 加工,避免机械替换造成跨租户队列停摆。
|
||||
- 2026-07-14 异步上传队列租户边界修复:请求入队、查询和重试按当前登录租户;后台领取按队列条目租户跨租户扫描,文件加工通过 `TenantHelper.dynamic` 进入对应租户,ZIP 子条目、失败回写和卡死清扫均沿用条目租户;新增队列结构回归测试。AIHR 全量测试 50/50、`ruoyi-admin` 跳测试打包通过;正式多租户文件上传、向量写入和跨租户隔离仍需测试环境回归。
|
||||
|
||||
Reference in New Issue
Block a user