优化
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
}
|
||||
+123
@@ -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(),
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user