优化
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+41
-1
@@ -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 = "查询目标租户内的快递公司详情")
|
||||
|
||||
+41
-1
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+193
@@ -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) { }
|
||||
}
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+29
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+4
@@ -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", "%示例%");
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+71
@@ -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);
|
||||
|
||||
|
||||
+17
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user