后端优化修复问题

This commit is contained in:
super
2026-06-18 18:53:16 +08:00
parent 021b0c618b
commit 0ecf5cd45a
44 changed files with 4275 additions and 81 deletions
@@ -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);
}
}
@@ -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> {
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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;
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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;
}
@@ -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;
}
@@ -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;
};
}
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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<>();
}
@@ -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;
}
}
@@ -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 不合法");
}
}
}
@@ -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) {}
}