diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java
index 643bb1607..966201676 100644
--- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java
+++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java
@@ -85,6 +85,20 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) {
verify(token); service.updateProfile(id, request); return ApiResponse.success(null);
}
+ @PutMapping("/{id}/login-identifiers")
+ public ApiResponse loginIdentifiers(
+ @RequestHeader(value="X-Internal-Service-Token", required=false) String token,
+ @PathVariable Long id, @Valid @RequestBody ChangeLoginIdentifiersRequest request) {
+ verify(token);
+ return ApiResponse.success(service.changeLoginIdentifiers(id, request));
+ }
+ @PutMapping("/{id}/phone")
+ public ApiResponse phone(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
+ @PathVariable Long id, @Valid @RequestBody UpdateAccountPhoneRequest request) {
+ verify(token);
+ service.updatePhone(id, request);
+ return ApiResponse.success(null);
+ }
@PutMapping("/{id}/password")
public ApiResponse reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
diff --git a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java
index 80dbe0f85..786deac67 100644
--- a/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java
+++ b/auth/src/main/java/com/baiyue/auth/dto/AccountManagementDtos.java
@@ -21,7 +21,8 @@ public final class AccountManagementDtos {
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record CreateAccountRequest(@NotNull Long accountId,
- @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
+ @Pattern(regexp = "^1\\d{10}$") String phone,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank String displayName, @NotNull Long tenantId,
@@ -32,9 +33,19 @@ public final class AccountManagementDtos {
String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) {
- this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
+ this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
+ }
+ public CreateAccountRequest(Long accountId, String username, String password, String displayName,
+ Long tenantId, Long membershipId, String appCode, String accountStatus) {
+ this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
}
}
+ public record ChangeLoginIdentifiersRequest(
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
+ @NotBlank @Pattern(regexp = "^1\\d{10}$") String phone,
+ @NotBlank String currentPassword) { }
+ public record UpdateAccountPhoneRequest(
+ @NotBlank @Pattern(regexp = "^1\\d{10}$") String phone) { }
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record RevokeAccountSessionsRequest(
@@ -56,10 +67,10 @@ public final class AccountManagementDtos {
public record ImpersonationExchangeRequest(@NotBlank String code) { }
public record UpdateAccountScopeRequest(@NotNull Long tenantId, @NotNull Long membershipId,
@NotBlank @Pattern(regexp = "MERCHANT|PLATFORM") String appCode) { }
- public record AccountSummary(Long accountId, String username, String displayName, String status,
+ public record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) {
- this(accountId, username, displayName, status, null, null, null, null);
+ this(accountId, username, null, displayName, status, null, null, null, null);
}
}
}
diff --git a/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java
index db5b95596..5e09a4298 100644
--- a/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java
+++ b/auth/src/main/java/com/baiyue/auth/dto/LoginRequest.java
@@ -2,13 +2,15 @@ package com.baiyue.auth.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
/**
* 登录请求契约,接收账号、密码和目标应用域等统一登录参数。
*/
@Schema(name = "LoginRequest", description = "Unified login credentials / 统一登录凭据")
public record LoginRequest(
- @Schema(description = "Login username / 登录用户名", example = "merchant_admin") @NotBlank String username,
+ @Schema(description = "Username or mainland China mobile number / 用户名或中国大陆手机号", example = "merchant_admin")
+ @NotBlank @Size(max = 64) String loginId,
@Schema(description = "Login password / 登录密码", example = "ChangeMe123!", format = "password") @NotBlank String password
) {
-}
\ No newline at end of file
+}
diff --git a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
index 695e36832..570d4a6a9 100644
--- a/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
+++ b/auth/src/main/java/com/baiyue/auth/dto/MerchantRegisterRequest.java
@@ -8,12 +8,12 @@ import jakarta.validation.constraints.Size;
@Schema(name = "MerchantRegisterRequest", description = "Phase-one merchant account registration")
public record MerchantRegisterRequest(
- @NotBlank @Pattern(regexp = "[A-Za-z0-9_]{4,64}") String username,
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 72) String password,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Size(max = 128) String companyName,
@NotBlank @Size(max = 64) String legalPerson,
- @NotBlank @Size(max = 32) String legalPhone,
+ @NotBlank @Pattern(regexp = "^1\\d{10}$") String legalPhone,
@Email @Size(max = 128) String contactEmail,
@Size(min = 2, max = 2) String countryCode,
@Size(max = 255) String address
diff --git a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java
index a26a18d33..22f96b839 100644
--- a/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java
+++ b/auth/src/main/java/com/baiyue/auth/entity/AuthAccount.java
@@ -18,6 +18,7 @@ import java.time.LocalDateTime;
public class AuthAccount {
@TableId private Long id;
private String username;
+ private String phone;
private String passwordHash;
private String displayName;
private String accountStatus;
diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java
index f4102abb5..636a1efe0 100644
--- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java
+++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
.last("LIMIT 1"));
}
+ public AuthAccount findByPhone(String phone) {
+ return mapper.selectOne(new LambdaQueryWrapper()
+ .eq(AuthAccount::getPhone, phone)
+ .last("LIMIT 1"));
+ }
+
public boolean existsByUsername(String username) {
return mapper.selectCount(new LambdaQueryWrapper()
.eq(AuthAccount::getUsername, username)) > 0;
}
+ public boolean existsByPhone(String phone) {
+ return mapper.selectCount(new LambdaQueryWrapper()
+ .eq(AuthAccount::getPhone, phone)) > 0;
+ }
+
+ public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
+ return mapper.selectCount(new LambdaQueryWrapper()
+ .eq(AuthAccount::getUsername, username)
+ .ne(AuthAccount::getId, accountId)) > 0;
+ }
+
+ public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
+ return mapper.selectCount(new LambdaQueryWrapper()
+ .eq(AuthAccount::getPhone, phone)
+ .ne(AuthAccount::getId, accountId)) > 0;
+ }
+
public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) {
diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java
index 795a40b31..651cc0b8f 100644
--- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java
+++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java
@@ -71,9 +71,13 @@ public class AccountManagementService {
if (repository.existsByUsername(request.username())) {
throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
}
+ if (request.phone() != null && repository.existsByPhone(request.phone())) {
+ throw new BizException(ResultCode.CONFLICT, "手机号已存在");
+ }
AuthAccount account = new AuthAccount();
account.setId(request.accountId());
account.setUsername(request.username());
+ account.setPhone(request.phone());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName());
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
@@ -117,6 +121,45 @@ public class AccountManagementService {
bumpVersion(account);
}
+ @Transactional
+ @BusinessAudit(event = "ACCOUNT_LOGIN_IDENTIFIERS_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
+ description = "修改本人用户名和手机号", captureAfter = false)
+ public AccountSummary changeLoginIdentifiers(Long accountId, ChangeLoginIdentifiersRequest request) {
+ AuthAccount account = required(accountId);
+ if (!passwordEncoder.matches(request.currentPassword(), account.getPasswordHash())) {
+ throw new BizException(ResultCode.UNAUTHORIZED, "当前密码错误");
+ }
+ String username = request.username().trim();
+ String phone = request.phone().trim();
+ if (repository.existsByUsernameForOtherAccount(username, accountId)) {
+ throw new BizException(ResultCode.CONFLICT, "用户名已存在");
+ }
+ if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
+ throw new BizException(ResultCode.CONFLICT, "手机号已存在");
+ }
+ if (Objects.equals(account.getUsername(), username) && Objects.equals(account.getPhone(), phone)) {
+ return summary(account);
+ }
+ account.setUsername(username);
+ account.setPhone(phone);
+ bumpVersion(account);
+ return summary(account);
+ }
+
+ @Transactional
+ @BusinessAudit(event = "ACCOUNT_PHONE_SYNC", resourceType = "ACCOUNT", resourceId = "#accountId",
+ description = "同步受管账号手机号", captureAfter = false)
+ public void updatePhone(Long accountId, UpdateAccountPhoneRequest request) {
+ AuthAccount account = required(accountId);
+ String phone = request.phone().trim();
+ if (Objects.equals(account.getPhone(), phone)) return;
+ if (repository.existsByPhoneForOtherAccount(phone, accountId)) {
+ throw new BizException(ResultCode.CONFLICT, "手机号已存在");
+ }
+ account.setPhone(phone);
+ bumpVersion(account);
+ }
+
@Transactional
@BusinessAudit(event = "PASSWORD_RESET", resourceType = "ACCOUNT", resourceId = "#accountId", description = "管理员重置密码", captureAfter = false)
public void resetPassword(Long accountId, ResetPasswordRequest request, boolean forceChangePassword) {
@@ -185,7 +228,7 @@ public class AccountManagementService {
}
private AccountSummary summary(AuthAccount a) {
- return new AccountSummary(a.getId(), a.getUsername(), a.getDisplayName(), a.getAccountStatus(),
+ return new AccountSummary(a.getId(), a.getUsername(), a.getPhone(), a.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
}
@@ -201,6 +244,7 @@ public class AccountManagementService {
private boolean sameIdentity(AuthAccount account, CreateAccountRequest request, String appCode) {
return Objects.equals(account.getUsername(), request.username())
+ && Objects.equals(account.getPhone(), request.phone())
&& Objects.equals(account.getDefaultTenantId(), request.tenantId())
&& Objects.equals(account.getOperatorTenantId(), request.tenantId())
&& Objects.equals(account.getDefaultMembershipId(), request.membershipId())
diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java
index 4f80622ee..51b84a88c 100644
--- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java
+++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java
@@ -57,10 +57,13 @@ public class AuthService {
@Transactional
- @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.username",
+ @BusinessAudit(event = "AUTH_LOGIN", resourceType = "ACCOUNT", resourceId = "#request.loginId",
description = "User login / 用户登录", captureAfter = false)
public TokenResponse login(LoginRequest request) {
- AuthAccount account = accountRepository.findByUsername(request.username());
+ String loginId = request.loginId().trim();
+ AuthAccount account = loginId.matches("^1\\d{10}$")
+ ? accountRepository.findByPhone(loginId)
+ : accountRepository.findByUsername(loginId);
if (account == null) throw invalidCredentials();
LocalDateTime now = LocalDateTime.now();
if (!"ACTIVE".equals(account.getAccountStatus()))
@@ -211,7 +214,7 @@ public class AuthService {
}
private static BizException invalidCredentials() {
- return new BizException(ResultCode.UNAUTHORIZED, "用户名或密码错误");
+ return new BizException(ResultCode.UNAUTHORIZED, "登录标识或密码错误");
}
}
diff --git a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
index d3fa0f65f..e0fc11be3 100644
--- a/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
+++ b/auth/src/main/java/com/baiyue/auth/service/MerchantRegistrationService.java
@@ -36,7 +36,10 @@ public class MerchantRegistrationService {
resourceId = "#request.username", description = "商户注册", captureAfter = false)
public MerchantRegisterResponse register(MerchantRegisterRequest request) {
if (accountRepository.existsByUsername(request.username())) {
- throw new BizException(ResultCode.CONFLICT, "Username already exists");
+ throw new BizException(ResultCode.CONFLICT, "用户名已存在");
+ }
+ if (accountRepository.existsByPhone(request.legalPhone())) {
+ throw new BizException(ResultCode.CONFLICT, "手机号已存在");
}
long accountId = IdWorker.getId();
String passwordHash = passwordEncoder.encode(request.password());
@@ -81,6 +84,7 @@ public class MerchantRegistrationService {
AuthAccount account = new AuthAccount();
account.setId(accountId);
account.setUsername(request.username());
+ account.setPhone(request.legalPhone());
account.setPasswordHash(passwordHash);
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
@@ -94,7 +98,7 @@ public class MerchantRegistrationService {
try {
accountRepository.insert(account);
} catch (DuplicateKeyException exception) {
- BizException failure = new BizException(ResultCode.CONFLICT, "Username already exists");
+ BizException failure = new BizException(ResultCode.CONFLICT, "用户名或手机号已存在");
failure.addSuppressed(exception);
compensateOnboarding(accountId, failure, compensationCompleted);
throw failure;
diff --git a/auth/src/main/resources/db/auth-login-identifiers.sql b/auth/src/main/resources/db/auth-login-identifiers.sql
new file mode 100644
index 000000000..e328e69b4
--- /dev/null
+++ b/auth/src/main/resources/db/auth-login-identifiers.sql
@@ -0,0 +1,43 @@
+-- Auth 单一登录标识增量:用户名或中国大陆手机号。
+-- 可重复执行;手机号仅从 IAM 当前用户档案回填,无法确认的账号保持 NULL。
+USE baiyue_auth;
+
+DROP PROCEDURE IF EXISTS auth_add_login_phone;
+DELIMITER $$
+CREATE PROCEDURE auth_add_login_phone()
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account' AND column_name = 'phone') THEN
+ ALTER TABLE auth_account ADD COLUMN phone VARCHAR(32) NULL COMMENT '全局唯一中国大陆登录手机号' AFTER username;
+ END IF;
+END$$
+DELIMITER ;
+CALL auth_add_login_phone();
+DROP PROCEDURE auth_add_login_phone;
+
+DROP PROCEDURE IF EXISTS auth_backfill_login_phone;
+DELIMITER $$
+CREATE PROCEDURE auth_backfill_login_phone()
+BEGIN
+ IF EXISTS (
+ SELECT user.phone
+ FROM baiyue_iam.iam_user user
+ WHERE user.deleted = 0 AND user.phone REGEXP '^1[0-9]{10}$'
+ GROUP BY user.phone HAVING COUNT(DISTINCT user.auth_user_id) > 1) THEN
+ SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'IAM 存在重复手机号,禁止建立 Auth 唯一登录索引';
+ END IF;
+ UPDATE auth_account account
+ JOIN baiyue_iam.iam_user user ON user.auth_user_id = account.id AND user.deleted = 0
+ SET account.phone = user.phone
+ WHERE account.phone IS NULL AND user.phone REGEXP '^1[0-9]{10}$';
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.statistics
+ WHERE table_schema = 'baiyue_auth' AND table_name = 'auth_account'
+ AND index_name = 'uk_auth_account_phone') THEN
+ ALTER TABLE auth_account ADD UNIQUE KEY uk_auth_account_phone (phone);
+ END IF;
+END$$
+DELIMITER ;
+CALL auth_backfill_login_phone();
+DROP PROCEDURE auth_backfill_login_phone;
diff --git a/auth/src/main/resources/db/auth.sql b/auth/src/main/resources/db/auth.sql
index a1ee7ad64..13659a4a3 100644
--- a/auth/src/main/resources/db/auth.sql
+++ b/auth/src/main/resources/db/auth.sql
@@ -6,6 +6,7 @@ USE baiyue_auth;
CREATE TABLE IF NOT EXISTS auth_account (
id BIGINT NOT NULL COMMENT '统一登录账号ID(雪花ID)',
username VARCHAR(64) NOT NULL COMMENT '全局唯一登录名',
+ phone VARCHAR(32) DEFAULT NULL COMMENT '全局唯一中国大陆登录手机号',
password_hash VARCHAR(255) NOT NULL COMMENT 'BCrypt密码哈希,禁止保存明文密码',
display_name VARCHAR(64) NOT NULL COMMENT '登录后展示名称',
account_status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '账号状态:ACTIVE正常、DISABLED停用、LOCKED锁定',
@@ -23,6 +24,7 @@ CREATE TABLE IF NOT EXISTS auth_account (
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_auth_account_username (username),
+ UNIQUE KEY uk_auth_account_phone (phone),
KEY idx_auth_account_status (account_status, locked_until)
) ENGINE=InnoDB COMMENT='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
index 4578286fa..8b207c1e3 100644
--- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
+++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java
@@ -6,6 +6,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.CreateAccountRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountProfileRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountStatusRequest;
import com.baiyue.auth.dto.AccountManagementDtos.UpdateAccountScopeRequest;
+import com.baiyue.auth.dto.AccountManagementDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.auth.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
@@ -157,6 +158,33 @@ class AccountManagementServiceTest {
verify(sessions).revokeAll(10L);
}
+ @Test void changeLoginIdentifiersRequiresCurrentPasswordAndRevokesSessions() {
+ account.setUsername("old.user");
+ account.setPhone("13800138000");
+ when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
+ when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(false);
+
+ var result = service.changeLoginIdentifiers(10L,
+ new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1"));
+
+ assertEquals("new.user", result.username());
+ assertEquals("13900139000", result.phone());
+ assertEquals(3, account.getTokenVersion());
+ verify(sessions).revokeAll(10L);
+ }
+
+ @Test void duplicatePhoneDoesNotChangeLoginIdentifiers() {
+ account.setUsername("old.user");
+ account.setPhone("13800138000");
+ when(repository.existsByUsernameForOtherAccount("new.user", 10L)).thenReturn(false);
+ when(repository.existsByPhoneForOtherAccount("13900139000", 10L)).thenReturn(true);
+
+ assertThrows(BizException.class, () -> service.changeLoginIdentifiers(10L,
+ new ChangeLoginIdentifiersRequest("new.user", "13900139000", "OldPass1")));
+ verify(repository, never()).save(any());
+ verify(sessions, never()).revokeAll(any());
+ }
+
@Test void rbacChangeRevokesAccessAndRefreshSessionsAndBumpsTokenVersions() {
AuthAccount second = new AuthAccount();
second.setId(11L); second.setTokenVersion(7); second.setAccountStatus("ACTIVE");
diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
index 4f0a36bc5..00040bbe3 100644
--- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
+++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java
@@ -100,6 +100,34 @@ class AuthServiceSmallJwtTest {
assertThat(response.overseasExpiresIn()).isEqualTo(900);
}
+ @Test
+ void mainlandPhoneIsResolvedAsTheSinglePhoneLoginIdentifier() {
+ AuthAccount account = new AuthAccount();
+ account.setId(42L);
+ account.setUsername("operator");
+ account.setPhone("13800138000");
+ account.setPasswordHash("hash");
+ account.setAccountStatus("ACTIVE");
+ account.setDefaultTenantId(0L);
+ account.setOperatorTenantId(0L);
+ account.setDefaultMembershipId(84L);
+ account.setDefaultAppCode("PLATFORM");
+ account.setTokenVersion(1);
+ AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM",
+ "WEB", Set.of(), Set.of(), 1L);
+ Jwt encoded = org.mockito.Mockito.mock(Jwt.class);
+ when(repository.findByPhone("13800138000")).thenReturn(account);
+ when(passwordEncoder.matches("secret", "hash")).thenReturn(true);
+ when(authorizationLoader.load(account)).thenReturn(snapshot);
+ when(jwtEncoder.encode(any())).thenReturn(encoded);
+ when(encoded.getTokenValue()).thenReturn("small-token");
+
+ service.login(new LoginRequest("13800138000", "secret"));
+
+ verify(repository).findByPhone("13800138000");
+ verify(repository, never()).findByUsername("13800138000");
+ }
+
@Test
void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() {
AuthAccount account = new AuthAccount();
diff --git a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java
index 28b516d46..18abf74a6 100644
--- a/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java
+++ b/iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java
@@ -21,6 +21,11 @@ public interface AuthAccountClient {
@PathVariable Long id, @RequestBody StatusRequest request);
@PutMapping("/{id}/profile") ApiResponse profile(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ProfileRequest request);
+ @PutMapping("/{id}/login-identifiers") ApiResponse changeLoginIdentifiers(
+ @RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id,
+ @RequestBody ChangeLoginIdentifiersRequest request);
+ @PutMapping("/{id}/phone") ApiResponse phone(@RequestHeader("X-Internal-Service-Token") String token,
+ @PathVariable Long id, @RequestBody PhoneRequest request);
@PutMapping("/{id}/password") ApiResponse reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request);
/** Idempotently disables and revokes all accounts belonging to a customer. */
@@ -37,25 +42,35 @@ public interface AuthAccountClient {
@DeleteMapping("/{id}/compensation") ApiResponse compensate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id);
- record CreateAccountRequest(Long accountId, String username, String password, String displayName,
+ record CreateAccountRequest(Long accountId, String username, String phone, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId) {
- this(accountId, username, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE");
+ this(accountId, username, null, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) {
- this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
+ this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
+ }
+ public CreateAccountRequest(Long accountId, String username, String password, String displayName,
+ Long tenantId, Long membershipId, String appCode, String accountStatus) {
+ this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
}
}
- record AccountSummary(Long accountId, String username, String displayName, String status,
+ record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) {
- this(accountId, username, displayName, status, null, null, null, null);
+ this(accountId, username, null, displayName, status, null, null, null, null);
+ }
+ public AccountSummary(Long accountId, String username, String displayName, String status,
+ LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
+ this(accountId, username, null, displayName, status, lastLoginAt, tenantId, membershipId, appCode);
}
}
record StatusRequest(String status) { }
record ProfileRequest(String displayName) { }
+ record ChangeLoginIdentifiersRequest(String username, String phone, String currentPassword) { }
+ record PhoneRequest(String phone) { }
record PasswordRequest(String newPassword, boolean forceChangePassword) {
public PasswordRequest(String newPassword) { this(newPassword, false); }
}
diff --git a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
index 97a950ed2..886722e72 100644
--- a/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
+++ b/iam/src/main/java/com/baiyue/iam/controller/MerchantTenantController.java
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
+import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.service.MemberService;
@@ -54,6 +55,18 @@ public class MerchantTenantController {
return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request));
}
+ @PutMapping("/profile/login-identifiers")
+ @PreAuthorize("isAuthenticated()")
+ @Operation(summary = "修改当前登录用户的用户名和手机号",
+ description = "必须使用当前密码重新认证;成功后吊销全部会话,客户端需重新登录。")
+ public ApiResponse changeLoginIdentifiers(
+ @Valid @RequestBody ChangeLoginIdentifiersRequest request,
+ @AuthenticationPrincipal AuthenticatedUser user) {
+ TenantGuard.rejectImpersonated(user);
+ long tenantId = TenantGuard.tenant(user, "MERCHANT");
+ return ApiResponse.success(personalProfileService.changeLoginIdentifiers(tenantId, user.userId(), request));
+ }
+
@GetMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。")
@@ -74,7 +87,7 @@ public class MerchantTenantController {
@PostMapping("/tenant/qualifications/submit-review")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
- @Operation(summary = "提交当前企业资质审核", description = "校验营业执照和法人身份证齐全后创建审核单;货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
+ @Operation(summary = "提交当前企业资质审核", description = "校验营业执照齐全后创建审核单;法人身份证、货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
public ApiResponse submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
}
diff --git a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java
index d1f6f488a..8981f2e44 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/MemberDtos.java
@@ -14,11 +14,11 @@ public final class MemberDtos {
private MemberDtos() { }
public record CreateMemberRequest(
- @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Size(max = 64) String displayName,
- @Size(max = 32) String phone,
+ @Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
@@ -30,7 +30,7 @@ public final class MemberDtos {
public record UpdateMemberRequest(
@NotBlank @Size(max = 64) String displayName,
- @Size(max = 32) String phone,
+ @Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
index 266bd6c2d..08bbab0ad 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java
@@ -57,9 +57,9 @@ public final class OnboardingDtos {
@NotBlank String businessLicenseDocumentUrl,
LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
- @NotBlank String legalPersonIdDocumentUrl,
+ String legalPersonIdDocumentUrl,
LocalDate legalPersonIdValidTo,
- boolean replaceLegalPersonId
+ Boolean replaceLegalPersonId
) { }
public record RequiredQualificationPackageResponse(
diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
index 5e36682fa..5ccc4e106 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformInternalUserDtos.java
@@ -20,9 +20,9 @@ public final class PlatformInternalUserDtos {
Long authUserId,
String userType,
@NotBlank @Size(max = 64) String displayName,
- @Size(max = 32) String phone,
+ @Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
- @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
+ @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String password,
@@ -31,7 +31,7 @@ public final class PlatformInternalUserDtos {
public record UpdateInternalUserRequest(
@NotBlank @Size(max = 64) String displayName,
- @Size(max = 32) String phone,
+ @Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank @Size(max = 64) String memberNo,
@NotNull List roleIds) { }
diff --git a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java
index 79dae468b..67ae5ea6b 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/PlatformUserManagementDtos.java
@@ -14,7 +14,7 @@ public final class PlatformUserManagementDtos {
private PlatformUserManagementDtos() { }
public record CreateUserRequest(
- @NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email,
diff --git a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
index 6dc36f880..471247703 100644
--- a/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
+++ b/iam/src/main/java/com/baiyue/iam/dto/ProfileDtos.java
@@ -2,6 +2,7 @@ package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** 用户档案、地址和资质的数据契约。 */
@@ -12,9 +13,13 @@ public final class ProfileDtos {
String displayName, String phone, String email) { }
public record UpdatePersonalProfileRequest(@NotBlank @Size(max = 64) String displayName,
- @Size(max = 32) String phone,
@Email @Size(max = 128) String email) { }
+ public record ChangeLoginIdentifiersRequest(
+ @NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
+ @NotBlank @Pattern(regexp = "^1\\d{10}$") String phone,
+ @NotBlank String currentPassword) { }
+
public record AddressRequest(@NotBlank String addressType, String countryCode, String province,
String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { }
diff --git a/iam/src/main/java/com/baiyue/iam/service/MemberService.java b/iam/src/main/java/com/baiyue/iam/service/MemberService.java
index 2119e6fc6..46813a762 100644
--- a/iam/src/main/java/com/baiyue/iam/service/MemberService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/MemberService.java
@@ -93,7 +93,8 @@ public class MemberService {
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
- authId, request.username(), request.password(), request.displayName(), tenantId, membershipId)));
+ authId, request.username(), request.phone(), request.password(), request.displayName(),
+ tenantId, membershipId, "MERCHANT", "ACTIVE")));
try {
IamUser user = new IamUser();
user.setId(userId); user.setAuthUserId(authId); user.setUserType("ENTERPRISE");
@@ -134,6 +135,10 @@ public class MemberService {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑");
}
IamUser user = requiredUser(membership.getUserId());
+ if (!java.util.Objects.equals(user.getPhone(), request.phone())) {
+ call(() -> authClient.phone(internalToken, user.getAuthUserId(),
+ new AuthAccountClient.PhoneRequest(request.phone())));
+ }
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo());
repository.saveUser(user); repository.save(membership);
diff --git a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java
index 0bd005cad..9631735be 100644
--- a/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/PersonalProfileService.java
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
+import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
+import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
@@ -11,12 +13,19 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.audit.BusinessAudit;
+import com.baiyue.common.core.api.ApiResponse;
+import feign.FeignException;
+import org.springframework.beans.factory.annotation.Value;
/** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */
@Service
public class PersonalProfileService {
@Resource
private MemberRepository repository;
+ @Resource
+ private AuthAccountClient authClient;
+ @Value("${baiyue.security.internal-service-token}")
+ private String internalToken;
public PersonalProfileResponse get(long tenantId, Long authUserId, String username) {
return response(tenantId, authUserId, username);
@@ -30,12 +39,40 @@ public class PersonalProfileService {
if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden();
IamUser user = repository.findUser(membership.getUserId());
if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
- user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
+ user.setDisplayName(request.displayName()); user.setEmail(request.email());
repository.saveUser(user);
return new PersonalProfileResponse(user.getId(), authUserId, username,
user.getDisplayName(), user.getPhone(), user.getEmail());
}
+ @Transactional
+ @BusinessAudit(event = "PERSONAL_LOGIN_IDENTIFIERS_UPDATE", resourceType = "IAM_USER",
+ resourceId = "#authUserId", description = "修改本人用户名和手机号", captureAfter = false)
+ public PersonalProfileResponse changeLoginIdentifiers(long tenantId, Long authUserId,
+ ChangeLoginIdentifiersRequest request) {
+ IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
+ if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden();
+ IamUser user = repository.findUser(membership.getUserId());
+ if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
+ AuthAccountClient.AccountSummary account;
+ try {
+ ApiResponse result = authClient.changeLoginIdentifiers(
+ internalToken, authUserId, new AuthAccountClient.ChangeLoginIdentifiersRequest(
+ request.username(), request.phone(), request.currentPassword()));
+ if (result == null || !result.successful() || result.data() == null) {
+ ResultCode code = result == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(result.code());
+ throw new BizException(code, result == null ? "认证账号服务暂时不可用" : result.message());
+ }
+ account = result.data();
+ } catch (FeignException ex) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
+ }
+ user.setPhone(account.phone());
+ repository.saveUser(user);
+ return new PersonalProfileResponse(user.getId(), authUserId, account.username(),
+ user.getDisplayName(), user.getPhone(), user.getEmail());
+ }
+
private PersonalProfileResponse response(long tenantId, Long authUserId, String username) {
IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
if (membership == null) throw forbidden();
diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
index baa3d4658..032898b2c 100644
--- a/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/PlatformInternalUserService.java
@@ -69,8 +69,8 @@ public class PlatformInternalUserService {
validateMemberNo(memberNo, null);
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
- authUserId, request.username(), request.password(), request.displayName(),
- PLATFORM_TENANT_ID, membershipId, "PLATFORM")));
+ authUserId, request.username(), request.phone(), request.password(), request.displayName(),
+ PLATFORM_TENANT_ID, membershipId, "PLATFORM", "ACTIVE")));
try {
IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email());
IamMembership membership = newMembership(membershipId, userId, memberNo);
@@ -96,12 +96,17 @@ public class PlatformInternalUserService {
IamUser user = requiredUser(membership.getUserId());
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName());
+ boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone());
List oldRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId);
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
if (displayNameChanged) {
call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName())));
}
+ if (phoneChanged) {
+ call(() -> authClient.phone(internalToken, user.getAuthUserId(),
+ new AuthAccountClient.PhoneRequest(request.phone())));
+ }
try {
user.setDisplayName(request.displayName());
user.setPhone(request.phone());
diff --git a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java
index 7d9b01f52..a277858ae 100644
--- a/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java
@@ -81,8 +81,8 @@ public class PlatformUserManagementService {
long membershipId = IdWorker.getId();
String appCode = appCode(tenantId);
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
- authUserId, request.username().trim(), request.password(), request.displayName().trim(),
- tenantId, membershipId, appCode, request.status())));
+ authUserId, request.username().trim(), request.phone().trim(), request.password(),
+ request.displayName().trim(), tenantId, membershipId, appCode, request.status())));
try {
IamUser user = new IamUser();
user.setId(userId);
@@ -137,12 +137,15 @@ public class PlatformUserManagementService {
}
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim());
+ boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone().trim());
boolean scopeChanged = oldTenantId != newTenantId;
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId);
if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
+ if (phoneChanged) call(() -> authClient.phone(internalToken, user.getAuthUserId(),
+ new AuthAccountClient.PhoneRequest(request.phone().trim())));
if (scopeChanged) call(() -> authClient.scope(internalToken, user.getAuthUserId(),
new AuthAccountClient.ScopeRequest(newTenantId, membershipId, appCode(newTenantId))));
try {
diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
index 0d5f25b1f..af90e9bf5 100644
--- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java
@@ -160,24 +160,32 @@ public class ProfileResourceService {
List businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE");
List legalPersonIds = currentQualifications(locked, "LEGAL_PERSON_ID");
requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照");
- requireReplaceAllowed(legalPersonIds, request.replaceLegalPersonId(), "法人身份证");
+ boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl());
+ if (hasLegalPersonId) {
+ requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证");
+ } else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) {
+ throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换");
+ }
String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment);
- String replacedLegalPersonIdUrl = supersede(legalPersonIds, replacementComment);
+ String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null;
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
creditCode, request.businessLicenseDocumentUrl());
businessLicense.setValidTo(request.businessLicenseValidTo());
repository.saveQualification(businessLicense);
- IamTenantQualification legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
- null, request.legalPersonIdDocumentUrl());
- legalPersonId.setValidTo(request.legalPersonIdValidTo());
- repository.saveQualification(legalPersonId);
+ IamTenantQualification legalPersonId = null;
+ if (hasLegalPersonId) {
+ legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
+ null, request.legalPersonIdDocumentUrl().trim());
+ legalPersonId.setValidTo(request.legalPersonIdValidTo());
+ repository.saveQualification(legalPersonId);
+ }
if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) {
tenant.setUnifiedSocialCreditCode(creditCode);
tenantRepository.saveTenant(tenant);
}
return new RequiredQualificationPackageResponse(
- businessLicense.getId(), legalPersonId.getId(), tenant.getStatus(),
+ businessLicense.getId(), legalPersonId == null ? null : legalPersonId.getId(), tenant.getStatus(),
replacedBusinessLicenseUrl, replacedLegalPersonIdUrl);
}
@@ -206,9 +214,9 @@ public class ProfileResourceService {
}
private void requireOptionalQualificationType(String type) {
- if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
+ if ("BUSINESS_LICENSE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST,
- "Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
+ "The business license must be uploaded in the required package / 营业执照和统一社会信用代码必须通过必填资质包上传");
}
}
diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java
index 6e1a6adbc..83b298eed 100644
--- a/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/TenantAdministratorProvisioningService.java
@@ -215,7 +215,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
IamTenant tenant, long membershipId) {
try {
ApiResponse response = authClient.create(internalToken,
- new AuthAccountClient.CreateAccountRequest(authUserId, username, password,
+ new AuthAccountClient.CreateAccountRequest(authUserId, username, tenant.getContactPhone(), password,
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
if (response == null) throw unavailable();
if (!response.successful()) {
diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
index f93c268cc..a8a7835d5 100644
--- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java
+++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java
@@ -52,8 +52,7 @@ import java.util.stream.Collectors;
*/
@Service
public class TenantService {
- private static final Set REQUIRED_QUALIFICATION_TYPES = Set.of(
- "BUSINESS_LICENSE", "LEGAL_PERSON_ID");
+ private static final Set REQUIRED_QUALIFICATION_TYPES = Set.of("BUSINESS_LICENSE");
@Resource
private TenantRepository repository;
@Resource
@@ -456,7 +455,7 @@ public class TenantService {
var qualifications = profileRepository.findQualifications(tenantId);
if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.BAD_REQUEST,
- "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质");
+ "Business license is required / 营业执照为必填资质");
}
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml
index ebedb134f..d4029ef5e 100644
--- a/iam/src/main/resources/openapi/iam-v1.yaml
+++ b/iam/src/main/resources/openapi/iam-v1.yaml
@@ -17,6 +17,56 @@ tags:
- name: RBAC
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
paths:
+ /api/merchant/v1/iam/profile:
+ get:
+ tags: [Merchant Tenant / 商户企业]
+ summary: 查看当前登录用户个人资料
+ responses:
+ '200':
+ description: 查询成功
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
+ '401': {$ref: '#/components/responses/Unauthorized'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ put:
+ tags: [Merchant Tenant / 商户企业]
+ summary: 修改当前登录用户普通资料
+ description: 仅修改显示名称和邮箱;用户名、手机号必须走敏感标识接口。
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/UpdatePersonalProfileRequest'}
+ responses:
+ '200':
+ description: 修改成功
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '401': {$ref: '#/components/responses/Unauthorized'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ /api/merchant/v1/iam/profile/login-identifiers:
+ put:
+ tags: [Merchant Tenant / 商户企业]
+ summary: 修改当前登录用户的用户名和手机号
+ description: 仅允许当前非代登录用户修改本人标识,必须用当前密码重新认证;成功后全部 Token 和会话失效,需重新登录。权限为已认证客户本人,无额外权限码。
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/ChangeLoginIdentifiersRequest'}
+ responses:
+ '200':
+ description: 修改成功并已吊销会话
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
+ '400': {$ref: '#/components/responses/BadRequest'}
+ '401': {$ref: '#/components/responses/Unauthorized'}
+ '403': {$ref: '#/components/responses/Forbidden'}
+ '409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/contact-phone-availability:
get:
tags: [Platform Tenant / 平台租户]
@@ -924,6 +974,35 @@ components:
ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas:
+ PersonalProfileResponse:
+ type: object
+ required: [userId, authUserId, username, displayName, phone]
+ properties:
+ userId: {type: string, pattern: '^\d+$'}
+ authUserId: {type: string, pattern: '^\d+$'}
+ username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
+ displayName: {type: string, maxLength: 64}
+ phone: {type: string, pattern: '^1\d{10}$'}
+ email: {type: string, format: email, nullable: true}
+ UpdatePersonalProfileRequest:
+ type: object
+ required: [displayName]
+ properties:
+ displayName: {type: string, minLength: 1, maxLength: 64}
+ email: {type: string, format: email, maxLength: 128, nullable: true}
+ ChangeLoginIdentifiersRequest:
+ type: object
+ required: [username, phone, currentPassword]
+ properties:
+ username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
+ phone: {type: string, pattern: '^1\d{10}$'}
+ currentPassword: {type: string, format: password, writeOnly: true}
+ PersonalProfileApiResponse:
+ allOf:
+ - {$ref: '#/components/schemas/ApiResponse'}
+ - type: object
+ properties:
+ data: {$ref: '#/components/schemas/PersonalProfileResponse'}
ApiResponse:
type: object
required: [code, message, timestamp]
@@ -1206,22 +1285,22 @@ components:
reviewComment: {type: string}
RequiredQualificationPackageRequest:
type: object
- required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense, legalPersonIdDocumentUrl, replaceLegalPersonId]
+ required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense]
properties:
tenantId: {type: string, pattern: '^\d+$'}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'}
businessLicenseDocumentUrl: {type: string, minLength: 1}
businessLicenseValidTo: {type: string, format: date, nullable: true}
replaceBusinessLicense: {type: boolean}
- legalPersonIdDocumentUrl: {type: string, minLength: 1}
+ legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填}
legalPersonIdValidTo: {type: string, format: date, nullable: true}
- replaceLegalPersonId: {type: boolean}
+ replaceLegalPersonId: {type: boolean, nullable: true, default: false}
RequiredQualificationPackageResponse:
type: object
- required: [businessLicenseQualificationId, legalPersonIdQualificationId, tenantStatus]
+ required: [businessLicenseQualificationId, tenantStatus]
properties:
businessLicenseQualificationId: {type: string, pattern: '^\d+$'}
- legalPersonIdQualificationId: {type: string, pattern: '^\d+$'}
+ legalPersonIdQualificationId: {type: string, pattern: '^\d+$', nullable: true}
tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]}
replacedBusinessLicenseDocumentUrl: {type: string, nullable: true}
replacedLegalPersonIdDocumentUrl: {type: string, nullable: true}
@@ -1317,7 +1396,7 @@ components:
type: object
required: [username, displayName, phone, tenantId, roleIds, password, status]
properties:
- username: {type: string, minLength: 4, maxLength: 64}
+ username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
@@ -1342,10 +1421,10 @@ components:
properties:
authUserId: {type: integer, format: int64, description: 兼容旧接口;填写后关联已有认证账号}
userType: {type: string, enum: [INTERNAL, PLATFORM], description: 兼容旧接口,可省略}
- username: {type: string, minLength: 4, maxLength: 64}
+ username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
displayName: {type: string, maxLength: 64}
- phone: {type: string, maxLength: 32}
+ phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
@@ -1354,7 +1433,7 @@ components:
required: [displayName, memberNo, roleIds]
properties:
displayName: {type: string, maxLength: 64}
- phone: {type: string, maxLength: 32}
+ phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
@@ -1413,10 +1492,10 @@ components:
type: object
required: [username, password, displayName, memberType]
properties:
- username: {type: string, minLength: 4, maxLength: 64}
+ username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
password: {type: string, format: password, minLength: 8, maxLength: 64}
displayName: {type: string, maxLength: 64}
- phone: {type: string, maxLength: 32}
+ phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string}
@@ -1429,7 +1508,7 @@ components:
required: [displayName, memberType]
properties:
displayName: {type: string, maxLength: 64}
- phone: {type: string, maxLength: 32}
+ phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string, maxLength: 64}
diff --git a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
index 6b12c273d..29f97b03f 100644
--- a/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/MemberServiceTest.java
@@ -161,6 +161,8 @@ class MemberServiceTest {
@Test void shouldHandleCase08() {
IamRole role = activeMerchantRole(50L, 100L);
when(roleMapper.selectById(50L)).thenReturn(role);
+ when(auth.phone(eq("internal-token"), eq(40L), any()))
+ .thenReturn(ApiResponse.success(null));
UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null,
"EMPLOYEE", "M-001", List.of(50L));
@@ -169,6 +171,7 @@ class MemberServiceTest {
assertEquals("新姓名", result.displayName());
verify(repository).saveUser(user);
verify(repository).replaceRoles(100L, 20L, List.of(50L));
+ verify(auth).phone(eq("internal-token"), eq(40L), any());
}
@Test void shouldHandleCase09() {
diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
index f99a921a9..24429a27a 100644
--- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java
@@ -83,6 +83,25 @@ class MerchantQualificationUploadTest {
verify(tenantRepository).saveTenant(tenant);
}
+ @Test
+ void requiredPackageAcceptsAnEmptyOptionalLegalPersonId() {
+ IamTenant tenant = new IamTenant();
+ tenant.setId(10001L);
+ tenant.setTenantType("MERCHANT");
+ tenant.setStatus("PROFILE_REQUIRED");
+ when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
+ when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
+
+ var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
+ 10001L, "91310101abcde12345",
+ "/api/merchant/v1/files/qualifications/license.png", null, false,
+ null, null, null));
+
+ assertNull(response.legalPersonIdQualificationId());
+ verify(profileRepository, times(1)).insertQualification(any());
+ verify(tenantRepository).saveTenant(tenant);
+ }
+
@Test
void requiredPackageCannotOverwriteAnEstablishedCreditCode() {
IamTenant tenant = new IamTenant();
diff --git a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
index 8f66b3ed4..287c962af 100644
--- a/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/PersonalProfileServiceTest.java
@@ -2,6 +2,9 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
+import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
+import com.baiyue.iam.client.AuthAccountClient;
+import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
@@ -10,14 +13,17 @@ import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("个人资料服务")
@SpringJUnitConfig(PersonalProfileService.class)
+@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-token")
class PersonalProfileServiceTest {
@MockBean private MemberRepository repository;
+ @MockBean private AuthAccountClient authClient;
@Resource private PersonalProfileService service;
@Test void canOnlyModifyOwnProfileWithinCurrentTenant() {
@@ -26,11 +32,29 @@ class PersonalProfileServiceTest {
when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
when(repository.findUser(3L)).thenReturn(user);
var result = service.update(1L, 2L, "tester",
- new UpdatePersonalProfileRequest("新姓名", "13800138000", "a@example.com"));
+ new UpdatePersonalProfileRequest("新姓名", "a@example.com"));
assertEquals("新姓名", result.displayName());
verify(repository).saveUser(user);
}
+
+ @Test void sensitiveLoginIdentifiersAreChangedOnlyThroughAuthReauthentication() {
+ IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE");
+ IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); user.setDisplayName("测试用户");
+ when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
+ when(repository.findUser(3L)).thenReturn(user);
+ when(authClient.changeLoginIdentifiers(any(), eq(2L), any())).thenReturn(ApiResponse.success(
+ new AuthAccountClient.AccountSummary(2L, "new.user", "13900139000", "测试用户", "ACTIVE",
+ null, 1L, 4L, "MERCHANT")));
+
+ var result = service.changeLoginIdentifiers(1L, 2L,
+ new ChangeLoginIdentifiersRequest("new.user", "13900139000", "CurrentPass1"));
+
+ assertEquals("new.user", result.username());
+ assertEquals("13900139000", result.phone());
+ verify(repository).saveUser(user);
+ }
+
@Test void crossTenantIdentityIsRejected() {
assertThrows(BizException.class, () -> service.get(9L, 2L, "tester"));
}
diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
index eb7bb54dc..6233f0642 100644
--- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
+++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java
@@ -369,11 +369,11 @@ class TenantServiceTest {
}
@Test
- void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
+ void explicitSubmissionRequiresBusinessLicenseAndCreatesReview() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
- qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
+ qualification("BUSINESS_LICENSE")));
var submitted = service.submitMerchantReview(10001L);
@@ -383,10 +383,10 @@ class TenantServiceTest {
}
@Test
- void explicitSubmissionRejectsAnIncompleteQualificationSet() {
+ void explicitSubmissionRejectsMissingBusinessLicense() {
when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
- qualification("BUSINESS_LICENSE")));
+ qualification("LEGAL_PERSON_ID")));
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
@@ -480,8 +480,8 @@ class TenantServiceTest {
void shouldHandleCase01() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
when(profileRepository.findQualifications(10001L)).thenReturn(
- List.of(approvedQualification("BUSINESS_LICENSE")),
- List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"),
+ List.of(),
+ List.of(approvedQualification("BUSINESS_LICENSE"),
rejectedQualification("FREIGHT_FORWARDER_CERTIFICATE")));
var withoutQualification = service.orderEligibility(10001L);
diff --git a/order/pom.xml b/order/pom.xml
index 4b53315e9..c9a6c1cf0 100644
--- a/order/pom.xml
+++ b/order/pom.xml
@@ -17,9 +17,9 @@
com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config
com.alibaba.cloudspring-cloud-starter-alibaba-sentinel
org.springframework.bootspring-boot-starter-actuator
+ org.apache.poipoi-ooxml5.3.0
com.mysqlmysql-connector-jruntime
org.springframework.bootspring-boot-starter-testtest
org.springframework.bootspring-boot-maven-plugin
-
diff --git a/order/src/main/java/com/baiyue/order/client/OrderBatchFileClient.java b/order/src/main/java/com/baiyue/order/client/OrderBatchFileClient.java
new file mode 100644
index 000000000..0bddcba15
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/client/OrderBatchFileClient.java
@@ -0,0 +1,23 @@
+package com.baiyue.order.client;
+
+import com.baiyue.common.core.api.ApiResponse;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.multipart.MultipartFile;
+
+@FeignClient(name = "file-service", contextId = "orderBatchFileClient",
+ url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files")
+public interface OrderBatchFileClient {
+ @PostMapping(value = "/{tenantId}/{businessType}/{businessId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token,
+ @RequestHeader("X-Operator-Id") long operatorId,
+ @PathVariable long tenantId, @PathVariable String businessType,
+ @PathVariable String businessId, @RequestPart("file") MultipartFile file);
+
+ record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType,
+ String businessId, String fileName, String contentType, Long sizeBytes) { }
+}
diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantCustomerAddressController.java b/order/src/main/java/com/baiyue/order/controller/MerchantCustomerAddressController.java
new file mode 100644
index 000000000..53654f06e
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/controller/MerchantCustomerAddressController.java
@@ -0,0 +1,62 @@
+package com.baiyue.order.controller;
+
+import com.baiyue.common.audit.BusinessAudit;
+import com.baiyue.common.core.api.ApiResponse;
+import com.baiyue.common.security.AuthenticatedUser;
+import com.baiyue.order.dto.CustomerAddressDtos;
+import com.baiyue.order.service.CustomerAddressService;
+import com.baiyue.order.service.OrderScopeGuard;
+import jakarta.annotation.Resource;
+import jakarta.validation.Valid;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/merchant/v1/order-addresses")
+public class MerchantCustomerAddressController {
+ @Resource private CustomerAddressService service;
+
+ @GetMapping
+ @PreAuthorize("hasAnyAuthority('merchant:order:list', 'merchant:order:create', 'merchant:order:edit')")
+ public ApiResponse> list(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestParam(required = false) String addressType) {
+ return ApiResponse.success(service.list(OrderScopeGuard.merchant(user), addressType));
+ }
+
+ @PostMapping
+ @PreAuthorize("hasAuthority('merchant:order:create')")
+ @BusinessAudit(event = "ORDER_ADDRESS_CREATE", resourceType = "ORDER_ADDRESS")
+ public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,
+ @Valid @RequestBody CustomerAddressDtos.SaveRequest request) {
+ return ApiResponse.success(service.create(OrderScopeGuard.merchant(user), user.userId(), request));
+ }
+
+ @PutMapping("/{id}")
+ @PreAuthorize("hasAuthority('merchant:order:edit')")
+ @BusinessAudit(event = "ORDER_ADDRESS_UPDATE", resourceType = "ORDER_ADDRESS", resourceId = "#id")
+ public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id,
+ @Valid @RequestBody CustomerAddressDtos.SaveRequest request) {
+ service.update(OrderScopeGuard.merchant(user), user.userId(), id, request);
+ return ApiResponse.success(null);
+ }
+
+ @DeleteMapping("/{id}")
+ @PreAuthorize("hasAuthority('merchant:order:edit')")
+ @BusinessAudit(event = "ORDER_ADDRESS_DISABLE", resourceType = "ORDER_ADDRESS", resourceId = "#id")
+ public ApiResponse disable(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) {
+ service.disable(OrderScopeGuard.merchant(user), user.userId(), id);
+ return ApiResponse.success(null);
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java
index e09240dcb..bbc8ad673 100644
--- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java
+++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java
@@ -7,6 +7,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
+import com.baiyue.order.dto.MerchantBatchImportDtos;
import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
@@ -16,6 +17,7 @@ import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.MerchantInboundOrderListService;
import com.baiyue.order.service.MerchantBatchDraftService;
+import com.baiyue.order.service.MerchantBatchImportService;
import com.baiyue.order.service.InboundCodeAssociationService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -33,6 +35,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+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.web.multipart.MultipartFile;
+import java.nio.charset.StandardCharsets;
import java.util.List;
@@ -53,6 +64,8 @@ public class MerchantFirstLegOrderController {
@Resource
private MerchantBatchDraftService batchDraftService;
@Resource
+ private MerchantBatchImportService batchImportService;
+ @Resource
private InboundCodeAssociationService inboundCodeAssociations;
@GetMapping("/inbound-code-policy")
@@ -102,6 +115,29 @@ public class MerchantFirstLegOrderController {
OrderScopeGuard.merchant(user), user.userId(), user.username(), idempotencyKey, items));
}
+ @GetMapping("/batch-imports/template")
+ @PreAuthorize("hasAuthority('merchant:order:create')")
+ public ResponseEntity batchImportTemplate() {
+ byte[] content = batchImportService.template();
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .header(HttpHeaders.CONTENT_DISPOSITION,
+ ContentDisposition.attachment().filename("first-leg-order-import.xlsx", StandardCharsets.UTF_8).build().toString())
+ .contentLength(content.length).body(content);
+ }
+
+ @BusinessAudit(event = "ORDER_BATCH_IMPORT", resourceType = "FIRST_LEG_ORDER", description = "Import order drafts from Excel")
+ @PostMapping(value = "/batch-imports", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @PreAuthorize("hasAuthority('merchant:order:create')")
+ public ApiResponse batchImport(
+ @AuthenticationPrincipal AuthenticatedUser user,
+ @RequestHeader("Idempotency-Key") String idempotencyKey,
+ @RequestParam MerchantBatchImportDtos.Mode mode,
+ @RequestPart("file") MultipartFile file) {
+ return ApiResponse.success(batchImportService.importFile(OrderScopeGuard.merchant(user), user.userId(),
+ user.username(), idempotencyKey, mode, file));
+ }
+
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Update draft / 修改草稿")
@Operation(summary = "Update draft / 修改草稿", description = "Updates an editable draft owned by the authenticated merchant tenant. / 修改当前认证商户租户拥有且可编辑的订单草稿。")
@ApiResponses({
diff --git a/order/src/main/java/com/baiyue/order/dto/CustomerAddressDtos.java b/order/src/main/java/com/baiyue/order/dto/CustomerAddressDtos.java
new file mode 100644
index 000000000..062f3aa62
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/dto/CustomerAddressDtos.java
@@ -0,0 +1,32 @@
+package com.baiyue.order.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Email;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Size;
+
+public final class CustomerAddressDtos {
+ private CustomerAddressDtos() { }
+
+ public record SaveRequest(
+ @NotBlank @Pattern(regexp = "PICKUP|PRIVATE_DESTINATION") String addressType,
+ @NotBlank @Size(max = 64) String addressName,
+ @NotBlank @Size(max = 128) String companyName,
+ @NotBlank @Size(max = 64) String contactName,
+ @NotBlank @Size(max = 32) String phone,
+ @Email @Size(max = 128) String email,
+ @NotBlank @Size(min = 2, max = 2) String countryCode,
+ @Size(max = 64) String province,
+ @NotBlank @Size(max = 64) String city,
+ @Size(max = 64) String district,
+ @NotBlank @Size(max = 255) String addressLine,
+ @Size(max = 20) String postalCode,
+ boolean defaultAddress) { }
+
+ public record Response(
+ @Schema(description = "内部 ID,仅供订单选择和路由关联;Long 按字符串序列化") Long id,
+ String addressType, String addressName, String companyName, String contactName,
+ String phone, String email, String countryCode, String province, String city,
+ String district, String addressLine, String postalCode, boolean defaultAddress) { }
+}
diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java b/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java
index b94bd111a..7ed443f14 100644
--- a/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java
+++ b/order/src/main/java/com/baiyue/order/dto/MerchantBatchDraftDtos.java
@@ -9,7 +9,10 @@ public final class MerchantBatchDraftDtos {
public record Item(
@Schema(description = "前端外层行唯一标识") String clientRowId,
- @Schema(description = "该行草稿及其货物明细") OrderRequests.SaveRequest draft) { }
+ @Schema(description = "该行草稿及其货物明细") OrderRequests.SaveRequest draft,
+ @Schema(hidden = true) String parseError) {
+ public Item(String clientRowId, OrderRequests.SaveRequest draft) { this(clientRowId, draft, null); }
+ }
public record Response(int total, int succeeded, int failed, List items) { }
diff --git a/order/src/main/java/com/baiyue/order/dto/MerchantBatchImportDtos.java b/order/src/main/java/com/baiyue/order/dto/MerchantBatchImportDtos.java
new file mode 100644
index 000000000..bfddbb037
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/dto/MerchantBatchImportDtos.java
@@ -0,0 +1,10 @@
+package com.baiyue.order.dto;
+
+public final class MerchantBatchImportDtos {
+ private MerchantBatchImportDtos() { }
+
+ public enum Mode { ATOMIC, PARTIAL }
+
+ public record Response(String batchNo, Mode mode, Long sourceFileId, Long errorReportFileId,
+ MerchantBatchDraftDtos.Response result) { }
+}
diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
index 6786d6578..e46d526c0 100644
--- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
+++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java
@@ -13,6 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
+import com.fasterxml.jackson.annotation.JsonIgnore;
import java.math.BigDecimal;
import java.time.LocalDate;
@@ -37,19 +38,17 @@ public final class OrderRequests {
@Schema(description = "Incoterm / 贸易术语", example = "FOB", maxLength = 16)
@Size(max = 16) String tradeTerm,
@Schema(description = "Destination ISO country code / 目的国 ISO 两位代码", example = "US", minLength = 2, maxLength = 2, requiredMode = Schema.RequiredMode.REQUIRED)
- @NotBlank @Size(min = 2, max = 2) String destinationCountryCode,
+ @Size(min = 2, max = 2) String destinationCountryCode,
@Schema(description = "Destination warehouse ID, logical reference / 目的仓库 ID,逻辑引用", example = "190000000000000101")
Long destinationWarehouseId,
@Schema(description = "Destination warehouse name snapshot / 目的仓库名称快照", example = "Los Angeles Main Warehouse / 洛杉矶主仓", maxLength = 128, requiredMode = Schema.RequiredMode.REQUIRED)
- @NotBlank @Size(max = 128) String destinationWarehouseName,
+ @Size(max = 128) String destinationWarehouseName,
@Schema(description = "Expected shipping date / 预计出运日期", example = "2026-08-15", type = "string", format = "date")
LocalDate expectedShipDate,
@Schema(description = "Customer remark / 客户备注", example = "Notify before pickup / 提货前联系", maxLength = 1000)
@Size(max = 1000) String customerRemark,
- @Schema(description = "Sender information; required when pickupMethod is PICKUP / 发货方信息,取货方式为 PICKUP 时必填")
- @Valid Party sender,
- @Schema(description = "Receiver information; required for PRIVATE destination / 收货方信息,私人地址目的地时必填")
- @Valid Party receiver,
+ @JsonIgnore @Schema(hidden = true) @Valid Party sender,
+ @JsonIgnore @Schema(hidden = true) @Valid Party receiver,
@Schema(description = "Cargo lines / 货物明细", requiredMode = Schema.RequiredMode.REQUIRED)
@NotEmpty @Valid List cargoes,
@NotBlank @Pattern(regexp = "SELF_EXPRESS|PICKUP|SELF_TRUCK") String pickupMethod,
@@ -67,7 +66,8 @@ public final class OrderRequests {
@Schema(description = "Estimate pricing tax mode; independent from taxMode / 参考报价包税维度,独立于 taxMode", allowableValues = {"TAX_INCLUDED", "TAX_EXCLUDED"})
@NotBlank @Pattern(regexp = "TAX_INCLUDED|TAX_EXCLUDED") String pricingTaxMode,
@Pattern(regexp = "FULL_CONTAINER|LCL") String containerMode,
- @NotNull Boolean customsInspection,
+ @Schema(description = "Whether customs inspection is required; optional for non-wood cargo / 是否商检;非木制品可不填")
+ Boolean customsInspection,
@NotBlank @Pattern(regexp = "FBA|OVERSEAS|PRIVATE") String destinationType,
@Size(max = 32) String amazonWarehouseCode,
@Size(max = 64) String shipmentId,
@@ -75,8 +75,30 @@ public final class OrderRequests {
@Schema(description = "旧系统入仓码;仅全局 LEGACY_MANUAL 模式接受,AUTO 模式必须留空", example = "NYEXX2608140001", maxLength = 40)
@Size(max = 40) @Pattern(regexp = "[A-Za-z0-9._:-]+") String inboundCode,
@Schema(description = "Fulfillment type; omitted means STANDARD / 履约类型;省略时为普通头程", allowableValues = {"STANDARD", "ONE_CLICK_DROPSHIP"}, defaultValue = "STANDARD")
- @Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType
+ @Pattern(regexp = "STANDARD|ONE_CLICK_DROPSHIP") String fulfillmentType,
+ @Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填")
+ @Min(1) Long senderAddressId,
+ @Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填")
+ @Min(1) Long privateDestinationAddressId
) {
+ public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
+ String destinationCountryCode, Long destinationWarehouseId,
+ String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
+ Party sender, Party receiver, List cargoes, String pickupMethod,
+ LocalDateTime requestedPickupAt, Long receivingWarehouseId,
+ String receivingWarehouseName, String channelCode, String channelName,
+ Long carrierId, String carrierName, String taxMode, String pricingTaxMode,
+ String containerMode, Boolean customsInspection, String destinationType,
+ String amazonWarehouseCode, String shipmentId, String referenceId,
+ String inboundCode, String fulfillmentType) {
+ this(customerId, customerName, transportMode, tradeTerm, destinationCountryCode,
+ destinationWarehouseId, destinationWarehouseName, expectedShipDate, customerRemark,
+ sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
+ receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
+ pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
+ shipmentId, referenceId, inboundCode, fulfillmentType, null, null);
+ }
+
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
String destinationCountryCode, Long destinationWarehouseId,
String destinationWarehouseName, LocalDate expectedShipDate, String customerRemark,
@@ -92,7 +114,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
- shipmentId, referenceId, inboundCode, null);
+ shipmentId, referenceId, inboundCode, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -109,7 +131,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
- shipmentId, referenceId, null, null);
+ shipmentId, referenceId, null, null, null, null);
}
public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm,
@@ -126,7 +148,7 @@ public final class OrderRequests {
sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId,
receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode,
pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode,
- shipmentId, referenceId, null, null);
+ shipmentId, referenceId, null, null, null, null);
}
@AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空")
@@ -200,8 +222,24 @@ public final class OrderRequests {
@DecimalMin("0.01") BigDecimal productHeightCm,
@Size(max = 64) String shipmentId,
@Size(max = 64) String referenceId,
- @Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId
+ @Schema(description = "Product image file ID / 产品图片文件ID") Long productImageFileId,
+ @Schema(description = "Authoritative wood-product decision frozen with this cargo line / 木制品权威判定,随货物行冻结", requiredMode = Schema.RequiredMode.REQUIRED)
+ @NotNull Boolean woodenProduct
) {
+ public Cargo(String cargoName, String hsCode, String packageType, int packageCount,
+ BigDecimal grossWeightKg, BigDecimal volumeCbm, BigDecimal lengthCm,
+ BigDecimal widthCm, BigDecimal heightCm, BigDecimal declaredValue, String currency,
+ boolean dangerousGoods, String cargoRemark, String skuNo, String upcNo,
+ String cargoNameEn, String customsHsCode, Integer unitsPerPackage, Integer totalQuantity,
+ BigDecimal netWeightKg, BigDecimal unitDeclaredValue, String material, String usage,
+ String model, String brand, BigDecimal productLengthCm, BigDecimal productWidthCm,
+ BigDecimal productHeightCm, String shipmentId, String referenceId, Long productImageFileId) {
+ this(cargoName, hsCode, packageType, packageCount, grossWeightKg, volumeCbm, lengthCm, widthCm,
+ heightCm, declaredValue, currency, dangerousGoods, cargoRemark, skuNo, upcNo, cargoNameEn,
+ customsHsCode, unitsPerPackage, totalQuantity, netWeightKg, unitDeclaredValue, material, usage,
+ model, brand, productLengthCm, productWidthCm, productHeightCm, shipmentId, referenceId,
+ productImageFileId, false);
+ }
}
@Schema(name = "FirstLegOrderQueryRequest", description = "First-leg order page query / 头程订单分页查询条件")
diff --git a/order/src/main/java/com/baiyue/order/entity/CustomerAddressEntity.java b/order/src/main/java/com/baiyue/order/entity/CustomerAddressEntity.java
new file mode 100644
index 000000000..ef2b8649d
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/entity/CustomerAddressEntity.java
@@ -0,0 +1,31 @@
+package com.baiyue.order.entity;
+
+import com.baiyue.common.core.model.BaseEntity;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+/** 客户自有地址簿;平台仓库主档不在本表维护。 */
+@Getter
+@Setter
+@TableName("ord_customer_address")
+public class CustomerAddressEntity extends BaseEntity {
+ @TableId(type = IdType.ASSIGN_ID) private Long id;
+ private Long tenantId;
+ private String addressType;
+ private String addressName;
+ private String companyName;
+ private String contactName;
+ private String phone;
+ private String email;
+ private String countryCode;
+ private String province;
+ private String city;
+ private String district;
+ private String addressLine;
+ private String postalCode;
+ private Boolean defaultAddress;
+ private Boolean active;
+}
diff --git a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
index 08a5236d4..61c64ea5d 100644
--- a/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
+++ b/order/src/main/java/com/baiyue/order/entity/OrderCargoEntity.java
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。
*/
@TableName("ord_order_cargo") public class OrderCargoEntity extends BaseEntity {
- @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId;
+ @TableId(type=IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long orderId; private Integer itemNo; private String cargoName; private String hsCode; private String packageType; private Integer packageCount; private BigDecimal grossWeightKg; private BigDecimal volumeCbm; private BigDecimal lengthCm; private BigDecimal widthCm; private BigDecimal heightCm; private BigDecimal declaredValue; private String currency; private Boolean dangerousGoods; private String cargoRemark; private String skuNo; private String upcNo; private String cargoNameEn; private String customsHsCode; private Integer unitsPerPackage; private Integer totalQuantity; private BigDecimal netWeightKg; private BigDecimal unitDeclaredValue; private String material; private String productUsage; private String model; private String brand; private BigDecimal productLengthCm; private BigDecimal productWidthCm; private BigDecimal productHeightCm; private String shipmentId; private String referenceId; private Long productImageFileId; private Boolean woodenProduct;
}
diff --git a/order/src/main/java/com/baiyue/order/mapper/CustomerAddressMapper.java b/order/src/main/java/com/baiyue/order/mapper/CustomerAddressMapper.java
new file mode 100644
index 000000000..f61a7aaa1
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/mapper/CustomerAddressMapper.java
@@ -0,0 +1,8 @@
+package com.baiyue.order.mapper;
+
+import com.baiyue.order.entity.CustomerAddressEntity;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface CustomerAddressMapper extends BaseMapper { }
diff --git a/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java
new file mode 100644
index 000000000..b6c22abf3
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/service/CustomerAddressService.java
@@ -0,0 +1,118 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.dto.CustomerAddressDtos;
+import com.baiyue.order.entity.CustomerAddressEntity;
+import com.baiyue.order.mapper.CustomerAddressMapper;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import com.baomidou.mybatisplus.core.toolkit.IdWorker;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.List;
+
+@Service
+public class CustomerAddressService {
+ @Resource private CustomerAddressMapper mapper;
+
+ public List list(long tenantId, String addressType) {
+ if (addressType != null && !addressType.matches("PICKUP|PRIVATE_DESTINATION")) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型不正确");
+ }
+ return mapper.selectList(new LambdaQueryWrapper()
+ .eq(CustomerAddressEntity::getTenantId, tenantId)
+ .eq(CustomerAddressEntity::getActive, true)
+ .eq(addressType != null, CustomerAddressEntity::getAddressType, addressType)
+ .orderByDesc(CustomerAddressEntity::getDefaultAddress)
+ .orderByDesc(CustomerAddressEntity::getUpdatedAt))
+ .stream().map(CustomerAddressService::response).toList();
+ }
+
+ @Transactional
+ public Long create(long tenantId, long userId, CustomerAddressDtos.SaveRequest request) {
+ CustomerAddressEntity value = new CustomerAddressEntity();
+ value.setId(IdWorker.getId());
+ value.setTenantId(tenantId);
+ apply(value, request);
+ value.setActive(true);
+ value.setCreatedBy(userId);
+ value.setUpdatedBy(userId);
+ if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), null);
+ mapper.insert(value);
+ return value.getId();
+ }
+
+ @Transactional
+ public void update(long tenantId, long userId, long id, CustomerAddressDtos.SaveRequest request) {
+ CustomerAddressEntity value = required(tenantId, id);
+ if (!value.getAddressType().equals(request.addressType())) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型创建后不可修改");
+ }
+ if (request.defaultAddress()) clearDefault(tenantId, request.addressType(), id);
+ apply(value, request);
+ value.setUpdatedBy(userId);
+ if (mapper.update(value, new LambdaUpdateWrapper()
+ .eq(CustomerAddressEntity::getTenantId, tenantId)
+ .eq(CustomerAddressEntity::getId, id)
+ .eq(CustomerAddressEntity::getActive, true)) != 1) {
+ throw new BizException(ResultCode.CONFLICT, "地址已被并发修改");
+ }
+ }
+
+ @Transactional
+ public void disable(long tenantId, long userId, long id) {
+ CustomerAddressEntity value = required(tenantId, id);
+ value.setActive(false);
+ value.setDefaultAddress(false);
+ value.setUpdatedBy(userId);
+ mapper.update(value, new LambdaUpdateWrapper()
+ .eq(CustomerAddressEntity::getTenantId, tenantId).eq(CustomerAddressEntity::getId, id));
+ }
+
+ public CustomerAddressEntity required(long tenantId, long id, String expectedType) {
+ CustomerAddressEntity value = required(tenantId, id);
+ if (!expectedType.equals(value.getAddressType())) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ "PICKUP".equals(expectedType) ? "请选择取货地址" : "请选择私人目的仓地址");
+ }
+ return value;
+ }
+
+ private CustomerAddressEntity required(long tenantId, long id) {
+ CustomerAddressEntity value = mapper.selectOne(new LambdaQueryWrapper()
+ .eq(CustomerAddressEntity::getTenantId, tenantId)
+ .eq(CustomerAddressEntity::getId, id)
+ .eq(CustomerAddressEntity::getActive, true));
+ if (value == null) throw new BizException(ResultCode.NOT_FOUND, "客户地址不存在或已停用");
+ return value;
+ }
+
+ private void clearDefault(long tenantId, String type, Long excludedId) {
+ mapper.update(null, new LambdaUpdateWrapper()
+ .eq(CustomerAddressEntity::getTenantId, tenantId)
+ .eq(CustomerAddressEntity::getAddressType, type)
+ .eq(CustomerAddressEntity::getActive, true)
+ .ne(excludedId != null, CustomerAddressEntity::getId, excludedId)
+ .set(CustomerAddressEntity::getDefaultAddress, false));
+ }
+
+ private static void apply(CustomerAddressEntity value, CustomerAddressDtos.SaveRequest source) {
+ value.setAddressType(source.addressType()); value.setAddressName(source.addressName().trim());
+ value.setCompanyName(source.companyName().trim()); value.setContactName(source.contactName().trim());
+ value.setPhone(source.phone().trim()); value.setEmail(source.email());
+ value.setCountryCode(source.countryCode().trim().toUpperCase()); value.setProvince(source.province());
+ value.setCity(source.city().trim()); value.setDistrict(source.district());
+ value.setAddressLine(source.addressLine().trim()); value.setPostalCode(source.postalCode());
+ value.setDefaultAddress(source.defaultAddress());
+ }
+
+ private static CustomerAddressDtos.Response response(CustomerAddressEntity value) {
+ return new CustomerAddressDtos.Response(value.getId(), value.getAddressType(), value.getAddressName(),
+ value.getCompanyName(), value.getContactName(), value.getPhone(), value.getEmail(), value.getCountryCode(),
+ value.getProvince(), value.getCity(), value.getDistrict(), value.getAddressLine(), value.getPostalCode(),
+ Boolean.TRUE.equals(value.getDefaultAddress()));
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java
index 717a3e1fa..bc1460aa9 100644
--- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java
+++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java
@@ -26,6 +26,20 @@ public class MerchantBatchDraftItemService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public OrderEntity create(long tenantId, long userId, String username,
OrderRequests.SaveRequest draft, String idempotencyKey) {
+ validate(draft);
+ Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
+ return repository.findOwned(tenantId, id);
+ }
+
+ @Transactional(propagation = Propagation.MANDATORY)
+ public OrderEntity createJoined(long tenantId, long userId, String username,
+ OrderRequests.SaveRequest draft, String idempotencyKey) {
+ validate(draft);
+ Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
+ return repository.findOwned(tenantId, id);
+ }
+
+ public void validate(OrderRequests.SaveRequest draft) {
var violations = validator.validate(draft);
if (!violations.isEmpty()) {
String message = violations.stream()
@@ -35,7 +49,5 @@ public class MerchantBatchDraftItemService {
.orElse("草稿参数不合法");
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
- Long id = orders.createDraft(tenantId, userId, username, draft, idempotencyKey);
- return repository.findOwned(tenantId, id);
}
}
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java
index 1614bd39f..e88073c87 100644
--- a/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java
+++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java
@@ -1,10 +1,12 @@
package com.baiyue.order.service;
import com.baiyue.common.core.exception.BizException;
+import com.baiyue.common.core.api.ResultCode;
import com.baiyue.order.dto.MerchantBatchDraftDtos;
import com.baiyue.order.entity.OrderEntity;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
@@ -37,7 +39,8 @@ public class MerchantBatchDraftService {
MerchantBatchDraftDtos.Item item = items.get(index);
String rowId = item == null ? null : item.clientRowId();
if (item == null || item.draft() == null) {
- results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0422", "草稿不能为空"));
+ results.add(MerchantBatchDraftDtos.Result.failure(index, rowId, "A0422",
+ item != null && StringUtils.hasText(item.parseError()) ? item.parseError() : "草稿不能为空"));
continue;
}
if (!StringUtils.hasText(rowId)) {
@@ -62,6 +65,39 @@ public class MerchantBatchDraftService {
return new MerchantBatchDraftDtos.Response(items.size(), succeeded, items.size() - succeeded, results);
}
+ @Transactional
+ public MerchantBatchDraftDtos.Response createAtomic(long tenantId, long userId, String username,
+ String batchKey, List items) {
+ validateBatch(batchKey, items);
+ Set rowIds = new HashSet<>();
+ for (var item : items) {
+ if (item == null || item.draft() == null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED,
+ item != null && StringUtils.hasText(item.parseError()) ? item.parseError() : "草稿不能为空");
+ }
+ if (!StringUtils.hasText(item.clientRowId()) || !rowIds.add(item.clientRowId())) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "clientRowId 不能为空且批次内不能重复");
+ }
+ itemService.validate(item.draft());
+ }
+ List results = new ArrayList<>();
+ for (int index = 0; index < items.size(); index++) {
+ var item = items.get(index);
+ OrderEntity order = itemService.createJoined(tenantId, userId, username, item.draft(),
+ derivedKey(batchKey, item.clientRowId()));
+ results.add(MerchantBatchDraftDtos.Result.success(index, item.clientRowId(), order.getId(),
+ order.getOrderNo(), order.getInboundCode()));
+ }
+ return new MerchantBatchDraftDtos.Response(items.size(), items.size(), 0, results);
+ }
+
+ private static void validateBatch(String batchKey, List items) {
+ if (!StringUtils.hasText(batchKey)) throw new BizException("必须提供 Idempotency-Key");
+ if (batchKey.trim().length() > 128) throw new BizException("Idempotency-Key 长度不能超过128");
+ if (items == null || items.isEmpty()) throw new BizException("批量草稿不能为空");
+ if (items.size() > MAX_ITEMS) throw new BizException("批量草稿最多 50 条");
+ }
+
static String derivedKey(String batchKey, String rowId) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256")
diff --git a/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java
new file mode 100644
index 000000000..e228e39f7
--- /dev/null
+++ b/order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java
@@ -0,0 +1,196 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.api.ResultCode;
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.client.OrderBatchFileClient;
+import com.baiyue.order.dto.MerchantBatchDraftDtos;
+import com.baiyue.order.dto.MerchantBatchImportDtos;
+import com.baiyue.order.dto.OrderRequests;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.annotation.Resource;
+import org.apache.poi.ss.usermodel.Cell;
+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.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class MerchantBatchImportService {
+ private static final int MAX_ROWS = 50;
+ private static final long MAX_BYTES = 5L * 1024 * 1024;
+ @Resource private MerchantBatchDraftService drafts;
+ @Resource private OrderBatchFileClient files;
+ @Resource private ObjectMapper json;
+ @Value("${baiyue.security.internal-service-token:}") private String token;
+ private static final List TEMPLATE_HEADERS = List.of(
+ "clientRowId", "customerName", "transportMode", "tradeTerm", "destinationCountryCode",
+ "destinationWarehouseId", "destinationWarehouseName", "expectedShipDate", "customerRemark",
+ "pickupMethod", "requestedPickupAt", "senderAddressId", "receivingWarehouseId",
+ "receivingWarehouseName", "channelCode", "channelName", "taxMode", "pricingTaxMode",
+ "containerMode", "customsInspection", "destinationType", "privateDestinationAddressId",
+ "amazonWarehouseCode", "shipmentId", "referenceId", "fulfillmentType", "cargo.cargoName",
+ "cargo.packageType", "cargo.packageCount", "cargo.grossWeightKg", "cargo.volumeCbm",
+ "cargo.material", "cargo.woodenProduct", "cargo.skuNo", "cargo.upcNo", "cargo.shipmentId",
+ "cargo.referenceId");
+
+ public byte[] template() {
+ try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
+ var sheet = workbook.createSheet("订单导入");
+ Row header = sheet.createRow(0);
+ for (int i = 0; i < TEMPLATE_HEADERS.size(); i++) header.createCell(i).setCellValue(TEMPLATE_HEADERS.get(i));
+ Row example = sheet.createRow(1);
+ example.createCell(0).setCellValue("row-001");
+ example.createCell(1).setCellValue("示例客户"); example.createCell(2).setCellValue("SEA");
+ example.createCell(9).setCellValue("SELF_EXPRESS"); example.createCell(12).setCellValue("190000000000000101");
+ example.createCell(13).setCellValue("国内收货仓"); example.createCell(14).setCellValue("CHANNEL-001");
+ example.createCell(15).setCellValue("示例渠道"); example.createCell(16).setCellValue("BUY_DECLARATION");
+ example.createCell(17).setCellValue("TAX_EXCLUDED"); example.createCell(20).setCellValue("OVERSEAS");
+ example.createCell(26).setCellValue("示例货物"); example.createCell(27).setCellValue("CARTON");
+ example.createCell(28).setCellValue("1"); example.createCell(29).setCellValue("1.000");
+ example.createCell(30).setCellValue("0.0100"); example.createCell(32).setCellValue("false");
+ for (int i = 0; i < TEMPLATE_HEADERS.size(); i++) sheet.setColumnWidth(i, 20 * 256);
+ workbook.write(out);
+ return out.toByteArray();
+ } catch (Exception exception) {
+ throw new BizException(ResultCode.INTERNAL_ERROR, "批量下单模板生成失败");
+ }
+ }
+
+ public MerchantBatchImportDtos.Response importFile(long tenantId, long userId, String username,
+ String idempotencyKey, MerchantBatchImportDtos.Mode mode,
+ MultipartFile file) {
+ requireFile(file);
+ String batchNo = batchNo(tenantId, userId, idempotencyKey);
+ byte[] content;
+ try { content = file.getBytes(); }
+ catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "无法读取 Excel 文件"); }
+ List items = parse(content);
+ Long sourceFileId = upload(tenantId, userId, batchNo, "ORDER_BATCH_IMPORT_SOURCE",
+ new BytesMultipartFile("file", safeName(file.getOriginalFilename()),
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", content));
+ MerchantBatchDraftDtos.Response result = mode == MerchantBatchImportDtos.Mode.ATOMIC
+ ? drafts.createAtomic(tenantId, userId, username, idempotencyKey, items)
+ : drafts.create(tenantId, userId, username, idempotencyKey, items);
+ Long errorFileId = result.failed() == 0 ? null : upload(tenantId, userId, batchNo,
+ "ORDER_BATCH_IMPORT_ERROR_REPORT", errorReport(result));
+ return new MerchantBatchImportDtos.Response(batchNo, mode, sourceFileId, errorFileId, result);
+ }
+
+ private List parse(byte[] content) {
+ try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
+ var sheet = workbook.getSheetAt(0);
+ if (sheet.getLastRowNum() < 1) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
+ List items = new ArrayList<>();
+ Row header = sheet.getRow(0);
+ if (header == null || !"clientRowId".equals(text(header.getCell(0)))) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 模板表头不正确");
+ }
+ List headers = new ArrayList<>();
+ for (int col = 0; col < header.getLastCellNum(); col++) headers.add(text(header.getCell(col)));
+ for (int rowNo = 1; rowNo <= sheet.getLastRowNum(); rowNo++) {
+ Row row = sheet.getRow(rowNo);
+ if (row == null || !StringUtils.hasText(text(row.getCell(0)))) continue;
+ if (items.size() >= MAX_ROWS) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 最多 50 条订单");
+ String rowId = text(row.getCell(0));
+ try {
+ var draft = json.createObjectNode();
+ var cargo = json.createObjectNode();
+ for (int col = 1; col < headers.size(); col++) {
+ String field = headers.get(col); String value = text(row.getCell(col));
+ if (!StringUtils.hasText(field) || !StringUtils.hasText(value)) continue;
+ if (field.startsWith("cargo.")) cargo.put(field.substring("cargo.".length()), value);
+ else draft.put(field, value);
+ }
+ draft.set("cargoes", json.createArrayNode().add(cargo));
+ items.add(new MerchantBatchDraftDtos.Item(rowId,
+ json.treeToValue(draft, OrderRequests.SaveRequest.class)));
+ } catch (Exception exception) {
+ items.add(new MerchantBatchDraftDtos.Item(rowId, null, "Excel 行字段格式不正确"));
+ }
+ }
+ if (items.isEmpty()) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 没有订单数据行");
+ return items;
+ } catch (BizException exception) { throw exception; }
+ catch (Exception exception) { throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 文件损坏或格式不正确"); }
+ }
+
+ private MultipartFile errorReport(MerchantBatchDraftDtos.Response result) {
+ try (var workbook = new XSSFWorkbook(); var out = new ByteArrayOutputStream()) {
+ var sheet = workbook.createSheet("错误报告");
+ Row header = sheet.createRow(0);
+ String[] headers = {"rowIndex", "clientRowId", "errorCode", "errorMessage"};
+ for (int i = 0; i < headers.length; i++) header.createCell(i).setCellValue(headers[i]);
+ int rowNo = 1;
+ for (var item : result.items()) if (!item.success()) {
+ Row row = sheet.createRow(rowNo++);
+ row.createCell(0).setCellValue(item.index() + 2);
+ row.createCell(1).setCellValue(item.clientRowId());
+ row.createCell(2).setCellValue(item.errorCode());
+ row.createCell(3).setCellValue(item.errorMessage());
+ }
+ workbook.write(out);
+ return new BytesMultipartFile("file", "order-batch-errors.xlsx",
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", out.toByteArray());
+ } catch (Exception exception) { throw new BizException(ResultCode.INTERNAL_ERROR, "错误报告生成失败"); }
+ }
+
+ private Long upload(long tenantId, long userId, String batchNo, String type, MultipartFile file) {
+ try {
+ var response = files.upload(token, userId, tenantId, type, batchNo, file);
+ if (response == null || !response.successful() || response.data() == null || response.data().fileId() == null) {
+ throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "批量下单文件存储失败");
+ }
+ return response.data().fileId();
+ } catch (BizException exception) { throw exception; }
+ catch (RuntimeException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "批量下单文件存储失败"); }
+ }
+
+ private static void requireFile(MultipartFile file) {
+ if (file == null || file.isEmpty()) throw new BizException(ResultCode.VALIDATION_FAILED, "请选择 Excel 文件");
+ if (file.getSize() > MAX_BYTES) throw new BizException(ResultCode.VALIDATION_FAILED, "Excel 文件不能超过 5MB");
+ String name = safeName(file.getOriginalFilename()).toLowerCase();
+ if (!name.endsWith(".xlsx")) throw new BizException(ResultCode.VALIDATION_FAILED, "仅支持 .xlsx 文件");
+ }
+
+ private static String text(Cell cell) {
+ if (cell == null) return null;
+ cell.setCellType(org.apache.poi.ss.usermodel.CellType.STRING);
+ String value = cell.getStringCellValue();
+ return value == null ? null : value.trim();
+ }
+
+ private static String safeName(String name) { return StringUtils.hasText(name) ? name : "order-batch.xlsx"; }
+ private static String batchNo(long tenantId, long userId, String key) {
+ if (!StringUtils.hasText(key)) throw new BizException(ResultCode.BAD_REQUEST, "必须提供 Idempotency-Key");
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256")
+ .digest((tenantId + ":" + userId + ":" + key.trim()).getBytes(StandardCharsets.UTF_8));
+ StringBuilder out = new StringBuilder("OBI-");
+ for (int i = 0; i < 10; i++) out.append(String.format("%02x", digest[i]));
+ return out.toString().toUpperCase();
+ } catch (Exception exception) { throw new IllegalStateException(exception); }
+ }
+
+ private record BytesMultipartFile(String name, String originalFilename, String contentType, byte[] bytes)
+ implements MultipartFile {
+ @Override public String getName() { return name; }
+ @Override public String getOriginalFilename() { return originalFilename; }
+ @Override public String getContentType() { return contentType; }
+ @Override public boolean isEmpty() { return bytes == null || bytes.length == 0; }
+ @Override public long getSize() { return bytes == null ? 0 : bytes.length; }
+ @Override public byte[] getBytes() { return bytes == null ? new byte[0] : bytes.clone(); }
+ @Override public java.io.InputStream getInputStream() { return new ByteArrayInputStream(getBytes()); }
+ @Override public void transferTo(java.io.File dest) throws java.io.IOException {
+ java.nio.file.Files.write(dest.toPath(), getBytes());
+ }
+ }
+}
diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
index 7687b485f..48adfb3e5 100644
--- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
+++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java
@@ -47,6 +47,7 @@ public class OrderApplicationService {
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
+ @Resource private ObjectProvider customerAddressServiceProvider;
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private ObjectProvider firstlegProvider;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@@ -78,6 +79,8 @@ public class OrderApplicationService {
private Long createDraftInternal(long tenantId, long userId, String username,
OrderRequests.SaveRequest request) {
+ AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
+ validateWoodInspection(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
var snapshots = masterDataGuard.validate(tenantId, request);
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
@@ -86,14 +89,14 @@ 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);
+ apply(order, tenantId, request, snapshots, addressSnapshots);
order.setCreatedBy(userId); order.setUpdatedBy(userId);
try {
repository.insertOrder(order);
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
- saveChildren(tenantId, userId, order.getId(), request);
+ saveChildren(tenantId, userId, order.getId(), request, addressSnapshots);
record(tenantId, order.getId(), null, OrderStatus.DRAFT, "CREATE", null, userId, username);
return order.getId();
}
@@ -115,6 +118,8 @@ public class OrderApplicationService {
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
+ AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
+ validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (allowSubmitted) validateCompleteRequest(request);
productImageGuard.requireOwned(tenantId, request.cargoes());
@@ -126,7 +131,7 @@ public class OrderApplicationService {
}
requirePricingChannel(tenantId, request.receivingWarehouseId(), request.channelCode(),
request.destinationType());
- apply(order, tenantId, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
+ apply(order, tenantId, request, snapshots, addressSnapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
if (before == OrderStatus.SUBMITTED) {
order.setStatus(OrderStatus.SUBMITTED);
}
@@ -136,7 +141,7 @@ public class OrderApplicationService {
throw duplicateInboundCode(order, duplicate);
}
repository.replaceDraftChildren(tenantId, id);
- saveChildren(tenantId, userId, id, request);
+ saveChildren(tenantId, userId, id, request, addressSnapshots);
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
if (before == OrderStatus.SUBMITTED) prepareDraftInbound(order, request);
}
@@ -273,7 +278,7 @@ public class OrderApplicationService {
if (!StringUtils.hasText(order.getPickupMethod()) || order.getReceivingWarehouseId() == null
|| !StringUtils.hasText(order.getReceivingWarehouseName())
|| !StringUtils.hasText(order.getChannelCode()) || !StringUtils.hasText(order.getChannelName())
- || !StringUtils.hasText(order.getTaxMode()) || order.getCustomsInspection() == null
+ || !StringUtils.hasText(order.getTaxMode())
|| !StringUtils.hasText(order.getDestinationType())) {
throw new BizException("Order phase-one required fields are incomplete");
}
@@ -287,6 +292,9 @@ public class OrderApplicationService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"提交订单前必须完善货物品名、包装类型、箱数、毛重和体积");
}
+ if (Boolean.TRUE.equals(cargo.getWoodenProduct()) && !Boolean.TRUE.equals(order.getCustomsInspection())) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
+ }
}
var parties = repository.findParties(tenantId, order.getId());
if ("PICKUP".equals(order.getPickupMethod()) && !hasParty(parties, "SENDER")) {
@@ -313,15 +321,16 @@ public class OrderApplicationService {
}
private void validateCompleteRequest(OrderRequests.SaveRequest request) {
- if ("PICKUP".equals(request.pickupMethod()) && request.sender() == null) {
- throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写发货信息");
+ if ("PICKUP".equals(request.pickupMethod()) && request.senderAddressId() == null && request.sender() == null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须选择取货地址");
}
if ("PICKUP".equals(request.pickupMethod()) && request.requestedPickupAt() == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须填写预约提货时间");
}
- if ("PRIVATE".equals(request.destinationType()) && request.receiver() == null) {
- throw new BizException(ResultCode.VALIDATION_FAILED, "私人地址必须填写收货信息");
+ if ("PRIVATE".equals(request.destinationType()) && request.privateDestinationAddressId() == null && request.receiver() == null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
}
+ validateWoodInspection(request);
for (var cargo : request.cargoes()) {
if (!StringUtils.hasText(cargo.cargoName()) || !StringUtils.hasText(cargo.packageType())
|| cargo.packageCount() < 1 || cargo.grossWeightKg() == null
@@ -442,15 +451,20 @@ public class OrderApplicationService {
default -> false;
};
}
- private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots) {
+ private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request,
+ OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses) {
order.setCustomerId(tenantId);
applyInboundCode(order, request.inboundCode());
order.setCustomerName(request.customerName().trim());
order.setTransportMode(request.transportMode()); order.setTradeTerm(request.tradeTerm());
- order.setDestinationCountryCode(snapshots.destination() == null ? request.destinationCountryCode()
+ order.setDestinationCountryCode(addresses.privateDestination() != null
+ ? addresses.privateDestination().getCountryCode()
+ : snapshots.destination() == null ? request.destinationCountryCode()
: snapshots.destination().warehouse().countryCode());
order.setDestinationWarehouseId(request.destinationWarehouseId());
- order.setDestinationWarehouseName(snapshots.destination() == null ? request.destinationWarehouseName()
+ order.setDestinationWarehouseName(addresses.privateDestination() != null
+ ? addresses.privateDestination().getAddressName()
+ : snapshots.destination() == null ? request.destinationWarehouseName()
: snapshots.destination().warehouse().warehouseName());
order.setExpectedShipDate(request.expectedShipDate()); order.setCustomerRemark(request.customerRemark());
order.setPickupMethod(request.pickupMethod());
@@ -545,7 +559,8 @@ public class OrderApplicationService {
.map(c -> c.cutoffTime() == null ? "支持收货" : "每日 " + c.cutoffTime() + " 前收货")
.findFirst().orElse(null);
}
- private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request) {
+ private void saveChildren(long tenantId, long userId, long orderId, OrderRequests.SaveRequest request,
+ AddressSnapshots addresses) {
int itemNo = 1;
for (var cargo : request.cargoes()) {
OrderCargoEntity item = new OrderCargoEntity();
@@ -566,19 +581,67 @@ public class OrderApplicationService {
item.setProductHeightCm(cargo.productHeightCm()); item.setShipmentId(cargo.shipmentId());
item.setReferenceId(cargo.referenceId());
item.setProductImageFileId(cargo.productImageFileId());
+ item.setWoodenProduct(cargo.woodenProduct());
item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertCargo(item);
}
- if (request.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", request.sender());
- if (request.receiver() != null) saveParty(tenantId, userId, orderId, "RECEIVER", request.receiver());
+ if (addresses.sender() != null) saveParty(tenantId, userId, orderId, "SENDER", addresses.sender());
+ if (addresses.privateDestination() != null) saveParty(tenantId, userId, orderId, "RECEIVER", addresses.privateDestination());
}
- private void saveParty(long tenantId, long userId, long orderId, String type, OrderRequests.Party party) {
+ private void saveParty(long tenantId, long userId, long orderId, String type, CustomerAddressEntity party) {
OrderPartyEntity item = new OrderPartyEntity();
item.setTenantId(tenantId); item.setOrderId(orderId); item.setPartyType(type);
- item.setCompanyName(party.companyName()); item.setContactName(party.contactName()); item.setPhone(party.phone());
- item.setEmail(party.email()); item.setCountryCode(party.countryCode()); item.setProvince(party.province());
- item.setCity(party.city()); item.setDistrict(party.district()); item.setAddressLine(party.addressLine());
- item.setPostalCode(party.postalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
+ item.setCompanyName(party.getCompanyName()); item.setContactName(party.getContactName()); item.setPhone(party.getPhone());
+ item.setEmail(party.getEmail()); item.setCountryCode(party.getCountryCode()); item.setProvince(party.getProvince());
+ item.setCity(party.getCity()); item.setDistrict(party.getDistrict()); item.setAddressLine(party.getAddressLine());
+ item.setPostalCode(party.getPostalCode()); item.setCreatedBy(userId); item.setUpdatedBy(userId); repository.insertParty(item);
}
+
+ private AddressSnapshots resolveCustomerAddresses(long tenantId, OrderRequests.SaveRequest request) {
+ CustomerAddressEntity sender = null;
+ CustomerAddressEntity privateDestination = null;
+ if ("PICKUP".equals(request.pickupMethod())) {
+ if (request.senderAddressId() == null) {
+ if (request.sender() != null) sender = transientAddress(request.sender(), "PICKUP", "临时取货地址");
+ else throw new BizException(ResultCode.VALIDATION_FAILED, "上门提货时必须选择取货地址");
+ } else {
+ sender = customerAddressServiceProvider.getObject().required(tenantId, request.senderAddressId(), "PICKUP");
+ }
+ } else if (request.senderAddressId() != null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "非上门提货订单不得选择取货地址");
+ }
+ if ("PRIVATE".equals(request.destinationType())) {
+ if (request.privateDestinationAddressId() == null) {
+ if (request.receiver() != null) privateDestination = transientAddress(
+ request.receiver(), "PRIVATE_DESTINATION", request.destinationWarehouseName());
+ else throw new BizException(ResultCode.VALIDATION_FAILED, "私人目的仓必须选择客户地址");
+ } else {
+ privateDestination = customerAddressServiceProvider.getObject().required(
+ tenantId, request.privateDestinationAddressId(), "PRIVATE_DESTINATION");
+ }
+ } else if (request.privateDestinationAddressId() != null) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "公共目的仓订单不得选择私人地址");
+ }
+ return new AddressSnapshots(sender, privateDestination);
+ }
+
+ static void validateWoodInspection(OrderRequests.SaveRequest request) {
+ boolean containsWood = request.cargoes() != null && request.cargoes().stream()
+ .anyMatch(cargo -> cargo != null && Boolean.TRUE.equals(cargo.woodenProduct()));
+ if (containsWood && !Boolean.TRUE.equals(request.customsInspection())) {
+ throw new BizException(ResultCode.VALIDATION_FAILED, "木制品必须选择商检");
+ }
+ }
+
+ private static CustomerAddressEntity transientAddress(OrderRequests.Party source, String type, String name) {
+ CustomerAddressEntity value = new CustomerAddressEntity();
+ value.setAddressType(type); value.setAddressName(name); value.setCompanyName(source.companyName());
+ value.setContactName(source.contactName()); value.setPhone(source.phone()); value.setEmail(source.email());
+ value.setCountryCode(source.countryCode()); value.setProvince(source.province()); value.setCity(source.city());
+ value.setDistrict(source.district()); value.setAddressLine(source.addressLine()); value.setPostalCode(source.postalCode());
+ return value;
+ }
+
+ private record AddressSnapshots(CustomerAddressEntity sender, CustomerAddressEntity privateDestination) { }
private void record(long tenantId, long orderId, OrderStatus from, OrderStatus to, String action,
String reason, long userId, String username) {
OrderStatusHistoryEntity history = new OrderStatusHistoryEntity();
diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql
index 27d732b9a..281a1eaed 100644
--- a/order/src/main/resources/db/order.sql
+++ b/order/src/main/resources/db/order.sql
@@ -236,6 +236,33 @@ CREATE TABLE IF NOT EXISTS ord_order_party (
CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单';
+CREATE TABLE IF NOT EXISTS ord_customer_address (
+ id BIGINT UNSIGNED NOT NULL COMMENT '客户地址ID',
+ tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
+ address_type VARCHAR(32) NOT NULL COMMENT 'PICKUP取货地址或PRIVATE_DESTINATION私人目的仓',
+ address_name VARCHAR(64) NOT NULL COMMENT '客户自定义地址名称',
+ company_name VARCHAR(128) NOT NULL COMMENT '公司名称',
+ contact_name VARCHAR(64) NOT NULL COMMENT '联系人',
+ phone VARCHAR(32) NOT NULL COMMENT '联系电话',
+ email VARCHAR(128) NULL COMMENT '联系邮箱',
+ country_code CHAR(2) NOT NULL COMMENT '国家二字码',
+ province VARCHAR(64) NULL COMMENT '省州',
+ city VARCHAR(64) NOT NULL COMMENT '城市',
+ district VARCHAR(64) NULL COMMENT '区县',
+ address_line VARCHAR(255) NOT NULL COMMENT '详细地址',
+ postal_code VARCHAR(20) NULL COMMENT '邮政编码',
+ default_address TINYINT(1) NOT NULL DEFAULT 0 COMMENT '同类型默认地址',
+ active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可选',
+ created_by BIGINT UNSIGNED NOT NULL,
+ created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+ updated_by BIGINT UNSIGNED NOT NULL,
+ updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ deleted TINYINT(1) NOT NULL DEFAULT 0,
+ version INT NOT NULL DEFAULT 0,
+ PRIMARY KEY (id),
+ KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at)
+) ENGINE=InnoDB COMMENT='客户自有下单地址簿;不承载平台仓库主档';
+
CREATE TABLE IF NOT EXISTS ord_order_cargo (
id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -272,6 +299,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
shipment_id VARCHAR(64) NULL COMMENT '货物级 Amazon Shipment ID',
reference_id VARCHAR(64) NULL COMMENT '货物级 Amazon Reference ID',
product_image_file_id BIGINT UNSIGNED NULL COMMENT '文件中心产品图片逻辑ID',
+ wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
@@ -284,6 +312,17 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo (
CONSTRAINT fk_cargo_order FOREIGN KEY (order_id) REFERENCES ord_order (id)
) ENGINE=InnoDB COMMENT='订单货物明细表:记录件数、重量、体积、申报价值和危险品属性';
+SET @order_wooden_product_sql = IF(
+ EXISTS(SELECT 1 FROM information_schema.columns
+ WHERE table_schema = 'baiyue_order' AND table_name = 'ord_order_cargo'
+ AND column_name = 'wooden_product'),
+ 'SELECT 1',
+ 'ALTER TABLE baiyue_order.ord_order_cargo ADD COLUMN wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''木制品权威判定快照;1时订单必须商检'' AFTER product_image_file_id'
+);
+PREPARE order_wooden_product_stmt FROM @order_wooden_product_sql;
+EXECUTE order_wooden_product_stmt;
+DEALLOCATE PREPARE order_wooden_product_stmt;
+
CREATE TABLE IF NOT EXISTS ord_order_internal_note (
id BIGINT UNSIGNED NOT NULL COMMENT '内部备注ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml
index bd025c9bc..09a39a094 100644
--- a/order/src/main/resources/openapi/order-v1.yaml
+++ b/order/src/main/resources/openapi/order-v1.yaml
@@ -6,6 +6,76 @@ info:
security:
- bearerAuth: []
paths:
+ /api/merchant/v1/order-addresses:
+ get:
+ summary: 查询当前客户下单地址簿
+ parameters:
+ - name: addressType
+ in: query
+ required: false
+ schema: {type: string, enum: [PICKUP, PRIVATE_DESTINATION]}
+ responses:
+ '200': {description: 只返回登录客户可用地址}
+ '403': {description: 需要订单查看、创建或编辑权限}
+ post:
+ summary: 保存当前客户下单地址
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/CustomerOrderAddressSaveRequest'}
+ responses:
+ '200': {description: 返回地址内部 ID,Long 按字符串序列化}
+ '403': {description: 需要 merchant:order:create 权限}
+ /api/merchant/v1/order-addresses/{id}:
+ put:
+ summary: 修改当前客户下单地址
+ description: 已创建订单保存独立快照,本操作不会修改历史订单。
+ parameters:
+ - {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: {$ref: '#/components/schemas/CustomerOrderAddressSaveRequest'}
+ responses: {'200': {description: 成功}, '403': {description: 需要 merchant:order:edit 权限}}
+ delete:
+ summary: 停用当前客户下单地址
+ description: 停用后不能用于新订单,历史订单快照不受影响。
+ parameters:
+ - {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
+ responses: {'200': {description: 成功}, '403': {description: 需要 merchant:order:edit 权限}}
+ /api/merchant/v1/orders/first-leg/batch-imports/template:
+ get:
+ summary: 下载 Excel 批量下单模板
+ responses:
+ '200': {description: xlsx 模板}
+ '403': {description: 需要 merchant:order:create 权限}
+ /api/merchant/v1/orders/first-leg/batch-imports:
+ post:
+ summary: 上传 Excel 批量创建订单草稿
+ description: >-
+ ATOMIC 在任一行失败时整批回滚;PARTIAL 每行独立事务,错误行不影响成功行。
+ 同一操作者重试必须复用 Idempotency-Key,每行再以 clientRowId 派生稳定幂等键。
+ 原文件和错误报告分别通过 File 的 ORDER_BATCH_IMPORT_SOURCE、ORDER_BATCH_IMPORT_ERROR_REPORT 契约保存。
+ parameters:
+ - $ref: '#/components/parameters/IdempotencyKey'
+ - name: mode
+ in: query
+ required: true
+ schema: {type: string, enum: [ATOMIC, PARTIAL]}
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [file]
+ properties:
+ file: {type: string, format: binary, description: 最大 5MB,仅 xlsx;最多 50 行}
+ responses:
+ '200': {description: 返回逐行结果、原文件 ID 及可选错误报告文件 ID}
+ '403': {description: 需要 merchant:order:create 权限}
/api/platform/v1/orders/first-leg/inbound-code-policy:
get:
summary: 平台端查询全系统入仓码模式
@@ -686,7 +756,7 @@ components:
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
FirstLegOrderDraft:
type: object
- required: [pricingTaxMode, cargoes]
+ required: [customerName, transportMode, cargoes, pickupMethod, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, taxMode, pricingTaxMode, destinationType]
properties:
fulfillmentType:
type: string
@@ -699,6 +769,9 @@ components:
pattern: '^[A-Za-z0-9._:-]+$'
description: 仅 LEGACY_MANUAL 模式填写的旧系统入仓码;AUTO 模式必须省略
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
+ senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址}
+ privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址}
+ customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true}
cargoes:
type: array
minItems: 1
@@ -707,7 +780,25 @@ components:
订单保存请求中的完整字段以服务端 FirstLegOrderSaveRequest 为准;fulfillmentType
省略时固定为 STANDARD,一键代发必须显式传 ONE_CLICK_DROPSHIP,且不复用计费 businessType;pricingTaxMode
是新建和编辑订单的必填报价维度;customerId 不接受客户端指定,服务端始终使用认证企业 ID;
- 发货单位和收货单位仅通过 sender/receiver 保存业务快照。历史存量订单的响应字段仍允许为空。
+ 发货与私人目的仓只接受客户地址 ID,服务端在订单参与方表冻结快照;地址簿后续修改不回写历史订单。
+ 私人目的仓不得写入或伪装为 Warehouse 平台仓主档。历史存量订单的响应字段仍允许为空。
+ CustomerOrderAddressSaveRequest:
+ type: object
+ required: [addressType, addressName, companyName, contactName, phone, countryCode, city, addressLine, defaultAddress]
+ properties:
+ addressType: {type: string, enum: [PICKUP, PRIVATE_DESTINATION]}
+ addressName: {type: string, maxLength: 64}
+ companyName: {type: string, maxLength: 128}
+ contactName: {type: string, maxLength: 64}
+ phone: {type: string, maxLength: 32}
+ email: {type: string, format: email, maxLength: 128, nullable: true}
+ countryCode: {type: string, minLength: 2, maxLength: 2}
+ province: {type: string, maxLength: 64, nullable: true}
+ city: {type: string, maxLength: 64}
+ district: {type: string, maxLength: 64, nullable: true}
+ addressLine: {type: string, maxLength: 255}
+ postalCode: {type: string, maxLength: 20, nullable: true}
+ defaultAddress: {type: boolean}
InboundCodePolicy:
type: object
required: [mode, manualEntryRequired]
@@ -769,7 +860,7 @@ components:
errorMessage: {type: string}
FirstLegOrderCargo:
type: object
- required: [skuNo, packageCount]
+ required: [packageCount, woodenProduct]
properties:
skuNo: {type: string, maxLength: 64}
cargoName: {type: string, maxLength: 128}
@@ -801,6 +892,7 @@ components:
cargoRemark: {type: string, maxLength: 500}
packageType: {type: string, maxLength: 32}
dangerousGoods: {type: boolean}
+ woodenProduct: {type: boolean, description: 木制品权威判定快照;为 true 时订单 customsInspection 必须为 true}
description: >-
原型货物明细字段。FBA 货物的 skuNo、upcNo、shipmentId、referenceId 由服务端按每条货物校验;
productImageFileId 必须属于当前登录客户。
diff --git a/order/src/test/java/com/baiyue/order/controller/OrderCustomerFoundationOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/OrderCustomerFoundationOpenApiContractTest.java
new file mode 100644
index 000000000..76236fc27
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/controller/OrderCustomerFoundationOpenApiContractTest.java
@@ -0,0 +1,26 @@
+package com.baiyue.order.controller;
+
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class OrderCustomerFoundationOpenApiContractTest {
+ @Test
+ void publishesAddressBatchAndWoodInspectionContracts() throws Exception {
+ try (var input = getClass().getResourceAsStream("/openapi/order-v1.yaml")) {
+ assertThat(input).isNotNull();
+ String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+ assertThat(yaml)
+ .contains("/api/merchant/v1/order-addresses:")
+ .contains("/api/merchant/v1/orders/first-leg/batch-imports:")
+ .contains("senderAddressId:")
+ .contains("privateDestinationAddressId:")
+ .contains("woodenProduct:")
+ .contains("ORDER_BATCH_IMPORT_SOURCE")
+ .contains("ATOMIC 在任一行失败时整批回滚")
+ .doesNotContain("发货单位和收货单位仅通过 sender/receiver");
+ }
+ }
+}
diff --git a/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java
new file mode 100644
index 000000000..24ece6108
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java
@@ -0,0 +1,41 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.dto.CustomerAddressDtos;
+import com.baiyue.order.entity.CustomerAddressEntity;
+import com.baiyue.order.mapper.CustomerAddressMapper;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CustomerAddressServiceTest {
+ @Test
+ void createsCustomerOwnedPrivateAddressWithoutCallingWarehouse() {
+ CustomerAddressMapper mapper = mock(CustomerAddressMapper.class);
+ CustomerAddressService service = new CustomerAddressService();
+ ReflectionTestUtils.setField(service, "mapper", mapper);
+ var request = new CustomerAddressDtos.SaveRequest("PRIVATE_DESTINATION", "洛杉矶私人仓", "客户海外仓",
+ "Alex", "+1-213-555-0100", null, "US", "CA", "Los Angeles", null,
+ "123 Private Ave", "90001", false);
+
+ service.create(10001L, 20001L, request);
+
+ verify(mapper).insert(any(CustomerAddressEntity.class));
+ }
+
+ @Test
+ void selectingAnotherCustomersAddressIsReportedAsNotFound() {
+ CustomerAddressMapper mapper = mock(CustomerAddressMapper.class);
+ when(mapper.selectOne(any())).thenReturn(null);
+ CustomerAddressService service = new CustomerAddressService();
+ ReflectionTestUtils.setField(service, "mapper", mapper);
+
+ assertThatThrownBy(() -> service.required(10001L, 90001L, "PICKUP"))
+ .isInstanceOf(BizException.class).hasMessage("客户地址不存在或已停用");
+ }
+}
diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java
index 6c81ff719..d2ef227f1 100644
--- a/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java
+++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchDraftServiceTest.java
@@ -21,6 +21,7 @@ import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
@SpringJUnitConfig(MerchantBatchDraftService.class)
class MerchantBatchDraftServiceTest {
@@ -85,6 +86,21 @@ class MerchantBatchDraftServiceTest {
assertThat(anotherRow).isNotEqualTo(first);
}
+ @Test
+ void atomicModeValidatesEveryRowBeforeCreatingJoinedTransactions() {
+ when(itemService.createJoined(anyLong(), anyLong(), isNull(), any(), anyString()))
+ .thenReturn(order(101L, "FL101", null), order(102L, "FL102", null));
+
+ var response = service.createAtomic(10L, 20L, null, "atomic-key",
+ List.of(item("row-1"), item("row-2")));
+
+ assertThat(response.succeeded()).isEqualTo(2);
+ verify(itemService, org.mockito.Mockito.times(2)).validate(any());
+ verify(itemService, org.mockito.Mockito.times(2))
+ .createJoined(anyLong(), anyLong(), isNull(), any(), anyString());
+ verify(itemService, never()).create(anyLong(), anyLong(), isNull(), any(), anyString());
+ }
+
@Test
void rejectsInvalidBatchBeforeCreatingRows() {
assertThatThrownBy(() -> service.create(10L, 20L, " ", List.of(item("row-1"))))
diff --git a/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java
new file mode 100644
index 000000000..f675cd68f
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java
@@ -0,0 +1,22 @@
+package com.baiyue.order.service;
+
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MerchantBatchImportServiceTest {
+ @Test
+ void templateUsesStableRowIdentityAndAuthoritativeDraftSchema() throws Exception {
+ byte[] content = new MerchantBatchImportService().template();
+ try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
+ var sheet = workbook.getSheetAt(0);
+ assertThat(sheet.getRow(0).getCell(0).getStringCellValue()).isEqualTo("clientRowId");
+ assertThat(sheet.getRow(0).getCell(11).getStringCellValue()).isEqualTo("senderAddressId");
+ assertThat(sheet.getRow(0).getCell(21).getStringCellValue()).isEqualTo("privateDestinationAddressId");
+ assertThat(sheet.getRow(0).getCell(32).getStringCellValue()).isEqualTo("cargo.woodenProduct");
+ }
+ }
+}
diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
index be1b0dce3..7675f999a 100644
--- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
+++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java
@@ -476,29 +476,31 @@ class OrderApplicationServiceStateTest {
}
@Test
- void pickupDraftAllowsMissingSender() {
+ void pickupDraftRequiresSelectedAddress() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "PICKUP", null, null, "OVERSEAS");
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
- service(repository, mock(OrderEligibilityGuard.class), masterData)
- .createDraft(20002L, 2L, request, "pickup-without-sender");
- verify(repository).insertOrder(any());
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
+ .createDraft(20002L, 2L, request, "pickup-without-sender"))
+ .isInstanceOf(BizException.class).hasMessage("上门提货时必须选择取货地址");
+ verify(repository, never()).insertOrder(any());
}
@Test
- void privateDraftAllowsMissingReceiver() {
+ void privateDraftRequiresSelectedAddress() {
OrderRepository repository = mock(OrderRepository.class);
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(null, "SELF_EXPRESS", null, null, "PRIVATE");
when(masterData.validate(20002L, request)).thenReturn(snapshots(null));
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
- service(repository, mock(OrderEligibilityGuard.class), masterData)
- .createDraft(20002L, 2L, request, "private-without-receiver");
- verify(repository).insertOrder(any());
+ assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
+ .createDraft(20002L, 2L, request, "private-without-receiver"))
+ .isInstanceOf(BizException.class).hasMessage("私人目的仓必须选择客户地址");
+ verify(repository, never()).insertOrder(any());
}
@Test
diff --git a/order/src/test/java/com/baiyue/order/service/OrderWoodInspectionRuleTest.java b/order/src/test/java/com/baiyue/order/service/OrderWoodInspectionRuleTest.java
new file mode 100644
index 000000000..365721e7c
--- /dev/null
+++ b/order/src/test/java/com/baiyue/order/service/OrderWoodInspectionRuleTest.java
@@ -0,0 +1,37 @@
+package com.baiyue.order.service;
+
+import com.baiyue.common.core.exception.BizException;
+import com.baiyue.order.dto.OrderRequests;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class OrderWoodInspectionRuleTest {
+ @Test
+ void woodProductRequiresInspectionWithStableMessage() {
+ OrderRequests.Cargo cargo = mock(OrderRequests.Cargo.class);
+ OrderRequests.SaveRequest request = mock(OrderRequests.SaveRequest.class);
+ when(cargo.woodenProduct()).thenReturn(true);
+ when(request.cargoes()).thenReturn(List.of(cargo));
+ when(request.customsInspection()).thenReturn(null);
+
+ assertThatThrownBy(() -> OrderApplicationService.validateWoodInspection(request))
+ .isInstanceOf(BizException.class).hasMessage("木制品必须选择商检");
+ }
+
+ @Test
+ void nonWoodProductAllowsInspectionToBeOmitted() {
+ OrderRequests.Cargo cargo = mock(OrderRequests.Cargo.class);
+ OrderRequests.SaveRequest request = mock(OrderRequests.SaveRequest.class);
+ when(cargo.woodenProduct()).thenReturn(false);
+ when(request.cargoes()).thenReturn(List.of(cargo));
+ when(request.customsInspection()).thenReturn(null);
+
+ assertThatCode(() -> OrderApplicationService.validateWoodInspection(request)).doesNotThrowAnyException();
+ }
+}