15 Commits

Author SHA1 Message Date
super
bca1335dc6 后端优化,增加新需求 2026-07-21 19:53:47 +08:00
super
ba48bbc6af 更新ASIN库新需求 2026-07-20 20:12:38 +08:00
super
cbde22ec79 视频任务管理更新 2026-07-19 22:23:26 +08:00
super
69784b8d32 添加新需求 2026-07-16 13:12:46 +08:00
super
8543dad514 修复后端BUG 2026-07-14 22:11:19 +08:00
super
8a9126e626 提交昨晚更新改动 2026-07-14 12:50:17 +08:00
super
d8ca8de537 完成视频部分的前后端 2026-07-11 19:42:42 +08:00
super
aab4ce942c 更新带货视频工作流接口和页面 2026-07-09 00:27:49 +08:00
super
0ecf5cd45a 后端优化修复问题 2026-06-18 18:53:16 +08:00
super
021b0c618b 修复图片下载问题 2026-06-16 22:19:55 +08:00
super
f44110fae5 新增数字人版本管理 2026-06-14 19:00:55 +08:00
super
ac19637ad6 处理修复BUG 2026-06-14 16:48:20 +08:00
super
8672668c33 处理下载为公共模块 2026-06-12 13:40:29 +08:00
super
17236265c6 修复删除asinBUG 2026-06-11 20:37:05 +08:00
super
c9efdf8b0e 修复菜单消失 2026-06-10 17:52:58 +08:00
198 changed files with 18101 additions and 1352 deletions

View File

@@ -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)) {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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"));
}

View File

@@ -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;
}

View File

@@ -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 {
}

View File

@@ -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());

View File

@@ -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));
}

View File

@@ -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", "评分"})

View File

@@ -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;

View File

@@ -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;

View File

@@ -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));
}

View File

@@ -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<>();
}

View File

@@ -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"})

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 可优先用于「结果文件」sheetrawRows 仅作为 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;
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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) {
}
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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));
}
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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()
);
}
}

View File

@@ -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";
}
}

View 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;
}

View File

@@ -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 生成预签名下载 URL1小时有效
*/
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兼容两种格式
* - 旧格式:完整预签名 URLhttps://bucket.endpoint/objectKey?Expires=...
* - 旧格式:完整 URLhttps://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) {
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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 = "";
}
}

View File

@@ -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、00 表示永久", example = "7")
private Integer expireDays;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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、00 表示永久")
private Integer expireDays;
@Schema(description = "过期时间")
private LocalDateTime expiresAt;
}

View File

@@ -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);
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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) {
}
}

View File

@@ -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();
}
}

View File

@@ -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));
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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")

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;
}
}

View File

@@ -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")

View File

@@ -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;

View File

@@ -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<>();
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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 = "正常")

View File

@@ -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));

View File

@@ -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));

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;

View File

@@ -8,7 +8,7 @@ import lombok.Data;
@Schema(description = "跳过 ASIN 明细")
public class SkipPriceAsinDetailDto {
@Schema(description = "ASIN")
@Schema(description = "商品 ASIN")
private String asin;
@JsonProperty("minimumPrice")

View File

@@ -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;

View File

@@ -12,6 +12,9 @@ public class ShopKeyItemVo {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "备注名")
private String remarkName;
@Schema(description = "紫鸟账号名称")
private String ziniaoAccountName;

View File

@@ -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());

View File

@@ -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;
}
}
}

View File

@@ -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(

View File

@@ -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")

View File

@@ -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