This commit is contained in:
2026-09-20 14:22:06 +08:00
parent 493d62e04c
commit b9e0c8ea2f
29 changed files with 1078 additions and 17 deletions
@@ -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); }
}
+6 -2
View File
@@ -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:
+34
View File
@@ -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和数据隔离键',
@@ -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 计价资格与费用可逆性
+2 -1
View File
@@ -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')
+1
View File
@@ -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]
@@ -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
@@ -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);
@@ -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]);
}
}
@@ -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