Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-01 19:25:16 +08:00
56 changed files with 1478 additions and 117 deletions
@@ -21,6 +21,11 @@ public interface AuthAccountClient {
@PathVariable Long id, @RequestBody StatusRequest request);
@PutMapping("/{id}/profile") ApiResponse<Void> profile(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody ProfileRequest request);
@PutMapping("/{id}/login-identifiers") ApiResponse<AccountSummary> changeLoginIdentifiers(
@RequestHeader("X-Internal-Service-Token") String token, @PathVariable Long id,
@RequestBody ChangeLoginIdentifiersRequest request);
@PutMapping("/{id}/phone") ApiResponse<Void> phone(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PhoneRequest request);
@PutMapping("/{id}/password") ApiResponse<Void> reset(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id, @RequestBody PasswordRequest request);
/** Idempotently disables and revokes all accounts belonging to a customer. */
@@ -37,25 +42,35 @@ public interface AuthAccountClient {
@DeleteMapping("/{id}/compensation") ApiResponse<Void> compensate(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable Long id);
record CreateAccountRequest(Long accountId, String username, String password, String displayName,
record CreateAccountRequest(Long accountId, String username, String phone, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId) {
this(accountId, username, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE");
this(accountId, username, null, password, displayName, tenantId, membershipId, "MERCHANT", "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode) {
this(accountId, username, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, "ACTIVE");
}
public CreateAccountRequest(Long accountId, String username, String password, String displayName,
Long tenantId, Long membershipId, String appCode, String accountStatus) {
this(accountId, username, null, password, displayName, tenantId, membershipId, appCode, accountStatus);
}
}
record AccountSummary(Long accountId, String username, String displayName, String status,
record AccountSummary(Long accountId, String username, String phone, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
public AccountSummary(Long accountId, String username, String displayName, String status) {
this(accountId, username, displayName, status, null, null, null, null);
this(accountId, username, null, displayName, status, null, null, null, null);
}
public AccountSummary(Long accountId, String username, String displayName, String status,
LocalDateTime lastLoginAt, Long tenantId, Long membershipId, String appCode) {
this(accountId, username, null, displayName, status, lastLoginAt, tenantId, membershipId, appCode);
}
}
record StatusRequest(String status) { }
record ProfileRequest(String displayName) { }
record ChangeLoginIdentifiersRequest(String username, String phone, String currentPassword) { }
record PhoneRequest(String phone) { }
record PasswordRequest(String newPassword, boolean forceChangePassword) {
public PasswordRequest(String newPassword) { this(newPassword, false); }
}
@@ -5,6 +5,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.MemberDtos.*;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.service.MemberService;
@@ -54,6 +55,18 @@ public class MerchantTenantController {
return ApiResponse.success(personalProfileService.update(tenantId, user.userId(), user.username(), request));
}
@PutMapping("/profile/login-identifiers")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "修改当前登录用户的用户名和手机号",
description = "必须使用当前密码重新认证;成功后吊销全部会话,客户端需重新登录。")
public ApiResponse<PersonalProfileResponse> changeLoginIdentifiers(
@Valid @RequestBody ChangeLoginIdentifiersRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.rejectImpersonated(user);
long tenantId = TenantGuard.tenant(user, "MERCHANT");
return ApiResponse.success(personalProfileService.changeLoginIdentifiers(tenantId, user.userId(), request));
}
@GetMapping("/tenant/profile")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "Get current enterprise profile / 查询当前企业资料", description = "Tenant ID is taken only from the authenticated merchant JWT. / 租户 ID 仅从已认证商户 JWT 获取。")
@@ -74,7 +87,7 @@ public class MerchantTenantController {
@PostMapping("/tenant/qualifications/submit-review")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "提交当前企业资质审核", description = "校验营业执照和法人身份证齐全后创建审核单;货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
@Operation(summary = "提交当前企业资质审核", description = "校验营业执照齐全后创建审核单;法人身份证、货代资质证书和银行开户许可证选填。上传或替换文件不会自动送审。")
public ApiResponse<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
}
@@ -14,11 +14,11 @@ public final class MemberDtos {
private MemberDtos() { }
public record CreateMemberRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String password,
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
@@ -30,7 +30,7 @@ public final class MemberDtos {
public record UpdateMemberRequest(
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank String memberType,
@Size(max = 64) String memberNo,
@@ -57,9 +57,9 @@ public final class OnboardingDtos {
@NotBlank String businessLicenseDocumentUrl,
LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
@NotBlank String legalPersonIdDocumentUrl,
String legalPersonIdDocumentUrl,
LocalDate legalPersonIdValidTo,
boolean replaceLegalPersonId
Boolean replaceLegalPersonId
) { }
public record RequiredQualificationPackageResponse(
@@ -20,9 +20,9 @@ public final class PlatformInternalUserDtos {
Long authUserId,
String userType,
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@Size(min = 8, max = 64)
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字")
String password,
@@ -31,7 +31,7 @@ public final class PlatformInternalUserDtos {
public record UpdateInternalUserRequest(
@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Pattern(regexp = "^1\\d{10}$") String phone,
@Email @Size(max = 128) String email,
@NotBlank @Size(max = 64) String memberNo,
@NotNull List<Long> roleIds) { }
@@ -14,7 +14,7 @@ public final class PlatformUserManagementDtos {
private PlatformUserManagementDtos() { }
public record CreateUserRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z0-9_.@-]{4,64}$") String username,
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Size(max = 64) String displayName,
@NotBlank @Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确") String phone,
@Email @Size(max = 128) String email,
@@ -2,6 +2,7 @@ package com.baiyue.iam.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/** 用户档案、地址和资质的数据契约。 */
@@ -12,9 +13,13 @@ public final class ProfileDtos {
String displayName, String phone, String email) { }
public record UpdatePersonalProfileRequest(@NotBlank @Size(max = 64) String displayName,
@Size(max = 32) String phone,
@Email @Size(max = 128) String email) { }
public record ChangeLoginIdentifiersRequest(
@NotBlank @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_.@-]{3,63}$") String username,
@NotBlank @Pattern(regexp = "^1\\d{10}$") String phone,
@NotBlank String currentPassword) { }
public record AddressRequest(@NotBlank String addressType, String countryCode, String province,
String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { }
@@ -93,7 +93,8 @@ public class MemberService {
long userId = IdWorker.getId();
long membershipId = IdWorker.getId();
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
authId, request.username(), request.password(), request.displayName(), tenantId, membershipId)));
authId, request.username(), request.phone(), request.password(), request.displayName(),
tenantId, membershipId, "MERCHANT", "ACTIVE")));
try {
IamUser user = new IamUser();
user.setId(userId); user.setAuthUserId(authId); user.setUserType("ENTERPRISE");
@@ -134,6 +135,10 @@ public class MemberService {
throw new BizException(ResultCode.FORBIDDEN, "企业所有者不允许通过成员管理接口编辑");
}
IamUser user = requiredUser(membership.getUserId());
if (!java.util.Objects.equals(user.getPhone(), request.phone())) {
call(() -> authClient.phone(internalToken, user.getAuthUserId(),
new AuthAccountClient.PhoneRequest(request.phone())));
}
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
membership.setMemberType(request.memberType()); membership.setMemberNo(request.memberNo());
repository.saveUser(user); repository.save(membership);
@@ -4,6 +4,8 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.ProfileDtos.PersonalProfileResponse;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
@@ -11,12 +13,19 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
/** 当前用户个人资料服务,必须同时匹配认证账号与当前租户成员关系。 */
@Service
public class PersonalProfileService {
@Resource
private MemberRepository repository;
@Resource
private AuthAccountClient authClient;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public PersonalProfileResponse get(long tenantId, Long authUserId, String username) {
return response(tenantId, authUserId, username);
@@ -30,12 +39,40 @@ public class PersonalProfileService {
if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden();
IamUser user = repository.findUser(membership.getUserId());
if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
user.setDisplayName(request.displayName()); user.setPhone(request.phone()); user.setEmail(request.email());
user.setDisplayName(request.displayName()); user.setEmail(request.email());
repository.saveUser(user);
return new PersonalProfileResponse(user.getId(), authUserId, username,
user.getDisplayName(), user.getPhone(), user.getEmail());
}
@Transactional
@BusinessAudit(event = "PERSONAL_LOGIN_IDENTIFIERS_UPDATE", resourceType = "IAM_USER",
resourceId = "#authUserId", description = "修改本人用户名和手机号", captureAfter = false)
public PersonalProfileResponse changeLoginIdentifiers(long tenantId, Long authUserId,
ChangeLoginIdentifiersRequest request) {
IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
if (membership == null || !"ACTIVE".equals(membership.getStatus())) throw forbidden();
IamUser user = repository.findUser(membership.getUserId());
if (user == null || !authUserId.equals(user.getAuthUserId())) throw forbidden();
AuthAccountClient.AccountSummary account;
try {
ApiResponse<AuthAccountClient.AccountSummary> result = authClient.changeLoginIdentifiers(
internalToken, authUserId, new AuthAccountClient.ChangeLoginIdentifiersRequest(
request.username(), request.phone(), request.currentPassword()));
if (result == null || !result.successful() || result.data() == null) {
ResultCode code = result == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(result.code());
throw new BizException(code, result == null ? "认证账号服务暂时不可用" : result.message());
}
account = result.data();
} catch (FeignException ex) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
}
user.setPhone(account.phone());
repository.saveUser(user);
return new PersonalProfileResponse(user.getId(), authUserId, account.username(),
user.getDisplayName(), user.getPhone(), user.getEmail());
}
private PersonalProfileResponse response(long tenantId, Long authUserId, String username) {
IamMembership membership = repository.findByTenantAndAuthUser(tenantId, authUserId);
if (membership == null) throw forbidden();
@@ -69,8 +69,8 @@ public class PlatformInternalUserService {
validateMemberNo(memberNo, null);
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
authUserId, request.username(), request.password(), request.displayName(),
PLATFORM_TENANT_ID, membershipId, "PLATFORM")));
authUserId, request.username(), request.phone(), request.password(), request.displayName(),
PLATFORM_TENANT_ID, membershipId, "PLATFORM", "ACTIVE")));
try {
IamUser user = newUser(userId, authUserId, request.displayName(), request.phone(), request.email());
IamMembership membership = newMembership(membershipId, userId, memberNo);
@@ -96,12 +96,17 @@ public class PlatformInternalUserService {
IamUser user = requiredUser(membership.getUserId());
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
boolean displayNameChanged = !Objects.equals(user.getDisplayName(), request.displayName());
boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone());
List<Long> oldRoleIds = repository.findRoleIds(PLATFORM_TENANT_ID, membershipId);
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
if (displayNameChanged) {
call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName())));
}
if (phoneChanged) {
call(() -> authClient.phone(internalToken, user.getAuthUserId(),
new AuthAccountClient.PhoneRequest(request.phone())));
}
try {
user.setDisplayName(request.displayName());
user.setPhone(request.phone());
@@ -81,8 +81,8 @@ public class PlatformUserManagementService {
long membershipId = IdWorker.getId();
String appCode = appCode(tenantId);
call(() -> authClient.create(internalToken, new AuthAccountClient.CreateAccountRequest(
authUserId, request.username().trim(), request.password(), request.displayName().trim(),
tenantId, membershipId, appCode, request.status())));
authUserId, request.username().trim(), request.phone().trim(), request.password(),
request.displayName().trim(), tenantId, membershipId, appCode, request.status())));
try {
IamUser user = new IamUser();
user.setId(userId);
@@ -137,12 +137,15 @@ public class PlatformUserManagementService {
}
AuthAccountClient.AccountSummary oldAccount = account(user.getAuthUserId());
boolean profileChanged = !Objects.equals(user.getDisplayName(), request.displayName().trim());
boolean phoneChanged = !Objects.equals(user.getPhone(), request.phone().trim());
boolean scopeChanged = oldTenantId != newTenantId;
boolean rolesChanged = !new java.util.HashSet<>(oldRoleIds).equals(new java.util.HashSet<>(roleIds));
boolean warehouseChanged = !Objects.equals(membership.getOverseasWarehouseId(), overseasWarehouseId);
if (profileChanged) call(() -> authClient.profile(internalToken, user.getAuthUserId(),
new AuthAccountClient.ProfileRequest(request.displayName().trim())));
if (phoneChanged) call(() -> authClient.phone(internalToken, user.getAuthUserId(),
new AuthAccountClient.PhoneRequest(request.phone().trim())));
if (scopeChanged) call(() -> authClient.scope(internalToken, user.getAuthUserId(),
new AuthAccountClient.ScopeRequest(newTenantId, membershipId, appCode(newTenantId))));
try {
@@ -160,24 +160,32 @@ public class ProfileResourceService {
List<IamTenantQualification> businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE");
List<IamTenantQualification> legalPersonIds = currentQualifications(locked, "LEGAL_PERSON_ID");
requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照");
requireReplaceAllowed(legalPersonIds, request.replaceLegalPersonId(), "法人身份证");
boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl());
if (hasLegalPersonId) {
requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证");
} else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) {
throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换");
}
String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment);
String replacedLegalPersonIdUrl = supersede(legalPersonIds, replacementComment);
String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null;
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
creditCode, request.businessLicenseDocumentUrl());
businessLicense.setValidTo(request.businessLicenseValidTo());
repository.saveQualification(businessLicense);
IamTenantQualification legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
null, request.legalPersonIdDocumentUrl());
legalPersonId.setValidTo(request.legalPersonIdValidTo());
repository.saveQualification(legalPersonId);
IamTenantQualification legalPersonId = null;
if (hasLegalPersonId) {
legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
null, request.legalPersonIdDocumentUrl().trim());
legalPersonId.setValidTo(request.legalPersonIdValidTo());
repository.saveQualification(legalPersonId);
}
if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) {
tenant.setUnifiedSocialCreditCode(creditCode);
tenantRepository.saveTenant(tenant);
}
return new RequiredQualificationPackageResponse(
businessLicense.getId(), legalPersonId.getId(), tenant.getStatus(),
businessLicense.getId(), legalPersonId == null ? null : legalPersonId.getId(), tenant.getStatus(),
replacedBusinessLicenseUrl, replacedLegalPersonIdUrl);
}
@@ -206,9 +214,9 @@ public class ProfileResourceService {
}
private void requireOptionalQualificationType(String type) {
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
if ("BUSINESS_LICENSE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST,
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传");
"The business license must be uploaded in the required package / 营业执照和统一社会信用代码必须通过必填资质包上传");
}
}
@@ -215,7 +215,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
IamTenant tenant, long membershipId) {
try {
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
new AuthAccountClient.CreateAccountRequest(authUserId, username, password,
new AuthAccountClient.CreateAccountRequest(authUserId, username, tenant.getContactPhone(), password,
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
if (response == null) throw unavailable();
if (!response.successful()) {
@@ -52,8 +52,7 @@ import java.util.stream.Collectors;
*/
@Service
public class TenantService {
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of(
"BUSINESS_LICENSE", "LEGAL_PERSON_ID");
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of("BUSINESS_LICENSE");
@Resource
private TenantRepository repository;
@Resource
@@ -456,7 +455,7 @@ public class TenantService {
var qualifications = profileRepository.findQualifications(tenantId);
if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.BAD_REQUEST,
"Business license and legal-person ID are required / 营业执照和法人身份证为必填资质");
"Business license is required / 营业执照为必填资质");
}
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
+91 -12
View File
@@ -17,6 +17,56 @@ tags:
- name: RBAC
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
paths:
/api/merchant/v1/iam/profile:
get:
tags: [Merchant Tenant / 商户企业]
summary: 查看当前登录用户个人资料
responses:
'200':
description: 查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
put:
tags: [Merchant Tenant / 商户企业]
summary: 修改当前登录用户普通资料
description: 仅修改显示名称和邮箱;用户名、手机号必须走敏感标识接口。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/UpdatePersonalProfileRequest'}
responses:
'200':
description: 修改成功
content:
application/json:
schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/profile/login-identifiers:
put:
tags: [Merchant Tenant / 商户企业]
summary: 修改当前登录用户的用户名和手机号
description: 仅允许当前非代登录用户修改本人标识,必须用当前密码重新认证;成功后全部 Token 和会话失效,需重新登录。权限为已认证客户本人,无额外权限码。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ChangeLoginIdentifiersRequest'}
responses:
'200':
description: 修改成功并已吊销会话
content:
application/json:
schema: {$ref: '#/components/schemas/PersonalProfileApiResponse'}
'400': {$ref: '#/components/responses/BadRequest'}
'401': {$ref: '#/components/responses/Unauthorized'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/platform/v1/iam/tenants/contact-phone-availability:
get:
tags: [Platform Tenant / 平台租户]
@@ -924,6 +974,35 @@ components:
ServiceUnavailable: {description: Authentication service unavailable / 认证服务不可用, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
UnprocessableEntity: {description: 参数校验失败, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
schemas:
PersonalProfileResponse:
type: object
required: [userId, authUserId, username, displayName, phone]
properties:
userId: {type: string, pattern: '^\d+$'}
authUserId: {type: string, pattern: '^\d+$'}
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, nullable: true}
UpdatePersonalProfileRequest:
type: object
required: [displayName]
properties:
displayName: {type: string, minLength: 1, maxLength: 64}
email: {type: string, format: email, maxLength: 128, nullable: true}
ChangeLoginIdentifiersRequest:
type: object
required: [username, phone, currentPassword]
properties:
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
phone: {type: string, pattern: '^1\d{10}$'}
currentPassword: {type: string, format: password, writeOnly: true}
PersonalProfileApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/PersonalProfileResponse'}
ApiResponse:
type: object
required: [code, message, timestamp]
@@ -1206,22 +1285,22 @@ components:
reviewComment: {type: string}
RequiredQualificationPackageRequest:
type: object
required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense, legalPersonIdDocumentUrl, replaceLegalPersonId]
required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense]
properties:
tenantId: {type: string, pattern: '^\d+$'}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'}
businessLicenseDocumentUrl: {type: string, minLength: 1}
businessLicenseValidTo: {type: string, format: date, nullable: true}
replaceBusinessLicense: {type: boolean}
legalPersonIdDocumentUrl: {type: string, minLength: 1}
legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填}
legalPersonIdValidTo: {type: string, format: date, nullable: true}
replaceLegalPersonId: {type: boolean}
replaceLegalPersonId: {type: boolean, nullable: true, default: false}
RequiredQualificationPackageResponse:
type: object
required: [businessLicenseQualificationId, legalPersonIdQualificationId, tenantStatus]
required: [businessLicenseQualificationId, tenantStatus]
properties:
businessLicenseQualificationId: {type: string, pattern: '^\d+$'}
legalPersonIdQualificationId: {type: string, pattern: '^\d+$'}
legalPersonIdQualificationId: {type: string, pattern: '^\d+$', nullable: true}
tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]}
replacedBusinessLicenseDocumentUrl: {type: string, nullable: true}
replacedLegalPersonIdDocumentUrl: {type: string, nullable: true}
@@ -1317,7 +1396,7 @@ components:
type: object
required: [username, displayName, phone, tenantId, roleIds, password, status]
properties:
username: {type: string, minLength: 4, maxLength: 64}
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
displayName: {type: string, maxLength: 64}
phone: {type: string, pattern: '^1\\d{10}$'}
email: {type: string, format: email, maxLength: 128}
@@ -1342,10 +1421,10 @@ components:
properties:
authUserId: {type: integer, format: int64, description: 兼容旧接口;填写后关联已有认证账号}
userType: {type: string, enum: [INTERNAL, PLATFORM], description: 兼容旧接口,可省略}
username: {type: string, minLength: 4, maxLength: 64}
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
password: {type: string, format: password, minLength: 8, maxLength: 64, writeOnly: true}
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
@@ -1354,7 +1433,7 @@ components:
required: [displayName, memberNo, roleIds]
properties:
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberNo: {type: string, maxLength: 64}
roleIds: {type: array, items: {type: integer, format: int64}}
@@ -1413,10 +1492,10 @@ components:
type: object
required: [username, password, displayName, memberType]
properties:
username: {type: string, minLength: 4, maxLength: 64}
username: {type: string, pattern: '^[A-Za-z][A-Za-z0-9_.@-]{3,63}$'}
password: {type: string, format: password, minLength: 8, maxLength: 64}
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string}
@@ -1429,7 +1508,7 @@ components:
required: [displayName, memberType]
properties:
displayName: {type: string, maxLength: 64}
phone: {type: string, maxLength: 32}
phone: {type: string, pattern: '^1\d{10}$'}
email: {type: string, format: email, maxLength: 128}
memberType: {type: string, enum: [EMPLOYEE]}
memberNo: {type: string, maxLength: 64}
@@ -161,6 +161,8 @@ class MemberServiceTest {
@Test void shouldHandleCase08() {
IamRole role = activeMerchantRole(50L, 100L);
when(roleMapper.selectById(50L)).thenReturn(role);
when(auth.phone(eq("internal-token"), eq(40L), any()))
.thenReturn(ApiResponse.success(null));
UpdateMemberRequest request = new UpdateMemberRequest("新姓名", "13800138000", null,
"EMPLOYEE", "M-001", List.of(50L));
@@ -169,6 +171,7 @@ class MemberServiceTest {
assertEquals("新姓名", result.displayName());
verify(repository).saveUser(user);
verify(repository).replaceRoles(100L, 20L, List.of(50L));
verify(auth).phone(eq("internal-token"), eq(40L), any());
}
@Test void shouldHandleCase09() {
@@ -83,6 +83,25 @@ class MerchantQualificationUploadTest {
verify(tenantRepository).saveTenant(tenant);
}
@Test
void requiredPackageAcceptsAnEmptyOptionalLegalPersonId() {
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false,
null, null, null));
assertNull(response.legalPersonIdQualificationId());
verify(profileRepository, times(1)).insertQualification(any());
verify(tenantRepository).saveTenant(tenant);
}
@Test
void requiredPackageCannotOverwriteAnEstablishedCreditCode() {
IamTenant tenant = new IamTenant();
@@ -2,6 +2,9 @@ package com.baiyue.iam.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.ProfileDtos.UpdatePersonalProfileRequest;
import com.baiyue.iam.dto.ProfileDtos.ChangeLoginIdentifiersRequest;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
@@ -10,14 +13,17 @@ import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.test.context.TestPropertySource;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@DisplayName("个人资料服务")
@SpringJUnitConfig(PersonalProfileService.class)
@TestPropertySource(properties = "baiyue.security.internal-service-token=test-internal-token")
class PersonalProfileServiceTest {
@MockBean private MemberRepository repository;
@MockBean private AuthAccountClient authClient;
@Resource private PersonalProfileService service;
@Test void canOnlyModifyOwnProfileWithinCurrentTenant() {
@@ -26,11 +32,29 @@ class PersonalProfileServiceTest {
when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
when(repository.findUser(3L)).thenReturn(user);
var result = service.update(1L, 2L, "tester",
new UpdatePersonalProfileRequest("新姓名", "13800138000", "a@example.com"));
new UpdatePersonalProfileRequest("新姓名", "a@example.com"));
assertEquals("新姓名", result.displayName());
verify(repository).saveUser(user);
}
@Test void sensitiveLoginIdentifiersAreChangedOnlyThroughAuthReauthentication() {
IamMembership membership = new IamMembership(); membership.setUserId(3L); membership.setStatus("ACTIVE");
IamUser user = new IamUser(); user.setId(3L); user.setAuthUserId(2L); user.setDisplayName("测试用户");
when(repository.findByTenantAndAuthUser(1L, 2L)).thenReturn(membership);
when(repository.findUser(3L)).thenReturn(user);
when(authClient.changeLoginIdentifiers(any(), eq(2L), any())).thenReturn(ApiResponse.success(
new AuthAccountClient.AccountSummary(2L, "new.user", "13900139000", "测试用户", "ACTIVE",
null, 1L, 4L, "MERCHANT")));
var result = service.changeLoginIdentifiers(1L, 2L,
new ChangeLoginIdentifiersRequest("new.user", "13900139000", "CurrentPass1"));
assertEquals("new.user", result.username());
assertEquals("13900139000", result.phone());
verify(repository).saveUser(user);
}
@Test void crossTenantIdentityIsRejected() {
assertThrows(BizException.class, () -> service.get(9L, 2L, "tester"));
}
@@ -369,11 +369,11 @@ class TenantServiceTest {
}
@Test
void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
void explicitSubmissionRequiresBusinessLicenseAndCreatesReview() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
qualification("BUSINESS_LICENSE")));
var submitted = service.submitMerchantReview(10001L);
@@ -383,10 +383,10 @@ class TenantServiceTest {
}
@Test
void explicitSubmissionRejectsAnIncompleteQualificationSet() {
void explicitSubmissionRejectsMissingBusinessLicense() {
when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE")));
qualification("LEGAL_PERSON_ID")));
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
@@ -480,8 +480,8 @@ class TenantServiceTest {
void shouldHandleCase01() {
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
when(profileRepository.findQualifications(10001L)).thenReturn(
List.of(approvedQualification("BUSINESS_LICENSE")),
List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"),
List.of(),
List.of(approvedQualification("BUSINESS_LICENSE"),
rejectedQualification("FREIGHT_FORWARDER_CERTIFICATE")));
var withoutQualification = service.orderEligibility(10001L);