This commit is contained in:
2026-08-03 18:28:35 +08:00
parent eac3da9dd8
commit fdb1e8af1c
43 changed files with 1707 additions and 69 deletions
@@ -3,12 +3,14 @@ package com.baiyue.file;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 文件中心服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@EnableFeignClients(basePackages = "com.baiyue.file.client")
@EnableScheduling
public class FileServiceApplication {
public static void main(String[] args) {
SpringApplication.run(FileServiceApplication.class, args);
@@ -0,0 +1,34 @@
package com.baiyue.file.client;
import com.baiyue.common.core.api.ApiResponse;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@FeignClient(name = "iam-service", contextId = "iamExportClient",
url = "${baiyue.clients.iam.url:}", path = "/internal/iam")
public interface IamExportClient {
@GetMapping("/exports/customers")
ApiResponse<CustomerExportBatch> customers(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "500") int limit,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status);
record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
record CustomerExportRow(
String id, String tenantCode, String tenantName, String legalName,
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
String contactName, String contactPhone, String contactEmail, String contactWechat,
String salesOwnerName, String servicePackage, Integer paymentTermDays,
String status, LocalDateTime createdAt, LocalDateTime approvedAt) { }
}
@@ -20,8 +20,10 @@ public interface IamQualificationClient {
@RequestBody PlatformQualificationRequest request);
record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
String certificateNo, String documentUrl,
java.time.LocalDate validTo, boolean replace) { }
record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
record PlatformQualificationResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
record PlatformQualificationRequest(Long tenantId, String qualificationType,
@@ -0,0 +1,81 @@
package com.baiyue.file.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse;
import com.baiyue.file.service.ExportTaskService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/platform/v1/files/export-tasks")
@Validated
public class PlatformExportTaskController {
private final ExportTaskService service;
public PlatformExportTaskController(ExportTaskService service) { this.service = service; }
@PostMapping
@PreAuthorize("isAuthenticated()")
public ApiResponse<ExportTaskResponse> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CreateExportTaskRequest request) {
return ApiResponse.success(service.create(user, request));
}
@GetMapping
@PreAuthorize("hasAuthority('platform:download:list')")
public ApiResponse<PageResult<ExportTaskResponse>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(defaultValue = "1") @Min(1) long page,
@RequestParam(defaultValue = "20") @Min(1) @Max(100) long size,
@RequestParam(required = false) String exportType,
@RequestParam(required = false) @Pattern(regexp = "PENDING|RUNNING|SUCCEEDED|FAILED|EXPIRED") String status,
@RequestParam(required = false) LocalDate requestedFrom,
@RequestParam(required = false) LocalDate requestedTo) {
return ApiResponse.success(service.page(user, page, size, exportType, status, requestedFrom, requestedTo));
}
@GetMapping("/{id:\\d+}")
@PreAuthorize("hasAuthority('platform:download:list')")
public ApiResponse<ExportTaskResponse> get(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id) {
return ApiResponse.success(service.get(user, id));
}
@GetMapping("/{id:\\d+}/download")
@PreAuthorize("hasAuthority('platform:download:list')")
public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id) {
var download = service.download(user, id);
ContentDisposition disposition = ContentDisposition.attachment()
.filename(download.fileName(), StandardCharsets.UTF_8).build();
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType(download.contentType()))
.body(download.resource());
}
@PostMapping("/{id:\\d+}/retry")
@PreAuthorize("hasAuthority('platform:download:list')")
public ApiResponse<ExportTaskResponse> retry(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable long id) {
return ApiResponse.success(service.retry(user, id));
}
}
@@ -18,6 +18,8 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDate;
@RestController
@RequestMapping("/api/merchant/v1/files/qualifications")
public class QualificationFileController {
@@ -31,13 +33,16 @@ public class QualificationFileController {
public ApiResponse<UploadResponse> upload(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestPart("file") MultipartFile file,
@RequestParam(value = "certificateNo", required = false) String certificateNo) {
@RequestParam(defaultValue = "BUSINESS_LICENSE") String qualificationType,
@RequestParam(value = "certificateNo", required = false) String certificateNo,
@RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) {
long tenantId = merchantTenant(user);
var stored = storage.store(tenantId, file);
try {
var response = iamClient.submit(internalToken,
new IamQualificationClient.QualificationRequest(tenantId, "BUSINESS_LICENSE",
certificateNo, stored.documentUrl()));
new IamQualificationClient.QualificationRequest(tenantId, qualificationType,
certificateNo, stored.documentUrl(), validTo, replace));
if (response == null) {
throw new IllegalStateException("IAM qualification response is empty");
}
@@ -52,6 +57,9 @@ public class QualificationFileController {
throw new IllegalStateException("IAM qualification response data is empty");
}
var data = response.data();
if (!stored.documentUrl().equals(data.replacedDocumentUrl())) {
storage.deleteByDocumentUrl(tenantId, data.replacedDocumentUrl());
}
return ApiResponse.success(new UploadResponse(data.qualificationId(), data.reviewId(),
data.tenantStatus(), stored.documentUrl()));
} catch (BizException exception) {
@@ -0,0 +1,24 @@
package com.baiyue.file.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Map;
public final class ExportTaskDtos {
private ExportTaskDtos() { }
public record CreateExportTaskRequest(
@NotBlank @Size(max = 64) String exportType,
@NotBlank @Size(max = 16) String format,
@NotBlank @Size(max = 128) String taskName,
Map<String, Object> parameters) { }
public record ExportTaskResponse(
String id, String taskNo, String exportType, String format, String taskName,
String status, Long totalRows, long processedRows, String fileName,
String contentType, Long sizeBytes, String failureMessage,
String requestedBy, String requestedUsername, LocalDateTime requestedAt,
LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime expiresAt) { }
}
@@ -0,0 +1,37 @@
package com.baiyue.file.entity;
import com.baiyue.common.core.model.BaseEntity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Getter
@Setter
@TableName("file_export_task")
public class FileExportTask extends BaseEntity {
@TableId private Long id;
private Long tenantId;
private String appCode;
private String taskNo;
private String exportType;
private String format;
private String taskName;
private String parametersJson;
private String status;
private Long totalRows;
private Long processedRows;
private String fileName;
private String objectKey;
private String contentType;
private Long sizeBytes;
private String failureMessage;
private Long requestedBy;
private String requestedUsername;
private LocalDateTime requestedAt;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime expiresAt;
}
@@ -0,0 +1,8 @@
package com.baiyue.file.mapper;
import com.baiyue.file.entity.FileExportTask;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface FileExportTaskMapper extends BaseMapper<FileExportTask> { }
@@ -0,0 +1,132 @@
package com.baiyue.file.repository;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.file.entity.FileExportTask;
import com.baiyue.file.mapper.FileExportTaskMapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
@Repository
public class ExportTaskRepository {
private final FileExportTaskMapper mapper;
public ExportTaskRepository(FileExportTaskMapper mapper) {
this.mapper = mapper;
}
public void insert(FileExportTask task) { mapper.insert(task); }
public FileExportTask find(long id) { return mapper.selectById(id); }
public PageResult<FileExportTask> page(long page, long size, long tenantId, String appCode,
Long requestedBy, String exportType, String status,
LocalDate requestedFrom, LocalDate requestedTo) {
var query = new LambdaQueryWrapper<FileExportTask>()
.eq(FileExportTask::getTenantId, tenantId)
.eq(FileExportTask::getAppCode, appCode)
.eq(requestedBy != null, FileExportTask::getRequestedBy, requestedBy)
.eq(StringUtils.hasText(exportType), FileExportTask::getExportType, exportType)
.eq(StringUtils.hasText(status), FileExportTask::getStatus, status)
.ge(requestedFrom != null, FileExportTask::getRequestedAt,
requestedFrom == null ? null : requestedFrom.atStartOfDay())
.lt(requestedTo != null, FileExportTask::getRequestedAt,
requestedTo == null ? null : requestedTo.plusDays(1).atStartOfDay())
.orderByDesc(FileExportTask::getRequestedAt)
.orderByDesc(FileExportTask::getId);
return PageResult.from(mapper.selectPage(new Page<>(page, size), query));
}
public Long firstPendingId() {
FileExportTask task = mapper.selectOne(new LambdaQueryWrapper<FileExportTask>()
.select(FileExportTask::getId)
.eq(FileExportTask::getStatus, "PENDING")
.orderByAsc(FileExportTask::getRequestedAt)
.orderByAsc(FileExportTask::getId)
.last("LIMIT 1"));
return task == null ? null : task.getId();
}
public boolean claim(long id, LocalDateTime now) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStartedAt, now)
.set(FileExportTask::getFailureMessage, null)) == 1;
}
public void progress(long id, long rows) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getProcessedRows, rows));
}
public boolean succeed(long id, String fileName, String objectKey, String contentType,
long sizeBytes, long rows, LocalDateTime completedAt, LocalDateTime expiresAt) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStatus, "SUCCEEDED")
.set(FileExportTask::getFileName, fileName)
.set(FileExportTask::getObjectKey, objectKey)
.set(FileExportTask::getContentType, contentType)
.set(FileExportTask::getSizeBytes, sizeBytes)
.set(FileExportTask::getTotalRows, rows)
.set(FileExportTask::getProcessedRows, rows)
.set(FileExportTask::getCompletedAt, completedAt)
.set(FileExportTask::getExpiresAt, expiresAt)) == 1;
}
public void fail(long id, String message, LocalDateTime completedAt) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "RUNNING")
.set(FileExportTask::getStatus, "FAILED")
.set(FileExportTask::getFailureMessage, message)
.set(FileExportTask::getCompletedAt, completedAt));
}
public boolean retry(long id) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "FAILED")
.set(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getProcessedRows, 0L)
.set(FileExportTask::getTotalRows, null)
.set(FileExportTask::getFailureMessage, null)
.set(FileExportTask::getStartedAt, null)
.set(FileExportTask::getCompletedAt, null)) == 1;
}
public int requeueStale(LocalDateTime cutoff) {
return mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getStatus, "RUNNING")
.lt(FileExportTask::getUpdatedAt, cutoff)
.set(FileExportTask::getStatus, "PENDING")
.set(FileExportTask::getStartedAt, null)
.set(FileExportTask::getFailureMessage, "Previous worker stopped before completion; task requeued"));
}
public List<FileExportTask> expiredArtifacts(LocalDateTime now) {
return mapper.selectList(new LambdaQueryWrapper<FileExportTask>()
.eq(FileExportTask::getStatus, "SUCCEEDED")
.le(FileExportTask::getExpiresAt, now)
.isNotNull(FileExportTask::getObjectKey)
.last("LIMIT 50"));
}
public void markExpired(long id) {
mapper.update(null, new LambdaUpdateWrapper<FileExportTask>()
.eq(FileExportTask::getId, id)
.eq(FileExportTask::getStatus, "SUCCEEDED")
.set(FileExportTask::getStatus, "EXPIRED"));
}
}
@@ -0,0 +1,168 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.file.client.IamExportClient;
import com.baiyue.file.entity.FileExportTask;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
public class CustomerExportTaskHandler implements ExportTaskHandler {
static final String CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private static final List<String> HEADERS = List.of(
"客户ID", "客户编码", "公司名称", "营业执照企业名称", "统一社会信用代码", "企业类型",
"成立日期", "注册资本", "币种", "注册地址", "联系人", "联系电话", "联系邮箱", "联系微信",
"销售人员", "客户套餐", "账期(天)", "状态", "入驻时间", "审批时间");
private final IamExportClient iamClient;
private final LocalExportStorage storage;
private final ObjectMapper objectMapper;
private final String internalToken;
public CustomerExportTaskHandler(IamExportClient iamClient, LocalExportStorage storage,
ObjectMapper objectMapper,
@Value("${baiyue.security.internal-service-token}") String internalToken) {
this.iamClient = iamClient;
this.storage = storage;
this.objectMapper = objectMapper;
this.internalToken = internalToken;
}
@Override public String exportType() { return "CUSTOMER_LIST"; }
@Override public String requiredPermission() { return "platform:customer:export"; }
@Override
public void validateParameters(Map<String, Object> parameters) {
if (parameters == null) return;
Object keyword = parameters.get("keyword");
if (keyword != null && String.valueOf(keyword).length() > 128) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出关键词不能超过 128 个字符");
}
Object status = parameters.get("status");
if (status != null && !String.valueOf(status).isBlank()
&& !Set.of("PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED")
.contains(String.valueOf(status))) {
throw new BizException(ResultCode.VALIDATION_FAILED, "客户导出状态无效");
}
}
@Override
public ExportResult export(FileExportTask task, ProgressReporter progress) {
CustomerParameters parameters = parameters(task.getParametersJson());
LocalExportStorage.PendingFile target = storage.create(task.getId());
long rows = 0;
try (target; SXSSFWorkbook workbook = new SXSSFWorkbook(100)) {
workbook.setCompressTempFiles(true);
Sheet sheet = workbook.createSheet("客户列表");
writeHeader(workbook, sheet);
Long cursor = null;
boolean hasMore;
do {
var response = iamClient.customers(internalToken, cursor, 500,
blankToNull(parameters.keyword()), blankToNull(parameters.status()));
if (response == null || !response.successful() || response.data() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM customer export is unavailable");
}
var batch = response.data();
for (var item : batch.records()) writeRow(sheet.createRow(Math.toIntExact(++rows)), item);
progress.update(rows);
hasMore = batch.hasMore();
if (hasMore && (batch.nextCursor() == null || batch.nextCursor().equals(cursor))) {
throw new IllegalStateException("IAM customer export cursor did not advance");
}
cursor = batch.nextCursor();
} while (hasMore);
for (int index = 0; index < HEADERS.size(); index++) sheet.setColumnWidth(index, columnWidth(index));
workbook.write(target.output());
} catch (IOException exception) {
storage.delete(target.objectKey());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "客户导出文件生成失败");
} catch (RuntimeException exception) {
storage.delete(target.objectKey());
throw exception;
}
String fileName = "客户列表-" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")) + ".xlsx";
return new ExportResult(fileName, target.objectKey(), CONTENT_TYPE,
storage.size(target.objectKey()), rows);
}
private CustomerParameters parameters(String json) {
try { return objectMapper.readValue(json, CustomerParameters.class); }
catch (Exception exception) { throw new BizException(ResultCode.BAD_REQUEST, "客户导出筛选条件无效"); }
}
private void writeHeader(Workbook workbook, Sheet sheet) {
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
var font = workbook.createFont();
font.setBold(true);
style.setFont(font);
Row row = sheet.createRow(0);
for (int index = 0; index < HEADERS.size(); index++) {
var cell = row.createCell(index);
cell.setCellValue(HEADERS.get(index));
cell.setCellStyle(style);
}
sheet.createFreezePane(0, 1);
}
private void writeRow(Row row, IamExportClient.CustomerExportRow item) {
int column = 0;
cell(row, column++, item.id()); cell(row, column++, item.tenantCode());
cell(row, column++, item.tenantName()); cell(row, column++, item.legalName());
cell(row, column++, item.unifiedSocialCreditCode()); cell(row, column++, item.enterpriseType());
cell(row, column++, item.establishedDate()); cell(row, column++, item.registeredCapital());
cell(row, column++, item.registeredCapitalCurrency()); cell(row, column++, item.registeredAddress());
cell(row, column++, item.contactName()); cell(row, column++, item.contactPhone());
cell(row, column++, item.contactEmail()); cell(row, column++, item.contactWechat());
cell(row, column++, item.salesOwnerName()); cell(row, column++, item.servicePackage());
cell(row, column++, item.paymentTermDays()); cell(row, column++, statusLabel(item.status()));
cell(row, column++, item.createdAt()); cell(row, column, item.approvedAt());
}
private void cell(Row row, int column, Object value) {
row.createCell(column).setCellValue(value == null ? "" : String.valueOf(value));
}
private int columnWidth(int index) {
return switch (index) {
case 2, 3, 9 -> 28 * 256;
case 4, 12 -> 24 * 256;
case 18, 19 -> 21 * 256;
default -> 16 * 256;
};
}
private String statusLabel(String status) {
if (status == null) return "";
return switch (status) {
case "PROFILE_REQUIRED" -> "待完善";
case "PENDING" -> "待审核";
case "ACTIVE" -> "启用";
case "REJECTED" -> "已驳回";
case "DISABLED" -> "已停用";
default -> status;
};
}
private String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
public record CustomerParameters(String keyword, String status) { }
}
@@ -0,0 +1,17 @@
package com.baiyue.file.service;
import com.baiyue.file.entity.FileExportTask;
import java.util.Map;
public interface ExportTaskHandler {
String exportType();
String requiredPermission();
default void validateParameters(Map<String, Object> parameters) { }
ExportResult export(FileExportTask task, ProgressReporter progress);
record ExportResult(String fileName, String objectKey, String contentType, long sizeBytes, long rows) { }
@FunctionalInterface
interface ProgressReporter { void update(long rows); }
}
@@ -0,0 +1,154 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
import com.baiyue.file.dto.ExportTaskDtos.ExportTaskResponse;
import com.baiyue.file.entity.FileExportTask;
import com.baiyue.file.repository.ExportTaskRepository;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
public class ExportTaskService {
private final ExportTaskRepository repository;
private final LocalExportStorage storage;
private final ObjectMapper objectMapper;
private final Map<String, ExportTaskHandler> handlers;
public ExportTaskService(ExportTaskRepository repository, LocalExportStorage storage,
ObjectMapper objectMapper, List<ExportTaskHandler> handlers) {
this.repository = repository;
this.storage = storage;
this.objectMapper = objectMapper;
this.handlers = handlers.stream().collect(Collectors.toUnmodifiableMap(
handler -> handler.exportType().toUpperCase(Locale.ROOT), Function.identity()));
}
public ExportTaskResponse create(AuthenticatedUser user, CreateExportTaskRequest request) {
requirePlatform(user);
String type = request.exportType().trim().toUpperCase(Locale.ROOT);
String format = request.format().trim().toUpperCase(Locale.ROOT);
ExportTaskHandler handler = handler(type);
if (!"XLSX".equals(format)) throw new BizException(ResultCode.VALIDATION_FAILED, "当前仅支持 XLSX 格式");
if (!user.permissions().contains(handler.requiredPermission())) {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
}
handler.validateParameters(request.parameters() == null ? Map.of() : request.parameters());
long id = IdWorker.getId();
LocalDateTime now = LocalDateTime.now();
FileExportTask task = new FileExportTask();
task.setId(id);
task.setTenantId(user.tenantId());
task.setAppCode(user.appCode().toUpperCase(Locale.ROOT));
task.setTaskNo("EXP" + id);
task.setExportType(type);
task.setFormat(format);
task.setTaskName(request.taskName().trim());
task.setParametersJson(parametersJson(request.parameters()));
task.setStatus("PENDING");
task.setProcessedRows(0L);
task.setRequestedBy(user.userId());
task.setRequestedUsername(user.username());
task.setRequestedAt(now);
repository.insert(task);
return response(task);
}
public PageResult<ExportTaskResponse> page(AuthenticatedUser user, long page, long size,
String exportType, String status,
LocalDate requestedFrom, LocalDate requestedTo) {
requirePlatform(user);
if (requestedFrom != null && requestedTo != null && requestedFrom.isAfter(requestedTo)) {
throw new BizException(ResultCode.VALIDATION_FAILED, "开始日期不能晚于结束日期");
}
Long owner = user.permissions().contains("platform:download:all") ? null : user.userId();
PageResult<FileExportTask> source = repository.page(page, size, user.tenantId(), user.appCode(), owner,
normalize(exportType), normalize(status), requestedFrom, requestedTo);
return new PageResult<>(source.records().stream().map(this::response).toList(),
source.page(), source.size(), source.total(), source.pages());
}
public ExportTaskResponse get(AuthenticatedUser user, long id) {
return response(ownedTask(user, id));
}
public DownloadResource download(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
if (!"SUCCEEDED".equals(task.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "导出任务尚未完成或文件已过期");
}
if (task.getExpiresAt() != null && !task.getExpiresAt().isAfter(LocalDateTime.now())) {
throw new BizException(ResultCode.CONFLICT, "导出文件已过期");
}
return new DownloadResource(storage.load(task.getObjectKey()), task.getFileName(), task.getContentType());
}
public ExportTaskResponse retry(AuthenticatedUser user, long id) {
FileExportTask task = ownedTask(user, id);
ExportTaskHandler handler = handler(task.getExportType());
if (!user.permissions().contains(handler.requiredPermission())) {
throw new BizException(ResultCode.FORBIDDEN, "缺少该导出类型的操作权限");
}
if (!"FAILED".equals(task.getStatus()) || !repository.retry(id)) {
throw new BizException(ResultCode.CONFLICT, "只有失败的导出任务可以重试");
}
return response(repository.find(id));
}
ExportTaskHandler handler(String exportType) {
ExportTaskHandler handler = handlers.get(exportType == null ? "" : exportType.toUpperCase(Locale.ROOT));
if (handler == null) throw new BizException(ResultCode.VALIDATION_FAILED, "不支持的导出类型");
return handler;
}
private FileExportTask ownedTask(AuthenticatedUser user, long id) {
requirePlatform(user);
FileExportTask task = repository.find(id);
if (task == null || !task.getTenantId().equals(user.tenantId())
|| !task.getAppCode().equalsIgnoreCase(user.appCode())) {
throw new BizException(ResultCode.NOT_FOUND, "导出任务不存在");
}
if (!task.getRequestedBy().equals(user.userId()) && !user.permissions().contains("platform:download:all")) {
throw new BizException(ResultCode.FORBIDDEN, "不能访问其他用户的导出任务");
}
return task;
}
private String parametersJson(Map<String, Object> parameters) {
try { return objectMapper.writeValueAsString(parameters == null ? Map.of() : parameters); }
catch (JsonProcessingException exception) { throw new BizException(ResultCode.BAD_REQUEST, "导出筛选条件无法序列化"); }
}
private ExportTaskResponse response(FileExportTask task) {
return new ExportTaskResponse(String.valueOf(task.getId()), task.getTaskNo(), task.getExportType(),
task.getFormat(), task.getTaskName(), task.getStatus(), task.getTotalRows(),
task.getProcessedRows() == null ? 0 : task.getProcessedRows(), task.getFileName(),
task.getContentType(), task.getSizeBytes(), task.getFailureMessage(),
String.valueOf(task.getRequestedBy()), task.getRequestedUsername(), task.getRequestedAt(),
task.getStartedAt(), task.getCompletedAt(), task.getExpiresAt());
}
private void requirePlatform(AuthenticatedUser user) {
if (user == null || user.userId() == null || user.tenantId() == null
|| !"PLATFORM".equalsIgnoreCase(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required");
}
}
private String normalize(String value) { return value == null || value.isBlank() ? null : value.trim().toUpperCase(Locale.ROOT); }
public record DownloadResource(org.springframework.core.io.Resource resource, String fileName, String contentType) { }
}
@@ -0,0 +1,75 @@
package com.baiyue.file.service;
import com.baiyue.file.entity.FileExportTask;
import com.baiyue.file.repository.ExportTaskRepository;
import com.baiyue.common.core.exception.BizException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class ExportTaskWorker {
private static final Logger LOGGER = LoggerFactory.getLogger(ExportTaskWorker.class);
private final ExportTaskRepository repository;
private final ExportTaskService service;
private final LocalExportStorage storage;
private final long retentionDays;
private final long staleRunningMinutes;
public ExportTaskWorker(ExportTaskRepository repository, ExportTaskService service,
LocalExportStorage storage,
@Value("${baiyue.file.export.retention-days:7}") long retentionDays,
@Value("${baiyue.file.export.stale-running-minutes:30}") long staleRunningMinutes) {
this.repository = repository;
this.service = service;
this.storage = storage;
this.retentionDays = Math.max(1, retentionDays);
this.staleRunningMinutes = Math.max(5, staleRunningMinutes);
}
@Scheduled(fixedDelayString = "${baiyue.file.export.worker-delay-ms:2000}")
public void poll() {
LocalDateTime now = LocalDateTime.now();
repository.requeueStale(now.minusMinutes(staleRunningMinutes));
expire(now);
processNext();
}
public boolean processNext() {
Long id = repository.firstPendingId();
if (id == null || !repository.claim(id, LocalDateTime.now())) return false;
FileExportTask task = repository.find(id);
try {
ExportTaskHandler handler = service.handler(task.getExportType());
var result = handler.export(task, rows -> repository.progress(id, rows));
LocalDateTime completed = LocalDateTime.now();
if (!repository.succeed(id, result.fileName(), result.objectKey(), result.contentType(),
result.sizeBytes(), result.rows(), completed, completed.plusDays(retentionDays))) {
storage.delete(result.objectKey());
throw new IllegalStateException("Export task state changed before completion");
}
} catch (Exception exception) {
String message = safeMessage(exception);
repository.fail(id, message, LocalDateTime.now());
LOGGER.error("导出任务执行失败,taskId={},exportType={}", id, task.getExportType(), exception);
}
return true;
}
private void expire(LocalDateTime now) {
for (FileExportTask task : repository.expiredArtifacts(now)) {
storage.delete(task.getObjectKey());
repository.markExpired(task.getId());
}
}
private String safeMessage(Exception exception) {
String message = exception instanceof BizException ? exception.getMessage() : "导出任务执行失败,请稍后重试";
if (message == null || message.isBlank()) message = "导出任务执行失败";
return message.length() <= 1000 ? message : message.substring(0, 1000);
}
}
@@ -0,0 +1,86 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.UUID;
@Service
public class LocalExportStorage {
private final Path root;
private final Path exportRoot;
public LocalExportStorage(@Value("${baiyue.file.storage-root}") String root) {
this.root = Path.of(root).toAbsolutePath().normalize();
this.exportRoot = this.root.resolve("exports").normalize();
}
@PostConstruct
void initialize() {
try {
Files.createDirectories(exportRoot);
if (!exportRoot.startsWith(root) || !Files.isDirectory(exportRoot) || !Files.isWritable(exportRoot)) {
throw new IOException("Export storage is not writable");
}
} catch (IOException exception) {
throw new IllegalStateException("导出文件存储目录不可用", exception);
}
}
public PendingFile create(long taskId) {
String objectKey = "exports/" + taskId + "/" + UUID.randomUUID() + ".xlsx";
Path target = resolve(objectKey);
try {
Files.createDirectories(target.getParent());
OutputStream output = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
return new PendingFile(objectKey, target, output);
} catch (IOException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件存储暂不可用");
}
}
public long size(String objectKey) {
try { return Files.size(resolve(objectKey)); }
catch (IOException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "导出文件读取失败"); }
}
public Resource load(String objectKey) {
Path target = resolve(objectKey);
if (!Files.isRegularFile(target)) throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理");
try { return new UrlResource(target.toUri()); }
catch (Exception exception) { throw new BizException(ResultCode.NOT_FOUND, "导出文件不存在或已清理"); }
}
public void delete(String objectKey) {
if (objectKey == null || objectKey.isBlank()) return;
Path target = resolve(objectKey);
try {
Files.deleteIfExists(target);
Path parent = target.getParent();
if (parent != null && parent.startsWith(exportRoot) && !parent.equals(exportRoot)) Files.deleteIfExists(parent);
} catch (IOException ignored) { }
}
private Path resolve(String objectKey) {
if (objectKey == null || !objectKey.matches("exports/\\d+/[0-9a-fA-F-]{36}\\.xlsx")) {
throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key");
}
Path target = root.resolve(objectKey).normalize();
if (!target.startsWith(exportRoot)) throw new BizException(ResultCode.BAD_REQUEST, "Invalid export object key");
return target;
}
public record PendingFile(String objectKey, Path path, OutputStream output) implements AutoCloseable {
@Override public void close() throws IOException { output.close(); }
}
}
+4
View File
@@ -33,6 +33,10 @@ baiyue:
url: ${IAM_SERVICE_URL:}
file:
storage-root: ${FILE_STORAGE_ROOT:${user.home}/baiyue-firstleg/uploads}
export:
retention-days: ${FILE_EXPORT_RETENTION_DAYS:7}
worker-delay-ms: ${FILE_EXPORT_WORKER_DELAY_MS:2000}
stale-running-minutes: ${FILE_EXPORT_STALE_RUNNING_MINUTES:30}
management.endpoints.web.exposure.include: health,info,prometheus
logging:
+36
View File
@@ -59,3 +59,39 @@ CREATE TABLE IF NOT EXISTS file_download_log (
KEY idx_download_log_file (tenant_id, file_id, downloaded_at),
KEY idx_download_log_user (tenant_id, user_id, downloaded_at)
) ENGINE=InnoDB COMMENT='文件下载审计日志表';
CREATE TABLE IF NOT EXISTS file_export_task (
id BIGINT UNSIGNED NOT NULL COMMENT '导出任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '申请人所属租户ID,平台任务为0',
app_code VARCHAR(24) NOT NULL COMMENT '申请端:PLATFORM或MERCHANT',
task_no VARCHAR(40) NOT NULL COMMENT '导出任务编号',
export_type VARCHAR(64) NOT NULL COMMENT '导出类型',
format VARCHAR(16) NOT NULL DEFAULT 'XLSX' COMMENT '导出格式',
task_name VARCHAR(128) NOT NULL COMMENT '任务名称',
parameters_json JSON NOT NULL COMMENT '筛选条件快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/RUNNING/SUCCEEDED/FAILED/EXPIRED',
total_rows BIGINT UNSIGNED NULL COMMENT '预计总行数',
processed_rows BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已处理行数',
file_name VARCHAR(255) NULL COMMENT '下载文件名',
object_key VARCHAR(512) NULL COMMENT '存储对象键',
content_type VARCHAR(128) NULL COMMENT '文件类型',
size_bytes BIGINT UNSIGNED NULL COMMENT '文件字节数',
failure_message VARCHAR(1000) NULL COMMENT '失败原因',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请用户ID',
requested_username VARCHAR(128) NULL COMMENT '申请账号快照',
requested_at DATETIME(3) NOT NULL COMMENT '申请时间',
started_at DATETIME(3) NULL COMMENT '开始时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
expires_at DATETIME(3) NULL COMMENT '文件过期时间',
created_by BIGINT UNSIGNED NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_file_export_task_no (task_no),
KEY idx_file_export_owner (tenant_id, app_code, requested_by, requested_at),
KEY idx_file_export_status (status, requested_at),
KEY idx_file_export_expiry (status, expires_at)
) ENGINE=InnoDB COMMENT='公共异步导出任务表';
@@ -0,0 +1,23 @@
package com.baiyue.file.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PlatformExportTaskControllerContractTest {
@Test
void exposesThePlatformFileRouteAndProtectsListingAndDownloads() throws Exception {
RequestMapping mapping = PlatformExportTaskController.class.getAnnotation(RequestMapping.class);
assertEquals("/api/platform/v1/files/export-tasks", mapping.value()[0]);
assertEquals("isAuthenticated()", PlatformExportTaskController.class
.getDeclaredMethod("create", AuthenticatedUser.class, CreateExportTaskRequest.class)
.getAnnotation(PreAuthorize.class).value());
assertEquals("hasAuthority('platform:download:list')", PlatformExportTaskController.class
.getDeclaredMethod("download", AuthenticatedUser.class, long.class)
.getAnnotation(PreAuthorize.class).value());
}
}
@@ -159,12 +159,33 @@ class QualificationFileContractTest {
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class,
() -> controller.upload(merchantUser(), file, null));
() -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false));
assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored);
}
@Test
void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient);
var file = new MockMultipartFile("file", "legal-person.png", "image/png", new byte[] {1, 2, 3});
var stored = storedFile();
var oldUrl = "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png";
when(storage.store(10001L, file)).thenReturn(stored);
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true);
var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class);
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
verify(storage).deleteByDocumentUrl(10001L, oldUrl);
assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType());
assertTrue(requestCaptor.getValue().replace());
}
private static PlatformQualificationFileController platformController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new PlatformQualificationFileController();
@@ -0,0 +1,55 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.file.client.IamExportClient;
import com.baiyue.file.entity.FileExportTask;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class CustomerExportTaskHandlerTest {
@TempDir Path tempDir;
@Test
void generatesARealXlsxFromIamBatchesAndReportsProgress() throws Exception {
IamExportClient client = mock(IamExportClient.class);
LocalExportStorage storage = new LocalExportStorage(tempDir.toString());
storage.initialize();
var row = new IamExportClient.CustomerExportRow(
"101", "TEN101", "柏悦物流", "柏悦物流有限公司", "91440300MA5ABCDE12",
"有限责任公司", null, null, "CNY", "深圳市", "张三", "13800138000",
"ops@example.com", "baiyue", "李四", "基础版", 30, "ACTIVE", null, null);
when(client.customers("internal-token", null, 500, "柏悦", "ACTIVE"))
.thenReturn(ApiResponse.success(new IamExportClient.CustomerExportBatch(List.of(row), 101L, false)));
CustomerExportTaskHandler handler = new CustomerExportTaskHandler(
client, storage, new ObjectMapper().findAndRegisterModules(), "internal-token");
FileExportTask task = new FileExportTask();
task.setId(9L);
task.setParametersJson("{\"keyword\":\"柏悦\",\"status\":\"ACTIVE\"}");
AtomicLong progress = new AtomicLong();
var result = handler.export(task, progress::set);
assertEquals(1L, result.rows());
assertEquals(1L, progress.get());
assertTrue(result.fileName().endsWith(".xlsx"));
Path workbookPath = tempDir.resolve(result.objectKey());
assertTrue(Files.isRegularFile(workbookPath));
try (XSSFWorkbook workbook = new XSSFWorkbook(Files.newInputStream(workbookPath))) {
assertEquals("客户ID", workbook.getSheetAt(0).getRow(0).getCell(0).getStringCellValue());
assertEquals("柏悦物流", workbook.getSheetAt(0).getRow(1).getCell(2).getStringCellValue());
assertEquals("13800138000", workbook.getSheetAt(0).getRow(1).getCell(11).getStringCellValue());
}
}
}
@@ -0,0 +1,65 @@
package com.baiyue.file.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.dto.ExportTaskDtos.CreateExportTaskRequest;
import com.baiyue.file.entity.FileExportTask;
import com.baiyue.file.repository.ExportTaskRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ExportTaskServiceTest {
@Test
void createsPersistedTaskOnlyWhenSourceExportPermissionIsPresent() {
ExportTaskRepository repository = mock(ExportTaskRepository.class);
ExportTaskHandler handler = mock(ExportTaskHandler.class);
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
when(handler.requiredPermission()).thenReturn("platform:customer:export");
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
new ObjectMapper(), List.of(handler));
doAnswer(invocation -> null).when(repository).insert(any());
var response = service.create(user(1L, Set.of("platform:customer:export")),
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of("status", "ACTIVE")));
assertEquals("PENDING", response.status());
assertEquals("CUSTOMER_LIST", response.exportType());
verify(repository).insert(any(FileExportTask.class));
BizException denied = assertThrows(BizException.class, () -> service.create(user(1L, Set.of()),
new CreateExportTaskRequest("CUSTOMER_LIST", "XLSX", "客户列表", Map.of())));
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
}
@Test
void refusesAnotherUsersTaskUnlessDownloadAllIsGranted() {
ExportTaskRepository repository = mock(ExportTaskRepository.class);
ExportTaskHandler handler = mock(ExportTaskHandler.class);
when(handler.exportType()).thenReturn("CUSTOMER_LIST");
ExportTaskService service = new ExportTaskService(repository, mock(LocalExportStorage.class),
new ObjectMapper(), List.of(handler));
FileExportTask task = new FileExportTask();
task.setId(9L); task.setTenantId(0L); task.setAppCode("PLATFORM"); task.setRequestedBy(2L);
when(repository.find(9L)).thenReturn(task);
BizException denied = assertThrows(BizException.class, () -> service.get(user(1L, Set.of()), 9L));
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
assertEquals("9", service.get(user(1L, Set.of("platform:download:all")), 9L).id());
}
private AuthenticatedUser user(long id, Set<String> permissions) {
return new AuthenticatedUser(id, 0L, 0L, id, "user" + id, "PLATFORM", "WEB", permissions);
}
}
@@ -0,0 +1,60 @@
package com.baiyue.file.service;
import com.baiyue.file.entity.FileExportTask;
import com.baiyue.file.repository.ExportTaskRepository;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ExportTaskWorkerTest {
@Test
void onlyExecutesATaskAfterTheAtomicClaimSucceeds() {
ExportTaskRepository repository = mock(ExportTaskRepository.class);
ExportTaskService service = mock(ExportTaskService.class);
LocalExportStorage storage = mock(LocalExportStorage.class);
ExportTaskHandler handler = mock(ExportTaskHandler.class);
FileExportTask task = new FileExportTask();
task.setId(9L); task.setExportType("CUSTOMER_LIST");
when(repository.firstPendingId()).thenReturn(9L);
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(true);
when(repository.find(9L)).thenReturn(task);
when(service.handler("CUSTOMER_LIST")).thenReturn(handler);
when(handler.export(eq(task), any())).thenAnswer(invocation -> {
ExportTaskHandler.ProgressReporter progress = invocation.getArgument(1);
progress.update(12L);
return new ExportTaskHandler.ExportResult("客户列表.xlsx", "exports/9/file.xlsx",
CustomerExportTaskHandler.CONTENT_TYPE, 1024L, 12L);
});
when(repository.succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(true);
ExportTaskWorker worker = new ExportTaskWorker(repository, service, storage, 7, 30);
worker.processNext();
verify(repository).progress(9L, 12L);
verify(repository).succeed(eq(9L), eq("客户列表.xlsx"), eq("exports/9/file.xlsx"),
eq(CustomerExportTaskHandler.CONTENT_TYPE), eq(1024L), eq(12L),
any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void skipsAContendedTaskThatAnotherWorkerClaimed() {
ExportTaskRepository repository = mock(ExportTaskRepository.class);
when(repository.firstPendingId()).thenReturn(9L);
when(repository.claim(eq(9L), any(LocalDateTime.class))).thenReturn(false);
ExportTaskWorker worker = new ExportTaskWorker(repository, mock(ExportTaskService.class),
mock(LocalExportStorage.class), 7, 30);
worker.processNext();
verify(repository, never()).find(9L);
}
}