This commit is contained in:
2026-08-05 16:26:10 +08:00
parent 06f7f9d2f2
commit 5ba9716e12
155 changed files with 2883 additions and 551 deletions
+11 -10
View File
@@ -4,8 +4,9 @@
- 商户侧资质公开接口在 IAM 中只提供查询;真实文件上传统一由 File 服务的 multipart 接口完成,IAM 仅接受 File 服务携带内部令牌提交的资质元数据。
- 负责租户、用户、成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照。
- 负责客户主档、用户、企业成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照;`iam_tenant`、`Tenant*` 及 `/tenants` 是历史兼容命名。
- 账号凭据与 Token 属于 `auth/`;资质文件内容属于 `file/`。IAM 只维护业务元数据和跨服务协作。
- 当前只实现“平台—客户”两级:`iam_tenant.id > 0` 的 `MERCHANT` 记录直接作为客户主档,相关 `tenant_id` 是客户隔离兼容字段;`iam_tenant.id = 0` 是平台账号、平台 RBAC 和平台公共资源技术作用域。不得由本模块提前引入真实租户、租户选择或租户级 RBAC。
## 功能快速索引
@@ -16,37 +17,37 @@
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 |
| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台租户和 PLATFORM 角色 |
| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台技术作用域和 PLATFORM 角色 |
| 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 |
| 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 |
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
| Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` |
| 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 |
| DTO | `src/main/java/com/baiyue/iam/dto/` | 按 Authorization、Member、Onboarding、Profile、Rbac、Tenant 分类 |
| 持久化 | `entity/`、`mapper/`、`repository/` | 租户、成员、角色、权限、地址、资质与审核模型 |
| 持久化 | `entity/`、`mapper/`、`repository/` | 客户主档、成员、角色、权限、地址、资质与审核模型;表字段保留 `tenant` 兼容命名 |
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
| OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 |
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、内部用户、平台资质和 RBAC 测试 |
## 修改与验证
- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业租户成员必须走商户成员边界。
- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台租户且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。
- `/users` 不提供跨租户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。
- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业客户成员必须走商户成员边界。
- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台技术作用域且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。
- `/users` 不提供跨客户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标客户现有资质,避免并发上传或替换产生多个有效同类型记录;方法名和查询字段保留历史兼容,该查询不得改回普通无锁列表。
- 企业送审和审核通过只要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID` 两类必填资质均为 `PENDING`;`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 为选填,已上传时仍随审核统一处理。审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
- `tenant_id` 是商户数据隔离核心;平台端与商户端访问域、角色和权限不可混用。
- `tenant_id > 0` 是当前客户数据隔离核心;平台端与商户端访问域、角色和权限不可混用,`tenant_id=0` 只表示平台技术作用域。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
- 平台企业客户分页仅查询商户租户;分页参数类型错误使用 body code `A0400`,Bean Validation 范围或状态约束错误使用 `A0422`,两者普通业务响应均为 HTTP 200。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台技术作用域下已启用的平台角色;空 `roleIds` 表示清空。
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前客户。
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须营业执照和法人身份证两类必填资质均为 `APPROVED`。任一必填资质缺失、驳回、已替换或过期都必须由服务端阻止提交订单;选填资质不影响下单资格。
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
+4 -4
View File
@@ -2,9 +2,9 @@
## 数据库归属 / 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`。
全部表位于 `baiyue_iam`,由 `iam-service` 独占连接。`auth-service` 只访问 `baiyue_auth.auth_account` 与 `auth_refresh_token`。`iam_user.auth_user_id` 是跨库逻辑 ID,禁止建立跨库外键。当前产品只包含“平台—客户”两级:商户数据通过兼容字段 `tenant_id > 0` 按客户强制隔离;平台账号、平台 RBAC 和平台公共资源使用技术作用域 `tenant_id=0`,平台身份使用 `app_code=PLATFORM`,商户端使用 `app_code=MERCHANT`。`tenant` 相关表、类、路径和字段为历史兼容命名,不表示已经存在真实租户层。
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`.
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. The current product has only platform and customer levels. Every merchant query must constrain the compatibility field `tenant_id`, which currently stores the customer ID. Platform identities, RBAC, and public warehouse master data use technical scope `tenant_id=0`; platform identity also uses `app_code=PLATFORM`, while merchant scope uses `app_code=MERCHANT`. Historical tenant-named tables, types, paths, and fields do not establish a real tenant layer.
## 代码落位 / Code placement
@@ -21,7 +21,7 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe
| POST | `/api/platform/v1/iam/reviews/{id}/approve` | PLATFORM | Approve registration / 审核通过 |
| POST | `/api/platform/v1/iam/reviews/{id}/reject` | PLATFORM | Reject registration / 审核驳回 |
| GET/POST | `/api/platform/v1/iam/users` | PLATFORM | Paginated platform-internal user administration (`tenant_id=0`) / 平台内部用户分页管理 |
| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform tenant and PLATFORM role options / 平台租户与平台角色选项 |
| GET | `/api/platform/v1/iam/users/options` | PLATFORM | Platform technical scope and PLATFORM role options / 平台技术作用域与平台角色选项 |
| PUT | `/api/platform/v1/iam/users/{membershipId}` | PLATFORM | Edit internal-user profile and roles / 编辑内部用户资料和角色 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/status` | PLATFORM | Enable or disable user / 启停用户 |
| PUT | `/api/platform/v1/iam/users/{membershipId}/password` | PLATFORM | Reset password / 重置密码 |
@@ -45,4 +45,4 @@ OpenAPI is synchronized in `openapi/iam-v1.yaml`.
- 新增、编辑成员基础资料分别需要 `merchant:member:create`、`merchant:member:edit`。
- 新增请求的 `roleIds` 非空,或编辑请求的完整目标角色集合与当前集合不同时,还必须具有 `merchant:member:grant-role`。
- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前租户下的 `roleIds`。
- `roleIds: []` 表示清空角色,不能绕过授权;编辑时角色集合未变化则不额外拒绝。成员详情和列表均实时返回当前客户下的 `roleIds`。
@@ -30,7 +30,12 @@ public class CustomerListExportProviderController {
@PostConstruct
void initializeToken() {
this.internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8);
byte[] configured = internalTokenValue == null
? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8);
if (configured.length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
this.internalToken = configured;
}
@PostMapping("/batches")
@@ -40,7 +40,14 @@ public class InternalTenantController {
private byte[] internalToken;
@PostConstruct
void initializeToken() { internalToken = internalTokenValue.getBytes(StandardCharsets.UTF_8); }
void initializeToken() {
byte[] configured = internalTokenValue == null
? new byte[0] : internalTokenValue.getBytes(StandardCharsets.UTF_8);
if (configured.length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节");
}
internalToken = configured;
}
@PostMapping("/merchant-registrations")
public ApiResponse<MerchantRegistrationResponse> register(
@@ -8,8 +8,11 @@ public final class 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 / 缺少商户租户");
if("PLATFORM".equals(expectedApp)) {
if(user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"Platform tenant must be zero / 平台作用域必须为 0");
return 0L;
}
if(!"MERCHANT".equals(expectedApp)||user.tenantId()<=0) throw new BizException(ResultCode.FORBIDDEN,"Merchant tenant required / 缺少商户租户");
return user.tenantId();
}
}
@@ -199,10 +199,7 @@ public class TenantService {
}
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 / 企业不存在");
}
IamTenant tenant = requireMerchant(tenantId);
return merchantVisibleResponse(tenant);
}
@@ -285,10 +282,7 @@ public class TenantService {
@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 / 企业不存在");
}
IamTenant tenant = requireMerchant(tenantId);
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
+13 -13
View File
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: Baiyue IAM API / 百悦身份与权限 API
description: Tenant, member, role, menu, page, button and API permission management. / 租户、成员、角色、菜单、页面、按钮和 API 权限管理。
description: Customer master data, members, roles, menus, pages, buttons and API permissions for the current single-tenant product. Historical tenant-named paths, schemas and fields are retained for compatibility; tenantId > 0 currently means customer ID, while 0 is technical scope for platform identities, RBAC, and public warehouse resources. / 当前单租户产品的客户主档、成员、角色、菜单、页面、按钮和 API 权限管理。tenant 命名的路径、模型和字段仅为历史兼容;tenantId 大于 0 当前表示客户 ID,0 表示平台身份、RBAC 和公共仓储资源技术作用域。
version: 1.0.1
servers:
- url: /
@@ -9,9 +9,9 @@ security:
- bearerAuth: []
tags:
- name: Platform Tenant / 平台租户
description: Enterprise registration and review / 企业注册与审核
description: Historical tag for platform customer administration; it does not represent a real tenant layer. / 历史兼容标签,实际用于平台客户管理,不表示真实租户层。
- name: Merchant Tenant / 商户企业
description: Current tenant profile and members / 当前企业资料与成员
description: Current customer profile and members; tenant naming is retained for compatibility. / 当前客户企业资料与成员;tenant 命名仅为兼容。
- name: IAM Resources / IAM 资源
description: Internal users, addresses and qualifications / 内部用户、地址与资质
- name: RBAC
@@ -237,7 +237,7 @@ paths:
/api/platform/v1/iam/users/options:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询平台租户和 PLATFORM 角色选项
summary: 查询平台技术作用域和 PLATFORM 角色选项
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
@@ -363,7 +363,7 @@ paths:
put:
tags: [IAM 资源]
summary: 全量分配平台内部用户角色
description: 仅允许分配平台租户下已启用的平台端角色;空集合表示清空角色。
description: 仅允许分配平台技术作用域(tenant_id=0)下已启用的平台端角色;空集合表示清空角色。
requestBody:
required: true
content:
@@ -382,7 +382,7 @@ paths:
get:
tags: [Merchant Tenant / 商户企业]
summary: Get current tenant profile / 查询当前企业资料
description: Tenant ID comes only from the verified JWT. / 租户 ID 仅取自已验证 JWT。
description: The compatibility field tenant ID comes only from the verified JWT and currently identifies the customer. / 客户隔离兼容字段 tenant ID 仅取自已验证 JWT,当前表示登录客户 ID。
responses:
'200': {$ref: '#/components/responses/Success'}
'401': {$ref: '#/components/responses/Unauthorized'}
@@ -432,7 +432,7 @@ paths:
get:
tags: [Merchant Tenant / 商户企业]
summary: 查询成员详情及当前角色
description: 返回当前租户成员资料,roleIds 为服务端实时查询的当前角色集合;跨租户成员按不存在处理。
description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
@@ -493,7 +493,7 @@ paths:
get:
tags: [RBAC]
summary: List roles / 查询角色
description: List roles in the selected application and tenant scope. / 查询指定应用及租户范围内的角色。
description: List roles in the selected application and current customer or platform technical scope. / 查询指定应用及当前客户范围或平台技术作用域内的角色。
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
@@ -553,7 +553,7 @@ paths:
put:
tags: [RBAC]
summary: Grant permissions to role / 设置角色权限
description: Replace role permissions; cross-tenant and cross-application grants are rejected. / 替换角色权限,拒绝跨租户和跨应用授权。
description: Replace role permissions; cross-customer and cross-application grants are rejected. / 替换角色权限,拒绝跨客户和跨应用授权。
parameters:
- {$ref: '#/components/parameters/App'}
requestBody:
@@ -719,7 +719,7 @@ components:
name: tenantId
in: path
required: true
description: 企业客户租户 ID
description: 企业客户 ID;tenantId 为历史兼容参数名
schema: {type: string, pattern: '^\d+$'}
PermissionId:
name: permissionId
@@ -732,7 +732,7 @@ components:
TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}}
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'}}}}
Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
NotFound: {description: Resource not found / 资源不存在, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Conflict: {description: Resource conflict / 资源冲突, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
@@ -977,7 +977,7 @@ components:
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
status: {type: string, enum: [ACTIVE, DISABLED]}
@@ -988,7 +988,7 @@ components:
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台租户 0}
tenantId: {type: integer, format: int64, enum: [0], description: 保留的兼容字段,只允许平台技术作用域 0}
roleIds: {type: array, minItems: 1, items: {type: integer, format: int64}}
CreateInternalUserRequest:
type: object
@@ -23,7 +23,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(CustomerListExportProviderController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes")
class CustomerListExportProviderControllerTest {
@MockBean private CustomerListExportProviderService service;
@Resource private CustomerListExportProviderController controller;
@@ -48,7 +48,7 @@ class CustomerListExportProviderControllerTest {
ExportBatchResponse batch = new ExportBatchResponse(1, "sha256:test", "snapshot",
List.of(), List.of(), null, false, 0L);
when(service.exportBatch(request)).thenReturn(batch);
var response = controller.batch("internal-token", request);
var response = controller.batch("test-internal-service-token-at-least-32-bytes", request);
assertEquals(ResultCode.SUCCESS.code(), response.code());
assertEquals(batch, response.data());
@@ -21,7 +21,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(InternalTenantController.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-service-token-at-least-32-bytes")
class InternalCustomerExportContractTest {
@MockBean private MerchantOnboardingService onboardingService;
@MockBean private ProfileResourceService profileResourceService;
@@ -36,7 +36,7 @@ class InternalCustomerExportContractTest {
when(service.exportMerchants(null, 100, "柏悦", "ACTIVE"))
.thenReturn(new CustomerExportBatch(List.of(), null, false));
var response = controller.exportCustomers("internal-token", null, 100, "柏悦", "ACTIVE");
var response = controller.exportCustomers("test-internal-service-token-at-least-32-bytes", null, 100, "柏悦", "ACTIVE");
assertEquals(ResultCode.SUCCESS.code(), response.code());
verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE");
}
@@ -0,0 +1,34 @@
package com.baiyue.iam.controller;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.junit.jupiter.api.Assertions.assertThrows;
class InternalServiceTokenConfigurationTest {
@Test
void internalTenantControllerRejectsEmptyAndShortTokens() {
assertInternalTenantTokenRejected(null);
assertInternalTenantTokenRejected("");
assertInternalTenantTokenRejected("short-token");
}
@Test
void customerExportControllerRejectsEmptyAndShortTokens() {
assertCustomerExportTokenRejected(null);
assertCustomerExportTokenRejected("");
assertCustomerExportTokenRejected("short-token");
}
private void assertInternalTenantTokenRejected(String token) {
InternalTenantController controller = new InternalTenantController();
ReflectionTestUtils.setField(controller, "internalTokenValue", token);
assertThrows(IllegalStateException.class, controller::initializeToken);
}
private void assertCustomerExportTokenRejected(String token) {
CustomerListExportProviderController controller = new CustomerListExportProviderController();
ReflectionTestUtils.setField(controller, "internalTokenValue", token);
assertThrows(IllegalStateException.class, controller::initializeToken);
}
}
@@ -51,7 +51,7 @@ class CustomerProfileMigrationContractTest {
@Test
void shouldHandleCase03() throws IOException {
Path repositoryRoot = Path.of(System.getProperty("maven.multiModuleProjectDirectory", ".."));
Path migration = repositoryRoot.resolve("deploy/sql/2026-07-31一期回归测试环境增量迁移.sql");
Path migration = repositoryRoot.resolve("deploy/sql/头程数据库统一增量-MySQL8.sql");
Path readme = repositoryRoot.resolve("deploy/sql/README.md");
assertThat(migration).exists();
@@ -60,27 +60,18 @@ class CustomerProfileMigrationContractTest {
String instructions = Files.readString(readme, StandardCharsets.UTF_8);
assertThat(sql)
.contains("CALL regression_assert_table('iam_tenant')")
.contains("CALL regression_assert_table('iam_tenant_review')")
.contains("CALL regression_assert_table('iam_tenant_qualification')")
.contains("ALTER TABLE iam_tenant MODIFY COLUMN status")
.contains("DEFAULT 'PROFILE_REQUIRED'")
.contains("CREATE PROCEDURE regression_add_iam_column")
.contains("regression_add_iam_column('approved_at'")
.contains("regression_add_iam_column('approved_by'")
.contains("regression_add_iam_column('reject_reason'")
.contains("regression_add_iam_related_column('iam_tenant_qualification', 'review_comment'")
.contains("'IAM注册审核字段'")
.contains("'IAM资质与成员审计字段'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_by'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'reject_reason'")
.contains("CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment'")
.contains("platform:customer:view")
.contains("platform:customer:export")
.contains("platform:download:list")
.contains("'MERCHANT_OWNER'")
.contains("IAM全局企业管理员模板")
.contains("owner_role.id, permission.id, 0")
.contains("'IAM客户档案字段' AS check_item")
.contains("10 AS expected_count")
.contains("47 AS expected_count")
.contains("MIGRATION_COMPLETED");
.contains("统一增量全部执行完成");
assertThat(instructions)
.contains("phase1-platform-master-data-and-permission-management.sql")
.contains("phase1-customer-profile.sql")
@@ -0,0 +1,38 @@
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;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class TenantGuardTest {
@Test
void acceptsPlatformOnlyAtZeroTenant() {
assertEquals(0L, TenantGuard.tenant(user(0L, "PLATFORM"), "PLATFORM"));
BizException exception = assertThrows(BizException.class,
() -> TenantGuard.tenant(user(10001L, "PLATFORM"), "PLATFORM"));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
}
@Test
void acceptsMerchantOnlyAtPositiveCustomerTenant() {
assertEquals(10001L, TenantGuard.tenant(user(10001L, "MERCHANT"), "MERCHANT"));
BizException exception = assertThrows(BizException.class,
() -> TenantGuard.tenant(user(0L, "MERCHANT"), "MERCHANT"));
assertEquals(ResultCode.FORBIDDEN, exception.getResultCode());
}
private AuthenticatedUser user(long tenantId, String appCode) {
return new AuthenticatedUser(42L, tenantId, tenantId, 9L,
"operator", appCode, appCode, Set.of());
}
}
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
@@ -122,6 +123,23 @@ class TenantServiceTest {
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
}
@Test
void merchantProfileCannotReadOrUpdatePlatformTenant() {
IamTenant platform = new IamTenant();
platform.setId(0L);
platform.setTenantType("PLATFORM");
when(repository.findTenant(0L)).thenReturn(platform);
BizException readFailure = assertThrows(BizException.class, () -> service.profile(0L));
BizException updateFailure = assertThrows(BizException.class, () -> service.update(0L,
new UpdateProfileRequest("Platform", "Platform", "Owner", "Contact",
"13800138000", "platform@example.com")));
assertEquals(ResultCode.NOT_FOUND, readFailure.getResultCode());
assertEquals(ResultCode.NOT_FOUND, updateFailure.getResultCode());
verify(repository, never()).saveTenant(platform);
}
@Test
void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() {