diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java new file mode 100644 index 0000000..fbaf64d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -0,0 +1,157 @@ +package com.nanri.aiimage.modules.pricetrack.controller; + +import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +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.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.net.URLEncoder; +import java.io.InputStream; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/price-track") +@Tag(name = "跟价", description = "亚马逊跟价处理:备选店铺、紫鸟匹配、创建任务、Python队列与回传。") +public class PriceTrackController { + + private final PriceTrackService priceTrackService; + private final PriceTrackTaskService priceTrackTaskService; + + @GetMapping("/candidates") + @Operation(summary = "查询备选店铺列表") + public ApiResponse> listCandidates( + @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY) + @RequestParam("user_id") Long userId) { + return ApiResponse.success(priceTrackService.listCandidates(userId)); + } + + @PostMapping("/candidates") + @Operation(summary = "新增备选店铺") + public ApiResponse addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) { + return ApiResponse.success(priceTrackService.addCandidate(request)); + } + + @DeleteMapping("/candidates/{id}") + @Operation(summary = "删除备选店铺") + public ApiResponse deleteCandidate( + @PathVariable Long id, + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + priceTrackService.deleteCandidate(userId, id); + return ApiResponse.success(null); + } + + @GetMapping("/country-preference") + @Operation(summary = "查询国家处理顺序") + public ApiResponse getCountryPreference( + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + return ApiResponse.success(priceTrackService.getCountryPreference(userId)); + } + + @PutMapping("/country-preference") + @Operation(summary = "保存国家处理顺序") + public ApiResponse saveCountryPreference( + @Valid @RequestBody PriceTrackCountryPreferenceSaveRequest request) { + return ApiResponse.success(priceTrackService.saveCountryPreference(request)); + } + + @PostMapping("/match-shops") + @Operation(summary = "批量匹配店铺") + public ApiResponse matchShops(@Valid @RequestBody PriceTrackMatchShopsRequest request) { + return ApiResponse.success(priceTrackService.matchShops(request)); + } + + @GetMapping("/dashboard") + @Operation(summary = "统计看板") + public ApiResponse dashboard( + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + return ApiResponse.success(priceTrackTaskService.dashboard(userId)); + } + + @GetMapping("/history") + @Operation(summary = "处理记录列表") + public ApiResponse history( + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + return ApiResponse.success(priceTrackTaskService.listHistory(userId)); + } + + @DeleteMapping("/history/{resultId}") + @Operation(summary = "删除单条结果记录") + public ApiResponse deleteHistory( + @PathVariable Long resultId, + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + priceTrackTaskService.deleteHistory(resultId, userId); + return ApiResponse.success(null); + } + + @PostMapping("/tasks") + @Operation(summary = "创建跟价处理任务") + public ApiResponse createTask(@Valid @RequestBody PriceTrackCreateTaskRequest request) { + return ApiResponse.success(priceTrackTaskService.createTask(request)); + } + + @DeleteMapping("/tasks/{taskId}") + @Operation(summary = "删除整条任务") + public ApiResponse deleteTask( + @PathVariable Long taskId, + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId) { + priceTrackTaskService.deleteTask(taskId, userId); + return ApiResponse.success(null); + } + + @PostMapping("/tasks/{taskId}/result") + @Operation(summary = "Python回传处理结果") + public ApiResponse submitResult(@PathVariable Long taskId, + @Valid @RequestBody PriceTrackSubmitResultRequest request) { + priceTrackTaskService.submitResult(taskId, request); + return ApiResponse.success(null); + } + + @GetMapping("/results/{resultId}/download") + @Operation(summary = "下载结果文件") + public void downloadResult( + @PathVariable Long resultId, + @Parameter(name = "user_id", required = true, in = ParameterIn.QUERY) @RequestParam("user_id") Long userId, + jakarta.servlet.http.HttpServletResponse response) { + String url = priceTrackTaskService.resolveResultDownloadUrl(resultId, userId); + String filename = priceTrackTaskService.resolveResultDownloadFilename(resultId, userId); + if (url == null || url.isBlank()) { + throw new ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND, "暂无可下载结果"); + } + try { + String encodedFilename = URLEncoder.encode(filename, StandardCharsets.UTF_8).replace("+", "%20"); + response.setContentType("application/octet-stream"); + response.setHeader(org.springframework.http.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(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR, "下载失败"); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackCountryPrefMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackCountryPrefMapper.java new file mode 100644 index 0000000..fa3d117 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackCountryPrefMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.pricetrack.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackCountryPrefEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PriceTrackCountryPrefMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackShopCandidateMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackShopCandidateMapper.java new file mode 100644 index 0000000..a2f724c --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/mapper/PriceTrackShopCandidateMapper.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.pricetrack.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidateEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PriceTrackShopCandidateMapper extends BaseMapper { +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCandidateAddRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCandidateAddRequest.java new file mode 100644 index 0000000..52130b5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCandidateAddRequest.java @@ -0,0 +1,14 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class PriceTrackCandidateAddRequest { + @NotNull(message = "user_id不能为空") + private Long userId; + + @NotBlank(message = "店铺名不能为空") + private String shopName; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCountryPreferenceSaveRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCountryPreferenceSaveRequest.java new file mode 100644 index 0000000..398bf35 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCountryPreferenceSaveRequest.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.util.List; + +@Data +public class PriceTrackCountryPreferenceSaveRequest { + @NotNull(message = "user_id不能为空") + private Long userId; + + @NotEmpty(message = "国家列表不能为空") + private List countryCodes; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java new file mode 100644 index 0000000..b57fdf2 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackCreateTaskRequest.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.util.List; +import java.util.Map; + +@Data +public class PriceTrackCreateTaskRequest { + @NotNull(message = "user_id不能为空") + private Long userId; + + /** 跟价模式:status_mode=按商品状态(全量), asin_mode=按指定ASIN(自定义) */ + private boolean statusMode; + private boolean asinMode; + + /** 匹配好的店铺队列项 */ + @NotEmpty(message = "店铺列表不能为空") + private List> items; + + /** ASIN文档路径(asinMode时使用) */ + private List asinFiles; + + /** 国家代码列表 */ + @NotEmpty(message = "国家列表不能为空") + private List countryCodes; + + /** 价格区间 */ + private Double minPrice; + private Double maxPrice; + + /** 价差比例(%) */ + private Double priceDiffPercent; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java new file mode 100644 index 0000000..56ff150 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackMatchShopsRequest.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import java.util.List; + +@Data +public class PriceTrackMatchShopsRequest { + @NotNull(message = "user_id不能为空") + private Long userId; + + @NotEmpty(message = "店铺列表不能为空") + private List shopNames; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java new file mode 100644 index 0000000..4ea279b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/dto/PriceTrackSubmitResultRequest.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.pricetrack.model.dto; + +import lombok.Data; +import java.util.List; +import java.util.Map; + +@Data +public class PriceTrackSubmitResultRequest { + private List shops; + + @Data + public static class ShopResult { + private String shopName; + private Map> countries; + private String error; + private Boolean success; + } + + @Data + public static class AsinResult { + private String asin; + private String currentPrice; + private String competitorPrice; + private String action; + private Boolean done; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackCountryPrefEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackCountryPrefEntity.java new file mode 100644 index 0000000..7000ae3 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackCountryPrefEntity.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.pricetrack.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +@Data +@TableName("biz_price_track_country_pref") +public class PriceTrackCountryPrefEntity { + @TableId(type = IdType.AUTO) + private Long userId; + + @TableField("country_codes_json") + private String countryCodesJson; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackShopCandidateEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackShopCandidateEntity.java new file mode 100644 index 0000000..276465f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/entity/PriceTrackShopCandidateEntity.java @@ -0,0 +1,17 @@ +package com.nanri.aiimage.modules.pricetrack.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_price_track_shop_candidate") +public class PriceTrackShopCandidateEntity { + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String shopName; + private LocalDateTime createdAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCandidateVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCandidateVo.java new file mode 100644 index 0000000..df9da27 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCandidateVo.java @@ -0,0 +1,11 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class PriceTrackCandidateVo { + private Long id; + private String shopName; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCountryPreferenceVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCountryPreferenceVo.java new file mode 100644 index 0000000..ebd9010 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCountryPreferenceVo.java @@ -0,0 +1,10 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import java.util.List; + +@Data +public class PriceTrackCountryPreferenceVo { + private Long userId; + private List countryCodes; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java new file mode 100644 index 0000000..322031d --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackCreateTaskVo.java @@ -0,0 +1,11 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import java.util.List; +import java.util.Map; + +@Data +public class PriceTrackCreateTaskVo { + private Long taskId; + private List> items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackDashboardVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackDashboardVo.java new file mode 100644 index 0000000..451d1e8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackDashboardVo.java @@ -0,0 +1,13 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class PriceTrackDashboardVo { + private int candidateCount; + private int processedTaskCount; + private int successTaskCount; + private int failedTaskCount; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackHistoryVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackHistoryVo.java new file mode 100644 index 0000000..d56f5e5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackHistoryVo.java @@ -0,0 +1,9 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import java.util.List; + +@Data +public class PriceTrackHistoryVo { + private List items; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java new file mode 100644 index 0000000..37090bc --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackMatchShopsVo.java @@ -0,0 +1,20 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import java.util.List; + +@Data +public class PriceTrackMatchShopsVo { + private List items; + + @Data + public static class PriceTrackShopQueueItem { + private String shopName; + private Long shopId; + private String platform; + private String companyName; + private Boolean matched; + private String matchStatus; + private String matchMessage; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java new file mode 100644 index 0000000..9159012 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/model/vo/PriceTrackResultItemVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.pricetrack.model.vo; + +import lombok.Data; +import lombok.experimental.Accessors; + +@Data +@Accessors(chain = true) +public class PriceTrackResultItemVo { + private Long resultId; + private Long taskId; + private String shopName; + private Object shopId; + private String platform; + private String companyName; + private Boolean matched; + private String matchStatus; + private String matchMessage; + private String taskStatus; + private Boolean success; + private String error; + private String outputFilename; + private String downloadUrl; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java new file mode 100644 index 0000000..730fca8 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackService.java @@ -0,0 +1,245 @@ +package com.nanri.aiimage.modules.pricetrack.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackCountryPrefMapper; +import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCandidateAddRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCountryPreferenceSaveRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackMatchShopsRequest; +import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackCountryPrefEntity; +import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidateEntity; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCandidateVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCountryPreferenceVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackMatchShopsVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem; +import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; +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 PriceTrackService { + + public static final List DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES"); + public static final List SUPPORTED_COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); + + private final PriceTrackShopCandidateMapper candidateMapper; + private final PriceTrackCountryPrefMapper countryPrefMapper; + private final ObjectMapper objectMapper; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; + private final SkipPriceAsinService skipPriceAsinService; + + public List listCandidates(Long userId) { + if (userId == null || userId <= 0) { + throw new BusinessException("user_id 不合法"); + } + List rows = candidateMapper.selectList( + new LambdaQueryWrapper() + .eq(PriceTrackShopCandidateEntity::getUserId, userId) + .orderByDesc(PriceTrackShopCandidateEntity::getId)); + List list = new ArrayList<>(); + for (PriceTrackShopCandidateEntity row : rows) { + if (row == null) continue; + PriceTrackCandidateVo vo = new PriceTrackCandidateVo(); + vo.setId(row.getId()); + vo.setShopName(row.getShopName()); + list.add(vo); + } + return list; + } + + @Transactional + public PriceTrackCandidateVo addCandidate(PriceTrackCandidateAddRequest request) { + if (request.getUserId() == null || request.getUserId() <= 0) { + throw new BusinessException("user_id 不合法"); + } + 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() : "店铺索引未命中,无法加入备选"; + throw new BusinessException(hint); + } + if (ZiniaoShopIndexService.MATCH_STATUS_CONFLICT.equals(indexHit.getMatchStatus())) { + throw new BusinessException(indexHit.getMatchMessage() != null + ? indexHit.getMatchMessage() : "存在多个同名店铺,请人工确认"); + } + // 查重 + PriceTrackShopCandidateEntity existing = candidateMapper.selectOne( + new LambdaQueryWrapper() + .eq(PriceTrackShopCandidateEntity::getUserId, request.getUserId()) + .eq(PriceTrackShopCandidateEntity::getShopName, normalized) + .last("LIMIT 1")); + if (existing != null) { + PriceTrackCandidateVo vo = new PriceTrackCandidateVo(); + vo.setId(existing.getId()); + vo.setShopName(existing.getShopName()); + return vo; + } + PriceTrackShopCandidateEntity entity = new PriceTrackShopCandidateEntity(); + entity.setUserId(request.getUserId()); + entity.setShopName(normalized); + entity.setCreatedAt(LocalDateTime.now()); + candidateMapper.insert(entity); + PriceTrackCandidateVo vo = new PriceTrackCandidateVo(); + vo.setId(entity.getId()); + vo.setShopName(entity.getShopName()); + return vo; + } + + @Transactional + public void deleteCandidate(Long userId, Long id) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + if (id == null || id <= 0) throw new BusinessException("id 不合法"); + PriceTrackShopCandidateEntity row = candidateMapper.selectById(id); + if (row == null || !userId.equals(row.getUserId())) { + throw new BusinessException("记录不存在"); + } + candidateMapper.deleteById(id); + } + + public PriceTrackMatchShopsVo matchShops(PriceTrackMatchShopsRequest request) { + if (request.getUserId() == null || request.getUserId() <= 0) { + throw new BusinessException("user_id 不合法"); + } + LinkedHashSet ordered = new LinkedHashSet<>(); + for (String raw : request.getShopNames()) { + String n = ziniaoShopSwitchService.normalizeShopName(raw); + if (!n.isBlank()) ordered.add(n); + } + if (ordered.isEmpty()) { + throw new BusinessException("shop_names 无有效店铺名"); + } + PriceTrackMatchShopsVo vo = new PriceTrackMatchShopsVo(); + for (String shopName : ordered) { + vo.getItems().add(matchOneShop(shopName)); + } + return vo; + } + + public PriceTrackCountryPreferenceVo getCountryPreference(Long userId) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(userId); + PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo(); + if (row == null || row.getCountryCodesJson() == null || row.getCountryCodesJson().isBlank()) { + vo.setUserId(userId); + vo.setCountryCodes(new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER)); + return vo; + } + try { + List parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference>() {}); + vo.setUserId(userId); + vo.setCountryCodes(sanitizeStoredCodes(parsed)); + } catch (Exception ex) { + vo.setUserId(userId); + vo.setCountryCodes(new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER)); + } + return vo; + } + + @Transactional + public PriceTrackCountryPreferenceVo saveCountryPreference(PriceTrackCountryPreferenceSaveRequest request) { + if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法"); + List normalized = validateCountryCodesForSave(request.getCountryCodes()); + String json; + try { + json = objectMapper.writeValueAsString(normalized); + } catch (Exception ex) { + throw new BusinessException("保存偏好失败"); + } + PriceTrackCountryPrefEntity row = countryPrefMapper.selectById(request.getUserId()); + if (row == null) { + row = new PriceTrackCountryPrefEntity(); + row.setUserId(request.getUserId()); + row.setCountryCodesJson(json); + countryPrefMapper.insert(row); + } else { + row.setCountryCodesJson(json); + countryPrefMapper.updateById(row); + } + PriceTrackCountryPreferenceVo vo = new PriceTrackCountryPreferenceVo(); + vo.setUserId(request.getUserId()); + vo.setCountryCodes(normalized); + return vo; + } + + private PriceTrackShopQueueItem matchOneShop(String shopName) { + PriceTrackShopQueueItem item = new PriceTrackShopQueueItem(); + item.setShopName(shopName); + try { + ZiniaoShopMatchResultVo m = ziniaoShopSwitchService.findIndexedStoreByName(shopName, false); + if (m == null) { + item.setMatched(false); + item.setMatchStatus("PENDING"); + item.setMatchMessage("匹配结果为空"); + return item; + } + item.setMatched(m.isMatched()); + item.setShopId(m.getShopId()); + item.setPlatform(m.getPlatform()); + item.setCompanyName(m.getCompanyName()); + item.setMatchStatus(m.getMatchStatus()); + item.setMatchMessage(m.getMatchMessage()); + } 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 static List sanitizeStoredCodes(List raw) { + List parsed = parseValidCountryCodes(raw); + if (parsed.isEmpty()) return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER); + return parsed; + } + + private static List validateCountryCodesForSave(List raw) { + if (raw == null || raw.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家"); + LinkedHashSet seen = new LinkedHashSet<>(); + List out = new ArrayList<>(); + for (String s : raw) { + if (s == null || s.isBlank()) throw new BusinessException("country_codes 含空项"); + String u = s.trim().toUpperCase(); + if (!SUPPORTED_COUNTRIES.contains(u)) throw new BusinessException("非法国家代码: " + s); + if (!seen.add(u)) throw new BusinessException("country_codes 存在重复: " + u); + out.add(u); + } + if (out.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家"); + return out; + } + + private static List parseValidCountryCodes(List raw) { + if (raw == null || raw.isEmpty()) return new ArrayList<>(); + LinkedHashSet seen = new LinkedHashSet<>(); + List out = new ArrayList<>(); + for (String s : raw) { + if (s == null || s.isBlank()) continue; + String u = s.trim().toUpperCase(); + if (!SUPPORTED_COUNTRIES.contains(u)) continue; + if (seen.add(u)) out.add(u); + } + return out; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java new file mode 100644 index 0000000..a3a4e98 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -0,0 +1,446 @@ +package com.nanri.aiimage.modules.pricetrack.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.pricetrack.mapper.PriceTrackShopCandidateMapper; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackCreateTaskRequest; +import com.nanri.aiimage.modules.pricetrack.model.dto.PriceTrackSubmitResultRequest; +import com.nanri.aiimage.modules.pricetrack.model.entity.PriceTrackShopCandidateEntity; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackCreateTaskVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackDashboardVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackHistoryVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackResultItemVo; +import com.nanri.aiimage.modules.pricetrack.model.vo.PriceTrackShopQueueItem; +import com.nanri.aiimage.modules.shopkey.service.SkipPriceAsinService; +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 cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.IdUtil; +import cn.hutool.core.util.ZipUtil; +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.Locale; +import java.util.Map; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PriceTrackTaskService { + + private static final String MODULE_TYPE = "PRICE_TRACK"; + private static final String CONTENT_TYPE_ZIP = "application/zip"; + + private final FileTaskMapper fileTaskMapper; + private final FileResultMapper fileResultMapper; + private final PriceTrackShopCandidateMapper candidateMapper; + private final ZiniaoShopSwitchService ziniaoShopSwitchService; + private final OssStorageService ossStorageService; + private final ObjectMapper objectMapper; + private final SkipPriceAsinService skipPriceAsinService; + + public PriceTrackDashboardVo dashboard(Long userId) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + PriceTrackDashboardVo vo = new PriceTrackDashboardVo(); + vo.setCandidateCount(candidateMapper.selectCount( + new LambdaQueryWrapper() + .eq(PriceTrackShopCandidateEntity::getUserId, userId))); + Long processed = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .in(FileTaskEntity::getStatus, List.of("SUCCESS", "FAILED"))); + Long success = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getUserId, userId) + .eq(FileTaskEntity::getStatus, "SUCCESS")); + Long failed = fileTaskMapper.selectCount(new LambdaQueryWrapper() + .eq(FileTaskEntity::getModuleType, MODULE_TYPE) + .eq(FileTaskEntity::getStatus, "FAILED")); + vo.setProcessedTaskCount(processed == null ? 0L : processed); + vo.setSuccessTaskCount(success == null ? 0L : success); + vo.setFailedTaskCount(failed == null ? 0L : failed); + return vo; + } + + public PriceTrackHistoryVo listHistory(Long userId) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + PriceTrackHistoryVo vo = new PriceTrackHistoryVo(); + List entities = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .eq(FileResultEntity::getUserId, userId) + .orderByDesc(FileResultEntity::getCreatedAt) + .last("limit 100")); + Map statusByTaskId = new LinkedHashMap<>(); + List taskIds = entities.stream() + .map(FileResultEntity::getTaskId) + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (!taskIds.isEmpty()) { + List tasks = fileTaskMapper.selectBatchIds(taskIds); + for (FileTaskEntity t : tasks) { + if (t != null) statusByTaskId.put(t.getId(), t.getStatus()); + } + } + List items = new ArrayList<>(); + for (FileResultEntity entity : entities) { + items.add(toHistoryItemVo(entity, statusByTaskId.get(entity.getTaskId()))); + } + vo.setItems(items); + return vo; + } + + @Transactional + public void deleteHistory(Long resultId, Long userId) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + 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); + if (taskId != null && taskId > 0) { + reconcileTaskAfterResultRemoval(taskId); + } + } + + @Transactional + public void deleteTask(Long taskId, Long userId) { + if (userId == null || userId <= 0) throw new BusinessException("user_id 不合法"); + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || !userId.equals(task.getUserId())) { + throw new BusinessException("任务不存在"); + } + fileResultMapper.delete(new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE)); + fileTaskMapper.deleteById(taskId); + } + + @Transactional + public PriceTrackCreateTaskVo createTask(PriceTrackCreateTaskRequest request) { + if (request.getUserId() == null || request.getUserId() <= 0) throw new BusinessException("user_id 不合法"); + if (request.getItems() == null || request.getItems().isEmpty()) throw new BusinessException("items 不能为空"); + + // 去重并校验匹配状态 + List uniqueItems = dedupeQueueItems(request.getItems()); + if (uniqueItems.isEmpty()) throw new BusinessException("items 不能为空"); + for (PriceTrackShopQueueItem item : uniqueItems) { + if (item == null || !Boolean.TRUE.equals(item.getMatched())) { + String name = item == null ? "" : Objects.toString(item.getShopName(), ""); + throw new BusinessException("存在未匹配店铺,无法创建任务: " + name); + } + } + + // 收集店铺名,查询跳过ASIN + List shopNames = uniqueItems.stream() + .map(PriceTrackShopQueueItem::getShopName) + .filter(s -> s != null && !s.isBlank()) + .distinct() + .toList(); + Map> skipAsinsByShop = skipPriceAsinService.listSkipAsinsByShops(shopNames); + log.info("[price-track] createTask skipAsins shops={}", skipAsinsByShop.keySet()); + + // 创建任务记录 + 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(LocalDateTime.now()); + task.setUpdatedAt(LocalDateTime.now()); + fileTaskMapper.insert(task); + + List snapshot = new ArrayList<>(); + for (PriceTrackShopQueueItem item : uniqueItems) { + String norm = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + if (norm.isBlank()) throw new BusinessException("店铺名无效"); + FileResultEntity fr = new FileResultEntity(); + fr.setTaskId(task.getId()); + fr.setModuleType(MODULE_TYPE); + fr.setSourceFilename(norm); + fr.setSourceFileUrl(Objects.toString(item.getShopId(), null)); + fr.setUserId(request.getUserId()); + fr.setSuccess(0); + fr.setCreatedAt(LocalDateTime.now()); + fileResultMapper.insert(fr); + snapshot.add(toSnapshotVo(fr, item, task.getStatus())); + } + + // 保存请求上下文(含skipASIN)用于Python后续查询 + try { + Map ctx = new LinkedHashMap<>(); + ctx.put("userId", request.getUserId()); + ctx.put("statusMode", request.isStatusMode()); + ctx.put("asinMode", request.isAsinMode()); + ctx.put("countryCodes", request.getCountryCodes()); + ctx.put("minPrice", request.getMinPrice()); + ctx.put("maxPrice", request.getMaxPrice()); + ctx.put("priceDiffPercent", request.getPriceDiffPercent()); + ctx.put("skipAsinsByShop", skipAsinsByShop); + ctx.put("items", uniqueItems); + task.setRequestJson(objectMapper.writeValueAsString(ctx)); + task.setResultJson(objectMapper.writeValueAsString(snapshot)); + } catch (Exception ex) { + throw new BusinessException("序列化任务数据失败"); + } + fileTaskMapper.updateById(task); + + // TODO: Python侧定时轮询 RUNNING 状态任务, pickup 后开始处理 + // Java侧通过 submitResult 接口接收Python回传结果 + + PriceTrackCreateTaskVo vo = new PriceTrackCreateTaskVo(); + vo.setTaskId(task.getId()); + vo.setItems(snapshot); + return vo; + } + + @Transactional + public void submitResult(Long taskId, PriceTrackSubmitResultRequest 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 = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { + throw new BusinessException("任务不存在"); + } + if ("SUCCESS".equals(task.getStatus()) || "FAILED".equals(task.getStatus())) { + log.warn("[price-track] submitResult rejected taskId={} status={}", taskId, task.getStatus()); + throw new BusinessException("任务已结束,拒绝重复提交"); + } + + List resultRows = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + + Map payloadByShop = new LinkedHashMap<>(); + for (PriceTrackSubmitResultRequest.ShopResult sr : request.getShops()) { + if (sr == null) continue; + String key = ziniaoShopSwitchService.normalizeShopName(sr.getShopName()); + if (!key.isBlank()) payloadByShop.put(key, sr); + } + + int ok = 0, fail = 0; + List allErrors = new ArrayList<>(); + boolean allDone = true; + + for (FileResultEntity fr : resultRows) { + String shopKey = fr.getSourceFilename(); + PriceTrackSubmitResultRequest.ShopResult payload = shopKey == null ? null : payloadByShop.get(shopKey); + if (payload == null) { + // 未匹配到payload的店铺保持待处理 + allDone = false; + continue; + } + if (payload.getError() != null && !payload.getError().isBlank()) { + markResultFailed(fr, payload.getError()); + fail++; + allErrors.add(shopKey + ": " + payload.getError()); + continue; + } + if (Boolean.FALSE.equals(payload.getSuccess())) { + markResultFailed(fr, "处理失败"); + fail++; + allErrors.add(shopKey + ": 处理失败"); + continue; + } + // 成功 + fr.setSuccess(1); + fr.setErrorMessage(null); + fr.setResultFilename(shopKey + "-price-track.xlsx"); + fr.setResultFileSize(0L); + fr.setRowCount(countAsinResults(payload)); + fileResultMapper.updateById(fr); + ok++; + } + + List latest = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + + allDone = latest.stream().noneMatch(fr -> + fr.getSuccess() == null || fr.getSuccess() == 0); + + task.setSuccessFileCount(ok); + task.setFailedFileCount(fail); + task.setUpdatedAt(LocalDateTime.now()); + + if (!allDone) { + task.setStatus("RUNNING"); + } else if (ok > 0 && fail == 0) { + task.setStatus("SUCCESS"); + task.setFinishedAt(LocalDateTime.now()); + } else if (ok > 0) { + task.setStatus("SUCCESS"); + task.setErrorMessage(String.join(";", allErrors)); + task.setFinishedAt(LocalDateTime.now()); + } else { + task.setStatus("FAILED"); + task.setErrorMessage(String.join(";", allErrors)); + task.setFinishedAt(LocalDateTime.now()); + } + try { + task.setResultJson(objectMapper.writeValueAsString(buildSnapshotFromDb(taskId, task.getStatus()))); + } catch (Exception ex) { + log.warn("[price-track] compact result json failed: {}", ex.getMessage()); + } + fileTaskMapper.updateById(task); + } + + 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 (entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank()) { + 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("记录不存在"); + } + if (entity.getResultFilename() != null && !entity.getResultFilename().isBlank()) { + return entity.getResultFilename(); + } + return safeFileStem(entity.getSourceFilename()) + ".xlsx"; + } + + // ---- private helpers ---- + + private void reconcileTaskAfterResultRemoval(Long taskId) { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + if (task == null || !MODULE_TYPE.equals(task.getModuleType())) return; + List latest = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + if (latest.isEmpty()) { + fileTaskMapper.deleteById(taskId); + return; + } + int ok = 0, fail = 0; + for (FileResultEntity fr : latest) { + if (Boolean.TRUE.equals(fr.getSuccess())) ok++; + else if (fr.getErrorMessage() != null && !fr.getErrorMessage().isBlank()) fail++; + } + task.setSuccessFileCount(ok); + task.setFailedFileCount(fail); + task.setUpdatedAt(LocalDateTime.now()); + boolean allDone = latest.stream().noneMatch(fr -> fr.getSuccess() == null || fr.getSuccess() == 0); + if (!allDone) return; + task.setStatus(ok > 0 ? "SUCCESS" : "FAILED"); + task.setFinishedAt(LocalDateTime.now()); + fileTaskMapper.updateById(task); + } + + private void markResultFailed(FileResultEntity fr, String message) { + fr.setSuccess(0); + fr.setErrorMessage(message); + fr.setResultFilename(null); + fr.setResultFileUrl(null); + fr.setResultFileSize(0L); + fr.setRowCount(0); + fileResultMapper.updateById(fr); + } + + private List dedupeQueueItems(List raw) { + Map byNorm = new LinkedHashMap<>(); + for (PriceTrackShopQueueItem item : raw) { + if (item == null) continue; + String k = ziniaoShopSwitchService.normalizeShopName(item.getShopName()); + if (k.isBlank()) continue; + byNorm.putIfAbsent(k, item); + } + return new ArrayList<>(byNorm.values()); + } + + private PriceTrackResultItemVo toSnapshotVo(FileResultEntity fr, PriceTrackShopQueueItem item, String taskStatus) { + PriceTrackResultItemVo vo = new PriceTrackResultItemVo(); + vo.setResultId(fr.getId()); + vo.setTaskId(fr.getTaskId()); + vo.setShopName(item.getShopName()); + vo.setShopId(item.getShopId()); + vo.setPlatform(item.getPlatform()); + vo.setCompanyName(item.getCompanyName()); + vo.setMatched(Boolean.TRUE.equals(item.getMatched())); + vo.setMatchStatus(item.getMatchStatus()); + vo.setMatchMessage(item.getMatchMessage()); + vo.setTaskStatus(taskStatus); + vo.setSuccess(false); + vo.setError(null); + vo.setOutputFilename(null); + vo.setDownloadUrl(null); + return vo; + } + + private PriceTrackResultItemVo toHistoryItemVo(FileResultEntity entity, String taskStatus) { + PriceTrackResultItemVo vo = new PriceTrackResultItemVo(); + vo.setResultId(entity.getId()); + vo.setTaskId(entity.getTaskId()); + vo.setShopName(entity.getSourceFilename()); + vo.setShopId(entity.getSourceFileUrl()); + vo.setTaskStatus(taskStatus); + boolean ok = entity.getSuccess() != null && entity.getSuccess() == 1; + vo.setSuccess(ok); + vo.setError(entity.getErrorMessage()); + vo.setOutputFilename(entity.getResultFilename()); + vo.setDownloadUrl(entity.getResultFileUrl() == null || entity.getResultFileUrl().isBlank() + ? null : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); + vo.setMatched(true); + return vo; + } + + private List buildSnapshotFromDb(Long taskId, String taskStatus) { + List rows = fileResultMapper.selectList( + new LambdaQueryWrapper() + .eq(FileResultEntity::getTaskId, taskId) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .orderByAsc(FileResultEntity::getId)); + List list = new ArrayList<>(); + for (FileResultEntity fr : rows) list.add(toHistoryItemVo(fr, taskStatus)); + return list; + } + + private int countAsinResults(PriceTrackSubmitResultRequest.ShopResult payload) { + if (payload == null || payload.getCountries() == null) return 0; + return payload.getCountries().values().stream() + .filter(l -> l != null) + .mapToInt(List::size) + .sum(); + } + + private static String safeFileStem(String shopName) { + String s = shopName == null ? "shop" : shopName.trim(); + if (s.isBlank()) s = "shop"; + return s.replaceAll("[\\\\/:*?\"<>|]", "_"); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 199c2fb..16c2438 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -209,4 +209,29 @@ public class SkipPriceAsinService { && normalizeBlank(entity.getAsinIt()).isEmpty() && normalizeBlank(entity.getAsinEs()).isEmpty(); } + + /** + * 按店铺名列表批量查询跳过跟价ASIN,返回 Map{shopName -> {countryCode -> asin列表}} + * 用于跟价任务创建时将skip ASIN数据下发给Python队列 + */ + public Map> listSkipAsinsByShops(List shopNames) { + if (shopNames == null || shopNames.isEmpty()) { + return Map.of(); + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .in(SkipPriceAsinEntity::getShopName, shopNames); + List rows = skipPriceAsinMapper.selectList(query); + Map> result = new java.util.LinkedHashMap<>(); + for (SkipPriceAsinEntity row : rows) { + String name = row.getShopName(); + Map countryMap = new java.util.LinkedHashMap<>(); + if (!normalizeBlank(row.getAsinDe()).isEmpty()) countryMap.put("DE", row.getAsinDe()); + if (!normalizeBlank(row.getAsinUk()).isEmpty()) countryMap.put("UK", row.getAsinUk()); + if (!normalizeBlank(row.getAsinFr()).isEmpty()) countryMap.put("FR", row.getAsinFr()); + if (!normalizeBlank(row.getAsinIt()).isEmpty()) countryMap.put("IT", row.getAsinIt()); + if (!normalizeBlank(row.getAsinEs()).isEmpty()) countryMap.put("ES", row.getAsinEs()); + result.put(name, countryMap); + } + return result; + } } diff --git a/frontend-vue/price-track.html b/frontend-vue/price-track.html new file mode 100644 index 0000000..f15f39c --- /dev/null +++ b/frontend-vue/price-track.html @@ -0,0 +1,12 @@ + + + + + + 跟价 - 数富AI + + +
+ + + diff --git a/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue new file mode 100644 index 0000000..2cb4e75 --- /dev/null +++ b/frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue @@ -0,0 +1,1457 @@ + + + + + diff --git a/frontend-vue/src/pages/brand/components/BrandTopBar.vue b/frontend-vue/src/pages/brand/components/BrandTopBar.vue index b2a07c7..b686f39 100644 --- a/frontend-vue/src/pages/brand/components/BrandTopBar.vue +++ b/frontend-vue/src/pages/brand/components/BrandTopBar.vue @@ -47,6 +47,7 @@ type ActiveNavKey = | 'delete-brand' | 'product-risk' | 'shop-match' + | 'pricing' type NavItem = { key: string @@ -78,7 +79,7 @@ const navGroups: ReadonlyArray<{ label: string; items: ReadonlyArray }> { key: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' }, { key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' }, { key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.html' }, - { key: 'pricing', label: '跟价' }, + { key: 'pricing', label: '跟价', href: '/new_web_source/price-track.html' }, { key: 'patrol-delete', label: '巡店删除' }, { key: 'withdraw', label: '取款' }, { key: 'shop-status', label: '店铺状态查询' }, diff --git a/frontend-vue/src/price-track-main.ts b/frontend-vue/src/price-track-main.ts new file mode 100644 index 0000000..5829623 --- /dev/null +++ b/frontend-vue/src/price-track-main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import '@/styles/main.css' +import BrandPriceTrackTab from '@/pages/brand/components/BrandPriceTrackTab.vue' + +createApp(BrandPriceTrackTab).use(ElementPlus).mount('#app') diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 4733606..24973cf 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -916,6 +916,181 @@ export function getShopMatchResultDownloadUrl(resultId: number) { return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`); } +// ========== 跟价 ========== + +export interface PriceTrackCandidateVo { + id: number; + shopName: string; +} + +export interface PriceTrackShopQueueItem { + shopName?: string; + shopId?: number | string | null; + matched?: boolean; + matchStatus?: string; + matchMessage?: string; + platform?: string; + companyName?: string; +} + +export interface PriceTrackMatchShopsVo { + items: PriceTrackShopQueueItem[]; +} + +export interface PriceTrackCountryPreferenceVo { + userId: number; + countryCodes: string[]; +} + +export interface PriceTrackDashboardVo { + candidateCount: number; + processedTaskCount: number; + successTaskCount: number; + failedTaskCount: number; +} + +export interface PriceTrackHistoryItem { + resultId?: number; + taskId?: number; + shopName?: string; + shopId?: number | string | null; + platform?: string; + companyName?: string; + matched?: boolean; + matchStatus?: string; + matchMessage?: string; + taskStatus?: string; + outputFilename?: string; + downloadUrl?: string; + error?: string; + success?: boolean; +} + +export interface PriceTrackHistoryVo { + items: PriceTrackHistoryItem[]; +} + +export interface PriceTrackCreateTaskVo { + taskId: number; + items: PriceTrackShopQueueItem[]; +} + +export function listPriceTrackCandidates() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/price-track/candidates?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function addPriceTrackCandidate(shopName: string) { + return unwrapJavaResponse( + post, { user_id: number; shopName: string }>( + `${JAVA_API_PREFIX}/price-track/candidates`, + { user_id: getCurrentUserId(), shopName }, + ), + ); +} + +export function deletePriceTrackCandidate(id: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/price-track/candidates/${id}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function getPriceTrackCountryPreference() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/price-track/country-preference?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function putPriceTrackCountryPreference(countryCodes: string[]) { + return unwrapJavaResponse( + put, { user_id: number; countryCodes: string[] }>( + `${JAVA_API_PREFIX}/price-track/country-preference`, + { user_id: getCurrentUserId(), countryCodes }, + ), + ); +} + +export function matchPriceTrackShops(shopNames: string[]) { + return unwrapJavaResponse( + post, { user_id: number; shopNames: string[] }>( + `${JAVA_API_PREFIX}/price-track/match-shops`, + { user_id: getCurrentUserId(), shopNames }, + ), + ); +} + +export function getPriceTrackDashboard() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/price-track/dashboard?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function getPriceTrackHistory() { + return unwrapJavaResponse( + get>( + `${JAVA_API_PREFIX}/price-track/history?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function deletePriceTrackHistory(resultId: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/price-track/history/${resultId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export interface PriceTrackCreateTaskRequest { + userId: number; + statusMode: boolean; + asinMode: boolean; + items: PriceTrackShopQueueItem[]; + asinFiles: string[]; + countryCodes: string[]; + minPrice?: number; + maxPrice?: number; + priceDiffPercent?: number; +} + +export function createPriceTrackTask(request: PriceTrackCreateTaskRequest) { + return unwrapJavaResponse( + post, PriceTrackCreateTaskRequest>( + `${JAVA_API_PREFIX}/price-track/tasks`, + { ...request, user_id: getCurrentUserId() }, + ), + ); +} + +export function deletePriceTrackTask(taskId: number) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/price-track/tasks/${taskId}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`, + ), + ); +} + +export function getPriceTrackResultDownloadUrl(resultId: number) { + return getJavaDownloadUrl(`/price-track/results/${resultId}/download`); +} + +export function deletePendingPriceTrackShopResult(shopName: string) { + return unwrapJavaResponse( + del>( + `${JAVA_API_PREFIX}/price-track/pending-shop-result?user_id=${encodeURIComponent(String(getCurrentUserId()))}&shop_name=${encodeURIComponent(shopName)}`, + ), + ); +} + export function getJavaDownloadUrl(path: string) { let raw = path.startsWith("http://") || path.startsWith("https://") diff --git a/frontend-vue/vite.config.ts b/frontend-vue/vite.config.ts index dba613f..ac76dec 100644 --- a/frontend-vue/vite.config.ts +++ b/frontend-vue/vite.config.ts @@ -48,6 +48,7 @@ export default defineConfig({ 'delete-brand': resolve(__dirname, 'delete-brand.html'), 'product-risk': resolve(__dirname, 'product-risk.html'), 'shop-match': resolve(__dirname, 'shop-match.html'), + 'price-track': resolve(__dirname, 'price-track.html'), }, output: { entryFileNames: 'assets/[name].js',