This commit is contained in:
2026-08-04 09:05:28 +08:00
parent fdb1e8af1c
commit 3afcff320a
116 changed files with 6749 additions and 749 deletions
@@ -0,0 +1,47 @@
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.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.iam.service.CustomerListExportProviderService;
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;
/** Internal provider endpoint consumed only by File Service. */
@RestController
@RequestMapping("/internal/exports/v1/CUSTOMER_LIST")
public class CustomerListExportProviderController {
private final CustomerListExportProviderService service;
private final byte[] internalToken;
public CustomerListExportProviderController(
CustomerListExportProviderService service,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.service = service;
this.internalToken = internalToken.getBytes(StandardCharsets.UTF_8);
}
@PostMapping("/batches")
public ApiResponse<ExportBatchResponse> batch(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestBody ExportBatchRequest request) {
verifyToken(token);
return ApiResponse.success(service.exportBatch(request));
}
private void verifyToken(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");
}
}
}
@@ -48,6 +48,15 @@ public class InternalTenantController {
return ApiResponse.success(onboardingService.register(request));
}
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
public ApiResponse<Void> compensateRegistration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long authUserId) {
verify(token);
onboardingService.compensateRegistration(authUserId);
return ApiResponse.success(null);
}
@PostMapping("/qualifications")
public ApiResponse<QualificationSubmissionResponse> submitQualification(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -8,6 +8,7 @@ import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.service.TenantGuard;
import com.baiyue.iam.service.TenantService;
@@ -80,7 +81,7 @@ public class PlatformTenantController {
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.register(request));
return ApiResponse.success(service.register(request, user.userId()));
}
@PutMapping("/tenants/{tenantId:\\d+}")
@@ -126,18 +127,17 @@ public class PlatformTenantController {
@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. / 审核通过并激活租户,操作写入审计日志。")
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve and activate the tenant. Client registrations keep their registration account as owner; only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活租户;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动开户并返回一次性凭据。")
@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,
public ApiResponse<ApprovalResponse> 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);
return ApiResponse.success(service.review(id, user.userId(), true, request.comment()));
}
@PostMapping("/reviews/{id}/reject")
@@ -25,6 +25,7 @@ public final class TenantDtos {
public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@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,
@@ -45,6 +46,7 @@ public final class TenantDtos {
public record UpdateProfileRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@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
@@ -55,6 +57,7 @@ public final class TenantDtos {
public record UpdateTenantRequest(
@NotBlank String tenantName,
@NotBlank String legalName,
@NotBlank @Size(max = 64) String legalRepresentative,
@NotBlank String contactName,
@NotBlank String contactPhone,
@Email String contactEmail,
@@ -80,9 +83,17 @@ public final class TenantDtos {
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
) { }
@Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据")
public record ApprovalResponse(
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated,
@Schema(description = "Generated login username; null when an owner already exists / 自动生成的登录账号,已有所有者时为空", nullable = true) String administratorUsername,
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
) { }
@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 legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
String contactPhone, String contactEmail, String contactWechat,
String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType,
LocalDate establishedDate, BigDecimal registeredCapital,
@@ -23,6 +23,7 @@ public class IamTenant extends BaseEntity {
private String tenantType;
private String status;
private String legalName;
private String legalRepresentative;
private String unifiedSocialCreditCode;
private String contactName;
private String contactPhone;
@@ -0,0 +1,60 @@
package com.baiyue.iam.repository;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.mapper.IamTenantMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.List;
/** Read-only customer queries used by the generic export provider. */
@Repository
public class CustomerListExportProviderRepository {
private final IamTenantMapper tenantMapper;
public CustomerListExportProviderRepository(IamTenantMapper tenantMapper) {
this.tenantMapper = tenantMapper;
}
public long maximumId(String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = filters(keyword, status)
.select(IamTenant::getId)
.orderByDesc(IamTenant::getId)
.last("LIMIT 1");
IamTenant tenant = tenantMapper.selectOne(wrapper);
return tenant == null || tenant.getId() == null ? 0L : tenant.getId();
}
public long estimateRows(long maximumId, String keyword, String status) {
if (maximumId <= 0) return 0L;
return tenantMapper.selectCount(filters(keyword, status).le(IamTenant::getId, maximumId));
}
public List<IamTenant> findBatch(Long cursor, long maximumId, int fetchSize,
String keyword, String status) {
if (maximumId <= 0) return List.of();
return tenantMapper.selectList(filters(keyword, status)
.gt(cursor != null, IamTenant::getId, cursor)
.le(IamTenant::getId, maximumId)
.orderByAsc(IamTenant::getId)
.last("LIMIT " + fetchSize));
}
private LambdaQueryWrapper<IamTenant> filters(String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status);
if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition
.like(IamTenant::getTenantCode, keyword)
.or().like(IamTenant::getTenantName, keyword)
.or().like(IamTenant::getLegalName, keyword)
.or().like(IamTenant::getUnifiedSocialCreditCode, keyword)
.or().like(IamTenant::getContactName, keyword)
.or().like(IamTenant::getContactPhone, keyword)
.or().like(IamTenant::getSalesOwnerName, keyword));
}
return wrapper;
}
}
@@ -39,6 +39,12 @@ public class MemberRepository {
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1"));
}
public IamMembership findOwnerByTenant(long tenantId) {
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getTenantId, tenantId)
.eq(IamMembership::getMemberType, "OWNER")
.last("LIMIT 1"));
}
public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) {
IamUser user = findUserByAuthId(authUserId);
if (user == null) return null;
@@ -30,6 +30,12 @@ public class TenantRepository {
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0;
}
public boolean existsByCreditCodeForOtherTenant(String creditCode, long tenantId) {
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)
.ne(IamTenant::getId, tenantId)) > 0;
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
/**
* Serializes the draft qualification workflow for one enterprise. The file service and the
@@ -47,6 +53,11 @@ public class TenantRepository {
.orderByAsc(IamTenant::getTenantName));
}
public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); }
public IamTenantReview findReviewForUpdate(Long reviewId) {
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
.eq(IamTenantReview::getId, reviewId)
.last("FOR UPDATE"));
}
public IamTenantReview findPendingReviewByTenant(Long tenantId) {
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
.eq(IamTenantReview::getTenantId, tenantId)
@@ -0,0 +1,265 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.repository.CustomerListExportProviderRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Implements the generic {@code CUSTOMER_LIST} export contract without writing export files. */
@Service
public class CustomerListExportProviderService {
static final int SCHEMA_VERSION = 1;
private static final int MAX_BATCH_SIZE = 500;
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
private static final Set<String> STATUSES = Set.of(
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED");
private static final Set<String> PARAMETER_KEYS = Set.of("keyword", "status");
private static final List<ExportColumn> COLUMNS = List.of(
column("id", "客户ID", ExportColumnType.STRING, 20),
column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
column("tenantName", "公司名称", ExportColumnType.STRING, 28),
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
column("establishedDate", "成立日期", ExportColumnType.DATE, 14),
column("registeredCapital", "注册资本", ExportColumnType.DECIMAL, 16),
column("registeredCapitalCurrency", "币种", ExportColumnType.STRING, 10),
column("registeredAddress", "注册地址", ExportColumnType.STRING, 32),
column("contactName", "联系人", ExportColumnType.STRING, 16),
column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
column("contactEmail", "联系邮箱", ExportColumnType.STRING, 24),
column("contactWechat", "联系微信", ExportColumnType.STRING, 18),
column("salesOwnerName", "销售人员", ExportColumnType.STRING, 16),
column("servicePackage", "客户套餐", ExportColumnType.STRING, 16),
column("paymentTermDays", "账期(天)", ExportColumnType.INTEGER, 12),
column("status", "状态", ExportColumnType.STRING, 14),
column("createdAt", "入驻时间", ExportColumnType.DATETIME, 21),
column("approvedAt", "审批时间", ExportColumnType.DATETIME, 21));
private static final String SCHEMA_HASH = schemaHash(COLUMNS);
private final CustomerListExportProviderRepository repository;
private final byte[] snapshotSigningKey;
public CustomerListExportProviderService(
CustomerListExportProviderRepository repository,
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
this.repository = repository;
this.snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8);
}
public ExportBatchResponse exportBatch(ExportBatchRequest request) {
ValidatedRequest validated = validate(request);
boolean firstBatch = validated.snapshotToken() == null;
long maximumId = firstBatch
? repository.maximumId(validated.keyword(), validated.status())
: verifySnapshot(validated);
String snapshotToken = firstBatch ? signSnapshot(validated, maximumId) : validated.snapshotToken();
long totalRows = firstBatch
? repository.estimateRows(maximumId, validated.keyword(), validated.status())
: 0L;
List<IamTenant> source = repository.findBatch(validated.cursor(), maximumId,
validated.limit() + 1, validated.keyword(), validated.status());
boolean hasMore = source.size() > validated.limit();
List<IamTenant> page = hasMore ? source.subList(0, validated.limit()) : source;
String nextCursor = page.isEmpty()
? validated.cursorText()
: String.valueOf(page.get(page.size() - 1).getId());
return new ExportBatchResponse(SCHEMA_VERSION, SCHEMA_HASH, snapshotToken,
firstBatch ? COLUMNS : List.of(), page.stream().map(this::row).toList(),
nextCursor, hasMore, firstBatch ? totalRows : null);
}
private ValidatedRequest validate(ExportBatchRequest request) {
if (request == null) throw invalid("Export batch request is required");
requirePositiveId(request.taskId(), "taskId");
if (!"0".equals(request.tenantId())) {
throw invalid("CUSTOMER_LIST only supports platform tenant 0");
}
if (!"PLATFORM".equals(request.appCode())) {
throw invalid("CUSTOMER_LIST only supports PLATFORM appCode");
}
requirePositiveId(request.requestedBy(), "requestedBy");
if (request.dispatchVersion() <= 0) throw invalid("dispatchVersion must be positive");
if (request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) {
throw invalid("limit must be between 1 and 500");
}
Map<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
if (!PARAMETER_KEYS.containsAll(parameters.keySet())) {
throw invalid("CUSTOMER_LIST contains unsupported parameters");
}
String keyword = optionalString(parameters.get("keyword"), "keyword");
if (keyword != null && keyword.length() > 128) throw invalid("keyword must not exceed 128 characters");
String status = optionalString(parameters.get("status"), "status");
if (status != null && !STATUSES.contains(status)) throw invalid("CUSTOMER_LIST status is invalid");
String snapshotToken = normalize(request.snapshotToken());
if (snapshotToken != null && snapshotToken.length() > MAX_SNAPSHOT_TOKEN_LENGTH) {
throw invalid("snapshotToken must not exceed 512 characters");
}
Long cursor = parseCursor(request.cursor());
if (snapshotToken == null && cursor != null) {
throw invalid("cursor requires a snapshotToken");
}
return new ValidatedRequest(request, keyword, status, cursor, normalize(request.cursor()), snapshotToken);
}
private long verifySnapshot(ValidatedRequest request) {
String[] parts = request.snapshotToken().split("\\.", -1);
if (parts.length != 3 || !"v1".equals(parts[0])) throw invalid("snapshotToken is invalid");
long maximumId;
byte[] suppliedSignature;
try {
maximumId = Long.parseLong(parts[1]);
suppliedSignature = Base64.getUrlDecoder().decode(parts[2]);
} catch (IllegalArgumentException exception) {
throw invalid("snapshotToken is invalid");
}
if (maximumId < 0) throw invalid("snapshotToken is invalid");
byte[] expectedSignature = hmac(snapshotSubject(request, maximumId));
if (!MessageDigest.isEqual(expectedSignature, suppliedSignature)) {
throw invalid("snapshotToken does not match this export task");
}
if (request.cursor() != null && request.cursor() > maximumId) {
throw invalid("cursor exceeds the export snapshot");
}
return maximumId;
}
private String signSnapshot(ValidatedRequest request, long maximumId) {
String signature = Base64.getUrlEncoder().withoutPadding()
.encodeToString(hmac(snapshotSubject(request, maximumId)));
return "v1." + maximumId + "." + signature;
}
private String snapshotSubject(ValidatedRequest request, long maximumId) {
ExportBatchRequest source = request.source();
return String.join("\n", "CUSTOMER_LIST", source.taskId(), source.tenantId(), source.appCode(),
source.requestedBy(), String.valueOf(source.dispatchVersion()),
request.keyword() == null ? "" : request.keyword(),
request.status() == null ? "" : request.status(), String.valueOf(maximumId));
}
private byte[] hmac(String value) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256"));
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
} catch (GeneralSecurityException exception) {
throw new IllegalStateException("HmacSHA256 is unavailable", exception);
}
}
private Map<String, Object> row(IamTenant tenant) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", String.valueOf(tenant.getId()));
row.put("tenantCode", tenant.getTenantCode());
row.put("tenantName", tenant.getTenantName());
row.put("legalName", tenant.getLegalName());
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
row.put("enterpriseType", tenant.getEnterpriseType());
row.put("establishedDate", tenant.getEstablishedDate());
row.put("registeredCapital", tenant.getRegisteredCapital());
row.put("registeredCapitalCurrency", tenant.getRegisteredCapitalCurrency());
row.put("registeredAddress", tenant.getRegisteredAddress());
row.put("contactName", tenant.getContactName());
row.put("contactPhone", tenant.getContactPhone());
row.put("contactEmail", tenant.getContactEmail());
row.put("contactWechat", tenant.getContactWechat());
row.put("salesOwnerName", tenant.getSalesOwnerName());
row.put("servicePackage", tenant.getServicePackage());
row.put("paymentTermDays", tenant.getPaymentTermDays());
row.put("status", statusLabel(tenant.getStatus()));
row.put("createdAt", tenant.getCreatedAt());
row.put("approvedAt", tenant.getApprovedAt());
return row;
}
private String statusLabel(String status) {
if (status == null) return null;
return switch (status) {
case "PROFILE_REQUIRED" -> "待完善";
case "PENDING" -> "待审核";
case "ACTIVE" -> "启用";
case "REJECTED" -> "已驳回";
case "DISABLED" -> "已停用";
default -> status;
};
}
private static ExportColumn column(String key, String title, ExportColumnType type, int width) {
return new ExportColumn(key, title, type, width);
}
private static String schemaHash(List<ExportColumn> columns) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
for (ExportColumn column : columns) {
digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type()
+ "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8));
}
return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest());
} catch (GeneralSecurityException exception) {
throw new ExceptionInInitializerError(exception);
}
}
private Long parseCursor(String value) {
String normalized = normalize(value);
if (normalized == null) return null;
try {
long parsed = Long.parseLong(normalized);
if (parsed < 0) throw invalid("cursor must not be negative");
return parsed;
} catch (NumberFormatException exception) {
throw invalid("cursor must be a numeric ID");
}
}
private void requirePositiveId(String value, String field) {
try {
if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID");
} catch (NumberFormatException exception) {
throw invalid(field + " must be a positive ID");
}
}
private String optionalString(Object value, String field) {
if (value == null) return null;
if (!(value instanceof String text)) throw invalid(field + " must be a string");
return normalize(text);
}
private String normalize(String value) {
if (value == null || value.isBlank()) return null;
return value.trim();
}
private BizException invalid(String message) {
return new BizException(ResultCode.VALIDATION_FAILED, message);
}
private record ValidatedRequest(ExportBatchRequest source, String keyword, String status,
Long cursor, String cursorText, String snapshotToken) {
int limit() { return source.limit(); }
}
}
@@ -9,12 +9,16 @@ 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.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
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.IamTenantQualificationMapper;
import com.baiyue.iam.mapper.IamTenantReviewMapper;
import com.baiyue.iam.mapper.IamUserMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
@@ -24,6 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
@Service
public class MerchantOnboardingService {
@@ -33,6 +38,8 @@ public class MerchantOnboardingService {
@Resource private IamRoleMapper roleMapper;
@Resource private IamMembershipRoleMapper membershipRoleMapper;
@Resource private IamTenantAddressMapper addressMapper;
@Resource private IamTenantQualificationMapper qualificationMapper;
@Resource private IamTenantReviewMapper reviewMapper;
@Transactional
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
@@ -61,7 +68,8 @@ public class MerchantOnboardingService {
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setLegalName(request.companyName());
tenant.setContactName(request.legalPerson());
tenant.setLegalRepresentative(request.legalPerson());
tenant.setContactName(request.displayName());
tenant.setContactPhone(request.legalPhone());
tenant.setContactEmail(request.contactEmail());
tenant.setDeleted(false);
@@ -112,4 +120,51 @@ public class MerchantOnboardingService {
}
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
}
/**
* Removes a just-created IAM registration when Auth could not persist the paired login account.
* Repeated calls are safe, while progressed onboarding data is deliberately protected.
*/
@Transactional
public void compensateRegistration(long authUserId) {
IamUser user = userMapper.selectOne(new LambdaQueryWrapper<IamUser>()
.eq(IamUser::getAuthUserId, authUserId)
.last("LIMIT 1 FOR UPDATE"));
if (user == null) {
return;
}
List<IamMembership> memberships = membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getUserId, user.getId())
.orderByAsc(IamMembership::getId)
.last("FOR UPDATE"));
if (memberships.size() != 1) {
throw new BizException(ResultCode.CONFLICT,
"IAM registration compensation is unsafe: membership state changed");
}
IamMembership membership = memberships.get(0);
IamTenant tenant = tenantMapper.selectOne(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getId, membership.getTenantId())
.last("FOR UPDATE"));
if (tenant == null || !"PROFILE_REQUIRED".equals(tenant.getStatus())
|| !"OWNER".equals(membership.getMemberType())) {
throw new BizException(ResultCode.CONFLICT,
"IAM registration compensation is unsafe: tenant state changed");
}
long qualificationCount = qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenant.getId()));
long reviewCount = reviewMapper.selectCount(new LambdaQueryWrapper<IamTenantReview>()
.eq(IamTenantReview::getTenantId, tenant.getId()));
if (qualificationCount > 0 || reviewCount > 0) {
throw new BizException(ResultCode.CONFLICT,
"IAM registration compensation is unsafe: onboarding has progressed");
}
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
.eq(IamMembershipRole::getMembershipId, membership.getId()));
membershipMapper.deleteById(membership.getId());
addressMapper.delete(new LambdaQueryWrapper<IamTenantAddress>()
.eq(IamTenantAddress::getTenantId, tenant.getId()));
userMapper.deleteById(user.getId());
tenantMapper.deleteById(tenant.getId());
}
}
@@ -65,6 +65,7 @@ public class ProfileResourceService {
throw new BizException(ResultCode.CONFLICT,
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
}
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType())
&& isCurrentQualification(item))
@@ -82,12 +83,12 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(),
request.qualificationType(), request.certificateNo(), request.documentUrl());
request.qualificationType(), certificateNo, request.documentUrl());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
if (StringUtils.hasText(request.certificateNo())
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
if ("BUSINESS_LICENSE".equals(request.qualificationType())
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
tenant.setUnifiedSocialCreditCode(certificateNo);
tenantRepository.saveTenant(tenant);
}
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
@@ -104,6 +105,7 @@ public class ProfileResourceService {
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
}
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType())
&& isCurrentQualification(item))
@@ -121,13 +123,36 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
request.certificateNo(), request.documentUrl());
certificateNo, request.documentUrl());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
if ("BUSINESS_LICENSE".equals(request.qualificationType())
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
tenant.setUnifiedSocialCreditCode(certificateNo);
tenantRepository.saveTenant(tenant);
}
return new PlatformQualificationRegistrationResponse(
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
}
private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) {
if (!"BUSINESS_LICENSE".equals(qualificationType)) {
return submittedCertificateNo;
}
String certificateNo = StringUtils.hasText(submittedCertificateNo)
? submittedCertificateNo.trim().toUpperCase()
: tenant.getUnifiedSocialCreditCode();
if (!StringUtils.hasText(certificateNo) || !certificateNo.matches("[0-9A-Z]{18}")) {
throw new BizException(ResultCode.BAD_REQUEST,
"A valid 18-character unified social credit code is required / 营业执照必须填写有效的18位统一社会信用代码");
}
if (tenantRepository.existsByCreditCodeForOtherTenant(certificateNo, tenant.getId())) {
throw new BizException(ResultCode.CONFLICT,
"Unified social credit code already exists / 统一社会信用代码已存在");
}
return certificateNo;
}
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);
@@ -0,0 +1,295 @@
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.entity.IamMembership;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.mapper.IamRoleMapper;
import com.baiyue.iam.repository.MemberRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.List;
/** Provisions the first enterprise owner while keeping credentials in Auth only. */
@Service
@Order(100)
public class TenantAdministratorProvisioningService implements TenantApprovalInitializer {
private static final Logger log = LoggerFactory.getLogger(TenantAdministratorProvisioningService.class);
private static final char[] UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
private static final char[] LOWER = "abcdefghijkmnopqrstuvwxyz".toCharArray();
private static final char[] DIGITS = "23456789".toCharArray();
private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray();
private static final int PASSWORD_LENGTH = 16;
private static final int USERNAME_SUFFIX_LENGTH = 8;
private static final int USERNAME_ATTEMPTS = 3;
private final MemberRepository members;
private final IamRoleMapper roles;
private final AuthAccountClient authClient;
private final String internalToken;
private final SecureRandom random;
public TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
AuthAccountClient authClient,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this(members, roles, authClient, internalToken, new SecureRandom());
}
TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
AuthAccountClient authClient, String internalToken,
SecureRandom random) {
this.members = members;
this.roles = roles;
this.authClient = authClient;
this.internalToken = internalToken;
this.random = random;
}
@Override
public String name() { return "tenant-administrator"; }
@Override
public ProvisioningResult initialize(ApprovalContext context) {
IamTenant tenant = context.tenant();
long operatorId = context.reviewerId();
IamRole ownerRole = requiredOwnerRole();
IamMembership existingOwner = members.findOwnerByTenant(tenant.getId());
if (existingOwner != null) return ensureExistingOwnerCanLogin(tenant, existingOwner, ownerRole);
if (!isPlatformCreatedTenant(tenant)) {
throw inconsistentOwner("客户端注册企业缺少原注册所有者,不能自动创建第二个管理员,请先修复注册身份");
}
long authUserId = IdWorker.getId();
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
String password = temporaryPassword();
String username = createAuthAccountWithUniqueUsername(authUserId, password, tenant, membershipId);
ProvisioningResult result = ProvisioningResult.created(authUserId, username, password);
try {
IamUser user = new IamUser();
user.setId(userId);
user.setAuthUserId(authUserId);
user.setUserType("ENTERPRISE");
user.setDisplayName(tenant.getContactName());
user.setPhone(tenant.getContactPhone());
user.setEmail(tenant.getContactEmail());
user.setStatus("ACTIVE");
members.insertUser(user);
IamMembership membership = new IamMembership();
membership.setId(membershipId);
membership.setTenantId(tenant.getId());
membership.setUserId(userId);
membership.setMemberType("OWNER");
membership.setMemberNo("OWNER-" + membershipId);
membership.setStatus("ACTIVE");
membership.setJoinedAt(LocalDateTime.now());
membership.setCreatedBy(operatorId);
membership.setUpdatedBy(operatorId);
members.insert(membership);
members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId()));
return result;
} catch (RuntimeException localFailure) {
compensate(context, result);
throw localFailure;
}
}
private IamRole requiredOwnerRole() {
IamRole ownerRole = roles.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, "企业管理员角色模板未初始化,不能审核通过");
}
return ownerRole;
}
private boolean isPlatformCreatedTenant(IamTenant tenant) {
// 只有平台创建接口会记录操作人;客户端注册后也会补齐信用代码,不能据此推断来源。
return tenant.getCreatedBy() != null;
}
private ProvisioningResult ensureExistingOwnerCanLogin(IamTenant tenant, IamMembership owner,
IamRole ownerRole) {
if (!"ACTIVE".equals(owner.getStatus())) {
throw inconsistentOwner("企业所有者成员已停用,不能审核通过");
}
IamUser user = members.findUser(owner.getUserId());
if (user == null || user.getAuthUserId() == null || !"ACTIVE".equals(user.getStatus())) {
throw inconsistentOwner("企业所有者身份不完整或已停用,不能审核通过");
}
AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId());
if (account == null) {
String password = temporaryPassword();
String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password,
tenant, owner.getId());
return ProvisioningResult.created(user.getAuthUserId(), username, password);
}
if (!"ACTIVE".equals(account.status())
|| !user.getAuthUserId().equals(account.accountId())
|| !tenant.getId().equals(account.tenantId())
|| !owner.getId().equals(account.membershipId())
|| !"MERCHANT".equals(account.appCode())) {
throw inconsistentOwner("企业所有者认证账号已停用或账号范围不一致,不能审核通过");
}
List<Long> roleIds = members.findRoleIds(tenant.getId(), owner.getId());
if (!roleIds.contains(ownerRole.getId())) {
List<Long> repairedRoleIds = new java.util.ArrayList<>(roleIds);
repairedRoleIds.add(ownerRole.getId());
members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList());
}
return ProvisioningResult.existing();
}
private AuthAccountClient.AccountSummary findAuthAccount(long authUserId) {
try {
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.summary(internalToken, authUserId);
if (response == null) throw unavailable();
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
if (code == ResultCode.NOT_FOUND) return null;
throw new BizException(code, response.message() == null || response.message().isBlank()
? code.message() : response.message());
}
if (response.data() == null) throw unavailable();
return response.data();
} catch (FeignException exception) {
if (exception.status() == 404) return null;
throw unavailable();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
@Override
public void compensate(ApprovalContext context, InitializationResult initialization) {
ProvisioningResult result = (ProvisioningResult) initialization;
if (result.created()) compensate(result.authUserId());
}
private String createAuthAccountWithUniqueUsername(long authUserId, String password,
IamTenant tenant, long membershipId) {
for (int attempt = 0; attempt < USERNAME_ATTEMPTS; attempt++) {
String username = username(tenant.getId());
try {
createAuthAccount(authUserId, username, password, tenant, membershipId);
return username;
} catch (BizException exception) {
if (exception.getResultCode() != ResultCode.CONFLICT || attempt == USERNAME_ATTEMPTS - 1) {
throw exception;
}
}
}
throw new IllegalStateException("Username generation attempts exhausted");
}
private void createAuthAccount(long authUserId, String username, String password,
IamTenant tenant, long membershipId) {
try {
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
new AuthAccountClient.CreateAccountRequest(authUserId, username, password,
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
if (response == null) throw unavailable();
if (!response.successful()) {
ResultCode code = ResultCode.of(response.code());
throw new BizException(code, response.message() == null || response.message().isBlank()
? code.message() : response.message());
}
if (response.data() == null) throw unavailable();
} catch (FeignException exception) {
if (exception.status() == 409) {
throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在");
}
throw unavailable();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw unavailable();
}
}
private void compensate(long authUserId) {
try {
ApiResponse<Void> response = authClient.compensate(internalToken, authUserId);
if (response == null || !response.successful()) throw unavailable();
} catch (RuntimeException compensationFailure) {
log.error("企业管理员创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"企业管理员创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
}
}
private String temporaryPassword() {
char[] value = new char[PASSWORD_LENGTH];
value[0] = pick(UPPER);
value[1] = pick(LOWER);
value[2] = pick(DIGITS);
for (int index = 3; index < value.length; index++) value[index] = pick(ALL);
for (int index = value.length - 1; index > 0; index--) {
int swap = random.nextInt(index + 1);
char current = value[index];
value[index] = value[swap];
value[swap] = current;
}
return new String(value);
}
private String username(long tenantId) {
char[] suffix = new char[USERNAME_SUFFIX_LENGTH];
for (int index = 0; index < suffix.length; index++) suffix[index] = pick(LOWER);
return "merchant_" + tenantId + "_" + new String(suffix);
}
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能审核通过");
}
private BizException inconsistentOwner(String message) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
}
public static final class ProvisioningResult implements InitializationResult {
private final boolean created;
private final Long authUserId;
private final String username;
private final String temporaryPassword;
private ProvisioningResult(boolean created, Long authUserId, String username, String temporaryPassword) {
this.created = created;
this.authUserId = authUserId;
this.username = username;
this.temporaryPassword = temporaryPassword;
}
static ProvisioningResult existing() { return new ProvisioningResult(false, null, null, null); }
static ProvisioningResult created(long authUserId, String username, String password) {
return new ProvisioningResult(true, authUserId, username, password);
}
public boolean created() { return created; }
public Long authUserId() { return authUserId; }
public String username() { return username; }
public String temporaryPassword() { return temporaryPassword; }
}
}
@@ -0,0 +1,123 @@
package com.baiyue.iam.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
/** Runs all tenant approval initializers and owns their reverse-order compensation lifecycle. */
@Service
public class TenantApprovalInitializationCoordinator {
private static final Logger log = LoggerFactory.getLogger(TenantApprovalInitializationCoordinator.class);
private final List<TenantApprovalInitializer> initializers;
public TenantApprovalInitializationCoordinator(List<TenantApprovalInitializer> initializers) {
List<TenantApprovalInitializer> ordered = new ArrayList<>(initializers);
AnnotationAwareOrderComparator.sort(ordered);
Set<String> names = new HashSet<>();
for (TenantApprovalInitializer initializer : ordered) {
String name = initializer.name();
if (name == null || name.isBlank() || !names.add(name)) {
throw new IllegalArgumentException("Tenant approval initializer names must be nonblank and unique: " + name);
}
}
this.initializers = List.copyOf(ordered);
}
public InitializationBatch initialize(TenantApprovalInitializer.ApprovalContext context) {
InitializationBatch batch = new InitializationBatch(context);
registerRollbackCompensation(batch);
try {
for (TenantApprovalInitializer initializer : initializers) {
TenantApprovalInitializer.InitializationResult result = initializer.initialize(context);
if (result == null) {
throw new IllegalStateException("Tenant approval initializer returned null: " + initializer.name());
}
batch.add(initializer, result);
}
return batch;
} catch (RuntimeException initializationFailure) {
try {
compensate(batch);
} catch (RuntimeException compensationFailure) {
compensationFailure.addSuppressed(initializationFailure);
throw compensationFailure;
}
throw initializationFailure;
}
}
public InitializationBatch empty(TenantApprovalInitializer.ApprovalContext context) {
return new InitializationBatch(context);
}
public void compensate(InitializationBatch batch) {
if (!batch.markCompensated()) return;
RuntimeException firstFailure = null;
List<CompletedInitialization> completed = new ArrayList<>(batch.completed());
Collections.reverse(completed);
for (CompletedInitialization item : completed) {
try {
item.initializer().compensate(batch.context(), item.result());
} catch (RuntimeException exception) {
log.error("企业审核初始化补偿失败,initializer={}, tenantId={}",
item.initializer().name(), batch.context().tenant().getId(), exception);
if (firstFailure == null) firstFailure = exception;
else firstFailure.addSuppressed(exception);
}
}
if (firstFailure != null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
"企业审核初始化补偿失败,请检查服务日志并修复数据一致性");
}
}
private void registerRollbackCompensation(InitializationBatch batch) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) return;
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) compensate(batch);
}
});
}
private record CompletedInitialization(TenantApprovalInitializer initializer,
TenantApprovalInitializer.InitializationResult result) { }
public static final class InitializationBatch {
private final TenantApprovalInitializer.ApprovalContext context;
private final List<CompletedInitialization> completed = new ArrayList<>();
private final AtomicBoolean compensated = new AtomicBoolean(false);
private InitializationBatch(TenantApprovalInitializer.ApprovalContext context) {
this.context = context;
}
private void add(TenantApprovalInitializer initializer,
TenantApprovalInitializer.InitializationResult result) {
completed.add(new CompletedInitialization(initializer, result));
}
private TenantApprovalInitializer.ApprovalContext context() { return context; }
private List<CompletedInitialization> completed() { return List.copyOf(completed); }
private boolean markCompensated() { return compensated.compareAndSet(false, true); }
public <T extends TenantApprovalInitializer.InitializationResult> Optional<T> result(Class<T> type) {
return completed.stream().map(CompletedInitialization::result)
.filter(type::isInstance).map(type::cast).findFirst();
}
}
}
@@ -0,0 +1,22 @@
package com.baiyue.iam.service;
import com.baiyue.iam.entity.IamTenant;
/**
* Extension point for provisioning defaults after an enterprise is approved.
* Implementations may initialize accounts, roles, rules, fee plans or other tenant-scoped defaults.
* Implementations and their compensation must be idempotent. A failed implementation must clean
* up its own partial work before throwing; the coordinator compensates every initializer that
* completed successfully, in reverse order. Use {@code @Order} for explicit dependencies.
*/
public interface TenantApprovalInitializer {
String name();
InitializationResult initialize(ApprovalContext context);
default void compensate(ApprovalContext context, InitializationResult result) { }
record ApprovalContext(IamTenant tenant, long reviewId, long reviewerId, String comment) { }
interface InitializationResult { }
}
@@ -17,6 +17,7 @@ import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
@@ -39,6 +40,8 @@ public class TenantService {
private TenantRepository repository;
@Resource
private ProfileResourceRepository profileRepository;
@Resource
private TenantApprovalInitializationCoordinator approvalInitialization;
@BusinessAudit(
@@ -49,7 +52,7 @@ public class TenantService {
captureAfter = false
)
@Transactional
public TenantResponse register(RegisterRequest request) {
public TenantResponse register(RegisterRequest request, long operatorId) {
if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) {
throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在");
}
@@ -61,6 +64,7 @@ public class TenantService {
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode());
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
@@ -75,6 +79,8 @@ public class TenantService {
tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency());
tenant.setRegisteredAddress(request.registeredAddress());
tenant.setInternalRemark(request.internalRemark());
tenant.setCreatedBy(operatorId);
tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant);
return response(tenant);
@@ -119,8 +125,8 @@ public class TenantService {
captureAfter = false
)
@Transactional
public void review(long reviewId, long reviewerId, boolean approve, String comment) {
IamTenantReview review = repository.findReview(reviewId);
public ApprovalResponse review(long reviewId, long reviewerId, boolean approve, String comment) {
IamTenantReview review = repository.findReviewForUpdate(reviewId);
if (review == null) {
throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在");
}
@@ -135,28 +141,49 @@ public class TenantService {
}
}
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);
var initializationContext = new TenantApprovalInitializer.ApprovalContext(
tenant, reviewId, reviewerId, comment);
var initialization = approve
? approvalInitialization.initialize(initializationContext)
: approvalInitialization.empty(initializationContext);
var administrator = initialization
.result(TenantAdministratorProvisioningService.ProvisioningResult.class)
.orElseGet(TenantAdministratorProvisioningService.ProvisioningResult::existing);
try {
review.setStatus(approve ? "APPROVED" : "REJECTED");
review.setReviewerId(reviewerId);
review.setReviewComment(comment);
review.setReviewedAt(LocalDateTime.now());
repository.saveReview(review);
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);
}
}
} catch (RuntimeException localFailure) {
try {
approvalInitialization.compensate(initialization);
} catch (RuntimeException compensationFailure) {
compensationFailure.addSuppressed(localFailure);
throw compensationFailure;
}
throw localFailure;
}
return new ApprovalResponse(tenant.getId(), administrator.created(),
administrator.username(), administrator.temporaryPassword());
}
public OrderEligibilityResponse orderEligibility(long tenantId) {
@@ -168,9 +195,9 @@ public class TenantService {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"Enterprise information must be approved before ordering");
}
if (profileRepository.countApprovedQualifications(tenantId) < 1) {
if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) {
return new OrderEligibilityResponse(false, tenant.getStatus(),
"At least one approved enterprise qualification is required");
"All required enterprise qualifications must be approved");
}
return new OrderEligibilityResponse(true, tenant.getStatus(), null);
}
@@ -195,6 +222,7 @@ public class TenantService {
IamTenant tenant = requireMerchant(tenantId);
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail());
@@ -237,6 +265,11 @@ public class TenantService {
if (repository.findPendingReviewByTenant(tenantId) != null) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
}
if (tenant.getUnifiedSocialCreditCode() == null
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
throw new BizException(ResultCode.BAD_REQUEST,
"A valid unified social credit code is required / 请先填写有效的18位统一社会信用代码");
}
var qualifications = profileRepository.findQualifications(tenantId);
if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.BAD_REQUEST,
@@ -262,6 +295,7 @@ public class TenantService {
}
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail());
@@ -274,8 +308,12 @@ public class TenantService {
}
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
return hasAllQualificationsWithStatus(qualifications, "PENDING");
}
private boolean hasAllQualificationsWithStatus(List<IamTenantQualification> qualifications, String status) {
return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream()
.anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())));
.anyMatch(item -> type.equals(item.getQualificationType()) && status.equals(item.getStatus())));
}
private IamTenant requireMerchant(long tenantId) {
@@ -297,7 +335,7 @@ public class TenantService {
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(),
tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(),
@@ -308,7 +346,7 @@ public class TenantService {
private TenantResponse summaryResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
response.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(),
response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(),
@@ -318,7 +356,7 @@ public class TenantService {
private TenantResponse merchantVisibleResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
response.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
response.paymentTermDays(), response.enterpriseType(), response.establishedDate(),
response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(),
+1
View File
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
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 '企业法定名称',
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
contact_name VARCHAR(64) NULL COMMENT '企业联系人',
contact_phone VARCHAR(32) NULL COMMENT '联系电话',
@@ -79,7 +79,6 @@ VALUES
(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),
@@ -91,6 +90,7 @@ VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0),
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
-- Platform: buttons.
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0),
(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),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0),
@@ -25,6 +25,7 @@ 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', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
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'");
@@ -55,11 +56,17 @@ 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;
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT owner_role.id, permission.id, 0
FROM iam_role owner_role
JOIN iam_permission permission
ON permission.app_code='MERCHANT' AND permission.tenant_id=0
AND permission.permission_type IN ('MENU','BUTTON')
AND permission.status='ACTIVE' AND permission.deleted=0
WHERE owner_role.tenant_id=0 AND owner_role.app_code='MERCHANT'
AND owner_role.role_code='MERCHANT_OWNER'
AND owner_role.status='ACTIVE' AND owner_role.deleted=0
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
-- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。
-- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。
+63 -6
View File
@@ -173,7 +173,7 @@ paths:
post:
tags: [Platform Tenant / 平台租户]
summary: Approve enterprise registration / 审核通过企业注册
description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。
description: Approve and activate the tenant. Client registrations keep the registration account as administrator. Only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活企业;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动创建管理员并返回一次性凭据。
parameters:
- {$ref: '#/components/parameters/Id'}
requestBody:
@@ -182,7 +182,11 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ReviewRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 审核通过;data 返回开户结果及一次性初始凭据
content:
application/json:
schema: {$ref: '#/components/schemas/TenantApprovalApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
'409': {$ref: '#/components/responses/Conflict'}
@@ -595,6 +599,41 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/exports/v1/CUSTOMER_LIST/batches:
post:
tags: [Internal Export Provider]
summary: 分批提供企业客户导出数据
description: 仅供 File Service 调用;按平台访问域、签名最大 ID 快照和游标只读查询商户客户。
security: []
parameters:
- name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
properties:
taskId: {type: string, pattern: '^\d+$'}
tenantId: {type: string, pattern: '^\d+$'}
appCode: {type: string, enum: [PLATFORM]}
requestedBy: {type: string, pattern: '^\d+$'}
dispatchVersion: {type: integer, minimum: 1}
parameters: {type: object}
cursor: {type: string, nullable: true}
snapshotToken: {type: string, nullable: true, maxLength: 512}
limit: {type: integer, minimum: 1, maximum: 500}
responses:
'200':
description: 统一响应;data 为 ExportBatchResponse,Long 值以字符串传输
content:
application/json:
schema: {$ref: '#/components/schemas/ApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
components:
securitySchemes:
bearerAuth:
@@ -677,6 +716,20 @@ components:
size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
TenantApprovalApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
TenantApprovalResponse:
type: object
required: [tenantId, administratorCreated]
properties:
tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'}
administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员}
administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs}
temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回}
TenantApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
@@ -704,6 +757,7 @@ components:
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
unifiedSocialCreditCode: {type: string}
contactName: {type: string}
contactPhone: {type: string}
@@ -763,10 +817,11 @@ components:
reviewComment: {type: string}
RegisterRequest:
type: object
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
required: [tenantName, legalName, legalRepresentative, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
legalName: {type: string, example: 张三}
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, example: 张三}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12}
contactName: {type: string, example: 李四}
contactPhone: {type: string, example: '13800138000'}
@@ -783,19 +838,21 @@ components:
internalRemark: {type: string, maxLength: 1000}
UpdateProfileRequest:
type: object
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}
UpdateTenantRequest:
type: object
required: [tenantName, legalName, contactName, contactPhone]
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
contactName: {type: string}
contactPhone: {type: string}
contactEmail: {type: string, format: email}