This commit is contained in:
2026-08-19 14:02:11 +08:00
590 changed files with 23946 additions and 5168 deletions
@@ -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) { }
}
@@ -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);
}
@@ -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)
@@ -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(),
@@ -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;
+1 -1
View File
@@ -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
+158 -10
View File
@@ -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);
}
}
@@ -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
@@ -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;
}
}
@@ -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) {
@@ -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");
@@ -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
@@ -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")
@@ -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 授权不完整");
}
@@ -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);
}