feat(external): 对外商品 upsert/delist、AK/SK 鉴权与拼多多发布链路
- 新增 ExternalGoodsController、ExternalGoodsAppService 及 DTO/VO - AK/SK 过滤器与 Security 配置;GoodsAddBo.shopId、insertGoods 写 shop_id - 移除 DeepSeekService;erp-api/bootstrap、nacos 示例与文档更新 - 作者: guochengyu Made-with: Cursor
This commit is contained in:
parent
18003448de
commit
565bc976fc
|
|
@ -155,12 +155,6 @@
|
|||
<artifactId>langchain4j-ollama</artifactId>
|
||||
<version>1.11.0</version>
|
||||
</dependency>
|
||||
<!-- OpenAI 集成(用于DeepSeek API) -->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
<artifactId>langchain4j-open-ai</artifactId>
|
||||
<version>1.11.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package cn.qihangerp.erp.serviceImpl;
|
||||
|
||||
import dev.langchain4j.model.ollama.OllamaChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.service.AiServices;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
|
@ -17,7 +17,6 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import cn.qihangerp.erp.service.OrderToolService;
|
||||
import cn.qihangerp.erp.service.GoodsToolService;
|
||||
import cn.qihangerp.erp.serviceImpl.ConversationHistoryManager;
|
||||
|
||||
/**
|
||||
* AI服务类,使用LangChain4J调用Ollama模型处理聊天内容
|
||||
|
|
@ -49,16 +48,6 @@ public class AiService {
|
|||
// 页面规则列表
|
||||
private List<PageRule> pageRules = new ArrayList<>();
|
||||
|
||||
// DeepSeek API 配置
|
||||
@Value("${deepseek.api.key:}")
|
||||
private String deepSeekApiKey;
|
||||
|
||||
@Value("${deepseek.api.endpoint:https://api.deepseek.com/v1/chat/completions}")
|
||||
private String deepSeekApiEndpoint;
|
||||
|
||||
@Value("${deepseek.api.model:deepseek-chat}")
|
||||
private String deepSeekModel;
|
||||
|
||||
/**
|
||||
* 构造方法,加载页面规则
|
||||
*/
|
||||
|
|
@ -219,52 +208,16 @@ public class AiService {
|
|||
System.out.println("创建AI工具服务失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 根据模型名称选择使用Ollama还是DeepSeek API
|
||||
if (model == null || model.isBlank()) {
|
||||
return "错误: 未选择模型,请从下拉框选择本地 Ollama 模型。";
|
||||
}
|
||||
if (model.toLowerCase(Locale.ROOT).startsWith("deepseek")) {
|
||||
return "错误: 已移除云端 DeepSeek,请选择本地 Ollama 模型。";
|
||||
}
|
||||
|
||||
OrderAiService aiService;
|
||||
if (model.startsWith("deepseek")) {
|
||||
// 使用DeepSeek API
|
||||
if (deepSeekApiKey == null || deepSeekApiKey.isEmpty()) {
|
||||
return "错误: DeepSeek API密钥未配置,请在application.yml中设置deepseek.api.key";
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试创建DeepSeek模型实例
|
||||
OpenAiChatModel deepSeekModelInstance = OpenAiChatModel.builder()
|
||||
.baseUrl(deepSeekApiEndpoint)
|
||||
.apiKey(deepSeekApiKey)
|
||||
.modelName(deepSeekModel)
|
||||
.temperature(0.7)
|
||||
.timeout(Duration.ofSeconds(300))
|
||||
.build();
|
||||
|
||||
if (orderSvc != null && goodsSvc != null) {
|
||||
aiService = AiServices.builder(OrderAiService.class)
|
||||
.chatModel(deepSeekModelInstance)
|
||||
.tools(orderSvc, goodsSvc)
|
||||
.build();
|
||||
} else if (orderSvc != null) {
|
||||
aiService = AiServices.builder(OrderAiService.class)
|
||||
.chatModel(deepSeekModelInstance)
|
||||
.tools(orderSvc)
|
||||
.build();
|
||||
} else if (goodsSvc != null) {
|
||||
aiService = AiServices.builder(OrderAiService.class)
|
||||
.chatModel(deepSeekModelInstance)
|
||||
.tools(goodsSvc)
|
||||
.build();
|
||||
} else {
|
||||
aiService = AiServices.builder(OrderAiService.class)
|
||||
.chatModel(deepSeekModelInstance)
|
||||
.build();
|
||||
}
|
||||
System.out.println("使用DeepSeek API处理消息");
|
||||
} catch (Exception e) {
|
||||
// 如果DeepSeek依赖不可用,返回错误消息
|
||||
return "错误: DeepSeek API依赖未配置,请检查Maven依赖是否正确";
|
||||
}
|
||||
} else {
|
||||
// 使用Ollama
|
||||
try {
|
||||
// 使用 Ollama
|
||||
try {
|
||||
System.out.println("尝试连接Ollama,模型: " + model);
|
||||
OllamaChatModel modelInstance = OllamaChatModel.builder()
|
||||
.baseUrl("http://localhost:11434") // Ollama默认端口
|
||||
|
|
@ -297,9 +250,8 @@ public class AiService {
|
|||
} catch (Exception e) {
|
||||
System.out.println("创建Ollama模型实例失败: " + e.getMessage());
|
||||
return "错误: 无法连接到Ollama服务,请检查Ollama是否已启动,端口是否正确(默认11434)";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
System.out.println("开始调用AI服务");
|
||||
String result = aiService.chat(enhancedMessage);
|
||||
|
|
|
|||
|
|
@ -1,365 +0,0 @@
|
|||
//package cn.qihangerp.erp.serviceImpl;
|
||||
//
|
||||
//import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
//import jakarta.annotation.PostConstruct;
|
||||
//import okhttp3.*;
|
||||
//import org.slf4j.Logger;
|
||||
//import org.slf4j.LoggerFactory;
|
||||
//import org.springframework.beans.factory.annotation.Value;
|
||||
//import org.springframework.stereotype.Service;
|
||||
//import java.io.IOException;
|
||||
//import java.util.*;
|
||||
//import java.util.concurrent.TimeUnit;
|
||||
//
|
||||
//@Service
|
||||
//public class DeepSeekService {
|
||||
//
|
||||
// private static final Logger log = LoggerFactory.getLogger(DeepSeekService.class);
|
||||
// private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
//
|
||||
// @Value("${deepseek.api.key}")
|
||||
// private String apiKey;
|
||||
//
|
||||
// @Value("${deepseek.api.endpoint:https://api.deepseek.com/v1/chat/completions}")
|
||||
// private String apiEndpoint;
|
||||
//
|
||||
// @Value("${deepseek.api.model:deepseek-chat}")
|
||||
// private String model;
|
||||
//
|
||||
// private OkHttpClient okHttpClient;
|
||||
// private final ObjectMapper objectMapper;
|
||||
//
|
||||
// // 缓存最近一次成功的分析结果
|
||||
// private Map<String, Object> cachedAnalysis = new HashMap<>();
|
||||
//
|
||||
// public DeepSeekService(ObjectMapper objectMapper) {
|
||||
// this.objectMapper = objectMapper;
|
||||
// }
|
||||
//
|
||||
// @PostConstruct
|
||||
// public void init() {
|
||||
// // 配置具有重试和连接池功能的OkHttpClient
|
||||
// this.okHttpClient = new OkHttpClient.Builder()
|
||||
// .connectTimeout(15, TimeUnit.SECONDS) // 连接超时
|
||||
// .readTimeout(30, TimeUnit.SECONDS) // 读取超时
|
||||
// .writeTimeout(15, TimeUnit.SECONDS) // 写入超时
|
||||
// .connectionPool(new ConnectionPool(5, 5, TimeUnit.MINUTES)) // 连接池
|
||||
// .addInterceptor(new RetryInterceptor(3)) // 自定义重试拦截器
|
||||
// .addInterceptor(new LoggingInterceptor()) // 日志拦截器
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 调用DeepSeek API - 带有Spring Retry重试机制
|
||||
// */
|
||||
//// @Retryable(
|
||||
//// value = {IOException.class, RuntimeException.class},
|
||||
//// maxAttempts = 3,
|
||||
//// backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000)
|
||||
//// )
|
||||
// public Map<String, Object> analyzeData(Map<String, Object> formattedData, String analysisType) {
|
||||
// String cacheKey = generateCacheKey(formattedData, analysisType);
|
||||
//
|
||||
// try {
|
||||
// // 1. 构建请求体
|
||||
// String requestBody = buildRequestBody(formattedData, analysisType);
|
||||
// RequestBody body = RequestBody.create(requestBody, JSON);
|
||||
//
|
||||
// // 2. 构建请求
|
||||
// Request request = new Request.Builder()
|
||||
// .url(apiEndpoint)
|
||||
// .header("Authorization", "Bearer " + apiKey)
|
||||
// .header("Content-Type", "application/json")
|
||||
// .post(body)
|
||||
// .build();
|
||||
//
|
||||
// // 3. 执行请求并处理响应
|
||||
// try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
// if (!response.isSuccessful()) {
|
||||
// handleErrorResponse(response, cacheKey);
|
||||
// }
|
||||
//
|
||||
// String responseBody = response.body().string();
|
||||
// Map<String, Object> result = parseResponse(responseBody, analysisType);
|
||||
//
|
||||
// // 缓存成功的结果
|
||||
// cacheSuccessfulResult(cacheKey, result);
|
||||
// return result;
|
||||
// }
|
||||
//
|
||||
// } catch (Exception e) {
|
||||
// log.error("调用DeepSeek API失败,尝试使用缓存或降级方案", e);
|
||||
// return getFallbackAnalysis(cacheKey, analysisType);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 为补货建议优化的专用方法
|
||||
// */
|
||||
// public Map<String, Object> generateReplenishmentSuggestions(Map<String, Object> inventoryData) {
|
||||
// try {
|
||||
// String prompt = buildReplenishmentPrompt(inventoryData);
|
||||
//
|
||||
// Map<String, Object> requestBody = Map.of(
|
||||
// "model", model,
|
||||
// "messages", List.of(
|
||||
// Map.of("role", "system", "content",
|
||||
// "你是一个经验丰富的电商库存管理专家,擅长制定补货策略。"),
|
||||
// Map.of("role", "user", "content", prompt)
|
||||
// ),
|
||||
// "temperature", 0.2,
|
||||
// "max_tokens", 1500,
|
||||
// "response_format", Map.of("type", "json_object")
|
||||
// );
|
||||
//
|
||||
// String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
//
|
||||
// Request request = new Request.Builder()
|
||||
// .url(apiEndpoint)
|
||||
// .header("Authorization", "Bearer " + apiKey)
|
||||
// .post(RequestBody.create(jsonBody, JSON))
|
||||
// .build();
|
||||
//
|
||||
// // 设置更短的超时时间,因为补货建议需要快速响应
|
||||
// OkHttpClient quickClient = okHttpClient.newBuilder()
|
||||
// .readTimeout(15, TimeUnit.SECONDS)
|
||||
// .build();
|
||||
//
|
||||
// try (Response response = quickClient.newCall(request).execute()) {
|
||||
// if (response.isSuccessful()) {
|
||||
// String responseBody = response.body().string();
|
||||
// return parseReplenishmentResponse(responseBody);
|
||||
// } else {
|
||||
// // 如果API失败,使用本地算法生成补货建议
|
||||
// return generateLocalReplenishmentSuggestions(inventoryData);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// } catch (Exception e) {
|
||||
// log.warn("AI补货建议失败,使用本地算法", e);
|
||||
// return generateLocalReplenishmentSuggestions(inventoryData);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 本地补货算法 - 服务降级方案
|
||||
// */
|
||||
// private Map<String, Object> generateLocalReplenishmentSuggestions(Map<String, Object> inventoryData) {
|
||||
// List<Map<String, Object>> products = (List<Map<String, Object>>) inventoryData.get("data");
|
||||
// List<Map<String, Object>> suggestions = new ArrayList<>();
|
||||
// int totalQuantity = 0;
|
||||
// double estimatedCost = 0.0;
|
||||
//
|
||||
// for (Map<String, Object> product : products) {
|
||||
// String status = (String) product.get("inventoryStatus");
|
||||
//
|
||||
// // 只处理需要补货的产品
|
||||
// if ("HEALTHY".equals(status) || "OVERSTOCK".equals(status)) {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// int currentStock = (int) product.getOrDefault("currentStock", 0);
|
||||
// int safetyStock = (int) product.getOrDefault("safetyStock", 100);
|
||||
// double avgDailySales = ((Integer) product.getOrDefault("avgDailySales", 10)).doubleValue();
|
||||
// double coverDays = (double) product.getOrDefault("coverDays", 0.0);
|
||||
//
|
||||
// Map<String, Object> suggestion = new HashMap<>();
|
||||
// suggestion.put("product_id", product.get("productId"));
|
||||
// suggestion.put("product_name", product.get("productName"));
|
||||
//
|
||||
// // 本地补货逻辑
|
||||
// int suggestedQty;
|
||||
// String urgency;
|
||||
//
|
||||
// if (currentStock <= 0) {
|
||||
// suggestedQty = (int) (avgDailySales * 30);
|
||||
// urgency = "紧急";
|
||||
// } else if (coverDays < 3) {
|
||||
// suggestedQty = (int) (avgDailySales * 15 - currentStock);
|
||||
// urgency = "高";
|
||||
// } else if (currentStock < safetyStock) {
|
||||
// suggestedQty = safetyStock * 2 - currentStock;
|
||||
// urgency = "中";
|
||||
// } else {
|
||||
// suggestedQty = (int) (avgDailySales * 7);
|
||||
// urgency = "低";
|
||||
// }
|
||||
//
|
||||
// suggestedQty = Math.max(suggestedQty, 10);
|
||||
// totalQuantity += suggestedQty;
|
||||
//
|
||||
// suggestion.put("suggested_quantity", suggestedQty);
|
||||
// suggestion.put("urgency", urgency);
|
||||
// suggestion.put("reason", "本地算法计算");
|
||||
// suggestion.put("expected_cover_days", suggestedQty / Math.max(avgDailySales, 1));
|
||||
//
|
||||
// suggestions.add(suggestion);
|
||||
// }
|
||||
//
|
||||
// return Map.of(
|
||||
// "success", true,
|
||||
// "source", "local_algorithm",
|
||||
// "replenishment_list", suggestions,
|
||||
// "total_replenishment_quantity", totalQuantity,
|
||||
// "analysis_summary", "基于本地规则生成的补货建议",
|
||||
// "recommendations", List.of(
|
||||
// "建议优先处理标记为'紧急'的商品",
|
||||
// "此为本地降级方案,AI分析恢复后将提供更精确建议"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 自定义重试拦截器
|
||||
// */
|
||||
// private static class RetryInterceptor implements Interceptor {
|
||||
// private final int maxRetries;
|
||||
//
|
||||
// public RetryInterceptor(int maxRetries) {
|
||||
// this.maxRetries = maxRetries;
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public Response intercept(Chain chain) throws IOException {
|
||||
// Request request = chain.request();
|
||||
// Response response = null;
|
||||
// IOException exception = null;
|
||||
//
|
||||
// // 重试逻辑
|
||||
// for (int retryCount = 0; retryCount <= maxRetries; retryCount++) {
|
||||
// try {
|
||||
// response = chain.proceed(request);
|
||||
//
|
||||
// // 只有服务器错误(5xx)或特定客户端错误才重试
|
||||
// if (response.isSuccessful() ||
|
||||
// (response.code() != 503 && response.code() != 429 && response.code() != 408)) {
|
||||
// return response;
|
||||
// }
|
||||
//
|
||||
// log.warn("API请求失败,状态码: {}, 重试: {}/{}",
|
||||
// response.code(), retryCount, maxRetries);
|
||||
//
|
||||
// // 关闭响应体
|
||||
// response.close();
|
||||
//
|
||||
// } catch (IOException e) {
|
||||
// exception = e;
|
||||
// log.warn("网络异常,重试: {}/{}", retryCount, maxRetries, e);
|
||||
// }
|
||||
//
|
||||
// // 如果不是最后一次重试,等待一段时间
|
||||
// if (retryCount < maxRetries) {
|
||||
// try {
|
||||
// // 指数退避:1s, 2s, 4s...
|
||||
// long waitTime = (long) Math.pow(2, retryCount) * 1000;
|
||||
// Thread.sleep(waitTime);
|
||||
// } catch (InterruptedException e) {
|
||||
// Thread.currentThread().interrupt();
|
||||
// throw new IOException("重试被中断", e);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (exception != null) {
|
||||
// throw exception;
|
||||
// }
|
||||
//
|
||||
// return response;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 日志拦截器
|
||||
// */
|
||||
// private static class LoggingInterceptor implements Interceptor {
|
||||
// @Override
|
||||
// public Response intercept(Chain chain) throws IOException {
|
||||
// Request request = chain.request();
|
||||
// long startTime = System.currentTimeMillis();
|
||||
//
|
||||
// log.debug("发送请求: {} {}", request.method(), request.url());
|
||||
//
|
||||
// Response response;
|
||||
// try {
|
||||
// response = chain.proceed(request);
|
||||
// } catch (IOException e) {
|
||||
// long duration = System.currentTimeMillis() - startTime;
|
||||
// log.error("请求失败: {} {} - 耗时: {}ms",
|
||||
// request.method(), request.url(), duration, e);
|
||||
// throw e;
|
||||
// }
|
||||
//
|
||||
// long duration = System.currentTimeMillis() - startTime;
|
||||
// log.info("收到响应: {} {} - 状态: {} - 耗时: {}ms",
|
||||
// request.method(), request.url(), response.code(), duration);
|
||||
//
|
||||
// return response;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 错误处理
|
||||
// */
|
||||
// private void handleErrorResponse(Response response, String cacheKey) throws IOException {
|
||||
// int code = response.code();
|
||||
// String errorBody = response.body() != null ? response.body().string() : "无错误详情";
|
||||
//
|
||||
// log.error("DeepSeek API错误响应: 状态码={}, 错误信息={}", code, errorBody);
|
||||
//
|
||||
// // 根据错误类型处理
|
||||
// if (code == 401) {
|
||||
// throw new RuntimeException("API密钥无效或已过期");
|
||||
// } else if (code == 429) {
|
||||
// throw new RuntimeException("请求频率超限,请稍后重试");
|
||||
// } else if (code == 503) {
|
||||
// // 服务不可用,尝试使用缓存
|
||||
// if (cachedAnalysis.containsKey(cacheKey)) {
|
||||
// log.info("服务不可用,使用缓存结果");
|
||||
// throw new ServiceUnavailableException("服务不可用,已返回缓存结果");
|
||||
// }
|
||||
// throw new RuntimeException("DeepSeek服务暂时不可用,请稍后重试");
|
||||
// } else {
|
||||
// throw new RuntimeException(String.format("API请求失败: %d - %s", code, errorBody));
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 服务降级:获取缓存或基础分析
|
||||
// */
|
||||
// private Map<String, Object> getFallbackAnalysis(String cacheKey, String analysisType) {
|
||||
// // 1. 首先尝试缓存
|
||||
// if (cachedAnalysis.containsKey(cacheKey)) {
|
||||
// log.info("使用缓存的AI分析结果");
|
||||
// Map<String, Object> cached = (Map<String, Object>) cachedAnalysis.get(cacheKey);
|
||||
// cached.put("source", "cached");
|
||||
// return cached;
|
||||
// }
|
||||
//
|
||||
// // 2. 返回基础分析模板
|
||||
// log.info("返回基础分析模板");
|
||||
// return Map.of(
|
||||
// "success", false,
|
||||
// "source", "fallback_template",
|
||||
// "message", "AI分析服务暂时不可用",
|
||||
// "basic_analysis", Map.of(
|
||||
// "suggestion", "建议检查库存水平,重点关注缺货商品",
|
||||
// "generated_at", new Date()
|
||||
// ),
|
||||
// "recommendations", List.of(
|
||||
// "1. 优先处理库存为0的商品",
|
||||
// "2. 检查日销量高但库存低的商品",
|
||||
// "3. AI服务恢复后重新获取详细分析"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// // 其他辅助方法(buildRequestBody, parseResponse等)保持原有逻辑
|
||||
// // ...
|
||||
//}
|
||||
//
|
||||
//// 自定义异常类
|
||||
//class ServiceUnavailableException extends RuntimeException {
|
||||
// public ServiceUnavailableException(String message) {
|
||||
// super(message);
|
||||
// }
|
||||
//}
|
||||
|
|
@ -1,24 +1,19 @@
|
|||
package cn.qihangerp.erp.serviceImpl;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import okhttp3.*;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 库存与销售示例分析(演示数据处理);报告由本地规则生成,不调用外部大模型 API。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
public class InventorySalesAnalyzer {
|
||||
|
||||
// 配置你的 DeepSeek API 信息
|
||||
private static final String API_KEY = "sk-e1f3aecc45e44eca9451d5a659a4bc91";
|
||||
private static final String API_URL = "https://api.deepseek.com/v1/chat/completions";
|
||||
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build();
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// 你的数据
|
||||
|
|
@ -30,14 +25,12 @@ public class InventorySalesAnalyzer {
|
|||
try {
|
||||
System.out.println("开始分析库存与销售数据...\n");
|
||||
|
||||
// 1. 解析数据
|
||||
List<InventoryItem> inventoryList = parseInventoryData();
|
||||
List<SalesOrder> salesList = parseSalesData();
|
||||
|
||||
// 2. 分析数据并生成报告
|
||||
String analysisResult = analyzeInventoryAndSales(inventoryList, salesList);
|
||||
|
||||
System.out.println("=== AI 分析报告 ===\n");
|
||||
System.out.println("=== 分析报告 ===\n");
|
||||
System.out.println(analysisResult);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
|
@ -49,12 +42,10 @@ public class InventorySalesAnalyzer {
|
|||
* 核心分析方法
|
||||
*/
|
||||
public static String analyzeInventoryAndSales(List<InventoryItem> inventory,
|
||||
List<SalesOrder> sales) throws IOException {
|
||||
List<SalesOrder> sales) {
|
||||
|
||||
// 1. 数据预处理:按 SKU ID 关联库存和销售数据
|
||||
Map<Integer, SkuAnalysis> analysisMap = new HashMap<>();
|
||||
|
||||
// 初始化库存数据
|
||||
for (InventoryItem item : inventory) {
|
||||
SkuAnalysis analysis = new SkuAnalysis();
|
||||
analysis.id = item.id;
|
||||
|
|
@ -64,177 +55,96 @@ public class InventorySalesAnalyzer {
|
|||
analysisMap.put(item.id, analysis);
|
||||
}
|
||||
|
||||
// 统计销售数据
|
||||
for (SalesOrder order : sales) {
|
||||
if (analysisMap.containsKey(order.skuId)) {
|
||||
SkuAnalysis analysis = analysisMap.get(order.skuId);
|
||||
analysis.totalSales += order.count;
|
||||
analysis.totalRevenue += order.itemAmount;
|
||||
analysis.orderCount++;
|
||||
|
||||
// 记录销售时间(用于趋势分析)
|
||||
analysis.salesTimes.add(order.orderTime);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算关键指标
|
||||
for (SkuAnalysis analysis : analysisMap.values()) {
|
||||
// 计算日均销量(假设数据是最近30天的)
|
||||
analysis.dailyAvgSales = analysis.totalSales / 30.0;
|
||||
|
||||
// 计算可售天数
|
||||
if (analysis.dailyAvgSales > 0) {
|
||||
analysis.daysOfSupply = analysis.stockNum / analysis.dailyAvgSales;
|
||||
} else {
|
||||
analysis.daysOfSupply = 999; // 无销售
|
||||
analysis.daysOfSupply = 999;
|
||||
}
|
||||
|
||||
// 判断库存状态
|
||||
analysis.stockStatus = determineStockStatus(analysis.stockNum, analysis.dailyAvgSales);
|
||||
}
|
||||
|
||||
// 3. 构建 AI 分析提示词
|
||||
String prompt = buildAnalysisPrompt(analysisMap);
|
||||
|
||||
// 4. 调用 DeepSeek API
|
||||
return callDeepSeekAPI(prompt);
|
||||
return buildLocalAnalysisReport(analysisMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AI 分析提示词
|
||||
*/
|
||||
private static String buildAnalysisPrompt(Map<Integer, SkuAnalysis> analysisMap) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
private static String buildLocalAnalysisReport(Map<Integer, SkuAnalysis> analysisMap) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## 库存健康度与销售概览(规则生成,非云端大模型)\n\n");
|
||||
sb.append("产品:雷士照明 LED 吸顶灯灯芯 | 分析时间:").append(new Date()).append("\n\n");
|
||||
|
||||
prompt.append("你是一名专业的电商库存管理专家。请分析以下 LED 灯具产品的库存与销售数据,并提供专业的分析报告和建议:\n\n");
|
||||
List<SkuAnalysis> rows = new ArrayList<>(analysisMap.values());
|
||||
rows.sort(Comparator.comparingInt(a -> a.id));
|
||||
|
||||
prompt.append("=== 数据概览 ===\n");
|
||||
prompt.append("产品名称:雷士照明 LED 吸顶灯灯芯\n");
|
||||
prompt.append("分析时间:").append(new Date()).append("\n\n");
|
||||
|
||||
prompt.append("=== 详细数据 ===\n");
|
||||
prompt.append(String.format("%-8s %-12s %-8s %-8s %-12s %-10s %-15s\n",
|
||||
"SKU ID", "规格", "库存量", "总销量", "总销售额", "可售天数", "库存状态"));
|
||||
prompt.append("-".repeat(80)).append("\n");
|
||||
|
||||
for (SkuAnalysis analysis : analysisMap.values()) {
|
||||
prompt.append(String.format("%-8d %-12s %-8d %-8d %-12.2f %-10.1f %-15s\n",
|
||||
analysis.id,
|
||||
analysis.skuName,
|
||||
analysis.stockNum,
|
||||
analysis.totalSales,
|
||||
analysis.totalRevenue,
|
||||
analysis.daysOfSupply,
|
||||
analysis.stockStatus
|
||||
));
|
||||
sb.append("| SKU | 规格 | 库存 | 总销量 | 销售额 | 可售天数(估) | 状态 |\n");
|
||||
sb.append("|-----|------|------|--------|--------|-------------|------|\n");
|
||||
for (SkuAnalysis a : rows) {
|
||||
sb.append(String.format("| %d | %s | %d | %d | %.2f | %.1f | %s |\n",
|
||||
a.id, a.skuName, a.stockNum, a.totalSales, a.totalRevenue, a.daysOfSupply, a.stockStatus));
|
||||
}
|
||||
|
||||
prompt.append("\n=== 分析要求 ===\n");
|
||||
prompt.append("请基于以上数据,提供以下分析:\n");
|
||||
prompt.append("1. **库存健康度分析**:评估每个SKU的库存状况,识别缺货风险\n");
|
||||
prompt.append("2. **销售表现分析**:分析各规格产品的销售情况,找出畅销款和滞销款\n");
|
||||
prompt.append("3. **补货建议**:\n");
|
||||
prompt.append(" - 哪些SKU需要立即补货?建议补货数量?\n");
|
||||
prompt.append(" - 哪些SKU库存过多?建议如何清理?\n");
|
||||
prompt.append(" - 建议的安全库存水平\n");
|
||||
prompt.append("4. **运营建议**:基于销售模式,给出采购、促销或产品组合建议\n\n");
|
||||
|
||||
prompt.append("请以专业报告格式回复,包含具体数据和理由。");
|
||||
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 DeepSeek API
|
||||
*/
|
||||
private static String callDeepSeekAPI(String prompt) throws IOException {
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", "deepseek-chat");
|
||||
requestBody.put("messages", Arrays.asList(
|
||||
Map.of("role", "user", "content", prompt)
|
||||
));
|
||||
requestBody.put("temperature", 0.3); // 降低随机性,使分析更稳定
|
||||
requestBody.put("max_tokens", 2000);
|
||||
|
||||
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
// 创建请求
|
||||
Request request = new Request.Builder()
|
||||
.url(API_URL)
|
||||
.header("Authorization", "Bearer " + API_KEY)
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, JSON))
|
||||
.build();
|
||||
|
||||
// 发送请求(带重试机制)
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful()) {
|
||||
String responseBody = response.body().string();
|
||||
return extractContentFromResponse(responseBody);
|
||||
} else if (response.code() == 429 || response.code() >= 500) {
|
||||
// 频率限制或服务器错误,等待后重试
|
||||
System.out.println("请求失败,状态码: " + response.code() + ",等待重试...");
|
||||
Thread.sleep(2000 * (attempt + 1));
|
||||
continue;
|
||||
} else {
|
||||
throw new IOException("API请求失败: " + response.code() + " - " + response.message());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("请求被中断", e);
|
||||
sb.append("\n### 补货与运营建议(基于可售天数与状态)\n");
|
||||
for (SkuAnalysis a : rows) {
|
||||
if ("缺货".equals(a.stockStatus) || "急需补货".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 建议优先补货,当前可售天数约 ").append(String.format("%.1f", a.daysOfSupply)).append(" 天。\n");
|
||||
} else if ("库存积压".equals(a.stockStatus) || "库存偏高".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 库存偏高,可考虑促销或调整采购节奏。\n");
|
||||
} else if ("滞销".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 近周期销量低,建议核查定价/曝光或搭配销售。\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("API请求失败,已重试3次");
|
||||
sb.append("\n> 若需自然语言深度解读,请在本机启动 Ollama 后通过工作助手选择本地模型。\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 响应中提取内容
|
||||
*/
|
||||
private static String extractContentFromResponse(String responseBody) throws IOException {
|
||||
Map<String, Object> responseMap = objectMapper.readValue(responseBody,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseMap.get("choices");
|
||||
if (choices != null && !choices.isEmpty()) {
|
||||
Map<String, Object> choice = choices.get(0);
|
||||
Map<String, Object> message = (Map<String, Object>) choice.get("message");
|
||||
return (String) message.get("content");
|
||||
}
|
||||
|
||||
return "未获取到有效回复";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断库存状态
|
||||
*/
|
||||
private static String determineStockStatus(int stock, double dailySales) {
|
||||
if (stock == 0) return "缺货";
|
||||
if (dailySales == 0) return "滞销";
|
||||
|
||||
if (stock == 0) {
|
||||
return "缺货";
|
||||
}
|
||||
if (dailySales == 0) {
|
||||
return "滞销";
|
||||
}
|
||||
double daysOfSupply = stock / dailySales;
|
||||
|
||||
if (daysOfSupply < 7) return "急需补货";
|
||||
if (daysOfSupply < 14) return "需要补货";
|
||||
if (daysOfSupply < 30) return "库存正常";
|
||||
if (daysOfSupply < 60) return "库存偏高";
|
||||
if (daysOfSupply < 7) {
|
||||
return "急需补货";
|
||||
}
|
||||
if (daysOfSupply < 14) {
|
||||
return "需要补货";
|
||||
}
|
||||
if (daysOfSupply < 30) {
|
||||
return "库存正常";
|
||||
}
|
||||
if (daysOfSupply < 60) {
|
||||
return "库存偏高";
|
||||
}
|
||||
return "库存积压";
|
||||
}
|
||||
|
||||
// 数据解析方法
|
||||
private static List<InventoryItem> parseInventoryData() throws IOException {
|
||||
return objectMapper.readValue(INVENTORY_JSON,
|
||||
new TypeReference<List<InventoryItem>>() {});
|
||||
new TypeReference<List<InventoryItem>>() {
|
||||
});
|
||||
}
|
||||
|
||||
private static List<SalesOrder> parseSalesData() throws IOException {
|
||||
return objectMapper.readValue(SALES_JSON,
|
||||
new TypeReference<List<SalesOrder>>() {});
|
||||
new TypeReference<List<SalesOrder>>() {
|
||||
});
|
||||
}
|
||||
|
||||
// 数据类定义
|
||||
static class InventoryItem {
|
||||
public int id;
|
||||
@JsonProperty("goods_title")
|
||||
|
|
@ -270,4 +180,4 @@ public class InventorySalesAnalyzer {
|
|||
public String stockStatus = "未知";
|
||||
public List<String> salesTimes = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,4 @@ mybatis-plus:
|
|||
mapper-locations: classpath*:mapper/**/*Mapper.xml
|
||||
type-aliases-package: cn.qihangerp.oms.domain;cn.qihangerp.module.domain;cn.qihangerp.security.entity;
|
||||
# configuration:
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启sql日志
|
||||
|
||||
deepseek:
|
||||
api:
|
||||
key: sk-8318ebe8e9d049d2b2a8bf506ba9b5fe
|
||||
endpoint: https://api.deepseek.com/v1
|
||||
model: deepseek-chat
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启sql日志
|
||||
|
|
@ -67,11 +67,11 @@
|
|||
<!-- <artifactId>spring-kafka</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
|
||||
<!-- <dependency>-->
|
||||
<!-- <groupId>com.alibaba.cloud</groupId>-->
|
||||
<!-- <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>-->
|
||||
<!-- </dependency>-->
|
||||
<!--SpringCloud Alibaba nacos 服务发现依赖-->
|
||||
<dependency>
|
||||
<groupId>com.alibaba.cloud</groupId>
|
||||
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
|
||||
</dependency>
|
||||
<!-- 父模块已含 nacos-discovery + bootstrap;本模块启用 Nacos Config 拉取 erp-api.yaml -->
|
||||
|
||||
<dependency>
|
||||
<groupId>cn.qihangerp.core</groupId>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
package cn.qihangerp.erp.controller;
|
||||
|
||||
import cn.qihangerp.common.AjaxResult;
|
||||
import cn.qihangerp.common.enums.EnumShopType;
|
||||
import cn.qihangerp.model.request.ExternalGoodsDelistRequest;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo;
|
||||
import cn.qihangerp.security.common.BaseController;
|
||||
import cn.qihangerp.service.external.ExternalGoodsAppService;
|
||||
import cn.qihangerp.service.external.pdd.ExternalPddProperties;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* External goods entry(AK/SK 由安全过滤器校验)。
|
||||
* <p>不查询 {@code o_shop}:{@code shopId} 仅作业务侧店铺维度标识写入 {@code o_goods}/{@code o_goods_sku}。</p>
|
||||
* <ul>
|
||||
* <li>{@code POST /external/goods/upsert} — 商品上架/同步(新建默认 {@code o_goods.status=1})</li>
|
||||
* <li>{@code POST /external/goods/delist} — 本地下架({@code status=2}),不调各平台下架 API</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/external/goods")
|
||||
public class ExternalGoodsController extends BaseController {
|
||||
private final ExternalGoodsAppService externalGoodsAppService;
|
||||
private final ExternalPddProperties externalPddProperties;
|
||||
|
||||
@PostMapping("/upsert")
|
||||
public AjaxResult upsert(@RequestBody ExternalGoodsUpsertRequest req) {
|
||||
if (req == null || req.getShopId() == null || req.getShopId() <= 0) {
|
||||
return AjaxResult.error("参数错误:shopId不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getPlatform())) {
|
||||
return AjaxResult.error("参数错误:platform不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getOutGoodsId())) {
|
||||
return AjaxResult.error("参数错误:outGoodsId不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getTitle())) {
|
||||
return AjaxResult.error("参数错误:title不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getCategoryCode())) {
|
||||
return AjaxResult.error("参数错误:categoryCode不能为空");
|
||||
}
|
||||
if (!hasMainVisual(req)) {
|
||||
return AjaxResult.error("参数错误:mainImage 与 images 至少填一种");
|
||||
}
|
||||
if (req.getSkus() != null) {
|
||||
for (var s : req.getSkus()) {
|
||||
if (s == null || !StringUtils.hasText(s.getOutSkuId())) {
|
||||
continue;
|
||||
}
|
||||
if (s.getSalePrice() == null && s.getRetailPrice() == null) {
|
||||
return AjaxResult.error("参数错误:sku 需填写 salePrice 或 retailPrice,outSkuId=" + s.getOutSkuId());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EnumShopType platform;
|
||||
try {
|
||||
platform = EnumShopType.valueOf(req.getPlatform().trim());
|
||||
} catch (Exception ex) {
|
||||
return AjaxResult.error("platform不合法");
|
||||
}
|
||||
|
||||
if (EnumShopType.PDD.equals(platform) && externalPddProperties.isPublishEnabled()) {
|
||||
if (req.getPddPopAuth() == null) {
|
||||
return AjaxResult.error("参数错误:开启拼多多发布时 pddPopAuth 不能为空");
|
||||
}
|
||||
var auth = req.getPddPopAuth();
|
||||
if (!StringUtils.hasText(auth.getAppKey()) || !StringUtils.hasText(auth.getAppSecret())
|
||||
|| !StringUtils.hasText(auth.getAccessToken())) {
|
||||
return AjaxResult.error("参数错误:pddPopAuth 需提供 appKey、appSecret、accessToken");
|
||||
}
|
||||
}
|
||||
|
||||
ExternalGoodsUpsertResultVo vo = externalGoodsAppService.upsertGoods(req);
|
||||
return AjaxResult.success(vo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地下架:将 {@code o_goods.status} 置为 2(已下架)。不调用拼多多等平台的下架接口。
|
||||
*/
|
||||
@PostMapping("/delist")
|
||||
public AjaxResult delist(@RequestBody ExternalGoodsDelistRequest req) {
|
||||
if (req == null || req.getShopId() == null || req.getShopId() <= 0) {
|
||||
return AjaxResult.error("参数错误:shopId不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getOutGoodsId())) {
|
||||
return AjaxResult.error("参数错误:outGoodsId不能为空");
|
||||
}
|
||||
try {
|
||||
externalGoodsAppService.delistGoods(req);
|
||||
return AjaxResult.success();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return AjaxResult.error(ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean hasMainVisual(ExternalGoodsUpsertRequest req) {
|
||||
if (StringUtils.hasText(req.getMainImage())) {
|
||||
return true;
|
||||
}
|
||||
List<String> imgs = req.getImages();
|
||||
if (imgs == null || imgs.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
for (String u : imgs) {
|
||||
if (StringUtils.hasText(u)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package cn.qihangerp.erp.controller;
|
||||
|
||||
import cn.qihangerp.common.AjaxResult;
|
||||
import cn.qihangerp.security.common.BaseController;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 历史接口占位:原写入 {@code o_shop} 的店铺授权已弃用。
|
||||
* <p><b>o_shop 表对外部集成已弃用</b>:拼多多凭证请在 {@code POST /external/goods/upsert} 的 {@code pddPopAuth} 中传入。</p>
|
||||
*
|
||||
* @author guochengyu
|
||||
* @deprecated 固定返回错误,勿再调用
|
||||
*/
|
||||
@Deprecated
|
||||
@RestController
|
||||
@RequestMapping("/external/shop")
|
||||
public class ExternalShopController extends BaseController {
|
||||
|
||||
@PostMapping("/credential")
|
||||
public AjaxResult pushCredential() {
|
||||
return AjaxResult.error("已停用:o_shop 已弃用,请在 POST /external/goods/upsert 请求体 pddPopAuth 传入拼多多凭证");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,365 +0,0 @@
|
|||
//package cn.qihangerp.erp.serviceImpl;
|
||||
//
|
||||
//import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
//import jakarta.annotation.PostConstruct;
|
||||
//import okhttp3.*;
|
||||
//import org.slf4j.Logger;
|
||||
//import org.slf4j.LoggerFactory;
|
||||
//import org.springframework.beans.factory.annotation.Value;
|
||||
//import org.springframework.stereotype.Service;
|
||||
//import java.io.IOException;
|
||||
//import java.util.*;
|
||||
//import java.util.concurrent.TimeUnit;
|
||||
//
|
||||
//@Service
|
||||
//public class DeepSeekService {
|
||||
//
|
||||
// private static final Logger log = LoggerFactory.getLogger(DeepSeekService.class);
|
||||
// private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
//
|
||||
// @Value("${deepseek.api.key}")
|
||||
// private String apiKey;
|
||||
//
|
||||
// @Value("${deepseek.api.endpoint:https://api.deepseek.com/v1/chat/completions}")
|
||||
// private String apiEndpoint;
|
||||
//
|
||||
// @Value("${deepseek.api.model:deepseek-chat}")
|
||||
// private String model;
|
||||
//
|
||||
// private OkHttpClient okHttpClient;
|
||||
// private final ObjectMapper objectMapper;
|
||||
//
|
||||
// // 缓存最近一次成功的分析结果
|
||||
// private Map<String, Object> cachedAnalysis = new HashMap<>();
|
||||
//
|
||||
// public DeepSeekService(ObjectMapper objectMapper) {
|
||||
// this.objectMapper = objectMapper;
|
||||
// }
|
||||
//
|
||||
// @PostConstruct
|
||||
// public void init() {
|
||||
// // 配置具有重试和连接池功能的OkHttpClient
|
||||
// this.okHttpClient = new OkHttpClient.Builder()
|
||||
// .connectTimeout(15, TimeUnit.SECONDS) // 连接超时
|
||||
// .readTimeout(30, TimeUnit.SECONDS) // 读取超时
|
||||
// .writeTimeout(15, TimeUnit.SECONDS) // 写入超时
|
||||
// .connectionPool(new ConnectionPool(5, 5, TimeUnit.MINUTES)) // 连接池
|
||||
// .addInterceptor(new RetryInterceptor(3)) // 自定义重试拦截器
|
||||
// .addInterceptor(new LoggingInterceptor()) // 日志拦截器
|
||||
// .build();
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 调用DeepSeek API - 带有Spring Retry重试机制
|
||||
// */
|
||||
//// @Retryable(
|
||||
//// value = {IOException.class, RuntimeException.class},
|
||||
//// maxAttempts = 3,
|
||||
//// backoff = @Backoff(delay = 1000, multiplier = 2, maxDelay = 10000)
|
||||
//// )
|
||||
// public Map<String, Object> analyzeData(Map<String, Object> formattedData, String analysisType) {
|
||||
// String cacheKey = generateCacheKey(formattedData, analysisType);
|
||||
//
|
||||
// try {
|
||||
// // 1. 构建请求体
|
||||
// String requestBody = buildRequestBody(formattedData, analysisType);
|
||||
// RequestBody body = RequestBody.create(requestBody, JSON);
|
||||
//
|
||||
// // 2. 构建请求
|
||||
// Request request = new Request.Builder()
|
||||
// .url(apiEndpoint)
|
||||
// .header("Authorization", "Bearer " + apiKey)
|
||||
// .header("Content-Type", "application/json")
|
||||
// .post(body)
|
||||
// .build();
|
||||
//
|
||||
// // 3. 执行请求并处理响应
|
||||
// try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
// if (!response.isSuccessful()) {
|
||||
// handleErrorResponse(response, cacheKey);
|
||||
// }
|
||||
//
|
||||
// String responseBody = response.body().string();
|
||||
// Map<String, Object> result = parseResponse(responseBody, analysisType);
|
||||
//
|
||||
// // 缓存成功的结果
|
||||
// cacheSuccessfulResult(cacheKey, result);
|
||||
// return result;
|
||||
// }
|
||||
//
|
||||
// } catch (Exception e) {
|
||||
// log.error("调用DeepSeek API失败,尝试使用缓存或降级方案", e);
|
||||
// return getFallbackAnalysis(cacheKey, analysisType);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 为补货建议优化的专用方法
|
||||
// */
|
||||
// public Map<String, Object> generateReplenishmentSuggestions(Map<String, Object> inventoryData) {
|
||||
// try {
|
||||
// String prompt = buildReplenishmentPrompt(inventoryData);
|
||||
//
|
||||
// Map<String, Object> requestBody = Map.of(
|
||||
// "model", model,
|
||||
// "messages", List.of(
|
||||
// Map.of("role", "system", "content",
|
||||
// "你是一个经验丰富的电商库存管理专家,擅长制定补货策略。"),
|
||||
// Map.of("role", "user", "content", prompt)
|
||||
// ),
|
||||
// "temperature", 0.2,
|
||||
// "max_tokens", 1500,
|
||||
// "response_format", Map.of("type", "json_object")
|
||||
// );
|
||||
//
|
||||
// String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
//
|
||||
// Request request = new Request.Builder()
|
||||
// .url(apiEndpoint)
|
||||
// .header("Authorization", "Bearer " + apiKey)
|
||||
// .post(RequestBody.create(jsonBody, JSON))
|
||||
// .build();
|
||||
//
|
||||
// // 设置更短的超时时间,因为补货建议需要快速响应
|
||||
// OkHttpClient quickClient = okHttpClient.newBuilder()
|
||||
// .readTimeout(15, TimeUnit.SECONDS)
|
||||
// .build();
|
||||
//
|
||||
// try (Response response = quickClient.newCall(request).execute()) {
|
||||
// if (response.isSuccessful()) {
|
||||
// String responseBody = response.body().string();
|
||||
// return parseReplenishmentResponse(responseBody);
|
||||
// } else {
|
||||
// // 如果API失败,使用本地算法生成补货建议
|
||||
// return generateLocalReplenishmentSuggestions(inventoryData);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// } catch (Exception e) {
|
||||
// log.warn("AI补货建议失败,使用本地算法", e);
|
||||
// return generateLocalReplenishmentSuggestions(inventoryData);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 本地补货算法 - 服务降级方案
|
||||
// */
|
||||
// private Map<String, Object> generateLocalReplenishmentSuggestions(Map<String, Object> inventoryData) {
|
||||
// List<Map<String, Object>> products = (List<Map<String, Object>>) inventoryData.get("data");
|
||||
// List<Map<String, Object>> suggestions = new ArrayList<>();
|
||||
// int totalQuantity = 0;
|
||||
// double estimatedCost = 0.0;
|
||||
//
|
||||
// for (Map<String, Object> product : products) {
|
||||
// String status = (String) product.get("inventoryStatus");
|
||||
//
|
||||
// // 只处理需要补货的产品
|
||||
// if ("HEALTHY".equals(status) || "OVERSTOCK".equals(status)) {
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// int currentStock = (int) product.getOrDefault("currentStock", 0);
|
||||
// int safetyStock = (int) product.getOrDefault("safetyStock", 100);
|
||||
// double avgDailySales = ((Integer) product.getOrDefault("avgDailySales", 10)).doubleValue();
|
||||
// double coverDays = (double) product.getOrDefault("coverDays", 0.0);
|
||||
//
|
||||
// Map<String, Object> suggestion = new HashMap<>();
|
||||
// suggestion.put("product_id", product.get("productId"));
|
||||
// suggestion.put("product_name", product.get("productName"));
|
||||
//
|
||||
// // 本地补货逻辑
|
||||
// int suggestedQty;
|
||||
// String urgency;
|
||||
//
|
||||
// if (currentStock <= 0) {
|
||||
// suggestedQty = (int) (avgDailySales * 30);
|
||||
// urgency = "紧急";
|
||||
// } else if (coverDays < 3) {
|
||||
// suggestedQty = (int) (avgDailySales * 15 - currentStock);
|
||||
// urgency = "高";
|
||||
// } else if (currentStock < safetyStock) {
|
||||
// suggestedQty = safetyStock * 2 - currentStock;
|
||||
// urgency = "中";
|
||||
// } else {
|
||||
// suggestedQty = (int) (avgDailySales * 7);
|
||||
// urgency = "低";
|
||||
// }
|
||||
//
|
||||
// suggestedQty = Math.max(suggestedQty, 10);
|
||||
// totalQuantity += suggestedQty;
|
||||
//
|
||||
// suggestion.put("suggested_quantity", suggestedQty);
|
||||
// suggestion.put("urgency", urgency);
|
||||
// suggestion.put("reason", "本地算法计算");
|
||||
// suggestion.put("expected_cover_days", suggestedQty / Math.max(avgDailySales, 1));
|
||||
//
|
||||
// suggestions.add(suggestion);
|
||||
// }
|
||||
//
|
||||
// return Map.of(
|
||||
// "success", true,
|
||||
// "source", "local_algorithm",
|
||||
// "replenishment_list", suggestions,
|
||||
// "total_replenishment_quantity", totalQuantity,
|
||||
// "analysis_summary", "基于本地规则生成的补货建议",
|
||||
// "recommendations", List.of(
|
||||
// "建议优先处理标记为'紧急'的商品",
|
||||
// "此为本地降级方案,AI分析恢复后将提供更精确建议"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 自定义重试拦截器
|
||||
// */
|
||||
// private static class RetryInterceptor implements Interceptor {
|
||||
// private final int maxRetries;
|
||||
//
|
||||
// public RetryInterceptor(int maxRetries) {
|
||||
// this.maxRetries = maxRetries;
|
||||
// }
|
||||
//
|
||||
// @Override
|
||||
// public Response intercept(Chain chain) throws IOException {
|
||||
// Request request = chain.request();
|
||||
// Response response = null;
|
||||
// IOException exception = null;
|
||||
//
|
||||
// // 重试逻辑
|
||||
// for (int retryCount = 0; retryCount <= maxRetries; retryCount++) {
|
||||
// try {
|
||||
// response = chain.proceed(request);
|
||||
//
|
||||
// // 只有服务器错误(5xx)或特定客户端错误才重试
|
||||
// if (response.isSuccessful() ||
|
||||
// (response.code() != 503 && response.code() != 429 && response.code() != 408)) {
|
||||
// return response;
|
||||
// }
|
||||
//
|
||||
// log.warn("API请求失败,状态码: {}, 重试: {}/{}",
|
||||
// response.code(), retryCount, maxRetries);
|
||||
//
|
||||
// // 关闭响应体
|
||||
// response.close();
|
||||
//
|
||||
// } catch (IOException e) {
|
||||
// exception = e;
|
||||
// log.warn("网络异常,重试: {}/{}", retryCount, maxRetries, e);
|
||||
// }
|
||||
//
|
||||
// // 如果不是最后一次重试,等待一段时间
|
||||
// if (retryCount < maxRetries) {
|
||||
// try {
|
||||
// // 指数退避:1s, 2s, 4s...
|
||||
// long waitTime = (long) Math.pow(2, retryCount) * 1000;
|
||||
// Thread.sleep(waitTime);
|
||||
// } catch (InterruptedException e) {
|
||||
// Thread.currentThread().interrupt();
|
||||
// throw new IOException("重试被中断", e);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (exception != null) {
|
||||
// throw exception;
|
||||
// }
|
||||
//
|
||||
// return response;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 日志拦截器
|
||||
// */
|
||||
// private static class LoggingInterceptor implements Interceptor {
|
||||
// @Override
|
||||
// public Response intercept(Chain chain) throws IOException {
|
||||
// Request request = chain.request();
|
||||
// long startTime = System.currentTimeMillis();
|
||||
//
|
||||
// log.debug("发送请求: {} {}", request.method(), request.url());
|
||||
//
|
||||
// Response response;
|
||||
// try {
|
||||
// response = chain.proceed(request);
|
||||
// } catch (IOException e) {
|
||||
// long duration = System.currentTimeMillis() - startTime;
|
||||
// log.error("请求失败: {} {} - 耗时: {}ms",
|
||||
// request.method(), request.url(), duration, e);
|
||||
// throw e;
|
||||
// }
|
||||
//
|
||||
// long duration = System.currentTimeMillis() - startTime;
|
||||
// log.info("收到响应: {} {} - 状态: {} - 耗时: {}ms",
|
||||
// request.method(), request.url(), response.code(), duration);
|
||||
//
|
||||
// return response;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 错误处理
|
||||
// */
|
||||
// private void handleErrorResponse(Response response, String cacheKey) throws IOException {
|
||||
// int code = response.code();
|
||||
// String errorBody = response.body() != null ? response.body().string() : "无错误详情";
|
||||
//
|
||||
// log.error("DeepSeek API错误响应: 状态码={}, 错误信息={}", code, errorBody);
|
||||
//
|
||||
// // 根据错误类型处理
|
||||
// if (code == 401) {
|
||||
// throw new RuntimeException("API密钥无效或已过期");
|
||||
// } else if (code == 429) {
|
||||
// throw new RuntimeException("请求频率超限,请稍后重试");
|
||||
// } else if (code == 503) {
|
||||
// // 服务不可用,尝试使用缓存
|
||||
// if (cachedAnalysis.containsKey(cacheKey)) {
|
||||
// log.info("服务不可用,使用缓存结果");
|
||||
// throw new ServiceUnavailableException("服务不可用,已返回缓存结果");
|
||||
// }
|
||||
// throw new RuntimeException("DeepSeek服务暂时不可用,请稍后重试");
|
||||
// } else {
|
||||
// throw new RuntimeException(String.format("API请求失败: %d - %s", code, errorBody));
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * 服务降级:获取缓存或基础分析
|
||||
// */
|
||||
// private Map<String, Object> getFallbackAnalysis(String cacheKey, String analysisType) {
|
||||
// // 1. 首先尝试缓存
|
||||
// if (cachedAnalysis.containsKey(cacheKey)) {
|
||||
// log.info("使用缓存的AI分析结果");
|
||||
// Map<String, Object> cached = (Map<String, Object>) cachedAnalysis.get(cacheKey);
|
||||
// cached.put("source", "cached");
|
||||
// return cached;
|
||||
// }
|
||||
//
|
||||
// // 2. 返回基础分析模板
|
||||
// log.info("返回基础分析模板");
|
||||
// return Map.of(
|
||||
// "success", false,
|
||||
// "source", "fallback_template",
|
||||
// "message", "AI分析服务暂时不可用",
|
||||
// "basic_analysis", Map.of(
|
||||
// "suggestion", "建议检查库存水平,重点关注缺货商品",
|
||||
// "generated_at", new Date()
|
||||
// ),
|
||||
// "recommendations", List.of(
|
||||
// "1. 优先处理库存为0的商品",
|
||||
// "2. 检查日销量高但库存低的商品",
|
||||
// "3. AI服务恢复后重新获取详细分析"
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
//
|
||||
// // 其他辅助方法(buildRequestBody, parseResponse等)保持原有逻辑
|
||||
// // ...
|
||||
//}
|
||||
//
|
||||
//// 自定义异常类
|
||||
//class ServiceUnavailableException extends RuntimeException {
|
||||
// public ServiceUnavailableException(String message) {
|
||||
// super(message);
|
||||
// }
|
||||
//}
|
||||
|
|
@ -1,24 +1,19 @@
|
|||
package cn.qihangerp.erp.serviceImpl;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import okhttp3.*;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 库存与销售示例分析(演示数据处理);报告由本地规则生成,不调用外部大模型 API。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
public class InventorySalesAnalyzer {
|
||||
|
||||
// 配置你的 DeepSeek API 信息
|
||||
private static final String API_KEY = "sk-e1f3aecc45e44eca9451d5a659a4bc91";
|
||||
private static final String API_URL = "https://api.deepseek.com/v1/chat/completions";
|
||||
|
||||
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8");
|
||||
private static final OkHttpClient client = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build();
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
// 你的数据
|
||||
|
|
@ -30,14 +25,12 @@ public class InventorySalesAnalyzer {
|
|||
try {
|
||||
System.out.println("开始分析库存与销售数据...\n");
|
||||
|
||||
// 1. 解析数据
|
||||
List<InventoryItem> inventoryList = parseInventoryData();
|
||||
List<SalesOrder> salesList = parseSalesData();
|
||||
|
||||
// 2. 分析数据并生成报告
|
||||
String analysisResult = analyzeInventoryAndSales(inventoryList, salesList);
|
||||
|
||||
System.out.println("=== AI 分析报告 ===\n");
|
||||
System.out.println("=== 分析报告 ===\n");
|
||||
System.out.println(analysisResult);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
|
@ -49,12 +42,10 @@ public class InventorySalesAnalyzer {
|
|||
* 核心分析方法
|
||||
*/
|
||||
public static String analyzeInventoryAndSales(List<InventoryItem> inventory,
|
||||
List<SalesOrder> sales) throws IOException {
|
||||
List<SalesOrder> sales) {
|
||||
|
||||
// 1. 数据预处理:按 SKU ID 关联库存和销售数据
|
||||
Map<Integer, SkuAnalysis> analysisMap = new HashMap<>();
|
||||
|
||||
// 初始化库存数据
|
||||
for (InventoryItem item : inventory) {
|
||||
SkuAnalysis analysis = new SkuAnalysis();
|
||||
analysis.id = item.id;
|
||||
|
|
@ -64,177 +55,96 @@ public class InventorySalesAnalyzer {
|
|||
analysisMap.put(item.id, analysis);
|
||||
}
|
||||
|
||||
// 统计销售数据
|
||||
for (SalesOrder order : sales) {
|
||||
if (analysisMap.containsKey(order.skuId)) {
|
||||
SkuAnalysis analysis = analysisMap.get(order.skuId);
|
||||
analysis.totalSales += order.count;
|
||||
analysis.totalRevenue += order.itemAmount;
|
||||
analysis.orderCount++;
|
||||
|
||||
// 记录销售时间(用于趋势分析)
|
||||
analysis.salesTimes.add(order.orderTime);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 计算关键指标
|
||||
for (SkuAnalysis analysis : analysisMap.values()) {
|
||||
// 计算日均销量(假设数据是最近30天的)
|
||||
analysis.dailyAvgSales = analysis.totalSales / 30.0;
|
||||
|
||||
// 计算可售天数
|
||||
if (analysis.dailyAvgSales > 0) {
|
||||
analysis.daysOfSupply = analysis.stockNum / analysis.dailyAvgSales;
|
||||
} else {
|
||||
analysis.daysOfSupply = 999; // 无销售
|
||||
analysis.daysOfSupply = 999;
|
||||
}
|
||||
|
||||
// 判断库存状态
|
||||
analysis.stockStatus = determineStockStatus(analysis.stockNum, analysis.dailyAvgSales);
|
||||
}
|
||||
|
||||
// 3. 构建 AI 分析提示词
|
||||
String prompt = buildAnalysisPrompt(analysisMap);
|
||||
|
||||
// 4. 调用 DeepSeek API
|
||||
return callDeepSeekAPI(prompt);
|
||||
return buildLocalAnalysisReport(analysisMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 AI 分析提示词
|
||||
*/
|
||||
private static String buildAnalysisPrompt(Map<Integer, SkuAnalysis> analysisMap) {
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
private static String buildLocalAnalysisReport(Map<Integer, SkuAnalysis> analysisMap) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## 库存健康度与销售概览(规则生成,非云端大模型)\n\n");
|
||||
sb.append("产品:雷士照明 LED 吸顶灯灯芯 | 分析时间:").append(new Date()).append("\n\n");
|
||||
|
||||
prompt.append("你是一名专业的电商库存管理专家。请分析以下 LED 灯具产品的库存与销售数据,并提供专业的分析报告和建议:\n\n");
|
||||
List<SkuAnalysis> rows = new ArrayList<>(analysisMap.values());
|
||||
rows.sort(Comparator.comparingInt(a -> a.id));
|
||||
|
||||
prompt.append("=== 数据概览 ===\n");
|
||||
prompt.append("产品名称:雷士照明 LED 吸顶灯灯芯\n");
|
||||
prompt.append("分析时间:").append(new Date()).append("\n\n");
|
||||
|
||||
prompt.append("=== 详细数据 ===\n");
|
||||
prompt.append(String.format("%-8s %-12s %-8s %-8s %-12s %-10s %-15s\n",
|
||||
"SKU ID", "规格", "库存量", "总销量", "总销售额", "可售天数", "库存状态"));
|
||||
prompt.append("-".repeat(80)).append("\n");
|
||||
|
||||
for (SkuAnalysis analysis : analysisMap.values()) {
|
||||
prompt.append(String.format("%-8d %-12s %-8d %-8d %-12.2f %-10.1f %-15s\n",
|
||||
analysis.id,
|
||||
analysis.skuName,
|
||||
analysis.stockNum,
|
||||
analysis.totalSales,
|
||||
analysis.totalRevenue,
|
||||
analysis.daysOfSupply,
|
||||
analysis.stockStatus
|
||||
));
|
||||
sb.append("| SKU | 规格 | 库存 | 总销量 | 销售额 | 可售天数(估) | 状态 |\n");
|
||||
sb.append("|-----|------|------|--------|--------|-------------|------|\n");
|
||||
for (SkuAnalysis a : rows) {
|
||||
sb.append(String.format("| %d | %s | %d | %d | %.2f | %.1f | %s |\n",
|
||||
a.id, a.skuName, a.stockNum, a.totalSales, a.totalRevenue, a.daysOfSupply, a.stockStatus));
|
||||
}
|
||||
|
||||
prompt.append("\n=== 分析要求 ===\n");
|
||||
prompt.append("请基于以上数据,提供以下分析:\n");
|
||||
prompt.append("1. **库存健康度分析**:评估每个SKU的库存状况,识别缺货风险\n");
|
||||
prompt.append("2. **销售表现分析**:分析各规格产品的销售情况,找出畅销款和滞销款\n");
|
||||
prompt.append("3. **补货建议**:\n");
|
||||
prompt.append(" - 哪些SKU需要立即补货?建议补货数量?\n");
|
||||
prompt.append(" - 哪些SKU库存过多?建议如何清理?\n");
|
||||
prompt.append(" - 建议的安全库存水平\n");
|
||||
prompt.append("4. **运营建议**:基于销售模式,给出采购、促销或产品组合建议\n\n");
|
||||
|
||||
prompt.append("请以专业报告格式回复,包含具体数据和理由。");
|
||||
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 DeepSeek API
|
||||
*/
|
||||
private static String callDeepSeekAPI(String prompt) throws IOException {
|
||||
// 构建请求体
|
||||
Map<String, Object> requestBody = new HashMap<>();
|
||||
requestBody.put("model", "deepseek-chat");
|
||||
requestBody.put("messages", Arrays.asList(
|
||||
Map.of("role", "user", "content", prompt)
|
||||
));
|
||||
requestBody.put("temperature", 0.3); // 降低随机性,使分析更稳定
|
||||
requestBody.put("max_tokens", 2000);
|
||||
|
||||
String jsonBody = objectMapper.writeValueAsString(requestBody);
|
||||
|
||||
// 创建请求
|
||||
Request request = new Request.Builder()
|
||||
.url(API_URL)
|
||||
.header("Authorization", "Bearer " + API_KEY)
|
||||
.header("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, JSON))
|
||||
.build();
|
||||
|
||||
// 发送请求(带重试机制)
|
||||
for (int attempt = 0; attempt < 3; attempt++) {
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (response.isSuccessful()) {
|
||||
String responseBody = response.body().string();
|
||||
return extractContentFromResponse(responseBody);
|
||||
} else if (response.code() == 429 || response.code() >= 500) {
|
||||
// 频率限制或服务器错误,等待后重试
|
||||
System.out.println("请求失败,状态码: " + response.code() + ",等待重试...");
|
||||
Thread.sleep(2000 * (attempt + 1));
|
||||
continue;
|
||||
} else {
|
||||
throw new IOException("API请求失败: " + response.code() + " - " + response.message());
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("请求被中断", e);
|
||||
sb.append("\n### 补货与运营建议(基于可售天数与状态)\n");
|
||||
for (SkuAnalysis a : rows) {
|
||||
if ("缺货".equals(a.stockStatus) || "急需补货".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 建议优先补货,当前可售天数约 ").append(String.format("%.1f", a.daysOfSupply)).append(" 天。\n");
|
||||
} else if ("库存积压".equals(a.stockStatus) || "库存偏高".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 库存偏高,可考虑促销或调整采购节奏。\n");
|
||||
} else if ("滞销".equals(a.stockStatus)) {
|
||||
sb.append("- SKU ").append(a.id).append(" (").append(a.skuName)
|
||||
.append("): 近周期销量低,建议核查定价/曝光或搭配销售。\n");
|
||||
}
|
||||
}
|
||||
|
||||
throw new IOException("API请求失败,已重试3次");
|
||||
sb.append("\n> 若需自然语言深度解读,请在本机启动 Ollama 后通过工作助手选择本地模型。\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 API 响应中提取内容
|
||||
*/
|
||||
private static String extractContentFromResponse(String responseBody) throws IOException {
|
||||
Map<String, Object> responseMap = objectMapper.readValue(responseBody,
|
||||
new TypeReference<Map<String, Object>>() {});
|
||||
|
||||
List<Map<String, Object>> choices = (List<Map<String, Object>>) responseMap.get("choices");
|
||||
if (choices != null && !choices.isEmpty()) {
|
||||
Map<String, Object> choice = choices.get(0);
|
||||
Map<String, Object> message = (Map<String, Object>) choice.get("message");
|
||||
return (String) message.get("content");
|
||||
}
|
||||
|
||||
return "未获取到有效回复";
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断库存状态
|
||||
*/
|
||||
private static String determineStockStatus(int stock, double dailySales) {
|
||||
if (stock == 0) return "缺货";
|
||||
if (dailySales == 0) return "滞销";
|
||||
|
||||
if (stock == 0) {
|
||||
return "缺货";
|
||||
}
|
||||
if (dailySales == 0) {
|
||||
return "滞销";
|
||||
}
|
||||
double daysOfSupply = stock / dailySales;
|
||||
|
||||
if (daysOfSupply < 7) return "急需补货";
|
||||
if (daysOfSupply < 14) return "需要补货";
|
||||
if (daysOfSupply < 30) return "库存正常";
|
||||
if (daysOfSupply < 60) return "库存偏高";
|
||||
if (daysOfSupply < 7) {
|
||||
return "急需补货";
|
||||
}
|
||||
if (daysOfSupply < 14) {
|
||||
return "需要补货";
|
||||
}
|
||||
if (daysOfSupply < 30) {
|
||||
return "库存正常";
|
||||
}
|
||||
if (daysOfSupply < 60) {
|
||||
return "库存偏高";
|
||||
}
|
||||
return "库存积压";
|
||||
}
|
||||
|
||||
// 数据解析方法
|
||||
private static List<InventoryItem> parseInventoryData() throws IOException {
|
||||
return objectMapper.readValue(INVENTORY_JSON,
|
||||
new TypeReference<List<InventoryItem>>() {});
|
||||
new TypeReference<List<InventoryItem>>() {
|
||||
});
|
||||
}
|
||||
|
||||
private static List<SalesOrder> parseSalesData() throws IOException {
|
||||
return objectMapper.readValue(SALES_JSON,
|
||||
new TypeReference<List<SalesOrder>>() {});
|
||||
new TypeReference<List<SalesOrder>>() {
|
||||
});
|
||||
}
|
||||
|
||||
// 数据类定义
|
||||
static class InventoryItem {
|
||||
public int id;
|
||||
@JsonProperty("goods_title")
|
||||
|
|
@ -270,4 +180,4 @@ public class InventorySalesAnalyzer {
|
|||
public String stockStatus = "未知";
|
||||
public List<String> salesTimes = new ArrayList<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,101 +1,35 @@
|
|||
# 本地/兜底配置:Nacos 不可达或 fail-fast=false 时使用;Nacos 中 erp-api.yaml 会覆盖同名项
|
||||
# 端口固定 38083(请在 Nacos erp-api.yaml 中同步 server.port)
|
||||
|
||||
server:
|
||||
port: 8083
|
||||
port: 38083
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: erp-api
|
||||
cloud:
|
||||
loadbalancer:
|
||||
nacos:
|
||||
enabled: true
|
||||
nacos:
|
||||
# serverAddr: 127.0.0.1:8848
|
||||
discovery:
|
||||
server-addr: 127.0.0.1:8848
|
||||
# username: nacos
|
||||
# password: nacos
|
||||
|
||||
data:
|
||||
# redis 配置
|
||||
redis:
|
||||
# 地址
|
||||
# host: 8.130.98.215
|
||||
host: 127.0.0.1
|
||||
# 端口,默认为6379
|
||||
host: 192.168.71.6
|
||||
port: 6379
|
||||
# 数据库索引
|
||||
database: 0
|
||||
# 密码
|
||||
# password: 123321
|
||||
# 连接超时时间
|
||||
password: HbxinhuaDB@2025
|
||||
timeout: 10s
|
||||
lettuce:
|
||||
pool:
|
||||
# 连接池中的最小空闲连接
|
||||
min-idle: 0
|
||||
# 连接池中的最大空闲连接
|
||||
max-idle: 8
|
||||
# 连接池的最大数据库连接数
|
||||
max-active: 8
|
||||
# #连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-wait: -1ms
|
||||
|
||||
datasource:
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://127.0.0.1:3306/qihang-erp?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
url: jdbc:mysql://192.168.71.10:3306/hbxh_erp_ecommerce_middleware?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: Andy_123
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
min-idle: 5
|
||||
connection-timeout: 30000
|
||||
idle-timeout: 600000
|
||||
max-lifetime: 1800000
|
||||
# kafka:
|
||||
# bootstrap-servers: localhost:9092
|
||||
# producer:
|
||||
# batch-size: 16384 #批量大小
|
||||
# acks: -1 #应答级别:多少个分区副本备份完成时向生产者发送ack确认(可选0、1、all/-1)
|
||||
# retries: 10 # 消息发送重试次数
|
||||
# # transaction-id-prefix: tx_1 #事务id前缀
|
||||
# buffer-memory: 33554432
|
||||
# key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
# value-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
# properties:
|
||||
# linger:
|
||||
# ms: 2000 #提交延迟
|
||||
# # partitioner: #指定分区器
|
||||
# # class: com.example.kafkademo.config.CustomizePartitioner
|
||||
# consumer:
|
||||
# group-id: testGroup #默认的消费组ID
|
||||
# enable-auto-commit: true #是否自动提交offset
|
||||
# auto-commit-interval: 2000 #提交offset延时
|
||||
# # 当kafka中没有初始offset或offset超出范围时将自动重置offset
|
||||
# # earliest:重置为分区中最小的offset;
|
||||
# # latest:重置为分区中最新的offset(消费分区中新产生的数据);
|
||||
# # none:只要有一个分区不存在已提交的offset,就抛出异常;
|
||||
# auto-offset-reset: latest
|
||||
# max-poll-records: 500 #单次拉取消息的最大条数
|
||||
# key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
# value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
# properties:
|
||||
# session:
|
||||
# timeout:
|
||||
# ms: 120000 # 消费会话超时时间(超过这个时间 consumer 没有发送心跳,就会触发 rebalance 操作)
|
||||
# request:
|
||||
# timeout:
|
||||
# ms: 18000 # 消费请求的超时时间
|
||||
|
||||
|
||||
|
||||
|
||||
password: HbxinhuaDB@2025
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:mapper/**/*Mapper.xml
|
||||
type-aliases-package: cn.qihangerp.oms.domain;cn.qihangerp.module.domain;cn.qihangerp.security.entity;
|
||||
# configuration:
|
||||
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启sql日志
|
||||
|
||||
deepseek:
|
||||
api:
|
||||
key:
|
||||
endpoint: https://api.deepseek.com/chat/completions
|
||||
model: deepseek-chat
|
||||
external:
|
||||
auth:
|
||||
api-key: external-uat-ak-001
|
||||
secret-key: external-uat-sk-001-9f2d3c4b5a6e7d8c
|
||||
timestamp-skew-ms: 300000
|
||||
|
||||
# 说明:对外商品接口见 temp/yunxi-erp-open-goods-upsert-api.md;Nacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
# 先于 application 加载(父模块已引入 spring-cloud-starter-bootstrap)
|
||||
#
|
||||
# 【本地无 Nacos】默认不连 Nacos:不设环境变量即可,仅用 jar 内 application.yml 启动。
|
||||
# 【UAT/K8s】请设置:NACOS_CONFIG_ENABLED=true、NACOS_DISCOVERY_ENABLED=true、NACOS_SERVER_ADDR
|
||||
#
|
||||
# maindata 通过注册中心调用时:Feign / LoadBalancer 服务名 = spring.application.name = erp-api
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: erp-api
|
||||
cloud:
|
||||
nacos:
|
||||
discovery:
|
||||
# 与 Nacos Config 解耦:仅注册发现时可 true + CONFIG_ENABLED false(一般 UAT 两者同开)
|
||||
enabled: ${NACOS_DISCOVERY_ENABLED:false}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:hbxhzt-test}
|
||||
group: ${NACOS_DISCOVERY_GROUP:DEFAULT_GROUP}
|
||||
username: ${NACOS_USERNAME:}
|
||||
password: ${NACOS_PASSWORD:}
|
||||
# UAT 默认 true;仅拉配置不注册时可设 NACOS_REGISTER_ENABLED=false
|
||||
register-enabled: ${NACOS_REGISTER_ENABLED:true}
|
||||
heart-beat-interval: 5000
|
||||
heart-beat-timeout: 15000
|
||||
ip-delete-timeout: 30000
|
||||
config:
|
||||
enabled: ${NACOS_CONFIG_ENABLED:false}
|
||||
server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848}
|
||||
namespace: ${NACOS_NAMESPACE:hbxhzt-test}
|
||||
group: ${NACOS_CONFIG_GROUP:DEFAULT_GROUP}
|
||||
file-extension: yaml
|
||||
username: ${NACOS_USERNAME:}
|
||||
password: ${NACOS_PASSWORD:}
|
||||
# 本地无 Nacos 时 false,不阻塞启动;UAT 建议 true,避免误用空配置
|
||||
fail-fast: ${NACOS_CONFIG_FAIL_FAST:false}
|
||||
refresh-enabled: true
|
||||
# 与 Yunxi 其他工程一致:共享 yundt-cube-common.yaml(仅当 NACOS_CONFIG_ENABLED=true 时拉取)
|
||||
shared-configs:
|
||||
- data-id: yundt-cube-common.yaml
|
||||
group: DEFAULT_GROUP
|
||||
refresh: true
|
||||
|
||||
# 与 Yunxi 工程一致,压低 Nacos 客户端日志量
|
||||
logging:
|
||||
level:
|
||||
com.alibaba.nacos: ERROR
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Nacos 配置说明(erp-api)
|
||||
|
||||
与 Yunxi 工程(如 `yundt-boot-center-maindata-aggboot`)对齐:**命名空间 `hbxhzt-test`**、共享 **`yundt-cube-common.yaml`**、**服务注册名 `erp-api`**(供 maindata Feign 调用)。
|
||||
|
||||
## 本地无 Nacos 启动
|
||||
|
||||
**不要设置** `NACOS_CONFIG_ENABLED` / `NACOS_DISCOVERY_ENABLED`(或显式设为 `false`),则不会连接 Nacos,仅使用 jar 内 **`application.yml`**。
|
||||
|
||||
## UAT / K8s 建议环境变量
|
||||
|
||||
| 变量 | 值 | 说明 |
|
||||
|------|-----|------|
|
||||
| `NACOS_CONFIG_ENABLED` | `true` | 拉取 Nacos 配置 |
|
||||
| `NACOS_DISCOVERY_ENABLED` | `true` | 注册到 Nacos,供 maindata 发现 |
|
||||
| `NACOS_SERVER_ADDR` | `nacos-headless.hbxhzt-test.svc:8848` | 按集群实际修改 |
|
||||
| `NACOS_NAMESPACE` | `hbxhzt-test` | 与控制台命名空间一致(默认已写进 bootstrap) |
|
||||
| `NACOS_CONFIG_FAIL_FAST` | `true` | UAT 建议:Nacos 不可达或缺配置时快速失败 |
|
||||
| `NACOS_USERNAME` / `NACOS_PASSWORD` | 按需 | Nacos 开启鉴权时 |
|
||||
|
||||
可选:`NACOS_REGISTER_ENABLED=false` 仅拉配置不注册(一般与 maindata 联调时保持注册)。
|
||||
|
||||
## 上传 `erp-api.yaml`
|
||||
|
||||
1. Nacos 控制台 → **hbxhzt-test** 命名空间 → **配置管理**。
|
||||
2. 新建:
|
||||
- **Data ID**:`erp-api.yaml`
|
||||
- **Group**:`DEFAULT_GROUP`
|
||||
- **格式**:YAML
|
||||
- **内容**:复制本目录 **`erp-api.yaml`**,按环境改库、Redis、`external.auth`(须与 maindata `ErpOpenProperties` 一致)。
|
||||
3. 同一命名空间下需已存在 **`yundt-cube-common.yaml`**(与兄弟工程共用)。
|
||||
|
||||
## maindata 调用说明
|
||||
|
||||
- **注册服务名**:`erp-api`(即 `spring.application.name`)。
|
||||
- **端口**:`38083`(在 `erp-api.yaml` 的 `server.port` 中配置)。
|
||||
- **HTTP 路径**:例如 `/external/goods/upsert`;若经网关,按网关路由配置。
|
||||
- **鉴权**:`/external/**` 仍为 AK/SK 签名,与 `external.auth` 及 maindata 侧密钥一致。
|
||||
|
||||
## 与 maindata `application-local` 类比
|
||||
|
||||
maindata 本地常用 `spring.cloud.nacos.discovery.register-enabled: false`;erp-api 通过 **不设 UAT 环境变量** 实现等价效果(完全不连 Nacos)。
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
# =============================================================================
|
||||
# Nacos 配置模板
|
||||
# - 命名空间:hbxhzt-test(与 bootstrap 默认 NACOS_NAMESPACE 一致)
|
||||
# - Data ID:erp-api.yaml(与 spring.application.name + file-extension 一致)
|
||||
# - Group:DEFAULT_GROUP
|
||||
# - 共享:bootstrap 已拉取 yundt-cube-common.yaml(与 Yunxi 其他工程一致)
|
||||
# 维护:guochengyu
|
||||
# =============================================================================
|
||||
|
||||
server:
|
||||
port: 38083
|
||||
|
||||
spring:
|
||||
data:
|
||||
redis:
|
||||
host: 192.168.71.6
|
||||
port: 6379
|
||||
database: 0
|
||||
password: HbxinhuaDB@2025
|
||||
timeout: 10s
|
||||
|
||||
datasource:
|
||||
driverClassName: com.mysql.cj.jdbc.Driver
|
||||
url: jdbc:mysql://192.168.71.10:3306/hbxh_erp_ecommerce_middleware?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
|
||||
username: root
|
||||
password: HbxinhuaDB@2025
|
||||
|
||||
mybatis-plus:
|
||||
mapper-locations: classpath*:mapper/**/*Mapper.xml
|
||||
type-aliases-package: cn.qihangerp.oms.domain;cn.qihangerp.module.domain;cn.qihangerp.security.entity;
|
||||
|
||||
# /external/** AK/SK(须与 maindata ErpOpenProperties 调用 ERP-Open 的密钥一致)
|
||||
external:
|
||||
auth:
|
||||
api-key: external-uat-ak-001
|
||||
secret-key: external-uat-sk-001-9f2d3c4b5a6e7d8c
|
||||
timestamp-skew-ms: 300000
|
||||
|
||||
# 拼多多:默认关闭发布;开启时请取消注释并补全 category-map / cost-template-map / sku-overrides
|
||||
#pdd:
|
||||
# publish-enabled: false
|
||||
# gateway-url: https://gw-api.pinduoduo.com/api/router
|
||||
# category-map: {}
|
||||
# cost-template-map: {}
|
||||
# sku-overrides: []
|
||||
|
|
@ -53,6 +53,7 @@ public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
|
|||
// log.info("token: " + token); || request.getRequestURI().equals("/getInfo") || request.getRequestURI().equals("/logout")
|
||||
if (request.getRequestURI().equals("/login")
|
||||
|| url.contains("/login")
|
||||
|| url.startsWith("/external/")
|
||||
|| url.contains("/captchaImage")
|
||||
|| url.contains("/order/get_detail")
|
||||
|| url.contains("/refund/get_detail")
|
||||
|
|
|
|||
|
|
@ -16,9 +16,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
|||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import cn.qihangerp.security.external.ExternalAkSkAuthenticationFilter;
|
||||
import cn.qihangerp.security.external.ExternalAkSkProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableConfigurationProperties(ExternalAkSkProperties.class)
|
||||
public class SecurityConfig {
|
||||
|
||||
@Autowired
|
||||
|
|
@ -31,6 +35,9 @@ public class SecurityConfig {
|
|||
*/
|
||||
@Autowired
|
||||
private LogoutSuccessHandlerImpl logoutSuccessHandler;
|
||||
|
||||
@Autowired
|
||||
private ExternalAkSkProperties externalAkSkProperties;
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
|
|
@ -41,6 +48,11 @@ public class SecurityConfig {
|
|||
return new JwtAuthenticationTokenFilter();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public ExternalAkSkAuthenticationFilter externalAkSkAuthenticationFilter() {
|
||||
return ExternalAkSkAuthenticationFilter.createDefault(externalAkSkProperties);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
|
||||
|
|
@ -81,6 +93,7 @@ public class SecurityConfig {
|
|||
|
||||
.authenticationProvider(authenticationProvider())
|
||||
// 加我们自定义的过滤器,替代UsernamePasswordAuthenticationFilter
|
||||
.addFilterBefore(externalAkSkAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
|
||||
// 添加Logout filter
|
||||
// 微服务退出
|
||||
|
|
|
|||
202
core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java
vendored
Normal file
202
core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java
vendored
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
package cn.qihangerp.security.external;
|
||||
|
||||
import cn.qihangerp.common.AjaxResult;
|
||||
import cn.qihangerp.common.enums.HttpStatus;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ReadListener;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* External system auth for /external/** endpoints.
|
||||
*
|
||||
* Header contract (frozen):
|
||||
* - X-Api-Key: AK
|
||||
* - X-Timestamp: ms
|
||||
* - X-Nonce: random string
|
||||
* - X-Signature: Base64(HMAC-SHA256(SK, canonicalString))
|
||||
*
|
||||
* canonicalString:
|
||||
* METHOD + "\n" + PATH + "\n" + timestamp + "\n" + nonce + "\n" + sha256Hex(rawBodyBytes)
|
||||
*/
|
||||
public class ExternalAkSkAuthenticationFilter extends OncePerRequestFilter {
|
||||
private static final String HEADER_API_KEY = "X-Api-Key";
|
||||
private static final String HEADER_TIMESTAMP = "X-Timestamp";
|
||||
private static final String HEADER_NONCE = "X-Nonce";
|
||||
private static final String HEADER_SIGNATURE = "X-Signature";
|
||||
|
||||
private final ExternalAkSkProperties props;
|
||||
private final ExternalRequestCaches caches;
|
||||
|
||||
public ExternalAkSkAuthenticationFilter(
|
||||
ExternalAkSkProperties props,
|
||||
ExternalRequestCaches caches
|
||||
) {
|
||||
this.props = Objects.requireNonNull(props, "props");
|
||||
this.caches = Objects.requireNonNull(caches, "caches");
|
||||
}
|
||||
|
||||
public static ExternalAkSkAuthenticationFilter createDefault(ExternalAkSkProperties props) {
|
||||
return new ExternalAkSkAuthenticationFilter(
|
||||
props,
|
||||
new ExternalRequestCaches(Clock.systemUTC())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path == null || !path.startsWith("/external/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
CachedBodyHttpServletRequest wrapped = request instanceof CachedBodyHttpServletRequest
|
||||
? (CachedBodyHttpServletRequest) request
|
||||
: new CachedBodyHttpServletRequest(request);
|
||||
|
||||
long nowMs = caches.nowMs();
|
||||
|
||||
String apiKey = wrapped.getHeader(HEADER_API_KEY);
|
||||
String tsStr = wrapped.getHeader(HEADER_TIMESTAMP);
|
||||
String nonce = wrapped.getHeader(HEADER_NONCE);
|
||||
String signature = wrapped.getHeader(HEADER_SIGNATURE);
|
||||
|
||||
if (!StringUtils.hasText(apiKey) || !StringUtils.hasText(tsStr) || !StringUtils.hasText(nonce) || !StringUtils.hasText(signature)) {
|
||||
writeUnauthorized(response, "缺少鉴权头");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey.equals(props.getApiKey())) {
|
||||
writeForbidden(response, "AK无效");
|
||||
return;
|
||||
}
|
||||
|
||||
long tsMs;
|
||||
try {
|
||||
tsMs = Long.parseLong(tsStr);
|
||||
} catch (NumberFormatException ex) {
|
||||
writeForbidden(response, "timestamp格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
long skew = Math.abs(nowMs - tsMs);
|
||||
if (skew > props.getTimestampSkewMs()) {
|
||||
writeForbidden(response, "timestamp过期");
|
||||
return;
|
||||
}
|
||||
|
||||
// anti-replay: use timestampSkew window as nonce TTL
|
||||
if (!caches.tryUseNonce(nonce, nowMs, props.getTimestampSkewMs())) {
|
||||
writeForbidden(response, "nonce重放");
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] bodyBytes = wrapped.getCachedBody();
|
||||
|
||||
String bodySha256Hex = ExternalRequestCaches.sha256Hex(bodyBytes == null ? new byte[0] : bodyBytes);
|
||||
String method = wrapped.getMethod() == null ? "" : wrapped.getMethod().toUpperCase();
|
||||
String path = wrapped.getRequestURI() == null ? "" : wrapped.getRequestURI();
|
||||
String canonical = method + "\n" + path + "\n" + tsStr + "\n" + nonce + "\n" + bodySha256Hex;
|
||||
|
||||
String expected = ExternalRequestCaches.hmacSha256Base64(props.getSecretKey(), canonical);
|
||||
if (!expected.equals(signature)) {
|
||||
writeForbidden(response, "签名错误");
|
||||
return;
|
||||
}
|
||||
|
||||
// mark authenticated for Spring Security
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
"external",
|
||||
null,
|
||||
List.of(new SimpleGrantedAuthority("ROLE_EXTERNAL"))
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
|
||||
filterChain.doFilter(wrapped, response);
|
||||
}
|
||||
|
||||
private void writeUnauthorized(HttpServletResponse response, String msg) throws IOException {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED);
|
||||
writeJson(response, AjaxResult.error(HttpStatus.UNAUTHORIZED, msg));
|
||||
}
|
||||
|
||||
private void writeForbidden(HttpServletResponse response, String msg) throws IOException {
|
||||
response.setStatus(HttpStatus.FORBIDDEN);
|
||||
writeJson(response, AjaxResult.error(HttpStatus.FORBIDDEN, msg));
|
||||
}
|
||||
|
||||
private void writeJson(HttpServletResponse response, AjaxResult body) throws IOException {
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write(JSON.toJSONString(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache request body and allow downstream re-read.
|
||||
*/
|
||||
private static class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
|
||||
private final byte[] cachedBody;
|
||||
|
||||
CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
|
||||
super(request);
|
||||
this.cachedBody = request.getInputStream().readAllBytes();
|
||||
}
|
||||
|
||||
byte[] getCachedBody() {
|
||||
return cachedBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream bais = new ByteArrayInputStream(cachedBody);
|
||||
return new ServletInputStream() {
|
||||
@Override
|
||||
public boolean isFinished() {
|
||||
return bais.available() == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isReady() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setReadListener(ReadListener readListener) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() {
|
||||
return bais.read();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
46
core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java
vendored
Normal file
46
core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package cn.qihangerp.security.external;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "external.auth")
|
||||
public class ExternalAkSkProperties {
|
||||
/**
|
||||
* External system API Key (AK)
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* External system secret (SK)
|
||||
*/
|
||||
private String secretKey;
|
||||
|
||||
/**
|
||||
* Timestamp tolerance in milliseconds.
|
||||
*/
|
||||
private long timestampSkewMs = 300_000L;
|
||||
|
||||
public String getApiKey() {
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
public void setApiKey(String apiKey) {
|
||||
this.apiKey = apiKey;
|
||||
}
|
||||
|
||||
public String getSecretKey() {
|
||||
return secretKey;
|
||||
}
|
||||
|
||||
public void setSecretKey(String secretKey) {
|
||||
this.secretKey = secretKey;
|
||||
}
|
||||
|
||||
public long getTimestampSkewMs() {
|
||||
return timestampSkewMs;
|
||||
}
|
||||
|
||||
public void setTimestampSkewMs(long timestampSkewMs) {
|
||||
this.timestampSkewMs = timestampSkewMs;
|
||||
}
|
||||
}
|
||||
|
||||
85
core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java
vendored
Normal file
85
core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java
vendored
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package cn.qihangerp.security.external;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* In-memory anti-replay (nonce) + idempotency caches.
|
||||
*
|
||||
* <p>首期按“单体优先 + 同步接口”落地,这里使用内存缓存,不引入额外依赖、不改库。
|
||||
* 后续如需多实例/重启不丢,可替换为 Redis/DB。</p>
|
||||
*/
|
||||
public class ExternalRequestCaches {
|
||||
private final Clock clock;
|
||||
private final Map<String, Long> nonceExpireAtMs = new ConcurrentHashMap<>();
|
||||
private final Map<String, CachedResponse> idempotency = new ConcurrentHashMap<>();
|
||||
|
||||
public ExternalRequestCaches(Clock clock) {
|
||||
this.clock = Objects.requireNonNull(clock, "clock");
|
||||
}
|
||||
|
||||
public boolean tryUseNonce(String nonce, long nowMs, long ttlMs) {
|
||||
cleanupNonce(nowMs);
|
||||
long expireAt = nowMs + ttlMs;
|
||||
return nonceExpireAtMs.putIfAbsent(nonce, expireAt) == null;
|
||||
}
|
||||
|
||||
public CachedResponse getIdempotency(String key, long nowMs) {
|
||||
cleanupIdempotency(nowMs);
|
||||
CachedResponse r = idempotency.get(key);
|
||||
if (r == null) return null;
|
||||
return r.expireAtMs > nowMs ? r : null;
|
||||
}
|
||||
|
||||
public void putIdempotency(String key, CachedResponse resp) {
|
||||
idempotency.put(key, resp);
|
||||
}
|
||||
|
||||
public long nowMs() {
|
||||
return clock.millis();
|
||||
}
|
||||
|
||||
private void cleanupNonce(long nowMs) {
|
||||
if (nonceExpireAtMs.size() < 10_000) return;
|
||||
nonceExpireAtMs.entrySet().removeIf(e -> e.getValue() <= nowMs);
|
||||
}
|
||||
|
||||
private void cleanupIdempotency(long nowMs) {
|
||||
if (idempotency.size() < 10_000) return;
|
||||
idempotency.entrySet().removeIf(e -> e.getValue().expireAtMs <= nowMs);
|
||||
}
|
||||
|
||||
public static String sha256Hex(byte[] bytes) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] digest = md.digest(bytes);
|
||||
StringBuilder sb = new StringBuilder(digest.length * 2);
|
||||
for (byte b : digest) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("sha256 failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String hmacSha256Base64(String secret, String message) {
|
||||
try {
|
||||
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
|
||||
mac.init(new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
byte[] sig = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(sig);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("hmac failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
public record CachedResponse(int httpStatus, String contentType, byte[] body, long expireAtMs) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ ai-agent 是一个基于 **LangChain4J** 的 AI 智能助手模块,用于实
|
|||
|
||||
**核心技术栈:**
|
||||
- LangChain4J (AI 框架)
|
||||
- Ollama (本地大模型) / DeepSeek API (云端大模型)
|
||||
- Ollama (本地大模型)
|
||||
- SSE (Server-Sent Events) 实时通信
|
||||
- Spring Cloud 微服务
|
||||
|
||||
|
|
@ -30,8 +30,7 @@ api/ai-agent/
|
|||
│ │ ├── AiService.java # AI服务核心逻辑 ⭐⭐核心
|
||||
│ │ ├── ConversationHistoryManager.java # 对话历史管理
|
||||
│ │ ├── SessionManager.java # 会话管理
|
||||
│ │ ├── InventorySalesAnalyzer.java # 库存销售分析
|
||||
│ │ └── DeepSeekService.java # DeepSeek API封装
|
||||
│ │ └── InventorySalesAnalyzer.java # 库存销售分析(本地规则报告)
|
||||
│ ├── feign/
|
||||
│ │ ├── OpenApiService.java # 内部API调用
|
||||
│ │ └── EchoService.java
|
||||
|
|
@ -82,8 +81,7 @@ api/ai-agent/
|
|||
- 限制历史上下文最长 2000 字符
|
||||
|
||||
3. **模型选择**
|
||||
- `deepseek` 前缀 → DeepSeek API
|
||||
- 其他 → Ollama 本地模型
|
||||
- 仅支持 **Ollama 本地模型**(`deepseek` 前缀会提示已移除云端能力)
|
||||
|
||||
4. **Tool 集成**
|
||||
- 绑定 `OrderToolService` 提供订单查询能力
|
||||
|
|
@ -124,13 +122,13 @@ api/ai-agent/
|
|||
|
||||
---
|
||||
|
||||
#### 6. InventorySalesAnalyzer (InventorySalesAnalyzer.java:11-273)
|
||||
#### 6. InventorySalesAnalyzer
|
||||
|
||||
**职责:** 库存销售分析(独立工具类)
|
||||
**职责:** 库存销售分析(独立工具类 / 示例 `main`)
|
||||
|
||||
- 解析库存和销售 JSON 数据
|
||||
- 计算关键指标:日均销量、可售天数、库存状态
|
||||
- 调用 DeepSeek API 生成分析报告
|
||||
- **本地规则**生成 Markdown 式报告(不调用外部大模型)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -152,7 +150,7 @@ api/ai-agent/
|
|||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||
│ PageRules │ │OrderToolSvc │ │ LLM Model │
|
||||
│ (页面导航) │ │ (订单查询) │ │(Ollama/DeepSeek)
|
||||
│ (页面导航) │ │ (订单查询) │ │ (Ollama) │
|
||||
└─────────────┘ └──────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -164,10 +162,7 @@ api/ai-agent/
|
|||
server.port: 8084 # AI服务端口
|
||||
spring.datasource: MySQL连接配置
|
||||
spring.data.redis: Redis连接配置
|
||||
deepseek.api: # DeepSeek API配置
|
||||
key: sk-xxxx
|
||||
endpoint: https://api.deepseek.com/v1
|
||||
model: deepseek-chat
|
||||
# 大模型:本机 Ollama(默认 http://localhost:11434),无云端 API 配置项
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -175,6 +170,5 @@ deepseek.api: # DeepSeek API配置
|
|||
### 六、待优化点
|
||||
|
||||
1. **OrderService.java** - 目前使用模拟数据,需对接真实 ERP 订单数据
|
||||
2. **API Key 硬编码** - InventorySalesAnalyzer 中的 API Key 应配置化
|
||||
3. **SessionManager** - 内存缓存无持久化,重启丢失
|
||||
4. **错误处理** - AI 服务调用失败时需更完善的降级策略
|
||||
2. **SessionManager** - 内存缓存无持久化,重启丢失
|
||||
3. **错误处理** - AI 服务调用失败时需更完善的降级策略
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
# External 系统 → 启航ERP(拼多多)商品上架网关:裁剪与改造方案
|
||||
|
||||
> 目标:在 `qihang-ecom-erp-open` 基础上,**仅保留**“商品上架/商品同步(仅商品域)/接口鉴权”能力,作为单体服务提供给外部系统调用。
|
||||
> 首期平台:**拼多多**。
|
||||
> 调用方:外部系统(传入商品数据 + `shopId` + `platform`)。
|
||||
> 店铺授权:由本服务维护(外部系统不传 appKey/appSecret/accessToken)。
|
||||
> 鉴权:**AK/SK 签名鉴权**(system-to-system)。
|
||||
|
||||
## 1. 范围与非目标
|
||||
|
||||
### 1.1 In Scope(首期必须)
|
||||
- **商品数据接入**:外部系统推送商品(SPU + SKU)到本服务(创建/更新)。
|
||||
- **拼多多上架全链路**:
|
||||
- **A** 创建商品(首次发布)
|
||||
- **B** 更新商品信息(标题/主图/详情/属性等)
|
||||
- **C** 更新 SKU(规格、价格等)
|
||||
- **D** 更新库存
|
||||
- **E** 上架/下架
|
||||
- **F** 接收“已准备好的商品数据”(图片/详情为可访问 URL 或可上传的素材)并发起平台发布
|
||||
- **最小商品同步**:
|
||||
- 从拼多多拉取商品(用于对账/状态回写/增量同步),保留 `pull_goods` 能力即可
|
||||
- 同步结果落库,供外部系统查询/校验
|
||||
- **接口鉴权**:
|
||||
- 调用方 → 本服务 API 必须签名验证(AK/SK)
|
||||
- 失败拒绝(401/403),并记录审计日志
|
||||
|
||||
### 1.2 Out of Scope(首期明确不做/可后置)
|
||||
- 订单/售后/发货/电子面单/仓库/采购等业务域
|
||||
- 多平台(淘宝/京东/抖店/快手/小红书/微信小店等)
|
||||
- 前端管理后台(Vue)与多租户/多商户能力
|
||||
- AI-agent 相关能力
|
||||
|
||||
## 2. 现状评估(基于当前开源代码)
|
||||
|
||||
### 2.1 已具备的基础能力
|
||||
- **商品库接入入口**:`erp-api` 下存在 `POST /goods/add`,并且模型存在 `outerErpGoodsId` 等外部标识字段,适合承接调用方商品数据。
|
||||
- **拼多多商品拉取(同步)**:`oms-api` 下存在 `POST /pdd/goods/pull_goods`,当前实现是“拉取列表并落库”。
|
||||
- **安全模块线索**:`core/security` 已存在并在 `erp-api`/`oms-api` 引用(但对调用方的 S2S 鉴权需要单独设计)。
|
||||
|
||||
### 2.2 关键缺口(必须改造/补齐)
|
||||
- 当前拼多多侧 `open-sdk-2.1.12.jar` 中公开的 `PddGoodsApiHelper` 仅暴露 `pullGoodsList`(拉取),未暴露“创建/更新/上下架/库存更新”等发布能力。
|
||||
- 因此,**商品上架全链路需要新增拼多多发布适配层**(可能复用已有签名/HTTP 基础设施,但要补齐 API)。
|
||||
|
||||
## 3. 目标架构(单体部署)
|
||||
|
||||
### 3.1 进程形态
|
||||
单体 Spring Boot 应用(Java 17),对外提供 REST API:
|
||||
- `external-api`:外部系统调用入口(推送商品/触发上架/查询结果)
|
||||
- `admin-api`(可选,首期可不暴露):用于运维/店铺授权维护/密钥配置
|
||||
|
||||
### 3.2 逻辑分层(建议)
|
||||
- **API 层**:只做参数校验、鉴权、幂等键处理、调用 Service、统一响应
|
||||
- **Service 层**:
|
||||
- 统一商品模型校验与映射
|
||||
- 上架编排(创建→更新→库存→上架状态)
|
||||
- 与拼多多适配层交互
|
||||
- 任务化(可选):异步发布/重试/死信
|
||||
- **Adapter 层(PDD)**:
|
||||
- HTTP + 签名 + 请求/响应模型
|
||||
- 平台错误码映射
|
||||
|
||||
## 4. 对外接口设计(External/外部系统 → 本服务)
|
||||
|
||||
> 说明:下面是建议接口。落地时以外部系统现有调用规范为准,可在后续命令下达后再对齐路径/DTO 命名。
|
||||
|
||||
### 4.0 已确认口径(冻结)
|
||||
- `shopId`:**全平台唯一**,可直接作为本服务店铺主键,不考虑跨平台撞号。
|
||||
- `platform`:由外部系统显式传入,取值采用 **`PDD`/`TAO`/`JD`/`DOU`/`WEI`/`KWAI`/`XHS`**(后续平台新增再扩展枚举)。
|
||||
- AK/SK:**一套即可**(外部系统作为单一调用方)。
|
||||
- `X-Timestamp`:**毫秒**(ms)。
|
||||
- `X-Signature`:**Base64** 编码(HMAC-SHA256 输出 bytes → Base64)。
|
||||
- 签名 body:使用**原始请求体 raw bytes** 计算 SHA-256(不做 JSON 重排/字段排序)。
|
||||
- 参数形态:**统一走 body**(尽量避免 query;GET 若必须使用 query,则改为 POST 查询接口更一致)。
|
||||
- 幂等:**完全由本服务内部实现**,不依赖调用方提供 `X-Request-Id`。
|
||||
- 商品素材:首期按**公网可访问 URL**(不做素材代上传)。
|
||||
- 接口形态:首期按**同步接口**(直接返回发布结果或明确错误)。
|
||||
|
||||
### 4.1 统一返回
|
||||
- `code`: 0 成功;非 0 失败
|
||||
- `msg`: 错误信息
|
||||
- `data`: 业务数据
|
||||
|
||||
### 4.2 鉴权(AK/SK 签名)
|
||||
#### 请求头
|
||||
- `X-Api-Key`: 分配给调用方的 AK
|
||||
- `X-Timestamp`: 毫秒时间戳(例如 `1710000000000`)
|
||||
- `X-Nonce`: 随机串(建议 16~32)
|
||||
- `X-Signature`: 签名串(Base64)
|
||||
- `Content-Type: application/json`
|
||||
|
||||
#### 签名串构造(推荐)
|
||||
canonical string:
|
||||
1. `HTTP_METHOD`(大写)
|
||||
2. `PATH`(不含域名,不含 query)
|
||||
3. `timestamp`
|
||||
4. `nonce`
|
||||
5. `bodySha256Hex`(请求体 raw bytes 的 SHA-256,hex)
|
||||
|
||||
拼接方式:
|
||||
```
|
||||
METHOD + "\n" +
|
||||
PATH + "\n" +
|
||||
timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
bodySha256Hex
|
||||
```
|
||||
|
||||
签名算法:
|
||||
- `signature = HMAC-SHA256(secret, canonicalString)`
|
||||
- 输出:对 `signature` 的 bytes 做 `Base64`。
|
||||
|
||||
#### 校验规则
|
||||
- `timestamp` 与服务端时间偏差 ≤ 300 秒(按 ms 计算)
|
||||
- `nonce` 在时间窗内不可重复(需要缓存,例如 Redis/本地 Caffeine,首期可先用 Redis)
|
||||
- `X-Api-Key` 必须存在且有效(数据库/配置中)
|
||||
- `X-Signature` 校验失败直接拒绝
|
||||
|
||||
#### 错误码建议
|
||||
- 401:缺少必要头/签名参数
|
||||
- 403:签名错误、AK 无效、nonce 重放
|
||||
- 429:频控(可选)
|
||||
|
||||
### 4.3 商品接入与上架接口(建议)
|
||||
#### 4.3.1 推送/更新商品(SPU + SKU)
|
||||
`POST /external/goods/upsert`
|
||||
|
||||
入参:
|
||||
- `shopId`(必填)
|
||||
- `platform`(必填,枚举,例如 `PDD`/`TAO`/`JD`…;首期仅允许 `PDD`)
|
||||
- `outGoodsId`:调用方侧商品ID(用于幂等与映射)
|
||||
- `title`、`mainImages[]`、`detail`(富文本/图片 URL 列表等)
|
||||
- `skus[]`:`outSkuId`、`spec`、`price`、`stock`、`barCode`…
|
||||
- `category`、`attrs`、`brand`、`weight`、`shipping`…
|
||||
|
||||
行为:
|
||||
- 本地保存/更新商品库(以 `shopId + outGoodsId` 幂等)
|
||||
- 返回本地 `goodsId`(内部ID)与当前状态
|
||||
|
||||
#### 4.3.2 触发上架(编排)
|
||||
`POST /external/goods/listing/submit`
|
||||
|
||||
入参:
|
||||
- `shopId`
|
||||
- `platform`(必填,首期仅允许 `PDD`)
|
||||
- `outGoodsId`
|
||||
- `mode`: `CREATE_OR_UPDATE`(默认)
|
||||
|
||||
行为(同步/异步二选一,建议首期同步 + 超时控制,后续演进异步):
|
||||
- 若平台商品不存在:创建商品 → 创建/更新 SKU → 设置库存 → 上架
|
||||
- 若已存在:更新商品 → 更新 SKU → 库存 → 上架/保持
|
||||
|
||||
返回:
|
||||
- `listingTaskId`(若异步)
|
||||
- 或直接返回平台侧 `goodsId`/状态
|
||||
|
||||
#### 4.3.3 上下架
|
||||
`POST /external/goods/listing/status`
|
||||
|
||||
入参:
|
||||
- `shopId`
|
||||
- `platform`(必填,首期仅允许 `PDD`)
|
||||
- `outGoodsId`
|
||||
- `status`: `ON`/`OFF`
|
||||
|
||||
#### 4.3.4 库存更新(可被 submit 内部调用,也可单独暴露)
|
||||
`POST /external/goods/stock/update`
|
||||
|
||||
入参:
|
||||
- `shopId`
|
||||
- `platform`(必填,首期仅允许 `PDD`)
|
||||
- `outGoodsId`
|
||||
- `skus[]`: `outSkuId` + `stock`
|
||||
|
||||
#### 4.3.5 查询发布状态/映射
|
||||
`GET /external/goods/listing/status?shopId=...&platform=...&outGoodsId=...`
|
||||
|
||||
返回:
|
||||
- 平台商品ID、上下架状态、最后一次错误、最后同步时间
|
||||
|
||||
## 5. 领域模型(最小集合)
|
||||
|
||||
### 5.1 本地核心表(建议最小)
|
||||
- `shop`:店铺基础信息(含平台类型=PDD)
|
||||
- `shop_auth`:拼多多 appKey/appSecret/accessToken/refreshToken/过期时间
|
||||
- `goods`:统一商品(承接调用方)
|
||||
- `goods_sku`:统一 SKU(承接调用方)
|
||||
- `goods_platform_mapping`:统一商品与平台商品映射(平台 goodsId、skuId 映射)
|
||||
- `listing_task`(可选):异步发布任务、重试次数、状态机
|
||||
- `api_client`:AK/SK、状态、权限范围、IP 白名单(可选)
|
||||
- `api_request_log`:审计(签名校验结果、请求摘要、耗时)
|
||||
|
||||
### 5.2 幂等策略(必须)
|
||||
- `shopId + outGoodsId`:商品幂等键
|
||||
- `shopId + outSkuId`:SKU 幂等键
|
||||
- **不依赖调用方幂等键**:本服务内部需实现“请求去重/防重复上架”。
|
||||
- 推荐:落库 `idempotency_key = sha256Hex(platform + shopId + outGoodsId + operation + normalizedBodyHash)`
|
||||
- 同一 `idempotency_key` 在有效期内重复请求:直接返回第一次执行结果
|
||||
- 对“提交上架”类接口:至少保证不会重复创建平台商品
|
||||
|
||||
## 6. 拼多多适配(PDD Adapter)设计要点
|
||||
|
||||
### 6.1 适配层职责
|
||||
- 拼多多 API 签名、请求发送、响应解析
|
||||
- 平台错误码归一化
|
||||
- “创建/更新/上下架/库存”接口封装为内部方法:
|
||||
- `createGoods(...)`
|
||||
- `updateGoods(...)`
|
||||
- `updateSku(...)`
|
||||
- `updateStock(...)`
|
||||
- `setGoodsOnSale(...)` / `setGoodsOffSale(...)`
|
||||
|
||||
### 6.2 依赖策略(重要)
|
||||
当前 `oms-api` 通过 `systemPath` 引入 `open-sdk-2.1.12.jar`。
|
||||
|
||||
首期有两条路径(二选一):
|
||||
- **路径 1(优先)**:在本仓库内新增 `pdd-adapter`(源码方式),不再依赖 `open-sdk-2.1.12.jar` 做发布能力(只保留 pull 同步可继续复用或也迁移)。
|
||||
- **路径 2(备选)**:升级/替换 `open-sdk`,确保其暴露发布相关 API(需要确认是否可获得源码/维护成本)。
|
||||
|
||||
> 推荐路径 1:可控、可审计、便于后续扩展多平台。
|
||||
|
||||
## 7. 裁剪方案(保留/删除建议)
|
||||
|
||||
> 原项目是微服务结构(gateway/sys-api/erp-api/oms-api 等)。你的目标是单体优先,因此裁剪思路是:
|
||||
> 1) **保留商品域 + 拼多多域 + 鉴权**
|
||||
> 2) 其它域模块不编译/不启动/不暴露接口
|
||||
|
||||
### 7.1 建议保留(首期)
|
||||
- `core/common`(通用工具、AjaxResult 等)
|
||||
- `core/security`(复用登录/权限基础设施的同时,新增 S2S AK/SK 鉴权)
|
||||
- `model`(实体/BO/VO)
|
||||
- `mapper`(DB 访问)
|
||||
- `service`、`serviceImpl` 中与商品/店铺/拼多多相关的最小集合
|
||||
- `api/erp-api`(商品库接入:`/goods/add` 等入口可重构为 `/external/goods/*`)
|
||||
- `api/oms-api` 中 **拼多多商品拉取**(同步)相关接口(可改为内部任务或保留对外)
|
||||
|
||||
### 7.2 建议禁用/后置
|
||||
- `api/ai-agent`
|
||||
- `api/sys-api`(如仅对调用方开放,不需要后台用户体系,可后置)
|
||||
- `api/gateway`(若单体直连,不需要网关;若保留网关需把 TokenFilter 改为 AK/SK 校验)
|
||||
- `oms-api` 中非拼多多平台(dou/jd/tao/wei/kwai…)
|
||||
- 订单/售后/发货/库存出入库/采购等 Controller 与 Service
|
||||
|
||||
## 8. 安全与审计
|
||||
- 所有外部系统接口强制 AK/SK
|
||||
- **平台一致性校验(强制)**:
|
||||
- 调用方传入 `platform`
|
||||
- 服务端根据 `shopId` 查询数据库中的 `shopType/platformType`
|
||||
- 若两者不一致:直接拒绝(建议 400 或 403),记录审计日志
|
||||
- 目的:避免“串平台/串店铺”导致误上架
|
||||
- `shopId` 必须存在且平台类型为 PDD
|
||||
- 店铺 accessToken 过期:返回明确错误码(例如 `SHOP_TOKEN_EXPIRED`),并支持后台刷新/重新授权流程(首期可人工)
|
||||
- 全链路日志:
|
||||
- `requestId`、`shopId`、`outGoodsId`、平台错误码、耗时
|
||||
- 敏感信息保护:
|
||||
- appSecret/accessToken 加密存储(KMS/本地对称加密;首期可用配置密钥 + AES)
|
||||
- 严禁打印明文 token/secret
|
||||
|
||||
## 9. 验证与验收清单(首期)
|
||||
- [ ] 外部系统推送商品 → 本地入库(幂等生效)
|
||||
- [ ] 触发上架 → 拼多多创建/更新/库存/上下架成功(全链路)
|
||||
- [ ] 上架失败可返回可读错误(含平台原始信息可追踪)
|
||||
- [ ] `pull_goods` 同步可用(用于对账/状态回写)
|
||||
- [ ] AK/SK:
|
||||
- [ ] 缺参拒绝
|
||||
- [ ] 签名错误拒绝
|
||||
- [ ] 超时拒绝
|
||||
- [ ] nonce 重放拒绝
|
||||
- [ ] 审计日志完整
|
||||
|
||||
## 10. 待你确认/后续落地前置
|
||||
- 拼多多商品发布所需字段“最小集”(类目、属性、物流、售后承诺、资质等)需要你提供调用方侧现有字段映射或样例 payload。
|
||||
- 是否需要“异步上架”(返回 taskId + 回调/轮询)。首期建议同步,但需要设定超时时间与重试策略。
|
||||
|
||||
|
|
@ -60,7 +60,7 @@ qihang-ecom-erp-open
|
|||
- 目录按平台拆分:`tao/jd/pdd/dou/wei/kwai`
|
||||
3. `sys-api`(端口 8082)
|
||||
- 用户、角色、菜单、字典、配置等系统能力
|
||||
4. `erp-api`(端口 8083)
|
||||
4. `erp-api`(端口 38083)
|
||||
- 商品、采购、库存、发货等 ERP 主业务能力
|
||||
|
||||
### 3.1 网关路由规则(核心)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@
|
|||
- `/api/open-api/**` → `open-api`(当前仓库聚合中未启用该子模块)
|
||||
- `/api/sys-api/**` → `sys-api`
|
||||
|
||||
`erp-api`、`oms-api`、`sys-api` 分别默认使用 `8083`、`8081`、`8082` 端口,并都注册到 Nacos。
|
||||
`erp-api`、`oms-api`、`sys-api` 分别默认使用 `38083`、`8081`、`8082` 端口,并都注册到 Nacos。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FE[Vue2 前端] --> GW[Gateway :8088]
|
||||
GW --> ERP[erp-api :8083]
|
||||
GW --> ERP[erp-api :38083]
|
||||
GW --> OMS[oms-api :8081]
|
||||
GW --> SYS[sys-api :8082]
|
||||
ERP --> MYSQL[(MySQL)]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
<resultMap id="BaseResultMap" type="cn.qihangerp.model.entity.OGoods">
|
||||
<id property="id" column="id" jdbcType="BIGINT"/>
|
||||
<result property="shopId" column="shop_id" jdbcType="BIGINT"/>
|
||||
<result property="name" column="name" jdbcType="VARCHAR"/>
|
||||
<result property="image" column="image" jdbcType="VARCHAR"/>
|
||||
<result property="outerErpGoodsId" column="outer_erp_goods_id" jdbcType="VARCHAR"/>
|
||||
|
|
@ -15,6 +16,9 @@
|
|||
<result property="goodsNum" column="goods_num" jdbcType="VARCHAR"/>
|
||||
<result property="unitName" column="unit_name" jdbcType="VARCHAR"/>
|
||||
<result property="categoryId" column="category_id" jdbcType="INTEGER"/>
|
||||
<result property="categoryCode" column="category_code" jdbcType="VARCHAR"/>
|
||||
<result property="brandCode" column="brand_code" jdbcType="VARCHAR"/>
|
||||
<result property="canonicalExt" column="canonical_ext" jdbcType="LONGVARCHAR"/>
|
||||
<result property="barCode" column="bar_code" jdbcType="VARCHAR"/>
|
||||
<result property="remark" column="remark" jdbcType="VARCHAR"/>
|
||||
<result property="status" column="status" jdbcType="TINYINT"/>
|
||||
|
|
@ -61,9 +65,9 @@
|
|||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id,name,image,province,city,town,
|
||||
id,shop_id,name,image,province,city,town,
|
||||
outer_erp_goods_id,goods_num,unit_name,
|
||||
category_id,bar_code,remark,
|
||||
category_id,category_code,brand_code,canonical_ext,bar_code,remark,
|
||||
status,length,height,
|
||||
width,width1,width2,
|
||||
width3,weight,disable,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
<resultMap id="BaseResultMap" type="cn.qihangerp.model.entity.OGoodsSku">
|
||||
<id property="id" column="id" jdbcType="BIGINT"/>
|
||||
<result property="shopId" column="shop_id" jdbcType="BIGINT"/>
|
||||
<result property="goodsId" column="goods_id" jdbcType="BIGINT"/>
|
||||
<result property="outerErpGoodsId" column="outer_erp_goods_id" jdbcType="VARCHAR"/>
|
||||
<result property="outerErpSkuId" column="outer_erp_sku_id" jdbcType="VARCHAR"/>
|
||||
|
|
@ -27,6 +28,11 @@
|
|||
<result property="purPrice" column="pur_price" jdbcType="DECIMAL"/>
|
||||
<result property="retailPrice" column="retail_price" jdbcType="DECIMAL"/>
|
||||
<result property="unitCost" column="unit_cost" jdbcType="DECIMAL"/>
|
||||
<result property="groupPrice" column="group_price" jdbcType="DECIMAL"/>
|
||||
<result property="stockQty" column="stock_qty" jdbcType="INTEGER"/>
|
||||
<result property="weightGram" column="weight_gram" jdbcType="BIGINT"/>
|
||||
<result property="skuImageUrl" column="sku_image_url" jdbcType="VARCHAR"/>
|
||||
<result property="canonicalExt" column="canonical_ext" jdbcType="LONGVARCHAR"/>
|
||||
<result property="remark" column="remark" jdbcType="VARCHAR"/>
|
||||
<result property="status" column="status" jdbcType="TINYINT"/>
|
||||
<result property="lowQty" column="low_qty" jdbcType="INTEGER"/>
|
||||
|
|
@ -37,14 +43,14 @@
|
|||
</resultMap>
|
||||
|
||||
<sql id="Base_Column_List">
|
||||
id,goods_id,outer_erp_goods_id,
|
||||
id,shop_id,goods_id,outer_erp_goods_id,
|
||||
outer_erp_sku_id,goods_name,goods_num,
|
||||
sku_name,sku_code,color_label,
|
||||
color_id,color_value,color_image,
|
||||
size_label,size_id,size_value,
|
||||
style_label,style_id,style_value,
|
||||
bar_code,pur_price,retail_price,
|
||||
unit_cost,remark,status,
|
||||
unit_cost,group_price,stock_qty,weight_gram,sku_image_url,canonical_ext,remark,status,
|
||||
low_qty,high_qty,volume,
|
||||
create_time,update_time
|
||||
</sql>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public class GoodsAddBo
|
|||
|
||||
/** 商品编号 */
|
||||
private String number;
|
||||
/** 店铺ID(可选;库表 o_goods.shop_id 非空时由调用方传入,如对外上架) */
|
||||
private Long shopId;
|
||||
/** 外部商品id */
|
||||
private String outerErpGoodsId;
|
||||
/**发货地*/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ public class OGoods implements Serializable {
|
|||
@TableId(value = "id", type= IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 店铺ID(对外入参shopId)
|
||||
*/
|
||||
private Long shopId;
|
||||
|
||||
/**
|
||||
* 商品名称
|
||||
*/
|
||||
|
|
@ -58,6 +63,21 @@ public class OGoods implements Serializable {
|
|||
*/
|
||||
private Long categoryId;
|
||||
|
||||
/**
|
||||
* 标准类目编码(Canonical,映射各平台叶子类目)
|
||||
*/
|
||||
private String categoryCode;
|
||||
|
||||
/**
|
||||
* 标准品牌编码(Canonical,映射各平台品牌)
|
||||
*/
|
||||
private String brandCode;
|
||||
|
||||
/**
|
||||
* Canonical 扩展 JSON(属性列表、详情图、履约策略编码等)
|
||||
*/
|
||||
private String canonicalExt;
|
||||
|
||||
/**
|
||||
* 条码
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ public class OGoodsSku implements Serializable {
|
|||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 店铺ID(对外入参shopId)
|
||||
*/
|
||||
private Long shopId;
|
||||
|
||||
/**
|
||||
* 外键(o_goods)
|
||||
*/
|
||||
|
|
@ -131,6 +136,31 @@ public class OGoodsSku implements Serializable {
|
|||
*/
|
||||
private BigDecimal unitCost;
|
||||
|
||||
/**
|
||||
* 团购价/拼团价等(由各平台适配层解释)
|
||||
*/
|
||||
private BigDecimal groupPrice;
|
||||
|
||||
/**
|
||||
* SKU 库存数量
|
||||
*/
|
||||
private Integer stockQty;
|
||||
|
||||
/**
|
||||
* SKU 重量(克)
|
||||
*/
|
||||
private Long weightGram;
|
||||
|
||||
/**
|
||||
* SKU 规格图/缩略图 URL
|
||||
*/
|
||||
private String skuImageUrl;
|
||||
|
||||
/**
|
||||
* SKU Canonical 扩展 JSON(如销售属性组合明细)
|
||||
*/
|
||||
private String canonicalExt;
|
||||
|
||||
/**
|
||||
* 备注
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
package cn.qihangerp.model.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 外部系统商品下架(本地 {@code o_goods.status=2}),不调用各平台开放 API。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
public class ExternalGoodsDelistRequest {
|
||||
|
||||
/** 店铺维度标识,与 upsert 一致 */
|
||||
private Long shopId;
|
||||
|
||||
/** 外部商品 ID(幂等键),与 upsert 的 outGoodsId 一致 */
|
||||
private String outGoodsId;
|
||||
}
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
package cn.qihangerp.model.request;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 外部系统统一商品上架/同步入参(Canonical,全平台同一套字段)。
|
||||
* <p>平台差异由本服务内部映射处理;{@link #shopId} 为店铺维度标识并写入商品表,<b>不校验</b> {@code o_shop}。</p>
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
public class ExternalGoodsUpsertRequest {
|
||||
|
||||
/**
|
||||
* 店铺维度标识(写入 {@code o_goods}/{@code o_goods_sku});<b>不校验</b> {@code o_shop} 表是否存在。
|
||||
*/
|
||||
private Long shopId;
|
||||
|
||||
/**
|
||||
* 目标平台枚举:PDD/TAO/JD/DOU/WEI/KWAI/XHS(与 EnumShopType 命名一致)
|
||||
*/
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 外部系统商品 ID(幂等键,店铺维度唯一)
|
||||
*/
|
||||
private String outGoodsId;
|
||||
|
||||
/**
|
||||
* 商品标题
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 主图 URL;可与 {@link #images} 二选一或同时传(同时传时建议首张与 mainImage 一致)
|
||||
*/
|
||||
private String mainImage;
|
||||
|
||||
/**
|
||||
* 轮播图 URL 列表(有序)
|
||||
*/
|
||||
private List<String> images;
|
||||
|
||||
/**
|
||||
* 详情图 URL 列表(有序)
|
||||
*/
|
||||
private List<String> detailImages;
|
||||
|
||||
/**
|
||||
* 主图/详情视频等(公网可访问 URL)
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* 标准类目编码(非平台 catId,由本服务映射到各平台叶子类目)
|
||||
*/
|
||||
private String categoryCode;
|
||||
|
||||
/**
|
||||
* 标准品牌编码(非平台 brandId)
|
||||
*/
|
||||
private String brandCode;
|
||||
|
||||
/**
|
||||
* 商品属性(非销售规格)
|
||||
*/
|
||||
private List<AttributeItem> attributes;
|
||||
|
||||
/**
|
||||
* 销售属性维度(如颜色、尺码等,values 为该维度可选值集合)
|
||||
*/
|
||||
private List<SalesAttributeItem> salesAttributes;
|
||||
|
||||
/**
|
||||
* 参考价/划线价(与平台展示口径可能不同,由适配层换算)
|
||||
*/
|
||||
private BigDecimal marketPrice;
|
||||
|
||||
/**
|
||||
* 承诺发货时效(小时),如 24、48
|
||||
*/
|
||||
private Integer shipWithinHours;
|
||||
|
||||
/**
|
||||
* 是否支持 7 天无理由(语义统一,各平台映射)
|
||||
*/
|
||||
private Boolean isRefundable;
|
||||
|
||||
/**
|
||||
* 是否预售
|
||||
*/
|
||||
private Boolean isPreSale;
|
||||
|
||||
/**
|
||||
* 是否二手
|
||||
*/
|
||||
private Boolean isSecondHand;
|
||||
|
||||
/**
|
||||
* 运费模板编码(本服务内映射到平台 templateId)
|
||||
*/
|
||||
private String logisticsTemplateCode;
|
||||
|
||||
/** 商品编号(商家侧) */
|
||||
private String goodsNum;
|
||||
|
||||
private String province;
|
||||
private String city;
|
||||
private String town;
|
||||
|
||||
private String barCode;
|
||||
|
||||
private BigDecimal purPrice;
|
||||
private BigDecimal wholePrice;
|
||||
/** SPU 层建议零售价;可与 SKU salePrice 并存 */
|
||||
private BigDecimal retailPrice;
|
||||
private BigDecimal unitCost;
|
||||
|
||||
/**
|
||||
* 商品外链(详情页/H5 等,非必填)
|
||||
*/
|
||||
private String linkUrl;
|
||||
|
||||
/**
|
||||
* 扩展 KV(禁止放平台私有字段名;仅放业务扩展)
|
||||
*/
|
||||
private Map<String, String> ext;
|
||||
|
||||
private List<Sku> skus;
|
||||
|
||||
/**
|
||||
* 拼多多 POP 凭证(<b>每次请求</b>由调用方传入)。当 {@code platform=PDD} 且服务端
|
||||
* {@code external.pdd.publish-enabled=true} 时<b>必填</b>;不依赖 {@code o_shop}。
|
||||
*/
|
||||
private PddPopAuth pddPopAuth;
|
||||
|
||||
@Data
|
||||
public static class PddPopAuth {
|
||||
@JsonAlias({"app_key", "clientId", "client_id"})
|
||||
private String appKey;
|
||||
|
||||
@JsonAlias({"app_secret", "clientSecret", "client_secret"})
|
||||
private String appSecret;
|
||||
|
||||
@JsonAlias({"access_token", "token", "sessionKey", "session_key"})
|
||||
private String accessToken;
|
||||
|
||||
/** 可选;非空则作为 POP gateway,否则用服务端 {@code external.pdd.gateway-url} */
|
||||
@JsonAlias({"gateway_url", "popGatewayUrl"})
|
||||
private String gatewayUrl;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AttributeItem {
|
||||
private String code;
|
||||
private String name;
|
||||
private String value;
|
||||
private String unit;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SalesAttributeItem {
|
||||
private String code;
|
||||
private String name;
|
||||
private List<String> values;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Sku {
|
||||
private String outSkuId;
|
||||
private String skuCode;
|
||||
private String skuName;
|
||||
/** 单买价/常规售价 */
|
||||
private BigDecimal salePrice;
|
||||
/** 团购价/拼团价等(PDD multi_price 等映射来源) */
|
||||
private BigDecimal groupPrice;
|
||||
private Integer stockQty;
|
||||
private Long weightGram;
|
||||
private String barCode;
|
||||
private String imageUrl;
|
||||
private List<SkuAttributeValue> attributeValues;
|
||||
|
||||
private BigDecimal purPrice;
|
||||
private BigDecimal retailPrice;
|
||||
private BigDecimal unitCost;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SkuAttributeValue {
|
||||
private String code;
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package cn.qihangerp.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* /external/goods/upsert 同步返回:本地落库 + 可选拼多多发布结果。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ExternalGoodsUpsertResultVo implements Serializable {
|
||||
|
||||
private Long goodsId;
|
||||
|
||||
/** 是否尝试调用拼多多上架 */
|
||||
private Boolean pddPublishAttempted;
|
||||
|
||||
/** 拼多多上架是否成功(未尝试时为 null) */
|
||||
private Boolean pddPublishSuccess;
|
||||
|
||||
/** 说明或错误摘要 */
|
||||
private String pddPublishMessage;
|
||||
|
||||
/** 拼多多网关响应片段(便于排错) */
|
||||
private String pddResponseSnippet;
|
||||
|
||||
// ---- 店铺凭证与自动拉取类目/spec(仅 platform=PDD 时有意义) ----
|
||||
|
||||
/** REQUEST(请求体 pddPopAuth)/ NONE;与 {@code o_shop} 无关 */
|
||||
private String shopCredentialSource;
|
||||
|
||||
private Boolean pddCatRuleFetched;
|
||||
/** pdd.goods.cat.rule.get 响应片段 */
|
||||
private String pddCatRuleSnippet;
|
||||
|
||||
/** 是否通过 spec.id.get 自动生成了 sku 规格 */
|
||||
private Boolean pddSpecAutoResolved;
|
||||
private String pddAutoResolveDetail;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package cn.qihangerp.model.vo;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 拼多多发布链路诊断(凭证来源、类目规则、自动 spec 等)。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class PddPublishLaneResultVo implements Serializable {
|
||||
|
||||
private Boolean attempted;
|
||||
private Boolean success;
|
||||
private String message;
|
||||
private String goodsAddSnippet;
|
||||
|
||||
/** REQUEST / NONE */
|
||||
private String shopCredentialSource;
|
||||
|
||||
private Boolean catRuleFetched;
|
||||
private String catRuleSnippet;
|
||||
|
||||
private Boolean specAutoResolved;
|
||||
private String autoResolveDetail;
|
||||
}
|
||||
|
|
@ -133,6 +133,9 @@ public class OGoodsServiceImpl extends ServiceImpl<OGoodsMapper, OGoods>
|
|||
goods.setProvince(bo.getProvince());
|
||||
goods.setCity(bo.getCity());
|
||||
goods.setTown(bo.getTown());
|
||||
if (bo.getShopId() != null) {
|
||||
goods.setShopId(bo.getShopId());
|
||||
}
|
||||
|
||||
// 1、添加主表o_goods
|
||||
goodsMapper.insert(goods);
|
||||
|
|
|
|||
18
service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java
vendored
Normal file
18
service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package cn.qihangerp.service.external;
|
||||
|
||||
import cn.qihangerp.model.request.ExternalGoodsDelistRequest;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo;
|
||||
|
||||
public interface ExternalGoodsAppService {
|
||||
|
||||
/**
|
||||
* Upsert 商品与 SKU(按请求 {@code shopId} 落库,不校验 {@code o_shop}),配置开启时对拼多多尝试 {@code pdd.goods.add}。
|
||||
*/
|
||||
ExternalGoodsUpsertResultVo upsertGoods(ExternalGoodsUpsertRequest req);
|
||||
|
||||
/**
|
||||
* 将本地商品标为已下架({@code o_goods.status=2});商品不存在则抛出非法参数异常。
|
||||
*/
|
||||
void delistGoods(ExternalGoodsDelistRequest req);
|
||||
}
|
||||
344
service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java
vendored
Normal file
344
service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java
vendored
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
package cn.qihangerp.service.external.impl;
|
||||
|
||||
import cn.qihangerp.common.ResultVo;
|
||||
import cn.qihangerp.model.bo.GoodsAddBo;
|
||||
import cn.qihangerp.model.entity.OGoods;
|
||||
import cn.qihangerp.model.entity.OGoodsSku;
|
||||
import cn.qihangerp.model.request.ExternalGoodsDelistRequest;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo;
|
||||
import cn.qihangerp.model.vo.PddPublishLaneResultVo;
|
||||
import cn.qihangerp.module.service.OGoodsService;
|
||||
import cn.qihangerp.module.service.OGoodsSkuService;
|
||||
import cn.qihangerp.service.external.ExternalGoodsAppService;
|
||||
import cn.qihangerp.service.external.pdd.ExternalPddPublishService;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author guochengyu
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class ExternalGoodsAppServiceImpl implements ExternalGoodsAppService {
|
||||
private final OGoodsService goodsService;
|
||||
private final OGoodsSkuService skuService;
|
||||
private final ExternalPddPublishService externalPddPublishService;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public ExternalGoodsUpsertResultVo upsertGoods(ExternalGoodsUpsertRequest req) {
|
||||
Objects.requireNonNull(req, "req");
|
||||
|
||||
OGoods existing = goodsService.getOne(new LambdaQueryWrapper<OGoods>()
|
||||
.eq(OGoods::getShopId, req.getShopId())
|
||||
.eq(OGoods::getOuterErpGoodsId, req.getOutGoodsId())
|
||||
.last("LIMIT 1"));
|
||||
|
||||
Long goodsId;
|
||||
if (existing == null) {
|
||||
GoodsAddBo addBo = toGoodsAddBo(req);
|
||||
ResultVo<Long> r = goodsService.insertGoods("external", addBo);
|
||||
if (r == null || r.getCode() != 0) {
|
||||
throw new IllegalArgumentException(r == null ? "新增商品失败" : r.getMsg());
|
||||
}
|
||||
goodsId = r.getData();
|
||||
OGoods inserted = goodsService.getById(goodsId);
|
||||
if (inserted != null) {
|
||||
applyGoodsCanonicalFields(inserted, req);
|
||||
goodsService.updateById(inserted);
|
||||
}
|
||||
} else {
|
||||
applyGoodsCanonicalFields(existing, req);
|
||||
goodsService.updateById(existing);
|
||||
goodsId = existing.getId();
|
||||
}
|
||||
|
||||
if (!CollectionUtils.isEmpty(req.getSkus())) {
|
||||
for (var skuReq : req.getSkus()) {
|
||||
if (skuReq == null || !StringUtils.hasText(skuReq.getOutSkuId())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
OGoodsSku sku = skuService.getOne(new LambdaQueryWrapper<OGoodsSku>()
|
||||
.eq(OGoodsSku::getShopId, req.getShopId())
|
||||
.eq(OGoodsSku::getOuterErpSkuId, skuReq.getOutSkuId())
|
||||
.last("LIMIT 1"));
|
||||
|
||||
if (sku == null) {
|
||||
sku = new OGoodsSku();
|
||||
sku.setShopId(req.getShopId());
|
||||
sku.setGoodsId(goodsId);
|
||||
sku.setOuterErpGoodsId(req.getOutGoodsId());
|
||||
sku.setOuterErpSkuId(skuReq.getOutSkuId());
|
||||
sku.setCreateTime(new Date());
|
||||
applySkuFields(sku, skuReq, req, goodsId);
|
||||
skuService.save(sku);
|
||||
} else {
|
||||
applySkuFields(sku, skuReq, req, goodsId);
|
||||
sku.setUpdateTime(new Date());
|
||||
skuService.updateById(sku);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExternalGoodsUpsertResultVo.ExternalGoodsUpsertResultVoBuilder out = ExternalGoodsUpsertResultVo.builder()
|
||||
.goodsId(goodsId)
|
||||
.pddPublishAttempted(false)
|
||||
.pddPublishSuccess(null)
|
||||
.pddPublishMessage(null)
|
||||
.pddResponseSnippet(null)
|
||||
.shopCredentialSource(null)
|
||||
.pddCatRuleFetched(null)
|
||||
.pddCatRuleSnippet(null)
|
||||
.pddSpecAutoResolved(null)
|
||||
.pddAutoResolveDetail(null);
|
||||
|
||||
if ("PDD".equalsIgnoreCase(req.getPlatform())) {
|
||||
OGoods g = goodsService.getById(goodsId);
|
||||
List<OGoodsSku> skuRows = skuService.list(new LambdaQueryWrapper<OGoodsSku>()
|
||||
.eq(OGoodsSku::getGoodsId, goodsId));
|
||||
Map<String, OGoodsSku> byOuter = new LinkedHashMap<>();
|
||||
for (OGoodsSku row : skuRows) {
|
||||
if (row != null && StringUtils.hasText(row.getOuterErpSkuId())) {
|
||||
byOuter.putIfAbsent(row.getOuterErpSkuId(), row);
|
||||
}
|
||||
}
|
||||
List<OGoodsSku> orderedSkus = new ArrayList<>();
|
||||
if (!CollectionUtils.isEmpty(req.getSkus())) {
|
||||
for (ExternalGoodsUpsertRequest.Sku sr : req.getSkus()) {
|
||||
if (sr == null || !StringUtils.hasText(sr.getOutSkuId())) {
|
||||
continue;
|
||||
}
|
||||
OGoodsSku row = byOuter.get(sr.getOutSkuId());
|
||||
if (row != null) {
|
||||
orderedSkus.add(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
PddPublishLaneResultVo lane = externalPddPublishService.publish(g, orderedSkus, req);
|
||||
out.pddPublishAttempted(Boolean.TRUE.equals(lane.getAttempted()));
|
||||
out.pddPublishSuccess(lane.getSuccess());
|
||||
out.pddPublishMessage(lane.getMessage());
|
||||
out.pddResponseSnippet(lane.getGoodsAddSnippet());
|
||||
out.shopCredentialSource(lane.getShopCredentialSource());
|
||||
out.pddCatRuleFetched(lane.getCatRuleFetched());
|
||||
out.pddCatRuleSnippet(lane.getCatRuleSnippet());
|
||||
out.pddSpecAutoResolved(lane.getSpecAutoResolved());
|
||||
out.pddAutoResolveDetail(lane.getAutoResolveDetail());
|
||||
}
|
||||
|
||||
return out.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void delistGoods(ExternalGoodsDelistRequest req) {
|
||||
Objects.requireNonNull(req, "req");
|
||||
if (req.getShopId() == null || req.getShopId() <= 0) {
|
||||
throw new IllegalArgumentException("shopId不能为空");
|
||||
}
|
||||
if (!StringUtils.hasText(req.getOutGoodsId())) {
|
||||
throw new IllegalArgumentException("outGoodsId不能为空");
|
||||
}
|
||||
OGoods existing = goodsService.getOne(new LambdaQueryWrapper<OGoods>()
|
||||
.eq(OGoods::getShopId, req.getShopId())
|
||||
.eq(OGoods::getOuterErpGoodsId, req.getOutGoodsId().trim())
|
||||
.last("LIMIT 1"));
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("商品不存在:shopId=" + req.getShopId() + ", outGoodsId=" + req.getOutGoodsId());
|
||||
}
|
||||
// 1 销售中 2 已下架(与 o_goods.status 注释一致)
|
||||
existing.setStatus(2);
|
||||
existing.setUpdateBy("external");
|
||||
existing.setUpdateTime(new Date());
|
||||
goodsService.updateById(existing);
|
||||
}
|
||||
|
||||
private static void applyGoodsCanonicalFields(OGoods g, ExternalGoodsUpsertRequest req) {
|
||||
g.setShopId(req.getShopId());
|
||||
g.setName(req.getTitle());
|
||||
g.setImage(resolveMainImage(req));
|
||||
g.setGoodsNum(resolveGoodsNumber(req));
|
||||
g.setBarCode(req.getBarCode());
|
||||
g.setProvince(req.getProvince());
|
||||
g.setCity(req.getCity());
|
||||
g.setTown(req.getTown());
|
||||
g.setPurPrice(req.getPurPrice());
|
||||
g.setWholePrice(req.getWholePrice());
|
||||
g.setRetailPrice(resolveSpuRetailPrice(req));
|
||||
g.setUnitCost(req.getUnitCost());
|
||||
g.setCategoryCode(req.getCategoryCode());
|
||||
g.setBrandCode(req.getBrandCode());
|
||||
g.setLinkUrl(req.getLinkUrl());
|
||||
g.setCanonicalExt(buildGoodsCanonicalExtJson(req));
|
||||
g.setUpdateBy("external");
|
||||
g.setUpdateTime(new Date());
|
||||
}
|
||||
|
||||
private static void applySkuFields(OGoodsSku sku, ExternalGoodsUpsertRequest.Sku skuReq, ExternalGoodsUpsertRequest req, Long goodsId) {
|
||||
sku.setShopId(req.getShopId());
|
||||
sku.setGoodsId(goodsId);
|
||||
sku.setOuterErpGoodsId(req.getOutGoodsId());
|
||||
sku.setGoodsName(req.getTitle());
|
||||
sku.setGoodsNum(resolveGoodsNumber(req));
|
||||
sku.setSkuCode(skuReq.getSkuCode());
|
||||
sku.setSkuName(skuReq.getSkuName());
|
||||
sku.setBarCode(skuReq.getBarCode());
|
||||
sku.setPurPrice(skuReq.getPurPrice());
|
||||
sku.setRetailPrice(resolveSkuSalePrice(skuReq));
|
||||
sku.setUnitCost(skuReq.getUnitCost());
|
||||
sku.setGroupPrice(skuReq.getGroupPrice());
|
||||
sku.setStockQty(skuReq.getStockQty());
|
||||
sku.setWeightGram(skuReq.getWeightGram());
|
||||
sku.setSkuImageUrl(StringUtils.hasText(skuReq.getImageUrl()) ? skuReq.getImageUrl() : null);
|
||||
sku.setCanonicalExt(buildSkuCanonicalExtJson(skuReq));
|
||||
}
|
||||
|
||||
private static GoodsAddBo toGoodsAddBo(ExternalGoodsUpsertRequest req) {
|
||||
GoodsAddBo bo = new GoodsAddBo();
|
||||
bo.setName(req.getTitle());
|
||||
bo.setImage(resolveMainImage(req));
|
||||
// insertGoods 要求 number 非空;对外可省略 goodsNum,用 outGoodsId 作为内部商品编码
|
||||
bo.setNumber(resolveGoodsNumber(req));
|
||||
bo.setShopId(req.getShopId());
|
||||
bo.setOuterErpGoodsId(req.getOutGoodsId());
|
||||
bo.setProvince(req.getProvince());
|
||||
bo.setCity(req.getCity());
|
||||
bo.setTown(req.getTown());
|
||||
bo.setCategoryId(null);
|
||||
bo.setBarCode(req.getBarCode());
|
||||
bo.setPurPrice(req.getPurPrice());
|
||||
bo.setWholePrice(req.getWholePrice());
|
||||
bo.setRetailPrice(resolveSpuRetailPrice(req));
|
||||
bo.setUnitCost(req.getUnitCost());
|
||||
// insertGoods 内 supplierId.toString(),对外新建默认 0,避免 NPE
|
||||
bo.setSupplierId(0L);
|
||||
bo.setBrandId(null);
|
||||
bo.setAttr5(null);
|
||||
bo.setLinkUrl(req.getLinkUrl());
|
||||
// 不在 insertGoods 中落 SKU:SKU 由本类后续循环写入且带 shopId,避免无 shopId 的重复行
|
||||
bo.setSpecList(new ArrayList<>());
|
||||
return bo;
|
||||
}
|
||||
|
||||
/** 商品编码:优先 goodsNum,否则用外部商品 ID,满足 insertGoods 与列表展示 */
|
||||
private static String resolveGoodsNumber(ExternalGoodsUpsertRequest req) {
|
||||
if (req == null) {
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.hasText(req.getGoodsNum())) {
|
||||
return req.getGoodsNum().trim();
|
||||
}
|
||||
if (StringUtils.hasText(req.getOutGoodsId())) {
|
||||
return req.getOutGoodsId().trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String resolveMainImage(ExternalGoodsUpsertRequest req) {
|
||||
if (StringUtils.hasText(req.getMainImage())) {
|
||||
return req.getMainImage().trim();
|
||||
}
|
||||
List<String> imgs = req.getImages();
|
||||
if (!CollectionUtils.isEmpty(imgs)) {
|
||||
for (String u : imgs) {
|
||||
if (StringUtils.hasText(u)) {
|
||||
return u.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static BigDecimal resolveSpuRetailPrice(ExternalGoodsUpsertRequest req) {
|
||||
if (req.getRetailPrice() != null) {
|
||||
return req.getRetailPrice();
|
||||
}
|
||||
if (CollectionUtils.isEmpty(req.getSkus())) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal max = null;
|
||||
for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) {
|
||||
if (s == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal p = resolveSkuSalePrice(s);
|
||||
if (p != null && (max == null || p.compareTo(max) > 0)) {
|
||||
max = p;
|
||||
}
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
private static BigDecimal resolveSkuSalePrice(ExternalGoodsUpsertRequest.Sku skuReq) {
|
||||
if (skuReq.getSalePrice() != null) {
|
||||
return skuReq.getSalePrice();
|
||||
}
|
||||
return skuReq.getRetailPrice();
|
||||
}
|
||||
|
||||
private static String buildGoodsCanonicalExtJson(ExternalGoodsUpsertRequest req) {
|
||||
JSONObject o = new JSONObject();
|
||||
if (!CollectionUtils.isEmpty(req.getImages())) {
|
||||
o.put("images", req.getImages());
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(req.getDetailImages())) {
|
||||
o.put("detailImages", req.getDetailImages());
|
||||
}
|
||||
if (StringUtils.hasText(req.getVideoUrl())) {
|
||||
o.put("videoUrl", req.getVideoUrl());
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(req.getAttributes())) {
|
||||
o.put("attributes", req.getAttributes());
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(req.getSalesAttributes())) {
|
||||
o.put("salesAttributes", req.getSalesAttributes());
|
||||
}
|
||||
if (req.getMarketPrice() != null) {
|
||||
o.put("marketPrice", req.getMarketPrice());
|
||||
}
|
||||
if (req.getShipWithinHours() != null) {
|
||||
o.put("shipWithinHours", req.getShipWithinHours());
|
||||
}
|
||||
if (req.getIsRefundable() != null) {
|
||||
o.put("isRefundable", req.getIsRefundable());
|
||||
}
|
||||
if (req.getIsPreSale() != null) {
|
||||
o.put("isPreSale", req.getIsPreSale());
|
||||
}
|
||||
if (req.getIsSecondHand() != null) {
|
||||
o.put("isSecondHand", req.getIsSecondHand());
|
||||
}
|
||||
if (StringUtils.hasText(req.getLogisticsTemplateCode())) {
|
||||
o.put("logisticsTemplateCode", req.getLogisticsTemplateCode());
|
||||
}
|
||||
if (!CollectionUtils.isEmpty(req.getExt())) {
|
||||
o.put("ext", req.getExt());
|
||||
}
|
||||
return o.isEmpty() ? null : JSON.toJSONString(o);
|
||||
}
|
||||
|
||||
private static String buildSkuCanonicalExtJson(ExternalGoodsUpsertRequest.Sku skuReq) {
|
||||
if (CollectionUtils.isEmpty(skuReq.getAttributeValues())) {
|
||||
return null;
|
||||
}
|
||||
JSONObject o = new JSONObject();
|
||||
o.put("attributeValues", skuReq.getAttributeValues());
|
||||
return JSON.toJSONString(o);
|
||||
}
|
||||
}
|
||||
12
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java
vendored
Normal file
12
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/**
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(ExternalPddProperties.class)
|
||||
public class ExternalPddConfiguration {
|
||||
}
|
||||
82
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java
vendored
Normal file
82
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼多多 POP 网关与上架映射(内部配置,不暴露给外部调用方)。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "external.pdd")
|
||||
public class ExternalPddProperties {
|
||||
|
||||
/**
|
||||
* 是否在 upsert 成功后尝试调用 pdd.goods.add
|
||||
*/
|
||||
private boolean publishEnabled = false;
|
||||
|
||||
/**
|
||||
* POP 网关地址
|
||||
*/
|
||||
private String gatewayUrl = "https://gw-api.pinduoduo.com/api/router";
|
||||
|
||||
/**
|
||||
* Canonical categoryCode -> 拼多多叶子类目 cat_id
|
||||
*/
|
||||
private Map<String, Long> categoryMap = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* logisticsTemplateCode 或 DEFAULT -> cost_template_id
|
||||
*/
|
||||
private Map<String, Long> costTemplateMap = new LinkedHashMap<>();
|
||||
|
||||
private int defaultGoodsType = 1;
|
||||
private int defaultCountryId = 0;
|
||||
/** 承诺发货秒数,如 172800=48h */
|
||||
private long defaultShipmentLimitSecond = 172800L;
|
||||
private boolean defaultIsFolt = true;
|
||||
private boolean defaultIsPreSale = false;
|
||||
private boolean defaultIsRefundable = true;
|
||||
private boolean defaultSecondHand = false;
|
||||
|
||||
/**
|
||||
* 与单次请求 {@code skus} 列表顺序一一对应(过滤掉 null 后的顺序),
|
||||
* 每条对应 PDD sku_list 一项的 spec_id_list 与 sku_properties。
|
||||
* <p>若为空且开启 {@link #autoResolveSpecIdsWhenSkuOverridesEmpty},将尝试用 cat.rule + spec.id.get 自动生成。</p>
|
||||
*/
|
||||
private List<PddSkuOverride> skuOverrides = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* 发布前自动调用 pdd.goods.cat.rule.get(结果片段写入接口返回便于排查)
|
||||
*/
|
||||
private boolean autoFetchCatRule = false;
|
||||
|
||||
/**
|
||||
* 当 {@link #skuOverrides} 为空时,根据类目规则找销售 parent_spec_id,并对每个 SKU 调 pdd.goods.spec.id.get(规格名=skuName)
|
||||
*/
|
||||
private boolean autoResolveSpecIdsWhenSkuOverridesEmpty = false;
|
||||
|
||||
@Data
|
||||
public static class PddSkuOverride {
|
||||
/**
|
||||
* 文档示例:"[25]" 或 "[20,5]"
|
||||
*/
|
||||
private String specIdList;
|
||||
private List<PddSkuProperty> properties = new ArrayList<>();
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class PddSkuProperty {
|
||||
private Long refPid;
|
||||
private Long vid;
|
||||
private String value;
|
||||
private String punit;
|
||||
}
|
||||
}
|
||||
187
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java
vendored
Normal file
187
service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java
vendored
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import cn.qihangerp.model.entity.OGoods;
|
||||
import cn.qihangerp.model.entity.OGoodsSku;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import cn.qihangerp.model.vo.PddPublishLaneResultVo;
|
||||
import cn.qihangerp.service.external.shop.PddShopCredential;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 拼多多发布:POP 凭证仅来自本次请求的 {@link ExternalGoodsUpsertRequest#getPddPopAuth()};不依赖 {@code o_shop}。
|
||||
* 可选自动拉取类目规则与 spec.id。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ExternalPddPublishService {
|
||||
|
||||
private final ExternalPddProperties props;
|
||||
private final PddGoodsAddParamBuilder paramBuilder;
|
||||
private final PddPopClient pddPopClient;
|
||||
private final PddCatRuleSpecAutoResolver catRuleSpecAutoResolver;
|
||||
|
||||
/**
|
||||
* 落库成功后尝试拼多多上架;返回链路诊断信息。
|
||||
*/
|
||||
public PddPublishLaneResultVo publish(OGoods goods, List<OGoodsSku> skus, ExternalGoodsUpsertRequest req) {
|
||||
if (!props.isPublishEnabled()) {
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(false)
|
||||
.success(null)
|
||||
.message("external.pdd.publish-enabled=false,未调用拼多多")
|
||||
.build();
|
||||
}
|
||||
|
||||
if (req == null) {
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(false)
|
||||
.success(false)
|
||||
.message("请求参数不能为空")
|
||||
.shopCredentialSource("NONE")
|
||||
.build();
|
||||
}
|
||||
|
||||
PddShopCredential cred = resolveCredentialFromRequest(req);
|
||||
if (cred == null || !StringUtils.hasText(cred.getAppKey()) || !StringUtils.hasText(cred.getAppSecret())
|
||||
|| !StringUtils.hasText(cred.getAccessToken())) {
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(false)
|
||||
.success(false)
|
||||
.message("拼多多凭证不完整:请在请求体 pddPopAuth 中传入 appKey、appSecret、accessToken")
|
||||
.shopCredentialSource("NONE")
|
||||
.build();
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("PDD publish shopId={} appKey={}", req.getShopId(), PddSensitiveLogUtil.maskForLog(cred.getAppKey()));
|
||||
}
|
||||
|
||||
String gateway = StringUtils.hasText(cred.getGatewayUrl()) ? cred.getGatewayUrl() : props.getGatewayUrl();
|
||||
|
||||
List<OGoodsSku> skuRows = skus == null ? List.of() : skus.stream()
|
||||
.filter(s -> s != null && StringUtils.hasText(s.getOuterErpSkuId()))
|
||||
.toList();
|
||||
|
||||
Long catId = paramBuilder.resolveCatIdForPublish(req.getCategoryCode(), props);
|
||||
if (catId == null || catId <= 0) {
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(false)
|
||||
.success(false)
|
||||
.message("缺少拼多多类目映射:external.pdd.category-map 未配置 categoryCode=" + req.getCategoryCode() + " 或 DEFAULT")
|
||||
.shopCredentialSource(cred.getSource())
|
||||
.build();
|
||||
}
|
||||
|
||||
String catRuleSnippet = null;
|
||||
boolean catFetched = false;
|
||||
boolean specAuto = false;
|
||||
String autoDetail = null;
|
||||
|
||||
List<ExternalPddProperties.PddSkuOverride> effectiveOverrides = null;
|
||||
if (!CollectionUtils.isEmpty(props.getSkuOverrides())) {
|
||||
effectiveOverrides = props.getSkuOverrides();
|
||||
} else if (props.isAutoResolveSpecIdsWhenSkuOverridesEmpty()) {
|
||||
PddCatRuleSpecAutoResolver.AutoResolveResult ar = catRuleSpecAutoResolver.resolve(cred, gateway, req, skuRows, catId);
|
||||
catRuleSnippet = ar.getCatRuleSnippet();
|
||||
catFetched = ar.isCatRuleFetched();
|
||||
autoDetail = ar.getDetail();
|
||||
if (ar.isResolved() && ar.getOverrides() != null && ar.getOverrides().size() == skuRows.size()) {
|
||||
effectiveOverrides = ar.getOverrides();
|
||||
specAuto = true;
|
||||
} else {
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(false)
|
||||
.success(false)
|
||||
.message("自动解析 SKU 规格失败:" + (autoDetail == null ? "unknown" : autoDetail))
|
||||
.shopCredentialSource(cred.getSource())
|
||||
.catRuleFetched(catFetched)
|
||||
.catRuleSnippet(catRuleSnippet)
|
||||
.specAutoResolved(false)
|
||||
.autoResolveDetail(autoDetail)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
if (props.isAutoFetchCatRule() && !catFetched) {
|
||||
try {
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("cat_id", catId);
|
||||
String raw = pddPopClient.invoke(gateway, cred.getAppKey(), cred.getAppSecret(), cred.getAccessToken(),
|
||||
"pdd.goods.cat.rule.get", JSON.toJSONString(body));
|
||||
catRuleSnippet = PddOpenApiSupport.snippet(raw, 2000);
|
||||
catFetched = true;
|
||||
} catch (Exception e) {
|
||||
log.warn("pdd.goods.cat.rule.get 失败: {}", e.getMessage());
|
||||
catRuleSnippet = "ERROR: " + e.getMessage();
|
||||
catFetched = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
String paramJson = paramBuilder.buildParamJson(goods, skus, req, props, effectiveOverrides);
|
||||
String raw = pddPopClient.invoke(gateway, cred.getAppKey(), cred.getAppSecret(), cred.getAccessToken(),
|
||||
"pdd.goods.add", paramJson);
|
||||
boolean ok = !PddOpenApiSupport.isError(raw);
|
||||
String errMsg = ok ? null : PddOpenApiSupport.formatError(raw);
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(true)
|
||||
.success(ok)
|
||||
.message(ok ? "pdd.goods.add 调用成功" : ("pdd.goods.add 失败: " + errMsg))
|
||||
.goodsAddSnippet(PddOpenApiSupport.snippet(raw, 2000))
|
||||
.shopCredentialSource(cred.getSource())
|
||||
.catRuleFetched(catFetched)
|
||||
.catRuleSnippet(catRuleSnippet)
|
||||
.specAutoResolved(specAuto)
|
||||
.autoResolveDetail(autoDetail)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
log.warn("拼多多发布失败: {}", e.getMessage());
|
||||
return PddPublishLaneResultVo.builder()
|
||||
.attempted(true)
|
||||
.success(false)
|
||||
.message(e.getMessage())
|
||||
.shopCredentialSource(cred.getSource())
|
||||
.catRuleFetched(catFetched)
|
||||
.catRuleSnippet(catRuleSnippet)
|
||||
.specAutoResolved(specAuto)
|
||||
.autoResolveDetail(autoDetail)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
private PddShopCredential resolveCredentialFromRequest(ExternalGoodsUpsertRequest req) {
|
||||
if (req == null || req.getPddPopAuth() == null) {
|
||||
return null;
|
||||
}
|
||||
ExternalGoodsUpsertRequest.PddPopAuth a = req.getPddPopAuth();
|
||||
PddShopCredential c = new PddShopCredential();
|
||||
c.setAppKey(trimToNull(a.getAppKey()));
|
||||
c.setAppSecret(trimToNull(a.getAppSecret()));
|
||||
c.setAccessToken(trimToNull(a.getAccessToken()));
|
||||
if (StringUtils.hasText(a.getGatewayUrl())) {
|
||||
c.setGatewayUrl(a.getGatewayUrl().trim());
|
||||
} else {
|
||||
c.setGatewayUrl(props.getGatewayUrl());
|
||||
}
|
||||
c.setSource("REQUEST");
|
||||
return c;
|
||||
}
|
||||
|
||||
private static String trimToNull(String s) {
|
||||
if (!StringUtils.hasText(s)) {
|
||||
return null;
|
||||
}
|
||||
return s.trim();
|
||||
}
|
||||
}
|
||||
150
service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java
vendored
Normal file
150
service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java
vendored
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import cn.qihangerp.model.entity.OGoodsSku;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import cn.qihangerp.service.external.shop.PddShopCredential;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 自动拉取 {@code pdd.goods.cat.rule.get},并在单销售规格维度下尝试 {@code pdd.goods.spec.id.get} 生成 sku-overrides。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@AllArgsConstructor
|
||||
@Service
|
||||
public class PddCatRuleSpecAutoResolver {
|
||||
|
||||
private final PddPopClient popClient;
|
||||
|
||||
@Data
|
||||
public static class AutoResolveResult {
|
||||
private List<ExternalPddProperties.PddSkuOverride> overrides = new ArrayList<>();
|
||||
private String detail;
|
||||
private boolean resolved;
|
||||
private boolean catRuleFetched;
|
||||
private String catRuleSnippet;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取类目规则,解析销售 parent_spec_id,并对每条 SKU 调用 spec.id.get。
|
||||
*/
|
||||
public AutoResolveResult resolve(PddShopCredential cred, String gatewayUrl, ExternalGoodsUpsertRequest req,
|
||||
List<OGoodsSku> orderedSkus, long catId) {
|
||||
AutoResolveResult r = new AutoResolveResult();
|
||||
if (orderedSkus == null || orderedSkus.isEmpty()) {
|
||||
r.setDetail("无有效 SKU,无法自动解析 spec");
|
||||
return r;
|
||||
}
|
||||
try {
|
||||
String raw = fetchCatRuleJson(cred, gatewayUrl, catId);
|
||||
r.setCatRuleFetched(true);
|
||||
r.setCatRuleSnippet(PddOpenApiSupport.snippet(raw, 2000));
|
||||
if (PddOpenApiSupport.isError(raw)) {
|
||||
r.setDetail("pdd.goods.cat.rule.get: " + PddOpenApiSupport.formatError(raw));
|
||||
return r;
|
||||
}
|
||||
Long parentSpecId = PddOpenApiSupport.findFirstSaleParentSpecId(raw);
|
||||
if (parentSpecId == null || parentSpecId <= 0) {
|
||||
r.setDetail("类目规则中未解析到销售 parent_spec_id");
|
||||
return r;
|
||||
}
|
||||
List<ExternalPddProperties.PddSkuOverride> ovs = buildSkuOverridesBySpecName(
|
||||
catId, parentSpecId, orderedSkus, req, cred, gatewayUrl);
|
||||
r.setOverrides(ovs);
|
||||
r.setResolved(ovs.size() == orderedSkus.size());
|
||||
r.setDetail("parent_spec_id=" + parentSpecId + ",自动生成 " + ovs.size() + " 条 spec(请求 SKU " + orderedSkus.size() + " 条)");
|
||||
return r;
|
||||
} catch (Exception e) {
|
||||
r.setDetail("自动解析异常: " + e.getMessage());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 pdd.goods.cat.rule.get
|
||||
*/
|
||||
public String fetchCatRuleJson(PddShopCredential cred, String gatewayUrl, long catId) throws Exception {
|
||||
JSONObject p = new JSONObject();
|
||||
p.put("cat_id", catId);
|
||||
return popClient.invoke(
|
||||
gatewayUrl,
|
||||
cred.getAppKey(),
|
||||
cred.getAppSecret(),
|
||||
cred.getAccessToken(),
|
||||
"pdd.goods.cat.rule.get",
|
||||
JSON.toJSONString(p)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对每个 SKU 用 skuName 作为规格值调用 spec.id.get,生成与 YAML 相同结构的 overrides。
|
||||
*/
|
||||
public List<ExternalPddProperties.PddSkuOverride> buildSkuOverridesBySpecName(
|
||||
long catId,
|
||||
long parentSpecId,
|
||||
List<OGoodsSku> orderedSkus,
|
||||
ExternalGoodsUpsertRequest req,
|
||||
PddShopCredential cred,
|
||||
String gatewayUrl) throws Exception {
|
||||
|
||||
List<ExternalPddProperties.PddSkuOverride> out = new ArrayList<>();
|
||||
for (OGoodsSku row : orderedSkus) {
|
||||
String specName = resolveSpecName(row, req);
|
||||
if (!StringUtils.hasText(specName)) {
|
||||
throw new IllegalStateException("无法解析 SKU 规格名:outSkuId=" + row.getOuterErpSkuId());
|
||||
}
|
||||
JSONObject p = new JSONObject();
|
||||
p.put("cat_id", catId);
|
||||
p.put("parent_spec_id", parentSpecId);
|
||||
p.put("spec_name", specName.trim());
|
||||
|
||||
String body = popClient.invoke(
|
||||
gatewayUrl,
|
||||
cred.getAppKey(),
|
||||
cred.getAppSecret(),
|
||||
cred.getAccessToken(),
|
||||
"pdd.goods.spec.id.get",
|
||||
JSON.toJSONString(p)
|
||||
);
|
||||
if (PddOpenApiSupport.isError(body)) {
|
||||
throw new IllegalStateException("pdd.goods.spec.id.get 失败 spec_name=" + specName + " : "
|
||||
+ PddOpenApiSupport.formatError(body));
|
||||
}
|
||||
Long sid = PddOpenApiSupport.parseSpecIdFromSpecIdGetResponse(body);
|
||||
if (sid == null || sid <= 0) {
|
||||
throw new IllegalStateException("pdd.goods.spec.id.get 未返回 spec_id,spec_name=" + specName + " body="
|
||||
+ PddOpenApiSupport.snippet(body, 400));
|
||||
}
|
||||
ExternalPddProperties.PddSkuOverride ov = new ExternalPddProperties.PddSkuOverride();
|
||||
ov.setSpecIdList("[" + sid + "]");
|
||||
ov.setProperties(new ArrayList<>());
|
||||
out.add(ov);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String resolveSpecName(OGoodsSku row, ExternalGoodsUpsertRequest req) {
|
||||
if (row != null && StringUtils.hasText(row.getSkuName())) {
|
||||
return row.getSkuName();
|
||||
}
|
||||
if (req != null && req.getSkus() != null) {
|
||||
for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) {
|
||||
if (s == null || !StringUtils.hasText(s.getOutSkuId()) || row == null) {
|
||||
continue;
|
||||
}
|
||||
if (s.getOutSkuId().equals(row.getOuterErpSkuId()) && StringUtils.hasText(s.getSkuName())) {
|
||||
return s.getSkuName();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
240
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java
vendored
Normal file
240
service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java
vendored
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import cn.qihangerp.model.entity.OGoods;
|
||||
import cn.qihangerp.model.entity.OGoodsSku;
|
||||
import cn.qihangerp.model.request.ExternalGoodsUpsertRequest;
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 将 Canonical 落库结果组装为 pdd.goods.add 的 param_json。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Component
|
||||
public class PddGoodsAddParamBuilder {
|
||||
|
||||
/**
|
||||
* 供发布前拉取类目规则、自动 spec 等使用(与 buildParamJson 内映射一致)。
|
||||
*/
|
||||
public Long resolveCatIdForPublish(String categoryCode, ExternalPddProperties props) {
|
||||
return resolveCatId(categoryCode, props);
|
||||
}
|
||||
|
||||
public String buildParamJson(OGoods goods, List<OGoodsSku> skus, ExternalGoodsUpsertRequest req,
|
||||
ExternalPddProperties props, List<ExternalPddProperties.PddSkuOverride> effectiveSkuOverrides) {
|
||||
Long catId = resolveCatId(req.getCategoryCode(), props);
|
||||
Long costTpl = resolveCostTemplate(req.getLogisticsTemplateCode(), props);
|
||||
if (catId == null || catId <= 0) {
|
||||
throw new IllegalStateException("缺少拼多多类目映射:external.pdd.category-map 中未找到 categoryCode="
|
||||
+ req.getCategoryCode() + " 或 DEFAULT");
|
||||
}
|
||||
if (costTpl == null || costTpl <= 0) {
|
||||
throw new IllegalStateException("缺少运费模板映射:external.pdd.cost-template-map 中未找到 logisticsTemplateCode="
|
||||
+ req.getLogisticsTemplateCode() + " 或 DEFAULT");
|
||||
}
|
||||
|
||||
List<OGoodsSku> skuRows = skus == null ? List.of() : skus.stream().filter(s -> s != null && StringUtils.hasText(s.getOuterErpSkuId())).toList();
|
||||
if (skuRows.isEmpty()) {
|
||||
throw new IllegalStateException("拼多多上架至少需要一条有效 SKU");
|
||||
}
|
||||
List<ExternalPddProperties.PddSkuOverride> overrides = effectiveSkuOverrides != null ? effectiveSkuOverrides : props.getSkuOverrides();
|
||||
if (overrides == null || overrides.size() != skuRows.size()) {
|
||||
throw new IllegalStateException("拼多多 SKU 规格映射数量须与 SKU 条数一致:请配置 external.pdd.sku-overrides 或开启自动 spec 解析,当前 SKU 数="
|
||||
+ skuRows.size());
|
||||
}
|
||||
|
||||
JSONObject root = new JSONObject();
|
||||
root.put("auto_fill_spu_property", true);
|
||||
root.put("cat_id", catId);
|
||||
root.put("cost_template_id", costTpl);
|
||||
root.put("country_id", props.getDefaultCountryId());
|
||||
root.put("goods_type", props.getDefaultGoodsType());
|
||||
root.put("goods_name", goods.getName());
|
||||
root.put("is_folt", props.isDefaultIsFolt());
|
||||
root.put("is_pre_sale", props.isDefaultIsPreSale());
|
||||
root.put("is_refundable", props.isDefaultIsRefundable());
|
||||
root.put("second_hand", props.isDefaultSecondHand());
|
||||
root.put("shipment_limit_second", resolveShipmentSeconds(req, props));
|
||||
|
||||
List<String> carousel = resolveCarousel(goods, req);
|
||||
if (carousel.isEmpty()) {
|
||||
throw new IllegalStateException("carousel_gallery 不能为空");
|
||||
}
|
||||
root.put("carousel_gallery", carousel);
|
||||
|
||||
List<String> detail = resolveDetailImages(goods);
|
||||
if (detail.isEmpty()) {
|
||||
// 平台要求详情图 1~20 张,若无则回退用轮播首张
|
||||
detail = List.of(carousel.get(0));
|
||||
}
|
||||
root.put("detail_gallery", detail);
|
||||
|
||||
long marketFen = resolveMarketPriceFen(req, skuRows);
|
||||
|
||||
if (StringUtils.hasText(goods.getOuterErpGoodsId())) {
|
||||
root.put("out_goods_id", goods.getOuterErpGoodsId());
|
||||
}
|
||||
|
||||
JSONArray skuList = new JSONArray();
|
||||
String thumbFallback = carousel.get(0);
|
||||
long maxSingleFen = 0L;
|
||||
for (int i = 0; i < skuRows.size(); i++) {
|
||||
OGoodsSku row = skuRows.get(i);
|
||||
ExternalPddProperties.PddSkuOverride ov = overrides.get(i);
|
||||
if (!StringUtils.hasText(ov.getSpecIdList())) {
|
||||
throw new IllegalStateException("external.pdd.sku-overrides[" + i + "].spec-id-list 不能为空");
|
||||
}
|
||||
long groupFen = yuanToFen(firstNonNull(row.getGroupPrice(), row.getRetailPrice()));
|
||||
long singleFen = yuanToFen(firstNonNull(row.getRetailPrice(), row.getGroupPrice()));
|
||||
if (groupFen <= 0) {
|
||||
throw new IllegalStateException("SKU 团购价/拼团价无效(groupPrice/salePrice 换算为 0 分),outSkuId=" + row.getOuterErpSkuId());
|
||||
}
|
||||
if (singleFen <= groupFen) {
|
||||
singleFen = groupFen + 100;
|
||||
}
|
||||
if (singleFen > maxSingleFen) {
|
||||
maxSingleFen = singleFen;
|
||||
}
|
||||
|
||||
JSONObject sku = new JSONObject();
|
||||
sku.put("is_onsale", 1);
|
||||
sku.put("limit_quantity", 999L);
|
||||
sku.put("multi_price", groupFen);
|
||||
sku.put("price", singleFen);
|
||||
sku.put("quantity", row.getStockQty() != null ? row.getStockQty().longValue() : 0L);
|
||||
sku.put("weight", row.getWeightGram() != null ? row.getWeightGram() : 1000L);
|
||||
sku.put("spec_id_list", ov.getSpecIdList());
|
||||
sku.put("thumb_url", StringUtils.hasText(row.getSkuImageUrl()) ? row.getSkuImageUrl() : thumbFallback);
|
||||
if (StringUtils.hasText(row.getOuterErpSkuId())) {
|
||||
sku.put("out_sku_sn", row.getOuterErpSkuId());
|
||||
}
|
||||
JSONArray skuProps = new JSONArray();
|
||||
if (ov.getProperties() != null) {
|
||||
for (ExternalPddProperties.PddSkuProperty p : ov.getProperties()) {
|
||||
if (p == null || p.getRefPid() == null) {
|
||||
continue;
|
||||
}
|
||||
JSONObject pj = new JSONObject();
|
||||
pj.put("ref_pid", p.getRefPid());
|
||||
if (p.getVid() != null) {
|
||||
pj.put("vid", p.getVid());
|
||||
}
|
||||
pj.put("value", p.getValue() == null ? "" : p.getValue());
|
||||
pj.put("punit", p.getPunit() == null ? "" : p.getPunit());
|
||||
skuProps.add(pj);
|
||||
}
|
||||
}
|
||||
sku.put("sku_properties", skuProps);
|
||||
skuList.add(sku);
|
||||
}
|
||||
if (marketFen <= maxSingleFen) {
|
||||
marketFen = maxSingleFen + 100;
|
||||
}
|
||||
root.put("market_price", marketFen);
|
||||
root.put("sku_list", skuList);
|
||||
root.put("goods_properties", new JSONArray());
|
||||
|
||||
return JSON.toJSONString(root);
|
||||
}
|
||||
|
||||
private static long resolveShipmentSeconds(ExternalGoodsUpsertRequest req, ExternalPddProperties props) {
|
||||
if (req.getShipWithinHours() != null && req.getShipWithinHours() > 0) {
|
||||
return req.getShipWithinHours() * 3600L;
|
||||
}
|
||||
return props.getDefaultShipmentLimitSecond();
|
||||
}
|
||||
|
||||
private static Long resolveCatId(String categoryCode, ExternalPddProperties props) {
|
||||
if (StringUtils.hasText(categoryCode) && props.getCategoryMap().containsKey(categoryCode)) {
|
||||
return props.getCategoryMap().get(categoryCode);
|
||||
}
|
||||
return props.getCategoryMap().get("DEFAULT");
|
||||
}
|
||||
|
||||
private static Long resolveCostTemplate(String logisticsTemplateCode, ExternalPddProperties props) {
|
||||
if (StringUtils.hasText(logisticsTemplateCode) && props.getCostTemplateMap().containsKey(logisticsTemplateCode)) {
|
||||
return props.getCostTemplateMap().get(logisticsTemplateCode);
|
||||
}
|
||||
return props.getCostTemplateMap().get("DEFAULT");
|
||||
}
|
||||
|
||||
private static List<String> resolveCarousel(OGoods goods, ExternalGoodsUpsertRequest req) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (StringUtils.hasText(goods.getImage())) {
|
||||
out.add(goods.getImage().trim());
|
||||
}
|
||||
if (req != null && !CollectionUtils.isEmpty(req.getImages())) {
|
||||
for (String u : req.getImages()) {
|
||||
if (StringUtils.hasText(u) && !out.contains(u.trim())) {
|
||||
out.add(u.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<String> resolveDetailImages(OGoods goods) {
|
||||
if (!StringUtils.hasText(goods.getCanonicalExt())) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
JSONObject o = JSON.parseObject(goods.getCanonicalExt());
|
||||
if (o == null || !o.containsKey("detailImages")) {
|
||||
return List.of();
|
||||
}
|
||||
JSONArray arr = o.getJSONArray("detailImages");
|
||||
if (arr == null || arr.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> list = new ArrayList<>();
|
||||
for (int i = 0; i < arr.size(); i++) {
|
||||
String u = arr.getString(i);
|
||||
if (StringUtils.hasText(u)) {
|
||||
list.add(u.trim());
|
||||
}
|
||||
}
|
||||
return list;
|
||||
} catch (Exception e) {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static long resolveMarketPriceFen(ExternalGoodsUpsertRequest req, List<OGoodsSku> skuRows) {
|
||||
if (req.getMarketPrice() != null) {
|
||||
return yuanToFen(req.getMarketPrice());
|
||||
}
|
||||
BigDecimal maxSingle = BigDecimal.ZERO;
|
||||
for (OGoodsSku s : skuRows) {
|
||||
BigDecimal p = firstNonNull(s.getRetailPrice(), s.getGroupPrice());
|
||||
if (p != null && p.compareTo(maxSingle) > 0) {
|
||||
maxSingle = p;
|
||||
}
|
||||
}
|
||||
if (maxSingle.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new IllegalStateException("marketPrice 与各 SKU 价格均为空,无法生成参考价");
|
||||
}
|
||||
// 参考价略高于单买价(分)
|
||||
return yuanToFen(maxSingle) + 100;
|
||||
}
|
||||
|
||||
private static long yuanToFen(BigDecimal yuan) {
|
||||
if (yuan == null) {
|
||||
return 0L;
|
||||
}
|
||||
return yuan.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).longValue();
|
||||
}
|
||||
|
||||
private static BigDecimal firstNonNull(BigDecimal a, BigDecimal b) {
|
||||
return a != null ? a : b;
|
||||
}
|
||||
}
|
||||
148
service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java
vendored
Normal file
148
service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java
vendored
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 解析拼多多 POP 返回 JSON 的通用工具。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
public final class PddOpenApiSupport {
|
||||
|
||||
private PddOpenApiSupport() {
|
||||
}
|
||||
|
||||
public static String snippet(String s, int max) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
return s.length() <= max ? s : s.substring(0, max) + "...";
|
||||
}
|
||||
|
||||
public static boolean isError(String body) {
|
||||
if (!StringUtils.hasText(body)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
JSONObject o = JSON.parseObject(body);
|
||||
return o != null && o.containsKey("error_response");
|
||||
} catch (Exception e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public static String formatError(String body) {
|
||||
try {
|
||||
JSONObject o = JSON.parseObject(body);
|
||||
if (o == null) {
|
||||
return body;
|
||||
}
|
||||
JSONObject err = o.getJSONObject("error_response");
|
||||
if (err != null) {
|
||||
String sub = err.getString("sub_msg");
|
||||
String main = err.getString("error_msg");
|
||||
if (StringUtils.hasText(sub)) {
|
||||
return main + " (" + sub + ")";
|
||||
}
|
||||
return main;
|
||||
}
|
||||
return body;
|
||||
} catch (Exception e) {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在类目规则 JSON 中递归查找 is_sale 为 true 的节点上的 parent_spec_id(取第一个)。
|
||||
*/
|
||||
public static Long findFirstSaleParentSpecId(String catRuleBody) {
|
||||
if (!StringUtils.hasText(catRuleBody)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject root = JSON.parseObject(catRuleBody);
|
||||
if (root == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject inner = root.getJSONObject("goods_cat_rule_get_response");
|
||||
if (inner == null) {
|
||||
inner = root;
|
||||
}
|
||||
long[] holder = new long[]{0L};
|
||||
walkForSaleParent(inner, holder);
|
||||
return holder[0] > 0 ? holder[0] : null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void walkForSaleParent(Object node, long[] holder) {
|
||||
if (holder[0] > 0) {
|
||||
return;
|
||||
}
|
||||
if (node instanceof JSONObject jo) {
|
||||
if (isTruthySale(jo.get("is_sale"))) {
|
||||
Long p = jo.getLong("parent_spec_id");
|
||||
if (p != null && p > 0) {
|
||||
holder[0] = p;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (String k : jo.keySet()) {
|
||||
walkForSaleParent(jo.get(k), holder);
|
||||
if (holder[0] > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (node instanceof JSONArray ja) {
|
||||
for (int i = 0; i < ja.size(); i++) {
|
||||
walkForSaleParent(ja.get(i), holder);
|
||||
if (holder[0] > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isTruthySale(Object v) {
|
||||
if (v == null) {
|
||||
return false;
|
||||
}
|
||||
if (v instanceof Boolean b) {
|
||||
return b;
|
||||
}
|
||||
if (v instanceof Number n) {
|
||||
return n.intValue() == 1;
|
||||
}
|
||||
return "1".equals(String.valueOf(v)) || "true".equalsIgnoreCase(String.valueOf(v));
|
||||
}
|
||||
|
||||
public static Long parseSpecIdFromSpecIdGetResponse(String body) {
|
||||
if (!StringUtils.hasText(body)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject o = JSON.parseObject(body);
|
||||
if (o == null) {
|
||||
return null;
|
||||
}
|
||||
JSONObject r = o.getJSONObject("goods_spec_id_get_response");
|
||||
if (r == null) {
|
||||
r = o.getJSONObject("spec_id_get_response");
|
||||
}
|
||||
if (r == null) {
|
||||
return null;
|
||||
}
|
||||
Long id = r.getLong("spec_id");
|
||||
if (id != null && id > 0) {
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 拼多多 POP HTTP 调用(application/x-www-form-urlencoded)。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Component
|
||||
public class PddPopClient {
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(15))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* @param type 如 pdd.goods.add
|
||||
* @param paramJson 业务参数 JSON 字符串(将作为 param_json 传递)
|
||||
*/
|
||||
public String invoke(String gatewayUrl, String clientId, String clientSecret, String accessToken,
|
||||
String type, String paramJson) throws Exception {
|
||||
if (!StringUtils.hasText(gatewayUrl)) {
|
||||
throw new IllegalArgumentException("gatewayUrl 不能为空");
|
||||
}
|
||||
long ts = System.currentTimeMillis() / 1000L;
|
||||
Map<String, String> params = new HashMap<>();
|
||||
params.put("type", type);
|
||||
params.put("client_id", clientId);
|
||||
params.put("timestamp", String.valueOf(ts));
|
||||
params.put("data_type", "JSON");
|
||||
params.put("version", "V1");
|
||||
if (StringUtils.hasText(accessToken)) {
|
||||
params.put("access_token", accessToken);
|
||||
}
|
||||
if (StringUtils.hasText(paramJson)) {
|
||||
params.put("param_json", paramJson);
|
||||
}
|
||||
String sign = PddPopSignUtil.sign(params, clientSecret);
|
||||
params.put("sign", sign);
|
||||
|
||||
String body = formEncode(params);
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(gatewayUrl))
|
||||
.timeout(Duration.ofSeconds(60))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
|
||||
.build();
|
||||
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
return resp.body();
|
||||
}
|
||||
|
||||
private static String formEncode(Map<String, String> params) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (sb.length() > 0) {
|
||||
sb.append('&');
|
||||
}
|
||||
sb.append(encode(e.getKey()))
|
||||
.append('=')
|
||||
.append(encode(e.getValue()));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String encode(String s) {
|
||||
return URLEncoder.encode(s == null ? "" : s, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* 拼多多 POP 签名:md5(client_secret + sorted_key_value_concat + client_secret) 大写。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
public final class PddPopSignUtil {
|
||||
|
||||
private PddPopSignUtil() {
|
||||
}
|
||||
|
||||
public static String sign(Map<String, String> params, String clientSecret) {
|
||||
TreeMap<String, String> sorted = new TreeMap<>();
|
||||
for (Map.Entry<String, String> e : params.entrySet()) {
|
||||
if (e.getKey() == null || "sign".equalsIgnoreCase(e.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (e.getValue() == null || e.getValue().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
sorted.put(e.getKey(), e.getValue());
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(clientSecret);
|
||||
for (Map.Entry<String, String> e : sorted.entrySet()) {
|
||||
sb.append(e.getKey()).append(e.getValue());
|
||||
}
|
||||
sb.append(clientSecret);
|
||||
return md5Upper(sb.toString());
|
||||
}
|
||||
|
||||
private static String md5Upper(String raw) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] dig = md.digest(raw.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : dig) {
|
||||
String h = Integer.toHexString(b & 0xff);
|
||||
if (h.length() == 1) {
|
||||
hex.append('0');
|
||||
}
|
||||
hex.append(h);
|
||||
}
|
||||
return hex.toString().toUpperCase();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("MD5 sign failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java
vendored
Normal file
28
service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package cn.qihangerp.service.external.pdd;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* POP 敏感字段日志脱敏(禁止在 info/warn/error 中输出完整 appSecret、accessToken)。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
public final class PddSensitiveLogUtil {
|
||||
|
||||
private PddSensitiveLogUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志用:保留首尾少量字符,中间掩码;过短则全部掩码。
|
||||
*/
|
||||
public static String maskForLog(String s) {
|
||||
if (!StringUtils.hasText(s)) {
|
||||
return "(empty)";
|
||||
}
|
||||
String t = s.trim();
|
||||
if (t.length() <= 8) {
|
||||
return "****";
|
||||
}
|
||||
return t.substring(0, 2) + "****" + t.substring(t.length() - 2);
|
||||
}
|
||||
}
|
||||
26
service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java
vendored
Normal file
26
service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package cn.qihangerp.service.external.shop;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 调用拼多多 POP 所需凭证(当前实现来自 upsert 请求体 {@code pddPopAuth})。
|
||||
*
|
||||
* @author guochengyu
|
||||
*/
|
||||
@Data
|
||||
public class PddShopCredential {
|
||||
|
||||
private String appKey;
|
||||
private String appSecret;
|
||||
private String accessToken;
|
||||
/** 非空时覆盖 external.pdd.gateway-url */
|
||||
private String gatewayUrl;
|
||||
|
||||
/** REQUEST:来自 upsert 请求体 {@code pddPopAuth} */
|
||||
private String source;
|
||||
|
||||
public boolean isComplete() {
|
||||
return StringUtils.hasText(appKey) && StringUtils.hasText(appSecret) && StringUtils.hasText(accessToken);
|
||||
}
|
||||
}
|
||||
|
|
@ -8,8 +8,7 @@
|
|||
<span class="title">工作助手</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-select v-model="selectedModel" size="mini" style="width: 120px; margin-right: 10px;">
|
||||
<el-option label="DeepSeek" value="deepseek"></el-option>
|
||||
<el-select v-model="selectedModel" size="mini" style="width: 120px; margin-right: 10px;" placeholder="选择模型">
|
||||
<el-option v-for="model in models" :key="model.value" :label="model.label" :value="model.value"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="selectedRole" size="mini" style="width: 120px; margin-right: 10px;">
|
||||
|
|
@ -183,7 +182,7 @@ export default {
|
|||
clientId: '',
|
||||
isSseConnected: false,
|
||||
isLoading: false,
|
||||
selectedModel: 'deepseek',
|
||||
selectedModel: '',
|
||||
selectedRole: '',
|
||||
models: [],
|
||||
sessionId: ''
|
||||
|
|
|
|||
Loading…
Reference in New Issue