From b9e0c8ea2f56c3636f910be85167d26b3d344134 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 14:22:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BaiyueSecurityAutoConfiguration.java | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 40 +++ .../file/controller/PdaReleaseController.java | 88 ++++++ .../file/service/PdaPackageStorage.java | 83 ++++++ .../file/service/PdaReleaseService.java | 229 +++++++++++++++ file/src/main/resources/application.yml | 8 +- file/src/main/resources/db/file.sql | 34 +++ .../InternalPriceBookController.java | 11 + .../main/resources/openapi/firstleg-v1.yaml | 11 + gateway/src/main/resources/application.yml | 3 +- .../main/resources/db/phase1-admin-rbac.sql | 14 +- order/AGENTS.md | 1 + .../client/LegacyImportPricingClient.java | 20 ++ .../client/LegacyImportWarehouseClient.java | 18 ++ .../order/client/TenantEligibilityClient.java | 8 + .../PlatformFirstLegOrderController.java | 19 ++ .../dto/PlatformLegacyOrderImportDtos.java | 19 ++ .../service/OrderApplicationService.java | 30 +- .../PlatformLegacyOrderImportService.java | 274 ++++++++++++++++++ .../src/main/resources/openapi/order-v1.yaml | 19 ++ ...eOrderExportPermissionSqlContractTest.java | 2 +- .../OrderControllerPermissionTest.java | 3 + .../OrderDeclarationUpdateContractTest.java | 2 +- .../PlatformOrderPageControllerTest.java | 2 + .../PlatformLegacyOrderImportServiceTest.java | 109 +++++++ .../InternalWarehouseController.java | 7 + .../repository/WarehouseRepository.java | 10 + .../service/WarehouseApplicationService.java | 10 + .../main/resources/openapi/warehouse-v1.yaml | 19 ++ 29 files changed, 1078 insertions(+), 17 deletions(-) create mode 100644 file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java create mode 100644 file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java create mode 100644 file/src/main/java/com/baiyue/file/service/PdaReleaseService.java create mode 100644 order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java create mode 100644 order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java create mode 100644 order/src/main/java/com/baiyue/order/dto/PlatformLegacyOrderImportDtos.java create mode 100644 order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java create mode 100644 order/src/test/java/com/baiyue/order/service/PlatformLegacyOrderImportServiceTest.java create mode 100644 warehouse/src/main/resources/openapi/warehouse-v1.yaml diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index 3713986d1..f27f21ff9 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -84,7 +84,7 @@ public class BaiyueSecurityAutoConfiguration { .exceptionHandling(errors -> errors .authenticationEntryPoint(entryPoint).accessDeniedHandler(deniedHandler)) .authorizeHttpRequests(auth -> auth - .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**").permitAll() + .requestMatchers("/actuator/health", "/v3/api-docs/**", "/swagger-ui/**", "/api/pda-updates/**").permitAll() .requestMatchers("/internal/**").permitAll() .requestMatchers("/openapi/**").permitAll() .requestMatchers("/api/merchant/**").hasAuthority("APP_MERCHANT") diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 9bc6efa61..9d1ddbd2f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4449,6 +4449,39 @@ CREATE TABLE file_object ( KEY idx_file_tenant_created (tenant_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件元数据表'; +CREATE TABLE file_pda_release_channel ( + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + current_apk_id char(36) DEFAULT NULL, + current_wgt_id char(36) DEFAULT NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内/海外PDA当前发布指针'; + +INSERT INTO file_pda_release_channel (app_code,app_id) VALUES +('domestic','__UNI__9BFFAF2'),('overseas','__UNI__936D079'); + +CREATE TABLE file_pda_release ( + id char(36) NOT NULL, + app_code varchar(16) NOT NULL, + app_id varchar(64) NOT NULL, + package_type varchar(8) NOT NULL, + version_name varchar(32) NOT NULL, + version_code int unsigned NOT NULL, + min_app_version_code int unsigned NOT NULL, + mandatory tinyint(1) NOT NULL DEFAULT '0', + release_notes varchar(1000) NOT NULL DEFAULT '', + status varchar(16) NOT NULL DEFAULT 'DRAFT', + object_key varchar(128) NOT NULL, + sha256 char(64) NOT NULL, + size_bytes bigint unsigned NOT NULL, + created_by bigint unsigned NOT NULL, + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at datetime(3) DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code,package_type,version_name), + KEY idx_pda_release_created (app_code,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA安装包与热更新发布历史'; + -- 服务 schema:baiyue_audit CREATE DATABASE IF NOT EXISTS baiyue_audit DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_audit; @@ -6004,6 +6037,8 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101034,0,'PLATFORM','BUTTON',5101030,'platform:permission:delete','删除权限资源',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101040,0,'PLATFORM','MENU',5101000,'platform:member:list','平台成员',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101041,0,'PLATFORM','BUTTON',5101040,'platform:member:grant-role','分配成员角色',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101060,0,'PLATFORM','MENU',5101000,'platform:pda-release:list','PDA版本','/system/pda-releases',NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5101061,0,'PLATFORM','BUTTON',5101060,'platform:pda-release:manage','发布PDA版本',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102000,0,'PLATFORM','MENU',0,'platform:order','头程订单','/first-leg-orders',NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102010,0,'PLATFORM','MENU',5102000,'platform:order:list','订单列表','/first-leg-orders/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102011,0,'PLATFORM','BUTTON',5102010,'platform:order:approve','订单审核通过',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6023,6 +6058,7 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102025,0,'PLATFORM','BUTTON',5102010,'platform:order:submit','平台代客提交订单',NULL,NULL,110,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102026,0,'PLATFORM','BUTTON',5102010,'platform:order:export','导出订单列表',NULL,NULL,120,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102027,0,'PLATFORM','BUTTON',5102010,'platform:order:declaration:edit','修改订单申报与运营预估',NULL,NULL,130,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102028,0,'PLATFORM','BUTTON',5102010,'platform:order:import','导入旧系统订单',NULL,NULL,140,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102030,0,'PLATFORM','MENU',5102000,'platform:firstleg:inbound:list','入库单','/inbounds','GET',30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102031,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:view','查看入库单详情',NULL,'GET',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102032,0,'PLATFORM','BUTTON',5102030,'platform:firstleg:inbound:hold','扣货与解除扣货',NULL,'POST',20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6380,6 +6416,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101034,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101040,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101041,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101060,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5101061,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102011,0,CURRENT_TIMESTAMP(3)); @@ -6399,6 +6437,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102025,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102028,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102030,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102032,0,CURRENT_TIMESTAMP(3)); @@ -6676,6 +6715,7 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102025,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102027,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102028,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102030,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102031,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102041,0,CURRENT_TIMESTAMP(3)); diff --git a/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java b/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java new file mode 100644 index 000000000..33dab387c --- /dev/null +++ b/file/src/main/java/com/baiyue/file/controller/PdaReleaseController.java @@ -0,0 +1,88 @@ +package com.baiyue.file.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.file.service.PdaReleaseService; +import org.springframework.core.io.Resource; +import org.springframework.http.CacheControl; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +@RestController +public class PdaReleaseController { + private final PdaReleaseService releases; + + public PdaReleaseController(PdaReleaseService releases) { this.releases = releases; } + + @GetMapping("/api/platform/v1/files/pda-releases/{app}") + @PreAuthorize("hasAuthority('platform:pda-release:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String app) { + requirePlatform(user); + return ApiResponse.success(releases.list(app)); + } + + @PostMapping(value = "/api/platform/v1/files/pda-releases/{app}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String app, @RequestParam String packageType, @RequestParam String versionName, + @RequestParam int versionCode, @RequestParam int minAppVersionCode, + @RequestParam(defaultValue = "false") boolean mandatory, @RequestParam(defaultValue = "") String releaseNotes, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(releases.upload(app, packageType, versionName, versionCode, + minAppVersionCode, mandatory, releaseNotes, file, requirePlatform(user))); + } + + @PostMapping("/api/platform/v1/files/pda-releases/{id}/publish") + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse publish(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String id) { + requirePlatform(user); + return ApiResponse.success(releases.publish(id)); + } + + @PostMapping("/api/platform/v1/files/pda-releases/{id}/withdraw") + @PreAuthorize("hasAuthority('platform:pda-release:manage')") + public ApiResponse withdraw(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String id) { + requirePlatform(user); + return ApiResponse.success(releases.withdraw(id)); + } + + @GetMapping("/api/pda-updates/{app}/latest") + public Map latest(@PathVariable String app, @RequestParam String appid, + @RequestParam String platform, @RequestParam int appVersionCode, @RequestParam String wgtVersion) { + return releases.latest(app, appid, platform, appVersionCode, wgtVersion); + } + + @GetMapping("/api/pda-updates/{app}/{id}/package") + public ResponseEntity download(@PathVariable String app, @PathVariable String id) { + PdaReleaseService.PackageFile file = releases.packageFile(app, id); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(file.sizeBytes()) + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() + .filename(file.fileName(), StandardCharsets.UTF_8).build().toString()) + .body(file.resource()); + } + + private static 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, "需要平台端身份"); + } + return user.userId(); + } +} diff --git a/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java b/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java new file mode 100644 index 000000000..688664cc9 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PdaPackageStorage.java @@ -0,0 +1,83 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.PathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.zip.ZipFile; + +@Component +public class PdaPackageStorage { + private static final long MAX_BYTES = 200L * 1024 * 1024; + private final Path root; + + public PdaPackageStorage(@Value("${baiyue.file.storage-root}") String storageRoot) { + root = Path.of(storageRoot).toAbsolutePath().normalize().resolve("pda-packages"); + } + + public record Stored(String key, long sizeBytes, String sha256) { } + + public Stored store(String app, String id, String type, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_BYTES) { + throw new BizException(ResultCode.BAD_REQUEST, "更新包为空或超过 200MB"); + } + String name = file.getOriginalFilename(); + if (name == null || !name.toLowerCase().endsWith("." + type)) { + throw new BizException(ResultCode.BAD_REQUEST, "文件扩展名与包类型不符"); + } + String key = app + "/" + id + "." + type; + Path target = path(key); + try { + Files.createDirectories(target.getParent()); + file.transferTo(target); + try (ZipFile zip = new ZipFile(target.toFile())) { + if (zip.size() == 0) throw new IOException("empty archive"); + } + return new Stored(key, Files.size(target), sha256(target)); + } catch (IOException | IllegalStateException exception) { + delete(key); + throw new BizException(ResultCode.BAD_REQUEST, "更新包不是有效的 APK/WGT 文件"); + } + } + + public Resource load(String key) { + Path file = path(key); + if (!Files.isRegularFile(file)) throw new BizException(ResultCode.NOT_FOUND, "更新包文件不存在"); + return new PathResource(file); + } + + public void delete(String key) { + try { Files.deleteIfExists(path(key)); } catch (IOException ignored) { } + } + + private Path path(String key) { + if (key == null || !key.matches("(domestic|overseas)/[a-f0-9-]{36}\\.(apk|wgt)")) { + throw new BizException(ResultCode.BAD_REQUEST, "更新包路径无效"); + } + Path path = root.resolve(key).normalize(); + if (!path.startsWith(root)) throw new BizException(ResultCode.FORBIDDEN, "更新包路径越界"); + return path; + } + + private static String sha256(Path file) throws IOException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + try (InputStream input = Files.newInputStream(file)) { + byte[] buffer = new byte[8192]; + for (int count; (count = input.read(buffer)) >= 0;) digest.update(buffer, 0, count); + } + return HexFormat.of().formatHex(digest.digest()); + } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } + } +} diff --git a/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java b/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java new file mode 100644 index 000000000..f5468b559 --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/PdaReleaseService.java @@ -0,0 +1,229 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +@Service +public class PdaReleaseService { + private final JdbcTemplate jdbc; + private final PdaPackageStorage storage; + private final String publicBaseUrl; + private final String domesticAppId; + private final String overseasAppId; + + public PdaReleaseService(JdbcTemplate jdbc, PdaPackageStorage storage, + @Value("${baiyue.file.pda.public-base-url:}") String publicBaseUrl, + @Value("${baiyue.file.pda.domestic-appid:__UNI__9BFFAF2}") String domesticAppId, + @Value("${baiyue.file.pda.overseas-appid:__UNI__936D079}") String overseasAppId) { + this.jdbc = jdbc; + this.storage = storage; + this.publicBaseUrl = publicBaseUrl.replaceAll("/+$", ""); + this.domesticAppId = domesticAppId; + this.overseasAppId = overseasAppId; + if (!this.publicBaseUrl.isEmpty() && !this.publicBaseUrl.startsWith("https://")) { + throw new IllegalArgumentException("PDA_UPDATE_PUBLIC_BASE_URL must use HTTPS"); + } + } + + public record Release(String id, String app, String appid, String packageType, String versionName, + int versionCode, int minAppVersionCode, boolean mandatory, String releaseNotes, + String status, boolean current, String sha256, long sizeBytes, String downloadUrl, + LocalDateTime createdAt, LocalDateTime publishedAt) { } + public record PackageFile(Resource resource, long sizeBytes, String fileName) { } + + private record Row(String id, String app, String appid, String type, String name, int code, + int minCode, boolean mandatory, String notes, String status, + String key, String sha256, long bytes, LocalDateTime createdAt, + LocalDateTime publishedAt) { } + private record Channel(String appid, String apkId, String wgtId) { } + + public Release upload(String app, String type, String versionName, int versionCode, + int minAppVersionCode, boolean mandatory, String notes, + MultipartFile file, long operatorId) { + String appid = appid(app); + type = type == null ? "" : type.toLowerCase(); + if (!type.equals("apk") && !type.equals("wgt")) throw bad("只支持 APK 或 WGT"); + if (versionName == null || versionName.length() > 32 + || !versionName.matches("\\d+(\\.\\d+)+")) throw bad("版本号格式无效"); + if (versionCode < 1 || minAppVersionCode < 1 + || (type.equals("apk") && versionCode != minAppVersionCode)) { + throw bad("APK 版本号或 WGT 最低基座版本无效"); + } + if (notes != null && notes.length() > 1000) throw bad("更新说明过长"); + String id = UUID.randomUUID().toString(); + PdaPackageStorage.Stored stored = storage.store(app, id, type, file); + try { + jdbc.update(""" + INSERT INTO file_pda_release + (id,app_code,app_id,package_type,version_name,version_code,min_app_version_code, + mandatory,release_notes,status,object_key,sha256,size_bytes,created_by) + VALUES (?,?,?,?,?,?,?,?,?,'DRAFT',?,?,?,?) + """, id, app, appid, type, versionName, versionCode, minAppVersionCode, + mandatory, notes == null ? "" : notes, stored.key(), stored.sha256(), + stored.sizeBytes(), operatorId); + } catch (RuntimeException exception) { + storage.delete(stored.key()); + throw exception; + } + return view(get(id)); + } + + @Transactional + public Release publish(String id) { + Row row = get(id); + if (!row.status().equals("DRAFT")) throw conflict("只有草稿可以发布"); + if (publicBaseUrl.isEmpty()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置"); + } + Channel channel = channel(row.app(), true); + if (!Objects.equals(channel.appid(), row.appid())) throw conflict("应用 AppID 已改变,请重新上传"); + Row current = current(row.type().equals("apk") ? channel.apkId() : channel.wgtId()); + if (current != null && (row.type().equals("apk") + ? row.code() <= current.code() + : compareVersion(row.name(), current.name()) <= 0)) { + throw conflict("新版本必须高于当前发布版本"); + } + if (row.type().equals("wgt")) { + Row apk = current(channel.apkId()); + if (apk == null || row.minCode() > apk.code()) throw conflict("请先发布兼容的 APK 基座"); + } + storage.load(row.key()); + if (jdbc.update("UPDATE file_pda_release SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP(3) WHERE id=? AND status='DRAFT'", id) != 1) { + throw conflict("版本状态已改变"); + } + String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id"; + jdbc.update("UPDATE file_pda_release_channel SET " + column + "=? WHERE app_code=?", id, row.app()); + return view(get(id)); + } + + @Transactional + public Release withdraw(String id) { + Row row = get(id); + Channel channel = channel(row.app(), true); + if (!row.status().equals("PUBLISHED")) throw conflict("只有已发布版本可以停发"); + String column = row.type().equals("apk") ? "current_apk_id" : "current_wgt_id"; + String currentId = row.type().equals("apk") ? channel.apkId() : channel.wgtId(); + if (id.equals(currentId)) { + jdbc.update("UPDATE file_pda_release_channel SET " + column + "=NULL WHERE app_code=?", row.app()); + } + jdbc.update("UPDATE file_pda_release SET status='WITHDRAWN' WHERE id=?", id); + return view(get(id)); + } + + public List list(String app) { + appid(app); + Channel channel = channel(app, false); + List rows = jdbc.query("SELECT * FROM file_pda_release WHERE app_code=? ORDER BY created_at DESC LIMIT 100", + (rs, index) -> map(rs), app); + return rows.stream().map(row -> view(row, channel)).toList(); + } + + public Map latest(String app, String requestedAppid, String platform, + int appVersionCode, String wgtVersion) { + if (!appid(app).equals(requestedAppid) || !"android".equalsIgnoreCase(platform) + || appVersionCode < 1 || wgtVersion == null || !wgtVersion.matches("\\d+(\\.\\d+)+")) { + throw bad("更新检查参数无效"); + } + Channel channel = channel(app, false); + Row apk = current(channel.apkId()); + if (apk != null && apk.code() > appVersionCode) return manifest(apk); + Row wgt = current(channel.wgtId()); + if (wgt != null && wgt.minCode() <= appVersionCode + && compareVersion(wgt.name(), wgtVersion) > 0) return manifest(wgt); + return Map.of("update", false); + } + + public PackageFile packageFile(String app, String id) { + Row row = get(id); + if (!row.app().equals(app) || !row.status().equals("PUBLISHED")) { + throw new BizException(ResultCode.NOT_FOUND, "更新包不存在"); + } + return new PackageFile(storage.load(row.key()), row.bytes(), + row.app() + "-pda-" + row.name() + "." + row.type()); + } + + private Map manifest(Row row) { + if (publicBaseUrl.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "更新服务公开地址未配置"); + return Map.of("update", true, "appid", row.appid(), "packageType", row.type(), + "versionName", row.name(), "versionCode", row.code(), "minAppVersionCode", row.minCode(), + "mandatory", row.mandatory(), "releaseNotes", row.notes(), "downloadUrl", url(row)); + } + + private Release view(Row row) { + return view(row, channel(row.app(), false)); + } + + private Release view(Row row, Channel channel) { + boolean current = row.id().equals(row.type().equals("apk") ? channel.apkId() : channel.wgtId()); + return new Release(row.id(), row.app(), row.appid(), row.type(), row.name(), row.code(), + row.minCode(), row.mandatory(), row.notes(), row.status(), current, row.sha256(), row.bytes(), + row.status().equals("PUBLISHED") && !publicBaseUrl.isEmpty() ? url(row) : null, + row.createdAt(), row.publishedAt()); + } + + private String url(Row row) { + return publicBaseUrl + "/api/pda-updates/" + row.app() + "/" + row.id() + "/package"; + } + + private Channel channel(String app, boolean locked) { + String sql = "SELECT app_id,current_apk_id,current_wgt_id FROM file_pda_release_channel WHERE app_code=?" + + (locked ? " FOR UPDATE" : ""); + List rows = jdbc.query(sql, (rs, index) -> + new Channel(rs.getString(1), rs.getString(2), rs.getString(3)), app); + if (rows.isEmpty()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "PDA 发布通道未初始化"); + return rows.get(0); + } + + private Row current(String id) { return id == null ? null : get(id); } + + private Row get(String id) { + List rows = jdbc.query("SELECT * FROM file_pda_release WHERE id=?", (rs, index) -> map(rs), id); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "PDA 版本不存在"); + return rows.get(0); + } + + private String appid(String app) { + return switch (app) { + case "domestic" -> domesticAppId; + case "overseas" -> overseasAppId; + default -> throw bad("PDA 应用类型无效"); + }; + } + + private static Row map(ResultSet rs) throws SQLException { + return new Row(rs.getString("id"), rs.getString("app_code"), rs.getString("app_id"), + rs.getString("package_type"), rs.getString("version_name"), rs.getInt("version_code"), + rs.getInt("min_app_version_code"), rs.getBoolean("mandatory"), rs.getString("release_notes"), + rs.getString("status"), rs.getString("object_key"), rs.getString("sha256"), rs.getLong("size_bytes"), + rs.getTimestamp("created_at").toLocalDateTime(), + rs.getTimestamp("published_at") == null ? null : rs.getTimestamp("published_at").toLocalDateTime()); + } + + private static int compareVersion(String left, String right) { + String[] a = left.split("\\."); + String[] b = right.split("\\."); + for (int i = 0; i < Math.max(a.length, b.length); i++) { + int x = i < a.length ? Integer.parseInt(a[i]) : 0; + int y = i < b.length ? Integer.parseInt(b[i]) : 0; + if (x != y) return Integer.compare(x, y); + } + return 0; + } + + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index ef4179c70..be91f53d6 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -40,8 +40,8 @@ spring: read-timeout: 15000 servlet: multipart: - max-file-size: 50MB - max-request-size: 51MB + max-file-size: 200MB + max-request-size: 201MB rocketmq: name-server: ${ROCKETMQ_NAME_SERVER:127.0.0.1:9876} producer: @@ -60,6 +60,10 @@ baiyue: url: ${FINANCE_SERVICE_URL:} file: storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads} + pda: + public-base-url: ${PDA_UPDATE_PUBLIC_BASE_URL:} + domestic-appid: ${PDA_DOMESTIC_APPID:__UNI__9BFFAF2} + overseas-appid: ${PDA_OVERSEAS_APPID:__UNI__936D079} price-book: max-size-bytes: ${FILE_PRICE_BOOK_MAX_SIZE_BYTES:10485760} export: diff --git a/file/src/main/resources/db/file.sql b/file/src/main/resources/db/file.sql index 17c8d2f4d..45f809085 100644 --- a/file/src/main/resources/db/file.sql +++ b/file/src/main/resources/db/file.sql @@ -28,6 +28,40 @@ CREATE TABLE IF NOT EXISTS file_object ( KEY idx_file_tenant_created (tenant_id, created_at) ) ENGINE=InnoDB COMMENT='文件元数据表'; +CREATE TABLE IF NOT EXISTS file_pda_release_channel ( + app_code VARCHAR(16) NOT NULL, + app_id VARCHAR(64) NOT NULL, + current_apk_id CHAR(36) NULL, + current_wgt_id CHAR(36) NULL, + PRIMARY KEY (app_code) +) ENGINE=InnoDB COMMENT='国内/海外PDA当前发布指针'; + +INSERT INTO file_pda_release_channel (app_code, app_id) VALUES + ('domestic', '__UNI__9BFFAF2'), ('overseas', '__UNI__936D079') +ON DUPLICATE KEY UPDATE app_id=VALUES(app_id); + +CREATE TABLE IF NOT EXISTS file_pda_release ( + id CHAR(36) NOT NULL, + app_code VARCHAR(16) NOT NULL, + app_id VARCHAR(64) NOT NULL, + package_type VARCHAR(8) NOT NULL, + version_name VARCHAR(32) NOT NULL, + version_code INT UNSIGNED NOT NULL, + min_app_version_code INT UNSIGNED NOT NULL, + mandatory TINYINT(1) NOT NULL DEFAULT 0, + release_notes VARCHAR(1000) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', + object_key VARCHAR(128) NOT NULL, + sha256 CHAR(64) NOT NULL, + size_bytes BIGINT UNSIGNED NOT NULL, + created_by BIGINT UNSIGNED NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + published_at DATETIME(3) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_pda_release_version (app_code, package_type, version_name), + KEY idx_pda_release_created (app_code, created_at) +) ENGINE=InnoDB COMMENT='PDA安装包与热更新发布历史'; + CREATE TABLE IF NOT EXISTS file_download_token ( id BIGINT UNSIGNED NOT NULL COMMENT '下载令牌ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java index 43973be5d..a734756ee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalPriceBookController.java @@ -12,6 +12,8 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -58,6 +60,15 @@ public class InternalPriceBookController { request.channelCode(), request.destinationType(), request.requireInboundCodePrefix())); } + @GetMapping("/channels") + public ApiResponse> channels( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestParam long tenantId, + @RequestParam long originWarehouseId) { + requireToken(token); + return ApiResponse.success(service.channels(tenantId, originWarehouseId)); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 811668338..1d9490311 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -136,6 +136,17 @@ paths: requireInboundCodePrefix: {type: boolean} responses: '200': {description: 成功时 data 返回 channelCode;requireInboundCodePrefix=true 时同时返回 inboundCodePrefix} + /internal/firstleg/v1/pricing/channels: + get: + summary: 查询指定客户和起运仓当前可用渠道 + description: 仅供订单服务的旧系统导入解析使用,不经公网网关暴露。 + security: + - internalServiceToken: [] + parameters: + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: originWarehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 返回当前有效价格簿中的渠道编码和名称} /internal/firstleg/v1/pricing/order-channel-changes/validate: post: summary: Order 提交渠道变更前校验 Firstleg 计价资格与费用可逆性 diff --git a/gateway/src/main/resources/application.yml b/gateway/src/main/resources/application.yml index b6f008ce4..b30bb2b85 100644 --- a/gateway/src/main/resources/application.yml +++ b/gateway/src/main/resources/application.yml @@ -69,7 +69,7 @@ spring: - id: file-service uri: lb://file-service predicates: - - Path=/api/merchant/v1/files/**,/api/platform/v1/files/** + - Path=/api/merchant/v1/files/**,/api/platform/v1/files/**,/api/pda-updates/** - id: print-service uri: lb://print-service predicates: @@ -161,6 +161,7 @@ baiyue: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} permit-paths: + - /api/pda-updates/** - /api/auth/login - /api/auth/register - /api/auth/refresh diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index 6b000fdd7..7a9917d00 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -131,18 +131,19 @@ VALUES (5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0), (5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0), (5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表', NULL, NULL, 120, 'ACTIVE', 0), - (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0) + (5102027, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:declaration:edit', '修改订单申报与运营预估', NULL, NULL, 130, 'ACTIVE', 0), + (5102028, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:import', '导入旧系统订单', NULL, NULL, 140, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000003, id, 0 FROM iam_permission -WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027) +WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES @@ -150,6 +151,13 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. +INSERT INTO iam_permission + (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5101060, 0, 'PLATFORM', 'MENU', 5101000, 'platform:pda-release:list', 'PDA版本', '/system/pda-releases', NULL, 60, 'ACTIVE', 0), + (5101061, 0, 'PLATFORM', 'BUTTON', 5101060, 'platform:pda-release:manage', '发布PDA版本', NULL, NULL, 10, 'ACTIVE', 0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), status='ACTIVE', deleted=0; + INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') diff --git a/order/AGENTS.md b/order/AGENTS.md index 045a0f685..d6c52ef40 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -21,6 +21,7 @@ | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | | 海外派送完成内部回写 | `controller/InternalOrderFulfillmentCompletionController.java`、`service/OrderFulfillmentCompletionService.java`;只接受 Firstleg 内部服务身份和已校验的来源订单/入仓码快照,幂等将对应订单推进为 `COMPLETED` 并写状态历史 | | 商户订单列表、批量草稿与中文 XLSX 导入 | `service/MerchantInboundOrderListService.java`、`MerchantBatchDraftService.java`、`MerchantBatchDraftItemService.java`、`MerchantBatchImportService.java`;动态目录客户端 `client/MerchantWarehouseCatalogClient.java`、`MerchantPricingCatalogClient.java`,客户地址由 `CustomerAddressService.java` 提供;接口见 `controller/MerchantFirstLegOrderController.java` 的 `/batch-imports/template`、`/batch-imports` | +| 平台旧系统订单导入 | `controller/PlatformFirstLegOrderController.java` 的 `/legacy-import`、`service/PlatformLegacyOrderImportService.java`;固定按名称精确解析启用客户“IT测试公司”,保留旧入仓码,按内部仓库目录和该客户当前价格簿逐行校验,成功行直接进入 `SUBMITTED` 待审核;一件代发缺 SKU 的行失败,普通 FBA 缺 SKU 时使用稳定 `LEGACY-{入仓码}` 测试占位码 | | 入库号生成 | `config/InboundCodeModeProvider.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java`、`service/OrderApplicationService.java` | | 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | | 下单仓库主数据守卫 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 | diff --git a/order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java b/order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java new file mode 100644 index 000000000..26f23af25 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java @@ -0,0 +1,20 @@ +package com.baiyue.order.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.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +@FeignClient(name = "firstleg-service", contextId = "legacyImportPricingClient", + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/pricing") +public interface LegacyImportPricingClient { + @GetMapping("/channels") + ApiResponse> channels(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("tenantId") long tenantId, + @RequestParam("originWarehouseId") long originWarehouseId); + + record Channel(String channelCode, String channelName) { } +} diff --git a/order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java b/order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java new file mode 100644 index 000000000..5e0d264f2 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java @@ -0,0 +1,18 @@ +package com.baiyue.order.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.RequestHeader; + +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "legacyImportWarehouseClient", + url = "${baiyue.clients.warehouse.url:}", path = "/internal/warehouses") +public interface LegacyImportWarehouseClient { + @GetMapping("/order-catalog") + ApiResponse> catalog(@RequestHeader("X-Internal-Service-Token") String token); + + record Item(Long id, String warehouseCode, String warehouseName, String warehouseType, + String countryCode) { } +} diff --git a/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java index 04666f955..178bb8333 100644 --- a/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java +++ b/order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java @@ -22,6 +22,14 @@ public interface TenantEligibilityClient { @RequestParam("size") long size, @RequestParam("customerId") long customerId); + @GetMapping("/finance-customers") + ApiResponse> customerSummaryByKeyword( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestParam("page") long page, + @RequestParam("size") long size, + @RequestParam("keyword") String keyword, + @RequestParam("status") String status); + record EligibilityResponse(boolean eligible, String tenantStatus, String reason) { } record CustomerSummary(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 419b2d182..e94f99327 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -19,6 +19,8 @@ import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.InboundCodeAssociationService; import com.baiyue.order.service.BatchOrderReviewPreviewService; +import com.baiyue.order.service.PlatformLegacyOrderImportService; +import com.baiyue.order.dto.PlatformLegacyOrderImportDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -36,6 +38,9 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; import java.util.ArrayList; import java.util.List; @@ -55,6 +60,20 @@ public class PlatformFirstLegOrderController { private InboundCodeAssociationService inboundCodeAssociations; @Resource private BatchOrderReviewPreviewService batchReviewPreview; + @Resource + private PlatformLegacyOrderImportService legacyImport; + + @BusinessAudit(event = "ORDER_LEGACY_IMPORT", resourceType = "FIRST_LEG_ORDER", description = "导入旧系统订单") + @PostMapping(value = "/legacy-import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:order:import')") + @Operation(summary = "导入旧系统订单", description = "固定归属 IT测试公司,保留旧入仓码并逐行创建待审核订单。") + public ApiResponse importLegacy( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @RequestPart("file") MultipartFile file) { + OrderScopeGuard.platform(user); + return ApiResponse.success(legacyImport.importFile(user.userId(), user.username(), idempotencyKey, file)); + } @GetMapping("/inbound-code-policy") @PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')") diff --git a/order/src/main/java/com/baiyue/order/dto/PlatformLegacyOrderImportDtos.java b/order/src/main/java/com/baiyue/order/dto/PlatformLegacyOrderImportDtos.java new file mode 100644 index 000000000..d6aa0e0c0 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/PlatformLegacyOrderImportDtos.java @@ -0,0 +1,19 @@ +package com.baiyue.order.dto; + +import java.util.List; + +public final class PlatformLegacyOrderImportDtos { + private PlatformLegacyOrderImportDtos() { } + + public record Response(int total, int succeeded, int failed, List items) { } + + public record Result(int excelRow, boolean success, String legacyInboundCode, + Long orderId, String orderNo, String errorMessage) { + public static Result success(int row, String code, Long orderId, String orderNo) { + return new Result(row, true, code, orderId, orderNo, null); + } + public static Result failure(int row, String code, String message) { + return new Result(row, false, code, null, null, message); + } + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 3b349c2f7..2e72adc96 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -76,13 +76,13 @@ public class OrderApplicationService { if (idempotency.replayed()) { return idempotency.record().getResultOrderId(); } - Long orderId = createDraftInternal(tenantId, userId, username, request); + Long orderId = createDraftInternal(tenantId, userId, username, request, false); completeIdempotency(idempotency, orderId); return orderId; } private Long createDraftInternal(long tenantId, long userId, String username, - OrderRequests.SaveRequest request) { + OrderRequests.SaveRequest request, boolean allowLegacyInboundCode) { AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request); validateWoodInspection(request); validateRequestCargoQuantities(request.cargoes(), false); @@ -94,7 +94,7 @@ public class OrderApplicationService { long orderId = IdWorker.getId(); order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); order.setStatus(OrderStatus.DRAFT); - apply(order, tenantId, request, snapshots, addressSnapshots); + apply(order, tenantId, request, snapshots, addressSnapshots, allowLegacyInboundCode); order.setCreatedBy(userId); order.setUpdatedBy(userId); try { repository.insertOrder(order); @@ -106,6 +106,19 @@ public class OrderApplicationService { return order.getId(); } + /** 旧系统并行期专用:保留旧入仓码,并在同一事务中直接创建为待审核订单。 */ + @Transactional + public Long importLegacySubmittedOrder(long tenantId, long userId, String username, + OrderRequests.SaveRequest request, String idempotencyKey) { + IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "IMPORT_LEGACY_ORDER", 0L, + idempotencyKey, requestHash("IMPORT_LEGACY_ORDER", request)); + if (idempotency.replayed()) return idempotency.record().getResultOrderId(); + Long orderId = createDraftInternal(tenantId, userId, username, request, true); + transit(tenantId, userId, username, orderId, OrderStatus.SUBMITTED, null); + completeIdempotency(idempotency, orderId); + return orderId; + } + @Transactional public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { updateOrderData(tenantId, userId, id, request, false); @@ -137,7 +150,7 @@ public class OrderApplicationService { } requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(), request.destinationType()); - apply(order, tenantId, request, snapshots, addressSnapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); + apply(order, tenantId, request, snapshots, addressSnapshots, false); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId); if (before == OrderStatus.SUBMITTED) { order.setStatus(OrderStatus.SUBMITTED); } @@ -575,9 +588,10 @@ public class OrderApplicationService { }; } private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, - OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses) { + OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses, + boolean allowLegacyInboundCode) { order.setCustomerId(tenantId); - applyInboundCode(order, request.inboundCode()); + applyInboundCode(order, request.inboundCode(), allowLegacyInboundCode); order.setCustomerName(request.customerName().trim()); order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm()); order.setDestinationCountryCode(addresses.privateDestination() != null @@ -616,10 +630,10 @@ public class OrderApplicationService { ? snapshots.destination().warehouse().warehouseCode() : request.amazonWarehouseCode()); order.setShipmentId(request.shipmentId()); order.setReferenceId(request.referenceId()); } - private void applyInboundCode(OrderEntity order, String requestedCode) { + private void applyInboundCode(OrderEntity order, String requestedCode, boolean allowLegacyInboundCode) { String normalized = StringUtils.hasText(requestedCode) ? requestedCode.trim().toUpperCase(Locale.ROOT) : null; - if (!legacyManualMode()) { + if (!legacyManualMode() && !allowLegacyInboundCode) { if (normalized != null && !normalized.equals(order.getInboundCode())) { throw new BizException(ResultCode.VALIDATION_FAILED, "当前系统由新系统自动生成入仓码,请勿人工填写"); } diff --git a/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java new file mode 100644 index 000000000..510c75797 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java @@ -0,0 +1,274 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.client.LegacyImportPricingClient; +import com.baiyue.order.client.LegacyImportWarehouseClient; +import com.baiyue.order.client.TenantEligibilityClient; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.dto.PlatformLegacyOrderImportDtos; +import jakarta.annotation.Resource; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayInputStream; +import java.math.BigDecimal; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class PlatformLegacyOrderImportService { + private static final String FIXED_CUSTOMER = "IT测试公司"; + private static final int MAX_ROWS = 200; + private static final long MAX_BYTES = 5L * 1024 * 1024; + private static final List HEADERS = List.of("客户公司", "入仓码", "订单状态", "渠道", "品名/材质", + "买单/退税", "目的仓", "邮编", "货件编号", "FBA仓追踪编码", "SKU", "总箱数", "预报毛重", + "预报体积", "货重比", "交货仓", "备注"); + private static final ThreadLocal FORMATTER = + ThreadLocal.withInitial(() -> new DataFormatter(Locale.SIMPLIFIED_CHINESE)); + + @Resource private TenantEligibilityClient tenants; + @Resource private LegacyImportWarehouseClient warehouses; + @Resource private LegacyImportPricingClient pricing; + @Resource private OrderApplicationService orders; + @Value("${baiyue.security.internal-service-token:}") private String token; + + public PlatformLegacyOrderImportDtos.Response importFile(long userId, String username, + String idempotencyKey, MultipartFile file) { + byte[] content = read(file); + Customer customer = fixedCustomer(); + Catalog catalog = catalog(customer.id()); + List rows = parse(content, customer, catalog); + List results = new ArrayList<>(); + int succeeded = 0; + for (ParsedRow row : rows) { + if (row.error() != null) { + results.add(PlatformLegacyOrderImportDtos.Result.failure(row.excelRow(), row.legacyInboundCode(), row.error())); + continue; + } + try { + Long orderId = orders.importLegacySubmittedOrder(customer.id(), userId, username, row.request(), + derivedKey(idempotencyKey, row.legacyInboundCode())); + var saved = orders.detail(customer.id(), orderId).order(); + results.add(PlatformLegacyOrderImportDtos.Result.success(row.excelRow(), row.legacyInboundCode(), + orderId, saved.getOrderNo())); + succeeded++; + } catch (BizException exception) { + results.add(PlatformLegacyOrderImportDtos.Result.failure(row.excelRow(), row.legacyInboundCode(), + exception.getMessage())); + } + } + return new PlatformLegacyOrderImportDtos.Response(rows.size(), succeeded, rows.size() - succeeded, results); + } + + private List parse(byte[] content, Customer customer, Catalog catalog) { + try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) { + var sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(2); + if (header == null) throw invalid("未找到旧系统表头(应位于第 3 行)"); + for (int i = 0; i < HEADERS.size(); i++) { + if (!HEADERS.get(i).equals(text(header, i))) throw invalid("旧系统 Excel 表头不正确:缺少“" + HEADERS.get(i) + "”"); + } + List rows = new ArrayList<>(); + for (int index = 3; index <= sheet.getLastRowNum(); index++) { + Row source = sheet.getRow(index); + if (source == null || !StringUtils.hasText(text(source, 1))) continue; + if (rows.size() >= MAX_ROWS) throw invalid("单次最多导入 " + MAX_ROWS + " 条订单"); + try { rows.add(new ParsedRow(index + 1, text(source, 1), request(source, customer, catalog), null)); } + catch (BizException exception) { rows.add(new ParsedRow(index + 1, text(source, 1), null, exception.getMessage())); } + } + if (rows.isEmpty()) throw invalid("Excel 没有订单数据行"); + return rows; + } catch (BizException exception) { throw exception; } + catch (Exception exception) { throw invalid("Excel 文件损坏或格式不正确"); } + } + + private OrderRequests.SaveRequest request(Row row, Customer customer, Catalog catalog) { + String inboundCode = required(row, 1, "入仓码").toUpperCase(Locale.ROOT); + if (!inboundCode.matches("[A-Za-z0-9._:-]+")) throw invalid("入仓码格式不正确"); + String receivingName = required(row, 15, "交货仓"); + var receiving = uniqueWarehouse(catalog.domestic(), receivingName, "国内收货仓"); + String destinationName = required(row, 6, "目的仓"); + String destinationType; + String fulfillmentType = "STANDARD"; + Long destinationId = null; + String destinationWarehouseName; + String destinationCountry = "US"; + String amazonCode = null; + if ("私人仓".equals(destinationName)) { + destinationType = "PRIVATE"; + destinationWarehouseName = "私人仓 " + required(row, 7, "邮编"); + } else if (destinationName.endsWith("一件代发")) { + destinationType = "OVERSEAS"; + fulfillmentType = "ONE_CLICK_DROPSHIP"; + var warehouse = uniqueWarehouse(catalog.overseas(), destinationName.replace("一件代发", ""), "海外仓目的仓"); + destinationId = warehouse.id(); destinationWarehouseName = warehouse.warehouseName(); + destinationCountry = warehouse.countryCode(); + } else { + destinationType = "FBA"; + var warehouse = uniqueWarehouse(catalog.fba(), destinationName, "FBA目的仓"); + destinationId = warehouse.id(); destinationWarehouseName = warehouse.warehouseName(); + destinationCountry = warehouse.countryCode(); amazonCode = warehouse.warehouseCode(); + } + var channel = uniqueChannel(catalog.channels(receiving.id()), required(row, 3, "渠道")); + CargoText cargoText = cargoText(required(row, 4, "品名/材质")); + int packages = positiveInt(required(row, 11, "总箱数"), "总箱数"); + BigDecimal grossWeight = positiveDecimal(required(row, 12, "预报毛重"), "预报毛重"); + BigDecimal volume = positiveDecimal(required(row, 13, "预报体积"), "预报体积"); + List skuEntries = skuEntries(text(row, 10)); + if ("ONE_CLICK_DROPSHIP".equals(fulfillmentType) && skuEntries.isEmpty()) { + throw invalid("一件代发订单缺少 SKU"); + } + String shipmentId = cleanReference(text(row, 8)); + String referenceId = cleanReference(text(row, 9)); + String remark = joinRemark(text(row, 16), "旧系统客户:" + text(row, 0)); + List cargoes = new ArrayList<>(); + if ("ONE_CLICK_DROPSHIP".equals(fulfillmentType)) { + int skuPackages = skuEntries.stream().mapToInt(SkuEntry::packages).sum(); + if (skuPackages != packages) throw invalid("SKU 明细箱数合计与总箱数不一致"); + for (int index = 0; index < skuEntries.size(); index++) { + SkuEntry entry = skuEntries.get(index); + BigDecimal ratio = BigDecimal.valueOf(entry.packages()).divide(BigDecimal.valueOf(packages), 8, java.math.RoundingMode.HALF_UP); + BigDecimal lineWeight = index == skuEntries.size() - 1 + ? grossWeight.subtract(cargoes.stream().map(OrderRequests.Cargo::grossWeightKg).reduce(BigDecimal.ZERO, BigDecimal::add)) + : grossWeight.multiply(ratio).setScale(3, java.math.RoundingMode.HALF_UP); + BigDecimal lineVolume = index == skuEntries.size() - 1 + ? volume.subtract(cargoes.stream().map(OrderRequests.Cargo::volumeCbm).reduce(BigDecimal.ZERO, BigDecimal::add)) + : volume.multiply(ratio).setScale(4, java.math.RoundingMode.HALF_UP); + cargoes.add(cargo(cargoText, entry.packages(), lineWeight, lineVolume, entry.sku(), null, null)); + } + } else { + String sku = skuEntries.isEmpty() && "FBA".equals(destinationType) ? "LEGACY-" + inboundCode + : skuEntries.isEmpty() ? null : skuEntries.get(0).sku(); + cargoes.add(cargo(cargoText, packages, grossWeight, volume, sku, shipmentId, referenceId)); + } + OrderRequests.Party receiver = "PRIVATE".equals(destinationType) + ? new OrderRequests.Party(customer.name(), "旧系统导入", "未提供", null, "US", null, + "未提供", null, destinationWarehouseName, required(row, 7, "邮编")) + : null; + return new OrderRequests.SaveRequest(customer.id(), customer.name(), "SEA", "FOB", destinationCountry, + destinationId, destinationWarehouseName, null, remark, null, receiver, cargoes, "SELF_TRUCK", null, + receiving.id(), receiving.warehouseName(), channel.channelCode(), channel.channelName(), null, null, + "买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND", + channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED", + "LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType, + "ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null); + } + + private Customer fixedCustomer() { + try { + var response = tenants.customerSummaryByKeyword(token, 1, 200, FIXED_CUSTOMER, "ACTIVE"); + if (response == null || !response.successful() || response.data() == null) throw unavailable("固定客户资料暂不可用"); + var matches = response.data().records().stream().filter(value -> FIXED_CUSTOMER.equals(value.tenantName())).toList(); + if (matches.size() != 1) throw invalid("固定客户“" + FIXED_CUSTOMER + "”不存在、未启用或名称不唯一"); + return new Customer(matches.get(0).id(), matches.get(0).tenantName()); + } catch (BizException exception) { throw exception; } + catch (RuntimeException exception) { throw unavailable("固定客户资料暂不可用"); } + } + + private Catalog catalog(long tenantId) { + try { + var response = warehouses.catalog(token); + if (response == null || !response.successful() || response.data() == null) throw unavailable("仓库目录暂不可用"); + var domestic = response.data().stream().filter(v -> "DOMESTIC".equals(v.warehouseType())).toList(); + Map> channels = new LinkedHashMap<>(); + for (var warehouse : domestic) channels.put(warehouse.id(), channels(tenantId, warehouse.id())); + return new Catalog(domestic, + response.data().stream().filter(v -> "FBA".equals(v.warehouseType())).toList(), + response.data().stream().filter(v -> "OVERSEAS".equals(v.warehouseType())).toList(), channels); + } catch (BizException exception) { throw exception; } + catch (RuntimeException exception) { throw unavailable("仓库目录暂不可用"); } + } + + private List channels(long tenantId, long warehouseId) { + var response = pricing.channels(token, tenantId, warehouseId); + if (response == null || !response.successful() || response.data() == null) throw unavailable("渠道目录暂不可用"); + return response.data(); + } + + private byte[] read(MultipartFile file) { + if (file == null || file.isEmpty()) throw invalid("请选择旧系统导出的 Excel 文件"); + if (file.getSize() > MAX_BYTES) throw invalid("Excel 文件不能超过 5MB"); + String name = file.getOriginalFilename(); + if (!StringUtils.hasText(name) || !name.toLowerCase(Locale.ROOT).endsWith(".xlsx")) throw invalid("仅支持 .xlsx 文件"); + try { return file.getBytes(); } catch (Exception exception) { throw invalid("无法读取 Excel 文件"); } + } + + private static LegacyImportWarehouseClient.Item uniqueWarehouse(List values, + String source, String label) { + String normalized = normalize(source); + List exact = values.stream().filter(value -> + normalized.equals(normalize(value.warehouseCode())) || normalized.equals(normalize(value.warehouseName()))).toList(); + List matches = exact.isEmpty() ? values.stream().filter(value -> + normalize(value.warehouseName()).contains(normalized) || normalized.contains(normalize(value.warehouseName()))).toList() : exact; + if (matches.size() != 1) throw invalid(label + "“" + source + "”无法唯一匹配当前主数据"); + return matches.get(0); + } + + private static LegacyImportPricingClient.Channel uniqueChannel(List values, String source) { + String normalized = normalize(source); + List exact = values.stream().filter(value -> normalized.equals(normalize(value.channelName()))).toList(); + if (exact.size() != 1) throw invalid("渠道“" + source + "”无法唯一匹配 IT测试公司的当前价格簿"); + return exact.get(0); + } + + private static CargoText cargoText(String source) { + String cleaned = source.replaceAll("-\\d+件\\s*$", "").trim(); + String[] parts = cleaned.split("-", 2); + return new CargoText(parts[0].trim(), parts.length > 1 ? parts[1].trim() : null); + } + + private static OrderRequests.Cargo cargo(CargoText text, int packages, BigDecimal grossWeight, + BigDecimal volume, String sku, String shipmentId, String referenceId) { + return new OrderRequests.Cargo(text.name(), null, "CARTON", packages, grossWeight, volume, + null, null, null, null, null, false, null, sku, null, null, null, 1, packages, + null, null, text.material(), null, null, null, null, null, null, + shipmentId, referenceId, null, false, null); + } + + private static List skuEntries(String source) { + if (!StringUtils.hasText(source)) return List.of(); + List result = new ArrayList<>(); + for (String line : source.split("\\R")) { + String value = line.trim(); + if (value.isBlank() || "/".equals(value)) continue; + var matcher = java.util.regex.Pattern.compile("^(.+?)-(\\d+)件$").matcher(value); + if (matcher.matches()) result.add(new SkuEntry(matcher.group(1).trim(), positiveInt(matcher.group(2), "SKU 箱数"))); + else result.add(new SkuEntry(value, 1)); + } + return result; + } + + private static String text(Row row, int column) { return row == null ? "" : FORMATTER.get().formatCellValue(row.getCell(column)).trim(); } + private static String required(Row row, int column, String label) { String value = text(row, column); if (!StringUtils.hasText(value)) throw invalid(label + "不能为空"); return value; } + private static int positiveInt(String value, String label) { try { int parsed = new BigDecimal(value).intValueExact(); if (parsed < 1) throw new NumberFormatException(); return parsed; } catch (Exception e) { throw invalid(label + "必须为正整数"); } } + private static BigDecimal positiveDecimal(String value, String label) { try { BigDecimal parsed = new BigDecimal(value); if (parsed.signum() <= 0) throw new NumberFormatException(); return parsed; } catch (Exception e) { throw invalid(label + "必须大于 0"); } } + private static String cleanReference(String value) { if (!StringUtils.hasText(value)) return null; String first = Arrays.stream(value.split("\\R")).map(String::trim).filter(v -> !v.isBlank()).findFirst().orElse(""); first = first.replaceAll("-\\d+件$", ""); return "/".equals(first) ? null : first; } + private static String joinRemark(String left, String right) { return StringUtils.hasText(left) ? left.trim() + ";" + right : right; } + private static String normalize(String value) { return value == null ? "" : value.replaceAll("[\\s()()_-]", "").toUpperCase(Locale.ROOT); } + private static String derivedKey(String batchKey, String inboundCode) { try { byte[] bytes = MessageDigest.getInstance("SHA-256").digest((batchKey.trim() + ":" + inboundCode).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception e) { throw unavailable("导入幂等键生成失败"); } } + private static BizException invalid(String message) { return new BizException(ResultCode.VALIDATION_FAILED, message); } + private static BizException unavailable(String message) { return new BizException(ResultCode.SERVICE_UNAVAILABLE, message); } + + private record Customer(long id, String name) { } + private record ParsedRow(int excelRow, String legacyInboundCode, OrderRequests.SaveRequest request, String error) { } + private record CargoText(String name, String material) { } + private record SkuEntry(String sku, int packages) { } + private record Catalog(List domestic, List fba, + List overseas, + Map> channelCache) { + List channels(long warehouseId) { + return channelCache.getOrDefault(warehouseId, List.of()); + } + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 3270cf299..3d3aa02da 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -593,6 +593,25 @@ paths: application/json: schema: {$ref: '#/components/schemas/BatchReviewPreviewEnvelope'} '403': {description: 需要平台身份和 platform:order:approve 权限} + /api/platform/v1/orders/first-leg/legacy-import: + post: + tags: [Platform first-leg orders] + summary: 导入旧系统订单 + description: 固定归属 IT测试公司;保留旧入仓码,按当前仓库和价格簿逐行校验,成功行直接进入待审核。 + parameters: + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {description: 返回逐行成功、失败及错误原因} + '403': {description: 需要平台身份和 platform:order:import 权限} /api/platform/v1/orders/first-leg/batch-approve: post: tags: [Platform first-leg orders] diff --git a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java index 52bbba672..4a514926c 100644 --- a/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/DomesticWarehouseOrderExportPermissionSqlContractTest.java @@ -28,7 +28,7 @@ class DomesticWarehouseOrderExportPermissionSqlContractTest { .contains("platform:download:list") .contains("4000003"); } - assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)"); + assertThat(iamSeed).contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)"); assertThat(unified) .contains("VALUES (4000001,5102026,0,CURRENT_TIMESTAMP(3))") .contains("VALUES (4000003,5102026,0,CURRENT_TIMESTAMP(3))") diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 36c238443..447ac09d4 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -7,6 +7,7 @@ import com.baiyue.order.dto.UnifiedOrderQuery; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; import java.util.List; @@ -49,6 +50,8 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class); assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')", AuthenticatedUser.class, long.class, OrderRequests.BatchRejectRequest.class); + assertAuthority(PlatformFirstLegOrderController.class, "importLegacy", "hasAuthority('platform:order:import')", + AuthenticatedUser.class, String.class, MultipartFile.class); } @Test diff --git a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java index 10b822081..ef4582c7a 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderDeclarationUpdateContractTest.java @@ -107,7 +107,7 @@ class OrderDeclarationUpdateContractTest { .contains("values (4000003,5102027,0,current_timestamp(3))"); assertThat(iamSource) .contains("platform:order:declaration:edit") - .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027)"); + .contains("WHERE id IN (5102019, 5102024, 5102025, 5102026, 5102027, 5102028)"); } @SuppressWarnings("unchecked") diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 28d51a583..023096295 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -10,6 +10,7 @@ import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.InboundCodeAssociationService; import com.baiyue.order.service.BatchOrderReviewPreviewService; +import com.baiyue.order.service.PlatformLegacyOrderImportService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; import org.springframework.boot.test.mock.mockito.MockBean; @@ -32,6 +33,7 @@ class PlatformOrderPageControllerTest { @MockBean private OrderApplicationService service; @MockBean private InboundCodeAssociationService inboundCodeAssociations; @MockBean private BatchOrderReviewPreviewService batchReviewPreview; + @MockBean private PlatformLegacyOrderImportService legacyImport; @Resource private PlatformFirstLegOrderController controller; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/service/PlatformLegacyOrderImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformLegacyOrderImportServiceTest.java new file mode 100644 index 000000000..027136a7a --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/PlatformLegacyOrderImportServiceTest.java @@ -0,0 +1,109 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.client.LegacyImportPricingClient; +import com.baiyue.order.client.LegacyImportWarehouseClient; +import com.baiyue.order.client.TenantEligibilityClient; +import com.baiyue.order.dto.OrderDetailResponse; +import com.baiyue.order.dto.OrderRequests; +import com.baiyue.order.entity.OrderEntity; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.ByteArrayOutputStream; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformLegacyOrderImportServiceTest { + private final TenantEligibilityClient tenants = mock(TenantEligibilityClient.class); + private final LegacyImportWarehouseClient warehouses = mock(LegacyImportWarehouseClient.class); + private final LegacyImportPricingClient pricing = mock(LegacyImportPricingClient.class); + private final OrderApplicationService orders = mock(OrderApplicationService.class); + private PlatformLegacyOrderImportService service; + + @BeforeEach + void setUp() { + service = new PlatformLegacyOrderImportService(); + ReflectionTestUtils.setField(service, "tenants", tenants); + ReflectionTestUtils.setField(service, "warehouses", warehouses); + ReflectionTestUtils.setField(service, "pricing", pricing); + ReflectionTestUtils.setField(service, "orders", orders); + ReflectionTestUtils.setField(service, "token", "internal-token"); + var customer = new TenantEligibilityClient.CustomerSummary(88L, "IT", "IT测试公司", "ACTIVE", null, null); + when(tenants.customerSummaryByKeyword("internal-token", 1, 200, "IT测试公司", "ACTIVE")) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(customer), 1, 200, 1, 1))); + when(warehouses.catalog("internal-token")).thenReturn(ApiResponse.success(List.of( + new LegacyImportWarehouseClient.Item(21L, "CN-YW", "义乌仓", "DOMESTIC", "CN"), + new LegacyImportWarehouseClient.Item(22L, "SCK4", "SCK4", "FBA", "US"), + new LegacyImportWarehouseClient.Item(23L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US")))); + when(pricing.channels("internal-token", 88L, 21L)).thenReturn(ApiResponse.success(List.of( + new LegacyImportPricingClient.Channel("MATSON", "美森正班海卡包税")))); + when(orders.importLegacySubmittedOrder(anyLong(), anyLong(), anyString(), any(), anyString())) + .thenReturn(1001L, 1002L); + when(orders.detail(anyLong(), anyLong())).thenAnswer(invocation -> { + OrderEntity order = new OrderEntity(); + order.setOrderNo("FL-" + invocation.getArgument(1)); + return new OrderDetailResponse(order, List.of(), List.of(), List.of(), List.of()); + }); + } + + @Test + void importsFbaAndSplitsDropshipSkuLinesForFixedCustomer() throws Exception { + var result = service.importFile(7L, "operator", "batch-key", + new MockMultipartFile("file", "legacy.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + workbook())); + + assertThat(result.total()).isEqualTo(2); + assertThat(result.succeeded()).isEqualTo(2); + assertThat(result.failed()).isZero(); + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderRequests.SaveRequest.class); + verify(orders, times(2)).importLegacySubmittedOrder(anyLong(), anyLong(), anyString(), captor.capture(), anyString()); + OrderRequests.SaveRequest fba = captor.getAllValues().get(0); + assertThat(fba.customerName()).isEqualTo("IT测试公司"); + assertThat(fba.inboundCode()).isEqualTo("ZBYHK2609200988"); + assertThat(fba.destinationType()).isEqualTo("FBA"); + assertThat(fba.cargoes().get(0).skuNo()).isEqualTo("LEGACY-ZBYHK2609200988"); + OrderRequests.SaveRequest dropship = captor.getAllValues().get(1); + assertThat(dropship.fulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP"); + assertThat(dropship.cargoes()).extracting(OrderRequests.Cargo::skuNo) + .containsExactly("SKU-S", "SKU-M", "SKU-L"); + assertThat(dropship.cargoes()).extracting(OrderRequests.Cargo::packageCount).containsExactly(1, 1, 1); + } + + private static byte[] workbook() throws Exception { + try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("sheet1"); + sheet.createRow(0).createCell(0).setCellValue("订单统计导出"); + sheet.createRow(1).createCell(0).setCellValue("导出人:测试"); + String[] headers = {"客户公司", "入仓码", "订单状态", "渠道", "品名/材质", "买单/退税", "目的仓", "邮编", + "货件编号", "FBA仓追踪编码", "SKU", "总箱数", "预报毛重", "预报体积", "货重比", "交货仓", "备注"}; + var header = sheet.createRow(2); + for (int index = 0; index < headers.length; index++) header.createCell(index).setCellValue(headers[index]); + String[] fba = {"旧客户", "ZBYHK2609200988", "审核通过", "美森正班海卡包税", "玩偶-polyester-10件", "退税", "SCK4", "91730", + "FBA123-10件", "REF123", "", "10", "102", "0.986", "103.45", "义乌仓", ""}; + String[] dropship = {"旧客户", "ZBYHK2609200983", "审核通过", "美森正班海卡包税", "内裤-polyester-3件", "买单", "洛杉矶仓一件代发", "91730", + "/-3件", "/", "SKU-S-1件\nSKU-M-1件\nSKU-L-1件", "3", "78", "0.360", "216.67", "义乌仓", ""}; + write(sheet.createRow(3), fba); + write(sheet.createRow(4), dropship); + workbook.write(out); + return out.toByteArray(); + } + } + + private static void write(org.apache.poi.ss.usermodel.Row row, String[] values) { + for (int index = 0; index < values.length; index++) row.createCell(index).setCellValue(values[index]); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java index b6fddbb0e..0b1f3b1e5 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalWarehouseController.java @@ -57,6 +57,13 @@ public class InternalWarehouseController { return ApiResponse.success(service.activeDomesticWarehouses()); } + @GetMapping("/order-catalog") + public ApiResponse> orderCatalog( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { + verifyToken(token); + return ApiResponse.success(service.activeOrderCatalog()); + } + @GetMapping("/shared-overseas") public ApiResponse> sharedOverseasWarehouses( @RequestHeader(value="X-Internal-Service-Token", required=false) String token) { diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java index 055df51b4..87ff92e64 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/WarehouseRepository.java @@ -68,6 +68,16 @@ public class WarehouseRepository { .orderByAsc(WarehouseEntity::getId)); } + public List findAllActive(long tenantId) { + return warehouses.selectList(new LambdaQueryWrapper() + .eq(WarehouseEntity::getTenantId, tenantId) + .eq(WarehouseEntity::getStatus, "ACTIVE") + .in(WarehouseEntity::getWarehouseType, List.of("DOMESTIC", "FBA", "OVERSEAS")) + .orderByAsc(WarehouseEntity::getWarehouseType) + .orderByAsc(WarehouseEntity::getWarehouseName) + .orderByAsc(WarehouseEntity::getId)); + } + public boolean existsCode(long tenantId, String code, Long excludedId) { return warehouses.selectCount(new LambdaQueryWrapper() .eq(WarehouseEntity::getTenantId, tenantId) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java index 0a3dd7d29..0ae0602ac 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseApplicationService.java @@ -105,6 +105,16 @@ public class WarehouseApplicationService { .toList(); } + public List activeOrderCatalog() { + return repository.findAllActive(WarehouseSaveNormalizer.PUBLIC_TENANT_ID).stream() + .map(value -> new WarehouseCatalogItem(value.getId(), value.getWarehouseCode(), + value.getWarehouseName(), value.getWarehouseType(), value.getCountryCode())) + .toList(); + } + + public record WarehouseCatalogItem(Long id, String warehouseCode, String warehouseName, + String warehouseType, String countryCode) { } + public List activeSharedOverseasWarehouses() { return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID, WarehouseSaveNormalizer.TYPE_OVERSEAS) diff --git a/warehouse/src/main/resources/openapi/warehouse-v1.yaml b/warehouse/src/main/resources/openapi/warehouse-v1.yaml new file mode 100644 index 000000000..909774b5e --- /dev/null +++ b/warehouse/src/main/resources/openapi/warehouse-v1.yaml @@ -0,0 +1,19 @@ +openapi: 3.0.3 +info: + title: Warehouse internal order catalog + version: 1.0.0 +paths: + /internal/warehouses/order-catalog: + get: + summary: 查询订单可引用的启用公共仓库目录 + description: 仅供订单服务解析旧系统导入文件;返回国内、FBA 和海外仓,不经公网网关暴露。 + security: + - internalServiceToken: [] + responses: + '200': {description: 返回仓库 ID、编码、名称、类型和国家编码} +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token