优化
This commit is contained in:
@@ -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(); }
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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='公共异步导出任务表';
|
||||
|
||||
+23
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user