From 222c3790dc9e4e88bde93fce5e38e0623be35ab2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 19:15:32 +0800 Subject: [PATCH 1/2] feat(auth): support username or phone login --- .../controller/InternalAccountController.java | 14 +++ .../auth/dto/AccountManagementDtos.java | 19 +++- .../com/baiyue/auth/dto/LoginRequest.java | 6 +- .../auth/dto/MerchantRegisterRequest.java | 4 +- .../com/baiyue/auth/entity/AuthAccount.java | 1 + .../repository/AuthAccountRepository.java | 23 ++++ .../service/AccountManagementService.java | 46 +++++++- .../com/baiyue/auth/service/AuthService.java | 9 +- .../service/MerchantRegistrationService.java | 8 +- .../resources/db/auth-login-identifiers.sql | 43 ++++++++ auth/src/main/resources/db/auth.sql | 2 + .../service/AccountManagementServiceTest.java | 28 +++++ .../auth/service/AuthServiceSmallJwtTest.java | 28 +++++ .../baiyue/iam/client/AuthAccountClient.java | 25 ++++- .../controller/MerchantTenantController.java | 15 ++- .../java/com/baiyue/iam/dto/MemberDtos.java | 6 +- .../com/baiyue/iam/dto/OnboardingDtos.java | 4 +- .../iam/dto/PlatformInternalUserDtos.java | 6 +- .../iam/dto/PlatformUserManagementDtos.java | 2 +- .../java/com/baiyue/iam/dto/ProfileDtos.java | 7 +- .../com/baiyue/iam/service/MemberService.java | 7 +- .../iam/service/PersonalProfileService.java | 39 ++++++- .../service/PlatformInternalUserService.java | 9 +- .../PlatformUserManagementService.java | 7 +- .../iam/service/ProfileResourceService.java | 26 +++-- ...enantAdministratorProvisioningService.java | 2 +- .../com/baiyue/iam/service/TenantService.java | 5 +- iam/src/main/resources/openapi/iam-v1.yaml | 103 ++++++++++++++++-- .../baiyue/iam/service/MemberServiceTest.java | 3 + .../MerchantQualificationUploadTest.java | 19 ++++ .../service/PersonalProfileServiceTest.java | 26 ++++- .../baiyue/iam/service/TenantServiceTest.java | 12 +- 32 files changed, 486 insertions(+), 68 deletions(-) create mode 100644 auth/src/main/resources/db/auth-login-identifiers.sql 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 fa52a8d25..cba1b2b57 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); From caffcb90a90ef9f22833b8dc9e69ecb1cdb9e2b6 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 19:17:43 +0800 Subject: [PATCH 2/2] feat(order): add customer order foundations --- order/pom.xml | 2 +- .../order/client/OrderBatchFileClient.java | 23 ++ .../MerchantCustomerAddressController.java | 62 ++++++ .../MerchantFirstLegOrderController.java | 36 ++++ .../baiyue/order/dto/CustomerAddressDtos.java | 32 +++ .../order/dto/MerchantBatchDraftDtos.java | 5 +- .../order/dto/MerchantBatchImportDtos.java | 10 + .../com/baiyue/order/dto/OrderRequests.java | 62 ++++-- .../order/entity/CustomerAddressEntity.java | 31 +++ .../baiyue/order/entity/OrderCargoEntity.java | 2 +- .../order/mapper/CustomerAddressMapper.java | 8 + .../order/service/CustomerAddressService.java | 118 +++++++++++ .../MerchantBatchDraftItemService.java | 16 +- .../service/MerchantBatchDraftService.java | 38 +++- .../service/MerchantBatchImportService.java | 196 ++++++++++++++++++ .../service/OrderApplicationService.java | 103 +++++++-- order/src/main/resources/db/order.sql | 39 ++++ .../src/main/resources/openapi/order-v1.yaml | 98 ++++++++- ...CustomerFoundationOpenApiContractTest.java | 26 +++ .../service/CustomerAddressServiceTest.java | 41 ++++ .../MerchantBatchDraftServiceTest.java | 16 ++ .../MerchantBatchImportServiceTest.java | 22 ++ .../OrderApplicationServiceStateTest.java | 18 +- .../service/OrderWoodInspectionRuleTest.java | 37 ++++ 24 files changed, 992 insertions(+), 49 deletions(-) create mode 100644 order/src/main/java/com/baiyue/order/client/OrderBatchFileClient.java create mode 100644 order/src/main/java/com/baiyue/order/controller/MerchantCustomerAddressController.java create mode 100644 order/src/main/java/com/baiyue/order/dto/CustomerAddressDtos.java create mode 100644 order/src/main/java/com/baiyue/order/dto/MerchantBatchImportDtos.java create mode 100644 order/src/main/java/com/baiyue/order/entity/CustomerAddressEntity.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/CustomerAddressMapper.java create mode 100644 order/src/main/java/com/baiyue/order/service/CustomerAddressService.java create mode 100644 order/src/main/java/com/baiyue/order/service/MerchantBatchImportService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/OrderCustomerFoundationOpenApiContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/CustomerAddressServiceTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantBatchImportServiceTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderWoodInspectionRuleTest.java 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(); + } +}