暂存提交

This commit is contained in:
2026-07-30 18:01:36 +08:00
commit 2f4e159356
368 changed files with 20370 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
# IAM 一期交付说明 / IAM Phase 1 Delivery
## 数据库归属 / Database ownership
全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。商户数据通过 `tenant_id` 强制隔离;平台租户使用 `tenant_id=0`,权限通过 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。
All tables belong to `baiyue_iam`, accessed only by `iam-service`. `auth-service` owns credentials and tokens in `baiyue_auth`. `iam_user.auth_user_id` is a logical cross-database reference; no cross-database foreign keys are used. Every merchant query must constrain `tenant_id`. Platform scope uses `tenant_id=0` and `app_code=PLATFORM`; merchant scope uses `app_code=MERCHANT`.
## 代码落位 / Code placement
将 `src/main/java` 下的包合并到 `D:/baiyue/iam/src/main/java`;SQL 执行 `db/baiyue_iam.sql`。代码基于 Java 17、Spring Boot 3.2、MyBatis-Plus 3.5.7,并返回现有 `ApiResponse`。
Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; execute `db/baiyue_iam.sql`. The skeleton targets Java 17, Spring Boot 3.2 and MyBatis-Plus 3.5.7, returning the existing `ApiResponse` envelope.
## 接口清单 / API catalogue
| Method | Path | App | Purpose / 用途 |
|---|---|---|---|
| POST | `/api/platform/v1/iam/tenants` | PLATFORM | Submit enterprise registration / 企业注册 |
| GET | `/api/platform/v1/iam/reviews` | PLATFORM | List pending reviews / 待审核列表 |
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 |
| POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 |
| GET/PUT | `/api/merchant/v1/iam/tenant/profile` | MERCHANT | View/update own profile / 企业资料 |
| GET/POST | `/api/merchant/v1/iam/members` | MERCHANT | List/add members / 成员 |
| GET/POST | `/api/{app}/v1/iam/roles` | PLATFORM/MERCHANT | Role management / 角色 |
| GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 |
OpenAPI is synchronized in `openapi/iam-v1.yaml`.
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>iam-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,16 @@
package com.baiyue.iam;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
/**
* 身份与权限服务启动入口,负责装配租户、成员、双端 RBAC 和授权快照相关组件。
*/
@MapperScan("com.baiyue.iam.mapper")
@EnableFeignClients(basePackages = "com.baiyue.iam.client")
@SpringBootApplication(scanBasePackages = "com.baiyue")
public class BaiyueIamApplication {
public static void main(String[] args) { SpringApplication.run(BaiyueIamApplication.class, args); }
}
@@ -0,0 +1,27 @@
package com.baiyue.iam.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.*;
/** auth-service 内部账号管理客户端。 */
@FeignClient(name = "auth-service", contextId = "authAccountClient",
url = "${baiyue.clients.auth.url:}", path = "/internal/auth/accounts")
public interface AuthAccountClient {
@PostMapping ApiResponse<AccountSummary> create(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody CreateAccountRequest request);
@GetMapping("/{id}") ApiResponse<AccountSummary> summary(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id);
@PutMapping("/{id}/status") ApiResponse<Void> status(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody StatusRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request);
@DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id);
record CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId) { }
record AccountSummary(Long accountId, String username, String displayName, String status) { }
record StatusRequest(String status) { }
record PasswordRequest(String newPassword) { }
}
@@ -0,0 +1,20 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* IAM 服务健康探针接口,用于确认身份与权限服务实例可用。
*/
@RestController
@RequestMapping("/api/platform/v1/iam")
public class IamHealthController {
@GetMapping("/status")
public ApiResponse<Map<String, String>> status() {
return ApiResponse.success(Map.of("service", "iam-service", "status", "UP"));
}
}
@@ -0,0 +1,52 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotRequest;
import com.baiyue.iam.dto.AuthorizationDtos.SnapshotResponse;
import com.baiyue.iam.service.AuthorizationSnapshotService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* IAM 内部授权接口,仅供认证服务查询账号在指定租户和应用域下的角色、权限及数据范围快照。
*/
@RestController
@RequestMapping("/internal/iam")
public class InternalAuthorizationController {
@Resource
private AuthorizationSnapshotService service;
@Value("${baiyue.security.internal-service-token}")
private String internalTokenValue;
private byte[] internalToken;
@PostConstruct
void initializeInternalToken() {
if (internalTokenValue == null || internalTokenValue.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/authorization-snapshot")
public ApiResponse<SnapshotResponse> load(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody SnapshotRequest request) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) {
throw new BizException(ResultCode.FORBIDDEN, "无效的服务间凭据");
}
return ApiResponse.success(service.load(request));
}
}
@@ -0,0 +1,65 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse;
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/iam")
public class InternalTenantController {
@Resource private MerchantOnboardingService onboardingService;
@Resource private ProfileResourceService profileResourceService;
@Resource private TenantService tenantService;
@Value("${baiyue.security.internal-service-token}") private String internalTokenValue;
private byte[] internalToken;
@PostConstruct
void initializeToken() { internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); }
@PostMapping("/merchant-registrations")
public ApiResponse<MerchantRegistrationResponse> register(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody MerchantRegistrationRequest request) {
verify(token);
return ApiResponse.success(onboardingService.register(request));
}
@PostMapping("/qualifications")
public ApiResponse<QualificationSubmissionResponse> submitQualification(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody QualificationSubmissionRequest request) {
verify(token);
return ApiResponse.success(profileResourceService.submitQualification(request));
}
@GetMapping("/tenants/{tenantId}/order-eligibility")
public ApiResponse<OrderEligibilityResponse> orderEligibility(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long tenantId) {
verify(token);
return ApiResponse.success(tenantService.orderEligibility(tenantId));
}
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) {
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
}
}
}
@@ -0,0 +1,122 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.service.MemberService;
import com.baiyue.iam.service.PersonalProfileService;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 商户端租户接口,提供当前租户资料维护、成员列表与成员新增能力,并强制使用登录租户边界。
*/
@RestController
@RequestMapping("/api/merchant/v1/iam")
@Tag(name = "Merchant tenant and members / 商户企业与成员", description = "Manage the current JWT tenant profile and enterprise members. / 管理当前 JWT 租户的企业资料和成员。")
public class MerchantTenantController {
@Resource
private TenantService tenantService;
@Resource
private MemberService memberService;
@Resource
private PersonalProfileService personalProfileService;
@GetMapping("/profile")
@Operation(summary = "查看当前登录用户个人资料")
public ApiResponse<PersonalProfileResponse> personalProfile(@AuthenticationPrincipal AuthenticatedUser user) {
long tenantId = TenantGuard.tenant(user, "MERCHANT");
return ApiResponse.success(personalProfileService.get(tenantId, user.userId(), user.username()));
}
@PutMapping("/profile")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "编辑当前登录用户个人资料")
public ApiResponse<PersonalProfileResponse> updatePersonalProfile(
@Valid @RequestBody UpdatePersonalProfileRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
long tenantId = TenantGuard.tenant(user, "MERCHANT");
return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request));
}
@GetMapping("/tenant/profile")
@Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Profile returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Tenant not found / 企业不存在")})
public ApiResponse<TenantResponse> profile(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.profile(TenantGuard.tenant(user, "MERCHANT")));
}
@PutMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:edit')")
@Operation(summary = "Update current enterprise profile / 更新当前企业资料", description = "Update basic profile fields within the authenticated tenant. / 在当前认证租户内更新企业基础资料。")
@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) {
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
}
@GetMapping("/members")
@Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")})
@PreAuthorize("hasAuthority('merchant:member:list')")
public ApiResponse<List<MemberResponse>> members(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(memberService.list(TenantGuard.tenant(user, "MERCHANT")));
}
@GetMapping("/members/{membershipId:\\d+}")
@PreAuthorize("hasAuthority('merchant:member:list')")
@Operation(summary = "查询成员详情及当前角色")
public ApiResponse<MemberResponse> member(@PathVariable Long membershipId,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(memberService.detail(TenantGuard.tenant(user, "MERCHANT"), membershipId));
}
@PostMapping("/members")
@Operation(summary = "Add enterprise member / 新增企业成员", description = "Create a membership under the current JWT tenant. / 在当前 JWT 租户下创建成员关系。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Member already exists / 成员已存在")})
@PreAuthorize("hasAuthority('merchant:member:create')")
public ApiResponse<MemberResponse> addMember(@Valid @RequestBody CreateMemberRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(memberService.create(TenantGuard.tenant(user, "MERCHANT"), request));
}
@PutMapping("/members/{membershipId:\\d+}")
@PreAuthorize("hasAuthority('merchant:member:edit')")
@Operation(summary = "编辑企业成员及角色")
public ApiResponse<MemberResponse> updateMember(@PathVariable Long membershipId,
@Valid @RequestBody UpdateMemberRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(memberService.update(TenantGuard.tenant(user, "MERCHANT"), membershipId, request));
}
@PutMapping("/members/{membershipId:\\d+}/status")
@PreAuthorize("hasAuthority('merchant:member:status')")
@Operation(summary = "启用或停用企业成员")
public ApiResponse<MemberResponse> changeMemberStatus(@PathVariable Long membershipId,
@Valid @RequestBody ChangeMemberStatusRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(memberService.changeStatus(TenantGuard.tenant(user, "MERCHANT"),
membershipId, request, user.membershipId()));
}
@PutMapping("/members/{membershipId:\\d+}/password")
@PreAuthorize("hasAuthority('merchant:member:reset-password')")
@Operation(summary = "管理员重置成员密码")
public ApiResponse<Void> resetMemberPassword(@PathVariable Long membershipId,
@Valid @RequestBody ResetMemberPasswordRequest request, @AuthenticationPrincipal AuthenticatedUser user) {
memberService.resetPassword(TenantGuard.tenant(user, "MERCHANT"), membershipId, request);
return ApiResponse.success(null);
}
}
@@ -0,0 +1,90 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
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.entity.IamTenantReview;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 后台端租户管理接口,提供商户入驻登记、待审列表和审批处理能力。
*/
@RestController
@RequestMapping("/api/platform/v1/iam")
@Tag(name = "Platform tenant administration / 平台企业管理", description = "Enterprise registration review and tenant activation for platform operators. / 平台运营人员进行企业注册审核和租户启用。")
public class PlatformTenantController {
@Resource
private TenantService service;
@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. / 创建待审核商户租户及注册审核单,需要平台端令牌。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在")
})
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.register(request));
}
@GetMapping("/reviews")
@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 / 查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")
})
public ApiResponse<List<IamTenantReview>> reviews(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pendingReviews());
}
@PostMapping("/reviews/{id}/approve")
@PreAuthorize("hasAuthority('platform:customer:approve')")
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
})
public ApiResponse<Void> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
@RequestBody ReviewRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.review(id, user.userId(), true, request.comment());
return ApiResponse.success(null);
}
@PostMapping("/reviews/{id}/reject")
@PreAuthorize("hasAuthority('platform:customer:reject')")
@Operation(summary = "Reject enterprise registration / 驳回企业注册", description = "Reject a pending review and retain the rejection comment. The operation is audited. / 驳回待审核企业并保留审核意见,操作写入审计日志。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Rejected / 已驳回"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
})
public ApiResponse<Void> reject(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
@RequestBody ReviewRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
service.review(id, user.userId(), false, request.comment());
return ApiResponse.success(null);
}
}
@@ -0,0 +1,90 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest;
import com.baiyue.iam.dto.ProfileDtos.QualificationRequest;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* IAM 档案资源接口,管理内部用户、租户地址和租户资质等身份附属资料。
*/
@RestController
@Tag(name = "IAM profiles and qualifications / IAM 用户、地址与资质", description = "Platform internal users and merchant tenant profile resources. / 平台内部用户及商户企业地址、资质资源。")
public class ProfileResourceController {
@Resource
private ProfileResourceService service;
@GetMapping("/api/platform/v1/iam/internal-users")
@Operation(summary = "List platform internal users / 查询平台内部用户", description = "Platform scope is required. / 需要平台端权限域。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User list returned / 查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")})
public ApiResponse<List<IamUser>> internalUsers(@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.internalUsers());
}
@PostMapping("/api/platform/v1/iam/internal-users")
@PreAuthorize("hasAuthority('platform:user:create')")
@Operation(summary = "Create platform internal user / 新增平台内部用户", description = "Create an IAM profile linked to an auth-service account. / 创建关联统一认证账号的 IAM 用户资料。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "User created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid user profile / 用户资料不合法")})
public ApiResponse<IamUser> addUser(@Valid @RequestBody CreateUserRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.addUser(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 / 查询成功")})
public ApiResponse<List<IamTenantAddress>> addresses(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.addresses(TenantGuard.tenant(user, "MERCHANT")));
}
@PostMapping("/api/merchant/v1/iam/tenant/addresses")
@PreAuthorize("hasAuthority('merchant:profile:edit')")
@Operation(summary = "Add enterprise address / 新增企业地址", description = "Add an operating, registered or return address to the current tenant. / 为当前租户新增经营、注册地址或退货地址。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Address created / 创建成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid address / 地址不合法")})
public ApiResponse<IamTenantAddress> addAddress(@Valid @RequestBody AddressRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.addAddress(TenantGuard.tenant(user, "MERCHANT"), request));
}
@GetMapping("/api/merchant/v1/iam/tenant/qualifications")
@Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")})
public ApiResponse<List<IamTenantQualification>> qualifications(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT")));
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications")
@PreAuthorize("hasAuthority('platform:customer:review')")
public ApiResponse<List<IamTenantQualification>> platformQualifications(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.qualifications(tenantId));
}
@PostMapping("/api/merchant/v1/iam/tenant/qualifications")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "Submit enterprise qualification / 提交企业资质", description = "Create a pending qualification record; document content is stored externally. / 创建待审核资质记录,文件内容由外部对象存储保存。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification submitted / 提交成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid qualification / 资质信息不合法")})
public ApiResponse<IamTenantQualification> addQualification(@Valid @RequestBody QualificationRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.addQualification(TenantGuard.tenant(user, "MERCHANT"), request));
}
}
@@ -0,0 +1,146 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.RbacDtos.*;
import com.baiyue.iam.entity.IamPermission;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.service.RbacService;
import com.baiyue.iam.service.TenantGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/** 平台端和商户端的角色权限管理接口。 */
@RestController
@RequestMapping("/api/{app}/v1/iam")
@Tag(name = "角色与权限管理")
public class RbacController {
@Resource private RbacService service;
@GetMapping("/roles")
@Operation(summary = "查询角色及当前权限")
@PreAuthorize("hasAuthority(#app + ':role:list')")
public ApiResponse<List<RoleResponse>> roles(@PathVariable String app,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.roles(scope.tenantId(), scope.appCode()));
}
@GetMapping("/roles/{roleId}")
@Operation(summary = "查询角色详情及当前权限")
@PreAuthorize("hasAuthority(#app + ':role:list')")
public ApiResponse<RoleResponse> role(@PathVariable String app, @PathVariable Long roleId,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.role(scope.tenantId(), scope.appCode(), roleId));
}
@PostMapping("/roles")
@Operation(summary = "创建角色")
@PreAuthorize("hasAuthority(#app + ':role:create')")
public ApiResponse<IamRole> createRole(@PathVariable String app,
@Valid @RequestBody CreateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.createRole(scope.tenantId(), scope.appCode(), request));
}
@PutMapping("/roles/{roleId}")
@Operation(summary = "编辑自定义角色")
@PreAuthorize("hasAuthority(#app + ':role:edit')")
public ApiResponse<RoleResponse> updateRole(@PathVariable String app, @PathVariable Long roleId,
@Valid @RequestBody UpdateRoleRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.updateRole(scope.tenantId(), scope.appCode(), roleId, request));
}
@PutMapping("/roles/{roleId}/status")
@Operation(summary = "启用或停用自定义角色")
@PreAuthorize("hasAuthority(#app + ':role:status')")
public ApiResponse<RoleResponse> changeStatus(@PathVariable String app, @PathVariable Long roleId,
@Valid @RequestBody ChangeRoleStatusRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.changeStatus(scope.tenantId(), scope.appCode(), roleId, request));
}
@DeleteMapping("/roles/{roleId}")
@Operation(summary = "删除未分配成员的自定义角色")
@PreAuthorize("hasAuthority(#app + ':role:delete')")
public ApiResponse<Void> deleteRole(@PathVariable String app, @PathVariable Long roleId,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
service.deleteRole(scope.tenantId(), scope.appCode(), roleId);
return ApiResponse.success(null);
}
@GetMapping("/members/{membershipId}/roles")
@Operation(summary = "查询成员当前角色")
@PreAuthorize("hasAuthority(#app + ':member:list')")
public ApiResponse<MemberRolesResponse> memberRoles(@PathVariable String app,
@PathVariable Long membershipId,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.memberRoles(scope.tenantId(), scope.appCode(), membershipId));
}
@PutMapping("/members/roles")
@Operation(summary = "全量设置成员角色,空集合表示清空")
@PreAuthorize("hasAuthority(#app + ':member:grant-role')")
public ApiResponse<Void> assignRoles(@PathVariable String app,
@Valid @RequestBody AssignRolesRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
service.assignRoles(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null);
}
@GetMapping("/permissions")
@Operation(summary = "查询权限资源")
@PreAuthorize("hasAuthority(#app + ':role:list')")
public ApiResponse<List<IamPermission>> permissions(@PathVariable String app,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.permissions(scope.tenantId(), scope.appCode()));
}
@PostMapping("/permissions")
@Operation(summary = "创建权限资源")
@PreAuthorize("hasAuthority(#app + ':permission:create')")
public ApiResponse<IamPermission> createPermission(@PathVariable String app,
@Valid @RequestBody CreatePermissionRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
return ApiResponse.success(service.createPermission(scope.tenantId(), scope.appCode(), request));
}
@PutMapping("/roles/permissions")
@Operation(summary = "全量设置角色权限,空集合表示清空")
@PreAuthorize("hasAuthority(#app + ':role:grant')")
public ApiResponse<Void> grant(@PathVariable String app,
@Valid @RequestBody GrantPermissionsRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
Scope scope = scope(app, user);
service.grantPermissions(scope.tenantId(), scope.appCode(), request);
return ApiResponse.success(null);
}
private Scope scope(String app, AuthenticatedUser user) {
String appCode = switch (app) {
case "platform" -> "PLATFORM";
case "merchant" -> "MERCHANT";
default -> throw new IllegalArgumentException("不支持的应用端");
};
return new Scope(TenantGuard.tenant(user, appCode), appCode);
}
private record Scope(long tenantId, String appCode) { }
}
@@ -0,0 +1,43 @@
package com.baiyue.iam.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Set;
/**
* 授权快照内部接口的数据契约集合,定义快照查询请求和包含角色、权限、数据范围的响应模型。
*/
public final class AuthorizationDtos {
private AuthorizationDtos() { }
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SnapshotRequest {
@NotNull
private Long userId;
@NotNull
private Long tenantId;
@NotNull
private Long membershipId;
@NotBlank
private String appCode;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class SnapshotResponse {
private Long userId;
private Long tenantId;
private Long membershipId;
private String appCode;
private String clientType;
private Set<String> roles;
private Set<String> permissions;
}
}
@@ -0,0 +1,51 @@
package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.util.List;
/** 企业成员接口的数据契约。 */
public final class MemberDtos {
private MemberDtos() { }
public record CreateMemberRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
List<Long> roleIds) {
public CreateMemberRequest {
roleIds = roleIds == null ? List.of() : List.copyOf(roleIds);
}
}
public record UpdateMemberRequest(
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
List<Long> roleIds) {
public UpdateMemberRequest {
roleIds = roleIds == null ? List.of() : List.copyOf(roleIds);
}
}
public record ChangeMemberStatusRequest(@NotBlank String status) { }
public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
public record MemberResponse(Long membershipId, Long userId, Long authUserId,
String displayName, String phone, String email,
String memberType, String memberNo, String status, List<Long> roleIds) { }
}
@@ -0,0 +1,32 @@
package com.baiyue.iam.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public final class OnboardingDtos {
private OnboardingDtos() { }
public record MerchantRegistrationRequest(
@NotNull Long authUserId,
@NotBlank String displayName,
@NotBlank String companyName,
@NotBlank String legalPerson,
@NotBlank String legalPhone,
String contactEmail,
String countryCode,
String address
) { }
public record MerchantRegistrationResponse(Long tenantId, Long membershipId, String tenantStatus) { }
public record QualificationSubmissionRequest(
@NotNull Long tenantId,
@NotBlank String qualificationType,
String certificateNo,
@NotBlank String documentUrl
) { }
public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
}
@@ -0,0 +1,30 @@
package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
/** 用户档案、地址和资质的数据契约。 */
public final class ProfileDtos {
private ProfileDtos() { }
public record CreateUserRequest(@NotNull Long authUserId, @NotBlank String userType,
@NotBlank String displayName, String phone, String email) { }
public record PersonalProfileResponse(Long userId, Long authUserId, String username,
String displayName, String phone, String email) { }
public record UpdatePersonalProfileRequest(@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Email @Size(max = 128) String email) { }
public record AddressRequest(@NotBlank String addressType, String countryCode, String province,
String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { }
public record QualificationRequest(@NotBlank String qualificationType, String certificateNo,
@NotBlank String documentUrl, LocalDate validFrom, LocalDate validTo) { }
}
@@ -0,0 +1,46 @@
package com.baiyue.iam.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.Set;
/** RBAC 管理接口的数据契约。 */
public final class RbacDtos {
private RbacDtos() { }
public record CreateRoleRequest(@NotBlank String roleCode,
@NotBlank String roleName,
String description) { }
public record UpdateRoleRequest(@NotBlank String roleName,
String description) { }
public record ChangeRoleStatusRequest(
@NotBlank @Pattern(regexp = "ACTIVE|DISABLED", message = "角色状态只能是 ACTIVE 或 DISABLED")
String status) { }
/** 角色回显模型,permissionIds 可直接用于全量授权保存。 */
public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode,
String roleName, String roleType, String status,
String description, Set<Long> permissionIds) { }
public record MemberRolesResponse(Long membershipId, Set<Long> roleIds) { }
public record CreatePermissionRequest(
@Schema(allowableValues = {"MENU", "PAGE", "BUTTON", "API"}) @NotBlank String type,
@NotBlank String code,
@NotBlank String name,
Long parentId,
String routePath,
String httpMethod,
Integer sortNo) { }
public record AssignRolesRequest(@NotNull Long membershipId,
@NotNull Set<Long> roleIds) { }
public record GrantPermissionsRequest(@NotNull Long roleId,
@NotNull Set<Long> permissionIds) { }
}
@@ -0,0 +1,42 @@
package com.baiyue.iam.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* 租户生命周期数据契约集合,定义入驻、资料更新、审核请求和租户响应。
*/
public final class TenantDtos {
private TenantDtos() { }
@Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料")
public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail
) { }
@Schema(name = "TenantProfileUpdateRequest", description = "Enterprise profile update / 企业资料更新")
public record UpdateProfileRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
) { }
@Schema(name = "TenantReviewRequest", description = "Enterprise review comment / 企业审核意见")
public record ReviewRequest(
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
String legalName, String unifiedSocialCreditCode, String contactName,
String contactPhone, String contactEmail) { }
}
@@ -0,0 +1,18 @@
package com.baiyue.iam.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
/** 角色数据范围规则,仅用于保证角色删除时关联数据一致清理。 */
@Getter
@Setter
@TableName("iam_data_scope")
public class IamDataScope {
@TableId private Long id;
private Long roleId;
private Long tenantId;
private String scopeType;
private String scopeExpression;
}
@@ -0,0 +1,14 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime;
/**
* 租户成员关系实体,对应表 {@code iam_membership};关联用户与租户并保存成员类型和状态,是租户内授权的主体。
*/
@Getter
@Setter
@TableName("iam_membership") public class IamMembership extends BaseEntity {
@TableId private Long id; private Long tenantId; private Long userId; private String memberType; private String memberNo; private String status; private LocalDateTime joinedAt; private LocalDateTime leftAt;
}
@@ -0,0 +1,14 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime;
/**
* 成员角色关联实体,对应表 {@code iam_membership_role};在租户维度记录成员获授角色的多对多关系。
*/
@Getter
@Setter
@TableName("iam_membership_role") public class IamMembershipRole {
private Long membershipId; private Long roleId; private Long tenantId; private LocalDateTime createdAt;
}
@@ -0,0 +1,72 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
/**
* 权限资源实体,对应表 {@code iam_permission};按租户和应用域定义菜单、按钮或接口权限编码及资源属性。
*/
@Getter
@Setter
@TableName("iam_permission")
public class IamPermission {
@TableId
private Long id;
private Long tenantId;
private String appCode;
private String permissionType;
private Long parentId;
private String permissionCode;
private String permissionName;
private String routePath;
private String httpMethod;
private Integer sortNo;
private String status;
}
@@ -0,0 +1,58 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
/**
* 角色实体,对应表 {@code iam_role};按租户和应用域维护平台端或商户端角色,实现两套权限体系相互隔离。
*/
@Getter
@Setter
@TableName("iam_role")
public class IamRole extends BaseEntity {
@TableId
private Long id;
private Long tenantId;
private String appCode;
private String roleCode;
private String roleName;
private String roleType;
private String status;
private String description;
}
@@ -0,0 +1,14 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime;
/**
* 角色权限关联实体,对应表 {@code iam_role_permission};在租户维度记录角色获授菜单、按钮或接口权限的关系。
*/
@Getter
@Setter
@TableName("iam_role_permission") public class IamRolePermission {
private Long roleId; private Long permissionId; private Long tenantId; private LocalDateTime createdAt;
}
@@ -0,0 +1,42 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.LocalDateTime;
/**
* 租户主数据实体,对应表 {@code iam_tenant};保存商户租户编码、企业信息和生命周期状态,是 SaaS 租户隔离的核心边界。
*/
@Getter
@Setter
@TableName("iam_tenant")
public class IamTenant extends BaseEntity {
@TableId private Long id;
private String tenantCode;
private String tenantName;
private String tenantType;
private String status;
private String legalName;
private String unifiedSocialCreditCode;
private String contactName;
private String contactPhone;
private String contactEmail;
private LocalDateTime approvedAt;
private Long approvedBy;
private String rejectReason;
}
@@ -0,0 +1,11 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName;
/**
* 租户地址实体,对应表 {@code iam_tenant_address};按租户保存经营、提货或联系地址及默认地址标识。
*/
@Getter
@Setter
@TableName("iam_tenant_address") public class IamTenantAddress { @TableId private Long id; private Long tenantId; private String addressType; private String countryCode; private String province; private String city; private String district; private String detail; private String postalCode; private Boolean isDefault; }
@@ -0,0 +1,11 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate;
/**
* 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。
*/
@Getter
@Setter
@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; }
@@ -0,0 +1,15 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDateTime;
/**
* 租户审核实体,对应表 {@code iam_tenant_review};记录商户入驻或资料变更的审核单、审核人、结论和意见。
*/
@Getter
@Setter
@TableName("iam_tenant_review")
public class IamTenantReview {
@TableId private Long id; private Long tenantId; private String reviewType; private String status; private Long reviewerId; private String reviewComment; private LocalDateTime reviewedAt; private LocalDateTime createdAt;
}
@@ -0,0 +1,11 @@
package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName;
/**
* IAM 用户档案实体,对应表 {@code iam_user};关联认证账号并保存内部或商户人员的展示名、联系方式和启停状态。
*/
@Getter
@Setter
@TableName("iam_user") public class IamUser { @TableId private Long id; private Long authUserId; private String userType; private String displayName; private String phone; private String email; private String status; }
@@ -0,0 +1,65 @@
package com.baiyue.iam.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
/**
* 授权快照只读查询 Mapper,聚合查询成员角色、权限编码和数据范围,避免在认证流程逐表读取。
*/
@Mapper
public interface AuthorizationQueryMapper {
@Select("""
SELECT COUNT(1)
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
WHERE u.auth_user_id = #{userId}
AND u.status = 'ACTIVE' AND u.deleted = 0
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
AND m.status = 'ACTIVE' AND m.deleted = 0
""")
int countActiveIdentity(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId);
@Select("""
SELECT DISTINCT r.role_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
WHERE u.auth_user_id = #{userId}
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
AND (r.tenant_id = 0 OR r.tenant_id = #{tenantId})
AND r.app_code = #{appCode}
AND r.status = 'ACTIVE' AND r.deleted = 0
ORDER BY r.role_code
""")
List<String> findRoles(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
@Select("""
SELECT DISTINCT p.permission_code
FROM iam_user u
JOIN iam_membership m ON m.user_id = u.id
JOIN iam_membership_role mr ON mr.membership_id = m.id AND mr.tenant_id = m.tenant_id
JOIN iam_role r ON r.id = mr.role_id
JOIN iam_role_permission rp ON rp.role_id = r.id
JOIN iam_permission p ON p.id = rp.permission_id
WHERE u.auth_user_id = #{userId}
AND m.id = #{membershipId} AND m.tenant_id = #{tenantId}
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'
ORDER BY p.permission_code
""")
List<String> findPermissions(@Param("userId") long userId,
@Param("tenantId") long tenantId,
@Param("membershipId") long membershipId,
@Param("appCode") String appCode);
}
@@ -0,0 +1,9 @@
package com.baiyue.iam.mapper;
import com.baiyue.iam.entity.IamDataScope;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
/** 角色数据范围 Mapper。 */
@Mapper
public interface IamDataScopeMapper extends BaseMapper<IamDataScope> { }
@@ -0,0 +1,4 @@
/**
* 租户成员关系 MyBatis-Plus Mapper,负责 {@code iam_membership} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamMembership; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamMembershipMapper extends BaseMapper<IamMembership>{}
@@ -0,0 +1,4 @@
/**
* 成员角色关联 MyBatis-Plus Mapper,负责 {@code iam_membership_role} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamMembershipRole; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamMembershipRoleMapper extends BaseMapper<IamMembershipRole>{}
@@ -0,0 +1,4 @@
/**
* 权限资源 MyBatis-Plus Mapper,负责 {@code iam_permission} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamPermission; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamPermissionMapper extends BaseMapper<IamPermission>{}
@@ -0,0 +1,4 @@
/**
* 角色 MyBatis-Plus Mapper,负责 {@code iam_role} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamRole; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamRoleMapper extends BaseMapper<IamRole>{}
@@ -0,0 +1,4 @@
/**
* 角色权限关联 MyBatis-Plus Mapper,负责 {@code iam_role_permission} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamRolePermission; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamRolePermissionMapper extends BaseMapper<IamRolePermission>{}
@@ -0,0 +1,4 @@
/**
* 租户地址 MyBatis-Plus Mapper,负责 {@code iam_tenant_address} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantAddress; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantAddressMapper extends BaseMapper<IamTenantAddress>{}
@@ -0,0 +1,4 @@
/**
* 租户主数据 MyBatis-Plus Mapper,负责 {@code iam_tenant} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenant; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantMapper extends BaseMapper<IamTenant>{}
@@ -0,0 +1,4 @@
/**
* 租户资质 MyBatis-Plus Mapper,负责 {@code iam_tenant_qualification} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantQualification; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantQualificationMapper extends BaseMapper<IamTenantQualification>{}
@@ -0,0 +1,4 @@
/**
* 租户审核 MyBatis-Plus Mapper,负责 {@code iam_tenant_review} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamTenantReview; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamTenantReviewMapper extends BaseMapper<IamTenantReview>{}
@@ -0,0 +1,4 @@
/**
* IAM 用户档案 MyBatis-Plus Mapper,负责 {@code iam_user} 表的基础持久化操作。
*/
package com.baiyue.iam.mapper; import com.baiyue.iam.entity.IamUser; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.apache.ibatis.annotations.Mapper; @Mapper public interface IamUserMapper extends BaseMapper<IamUser>{}
@@ -0,0 +1,79 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamMembershipRole;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamMembershipMapper;
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import com.baiyue.iam.mapper.IamUserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 企业成员仓储,所有成员查询均显式携带租户条件。 */
@Repository
public class MemberRepository {
private final IamMembershipMapper membershipMapper;
private final IamUserMapper userMapper;
private final IamMembershipRoleMapper roleMapper;
public MemberRepository(IamMembershipMapper membershipMapper, IamUserMapper userMapper,
IamMembershipRoleMapper roleMapper) {
this.membershipMapper = membershipMapper;
this.userMapper = userMapper;
this.roleMapper = roleMapper;
}
public List<IamMembership> findByTenant(long tenantId) {
return membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).orderByDesc(IamMembership::getJoinedAt));
}
public IamMembership findById(Long id) { return membershipMapper.selectById(id); }
public IamMembership findByTenantAndId(long tenantId, Long id) {
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1"));
}
public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) {
IamUser user = findUserByAuthId(authUserId);
if (user == null) return null;
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getUserId, user.getId()).last("LIMIT 1"));
}
public boolean exists(long tenantId, Long userId) {
return membershipMapper.selectCount(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getUserId, userId)) > 0;
}
public boolean existsMemberNo(long tenantId, String memberNo, Long excludedMembershipId) {
if (memberNo == null || memberNo.isBlank()) return false;
LambdaQueryWrapper<IamMembership> query = new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getMemberNo, memberNo);
if (excludedMembershipId != null) query.ne(IamMembership::getId, excludedMembershipId);
return membershipMapper.selectCount(query) > 0;
}
public IamUser findUser(Long id) { return userMapper.selectById(id); }
public IamUser findUserByAuthId(Long authId) {
return userMapper.selectOne(new LambdaQueryWrapper<IamUser>()
.eq(IamUser::getAuthUserId, authId).last("LIMIT 1"));
}
public List<Long> findRoleIds(long tenantId, Long membershipId) {
return roleMapper.selectList(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.eq(IamMembershipRole::getMembershipId, membershipId))
.stream().map(IamMembershipRole::getRoleId).toList();
}
public void replaceRoles(long tenantId, Long membershipId, List<Long> roleIds) {
roleMapper.delete(new QueryWrapper<IamMembershipRole>().eq("tenant_id", tenantId)
.eq("membership_id", membershipId));
for (Long roleId : roleIds) {
IamMembershipRole item = new IamMembershipRole();
item.setTenantId(tenantId); item.setMembershipId(membershipId); item.setRoleId(roleId);
item.setCreatedAt(java.time.LocalDateTime.now()); roleMapper.insert(item);
}
}
public void insert(IamMembership value) { membershipMapper.insert(value); }
public void save(IamMembership value) { membershipMapper.updateById(value); }
public void insertUser(IamUser value) { userMapper.insert(value); }
public void saveUser(IamUser value) { userMapper.updateById(value); }
}
@@ -0,0 +1,44 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamTenantAddressMapper;
import com.baiyue.iam.mapper.IamTenantQualificationMapper;
import com.baiyue.iam.mapper.IamUserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* IAM 档案资源仓储,封装用户档案、租户地址和租户资质的写入与分租户查询。
*/
@Repository
public class ProfileResourceRepository {
private final IamUserMapper userMapper;
private final IamTenantAddressMapper addressMapper;
private final IamTenantQualificationMapper qualificationMapper;
public ProfileResourceRepository(IamUserMapper userMapper, IamTenantAddressMapper addressMapper,
IamTenantQualificationMapper qualificationMapper) {
this.userMapper = userMapper;
this.addressMapper = addressMapper;
this.qualificationMapper = qualificationMapper;
}
public void insertUser(IamUser user) { userMapper.insert(user); }
public IamUser findUserByAuthId(Long authUserId) { return userMapper.selectOne(new LambdaQueryWrapper<IamUser>().eq(IamUser::getAuthUserId, authUserId).last("LIMIT 1")); }
public void saveUser(IamUser user) { userMapper.updateById(user); }
public void insertAddress(IamTenantAddress address) { addressMapper.insert(address); }
public void insertQualification(IamTenantQualification qualification) { qualificationMapper.insert(qualification); }
public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); }
public List<IamUser> findInternalUsers() { return userMapper.selectList(new LambdaQueryWrapper<IamUser>().eq(IamUser::getUserType, "INTERNAL")); }
public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); }
public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); }
public long countApprovedQualifications(long tenantId) {
return qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId)
.eq(IamTenantQualification::getStatus, "APPROVED"));
}
}
@@ -0,0 +1,150 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.entity.*;
import com.baiyue.iam.mapper.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */
@Repository
public class RbacRepository {
private final IamRoleMapper roleMapper;
private final IamPermissionMapper permissionMapper;
private final IamMembershipRoleMapper membershipRoleMapper;
private final IamRolePermissionMapper rolePermissionMapper;
private final IamDataScopeMapper dataScopeMapper;
public RbacRepository(IamRoleMapper roleMapper, IamPermissionMapper permissionMapper,
IamMembershipRoleMapper membershipRoleMapper,
IamRolePermissionMapper rolePermissionMapper,
IamDataScopeMapper dataScopeMapper) {
this.roleMapper = roleMapper;
this.permissionMapper = permissionMapper;
this.membershipRoleMapper = membershipRoleMapper;
this.rolePermissionMapper = rolePermissionMapper;
this.dataScopeMapper = dataScopeMapper;
}
public IamRole findRole(Long id, long tenantId, String appCode) {
return roleMapper.selectOne(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getId, id).eq(IamRole::getTenantId, tenantId)
.eq(IamRole::getAppCode, appCode));
}
public IamPermission findPermission(Long id, long tenantId, String appCode) {
return permissionMapper.selectOne(new LambdaQueryWrapper<IamPermission>()
.eq(IamPermission::getId, id)
.in(IamPermission::getTenantId, List.of(0L, tenantId))
.eq(IamPermission::getAppCode, appCode));
}
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 List<IamRole> findRoles(long tenantId, String appCode) {
return roleMapper.selectList(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode)
.orderByAsc(IamRole::getRoleCode));
}
public boolean existsRoleCode(long tenantId, String appCode, String roleCode, Long excludeId) {
LambdaQueryWrapper<IamRole> query = new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode)
.eq(IamRole::getRoleCode, roleCode);
if (excludeId != null) query.ne(IamRole::getId, excludeId);
return roleMapper.selectCount(query) > 0;
}
public List<IamPermission> findPermissions(long tenantId, String appCode) {
return permissionMapper.selectList(new LambdaQueryWrapper<IamPermission>()
.in(IamPermission::getTenantId, List.of(0L, tenantId))
.eq(IamPermission::getAppCode, appCode)
.orderByAsc(IamPermission::getPermissionCode));
}
public boolean existsPermissionCode(long tenantId, String appCode, String permissionCode) {
return permissionMapper.selectCount(new LambdaQueryWrapper<IamPermission>()
.eq(IamPermission::getTenantId, tenantId)
.eq(IamPermission::getAppCode, appCode)
.eq(IamPermission::getPermissionCode, permissionCode)) > 0;
}
public Set<Long> findRolePermissionIds(long tenantId, Long roleId) {
List<IamRolePermission> rows = rolePermissionMapper.selectList(
new LambdaQueryWrapper<IamRolePermission>()
.eq(IamRolePermission::getTenantId, tenantId)
.eq(IamRolePermission::getRoleId, roleId)
.orderByAsc(IamRolePermission::getPermissionId));
Set<Long> result = new LinkedHashSet<>();
rows.forEach(row -> result.add(row.getPermissionId()));
return result;
}
public Set<Long> findMemberRoleIds(long tenantId, Long membershipId) {
List<IamMembershipRole> rows = membershipRoleMapper.selectList(
new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.eq(IamMembershipRole::getMembershipId, membershipId)
.orderByAsc(IamMembershipRole::getRoleId));
Set<Long> result = new LinkedHashSet<>();
rows.forEach(row -> result.add(row.getRoleId()));
return result;
}
public boolean isRoleAssigned(long tenantId, Long roleId) {
return membershipRoleMapper.selectCount(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.eq(IamMembershipRole::getRoleId, roleId)) > 0;
}
public void deleteRole(long tenantId, String appCode, Long roleId) {
rolePermissionMapper.delete(new LambdaQueryWrapper<IamRolePermission>()
.eq(IamRolePermission::getTenantId, tenantId)
.eq(IamRolePermission::getRoleId, roleId));
dataScopeMapper.delete(new LambdaQueryWrapper<IamDataScope>()
.eq(IamDataScope::getTenantId, tenantId)
.eq(IamDataScope::getRoleId, roleId));
roleMapper.delete(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getTenantId, tenantId).eq(IamRole::getAppCode, appCode)
.eq(IamRole::getId, roleId));
}
public void replaceMemberRoles(long tenantId, String appCode, Long membershipId, Iterable<Long> roleIds) {
// 仅删除当前应用域角色,避免一个应用端保存时误清另一个应用端的角色。
List<Long> scopedRoleIds = findRoles(tenantId, appCode).stream().map(IamRole::getId).toList();
if (!scopedRoleIds.isEmpty()) {
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getTenantId, tenantId)
.eq(IamMembershipRole::getMembershipId, membershipId)
.in(IamMembershipRole::getRoleId, scopedRoleIds));
}
for (Long roleId : roleIds) {
IamMembershipRole relation = new IamMembershipRole();
relation.setMembershipId(membershipId);
relation.setRoleId(roleId);
relation.setTenantId(tenantId);
relation.setCreatedAt(LocalDateTime.now());
membershipRoleMapper.insert(relation);
}
}
public void replaceRolePermissions(long tenantId, Long roleId, Iterable<Long> permissionIds) {
rolePermissionMapper.delete(new LambdaQueryWrapper<IamRolePermission>()
.eq(IamRolePermission::getTenantId, tenantId)
.eq(IamRolePermission::getRoleId, roleId));
for (Long permissionId : permissionIds) {
IamRolePermission relation = new IamRolePermission();
relation.setRoleId(roleId);
relation.setPermissionId(permissionId);
relation.setTenantId(tenantId);
relation.setCreatedAt(LocalDateTime.now());
rolePermissionMapper.insert(relation);
}
}
}
@@ -0,0 +1,48 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.mapper.IamTenantMapper;
import com.baiyue.iam.mapper.IamTenantReviewMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。
*/
@Repository
public class TenantRepository {
private final IamTenantMapper tenantMapper;
private final IamTenantReviewMapper reviewMapper;
public TenantRepository(IamTenantMapper tenantMapper, IamTenantReviewMapper reviewMapper) {
this.tenantMapper = tenantMapper;
this.reviewMapper = reviewMapper;
}
public boolean existsByCreditCode(String creditCode) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0;
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); }
public IamTenantReview findPendingReviewByTenant(Long tenantId) {
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
.eq(IamTenantReview::getTenantId, tenantId)
.eq(IamTenantReview::getStatus, "PENDING")
.last("LIMIT 1"));
}
public void insertTenant(IamTenant tenant) { tenantMapper.insert(tenant); }
public void saveTenant(IamTenant tenant) { tenantMapper.updateById(tenant); }
public void insertReview(IamTenantReview review) { reviewMapper.insert(review); }
public void saveReview(IamTenantReview review) { reviewMapper.updateById(review); }
public List<IamTenantReview> findPendingReviews() {
return reviewMapper.selectList(new LambdaQueryWrapper<IamTenantReview>()
.eq(IamTenantReview::getStatus, "PENDING")
.orderByAsc(IamTenantReview::getId));
}
}
@@ -0,0 +1,48 @@
package com.baiyue.iam.service;
import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ResultCode;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* 授权快照服务,校验账号与成员关系,并聚合指定租户、应用域下的角色、权限和数据范围。
*/
@Service
public class AuthorizationSnapshotService {
private static final Set<String> APPS = Set.of("MERCHANT", "PLATFORM");
@Resource
private AuthorizationQueryMapper mapper;
@Transactional(readOnly = true)
public SnapshotResponse load(SnapshotRequest request) {
String appCode = request.getAppCode().trim().toUpperCase(java.util.Locale.ROOT);
if (!APPS.contains(appCode)) {
throw new BizException(ResultCode.FORBIDDEN, "未知的应用身份域");
}
if ("MERCHANT".equals(appCode) && request.getTenantId() <= 0) {
throw new BizException(ResultCode.FORBIDDEN, "商户账号缺少有效租户");
}
if ("PLATFORM".equals(appCode) && request.getTenantId() != 0) {
throw new BizException(ResultCode.FORBIDDEN, "平台账号必须属于平台租户");
}
if (mapper.countActiveIdentity(request.getUserId(), request.getTenantId(), request.getMembershipId()) == 0) {
throw new BizException(ResultCode.FORBIDDEN, "IAM 用户或成员身份无效");
}
Set<String> roles = new LinkedHashSet<>(mapper.findRoles(
request.getUserId(), request.getTenantId(), request.getMembershipId(), appCode));
Set<String> permissions = new LinkedHashSet<>(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));
}
}
@@ -0,0 +1,200 @@
package com.baiyue.iam.service;
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.audit.BusinessAudit;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** 企业成员闭环服务,账号凭据归 auth-service,人员档案与租户关系归 IAM。 */
@Service
public class MemberService {
private static final Logger log = LoggerFactory.getLogger(MemberService.class);
private final MemberRepository repository;
private final IamRoleMapper roleMapper;
private final AuthAccountClient authClient;
private final String internalToken;
public MemberService(MemberRepository repository, IamRoleMapper roleMapper, AuthAccountClient authClient,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.repository = repository;
this.roleMapper = roleMapper;
this.authClient = authClient;
this.internalToken = internalToken;
}
public List<MemberResponse> list(long tenantId) {
return repository.findByTenant(tenantId).stream().map(m -> response(tenantId, m)).toList();
}
public MemberResponse detail(long tenantId, Long membershipId) {
return response(tenantId, required(tenantId, membershipId));
}
@Transactional
@BusinessAudit(event = "MEMBER_CREATE", resourceType = "MEMBERSHIP", resourceId = "#request.username", description = "新增企业成员")
public MemberResponse create(long tenantId, CreateMemberRequest request) {
rejectOwnerMutation(request.memberType());
validateRoles(tenantId, request.roleIds());
validateMemberNo(tenantId, request.memberNo(), null);
long authId = IdWorker.getId();
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
authId, request.username(), request.password(), request.displayName(), tenantId, membershipId)));
try {
IamUser user = new IamUser();
user.setId(userId); user.setAuthUserId(authId); user.setUserType("ENTERPRISE");
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
user.setStatus("ACTIVE"); repository.insertUser(user);
IamMembership membership = new IamMembership();
membership.setId(membershipId); membership.setTenantId(tenantId); membership.setUserId(userId);
membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo());
membership.setStatus("ACTIVE"); membership.setJoinedAt(LocalDateTime.now()); repository.insert(membership);
repository.replaceRoles(tenantId, membershipId, request.roleIds());
return response(tenantId, membership, user);
} catch (RuntimeException exception) {
try {
authClient.compensate(internalToken, authId);
} catch (RuntimeException compensationFailure) {
log.error("成员创建失败且认证账号补偿失败,authUserId={}", authId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"成员创建未完成,认证账号补偿失败,请按 authUserId=" + authId + " 进行一致性修复");
}
throw exception;
}
}
@Transactional
@BusinessAudit(event = "MEMBER_UPDATE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "编辑企业成员")
public MemberResponse update(long tenantId, Long membershipId, UpdateMemberRequest request) {
rejectOwnerMutation(request.memberType());
validateRoles(tenantId, request.roleIds());
validateMemberNo(tenantId, request.memberNo(), membershipId);
IamMembership membership = required(tenantId, membershipId);
if ("OWNER".equals(membership.getMemberType())) {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑");
}
IamUser user = requiredUser(membership.getUserId());
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo());
repository.saveUser(user); repository.save(membership);
repository.replaceRoles(tenantId, membershipId, request.roleIds());
return response(tenantId, membership);
}
@Transactional
@BusinessAudit(event = "MEMBER_STATUS_CHANGE", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "启停企业成员")
public MemberResponse changeStatus(long tenantId, Long membershipId, ChangeMemberStatusRequest request,
Long operatorMembershipId) {
if (!"ACTIVE".equals(request.status()) && !"DISABLED".equals(request.status()))
throw new BizException(ResultCode.BAD_REQUEST, "成员状态只允许 ACTIVE 或 DISABLED");
IamMembership membership = required(tenantId, membershipId);
if ("OWNER".equals(membership.getMemberType()) && "DISABLED".equals(request.status()))
throw new BizException(ResultCode.FORBIDDEN, "企业所有者不能被停用");
if (membershipId.equals(operatorMembershipId) && "DISABLED".equals(request.status()))
throw new BizException(ResultCode.BAD_REQUEST, "不能停用当前登录成员");
IamUser user = requiredUser(membership.getUserId());
String oldStatus = membership.getStatus();
call(() -> authClient.status(internalToken, user.getAuthUserId(),
new AuthAccountClient.StatusRequest(request.status())));
try {
membership.setStatus(request.status());
membership.setLeftAt("DISABLED".equals(request.status()) ? LocalDateTime.now() : null);
user.setStatus(request.status()); repository.saveUser(user); repository.save(membership);
} catch (RuntimeException localFailure) {
try {
authClient.status(internalToken, user.getAuthUserId(), new AuthAccountClient.StatusRequest(oldStatus));
} catch (RuntimeException compensationFailure) {
log.error("成员状态写入失败且认证账号状态补偿失败,authUserId={}", user.getAuthUserId(), compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"成员状态未完成,认证账号补偿失败,请按 authUserId=" + user.getAuthUserId() + " 进行一致性修复");
}
throw localFailure;
}
return response(tenantId, membership);
}
@BusinessAudit(event = "MEMBER_PASSWORD_RESET", resourceType = "MEMBERSHIP", resourceId = "#membershipId", description = "管理员重置成员密码", captureAfter = false)
public void resetPassword(long tenantId, Long membershipId, ResetMemberPasswordRequest request) {
IamMembership membership = required(tenantId, membershipId);
if ("OWNER".equals(membership.getMemberType())) {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者只能自行修改密码");
}
IamUser user = requiredUser(membership.getUserId());
call(() -> authClient.reset(internalToken, user.getAuthUserId(),
new AuthAccountClient.PasswordRequest(request.newPassword())));
}
private MemberResponse response(long tenantId, IamMembership membership) {
IamUser user = requiredUser(membership.getUserId());
return response(tenantId, membership, user);
}
private MemberResponse response(long tenantId, IamMembership m, IamUser u) {
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(),
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(),
repository.findRoleIds(tenantId, m.getId()));
}
private IamMembership required(long tenantId, Long membershipId) {
IamMembership value = repository.findByTenantAndId(tenantId, membershipId);
if (value == null) throw new BizException(ResultCode.NOT_FOUND, "成员不存在");
return value;
}
private IamUser requiredUser(Long id) {
IamUser user = repository.findUser(id);
if (user == null) throw new BizException(ResultCode.NOT_FOUND, "成员档案不存在");
return user;
}
private void validateRoles(long tenantId, List<Long> ids) {
for (Long id : ids) {
IamRole role = roleMapper.selectById(id);
if (role == null || role.getTenantId() == null || role.getTenantId() != tenantId
|| !"MERCHANT".equals(role.getAppCode()) || !"ACTIVE".equals(role.getStatus())
|| "MERCHANT_OWNER".equals(role.getRoleCode()))
throw new BizException(ResultCode.FORBIDDEN, "禁止分配其他租户、其他应用或已停用角色");
}
}
private void rejectOwnerMutation(String memberType) {
if ("OWNER".equals(memberType)) {
throw new BizException(ResultCode.FORBIDDEN, "不能通过成员管理接口创建或转让企业所有者");
}
if (!"EMPLOYEE".equals(memberType)) {
throw new BizException(ResultCode.BAD_REQUEST, "成员类型只能是 EMPLOYEE");
}
}
private void validateMemberNo(long tenantId, String memberNo, Long excludedId) {
if (repository.existsMemberNo(tenantId, memberNo, excludedId))
throw new BizException(ResultCode.CONFLICT, "企业成员编号已存在");
}
private <T> ApiResponse<T> call(RemoteCall<T> action) {
try {
ApiResponse<T> result = action.call();
if (result == null || !result.successful()) throw unavailable();
return result;
} catch (FeignException exception) {
if (exception.status() == 409) throw new BizException(ResultCode.CONFLICT, "登录账号已存在");
if (exception.status() == 404) throw new BizException(ResultCode.NOT_FOUND, "认证账号不存在");
throw unavailable();
}
}
private <T> T data(ApiResponse<T> value) { return value.data(); }
private BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用"); }
@FunctionalInterface private interface RemoteCall<T> { ApiResponse<T> call(); }
}
@@ -0,0 +1,115 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamMembershipRole;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamMembershipMapper;
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.mapper.IamTenantAddressMapper;
import com.baiyue.iam.mapper.IamTenantMapper;
import com.baiyue.iam.mapper.IamUserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Service
public class MerchantOnboardingService {
@Resource private IamTenantMapper tenantMapper;
@Resource private IamUserMapper userMapper;
@Resource private IamMembershipMapper membershipMapper;
@Resource private IamRoleMapper roleMapper;
@Resource private IamMembershipRoleMapper membershipRoleMapper;
@Resource private IamTenantAddressMapper addressMapper;
@Transactional
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
if (userMapper.selectCount(new LambdaQueryWrapper<IamUser>()
.eq(IamUser::getAuthUserId, request.authUserId())) > 0) {
throw new BizException(ResultCode.CONFLICT, "Registration already exists");
}
IamRole ownerRole = roleMapper.selectOne(new LambdaQueryWrapper<IamRole>()
.eq(IamRole::getTenantId, 0L)
.eq(IamRole::getAppCode, "MERCHANT")
.eq(IamRole::getRoleCode, "MERCHANT_OWNER")
.eq(IamRole::getStatus, "ACTIVE")
.last("LIMIT 1"));
if (ownerRole == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Merchant role template is not initialized");
}
long tenantId = IdWorker.getId();
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
IamTenant tenant = new IamTenant();
tenant.setId(tenantId);
tenant.setTenantCode("TEN" + tenantId);
tenant.setTenantName(request.companyName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setLegalName(request.companyName());
tenant.setContactName(request.legalPerson());
tenant.setContactPhone(request.legalPhone());
tenant.setContactEmail(request.contactEmail());
tenant.setDeleted(false);
tenant.setVersion(0);
tenantMapper.insert(tenant);
IamUser user = new IamUser();
user.setId(userId);
user.setAuthUserId(request.authUserId());
user.setUserType("ENTERPRISE");
user.setDisplayName(request.displayName());
user.setPhone(request.legalPhone());
user.setEmail(request.contactEmail());
user.setStatus("ACTIVE");
userMapper.insert(user);
IamMembership membership = new IamMembership();
membership.setId(membershipId);
membership.setTenantId(tenantId);
membership.setUserId(userId);
membership.setMemberType("OWNER");
membership.setMemberNo("OWNER-" + membershipId);
membership.setStatus("ACTIVE");
membership.setJoinedAt(LocalDateTime.now());
membership.setCreatedBy(request.authUserId());
membership.setUpdatedBy(request.authUserId());
membership.setDeleted(false);
membership.setVersion(0);
membershipMapper.insert(membership);
IamMembershipRole membershipRole = new IamMembershipRole();
membershipRole.setMembershipId(membershipId);
membershipRole.setRoleId(ownerRole.getId());
membershipRole.setTenantId(tenantId);
membershipRole.setCreatedAt(LocalDateTime.now());
membershipRoleMapper.insert(membershipRole);
if (StringUtils.hasText(request.address())) {
IamTenantAddress address = new IamTenantAddress();
address.setId(IdWorker.getId());
address.setTenantId(tenantId);
address.setAddressType("REGISTERED");
address.setCountryCode(StringUtils.hasText(request.countryCode())
? request.countryCode().toUpperCase() : "CN");
address.setDetail(request.address());
address.setIsDefault(true);
addressMapper.insert(address);
}
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
}
}
@@ -0,0 +1,47 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.audit.BusinessAudit;
/** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */
@Service
public class PersonalProfileService {
private final MemberRepository repository;
public PersonalProfileService(MemberRepository repository) { this.repository = repository; }
public PersonalProfileResponse get(long tenantId, Long authUserId, String username) {
return response(tenantId, authUserId, username);
}
@Transactional
@BusinessAudit(event = "PERSONAL_PROFILE_UPDATE", resourceType = "IAM_USER", resourceId = "#authUserId", description = "编辑个人资料")
public PersonalProfileResponse update(long tenantId, Long authUserId, String username,
UpdatePersonalProfileRequest request) {
IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden();
IamUser user = repository.findUser(membership.getUserId());
if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
repository.saveUser(user);
return new PersonalProfileResponse(user.getId(), authUserId, username,
user.getDisplayName(), user.getPhone(), user.getEmail());
}
private PersonalProfileResponse response(long tenantId, Long authUserId, String username) {
IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
if (membership == null) throw forbidden();
IamUser user = repository.findUser(membership.getUserId());
if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
return new PersonalProfileResponse(user.getId(), authUserId, username,
user.getDisplayName(), user.getPhone(), user.getEmail());
}
private BizException forbidden() { return new BizException(ResultCode.FORBIDDEN, "当前身份不属于该租户"); }
}
@@ -0,0 +1,105 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.CreateUserRequest;
import com.baiyue.iam.dto.ProfileDtos.QualificationRequest;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class ProfileResourceService {
@Resource private ProfileResourceRepository repository;
@Resource private TenantRepository tenantRepository;
@BusinessAudit(event = "INTERNAL_USER_CREATE", resourceType = "IAM_USER",
resourceId = "#q.authUserId", description = "新增平台内部用户")
public IamUser addUser(CreateUserRequest q) {
IamUser u = new IamUser();
u.setId(IdWorker.getId()); u.setAuthUserId(q.authUserId()); u.setUserType(q.userType());
u.setDisplayName(q.displayName()); u.setPhone(q.phone()); u.setEmail(q.email()); u.setStatus("ACTIVE");
repository.insertUser(u); return u;
}
public List<IamUser> internalUsers() { return repository.findInternalUsers(); }
public List<IamTenantAddress> addresses(long tenant) { return repository.findAddresses(tenant); }
@BusinessAudit(event = "TENANT_ADDRESS_CREATE", resourceType = "TENANT_ADDRESS",
resourceId = "#tenant", description = "新增企业地址")
public IamTenantAddress addAddress(long tenant, AddressRequest q) {
IamTenantAddress a = new IamTenantAddress();
a.setId(IdWorker.getId()); a.setTenantId(tenant); a.setAddressType(q.addressType());
a.setCountryCode(q.countryCode() == null ? "CN" : q.countryCode()); a.setProvince(q.province());
a.setCity(q.city()); a.setDistrict(q.district()); a.setDetail(q.detail());
a.setPostalCode(q.postalCode()); a.setIsDefault(Boolean.TRUE.equals(q.isDefault()));
repository.insertAddress(a); return a;
}
public List<IamTenantQualification> qualifications(long tenant) { return repository.findQualifications(tenant); }
public IamTenantQualification addQualification(long tenant, QualificationRequest q) {
throw new BizException(ResultCode.CONFLICT,
"Upload the business license through the qualification file API");
}
@Transactional
@BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false)
public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) {
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found");
}
if ("ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved");
}
if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review");
}
if (repository.findQualifications(tenant.getId()).stream()
.anyMatch(item -> !"REJECTED".equals(item.getStatus()))) {
throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image");
}
IamTenantQualification qualification = createQualification(tenant.getId(),
request.qualificationType(), request.certificateNo(), request.documentUrl());
if (StringUtils.hasText(request.certificateNo())
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
}
tenant.setStatus("PENDING");
tenant.setRejectReason(null);
tenantRepository.saveTenant(tenant);
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenant.getId());
review.setReviewType("REGISTER"); review.setStatus("PENDING");
review.setCreatedAt(LocalDateTime.now());
tenantRepository.insertReview(review);
return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus());
}
private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) {
IamTenantQualification x = new IamTenantQualification();
x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type);
x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING");
repository.insertQualification(x); return x;
}
}
@@ -0,0 +1,218 @@
package com.baiyue.iam.service;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.RbacDtos.*;
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 com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.dao.DataIntegrityViolationException;
import java.util.LinkedHashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Comparator;
/** 双端 RBAC 应用服务,严格按租户和应用域隔离。 */
@Service
public class RbacService {
@Resource private RbacRepository repository;
@Resource private MemberRepository memberRepository;
public List<RoleResponse> roles(long tenantId, String appCode) {
return repository.findRoles(tenantId, appCode).stream()
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId())))
.toList();
}
public RoleResponse role(long tenantId, String appCode, Long roleId) {
IamRole role = requireRole(tenantId, appCode, roleId);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
}
@BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode",
description = "创建角色", captureBefore = false, captureAfter = true)
public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) {
if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) {
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
}
IamRole role = new IamRole();
role.setId(IdWorker.getId());
role.setTenantId(tenantId);
role.setAppCode(appCode);
role.setRoleCode(request.roleCode());
role.setRoleName(request.roleName());
role.setDescription(request.description());
role.setRoleType("CUSTOM");
role.setStatus("ACTIVE");
try {
repository.insertRole(role);
} catch (DataIntegrityViolationException exception) {
// 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
}
return role;
}
@BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId",
description = "编辑角色", captureBefore = true, captureAfter = true)
public RoleResponse updateRole(long tenantId, String appCode, Long roleId, UpdateRoleRequest request) {
IamRole role = requireCustomRole(tenantId, appCode, roleId);
role.setRoleName(request.roleName());
role.setDescription(request.description());
repository.updateRole(role);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
}
@BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId",
description = "启停角色", captureBefore = true, captureAfter = true)
public RoleResponse changeStatus(long tenantId, String appCode, Long roleId,
ChangeRoleStatusRequest request) {
IamRole role = requireCustomRole(tenantId, appCode, roleId);
role.setStatus(request.status());
repository.updateRole(role);
return response(role, repository.findRolePermissionIds(tenantId, roleId));
}
/** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */
@BusinessAudit(event = "ROLE_DELETE", resourceType = "ROLE", resourceId = "#roleId",
description = "删除角色", captureBefore = true, captureAfter = false)
@Transactional
public void deleteRole(long tenantId, String appCode, Long roleId) {
IamRole role = requireCustomRole(tenantId, appCode, roleId);
if (repository.isRoleAssigned(tenantId, role.getId())) {
throw new BizException(ResultCode.CONFLICT, "角色已分配给成员,不能删除");
}
repository.deleteRole(tenantId, appCode, roleId);
}
public List<IamPermission> permissions(long tenantId, String appCode) {
Map<String, IamPermission> effective = new LinkedHashMap<>();
repository.findPermissions(tenantId, appCode).stream()
.sorted(Comparator
.comparingInt((IamPermission permission) ->
permission.getTenantId() != null && permission.getTenantId() == tenantId ? 0 : 1)
.thenComparing(IamPermission::getPermissionCode))
.forEach(permission -> effective.putIfAbsent(permission.getPermissionCode(), permission));
return List.copyOf(effective.values());
}
@BusinessAudit(event = "PERMISSION_CREATE", resourceType = "PERMISSION", resourceId = "#request.code",
description = "创建权限资源", captureBefore = false, captureAfter = true)
public IamPermission createPermission(long tenantId, String appCode, CreatePermissionRequest request) {
if (!Set.of("MENU", "PAGE", "BUTTON", "API").contains(request.type())) {
throw new BizException(ResultCode.BAD_REQUEST, "权限类型只能是 MENU、PAGE、BUTTON 或 API");
}
if (repository.existsPermissionCode(tenantId, appCode, request.code())) {
throw new BizException(ResultCode.CONFLICT, "权限编码已存在");
}
if (request.parentId() != null && request.parentId() > 0
&& repository.findPermission(request.parentId(), tenantId, appCode) == null) {
throw forbidden();
}
IamPermission permission = new IamPermission();
permission.setId(IdWorker.getId());
permission.setTenantId(tenantId);
permission.setAppCode(appCode);
permission.setPermissionType(request.type());
permission.setPermissionCode(request.code());
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());
permission.setStatus("ACTIVE");
try {
repository.insertPermission(permission);
} catch (DataIntegrityViolationException exception) {
throw new BizException(ResultCode.CONFLICT, "权限编码已存在");
}
return permission;
}
public MemberRolesResponse memberRoles(long tenantId, String appCode, Long membershipId) {
requireMembership(tenantId, membershipId);
Set<Long> scopedIds = new LinkedHashSet<>();
for (Long roleId : repository.findMemberRoleIds(tenantId, membershipId)) {
if (repository.findRole(roleId, tenantId, appCode) != null) scopedIds.add(roleId);
}
return new MemberRolesResponse(membershipId, Set.copyOf(scopedIds));
}
@BusinessAudit(event = "MEMBER_ROLE_ASSIGN", resourceType = "MEMBERSHIP",
resourceId = "#request.membershipId", description = "分配成员角色",
captureBefore = true, captureAfter = true)
@Transactional
public void assignRoles(long tenantId, String appCode, AssignRolesRequest request) {
IamMembership membership = requireMembership(tenantId, request.membershipId());
if ("OWNER".equals(membership.getMemberType())) {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不允许通过普通授权接口修改");
}
for (Long roleId : request.roleIds()) {
IamRole role = requireRole(tenantId, appCode, roleId);
if ("MERCHANT_OWNER".equals(role.getRoleCode())) {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者角色不能通过普通授权接口分配");
}
if (!"ACTIVE".equals(role.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员");
}
}
repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds());
}
@BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE",
resourceId = "#request.roleId", description = "设置角色权限",
captureBefore = true, captureAfter = true)
@Transactional
public void grantPermissions(long tenantId, String appCode, GrantPermissionsRequest request) {
requireCustomRole(tenantId, appCode, request.roleId());
for (Long permissionId : request.permissionIds()) {
IamPermission permission = repository.findPermission(permissionId, tenantId, appCode);
if (permission == null) throw forbidden();
if (!"ACTIVE".equals(permission.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "停用权限不能授予角色");
}
}
repository.replaceRolePermissions(tenantId, request.roleId(), request.permissionIds());
}
private IamRole requireRole(long tenantId, String appCode, Long roleId) {
IamRole role = repository.findRole(roleId, tenantId, appCode);
if (role == null) throw forbidden();
return role;
}
private IamRole requireCustomRole(long tenantId, String appCode, Long roleId) {
IamRole role = requireRole(tenantId, appCode, roleId);
if ("SYSTEM".equals(role.getRoleType())) {
throw new BizException(ResultCode.CONFLICT, "系统角色不允许修改、停用或删除");
}
return role;
}
private IamMembership requireMembership(long tenantId, Long membershipId) {
IamMembership membership = memberRepository.findById(membershipId);
if (membership == null || membership.getTenantId() == null
|| membership.getTenantId().longValue() != tenantId) throw forbidden();
return membership;
}
private RoleResponse response(IamRole role, Set<Long> permissionIds) {
return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(),
role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(),
Set.copyOf(permissionIds));
}
private BizException forbidden() {
return new BizException(ResultCode.FORBIDDEN, "禁止跨租户或跨应用访问");
}
}
@@ -0,0 +1,15 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser;
/**
* 租户访问守卫,从登录身份提取租户并校验目标应用域,防止平台端、商户端或不同租户间越权。
*/
public final class TenantGuard {
private TenantGuard(){}
public static long tenant(AuthenticatedUser user,String expectedApp){
if(user==null||user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"Authentication required / 请先登录");
if(!expectedApp.equals(user.appCode())) throw new BizException(ResultCode.FORBIDDEN,"Wrong application scope / 应用权限域不匹配");
if("PLATFORM".equals(expectedApp)) return 0L;
if(user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户");
return user.tenantId();
}
}
@@ -0,0 +1,169 @@
package com.baiyue.iam.service;
import jakarta.annotation.Resource;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
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.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
*/
@Service
public class TenantService {
@Resource
private TenantRepository repository;
@Resource
private ProfileResourceRepository profileRepository;
@BusinessAudit(
event = "TENANT_REGISTER",
resourceType = "TENANT",
description = "Register tenant / 企业注册",
captureBefore = false,
captureAfter = false
)
@Transactional
public TenantResponse register(RegisterRequest request) {
if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) {
throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在");
}
IamTenant tenant = new IamTenant();
tenant.setId(IdWorker.getId());
tenant.setTenantCode("TEN" + tenant.getId());
tenant.setTenantName(request.tenantName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PENDING");
tenant.setLegalName(request.legalName());
tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode());
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail());
repository.insertTenant(tenant);
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId());
review.setTenantId(tenant.getId());
review.setReviewType("REGISTER");
review.setStatus("PENDING");
review.setCreatedAt(LocalDateTime.now());
repository.insertReview(review);
return response(tenant);
}
public List<IamTenantReview> pendingReviews() {
return repository.findPendingReviews();
}
@BusinessAudit(
event = "TENANT_REVIEW",
resourceType = "TENANT_REVIEW",
resourceId = "#reviewId",
description = "Review tenant registration / 企业注册审核",
captureBefore = false,
captureAfter = false
)
@Transactional
public void review(long reviewId, long reviewerId, boolean approve, String comment) {
IamTenantReview review = repository.findReview(reviewId);
if (review == null) {
throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在");
}
if (!"PENDING".equals(review.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成");
}
review.setStatus(approve ? "APPROVED" : "REJECTED");
review.setReviewerId(reviewerId);
review.setReviewComment(comment);
review.setReviewedAt(LocalDateTime.now());
repository.saveReview(review);
IamTenant tenant = repository.findTenant(review.getTenantId());
if (tenant == null) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
}
tenant.setStatus(approve ? "ACTIVE" : "REJECTED");
tenant.setRejectReason(approve ? null : comment);
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
tenant.setApprovedBy(approve ? reviewerId : null);
repository.saveTenant(tenant);
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
if ("PENDING".equals(qualification.getStatus())) {
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
qualification.setReviewComment(comment);
profileRepository.saveQualification(qualification);
}
}
}
public OrderEligibilityResponse orderEligibility(long tenantId) {
IamTenant tenant = repository.findTenant(tenantId);
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
return new OrderEligibilityResponse(false, "NOT_FOUND", "Enterprise does not exist");
}
if (!"ACTIVE".equals(tenant.getStatus())) {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"Enterprise information must be approved before ordering");
}
if (profileRepository.countApprovedQualifications(tenantId) < 1) {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"At least one approved enterprise qualification is required");
}
return new OrderEligibilityResponse(true, tenant.getStatus(), null);
}
public TenantResponse profile(long tenantId) {
IamTenant tenant = repository.findTenant(tenantId);
if (tenant == null || !tenantIdEquals(tenant, tenantId)) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
}
return response(tenant);
}
@Transactional
@BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT",
resourceId = "#tenantId", description = "编辑企业资料")
public TenantResponse update(long tenantId, UpdateProfileRequest request) {
IamTenant tenant = repository.findTenant(tenantId);
if (tenant == null || !tenantIdEquals(tenant, tenantId)) {
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
}
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);
}
private boolean tenantIdEquals(IamTenant tenant, long id) {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail()
);
}
}
+34
View File
@@ -0,0 +1,34 @@
server:
port: ${IAM_PORT:9201}
spring:
application:
name: iam-service
config:
import:
- optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true
- optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
enabled: ${NACOS_DISCOVERY_ENABLED:true}
register-enabled: ${NACOS_REGISTER_ENABLED:true}
group: ${NACOS_GROUP:BAIYUE}
namespace: ${NACOS_NAMESPACE:public}
config:
namespace: ${NACOS_NAMESPACE:public}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
baiyue:
clients:
auth:
url: ${AUTH_SERVICE_URL:}
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
management.endpoints.web.exposure.include: health,info,prometheus
+199
View File
@@ -0,0 +1,199 @@
SET NAMES utf8mb4;
-- IAM 服务独占数据库:负责租户、成员、双端角色、菜单/页面/按钮/API权限和数据范围。
CREATE DATABASE IF NOT EXISTS baiyue_iam
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '租户ID(雪花ID)',
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一租户编码',
tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED',
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
contact_name VARCHAR(64) NULL COMMENT '企业联系人',
contact_phone VARCHAR(32) NULL COMMENT '联系电话',
contact_email VARCHAR(128) NULL COMMENT '联系邮箱',
approved_at DATETIME(3) NULL COMMENT '审核通过时间',
approved_by BIGINT NULL COMMENT '审核人ID',
reject_reason VARCHAR(500) NULL COMMENT '驳回原因',
created_by BIGINT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_iam_tenant_code (tenant_code),
UNIQUE KEY uk_iam_tenant_credit (unified_social_credit_code),
KEY idx_iam_tenant_status (status, created_at)
) ENGINE=InnoDB COMMENT='企业租户主表:作为SaaS数据隔离边界,保存商户企业资料、审核结果和启停状态';
CREATE TABLE IF NOT EXISTS iam_tenant_review (
id BIGINT NOT NULL COMMENT '审核记录ID',
tenant_id BIGINT NOT NULL COMMENT '被审核租户ID',
review_type VARCHAR(20) NOT NULL DEFAULT 'REGISTER' COMMENT '审核类型',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态',
reviewer_id BIGINT NULL COMMENT '审核人ID',
review_comment VARCHAR(500) NULL COMMENT '审核意见',
reviewed_at DATETIME(3) NULL COMMENT '审核完成时间',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '进入审核队列时间',
PRIMARY KEY (id),
KEY idx_review_queue (status, created_at),
KEY idx_review_tenant (tenant_id)
) ENGINE=InnoDB COMMENT='租户审核记录表:保存企业注册、资料变更等审核过程和审核意见';
CREATE TABLE IF NOT EXISTS iam_tenant_address (
id BIGINT NOT NULL COMMENT '租户地址ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
address_type VARCHAR(20) NOT NULL COMMENT '地址类型',
country_code CHAR(2) NOT NULL DEFAULT 'CN' COMMENT '国家二字码',
province VARCHAR(64) NULL COMMENT '省/州',
city VARCHAR(64) NULL COMMENT '城市',
district VARCHAR(64) NULL COMMENT '区县',
detail VARCHAR(255) NOT NULL COMMENT '详细地址',
postal_code VARCHAR(16) NULL COMMENT '邮政编码',
is_default 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 '逻辑删除标记',
PRIMARY KEY (id),
KEY idx_address_tenant (tenant_id, address_type)
) ENGINE=InnoDB COMMENT='租户地址表:保存企业注册地址、办公地址等租户级资料';
CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
id BIGINT NOT NULL COMMENT '资质记录ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
qualification_type VARCHAR(32) NOT NULL COMMENT '资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态',
review_comment VARCHAR(500) NULL 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 '逻辑删除标记',
PRIMARY KEY (id),
KEY idx_qualification_tenant (tenant_id, status)
) ENGINE=InnoDB COMMENT='租户资质表:保存企业证照、有效期、文件地址和审核状态';
CREATE TABLE IF NOT EXISTS iam_user (
id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
auth_user_id BIGINT NOT NULL COMMENT '认证服务账号逻辑ID',
user_type VARCHAR(20) NOT NULL DEFAULT 'ENTERPRISE' COMMENT '用户类型',
display_name VARCHAR(64) NOT NULL COMMENT '用户展示名称',
phone VARCHAR(32) NULL COMMENT '手机号',
email VARCHAR(128) NULL COMMENT '邮箱',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' 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 '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_iam_user_auth (auth_user_id),
KEY idx_iam_user_status (status)
) ENGINE=InnoDB COMMENT='IAM用户资料表:连接统一认证账号与业务身份资料,不保存密码';
CREATE TABLE IF NOT EXISTS iam_membership (
id BIGINT NOT NULL COMMENT '租户成员关系ID',
tenant_id BIGINT NOT NULL COMMENT '所属租户ID',
user_id BIGINT NOT NULL COMMENT 'IAM用户资料ID',
member_type VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE' COMMENT '成员类型',
member_no VARCHAR(64) NULL COMMENT '租户内部成员编号',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '成员状态',
joined_at DATETIME(3) NULL COMMENT '加入租户时间',
left_at DATETIME(3) NULL 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 '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_membership_tenant_user (tenant_id, user_id),
UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no),
KEY idx_membership_user (user_id, status),
KEY idx_membership_tenant (tenant_id, status)
) ENGINE=InnoDB COMMENT='租户成员关系表:表示一个用户在某个企业租户中的成员身份,是分配商户端角色的主体';
CREATE TABLE IF NOT EXISTS iam_role (
id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '角色所属租户;0表示平台预置角色',
app_code VARCHAR(20) NOT NULL COMMENT '应用端:MERCHANT或PLATFORM',
role_code VARCHAR(64) NOT NULL COMMENT '作用域内唯一角色编码',
role_name VARCHAR(128) NOT NULL COMMENT '角色名称',
role_type VARCHAR(20) NOT NULL DEFAULT 'CUSTOM' COMMENT '角色类型:SYSTEM或CUSTOM',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' COMMENT '角色状态',
description VARCHAR(255) NULL COMMENT '角色用途说明',
created_by BIGINT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT NULL COMMENT '更新人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_role_scope_code (tenant_id, app_code, role_code),
KEY idx_role_tenant (tenant_id, app_code, status)
) ENGINE=InnoDB COMMENT='角色表:按租户和应用端隔离商户端、平台后台两套角色体系';
CREATE TABLE IF NOT EXISTS iam_membership_role (
membership_id BIGINT NOT NULL COMMENT '租户成员关系ID',
role_id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于租户隔离校验和查询',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间',
PRIMARY KEY (membership_id, role_id),
KEY idx_mr_tenant (tenant_id)
) ENGINE=InnoDB COMMENT='成员角色关联表:将租户成员分配到对应应用端的一个或多个角色';
CREATE TABLE IF NOT EXISTS iam_permission (
id BIGINT NOT NULL COMMENT '权限资源ID',
tenant_id BIGINT NOT NULL DEFAULT 0 COMMENT '权限所属租户;0表示平台预置权限',
app_code VARCHAR(20) NOT NULL COMMENT '所属应用端:MERCHANT或PLATFORM',
permission_type VARCHAR(20) NOT NULL COMMENT '权限类型:MENU、PAGE、BUTTON或API',
parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父权限ID,用于构造菜单和页面树',
permission_code VARCHAR(128) NOT NULL COMMENT '鉴权使用的唯一权限码',
permission_name VARCHAR(128) NOT NULL COMMENT '权限名称',
route_path VARCHAR(255) NULL COMMENT '前端路由或API路径',
http_method VARCHAR(16) NULL COMMENT 'API权限对应的HTTP方法',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同级显示顺序',
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' 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 '逻辑删除标记',
PRIMARY KEY (id),
UNIQUE KEY uk_permission_scope_code (tenant_id, app_code, permission_code),
KEY idx_permission_parent (parent_id),
KEY idx_permission_api (http_method, route_path)
) ENGINE=InnoDB COMMENT='统一权限资源表:支持商户端和平台后台两套菜单、页面、按钮及API权限,权限最细可控制到按钮';
CREATE TABLE IF NOT EXISTS iam_role_permission (
role_id BIGINT NOT NULL COMMENT '角色ID',
permission_id BIGINT NOT NULL COMMENT '权限资源ID',
tenant_id BIGINT NOT NULL COMMENT '冗余租户ID,用于隔离校验和查询',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '授权时间',
PRIMARY KEY (role_id, permission_id),
KEY idx_rp_tenant (tenant_id)
) ENGINE=InnoDB COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源';
CREATE TABLE IF NOT EXISTS iam_data_scope (
id BIGINT NOT NULL COMMENT '数据范围规则ID',
role_id BIGINT NOT NULL COMMENT '角色ID',
tenant_id BIGINT NOT NULL COMMENT '规则所属租户ID',
scope_type VARCHAR(20) NOT NULL DEFAULT 'SELF' COMMENT '范围类型:SELF、DEPARTMENT、TENANT或CUSTOM',
scope_expression JSON NULL COMMENT '自定义数据范围表达式',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_scope_role (role_id)
) ENGINE=InnoDB COMMENT='角色数据范围表:在功能权限之外限制角色可查看和操作的租户内数据集合';
-- 存量数据库重复执行初始化脚本时,不重复创建审核状态索引。
SET @review_status_index_sql = IF(
EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'iam_tenant_review'
AND index_name = 'idx_tenant_review_status'),
'SELECT 1',
'CREATE INDEX idx_tenant_review_status ON iam_tenant_review (tenant_id, status)'
);
PREPARE review_status_index_stmt FROM @review_status_index_sql;
EXECUTE review_status_index_stmt;
DEALLOCATE PREPARE review_status_index_stmt;
@@ -0,0 +1,185 @@
-- Phase-one administrator identities and RBAC seed data.
-- Scope is intentionally limited to first-level menus, second-level menus and buttons.
-- PAGE and API permission resources are not part of phase one.
USE baiyue_iam;
SET NAMES utf8mb4;
-- Keep the role table aligned with the shared BaseEntity audit fields.
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'created_by'),
'SELECT 1',
'ALTER TABLE iam_role ADD COLUMN created_by BIGINT NULL AFTER description'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl = IF(
EXISTS(SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'iam_role' AND column_name = 'updated_by'),
'SELECT 1',
'ALTER TABLE iam_role ADD COLUMN updated_by BIGINT NULL AFTER created_at'
);
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
INSERT INTO iam_tenant (
id, tenant_code, tenant_name, tenant_type, status, legal_name,
unified_social_credit_code, contact_name, contact_phone, contact_email,
approved_at, approved_by, created_by, deleted, version
) VALUES
(0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台',
NULL, '平台管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0),
(10001, 'PHASE1_DEMO', '一期演示企业', 'MERCHANT', 'ACTIVE', '一期演示企业有限公司',
'91310000PHASE10001', '企业管理员', '13800000000', 'merchant@example.com',
NOW(3), 1000001, 1000001, 0, 0)
ON DUPLICATE KEY UPDATE
tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type),
status = 'ACTIVE', legal_name = VALUES(legal_name),
contact_name = VALUES(contact_name), contact_phone = VALUES(contact_phone),
contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)),
approved_by = VALUES(approved_by), deleted = 0;
INSERT INTO iam_user (
id, auth_user_id, user_type, display_name, phone, email, status, deleted, version
) VALUES
(2000001, 1000001, 'INTERNAL', '平台管理员', NULL, NULL, 'ACTIVE', 0, 0),
(2000002, 1000002, 'ENTERPRISE', '一期演示企业管理员', '13800000000',
'merchant@example.com', 'ACTIVE', 0, 0)
ON DUPLICATE KEY UPDATE
display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email),
status = 'ACTIVE', deleted = 0;
INSERT INTO iam_membership (
id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version
) VALUES
(3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0),
(3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0)
ON DUPLICATE KEY UPDATE
member_type = VALUES(member_type), member_no = VALUES(member_no),
status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0;
INSERT INTO iam_role (
id, tenant_id, app_code, role_code, role_name, role_type, status, description, deleted, version
) VALUES
(4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0),
(4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0),
(4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0),
(4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0),
(4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0)
ON DUPLICATE KEY UPDATE
role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE',
description = VALUES(description), deleted = 0;
-- Platform: level-one menus.
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
(5100000, 0, 'PLATFORM', 'MENU', 0, 'platform:customer', '客户管理', '/customers', NULL, 10, 'ACTIVE', 0),
(5101000, 0, 'PLATFORM', 'MENU', 0, 'platform:system', '系统管理', '/system', NULL, 20, 'ACTIVE', 0),
(5102000, 0, 'PLATFORM', 'MENU', 0, 'platform:order', '头程订单', '/first-leg-orders', NULL, 30, 'ACTIVE', 0),
(5103000, 0, 'PLATFORM', 'MENU', 0, 'platform:master-data', '基础资料', '/master-data', NULL, 40, 'ACTIVE', 0),
(5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0),
-- Platform: level-two menus.
(5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0),
(5100020, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:review', '企业审核', '/customers/reviews', NULL, 20, 'ACTIVE', 0),
(5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0),
(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),
(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),
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
-- Platform: buttons.
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
(5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0),
(5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0),
(5101021, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0),
(5101022, 0, 'PLATFORM', 'BUTTON', 5101020, 'platform:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0),
(5101031, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:create', '新增菜单按钮', NULL, NULL, 10, 'ACTIVE', 0),
(5102011, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:approve', '订单审核通过', NULL, NULL, 10, 'ACTIVE', 0),
(5102012, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:reject', '订单驳回', NULL, NULL, 20, 'ACTIVE', 0),
(5102013, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:accept', '订单受理', NULL, NULL, 30, 'ACTIVE', 0),
(5102014, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:process', '开始处理', NULL, NULL, 40, 'ACTIVE', 0),
(5102015, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:complete', '完成订单', NULL, NULL, 50, 'ACTIVE', 0),
(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),
(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)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
-- 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)
VALUES
(5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0),
(5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0),
(5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0),
(5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0),
(5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0),
(5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0),
(5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0),
(5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0),
(5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0),
(5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0),
(5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0),
(5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0),
(5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0),
(5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0),
(5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0),
(5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0),
(5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0),
(5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0),
(5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0),
(5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0),
(5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', 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),
http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES
(3000001, 4000001, 0),
(3000002, 4000101, 10001)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- Full administrator roles receive all phase-one permissions in their application scope.
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0 FROM iam_permission
WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM iam_permission
WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- Limited platform and merchant operational roles.
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000002, id, 0 FROM iam_permission
WHERE permission_code IN (
'platform:customer', 'platform:customer:list', 'platform:customer:review',
'platform:customer:approve', 'platform:customer:reject'
)
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%'
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000102, id, 10001 FROM iam_permission
WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES
(6000001, 4000001, 0, 'TENANT', NULL),
(6000002, 4000002, 0, 'TENANT', NULL),
(6000003, 4000003, 0, 'TENANT', NULL),
(6000101, 4000101, 10001, 'TENANT', NULL),
(6000102, 4000102, 10001, 'TENANT', NULL)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL;
@@ -0,0 +1,38 @@
SET NAMES utf8mb4;
-- 企业成员管理按钮权限增量;部署后应将权限按需授予企业管理员角色。
USE baiyue_iam;
-- 为存量数据库补齐企业内成员编号唯一约束,防止并发重复创建。
SET @member_no_index_sql = IF(
EXISTS(SELECT 1 FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'iam_membership'
AND index_name = 'uk_membership_tenant_no'),
'SELECT 1',
'ALTER TABLE iam_membership ADD UNIQUE KEY uk_membership_tenant_no (tenant_id, member_no)'
);
PREPARE member_no_index_stmt FROM @member_no_index_sql;
EXECUTE member_no_index_stmt;
DEALLOCATE PREPARE member_no_index_stmt;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted)
VALUES
(5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0),
(5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0),
(5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0),
(5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0),
(5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0),
(5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM iam_permission
WHERE tenant_id = 10001 AND permission_code IN
('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000100, id, 0 FROM iam_permission
WHERE tenant_id = 0 AND permission_code IN
('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -0,0 +1,71 @@
-- Phase-one merchant onboarding state migration.
USE baiyue_iam;
SET NAMES utf8mb4;
ALTER TABLE iam_tenant MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED'
COMMENT 'PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED or DISABLED';
DROP PROCEDURE IF EXISTS phase1_iam_add_column;
DELIMITER $$
CREATE PROCEDURE phase1_iam_add_column(
IN p_table_name VARCHAR(64), IN p_column_name VARCHAR(64), IN p_definition VARCHAR(1000)
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = p_table_name AND COLUMN_NAME = p_column_name
) THEN
SET @phase1_iam_sql = CONCAT('ALTER TABLE `', p_table_name, '` ADD COLUMN `',
p_column_name, '` ', p_definition);
PREPARE phase1_iam_stmt FROM @phase1_iam_sql;
EXECUTE phase1_iam_stmt;
DEALLOCATE PREPARE phase1_iam_stmt;
END IF;
END$$
DELIMITER ;
CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'");
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
DROP PROCEDURE phase1_iam_add_column;
-- A global merchant owner template is assigned to newly registered enterprises.
INSERT INTO iam_role (
id, tenant_id, app_code, role_code, role_name, role_type, status,
description, created_by, updated_by, deleted, version
) VALUES (
4000100, 0, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE',
'一期企业全部菜单和操作按钮模板', 1000001, 1000001, 0, 0
)
ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0;
-- Clone the一期 merchant menu/button template into the global scope.
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
)
SELECT id + 100000, 0, app_code, permission_type,
CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END,
permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0
FROM iam_permission
WHERE tenant_id = 10001 AND app_code = 'MERCHANT'
AND permission_type IN ('MENU', 'BUTTON')
ON DUPLICATE KEY UPDATE
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000100, id, 0
FROM iam_permission
WHERE app_code='MERCHANT' AND tenant_id=0
AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0;
-- Keep the phase-one demo merchant eligible for existing order acceptance tests.
INSERT INTO iam_tenant_qualification (
id, tenant_id, qualification_type, certificate_no, document_url, status, deleted
) VALUES (
7000101, 10001, 'BUSINESS_LICENSE', '91310000PHASE10001',
'/phase1-seed/demo-business-license.jpg', 'APPROVED', 0
)
ON DUPLICATE KEY UPDATE status='APPROVED', deleted=0;
@@ -0,0 +1,44 @@
SET NAMES utf8mb4;
-- 角色管理闭环权限迁移,可重复执行。
USE baiyue_iam;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, sort_no, status, deleted)
VALUES
(5100011, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:create', '新增企业客户', 10, '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),
(5101040, 0, 'PLATFORM', 'MENU', 5101000, 'platform:member:list', '平台成员', 40, 'ACTIVE', 0),
(5101041, 0, 'PLATFORM', 'BUTTON', 5101040, 'platform:member:grant-role', '分配成员角色', 10, '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),
(5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0),
(5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0),
(5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no),
status = 'ACTIVE', 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:role:edit', 'platform:role:status', 'platform:role:delete',
'platform:member:list', 'platform:member:grant-role', 'platform:customer:create')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000101, id, 10001 FROM iam_permission
WHERE tenant_id = 10001 AND app_code = 'MERCHANT'
AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000100, id, 0 FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'MERCHANT'
AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
+391
View File
@@ -0,0 +1,391 @@
openapi: 3.0.3
info:
title: Baiyue IAM API / 百悦身份与权限 API
description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。
version: 1.0.1
servers:
- url: /
security:
- bearerAuth: []
tags:
- name: Platform Tenant / 平台租户
description: Enterprise registration and review / 企业注册与审核
- name: Merchant Tenant / 商户企业
description: Current tenant profile and members / 当前企业资料与成员
- name: IAM Resources / IAM 资源
description: Internal users, addresses and qualifications / 内部用户、地址与资质
- name: RBAC
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
paths:
/api/platform/v1/iam/tenants:
post:
tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业租户
description: Create a pending enterprise tenant and review task. Platform identity is required. / 创建待审核企业租户及审核任务,要求平台端身份。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/RegisterRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/reviews:
get:
tags: [Platform Tenant / 平台租户]
summary: List pending tenant reviews / 查询待审核企业
description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/platform/v1/iam/reviews/{id}/approve:
post:
tags: [Platform Tenant / 平台租户]
summary: Approve enterprise registration / 审核通过企业注册
description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。
parameters:
- {$ref: '#/components/parameters/Id'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReviewRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/reviews/{id}/reject:
post:
tags: [Platform Tenant / 平台租户]
summary: Reject enterprise registration / 驳回企业注册
description: Reject a pending registration and save the review comment. / 驳回待审核注册申请并保存审核意见。
parameters:
- {$ref: '#/components/parameters/Id'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ReviewRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/internal-users:
get:
tags: [IAM Resources / IAM 资源]
summary: List platform internal users / 查询平台内部用户
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
summary: Create platform internal user / 新增平台内部用户
description: Create an IAM profile linked to an existing authentication account. / 创建关联已有认证账号的 IAM 用户资料。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateIamUserRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/tenant/profile:
get:
tags: [Merchant Tenant / 商户企业]
summary: Get current tenant profile / 查询当前企业资料
description: Tenant ID comes only from the verified JWT. / 租户 ID 仅取自已验证 JWT。
responses:
'200': {$ref: '#/components/responses/Success'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
put:
tags: [Merchant Tenant / 商户企业]
summary: Update current tenant profile / 更新当前企业资料
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdateProfileRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/merchant/v1/iam/members:
get:
tags: [Merchant Tenant / 商户企业]
summary: List enterprise members / 查询企业成员
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [Merchant Tenant / 商户企业]
summary: Add enterprise member / 新增企业成员
description: Attach an existing IAM user to the current tenant. / 将已有 IAM 用户加入当前企业。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateMemberRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/tenant/addresses:
get:
tags: [IAM Resources / IAM 资源]
summary: List tenant addresses / 查询企业地址
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
summary: Add tenant address / 新增企业地址
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AddressRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/tenant/qualifications:
get:
tags: [IAM Resources / IAM 资源]
summary: List tenant qualifications / 查询企业资质
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [IAM Resources / IAM 资源]
summary: Add tenant qualification / 新增企业资质
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QualificationRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/{app}/v1/iam/roles:
parameters:
- {$ref: '#/components/parameters/App'}
get:
tags: [RBAC]
summary: List roles / 查询角色
description: List roles in the selected application and tenant scope. / 查询指定应用及租户范围内的角色。
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [RBAC]
summary: Create role / 新增角色
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreateRoleRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/members/roles:
put:
tags: [RBAC]
summary: Assign roles to member / 设置成员角色
description: Replace member role assignments in the same tenant and application. / 在同一租户和应用域内替换成员角色。
parameters:
- {$ref: '#/components/parameters/App'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AssignRolesRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/{app}/v1/iam/permissions:
parameters:
- {$ref: '#/components/parameters/App'}
get:
tags: [RBAC]
summary: List menu, page, button and API permissions / 查询菜单、页面、按钮和 API 权限
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
post:
tags: [RBAC]
summary: Create permission / 新增权限
description: Create a MENU, PAGE, BUTTON or API permission node. / 创建 MENU、PAGE、BUTTON 或 API 权限节点。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/CreatePermissionRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/roles/permissions:
put:
tags: [RBAC]
summary: Grant permissions to role / 设置角色权限
description: Replace role permissions; cross-tenant and cross-application grants are rejected. / 替换角色权限,拒绝跨租户和跨应用授权。
parameters:
- {$ref: '#/components/parameters/App'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/GrantPermissionsRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
parameters:
Id:
name: id
in: path
required: true
description: Resource ID / 资源 ID
schema: {type: integer, format: int64}
example: 190000000000000001
App:
name: app
in: path
required: true
description: Application domain / 应用域
schema: {type: string, enum: [platform, merchant]}
example: platform
responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
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'}}}}
schemas:
ApiResponse:
type: object
required: [code, message, timestamp]
properties:
code: {type: string, example: '00000'}
message: {type: string, example: Success}
data: {}
requestId: {type: string}
traceId: {type: string}
timestamp: {type: string, format: date-time}
RegisterRequest:
type: object
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone]
properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
legalName: {type: string, example: 张三}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12}
contactName: {type: string, example: 李四}
contactPhone: {type: string, example: '13800138000'}
contactEmail: {type: string, format: email, example: contact@example.com}
UpdateProfileRequest:
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}
ReviewRequest:
type: object
properties:
comment: {type: string, description: Review comment or rejection reason / 审核意见或驳回原因}
CreateIamUserRequest:
type: object
required: [authUserId, userType, displayName]
properties:
authUserId: {type: integer, format: int64}
userType: {type: string, enum: [PLATFORM, ENTERPRISE]}
displayName: {type: string}
phone: {type: string}
email: {type: string, format: email}
CreateMemberRequest:
type: object
required: [username, password, displayName, memberType]
properties:
username: {type: string, minLength: 4, maxLength: 64}
password: {type: string, format: password, minLength: 8, maxLength: 64}
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
email: {type: string, format: email, maxLength: 128}
memberType: {type: string, enum: [OWNER, ADMIN, EMPLOYEE]}
memberNo: {type: string}
roleIds: {type: array, items: {type: integer, format: int64}}
AddressRequest:
type: object
required: [addressType, detail]
properties:
addressType: {type: string, enum: [OPERATING, BILLING, RETURN]}
countryCode: {type: string, example: CN}
province: {type: string}
city: {type: string}
district: {type: string}
detail: {type: string}
postalCode: {type: string}
isDefault: {type: boolean}
QualificationRequest:
type: object
required: [qualificationType, documentUrl]
properties:
qualificationType: {type: string, example: BUSINESS_LICENSE}
certificateNo: {type: string}
documentUrl: {type: string, format: uri}
validFrom: {type: string, format: date}
validTo: {type: string, format: date}
CreateRoleRequest:
type: object
required: [roleCode, roleName]
properties:
roleCode: {type: string, example: ORDER_OPERATOR}
roleName: {type: string, example: 订单操作员}
description: {type: string}
CreatePermissionRequest:
type: object
required: [type, code, name]
properties:
type: {type: string, enum: [MENU, PAGE, BUTTON, API], example: BUTTON}
code: {type: string, example: 'order:firstleg:submit'}
name: {type: string, example: 提交头程订单}
parentId: {type: integer, format: int64, example: 0}
routePath: {type: string}
httpMethod: {type: string, enum: [GET, POST, PUT, PATCH, DELETE]}
sortNo: {type: integer, example: 10}
AssignRolesRequest:
type: object
required: [membershipId, roleIds]
properties:
membershipId: {type: integer, format: int64}
roleIds: {type: array, items: {type: integer, format: int64}}
GrantPermissionsRequest:
type: object
required: [roleId, permissionIds]
properties:
roleId: {type: integer, format: int64}
permissionIds: {type: array, items: {type: integer, format: int64}}
@@ -0,0 +1,20 @@
package com.baiyue.iam.controller;
import org.junit.jupiter.api.Test;
import org.springframework.web.util.pattern.PathPatternParser;
import org.springframework.http.server.PathContainer;
import static org.assertj.core.api.Assertions.assertThat;
/** 防止成员数字主键路由再次拦截固定的角色分配路径。 */
class MerchantMemberRouteTest {
private final PathPatternParser parser = new PathPatternParser();
@Test
void 成员编辑路由只接受数字主键() {
var pattern = parser.parse("/api/merchant/v1/iam/members/{membershipId:\\d+}");
assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/123"))).isTrue();
assertThat(pattern.matches(PathContainer.parsePath("/api/merchant/v1/iam/members/roles"))).isFalse();
}
}
@@ -0,0 +1,64 @@
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.iam.mapper.AuthorizationQueryMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AuthorizationSnapshotServiceTest {
@Test
void loadsRolesAndPermissionsForActiveMerchantIdentity() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(1);
when(mapper.findRoles(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("ADMIN", "OPERATOR"));
when(mapper.findPermissions(42L, 7L, 9L, "MERCHANT"))
.thenReturn(List.of("order:read", "order:create"));
var result = service(mapper)
.load(new SnapshotRequest(42L, 7L, 9L, "merchant"));
assertThat(result.getAppCode()).isEqualTo("MERCHANT");
assertThat(result.getClientType()).isEqualTo("MERCHANT");
assertThat(result.getRoles()).containsExactlyInAnyOrder("ADMIN", "OPERATOR");
assertThat(result.getPermissions()).containsExactlyInAnyOrder("order:read", "order:create");
}
@Test
void rejectsPlatformIdentityOutsidePlatformTenant() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
assertThatThrownBy(() -> service(mapper)
.load(new SnapshotRequest(42L, 7L, 9L, "PLATFORM")))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
}
@Test
void rejectsInactiveMembership() {
AuthorizationQueryMapper mapper = mock(AuthorizationQueryMapper.class);
when(mapper.countActiveIdentity(42L, 7L, 9L)).thenReturn(0);
assertThatThrownBy(() -> service(mapper)
.load(new SnapshotRequest(42L, 7L, 9L, "MERCHANT")))
.isInstanceOfSatisfying(BizException.class,
exception -> assertThat(exception.getResultCode()).isEqualTo(ResultCode.FORBIDDEN));
verify(mapper).countActiveIdentity(42L, 7L, 9L);
}
private AuthorizationSnapshotService service(AuthorizationQueryMapper mapper) {
AuthorizationSnapshotService service = new AuthorizationSnapshotService();
ReflectionTestUtils.setField(service, "mapper", mapper);
return service;
}
}
@@ -0,0 +1,94 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.entity.IamMembership;
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.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class MemberServiceTest {
private final MemberRepository repository = mock(MemberRepository.class);
private final IamRoleMapper roleMapper = mock(IamRoleMapper.class);
private final AuthAccountClient auth = mock(AuthAccountClient.class);
private final MemberService service = new MemberService(repository, roleMapper, auth, "internal-token");
private IamMembership member;
private IamUser user;
@BeforeEach void setUp() {
member = new IamMembership(); member.setId(20L); member.setTenantId(100L); member.setUserId(30L);
member.setMemberType("EMPLOYEE"); member.setStatus("ACTIVE");
user = new IamUser(); user.setId(30L); user.setAuthUserId(40L); user.setDisplayName("测试成员");
when(repository.findByTenantAndId(100L, 20L)).thenReturn(member);
when(repository.findUser(30L)).thenReturn(user);
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
}
@Test void 成员详情返回联系方式和已有角色() {
user.setPhone("13800138000"); user.setEmail("member@example.com");
MemberResponse result = service.detail(100L, 20L);
assertEquals("测试成员", result.displayName());
assertEquals("13800138000", result.phone());
assertEquals(List.of(50L), result.roleIds());
verifyNoInteractions(auth);
}
@Test void 跨租户查询被拒绝() {
when(repository.findByTenantAndId(200L, 20L)).thenReturn(null);
assertThrows(BizException.class, () -> service.detail(200L, 20L));
}
@Test void 企业所有者不可停用() {
member.setMemberType("OWNER");
BizException exception = assertThrows(BizException.class, () -> service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 20L));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
verify(auth, never()).status(anyString(), anyLong(), any());
}
@Test void 停用成员同步认证账号并保存本地状态() {
when(auth.status(anyString(), eq(40L), any())).thenReturn(ApiResponse.success(null));
MemberResponse result = service.changeStatus(100L, 20L,
new ChangeMemberStatusRequest("DISABLED"), 99L);
assertEquals("DISABLED", result.status());
verify(repository).save(member);
verify(repository).saveUser(user);
}
@Test void 认证服务失败时不得写入本地状态() {
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 企业所有者不能通过成员编辑接口降级或变更角色() {
member.setMemberType("OWNER");
UpdateMemberRequest request = new UpdateMemberRequest("所有者", null, null,
"EMPLOYEE", null, List.of());
assertThrows(BizException.class, () -> service.update(100L, 20L, request));
verify(repository, never()).replaceRoles(anyLong(), anyLong(), anyList());
}
@Test void 企业所有者密码不能被成员管理员重置() {
member.setMemberType("OWNER");
assertThrows(BizException.class, () -> service.resetPassword(100L, 20L,
new ResetMemberPasswordRequest("OwnerNew123")));
verify(auth, never()).reset(anyString(), anyLong(), any());
}
}
@@ -0,0 +1,31 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.exception.BizException;
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.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PersonalProfileServiceTest {
private final MemberRepository repository = mock(MemberRepository.class);
private final PersonalProfileService service = new PersonalProfileService(repository);
@Test void 只能修改当前租户中的本人资料() {
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);
when(repository.findUser(3L)).thenReturn(user);
var result = service.update(1L, 2L, "tester",
new UpdatePersonalProfileRequest("新姓名", "13800138000", "a@example.com"));
assertEquals("新姓名", result.displayName());
verify(repository).saveUser(user);
}
@Test void 跨租户身份被拒绝() {
assertThrows(BizException.class, () -> service.get(9L, 2L, "tester"));
}
}
@@ -0,0 +1,282 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.RbacDtos.AssignRolesRequest;
import com.baiyue.iam.dto.RbacDtos.GrantPermissionsRequest;
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.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.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class RbacServiceTest {
@Mock private RbacRepository repository;
@Mock private MemberRepository memberRepository;
private RbacService service;
@BeforeEach
void setUp() {
service = new RbacService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "memberRepository", memberRepository);
}
@Test
void 角色列表返回当前权限用于安全回显() {
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));
var result = service.roles(100L, "MERCHANT");
assertEquals(Set.of(21L, 22L), result.get(0).permissionIds());
assertEquals(100L, result.get(0).tenantId());
}
@Test
void 权限列表同码时优先当前租户并去除全局模板重复项() {
IamPermission global = permission(21L, 0L, "merchant:member:list");
IamPermission tenant = permission(22L, 100L, "merchant:member:list");
IamPermission fallback = permission(23L, 0L, "merchant:member:status");
when(repository.findPermissions(100L, "MERCHANT"))
.thenReturn(List.of(global, fallback, tenant));
var result = service.permissions(100L, "MERCHANT");
assertEquals(2, result.size());
assertEquals(22L, result.get(0).getId());
assertEquals(23L, result.get(1).getId());
}
@Test
void 跨租户角色访问被拒绝() {
when(repository.findRole(11L, 200L, "MERCHANT")).thenReturn(null);
BizException exception = assertThrows(BizException.class,
() -> service.role(200L, "MERCHANT", 11L));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
}
@Test
void 系统角色禁止删除() {
when(repository.findRole(11L, 0L, "PLATFORM"))
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
BizException exception = assertThrows(BizException.class,
() -> service.deleteRole(0L, "PLATFORM", 11L));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).deleteRole(anyLong(), any(), anyLong());
}
@Test
void 已分配成员的自定义角色禁止删除() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(repository.isRoleAssigned(100L, 11L)).thenReturn(true);
BizException exception = assertThrows(BizException.class,
() -> service.deleteRole(100L, "MERCHANT", 11L));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).deleteRole(anyLong(), any(), anyLong());
}
@Test
void 空权限集合可以明确清空授权() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
service.grantPermissions(100L, "MERCHANT", new GrantPermissionsRequest(11L, Set.of()));
verify(repository).replaceRolePermissions(100L, 11L, Set.of());
}
@Test
void 空角色集合可以明确清空成员授权() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
when(memberRepository.findById(31L)).thenReturn(membership);
service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of()));
verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of());
}
@Test
void 成员角色回显只返回当前应用域角色() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
when(memberRepository.findById(31L)).thenReturn(membership);
when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L, 12L));
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(repository.findRole(12L, 100L, "MERCHANT")).thenReturn(null);
var result = service.memberRoles(100L, "MERCHANT", 31L);
assertEquals(Set.of(11L), result.roleIds());
}
@Test
void 同一租户同一应用角色编码重复时返回冲突() {
when(repository.existsRoleCode(100L, "MERCHANT", "ORDER_ADMIN", null)).thenReturn(true);
BizException exception = assertThrows(BizException.class, () -> service.createRole(
100L, "MERCHANT", new CreateRoleRequest("ORDER_ADMIN", "订单管理员", null)));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).insertRole(any());
}
@Test
void 停用角色不得继续分配给成员() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
when(memberRepository.findById(31L)).thenReturn(membership);
IamRole disabled = role(11L, 100L, "MERCHANT", "CUSTOM");
disabled.setStatus("DISABLED");
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(disabled);
BizException exception = assertThrows(BizException.class, () -> service.assignRoles(
100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L))));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any());
}
@Test
void 自定义角色可以编辑和停用并保持权限回显() {
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role);
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L));
var updated = service.updateRole(100L, "MERCHANT", 11L,
new UpdateRoleRequest("新名称", "新说明"));
var disabled = service.changeStatus(100L, "MERCHANT", 11L,
new ChangeRoleStatusRequest("DISABLED"));
assertEquals("新名称", updated.roleName());
assertEquals(Set.of(21L), updated.permissionIds());
assertEquals("DISABLED", disabled.status());
verify(repository, times(2)).updateRole(role);
}
@Test
void 未使用的自定义角色可以安全删除() {
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(repository.isRoleAssigned(100L, 11L)).thenReturn(false);
service.deleteRole(100L, "MERCHANT", 11L);
verify(repository).deleteRole(100L, "MERCHANT", 11L);
}
@Test
void 企业所有者角色不能通过普通成员授权接口修改() {
IamMembership owner = new IamMembership();
owner.setId(31L);
owner.setTenantId(100L);
owner.setMemberType("OWNER");
when(memberRepository.findById(31L)).thenReturn(owner);
BizException exception = assertThrows(BizException.class, () -> service.assignRoles(
100L, "MERCHANT", new AssignRolesRequest(31L, Set.of())));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any());
}
@Test
void 系统角色权限不能通过普通授权接口修改() {
when(repository.findRole(11L, 0L, "PLATFORM"))
.thenReturn(role(11L, 0L, "PLATFORM", "SYSTEM"));
BizException exception = assertThrows(BizException.class, () -> service.grantPermissions(
0L, "PLATFORM", new GrantPermissionsRequest(11L, Set.of())));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).replaceRolePermissions(anyLong(), anyLong(), any());
}
@Test
void 企业所有者系统角色不能分配给普通成员() {
IamMembership member = new IamMembership();
member.setId(31L);
member.setTenantId(100L);
member.setMemberType("EMPLOYEE");
when(memberRepository.findById(31L)).thenReturn(member);
IamRole ownerRole = role(11L, 100L, "MERCHANT", "SYSTEM");
ownerRole.setRoleCode("MERCHANT_OWNER");
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(ownerRole);
BizException exception = assertThrows(BizException.class, () -> service.assignRoles(
100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L))));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
verify(repository, never()).replaceMemberRoles(anyLong(), any(), anyLong(), any());
}
@Test
void 权限编码重复时返回稳定冲突且不写数据库() {
when(repository.existsPermissionCode(100L, "MERCHANT", "merchant:role:test"))
.thenReturn(true);
CreatePermissionRequest request = new CreatePermissionRequest(
"BUTTON", "merchant:role:test", "测试按钮", 0L, null, null, 1);
BizException exception = assertThrows(BizException.class,
() -> service.createPermission(100L, "MERCHANT", request));
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(repository, never()).insertPermission(any());
}
private IamRole role(Long id, Long tenantId, String appCode, String roleType) {
IamRole role = new IamRole();
role.setId(id);
role.setTenantId(tenantId);
role.setAppCode(appCode);
role.setRoleCode("ROLE_" + id);
role.setRoleName("测试角色");
role.setRoleType(roleType);
role.setStatus("ACTIVE");
return role;
}
private IamPermission permission(Long id, Long tenantId, String code) {
IamPermission permission = new IamPermission();
permission.setId(id);
permission.setTenantId(tenantId);
permission.setAppCode("MERCHANT");
permission.setPermissionCode(code);
permission.setPermissionName(code);
permission.setPermissionType("BUTTON");
permission.setStatus("ACTIVE");
return permission;
}
}
@@ -0,0 +1,39 @@
-- Any returned row represents a failed phase-one seed assertion.
SELECT 'missing_auth_accounts' AS failure
WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account
WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2
UNION ALL
SELECT 'missing_active_identities'
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u
JOIN baiyue_iam.iam_membership m ON m.user_id = u.id
WHERE u.auth_user_id IN (1000001, 1000002)
AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2
UNION ALL
SELECT 'unexpected_permission_type'
WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission
WHERE id BETWEEN 5100000 AND 5299999 AND permission_type NOT IN ('MENU', 'BUTTON'))
UNION ALL
SELECT 'invalid_menu_depth'
WHERE EXISTS (
SELECT 1 FROM baiyue_iam.iam_permission child
JOIN baiyue_iam.iam_permission parent ON parent.id = child.parent_id
WHERE child.id BETWEEN 5100000 AND 5299999
AND child.permission_type = 'MENU' AND parent.parent_id <> 0
)
UNION ALL
SELECT 'invalid_button_parent'
WHERE EXISTS (
SELECT 1 FROM baiyue_iam.iam_permission button_permission
LEFT JOIN baiyue_iam.iam_permission menu_permission ON menu_permission.id = button_permission.parent_id
WHERE button_permission.id BETWEEN 5100000 AND 5299999
AND button_permission.permission_type = 'BUTTON'
AND (menu_permission.id IS NULL OR menu_permission.permission_type <> 'MENU' OR menu_permission.parent_id = 0)
)
UNION ALL
SELECT 'platform_admin_not_fully_granted'
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001)
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON'))
UNION ALL
SELECT 'merchant_owner_not_fully_granted'
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101)
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON'));