Merge branch 'main' of https://fun-md.com/whfh/label_backend
# Conflicts: # src/test/java/com/label/blackbox/AbstractBlackBoxTest.java # src/test/java/com/label/blackbox/SwaggerLiveBlackBoxTest.java # src/test/java/com/label/integration/AuthIntegrationTest.java # src/test/java/com/label/integration/ExportIntegrationTest.java # src/test/java/com/label/integration/ExtractionApprovalIntegrationTest.java # src/test/java/com/label/integration/MultiTenantIsolationTest.java # src/test/java/com/label/integration/QaApprovalIntegrationTest.java # src/test/java/com/label/integration/SourceIntegrationTest.java # src/test/java/com/label/integration/SysConfigIntegrationTest.java # src/test/java/com/label/integration/TaskClaimConcurrencyTest.java # src/test/java/com/label/integration/UserManagementIntegrationTest.java # src/test/java/com/label/integration/VideoCallbackIdempotencyTest.java # src/test/java/com/label/unit/AuthInterceptorTest.java
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ target/
|
|||||||
*.ear
|
*.ear
|
||||||
docs/
|
docs/
|
||||||
specs/
|
specs/
|
||||||
|
src/test/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 2. IDE 配置文件
|
# 2. IDE 配置文件
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
package com.label;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.test.context.DynamicPropertyRegistry;
|
|
||||||
import org.springframework.test.context.DynamicPropertySource;
|
|
||||||
import org.testcontainers.containers.GenericContainer;
|
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
|
||||||
import org.testcontainers.junit.jupiter.Container;
|
|
||||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
||||||
import org.testcontainers.utility.DockerImageName;
|
|
||||||
import org.testcontainers.utility.MountableFile;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Base class for all integration tests.
|
|
||||||
*
|
|
||||||
* Starts real PostgreSQL 16 and Redis 7 containers (shared across test class instances).
|
|
||||||
* Executes sql/init.sql to initialize schema and seed data.
|
|
||||||
*
|
|
||||||
* DESIGN:
|
|
||||||
* - @Container with static fields → containers are shared across test methods (faster)
|
|
||||||
* - @DynamicPropertySource → overrides datasource/redis properties at runtime
|
|
||||||
* - @BeforeEach cleanData() → truncates business tables (not sys_company/sys_user) between tests
|
|
||||||
*/
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
|
||||||
@Testcontainers
|
|
||||||
public abstract class AbstractIntegrationTest {
|
|
||||||
|
|
||||||
@LocalServerPort
|
|
||||||
protected int port;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
protected JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
@SuppressWarnings("resource")
|
|
||||||
@Container
|
|
||||||
protected static final PostgreSQLContainer<?> postgres =
|
|
||||||
new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
|
|
||||||
.withDatabaseName("label_db")
|
|
||||||
.withUsername("label")
|
|
||||||
.withPassword("label_password")
|
|
||||||
.withCopyFileToContainer(
|
|
||||||
MountableFile.forClasspathResource("db/init.sql"),
|
|
||||||
"/docker-entrypoint-initdb.d/init.sql");
|
|
||||||
|
|
||||||
@SuppressWarnings("resource")
|
|
||||||
@Container
|
|
||||||
protected static final GenericContainer<?> redis =
|
|
||||||
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
|
|
||||||
.withExposedPorts(6379)
|
|
||||||
.withCommand("redis-server", "--requirepass", "test_redis_password");
|
|
||||||
|
|
||||||
@DynamicPropertySource
|
|
||||||
static void configureProperties(DynamicPropertyRegistry registry) {
|
|
||||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
|
||||||
registry.add("spring.datasource.username", postgres::getUsername);
|
|
||||||
registry.add("spring.datasource.password", postgres::getPassword);
|
|
||||||
registry.add("spring.data.redis.host", redis::getHost);
|
|
||||||
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
|
|
||||||
registry.add("spring.data.redis.password", () -> "test_redis_password");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clean only business data between tests to keep schema intact.
|
|
||||||
* Keep sys_company and sys_user since init.sql seeds them.
|
|
||||||
*/
|
|
||||||
@BeforeEach
|
|
||||||
void cleanData() {
|
|
||||||
jdbcTemplate.execute("TRUNCATE TABLE video_process_job, annotation_task_history, " +
|
|
||||||
"sys_operation_log, sys_config, export_batch, training_dataset, " +
|
|
||||||
"annotation_result, annotation_task, source_data RESTART IDENTITY CASCADE");
|
|
||||||
// Re-insert global sys_config entries that were truncated
|
|
||||||
jdbcTemplate.execute("INSERT INTO sys_config (company_id, config_key, config_value) VALUES " +
|
|
||||||
"(NULL, 'token_ttl_seconds', '7200'), " +
|
|
||||||
"(NULL, 'model_default', 'glm-4'), " +
|
|
||||||
"(NULL, 'video_frame_interval', '30') " +
|
|
||||||
"ON CONFLICT DO NOTHING");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Helper: get base URL for REST calls */
|
|
||||||
protected String baseUrl(String path) {
|
|
||||||
return "http://localhost:" + port + path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.label;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
|
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
|
||||||
class LabelBackendApplicationTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void contextLoads() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,413 +0,0 @@
|
|||||||
package com.label.blackbox;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.TestInfo;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.http.HttpEntity;
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpMethod;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
import org.springframework.beans.factory.config.YamlPropertiesFactoryBean;
|
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
|
||||||
|
|
||||||
import javax.sql.DataSource;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Properties;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.junit.jupiter.api.Assertions.fail;
|
|
||||||
|
|
||||||
abstract class AbstractBlackBoxTest {
|
|
||||||
|
|
||||||
private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(10);
|
|
||||||
private static final Properties APPLICATION = loadApplicationProperties();
|
|
||||||
|
|
||||||
protected final TestRestTemplate restTemplate = new TestRestTemplate();
|
|
||||||
protected final ObjectMapper objectMapper = new ObjectMapper();
|
|
||||||
|
|
||||||
protected JdbcTemplate jdbcTemplate;
|
|
||||||
protected String baseUrl;
|
|
||||||
protected String callbackSecret;
|
|
||||||
|
|
||||||
protected String runId;
|
|
||||||
protected Long companyId;
|
|
||||||
protected String companyCode;
|
|
||||||
protected String companyName;
|
|
||||||
|
|
||||||
protected TestUser adminUser;
|
|
||||||
protected TestUser reviewerUser;
|
|
||||||
protected TestUser annotatorUser;
|
|
||||||
protected TestUser annotator2User;
|
|
||||||
protected TestUser uploaderUser;
|
|
||||||
|
|
||||||
protected String adminToken;
|
|
||||||
protected String reviewerToken;
|
|
||||||
protected String annotatorToken;
|
|
||||||
protected String annotator2Token;
|
|
||||||
protected String uploaderToken;
|
|
||||||
|
|
||||||
protected boolean roleAwareAuthEnabled;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setUpBlackBox(TestInfo testInfo) {
|
|
||||||
this.jdbcTemplate = new JdbcTemplate(createDataSource());
|
|
||||||
this.baseUrl = resolveBaseUrl();
|
|
||||||
this.callbackSecret = resolved("video.callback-secret", "");
|
|
||||||
this.runId = buildRunId(testInfo);
|
|
||||||
|
|
||||||
assertBackendReachable();
|
|
||||||
createIsolatedCompanyAndUsers();
|
|
||||||
issueTokens();
|
|
||||||
detectRuntimeAuthMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanUpBlackBox() {
|
|
||||||
if (companyId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
jdbcTemplate.update("DELETE FROM annotation_task_history WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM sys_operation_log WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM video_process_job WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM training_dataset WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM annotation_result WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM export_batch WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM annotation_task WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM source_data WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM sys_config WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM sys_user WHERE company_id = ?", companyId);
|
|
||||||
jdbcTemplate.update("DELETE FROM sys_company WHERE id = ?", companyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void requireRoleAwareAuth() {
|
|
||||||
org.junit.jupiter.api.Assumptions.assumeTrue(
|
|
||||||
roleAwareAuthEnabled,
|
|
||||||
"当前运行中的 backend 未启用真实多角色认证,跳过依赖角色/租户隔离的黑盒用例");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<String> getRaw(String path) {
|
|
||||||
return restTemplate.getForEntity(url(path), String.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> get(String path, String token) {
|
|
||||||
return exchange(path, HttpMethod.GET, null, token, MediaType.APPLICATION_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> delete(String path, String token) {
|
|
||||||
return exchange(path, HttpMethod.DELETE, null, token, MediaType.APPLICATION_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> postJson(String path, Object body, String token) {
|
|
||||||
return exchange(path, HttpMethod.POST, body, token, MediaType.APPLICATION_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> putJson(String path, Object body, String token) {
|
|
||||||
return exchange(path, HttpMethod.PUT, body, token, MediaType.APPLICATION_JSON);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> upload(String path, String token, String filename, String dataType, byte[] bytes) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
|
||||||
if (token != null && !token.isBlank()) {
|
|
||||||
headers.setBearerAuth(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
||||||
body.add("dataType", dataType);
|
|
||||||
body.add("file", new ByteArrayResource(bytes) {
|
|
||||||
@Override
|
|
||||||
public String getFilename() {
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return restTemplate.exchange(url(path), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected ResponseEntity<Map> postVideoCallback(Map<String, Object> body) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
if (callbackSecret != null && !callbackSecret.isBlank()) {
|
|
||||||
headers.set("X-Callback-Secret", callbackSecret);
|
|
||||||
}
|
|
||||||
return restTemplate.exchange(url("/label/api/video/callback"), HttpMethod.POST, new HttpEntity<>(body, headers), Map.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected String login(String targetCompanyCode, String username, String password) {
|
|
||||||
Map<String, Object> body = Map.of(
|
|
||||||
"companyCode", targetCompanyCode,
|
|
||||||
"username", username,
|
|
||||||
"password", password
|
|
||||||
);
|
|
||||||
ResponseEntity<Map> response = postJson("/label/api/auth/login", body, null);
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
|
|
||||||
return String.valueOf(data.get("token"));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long uploadTextSource(String token) {
|
|
||||||
ResponseEntity<Map> response = upload(
|
|
||||||
"/label/api/source/upload",
|
|
||||||
token,
|
|
||||||
"bb-" + runId + ".txt",
|
|
||||||
"TEXT",
|
|
||||||
("hello-blackbox-" + runId).getBytes(StandardCharsets.UTF_8));
|
|
||||||
assertSuccess(response, HttpStatus.CREATED);
|
|
||||||
return dataId(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long uploadVideoSource(String token) {
|
|
||||||
ResponseEntity<Map> response = upload(
|
|
||||||
"/label/api/source/upload",
|
|
||||||
token,
|
|
||||||
"bb-" + runId + ".mp4",
|
|
||||||
"VIDEO",
|
|
||||||
("fake-video-" + runId).getBytes(StandardCharsets.UTF_8));
|
|
||||||
assertSuccess(response, HttpStatus.CREATED);
|
|
||||||
return dataId(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long createTask(Long sourceId, String taskType) {
|
|
||||||
ResponseEntity<Map> response = postJson("/label/api/tasks", Map.of(
|
|
||||||
"sourceId", sourceId,
|
|
||||||
"taskType", taskType
|
|
||||||
), adminToken);
|
|
||||||
assertSuccess(response, HttpStatus.OK);
|
|
||||||
return dataId(response);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long latestTaskId(Long sourceId, String taskType) {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM annotation_task WHERE company_id = ? AND source_id = ? AND task_type = ? " +
|
|
||||||
"ORDER BY id DESC LIMIT 1",
|
|
||||||
Long.class,
|
|
||||||
companyId, sourceId, taskType);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long latestApprovedDatasetId(Long sourceId) {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM training_dataset WHERE company_id = ? AND source_id = ? AND status = 'APPROVED' " +
|
|
||||||
"ORDER BY id DESC LIMIT 1",
|
|
||||||
Long.class,
|
|
||||||
companyId, sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long insertFailedVideoJob(Long sourceId) {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " +
|
|
||||||
"VALUES (?, ?, 'FRAME_EXTRACT', 'FAILED', '{}'::jsonb, 3, 3) RETURNING id",
|
|
||||||
Long.class,
|
|
||||||
companyId, sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long insertPendingVideoJob(Long sourceId) {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"INSERT INTO video_process_job (company_id, source_id, job_type, status, params, retry_count, max_retries) " +
|
|
||||||
"VALUES (?, ?, 'FRAME_EXTRACT', 'PENDING', '{}'::jsonb, 0, 3) RETURNING id",
|
|
||||||
Long.class,
|
|
||||||
companyId, sourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void assertSuccess(ResponseEntity<Map> response, HttpStatus expectedStatus) {
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(expectedStatus);
|
|
||||||
assertThat(response.getBody()).isNotNull();
|
|
||||||
assertThat(response.getBody().get("code")).isEqualTo("SUCCESS");
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long dataId(ResponseEntity<Map> response) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
|
|
||||||
return ((Number) data.get("id")).longValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected boolean responseContainsId(ResponseEntity<Map> response, Long id) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) Objects.requireNonNull(response.getBody()).get("data");
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> items = (List<Map<String, Object>>) data.get("items");
|
|
||||||
return items.stream().anyMatch(item -> id.equals(((Number) item.get("id")).longValue()));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected Long claimedByOfTask(Long taskId) {
|
|
||||||
return jdbcTemplate.queryForObject(
|
|
||||||
"SELECT claimed_by FROM annotation_task WHERE id = ?",
|
|
||||||
Long.class,
|
|
||||||
taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected String url(String path) {
|
|
||||||
return baseUrl + path;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolveBaseUrl() {
|
|
||||||
String override = System.getProperty("blackbox.base-url");
|
|
||||||
if (override == null || override.isBlank()) {
|
|
||||||
override = System.getenv("BLACKBOX_BASE_URL");
|
|
||||||
}
|
|
||||||
if (override != null && !override.isBlank()) {
|
|
||||||
return override.endsWith("/") ? override.substring(0, override.length() - 1) : override;
|
|
||||||
}
|
|
||||||
return "http://127.0.0.1:" + resolved("server.port", "8080");
|
|
||||||
}
|
|
||||||
|
|
||||||
private ResponseEntity<Map> exchange(String path,
|
|
||||||
HttpMethod method,
|
|
||||||
Object body,
|
|
||||||
String token,
|
|
||||||
MediaType contentType) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(contentType);
|
|
||||||
if (token != null && !token.isBlank()) {
|
|
||||||
headers.setBearerAuth(token);
|
|
||||||
}
|
|
||||||
return restTemplate.exchange(url(path), method, new HttpEntity<>(body, headers), Map.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void assertBackendReachable() {
|
|
||||||
try {
|
|
||||||
ResponseEntity<String> response = getRaw("/v3/api-docs");
|
|
||||||
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
fail("无法连接运行中的 backend,请确认服务已按 application.yml 配置启动: " + ex.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void createIsolatedCompanyAndUsers() {
|
|
||||||
this.companyCode = ("BB" + runId).toUpperCase(Locale.ROOT);
|
|
||||||
this.companyName = "黑盒测试-" + runId;
|
|
||||||
this.companyId = jdbcTemplate.queryForObject(
|
|
||||||
"INSERT INTO sys_company (company_name, company_code, status) VALUES (?, ?, 'ACTIVE') RETURNING id",
|
|
||||||
Long.class,
|
|
||||||
companyName, companyCode);
|
|
||||||
|
|
||||||
this.adminUser = insertUser("admin", "ADMIN");
|
|
||||||
this.reviewerUser = insertUser("reviewer", "REVIEWER");
|
|
||||||
this.annotatorUser = insertUser("annotator", "ANNOTATOR");
|
|
||||||
this.annotator2User = insertUser("annotator2", "ANNOTATOR");
|
|
||||||
this.uploaderUser = insertUser("uploader", "UPLOADER");
|
|
||||||
}
|
|
||||||
|
|
||||||
private TestUser insertUser(String namePrefix, String role) {
|
|
||||||
String username = (namePrefix + "_" + runId).toLowerCase(Locale.ROOT);
|
|
||||||
String password = "Bb@" + runId;
|
|
||||||
Long userId = jdbcTemplate.queryForObject(
|
|
||||||
"INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " +
|
|
||||||
"VALUES (?, ?, ?, ?, ?, 'ACTIVE') RETURNING id",
|
|
||||||
Long.class,
|
|
||||||
companyId,
|
|
||||||
username,
|
|
||||||
PASSWORD_ENCODER.encode(password),
|
|
||||||
"黑盒-" + namePrefix,
|
|
||||||
role);
|
|
||||||
return new TestUser(userId, username, password, role);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void issueTokens() {
|
|
||||||
this.adminToken = login(companyCode, adminUser.username(), adminUser.password());
|
|
||||||
this.reviewerToken = login(companyCode, reviewerUser.username(), reviewerUser.password());
|
|
||||||
this.annotatorToken = login(companyCode, annotatorUser.username(), annotatorUser.password());
|
|
||||||
this.annotator2Token = login(companyCode, annotator2User.username(), annotator2User.password());
|
|
||||||
this.uploaderToken = login(companyCode, uploaderUser.username(), uploaderUser.password());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void detectRuntimeAuthMode() {
|
|
||||||
try {
|
|
||||||
ResponseEntity<Map> adminMe = get("/label/api/auth/me", adminToken);
|
|
||||||
ResponseEntity<Map> reviewerMe = get("/label/api/auth/me", reviewerToken);
|
|
||||||
if (!adminMe.getStatusCode().is2xxSuccessful() || !reviewerMe.getStatusCode().is2xxSuccessful()) {
|
|
||||||
this.roleAwareAuthEnabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> adminData = (Map<String, Object>) Objects.requireNonNull(adminMe.getBody()).get("data");
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> reviewerData = (Map<String, Object>) Objects.requireNonNull(reviewerMe.getBody()).get("data");
|
|
||||||
|
|
||||||
this.roleAwareAuthEnabled =
|
|
||||||
adminUser.username().equals(adminData.get("username"))
|
|
||||||
&& reviewerUser.username().equals(reviewerData.get("username"))
|
|
||||||
&& "ADMIN".equals(adminData.get("role"))
|
|
||||||
&& "REVIEWER".equals(reviewerData.get("role"))
|
|
||||||
&& companyId.equals(((Number) adminData.get("companyId")).longValue())
|
|
||||||
&& companyId.equals(((Number) reviewerData.get("companyId")).longValue());
|
|
||||||
} catch (Exception ex) {
|
|
||||||
this.roleAwareAuthEnabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private DataSource createDataSource() {
|
|
||||||
DriverManagerDataSource dataSource = new DriverManagerDataSource();
|
|
||||||
dataSource.setDriverClassName(resolved("spring.datasource.driver-class-name", "org.postgresql.Driver"));
|
|
||||||
dataSource.setUrl(resolved("spring.datasource.url", ""));
|
|
||||||
dataSource.setUsername(resolved("spring.datasource.username", ""));
|
|
||||||
dataSource.setPassword(resolved("spring.datasource.password", ""));
|
|
||||||
return dataSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String resolved(String key, String fallback) {
|
|
||||||
String raw = APPLICATION.getProperty(key);
|
|
||||||
if (raw == null || raw.isBlank()) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return resolvePlaceholder(raw, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Properties loadApplicationProperties() {
|
|
||||||
YamlPropertiesFactoryBean yaml = new YamlPropertiesFactoryBean();
|
|
||||||
yaml.setResources(new ClassPathResource("application.yml"));
|
|
||||||
Properties properties = yaml.getObject();
|
|
||||||
if (properties == null) {
|
|
||||||
throw new IllegalStateException("无法加载 application.yml");
|
|
||||||
}
|
|
||||||
return properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String resolvePlaceholder(String raw, String fallback) {
|
|
||||||
if (!raw.startsWith("${") || !raw.endsWith("}")) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
String inner = raw.substring(2, raw.length() - 1);
|
|
||||||
int splitIndex = inner.indexOf(':');
|
|
||||||
if (splitIndex < 0) {
|
|
||||||
String envValue = System.getenv(inner);
|
|
||||||
return envValue != null ? envValue : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
String envKey = inner.substring(0, splitIndex);
|
|
||||||
String defaultValue = inner.substring(splitIndex + 1);
|
|
||||||
String envValue = System.getenv(envKey);
|
|
||||||
return envValue != null ? envValue : defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String buildRunId(TestInfo testInfo) {
|
|
||||||
String methodName = testInfo.getTestMethod().map(method -> method.getName()).orElse("case");
|
|
||||||
String normalized = methodName.replaceAll("[^a-zA-Z0-9]", "").toLowerCase(Locale.ROOT);
|
|
||||||
if (normalized.length() > 10) {
|
|
||||||
normalized = normalized.substring(0, 10);
|
|
||||||
}
|
|
||||||
return normalized + Long.toHexString(Instant.now().toEpochMilli()).substring(5) + UUID.randomUUID().toString().substring(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected record TestUser(Long id, String username, String password, String role) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
package com.label.blackbox;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
|
||||||
|
|
||||||
class SwaggerLiveBlackBoxTest extends AbstractBlackBoxTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公共接口与认证接口在真实运行环境下可访问")
|
|
||||||
void publicAndAuthEndpoints_shouldWork() {
|
|
||||||
ResponseEntity<String> openApi = getRaw("/v3/api-docs");
|
|
||||||
assertThat(openApi.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(openApi.getBody()).contains("/label/api/auth/login");
|
|
||||||
|
|
||||||
ResponseEntity<String> swaggerUi = getRaw("/swagger-ui.html");
|
|
||||||
assertThat(swaggerUi.getStatusCode().is2xxSuccessful() || swaggerUi.getStatusCode().is3xxRedirection()).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> login = postJson("/label/api/auth/login", Map.of(
|
|
||||||
"companyCode", companyCode,
|
|
||||||
"username", adminUser.username(),
|
|
||||||
"password", adminUser.password()
|
|
||||||
), null);
|
|
||||||
assertSuccess(login, HttpStatus.OK);
|
|
||||||
|
|
||||||
if (!roleAwareAuthEnabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseEntity<Map> me = get("/label/api/auth/me", adminToken);
|
|
||||||
assertSuccess(me, HttpStatus.OK);
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> meData = (Map<String, Object>) me.getBody().get("data");
|
|
||||||
assertThat(meData.get("username")).isEqualTo(adminUser.username());
|
|
||||||
assertThat(meData.get("role")).isEqualTo("ADMIN");
|
|
||||||
|
|
||||||
ResponseEntity<Map> logout = postJson("/label/api/auth/logout", null, adminToken);
|
|
||||||
assertSuccess(logout, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> meAfterLogout = get("/label/api/auth/me", adminToken);
|
|
||||||
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公司管理与用户管理接口在真实运行环境下可覆盖")
|
|
||||||
void companyAndUserEndpoints_shouldWork() {
|
|
||||||
requireRoleAwareAuth();
|
|
||||||
|
|
||||||
ResponseEntity<Map> companyList = get("/label/api/companies?page=1&pageSize=20", adminToken);
|
|
||||||
assertSuccess(companyList, HttpStatus.OK);
|
|
||||||
|
|
||||||
String extraCompanyCode = ("EXT" + runId).toUpperCase();
|
|
||||||
String extraCompanyName = "扩展公司-" + runId;
|
|
||||||
ResponseEntity<Map> createCompany = postJson("/label/api/companies", Map.of(
|
|
||||||
"companyName", extraCompanyName,
|
|
||||||
"companyCode", extraCompanyCode
|
|
||||||
), adminToken);
|
|
||||||
assertSuccess(createCompany, HttpStatus.CREATED);
|
|
||||||
Long extraCompanyId = dataId(createCompany);
|
|
||||||
|
|
||||||
ResponseEntity<Map> updateCompany = putJson("/label/api/companies/" + extraCompanyId, Map.of(
|
|
||||||
"companyName", extraCompanyName + "-改",
|
|
||||||
"companyCode", extraCompanyCode
|
|
||||||
), adminToken);
|
|
||||||
assertSuccess(updateCompany, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> companyStatus = putJson("/label/api/companies/" + extraCompanyId + "/status",
|
|
||||||
Map.of("status", "DISABLED"), adminToken);
|
|
||||||
assertSuccess(companyStatus, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> deleteCompany = delete("/label/api/companies/" + extraCompanyId, adminToken);
|
|
||||||
assertSuccess(deleteCompany, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> userList = get("/label/api/users?page=1&pageSize=20", adminToken);
|
|
||||||
assertSuccess(userList, HttpStatus.OK);
|
|
||||||
|
|
||||||
String username = "bb_user_" + UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
String password = "BbUser@123";
|
|
||||||
ResponseEntity<Map> createUser = postJson("/label/api/users", Map.of(
|
|
||||||
"username", username,
|
|
||||||
"password", password,
|
|
||||||
"realName", "黑盒用户",
|
|
||||||
"role", "ANNOTATOR"
|
|
||||||
), adminToken);
|
|
||||||
assertSuccess(createUser, HttpStatus.OK);
|
|
||||||
Long userId = dataId(createUser);
|
|
||||||
|
|
||||||
ResponseEntity<Map> updateUser = putJson("/label/api/users/" + userId, Map.of(
|
|
||||||
"realName", "黑盒用户-改",
|
|
||||||
"password", "BbUser@456"
|
|
||||||
), adminToken);
|
|
||||||
assertSuccess(updateUser, HttpStatus.OK);
|
|
||||||
|
|
||||||
String userToken = login(companyCode, username, "BbUser@456");
|
|
||||||
|
|
||||||
ResponseEntity<Map> beforeRoleChange = get("/label/api/tasks/pending-review", userToken);
|
|
||||||
assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
|
||||||
|
|
||||||
ResponseEntity<Map> updateRole = putJson("/label/api/users/" + userId + "/role",
|
|
||||||
Map.of("role", "REVIEWER"), adminToken);
|
|
||||||
assertSuccess(updateRole, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> afterRoleChange = get("/label/api/tasks/pending-review", userToken);
|
|
||||||
assertThat(afterRoleChange.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> updateStatus = putJson("/label/api/users/" + userId + "/status",
|
|
||||||
Map.of("status", "DISABLED"), adminToken);
|
|
||||||
assertSuccess(updateStatus, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> meAfterDisable = get("/label/api/auth/me", userToken);
|
|
||||||
assertThat(meAfterDisable.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("资料与任务管理接口在真实运行环境下可覆盖")
|
|
||||||
void sourceAndTaskEndpoints_shouldWork() {
|
|
||||||
requireRoleAwareAuth();
|
|
||||||
|
|
||||||
Long disposableSourceId = uploadTextSource(uploaderToken);
|
|
||||||
ResponseEntity<Map> deleteDisposable = delete("/label/api/source/" + disposableSourceId, adminToken);
|
|
||||||
assertSuccess(deleteDisposable, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long sourceId = uploadTextSource(uploaderToken);
|
|
||||||
|
|
||||||
ResponseEntity<Map> uploaderList = get("/label/api/source/list?page=1&pageSize=20", uploaderToken);
|
|
||||||
assertSuccess(uploaderList, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(uploaderList, sourceId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> adminList = get("/label/api/source/list?page=1&pageSize=20", adminToken);
|
|
||||||
assertSuccess(adminList, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(adminList, sourceId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> sourceDetail = get("/label/api/source/" + sourceId, adminToken);
|
|
||||||
assertSuccess(sourceDetail, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long taskId = createTask(sourceId, "EXTRACTION");
|
|
||||||
|
|
||||||
ResponseEntity<Map> pool = get("/label/api/tasks/pool?page=1&pageSize=20", annotatorToken);
|
|
||||||
assertSuccess(pool, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(pool, taskId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> allTasks = get("/label/api/tasks?page=1&pageSize=20&taskType=EXTRACTION", adminToken);
|
|
||||||
assertSuccess(allTasks, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(allTasks, taskId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> taskDetail = get("/label/api/tasks/" + taskId, annotatorToken);
|
|
||||||
assertSuccess(taskDetail, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> claim = postJson("/label/api/tasks/" + taskId + "/claim", null, annotatorToken);
|
|
||||||
assertSuccess(claim, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> mine = get("/label/api/tasks/mine?page=1&pageSize=20", annotatorToken);
|
|
||||||
assertSuccess(mine, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(mine, taskId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> unclaim = postJson("/label/api/tasks/" + taskId + "/unclaim", null, annotatorToken);
|
|
||||||
assertSuccess(unclaim, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long sourceId2 = uploadTextSource(uploaderToken);
|
|
||||||
Long taskId2 = createTask(sourceId2, "EXTRACTION");
|
|
||||||
ResponseEntity<Map> claimTask2 = postJson("/label/api/tasks/" + taskId2 + "/claim", null, annotatorToken);
|
|
||||||
assertSuccess(claimTask2, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> reassign = putJson("/label/api/tasks/" + taskId2 + "/reassign",
|
|
||||||
Map.of("userId", annotator2User.id()), adminToken);
|
|
||||||
assertSuccess(reassign, HttpStatus.OK);
|
|
||||||
assertThat(claimedByOfTask(taskId2)).isEqualTo(annotator2User.id());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("提取、问答、配置与导出接口在真实运行环境下可覆盖")
|
|
||||||
void extractionQaConfigAndExportEndpoints_shouldWork() {
|
|
||||||
requireRoleAwareAuth();
|
|
||||||
|
|
||||||
ResponseEntity<Map> listConfig = get("/label/api/config", adminToken);
|
|
||||||
assertSuccess(listConfig, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> updateConfig = putJson("/label/api/config/model_default",
|
|
||||||
Map.of("value", "glm-4-blackbox-" + runId, "description", "黑盒测试默认模型"),
|
|
||||||
adminToken);
|
|
||||||
assertSuccess(updateConfig, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long sourceId = uploadTextSource(uploaderToken);
|
|
||||||
Long extractionTaskId = createTask(sourceId, "EXTRACTION");
|
|
||||||
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> extractionGet = get("/label/api/extraction/" + extractionTaskId, annotatorToken);
|
|
||||||
assertSuccess(extractionGet, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> extractionPut = putJson("/label/api/extraction/" + extractionTaskId,
|
|
||||||
"{\"items\":[{\"label\":\"entity\",\"text\":\"北京\"}]}",
|
|
||||||
annotatorToken);
|
|
||||||
assertSuccess(extractionPut, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> extractionSubmit = postJson("/label/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken);
|
|
||||||
assertSuccess(extractionSubmit, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> pendingExtraction = get("/label/api/tasks/pending-review?page=1&pageSize=20&taskType=EXTRACTION", reviewerToken);
|
|
||||||
assertSuccess(pendingExtraction, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(pendingExtraction, extractionTaskId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> extractionReject = postJson("/label/api/extraction/" + extractionTaskId + "/reject",
|
|
||||||
Map.of("reason", "黑盒驳回一次"), reviewerToken);
|
|
||||||
assertSuccess(extractionReject, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> reclaim = postJson("/label/api/tasks/" + extractionTaskId + "/reclaim", null, annotatorToken);
|
|
||||||
assertSuccess(reclaim, HttpStatus.OK);
|
|
||||||
|
|
||||||
assertSuccess(putJson("/label/api/extraction/" + extractionTaskId,
|
|
||||||
"{\"items\":[{\"label\":\"entity\",\"text\":\"上海\"}]}",
|
|
||||||
annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> extractionApprove = postJson("/label/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken);
|
|
||||||
assertSuccess(extractionApprove, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION");
|
|
||||||
assertThat(qaTaskId).isNotNull();
|
|
||||||
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> qaGet = get("/label/api/qa/" + qaTaskId, annotatorToken);
|
|
||||||
assertSuccess(qaGet, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> qaPut = putJson("/label/api/qa/" + qaTaskId,
|
|
||||||
Map.of("items", List.of(Map.of("question", "北京在哪里", "answer", "中国"))),
|
|
||||||
annotatorToken);
|
|
||||||
assertSuccess(qaPut, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> qaSubmit = postJson("/label/api/qa/" + qaTaskId + "/submit", null, annotatorToken);
|
|
||||||
assertSuccess(qaSubmit, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> pendingQa = get("/label/api/tasks/pending-review?page=1&pageSize=20&taskType=QA_GENERATION", reviewerToken);
|
|
||||||
assertSuccess(pendingQa, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(pendingQa, qaTaskId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> qaApprove = postJson("/label/api/qa/" + qaTaskId + "/approve", null, reviewerToken);
|
|
||||||
assertSuccess(qaApprove, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> samples = get("/label/api/training/samples?page=1&pageSize=20&sampleType=TEXT", adminToken);
|
|
||||||
assertSuccess(samples, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long datasetId = latestApprovedDatasetId(sourceId);
|
|
||||||
ResponseEntity<Map> createBatch = postJson("/label/api/export/batch",
|
|
||||||
Map.of("sampleIds", List.of(datasetId)), adminToken);
|
|
||||||
assertSuccess(createBatch, HttpStatus.CREATED);
|
|
||||||
Long batchId = dataId(createBatch);
|
|
||||||
|
|
||||||
ResponseEntity<Map> exportList = get("/label/api/export/list?page=1&pageSize=20", adminToken);
|
|
||||||
assertSuccess(exportList, HttpStatus.OK);
|
|
||||||
assertThat(responseContainsId(exportList, batchId)).isTrue();
|
|
||||||
|
|
||||||
ResponseEntity<Map> exportStatus = get("/label/api/export/" + batchId + "/status", adminToken);
|
|
||||||
assertSuccess(exportStatus, HttpStatus.OK);
|
|
||||||
|
|
||||||
// 第二条链路覆盖 QA reject
|
|
||||||
Long sourceId2 = uploadTextSource(uploaderToken);
|
|
||||||
Long extractionTaskId2 = createTask(sourceId2, "EXTRACTION");
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + extractionTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/extraction/" + extractionTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/extraction/" + extractionTaskId2 + "/approve", null, reviewerToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
Long qaTaskId2 = latestTaskId(sourceId2, "QA_GENERATION");
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + qaTaskId2 + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/qa/" + qaTaskId2 + "/submit", null, annotatorToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> qaReject = postJson("/label/api/qa/" + qaTaskId2 + "/reject",
|
|
||||||
Map.of("reason", "黑盒问答驳回"), reviewerToken);
|
|
||||||
assertSuccess(qaReject, HttpStatus.OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("视频处理与微调接口在显式开启重链路模式时可覆盖")
|
|
||||||
void videoAndFinetuneEndpoints_shouldWorkWhenHeavyModeEnabled() {
|
|
||||||
requireRoleAwareAuth();
|
|
||||||
assumeTrue(Boolean.getBoolean("blackbox.heavy.enabled"),
|
|
||||||
"未开启 -Dblackbox.heavy.enabled=true,跳过视频处理与微调重链路黑盒用例");
|
|
||||||
|
|
||||||
Long sourceId = uploadTextSource(uploaderToken);
|
|
||||||
Long extractionTaskId = createTask(sourceId, "EXTRACTION");
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + extractionTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/extraction/" + extractionTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/extraction/" + extractionTaskId + "/approve", null, reviewerToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
Long qaTaskId = latestTaskId(sourceId, "QA_GENERATION");
|
|
||||||
assertSuccess(postJson("/label/api/tasks/" + qaTaskId + "/claim", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/qa/" + qaTaskId + "/submit", null, annotatorToken), HttpStatus.OK);
|
|
||||||
assertSuccess(postJson("/label/api/qa/" + qaTaskId + "/approve", null, reviewerToken), HttpStatus.OK);
|
|
||||||
|
|
||||||
Long datasetId = latestApprovedDatasetId(sourceId);
|
|
||||||
ResponseEntity<Map> createBatch = postJson("/label/api/export/batch",
|
|
||||||
Map.of("sampleIds", List.of(datasetId)), adminToken);
|
|
||||||
assertSuccess(createBatch, HttpStatus.CREATED);
|
|
||||||
Long batchId = dataId(createBatch);
|
|
||||||
|
|
||||||
ResponseEntity<Map> finetune = postJson("/label/api/export/" + batchId + "/finetune", null, adminToken);
|
|
||||||
assertSuccess(finetune, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long videoSourceId = uploadVideoSource(uploaderToken);
|
|
||||||
ResponseEntity<Map> createVideoJob = postJson("/label/api/video/process",
|
|
||||||
Map.of("sourceId", videoSourceId, "jobType", "FRAME_EXTRACT", "params", "{\"frameInterval\":30}"),
|
|
||||||
adminToken);
|
|
||||||
assertSuccess(createVideoJob, HttpStatus.OK);
|
|
||||||
Long jobId = dataId(createVideoJob);
|
|
||||||
|
|
||||||
ResponseEntity<Map> getVideoJob = get("/label/api/video/jobs/" + jobId, adminToken);
|
|
||||||
assertSuccess(getVideoJob, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long failedJobId = insertFailedVideoJob(videoSourceId);
|
|
||||||
ResponseEntity<Map> resetJob = postJson("/label/api/video/jobs/" + failedJobId + "/reset", null, adminToken);
|
|
||||||
assertSuccess(resetJob, HttpStatus.OK);
|
|
||||||
|
|
||||||
Long callbackJobId = insertPendingVideoJob(videoSourceId);
|
|
||||||
ResponseEntity<Map> callbackSuccess1 = postVideoCallback(Map.of(
|
|
||||||
"jobId", callbackJobId,
|
|
||||||
"status", "SUCCESS",
|
|
||||||
"outputPath", "processed/" + runId + "/frames.zip"
|
|
||||||
));
|
|
||||||
assertSuccess(callbackSuccess1, HttpStatus.OK);
|
|
||||||
|
|
||||||
ResponseEntity<Map> callbackSuccess2 = postVideoCallback(Map.of(
|
|
||||||
"jobId", callbackJobId,
|
|
||||||
"status", "SUCCESS",
|
|
||||||
"outputPath", "processed/" + runId + "/frames.zip"
|
|
||||||
));
|
|
||||||
assertSuccess(callbackSuccess2, HttpStatus.OK);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.common.result.Result;
|
|
||||||
import com.label.dto.LoginRequest;
|
|
||||||
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.core.ParameterizedTypeReference;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 认证流程集成测试(US1)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 正确密码登录 → 返回 token
|
|
||||||
* 2. 错误密码登录 → 401
|
|
||||||
* 3. 不存在的公司代码 → 401
|
|
||||||
* 4. 有效 Token 访问 /label/api/auth/me → 200,返回用户信息
|
|
||||||
* 5. 主动退出后,原 Token 访问 /label/api/auth/me → 401
|
|
||||||
*
|
|
||||||
* 测试数据来自 init.sql 种子(DEMO 公司 / admin / admin123)
|
|
||||||
*/
|
|
||||||
public class AuthIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 登录测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("正确密码登录 → 返回 token")
|
|
||||||
void login_withCorrectCredentials_returnsToken() {
|
|
||||||
ResponseEntity<Map> response = doLogin("DEMO", "admin", "admin123");
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
Map<?, ?> body = response.getBody();
|
|
||||||
assertThat(body).isNotNull();
|
|
||||||
assertThat(body.get("code")).isEqualTo("SUCCESS");
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) body.get("data");
|
|
||||||
assertThat(data.get("token")).isNotNull().isInstanceOf(String.class);
|
|
||||||
assertThat((String) data.get("token")).isNotBlank();
|
|
||||||
assertThat(data.get("role")).isEqualTo("ADMIN");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("错误密码登录 → 401 Unauthorized")
|
|
||||||
void login_withWrongPassword_returns401() {
|
|
||||||
ResponseEntity<Map> response = doLogin("DEMO", "admin", "wrong_password");
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("不存在的公司代码 → 401 Unauthorized")
|
|
||||||
void login_withUnknownCompany_returns401() {
|
|
||||||
ResponseEntity<Map> response = doLogin("NONEXIST", "admin", "admin123");
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ /me 测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("有效 Token 访问 /api/auth/me → 200,返回用户信息")
|
|
||||||
void me_withValidToken_returns200WithUserInfo() {
|
|
||||||
String token = loginAndGetToken("DEMO", "admin", "admin123");
|
|
||||||
assertThat(token).isNotBlank();
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/me"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(token),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
|
||||||
assertThat(data.get("username")).isEqualTo("admin");
|
|
||||||
assertThat(data.get("role")).isEqualTo("ADMIN");
|
|
||||||
assertThat(data.get("companyId")).isNotNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("无 Token 访问 /api/auth/me → 401")
|
|
||||||
void me_withNoToken_returns401() {
|
|
||||||
ResponseEntity<String> response = restTemplate.getForEntity(
|
|
||||||
baseUrl("/label/api/auth/me"), String.class);
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 退出测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("主动退出后,原 Token 访问 /api/auth/me → 401")
|
|
||||||
void logout_thenMe_returns401() {
|
|
||||||
String token = loginAndGetToken("DEMO", "admin", "admin123");
|
|
||||||
assertThat(token).isNotBlank();
|
|
||||||
|
|
||||||
// 确认登录有效
|
|
||||||
ResponseEntity<Map> meResponse = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/me"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(token),
|
|
||||||
Map.class);
|
|
||||||
assertThat(meResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 退出
|
|
||||||
ResponseEntity<Map> logoutResponse = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/logout"),
|
|
||||||
HttpMethod.POST,
|
|
||||||
bearerRequest(token),
|
|
||||||
Map.class);
|
|
||||||
assertThat(logoutResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 退出后再访问 /me → 401
|
|
||||||
ResponseEntity<Map> meAfterLogout = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/me"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(token),
|
|
||||||
Map.class);
|
|
||||||
assertThat(meAfterLogout.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
/** 发起登录请求,返回原始 ResponseEntity */
|
|
||||||
private ResponseEntity<Map> doLogin(String companyCode, String username, String password) {
|
|
||||||
LoginRequest req = new LoginRequest();
|
|
||||||
req.setCompanyCode(companyCode);
|
|
||||||
req.setUsername(username);
|
|
||||||
req.setPassword(password);
|
|
||||||
return restTemplate.postForEntity(baseUrl("/label/api/auth/login"), req, Map.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录并提取 token 字符串;失败时返回 null */
|
|
||||||
private String loginAndGetToken(String companyCode, String username, String password) {
|
|
||||||
ResponseEntity<Map> response = doLogin(companyCode, username, password);
|
|
||||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
|
||||||
return (String) data.get("token");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 构造带 Bearer Token 的请求实体(无 body) */
|
|
||||||
private HttpEntity<Void> bearerRequest(String token) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
return new HttpEntity<>(headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.*;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 训练数据导出集成测试(US6)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 包含非 APPROVED 样本时返回 400 INVALID_SAMPLES
|
|
||||||
* 2. sampleIds 为空时返回 400 EMPTY_SAMPLES
|
|
||||||
* 3. 非 ADMIN 访问 → 403 Forbidden
|
|
||||||
*
|
|
||||||
* 注意:实际上传 RustFS 需要 MinIO 容器支持,此处仅测试可验证的业务逻辑。
|
|
||||||
* 文件存在性验证需启动 MinIO 容器(超出当前测试范围)。
|
|
||||||
*/
|
|
||||||
public class ExportIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String ADMIN_TOKEN = "test-admin-token-export";
|
|
||||||
private static final String ANNOTATOR_TOKEN = "test-annotator-token-export";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private Long sourceId;
|
|
||||||
private Long approvedDatasetId;
|
|
||||||
private Long pendingDatasetId;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setupTokensAndData() {
|
|
||||||
Long companyId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
|
|
||||||
Long userId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
|
|
||||||
|
|
||||||
// 伪造 Redis Token
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
|
|
||||||
Map.of("userId", userId.toString(), "role", "ADMIN",
|
|
||||||
"companyId", companyId.toString(), "username", "admin"),
|
|
||||||
3600L);
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(ANNOTATOR_TOKEN),
|
|
||||||
Map.of("userId", "3", "role", "ANNOTATOR",
|
|
||||||
"companyId", companyId.toString(), "username", "annotator01"),
|
|
||||||
3600L);
|
|
||||||
|
|
||||||
// 插入 source_data
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (" + companyId + ", " + userId + ", 'TEXT', " +
|
|
||||||
"'test/export-test/file.txt', 'file.txt', 100, 'test-bucket', 'APPROVED')");
|
|
||||||
sourceId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 插入 EXTRACTION 任务(已 APPROVED,用于关联 training_dataset)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO annotation_task (company_id, source_id, task_type, status, is_final) " +
|
|
||||||
"VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'APPROVED', true)");
|
|
||||||
Long taskId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 插入 APPROVED training_dataset
|
|
||||||
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\":\"Q1\",\"answer\":\"A1\"}]}'::jsonb, " +
|
|
||||||
"'APPROVED')");
|
|
||||||
approvedDatasetId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 插入 PENDING_REVIEW training_dataset(用于测试校验失败)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO training_dataset (company_id, task_id, source_id, sample_type, " +
|
|
||||||
"glm_format_json, status) VALUES (" + companyId + ", " + taskId + ", " + sourceId +
|
|
||||||
", 'TEXT', '{\"conversations\":[]}'::jsonb, 'PENDING_REVIEW')");
|
|
||||||
pendingDatasetId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM training_dataset ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanupTokens() {
|
|
||||||
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
|
|
||||||
redisService.delete(RedisUtil.tokenKey(ANNOTATOR_TOKEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 权限测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非 ADMIN 访问导出接口 → 403 Forbidden")
|
|
||||||
void createBatch_byAnnotator_returns403() {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + ANNOTATOR_TOKEN);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
|
|
||||||
Map.of("sampleIds", List.of(approvedDatasetId)), headers);
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/export/batch"), HttpMethod.POST, req, Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 样本校验测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("sampleIds 为空 → 400 EMPTY_SAMPLES")
|
|
||||||
void createBatch_withEmptyIds_returns400() {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
|
|
||||||
Map.of("sampleIds", List.of()), headers);
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/export/batch"), HttpMethod.POST, req, Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
||||||
assertThat(response.getBody().get("code")).isEqualTo("EMPTY_SAMPLES");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("包含非 APPROVED 样本 → 400 INVALID_SAMPLES")
|
|
||||||
void createBatch_withNonApprovedSample_returns400() {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
// 混合 APPROVED + PENDING_REVIEW
|
|
||||||
HttpEntity<Map<String, Object>> req = new HttpEntity<>(
|
|
||||||
Map.of("sampleIds", List.of(approvedDatasetId, pendingDatasetId)), headers);
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/export/batch"), HttpMethod.POST, req, Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
||||||
assertThat(response.getBody().get("code")).isEqualTo("INVALID_SAMPLES");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("查询已审批样本列表 → 200,包含 APPROVED 样本")
|
|
||||||
void listSamples_adminOnly_returns200() {
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/training/samples"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
|
||||||
assertThat(((Number) data.get("total")).longValue()).isGreaterThanOrEqualTo(1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
private HttpEntity<Void> bearerRequest(String token) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
return new HttpEntity<>(headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取阶段审批集成测试(US4)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 审批通过 → QA_GENERATION 任务自动创建,source_data 状态更新为 QA_REVIEW
|
|
||||||
* 2. 审批人与提交人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN
|
|
||||||
* 3. 驳回后标注员可重领任务并再次提交
|
|
||||||
*/
|
|
||||||
public class ExtractionApprovalIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
private Long sourceId;
|
|
||||||
private Long taskId;
|
|
||||||
private Long annotatorUserId;
|
|
||||||
private Long reviewerUserId;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() {
|
|
||||||
// 获取种子用户 ID(init.sql 中已插入)
|
|
||||||
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_data
|
|
||||||
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/approval-test/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')");
|
|
||||||
sourceId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 插入 UNCLAIMED EXTRACTION 任务
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
|
|
||||||
"VALUES (" + companyId + ", " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')");
|
|
||||||
taskId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 1: 审批通过 → QA 任务自动创建 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("审批通过后,QA_GENERATION 任务自动创建,source_data 状态变为 QA_REVIEW")
|
|
||||||
void approveTask_thenQaTaskAndSourceStatusUpdated() {
|
|
||||||
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
|
|
||||||
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
|
|
||||||
|
|
||||||
// 1. 标注员领取任务
|
|
||||||
ResponseEntity<Map> claimResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/" + taskId + "/claim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 2. 标注员提交标注
|
|
||||||
ResponseEntity<Map> submitResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/extraction/" + taskId + "/submit"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 3. 审核员审批通过
|
|
||||||
// 注:ExtractionApprovedEventListener(@TransactionalEventListener AFTER_COMMIT)
|
|
||||||
// 在同一线程中同步执行,HTTP 响应返回前已完成后续处理
|
|
||||||
ResponseEntity<Map> approveResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/extraction/" + taskId + "/approve"),
|
|
||||||
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
|
|
||||||
assertThat(approveResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 验证:原任务状态变为 APPROVED,is_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);
|
|
||||||
|
|
||||||
// 验证:QA_GENERATION 任务已自动创建(UNCLAIMED 状态)
|
|
||||||
Integer qaTaskCount = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM annotation_task " +
|
|
||||||
"WHERE source_id = ? AND task_type = 'QA_GENERATION' AND status = 'UNCLAIMED'",
|
|
||||||
Integer.class, sourceId);
|
|
||||||
assertThat(qaTaskCount).as("QA_GENERATION 任务应已创建").isEqualTo(1);
|
|
||||||
|
|
||||||
// 验证: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");
|
|
||||||
|
|
||||||
// 验证:training_dataset 已以 PENDING_REVIEW 状态创建
|
|
||||||
Integer datasetCount = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM training_dataset " +
|
|
||||||
"WHERE source_id = ? AND status = 'PENDING_REVIEW'",
|
|
||||||
Integer.class, sourceId);
|
|
||||||
assertThat(datasetCount).as("training_dataset 应已创建").isEqualTo(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 2: 自审返回 403 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("审批人与任务领取人相同(自审)→ 403 SELF_REVIEW_FORBIDDEN")
|
|
||||||
void approveOwnSubmission_returnsForbidden() {
|
|
||||||
// 直接将任务置为 SUBMITTED 并设 claimed_by = reviewer01(模拟自审场景)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"UPDATE annotation_task " +
|
|
||||||
"SET status = 'SUBMITTED', claimed_by = " + reviewerUserId +
|
|
||||||
", claimed_at = NOW(), submitted_at = NOW() " +
|
|
||||||
"WHERE id = " + taskId);
|
|
||||||
|
|
||||||
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
|
|
||||||
|
|
||||||
ResponseEntity<Map> resp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/extraction/" + taskId + "/approve"),
|
|
||||||
HttpMethod.POST, bearerRequest(reviewerToken), Map.class);
|
|
||||||
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
|
||||||
|
|
||||||
// 验证任务状态未变
|
|
||||||
String status = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
|
|
||||||
assertThat(status).isEqualTo("SUBMITTED");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 3: 驳回 → 重领 → 再提交 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("驳回后标注员可重领任务并再次提交,任务状态恢复为 SUBMITTED")
|
|
||||||
void rejectThenReclaimAndResubmit_succeeds() {
|
|
||||||
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
|
|
||||||
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
|
|
||||||
|
|
||||||
// 1. 标注员领取并提交
|
|
||||||
restTemplate.exchange(baseUrl("/label/api/tasks/" + taskId + "/claim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
restTemplate.exchange(baseUrl("/label/api/extraction/" + taskId + "/submit"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
|
|
||||||
// 2. 审核员驳回(驳回原因必填)
|
|
||||||
HttpHeaders rejectHeaders = new HttpHeaders();
|
|
||||||
rejectHeaders.set("Authorization", "Bearer " + reviewerToken);
|
|
||||||
rejectHeaders.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
HttpEntity<Map<String, String>> rejectReq = new HttpEntity<>(
|
|
||||||
Map.of("reason", "实体识别有误,请重新标注"), rejectHeaders);
|
|
||||||
|
|
||||||
ResponseEntity<Map> rejectResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/extraction/" + 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");
|
|
||||||
|
|
||||||
// 3. 标注员重领任务(REJECTED → IN_PROGRESS)
|
|
||||||
ResponseEntity<Map> reclaimResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/" + taskId + "/reclaim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 验证:任务状态恢复为 IN_PROGRESS
|
|
||||||
String statusAfterReclaim = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
|
|
||||||
assertThat(statusAfterReclaim).isEqualTo("IN_PROGRESS");
|
|
||||||
|
|
||||||
// 4. 标注员再次提交(IN_PROGRESS → SUBMITTED)
|
|
||||||
ResponseEntity<Map> resubmitResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/extraction/" + 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("/label/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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
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.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 多租户隔离集成测试(Phase 10 / T070)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 公司 A 的 ADMIN 查询资料列表 → 只能看到公司 A 的资料,看不到公司 B 的
|
|
||||||
* 2. 公司 B 的 ADMIN 查询任务 → 只能看到公司 B 的任务,看不到公司 A 的
|
|
||||||
* 3. 公司 A 的 sys_config 配置不影响公司 B(配置隔离)
|
|
||||||
*/
|
|
||||||
public class MultiTenantIsolationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String TOKEN_A = "test-admin-token-company-a";
|
|
||||||
private static final String TOKEN_B = "test-admin-token-company-b";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private Long companyAId; // DEMO 公司(已在 init.sql 中创建)
|
|
||||||
private Long companyBId; // 测试用第二家公司
|
|
||||||
private Long adminAId; // DEMO 公司 admin
|
|
||||||
private Long adminBId; // 第二家公司 admin
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setupCompaniesAndTokens() {
|
|
||||||
// 公司 A:使用 init.sql 中的 DEMO 公司
|
|
||||||
companyAId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
|
|
||||||
adminAId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_user WHERE username = 'admin' AND company_id = ?",
|
|
||||||
Long.class, companyAId);
|
|
||||||
|
|
||||||
// 公司 B:在测试中创建第二家公司
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO sys_company (company_name, company_code, status) " +
|
|
||||||
"VALUES ('测试公司B', 'TESTB', 'ACTIVE') ON CONFLICT DO NOTHING");
|
|
||||||
companyBId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_company WHERE company_code = 'TESTB'", Long.class);
|
|
||||||
|
|
||||||
// 为公司 B 创建 admin 用户
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status) " +
|
|
||||||
"VALUES (" + companyBId + ", 'admin_b', " +
|
|
||||||
"'$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi', " +
|
|
||||||
"'B公司管理员', 'ADMIN', 'ACTIVE') ON CONFLICT (company_id, username) DO NOTHING");
|
|
||||||
adminBId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_user WHERE username = 'admin_b' AND company_id = ?",
|
|
||||||
Long.class, companyBId);
|
|
||||||
|
|
||||||
// 伪造 Redis Token
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(TOKEN_A),
|
|
||||||
Map.of("userId", adminAId.toString(), "role", "ADMIN",
|
|
||||||
"companyId", companyAId.toString(), "username", "admin"),
|
|
||||||
3600L);
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(TOKEN_B),
|
|
||||||
Map.of("userId", adminBId.toString(), "role", "ADMIN",
|
|
||||||
"companyId", companyBId.toString(), "username", "admin_b"),
|
|
||||||
3600L);
|
|
||||||
|
|
||||||
// 公司 A 插入两条 source_data
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (" + companyAId + ", " + adminAId + ", 'TEXT', " +
|
|
||||||
"'company-a/file1.txt', 'file1.txt', 100, 'label-source-data', 'PENDING'), " +
|
|
||||||
"(" + companyAId + ", " + adminAId + ", 'TEXT', " +
|
|
||||||
"'company-a/file2.txt', 'file2.txt', 200, 'label-source-data', 'PENDING')");
|
|
||||||
|
|
||||||
// 公司 B 插入一条 source_data
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (" + companyBId + ", " + adminBId + ", 'TEXT', " +
|
|
||||||
"'company-b/file1.txt', 'file1.txt', 300, 'label-source-data', 'PENDING')");
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanupTokensAndCompanyB() {
|
|
||||||
redisService.delete(RedisUtil.tokenKey(TOKEN_A));
|
|
||||||
redisService.delete(RedisUtil.tokenKey(TOKEN_B));
|
|
||||||
// 清理公司 B 的数据(sys_company 不在 cleanData TRUNCATE 范围内)
|
|
||||||
jdbcTemplate.execute("DELETE FROM sys_user WHERE username = 'admin_b'");
|
|
||||||
jdbcTemplate.execute("DELETE FROM sys_company WHERE company_code = 'TESTB'");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 1: 资料列表隔离 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公司 A 只能查看本公司资料,看不到公司 B 的资料")
|
|
||||||
void sourceList_companyA_cannotSeeCompanyBData() {
|
|
||||||
ResponseEntity<Map> resp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/list?page=1&pageSize=50"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(TOKEN_A),
|
|
||||||
Map.class);
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
|
|
||||||
assertThat(((Number) data.get("total")).longValue())
|
|
||||||
.as("公司 A 应只看到自己的 2 条资料")
|
|
||||||
.isEqualTo(2L);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> records = (List<Map<String, Object>>) data.get("records");
|
|
||||||
records.forEach(r ->
|
|
||||||
assertThat(((Number) r.get("companyId")).longValue())
|
|
||||||
.as("每条资料的 companyId 应为公司 A 的 ID")
|
|
||||||
.isEqualTo(companyAId));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公司 B 只能查看本公司资料,看不到公司 A 的资料")
|
|
||||||
void sourceList_companyB_cannotSeeCompanyAData() {
|
|
||||||
ResponseEntity<Map> resp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/list?page=1&pageSize=50"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(TOKEN_B),
|
|
||||||
Map.class);
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) resp.getBody().get("data");
|
|
||||||
assertThat(((Number) data.get("total")).longValue())
|
|
||||||
.as("公司 B 应只看到自己的 1 条资料")
|
|
||||||
.isEqualTo(1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 2: 配置隔离 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公司 A 设置专属配置,公司 B 仍使用全局默认")
|
|
||||||
void sysConfig_companyA_doesNotAffectCompanyB() {
|
|
||||||
// 公司 A 设置专属 model_default
|
|
||||||
HttpHeaders headersA = new HttpHeaders();
|
|
||||||
headersA.set("Authorization", "Bearer " + TOKEN_A);
|
|
||||||
headersA.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/config/model_default"),
|
|
||||||
HttpMethod.PUT,
|
|
||||||
new HttpEntity<>(Map.of("value", "glm-4-plus"), headersA),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
// 公司 B 查询配置列表
|
|
||||||
ResponseEntity<Map> respB = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/config"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(TOKEN_B),
|
|
||||||
Map.class);
|
|
||||||
assertThat(respB.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> configsB = (List<Map<String, Object>>) respB.getBody().get("data");
|
|
||||||
|
|
||||||
Map<String, Object> modelCfgB = configsB.stream()
|
|
||||||
.filter(c -> "model_default".equals(c.get("configKey")))
|
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (modelCfgB != null) {
|
|
||||||
// 公司 B 未设置专属,应使用全局默认 glm-4,scope=GLOBAL
|
|
||||||
assertThat(modelCfgB.get("scope"))
|
|
||||||
.as("公司 B 应使用全局默认配置,scope=GLOBAL")
|
|
||||||
.isEqualTo("GLOBAL");
|
|
||||||
assertThat(modelCfgB.get("configValue"))
|
|
||||||
.as("公司 B model_default 应为全局默认 glm-4,不受公司 A 设置影响")
|
|
||||||
.isEqualTo("glm-4");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
private HttpEntity<Void> bearerRequest(String token) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
return new HttpEntity<>(headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.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 = APPROVED,source_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_data(QA_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=APPROVED,source_data.status=APPROVED")
|
|
||||||
void approveQaTask_thenDatasetAndSourceApproved() {
|
|
||||||
String annotatorToken = loginAndGetToken("DEMO", "annotator01", "annot123");
|
|
||||||
String reviewerToken = loginAndGetToken("DEMO", "reviewer01", "review123");
|
|
||||||
|
|
||||||
// 注意:QA 任务 claim 端点为 POST /api/tasks/{id}/claim(ANNOTATOR 角色)
|
|
||||||
// 但 TaskController.getPool 只给 ANNOTATOR 显示 EXTRACTION/UNCLAIMED
|
|
||||||
// QA 任务由 ANNOTATOR 直接领取(不经过任务池)
|
|
||||||
ResponseEntity<Map> claimResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/" + taskId + "/claim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(claimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 提交 QA 结果
|
|
||||||
ResponseEntity<Map> submitResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/qa/" + taskId + "/submit"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(submitResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 审批通过
|
|
||||||
ResponseEntity<Map> approveResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/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 → APPROVED,is_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("/label/api/tasks/" + taskId + "/claim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
restTemplate.exchange(baseUrl("/label/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("/label/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("/label/api/tasks/" + taskId + "/reclaim"),
|
|
||||||
HttpMethod.POST, bearerRequest(annotatorToken), Map.class);
|
|
||||||
assertThat(reclaimResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 再次提交
|
|
||||||
ResponseEntity<Map> resubmitResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/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("/label/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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.*;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.core.io.ByteArrayResource;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
import org.springframework.util.LinkedMultiValueMap;
|
|
||||||
import org.springframework.util.MultiValueMap;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 原始资料管理集成测试(US2)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. UPLOADER 上传文本 → 列表仅返回自己的资料
|
|
||||||
* 2. ADMIN 查看列表 → 返回全公司资料
|
|
||||||
* 3. 上传视频 → status = PENDING(视频预处理由 Phase 9 处理)
|
|
||||||
* 4. 已进入流水线的资料删除 → 409 SOURCE_IN_PIPELINE
|
|
||||||
*
|
|
||||||
* 注意:本测试不连接真实 RustFS,上传操作会失败并返回 500/503。
|
|
||||||
* 测试仅验证可访问的业务逻辑(权限、状态机)。
|
|
||||||
* 如需覆盖文件上传,需在测试环境配置 Mock RustFsClient 或启动 MinIO 容器。
|
|
||||||
*/
|
|
||||||
public class SourceIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String UPLOADER_TOKEN = "test-uploader-token-source";
|
|
||||||
private static final String UPLOADER2_TOKEN = "test-uploader2-token-source";
|
|
||||||
private static final String ADMIN_TOKEN = "test-admin-token-source";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setupTokens() {
|
|
||||||
// uploader01 token (userId=4 from init.sql seed)
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(UPLOADER_TOKEN),
|
|
||||||
Map.of("userId", "4", "role", "UPLOADER", "companyId", "1", "username", "uploader01"),
|
|
||||||
3600L);
|
|
||||||
// admin token (userId=1 from init.sql seed)
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
|
|
||||||
Map.of("userId", "1", "role", "ADMIN", "companyId", "1", "username", "admin"),
|
|
||||||
3600L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanupTokens() {
|
|
||||||
redisService.delete(RedisUtil.tokenKey(UPLOADER_TOKEN));
|
|
||||||
redisService.delete(RedisUtil.tokenKey(UPLOADER2_TOKEN));
|
|
||||||
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 权限测试 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("无 Token 访问上传接口 → 401")
|
|
||||||
void upload_withoutToken_returns401() {
|
|
||||||
ResponseEntity<String> response = restTemplate.postForEntity(
|
|
||||||
baseUrl("/label/api/source/upload"), null, String.class);
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("UPLOADER 访问列表接口(无数据)→ 200,items 为空")
|
|
||||||
void list_uploaderWithNoData_returnsEmptyList() {
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/list"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(UPLOADER_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
|
||||||
assertThat(data.get("items")).isInstanceOf(List.class);
|
|
||||||
assertThat(((List<?>) data.get("items"))).isEmpty();
|
|
||||||
assertThat(((Number) data.get("total")).longValue()).isEqualTo(0L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("ADMIN 访问列表接口(无数据)→ 200,items 为空")
|
|
||||||
void list_adminWithNoData_returnsEmptyList() {
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/list"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> data = (Map<String, Object>) response.getBody().get("data");
|
|
||||||
assertThat(((List<?>) data.get("items"))).isEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("删除不存在的资料 → 404")
|
|
||||||
void delete_nonExistentSource_returns404() {
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/9999"),
|
|
||||||
HttpMethod.DELETE,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非 ADMIN 删除资料 → 403 Forbidden")
|
|
||||||
void delete_byUploader_returns403() {
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/9999"),
|
|
||||||
HttpMethod.DELETE,
|
|
||||||
bearerRequest(UPLOADER_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("ADMIN 删除已进入流水线的资料 → 409 SOURCE_IN_PIPELINE")
|
|
||||||
void delete_sourceInPipeline_returns409() {
|
|
||||||
// 直接向 DB 插入一条 EXTRACTING 状态的资料(模拟已进入流水线)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'EXTRACTING')");
|
|
||||||
|
|
||||||
Long sourceId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM source_data WHERE status='EXTRACTING' LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
assertThat(sourceId).isNotNull();
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/source/" + sourceId),
|
|
||||||
HttpMethod.DELETE,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> body = response.getBody();
|
|
||||||
assertThat(body.get("code")).isEqualTo("SOURCE_IN_PIPELINE");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
private HttpEntity<Void> bearerRequest(String token) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
return new HttpEntity<>(headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
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.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 系统配置集成测试(US8)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 公司专属配置覆盖全局默认
|
|
||||||
* 2. 未设置公司专属时,回退至全局默认
|
|
||||||
* 3. 未知配置键 → 400 UNKNOWN_CONFIG_KEY
|
|
||||||
*/
|
|
||||||
public class SysConfigIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String ADMIN_TOKEN = "test-admin-token-config";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private Long companyId;
|
|
||||||
private Long adminUserId;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setupToken() {
|
|
||||||
companyId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
|
|
||||||
adminUserId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
|
|
||||||
|
|
||||||
// 伪造 Redis Token
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
|
|
||||||
Map.of("userId", adminUserId.toString(), "role", "ADMIN",
|
|
||||||
"companyId", companyId.toString(), "username", "admin"),
|
|
||||||
3600L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanupTokens() {
|
|
||||||
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 1: 公司配置覆盖全局 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("公司专属配置优先于全局默认(scope=COMPANY 覆盖 scope=GLOBAL)")
|
|
||||||
void companyConfig_overridesGlobalDefault() {
|
|
||||||
// 设置公司专属配置(覆盖全局 model_default)
|
|
||||||
updateConfig("model_default", "glm-4-plus", "公司专属模型");
|
|
||||||
|
|
||||||
// 查询配置列表
|
|
||||||
ResponseEntity<Map> listResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/config"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> configs = (List<Map<String, Object>>) listResp.getBody().get("data");
|
|
||||||
assertThat(configs).isNotEmpty();
|
|
||||||
|
|
||||||
// 找到 model_default 配置
|
|
||||||
Map<String, Object> modelConfig = configs.stream()
|
|
||||||
.filter(c -> "model_default".equals(c.get("configKey")))
|
|
||||||
.findFirst()
|
|
||||||
.orElseThrow(() -> new AssertionError("model_default 配置不存在"));
|
|
||||||
|
|
||||||
// 应返回公司专属配置值,scope=COMPANY
|
|
||||||
assertThat(modelConfig.get("configValue"))
|
|
||||||
.as("公司专属配置应覆盖全局默认")
|
|
||||||
.isEqualTo("glm-4-plus");
|
|
||||||
assertThat(modelConfig.get("scope"))
|
|
||||||
.as("scope 应标记为 COMPANY")
|
|
||||||
.isEqualTo("COMPANY");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 2: 回退全局默认 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("未设置公司专属配置时,返回全局默认值(scope=GLOBAL)")
|
|
||||||
void globalConfig_usedWhenNoCompanyOverride() {
|
|
||||||
// 不设置公司专属,直接查询列表
|
|
||||||
ResponseEntity<Map> listResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/config"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(ADMIN_TOKEN),
|
|
||||||
Map.class);
|
|
||||||
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<Map<String, Object>> configs = (List<Map<String, Object>>) listResp.getBody().get("data");
|
|
||||||
|
|
||||||
// 至少包含 AbstractIntegrationTest.cleanData() 中插入的全局配置
|
|
||||||
assertThat(configs).isNotEmpty();
|
|
||||||
|
|
||||||
// 所有配置都应有 scope 字段
|
|
||||||
configs.forEach(cfg ->
|
|
||||||
assertThat(cfg.containsKey("scope")).as("每条配置应含 scope 字段").isTrue());
|
|
||||||
|
|
||||||
// token_ttl_seconds 全局默认应为 7200
|
|
||||||
Map<String, Object> ttlConfig = configs.stream()
|
|
||||||
.filter(c -> "token_ttl_seconds".equals(c.get("configKey")))
|
|
||||||
.findFirst()
|
|
||||||
.orElse(null);
|
|
||||||
|
|
||||||
if (ttlConfig != null) {
|
|
||||||
assertThat(ttlConfig.get("configValue")).isEqualTo("7200");
|
|
||||||
assertThat(ttlConfig.get("scope")).isEqualTo("GLOBAL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 3: 未知配置键 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("更新未知配置键 → 400 UNKNOWN_CONFIG_KEY")
|
|
||||||
void updateUnknownKey_returns400() {
|
|
||||||
ResponseEntity<Map> resp = updateConfig("unknown_key_xyz", "someValue", null);
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
|
||||||
assertThat(resp.getBody().get("code")).isEqualTo("UNKNOWN_CONFIG_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 4: UPSERT 同键两次 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("同一配置键两次 PUT → 第二次更新而非重复插入")
|
|
||||||
void updateSameKey_twice_upserts() {
|
|
||||||
updateConfig("video_frame_interval", "60", "帧间隔 60s");
|
|
||||||
updateConfig("video_frame_interval", "120", "帧间隔 120s");
|
|
||||||
|
|
||||||
// 数据库中公司专属 video_frame_interval 应只有一条记录
|
|
||||||
Integer count = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT COUNT(*) FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'",
|
|
||||||
Integer.class, companyId);
|
|
||||||
assertThat(count).as("UPSERT:同键应只有一条公司专属记录").isEqualTo(1);
|
|
||||||
|
|
||||||
// 值应为最后一次 PUT 的值
|
|
||||||
String value = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT config_value FROM sys_config WHERE company_id = ? AND config_key = 'video_frame_interval'",
|
|
||||||
String.class, companyId);
|
|
||||||
assertThat(value).isEqualTo("120");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
private ResponseEntity<Map> updateConfig(String key, String value, String description) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
Map<String, String> body = description != null
|
|
||||||
? Map.of("value", value, "description", description)
|
|
||||||
: Map.of("value", value);
|
|
||||||
|
|
||||||
return restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/config/" + key),
|
|
||||||
HttpMethod.PUT,
|
|
||||||
new HttpEntity<>(body, headers),
|
|
||||||
Map.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpEntity<Void> bearerRequest(String token) {
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
return new HttpEntity<>(headers);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.*;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.http.*;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.*;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 任务领取并发安全集成测试(US3)。
|
|
||||||
*
|
|
||||||
* 测试场景:10 个线程同时争抢同一 UNCLAIMED 任务。
|
|
||||||
* 期望结果:
|
|
||||||
* - 恰好 1 人成功(200 OK)
|
|
||||||
* - 其余 9 人收到 TASK_CLAIMED (409)
|
|
||||||
* - DB 中 claimed_by 唯一(只有一个用户 ID)
|
|
||||||
*
|
|
||||||
* 此测试需要 10 个不同的 userId,使用同一 DB 用户账号但不同的 Token。
|
|
||||||
*/
|
|
||||||
public class TaskClaimConcurrencyTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private Long taskId;
|
|
||||||
private final List<String> tokens = new ArrayList<>();
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() {
|
|
||||||
// 创建测试任务(直接向 DB 插入一条 UNCLAIMED 任务)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (1, 1, 'TEXT', 'test/path/file.txt', 'file.txt', 100, 'test-bucket', 'PENDING')");
|
|
||||||
Long sourceId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO annotation_task (company_id, source_id, task_type, status) " +
|
|
||||||
"VALUES (1, " + sourceId + ", 'EXTRACTION', 'UNCLAIMED')");
|
|
||||||
taskId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM annotation_task ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 创建 10 个 Annotator Token(模拟不同用户)
|
|
||||||
for (int i = 1; i <= 10; i++) {
|
|
||||||
String token = "concurrency-test-token-" + i;
|
|
||||||
tokens.add(token);
|
|
||||||
// 所有 Token 使用 userId=3(annotator01),这在真实场景不会发生
|
|
||||||
// 但在测试中用于验证并发锁机制(redis key 基于 taskId,不是 userId)
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(token),
|
|
||||||
Map.of("userId", String.valueOf(i + 100), // 假设 userId > 100 不存在,但不影响锁逻辑
|
|
||||||
"role", "ANNOTATOR", "companyId", "1", "username", "annotator" + i),
|
|
||||||
3600L);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanup() {
|
|
||||||
tokens.forEach(token -> redisService.delete(RedisUtil.tokenKey(token)));
|
|
||||||
if (taskId != null) {
|
|
||||||
redisService.delete(RedisUtil.taskClaimKey(taskId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("10 线程并发抢同一任务:恰好 1 人成功,其余 9 人收到 409 TASK_CLAIMED")
|
|
||||||
void concurrentClaim_onlyOneSucceeds() throws InterruptedException {
|
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(10);
|
|
||||||
CountDownLatch startLatch = new CountDownLatch(1);
|
|
||||||
CountDownLatch doneLatch = new CountDownLatch(10);
|
|
||||||
|
|
||||||
AtomicInteger successCount = new AtomicInteger(0);
|
|
||||||
AtomicInteger conflictCount = new AtomicInteger(0);
|
|
||||||
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
final String token = tokens.get(i);
|
|
||||||
executor.submit(() -> {
|
|
||||||
try {
|
|
||||||
startLatch.await(); // 等待起跑信号,最大化并发
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + token);
|
|
||||||
HttpEntity<Void> request = new HttpEntity<>(headers);
|
|
||||||
|
|
||||||
ResponseEntity<Map> response = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/" + taskId + "/claim"),
|
|
||||||
HttpMethod.POST, request, Map.class);
|
|
||||||
|
|
||||||
if (response.getStatusCode() == HttpStatus.OK) {
|
|
||||||
successCount.incrementAndGet();
|
|
||||||
} else if (response.getStatusCode() == HttpStatus.CONFLICT) {
|
|
||||||
conflictCount.incrementAndGet();
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
conflictCount.incrementAndGet(); // 异常也算失败
|
|
||||||
} finally {
|
|
||||||
doneLatch.countDown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startLatch.countDown(); // 同时放行所有线程
|
|
||||||
doneLatch.await(30, TimeUnit.SECONDS);
|
|
||||||
executor.shutdown();
|
|
||||||
|
|
||||||
// 恰好 1 人成功
|
|
||||||
assertThat(successCount.get()).isEqualTo(1);
|
|
||||||
// 其余 9 人失败(409 或异常)
|
|
||||||
assertThat(conflictCount.get()).isEqualTo(9);
|
|
||||||
|
|
||||||
// DB 中 claimed_by 有且仅有一个值
|
|
||||||
String claimedByStr = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT claimed_by::text FROM annotation_task WHERE id = ?",
|
|
||||||
String.class, taskId);
|
|
||||||
assertThat(claimedByStr).isNotNull();
|
|
||||||
|
|
||||||
// DB 中状态为 IN_PROGRESS
|
|
||||||
String status = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM annotation_task WHERE id = ?", String.class, taskId);
|
|
||||||
assertThat(status).isEqualTo("IN_PROGRESS");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.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 java.util.UUID;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户管理集成测试(US7)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 变更角色后权限下一次请求立即生效(无需重新登录)
|
|
||||||
* 2. 禁用账号后现有 Token 下一次请求立即返回 401
|
|
||||||
*/
|
|
||||||
public class UserManagementIntegrationTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
private String adminToken;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() {
|
|
||||||
adminToken = loginAndGetToken("DEMO", "admin", "admin123");
|
|
||||||
assertThat(adminToken).isNotBlank();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 1: 角色变更立即生效 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("创建用户为 ANNOTATOR,变更为 REVIEWER 后同一 Token 立即可访问审批接口")
|
|
||||||
void updateRole_takesEffectImmediately() {
|
|
||||||
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
|
|
||||||
// 1. 创建 ANNOTATOR 用户
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + adminToken);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
ResponseEntity<Map> createResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/users"),
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(Map.of(
|
|
||||||
"username", uniqueUsername,
|
|
||||||
"password", "test1234",
|
|
||||||
"realName", "测试用户",
|
|
||||||
"role", "ANNOTATOR"
|
|
||||||
), headers),
|
|
||||||
Map.class);
|
|
||||||
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> userData = (Map<String, Object>) createResp.getBody().get("data");
|
|
||||||
Long newUserId = ((Number) userData.get("id")).longValue();
|
|
||||||
|
|
||||||
// 2. 新用户登录获取 Token
|
|
||||||
String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234");
|
|
||||||
assertThat(userToken).isNotBlank();
|
|
||||||
|
|
||||||
// 3. 验证:ANNOTATOR 无法访问待审批队列(REVIEWER 专属)→ 403
|
|
||||||
ResponseEntity<Map> beforeRoleChange = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/pending-review"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(userToken),
|
|
||||||
Map.class);
|
|
||||||
assertThat(beforeRoleChange.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
|
||||||
|
|
||||||
// 4. ADMIN 变更角色为 REVIEWER
|
|
||||||
ResponseEntity<Map> roleResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/users/" + newUserId + "/role"),
|
|
||||||
HttpMethod.PUT,
|
|
||||||
new HttpEntity<>(Map.of("role", "REVIEWER"), headers),
|
|
||||||
Map.class);
|
|
||||||
assertThat(roleResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 5. 验证:同一 Token 下次请求立即具有 REVIEWER 权限 → 200
|
|
||||||
ResponseEntity<Map> afterRoleChange = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/tasks/pending-review"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(userToken),
|
|
||||||
Map.class);
|
|
||||||
assertThat(afterRoleChange.getStatusCode())
|
|
||||||
.as("角色变更后同一 Token 应立即具有 REVIEWER 权限")
|
|
||||||
.isEqualTo(HttpStatus.OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 2: 禁用账号 Token 立即失效 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("禁用账号后,现有 Token 下一次请求立即返回 401")
|
|
||||||
void disableAccount_tokenInvalidatedImmediately() {
|
|
||||||
String uniqueUsername = "testuser-" + UUID.randomUUID().toString().substring(0, 8);
|
|
||||||
|
|
||||||
// 1. 创建用户
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + adminToken);
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
|
|
||||||
ResponseEntity<Map> createResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/users"),
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(Map.of(
|
|
||||||
"username", uniqueUsername,
|
|
||||||
"password", "test1234",
|
|
||||||
"realName", "测试用户",
|
|
||||||
"role", "ANNOTATOR"
|
|
||||||
), headers),
|
|
||||||
Map.class);
|
|
||||||
assertThat(createResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
Map<String, Object> userData = (Map<String, Object>) createResp.getBody().get("data");
|
|
||||||
Long newUserId = ((Number) userData.get("id")).longValue();
|
|
||||||
|
|
||||||
// 2. 新用户登录,获取 Token
|
|
||||||
String userToken = loginAndGetToken("DEMO", uniqueUsername, "test1234");
|
|
||||||
assertThat(userToken).isNotBlank();
|
|
||||||
|
|
||||||
// 3. 验证 Token 有效
|
|
||||||
ResponseEntity<Map> meResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/me"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(userToken),
|
|
||||||
Map.class);
|
|
||||||
assertThat(meResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 4. ADMIN 禁用账号
|
|
||||||
ResponseEntity<Map> disableResp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/users/" + newUserId + "/status"),
|
|
||||||
HttpMethod.PUT,
|
|
||||||
new HttpEntity<>(Map.of("status", "DISABLED"), headers),
|
|
||||||
Map.class);
|
|
||||||
assertThat(disableResp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 5. 验证:禁用后,现有 Token 立即失效 → 401
|
|
||||||
ResponseEntity<Map> meAfterDisable = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/auth/me"),
|
|
||||||
HttpMethod.GET,
|
|
||||||
bearerRequest(userToken),
|
|
||||||
Map.class);
|
|
||||||
assertThat(meAfterDisable.getStatusCode())
|
|
||||||
.as("禁用账号后现有 Token 应立即失效")
|
|
||||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
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("/label/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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
package com.label.integration;
|
|
||||||
|
|
||||||
import com.label.AbstractIntegrationTest;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
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;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 视频处理回调幂等与重试集成测试(US8)。
|
|
||||||
*
|
|
||||||
* 测试场景:
|
|
||||||
* 1. 同一 jobId 收到两次 SUCCESS 回调:annotation_task(EXTRACTION)仅创建一次
|
|
||||||
* 2. 超出最大重试次数 → job.status = FAILED,source_data.status = PENDING
|
|
||||||
*/
|
|
||||||
public class VideoCallbackIdempotencyTest extends AbstractIntegrationTest {
|
|
||||||
|
|
||||||
private static final String ADMIN_TOKEN = "test-admin-token-video";
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TestRestTemplate restTemplate;
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private RedisService redisService;
|
|
||||||
|
|
||||||
private Long companyId;
|
|
||||||
private Long adminUserId;
|
|
||||||
private Long sourceId;
|
|
||||||
private Long jobId;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setupTokenAndData() {
|
|
||||||
companyId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_company WHERE company_code = 'DEMO'", Long.class);
|
|
||||||
adminUserId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM sys_user WHERE username = 'admin'", Long.class);
|
|
||||||
|
|
||||||
// 伪造 Redis Token
|
|
||||||
redisService.hSetAll(RedisUtil.tokenKey(ADMIN_TOKEN),
|
|
||||||
Map.of("userId", adminUserId.toString(), "role", "ADMIN",
|
|
||||||
"companyId", companyId.toString(), "username", "admin"),
|
|
||||||
3600L);
|
|
||||||
|
|
||||||
// 插入 source_data(PREPROCESSING 状态,模拟视频处理中)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO source_data (company_id, uploader_id, data_type, file_path, " +
|
|
||||||
"file_name, file_size, bucket_name, status) " +
|
|
||||||
"VALUES (" + companyId + ", " + adminUserId + ", 'VIDEO', " +
|
|
||||||
"'videos/test.mp4', 'test.mp4', 10240, 'label-source-data', 'PREPROCESSING')");
|
|
||||||
sourceId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM source_data ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
|
|
||||||
// 插入 PENDING 视频处理任务
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"INSERT INTO video_process_job (company_id, source_id, job_type, status, " +
|
|
||||||
"params, retry_count, max_retries) " +
|
|
||||||
"VALUES (" + companyId + ", " + sourceId + ", 'FRAME_EXTRACT', 'PENDING', " +
|
|
||||||
"'{}'::jsonb, 0, 3)");
|
|
||||||
jobId = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT id FROM video_process_job ORDER BY id DESC LIMIT 1", Long.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void cleanupTokens() {
|
|
||||||
redisService.delete(RedisUtil.tokenKey(ADMIN_TOKEN));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 1: 幂等性 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("同一 jobId 发送两次 SUCCESS 回调:source_data 仅更新一次,status=PENDING")
|
|
||||||
void successCallback_idempotent_sourceUpdatedOnce() {
|
|
||||||
// 第一次 SUCCESS 回调
|
|
||||||
ResponseEntity<Map> resp1 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null);
|
|
||||||
assertThat(resp1.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 验证第一次回调后状态
|
|
||||||
String jobStatus1 = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM video_process_job WHERE id = ?", String.class, jobId);
|
|
||||||
assertThat(jobStatus1).isEqualTo("SUCCESS");
|
|
||||||
|
|
||||||
String sourceStatus1 = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
|
|
||||||
assertThat(sourceStatus1).isEqualTo("PENDING");
|
|
||||||
|
|
||||||
// 第二次 SUCCESS 回调(幂等:应直接返回,不重复处理)
|
|
||||||
ResponseEntity<Map> resp2 = sendCallback(jobId, "SUCCESS", "processed/frames.zip", null);
|
|
||||||
assertThat(resp2.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 状态仍为 SUCCESS + PENDING,未被改变
|
|
||||||
String jobStatus2 = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM video_process_job WHERE id = ?", String.class, jobId);
|
|
||||||
assertThat(jobStatus2).as("幂等:第二次回调不应改变 job 状态").isEqualTo("SUCCESS");
|
|
||||||
|
|
||||||
String sourceStatus2 = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
|
|
||||||
assertThat(sourceStatus2).as("幂等:第二次回调不应改变 source_data 状态").isEqualTo("PENDING");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 2: 超出重试上限 → FAILED --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("超出最大重试次数后 → job.status=FAILED,source_data.status=PENDING")
|
|
||||||
void failedCallback_exceedsMaxRetries_jobBecomesFailedAndSourceReverts() {
|
|
||||||
// 将 retry_count 设为 max_retries-1(再失败一次就超限)
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"UPDATE video_process_job SET retry_count = 2, max_retries = 3, " +
|
|
||||||
"status = 'RETRYING' WHERE id = " + jobId);
|
|
||||||
|
|
||||||
// 发送最后一次 FAILED 回调(retry_count 变为 3 = max_retries → 超限)
|
|
||||||
ResponseEntity<Map> resp = sendCallback(jobId, "FAILED", null, "ffmpeg 处理超时");
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 验证 job → FAILED
|
|
||||||
Map<String, Object> jobRow = jdbcTemplate.queryForMap(
|
|
||||||
"SELECT status, retry_count, error_message FROM video_process_job WHERE id = ?", jobId);
|
|
||||||
assertThat(jobRow.get("status")).as("超出重试上限后 job 应为 FAILED").isEqualTo("FAILED");
|
|
||||||
assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(3);
|
|
||||||
assertThat(jobRow.get("error_message")).isEqualTo("ffmpeg 处理超时");
|
|
||||||
|
|
||||||
// 验证 source_data → PENDING(管理员可重新处理)
|
|
||||||
String sourceStatus = jdbcTemplate.queryForObject(
|
|
||||||
"SELECT status FROM source_data WHERE id = ?", String.class, sourceId);
|
|
||||||
assertThat(sourceStatus).as("超出重试上限后 source_data 应回退为 PENDING").isEqualTo("PENDING");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 测试 3: 管理员重置 --
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("管理员重置 FAILED 任务 → job.status=PENDING,retryCount=0")
|
|
||||||
void resetFailedJob_succeeds() {
|
|
||||||
// 先将任务置为 FAILED 状态
|
|
||||||
jdbcTemplate.execute(
|
|
||||||
"UPDATE video_process_job SET status = 'FAILED', retry_count = 3 WHERE id = " + jobId);
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.set("Authorization", "Bearer " + ADMIN_TOKEN);
|
|
||||||
ResponseEntity<Map> resp = restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/video/jobs/" + jobId + "/reset"),
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(headers),
|
|
||||||
Map.class);
|
|
||||||
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
|
|
||||||
// 验证
|
|
||||||
Map<String, Object> jobRow = jdbcTemplate.queryForMap(
|
|
||||||
"SELECT status, retry_count FROM video_process_job WHERE id = ?", jobId);
|
|
||||||
assertThat(jobRow.get("status")).isEqualTo("PENDING");
|
|
||||||
assertThat(((Number) jobRow.get("retry_count")).intValue()).isEqualTo(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------ 工具方法 --
|
|
||||||
|
|
||||||
private ResponseEntity<Map> sendCallback(Long jobId, String status,
|
|
||||||
String outputPath, String errorMessage) {
|
|
||||||
Map<String, Object> body;
|
|
||||||
if ("SUCCESS".equals(status)) {
|
|
||||||
body = Map.of("jobId", jobId, "status", status, "outputPath", outputPath);
|
|
||||||
} else {
|
|
||||||
body = Map.of("jobId", jobId, "status", status, "errorMessage",
|
|
||||||
errorMessage != null ? errorMessage : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
return restTemplate.exchange(
|
|
||||||
baseUrl("/label/api/video/callback"),
|
|
||||||
HttpMethod.POST,
|
|
||||||
new HttpEntity<>(body, headers),
|
|
||||||
Map.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
|
||||||
import org.springframework.core.env.PropertySource;
|
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@DisplayName("应用配置单元测试")
|
|
||||||
class ApplicationConfigTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("application.yml 提供 Swagger 和 auth 测试开关配置")
|
|
||||||
void applicationYaml_containsSwaggerAndAuthToggle() throws Exception {
|
|
||||||
PropertySource<?> source = new YamlPropertySourceLoader()
|
|
||||||
.load("application", new ClassPathResource("application.yml"))
|
|
||||||
.get(0);
|
|
||||||
|
|
||||||
assertThat(source.getProperty("springdoc.api-docs.enabled")).isEqualTo(true);
|
|
||||||
assertThat(source.getProperty("springdoc.api-docs.path")).isEqualTo("/v3/api-docs");
|
|
||||||
assertThat(source.getProperty("springdoc.swagger-ui.enabled")).isEqualTo(true);
|
|
||||||
assertThat(source.getProperty("springdoc.swagger-ui.path")).isEqualTo("/swagger-ui.html");
|
|
||||||
assertThat(source.getProperty("auth.enabled")).isEqualTo(true);
|
|
||||||
assertThat(source.getProperty("auth.mock-company-id")).isEqualTo(1);
|
|
||||||
assertThat(source.getProperty("auth.mock-user-id")).isEqualTo(1);
|
|
||||||
assertThat(source.getProperty("auth.mock-role")).isEqualTo("ADMIN");
|
|
||||||
assertThat(source.getProperty("logging.level.com.label")).isEqualTo("INFO");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("application.yml 默认值不指向公网服务或携带真实默认密码")
|
|
||||||
void applicationYaml_doesNotShipPublicInfrastructureDefaults() throws Exception {
|
|
||||||
String yaml = new ClassPathResource("application.yml")
|
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
assertThat(yaml).doesNotContain("39.107.112.174");
|
|
||||||
assertThat(yaml).doesNotContain("postgres!Pw");
|
|
||||||
assertThat(yaml).doesNotContain("jsti@2024");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("logback.xml 启用 60 MB 滚动文件日志")
|
|
||||||
void logback_enablesRollingFileAppender() throws Exception {
|
|
||||||
String xml = new ClassPathResource("logback.xml")
|
|
||||||
.getContentAsString(StandardCharsets.UTF_8);
|
|
||||||
|
|
||||||
assertThat(xml).contains("<maxFileSize>60MB</maxFileSize>");
|
|
||||||
assertThat(xml).contains("<appender-ref ref=\"FILE\"/>");
|
|
||||||
assertThat(xml).doesNotContain("<!-- <appender-ref ref=\"FILE\"/> -->");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.label.annotation.RequireAuth;
|
|
||||||
import com.label.annotation.RequireRole;
|
|
||||||
import com.label.common.auth.TokenPrincipal;
|
|
||||||
import com.label.common.context.CompanyContext;
|
|
||||||
import com.label.interceptor.AuthInterceptor;
|
|
||||||
import com.label.service.RedisService;
|
|
||||||
import com.label.util.RedisUtil;
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import org.junit.jupiter.api.AfterEach;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
|
||||||
import org.springframework.web.method.HandlerMethod;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@DisplayName("自定义认证鉴权拦截器测试")
|
|
||||||
class AuthInterceptorTest {
|
|
||||||
|
|
||||||
private final RedisService redisService = mock(RedisService.class);
|
|
||||||
private final AuthInterceptor interceptor = new AuthInterceptor(redisService, new ObjectMapper());
|
|
||||||
|
|
||||||
@AfterEach
|
|
||||||
void tearDown() {
|
|
||||||
CompanyContext.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("有效 Token 会注入 Principal、租户上下文并刷新 TTL")
|
|
||||||
void validTokenInjectsPrincipalAndRefreshesTtl() throws Exception {
|
|
||||||
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
|
|
||||||
ReflectionTestUtils.setField(interceptor, "tokenTtlSeconds", 7200L);
|
|
||||||
when(redisService.hGetAll(RedisUtil.tokenKey("valid-token"))).thenReturn(Map.of(
|
|
||||||
"userId", "10",
|
|
||||||
"role", "ADMIN",
|
|
||||||
"companyId", "20",
|
|
||||||
"username", "admin"
|
|
||||||
));
|
|
||||||
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/label/api/test/admin");
|
|
||||||
request.addHeader("Authorization", "Bearer valid-token");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
boolean proceed = interceptor.preHandle(request, response, handler("adminOnly"));
|
|
||||||
|
|
||||||
assertThat(proceed).isTrue();
|
|
||||||
TokenPrincipal principal = (TokenPrincipal) request.getAttribute("__token_principal__");
|
|
||||||
assertThat(principal.getUserId()).isEqualTo(10L);
|
|
||||||
assertThat(principal.getRole()).isEqualTo("ADMIN");
|
|
||||||
assertThat(CompanyContext.get()).isEqualTo(20L);
|
|
||||||
verify(redisService).expire(RedisUtil.tokenKey("valid-token"), 7200L);
|
|
||||||
verify(redisService).expire(RedisUtil.userSessionsKey(10L), 7200L);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("角色继承规则允许 ADMIN 访问 REVIEWER 接口")
|
|
||||||
void adminRoleInheritsReviewerRole() throws Exception {
|
|
||||||
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
|
|
||||||
when(redisService.hGetAll(RedisUtil.tokenKey("admin-token"))).thenReturn(Map.of(
|
|
||||||
"userId", "1",
|
|
||||||
"role", "ADMIN",
|
|
||||||
"companyId", "1",
|
|
||||||
"username", "admin"
|
|
||||||
));
|
|
||||||
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/label/api/test/reviewer");
|
|
||||||
request.addHeader("Authorization", "Bearer admin-token");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isTrue();
|
|
||||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("角色不足时返回 403")
|
|
||||||
void insufficientRoleReturnsForbidden() throws Exception {
|
|
||||||
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
|
|
||||||
when(redisService.hGetAll(RedisUtil.tokenKey("annotator-token"))).thenReturn(Map.of(
|
|
||||||
"userId", "2",
|
|
||||||
"role", "ANNOTATOR",
|
|
||||||
"companyId", "1",
|
|
||||||
"username", "annotator"
|
|
||||||
));
|
|
||||||
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/label/api/test/reviewer");
|
|
||||||
request.addHeader("Authorization", "Bearer annotator-token");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
assertThat(interceptor.preHandle(request, response, handler("reviewerOnly"))).isFalse();
|
|
||||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("缺少 Token 时返回 401")
|
|
||||||
void missingTokenReturnsUnauthorized() throws Exception {
|
|
||||||
ReflectionTestUtils.setField(interceptor, "authEnabled", true);
|
|
||||||
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/label/api/test/admin");
|
|
||||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
|
||||||
|
|
||||||
assertThat(interceptor.preHandle(request, response, handler("adminOnly"))).isFalse();
|
|
||||||
assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_UNAUTHORIZED);
|
|
||||||
verify(redisService, never()).hGetAll(org.mockito.ArgumentMatchers.anyString());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("UserContext 已被移除,避免重复维护用户 ThreadLocal")
|
|
||||||
void userContextClassShouldBeRemoved() {
|
|
||||||
assertThatThrownBy(() -> Class.forName("com.label.common.context.UserContext"))
|
|
||||||
.isInstanceOf(ClassNotFoundException.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("请求完成后清理公司 ThreadLocal")
|
|
||||||
void afterCompletionClearsCompanyContext() throws Exception {
|
|
||||||
CompanyContext.set(20L);
|
|
||||||
|
|
||||||
interceptor.afterCompletion(new MockHttpServletRequest(), new MockHttpServletResponse(),
|
|
||||||
handler("adminOnly"), null);
|
|
||||||
|
|
||||||
assertThat(CompanyContext.get()).isEqualTo(-1L);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static HandlerMethod handler(String methodName) throws NoSuchMethodException {
|
|
||||||
Method method = TestController.class.getDeclaredMethod(methodName);
|
|
||||||
return new HandlerMethod(new TestController(), method);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class TestController {
|
|
||||||
@RequireRole("ADMIN")
|
|
||||||
void adminOnly() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequireRole("REVIEWER")
|
|
||||||
void reviewerOnly() {
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequireAuth
|
|
||||||
void authenticatedOnly() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import com.label.common.exception.BusinessException;
|
|
||||||
import com.label.entity.SysCompany;
|
|
||||||
import com.label.mapper.SysCompanyMapper;
|
|
||||||
import com.label.mapper.SysUserMapper;
|
|
||||||
import com.label.service.CompanyService;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.verify;
|
|
||||||
import static org.mockito.Mockito.when;
|
|
||||||
|
|
||||||
@DisplayName("公司管理服务测试")
|
|
||||||
class CompanyServiceTest {
|
|
||||||
|
|
||||||
private final SysCompanyMapper companyMapper = mock(SysCompanyMapper.class);
|
|
||||||
private final SysUserMapper userMapper = mock(SysUserMapper.class);
|
|
||||||
private final CompanyService companyService = new CompanyService(companyMapper, userMapper);
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("创建公司时写入 ACTIVE 状态并保存公司代码")
|
|
||||||
void createCompanyInsertsActiveCompany() {
|
|
||||||
SysCompany company = companyService.create("测试公司", "TEST");
|
|
||||||
|
|
||||||
assertThat(company.getCompanyName()).isEqualTo("测试公司");
|
|
||||||
assertThat(company.getCompanyCode()).isEqualTo("TEST");
|
|
||||||
assertThat(company.getStatus()).isEqualTo("ACTIVE");
|
|
||||||
verify(companyMapper).insert(any(SysCompany.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("创建公司时拒绝重复公司代码")
|
|
||||||
void createCompanyRejectsDuplicateCode() {
|
|
||||||
SysCompany existing = new SysCompany();
|
|
||||||
existing.setId(1L);
|
|
||||||
when(companyMapper.selectByCompanyCode("DEMO")).thenReturn(existing);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> companyService.create("演示公司", "DEMO"))
|
|
||||||
.isInstanceOf(BusinessException.class)
|
|
||||||
.hasMessageContaining("公司代码已存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("禁用公司时只允许 ACTIVE 或 DISABLED")
|
|
||||||
void updateStatusRejectsInvalidStatus() {
|
|
||||||
SysCompany existing = new SysCompany();
|
|
||||||
existing.setId(1L);
|
|
||||||
existing.setStatus("ACTIVE");
|
|
||||||
when(companyMapper.selectById(1L)).thenReturn(existing);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> companyService.updateStatus(1L, "DELETED"))
|
|
||||||
.isInstanceOf(BusinessException.class)
|
|
||||||
.hasMessageContaining("公司状态不合法");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("删除公司时若仍有关联用户则拒绝删除")
|
|
||||||
void deleteRejectsCompanyWithUsers() {
|
|
||||||
SysCompany existing = new SysCompany();
|
|
||||||
existing.setId(1L);
|
|
||||||
when(companyMapper.selectById(1L)).thenReturn(existing);
|
|
||||||
when(userMapper.countByCompanyId(1L)).thenReturn(2L);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> companyService.delete(1L))
|
|
||||||
.isInstanceOf(BusinessException.class)
|
|
||||||
.hasMessageContaining("公司下仍存在用户");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import com.label.controller.AuthController;
|
|
||||||
import com.label.controller.CompanyController;
|
|
||||||
import com.label.controller.ExportController;
|
|
||||||
import com.label.controller.ExtractionController;
|
|
||||||
import com.label.controller.QaController;
|
|
||||||
import com.label.controller.SourceController;
|
|
||||||
import com.label.controller.SysConfigController;
|
|
||||||
import com.label.controller.TaskController;
|
|
||||||
import com.label.controller.UserController;
|
|
||||||
import com.label.controller.VideoController;
|
|
||||||
import com.label.dto.SourceResponse;
|
|
||||||
import com.label.dto.TaskResponse;
|
|
||||||
import com.label.dto.LoginRequest;
|
|
||||||
import com.label.dto.LoginResponse;
|
|
||||||
import com.label.dto.UserInfoResponse;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
|
||||||
import org.springframework.web.bind.annotation.PutMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
|
||||||
import java.lang.reflect.Modifier;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
@DisplayName("OpenAPI 注解覆盖测试")
|
|
||||||
class OpenApiAnnotationTest {
|
|
||||||
|
|
||||||
private static final List<Class<?>> CONTROLLERS = List.of(
|
|
||||||
AuthController.class,
|
|
||||||
CompanyController.class,
|
|
||||||
UserController.class,
|
|
||||||
SourceController.class,
|
|
||||||
TaskController.class,
|
|
||||||
ExtractionController.class,
|
|
||||||
QaController.class,
|
|
||||||
ExportController.class,
|
|
||||||
SysConfigController.class,
|
|
||||||
VideoController.class
|
|
||||||
);
|
|
||||||
|
|
||||||
private static final List<Class<?>> DTOS = List.of(
|
|
||||||
LoginRequest.class,
|
|
||||||
LoginResponse.class,
|
|
||||||
UserInfoResponse.class,
|
|
||||||
TaskResponse.class,
|
|
||||||
SourceResponse.class
|
|
||||||
);
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("所有 REST Controller 都声明 @Tag")
|
|
||||||
void allControllersHaveTag() {
|
|
||||||
assertThat(CONTROLLERS)
|
|
||||||
.allSatisfy(controller ->
|
|
||||||
assertThat(controller.getAnnotation(Tag.class))
|
|
||||||
.as(controller.getSimpleName() + " should have @Tag")
|
|
||||||
.isNotNull());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("所有 REST endpoint 方法都声明 @Operation")
|
|
||||||
void allEndpointMethodsHaveOperation() {
|
|
||||||
for (Class<?> controller : CONTROLLERS) {
|
|
||||||
Arrays.stream(controller.getDeclaredMethods())
|
|
||||||
.filter(method -> !Modifier.isPrivate(method.getModifiers()))
|
|
||||||
.filter(OpenApiAnnotationTest::isEndpointMethod)
|
|
||||||
.forEach(method -> assertThat(method.getAnnotation(Operation.class))
|
|
||||||
.as(controller.getSimpleName() + "." + method.getName() + " should have @Operation")
|
|
||||||
.isNotNull());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("核心 DTO 都声明 @Schema")
|
|
||||||
void coreDtosHaveSchema() {
|
|
||||||
assertThat(DTOS)
|
|
||||||
.allSatisfy(dto ->
|
|
||||||
assertThat(dto.getAnnotation(Schema.class))
|
|
||||||
.as(dto.getSimpleName() + " should have @Schema")
|
|
||||||
.isNotNull());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean isEndpointMethod(Method method) {
|
|
||||||
return method.isAnnotationPresent(GetMapping.class)
|
|
||||||
|| method.isAnnotationPresent(PostMapping.class)
|
|
||||||
|| method.isAnnotationPresent(PutMapping.class)
|
|
||||||
|| method.isAnnotationPresent(DeleteMapping.class)
|
|
||||||
|| method.isAnnotationPresent(RequestMapping.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
||||||
|
|
||||||
@DisplayName("标准目录扁平化迁移守卫测试")
|
|
||||||
class PackageStructureMigrationTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("基础设施类已迁移到目标目录")
|
|
||||||
void infrastructureTypesMoved() {
|
|
||||||
assertClassExists("com.label.annotation.OperationLog");
|
|
||||||
assertClassExists("com.label.aspect.AuditAspect");
|
|
||||||
assertClassExists("com.label.config.MybatisPlusConfig");
|
|
||||||
assertClassExists("com.label.config.OpenApiConfig");
|
|
||||||
assertClassExists("com.label.config.RedisConfig");
|
|
||||||
assertClassExists("com.label.event.ExtractionApprovedEvent");
|
|
||||||
assertClassExists("com.label.listener.ExtractionApprovedEventListener");
|
|
||||||
|
|
||||||
assertClassMissing("com.label.common.aop.OperationLog");
|
|
||||||
assertClassMissing("com.label.common.aop.AuditAspect");
|
|
||||||
assertClassMissing("com.label.common.config.MybatisPlusConfig");
|
|
||||||
assertClassMissing("com.label.common.config.OpenApiConfig");
|
|
||||||
assertClassMissing("com.label.common.config.RedisConfig");
|
|
||||||
assertClassMissing("com.label.module.annotation.event.ExtractionApprovedEvent");
|
|
||||||
assertClassMissing("com.label.module.annotation.service.ExtractionApprovedEventListener");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("DTO、实体、Mapper 已迁移到扁平数据层")
|
|
||||||
void dataTypesMoved() {
|
|
||||||
for (String fqcn : java.util.List.of(
|
|
||||||
"com.label.dto.LoginRequest",
|
|
||||||
"com.label.dto.LoginResponse",
|
|
||||||
"com.label.dto.UserInfoResponse",
|
|
||||||
"com.label.dto.TaskResponse",
|
|
||||||
"com.label.dto.SourceResponse",
|
|
||||||
"com.label.entity.AnnotationResult",
|
|
||||||
"com.label.entity.TrainingDataset",
|
|
||||||
"com.label.entity.SysConfig",
|
|
||||||
"com.label.entity.ExportBatch",
|
|
||||||
"com.label.entity.SourceData",
|
|
||||||
"com.label.entity.AnnotationTask",
|
|
||||||
"com.label.entity.AnnotationTaskHistory",
|
|
||||||
"com.label.entity.SysCompany",
|
|
||||||
"com.label.entity.SysUser",
|
|
||||||
"com.label.entity.VideoProcessJob",
|
|
||||||
"com.label.mapper.AnnotationResultMapper",
|
|
||||||
"com.label.mapper.TrainingDatasetMapper",
|
|
||||||
"com.label.mapper.SysConfigMapper",
|
|
||||||
"com.label.mapper.ExportBatchMapper",
|
|
||||||
"com.label.mapper.SourceDataMapper",
|
|
||||||
"com.label.mapper.AnnotationTaskMapper",
|
|
||||||
"com.label.mapper.TaskHistoryMapper",
|
|
||||||
"com.label.mapper.SysCompanyMapper",
|
|
||||||
"com.label.mapper.SysUserMapper",
|
|
||||||
"com.label.mapper.VideoProcessJobMapper")) {
|
|
||||||
assertClassExists(fqcn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("服务类已迁移到扁平 service 目录")
|
|
||||||
void serviceTypesMoved() {
|
|
||||||
for (String fqcn : java.util.List.of(
|
|
||||||
"com.label.service.ExtractionService",
|
|
||||||
"com.label.service.QaService",
|
|
||||||
"com.label.service.SysConfigService",
|
|
||||||
"com.label.service.ExportService",
|
|
||||||
"com.label.service.FinetuneService",
|
|
||||||
"com.label.service.SourceService",
|
|
||||||
"com.label.service.TaskClaimService",
|
|
||||||
"com.label.service.TaskService",
|
|
||||||
"com.label.service.AuthService",
|
|
||||||
"com.label.service.UserService",
|
|
||||||
"com.label.service.VideoProcessService")) {
|
|
||||||
assertClassExists(fqcn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("控制器类已迁移到扁平 controller 目录")
|
|
||||||
void controllerTypesMoved() {
|
|
||||||
for (String fqcn : java.util.List.of(
|
|
||||||
"com.label.controller.AuthController",
|
|
||||||
"com.label.controller.UserController",
|
|
||||||
"com.label.controller.SourceController",
|
|
||||||
"com.label.controller.TaskController",
|
|
||||||
"com.label.controller.ExtractionController",
|
|
||||||
"com.label.controller.QaController",
|
|
||||||
"com.label.controller.ExportController",
|
|
||||||
"com.label.controller.SysConfigController",
|
|
||||||
"com.label.controller.VideoController")) {
|
|
||||||
assertClassExists(fqcn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("源码中不再引用 legacy module 与 common 目录包名")
|
|
||||||
void sourceTreeHasNoLegacyPackageReferences() throws Exception {
|
|
||||||
java.nio.file.Path self = java.nio.file.Path.of(
|
|
||||||
"src", "test", "java", "com", "label", "unit", "PackageStructureMigrationTest.java");
|
|
||||||
try (java.util.stream.Stream<java.nio.file.Path> paths = java.nio.file.Files.walk(java.nio.file.Path.of("src"))) {
|
|
||||||
java.util.List<String> violations = paths
|
|
||||||
.filter(path -> path.toString().endsWith(".java"))
|
|
||||||
.filter(path -> !path.normalize().endsWith(self))
|
|
||||||
.map(path -> {
|
|
||||||
try {
|
|
||||||
String text = java.nio.file.Files.readString(path);
|
|
||||||
boolean legacy = text.contains("com.label.module.")
|
|
||||||
|| text.contains("com.label.common.aop")
|
|
||||||
|| text.contains("com.label.common.config");
|
|
||||||
return legacy ? path.toString() : null;
|
|
||||||
} catch (Exception e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(java.util.Objects::nonNull)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
org.assertj.core.api.Assertions.assertThat(violations).isEmpty();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void assertClassExists(String fqcn) {
|
|
||||||
assertThatCode(() -> Class.forName(fqcn)).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void assertClassMissing(String fqcn) {
|
|
||||||
assertThatThrownBy(() -> Class.forName(fqcn)).isInstanceOf(ClassNotFoundException.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
package com.label.unit;
|
|
||||||
|
|
||||||
import com.label.common.exception.BusinessException;
|
|
||||||
import com.label.common.statemachine.*;
|
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Nested;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for all state machine enums and StateValidator.
|
|
||||||
* No Spring context needed - pure unit tests.
|
|
||||||
*/
|
|
||||||
@DisplayName("状态机单元测试")
|
|
||||||
class StateMachineTest {
|
|
||||||
|
|
||||||
// ===== SourceStatus =====
|
|
||||||
@Nested
|
|
||||||
@DisplayName("SourceStatus 状态机")
|
|
||||||
class SourceStatusTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PENDING → EXTRACTING(文本/图片直接提取)")
|
|
||||||
void pendingToExtracting() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.EXTRACTING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PENDING → PREPROCESSING(视频上传)")
|
|
||||||
void pendingToPreprocessing() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.PREPROCESSING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PREPROCESSING → PENDING(视频预处理完成)")
|
|
||||||
void preprocessingToPending() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PREPROCESSING, SourceStatus.PENDING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:EXTRACTING → QA_REVIEW(提取审批通过)")
|
|
||||||
void extractingToQaReview() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.EXTRACTING, SourceStatus.QA_REVIEW)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:QA_REVIEW → APPROVED(QA 审批通过)")
|
|
||||||
void qaReviewToApproved() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.QA_REVIEW, SourceStatus.APPROVED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:APPROVED → PENDING 抛出异常")
|
|
||||||
void approvedToPendingFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.APPROVED, SourceStatus.PENDING)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:PENDING → APPROVED(跳过中间状态)抛出异常")
|
|
||||||
void pendingToApprovedFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(SourceStatus.TRANSITIONS, SourceStatus.PENDING, SourceStatus.APPROVED)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== TaskStatus =====
|
|
||||||
@Nested
|
|
||||||
@DisplayName("TaskStatus 状态机")
|
|
||||||
class TaskStatusTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:UNCLAIMED → IN_PROGRESS(领取)")
|
|
||||||
void unclaimedToInProgress() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.IN_PROGRESS)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:IN_PROGRESS → SUBMITTED(提交)")
|
|
||||||
void inProgressToSubmitted() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.SUBMITTED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:IN_PROGRESS → UNCLAIMED(放弃)")
|
|
||||||
void inProgressToUnclaimed() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.UNCLAIMED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:IN_PROGRESS → IN_PROGRESS(ADMIN 强制转移,持有人变更)")
|
|
||||||
void inProgressToInProgress() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.IN_PROGRESS, TaskStatus.IN_PROGRESS)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:SUBMITTED → APPROVED(审批通过)")
|
|
||||||
void submittedToApproved() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.APPROVED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:SUBMITTED → REJECTED(审批驳回)")
|
|
||||||
void submittedToRejected() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.SUBMITTED, TaskStatus.REJECTED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:REJECTED → IN_PROGRESS(标注员重领)")
|
|
||||||
void rejectedToInProgress() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.REJECTED, TaskStatus.IN_PROGRESS)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:APPROVED → IN_PROGRESS 抛出异常")
|
|
||||||
void approvedToInProgressFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.APPROVED, TaskStatus.IN_PROGRESS)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:UNCLAIMED → SUBMITTED(跳过 IN_PROGRESS)抛出异常")
|
|
||||||
void unclaimedToSubmittedFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(TaskStatus.TRANSITIONS, TaskStatus.UNCLAIMED, TaskStatus.SUBMITTED)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== DatasetStatus =====
|
|
||||||
@Nested
|
|
||||||
@DisplayName("DatasetStatus 状态机")
|
|
||||||
class DatasetStatusTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PENDING_REVIEW → APPROVED")
|
|
||||||
void pendingReviewToApproved() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.APPROVED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PENDING_REVIEW → REJECTED")
|
|
||||||
void pendingReviewToRejected() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.PENDING_REVIEW, DatasetStatus.REJECTED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:REJECTED → PENDING_REVIEW(重新提交)")
|
|
||||||
void rejectedToPendingReview() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.REJECTED, DatasetStatus.PENDING_REVIEW)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:APPROVED → REJECTED 抛出异常")
|
|
||||||
void approvedToRejectedFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(DatasetStatus.TRANSITIONS, DatasetStatus.APPROVED, DatasetStatus.REJECTED)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== VideoJobStatus =====
|
|
||||||
@Nested
|
|
||||||
@DisplayName("VideoJobStatus 状态机")
|
|
||||||
class VideoJobStatusTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:PENDING → RUNNING")
|
|
||||||
void pendingToRunning() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.PENDING, VideoJobStatus.RUNNING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:RUNNING → SUCCESS")
|
|
||||||
void runningToSuccess() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.SUCCESS)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:RUNNING → RETRYING(失败且未超重试次数)")
|
|
||||||
void runningToRetrying() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.RETRYING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:RUNNING → FAILED(失败且超过最大重试)")
|
|
||||||
void runningToFailed() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RUNNING, VideoJobStatus.FAILED)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("合法转换:RETRYING → RUNNING(AI 重试)")
|
|
||||||
void retryingToRunning() {
|
|
||||||
assertThatCode(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.RETRYING, VideoJobStatus.RUNNING)
|
|
||||||
).doesNotThrowAnyException();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:FAILED → PENDING 不在状态机内(ADMIN 手动触发,不走 StateValidator)")
|
|
||||||
void failedToPendingNotInStateMachine() {
|
|
||||||
// FAILED → PENDING is intentionally NOT in TRANSITIONS (ADMIN manual reset via special API)
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.FAILED, VideoJobStatus.PENDING)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("非法转换:SUCCESS → RUNNING 抛出异常")
|
|
||||||
void successToRunningFails() {
|
|
||||||
assertThatThrownBy(() ->
|
|
||||||
StateValidator.assertTransition(VideoJobStatus.TRANSITIONS, VideoJobStatus.SUCCESS, VideoJobStatus.RUNNING)
|
|
||||||
).isInstanceOf(BusinessException.class)
|
|
||||||
.extracting("code").isEqualTo("INVALID_STATE_TRANSITION");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
-- label_backend init.sql
|
|
||||||
-- PostgreSQL 14+
|
|
||||||
-- 按依赖顺序建全部 11 张表:
|
|
||||||
-- sys_company → sys_user → source_data → annotation_task → annotation_result
|
|
||||||
-- → training_dataset → export_batch → sys_config → sys_operation_log
|
|
||||||
-- → annotation_task_history → video_process_job
|
|
||||||
-- 含所有索引及初始配置数据
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 扩展
|
|
||||||
-- ============================================================
|
|
||||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 1. sys_company(租户)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS sys_company (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_name VARCHAR(100) NOT NULL,
|
|
||||||
company_code VARCHAR(50) NOT NULL,
|
|
||||||
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uk_sys_company_name UNIQUE (company_name),
|
|
||||||
CONSTRAINT uk_sys_company_code UNIQUE (company_code)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 2. sys_user(用户)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS sys_user (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
username VARCHAR(50) NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL, -- BCrypt, strength >= 10
|
|
||||||
real_name VARCHAR(50),
|
|
||||||
role VARCHAR(20) NOT NULL, -- UPLOADER / ANNOTATOR / REVIEWER / ADMIN
|
|
||||||
status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / DISABLED
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT uk_sys_user_company_username UNIQUE (company_id, username)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sys_user_company_id
|
|
||||||
ON sys_user (company_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 3. source_data(原始资料)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS source_data (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
uploader_id BIGINT REFERENCES sys_user(id),
|
|
||||||
data_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO
|
|
||||||
file_path VARCHAR(500) NOT NULL, -- RustFS object path
|
|
||||||
file_name VARCHAR(255) NOT NULL,
|
|
||||||
file_size BIGINT,
|
|
||||||
bucket_name VARCHAR(100) NOT NULL,
|
|
||||||
parent_source_id BIGINT REFERENCES source_data(id), -- 视频帧 / 文本片段
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
|
||||||
-- PENDING / PREPROCESSING / EXTRACTING / QA_REVIEW / APPROVED
|
|
||||||
reject_reason TEXT, -- 保留字段(当前无 REJECTED 状态)
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_data_company_id
|
|
||||||
ON source_data (company_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_data_company_status
|
|
||||||
ON source_data (company_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_source_data_parent_source_id
|
|
||||||
ON source_data (parent_source_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 4. annotation_task(标注任务)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS annotation_task (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
source_id BIGINT NOT NULL REFERENCES source_data(id),
|
|
||||||
task_type VARCHAR(30) NOT NULL, -- EXTRACTION / QA_GENERATION
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'UNCLAIMED',
|
|
||||||
-- UNCLAIMED / IN_PROGRESS / SUBMITTED / APPROVED / REJECTED
|
|
||||||
claimed_by BIGINT REFERENCES sys_user(id),
|
|
||||||
claimed_at TIMESTAMP,
|
|
||||||
submitted_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
is_final BOOLEAN NOT NULL DEFAULT FALSE, -- true 即 APPROVED 且无需再审
|
|
||||||
ai_model VARCHAR(50),
|
|
||||||
reject_reason TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_task_company_status
|
|
||||||
ON annotation_task (company_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_task_source_id
|
|
||||||
ON annotation_task (source_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_task_claimed_by
|
|
||||||
ON annotation_task (claimed_by);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 5. annotation_result(标注结果,JSONB)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS annotation_result (
|
|
||||||
id BIGSERIAL NOT NULL,
|
|
||||||
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
result_json JSONB NOT NULL DEFAULT '[]'::jsonb, -- 整体替换语义
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT pk_annotation_result PRIMARY KEY (id),
|
|
||||||
CONSTRAINT uk_annotation_result_task_id UNIQUE (task_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_result_task_id
|
|
||||||
ON annotation_result (task_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_result_company_id
|
|
||||||
ON annotation_result (company_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 6. training_dataset(训练数据集)
|
|
||||||
-- export_batch_id FK 在 export_batch 建完后补加
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS training_dataset (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
|
|
||||||
source_id BIGINT NOT NULL REFERENCES source_data(id),
|
|
||||||
sample_type VARCHAR(20) NOT NULL, -- TEXT / IMAGE / VIDEO_FRAME
|
|
||||||
glm_format_json JSONB NOT NULL, -- GLM fine-tune 格式
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING_REVIEW',
|
|
||||||
-- PENDING_REVIEW / APPROVED / REJECTED
|
|
||||||
export_batch_id BIGINT, -- 导出后填写,FK 在下方补加
|
|
||||||
exported_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_dataset_company_status
|
|
||||||
ON training_dataset (company_id, status);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_training_dataset_task_id
|
|
||||||
ON training_dataset (task_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 7. export_batch(导出批次)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS export_batch (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
batch_uuid UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
||||||
sample_count INT NOT NULL DEFAULT 0,
|
|
||||||
dataset_file_path VARCHAR(500), -- 导出 JSONL 的 RustFS 路径
|
|
||||||
glm_job_id VARCHAR(100), -- GLM fine-tune 任务 ID
|
|
||||||
finetune_status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED',
|
|
||||||
-- NOT_STARTED / RUNNING / COMPLETED / FAILED
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_export_batch_company_id
|
|
||||||
ON export_batch (company_id);
|
|
||||||
|
|
||||||
-- 补加 training_dataset.export_batch_id FK
|
|
||||||
ALTER TABLE training_dataset
|
|
||||||
ADD CONSTRAINT fk_training_dataset_export_batch
|
|
||||||
FOREIGN KEY (export_batch_id) REFERENCES export_batch(id)
|
|
||||||
NOT VALID; -- 允许已有 NULL 行,不强制回溯校验
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 8. sys_config(系统配置)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS sys_config (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT REFERENCES sys_company(id), -- NULL = 全局默认
|
|
||||||
config_key VARCHAR(100) NOT NULL,
|
|
||||||
config_value TEXT NOT NULL,
|
|
||||||
description VARCHAR(255),
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 公司级配置唯一索引
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_company_key
|
|
||||||
ON sys_config (company_id, config_key)
|
|
||||||
WHERE company_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- 全局配置唯一索引
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_global_key
|
|
||||||
ON sys_config (config_key)
|
|
||||||
WHERE company_id IS NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sys_config_company_key
|
|
||||||
ON sys_config (company_id, config_key);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 9. sys_operation_log(操作日志,仅追加)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS sys_operation_log (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
operator_id BIGINT REFERENCES sys_user(id),
|
|
||||||
operation_type VARCHAR(50) NOT NULL, -- 例如 EXTRACTION_APPROVE / USER_LOGIN
|
|
||||||
target_id BIGINT,
|
|
||||||
target_type VARCHAR(50),
|
|
||||||
detail JSONB,
|
|
||||||
result VARCHAR(10), -- SUCCESS / FAILURE
|
|
||||||
error_message TEXT,
|
|
||||||
operated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
-- 无 updated_at(仅追加表,永不更新)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sys_operation_log_company_operated_at
|
|
||||||
ON sys_operation_log (company_id, operated_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_sys_operation_log_operator_id
|
|
||||||
ON sys_operation_log (operator_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 10. annotation_task_history(任务状态历史,仅追加)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS annotation_task_history (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
task_id BIGINT NOT NULL REFERENCES annotation_task(id),
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
from_status VARCHAR(20),
|
|
||||||
to_status VARCHAR(20) NOT NULL,
|
|
||||||
operator_id BIGINT REFERENCES sys_user(id),
|
|
||||||
operator_role VARCHAR(20),
|
|
||||||
comment TEXT,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
-- 无 updated_at(仅追加表,永不更新)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_task_history_task_id
|
|
||||||
ON annotation_task_history (task_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_annotation_task_history_company_id
|
|
||||||
ON annotation_task_history (company_id);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 11. video_process_job(视频处理作业)
|
|
||||||
-- ============================================================
|
|
||||||
CREATE TABLE IF NOT EXISTS video_process_job (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
company_id BIGINT NOT NULL REFERENCES sys_company(id),
|
|
||||||
source_id BIGINT NOT NULL REFERENCES source_data(id),
|
|
||||||
job_type VARCHAR(30) NOT NULL, -- FRAME_EXTRACT / VIDEO_TO_TEXT
|
|
||||||
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
|
|
||||||
-- PENDING / RUNNING / SUCCESS / FAILED / RETRYING
|
|
||||||
params JSONB, -- 例如 {"frameInterval": 30, "mode": "FRAME"}
|
|
||||||
output_path VARCHAR(500), -- 完成后的 RustFS 输出路径
|
|
||||||
retry_count INT NOT NULL DEFAULT 0,
|
|
||||||
max_retries INT NOT NULL DEFAULT 3,
|
|
||||||
error_message TEXT,
|
|
||||||
started_at TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_video_process_job_company_id
|
|
||||||
ON video_process_job (company_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_video_process_job_source_id
|
|
||||||
ON video_process_job (source_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_video_process_job_status
|
|
||||||
ON video_process_job (status);
|
|
||||||
|
|
||||||
-- ============================================================
|
|
||||||
-- 初始数据
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
-- 1. 演示公司
|
|
||||||
INSERT INTO sys_company (company_name, company_code, status)
|
|
||||||
VALUES ('演示公司', 'DEMO', 'ACTIVE')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
|
|
||||||
-- 2. 初始用户(BCrypt strength=10)
|
|
||||||
-- admin / admin123
|
|
||||||
-- reviewer01/ review123
|
|
||||||
-- annotator01/annot123
|
|
||||||
-- uploader01 / upload123
|
|
||||||
INSERT INTO sys_user (company_id, username, password_hash, real_name, role, status)
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
u.username,
|
|
||||||
u.password_hash,
|
|
||||||
u.real_name,
|
|
||||||
u.role,
|
|
||||||
'ACTIVE'
|
|
||||||
FROM sys_company c
|
|
||||||
CROSS JOIN (VALUES
|
|
||||||
('admin',
|
|
||||||
'$2a$10$B8iR5z43URiNPm.eut3JvufIPBuvGx5ZZmqyUqE1A1WdbZppX5bmi',
|
|
||||||
'管理员',
|
|
||||||
'ADMIN'),
|
|
||||||
('reviewer01',
|
|
||||||
'$2a$10$euOJZRfUtYNW7WHpfW1Ciee5b3rjkYFe3yQHT/uCQWrYVc0XQcukm',
|
|
||||||
'审核员01',
|
|
||||||
'REVIEWER'),
|
|
||||||
('annotator01',
|
|
||||||
'$2a$10$8UKwHPNASauKMTrqosR0Reg1X1gkFzFlGa/HBwNLXUELaj4e/zcqu',
|
|
||||||
'标注员01',
|
|
||||||
'ANNOTATOR'),
|
|
||||||
('uploader01',
|
|
||||||
'$2a$10$o2d7jsT31vyxIJHUo50mUefoZLLvGqft97zaL9OQCjRxn9ie1H/1O',
|
|
||||||
'上传员01',
|
|
||||||
'UPLOADER')
|
|
||||||
) AS u(username, password_hash, real_name, role)
|
|
||||||
WHERE c.company_code = 'DEMO'
|
|
||||||
ON CONFLICT (company_id, username) DO NOTHING;
|
|
||||||
|
|
||||||
-- 3. 全局系统配置
|
|
||||||
INSERT INTO sys_config (company_id, config_key, config_value, description)
|
|
||||||
VALUES
|
|
||||||
(NULL, 'token_ttl_seconds', '7200',
|
|
||||||
'会话凭证有效期(秒)'),
|
|
||||||
(NULL, 'model_default', 'glm-4',
|
|
||||||
'AI 辅助默认模型'),
|
|
||||||
(NULL, 'video_frame_interval', '30',
|
|
||||||
'视频帧提取间隔(帧数)'),
|
|
||||||
(NULL, 'prompt_extract_text',
|
|
||||||
'请提取以下文本中的主语-谓语-宾语三元组,以JSON数组格式返回,每个元素包含subject、predicate、object、sourceText、startOffset、endOffset字段。',
|
|
||||||
'文本三元组提取 Prompt 模板'),
|
|
||||||
(NULL, 'prompt_extract_image',
|
|
||||||
'请提取图片中的实体关系四元组,以JSON数组格式返回,每个元素包含subject、relation、object、modifier、confidence字段。',
|
|
||||||
'图片四元组提取 Prompt 模板'),
|
|
||||||
(NULL, 'prompt_qa_gen_text',
|
|
||||||
'根据以下文本三元组生成高质量问答对,以JSON数组格式返回,每个元素包含question、answer、difficulty字段。',
|
|
||||||
'文本问答生成 Prompt 模板'),
|
|
||||||
(NULL, 'prompt_qa_gen_image',
|
|
||||||
'根据以下图片四元组生成高质量问答对,以JSON数组格式返回,每个元素包含question、answer、imageRef、difficulty字段。',
|
|
||||||
'图片问答生成 Prompt 模板')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
Reference in New Issue
Block a user