瀏覽代碼

库存后端提交

Gogs 2 月之前
父節點
當前提交
4b9136205b

+ 33 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/StorageAgentController.java

@@ -0,0 +1,33 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.common.utils.StringUtils;
+import com.dtm.storage.model.AgentChatRequest;
+import com.dtm.storage.service.StorageAgentService;
+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.Map;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/agent")
+public class StorageAgentController {
+    private final StorageAgentService agentService;
+
+    public StorageAgentController(StorageAgentService agentService) {
+        this.agentService = agentService;
+    }
+
+    @PostMapping("/chat")
+    public AjaxResult chat(@RequestBody(required = false) AgentChatRequest request) {
+        if (request == null || StringUtils.isEmpty(request.getMessage())) {
+            return AjaxResult.error("message不能为空");
+        }
+        Map<String, Object> result = agentService.chat(request);
+        return AjaxResult.success(result);
+    }
+}

+ 45 - 0
dtm-storage/src/main/java/com/dtm/storage/controller/StorageUploadController.java

@@ -0,0 +1,45 @@
+package com.dtm.storage.controller;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.storage.service.StorageUploadService;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.Map;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/inventory")
+public class StorageUploadController {
+    private final StorageUploadService uploadService;
+
+    public StorageUploadController(StorageUploadService uploadService) {
+        this.uploadService = uploadService;
+    }
+
+    @PostMapping("/upload")
+    public AjaxResult uploadInventoryFiles(@RequestParam(value = "purchaseFile", required = false) MultipartFile purchaseFile,
+                                           @RequestParam(value = "salesFile", required = false) MultipartFile salesFile,
+                                           @RequestParam(value = "assemblyFile", required = false) MultipartFile assemblyFile,
+                                           @RequestParam(value = "productFile", required = false) MultipartFile productFile,
+                                           @RequestParam(value = "semiMappingFile", required = false) MultipartFile semiMappingFile) {
+        try {
+            Map<String, Object> result = uploadService.uploadFiles(
+                    purchaseFile,
+                    salesFile,
+                    assemblyFile,
+                    productFile,
+                    semiMappingFile
+            );
+            return AjaxResult.success("上传成功", result);
+        } catch (IllegalArgumentException e) {
+            return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            return AjaxResult.error("上传失败: " + e.getMessage());
+        }
+    }
+}

+ 22 - 0
dtm-storage/src/main/java/com/dtm/storage/model/AgentChatMessage.java

@@ -0,0 +1,22 @@
+package com.dtm.storage.model;
+
+public class AgentChatMessage {
+    private String role;
+    private String content;
+
+    public String getRole() {
+        return role;
+    }
+
+    public void setRole(String role) {
+        this.role = role;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
+}

+ 33 - 0
dtm-storage/src/main/java/com/dtm/storage/model/AgentChatRequest.java

@@ -0,0 +1,33 @@
+package com.dtm.storage.model;
+
+import java.util.List;
+
+public class AgentChatRequest {
+    private String message;
+    private List<AgentChatMessage> history;
+    private String sku;
+
+    public String getMessage() {
+        return message;
+    }
+
+    public void setMessage(String message) {
+        this.message = message;
+    }
+
+    public List<AgentChatMessage> getHistory() {
+        return history;
+    }
+
+    public void setHistory(List<AgentChatMessage> history) {
+        this.history = history;
+    }
+
+    public String getSku() {
+        return sku;
+    }
+
+    public void setSku(String sku) {
+        this.sku = sku;
+    }
+}

+ 589 - 0
dtm-storage/src/main/java/com/dtm/storage/service/StorageAgentService.java

@@ -0,0 +1,589 @@
+package com.dtm.storage.service;
+
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.dtm.common.utils.StringUtils;
+import com.dtm.storage.model.AgentChatMessage;
+import com.dtm.storage.model.AgentChatRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.client.HttpStatusCodeException;
+import org.springframework.web.client.ResourceAccessException;
+
+import javax.annotation.PostConstruct;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+@Service
+public class StorageAgentService {
+    private static final Logger log = LoggerFactory.getLogger(StorageAgentService.class);
+    private static final int MAX_HISTORY = 6;
+    private static final int MAX_MESSAGE_LENGTH = 2000;
+
+    private final InventoryService inventoryService;
+    private final RiskService riskService;
+    private final ProductService productService;
+    private final StorageDataLoader dataLoader;
+
+    @Value("${zhipu.api-key:}")
+    private String apiKey;
+
+    @Value("${zhipu.base-url:https://open.bigmodel.cn/api/paas/v4/chat/completions}")
+    private String baseUrl;
+
+    @Value("${zhipu.model:glm-4.7-flash}")
+    private String model;
+
+    @Value("${zhipu.temperature:0.4}")
+    private double temperature;
+
+    @Value("${zhipu.max-tokens:1024}")
+    private int maxTokens;
+
+    @Value("${zhipu.timeout-ms:15000}")
+    private int timeoutMs;
+
+    private RestTemplate restTemplate;
+
+    public StorageAgentService(InventoryService inventoryService,
+                               RiskService riskService,
+                               ProductService productService,
+                               StorageDataLoader dataLoader) {
+        this.inventoryService = inventoryService;
+        this.riskService = riskService;
+        this.productService = productService;
+        this.dataLoader = dataLoader;
+    }
+
+    @PostConstruct
+    public void init() {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(timeoutMs);
+        factory.setReadTimeout(timeoutMs);
+        this.restTemplate = new RestTemplate(factory);
+    }
+
+    public Map<String, Object> chat(AgentChatRequest request) {
+        long startedAt = System.currentTimeMillis();
+        String message = normalizeMessage(request == null ? null : request.getMessage());
+        if (StringUtils.isEmpty(message)) {
+            return buildSimpleResponse("请输入问题后再尝试。", null, true, null);
+        }
+
+        String sku = detectSku(message, request == null ? null : request.getSku());
+        Map<String, Object> context;
+        try {
+            context = buildContext(message, sku);
+        } catch (Exception e) {
+            log.error("Storage agent context build failed: {}", e.getMessage(), e);
+            context = new LinkedHashMap<>();
+        }
+
+        String resolvedKey = resolveApiKey();
+        if (StringUtils.isEmpty(resolvedKey)) {
+            String fallback = buildFallbackAnswer(message, context, sku);
+            return buildSimpleResponse(fallback, sku, true, null);
+        }
+
+        try {
+            String reply = callZhipu(message, context, request == null ? null : request.getHistory(), resolvedKey, maxTokens);
+            if (StringUtils.isEmpty(reply)) {
+                String fallback = buildFallbackAnswer(message, context, sku);
+                return buildSimpleResponse(fallback, sku, true, null);
+            }
+            return buildSimpleResponse(reply, sku, false, null);
+        } catch (ResourceAccessException e) {
+            if (isTimeout(e)) {
+                log.warn("Zhipu timeout, retry with lite context (sku={})", sku);
+                try {
+                    Map<String, Object> liteContext = buildLiteContext(context, sku);
+                    String reply = callZhipu(message, liteContext, request == null ? null : request.getHistory(), resolvedKey, 256);
+                    if (!StringUtils.isEmpty(reply)) {
+                        return buildSimpleResponse(reply, sku, false, null);
+                    }
+                } catch (Exception retryEx) {
+                    log.error("Storage agent retry failed: {}", retryEx.getMessage(), retryEx);
+                }
+            }
+            log.error("Storage agent call failed: {}", e.getMessage(), e);
+            String fallback = buildFallbackAnswer(message, context, sku);
+            return buildSimpleResponse(fallback, sku, true, null);
+        } catch (Exception e) {
+            log.error("Storage agent call failed: {}", e.getMessage(), e);
+            String fallback = buildFallbackAnswer(message, context, sku);
+            return buildSimpleResponse(fallback, sku, true, null);
+        } finally {
+            long cost = System.currentTimeMillis() - startedAt;
+            if (cost > 5000) {
+                log.warn("Storage agent request slow: {} ms (sku={})", cost, sku);
+            } else {
+                log.info("Storage agent request ok: {} ms (sku={})", cost, sku);
+            }
+        }
+    }
+
+    private String callZhipu(String message,
+                             Map<String, Object> context,
+                             List<AgentChatMessage> history,
+                             String resolvedKey,
+                             int maxTokensOverride) {
+        JSONArray messages = new JSONArray();
+        messages.add(buildMessage("system", buildSystemPrompt()));
+
+        List<AgentChatMessage> trimmedHistory = trimHistory(history);
+        for (AgentChatMessage item : trimmedHistory) {
+            if (item == null) {
+                continue;
+            }
+            String role = normalizeRole(item.getRole());
+            String content = normalizeMessage(item.getContent());
+            if (StringUtils.isEmpty(content) || StringUtils.isEmpty(role)) {
+                continue;
+            }
+            messages.add(buildMessage(role, content));
+        }
+
+        String contextJson = JSON.toJSONString(context);
+        String userContent = "User question: " + message + "\n\nAvailable data (JSON):\n" + contextJson;
+        messages.add(buildMessage("user", userContent));
+
+        JSONObject payload = new JSONObject();
+        payload.put("model", model);
+        payload.put("messages", messages);
+        payload.put("temperature", temperature);
+        payload.put("max_tokens", maxTokensOverride > 0 ? maxTokensOverride : maxTokens);
+        payload.put("stream", false);
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.setBearerAuth(resolvedKey);
+        HttpEntity<String> requestEntity = new HttpEntity<>(payload.toJSONString(), headers);
+
+        ResponseEntity<String> response;
+        try {
+            response = restTemplate.postForEntity(baseUrl, requestEntity, String.class);
+        } catch (HttpStatusCodeException e) {
+            log.error("Zhipu API error: status={} body={}", e.getStatusCode(), e.getResponseBodyAsString());
+            throw e;
+        } catch (ResourceAccessException e) {
+            log.error("Zhipu API access error: {}", e.getMessage());
+            throw e;
+        }
+        if (response == null || response.getBody() == null) {
+            return null;
+        }
+        JSONObject body = JSON.parseObject(response.getBody());
+        JSONArray choices = body.getJSONArray("choices");
+        if (choices == null || choices.isEmpty()) {
+            return null;
+        }
+        JSONObject choice = choices.getJSONObject(0);
+        if (choice == null) {
+            return null;
+        }
+        JSONObject messageObj = choice.getJSONObject("message");
+        if (messageObj == null) {
+            return null;
+        }
+        String content = messageObj.getString("content");
+        if (!StringUtils.isEmpty(content)) {
+            return content;
+        }
+        String reasoning = messageObj.getString("reasoning_content");
+        if (!StringUtils.isEmpty(reasoning)) {
+            return reasoning;
+        }
+        return null;
+    }
+
+    private JSONObject buildMessage(String role, String content) {
+        JSONObject obj = new JSONObject();
+        obj.put("role", role);
+        obj.put("content", content);
+        return obj;
+    }
+
+    private String buildSystemPrompt() {
+        return "You are an inventory analytics assistant. " +
+                "Use the provided business data to answer the user's question. " +
+                "If the data is insufficient, say so and suggest what to check next. " +
+                "Do not fabricate numbers. Respond in Simplified Chinese. " +
+                "Be concise and provide actionable suggestions.";
+    }
+
+    private Map<String, Object> buildContext(String message, String sku) {
+        Map<String, Object> context = new LinkedHashMap<>();
+        context.put("generatedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+        context.put("overview", inventoryService.getOverviewData());
+        context.put("healthIndex", inventoryService.getHealthIndex());
+        context.put("structure", inventoryService.getStructureData());
+        context.put("lifecycleDistribution", inventoryService.getLifecycleDistribution());
+        context.put("monthlyComparison", trimMonthly(inventoryService.getMonthlyComparisonData(), 12));
+        context.put("riskStats", riskService.getRiskStatistics());
+
+        Map<String, Object> riskList = riskService.getRiskList(1, 5, null, null);
+        context.put("topRisks", riskList.getOrDefault("list", Collections.emptyList()));
+        context.put("optimizationFeedback", riskService.getOptimizationFeedback());
+
+        List<Map<String, Object>> skuSummary = inventoryService.getSkuSummaryTable();
+        context.put("topSkus", sliceList(skuSummary, 5));
+        List<Map<String, Object>> spuSummary = inventoryService.getSpuSummaryTable();
+        context.put("topSpus", sliceList(spuSummary, 5));
+
+        context.put("dataQuality", buildDataQuality());
+
+        if (!StringUtils.isEmpty(sku)) {
+            context.put("skuFocus", buildSkuFocus(sku));
+        }
+
+        return context;
+    }
+
+    private Map<String, Object> buildSkuFocus(String sku) {
+        Map<String, Object> focus = new LinkedHashMap<>();
+        focus.put("sku", sku);
+
+        Map<String, Object> skuRow = findSkuRow(sku);
+        if (skuRow != null) {
+            focus.put("summary", pickKeys(skuRow, "purchaseQty", "salesQty", "inventory", "turnoverRate", "purchaseAmount"));
+        }
+
+        Map<String, Object> riskRow = riskService.getRiskBySku(sku);
+        if (riskRow != null) {
+            focus.put("risk", pickKeys(riskRow, "riskLevel", "riskType", "riskScore", "inventory", "avgDailySales", "coverageDays", "turnoverRate", "description", "suggestion"));
+        }
+
+        Map<String, Object> trend = productService.getProductTrend(sku);
+        if (trend != null) {
+            focus.put("trendSummary", pickKeys(trend, "purchaseQty", "salesQty", "currentInventory", "turnoverRate"));
+            Object breakdown = trend.get("turnoverBreakdown");
+            if (breakdown instanceof List) {
+                focus.put("turnoverBreakdown", sliceList((List<Map<String, Object>>) breakdown, 3));
+            }
+        }
+
+        return focus;
+    }
+
+    private Map<String, Object> findSkuRow(String sku) {
+        List<Map<String, Object>> table = inventoryService.getSkuSummaryTable();
+        for (Map<String, Object> row : table) {
+            Object code = row.get("sku");
+            if (code != null && sku.equalsIgnoreCase(String.valueOf(code))) {
+                return row;
+            }
+        }
+        return null;
+    }
+
+    private Map<String, Object> buildDataQuality() {
+        Map<String, Object> data = new LinkedHashMap<>();
+        data.put("purchaseRecords", dataLoader.getPurchaseRecords().size());
+        data.put("salesRecords", dataLoader.getSalesRecords().size());
+        data.put("assemblyRecords", dataLoader.getAssemblyRecords().size());
+        data.put("productInfo", dataLoader.getProductInfo().size());
+        Map<String, Object> range = buildDateRange();
+        if (!range.isEmpty()) {
+            data.put("dateRange", range);
+        }
+        return data;
+    }
+
+    private Map<String, Object> buildLiteContext(Map<String, Object> fullContext, String sku) {
+        Map<String, Object> lite = new LinkedHashMap<>();
+        lite.put("generatedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
+        if (fullContext != null) {
+            if (fullContext.containsKey("overview")) {
+                lite.put("overview", fullContext.get("overview"));
+            }
+            if (fullContext.containsKey("riskStats")) {
+                lite.put("riskStats", fullContext.get("riskStats"));
+            }
+            if (fullContext.containsKey("optimizationFeedback")) {
+                lite.put("optimizationFeedback", fullContext.get("optimizationFeedback"));
+            }
+            if (fullContext.containsKey("dataQuality")) {
+                lite.put("dataQuality", fullContext.get("dataQuality"));
+            }
+            if (fullContext.containsKey("skuFocus")) {
+                lite.put("skuFocus", fullContext.get("skuFocus"));
+            }
+        }
+        if (!StringUtils.isEmpty(sku) && !lite.containsKey("skuFocus")) {
+            lite.put("skuFocus", buildSkuFocus(sku));
+        }
+        return lite;
+    }
+
+    private Map<String, Object> buildDateRange() {
+        List<LocalDate> dates = new ArrayList<>();
+        dataLoader.getPurchaseRecords().stream().map(r -> r.getDate()).filter(d -> d != null).forEach(dates::add);
+        dataLoader.getSalesRecords().stream().map(r -> r.getDate()).filter(d -> d != null).forEach(dates::add);
+        dataLoader.getAssemblyRecords().stream().map(r -> r.getDate()).filter(d -> d != null).forEach(dates::add);
+        if (dates.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        LocalDate min = dates.stream().min(LocalDate::compareTo).orElse(null);
+        LocalDate max = dates.stream().max(LocalDate::compareTo).orElse(null);
+        Map<String, Object> range = new LinkedHashMap<>();
+        range.put("start", min == null ? null : min.toString());
+        range.put("end", max == null ? null : max.toString());
+        return range;
+    }
+
+    private Map<String, Object> trimMonthly(Map<String, Object> data, int max) {
+        if (data == null || data.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        List<?> months = (List<?>) data.getOrDefault("months", Collections.emptyList());
+        List<?> purchase = (List<?>) data.getOrDefault("purchase", Collections.emptyList());
+        List<?> sales = (List<?>) data.getOrDefault("sales", Collections.emptyList());
+        List<?> inventory = (List<?>) data.getOrDefault("inventory", Collections.emptyList());
+        int size = months == null ? 0 : months.size();
+        int start = Math.max(0, size - max);
+        Map<String, Object> trimmed = new LinkedHashMap<>();
+        trimmed.put("months", sliceListAny(months, start, size));
+        trimmed.put("purchase", sliceListAny(purchase, start, size));
+        trimmed.put("sales", sliceListAny(sales, start, size));
+        trimmed.put("inventory", sliceListAny(inventory, start, size));
+        return trimmed;
+    }
+
+    private String buildFallbackAnswer(String message, Map<String, Object> context, String sku) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("AI服务暂不可用,先给出基于当前数据的简要建议:\n");
+
+        Object overviewObj = context.get("overview");
+        if (overviewObj instanceof Map) {
+            Map<?, ?> overview = (Map<?, ?>) overviewObj;
+            sb.append("1. 总库存约 ").append(valueOrDefault(overview, "totalInventory", "N/A")).append(",")
+                    .append("周转率 ").append(valueOrDefault(overview, "turnoverRate", "N/A")).append("。\n");
+        }
+
+        Object riskObj = context.get("riskStats");
+        if (riskObj instanceof Map) {
+            Map<?, ?> risk = (Map<?, ?>) riskObj;
+            sb.append("2. 风险概览:critical ")
+                    .append(valueOrDefault(risk, "critical", 0)).append(",warning ")
+                    .append(valueOrDefault(risk, "warning", 0)).append(",info ")
+                    .append(valueOrDefault(risk, "info", 0)).append("。\n");
+        }
+
+        if (!StringUtils.isEmpty(sku)) {
+            Object focusObj = context.get("skuFocus");
+            if (focusObj instanceof Map) {
+                Map<?, ?> focus = (Map<?, ?>) focusObj;
+                Object summaryObj = focus.get("summary");
+                if (summaryObj instanceof Map) {
+                    Map<?, ?> summary = (Map<?, ?>) summaryObj;
+                    sb.append("3. SKU ").append(sku).append(":库存 ")
+                            .append(valueOrDefault(summary, "inventory", "N/A"))
+                            .append(",采购 ")
+                            .append(valueOrDefault(summary, "purchaseQty", "N/A"))
+                            .append(",销售 ")
+                            .append(valueOrDefault(summary, "salesQty", "N/A"))
+                            .append(",周转率 ")
+                            .append(valueOrDefault(summary, "turnoverRate", "N/A"))
+                            .append("。\n");
+                }
+                Object riskRow = focus.get("risk");
+                if (riskRow instanceof Map) {
+                    Map<?, ?> risk = (Map<?, ?>) riskRow;
+                    sb.append("4. 风险等级 ")
+                            .append(valueOrDefault(risk, "riskLevel", "N/A"))
+                            .append(",建议:")
+                            .append(valueOrDefault(risk, "suggestion", "请关注库存结构与销售变化。"))
+                            .append("\n");
+                }
+            }
+        }
+
+        sb.append("如需更精细的建议,请稍后重试或检查API配置。");
+        return sb.toString();
+    }
+
+    private Map<String, Object> buildSimpleResponse(String reply, String sku, boolean fallback, String requestId) {
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("reply", reply);
+        result.put("model", model);
+        result.put("fallback", fallback);
+        if (!StringUtils.isEmpty(sku)) {
+            result.put("sku", sku);
+        }
+        if (!StringUtils.isEmpty(requestId)) {
+            result.put("requestId", requestId);
+        }
+        return result;
+    }
+
+    private Object valueOrDefault(Map<?, ?> map, String key, Object defaultValue) {
+        if (map == null) {
+            return defaultValue;
+        }
+        Object value = map.get(key);
+        return value == null ? defaultValue : value;
+    }
+
+    private boolean isTimeout(ResourceAccessException e) {
+        Throwable cause = e.getCause();
+        while (cause != null) {
+            if (cause instanceof java.net.SocketTimeoutException) {
+                return true;
+            }
+            cause = cause.getCause();
+        }
+        return false;
+    }
+
+    private String resolveApiKey() {
+        if (!StringUtils.isEmpty(apiKey)) {
+            return apiKey.trim();
+        }
+        String sys = System.getProperty("zhipu.api.key");
+        if (!StringUtils.isEmpty(sys)) {
+            return sys.trim();
+        }
+        String env = System.getenv("ZHIPU_API_KEY");
+        if (!StringUtils.isEmpty(env)) {
+            return env.trim();
+        }
+        return "";
+    }
+
+    private String detectSku(String message, String skuHint) {
+        if (!StringUtils.isEmpty(skuHint)) {
+            return skuHint.trim();
+        }
+        if (StringUtils.isEmpty(message)) {
+            return null;
+        }
+
+        String normalized = message.toUpperCase(Locale.ROOT);
+        Set<String> knownSkus = loadKnownSkus();
+        for (String sku : knownSkus) {
+            if (normalized.contains(sku.toUpperCase(Locale.ROOT))) {
+                return sku;
+            }
+        }
+
+        Pattern pattern = Pattern.compile("(SKU|sku)?\\s*([A-Za-z0-9_-]{3,})");
+        Matcher matcher = pattern.matcher(message);
+        while (matcher.find()) {
+            String candidate = matcher.group(2);
+            if (candidate != null && knownSkus.contains(candidate)) {
+                return candidate;
+            }
+        }
+        return null;
+    }
+
+    private Set<String> loadKnownSkus() {
+        Set<String> skus = new HashSet<>();
+        dataLoader.getProductInfo().forEach(info -> {
+            if (info.getProductCode() != null && !info.getProductCode().trim().isEmpty()) {
+                skus.add(info.getProductCode().trim());
+            }
+        });
+        dataLoader.getPurchaseRecords().forEach(record -> {
+            if (record.getProductCode() != null && !record.getProductCode().trim().isEmpty()) {
+                skus.add(record.getProductCode().trim());
+            }
+        });
+        return skus;
+    }
+
+    private String normalizeMessage(String message) {
+        if (message == null) {
+            return null;
+        }
+        String trimmed = message.trim();
+        if (trimmed.length() > MAX_MESSAGE_LENGTH) {
+            return trimmed.substring(0, MAX_MESSAGE_LENGTH);
+        }
+        return trimmed;
+    }
+
+    private String normalizeRole(String role) {
+        if (StringUtils.isEmpty(role)) {
+            return null;
+        }
+        String normalized = role.trim().toLowerCase(Locale.ROOT);
+        if ("user".equals(normalized) || "assistant".equals(normalized)) {
+            return normalized;
+        }
+        return null;
+    }
+
+    private List<AgentChatMessage> trimHistory(List<AgentChatMessage> history) {
+        if (history == null || history.isEmpty()) {
+            return Collections.emptyList();
+        }
+        int size = history.size();
+        int start = Math.max(0, size - MAX_HISTORY);
+        return history.subList(start, size);
+    }
+
+    private Map<String, Object> pickKeys(Map<String, Object> source, String... keys) {
+        Map<String, Object> picked = new LinkedHashMap<>();
+        if (source == null || keys == null) {
+            return picked;
+        }
+        for (String key : keys) {
+            if (source.containsKey(key)) {
+                picked.put(key, source.get(key));
+            }
+        }
+        return picked;
+    }
+
+    private <T> List<T> sliceList(List<T> list, int max) {
+        if (list == null || list.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return list.subList(0, Math.min(max, list.size()));
+    }
+
+    private <T> List<T> sliceList(List<T> list, int start, int end) {
+        if (list == null || list.isEmpty()) {
+            return Collections.emptyList();
+        }
+        int safeStart = Math.max(0, start);
+        int safeEnd = Math.min(list.size(), end);
+        if (safeStart >= safeEnd) {
+            return Collections.emptyList();
+        }
+        return new ArrayList<>(list.subList(safeStart, safeEnd));
+    }
+
+    private List<?> sliceListAny(List<?> list, int start, int end) {
+        if (list == null || list.isEmpty()) {
+            return Collections.emptyList();
+        }
+        int safeStart = Math.max(0, start);
+        int safeEnd = Math.min(list.size(), end);
+        if (safeStart >= safeEnd) {
+            return Collections.emptyList();
+        }
+        return new ArrayList<>(list.subList(safeStart, safeEnd));
+    }
+}

+ 152 - 0
dtm-storage/src/main/java/com/dtm/storage/service/StorageUploadService.java

@@ -0,0 +1,152 @@
+package com.dtm.storage.service;
+
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+@Service
+public class StorageUploadService {
+    private static final String[] ALLOWED_EXT = new String[]{".xlsx", ".xls"};
+
+    private final StorageDataLoader dataLoader;
+    private Path activeTempDir;
+
+    public StorageUploadService(StorageDataLoader dataLoader) {
+        this.dataLoader = dataLoader;
+    }
+
+    public Map<String, Object> uploadFiles(MultipartFile purchaseFile,
+                                           MultipartFile salesFile,
+                                           MultipartFile assemblyFile,
+                                           MultipartFile productFile,
+                                           MultipartFile semiMappingFile) {
+        List<Map<String, Object>> saved = new ArrayList<>();
+        Path basePath = prepareTempDir();
+
+        int savedCount = 0;
+        if (purchaseFile != null && !purchaseFile.isEmpty()) {
+            saved.add(saveFile(basePath, "入库数据", purchaseFile, "入库数据", "00-入库数据-上传"));
+            savedCount++;
+        }
+        if (salesFile != null && !salesFile.isEmpty()) {
+            saved.add(saveFile(basePath, "销售数据", salesFile, "销售数据", "00-销售数据-上传"));
+            savedCount++;
+        }
+        if (assemblyFile != null && !assemblyFile.isEmpty()) {
+            saved.add(saveFile(basePath, "组装数据", assemblyFile, "半成品组装", "00-半成品组装-上传"));
+            savedCount++;
+        }
+        if (productFile != null && !productFile.isEmpty()) {
+            saved.add(saveFile(basePath, "产品资料", productFile, "入库数据", "00-产品资料-上传"));
+            savedCount++;
+        }
+        if (semiMappingFile != null && !semiMappingFile.isEmpty()) {
+            saved.add(saveFile(basePath, "半成品映射", semiMappingFile, "半成品组装", "00-半成品组装-匹配-上传"));
+            savedCount++;
+        }
+
+        if (savedCount == 0) {
+            throw new IllegalArgumentException("请至少选择一个要上传的文件");
+        }
+
+        dataLoader.useTemporaryBasePath(basePath);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("basePath", basePath.toAbsolutePath().toString());
+        result.put("files", saved);
+        result.put("count", savedCount);
+        return result;
+    }
+
+    private synchronized Path prepareTempDir() {
+        cleanupTempDir();
+        try {
+            Path temp = Files.createTempDirectory("dtm-storage-");
+            this.activeTempDir = temp;
+            return temp;
+        } catch (Exception e) {
+            throw new IllegalStateException("创建临时目录失败: " + e.getMessage(), e);
+        }
+    }
+
+    private void cleanupTempDir() {
+        if (activeTempDir == null || !Files.exists(activeTempDir)) {
+            return;
+        }
+        try {
+            Files.walk(activeTempDir)
+                    .sorted((a, b) -> b.compareTo(a))
+                    .forEach(path -> {
+                        try {
+                            Files.deleteIfExists(path);
+                        } catch (Exception ignored) {
+                        }
+                    });
+        } catch (Exception ignored) {
+        } finally {
+            activeTempDir = null;
+        }
+    }
+
+    private Map<String, Object> saveFile(Path basePath, String label, MultipartFile file, String dirName, String baseName) {
+        String extension = resolveExtension(file);
+        if (!isAllowed(extension)) {
+            throw new IllegalArgumentException("不支持的文件格式: " + extension + ",仅支持 xlsx/xls");
+        }
+
+        if (basePath == null) {
+            throw new IllegalStateException("无法创建临时目录");
+        }
+
+        try {
+            Path dir = basePath.resolve(dirName);
+            Files.createDirectories(dir);
+            String fileName = baseName + extension;
+            Path target = dir.resolve(fileName);
+            if (Files.exists(target)) {
+                Files.delete(target);
+            }
+            file.transferTo(target.toFile());
+
+            Map<String, Object> info = new LinkedHashMap<>();
+            info.put("label", label);
+            info.put("fileName", fileName);
+            info.put("size", file.getSize());
+            info.put("path", target.toAbsolutePath().toString());
+            return info;
+        } catch (Exception e) {
+            throw new IllegalStateException("保存文件失败: " + e.getMessage(), e);
+        }
+    }
+
+    private String resolveExtension(MultipartFile file) {
+        String name = file == null ? null : file.getOriginalFilename();
+        if (name == null) {
+            return "";
+        }
+        int idx = name.lastIndexOf('.');
+        if (idx < 0) {
+            return "";
+        }
+        return name.substring(idx).toLowerCase(Locale.ROOT);
+    }
+
+    private boolean isAllowed(String ext) {
+        if (ext == null || ext.isEmpty()) {
+            return false;
+        }
+        for (String allowed : ALLOWED_EXT) {
+            if (allowed.equalsIgnoreCase(ext)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}