This commit is contained in:
2026-08-26 14:08:41 +08:00
parent 4398e8b347
commit 7f05183904
12 changed files with 11 additions and 139 deletions
+1
View File
@@ -81,6 +81,7 @@
- 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。
- 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。
@@ -13,7 +13,6 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -75,20 +74,6 @@ public class InternalBusinessFileController {
.body(value.content());
}
@DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable @NotBlank String businessType,
@PathVariable @NotBlank String businessId,
@PathVariable @Positive long fileId) {
check(token);
checkTenantId(tenantId);
service.delete(tenantId, businessType, businessId, fileId, operatorId);
return ApiResponse.success(null);
}
private void check(String token) {
if (token == null || !MessageDigest.isEqual(
token.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8))) {
@@ -3,16 +3,7 @@ package com.baiyue.file.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.file.entity.FileObjectEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
@Mapper
public interface FileObjectMapper extends BaseMapper<FileObjectEntity> {
@Update("UPDATE file_object SET status='VOID',deleted=1,updated_by=#{operatorId},"
+ "updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{fileId} AND tenant_id=#{tenantId} "
+ "AND business_type=#{businessType} AND business_id=#{businessId} AND status='ACTIVE' "
+ "AND deleted=0 AND version=#{version}")
int voidOwned(@Param("tenantId") long tenantId, @Param("businessType") String businessType,
@Param("businessId") String businessId, @Param("fileId") long fileId,
@Param("version") int version, @Param("operatorId") long operatorId);
}
@@ -65,16 +65,6 @@ public class BusinessFileStorage {
}
}
boolean delete(String objectKey) {
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(root)) return false;
try {
return Files.deleteIfExists(target);
} catch (IOException exception) {
return false;
}
}
private void verifyContent(MultipartFile file, String type) {
try (InputStream input = file.getInputStream()) {
byte[] prefix = input.readNBytes(4096);
@@ -7,7 +7,6 @@ import com.baiyue.file.entity.FileObjectEntity;
import com.baiyue.file.mapper.FileObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
@@ -71,16 +70,6 @@ public class InternalBusinessFileService {
return new Download(value.getOriginalName(), value.getContentType(), storage.read(value.getObjectKey()));
}
@Transactional
public void delete(long tenantId, String businessType, String businessId, long fileId, long operatorId) {
if (operatorId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "操作人必须有效");
FileObjectEntity value = required(tenantId, businessType, businessId, fileId);
int changed = files.voidOwned(tenantId, businessType, businessId, fileId,
value.getVersion(), operatorId);
if (changed != 1) throw new BizException(ResultCode.CONFLICT, "业务附件已变化,请刷新后重试");
storage.delete(value.getObjectKey());
}
private FileObjectEntity required(long tenantId, String businessType, String businessId, long fileId) {
validateScope(tenantId, businessType, businessId, fileId);
FileObjectEntity value = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
@@ -45,14 +45,6 @@ class InternalBusinessFileControllerTest {
verify(service).download(0L, "LOADING_CUSTOMS_DECLARATION", "51", 91L);
}
@Test void exactOwnershipCompensationDeleteRemainsBehindInternalToken() {
var response = controller.delete("internal-token", 7L, 0L,
"CONTAINER_DELIVERY_ORDER", "41", 91L);
assertThat(response.successful()).isTrue();
verify(service).delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L);
}
@Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() {
var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes());
assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L,
@@ -138,28 +138,4 @@ class InternalBusinessFileServiceTest {
verify(storage).read(value.getObjectKey());
}
@Test void compensationDeleteRequiresExactOwnershipAndLogicallyVoidsBeforeStorageCleanup() {
FileObjectEntity value = new FileObjectEntity();
value.setId(91L); value.setTenantId(0L); value.setBusinessType("CONTAINER_DELIVERY_ORDER");
value.setBusinessId("41"); value.setStatus("ACTIVE"); value.setDeleted(false); value.setVersion(0);
value.setObjectKey("business-files/0/41/do.pdf");
when(files.selectOne(any())).thenReturn(value);
when(files.voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L)).thenReturn(1);
when(storage.delete(value.getObjectKey())).thenReturn(true);
service.delete(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 7L);
verify(files).voidOwned(0L, "CONTAINER_DELIVERY_ORDER", "41", 91L, 0, 7L);
verify(storage).delete(value.getObjectKey());
}
@Test void compensationMapperUsesExactCompositeOwnershipAndOptimisticVersion() throws Exception {
String sql = String.join(" ", FileObjectMapper.class.getMethod("voidOwned", long.class,
String.class, String.class, long.class, int.class, long.class)
.getAnnotation(org.apache.ibatis.annotations.Update.class).value());
assertThat(sql)
.contains("id=#{fileId}", "tenant_id=#{tenantId}", "business_type=#{businessType}")
.contains("business_id=#{businessId}", "status='ACTIVE'", "deleted=0", "version=#{version}")
.contains("status='VOID'", "deleted=1", "version=version+1");
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId><exclusions><exclusion><groupId>io.github.openfeign.form</groupId><artifactId>feign-form-spring</artifactId></exclusion></exclusions></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-stream-rocketmq</artifactId></dependency>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
@@ -3,7 +3,6 @@ package com.baiyue.firstleg.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
@@ -30,12 +29,6 @@ public interface LoadingDocumentFileClient {
ResponseEntity<byte[]> download(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long tenantId,@PathVariable String businessType,@PathVariable String businessId,@PathVariable long fileId);
@DeleteMapping("/{tenantId}/{businessType}/{businessId}/{fileId}")
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId, @PathVariable String businessType,
@PathVariable String businessId, @PathVariable long fileId);
record Metadata(Long fileId, Long tenantId, String businessType, String businessId,
String fileName, String sha256, String contentType, Long sizeBytes) {}
}
@@ -38,8 +38,6 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.MediaType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@@ -52,7 +50,6 @@ import java.util.Set;
@Service
public class LoadingListService {
private static final Logger log = LoggerFactory.getLogger(LoadingListService.class);
private static final Set<String> PRELOAD_STATUSES = Set.of("EDITING", "LOADING");
public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE";
public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION";
@@ -321,16 +318,8 @@ public class LoadingListService {
requireReplaceableDocument(current, expectedVersion);
LoadingDocumentFileClient.Metadata metadata = uploadDocumentFile(
businessType, businessId, operatorId, file, fileHash);
try {
return persistDocument(plan, documentType, current, metadata, operatorId, operatorName,
operationId, "UPLOAD_" + documentType, requestHash, operationReason);
} catch (RuntimeException exception) {
compensateUploadedFile(businessType, businessId, metadata.fileId(), operatorId);
if (exception instanceof DataIntegrityViolationException) {
throw conflict("资料状态或数据库约束已变化,请刷新后重试");
}
throw exception;
}
return persistDocument(plan, documentType, current, metadata, operatorId, operatorName,
operationId, "UPLOAD_" + documentType, requestHash, operationReason);
}
private BusinessDocument persistDocument(AggregateRow plan, String documentType,
@@ -432,19 +421,6 @@ public class LoadingListService {
}
}
private void compensateUploadedFile(String businessType, String businessId, long fileId, long operatorId) {
try {
var response = documentFiles.delete(internalToken, operatorId, 0, businessType, businessId, fileId);
if (response == null || !response.successful()) {
log.error("Business file compensation failed: type={}, businessId={}, fileId={}",
businessType, businessId, fileId);
}
} catch (RuntimeException exception) {
log.error("Business file compensation request failed: type={}, businessId={}, fileId={}",
businessType, businessId, fileId, exception);
}
}
private static void requireReplaceableDocument(ContainerDocumentEntity current, int expectedVersion) {
if (current == null && expectedVersion != 0) throw conflict("资料版本已变化,请刷新后重试");
if (current != null && !java.util.Objects.equals(current.getVersion(), expectedVersion)) {
@@ -625,7 +625,6 @@ class LoadingListServiceTest {
.containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"),
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong());
}
@Test
@@ -694,33 +693,6 @@ class LoadingListServiceTest {
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() {
AggregateRow plan = row("COMPLETED", 4);
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
String contentHash = sha256Bytes(content);
var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L,
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(any())).thenThrow(
new org.springframework.dao.DataIntegrityViolationException("trigger rejected"));
when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L))).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L));
}
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
+7
View File
@@ -84,6 +84,13 @@ $clientAgents = Read-RequiredText $clientAgentsPath
$pdaAgents = Read-RequiredText $pdaAgentsPath
$overseasPdaAgents = Read-RequiredText $overseasPdaAgentsPath
$backendAgents = Read-RequiredText $backendAgentsPath
$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md')
# 当前未上线阶段的无兼容原则必须覆盖全部实现与运行状态,不能再次缩窄为文件或数据库专项规则。
Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机、权限、配置键、数据库结构与初始化 SQL、事件、路由、页面、Mock/Fixture、测试和脚本' '根无兼容收敛范围'
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
# 部署单元必须能从根索引进入。
Get-ChildItem -LiteralPath (Join-Path $workspaceRoot 'apps') -Directory |