暂存提交

This commit is contained in:
2026-07-31 14:55:34 +08:00
parent bc3c18a6e9
commit 7a19e2f096
28 changed files with 655 additions and 46 deletions
@@ -16,6 +16,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -39,15 +40,18 @@ public class PlatformTenantController {
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "List enterprise tenants / 查询企业列表", description = "Returns paged merchant enterprise tenants for platform operators. / 平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Tenant list returned / 企业列表查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid paging parameters / 分页参数无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "需要登录"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "需要平台端权限"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "422", description = "分页范围或状态无效")
})
public ApiResponse<PageResult<TenantResponse>> page(
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
@@ -55,7 +55,7 @@ public interface AuthorizationQueryMapper {
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode} AND r.status = 'ACTIVE' AND r.deleted = 0
AND (p.tenant_id = 0 OR p.tenant_id = #{tenantId})
AND p.app_code = #{appCode} AND p.status = 'ACTIVE'
AND p.app_code = #{appCode} AND p.status = 'ACTIVE' AND p.deleted = 0
ORDER BY p.permission_code
""")
List<String> findPermissions(@Param("userId") long userId,
@@ -6,10 +6,12 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse;
import com.baiyue.iam.mapper.AuthorizationQueryMapper;
import com.baiyue.common.security.AuthenticatedUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
@@ -24,6 +26,7 @@ public class AuthorizationSnapshotService {
@Transactional(readOnly = true)
public SnapshotResponse load(SnapshotRequest request) {
requireRequest(request);
String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT);
if (!APPS.contains(appCode)) {
throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域");
@@ -38,11 +41,40 @@ public class AuthorizationSnapshotService {
throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效");
}
Set<String> roles = new LinkedHashSet<>(mapper.findRoles(
Set<String> roles = normalizedSet(mapper.findRoles(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
Set<String> permissions = new LinkedHashSet<>(mapper.findPermissions(
Set<String> permissions = normalizedSet(mapper.findPermissions(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
return new SnapshotResponse(request.getUserId(), request.getTenantId(), request.getMembershipId(),
appCode, appCode, Set.copyOf(roles), Set.copyOf(permissions));
}
/**
* 根据已认证请求主体重新计算有效权限,而不是复用 JWT 中可能已经过期的权限声明。
*/
@Transactional(readOnly = true)
public Set<String> currentPermissions(AuthenticatedUser user, String appCode) {
if (user == null || user.userId() == null || user.tenantId() == null || user.membershipId() == null) {
throw new BizException(ResultCode.UNAUTHORIZED, "Authentication identity is incomplete / 登录身份不完整");
}
return load(new SnapshotRequest(user.userId(), user.tenantId(), user.membershipId(), appCode))
.getPermissions();
}
private void requireRequest(SnapshotRequest request) {
if (request == null || request.getUserId() == null || request.getTenantId() == null
|| request.getMembershipId() == null || request.getAppCode() == null
|| request.getAppCode().isBlank()) {
throw new BizException(ResultCode.FORBIDDEN, "Authorization identity is incomplete / 授权身份不完整");
}
}
private Set<String> normalizedSet(List<String> values) {
if (values == null || values.isEmpty()) {
return Set.of();
}
LinkedHashSet<String> normalized = new LinkedHashSet<>();
values.stream().filter(java.util.Objects::nonNull).forEach(normalized::add);
return Set.copyOf(normalized);
}
}
@@ -84,6 +84,7 @@ VALUES
(5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0),
(5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0),
(5102010, 0, 'PLATFORM', 'MENU', 5102000, 'platform:order:list', '订单列表', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0),
(5102020, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:task:list', '履约任务', '/first-leg-tasks/list', NULL, 20, 'ACTIVE', 0),
(5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0),
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0),
(5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0),
@@ -103,9 +104,18 @@ VALUES
(5102016, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:cancel', '取消订单', NULL, NULL, 60, 'ACTIVE', 0),
(5102017, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:note', '内部备注', NULL, NULL, 70, 'ACTIVE', 0),
(5102018, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:fulfillment', '登记履约快递', NULL, NULL, 80, 'ACTIVE', 0),
(5102021, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:task:view', '查看履约任务', NULL, NULL, 10, 'ACTIVE', 0),
(5102022, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:history', '查看分配历史', NULL, NULL, 20, 'ACTIVE', 0),
(5102023, 0, 'PLATFORM', 'BUTTON', 5102020, 'platform:firstleg:assignment:assign', '分配履约任务', NULL, NULL, 30, 'ACTIVE', 0),
(5103011, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:create', '新增仓库', NULL, NULL, 10, 'ACTIVE', 0),
(5103012, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:edit', '编辑仓库', NULL, NULL, 20, 'ACTIVE', 0),
(5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0)
(5103013, 0, 'PLATFORM', 'BUTTON', 5103010, 'platform:warehouse:status', '启用停用', NULL, NULL, 30, 'ACTIVE', 0),
(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),
(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)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
@@ -168,7 +178,7 @@ 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 permission_code LIKE 'platform:order%'
WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
+93 -1
View File
@@ -18,6 +18,25 @@ tags:
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
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` 权限。
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}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses:
'200':
description: 企业客户分页查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/TenantPageApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'422': {$ref: '#/components/responses/UnprocessableEntity'}
post:
tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业租户
@@ -32,6 +51,32 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/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`。
responses:
'200':
description: 当前平台成员权限查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/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. / 返回当前有效商户成员权限编码。
responses:
'200':
description: 当前商户成员权限查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PermissionCodesApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/reviews:
get:
tags: [Platform Tenant / 平台租户]
@@ -79,7 +124,11 @@ paths:
tags: [IAM Resources / IAM 资源]
summary: 查询平台内部用户
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 平台内部用户查询成功,公开主键为 membershipId 并回显 roleIds
content:
application/json:
schema: {$ref: '#/components/schemas/InternalUserListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
@@ -326,6 +375,7 @@ components:
Forbidden: {description: Permission or tenant scope rejected / 权限或租户范围不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas:
ApiResponse:
type: object
@@ -337,6 +387,48 @@ components:
requestId: {type: string}
traceId: {type: string}
timestamp: {type: string, format: date-time}
PermissionCodesApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: array
uniqueItems: true
items: {type: string}
TenantPageApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/TenantResponse'}}
page: {type: integer, format: int64, minimum: 1}
size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
InternalUserListApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/InternalUserResponse'}}
TenantResponse:
type: object
required: [id, tenantCode, tenantName, status]
properties:
id: {type: integer, format: int64}
tenantCode: {type: string}
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
legalName: {type: string}
unifiedSocialCreditCode: {type: string}
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
RegisterRequest:
type: object
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone]
@@ -3,6 +3,7 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
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.Test;
import org.springframework.test.util.ReflectionTestUtils;
@@ -56,6 +57,43 @@ class AuthorizationSnapshotServiceTest {
verify(mapper).countActiveIdentity(42L, 7L, 9L);
}
@Test
void loadsCurrentPlatformPermissionsWithoutRoleManagementPermission() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(List.of("ORDER_OPERATOR"));
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM"))
.thenReturn(List.of("platform:order:list"));
var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(result).containsExactly("platform:order:list");
verify(mapper).findPermissions(42L, 0L, 9L, "PLATFORM");
}
@Test
void rejectsIncompleteCurrentIdentityAsBusinessError() {
assertThatThrownBy(() -> service(mock(AuthorizationQueryMapper.class))
.currentPermissions(new AuthenticatedUser(42L, 0L, 0L, null,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM"))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.UNAUTHORIZED));
}
@Test
void 空角色和权限查询结果不应触发五百错误() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 0L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
when(mapper.findPermissions(42L, 0L, 9L, "PLATFORM")).thenReturn(null);
var result = service(mapper).currentPermissions(new AuthenticatedUser(42L, 0L, 0L, 9L,
"operator", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(result).isEmpty();
}
private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) {
AuthorizationSnapshotService service = new AuthorizationSnapshotService();
ReflectionTestUtils.setField(service, "mapper", mapper);