提交任务更新
This commit is contained in:
@@ -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<java.util.List<PriceTrackCandidateVo>> 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<PriceTrackCandidateVo> addCandidate(@Valid @RequestBody PriceTrackCandidateAddRequest request) {
|
||||
return ApiResponse.success(priceTrackService.addCandidate(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/candidates/{id}")
|
||||
@Operation(summary = "删除备选店铺")
|
||||
public ApiResponse<Void> 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<PriceTrackCountryPreferenceVo> 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<PriceTrackCountryPreferenceVo> saveCountryPreference(
|
||||
@Valid @RequestBody PriceTrackCountryPreferenceSaveRequest request) {
|
||||
return ApiResponse.success(priceTrackService.saveCountryPreference(request));
|
||||
}
|
||||
|
||||
@PostMapping("/match-shops")
|
||||
@Operation(summary = "批量匹配店铺")
|
||||
public ApiResponse<PriceTrackMatchShopsVo> matchShops(@Valid @RequestBody PriceTrackMatchShopsRequest request) {
|
||||
return ApiResponse.success(priceTrackService.matchShops(request));
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
@Operation(summary = "统计看板")
|
||||
public ApiResponse<PriceTrackDashboardVo> 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<PriceTrackHistoryVo> 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<Void> 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<PriceTrackCreateTaskVo> createTask(@Valid @RequestBody PriceTrackCreateTaskRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.createTask(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除整条任务")
|
||||
public ApiResponse<Void> 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<Void> 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, "下载失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<PriceTrackCountryPrefEntity> {
|
||||
}
|
||||
@@ -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<PriceTrackShopCandidateEntity> {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> countryCodes;
|
||||
}
|
||||
@@ -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<Map<String, Object>> items;
|
||||
|
||||
/** ASIN文档路径(asinMode时使用) */
|
||||
private List<String> asinFiles;
|
||||
|
||||
/** 国家代码列表 */
|
||||
@NotEmpty(message = "国家列表不能为空")
|
||||
private List<String> countryCodes;
|
||||
|
||||
/** 价格区间 */
|
||||
private Double minPrice;
|
||||
private Double maxPrice;
|
||||
|
||||
/** 价差比例(%) */
|
||||
private Double priceDiffPercent;
|
||||
}
|
||||
@@ -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<String> shopNames;
|
||||
}
|
||||
@@ -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<ShopResult> shops;
|
||||
|
||||
@Data
|
||||
public static class ShopResult {
|
||||
private String shopName;
|
||||
private Map<String, List<AsinResult>> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> countryCodes;
|
||||
}
|
||||
@@ -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<Map<String, Object>> items;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PriceTrackResultItemVo> items;
|
||||
}
|
||||
@@ -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<PriceTrackShopQueueItem> 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<String> DEFAULT_COUNTRY_PREFERENCE_ORDER = List.of("DE", "UK", "FR", "IT", "ES");
|
||||
public static final List<String> 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<PriceTrackCandidateVo> listCandidates(Long userId) {
|
||||
if (userId == null || userId <= 0) {
|
||||
throw new BusinessException("user_id 不合法");
|
||||
}
|
||||
List<PriceTrackShopCandidateEntity> rows = candidateMapper.selectList(
|
||||
new LambdaQueryWrapper<PriceTrackShopCandidateEntity>()
|
||||
.eq(PriceTrackShopCandidateEntity::getUserId, userId)
|
||||
.orderByDesc(PriceTrackShopCandidateEntity::getId));
|
||||
List<PriceTrackCandidateVo> 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<PriceTrackShopCandidateEntity>()
|
||||
.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<String> 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<String> parsed = objectMapper.readValue(row.getCountryCodesJson(), new TypeReference<List<String>>() {});
|
||||
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<String> 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<String> sanitizeStoredCodes(List<String> raw) {
|
||||
List<String> parsed = parseValidCountryCodes(raw);
|
||||
if (parsed.isEmpty()) return new ArrayList<>(DEFAULT_COUNTRY_PREFERENCE_ORDER);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static List<String> validateCountryCodesForSave(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) throw new BusinessException("country_codes 至少选择 1 个国家");
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String 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<String> parseValidCountryCodes(List<String> raw) {
|
||||
if (raw == null || raw.isEmpty()) return new ArrayList<>();
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<PriceTrackShopCandidateEntity>()
|
||||
.eq(PriceTrackShopCandidateEntity::getUserId, userId)));
|
||||
Long processed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.in(FileTaskEntity::getStatus, List.of("SUCCESS", "FAILED")));
|
||||
Long success = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.eq(FileTaskEntity::getStatus, "SUCCESS"));
|
||||
Long failed = fileTaskMapper.selectCount(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.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<FileResultEntity> entities = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileResultEntity::getUserId, userId)
|
||||
.orderByDesc(FileResultEntity::getCreatedAt)
|
||||
.last("limit 100"));
|
||||
Map<Long, String> statusByTaskId = new LinkedHashMap<>();
|
||||
List<Long> taskIds = entities.stream()
|
||||
.map(FileResultEntity::getTaskId)
|
||||
.filter(id -> id != null && id > 0)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (!taskIds.isEmpty()) {
|
||||
List<FileTaskEntity> tasks = fileTaskMapper.selectBatchIds(taskIds);
|
||||
for (FileTaskEntity t : tasks) {
|
||||
if (t != null) statusByTaskId.put(t.getId(), t.getStatus());
|
||||
}
|
||||
}
|
||||
List<PriceTrackResultItemVo> 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<FileResultEntity>()
|
||||
.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<PriceTrackShopQueueItem> 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<String> shopNames = uniqueItems.stream()
|
||||
.map(PriceTrackShopQueueItem::getShopName)
|
||||
.filter(s -> s != null && !s.isBlank())
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, Map<String, String>> 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<PriceTrackResultItemVo> 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<String, Object> 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<FileResultEntity> resultRows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
|
||||
Map<String, PriceTrackSubmitResultRequest.ShopResult> 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<String> 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<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.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<FileResultEntity> latest = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.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<PriceTrackShopQueueItem> dedupeQueueItems(List<PriceTrackShopQueueItem> raw) {
|
||||
Map<String, PriceTrackShopQueueItem> 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<PriceTrackResultItemVo> buildSnapshotFromDb(Long taskId, String taskStatus) {
|
||||
List<FileResultEntity> rows = fileResultMapper.selectList(
|
||||
new LambdaQueryWrapper<FileResultEntity>()
|
||||
.eq(FileResultEntity::getTaskId, taskId)
|
||||
.eq(FileResultEntity::getModuleType, MODULE_TYPE)
|
||||
.orderByAsc(FileResultEntity::getId));
|
||||
List<PriceTrackResultItemVo> 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("[\\\\/:*?\"<>|]", "_");
|
||||
}
|
||||
}
|
||||
@@ -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<String, Map<String, String>> listSkipAsinsByShops(List<String> shopNames) {
|
||||
if (shopNames == null || shopNames.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.in(SkipPriceAsinEntity::getShopName, shopNames);
|
||||
List<SkipPriceAsinEntity> rows = skipPriceAsinMapper.selectList(query);
|
||||
Map<String, Map<String, String>> result = new java.util.LinkedHashMap<>();
|
||||
for (SkipPriceAsinEntity row : rows) {
|
||||
String name = row.getShopName();
|
||||
Map<String, String> 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;
|
||||
}
|
||||
}
|
||||
|
||||
12
frontend-vue/price-track.html
Normal file
12
frontend-vue/price-track.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>跟价 - 数富AI</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/price-track-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1457
frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue
Normal file
1457
frontend-vue/src/pages/brand/components/BrandPriceTrackTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<NavItem> }>
|
||||
{ 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: '店铺状态查询' },
|
||||
|
||||
7
frontend-vue/src/price-track-main.ts
Normal file
7
frontend-vue/src/price-track-main.ts
Normal file
@@ -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')
|
||||
@@ -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<JavaApiResponse<PriceTrackCandidateVo[]>>(
|
||||
`${JAVA_API_PREFIX}/price-track/candidates?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function addPriceTrackCandidate(shopName: string) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackCandidateVo>, { user_id: number; shopName: string }>(
|
||||
`${JAVA_API_PREFIX}/price-track/candidates`,
|
||||
{ user_id: getCurrentUserId(), shopName },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePriceTrackCandidate(id: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${JAVA_API_PREFIX}/price-track/candidates/${id}?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackCountryPreference() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackCountryPreferenceVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/country-preference?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function putPriceTrackCountryPreference(countryCodes: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
put<JavaApiResponse<PriceTrackCountryPreferenceVo>, { user_id: number; countryCodes: string[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/country-preference`,
|
||||
{ user_id: getCurrentUserId(), countryCodes },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function matchPriceTrackShops(shopNames: string[]) {
|
||||
return unwrapJavaResponse(
|
||||
post<JavaApiResponse<PriceTrackMatchShopsVo>, { user_id: number; shopNames: string[] }>(
|
||||
`${JAVA_API_PREFIX}/price-track/match-shops`,
|
||||
{ user_id: getCurrentUserId(), shopNames },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackDashboard() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackDashboardVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/dashboard?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getPriceTrackHistory() {
|
||||
return unwrapJavaResponse(
|
||||
get<JavaApiResponse<PriceTrackHistoryVo>>(
|
||||
`${JAVA_API_PREFIX}/price-track/history?user_id=${encodeURIComponent(String(getCurrentUserId()))}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePriceTrackHistory(resultId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${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<JavaApiResponse<PriceTrackCreateTaskVo>, PriceTrackCreateTaskRequest>(
|
||||
`${JAVA_API_PREFIX}/price-track/tasks`,
|
||||
{ ...request, user_id: getCurrentUserId() },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePriceTrackTask(taskId: number) {
|
||||
return unwrapJavaResponse(
|
||||
del<JavaApiResponse<null>>(
|
||||
`${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<JavaApiResponse<null>>(
|
||||
`${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://")
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user