fix: reject unavailable self-operated collection commands before initialization
This commit is contained in:
+47
-4
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -23,18 +24,39 @@ public class SelfopCommandService {
|
||||
}
|
||||
@Transactional
|
||||
public SelfopSyncTask request(Command c,String operator){
|
||||
identity(c.requestKey(),100);identity(c.warehouseCode(),64);require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED");
|
||||
taskService.initialize();
|
||||
if("RETRY".equals(c.action())){identity(c.taskNo(),64);identity(c.reason(),500);
|
||||
try { return requestChecked(c,operator); }
|
||||
catch(IllegalStateException failure){
|
||||
String message=failure.getMessage()==null?null:REJECTIONS.get(failure.getMessage());
|
||||
if(message==null)throw failure;
|
||||
throw new BizException(message);
|
||||
}
|
||||
}
|
||||
private SelfopSyncTask requestChecked(Command c,String operator){
|
||||
if(!config.isExecutionEnabled())throw new BizException("采集功能未启用,当前环境不允许执行采集或重试");
|
||||
if(c==null)throw new BizException("请提供采集命令");
|
||||
input(c.requestKey(),100,"请求标识");input(c.warehouseCode(),64,"仓库编码");
|
||||
require(config.getWarehouses().containsKey(c.warehouseCode()),"SELFOP_WAREHOUSE_UNCONFIGURED");
|
||||
if(c.action()==null||!Set.of("RETRY","INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()))
|
||||
throw new BizException("不支持此采集操作");
|
||||
if("RETRY".equals(c.action())){input(c.taskNo(),64,"任务编号");input(c.reason(),500,"重试原因");
|
||||
var original=tasks.selectOne(new QueryWrapper<SelfopSyncTask>().eq("task_no",c.taskNo()).eq("source_account",config.getSourceAccount()));require(original!=null,"SELFOP_TASK_SCOPE_MISMATCH");
|
||||
require("WAREHOUSE_SYNC".equals(original.getTaskRole())&&c.warehouseCode().equals(original.getWarehouseCode()),"SELFOP_TASK_SCOPE_MISMATCH");
|
||||
require(!"WAITING_SOURCE".equals(original.getStatus()),"SELFOP_RETRY_WAITING_SOURCE");
|
||||
require("BUSINESS".equals(original.getExecutionPurpose())&&"BUSINESS".equals(config.getCollectionMode()),"SELFOP_BUSINESS_PURPOSE_REQUIRED");
|
||||
return taskService.retry(original.getId(),c.requestKey(),operator,c.reason());}
|
||||
require(Set.of("INVENTORY","BACKFILL","RECHECK","REBUILD","VALIDATION").contains(c.action()),"SELFOP_COMMAND_INVALID");
|
||||
String purpose=SelfopCollectionPolicy.configuredPurpose(config);
|
||||
require(("VALIDATION".equals(purpose))==("VALIDATION".equals(c.action())),"SELFOP_VALIDATION_ENTRY_REQUIRED");
|
||||
if("RECHECK".equals(c.action())){input(c.customerCode(),64,"货主编码");input(c.deliveryNo(),128,"出库单号");}
|
||||
if(Set.of("BACKFILL","VALIDATION").contains(c.action())){
|
||||
require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil()),"SELFOP_QUERY_WINDOW_INVALID");
|
||||
require(!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_SLICE_MAX_ONE_DAY");
|
||||
if(config.getSourceQueryTimezone()!=null){var source=ZoneId.of(config.getSourceQueryTimezone());
|
||||
require(source.getRules().getValidOffsets(c.sourceFrom()).size()==1&&source.getRules().getValidOffsets(c.sourceUntil()).size()==1,"SELFOP_SOURCE_TIME_AMBIGUOUS");}
|
||||
}
|
||||
taskService.initialize();
|
||||
var scope=scopes.selectOne(new QueryWrapper<SelfopSyncScope>().eq("source_account",config.getSourceAccount()).eq("warehouse_code",c.warehouseCode())
|
||||
.eq("data_family","INVENTORY".equals(c.action())?"INVENTORY":"OUTBOUND"));
|
||||
require(scope!=null,"SELFOP_SCOPE_MISSING");
|
||||
var task=new SelfopSyncTask();task.setSourceAccount(config.getSourceAccount());task.setRequestedBy(operator);task.setRequestKey("manual:"+c.requestKey());
|
||||
task.setExecutionPurpose(purpose);
|
||||
task.setTaskType("INVENTORY".equals(c.action())?"BACKFILL":c.action());task.setScopeHash(scope.getScopeHash());
|
||||
@@ -42,6 +64,7 @@ public class SelfopCommandService {
|
||||
task.setTaskRole("WAREHOUSE_SYNC");task.setScopeId(scope.getId());task.setWarehouseCode(c.warehouseCode());task.setQueryScopeJson(scope.getScopeJson());
|
||||
}else{
|
||||
var account=accounts.selectOne(new QueryWrapper<SelfopOutboundDiscoveryScope>().eq("source_account",config.getSourceAccount()));
|
||||
require(account!=null,"SELFOP_SCOPE_MISSING");
|
||||
task.setTaskRole("ACCOUNT_DISCOVERY");task.setDiscoveryScopeId(account.getId());
|
||||
if("VALIDATION".equals(c.action())){
|
||||
require(c.sourceFrom()!=null&&c.sourceUntil()!=null&&c.sourceFrom().isBefore(c.sourceUntil())&&!c.sourceUntil().isAfter(c.sourceFrom().plusDays(1)),"SELFOP_QUERY_WINDOW_INVALID");
|
||||
@@ -62,4 +85,24 @@ public class SelfopCommandService {
|
||||
}
|
||||
task.setRequestHash(hash(Arrays.asList(c,task.getScopeHash())));return taskService.create(task);
|
||||
}
|
||||
private static void input(String value,int max,String label){
|
||||
if(value==null||value.isBlank()||value.length()>max)throw new BizException(label+"不能为空且不能超过"+max+"个字符");
|
||||
}
|
||||
private static final Map<String,String> REJECTIONS=Map.ofEntries(
|
||||
Map.entry("SELFOP_WAREHOUSE_UNCONFIGURED","该仓库尚未配置采集,暂不能提交任务"),
|
||||
Map.entry("SELFOP_TASK_SCOPE_MISMATCH","任务不存在或不属于当前账号和仓库"),
|
||||
Map.entry("SELFOP_TASK_MISSING","任务不存在,请刷新后重试"),
|
||||
Map.entry("SELFOP_RETRY_WAITING_SOURCE","任务正在等待来源数据,暂不能重试"),
|
||||
Map.entry("SELFOP_BUSINESS_PURPOSE_REQUIRED","验证任务不能通过正式采集重试恢复"),
|
||||
Map.entry("SELFOP_VALIDATION_ENTRY_REQUIRED","当前采集模式与所选操作不一致,请使用对应入口"),
|
||||
Map.entry("SELFOP_QUERY_WINDOW_INVALID","采集时间范围不能为空,结束时间必须晚于开始时间"),
|
||||
Map.entry("SELFOP_SLICE_MAX_ONE_DAY","单次采集时间范围不能超过一天"),
|
||||
Map.entry("SELFOP_SOURCE_TIME_AMBIGUOUS","来源时间存在时区歧义,请重新选择时间范围"),
|
||||
Map.entry("SELFOP_SCOPE_MISSING","采集作用域尚未准备就绪,请联系管理员"),
|
||||
Map.entry("SELFOP_IDEMPOTENCY_CONFLICT","请求标识已用于其他内容,请刷新后重新提交"),
|
||||
Map.entry("SELFOP_RETRY_LEASE_BUSY","任务正在执行,暂不能重试"),
|
||||
Map.entry("SELFOP_TASK_ALREADY_SUCCEEDED","任务已成功完成,无需重试"),
|
||||
Map.entry("SELFOP_COLLECTION_MODE_REJECTED","当前采集模式不允许此操作"),
|
||||
Map.entry("SELFOP_BUSINESS_ENVIRONMENT_REJECTED","当前环境不允许正式采集"),
|
||||
Map.entry("SELFOP_BUDGET_INVALID","采集预算配置无效,请联系管理员"));
|
||||
}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ public class SyncController {
|
||||
@PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')")
|
||||
@Operation(summary="创建自营分析人工采集或恢复任务",description="平台独立写权限 platform:self-operated-analytics:sync;持久化后异步执行,执行开关默认关闭。BACKFILL使用不超过一天的来源时间切片;未知源时区仅允许暂存。")
|
||||
public ApiResponse<Accepted> request(@AuthenticationPrincipal AuthenticatedUser user,@RequestBody SelfopCommandService.Command command){
|
||||
if(user==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync"))
|
||||
if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0||!"PLATFORM".equals(user.appCode())||user.impersonated()||!user.permissions().contains("platform:self-operated-analytics:sync"))
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅限已授权平台人员");
|
||||
var task=commands.request(command,String.valueOf(user.userId()));return ApiResponse.success(new Accepted(task.getTaskNo(),command.warehouseCode(),"ACCEPTED"));
|
||||
}
|
||||
|
||||
+7
-6
@@ -26,7 +26,7 @@ class SelfopCommandServiceTest {
|
||||
configure();
|
||||
var original = original("synthetic-account", "WH-B");
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SelfopSyncTask> q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; });
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH");
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("任务不存在或不属于当前账号和仓库");
|
||||
verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString());
|
||||
verify(taskService, never()).create(any(SelfopSyncTask.class));
|
||||
assertThat(original.getStatus()).isEqualTo("DEAD");
|
||||
@@ -36,7 +36,7 @@ class SelfopCommandServiceTest {
|
||||
configure();
|
||||
var original = original("different-source-account", "WH-A");
|
||||
when(tasks.selectOne(any())).thenAnswer(call -> { com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<SelfopSyncTask> q=call.getArgument(0); q.getSqlSegment(); return q.getParamNameValuePairs().containsValue(original.getSourceAccount())?original:null; });
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("SELFOP_TASK_SCOPE_MISMATCH");
|
||||
assertThatThrownBy(() -> service.request(retry(), "42")).hasMessage("任务不存在或不属于当前账号和仓库");
|
||||
verify(taskService, never()).retry(anyLong(), anyString(), anyString(), anyString());
|
||||
verify(taskService, never()).create(any(SelfopSyncTask.class));
|
||||
assertThat(original.getStatus()).isEqualTo("DEAD");
|
||||
@@ -77,16 +77,17 @@ class SelfopCommandServiceTest {
|
||||
configure();config.setCollectionMode("OFF");
|
||||
assertThat(config.isExecutionEnabled()).isFalse();
|
||||
var c=new SelfopCommandService.Command("disabled","WH-A","INVENTORY",null,null,null,null,null,"offline test");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_PURPOSE_INVALID");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试");
|
||||
assertThatThrownBy(()->service.request(retry(),"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试");
|
||||
config.setCollectionMode("BUSINESS");config.setEnvironment("test");
|
||||
assertThat(config.isExecutionEnabled()).isFalse();
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_BUSINESS_ENVIRONMENT_REJECTED");
|
||||
verify(taskService,never()).create(any(SelfopSyncTask.class));
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("采集功能未启用,当前环境不允许执行采集或重试");
|
||||
verifyNoInteractions(taskService,scopes,accounts,tasks);
|
||||
}
|
||||
@Test void validationModeRejectsFormalInventoryEntry() {
|
||||
configure();config.setCollectionMode("VALIDATION");
|
||||
var c=new SelfopCommandService.Command("wrong-purpose","WH-A","INVENTORY",null,null,null,null,null,"offline test");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("SELFOP_VALIDATION_ENTRY_REQUIRED");
|
||||
assertThatThrownBy(()->service.request(c,"42")).hasMessage("当前采集模式与所选操作不一致,请使用对应入口");
|
||||
verify(taskService,never()).create(any(SelfopSyncTask.class));
|
||||
}
|
||||
private SelfopCommandService.Command retry() {
|
||||
|
||||
+84
@@ -13,6 +13,17 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.util.Set;
|
||||
import com.baiyue.common.web.GlobalExceptionHandler;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -88,6 +99,79 @@ class SyncControllerTest {
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('" + SYNC + "')");
|
||||
}
|
||||
|
||||
@Test void offAndRetryAreChineseBusinessRejectionsBeforeAnyInitialization() throws Exception {
|
||||
var config=new SelfopAnalyticsProperties();
|
||||
var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class);
|
||||
var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class);
|
||||
var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows));
|
||||
rejected(mvc,command,"采集功能未启用,当前环境不允许执行采集或重试");
|
||||
rejected(mvc,new SelfopCommandService.Command("retry","WH-A","RETRY","LXCOL-1",null,null,null,null,"原因"),
|
||||
"采集功能未启用,当前环境不允许执行采集或重试");
|
||||
verifyNoInteractions(tasks,scopes,accounts,rows);
|
||||
}
|
||||
|
||||
@Test void unconfiguredWarehouseAndInvalidPublicInputsRejectBeforeInitialization() throws Exception {
|
||||
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production");
|
||||
var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class);
|
||||
var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class);
|
||||
var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows));
|
||||
rejected(mvc,command,"该仓库尚未配置采集,暂不能提交任务");
|
||||
config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse());
|
||||
rejected(mvc,new SelfopCommandService.Command("invalid","WH-A",null,null,null,null,null,null,null),"不支持此采集操作");
|
||||
rejected(mvc,new SelfopCommandService.Command("invalid","WH-A","BACKFILL",null,null,null,null,null,null),
|
||||
"采集时间范围不能为空,结束时间必须晚于开始时间");
|
||||
rejected(mvc,new SelfopCommandService.Command("invalid","WH-A","RETRY","LXCOL-1",null,null,null,null," "),
|
||||
"重试原因不能为空且不能超过500个字符");
|
||||
verifyNoInteractions(tasks,scopes,accounts,rows);
|
||||
}
|
||||
|
||||
@Test void controlledTaskConflictIsBusinessRejectionButUnknownFailureRemainsSystemError() throws Exception {
|
||||
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production");
|
||||
config.setSourceAccount("synthetic-account");config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse());
|
||||
var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class);
|
||||
var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class);
|
||||
var scope=new SelfopSyncScope();scope.setId(10L);scope.setScopeHash("hash");when(scopes.selectOne(any())).thenReturn(scope);
|
||||
var mvc=mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows));
|
||||
doThrow(new IllegalStateException("SELFOP_IDEMPOTENCY_CONFLICT")).when(tasks).create(any());
|
||||
rejected(mvc,command,"请求标识已用于其他内容,请刷新后重新提交");
|
||||
doThrow(new IllegalStateException("unknown runtime failure")).when(tasks).create(any());
|
||||
mvc.perform(post("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks")
|
||||
.contentType("application/json").content(SelfopSupport.json(command)))
|
||||
// The platform handler transports all result codes in HTTP 200; unknown failures retain S0500.
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0500"));
|
||||
}
|
||||
|
||||
@Test void runningRetryIsControlledAndDoesNotInitializeScopes() throws Exception {
|
||||
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");config.setEnvironment("production");
|
||||
config.setSourceAccount("synthetic-account");config.getWarehouses().put("WH-A",new SelfopAnalyticsProperties.Warehouse());
|
||||
var tasks=mock(SelfopTaskService.class);var scopes=mock(SelfopSyncScopeMapper.class);
|
||||
var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);var rows=mock(SelfopSyncTaskMapper.class);
|
||||
var original=new SelfopSyncTask();original.setId(1L);original.setTaskRole("WAREHOUSE_SYNC");
|
||||
original.setWarehouseCode("WH-A");original.setExecutionPurpose("BUSINESS");original.setStatus("FETCHING");
|
||||
when(rows.selectOne(any())).thenReturn(original);
|
||||
doThrow(new IllegalStateException("SELFOP_RETRY_LEASE_BUSY")).when(tasks).retry(1L,"retry","42","原因");
|
||||
rejected(mvc(new SelfopCommandService(tasks,scopes,accounts,config,rows)),
|
||||
new SelfopCommandService.Command("retry","WH-A","RETRY","LXCOL-1",null,null,null,null,"原因"),
|
||||
"任务正在执行,暂不能重试");
|
||||
verify(tasks,never()).initialize();verify(tasks,never()).create(any());verifyNoInteractions(scopes,accounts);
|
||||
}
|
||||
|
||||
private void rejected(MockMvc mvc,SelfopCommandService.Command request,String message) throws Exception {
|
||||
mvc.perform(post("/api/platform/v1/last-leg/self-operated-analytics/sync-tasks")
|
||||
.contentType("application/json").content(SelfopSupport.json(request)))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001"))
|
||||
.andExpect(jsonPath("$.message").value(message));
|
||||
}
|
||||
private MockMvc mvc(SelfopCommandService commands){
|
||||
return MockMvcBuilders.standaloneSetup(new SyncController(commands)).setControllerAdvice(new GlobalExceptionHandler())
|
||||
.setCustomArgumentResolvers(new HandlerMethodArgumentResolver(){
|
||||
public boolean supportsParameter(MethodParameter parameter){return parameter.getParameterType()==AuthenticatedUser.class;}
|
||||
public Object resolveArgument(MethodParameter parameter,ModelAndViewContainer container,NativeWebRequest request,WebDataBinderFactory factory){
|
||||
return user(0L,Set.of(SYNC));
|
||||
}
|
||||
}).build();
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(Long customerScope, Set<String> permissions) {
|
||||
return new AuthenticatedUser(42L, customerScope, 0L, 1L, "synthetic-user", "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user