feat(aihr): align figma pages for release

This commit is contained in:
2026-07-14 02:04:36 +08:00
parent 922fe7ff29
commit 87360a25d3
51 changed files with 3575 additions and 629 deletions
@@ -16,6 +16,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.HomeResponse;
import org.dromara.aihr.domain.vo.AihrMobileDto.IdentityResponse;
import org.dromara.aihr.service.AihrOrgSyncService;
import org.dromara.aihr.service.AihrMobileSeedService;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
@@ -44,6 +46,7 @@ import java.util.Map;
public class AihrMobileController {
private final AihrMobileSeedService mobileSeedService;
private final AihrOrgSyncService orgSyncService;
private final ISysOssService ossService;
@SaIgnore
@@ -52,11 +55,42 @@ public class AihrMobileController {
return R.ok(mobileSeedService.home(role));
}
/**
* Return the authenticated APP user's mobile role from the latest org snapshot.
* The login response intentionally stays compatible with the shared OAuth contract.
*/
@GetMapping("/me")
public R<IdentityResponse> me() {
String phone = currentAppUsername();
if (phone.isBlank()) {
return R.ok(new IdentityResponse("user", "员工端", "", "一线"));
}
var snapshot = orgSyncService.snapshot(phone, null, null, "active", 1, 1, 1);
var row = snapshot.rows().stream().findFirst();
if (row.isEmpty()) {
return R.ok(new IdentityResponse("user", "员工端", "", "一线"));
}
var person = row.get();
boolean supervisor = "主管".equals(person.positionLevel()) || "项目经理".equals(person.positionLevel());
return R.ok(new IdentityResponse(
supervisor ? "supervisor" : "user",
supervisor ? "主管端" : "员工端",
person.positionName(),
person.positionLevel()
));
}
@GetMapping("/practice/history")
public R<List<RecordResponse>> practiceHistory(@RequestParam(required = false) String extPartyId) {
return R.ok(mobileSeedService.practiceHistory(ownMobileExtPartyId(extPartyId)));
}
@GetMapping("/practice/history/{id}")
public R<Map<String, Object>> practiceHistoryDetail(@PathVariable Long id) {
ReviewDetailResponse detail = mobileSeedService.practiceHistoryDetail(id, ownMobileExtPartyId(null));
return detail == null ? R.fail("训练记录不存在") : R.ok(reviewDetailPayload(detail));
}
@GetMapping("/practice/reviews")
public R<List<RecordResponse>> practiceReviews(@RequestParam(required = false) String projectCode,
@RequestParam(required = false) String extPartyId,
@@ -172,7 +206,7 @@ public class AihrMobileController {
Map<String, Object> item = new LinkedHashMap<>();
item.put("role", turn.role());
item.put("label", turn.label());
item.put("text", turn.text());
item.put("text", maskSensitiveText(turn.text()));
item.put("audioUrl", turn.audioUrl());
item.put("audioOssId", turn.audioOssId());
return item;
@@ -184,10 +218,17 @@ public class AihrMobileController {
private static Map<String, Object> annotationPayload(AnnotationResponse annotation) {
return Map.of(
"turnIndex", annotation.turnIndex(),
"sourceText", annotation.sourceText(),
"issue", annotation.issue(),
"rewrite", annotation.rewrite(),
"sopPoint", annotation.sopPoint()
"sourceText", maskSensitiveText(annotation.sourceText()),
"issue", maskSensitiveText(annotation.issue()),
"rewrite", maskSensitiveText(annotation.rewrite()),
"sopPoint", maskSensitiveText(annotation.sopPoint())
);
}
private static String maskSensitiveText(String value) {
if (value == null || value.isBlank()) {
return value;
}
return value.replaceAll("(1\\d{2})\\d{4}(\\d{4})", "$1****$2");
}
}
@@ -12,6 +12,9 @@ import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -86,6 +89,24 @@ public class AihrPracticeController {
return R.ok(practiceSeedService.rubrics(scenarioId));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/rubrics/manage")
public R<List<RubricAdminResponse>> manageRubrics() {
return R.ok(practiceSeedService.manageRubrics());
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/rubrics/manage")
public R<RubricAdminResponse> saveRubric(@RequestBody RubricSaveRequest request) {
return R.ok(practiceSeedService.saveRubric(request));
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PatchMapping("/rubrics/{code}/enabled")
public R<Void> updateRubricEnabled(@PathVariable String code, @RequestBody RubricEnabledRequest request) {
return practiceSeedService.updateRubricEnabled(code, request) ? R.ok() : R.fail("评分规则不存在");
}
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@GetMapping("/export")
public void export(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
@@ -41,6 +41,10 @@ public final class AihrInterviewDto {
public record DimensionResponse(String label, Object value, String note) {
}
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary) {
public record QuestionRecordResponse(Integer seq, String question, String answer, String rating) {
}
public record RecordResponse(String time, String candidate, String position, Integer score, String result, String summary,
List<QuestionRecordResponse> questions) {
}
}
@@ -78,6 +78,32 @@ public final class AihrPracticeDto {
public record RubricDimensionResponse(String code, String name, Double weight, String description) {
}
public record RubricAdminResponse(
String code,
String scenarioCode,
String scenarioName,
String name,
String version,
Boolean enabled,
String description,
List<RubricDimensionResponse> dimensions
) {
}
public record RubricSaveRequest(
String code,
String scenarioCode,
String name,
String version,
Boolean enabled,
String description,
List<RubricDimensionResponse> dimensions
) {
}
public record RubricEnabledRequest(Boolean enabled) {
}
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
@@ -10,6 +10,14 @@ public final class AihrMobileDto {
private AihrMobileDto() {
}
public record IdentityResponse(
String role,
String roleLabel,
String positionName,
String positionLevel
) {
}
public record HomeResponse(
String role,
String roleLabel,
@@ -11,10 +11,12 @@ import org.dromara.aihr.domain.AihrInterviewDto.DimensionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse;
import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse;
import org.dromara.aihr.domain.AihrInterviewDto.QuestionRecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import java.sql.ResultSet;
@@ -88,7 +90,8 @@ public class AihrInterviewService {
}
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
String time = LocalDateTime.now().format(TIME_FORMAT);
RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary());
List<QuestionRecordResponse> questions = questionRecords(session);
RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary(), questions);
saveResult(sessionKey, session, score);
return new FinishResponse(
score.total(),
@@ -110,7 +113,7 @@ public class AihrInterviewService {
ensureResultTable();
int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100));
StringBuilder sql = new StringBuilder("""
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary
SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary, questions_json
FROM aihr_interview_result
WHERE tenant_id = '000000'
""");
@@ -134,7 +137,8 @@ public class AihrInterviewService {
rs.getString("position_code"),
rs.getInt("total_score"),
rs.getString("result"),
rs.getString("ai_summary")
rs.getString("ai_summary"),
parseQuestionRecords(rs.getString("questions_json"))
);
}
@@ -143,8 +147,8 @@ public class AihrInterviewService {
jdbcTemplate.update("""
INSERT INTO aihr_interview_result
(tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result,
suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
suggestion, ai_summary, evidence, source, answers_json, questions_json, dimensions_json, finished_time, create_time, update_time)
VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now())
ON DUPLICATE KEY UPDATE
candidate_id = VALUES(candidate_id),
candidate_name = VALUES(candidate_name),
@@ -156,6 +160,7 @@ public class AihrInterviewService {
evidence = VALUES(evidence),
source = VALUES(source),
answers_json = VALUES(answers_json),
questions_json = VALUES(questions_json),
dimensions_json = VALUES(dimensions_json),
finished_time = VALUES(finished_time),
update_time = now()
@@ -171,6 +176,7 @@ public class AihrInterviewService {
score.evidence(),
score.source(),
json(session.answers()),
json(questionRecords(session)),
json(score.dimensions())
);
}
@@ -198,6 +204,7 @@ public class AihrInterviewService {
`evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据',
`source` varchar(40) DEFAULT NULL COMMENT '评分来源',
`answers_json` text DEFAULT NULL COMMENT '候选人回答JSON',
`questions_json` text DEFAULT NULL COMMENT '面试问题与回答快照JSON',
`dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON',
`finished_time` datetime DEFAULT NULL COMMENT '完成时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
@@ -208,10 +215,35 @@ public class AihrInterviewService {
KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果';
""");
try {
jdbcTemplate.execute("ALTER TABLE aihr_interview_result ADD COLUMN questions_json text DEFAULT NULL COMMENT '面试问题与回答快照JSON' AFTER answers_json");
} catch (DataAccessException error) {
if (!String.valueOf(error.getMessage()).contains("Duplicate column")) {
throw error;
}
}
tableReady = true;
}
}
private List<QuestionRecordResponse> questionRecords(InterviewSession session) {
return session.questions().stream().map(question -> {
String answer = clean(session.answers().get(question.questionId()));
String rating = answer.length() >= 32 ? "优秀" : answer.length() >= 12 ? "良好" : "待提升";
return new QuestionRecordResponse(question.seq(), question.questionText(), answer.isBlank() ? "未回答" : answer, rating);
}).toList();
}
private List<QuestionRecordResponse> parseQuestionRecords(String value) {
if (isBlank(value)) return List.of();
try {
return objectMapper.readValue(value,
objectMapper.getTypeFactory().constructCollectionType(List.class, QuestionRecordResponse.class));
} catch (Exception e) {
return List.of();
}
}
private String json(Object value) {
try {
return objectMapper.writeValueAsString(value);
@@ -77,6 +77,10 @@ public class AihrMobileSeedService {
return practiceSeedService.reviewDetail(id, supervisorExtPartyId);
}
public ReviewDetailResponse practiceHistoryDetail(Long id, String extPartyId) {
return practiceSeedService.historyDetail(id, extPartyId);
}
public boolean markReviewed(Long id) {
return markReviewed(id, null);
}
@@ -28,7 +28,10 @@ import org.dromara.aihr.domain.AihrPracticeDto.RecordResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewAdviceRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ReviewDetailResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricDimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricAdminResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RubricResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RubricSaveRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioEnabledRequest;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioResponse;
import org.dromara.aihr.domain.AihrPracticeDto.ScenarioSaveRequest;
@@ -380,6 +383,79 @@ public class AihrPracticeSeedService {
return List.of(defaultRubric(isBlank(scenarioId) ? "complaint-water" : scenarioId.trim()));
}
public List<RubricAdminResponse> manageRubrics() {
ensureRubricTables();
return jdbcTemplate.query("""
SELECT r.rubric_code, r.scenario_code, COALESCE(s.scenario_name, r.scenario_code) AS scenario_name,
r.rubric_name, r.version, r.enabled, r.description
FROM aihr_practice_rubric r
LEFT JOIN aihr_practice_scenario s
ON s.tenant_id = r.tenant_id AND s.scenario_code = r.scenario_code
WHERE r.tenant_id = ?
ORDER BY r.scenario_code, r.version DESC, r.id
""", (rs, rowNum) -> new RubricAdminResponse(
rs.getString("rubric_code"),
rs.getString("scenario_code"),
rs.getString("scenario_name"),
rs.getString("rubric_name"),
rs.getString("version"),
rs.getBoolean("enabled"),
rs.getString("description"),
rubricDimensions(rs.getString("rubric_code"))
), TENANT_ID);
}
@Transactional(rollbackFor = Exception.class)
public RubricAdminResponse saveRubric(RubricSaveRequest request) {
ensureRubricTables();
String code = normalizeScenarioCode(firstNonBlank(request == null ? null : request.code(), "rubric-" + System.currentTimeMillis()));
String scenarioCode = firstNonBlank(request == null ? null : request.scenarioCode(), "complaint-water");
String name = firstNonBlank(request == null ? null : request.name(), "生活顾问评分规则");
String version = firstNonBlank(request == null ? null : request.version(), "v1");
boolean enabled = request == null || request.enabled() == null || request.enabled();
Timestamp now = Timestamp.valueOf(LocalDateTime.now());
jdbcTemplate.update("""
INSERT INTO aihr_practice_rubric
(tenant_id, rubric_code, scenario_code, rubric_name, version, enabled, description, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
scenario_code = VALUES(scenario_code), rubric_name = VALUES(rubric_name), version = VALUES(version),
enabled = VALUES(enabled), description = VALUES(description), update_time = VALUES(update_time)
""",
TENANT_ID, code, scenarioCode, name, version, enabled,
request == null ? null : request.description(), now, now);
jdbcTemplate.update("DELETE FROM aihr_practice_rubric_dimension WHERE tenant_id = ? AND rubric_code = ?", TENANT_ID, code);
List<RubricDimensionResponse> dimensions = request == null || request.dimensions() == null ? List.of() : request.dimensions();
int sort = 10;
for (RubricDimensionResponse dimension : dimensions) {
if (dimension == null || isBlank(dimension.code()) || isBlank(dimension.name())) {
continue;
}
double weight = Math.max(0, Math.min(1, dimension.weight() == null ? 0 : dimension.weight()));
jdbcTemplate.update("""
INSERT INTO aihr_practice_rubric_dimension
(tenant_id, rubric_code, dimension_code, dimension_name, weight, description, sort_order, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", TENANT_ID, code, dimension.code().trim(), dimension.name().trim(), weight,
dimension.description(), sort, now, now);
sort += 10;
}
return manageRubrics().stream().filter(item -> code.equals(item.code())).findFirst()
.orElseThrow(() -> new ServiceException("评分规则保存失败"));
}
public boolean updateRubricEnabled(String code, RubricEnabledRequest request) {
ensureRubricTables();
if (isBlank(code)) {
return false;
}
return jdbcTemplate.update("""
UPDATE aihr_practice_rubric SET enabled = ?, update_time = ?
WHERE tenant_id = ? AND rubric_code = ?
""", request == null || request.enabled() == null || request.enabled(),
Timestamp.valueOf(LocalDateTime.now()), TENANT_ID, code.trim()) > 0;
}
public StartResponse start(StartRequest request) {
ScenarioSeed scenario = resolveScenario(request == null ? null : request.scenarioId(), null);
RoundSeed firstRound = scenario.rounds().get(0);
@@ -999,6 +1075,11 @@ public class AihrPracticeSeedService {
return detail == null || !inTeamScope(supervisorExtPartyId, reviewExtPartyId(id)) ? null : detail;
}
public ReviewDetailResponse historyDetail(Long id, String extPartyId) {
ReviewDetailResponse detail = reviewDetail(id);
return detail == null || isBlank(extPartyId) || !extPartyId.equals(reviewExtPartyId(id)) ? null : detail;
}
@Transactional(rollbackFor = Exception.class)
public boolean markReviewed(Long id) {
return markReviewed(id, null);
@@ -2853,6 +2934,41 @@ public class AihrPracticeSeedService {
}
}
private void ensureRubricTables() {
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) DEFAULT '000000',
`rubric_code` varchar(100) NOT NULL,
`scenario_code` varchar(80) NOT NULL,
`rubric_name` varchar(100) NOT NULL,
`version` varchar(30) DEFAULT 'v1',
`enabled` tinyint DEFAULT 1,
`description` varchar(500) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_rubric` (`tenant_id`, `rubric_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
jdbcTemplate.execute("""
CREATE TABLE IF NOT EXISTS `aihr_practice_rubric_dimension` (
`id` bigint NOT NULL AUTO_INCREMENT,
`tenant_id` varchar(20) DEFAULT '000000',
`rubric_code` varchar(100) NOT NULL,
`dimension_code` varchar(50) NOT NULL,
`dimension_name` varchar(50) NOT NULL,
`weight` decimal(5,2) NOT NULL,
`description` varchar(500) DEFAULT NULL,
`sort_order` int DEFAULT 0,
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_practice_rubric_dimension` (`tenant_id`, `rubric_code`, `dimension_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
""");
}
private void ensureCalibrationTable() {
if (calibrationTableReady) {
return;
@@ -19,12 +19,15 @@ import java.nio.file.StandardCopyOption;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/**
* 批量上传队列:上传请求只做「暂存文件 + 入队 + 秒回」,解析/归类/向量化由本服务的后台 worker
@@ -39,6 +42,9 @@ public class AihrUploadQueueService {
private static final long MAX_FILE_BYTES = 100L * 1024 * 1024;
/** 视频单独放宽:培训视频普遍大于文档;更大的走服务端目录导入。 */
private static final long MAX_VIDEO_BYTES = 500L * 1024 * 1024;
private static final long MAX_ARCHIVE_BYTES = 500L * 1024 * 1024;
private static final long MAX_ARCHIVE_UNPACKED_BYTES = 2L * 1024 * 1024 * 1024;
private static final int MAX_ARCHIVE_ENTRIES = 1000;
private static final int MAX_ERROR_CHARS = 480;
/** 加工并发上限:链路含 LLM 归类与 embedding 外呼,并发过高会互相争抢配额并拖慢单文件耗时。 */
private static final int WORKER_PERMITS = 2;
@@ -76,12 +82,16 @@ public class AihrUploadQueueService {
throw new ServiceException("上传文件不能为空");
}
String fileName = sanitizeFileName(file.getOriginalFilename());
if (!AihrSopSeedService.supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频文件");
boolean archive = isZip(fileName);
if (!archive && !AihrSopSeedService.supportedFile(fileName)) {
throw new ServiceException("仅支持 txt/md/markdown/pdf/doc/docx/xls/xlsx/ppt/pptx/图片/视频/zip文件");
}
long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
long maxBytes = archive ? MAX_ARCHIVE_BYTES
: AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
if (file.getSize() > maxBytes) {
throw new ServiceException(AihrVideoService.videoFile(fileName)
throw new ServiceException(archive
? "ZIP不能超过 500MB"
: AihrVideoService.videoFile(fileName)
? "视频不能超过 500MB,更大的请放到导入根目录走服务端导入"
: "文件不能超过 100MB");
}
@@ -135,11 +145,11 @@ public class AihrUploadQueueService {
throw new ServiceException("重试条目不存在");
}
ItemRow row = jdbcTemplate.query("""
select id, file_name, category, staging_path, status
select 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("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
? 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);
if (row == null) {
throw new ServiceException("重试条目不存在");
@@ -190,17 +200,16 @@ public class AihrUploadQueueService {
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
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
)
order by i.id asc
limit 10
""", (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)), TENANT_ID, TENANT_ID);
""", (rs, rowNum) -> new QueueCandidate(rs.getLong(1), rs.getString(2)),
TENANT_ID, videoBusy ? 1 : 0, TENANT_ID);
for (QueueCandidate candidate : candidates) {
if (videoBusy && AihrVideoService.videoFile(candidate.fileName())) {
continue;
}
int updated = jdbcTemplate.update("""
update aihr_knowledge_upload_item
set status = 1, update_time = now()
@@ -229,11 +238,11 @@ public class AihrUploadQueueService {
private void processItem(Long id) {
ItemRow row = jdbcTemplate.query("""
select id, file_name, category, staging_path, status
select 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("file_name"), rs.getString("category"), rs.getString("staging_path"), rs.getInt("status"))
? 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);
if (row == null) {
return;
@@ -244,6 +253,18 @@ public class AihrUploadQueueService {
return;
}
try {
if (isZip(row.fileName())) {
int extracted = expandArchive(row);
int updated = jdbcTemplate.update("""
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);
if (updated == 1) {
deleteQuietly(staged);
}
return;
}
UploadResponse result = sopSeedService.processStagedDocument(row.fileName(), row.category(), staged);
// 0 片段的完成态(如图片待 OCR)把说明写进 error 列,面板可见原因
String note = result.fragments() != null && result.fragments() == 0 ? truncateError(result.summary()) : null;
@@ -264,6 +285,82 @@ public class AihrUploadQueueService {
}
}
private int expandArchive(ItemRow archive) throws IOException {
List<ExtractedFile> files = new ArrayList<>();
long unpackedBytes = 0;
int entries = 0;
try (var input = Files.newInputStream(Path.of(archive.stagingPath()));
var zip = new ZipInputStream(input)) {
ZipEntry entry;
while ((entry = zip.getNextEntry()) != null) {
if (++entries > MAX_ARCHIVE_ENTRIES) {
throw new IOException("ZIP文件数量不能超过 1000 个");
}
if (entry.isDirectory()) {
continue;
}
String entryName = entry.getName() == null ? "" : entry.getName().replace('\\', '/');
Path entryPath = Path.of(entryName).normalize();
if (entryName.isBlank() || entryPath.isAbsolute() || entryPath.startsWith("..")) {
throw new IOException("ZIP包含不安全的文件路径");
}
String fileName = sanitizeFileName(entryPath.getFileName().toString());
if (isZip(fileName)) {
throw new IOException("不支持嵌套 ZIP 文件");
}
if (!AihrSopSeedService.supportedFile(fileName)) {
continue;
}
long maxBytes = AihrVideoService.videoFile(fileName) ? MAX_VIDEO_BYTES : MAX_FILE_BYTES;
Path extracted = stagingPath(fileName);
long entryBytes = 0;
try (var output = Files.newOutputStream(extracted)) {
byte[] buffer = new byte[8192];
int read;
while ((read = zip.read(buffer)) != -1) {
entryBytes += read;
unpackedBytes += read;
if (entryBytes > maxBytes || unpackedBytes > MAX_ARCHIVE_UNPACKED_BYTES) {
throw new IOException("ZIP解压后文件超过大小限制");
}
output.write(buffer, 0, read);
}
}
files.add(new ExtractedFile(fileName, extracted));
}
} catch (IOException e) {
files.forEach(file -> deleteQuietly(file.stagedPath()));
throw e;
}
if (files.isEmpty()) {
throw new IOException("ZIP内没有支持的资料文件");
}
try {
for (ExtractedFile file : files) {
jdbcTemplate.update("""
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());
}
} 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());
deleteQuietly(file.stagedPath());
}
throw new IOException("ZIP文件入队失败", e);
}
return files.size();
}
private static boolean isZip(String fileName) {
return fileName.toLowerCase().endsWith(".zip");
}
private record ExtractedFile(String fileName, Path stagedPath) {
}
/** 清扫卡在「处理中」的孤儿条目(重启/异常导致),重置回待处理后再触发一轮加工;顺带回收过期暂存文件。 */
private void sweepAndTrigger() {
try {
@@ -362,7 +459,9 @@ public class AihrUploadQueueService {
}
private Path stagingPath(String fileName) {
return stagingRoot.resolve(UUID.randomUUID() + "_" + fileName);
String lower = fileName.toLowerCase();
String extension = lower.contains(".") ? lower.substring(lower.lastIndexOf('.')) : "";
return stagingRoot.resolve(UUID.randomUUID() + extension);
}
/** 去掉路径分隔与控制字符,防止暂存路径逃逸出 stagingRoot。 */
@@ -422,6 +521,6 @@ public class AihrUploadQueueService {
}
}
private record ItemRow(Long id, String fileName, String category, String stagingPath, int status) {
private record ItemRow(Long id, String batchId, String fileName, String category, String stagingPath, int status) {
}
}