优化
This commit is contained in:
+1
-1
@@ -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")
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<PdaReleaseService.Release>> 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<PdaReleaseService.Release> 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<PdaReleaseService.Release> 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<PdaReleaseService.Release> withdraw(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String id) {
|
||||
requirePlatform(user);
|
||||
return ApiResponse.success(releases.withdraw(id));
|
||||
}
|
||||
|
||||
@GetMapping("/api/pda-updates/{app}/latest")
|
||||
public Map<String, Object> 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<Resource> 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();
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
@@ -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<Release> list(String app) {
|
||||
appid(app);
|
||||
Channel channel = channel(app, false);
|
||||
List<Row> 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<String, Object> 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<String, Object> 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<Channel> 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<Row> 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); }
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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和数据隔离键',
|
||||
|
||||
+11
@@ -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<java.util.List<com.baiyue.firstleg.dto.PriceBookDtos.CurrentChannel>> 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, "内部服务凭证无效");
|
||||
|
||||
@@ -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 计价资格与费用可逆性
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`;校验公共收货仓、目的仓类型、状态、地址与收货能力,并保存服务端快照 |
|
||||
|
||||
@@ -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<List<Channel>> channels(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam("tenantId") long tenantId,
|
||||
@RequestParam("originWarehouseId") long originWarehouseId);
|
||||
|
||||
record Channel(String channelCode, String channelName) { }
|
||||
}
|
||||
@@ -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<List<Item>> catalog(@RequestHeader("X-Internal-Service-Token") String token);
|
||||
|
||||
record Item(Long id, String warehouseCode, String warehouseName, String warehouseType,
|
||||
String countryCode) { }
|
||||
}
|
||||
@@ -22,6 +22,14 @@ public interface TenantEligibilityClient {
|
||||
@RequestParam("size") long size,
|
||||
@RequestParam("customerId") long customerId);
|
||||
|
||||
@GetMapping("/finance-customers")
|
||||
ApiResponse<PageResult<CustomerSummary>> 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) { }
|
||||
|
||||
@@ -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<PlatformLegacyOrderImportDtos.Response> 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')")
|
||||
|
||||
@@ -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<Result> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "当前系统由新系统自动生成入仓码,请勿人工填写");
|
||||
}
|
||||
|
||||
@@ -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<String> HEADERS = List.of("客户公司", "入仓码", "订单状态", "渠道", "品名/材质",
|
||||
"买单/退税", "目的仓", "邮编", "货件编号", "FBA仓追踪编码", "SKU", "总箱数", "预报毛重",
|
||||
"预报体积", "货重比", "交货仓", "备注");
|
||||
private static final ThreadLocal<DataFormatter> 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<ParsedRow> rows = parse(content, customer, catalog);
|
||||
List<PlatformLegacyOrderImportDtos.Result> 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<ParsedRow> 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<ParsedRow> 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<SkuEntry> 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<OrderRequests.Cargo> 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<Long, List<LegacyImportPricingClient.Channel>> 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<LegacyImportPricingClient.Channel> 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<LegacyImportWarehouseClient.Item> values,
|
||||
String source, String label) {
|
||||
String normalized = normalize(source);
|
||||
List<LegacyImportWarehouseClient.Item> exact = values.stream().filter(value ->
|
||||
normalized.equals(normalize(value.warehouseCode())) || normalized.equals(normalize(value.warehouseName()))).toList();
|
||||
List<LegacyImportWarehouseClient.Item> 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<LegacyImportPricingClient.Channel> values, String source) {
|
||||
String normalized = normalize(source);
|
||||
List<LegacyImportPricingClient.Channel> 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<SkuEntry> skuEntries(String source) {
|
||||
if (!StringUtils.hasText(source)) return List.of();
|
||||
List<SkuEntry> 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<LegacyImportWarehouseClient.Item> domestic, List<LegacyImportWarehouseClient.Item> fba,
|
||||
List<LegacyImportWarehouseClient.Item> overseas,
|
||||
Map<Long, List<LegacyImportPricingClient.Channel>> channelCache) {
|
||||
List<LegacyImportPricingClient.Channel> channels(long warehouseId) {
|
||||
return channelCache.getOrDefault(warehouseId, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
+1
-1
@@ -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))")
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
+109
@@ -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<OrderRequests.SaveRequest> 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]);
|
||||
}
|
||||
}
|
||||
+7
@@ -57,6 +57,13 @@ public class InternalWarehouseController {
|
||||
return ApiResponse.success(service.activeDomesticWarehouses());
|
||||
}
|
||||
|
||||
@GetMapping("/order-catalog")
|
||||
public ApiResponse<java.util.List<WarehouseApplicationService.WarehouseCatalogItem>> orderCatalog(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token) {
|
||||
verifyToken(token);
|
||||
return ApiResponse.success(service.activeOrderCatalog());
|
||||
}
|
||||
|
||||
@GetMapping("/shared-overseas")
|
||||
public ApiResponse<List<SharedOverseasWarehouseResponse>> sharedOverseasWarehouses(
|
||||
@RequestHeader(value="X-Internal-Service-Token", required=false) String token) {
|
||||
|
||||
@@ -68,6 +68,16 @@ public class WarehouseRepository {
|
||||
.orderByAsc(WarehouseEntity::getId));
|
||||
}
|
||||
|
||||
public List<WarehouseEntity> findAllActive(long tenantId) {
|
||||
return warehouses.selectList(new LambdaQueryWrapper<WarehouseEntity>()
|
||||
.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<WarehouseEntity>()
|
||||
.eq(WarehouseEntity::getTenantId, tenantId)
|
||||
|
||||
@@ -105,6 +105,16 @@ public class WarehouseApplicationService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<WarehouseCatalogItem> 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<SharedOverseasWarehouseResponse> activeSharedOverseasWarehouses() {
|
||||
return repository.findActiveByType(WarehouseSaveNormalizer.PUBLIC_TENANT_ID,
|
||||
WarehouseSaveNormalizer.TYPE_OVERSEAS)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user