后端优化修复问题
This commit is contained in:
+179
@@ -0,0 +1,179 @@
|
||||
package com.nanri.aiimage.modules.withdraw.controller;
|
||||
|
||||
import com.nanri.aiimage.common.api.ApiResponse;
|
||||
import com.nanri.aiimage.common.util.DownloadHeaderUtil;
|
||||
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 com.nanri.aiimage.modules.withdraw.model.dto.WithdrawCandidateClearRequest;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawCreateTaskRequest;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawSubmitResultRequest;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawTaskBatchRequest;
|
||||
import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawCreateTaskVo;
|
||||
import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawHistoryVo;
|
||||
import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawTaskBatchVo;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawResolveService;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
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.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/withdraw")
|
||||
@Tag(name = "取款任务", description = "店铺取款任务模块:维护待取款店铺、匹配紫鸟店铺、创建任务、接收 Python 回传、查询进度和下载结果文件。")
|
||||
public class WithdrawTaskController {
|
||||
|
||||
private final WithdrawResolveService withdrawResolveService;
|
||||
private final WithdrawTaskService withdrawTaskService;
|
||||
|
||||
@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(withdrawResolveService.listCandidates(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/candidates")
|
||||
@Operation(summary = "新增取款备选店铺", description = "新增一个待处理店铺名到当前用户的取款备选列表;同一用户下店铺名会规范化去重。")
|
||||
public ApiResponse<ProductRiskCandidateVo> addCandidate(@Valid @RequestBody ProductRiskCandidateAddRequest request) {
|
||||
return ApiResponse.success(withdrawResolveService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除单个取款备选店铺", description = "按备选记录主键删除当前用户的一条取款备选店铺记录。")
|
||||
public ApiResponse<Void> deleteCandidate(
|
||||
@Parameter(description = "备选店铺记录主键,即 candidates 接口返回的 id。", required = true, example = "100")
|
||||
@PathVariable Long id,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
withdrawResolveService.deleteCandidate(userId, id);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/candidates/clear")
|
||||
@Operation(summary = "批量清空取款备选店铺", description = "按店铺名批量删除当前用户的备选记录;通常在店铺成功推送到 Python 队列后清理已匹配数据。")
|
||||
public ApiResponse<Void> clearCandidates(@Valid @RequestBody WithdrawCandidateClearRequest request) {
|
||||
withdrawResolveService.deleteCandidatesByShopNames(request.getUserId(), request.getShopNames());
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/match-shops")
|
||||
@Operation(summary = "匹配取款店铺", description = "根据店铺名称批量查询紫鸟索引,返回店铺 ID、平台、公司名、匹配状态等信息;只有 matched=true 的店铺可创建任务。")
|
||||
public ApiResponse<ProductRiskMatchShopsVo> matchShops(@Valid @RequestBody ProductRiskMatchShopsRequest request) {
|
||||
return ApiResponse.success(withdrawResolveService.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(withdrawTaskService.dashboard(userId));
|
||||
}
|
||||
|
||||
@GetMapping("/history")
|
||||
@Operation(summary = "查询取款任务记录", description = "查询当前用户取款模块的运行中和历史结果记录,包含任务状态、下载地址、店铺结果和明细行。")
|
||||
public ApiResponse<WithdrawHistoryVo> history(
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
return ApiResponse.success(withdrawTaskService.listHistory(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/progress/batch")
|
||||
@Operation(summary = "批量查询取款任务进度", description = "前端轮询使用。按任务 ID 批量查询轻量进度和结果快照;不存在的任务 ID 会返回在 missingTaskIds 中。")
|
||||
public ApiResponse<WithdrawTaskBatchVo> taskProgressBatch(@Valid @RequestBody WithdrawTaskBatchRequest request) {
|
||||
return ApiResponse.success(withdrawTaskService.getTaskProgressBatch(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建取款任务", description = "为一批已匹配店铺创建一个取款任务,写入任务记录和店铺结果占位记录,并返回 taskId 及初始店铺快照。")
|
||||
public ApiResponse<WithdrawCreateTaskVo> createTask(@Valid @RequestBody WithdrawCreateTaskRequest request) {
|
||||
return ApiResponse.success(withdrawTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "提交取款任务结果", description = "Python 回传取款处理结果。支持同一 taskId 多次增量提交;shopDone=true 表示该店铺已处理完成,可进入文件组装。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
@Parameter(description = "取款任务 ID,即 createTask 返回的 taskId。", required = true, example = "200")
|
||||
@PathVariable Long taskId,
|
||||
@Valid @RequestBody WithdrawSubmitResultRequest request,
|
||||
jakarta.servlet.http.HttpServletResponse response) {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
withdrawTaskService.submitResult(taskId, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@GetMapping("/results/{resultId}/download")
|
||||
@Operation(summary = "下载取款结果文件", description = "按结果记录 ID 下载后端组装好的取款 Excel 文件。只有文件组装完成后才有可下载地址。")
|
||||
public void downloadResult(
|
||||
@Parameter(description = "结果记录 ID,即 history/items 中的 resultId。", required = true, example = "1001")
|
||||
@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 = withdrawTaskService.resolveResultDownloadUrl(resultId, userId);
|
||||
String filename = withdrawTaskService.resolveResultDownloadFilename(resultId, userId);
|
||||
if (url == null || url.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "暂无可下载结果");
|
||||
}
|
||||
try {
|
||||
response.setContentType("application/octet-stream");
|
||||
DownloadHeaderUtil.setAttachment(response, filename);
|
||||
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 = "取款任务 ID。", required = true, example = "200")
|
||||
@PathVariable Long taskId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
withdrawTaskService.deleteTask(taskId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/history/{resultId}")
|
||||
@Operation(summary = "删除单条取款历史记录", description = "删除一条取款结果记录,并同步重算父任务状态。")
|
||||
public ApiResponse<Void> deleteHistory(
|
||||
@Parameter(description = "结果记录 ID,即 history/items 中的 resultId。", required = true, example = "1001")
|
||||
@PathVariable Long resultId,
|
||||
@Parameter(name = "user_id", description = "当前登录用户 ID。", required = true, in = ParameterIn.QUERY, example = "1")
|
||||
@RequestParam("user_id") Long userId) {
|
||||
withdrawTaskService.deleteHistory(resultId, userId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.nanri.aiimage.modules.withdraw.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface WithdrawShopCandidateMapper extends BaseMapper<WithdrawShopCandidateEntity> {
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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 lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量清空取款备选店铺请求体:按店铺名删除当前用户的备选记录")
|
||||
public class WithdrawCandidateClearRequest {
|
||||
|
||||
@NotNull
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID,数据按用户隔离", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@NotEmpty
|
||||
@JsonProperty("shop_names")
|
||||
@Schema(description = "需要从备选列表移除的店铺名称列表;通常为已成功创建取款任务的店铺", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"店铺甲\",\"店铺乙\"]")
|
||||
private List<String> shopNames = new ArrayList<>();
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建取款任务请求体:前端将已匹配的多个店铺合并为一个任务提交,后端创建任务和各店铺结果占位记录")
|
||||
public class WithdrawCreateTaskRequest {
|
||||
|
||||
@NotNull
|
||||
@JsonProperty("user_id")
|
||||
@Schema(description = "当前用户 ID,任务归属和数据隔离使用", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Long userId;
|
||||
|
||||
@JsonProperty("reserved_amount")
|
||||
@Schema(description = "保留金额筛选条件;创建任务时随任务快照保存,并下发给 Python 侧用于计算取款金额", example = "100.00")
|
||||
private BigDecimal reservedAmount;
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "本次任务包含的店铺列表;多个店铺会合并为同一个取款任务并串行处理", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<WithdrawTaskItemDto> items = new ArrayList<>();
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.nanri.aiimage.modules.withdraw.model.enums.WithdrawStatus;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Data
|
||||
@Schema(description = "取款结果明细行:对应 Excel 中一个店铺下某个国家的一行数据")
|
||||
public class WithdrawRowDto {
|
||||
|
||||
@JsonProperty("country")
|
||||
@JsonAlias({"countryCode", "country_code"})
|
||||
@Schema(description = "国家或站点代码;常用值 DE、UK/GB、FR、IT、ES,生成 Excel 时会显示为中文国家名", example = "DE")
|
||||
private String country;
|
||||
|
||||
@JsonProperty("shopAmount")
|
||||
@JsonAlias({"shop_amount", "amount", "availableAmount", "available_amount"})
|
||||
@Schema(description = "店铺可用资金金额;参与“所有店铺金额总数”和店铺小计累加", example = "1234.56")
|
||||
private BigDecimal shopAmount;
|
||||
|
||||
@JsonProperty("withdrawAmount")
|
||||
@JsonAlias({"withdraw_amount", "withdrawalAmount", "withdrawal_amount"})
|
||||
@Schema(description = "本次取款金额;为负数时 Excel 的“取款金额”和“状态”留空,且不计入取款金额总数", example = "1000.00")
|
||||
private BigDecimal withdrawAmount;
|
||||
|
||||
@Schema(
|
||||
description = "取款状态:ZERO_AVAILABLE=可用资金为0不可取款;SUCCESS=成功;BALANCE_FORBIDDEN=有余额禁止取出;NEGATIVE_WITHDRAW_BLANK=取款金额为负数则留空",
|
||||
example = "SUCCESS")
|
||||
private WithdrawStatus status;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
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 = "Python 回传的单个店铺取款结果:成功时带 rows,失败时带 error")
|
||||
public class WithdrawShopPayloadDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@JsonAlias({"shop_name"})
|
||||
@Schema(description = "店铺名称,用于匹配任务内的店铺结果记录;建议使用创建任务时的 shopName", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "错误信息;非空表示该店铺处理失败,后端会写入失败原因并不参与 Excel 成功数据组装")
|
||||
private String error;
|
||||
|
||||
@JsonProperty("shopDone")
|
||||
@JsonAlias({"shop_done"})
|
||||
@Schema(description = "该店铺是否已处理完成;true 表示该店进入终态,可参与任务完成判断和文件组装", example = "true")
|
||||
private Boolean shopDone;
|
||||
|
||||
@JsonProperty("submissionId")
|
||||
@JsonAlias({"submission_id"})
|
||||
@Schema(description = "Python 侧提交批次或流水号,可用于排查重复回传和日志关联")
|
||||
private String submissionId;
|
||||
|
||||
@JsonProperty("rows")
|
||||
@JsonAlias({"items", "countryResults", "country_results"})
|
||||
@Schema(description = "该店铺各国家取款明细行;生成 Excel 时按店铺分组写入,店铺之间空一行")
|
||||
private List<WithdrawRowDto> rows = new ArrayList<>();
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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 = "Python 回传取款任务结果请求体:按店铺提交取款明细、错误信息和完成标记")
|
||||
public class WithdrawSubmitResultRequest {
|
||||
|
||||
@Valid
|
||||
@NotEmpty
|
||||
@Schema(description = "本次回传涉及的店铺结果列表;可增量回传,店名用于匹配任务内的店铺结果记录", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<WithdrawShopPayloadDto> shops = new ArrayList<>();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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 WithdrawTaskBatchRequest {
|
||||
@Schema(description = "待查询的取款任务 ID 列表;不存在或非取款模块任务会返回到 missingTaskIds", requiredMode = Schema.RequiredMode.REQUIRED, example = "[200,201]")
|
||||
private List<Long> taskIds = new ArrayList<>();
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建取款任务的单个店铺项:来自匹配店铺接口返回的 items 元素")
|
||||
public class WithdrawTaskItemDto {
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称,用于展示、结果匹配和生成 Excel 店铺分组", requiredMode = Schema.RequiredMode.REQUIRED, example = "测试店铺A")
|
||||
private String shopName;
|
||||
|
||||
@Schema(description = "是否已匹配到紫鸟店铺;只有 true 的店铺才应创建取款任务", example = "true")
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
@Schema(description = "紫鸟侧店铺 ID,推送给 Python 打开对应店铺时使用", example = "15206")
|
||||
private String shopId;
|
||||
|
||||
@Schema(description = "店铺平台,例如亚马逊", example = "亚马逊")
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
@Schema(description = "公司名称或店铺主体名称")
|
||||
private String companyName;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE;具体值以后端匹配服务为准", example = "MATCHED")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
@Schema(description = "匹配状态说明,供前端展示和排查未匹配原因")
|
||||
private String matchMessage;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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_withdraw_shop_candidate")
|
||||
public class WithdrawShopCandidateEntity {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String shopName;
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.enums;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||
import com.fasterxml.jackson.annotation.JsonValue;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
@Schema(
|
||||
description = "取款明细状态枚举:ZERO_AVAILABLE=可用资金为0不可取款;SUCCESS=成功;BALANCE_FORBIDDEN=有余额禁止取出;NEGATIVE_WITHDRAW_BLANK=取款金额为负数则留空")
|
||||
public enum WithdrawStatus {
|
||||
ZERO_AVAILABLE("ZERO_AVAILABLE", "可用资金为0不可取款"),
|
||||
SUCCESS("SUCCESS", "成功"),
|
||||
BALANCE_FORBIDDEN("BALANCE_FORBIDDEN", "有余额禁止取出"),
|
||||
NEGATIVE_WITHDRAW_BLANK("NEGATIVE_WITHDRAW_BLANK", "取款金额为负数则留空");
|
||||
|
||||
private final String code;
|
||||
private final String displayName;
|
||||
|
||||
WithdrawStatus(String code, String displayName) {
|
||||
this.code = code;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
@JsonValue
|
||||
public String getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@JsonCreator
|
||||
public static WithdrawStatus from(Object raw) {
|
||||
if (raw == null) {
|
||||
return SUCCESS;
|
||||
}
|
||||
String value = String.valueOf(raw).trim();
|
||||
if (value.isEmpty()) {
|
||||
return NEGATIVE_WITHDRAW_BLANK;
|
||||
}
|
||||
for (WithdrawStatus status : values()) {
|
||||
if (status.code.equalsIgnoreCase(value) || status.displayName.equals(value)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return switch (value) {
|
||||
case "可用资金为0不可取款" -> ZERO_AVAILABLE;
|
||||
case "成功" -> SUCCESS;
|
||||
case "有余额禁止取出" -> BALANCE_FORBIDDEN;
|
||||
case "取款金额为负数则留空", "取款金额为负数", "留空" -> NEGATIVE_WITHDRAW_BLANK;
|
||||
default -> SUCCESS;
|
||||
};
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "创建取款任务响应体:返回新任务 ID 和各店铺初始结果快照")
|
||||
public class WithdrawCreateTaskVo {
|
||||
@Schema(description = "新建取款任务 ID,后续推送 Python 队列和回传结果都使用该值", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@Schema(description = "任务内店铺结果快照;每个店铺一项,包含 resultId、店铺信息和初始任务状态")
|
||||
private List<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package com.nanri.aiimage.modules.withdraw.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 WithdrawHistoryVo {
|
||||
@Schema(description = "取款结果项列表;一个任务可能包含多个店铺,任务级汇总项的 shops 字段会包含店铺明细")
|
||||
private List<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.vo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawRowDto;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "取款结果项:可表示任务级汇总,也可表示任务下的单个店铺结果")
|
||||
public class WithdrawResultItemVo {
|
||||
@Schema(description = "结果记录 ID;下载结果、删除单条历史记录时使用。任务级汇总项可能取任务下首个结果 ID", example = "1001")
|
||||
private Long resultId;
|
||||
|
||||
@Schema(description = "所属取款任务 ID;一个任务可包含多个店铺", example = "200")
|
||||
private Long taskId;
|
||||
|
||||
@JsonProperty("shopName")
|
||||
@Schema(description = "店铺名称;任务级汇总项可能为多个店铺名拼接,单店项为具体店铺名", example = "测试店铺A")
|
||||
private String shopName;
|
||||
|
||||
@JsonProperty("shopId")
|
||||
@Schema(description = "紫鸟侧店铺 ID")
|
||||
private String shopId;
|
||||
|
||||
@Schema(description = "平台,例如亚马逊")
|
||||
private String platform;
|
||||
|
||||
@JsonProperty("companyName")
|
||||
@Schema(description = "公司名称或店铺主体名称")
|
||||
private String companyName;
|
||||
|
||||
@Schema(description = "创建任务时该店铺是否匹配成功")
|
||||
private boolean matched;
|
||||
|
||||
@JsonProperty("matchStatus")
|
||||
@Schema(description = "匹配状态码,例如 MATCHED、PENDING、CONFLICT、INDEX_STALE")
|
||||
private String matchStatus;
|
||||
|
||||
@JsonProperty("matchMessage")
|
||||
@Schema(description = "匹配状态说明")
|
||||
private String matchMessage;
|
||||
|
||||
@JsonProperty("taskStatus")
|
||||
@Schema(description = "任务状态:RUNNING=执行中,SUCCESS=成功结束,FAILED=失败结束", example = "RUNNING")
|
||||
private String taskStatus;
|
||||
|
||||
@JsonProperty("reservedAmount")
|
||||
@Schema(description = "创建任务时传入的保留金额筛选条件", example = "100.00")
|
||||
private BigDecimal reservedAmount;
|
||||
|
||||
@Schema(description = "该结果项是否成功;true=成功,false=失败,null=尚未完成")
|
||||
private Boolean success;
|
||||
|
||||
@Schema(description = "失败原因;success=false 时通常有值")
|
||||
private String error;
|
||||
|
||||
@Schema(description = "任务或结果记录创建时间")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Schema(description = "任务或结果记录完成时间")
|
||||
private LocalDateTime finishedAt;
|
||||
|
||||
@Schema(description = "生成的取款 Excel 文件名;文件组装完成后有值")
|
||||
private String outputFilename;
|
||||
|
||||
@Schema(description = "结果文件下载地址;也可通过 GET /api/withdraw/results/{resultId}/download 下载")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "后台文件组装任务 ID;用于排查 Excel 生成任务")
|
||||
private Long fileJobId;
|
||||
|
||||
@Schema(description = "文件组装状态:PENDING/RUNNING/SUCCESS/FAILED 等,以文件任务表状态为准")
|
||||
private String fileStatus;
|
||||
|
||||
@Schema(description = "文件组装失败原因;fileStatus=FAILED 时通常有值")
|
||||
private String fileError;
|
||||
|
||||
@Schema(description = "结果文件是否已可下载;true 表示 downloadUrl 或下载接口可用")
|
||||
private Boolean fileReady;
|
||||
|
||||
@Schema(description = "该店铺的取款明细行;每行对应一个国家/站点")
|
||||
private List<WithdrawRowDto> rows = new ArrayList<>();
|
||||
|
||||
@Schema(description = "任务级汇总项下的店铺结果列表;单店项通常为空")
|
||||
private List<WithdrawResultItemVo> shops = new ArrayList<>();
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.nanri.aiimage.modules.withdraw.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Schema(description = "批量查询取款任务进度响应体:返回任务快照和未命中的任务 ID")
|
||||
public class WithdrawTaskBatchVo {
|
||||
@Schema(description = "已查询到的任务或店铺结果快照;包含任务状态、文件状态、明细行和下载地址")
|
||||
private List<WithdrawResultItemVo> items = new ArrayList<>();
|
||||
|
||||
@Schema(description = "请求中不存在、已删除或不属于取款模块的任务 ID 列表")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.nanri.aiimage.modules.withdraw.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawRowDto;
|
||||
import com.nanri.aiimage.modules.withdraw.model.enums.WithdrawStatus;
|
||||
import com.nanri.aiimage.modules.withdraw.model.vo.WithdrawResultItemVo;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.poi.ss.usermodel.Cell;
|
||||
import org.apache.poi.ss.usermodel.Row;
|
||||
import org.apache.poi.ss.usermodel.Sheet;
|
||||
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WithdrawExcelAssemblyService {
|
||||
|
||||
private static final String[] HEADER = {
|
||||
"店铺名", "国家", "店铺金额", "取款金额", "状态", "",
|
||||
"所有店铺金额总数", "所有店铺取款金额总数"
|
||||
};
|
||||
private static final Map<String, String> COUNTRY_NAMES = Map.of(
|
||||
"DE", "德国",
|
||||
"UK", "英国",
|
||||
"GB", "英国",
|
||||
"FR", "法国",
|
||||
"IT", "意大利",
|
||||
"ES", "西班牙"
|
||||
);
|
||||
|
||||
public void writeWorkbook(File outputXlsx, List<WithdrawResultItemVo> items) {
|
||||
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
|
||||
workbook.setCompressTempFiles(true);
|
||||
try (FileOutputStream outputStream = new FileOutputStream(outputXlsx)) {
|
||||
Sheet sheet = workbook.createSheet("Sheet1");
|
||||
writeHeader(sheet);
|
||||
BigDecimal totalShopAmount = totalShopAmount(items);
|
||||
BigDecimal totalWithdrawAmount = totalWithdrawAmount(items);
|
||||
|
||||
int rowIndex = 1;
|
||||
boolean totalsWritten = false;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
List<WithdrawRowDto> rows = item.getRows() == null ? List.of() : item.getRows();
|
||||
if (rows.isEmpty()) {
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
row.createCell(0).setCellValue(safe(item.getShopName()));
|
||||
if (!totalsWritten) {
|
||||
writeTotals(row, totalShopAmount, totalWithdrawAmount);
|
||||
totalsWritten = true;
|
||||
}
|
||||
} else {
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
WithdrawRowDto data = rows.get(i);
|
||||
Row row = sheet.createRow(rowIndex++);
|
||||
row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : "");
|
||||
row.createCell(1).setCellValue(formatCountry(data == null ? null : data.getCountry()));
|
||||
writeMoney(row.createCell(2), data == null ? null : data.getShopAmount());
|
||||
if (!isNegativeWithdraw(data)) {
|
||||
writeMoney(row.createCell(3), data == null ? null : data.getWithdrawAmount());
|
||||
} else {
|
||||
row.createCell(3).setCellValue("");
|
||||
}
|
||||
row.createCell(4).setCellValue(formatStatus(data));
|
||||
if (!totalsWritten) {
|
||||
writeTotals(row, totalShopAmount, totalWithdrawAmount);
|
||||
totalsWritten = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row subtotal = sheet.createRow(rowIndex++);
|
||||
writeMoney(subtotal.createCell(2), shopSubtotal(rows));
|
||||
writeMoney(subtotal.createCell(3), withdrawSubtotal(rows));
|
||||
rowIndex++;
|
||||
}
|
||||
workbook.write(outputStream);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[withdraw] write workbook failed: {}", ex.getMessage());
|
||||
throw new BusinessException("生成取款 Excel 失败: " + ex.getMessage());
|
||||
} finally {
|
||||
try {
|
||||
workbook.close();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
workbook.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public int countRows(List<WithdrawResultItemVo> items) {
|
||||
int count = 0;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
int dataRows = Math.max(1, item.getRows() == null ? 0 : item.getRows().size());
|
||||
count += dataRows + 2;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void writeHeader(Sheet sheet) {
|
||||
Row headerRow = sheet.createRow(0);
|
||||
for (int columnIndex = 0; columnIndex < HEADER.length; columnIndex++) {
|
||||
headerRow.createCell(columnIndex).setCellValue(HEADER[columnIndex]);
|
||||
sheet.setColumnWidth(columnIndex, (columnIndex == 0 ? 24 : 18) * 256);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeTotals(Row row, BigDecimal totalShopAmount, BigDecimal totalWithdrawAmount) {
|
||||
writeMoney(row.createCell(6), totalShopAmount);
|
||||
writeMoney(row.createCell(7), totalWithdrawAmount);
|
||||
}
|
||||
|
||||
private BigDecimal totalShopAmount(List<WithdrawResultItemVo> items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
total = total.add(shopSubtotal(item.getRows()));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal totalWithdrawAmount(List<WithdrawResultItemVo> items) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawResultItemVo item : items == null ? List.<WithdrawResultItemVo>of() : items) {
|
||||
if (item == null || Boolean.FALSE.equals(item.getSuccess())) {
|
||||
continue;
|
||||
}
|
||||
total = total.add(withdrawSubtotal(item.getRows()));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal shopSubtotal(List<WithdrawRowDto> rows) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawRowDto row : rows == null ? List.<WithdrawRowDto>of() : rows) {
|
||||
if (row != null && row.getShopAmount() != null) {
|
||||
total = total.add(row.getShopAmount());
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private BigDecimal withdrawSubtotal(List<WithdrawRowDto> rows) {
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WithdrawRowDto row : rows == null ? List.<WithdrawRowDto>of() : rows) {
|
||||
if (row != null && row.getWithdrawAmount() != null && !isNegativeWithdraw(row)) {
|
||||
total = total.add(row.getWithdrawAmount());
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private boolean isNegativeWithdraw(WithdrawRowDto row) {
|
||||
if (row == null) {
|
||||
return false;
|
||||
}
|
||||
return WithdrawStatus.NEGATIVE_WITHDRAW_BLANK.equals(row.getStatus())
|
||||
|| (row.getWithdrawAmount() != null && row.getWithdrawAmount().compareTo(BigDecimal.ZERO) < 0);
|
||||
}
|
||||
|
||||
private String formatStatus(WithdrawRowDto row) {
|
||||
if (isNegativeWithdraw(row)) {
|
||||
return "";
|
||||
}
|
||||
WithdrawStatus status = row == null ? null : row.getStatus();
|
||||
return status == null ? "" : status.getDisplayName();
|
||||
}
|
||||
|
||||
private String formatCountry(String country) {
|
||||
if (country == null || country.isBlank()) {
|
||||
return "";
|
||||
}
|
||||
String value = country.trim();
|
||||
return COUNTRY_NAMES.getOrDefault(value.toUpperCase(), value);
|
||||
}
|
||||
|
||||
private void writeMoney(Cell cell, BigDecimal value) {
|
||||
if (value == null) {
|
||||
cell.setCellValue("");
|
||||
return;
|
||||
}
|
||||
cell.setCellValue(value.doubleValue());
|
||||
}
|
||||
|
||||
private String safe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.nanri.aiimage.modules.withdraw.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
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.withdraw.mapper.WithdrawShopCandidateMapper;
|
||||
import com.nanri.aiimage.modules.withdraw.model.entity.WithdrawShopCandidateEntity;
|
||||
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 WithdrawResolveService {
|
||||
|
||||
private final WithdrawShopCandidateMapper candidateMapper;
|
||||
private final ZiniaoShopSwitchService ziniaoShopSwitchService;
|
||||
|
||||
public List<ProductRiskCandidateVo> listCandidates(Long userId) {
|
||||
validateUserId(userId);
|
||||
List<WithdrawShopCandidateEntity> rows = candidateMapper.selectList(
|
||||
new LambdaQueryWrapper<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::getUserId, userId)
|
||||
.orderByDesc(WithdrawShopCandidateEntity::getId));
|
||||
List<ProductRiskCandidateVo> out = new ArrayList<>();
|
||||
for (WithdrawShopCandidateEntity row : rows) {
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(row.getId());
|
||||
vo.setShopName(row.getShopName());
|
||||
vo.setCreatedAt(row.getCreatedAt());
|
||||
out.add(vo);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@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() : "存在多个同名店铺,请人工确认");
|
||||
}
|
||||
WithdrawShopCandidateEntity existing = candidateMapper.selectOne(new LambdaQueryWrapper<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::getUserId, request.getUserId())
|
||||
.eq(WithdrawShopCandidateEntity::getShopName, normalized)
|
||||
.last("limit 1"));
|
||||
if (existing == null) {
|
||||
existing = new WithdrawShopCandidateEntity();
|
||||
existing.setUserId(request.getUserId());
|
||||
existing.setShopName(normalized);
|
||||
existing.setCreatedAt(LocalDateTime.now());
|
||||
candidateMapper.insert(existing);
|
||||
}
|
||||
ProductRiskCandidateVo vo = new ProductRiskCandidateVo();
|
||||
vo.setId(existing.getId());
|
||||
vo.setShopName(existing.getShopName());
|
||||
vo.setCreatedAt(existing.getCreatedAt());
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCandidate(Long userId, Long id) {
|
||||
validateUserId(userId);
|
||||
if (id == null || id <= 0) {
|
||||
throw new BusinessException("id 不合法");
|
||||
}
|
||||
WithdrawShopCandidateEntity row = candidateMapper.selectById(id);
|
||||
if (row == null || !userId.equals(row.getUserId())) {
|
||||
throw new BusinessException("记录不存在");
|
||||
}
|
||||
candidateMapper.deleteById(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteCandidatesByShopNames(Long userId, List<String> shopNames) {
|
||||
validateUserId(userId);
|
||||
LinkedHashSet<String> normalizedNames = new LinkedHashSet<>();
|
||||
for (String raw : shopNames == null ? List.<String>of() : shopNames) {
|
||||
String normalized = ziniaoShopSwitchService.normalizeShopName(raw);
|
||||
if (!normalized.isBlank()) {
|
||||
normalizedNames.add(normalized);
|
||||
}
|
||||
}
|
||||
if (normalizedNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
candidateMapper.delete(new LambdaQueryWrapper<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::getUserId, userId)
|
||||
.in(WithdrawShopCandidateEntity::getShopName, normalizedNames));
|
||||
}
|
||||
|
||||
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<WithdrawShopCandidateEntity>()
|
||||
.eq(WithdrawShopCandidateEntity::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 不合法");
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.nanri.aiimage.modules.withdraw.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.TaskPressureProperties;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService;
|
||||
import com.nanri.aiimage.modules.withdraw.model.dto.WithdrawShopPayloadDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WithdrawTaskCacheService {
|
||||
|
||||
private static final String MODULE_TYPE = "WITHDRAW";
|
||||
private static final long PAYLOAD_TTL_HOURS = 24;
|
||||
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final TaskPressureProperties taskPressureProperties;
|
||||
private final TaskScopePayloadStorageService taskScopePayloadStorageService;
|
||||
private final ConcurrentHashMap<Long, LocalTaskEntityCacheEntry> taskEntityLocalCache = new ConcurrentHashMap<>();
|
||||
|
||||
public WithdrawShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) {
|
||||
return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, WithdrawShopPayloadDto.class);
|
||||
}
|
||||
|
||||
public void saveShopMergedPayload(Long taskId, String shopKey, WithdrawShopPayloadDto payload) {
|
||||
if (taskId == null || taskId <= 0 || shopKey == null || shopKey.isBlank() || payload == null) {
|
||||
return;
|
||||
}
|
||||
taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload);
|
||||
touchTaskHeartbeat(taskId);
|
||||
}
|
||||
|
||||
public void removeShopMergedPayload(Long taskId, String shopKey) {
|
||||
taskScopePayloadStorageService.removeScopePayload(taskId, MODULE_TYPE, shopKey);
|
||||
}
|
||||
|
||||
public Map<String, WithdrawShopPayloadDto> getAllShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.getAllScopePayload(taskId, MODULE_TYPE, WithdrawShopPayloadDto.class);
|
||||
}
|
||||
|
||||
public boolean hasAnyShopMergedPayload(Long taskId) {
|
||||
return taskScopePayloadStorageService.hasAnyScopePayload(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
public Map<Long, Long> getTaskHeartbeatMillisBatch(List<Long> taskIds) {
|
||||
Map<Long, Long> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList();
|
||||
if (normalized.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> keys = normalized.stream().map(this::buildTaskHeartbeatKey).toList();
|
||||
List<String> values;
|
||||
try {
|
||||
values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[withdraw-cache] batch get heartbeat degraded taskIds={} msg={}", normalized, ex.getMessage());
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < normalized.size(); i++) {
|
||||
String raw = values != null && i < values.size() ? values.get(i) : null;
|
||||
try {
|
||||
result.put(normalized.get(i), raw == null || raw.isBlank() ? 0L : Long.parseLong(raw));
|
||||
} catch (NumberFormatException ignored) {
|
||||
result.put(normalized.get(i), 0L);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public void touchTaskHeartbeat(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
stringRedisTemplate.opsForValue().set(
|
||||
buildTaskHeartbeatKey(taskId),
|
||||
String.valueOf(Instant.now().toEpochMilli()),
|
||||
Duration.ofHours(PAYLOAD_TTL_HOURS));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[withdraw-cache] touch heartbeat degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void deleteTaskCache(Long taskId) {
|
||||
if (taskId == null || taskId <= 0) {
|
||||
return;
|
||||
}
|
||||
taskEntityLocalCache.remove(taskId);
|
||||
try {
|
||||
stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId));
|
||||
stringRedisTemplate.delete(buildTaskEntityKey(taskId));
|
||||
} catch (Exception ex) {
|
||||
log.warn("[withdraw-cache] delete cache degraded taskId={} msg={}", taskId, ex.getMessage());
|
||||
}
|
||||
taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE);
|
||||
}
|
||||
|
||||
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(List<Long> taskIds) {
|
||||
Map<Long, FileTaskEntity> result = new LinkedHashMap<>();
|
||||
if (taskIds == null || taskIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<Long> normalized = taskIds.stream().filter(id -> id != null && id > 0).distinct().toList();
|
||||
long now = System.currentTimeMillis();
|
||||
List<Long> missingIds = new ArrayList<>();
|
||||
for (Long taskId : normalized) {
|
||||
LocalTaskEntityCacheEntry cached = taskEntityLocalCache.get(taskId);
|
||||
if (cached != null && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis())) {
|
||||
result.put(taskId, objectMapper.convertValue(cached.task(), FileTaskEntity.class));
|
||||
} else {
|
||||
missingIds.add(taskId);
|
||||
}
|
||||
}
|
||||
if (missingIds.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
List<String> keys = missingIds.stream().map(this::buildTaskEntityKey).toList();
|
||||
List<String> values;
|
||||
try {
|
||||
values = stringRedisTemplate.opsForValue().multiGet(keys);
|
||||
} catch (Exception ex) {
|
||||
log.warn("[withdraw-cache] batch get task cache degraded taskIds={} msg={}", missingIds, ex.getMessage());
|
||||
return result;
|
||||
}
|
||||
for (int i = 0; i < missingIds.size(); i++) {
|
||||
Long taskId = missingIds.get(i);
|
||||
String raw = values != null && i < values.size() ? values.get(i) : null;
|
||||
if (raw == null || raw.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
FileTaskEntity task = objectMapper.readValue(raw, FileTaskEntity.class);
|
||||
result.put(taskId, task);
|
||||
taskEntityLocalCache.put(taskId, new LocalTaskEntityCacheEntry(now, task));
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private String buildTaskHeartbeatKey(Long taskId) {
|
||||
return "withdraw:task:heartbeat:" + taskId;
|
||||
}
|
||||
|
||||
private String buildTaskEntityKey(Long taskId) {
|
||||
return "withdraw:task:entity:" + taskId;
|
||||
}
|
||||
|
||||
private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {}
|
||||
}
|
||||
+1071
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user