fix(aihr): audit candidate material reviews

This commit is contained in:
2026-07-14 11:00:36 +08:00
parent 4b177c618b
commit 00cb4d48e9
8 changed files with 75 additions and 8 deletions
@@ -64,7 +64,9 @@ public class AihrCandidateMaterialController {
@SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR)
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review") @PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) { public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(materialService.review(id, request)); LoginUser loginUser = LoginHelper.getLoginUser();
String operator = loginUser == null ? "unknown" : loginUser.getUsername();
return R.ok(materialService.review(id, request, operator));
} }
private static String currentAppUsername() { private static String currentAppUsername() {
@@ -5,6 +5,7 @@ import org.dromara.common.satoken.utils.LoginHelper;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.domain.vo.SysOssVo;
import org.dromara.system.service.ISysOssService; import org.dromara.system.service.ISysOssService;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder; import org.springframework.jdbc.support.KeyHolder;
@@ -96,7 +97,7 @@ public class AihrCandidateMaterialService {
ensureTable(); ensureTable();
String normalizedStatus = clean(status, ""); String normalizedStatus = clean(status, "");
return jdbcTemplate.query(""" return jdbcTemplate.query("""
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, reviewer, reviewed_time, create_time
from aihr_candidate_material from aihr_candidate_material
where tenant_id = ? and (? = '' or status = ?) where tenant_id = ? and (? = '' or status = ?)
order by id desc order by id desc
@@ -110,18 +111,24 @@ public class AihrCandidateMaterialService {
rs.getString("material_type"), rs.getString("material_type"),
rs.getLong("file_size"), rs.getLong("file_size"),
rs.getString("status"), rs.getString("status"),
rs.getString("reviewer"),
rs.getTimestamp("reviewed_time") == null ? "" : rs.getTimestamp("reviewed_time").toLocalDateTime().format(TIME_FORMAT),
rs.getTimestamp("create_time").toLocalDateTime() rs.getTimestamp("create_time").toLocalDateTime()
), tenantId(), normalizedStatus, normalizedStatus); ), tenantId(), normalizedStatus, normalizedStatus);
} }
public MaterialReviewResponse review(Long id, ReviewRequest request) { public MaterialReviewResponse review(Long id, ReviewRequest request) {
return review(id, request, null);
}
public MaterialReviewResponse review(Long id, ReviewRequest request, String operator) {
ensureTable(); ensureTable();
String status = normalizeReviewStatus(request == null ? null : request.status()); String status = normalizeReviewStatus(request == null ? null : request.status());
int updated = jdbcTemplate.update(""" int updated = jdbcTemplate.update("""
update aihr_candidate_material update aihr_candidate_material
set status = ?, update_time = now() set status = ?, reviewer = ?, reviewed_time = now(), update_time = now()
where tenant_id = ? and id = ? where tenant_id = ? and id = ?
""", status, tenantId(), id); """, status, clean(operator, "人工审核"), tenantId(), id);
if (updated == 0) { if (updated == 0) {
throw new IllegalArgumentException("候选资料不存在"); throw new IllegalArgumentException("候选资料不存在");
} }
@@ -148,6 +155,8 @@ public class AihrCandidateMaterialService {
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME',
`oss_id` bigint NOT NULL COMMENT '对象存储ID', `oss_id` bigint NOT NULL COMMENT '对象存储ID',
`status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态',
`reviewer` varchar(100) DEFAULT NULL COMMENT '审核人',
`reviewed_time` datetime DEFAULT NULL COMMENT '审核时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
@@ -156,13 +165,15 @@ public class AihrCandidateMaterialService {
KEY `idx_aihr_candidate_material_oss` (`oss_id`) KEY `idx_aihr_candidate_material_oss` (`oss_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料';
"""); """);
ensureColumn("reviewer", "ALTER TABLE aihr_candidate_material ADD COLUMN `reviewer` varchar(100) DEFAULT NULL COMMENT '审核人' AFTER `status`");
ensureColumn("reviewed_time", "ALTER TABLE aihr_candidate_material ADD COLUMN `reviewed_time` datetime DEFAULT NULL COMMENT '审核时间' AFTER `reviewer`");
tableReady = true; tableReady = true;
} }
} }
private MaterialReviewResponse findReview(Long id) { private MaterialReviewResponse findReview(Long id) {
return jdbcTemplate.queryForObject(""" return jdbcTemplate.queryForObject("""
select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, reviewer, reviewed_time, create_time
from aihr_candidate_material from aihr_candidate_material
where tenant_id = ? and id = ? where tenant_id = ? and id = ?
""", (rs, rowNum) -> reviewRow( """, (rs, rowNum) -> reviewRow(
@@ -174,6 +185,8 @@ public class AihrCandidateMaterialService {
rs.getString("material_type"), rs.getString("material_type"),
rs.getLong("file_size"), rs.getLong("file_size"),
rs.getString("status"), rs.getString("status"),
rs.getString("reviewer"),
rs.getTimestamp("reviewed_time") == null ? "" : rs.getTimestamp("reviewed_time").toLocalDateTime().format(TIME_FORMAT),
rs.getTimestamp("create_time").toLocalDateTime() rs.getTimestamp("create_time").toLocalDateTime()
), tenantId(), id); ), tenantId(), id);
} }
@@ -183,10 +196,39 @@ public class AihrCandidateMaterialService {
return clean(tenantId, TENANT_ID); return clean(tenantId, TENANT_ID);
} }
private void ensureColumn(String column, String ddl) {
Integer exists = jdbcTemplate.queryForObject("""
select count(*)
from information_schema.columns
where table_schema = database() and table_name = 'aihr_candidate_material' and column_name = ?
""", Integer.class, column);
if (exists != null && exists > 0) {
return;
}
try {
jdbcTemplate.execute(ddl);
} catch (DataAccessException error) {
if (!isDuplicateColumnError(error)) {
throw error;
}
}
}
private static boolean isDuplicateColumnError(DataAccessException error) {
Throwable current = error;
while (current != null) {
if (String.valueOf(current.getMessage()).contains("Duplicate column")) {
return true;
}
current = current.getCause();
}
return false;
}
private static MaterialReviewResponse reviewRow(Long id, String candidateId, String candidateName, Long ossId, private static MaterialReviewResponse reviewRow(Long id, String candidateId, String candidateName, Long ossId,
String fileName, String materialType, Long fileSize, String status, String fileName, String materialType, Long fileSize, String status,
LocalDateTime createTime) { String reviewer, String reviewedTime, LocalDateTime createTime) {
return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, createTime.format(TIME_FORMAT)); return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, reviewer, reviewedTime, createTime.format(TIME_FORMAT));
} }
private static String materialType(String fileName) { private static String materialType(String fileName) {
@@ -239,7 +281,7 @@ public class AihrCandidateMaterialService {
} }
public record MaterialReviewResponse(Long id, String candidateId, String candidateName, Long ossId, String fileName, public record MaterialReviewResponse(Long id, String candidateId, String candidateName, Long ossId, String fileName,
String materialType, Long fileSize, String status, String time) { String materialType, Long fileSize, String status, String reviewer, String reviewedTime, String time) {
} }
public record ReviewRequest(String status) { public record ReviewRequest(String status) {
@@ -4,6 +4,9 @@ import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -44,4 +47,16 @@ class AihrCandidateMaterialServiceTest {
assertThrows(IllegalArgumentException.class, () -> AihrCandidateMaterialService.normalizeReviewStatus("")); assertThrows(IllegalArgumentException.class, () -> AihrCandidateMaterialService.normalizeReviewStatus(""));
assertThrows(IllegalArgumentException.class, () -> AihrCandidateMaterialService.normalizeReviewStatus("未知状态")); assertThrows(IllegalArgumentException.class, () -> AihrCandidateMaterialService.normalizeReviewStatus("未知状态"));
} }
@Test
@Tag("dev")
void reviewUsesAuthenticatedOperatorForAuditTrail() throws Exception {
Path source = Path.of("src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java");
}
String code = Files.readString(source);
assertTrue(code.contains("review(id, request, operator)"));
}
} }
@@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS `aihr_candidate_material` (
`content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME',
`oss_id` bigint NOT NULL COMMENT '对象存储ID', `oss_id` bigint NOT NULL COMMENT '对象存储ID',
`status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态',
`reviewer` varchar(100) DEFAULT NULL COMMENT '审核人',
`reviewed_time` datetime DEFAULT NULL COMMENT '审核时间',
`create_time` datetime DEFAULT NULL COMMENT '创建时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
+1
View File
@@ -182,3 +182,4 @@
- 2026-07-14 BRD 知识答案反馈复核:原先只更新 `review_status`,没有复核人和复核时间;现由服务端记录登录操作人到 `reviewer/reviewed_time`,管理端待复核表同步展示留痕。 - 2026-07-14 BRD 知识答案反馈复核:原先只更新 `review_status`,没有复核人和复核时间;现由服务端记录登录操作人到 `reviewer/reviewed_time`,管理端待复核表同步展示留痕。
- 2026-07-14 BRD 知识反馈归属复核:答案反馈原先信任客户端传入的片段 ID,未校验知识片段是否属于当前租户;现入库前按当前租户校验片段归属并限制单次最多 20 个片段。 - 2026-07-14 BRD 知识反馈归属复核:答案反馈原先信任客户端传入的片段 ID,未校验知识片段是否属于当前租户;现入库前按当前租户校验片段归属并限制单次最多 20 个片段。
- 2026-07-14 BRD 知识反馈关联复核:答案反馈原先直接接受客户端传入的 `reviewId`;现仅保留当前租户存在的 SOP 评审关联,非法或跨租户 ID 归一为空。 - 2026-07-14 BRD 知识反馈关联复核:答案反馈原先直接接受客户端传入的 `reviewId`;现仅保留当前租户存在的 SOP 评审关联,非法或跨租户 ID 归一为空。
- 2026-07-14 BRD 候选资料审核留痕:候选资料审核此前只有状态三态,没有审核人和审核时间;现由服务端从当前 HR 登录态写入 `reviewer/reviewed_time`,管理端审核表同步展示。
+2
View File
@@ -99,6 +99,8 @@ export type CandidateMaterialReview = {
materialType: string; materialType: string;
fileSize: number; fileSize: number;
status: string; status: string;
reviewer?: string | null;
reviewedTime?: string | null;
time: string; time: string;
}; };
+2
View File
@@ -254,6 +254,8 @@
<el-tag :type="materialTagType(row.status)" size="small">{{ row.status }}</el-tag> <el-tag :type="materialTagType(row.status)" size="small">{{ row.status }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="reviewer" label="审核人" width="120" />
<el-table-column prop="reviewedTime" label="审核时间" width="170" />
<el-table-column label="操作" width="160" fixed="right"> <el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" :disabled="row.status === '已通过'" @click="reviewMaterial(row, '已通过')">通过</el-button> <el-button link type="primary" :disabled="row.status === '已通过'" @click="reviewMaterial(row, '已通过')">通过</el-button>
+1
View File
@@ -359,6 +359,7 @@ contains mobile-uni/src/services/candidate.ts "/api/recruit/interview/start"
contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials" contains mobile-uni/src/services/candidate.ts "/api/aihr/mobile/candidate/materials"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "候选人登录态无效" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "候选人登录态无效"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "@SaCheckLogin" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "@SaCheckLogin"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java "review(id, request, operator)"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "SUPPORTED_MATERIAL_EXTENSIONS" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "SUPPORTED_MATERIAL_EXTENSIONS"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "审核接口必须明确提交" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java "审核接口必须明确提交"
contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "SUPPORTED_AUDIO_EXTENSIONS" contains backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java "SUPPORTED_AUDIO_EXTENSIONS"