fix(aihr): bind SOP reviews to pilot identity

This commit is contained in:
2026-07-14 15:43:23 +08:00
parent 64f369eff0
commit 14d91f86a9
7 changed files with 113 additions and 27 deletions
@@ -31,6 +31,7 @@ import org.dromara.aihr.service.AihrUploadQueueService;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.satoken.utils.LoginHelper;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -82,7 +83,15 @@ public class AihrSopController {
@SaCheckLogin
@PostMapping("/search")
public R<SearchResponse> search(@RequestBody SearchRequest request) {
return R.ok(sopSeedService.search(restrictDemoVerificationSource(request)));
return R.ok(sopSeedService.search(restrictDemoVerificationSource(request), currentRequesterExtPartyId()));
}
private String currentRequesterExtPartyId() {
LoginUser loginUser = LoginHelper.getLoginUser();
if (loginUser == null || !UserType.APP_USER.getUserType().equals(loginUser.getUserType())) {
return "";
}
return loginUser.getUsername();
}
private SearchRequest restrictDemoVerificationSource(SearchRequest request) {
@@ -139,24 +139,29 @@ public class AihrSopSeedService {
}
public SearchResponse search(SearchRequest request) {
return search(request, "");
}
public SearchResponse search(SearchRequest request, String requesterExtPartyId) {
String requestedCategory = request == null ? "" : request.category();
SopSeed seed = seeds.getOrDefault(requestedCategory, seeds.get("投诉处理 SOP"));
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
String source = normalizeSearchSource(request == null ? null : request.source());
String requester = isBlank(requesterExtPartyId) ? "" : requesterExtPartyId.trim();
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
if (isBlank(queryText)) {
return withReviewId(noEvidenceResponse("", category), source);
return withReviewId(noEvidenceResponse("", category), source, requester);
}
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
if (dbResponse != null) {
if (isNoEvidenceAnswer(dbResponse.answer())) {
recordKnowledgeGap(queryText, category, position, source);
}
return withReviewId(dbResponse, source);
return withReviewId(dbResponse, source, requester);
}
recordKnowledgeGap(queryText, category, position, source);
return withReviewId(noEvidenceResponse(queryText, category), source);
return withReviewId(noEvidenceResponse(queryText, category), source, requester);
}
/**
@@ -1853,8 +1858,9 @@ public class AihrSopSeedService {
}
}
private SearchResponse withReviewId(SearchResponse response, String source) {
Long reviewId = shouldCreateSopReview(response.queryText(), source) ? createSopReview(response, source) : null;
private SearchResponse withReviewId(SearchResponse response, String source, String requesterExtPartyId) {
Long reviewId = shouldCreateSopReview(response.queryText(), source)
? createSopReview(response, source, requesterExtPartyId) : null;
return new SearchResponse(
response.queryText(),
response.category(),
@@ -1878,15 +1884,15 @@ public class AihrSopSeedService {
static boolean shouldCreateSopReview(String queryText, String source) {
return !isBlank(queryText) && !skipSopReview(source);
}
private Long createSopReview(SearchResponse response, String source) {
private Long createSopReview(SearchResponse response, String source, String requesterExtPartyId) {
try {
ensureSopReviewTable();
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement("""
INSERT INTO aihr_sop_answer_review
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, prompt_version, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
(tenant_id, query_text, category, answer_text, reference_text, snippet_count, source, requester_ext_party_id, prompt_version, status, create_time, update_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '待评审', ?, ?)
""", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, tenantId());
ps.setString(2, truncate(AihrSensitiveText.forModel(response.queryText()), 1000));
@@ -1895,10 +1901,11 @@ public class AihrSopSeedService {
ps.setString(5, truncate(AihrSensitiveText.forModel(response.reference()), 1000));
ps.setInt(6, response.snippets() == null ? 0 : response.snippets().size());
ps.setString(7, normalizeSearchSource(source));
ps.setString(8, firstNonBlank(response.promptVersion(), "unknown"));
ps.setString(8, requesterExtPartyId.isBlank() ? null : truncate(requesterExtPartyId, 100));
ps.setString(9, firstNonBlank(response.promptVersion(), "unknown"));
Timestamp now = Timestamp.valueOf(java.time.LocalDateTime.now());
ps.setTimestamp(9, now);
ps.setTimestamp(10, now);
ps.setTimestamp(11, now);
return ps;
}, keyHolder);
Number key = keyHolder.getKey();
@@ -1930,10 +1937,18 @@ public class AihrSopSeedService {
public SopReviewStats sopReviewStats() {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%')
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN r.usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review r
WHERE r.tenant_id = ? AND r.status = '已评审'
AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL
AND EXISTS (
SELECT 1
FROM aihr_org_snapshot o
WHERE o.tenant_id = r.tenant_id
AND o.employment_status = 'active'
AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)
)
AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%')
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")), tenantId());
}
@@ -1944,11 +1959,19 @@ public class AihrSopSeedService {
public SopReviewStats sopReviewStats(LocalDateTime startTime, LocalDateTime endTime) {
ensureSopReviewTable();
return jdbcTemplate.queryForObject("""
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review
WHERE tenant_id = ? AND status = '已评审'
AND (LOWER(TRIM(COALESCE(category, ''))) = 'sop' OR category LIKE '%SOP%')
AND reviewed_time >= ? AND reviewed_time < ?
SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN r.usable = 1 THEN 1 ELSE 0 END), 0) AS usable
FROM aihr_sop_answer_review r
WHERE r.tenant_id = ? AND r.status = '已评审'
AND NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL
AND EXISTS (
SELECT 1
FROM aihr_org_snapshot o
WHERE o.tenant_id = r.tenant_id
AND o.employment_status = 'active'
AND (o.ext_party_id = r.requester_ext_party_id OR o.person_phone = r.requester_ext_party_id)
)
AND (LOWER(TRIM(COALESCE(r.category, ''))) = 'sop' OR r.category LIKE '%SOP%')
AND r.reviewed_time >= ? AND r.reviewed_time < ?
""", (rs, rowNum) -> new SopReviewStats(rs.getInt("total"), rs.getInt("usable")),
tenantId(), Timestamp.valueOf(startTime), Timestamp.valueOf(endTime));
}
@@ -1971,6 +1994,7 @@ public class AihrSopSeedService {
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID',
`prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
@@ -1984,6 +2008,7 @@ public class AihrSopSeedService {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR SOP问答人工评审';
""");
ensureColumn("aihr_sop_answer_review", "source", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源' AFTER `snippet_count`");
ensureColumn("aihr_sop_answer_review", "requester_ext_party_id", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID' AFTER `source`");
ensureColumn("aihr_sop_answer_review", "prompt_version", "ALTER TABLE aihr_sop_answer_review ADD COLUMN `prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本' AFTER `source`");
sopReviewTableReady = true;
}
@@ -62,8 +62,8 @@ public class AihrSopSeedServiceTest {
}
String code = Files.readString(source);
assertTrue(code.contains("LOWER(TRIM(COALESCE(category, ''))) = 'sop'"));
assertTrue(code.contains("category LIKE '%SOP%'"));
assertTrue(code.contains("LOWER(TRIM(COALESCE(r.category, ''))) = 'sop'"));
assertTrue(code.contains("r.category LIKE '%SOP%'"));
}
@Test
@@ -139,6 +139,28 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("reviewSearch(id, request, operator)"));
}
@Test
@Tag("dev")
public void formalSopSamplesRequireAuthenticatedAppRequesterIdentity() throws Exception {
Path controller = Path.of("src/main/java/org/dromara/aihr/controller/AihrSopController.java");
if (!Files.exists(controller)) {
controller = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrSopController.java");
}
Path service = Path.of("src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
if (!Files.exists(service)) {
service = Path.of("ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java");
}
String controllerCode = Files.readString(controller);
String serviceCode = Files.readString(service);
assertTrue(controllerCode.contains("UserType.APP_USER.getUserType()"));
assertTrue(controllerCode.contains("currentRequesterExtPartyId()"));
assertTrue(serviceCode.contains("requester_ext_party_id"));
assertTrue(serviceCode.contains("NULLIF(TRIM(r.requester_ext_party_id), '') IS NOT NULL"));
assertTrue(serviceCode.contains("FROM aihr_org_snapshot o"));
assertTrue(serviceCode.contains("o.employment_status = 'active'"));
}
@Test
@Tag("dev")
public void demoVerificationSourceIsRestrictedToSuperAdmin() throws Exception {
@@ -237,6 +237,7 @@ CREATE TABLE IF NOT EXISTS `aihr_sop_answer_review` (
`reference_text` varchar(1000) DEFAULT NULL COMMENT '引用',
`snippet_count` int DEFAULT 0 COMMENT '命中片段数',
`source` varchar(50) DEFAULT 'knowledge_search' COMMENT '来源',
`requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT '提问员工外部主体ID',
`prompt_version` varchar(80) DEFAULT NULL COMMENT '答案生成提示词版本',
`usable` tinyint DEFAULT NULL COMMENT '人工评审是否可用',
`reviewer` varchar(100) DEFAULT NULL COMMENT '评审人',
@@ -0,0 +1,24 @@
-- AIHR SOP 问答提问员工主体迁移(MySQL 8.x)
-- 仅补齐结构,不写入业务数据;可重复执行。
SET @aihr_sop_review_table_exists := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_sop_answer_review'
);
SET @aihr_sop_requester_exists := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'aihr_sop_answer_review'
AND COLUMN_NAME = 'requester_ext_party_id'
);
SET @aihr_sop_requester_ddl := IF(
@aihr_sop_review_table_exists = 1 AND @aihr_sop_requester_exists = 0,
'ALTER TABLE aihr_sop_answer_review ADD COLUMN `requester_ext_party_id` varchar(100) DEFAULT NULL COMMENT ''提问员工外部主体ID'' AFTER `source`',
'SELECT 1'
);
PREPARE aihr_sop_requester_stmt FROM @aihr_sop_requester_ddl;
EXECUTE aihr_sop_requester_stmt;
DEALLOCATE PREPARE aihr_sop_requester_stmt;