增加公共下载进度、增加接收SKU、密钥分别存放
This commit is contained in:
@@ -9,12 +9,12 @@ import java.util.function.Predicate;
|
||||
|
||||
public final class FailedStatusRowFilter {
|
||||
|
||||
private static final String STATUS_HEADER_CN = "\u72b6\u6001";
|
||||
private static final String FAILED_STATUS_ERROR = "\u9519\u8bef";
|
||||
private static final String FAILED_STATUS_FAILED_CN = "\u5931\u8d25";
|
||||
private static final String STATUS_HEADER_CN = "状态";
|
||||
private static final String FAILED_STATUS_ERROR = "错误";
|
||||
private static final String FAILED_STATUS_FAILED_CN = "失败";
|
||||
private static final String FAILED_STATUS_FAILED_EN = "failed";
|
||||
private static final String NO_MATCHED_ROWS_MESSAGE =
|
||||
"\u72b6\u6001\u5217\u8fc7\u6ee4\u540e\u672a\u627e\u5230 \u9519\u8bef/\u5931\u8d25/FAILED \u6570\u636e";
|
||||
"状态列过滤后未找到 错误/失败/FAILED 数据";
|
||||
|
||||
private static final Set<String> STATUS_HEADER_ALIASES = Set.of(
|
||||
normalizeHeader(STATUS_HEADER_CN),
|
||||
@@ -56,8 +56,8 @@ public final class FailedStatusRowFilter {
|
||||
|
||||
/**
|
||||
* 暴露表头归一化逻辑给其它模块复用,避免每个模块各自维护一份规则。
|
||||
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合一、转小写,
|
||||
* 并剥除常见分隔符(含中文全角括号、方括号、冒号)。
|
||||
* 处理:去 BOM、全角空格转半角、首尾 trim、连续空白合并、转小写,
|
||||
* 并剔除常见分隔符(含中文全角括号、方括号、冒号)。
|
||||
*/
|
||||
public static String canonicalizeHeader(String value) {
|
||||
return normalizeHeader(value);
|
||||
@@ -92,8 +92,8 @@ public final class FailedStatusRowFilter {
|
||||
}
|
||||
|
||||
private static String normalizeHeader(String value) {
|
||||
// 扩展:兼容中文全角括号 ()、中文方括号 【】、中文冒号 :,避免
|
||||
// "状态(结果)"、"标题维度(商标)" 等全角括号表头被识别失败。
|
||||
// 兼容中文全角括号()、中文方括号【】、中文冒号:,
|
||||
// 避免“状态(结果)”“标题维度(商标)”等表头识别失败。
|
||||
return normalizeValue(value).replaceAll("[\\s_\\-()\\[\\]{}::()【】]+", "");
|
||||
}
|
||||
|
||||
|
||||
@@ -355,13 +355,14 @@ public class AppearancePatentCozeClient {
|
||||
List<String> rowIds = rows.stream().map(row -> nonBlank(row.getId(), "")).toList();
|
||||
List<String> asins = rows.stream().map(row -> nonBlank(row.getAsin(), "")).toList();
|
||||
List<String> countries = rows.stream().map(row -> nonBlank(row.getCountry(), "")).toList();
|
||||
List<String> skus = rows.stream().map(row -> nonBlank(row.getSku(), "")).toList();
|
||||
List<String> titles = rows.stream().map(row -> nonBlank(row.getTitle(), row.getAsin())).toList();
|
||||
List<String> urls = rows.stream().map(row -> nonBlank(row.getUrl(), "")).toList();
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("title_list", titles);
|
||||
parameters.put("url_list", urls);
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, titles, urls));
|
||||
parameters.put("items", buildItemObjects(rows, groupKeys, rowIds, asins, countries, skus, titles, urls));
|
||||
// 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。
|
||||
if (prompt != null && !prompt.isBlank()) {
|
||||
parameters.put("prompt", prompt);
|
||||
@@ -403,6 +404,7 @@ public class AppearancePatentCozeClient {
|
||||
List<String> rowIds,
|
||||
List<String> asins,
|
||||
List<String> countries,
|
||||
List<String> skus,
|
||||
List<String> titles,
|
||||
List<String> urls) {
|
||||
List<Map<String, Object>> items = new ArrayList<>(rows.size());
|
||||
@@ -412,6 +414,7 @@ public class AppearancePatentCozeClient {
|
||||
item.put("row_id", rowIds.get(i));
|
||||
item.put("asin", asins.get(i));
|
||||
item.put("country", countries.get(i));
|
||||
item.put("sku", skus.get(i));
|
||||
item.put("title", titles.get(i));
|
||||
item.put("url", urls.get(i));
|
||||
items.add(item);
|
||||
@@ -591,6 +594,7 @@ public class AppearancePatentCozeClient {
|
||||
row.setId(source.getId());
|
||||
row.setAsin(source.getAsin());
|
||||
row.setCountry(source.getCountry());
|
||||
row.setSku(source.getSku());
|
||||
row.setUrl(source.getUrl());
|
||||
row.setTitle(source.getTitle());
|
||||
row.setError(source.getError());
|
||||
|
||||
@@ -29,6 +29,10 @@ public class AppearancePatentResultRowDto {
|
||||
@Schema(description = "站点或国家。来自 Excel 的国家列,例如英国、德国、法国。", example = "英国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001")
|
||||
@JsonAlias({"SKU", "sellerSku", "seller_sku", "merchantSku", "merchant_sku", "商品SKU", "商品 sku", "库存SKU"})
|
||||
private String sku;
|
||||
|
||||
@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",
|
||||
|
||||
@@ -36,6 +36,9 @@ public class AppearancePatentParsedRowVo {
|
||||
@Schema(description = "价格。", example = "12.99")
|
||||
private String price;
|
||||
|
||||
@Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001")
|
||||
private String sku;
|
||||
|
||||
@Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg")
|
||||
private String url;
|
||||
|
||||
|
||||
@@ -1255,6 +1255,7 @@ public class AppearancePatentTaskService {
|
||||
row.setId(sibling.getDisplayId());
|
||||
row.setAsin(sibling.getAsin());
|
||||
row.setCountry(sibling.getCountry());
|
||||
row.setSku(firstNonBlank(representative.getSku(), sibling.getSku()));
|
||||
row.setUrl(firstNonBlank(representative.getUrl(), sibling.getUrl()));
|
||||
row.setTitle(firstNonBlank(representative.getTitle(), sibling.getTitle()));
|
||||
row.setError(representative.getError());
|
||||
@@ -2178,8 +2179,8 @@ public class AppearancePatentTaskService {
|
||||
|| normalized.contains("execution limit")
|
||||
|| normalized.contains("720712008")
|
||||
|| normalized.contains("720701002")
|
||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||
|| normalized.contains("工作流节点执行超限")
|
||||
|| normalized.contains("调用超时");
|
||||
}
|
||||
|
||||
private boolean isRetryableCozeFailure(String failureMessage) {
|
||||
@@ -2197,10 +2198,10 @@ public class AppearancePatentTaskService {
|
||||
|| normalized.contains("720712008")
|
||||
|| normalized.contains("720701002")
|
||||
|| normalized.contains("plugin limit")
|
||||
|| normalized.contains("\u9650\u6d41")
|
||||
|| normalized.contains("\u7a0d\u540e\u91cd\u8bd5")
|
||||
|| normalized.contains("\u5de5\u4f5c\u6d41\u8282\u70b9\u6267\u884c\u8d85\u9650")
|
||||
|| normalized.contains("\u8c03\u7528\u8d85\u65f6");
|
||||
|| normalized.contains("限流")
|
||||
|| normalized.contains("稍后重试")
|
||||
|| normalized.contains("工作流节点执行超限")
|
||||
|| normalized.contains("调用超时");
|
||||
}
|
||||
|
||||
private void updateCozeStateRunning(TaskScopeStateEntity state, String error) {
|
||||
@@ -3379,6 +3380,8 @@ public class AppearancePatentTaskService {
|
||||
int asinCol = findRequiredHeader(headerMap, "asin");
|
||||
int countryCol = findRequiredHeader(headerMap, "国家", "country");
|
||||
int priceCol = findOptionalHeaderExact(headerMap, "价格", "price");
|
||||
int skuCol = findOptionalHeaderExact(headerMap,
|
||||
"sku", "seller sku", "seller_sku", "merchant sku", "merchant_sku", "商品sku", "商品 sku", "库存sku");
|
||||
int urlCol = findOptionalHeaderExact(headerMap,
|
||||
"url", "rul", "link", "image", "img", "pic", "picture",
|
||||
"链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接");
|
||||
@@ -3426,6 +3429,7 @@ public class AppearancePatentTaskService {
|
||||
vo.setAsin(asin);
|
||||
vo.setCountry(country);
|
||||
vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : "");
|
||||
vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : "");
|
||||
vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : "");
|
||||
vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : "");
|
||||
parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : ""));
|
||||
@@ -3470,9 +3474,11 @@ public class AppearancePatentTaskService {
|
||||
for (List<AppearancePatentParsedRowVo> siblings : rowsByBaseId.values()) {
|
||||
String title = "";
|
||||
String url = "";
|
||||
String sku = "";
|
||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||
title = firstNonBlank(title, sibling.getTitle());
|
||||
url = firstNonBlank(url, sibling.getUrl());
|
||||
sku = firstNonBlank(sku, sibling.getSku());
|
||||
}
|
||||
for (AppearancePatentParsedRowVo sibling : siblings) {
|
||||
if (normalize(sibling.getTitle()).isBlank()) {
|
||||
@@ -3481,6 +3487,9 @@ public class AppearancePatentTaskService {
|
||||
if (normalize(sibling.getUrl()).isBlank()) {
|
||||
sibling.setUrl(url);
|
||||
}
|
||||
if (normalize(sibling.getSku()).isBlank()) {
|
||||
sibling.setSku(sku);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package com.nanri.aiimage.modules.collectdata.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataParseRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
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.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/collect-data")
|
||||
@Tag(name = "采集数据", description = "采集数据任务接口。前端上传 Excel 后由 Java 解析并落库;Python 端按页拉取明细数据进行采集。")
|
||||
public class CollectDataController {
|
||||
|
||||
private final CollectDataService service;
|
||||
|
||||
@PostMapping("/parse")
|
||||
@Operation(summary = "解析 Excel 并创建任务", description = "解析上传后的 Excel 文件,按行入库到 biz_collect_data_item,并保存任务筛选条件。任务初始状态为 PENDING。")
|
||||
public ApiResponse<CollectDataParseVo> parse(@Valid @RequestBody CollectDataParseRequest request) {
|
||||
return ApiResponse.success(service.parseAndCreateTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/activate")
|
||||
@Operation(summary = "激活任务", description = "前端推送 Python 队列前调用,将任务状态从 PENDING 修改为 RUNNING,表示已交给 Python 处理。")
|
||||
public ApiResponse<Void> activate(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.activateTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/items")
|
||||
@Operation(summary = "分页获取任务明细数据", description = "供 Python 端拉取,默认每页 50 条;返回任务关联的筛选条件,便于 Python 端按筛选条件采集。")
|
||||
public ApiResponse<CollectDataItemsPageVo> items(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "用户 ID,可选;传入时会校验任务归属")
|
||||
@RequestParam(value = "user_id", required = false) Long userId,
|
||||
@Parameter(description = "页码,从 1 开始", example = "1")
|
||||
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
|
||||
@Parameter(description = "每页条数,默认 50,最大 200", example = "50")
|
||||
@RequestParam(value = "page_size", required = false, defaultValue = "50") Integer pageSize) {
|
||||
return ApiResponse.success(service.getItemsPage(taskId, userId, page, pageSize));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询采集任务总览")
|
||||
public ApiResponse<CollectDataDashboardVo> dashboard(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(service.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询采集任务历史")
|
||||
public ApiResponse<CollectDataHistoryVo> history(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
@Parameter(description = "返回数量上限,默认 50,最大 100", example = "50")
|
||||
@RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) {
|
||||
return ApiResponse.success(service.history(userId, limit));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询任务进度")
|
||||
public ApiResponse<CollectDataTaskBatchVo> progressBatch(@Valid @RequestBody CollectDataTaskBatchRequest request) {
|
||||
return ApiResponse.success(service.progressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除任务及其明细行、关联结果记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "采集任务 ID", required = true, example = "9001")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除历史记录")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果记录 ID", required = true, example = "1001")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
service.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/country-preference")
|
||||
@Operation(summary = "查询国家处理顺序", description = "返回当前用户保存的国家代码列表,顺序即处理顺序。")
|
||||
public ApiResponse<CollectDataCountryPreferenceVo> getCountryPreference(
|
||||
@Parameter(description = "当前用户 ID", required = true, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(service.getCountryPreference(userId));
|
||||
}
|
||||
|
||||
@PutMapping("/country-preference")
|
||||
@Operation(summary = "保存国家处理顺序", description = "保存当前用户的国家勾选与处理顺序。")
|
||||
public ApiResponse<CollectDataCountryPreferenceVo> saveCountryPreference(
|
||||
@Valid @RequestBody CollectDataCountryPreferenceSaveRequest request) {
|
||||
return ApiResponse.success(service.saveCountryPreference(request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataCountryPrefEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface CollectDataCountryPrefMapper extends BaseMapper<CollectDataCountryPrefEntity> {
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.collectdata.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface CollectDataItemMapper extends BaseMapper<CollectDataItemEntity> {
|
||||
|
||||
@Insert("""
|
||||
<script>
|
||||
INSERT INTO biz_collect_data_item
|
||||
(task_id, row_index, source_file_key, source_filename, keyword, status_value, extra_json, created_at)
|
||||
VALUES
|
||||
<foreach collection="rows" item="row" separator=",">
|
||||
(#{row.taskId}, #{row.rowIndex}, #{row.sourceFileKey}, #{row.sourceFilename},
|
||||
#{row.keyword}, #{row.statusValue}, #{row.extraJson}, #{row.createdAt})
|
||||
</foreach>
|
||||
</script>
|
||||
""")
|
||||
int insertBatch(@Param("rows") List<CollectDataItemEntity> rows);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "保存采集数据五国处理顺序:至少 1 个、最多 5 个,代码须为 DE、UK、FR、IT、ES,且不能重复")
|
||||
public class CollectDataCountryPreferenceSaveRequest {
|
||||
|
||||
@NotNull(message = "user_id 不能为空")
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty(message = "country_codes 不能为空")
|
||||
@Size(max = 5, message = "country_codes 最多 5 项")
|
||||
@JsonProperty("country_codes")
|
||||
@Schema(description = "国家代码列表(DE、UK、FR、IT、ES),顺序即处理顺序", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据筛选条件")
|
||||
public class CollectDataFiltersDto {
|
||||
|
||||
@Schema(description = "金额(手动输入),可为空", example = "59.99")
|
||||
private BigDecimal amount;
|
||||
|
||||
@Schema(description = "排名(手动输入),可为空", example = "100000")
|
||||
private Integer rank;
|
||||
|
||||
@Schema(description = "是否包含 FBA 商品")
|
||||
private Boolean fba;
|
||||
|
||||
@Schema(description = "是否包含 FBM 商品")
|
||||
private Boolean fbm;
|
||||
|
||||
@Schema(description = "国家代码列表,按用户拖拽顺序保存", example = "[\"DE\",\"UK\"]")
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据解析请求:提交筛选条件和上传后的 Excel 文件,由后端解析并落库。")
|
||||
public class CollectDataParseRequest {
|
||||
|
||||
@JsonProperty("user_id")
|
||||
@NotNull
|
||||
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty
|
||||
@Schema(description = "已上传的 Excel 文件列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<CollectDataSourceFileDto> files;
|
||||
|
||||
@JsonProperty("task_type")
|
||||
@Schema(description = "任务类型,前端可自定义传入;为空时默认 collect-data", example = "collect-data")
|
||||
private String taskType;
|
||||
|
||||
@Valid
|
||||
@Schema(description = "筛选条件")
|
||||
private CollectDataFiltersDto filters;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据源文件信息")
|
||||
public class CollectDataSourceFileDto {
|
||||
@Schema(description = "上传接口返回的临时文件 key", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String fileKey;
|
||||
|
||||
@Schema(description = "原始文件名")
|
||||
private String originalFilename;
|
||||
|
||||
@Schema(description = "相对目录路径")
|
||||
private String relativePath;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量查询采集数据任务进度的请求")
|
||||
public class CollectDataTaskBatchRequest {
|
||||
@NotEmpty
|
||||
@Schema(description = "任务 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<Long> taskIds;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@TableName("biz_collect_data_country_pref")
|
||||
public class CollectDataCountryPrefEntity {
|
||||
|
||||
@TableId(type = IdType.INPUT)
|
||||
private Long userId;
|
||||
|
||||
@TableField("country_codes_json")
|
||||
private String countryCodesJson;
|
||||
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.collectdata.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_collect_data_item")
|
||||
public class CollectDataItemEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long taskId;
|
||||
private Integer rowIndex;
|
||||
private String sourceFileKey;
|
||||
private String sourceFilename;
|
||||
private String keyword;
|
||||
private String statusValue;
|
||||
private String extraJson;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
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 = "采集数据用户五国处理偏好:按顺序排列的国家代码列表,如 DE、UK")
|
||||
public class CollectDataCountryPreferenceVo {
|
||||
|
||||
@JsonProperty("country_codes")
|
||||
@Schema(description = "已选国家代码,顺序即处理顺序;未持久化时返回默认 DE -> UK -> FR -> IT -> ES")
|
||||
private List<String> countryCodes = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据总览统计")
|
||||
public class CollectDataDashboardVo {
|
||||
@Schema(description = "运行中任务数量")
|
||||
private Long pendingTaskCount;
|
||||
@Schema(description = "已结束任务数量")
|
||||
private Long processedTaskCount;
|
||||
@Schema(description = "成功任务数量")
|
||||
private Long successTaskCount;
|
||||
@Schema(description = "失败任务数量")
|
||||
private Long failedTaskCount;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据历史/任务记录")
|
||||
public class CollectDataHistoryItemVo {
|
||||
@Schema(description = "结果记录 ID")
|
||||
private Long resultId;
|
||||
|
||||
@Schema(description = "任务 ID")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "源文件聚合展示名")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "结果文件名")
|
||||
private String resultFilename;
|
||||
|
||||
@Schema(description = "下载地址")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "任务状态:PENDING / RUNNING / SUCCESS / FAILED")
|
||||
private String taskStatus;
|
||||
|
||||
@Schema(description = "结果是否成功")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "落库行数")
|
||||
private Integer rowCount;
|
||||
|
||||
@Schema(description = "记录创建时间")
|
||||
private String createdAt;
|
||||
|
||||
@Schema(description = "任务开始时间")
|
||||
private String startedAt;
|
||||
|
||||
@Schema(description = "任务结束时间")
|
||||
private String finishedAt;
|
||||
|
||||
@Schema(description = "任务类型")
|
||||
private String taskType;
|
||||
|
||||
@Schema(description = "解析时保存的筛选条件")
|
||||
private CollectDataFiltersDto filters;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据历史列表")
|
||||
public class CollectDataHistoryVo {
|
||||
@Schema(description = "历史记录列表")
|
||||
private List<CollectDataHistoryItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据明细行")
|
||||
public class CollectDataItemVo {
|
||||
@Schema(description = "数据库主键")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "行号,从 1 开始")
|
||||
private Integer rowIndex;
|
||||
|
||||
@Schema(description = "来源文件 key")
|
||||
private String sourceFileKey;
|
||||
|
||||
@Schema(description = "来源文件名")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "关键词列内容")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "状态列内容")
|
||||
private String statusValue;
|
||||
|
||||
@Schema(description = "其它列内容(列名 -> 字符串值)")
|
||||
private Map<String, String> extra = new LinkedHashMap<>();
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据明细分页结果,默认每页 50 条,供 Python 拉取")
|
||||
public class CollectDataItemsPageVo {
|
||||
@Schema(description = "任务 ID")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "任务类型")
|
||||
private String taskType;
|
||||
|
||||
@Schema(description = "任务状态")
|
||||
private String taskStatus;
|
||||
|
||||
@Schema(description = "页码,从 1 开始")
|
||||
private Integer page;
|
||||
|
||||
@Schema(description = "每页条数")
|
||||
private Integer pageSize;
|
||||
|
||||
@Schema(description = "本页条数")
|
||||
private Integer count;
|
||||
|
||||
@Schema(description = "总条数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "总页数")
|
||||
private Integer totalPages;
|
||||
|
||||
@Schema(description = "本任务关联的筛选条件")
|
||||
private CollectDataFiltersDto filters;
|
||||
|
||||
@Schema(description = "明细列表")
|
||||
private List<CollectDataItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据解析结果")
|
||||
public class CollectDataParseVo {
|
||||
|
||||
@Schema(description = "新创建的任务 ID", example = "9001")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "聚合源文件展示名", example = "采集前数据.xlsx 等 1 个文件")
|
||||
private String sourceFilename;
|
||||
|
||||
@Schema(description = "源文件数量")
|
||||
private Integer sourceFileCount;
|
||||
|
||||
@Schema(description = "Excel 中检测到的非空数据总行数")
|
||||
private Integer totalRows;
|
||||
|
||||
@Schema(description = "落库的有效行数")
|
||||
private Integer acceptedRows;
|
||||
|
||||
@Schema(description = "因缺少必要字段被丢弃的行数")
|
||||
private Integer droppedRows;
|
||||
|
||||
@Schema(description = "推荐分页大小,便于 Python 拉取分页数据", example = "50")
|
||||
private Integer pageSize;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量任务进度返回")
|
||||
public class CollectDataTaskBatchVo {
|
||||
@Schema(description = "任务详情列表")
|
||||
private List<CollectDataTaskDetailVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "未找到的任务 ID")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据任务进度详情")
|
||||
public class CollectDataTaskDetailVo {
|
||||
@Schema(description = "任务摘要")
|
||||
private CollectDataTaskSummaryVo task;
|
||||
|
||||
@Schema(description = "结果/历史记录")
|
||||
private List<CollectDataHistoryItemVo> items = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.nanri.aiimage.modules.collectdata.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "采集数据任务摘要")
|
||||
public class CollectDataTaskSummaryVo {
|
||||
@Schema(description = "任务 ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "任务编号")
|
||||
private String taskNo;
|
||||
|
||||
@Schema(description = "任务状态")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private String createdAt;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private String updatedAt;
|
||||
|
||||
@Schema(description = "结束时间")
|
||||
private String finishedAt;
|
||||
}
|
||||
@@ -0,0 +1,732 @@
|
||||
package com.nanri.aiimage.modules.collectdata.service;
|
||||
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataCountryPrefMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.mapper.CollectDataItemMapper;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataCountryPreferenceSaveRequest;
|
||||
import com.nanri.aiimage.modules.collectdata.model.dto.CollectDataFiltersDto;
|
||||
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.entity.CollectDataCountryPrefEntity;
|
||||
import com.nanri.aiimage.modules.collectdata.model.entity.CollectDataItemEntity;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataCountryPreferenceVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataDashboardVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryItemVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataHistoryVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataItemsPageVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataParseVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskDetailVo;
|
||||
import com.nanri.aiimage.modules.collectdata.model.vo.CollectDataTaskSummaryVo;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.ss.usermodel.WorkbookFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
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;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class CollectDataService {
|
||||
|
||||
public static final String MODULE_TYPE = "COLLECT_DATA";
|
||||
public static final int DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* 默认国家偏好顺序:DE -> UK -> FR -> IT -> ES,与前端保持一致。
|
||||
*/
|
||||
public static final List<String> DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
private static final String STATUS_RUNNING = "RUNNING";
|
||||
private static final String STATUS_SUCCESS = "SUCCESS";
|
||||
private static final String STATUS_FAILED = "FAILED";
|
||||
|
||||
private static final String DEFAULT_TASK_TYPE = "collect-data";
|
||||
private static final int ITEM_INSERT_BATCH_SIZE = 500;
|
||||
|
||||
private static final List<String> KEYWORD_HEADER_ALIASES = List.of("关键词", "keyword", "key word");
|
||||
private static final List<String> STATUS_HEADER_ALIASES = List.of("状态", "status");
|
||||
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
|
||||
private final LocalFileStorageService localFileStorageService;
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final CollectDataItemMapper collectDataItemMapper;
|
||||
private final CollectDataCountryPrefMapper collectDataCountryPrefMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public CollectDataParseVo parseAndCreateTask(CollectDataParseRequest request) {
|
||||
long startedAt = System.currentTimeMillis();
|
||||
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
if (request.getFiles() == null || request.getFiles().isEmpty()) {
|
||||
throw new BusinessException("请先上传 Excel 文件");
|
||||
}
|
||||
List<CollectDataSourceFileDto> sources = request.getFiles().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(f -> f.getFileKey() != null && !f.getFileKey().isBlank())
|
||||
.toList();
|
||||
if (sources.isEmpty()) {
|
||||
throw new BusinessException("请先上传 Excel 文件");
|
||||
}
|
||||
String requestedTaskType = normalize(request.getTaskType());
|
||||
final String taskType = requestedTaskType.isBlank() ? DEFAULT_TASK_TYPE : requestedTaskType;
|
||||
CollectDataFiltersDto filters = request.getFilters() == null ? new CollectDataFiltersDto() : request.getFilters();
|
||||
|
||||
List<ParsedRow> parsedRows = new ArrayList<>();
|
||||
int totalRows = 0;
|
||||
int droppedRows = 0;
|
||||
for (CollectDataSourceFileDto source : sources) {
|
||||
File input = localFileStorageService.findLocalSourceFile(source.getFileKey());
|
||||
if (input == null || !input.exists()) {
|
||||
throw new BusinessException("源文件不存在");
|
||||
}
|
||||
ParsedWorkbook parsed = parseWorkbook(input, source);
|
||||
totalRows += parsed.totalRows();
|
||||
droppedRows += parsed.droppedRows();
|
||||
parsedRows.addAll(parsed.rows());
|
||||
}
|
||||
if (parsedRows.isEmpty()) {
|
||||
throw new BusinessException("未解析到有效数据行");
|
||||
}
|
||||
|
||||
long parsedAt = System.currentTimeMillis();
|
||||
String aggregateFilename = buildAggregateSourceFilenameLabel(sources);
|
||||
PersistedParse persisted = transactionTemplate.execute(status ->
|
||||
persistParsedTask(request, sources, taskType, filters, parsedRows, aggregateFilename));
|
||||
if (persisted == null) {
|
||||
throw new BusinessException("创建采集任务失败");
|
||||
}
|
||||
long persistedAt = System.currentTimeMillis();
|
||||
log.info("[collect-data] parseAndCreateTask userId={} files={} totalRows={} acceptedRows={} droppedRows={} parseMs={} persistMs={} totalMs={}",
|
||||
request.getUserId(), sources.size(), totalRows, parsedRows.size(), droppedRows,
|
||||
parsedAt - startedAt, persistedAt - parsedAt, persistedAt - startedAt);
|
||||
|
||||
CollectDataParseVo vo = new CollectDataParseVo();
|
||||
vo.setTaskId(persisted.task().getId());
|
||||
vo.setTaskNo(persisted.task().getTaskNo());
|
||||
vo.setSourceFilename(aggregateFilename);
|
||||
vo.setSourceFileCount(sources.size());
|
||||
vo.setTotalRows(totalRows);
|
||||
vo.setAcceptedRows(parsedRows.size());
|
||||
vo.setDroppedRows(droppedRows);
|
||||
vo.setPageSize(DEFAULT_PAGE_SIZE);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private PersistedParse persistParsedTask(CollectDataParseRequest request,
|
||||
List<CollectDataSourceFileDto> sources,
|
||||
String taskType,
|
||||
CollectDataFiltersDto filters,
|
||||
List<ParsedRow> parsedRows,
|
||||
String aggregateFilename) {
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setTaskMode("PYTHON_QUEUE");
|
||||
task.setStatus(STATUS_PENDING);
|
||||
task.setSourceFileCount(sources.size());
|
||||
task.setSuccessFileCount(0);
|
||||
task.setFailedFileCount(0);
|
||||
task.setCreatedBy("user:" + request.getUserId());
|
||||
task.setUserId(request.getUserId());
|
||||
task.setCreatedAt(LocalDateTime.now());
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
try {
|
||||
Map<String, Object> requestPayload = new LinkedHashMap<>();
|
||||
requestPayload.put("userId", request.getUserId());
|
||||
requestPayload.put("taskType", taskType);
|
||||
requestPayload.put("filters", filters);
|
||||
requestPayload.put("files", sources);
|
||||
task.setRequestJson(objectMapper.writeValueAsString(requestPayload));
|
||||
task.setResultJson("{}");
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("序列化任务信息失败");
|
||||
}
|
||||
fileTaskMapper.insert(task);
|
||||
|
||||
int rowIndex = 0;
|
||||
LocalDateTime itemCreatedAt = LocalDateTime.now();
|
||||
List<CollectDataItemEntity> itemBatch = new ArrayList<>(Math.min(parsedRows.size(), ITEM_INSERT_BATCH_SIZE));
|
||||
for (ParsedRow parsedRow : parsedRows) {
|
||||
rowIndex++;
|
||||
CollectDataItemEntity entity = new CollectDataItemEntity();
|
||||
entity.setTaskId(task.getId());
|
||||
entity.setRowIndex(rowIndex);
|
||||
entity.setSourceFileKey(parsedRow.sourceFileKey());
|
||||
entity.setSourceFilename(parsedRow.sourceFilename());
|
||||
entity.setKeyword(parsedRow.keyword());
|
||||
entity.setStatusValue(parsedRow.statusValue());
|
||||
try {
|
||||
entity.setExtraJson(objectMapper.writeValueAsString(parsedRow.extra()));
|
||||
} catch (Exception ex) {
|
||||
entity.setExtraJson("{}");
|
||||
}
|
||||
entity.setCreatedAt(itemCreatedAt);
|
||||
itemBatch.add(entity);
|
||||
if (itemBatch.size() >= ITEM_INSERT_BATCH_SIZE) {
|
||||
collectDataItemMapper.insertBatch(itemBatch);
|
||||
itemBatch.clear();
|
||||
}
|
||||
}
|
||||
if (!itemBatch.isEmpty()) {
|
||||
collectDataItemMapper.insertBatch(itemBatch);
|
||||
}
|
||||
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(MODULE_TYPE);
|
||||
result.setSourceFilename(aggregateFilename);
|
||||
result.setSourceFileUrl(buildAggregateSourceFileUrl(sources));
|
||||
result.setRowCount(parsedRows.size());
|
||||
result.setUserId(request.getUserId());
|
||||
result.setCreatedAt(LocalDateTime.now());
|
||||
fileResultMapper.insert(result);
|
||||
|
||||
return new PersistedParse(task, result);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void activateTask(Long taskId, Long userId) {
|
||||
FileTaskEntity task = requireTask(taskId, userId);
|
||||
if (STATUS_SUCCESS.equals(task.getStatus()) || STATUS_FAILED.equals(task.getStatus())) {
|
||||
throw new BusinessException("任务已结束");
|
||||
}
|
||||
task.setStatus(STATUS_RUNNING);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
fileTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
public CollectDataDashboardVo dashboard(Long userId) {
|
||||
CollectDataDashboardVo vo = new CollectDataDashboardVo();
|
||||
vo.setPendingTaskCount(countTask(userId, STATUS_RUNNING));
|
||||
vo.setSuccessTaskCount(countTask(userId, STATUS_SUCCESS));
|
||||
vo.setFailedTaskCount(countTask(userId, STATUS_FAILED));
|
||||
long processed = (vo.getSuccessTaskCount() == null ? 0 : vo.getSuccessTaskCount())
|
||||
+ (vo.getFailedTaskCount() == null ? 0 : vo.getFailedTaskCount());
|
||||
vo.setProcessedTaskCount(processed);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public CollectDataHistoryVo history(Long userId, Integer limit) {
|
||||
CollectDataHistoryVo vo = new CollectDataHistoryVo();
|
||||
if (userId == null || userId <= 0) {
|
||||
return vo;
|
||||
}
|
||||
int safeLimit = Math.max(1, Math.min(limit == null ? 50 : limit, 100));
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit " + safeLimit));
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return vo;
|
||||
}
|
||||
List<Long> taskIds = rows.stream().map(FileResultEntity::getTaskId).filter(Objects::nonNull).distinct().toList();
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMap(taskIds);
|
||||
for (FileResultEntity row : rows) {
|
||||
FileTaskEntity task = row.getTaskId() == null ? null : taskMap.get(row.getTaskId());
|
||||
vo.getItems().add(toHistoryItem(row, task));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public CollectDataTaskBatchVo progressBatch(List<Long> taskIds) {
|
||||
CollectDataTaskBatchVo vo = new CollectDataTaskBatchVo();
|
||||
List<Long> normalizedIds = taskIds == null ? List.of() : taskIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalizedIds.isEmpty()) {
|
||||
return vo;
|
||||
}
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMap(normalizedIds);
|
||||
List<FileResultEntity> resultRows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.in(FileResultEntity::getTaskId, normalizedIds)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt));
|
||||
Map<Long, FileResultEntity> resultByTaskId = new LinkedHashMap<>();
|
||||
if (resultRows != null) {
|
||||
for (FileResultEntity row : resultRows) {
|
||||
if (row.getTaskId() != null) {
|
||||
resultByTaskId.putIfAbsent(row.getTaskId(), row);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (Long taskId : normalizedIds) {
|
||||
FileTaskEntity task = taskMap.get(taskId);
|
||||
if (task == null) {
|
||||
vo.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
CollectDataTaskDetailVo detail = new CollectDataTaskDetailVo();
|
||||
detail.setTask(toTaskSummary(task));
|
||||
FileResultEntity row = resultByTaskId.get(taskId);
|
||||
if (row != null) {
|
||||
detail.getItems().add(toHistoryItem(row, task));
|
||||
}
|
||||
vo.getItems().add(detail);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public CollectDataItemsPageVo getItemsPage(Long taskId, Long userId, Integer page, Integer pageSize) {
|
||||
FileTaskEntity task = requireTask(taskId, userId);
|
||||
int safePage = page == null || page <= 0 ? 1 : page;
|
||||
int safePageSize = pageSize == null || pageSize <= 0 ? DEFAULT_PAGE_SIZE : Math.min(pageSize, 200);
|
||||
|
||||
Long total = collectDataItemMapper.selectCount(new LambdaQueryWrapper<CollectDataItemEntity>()
|
||||
.eq(CollectDataItemEntity::getTaskId, taskId));
|
||||
long totalRows = total == null ? 0L : total;
|
||||
int totalPages = totalRows == 0 ? 0 : (int) ((totalRows + safePageSize - 1) / safePageSize);
|
||||
|
||||
int offset = (safePage - 1) * safePageSize;
|
||||
List<CollectDataItemEntity> rows = collectDataItemMapper.selectList(new LambdaQueryWrapper<CollectDataItemEntity>()
|
||||
.eq(CollectDataItemEntity::getTaskId, taskId)
|
||||
.orderByAsc(CollectDataItemEntity::getRowIndex)
|
||||
.last("limit " + safePageSize + " offset " + Math.max(offset, 0)));
|
||||
|
||||
CollectDataItemsPageVo vo = new CollectDataItemsPageVo();
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setTaskNo(task.getTaskNo());
|
||||
vo.setTaskStatus(task.getStatus());
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
vo.setTotal(totalRows);
|
||||
vo.setTotalPages(totalPages);
|
||||
vo.setFilters(extractFiltersFromRequest(task));
|
||||
vo.setTaskType(extractTaskTypeFromRequest(task));
|
||||
|
||||
List<CollectDataItemVo> items = new ArrayList<>();
|
||||
if (rows != null) {
|
||||
for (CollectDataItemEntity row : rows) {
|
||||
items.add(toItemVo(row));
|
||||
}
|
||||
}
|
||||
vo.setItems(items);
|
||||
vo.setCount(items.size());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTask(Long taskId, Long userId) {
|
||||
FileTaskEntity task = requireTask(taskId, userId);
|
||||
collectDataItemMapper.delete(new LambdaQueryWrapper<CollectDataItemEntity>()
|
||||
.eq(CollectDataItemEntity::getTaskId, task.getId()));
|
||||
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, task.getId())
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
fileTaskMapper.deleteById(task.getId());
|
||||
}
|
||||
|
||||
public void deleteHistory(Long resultId, Long userId) {
|
||||
FileResultEntity row = fileResultMapper.selectById(resultId);
|
||||
if (row == null || !MODULE_TYPE.equals(row.getModuleType()) || !Objects.equals(userId, row.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
fileResultMapper.deleteById(resultId);
|
||||
}
|
||||
|
||||
public CollectDataCountryPreferenceVo getCountryPreference(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
CollectDataCountryPrefEntity row = collectDataCountryPrefMapper.selectById(userId);
|
||||
CollectDataCountryPreferenceVo vo = new CollectDataCountryPreferenceVo();
|
||||
if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) {
|
||||
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
return vo;
|
||||
}
|
||||
try {
|
||||
List<String> parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference<List<String>>() {
|
||||
});
|
||||
vo.getCountryCodes().addAll(sanitizeStoredCountryCodes(parsed));
|
||||
} catch (Exception ex) {
|
||||
vo.getCountryCodes().addAll(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public CollectDataCountryPreferenceVo saveCountryPreference(CollectDataCountryPreferenceSaveRequest request) {
|
||||
if (request == null || request.getUserId() == null || request.getUserId() <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
List<String> normalized = validateCountryCodesForSave(request.getCountryCodes());
|
||||
String json;
|
||||
try {
|
||||
json = objectMapper.writeValueAsString(normalized);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("保存国家偏好失败");
|
||||
}
|
||||
CollectDataCountryPrefEntity row = collectDataCountryPrefMapper.selectById(request.getUserId());
|
||||
if (row == null) {
|
||||
row = new CollectDataCountryPrefEntity();
|
||||
row.setUserId(request.getUserId());
|
||||
row.setCountryCodesJson(json);
|
||||
collectDataCountryPrefMapper.insert(row);
|
||||
} else {
|
||||
row.setCountryCodesJson(json);
|
||||
collectDataCountryPrefMapper.updateById(row);
|
||||
}
|
||||
CollectDataCountryPreferenceVo vo = new CollectDataCountryPreferenceVo();
|
||||
vo.getCountryCodes().addAll(normalized);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private static List<String> sanitizeStoredCountryCodes(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
}
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String code : raw) {
|
||||
if (code == null || code.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String upper = code.trim().toUpperCase(Locale.ROOT);
|
||||
if (!DEFAULT_COUNTRY_PREFERENCE_ORDER.contains(upper)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.add(upper)) {
|
||||
out.add(upper);
|
||||
}
|
||||
}
|
||||
return out.isEmpty() ? new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER) : out;
|
||||
}
|
||||
|
||||
private static List<String> validateCountryCodesForSave(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
}
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String code : raw) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException("country_codes 含空项");
|
||||
}
|
||||
String upper = code.trim().toUpperCase(Locale.ROOT);
|
||||
if (!DEFAULT_COUNTRY_PREFERENCE_ORDER.contains(upper)) {
|
||||
throw new BusinessException("非法国家代码: " + code);
|
||||
}
|
||||
if (!seen.add(upper)) {
|
||||
throw new BusinessException("country_codes 存在重复: " + upper);
|
||||
}
|
||||
out.add(upper);
|
||||
}
|
||||
if (out.size() > DEFAULT_COUNTRY_PREFERENCE_ORDER.size()) {
|
||||
throw new BusinessException("country_codes 最多 5 项");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private FileTaskEntity requireTask(Long taskId, Long userId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
FileTaskEntity task = fileTaskMapper.selectById(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if (userId != null && userId > 0 && !Objects.equals(userId, task.getUserId())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
return task;
|
||||
}
|
||||
|
||||
private long countTask(Long userId, String status) {
|
||||
if (userId == null || userId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.eq(FileTaskEntity::getStatus, status));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskMap(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return taskMap;
|
||||
}
|
||||
List<FileTaskEntity> tasks = fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.in(FileTaskEntity::getId, taskIds));
|
||||
if (tasks != null) {
|
||||
for (FileTaskEntity task : tasks) {
|
||||
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
}
|
||||
return taskMap;
|
||||
}
|
||||
|
||||
private CollectDataHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task) {
|
||||
CollectDataHistoryItemVo vo = new CollectDataHistoryItemVo();
|
||||
vo.setResultId(row.getId());
|
||||
vo.setTaskId(row.getTaskId());
|
||||
vo.setSourceFilename(row.getSourceFilename());
|
||||
vo.setResultFilename(row.getResultFilename());
|
||||
vo.setRowCount(row.getRowCount());
|
||||
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
|
||||
vo.setError(row.getErrorMessage());
|
||||
vo.setCreatedAt(formatTime(row.getCreatedAt()));
|
||||
if (task != null) {
|
||||
vo.setTaskNo(task.getTaskNo());
|
||||
vo.setTaskStatus(task.getStatus());
|
||||
vo.setStartedAt(formatTime(task.getCreatedAt()));
|
||||
vo.setFinishedAt(formatTime(task.getFinishedAt()));
|
||||
vo.setTaskType(extractTaskTypeFromRequest(task));
|
||||
vo.setFilters(extractFiltersFromRequest(task));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private CollectDataTaskSummaryVo toTaskSummary(FileTaskEntity task) {
|
||||
CollectDataTaskSummaryVo vo = new CollectDataTaskSummaryVo();
|
||||
vo.setId(task.getId());
|
||||
vo.setTaskNo(task.getTaskNo());
|
||||
vo.setStatus(task.getStatus());
|
||||
vo.setErrorMessage(task.getErrorMessage());
|
||||
vo.setCreatedAt(formatTime(task.getCreatedAt()));
|
||||
vo.setUpdatedAt(formatTime(task.getUpdatedAt()));
|
||||
vo.setFinishedAt(formatTime(task.getFinishedAt()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
private CollectDataItemVo toItemVo(CollectDataItemEntity row) {
|
||||
CollectDataItemVo vo = new CollectDataItemVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setRowIndex(row.getRowIndex());
|
||||
vo.setSourceFileKey(row.getSourceFileKey());
|
||||
vo.setSourceFilename(row.getSourceFilename());
|
||||
vo.setKeyword(row.getKeyword());
|
||||
vo.setStatusValue(row.getStatusValue());
|
||||
if (row.getExtraJson() != null && !row.getExtraJson().isBlank()) {
|
||||
try {
|
||||
Map<String, String> extra = objectMapper.readValue(row.getExtraJson(), new TypeReference<>() {
|
||||
});
|
||||
if (extra != null) {
|
||||
vo.getExtra().putAll(extra);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] parse extra json failed rowId={} err={}", row.getId(), ex.getMessage());
|
||||
}
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private CollectDataFiltersDto extractFiltersFromRequest(FileTaskEntity task) {
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||
return new CollectDataFiltersDto();
|
||||
}
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(task.getRequestJson());
|
||||
JsonNode filtersNode = root.get("filters");
|
||||
if (filtersNode == null || filtersNode.isNull()) {
|
||||
return new CollectDataFiltersDto();
|
||||
}
|
||||
return objectMapper.treeToValue(filtersNode, CollectDataFiltersDto.class);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] parse request filters failed taskId={} err={}", task.getId(), ex.getMessage());
|
||||
return new CollectDataFiltersDto();
|
||||
}
|
||||
}
|
||||
|
||||
private String extractTaskTypeFromRequest(FileTaskEntity task) {
|
||||
if (task == null || task.getRequestJson() == null || task.getRequestJson().isBlank()) {
|
||||
return DEFAULT_TASK_TYPE;
|
||||
}
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(task.getRequestJson());
|
||||
JsonNode taskTypeNode = root.get("taskType");
|
||||
String value = taskTypeNode == null || taskTypeNode.isNull() ? null : taskTypeNode.asText(null);
|
||||
return value == null || value.isBlank() ? DEFAULT_TASK_TYPE : value;
|
||||
} catch (Exception ex) {
|
||||
return DEFAULT_TASK_TYPE;
|
||||
}
|
||||
}
|
||||
|
||||
private String buildAggregateSourceFilenameLabel(List<CollectDataSourceFileDto> sources) {
|
||||
if (sources == null || sources.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String first = firstNonBlank(sources.get(0).getOriginalFilename(), sources.get(0).getFileKey());
|
||||
if (sources.size() == 1) {
|
||||
return first;
|
||||
}
|
||||
return first + " 等 " + sources.size() + " 个文件";
|
||||
}
|
||||
|
||||
private String buildAggregateSourceFileUrl(List<CollectDataSourceFileDto> sources) {
|
||||
if (sources == null || sources.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < sources.size(); i++) {
|
||||
if (i > 0) {
|
||||
sb.append('|');
|
||||
}
|
||||
sb.append(sources.get(i).getFileKey());
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private ParsedWorkbook parseWorkbook(File input, CollectDataSourceFileDto source) {
|
||||
DataFormatter formatter = new DataFormatter();
|
||||
try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) {
|
||||
Sheet sheet = workbook.getSheetAt(0);
|
||||
Row header = sheet.getRow(0);
|
||||
if (header == null) {
|
||||
throw new BusinessException("Excel 表头为空");
|
||||
}
|
||||
|
||||
List<String> headers = new ArrayList<>();
|
||||
for (int i = 0; i < header.getLastCellNum(); i++) {
|
||||
String value = normalize(formatter.formatCellValue(header.getCell(i)));
|
||||
headers.add(value.isBlank() ? "列" + (i + 1) : value);
|
||||
}
|
||||
|
||||
int keywordCol = findHeaderIndex(headers, KEYWORD_HEADER_ALIASES);
|
||||
int statusCol = findHeaderIndex(headers, STATUS_HEADER_ALIASES);
|
||||
|
||||
List<ParsedRow> rows = new ArrayList<>();
|
||||
int totalRows = 0;
|
||||
int droppedRows = 0;
|
||||
String filename = firstNonBlank(source.getOriginalFilename(), input.getName());
|
||||
for (int i = 1; i <= sheet.getLastRowNum(); i++) {
|
||||
Row row = sheet.getRow(i);
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
Map<String, String> extra = new LinkedHashMap<>();
|
||||
String keyword = "";
|
||||
String statusValue = "";
|
||||
boolean nonEmpty = false;
|
||||
for (int c = 0; c < headers.size(); c++) {
|
||||
Cell cell = row.getCell(c);
|
||||
String value = normalize(cell == null ? "" : formatter.formatCellValue(cell));
|
||||
if (!value.isBlank()) {
|
||||
nonEmpty = true;
|
||||
}
|
||||
if (c == keywordCol) {
|
||||
keyword = value;
|
||||
} else if (c == statusCol) {
|
||||
statusValue = value;
|
||||
} else {
|
||||
extra.put(headers.get(c), value);
|
||||
}
|
||||
}
|
||||
if (!nonEmpty) {
|
||||
continue;
|
||||
}
|
||||
totalRows++;
|
||||
if (keywordCol >= 0 && keyword.isBlank()) {
|
||||
droppedRows++;
|
||||
continue;
|
||||
}
|
||||
rows.add(new ParsedRow(source.getFileKey(), filename, keyword, statusValue, extra));
|
||||
}
|
||||
return new ParsedWorkbook(totalRows, droppedRows, rows);
|
||||
} catch (BusinessException ex) {
|
||||
throw ex;
|
||||
} catch (Exception ex) {
|
||||
log.warn("[collect-data] parse workbook failed file={} err={}", input, ex.getMessage());
|
||||
throw new BusinessException("解析 Excel 失败");
|
||||
}
|
||||
}
|
||||
|
||||
private int findHeaderIndex(List<String> headers, List<String> aliases) {
|
||||
if (headers == null || headers.isEmpty() || aliases == null || aliases.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < headers.size(); i++) {
|
||||
String lower = headers.get(i).toLowerCase(Locale.ROOT);
|
||||
for (String alias : aliases) {
|
||||
if (lower.contains(alias.toLowerCase(Locale.ROOT))) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private String formatTime(LocalDateTime time) {
|
||||
return time == null ? null : time.format(TIME_FORMATTER);
|
||||
}
|
||||
|
||||
private String firstNonBlank(String... values) {
|
||||
if (values == null) {
|
||||
return "";
|
||||
}
|
||||
for (String value : values) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = value.replace(String.valueOf((char) 0xFEFF), "")
|
||||
.replace((char) 0x3000, ' ')
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", " ")
|
||||
.trim();
|
||||
return WHITESPACE_PATTERN.matcher(normalized).replaceAll(" ");
|
||||
}
|
||||
|
||||
private record ParsedRow(String sourceFileKey,
|
||||
String sourceFilename,
|
||||
String keyword,
|
||||
String statusValue,
|
||||
Map<String, String> extra) {
|
||||
}
|
||||
|
||||
private record ParsedWorkbook(int totalRows, int droppedRows, List<ParsedRow> rows) {
|
||||
}
|
||||
|
||||
private record PersistedParse(FileTaskEntity task, FileResultEntity result) {
|
||||
}
|
||||
}
|
||||
@@ -152,13 +152,13 @@ public class DeleteBrandRunController {
|
||||
@Operation(summary = "Download one delete-brand result")
|
||||
public void downloadResult(
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "褰撳墠鐧诲綍鐢ㄦ埛 ID", required = true, in = ParameterIn.QUERY)
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID", required = true, in = ParameterIn.QUERY)
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = deleteBrandRunService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = deleteBrandRunService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "缁撴灉鏃犲彲涓嬭浇");
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "结果无可下载");
|
||||
}
|
||||
if (filename == null || filename.isBlank()) {
|
||||
String rawPath = URI.create(url).getPath();
|
||||
@@ -177,7 +177,7 @@ public class DeleteBrandRunController {
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "涓嬭浇澶辫触");
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -628,7 +628,7 @@ public class DeleteBrandStaleTaskService {
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE_QUERY_ASIN)
|
||||
.eq(FileTaskEntity::getStatus, "RUNNING")
|
||||
.set(FileTaskEntity::getStatus, "FAILED")
|
||||
.set(FileTaskEntity::getErrorMessage, "闀挎椂闂存湭鏀跺埌 Python 缁撴灉鍥炰紶锛屼换鍔″凡鑷姩澶辫触")
|
||||
.set(FileTaskEntity::getErrorMessage, "长时间未收到 Python 结果回传,任务已自动失败")
|
||||
.set(FileTaskEntity::getUpdatedAt, LocalDateTime.now())
|
||||
.set(FileTaskEntity::getFinishedAt, LocalDateTime.now()));
|
||||
if (updated > 0) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataCreateRequest;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataUpdateRequest;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataItemVo;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
|
||||
import com.nanri.aiimage.modules.invalidasin.service.InvalidAsinDataService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
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.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/admin/invalid-asin-data")
|
||||
@Tag(name = "不符合ASIN数据", description = "维护不符合ASIN数据列表,支持增删改查。")
|
||||
public class InvalidAsinDataController {
|
||||
|
||||
private final InvalidAsinDataService invalidAsinDataService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "分页查询不符合ASIN数据", description = "分页查询不符合ASIN数据,支持按 ASIN/品牌 模糊搜索。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataPageVo.class)))
|
||||
})
|
||||
public ApiResponse<InvalidAsinDataPageVo> 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(invalidAsinDataService.page(page, pageSize, keyword));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "新增不符合ASIN数据", description = "新增一条不符合ASIN数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "创建成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复")
|
||||
})
|
||||
public ApiResponse<InvalidAsinDataItemVo> create(@Valid @RequestBody InvalidAsinDataCreateRequest request) {
|
||||
return ApiResponse.success("创建成功", invalidAsinDataService.create(request));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
@Operation(summary = "更新不符合ASIN数据", description = "按 ID 更新一条不符合ASIN数据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "更新成功", content = @Content(schema = @Schema(implementation = InvalidAsinDataItemVo.class))),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "参数不合法或数据重复"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "数据不存在")
|
||||
})
|
||||
public ApiResponse<InvalidAsinDataItemVo> update(
|
||||
@Parameter(description = "主键ID", required = true) @PathVariable Long id,
|
||||
@Valid @RequestBody InvalidAsinDataUpdateRequest request) {
|
||||
return ApiResponse.success("更新成功", invalidAsinDataService.update(id, request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Operation(summary = "删除不符合ASIN数据", description = "按 ID 删除一条不符合ASIN数据。")
|
||||
@ApiResponses({
|
||||
@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) {
|
||||
invalidAsinDataService.delete(id);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface InvalidAsinDataMapper extends BaseMapper<InvalidAsinDataEntity> {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "不符合ASIN数据新增请求")
|
||||
public class InvalidAsinDataCreateRequest {
|
||||
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
@Size(max = 128, message = "ASIN 长度不能超过128个字符")
|
||||
@Schema(description = "ASIN 值", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String dataValue;
|
||||
|
||||
@Size(max = 128, message = "品牌长度不能超过128个字符")
|
||||
@Schema(description = "品牌名称")
|
||||
private String brand;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "不符合ASIN数据更新请求")
|
||||
public class InvalidAsinDataUpdateRequest {
|
||||
|
||||
@NotBlank(message = "ASIN 不能为空")
|
||||
@Size(max = 128, message = "ASIN 长度不能超过128个字符")
|
||||
@Schema(description = "ASIN 值", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String dataValue;
|
||||
|
||||
@Size(max = 128, message = "品牌长度不能超过128个字符")
|
||||
@Schema(description = "品牌名称")
|
||||
private String brand;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.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_invalid_asin_data")
|
||||
public class InvalidAsinDataEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String dataValue;
|
||||
private String brand;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@Schema(description = "不符合ASIN数据项")
|
||||
public class InvalidAsinDataItemVo {
|
||||
|
||||
@Schema(description = "主键ID")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "ASIN 值")
|
||||
private String dataValue;
|
||||
|
||||
@Schema(description = "品牌名称")
|
||||
private String brand;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "不符合ASIN数据分页结果")
|
||||
public class InvalidAsinDataPageVo {
|
||||
|
||||
@Schema(description = "列表")
|
||||
private List<InvalidAsinDataItemVo> items;
|
||||
|
||||
@Schema(description = "总数")
|
||||
private Long total;
|
||||
|
||||
@Schema(description = "页码")
|
||||
private Long page;
|
||||
|
||||
@Schema(description = "每页数量")
|
||||
private Long pageSize;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.nanri.aiimage.modules.invalidasin.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.invalidasin.mapper.InvalidAsinDataMapper;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataCreateRequest;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.dto.InvalidAsinDataUpdateRequest;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.entity.InvalidAsinDataEntity;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataItemVo;
|
||||
import com.nanri.aiimage.modules.invalidasin.model.vo.InvalidAsinDataPageVo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvalidAsinDataService {
|
||||
|
||||
private final InvalidAsinDataMapper invalidAsinDataMapper;
|
||||
|
||||
public InvalidAsinDataPageVo page(long page, long pageSize, String keyword) {
|
||||
long safePage = Math.max(page, 1);
|
||||
long safePageSize = Math.min(Math.max(pageSize, 1), 100);
|
||||
String safeKeyword = keyword == null ? "" : keyword.trim();
|
||||
LambdaQueryWrapper<InvalidAsinDataEntity> query = new LambdaQueryWrapper<InvalidAsinDataEntity>()
|
||||
.and(!safeKeyword.isEmpty(), wrapper -> wrapper
|
||||
.like(InvalidAsinDataEntity::getDataValue, safeKeyword)
|
||||
.or()
|
||||
.like(InvalidAsinDataEntity::getBrand, safeKeyword))
|
||||
.orderByDesc(InvalidAsinDataEntity::getId);
|
||||
Long total = invalidAsinDataMapper.selectCount(query);
|
||||
List<InvalidAsinDataItemVo> items = invalidAsinDataMapper
|
||||
.selectList(query.last("LIMIT " + ((safePage - 1) * safePageSize) + ", " + safePageSize))
|
||||
.stream()
|
||||
.map(this::toItemVo)
|
||||
.toList();
|
||||
InvalidAsinDataPageVo vo = new InvalidAsinDataPageVo();
|
||||
vo.setItems(items);
|
||||
vo.setTotal(total);
|
||||
vo.setPage(safePage);
|
||||
vo.setPageSize(safePageSize);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InvalidAsinDataItemVo create(InvalidAsinDataCreateRequest request) {
|
||||
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
||||
String brand = normalizeOptional(request.getBrand());
|
||||
ensureUnique(dataValue, null);
|
||||
InvalidAsinDataEntity entity = new InvalidAsinDataEntity();
|
||||
entity.setDataValue(dataValue);
|
||||
entity.setBrand(brand);
|
||||
invalidAsinDataMapper.insert(entity);
|
||||
return toItemVo(getById(entity.getId()));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InvalidAsinDataItemVo update(Long id, InvalidAsinDataUpdateRequest request) {
|
||||
InvalidAsinDataEntity entity = getById(id);
|
||||
String dataValue = normalizeRequired(request.getDataValue(), "ASIN 不能为空");
|
||||
String brand = normalizeOptional(request.getBrand());
|
||||
ensureUnique(dataValue, id);
|
||||
entity.setDataValue(dataValue);
|
||||
entity.setBrand(brand);
|
||||
invalidAsinDataMapper.updateById(entity);
|
||||
return toItemVo(getById(id));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void delete(Long id) {
|
||||
InvalidAsinDataEntity entity = getById(id);
|
||||
invalidAsinDataMapper.deleteById(entity.getId());
|
||||
}
|
||||
|
||||
private InvalidAsinDataEntity getById(Long id) {
|
||||
InvalidAsinDataEntity entity = invalidAsinDataMapper.selectById(id);
|
||||
if (entity == null) {
|
||||
throw new BusinessException("数据不存在");
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void ensureUnique(String dataValue, Long excludeId) {
|
||||
LambdaQueryWrapper<InvalidAsinDataEntity> query = new LambdaQueryWrapper<InvalidAsinDataEntity>()
|
||||
.eq(InvalidAsinDataEntity::getDataValue, dataValue)
|
||||
.last("LIMIT 1");
|
||||
InvalidAsinDataEntity exists = invalidAsinDataMapper.selectOne(query);
|
||||
if (exists != null && (excludeId == null || !exists.getId().equals(excludeId))) {
|
||||
throw new BusinessException("ASIN 已存在");
|
||||
}
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String message) {
|
||||
String normalized = normalizeText(value);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeOptional(String value) {
|
||||
String normalized = normalizeText(value);
|
||||
return normalized.isEmpty() ? null : normalized;
|
||||
}
|
||||
|
||||
private String normalizeText(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.replace("", "")
|
||||
.replace(" ", " ")
|
||||
.replace("\r\n", " ")
|
||||
.replace("\r", " ")
|
||||
.replace("\n", " ")
|
||||
.replace("\t", " ")
|
||||
.trim()
|
||||
.replaceAll("\\s+", " ");
|
||||
}
|
||||
|
||||
private InvalidAsinDataItemVo toItemVo(InvalidAsinDataEntity entity) {
|
||||
InvalidAsinDataItemVo vo = new InvalidAsinDataItemVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setDataValue(entity.getDataValue());
|
||||
vo.setBrand(entity.getBrand());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
@@ -1044,7 +1044,7 @@ public class PatrolDeleteTaskService {
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||
if (lockHandle == null) {
|
||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
||||
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||
}
|
||||
return lockHandle;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
new DefaultAdminMenu("用户管理", "admin_users", "users", 10),
|
||||
new DefaultAdminMenu("栏目权限配置", "admin_columns", "columns", 20),
|
||||
new DefaultAdminMenu("数据去重总数据", "admin_dedupe_total_data", "dedupe-total-data", 30),
|
||||
new DefaultAdminMenu("不符合ASIN数据", "admin_invalid_asin_data", "invalid-asin-data", 35),
|
||||
new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40),
|
||||
new DefaultAdminMenu("店铺管理", "admin_shop_manage", "shop-manage", 50),
|
||||
new DefaultAdminMenu("跳过跟价ASIN", "admin_skip_price_asin", "skip-price-asin", 60),
|
||||
|
||||
@@ -1391,7 +1391,7 @@ public class PriceTrackTaskService {
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||
if (lockHandle == null) {
|
||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
||||
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||
}
|
||||
return lockHandle;
|
||||
}
|
||||
|
||||
@@ -970,7 +970,7 @@ public class ProductRiskTaskService {
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||
if (lockHandle == null) {
|
||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
||||
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||
}
|
||||
return lockHandle;
|
||||
}
|
||||
|
||||
@@ -1003,7 +1003,7 @@ public class QueryAsinTaskService {
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||
if (lockHandle == null) {
|
||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
||||
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||
}
|
||||
return lockHandle;
|
||||
}
|
||||
|
||||
@@ -33,15 +33,8 @@ import java.time.format.DateTimeFormatter;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping({
|
||||
"/admin/skip-price-asins",
|
||||
"/admin/skip-price-asin",
|
||||
"/api/admin/skip-price-asins",
|
||||
"/api/admin/skip-price-asin",
|
||||
"/skip-price-asins",
|
||||
"/skip-price-asin"
|
||||
})
|
||||
@Tag(name = "Skip Price ASIN", description = "Manage skip price asin data")
|
||||
@RequestMapping("/api/admin/skip-price-asins")
|
||||
@Tag(name = "跳过跟价 ASIN", description = "管理跳过跟价 ASIN 数据,包括分页、导出、导入、按国家更新和删除。")
|
||||
public class SkipPriceAsinController {
|
||||
|
||||
private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
|
||||
@@ -49,15 +42,15 @@ public class SkipPriceAsinController {
|
||||
private final SkipPriceAsinService skipPriceAsinService;
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Page query skip price asins")
|
||||
@Operation(summary = "分页查询跳过跟价 ASIN", description = "支持按分组、店铺名、ASIN 和当前操作人可见范围筛选。")
|
||||
public ApiResponse<SkipPriceAsinPageVo> page(
|
||||
@Parameter(description = "page") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||
@Parameter(description = "page size") @RequestParam(name = "page_size", defaultValue = "15") Long pageSize,
|
||||
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "页码") @RequestParam(name = "page", defaultValue = "1") Long page,
|
||||
@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 = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success(skipPriceAsinService.page(
|
||||
page,
|
||||
pageSize,
|
||||
@@ -69,13 +62,13 @@ public class SkipPriceAsinController {
|
||||
}
|
||||
|
||||
@GetMapping("/export")
|
||||
@Operation(summary = "Export skip price asins")
|
||||
@Operation(summary = "导出跳过跟价 ASIN", description = "按当前查询条件导出 Excel。")
|
||||
public ResponseEntity<byte[]> export(
|
||||
@Parameter(description = "group id") @RequestParam(name = "group_id", required = false) Long groupId,
|
||||
@Parameter(description = "shop name") @RequestParam(name = "shop_name", required = false) String shopName,
|
||||
@Parameter(description = "asin") @RequestParam(name = "asin", required = false) String asin,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id", required = false) Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@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 = "当前操作人用户 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));
|
||||
String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx";
|
||||
return ResponseEntity.ok()
|
||||
@@ -86,58 +79,60 @@ public class SkipPriceAsinController {
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "Create or update skip price asin")
|
||||
@Operation(summary = "新增或更新跳过跟价 ASIN", description = "按店铺和国家维度新增或更新跳过跟价 ASIN。")
|
||||
public ApiResponse<SkipPriceAsinItemVo> create(
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody SkipPriceAsinCreateRequest request) {
|
||||
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f",
|
||||
return ApiResponse.success("保存成功",
|
||||
skipPriceAsinService.createOrUpdate(request, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
@Operation(summary = "Import skip price asin")
|
||||
@Operation(summary = "导入跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步导入新增数据。")
|
||||
public ApiResponse<QueryAsinImportStartVo> importExcel(
|
||||
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("\u5f00\u59cb\u5bfc\u5165",
|
||||
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始导入",
|
||||
skipPriceAsinService.startImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/import/{importId}")
|
||||
@Operation(summary = "Query import progress")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(@PathVariable String importId) {
|
||||
@Operation(summary = "查询导入进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> importProgress(
|
||||
@Parameter(description = "导入任务 ID", required = true) @PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getImportProgress(importId));
|
||||
}
|
||||
|
||||
@PostMapping("/delete-import")
|
||||
@Operation(summary = "Import delete skip price asin")
|
||||
@Operation(summary = "导入删除跳过跟价 ASIN", description = "上传 xlsx 或 xls 文件,异步按文件内容删除数据。")
|
||||
public ApiResponse<QueryAsinImportStartVo> deleteImportExcel(
|
||||
@Parameter(description = "xlsx or xls file", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "group id", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("\u5f00\u59cb\u5220\u9664",
|
||||
@Parameter(description = "xlsx 或 xls 文件", required = true) @RequestParam("file") MultipartFile file,
|
||||
@Parameter(description = "分组 ID", required = true) @RequestParam(name = "group_id") Long groupId,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
return ApiResponse.success("开始删除",
|
||||
skipPriceAsinService.startDeleteImport(file, groupId, operatorId, Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@GetMapping("/delete-import/{importId}")
|
||||
@Operation(summary = "Query delete import progress")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(@PathVariable String importId) {
|
||||
@Operation(summary = "查询导入删除进度")
|
||||
public ApiResponse<QueryAsinImportProgressVo> deleteImportProgress(
|
||||
@Parameter(description = "导入删除任务 ID", required = true) @PathVariable String importId) {
|
||||
return ApiResponse.success(skipPriceAsinService.getDeleteImportProgress(importId));
|
||||
}
|
||||
|
||||
@PutMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
|
||||
@Operation(summary = "Update one country skip price asin")
|
||||
@PutMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "更新指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<SkipPriceAsinItemVo> updateCountry(
|
||||
@Parameter(description = "id", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "country code", required = true) @PathVariable String country,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin,
|
||||
@Valid @RequestBody SkipPriceAsinCountryUpdateRequest request) {
|
||||
return ApiResponse.success("\u4fdd\u5b58\u6210\u529f", skipPriceAsinService.updateCountry(
|
||||
return ApiResponse.success("保存成功", skipPriceAsinService.updateCountry(
|
||||
id,
|
||||
country,
|
||||
request.getAsin(),
|
||||
@@ -146,14 +141,14 @@ public class SkipPriceAsinController {
|
||||
Boolean.TRUE.equals(superAdmin)));
|
||||
}
|
||||
|
||||
@DeleteMapping({"/{id}/countries/{country}", "/{id}/country/{country}"})
|
||||
@Operation(summary = "Delete one country skip price asin")
|
||||
@DeleteMapping("/{id}/countries/{country}")
|
||||
@Operation(summary = "删除指定国家的跳过跟价 ASIN")
|
||||
public ApiResponse<Void> deleteCountry(
|
||||
@Parameter(description = "id", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "country code", required = true) @PathVariable String country,
|
||||
@Parameter(description = "operator user id") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "is super admin") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
@Parameter(description = "主键 ID", required = true) @PathVariable Long id,
|
||||
@Parameter(description = "国家代码", required = true) @PathVariable String country,
|
||||
@Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id") Long operatorId,
|
||||
@Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) {
|
||||
skipPriceAsinService.deleteCountry(id, country, operatorId, Boolean.TRUE.equals(superAdmin));
|
||||
return ApiResponse.success("\u5220\u9664\u6210\u529f", null);
|
||||
return ApiResponse.success("删除成功", null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,7 @@ public class SkipPriceAsinService {
|
||||
case "FR" -> query.eq(SkipPriceAsinEntity::getAsinFr, asin);
|
||||
case "IT" -> query.eq(SkipPriceAsinEntity::getAsinIt, asin);
|
||||
case "ES" -> query.eq(SkipPriceAsinEntity::getAsinEs, asin);
|
||||
default -> throw new BusinessException("鍥藉鍙傛暟涓嶆敮鎸? " + country);
|
||||
default -> throw new BusinessException("国家参数不支持: " + country);
|
||||
}
|
||||
return skipPriceAsinMapper.selectOne(query.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
@@ -877,7 +877,7 @@ public class ShopMatchTaskService {
|
||||
private TaskDistributedLockService.LockHandle acquireTaskLockOrThrow(Long taskId) {
|
||||
TaskDistributedLockService.LockHandle lockHandle = acquireTaskLock(taskId);
|
||||
if (lockHandle == null) {
|
||||
throw new BusinessException(40901, "浠诲姟姝e湪澶勭悊涓紝璇风◢鍚庡啀璇?");
|
||||
throw new BusinessException(40901, "任务正在处理中,请稍后再试");
|
||||
}
|
||||
return lockHandle;
|
||||
}
|
||||
|
||||
@@ -4811,7 +4811,7 @@ public class SimilarAsinTaskService {
|
||||
vo.setTaskId(row.getTaskId());
|
||||
vo.setSourceFilename(row.getSourceFilename());
|
||||
vo.setResultFilename(row.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
vo.setDownloadUrl(buildFreshDownloadUrl(row));
|
||||
attachFileJobState(vo, row, job);
|
||||
vo.setTaskStatus(task == null ? null : task.getStatus());
|
||||
vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1);
|
||||
@@ -4824,6 +4824,20 @@ public class SimilarAsinTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
private String buildFreshDownloadUrl(FileResultEntity row) {
|
||||
String resultFileUrl = row == null ? null : row.getResultFileUrl();
|
||||
if (resultFileUrl == null || resultFileUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ossStorageService.generateFreshDownloadUrl(resultFileUrl);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[similar-asin] generate fresh download url failed resultId={} err={}",
|
||||
row.getId(), ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void attachFileJobState(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) {
|
||||
vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank());
|
||||
if (job == null) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_invalid_asin_data (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
data_value VARCHAR(128) NOT NULL COMMENT '不符合 ASIN 值',
|
||||
brand VARCHAR(128) DEFAULT NULL COMMENT '品牌名称',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
UNIQUE KEY uk_data_value (data_value),
|
||||
KEY idx_brand (brand),
|
||||
KEY idx_created_at (created_at)
|
||||
) COMMENT='不符合ASIN数据表';
|
||||
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||
SELECT '不符合ASIN数据', 'admin_invalid_asin_data', 'admin', 'invalid-asin-data', 35
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `columns` WHERE `column_key` = 'admin_invalid_asin_data'
|
||||
);
|
||||
|
||||
UPDATE `columns`
|
||||
SET `name` = '不符合ASIN数据',
|
||||
`menu_type` = 'admin',
|
||||
`route_path` = 'invalid-asin-data',
|
||||
`sort_order` = 35
|
||||
WHERE `column_key` = 'admin_invalid_asin_data';
|
||||
30
backend-java/src/main/resources/db/V55__collect_data.sql
Normal file
30
backend-java/src/main/resources/db/V55__collect_data.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- 采集数据模块:复用通用任务表 biz_file_task / biz_file_result(module_type = COLLECT_DATA)。
|
||||
-- 解析后的明细行入库到 biz_collect_data_item,便于 Python 端按页(50/页)拉取。
|
||||
-- 筛选条件(金额、排名、FBA、FBM、国家列表)以 JSON 形式存放在 biz_file_task.request_json 中。
|
||||
CREATE TABLE IF NOT EXISTS biz_collect_data_item (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
|
||||
task_id BIGINT NOT NULL COMMENT '所属任务 ID(biz_file_task.id)',
|
||||
row_index INT NOT NULL COMMENT '行号,从 1 开始,按解析顺序递增',
|
||||
source_file_key VARCHAR(255) DEFAULT NULL COMMENT '来源文件 key',
|
||||
source_filename VARCHAR(255) DEFAULT NULL COMMENT '来源文件名',
|
||||
keyword VARCHAR(512) DEFAULT NULL COMMENT '关键词列内容',
|
||||
status_value VARCHAR(128) DEFAULT NULL COMMENT '状态列内容',
|
||||
extra_json TEXT DEFAULT NULL COMMENT '其它列内容 JSON',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
UNIQUE KEY uk_task_row (task_id, row_index),
|
||||
KEY idx_task_id (task_id)
|
||||
) COMMENT='采集数据解析明细';
|
||||
|
||||
-- 前端栏目权限兜底:采集数据属于前端工具下的页面。
|
||||
INSERT INTO `columns` (`name`, `column_key`, `menu_type`, `route_path`, `sort_order`)
|
||||
SELECT '采集数据', 'collect_data', 'app', 'collect-data', 105
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `columns` WHERE `column_key` = 'collect_data'
|
||||
);
|
||||
|
||||
UPDATE `columns`
|
||||
SET `name` = '采集数据',
|
||||
`menu_type` = 'app',
|
||||
`route_path` = 'collect-data',
|
||||
`sort_order` = 105
|
||||
WHERE `column_key` = 'collect_data';
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS biz_collect_data_country_pref (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
|
||||
country_codes_json VARCHAR(256) NOT NULL COMMENT 'JSON 数组:五国代码按处理顺序保存,如 ["DE","UK","FR"]',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
|
||||
) COMMENT='采集数据用户五国处理顺序与勾选配置';
|
||||
Reference in New Issue
Block a user