This commit is contained in:
2026-08-03 13:16:19 +08:00
parent 481747d9dc
commit 9eb03d4bce
54 changed files with 1925 additions and 52 deletions
@@ -56,6 +56,11 @@ public class InternalAccountController {
@PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
verify(token); service.resetPassword(id, request); return ApiResponse.success(null);
}
@PutMapping("/{id}/scope")
public ApiResponse<Void> scope(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id, @Valid @RequestBody UpdateAccountScopeRequest request) {
verify(token); service.updateScope(id, request); return ApiResponse.success(null);
}
@DeleteMapping("/{id}/compensation")
public ApiResponse<Void> compensate(@RequestHeader(value="X-Internal-Service-Token", required=false) String token,
@PathVariable Long id) {
@@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Pattern;
import java.time.LocalDateTime;
/** 账号安全及内部账号管理契约。 */
public final class AccountManagementDtos {
private AccountManagementDtos() { }
@@ -19,10 +21,24 @@ public final class AccountManagementDtos {
@NotBlank String displayName, @NotNull Long tenantId,
@NotNull Long membershipId,
@Pattern(regexp = "MERCHANT|PLATFORM", message = "应用端只能是 MERCHANT 或 PLATFORM")
String appCode) { }
String appCode,
@Pattern(regexp = "ACTIVE|DISABLED", message = "账号状态只能是 ACTIVE 或 DISABLED")
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");
}
}
public record UpdateAccountProfileRequest(@NotBlank @Size(max = 64) String displayName) { }
public record UpdateAccountStatusRequest(@NotBlank String status) { }
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record AccountSummary(Long accountId, String username, String displayName, String status) { }
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,
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);
}
}
}
@@ -54,7 +54,7 @@ public class AccountManagementService {
account.setUsername(request.username());
account.setPasswordHash(passwordEncoder.encode(request.password()));
account.setDisplayName(request.displayName());
account.setAccountStatus("ACTIVE");
account.setAccountStatus(request.accountStatus() == null ? "ACTIVE" : request.accountStatus());
account.setFailedLoginCount(0);
account.setTokenVersion(1);
account.setDefaultTenantId(request.tenantId());
@@ -93,6 +93,20 @@ public class AccountManagementService {
setPasswordAndRevoke(required(accountId), request.newPassword());
}
@Transactional
@BusinessAudit(event = "ACCOUNT_SCOPE_CHANGE", resourceType = "ACCOUNT", resourceId = "#accountId",
description = "修改账号所属租户", captureAfter = false)
public void updateScope(Long accountId, UpdateAccountScopeRequest request) {
String appCode = request.appCode().trim().toUpperCase(Locale.ROOT);
validateScope(appCode, request.tenantId());
AuthAccount account = required(accountId);
account.setDefaultTenantId(request.tenantId());
account.setOperatorTenantId(request.tenantId());
account.setDefaultMembershipId(request.membershipId());
account.setDefaultAppCode(appCode);
bumpVersion(account);
}
@Transactional
@BusinessAudit(event = "ACCOUNT_COMPENSATE_DELETE", resourceType = "ACCOUNT",
resourceId = "#accountId", description = "补偿删除认证账号", captureAfter = false)
@@ -127,7 +141,8 @@ 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.getDisplayName(), a.getAccountStatus(),
a.getLastLoginAt(), a.getDefaultTenantId(), a.getDefaultMembershipId(), a.getDefaultAppCode());
}
private void validateScope(String appCode, Long tenantId) {
@@ -4,6 +4,7 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest;
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.entity.AuthAccount;
import com.baiyue.auth.repository.AuthAccountRepository;
import com.baiyue.common.core.exception.BizException;
@@ -15,6 +16,7 @@ import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.time.LocalDateTime;
@DisplayName("账号管理服务")
class AccountManagementServiceTest {
@@ -69,6 +71,37 @@ class AccountManagementServiceTest {
assertNotEquals("Password1", captor.getValue().getPasswordHash());
}
@Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() {
when(repository.findById(20L)).thenReturn(null);
when(repository.existsByUsername("merchant.user")).thenReturn(false);
var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1",
"企业用户", 10001L, 30L, "MERCHANT", "DISABLED"));
var captor = org.mockito.ArgumentCaptor.forClass(AuthAccount.class);
verify(repository).insert(captor.capture());
AuthAccount stored = captor.getValue();
stored.setLastLoginAt(LocalDateTime.of(2026, 6, 1, 10, 30));
when(repository.findById(20L)).thenReturn(stored);
var summary = service.summary(20L);
assertEquals("DISABLED", created.status());
assertEquals(10001L, summary.tenantId());
assertEquals(30L, summary.membershipId());
assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), summary.lastLoginAt());
}
@Test void updateScopeChangesTenantAndRevokesExistingSessions() {
account.setDefaultTenantId(10001L); account.setOperatorTenantId(10001L);
account.setDefaultMembershipId(30L); account.setDefaultAppCode("MERCHANT");
service.updateScope(10L, new UpdateAccountScopeRequest(10002L, 30L, "MERCHANT"));
assertEquals(10002L, account.getDefaultTenantId());
assertEquals(10002L, account.getOperatorTenantId());
verify(repository).save(account);
verify(sessions).revokeAll(10L);
}
@Test void createAccountRetriesIdempotentlyForSameAccountAndOwner() {
account.setUsername("platform.user"); account.setDisplayName("平台用户");
account.setDefaultTenantId(0L); account.setOperatorTenantId(0L);
@@ -17,7 +17,15 @@ if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ];
fi
for sql_file in \
auth/src/main/resources/db/auth.sql \
iam/src/main/resources/db/iam.sql \
auth/src/main/resources/db/phase1-admin-accounts.sql \
iam/src/main/resources/db/phase1-admin-rbac.sql \
iam/src/main/resources/db/phase1-onboarding.sql \
iam/src/main/resources/db/phase1-customer-profile-fields.sql \
iam/src/main/resources/db/phase1-member-permissions.sql \
iam/src/main/resources/db/rbac-role-management-v2.sql \
iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \
order/src/main/resources/db/order.sql \
order/src/main/resources/db/phase1-order-extension.sql \
order/src/main/resources/db/phase1-masterdata-extension.sql \
+5
View File
@@ -20,6 +20,11 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe
| GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 |
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 |
| POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 |
| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Unified platform and merchant user administration / 统一用户管理 |
| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Tenant and role options / 用户租户与角色选项 |
| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit profile, tenant and roles / 编辑用户资料、租户和角色 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 |
| GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 |
| GET/POST | `/api/merchant/v1/iam/members` | MERCHANT | List/add members / 成员 |
| GET/POST | `/api/{app}/v1/iam/roles` | PLATFORM/MERCHANT | Role management / 角色 |
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
/** auth-service 内部账号管理客户端。 */
@FeignClient(name = "auth-service", contextId = "authAccountClient",
url = "${baiyue.clients.auth.url:}", path = "/internal/auth/accounts")
@@ -18,18 +20,30 @@ public interface AuthAccountClient {
@PathVariable Long id, @RequestBody ProfileRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request);
@PutMapping("/{id}/scope") ApiResponse<Void> scope(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ScopeRequest request);
@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,
Long tenantId, Long membershipId, String appCode) {
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");
this(accountId, username, 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");
}
}
record AccountSummary(Long accountId, String username, 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);
}
}
record AccountSummary(Long accountId, String username, String displayName, String status) { }
record StatusRequest(String status) { }
record ProfileRequest(String displayName) { }
record PasswordRequest(String newPassword) { }
record ScopeRequest(Long tenantId, Long membershipId, String appCode) { }
}
@@ -0,0 +1,83 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.service.PlatformUserManagementService;
import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 原型“用户管理”的平台统一入口,覆盖平台与企业租户账号。 */
@RestController
@RequestMapping("/api/platform/v1/iam/users")
@Tag(name = "Platform user administration / 平台用户管理")
public class PlatformUserManagementController {
private final PlatformUserManagementService service;
public PlatformUserManagementController(PlatformUserManagementService service) {
this.service = service;
}
@GetMapping
@PreAuthorize("hasAuthority('platform:user:list')")
@Operation(summary = "统一查询平台及企业租户用户")
public ApiResponse<List<UserResponse>> list(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.list());
}
@GetMapping("/options")
@PreAuthorize("hasAuthority('platform:user:list')")
@Operation(summary = "查询用户管理可选租户和角色")
public ApiResponse<UserOptions> options(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.options());
}
@PostMapping
@PreAuthorize("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')")
@Operation(summary = "新增平台或企业租户用户")
public ApiResponse<UserResponse> create(@Valid @RequestBody CreateUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.create(request));
}
@PutMapping("/{membershipId:\\d+}")
@PreAuthorize("hasAuthority('platform:user:edit')")
@Operation(summary = "编辑用户资料、租户、角色、状态和密码")
public ApiResponse<UserResponse> update(@PathVariable Long membershipId,
@Valid @RequestBody UpdateUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.update(membershipId, request, user.permissions()));
}
@PutMapping("/{membershipId:\\d+}/status")
@PreAuthorize("hasAuthority('platform:user:status')")
@Operation(summary = "启用或停用用户")
public ApiResponse<UserResponse> status(@PathVariable Long membershipId,
@Valid @RequestBody ChangeStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.changeStatus(membershipId, request, user.membershipId()));
}
@PutMapping("/{membershipId:\\d+}/password")
@PreAuthorize("hasAuthority('platform:user:reset-password')")
@Operation(summary = "重置用户密码")
public ApiResponse<Void> password(@PathVariable Long membershipId,
@Valid @RequestBody ResetPasswordRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.resetPassword(membershipId, request);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,48 @@
package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/** 平台统一用户管理契约,覆盖平台账号与企业租户账号。 */
public final class PlatformUserManagementDtos {
private PlatformUserManagementDtos() { }
public record CreateUserRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email,
@NotNull Long tenantId,
@NotNull List<Long> roleIds,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
public record UpdateUserRequest(
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email,
@NotNull Long tenantId,
@NotNull List<Long> roleIds) { }
public record ChangeStatusRequest(@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
public record ResetPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword) { }
public record UserResponse(Long membershipId, Long userId, Long authUserId, String username,
String displayName, String phone, String email, Long tenantId,
String tenantName, String memberType, String memberNo, String status,
List<Long> roleIds, List<String> roleNames, LocalDateTime lastLoginAt,
boolean protectedAccount) { }
public record TenantOption(Long id, String name, String status) { }
public record RoleOption(Long id, Long tenantId, String name, String status) { }
public record UserOptions(List<TenantOption> tenants, List<RoleOption> roles) { }
}
@@ -51,7 +51,13 @@ public final class TenantDtos {
@Size(max = 64) String salesPerson,
@Size(max = 255) String businessAddress,
@Size(max = 500) String remark
) { }
) {
public UpdateTenantRequest(String tenantName, String legalName, String contactName,
String contactPhone, String contactEmail) {
this(tenantName, legalName, contactName, contactPhone, contactEmail,
null, null, null, null, null);
}
}
@Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status")
public record ChangeTenantStatusRequest(
@@ -30,6 +30,10 @@ public class MemberRepository {
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt));
}
public List<IamMembership> findAll() {
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
.orderByDesc(IamMembership::getJoinedAt));
}
public IamMembership findById(Long id) { return membershipMapper.selectById(id); }
public IamMembership findByTenantAndId(long tenantId, Long id) {
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
@@ -33,6 +33,11 @@ public class TenantRepository {
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
public List<IamTenant> listMerchants() {
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.orderByAsc(IamTenant::getTenantName));
}
public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); }
public IamTenantReview findPendingReviewByTenant(Long tenantId) {
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
@@ -0,0 +1,343 @@
package com.baiyue.iam.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.TenantRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 平台管理员跨租户维护登录用户;凭据仍只由 auth-service 保存。 */
@Service
public class PlatformUserManagementService {
private static final long PLATFORM_TENANT_ID = 0L;
private static final Logger log = LoggerFactory.getLogger(PlatformUserManagementService.class);
private final MemberRepository members;
private final TenantRepository tenants;
private final IamRoleMapper roleMapper;
private final AuthAccountClient authClient;
private final String internalToken;
public PlatformUserManagementService(MemberRepository members, TenantRepository tenants,
IamRoleMapper roleMapper, AuthAccountClient authClient,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.members = members;
this.tenants = tenants;
this.roleMapper = roleMapper;
this.authClient = authClient;
this.internalToken = internalToken;
}
@Transactional(readOnly = true)
public List<UserResponse> list() {
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream()
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
Map<Long, IamRole> roleMap = allRoles().stream()
.collect(Collectors.toMap(IamRole::getId, Function.identity()));
return members.findAll().stream().map(membership -> response(membership, tenantMap, roleMap)).toList();
}
@Transactional(readOnly = true)
public UserOptions options() {
List<TenantOption> tenantOptions = new java.util.ArrayList<>();
tenantOptions.add(new TenantOption(PLATFORM_TENANT_ID, "平台", "ACTIVE"));
tenants.listMerchants().stream()
.map(value -> new TenantOption(value.getId(), value.getTenantName(), value.getStatus()))
.forEach(tenantOptions::add);
List<RoleOption> roleOptions = allRoles().stream()
.map(value -> new RoleOption(value.getId(), value.getTenantId(), value.getRoleName(), value.getStatus()))
.toList();
return new UserOptions(List.copyOf(tenantOptions), roleOptions);
}
@Transactional
@BusinessAudit(event = "PLATFORM_USER_CREATE", resourceType = "MEMBERSHIP",
resourceId = "#request.username", description = "平台新增用户", captureAfter = false)
public UserResponse create(CreateUserRequest request) {
long tenantId = request.tenantId();
validateTenant(tenantId);
List<Long> roleIds = validateRoles(tenantId, request.roleIds());
long authUserId = IdWorker.getId();
long userId = IdWorker.getId();
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())));
try {
IamUser user = new IamUser();
user.setId(userId);
user.setAuthUserId(authUserId);
user.setUserType(userType(tenantId));
user.setDisplayName(request.displayName().trim());
user.setPhone(request.phone().trim());
user.setEmail(trimToNull(request.email()));
user.setStatus(request.status());
members.insertUser(user);
IamMembership membership = new IamMembership();
membership.setId(membershipId);
membership.setTenantId(tenantId);
membership.setUserId(userId);
membership.setMemberType("EMPLOYEE");
membership.setMemberNo(memberNo(tenantId, membershipId));
membership.setStatus(request.status());
membership.setJoinedAt(LocalDateTime.now());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
members.insert(membership);
members.replaceRoles(tenantId, membershipId, roleIds);
return response(membership);
} catch (RuntimeException localFailure) {
compensateCreate(authUserId, localFailure);
throw localFailure;
}
}
@Transactional
@BusinessAudit(event = "PLATFORM_USER_UPDATE", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台编辑用户", captureAfter = false)
public UserResponse update(Long membershipId, UpdateUserRequest request, Set<String> operatorPermissions) {
IamMembership membership = required(membershipId);
IamUser user = requiredUser(membership.getUserId());
long oldTenantId = membership.getTenantId();
long newTenantId = request.tenantId();
validateTenant(newTenantId);
List<Long> roleIds = validateRoles(newTenantId, request.roleIds());
List<Long> oldRoleIds = members.findRoleIds(oldTenantId, membershipId);
if (isProtectedPlatformAdmin(membership, oldRoleIds)
&& (newTenantId != PLATFORM_TENANT_ID || !containsPlatformAdminRole(roleIds))) {
throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能更换租户或移除超级管理员角色");
}
if ((oldTenantId != newTenantId || !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds)))
&& (operatorPermissions == null || !operatorPermissions.contains("platform:user:grant-role"))) {
throw new BizException(ResultCode.FORBIDDEN, "变更用户所属租户或角色需要分配角色权限");
}
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim());
boolean scopeChanged = oldTenantId != newTenantId;
if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
if (scopeChanged) call(() -> authClient.scope(internalToken, user.getAuthUserId(),
new AuthAccountClient.ScopeRequest(newTenantId, membershipId, appCode(newTenantId))));
try {
user.setDisplayName(request.displayName().trim());
user.setPhone(request.phone().trim());
user.setEmail(trimToNull(request.email()));
user.setUserType(userType(newTenantId));
membership.setTenantId(newTenantId);
if (scopeChanged && members.existsMemberNo(newTenantId, membership.getMemberNo(), membershipId)) {
membership.setMemberNo(memberNo(newTenantId, membershipId));
}
members.saveUser(user);
members.save(membership);
if (scopeChanged) members.replaceRoles(oldTenantId, membershipId, List.of());
members.replaceRoles(newTenantId, membershipId, roleIds);
} catch (RuntimeException localFailure) {
compensateUpdate(user.getAuthUserId(), membershipId, oldTenantId, oldAccount,
profileChanged, scopeChanged, localFailure);
throw localFailure;
}
return response(membership);
}
@Transactional
@BusinessAudit(event = "PLATFORM_USER_STATUS_CHANGE", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台启停用户", captureAfter = false)
public UserResponse changeStatus(Long membershipId, ChangeStatusRequest request, Long operatorMembershipId) {
IamMembership membership = required(membershipId);
if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status())) {
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录用户");
}
IamUser user = requiredUser(membership.getUserId());
if ("DISABLED".equals(request.status())
&& isProtectedPlatformAdmin(membership,
members.findRoleIds(membership.getTenantId(), membershipId))) {
throw new BizException(ResultCode.FORBIDDEN, "系统管理员不能被禁用");
}
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(request.status())));
try {
membership.setStatus(request.status());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
user.setStatus(request.status());
members.saveUser(user);
members.save(membership);
} catch (RuntimeException localFailure) {
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(oldAccount.status())));
throw localFailure;
}
return response(membership);
}
@BusinessAudit(event = "PLATFORM_USER_PASSWORD_RESET", resourceType = "MEMBERSHIP",
resourceId = "#membershipId", description = "平台重置用户密码", captureAfter = false)
public void resetPassword(Long membershipId, ResetPasswordRequest request) {
IamUser user = requiredUser(required(membershipId).getUserId());
call(() -> authClient.reset(internalToken, user.getAuthUserId(),
new AuthAccountClient.PasswordRequest(request.newPassword())));
}
private UserResponse response(IamMembership membership) {
Map<Long, IamTenant> tenantMap = tenants.listMerchants().stream()
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
Map<Long, IamRole> roleMap = allRoles().stream()
.collect(Collectors.toMap(IamRole::getId, Function.identity()));
return response(membership, tenantMap, roleMap);
}
private UserResponse response(IamMembership membership, Map<Long, IamTenant> tenantMap,
Map<Long, IamRole> roleMap) {
IamUser user = requiredUser(membership.getUserId());
AuthAccountClient.AccountSummary account = account(user.getAuthUserId());
List<Long> roleIds = members.findRoleIds(membership.getTenantId(), membership.getId());
List<String> roleNames = roleIds.stream().map(roleMap::get).filter(Objects::nonNull)
.map(IamRole::getRoleName).toList();
String tenantName = membership.getTenantId() == PLATFORM_TENANT_ID ? "平台"
: tenantMap.containsKey(membership.getTenantId())
? tenantMap.get(membership.getTenantId()).getTenantName() : "未知租户";
return new UserResponse(membership.getId(), user.getId(), user.getAuthUserId(), account.username(),
user.getDisplayName(), user.getPhone(), user.getEmail(), membership.getTenantId(), tenantName,
membership.getMemberType(), membership.getMemberNo(), membership.getStatus(), roleIds,
roleNames, account.lastLoginAt(), isProtectedPlatformAdmin(membership, roleIds));
}
private List<IamRole> allRoles() {
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
.in(IamRole::getAppCode, "PLATFORM", "MERCHANT")
.orderByAsc(IamRole::getTenantId).orderByAsc(IamRole::getRoleName));
}
private List<Long> validateRoles(long tenantId, List<Long> requested) {
List<Long> roleIds = List.copyOf(new LinkedHashSet<>(requested == null ? List.of() : requested));
if (roleIds.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请选择角色");
for (Long roleId : roleIds) {
IamRole role = roleMapper.selectById(roleId);
if (role == null || !Objects.equals(role.getTenantId(), tenantId)
|| !appCode(tenantId).equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())) {
throw new BizException(ResultCode.FORBIDDEN, "角色必须属于所选租户且处于启用状态");
}
}
return roleIds;
}
private boolean isProtectedPlatformAdmin(IamMembership membership, List<Long> roleIds) {
return membership.getTenantId() == PLATFORM_TENANT_ID && containsPlatformAdminRole(roleIds);
}
private boolean containsPlatformAdminRole(List<Long> roleIds) {
return roleIds.stream().map(roleMapper::selectById).filter(Objects::nonNull)
.anyMatch(role -> "PLATFORM_ADMIN".equals(role.getRoleCode()));
}
private void validateTenant(long tenantId) {
if (tenantId == PLATFORM_TENANT_ID) return;
IamTenant tenant = tenants.findTenant(tenantId);
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType()) || !"ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.BAD_REQUEST, "只能选择正常状态的企业租户");
}
}
private IamMembership required(Long membershipId) {
IamMembership membership = members.findById(membershipId);
if (membership == null) throw new BizException(ResultCode.NOT_FOUND, "用户不存在");
return membership;
}
private IamUser requiredUser(Long userId) {
IamUser user = members.findUser(userId);
if (user == null) throw new BizException(ResultCode.NOT_FOUND, "用户档案不存在");
return user;
}
private AuthAccountClient.AccountSummary account(Long authUserId) {
ApiResponse<AuthAccountClient.AccountSummary> result = call(() -> authClient.summary(internalToken, authUserId));
if (result.data() == null) throw unavailable();
return result.data();
}
private String appCode(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "PLATFORM" : "MERCHANT"; }
private String userType(long tenantId) { return tenantId == PLATFORM_TENANT_ID ? "INTERNAL" : "ENTERPRISE"; }
private String memberNo(long tenantId, long membershipId) {
return (tenantId == PLATFORM_TENANT_ID ? "PLATFORM-" : "MEMBER-") + membershipId;
}
private String trimToNull(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private void compensateCreate(long authUserId, RuntimeException localFailure) {
try {
call(() -> authClient.compensate(internalToken, authUserId));
} catch (RuntimeException compensationFailure) {
log.error("统一用户创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"用户创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
}
}
private void compensateUpdate(long authUserId, long membershipId, long oldTenantId,
AuthAccountClient.AccountSummary oldAccount, boolean profileChanged,
boolean scopeChanged, RuntimeException localFailure) {
try {
if (scopeChanged) call(() -> authClient.scope(internalToken, authUserId,
new AuthAccountClient.ScopeRequest(oldTenantId, membershipId, appCode(oldTenantId))));
if (profileChanged) call(() -> authClient.profile(internalToken, authUserId,
new AuthAccountClient.ProfileRequest(oldAccount.displayName())));
} catch (RuntimeException compensationFailure) {
log.error("统一用户编辑失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"用户编辑未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
}
}
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> result = action.call();
if (result == null) throw unavailable();
if (!result.successful()) {
ResultCode code = ResultCode.of(result.code());
throw new BizException(code, result.message() == null || result.message().isBlank()
? code.message() : result.message());
}
return result;
} catch (FeignException exception) {
throw unavailable();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
@FunctionalInterface private interface RemoteCall<T> { ApiResponse<T> call(); }
}
@@ -93,6 +93,10 @@ VALUES
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
(5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0),
(5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0),
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0),
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0),
(5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', NULL, NULL, 40, 'ACTIVE', 0),
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配用户角色', NULL, NULL, 50, 'ACTIVE', 0),
(5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0),
(5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0),
(5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0),
@@ -113,9 +117,11 @@ VALUES
(5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', NULL, NULL, 10, 'ACTIVE', 0),
(5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', NULL, NULL, 20, 'ACTIVE', 0),
(5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启用停用快递公司', NULL, NULL, 30, 'ACTIVE', 0),
(5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', NULL, NULL, 40, 'ACTIVE', 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', NULL, NULL, 10, 'ACTIVE', 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', NULL, NULL, 20, 'ACTIVE', 0),
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0)
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启用停用往来单位', NULL, NULL, 30, 'ACTIVE', 0),
(5103034, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:import', '批量导入往来单位', NULL, NULL, 40, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -1,12 +0,0 @@
-- 平台客户档案原型字段补齐,可重复执行。
USE baiyue_iam;
SET @add_customer_columns = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'iam_tenant' AND column_name = 'service_package'),
'SELECT 1',
'ALTER TABLE iam_tenant ADD COLUMN service_package VARCHAR(64) NULL COMMENT ''客户服务套餐'' AFTER contact_email, ADD COLUMN credit_days INT NOT NULL DEFAULT 0 COMMENT ''账期天数'' AFTER service_package, ADD COLUMN sales_person VARCHAR(64) NULL COMMENT ''负责销售人员'' AFTER credit_days, ADD COLUMN business_address VARCHAR(255) NULL COMMENT ''企业经营地址'' AFTER sales_person, ADD COLUMN remark VARCHAR(500) NULL COMMENT ''平台客户备注'' AFTER business_address'
);
PREPARE add_customer_columns_stmt FROM @add_customer_columns;
EXECUTE add_customer_columns_stmt;
DEALLOCATE PREPARE add_customer_columns_stmt;
@@ -31,6 +31,7 @@ VALUES
(5103021, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:create', '新增快递公司', 10, 'ACTIVE', 1, 0),
(5103022, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:edit', '编辑快递公司', 20, 'ACTIVE', 1, 0),
(5103023, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:status', '启停快递公司', 30, 'ACTIVE', 1, 0),
(5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0),
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0)
@@ -46,7 +47,7 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN (
'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role',
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status',
'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import',
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+104
View File
@@ -145,6 +145,73 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users:
get:
tags: [IAM Resources / IAM 资源]
summary: 统一查询平台及企业租户用户
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
summary: 新增平台或企业租户用户
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreatePlatformManagedUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/options:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询用户管理可选租户和角色
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/{membershipId}:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 编辑用户资料、所属租户及角色
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdatePlatformManagedUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/{membershipId}/status:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 启用或停用平台及企业租户用户
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ChangeInternalUserStatusRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/users/{membershipId}/password:
parameters:
- {$ref: '#/components/parameters/MembershipId'}
put:
tags: [IAM Resources / IAM 资源]
summary: 重置平台及企业租户用户密码
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ResetInternalUserPasswordRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/internal-users:
get:
tags: [IAM Resources / IAM 资源]
@@ -505,6 +572,12 @@ components:
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
servicePackage: {type: string, maxLength: 64}
creditDays: {type: integer, minimum: 0}
salesPerson: {type: string, maxLength: 64}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
createdAt: {type: string, format: date-time}
RegisterRequest:
type: object
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone]
@@ -515,6 +588,11 @@ components:
contactName: {type: string, example: 李四}
contactPhone: {type: string, example: '13800138000'}
contactEmail: {type: string, format: email, example: contact@example.com}
servicePackage: {type: string, maxLength: 64, example: 标准版}
creditDays: {type: integer, minimum: 0, example: 30}
salesPerson: {type: string, maxLength: 64, example: 李四}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
UpdateProfileRequest:
type: object
required: [tenantName, legalName, contactName, contactPhone]
@@ -533,6 +611,11 @@ components:
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
servicePackage: {type: string, maxLength: 64}
creditDays: {type: integer, minimum: 0}
salesPerson: {type: string, maxLength: 64}
businessAddress: {type: string, maxLength: 255}
remark: {type: string, maxLength: 500}
ChangeTenantStatusRequest:
type: object
required: [status]
@@ -542,6 +625,27 @@ components:
type: object
properties:
comment: {type: string, maxLength: 500, description: Review comment or rejection reason / 审核意见或驳回原因}
CreatePlatformManagedUserRequest:
type: object
required: [username, displayName, phone, tenantId, roleIds, password, status]
properties:
username: {type: string, minLength: 4, maxLength: 64}
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
status: {type: string, enum: [ACTIVE, DISABLED]}
UpdatePlatformManagedUserRequest:
type: object
required: [displayName, phone, tenantId, roleIds]
properties:
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
CreateInternalUserRequest:
type: object
required: [displayName]
@@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.lang.reflect.Method;
@@ -35,6 +36,33 @@ class PlatformInternalUserContractTest {
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles");
}
@Test
void unifiedPlatformUserEndpointsKeepIndependentPermissions() throws Exception {
Method list = PlatformUserManagementController.class.getMethod("list",
com.baiyue.common.security.AuthenticatedUser.class);
Method create = PlatformUserManagementController.class.getMethod("create",
com.baiyue.iam.dto.PlatformUserManagementDtos.CreateUserRequest.class,
com.baiyue.common.security.AuthenticatedUser.class);
Method update = PlatformUserManagementController.class.getMethod("update", Long.class,
com.baiyue.iam.dto.PlatformUserManagementDtos.UpdateUserRequest.class,
com.baiyue.common.security.AuthenticatedUser.class);
Method status = PlatformUserManagementController.class.getMethod("status", Long.class,
com.baiyue.iam.dto.PlatformUserManagementDtos.ChangeStatusRequest.class,
com.baiyue.common.security.AuthenticatedUser.class);
Method password = PlatformUserManagementController.class.getMethod("password", Long.class,
com.baiyue.iam.dto.PlatformUserManagementDtos.ResetPasswordRequest.class,
com.baiyue.common.security.AuthenticatedUser.class);
assertEquals("hasAuthority('platform:user:list')", list.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:user:create') and hasAuthority('platform:user:grant-role')",
create.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:user:edit')", update.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:user:status')", status.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:user:reset-password')", password.getAnnotation(PreAuthorize.class).value());
assertNotNull(create.getAnnotation(PostMapping.class));
assertNotNull(update.getAnnotation(PutMapping.class));
}
@Test
void tenantProfileReadRequiresViewPermission() throws Exception {
Method method = MerchantTenantController.class.getMethod("profile",
@@ -0,0 +1,158 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.PlatformUserManagementDtos.*;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.TenantRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class PlatformUserManagementServiceTest {
private final MemberRepository members = mock(MemberRepository.class);
private final TenantRepository tenants = mock(TenantRepository.class);
private final IamRoleMapper roles = mock(IamRoleMapper.class);
private final AuthAccountClient auth = mock(AuthAccountClient.class);
private final PlatformUserManagementService service =
new PlatformUserManagementService(members, tenants, roles, auth, "internal-token");
private IamTenant merchant;
private IamRole merchantRole;
private IamMembership membership;
private IamUser user;
@BeforeEach
void setUp() {
merchant = new IamTenant();
merchant.setId(10001L); merchant.setTenantType("MERCHANT"); merchant.setTenantName("华远贸易");
merchant.setStatus("ACTIVE");
merchantRole = new IamRole();
merchantRole.setId(40001L); merchantRole.setTenantId(10001L); merchantRole.setAppCode("MERCHANT");
merchantRole.setRoleName("租户管理员"); merchantRole.setStatus("ACTIVE");
membership = new IamMembership();
membership.setId(30L); membership.setTenantId(10001L); membership.setUserId(20L);
membership.setMemberType("EMPLOYEE"); membership.setMemberNo("MEMBER-30"); membership.setStatus("ACTIVE");
user = new IamUser();
user.setId(20L); user.setAuthUserId(10L); user.setUserType("ENTERPRISE");
user.setDisplayName("张伟"); user.setPhone("13800138000"); user.setStatus("ACTIVE");
when(tenants.findTenant(10001L)).thenReturn(merchant);
when(tenants.listMerchants()).thenReturn(List.of(merchant));
when(roles.selectById(40001L)).thenReturn(merchantRole);
when(roles.selectList(any())).thenReturn(List.of(merchantRole));
when(members.findById(30L)).thenReturn(membership);
when(members.findUser(20L)).thenReturn(user);
when(members.findRoleIds(10001L, 30L)).thenReturn(List.of(40001L));
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(10L, "zhangwei", "张伟", "ACTIVE",
LocalDateTime.of(2026, 6, 1, 10, 30), 10001L, 30L, "MERCHANT")));
}
@Test
void listReturnsTenantRoleAndLastLoginForMerchantUser() {
when(members.findAll()).thenReturn(List.of(membership));
UserResponse response = service.list().get(0);
assertEquals("zhangwei", response.username());
assertEquals("华远贸易", response.tenantName());
assertEquals(List.of("租户管理员"), response.roleNames());
assertEquals(LocalDateTime.of(2026, 6, 1, 10, 30), response.lastLoginAt());
}
@Test
void createMerchantUserCreatesDisabledMerchantAccountAndMembership() {
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED")));
when(auth.summary(anyString(), anyLong())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(99L, "newuser", "新用户", "DISABLED")));
when(members.findUser(anyLong())).thenReturn(user);
CreateUserRequest request = new CreateUserRequest("newuser", "新用户", "13900139000",
"new@example.com", 10001L, List.of(40001L), "Password1", "DISABLED");
service.create(request);
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> account =
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
verify(auth).create(eq("internal-token"), account.capture());
assertEquals("MERCHANT", account.getValue().appCode());
assertEquals("DISABLED", account.getValue().accountStatus());
verify(members).insertUser(argThat(value -> "ENTERPRISE".equals(value.getUserType())));
verify(members).insert(argThat(value -> value.getTenantId() == 10001L
&& "DISABLED".equals(value.getStatus())));
}
@Test
void tenantMoveUpdatesAuthScopeAndRolesWhenOperatorCanGrantRoles() {
IamTenant target = new IamTenant();
target.setId(10002L); target.setTenantType("MERCHANT"); target.setTenantName("深圳速达"); target.setStatus("ACTIVE");
IamRole targetRole = new IamRole();
targetRole.setId(40002L); targetRole.setTenantId(10002L); targetRole.setAppCode("MERCHANT");
targetRole.setRoleName("操作员"); targetRole.setStatus("ACTIVE");
when(tenants.findTenant(10002L)).thenReturn(target);
when(tenants.listMerchants()).thenReturn(List.of(merchant, target));
when(roles.selectById(40002L)).thenReturn(targetRole);
when(roles.selectList(any())).thenReturn(List.of(merchantRole, targetRole));
when(auth.scope(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null));
when(auth.profile(anyString(), anyLong(), any())).thenReturn(ApiResponse.success(null));
UpdateUserRequest request = new UpdateUserRequest("张伟新", "13800138000", null,
10002L, List.of(40002L));
UserResponse response = service.update(30L, request, Set.of("platform:user:grant-role"));
verify(auth).scope(eq("internal-token"), eq(10L),
argThat(value -> value.tenantId() == 10002L && "MERCHANT".equals(value.appCode())));
verify(members).replaceRoles(10001L, 30L, List.of());
verify(members).replaceRoles(10002L, 30L, List.of(40002L));
assertEquals(10002L, response.tenantId());
}
@Test
void tenantOrRoleChangeRequiresGrantRolePermission() {
IamRole otherRole = new IamRole();
otherRole.setId(40003L); otherRole.setTenantId(10001L); otherRole.setAppCode("MERCHANT");
otherRole.setRoleName("操作员"); otherRole.setStatus("ACTIVE");
when(roles.selectById(40003L)).thenReturn(otherRole);
UpdateUserRequest request = new UpdateUserRequest("张伟", "13800138000", null,
10001L, List.of(40003L));
assertThrows(BizException.class, () -> service.update(30L, request, Set.of()));
verify(auth, never()).scope(anyString(), anyLong(), any());
}
@Test
void currentPlatformOperatorCannotDisableSelf() {
assertThrows(BizException.class,
() -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 30L));
verify(auth, never()).status(anyString(), anyLong(), any());
}
@Test
void builtInPlatformAdministratorCannotBeDisabled() {
membership.setTenantId(0L);
IamRole platformAdmin = new IamRole();
platformAdmin.setId(40000L); platformAdmin.setTenantId(0L); platformAdmin.setAppCode("PLATFORM");
platformAdmin.setRoleCode("PLATFORM_ADMIN"); platformAdmin.setRoleName("平台超级管理员");
platformAdmin.setStatus("ACTIVE");
when(roles.selectById(40000L)).thenReturn(platformAdmin);
when(members.findRoleIds(0L, 30L)).thenReturn(List.of(40000L));
assertThrows(BizException.class,
() -> service.changeStatus(30L, new ChangeStatusRequest("DISABLED"), 99L));
verify(auth, never()).status(anyString(), anyLong(), any());
}
}
@@ -3,6 +3,7 @@ package com.baiyue.order.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@FeignClient(name="warehouse-service", contextId="warehousePartnerClient",
url="${baiyue.clients.warehouse.url:}", path="/internal/warehouse-partners")
@@ -10,5 +11,6 @@ public interface PartnerClient {
@GetMapping("/{tenantId}/{id}/active")
ApiResponse<Partner> active(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable("tenantId") long tenantId, @PathVariable("id") long id);
record Partner(Long id, Long tenantId, String partnerName, String status) { }
record Partner(Long id, Long tenantId, String partnerName, String status,
String partnerType, List<String> types) { }
}
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.entity.OrderEntity;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
@@ -27,6 +28,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PutMapping;
import java.util.List;
/**
* 平台端头程订单管理接口,在明确的目标租户范围内提供查询、审核和履约状态流转能力。
@@ -110,6 +112,20 @@ public class PlatformFirstLegOrderController {
return ApiResponse.success(service.page(OrderScopeGuard.platform(user, tenantId), query));
}
@GetMapping("/tenant-counts")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "批量查询客户运单数", description = "按客户租户 ID 返回真实头程运单数量,最多 200 个客户。")
public ApiResponse<List<TenantOrderCountResponse>> tenantCounts(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam List<Long> tenantIds) {
if (tenantIds.isEmpty() || tenantIds.size() > 200 || tenantIds.stream().anyMatch(id -> id == null || id <= 0)) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "1 to 200 valid tenant IDs are required / 需要 1 至 200 个有效租户 ID");
}
OrderScopeGuard.platform(user, tenantIds.get(0));
return ApiResponse.success(service.countByTenantIds(tenantIds));
}
@Operation(summary = "Tenant order detail / 租户订单详情", description = "Returns the complete order aggregate within the explicit target tenant. / 返回明确目标租户内的完整订单聚合。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Query succeeded / 查询成功"),
@@ -0,0 +1,6 @@
package com.baiyue.order.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "TenantOrderCountResponse", description = "平台客户的头程运单数量")
public record TenantOrderCountResponse(Long tenantId, Long orderCount) { }
@@ -5,11 +5,13 @@ import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Map;
/**
* 订单聚合仓储,组合订单主表、货物、参与方、备注、状态历史与 Outbox 的持久化操作,
@@ -86,6 +88,18 @@ public class OrderRepository {
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
}
public Map<Long, Long> countByTenantIds(List<Long> tenantIds) {
if (tenantIds.isEmpty()) return Map.of();
var rows = orderMapper.selectMaps(new QueryWrapper<OrderEntity>()
.select("tenant_id", "COUNT(*) AS order_count")
.in("tenant_id", tenantIds)
.groupBy("tenant_id"));
return rows.stream().collect(java.util.stream.Collectors.toMap(
row -> ((Number) row.get("tenant_id")).longValue(),
row -> ((Number) row.get("order_count")).longValue()
));
}
public List<OrderCargoEntity> findCargo(long tenantId, long orderId) {
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
.eq(OrderCargoEntity::getTenantId, tenantId).eq(OrderCargoEntity::getOrderId, orderId)
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.dto.OrderDetailResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.TenantOrderCountResponse;
import com.baiyue.order.entity.*;
import com.baiyue.order.repository.OrderRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -108,6 +109,13 @@ public class OrderApplicationService {
}
public PageResult<OrderEntity> page(long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); }
public List<TenantOrderCountResponse> countByTenantIds(List<Long> tenantIds) {
var counts = repository.countByTenantIds(tenantIds);
return tenantIds.stream().distinct()
.map(tenantId -> new TenantOrderCountResponse(tenantId, counts.getOrDefault(tenantId, 0L)))
.toList();
}
public OrderDetailResponse detail(long tenantId, long id) {
OrderEntity order = require(tenantId, id);
return new OrderDetailResponse(order, repository.findCargo(tenantId, id), repository.findParties(tenantId, id),
@@ -72,6 +72,8 @@ public class OrderMasterDataGuard {
throw new BizException(ResultCode.FORBIDDEN,"往来单位不属于当前企业");
if (!"ACTIVE".equals(value.status()))
throw new BizException(ResultCode.CONFLICT,"往来单位已停用");
if (value.types() == null || !value.types().contains("SHIPPER"))
throw new BizException(ResultCode.VALIDATION_FAILED,"所选往来单位不具备发货单位类型");
if (!StringUtils.hasText(value.partnerName())) throw unavailable("往来单位");
return value;
}
@@ -40,6 +40,18 @@ paths:
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:create 权限}
/api/platform/v1/orders/first-leg/tenant-counts:
get:
summary: 批量查询客户运单数
description: 按客户租户 ID 返回真实头程运单数量,最多 200 个租户。
parameters:
- name: tenantIds
in: query
required: true
schema: {type: array, maxItems: 200, items: {type: integer, format: int64}}
responses:
'200': {description: 查询成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:list 权限}
/api/platform/v1/orders/first-leg/{id}/submit:
post:
summary: 平台代客提交指定订单
@@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.lang.reflect.Method;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@@ -26,6 +27,8 @@ class OrderControllerPermissionTest {
AuthenticatedUser.class, long.class, OrderRequests.QueryRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "detail", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, long.class, long.class);
assertAuthority(PlatformFirstLegOrderController.class, "tenantCounts", "hasAuthority('platform:order:list')",
AuthenticatedUser.class, List.class);
}
@Test
@@ -24,7 +24,7 @@ class OrderMasterDataGuardTest {
@Test
void validMasterDataReturnsTrustedServerSnapshot() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var address=new WarehouseClient.Address("CN","广东省","深圳市","宝安区","518000","示例路1号",null);
var capability=new WarehouseClient.Capability("RECEIVING",true,java.time.LocalTime.of(16,0));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),List.of(address),List.of(capability));
@@ -57,7 +57,7 @@ class OrderMasterDataGuardTest {
@Test
void masterDataResponseWithNullElementReturnsControlledBusinessError() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE");
var partner=new PartnerClient.Partner(7L,10001L,"服务端客户名","ACTIVE","SHIPPER",List.of("SHIPPER"));
var wh=new WarehouseClient.Detail(new WarehouseClient.Warehouse(8L,10001L,"深圳仓","ACTIVE"),
Collections.singletonList(null), Collections.singletonList(null));
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(partner));
@@ -71,7 +71,7 @@ class OrderMasterDataGuardTest {
@Test
void masterDataResponseMissingSnapshotNameIsServiceUnavailable() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE")));
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,null,"ACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
@@ -133,13 +133,25 @@ class OrderMasterDataGuardTest {
void inactiveMasterDataInSuccessfulResponseMapsToConflict() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7))
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE")));
.thenReturn(ApiResponse.success(new PartnerClient.Partner(7L,10001L,"客户","INACTIVE","SHIPPER",List.of("SHIPPER"))));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
}
@Test
void partnerWithoutShipperTypeIsRejected() {
WarehouseClient warehouses=mock(WarehouseClient.class); PartnerClient partners=mock(PartnerClient.class);
when(partners.active("token",10001,7)).thenReturn(ApiResponse.success(
new PartnerClient.Partner(7L,10001L,"收货单位","ACTIVE","CONSIGNEE",List.of("CONSIGNEE"))));
assertThatThrownBy(() -> new OrderMasterDataGuard(warehouses,partners,"token").validate(10001,request(7L,8L)))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.VALIDATION_FAILED);
verifyNoInteractions(warehouses);
}
private FeignException feign(int status) {
Request request = Request.create(Request.HttpMethod.GET, "http://warehouse-service/internal/test",
Map.of(), null, StandardCharsets.UTF_8, null);
@@ -0,0 +1,29 @@
package com.baiyue.order.service;
import com.baiyue.order.repository.OrderRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class TenantOrderCountServiceTest {
@Test
void returnsZeroForTenantsWithoutOrdersAndDeduplicatesRequestedIds() {
OrderRepository repository = mock(OrderRepository.class);
when(repository.countByTenantIds(List.of(8L, 9L, 8L))).thenReturn(Map.of(8L, 12L));
OrderApplicationService service = new OrderApplicationService();
ReflectionTestUtils.setField(service, "repository", repository);
assertThat(service.countByTenantIds(List.of(8L, 9L, 8L)))
.extracting("tenantId", "orderCount")
.containsExactly(
org.assertj.core.groups.Tuple.tuple(8L, 12L),
org.assertj.core.groups.Tuple.tuple(9L, 0L)
);
}
}
+5
View File
@@ -12,6 +12,11 @@
<artifactId>warehouse-service</artifactId>
<dependencies>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.3.0</version>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId>
<artifactId>baiyue-common-openapi</artifactId>
@@ -6,10 +6,12 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanySaveRequest;
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.service.ExpressCompanyQueryService;
import com.baiyue.warehouse.service.ExpressCompanyImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -19,6 +21,10 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -28,6 +34,11 @@ 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 org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** 平台快递公司管理接口。 */
@Validated
@@ -36,9 +47,11 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/express-companies")
public class PlatformExpressCompanyController {
private final ExpressCompanyQueryService service;
private final ExpressCompanyImportService importService;
public PlatformExpressCompanyController(ExpressCompanyQueryService service) {
public PlatformExpressCompanyController(ExpressCompanyQueryService service, ExpressCompanyImportService importService) {
this.service = service;
this.importService = importService;
}
@GetMapping
@@ -57,6 +70,33 @@ public class PlatformExpressCompanyController {
return ApiResponse.success(service.page(tenantId, query));
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:express:import')")
@Operation(summary = "下载快递公司导入模板", description = "下载当前版本的 XLSX 标准模板")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("express-company-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:express:import')")
@Operation(summary = "批量导入快递公司", description = "使用标准 XLSX 模板整批校验并新增快递公司")
public ApiResponse<ExpressCompanyImportResult> importCompanies(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
}
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('platform:express:list')")
@Operation(summary = "查询快递公司详情", description = "查询目标租户内的快递公司详情")
@@ -7,9 +7,11 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerSaveRequest;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.dto.MasterDataQueries.StatusRequest;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PlatformPartnerQueryService;
import com.baiyue.warehouse.service.PartnerImportService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -28,6 +30,15 @@ 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 org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/** 平台往来单位管理接口。 */
@Validated
@@ -36,9 +47,38 @@ import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/platform/v1/partners")
public class PlatformPartnerController {
private final PlatformPartnerQueryService service;
private final PartnerImportService importService;
public PlatformPartnerController(PlatformPartnerQueryService service) {
public PlatformPartnerController(PlatformPartnerQueryService service, PartnerImportService importService) {
this.service = service;
this.importService = importService;
}
@GetMapping("/import-template")
@PreAuthorize("hasAuthority('platform:counterparty:import')")
@Operation(summary = "下载往来单位导入模板", description = "下载当前版本的 XLSX 标准模板")
public ResponseEntity<byte[]> importTemplate(@AuthenticationPrincipal AuthenticatedUser user) {
requirePlatform(user);
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(ContentDisposition.attachment()
.filename("partner-import-template-v1.xlsx", StandardCharsets.UTF_8).build());
headers.setContentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
return ResponseEntity.ok().headers(headers).body(importService.template());
}
@PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:counterparty:import')")
@Operation(summary = "批量导入往来单位", description = "使用标准 XLSX 模板整批校验并新增往来单位")
public ApiResponse<PartnerImportResult> importPartners(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId,
@RequestPart("file") MultipartFile file) {
requirePlatform(user);
try {
return ApiResponse.success(importService.importFile(tenantId, file.getOriginalFilename(), file.getBytes()));
} catch (IOException exception) {
throw new BizException(ResultCode.BAD_REQUEST, "导入文件读取失败");
}
}
@GetMapping
@@ -8,6 +8,8 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 平台基础资料请求契约。 */
public final class MasterDataQueries {
private MasterDataQueries() {
@@ -63,8 +65,8 @@ public final class MasterDataQueries {
@NotBlank @Size(max = 50) String partnerCode,
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
@NotBlank @Size(max = 128) String partnerName,
@Schema(description = "往来单位类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
@NotBlank @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH)$") String partnerType,
@Schema(description = "兼容旧客户端的单值类型", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String partnerType,
@Schema(description = "联系人", example = "张三")
@NotBlank @Size(max = 64) String contactName,
@Schema(description = "联系电话", example = "13800138000")
@@ -84,7 +86,24 @@ public final class MasterDataQueries {
@Schema(description = "邮政编码", example = "518000")
@Size(max = 20) String postalCode,
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
@NotBlank @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status,
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types,
@Schema(description = "备注") @Size(max = 500) String remark
) {
}
@Schema(name = "PartnerImportError", description = "往来单位导入错误")
public record PartnerImportError(int row, String field, String message) {
}
@Schema(name = "PartnerImportResult", description = "往来单位批量导入结果")
public record PartnerImportResult(
String fileName,
int totalRows,
int importedRows,
boolean success,
List<PartnerImportError> errors
) {
}
@@ -107,6 +126,24 @@ public final class MasterDataQueries {
) {
}
@Schema(name = "ExpressCompanyImportError", description = "快递公司导入错误")
public record ExpressCompanyImportError(
@Schema(description = "数据行号,表头后的第一行数据为 4") int row,
@Schema(description = "字段名称") String field,
@Schema(description = "错误原因") String message
) {
}
@Schema(name = "ExpressCompanyImportResult", description = "快递公司批量导入结果")
public record ExpressCompanyImportResult(
@Schema(description = "原始文件名") String fileName,
@Schema(description = "数据行总数") int totalRows,
@Schema(description = "成功写入行数") int importedRows,
@Schema(description = "是否成功") boolean success,
@Schema(description = "逐行错误") List<ExpressCompanyImportError> errors
) {
}
@Schema(name = "MasterDataStatusRequest", description = "基础资料状态变更请求")
public record StatusRequest(
@Schema(description = "目标状态", allowableValues = {"ACTIVE", "INACTIVE"})
@@ -1,6 +1,7 @@
package com.baiyue.warehouse.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.common.core.model.BaseEntity;
@@ -8,6 +9,8 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/** 仓储域内的租户往来单位配置。 */
@Schema(name = "Partner", description = "租户维度往来单位主数据")
@Getter @Setter
@@ -17,12 +20,17 @@ public class PartnerEntity extends BaseEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id;
@Schema(description = "所属租户 ID", example = "10001")
private Long tenantId;
@Schema(description = "所属业务数据范围 ID,由服务端维护", example = "10001")
private Long dataScopeId;
@Schema(description = "租户内唯一往来单位编码", example = "CUS-001")
private String partnerCode;
@Schema(description = "往来单位名称", example = "深圳示例贸易有限公司")
private String partnerName;
@Schema(description = "类型", example = "CUSTOMER", allowableValues = {"CUSTOMER", "SUPPLIER", "BOTH"})
private String partnerType;
@TableField(exist = false)
@Schema(description = "单位类型集合", allowableValues = {"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
private List<String> types;
@Schema(description = "联系人")
private String contactName;
@Schema(description = "联系电话")
@@ -43,4 +51,6 @@ public class PartnerEntity extends BaseEntity {
private String postalCode;
@Schema(description = "状态", example = "ACTIVE", allowableValues = {"ACTIVE", "INACTIVE"})
private String status;
@Schema(description = "备注")
private String remark;
}
@@ -0,0 +1,12 @@
package com.baiyue.warehouse.entity;
import lombok.Getter;
import lombok.Setter;
/** 往来单位与稳定业务类型的关联。 */
@Getter
@Setter
public class PartnerTypeEntity {
private Long partnerId;
private String partnerType;
}
@@ -1,4 +1,42 @@
package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.warehouse.entity.PartnerEntity;
public interface PartnerMapper extends BaseMapper<PartnerEntity> { }
import com.baiyue.warehouse.entity.PartnerTypeEntity;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.Collection;
import java.util.List;
public interface PartnerMapper extends BaseMapper<PartnerEntity> {
@Select("""
<script>
SELECT partner_id AS partnerId, partner_type AS partnerType
FROM wh_partner_type
WHERE partner_id IN
<foreach collection="partnerIds" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY partner_id, partner_type
</script>
""")
List<PartnerTypeEntity> selectTypes(@Param("partnerIds") Collection<Long> partnerIds);
@Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}")
List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type);
@Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}")
void deleteTypes(@Param("partnerId") long partnerId);
@Insert("""
<script>
INSERT INTO wh_partner_type(partner_id, tenant_id, data_scope_id, partner_type)
VALUES
<foreach collection="types" item="type" separator=",">
(#{partnerId}, #{tenantId}, #{dataScopeId}, #{type})
</foreach>
</script>
""")
void insertTypes(@Param("partnerId") long partnerId, @Param("tenantId") long tenantId,
@Param("dataScopeId") long dataScopeId, @Param("types") Collection<String> types);
}
@@ -44,6 +44,13 @@ public class ExpressCompanyRepository {
.ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0;
}
public boolean existsName(long tenantId, String name, Long excludedId) {
return mapper.selectCount(new LambdaQueryWrapper<ExpressCompanyEntity>()
.eq(ExpressCompanyEntity::getTenantId, tenantId)
.eq(ExpressCompanyEntity::getCompanyName, name)
.ne(excludedId != null, ExpressCompanyEntity::getId, excludedId)) > 0;
}
public void insert(ExpressCompanyEntity entity) {
mapper.insert(entity);
}
@@ -9,29 +9,46 @@ import com.baiyue.warehouse.mapper.PartnerMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
@Repository
public class PartnerRepository {
private final PartnerMapper mapper;
public PartnerRepository(PartnerMapper mapper) { this.mapper = mapper; }
public List<PartnerEntity> activeCandidates(long tenantId, String keyword, String type) {
return mapper.selectList(new LambdaQueryWrapper<PartnerEntity>()
List<Long> typedIds = StringUtils.hasText(type)
? typedIds(tenantId, type)
: java.util.stream.Stream.concat(
typedIds(tenantId, "SHIPPER").stream(), typedIds(tenantId, "CONSIGNEE").stream())
.distinct().toList();
if (typedIds.isEmpty()) return List.of();
List<PartnerEntity> records = mapper.selectList(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getStatus, "ACTIVE")
.eq(StringUtils.hasText(type), PartnerEntity::getPartnerType, type)
.in(PartnerEntity::getId, typedIds)
.and(StringUtils.hasText(keyword), q -> q.like(PartnerEntity::getPartnerCode, keyword)
.or().like(PartnerEntity::getPartnerName, keyword).or().like(PartnerEntity::getContactName, keyword))
.orderByAsc(PartnerEntity::getPartnerName));
hydrateTypes(records);
return records;
}
public PartnerEntity findActive(long tenantId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId).eq(PartnerEntity::getId, id)
.eq(PartnerEntity::getStatus, "ACTIVE"));
hydrateTypes(entity == null ? List.of() : List.of(entity));
return entity;
}
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery request) {
List<Long> typedIds = typedIds(tenantId, request.type());
if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) {
return new PageResult<>(List.of(), request.page(), request.size(), 0, 0);
}
LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(request.type()), PartnerEntity::getPartnerType, request.type())
.in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds)
.eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status())
.and(StringUtils.hasText(request.keyword()), query -> query
.like(PartnerEntity::getPartnerCode, request.keyword())
@@ -40,13 +57,17 @@ public class PartnerRepository {
.or().like(PartnerEntity::getContactPhone, request.keyword()))
.orderByAsc(PartnerEntity::getPartnerName)
.orderByAsc(PartnerEntity::getId);
return PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper));
PageResult<PartnerEntity> result = PageResult.from(mapper.selectPage(new Page<>(request.page(), request.size()), wrapper));
hydrateTypes(result.records());
return result;
}
public PartnerEntity findOwned(long tenantId, long id) {
return mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
PartnerEntity entity = mapper.selectOne(new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId)
.eq(PartnerEntity::getId, id));
hydrateTypes(entity == null ? List.of() : List.of(entity));
return entity;
}
public boolean existsCode(long tenantId, String code, Long excludedId) {
@@ -58,9 +79,40 @@ public class PartnerRepository {
public void insert(PartnerEntity entity) {
mapper.insert(entity);
replaceTypes(entity);
}
public void save(PartnerEntity entity) {
mapper.updateById(entity);
replaceTypes(entity);
}
private List<Long> typedIds(long tenantId, String type) {
return StringUtils.hasText(type) ? mapper.selectPartnerIdsByType(tenantId, type) : List.of();
}
private void replaceTypes(PartnerEntity entity) {
mapper.deleteTypes(entity.getId());
Collection<String> types = entity.getTypes();
if (types != null && !types.isEmpty()) {
mapper.insertTypes(entity.getId(), entity.getTenantId(), entity.getDataScopeId(), types);
}
}
private void hydrateTypes(List<PartnerEntity> records) {
if (records.isEmpty()) return;
Map<Long, List<String>> grouped = new LinkedHashMap<>();
mapper.selectTypes(records.stream().map(PartnerEntity::getId).toList())
.forEach(item -> grouped.computeIfAbsent(item.getPartnerId(), ignored -> new java.util.ArrayList<>())
.add(item.getPartnerType()));
records.forEach(record -> {
List<String> types = grouped.get(record.getId());
record.setTypes(types == null || types.isEmpty() ? legacyTypes(record.getPartnerType()) : List.copyOf(types));
});
}
private static List<String> legacyTypes(String type) {
if ("BOTH".equals(type)) return List.of("CUSTOMER", "SUPPLIER");
return StringUtils.hasText(type) ? List.of(type) : List.of();
}
}
@@ -0,0 +1,193 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportError;
import com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyImportResult;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
/** 标准模板和快递公司批量导入。 */
@Service
public class ExpressCompanyImportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final String[] HEADERS = {
"快递公司编码*", "快递公司名称*", "客服电话", "官方网站", "轨迹查询地址模板", "状态*", "备注"
};
private final ExpressCompanyRepository repository;
public ExpressCompanyImportService(ExpressCompanyRepository repository) {
this.repository = repository;
}
public byte[] template() {
try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("快递公司");
Row version = sheet.createRow(0);
version.createCell(0).setCellValue("模板版本");
version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1);
instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue("仅新增;编码、名称、状态必填;状态填写 ACTIVE 或 INACTIVE");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) header.createCell(i).setCellValue(HEADERS[i]);
for (int i = 0; i < HEADERS.length; i++) sheet.autoSizeColumn(i);
workbook.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("生成快递公司导入模板失败", exception);
}
}
@Transactional
@BusinessAudit(event = "EXPRESS_COMPANY_IMPORT", resourceType = "EXPRESS_COMPANY_IMPORT", description = "批量导入快递公司", captureBefore = false)
public ExpressCompanyImportResult importFile(long tenantId, String fileName, byte[] content) {
String safeFileName = fileName == null || fileName.isBlank() ? "未命名文件.xlsx" : fileName;
if (content == null || content.length == 0) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能为空"));
}
if (content.length > MAX_FILE_BYTES) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "文件不能超过 5MB"));
}
if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "文件", "只支持 .xlsx 文件"));
}
List<ImportRow> rows = new ArrayList<>();
List<ExpressCompanyImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) {
return failure(safeFileName, 0, new ExpressCompanyImportError(0, "模板", "缺少快递公司工作表"));
}
XSSFSheet sheet = workbook.getSheetAt(0);
DataFormatter formatter = new DataFormatter();
if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter))
|| !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) {
errors.add(new ExpressCompanyImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) {
if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new ExpressCompanyImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”"));
}
}
if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, 0, 0, false, errors);
for (int rowIndex = 3; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (isBlank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) {
errors.add(new ExpressCompanyImportError(rowIndex + 1, "文件", "单次最多导入 1000 行"));
break;
}
ImportRow importRow = new ImportRow(rowIndex + 1,
value(row, 0, formatter), value(row, 1, formatter), value(row, 2, formatter),
value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter), value(row, 6, formatter));
rows.add(importRow);
}
} catch (IOException | RuntimeException exception) {
return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "文件", "文件格式无法读取,请使用标准 XLSX 模板"));
}
if (rows.isEmpty() && errors.isEmpty()) {
errors.add(new ExpressCompanyImportError(0, "文件", "文件中没有可导入的数据行"));
}
for (ImportRow row : rows) validate(tenantId, row, rows, errors);
if (!errors.isEmpty()) return new ExpressCompanyImportResult(safeFileName, rows.size(), 0, false, errors);
try {
for (ImportRow row : rows) repository.insert(toEntity(tenantId, row));
} catch (DataIntegrityViolationException exception) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return failure(safeFileName, rows.size(), new ExpressCompanyImportError(0, "编码或名称", "与现有快递公司重复,整批数据未写入"));
}
return new ExpressCompanyImportResult(safeFileName, rows.size(), rows.size(), true, List.of());
}
private void validate(long tenantId, ImportRow row, List<ImportRow> rows, List<ExpressCompanyImportError> errors) {
if (row.code.isBlank()) errors.add(error(row, "快递公司编码", "不能为空"));
if (row.code.length() > 50) errors.add(error(row, "快递公司编码", "不能超过 50 个字符"));
if (row.name.isBlank()) errors.add(error(row, "快递公司名称", "不能为空"));
if (row.name.length() > 128) errors.add(error(row, "快递公司名称", "不能超过 128 个字符"));
if (row.phone.length() > 32) errors.add(error(row, "客服电话", "不能超过 32 个字符"));
if (row.website.length() > 255 || !validUrl(row.website)) errors.add(error(row, "官方网站", "必须是 http:// 或 https:// 开头的网址"));
if (row.trackingTemplate.length() > 500) errors.add(error(row, "轨迹查询地址模板", "不能超过 500 个字符"));
if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE"));
if (row.remark.length() > 500) errors.add(error(row, "备注", "不能超过 500 个字符"));
for (ImportRow previous : rows) {
if (previous == row) break;
if (!row.code.isBlank() && previous.code.equalsIgnoreCase(row.code)) {
errors.add(error(row, "快递公司编码", "与文件内其他行重复"));
}
if (!row.name.isBlank() && previous.name.equalsIgnoreCase(row.name)) {
errors.add(error(row, "快递公司名称", "与文件内其他行重复"));
}
}
if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) errors.add(error(row, "快递公司编码", "与现有档案重复"));
if (!row.name.isBlank() && repository.existsName(tenantId, row.name, null)) errors.add(error(row, "快递公司名称", "与现有档案重复"));
}
private static ExpressCompanyEntity toEntity(long tenantId, ImportRow row) {
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setTenantId(tenantId);
entity.setCompanyCode(row.code);
entity.setCompanyName(row.name);
entity.setServicePhone(nullable(row.phone));
entity.setWebsite(nullable(row.website));
entity.setTrackingUrlTemplate(nullable(row.trackingTemplate));
entity.setStatus(row.status);
entity.setRemark(nullable(row.remark));
return entity;
}
private static String value(Row row, int index, DataFormatter formatter) {
if (row == null) return "";
Cell cell = row.getCell(index);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private static boolean isBlank(Row row, DataFormatter formatter) {
if (row == null) return true;
for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false;
return true;
}
private static boolean validUrl(String value) {
if (value.isBlank()) return true;
try {
URI uri = new URI(value);
return ("http".equalsIgnoreCase(uri.getScheme()) || "https".equalsIgnoreCase(uri.getScheme())) && uri.getHost() != null;
} catch (URISyntaxException exception) {
return false;
}
}
private static String nullable(String value) { return value.isBlank() ? null : value; }
private static ExpressCompanyImportError error(ImportRow row, String field, String message) {
return new ExpressCompanyImportError(row.row, field, message);
}
private static ExpressCompanyImportResult failure(String fileName, int rows, ExpressCompanyImportError error) {
return new ExpressCompanyImportResult(fileName, rows, 0, false, List.of(error));
}
private record ImportRow(int row, String code, String name, String phone, String website,
String trackingTemplate, String status, String remark) { }
}
@@ -33,13 +33,14 @@ public class ExpressCompanyQueryService {
@BusinessAudit(event = "EXPRESS_COMPANY_CREATE", resourceType = "EXPRESS_COMPANY", description = "新增快递公司", captureBefore = false)
public Long create(long tenantId, ExpressCompanySaveRequest request) {
ensureCodeUnique(tenantId, request.companyCode(), null);
ensureNameUnique(tenantId, request.companyName(), null);
ExpressCompanyEntity entity = new ExpressCompanyEntity();
entity.setTenantId(tenantId);
copy(entity, request);
try {
repository.insert(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在");
}
return entity.getId();
}
@@ -49,11 +50,12 @@ public class ExpressCompanyQueryService {
public void update(long tenantId, long id, ExpressCompanySaveRequest request) {
ExpressCompanyEntity entity = owned(tenantId, id);
ensureCodeUnique(tenantId, request.companyCode(), id);
ensureNameUnique(tenantId, request.companyName(), id);
copy(entity, request);
try {
repository.save(entity);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "快递公司编码已存在");
throw new BizException(ResultCode.CONFLICT, "快递公司编码或名称已存在");
}
}
@@ -80,6 +82,12 @@ public class ExpressCompanyQueryService {
}
}
private void ensureNameUnique(long tenantId, String name, Long excludedId) {
if (repository.existsName(tenantId, name, excludedId)) {
throw new BizException(ResultCode.CONFLICT, "快递公司名称已存在");
}
}
private static void requireStatus(String status) {
if (!"ACTIVE".equals(status) && !"INACTIVE".equals(status)) {
throw new BizException(ResultCode.BAD_REQUEST, "状态只能设置为 ACTIVE 或 INACTIVE");
@@ -0,0 +1,218 @@
package com.baiyue.warehouse.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportError;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.repository.PartnerRepository;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFSheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/** 往来单位标准模板生成与原子批量导入。 */
@Service
public class PartnerImportService {
private static final int MAX_FILE_BYTES = 5 * 1024 * 1024;
private static final int MAX_ROWS = 1000;
private static final String TEMPLATE_VERSION = "1";
private static final Set<String> ALLOWED_TYPES = Set.of(
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE");
private static final Pattern EMAIL = Pattern.compile("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$");
private static final String[] HEADERS = {
"单位编码*", "单位名称*", "单位类型*", "联系人*", "联系电话*", "联系邮箱",
"国家二字码*", "省/州", "城市*", "区/县", "详细地址*", "邮政编码", "状态*", "备注"
};
private final PartnerRepository repository;
public PartnerImportService(PartnerRepository repository) {
this.repository = repository;
}
public byte[] template() {
try (XSSFWorkbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) {
XSSFSheet sheet = workbook.createSheet("往来单位");
Row version = sheet.createRow(0);
version.createCell(0).setCellValue("模板版本");
version.createCell(1).setCellValue(TEMPLATE_VERSION);
Row instruction = sheet.createRow(1);
instruction.createCell(0).setCellValue("说明");
instruction.createCell(1).setCellValue(
"仅新增;类型可用中文逗号或英文逗号分隔:CUSTOMER、SUPPLIER、CARRIER、CUSTOMS_BROKER、SHIPPER、CONSIGNEE;状态为 ACTIVE 或 INACTIVE");
Row header = sheet.createRow(2);
for (int i = 0; i < HEADERS.length; i++) {
header.createCell(i).setCellValue(HEADERS[i]);
sheet.setColumnWidth(i, Math.min(48, Math.max(14, HEADERS[i].length() * 2 + 4)) * 256);
}
workbook.write(output);
return output.toByteArray();
} catch (IOException exception) {
throw new IllegalStateException("生成往来单位导入模板失败", exception);
}
}
@Transactional
@BusinessAudit(event = "PARTNER_IMPORT", resourceType = "PARTNER_IMPORT", description = "批量导入往来单位", captureBefore = false)
public PartnerImportResult importFile(long tenantId, String fileName, byte[] content) {
String safeFileName = safeFileName(fileName);
if (content == null || content.length == 0) return failure(safeFileName, 0, 0, "文件", "文件不能为空");
if (content.length > MAX_FILE_BYTES) return failure(safeFileName, 0, 0, "文件", "文件不能超过 5MB");
if (!safeFileName.toLowerCase(Locale.ROOT).endsWith(".xlsx")) {
return failure(safeFileName, 0, 0, "文件", "只支持 .xlsx 文件");
}
List<ImportRow> rows = new ArrayList<>();
List<PartnerImportError> errors = new ArrayList<>();
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
if (workbook.getNumberOfSheets() == 0) return failure(safeFileName, 0, 0, "模板", "缺少往来单位工作表");
XSSFSheet sheet = workbook.getSheetAt(0);
DataFormatter formatter = new DataFormatter();
if (!"模板版本".equals(value(sheet.getRow(0), 0, formatter))
|| !TEMPLATE_VERSION.equals(value(sheet.getRow(0), 1, formatter))) {
errors.add(new PartnerImportError(1, "模板版本", "模板版本不匹配,请下载最新模板"));
}
for (int i = 0; i < HEADERS.length; i++) {
if (!HEADERS[i].equals(value(sheet.getRow(2), i, formatter))) {
errors.add(new PartnerImportError(3, "表头", "第 " + (i + 1) + " 列应为“" + HEADERS[i] + "”"));
}
}
if (!errors.isEmpty()) return result(safeFileName, 0, 0, errors);
for (int index = 3; index <= sheet.getLastRowNum(); index++) {
Row row = sheet.getRow(index);
if (isBlank(row, formatter)) continue;
if (rows.size() >= MAX_ROWS) {
errors.add(new PartnerImportError(index + 1, "文件", "单次最多导入 1000 行"));
break;
}
ImportRow item = new ImportRow(index + 1,
value(row, 0, formatter), value(row, 1, formatter), parseTypes(value(row, 2, formatter)),
value(row, 3, formatter), value(row, 4, formatter), value(row, 5, formatter),
value(row, 6, formatter).toUpperCase(Locale.ROOT), value(row, 7, formatter),
value(row, 8, formatter), value(row, 9, formatter), value(row, 10, formatter),
value(row, 11, formatter), value(row, 12, formatter).toUpperCase(Locale.ROOT), value(row, 13, formatter));
validate(tenantId, item, rows, errors);
rows.add(item);
}
} catch (IOException | RuntimeException exception) {
return failure(safeFileName, rows.size(), 0, "文件", "文件格式无法读取,请使用标准 XLSX 模板");
}
if (rows.isEmpty() && errors.isEmpty()) errors.add(new PartnerImportError(0, "文件", "文件中没有可导入的数据行"));
if (!errors.isEmpty()) return result(safeFileName, rows.size(), 0, errors);
try {
for (ImportRow row : rows) repository.insert(toEntity(tenantId, row));
} catch (DataIntegrityViolationException exception) {
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return failure(safeFileName, rows.size(), 0, "单位编码", "与现有档案重复,整批数据未写入");
}
return new PartnerImportResult(safeFileName, rows.size(), rows.size(), true, List.of());
}
private void validate(long tenantId, ImportRow row, List<ImportRow> previousRows, List<PartnerImportError> errors) {
required(row, errors, "单位编码", row.code, 50);
required(row, errors, "单位名称", row.name, 128);
if (row.types.isEmpty()) errors.add(error(row, "单位类型", "至少填写一个类型"));
row.types.stream().filter(type -> !ALLOWED_TYPES.contains(type))
.forEach(type -> errors.add(error(row, "单位类型", "不支持类型 " + type)));
required(row, errors, "联系人", row.contactName, 64);
required(row, errors, "联系电话", row.contactPhone, 32);
if (row.email.length() > 128 || (!row.email.isBlank() && !EMAIL.matcher(row.email).matches())) {
errors.add(error(row, "联系邮箱", "请输入有效的邮箱地址,且不超过 128 个字符"));
}
if (!row.countryCode.matches("^[A-Z]{2}$")) errors.add(error(row, "国家二字码", "必须是两个英文字母"));
optionalLength(row, errors, "省/州", row.province, 64);
required(row, errors, "城市", row.city, 64);
optionalLength(row, errors, "区/县", row.district, 64);
required(row, errors, "详细地址", row.address, 255);
optionalLength(row, errors, "邮政编码", row.postalCode, 20);
if (!"ACTIVE".equals(row.status) && !"INACTIVE".equals(row.status)) errors.add(error(row, "状态", "只能填写 ACTIVE 或 INACTIVE"));
optionalLength(row, errors, "备注", row.remark, 500);
if (previousRows.stream().anyMatch(previous -> previous.code.equalsIgnoreCase(row.code))) {
errors.add(error(row, "单位编码", "与文件内其他行重复"));
}
if (!row.code.isBlank() && repository.existsCode(tenantId, row.code, null)) {
errors.add(error(row, "单位编码", "与现有档案重复"));
}
}
private static PartnerEntity toEntity(long tenantId, ImportRow row) {
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
entity.setPartnerCode(row.code);
entity.setPartnerName(row.name);
entity.setTypes(row.types);
entity.setPartnerType(row.types.contains("CUSTOMER") && row.types.contains("SUPPLIER") ? "BOTH" : row.types.get(0));
entity.setContactName(row.contactName);
entity.setContactPhone(row.contactPhone);
entity.setContactEmail(nullable(row.email));
entity.setCountryCode(row.countryCode);
entity.setProvince(nullable(row.province));
entity.setCity(row.city);
entity.setDistrict(nullable(row.district));
entity.setAddressLine(row.address);
entity.setPostalCode(nullable(row.postalCode));
entity.setStatus(row.status);
entity.setRemark(nullable(row.remark));
return entity;
}
private static List<String> parseTypes(String value) {
LinkedHashSet<String> types = new LinkedHashSet<>();
for (String type : value.toUpperCase(Locale.ROOT).split("[,,、]")) if (!type.isBlank()) types.add(type.trim());
return List.copyOf(types);
}
private static void required(ImportRow row, List<PartnerImportError> errors, String field, String value, int max) {
if (value.isBlank()) errors.add(error(row, field, "不能为空"));
else if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符"));
}
private static void optionalLength(ImportRow row, List<PartnerImportError> errors, String field, String value, int max) {
if (value.length() > max) errors.add(error(row, field, "不能超过 " + max + " 个字符"));
}
private static String value(Row row, int index, DataFormatter formatter) {
if (row == null) return "";
Cell cell = row.getCell(index);
return cell == null ? "" : formatter.formatCellValue(cell).trim();
}
private static boolean isBlank(Row row, DataFormatter formatter) {
if (row == null) return true;
for (int i = 0; i < HEADERS.length; i++) if (!value(row, i, formatter).isBlank()) return false;
return true;
}
private static String nullable(String value) { return value.isBlank() ? null : value; }
private static String safeFileName(String value) {
if (value == null || value.isBlank()) return "未命名文件.xlsx";
String normalized = value.replace('\\', '/');
return normalized.substring(normalized.lastIndexOf('/') + 1);
}
private static PartnerImportError error(ImportRow row, String field, String message) {
return new PartnerImportError(row.row, field, message);
}
private static PartnerImportResult failure(String fileName, int total, int imported, String field, String message) {
return result(fileName, total, imported, List.of(new PartnerImportError(0, field, message)));
}
private static PartnerImportResult result(String fileName, int total, int imported, List<PartnerImportError> errors) {
return new PartnerImportResult(fileName, total, imported, errors.isEmpty(), errors);
}
private record ImportRow(int row, String code, String name, List<String> types, String contactName,
String contactPhone, String email, String countryCode, String province, String city,
String district, String address, String postalCode, String status, String remark) { }
}
@@ -12,6 +12,9 @@ public class PartnerQueryService {
private final PartnerRepository repository;
public PartnerQueryService(PartnerRepository repository) { this.repository = repository; }
public List<PartnerEntity> candidates(long tenantId, String keyword, String type) {
if (type != null && !type.isBlank() && !"SHIPPER".equals(type) && !"CONSIGNEE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST, "候选类型只能是 SHIPPER 或 CONSIGNEE");
}
return repository.activeCandidates(tenantId, keyword, type);
}
public PartnerEntity active(long tenantId, long id) {
@@ -12,6 +12,10 @@ import org.springframework.stereotype.Service;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** 平台往来单位管理服务。 */
@Service
public class PlatformPartnerQueryService {
@@ -35,6 +39,7 @@ public class PlatformPartnerQueryService {
ensureCodeUnique(tenantId, request.partnerCode(), null);
PartnerEntity entity = new PartnerEntity();
entity.setTenantId(tenantId);
entity.setDataScopeId(tenantId);
copy(entity, request);
try {
repository.insert(entity);
@@ -87,9 +92,11 @@ public class PlatformPartnerQueryService {
}
private static void copy(PartnerEntity entity, PartnerSaveRequest request) {
List<String> types = normalizeTypes(request.types(), request.partnerType());
entity.setPartnerCode(request.partnerCode());
entity.setPartnerName(request.partnerName());
entity.setPartnerType(request.partnerType());
entity.setTypes(types);
entity.setPartnerType(legacyType(types));
entity.setContactName(request.contactName());
entity.setContactPhone(request.contactPhone());
entity.setContactEmail(request.contactEmail());
@@ -100,5 +107,26 @@ public class PlatformPartnerQueryService {
entity.setAddressLine(request.addressLine());
entity.setPostalCode(request.postalCode());
entity.setStatus(request.status());
entity.setRemark(request.remark());
}
static List<String> normalizeTypes(List<String> requested, String legacyType) {
Set<String> normalized = new LinkedHashSet<>();
if (requested != null) requested.forEach(type -> {
if (type != null && !type.isBlank()) normalized.add(type);
});
if (normalized.isEmpty() && "BOTH".equals(legacyType)) {
normalized.add("CUSTOMER");
normalized.add("SUPPLIER");
} else if (normalized.isEmpty() && legacyType != null && !legacyType.isBlank()) {
normalized.add(legacyType);
}
if (normalized.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "请至少选择一个单位类型");
return List.copyOf(normalized);
}
private static String legacyType(List<String> types) {
if (types.contains("CUSTOMER") && types.contains("SUPPLIER")) return "BOTH";
return types.get(0);
}
}
@@ -16,7 +16,8 @@ public class WarehouseSchemaReadinessService {
"SELECT id, tenant_id, warehouse_code, warehouse_name, warehouse_type, country_code, timezone, status, deleted FROM wh_warehouse LIMIT 0",
"SELECT id, tenant_id, warehouse_id, address_type, country_code, city, address_line1, deleted FROM wh_warehouse_address LIMIT 0",
"SELECT id, tenant_id, warehouse_id, capability_code, capability_name, enabled, deleted FROM wh_service_capability LIMIT 0",
"SELECT id, tenant_id, partner_code, partner_name, partner_type, status, deleted FROM wh_partner LIMIT 0",
"SELECT id, tenant_id, data_scope_id, partner_code, partner_name, partner_type, status, remark, deleted FROM wh_partner LIMIT 0",
"SELECT partner_id, tenant_id, data_scope_id, partner_type FROM wh_partner_type LIMIT 0",
"SELECT id, tenant_id, company_code, company_name, status, deleted FROM wh_express_company LIMIT 0"
);
@@ -145,3 +145,6 @@ PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_code') = 0,
'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
SET @sql = IF((SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = @schema_name AND table_name = 'wh_express_company' AND index_name = 'uk_wh_express_tenant_name') = 0,
'ALTER TABLE wh_express_company ADD UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted)', 'SELECT 1');
PREPARE statement FROM @sql; EXECUTE statement; DEALLOCATE PREPARE statement;
@@ -19,5 +19,6 @@ CREATE TABLE IF NOT EXISTS wh_express_company (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_wh_express_tenant_code (tenant_id, company_code, deleted),
UNIQUE KEY uk_wh_express_tenant_name (tenant_id, company_name, deleted),
KEY idx_wh_express_tenant_status (tenant_id, status, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户维度快递公司主数据';
@@ -2,19 +2,30 @@ SET NAMES utf8mb4;
USE baiyue_warehouse;
CREATE TABLE IF NOT EXISTS wh_partner (
id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL COMMENT '所属业务数据范围',
partner_code VARCHAR(50) NOT NULL, partner_name VARCHAR(128) NOT NULL,
partner_type VARCHAR(20) NOT NULL COMMENT 'CUSTOMER、SUPPLIER或BOTH',
contact_name VARCHAR(64) NOT NULL, contact_phone VARCHAR(32) NOT NULL,
contact_email VARCHAR(128) NULL, country_code CHAR(2) NOT NULL,
province VARCHAR(64) NULL, city VARCHAR(64) NOT NULL, district VARCHAR(64) NULL,
address_line VARCHAR(255) NOT NULL, postal_code VARCHAR(20) NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT UNSIGNED NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', remark VARCHAR(500) NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_by BIGINT UNSIGNED 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), UNIQUE KEY uk_wh_partner_tenant_code(tenant_id,partner_code,deleted),
KEY idx_wh_partner_tenant_status(tenant_id,status,deleted)
) ENGINE=InnoDB COMMENT='仓储域内的租户往来单位配置';
INSERT INTO wh_partner(id,tenant_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
CREATE TABLE IF NOT EXISTS wh_partner_type (
partner_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
data_scope_id BIGINT UNSIGNED NOT NULL, partner_type VARCHAR(32) NOT NULL,
PRIMARY KEY(partner_id,partner_type),
KEY idx_wh_partner_type_scope(tenant_id,data_scope_id,partner_type,partner_id)
) ENGINE=InnoDB COMMENT='往来单位稳定业务类型';
INSERT INTO wh_partner(id,tenant_id,data_scope_id,partner_code,partner_name,partner_type,contact_name,contact_phone,contact_email,country_code,province,city,district,address_line,postal_code,status)
VALUES(191000000000000001,10001,10001,'CUS-001','深圳示例贸易有限公司','CUSTOMER','张三','13800138000','contact@example.com','CN','广东省','深圳市','宝安区','航城街道示例路1号','518000','ACTIVE')
ON DUPLICATE KEY UPDATE partner_name=VALUES(partner_name),status='ACTIVE';
INSERT INTO wh_partner_type(partner_id,tenant_id,data_scope_id,partner_type)
VALUES(191000000000000001,10001,10001,'CUSTOMER')
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id),data_scope_id=VALUES(data_scope_id);
@@ -24,6 +24,8 @@ class PlatformMasterDataControllerContractTest {
assertMethod(PlatformPartnerController.class, "create", PostMapping.class, "platform:counterparty:create");
assertMethod(PlatformPartnerController.class, "update", PutMapping.class, "platform:counterparty:edit");
assertMethod(PlatformPartnerController.class, "changeStatus", PatchMapping.class, "platform:counterparty:status");
assertMethod(PlatformPartnerController.class, "importTemplate", GetMapping.class, "platform:counterparty:import");
assertMethod(PlatformPartnerController.class, "importPartners", PostMapping.class, "platform:counterparty:import");
}
@Test
@@ -34,6 +36,8 @@ class PlatformMasterDataControllerContractTest {
assertMethod(PlatformExpressCompanyController.class, "create", PostMapping.class, "platform:express:create");
assertMethod(PlatformExpressCompanyController.class, "update", PutMapping.class, "platform:express:edit");
assertMethod(PlatformExpressCompanyController.class, "changeStatus", PatchMapping.class, "platform:express:status");
assertMethod(PlatformExpressCompanyController.class, "importTemplate", GetMapping.class, "platform:express:import");
assertMethod(PlatformExpressCompanyController.class, "importCompanies", PostMapping.class, "platform:express:import");
}
private static void assertContract(Class<?> controller, String authority, String path) throws Exception {
@@ -26,10 +26,28 @@ class PartnerRepositoryTest {
@DisplayName("候选查询强制租户和启用状态")
void shouldEnforceTenantAndActiveStatusForCandidateQuery() {
PartnerMapper mapper=mock(PartnerMapper.class); when(mapper.selectList(any())).thenReturn(List.of());
new PartnerRepository(mapper).activeCandidates(10001,"示例","CUSTOMER");
when(mapper.selectPartnerIdsByType(10001,"SHIPPER")).thenReturn(List.of(1L));
new PartnerRepository(mapper).activeCandidates(10001,"示例","SHIPPER");
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> c=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(c.capture()); c.getValue().getSqlSegment();
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE","CUSTOMER");
assertThat(c.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L);
verify(mapper).selectPartnerIdsByType(10001L, "SHIPPER");
}
@Test
@DisplayName("默认候选只包含发货单位和收货单位")
void shouldLimitDefaultCandidatesToOrderPartyTypes() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPartnerIdsByType(10001L,"SHIPPER")).thenReturn(List.of(1L));
when(mapper.selectPartnerIdsByType(10001L,"CONSIGNEE")).thenReturn(List.of(2L,1L));
when(mapper.selectList(any())).thenReturn(List.of());
new PartnerRepository(mapper).activeCandidates(10001L,null,null);
@SuppressWarnings("unchecked") ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(mapper).selectList(wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values()).contains(10001L,"ACTIVE",1L,2L);
}
@Test
@DisplayName("详情查询强制租户和启用状态")
@@ -45,6 +63,7 @@ class PartnerRepositoryTest {
@DisplayName("平台分页强制租户并支持筛选")
void shouldEnforceTenantAndSupportFilteringForPlatformPage() {
PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L));
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
new PartnerRepository(mapper).page(10001L,
@@ -55,6 +74,6 @@ class PartnerRepositoryTest {
verify(mapper).selectPage(any(Page.class), wrapper.capture());
wrapper.getValue().getSqlSegment();
assertThat(wrapper.getValue().getParamNameValuePairs().values())
.contains(10001L, "CUSTOMER", "ACTIVE", "%示例%");
.contains(10001L, 9L, "ACTIVE", "%示例%");
}
}
@@ -28,9 +28,11 @@ class WarehouseSchemaContractTest {
@DisplayName("往来单位表包含实体全部持久化字段")
void shouldContainAllPersistentFieldsInPartnerTable() throws IOException {
String sql = read("warehouse-partner.sql");
assertColumns(sql, "wh_partner", "partner_code", "partner_name", "partner_type", "contact_name",
assertColumns(sql, "wh_partner", "data_scope_id", "partner_code", "partner_name", "partner_type", "contact_name",
"contact_phone", "contact_email", "country_code", "province", "city", "district", "address_line",
"postal_code", "status");
"postal_code", "status", "remark");
assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_partner_type")
.contains("idx_wh_partner_type_scope");
assertBaseColumns(sql);
}
@@ -0,0 +1,71 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.repository.ExpressCompanyRepository;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DisplayName("快递公司批量导入")
class ExpressCompanyImportServiceTest {
@Test
@DisplayName("标准模板通过整批校验后写入全部数据")
void shouldImportValidWorkbookAfterFullValidation() throws Exception {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyImportService service = new ExpressCompanyImportService(repository);
byte[] workbook = withRows(service.template(), new String[][] {
{"SF", "顺丰速运", "95338", "https://www.sf-express.com", "https://example.com/{trackingNo}", "ACTIVE", "国内件"}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(ExpressCompanyEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getTenantId()).isEqualTo(10001L);
assertThat(captor.getValue().getCompanyCode()).isEqualTo("SF");
}
@Test
@DisplayName("文件内重复名称时整批拒绝且不写入")
void shouldRejectWholeWorkbookWhenAnyRowIsInvalid() throws Exception {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
ExpressCompanyImportService service = new ExpressCompanyImportService(repository);
byte[] workbook = withRows(service.template(), new String[][] {
{"SF", "顺丰速运", "", "", "", "ACTIVE", ""},
{"SF2", "顺丰速运", "", "", "", "ACTIVE", ""}
});
var result = service.importFile(10001L, "express.xlsx", workbook);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().contains("名称"));
verify(repository, never()).insert(any());
}
private static byte[] withRows(byte[] template, String[][] rows) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
var sheet = workbook.getSheetAt(0);
for (int rowIndex = 0; rowIndex < rows.length; rowIndex++) {
var row = sheet.createRow(rowIndex + 3);
for (int column = 0; column < rows[rowIndex].length; column++) {
row.createCell(column).setCellValue(rows[rowIndex][column]);
}
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -0,0 +1,75 @@
package com.baiyue.warehouse.service;
import com.baiyue.warehouse.dto.MasterDataQueries.PartnerImportResult;
import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.repository.PartnerRepository;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@DisplayName("往来单位批量导入")
class PartnerImportServiceTest {
@Test
@DisplayName("标准模板支持多类型并写入服务端数据范围")
void shouldImportValidMultiTypeRow() throws Exception {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerImportService service = new PartnerImportService(repository);
byte[] file = withRows(service.template(), new String[][] {
{"CUS-100", "测试单位", "CUSTOMER,SHIPPER", "张三", "13800138000", "test@example.com",
"CN", "广东省", "深圳市", "南山区", "测试路 1 号", "518000", "ACTIVE", "测试备注"}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
assertThat(result.success()).isTrue();
assertThat(result.importedRows()).isEqualTo(1);
var captor = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(captor.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
assertThat(captor.getValue().getRemark()).isEqualTo("测试备注");
}
@Test
@DisplayName("文件内任一错误时整批零写入并返回行号")
void shouldRejectWholeBatchWhenAnyRowIsInvalid() throws Exception {
PartnerRepository repository = mock(PartnerRepository.class);
PartnerImportService service = new PartnerImportService(repository);
byte[] file = withRows(service.template(), new String[][] {
{"CUS-100", "测试单位一", "CUSTOMER", "张三", "13800138000", "", "CN", "", "深圳市", "", "测试路 1 号", "", "ACTIVE", ""},
{"CUS-100", "测试单位二", "UNKNOWN", "李四", "13900139000", "bad-email", "C", "", "", "", "", "", "ENABLED", ""}
});
PartnerImportResult result = service.importFile(10001L, "partners.xlsx", file);
assertThat(result.success()).isFalse();
assertThat(result.importedRows()).isZero();
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位编码"));
assertThat(result.errors()).anyMatch(error -> error.row() == 5 && error.field().equals("单位类型"));
verify(repository, never()).insert(any());
}
private static byte[] withRows(byte[] template, String[][] values) throws Exception {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(template));
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
for (int rowIndex = 0; rowIndex < values.length; rowIndex++) {
Row row = workbook.getSheetAt(0).createRow(rowIndex + 3);
for (int column = 0; column < values[rowIndex].length; column++) {
row.createCell(column).setCellValue(values[rowIndex][column]);
}
}
workbook.write(output);
return output.toByteArray();
}
}
}
@@ -12,6 +12,11 @@ import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PartnerQueryServiceTest {
@Test void unsupportedCandidateTypeReturnsBadRequest() {
PartnerRepository repository = mock(PartnerRepository.class);
assertThatThrownBy(() -> new PartnerQueryService(repository).candidates(10001L, null, "CUSTOMER"))
.isInstanceOf(BizException.class).hasMessageContaining("SHIPPER");
}
@Test void missingOrCrossTenantPartnerReturnsNotFound() {
PartnerRepository repository = mock(PartnerRepository.class);
@@ -72,8 +72,11 @@ class PlatformMasterDataManagementServiceTest {
org.mockito.ArgumentCaptor<PartnerEntity> entity = org.mockito.ArgumentCaptor.forClass(PartnerEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getTenantId()).isEqualTo(10001L);
assertThat(entity.getValue().getDataScopeId()).isEqualTo(10001L);
assertThat(entity.getValue().getPartnerCode()).isEqualTo("CUS-001");
assertThat(entity.getValue().getPartnerName()).isEqualTo("示例客户");
assertThat(entity.getValue().getTypes()).containsExactly("CUSTOMER", "SHIPPER");
assertThat(entity.getValue().getRemark()).isEqualTo("测试备注");
}
@Test
@@ -132,6 +135,18 @@ class PlatformMasterDataManagementServiceTest {
verify(repository, never()).insert(any());
}
@Test
@DisplayName("快递公司名称在租户内必须唯一")
void shouldRequireUniqueExpressCompanyNameWithinTenant() {
ExpressCompanyRepository repository = mock(ExpressCompanyRepository.class);
when(repository.existsName(10001L, "顺丰速运", null)).thenReturn(true);
ExpressCompanyQueryService service = new ExpressCompanyQueryService(repository);
assertThatThrownBy(() -> service.create(10001L, expressRequest()))
.isInstanceOf(BizException.class).hasMessageContaining("名称");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("快递公司并发重复编码统一返回业务冲突")
void shouldReturnBusinessConflictForConcurrentDuplicateExpressCompanyCode() {
@@ -200,7 +215,8 @@ class PlatformMasterDataManagementServiceTest {
private static PartnerSaveRequest partnerRequest() {
return new PartnerSaveRequest("CUS-001", "示例客户", "CUSTOMER", "张三", "13800138000",
"test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE");
"test@example.com", "CN", "广东省", "深圳市", "南山区", "示例地址", "518000", "ACTIVE",
java.util.List.of("CUSTOMER", "SHIPPER"), "测试备注");
}
private static ExpressCompanySaveRequest expressRequest() {