feat(aihr): align figma pages for release
This commit is contained in:
+46
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+5
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -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,
|
||||
|
||||
+37
-5
@@ -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);
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+116
@@ -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;
|
||||
|
||||
+113
-14
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `aihr_interview_result` (
|
||||
`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 '创建时间',
|
||||
|
||||
Reference in New Issue
Block a user