This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
@@ -21,13 +21,14 @@ public class ExportProviderClient {
@Resource private WarehouseExportProviderClient warehouse;
@Resource private FirstlegExportProviderClient firstleg;
@Resource private FinanceExportProviderClient finance;
@Resource private LastlegExportProviderClient lastleg;
@Resource
private MeterRegistry meters;
@PostConstruct
void initializeClients() {
this.clients = Map.of("iam-service", iam, "order-service", order,
"warehouse-service", warehouse, "firstleg-service", firstleg,
"finance-service", finance);
"finance-service", finance, "lastleg-service", lastleg);
}
public ApiResponse<ExportBatchResponse> batch(String internalToken, String exportType,
@@ -0,0 +1,7 @@
package com.baiyue.file.client;
import org.springframework.cloud.openfeign.FeignClient;
@FeignClient(name="lastleg-service", contextId="lastlegExportProviderClient",
url="${baiyue.clients.lastleg.url:}", configuration=ExportProviderFeignConfiguration.class)
interface LastlegExportProviderClient extends ExportProviderFeignClient { }
@@ -11,7 +11,7 @@ import java.util.regex.Pattern;
@Component
@ConfigurationProperties(prefix = "baiyue.file.export")
public class ExportDefinitionRegistry {
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service");
private static final Set<String> SERVICES = Set.of("iam-service", "order-service", "warehouse-service", "firstleg-service", "finance-service", "lastleg-service");
private static final Pattern TYPE = Pattern.compile("[A-Z0-9_]+");
private static final Pattern PATH = Pattern.compile("/internal/exports/v1/([A-Z0-9_]+)/batches");
private static final Pattern PERMISSION = Pattern.compile("[a-z]+(?::[a-z-]+){2,}");
@@ -46,7 +46,11 @@ public class ExportTaskService {
}
validateParameters(type, request.parameters());
validateTypeParameters(type, request.parameters());
String parametersJson = parametersJson(request.parameters());
Map<String, Object> parameters = new TreeMap<>(request.parameters() == null ? Map.of() : request.parameters());
if ("TRIP_OPERATIONS_LIST".equals(type)) {
parameters.put("costVisible", user.permissions().contains("platform:fleet:trip:list"));
}
String parametersJson = parametersJson(parameters);
long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
FileExportTask task = new FileExportTask();
@@ -92,6 +96,7 @@ public class ExportTaskService {
public DownloadResource download(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
requireExportCostAccess(user, task);
if (!"SUCCEEDED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
}
@@ -103,6 +108,7 @@ public class ExportTaskService {
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
requireExportCostAccess(user, task);
var definition = registry.require(task.getExportType());
if (!definition.isEnabled()) throw new BizException(ResultCode.CONFLICT, "该导出类型已停用");
String permission = definition.getRequiredPermission();
@@ -128,6 +134,18 @@ public class ExportTaskService {
return response(repository.find(id));
}
private void requireExportCostAccess(AuthenticatedUser user, FileExportTask task) {
if (!"TRIP_OPERATIONS_LIST".equals(task.getExportType())) return;
try {
if (objectMapper.readTree(task.getParametersJson()).path("costVisible").asBoolean(false)
&& !user.permissions().contains("platform:fleet:trip:list")) {
throw new BizException(ResultCode.FORBIDDEN, "缺少出车成本读取权限,请重新创建不含成本的导出任务");
}
} catch (JsonProcessingException exception) {
throw new BizException(ResultCode.CONFLICT, "导出筛选快照无效,请重新创建任务");
}
}
private FileExportTask ownedTask(AuthenticatedUser user, long id) {
requirePlatform(user);
FileExportTask task = repository.find(id);
+75
View File
@@ -35,6 +35,9 @@ spring:
firstlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
lastlegExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
financeExportProviderClient:
connect-timeout: 3000
read-timeout: 15000
@@ -203,4 +206,76 @@ baiyue:
max-rows: 200000
max-attempts: 3
batch-size: 500
LAST_MILE_ORDER_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/LAST_MILE_ORDER_LIST/batches
required-permission: platform:overseas-warehouse:last-mile-order:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
OVERSEAS_INVENTORY_BALANCE:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_BALANCE/batches
required-permission: platform:overseas-warehouse:inventory:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
OVERSEAS_INVENTORY_LEDGER:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_INVENTORY_LEDGER/batches
required-permission: platform:overseas-warehouse:inventory:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
OVERSEAS_QUOTE_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/OVERSEAS_QUOTE_LIST/batches
required-permission: platform:overseas-master-data:quote:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
MONTHLY_EXCHANGE_RATE_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/MONTHLY_EXCHANGE_RATE_LIST/batches
required-permission: platform:overseas-master-data:exchange-rate:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
VEHICLE_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/VEHICLE_LIST/batches
required-permission: platform:overseas-master-data:vehicle:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
FLEET_QUOTE_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/FLEET_QUOTE_LIST/batches
required-permission: platform:fleet:quote:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
TRIP_OPERATIONS_LIST:
provider-service: lastleg-service
provider-path: /internal/exports/v1/TRIP_OPERATIONS_LIST/batches
required-permission: platform:fleet:operations:export
app-scope: PLATFORM
enabled: true
max-rows: 200000
max-attempts: 3
batch-size: 200
management.endpoints.web.exposure.include: health,info,prometheus
@@ -25,6 +25,7 @@ class ExportProviderClientTest {
@MockBean private WarehouseExportProviderClient warehouse;
@MockBean private FirstlegExportProviderClient firstleg;
@MockBean private FinanceExportProviderClient finance;
@MockBean private LastlegExportProviderClient lastleg;
@Resource private ExportProviderClient client;
@Configuration(proxyBeanMethods = false)
@@ -48,6 +49,16 @@ class ExportProviderClientTest {
verifyNoInteractions(iam,warehouse,firstleg,finance);
}
@Test void routesLastlegExportsThroughTheBoundedInternalClient() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(lastleg.batch(anyString(),anyString(),any())).thenReturn(response);
var definition=new ExportDefinitionRegistry.Definition();definition.setProviderService("lastleg-service");
var request=new ExportBatchRequest("9","0","PLATFORM","7",1,Map.of(),null,null,200);
assertSame(response,client.batch("token","LAST_MILE_ORDER_LIST",definition,request));
verify(lastleg).batch("token","LAST_MILE_ORDER_LIST",request);
verifyNoInteractions(iam,order,warehouse,firstleg,finance);
}
@Test void routesFinanceExportsToFinanceService() {
var response=ApiResponse.success(new ExportBatchResponse(1,"hash","snapshot",java.util.List.of(),java.util.List.of(),null,false,0L));
when(finance.batch(anyString(),anyString(),any())).thenReturn(response);
@@ -0,0 +1,48 @@
package com.baiyue.file.config;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class LastlegExportDefinitionContractTest {
@Test void localAndNacosDefinitionsKeepTheSameBoundedProviderAndPermissions() throws Exception {
Map<?, ?> local = definitions(Path.of("src/main/resources/application.yml"));
Map<?, ?> nacos = definitions(Path.of("../config/nacos/test/file-service.yml"));
Map<String, String> expected = Map.ofEntries(
Map.entry("LAST_MILE_ORDER_LIST", "platform:overseas-warehouse:last-mile-order:export"),
Map.entry("OVERSEAS_INVENTORY_BALANCE", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_INVENTORY_LEDGER", "platform:overseas-warehouse:inventory:export"),
Map.entry("OVERSEAS_QUOTE_LIST", "platform:overseas-master-data:quote:export"),
Map.entry("MONTHLY_EXCHANGE_RATE_LIST", "platform:overseas-master-data:exchange-rate:export"),
Map.entry("VEHICLE_LIST", "platform:overseas-master-data:vehicle:export"),
Map.entry("FLEET_QUOTE_LIST", "platform:fleet:quote:export"),
Map.entry("TRIP_OPERATIONS_LIST", "platform:fleet:operations:export"));
for (var entry : expected.entrySet()) {
var definition = (Map<?, ?>) local.get(entry.getKey());
assertNotNull(definition, entry.getKey());
assertEquals(definition, nacos.get(entry.getKey()));
assertEquals("lastleg-service", definition.get("provider-service"));
assertEquals("/internal/exports/v1/" + entry.getKey() + "/batches", definition.get("provider-path"));
assertEquals(entry.getValue(), definition.get("required-permission"));
assertEquals("PLATFORM", definition.get("app-scope"));
assertEquals(200, definition.get("batch-size"));
var controlled = new ExportDefinitionRegistry.Definition();
controlled.setProviderService("lastleg-service");
controlled.setProviderPath(String.valueOf(definition.get("provider-path")));
controlled.setRequiredPermission(entry.getValue());
controlled.setAppScope("PLATFORM");
var registry = new ExportDefinitionRegistry();
assertDoesNotThrow(() -> registry.setDefinitions(Map.of(entry.getKey(), controlled)));
}
}
private Map<?, ?> definitions(Path path) throws Exception {
Object value = new Yaml().load(Files.readString(path));
for (String key : new String[]{"baiyue", "file", "export", "definitions"}) value = ((Map<?, ?>) value).get(key);
return (Map<?, ?>) value;
}
}
@@ -28,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(classes = {ExportTaskService.class, ExportTaskServiceTest.TestConfig.class})
@@ -55,6 +56,32 @@ class ExportTaskServiceTest {
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
}
@Test
void operationExportDerivesCostVisibilityFromAuthenticatedPermissions() {
var definition = definition();
definition.setRequiredPermission("platform:fleet:operations:export");
when(registry.require("TRIP_OPERATIONS_LIST")).thenReturn(definition);
when(states.createConstrained(any(FileExportTask.class))).thenAnswer(invocation ->
new ExportStateService.CreationResult(invocation.getArgument(0), true));
service.create(user(1L, Set.of("platform:fleet:operations:export")),
new CreateExportTaskRequest("TRIP_OPERATIONS_LIST", "XLSX", "出车记录", Map.of("costVisible", true)));
var captured = org.mockito.ArgumentCaptor.forClass(FileExportTask.class);
verify(states).createConstrained(captured.capture());
assertTrue(captured.getValue().getParametersJson().contains("\"costVisible\":false"));
}
@Test
void costPermissionCannotBeBypassedByDownloadAllOrRetry() {
FileExportTask task = new FileExportTask();
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
task.setExportType("TRIP_OPERATIONS_LIST"); task.setParametersJson("{\"costVisible\":true}");
when(repository.find(9L)).thenReturn(task);
var reader = user(1L, Set.of("platform:download:all", "platform:fleet:operations:export"));
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.download(reader,9L)).getResultCode());
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class, () -> service.retry(reader,9L)).getResultCode());
verifyNoInteractions(storage);
}
@Test
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
when(registry.require("CUSTOMER_LIST")).thenReturn(definition());