feat(phase6): US5 QA 问答生成阶段标注与审批模块

- QaService:getResult/updateResult/submit/approve/reject 五大方法
  - approve() 单事务内完成:training_dataset→APPROVED + task→APPROVED + source_data→APPROVED
  - reject() 清除候选问答对(deleteByTaskId),source_data 保持 QA_REVIEW 状态
  - 与 ExtractionService 同款自审校验(SELF_REVIEW_FORBIDDEN 403)
- QaController:5 个端点 /api/qa/{taskId} 系列,ANNOTATOR/REVIEWER 权限分离
- 集成测试 QaApprovalIntegrationTest:
  - 审批通过验证整条流水线终态(training_dataset+source_data 均为 APPROVED)
  - 驳回验证候选记录清除 + 重领再提交全流程
This commit is contained in:
wh
2026-04-09 15:39:28 +08:00
parent 927e4f1cf3
commit 6d972511ff
3 changed files with 521 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
package com.label.integration;
import com.label.AbstractIntegrationTest;
import com.label.module.user.dto.LoginRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* QA 问答生成阶段审批集成测试US5
*
* 测试场景:
* 1. QA 审批通过 → training_dataset.status = APPROVEDsource_data.status = APPROVED
* 2. QA 驳回 → 候选问答对被删除,标注员可重领
*/
public class QaApprovalIntegrationTest extends AbstractIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
private Long sourceId;
private Long taskId;
private Long datasetId;
private Long annotatorUserId;
private Long reviewerUserId;
@BeforeEach
void setup() {
annotatorUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'annotator01'", Long.class);
reviewerUserId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_user WHERE username = 'reviewer01'", Long.class);
Long companyId = jdbcTemplate.queryForObject(
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
// 插入 source_dataQA_REVIEW 状态,模拟提取审批已完成)
jdbcTemplate.execute(
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
"file_name, file_size, bucket_name, status) " +
"VALUES (" + companyId + ", " + annotatorUserId + ", 'TEXT', " +
"'test/qa-test/file.txt', 'file.txt', 100, 'test-bucket', 'QA_REVIEW')");
sourceId = jdbcTemplate.queryForObject(
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
// 插入 QA_GENERATION 任务UNCLAIMED 状态,模拟提取审批通过后自动创建的 QA 任务)
jdbcTemplate.execute(
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
"VALUES (" + companyId + ", " + sourceId + ", 'QA_GENERATION', 'UNCLAIMED')");
taskId = jdbcTemplate.queryForObject(
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
// 插入候选问答对(模拟 ExtractionApprovedEventListener 创建)
jdbcTemplate.execute(
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
", 'TEXT', '{\"conversations\":[{\"question\":\"北京是哪个国家的首都?\",\"answer\":\"中国\"}]}'::jsonb, " +
"'PENDING_REVIEW')");
datasetId = jdbcTemplate.queryForObject(
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
}
// ------------------------------------------------------------------ 测试 1: 审批通过 → 终态 --
@Test
@DisplayName("QA 审批通过 → training_dataset.status=APPROVEDsource_data.status=APPROVED")
void approveQaTask_thenDatasetAndSourceApproved() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 注意QA 任务 claim 端点为 POST /api/tasks/{id}/claimANNOTATOR 角色)
// 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED
// QA 任务由 ANNOTATOR 直接领取(不经过任务池)
ResponseEntity<Map> claimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 提交 QA 结果
ResponseEntity<Map> submitResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 审批通过
ResponseEntity<Map> approveResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/approve"),
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证training_dataset → APPROVED
String datasetStatus = jdbcTemplate.queryForObject(
"SELECT status FROM training_dataset WHERE id = ?", String.class, datasetId);
assertThat(datasetStatus).as("training_dataset 状态应为 APPROVED").isEqualTo("APPROVED");
// 验证annotation_task → APPROVEDis_final=true
Map<String, Object> taskRow = jdbcTemplate.queryForMap(
"SELECT status, is_final FROM annotation_task WHERE id = ?", taskId);
assertThat(taskRow.get("status")).isEqualTo("APPROVED");
assertThat(taskRow.get("is_final")).isEqualTo(Boolean.TRUE);
// 验证source_data → APPROVED整条流水线完成
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("source_data 状态应为 APPROVED流水线终态").isEqualTo("APPROVED");
}
// ------------------------------------------------------------------ 测试 2: 驳回 → 候选记录删除 → 可重领 --
@Test
@DisplayName("QA 驳回 → 候选问答对被删除,标注员可重领并再次提交")
void rejectQaTask_thenDatasetDeletedAndReclaimable() {
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
// 领取并提交
restTemplate.exchange(baseUrl("/api/tasks/" + taskId + "/claim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
restTemplate.exchange(baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
// 驳回(驳回原因必填)
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + reviewerToken);
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
Map.of("reason", "问题描述不准确,请修改"), headers);
ResponseEntity<Map> rejectResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/reject"),
HttpMethod.POST, rejectReq, Map.class);
assertThat(rejectResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 REJECTED
String statusAfterReject = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(statusAfterReject).isEqualTo("REJECTED");
// 验证:候选问答对已被删除
Integer datasetCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM training_dataset WHERE task_id = ?",
Integer.class, taskId);
assertThat(datasetCount).as("驳回后候选问答对应被删除").isEqualTo(0);
// 验证source_data 保持 QA_REVIEW不变
String sourceStatus = jdbcTemplate.queryForObject(
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
assertThat(sourceStatus).as("驳回后 source_data 应保持 QA_REVIEW").isEqualTo("QA_REVIEW");
// 标注员重领任务
ResponseEntity<Map> reclaimResp = restTemplate.exchange(
baseUrl("/api/tasks/" + taskId + "/reclaim"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 再次提交
ResponseEntity<Map> resubmitResp = restTemplate.exchange(
baseUrl("/api/qa/" + taskId + "/submit"),
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
assertThat(resubmitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
// 验证:任务状态变为 SUBMITTED
String finalStatus = jdbcTemplate.queryForObject(
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
assertThat(finalStatus).isEqualTo("SUBMITTED");
}
// ------------------------------------------------------------------ 工具方法 --
private String loginAndGetToken(String companyCode, String username, String password) {
LoginRequest req = new LoginRequest();
req.setCompanyCode(companyCode);
req.setUsername(username);
req.setPassword(password);
ResponseEntity<Map> response = restTemplate.postForEntity(
baseUrl("/api/auth/login"), req, Map.class);
if (!response.getStatusCode().is2xxSuccessful()) {
return null;
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
return (String) data.get("token");
}
private HttpEntity<Void> bearerRequest(String token) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
return new HttpEntity<>(headers);
}
}