diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java index 05d7346e..70448761 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java @@ -64,7 +64,9 @@ public class AihrCandidateMaterialController { @SaCheckRole(value = {TenantConstants.SUPER_ADMIN_ROLE_KEY, HR_OPERATOR_ROLE}, mode = SaMode.OR) @PostMapping("/api/aihr/hr/candidate/materials/{id}/review") public R 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() { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java index cf5ab68c..f52cbd4c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java @@ -5,6 +5,7 @@ import org.dromara.common.satoken.utils.LoginHelper; import org.dromara.common.core.exception.ServiceException; import org.dromara.system.domain.vo.SysOssVo; import org.dromara.system.service.ISysOssService; +import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; @@ -96,7 +97,7 @@ public class AihrCandidateMaterialService { ensureTable(); String normalizedStatus = clean(status, ""); 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 where tenant_id = ? and (? = '' or status = ?) order by id desc @@ -110,18 +111,24 @@ public class AihrCandidateMaterialService { rs.getString("material_type"), rs.getLong("file_size"), rs.getString("status"), + rs.getString("reviewer"), + rs.getTimestamp("reviewed_time") == null ? "" : rs.getTimestamp("reviewed_time").toLocalDateTime().format(TIME_FORMAT), rs.getTimestamp("create_time").toLocalDateTime() ), tenantId(), normalizedStatus, normalizedStatus); } public MaterialReviewResponse review(Long id, ReviewRequest request) { + return review(id, request, null); + } + + public MaterialReviewResponse review(Long id, ReviewRequest request, String operator) { ensureTable(); String status = normalizeReviewStatus(request == null ? null : request.status()); int updated = jdbcTemplate.update(""" update aihr_candidate_material - set status = ?, update_time = now() + set status = ?, reviewer = ?, reviewed_time = now(), update_time = now() where tenant_id = ? and id = ? - """, status, tenantId(), id); + """, status, clean(operator, "人工审核"), tenantId(), id); if (updated == 0) { throw new IllegalArgumentException("候选资料不存在"); } @@ -148,6 +155,8 @@ public class AihrCandidateMaterialService { `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', `oss_id` bigint NOT NULL COMMENT '对象存储ID', `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', + `reviewer` varchar(100) DEFAULT NULL COMMENT '审核人', + `reviewed_time` datetime DEFAULT NULL COMMENT '审核时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), @@ -156,13 +165,15 @@ public class AihrCandidateMaterialService { KEY `idx_aihr_candidate_material_oss` (`oss_id`) ) 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; } } private MaterialReviewResponse findReview(Long id) { 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 where tenant_id = ? and id = ? """, (rs, rowNum) -> reviewRow( @@ -174,6 +185,8 @@ public class AihrCandidateMaterialService { rs.getString("material_type"), rs.getLong("file_size"), rs.getString("status"), + rs.getString("reviewer"), + rs.getTimestamp("reviewed_time") == null ? "" : rs.getTimestamp("reviewed_time").toLocalDateTime().format(TIME_FORMAT), rs.getTimestamp("create_time").toLocalDateTime() ), tenantId(), id); } @@ -183,10 +196,39 @@ public class AihrCandidateMaterialService { 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, String fileName, String materialType, Long fileSize, String status, - LocalDateTime createTime) { - return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, createTime.format(TIME_FORMAT)); + String reviewer, String reviewedTime, LocalDateTime createTime) { + return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, reviewer, reviewedTime, createTime.format(TIME_FORMAT)); } 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, - 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) { diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java index 79f108f7..e2059029 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrCandidateMaterialServiceTest.java @@ -4,6 +4,9 @@ import org.dromara.common.core.exception.ServiceException; 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.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -44,4 +47,16 @@ class AihrCandidateMaterialServiceTest { 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)")); + } } diff --git a/backend/script/sql/aihr_candidate_material_mysql8.sql b/backend/script/sql/aihr_candidate_material_mysql8.sql index d88a2120..e88e43d6 100644 --- a/backend/script/sql/aihr_candidate_material_mysql8.sql +++ b/backend/script/sql/aihr_candidate_material_mysql8.sql @@ -11,6 +11,8 @@ CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', `oss_id` bigint NOT NULL COMMENT '对象存储ID', `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', + `reviewer` varchar(100) DEFAULT NULL COMMENT '审核人', + `reviewed_time` datetime DEFAULT NULL COMMENT '审核时间', `create_time` datetime DEFAULT NULL COMMENT '创建时间', `update_time` datetime DEFAULT NULL COMMENT '更新时间', PRIMARY KEY (`id`), diff --git a/docs/BRD_IMPLEMENTATION_AUDIT.md b/docs/BRD_IMPLEMENTATION_AUDIT.md index 58c6c5ae..e8855059 100644 --- a/docs/BRD_IMPLEMENTATION_AUDIT.md +++ b/docs/BRD_IMPLEMENTATION_AUDIT.md @@ -182,3 +182,4 @@ - 2026-07-14 BRD 知识答案反馈复核:原先只更新 `review_status`,没有复核人和复核时间;现由服务端记录登录操作人到 `reviewer/reviewed_time`,管理端待复核表同步展示留痕。 - 2026-07-14 BRD 知识反馈归属复核:答案反馈原先信任客户端传入的片段 ID,未校验知识片段是否属于当前租户;现入库前按当前租户校验片段归属并限制单次最多 20 个片段。 - 2026-07-14 BRD 知识反馈关联复核:答案反馈原先直接接受客户端传入的 `reviewId`;现仅保留当前租户存在的 SOP 评审关联,非法或跨租户 ID 归一为空。 +- 2026-07-14 BRD 候选资料审核留痕:候选资料审核此前只有状态三态,没有审核人和审核时间;现由服务端从当前 HR 登录态写入 `reviewer/reviewed_time`,管理端审核表同步展示。 diff --git a/frontend/src/api/aihr/interview.ts b/frontend/src/api/aihr/interview.ts index 1e309d77..5144b87a 100644 --- a/frontend/src/api/aihr/interview.ts +++ b/frontend/src/api/aihr/interview.ts @@ -99,6 +99,8 @@ export type CandidateMaterialReview = { materialType: string; fileSize: number; status: string; + reviewer?: string | null; + reviewedTime?: string | null; time: string; }; diff --git a/frontend/src/views/recruit/interview.vue b/frontend/src/views/recruit/interview.vue index c161d3ba..924f8156 100644 --- a/frontend/src/views/recruit/interview.vue +++ b/frontend/src/views/recruit/interview.vue @@ -254,6 +254,8 @@ {{ row.status }} + +