Compare commits
15 Commits
c3d43aa26a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bca1335dc6 | ||
|
|
ba48bbc6af | ||
|
|
cbde22ec79 | ||
|
|
69784b8d32 | ||
|
|
8543dad514 | ||
|
|
8a9126e626 | ||
|
|
d8ca8de537 | ||
|
|
aab4ce942c | ||
|
|
0ecf5cd45a | ||
|
|
021b0c618b | ||
|
|
f44110fae5 | ||
|
|
ac19637ad6 | ||
|
|
8672668c33 | ||
|
|
17236265c6 | ||
|
|
c9efdf8b0e |
@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@@ -70,6 +71,12 @@ public class GlobalExceptionHandler {
|
||||
log.debug("Client disconnected before response completed: {}", ex.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleResponseStatusException(ResponseStatusException ex) {
|
||||
String message = ex.getReason() == null || ex.getReason().isBlank() ? ex.getMessage() : ex.getReason();
|
||||
return ResponseEntity.status(ex.getStatusCode()).body(ApiResponse.fail(message));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ApiResponse<Void> handleException(Exception ex) {
|
||||
if (isClientAbort(ex)) {
|
||||
|
||||
@@ -39,4 +39,9 @@ public class DeleteBrandProgressProperties {
|
||||
* Query ASIN RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||
*/
|
||||
private long queryAsinStaleTimeoutMinutes = 30;
|
||||
|
||||
/**
|
||||
* Withdraw RUNNING timeout auto-finalize/fail threshold in minutes.
|
||||
*/
|
||||
private long withdrawStaleTimeoutMinutes = 30;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "aiimage.image-video")
|
||||
public class ImageVideoProperties {
|
||||
private String cozeBaseUrl = "https://api.coze.cn";
|
||||
private String cozeToken = "";
|
||||
private String cozeWorkflowPath = "/v1/workflow/run";
|
||||
private String cozeWorkflowHistoryPath = "/v1/workflows/{workflow_id}/run_histories/{execute_id}";
|
||||
private String douyinCopyWorkflowId = "7652941112982798388";
|
||||
private String imageVideoWorkflowId = "7659086169628377124";
|
||||
private String voiceDeleteWorkflowId = "7652954386453299243";
|
||||
private String voiceListWorkflowId = "7652954332346089498";
|
||||
private String voiceCloneWorkflowId = "7652954356887961641";
|
||||
private String voiceSynthesisWorkflowId = "7652954297530105894";
|
||||
private int cozeConnectTimeoutMillis = 10000;
|
||||
private int cozeReadTimeoutMillis = 3600000;
|
||||
private int archiveConnectTimeoutMillis = 10000;
|
||||
private int archiveReadTimeoutMillis = 600000;
|
||||
private int archiveMaxAttempts = 3;
|
||||
}
|
||||
@@ -11,5 +11,6 @@ import java.util.List;
|
||||
public class ModuleCleanupProperties {
|
||||
private boolean enabled = true;
|
||||
private String cron = "0 0 0 * * *";
|
||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN"));
|
||||
private long retentionDays = 7;
|
||||
private List<String> moduleTypes = new ArrayList<>(List.of("DEDUPE", "SPLIT", "CONVERT", "DELETE_BRAND", "PRODUCT_RISK_RESOLVE", "PRICE_TRACK", "SHOP_MATCH", "PATROL_DELETE", "QUERY_ASIN", "WITHDRAW", "APPEARANCE_PATENT", "SIMILAR_ASIN", "COLLECT_DATA"));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
public class OssProperties {
|
||||
private String region;
|
||||
private String endpoint;
|
||||
private String publicEndpoint;
|
||||
private String bucket;
|
||||
private String imageVideoBucket;
|
||||
private String accessKeyId;
|
||||
private String accessKeySecret;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, InstanceRoutingProperties.class})
|
||||
@EnableConfigurationProperties({OssProperties.class, TransientStorageProperties.class, StorageProperties.class, BrandProgressProperties.class, DeleteBrandProgressProperties.class, BrandCheckProperties.class, ZiniaoProperties.class, ModuleCleanupProperties.class, TaskPressureProperties.class, TaskImageCacheCleanupProperties.class, AppearancePatentProperties.class, SimilarAsinProperties.class, ImageVideoProperties.class, InstanceRoutingProperties.class})
|
||||
public class PropertiesConfig {
|
||||
}
|
||||
|
||||
@@ -675,6 +675,8 @@ public class AppearancePatentCozeClient {
|
||||
row.setAsin(source.getAsin());
|
||||
row.setCountry(source.getCountry());
|
||||
row.setSku(source.getSku());
|
||||
row.setBrand(source.getBrand());
|
||||
row.setPrice(source.getPrice());
|
||||
row.setUrl(source.getUrl());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
|
||||
@@ -87,7 +87,7 @@ public class AppearancePatentController {
|
||||
public ApiResponse<AppearancePatentHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 50, max 100", example = "50")
|
||||
@Parameter(description = "历史记录条数,默认 50,最大 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@@ -33,6 +33,14 @@ public class AppearancePatentResultRowDto {
|
||||
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
||||
private String sku;
|
||||
|
||||
@Schema(description = "Python 采集到的商品品牌。导出时优先于源 Excel 中的品牌。", example = "Nike")
|
||||
@JsonAlias({"Brand", "品牌"})
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "Python 采集并通过结果接口回传的商品价格。最终导出不再使用源 Excel 价格。", example = "12.99")
|
||||
@JsonAlias({"Price", "价格"})
|
||||
private String price;
|
||||
|
||||
@Schema(description = "商品主图或待检测图片 URL。Java 调用 Coze 时会放入 url_list。", example = "https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg")
|
||||
@JsonAlias({
|
||||
"imageUrl", "image_url", "imgUrl", "img_url", "pictureUrl", "picture_url",
|
||||
@@ -54,7 +62,7 @@ public class AppearancePatentResultRowDto {
|
||||
private Boolean done;
|
||||
|
||||
@JsonAlias({"row_status", "rowStatus", "Status"})
|
||||
@Schema(description = "Coze row status", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String status;
|
||||
|
||||
@Schema(description = "Java 调用 Coze 后生成的标题维度检测结果,对应最终 xlsx 的“标题维度(商标)”列。Python 回传请求中不要传该字段;即使传入,后端也会以 Java/Coze 处理结果为准。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@@ -71,15 +79,15 @@ public class AppearancePatentResultRowDto {
|
||||
private String conclusion;
|
||||
|
||||
@JsonAlias({"title_reason", "titleReason"})
|
||||
@Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String titleReason;
|
||||
|
||||
@JsonAlias({"appearance_reason", "appearanceReason"})
|
||||
@Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String appearanceReason;
|
||||
|
||||
@JsonAlias({"patent_reason", "patentReason", "patent reason"})
|
||||
@Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
@Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY)
|
||||
private String patentReason;
|
||||
|
||||
@JsonAlias({"score", "Score", "评分"})
|
||||
|
||||
@@ -14,7 +14,7 @@ public class AppearancePatentHistoryItemVo {
|
||||
private String sourceFilename;
|
||||
@Schema(description = "最终结果文件名。任务完成并生成 xlsx 后返回。", example = "17-result.xlsx")
|
||||
private String resultFilename;
|
||||
@Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的新鲜预签名 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/appearance_patent/xxx/17-result.xlsx?Expires=...")
|
||||
@Schema(description = "最终结果文件下载地址。后端基于 OSS objectKey 生成的公开直链 URL。", example = "https://bucket.oss-cn-hangzhou.aliyuncs.com/result/appearance_patent/xxx/17-result.xlsx")
|
||||
private String downloadUrl;
|
||||
private Long fileJobId;
|
||||
private String fileStatus;
|
||||
|
||||
@@ -1036,7 +1036,12 @@ public class AppearancePatentTaskService {
|
||||
}
|
||||
Map<String, AppearancePatentResultRowDto> persistedRows = readChunkRows(chunk);
|
||||
for (AppearancePatentResultRowDto row : rows) {
|
||||
persistedRows.put(rowKey(row), row);
|
||||
String key = rowKey(row);
|
||||
AppearancePatentResultRowDto submittedRow = persistedRows.get(key);
|
||||
if (submittedRow != null) {
|
||||
row.setPrice(submittedRow.getPrice());
|
||||
}
|
||||
persistedRows.put(key, row);
|
||||
}
|
||||
String payloadJson = writeJson(new ArrayList<>(persistedRows.values()), "appearance patent chunk payload merge failed");
|
||||
String oldPayload = chunk.getPayloadJson();
|
||||
@@ -1268,6 +1273,7 @@ public class AppearancePatentTaskService {
|
||||
row.setAsin(sibling.getAsin());
|
||||
row.setCountry(sibling.getCountry());
|
||||
row.setSku(firstNonBlank(representative.getSku(), sibling.getSku()));
|
||||
row.setBrand(representative.getBrand());
|
||||
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
||||
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
||||
row.setError(representative.getError());
|
||||
@@ -3326,8 +3332,8 @@ public class AppearancePatentTaskService {
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), ""));
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), ""));
|
||||
row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name"));
|
||||
row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "品牌", "brand"));
|
||||
row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getPrice(), ""));
|
||||
row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow));
|
||||
row.createCell(col++).setCellValue(resolvePrice(resultRow));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl()));
|
||||
row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku()));
|
||||
@@ -4172,6 +4178,27 @@ public class AppearancePatentTaskService {
|
||||
return "";
|
||||
}
|
||||
|
||||
static String resolveBrand(AppearancePatentResultRowDto resultRow, AppearancePatentParsedRowVo parsedRow) {
|
||||
String pythonBrand = resultRow == null ? "" : resultRow.getBrand();
|
||||
if (pythonBrand != null && !pythonBrand.isBlank()) {
|
||||
return pythonBrand.trim();
|
||||
}
|
||||
if (parsedRow == null || parsedRow.getValues() == null) {
|
||||
return "";
|
||||
}
|
||||
for (Map.Entry<String, String> entry : parsedRow.getValues().entrySet()) {
|
||||
String header = entry.getKey() == null ? "" : entry.getKey().trim().toLowerCase(Locale.ROOT);
|
||||
if (header.contains("品牌") || header.contains("brand")) {
|
||||
return entry.getValue() == null ? "" : entry.getValue();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static String resolvePrice(AppearancePatentResultRowDto resultRow) {
|
||||
return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim();
|
||||
}
|
||||
|
||||
private boolean hasInfringingPersistedConclusion(Long taskId) {
|
||||
if (taskId == null) {
|
||||
return false;
|
||||
|
||||
@@ -377,7 +377,7 @@ public class BrandTaskService {
|
||||
if (stored == null || stored.isBlank()) {
|
||||
throw new BusinessException("无结果可下载");
|
||||
}
|
||||
// 统一通过 OssStorageService.generateFreshDownloadUrl 生成新鲜预签名 URL
|
||||
// 统一通过 OssStorageService.generateFreshDownloadUrl 生成公开 OSS 直链
|
||||
return ossStorageService.generateFreshDownloadUrl(stored);
|
||||
}
|
||||
|
||||
@@ -1037,7 +1037,7 @@ public class BrandTaskService {
|
||||
}
|
||||
List<String> fullUrls = new ArrayList<>();
|
||||
for (OutputEntry entry : entries) {
|
||||
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取并重新签名(见 OssStorageService.resolveObjectKey)
|
||||
// 存储完整公开地址,下载时会自动通过 resolveObjectKey 提取 objectKey(见 OssStorageService.resolveObjectKey)
|
||||
String objectKey = ossStorageService.uploadResultFile(entry.resultFile(), MODULE_TYPE);
|
||||
fullUrls.add(ossStorageService.getPublicUrl(objectKey));
|
||||
}
|
||||
|
||||
@@ -38,4 +38,8 @@ public class CollectDataSubmitResultRequest {
|
||||
@JsonAlias({"rows", "data", "items"})
|
||||
@Schema(description = "本分片回传数据行")
|
||||
private List<CollectDataSubmitRowDto> items = new ArrayList<>();
|
||||
|
||||
@JsonAlias({"summary", "summary_rows", "汇总"})
|
||||
@Schema(description = "Python 端按关键词聚合的配送方式 / 页数统计;通常仅在 done=true 时携带,空数组等同于不携带,Java 回退到自聚合 rawItems。")
|
||||
private List<CollectDataSummaryRowDto> summaries = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ public class CollectDataSubmitRowDto {
|
||||
private String brand;
|
||||
|
||||
@JsonAlias({"ASIN", "asin"})
|
||||
@Schema(description = "ASIN", example = "B0XXXXXXX")
|
||||
@Schema(description = "商品 ASIN", example = "B0XXXXXXX")
|
||||
private String asin;
|
||||
|
||||
@JsonAlias({"价格", "price"})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@Schema(description = "采集数据 Python 关键词级聚合统计行,通常仅在 done=true 时携带")
|
||||
public class CollectDataSummaryRowDto {
|
||||
|
||||
/**
|
||||
* 关键词原文。
|
||||
*/
|
||||
@JsonAlias({"关键词", "key_word", "key word"})
|
||||
@Schema(description = "关键词原文", example = "phone case")
|
||||
private String keyword;
|
||||
|
||||
/**
|
||||
* 该关键词命中 FBA 配送方式的行数。
|
||||
*/
|
||||
@Schema(description = "FBA 行数", example = "12")
|
||||
private Integer fba;
|
||||
|
||||
/**
|
||||
* 该关键词命中 FBM 配送方式的行数。
|
||||
*/
|
||||
@Schema(description = "FBM 行数", example = "8")
|
||||
private Integer fbm;
|
||||
|
||||
/**
|
||||
* 该关键词命中 AMZ 配送方式的行数。
|
||||
*/
|
||||
@Schema(description = "AMZ 行数", example = "5")
|
||||
private Integer amz;
|
||||
|
||||
/**
|
||||
* 该关键词「无配送方式」(未识别)行数。
|
||||
*/
|
||||
@JsonProperty("none_count")
|
||||
@JsonAlias({"none", "unknown", "无配送方式", "noneCount"})
|
||||
@Schema(description = "无配送方式(未识别 FBA/FBM/AMZ)行数", example = "3")
|
||||
private Integer noneCount;
|
||||
|
||||
/**
|
||||
* 该关键词所有命中行的最大页码,由 Python 抓取端汇总。
|
||||
*/
|
||||
@JsonProperty("total_page")
|
||||
@JsonAlias({"page", "totalPage", "页数", "max_page", "maxPage"})
|
||||
@Schema(description = "该关键词总页数(Python 端取所有命中行的最大页码)", example = "10")
|
||||
private Integer totalPage;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.collectdata.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataResultRowVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
@@ -30,14 +31,18 @@ public class CollectDataExcelAssemblyService {
|
||||
*
|
||||
* @param outputXlsx 目标文件
|
||||
* @param items 经过 ASIN 去重 / 无效品牌过滤 / 品牌检测后保留下来的明细行,写入「采集数据结果」sheet
|
||||
* @param rawItems Python 回传的全量原始行(不经后端任何过滤),写入「结果文件」sheet 的关键词聚合统计
|
||||
* @param summaries Python 在 done=true 时携带的关键词级聚合统计;非空时优先使用,直接落「结果文件」sheet
|
||||
* @param rawItems Python 回传的全量原始行(不经后端任何过滤);仅在 summaries 为空时作为 fallback 自聚合
|
||||
*/
|
||||
public void writeWorkbook(File outputXlsx, List<CollectDataResultRowVo> items, List<CollectDataResultRowVo> rawItems) {
|
||||
public void writeWorkbook(File outputXlsx,
|
||||
List<CollectDataResultRowVo> items,
|
||||
List<CollectDataSummaryRowDto> summaries,
|
||||
List<CollectDataResultRowVo> rawItems) {
|
||||
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
|
||||
workbook.setCompressTempFiles(true);
|
||||
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
||||
writeDetailSheet(workbook, items);
|
||||
writeSummarySheet(workbook, rawItems);
|
||||
writeSummarySheet(workbook, summaries, rawItems);
|
||||
workbook.write(outputStream);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] write workbook failed: {}", ex.getMessage());
|
||||
@@ -76,11 +81,14 @@ public class CollectDataExcelAssemblyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 「结果文件」sheet 基于 Python 回传的全量原始数据按关键词聚合:
|
||||
* - FBA / FBM / AMZ / 无配送方式 列为各分类的命中行数;
|
||||
* - 页数列取该关键词所有原始行中页码的最大值,反映 Python 实际抓取到的总页数。
|
||||
* 「结果文件」sheet 的关键词级聚合:
|
||||
* - 优先使用 Python 在 done=true 时携带的 summaries,避免 Java 端二次聚合带来的口径漂移;
|
||||
* - 当 summaries 为空(Python 端尚未接入或解析失败)时,回退到基于 rawItems 自聚合,
|
||||
* 保证 Excel 不会因迁移过渡期而出现空表。等 Python 端全部接入并稳定后,可移除 fallback 分支。
|
||||
*/
|
||||
private void writeSummarySheet(SXSSFWorkbook workbook, List<CollectDataResultRowVo> rawItems) {
|
||||
private void writeSummarySheet(SXSSFWorkbook workbook,
|
||||
List<CollectDataSummaryRowDto> summaries,
|
||||
List<CollectDataResultRowVo> rawItems) {
|
||||
Sheet sheet = workbook.createSheet(SHEET_SUMMARY_NAME);
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int i = 0; i < SHEET_SUMMARY_HEADER.length; i++) {
|
||||
@@ -88,6 +96,30 @@ public class CollectDataExcelAssemblyService {
|
||||
sheet.setColumnWidth(i, (i == 0 ? 28 : 12) * 256);
|
||||
}
|
||||
|
||||
if (summaries != null && !summaries.isEmpty()) {
|
||||
// 优先分支:Python 携带的关键词级聚合直接落表,null 字段统一兜底为 0;页数 ≤0 写空字符串。
|
||||
int rowIndex = 1;
|
||||
for (CollectDataSummaryRowDto item : summaries) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
row.createCell(0).setCellValue(safe(item.getKeyword()));
|
||||
row.createCell(1).setCellValue(intOrZero(item.getFba()));
|
||||
row.createCell(2).setCellValue(intOrZero(item.getFbm()));
|
||||
row.createCell(3).setCellValue(intOrZero(item.getAmz()));
|
||||
row.createCell(4).setCellValue(intOrZero(item.getNoneCount()));
|
||||
Integer totalPage = item.getTotalPage();
|
||||
if (totalPage != null && totalPage > 0) {
|
||||
row.createCell(5).setCellValue(totalPage);
|
||||
} else {
|
||||
row.createCell(5).setCellValue("");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback 分支:基于 rawItems 自聚合(Python 端未接入时使用)。
|
||||
Map<String, KeywordSummary> grouped = new LinkedHashMap<>();
|
||||
if (rawItems != null) {
|
||||
for (CollectDataResultRowVo item : rawItems) {
|
||||
@@ -131,6 +163,10 @@ public class CollectDataExcelAssemblyService {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
private int intOrZero(Integer value) {
|
||||
return value == null ? 0 : value;
|
||||
}
|
||||
|
||||
private static class KeywordSummary {
|
||||
int fba;
|
||||
int fbm;
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSourceFileDto;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSubmitRowDto;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataSummaryRowDto;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
|
||||
@@ -454,6 +455,11 @@ public class CollectDataService {
|
||||
persistScope(taskId, scopeKey, scopeHash, chunkTotal, request);
|
||||
|
||||
stats.finalRowCount = countFinalRows(taskId);
|
||||
// Python 在 done=true 那次回传携带关键词级聚合统计;非空时按"最后一次为准"覆盖。
|
||||
List<CollectDataSummaryRowDto> incomingSummaries = request.getSummaries();
|
||||
if (incomingSummaries != null && !incomingSummaries.isEmpty()) {
|
||||
stats.summaries = new ArrayList<>(incomingSummaries);
|
||||
}
|
||||
persistStats(task, stats);
|
||||
|
||||
if (request.getError() != null && !request.getError().isBlank()) {
|
||||
@@ -812,6 +818,8 @@ public class CollectDataService {
|
||||
if (result == null || !MODULE_TYPE.equals(result.getModuleType())) {
|
||||
throw new BusinessException("结果记录不存在");
|
||||
}
|
||||
// 先加载 stats,使 Python 携带的 summaries 可优先用于「结果文件」sheet;rawRows 仅作为 fallback。
|
||||
CollectDataStats stats = loadStats(task);
|
||||
List<CollectDataResultRowVo> rows = loadFinalRows(task.getId());
|
||||
// Sheet「结果文件」按需求基于 Python 回传的全量数据聚合,不经后端 ASIN/品牌过滤丢弃,
|
||||
// 因此从 biz_task_chunk 反序列化全部原始行。
|
||||
@@ -820,7 +828,7 @@ public class CollectDataService {
|
||||
String filename = buildResultFilename(task, result);
|
||||
File xlsx = FileUtil.file(workRoot, filename);
|
||||
try {
|
||||
excelAssemblyService.writeWorkbook(xlsx, rows, rawRows);
|
||||
excelAssemblyService.writeWorkbook(xlsx, rows, stats.summaries, rawRows);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
result.setResultFilename(filename);
|
||||
result.setResultFileUrl(objectKey);
|
||||
@@ -831,7 +839,7 @@ public class CollectDataService {
|
||||
result.setErrorMessage(null);
|
||||
fileResultMapper.updateById(result);
|
||||
|
||||
CollectDataStats stats = loadStats(task);
|
||||
// 复用同一份 stats 更新 finalRowCount 后再持久化,避免重复 loadStats 丢失 summaries。
|
||||
stats.finalRowCount = rows.size();
|
||||
persistStats(task, stats);
|
||||
task.setStatus(STATUS_SUCCESS);
|
||||
@@ -998,6 +1006,16 @@ public class CollectDataService {
|
||||
stats.brandRejectedCount = root.path("brandRejectedCount").asInt(0);
|
||||
stats.brandQueryFailedCount = root.path("brandQueryFailedCount").asInt(0);
|
||||
stats.finalRowCount = root.path("finalRowCount").asInt(0);
|
||||
// 反序列化 Python 携带的关键词级聚合;解析失败保持空 list,仅 warn 不抛,避免影响其他统计
|
||||
JsonNode summariesNode = root.get("summaries");
|
||||
if (summariesNode != null && summariesNode.isArray()) {
|
||||
try {
|
||||
stats.summaries = objectMapper.convertValue(summariesNode,
|
||||
new TypeReference<List<CollectDataSummaryRowDto>>() {});
|
||||
} catch (Exception convertEx) {
|
||||
log.warn("[collect-data] parse summaries failed taskId={} err={}", task.getId(), convertEx.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] parse result stats failed taskId={} err={}", task.getId(), ex.getMessage());
|
||||
}
|
||||
@@ -1017,6 +1035,8 @@ public class CollectDataService {
|
||||
payload.put("brandRejectedCount", stats.brandRejectedCount);
|
||||
payload.put("brandQueryFailedCount", stats.brandQueryFailedCount);
|
||||
payload.put("finalRowCount", stats.finalRowCount);
|
||||
// 持久化 Python 携带的关键词级聚合;后续 processResultFileJob 阶段读取用于落 Excel
|
||||
payload.put("summaries", stats.summaries == null ? List.of() : stats.summaries);
|
||||
task.setResultJson(objectMapper.writeValueAsString(payload));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("保存采集统计失败");
|
||||
@@ -1129,6 +1149,8 @@ public class CollectDataService {
|
||||
private int brandRejectedCount;
|
||||
private int brandQueryFailedCount;
|
||||
private int finalRowCount;
|
||||
// Python 在 done=true 时携带的关键词级聚合统计;空表示由 Java 端用 rawItems 自聚合兜底
|
||||
private List<CollectDataSummaryRowDto> summaries = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -1359,9 +1381,11 @@ public class CollectDataService {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ossStorageService.generateFreshDownloadUrl(resultFileUrl);
|
||||
// bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL,避免签名 1 小时过期后下载 403。
|
||||
// resolveObjectKey 兼容历史存量的旧格式(带签名的完整 URL),统一还原为纯 objectKey 再拼公开 URL。
|
||||
return ossStorageService.getPublicUrl(ossStorageService.resolveObjectKey(resultFileUrl));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] generate fresh download url failed resultId={} err={}",
|
||||
log.warn("[collect-data] build public download url failed resultId={} err={}",
|
||||
row.getId(), ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -339,6 +339,10 @@ public class ConvertRunService {
|
||||
lineValues.add(batchId + "-" + rowIndex[0]);
|
||||
continue;
|
||||
}
|
||||
if ("leadtime-to-ship".equals(column)) {
|
||||
lineValues.add("5");
|
||||
continue;
|
||||
}
|
||||
if (fieldMapping.containsKey(column)) {
|
||||
String sourceColumn = fieldMapping.get(column);
|
||||
Integer sourceIndex = resolvedHeaderMap.get(sourceColumn);
|
||||
|
||||
@@ -52,7 +52,7 @@ public class ConvertTemplateService {
|
||||
entity.setRequiredSourceColumnsJson("[\"ASIN\",\"价格\"]");
|
||||
entity.setHeaderColumnsJson("[\"sku\",\"price\",\"quantity\",\"product-id\",\"product-id-type\",\"condition-type\",\"condition-note\",\"ASIN-hint\",\"title\",\"product-tax-code\",\"operation-type\",\"sale-price\",\"sale-start-date\",\"sale-end-date\",\"leadtime-to-ship\",\"launch-date\",\"is-giftwrap-available\",\"is-gift-message-available\"]");
|
||||
entity.setFieldMappingJson("{\"price\":\"价格\",\"product-id\":\"ASIN\"}");
|
||||
entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"4\"}");
|
||||
entity.setDefaultsJson("{\"quantity\":\"100\",\"product-id-type\":\"ASIN\",\"condition-type\":\"new\",\"leadtime-to-ship\":\"5\"}");
|
||||
entity.setPreambleLinesJson("[\"TemplateType=Offer\\tVersion=1.4\"]");
|
||||
entity.setBlankHeaderRowsAfterSchema(1);
|
||||
entity.setIsDefault(0);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.dedupe.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataCreateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.dto.DedupeTotalDataUpdateRequest;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportProgressVo;
|
||||
@@ -17,6 +18,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
@@ -28,12 +33,18 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/dedupe-total-data")
|
||||
@Tag(name = "数据去重总数据", description = "维护数据去重模块的总数据列表,支持增删改查。")
|
||||
public class DedupeTotalDataController {
|
||||
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||
|
||||
private final DedupeTotalDataService dedupeTotalDataService;
|
||||
|
||||
@GetMapping
|
||||
@@ -44,8 +55,28 @@ public class DedupeTotalDataController {
|
||||
public ApiResponse<DedupeTotalDataPageVo> page(
|
||||
@Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page,
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword) {
|
||||
return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword));
|
||||
@Parameter(description = "数据值模糊搜索关键字") @RequestParam(required = false) String keyword,
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
return ApiResponse.success(dedupeTotalDataService.page(page, pageSize, keyword, username, operatorId));
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据。")
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username,
|
||||
@Parameter(description = "开始日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate,
|
||||
@Parameter(description = "结束日期(包含)")
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
byte[] bytes = dedupeTotalDataService.export(username, startDate, endDate, operatorId);
|
||||
String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename))
|
||||
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
|
||||
.contentLength(bytes.length)
|
||||
.body(bytes);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@@ -54,8 +85,10 @@ public class DedupeTotalDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = DedupeTotalDataItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataItemVo> create(@Valid @RequestBody DedupeTotalDataCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", dedupeTotalDataService.create(request));
|
||||
public ApiResponse<DedupeTotalDataItemVo> create(
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Valid @RequestBody DedupeTotalDataCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", dedupeTotalDataService.create(request, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@@ -65,14 +98,17 @@ public class DedupeTotalDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataImportStartVo> importExcel(
|
||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file));
|
||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
return ApiResponse.success("开始导入", dedupeTotalDataService.startImport(file, operatorId));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "查询导入进度", description = "根据导入任务 ID 查询当前进度。")
|
||||
public ApiResponse<DedupeTotalDataImportProgressVo> importProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId));
|
||||
public ApiResponse<DedupeTotalDataImportProgressVo> importProgress(
|
||||
@PathVariable String importId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
return ApiResponse.success(dedupeTotalDataService.getImportProgress(importId, operatorId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@@ -82,14 +118,17 @@ public class DedupeTotalDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "文件不合法或缺少 ASIN 列")
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataImportStartVo> deleteImportExcel(
|
||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file));
|
||||
@Parameter(description = "xlsx 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
return ApiResponse.success("开始删除", dedupeTotalDataService.startDeleteImport(file, operatorId));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "查询删除导入进度", description = "根据删除导入任务 ID 查询当前进度。")
|
||||
public ApiResponse<DedupeTotalDataImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
||||
return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId));
|
||||
public ApiResponse<DedupeTotalDataImportProgressVo> deleteImportProgress(
|
||||
@PathVariable String importId,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
return ApiResponse.success(dedupeTotalDataService.getDeleteImportProgress(importId, operatorId));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@@ -101,8 +140,9 @@ public class DedupeTotalDataController {
|
||||
})
|
||||
public ApiResponse<DedupeTotalDataItemVo> update(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId,
|
||||
@Valid @RequestBody DedupeTotalDataUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request));
|
||||
return ApiResponse.success("更新成功", dedupeTotalDataService.update(id, request, operatorId));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@@ -111,8 +151,10 @@ public class DedupeTotalDataController {
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "删除成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
||||
})
|
||||
public ApiResponse<Void> delete(@Parameter(description = "主键ID", required = true) @PathVariable Long id) {
|
||||
dedupeTotalDataService.delete(id);
|
||||
public ApiResponse<Void> delete(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam Long operatorId) {
|
||||
dedupeTotalDataService.delete(id, operatorId);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public class DedupeTotalDataEntity {
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String dataValue;
|
||||
private Long uploaderUserId;
|
||||
private String uploaderUsername;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ public class DedupeTotalDataItemVo {
|
||||
@Schema(description = "总数据值")
|
||||
private String dataValue;
|
||||
|
||||
@Schema(description = "上传用户ID")
|
||||
private Long uploaderUserId;
|
||||
|
||||
@Schema(description = "上传用户名")
|
||||
private String username;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -12,27 +12,39 @@ import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportStartVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataImportVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataItemVo;
|
||||
import com.nanri.aiimage.modules.dedupe.model.vo.DedupeTotalDataPageVo;
|
||||
import com.nanri.aiimage.modules.permission.mapper.AdminUserMapper;
|
||||
import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageGroupMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.DataFormatter;
|
||||
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.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
@@ -42,11 +54,19 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class DedupeTotalDataService {
|
||||
|
||||
private static final int COMPARE_BATCH_SIZE = 5000;
|
||||
private static final long COMPLETED_PROGRESS_RETENTION_MILLIS = 60 * 60 * 1000L;
|
||||
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final DedupeTotalDataMapper dedupeTotalDataMapper;
|
||||
private final AdminUserMapper adminUserMapper;
|
||||
private final ShopManageGroupMapper shopManageGroupMapper;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
private final Map<String, DedupeTotalDataImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, DedupeTotalDataImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> importOwnerMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> deleteImportOwnerMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> importCompletedAtMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, Long> deleteImportCompletedAtMap = new ConcurrentHashMap<>();
|
||||
|
||||
private TransactionTemplate newRequiresNewTemplate() {
|
||||
TransactionTemplate template = new TransactionTemplate(transactionManager);
|
||||
@@ -54,12 +74,16 @@ public class DedupeTotalDataService {
|
||||
return template;
|
||||
}
|
||||
|
||||
public DedupeTotalDataPageVo page(long page, long pageSize, String keyword) {
|
||||
public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, Long operatorId) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
String safeKeyword = keyword == null ? "" : keyword.trim();
|
||||
String safeUsername = username == null ? "" : username.trim();
|
||||
AccessScope scope = resolveAccessScope(operatorId);
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword)
|
||||
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())
|
||||
.like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername)
|
||||
.orderByDesc(DedupeTotalDataEntity::getId);
|
||||
Long total = dedupeTotalDataMapper.selectCount(query);
|
||||
List<DedupeTotalDataItemVo> items = dedupeTotalDataMapper.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
||||
@@ -74,12 +98,66 @@ public class DedupeTotalDataService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
public byte[] export(String username, LocalDate startDate, LocalDate endDate, Long operatorId) {
|
||||
if (startDate != null && endDate != null && startDate.isAfter(endDate)) {
|
||||
throw new BusinessException("开始日期不能晚于结束日期");
|
||||
}
|
||||
String safeUsername = username == null ? "" : username.trim();
|
||||
AccessScope scope = resolveAccessScope(operatorId);
|
||||
LambdaQueryWrapper<DedupeTotalDataEntity> query = new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds())
|
||||
.like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername)
|
||||
.ge(startDate != null, DedupeTotalDataEntity::getCreatedAt,
|
||||
startDate == null ? null : startDate.atStartOfDay())
|
||||
.lt(endDate != null, DedupeTotalDataEntity::getCreatedAt,
|
||||
endDate == null ? null : endDate.plusDays(1).atStartOfDay())
|
||||
.orderByDesc(DedupeTotalDataEntity::getId);
|
||||
List<DedupeTotalDataEntity> rows = dedupeTotalDataMapper.selectList(query);
|
||||
return buildExportWorkbook(rows);
|
||||
}
|
||||
|
||||
private byte[] buildExportWorkbook(List<DedupeTotalDataEntity> rows) {
|
||||
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100);
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
Sheet sheet = workbook.createSheet("DedupeTotalData");
|
||||
Row header = sheet.createRow(0);
|
||||
header.createCell(0).setCellValue("ID");
|
||||
header.createCell(1).setCellValue("ASIN值");
|
||||
header.createCell(2).setCellValue("用户名");
|
||||
header.createCell(3).setCellValue("创建时间");
|
||||
for (int index = 0; index < rows.size(); index++) {
|
||||
DedupeTotalDataEntity entity = rows.get(index);
|
||||
Row row = sheet.createRow(index + 1);
|
||||
row.createCell(0).setCellValue(entity.getId() == null ? "" : String.valueOf(entity.getId()));
|
||||
row.createCell(1).setCellValue(entity.getDataValue() == null ? "" : entity.getDataValue());
|
||||
row.createCell(2).setCellValue(entity.getUploaderUsername() == null ? "" : entity.getUploaderUsername());
|
||||
row.createCell(3).setCellValue(formatExportTime(entity.getCreatedAt()));
|
||||
}
|
||||
sheet.setColumnWidth(0, 3600);
|
||||
sheet.setColumnWidth(1, 5200);
|
||||
sheet.setColumnWidth(2, 5200);
|
||||
sheet.setColumnWidth(3, 5600);
|
||||
workbook.write(outputStream);
|
||||
workbook.dispose();
|
||||
return outputStream.toByteArray();
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("导出数据去重总数据失败");
|
||||
}
|
||||
}
|
||||
|
||||
private String formatExportTime(LocalDateTime value) {
|
||||
return value == null ? "" : value.format(EXPORT_TIME_FORMATTER);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request) {
|
||||
public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) {
|
||||
AdminUserEntity uploader = getOperator(operatorId);
|
||||
String dataValue = normalizeComparableValue(request.getDataValue());
|
||||
ensureUnique(dataValue, null);
|
||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||
entity.setDataValue(dataValue);
|
||||
entity.setUploaderUserId(uploader.getId());
|
||||
entity.setUploaderUsername(uploader.getUsername());
|
||||
dedupeTotalDataMapper.insert(entity);
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
@@ -106,15 +184,23 @@ public class DedupeTotalDataService {
|
||||
Set<String> existingValues = new HashSet<>();
|
||||
for (int start = 0; start < normalizedValues.size(); start += COMPARE_BATCH_SIZE) {
|
||||
int end = Math.min(start + COMPARE_BATCH_SIZE, normalizedValues.size());
|
||||
existingValues.addAll(dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end)));
|
||||
List<String> batch = dedupeTotalDataMapper.selectExistingDataValues(normalizedValues.subList(start, end));
|
||||
if (batch != null) {
|
||||
batch.stream()
|
||||
.map(this::normalizeComparableValueOrBlank)
|
||||
.filter(value -> !value.isEmpty())
|
||||
.forEach(existingValues::add);
|
||||
}
|
||||
}
|
||||
return existingValues;
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportStartVo startImport(MultipartFile file) {
|
||||
public DedupeTotalDataImportStartVo startImport(MultipartFile file, Long operatorId) {
|
||||
cleanupExpiredProgress();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 文件");
|
||||
}
|
||||
AdminUserEntity uploader = getOperator(operatorId);
|
||||
String importId = IdUtil.fastSimpleUUID();
|
||||
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
||||
progress.setStatus("pending");
|
||||
@@ -124,13 +210,16 @@ public class DedupeTotalDataService {
|
||||
progress.setInsertedCount(0);
|
||||
progress.setSkippedCount(0);
|
||||
importProgressMap.put(importId, progress);
|
||||
importOwnerMap.put(importId, uploader.getId());
|
||||
|
||||
try {
|
||||
File tempFile = saveMultipartToTempFile(file);
|
||||
String filename = file.getOriginalFilename();
|
||||
Thread.ofVirtual().start(() -> runImportTask(importId, tempFile, filename));
|
||||
Thread.ofVirtual().start(() -> runImportTask(
|
||||
importId, tempFile, filename, uploader.getId(), uploader.getUsername()));
|
||||
} catch (Exception e) {
|
||||
importProgressMap.remove(importId);
|
||||
importOwnerMap.remove(importId);
|
||||
throw new BusinessException("读取上传文件失败");
|
||||
}
|
||||
|
||||
@@ -139,18 +228,23 @@ public class DedupeTotalDataService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportProgressVo getImportProgress(String importId) {
|
||||
public DedupeTotalDataImportProgressVo getImportProgress(String importId, Long operatorId) {
|
||||
cleanupExpiredProgress();
|
||||
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
Long ownerId = importOwnerMap.get(importId);
|
||||
if (progress == null || ownerId == null) {
|
||||
throw new BusinessException("导入任务不存在");
|
||||
}
|
||||
ensureUploaderAccess(ownerId, resolveAccessScope(operatorId));
|
||||
return progress;
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file) {
|
||||
public DedupeTotalDataImportStartVo startDeleteImport(MultipartFile file, Long operatorId) {
|
||||
cleanupExpiredProgress();
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 文件");
|
||||
}
|
||||
AdminUserEntity operator = getOperator(operatorId);
|
||||
String importId = IdUtil.fastSimpleUUID();
|
||||
DedupeTotalDataImportProgressVo progress = new DedupeTotalDataImportProgressVo();
|
||||
progress.setStatus("pending");
|
||||
@@ -160,13 +254,15 @@ public class DedupeTotalDataService {
|
||||
progress.setInsertedCount(0);
|
||||
progress.setSkippedCount(0);
|
||||
deleteImportProgressMap.put(importId, progress);
|
||||
deleteImportOwnerMap.put(importId, operator.getId());
|
||||
|
||||
try {
|
||||
File tempFile = saveMultipartToTempFile(file);
|
||||
String filename = file.getOriginalFilename();
|
||||
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename));
|
||||
Thread.ofVirtual().start(() -> runDeleteImportTask(importId, tempFile, filename, operator.getId()));
|
||||
} catch (Exception e) {
|
||||
deleteImportProgressMap.remove(importId);
|
||||
deleteImportOwnerMap.remove(importId);
|
||||
throw new BusinessException("读取上传文件失败");
|
||||
}
|
||||
|
||||
@@ -175,62 +271,26 @@ public class DedupeTotalDataService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId) {
|
||||
public DedupeTotalDataImportProgressVo getDeleteImportProgress(String importId, Long operatorId) {
|
||||
cleanupExpiredProgress();
|
||||
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
Long ownerId = deleteImportOwnerMap.get(importId);
|
||||
if (progress == null || ownerId == null) {
|
||||
throw new BusinessException("删除任务不存在");
|
||||
}
|
||||
ensureUploaderAccess(ownerId, resolveAccessScope(operatorId));
|
||||
return progress;
|
||||
}
|
||||
|
||||
private void runDeleteImportTask(String importId, byte[] fileBytes, String filename) {
|
||||
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
return;
|
||||
}
|
||||
progress.setStatus("running");
|
||||
try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) {
|
||||
DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress);
|
||||
progress.setTotalRows(result.getTotalRows());
|
||||
progress.setAsinCount(result.getAsinCount());
|
||||
progress.setInsertedCount(result.getInsertedCount());
|
||||
progress.setSkippedCount(result.getSkippedCount());
|
||||
progress.setProcessedRows(result.getTotalRows());
|
||||
progress.setStatus("success");
|
||||
} catch (Exception e) {
|
||||
progress.setStatus("failed");
|
||||
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void runImportTask(String importId, byte[] fileBytes, String filename) {
|
||||
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
return;
|
||||
}
|
||||
progress.setStatus("running");
|
||||
try (InputStream inputStream = new ByteArrayInputStream(fileBytes)) {
|
||||
DedupeTotalDataImportVo result = importFromExcelInternal(inputStream, filename, progress);
|
||||
progress.setTotalRows(result.getTotalRows());
|
||||
progress.setAsinCount(result.getAsinCount());
|
||||
progress.setInsertedCount(result.getInsertedCount());
|
||||
progress.setSkippedCount(result.getSkippedCount());
|
||||
progress.setProcessedRows(result.getTotalRows());
|
||||
progress.setStatus("success");
|
||||
} catch (Exception e) {
|
||||
progress.setStatus("failed");
|
||||
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败");
|
||||
}
|
||||
}
|
||||
|
||||
private void runDeleteImportTask(String importId, File tempFile, String filename) {
|
||||
private void runDeleteImportTask(String importId, File tempFile, String filename, Long operatorId) {
|
||||
DedupeTotalDataImportProgressVo progress = deleteImportProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
return;
|
||||
}
|
||||
progress.setStatus("running");
|
||||
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
|
||||
DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress);
|
||||
AccessScope scope = resolveAccessScope(operatorId);
|
||||
DedupeTotalDataImportVo result = deleteFromExcelInternal(inputStream, filename, progress, scope);
|
||||
progress.setTotalRows(result.getTotalRows());
|
||||
progress.setAsinCount(result.getAsinCount());
|
||||
progress.setInsertedCount(result.getInsertedCount());
|
||||
@@ -242,17 +302,20 @@ public class DedupeTotalDataService {
|
||||
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "删除 Excel 匹配数据失败");
|
||||
} finally {
|
||||
deleteQuietly(tempFile);
|
||||
deleteImportCompletedAtMap.put(importId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private void runImportTask(String importId, File tempFile, String filename) {
|
||||
private void runImportTask(String importId, File tempFile, String filename,
|
||||
Long uploaderUserId, String uploaderUsername) {
|
||||
DedupeTotalDataImportProgressVo progress = importProgressMap.get(importId);
|
||||
if (progress == null) {
|
||||
return;
|
||||
}
|
||||
progress.setStatus("running");
|
||||
try (InputStream inputStream = Files.newInputStream(tempFile.toPath())) {
|
||||
DedupeTotalDataImportVo result = importFromExcelInternal(inputStream, filename, progress);
|
||||
DedupeTotalDataImportVo result = importFromExcelInternal(
|
||||
inputStream, filename, progress, uploaderUserId, uploaderUsername);
|
||||
progress.setTotalRows(result.getTotalRows());
|
||||
progress.setAsinCount(result.getAsinCount());
|
||||
progress.setInsertedCount(result.getInsertedCount());
|
||||
@@ -264,6 +327,7 @@ public class DedupeTotalDataService {
|
||||
progress.setErrorMessage(e instanceof BusinessException ? e.getMessage() : "导入 Excel 失败");
|
||||
} finally {
|
||||
deleteQuietly(tempFile);
|
||||
importCompletedAtMap.put(importId, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,12 +355,14 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportVo importFromExcel(MultipartFile file) {
|
||||
public DedupeTotalDataImportVo importFromExcel(MultipartFile file, Long operatorId) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 文件");
|
||||
}
|
||||
AdminUserEntity uploader = getOperator(operatorId);
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
return importFromExcelInternal(inputStream, file.getOriginalFilename(), null);
|
||||
return importFromExcelInternal(
|
||||
inputStream, file.getOriginalFilename(), null, uploader.getId(), uploader.getUsername());
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -304,12 +370,13 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
}
|
||||
|
||||
public DedupeTotalDataImportVo deleteFromExcel(MultipartFile file) {
|
||||
public DedupeTotalDataImportVo deleteFromExcel(MultipartFile file, Long operatorId) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传 xlsx 文件");
|
||||
}
|
||||
AccessScope scope = resolveAccessScope(operatorId);
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null);
|
||||
return deleteFromExcelInternal(inputStream, file.getOriginalFilename(), null, scope);
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -317,7 +384,9 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
}
|
||||
|
||||
private DedupeTotalDataImportVo importFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) {
|
||||
private DedupeTotalDataImportVo importFromExcelInternal(InputStream inputStream, String filename,
|
||||
DedupeTotalDataImportProgressVo progress,
|
||||
Long uploaderUserId, String uploaderUsername) {
|
||||
String lowerFilename = filename == null ? "" : filename.toLowerCase();
|
||||
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||
@@ -405,15 +474,22 @@ public class DedupeTotalDataService {
|
||||
continue;
|
||||
}
|
||||
final String pendingDataValue = dataValue;
|
||||
Boolean inserted = newRequiresNewTemplate().execute(status -> {
|
||||
if (existsDataValue(pendingDataValue)) {
|
||||
return false;
|
||||
}
|
||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||
entity.setDataValue(pendingDataValue);
|
||||
dedupeTotalDataMapper.insert(entity);
|
||||
return true;
|
||||
});
|
||||
Boolean inserted;
|
||||
try {
|
||||
inserted = newRequiresNewTemplate().execute(status -> {
|
||||
if (existsDataValue(pendingDataValue)) {
|
||||
return false;
|
||||
}
|
||||
DedupeTotalDataEntity entity = new DedupeTotalDataEntity();
|
||||
entity.setDataValue(pendingDataValue);
|
||||
entity.setUploaderUserId(uploaderUserId);
|
||||
entity.setUploaderUsername(uploaderUsername);
|
||||
dedupeTotalDataMapper.insert(entity);
|
||||
return true;
|
||||
});
|
||||
} catch (DuplicateKeyException ignored) {
|
||||
inserted = false;
|
||||
}
|
||||
if (Boolean.TRUE.equals(inserted)) {
|
||||
insertedCount++;
|
||||
} else {
|
||||
@@ -440,7 +516,9 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
}
|
||||
|
||||
private DedupeTotalDataImportVo deleteFromExcelInternal(InputStream inputStream, String filename, DedupeTotalDataImportProgressVo progress) {
|
||||
private DedupeTotalDataImportVo deleteFromExcelInternal(InputStream inputStream, String filename,
|
||||
DedupeTotalDataImportProgressVo progress,
|
||||
AccessScope scope) {
|
||||
String lowerFilename = filename == null ? "" : filename.toLowerCase();
|
||||
if (!(lowerFilename.endsWith(".xlsx") || lowerFilename.endsWith(".xls"))) {
|
||||
throw new BusinessException("仅支持 .xlsx 或 .xls 文件");
|
||||
@@ -518,7 +596,7 @@ public class DedupeTotalDataService {
|
||||
continue;
|
||||
}
|
||||
|
||||
int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue));
|
||||
int deletedThisRow = newRequiresNewTemplate().execute(status -> deleteByDataValue(dataValue, scope));
|
||||
if (deletedThisRow > 0) {
|
||||
deletedCount += deletedThisRow;
|
||||
} else {
|
||||
@@ -546,8 +624,8 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request) {
|
||||
DedupeTotalDataEntity entity = getById(id);
|
||||
public DedupeTotalDataItemVo update(Long id, DedupeTotalDataUpdateRequest request, Long operatorId) {
|
||||
DedupeTotalDataEntity entity = getAccessibleById(id, operatorId);
|
||||
String dataValue = normalizeComparableValue(request.getDataValue());
|
||||
ensureUnique(dataValue, id);
|
||||
entity.setDataValue(dataValue);
|
||||
@@ -556,11 +634,17 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
DedupeTotalDataEntity entity = getById(id);
|
||||
public void delete(Long id, Long operatorId) {
|
||||
DedupeTotalDataEntity entity = getAccessibleById(id, operatorId);
|
||||
dedupeTotalDataMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
private DedupeTotalDataEntity getAccessibleById(Long id, Long operatorId) {
|
||||
DedupeTotalDataEntity entity = getById(id);
|
||||
ensureUploaderAccess(entity.getUploaderUserId(), resolveAccessScope(operatorId));
|
||||
return entity;
|
||||
}
|
||||
|
||||
private DedupeTotalDataEntity getById(Long id) {
|
||||
DedupeTotalDataEntity entity = dedupeTotalDataMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
@@ -578,7 +662,7 @@ public class DedupeTotalDataService {
|
||||
}
|
||||
|
||||
public String normalizeComparableValueOrBlank(String value) {
|
||||
return normalizeExcelText(value);
|
||||
return normalizeExcelText(value).toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private void ensureUnique(String dataValue, Long excludeId) {
|
||||
@@ -598,9 +682,68 @@ public class DedupeTotalDataService {
|
||||
return dedupeTotalDataMapper.selectOne(query) != null;
|
||||
}
|
||||
|
||||
private int deleteByDataValue(String dataValue) {
|
||||
private int deleteByDataValue(String dataValue, AccessScope scope) {
|
||||
return dedupeTotalDataMapper.delete(new LambdaQueryWrapper<DedupeTotalDataEntity>()
|
||||
.eq(DedupeTotalDataEntity::getDataValue, dataValue));
|
||||
.eq(DedupeTotalDataEntity::getDataValue, dataValue)
|
||||
.in(!scope.allUsers(), DedupeTotalDataEntity::getUploaderUserId, scope.userIds()));
|
||||
}
|
||||
|
||||
private AccessScope resolveAccessScope(Long operatorId) {
|
||||
AdminUserEntity operator = getOperator(operatorId);
|
||||
if (isSuperAdmin(operator)) {
|
||||
return new AccessScope(true, Set.of());
|
||||
}
|
||||
LinkedHashSet<Long> visibleUserIds = new LinkedHashSet<>();
|
||||
visibleUserIds.add(operator.getId());
|
||||
List<Long> memberUserIds = shopManageGroupMapper.selectManagedMemberUserIds(operator.getId());
|
||||
if (memberUserIds != null) {
|
||||
memberUserIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.forEach(visibleUserIds::add);
|
||||
}
|
||||
return new AccessScope(false, Set.copyOf(visibleUserIds));
|
||||
}
|
||||
|
||||
private AdminUserEntity getOperator(Long operatorId) {
|
||||
if (operatorId == null || operatorId <= 0) {
|
||||
throw new BusinessException("当前操作用户不能为空");
|
||||
}
|
||||
AdminUserEntity operator = adminUserMapper.selectById(operatorId);
|
||||
if (operator == null) {
|
||||
throw new BusinessException("当前操作用户不存在");
|
||||
}
|
||||
return operator;
|
||||
}
|
||||
|
||||
private boolean isSuperAdmin(AdminUserEntity user) {
|
||||
String role = user.getRole() == null ? "" : user.getRole().trim().toLowerCase(Locale.ROOT);
|
||||
return "super_admin".equals(role)
|
||||
|| (role.isEmpty() && Integer.valueOf(1).equals(user.getIsAdmin()) && user.getCreatedById() == null);
|
||||
}
|
||||
|
||||
private void ensureUploaderAccess(Long uploaderUserId, AccessScope scope) {
|
||||
if (!scope.allUsers() && (uploaderUserId == null || !scope.userIds().contains(uploaderUserId))) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权操作该总数据");
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupExpiredProgress() {
|
||||
long cutoff = System.currentTimeMillis() - COMPLETED_PROGRESS_RETENTION_MILLIS;
|
||||
cleanupExpiredProgressEntries(importCompletedAtMap, importProgressMap, importOwnerMap, cutoff);
|
||||
cleanupExpiredProgressEntries(deleteImportCompletedAtMap, deleteImportProgressMap, deleteImportOwnerMap, cutoff);
|
||||
}
|
||||
|
||||
private void cleanupExpiredProgressEntries(
|
||||
Map<String, Long> completedAtMap,
|
||||
Map<String, DedupeTotalDataImportProgressVo> progressMap,
|
||||
Map<String, Long> ownerMap,
|
||||
long cutoff) {
|
||||
completedAtMap.forEach((id, completedAt) -> {
|
||||
if (completedAt != null && completedAt < cutoff && completedAtMap.remove(id, completedAt)) {
|
||||
progressMap.remove(id);
|
||||
ownerMap.remove(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String normalizeExcelText(String value) {
|
||||
@@ -621,7 +764,12 @@ public class DedupeTotalDataService {
|
||||
DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setDataValue(entity.getDataValue());
|
||||
vo.setUploaderUserId(entity.getUploaderUserId());
|
||||
vo.setUsername(entity.getUploaderUsername());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private record AccessScope(boolean allUsers, Set<Long> userIds) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ public class DeleteBrandRunController {
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "Download one delete-brand result")
|
||||
@Operation(summary = "下载单个删除品牌结果")
|
||||
public void downloadResult(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
|
||||
@@ -1625,13 +1625,26 @@ public class DeleteBrandRunService {
|
||||
}
|
||||
resultEntity.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
resultEntity.setRowCount(parsedFile.getTotalRows());
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
boolean assembleTerminallyFailed = false;
|
||||
if (!alreadyAssembled) {
|
||||
taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity);
|
||||
waitingForAssemble = true;
|
||||
TaskFileJobEntity assembleJob = taskFileJobService.enqueueAssembleResult(task.getId(), MODULE_TYPE, resultEntity.getId(), fileIdentity);
|
||||
// job 已终态失败(retryCount 已达上限)时不再等待组装,否则任务会永远卡在 RUNNING。
|
||||
assembleTerminallyFailed = assembleJob != null
|
||||
&& "FAILED".equals(assembleJob.getStatus())
|
||||
&& assembleJob.getRetryCount() != null
|
||||
&& assembleJob.getRetryCount() >= TaskFileJobService.MAX_RETRY_COUNT;
|
||||
if (!assembleTerminallyFailed) {
|
||||
waitingForAssemble = true;
|
||||
}
|
||||
}
|
||||
if (assembleTerminallyFailed) {
|
||||
resultEntity.setSuccess(0);
|
||||
resultEntity.setErrorMessage("结果文件生成失败");
|
||||
} else {
|
||||
resultEntity.setSuccess(1);
|
||||
resultEntity.setErrorMessage(null);
|
||||
}
|
||||
fileResultMapper.updateById(resultEntity);
|
||||
|
||||
DeleteBrandResultItemVo item = new DeleteBrandResultItemVo();
|
||||
item.setResultId(resultEntity.getId());
|
||||
@@ -1878,7 +1891,11 @@ public class DeleteBrandRunService {
|
||||
Row countryRow = sheet.createRow(0);
|
||||
Row headerRow = sheet.createRow(1);
|
||||
|
||||
for (int countryIndex = 0; countryIndex < mergedFile.countries().size(); countryIndex++) {
|
||||
// SXSSF 是流式 workbook,超过窗口(200 行)的旧行会被 flush 到磁盘后不可再修改。
|
||||
// 因此这里必须按“行”顺序写入(行号严格递增),不能按“国家列”循环回头补已 flush 的行。
|
||||
int countryCount = mergedFile.countries().size();
|
||||
int maxItemCount = 0;
|
||||
for (int countryIndex = 0; countryIndex < countryCount; countryIndex++) {
|
||||
DeleteBrandProcessedCountryDto country = mergedFile.countries().get(countryIndex);
|
||||
int asinColumnIndex = countryIndex * 2;
|
||||
int statusColumnIndex = asinColumnIndex + 1;
|
||||
@@ -1888,22 +1905,26 @@ public class DeleteBrandRunService {
|
||||
createTextCell(headerRow, statusColumnIndex, "状态");
|
||||
|
||||
List<DeleteBrandCountryResultItemDto> items = country.getItems();
|
||||
if (items == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int itemIndex = 0; itemIndex < items.size(); itemIndex++) {
|
||||
DeleteBrandCountryResultItemDto item = items.get(itemIndex);
|
||||
Row row = sheet.getRow(itemIndex + 2);
|
||||
if (row == null) {
|
||||
row = sheet.createRow(itemIndex + 2);
|
||||
}
|
||||
createTextCell(row, asinColumnIndex, item.getAsin());
|
||||
createTextCell(row, statusColumnIndex, item.getStatus());
|
||||
if (items != null) {
|
||||
maxItemCount = Math.max(maxItemCount, items.size());
|
||||
}
|
||||
}
|
||||
|
||||
applyDeleteBrandColumnWidths(sheet, mergedFile.countries().size() * 2);
|
||||
for (int itemIndex = 0; itemIndex < maxItemCount; itemIndex++) {
|
||||
Row row = sheet.createRow(itemIndex + 2);
|
||||
for (int countryIndex = 0; countryIndex < countryCount; countryIndex++) {
|
||||
List<DeleteBrandCountryResultItemDto> items = mergedFile.countries().get(countryIndex).getItems();
|
||||
if (items == null || itemIndex >= items.size()) {
|
||||
continue;
|
||||
}
|
||||
DeleteBrandCountryResultItemDto item = items.get(itemIndex);
|
||||
int asinColumnIndex = countryIndex * 2;
|
||||
createTextCell(row, asinColumnIndex, item.getAsin());
|
||||
createTextCell(row, asinColumnIndex + 1, item.getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
applyDeleteBrandColumnWidths(sheet, countryCount * 2);
|
||||
workbook.write(outputStream);
|
||||
return outputFile;
|
||||
} catch (Exception ex) {
|
||||
|
||||
@@ -17,6 +17,8 @@ import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskCacheService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskCacheService;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
|
||||
@@ -48,6 +50,7 @@ public class DeleteBrandStaleTaskService {
|
||||
private static final String MODULE_TYPE_SHOP_MATCH = "SHOP_MATCH";
|
||||
private static final String MODULE_TYPE_PATROL_DELETE = "PATROL_DELETE";
|
||||
private static final String MODULE_TYPE_QUERY_ASIN = "QUERY_ASIN";
|
||||
private static final String MODULE_TYPE_WITHDRAW = "WITHDRAW";
|
||||
private static final Duration STALE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration FINALIZE_CHECK_LOCK_TTL = Duration.ofMinutes(10);
|
||||
private static final Duration TEMP_DIR_CLEANUP_LOCK_TTL = Duration.ofMinutes(10);
|
||||
@@ -66,6 +69,8 @@ public class DeleteBrandStaleTaskService {
|
||||
private final PatrolDeleteTaskCacheService patrolDeleteTaskCacheService;
|
||||
private final QueryAsinTaskService queryAsinTaskService;
|
||||
private final QueryAsinTaskCacheService queryAsinTaskCacheService;
|
||||
private final WithdrawTaskService withdrawTaskService;
|
||||
private final WithdrawTaskCacheService withdrawTaskCacheService;
|
||||
private final BrandTaskService brandTaskService;
|
||||
private final AppearancePatentTaskService appearancePatentTaskService;
|
||||
private final SimilarAsinTaskService similarAsinTaskService;
|
||||
@@ -94,6 +99,7 @@ public class DeleteBrandStaleTaskService {
|
||||
ShopMatchStaleCheckStats shopMatchStats = failStaleShopMatchTasks();
|
||||
ShopMatchStaleCheckStats patrolDeleteStats = failStalePatrolDeleteTasks();
|
||||
ShopMatchStaleCheckStats queryAsinStats = failStaleQueryAsinTasks();
|
||||
ShopMatchStaleCheckStats withdrawStats = failStaleWithdrawTasks();
|
||||
runModuleStaleCheck("brand", brandTaskService::failStaleRunningTasks);
|
||||
runModuleStaleCheck("appearance-patent", appearancePatentTaskService::finalizeStaleTasks);
|
||||
runModuleStaleCheck("similar-asin", similarAsinTaskService::finalizeStaleTasks);
|
||||
@@ -132,6 +138,13 @@ public class DeleteBrandStaleTaskService {
|
||||
queryAsinStats.skippedTaskCount,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
Thread.currentThread().getName());
|
||||
log.info("[stale-check] withdraw summary scanned={} finalized={} failed={} skipped={} elapsedMs={} thread={}",
|
||||
withdrawStats.scannedTaskCount,
|
||||
withdrawStats.finalizedTaskCount,
|
||||
withdrawStats.failedTaskCount,
|
||||
withdrawStats.skippedTaskCount,
|
||||
System.currentTimeMillis() - startedAt,
|
||||
Thread.currentThread().getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +615,62 @@ public class DeleteBrandStaleTaskService {
|
||||
return stats;
|
||||
}
|
||||
|
||||
private ShopMatchStaleCheckStats failStaleWithdrawTasks() {
|
||||
ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats();
|
||||
long minutes = Math.max(1L, deleteBrandProgressProperties.getWithdrawStaleTimeoutMinutes());
|
||||
long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis();
|
||||
long nowMillis = System.currentTimeMillis();
|
||||
LocalDateTime threshold = LocalDateTime.now().minusMinutes(minutes);
|
||||
java.util.List<FileTaskEntity> runningTasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_WITHDRAW)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
.lt(FileTaskEntity::getUpdatedAt, threshold)
|
||||
.last("limit 200"));
|
||||
stats.scannedTaskCount = runningTasks.size();
|
||||
if (runningTasks.isEmpty()) {
|
||||
return stats;
|
||||
}
|
||||
Map<Long, Long> heartbeatByTaskId = withdrawTaskCacheService.getTaskHeartbeatMillisBatch(
|
||||
runningTasks.stream().map(FileTaskEntity::getId).toList());
|
||||
for (FileTaskEntity task : runningTasks) {
|
||||
long lastHeartbeatMillis = heartbeatByTaskId.getOrDefault(task.getId(), 0L);
|
||||
if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(MODULE_TYPE_WITHDRAW, task.getId());
|
||||
if (taskLockHandle == null) {
|
||||
stats.skippedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
try (taskLockHandle) {
|
||||
try {
|
||||
if (withdrawTaskService.tryFinalizeTask(task.getId(), true)) {
|
||||
stats.finalizedTaskCount++;
|
||||
continue;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[stale-check] withdraw finalize threw taskId={} msg={}", task.getId(), ex.getMessage());
|
||||
}
|
||||
int updated = fileTaskMapper.update(null, new LambdaUpdateWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getId, task.getId())
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_WITHDRAW)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
.set(FileTaskEntity::getStatus, "FAILED")
|
||||
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
|
||||
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
|
||||
if (updated > 0) {
|
||||
stats.failedTaskCount++;
|
||||
withdrawTaskCacheService.deleteTaskCache(task.getId());
|
||||
} else {
|
||||
stats.skippedTaskCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLock(String moduleType, Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = taskDistributedLockService.acquire(moduleType, taskId, 0L);
|
||||
if (lockHandle == null) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.digitalhuman.model.dto.UploadVersionRequest;
|
||||
import com.nanri.aiimage.modules.digitalhuman.model.vo.DigitalHumanVersionVo;
|
||||
import com.nanri.aiimage.modules.digitalhuman.service.DigitalHumanVersionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/digital-human/versions")
|
||||
@Tag(name = "数字人版本管理", description = "数字人程序版本管理接口,包括上传、查询、发布、删除等功能")
|
||||
public class DigitalHumanVersionController {
|
||||
|
||||
private final DigitalHumanVersionService versionService;
|
||||
|
||||
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(summary = "上传新版本", description = "上传数字人程序新版本到 OSS,并记录版本信息")
|
||||
public ApiResponse<DigitalHumanVersionVo> uploadVersion(@Valid @ModelAttribute UploadVersionRequest request) {
|
||||
DigitalHumanVersionVo vo = versionService.uploadVersion(
|
||||
request.getVersion(),
|
||||
request.getFile(),
|
||||
request.getChangelog(),
|
||||
request.getMinClientVersion(),
|
||||
request.getCreatedBy()
|
||||
);
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "查询版本列表", description = "查询所有数字人版本,按创建时间倒序")
|
||||
public ApiResponse<List<DigitalHumanVersionVo>> listVersions() {
|
||||
return ApiResponse.success(versionService.listVersions());
|
||||
}
|
||||
|
||||
@GetMapping("/latest")
|
||||
@Operation(summary = "获取最新版本", description = "获取当前标记为最新且已发布的版本")
|
||||
public ApiResponse<DigitalHumanVersionVo> getLatestVersion() {
|
||||
return ApiResponse.success(versionService.getLatestVersion());
|
||||
}
|
||||
|
||||
@GetMapping("/{version}")
|
||||
@Operation(summary = "获取版本详情", description = "根据版本号获取版本详细信息")
|
||||
public ApiResponse<DigitalHumanVersionVo> getVersionByVersion(
|
||||
@Parameter(description = "版本号", example = "1.0.0") @PathVariable String version) {
|
||||
return ApiResponse.success(versionService.getVersionByVersion(version));
|
||||
}
|
||||
|
||||
@PostMapping("/{version}/release")
|
||||
@Operation(summary = "发布版本", description = "将草稿状态的版本发布,状态变更为 RELEASED")
|
||||
public ApiResponse<DigitalHumanVersionVo> releaseVersion(
|
||||
@Parameter(description = "版本号", example = "1.0.0") @PathVariable String version) {
|
||||
return ApiResponse.success(versionService.releaseVersion(version));
|
||||
}
|
||||
|
||||
@PostMapping("/{version}/set-latest")
|
||||
@Operation(summary = "设为最新版本", description = "将指定已发布版本设置为最新版本,清除其他版本的最新标记")
|
||||
public ApiResponse<DigitalHumanVersionVo> setLatest(
|
||||
@Parameter(description = "版本号", example = "1.0.0") @PathVariable String version) {
|
||||
return ApiResponse.success(versionService.setLatest(version));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{version}")
|
||||
@Operation(summary = "删除版本", description = "删除指定版本,同时删除 OSS 上的文件。最新版本不允许删除")
|
||||
public ApiResponse<Void> deleteVersion(
|
||||
@Parameter(description = "版本号", example = "1.0.0") @PathVariable String version) {
|
||||
versionService.deleteVersion(version);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/{version}/download-url")
|
||||
@Operation(summary = "获取下载链接", description = "获取指定版本的 OSS 下载链接")
|
||||
public ApiResponse<String> getDownloadUrl(
|
||||
@Parameter(description = "版本号", example = "1.0.0") @PathVariable String version) {
|
||||
return ApiResponse.success(versionService.getDownloadUrl(version));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.digitalhuman.model.entity.DigitalHumanVersionEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface DigitalHumanVersionMapper extends BaseMapper<DigitalHumanVersionEntity> {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@Data
|
||||
@Schema(description = "上传数字人版本请求")
|
||||
public class UploadVersionRequest {
|
||||
|
||||
@NotBlank(message = "版本号不能为空")
|
||||
@Schema(description = "版本号", example = "1.0.0", required = true)
|
||||
private String version;
|
||||
|
||||
@NotNull(message = "文件不能为空")
|
||||
@Schema(description = "数字人程序压缩包", required = true)
|
||||
private MultipartFile file;
|
||||
|
||||
@Schema(description = "更新日志")
|
||||
private String changelog;
|
||||
|
||||
@Schema(description = "最低客户端版本要求", example = "1.0.0")
|
||||
private String minClientVersion;
|
||||
|
||||
@Schema(description = "创建人")
|
||||
private String createdBy;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_digital_human_version")
|
||||
public class DigitalHumanVersionEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String version;
|
||||
private String ossObjectKey;
|
||||
private Long fileSize;
|
||||
private String md5;
|
||||
private String changelog;
|
||||
private String minClientVersion;
|
||||
private Boolean isLatest;
|
||||
private String status;
|
||||
private String createdBy;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime releasedAt;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "数字人版本信息")
|
||||
public class DigitalHumanVersionVo {
|
||||
|
||||
@Schema(description = "版本ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "版本号", example = "1.0.0")
|
||||
private String version;
|
||||
|
||||
@Schema(description = "文件大小(字节)")
|
||||
private Long fileSize;
|
||||
|
||||
@Schema(description = "文件MD5")
|
||||
private String md5;
|
||||
|
||||
@Schema(description = "更新日志")
|
||||
private String changelog;
|
||||
|
||||
@Schema(description = "最低客户端版本要求", example = "1.0.0")
|
||||
private String minClientVersion;
|
||||
|
||||
@Schema(description = "是否最新版本")
|
||||
private Boolean isLatest;
|
||||
|
||||
@Schema(description = "状态:DRAFT/RELEASED")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "下载链接")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "创建人")
|
||||
private String createdBy;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "发布时间")
|
||||
private LocalDateTime releasedAt;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package com.nanri.aiimage.modules.digitalhuman.service;
|
||||
|
||||
import com.aliyun.oss.OSS;
|
||||
import com.aliyun.oss.OSSClientBuilder;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.OssProperties;
|
||||
import com.nanri.aiimage.modules.digitalhuman.mapper.DigitalHumanVersionMapper;
|
||||
import com.nanri.aiimage.modules.digitalhuman.model.entity.DigitalHumanVersionEntity;
|
||||
import com.nanri.aiimage.modules.digitalhuman.model.vo.DigitalHumanVersionVo;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DigitalHumanVersionService {
|
||||
|
||||
private static final String STATUS_DRAFT = "DRAFT";
|
||||
private static final String STATUS_RELEASED = "RELEASED";
|
||||
private static final String OSS_PATH_PREFIX = "digital-human/versions/";
|
||||
|
||||
private final DigitalHumanVersionMapper versionMapper;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final OssProperties ossProperties;
|
||||
|
||||
@Transactional
|
||||
public DigitalHumanVersionVo uploadVersion(String version, MultipartFile file, String changelog,
|
||||
String minClientVersion, String createdBy) {
|
||||
// 检查版本号是否已存在
|
||||
DigitalHumanVersionEntity existing = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (existing != null) {
|
||||
throw new BusinessException("版本号已存在:" + version);
|
||||
}
|
||||
|
||||
// 保存临时文件
|
||||
long startedAt = System.nanoTime();
|
||||
long fileSize = file == null ? -1L : file.getSize();
|
||||
String originalFilename = file == null ? "" : file.getOriginalFilename();
|
||||
log.info("[digital-human-version] upload start version={} filename={} requestFileSize={}",
|
||||
version, originalFilename, fileSize);
|
||||
File tempFile = null;
|
||||
try {
|
||||
tempFile = Files.createTempFile("digital-human-", ".zip").toFile();
|
||||
file.transferTo(tempFile);
|
||||
log.info("[digital-human-version] temp file saved version={} bytes={} elapsedMs={}",
|
||||
version, tempFile.length(), elapsedMs(startedAt));
|
||||
|
||||
// 计算 MD5
|
||||
String md5 = calculateMd5(tempFile);
|
||||
log.info("[digital-human-version] md5 calculated version={} md5={} elapsedMs={}",
|
||||
version, md5, elapsedMs(startedAt));
|
||||
|
||||
// 构建 OSS 路径
|
||||
String ossObjectKey = OSS_PATH_PREFIX + "v" + version + "/ShuFuDigitalHuman.zip";
|
||||
|
||||
// 上传到 OSS
|
||||
OSS ossClient = buildOssClient();
|
||||
try {
|
||||
log.info("[digital-human-version] oss upload start version={} objectKey={} bytes={} elapsedMs={}",
|
||||
version, ossObjectKey, tempFile.length(), elapsedMs(startedAt));
|
||||
ossClient.putObject(ossProperties.getBucket(), ossObjectKey, tempFile);
|
||||
if (!ossClient.doesObjectExist(ossProperties.getBucket(), ossObjectKey)) {
|
||||
throw new BusinessException("数字人版本文件上传后在 OSS 中不可见,请重试");
|
||||
}
|
||||
log.info("[digital-human-version] oss uploaded version={} objectKey={} bytes={} elapsedMs={}",
|
||||
version, ossObjectKey, tempFile.length(), elapsedMs(startedAt));
|
||||
} finally {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
|
||||
// 保存数据库记录
|
||||
DigitalHumanVersionEntity entity = new DigitalHumanVersionEntity();
|
||||
entity.setVersion(version);
|
||||
entity.setOssObjectKey(ossObjectKey);
|
||||
entity.setFileSize(tempFile.length());
|
||||
entity.setMd5(md5);
|
||||
entity.setChangelog(changelog);
|
||||
entity.setMinClientVersion(minClientVersion);
|
||||
entity.setIsLatest(false);
|
||||
entity.setStatus(STATUS_DRAFT);
|
||||
entity.setCreatedBy(createdBy);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
|
||||
versionMapper.insert(entity);
|
||||
log.info("[digital-human-version] upload finished version={} id={} bytes={} elapsedMs={}",
|
||||
version, entity.getId(), entity.getFileSize(), elapsedMs(startedAt));
|
||||
|
||||
return toVo(entity);
|
||||
} catch (IOException e) {
|
||||
log.error("上传数字人版本失败", e);
|
||||
throw new BusinessException("文件处理失败:" + e.getMessage());
|
||||
} catch (RuntimeException e) {
|
||||
log.error("[digital-human-version] upload failed version={} filename={} elapsedMs={} err={}",
|
||||
version, originalFilename, elapsedMs(startedAt), e.getMessage(), e);
|
||||
throw e;
|
||||
} finally {
|
||||
if (tempFile != null && tempFile.exists()) {
|
||||
tempFile.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long elapsedMs(long startedAt) {
|
||||
return (System.nanoTime() - startedAt) / 1_000_000L;
|
||||
}
|
||||
|
||||
public List<DigitalHumanVersionVo> listVersions() {
|
||||
List<DigitalHumanVersionEntity> entities = versionMapper.selectList(
|
||||
new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.orderByDesc(DigitalHumanVersionEntity::getCreatedAt)
|
||||
);
|
||||
return entities.stream().map(this::toVo).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public DigitalHumanVersionVo getLatestVersion() {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getIsLatest, true)
|
||||
.eq(DigitalHumanVersionEntity::getStatus, STATUS_RELEASED)
|
||||
.last("limit 1"));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("暂无已发布的版本");
|
||||
}
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
public DigitalHumanVersionVo getVersionByVersion(String version) {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("版本不存在:" + version);
|
||||
}
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DigitalHumanVersionVo releaseVersion(String version) {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("版本不存在:" + version);
|
||||
}
|
||||
if (STATUS_RELEASED.equals(entity.getStatus())) {
|
||||
throw new BusinessException("版本已发布");
|
||||
}
|
||||
|
||||
if (!ossStorageService.objectExists(entity.getOssObjectKey())) {
|
||||
throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey());
|
||||
}
|
||||
|
||||
entity.setStatus(STATUS_RELEASED);
|
||||
entity.setReleasedAt(LocalDateTime.now());
|
||||
versionMapper.updateById(entity);
|
||||
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public DigitalHumanVersionVo setLatest(String version) {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("版本不存在:" + version);
|
||||
}
|
||||
if (!STATUS_RELEASED.equals(entity.getStatus())) {
|
||||
throw new BusinessException("只有已发布的版本才能设为最新");
|
||||
}
|
||||
|
||||
// 清除其他版本的 is_latest 标记
|
||||
if (!ossStorageService.objectExists(entity.getOssObjectKey())) {
|
||||
throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey());
|
||||
}
|
||||
|
||||
versionMapper.update(null, new LambdaUpdateWrapper<DigitalHumanVersionEntity>()
|
||||
.set(DigitalHumanVersionEntity::getIsLatest, false)
|
||||
.eq(DigitalHumanVersionEntity::getIsLatest, true));
|
||||
|
||||
// 设置当前版本为最新
|
||||
entity.setIsLatest(true);
|
||||
versionMapper.updateById(entity);
|
||||
|
||||
return toVo(entity);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteVersion(String version) {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("版本不存在:" + version);
|
||||
}
|
||||
if (Boolean.TRUE.equals(entity.getIsLatest())) {
|
||||
throw new BusinessException("最新版本不能删除");
|
||||
}
|
||||
|
||||
// 删除 OSS 文件
|
||||
try {
|
||||
ossStorageService.deleteObject(entity.getOssObjectKey());
|
||||
} catch (Exception e) {
|
||||
log.warn("删除 OSS 文件失败:{}", entity.getOssObjectKey(), e);
|
||||
}
|
||||
|
||||
// 删除数据库记录
|
||||
versionMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
public String getDownloadUrl(String version) {
|
||||
DigitalHumanVersionEntity entity = versionMapper.selectOne(new LambdaQueryWrapper<DigitalHumanVersionEntity>()
|
||||
.eq(DigitalHumanVersionEntity::getVersion, version));
|
||||
if (entity == null) {
|
||||
throw new BusinessException("版本不存在:" + version);
|
||||
}
|
||||
if (!ossStorageService.objectExists(entity.getOssObjectKey())) {
|
||||
throw new BusinessException("数字人版本文件不存在于 OSS,请重新上传该版本:" + entity.getOssObjectKey());
|
||||
}
|
||||
return ossStorageService.generateDownloadUrl(entity.getOssObjectKey());
|
||||
}
|
||||
|
||||
private DigitalHumanVersionVo toVo(DigitalHumanVersionEntity entity) {
|
||||
DigitalHumanVersionVo vo = new DigitalHumanVersionVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setVersion(entity.getVersion());
|
||||
vo.setFileSize(entity.getFileSize());
|
||||
vo.setMd5(entity.getMd5());
|
||||
vo.setChangelog(entity.getChangelog());
|
||||
vo.setMinClientVersion(entity.getMinClientVersion());
|
||||
vo.setIsLatest(Boolean.TRUE.equals(entity.getIsLatest()));
|
||||
vo.setStatus(entity.getStatus());
|
||||
vo.setDownloadUrl(ossStorageService.generateDownloadUrl(entity.getOssObjectKey()));
|
||||
vo.setCreatedBy(entity.getCreatedBy());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
vo.setReleasedAt(entity.getReleasedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String calculateMd5(File file) {
|
||||
try (FileInputStream fis = new FileInputStream(file)) {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] buffer = new byte[8192];
|
||||
int bytesRead;
|
||||
while ((bytesRead = fis.read(buffer)) != -1) {
|
||||
md.update(buffer, 0, bytesRead);
|
||||
}
|
||||
byte[] digest = md.digest();
|
||||
BigInteger bigInt = new BigInteger(1, digest);
|
||||
return bigInt.toString(16).toLowerCase();
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("计算 MD5 失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private OSS buildOssClient() {
|
||||
return new OSSClientBuilder().build(
|
||||
"https://" + ossProperties.getEndpoint(),
|
||||
ossProperties.getAccessKeyId(),
|
||||
ossProperties.getAccessKeySecret()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.file.model.vo.ExcelInfoVo;
|
||||
import com.nanri.aiimage.modules.file.model.vo.UploadFileVo;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
@@ -19,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/files")
|
||||
@@ -26,6 +29,7 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
public class FileUploadController {
|
||||
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
private final OssStorageService ossStorageService;
|
||||
|
||||
@PostMapping("/upload")
|
||||
@Operation(
|
||||
@@ -47,8 +51,22 @@ public class FileUploadController {
|
||||
public ApiResponse<UploadFileVo> upload(
|
||||
@Parameter(name = "file", description = "待上传的 Excel 或业务源文件", required = true, in = ParameterIn.QUERY)
|
||||
MultipartFile file,
|
||||
@RequestParam(required = false) String relativePath) throws Exception {
|
||||
return ApiResponse.success(localFileStorageService.saveTempFile(file, relativePath));
|
||||
@RequestParam(required = false) String relativePath,
|
||||
@RequestParam(defaultValue = "false") boolean uploadToOss,
|
||||
@RequestParam(defaultValue = "COMMON") String moduleType) throws Exception {
|
||||
UploadFileVo vo = localFileStorageService.saveTempFile(file, relativePath);
|
||||
if (uploadToOss) {
|
||||
File localFile = new File(vo.getLocalPath());
|
||||
OssStorageService.UploadedResult uploaded = ossStorageService.uploadPublicFileWithFreshDownloadUrl(
|
||||
localFile,
|
||||
moduleType,
|
||||
file.getOriginalFilename()
|
||||
);
|
||||
vo.setObjectKey(uploaded.objectKey());
|
||||
vo.setUrl(uploaded.downloadUrl());
|
||||
vo.setMediaType(resolveMediaType(file.getContentType(), file.getOriginalFilename()));
|
||||
}
|
||||
return ApiResponse.success(vo);
|
||||
}
|
||||
|
||||
@GetMapping("/excel-info")
|
||||
@@ -60,4 +78,19 @@ public class FileUploadController {
|
||||
public ApiResponse<ExcelInfoVo> excelInfo(@RequestParam String fileKey) throws Exception {
|
||||
return ApiResponse.success(localFileStorageService.getExcelInfo(fileKey));
|
||||
}
|
||||
|
||||
private String resolveMediaType(String contentType, String filename) {
|
||||
String type = contentType == null ? "" : contentType.toLowerCase();
|
||||
String name = filename == null ? "" : filename.toLowerCase();
|
||||
if (type.startsWith("image/") || name.matches(".*\\.(png|jpe?g|webp|gif|bmp|svg)$")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/") || name.matches(".*\\.(mp4|mov|webm|m4v|ogg|avi|mkv)$")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/") || name.matches(".*\\.(mp3|wav|m4a|aac|flac)$")) {
|
||||
return "audio";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,4 +21,13 @@ public class UploadFileVo {
|
||||
|
||||
@Schema(description = "相对上传目录路径")
|
||||
private String relativePath;
|
||||
|
||||
@Schema(description = "OSS 对象 key。仅 uploadToOss=true 时返回")
|
||||
private String objectKey;
|
||||
|
||||
@Schema(description = "OSS 公网 URL。仅 uploadToOss=true 时返回")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "媒体类型:image/video/audio/file。仅 uploadToOss=true 时返回")
|
||||
private String mediaType;
|
||||
}
|
||||
|
||||
@@ -10,9 +10,8 @@ import org.springframework.stereotype.Service;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@@ -23,8 +22,8 @@ public class OssStorageService {
|
||||
private final OssProperties ossProperties;
|
||||
|
||||
/**
|
||||
* 上传结果文件到 OSS,返回 objectKey(非预签名 URL)。
|
||||
* 调用方应存储 objectKey,下载时通过 generateFreshDownloadUrl 按需生成链接。
|
||||
* 上传结果文件到 OSS,返回 objectKey。
|
||||
* 调用方应存储 objectKey,下载时通过 generateFreshDownloadUrl 生成公开直链。
|
||||
*/
|
||||
public String uploadResultFile(File file, String moduleType) {
|
||||
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
|
||||
@@ -39,12 +38,30 @@ public class OssStorageService {
|
||||
|
||||
public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) {
|
||||
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
|
||||
String bucket = resolveBucket(moduleType);
|
||||
OSS ossClient = buildClient();
|
||||
try {
|
||||
ossClient.putObject(ossProperties.getBucket(), objectKey, file);
|
||||
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
|
||||
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
|
||||
return new UploadedResult(objectKey, url.toString());
|
||||
ossClient.putObject(bucket, objectKey, file);
|
||||
return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket));
|
||||
} finally {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
public UploadedResult uploadPublicFileWithFreshDownloadUrl(File file, String moduleType, String originalFilename) {
|
||||
String normalizedModuleType = moduleType == null || moduleType.isBlank()
|
||||
? "common"
|
||||
: moduleType.trim().toLowerCase();
|
||||
String objectName = sanitizeObjectName(originalFilename);
|
||||
if (objectName.isBlank()) {
|
||||
objectName = file.getName();
|
||||
}
|
||||
String objectKey = String.format("upload/%s/%s/%s", normalizedModuleType, UUID.randomUUID(), objectName);
|
||||
String bucket = resolveBucket(moduleType);
|
||||
OSS ossClient = buildClient();
|
||||
try {
|
||||
ossClient.putObject(bucket, objectKey, file);
|
||||
return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket));
|
||||
} finally {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
@@ -112,33 +129,50 @@ public class OssStorageService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 objectKey 生成预签名下载 URL(1小时有效)。
|
||||
*/
|
||||
public String generateDownloadUrl(String objectKey) {
|
||||
public boolean objectExists(String value) {
|
||||
String objectKey = resolveObjectKey(value);
|
||||
if (objectKey == null || objectKey.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
OSS ossClient = buildClient();
|
||||
try {
|
||||
Date expiration = new Date(System.currentTimeMillis() + 3600_000L);
|
||||
URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration);
|
||||
return url.toString();
|
||||
return ossClient.doesObjectExist(ossProperties.getBucket(), objectKey);
|
||||
} finally {
|
||||
ossClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 objectKey 生成公开直链下载 URL。
|
||||
*/
|
||||
public String generateDownloadUrl(String objectKey) {
|
||||
return getPublicUrl(objectKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开(无签名)URL,格式:https://{bucket}.{endpoint}/{objectKey}
|
||||
*/
|
||||
public String getPublicUrl(String objectKey) {
|
||||
if (objectKey == null || objectKey.isBlank()) {
|
||||
public String getPublicUrl(String value) {
|
||||
return getPublicUrl(value, ossProperties.getBucket());
|
||||
}
|
||||
|
||||
private String getPublicUrl(String value, String bucket) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return String.format("https://%s.%s/%s", ossProperties.getBucket(), ossProperties.getEndpoint(), objectKey);
|
||||
String objectKey = resolveObjectKey(value);
|
||||
String normalizedKey = objectKey.startsWith("/") ? objectKey.substring(1) : objectKey;
|
||||
String host = String.format("%s.%s", bucket, publicEndpoint());
|
||||
try {
|
||||
return new URI("https", host, "/" + normalizedKey, null).toASCIIString();
|
||||
} catch (Exception ignored) {
|
||||
return String.format("https://%s/%s", host, normalizedKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从存储值中解析出 objectKey,兼容两种格式:
|
||||
* - 旧格式:完整预签名 URL(https://bucket.endpoint/objectKey?Expires=...)
|
||||
* - 旧格式:完整 URL(https://bucket.endpoint/objectKey?Expires=...)
|
||||
* - 新格式:直接是 objectKey(如 result/dedupe/uuid/file.xlsx)
|
||||
*/
|
||||
public String resolveObjectKey(String value) {
|
||||
@@ -151,13 +185,21 @@ public class OssStorageService {
|
||||
return path.startsWith("/") ? path.substring(1) : path;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
int schemeEnd = value.indexOf("://");
|
||||
int pathStart = schemeEnd < 0 ? -1 : value.indexOf('/', schemeEnd + 3);
|
||||
if (pathStart >= 0 && pathStart + 1 < value.length()) {
|
||||
String path = value.substring(pathStart + 1);
|
||||
int queryStart = path.indexOf('?');
|
||||
String objectKey = queryStart >= 0 ? path.substring(0, queryStart) : path;
|
||||
return URLDecoder.decode(objectKey, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据存储值(objectKey 或旧格式预签名 URL)生成新鲜的预签名下载 URL。
|
||||
* 供各模块 listHistory 使用,每次按需生成,避免旧 URL 1小时后过期。
|
||||
* 根据存储值(objectKey 或旧格式完整 URL)生成公开直链下载 URL。
|
||||
* 供各模块 listHistory 和下载接口使用,避免签名过期。
|
||||
*/
|
||||
public String generateFreshDownloadUrl(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
@@ -174,6 +216,42 @@ public class OssStorageService {
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveBucket(String moduleType) {
|
||||
if (moduleType != null
|
||||
&& "IMAGE_VIDEO".equalsIgnoreCase(moduleType.trim())
|
||||
&& ossProperties.getImageVideoBucket() != null
|
||||
&& !ossProperties.getImageVideoBucket().isBlank()) {
|
||||
return ossProperties.getImageVideoBucket().trim();
|
||||
}
|
||||
return ossProperties.getBucket();
|
||||
}
|
||||
|
||||
private String publicEndpoint() {
|
||||
String endpoint = ossProperties.getPublicEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
endpoint = ossProperties.getEndpoint();
|
||||
}
|
||||
endpoint = endpoint.trim();
|
||||
if (endpoint.startsWith("http://")) {
|
||||
endpoint = endpoint.substring("http://".length());
|
||||
} else if (endpoint.startsWith("https://")) {
|
||||
endpoint = endpoint.substring("https://".length());
|
||||
}
|
||||
return endpoint.endsWith("/") ? endpoint.substring(0, endpoint.length() - 1) : endpoint;
|
||||
}
|
||||
|
||||
private String sanitizeObjectName(String filename) {
|
||||
if (filename == null || filename.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String normalized = filename.trim().replace('\\', '/');
|
||||
int slashIndex = normalized.lastIndexOf('/');
|
||||
if (slashIndex >= 0 && slashIndex + 1 < normalized.length()) {
|
||||
normalized = normalized.substring(slashIndex + 1);
|
||||
}
|
||||
return normalized.replaceAll("[\\r\\n]", "_");
|
||||
}
|
||||
|
||||
public record UploadedResult(String objectKey, String downloadUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoSecretSaveRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceCloneRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceDeleteRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceListRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceSynthesisRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowResultRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowRunRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoAsyncTaskVo;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoMediaUploadVo;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoSecretStatusVo;
|
||||
import com.nanri.aiimage.modules.imagevideo.service.ImageVideoCozeService;
|
||||
import com.nanri.aiimage.modules.imagevideo.service.ImageVideoAsyncTaskService;
|
||||
import com.nanri.aiimage.modules.imagevideo.service.ImageVideoSecretService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/image-video")
|
||||
@Tag(name = "视频复刻/图生视频", description = "视频复刻和图生视频相关 AI 接口")
|
||||
public class ImageVideoController {
|
||||
|
||||
private final ImageVideoCozeService imageVideoCozeService;
|
||||
private final ImageVideoSecretService imageVideoSecretService;
|
||||
private final ImageVideoAsyncTaskService imageVideoAsyncTaskService;
|
||||
|
||||
@GetMapping("/secrets")
|
||||
@Operation(summary = "查询视频密钥配置状态")
|
||||
public ApiResponse<ImageVideoSecretStatusVo> secretStatus(@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(imageVideoSecretService.status(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/secrets")
|
||||
@Operation(summary = "保存视频密钥配置")
|
||||
public ApiResponse<ImageVideoSecretStatusVo> saveSecrets(@Valid @RequestBody ImageVideoSecretSaveRequest request) {
|
||||
return ApiResponse.success(imageVideoSecretService.save(request));
|
||||
}
|
||||
|
||||
@PostMapping("/douyin-copy")
|
||||
@Operation(summary = "抖音文案识别和仿写")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> douyinCopy(@Valid @RequestBody DouyinCopyRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitDouyinCopy(request));
|
||||
}
|
||||
|
||||
@PostMapping("/workflow/run")
|
||||
@Operation(summary = "提交图生/复刻视频工作流")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> runWorkflow(@Valid @RequestBody ImageVideoWorkflowRunRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitWorkflow(request));
|
||||
}
|
||||
|
||||
@PostMapping("/workflow/result")
|
||||
@Operation(summary = "查询图生/复刻视频工作流异步结果")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> workflowResult(@Valid @RequestBody ImageVideoWorkflowResultRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitWorkflowResult(request));
|
||||
}
|
||||
|
||||
@PostMapping("/media/upload")
|
||||
@Operation(summary = "上传图生/复刻视频媒体素材")
|
||||
public ApiResponse<ImageVideoMediaUploadVo> uploadMedia(@RequestParam("file") MultipartFile file) {
|
||||
return ApiResponse.success(imageVideoCozeService.uploadMedia(file));
|
||||
}
|
||||
|
||||
@PostMapping("/voice/list")
|
||||
@Operation(summary = "查看音色")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceList(request));
|
||||
}
|
||||
|
||||
@PostMapping("/voice/delete")
|
||||
@Operation(summary = "删除音色")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> deleteVoice(@Valid @RequestBody ImageVideoVoiceDeleteRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceDelete(request));
|
||||
}
|
||||
|
||||
@PostMapping("/voice/clone")
|
||||
@Operation(summary = "音色克隆")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceClone(request));
|
||||
}
|
||||
|
||||
@PostMapping("/voice/synthesis")
|
||||
@Operation(summary = "语音合成")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> synthesizeVoice(@Valid @RequestBody ImageVideoVoiceSynthesisRequest request) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.submitVoiceSynthesis(request));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "查询图生视频异步任务")
|
||||
public ApiResponse<ImageVideoAsyncTaskVo> getTask(
|
||||
@PathVariable Long taskId,
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(imageVideoAsyncTaskService.getTask(taskId, userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface ImageVideoAsyncTaskMapper extends BaseMapper<ImageVideoAsyncTaskEntity> {
|
||||
|
||||
@Update("UPDATE biz_image_video_async_task "
|
||||
+ "SET status = 'RUNNING', updated_at = NOW(), attempt_count = attempt_count + 1 "
|
||||
+ "WHERE id = #{taskId} AND status = 'PENDING'")
|
||||
int claimPending(Long taskId);
|
||||
|
||||
@Update("UPDATE biz_image_video_async_task "
|
||||
+ "SET status = 'POLLING', updated_at = NOW(), attempt_count = attempt_count + 1 "
|
||||
+ "WHERE id = #{taskId} AND status = 'WAITING'")
|
||||
int claimWaiting(Long taskId);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoSecretEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ImageVideoSecretMapper extends BaseMapper<ImageVideoSecretEntity> {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoWorkflowConfigEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface ImageVideoWorkflowConfigMapper extends BaseMapper<ImageVideoWorkflowConfigEntity> {
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "抖音文案解析和仿写请求")
|
||||
public class DouyinCopyRequest {
|
||||
|
||||
@Schema(description = "用户ID", example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "文案工作流业务 api_key")
|
||||
@JsonProperty("api_key")
|
||||
@JsonAlias("apiKey")
|
||||
private String apiKey = "";
|
||||
|
||||
@Schema(description = "T8 平台密钥")
|
||||
@JsonProperty("t8_key")
|
||||
@JsonAlias("t8Key")
|
||||
private String t8Key = "";
|
||||
|
||||
@NotBlank(message = "url 不能为空")
|
||||
@Schema(description = "抖音分享口令、短链或视频链接", example = "2.38 复制打开抖音,看看")
|
||||
private String url;
|
||||
|
||||
@Schema(description = "目标视频时长,单位秒", example = "15")
|
||||
private Integer duration;
|
||||
|
||||
@Schema(description = "产品信息")
|
||||
@JsonProperty("proc_info")
|
||||
@JsonAlias("procInfo")
|
||||
private ProcInfo procInfo = new ProcInfo();
|
||||
|
||||
@Data
|
||||
@Schema(description = "抖音文案仿写产品信息")
|
||||
public static class ProcInfo {
|
||||
@Schema(description = "产品类目", example = "连衣裙")
|
||||
private String type = "";
|
||||
|
||||
@Schema(description = "产品名称", example = "亚麻连衣裙")
|
||||
private String name = "";
|
||||
|
||||
@Schema(description = "产品图片 URL 列表")
|
||||
@JsonProperty("proc_image")
|
||||
@JsonAlias("procImage")
|
||||
private List<String> procImage = new ArrayList<>();
|
||||
|
||||
@Schema(description = "核心卖点或产品属性", example = "优雅、亚麻、白色")
|
||||
private String properties = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "视频复刻/图生视频密钥保存请求")
|
||||
public class ImageVideoSecretSaveRequest {
|
||||
|
||||
@NotNull(message = "userId 不能为空")
|
||||
@Schema(description = "用户ID", example = "1")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "爬虫密钥;首次配置或过期后必填", example = "sk-...")
|
||||
private String copyApiKey;
|
||||
|
||||
private String t8Key;
|
||||
|
||||
@Schema(description = "T8密钥(国内);首次配置或过期后必填")
|
||||
private String t8VideoKey;
|
||||
|
||||
@Schema(description = "音频密钥;首次配置或过期后必填", example = "sk-api-...")
|
||||
private String voiceApiKey;
|
||||
|
||||
@Schema(description = "音频团队ID;首次配置或过期后必填", example = "2030928714635682107")
|
||||
private String voiceGroupId;
|
||||
|
||||
@NotNull(message = "有效期不能为空")
|
||||
@Schema(description = "有效期天数,只允许 1、7、30、0;0 表示永久", example = "7")
|
||||
private Integer expireDays;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoVoiceCloneRequest {
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
private String name;
|
||||
private String audioUrl;
|
||||
private String videoUrl;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoVoiceDeleteRequest {
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "voice_id 不能为空")
|
||||
private String voiceId;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoVoiceListRequest {
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
|
||||
private String name;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoVoiceSynthesisRequest {
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "text 不能为空")
|
||||
private String text;
|
||||
|
||||
@NotBlank(message = "voice_id 不能为空")
|
||||
private String voiceId;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoWorkflowResultRequest {
|
||||
@NotNull(message = "userId cannot be null")
|
||||
private Long userId;
|
||||
|
||||
@NotBlank(message = "executeId cannot be blank")
|
||||
private String executeId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class ImageVideoWorkflowRunRequest {
|
||||
@NotNull(message = "用户ID不能为空")
|
||||
private Long userId;
|
||||
|
||||
@NotNull(message = "工作流参数不能为空")
|
||||
private Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_image_video_async_task")
|
||||
public class ImageVideoAsyncTaskEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String taskType;
|
||||
private String status;
|
||||
private String requestJson;
|
||||
private String submitResponseJson;
|
||||
private String resultJson;
|
||||
private String videoUrlsJson;
|
||||
private String debugUrl;
|
||||
private String archivedVideosJson;
|
||||
private String archiveStatus;
|
||||
private String archiveError;
|
||||
private Integer archiveAttemptCount;
|
||||
private LocalDateTime archivedAt;
|
||||
private String errorMessage;
|
||||
private String cozeExecuteId;
|
||||
private String cozeStatus;
|
||||
private Integer attemptCount;
|
||||
private LocalDateTime submittedAt;
|
||||
private LocalDateTime completedAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_image_video_secret")
|
||||
public class ImageVideoSecretEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String copyApiKey;
|
||||
private String t8Key;
|
||||
private String t8VideoKey;
|
||||
private String voiceApiKey;
|
||||
private String voiceGroupId;
|
||||
private LocalDateTime expiresAt;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_image_video_workflow_config")
|
||||
public class ImageVideoWorkflowConfigEntity {
|
||||
|
||||
@TableId
|
||||
private String configKey;
|
||||
private String workflowId;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "抖音文案解析和仿写结果")
|
||||
public class DouyinCopyVo {
|
||||
|
||||
@Schema(description = "识别出的原始文案")
|
||||
private String recognizedContent;
|
||||
|
||||
@Schema(description = "仿写后的文案")
|
||||
private String scriptDraft;
|
||||
|
||||
@Schema(description = "Coze 执行 ID")
|
||||
private String executeId;
|
||||
|
||||
@Schema(description = "Coze 调试链接")
|
||||
private String debugUrl;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class ImageVideoAsyncTaskVo {
|
||||
private Long taskId;
|
||||
private String taskType;
|
||||
private String status;
|
||||
private String cozeExecuteId;
|
||||
private String cozeStatus;
|
||||
private String debugUrl;
|
||||
private Object result;
|
||||
private String errorMessage;
|
||||
private LocalDateTime submittedAt;
|
||||
private LocalDateTime completedAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ImageVideoMediaUploadVo {
|
||||
private String url;
|
||||
private String objectKey;
|
||||
private String originalFilename;
|
||||
private String mediaType;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "视频复刻/图生视频密钥配置状态")
|
||||
public class ImageVideoSecretStatusVo {
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "是否已保存完整密钥")
|
||||
private Boolean configured;
|
||||
|
||||
@Schema(description = "是否在有效期内")
|
||||
private Boolean valid;
|
||||
|
||||
@Schema(description = "是否已经过期")
|
||||
private Boolean expired;
|
||||
|
||||
@Schema(description = "爬虫密钥是否已保存")
|
||||
private Boolean hasCopyApiKey;
|
||||
|
||||
private Boolean hasT8Key;
|
||||
|
||||
private Boolean hasT8VideoKey;
|
||||
|
||||
@Schema(description = "音频密钥是否已保存")
|
||||
private Boolean hasVoiceApiKey;
|
||||
|
||||
@Schema(description = "音频团队ID是否已保存")
|
||||
private Boolean hasVoiceGroupId;
|
||||
|
||||
@Schema(description = "脱敏后的爬虫密钥")
|
||||
private String copyApiKeyMasked;
|
||||
|
||||
private String t8KeyMasked;
|
||||
|
||||
private String t8VideoKeyMasked;
|
||||
|
||||
@Schema(description = "脱敏后的音频密钥")
|
||||
private String voiceApiKeyMasked;
|
||||
|
||||
@Schema(description = "脱敏后的音频团队ID")
|
||||
private String voiceGroupIdMasked;
|
||||
|
||||
@Schema(description = "用户选择的有效期天数,支持 1、7、30、0;0 表示永久")
|
||||
private Integer expireDays;
|
||||
|
||||
@Schema(description = "过期时间")
|
||||
private LocalDateTime expiresAt;
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageVideoArchiveService {
|
||||
|
||||
static final String TASK_TYPE = "IMAGE_VIDEO_WORKFLOW";
|
||||
private static final int BATCH_SIZE = 20;
|
||||
private static final int RETENTION_CLEANUP_BATCH_SIZE = 200;
|
||||
private static final int RETENTION_DAYS = 3;
|
||||
private static final Set<String> VIDEO_KEYS = Set.of(
|
||||
"videourl", "video", "outputvideourl", "resultvideourl"
|
||||
);
|
||||
private static final Set<String> DEBUG_KEYS = Set.of("debugurl");
|
||||
|
||||
private final ImageVideoAsyncTaskMapper taskMapper;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ImageVideoProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
void captureSubmitResponse(ImageVideoAsyncTaskEntity task, Object response) {
|
||||
if (!isWorkflowTask(task) || response == null) {
|
||||
return;
|
||||
}
|
||||
task.setSubmitResponseJson(writeJson(response));
|
||||
String debugUrl = findFirstUrl(response, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void capturePollResponse(ImageVideoAsyncTaskEntity task, Object response) {
|
||||
if (!isWorkflowTask(task) || response == null
|
||||
|| (task.getDebugUrl() != null && !task.getDebugUrl().isBlank())) {
|
||||
return;
|
||||
}
|
||||
String debugUrl = findFirstUrl(response, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
|
||||
void enrichCompletedTask(ImageVideoAsyncTaskEntity task, Object result) {
|
||||
if (!isWorkflowTask(task)) {
|
||||
return;
|
||||
}
|
||||
List<String> videoUrls = findUrls(result, VIDEO_KEYS);
|
||||
task.setVideoUrlsJson(writeJson(videoUrls));
|
||||
if (task.getDebugUrl() == null || task.getDebugUrl().isBlank()) {
|
||||
String debugUrl = findFirstUrl(result, DEBUG_KEYS);
|
||||
if (!debugUrl.isBlank()) {
|
||||
task.setDebugUrl(debugUrl);
|
||||
}
|
||||
}
|
||||
task.setArchiveStatus(videoUrls.isEmpty() ? "NO_VIDEO" : "PENDING");
|
||||
task.setArchiveError(null);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.archive-delay-ms:10000}")
|
||||
public void archivePendingVideos() {
|
||||
int maxAttempts = Math.max(1, properties.getArchiveMaxAttempts());
|
||||
List<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(
|
||||
new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getTaskType, TASK_TYPE)
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, "SUCCESS")
|
||||
.and(q -> q.isNull(ImageVideoAsyncTaskEntity::getArchiveStatus)
|
||||
.or().in(ImageVideoAsyncTaskEntity::getArchiveStatus, "PENDING", "FAILED"))
|
||||
.lt(ImageVideoAsyncTaskEntity::getArchiveAttemptCount, maxAttempts)
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getId)
|
||||
.last("LIMIT " + BATCH_SIZE));
|
||||
for (ImageVideoAsyncTaskEntity task : tasks) {
|
||||
backfillDerivedFields(task);
|
||||
if ("PENDING".equals(task.getArchiveStatus()) || "FAILED".equals(task.getArchiveStatus())) {
|
||||
archiveTask(task.getId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.retention-cleanup-delay-ms:600000}")
|
||||
public void cleanupExpiredWorkflowTasks() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusDays(RETENTION_DAYS);
|
||||
List<ImageVideoAsyncTaskEntity> expiredTasks = taskMapper.selectList(
|
||||
new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getTaskType, TASK_TYPE)
|
||||
.lt(ImageVideoAsyncTaskEntity::getSubmittedAt, cutoff)
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getId)
|
||||
.last("LIMIT " + RETENTION_CLEANUP_BATCH_SIZE));
|
||||
int deleted = 0;
|
||||
for (ImageVideoAsyncTaskEntity task : expiredTasks) {
|
||||
if (!deleteArchivedObjects(task)) {
|
||||
continue;
|
||||
}
|
||||
deleted += taskMapper.deleteById(task.getId());
|
||||
}
|
||||
if (deleted > 0) {
|
||||
log.info("[image-video] expired workflow task cleanup deleted={} cutoff={}", deleted, cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean deleteArchivedObjects(ImageVideoAsyncTaskEntity task) {
|
||||
Set<String> objectKeys = new LinkedHashSet<>();
|
||||
for (Map<String, Object> archivedVideo : readArchivedVideos(task.getArchivedVideosJson())) {
|
||||
Object objectKey = archivedVideo.get("objectKey");
|
||||
if (objectKey != null && !String.valueOf(objectKey).isBlank()) {
|
||||
objectKeys.add(String.valueOf(objectKey));
|
||||
}
|
||||
}
|
||||
for (String objectKey : objectKeys) {
|
||||
try {
|
||||
ossStorageService.deleteObject(objectKey);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[image-video] expired task OSS cleanup failed taskId={} objectKey={} error={}",
|
||||
task.getId(), objectKey, ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void backfillDerivedFields(ImageVideoAsyncTaskEntity task) {
|
||||
if (task.getArchiveStatus() != null) {
|
||||
return;
|
||||
}
|
||||
Object result = readJsonValue(task.getResultJson());
|
||||
enrichCompletedTask(task, result);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private void archiveTask(Long taskId) {
|
||||
int maxAttempts = Math.max(1, properties.getArchiveMaxAttempts());
|
||||
int claimed = taskMapper.update(null, new LambdaUpdateWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.set(ImageVideoAsyncTaskEntity::getArchiveStatus, "ARCHIVING")
|
||||
.set(ImageVideoAsyncTaskEntity::getArchiveError, null)
|
||||
.setSql("archive_attempt_count = archive_attempt_count + 1")
|
||||
.set(ImageVideoAsyncTaskEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.eq(ImageVideoAsyncTaskEntity::getId, taskId)
|
||||
.in(ImageVideoAsyncTaskEntity::getArchiveStatus, "PENDING", "FAILED")
|
||||
.lt(ImageVideoAsyncTaskEntity::getArchiveAttemptCount, maxAttempts));
|
||||
if (claimed != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
List<String> videoUrls = readStringList(task.getVideoUrlsJson());
|
||||
List<Map<String, Object>> archived = readArchivedVideos(task.getArchivedVideosJson());
|
||||
Map<String, Map<String, Object>> existingBySource = new LinkedHashMap<>();
|
||||
for (Map<String, Object> item : archived) {
|
||||
existingBySource.put(String.valueOf(item.getOrDefault("sourceUrl", "")), item);
|
||||
}
|
||||
|
||||
String firstError = "";
|
||||
List<Map<String, Object>> updated = new ArrayList<>();
|
||||
for (String sourceUrl : videoUrls) {
|
||||
Map<String, Object> existing = existingBySource.get(sourceUrl);
|
||||
if (existing != null && "SUCCESS".equals(existing.get("status"))) {
|
||||
updated.add(existing);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
updated.add(archiveOne(sourceUrl));
|
||||
} catch (Exception ex) {
|
||||
if (firstError.isBlank()) {
|
||||
firstError = messageOf(ex);
|
||||
}
|
||||
Map<String, Object> failed = new LinkedHashMap<>();
|
||||
failed.put("sourceUrl", sourceUrl);
|
||||
failed.put("status", "FAILED");
|
||||
failed.put("error", messageOf(ex));
|
||||
updated.add(failed);
|
||||
}
|
||||
}
|
||||
|
||||
task.setArchivedVideosJson(writeJson(updated));
|
||||
task.setArchiveStatus(firstError.isBlank() ? "SUCCESS" : "FAILED");
|
||||
task.setArchiveError(firstError.isBlank() ? null : truncate(firstError));
|
||||
task.setArchivedAt(firstError.isBlank() ? LocalDateTime.now() : null);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private Map<String, Object> archiveOne(String sourceUrl) throws Exception {
|
||||
URI uri = URI.create(sourceUrl);
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(scheme) && !"https".equals(scheme)) {
|
||||
throw new IllegalArgumentException("Unsupported video URL scheme");
|
||||
}
|
||||
String suffix = videoSuffix(uri.getPath());
|
||||
File tempFile = Files.createTempFile("image-video-archive-", suffix).toFile();
|
||||
HttpURLConnection connection = null;
|
||||
try {
|
||||
connection = (HttpURLConnection) uri.toURL().openConnection();
|
||||
connection.setConnectTimeout(Math.max(1000, properties.getArchiveConnectTimeoutMillis()));
|
||||
connection.setReadTimeout(Math.max(1000, properties.getArchiveReadTimeoutMillis()));
|
||||
connection.setInstanceFollowRedirects(true);
|
||||
connection.setRequestProperty("User-Agent", "aiimage-video-archive/1.0");
|
||||
int status = connection.getResponseCode();
|
||||
if (status < 200 || status >= 300) {
|
||||
throw new IllegalStateException("Video download returned HTTP " + status);
|
||||
}
|
||||
try (var input = connection.getInputStream(); var output = new FileOutputStream(tempFile)) {
|
||||
input.transferTo(output);
|
||||
}
|
||||
OssStorageService.UploadedResult uploaded =
|
||||
ossStorageService.uploadResultFileWithFreshDownloadUrl(tempFile, "IMAGE_VIDEO");
|
||||
Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("sourceUrl", sourceUrl);
|
||||
item.put("objectKey", uploaded.objectKey());
|
||||
item.put("url", uploaded.downloadUrl());
|
||||
item.put("status", "SUCCESS");
|
||||
return item;
|
||||
} finally {
|
||||
if (connection != null) {
|
||||
connection.disconnect();
|
||||
}
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
log.warn("[image-video] archive temp file delete failed path={}", tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static List<String> findUrls(Object value, Set<String> keys) {
|
||||
LinkedHashSet<String> result = new LinkedHashSet<>();
|
||||
collectUrls(value, keys, result, new ObjectMapper());
|
||||
return new ArrayList<>(result);
|
||||
}
|
||||
|
||||
private static void collectUrls(Object value, Set<String> keys, Set<String> result, ObjectMapper mapper) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
String key = canonicalKey(String.valueOf(entry.getKey()));
|
||||
if (keys.contains(key)) {
|
||||
collectHttpStrings(entry.getValue(), result, mapper);
|
||||
}
|
||||
collectUrls(entry.getValue(), keys, result, mapper);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value instanceof Iterable<?> iterable) {
|
||||
iterable.forEach(item -> collectUrls(item, keys, result, mapper));
|
||||
return;
|
||||
}
|
||||
if (value instanceof JsonNode node) {
|
||||
collectUrls(mapper.convertValue(node, Object.class), keys, result, mapper);
|
||||
return;
|
||||
}
|
||||
if (value instanceof String text && looksLikeJson(text)) {
|
||||
try {
|
||||
collectUrls(mapper.readValue(text, Object.class), keys, result, mapper);
|
||||
} catch (Exception ignored) {
|
||||
// Not every string that starts with a brace is valid JSON.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void collectHttpStrings(Object value, Set<String> result, ObjectMapper mapper) {
|
||||
if (value instanceof String text) {
|
||||
String normalized = text.trim();
|
||||
if (normalized.startsWith("http://") || normalized.startsWith("https://")) {
|
||||
result.add(normalized);
|
||||
} else if (looksLikeJson(normalized)) {
|
||||
try {
|
||||
collectHttpStrings(mapper.readValue(normalized, Object.class), result, mapper);
|
||||
} catch (Exception ignored) {
|
||||
// Ignore malformed nested JSON.
|
||||
}
|
||||
}
|
||||
} else if (value instanceof Iterable<?> iterable) {
|
||||
iterable.forEach(item -> collectHttpStrings(item, result, mapper));
|
||||
} else if (value instanceof Map<?, ?> map) {
|
||||
map.values().forEach(item -> collectHttpStrings(item, result, mapper));
|
||||
}
|
||||
}
|
||||
|
||||
private static String findFirstUrl(Object value, Set<String> keys) {
|
||||
List<String> urls = findUrls(value, keys);
|
||||
return urls.isEmpty() ? "" : urls.getFirst();
|
||||
}
|
||||
|
||||
private static String canonicalKey(String key) {
|
||||
return key == null ? "" : key.replaceAll("[^A-Za-z0-9]", "").toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private static boolean looksLikeJson(String value) {
|
||||
String text = value == null ? "" : value.trim();
|
||||
return (text.startsWith("{") && text.endsWith("}")) || (text.startsWith("[") && text.endsWith("]"));
|
||||
}
|
||||
|
||||
private boolean isWorkflowTask(ImageVideoAsyncTaskEntity task) {
|
||||
return task != null && TASK_TYPE.equals(task.getTaskType());
|
||||
}
|
||||
|
||||
private Object readJsonValue(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, Object.class);
|
||||
} catch (Exception ex) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> readStringList(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception ex) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> readArchivedVideos(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception ex) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("Image video audit serialization failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String videoSuffix(String path) {
|
||||
String normalized = path == null ? "" : path.toLowerCase(Locale.ROOT);
|
||||
for (String extension : List.of(".mp4", ".mov", ".webm", ".m4v")) {
|
||||
if (normalized.endsWith(extension)) {
|
||||
return extension;
|
||||
}
|
||||
}
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
private String messageOf(Exception ex) {
|
||||
return ex.getMessage() == null || ex.getMessage().isBlank() ? ex.getClass().getSimpleName() : ex.getMessage();
|
||||
}
|
||||
|
||||
private String truncate(String value) {
|
||||
return value.length() <= 1000 ? value : value.substring(0, 1000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoAsyncTaskMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceCloneRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceDeleteRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceListRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoVoiceSynthesisRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowResultRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoWorkflowRunRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoAsyncTaskEntity;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoAsyncTaskVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
public class ImageVideoAsyncTaskService {
|
||||
|
||||
private static final int DISPATCH_BATCH_SIZE = 20;
|
||||
private static final int POLL_BATCH_SIZE = 50;
|
||||
private static final int FAILED_TASK_RETENTION_MINUTES = 10;
|
||||
private static final Set<String> TERMINAL_STATUSES = Set.of(
|
||||
"SUCCESS", "SUCCEEDED", "COMPLETED", "DONE", "FINISHED",
|
||||
"FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED"
|
||||
);
|
||||
private static final Set<String> FAILED_STATUSES = Set.of("FAILED", "FAIL", "ERROR", "CANCELED", "CANCELLED");
|
||||
private static final Set<String> COZE_EXECUTION_STATUS_FIELDS = Set.of(
|
||||
"execute_status", "executeStatus", "workflow_status", "workflowStatus", "status"
|
||||
);
|
||||
private static final Set<String> COZE_PRIMARY_STATUS_FIELDS = Set.of(
|
||||
"execute_status", "executeStatus", "workflow_status", "workflowStatus"
|
||||
);
|
||||
|
||||
private final ImageVideoAsyncTaskMapper taskMapper;
|
||||
private final ImageVideoCozeService cozeService;
|
||||
private final ImageVideoWorkflowConfigService workflowConfigService;
|
||||
private final ImageVideoArchiveService archiveService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskExecutor cozeTaskExecutor;
|
||||
|
||||
public ImageVideoAsyncTaskService(
|
||||
ImageVideoAsyncTaskMapper taskMapper,
|
||||
ImageVideoCozeService cozeService,
|
||||
ImageVideoWorkflowConfigService workflowConfigService,
|
||||
ImageVideoArchiveService archiveService,
|
||||
ObjectMapper objectMapper,
|
||||
@Qualifier("cozeTaskExecutor") TaskExecutor cozeTaskExecutor) {
|
||||
this.taskMapper = taskMapper;
|
||||
this.cozeService = cozeService;
|
||||
this.workflowConfigService = workflowConfigService;
|
||||
this.archiveService = archiveService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.cozeTaskExecutor = cozeTaskExecutor;
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitDouyinCopy(DouyinCopyRequest request) {
|
||||
return submit(TaskType.DOUYIN_COPY, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitWorkflow(ImageVideoWorkflowRunRequest request) {
|
||||
return submit(TaskType.IMAGE_VIDEO_WORKFLOW, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitWorkflowResult(ImageVideoWorkflowResultRequest request) {
|
||||
return submit(TaskType.WORKFLOW_RESULT, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitVoiceList(ImageVideoVoiceListRequest request) {
|
||||
return submit(TaskType.VOICE_LIST, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitVoiceDelete(ImageVideoVoiceDeleteRequest request) {
|
||||
return submit(TaskType.VOICE_DELETE, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitVoiceClone(ImageVideoVoiceCloneRequest request) {
|
||||
return submit(TaskType.VOICE_CLONE, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo submitVoiceSynthesis(ImageVideoVoiceSynthesisRequest request) {
|
||||
return submit(TaskType.VOICE_SYNTHESIS, request.getUserId(), request);
|
||||
}
|
||||
|
||||
public ImageVideoAsyncTaskVo getTask(Long taskId, Long userId) {
|
||||
ImageVideoAsyncTaskEntity task = taskMapper.selectOne(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getId, taskId)
|
||||
.eq(ImageVideoAsyncTaskEntity::getUserId, userId));
|
||||
if (task == null) {
|
||||
throw new BusinessException("Image video task not found");
|
||||
}
|
||||
task = recoverFalseFailedTask(task);
|
||||
ImageVideoAsyncTaskVo result = toVo(task);
|
||||
if (TaskStatus.FAILED.name().equals(task.getStatus())) {
|
||||
taskMapper.deleteById(task.getId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.async-task-dispatch-delay-ms:1000}")
|
||||
public void dispatchPendingTasks() {
|
||||
List<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.PENDING.name())
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getId)
|
||||
.last("LIMIT " + DISPATCH_BATCH_SIZE));
|
||||
tasks.forEach(task -> cozeTaskExecutor.execute(() -> executeTask(task.getId())));
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.async-task-poll-delay-ms:5000}")
|
||||
public void pollWaitingTasks() {
|
||||
List<ImageVideoAsyncTaskEntity> tasks = taskMapper.selectList(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.WAITING.name())
|
||||
.orderByAsc(ImageVideoAsyncTaskEntity::getUpdatedAt)
|
||||
.last("LIMIT " + POLL_BATCH_SIZE));
|
||||
tasks.forEach(task -> cozeTaskExecutor.execute(() -> pollTask(task.getId())));
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${aiimage.image-video.failed-task-cleanup-delay-ms:60000}")
|
||||
public void cleanupFailedTasks() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(FAILED_TASK_RETENTION_MINUTES);
|
||||
int deleted = taskMapper.delete(new LambdaQueryWrapper<ImageVideoAsyncTaskEntity>()
|
||||
.eq(ImageVideoAsyncTaskEntity::getStatus, TaskStatus.FAILED.name())
|
||||
.lt(ImageVideoAsyncTaskEntity::getUpdatedAt, cutoff));
|
||||
if (deleted > 0) {
|
||||
log.info("[image-video] removed expired failed tasks count={}", deleted);
|
||||
}
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskVo submit(TaskType type, Long userId, Object payload) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("userId is required");
|
||||
}
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ImageVideoAsyncTaskEntity task = new ImageVideoAsyncTaskEntity();
|
||||
task.setUserId(userId);
|
||||
task.setTaskType(type.name());
|
||||
task.setStatus(TaskStatus.PENDING.name());
|
||||
task.setRequestJson(writeJson(payload));
|
||||
task.setAttemptCount(0);
|
||||
task.setSubmittedAt(now);
|
||||
task.setCreatedAt(now);
|
||||
task.setUpdatedAt(now);
|
||||
taskMapper.insert(task);
|
||||
cozeTaskExecutor.execute(() -> executeTask(task.getId()));
|
||||
return toVo(task);
|
||||
}
|
||||
|
||||
private void executeTask(Long taskId) {
|
||||
if (taskMapper.claimPending(taskId) != 1) {
|
||||
return;
|
||||
}
|
||||
ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TaskType type = TaskType.valueOf(task.getTaskType());
|
||||
if (type == TaskType.WORKFLOW_RESULT) {
|
||||
ImageVideoWorkflowResultRequest request = readJson(task.getRequestJson(), ImageVideoWorkflowResultRequest.class);
|
||||
waitForCoze(task, request.getExecuteId(), "", null);
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> response = submitToCoze(type, task.getRequestJson());
|
||||
archiveService.captureSubmitResponse(task, response);
|
||||
String executeId = findText(response, Set.of("execute_id", "executeId"));
|
||||
String cozeStatus = resolveCozeExecutionStatus(response);
|
||||
if (!executeId.isBlank() && !TERMINAL_STATUSES.contains(cozeStatus)) {
|
||||
waitForCoze(task, executeId, cozeStatus, response);
|
||||
return;
|
||||
}
|
||||
completeTask(task, transformResult(type, response), cozeStatus);
|
||||
} catch (Exception ex) {
|
||||
failTask(task, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void pollTask(Long taskId) {
|
||||
if (taskMapper.claimWaiting(taskId) != 1) {
|
||||
return;
|
||||
}
|
||||
ImageVideoAsyncTaskEntity task = taskMapper.selectById(taskId);
|
||||
if (task == null) {
|
||||
return;
|
||||
}
|
||||
if (task.getSubmittedAt() != null && task.getSubmittedAt().plusHours(1).isBefore(LocalDateTime.now())) {
|
||||
failTask(task, new BusinessException("Coze task exceeded the one-hour polling limit"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
TaskType type = TaskType.valueOf(task.getTaskType());
|
||||
String workflowId = workflowIdFor(type);
|
||||
Map<String, Object> result = cozeService.getWorkflowResult(task.getUserId(), workflowId, task.getCozeExecuteId());
|
||||
archiveService.capturePollResponse(task, result);
|
||||
String cozeStatus = resolveCozeExecutionStatus(result);
|
||||
if (TERMINAL_STATUSES.contains(cozeStatus)) {
|
||||
if (FAILED_STATUSES.contains(cozeStatus)) {
|
||||
task.setCozeStatus(cozeStatus);
|
||||
task.setResultJson(writeJson(result));
|
||||
failTask(task, new BusinessException("Coze workflow finished with status " + cozeStatus));
|
||||
} else {
|
||||
completeTask(task, transformResult(type, result), cozeStatus);
|
||||
}
|
||||
return;
|
||||
}
|
||||
task.setStatus(TaskStatus.WAITING.name());
|
||||
task.setCozeStatus(cozeStatus);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
} catch (Exception ex) {
|
||||
// Coze history calls are retried by the next polling cycle until the task's overall deadline.
|
||||
task.setStatus(TaskStatus.WAITING.name());
|
||||
task.setErrorMessage(truncate(messageOf(ex)));
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
log.warn("[image-video] async task poll failed taskId={} type={} error={}",
|
||||
task.getId(), task.getTaskType(), messageOf(ex));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> submitToCoze(TaskType type, String requestJson) {
|
||||
return switch (type) {
|
||||
case DOUYIN_COPY -> cozeService.submitDouyinCopy(readJson(requestJson, DouyinCopyRequest.class));
|
||||
case IMAGE_VIDEO_WORKFLOW -> {
|
||||
ImageVideoWorkflowRunRequest request = readJson(requestJson, ImageVideoWorkflowRunRequest.class);
|
||||
yield cozeService.runImageVideoWorkflow(request.getUserId(), request.getParameters());
|
||||
}
|
||||
case VOICE_LIST -> {
|
||||
ImageVideoVoiceListRequest request = readJson(requestJson, ImageVideoVoiceListRequest.class);
|
||||
yield cozeService.runVoiceList(request.getUserId(), request.getName());
|
||||
}
|
||||
case VOICE_DELETE -> {
|
||||
ImageVideoVoiceDeleteRequest request = readJson(requestJson, ImageVideoVoiceDeleteRequest.class);
|
||||
yield cozeService.runVoiceDelete(request.getUserId(), request.getVoiceId());
|
||||
}
|
||||
case VOICE_CLONE -> {
|
||||
ImageVideoVoiceCloneRequest request = readJson(requestJson, ImageVideoVoiceCloneRequest.class);
|
||||
yield cozeService.runVoiceClone(request.getUserId(), request.getName(), request.getAudioUrl(), request.getVideoUrl());
|
||||
}
|
||||
case VOICE_SYNTHESIS -> {
|
||||
ImageVideoVoiceSynthesisRequest request = readJson(requestJson, ImageVideoVoiceSynthesisRequest.class);
|
||||
yield cozeService.runVoiceSynthesis(request.getUserId(), request.getText(), request.getVoiceId());
|
||||
}
|
||||
case WORKFLOW_RESULT -> throw new IllegalStateException("Workflow result tasks do not submit a workflow");
|
||||
};
|
||||
}
|
||||
|
||||
private String workflowIdFor(TaskType type) {
|
||||
return switch (type) {
|
||||
case DOUYIN_COPY -> workflowConfigService.douyinCopyWorkflowId();
|
||||
case IMAGE_VIDEO_WORKFLOW, WORKFLOW_RESULT -> workflowConfigService.imageVideoWorkflowId();
|
||||
case VOICE_LIST -> workflowConfigService.voiceListWorkflowId();
|
||||
case VOICE_DELETE -> workflowConfigService.voiceDeleteWorkflowId();
|
||||
case VOICE_CLONE -> workflowConfigService.voiceCloneWorkflowId();
|
||||
case VOICE_SYNTHESIS -> workflowConfigService.voiceSynthesisWorkflowId();
|
||||
};
|
||||
}
|
||||
|
||||
private Object transformResult(TaskType type, Map<String, Object> result) {
|
||||
return type == TaskType.DOUYIN_COPY ? cozeService.parseDouyinCopyResult(result) : result;
|
||||
}
|
||||
|
||||
private void waitForCoze(ImageVideoAsyncTaskEntity task, String executeId, String cozeStatus, Object submitResponse) {
|
||||
archiveService.captureSubmitResponse(task, submitResponse);
|
||||
task.setStatus(TaskStatus.WAITING.name());
|
||||
task.setCozeExecuteId(executeId);
|
||||
task.setCozeStatus(normalizeStatus(cozeStatus));
|
||||
task.setErrorMessage(null);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private void completeTask(ImageVideoAsyncTaskEntity task, Object result, String cozeStatus) {
|
||||
task.setStatus(TaskStatus.SUCCESS.name());
|
||||
task.setResultJson(writeJson(result));
|
||||
archiveService.enrichCompletedTask(task, result);
|
||||
task.setCozeStatus(normalizeStatus(cozeStatus));
|
||||
task.setErrorMessage(null);
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(task.getCompletedAt());
|
||||
taskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private void failTask(ImageVideoAsyncTaskEntity task, Exception ex) {
|
||||
task.setStatus(TaskStatus.FAILED.name());
|
||||
task.setErrorMessage(truncate(messageOf(ex)));
|
||||
task.setCompletedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(task.getCompletedAt());
|
||||
taskMapper.updateById(task);
|
||||
log.warn("[image-video] async task failed taskId={} type={} error={}",
|
||||
task.getId(), task.getTaskType(), messageOf(ex));
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskEntity recoverFalseFailedTask(ImageVideoAsyncTaskEntity task) {
|
||||
if (!TaskStatus.FAILED.name().equals(task.getStatus())) {
|
||||
return task;
|
||||
}
|
||||
if (task.getCozeExecuteId() == null || task.getCozeExecuteId().isBlank()) {
|
||||
return task;
|
||||
}
|
||||
String storedCozeStatus = normalizeStatus(task.getCozeStatus());
|
||||
if (storedCozeStatus.isBlank() || TERMINAL_STATUSES.contains(storedCozeStatus)) {
|
||||
return task;
|
||||
}
|
||||
String errorMessage = task.getErrorMessage() == null ? "" : task.getErrorMessage();
|
||||
if (!errorMessage.startsWith("Coze workflow finished with status ")) {
|
||||
return task;
|
||||
}
|
||||
task.setStatus(TaskStatus.WAITING.name());
|
||||
task.setErrorMessage(null);
|
||||
task.setCompletedAt(null);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
taskMapper.updateById(task);
|
||||
log.info("[image-video] recovered false failed async task taskId={} cozeStatus={}",
|
||||
task.getId(), storedCozeStatus);
|
||||
return task;
|
||||
}
|
||||
|
||||
private ImageVideoAsyncTaskVo toVo(ImageVideoAsyncTaskEntity task) {
|
||||
ImageVideoAsyncTaskVo vo = new ImageVideoAsyncTaskVo();
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setTaskType(task.getTaskType());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setCozeExecuteId(task.getCozeExecuteId());
|
||||
vo.setCozeStatus(task.getCozeStatus());
|
||||
vo.setDebugUrl(task.getDebugUrl());
|
||||
vo.setResult(readResult(task.getResultJson()));
|
||||
vo.setErrorMessage(task.getErrorMessage());
|
||||
vo.setSubmittedAt(task.getSubmittedAt());
|
||||
vo.setCompletedAt(task.getCompletedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private <T> T readJson(String json, Class<T> type) {
|
||||
try {
|
||||
return objectMapper.readValue(json, type);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Image video task payload is invalid", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Object readResult(String json) {
|
||||
if (json == null || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception ex) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Image video task serialization failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private String findText(Object value, Set<String> names) {
|
||||
if (value instanceof Map<?, ?> map) {
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
Object child = entry.getValue();
|
||||
if (names.stream().anyMatch(name -> name.equalsIgnoreCase(key)) && child != null) {
|
||||
String text = String.valueOf(child).trim();
|
||||
if (!text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Object child : map.values()) {
|
||||
String nested = findText(child, names);
|
||||
if (!nested.isBlank()) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
} else if (value instanceof Collection<?> collection) {
|
||||
for (Object child : collection) {
|
||||
String nested = findText(child, names);
|
||||
if (!nested.isBlank()) {
|
||||
return nested;
|
||||
}
|
||||
}
|
||||
} else if (value instanceof String text) {
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return findText(objectMapper.readValue(trimmed, Object.class), names);
|
||||
} catch (Exception ignored) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String resolveCozeExecutionStatus(Object value) {
|
||||
Object data = childValue(value, "data");
|
||||
String dataPrimaryStatus = findDirectText(parsePossiblyJson(data), COZE_PRIMARY_STATUS_FIELDS);
|
||||
if (!dataPrimaryStatus.isBlank()) {
|
||||
return normalizeStatus(dataPrimaryStatus);
|
||||
}
|
||||
String directPrimaryStatus = findDirectText(value, COZE_PRIMARY_STATUS_FIELDS);
|
||||
if (!directPrimaryStatus.isBlank()) {
|
||||
return normalizeStatus(directPrimaryStatus);
|
||||
}
|
||||
String dataStatus = findDirectText(parsePossiblyJson(data), COZE_EXECUTION_STATUS_FIELDS);
|
||||
if (!dataStatus.isBlank()) {
|
||||
return normalizeStatus(dataStatus);
|
||||
}
|
||||
return normalizeStatus(findDirectText(value, COZE_EXECUTION_STATUS_FIELDS));
|
||||
}
|
||||
|
||||
private Object childValue(Object value, String name) {
|
||||
if (!(value instanceof Map<?, ?> map)) {
|
||||
return null;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
if (name.equalsIgnoreCase(String.valueOf(entry.getKey()))) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String findDirectText(Object value, Set<String> names) {
|
||||
Object parsed = parsePossiblyJson(value);
|
||||
if (parsed instanceof Collection<?> collection) {
|
||||
for (Object child : collection) {
|
||||
String text = findDirectText(child, names);
|
||||
if (!text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (!(parsed instanceof Map<?, ?> map)) {
|
||||
return "";
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : map.entrySet()) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
Object child = entry.getValue();
|
||||
if (names.stream().anyMatch(name -> name.equalsIgnoreCase(key)) && child != null) {
|
||||
String text = String.valueOf(child).trim();
|
||||
if (!text.isBlank()) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private Object parsePossiblyJson(Object value) {
|
||||
if (value instanceof String text) {
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
try {
|
||||
return objectMapper.readValue(trimmed, Object.class);
|
||||
} catch (Exception ignored) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String normalizeStatus(String value) {
|
||||
return value == null ? "" : value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private String messageOf(Exception ex) {
|
||||
String message = ex.getMessage();
|
||||
return message == null || message.isBlank() ? ex.getClass().getSimpleName() : message;
|
||||
}
|
||||
|
||||
private String truncate(String value) {
|
||||
return value.length() <= 1000 ? value : value.substring(0, 1000);
|
||||
}
|
||||
|
||||
private enum TaskStatus {
|
||||
PENDING, RUNNING, WAITING, POLLING, SUCCESS, FAILED
|
||||
}
|
||||
|
||||
private enum TaskType {
|
||||
DOUYIN_COPY,
|
||||
IMAGE_VIDEO_WORKFLOW,
|
||||
WORKFLOW_RESULT,
|
||||
VOICE_LIST,
|
||||
VOICE_DELETE,
|
||||
VOICE_CLONE,
|
||||
VOICE_SYNTHESIS
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,761 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoMediaUploadVo;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.File;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageVideoCozeService {
|
||||
|
||||
private static final List<String> ORIGINAL_FIELDS = List.of(
|
||||
"recognizedContent", "recognized_content", "originalContent", "original_content",
|
||||
"originContent", "origin_content", "rawContent", "raw_content", "description", "content"
|
||||
);
|
||||
private static final List<String> SCRIPT_FIELDS = List.of(
|
||||
"scriptDraft", "script_draft", "rewrittenContent", "rewritten_content",
|
||||
"fullContent", "full_content", "copywriting", "script", "output", "text", "result"
|
||||
);
|
||||
private static final Set<String> IMAGE_VIDEO_MODELS = Set.of(
|
||||
"sdols-2.0", "sdols-2.0-fast", "doubao-seedance-2.0-mini", "gemini-omini"
|
||||
);
|
||||
|
||||
private final ImageVideoProperties properties;
|
||||
private final ImageVideoSecretService secretService;
|
||||
private final ImageVideoWorkflowConfigService workflowConfigService;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private volatile HttpClient sharedHttpClient;
|
||||
|
||||
public Map<String, Object> submitDouyinCopy(DouyinCopyRequest request) {
|
||||
if (request == null) {
|
||||
throw new BusinessException("请求参数不能为空");
|
||||
}
|
||||
String normalizedUrl = request.getUrl() == null ? "" : request.getUrl().trim();
|
||||
if (normalizedUrl.isBlank()) {
|
||||
throw new BusinessException("抖音口令或链接不能为空");
|
||||
}
|
||||
if (looksLikeJsonObject(normalizedUrl)) {
|
||||
throw new BusinessException("请粘贴视频链接或文案来源链接,不要粘贴接口返回内容");
|
||||
}
|
||||
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.resolveForDouyinCopy(request.getUserId());
|
||||
String responseText = postWorkflow(request, normalizedUrl,
|
||||
firstNonBlank(request.getApiKey(), secret.copyApiKey()),
|
||||
firstNonBlank(request.getT8Key(), secret.t8Key()),
|
||||
secret.cozeToken(),
|
||||
workflowConfigService.douyinCopyWorkflowId(), true);
|
||||
return parseCozeMap("douyin copy", responseText);
|
||||
}
|
||||
|
||||
public Map<String, Object> runImageVideoWorkflow(Long userId, Map<String, Object> parameters) {
|
||||
if (parameters == null || parameters.isEmpty()) {
|
||||
throw new BusinessException("工作流参数不能为空");
|
||||
}
|
||||
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
String cozeToken = firstNonBlank(secret.cozeToken());
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze token 未配置");
|
||||
}
|
||||
|
||||
Map<String, Object> resolvedParameters = new LinkedHashMap<>(parameters);
|
||||
Map<String, Object> apiKeyInfo = new LinkedHashMap<>();
|
||||
Object incomingApiKeyInfo = resolvedParameters.get("api_key_info");
|
||||
if (incomingApiKeyInfo instanceof Map<?, ?>) {
|
||||
Map<?, ?> incomingMap = (Map<?, ?>) incomingApiKeyInfo;
|
||||
incomingMap.forEach((key, value) -> apiKeyInfo.put(String.valueOf(key), value));
|
||||
}
|
||||
String aiConductorKey = firstNonBlank(asText(apiKeyInfo.get("ai_conductor_key")),
|
||||
asText(resolvedParameters.get("api_key")), secret.copyApiKey());
|
||||
String t8starKey = firstNonBlank(asText(apiKeyInfo.get("t8star_key")), secret.t8Key());
|
||||
String t8VideoKey = firstNonBlank(asText(apiKeyInfo.get("t8_video_key")), secret.t8VideoKey());
|
||||
if (!hasText(aiConductorKey)) {
|
||||
throw new BusinessException("图生视频 ai_conductor_key 未配置");
|
||||
}
|
||||
if (!hasText(t8starKey)) {
|
||||
throw new BusinessException("图生视频 t8star_key 未配置");
|
||||
}
|
||||
if (!hasText(t8VideoKey)) {
|
||||
throw new BusinessException("图生视频 t8_video_key 未配置");
|
||||
}
|
||||
apiKeyInfo.put("t8star_key", t8starKey);
|
||||
apiKeyInfo.put("t8_video_key", t8VideoKey);
|
||||
apiKeyInfo.put("ai_conductor_key", aiConductorKey);
|
||||
resolvedParameters.put("api_key_info", apiKeyInfo);
|
||||
resolvedParameters.remove("api_key");
|
||||
normalizeImageVideoParameters(resolvedParameters);
|
||||
|
||||
String workflowId = workflowConfigService.imageVideoWorkflowId();
|
||||
return postWorkflowParameters("image video", workflowId, resolvedParameters, cozeToken, true);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private void normalizeImageVideoParameters(Map<String, Object> parameters) {
|
||||
Object faceInfo = parameters.get("face_info");
|
||||
if (faceInfo instanceof Map<?, ?>) {
|
||||
Map<String, Object> normalizedFaceInfo = new LinkedHashMap<>();
|
||||
((Map<?, ?>) faceInfo).forEach((key, value) -> normalizedFaceInfo.put(String.valueOf(key), value));
|
||||
normalizedFaceInfo.put("model_image", normalizeStringList(normalizedFaceInfo.get("model_image")));
|
||||
parameters.put("face_info", normalizedFaceInfo);
|
||||
}
|
||||
|
||||
Object procInfo = parameters.get("proc_info");
|
||||
if (procInfo instanceof Map<?, ?>) {
|
||||
Map<String, Object> normalizedProcInfo = new LinkedHashMap<>();
|
||||
((Map<?, ?>) procInfo).forEach((key, value) -> normalizedProcInfo.put(String.valueOf(key), value));
|
||||
normalizedProcInfo.put("proc_image", normalizeStringList(normalizedProcInfo.get("proc_image")));
|
||||
parameters.put("proc_info", normalizedProcInfo);
|
||||
}
|
||||
|
||||
Object videoInfo = parameters.get("video_info");
|
||||
if (videoInfo instanceof Map<?, ?>) {
|
||||
Map<String, Object> normalizedVideoInfo = new LinkedHashMap<>();
|
||||
((Map<?, ?>) videoInfo).forEach((key, value) -> normalizedVideoInfo.put(String.valueOf(key), value));
|
||||
String model = firstNonBlank(asText(normalizedVideoInfo.get("model")), "sdols-2.0-fast");
|
||||
if (!IMAGE_VIDEO_MODELS.contains(model)) {
|
||||
throw new BusinessException("不支持的视频模型: " + model);
|
||||
}
|
||||
normalizedVideoInfo.put("model", model);
|
||||
normalizedVideoInfo.put("draft", !"false".equalsIgnoreCase(asText(normalizedVideoInfo.get("draft"))));
|
||||
parameters.put("video_info", normalizedVideoInfo);
|
||||
}
|
||||
|
||||
Object audioInfo = parameters.get("audio_info");
|
||||
Map<String, Object> normalizedAudioInfo = new LinkedHashMap<>();
|
||||
if (audioInfo instanceof Map<?, ?>) {
|
||||
((Map<?, ?>) audioInfo).forEach((key, value) -> normalizedAudioInfo.put(String.valueOf(key), value));
|
||||
}
|
||||
String audioType = firstNonBlank(asText(normalizedAudioInfo.get("type")), "1");
|
||||
if (!"1".equals(audioType) && !"2".equals(audioType)) {
|
||||
throw new BusinessException("不支持的音频类型: " + audioType);
|
||||
}
|
||||
String audioMode = firstNonBlank(asText(normalizedAudioInfo.get("mode")), "1");
|
||||
if (!"1".equals(audioMode) && !"2".equals(audioMode) && !"3".equals(audioMode)) {
|
||||
throw new BusinessException("不支持的音频模式: " + audioMode);
|
||||
}
|
||||
String audioUrl = firstNonBlank(asText(normalizedAudioInfo.get("audio_url")));
|
||||
String bgmUrl = firstNonBlank(asText(normalizedAudioInfo.get("bgm_url")));
|
||||
String voiceName = firstNonBlank(asText(normalizedAudioInfo.get("voice_name")));
|
||||
if ("1".equals(audioMode)) {
|
||||
bgmUrl = "";
|
||||
} else if ("2".equals(audioMode)) {
|
||||
audioUrl = "";
|
||||
voiceName = "";
|
||||
} else {
|
||||
bgmUrl = "";
|
||||
voiceName = "";
|
||||
}
|
||||
normalizedAudioInfo.put("audio_url", audioUrl);
|
||||
normalizedAudioInfo.put("type", Integer.valueOf(audioType));
|
||||
normalizedAudioInfo.put("bgm_url", bgmUrl);
|
||||
normalizedAudioInfo.put("mode", Integer.valueOf(audioMode));
|
||||
normalizedAudioInfo.put("voice_name", voiceName);
|
||||
parameters.put("audio_info", normalizedAudioInfo);
|
||||
}
|
||||
|
||||
private List<String> normalizeStringList(Object value) {
|
||||
List<String> urls = new ArrayList<>();
|
||||
if (value instanceof List<?>) {
|
||||
for (Object item : (List<?>) value) {
|
||||
String text = firstNonBlank(asText(item));
|
||||
if (hasText(text)) {
|
||||
urls.add(text);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
String text = firstNonBlank(asText(value));
|
||||
if (hasText(text)) {
|
||||
urls.add(text);
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
private boolean looksLikeJsonObject(String value) {
|
||||
String text = firstNonBlank(value);
|
||||
return text.startsWith("{") && text.endsWith("}");
|
||||
}
|
||||
|
||||
public Map<String, Object> getWorkflowResult(Long userId, String workflowId, String executeId) {
|
||||
String resolvedExecuteId = firstNonBlank(executeId);
|
||||
if (!hasText(resolvedExecuteId)) {
|
||||
throw new BusinessException("execute_id cannot be blank");
|
||||
}
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
String cozeToken = firstNonBlank(secret.cozeToken());
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze token 未配置");
|
||||
}
|
||||
String resolvedWorkflowId = firstNonBlank(workflowId);
|
||||
if (!hasText(resolvedWorkflowId)) {
|
||||
throw new BusinessException("image video workflow_id 未配置");
|
||||
}
|
||||
|
||||
String path = firstNonBlank(properties.getCozeWorkflowHistoryPath(), "/v1/workflows/{workflow_id}/run_histories/{execute_id}")
|
||||
.replace("{workflow_id}", resolvedWorkflowId)
|
||||
.replace("{execute_id}", resolvedExecuteId);
|
||||
String endpoint = joinUrl(properties.getCozeBaseUrl(), path);
|
||||
String responseText = getCozeJson("image video history", endpoint, cozeToken);
|
||||
return parseCozeMap("image video history", responseText);
|
||||
}
|
||||
|
||||
public ImageVideoMediaUploadVo uploadMedia(MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new BusinessException("请上传媒体文件");
|
||||
}
|
||||
String originalFilename = firstNonBlank(file.getOriginalFilename(), "media");
|
||||
String mediaType = resolveMediaType(file.getContentType(), originalFilename);
|
||||
if (!List.of("image", "video", "audio").contains(mediaType)) {
|
||||
throw new BusinessException("仅支持图片、视频或音频文件");
|
||||
}
|
||||
File tempFile = null;
|
||||
try {
|
||||
String suffix = "";
|
||||
int dotIndex = originalFilename.lastIndexOf('.');
|
||||
if (dotIndex >= 0 && dotIndex + 1 < originalFilename.length()) {
|
||||
suffix = originalFilename.substring(dotIndex);
|
||||
}
|
||||
tempFile = Files.createTempFile("image-video-", suffix).toFile();
|
||||
file.transferTo(tempFile);
|
||||
OssStorageService.UploadedResult uploaded = ossStorageService.uploadResultFileWithFreshDownloadUrl(tempFile, "IMAGE_VIDEO");
|
||||
ImageVideoMediaUploadVo vo = new ImageVideoMediaUploadVo();
|
||||
vo.setUrl(uploaded.downloadUrl());
|
||||
vo.setObjectKey(uploaded.objectKey());
|
||||
vo.setOriginalFilename(originalFilename);
|
||||
vo.setMediaType(mediaType);
|
||||
return vo;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("媒体上传失败: " + ex.getMessage(), ex);
|
||||
} finally {
|
||||
if (tempFile != null && tempFile.exists() && !tempFile.delete()) {
|
||||
log.warn("[image-video] temp media delete failed path={}", tempFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> runVoiceList(Long userId, String name) {
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("api_key", secret.voiceApiKey());
|
||||
parameters.put("name", firstNonBlank(name));
|
||||
parameters.put("user_id", userId);
|
||||
return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), true);
|
||||
}
|
||||
|
||||
public Map<String, Object> runVoiceDelete(Long userId, String voiceId) {
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("api_key", secret.voiceApiKey());
|
||||
parameters.put("group_id", secret.voiceGroupId());
|
||||
parameters.put("user_id", userId);
|
||||
parameters.put("voice_id", firstNonBlank(voiceId));
|
||||
return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), true);
|
||||
}
|
||||
|
||||
public Map<String, Object> runVoiceClone(Long userId, String name, String audioUrl, String videoUrl) {
|
||||
String resolvedVideoUrl = firstNonBlank(videoUrl);
|
||||
String resolvedAudioUrl = firstNonBlank(audioUrl, resolvedVideoUrl);
|
||||
if (!hasText(resolvedAudioUrl) && !hasText(resolvedVideoUrl)) {
|
||||
throw new BusinessException("请上传音频或填写视频链接");
|
||||
}
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("api_key", secret.voiceApiKey());
|
||||
parameters.put("name", firstNonBlank(name));
|
||||
parameters.put("user_id", userId);
|
||||
parameters.put("audio_url", resolvedAudioUrl);
|
||||
parameters.put("video_url", resolvedVideoUrl);
|
||||
return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), true);
|
||||
}
|
||||
|
||||
public Map<String, Object> runVoiceSynthesis(Long userId, String text, String voiceId) {
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("api_key", secret.voiceApiKey());
|
||||
parameters.put("group_id", secret.voiceGroupId());
|
||||
parameters.put("text", firstNonBlank(text));
|
||||
parameters.put("voice_id", firstNonBlank(voiceId));
|
||||
return postWorkflowParameters("voice synthesis", workflowConfigService.voiceSynthesisWorkflowId(), parameters, secret.cozeToken(), true);
|
||||
}
|
||||
|
||||
private String postWorkflow(DouyinCopyRequest request, String url, String apiKey, String t8Key, String token,
|
||||
String workflowId, boolean async) {
|
||||
String resolvedApiKey = firstNonBlank(apiKey);
|
||||
String resolvedT8Key = firstNonBlank(t8Key);
|
||||
String cozeToken = firstNonBlank(token);
|
||||
if (!hasText(resolvedT8Key)) {
|
||||
throw new BusinessException("image-video douyin copy t8_key not configured");
|
||||
}
|
||||
if (!hasText(resolvedApiKey)) {
|
||||
throw new BusinessException("图生视频抖音文案 api_key 未配置");
|
||||
}
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze token 未配置");
|
||||
}
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("api_key", resolvedApiKey);
|
||||
parameters.put("t8_key", resolvedT8Key);
|
||||
parameters.put("url", url);
|
||||
parameters.put("duration", normalizeDouyinCopyDuration(request.getDuration()));
|
||||
parameters.put("proc_info", normalizeDouyinCopyProcInfo(request.getProcInfo()));
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId());
|
||||
body.put("workflow_id", resolvedWorkflowId);
|
||||
body.put("parameters", parameters);
|
||||
if (async) {
|
||||
body.put("is_async", true);
|
||||
}
|
||||
|
||||
String endpoint = joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath());
|
||||
log.info("[image-video] douyin copy coze request url={} workflowId={} inputLength={}",
|
||||
endpoint, resolvedWorkflowId, url.length());
|
||||
log.info("[image-video] douyin copy coze request detail endpoint={} authorization=Bearer {} body={}",
|
||||
endpoint, maskForLog(cozeToken), toJsonForLog(body));
|
||||
return postCozeJson("douyin copy", endpoint, body, cozeToken);
|
||||
}
|
||||
|
||||
private int normalizeDouyinCopyDuration(Integer duration) {
|
||||
if (duration == null || duration < 0) {
|
||||
return 0;
|
||||
}
|
||||
return duration;
|
||||
}
|
||||
|
||||
private Map<String, Object> normalizeDouyinCopyProcInfo(DouyinCopyRequest.ProcInfo procInfo) {
|
||||
Map<String, Object> normalized = new LinkedHashMap<>();
|
||||
if (procInfo == null) {
|
||||
normalized.put("properties", "");
|
||||
normalized.put("type", "");
|
||||
normalized.put("name", "");
|
||||
normalized.put("proc_image", List.of());
|
||||
return normalized;
|
||||
}
|
||||
normalized.put("properties", firstNonBlank(procInfo.getProperties()));
|
||||
normalized.put("type", firstNonBlank(procInfo.getType()));
|
||||
normalized.put("name", firstNonBlank(procInfo.getName()));
|
||||
List<String> images = normalizeStringList(procInfo.getProcImage());
|
||||
normalized.put("proc_image", images);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Map<String, Object> postWorkflowParameters(String label, String workflowId, Map<String, Object> parameters,
|
||||
String token, boolean async) {
|
||||
String cozeToken = firstNonBlank(token);
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze token 未配置");
|
||||
}
|
||||
String resolvedWorkflowId = firstNonBlank(workflowId);
|
||||
if (!hasText(resolvedWorkflowId)) {
|
||||
throw new BusinessException(label + " workflow_id 未配置");
|
||||
}
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("workflow_id", resolvedWorkflowId);
|
||||
body.put("parameters", parameters == null ? Map.of() : parameters);
|
||||
if (async) {
|
||||
body.put("is_async", true);
|
||||
}
|
||||
|
||||
String endpoint = joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath());
|
||||
log.info("[image-video] {} coze request url={} workflowId={}", label, endpoint, resolvedWorkflowId);
|
||||
log.info("[image-video] {} coze request detail endpoint={} authorization=Bearer {} body={}",
|
||||
label, endpoint, maskForLog(cozeToken), toJsonForLog(body));
|
||||
String responseText = postCozeJson(label, endpoint, body, cozeToken);
|
||||
|
||||
return parseCozeMap(label, responseText);
|
||||
}
|
||||
|
||||
private Map<String, Object> parseCozeMap(String label, String responseText) {
|
||||
try {
|
||||
Map<String, Object> result = objectMapper.readValue(responseText, new TypeReference<>() {});
|
||||
Object code = result.get("code");
|
||||
if (code instanceof Number number && number.intValue() != 0) {
|
||||
throw new BusinessException(firstNonBlank(asText(result.get("msg")), asText(result.get("message")), "Coze 工作流提交失败"));
|
||||
}
|
||||
return result;
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Coze 返回解析失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private DouyinCopyVo parseDouyinCopyResponse(String responseText) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(responseText);
|
||||
if (root.has("code") && root.path("code").asInt(0) != 0) {
|
||||
throw new BusinessException(firstNonBlank(root.path("msg").asText(null), root.path("message").asText(null), "Coze 工作流执行失败"));
|
||||
}
|
||||
|
||||
JsonNode data = root.path("data");
|
||||
JsonNode payload = unwrapPossiblyJsonText(data);
|
||||
String recognized = firstNonBlank(findText(payload, ORIGINAL_FIELDS), findText(root, ORIGINAL_FIELDS));
|
||||
String script = firstNonBlank(findText(payload, SCRIPT_FIELDS), findText(root, SCRIPT_FIELDS));
|
||||
|
||||
if (!hasText(recognized) && !hasText(script)) {
|
||||
String fallback = flattenText(payload);
|
||||
recognized = fallback;
|
||||
script = fallback;
|
||||
} else if (!hasText(script)) {
|
||||
script = recognized;
|
||||
} else if (!hasText(recognized)) {
|
||||
recognized = script;
|
||||
}
|
||||
|
||||
if (!hasText(recognized) && !hasText(script)) {
|
||||
throw new BusinessException("Coze 返回为空,未识别到文案内容");
|
||||
}
|
||||
|
||||
DouyinCopyVo vo = new DouyinCopyVo();
|
||||
vo.setRecognizedContent(cleanCopyText(recognized));
|
||||
vo.setScriptDraft(cleanCopyText(script));
|
||||
vo.setExecuteId(firstNonBlank(
|
||||
root.path("execute_id").asText(""),
|
||||
findText(root, List.of("execute_id", "executeId"))));
|
||||
vo.setDebugUrl(firstNonBlank(
|
||||
root.path("debug_url").asText(""),
|
||||
findText(root, List.of("debug_url", "debugUrl"))));
|
||||
return vo;
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[image-video] parse douyin copy response failed body={}", responseText, ex);
|
||||
throw new BusinessException("Coze 返回解析失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public DouyinCopyVo parseDouyinCopyResult(Map<String, Object> result) {
|
||||
try {
|
||||
return parseDouyinCopyResponse(objectMapper.writeValueAsString(result == null ? Map.of() : result));
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Coze response parse failed: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String cleanCopyText(String value) {
|
||||
String text = firstNonBlank(value);
|
||||
if (!hasText(text)) {
|
||||
return "";
|
||||
}
|
||||
return text
|
||||
.replaceAll("(?m)^\\s*https?://\\S+\\s*$", "")
|
||||
.replaceAll("https?://\\S+", "")
|
||||
.replaceAll("(?m)^[ \\t]+|[ \\t]+$", "")
|
||||
.replaceAll("\\n{3,}", "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
private JsonNode unwrapPossiblyJsonText(JsonNode node) {
|
||||
JsonNode current = node;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if (current == null || current.isMissingNode() || !current.isTextual()) {
|
||||
return current;
|
||||
}
|
||||
String text = current.asText("").trim();
|
||||
if (!(text.startsWith("{") || text.startsWith("["))) {
|
||||
return current;
|
||||
}
|
||||
try {
|
||||
current = objectMapper.readTree(text);
|
||||
} catch (Exception ignored) {
|
||||
return current;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private String findText(JsonNode node, List<String> fieldNames) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return "";
|
||||
}
|
||||
for (String fieldName : fieldNames) {
|
||||
JsonNode value = node.path(fieldName);
|
||||
String text = normalizeNodeText(value);
|
||||
if (hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
if (node.isObject()) {
|
||||
for (JsonNode child : node) {
|
||||
String text = findText(unwrapPossiblyJsonText(child), fieldNames);
|
||||
if (hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.isArray()) {
|
||||
for (JsonNode child : node) {
|
||||
String text = findText(unwrapPossiblyJsonText(child), fieldNames);
|
||||
if (hasText(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalizeNodeText(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return "";
|
||||
}
|
||||
JsonNode unwrapped = unwrapPossiblyJsonText(node);
|
||||
if (unwrapped != node) {
|
||||
return flattenText(unwrapped);
|
||||
}
|
||||
if (node.isTextual()) {
|
||||
return node.asText("");
|
||||
}
|
||||
if (node.isNumber() || node.isBoolean()) {
|
||||
return node.asText("");
|
||||
}
|
||||
return flattenText(node);
|
||||
}
|
||||
|
||||
private String flattenText(JsonNode node) {
|
||||
if (node == null || node.isMissingNode() || node.isNull()) {
|
||||
return "";
|
||||
}
|
||||
if (node.isTextual() || node.isNumber() || node.isBoolean()) {
|
||||
return node.asText("").trim();
|
||||
}
|
||||
if (node.isArray()) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (JsonNode child : node) {
|
||||
String text = flattenText(unwrapPossiblyJsonText(child));
|
||||
if (hasText(text)) {
|
||||
if (!out.isEmpty()) {
|
||||
out.append('\n');
|
||||
}
|
||||
out.append(text.trim());
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
if (node.isObject()) {
|
||||
StringBuilder out = new StringBuilder();
|
||||
for (JsonNode child : node) {
|
||||
String text = flattenText(unwrapPossiblyJsonText(child));
|
||||
if (hasText(text)) {
|
||||
if (!out.isEmpty()) {
|
||||
out.append('\n');
|
||||
}
|
||||
out.append(text.trim());
|
||||
}
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String postCozeJson(String label, String endpoint, Map<String, Object> body, String cozeToken) {
|
||||
try {
|
||||
String requestBody = objectMapper.writeValueAsString(body == null ? Map.of() : body);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(endpoint))
|
||||
.timeout(Duration.ofMillis(Math.max(1000, properties.getCozeReadTimeoutMillis())))
|
||||
.header("Authorization", "Bearer " + stripBearer(cozeToken))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("Accept-Charset", StandardCharsets.UTF_8.name())
|
||||
.POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
String responseBody = response.body() == null ? "" : response.body();
|
||||
log.info("[image-video] {} coze response status={} bodyLength={}",
|
||||
label, response.statusCode(), responseBody.length());
|
||||
log.info("[image-video] {} coze response detail status={} body={}",
|
||||
label, response.statusCode(), responseBody);
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new BusinessException("Coze 调用失败: HTTP " + response.statusCode());
|
||||
}
|
||||
return responseBody;
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("Coze 调用被中断", ex);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Coze 调用失败: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String getCozeJson(String label, String endpoint, String cozeToken) {
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(endpoint))
|
||||
.timeout(Duration.ofMillis(Math.max(1000, properties.getCozeReadTimeoutMillis())))
|
||||
.header("Authorization", "Bearer " + stripBearer(cozeToken))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.header("Accept-Charset", StandardCharsets.UTF_8.name())
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
String responseBody = response.body() == null ? "" : response.body();
|
||||
log.info("[image-video] {} coze response status={} bodyLength={}",
|
||||
label, response.statusCode(), responseBody.length());
|
||||
log.info("[image-video] {} coze response detail status={} body={}",
|
||||
label, response.statusCode(), responseBody);
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new BusinessException("Coze 调用失败: HTTP " + response.statusCode());
|
||||
}
|
||||
return responseBody;
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("Coze 调用被中断", ex);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("Coze 调用失败: " + ex.getMessage(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient httpClient() {
|
||||
HttpClient client = sharedHttpClient;
|
||||
if (client == null) {
|
||||
synchronized (this) {
|
||||
if (sharedHttpClient == null) {
|
||||
sharedHttpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(Math.max(1000, properties.getCozeConnectTimeoutMillis())))
|
||||
.build();
|
||||
}
|
||||
client = sharedHttpClient;
|
||||
}
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
private String joinUrl(String baseUrl, String path) {
|
||||
String base = firstNonBlank(baseUrl, "https://api.coze.cn").trim();
|
||||
String suffix = firstNonBlank(path, "/v1/workflow/run").trim();
|
||||
if (base.endsWith("/") && suffix.startsWith("/")) {
|
||||
return base.substring(0, base.length() - 1) + suffix;
|
||||
}
|
||||
if (!base.endsWith("/") && !suffix.startsWith("/")) {
|
||||
return base + "/" + suffix;
|
||||
}
|
||||
return base + suffix;
|
||||
}
|
||||
|
||||
private String stripBearer(String token) {
|
||||
String normalized = token == null ? "" : token.trim();
|
||||
if (normalized.regionMatches(true, 0, "Bearer ", 0, 7)) {
|
||||
return normalized.substring(7).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
for (String value : values) {
|
||||
if (hasText(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
private String asText(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
private String resolveMediaType(String contentType, String filename) {
|
||||
String type = contentType == null ? "" : contentType.toLowerCase();
|
||||
String name = filename == null ? "" : filename.toLowerCase();
|
||||
if (type.startsWith("image/") || name.matches(".*\\.(png|jpe?g|webp|gif|bmp)$")) {
|
||||
return "image";
|
||||
}
|
||||
if (type.startsWith("video/") || name.matches(".*\\.(mp4|mov|webm|m4v|avi|mkv)$")) {
|
||||
return "video";
|
||||
}
|
||||
if (type.startsWith("audio/") || name.matches(".*\\.(mp3|wav|m4a|aac|flac|ogg)$")) {
|
||||
return "audio";
|
||||
}
|
||||
return "file";
|
||||
}
|
||||
|
||||
private String toJsonForLog(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(maskSecretsForLog(value));
|
||||
} catch (Exception ex) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
|
||||
private Object maskSecretsForLog(Object value) {
|
||||
if (value instanceof Map<?, ?> source) {
|
||||
Map<String, Object> masked = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> entry : source.entrySet()) {
|
||||
String key = String.valueOf(entry.getKey());
|
||||
Object rawValue = entry.getValue();
|
||||
if (isSensitiveLogKey(key)) {
|
||||
masked.put(key, maskForLog(asText(rawValue)));
|
||||
} else {
|
||||
masked.put(key, maskSecretsForLog(rawValue));
|
||||
}
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
if (value instanceof List<?> source) {
|
||||
List<Object> masked = new ArrayList<>(source.size());
|
||||
for (Object item : source) {
|
||||
masked.add(maskSecretsForLog(item));
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private boolean isSensitiveLogKey(String key) {
|
||||
String normalized = key == null ? "" : key.toLowerCase();
|
||||
return normalized.contains("token")
|
||||
|| normalized.contains("secret")
|
||||
|| normalized.contains("authorization")
|
||||
|| normalized.endsWith("_key")
|
||||
|| normalized.equals("key")
|
||||
|| normalized.equals("api_key")
|
||||
|| normalized.equals("t8_key");
|
||||
}
|
||||
|
||||
private String maskForLog(String value) {
|
||||
if (!hasText(value)) {
|
||||
return "";
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.length() <= 10) {
|
||||
return "***";
|
||||
}
|
||||
return text.substring(0, 6) + "***" + text.substring(text.length() - 4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.security.ShopCredentialCryptoService;
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoSecretMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.dto.ImageVideoSecretSaveRequest;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoSecretEntity;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoSecretStatusVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageVideoSecretService {
|
||||
|
||||
private static final Set<Integer> ALLOWED_EXPIRE_DAYS = Set.of(1, 7, 30);
|
||||
private static final int PERMANENT_EXPIRE_DAYS = 0;
|
||||
private static final LocalDateTime PERMANENT_EXPIRES_AT = LocalDateTime.of(9999, 12, 31, 23, 59, 59);
|
||||
|
||||
private final ImageVideoSecretMapper secretMapper;
|
||||
private final ShopCredentialCryptoService cryptoService;
|
||||
private final ImageVideoProperties properties;
|
||||
|
||||
public ImageVideoSecretStatusVo status(Long userId) {
|
||||
validateUserId(userId);
|
||||
return toStatus(userId, selectByUserId(userId));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ImageVideoSecretStatusVo save(ImageVideoSecretSaveRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
Integer expireDays = request.getExpireDays();
|
||||
if (expireDays == null || (expireDays != PERMANENT_EXPIRE_DAYS && !ALLOWED_EXPIRE_DAYS.contains(expireDays))) {
|
||||
throw new BusinessException("有效期只支持 1 天、7 天、30 天、永久");
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
ImageVideoSecretEntity existing = selectByUserId(request.getUserId());
|
||||
boolean mustInputAll = existing == null || existing.getExpiresAt() == null || !existing.getExpiresAt().isAfter(now);
|
||||
|
||||
String copyApiKey = normalize(request.getCopyApiKey());
|
||||
String t8Key = normalize(request.getT8Key());
|
||||
String t8VideoKey = normalize(request.getT8VideoKey());
|
||||
String voiceApiKey = normalize(request.getVoiceApiKey());
|
||||
String voiceGroupId = normalize(request.getVoiceGroupId());
|
||||
if (mustInputAll) {
|
||||
requireText(copyApiKey, "爬虫密钥不能为空");
|
||||
requireText(t8Key, "t8_key 不能为空");
|
||||
requireText(t8VideoKey, "t8_video_key 不能为空");
|
||||
requireText(voiceApiKey, "音频密钥不能为空");
|
||||
requireText(voiceGroupId, "音频团队ID不能为空");
|
||||
}
|
||||
|
||||
ImageVideoSecretEntity row = existing == null ? new ImageVideoSecretEntity() : existing;
|
||||
row.setUserId(request.getUserId());
|
||||
if (hasText(copyApiKey)) {
|
||||
row.setCopyApiKey(cryptoService.encrypt(copyApiKey));
|
||||
}
|
||||
if (hasText(t8Key)) {
|
||||
row.setT8Key(cryptoService.encrypt(t8Key));
|
||||
}
|
||||
if (hasText(t8VideoKey)) {
|
||||
row.setT8VideoKey(cryptoService.encrypt(t8VideoKey));
|
||||
}
|
||||
if (hasText(voiceApiKey)) {
|
||||
row.setVoiceApiKey(cryptoService.encrypt(voiceApiKey));
|
||||
}
|
||||
if (hasText(voiceGroupId)) {
|
||||
row.setVoiceGroupId(cryptoService.encrypt(voiceGroupId));
|
||||
}
|
||||
row.setExpiresAt(expireDays == PERMANENT_EXPIRE_DAYS ? PERMANENT_EXPIRES_AT : now.plusDays(expireDays));
|
||||
row.setUpdatedAt(now);
|
||||
|
||||
if (row.getId() == null) {
|
||||
row.setCreatedAt(now);
|
||||
secretMapper.insert(row);
|
||||
} else {
|
||||
secretMapper.updateById(row);
|
||||
}
|
||||
return toStatus(request.getUserId(), row);
|
||||
}
|
||||
|
||||
public ResolvedSecret requireValid(Long userId) {
|
||||
validateUserId(userId);
|
||||
ImageVideoSecretEntity row = selectByUserId(userId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (row == null) {
|
||||
throw new BusinessException("请先配置视频密钥");
|
||||
}
|
||||
if (row.getExpiresAt() == null || !row.getExpiresAt().isAfter(now)) {
|
||||
throw new BusinessException("视频密钥已过期,请重新配置");
|
||||
}
|
||||
String cozeToken = normalize(properties.getCozeToken());
|
||||
String copyApiKey = decrypt(row.getCopyApiKey());
|
||||
String t8Key = decrypt(row.getT8Key());
|
||||
String t8VideoKey = decrypt(row.getT8VideoKey());
|
||||
String voiceApiKey = decrypt(row.getVoiceApiKey());
|
||||
String voiceGroupId = decrypt(row.getVoiceGroupId());
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze 访问令牌未配置");
|
||||
}
|
||||
if (!hasText(copyApiKey) || !hasText(t8Key) || !hasText(t8VideoKey) || !hasText(voiceApiKey) || !hasText(voiceGroupId)) {
|
||||
throw new BusinessException("视频密钥未配置完整,请重新配置");
|
||||
}
|
||||
return new ResolvedSecret(cozeToken, copyApiKey, t8Key, t8VideoKey, voiceApiKey, voiceGroupId);
|
||||
}
|
||||
|
||||
public ResolvedSecret resolveForDouyinCopy(Long userId) {
|
||||
String cozeToken = normalize(properties.getCozeToken());
|
||||
if (!hasText(cozeToken)) {
|
||||
throw new BusinessException("图生视频 Coze 访问令牌未配置");
|
||||
}
|
||||
if (userId == null || userId <= 0) {
|
||||
return new ResolvedSecret(cozeToken, "", "", "", "", "");
|
||||
}
|
||||
|
||||
ImageVideoSecretEntity row = selectByUserId(userId);
|
||||
if (row == null || row.getExpiresAt() == null || !row.getExpiresAt().isAfter(LocalDateTime.now())) {
|
||||
return new ResolvedSecret(cozeToken, "", "", "", "", "");
|
||||
}
|
||||
return new ResolvedSecret(
|
||||
cozeToken,
|
||||
decrypt(row.getCopyApiKey()),
|
||||
decrypt(row.getT8Key()),
|
||||
decrypt(row.getT8VideoKey()),
|
||||
decrypt(row.getVoiceApiKey()),
|
||||
decrypt(row.getVoiceGroupId())
|
||||
);
|
||||
}
|
||||
|
||||
private ImageVideoSecretEntity selectByUserId(Long userId) {
|
||||
return secretMapper.selectOne(new LambdaQueryWrapper<ImageVideoSecretEntity>()
|
||||
.eq(ImageVideoSecretEntity::getUserId, userId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
private ImageVideoSecretStatusVo toStatus(Long userId, ImageVideoSecretEntity row) {
|
||||
ImageVideoSecretStatusVo vo = new ImageVideoSecretStatusVo();
|
||||
vo.setUserId(userId);
|
||||
if (row == null) {
|
||||
vo.setConfigured(false);
|
||||
vo.setValid(false);
|
||||
vo.setExpired(false);
|
||||
vo.setHasCopyApiKey(false);
|
||||
vo.setHasT8Key(false);
|
||||
vo.setHasT8VideoKey(false);
|
||||
vo.setHasVoiceApiKey(false);
|
||||
vo.setHasVoiceGroupId(false);
|
||||
return vo;
|
||||
}
|
||||
|
||||
String copyApiKey = decrypt(row.getCopyApiKey());
|
||||
String t8Key = decrypt(row.getT8Key());
|
||||
String t8VideoKey = decrypt(row.getT8VideoKey());
|
||||
String voiceApiKey = decrypt(row.getVoiceApiKey());
|
||||
String voiceGroupId = decrypt(row.getVoiceGroupId());
|
||||
boolean hasCopy = hasText(copyApiKey);
|
||||
boolean hasT8 = hasText(t8Key);
|
||||
boolean hasT8Video = hasText(t8VideoKey);
|
||||
boolean hasVoice = hasText(voiceApiKey);
|
||||
boolean hasGroup = hasText(voiceGroupId);
|
||||
boolean expired = row.getExpiresAt() != null && !row.getExpiresAt().isAfter(LocalDateTime.now());
|
||||
boolean configured = hasCopy && hasT8 && hasT8Video && hasVoice && hasGroup;
|
||||
|
||||
vo.setConfigured(configured);
|
||||
vo.setValid(configured && !expired && row.getExpiresAt() != null);
|
||||
vo.setExpired(expired);
|
||||
vo.setHasCopyApiKey(hasCopy);
|
||||
vo.setHasT8Key(hasT8);
|
||||
vo.setHasT8VideoKey(hasT8Video);
|
||||
vo.setHasVoiceApiKey(hasVoice);
|
||||
vo.setHasVoiceGroupId(hasGroup);
|
||||
vo.setCopyApiKeyMasked(mask(copyApiKey));
|
||||
vo.setT8KeyMasked(mask(t8Key));
|
||||
vo.setT8VideoKeyMasked(mask(t8VideoKey));
|
||||
vo.setVoiceApiKeyMasked(mask(voiceApiKey));
|
||||
vo.setVoiceGroupIdMasked(mask(voiceGroupId));
|
||||
vo.setExpireDays(resolveExpireDays(row));
|
||||
vo.setExpiresAt(row.getExpiresAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Integer resolveExpireDays(ImageVideoSecretEntity row) {
|
||||
if (row.getExpiresAt() == null) {
|
||||
return 7;
|
||||
}
|
||||
if (!row.getExpiresAt().isBefore(PERMANENT_EXPIRES_AT)) {
|
||||
return PERMANENT_EXPIRE_DAYS;
|
||||
}
|
||||
LocalDateTime baseTime = row.getUpdatedAt() != null ? row.getUpdatedAt() : row.getCreatedAt();
|
||||
if (baseTime == null) {
|
||||
return 7;
|
||||
}
|
||||
long days = java.time.Duration.between(baseTime, row.getExpiresAt()).toDays();
|
||||
if (days >= 25) {
|
||||
return 30;
|
||||
}
|
||||
if (days >= 5) {
|
||||
return 7;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
private String decrypt(String cipherText) {
|
||||
if (!hasText(cipherText)) {
|
||||
return "";
|
||||
}
|
||||
return cryptoService.decrypt(cipherText);
|
||||
}
|
||||
|
||||
private String mask(String value) {
|
||||
if (!hasText(value)) {
|
||||
return "";
|
||||
}
|
||||
String text = value.trim();
|
||||
if (text.length() <= 8) {
|
||||
return "****";
|
||||
}
|
||||
return text.substring(0, 4) + "****" + text.substring(text.length() - 4);
|
||||
}
|
||||
|
||||
private void validateUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireText(String value, String message) {
|
||||
if (!hasText(value)) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
|
||||
public record ResolvedSecret(String cozeToken, String copyApiKey, String t8Key, String t8VideoKey, String voiceApiKey, String voiceGroupId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.nanri.aiimage.modules.imagevideo.service;
|
||||
|
||||
import com.nanri.aiimage.config.ImageVideoProperties;
|
||||
import com.nanri.aiimage.modules.imagevideo.mapper.ImageVideoWorkflowConfigMapper;
|
||||
import com.nanri.aiimage.modules.imagevideo.model.entity.ImageVideoWorkflowConfigEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ImageVideoWorkflowConfigService {
|
||||
|
||||
public static final String DOUYIN_COPY = "douyin_copy";
|
||||
public static final String IMAGE_VIDEO = "image_video";
|
||||
public static final String VOICE_DELETE = "voice_delete";
|
||||
public static final String VOICE_LIST = "voice_list";
|
||||
public static final String VOICE_CLONE = "voice_clone";
|
||||
public static final String VOICE_SYNTHESIS = "voice_synthesis";
|
||||
|
||||
private final ImageVideoWorkflowConfigMapper configMapper;
|
||||
private final ImageVideoProperties properties;
|
||||
|
||||
public String douyinCopyWorkflowId() {
|
||||
return resolve(DOUYIN_COPY, properties.getDouyinCopyWorkflowId());
|
||||
}
|
||||
|
||||
public String imageVideoWorkflowId() {
|
||||
return resolve(IMAGE_VIDEO, properties.getImageVideoWorkflowId());
|
||||
}
|
||||
|
||||
public String voiceDeleteWorkflowId() {
|
||||
return resolve(VOICE_DELETE, properties.getVoiceDeleteWorkflowId());
|
||||
}
|
||||
|
||||
public String voiceListWorkflowId() {
|
||||
return resolve(VOICE_LIST, properties.getVoiceListWorkflowId());
|
||||
}
|
||||
|
||||
public String voiceCloneWorkflowId() {
|
||||
return resolve(VOICE_CLONE, properties.getVoiceCloneWorkflowId());
|
||||
}
|
||||
|
||||
public String voiceSynthesisWorkflowId() {
|
||||
return resolve(VOICE_SYNTHESIS, properties.getVoiceSynthesisWorkflowId());
|
||||
}
|
||||
|
||||
private String resolve(String configKey, String fallback) {
|
||||
ImageVideoWorkflowConfigEntity row = configMapper.selectById(configKey);
|
||||
String configured = row == null ? "" : normalize(row.getWorkflowId());
|
||||
return hasText(configured) ? configured : normalize(fallback);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private boolean hasText(String value) {
|
||||
return value != null && !value.trim().isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ public class PatrolDeleteController {
|
||||
public ApiResponse<PatrolDeleteHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "history limit, default 30, max 100", example = "30")
|
||||
@Parameter(description = "历史记录条数,默认 30,最大 100", example = "30")
|
||||
@RequestParam(value = "limit", required = false) Integer limit) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.listHistory(userId, limit));
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ public class PermissionMenuSchemaInitializer {
|
||||
private static final List<DefaultAppMenu> DEFAULT_APP_MENUS = List.of(
|
||||
new DefaultAppMenu("前端工具", "brand_front_tools", "brand-front-tools", 110),
|
||||
new DefaultAppMenu("运营工具", "brand_operation_tools", "brand-operation-tools", 120),
|
||||
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130)
|
||||
new DefaultAppMenu("后勤工具", "brand_logistics_tools", "brand-logistics-tools", 130),
|
||||
new DefaultAppMenu("取款", "withdraw", "withdraw", 140)
|
||||
);
|
||||
|
||||
private static final List<DefaultAdminMenu> DEFAULT_ADMIN_MENUS = List.of(
|
||||
@@ -36,7 +37,8 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAdminMenu("查询ASIN", "admin_query_asin", "query-asin", 65),
|
||||
new DefaultAdminMenu("商品类目", "admin_product_categories", "product-categories", 66),
|
||||
new DefaultAdminMenu("查看生成记录", "admin_history", "history", 70),
|
||||
new DefaultAdminMenu("版本管理", "admin_version", "version", 80)
|
||||
new DefaultAdminMenu("软件版本管理", "admin_version", "version", 80),
|
||||
new DefaultAdminMenu("数字人版本管理", "digital_human_version", "digital-human-version", 81)
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
|
||||
@@ -34,6 +34,7 @@ public class PermissionMenuService {
|
||||
|
||||
public static final String MENU_TYPE_APP = "app";
|
||||
public static final String MENU_TYPE_ADMIN = "admin";
|
||||
private static final String IMAGE_VIDEO_DATA_PERMISSION_KEY = "admin_image_video_task_data";
|
||||
|
||||
private final PermissionMenuMapper permissionMenuMapper;
|
||||
private final UserColumnPermissionMapper userColumnPermissionMapper;
|
||||
@@ -166,6 +167,22 @@ public class PermissionMenuService {
|
||||
}
|
||||
}
|
||||
|
||||
PermissionMenuEntity imageVideoDataPermission = permissionMenuMapper.selectOne(
|
||||
new LambdaQueryWrapper<PermissionMenuEntity>()
|
||||
.eq(PermissionMenuEntity::getColumnKey, IMAGE_VIDEO_DATA_PERMISSION_KEY)
|
||||
.last("LIMIT 1"));
|
||||
if (imageVideoDataPermission != null) {
|
||||
requestedIds = new ArrayList<>(requestedIds);
|
||||
Long existingCount = userColumnPermissionMapper.selectCount(
|
||||
new LambdaQueryWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId)
|
||||
.eq(UserColumnPermissionEntity::getColumnId, imageVideoDataPermission.getId()));
|
||||
requestedIds.remove(imageVideoDataPermission.getId());
|
||||
if (existingCount != null && existingCount > 0) {
|
||||
requestedIds.add(imageVideoDataPermission.getId());
|
||||
}
|
||||
}
|
||||
|
||||
userColumnPermissionMapper.delete(new LambdaUpdateWrapper<UserColumnPermissionEntity>()
|
||||
.eq(UserColumnPermissionEntity::getUserId, userId));
|
||||
for (Long columnId : requestedIds) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackDispatchFailedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunChildFinishedRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackLoopRunCreateRequest;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest;
|
||||
@@ -20,9 +21,12 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackLoopRunVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackPendingDeleteVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinCheckVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackLoopRunService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
@@ -58,6 +62,7 @@ public class PriceTrackController {
|
||||
private final PriceTrackService priceTrackService;
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
private final PriceTrackLoopRunService priceTrackLoopRunService;
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在跟价模块中保存的备选店铺。")
|
||||
@@ -104,6 +109,48 @@ public class PriceTrackController {
|
||||
return ApiResponse.success(priceTrackService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/skip-asins/paginated")
|
||||
@Operation(
|
||||
summary = "分页查询任务的跳过 ASIN 数据",
|
||||
description = "根据任务编号分页拉取该任务关联的 ASIN 数据。"
|
||||
+ "全量模式:按任务店铺和国家从跳过跟价库查询;"
|
||||
+ "文件模式:从任务保存的上传文件解析数据查询。"
|
||||
+ "未传国家过滤条件时,默认使用任务创建时选择的国家。"
|
||||
)
|
||||
public ApiResponse<SkipPriceAsinPageVo> getTaskSkipAsinsPaginated(
|
||||
@Parameter(description = "任务编号", required = true, example = "200")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@Parameter(description = "每页条数,默认 1000,最大 2000", example = "1000")
|
||||
@RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize,
|
||||
@Parameter(description = "店铺名称过滤条件,可重复传入,也可用英文逗号分隔;仅在任务关联店铺范围内生效", example = "测试店铺A")
|
||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔;仅在任务国家范围内生效", example = "DE")
|
||||
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
||||
return ApiResponse.success(priceTrackTaskService.getTaskSkipAsinsPaginated(
|
||||
taskId,
|
||||
page,
|
||||
pageSize,
|
||||
shopNames,
|
||||
countryCodes));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/skip-asin/check")
|
||||
@Operation(
|
||||
summary = "检查单个跳过跟价 ASIN",
|
||||
description = "Python 按国家和单个 ASIN 查询是否命中跳过跟价库,避免分页加载全量跳过 ASIN。"
|
||||
)
|
||||
public ApiResponse<SkipPriceAsinCheckVo> checkTaskSkipAsin(
|
||||
@Parameter(description = "任务 ID", required = true, example = "200")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "国家代码", required = true, example = "DE")
|
||||
@RequestParam("country") String country,
|
||||
@Parameter(description = "商品 ASIN", required = true, example = "B0XXXX")
|
||||
@RequestParam("asin") String asin) {
|
||||
return ApiResponse.success(priceTrackTaskService.checkTaskSkipAsin(taskId, country, asin));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "统计看板", description = "返回备选店铺数、已结束任务数、成功任务数和失败任务数。")
|
||||
public ApiResponse<PriceTrackDashboardVo> dashboard(
|
||||
@@ -136,6 +183,16 @@ public class PriceTrackController {
|
||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/dispatch-failed")
|
||||
@Operation(summary = "标记 Python 入队失败", description = "前端创建任务后若 Python 队列拒绝接收,立即将任务及占位店铺标记为失败。")
|
||||
public ApiResponse<Void> markDispatchFailed(
|
||||
@PathVariable Long taskId,
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Valid @RequestBody PriceTrackDispatchFailedRequest request) {
|
||||
priceTrackTaskService.markDispatchFailed(taskId, userId, request.getErrorMessage());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/loop-runs")
|
||||
@Operation(summary = "创建跟价循环任务", description = "创建整批店铺的多轮循环主任务。")
|
||||
public ApiResponse<PriceTrackLoopRunVo> createLoopRun(@Valid @RequestBody PriceTrackLoopRunCreateRequest request) {
|
||||
@@ -214,7 +271,7 @@ public class PriceTrackController {
|
||||
+ "未完成/处理中:本次只传增量行数据,shop.success 不传或传 null,后端仅合并缓存继续等待。"
|
||||
+ "已完成:传最后一批数据并设置 shop.success=true,后端会使用该店铺累计后的完整数据出结果。"
|
||||
+ "失败:传 shop.success=false 或传非空 shop.error。"
|
||||
+ "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、shippingFee、minimumPrice、firstPlace、secondPlace、cartShopName、priceChangeStatus、modifyCount、status。"
|
||||
+ "行级数据只保留表头字段:shopMallName、asin、price、recommendedPrice、shippingFee、minimumPrice、firstPlace、firstShop、secondPlace、secondShop、cartShopName、priceChangeStatus、modifyCount、status。"
|
||||
)
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(
|
||||
|
||||
@@ -33,12 +33,12 @@ public class PriceTrackCreateTaskRequest {
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
@Schema(description = "国家代码列表,顺序即跟价处理顺序", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"DE\",\"UK\",\"FR\"]")
|
||||
private List<String> countryCodes;
|
||||
@Schema(description = "optional loop run id", example = "1")
|
||||
@Schema(description = "可选的循环运行 ID", example = "1")
|
||||
private Long loopRunId;
|
||||
|
||||
@Schema(description = "optional round index, 1-based", example = "2")
|
||||
@Schema(description = "可选的轮次序号,从 1 开始", example = "2")
|
||||
private Integer roundIndex;
|
||||
|
||||
@Schema(description = "optional shop index, 0-based", example = "0")
|
||||
@Schema(description = "可选的店铺序号,从 0 开始", example = "0")
|
||||
private Integer shopIndex;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "跟价任务推送 Python 队列失败请求")
|
||||
public class PriceTrackDispatchFailedRequest {
|
||||
|
||||
@NotBlank(message = "errorMessage不能为空")
|
||||
@Schema(description = "Python 队列拒绝或调用失败的原始原因", example = "当前店铺正在执行中")
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -32,7 +32,7 @@ public class PriceTrackLoopRunCreateRequest {
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@NotBlank(message = "executionMode不能为空")
|
||||
@Schema(description = "FINITE or INFINITE", example = "FINITE")
|
||||
@Schema(description = "循环模式:FINITE 表示有限次数,INFINITE 表示无限循环", example = "FINITE")
|
||||
private String executionMode;
|
||||
|
||||
@Schema(description = "固定轮次时必填", example = "3")
|
||||
|
||||
@@ -8,20 +8,20 @@ import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Price track match shops request")
|
||||
@Schema(description = "跟价店铺匹配请求")
|
||||
public class PriceTrackMatchShopsRequest {
|
||||
|
||||
@NotNull(message = "userId is required")
|
||||
@Schema(description = "Current user id", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "shopNames cannot be empty")
|
||||
@Schema(description = "Shop names to match", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "需要匹配的店铺名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> shopNames;
|
||||
|
||||
@Schema(description = "Selected ASIN file local paths")
|
||||
@Schema(description = "已选择的 ASIN 文件本地路径")
|
||||
private List<String> asinFiles;
|
||||
|
||||
@Schema(description = "Selected country codes")
|
||||
@Schema(description = "已选择的国家代码")
|
||||
private List<String> countryCodes;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "店铺商城名称", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "ASIN", example = "B0ABCDE123")
|
||||
@Schema(description = "商品 ASIN", example = "B0ABCDE123")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "价格", example = "19.99")
|
||||
@@ -83,9 +83,17 @@ public class PriceTrackSubmitResultRequest {
|
||||
@Schema(description = "第一名", example = "竞对A")
|
||||
private String firstPlace;
|
||||
|
||||
@JsonAlias({"first_shop", "firstPlaceShop", "first_place_shop", "第一名店铺名"})
|
||||
@Schema(description = "第一名店铺名", example = "店铺A")
|
||||
private String firstShop;
|
||||
|
||||
@Schema(description = "第二名", example = "竞对B")
|
||||
private String secondPlace;
|
||||
|
||||
@JsonAlias({"second_shop", "secondPlaceShop", "second_place_shop", "第二名店铺名"})
|
||||
@Schema(description = "第二名店铺名", example = "店铺B")
|
||||
private String secondShop;
|
||||
|
||||
@Schema(description = "购物车店铺名", example = "店铺A")
|
||||
private String cartShopName;
|
||||
|
||||
|
||||
@@ -7,23 +7,23 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "price track create task response")
|
||||
@Schema(description = "跟价任务创建响应")
|
||||
public class PriceTrackCreateTaskVo {
|
||||
@Schema(description = "task id", example = "200")
|
||||
@Schema(description = "任务 ID", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "initial task items")
|
||||
@Schema(description = "初始任务项")
|
||||
private List<PriceTrackResultItemVo> items;
|
||||
|
||||
@Schema(description = "all skip asins grouped by country")
|
||||
@Schema(description = "按国家分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "all skip asin details grouped by country code, each item includes asin and minimumPrice")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
|
||||
|
||||
@Schema(description = "parsed asin rows by country")
|
||||
@Schema(description = "按国家分组的已解析 ASIN 行")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
|
||||
@Schema(description = "minimum price grouped by country code and asin")
|
||||
@Schema(description = "按国家代码和 ASIN 分组的最低价格")
|
||||
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
|
||||
}
|
||||
|
||||
@@ -8,53 +8,53 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Price track shop match response")
|
||||
@Schema(description = "跟价店铺匹配响应")
|
||||
public class PriceTrackMatchShopsVo {
|
||||
|
||||
@Schema(description = "Match results in the same order as requested shop names")
|
||||
@Schema(description = "匹配结果,顺序与请求中的店铺名称一致")
|
||||
private List<PriceTrackShopQueueItem> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsinsByCountry;
|
||||
|
||||
@Schema(description = "All skip ASIN details grouped by country code, each item includes asin and minimumPrice")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN 明细,每项包含 asin 和 minimumPrice")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry;
|
||||
|
||||
@Schema(description = "Parsed asin rows grouped by country code")
|
||||
@Schema(description = "按国家代码分组的已解析 ASIN 行")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry;
|
||||
|
||||
@Schema(description = "Minimum price grouped by country code and asin")
|
||||
@Schema(description = "按国家代码和 ASIN 分组的最低价格")
|
||||
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Single matched shop item")
|
||||
@Schema(description = "单个店铺匹配结果")
|
||||
public static class PriceTrackShopQueueItem {
|
||||
|
||||
@Schema(description = "Shop name", example = "Demo Shop")
|
||||
@Schema(description = "店铺名称", example = "Demo Shop")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name", example = "Amazon.de")
|
||||
@Schema(description = "店铺商城名称", example = "Amazon.de")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "Matched shop id")
|
||||
@Schema(description = "匹配到的店铺 ID")
|
||||
private Object shopId;
|
||||
|
||||
@Schema(description = "Platform", example = "Amazon")
|
||||
@Schema(description = "平台", example = "Amazon")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "Company name")
|
||||
@Schema(description = "公司名称")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "Whether the shop matched")
|
||||
@Schema(description = "店铺是否匹配成功")
|
||||
private Boolean matched;
|
||||
|
||||
@Schema(description = "Match status, such as MATCHED or PENDING")
|
||||
@Schema(description = "匹配状态,例如 MATCHED 或 PENDING")
|
||||
private String matchStatus;
|
||||
|
||||
@Schema(description = "Readable match message")
|
||||
@Schema(description = "可读的匹配结果说明")
|
||||
private String matchMessage;
|
||||
|
||||
@Schema(description = "All skip ASINs grouped by country code")
|
||||
@Schema(description = "按国家代码分组的全部跳过跟价 ASIN")
|
||||
private Map<String, List<String>> skipAsins;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public class PriceTrackResultItemVo {
|
||||
@Schema(description = "店铺名称,详情页会尽量恢复创建任务时的展示名")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "Shop mall name")
|
||||
@Schema(description = "店铺商城名称")
|
||||
private String shopMallName;
|
||||
|
||||
@Schema(description = "店铺 ID(紫鸟)")
|
||||
@@ -50,7 +50,7 @@ public class PriceTrackResultItemVo {
|
||||
@Schema(description = "生成的 xlsx 文件名")
|
||||
private String outputFilename;
|
||||
|
||||
@Schema(description = "预签名下载 URL,列表可能带回,也可通过下载接口获取")
|
||||
@Schema(description = "公开 OSS 直链下载 URL,列表可能带回,也可通过下载接口获取")
|
||||
private String downloadUrl;
|
||||
private Long fileJobId;
|
||||
private String fileStatus;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个跳过跟价 ASIN 查询响应")
|
||||
public class SkipPriceAsinCheckVo {
|
||||
|
||||
@Schema(description = "国家代码", example = "DE")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "规范化后的 ASIN", example = "B0XXXX")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "该 ASIN 是否存在于跳过跟价 ASIN 表中")
|
||||
private boolean exists;
|
||||
|
||||
@Schema(description = "已配置的最低价格;未配置或未找到时为空")
|
||||
private String minimumPrice;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.nanri.aiimage.modules.pricetrack.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 跟价任务跳过 ASIN 分页响应。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "跟价任务跳过 ASIN 分页响应")
|
||||
public class SkipPriceAsinPageVo {
|
||||
|
||||
@Schema(description = "当前页码,从 1 开始")
|
||||
private Integer page;
|
||||
|
||||
@Schema(description = "每页条数")
|
||||
private Integer pageSize;
|
||||
|
||||
@Schema(description = "总记录数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "总页数")
|
||||
private Integer totalPages;
|
||||
|
||||
@Schema(description = "按国家分组的 ASIN 列表")
|
||||
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
|
||||
@Schema(description = "按国家分组的 ASIN 明细列表,包含 ASIN 和最低价")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
|
||||
@JsonProperty("skip_asins")
|
||||
@Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表")
|
||||
private Map<String, List<String>> skipAsins = new LinkedHashMap<>();
|
||||
|
||||
@JsonProperty("skip_asins_by_country")
|
||||
@Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 列表")
|
||||
private Map<String, List<String>> skipAsinsByCountryLegacy = new LinkedHashMap<>();
|
||||
|
||||
@JsonProperty("skip_asin_details_by_country")
|
||||
@Schema(description = "兼容旧版队列字段:按国家分组的 ASIN 明细列表")
|
||||
private Map<String, List<Map<String, String>>> skipAsinDetailsByCountryLegacy = new LinkedHashMap<>();
|
||||
|
||||
@JsonProperty("asin_rows_by_country")
|
||||
@Schema(description = "兼容旧版队列字段:按国家分组的指定 ASIN 文件行数据")
|
||||
private Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
|
||||
@JsonProperty("minimum_price_by_country_and_asin")
|
||||
@Schema(description = "兼容旧版队列字段:按国家和 ASIN 分组的最低价映射")
|
||||
private Map<String, Map<String, String>> minimumPriceByCountryAndAsin = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -27,7 +27,9 @@ public class PriceTrackExcelAssemblyService {
|
||||
"运费",
|
||||
"最低价",
|
||||
"第一名",
|
||||
"第一名店铺名",
|
||||
"第二名",
|
||||
"第二名店铺名",
|
||||
"购物车店铺名",
|
||||
"改价价格",
|
||||
"改价情况",
|
||||
@@ -62,12 +64,14 @@ public class PriceTrackExcelAssemblyService {
|
||||
row.createCell(4).setCellValue(valueOf(item.getShippingFee()));
|
||||
row.createCell(5).setCellValue(valueOf(item.getMinimumPrice()));
|
||||
row.createCell(6).setCellValue(valueOf(item.getFirstPlace()));
|
||||
row.createCell(7).setCellValue(valueOf(item.getSecondPlace()));
|
||||
row.createCell(8).setCellValue(valueOf(item.getCartShopName()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getPriceChangePrice()));
|
||||
row.createCell(10).setCellValue(valueOf(item.getPriceChangeStatus()));
|
||||
row.createCell(11).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(12).setCellValue(valueOf(item.getStatus()));
|
||||
row.createCell(7).setCellValue(valueOf(item.getFirstShop()));
|
||||
row.createCell(8).setCellValue(valueOf(item.getSecondPlace()));
|
||||
row.createCell(9).setCellValue(valueOf(item.getSecondShop()));
|
||||
row.createCell(10).setCellValue(valueOf(item.getCartShopName()));
|
||||
row.createCell(11).setCellValue(valueOf(item.getPriceChangePrice()));
|
||||
row.createCell(12).setCellValue(valueOf(item.getPriceChangeStatus()));
|
||||
row.createCell(13).setCellValue(valueOf(item.getModifyCount()));
|
||||
row.createCell(14).setCellValue(valueOf(item.getStatus()));
|
||||
}
|
||||
applyDefaultColumnWidths(sheet);
|
||||
}
|
||||
@@ -127,7 +131,7 @@ public class PriceTrackExcelAssemblyService {
|
||||
}
|
||||
|
||||
private void applyDefaultColumnWidths(Sheet sheet) {
|
||||
int[] widths = {22, 18, 12, 12, 12, 12, 20, 20, 22, 14, 18, 12, 12};
|
||||
int[] widths = {22, 18, 12, 12, 12, 12, 20, 22, 20, 22, 22, 14, 18, 12, 12};
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
@@ -145,24 +145,16 @@ public class PriceTrackService {
|
||||
}
|
||||
|
||||
boolean asinMode = request.getAsinFiles() != null && !request.getAsinFiles().isEmpty();
|
||||
Map<String, List<String>> skipAsinsByCountry = asinMode
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = asinMode
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinDetailsByCountry();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry =
|
||||
!asinMode
|
||||
? new LinkedHashMap<>()
|
||||
: priceTrackTaskService.parseMatchAsinRowsByCountry(
|
||||
request.getAsinFiles(),
|
||||
request.getCountryCodes());
|
||||
// 不再在 matchShops 时加载全量数据,改由 Python 端分页拉取
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
|
||||
PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo();
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountry(skipAsinDetailsByCountry);
|
||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||
vo.setMinimumPriceByCountryAndAsin(priceTrackTaskService.buildMinimumPriceLookupForMatch(asinRowsByCountry));
|
||||
vo.setMinimumPriceByCountryAndAsin(new LinkedHashMap<>());
|
||||
for (String shopName : ordered) {
|
||||
vo.getItems().add(matchOneShop(shopName, skipAsinsByCountry));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackResultItemVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskDetailVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackTaskItemVo;
|
||||
import com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinCheckVo;
|
||||
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinDetailDto;
|
||||
import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
@@ -46,6 +48,7 @@ import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
@@ -59,6 +62,7 @@ public class PriceTrackTaskService {
|
||||
|
||||
private static final String MODULE_TYPE = "PRICE_TRACK";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final String ASIN_ROWS_PAYLOAD_SCOPE = "price-track-asin-rows";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
@@ -78,7 +82,7 @@ public class PriceTrackTaskService {
|
||||
private FileTaskEntity loadTaskForExecution(Long taskId) {
|
||||
Map<Long, FileTaskEntity> cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
FileTaskEntity cached = cachedTasks.get(taskId);
|
||||
if (cached != null) {
|
||||
if (cached != null && hasText(cached.getRequestJson())) {
|
||||
return cached;
|
||||
}
|
||||
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
|
||||
@@ -273,6 +277,46 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markDispatchFailed(Long taskId, Long userId, String errorMessage) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
if (errorMessage == null || errorMessage.isBlank()) throw new BusinessException("errorMessage 不能为空");
|
||||
String normalizedError = errorMessage.trim();
|
||||
try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) {
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
return;
|
||||
}
|
||||
List<FileResultEntity> results = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
if (results.isEmpty()) {
|
||||
task.setStatus("FAILED");
|
||||
task.setErrorMessage(normalizedError);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
cleanupTaskCacheIfTerminal(taskId, task.getStatus());
|
||||
priceTrackLoopRunService.syncLoopRunAfterChildTerminal(taskId);
|
||||
return;
|
||||
}
|
||||
for (FileResultEntity result : results) {
|
||||
boolean succeeded = result.getSuccess() != null && result.getSuccess() == 1;
|
||||
boolean failed = result.getErrorMessage() != null && !result.getErrorMessage().isBlank();
|
||||
if (!succeeded && !failed) {
|
||||
markResultFailed(result, normalizedError);
|
||||
}
|
||||
}
|
||||
updateTaskStatusFromLatestRows(task, results);
|
||||
log.warn("[price-track] Python dispatch failed taskId={} userId={} error={}", taskId, userId, normalizedError);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackPendingDeleteVo deletePendingShopResult(Long userId, String shopName) {
|
||||
if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法");
|
||||
@@ -331,7 +375,7 @@ public class PriceTrackTaskService {
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(taskIds);
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskProgressMapByIds(taskIds);
|
||||
for (Long taskId : taskIds) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
continue;
|
||||
@@ -348,6 +392,41 @@ public class PriceTrackTaskService {
|
||||
return batch;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskProgressMapByIds(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalizedTaskIds = taskIds.stream()
|
||||
.filter(taskId -> taskId != null && taskId > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalizedTaskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < normalizedTaskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, normalizedTaskIds.size());
|
||||
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getTaskNo,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getErrorMessage,
|
||||
FileTaskEntity::getCreatedAt,
|
||||
FileTaskEntity::getUpdatedAt,
|
||||
FileTaskEntity::getFinishedAt,
|
||||
FileTaskEntity::getRequestJson)
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.in(FileTaskEntity::getId, normalizedTaskIds.subList(start, end)));
|
||||
for (FileTaskEntity task : tasks) {
|
||||
if (task != null && task.getId() != null) {
|
||||
result.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) {
|
||||
if (request.isStatusMode() == request.isAsinMode()) throw new BusinessException("跟价模式必须二选一");
|
||||
@@ -367,18 +446,14 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总店铺名并查询需要跳过的 ASIN
|
||||
Map<String, List<String>> skipAsinsByCountry = request.isAsinMode()
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinsByCountry();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = request.isAsinMode()
|
||||
? new LinkedHashMap<>()
|
||||
: skipPriceAsinService.listAllSkipAsinDetailsByCountry();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = request.isAsinMode()
|
||||
? parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes())
|
||||
: new LinkedHashMap<>();
|
||||
Map<String, Map<String, String>> minimumPriceByCountryAndAsin =
|
||||
buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsForPayload = new LinkedHashMap<>();
|
||||
Map<String, Map<String, String>> minimumPriceByCountryAndAsin = new LinkedHashMap<>();
|
||||
if (request.isAsinMode()) {
|
||||
asinRowsForPayload = parseAsinRowsByCountry(request.getAsinFiles(), request.getCountryCodes());
|
||||
}
|
||||
log.info("[price-track] createTask skipAsins countries={} asinMode={}",
|
||||
skipAsinsByCountry.keySet(), request.isAsinMode());
|
||||
|
||||
@@ -396,8 +471,10 @@ public class PriceTrackTaskService {
|
||||
task.setCreatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.insert(task);
|
||||
priceTrackTaskCacheService.saveTaskCache(task);
|
||||
priceTrackTaskCacheService.touchTaskHeartbeat(task.getId());
|
||||
if (request.isAsinMode()) {
|
||||
saveTaskAsinRowsPayload(task.getId(), asinRowsForPayload);
|
||||
}
|
||||
if (request.getLoopRunId() != null) {
|
||||
priceTrackLoopRunService.bindChildTask(request.getLoopRunId(), task.getId(), request.getRoundIndex(), request.getShopIndex());
|
||||
}
|
||||
@@ -428,10 +505,15 @@ public class PriceTrackTaskService {
|
||||
ctx.put("statusMode", request.isStatusMode());
|
||||
ctx.put("asinMode", request.isAsinMode());
|
||||
ctx.put("countryCodes", request.getCountryCodes());
|
||||
ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||
ctx.put("skipAsinDetailsByCountry", skipAsinDetailsByCountry);
|
||||
ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||
ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
|
||||
// 不再保存全量 ASIN 数据到 requestJson
|
||||
// ctx.put("skipAsinsByCountry", skipAsinsByCountry);
|
||||
// ctx.put("skipAsinDetailsByCountry", skipAsinDetailsByCountry);
|
||||
// ctx.put("asinRowsByCountry", asinRowsByCountry);
|
||||
// ctx.put("minimumPriceByCountryAndAsin", minimumPriceByCountryAndAsin);
|
||||
// 文件模式:保存文件路径,供后续分页读取
|
||||
if (request.isAsinMode()) {
|
||||
ctx.put("asinFiles", request.getAsinFiles());
|
||||
}
|
||||
ctx.put("items", uniqueItems);
|
||||
ctx.put("loopRunId", request.getLoopRunId());
|
||||
ctx.put("roundIndex", request.getRoundIndex());
|
||||
@@ -442,6 +524,7 @@ public class PriceTrackTaskService {
|
||||
throw new BusinessException("序列化任务数据失败");
|
||||
}
|
||||
fileTaskMapper.updateById(task);
|
||||
priceTrackTaskCacheService.saveTaskCache(task);
|
||||
|
||||
// TODO: Python 侧轮询 RUNNING 任务,pickup 后开始处理
|
||||
// Java 侧通过 submitResult 接口接收 Python 回传结果
|
||||
@@ -508,7 +591,7 @@ public class PriceTrackTaskService {
|
||||
continue;
|
||||
}
|
||||
if (countPayloadRows(merged) <= 0) {
|
||||
markResultFailed(fr, "no usable price-track rows received");
|
||||
markResultFailed(fr, buildNoUsableRowsMessage(payload));
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
@@ -585,7 +668,7 @@ public class PriceTrackTaskService {
|
||||
continue;
|
||||
}
|
||||
if (countPayloadRows(cachedPayload) <= 0) {
|
||||
markResultFailed(fr, "no usable price-track rows received before interruption");
|
||||
markResultFailed(fr, buildNoUsableRowsMessage(cachedPayload));
|
||||
priceTrackTaskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
changed = true;
|
||||
log.warn("[price-track] stale finalize finished without data taskId={} shop={} fromCompensation={}",
|
||||
@@ -764,6 +847,501 @@ public class PriceTrackTaskService {
|
||||
return buildMinimumPriceByCountryAndAsin(asinRowsByCountry);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询任务关联的跳过 ASIN 数据
|
||||
*
|
||||
* @param taskId 任务 ID
|
||||
* @param page 页码,从 1 开始
|
||||
* @param pageSize 每页条数
|
||||
* @return 分页结果
|
||||
*/
|
||||
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskSkipAsinsPaginated(
|
||||
Long taskId, int page, int pageSize) {
|
||||
return getTaskSkipAsinsPaginated(taskId, page, pageSize, List.of(), List.of());
|
||||
}
|
||||
|
||||
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskSkipAsinsPaginated(
|
||||
Long taskId, int page, int pageSize, List<String> requestedShopNames, List<String> requestedCountryCodes) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
}
|
||||
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
|
||||
PriceTrackCreateTaskRequest request = parseTaskRequest(task);
|
||||
|
||||
List<String> countryCodes = resolveSkipAsinCountryCodes(request, requestedCountryCodes);
|
||||
if (countryCodes.isEmpty()) {
|
||||
return emptySkipAsinPage(List.of(), page, pageSize);
|
||||
}
|
||||
|
||||
// Choose full mode or uploaded-ASIN file mode.
|
||||
boolean isAsinMode = request.isAsinMode();
|
||||
|
||||
if (isAsinMode) {
|
||||
Map<String, List<Map<String, String>>> storedRows = loadTaskAsinRowsPayload(taskId);
|
||||
if (storedRows != null) {
|
||||
return getTaskAsinRowsPaginated(storedRows, countryCodes, page, pageSize);
|
||||
}
|
||||
// File mode reads parsed uploaded ASIN rows.
|
||||
return getTaskAsinFilePaginated(request, countryCodes, page, pageSize);
|
||||
} else {
|
||||
List<String> shopNames = resolveSkipAsinShopNames(request, requestedShopNames);
|
||||
if (shopNames.isEmpty()) {
|
||||
return emptySkipAsinPage(countryCodes, page, pageSize);
|
||||
}
|
||||
return skipPriceAsinService.listSkipAsinsPaginated(shopNames, countryCodes, page, pageSize);
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> resolveSkipAsinCountryCodes(PriceTrackCreateTaskRequest request, List<String> requestedCountryCodes) {
|
||||
List<String> taskCountryCodes = normalizeTaskCountryCodes(request == null ? null : request.getCountryCodes());
|
||||
LinkedHashSet<String> requested = new LinkedHashSet<>();
|
||||
for (String country : splitQueryValues(requestedCountryCodes)) {
|
||||
requested.add(normalizeLookupCountry(country));
|
||||
}
|
||||
if (requested.isEmpty()) {
|
||||
return taskCountryCodes;
|
||||
}
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String country : requested) {
|
||||
if (isTaskCountryEnabled(taskCountryCodes, country)) {
|
||||
result.add(country);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<String> normalizeTaskCountryCodes(List<String> countryCodes) {
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
if (countryCodes != null) {
|
||||
for (String country : countryCodes) {
|
||||
String value = country == null ? "" : country.trim().toUpperCase(Locale.ROOT);
|
||||
if (!value.isEmpty()) {
|
||||
normalized.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(normalized);
|
||||
}
|
||||
|
||||
private List<String> resolveSkipAsinShopNames(PriceTrackCreateTaskRequest request, List<String> requestedShopNames) {
|
||||
List<String> taskShopNames = resolveTaskShopNames(request);
|
||||
List<String> requested = splitQueryValues(requestedShopNames);
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
for (String shopName : requested) {
|
||||
String value = ziniaoShopSwitchService.normalizeShopName(shopName);
|
||||
if (!value.isBlank() && (taskShopNames.isEmpty() || taskShopNames.contains(value))) {
|
||||
normalized.add(value);
|
||||
}
|
||||
}
|
||||
if (!normalized.isEmpty()) {
|
||||
return new ArrayList<>(normalized);
|
||||
}
|
||||
if (!requested.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return taskShopNames;
|
||||
}
|
||||
|
||||
private List<String> splitQueryValues(List<String> values) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (values == null) {
|
||||
return result;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
for (String part : value.split(",")) {
|
||||
String trimmed = part.trim();
|
||||
if (!trimmed.isEmpty()) {
|
||||
result.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo emptySkipAsinPage(
|
||||
List<String> countryCodes, int page, int pageSize) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 2000) {
|
||||
pageSize = 1000;
|
||||
}
|
||||
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||
? List.of("DE", "UK", "FR", "IT", "ES")
|
||||
: countryCodes;
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||
for (String country : targetCountries) {
|
||||
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||
}
|
||||
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
vo.setTotal(0L);
|
||||
vo.setTotalPages(0);
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||
vo.setSkipAsins(skipAsinsByCountry);
|
||||
vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry);
|
||||
vo.setAsinRowsByCountry(new LinkedHashMap<>());
|
||||
vo.setMinimumPriceByCountryAndAsin(new LinkedHashMap<>());
|
||||
return vo;
|
||||
}
|
||||
|
||||
private List<String> resolveTaskShopNames(PriceTrackCreateTaskRequest request) {
|
||||
List<String> shopNames = new ArrayList<>();
|
||||
if (request == null || request.getItems() == null) {
|
||||
return shopNames;
|
||||
}
|
||||
for (PriceTrackMatchShopsVo.PriceTrackShopQueueItem item : request.getItems()) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (!normalized.isBlank() && !shopNames.contains(normalized)) {
|
||||
shopNames.add(normalized);
|
||||
}
|
||||
}
|
||||
return shopNames;
|
||||
}
|
||||
|
||||
public SkipPriceAsinCheckVo checkTaskSkipAsin(Long taskId, String country, String asin) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
}
|
||||
String normalizedCountry = normalizeLookupCountry(country);
|
||||
String normalizedAsin = normalizeLookupAsin(asin);
|
||||
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
PriceTrackCreateTaskRequest request = parseTaskRequest(task);
|
||||
|
||||
SkipPriceAsinCheckVo vo = new SkipPriceAsinCheckVo();
|
||||
vo.setCountry(normalizedCountry);
|
||||
vo.setAsin(normalizedAsin);
|
||||
vo.setExists(false);
|
||||
vo.setMinimumPrice("");
|
||||
|
||||
if (request.isAsinMode() || !isTaskCountryEnabled(request.getCountryCodes(), normalizedCountry)) {
|
||||
return vo;
|
||||
}
|
||||
SkipPriceAsinDetailDto detail = skipPriceAsinService.findSkipAsinByCountryAndAsin(normalizedCountry, normalizedAsin);
|
||||
if (detail != null) {
|
||||
vo.setExists(true);
|
||||
vo.setMinimumPrice(detail.getMinimumPrice() == null ? "" : detail.getMinimumPrice());
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private PriceTrackCreateTaskRequest parseTaskRequest(FileTaskEntity task) {
|
||||
try {
|
||||
if (task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||
throw new BusinessException("任务请求参数为空");
|
||||
}
|
||||
return objectMapper.readValue(task.getRequestJson(), PriceTrackCreateTaskRequest.class);
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("任务请求参数解析失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isTaskCountryEnabled(List<String> countryCodes, String country) {
|
||||
if (countryCodes == null || countryCodes.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return countryCodes.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(code -> code.trim().toUpperCase(Locale.ROOT))
|
||||
.anyMatch(country::equals);
|
||||
}
|
||||
|
||||
private String normalizeLookupCountry(String country) {
|
||||
String normalized = country == null ? "" : country.trim().toUpperCase(Locale.ROOT);
|
||||
if (!List.of("DE", "UK", "FR", "IT", "ES").contains(normalized)) {
|
||||
throw new BusinessException("国家参数不支持: " + country);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeLookupAsin(String asin) {
|
||||
String normalized = asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new BusinessException("ASIN 不能为空");
|
||||
}
|
||||
if (normalized.length() > 64) {
|
||||
throw new BusinessException("ASIN 长度不能超过 64");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件模式:从任务上传的文件中分页返回 ASIN 数据
|
||||
*/
|
||||
private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskAsinFilePaginated(
|
||||
PriceTrackCreateTaskRequest request, List<String> countryCodes, int page, int pageSize) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 2000) {
|
||||
pageSize = 1000;
|
||||
}
|
||||
|
||||
// 解析上传文件中的所有 ASIN 数据
|
||||
Map<String, List<Map<String, String>>> allAsinRows = parseAsinRowsByCountry(
|
||||
request.getAsinFiles(),
|
||||
request.getCountryCodes()
|
||||
);
|
||||
|
||||
// 按国家分组并分页
|
||||
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||
? new ArrayList<>(allAsinRows.keySet())
|
||||
: countryCodes;
|
||||
|
||||
// 计算总记录数(所有国家的 ASIN 行数总和)
|
||||
long totalRows = 0;
|
||||
for (String country : targetCountries) {
|
||||
List<Map<String, String>> rows = allAsinRows.getOrDefault(country, List.of());
|
||||
totalRows += rows.size();
|
||||
}
|
||||
|
||||
// 计算分页
|
||||
int totalPages = (int) ((totalRows + pageSize - 1) / pageSize);
|
||||
int startIndex = (page - 1) * pageSize;
|
||||
int endIndex = Math.min(startIndex + pageSize, (int) totalRows);
|
||||
|
||||
// 提取当前页的数据(跨国家顺序提取)
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
|
||||
// 初始化所有目标国家
|
||||
for (String country : targetCountries) {
|
||||
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||
asinRowsByCountry.put(country, new ArrayList<>());
|
||||
}
|
||||
|
||||
int currentIndex = 0;
|
||||
boolean shouldBreak = false;
|
||||
|
||||
for (String country : targetCountries) {
|
||||
if (shouldBreak) {
|
||||
break;
|
||||
}
|
||||
|
||||
List<Map<String, String>> countryRows = allAsinRows.getOrDefault(country, List.of());
|
||||
for (Map<String, String> row : countryRows) {
|
||||
// 判断当前行是否在分页范围内
|
||||
if (currentIndex >= startIndex && currentIndex < endIndex) {
|
||||
String asin = row.get("asin");
|
||||
String minimumPrice = row.get("minimumPrice");
|
||||
|
||||
if (asin != null && !asin.isBlank()) {
|
||||
skipAsinsByCountry.get(country).add(asin);
|
||||
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
detail.put("asin", asin);
|
||||
detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice);
|
||||
skipDetailsByCountry.get(country).add(detail);
|
||||
asinRowsByCountry.get(country).add(new LinkedHashMap<>(row));
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex++;
|
||||
|
||||
// 已达到本页结束位置
|
||||
if (currentIndex >= endIndex) {
|
||||
shouldBreak = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 构造返回 VO
|
||||
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
vo.setTotal(totalRows);
|
||||
vo.setTotalPages(totalPages);
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||
vo.setSkipAsins(skipAsinsByCountry);
|
||||
vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry);
|
||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||
vo.setMinimumPriceByCountryAndAsin(buildMinimumPriceByCountryAndAsin(asinRowsByCountry));
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
private com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo getTaskAsinRowsPaginated(
|
||||
Map<String, List<Map<String, String>>> allAsinRows, List<String> countryCodes, int page, int pageSize) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 2000) {
|
||||
pageSize = 1000;
|
||||
}
|
||||
if (allAsinRows == null) {
|
||||
allAsinRows = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||
? new ArrayList<>(allAsinRows.keySet())
|
||||
: countryCodes;
|
||||
|
||||
long totalRows = 0;
|
||||
for (String country : targetCountries) {
|
||||
List<Map<String, String>> rows = allAsinRows.getOrDefault(country, List.of());
|
||||
totalRows += rows.size();
|
||||
}
|
||||
|
||||
int totalPages = (int) ((totalRows + pageSize - 1) / pageSize);
|
||||
int startIndex = (page - 1) * pageSize;
|
||||
int endIndex = Math.min(startIndex + pageSize, (int) totalRows);
|
||||
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> asinRowsByCountry = new LinkedHashMap<>();
|
||||
for (String country : targetCountries) {
|
||||
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||
asinRowsByCountry.put(country, new ArrayList<>());
|
||||
}
|
||||
|
||||
int currentIndex = 0;
|
||||
boolean shouldBreak = false;
|
||||
for (String country : targetCountries) {
|
||||
if (shouldBreak) {
|
||||
break;
|
||||
}
|
||||
List<Map<String, String>> countryRows = allAsinRows.getOrDefault(country, List.of());
|
||||
for (Map<String, String> row : countryRows) {
|
||||
if (currentIndex >= startIndex && currentIndex < endIndex) {
|
||||
String asin = row.get("asin");
|
||||
String minimumPrice = row.get("minimumPrice");
|
||||
if (asin != null && !asin.isBlank()) {
|
||||
skipAsinsByCountry.get(country).add(asin);
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
detail.put("asin", asin);
|
||||
detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice);
|
||||
skipDetailsByCountry.get(country).add(detail);
|
||||
asinRowsByCountry.get(country).add(new LinkedHashMap<>(row));
|
||||
}
|
||||
}
|
||||
currentIndex++;
|
||||
if (currentIndex >= endIndex) {
|
||||
shouldBreak = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
vo.setTotal(totalRows);
|
||||
vo.setTotalPages(totalPages);
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||
vo.setSkipAsins(skipAsinsByCountry);
|
||||
vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry);
|
||||
vo.setAsinRowsByCountry(asinRowsByCountry);
|
||||
vo.setMinimumPriceByCountryAndAsin(buildMinimumPriceByCountryAndAsin(asinRowsByCountry));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void saveTaskAsinRowsPayload(Long taskId, Map<String, List<Map<String, String>>> asinRowsByCountry) {
|
||||
if (asinRowsByCountry == null) {
|
||||
return;
|
||||
}
|
||||
taskResultPayloadService.saveLatest(taskId, MODULE_TYPE, ASIN_ROWS_PAYLOAD_SCOPE, asinRowsByCountry);
|
||||
log.info("[price-track] saved asin rows payload taskId={} countries={} rows={}",
|
||||
taskId, asinRowsByCountry.keySet(), countAsinRows(asinRowsByCountry));
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> loadTaskAsinRowsPayload(Long taskId) {
|
||||
try {
|
||||
Object payload = taskResultPayloadService.getLatest(taskId, MODULE_TYPE, ASIN_ROWS_PAYLOAD_SCOPE, Map.class);
|
||||
if (payload == null) {
|
||||
return null;
|
||||
}
|
||||
Map<String, List<Map<String, String>>> rows = normalizeAsinRowsPayload(payload);
|
||||
if (!rows.isEmpty()) {
|
||||
log.info("[price-track] loaded asin rows payload taskId={} countries={} rows={}",
|
||||
taskId, rows.keySet(), countAsinRows(rows));
|
||||
}
|
||||
return rows;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[price-track] load asin rows payload failed taskId={} err={}", taskId, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> normalizeAsinRowsPayload(Object payload) {
|
||||
Map<String, List<Map<String, String>>> out = new LinkedHashMap<>();
|
||||
if (!(payload instanceof Map<?, ?> rawMap)) {
|
||||
return out;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
|
||||
String country = Objects.toString(entry.getKey(), "").trim().toUpperCase(Locale.ROOT);
|
||||
if (country.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
List<Map<String, String>> rows = new ArrayList<>();
|
||||
if (entry.getValue() instanceof List<?> rawRows) {
|
||||
for (Object rawRow : rawRows) {
|
||||
if (!(rawRow instanceof Map<?, ?> rawRowMap)) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> row = new LinkedHashMap<>();
|
||||
for (Map.Entry<?, ?> cell : rawRowMap.entrySet()) {
|
||||
String key = Objects.toString(cell.getKey(), "").trim();
|
||||
if (key.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
row.put(key, Objects.toString(cell.getValue(), ""));
|
||||
}
|
||||
if (!row.isEmpty()) {
|
||||
rows.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.put(country, rows);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private long countAsinRows(Map<String, List<Map<String, String>>> asinRowsByCountry) {
|
||||
if (asinRowsByCountry == null || asinRowsByCountry.isEmpty()) {
|
||||
return 0L;
|
||||
}
|
||||
long total = 0L;
|
||||
for (List<Map<String, String>> rows : asinRowsByCountry.values()) {
|
||||
if (rows != null) {
|
||||
total += rows.size();
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private Map<String, List<Map<String, String>>> parseAsinRowsByCountry(List<String> asinFiles, List<String> countryCodes) {
|
||||
Map<String, List<Map<String, String>>> merged = new LinkedHashMap<>();
|
||||
if (asinFiles == null || asinFiles.isEmpty()) {
|
||||
@@ -949,7 +1527,7 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
|
||||
private boolean isEmptyAsinRow(Map<String, String> row) {
|
||||
return row.values().stream().allMatch(value -> value == null || value.isBlank());
|
||||
return row == null || normalizeCellText(row.get("asin")).isBlank();
|
||||
}
|
||||
|
||||
private String mapHeaderKey(String rawHeader) {
|
||||
@@ -959,12 +1537,16 @@ public class PriceTrackTaskService {
|
||||
}
|
||||
return switch (header) {
|
||||
case "店铺商城名称", "shopmallname" -> "shopMallName";
|
||||
case "asin" -> "asin";
|
||||
case "asin", "asin码", "asin编码", "商品asin", "商品编码", "商品编号",
|
||||
"子asin", "父asin", "amazonasin", "amazon_asin", "amazon asin",
|
||||
"sellerasin", "seller_asin", "seller asin" -> "asin";
|
||||
case "价格", "price" -> "price";
|
||||
case "推荐价", "recommendedprice" -> "recommendedPrice";
|
||||
case "最低价", "minimumprice" -> "minimumPrice";
|
||||
case "第一名", "firstplace" -> "firstPlace";
|
||||
case "第一名店铺名", "firstshop", "firstplaceshop" -> "firstShop";
|
||||
case "第二名", "secondplace" -> "secondPlace";
|
||||
case "第二名店铺名", "secondshop", "secondplaceshop" -> "secondShop";
|
||||
case "购物车店铺名", "cartshopname" -> "cartShopName";
|
||||
case "改价情况", "pricechangestatus" -> "priceChangeStatus";
|
||||
case "修改次数", "modifycount" -> "modifyCount";
|
||||
@@ -1234,7 +1816,9 @@ public class PriceTrackTaskService {
|
||||
merged.setShippingFee(firstNonBlank(incoming.getShippingFee(), merged.getShippingFee()));
|
||||
merged.setMinimumPrice(firstNonBlank(incoming.getMinimumPrice(), merged.getMinimumPrice()));
|
||||
merged.setFirstPlace(firstNonBlank(incoming.getFirstPlace(), merged.getFirstPlace()));
|
||||
merged.setFirstShop(firstNonBlank(incoming.getFirstShop(), merged.getFirstShop()));
|
||||
merged.setSecondPlace(firstNonBlank(incoming.getSecondPlace(), merged.getSecondPlace()));
|
||||
merged.setSecondShop(firstNonBlank(incoming.getSecondShop(), merged.getSecondShop()));
|
||||
merged.setCartShopName(firstNonBlank(incoming.getCartShopName(), merged.getCartShopName()));
|
||||
merged.setPriceChangePrice(firstNonBlank(incoming.getPriceChangePrice(), merged.getPriceChangePrice()));
|
||||
merged.setPriceChangeStatus(firstNonBlank(incoming.getPriceChangeStatus(), merged.getPriceChangeStatus()));
|
||||
@@ -1259,7 +1843,9 @@ public class PriceTrackTaskService {
|
||||
out.setShippingFee(row.getShippingFee());
|
||||
out.setMinimumPrice(row.getMinimumPrice());
|
||||
out.setFirstPlace(row.getFirstPlace());
|
||||
out.setFirstShop(row.getFirstShop());
|
||||
out.setSecondPlace(row.getSecondPlace());
|
||||
out.setSecondShop(row.getSecondShop());
|
||||
out.setCartShopName(row.getCartShopName());
|
||||
out.setPriceChangePrice(row.getPriceChangePrice());
|
||||
out.setPriceChangeStatus(row.getPriceChangeStatus());
|
||||
@@ -1298,7 +1884,30 @@ public class PriceTrackTaskService {
|
||||
if (payload == null) {
|
||||
return 0;
|
||||
}
|
||||
return excelAssemblyService.countRows(excelAssemblyService.normalizeCountriesMap(payload.getCountries()));
|
||||
Map<String, List<PriceTrackSubmitResultRequest.AsinResult>> countries =
|
||||
excelAssemblyService.normalizeCountriesMap(payload.getCountries());
|
||||
if (countries.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int count = 0;
|
||||
for (List<PriceTrackSubmitResultRequest.AsinResult> rows : countries.values()) {
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (PriceTrackSubmitResultRequest.AsinResult row : rows) {
|
||||
if (row != null && hasText(row.getAsin())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private String buildNoUsableRowsMessage(PriceTrackSubmitResultRequest.ShopResult payload) {
|
||||
if (payload != null && hasText(payload.getError())) {
|
||||
return payload.getError().trim();
|
||||
}
|
||||
return "no usable price-track rows received";
|
||||
}
|
||||
private String firstNonBlank(String preferred, String fallback) {
|
||||
if (preferred != null && !preferred.isBlank()) {
|
||||
@@ -1405,6 +2014,10 @@ public class PriceTrackTaskService {
|
||||
return taskDistributedLockService.acquire(MODULE_TYPE, taskId);
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private void cleanupTaskCacheIfTerminal(Long taskId, String taskStatus) {
|
||||
if (!"SUCCESS".equals(taskStatus) && !"FAILED".equals(taskStatus) && !"DELETE_EMPTY".equals(taskStatus)) {
|
||||
return;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.productrisk.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCountryPreferenceSaveRequest;
|
||||
@@ -243,13 +244,19 @@ public class ProductRiskResolveController {
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = productRiskTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
String url;
|
||||
String filename;
|
||||
try {
|
||||
url = productRiskTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
filename = productRiskTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
} catch (BusinessException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, ex.getMessage(), ex);
|
||||
}
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setContentType("application/zip");
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
|
||||
@@ -4,6 +4,9 @@ import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单国 sheet 内一行数据,与商品风险处理 Excel 模板列一致")
|
||||
public class ProductRiskRowDto {
|
||||
@@ -30,4 +33,19 @@ public class ProductRiskRowDto {
|
||||
@JsonProperty("removeStatus")
|
||||
@Schema(description = "移除状态")
|
||||
private String removeStatus;
|
||||
|
||||
@JsonProperty("violationDetails")
|
||||
@Schema(description = "该 ASIN 的逐项违规处理明细")
|
||||
private List<ViolationDetail> violationDetails = new ArrayList<>();
|
||||
|
||||
@Data
|
||||
@Schema(description = "单项违规处理结果")
|
||||
public static class ViolationDetail {
|
||||
|
||||
@Schema(description = "违规原因标题", example = "商品安全违规")
|
||||
private String reason;
|
||||
|
||||
@Schema(description = "处理状态", allowableValues = {"成功", "失败"})
|
||||
private String status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "商品风险模块统计看板(当前用户维度)")
|
||||
@Schema(description = "店铺任务统计看板(当前用户维度);商品风险和取款等复用模块按各自模块类型统计")
|
||||
public class ProductRiskDashboardVo {
|
||||
|
||||
@JsonProperty("candidateCount")
|
||||
@Schema(description = "备选店铺条数(biz_product_risk_shop_candidate)")
|
||||
@Schema(description = "备选店铺条数;按当前接口所属模块和当前用户统计")
|
||||
private long candidateCount;
|
||||
|
||||
@JsonProperty("processedTaskCount")
|
||||
@Schema(description = "已结束任务数:状态为 SUCCESS 或 FAILED 的商品风险任务总数")
|
||||
@Schema(description = "已结束任务数:状态为 SUCCESS 或 FAILED 的任务总数")
|
||||
private long processedTaskCount;
|
||||
|
||||
@JsonProperty("successTaskCount")
|
||||
|
||||
@@ -61,7 +61,7 @@ public class ProductRiskResultItemVo {
|
||||
private String outputFilename;
|
||||
|
||||
@JsonProperty("downloadUrl")
|
||||
@Schema(description = "预签名下载 URL(列表里可能带;直链下载也可用 GET /results/{resultId}/download)")
|
||||
@Schema(description = "公开 OSS 直链下载 URL(列表里可能带;也可用 GET /results/{resultId}/download)")
|
||||
private String downloadUrl;
|
||||
private Long fileJobId;
|
||||
private String fileStatus;
|
||||
|
||||
@@ -59,9 +59,11 @@ public class ProductRiskShopQueueItemVo {
|
||||
|
||||
@JsonAlias("skip_asins_by_country")
|
||||
@JsonProperty("skipAsinsByCountry")
|
||||
@Schema(description = "按国家分组的跳过 ASIN 列表;跟价任务使用,取款任务通常可忽略")
|
||||
private Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
|
||||
@JsonAlias("skip_asin_details_by_country")
|
||||
@JsonProperty("skipAsinDetailsByCountry")
|
||||
@Schema(description = "按国家分组的跳过 ASIN 明细;包含数据库中维护的跳过跟价 ASIN 信息,取款任务通常可忽略")
|
||||
private Map<String, List<SkipPriceAsinDetailDto>> skipAsinDetailsByCountry = new LinkedHashMap<>();
|
||||
}
|
||||
|
||||
@@ -9,52 +9,52 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "Task summary")
|
||||
@Schema(description = "任务摘要:任务主表的基础状态、时间和调度信息")
|
||||
public class ProductRiskTaskItemVo {
|
||||
|
||||
@Schema(description = "Task primary key", example = "200")
|
||||
@Schema(description = "任务主键 biz_file_task.id", example = "200")
|
||||
private Long id;
|
||||
|
||||
@JsonProperty("taskNo")
|
||||
@Schema(description = "Task number")
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "Task status")
|
||||
@Schema(description = "任务状态:RUNNING=执行中,SUCCESS=成功结束,FAILED=失败结束")
|
||||
private String status;
|
||||
|
||||
@JsonProperty("errorMessage")
|
||||
@Schema(description = "Task error message")
|
||||
@Schema(description = "任务失败原因或异常信息")
|
||||
private String errorMessage;
|
||||
|
||||
@JsonProperty("createdAt")
|
||||
@Schema(description = "Created at")
|
||||
@Schema(description = "任务创建时间")
|
||||
private String createdAt;
|
||||
|
||||
@JsonProperty("updatedAt")
|
||||
@Schema(description = "Updated at")
|
||||
@Schema(description = "任务最后更新时间")
|
||||
private String updatedAt;
|
||||
|
||||
@JsonProperty("finishedAt")
|
||||
@Schema(description = "Finished at")
|
||||
@Schema(description = "任务完成时间")
|
||||
private String finishedAt;
|
||||
|
||||
@JsonProperty("scheduledAt")
|
||||
@Schema(description = "Next scheduled execution time")
|
||||
@Schema(description = "下次计划执行时间;未设置定时时为空")
|
||||
private String scheduledAt;
|
||||
|
||||
@JsonProperty("countryCodes")
|
||||
@Schema(description = "Country codes used by shop match task")
|
||||
@Schema(description = "任务使用的国家/站点代码列表")
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
|
||||
@JsonProperty("scheduleStages")
|
||||
@Schema(description = "Stage schedule of shop match task")
|
||||
@Schema(description = "分阶段调度信息;用于需要按阶段执行的店铺任务")
|
||||
private List<ShopMatchTaskStageVo> scheduleStages = new ArrayList<>();
|
||||
|
||||
@JsonProperty("currentStageIndex")
|
||||
@Schema(description = "Next pending stage index")
|
||||
@Schema(description = "下一个待执行阶段索引")
|
||||
private Integer currentStageIndex;
|
||||
|
||||
@JsonProperty("activeStageIndex")
|
||||
@Schema(description = "Current running stage index")
|
||||
@Schema(description = "当前正在执行的阶段索引")
|
||||
private Integer activeStageIndex;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,13 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskRowDto;
|
||||
import com.nanri.aiimage.modules.productrisk.model.enums.ProductRiskCountryCode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.CellStyle;
|
||||
import org.apache.poi.ss.usermodel.BorderStyle;
|
||||
import org.apache.poi.ss.usermodel.HorizontalAlignment;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.ss.usermodel.VerticalAlignment;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -15,6 +20,7 @@ import java.io.FileOutputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -30,7 +36,10 @@ public class ProductRiskExcelAssemblyService {
|
||||
"状态",
|
||||
"完成",
|
||||
"移除ASIN",
|
||||
"移除状态"
|
||||
"移除状态",
|
||||
"ASIN",
|
||||
"原因",
|
||||
"状态"
|
||||
};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, String shopDisplayName, Map<String, List<ProductRiskRowDto>> countries) {
|
||||
@@ -45,16 +54,38 @@ public class ProductRiskExcelAssemblyService {
|
||||
headerRow.createCell(c).setCellValue(HEADER[c]);
|
||||
}
|
||||
List<ProductRiskRowDto> rows = safe.getOrDefault(sheetName, List.of());
|
||||
CellStyle detailStyle = createDetailStyle(workbook);
|
||||
CellStyle detailAsinStyle = createDetailAsinStyle(workbook);
|
||||
int rowIdx = 1;
|
||||
for (ProductRiskRowDto dto : rows) {
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||
createTextCell(row, 3, dto == null || dto.getDone() == null ? null : String.valueOf(dto.getDone()));
|
||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
||||
List<ProductRiskRowDto.ViolationDetail> details = violationDetails(dto);
|
||||
int groupSize = Math.max(1, details.size());
|
||||
String asin = firstNonBlank(
|
||||
dto == null ? null : dto.getProductAsinSku(),
|
||||
dto == null ? null : dto.getRemoveAsin());
|
||||
for (int offset = 0; offset < groupSize; offset++) {
|
||||
Row row = sheet.createRow(rowIdx + offset);
|
||||
if (offset == 0) {
|
||||
createTextCell(row, 0, firstNonBlank(dto == null ? null : dto.getShopName(), shopDisplayName));
|
||||
createTextCell(row, 1, dto == null ? null : dto.getProductAsinSku());
|
||||
createTextCell(row, 2, dto == null ? null : dto.getStatus());
|
||||
createTextCell(row, 3, completionDisplayValue(dto));
|
||||
createTextCell(row, 4, dto == null ? null : dto.getRemoveAsin());
|
||||
createTextCell(row, 5, dto == null ? null : dto.getRemoveStatus());
|
||||
}
|
||||
if (offset < details.size()) {
|
||||
ProductRiskRowDto.ViolationDetail detail = details.get(offset);
|
||||
createStyledTextCell(row, 6, asin, detailAsinStyle);
|
||||
createStyledTextCell(row, 7, detail.getReason(), detailStyle);
|
||||
createStyledTextCell(row, 8, detail.getStatus(), detailStyle);
|
||||
}
|
||||
}
|
||||
if (details.size() > 1) {
|
||||
sheet.addMergedRegion(new CellRangeAddress(rowIdx, rowIdx + details.size() - 1, 6, 6));
|
||||
}
|
||||
rowIdx += groupSize;
|
||||
}
|
||||
applyDetailHeaderStyle(headerRow, detailStyle);
|
||||
applyDefaultColumnWidths(sheet);
|
||||
}
|
||||
workbook.write(fos);
|
||||
@@ -104,6 +135,41 @@ public class ProductRiskExcelAssemblyService {
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
}
|
||||
|
||||
private static void createStyledTextCell(Row row, int index, String value, CellStyle style) {
|
||||
Cell cell = row.createCell(index);
|
||||
cell.setCellValue(value == null ? "" : value);
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private static CellStyle createDetailStyle(SXSSFWorkbook workbook) {
|
||||
CellStyle style = workbook.createCellStyle();
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
style.setVerticalAlignment(VerticalAlignment.CENTER);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static CellStyle createDetailAsinStyle(SXSSFWorkbook workbook) {
|
||||
CellStyle style = createDetailStyle(workbook);
|
||||
style.setAlignment(HorizontalAlignment.CENTER);
|
||||
return style;
|
||||
}
|
||||
|
||||
private static void applyDetailHeaderStyle(Row headerRow, CellStyle style) {
|
||||
for (int column = 6; column <= 8; column++) {
|
||||
headerRow.getCell(column).setCellStyle(style);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<ProductRiskRowDto.ViolationDetail> violationDetails(ProductRiskRowDto row) {
|
||||
if (row == null || row.getViolationDetails() == null) {
|
||||
return List.of();
|
||||
}
|
||||
return row.getViolationDetails().stream().filter(Objects::nonNull).toList();
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String primary, String fallback) {
|
||||
if (primary != null && !primary.isBlank()) {
|
||||
return primary;
|
||||
@@ -111,8 +177,46 @@ public class ProductRiskExcelAssemblyService {
|
||||
return fallback == null ? "" : fallback;
|
||||
}
|
||||
|
||||
private static boolean hasBusinessData(ProductRiskRowDto dto) {
|
||||
return dto != null
|
||||
&& (hasText(dto.getProductAsinSku())
|
||||
|| hasText(dto.getStatus())
|
||||
|| hasText(dto.getRemoveAsin())
|
||||
|| hasText(dto.getRemoveStatus()));
|
||||
}
|
||||
|
||||
private static String completionDisplayValue(ProductRiskRowDto dto) {
|
||||
if (dto == null || !hasBusinessData(dto)) {
|
||||
return "";
|
||||
}
|
||||
return isCompleted(dto) ? "是" : "否";
|
||||
}
|
||||
|
||||
private static boolean isCompleted(ProductRiskRowDto dto) {
|
||||
return Boolean.TRUE.equals(dto.getDone())
|
||||
|| indicatesCompleted(dto.getStatus())
|
||||
|| indicatesCompleted(dto.getRemoveStatus());
|
||||
}
|
||||
|
||||
private static boolean indicatesCompleted(String value) {
|
||||
if (!hasText(value)) {
|
||||
return false;
|
||||
}
|
||||
String normalized = value.trim().toUpperCase(java.util.Locale.ROOT);
|
||||
return normalized.contains("完成")
|
||||
|| normalized.contains("成功")
|
||||
|| normalized.contains("已处理")
|
||||
|| normalized.contains("DONE")
|
||||
|| normalized.contains("SUCCESS")
|
||||
|| normalized.contains("COMPLETED");
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private void applyDefaultColumnWidths(Sheet sheet) {
|
||||
int[] widths = {20, 22, 18, 10, 18, 18};
|
||||
int[] widths = {20, 22, 18, 10, 18, 18, 18, 24, 12};
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ public class ProductRiskTaskService {
|
||||
if (latestEntity == null || !MODULE_TYPE.equals(latestEntity.getModuleType()) || !userId.equals(latestEntity.getUserId())) {
|
||||
throw new BusinessException("record not found");
|
||||
}
|
||||
taskFileJobService.deleteResultJobs(latestEntity.getTaskId(), MODULE_TYPE, latestEntity.getId());
|
||||
fileResultMapper.deleteById(resultId);
|
||||
reconcileTaskAfterResultRemoval(taskId);
|
||||
}
|
||||
@@ -275,6 +276,7 @@ public class ProductRiskTaskService {
|
||||
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
taskFileJobService.deleteTaskJobs(taskId, MODULE_TYPE);
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
cleanupTaskCacheIfTerminal(taskId, "DELETE_EMPTY");
|
||||
}
|
||||
@@ -1243,6 +1245,8 @@ public class ProductRiskTaskService {
|
||||
merged.setDone(incoming.getDone() != null ? incoming.getDone() : merged.getDone());
|
||||
merged.setRemoveAsin(firstNonBlank(incoming.getRemoveAsin(), merged.getRemoveAsin()));
|
||||
merged.setRemoveStatus(firstNonBlank(incoming.getRemoveStatus(), merged.getRemoveStatus()));
|
||||
merged.setViolationDetails(mergeViolationDetails(
|
||||
merged.getViolationDetails(), incoming.getViolationDetails()));
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -1257,6 +1261,31 @@ public class ProductRiskTaskService {
|
||||
out.setDone(row.getDone());
|
||||
out.setRemoveAsin(row.getRemoveAsin());
|
||||
out.setRemoveStatus(row.getRemoveStatus());
|
||||
out.setViolationDetails(cloneViolationDetails(row.getViolationDetails()));
|
||||
return out;
|
||||
}
|
||||
|
||||
static List<ProductRiskRowDto.ViolationDetail> mergeViolationDetails(
|
||||
List<ProductRiskRowDto.ViolationDetail> existing,
|
||||
List<ProductRiskRowDto.ViolationDetail> incoming) {
|
||||
return cloneViolationDetails(incoming == null || incoming.isEmpty() ? existing : incoming);
|
||||
}
|
||||
|
||||
private static List<ProductRiskRowDto.ViolationDetail> cloneViolationDetails(
|
||||
List<ProductRiskRowDto.ViolationDetail> details) {
|
||||
if (details == null || details.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<ProductRiskRowDto.ViolationDetail> out = new ArrayList<>(details.size());
|
||||
for (ProductRiskRowDto.ViolationDetail detail : details) {
|
||||
if (detail == null) {
|
||||
continue;
|
||||
}
|
||||
ProductRiskRowDto.ViolationDetail copy = new ProductRiskRowDto.ViolationDetail();
|
||||
copy.setReason(detail.getReason());
|
||||
copy.setStatus(detail.getStatus());
|
||||
out.add(copy);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
@Schema(description = "查询 ASIN 单条结果")
|
||||
public class QueryAsinAsinStatusDto {
|
||||
|
||||
@Schema(description = "ASIN", example = "B0BRZZR3N2")
|
||||
@Schema(description = "商品 ASIN", example = "B0BRZZR3N2")
|
||||
private String asin;
|
||||
|
||||
@Schema(description = "Python 查询后返回的状态", example = "正常")
|
||||
|
||||
@@ -48,7 +48,7 @@ public class QueryAsinController {
|
||||
@Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success(queryAsinService.page(
|
||||
@@ -66,7 +66,7 @@ public class QueryAsinController {
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "分组ID") @RequestParam(required = false) Long groupId,
|
||||
@Parameter(description = "店铺名") @RequestParam(required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) {
|
||||
byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
|
||||
@@ -48,7 +48,7 @@ public class SkipPriceAsinController {
|
||||
@Parameter(description = "每页数量") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success(skipPriceAsinService.page(
|
||||
@@ -66,7 +66,7 @@ public class SkipPriceAsinController {
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
|
||||
@@ -67,4 +67,15 @@ public interface ShopManageGroupMapper extends BaseMapper<ShopManageGroupEntity>
|
||||
ORDER BY visible_user_id ASC
|
||||
""")
|
||||
List<Long> selectAccessibleCreatorUserIds(@Param("operatorId") Long operatorId);
|
||||
|
||||
@Select("""
|
||||
SELECT DISTINCT gm.user_id
|
||||
FROM biz_shop_manage_group g
|
||||
INNER JOIN biz_shop_manage_group_member gm ON gm.group_id = g.id
|
||||
INNER JOIN users u ON u.id = gm.user_id
|
||||
WHERE g.created_by_id = #{operatorId}
|
||||
OR g.user_id = #{operatorId}
|
||||
ORDER BY gm.user_id ASC
|
||||
""")
|
||||
List<Long> selectManagedMemberUserIds(@Param("operatorId") Long operatorId);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class QueryAsinCountryUpdateRequest {
|
||||
|
||||
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
private String asin;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import lombok.Data;
|
||||
@Schema(description = "店铺密钥新增请求")
|
||||
public class ShopKeyCreateRequest {
|
||||
|
||||
@Size(max = 128, message = "备注名长度不能超过128个字符")
|
||||
@Schema(description = "备注名")
|
||||
private String remarkName;
|
||||
|
||||
@NotBlank(message = "紫鸟账号名称不能为空")
|
||||
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
|
||||
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -9,6 +9,10 @@ import lombok.Data;
|
||||
@Schema(description = "店铺密钥更新请求")
|
||||
public class ShopKeyUpdateRequest {
|
||||
|
||||
@Size(max = 128, message = "备注名长度不能超过128个字符")
|
||||
@Schema(description = "备注名")
|
||||
private String remarkName;
|
||||
|
||||
@NotBlank(message = "紫鸟账号名称不能为空")
|
||||
@Size(max = 128, message = "紫鸟账号名称长度不能超过128个字符")
|
||||
@Schema(description = "紫鸟账号名称", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
|
||||
@@ -9,7 +9,7 @@ import java.math.BigDecimal;
|
||||
@Data
|
||||
public class SkipPriceAsinCountryUpdateRequest {
|
||||
|
||||
@Schema(description = "ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@Schema(description = "商品 ASIN", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
private String asin;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import lombok.Data;
|
||||
@Schema(description = "跳过 ASIN 明细")
|
||||
public class SkipPriceAsinDetailDto {
|
||||
|
||||
@Schema(description = "ASIN")
|
||||
@Schema(description = "商品 ASIN")
|
||||
private String asin;
|
||||
|
||||
@JsonProperty("minimumPrice")
|
||||
|
||||
@@ -13,6 +13,7 @@ public class ShopKeyEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String remarkName;
|
||||
private String ziniaoAccountName;
|
||||
private String ziniaoToken;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@@ -12,6 +12,9 @@ public class ShopKeyItemVo {
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "备注名")
|
||||
private String remarkName;
|
||||
|
||||
@Schema(description = "紫鸟账号名称")
|
||||
private String ziniaoAccountName;
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public class ShopKeyService {
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
ShopKeyEntity entity = new ShopKeyEntity();
|
||||
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
shopKeyMapper.insert(entity);
|
||||
@@ -59,6 +60,7 @@ public class ShopKeyService {
|
||||
ShopKeyEntity entity = getById(id);
|
||||
String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空");
|
||||
String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空");
|
||||
entity.setRemarkName(normalizeOptional(request.getRemarkName()));
|
||||
entity.setZiniaoAccountName(ziniaoAccountName);
|
||||
entity.setZiniaoToken(ziniaoToken);
|
||||
shopKeyMapper.updateById(entity);
|
||||
@@ -89,9 +91,14 @@ public class ShopKeyService {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeOptional(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
private ShopKeyItemVo toItemVo(ShopKeyEntity entity) {
|
||||
ShopKeyItemVo vo = new ShopKeyItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setRemarkName(entity.getRemarkName());
|
||||
vo.setZiniaoAccountName(entity.getZiniaoAccountName());
|
||||
vo.setZiniaoToken(entity.getZiniaoToken());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.nanri.aiimage.modules.shopkey.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
|
||||
@@ -35,6 +36,7 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
@@ -56,6 +58,7 @@ public class SkipPriceAsinService {
|
||||
private final ShopManageGroupService shopManageGroupService;
|
||||
private final Map<String, QueryAsinImportProgressVo> importProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, QueryAsinImportProgressVo> deleteImportProgressMap = new ConcurrentHashMap<>();
|
||||
private final Map<String, CachedSkipAsinLookup> skipAsinLookupCache = new ConcurrentHashMap<>();
|
||||
|
||||
public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin,
|
||||
Long operatorId, boolean superAdmin) {
|
||||
@@ -1055,6 +1058,219 @@ public class SkipPriceAsinService {
|
||||
return grouped;
|
||||
}
|
||||
|
||||
public SkipPriceAsinDetailDto findSkipAsinByCountryAndAsin(String country, String asin) {
|
||||
String normalizedCountry = normalizeCountry(country);
|
||||
String normalizedAsin = normalizeAsin(asin);
|
||||
String cacheKey = normalizedCountry + ":" + normalizedAsin;
|
||||
long now = System.currentTimeMillis();
|
||||
CachedSkipAsinLookup cached = skipAsinLookupCache.get(cacheKey);
|
||||
if (cached != null && cached.isFresh(now)) {
|
||||
return cached.toDto(normalizedAsin);
|
||||
}
|
||||
|
||||
SkipPriceAsinEntity row = selectCountryAsin(normalizedCountry, normalizedAsin);
|
||||
String minimumPrice = "";
|
||||
if (row != null) {
|
||||
BigDecimal value = getCountryMinimumPrice(row, normalizedCountry);
|
||||
minimumPrice = value == null ? "" : value.toPlainString();
|
||||
}
|
||||
cacheSkipAsinLookup(cacheKey, new CachedSkipAsinLookup(row != null, minimumPrice, now));
|
||||
if (row == null) {
|
||||
return null;
|
||||
}
|
||||
SkipPriceAsinDetailDto dto = new SkipPriceAsinDetailDto();
|
||||
dto.setAsin(normalizedAsin);
|
||||
dto.setMinimumPrice(minimumPrice);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private SkipPriceAsinEntity selectCountryAsin(String country, String asin) {
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<>();
|
||||
switch (country) {
|
||||
case "DE" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinDe,
|
||||
SkipPriceAsinEntity::getMinimumPriceDe)
|
||||
.eq(SkipPriceAsinEntity::getAsinDe, asin);
|
||||
case "UK" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinUk,
|
||||
SkipPriceAsinEntity::getMinimumPriceUk)
|
||||
.eq(SkipPriceAsinEntity::getAsinUk, asin);
|
||||
case "FR" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinFr,
|
||||
SkipPriceAsinEntity::getMinimumPriceFr)
|
||||
.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
||||
case "IT" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinIt,
|
||||
SkipPriceAsinEntity::getMinimumPriceIt)
|
||||
.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
||||
case "ES" -> query.select(SkipPriceAsinEntity::getId, SkipPriceAsinEntity::getAsinEs,
|
||||
SkipPriceAsinEntity::getMinimumPriceEs)
|
||||
.eq(SkipPriceAsinEntity::getAsinEs, asin);
|
||||
default -> throw new BusinessException("unsupported country: " + country);
|
||||
}
|
||||
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private void cacheSkipAsinLookup(String cacheKey, CachedSkipAsinLookup value) {
|
||||
if (skipAsinLookupCache.size() > 20000) {
|
||||
skipAsinLookupCache.clear();
|
||||
}
|
||||
skipAsinLookupCache.put(cacheKey, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询跳过 ASIN 数据(按商品状态全量模式使用)
|
||||
* 按 ASIN 个数精确分页(而非表记录数)
|
||||
*
|
||||
* @param shopName 店铺名称(可选,为空则查询所有)
|
||||
* @param countryCodes 国家代码列表
|
||||
* @param page 页码,从 1 开始
|
||||
* @param pageSize 每页条数
|
||||
* @return 分页结果
|
||||
*/
|
||||
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated(
|
||||
String shopName, List<String> countryCodes, int page, int pageSize) {
|
||||
String normalizedShopName = normalizeBlank(shopName);
|
||||
return listSkipAsinsPaginated(
|
||||
normalizedShopName.isEmpty() ? List.of() : List.of(normalizedShopName),
|
||||
countryCodes,
|
||||
page,
|
||||
pageSize);
|
||||
}
|
||||
|
||||
public com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo listSkipAsinsPaginated(
|
||||
List<String> shopNames, List<String> countryCodes, int page, int pageSize) {
|
||||
if (page < 1) {
|
||||
page = 1;
|
||||
}
|
||||
if (pageSize < 1 || pageSize > 2000) {
|
||||
pageSize = 1000;
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
List<String> targetCountries = (countryCodes == null || countryCodes.isEmpty())
|
||||
? List.of("DE", "UK", "FR", "IT", "ES")
|
||||
: countryCodes;
|
||||
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> queryWrapper = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.orderByDesc(SkipPriceAsinEntity::getId);
|
||||
|
||||
// 如果指定了店铺名,按店铺过滤
|
||||
LinkedHashSet<String> normalizedShopNames = new LinkedHashSet<>();
|
||||
if (shopNames != null) {
|
||||
for (String shopName : shopNames) {
|
||||
String normalizedShopName = normalizeBlank(shopName);
|
||||
if (!normalizedShopName.isEmpty()) {
|
||||
normalizedShopNames.add(normalizedShopName);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!normalizedShopNames.isEmpty()) {
|
||||
queryWrapper.in(SkipPriceAsinEntity::getShopName, normalizedShopNames);
|
||||
}
|
||||
applyCountryAsinFilter(queryWrapper, targetCountries);
|
||||
|
||||
// 查询所有记录(用于内存分页)
|
||||
List<SkipPriceAsinEntity> allRecords = skipPriceAsinMapper.selectList(queryWrapper);
|
||||
|
||||
// 展开所有 ASIN 为扁平列表
|
||||
List<AsinItem> flattenedAsins = new ArrayList<>();
|
||||
for (SkipPriceAsinEntity row : allRecords) {
|
||||
for (String country : targetCountries) {
|
||||
String asin = getCountryAsin(row, country);
|
||||
BigDecimal minimumPrice = getCountryMinimumPrice(row, country);
|
||||
|
||||
if (asin != null && !asin.isBlank()) {
|
||||
AsinItem item = new AsinItem();
|
||||
item.country = country;
|
||||
item.asin = asin;
|
||||
item.minimumPrice = minimumPrice;
|
||||
flattenedAsins.add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总数和分页
|
||||
long totalAsins = flattenedAsins.size();
|
||||
int totalPages = (int) ((totalAsins + pageSize - 1) / pageSize);
|
||||
int startIndex = (page - 1) * pageSize;
|
||||
int endIndex = Math.min(startIndex + pageSize, flattenedAsins.size());
|
||||
|
||||
// 提取当前页的 ASIN
|
||||
Map<String, List<String>> skipAsinsByCountry = new LinkedHashMap<>();
|
||||
Map<String, List<Map<String, String>>> skipDetailsByCountry = new LinkedHashMap<>();
|
||||
|
||||
for (String country : targetCountries) {
|
||||
skipAsinsByCountry.put(country, new ArrayList<>());
|
||||
skipDetailsByCountry.put(country, new ArrayList<>());
|
||||
}
|
||||
|
||||
for (int i = startIndex; i < endIndex; i++) {
|
||||
AsinItem item = flattenedAsins.get(i);
|
||||
skipAsinsByCountry.get(item.country).add(item.asin);
|
||||
|
||||
Map<String, String> detail = new LinkedHashMap<>();
|
||||
detail.put("asin", item.asin);
|
||||
detail.put("minimumPrice", item.minimumPrice == null ? "" : item.minimumPrice.toPlainString());
|
||||
skipDetailsByCountry.get(item.country).add(detail);
|
||||
}
|
||||
|
||||
// 构造返回 VO
|
||||
com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo vo =
|
||||
new com.nanri.aiimage.modules.pricetrack.model.vo.SkipPriceAsinPageVo();
|
||||
vo.setPage(page);
|
||||
vo.setPageSize(pageSize);
|
||||
vo.setTotal(totalAsins);
|
||||
vo.setTotalPages(totalPages);
|
||||
vo.setSkipAsinsByCountry(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountry(skipDetailsByCountry);
|
||||
vo.setSkipAsins(skipAsinsByCountry);
|
||||
vo.setSkipAsinsByCountryLegacy(skipAsinsByCountry);
|
||||
vo.setSkipAsinDetailsByCountryLegacy(skipDetailsByCountry);
|
||||
vo.setAsinRowsByCountry(new LinkedHashMap<>());
|
||||
vo.setMinimumPriceByCountryAndAsin(new LinkedHashMap<>());
|
||||
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void applyCountryAsinFilter(LambdaQueryWrapper<SkipPriceAsinEntity> queryWrapper, List<String> countries) {
|
||||
LinkedHashSet<String> normalizedCountries = new LinkedHashSet<>();
|
||||
if (countries != null) {
|
||||
for (String country : countries) {
|
||||
String normalized = normalizeBlank(country).toUpperCase(Locale.ROOT);
|
||||
switch (normalized) {
|
||||
case "DE", "UK", "FR", "IT", "ES" -> normalizedCountries.add(normalized);
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (normalizedCountries.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
queryWrapper.and(wrapper -> {
|
||||
boolean[] first = {true};
|
||||
for (String country : normalizedCountries) {
|
||||
if (!first[0]) {
|
||||
wrapper.or();
|
||||
}
|
||||
switch (country) {
|
||||
case "DE" -> wrapper.ne(SkipPriceAsinEntity::getAsinDe, "");
|
||||
case "UK" -> wrapper.ne(SkipPriceAsinEntity::getAsinUk, "");
|
||||
case "FR" -> wrapper.ne(SkipPriceAsinEntity::getAsinFr, "");
|
||||
case "IT" -> wrapper.ne(SkipPriceAsinEntity::getAsinIt, "");
|
||||
case "ES" -> wrapper.ne(SkipPriceAsinEntity::getAsinEs, "");
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
first[0] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 内部辅助类
|
||||
private static class AsinItem {
|
||||
String country;
|
||||
String asin;
|
||||
BigDecimal minimumPrice;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean removeByShopCountryAndAsin(String shopName, String country, String asin) {
|
||||
String normalizedShopName = normalizeBlank(shopName);
|
||||
@@ -1108,4 +1324,22 @@ public class SkipPriceAsinService {
|
||||
detail.put("minimumPrice", minimumPrice == null ? "" : minimumPrice.toPlainString());
|
||||
bucket.add(detail);
|
||||
}
|
||||
|
||||
private record CachedSkipAsinLookup(boolean exists, String minimumPrice, long cachedAtMillis) {
|
||||
private static final long TTL_MILLIS = 30_000L;
|
||||
|
||||
boolean isFresh(long now) {
|
||||
return now - cachedAtMillis <= TTL_MILLIS;
|
||||
}
|
||||
|
||||
SkipPriceAsinDetailDto toDto(String asin) {
|
||||
if (!exists) {
|
||||
return null;
|
||||
}
|
||||
SkipPriceAsinDetailDto dto = new SkipPriceAsinDetailDto();
|
||||
dto.setAsin(asin);
|
||||
dto.setMinimumPrice(minimumPrice == null ? "" : minimumPrice);
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequ
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskHistoryVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
|
||||
@@ -16,6 +15,8 @@ import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchStageCompleteRequest;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.dto.ShopMatchSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.shopmatch.model.vo.ShopMatchSkipAsinPageVo;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchResolveService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -122,7 +123,7 @@ public class ShopMatchController {
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建匹配店铺任务", description = "创建任务与各店铺占位结果,支持立即执行或多时间点定时执行。")
|
||||
public ApiResponse<ProductRiskCreateTaskVo> createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) {
|
||||
public ApiResponse<ShopMatchCreateTaskVo> createTask(@Valid @RequestBody ShopMatchCreateTaskRequest request) {
|
||||
return ApiResponse.success(shopMatchTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@@ -146,6 +147,26 @@ public class ShopMatchController {
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/skip-asins/paginated")
|
||||
@Operation(summary = "分页查询匹配店铺任务 ASIN 表数据", description = "Python 按任务、店铺和国家分页拉取从创建任务接口迁移出的 ASIN 表数据。")
|
||||
public ApiResponse<ShopMatchSkipAsinPageVo> getTaskSkipAsinsPaginated(
|
||||
@Parameter(description = "任务编号", required = true, example = "200") @PathVariable Long taskId,
|
||||
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@Parameter(description = "每页条数,默认 1000,最大 2000", example = "1000")
|
||||
@RequestParam(value = "page_size", defaultValue = "1000") Integer pageSize,
|
||||
@Parameter(description = "店铺名称过滤条件,可重复传入,也可用英文逗号分隔", example = "测试店铺A")
|
||||
@RequestParam(value = "shop_name", required = false) List<String> shopNames,
|
||||
@Parameter(description = "国家代码过滤条件,可重复传入,也可用英文逗号分隔", example = "DE")
|
||||
@RequestParam(value = "country_code", required = false) List<String> countryCodes) {
|
||||
return ApiResponse.success(shopMatchTaskService.getTaskSkipAsinsPaginated(
|
||||
taskId,
|
||||
page,
|
||||
pageSize,
|
||||
shopNames,
|
||||
countryCodes));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/stage-finished")
|
||||
@Operation(summary = "标记阶段完成", description = "某个定时阶段完成后推进到下一阶段,或等待最终收尾。")
|
||||
public ApiResponse<Void> completeStage(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.nanri.aiimage.modules.shopmatch.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
@@ -24,7 +23,7 @@ public class ShopMatchCreateTaskRequest {
|
||||
@NotEmpty(message = "items cannot be empty")
|
||||
@Valid
|
||||
@Schema(description = "已匹配店铺列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<ProductRiskShopQueueItemVo> items = new ArrayList<>();
|
||||
private List<ShopMatchTaskItemDto> items = new ArrayList<>();
|
||||
|
||||
@NotEmpty(message = "country_codes cannot be empty")
|
||||
@JsonProperty("country_codes")
|
||||
|
||||
@@ -9,7 +9,7 @@ import lombok.Data;
|
||||
@Schema(description = "匹配店铺单行结果")
|
||||
public class ShopMatchRowDto {
|
||||
|
||||
@Schema(description = "ASIN")
|
||||
@Schema(description = "商品 ASIN")
|
||||
private String asin;
|
||||
|
||||
@JsonAlias("minimum_price")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user