提交任务更新

This commit is contained in:
2026-04-13 20:07:00 +08:00
parent 4a58b366b6
commit 9ad6e037dc
26 changed files with 2782 additions and 1 deletions

View File

@@ -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, "下载失败");
}
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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("[\\\\/:*?\"<>|]", "_");
}
}

View File

@@ -209,4 +209,29 @@ public class SkipPriceAsinService {
&& normalizeBlank(entity.getAsinIt()).isEmpty() && normalizeBlank(entity.getAsinIt()).isEmpty()
&& normalizeBlank(entity.getAsinEs()).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;
}
} }

View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -47,6 +47,7 @@ type ActiveNavKey =
| 'delete-brand' | 'delete-brand'
| 'product-risk' | 'product-risk'
| 'shop-match' | 'shop-match'
| 'pricing'
type NavItem = { type NavItem = {
key: string 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: 'delete-brand', label: '删除ASIN', href: '/new_web_source/delete-brand.html' },
{ key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' }, { key: 'product-risk', label: '商品风险解决', href: '/new_web_source/product-risk.html' },
{ key: 'shop-match', label: '定时匹配', href: '/new_web_source/shop-match.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: 'patrol-delete', label: '巡店删除' },
{ key: 'withdraw', label: '取款' }, { key: 'withdraw', label: '取款' },
{ key: 'shop-status', label: '店铺状态查询' }, { key: 'shop-status', label: '店铺状态查询' },

View 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')

View File

@@ -916,6 +916,181 @@ export function getShopMatchResultDownloadUrl(resultId: number) {
return getJavaDownloadUrl(`/shop-match/results/${resultId}/download`); 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) { export function getJavaDownloadUrl(path: string) {
let raw = let raw =
path.startsWith("http://") || path.startsWith("https://") path.startsWith("http://") || path.startsWith("https://")

View File

@@ -48,6 +48,7 @@ export default defineConfig({
'delete-brand': resolve(__dirname, 'delete-brand.html'), 'delete-brand': resolve(__dirname, 'delete-brand.html'),
'product-risk': resolve(__dirname, 'product-risk.html'), 'product-risk': resolve(__dirname, 'product-risk.html'),
'shop-match': resolve(__dirname, 'shop-match.html'), 'shop-match': resolve(__dirname, 'shop-match.html'),
'price-track': resolve(__dirname, 'price-track.html'),
}, },
output: { output: {
entryFileNames: 'assets/[name].js', entryFileNames: 'assets/[name].js',