This commit is contained in:
2026-08-08 09:17:42 +08:00
parent 4a6f976a91
commit 5951e6aa68
27 changed files with 598 additions and 13 deletions
@@ -22,6 +22,12 @@ 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);
/** Idempotently disables and revokes all accounts belonging to a customer. */
@PutMapping("/tenant/{tenantId}/archive") ApiResponse<Void> archiveTenant(
@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long tenantId);
@PostMapping("/impersonation-tickets") ApiResponse<ImpersonationTicketResponse> createImpersonationTicket(
@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody ImpersonationTicketRequest 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,
@@ -46,6 +52,11 @@ public interface AuthAccountClient {
}
record StatusRequest(String status) { }
record ProfileRequest(String displayName) { }
record PasswordRequest(String newPassword) { }
record PasswordRequest(String newPassword, boolean forceChangePassword) {
public PasswordRequest(String newPassword) { this(newPassword, false); }
}
record ScopeRequest(Long tenantId, Long membershipId, String appCode) { }
record ImpersonationTicketRequest(Long targetAuthUserId, Long tenantId, Long membershipId,
Long actorUserId, Long actorMembershipId, String reason) { }
record ImpersonationTicketResponse(String code, String ticketId, long expiresIn) { }
}
@@ -49,6 +49,7 @@ public class MerchantTenantController {
public ApiResponse<PersonalProfileResponse> updatePersonalProfile(
@Valid @RequestBody UpdatePersonalProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
long tenantId = TenantGuard.tenant(user, "MERCHANT");
return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request));
}
@@ -67,6 +68,7 @@ public class MerchantTenantController {
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile updated / 更新成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid profile / 资料不合法")})
public ApiResponse<TenantResponse> update(@Valid @RequestBody UpdateProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
}
@@ -99,6 +101,7 @@ public class MerchantTenantController {
@PreAuthorize("hasAuthority('merchant:member:create')")
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request,
user.permissions()));
}
@@ -108,6 +111,7 @@ public class MerchantTenantController {
@Operation(summary = "编辑企业成员及角色")
public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId,
@Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request,
user.permissions()));
}
@@ -117,6 +121,7 @@ public class MerchantTenantController {
@Operation(summary = "启用或停用企业成员")
public ApiResponse<MemberResponse> changeMemberStatus(@PathVariable Long membershipId,
@Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"),
membershipId, request, user.membershipId()));
}
@@ -126,6 +131,7 @@ public class MerchantTenantController {
@Operation(summary = "管理员重置成员密码")
public ApiResponse<Void> resetMemberPassword(@PathVariable Long membershipId,
@Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request);
return ApiResponse.success(null);
}
@@ -8,7 +8,10 @@ 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.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
@@ -128,6 +131,37 @@ public class PlatformTenantController {
return ApiResponse.success(service.changeMerchantStatus(tenantId, request));
}
@PutMapping("/tenants/{tenantId:\\d+}/owner/password")
@PreAuthorize("hasAuthority('platform:customer:reset-password')")
@Operation(summary = "重置客户企业管理员密码")
public ApiResponse<Void> resetOwnerPassword(@PathVariable long tenantId,
@Valid @RequestBody ResetCustomerOwnerPasswordRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.resetOwnerPassword(tenantId, request);
return ApiResponse.success(null);
}
@PostMapping("/tenants/{tenantId:\\d+}/archive")
@PreAuthorize("hasAuthority('platform:customer:delete')")
@Operation(summary = "逻辑归档客户", description = "停用客户并撤销其全部账号,保留业务历史数据")
public ApiResponse<TenantResponse> archive(@PathVariable long tenantId,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.archiveMerchant(tenantId));
}
@PostMapping("/tenants/{tenantId:\\d+}/impersonation")
@PreAuthorize("hasAuthority('platform:customer:impersonate')")
@Operation(summary = "创建客户代登录授权码", description = "创建受审计、60 秒有效且只能兑换一次的客户企业管理员授权码")
public ApiResponse<CustomerImpersonationResponse> createImpersonation(
@PathVariable long tenantId,
@Valid @RequestBody CustomerImpersonationRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.createImpersonationTicket(tenantId, request, user));
}
@PostMapping("/tenants/{tenantId:\\d+}/submit-review")
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
@Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。")
@@ -49,6 +49,7 @@ public class RbacController {
@Valid @RequestBody CreateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request));
}
@@ -59,6 +60,7 @@ public class RbacController {
@Valid @RequestBody UpdateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request));
}
@@ -69,6 +71,7 @@ public class RbacController {
@Valid @RequestBody ChangeRoleStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request));
}
@@ -78,6 +81,7 @@ public class RbacController {
public ApiResponse<Void> deleteRole(@PathVariable String app, @PathVariable Long roleId,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.deleteRole(scope.tenantId(), scope.appCode(), roleId);
return ApiResponse.success(null);
}
@@ -99,6 +103,7 @@ public class RbacController {
@Valid @RequestBody AssignRolesRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.assignRoles(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null);
}
@@ -119,6 +124,7 @@ public class RbacController {
@Valid @RequestBody CreatePermissionRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
}
@@ -129,6 +135,7 @@ public class RbacController {
@Valid @RequestBody UpdatePermissionRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.updatePermission(scope.tenantId(), scope.appCode(), permissionId, request));
}
@@ -139,6 +146,7 @@ public class RbacController {
@Valid @RequestBody ChangePermissionStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
return ApiResponse.success(service.changePermissionStatus(scope.tenantId(), scope.appCode(), permissionId, request));
}
@@ -148,6 +156,7 @@ public class RbacController {
public ApiResponse<Void> deletePermission(@PathVariable String app, @PathVariable Long permissionId,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.deletePermission(scope.tenantId(), scope.appCode(), permissionId);
return ApiResponse.success(null);
}
@@ -159,6 +168,7 @@ public class RbacController {
@Valid @RequestBody GrantPermissionsRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
TenantGuard.rejectImpersonated(user);
service.grantPermissions(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null);
}
@@ -78,6 +78,24 @@ public final class TenantDtos {
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED") String status
) { }
@Schema(name = "ResetCustomerOwnerPasswordRequest", description = "Reset the enterprise owner's login password")
public record ResetCustomerOwnerPasswordRequest(
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String newPassword,
Boolean forceChangePassword
) {
public boolean forceChange() { return forceChangePassword == null || forceChangePassword; }
}
@Schema(name = "CustomerImpersonationRequest", description = "Reason for audited customer owner impersonation")
public record CustomerImpersonationRequest(
@NotBlank @Size(min = 5, max = 500) String reason
) { }
@Schema(name = "CustomerImpersonationResponse", description = "One-time short-lived impersonation authorization code")
public record CustomerImpersonationResponse(String code, String ticketId, long expiresIn) { }
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
public record ReviewRequest(
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
@@ -13,6 +13,9 @@ public final class TenantGuard {
return 0L;
}
if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户");
return user.tenantId();
return user.tenantId();
}
public static void rejectImpersonated(AuthenticatedUser user){
if(user!=null&&user.impersonated()) throw new BizException(ResultCode.FORBIDDEN,"代登录会话不能执行账号或权限安全操作");
}
}
@@ -6,12 +6,17 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
@@ -20,6 +25,8 @@ import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
@@ -46,6 +53,8 @@ public class TenantService {
private ProfileResourceRepository profileRepository;
@Resource
private AuthAccountClient authClient;
@Resource
private MemberRepository memberRepository;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
@Resource
@@ -53,6 +62,83 @@ public class TenantService {
@Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台重置客户企业管理员密码",
captureBefore = false, captureAfter = false)
public void resetOwnerPassword(long tenantId, ResetCustomerOwnerPasswordRequest request) {
requireMerchant(tenantId);
IamMembership owner = memberRepository.findOwnerByTenant(tenantId);
if (owner == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在");
IamUser user = memberRepository.findUser(owner.getUserId());
if (user == null || user.getAuthUserId() == null) throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在");
try {
ApiResponse<Void> result = authClient.reset(internalToken, user.getAuthUserId(),
new AuthAccountClient.PasswordRequest(request.newPassword(), request.forceChange()));
if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
}
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_ARCHIVE", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台逻辑归档客户",
captureBefore = false, captureAfter = false)
public TenantResponse archiveMerchant(long tenantId) {
IamTenant tenant = requireMerchantForUpdate(tenantId);
if ("DISABLED".equals(tenant.getStatus())) return response(tenant);
if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档");
}
if (repository.findPendingReviewByTenant(tenantId) != null) {
throw new BizException(ResultCode.CONFLICT, "客户存在待审核记录,不能归档");
}
try {
ApiResponse<Void> result = authClient.archiveTenant(internalToken, tenantId);
if (result == null || !result.successful()) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
tenant.setStatus("DISABLED");
repository.saveTenant(tenant);
return response(tenant);
}
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_IMPERSONATION_TICKET_CREATE", resourceType = "TENANT",
resourceId = "#tenantId", description = "平台创建客户代登录授权码",
captureBefore = false, captureAfter = false)
public CustomerImpersonationResponse createImpersonationTicket(long tenantId,
CustomerImpersonationRequest request,
AuthenticatedUser actor) {
IamTenant tenant = requireMerchant(tenantId);
if (!"ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "只有已启用客户可以代登录");
}
IamMembership owner = memberRepository.findOwnerByTenant(tenantId);
if (owner == null || !"ACTIVE".equals(owner.getStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "企业管理员不存在或已停用");
}
IamUser ownerUser = memberRepository.findUser(owner.getUserId());
if (ownerUser == null || ownerUser.getAuthUserId() == null || !"ACTIVE".equals(ownerUser.getStatus())) {
throw new BizException(ResultCode.NOT_FOUND, "企业管理员账号不存在或已停用");
}
try {
ApiResponse<AuthAccountClient.ImpersonationTicketResponse> result = authClient.createImpersonationTicket(
internalToken,
new AuthAccountClient.ImpersonationTicketRequest(ownerUser.getAuthUserId(), tenantId, owner.getId(),
actor.userId(), actor.membershipId(), request.reason().trim()));
if (result == null || !result.successful() || result.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
AuthAccountClient.ImpersonationTicketResponse ticket = result.data();
return new CustomerImpersonationResponse(ticket.code(), ticket.ticketId(), ticket.expiresIn());
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
}
@BusinessAudit(
event = "TENANT_REGISTER",
@@ -36,6 +36,9 @@ 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),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0),
(5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 1, 0),
(5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 1, 0),
(5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, '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),
@@ -58,7 +61,8 @@ 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:customer:edit', 'platform:customer:status', 'platform:customer:reset-password',
'platform:customer:delete', 'platform:customer:impersonate', '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:import',
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status',
@@ -7,6 +7,9 @@ INSERT INTO iam_permission
permission_name, sort_no, status, deleted)
VALUES
(5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, 'ACTIVE', 0),
(5100016, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:reset-password', '重置客户管理员密码', 50, 'ACTIVE', 0),
(5100017, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:delete', '归档企业客户', 60, 'ACTIVE', 0),
(5100018, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:impersonate', '跳转客户登录', 70, 'ACTIVE', 0),
(5101023, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:edit', '编辑角色', 30, 'ACTIVE', 0),
(5101024, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:status', '启停角色', 40, 'ACTIVE', 0),
(5101025, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:delete', '删除角色', 50, 'ACTIVE', 0),
@@ -183,6 +183,63 @@ paths:
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/{tenantId}/owner/password:
parameters:
- {$ref: '#/components/parameters/TenantId'}
put:
tags: [Platform Tenant / 平台租户]
summary: 重置客户企业管理员密码
description: 仅重置指定客户 OWNER 的认证密码;密码不写入审计快照并撤销该账号现有会话。
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ResetCustomerOwnerPasswordRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/platform/v1/iam/tenants/{tenantId}/archive:
parameters:
- {$ref: '#/components/parameters/TenantId'}
post:
tags: [Platform Tenant / 平台租户]
summary: 逻辑归档客户
description: 仅停用客户及其认证账号,保留业务历史数据;重复归档幂等返回当前客户。
security: [{bearerAuth: []}]
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/platform/v1/iam/tenants/{tenantId}/impersonation:
parameters:
- {$ref: '#/components/parameters/TenantId'}
post:
tags: [Platform Tenant / 平台租户]
summary: 创建客户代登录授权码
description: 创建 60 秒有效、只能兑换一次且不包含刷新令牌的客户 OWNER 代登录授权码。
security: [{bearerAuth: []}]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerImpersonationRequest'}
responses:
'200':
description: 授权码创建成功
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerImpersonationApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
'503': {$ref: '#/components/responses/ServiceUnavailable'}
/api/merchant/v1/iam/authorization/permissions:
get:
tags: [IAM Resources / IAM 资源]
@@ -770,6 +827,7 @@ components:
Forbidden: {description: Permission, customer scope or platform technical 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'}}}}
ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas:
ApiResponse:
@@ -1022,6 +1080,29 @@ components:
required: [status]
properties:
status: {type: string, enum: [ACTIVE, DISABLED]}
ResetCustomerOwnerPasswordRequest:
type: object
required: [newPassword]
properties:
newPassword: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
forceChangePassword: {type: boolean, default: true}
CustomerImpersonationRequest:
type: object
required: [reason]
properties:
reason: {type: string, minLength: 5, maxLength: 500}
CustomerImpersonationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [code, ticketId, expiresIn]
properties:
code: {type: string, writeOnly: true}
ticketId: {type: string, format: uuid}
expiresIn: {type: integer, example: 60}
ReviewRequest:
type: object
properties:
@@ -66,6 +66,25 @@ class PlatformTenantControllerContractTest {
returnType.getActualTypeArguments()[0]);
}
@Test
void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception {
Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class,
com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/owner/password", method.getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:reset-password')", method.getAnnotation(PreAuthorize.class).value());
}
@Test
void shouldExposeArchiveAndImpersonationWithDedicatedPermissions() throws Exception {
Method archive = PlatformTenantController.class.getMethod("archive", long.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/archive", archive.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:delete')", archive.getAnnotation(PreAuthorize.class).value());
Method impersonation = PlatformTenantController.class.getMethod("createImpersonation", long.class,
com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest.class, AuthenticatedUser.class);
assertEquals("/tenants/{tenantId:\\d+}/impersonation", impersonation.getAnnotation(org.springframework.web.bind.annotation.PostMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:impersonate')", impersonation.getAnnotation(PreAuthorize.class).value());
}
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
return method.getParameters()[parameter].getAnnotation(type);
}
@@ -8,6 +8,11 @@ import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.MemberRepository;
@@ -137,6 +142,57 @@ class TenantServiceTest {
verify(authClient).availability("internal-token", "13800138000");
}
@Test
void resetOwnerPasswordResolvesOwnerAuthAccountAndForwardsForceChangeFlag() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L);
owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE");
when(members.findOwnerByTenant(10001L)).thenReturn(owner);
when(members.findUser(71L)).thenReturn(user);
when(authClient.reset(eq("internal-token"), eq(72L), any(AuthAccountClient.PasswordRequest.class)))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(null));
service.resetOwnerPassword(10001L, new ResetCustomerOwnerPasswordRequest("NewPass2", null));
verify(authClient).reset(eq("internal-token"), eq(72L),
argThat(request -> request.forceChangePassword() && "NewPass2".equals(request.newPassword())));
}
@Test
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
IamTenant tenant = merchant(10001L, "DISABLED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
verify(authClient, never()).archiveTenant(any(), any());
verify(repository, never()).saveTenant(any());
}
@Test
void impersonationTicketUsesActiveOwnerAndCurrentPlatformActor() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
IamMembership owner = new IamMembership(); owner.setId(70L); owner.setTenantId(10001L);
owner.setUserId(71L); owner.setMemberType("OWNER"); owner.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(71L); user.setAuthUserId(72L); user.setStatus("ACTIVE");
when(members.findOwnerByTenant(10001L)).thenReturn(owner);
when(members.findUser(71L)).thenReturn(user);
when(authClient.createImpersonationTicket(eq("internal-token"), any(AuthAccountClient.ImpersonationTicketRequest.class)))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(
new AuthAccountClient.ImpersonationTicketResponse("one-time-code", "ticket-1", 60)));
AuthenticatedUser actor = new AuthenticatedUser(9001L, 0L, 0L, 9002L,
"operator", "PLATFORM", "WEB", java.util.Set.of("platform:customer:impersonate"));
var result = service.createImpersonationTicket(10001L,
new CustomerImpersonationRequest("support investigation"), actor);
assertEquals("one-time-code", result.code());
verify(authClient).createImpersonationTicket(eq("internal-token"), argThat(request ->
request.targetAuthUserId().equals(72L) && request.tenantId().equals(10001L)
&& request.membershipId().equals(70L) && request.actorUserId().equals(9001L)
&& request.actorMembershipId().equals(9002L)));
}
@Test
void platformCannotBypassReviewToActivatePendingTenant() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));