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:
huangyujie 2026-03-23 19:22:35 +08:00
parent 18003448de
commit 565bc976fc
46 changed files with 2801 additions and 1211 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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日志

View File

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

View File

@ -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 entryAK/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 或 retailPriceoutSkuId=" + 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;
}
}

View File

@ -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 传入拼多多凭证");
}
}

View File

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

View File

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

View File

@ -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.mdNacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md

View File

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

View File

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

View File

@ -0,0 +1,45 @@
# =============================================================================
# Nacos 配置模板
# - 命名空间hbxhzt-test与 bootstrap 默认 NACOS_NAMESPACE 一致)
# - Data IDerp-api.yaml与 spring.application.name + file-extension 一致)
# - GroupDEFAULT_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: []

View File

@ -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")

View File

@ -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
// 微服务退出

View 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));
}
}
}

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

View 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) {
}
}

View File

@ -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 服务调用失败时需更完善的降级策略

View File

@ -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**(尽量避免 queryGET 若必须使用 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-256hex
拼接方式:
```
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 + 回调/轮询)。首期建议同步,但需要设定超时时间与重试策略。

View File

@ -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 网关路由规则(核心)

View File

@ -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)]

View File

@ -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,

View File

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

View File

@ -28,6 +28,8 @@ public class GoodsAddBo
/** 商品编号 */
private String number;
/** 店铺ID可选库表 o_goods.shop_id 非空时由调用方传入,如对外上架) */
private Long shopId;
/** 外部商品id */
private String outerErpGoodsId;
/**发货地*/

View File

@ -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;
/**
* 条码
*/

View File

@ -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;
/**
* 备注
*/

View File

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

View File

@ -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;
/**
* 承诺发货时效小时 2448
*/
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;
}
}

View File

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

View File

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

View File

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

View 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);
}

View 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 中落 SKUSKU 由本类后续循环写入且带 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);
}
}

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

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

View 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();
}
}

View 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_idspec_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;
}
}

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

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
package cn.qihangerp.service.external.pdd;
import org.springframework.util.StringUtils;
/**
* POP 敏感字段日志脱敏禁止在 info/warn/error 中输出完整 appSecretaccessToken
*
* @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);
}
}

View 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);
}
}

View File

@ -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: ''