处理后台管理系统、修复BUG、处理权限
This commit is contained in:
+371
@@ -0,0 +1,371 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteHistoryVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteResolveService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/patrol-delete")
|
||||
@Tag(
|
||||
name = "巡店删除",
|
||||
description = "巡店删除模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。涉及查询和删除的接口需要携带 user_id。")
|
||||
public class PatrolDeleteController {
|
||||
|
||||
private final PatrolDeleteResolveService patrolDeleteResolveService;
|
||||
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
||||
|
||||
@GetMapping("/candidates")
|
||||
@Operation(summary = "查询备选店铺列表", description = "返回当前用户在巡店删除模块中已保存的备选店铺。")
|
||||
public ApiResponse<List<ProductRiskCandidateVo>> listCandidates(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(patrolDeleteResolveService.listCandidates(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/candidates")
|
||||
@Operation(summary = "新增备选店铺", description = "向巡店删除备选区新增一条店铺记录,供后续匹配和创建任务使用。")
|
||||
public ApiResponse<ProductRiskCandidateVo> addCandidate(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "新增备选店铺请求,需传入用户 ID 和店铺名。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = ProductRiskCandidateAddRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "新增店铺示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"shop_name": "郭亚芳"
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody ProductRiskCandidateAddRequest request) {
|
||||
return ApiResponse.success(patrolDeleteResolveService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除备选店铺", description = "删除当前用户名下的一条备选店铺记录。")
|
||||
public ApiResponse<Void> deleteCandidate(
|
||||
@Parameter(description = "备选店铺记录主键", example = "10")
|
||||
@PathVariable Long id,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
patrolDeleteResolveService.deleteCandidate(userId, id);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/match-shops")
|
||||
@Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。")
|
||||
public ApiResponse<ProductRiskMatchShopsVo> matchShops(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = ProductRiskMatchShopsRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "匹配店铺示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"shop_names": [
|
||||
"郭亚芳",
|
||||
"示例店铺A"
|
||||
]
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody ProductRiskMatchShopsRequest request) {
|
||||
return ApiResponse.success(patrolDeleteResolveService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "查询统计看板", description = "返回备选店铺数、已处理任务数、成功任务数和失败任务数。")
|
||||
public ApiResponse<ProductRiskDashboardVo> dashboard(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询任务记录", description = "返回当前用户在巡店删除模块中的当前任务和历史任务记录。")
|
||||
public ApiResponse<PatrolDeleteHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.listHistory(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询巡店删除任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。")
|
||||
public ApiResponse<PatrolDeleteTaskBatchVo> taskProgressBatch(@Valid @RequestBody PatrolDeleteTaskBatchRequest request) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.getTaskProgressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建巡店删除任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。")
|
||||
public ApiResponse<PatrolDeleteCreateTaskVo> createTask(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "创建任务请求。items 中每一项代表一个待处理店铺,需携带默认模板结构。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = PatrolDeleteCreateTaskRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "创建任务示例",
|
||||
value = """
|
||||
{
|
||||
"user_id": 1,
|
||||
"items": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"matched": true,
|
||||
"shopId": "27730548558377",
|
||||
"platform": "亚马逊",
|
||||
"companyName": "示例公司",
|
||||
"matchStatus": "MATCHED",
|
||||
"matchMessage": "索引已命中",
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "全部",
|
||||
"quantity": "",
|
||||
"deleteQuantity": "",
|
||||
"processStatus": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "德国",
|
||||
"ratio": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""")))
|
||||
@Valid @RequestBody PatrolDeleteCreateTaskRequest request) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(
|
||||
summary = "提交巡店删除结果",
|
||||
description = "供 Python 端回传处理结果。支持按店铺分片多次提交,服务端会合并分片、按店铺完成状态自动收尾,并在任务结束后生成 Excel。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(description = "任务主键,必须是运行中的任务", example = "3089")
|
||||
@PathVariable Long taskId,
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "巡店删除结果回传请求。shops 表示本次提交的店铺结果分片列表。shopDone=true 表示该店铺已全部提交完成。",
|
||||
required = true,
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = PatrolDeleteSubmitResultRequest.class),
|
||||
examples = {
|
||||
@ExampleObject(
|
||||
name = "成功分片示例",
|
||||
summary = "一个店铺分片回传,包含状态数据和购物车比例数据",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"submissionId": "patrol-delete:3089:郭亚芳:1711111111111",
|
||||
"chunkIndex": 1,
|
||||
"chunkTotal": 2,
|
||||
"shopDone": false,
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "德国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "12",
|
||||
"deleteQuantity": "3",
|
||||
"processStatus": "处理中"
|
||||
},
|
||||
{
|
||||
"status": "下架",
|
||||
"quantity": "2",
|
||||
"deleteQuantity": "2",
|
||||
"processStatus": "已完成"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"country": "英国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "5",
|
||||
"deleteQuantity": "1",
|
||||
"processStatus": "处理中"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "德国",
|
||||
"ratio": "25%"
|
||||
},
|
||||
{
|
||||
"country": "英国",
|
||||
"ratio": "18%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "店铺完成示例",
|
||||
summary = "最后一片提交完成后,将 shopDone 置为 true",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"submissionId": "patrol-delete:3089:郭亚芳:1711111111111",
|
||||
"chunkIndex": 2,
|
||||
"chunkTotal": 2,
|
||||
"shopDone": true,
|
||||
"countrySections": [
|
||||
{
|
||||
"country": "法国",
|
||||
"rows": [
|
||||
{
|
||||
"status": "正常",
|
||||
"quantity": "4",
|
||||
"deleteQuantity": "0",
|
||||
"processStatus": "已完成"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"cartRatios": [
|
||||
{
|
||||
"country": "法国",
|
||||
"ratio": "12%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""),
|
||||
@ExampleObject(
|
||||
name = "失败示例",
|
||||
summary = "店铺处理失败时直接回传错误信息",
|
||||
value = """
|
||||
{
|
||||
"shops": [
|
||||
{
|
||||
"shopName": "郭亚芳",
|
||||
"shopDone": true,
|
||||
"error": "紫鸟页面加载超时,未能完成删除数据采集"
|
||||
}
|
||||
]
|
||||
}
|
||||
""")
|
||||
}))
|
||||
@Valid @RequestBody PatrolDeleteSubmitResultRequest request,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
patrolDeleteTaskService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的巡店删除 Excel 文件。")
|
||||
public void downloadResult(
|
||||
@Parameter(description = "结果记录主键", example = "4599")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
String url = patrolDeleteTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = patrolDeleteTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20");
|
||||
response.setContentType("application/octet-stream");
|
||||
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + filename + "\"; filename*=UTF-8''" + encodedFilename);
|
||||
try (InputStream in = URI.create(url).toURL().openStream()) {
|
||||
byte[] buffer = new byte[65536];
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
response.getOutputStream().write(buffer, 0, read);
|
||||
}
|
||||
response.getOutputStream().flush();
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "下载失败");
|
||||
}
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除整条巡店删除任务以及该任务下关联的所有结果记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
@Parameter(description = "任务主键", example = "3089")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
patrolDeleteTaskService.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除单条历史记录", description = "删除一条巡店删除结果记录,并同步重算其所属任务状态。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果记录主键", example = "4599")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
patrolDeleteTaskService.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteShopCandidateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PatrolDeleteShopCandidateMapper extends BaseMapper<PatrolDeleteShopCandidateEntity> {
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "购物车比例数据")
|
||||
public class PatrolDeleteCartRatioDto {
|
||||
|
||||
@Schema(description = "国家名称", example = "德国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "购物车比例", example = "25%")
|
||||
private String ratio;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "国家状态数据中的单行记录")
|
||||
public class PatrolDeleteCountryMetricRowDto {
|
||||
|
||||
@Schema(description = "商品状态", example = "正常")
|
||||
private String status;
|
||||
|
||||
@Schema(description = "该状态下的商品数量", example = "12")
|
||||
private String quantity;
|
||||
|
||||
@JsonProperty("deleteQuantity")
|
||||
@Schema(description = "已删除数量", example = "3")
|
||||
private String deleteQuantity;
|
||||
|
||||
@JsonProperty("processStatus")
|
||||
@Schema(description = "处理结果或处理状态", example = "处理中")
|
||||
private String processStatus;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "单个国家的状态数据分组")
|
||||
public class PatrolDeleteCountrySectionDto {
|
||||
|
||||
@Schema(description = "国家名称", example = "德国")
|
||||
private String country;
|
||||
|
||||
@Schema(description = "该国家下的多行状态数据")
|
||||
private List<PatrolDeleteCountryMetricRowDto> rows = new ArrayList<>();
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.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.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建巡店删除任务请求")
|
||||
public class PatrolDeleteCreateTaskRequest {
|
||||
|
||||
@NotNull
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long userId;
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "待创建的店铺任务列表,通常由已匹配成功的店铺组成", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<PatrolDeleteTaskItemDto> items = new ArrayList<>();
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
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 = "单个店铺的巡店删除结果分片")
|
||||
public class PatrolDeleteShopPayloadDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称,服务端按店铺名称聚合分片", example = "郭亚芳")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成删除数据采集")
|
||||
private String error;
|
||||
|
||||
@JsonProperty("countrySections")
|
||||
@Schema(description = "各国家的状态数据列表。同一国家可分多片提交,服务端会按顺序合并")
|
||||
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
|
||||
|
||||
@JsonProperty("cartRatios")
|
||||
@Schema(description = "购物车比例数据列表,通常只在店铺首行展示")
|
||||
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
|
||||
|
||||
@JsonProperty("shopDone")
|
||||
@Schema(description = "该店铺是否已全部提交完成。最后一片应传 true", example = "true")
|
||||
private Boolean shopDone;
|
||||
|
||||
@JsonProperty("submissionId")
|
||||
@Schema(description = "本次店铺提交批次标识,便于问题排查与日志跟踪", example = "patrol-delete:3089:郭亚芳:1711111111111")
|
||||
private String submissionId;
|
||||
|
||||
@JsonProperty("chunkIndex")
|
||||
@Schema(description = "当前分片序号,建议从 1 开始", example = "1")
|
||||
private Integer chunkIndex;
|
||||
|
||||
@JsonProperty("chunkTotal")
|
||||
@Schema(description = "当前店铺总分片数", example = "2")
|
||||
private Integer chunkTotal;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "巡店删除结果回传请求")
|
||||
public class PatrolDeleteSubmitResultRequest {
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<PatrolDeleteShopPayloadDto> shops = new ArrayList<>();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PatrolDeleteTaskBatchRequest {
|
||||
|
||||
@NotEmpty(message = "taskIds 不能为空")
|
||||
private List<Long> taskIds = new ArrayList<>();
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "巡店删除任务中的单个店铺项")
|
||||
public class PatrolDeleteTaskItemDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称", example = "郭亚芳")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "该店铺是否已完成紫鸟匹配,创建任务时必须为 true", example = "true")
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
@Schema(description = "匹配到的紫鸟店铺 ID", example = "27730548558377")
|
||||
private String shopId;
|
||||
|
||||
@Schema(description = "店铺所属平台", example = "亚马逊")
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
@Schema(description = "店铺所属公司名称", example = "示例公司")
|
||||
private String companyName;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
@Schema(description = "匹配状态,如 MATCHED、PENDING、CONFLICT、INDEX_STALE", example = "MATCHED")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
@Schema(description = "匹配状态说明", example = "索引已命中")
|
||||
private String matchMessage;
|
||||
|
||||
@Valid
|
||||
@JsonProperty("countrySections")
|
||||
@Schema(description = "默认模板中的国家状态数据结构")
|
||||
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
|
||||
|
||||
@Valid
|
||||
@JsonProperty("cartRatios")
|
||||
@Schema(description = "默认模板中的购物车比例结构")
|
||||
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.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_patrol_delete_shop_candidate")
|
||||
public class PatrolDeleteShopCandidateEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String shopName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PatrolDeleteCreateTaskVo {
|
||||
|
||||
private Long taskId;
|
||||
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PatrolDeleteHistoryVo {
|
||||
|
||||
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PatrolDeleteResultItemVo {
|
||||
|
||||
private Long resultId;
|
||||
private Long taskId;
|
||||
|
||||
@JsonProperty("shopName")
|
||||
private String shopName;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
private String shopId;
|
||||
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
private String companyName;
|
||||
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
private String matchMessage;
|
||||
|
||||
@JsonProperty("taskStatus")
|
||||
private String taskStatus;
|
||||
|
||||
private Boolean success;
|
||||
private String error;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime finishedAt;
|
||||
private String outputFilename;
|
||||
private String downloadUrl;
|
||||
|
||||
@JsonProperty("countrySections")
|
||||
private List<PatrolDeleteCountrySectionDto> countrySections = new ArrayList<>();
|
||||
|
||||
@JsonProperty("cartRatios")
|
||||
private List<PatrolDeleteCartRatioDto> cartRatios = new ArrayList<>();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.model.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class PatrolDeleteTaskBatchVo {
|
||||
|
||||
private List<PatrolDeleteResultItemVo> items = new ArrayList<>();
|
||||
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountryMetricRowDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteResultItemVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PatrolDeleteExcelAssemblyService {
|
||||
|
||||
private static final String COUNTRY_DE = "德国";
|
||||
private static final String COUNTRY_UK = "英国";
|
||||
private static final String COUNTRY_FR = "法国";
|
||||
private static final String COUNTRY_IT = "意大利";
|
||||
private static final String COUNTRY_ES = "西班牙";
|
||||
|
||||
private static final String[] HEADER = {
|
||||
"店铺名",
|
||||
COUNTRY_DE, "数量", "删除数量", "删除结果",
|
||||
COUNTRY_UK, "数量", "删除数量", "删除结果",
|
||||
COUNTRY_FR, "数量", "删除数量", "删除结果",
|
||||
COUNTRY_IT, "数量", "删除数量", "删除结果",
|
||||
COUNTRY_ES, "数量", "删除数量", "删除结果",
|
||||
"国家", "购物车比例",
|
||||
"国家", "购物车比例",
|
||||
"国家", "购物车比例",
|
||||
"国家", "购物车比例",
|
||||
"国家", "购物车比例"
|
||||
};
|
||||
|
||||
public void writeWorkbook(File outputXlsx, List<PatrolDeleteResultItemVo> items) {
|
||||
try (XSSFWorkbook workbook = new XSSFWorkbook();
|
||||
FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
||||
Sheet sheet = workbook.createSheet("巡店删除结果");
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
|
||||
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
|
||||
}
|
||||
|
||||
int rowIndex = 1;
|
||||
for (PatrolDeleteResultItemVo item : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
int shopRowCount = maxCountryRowCount(item.getCountrySections());
|
||||
for (int shopRowIndex = 0; shopRowIndex < shopRowCount; shopRowIndex++) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
row.createCell(0).setCellValue(shopRowIndex == 0 ? safe(item.getShopName()) : "");
|
||||
writeCountryColumns(row, 1, findSection(item.getCountrySections(), COUNTRY_DE), shopRowIndex);
|
||||
writeCountryColumns(row, 5, findSection(item.getCountrySections(), COUNTRY_UK), shopRowIndex);
|
||||
writeCountryColumns(row, 9, findSection(item.getCountrySections(), COUNTRY_FR), shopRowIndex);
|
||||
writeCountryColumns(row, 13, findSection(item.getCountrySections(), COUNTRY_IT), shopRowIndex);
|
||||
writeCountryColumns(row, 17, findSection(item.getCountrySections(), COUNTRY_ES), shopRowIndex);
|
||||
if (shopRowIndex == 0) {
|
||||
writeCartRatioColumns(row, 21, item.getCartRatios(), 0);
|
||||
writeCartRatioColumns(row, 23, item.getCartRatios(), 1);
|
||||
writeCartRatioColumns(row, 25, item.getCartRatios(), 2);
|
||||
writeCartRatioColumns(row, 27, item.getCartRatios(), 3);
|
||||
writeCartRatioColumns(row, 29, item.getCartRatios(), 4);
|
||||
}
|
||||
}
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
|
||||
sheet.autoSizeColumn(columnIndex);
|
||||
}
|
||||
workbook.write(outputStream);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[patrol-delete] write workbook failed: {}", ex.getMessage());
|
||||
throw new BusinessException("生成巡店删除结果 Excel 失败: " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public int countRows(List<PatrolDeleteResultItemVo> items) {
|
||||
int count = 0;
|
||||
if (items == null) {
|
||||
return 0;
|
||||
}
|
||||
for (PatrolDeleteResultItemVo item : items) {
|
||||
if (item != null && !Boolean.FALSE.equals(item.getSuccess())) {
|
||||
count += maxCountryRowCount(item.getCountrySections());
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private int maxCountryRowCount(List<PatrolDeleteCountrySectionDto> sections) {
|
||||
int max = 1;
|
||||
if (sections == null) {
|
||||
return max;
|
||||
}
|
||||
for (PatrolDeleteCountrySectionDto section : sections) {
|
||||
if (section != null && section.getRows() != null && section.getRows().size() > max) {
|
||||
max = section.getRows().size();
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private PatrolDeleteCountrySectionDto findSection(List<PatrolDeleteCountrySectionDto> sections, String country) {
|
||||
if (sections == null) {
|
||||
return null;
|
||||
}
|
||||
for (PatrolDeleteCountrySectionDto section : sections) {
|
||||
if (section != null && country.equals(section.getCountry())) {
|
||||
return section;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void writeCountryColumns(Row row, int startColumn, PatrolDeleteCountrySectionDto section, int rowIndex) {
|
||||
PatrolDeleteCountryMetricRowDto metric = null;
|
||||
if (section != null && section.getRows() != null && rowIndex < section.getRows().size()) {
|
||||
metric = section.getRows().get(rowIndex);
|
||||
}
|
||||
row.createCell(startColumn).setCellValue(metric == null ? "" : safe(metric.getStatus()));
|
||||
row.createCell(startColumn + 1).setCellValue(metric == null ? "" : safe(metric.getQuantity()));
|
||||
row.createCell(startColumn + 2).setCellValue(metric == null ? "" : safe(metric.getDeleteQuantity()));
|
||||
row.createCell(startColumn + 3).setCellValue(metric == null ? "" : safe(metric.getProcessStatus()));
|
||||
}
|
||||
|
||||
private void writeCartRatioColumns(Row row, int startColumn, List<PatrolDeleteCartRatioDto> ratios, int ratioIndex) {
|
||||
PatrolDeleteCartRatioDto ratio = ratios != null && ratioIndex < ratios.size() ? ratios.get(ratioIndex) : null;
|
||||
row.createCell(startColumn).setCellValue(ratio == null ? "" : safe(ratio.getCountry()));
|
||||
row.createCell(startColumn + 1).setCellValue(ratio == null ? "" : safe(ratio.getRatio()));
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.patroldelete.mapper.PatrolDeleteShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.entity.PatrolDeleteShopCandidateEntity;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskShopQueueItemVo;
|
||||
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService;
|
||||
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PatrolDeleteResolveService {
|
||||
|
||||
private final PatrolDeleteShopCandidateMapper candidateMapper;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
|
||||
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
List<PatrolDeleteShopCandidateEntity> rows = candidateMapper.selectList(
|
||||
new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
|
||||
.eq(PatrolDeleteShopCandidateEntity::getUserId, userId)
|
||||
.orderByDesc(PatrolDeleteShopCandidateEntity::getId));
|
||||
List<ProductRiskCandidateVo> list = new ArrayList<>();
|
||||
for (PatrolDeleteShopCandidateEntity row : rows) {
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setShopName(row.getShopName());
|
||||
vo.setCreatedAt(row.getCreatedAt());
|
||||
list.add(vo);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ProductRiskCandidateVo addCandidate(ProductRiskCandidateAddRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(request.getShopName());
|
||||
if (normalized.isBlank()) {
|
||||
throw new BusinessException("店铺名不能为空");
|
||||
}
|
||||
ZiniaoShopMatchResultVo indexHit = ziniaoShopSwitchService.findIndexedStoreByName(normalized, false);
|
||||
if (indexHit == null || !indexHit.isMatched()) {
|
||||
String hint = indexHit != null && indexHit.getMatchMessage() != null && !indexHit.getMatchMessage().isBlank()
|
||||
? indexHit.getMatchMessage()
|
||||
: "店铺索引未命中,无法加入备选";
|
||||
throw new BusinessException(hint);
|
||||
}
|
||||
if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) {
|
||||
throw new BusinessException(indexHit.getMatchMessage() != null ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认");
|
||||
}
|
||||
PatrolDeleteShopCandidateEntity existing = candidateMapper.selectOne(
|
||||
new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
|
||||
.eq(PatrolDeleteShopCandidateEntity::getUserId, request.getUserId())
|
||||
.eq(PatrolDeleteShopCandidateEntity::getShopName, normalized)
|
||||
.last("limit 1"));
|
||||
if (existing != null) {
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(existing.getId());
|
||||
vo.setShopName(existing.getShopName());
|
||||
vo.setCreatedAt(existing.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
PatrolDeleteShopCandidateEntity entity = new PatrolDeleteShopCandidateEntity();
|
||||
entity.setUserId(request.getUserId());
|
||||
entity.setShopName(normalized);
|
||||
entity.setCreatedAt(LocalDateTime.now());
|
||||
candidateMapper.insert(entity);
|
||||
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(entity.getId());
|
||||
vo.setShopName(entity.getShopName());
|
||||
vo.setCreatedAt(entity.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCandidate(Long userId, Long id) {
|
||||
validateUserId(userId);
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("id 不合法");
|
||||
}
|
||||
PatrolDeleteShopCandidateEntity row = candidateMapper.selectById(id);
|
||||
if (row == null || !userId.equals(row.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
candidateMapper.deleteById(id);
|
||||
}
|
||||
|
||||
public ProductRiskMatchShopsVo matchShops(ProductRiskMatchShopsRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
LinkedHashSet<String> ordered = new LinkedHashSet<>();
|
||||
for (String raw : request.getShopNames()) {
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!normalized.isBlank()) {
|
||||
ordered.add(normalized);
|
||||
}
|
||||
}
|
||||
if (ordered.isEmpty()) {
|
||||
throw new BusinessException("shop_names 无有效店铺名");
|
||||
}
|
||||
ProductRiskMatchShopsVo vo = new ProductRiskMatchShopsVo();
|
||||
for (String shopName : ordered) {
|
||||
vo.getItems().add(matchOneShop(shopName));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
public long countCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
Long count = candidateMapper.selectCount(new LambdaQueryWrapper<PatrolDeleteShopCandidateEntity>()
|
||||
.eq(PatrolDeleteShopCandidateEntity::getUserId, userId));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
private ProductRiskShopQueueItemVo matchOneShop(String shopName) {
|
||||
ProductRiskShopQueueItemVo item = new ProductRiskShopQueueItemVo();
|
||||
item.setShopName(shopName);
|
||||
try {
|
||||
ZiniaoShopMatchResultVo matched = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false);
|
||||
if (matched == null) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage("匹配结果为空");
|
||||
return item;
|
||||
}
|
||||
item.setMatched(matched.isMatched());
|
||||
item.setShopId(matched.getShopId());
|
||||
item.setPlatform(matched.getPlatform());
|
||||
item.setCompanyName(matched.getCompanyName());
|
||||
item.setMatchedUserId(matched.getMatchedUserId());
|
||||
item.setMatchStatus(matched.getMatchStatus());
|
||||
item.setMatchMessage(matched.getMatchMessage());
|
||||
item.setOpenStoreUrl(matched.getOpenStoreUrl());
|
||||
} catch (BusinessException ex) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage(ex.getMessage());
|
||||
} catch (Exception ex) {
|
||||
item.setMatched(false);
|
||||
item.setMatchStatus("PENDING");
|
||||
item.setMatchMessage(Objects.toString(ex.getMessage(), "匹配异常"));
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private void validateUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PatrolDeleteTaskCacheService {
|
||||
|
||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||
|
||||
public PatrolDeleteShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
Object raw = stringRedisTemplate.opsForHash().get(buildShopPayloadKey(taskId), shopKey);
|
||||
if (!(raw instanceof String json) || json.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, PatrolDeleteShopPayloadDto.class);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("读取巡店删除店铺缓存失败");
|
||||
}
|
||||
}
|
||||
|
||||
public void saveShopMergedPayload(Long taskId, String shopKey, PatrolDeleteShopPayloadDto payload) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForHash().put(buildShopPayloadKey(taskId), shopKey, objectMapper.writeValueAsString(payload));
|
||||
stringRedisTemplate.expire(buildShopPayloadKey(taskId), Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
touchTaskHeartbeat(taskId);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("暂存巡店删除店铺缓存失败");
|
||||
}
|
||||
}
|
||||
|
||||
public void removeShopMergedPayload(Long taskId, String shopKey) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
stringRedisTemplate.opsForHash().delete(buildShopPayloadKey(taskId), shopKey);
|
||||
}
|
||||
|
||||
public Map<String, PatrolDeleteShopPayloadDto> getAllShopMergedPayload(Long taskId) {
|
||||
try {
|
||||
Map<Object, Object> raw = stringRedisTemplate.opsForHash().entries(buildShopPayloadKey(taskId));
|
||||
if (raw == null || raw.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, PatrolDeleteShopPayloadDto> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<Object, Object> entry : raw.entrySet()) {
|
||||
if (!(entry.getKey() instanceof String key) || !(entry.getValue() instanceof String val) || val.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
out.put(key, objectMapper.readValue(val, PatrolDeleteShopPayloadDto.class));
|
||||
}
|
||||
return out;
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("读取巡店删除缓存失败");
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasAnyShopMergedPayload(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
|
||||
return size != null && size > 0;
|
||||
}
|
||||
|
||||
public long countShopMergedPayload(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
Long size = stringRedisTemplate.opsForHash().size(buildShopPayloadKey(taskId));
|
||||
return size == null ? 0L : size;
|
||||
}
|
||||
|
||||
public long getTaskHeartbeatMillis(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId));
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
try {
|
||||
return Long.parseLong(raw);
|
||||
} catch (NumberFormatException ignored) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskHeartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
taskEntityLocalCache.remove(taskId);
|
||||
stringRedisTemplate.delete(buildShopPayloadKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
||||
}
|
||||
|
||||
public void saveTaskCache(FileTaskEntity task) {
|
||||
if (task == null || task.getId() == null) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
taskEntityLocalCache.put(task.getId(), new LocalTaskEntityCacheEntry(
|
||||
now,
|
||||
objectMapper.convertValue(task, FileTaskEntity.class)
|
||||
));
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskEntityKey(task.getId()),
|
||||
objectMapper.writeValueAsString(task),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS)
|
||||
);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<Long, FileTaskEntity> getTaskCacheBatch(java.util.List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
java.util.List<Long> normalized = taskIds.stream()
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalized.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
java.util.List<Long> missingIds = new ArrayList<>();
|
||||
for (Long taskId : normalized) {
|
||||
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
|
||||
if (isLocalCacheFresh(cached, now)) {
|
||||
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
||||
} else {
|
||||
missingIds.add(taskId);
|
||||
}
|
||||
}
|
||||
if (missingIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
java.util.List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
|
||||
java.util.List<String> values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||
for (int i = 0; i < missingIds.size(); i++) {
|
||||
Long taskId = missingIds.get(i);
|
||||
String val = values != null && i < values.size() ? values.get(i) : null;
|
||||
if (val == null || val.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
FileTaskEntity task = objectMapper.readValue(val, FileTaskEntity.class);
|
||||
result.put(taskId, task);
|
||||
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildShopPayloadKey(Long taskId) {
|
||||
return "patrol-delete:task:shop-payload:" + taskId;
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "patrol-delete:task:heartbeat:" + taskId;
|
||||
}
|
||||
|
||||
private String buildTaskEntityKey(Long taskId) {
|
||||
return "patrol-delete:task:entity:" + taskId;
|
||||
}
|
||||
|
||||
private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) {
|
||||
return cached != null
|
||||
&& now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis());
|
||||
}
|
||||
|
||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||
}
|
||||
+883
@@ -0,0 +1,883 @@
|
||||
package com.nanri.aiimage.modules.patroldelete.service;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
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.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCartRatioDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountryMetricRowDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCountrySectionDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteShopPayloadDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.dto.PatrolDeleteTaskItemDto;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteHistoryVo;
|
||||
import com.nanri.aiimage.modules.patroldelete.model.vo.PatrolDeleteResultItemVo;
|
||||
import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo;
|
||||
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 com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.File;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class PatrolDeleteTaskService {
|
||||
|
||||
private static final String MODULE_TYPE = "PATROL_DELETE";
|
||||
private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
||||
private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断";
|
||||
|
||||
private final FileTaskMapper fileTaskMapper;
|
||||
private final FileResultMapper fileResultMapper;
|
||||
private final PatrolDeleteResolveService patrolDeleteResolveService;
|
||||
private final PatrolDeleteExcelAssemblyService excelAssemblyService;
|
||||
private final PatrolDeleteTaskCacheService taskCacheService;
|
||||
private final OssStorageService ossStorageService;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
|
||||
private FileTaskEntity loadTaskForExecution(Long taskId) {
|
||||
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
FileTaskEntity cached = cachedTasks.get(taskId);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
FileTaskEntity dbTask = fileTaskMapper.selectById(taskId);
|
||||
if (dbTask != null && MODULE_TYPE.equals(dbTask.getModuleType())) {
|
||||
taskCacheService.saveTaskCache(dbTask);
|
||||
}
|
||||
return dbTask;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskMapByIds(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalizedTaskIds = taskIds.stream()
|
||||
.filter(taskId -> taskId != null && taskId > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (normalizedTaskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
Map<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(normalizedTaskIds);
|
||||
result.putAll(cachedTasks);
|
||||
List<Long> missingTaskIds = normalizedTaskIds.stream()
|
||||
.filter(taskId -> !cachedTasks.containsKey(taskId))
|
||||
.toList();
|
||||
if (!missingTaskIds.isEmpty()) {
|
||||
for (FileTaskEntity dbTask : selectTasksByIdsInBatches(missingTaskIds)) {
|
||||
if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) {
|
||||
continue;
|
||||
}
|
||||
result.put(dbTask.getId(), dbTask);
|
||||
if ("RUNNING".equals(dbTask.getStatus())) {
|
||||
taskCacheService.saveTaskCache(dbTask);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<FileTaskEntity> selectTasksByIdsInBatches(List<Long> taskIds) {
|
||||
List<FileTaskEntity> tasks = new ArrayList<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return tasks;
|
||||
}
|
||||
int batchSize = Math.max(1, taskPressureProperties.getDbSelectBatchSize());
|
||||
for (int start = 0; start < taskIds.size(); start += batchSize) {
|
||||
int end = Math.min(start + batchSize, taskIds.size());
|
||||
tasks.addAll(fileTaskMapper.selectBatchIds(taskIds.subList(start, end)));
|
||||
}
|
||||
return tasks;
|
||||
}
|
||||
|
||||
public ProductRiskDashboardVo dashboard(Long userId) {
|
||||
validateUserId(userId);
|
||||
ProductRiskDashboardVo vo = new ProductRiskDashboardVo();
|
||||
vo.setCandidateCount(patrolDeleteResolveService.countCandidates(userId));
|
||||
vo.setProcessedTaskCount(countTasks(userId, List.of("SUCCESS", "FAILED")));
|
||||
vo.setSuccessTaskCount(countTasks(userId, List.of("SUCCESS")));
|
||||
vo.setFailedTaskCount(countTasks(userId, List.of("FAILED")));
|
||||
return vo;
|
||||
}
|
||||
|
||||
public PatrolDeleteHistoryVo listHistory(Long userId) {
|
||||
validateUserId(userId);
|
||||
PatrolDeleteHistoryVo vo = new PatrolDeleteHistoryVo();
|
||||
List<FileResultEntity> entities = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
if (entities.isEmpty()) {
|
||||
vo.setItems(List.of());
|
||||
return vo;
|
||||
}
|
||||
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMap(entities);
|
||||
Map<Long, Map<Long, PatrolDeleteResultItemVo>> snapshotMap = buildSnapshotMap(taskMap);
|
||||
List<PatrolDeleteResultItemVo> items = new ArrayList<>();
|
||||
for (FileResultEntity entity : entities) {
|
||||
FileTaskEntity task = taskMap.get(entity.getTaskId());
|
||||
PatrolDeleteResultItemVo snapshot = snapshotMap.getOrDefault(entity.getTaskId(), Map.of()).get(entity.getId());
|
||||
items.add(toHistoryItem(entity, task, snapshot));
|
||||
}
|
||||
vo.setItems(items);
|
||||
return vo;
|
||||
}
|
||||
|
||||
public PatrolDeleteTaskBatchVo getTaskProgressBatch(List<Long> taskIds) {
|
||||
PatrolDeleteTaskBatchVo batch = new PatrolDeleteTaskBatchVo();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
|
||||
List<Long> normalizedTaskIds = taskIds.stream()
|
||||
.filter(taskId -> taskId != null && taskId > 0)
|
||||
.distinct()
|
||||
.limit(50)
|
||||
.toList();
|
||||
if (normalizedTaskIds.isEmpty()) {
|
||||
return batch;
|
||||
}
|
||||
|
||||
Map<Long, FileTaskEntity> taskMap = loadTaskMapByIds(normalizedTaskIds);
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.in(FileResultEntity::getTaskId, normalizedTaskIds)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
Map<Long, List<FileResultEntity>> rowsByTaskId = new LinkedHashMap<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
rowsByTaskId.computeIfAbsent(row.getTaskId(), ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
|
||||
for (Long taskId : normalizedTaskIds) {
|
||||
FileTaskEntity task = taskMap.get(taskId);
|
||||
if (task == null) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
List<FileResultEntity> taskRows = rowsByTaskId.get(taskId);
|
||||
if (taskRows == null || taskRows.isEmpty()) {
|
||||
batch.getMissingTaskIds().add(taskId);
|
||||
continue;
|
||||
}
|
||||
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, taskRows);
|
||||
batch.getItems().addAll(snapshots);
|
||||
}
|
||||
return batch;
|
||||
}
|
||||
|
||||
public String resolveResultDownloadUrl(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
if (blank(entity.getResultFileUrl())) {
|
||||
throw new BusinessException("暂无可下载文件");
|
||||
}
|
||||
return ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl());
|
||||
}
|
||||
|
||||
public String resolveResultDownloadFilename(Long resultId, Long userId) {
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
return !blank(entity.getResultFilename())
|
||||
? entity.getResultFilename()
|
||||
: safeFileStem(entity.getSourceFilename()) + ".xlsx";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public PatrolDeleteCreateTaskVo createTask(PatrolDeleteCreateTaskRequest request) {
|
||||
validateUserId(request.getUserId());
|
||||
if (request.getItems() == null || request.getItems().isEmpty()) {
|
||||
throw new BusinessException("items 不能为空");
|
||||
}
|
||||
|
||||
List<PatrolDeleteTaskItemDto> uniqueItems = dedupeItems(request.getItems());
|
||||
if (uniqueItems.isEmpty()) {
|
||||
throw new BusinessException("items 不能为空");
|
||||
}
|
||||
for (PatrolDeleteTaskItemDto item : uniqueItems) {
|
||||
if (item == null || !item.isMatched()) {
|
||||
throw new BusinessException("存在未匹配店铺,无法创建任务");
|
||||
}
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setTaskNo(MODULE_TYPE + "-" + IdUtil.getSnowflakeNextIdStr());
|
||||
task.setModuleType(MODULE_TYPE);
|
||||
task.setTaskMode("PYTHON_QUEUE");
|
||||
task.setStatus("RUNNING");
|
||||
task.setSourceFileCount(uniqueItems.size());
|
||||
task.setSuccessFileCount(0);
|
||||
task.setFailedFileCount(0);
|
||||
task.setCreatedBy("user:" + request.getUserId());
|
||||
task.setUserId(request.getUserId());
|
||||
task.setCreatedAt(now);
|
||||
task.setUpdatedAt(now);
|
||||
fileTaskMapper.insert(task);
|
||||
taskCacheService.saveTaskCache(task);
|
||||
taskCacheService.touchTaskHeartbeat(task.getId());
|
||||
|
||||
List<PatrolDeleteResultItemVo> snapshots = new ArrayList<>();
|
||||
for (PatrolDeleteTaskItemDto item : uniqueItems) {
|
||||
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(normalizedShopName)) {
|
||||
throw new BusinessException("任务数据已失效,请刷新后重试");
|
||||
}
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setTaskId(task.getId());
|
||||
result.setModuleType(MODULE_TYPE);
|
||||
result.setSourceFilename(normalizedShopName);
|
||||
result.setSourceFileUrl(item.getShopId());
|
||||
result.setUserId(request.getUserId());
|
||||
result.setSuccess(null);
|
||||
result.setCreatedAt(now);
|
||||
fileResultMapper.insert(result);
|
||||
snapshots.add(toSnapshotVo(result, item, task.getStatus(), null));
|
||||
}
|
||||
persistTaskJson(task, uniqueItems, snapshots);
|
||||
|
||||
PatrolDeleteCreateTaskVo vo = new PatrolDeleteCreateTaskVo();
|
||||
vo.setTaskId(task.getId());
|
||||
vo.setItems(snapshots);
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void submitResult(Long taskId, PatrolDeleteSubmitResultRequest request) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
throw new BusinessException("taskId 不合法");
|
||||
}
|
||||
if (request == null || request.getShops() == null || request.getShops().isEmpty()) {
|
||||
throw new BusinessException("shops 不能为空");
|
||||
}
|
||||
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
throw new BusinessException("任务已结束,拒绝重复提交");
|
||||
}
|
||||
|
||||
taskCacheService.touchTaskHeartbeat(taskId);
|
||||
List<FileResultEntity> resultRows = listTaskRows(taskId);
|
||||
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, resultRows);
|
||||
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
|
||||
Map<String, PatrolDeleteShopPayloadDto> payloadByShop = normalizePayloadByShop(request.getShops());
|
||||
|
||||
for (FileResultEntity row : resultRows) {
|
||||
String shopKey = row.getSourceFilename();
|
||||
PatrolDeleteShopPayloadDto incoming = payloadByShop.get(shopKey);
|
||||
if (incoming == null) {
|
||||
continue;
|
||||
}
|
||||
PatrolDeleteShopPayloadDto mergedPayload = mergeShopPayload(taskId, shopKey, incoming);
|
||||
PatrolDeleteResultItemVo snapshot = snapshotByResultId.get(row.getId());
|
||||
mergePayloadIntoSnapshot(snapshot, mergedPayload);
|
||||
|
||||
if (!blank(mergedPayload.getError())) {
|
||||
markResultFailed(row, mergedPayload.getError());
|
||||
applyFailureToSnapshot(snapshot, mergedPayload.getError());
|
||||
taskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(mergedPayload.getShopDone())) {
|
||||
markResultSuccess(row);
|
||||
applySuccessToSnapshot(snapshot);
|
||||
taskCacheService.removeShopMergedPayload(taskId, shopKey);
|
||||
}
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
tryFinalizeTask(taskId, false);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean tryFinalizeTask(Long taskId, boolean fromCompensation) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
return false;
|
||||
}
|
||||
if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||
if (rows.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Map<String, PatrolDeleteShopPayloadDto> cachedPayloads = taskCacheService.getAllShopMergedPayload(taskId);
|
||||
List<PatrolDeleteResultItemVo> snapshots = buildSnapshotFromDb(task, rows);
|
||||
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(snapshots);
|
||||
boolean changed = false;
|
||||
|
||||
for (FileResultEntity row : rows) {
|
||||
if (isResultFinished(row)) {
|
||||
continue;
|
||||
}
|
||||
PatrolDeleteResultItemVo snapshot = snapshotByResultId.get(row.getId());
|
||||
PatrolDeleteShopPayloadDto cached = cachedPayloads.get(row.getSourceFilename());
|
||||
if (cached == null) {
|
||||
if (fromCompensation) {
|
||||
markResultFailed(row, INTERRUPTED_MESSAGE);
|
||||
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
mergePayloadIntoSnapshot(snapshot, cached);
|
||||
|
||||
if (!blank(cached.getError())) {
|
||||
markResultFailed(row, cached.getError());
|
||||
applyFailureToSnapshot(snapshot, cached.getError());
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(cached.getShopDone()) || (fromCompensation && hasAnyPayloadData(cached))) {
|
||||
markResultSuccess(row);
|
||||
applySuccessToSnapshot(snapshot);
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fromCompensation) {
|
||||
markResultFailed(row, INTERRUPTED_MESSAGE);
|
||||
applyFailureToSnapshot(snapshot, INTERRUPTED_MESSAGE);
|
||||
taskCacheService.removeShopMergedPayload(taskId, row.getSourceFilename());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
List<FileResultEntity> latestRows = listTaskRows(taskId);
|
||||
updateTaskStatusFromRows(task, latestRows);
|
||||
if (latestRows.stream().allMatch(this::isResultFinished)) {
|
||||
finalizeTaskWorkbook(task, latestRows, snapshots);
|
||||
return true;
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
return changed;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteTask(Long taskId, Long userId) {
|
||||
validateUserId(userId);
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) {
|
||||
throw new BusinessException("任务不存在");
|
||||
}
|
||||
fileResultMapper.delete(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE));
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
taskCacheService.deleteTaskCache(taskId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteHistory(Long resultId, Long userId) {
|
||||
validateUserId(userId);
|
||||
FileResultEntity entity = fileResultMapper.selectById(resultId);
|
||||
if (entity == null || !MODULE_TYPE.equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
Long taskId = entity.getTaskId();
|
||||
fileResultMapper.deleteById(resultId);
|
||||
reconcileTaskAfterResultRemoval(taskId);
|
||||
}
|
||||
|
||||
private void reconcileTaskAfterResultRemoval(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
FileTaskEntity task = loadTaskForExecution(taskId);
|
||||
if (task == null || !MODULE_TYPE.equals(task.getModuleType())) {
|
||||
return;
|
||||
}
|
||||
List<FileResultEntity> rows = listTaskRows(taskId);
|
||||
if (rows.isEmpty()) {
|
||||
fileTaskMapper.deleteById(taskId);
|
||||
taskCacheService.deleteTaskCache(taskId);
|
||||
return;
|
||||
}
|
||||
updateTaskStatusFromRows(task, rows);
|
||||
persistSnapshotJson(task, buildSnapshotFromDb(task, rows));
|
||||
fileTaskMapper.updateById(task);
|
||||
}
|
||||
|
||||
private long countTasks(Long userId, List<String> statuses) {
|
||||
Long count = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.in(FileTaskEntity::getStatus, statuses));
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
private List<FileResultEntity> listTaskRows(Long taskId) {
|
||||
return fileResultMapper.selectList(new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
}
|
||||
|
||||
private Map<String, PatrolDeleteShopPayloadDto> normalizePayloadByShop(List<PatrolDeleteShopPayloadDto> shops) {
|
||||
Map<String, PatrolDeleteShopPayloadDto> payloadByShop = new LinkedHashMap<>();
|
||||
for (PatrolDeleteShopPayloadDto item : shops) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String shopKey = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(shopKey)) {
|
||||
continue;
|
||||
}
|
||||
item.setShopName(shopKey);
|
||||
payloadByShop.put(shopKey, item);
|
||||
}
|
||||
return payloadByShop;
|
||||
}
|
||||
|
||||
private Map<Long, FileTaskEntity> loadTaskMap(List<FileResultEntity> entities) {
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
if (taskIds.isEmpty()) {
|
||||
return taskMap;
|
||||
}
|
||||
for (FileTaskEntity task : selectTasksByIdsInBatches(taskIds)) {
|
||||
if (task != null && MODULE_TYPE.equals(task.getModuleType())) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
return taskMap;
|
||||
}
|
||||
|
||||
private Map<Long, Map<Long, PatrolDeleteResultItemVo>> buildSnapshotMap(Map<Long, FileTaskEntity> taskMap) {
|
||||
Map<Long, Map<Long, PatrolDeleteResultItemVo>> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<Long, FileTaskEntity> entry : taskMap.entrySet()) {
|
||||
out.put(entry.getKey(), indexSnapshotByResultId(parseTaskSnapshots(entry.getValue().getResultJson())));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private PatrolDeleteResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, PatrolDeleteResultItemVo snapshot) {
|
||||
PatrolDeleteResultItemVo item = snapshot != null ? snapshot : new PatrolDeleteResultItemVo();
|
||||
item.setResultId(entity.getId());
|
||||
item.setTaskId(entity.getTaskId());
|
||||
item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename()));
|
||||
item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl()));
|
||||
item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus());
|
||||
item.setSuccess(entity.getSuccess() == null ? item.getSuccess() : entity.getSuccess() == 1);
|
||||
item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError());
|
||||
item.setCreatedAt(entity.getCreatedAt());
|
||||
item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt());
|
||||
item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename()));
|
||||
item.setDownloadUrl(blank(entity.getResultFileUrl())
|
||||
? item.getDownloadUrl()
|
||||
: ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl()));
|
||||
if (item.getCountrySections() == null) {
|
||||
item.setCountrySections(new ArrayList<>());
|
||||
}
|
||||
if (item.getCartRatios() == null) {
|
||||
item.setCartRatios(new ArrayList<>());
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private List<PatrolDeleteTaskItemDto> dedupeItems(List<PatrolDeleteTaskItemDto> items) {
|
||||
LinkedHashMap<String, PatrolDeleteTaskItemDto> map = new LinkedHashMap<>();
|
||||
for (PatrolDeleteTaskItemDto item : items) {
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
String normalizedShopName = ziniaoShopSwitchService.normalizeShopName(item.getShopName());
|
||||
if (blank(normalizedShopName)) {
|
||||
continue;
|
||||
}
|
||||
item.setShopName(normalizedShopName);
|
||||
String key = normalizedShopName + "::" + Objects.toString(item.getShopId(), "");
|
||||
map.put(key, item);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private PatrolDeleteResultItemVo toSnapshotVo(FileResultEntity result, PatrolDeleteTaskItemDto item, String taskStatus, LocalDateTime finishedAt) {
|
||||
PatrolDeleteResultItemVo vo = new PatrolDeleteResultItemVo();
|
||||
vo.setResultId(result.getId());
|
||||
vo.setTaskId(result.getTaskId());
|
||||
vo.setShopName(item.getShopName());
|
||||
vo.setShopId(item.getShopId());
|
||||
vo.setPlatform(item.getPlatform());
|
||||
vo.setCompanyName(item.getCompanyName());
|
||||
vo.setMatched(item.isMatched());
|
||||
vo.setMatchStatus(item.getMatchStatus());
|
||||
vo.setMatchMessage(item.getMatchMessage());
|
||||
vo.setTaskStatus(taskStatus);
|
||||
vo.setSuccess(result.getSuccess() == null ? null : result.getSuccess() == 1);
|
||||
vo.setError(result.getErrorMessage());
|
||||
vo.setCreatedAt(result.getCreatedAt());
|
||||
vo.setFinishedAt(finishedAt);
|
||||
vo.setCountrySections(copyCountrySections(item.getCountrySections()));
|
||||
vo.setCartRatios(copyCartRatios(item.getCartRatios()));
|
||||
vo.setOutputFilename(result.getResultFilename());
|
||||
vo.setDownloadUrl(null);
|
||||
return vo;
|
||||
}
|
||||
|
||||
private void persistTaskJson(FileTaskEntity task, List<PatrolDeleteTaskItemDto> requestItems, List<PatrolDeleteResultItemVo> snapshots) {
|
||||
try {
|
||||
task.setRequestJson(objectMapper.writeValueAsString(requestItems));
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshots));
|
||||
fileTaskMapper.updateById(task);
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("巡店删除任务快照保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private List<PatrolDeleteResultItemVo> buildSnapshotFromDb(FileTaskEntity task, List<FileResultEntity> rows) {
|
||||
List<PatrolDeleteResultItemVo> existing = parseTaskSnapshots(task.getResultJson());
|
||||
Map<Long, PatrolDeleteResultItemVo> snapshotByResultId = indexSnapshotByResultId(existing);
|
||||
List<PatrolDeleteResultItemVo> list = new ArrayList<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
list.add(toHistoryItem(row, task, snapshotByResultId.get(row.getId())));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private Map<Long, PatrolDeleteResultItemVo> indexSnapshotByResultId(List<PatrolDeleteResultItemVo> snapshots) {
|
||||
Map<Long, PatrolDeleteResultItemVo> map = new LinkedHashMap<>();
|
||||
if (snapshots == null) {
|
||||
return map;
|
||||
}
|
||||
for (PatrolDeleteResultItemVo snapshot : snapshots) {
|
||||
if (snapshot != null && snapshot.getResultId() != null) {
|
||||
map.put(snapshot.getResultId(), snapshot);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private void updateTaskStatusFromRows(FileTaskEntity task, List<FileResultEntity> rows) {
|
||||
long successCount = rows.stream().filter(row -> Integer.valueOf(1).equals(row.getSuccess())).count();
|
||||
long failedCount = rows.stream().filter(row -> Integer.valueOf(0).equals(row.getSuccess())).count();
|
||||
long pendingCount = rows.stream().filter(row -> !isResultFinished(row)).count();
|
||||
task.setSuccessFileCount((int) successCount);
|
||||
task.setFailedFileCount((int) failedCount);
|
||||
task.setUpdatedAt(LocalDateTime.now());
|
||||
if (pendingCount > 0) {
|
||||
task.setStatus("RUNNING");
|
||||
task.setFinishedAt(null);
|
||||
task.setErrorMessage(null);
|
||||
return;
|
||||
}
|
||||
task.setStatus(failedCount > 0 ? "FAILED" : "SUCCESS");
|
||||
task.setFinishedAt(LocalDateTime.now());
|
||||
if (failedCount > 0) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
for (FileResultEntity row : rows) {
|
||||
if (!blank(row.getErrorMessage())) {
|
||||
errors.add(row.getSourceFilename() + ": " + row.getErrorMessage());
|
||||
}
|
||||
}
|
||||
task.setErrorMessage(String.join("; ", errors));
|
||||
} else {
|
||||
task.setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
|
||||
private PatrolDeleteShopPayloadDto mergeShopPayload(Long taskId, String shopKey, PatrolDeleteShopPayloadDto incoming) {
|
||||
PatrolDeleteShopPayloadDto merged = taskCacheService.getShopMergedPayload(taskId, shopKey);
|
||||
if (merged == null) {
|
||||
merged = new PatrolDeleteShopPayloadDto();
|
||||
merged.setShopName(shopKey);
|
||||
merged.setCountrySections(new ArrayList<>());
|
||||
merged.setCartRatios(new ArrayList<>());
|
||||
}
|
||||
|
||||
merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName()));
|
||||
merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId()));
|
||||
merged.setChunkIndex(incoming.getChunkIndex());
|
||||
merged.setChunkTotal(incoming.getChunkTotal());
|
||||
|
||||
if (!blank(incoming.getError())) {
|
||||
merged.setError(incoming.getError().trim());
|
||||
merged.setShopDone(Boolean.TRUE);
|
||||
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
merged.setCountrySections(mergeCountrySections(merged.getCountrySections(), incoming.getCountrySections()));
|
||||
merged.setCartRatios(mergeCartRatios(merged.getCartRatios(), incoming.getCartRatios()));
|
||||
if (Boolean.TRUE.equals(incoming.getShopDone())) {
|
||||
merged.setShopDone(Boolean.TRUE);
|
||||
}
|
||||
taskCacheService.saveShopMergedPayload(taskId, shopKey, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
private void mergePayloadIntoSnapshot(PatrolDeleteResultItemVo snapshot, PatrolDeleteShopPayloadDto payload) {
|
||||
if (snapshot == null || payload == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName()));
|
||||
snapshot.setCountrySections(copyCountrySections(payload.getCountrySections()));
|
||||
snapshot.setCartRatios(copyCartRatios(payload.getCartRatios()));
|
||||
if (!blank(payload.getError())) {
|
||||
snapshot.setError(payload.getError().trim());
|
||||
}
|
||||
}
|
||||
|
||||
private void applySuccessToSnapshot(PatrolDeleteResultItemVo snapshot) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setSuccess(Boolean.TRUE);
|
||||
snapshot.setError(null);
|
||||
}
|
||||
|
||||
private void applyFailureToSnapshot(PatrolDeleteResultItemVo snapshot, String error) {
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
snapshot.setSuccess(Boolean.FALSE);
|
||||
snapshot.setError(blankToNull(error));
|
||||
}
|
||||
|
||||
private List<PatrolDeleteCountrySectionDto> mergeCountrySections(List<PatrolDeleteCountrySectionDto> base, List<PatrolDeleteCountrySectionDto> incoming) {
|
||||
Map<String, PatrolDeleteCountrySectionDto> map = new LinkedHashMap<>();
|
||||
for (PatrolDeleteCountrySectionDto section : copyCountrySections(base)) {
|
||||
map.put(section.getCountry(), section);
|
||||
}
|
||||
for (PatrolDeleteCountrySectionDto section : copyCountrySections(incoming)) {
|
||||
PatrolDeleteCountrySectionDto existing = map.get(section.getCountry());
|
||||
if (existing == null) {
|
||||
map.put(section.getCountry(), section);
|
||||
continue;
|
||||
}
|
||||
List<PatrolDeleteCountryMetricRowDto> mergedRows = existing.getRows() == null
|
||||
? new ArrayList<>()
|
||||
: new ArrayList<>(existing.getRows());
|
||||
if (section.getRows() != null) {
|
||||
mergedRows.addAll(section.getRows());
|
||||
}
|
||||
existing.setRows(mergedRows);
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private List<PatrolDeleteCartRatioDto> mergeCartRatios(List<PatrolDeleteCartRatioDto> base, List<PatrolDeleteCartRatioDto> incoming) {
|
||||
Map<String, PatrolDeleteCartRatioDto> map = new LinkedHashMap<>();
|
||||
for (PatrolDeleteCartRatioDto ratio : copyCartRatios(base)) {
|
||||
map.put(ratio.getCountry(), ratio);
|
||||
}
|
||||
for (PatrolDeleteCartRatioDto ratio : copyCartRatios(incoming)) {
|
||||
if (!blank(ratio.getCountry())) {
|
||||
map.put(ratio.getCountry(), ratio);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(map.values());
|
||||
}
|
||||
|
||||
private boolean hasAnyPayloadData(PatrolDeleteShopPayloadDto payload) {
|
||||
if (payload == null) {
|
||||
return false;
|
||||
}
|
||||
if (payload.getCountrySections() != null) {
|
||||
for (PatrolDeleteCountrySectionDto section : payload.getCountrySections()) {
|
||||
if (section == null || section.getRows() == null) {
|
||||
continue;
|
||||
}
|
||||
for (PatrolDeleteCountryMetricRowDto row : section.getRows()) {
|
||||
if (row != null && (!blank(row.getStatus())
|
||||
|| !blank(row.getQuantity())
|
||||
|| !blank(row.getDeleteQuantity())
|
||||
|| !blank(row.getProcessStatus()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (payload.getCartRatios() != null) {
|
||||
for (PatrolDeleteCartRatioDto ratio : payload.getCartRatios()) {
|
||||
if (ratio != null && (!blank(ratio.getCountry()) || !blank(ratio.getRatio()))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void finalizeTaskWorkbook(FileTaskEntity task, List<FileResultEntity> rows, List<PatrolDeleteResultItemVo> snapshots) {
|
||||
List<PatrolDeleteResultItemVo> successItems = snapshots.stream()
|
||||
.filter(item -> Boolean.TRUE.equals(item.getSuccess()))
|
||||
.toList();
|
||||
if (!successItems.isEmpty()) {
|
||||
File workRoot = FileUtil.mkdir(FileUtil.file(System.getProperty("java.io.tmpdir"), "patrol-delete-result", String.valueOf(task.getId())));
|
||||
String filename = safeFileStem("巡店删除-" + task.getId()) + ".xlsx";
|
||||
File xlsx = FileUtil.file(workRoot, filename);
|
||||
try {
|
||||
excelAssemblyService.writeWorkbook(xlsx, successItems);
|
||||
String objectKey = ossStorageService.uploadResultFile(xlsx, MODULE_TYPE);
|
||||
long fileSize = xlsx.length();
|
||||
int rowCount = excelAssemblyService.countRows(successItems);
|
||||
for (FileResultEntity row : rows) {
|
||||
if (Integer.valueOf(1).equals(row.getSuccess())) {
|
||||
row.setResultFilename(filename);
|
||||
row.setResultFileUrl(objectKey);
|
||||
row.setResultFileSize(fileSize);
|
||||
row.setResultContentType(CONTENT_TYPE_XLSX);
|
||||
row.setRowCount(rowCount);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
}
|
||||
snapshots = buildSnapshotFromDb(task, rows);
|
||||
} finally {
|
||||
FileUtil.del(xlsx);
|
||||
}
|
||||
}
|
||||
|
||||
persistSnapshotJson(task, snapshots);
|
||||
fileTaskMapper.updateById(task);
|
||||
taskCacheService.deleteTaskCache(task.getId());
|
||||
}
|
||||
|
||||
private void markResultSuccess(FileResultEntity row) {
|
||||
row.setSuccess(1);
|
||||
row.setErrorMessage(null);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
|
||||
private void markResultFailed(FileResultEntity row, String message) {
|
||||
row.setSuccess(0);
|
||||
row.setErrorMessage(blankToNull(message));
|
||||
row.setResultFilename(null);
|
||||
row.setResultFileUrl(null);
|
||||
row.setResultFileSize(0L);
|
||||
row.setResultContentType(null);
|
||||
row.setRowCount(0);
|
||||
fileResultMapper.updateById(row);
|
||||
}
|
||||
|
||||
private boolean isResultFinished(FileResultEntity row) {
|
||||
return Integer.valueOf(1).equals(row.getSuccess()) || Integer.valueOf(0).equals(row.getSuccess());
|
||||
}
|
||||
|
||||
private List<PatrolDeleteResultItemVo> parseTaskSnapshots(String json) {
|
||||
if (blank(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<PatrolDeleteResultItemVo>>() {});
|
||||
} catch (Exception ex) {
|
||||
log.warn("[patrol-delete] parse task snapshot failed: {}", ex.getMessage());
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private void persistSnapshotJson(FileTaskEntity task, List<PatrolDeleteResultItemVo> snapshots) {
|
||||
try {
|
||||
task.setResultJson(objectMapper.writeValueAsString(snapshots == null ? List.of() : snapshots));
|
||||
} catch (Exception ex) {
|
||||
throw new BusinessException("巡店删除任务快照保存失败");
|
||||
}
|
||||
}
|
||||
|
||||
private List<PatrolDeleteCountrySectionDto> copyCountrySections(List<PatrolDeleteCountrySectionDto> sections) {
|
||||
List<PatrolDeleteCountrySectionDto> copy = new ArrayList<>();
|
||||
if (sections == null) {
|
||||
return copy;
|
||||
}
|
||||
for (PatrolDeleteCountrySectionDto section : sections) {
|
||||
if (section == null) {
|
||||
continue;
|
||||
}
|
||||
PatrolDeleteCountrySectionDto item = new PatrolDeleteCountrySectionDto();
|
||||
item.setCountry(section.getCountry());
|
||||
item.setRows(section.getRows() == null ? new ArrayList<>() : new ArrayList<>(section.getRows()));
|
||||
copy.add(item);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private List<PatrolDeleteCartRatioDto> copyCartRatios(List<PatrolDeleteCartRatioDto> ratios) {
|
||||
List<PatrolDeleteCartRatioDto> copy = new ArrayList<>();
|
||||
if (ratios == null) {
|
||||
return copy;
|
||||
}
|
||||
for (PatrolDeleteCartRatioDto ratio : ratios) {
|
||||
if (ratio == null) {
|
||||
continue;
|
||||
}
|
||||
PatrolDeleteCartRatioDto item = new PatrolDeleteCartRatioDto();
|
||||
item.setCountry(ratio.getCountry());
|
||||
item.setRatio(ratio.getRatio());
|
||||
copy.add(item);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return !blank(first) ? first : second;
|
||||
}
|
||||
|
||||
private String safeFileStem(String value) {
|
||||
String raw = value == null ? "result" : value.trim();
|
||||
String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_");
|
||||
return safe.isBlank() ? "result" : safe;
|
||||
}
|
||||
|
||||
private boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private String blankToNull(String value) {
|
||||
return blank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
private void validateUserId(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user