From 565bc976fcc1a4a86ef35e0d15b7bc7e1d407002 Mon Sep 17 00:00:00 2001 From: huangyujie <27665451@qq.com> Date: Mon, 23 Mar 2026 19:22:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(external):=20=E5=AF=B9=E5=A4=96=E5=95=86?= =?UTF-8?q?=E5=93=81=20upsert/delist=E3=80=81AK/SK=20=E9=89=B4=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=E6=8B=BC=E5=A4=9A=E5=A4=9A=E5=8F=91=E5=B8=83=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 ExternalGoodsController、ExternalGoodsAppService 及 DTO/VO - AK/SK 过滤器与 Security 配置;GoodsAddBo.shopId、insertGoods 写 shop_id - 移除 DeepSeekService;erp-api/bootstrap、nacos 示例与文档更新 - 作者: guochengyu Made-with: Cursor --- api/ai-agent/pom.xml | 6 - .../qihangerp/erp/serviceImpl/AiService.java | 72 +--- .../erp/serviceImpl/DeepSeekService.java | 365 ------------------ .../serviceImpl/InventorySalesAnalyzer.java | 204 +++------- .../src/main/resources/application.yml | 8 +- api/erp-api/pom.xml | 10 +- .../controller/ExternalGoodsController.java | 124 ++++++ .../controller/ExternalShopController.java | 25 ++ .../erp/serviceImpl/DeepSeekService.java | 365 ------------------ .../serviceImpl/InventorySalesAnalyzer.java | 204 +++------- .../src/main/resources/application.yml | 100 +---- api/erp-api/src/main/resources/bootstrap.yml | 46 +++ .../src/main/resources/nacos/README.md | 41 ++ .../src/main/resources/nacos/erp-api.yaml | 45 +++ .../JwtAuthenticationTokenFilter.java | 1 + .../cn/qihangerp/security/SecurityConfig.java | 13 + .../ExternalAkSkAuthenticationFilter.java | 202 ++++++++++ .../external/ExternalAkSkProperties.java | 46 +++ .../external/ExternalRequestCaches.java | 85 ++++ docs/ai-agent-analysis.md | 26 +- docs/yunxi-pdd-goods-listing-gateway.md | 273 +++++++++++++ docs/新人入门指南.md | 2 +- docs/项目深度介绍.md | 4 +- .../main/resources/mapper/OGoodsMapper.xml | 8 +- .../main/resources/mapper/OGoodsSkuMapper.xml | 10 +- .../cn/qihangerp/model/bo/GoodsAddBo.java | 2 + .../cn/qihangerp/model/entity/OGoods.java | 20 + .../cn/qihangerp/model/entity/OGoodsSku.java | 30 ++ .../request/ExternalGoodsDelistRequest.java | 18 + .../request/ExternalGoodsUpsertRequest.java | 198 ++++++++++ .../model/vo/ExternalGoodsUpsertResultVo.java | 47 +++ .../model/vo/PddPublishLaneResultVo.java | 34 ++ .../service/impl/OGoodsServiceImpl.java | 3 + .../external/ExternalGoodsAppService.java | 18 + .../impl/ExternalGoodsAppServiceImpl.java | 344 +++++++++++++++++ .../pdd/ExternalPddConfiguration.java | 12 + .../external/pdd/ExternalPddProperties.java | 82 ++++ .../pdd/ExternalPddPublishService.java | 187 +++++++++ .../pdd/PddCatRuleSpecAutoResolver.java | 150 +++++++ .../external/pdd/PddGoodsAddParamBuilder.java | 240 ++++++++++++ .../external/pdd/PddOpenApiSupport.java | 148 +++++++ .../service/external/pdd/PddPopClient.java | 80 ++++ .../service/external/pdd/PddPopSignUtil.java | 55 +++ .../external/pdd/PddSensitiveLogUtil.java | 28 ++ .../external/shop/PddShopCredential.java | 26 ++ vue/src/views/index.vue | 5 +- 46 files changed, 2801 insertions(+), 1211 deletions(-) delete mode 100644 api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java create mode 100644 api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java create mode 100644 api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalShopController.java delete mode 100644 api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java create mode 100644 api/erp-api/src/main/resources/bootstrap.yml create mode 100644 api/erp-api/src/main/resources/nacos/README.md create mode 100644 api/erp-api/src/main/resources/nacos/erp-api.yaml create mode 100644 core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java create mode 100644 core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java create mode 100644 core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java create mode 100644 docs/yunxi-pdd-goods-listing-gateway.md create mode 100644 model/src/main/java/cn/qihangerp/model/request/ExternalGoodsDelistRequest.java create mode 100644 model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java create mode 100644 model/src/main/java/cn/qihangerp/model/vo/ExternalGoodsUpsertResultVo.java create mode 100644 model/src/main/java/cn/qihangerp/model/vo/PddPublishLaneResultVo.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddPopSignUtil.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java create mode 100644 service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java diff --git a/api/ai-agent/pom.xml b/api/ai-agent/pom.xml index c9a3d65c..794e7b0d 100644 --- a/api/ai-agent/pom.xml +++ b/api/ai-agent/pom.xml @@ -155,12 +155,6 @@ langchain4j-ollama 1.11.0 - - - dev.langchain4j - langchain4j-open-ai - 1.11.0 - diff --git a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/AiService.java b/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/AiService.java index b18a9fea..b62d2000 100644 --- a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/AiService.java +++ b/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/AiService.java @@ -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 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); diff --git a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java b/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java deleted file mode 100644 index 4dddf36f..00000000 --- a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java +++ /dev/null @@ -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 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 analyzeData(Map 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 result = parseResponse(responseBody, analysisType); -// -// // 缓存成功的结果 -// cacheSuccessfulResult(cacheKey, result); -// return result; -// } -// -// } catch (Exception e) { -// log.error("调用DeepSeek API失败,尝试使用缓存或降级方案", e); -// return getFallbackAnalysis(cacheKey, analysisType); -// } -// } -// -// /** -// * 为补货建议优化的专用方法 -// */ -// public Map generateReplenishmentSuggestions(Map inventoryData) { -// try { -// String prompt = buildReplenishmentPrompt(inventoryData); -// -// Map 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 generateLocalReplenishmentSuggestions(Map inventoryData) { -// List> products = (List>) inventoryData.get("data"); -// List> suggestions = new ArrayList<>(); -// int totalQuantity = 0; -// double estimatedCost = 0.0; -// -// for (Map 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 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 getFallbackAnalysis(String cacheKey, String analysisType) { -// // 1. 首先尝试缓存 -// if (cachedAnalysis.containsKey(cacheKey)) { -// log.info("使用缓存的AI分析结果"); -// Map cached = (Map) 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); -// } -//} \ No newline at end of file diff --git a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java b/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java index ef4c5154..95aacd80 100644 --- a/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java +++ b/api/ai-agent/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java @@ -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 inventoryList = parseInventoryData(); List 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 inventory, - List sales) throws IOException { + List sales) { - // 1. 数据预处理:按 SKU ID 关联库存和销售数据 Map 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 analysisMap) { - StringBuilder prompt = new StringBuilder(); + private static String buildLocalAnalysisReport(Map analysisMap) { + StringBuilder sb = new StringBuilder(); + sb.append("## 库存健康度与销售概览(规则生成,非云端大模型)\n\n"); + sb.append("产品:雷士照明 LED 吸顶灯灯芯 | 分析时间:").append(new Date()).append("\n\n"); - prompt.append("你是一名专业的电商库存管理专家。请分析以下 LED 灯具产品的库存与销售数据,并提供专业的分析报告和建议:\n\n"); + List 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 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 responseMap = objectMapper.readValue(responseBody, - new TypeReference>() {}); - - List> choices = (List>) responseMap.get("choices"); - if (choices != null && !choices.isEmpty()) { - Map choice = choices.get(0); - Map message = (Map) 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 parseInventoryData() throws IOException { return objectMapper.readValue(INVENTORY_JSON, - new TypeReference>() {}); + new TypeReference>() { + }); } private static List parseSalesData() throws IOException { return objectMapper.readValue(SALES_JSON, - new TypeReference>() {}); + new TypeReference>() { + }); } - // 数据类定义 static class InventoryItem { public int id; @JsonProperty("goods_title") @@ -270,4 +180,4 @@ public class InventorySalesAnalyzer { public String stockStatus = "未知"; public List salesTimes = new ArrayList<>(); } -} \ No newline at end of file +} diff --git a/api/ai-agent/src/main/resources/application.yml b/api/ai-agent/src/main/resources/application.yml index 854dd073..14d1dd88 100644 --- a/api/ai-agent/src/main/resources/application.yml +++ b/api/ai-agent/src/main/resources/application.yml @@ -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 \ No newline at end of file +# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开启sql日志 \ No newline at end of file diff --git a/api/erp-api/pom.xml b/api/erp-api/pom.xml index 2feec11c..6ab37a6d 100644 --- a/api/erp-api/pom.xml +++ b/api/erp-api/pom.xml @@ -67,11 +67,11 @@ - - - - - + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + cn.qihangerp.core diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java new file mode 100644 index 00000000..4cae8b61 --- /dev/null +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalGoodsController.java @@ -0,0 +1,124 @@ +package cn.qihangerp.erp.controller; + +import cn.qihangerp.common.AjaxResult; +import cn.qihangerp.common.enums.EnumShopType; +import cn.qihangerp.model.request.ExternalGoodsDelistRequest; +import cn.qihangerp.model.request.ExternalGoodsUpsertRequest; +import cn.qihangerp.model.vo.ExternalGoodsUpsertResultVo; +import cn.qihangerp.security.common.BaseController; +import cn.qihangerp.service.external.ExternalGoodsAppService; +import cn.qihangerp.service.external.pdd.ExternalPddProperties; +import lombok.AllArgsConstructor; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * External goods entry(AK/SK 由安全过滤器校验)。 + *

不查询 {@code o_shop}:{@code shopId} 仅作业务侧店铺维度标识写入 {@code o_goods}/{@code o_goods_sku}。

+ *
    + *
  • {@code POST /external/goods/upsert} — 商品上架/同步(新建默认 {@code o_goods.status=1})
  • + *
  • {@code POST /external/goods/delist} — 本地下架({@code status=2}),不调各平台下架 API
  • + *
+ * + * @author guochengyu + */ +@AllArgsConstructor +@RestController +@RequestMapping("/external/goods") +public class ExternalGoodsController extends BaseController { + private final ExternalGoodsAppService externalGoodsAppService; + private final ExternalPddProperties externalPddProperties; + + @PostMapping("/upsert") + public AjaxResult upsert(@RequestBody ExternalGoodsUpsertRequest req) { + if (req == null || req.getShopId() == null || req.getShopId() <= 0) { + return AjaxResult.error("参数错误:shopId不能为空"); + } + if (!StringUtils.hasText(req.getPlatform())) { + return AjaxResult.error("参数错误:platform不能为空"); + } + if (!StringUtils.hasText(req.getOutGoodsId())) { + return AjaxResult.error("参数错误:outGoodsId不能为空"); + } + if (!StringUtils.hasText(req.getTitle())) { + return AjaxResult.error("参数错误:title不能为空"); + } + if (!StringUtils.hasText(req.getCategoryCode())) { + return AjaxResult.error("参数错误:categoryCode不能为空"); + } + if (!hasMainVisual(req)) { + return AjaxResult.error("参数错误:mainImage 与 images 至少填一种"); + } + if (req.getSkus() != null) { + for (var s : req.getSkus()) { + if (s == null || !StringUtils.hasText(s.getOutSkuId())) { + continue; + } + if (s.getSalePrice() == null && s.getRetailPrice() == null) { + return AjaxResult.error("参数错误:sku 需填写 salePrice 或 retailPrice,outSkuId=" + s.getOutSkuId()); + } + } + } + + EnumShopType platform; + try { + platform = EnumShopType.valueOf(req.getPlatform().trim()); + } catch (Exception ex) { + return AjaxResult.error("platform不合法"); + } + + if (EnumShopType.PDD.equals(platform) && externalPddProperties.isPublishEnabled()) { + if (req.getPddPopAuth() == null) { + return AjaxResult.error("参数错误:开启拼多多发布时 pddPopAuth 不能为空"); + } + var auth = req.getPddPopAuth(); + if (!StringUtils.hasText(auth.getAppKey()) || !StringUtils.hasText(auth.getAppSecret()) + || !StringUtils.hasText(auth.getAccessToken())) { + return AjaxResult.error("参数错误:pddPopAuth 需提供 appKey、appSecret、accessToken"); + } + } + + ExternalGoodsUpsertResultVo vo = externalGoodsAppService.upsertGoods(req); + return AjaxResult.success(vo); + } + + /** + * 本地下架:将 {@code o_goods.status} 置为 2(已下架)。不调用拼多多等平台的下架接口。 + */ + @PostMapping("/delist") + public AjaxResult delist(@RequestBody ExternalGoodsDelistRequest req) { + if (req == null || req.getShopId() == null || req.getShopId() <= 0) { + return AjaxResult.error("参数错误:shopId不能为空"); + } + if (!StringUtils.hasText(req.getOutGoodsId())) { + return AjaxResult.error("参数错误:outGoodsId不能为空"); + } + try { + externalGoodsAppService.delistGoods(req); + return AjaxResult.success(); + } catch (IllegalArgumentException ex) { + return AjaxResult.error(ex.getMessage()); + } + } + + private static boolean hasMainVisual(ExternalGoodsUpsertRequest req) { + if (StringUtils.hasText(req.getMainImage())) { + return true; + } + List imgs = req.getImages(); + if (imgs == null || imgs.isEmpty()) { + return false; + } + for (String u : imgs) { + if (StringUtils.hasText(u)) { + return true; + } + } + return false; + } +} diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalShopController.java b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalShopController.java new file mode 100644 index 00000000..b3040d10 --- /dev/null +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/controller/ExternalShopController.java @@ -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} 的店铺授权已弃用。 + *

o_shop 表对外部集成已弃用:拼多多凭证请在 {@code POST /external/goods/upsert} 的 {@code pddPopAuth} 中传入。

+ * + * @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 传入拼多多凭证"); + } +} diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java b/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java deleted file mode 100644 index 4dddf36f..00000000 --- a/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/DeepSeekService.java +++ /dev/null @@ -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 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 analyzeData(Map 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 result = parseResponse(responseBody, analysisType); -// -// // 缓存成功的结果 -// cacheSuccessfulResult(cacheKey, result); -// return result; -// } -// -// } catch (Exception e) { -// log.error("调用DeepSeek API失败,尝试使用缓存或降级方案", e); -// return getFallbackAnalysis(cacheKey, analysisType); -// } -// } -// -// /** -// * 为补货建议优化的专用方法 -// */ -// public Map generateReplenishmentSuggestions(Map inventoryData) { -// try { -// String prompt = buildReplenishmentPrompt(inventoryData); -// -// Map 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 generateLocalReplenishmentSuggestions(Map inventoryData) { -// List> products = (List>) inventoryData.get("data"); -// List> suggestions = new ArrayList<>(); -// int totalQuantity = 0; -// double estimatedCost = 0.0; -// -// for (Map 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 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 getFallbackAnalysis(String cacheKey, String analysisType) { -// // 1. 首先尝试缓存 -// if (cachedAnalysis.containsKey(cacheKey)) { -// log.info("使用缓存的AI分析结果"); -// Map cached = (Map) 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); -// } -//} \ No newline at end of file diff --git a/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java b/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java index ef4c5154..95aacd80 100644 --- a/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java +++ b/api/erp-api/src/main/java/cn/qihangerp/erp/serviceImpl/InventorySalesAnalyzer.java @@ -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 inventoryList = parseInventoryData(); List 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 inventory, - List sales) throws IOException { + List sales) { - // 1. 数据预处理:按 SKU ID 关联库存和销售数据 Map 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 analysisMap) { - StringBuilder prompt = new StringBuilder(); + private static String buildLocalAnalysisReport(Map analysisMap) { + StringBuilder sb = new StringBuilder(); + sb.append("## 库存健康度与销售概览(规则生成,非云端大模型)\n\n"); + sb.append("产品:雷士照明 LED 吸顶灯灯芯 | 分析时间:").append(new Date()).append("\n\n"); - prompt.append("你是一名专业的电商库存管理专家。请分析以下 LED 灯具产品的库存与销售数据,并提供专业的分析报告和建议:\n\n"); + List 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 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 responseMap = objectMapper.readValue(responseBody, - new TypeReference>() {}); - - List> choices = (List>) responseMap.get("choices"); - if (choices != null && !choices.isEmpty()) { - Map choice = choices.get(0); - Map message = (Map) 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 parseInventoryData() throws IOException { return objectMapper.readValue(INVENTORY_JSON, - new TypeReference>() {}); + new TypeReference>() { + }); } private static List parseSalesData() throws IOException { return objectMapper.readValue(SALES_JSON, - new TypeReference>() {}); + new TypeReference>() { + }); } - // 数据类定义 static class InventoryItem { public int id; @JsonProperty("goods_title") @@ -270,4 +180,4 @@ public class InventorySalesAnalyzer { public String stockStatus = "未知"; public List salesTimes = new ArrayList<>(); } -} \ No newline at end of file +} diff --git a/api/erp-api/src/main/resources/application.yml b/api/erp-api/src/main/resources/application.yml index 23ad8053..cd43e2c8 100644 --- a/api/erp-api/src/main/resources/application.yml +++ b/api/erp-api/src/main/resources/application.yml @@ -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 \ No newline at end of file +external: + auth: + api-key: external-uat-ak-001 + secret-key: external-uat-sk-001-9f2d3c4b5a6e7d8c + timestamp-skew-ms: 300000 + +# 说明:对外商品接口见 temp/yunxi-erp-open-goods-upsert-api.md;Nacos 对齐见 temp/erp-open-erp-api-nacos-yunxi-reference.md diff --git a/api/erp-api/src/main/resources/bootstrap.yml b/api/erp-api/src/main/resources/bootstrap.yml new file mode 100644 index 00000000..cdb26946 --- /dev/null +++ b/api/erp-api/src/main/resources/bootstrap.yml @@ -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 diff --git a/api/erp-api/src/main/resources/nacos/README.md b/api/erp-api/src/main/resources/nacos/README.md new file mode 100644 index 00000000..8c851891 --- /dev/null +++ b/api/erp-api/src/main/resources/nacos/README.md @@ -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)。 diff --git a/api/erp-api/src/main/resources/nacos/erp-api.yaml b/api/erp-api/src/main/resources/nacos/erp-api.yaml new file mode 100644 index 00000000..0472b329 --- /dev/null +++ b/api/erp-api/src/main/resources/nacos/erp-api.yaml @@ -0,0 +1,45 @@ +# ============================================================================= +# Nacos 配置模板 +# - 命名空间:hbxhzt-test(与 bootstrap 默认 NACOS_NAMESPACE 一致) +# - Data ID:erp-api.yaml(与 spring.application.name + file-extension 一致) +# - Group:DEFAULT_GROUP +# - 共享:bootstrap 已拉取 yundt-cube-common.yaml(与 Yunxi 其他工程一致) +# 维护:guochengyu +# ============================================================================= + +server: + port: 38083 + +spring: + data: + redis: + host: 192.168.71.6 + port: 6379 + database: 0 + password: HbxinhuaDB@2025 + timeout: 10s + + datasource: + driverClassName: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://192.168.71.10:3306/hbxh_erp_ecommerce_middleware?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8 + username: root + password: HbxinhuaDB@2025 + +mybatis-plus: + mapper-locations: classpath*:mapper/**/*Mapper.xml + type-aliases-package: cn.qihangerp.oms.domain;cn.qihangerp.module.domain;cn.qihangerp.security.entity; + +# /external/** AK/SK(须与 maindata ErpOpenProperties 调用 ERP-Open 的密钥一致) +external: + auth: + api-key: external-uat-ak-001 + secret-key: external-uat-sk-001-9f2d3c4b5a6e7d8c + timestamp-skew-ms: 300000 + + # 拼多多:默认关闭发布;开启时请取消注释并补全 category-map / cost-template-map / sku-overrides + #pdd: + # publish-enabled: false + # gateway-url: https://gw-api.pinduoduo.com/api/router + # category-map: {} + # cost-template-map: {} + # sku-overrides: [] diff --git a/core/security/src/main/java/cn/qihangerp/security/JwtAuthenticationTokenFilter.java b/core/security/src/main/java/cn/qihangerp/security/JwtAuthenticationTokenFilter.java index a8b75e75..2841ba53 100644 --- a/core/security/src/main/java/cn/qihangerp/security/JwtAuthenticationTokenFilter.java +++ b/core/security/src/main/java/cn/qihangerp/security/JwtAuthenticationTokenFilter.java @@ -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") diff --git a/core/security/src/main/java/cn/qihangerp/security/SecurityConfig.java b/core/security/src/main/java/cn/qihangerp/security/SecurityConfig.java index b43f4553..af0a3ecf 100644 --- a/core/security/src/main/java/cn/qihangerp/security/SecurityConfig.java +++ b/core/security/src/main/java/cn/qihangerp/security/SecurityConfig.java @@ -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 // 微服务退出 diff --git a/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java b/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java new file mode 100644 index 00000000..44fe9459 --- /dev/null +++ b/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkAuthenticationFilter.java @@ -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)); + } + } +} + diff --git a/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java b/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java new file mode 100644 index 00000000..20d1c824 --- /dev/null +++ b/core/security/src/main/java/cn/qihangerp/security/external/ExternalAkSkProperties.java @@ -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; + } +} + diff --git a/core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java b/core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java new file mode 100644 index 00000000..4b592867 --- /dev/null +++ b/core/security/src/main/java/cn/qihangerp/security/external/ExternalRequestCaches.java @@ -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. + * + *

首期按“单体优先 + 同步接口”落地,这里使用内存缓存,不引入额外依赖、不改库。 + * 后续如需多实例/重启不丢,可替换为 Redis/DB。

+ */ +public class ExternalRequestCaches { + private final Clock clock; + private final Map nonceExpireAtMs = new ConcurrentHashMap<>(); + private final Map 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) { + } +} + diff --git a/docs/ai-agent-analysis.md b/docs/ai-agent-analysis.md index 1e1ef927..3541a8e9 100644 --- a/docs/ai-agent-analysis.md +++ b/docs/ai-agent-analysis.md @@ -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 服务调用失败时需更完善的降级策略 diff --git a/docs/yunxi-pdd-goods-listing-gateway.md b/docs/yunxi-pdd-goods-listing-gateway.md new file mode 100644 index 00000000..9457061d --- /dev/null +++ b/docs/yunxi-pdd-goods-listing-gateway.md @@ -0,0 +1,273 @@ +# External 系统 → 启航ERP(拼多多)商品上架网关:裁剪与改造方案 + +> 目标:在 `qihang-ecom-erp-open` 基础上,**仅保留**“商品上架/商品同步(仅商品域)/接口鉴权”能力,作为单体服务提供给外部系统调用。 +> 首期平台:**拼多多**。 +> 调用方:外部系统(传入商品数据 + `shopId` + `platform`)。 +> 店铺授权:由本服务维护(外部系统不传 appKey/appSecret/accessToken)。 +> 鉴权:**AK/SK 签名鉴权**(system-to-system)。 + +## 1. 范围与非目标 + +### 1.1 In Scope(首期必须) +- **商品数据接入**:外部系统推送商品(SPU + SKU)到本服务(创建/更新)。 +- **拼多多上架全链路**: + - **A** 创建商品(首次发布) + - **B** 更新商品信息(标题/主图/详情/属性等) + - **C** 更新 SKU(规格、价格等) + - **D** 更新库存 + - **E** 上架/下架 + - **F** 接收“已准备好的商品数据”(图片/详情为可访问 URL 或可上传的素材)并发起平台发布 +- **最小商品同步**: + - 从拼多多拉取商品(用于对账/状态回写/增量同步),保留 `pull_goods` 能力即可 + - 同步结果落库,供外部系统查询/校验 +- **接口鉴权**: + - 调用方 → 本服务 API 必须签名验证(AK/SK) + - 失败拒绝(401/403),并记录审计日志 + +### 1.2 Out of Scope(首期明确不做/可后置) +- 订单/售后/发货/电子面单/仓库/采购等业务域 +- 多平台(淘宝/京东/抖店/快手/小红书/微信小店等) +- 前端管理后台(Vue)与多租户/多商户能力 +- AI-agent 相关能力 + +## 2. 现状评估(基于当前开源代码) + +### 2.1 已具备的基础能力 +- **商品库接入入口**:`erp-api` 下存在 `POST /goods/add`,并且模型存在 `outerErpGoodsId` 等外部标识字段,适合承接调用方商品数据。 +- **拼多多商品拉取(同步)**:`oms-api` 下存在 `POST /pdd/goods/pull_goods`,当前实现是“拉取列表并落库”。 +- **安全模块线索**:`core/security` 已存在并在 `erp-api`/`oms-api` 引用(但对调用方的 S2S 鉴权需要单独设计)。 + +### 2.2 关键缺口(必须改造/补齐) +- 当前拼多多侧 `open-sdk-2.1.12.jar` 中公开的 `PddGoodsApiHelper` 仅暴露 `pullGoodsList`(拉取),未暴露“创建/更新/上下架/库存更新”等发布能力。 +- 因此,**商品上架全链路需要新增拼多多发布适配层**(可能复用已有签名/HTTP 基础设施,但要补齐 API)。 + +## 3. 目标架构(单体部署) + +### 3.1 进程形态 +单体 Spring Boot 应用(Java 17),对外提供 REST API: +- `external-api`:外部系统调用入口(推送商品/触发上架/查询结果) +- `admin-api`(可选,首期可不暴露):用于运维/店铺授权维护/密钥配置 + +### 3.2 逻辑分层(建议) +- **API 层**:只做参数校验、鉴权、幂等键处理、调用 Service、统一响应 +- **Service 层**: + - 统一商品模型校验与映射 + - 上架编排(创建→更新→库存→上架状态) + - 与拼多多适配层交互 + - 任务化(可选):异步发布/重试/死信 +- **Adapter 层(PDD)**: + - HTTP + 签名 + 请求/响应模型 + - 平台错误码映射 + +## 4. 对外接口设计(External/外部系统 → 本服务) + +> 说明:下面是建议接口。落地时以外部系统现有调用规范为准,可在后续命令下达后再对齐路径/DTO 命名。 + +### 4.0 已确认口径(冻结) +- `shopId`:**全平台唯一**,可直接作为本服务店铺主键,不考虑跨平台撞号。 +- `platform`:由外部系统显式传入,取值采用 **`PDD`/`TAO`/`JD`/`DOU`/`WEI`/`KWAI`/`XHS`**(后续平台新增再扩展枚举)。 +- AK/SK:**一套即可**(外部系统作为单一调用方)。 +- `X-Timestamp`:**毫秒**(ms)。 +- `X-Signature`:**Base64** 编码(HMAC-SHA256 输出 bytes → Base64)。 +- 签名 body:使用**原始请求体 raw bytes** 计算 SHA-256(不做 JSON 重排/字段排序)。 +- 参数形态:**统一走 body**(尽量避免 query;GET 若必须使用 query,则改为 POST 查询接口更一致)。 +- 幂等:**完全由本服务内部实现**,不依赖调用方提供 `X-Request-Id`。 +- 商品素材:首期按**公网可访问 URL**(不做素材代上传)。 +- 接口形态:首期按**同步接口**(直接返回发布结果或明确错误)。 + +### 4.1 统一返回 +- `code`: 0 成功;非 0 失败 +- `msg`: 错误信息 +- `data`: 业务数据 + +### 4.2 鉴权(AK/SK 签名) +#### 请求头 +- `X-Api-Key`: 分配给调用方的 AK +- `X-Timestamp`: 毫秒时间戳(例如 `1710000000000`) +- `X-Nonce`: 随机串(建议 16~32) +- `X-Signature`: 签名串(Base64) +- `Content-Type: application/json` + +#### 签名串构造(推荐) +canonical string: +1. `HTTP_METHOD`(大写) +2. `PATH`(不含域名,不含 query) +3. `timestamp` +4. `nonce` +5. `bodySha256Hex`(请求体 raw bytes 的 SHA-256,hex) + +拼接方式: +``` +METHOD + "\n" + +PATH + "\n" + +timestamp + "\n" + +nonce + "\n" + +bodySha256Hex +``` + +签名算法: +- `signature = HMAC-SHA256(secret, canonicalString)` +- 输出:对 `signature` 的 bytes 做 `Base64`。 + +#### 校验规则 +- `timestamp` 与服务端时间偏差 ≤ 300 秒(按 ms 计算) +- `nonce` 在时间窗内不可重复(需要缓存,例如 Redis/本地 Caffeine,首期可先用 Redis) +- `X-Api-Key` 必须存在且有效(数据库/配置中) +- `X-Signature` 校验失败直接拒绝 + +#### 错误码建议 +- 401:缺少必要头/签名参数 +- 403:签名错误、AK 无效、nonce 重放 +- 429:频控(可选) + +### 4.3 商品接入与上架接口(建议) +#### 4.3.1 推送/更新商品(SPU + SKU) +`POST /external/goods/upsert` + +入参: +- `shopId`(必填) +- `platform`(必填,枚举,例如 `PDD`/`TAO`/`JD`…;首期仅允许 `PDD`) +- `outGoodsId`:调用方侧商品ID(用于幂等与映射) +- `title`、`mainImages[]`、`detail`(富文本/图片 URL 列表等) +- `skus[]`:`outSkuId`、`spec`、`price`、`stock`、`barCode`… +- `category`、`attrs`、`brand`、`weight`、`shipping`… + +行为: +- 本地保存/更新商品库(以 `shopId + outGoodsId` 幂等) +- 返回本地 `goodsId`(内部ID)与当前状态 + +#### 4.3.2 触发上架(编排) +`POST /external/goods/listing/submit` + +入参: +- `shopId` +- `platform`(必填,首期仅允许 `PDD`) +- `outGoodsId` +- `mode`: `CREATE_OR_UPDATE`(默认) + +行为(同步/异步二选一,建议首期同步 + 超时控制,后续演进异步): +- 若平台商品不存在:创建商品 → 创建/更新 SKU → 设置库存 → 上架 +- 若已存在:更新商品 → 更新 SKU → 库存 → 上架/保持 + +返回: +- `listingTaskId`(若异步) +- 或直接返回平台侧 `goodsId`/状态 + +#### 4.3.3 上下架 +`POST /external/goods/listing/status` + +入参: +- `shopId` +- `platform`(必填,首期仅允许 `PDD`) +- `outGoodsId` +- `status`: `ON`/`OFF` + +#### 4.3.4 库存更新(可被 submit 内部调用,也可单独暴露) +`POST /external/goods/stock/update` + +入参: +- `shopId` +- `platform`(必填,首期仅允许 `PDD`) +- `outGoodsId` +- `skus[]`: `outSkuId` + `stock` + +#### 4.3.5 查询发布状态/映射 +`GET /external/goods/listing/status?shopId=...&platform=...&outGoodsId=...` + +返回: +- 平台商品ID、上下架状态、最后一次错误、最后同步时间 + +## 5. 领域模型(最小集合) + +### 5.1 本地核心表(建议最小) +- `shop`:店铺基础信息(含平台类型=PDD) +- `shop_auth`:拼多多 appKey/appSecret/accessToken/refreshToken/过期时间 +- `goods`:统一商品(承接调用方) +- `goods_sku`:统一 SKU(承接调用方) +- `goods_platform_mapping`:统一商品与平台商品映射(平台 goodsId、skuId 映射) +- `listing_task`(可选):异步发布任务、重试次数、状态机 +- `api_client`:AK/SK、状态、权限范围、IP 白名单(可选) +- `api_request_log`:审计(签名校验结果、请求摘要、耗时) + +### 5.2 幂等策略(必须) +- `shopId + outGoodsId`:商品幂等键 +- `shopId + outSkuId`:SKU 幂等键 +- **不依赖调用方幂等键**:本服务内部需实现“请求去重/防重复上架”。 + - 推荐:落库 `idempotency_key = sha256Hex(platform + shopId + outGoodsId + operation + normalizedBodyHash)` + - 同一 `idempotency_key` 在有效期内重复请求:直接返回第一次执行结果 + - 对“提交上架”类接口:至少保证不会重复创建平台商品 + +## 6. 拼多多适配(PDD Adapter)设计要点 + +### 6.1 适配层职责 +- 拼多多 API 签名、请求发送、响应解析 +- 平台错误码归一化 +- “创建/更新/上下架/库存”接口封装为内部方法: + - `createGoods(...)` + - `updateGoods(...)` + - `updateSku(...)` + - `updateStock(...)` + - `setGoodsOnSale(...)` / `setGoodsOffSale(...)` + +### 6.2 依赖策略(重要) +当前 `oms-api` 通过 `systemPath` 引入 `open-sdk-2.1.12.jar`。 + +首期有两条路径(二选一): +- **路径 1(优先)**:在本仓库内新增 `pdd-adapter`(源码方式),不再依赖 `open-sdk-2.1.12.jar` 做发布能力(只保留 pull 同步可继续复用或也迁移)。 +- **路径 2(备选)**:升级/替换 `open-sdk`,确保其暴露发布相关 API(需要确认是否可获得源码/维护成本)。 + +> 推荐路径 1:可控、可审计、便于后续扩展多平台。 + +## 7. 裁剪方案(保留/删除建议) + +> 原项目是微服务结构(gateway/sys-api/erp-api/oms-api 等)。你的目标是单体优先,因此裁剪思路是: +> 1) **保留商品域 + 拼多多域 + 鉴权** +> 2) 其它域模块不编译/不启动/不暴露接口 + +### 7.1 建议保留(首期) +- `core/common`(通用工具、AjaxResult 等) +- `core/security`(复用登录/权限基础设施的同时,新增 S2S AK/SK 鉴权) +- `model`(实体/BO/VO) +- `mapper`(DB 访问) +- `service`、`serviceImpl` 中与商品/店铺/拼多多相关的最小集合 +- `api/erp-api`(商品库接入:`/goods/add` 等入口可重构为 `/external/goods/*`) +- `api/oms-api` 中 **拼多多商品拉取**(同步)相关接口(可改为内部任务或保留对外) + +### 7.2 建议禁用/后置 +- `api/ai-agent` +- `api/sys-api`(如仅对调用方开放,不需要后台用户体系,可后置) +- `api/gateway`(若单体直连,不需要网关;若保留网关需把 TokenFilter 改为 AK/SK 校验) +- `oms-api` 中非拼多多平台(dou/jd/tao/wei/kwai…) +- 订单/售后/发货/库存出入库/采购等 Controller 与 Service + +## 8. 安全与审计 +- 所有外部系统接口强制 AK/SK +- **平台一致性校验(强制)**: + - 调用方传入 `platform` + - 服务端根据 `shopId` 查询数据库中的 `shopType/platformType` + - 若两者不一致:直接拒绝(建议 400 或 403),记录审计日志 + - 目的:避免“串平台/串店铺”导致误上架 +- `shopId` 必须存在且平台类型为 PDD +- 店铺 accessToken 过期:返回明确错误码(例如 `SHOP_TOKEN_EXPIRED`),并支持后台刷新/重新授权流程(首期可人工) +- 全链路日志: + - `requestId`、`shopId`、`outGoodsId`、平台错误码、耗时 +- 敏感信息保护: + - appSecret/accessToken 加密存储(KMS/本地对称加密;首期可用配置密钥 + AES) + - 严禁打印明文 token/secret + +## 9. 验证与验收清单(首期) +- [ ] 外部系统推送商品 → 本地入库(幂等生效) +- [ ] 触发上架 → 拼多多创建/更新/库存/上下架成功(全链路) +- [ ] 上架失败可返回可读错误(含平台原始信息可追踪) +- [ ] `pull_goods` 同步可用(用于对账/状态回写) +- [ ] AK/SK: + - [ ] 缺参拒绝 + - [ ] 签名错误拒绝 + - [ ] 超时拒绝 + - [ ] nonce 重放拒绝 + - [ ] 审计日志完整 + +## 10. 待你确认/后续落地前置 +- 拼多多商品发布所需字段“最小集”(类目、属性、物流、售后承诺、资质等)需要你提供调用方侧现有字段映射或样例 payload。 +- 是否需要“异步上架”(返回 taskId + 回调/轮询)。首期建议同步,但需要设定超时时间与重试策略。 + diff --git a/docs/新人入门指南.md b/docs/新人入门指南.md index 6594dbd5..cbbe7e38 100644 --- a/docs/新人入门指南.md +++ b/docs/新人入门指南.md @@ -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 网关路由规则(核心) diff --git a/docs/项目深度介绍.md b/docs/项目深度介绍.md index c52deaad..5647cf03 100644 --- a/docs/项目深度介绍.md +++ b/docs/项目深度介绍.md @@ -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)] diff --git a/mapper/src/main/resources/mapper/OGoodsMapper.xml b/mapper/src/main/resources/mapper/OGoodsMapper.xml index db76b7ac..c1d87ae1 100644 --- a/mapper/src/main/resources/mapper/OGoodsMapper.xml +++ b/mapper/src/main/resources/mapper/OGoodsMapper.xml @@ -6,6 +6,7 @@ + @@ -15,6 +16,9 @@ + + + @@ -61,9 +65,9 @@ - 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, diff --git a/mapper/src/main/resources/mapper/OGoodsSkuMapper.xml b/mapper/src/main/resources/mapper/OGoodsSkuMapper.xml index d661844f..e8a1c7c4 100644 --- a/mapper/src/main/resources/mapper/OGoodsSkuMapper.xml +++ b/mapper/src/main/resources/mapper/OGoodsSkuMapper.xml @@ -6,6 +6,7 @@ + @@ -27,6 +28,11 @@ + + + + + @@ -37,14 +43,14 @@ - 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 diff --git a/model/src/main/java/cn/qihangerp/model/bo/GoodsAddBo.java b/model/src/main/java/cn/qihangerp/model/bo/GoodsAddBo.java index 9f9e43d8..8ca35275 100644 --- a/model/src/main/java/cn/qihangerp/model/bo/GoodsAddBo.java +++ b/model/src/main/java/cn/qihangerp/model/bo/GoodsAddBo.java @@ -28,6 +28,8 @@ public class GoodsAddBo /** 商品编号 */ private String number; + /** 店铺ID(可选;库表 o_goods.shop_id 非空时由调用方传入,如对外上架) */ + private Long shopId; /** 外部商品id */ private String outerErpGoodsId; /**发货地*/ diff --git a/model/src/main/java/cn/qihangerp/model/entity/OGoods.java b/model/src/main/java/cn/qihangerp/model/entity/OGoods.java index 3149b254..d1ac7c5a 100644 --- a/model/src/main/java/cn/qihangerp/model/entity/OGoods.java +++ b/model/src/main/java/cn/qihangerp/model/entity/OGoods.java @@ -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; + /** * 条码 */ diff --git a/model/src/main/java/cn/qihangerp/model/entity/OGoodsSku.java b/model/src/main/java/cn/qihangerp/model/entity/OGoodsSku.java index 9b26d906..44fd6e30 100644 --- a/model/src/main/java/cn/qihangerp/model/entity/OGoodsSku.java +++ b/model/src/main/java/cn/qihangerp/model/entity/OGoodsSku.java @@ -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; + /** * 备注 */ diff --git a/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsDelistRequest.java b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsDelistRequest.java new file mode 100644 index 00000000..385db40c --- /dev/null +++ b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsDelistRequest.java @@ -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; +} diff --git a/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java new file mode 100644 index 00000000..a578fb2d --- /dev/null +++ b/model/src/main/java/cn/qihangerp/model/request/ExternalGoodsUpsertRequest.java @@ -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,全平台同一套字段)。 + *

平台差异由本服务内部映射处理;{@link #shopId} 为店铺维度标识并写入商品表,不校验 {@code o_shop}。

+ * + * @author guochengyu + */ +@Data +public class ExternalGoodsUpsertRequest { + + /** + * 店铺维度标识(写入 {@code o_goods}/{@code o_goods_sku});不校验 {@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 images; + + /** + * 详情图 URL 列表(有序) + */ + private List detailImages; + + /** + * 主图/详情视频等(公网可访问 URL) + */ + private String videoUrl; + + /** + * 标准类目编码(非平台 catId,由本服务映射到各平台叶子类目) + */ + private String categoryCode; + + /** + * 标准品牌编码(非平台 brandId) + */ + private String brandCode; + + /** + * 商品属性(非销售规格) + */ + private List attributes; + + /** + * 销售属性维度(如颜色、尺码等,values 为该维度可选值集合) + */ + private List salesAttributes; + + /** + * 参考价/划线价(与平台展示口径可能不同,由适配层换算) + */ + private BigDecimal marketPrice; + + /** + * 承诺发货时效(小时),如 24、48 + */ + private Integer shipWithinHours; + + /** + * 是否支持 7 天无理由(语义统一,各平台映射) + */ + private Boolean isRefundable; + + /** + * 是否预售 + */ + private Boolean isPreSale; + + /** + * 是否二手 + */ + private Boolean isSecondHand; + + /** + * 运费模板编码(本服务内映射到平台 templateId) + */ + private String logisticsTemplateCode; + + /** 商品编号(商家侧) */ + private String goodsNum; + + private String province; + private String city; + private String town; + + private String barCode; + + private BigDecimal purPrice; + private BigDecimal wholePrice; + /** SPU 层建议零售价;可与 SKU salePrice 并存 */ + private BigDecimal retailPrice; + private BigDecimal unitCost; + + /** + * 商品外链(详情页/H5 等,非必填) + */ + private String linkUrl; + + /** + * 扩展 KV(禁止放平台私有字段名;仅放业务扩展) + */ + private Map ext; + + private List skus; + + /** + * 拼多多 POP 凭证(每次请求由调用方传入)。当 {@code platform=PDD} 且服务端 + * {@code external.pdd.publish-enabled=true} 时必填;不依赖 {@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 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 attributeValues; + + private BigDecimal purPrice; + private BigDecimal retailPrice; + private BigDecimal unitCost; + } + + @Data + public static class SkuAttributeValue { + private String code; + private String value; + } +} diff --git a/model/src/main/java/cn/qihangerp/model/vo/ExternalGoodsUpsertResultVo.java b/model/src/main/java/cn/qihangerp/model/vo/ExternalGoodsUpsertResultVo.java new file mode 100644 index 00000000..5e4730f5 --- /dev/null +++ b/model/src/main/java/cn/qihangerp/model/vo/ExternalGoodsUpsertResultVo.java @@ -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; +} diff --git a/model/src/main/java/cn/qihangerp/model/vo/PddPublishLaneResultVo.java b/model/src/main/java/cn/qihangerp/model/vo/PddPublishLaneResultVo.java new file mode 100644 index 00000000..028f584c --- /dev/null +++ b/model/src/main/java/cn/qihangerp/model/vo/PddPublishLaneResultVo.java @@ -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; +} diff --git a/service/src/main/java/cn/qihangerp/module/service/impl/OGoodsServiceImpl.java b/service/src/main/java/cn/qihangerp/module/service/impl/OGoodsServiceImpl.java index a0c2a5af..a97feb3b 100644 --- a/service/src/main/java/cn/qihangerp/module/service/impl/OGoodsServiceImpl.java +++ b/service/src/main/java/cn/qihangerp/module/service/impl/OGoodsServiceImpl.java @@ -133,6 +133,9 @@ public class OGoodsServiceImpl extends ServiceImpl 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); diff --git a/service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java b/service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java new file mode 100644 index 00000000..0b6a3b0b --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/ExternalGoodsAppService.java @@ -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); +} diff --git a/service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java b/service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java new file mode 100644 index 00000000..8efb363f --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/impl/ExternalGoodsAppServiceImpl.java @@ -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() + .eq(OGoods::getShopId, req.getShopId()) + .eq(OGoods::getOuterErpGoodsId, req.getOutGoodsId()) + .last("LIMIT 1")); + + Long goodsId; + if (existing == null) { + GoodsAddBo addBo = toGoodsAddBo(req); + ResultVo 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() + .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 skuRows = skuService.list(new LambdaQueryWrapper() + .eq(OGoodsSku::getGoodsId, goodsId)); + Map byOuter = new LinkedHashMap<>(); + for (OGoodsSku row : skuRows) { + if (row != null && StringUtils.hasText(row.getOuterErpSkuId())) { + byOuter.putIfAbsent(row.getOuterErpSkuId(), row); + } + } + List 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() + .eq(OGoods::getShopId, req.getShopId()) + .eq(OGoods::getOuterErpGoodsId, req.getOutGoodsId().trim()) + .last("LIMIT 1")); + if (existing == null) { + throw new IllegalArgumentException("商品不存在:shopId=" + req.getShopId() + ", outGoodsId=" + req.getOutGoodsId()); + } + // 1 销售中 2 已下架(与 o_goods.status 注释一致) + existing.setStatus(2); + existing.setUpdateBy("external"); + existing.setUpdateTime(new Date()); + goodsService.updateById(existing); + } + + private static void applyGoodsCanonicalFields(OGoods g, ExternalGoodsUpsertRequest req) { + g.setShopId(req.getShopId()); + g.setName(req.getTitle()); + g.setImage(resolveMainImage(req)); + g.setGoodsNum(resolveGoodsNumber(req)); + g.setBarCode(req.getBarCode()); + g.setProvince(req.getProvince()); + g.setCity(req.getCity()); + g.setTown(req.getTown()); + g.setPurPrice(req.getPurPrice()); + g.setWholePrice(req.getWholePrice()); + g.setRetailPrice(resolveSpuRetailPrice(req)); + g.setUnitCost(req.getUnitCost()); + g.setCategoryCode(req.getCategoryCode()); + g.setBrandCode(req.getBrandCode()); + g.setLinkUrl(req.getLinkUrl()); + g.setCanonicalExt(buildGoodsCanonicalExtJson(req)); + g.setUpdateBy("external"); + g.setUpdateTime(new Date()); + } + + private static void applySkuFields(OGoodsSku sku, ExternalGoodsUpsertRequest.Sku skuReq, ExternalGoodsUpsertRequest req, Long goodsId) { + sku.setShopId(req.getShopId()); + sku.setGoodsId(goodsId); + sku.setOuterErpGoodsId(req.getOutGoodsId()); + sku.setGoodsName(req.getTitle()); + sku.setGoodsNum(resolveGoodsNumber(req)); + sku.setSkuCode(skuReq.getSkuCode()); + sku.setSkuName(skuReq.getSkuName()); + sku.setBarCode(skuReq.getBarCode()); + sku.setPurPrice(skuReq.getPurPrice()); + sku.setRetailPrice(resolveSkuSalePrice(skuReq)); + sku.setUnitCost(skuReq.getUnitCost()); + sku.setGroupPrice(skuReq.getGroupPrice()); + sku.setStockQty(skuReq.getStockQty()); + sku.setWeightGram(skuReq.getWeightGram()); + sku.setSkuImageUrl(StringUtils.hasText(skuReq.getImageUrl()) ? skuReq.getImageUrl() : null); + sku.setCanonicalExt(buildSkuCanonicalExtJson(skuReq)); + } + + private static GoodsAddBo toGoodsAddBo(ExternalGoodsUpsertRequest req) { + GoodsAddBo bo = new GoodsAddBo(); + bo.setName(req.getTitle()); + bo.setImage(resolveMainImage(req)); + // insertGoods 要求 number 非空;对外可省略 goodsNum,用 outGoodsId 作为内部商品编码 + bo.setNumber(resolveGoodsNumber(req)); + bo.setShopId(req.getShopId()); + bo.setOuterErpGoodsId(req.getOutGoodsId()); + bo.setProvince(req.getProvince()); + bo.setCity(req.getCity()); + bo.setTown(req.getTown()); + bo.setCategoryId(null); + bo.setBarCode(req.getBarCode()); + bo.setPurPrice(req.getPurPrice()); + bo.setWholePrice(req.getWholePrice()); + bo.setRetailPrice(resolveSpuRetailPrice(req)); + bo.setUnitCost(req.getUnitCost()); + // insertGoods 内 supplierId.toString(),对外新建默认 0,避免 NPE + bo.setSupplierId(0L); + bo.setBrandId(null); + bo.setAttr5(null); + bo.setLinkUrl(req.getLinkUrl()); + // 不在 insertGoods 中落 SKU:SKU 由本类后续循环写入且带 shopId,避免无 shopId 的重复行 + bo.setSpecList(new ArrayList<>()); + return bo; + } + + /** 商品编码:优先 goodsNum,否则用外部商品 ID,满足 insertGoods 与列表展示 */ + private static String resolveGoodsNumber(ExternalGoodsUpsertRequest req) { + if (req == null) { + return null; + } + if (StringUtils.hasText(req.getGoodsNum())) { + return req.getGoodsNum().trim(); + } + if (StringUtils.hasText(req.getOutGoodsId())) { + return req.getOutGoodsId().trim(); + } + return null; + } + + private static String resolveMainImage(ExternalGoodsUpsertRequest req) { + if (StringUtils.hasText(req.getMainImage())) { + return req.getMainImage().trim(); + } + List 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); + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java new file mode 100644 index 00000000..5f549416 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddConfiguration.java @@ -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 { +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java new file mode 100644 index 00000000..45ec5e65 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddProperties.java @@ -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 categoryMap = new LinkedHashMap<>(); + + /** + * logisticsTemplateCode 或 DEFAULT -> cost_template_id + */ + private Map 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。 + *

若为空且开启 {@link #autoResolveSpecIdsWhenSkuOverridesEmpty},将尝试用 cat.rule + spec.id.get 自动生成。

+ */ + private List 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 properties = new ArrayList<>(); + } + + @Data + public static class PddSkuProperty { + private Long refPid; + private Long vid; + private String value; + private String punit; + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java new file mode 100644 index 00000000..3e18a6c0 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/ExternalPddPublishService.java @@ -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 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 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 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(); + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java new file mode 100644 index 00000000..db88b520 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddCatRuleSpecAutoResolver.java @@ -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 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 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 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 buildSkuOverridesBySpecName( + long catId, + long parentSpecId, + List orderedSkus, + ExternalGoodsUpsertRequest req, + PddShopCredential cred, + String gatewayUrl) throws Exception { + + List out = new ArrayList<>(); + for (OGoodsSku row : orderedSkus) { + String specName = resolveSpecName(row, req); + if (!StringUtils.hasText(specName)) { + throw new IllegalStateException("无法解析 SKU 规格名:outSkuId=" + row.getOuterErpSkuId()); + } + JSONObject p = new JSONObject(); + p.put("cat_id", catId); + p.put("parent_spec_id", parentSpecId); + p.put("spec_name", specName.trim()); + + String body = popClient.invoke( + gatewayUrl, + cred.getAppKey(), + cred.getAppSecret(), + cred.getAccessToken(), + "pdd.goods.spec.id.get", + JSON.toJSONString(p) + ); + if (PddOpenApiSupport.isError(body)) { + throw new IllegalStateException("pdd.goods.spec.id.get 失败 spec_name=" + specName + " : " + + PddOpenApiSupport.formatError(body)); + } + Long sid = PddOpenApiSupport.parseSpecIdFromSpecIdGetResponse(body); + if (sid == null || sid <= 0) { + throw new IllegalStateException("pdd.goods.spec.id.get 未返回 spec_id,spec_name=" + specName + " body=" + + PddOpenApiSupport.snippet(body, 400)); + } + ExternalPddProperties.PddSkuOverride ov = new ExternalPddProperties.PddSkuOverride(); + ov.setSpecIdList("[" + sid + "]"); + ov.setProperties(new ArrayList<>()); + out.add(ov); + } + return out; + } + + private static String resolveSpecName(OGoodsSku row, ExternalGoodsUpsertRequest req) { + if (row != null && StringUtils.hasText(row.getSkuName())) { + return row.getSkuName(); + } + if (req != null && req.getSkus() != null) { + for (ExternalGoodsUpsertRequest.Sku s : req.getSkus()) { + if (s == null || !StringUtils.hasText(s.getOutSkuId()) || row == null) { + continue; + } + if (s.getOutSkuId().equals(row.getOuterErpSkuId()) && StringUtils.hasText(s.getSkuName())) { + return s.getSkuName(); + } + } + } + return null; + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java new file mode 100644 index 00000000..f3297371 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddGoodsAddParamBuilder.java @@ -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 skus, ExternalGoodsUpsertRequest req, + ExternalPddProperties props, List 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 skuRows = skus == null ? List.of() : skus.stream().filter(s -> s != null && StringUtils.hasText(s.getOuterErpSkuId())).toList(); + if (skuRows.isEmpty()) { + throw new IllegalStateException("拼多多上架至少需要一条有效 SKU"); + } + List 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 carousel = resolveCarousel(goods, req); + if (carousel.isEmpty()) { + throw new IllegalStateException("carousel_gallery 不能为空"); + } + root.put("carousel_gallery", carousel); + + List 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 resolveCarousel(OGoods goods, ExternalGoodsUpsertRequest req) { + List 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 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 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 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; + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java new file mode 100644 index 00000000..6608a9db --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddOpenApiSupport.java @@ -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; + } + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java new file mode 100644 index 00000000..f0b01f5b --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopClient.java @@ -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 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 resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + return resp.body(); + } + + private static String formEncode(Map params) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry 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); + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopSignUtil.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopSignUtil.java new file mode 100644 index 00000000..20cf1ef6 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddPopSignUtil.java @@ -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 params, String clientSecret) { + TreeMap sorted = new TreeMap<>(); + for (Map.Entry 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 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); + } + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java b/service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java new file mode 100644 index 00000000..60f7c292 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/pdd/PddSensitiveLogUtil.java @@ -0,0 +1,28 @@ +package cn.qihangerp.service.external.pdd; + +import org.springframework.util.StringUtils; + +/** + * POP 敏感字段日志脱敏(禁止在 info/warn/error 中输出完整 appSecret、accessToken)。 + * + * @author guochengyu + */ +public final class PddSensitiveLogUtil { + + private PddSensitiveLogUtil() { + } + + /** + * 日志用:保留首尾少量字符,中间掩码;过短则全部掩码。 + */ + public static String maskForLog(String s) { + if (!StringUtils.hasText(s)) { + return "(empty)"; + } + String t = s.trim(); + if (t.length() <= 8) { + return "****"; + } + return t.substring(0, 2) + "****" + t.substring(t.length() - 2); + } +} diff --git a/service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java b/service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java new file mode 100644 index 00000000..c24d9b57 --- /dev/null +++ b/service/src/main/java/cn/qihangerp/service/external/shop/PddShopCredential.java @@ -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); + } +} diff --git a/vue/src/views/index.vue b/vue/src/views/index.vue index 3fe80f8c..33e0675a 100644 --- a/vue/src/views/index.vue +++ b/vue/src/views/index.vue @@ -8,8 +8,7 @@ 工作助手
- - + @@ -183,7 +182,7 @@ export default { clientId: '', isSseConnected: false, isLoading: false, - selectedModel: 'deepseek', + selectedModel: '', selectedRole: '', models: [], sessionId: ''