优化
This commit is contained in:
+3
-3
@@ -35,18 +35,18 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 严格校验租户、文件名/路径、大小、类型和读取权限,防止目录穿越、越权访问和任意文件上传。
|
||||
- 严格校验客户归属、文件名/路径、大小、类型和读取权限,防止目录穿越、跨客户访问和任意文件上传;接口和存储路径中的 `tenantId`、`tenant_id` 当前是客户隔离兼容字段。
|
||||
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。
|
||||
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
|
||||
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
|
||||
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
|
||||
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
|
||||
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他租户文件。
|
||||
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;删除第三方证据采用逻辑删除并校验业务类型,不能删除称重图片或其他客户文件。
|
||||
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
|
||||
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
|
||||
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
|
||||
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
|
||||
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标租户安全清理旧 UUID 文件。
|
||||
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录兼容字段 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标客户安全清理旧 UUID 文件。
|
||||
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
|
||||
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
|
||||
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
|
||||
|
||||
@@ -83,6 +83,7 @@ public class PlatformMeasurementImageController {
|
||||
|
||||
private long requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|
||||
|| user.tenantId() == null || user.tenantId() != 0L
|
||||
|| user.userId() == null || user.userId() <= 0) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "需要平台端身份");
|
||||
}
|
||||
|
||||
+11
-1
@@ -47,6 +47,7 @@ public class PlatformQualificationFileController {
|
||||
@RequestParam(required = false) LocalDate validTo,
|
||||
@RequestParam(defaultValue = "false") boolean replace) {
|
||||
requirePlatform(user);
|
||||
requireCustomer(tenantId);
|
||||
qualificationType = QualificationTypes.requireOptional(qualificationType);
|
||||
var stored = storage.store(tenantId, file);
|
||||
try {
|
||||
@@ -94,6 +95,7 @@ public class PlatformQualificationFileController {
|
||||
@RequestParam(defaultValue = "false") boolean replaceBusinessLicense,
|
||||
@RequestParam(defaultValue = "false") boolean replaceLegalPersonId) {
|
||||
requirePlatform(user);
|
||||
requireCustomer(tenantId);
|
||||
return ApiResponse.success(packageService.upload(tenantId, businessLicense, legalPersonId,
|
||||
unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo,
|
||||
replaceBusinessLicense, replaceLegalPersonId, true));
|
||||
@@ -105,6 +107,7 @@ public class PlatformQualificationFileController {
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long tenantId, @PathVariable String fileName) {
|
||||
requirePlatform(user);
|
||||
requireCustomer(tenantId);
|
||||
var resource = storage.load(tenantId, fileName);
|
||||
String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE
|
||||
: (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE);
|
||||
@@ -113,8 +116,15 @@ public class PlatformQualificationFileController {
|
||||
}
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
|
||||
if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())
|
||||
|| user.tenantId() == null || user.tenantId() != 0L) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCustomer(long tenantId) {
|
||||
if (tenantId <= 0L) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Customer identity must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ public class ExportTaskService {
|
||||
|
||||
private void requirePlatform(AuthenticatedUser user) {
|
||||
if (user == null || user.userId() == null || user.tenantId() == null
|
||||
|| !"PLATFORM".equalsIgnoreCase(user.appCode())) {
|
||||
|| user.tenantId() != 0L || !"PLATFORM".equalsIgnoreCase(user.appCode())) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -61,4 +61,14 @@ class PlatformMeasurementImageControllerContractTest {
|
||||
|
||||
verify(images).download(10001L, 88L, 99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlatformTokenWithCustomerTenantScope() {
|
||||
var file = new MockMultipartFile("file", "weight.png", MediaType.IMAGE_PNG_VALUE, new byte[]{1});
|
||||
var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L,
|
||||
"admin", "PLATFORM", "WEB", Set.of());
|
||||
|
||||
assertThatThrownBy(() -> controller.upload(malformed, 10001L, 88L, file))
|
||||
.isInstanceOf(BizException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,32 @@ class QualificationFileContractTest {
|
||||
assertEquals("hasAuthority('platform:customer:view')", method.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformQualificationRejectsCustomerScopedPlatformToken() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var controller = platformController(storage, mock(IamQualificationClient.class));
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L,
|
||||
"admin", "PLATFORM", "WEB", Set.of());
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(malformed, 10001L, file,
|
||||
"BANK_ACCOUNT_LICENSE", null, null, false));
|
||||
verify(storage, never()).store(any(Long.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformQualificationRejectsPlatformScopeAsCustomerTarget() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
var controller = platformController(storage, mock(IamQualificationClient.class));
|
||||
var file = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4".getBytes());
|
||||
|
||||
assertThrows(BizException.class,
|
||||
() -> controller.upload(platformUser(), 0L, file,
|
||||
"BANK_ACCOUNT_LICENSE", null, null, false));
|
||||
verify(storage, never()).store(any(Long.class), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformReplacementIsForwardedAndCleansThePreviousFileAfterIamSucceeds() {
|
||||
var storage = mock(LocalQualificationStorage.class);
|
||||
|
||||
@@ -74,6 +74,16 @@ class ExportTaskServiceTest {
|
||||
assertEquals(ResultCode.VALIDATION_FAILED,error.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPlatformTokenWithCustomerTenantScope() {
|
||||
var malformed = new AuthenticatedUser(1L, 10001L, 10001L, 1L,
|
||||
"user1", "PLATFORM", "WEB", Set.of("platform:customer:export"));
|
||||
assertEquals(ResultCode.FORBIDDEN, assertThrows(BizException.class,
|
||||
() -> service.create(malformed,
|
||||
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of())))
|
||||
.getResultCode());
|
||||
}
|
||||
|
||||
private AuthenticatedUser user(long id, Set<String> permissions) {
|
||||
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@ package com.baiyue.file.service;
|
||||
import com.baiyue.file.client.WeightOcrClient;
|
||||
import com.baiyue.file.entity.FileObjectEntity;
|
||||
import com.baiyue.file.mapper.FileObjectMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -28,6 +33,14 @@ class MeasurementImageServiceEvidenceTest {
|
||||
@MockBean private FileObjectMapper files;
|
||||
@MockBean private WeightOcrClient ocr;
|
||||
@Resource private MeasurementImageService service;
|
||||
|
||||
@BeforeAll
|
||||
static void initializeEntityMetadata() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), "measurement-image-test"),
|
||||
FileObjectEntity.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldHandleCase01() {
|
||||
var image = new MockMultipartFile("file", "measure.png", MediaType.IMAGE_PNG_VALUE,
|
||||
@@ -62,4 +75,20 @@ class MeasurementImageServiceEvidenceTest {
|
||||
.hasMessageContaining("不执行重量OCR");
|
||||
verifyNoInteractions(ocr);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesAFileTupleFromAnotherCustomer() {
|
||||
when(files.selectOne(any())).thenAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
LambdaQueryWrapper<FileObjectEntity> query = invocation.getArgument(0);
|
||||
query.getSqlSegment();
|
||||
assertThat(query.getParamNameValuePairs().values())
|
||||
.contains(11L, 41L, "21", false);
|
||||
return null;
|
||||
});
|
||||
|
||||
assertThatThrownBy(() -> service.download(11L, 21L, 41L))
|
||||
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
verifyNoInteractions(storage);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user