feat(phase9-10): US8 视频处理与系统配置模块 + 代码审查修复
Phase 9 (US8):
- VideoProcessJob 实体 + VideoProcessJobMapper
- SysConfig 实体 + SysConfigMapper(手动多租户查询)
- VideoProcessService:createJob/handleCallback(幂等)/reset
- T074 修复:AI 触发通过 TransactionSynchronization.afterCommit() 延迟至事务提交后
- VideoController:4 个端点,/api/video/callback 无需认证
- SysConfigService:公司专属优先 > 全局默认回退,UPSERT 仅允许已知键
- SysConfigController:GET /api/config + PUT /api/config/{key}
- TokenFilter:/api/video/callback 绕过 Token 认证
- 集成测试:VideoCallbackIdempotencyTest、SysConfigIntegrationTest
Phase 10 (代码审查与修复):
- T070 MultiTenantIsolationTest:跨公司资料/配置隔离验证
- T071 SourceController.upload():ResponseEntity<Result<T>> → Result<T> + @ResponseStatus
- T074 FinetuneService.trigger():移除 @Transactional,AI 调用在事务外执行
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package com.label.integration;
|
||||
|
||||
import com.label.AbstractIntegrationTest;
|
||||
import com.label.common.redis.RedisKeyManager;
|
||||
import com.label.common.redis.RedisService;
|
||||
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(RedisKeyManager.tokenKey(TOKEN_A),
|
||||
Map.of("userId", adminAId.toString(), "role", "ADMIN",
|
||||
"companyId", companyAId.toString(), "username", "admin"),
|
||||
3600L);
|
||||
redisService.hSetAll(RedisKeyManager.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(RedisKeyManager.tokenKey(TOKEN_A));
|
||||
redisService.delete(RedisKeyManager.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("/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("/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("/api/config/model_default"),
|
||||
HttpMethod.PUT,
|
||||
new HttpEntity<>(Map.of("value", "glm-4-plus"), headersA),
|
||||
Map.class);
|
||||
|
||||
// 公司 B 查询配置列表
|
||||
ResponseEntity<Map> respB = restTemplate.exchange(
|
||||
baseUrl("/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.label.integration;
|
||||
|
||||
import com.label.AbstractIntegrationTest;
|
||||
import com.label.common.redis.RedisKeyManager;
|
||||
import com.label.common.redis.RedisService;
|
||||
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(RedisKeyManager.tokenKey(ADMIN_TOKEN),
|
||||
Map.of("userId", adminUserId.toString(), "role", "ADMIN",
|
||||
"companyId", companyId.toString(), "username", "admin"),
|
||||
3600L);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanupTokens() {
|
||||
redisService.delete(RedisKeyManager.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("/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("/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("/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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package com.label.integration;
|
||||
|
||||
import com.label.AbstractIntegrationTest;
|
||||
import com.label.common.redis.RedisKeyManager;
|
||||
import com.label.common.redis.RedisService;
|
||||
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(RedisKeyManager.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(RedisKeyManager.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("/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("/api/video/callback"),
|
||||
HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
Map.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user