Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into dev
This commit is contained in:
@@ -16,6 +16,8 @@ import com.baiyue.iam.service.MerchantOnboardingService;
|
||||
import com.baiyue.iam.service.ProfileResourceService;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -106,6 +108,18 @@ public class InternalTenantController {
|
||||
return ApiResponse.success(tenantService.orderEligibility(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/finance-customers")
|
||||
public ApiResponse<PageResult<FinanceCustomerSummary>> financeCustomers(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestParam(defaultValue = "1") @Min(1) long page,
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) @Pattern(regexp = "ACTIVE|DISABLED") String status,
|
||||
@RequestParam(required = false) Long customerId) {
|
||||
verify(token);
|
||||
return ApiResponse.success(tenantService.pageFinanceCustomers(page, size, keyword, status, customerId));
|
||||
}
|
||||
|
||||
@GetMapping("/exports/customers")
|
||||
public ApiResponse<CustomerExportBatch> exportCustomers(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@@ -113,7 +127,7 @@ public class InternalTenantController {
|
||||
@RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) {
|
||||
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) {
|
||||
verify(token);
|
||||
return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.CreationResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
import com.baiyue.iam.service.MemberService;
|
||||
@@ -49,7 +49,7 @@ public class PlatformTenantController {
|
||||
|
||||
@GetMapping("/tenants")
|
||||
@PreAuthorize("hasAuthority('platform:customer:list')")
|
||||
@Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持企业名称、统一社会信用代码、联系人和状态筛选。")
|
||||
@Operation(summary = "查询企业列表", description = "平台端分页查询商户企业,支持客户名称和状态筛选。客户编号、联系人等仅作展示信息,不作为搜索条件。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "企业列表查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "分页参数类型无效"),
|
||||
@@ -62,7 +62,7 @@ public class PlatformTenantController {
|
||||
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
|
||||
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
|
||||
@@ -179,7 +179,7 @@ public class PlatformTenantController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Review list returned / 查询成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域")
|
||||
})
|
||||
public ApiResponse<List<IamTenantReview>> reviews(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
public ApiResponse<List<PendingReviewResponse>> reviews(@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.pendingReviews());
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.iam.dto.RbacDtos.*;
|
||||
import com.baiyue.iam.entity.IamPermission;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.service.RbacService;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -45,7 +44,7 @@ public class RbacController {
|
||||
@PostMapping("/roles")
|
||||
@Operation(summary = "创建角色")
|
||||
@PreAuthorize("hasAuthority(#app + ':role:create')")
|
||||
public ApiResponse<IamRole> createRole(@PathVariable String app,
|
||||
public ApiResponse<RoleResponse> createRole(@PathVariable String app,
|
||||
@Valid @RequestBody CreateRoleRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
Scope scope = scope(app, user);
|
||||
|
||||
@@ -6,6 +6,7 @@ import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** 企业成员接口的数据契约。 */
|
||||
@@ -44,8 +45,9 @@ public final class MemberDtos {
|
||||
public record ResetMemberPasswordRequest(@NotBlank @Size(min = 8, max = 64)
|
||||
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$", message = "密码必须同时包含字母和数字") String newPassword) { }
|
||||
|
||||
public record MemberResponse(Long membershipId, Long userId, Long authUserId,
|
||||
public record MemberResponse(Long membershipId, Long userId, Long authUserId, String username,
|
||||
String displayName, String phone, String email,
|
||||
String memberType, String memberNo, String status, List<Long> roleIds) { }
|
||||
String memberType, String memberNo, String status, LocalDateTime lastLoginAt,
|
||||
List<Long> roleIds) { }
|
||||
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ public final class RbacDtos {
|
||||
/** 角色回显模型,permissionIds 可直接用于全量授权保存。 */
|
||||
public record RoleResponse(Long id, Long tenantId, String appCode, String roleCode,
|
||||
String roleName, String roleType, String status,
|
||||
String description, Set<Long> permissionIds) { }
|
||||
String description, long memberCount, Set<Long> permissionIds) { }
|
||||
|
||||
public record MemberRolesResponse(Long membershipId, Set<Long> roleIds) { }
|
||||
|
||||
|
||||
@@ -101,6 +101,21 @@ public final class TenantDtos {
|
||||
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
|
||||
) { }
|
||||
|
||||
@Schema(name = "PendingEnterpriseReviewResponse", description = "Pending enterprise review with the customer summary required by platform reviewers / 待审核企业及客户摘要")
|
||||
public record PendingReviewResponse(
|
||||
@Schema(description = "Review ID / 审核单 ID", example = "190000000000000001") Long id,
|
||||
@Schema(description = "Customer ID / 客户 ID", example = "190000000000000002") Long tenantId,
|
||||
@Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType,
|
||||
@Schema(description = "Review status / 审核状态", example = "PENDING") String status,
|
||||
@Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName,
|
||||
@Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail,
|
||||
@Schema(description = "Review submission time / 提交审核时间") LocalDateTime submittedAt,
|
||||
@Schema(description = "Review comment / 审核意见", nullable = true) String reviewComment
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据")
|
||||
public record ApprovalResponse(
|
||||
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
|
||||
@@ -124,4 +139,8 @@ public final class TenantDtos {
|
||||
LocalDate establishedDate, BigDecimal registeredCapital,
|
||||
String registeredCapitalCurrency, String registeredAddress,
|
||||
String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { }
|
||||
|
||||
/** Full customer contact summary for trusted internal finance read models. */
|
||||
public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status,
|
||||
String contactName, String contactPhone) { }
|
||||
}
|
||||
|
||||
+2
@@ -31,6 +31,8 @@ public class PlatformNotificationController {
|
||||
public ApiResponse<TemplateResponse> status(@PathVariable Long id,@Valid@RequestBody ChangeTemplateStatusRequest request,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.changeTemplateStatus(id,request,user.membershipId()));}
|
||||
@GetMapping("/notification-tasks") @PreAuthorize("hasAuthority('platform:notification:task:list')")
|
||||
public ApiResponse<PageResult<TaskListItem>> tasks(@RequestParam(defaultValue="1")@Min(1)long page,@RequestParam(defaultValue="20")@Min(1)@Max(200)long size,@RequestParam(required=false)String taskNo,@RequestParam(required=false)String category,@RequestParam(required=false)String title,@RequestParam(required=false)@Pattern(regexp="CREATED|SCHEDULED|DISPATCHING|RETRYING|SENT|DEAD|CANCELLED")String status,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.tasks(page,size,taskNo,category,title,status));}
|
||||
@GetMapping("/notification-tasks/{id:\\d+}") @PreAuthorize("hasAuthority('platform:notification:task:list')")
|
||||
public ApiResponse<TaskDetail> taskDetail(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.taskDetail(id));}
|
||||
@PutMapping("/notification-tasks/{id:\\d+}/cancel") @PreAuthorize("hasAuthority('platform:notification:task:cancel')") public ApiResponse<TaskListItem> cancel(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.cancel(id));}
|
||||
@PutMapping("/notification-tasks/{id:\\d+}/retry") @PreAuthorize("hasAuthority('platform:notification:task:retry')") public ApiResponse<TaskListItem> retry(@PathVariable Long id,@AuthenticationPrincipal AuthenticatedUser user){platform(user);return ApiResponse.success(service.retry(id));}
|
||||
private void platform(AuthenticatedUser user){TenantGuard.tenant(user,"PLATFORM");}
|
||||
|
||||
@@ -28,10 +28,10 @@ public final class NotificationDtos {
|
||||
@Size(max = 128) String sourceEventId,
|
||||
@JsonIgnore @Size(max = 128) String sourceEventType) { }
|
||||
|
||||
public record NotificationSummary(Long id, String category, String title, String status,
|
||||
public record NotificationSummary(Long id, String category, String title, String summary, String status,
|
||||
LocalDateTime sentAt, LocalDateTime readAt) { }
|
||||
|
||||
public record NotificationDetail(Long id, String category, String title, String body,
|
||||
public record NotificationDetail(Long id, String category, String title, String summary, String body,
|
||||
String status, String sourceType, String sourceId,
|
||||
LocalDateTime sentAt, LocalDateTime readAt) { }
|
||||
|
||||
@@ -69,4 +69,12 @@ public final class NotificationDtos {
|
||||
public record TaskListItem(Long id, String taskNo, String category, String title, String status,
|
||||
int recipientCount, LocalDateTime scheduledAt, LocalDateTime sentAt,
|
||||
boolean duplicated) { }
|
||||
|
||||
public record TaskRecipientItem(Long id, String displayName, String appCode, String status,
|
||||
String failureReason, LocalDateTime deliveredAt) { }
|
||||
|
||||
public record TaskDetail(Long id, String taskNo, String category, String title, String status,
|
||||
int recipientCount, int attemptCount, LocalDateTime scheduledAt,
|
||||
LocalDateTime sentAt, String lastError,
|
||||
List<TaskRecipientItem> recipients) { }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,35 @@ import org.apache.ibatis.annotations.Update;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import java.time.LocalDateTime;
|
||||
@Mapper public interface NotificationTaskMapper extends BaseMapper<NotificationTask> {
|
||||
@Update("""
|
||||
UPDATE iam_notification_task
|
||||
SET status = 'DISPATCHING',
|
||||
attempt_count = attempt_count + 1,
|
||||
updated_at = #{now}
|
||||
WHERE id = #{taskId}
|
||||
AND status = 'SCHEDULED'
|
||||
AND scheduled_at <= #{now}
|
||||
AND attempt_count < #{maxAttempts}
|
||||
""")
|
||||
int claimScheduled(@Param("taskId") Long taskId,
|
||||
@Param("maxAttempts") int maxAttempts,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
@Update("""
|
||||
UPDATE iam_notification_task
|
||||
SET status = 'DISPATCHING',
|
||||
attempt_count = attempt_count + 1,
|
||||
updated_at = #{now}
|
||||
WHERE id = #{taskId}
|
||||
AND status = 'RETRYING'
|
||||
AND updated_at <= #{dueBefore}
|
||||
AND attempt_count < #{maxAttempts}
|
||||
""")
|
||||
int claimRetrying(@Param("taskId") Long taskId,
|
||||
@Param("maxAttempts") int maxAttempts,
|
||||
@Param("dueBefore") LocalDateTime dueBefore,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
@Update("""
|
||||
UPDATE iam_notification_task task
|
||||
SET task.status = 'DEAD',
|
||||
@@ -27,15 +56,36 @@ import java.time.LocalDateTime;
|
||||
int deadTimedOutDispatchingWithDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore,
|
||||
@Param("now") LocalDateTime now);
|
||||
|
||||
@Update("""
|
||||
UPDATE iam_notification_task task
|
||||
SET task.status = 'DEAD',
|
||||
task.last_error = 'DISPATCH_TIMEOUT_ATTEMPT_LIMIT',
|
||||
task.updated_at = #{now}
|
||||
WHERE task.status = 'DISPATCHING'
|
||||
AND task.updated_at <= #{staleBefore}
|
||||
AND task.sent_at IS NULL
|
||||
AND task.attempt_count >= #{maxAttempts}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM iam_notification_recipient recipient
|
||||
WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM iam_notification_inbox inbox WHERE inbox.task_id = task.id
|
||||
)
|
||||
""")
|
||||
int deadTimedOutDispatchingAtAttemptLimit(@Param("staleBefore") LocalDateTime staleBefore,
|
||||
@Param("now") LocalDateTime now,
|
||||
@Param("maxAttempts") int maxAttempts);
|
||||
|
||||
@Update("""
|
||||
UPDATE iam_notification_task task
|
||||
SET task.status = 'RETRYING',
|
||||
task.attempt_count = task.attempt_count + 1,
|
||||
task.last_error = 'DISPATCH_TIMEOUT_RECOVERED',
|
||||
task.updated_at = #{now}
|
||||
WHERE task.status = 'DISPATCHING'
|
||||
AND task.updated_at <= #{staleBefore}
|
||||
AND task.sent_at IS NULL
|
||||
AND task.attempt_count < #{maxAttempts}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM iam_notification_recipient recipient
|
||||
WHERE recipient.task_id = task.id AND recipient.status = 'DELIVERED'
|
||||
@@ -45,5 +95,19 @@ import java.time.LocalDateTime;
|
||||
)
|
||||
""")
|
||||
int retryTimedOutDispatchingWithoutDeliveryEvidence(@Param("staleBefore") LocalDateTime staleBefore,
|
||||
@Param("now") LocalDateTime now);
|
||||
@Param("now") LocalDateTime now,
|
||||
@Param("maxAttempts") int maxAttempts);
|
||||
|
||||
@Update("""
|
||||
UPDATE iam_notification_task
|
||||
SET status = CASE WHEN attempt_count >= #{maxAttempts} THEN 'DEAD' ELSE 'RETRYING' END,
|
||||
last_error = #{error},
|
||||
updated_at = #{now}
|
||||
WHERE id = #{taskId}
|
||||
AND status = 'DISPATCHING'
|
||||
""")
|
||||
int failDispatch(@Param("taskId") Long taskId,
|
||||
@Param("maxAttempts") int maxAttempts,
|
||||
@Param("error") String error,
|
||||
@Param("now") LocalDateTime now);
|
||||
}
|
||||
|
||||
+35
-13
@@ -49,21 +49,43 @@ public class NotificationRepository {
|
||||
public void insertRecipient(NotificationRecipient value) { recipients.insert(value); }
|
||||
public List<NotificationRecipient> recipients(Long taskId) { return recipients.selectList(new LambdaQueryWrapper<NotificationRecipient>().eq(NotificationRecipient::getTaskId,taskId)); }
|
||||
public void saveRecipient(NotificationRecipient value) { recipients.updateById(value); }
|
||||
public List<NotificationTask> dueTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,java.time.LocalDateTime.now()).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); }
|
||||
public boolean claimScheduled(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"SCHEDULED")
|
||||
.set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; }
|
||||
public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now) {
|
||||
int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now);
|
||||
return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now);
|
||||
public List<NotificationTask> dueTasks(java.time.LocalDateTime now) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getStatus,"SCHEDULED").le(NotificationTask::getScheduledAt,now).orderByAsc(NotificationTask::getScheduledAt).last("LIMIT 100")); }
|
||||
public boolean claimScheduled(Long taskId,int maxAttempts,java.time.LocalDateTime now) {
|
||||
return tasks.claimScheduled(taskId,maxAttempts,now)==1;
|
||||
}
|
||||
public List<NotificationTask> retryingTasks() { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getStatus,"RETRYING").orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); }
|
||||
public boolean claimRetrying(Long taskId) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"RETRYING")
|
||||
.set(NotificationTask::getStatus,"DISPATCHING").set(NotificationTask::getUpdatedAt,java.time.LocalDateTime.now()))==1; }
|
||||
public int recoverTimedOutDispatching(java.time.LocalDateTime staleBefore,java.time.LocalDateTime now,int maxAttempts) {
|
||||
int dead = tasks.deadTimedOutDispatchingWithDeliveryEvidence(staleBefore,now);
|
||||
dead += tasks.deadTimedOutDispatchingAtAttemptLimit(staleBefore,now,maxAttempts);
|
||||
return dead + tasks.retryTimedOutDispatchingWithoutDeliveryEvidence(staleBefore,now,maxAttempts);
|
||||
}
|
||||
public List<NotificationTask> retryingTasks(java.time.LocalDateTime dueBefore,int maxAttempts) { return tasks.selectList(new LambdaQueryWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getStatus,"RETRYING").lt(NotificationTask::getAttemptCount,maxAttempts)
|
||||
.le(NotificationTask::getUpdatedAt,dueBefore).orderByAsc(NotificationTask::getUpdatedAt).last("LIMIT 100")); }
|
||||
public boolean claimRetrying(Long taskId,int maxAttempts,java.time.LocalDateTime dueBefore,java.time.LocalDateTime now) {
|
||||
return tasks.claimRetrying(taskId,maxAttempts,dueBefore,now)==1;
|
||||
}
|
||||
public boolean completeDispatch(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getId,taskId).eq(NotificationTask::getStatus,"DISPATCHING")
|
||||
.set(NotificationTask::getStatus,"SENT").set(NotificationTask::getSentAt,now)
|
||||
.set(NotificationTask::getLastError,null).set(NotificationTask::getUpdatedAt,now))==1; }
|
||||
public boolean failDispatch(Long taskId,int maxAttempts,String error,java.time.LocalDateTime now) {
|
||||
return tasks.failDispatch(taskId,maxAttempts,error,now)==1;
|
||||
}
|
||||
public boolean requeueForManualRetry(Long taskId,java.time.LocalDateTime now) { return tasks.update(null,new LambdaUpdateWrapper<NotificationTask>()
|
||||
.eq(NotificationTask::getId,taskId).in(NotificationTask::getStatus,"RETRYING","DEAD")
|
||||
.set(NotificationTask::getStatus,"RETRYING").set(NotificationTask::getAttemptCount,0)
|
||||
.set(NotificationTask::getLastError,null).set(NotificationTask::getSentAt,null)
|
||||
.set(NotificationTask::getUpdatedAt,now))==1; }
|
||||
public void insertInbox(NotificationInbox value) { inbox.insert(value); }
|
||||
public boolean markRecipientDelivered(Long recipientId,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper<NotificationRecipient>()
|
||||
.eq(NotificationRecipient::getId,recipientId).ne(NotificationRecipient::getStatus,"DELIVERED")
|
||||
.set(NotificationRecipient::getStatus,"DELIVERED").set(NotificationRecipient::getFailureReason,null)
|
||||
.set(NotificationRecipient::getDeliveredAt,now).set(NotificationRecipient::getUpdatedAt,now))==1; }
|
||||
public int markUndeliveredRecipientsFailed(Long taskId,String error,java.time.LocalDateTime now) { return recipients.update(null,new LambdaUpdateWrapper<NotificationRecipient>()
|
||||
.eq(NotificationRecipient::getTaskId,taskId).ne(NotificationRecipient::getStatus,"DELIVERED")
|
||||
.set(NotificationRecipient::getStatus,"FAILED").set(NotificationRecipient::getFailureReason,error)
|
||||
.set(NotificationRecipient::getUpdatedAt,now)); }
|
||||
public PageResult<NotificationInbox> pageInbox(long tenantId,long membershipId,String app,long page,long size,String status,String category) {
|
||||
return PageResult.from(inbox.selectPage(new Page<>(page,size), new LambdaQueryWrapper<NotificationInbox>()
|
||||
.eq(NotificationInbox::getTenantId,tenantId).eq(NotificationInbox::getMembershipId,membershipId)
|
||||
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.baiyue.iam.notification.service;
|
||||
|
||||
import com.baiyue.iam.notification.entity.NotificationInbox;
|
||||
import com.baiyue.iam.notification.entity.NotificationRecipient;
|
||||
import com.baiyue.iam.notification.entity.NotificationTask;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class NotificationDispatchTransactionService {
|
||||
static final int MAX_DISPATCH_ATTEMPTS = 3;
|
||||
static final long RETRY_DELAY_SECONDS = 30;
|
||||
|
||||
@Resource private NotificationRepository repository;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean claimScheduled(Long taskId) {
|
||||
return repository.claimScheduled(taskId, MAX_DISPATCH_ATTEMPTS, LocalDateTime.now());
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean claimRetrying(Long taskId) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
return repository.claimRetrying(taskId, MAX_DISPATCH_ATTEMPTS,
|
||||
now.minusSeconds(RETRY_DELAY_SECONDS), now);
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void deliverClaimed(Long taskId) {
|
||||
NotificationTask task = repository.task(taskId);
|
||||
if (task == null || !"DISPATCHING".equals(task.getStatus())) {
|
||||
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (NotificationRecipient recipient : repository.recipients(taskId)) {
|
||||
if ("DELIVERED".equals(recipient.getStatus())) continue;
|
||||
NotificationInbox item = new NotificationInbox();
|
||||
item.setId(IdWorker.getId()); item.setTaskId(taskId); item.setRecipientId(recipient.getId());
|
||||
item.setTenantId(recipient.getTenantId()); item.setMembershipId(recipient.getMembershipId());
|
||||
item.setAppCode(recipient.getAppCode()); item.setCategory(task.getCategory()); item.setTitle(task.getTitle());
|
||||
item.setBody(task.getBody()); item.setSourceType(task.getSourceEventType()); item.setSourceId(task.getSourceEventId());
|
||||
item.setStatus("UNREAD"); item.setSentAt(now); item.setCreatedAt(now); item.setUpdatedAt(now);
|
||||
repository.insertInbox(item);
|
||||
if (!repository.markRecipientDelivered(recipient.getId(), now)) {
|
||||
throw new IllegalStateException("NOTIFICATION_RECIPIENT_DELIVERY_STATE_LOST");
|
||||
}
|
||||
}
|
||||
if (!repository.completeDispatch(taskId, now)) {
|
||||
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void recordFailure(Long taskId, Throwable failure) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
String error = boundedError(failure);
|
||||
repository.markUndeliveredRecipientsFailed(taskId, error, now);
|
||||
if (!repository.failDispatch(taskId, MAX_DISPATCH_ATTEMPTS, error, now)) {
|
||||
throw new IllegalStateException("NOTIFICATION_DISPATCH_CLAIM_LOST");
|
||||
}
|
||||
}
|
||||
|
||||
private String boundedError(Throwable failure) {
|
||||
String message = failure.getMessage();
|
||||
String error = message == null || message.isBlank() ? failure.getClass().getSimpleName() : message;
|
||||
return error.length() <= 1000 ? error : error.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import java.util.Set;
|
||||
@Service
|
||||
public class NotificationService {
|
||||
private static final long EVENT_PROCESSING_TIMEOUT_MINUTES = 5;
|
||||
private static final int INBOX_SUMMARY_MAX_CODE_POINTS = 120;
|
||||
|
||||
public enum EventClaimStatus { ACQUIRED, PROCESSED, PROCESSING }
|
||||
public record EventClaim(NotificationEventInbox event, EventClaimStatus status) { }
|
||||
@@ -33,6 +34,7 @@ public class NotificationService {
|
||||
@Resource private NotificationTemplateRenderer renderer;
|
||||
@Resource private NotificationRecipientResolver resolver;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private NotificationDispatchTransactionService dispatchTransactions;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<NotificationSummary> inbox(long tenantId, long membershipId, String appCode,
|
||||
@@ -183,35 +185,44 @@ public class NotificationService {
|
||||
public int recoverTimedOutDispatching(long timeoutSeconds) {
|
||||
if (timeoutSeconds < 1) throw new IllegalArgumentException("Dispatch timeout must be positive");
|
||||
LocalDateTime now = eventNow();
|
||||
return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now);
|
||||
return repository.recoverTimedOutDispatching(now.minusSeconds(timeoutSeconds), now,
|
||||
NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Long> dueScheduledTaskIds() {
|
||||
return repository.dueTasks().stream().map(NotificationTask::getId).toList();
|
||||
return repository.dueTasks(LocalDateTime.now()).stream().map(NotificationTask::getId).toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<Long> retryingTaskIds() {
|
||||
return repository.retryingTasks().stream().map(NotificationTask::getId).toList();
|
||||
LocalDateTime dueBefore = LocalDateTime.now()
|
||||
.minusSeconds(NotificationDispatchTransactionService.RETRY_DELAY_SECONDS);
|
||||
return repository.retryingTasks(dueBefore, NotificationDispatchTransactionService.MAX_DISPATCH_ATTEMPTS)
|
||||
.stream().map(NotificationTask::getId).toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean dispatchScheduled(Long taskId) {
|
||||
if (!repository.claimScheduled(taskId)) return false;
|
||||
NotificationTask task = requiredTask(taskId);
|
||||
task.setStatus("DISPATCHING");
|
||||
deliver(task, LocalDateTime.now());
|
||||
return true;
|
||||
return dispatch(taskId, dispatchTransactions.claimScheduled(taskId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean dispatchRetrying(Long taskId) {
|
||||
if (!repository.claimRetrying(taskId)) return false;
|
||||
NotificationTask task = requiredTask(taskId);
|
||||
task.setStatus("DISPATCHING");
|
||||
deliver(task, LocalDateTime.now());
|
||||
return true;
|
||||
return dispatch(taskId, dispatchTransactions.claimRetrying(taskId));
|
||||
}
|
||||
|
||||
private boolean dispatch(Long taskId, boolean claimed) {
|
||||
if (!claimed) return false;
|
||||
try {
|
||||
dispatchTransactions.deliverClaimed(taskId);
|
||||
return true;
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
dispatchTransactions.recordFailure(taskId, failure);
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
failure.addSuppressed(persistenceFailure);
|
||||
}
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
@@ -280,6 +291,19 @@ public class NotificationService {
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public TaskDetail taskDetail(Long id) {
|
||||
NotificationTask task = requiredTask(id);
|
||||
List<TaskRecipientItem> recipients = repository.recipients(id).stream()
|
||||
.map(value -> new TaskRecipientItem(value.getId(), value.getDisplayName(), value.getAppCode(),
|
||||
value.getStatus(), value.getFailureReason(), value.getDeliveredAt()))
|
||||
.toList();
|
||||
return new TaskDetail(task.getId(), task.getTaskNo(), task.getCategory(), task.getTitle(), task.getStatus(),
|
||||
task.getRecipientCount(), task.getAttemptCount() == null ? 0 : task.getAttemptCount(),
|
||||
task.getScheduledAt(), task.getSentAt(),
|
||||
task.getLastError(), recipients);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "NOTIFICATION_TASK_CANCEL", resourceType = "NOTIFICATION_TASK",
|
||||
resourceId = "#id", description = "取消消息任务", captureAfter = false)
|
||||
@@ -298,8 +322,9 @@ public class NotificationService {
|
||||
NotificationTask task = requiredTask(id);
|
||||
if (!Set.of("RETRYING", "DEAD").contains(task.getStatus()))
|
||||
fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
|
||||
task.setStatus("RETRYING"); task.setAttemptCount(task.getAttemptCount() + 1); repository.saveTask(task);
|
||||
deliver(task, LocalDateTime.now());
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (!repository.requeueForManualRetry(id, now)) fail(ResultCode.CONFLICT, "NOTIFICATION_INVALID_STATE");
|
||||
task.setStatus("RETRYING"); task.setAttemptCount(0); task.setLastError(null); task.setUpdatedAt(now);
|
||||
return taskItem(task, false);
|
||||
}
|
||||
|
||||
@@ -319,12 +344,21 @@ public class NotificationService {
|
||||
return value;
|
||||
}
|
||||
private NotificationSummary summary(NotificationInbox value) {
|
||||
return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(), value.getStatus(),
|
||||
value.getSentAt(), value.getReadAt());
|
||||
return new NotificationSummary(value.getId(), value.getCategory(), value.getTitle(),
|
||||
boundedSummary(value.getBody()), value.getStatus(), value.getSentAt(), value.getReadAt());
|
||||
}
|
||||
|
||||
private String boundedSummary(String body) {
|
||||
if (body == null || body.isBlank()) return "";
|
||||
String compact = body.strip().replaceAll("\\s+", " ");
|
||||
int count = compact.codePointCount(0, compact.length());
|
||||
if (count <= INBOX_SUMMARY_MAX_CODE_POINTS) return compact;
|
||||
return compact.substring(0, compact.offsetByCodePoints(0, INBOX_SUMMARY_MAX_CODE_POINTS));
|
||||
}
|
||||
private NotificationDetail detail(NotificationInbox value) {
|
||||
return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(), value.getBody(),
|
||||
value.getStatus(), value.getSourceType(), value.getSourceId(), value.getSentAt(), value.getReadAt());
|
||||
return new NotificationDetail(value.getId(), value.getCategory(), value.getTitle(),
|
||||
boundedSummary(value.getBody()), value.getBody(), value.getStatus(), value.getSourceType(),
|
||||
value.getSourceId(), value.getSentAt(), value.getReadAt());
|
||||
}
|
||||
private TemplateResponse template(NotificationTemplate value) {
|
||||
return new TemplateResponse(value.getId(), value.getTemplateCode(), value.getTemplateName(),
|
||||
|
||||
+1
-8
@@ -44,14 +44,7 @@ public class CustomerListExportProviderRepository {
|
||||
.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));
|
||||
wrapper.like(IamTenant::getTenantName, keyword);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** RBAC 仓储,所有业务查询均显式携带租户和应用域。 */
|
||||
@@ -132,6 +134,23 @@ public class RbacRepository {
|
||||
.eq(IamMembershipRole::getRoleId, roleId)) > 0;
|
||||
}
|
||||
|
||||
public long countRoleMembers(long tenantId, Long roleId) {
|
||||
return membershipRoleMapper.selectCount(new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getTenantId, tenantId)
|
||||
.eq(IamMembershipRole::getRoleId, roleId));
|
||||
}
|
||||
|
||||
public Map<Long, Long> findRoleMemberCounts(long tenantId, List<Long> roleIds) {
|
||||
if (roleIds.isEmpty()) return Map.of();
|
||||
List<IamMembershipRole> assignments = membershipRoleMapper.selectList(
|
||||
new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getTenantId, tenantId)
|
||||
.in(IamMembershipRole::getRoleId, roleIds));
|
||||
Map<Long, Long> counts = new LinkedHashMap<>();
|
||||
assignments.forEach(assignment -> counts.merge(assignment.getRoleId(), 1L, Long::sum));
|
||||
return counts;
|
||||
}
|
||||
|
||||
public List<Long> findAssignedAuthUserIds(long tenantId, Long roleId) {
|
||||
return membershipRoleMapper.findAssignedAuthUserIds(tenantId, roleId);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Collection;
|
||||
|
||||
/**
|
||||
* 租户仓储,封装租户及审核单的唯一性校验、查询、创建、更新和待审列表操作。
|
||||
@@ -48,6 +49,12 @@ public class TenantRepository {
|
||||
}
|
||||
|
||||
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
|
||||
public List<IamTenant> findMerchantsByIds(Collection<Long> tenantIds) {
|
||||
if (tenantIds == null || tenantIds.isEmpty()) return List.of();
|
||||
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
|
||||
.in(IamTenant::getId, tenantIds)
|
||||
.eq(IamTenant::getTenantType, "MERCHANT"));
|
||||
}
|
||||
/**
|
||||
* Serializes the draft qualification workflow for one enterprise. The file service and the
|
||||
* merchant submit action run in separate requests, so qualification-row locks alone cannot
|
||||
@@ -91,15 +98,28 @@ public class TenantRepository {
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
|
||||
.orderByDesc(IamTenant::getCreatedAt);
|
||||
if (StringUtils.hasText(keyword)) {
|
||||
wrapper.like(IamTenant::getTenantName, keyword);
|
||||
}
|
||||
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
|
||||
}
|
||||
|
||||
public PageResult<IamTenant> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(customerId != null, IamTenant::getId, customerId)
|
||||
.in(!StringUtils.hasText(status), IamTenant::getStatus, List.of("ACTIVE", "DISABLED"))
|
||||
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
|
||||
.orderByDesc(IamTenant::getCreatedAt);
|
||||
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));
|
||||
.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 PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class CustomerListExportProviderService {
|
||||
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");
|
||||
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED", "ARCHIVED");
|
||||
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),
|
||||
@@ -205,6 +205,7 @@ public class CustomerListExportProviderService {
|
||||
case "ACTIVE" -> "启用";
|
||||
case "REJECTED" -> "已驳回";
|
||||
case "DISABLED" -> "已停用";
|
||||
case "ARCHIVED" -> "已归档";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,8 +192,9 @@ public class MemberService {
|
||||
return response(tenantId, membership, user);
|
||||
}
|
||||
private MemberResponse response(long tenantId, IamMembership m, IamUser u) {
|
||||
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), u.getDisplayName(),
|
||||
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(),
|
||||
AuthAccountClient.AccountSummary account = accountSummary(u.getAuthUserId());
|
||||
return new MemberResponse(m.getId(), u.getId(), u.getAuthUserId(), account.username(), u.getDisplayName(),
|
||||
u.getPhone(), u.getEmail(), m.getMemberType(), m.getMemberNo(), m.getStatus(), account.lastLoginAt(),
|
||||
repository.findRoleIds(tenantId, m.getId()));
|
||||
}
|
||||
private IamMembership required(long tenantId, Long membershipId) {
|
||||
|
||||
@@ -38,19 +38,24 @@ public class RbacService {
|
||||
private String internalToken;
|
||||
|
||||
public List<RoleResponse> roles(long tenantId, String appCode) {
|
||||
return repository.findRoles(tenantId, appCode).stream()
|
||||
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId())))
|
||||
List<IamRole> roles = repository.findRoles(tenantId, appCode);
|
||||
Map<Long, Long> memberCounts = repository.findRoleMemberCounts(
|
||||
tenantId, roles.stream().map(IamRole::getId).toList());
|
||||
return roles.stream()
|
||||
.map(role -> response(role, repository.findRolePermissionIds(tenantId, role.getId()),
|
||||
memberCounts.getOrDefault(role.getId(), 0L)))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public RoleResponse role(long tenantId, String appCode, Long roleId) {
|
||||
IamRole role = requireRole(tenantId, appCode, roleId);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_CREATE", resourceType = "ROLE", resourceId = "#request.roleCode",
|
||||
description = "创建角色", captureBefore = false, captureAfter = true)
|
||||
public IamRole createRole(long tenantId, String appCode, CreateRoleRequest request) {
|
||||
public RoleResponse createRole(long tenantId, String appCode, CreateRoleRequest request) {
|
||||
if (repository.existsRoleCode(tenantId, appCode, request.roleCode(), null)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
||||
}
|
||||
@@ -69,7 +74,7 @@ public class RbacService {
|
||||
// 数据库唯一键负责兜底并发创建,向调用方保持稳定的业务错误。
|
||||
throw new BizException(ResultCode.CONFLICT, "角色编码已存在");
|
||||
}
|
||||
return role;
|
||||
return response(role, Set.of(), 0L);
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_UPDATE", resourceType = "ROLE", resourceId = "#roleId",
|
||||
@@ -79,7 +84,8 @@ public class RbacService {
|
||||
role.setRoleName(request.roleName());
|
||||
role.setDescription(request.description());
|
||||
repository.updateRole(role);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ROLE_STATUS_CHANGE", resourceType = "ROLE", resourceId = "#roleId",
|
||||
@@ -91,7 +97,8 @@ public class RbacService {
|
||||
role.setStatus(request.status());
|
||||
repository.updateRole(role);
|
||||
revokeAssignedSessions(tenantId, roleId);
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId));
|
||||
return response(role, repository.findRolePermissionIds(tenantId, roleId),
|
||||
repository.countRoleMembers(tenantId, roleId));
|
||||
}
|
||||
|
||||
/** 删除策略:系统角色禁止删除;已分配给成员的角色拒绝删除;其余角色清理授权关系后逻辑删除。 */
|
||||
@@ -324,10 +331,10 @@ public class RbacService {
|
||||
return membership;
|
||||
}
|
||||
|
||||
private RoleResponse response(IamRole role, Set<Long> permissionIds) {
|
||||
private RoleResponse response(IamRole role, Set<Long> permissionIds, long memberCount) {
|
||||
return new RoleResponse(role.getId(), role.getTenantId(), role.getAppCode(), role.getRoleCode(),
|
||||
role.getRoleName(), role.getRoleType(), role.getStatus(), role.getDescription(),
|
||||
Set.copyOf(permissionIds));
|
||||
memberCount, Set.copyOf(permissionIds));
|
||||
}
|
||||
|
||||
private BizException forbidden() {
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
|
||||
|
||||
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.FinanceCustomerSummary;
|
||||
import com.baiyue.iam.dto.TenantDtos.PendingReviewResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
@@ -39,7 +41,10 @@ import feign.FeignException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
|
||||
@@ -94,7 +99,7 @@ public class TenantService {
|
||||
captureBefore = false, captureAfter = false)
|
||||
public TenantResponse archiveMerchant(long tenantId) {
|
||||
IamTenant tenant = requireMerchantForUpdate(tenantId);
|
||||
if ("DISABLED".equals(tenant.getStatus())) return response(tenant);
|
||||
if ("ARCHIVED".equals(tenant.getStatus())) return response(tenant);
|
||||
if (!Set.of("PROFILE_REQUIRED", "REJECTED").contains(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "当前客户状态不允许归档");
|
||||
}
|
||||
@@ -107,7 +112,7 @@ public class TenantService {
|
||||
} catch (FeignException ex) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用");
|
||||
}
|
||||
tenant.setStatus("DISABLED");
|
||||
tenant.setStatus("ARCHIVED");
|
||||
repository.saveTenant(tenant);
|
||||
return response(tenant);
|
||||
}
|
||||
@@ -190,8 +195,17 @@ public class TenantService {
|
||||
return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword());
|
||||
}
|
||||
|
||||
public List<IamTenantReview> pendingReviews() {
|
||||
return repository.findPendingReviews();
|
||||
public List<PendingReviewResponse> pendingReviews() {
|
||||
List<IamTenantReview> reviews = repository.findPendingReviews();
|
||||
if (reviews.isEmpty()) return List.of();
|
||||
Map<Long, IamTenant> tenants = repository.findMerchantsByIds(reviews.stream()
|
||||
.map(IamTenantReview::getTenantId)
|
||||
.collect(Collectors.toSet()))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(IamTenant::getId, Function.identity()));
|
||||
return reviews.stream()
|
||||
.map(review -> pendingReviewResponse(review, tenants.get(review.getTenantId())))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public PageResult<TenantResponse> pageMerchants(long page, long size, String keyword, String status) {
|
||||
@@ -200,6 +214,14 @@ public class TenantService {
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public PageResult<FinanceCustomerSummary> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
|
||||
PageResult<IamTenant> source = repository.pageFinanceCustomers(page, size, keyword, status, customerId);
|
||||
return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
tenant.getContactName(), tenant.getContactPhone())).toList(),
|
||||
source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) {
|
||||
int boundedLimit = Math.min(Math.max(limit, 1), 500);
|
||||
List<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
|
||||
@@ -343,6 +365,7 @@ public class TenantService {
|
||||
description = "平台编辑企业客户档案")
|
||||
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
ensureNotArchived(tenant);
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
String existingContactPhone = normalizeContactPhone(tenant.getContactPhone());
|
||||
if (!contactPhone.equals(existingContactPhone)) {
|
||||
@@ -423,6 +446,7 @@ public class TenantService {
|
||||
resourceId = "#tenantId", description = "编辑企业资料")
|
||||
public TenantResponse update(long tenantId, UpdateProfileRequest request) {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
ensureNotArchived(tenant);
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
@@ -437,6 +461,12 @@ public class TenantService {
|
||||
return tenant.getId() != null && tenant.getId().longValue() == id;
|
||||
}
|
||||
|
||||
private void ensureNotArchived(IamTenant tenant) {
|
||||
if ("ARCHIVED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Archived customer is read-only / 已归档客户仅可查看");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeContactPhone(String contactPhone) {
|
||||
return contactPhone == null ? "" : contactPhone.trim();
|
||||
}
|
||||
@@ -483,6 +513,17 @@ public class TenantService {
|
||||
);
|
||||
}
|
||||
|
||||
private PendingReviewResponse pendingReviewResponse(IamTenantReview review, IamTenant tenant) {
|
||||
return new PendingReviewResponse(
|
||||
review.getId(), review.getTenantId(), review.getReviewType(), review.getStatus(),
|
||||
tenant == null ? null : tenant.getTenantName(),
|
||||
tenant == null ? null : tenant.getTenantCode(),
|
||||
tenant == null ? null : tenant.getContactName(),
|
||||
tenant == null ? null : tenant.getContactPhone(),
|
||||
tenant == null ? null : tenant.getContactEmail(),
|
||||
review.getCreatedAt(), review.getReviewComment());
|
||||
}
|
||||
|
||||
private TenantResponse summaryResponse(IamTenant tenant) {
|
||||
TenantResponse response = response(tenant);
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
|
||||
|
||||
@@ -21,7 +21,15 @@ VALUES
|
||||
(5105041,0,'PLATFORM','MENU',5105000,'platform:finance:customer-arap:list','客户应收应付','/finance/customer-arap','GET',20,'ACTIVE',1,0),
|
||||
(5105042,0,'PLATFORM','MENU',5105000,'platform:finance:customer-statement:list','客户对账单','/finance/customer-statements','GET',30,'ACTIVE',1,0),
|
||||
(5105043,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ar:list','结算单应收','/finance/settlement-receivables','GET',40,'ACTIVE',1,0),
|
||||
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0)
|
||||
(5105044,0,'PLATFORM','MENU',5105000,'platform:finance:settlement-ap:list','结算单应付','/finance/settlement-payables','GET',50,'ACTIVE',1,0),
|
||||
(5105045,0,'PLATFORM','MENU',5105000,'platform:finance:receivable-statistics:list','应收款统计表','/finance/receivable-statistics','GET',25,'ACTIVE',1,0),
|
||||
(5105050,0,'PLATFORM','BUTTON',5105040,'platform:finance:customer-account:export','导出客户账户',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5105051,0,'PLATFORM','BUTTON',5105041,'platform:finance:customer-arap:export','导出客户应收应付',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5105052,0,'PLATFORM','BUTTON',5105042,'platform:finance:customer-statement:export','导出客户对账单',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0),
|
||||
(5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type=VALUES(permission_type), parent_id=VALUES(parent_id),
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
@@ -54,7 +62,12 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3)
|
||||
AND p.permission_code IN (
|
||||
'platform:finance:customer-account:list','platform:finance:customer-arap:list',
|
||||
'platform:finance:customer-statement:list','platform:finance:settlement-ar:list',
|
||||
'platform:finance:settlement-ap:list','platform:firstleg:inbound:measure:view',
|
||||
'platform:finance:settlement-ap:list','platform:finance:customer-account:export',
|
||||
'platform:finance:customer-arap:export','platform:finance:customer-statement:export',
|
||||
'platform:finance:settlement-ar:export','platform:finance:settlement-ap:export',
|
||||
'platform:finance:receivable-statistics:list','platform:finance:receivable-statistics:export',
|
||||
'platform:download:list',
|
||||
'platform:firstleg:inbound:measure:view',
|
||||
'firstleg:inbound:measurement-review','platform:supplier:list',
|
||||
'platform:supplier:create','platform:supplier:edit','platform:supplier:status',
|
||||
'platform:domestic-container:list','platform:domestic-container:inbound:create',
|
||||
@@ -74,7 +87,7 @@ UPDATE iam_permission p
|
||||
|
||||
UPDATE iam_permission p
|
||||
JOIN (SELECT 'platform:finance:fee-source:review' code, 5105041 parent_id UNION ALL
|
||||
SELECT 'platform:finance:fee-source:settlement', 5105041 UNION ALL
|
||||
SELECT 'platform:finance:fee-source:edit', 5105041 UNION ALL
|
||||
SELECT 'platform:finance:reconciliation:create', 5105042 UNION ALL
|
||||
SELECT 'platform:finance:reconciliation:review', 5105042 UNION ALL
|
||||
SELECT 'platform:finance:reconciliation:issue', 5105042 UNION ALL
|
||||
@@ -86,3 +99,10 @@ UPDATE iam_permission p
|
||||
ON mapping.code=p.permission_code
|
||||
SET p.parent_id=mapping.parent_id
|
||||
WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON';
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
|
||||
SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3)
|
||||
FROM iam_role r
|
||||
JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT'
|
||||
WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN')
|
||||
AND p.permission_code='merchant:finance:statement:confirm';
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
USE baiyue_iam;
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DELETE rp FROM iam_role_permission rp
|
||||
JOIN iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id
|
||||
WHERE p.tenant_id=0 AND p.app_code='PLATFORM'
|
||||
AND p.permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
|
||||
DELETE FROM iam_permission
|
||||
WHERE tenant_id=0 AND app_code='PLATFORM'
|
||||
AND permission_code IN ('platform:finance:fee-source:settlement','platform:finance:bill:create','platform:finance:bill:issue');
|
||||
|
||||
DROP PROCEDURE IF EXISTS migrate_finance_g1_g2_permissions;
|
||||
DELIMITER $$
|
||||
CREATE PROCEDURE migrate_finance_g1_g2_permissions()
|
||||
@@ -12,10 +20,10 @@ BEGIN
|
||||
FROM iam_permission
|
||||
WHERE (id = 5105000 AND permission_code <> 'platform:finance')
|
||||
OR (id = 5105024 AND permission_code <> 'platform:finance:fee-source:review')
|
||||
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:settlement')
|
||||
OR (id = 5105025 AND permission_code <> 'platform:finance:fee-source:edit')
|
||||
OR (permission_code = 'platform:finance' AND id <> 5105000)
|
||||
OR (permission_code = 'platform:finance:fee-source:review' AND id <> 5105024)
|
||||
OR (permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025)
|
||||
OR (permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025)
|
||||
OR (id IN (5105000, 5105024, 5105025)
|
||||
AND (tenant_id <> 0 OR app_code <> 'PLATFORM'))
|
||||
) THEN
|
||||
@@ -31,8 +39,8 @@ BEGIN
|
||||
'财务管理', '/finance', NULL, 60, 'ACTIVE', 1, 0),
|
||||
(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review',
|
||||
'审核财务费用来源', NULL, 'POST', 150, 'ACTIVE', 1, 0),
|
||||
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement',
|
||||
'设置费用来源参与结算', NULL, 'PUT', 160, 'ACTIVE', 1, 0)
|
||||
(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit',
|
||||
'编辑财务费用来源', NULL, 'PUT', 160, 'ACTIVE', 1, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
|
||||
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
|
||||
@@ -55,7 +63,7 @@ BEGIN
|
||||
AND deleted = 0
|
||||
AND permission_code IN (
|
||||
'platform:finance:fee-source:review',
|
||||
'platform:finance:fee-source:settlement')) <> 2 THEN
|
||||
'platform:finance:fee-source:edit')) <> 2 THEN
|
||||
SIGNAL SQLSTATE '45000'
|
||||
SET MESSAGE_TEXT = 'finance G1/G2 permission catalog is incomplete';
|
||||
END IF;
|
||||
|
||||
@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
|
||||
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档',
|
||||
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
|
||||
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
|
||||
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
|
||||
|
||||
@@ -38,6 +38,22 @@ ON DUPLICATE KEY UPDATE
|
||||
contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)),
|
||||
approved_by = VALUES(approved_by), deleted = 0;
|
||||
|
||||
-- The built-in IT customer must be immediately usable for order acceptance tests.
|
||||
-- These approved metadata rows intentionally have no document URL: initialization must not
|
||||
-- point at qualification files that do not exist in File service storage.
|
||||
INSERT INTO iam_tenant_qualification (
|
||||
id, tenant_id, qualification_type, certificate_no, document_url,
|
||||
valid_from, valid_to, status, review_comment, deleted
|
||||
) VALUES
|
||||
(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,
|
||||
NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0),
|
||||
(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,
|
||||
NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type),
|
||||
certificate_no = VALUES(certificate_no), status = 'APPROVED',
|
||||
review_comment = VALUES(review_comment), deleted = 0;
|
||||
|
||||
INSERT INTO iam_user (
|
||||
id, auth_user_id, user_type, display_name, phone, email, status, deleted, version
|
||||
) VALUES
|
||||
|
||||
@@ -44,8 +44,8 @@ paths:
|
||||
parameters:
|
||||
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
- {name: keyword, in: query, required: false, schema: {type: string}, description: 企业编码、名称、信用代码或联系人关键字}
|
||||
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
|
||||
- {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索}
|
||||
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}}
|
||||
responses:
|
||||
'200':
|
||||
description: 企业客户分页查询成功
|
||||
@@ -259,7 +259,11 @@ paths:
|
||||
summary: List pending tenant reviews / 查询待审核企业
|
||||
description: Return enterprise registrations waiting for a platform decision. / 返回等待平台处理的企业注册审核记录。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 待审核企业及客户摘要查询成功
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PendingEnterpriseReviewListApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/platform/v1/iam/reviews/{id}/approve:
|
||||
post:
|
||||
@@ -498,7 +502,11 @@ paths:
|
||||
tags: [Merchant Tenant / 商户企业]
|
||||
summary: List enterprise members / 查询企业成员
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 企业成员列表,包含真实登录账号与最后登录时间
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberListApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
post:
|
||||
tags: [Merchant Tenant / 商户企业]
|
||||
@@ -510,7 +518,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/CreateMemberRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 新增后的企业成员
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -526,7 +538,11 @@ paths:
|
||||
summary: 查询成员详情及当前角色
|
||||
description: 返回当前客户成员资料,roleIds 为服务端实时查询的当前角色集合;跨客户成员按不存在处理。
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 企业成员详情
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
put:
|
||||
@@ -539,7 +555,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/UpdateMemberRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 更新后的企业成员
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/MerchantMemberApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -548,7 +568,11 @@ paths:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: List tenant addresses / 查询企业地址
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 角色列表及各角色实时成员数
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RoleListApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
post:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
@@ -559,7 +583,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/AddressRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 新增后的角色及实时成员数
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RoleApiResponse'}
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/merchant/v1/iam/tenant/qualifications:
|
||||
@@ -740,6 +768,39 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/internal/iam/finance-customers:
|
||||
get:
|
||||
tags: [IAM Resources / IAM 资源]
|
||||
summary: 提供财务客户主数据摘要
|
||||
description: 仅供 Finance Service 调用;只返回正常/停用客户及财务账户所需的客户编号、名称和联系方式,不依赖财务表是否已有记录。
|
||||
security: []
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/InternalServiceToken'}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
- {name: keyword, in: query, required: false, schema: {type: string}}
|
||||
- {name: status, in: query, required: false, schema: {type: string, enum: [ACTIVE, DISABLED]}}
|
||||
- {name: customerId, in: query, required: false, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses:
|
||||
'200':
|
||||
description: 财务客户主数据分页摘要
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/FinanceCustomerSummary'}}
|
||||
page: {type: integer}
|
||||
size: {type: integer}
|
||||
total: {type: integer, format: int64}
|
||||
pages: {type: integer}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/internal/exports/v1/CUSTOMER_LIST/batches:
|
||||
post:
|
||||
tags: [Internal Export Provider]
|
||||
@@ -850,6 +911,60 @@ components:
|
||||
type: array
|
||||
uniqueItems: true
|
||||
items: {type: string}
|
||||
MerchantMemberResponse:
|
||||
type: object
|
||||
required: [membershipId, userId, authUserId, username, displayName, memberType, status, lastLoginAt, roleIds]
|
||||
properties:
|
||||
membershipId: {type: string, pattern: '^\d+$'}
|
||||
userId: {type: string, pattern: '^\d+$'}
|
||||
authUserId: {type: string, pattern: '^\d+$'}
|
||||
username: {type: string, description: 统一认证登录账号}
|
||||
displayName: {type: string}
|
||||
phone: {type: string, nullable: true}
|
||||
email: {type: string, format: email, nullable: true}
|
||||
memberType: {type: string, enum: [OWNER, EMPLOYEE]}
|
||||
memberNo: {type: string, nullable: true, description: 企业内部成员编号,不作为登录账号}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
lastLoginAt: {type: string, format: date-time, nullable: true, description: 从 Auth 账号事实返回;null 表示从未登录}
|
||||
roleIds: {type: array, items: {type: string, pattern: '^\d+$'}}
|
||||
MerchantMemberListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/MerchantMemberResponse'}}
|
||||
MerchantMemberApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/MerchantMemberResponse'}
|
||||
RoleResponse:
|
||||
type: object
|
||||
required: [id, tenantId, appCode, roleCode, roleName, roleType, status, memberCount, permissionIds]
|
||||
properties:
|
||||
id: {type: string, pattern: '^\d+$'}
|
||||
tenantId: {type: string, pattern: '^\d+$'}
|
||||
appCode: {type: string, enum: [PLATFORM, MERCHANT]}
|
||||
roleCode: {type: string}
|
||||
roleName: {type: string}
|
||||
roleType: {type: string, enum: [SYSTEM, CUSTOM]}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
description: {type: string, nullable: true}
|
||||
memberCount: {type: integer, format: int64, minimum: 0, description: 当前客户及应用域内分配该角色的成员数}
|
||||
permissionIds: {type: array, uniqueItems: true, items: {type: string, pattern: '^\d+$'}}
|
||||
RoleListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/RoleResponse'}}
|
||||
RoleApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/RoleResponse'}
|
||||
TenantPageApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
@@ -870,6 +985,29 @@ components:
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
|
||||
PendingEnterpriseReviewListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: {$ref: '#/components/schemas/PendingEnterpriseReviewResponse'}
|
||||
PendingEnterpriseReviewResponse:
|
||||
type: object
|
||||
required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]
|
||||
properties:
|
||||
id: {type: string, pattern: '^\d+$', description: 审核单 ID}
|
||||
tenantId: {type: string, pattern: '^\d+$', description: 客户 ID}
|
||||
reviewType: {type: string, example: REGISTER}
|
||||
status: {type: string, enum: [PENDING]}
|
||||
tenantName: {type: string, description: 客户名称}
|
||||
tenantCode: {type: string, description: 客户编号}
|
||||
contactName: {type: string, description: 联系人}
|
||||
contactPhone: {type: string, nullable: true, description: 联系电话}
|
||||
contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱}
|
||||
submittedAt: {type: string, format: date-time, description: 提交审核时间}
|
||||
reviewComment: {type: string, nullable: true, description: 审核意见}
|
||||
TenantCreationApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
@@ -952,7 +1090,7 @@ components:
|
||||
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
|
||||
tenantCode: {type: string}
|
||||
tenantName: {type: string}
|
||||
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
|
||||
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
unifiedSocialCreditCode: {type: string}
|
||||
@@ -971,6 +1109,16 @@ components:
|
||||
internalRemark: {type: string, maxLength: 1000, description: 仅平台端可见}
|
||||
createdAt: {type: string, format: date-time, description: 入驻时间}
|
||||
approvedAt: {type: string, format: date-time, description: 审批通过时间}
|
||||
FinanceCustomerSummary:
|
||||
type: object
|
||||
required: [id, tenantCode, tenantName, status]
|
||||
properties:
|
||||
id: {type: string, pattern: '^\d+$'}
|
||||
tenantCode: {type: string}
|
||||
tenantName: {type: string}
|
||||
status: {type: string, enum: [ACTIVE, DISABLED]}
|
||||
contactName: {type: string, nullable: true}
|
||||
contactPhone: {type: string, nullable: true}
|
||||
TenantAddressListApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
|
||||
@@ -50,7 +50,7 @@ paths:
|
||||
tags: [Platform notification administration]
|
||||
summary: Create an IN_APP notification task
|
||||
requestBody: {$ref: '#/components/requestBodies/CreateNotification'}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
|
||||
/platform/v1/notification-templates:
|
||||
get:
|
||||
tags: [Platform notification administration]
|
||||
@@ -95,18 +95,26 @@ paths:
|
||||
- {name: title, in: query, description: Fuzzy match rendered title, schema: {type: string, maxLength: 128}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskPage'}}
|
||||
/platform/v1/notification-tasks/{id}:
|
||||
get:
|
||||
tags: [Platform notification administration]
|
||||
summary: Get notification task delivery details
|
||||
description: Uses the task list permission and exposes bounded operational evidence without message body content.
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
/platform/v1/notification-tasks/{id}/cancel:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Cancel a CREATED or SCHEDULED task
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
|
||||
/platform/v1/notification-tasks/{id}/retry:
|
||||
put:
|
||||
tags: [Platform notification administration]
|
||||
summary: Retry a RETRYING or DEAD task
|
||||
summary: Requeue a RETRYING or DEAD task
|
||||
description: Resets the current delivery-attempt counter and returns the task in RETRYING; scheduler delivery starts after the retry delay.
|
||||
parameters: [{$ref: '#/components/parameters/IdPath'}]
|
||||
responses: {'200': {$ref: '#/components/responses/TaskDetail'}}
|
||||
responses: {'200': {$ref: '#/components/responses/TaskItem'}}
|
||||
components:
|
||||
parameters:
|
||||
AppPath: {name: app, in: path, required: true, schema: {type: string, enum: [platform, merchant]}}
|
||||
@@ -126,6 +134,7 @@ components:
|
||||
TemplatePage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplatePageResponse'}}}}
|
||||
TemplateDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TemplateDetailResponse'}}}}
|
||||
TaskPage: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskPageResponse'}}}}
|
||||
TaskItem: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskItemResponse'}}}}
|
||||
TaskDetail: {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/TaskDetailResponse'}}}}
|
||||
schemas:
|
||||
CreateNotificationRequest:
|
||||
@@ -159,10 +168,12 @@ components:
|
||||
type: object
|
||||
required: [targetStatus, version]
|
||||
properties: {targetStatus: {type: string, enum: [ACTIVE, DISABLED], description: ACTIVE is accepted only from DRAFT; DISABLED only from ACTIVE.}, version: {type: integer, minimum: 1}}
|
||||
InboxItem: {type: object, required: [id, category, title, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}}
|
||||
InboxItem: {type: object, required: [id, category, title, summary, status, sentAt], properties: {id: {type: integer, format: int64}, category: {type: string}, title: {type: string}, summary: {type: string, maxLength: 120, description: Whitespace-compacted body preview limited to 120 Unicode code points; never contains the full body unless the body itself fits.}, status: {type: string, enum: [UNREAD, READ]}, sentAt: {type: string, format: date-time}, readAt: {type: string, format: date-time, nullable: true}}}
|
||||
InboxDetail: {allOf: [{$ref: '#/components/schemas/InboxItem'}, {type: object, required: [body], properties: {body: {type: string}, sourceType: {type: string, nullable: true}, sourceId: {type: string, nullable: true}}}]}
|
||||
TemplateItem: {type: object, required: [id, templateCode, templateName, category, channel, titleTemplate, bodyTemplate, variableWhitelist, status, version], properties: {id: {type: integer, format: int64}, templateCode: {type: string}, templateName: {type: string}, category: {type: string}, channel: {type: string, enum: [IN_APP]}, titleTemplate: {type: string}, bodyTemplate: {type: string}, variableWhitelist: {type: array, items: {type: string}}, status: {type: string, enum: [DRAFT, ACTIVE, DISABLED]}, version: {type: integer}}}
|
||||
TaskItem: {type: object, required: [id, taskNo, category, title, status, recipientCount, duplicated], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, duplicated: {type: boolean}}}
|
||||
TaskRecipientItem: {type: object, required: [id, displayName, appCode, status], properties: {id: {type: integer, format: int64}, displayName: {type: string}, appCode: {type: string}, status: {type: string}, failureReason: {type: string, nullable: true}, deliveredAt: {type: string, format: date-time, nullable: true}}}
|
||||
TaskDetail: {type: object, required: [id, taskNo, category, title, status, recipientCount, attemptCount, recipients], properties: {id: {type: integer, format: int64}, taskNo: {type: string}, category: {type: string}, title: {type: string}, status: {type: string, enum: [CREATED, SCHEDULED, DISPATCHING, RETRYING, SENT, DEAD, CANCELLED]}, recipientCount: {type: integer}, attemptCount: {type: integer, minimum: 0, maximum: 3, description: Number of started delivery attempts in the current automatic retry cycle.}, scheduledAt: {type: string, format: date-time, nullable: true}, sentAt: {type: string, format: date-time, nullable: true}, lastError: {type: string, nullable: true}, recipients: {type: array, items: {$ref: '#/components/schemas/TaskRecipientItem'}}}}
|
||||
InboxPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/InboxItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
|
||||
TemplatePageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TemplateItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
|
||||
TaskPageData: {type: object, required: [records, page, size, total, pages], properties: {records: {type: array, items: {$ref: '#/components/schemas/TaskItem'}}, page: {type: integer}, size: {type: integer}, total: {type: integer}, pages: {type: integer}}}
|
||||
@@ -173,4 +184,5 @@ components:
|
||||
TemplatePageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplatePageData'}}}
|
||||
TemplateDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TemplateItem'}}}
|
||||
TaskPageResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskPageData'}}}
|
||||
TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}}
|
||||
TaskItemResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskItem'}}}
|
||||
TaskDetailResponse: {type: object, required: [code, data], properties: {code: {type: string, example: '200'}, data: {$ref: '#/components/schemas/TaskDetail'}}}
|
||||
|
||||
@@ -23,6 +23,10 @@ class IamOpenApiContractTest {
|
||||
.contains("PermissionCodesApiResponse:")
|
||||
.contains("TenantPageApiResponse:")
|
||||
.contains("TenantApiResponse:")
|
||||
.contains("PendingEnterpriseReviewListApiResponse:")
|
||||
.contains("PendingEnterpriseReviewResponse:")
|
||||
.contains("required: [id, tenantId, reviewType, status, tenantName, tenantCode, contactName, submittedAt]")
|
||||
.contains("submittedAt: {type: string, format: date-time, description: 提交审核时间}")
|
||||
.contains("TenantSuccess:")
|
||||
.contains("salesOwnerName:")
|
||||
.contains("paymentTermDays:")
|
||||
@@ -36,6 +40,11 @@ class IamOpenApiContractTest {
|
||||
.contains("TenantAddressListApiResponse:")
|
||||
.contains("TenantQualificationListApiResponse:")
|
||||
.contains("InternalUserListApiResponse:")
|
||||
.contains("MerchantMemberResponse:")
|
||||
.contains("username: {type: string, description: 统一认证登录账号}")
|
||||
.contains("lastLoginAt: {type: string, format: date-time, nullable: true")
|
||||
.contains("RoleResponse:")
|
||||
.contains("memberCount: {type: integer, format: int64, minimum: 0")
|
||||
.contains("/api/platform/v1/iam/internal-users/{membershipId}/roles:")
|
||||
.contains("AssignInternalUserRolesRequest:")
|
||||
.contains("/api/merchant/v1/iam/members/{membershipId}:")
|
||||
|
||||
@@ -40,4 +40,18 @@ class InternalCustomerExportContractTest {
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesFinanceCustomerMasterDataOnlyWithInternalToken() {
|
||||
BizException denied = assertThrows(BizException.class,
|
||||
() -> controller.financeCustomers(null, 1, 20, "Alice", "ACTIVE", null));
|
||||
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
|
||||
|
||||
when(service.pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null))
|
||||
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
var response = controller.financeCustomers("test-internal-service-token-at-least-32-bytes", 1, 20,
|
||||
"Alice", "ACTIVE", null);
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
verify(service).pageFinanceCustomers(1, 20, "Alice", "ACTIVE", null);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
|
||||
assertNotNull(annotation(method, 0, Min.class));
|
||||
assertNotNull(annotation(method, 1, Min.class));
|
||||
assertEquals(200, annotation(method, 1, Max.class).value());
|
||||
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
|
||||
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
|
||||
annotation(method, 3, Pattern.class).regexp());
|
||||
|
||||
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
|
||||
@@ -46,6 +46,11 @@ class PlatformTenantControllerContractTest {
|
||||
|
||||
assertEquals("hasAuthority('platform:customer:review')",
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
ParameterizedType apiResponse = (ParameterizedType) method.getGenericReturnType();
|
||||
ParameterizedType list = (ParameterizedType) apiResponse.getActualTypeArguments()[0];
|
||||
assertEquals(java.util.List.class, list.getRawType());
|
||||
assertEquals(com.baiyue.iam.dto.TenantDtos.PendingReviewResponse.class,
|
||||
list.getActualTypeArguments()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
import com.baiyue.iam.notification.entity.NotificationRecipient;
|
||||
import com.baiyue.iam.notification.entity.NotificationTask;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationDispatchTransactionServiceTest {
|
||||
private final NotificationRepository repository = mock(NotificationRepository.class);
|
||||
private final NotificationDispatchTransactionService service = new NotificationDispatchTransactionService();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
}
|
||||
|
||||
@Test void claimDeliveryAndFailureEvidenceUseIndependentTransactions() throws Exception {
|
||||
for (var method : List.of(
|
||||
NotificationDispatchTransactionService.class.getMethod("claimScheduled", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("claimRetrying", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("deliverClaimed", Long.class),
|
||||
NotificationDispatchTransactionService.class.getMethod("recordFailure", Long.class, Throwable.class))) {
|
||||
assertThat(method.getAnnotation(Transactional.class)).isNotNull()
|
||||
.extracting(Transactional::propagation)
|
||||
.isEqualTo(Propagation.REQUIRES_NEW);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void claimStartsOneOfThreeAllowedAttempts() {
|
||||
when(repository.claimScheduled(eq(41L), eq(3), any())).thenReturn(true);
|
||||
|
||||
assertThat(service.claimScheduled(41L)).isTrue();
|
||||
|
||||
verify(repository).claimScheduled(eq(41L), eq(3), any());
|
||||
}
|
||||
|
||||
@Test void retryClaimEnforcesDelayAndSameAttemptLimit() {
|
||||
when(repository.claimRetrying(eq(41L), eq(3), any(), any())).thenReturn(true);
|
||||
|
||||
assertThat(service.claimRetrying(41L)).isTrue();
|
||||
|
||||
ArgumentCaptor<LocalDateTime> dueBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(repository).claimRetrying(eq(41L), eq(3), dueBefore.capture(), now.capture());
|
||||
assertThat(dueBefore.getValue()).isEqualTo(now.getValue().minusSeconds(30));
|
||||
}
|
||||
|
||||
@Test void successfulDeliveryWritesInboxThenCompletesTaskAndClearsRecipientFailure() {
|
||||
NotificationTask task = task("DISPATCHING");
|
||||
NotificationRecipient recipient = recipient("FAILED");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
when(repository.markRecipientDelivered(eq(51L), any())).thenReturn(true);
|
||||
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
|
||||
|
||||
service.deliverClaimed(41L);
|
||||
|
||||
verify(repository).insertInbox(argThat(item -> item.getTaskId().equals(41L)
|
||||
&& item.getRecipientId().equals(51L) && "UNREAD".equals(item.getStatus())));
|
||||
var ordered = inOrder(repository);
|
||||
ordered.verify(repository).markRecipientDelivered(eq(51L), any());
|
||||
ordered.verify(repository).completeDispatch(eq(41L), any());
|
||||
}
|
||||
|
||||
@Test void alreadyDeliveredRecipientIsNotInsertedAgain() {
|
||||
NotificationTask task = task("DISPATCHING");
|
||||
NotificationRecipient recipient = recipient("DELIVERED");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
when(repository.completeDispatch(eq(41L), any())).thenReturn(true);
|
||||
|
||||
service.deliverClaimed(41L);
|
||||
|
||||
verify(repository, never()).insertInbox(any());
|
||||
verify(repository, never()).markRecipientDelivered(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test void failurePersistsBoundedEvidenceAndUsesAttemptLimit() {
|
||||
when(repository.failDispatch(eq(41L), eq(3), anyString(), any())).thenReturn(true);
|
||||
|
||||
service.recordFailure(41L, new IllegalStateException("x".repeat(1200)));
|
||||
|
||||
ArgumentCaptor<String> error = ArgumentCaptor.forClass(String.class);
|
||||
verify(repository).markUndeliveredRecipientsFailed(eq(41L), error.capture(), any());
|
||||
assertThat(error.getValue()).hasSize(1000);
|
||||
verify(repository).failDispatch(eq(41L), eq(3), eq(error.getValue()), any());
|
||||
}
|
||||
|
||||
@Test void rejectsDeliveryWithoutCurrentClaim() {
|
||||
when(repository.task(41L)).thenReturn(task("RETRYING"));
|
||||
|
||||
assertThatThrownBy(() -> service.deliverClaimed(41L))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("NOTIFICATION_DISPATCH_CLAIM_LOST");
|
||||
|
||||
verify(repository, never()).insertInbox(any());
|
||||
}
|
||||
|
||||
private NotificationTask task(String status) {
|
||||
NotificationTask task = new NotificationTask();
|
||||
task.setId(41L); task.setStatus(status); task.setCategory("ORDER"); task.setTitle("状态变化");
|
||||
task.setBody("正文"); task.setSourceEventId("event-1"); task.setSourceEventType("order.status.changed");
|
||||
return task;
|
||||
}
|
||||
|
||||
private NotificationRecipient recipient(String status) {
|
||||
NotificationRecipient recipient = new NotificationRecipient();
|
||||
recipient.setId(51L); recipient.setTaskId(41L); recipient.setTenantId(9L);
|
||||
recipient.setMembershipId(11L); recipient.setAppCode("MERCHANT"); recipient.setStatus(status);
|
||||
recipient.setFailureReason("old error"); recipient.setUpdatedAt(LocalDateTime.now());
|
||||
return recipient;
|
||||
}
|
||||
}
|
||||
+10
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.iam.notification;
|
||||
|
||||
import com.baiyue.iam.notification.controller.NotificationInboxController;
|
||||
import com.baiyue.iam.notification.controller.PlatformNotificationController;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
@@ -18,6 +19,15 @@ class NotificationInboxPermissionContractTest {
|
||||
assertPermission("readAll", "hasAuthority(#app + ':notification:read-all')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void taskDetailUsesTheTaskListPermission() {
|
||||
var method = Arrays.stream(PlatformNotificationController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals("taskDetail"))
|
||||
.findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('platform:notification:task:list')");
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String expected) {
|
||||
var method = Arrays.stream(NotificationInboxController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
|
||||
@@ -28,6 +28,7 @@ class NotificationOpenApiContractTest {
|
||||
"/{app}/v1/notifications/read-all", "/platform/v1/notifications",
|
||||
"/platform/v1/notification-templates", "/platform/v1/notification-templates/{id}",
|
||||
"/platform/v1/notification-templates/{id}/status", "/platform/v1/notification-tasks",
|
||||
"/platform/v1/notification-tasks/{id}",
|
||||
"/platform/v1/notification-tasks/{id}/cancel", "/platform/v1/notification-tasks/{id}/retry");
|
||||
assertThat(paths).doesNotContainKey("/merchant/v1/notifications:create");
|
||||
|
||||
@@ -37,6 +38,7 @@ class NotificationOpenApiContractTest {
|
||||
assertRequestBody(paths, "/platform/v1/notification-templates/{id}/status", "put");
|
||||
for (String path : List.of("/platform/v1/notification-templates/{id}",
|
||||
"/platform/v1/notification-templates/{id}/status",
|
||||
"/platform/v1/notification-tasks/{id}",
|
||||
"/platform/v1/notification-tasks/{id}/cancel",
|
||||
"/platform/v1/notification-tasks/{id}/retry")) {
|
||||
Map<String, Object> operation = map(map(paths.get(path)).values().iterator().next());
|
||||
@@ -50,6 +52,7 @@ class NotificationOpenApiContractTest {
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxPageResponse",
|
||||
"InboxDetailResponse", "UnreadCountResponse", "ReadAllResponse",
|
||||
"TemplateItem", "TemplateDetailResponse", "TaskItem", "TaskDetailResponse");
|
||||
assertThat(schemas).containsKeys("TaskItemResponse", "TaskRecipientItem", "TaskDetail");
|
||||
for (String schema : List.of("CreateNotificationRequest", "CreateTemplateRequest",
|
||||
"UpdateTemplateRequest", "ChangeTemplateStatusRequest", "InboxItem", "TemplateItem",
|
||||
"TaskItem", "InboxPageResponse", "TemplateDetailResponse", "TaskDetailResponse")) {
|
||||
@@ -59,6 +62,13 @@ class NotificationOpenApiContractTest {
|
||||
.contains("DISABLED cannot return to ACTIVE")
|
||||
.contains("does not support deleting or clearing read messages")
|
||||
.contains("name: taskNo").contains("name: templateCode").contains("name: title");
|
||||
Map<String, Object> inboxItem = map(schemas.get("InboxItem"));
|
||||
assertThat(list(inboxItem.get("required"))).contains("summary");
|
||||
assertThat(map(map(inboxItem.get("properties")).get("summary"))).containsEntry("maxLength", 120);
|
||||
Map<String, Object> taskDetail = map(schemas.get("TaskDetail"));
|
||||
assertThat(map(map(taskDetail.get("properties")).get("attemptCount")))
|
||||
.containsEntry("minimum", 0).containsEntry("maximum", 3);
|
||||
assertThat(document).contains("scheduler delivery starts after the retry delay");
|
||||
}
|
||||
|
||||
private void assertRequestBody(Map<String, Object> paths, String path, String method) {
|
||||
|
||||
+36
-2
@@ -13,15 +13,34 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class NotificationSchedulerRecoveryTest {
|
||||
@Test void claimSqlAtomicallyIncrementsAttemptsAndEnforcesDueTimeAndLimit() throws Exception {
|
||||
Update scheduled = NotificationTaskMapper.class
|
||||
.getMethod("claimScheduled", Long.class, int.class, java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String scheduledSql = String.join(" ", scheduled.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(scheduledSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
|
||||
"STATUS = 'SCHEDULED'", "SCHEDULED_AT <= #{NOW}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
|
||||
|
||||
Update retrying = NotificationTaskMapper.class
|
||||
.getMethod("claimRetrying", Long.class, int.class, java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String retryingSql = String.join(" ", retrying.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(retryingSql).contains("STATUS = 'DISPATCHING'", "ATTEMPT_COUNT = ATTEMPT_COUNT + 1",
|
||||
"STATUS = 'RETRYING'", "UPDATED_AT <= #{DUEBEFORE}", "ATTEMPT_COUNT < #{MAXATTEMPTS}");
|
||||
}
|
||||
|
||||
@Test void recoverySqlRequiresTimeoutAndNoSuccessfulDeliveryEvidence() throws Exception {
|
||||
Update retry = NotificationTaskMapper.class
|
||||
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
|
||||
.getMethod("retryTimedOutDispatchingWithoutDeliveryEvidence", java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class, int.class)
|
||||
.getAnnotation(Update.class);
|
||||
String retrySql = String.join(" ", retry.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
|
||||
assertThat(retrySql).contains("TASK.STATUS = 'DISPATCHING'", "TASK.UPDATED_AT <= #{STALEBEFORE}",
|
||||
"TASK.SENT_AT IS NULL", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX")
|
||||
.contains("SET TASK.STATUS = 'RETRYING'");
|
||||
.contains("SET TASK.STATUS = 'RETRYING'", "TASK.ATTEMPT_COUNT < #{MAXATTEMPTS}")
|
||||
.doesNotContain("ATTEMPT_COUNT = TASK.ATTEMPT_COUNT + 1");
|
||||
|
||||
Update dead = NotificationTaskMapper.class
|
||||
.getMethod("deadTimedOutDispatchingWithDeliveryEvidence", java.time.LocalDateTime.class, java.time.LocalDateTime.class)
|
||||
@@ -30,6 +49,21 @@ class NotificationSchedulerRecoveryTest {
|
||||
assertThat(deadSql).contains("SET TASK.STATUS = 'DEAD'", "TASK.STATUS = 'DISPATCHING'",
|
||||
"TASK.UPDATED_AT <= #{STALEBEFORE}", "RECIPIENT.STATUS = 'DELIVERED'", "IAM_NOTIFICATION_INBOX",
|
||||
"DISPATCH_TIMEOUT_WITH_DELIVERY_EVIDENCE");
|
||||
|
||||
Update attemptLimit = NotificationTaskMapper.class
|
||||
.getMethod("deadTimedOutDispatchingAtAttemptLimit", java.time.LocalDateTime.class,
|
||||
java.time.LocalDateTime.class, int.class)
|
||||
.getAnnotation(Update.class);
|
||||
String attemptLimitSql = String.join(" ", attemptLimit.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(attemptLimitSql).contains("SET TASK.STATUS = 'DEAD'",
|
||||
"TASK.ATTEMPT_COUNT >= #{MAXATTEMPTS}", "DISPATCH_TIMEOUT_ATTEMPT_LIMIT");
|
||||
|
||||
Update failed = NotificationTaskMapper.class
|
||||
.getMethod("failDispatch", Long.class, int.class, String.class, java.time.LocalDateTime.class)
|
||||
.getAnnotation(Update.class);
|
||||
String failedSql = String.join(" ", failed.value()).replaceAll("\\s+", " ").toUpperCase();
|
||||
assertThat(failedSql).contains("STATUS = CASE WHEN ATTEMPT_COUNT >= #{MAXATTEMPTS} THEN 'DEAD' ELSE 'RETRYING' END",
|
||||
"LAST_ERROR = #{ERROR}", "STATUS = 'DISPATCHING'");
|
||||
}
|
||||
|
||||
@Test void schedulerRecoversBeforeClaimingScheduledAndRetryingTasks() {
|
||||
|
||||
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.notification.dto.NotificationDtos.*;
|
||||
import com.baiyue.iam.notification.entity.NotificationRecipient;
|
||||
import com.baiyue.iam.notification.entity.NotificationEventInbox;
|
||||
import com.baiyue.iam.notification.entity.NotificationInbox;
|
||||
import com.baiyue.iam.notification.entity.NotificationTask;
|
||||
import com.baiyue.iam.notification.entity.NotificationTemplate;
|
||||
import com.baiyue.iam.notification.repository.NotificationRepository;
|
||||
import com.baiyue.iam.notification.service.NotificationRecipientResolver;
|
||||
import com.baiyue.iam.notification.service.NotificationDispatchTransactionService;
|
||||
import com.baiyue.iam.notification.service.NotificationService;
|
||||
import com.baiyue.iam.notification.service.NotificationTemplateRenderer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -27,6 +29,8 @@ import static org.mockito.Mockito.*;
|
||||
class NotificationServiceTest {
|
||||
private final NotificationRepository repository = mock(NotificationRepository.class);
|
||||
private final NotificationRecipientResolver resolver = mock(NotificationRecipientResolver.class);
|
||||
private final NotificationDispatchTransactionService dispatchTransactions =
|
||||
mock(NotificationDispatchTransactionService.class);
|
||||
private final NotificationService service = new NotificationService();
|
||||
|
||||
@BeforeEach void setup() {
|
||||
@@ -34,6 +38,7 @@ class NotificationServiceTest {
|
||||
ReflectionTestUtils.setField(service, "renderer", new NotificationTemplateRenderer());
|
||||
ReflectionTestUtils.setField(service, "resolver", resolver);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "dispatchTransactions", dispatchTransactions);
|
||||
}
|
||||
|
||||
@Test void createsTaskAndImmutableRecipientSnapshot() {
|
||||
@@ -63,6 +68,39 @@ class NotificationServiceTest {
|
||||
assertThat(stored.get(0).getStatus()).isEqualTo("DELIVERED");
|
||||
}
|
||||
|
||||
@Test void returnsWhitespaceCompactedUnicodeBoundedInboxSummaryWithoutFullBody() {
|
||||
NotificationInbox item = new NotificationInbox();
|
||||
item.setId(8L); item.setCategory("ORDER"); item.setTitle("状态变化"); item.setStatus("UNREAD");
|
||||
item.setSentAt(LocalDateTime.now()); item.setBody(" 第一行\n" + "测".repeat(140) + " ");
|
||||
when(repository.pageInbox(9L, 11L, "MERCHANT", 1, 20, null, null))
|
||||
.thenReturn(new com.baiyue.common.core.api.PageResult<>(List.of(item), 1, 20, 1, 1));
|
||||
|
||||
NotificationSummary summary = service.inbox(9L, 11L, "MERCHANT", 1, 20, null, null).records().get(0);
|
||||
|
||||
assertThat(summary.summary()).startsWith("第一行 ");
|
||||
assertThat(summary.summary().codePointCount(0, summary.summary().length())).isEqualTo(120);
|
||||
assertThat(summary.summary()).isNotEqualTo(item.getBody());
|
||||
}
|
||||
|
||||
@Test void returnsTaskAndRecipientFailureEvidence() {
|
||||
NotificationTask task = task(41L, "DEAD");
|
||||
task.setTaskNo("NT41"); task.setLastError("gateway timeout"); task.setAttemptCount(3);
|
||||
NotificationRecipient recipient = new NotificationRecipient();
|
||||
recipient.setId(51L); recipient.setDisplayName("客户成员"); recipient.setAppCode("MERCHANT");
|
||||
recipient.setStatus("FAILED"); recipient.setFailureReason("gateway timeout");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of(recipient));
|
||||
|
||||
TaskDetail detail = service.taskDetail(41L);
|
||||
|
||||
assertThat(detail.attemptCount()).isEqualTo(3);
|
||||
assertThat(detail.lastError()).isEqualTo("gateway timeout");
|
||||
assertThat(detail.recipients()).singleElement().satisfies(value -> {
|
||||
assertThat(value.status()).isEqualTo("FAILED");
|
||||
assertThat(value.failureReason()).isEqualTo("gateway timeout");
|
||||
});
|
||||
}
|
||||
|
||||
@Test void returnsOriginalTaskForRepeatedIdempotencyKey() {
|
||||
NotificationTask existing = new NotificationTask(); existing.setId(5L); existing.setTaskNo("NT5");
|
||||
existing.setCategory("ORDER"); existing.setTitle("A"); existing.setStatus("SENT"); existing.setRecipientCount(1);
|
||||
@@ -151,31 +189,28 @@ class NotificationServiceTest {
|
||||
}
|
||||
|
||||
@Test void recoversOnlyTasksOlderThanConfiguredDispatchTimeout() {
|
||||
when(repository.recoverTimedOutDispatching(any(), any())).thenReturn(2);
|
||||
when(repository.recoverTimedOutDispatching(any(), any(), eq(3))).thenReturn(2);
|
||||
|
||||
assertThat(service.recoverTimedOutDispatching(300)).isEqualTo(2);
|
||||
|
||||
ArgumentCaptor<LocalDateTime> staleBefore = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
ArgumentCaptor<LocalDateTime> now = ArgumentCaptor.forClass(LocalDateTime.class);
|
||||
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture());
|
||||
verify(repository).recoverTimedOutDispatching(staleBefore.capture(), now.capture(), eq(3));
|
||||
assertThat(staleBefore.getValue()).isEqualTo(now.getValue().minusSeconds(300));
|
||||
}
|
||||
|
||||
@Test void onlyOneInstanceCanClaimRecoveredTask() {
|
||||
when(repository.claimRetrying(41L)).thenReturn(true, false);
|
||||
NotificationTask task = task(41L, "RETRYING");
|
||||
when(repository.task(41L)).thenReturn(task);
|
||||
when(repository.recipients(41L)).thenReturn(List.of());
|
||||
when(dispatchTransactions.claimRetrying(41L)).thenReturn(true, false);
|
||||
|
||||
assertThat(service.dispatchRetrying(41L)).isTrue();
|
||||
assertThat(service.dispatchRetrying(41L)).isFalse();
|
||||
|
||||
verify(repository, times(2)).claimRetrying(41L);
|
||||
verify(repository, times(1)).saveTask(task);
|
||||
verify(dispatchTransactions, times(2)).claimRetrying(41L);
|
||||
verify(dispatchTransactions, times(1)).deliverClaimed(41L);
|
||||
}
|
||||
|
||||
@Test void sentTaskCannotBeClaimedForRecoveryDelivery() {
|
||||
when(repository.claimRetrying(42L)).thenReturn(false);
|
||||
when(dispatchTransactions.claimRetrying(42L)).thenReturn(false);
|
||||
|
||||
assertThat(service.dispatchRetrying(42L)).isFalse();
|
||||
|
||||
@@ -183,6 +218,30 @@ class NotificationServiceTest {
|
||||
verify(repository, never()).recipients(42L);
|
||||
}
|
||||
|
||||
@Test void persistsScheduledDeliveryFailureAfterCommittedClaim() {
|
||||
IllegalStateException failure = new IllegalStateException("inbox unavailable");
|
||||
when(dispatchTransactions.claimScheduled(43L)).thenReturn(true);
|
||||
doThrow(failure).when(dispatchTransactions).deliverClaimed(43L);
|
||||
|
||||
assertThatThrownBy(() -> service.dispatchScheduled(43L)).isSameAs(failure);
|
||||
|
||||
verify(dispatchTransactions).recordFailure(43L, failure);
|
||||
}
|
||||
|
||||
@Test void manualRetryResetsAttemptBudgetAndWaitsForScheduler() {
|
||||
NotificationTask task = task(44L, "DEAD");
|
||||
task.setAttemptCount(3); task.setLastError("timeout");
|
||||
when(repository.task(44L)).thenReturn(task);
|
||||
when(repository.requeueForManualRetry(eq(44L), any())).thenReturn(true);
|
||||
|
||||
TaskListItem result = service.retry(44L);
|
||||
|
||||
assertThat(result.status()).isEqualTo("RETRYING");
|
||||
assertThat(task.getAttemptCount()).isZero();
|
||||
assertThat(task.getLastError()).isNull();
|
||||
verify(dispatchTransactions, never()).deliverClaimed(anyLong());
|
||||
}
|
||||
|
||||
private NotificationTask task(long id, String status) {
|
||||
NotificationTask value = new NotificationTask();
|
||||
value.setId(id); value.setStatus(status); value.setCategory("ORDER"); value.setTitle("A");
|
||||
|
||||
+26
-1
@@ -13,7 +13,12 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
private static final String[] FINANCE_MENUS = {
|
||||
"platform:finance:customer-account:list", "platform:finance:customer-arap:list",
|
||||
"platform:finance:customer-statement:list", "platform:finance:settlement-ar:list",
|
||||
"platform:finance:settlement-ap:list"
|
||||
"platform:finance:settlement-ap:list", "platform:finance:receivable-statistics:list"
|
||||
};
|
||||
private static final String[] FINANCE_EXPORTS = {
|
||||
"platform:finance:customer-account:export", "platform:finance:customer-arap:export",
|
||||
"platform:finance:customer-statement:export", "platform:finance:settlement-ar:export",
|
||||
"platform:finance:settlement-ap:export", "platform:finance:receivable-statistics:export"
|
||||
};
|
||||
|
||||
@Test
|
||||
@@ -23,9 +28,11 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
|
||||
for (int index = 0; index < FINANCE_MENUS.length; index++) {
|
||||
assertThat(sql).contains("(" + (5105040 + index) + ",0,'PLATFORM','MENU',5105000,'" + FINANCE_MENUS[index] + "'");
|
||||
assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
|
||||
}
|
||||
assertThat(sql)
|
||||
.contains("'platform:finance:fee-source:review' code, 5105041 parent_id")
|
||||
.contains("'platform:finance:fee-source:edit', 5105041")
|
||||
.contains("'platform:finance:reconciliation:create', 5105042")
|
||||
.contains("'platform:finance:settlement-ar:create', 5105043")
|
||||
.contains("'platform:finance:settlement-ap:create', 5105044")
|
||||
@@ -36,6 +43,24 @@ class AdminPrototypeMenuPermissionSqlContractTest {
|
||||
.doesNotContain("SELECT 'platform:finance:settlement-ar:view', 5105043")
|
||||
.doesNotContain("SELECT 'platform:finance:settlement-ap:view', 5105044")
|
||||
.contains("role_code='PLATFORM_ADMIN'");
|
||||
|
||||
int grantStart = sql.indexOf("role_code='PLATFORM_ADMIN'");
|
||||
String platformAdminGrant = sql.substring(grantStart, sql.indexOf(");", grantStart));
|
||||
for (String permission : FINANCE_EXPORTS) assertThat(platformAdminGrant).contains(permission);
|
||||
assertThat(platformAdminGrant).contains("platform:download:list");
|
||||
|
||||
String unified = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8);
|
||||
for (int index = 0; index < FINANCE_EXPORTS.length; index++) {
|
||||
assertThat(unified).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'");
|
||||
}
|
||||
assertThat(unified)
|
||||
.contains("platform:finance:receivable-statistics:export")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33")
|
||||
.contains("Finance 导出和下载中心授权不完整")
|
||||
.contains("merchant:finance:statement:confirm")
|
||||
.satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'"))
|
||||
.isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()")));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
-1
@@ -40,8 +40,8 @@ class CustomerListExportProviderRepositoryTest {
|
||||
assertThat(query.getSqlSegment())
|
||||
.contains("tenant_type")
|
||||
.contains("status")
|
||||
.contains("tenant_code")
|
||||
.contains("tenant_name")
|
||||
.doesNotContain("tenant_code")
|
||||
.contains("id >")
|
||||
.contains("id <=")
|
||||
.contains("ORDER BY id ASC")
|
||||
|
||||
+5
-5
@@ -17,7 +17,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
|
||||
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
|
||||
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
|
||||
.contains("ON DUPLICATE KEY UPDATE")
|
||||
@@ -35,7 +35,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("SIGNAL SQLSTATE '45000'")
|
||||
.contains("permission_code = 'platform:finance:fee-source:settlement' AND id <> 5105025")
|
||||
.contains("permission_code = 'platform:finance:fee-source:edit' AND id <> 5105025")
|
||||
.contains("tenant_id <> 0 OR app_code <> 'PLATFORM'")
|
||||
.contains("parent_id = 5105000")
|
||||
.contains("permission_type = 'MENU'")
|
||||
@@ -52,7 +52,7 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
|
||||
assertThat(financeSection)
|
||||
.contains("(5105024, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:review'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:settlement'")
|
||||
.contains("(5105025, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:fee-source:edit'")
|
||||
.contains("NULL, 'POST', 150, 'ACTIVE', 1, 0")
|
||||
.contains("NULL, 'PUT', 160, 'ACTIVE', 1, 0")
|
||||
.contains("permission_code IN (\n 'platform:finance',")
|
||||
@@ -63,9 +63,9 @@ class FinanceG1G2PermissionSqlContractTest {
|
||||
assertThat(sql)
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 28")
|
||||
.contains("LIKE 'platform:finance:%')) <> 33")
|
||||
.contains("平台管理员 Finance 授权不完整");
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -51,9 +51,9 @@ class FinanceG3G4PermissionSqlContractTest {
|
||||
assertThat(sql)
|
||||
.contains("platform:finance:settlement-ar:list")
|
||||
.contains("platform:finance:settlement-ap:list")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 27")
|
||||
.contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32")
|
||||
.contains("rp.role_id=4000001")
|
||||
.contains("LIKE 'platform:finance:%')) <> 28");
|
||||
.contains("LIKE 'platform:finance:%')) <> 33");
|
||||
}
|
||||
|
||||
private static Path resolveUnifiedSql() {
|
||||
|
||||
@@ -19,6 +19,7 @@ import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -45,15 +46,20 @@ class MemberServiceTest {
|
||||
when(repository.findByTenantAndId(100L, 20L)).thenReturn(member);
|
||||
when(repository.findUser(30L)).thenReturn(user);
|
||||
when(repository.findRoleIds(100L, 20L)).thenReturn(List.of(50L));
|
||||
when(auth.summary("internal-token", 40L)).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(40L, "member01", "测试成员", "ACTIVE",
|
||||
LocalDateTime.of(2026, 8, 17, 10, 30), 100L, 20L, "MERCHANT")));
|
||||
}
|
||||
|
||||
@Test void shouldHandleCase01() {
|
||||
user.setPhone("13800138000"); user.setEmail("member@example.com");
|
||||
MemberResponse result = service.detail(100L, 20L);
|
||||
assertEquals("测试成员", result.displayName());
|
||||
assertEquals("member01", result.username());
|
||||
assertEquals("13800138000", result.phone());
|
||||
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 30), result.lastLoginAt());
|
||||
assertEquals(List.of(50L), result.roleIds());
|
||||
verifyNoInteractions(auth);
|
||||
verify(auth).summary("internal-token", 40L);
|
||||
}
|
||||
|
||||
@Test void shouldReturnOwnerAccountForPlatformCustomerDetail() {
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.springframework.test.context.TestPropertySource;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
@@ -47,11 +48,14 @@ class RbacServiceTest {
|
||||
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
|
||||
when(repository.findRoles(100L, "MERCHANT")).thenReturn(List.of(role));
|
||||
when(repository.findRolePermissionIds(100L, 11L)).thenReturn(Set.of(21L, 22L));
|
||||
when(repository.findRoleMemberCounts(100L, List.of(11L))).thenReturn(Map.of(11L, 3L));
|
||||
|
||||
var result = service.roles(100L, "MERCHANT");
|
||||
|
||||
assertEquals(Set.of(21L, 22L), result.get(0).permissionIds());
|
||||
assertEquals(3L, result.get(0).memberCount());
|
||||
assertEquals(100L, result.get(0).tenantId());
|
||||
verify(repository, never()).countRoleMembers(anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -30,6 +30,7 @@ import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -94,6 +95,55 @@ class TenantServiceTest {
|
||||
verify(repository).pageMerchants(1, 10, "Example", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewsIncludeCustomerSummaryWithoutPerReviewQueries() {
|
||||
IamTenantReview review = review(71L, 10001L);
|
||||
review.setReviewType("REGISTER");
|
||||
review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27));
|
||||
IamTenant tenant = merchant(10001L, "PENDING");
|
||||
tenant.setTenantCode("TEN10001");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
tenant.setContactEmail("alice@example.com");
|
||||
when(repository.findPendingReviews()).thenReturn(List.of(review));
|
||||
when(repository.findMerchantsByIds(Set.of(10001L))).thenReturn(List.of(tenant));
|
||||
|
||||
var result = service.pendingReviews();
|
||||
|
||||
assertEquals(1, result.size());
|
||||
assertEquals(71L, result.get(0).id());
|
||||
assertEquals(10001L, result.get(0).tenantId());
|
||||
assertEquals("REGISTER", result.get(0).reviewType());
|
||||
assertEquals("PENDING", result.get(0).status());
|
||||
assertEquals("Example Merchant", result.get(0).tenantName());
|
||||
assertEquals("TEN10001", result.get(0).tenantCode());
|
||||
assertEquals("Alice", result.get(0).contactName());
|
||||
assertEquals("13800138000", result.get(0).contactPhone());
|
||||
assertEquals("alice@example.com", result.get(0).contactEmail());
|
||||
assertEquals(LocalDateTime.of(2026, 8, 17, 10, 27), result.get(0).submittedAt());
|
||||
verify(repository).findMerchantsByIds(Set.of(10001L));
|
||||
verify(repository, never()).findTenant(10001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
tenant.setTenantCode("TEN10001");
|
||||
tenant.setTenantName("Example Merchant");
|
||||
tenant.setContactName("Alice");
|
||||
tenant.setContactPhone("13800138000");
|
||||
when(repository.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null))
|
||||
.thenReturn(new PageResult<>(List.of(tenant), 1, 10, 1, 1));
|
||||
|
||||
var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
|
||||
|
||||
assertEquals("TEN10001", result.records().get(0).tenantCode());
|
||||
assertEquals("Alice", result.records().get(0).contactName());
|
||||
assertEquals("13800138000", result.records().get(0).contactPhone());
|
||||
verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanMaintainAndDisableOnlyMerchantTenant() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
@@ -162,11 +212,11 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void archiveIsIdempotentForDisabledCustomerWithoutCallingAuth() {
|
||||
IamTenant tenant = merchant(10001L, "DISABLED");
|
||||
void archiveIsIdempotentForArchivedCustomerWithoutCallingAuth() {
|
||||
IamTenant tenant = merchant(10001L, "ARCHIVED");
|
||||
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
|
||||
assertEquals("DISABLED", service.archiveMerchant(10001L).status());
|
||||
assertEquals("ARCHIVED", service.archiveMerchant(10001L).status());
|
||||
verify(authClient, never()).archiveTenant(any(), any());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
@@ -205,6 +255,34 @@ class TenantServiceTest {
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedCustomerCannotBeReactivated() {
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "ARCHIVED"));
|
||||
|
||||
BizException exception = assertThrows(BizException.class,
|
||||
() -> service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("ACTIVE")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void archivedCustomerIsReadOnlyForPlatformAndMerchantProfileUpdates() {
|
||||
IamTenant archived = merchant(10001L, "ARCHIVED");
|
||||
when(repository.findTenant(10001L)).thenReturn(archived);
|
||||
|
||||
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
|
||||
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
|
||||
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
|
||||
"13800138000", "alice@example.com")));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, platformFailure.getResultCode());
|
||||
assertEquals(ResultCode.CONFLICT, merchantFailure.getResultCode());
|
||||
verify(repository, never()).saveTenant(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantProfileCannotReadOrUpdatePlatformTenant() {
|
||||
IamTenant platform = new IamTenant();
|
||||
@@ -283,7 +361,7 @@ class TenantServiceTest {
|
||||
|
||||
var archived = service.archiveMerchant(10001L);
|
||||
|
||||
assertEquals("DISABLED", archived.status());
|
||||
assertEquals("ARCHIVED", archived.status());
|
||||
verify(authClient).archiveTenant("internal-token", 10001L);
|
||||
verify(repository).saveTenant(tenant);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user