暂存提交
This commit is contained in:
+5
-1
@@ -27,6 +27,8 @@
|
||||
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料和 RBAC 测试 |
|
||||
|
||||
## 修改与验证
|
||||
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
|
||||
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
|
||||
|
||||
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
|
||||
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
|
||||
@@ -34,5 +36,7 @@
|
||||
- 平台企业客户分页仅查询商户租户;分页参数类型错误返回 400,范围或状态约束错误返回 422。
|
||||
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
|
||||
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
|
||||
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。
|
||||
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
|
||||
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
|
||||
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
|
||||
|
||||
@@ -25,18 +25,17 @@ import java.util.Set;
|
||||
* 因此角色被停用或权限被回收后,下一次查询会立即生效。</p>
|
||||
*/
|
||||
@RestController
|
||||
@Tag(name = "Authorization / 当前用户授权", description = "Returns current member effective permission codes.")
|
||||
@Tag(name = "当前用户授权", description = "返回当前成员的有效权限编码。")
|
||||
public class AuthorizationController {
|
||||
@Resource
|
||||
private AuthorizationSnapshotService authorizationSnapshotService;
|
||||
|
||||
@GetMapping("/api/{app}/v1/iam/authorization/permissions")
|
||||
@Operation(summary = "Get current effective permissions / 查询当前用户有效权限",
|
||||
description = "Does not require role-management permissions. / 不依赖角色管理权限。")
|
||||
@Operation(summary = "查询当前用户有效权限", description = "不依赖角色管理权限。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Permissions returned"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "Authentication required"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Wrong application scope or inactive identity")
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "权限查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "应用权限域错误或身份已停用")
|
||||
})
|
||||
public ApiResponse<Set<String>> permissions(@PathVariable String app,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
@@ -49,7 +48,7 @@ public class AuthorizationController {
|
||||
return switch (app) {
|
||||
case "platform" -> "PLATFORM";
|
||||
case "merchant" -> "MERCHANT";
|
||||
default -> throw new BizException(ResultCode.BAD_REQUEST, "Unsupported application scope / 不支持的应用端");
|
||||
default -> throw new BizException(ResultCode.BAD_REQUEST, "不支持的应用端");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -38,7 +40,7 @@ public class PlatformTenantController {
|
||||
|
||||
@GetMapping("/tenants")
|
||||
@PreAuthorize("hasAuthority('platform:customer:list')")
|
||||
@Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
|
||||
@Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
|
||||
@@ -57,6 +59,15 @@ public class PlatformTenantController {
|
||||
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/{tenantId:\\d+}")
|
||||
@PreAuthorize("hasAuthority('platform:customer:list')")
|
||||
@Operation(summary = "查询企业客户档案")
|
||||
public ApiResponse<TenantResponse> tenant(@PathVariable long tenantId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.merchant(tenantId));
|
||||
}
|
||||
|
||||
@PostMapping("/tenants")
|
||||
@PreAuthorize("hasAuthority('platform:customer:create')")
|
||||
@Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。")
|
||||
@@ -72,7 +83,28 @@ public class PlatformTenantController {
|
||||
return ApiResponse.success(service.register(request));
|
||||
}
|
||||
|
||||
@PutMapping("/tenants/{tenantId:\\d+}")
|
||||
@PreAuthorize("hasAuthority('platform:customer:edit')")
|
||||
@Operation(summary = "编辑企业客户档案")
|
||||
public ApiResponse<TenantResponse> updateTenant(@PathVariable long tenantId,
|
||||
@Valid @RequestBody UpdateTenantRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.updateMerchant(tenantId, request));
|
||||
}
|
||||
|
||||
@PutMapping("/tenants/{tenantId:\\d+}/status")
|
||||
@PreAuthorize("hasAuthority('platform:customer:status')")
|
||||
@Operation(summary = "启停企业客户")
|
||||
public ApiResponse<TenantResponse> changeTenantStatus(@PathVariable long tenantId,
|
||||
@Valid @RequestBody ChangeTenantStatusRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.changeMerchantStatus(tenantId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/reviews")
|
||||
@PreAuthorize("hasAuthority('platform:customer:review')")
|
||||
@Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"),
|
||||
|
||||
@@ -81,6 +81,16 @@ public class ProfileResourceController {
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PutMapping("/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles")
|
||||
@PreAuthorize("hasAuthority('platform:user:grant-role')")
|
||||
@Operation(summary = "分配平台内部用户角色")
|
||||
public ApiResponse<InternalUserResponse> assignInternalUserRoles(
|
||||
@PathVariable Long membershipId, @Valid @RequestBody AssignInternalUserRolesRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(internalUserService.assignRoles(membershipId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/api/merchant/v1/iam/tenant/addresses")
|
||||
@Operation(summary = "List enterprise addresses / 查询企业地址", description = "Tenant ID is taken from the merchant JWT. / 租户 ID 从商户 JWT 获取。")
|
||||
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address list returned / 查询成功")})
|
||||
|
||||
@@ -122,6 +122,36 @@ public class RbacController {
|
||||
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
|
||||
}
|
||||
|
||||
@PutMapping("/permissions/{permissionId}")
|
||||
@Operation(summary = "编辑自定义权限资源")
|
||||
@PreAuthorize("hasAuthority(#app + ':permission:edit')")
|
||||
public ApiResponse<IamPermission> updatePermission(@PathVariable String app, @PathVariable Long permissionId,
|
||||
@Valid @RequestBody UpdatePermissionRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request));
|
||||
}
|
||||
|
||||
@PutMapping("/permissions/{permissionId}/status")
|
||||
@Operation(summary = "启停自定义权限资源")
|
||||
@PreAuthorize("hasAuthority(#app + ':permission:status')")
|
||||
public ApiResponse<IamPermission> changePermissionStatus(@PathVariable String app, @PathVariable Long permissionId,
|
||||
@Valid @RequestBody ChangePermissionStatusRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/permissions/{permissionId}")
|
||||
@Operation(summary = "删除未绑定的自定义权限资源")
|
||||
@PreAuthorize("hasAuthority(#app + ':permission:delete')")
|
||||
public ApiResponse<Void> deletePermission(@PathVariable String app, @PathVariable Long permissionId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
service.deletePermission(scope.tenantId(), scope.appCode(), permissionId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PutMapping("/roles/permissions")
|
||||
@Operation(summary = "全量设置角色权限,空集合表示清空")
|
||||
@PreAuthorize("hasAuthority(#app + ':role:grant')")
|
||||
|
||||
@@ -44,6 +44,9 @@ public final class PlatformInternalUserDtos {
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
|
||||
String newPassword) { }
|
||||
|
||||
/** 全量替换平台成员角色;空集合表示清空全部自定义角色。 */
|
||||
public record AssignInternalUserRolesRequest(@NotNull List<Long> roleIds) { }
|
||||
|
||||
public record InternalUserResponse(
|
||||
Long membershipId,
|
||||
Long userId,
|
||||
|
||||
@@ -38,6 +38,17 @@ public final class RbacDtos {
|
||||
String httpMethod,
|
||||
Integer sortNo) { }
|
||||
|
||||
/** 权限编码和类型参与服务端鉴权,编辑时不可变更。 */
|
||||
public record UpdatePermissionRequest(
|
||||
@NotBlank String name,
|
||||
Long parentId,
|
||||
String routePath,
|
||||
String httpMethod,
|
||||
Integer sortNo) { }
|
||||
|
||||
public record ChangePermissionStatusRequest(
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status) { }
|
||||
|
||||
public record AssignRolesRequest(@NotNull Long membershipId,
|
||||
@NotNull Set<Long> roleIds) { }
|
||||
|
||||
|
||||
@@ -30,6 +30,21 @@ public final class TenantDtos {
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
|
||||
) { }
|
||||
|
||||
/** 平台端客户档案维护。统一社会信用代码是注册标识,不允许经由普通编辑接口修改。 */
|
||||
@Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile")
|
||||
public record UpdateTenantRequest(
|
||||
@NotBlank String tenantName,
|
||||
@NotBlank String legalName,
|
||||
@NotBlank String contactName,
|
||||
@NotBlank String contactPhone,
|
||||
@Email String contactEmail
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantStatusChangeRequest", description = "Platform controlled merchant tenant status")
|
||||
public record ChangeTenantStatusRequest(
|
||||
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
|
||||
public record ReviewRequest(
|
||||
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
|
||||
|
||||
@@ -4,6 +4,7 @@ import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,10 @@ public class IamPermission {
|
||||
private String httpMethod;
|
||||
private Integer sortNo;
|
||||
private String status;
|
||||
/** 系统预置资源不得通过租户权限管理接口修改。 */
|
||||
private Boolean builtIn;
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,6 +46,30 @@ public class RbacRepository {
|
||||
public void insertRole(IamRole role) { roleMapper.insert(role); }
|
||||
public void updateRole(IamRole role) { roleMapper.updateById(role); }
|
||||
public void insertPermission(IamPermission permission) { permissionMapper.insert(permission); }
|
||||
public void updatePermission(IamPermission permission) { permissionMapper.updateById(permission); }
|
||||
|
||||
/** 写操作必须精确匹配所属权限域;全局模板可读,但商户不可修改。 */
|
||||
public IamPermission findOwnedPermission(Long id, long tenantId, String appCode) {
|
||||
return permissionMapper.selectOne(new LambdaQueryWrapper<IamPermission>()
|
||||
.eq(IamPermission::getId, id)
|
||||
.eq(IamPermission::getTenantId, tenantId)
|
||||
.eq(IamPermission::getAppCode, appCode));
|
||||
}
|
||||
|
||||
public boolean hasPermissionChildren(long tenantId, String appCode, Long permissionId) {
|
||||
return permissionMapper.selectCount(new LambdaQueryWrapper<IamPermission>()
|
||||
.eq(IamPermission::getTenantId, tenantId)
|
||||
.eq(IamPermission::getAppCode, appCode)
|
||||
.eq(IamPermission::getParentId, permissionId)) > 0;
|
||||
}
|
||||
|
||||
public boolean isPermissionGranted(long tenantId, Long permissionId) {
|
||||
return rolePermissionMapper.selectCount(new LambdaQueryWrapper<IamRolePermission>()
|
||||
.eq(IamRolePermission::getTenantId, tenantId)
|
||||
.eq(IamRolePermission::getPermissionId, permissionId)) > 0;
|
||||
}
|
||||
|
||||
public void deletePermission(IamPermission permission) { permissionMapper.deleteById(permission.getId()); }
|
||||
|
||||
public List<IamRole> findRoles(long tenantId, String appCode) {
|
||||
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
|
||||
|
||||
@@ -155,6 +155,19 @@ public class PlatformInternalUserService {
|
||||
new AuthAccountClient.PasswordRequest(request.newPassword())));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_INTERNAL_USER_ROLE_ASSIGN", resourceType = "MEMBERSHIP",
|
||||
resourceId = "#membershipId", description = "分配平台内部用户角色", captureAfter = false)
|
||||
public InternalUserResponse assignRoles(Long membershipId, AssignInternalUserRolesRequest request) {
|
||||
IamMembership membership = required(membershipId);
|
||||
List<Long> roleIds = normalizeRoles(request.roleIds());
|
||||
validateRoles(roleIds);
|
||||
repository.replaceRoles(PLATFORM_TENANT_ID, membershipId, roleIds);
|
||||
IamUser user = requiredUser(membership.getUserId());
|
||||
AuthAccountClient.AccountSummary authAccount = account(user.getAuthUserId());
|
||||
return response(membership, user, authAccount.username(), roleIds);
|
||||
}
|
||||
|
||||
private InternalUserResponse linkExistingAccount(CreateInternalUserRequest request, List<Long> roleIds) {
|
||||
IamUser existingUser = repository.findUserByAuthId(request.authUserId());
|
||||
IamMembership existingMembership = repository.findByTenantAndAuthUser(
|
||||
|
||||
@@ -131,6 +131,7 @@ public class RbacService {
|
||||
permission.setHttpMethod(request.httpMethod());
|
||||
permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
|
||||
permission.setStatus("ACTIVE");
|
||||
permission.setBuiltIn(false);
|
||||
try {
|
||||
repository.insertPermission(permission);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
@@ -139,6 +140,46 @@ public class RbacService {
|
||||
return permission;
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "PERMISSION_UPDATE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
||||
description = "编辑权限资源", captureBefore = true, captureAfter = true)
|
||||
public IamPermission updatePermission(long tenantId, String appCode, Long permissionId,
|
||||
UpdatePermissionRequest request) {
|
||||
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
||||
validatePermissionParent(tenantId, appCode, permissionId, request.parentId());
|
||||
permission.setPermissionName(request.name());
|
||||
permission.setParentId(request.parentId() == null ? 0L : request.parentId());
|
||||
permission.setRoutePath(request.routePath());
|
||||
permission.setHttpMethod(request.httpMethod());
|
||||
permission.setSortNo(request.sortNo() == null ? 0 : request.sortNo());
|
||||
repository.updatePermission(permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "PERMISSION_STATUS_CHANGE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
||||
description = "启停权限资源", captureBefore = true, captureAfter = true)
|
||||
public IamPermission changePermissionStatus(long tenantId, String appCode, Long permissionId,
|
||||
ChangePermissionStatusRequest request) {
|
||||
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
||||
permission.setStatus(request.status());
|
||||
repository.updatePermission(permission);
|
||||
return permission;
|
||||
}
|
||||
|
||||
/** 带有子资源或已授权给角色的资源不得直接删除。 */
|
||||
@BusinessAudit(event = "PERMISSION_DELETE", resourceType = "PERMISSION", resourceId = "#permissionId",
|
||||
description = "删除权限资源", captureBefore = true, captureAfter = false)
|
||||
@Transactional
|
||||
public void deletePermission(long tenantId, String appCode, Long permissionId) {
|
||||
IamPermission permission = requireMutablePermission(tenantId, appCode, permissionId);
|
||||
if (repository.hasPermissionChildren(tenantId, appCode, permissionId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Permission has child resources / 权限资源存在子节点,不能删除");
|
||||
}
|
||||
if (repository.isPermissionGranted(tenantId, permissionId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Permission is granted to roles / 权限资源已被角色授予,不能删除");
|
||||
}
|
||||
repository.deletePermission(permission);
|
||||
}
|
||||
|
||||
public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) {
|
||||
requireMembership(tenantId, membershipId);
|
||||
Set<Long> scopedIds = new LinkedHashSet<>();
|
||||
@@ -191,6 +232,34 @@ public class RbacService {
|
||||
return role;
|
||||
}
|
||||
|
||||
private IamPermission requireMutablePermission(long tenantId, String appCode, Long permissionId) {
|
||||
IamPermission permission = repository.findOwnedPermission(permissionId, tenantId, appCode);
|
||||
if (permission == null) throw forbidden();
|
||||
if (Boolean.TRUE.equals(permission.getBuiltIn())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Built-in permission cannot be changed / 系统预置权限不允许编辑、启停或删除");
|
||||
}
|
||||
return permission;
|
||||
}
|
||||
|
||||
private void validatePermissionParent(long tenantId, String appCode, Long permissionId, Long parentId) {
|
||||
if (parentId == null || parentId == 0) return;
|
||||
if (parentId.equals(permissionId)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Permission cannot be its own parent / 权限不能设置自身为父节点");
|
||||
}
|
||||
IamPermission parent = repository.findPermission(parentId, tenantId, appCode);
|
||||
if (parent == null) throw forbidden();
|
||||
Long cursor = parent.getParentId();
|
||||
while (cursor != null && cursor > 0) {
|
||||
if (cursor.equals(permissionId)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Permission parent cycle is not allowed / 权限节点不能形成环");
|
||||
}
|
||||
IamPermission ancestor = repository.findPermission(cursor, tenantId, appCode);
|
||||
if (ancestor == null || cursor.equals(ancestor.getParentId())) break;
|
||||
cursor = ancestor.getParentId();
|
||||
}
|
||||
}
|
||||
|
||||
private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) {
|
||||
IamRole role = requireRole(tenantId, appCode, roleId);
|
||||
if ("SYSTEM".equals(role.getRoleType())) {
|
||||
|
||||
@@ -14,6 +14,8 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -145,6 +147,39 @@ public class TenantService {
|
||||
return response(tenant);
|
||||
}
|
||||
|
||||
/** 平台端查询客户档案:仅能查商户租户,不暴露平台租户。 */
|
||||
public TenantResponse merchant(long tenantId) {
|
||||
return response(requireMerchant(tenantId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
|
||||
description = "平台编辑企业客户档案")
|
||||
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
repository.saveTenant(tenant);
|
||||
return response(tenant);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_STATUS_CHANGE", resourceType = "TENANT", resourceId = "#tenantId",
|
||||
description = "平台启停企业客户")
|
||||
public TenantResponse changeMerchantStatus(long tenantId, ChangeTenantStatusRequest request) {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
// PENDING/REJECTED 必须走审核流程,不允许状态维护接口绕过审核。
|
||||
if (!"ACTIVE".equals(tenant.getStatus()) && !"DISABLED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Tenant status must be changed through review / 当前租户状态必须通过审核流程变更");
|
||||
}
|
||||
tenant.setStatus(request.status());
|
||||
repository.saveTenant(tenant);
|
||||
return response(tenant);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT",
|
||||
resourceId = "#tenantId", description = "编辑企业资料")
|
||||
@@ -166,6 +201,14 @@ public class TenantService {
|
||||
return tenant.getId() != null && tenant.getId().longValue() == id;
|
||||
}
|
||||
|
||||
private IamTenant requireMerchant(long tenantId) {
|
||||
IamTenant tenant = repository.findTenant(tenantId);
|
||||
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在");
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private TenantResponse response(IamTenant tenant) {
|
||||
return new TenantResponse(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
|
||||
@@ -158,6 +158,7 @@ CREATE TABLE IF NOT EXISTS iam_permission (
|
||||
http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法',
|
||||
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '权限状态',
|
||||
built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置权限不可通过管理接口修改',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
|
||||
@@ -121,6 +121,26 @@ ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
-- 平台代客创建、编辑草稿权限;可重复执行并纳入已有平台管理员和订单运营角色授权。
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5102019, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:create', '平台代客创建订单', NULL, NULL, 90, 'ACTIVE', 0),
|
||||
(5102024, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:edit', '平台编辑订单草稿', NULL, NULL, 100, 'ACTIVE', 0),
|
||||
(5102025, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:submit', '平台代客提交订单', NULL, NULL, 110, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000001, id, 0 FROM iam_permission
|
||||
WHERE id IN (5102019, 5102024, 5102025)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000003, id, 0 FROM iam_permission
|
||||
WHERE id IN (5102019, 5102024, 5102025)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
-- Merchant: level-one menus, level-two menus and buttons.
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
-- 权限资源维护与平台主数据权限迁移。
|
||||
-- 在 phase1-admin-rbac.sql、rbac-role-management-v2.sql 之后执行,可重复执行。
|
||||
USE baiyue_iam;
|
||||
|
||||
-- 兼容不支持 ADD COLUMN IF NOT EXISTS 的 MySQL 版本,通过元数据判断后执行。
|
||||
SET @iam_permission_built_in_sql = IF(
|
||||
EXISTS(SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = 'iam_permission' AND column_name = 'built_in'),
|
||||
'SELECT 1',
|
||||
"ALTER TABLE iam_permission ADD COLUMN built_in TINYINT NOT NULL DEFAULT 0 COMMENT '是否为系统预置权限;预置资源不可通过管理接口修改' AFTER status"
|
||||
);
|
||||
PREPARE iam_permission_built_in_stmt FROM @iam_permission_built_in_sql;
|
||||
EXECUTE iam_permission_built_in_stmt;
|
||||
DEALLOCATE PREPARE iam_permission_built_in_stmt;
|
||||
|
||||
-- 一期固定编号范围内的资源均为种子资源,运行期生成的雪花编号不作预置标记。
|
||||
UPDATE iam_permission
|
||||
SET built_in = 1
|
||||
WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
||||
sort_no, status, built_in, deleted)
|
||||
VALUES
|
||||
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
|
||||
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
|
||||
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
|
||||
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
|
||||
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
|
||||
(5101034, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:delete', '删除权限资源', 40, 'ACTIVE', 1, 0),
|
||||
(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),
|
||||
(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)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
|
||||
built_in = 1, deleted = 0;
|
||||
|
||||
-- 新增平台操作权限默认只授予系统管理员。
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000001, id, 0
|
||||
FROM iam_permission
|
||||
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:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
@@ -15,6 +15,7 @@ VALUES
|
||||
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', 20, 'ACTIVE', 0),
|
||||
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0),
|
||||
(5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0),
|
||||
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0),
|
||||
(5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0),
|
||||
(5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0),
|
||||
(5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0),
|
||||
@@ -32,7 +33,8 @@ SELECT 4000001, id, 0 FROM iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
|
||||
AND permission_code IN ('platform:role:edit', 'platform:role:status', 'platform:role:delete',
|
||||
'platform:member:list', 'platform:member:grant-role', 'platform:customer:create',
|
||||
'platform:user:edit', 'platform:user:status', 'platform:user:reset-password')
|
||||
'platform:user:edit', 'platform:user:status', 'platform:user:reset-password',
|
||||
'platform:user:grant-role')
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
|
||||
@@ -20,8 +20,8 @@ paths:
|
||||
/api/platform/v1/iam/tenants:
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: List enterprise tenants / 查询企业客户列表
|
||||
description: Returns paged merchant tenants. Requires `platform:customer:list`. / 分页查询商户企业客户,需要 `platform:customer:list` 权限。
|
||||
summary: 查询企业客户列表
|
||||
description: 分页查询商户企业客户,需要 `platform:customer:list` 权限。
|
||||
parameters:
|
||||
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
@@ -54,8 +54,8 @@ paths:
|
||||
/api/platform/v1/iam/authorization/permissions:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: Get current effective permissions / 查询当前用户有效权限
|
||||
description: Returns the current active platform membership's permission codes. It only requires a valid PLATFORM JWT and does not require `platform:role:list`. / 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前平台成员权限查询成功
|
||||
@@ -64,11 +64,51 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
|
||||
'401': {$ref: '#/components/responses/Unauthorized'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/platform/v1/iam/tenants/{tenantId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 查询企业客户档案
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 编辑企业客户档案
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UpdateTenantRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/api/platform/v1/iam/tenants/{tenantId}/status:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/TenantId'}
|
||||
put:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 启停企业客户
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ChangeTenantStatusRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/api/merchant/v1/iam/authorization/permissions:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: Get current effective permissions / 查询当前用户有效权限
|
||||
description: Returns the current active merchant membership's permission codes. / 返回当前有效商户成员权限编码。
|
||||
summary: 查询当前用户有效权限
|
||||
description: 返回当前有效商户成员权限编码。
|
||||
responses:
|
||||
'200':
|
||||
description: 当前商户成员权限查询成功
|
||||
@@ -193,6 +233,27 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/api/platform/v1/iam/internal-users/{membershipId}/roles:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/MembershipId'}
|
||||
put:
|
||||
tags: [IAM 资源]
|
||||
summary: 全量分配平台内部用户角色
|
||||
description: 仅允许分配平台租户下已启用的平台端角色;空集合表示清空角色。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AssignInternalUserRolesRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: 角色分配成功并返回最新内部用户模型
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InternalUserApiResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
/api/merchant/v1/iam/tenant/profile:
|
||||
get:
|
||||
tags: [Merchant Tenant / 商户企业]
|
||||
@@ -341,6 +402,49 @@ paths:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/{app}/v1/iam/permissions/{permissionId}:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/App'}
|
||||
- {$ref: '#/components/parameters/PermissionId'}
|
||||
put:
|
||||
tags: [RBAC]
|
||||
summary: 编辑自定义权限资源
|
||||
description: 权限编码和类型不可变更,系统预置资源不可修改。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UpdatePermissionRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
delete:
|
||||
tags: [RBAC]
|
||||
summary: 删除未绑定的自定义权限资源
|
||||
description: 系统预置资源、存在子资源或已绑定角色的资源不可删除。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/api/{app}/v1/iam/permissions/{permissionId}/status:
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/App'}
|
||||
- {$ref: '#/components/parameters/PermissionId'}
|
||||
put:
|
||||
tags: [RBAC]
|
||||
summary: 启停自定义权限资源
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ChangePermissionStatusRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -366,7 +470,19 @@ components:
|
||||
name: membershipId
|
||||
in: path
|
||||
required: true
|
||||
description: Platform membership ID / 平台成员关系 ID
|
||||
description: 平台成员关系 ID
|
||||
schema: {type: integer, format: int64}
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: path
|
||||
required: true
|
||||
description: 企业客户租户 ID
|
||||
schema: {type: integer, format: int64, minimum: 1}
|
||||
PermissionId:
|
||||
name: permissionId
|
||||
in: path
|
||||
required: true
|
||||
description: 权限资源 ID
|
||||
schema: {type: integer, format: int64}
|
||||
responses:
|
||||
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
|
||||
@@ -416,6 +532,12 @@ components:
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}}
|
||||
InternalUserApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/InternalUserResponse'}
|
||||
TenantResponse:
|
||||
type: object
|
||||
required: [id, tenantCode, tenantName, status]
|
||||
@@ -448,6 +570,20 @@ components:
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
UpdateTenantRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
legalName: {type: string}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
ChangeTenantStatusRequest:
|
||||
type: object
|
||||
required: [status]
|
||||
properties:
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
ReviewRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -484,6 +620,11 @@ components:
|
||||
required: [newPassword]
|
||||
properties:
|
||||
newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
|
||||
AssignInternalUserRolesRequest:
|
||||
type: object
|
||||
required: [roleIds]
|
||||
properties:
|
||||
roleIds: {type: array, items: {type: integer, format: int64}}
|
||||
InternalUserResponse:
|
||||
type: object
|
||||
required: [membershipId, userId, authUserId, username, displayName, memberType, status, roleIds]
|
||||
@@ -541,6 +682,20 @@ components:
|
||||
routePath: {type: string}
|
||||
httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]}
|
||||
sortNo: {type: integer, example: 10}
|
||||
UpdatePermissionRequest:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name: {type: string}
|
||||
parentId: {type: integer, format: int64}
|
||||
routePath: {type: string}
|
||||
httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]}
|
||||
sortNo: {type: integer}
|
||||
ChangePermissionStatusRequest:
|
||||
type: object
|
||||
required: [status]
|
||||
properties:
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
AssignRolesRequest:
|
||||
type: object
|
||||
required: [membershipId, roleIds]
|
||||
|
||||
+3
-1
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -10,9 +11,10 @@ import java.lang.reflect.Method;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
@DisplayName("权限快照接口契约")
|
||||
class AuthorizationControllerContractTest {
|
||||
@Test
|
||||
void 当前权限接口不应依赖角色管理权限() throws Exception {
|
||||
void currentPermissionsEndpointDoesNotRequireRoleManagementPermission() throws Exception {
|
||||
Method method = AuthorizationController.class.getMethod("permissions", String.class,
|
||||
AuthenticatedUser.class);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
@@ -9,9 +10,10 @@ import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@DisplayName("IAM OpenAPI 契约")
|
||||
class IamOpenApiContractTest {
|
||||
@Test
|
||||
void OpenApi应声明权限分页和内部用户的真实响应结构() throws IOException {
|
||||
void openApiDeclaresPermissionPaginationAndInternalUserResponseSchema() throws IOException {
|
||||
String document;
|
||||
try (var stream = getClass().getResourceAsStream("/openapi/iam-v1.yaml")) {
|
||||
assertThat(stream).isNotNull();
|
||||
@@ -23,6 +25,8 @@ class IamOpenApiContractTest {
|
||||
.contains("PermissionCodesApiResponse:")
|
||||
.contains("TenantPageApiResponse:")
|
||||
.contains("InternalUserListApiResponse:")
|
||||
.contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
|
||||
.contains("AssignInternalUserRolesRequest:")
|
||||
.contains("membershipId: {type: integer, format: int64}")
|
||||
.contains("roleIds: {type: array, items: {type: integer, format: int64}}")
|
||||
.contains("'422': {$ref: '#/components/responses/UnprocessableEntity'}");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.util.pattern.PathPatternParser;
|
||||
import org.springframework.http.server.PathContainer;
|
||||
@@ -7,11 +8,12 @@ import org.springframework.http.server.PathContainer;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/** 防止成员数字主键路由再次拦截固定的角色分配路径。 */
|
||||
@DisplayName("商户成员路由")
|
||||
class MerchantMemberRouteTest {
|
||||
private final PathPatternParser parser = new PathPatternParser();
|
||||
|
||||
@Test
|
||||
void 成员编辑路由只接受数字主键() {
|
||||
void memberUpdateRouteAcceptsNumericPrimaryKeyOnly() {
|
||||
var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}");
|
||||
|
||||
assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -9,10 +10,11 @@ import java.lang.reflect.Method;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@DisplayName("平台内部用户接口契约")
|
||||
class PlatformInternalUserContractTest {
|
||||
|
||||
@Test
|
||||
void 内部用户列表必须校验列表权限() throws Exception {
|
||||
void internalUserListRequiresListPermission() throws Exception {
|
||||
Method method = ProfileResourceController.class.getMethod("internalUsers",
|
||||
com.baiyue.common.security.AuthenticatedUser.class);
|
||||
|
||||
@@ -23,16 +25,18 @@ class PlatformInternalUserContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 平台用户写接口以成员关系主键寻址() {
|
||||
void platformUserWriteEndpointsAddressMembershipId() {
|
||||
assertMapping("updateInternalUser", "/api/platform/v1/iam/internal-users/{membershipId:\\d+}");
|
||||
assertMapping("changeInternalUserStatus",
|
||||
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/status");
|
||||
assertMapping("resetInternalUserPassword",
|
||||
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/password");
|
||||
assertMapping("assignInternalUserRoles",
|
||||
"/api/platform/v1/iam/internal-users/{membershipId:\\d+}/roles");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业资料查看必须校验查看权限() throws Exception {
|
||||
void tenantProfileReadRequiresViewPermission() throws Exception {
|
||||
Method method = MerchantTenantController.class.getMethod("profile",
|
||||
com.baiyue.common.security.AuthenticatedUser.class);
|
||||
assertEquals("hasAuthority('merchant:profile:view')",
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -18,9 +19,10 @@ import java.util.stream.Collectors;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
@DisplayName("平台客户接口契约")
|
||||
class PlatformTenantControllerContractTest {
|
||||
@Test
|
||||
void 客户分页接口声明权限分页约束和错误状态() throws Exception {
|
||||
void merchantPageEndpointDeclaresPermissionPaginationConstraintsAndErrorStatuses() throws Exception {
|
||||
Method method = PlatformTenantController.class.getMethod("page", long.class, long.class,
|
||||
String.class, String.class, AuthenticatedUser.class);
|
||||
|
||||
@@ -39,6 +41,14 @@ class PlatformTenantControllerContractTest {
|
||||
assertEquals(Set.of("200", "400", "401", "403", "422"), statuses);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingMerchantListRequiresMerchantApprovalPermission() throws Exception {
|
||||
Method method = PlatformTenantController.class.getMethod("reviews", AuthenticatedUser.class);
|
||||
|
||||
assertEquals("hasAuthority('platform:customer:review')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
|
||||
return method.getParameters()[parameter].getAnnotation(type);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
@@ -10,6 +11,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
@DisplayName("企业资质上传接口契约")
|
||||
class QualificationUploadContractTest {
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@@ -16,6 +17,7 @@ import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("权限快照服务")
|
||||
class AuthorizationSnapshotServiceTest {
|
||||
@Test
|
||||
void loadsRolesAndPermissionsForActiveMerchantIdentity() {
|
||||
@@ -82,7 +84,7 @@ class AuthorizationSnapshotServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空角色和权限查询结果不应触发五百错误() {
|
||||
void emptyRoleAndPermissionQueriesDoNotTriggerServerError() {
|
||||
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
|
||||
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
|
||||
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
@@ -18,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("商户成员服务")
|
||||
class MemberServiceTest {
|
||||
private final MemberRepository repository = mock(MemberRepository.class);
|
||||
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
|
||||
@@ -35,7 +37,7 @@ class MemberServiceTest {
|
||||
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
|
||||
}
|
||||
|
||||
@Test void 成员详情返回联系方式和已有角色() {
|
||||
@Test void memberDetailsReturnContactInformationAndExistingRoles() {
|
||||
user.setPhone("13800138000"); user.setEmail("member@example.com");
|
||||
MemberResponse result = service.detail(100L, 20L);
|
||||
assertEquals("测试成员", result.displayName());
|
||||
@@ -44,12 +46,12 @@ class MemberServiceTest {
|
||||
verifyNoInteractions(auth);
|
||||
}
|
||||
|
||||
@Test void 跨租户查询被拒绝() {
|
||||
@Test void crossTenantLookupIsRejected() {
|
||||
when(repository.findByTenantAndId(200L, 20L)).thenReturn(null);
|
||||
assertThrows(BizException.class, () -> service.detail(200L, 20L));
|
||||
}
|
||||
|
||||
@Test void 企业所有者不可停用() {
|
||||
@Test void tenantOwnerCannotBeDisabled() {
|
||||
member.setMemberType("OWNER");
|
||||
BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L,
|
||||
new ChangeMemberStatusRequest("DISABLED"), 20L));
|
||||
@@ -57,7 +59,7 @@ class MemberServiceTest {
|
||||
verify(auth, never()).status(anyString(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test void 停用成员同步认证账号并保存本地状态() {
|
||||
@Test void disablingMemberSynchronizesAuthAccountAndPersistsLocalStatus() {
|
||||
when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null));
|
||||
MemberResponse result = service.changeStatus(100L, 20L,
|
||||
new ChangeMemberStatusRequest("DISABLED"), 99L);
|
||||
@@ -66,14 +68,14 @@ class MemberServiceTest {
|
||||
verify(repository).saveUser(user);
|
||||
}
|
||||
|
||||
@Test void 认证服务失败时不得写入本地状态() {
|
||||
@Test void authServiceFailureDoesNotPersistLocalStatus() {
|
||||
when(auth.status(anyString(), eq(40L), any())).thenThrow(new RuntimeException("网络失败"));
|
||||
assertThrows(RuntimeException.class, () -> service.changeStatus(100L, 20L,
|
||||
new ChangeMemberStatusRequest("DISABLED"), 99L));
|
||||
verify(repository, never()).save(member);
|
||||
}
|
||||
|
||||
@Test void 企业所有者不能通过成员编辑接口降级或变更角色() {
|
||||
@Test void tenantOwnerCannotBeDowngradedOrHaveRolesChangedThroughMemberUpdate() {
|
||||
member.setMemberType("OWNER");
|
||||
UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null,
|
||||
"EMPLOYEE", null, List.of());
|
||||
@@ -83,7 +85,7 @@ class MemberServiceTest {
|
||||
verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
|
||||
}
|
||||
|
||||
@Test void 企业所有者密码不能被成员管理员重置() {
|
||||
@Test void tenantOwnerPasswordCannotBeResetByMemberAdministrator() {
|
||||
member.setMemberType("OWNER");
|
||||
|
||||
assertThrows(BizException.class, () -> service.resetPassword(100L, 20L,
|
||||
|
||||
@@ -5,16 +5,18 @@ import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("个人资料服务")
|
||||
class PersonalProfileServiceTest {
|
||||
private final MemberRepository repository = mock(MemberRepository.class);
|
||||
private final PersonalProfileService service = new PersonalProfileService(repository);
|
||||
|
||||
@Test void 只能修改当前租户中的本人资料() {
|
||||
@Test void canOnlyModifyOwnProfileWithinCurrentTenant() {
|
||||
IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE");
|
||||
IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L);
|
||||
when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
|
||||
@@ -25,7 +27,7 @@ class PersonalProfileServiceTest {
|
||||
verify(repository).saveUser(user);
|
||||
}
|
||||
|
||||
@Test void 跨租户身份被拒绝() {
|
||||
@Test void crossTenantIdentityIsRejected() {
|
||||
assertThrows(BizException.class, () -> service.get(9L, 2L, "tester"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
@@ -19,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@DisplayName("平台内部用户服务")
|
||||
class PlatformInternalUserServiceTest {
|
||||
private final MemberRepository repository = mock(MemberRepository.class);
|
||||
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
|
||||
@@ -45,7 +47,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 列表返回成员主键联系方式和当前角色() {
|
||||
void listReturnsMembershipIdContactInformationAndCurrentRoles() {
|
||||
when(repository.findByTenant(0L)).thenReturn(List.of(membership));
|
||||
|
||||
InternalUserResponse result = service.list().get(0);
|
||||
@@ -57,7 +59,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 新建平台用户在Auth创建平台账号并写入IAM成员() {
|
||||
void createPlatformUserCreatesPlatformAccountInAuthAndMembershipInIam() {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(40L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
|
||||
when(roleMapper.selectById(40L)).thenReturn(role);
|
||||
@@ -81,7 +83,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 本地创建失败会补偿认证账号且不记录密码() {
|
||||
void localCreationFailureCompensatesAuthAccountWithoutRecordingPassword() {
|
||||
when(auth.create(anyString(), any())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(99L, "new.operator", "新运营", "ACTIVE")));
|
||||
when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null));
|
||||
@@ -95,7 +97,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 不能停用当前登录的平台成员() {
|
||||
void cannotDisableCurrentlyAuthenticatedPlatformMember() {
|
||||
assertThrows(BizException.class, () -> service.changeStatus(30L,
|
||||
new ChangeInternalUserStatusRequest("DISABLED"), 30L));
|
||||
|
||||
@@ -103,7 +105,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 状态本地写入失败时恢复Auth且成功后不再远程查询() {
|
||||
void localStatusPersistenceFailureRestoresAuthAndAvoidsFurtherRemoteLookupsAfterSuccess() {
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
doThrow(new RuntimeException("IAM 写入失败")).when(repository).saveUser(user);
|
||||
|
||||
@@ -118,7 +120,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 状态补偿应恢复Auth原状态而不是IAM旧状态() {
|
||||
void statusCompensationRestoresOriginalAuthStatusInsteadOfOldIamStatus() {
|
||||
when(auth.summary("internal-token", 10L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(10L, "operator", "平台运营", "DISABLED")));
|
||||
when(auth.status(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
@@ -134,7 +136,7 @@ class PlatformInternalUserServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 重置密码只转交Auth不写入IAM() {
|
||||
void resetPasswordDelegatesOnlyToAuthWithoutWritingIam() {
|
||||
when(auth.reset(anyString(), eq(10L), any())).thenReturn(ApiResponse.success(null));
|
||||
|
||||
service.resetPassword(30L, new ResetInternalUserPasswordRequest("ResetPass1"));
|
||||
@@ -143,4 +145,30 @@ class PlatformInternalUserServiceTest {
|
||||
argThat(value -> "ResetPass1".equals(value.newPassword())));
|
||||
verify(repository, never()).saveUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roleAssignmentAcceptsEnabledPlatformRolesOnlyAndReturnsLatestView() {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(41L); role.setTenantId(0L); role.setAppCode("PLATFORM"); role.setStatus("ACTIVE");
|
||||
when(roleMapper.selectById(41L)).thenReturn(role);
|
||||
|
||||
InternalUserResponse result = service.assignRoles(30L,
|
||||
new AssignInternalUserRolesRequest(List.of(41L, 41L)));
|
||||
|
||||
assertEquals(List.of(41L), result.roleIds());
|
||||
verify(repository).replaceRoles(0L, 30L, List.of(41L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preventsAssigningMerchantRolesToPlatformUser() {
|
||||
IamRole merchantRole = new IamRole();
|
||||
merchantRole.setId(42L); merchantRole.setTenantId(10001L);
|
||||
merchantRole.setAppCode("MERCHANT"); merchantRole.setStatus("ACTIVE");
|
||||
when(roleMapper.selectById(42L)).thenReturn(merchantRole);
|
||||
|
||||
assertThrows(BizException.class, () -> service.assignRoles(30L,
|
||||
new AssignInternalUserRolesRequest(List.of(42L))));
|
||||
|
||||
verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,15 @@ import com.baiyue.iam.dto.RbacDtos.CreateRoleRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.UpdateRoleRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.ChangeRoleStatusRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.CreatePermissionRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.UpdatePermissionRequest;
|
||||
import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamPermission;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baiyue.iam.repository.RbacRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@@ -29,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("角色与权限服务")
|
||||
class RbacServiceTest {
|
||||
@Mock private RbacRepository repository;
|
||||
@Mock private MemberRepository memberRepository;
|
||||
@@ -42,7 +46,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 角色列表返回当前权限用于安全回显() {
|
||||
void roleListReturnsCurrentPermissionsForSafeView() {
|
||||
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
|
||||
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
|
||||
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
|
||||
@@ -54,7 +58,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 权限列表同码时优先当前租户并去除全局模板重复项() {
|
||||
void permissionListPrefersCurrentTenantAndRemovesDuplicateGlobalTemplatesWithSameCode() {
|
||||
IamPermission global = permission(21L, 0L, "merchant:member:list");
|
||||
IamPermission tenant = permission(22L, 100L, "merchant:member:list");
|
||||
IamPermission fallback = permission(23L, 0L, "merchant:member:status");
|
||||
@@ -69,7 +73,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 跨租户角色访问被拒绝() {
|
||||
void crossTenantRoleAccessIsRejected() {
|
||||
when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null);
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
@@ -79,7 +83,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 系统角色禁止删除() {
|
||||
void systemRoleCannotBeDeleted() {
|
||||
when(repository.findRole(11L, 0L, "PLATFORM"))
|
||||
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
|
||||
|
||||
@@ -91,7 +95,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 已分配成员的自定义角色禁止删除() {
|
||||
void customRoleAssignedToMembersCannotBeDeleted() {
|
||||
when(repository.findRole(11L, 100L, "MERCHANT"))
|
||||
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
|
||||
when(repository.isRoleAssigned(100L, 11L)).thenReturn(true);
|
||||
@@ -104,7 +108,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空权限集合可以明确清空授权() {
|
||||
void emptyPermissionSetExplicitlyClearsAuthorization() {
|
||||
when(repository.findRole(11L, 100L, "MERCHANT"))
|
||||
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
|
||||
|
||||
@@ -114,7 +118,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 空角色集合可以明确清空成员授权() {
|
||||
void emptyRoleSetExplicitlyClearsMemberAuthorization() {
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(31L);
|
||||
membership.setTenantId(100L);
|
||||
@@ -126,7 +130,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 成员角色回显只返回当前应用域角色() {
|
||||
void memberRoleViewReturnsRolesFromCurrentApplicationDomainOnly() {
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(31L);
|
||||
membership.setTenantId(100L);
|
||||
@@ -142,7 +146,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 同一租户同一应用角色编码重复时返回冲突() {
|
||||
void duplicateRoleCodeWithinTenantAndApplicationReturnsConflict() {
|
||||
when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true);
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.createRole(
|
||||
@@ -153,7 +157,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 停用角色不得继续分配给成员() {
|
||||
void disabledRoleCannotBeAssignedToMember() {
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(31L);
|
||||
membership.setTenantId(100L);
|
||||
@@ -170,7 +174,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 自定义角色可以编辑和停用并保持权限回显() {
|
||||
void customRoleCanBeUpdatedAndDisabledWhileRetainingPermissionView() {
|
||||
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
|
||||
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role);
|
||||
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L));
|
||||
@@ -187,7 +191,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 未使用的自定义角色可以安全删除() {
|
||||
void unreferencedCustomRoleCanBeDeletedSafely() {
|
||||
when(repository.findRole(11L, 100L, "MERCHANT"))
|
||||
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
|
||||
when(repository.isRoleAssigned(100L, 11L)).thenReturn(false);
|
||||
@@ -198,7 +202,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业所有者角色不能通过普通成员授权接口修改() {
|
||||
void tenantOwnerRolesCannotBeChangedThroughRegularMemberAuthorizationEndpoint() {
|
||||
IamMembership owner = new IamMembership();
|
||||
owner.setId(31L);
|
||||
owner.setTenantId(100L);
|
||||
@@ -213,7 +217,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 系统角色权限不能通过普通授权接口修改() {
|
||||
void systemRolePermissionsCannotBeChangedThroughRegularAuthorizationEndpoint() {
|
||||
when(repository.findRole(11L, 0L, "PLATFORM"))
|
||||
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
|
||||
|
||||
@@ -225,7 +229,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业所有者系统角色不能分配给普通成员() {
|
||||
void tenantOwnerSystemRoleCannotBeAssignedToRegularMember() {
|
||||
IamMembership member = new IamMembership();
|
||||
member.setId(31L);
|
||||
member.setTenantId(100L);
|
||||
@@ -243,7 +247,7 @@ class RbacServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 权限编码重复时返回稳定冲突且不写数据库() {
|
||||
void duplicatePermissionCodeReturnsStableConflictWithoutDatabaseWrite() {
|
||||
when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test"))
|
||||
.thenReturn(true);
|
||||
CreatePermissionRequest request = new CreatePermissionRequest(
|
||||
@@ -256,6 +260,67 @@ class RbacServiceTest {
|
||||
verify(repository, never()).insertPermission(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void builtInSystemPermissionCannotBeUpdatedDisabledOrDeleted() {
|
||||
IamPermission builtIn = permission(21L, 0L, "platform:permission:list");
|
||||
builtIn.setAppCode("PLATFORM");
|
||||
builtIn.setBuiltIn(true);
|
||||
when(repository.findOwnedPermission(21L, 0L, "PLATFORM")).thenReturn(builtIn);
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.changePermissionStatus(
|
||||
0L, "PLATFORM", 21L, new ChangePermissionStatusRequest("DISABLED")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(repository, never()).updatePermission(any());
|
||||
verify(repository, never()).deletePermission(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customPermissionCanBeUpdatedAndToggledWithoutChangingCode() {
|
||||
IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
|
||||
when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
|
||||
|
||||
var updated = service.updatePermission(100L, "MERCHANT", 21L,
|
||||
new UpdatePermissionRequest("新名称", 0L, "/custom", "PUT", 10));
|
||||
var disabled = service.changePermissionStatus(100L, "MERCHANT", 21L,
|
||||
new ChangePermissionStatusRequest("DISABLED"));
|
||||
|
||||
assertEquals("merchant:custom:edit", updated.getPermissionCode());
|
||||
assertEquals("新名称", updated.getPermissionName());
|
||||
assertEquals("DISABLED", disabled.getStatus());
|
||||
verify(repository, times(2)).updatePermission(custom);
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionResourceWithChildrenOrRoleBindingsRejectsDeletion() {
|
||||
IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
|
||||
when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
|
||||
when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(true);
|
||||
|
||||
BizException childFailure = assertThrows(BizException.class,
|
||||
() -> service.deletePermission(100L, "MERCHANT", 21L));
|
||||
assertEquals(ResultCode.CONFLICT, childFailure.getResultCode());
|
||||
|
||||
when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false);
|
||||
when(repository.isPermissionGranted(100L, 21L)).thenReturn(true);
|
||||
BizException bindingFailure = assertThrows(BizException.class,
|
||||
() -> service.deletePermission(100L, "MERCHANT", 21L));
|
||||
assertEquals(ResultCode.CONFLICT, bindingFailure.getResultCode());
|
||||
verify(repository, never()).deletePermission(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unreferencedCustomPermissionCanBeDeleted() {
|
||||
IamPermission custom = permission(21L, 100L, "merchant:custom:edit");
|
||||
when(repository.findOwnedPermission(21L, 100L, "MERCHANT")).thenReturn(custom);
|
||||
when(repository.hasPermissionChildren(100L, "MERCHANT", 21L)).thenReturn(false);
|
||||
when(repository.isPermissionGranted(100L, 21L)).thenReturn(false);
|
||||
|
||||
service.deletePermission(100L, "MERCHANT", 21L);
|
||||
|
||||
verify(repository).deletePermission(custom);
|
||||
}
|
||||
|
||||
private IamRole role(Long id, Long tenantId, String appCode, String roleType) {
|
||||
IamRole role = new IamRole();
|
||||
role.setId(id);
|
||||
|
||||
@@ -2,7 +2,11 @@ package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@@ -12,7 +16,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
|
||||
@DisplayName("企业租户服务")
|
||||
class TenantServiceTest {
|
||||
|
||||
@Test
|
||||
@@ -41,4 +49,66 @@ class TenantServiceTest {
|
||||
assertEquals("Alice", result.records().get(0).contactName());
|
||||
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanMaintainAndDisableOnlyMerchantTenant() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com"));
|
||||
var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED"));
|
||||
|
||||
assertEquals("New name", updated.tenantName());
|
||||
assertEquals("DISABLED", disabled.status());
|
||||
verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCannotBypassReviewToActivatePendingTenant() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeTenantStillRequiresApprovedQualificationToPlaceOrder() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
|
||||
when(profileRepository.countApprovedQualifications(10001L)).thenReturn(0L, 1L);
|
||||
|
||||
var withoutQualification = service.orderEligibility(10001L);
|
||||
var withQualification = service.orderEligibility(10001L);
|
||||
|
||||
assertEquals(false, withoutQualification.eligible());
|
||||
assertEquals("ACTIVE", withoutQualification.tenantStatus());
|
||||
assertEquals(true, withQualification.eligible());
|
||||
}
|
||||
|
||||
private IamTenant merchant(long id, String status) {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(id);
|
||||
tenant.setTenantCode("TEN" + id);
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus(status);
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setLegalName("Example Merchant Ltd");
|
||||
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user