Test #1

Merged
admin merged 1726 commits from test into main 2026-09-17 11:52:45 +08:00
56 changed files with 1478 additions and 117 deletions
Showing only changes of commit 28526bf43e - Show all commits
@@ -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<AccountSummary> 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<Void> 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<Void> reset(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
@@ -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);
}
}
}
@@ -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
) {
}
}
@@ -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
@@ -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;
@@ -22,11 +22,34 @@ public class AuthAccountRepository {
.last("LIMIT 1"));
}
public AuthAccount findByPhone(String phone) {
return mapper.selectOne(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.last("LIMIT 1"));
}
public boolean existsByUsername(String username) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)) > 0;
}
public boolean existsByPhone(String phone) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)) > 0;
}
public boolean existsByUsernameForOtherAccount(String username, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getUsername, username)
.ne(AuthAccount::getId, accountId)) > 0;
}
public boolean existsByPhoneForOtherAccount(String phone, Long accountId) {
return mapper.selectCount(new LambdaQueryWrapper<AuthAccount>()
.eq(AuthAccount::getPhone, phone)
.ne(AuthAccount::getId, accountId)) > 0;
}
public void insert(AuthAccount account) { mapper.insert(account); }
public AuthAccount findById(Long id) {
@@ -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())
@@ -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, "登录标识或密码错误");
}
}
@@ -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;
@@ -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;
+2
View File
@@ -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='统一登录账号表:保存平台端和商户端共用的认证凭据及账号安全状态';
@@ -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");
@@ -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();
@@ -21,6 +21,11 @@ public interface AuthAccountClient {
@PathVariable Long id, @RequestBody StatusRequest request);
@PutMapping("/{id}/profile") ApiResponse<Void> profile(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ProfileRequest request);
@PutMapping("/{id}/login-identifiers") ApiResponse<AccountSummary> changeLoginIdentifiers(
@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id,
@RequestBody ChangeLoginIdentifiersRequest request);
@PutMapping("/{id}/phone") ApiResponse<Void> phone(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PhoneRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> 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<Void> 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); }
}
@@ -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<PersonalProfileResponse> 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<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
}
@@ -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,
@@ -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(
@@ -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<Long> roleIds) { }
@@ -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,
@@ -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) { }
@@ -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);
@@ -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<AuthAccountClient.AccountSummary> 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();
@@ -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<Long> 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());
@@ -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 {
@@ -160,24 +160,32 @@ public class ProfileResourceService {
List<IamTenantQualification> businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE");
List<IamTenantQualification> 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 / 营业执照和统一社会信用代码必须通过必填资质包上传");
}
}
@@ -215,7 +215,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
IamTenant tenant, long membershipId) {
try {
ApiResponse<AuthAccountClient.AccountSummary> 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()) {
@@ -52,8 +52,7 @@ import java.util.stream.Collectors;
*/
@Service
public class TenantService {
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of(
"BUSINESS_LICENSE", "LEGAL_PERSON_ID");
private static final Set<String> 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);
+91 -12
View File
@@ -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}
@@ -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() {
@@ -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();
@@ -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"));
}
@@ -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);
+1 -1
View File
@@ -17,9 +17,9 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.3.0</version></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -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<Metadata> 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) { }
}
@@ -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<CustomerAddressDtos.Response>> 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<Long> 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<Void> 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<Void> disable(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) {
service.disable(OrderScopeGuard.merchant(user), user.userId(), id);
return ApiResponse.success(null);
}
}
@@ -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<byte[]> 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<MerchantBatchImportDtos.Response> 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({
@@ -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) { }
}
@@ -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<Result> items) { }
@@ -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) { }
}
@@ -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<Cargo> 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<Cargo> 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 / 头程订单分页查询条件")
@@ -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;
}
@@ -12,7 +12,7 @@ import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.
* 申报价值及危险品标识等货物信息。</p>
*/
@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;
}
@@ -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<CustomerAddressEntity> { }
@@ -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<CustomerAddressDtos.Response> list(long tenantId, String addressType) {
if (addressType != null && !addressType.matches("PICKUP|PRIVATE_DESTINATION")) {
throw new BizException(ResultCode.VALIDATION_FAILED, "地址类型不正确");
}
return mapper.selectList(new LambdaQueryWrapper<CustomerAddressEntity>()
.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<CustomerAddressEntity>()
.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<CustomerAddressEntity>()
.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<CustomerAddressEntity>()
.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<CustomerAddressEntity>()
.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()));
}
}
@@ -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);
}
}
@@ -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<MerchantBatchDraftDtos.Item> items) {
validateBatch(batchKey, items);
Set<String> 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<MerchantBatchDraftDtos.Result> 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<MerchantBatchDraftDtos.Item> 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")
@@ -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<String> 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<MerchantBatchDraftDtos.Item> 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<MerchantBatchDraftDtos.Item> 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<MerchantBatchDraftDtos.Item> 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<String> 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());
}
}
}
@@ -47,6 +47,7 @@ public class OrderApplicationService {
@Resource private OrderMasterDataGuard masterDataGuard;
@Resource private OrderPricingChannelGuard pricingChannelGuard;
@Resource private ProductImageGuard productImageGuard;
@Resource private ObjectProvider<CustomerAddressService> customerAddressServiceProvider;
@Resource private InboundCodeModeProvider inboundCodeModeProvider;
@Resource private ObjectProvider<FirstlegInboundClient> 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();
+39
View File
@@ -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和数据隔离键',
+95 -3
View File
@@ -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 必须属于当前登录客户。
@@ -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");
}
}
}
@@ -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("客户地址不存在或已停用");
}
}
@@ -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"))))
@@ -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");
}
}
}
@@ -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
@@ -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();
}
}