{"repo": "chenjian639/journal", "files": {"src/main/java/com/paper/config/EnvConfig.java": {"b2f92fc8120598fff74d514668b5aeb30af8ea63": {"t": 1785745661.3245747, "n": 7169, "h": "47c27865da35065a", "c": "package com.paper.config;\n\nimport java.io.BufferedReader;\nimport java.io.File;\nimport java.io.FileReader;\nimport java.io.IOException;\nimport java.util.HashMap;\nimport java.util.Map;\n\n/**\n * 环境配置管理\n * 从 .env 文件和系统环境变量读取配置\n */\npublic class EnvConfig {\n \n private static final Map config = new HashMap<>();\n private static boolean initialized = false;\n \n // ====== 配置键常量 ======\n \n // 数据库\n public static final String DB_MODE = \"DB_MODE\";\n public static final String MYSQL_HOST = \"MYSQL_HOST\";\n public static final String MYSQL_PORT = \"MYSQL_PORT\";\n public static final String MYSQL_DATABASE = \"MYSQL_DATABASE\";\n public static final String MYSQL_USER = \"MYSQL_USER\";\n public static final String MYSQL_PASSWORD = \"MYSQL_PASSWORD\";\n \n // 邮件\n public static final String MAIL_ENABLED = \"MAIL_ENABLED\";\n public static final String MAIL_HOST = \"MAIL_HOST\";\n public static final String MAIL_PORT = \"MAIL_PORT\";\n public static final String MAIL_USERNAME = \"MAIL_USERNAME\";\n public static final String MAIL_PASSWORD = \"MAIL_PASSWORD\";\n \n // DeepSeek AI\n public static final String DEEPSEEK_API_KEY = \"DEEPSEEK_API_KEY\";\n public static final String DEEPSEEK_API_BASE = \"DEEPSEEK_API_BASE\";\n public static final String DEEPSEEK_MODEL = \"DEEPSEEK_MODEL\";\n\n // 兼容旧配置键(历史版本可能使用这些)\n private static final String LEGACY_AI_API_KEY = \"AI_API_KEY\";\n private static final String LEGACY_AI_API_BASE = \"AI_API_BASE\";\n private static final String LEGACY_AI_MODEL = \"AI_MODEL\";\n \n // 服务器\n public static final String SERVER_HOST = \"SERVER_HOST\";\n public static final String SERVER_PORT = \"SERVER_PORT\";\n \n // 日志\n public static final String LOG_LEVEL = \"LOG_LEVEL\";\n \n static {\n initialize();\n }\n \n private static synchronized void initialize() {\n if (initialized) return;\n \n loadEnvFile();\n loadSystemEnv();\n applyLegacyAliases();\n setDefaults();\n \n initialized = true;\n System.out.println(\"[EnvConfig] Config loaded, total \" + config.size() + \" items\");\n }\n\n private static void applyLegacyAliases() {\n // 兼容:如果用户只配置了 AI_API_KEY,则映射到 DEEPSEEK_API_KEY\n if (!config.containsKey(DEEPSEEK_API_KEY) && config.containsKey(LEGACY_AI_API_KEY)) {\n config.put(DEEPSEEK_API_KEY, config.get(LEGACY_AI_API_KEY));\n }\n if (!config.containsKey(DEEPSEEK_API_BASE) && config.containsKey(LEGACY_AI_API_BASE)) {\n config.put(DEEPSEEK_API_BASE, config.get(LEGACY_AI_API_BASE));\n }\n if (!config.containsKey(DEEPSEEK_MODEL) && config.containsKey(LEGACY_AI_MODEL)) {\n config.put(DEEPSEEK_MODEL, config.get(LEGACY_AI_MODEL));\n }\n }\n \n private static void loadEnvFile() {\n String[] paths = { \".env\", \"../.env\", System.getProperty(\"user.dir\") + \"/.env\" };\n \n for (String path : paths) {\n File envFile = new File(path);\n if (envFile.exists() && envFile.isFile()) {\n try (BufferedReader reader = new BufferedReader(new FileReader(envFile))) {\n String line;\n while ((line = reader.readLine()) != null) {\n line = line.trim();\n if (line.isEmpty() || line.startsWith(\"#\")) continue;\n \n int eq = line.indexOf('=');\n if (eq > 0) {\n String key = line.substring(0, eq).trim();\n String value = line.substring(eq + 1).trim();\n // 移除引号\n if ((value.startsWith(\"\\\"\") && value.endsWith(\"\\\"\")) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.substring(1, value.length() - 1);\n }\n config.put(key, value);\n }\n }\n System.out.println(\"[EnvConfig] Loaded: \" + envFile.getAbsolutePath());\n return;\n } catch (IOException e) {\n System.err.println(\"[EnvConfig] Read failed: \" + e.getMessage());\n }\n }\n }\n }\n \n private static void loadSystemEnv() {\n for (Map.Entry entry : System.getenv().entrySet()) {\n String key = entry.getKey();\n if (key.startsWith(\"DB_\") || key.startsWith(\"MYSQL_\") || \n key.startsWith(\"MAIL_\") || key.startsWith(\"DEEPSEEK_\") ||\n key.startsWith(\"SERVER_\") || key.startsWith(\"LOG_\")) {\n config.put(key, entry.getValue());\n }\n }\n }\n \n private static void setDefaults() {\n config.putIfAbsent(DB_MODE, \"sqlite\");\n config.putIfAbsent(MYSQL_HOST, \"localhost\");\n config.putIfAbsent(MYSQL_PORT, \"3306\");\n config.putIfAbsent(MYSQL_DATABASE, \"paper_sys\");\n config.putIfAbsent(MYSQL_USER, \"root\");\n \n config.putIfAbsent(MAIL_ENABLED, \"false\");\n config.putIfAbsent(MAIL_HOST, \"smtp.qq.com\");\n config.putIfAbsent(MAIL_PORT, \"465\");\n \n config.putIfAbsent(DEEPSEEK_API_BASE, \"https://api.deepseek.com/v1\");\n config.putIfAbsent(DEEPSEEK_MODEL, \"deepseek-chat\");\n \n config.putIfAbsent(SERVER_HOST, \"0.0.0.0\");\n config.putIfAbsent(SERVER_PORT, \"5000\");\n config.putIfAbsent(LOG_LEVEL, \"INFO\");\n }\n \n // ====== 公共方法 ======\n \n public static String get(String key) {\n return config.get(key);\n }\n \n public static String get(String key, String defaultValue) {\n return config.getOrDefault(key, defaultValue);\n }\n \n public static int getInt(String key, int defaultValue) {\n String value = config.get(key);\n if (value == null) return defaultValue;\n try {\n return Integer.parseInt(value);\n } catch (NumberFormatException e) {\n return defaultValue;\n }\n }\n \n public static boolean getBoolean(String key, boolean defaultValue) {\n String value = config.get(key);\n if (value == null) return defaultValue;\n return \"true\".equalsIgnoreCase(value) || \"1\".equals(value);\n }\n \n // ====== 便捷方法 ======\n \n public static boolean isSQLiteMode() {\n return \"sqlite\".equalsIgnoreCase(get(DB_MODE, \"sqlite\"));\n }\n \n public static boolean isMailEnabled() {\n String password = get(MAIL_PASSWORD);\n return getBoolean(MAIL_ENABLED, false) && \n password != null && !password.isEmpty() && !password.startsWith(\"your\");\n }\n \n public static boolean hasValidDeepSeekKey() {\n String key = get(DEEPSEEK_API_KEY);\n return key != null && !key.isEmpty() && !key.startsWith(\"your-\");\n }\n \n public static String getMySQLUrl() {\n return String.format(\"jdbc:mysql://%s:%s/%s?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true\",\n get(MYSQL_HOST), get(MYSQL_PORT), get(MYSQL_DATABASE));\n }\n}\n"}}, "src/main/java/com/paper/controller/AnalysisController.java": {"b2f92fc8120598fff74d514668b5aeb30af8ea63": {"t": 1785745692.666904, "n": 26420, "h": "b77cb917348f5783", "c": "package com.paper.controller;\n\nimport java.io.ByteArrayOutputStream;\nimport java.io.IOException;\nimport java.net.URLEncoder;\nimport java.nio.charset.StandardCharsets;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.nio.file.Paths;\nimport java.nio.file.attribute.FileTime;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport java.util.stream.Stream;\nimport java.util.zip.ZipEntry;\nimport java.util.zip.ZipOutputStream;\n\nimport org.springframework.http.HttpHeaders;\nimport org.springframework.http.MediaType;\nimport org.springframework.http.ResponseEntity;\nimport org.springframework.stereotype.Controller;\nimport org.springframework.ui.Model;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.PostMapping;\nimport org.springframework.web.bind.annotation.RequestBody;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.ResponseBody;\nimport org.springframework.web.multipart.MultipartFile;\n\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.paper.dao.AnalysisDAO;\nimport com.paper.model.AnalysisRecord;\nimport com.paper.service.AnalysisService;\nimport com.paper.service.UserUploadCsvNormalizer;\nimport com.paper.utils.ResponseUtils;\nimport com.paper.utils.ValidationUtils;\n\n/**\n * 期刊分析控制器\n */\n@Controller\n@RequestMapping(\"/analysis\")\npublic class AnalysisController {\n\n private final AnalysisService analysisService;\n private final UserUploadCsvNormalizer csvNormalizer;\n private final AnalysisDAO analysisDAO;\n\n public AnalysisController(AnalysisService analysisService, UserUploadCsvNormalizer csvNormalizer, AnalysisDAO analysisDAO) {\n this.analysisService = analysisService;\n this.csvNormalizer = csvNormalizer;\n this.analysisDAO = analysisDAO;\n }\n\n // 用户上传文件存储目录:uploads/{username}/\n private static final String UPLOAD_BASE_DIR = \"uploads/\";\n private static final List ALLOWED_EXTENSIONS = List.of(\".json\", \".csv\");\n private static final long MAX_FILE_SIZE = 1000 * 1024 * 1024; // 100MB\n private static final ObjectMapper objectMapper = new ObjectMapper();\n\n public record OutputFileItem(String path, long size, String lastModified) {}\n\n /**\n * 获取用户上传目录\n */\n private Path getUserUploadDir(String username) {\n if (ValidationUtils.isBlank(username)) {\n return Paths.get(UPLOAD_BASE_DIR, \"anonymous\");\n }\n return Paths.get(UPLOAD_BASE_DIR, username);\n }\n\n /**\n * 上传数据文件(需要用户名)\n */\n @PostMapping(\"/upload\")\n @ResponseBody\n public Map uploadFile(\n @RequestParam(\"file\") MultipartFile file,\n @RequestParam(required = false) String username) {\n \n if (file.isEmpty()) {\n return ResponseUtils.error(\"请选择要上传的文件\");\n }\n \n if (file.getSize() > MAX_FILE_SIZE) {\n return ResponseUtils.error(\"文件大小不能超过1000MB\");\n }\n \n String originalFilename = file.getOriginalFilename();\n String extension = getFileExtension(originalFilename);\n if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {\n return ResponseUtils.error(\"仅支持JSON和CSV格式文件\");\n }\n \n try {\n // 使用用户专属目录\n Path uploadPath = getUserUploadDir(username);\n if (!Files.exists(uploadPath)) {\n Files.createDirectories(uploadPath);\n }\n \n // 清理用户目录下的旧数据文件(只保留一份)\n cleanUserDataFiles(uploadPath);\n \n String uniqueFilename = UUID.randomUUID().toString() + extension;\n Path filePath = uploadPath.resolve(uniqueFilename);\n Files.copy(file.getInputStream(), filePath);\n\n // 对用户上传 CSV 做一次“表头/分隔符”规范化,避免期刊列(journal)无法识别\n Map normalizeInfo = null;\n if (\".csv\".equalsIgnoreCase(extension)) {\n try {\n var report = csvNormalizer.normalizeInPlace(filePath);\n normalizeInfo = new HashMap<>();\n normalizeInfo.put(\"changed\", report.changed());\n normalizeInfo.put(\"encoding\", report.encoding());\n normalizeInfo.put(\"detectedDelimiter\", String.valueOf(report.detectedDelimiter()));\n normalizeInfo.put(\"renamedHeaders\", report.renamedHeaders());\n normalizeInfo.put(\"warnings\", report.warnings());\n } catch (Exception e) {\n normalizeInfo = new HashMap<>();\n normalizeInfo.put(\"changed\", false);\n normalizeInfo.put(\"error\", \"CSV 规范化失败: \" + e.getMessage());\n }\n }\n \n // 保存到数据库\n if (ValidationUtils.isNotBlank(username)) {\n try {\n analysisDAO.saveUploadRecord(username, uniqueFilename, originalFilename, file.getSize());\n } catch (Exception e) {\n System.err.println(\"Failed to save upload record: \" + e.getMessage());\n }\n }\n \n Map data = new HashMap<>();\n data.put(\"filename\", uniqueFilename);\n data.put(\"originalName\", originalFilename);\n data.put(\"size\", file.getSize());\n data.put(\"userDir\", uploadPath.toString());\n if (normalizeInfo != null) {\n data.put(\"csvNormalize\", normalizeInfo);\n }\n return ResponseUtils.success(\"文件上传成功\", data);\n \n } catch (IOException e) {\n return ResponseUtils.error(\"文件上传失败: \" + e.getMessage());\n }\n }\n\n /**\n * 运行数据分析(保存结果到数据库)\n */\n @PostMapping(\"/run\")\n @ResponseBody\n public Map runAnalysis(\n @RequestParam(required = false) String filename,\n @RequestParam(required = false) String username) {\n try {\n Map result;\n String analysisFilename = filename;\n \n if (ValidationUtils.isNotBlank(filename)) {\n if (!ValidationUtils.isSafeFilename(filename)) {\n return ResponseUtils.error(\"无效的文件名\");\n }\n // 使用用户专属目录\n Path userDir = getUserUploadDir(username);\n result = analysisService.analyzeFile(userDir.resolve(filename).toString());\n } else if (ValidationUtils.isNotBlank(username)) {\n // 分析用户目录下的所有文件\n Path userDir = getUserUploadDir(username);\n result = analysisService.analyzeUserDirectory(userDir.toString());\n // 为用户目录分析生成一个唯一文件名标识\n analysisFilename = \"user_analysis_\" + System.currentTimeMillis() + \".json\";\n } else {\n result = analysisService.analyzeAllData();\n analysisFilename = \"db_analysis_\" + System.currentTimeMillis() + \".json\";\n }\n \n // 保存分析结果到数据库\n if (Boolean.TRUE.equals(result.get(\"success\")) && ValidationUtils.isNotBlank(username)) {\n try {\n Object analysisObj = result.get(\"analysis\");\n String analysisJson = analysisObj != null ? objectMapper.writeValueAsString(analysisObj) : \"{}\";\n \n // 如果是新分析,先创建记录\n if (analysisFilename != null && analysisFilename.startsWith(\"user_analysis_\")) {\n analysisDAO.saveAnalysisRecord(username, analysisFilename, \"用户数据分析\", analysisJson);\n } else if (analysisFilename != null) {\n analysisDAO.updateAnalysisResult(analysisFilename, analysisJson);\n }\n\n // 归档本次分析产物(outputs/data + analysis_result.json),用于后续按 analysisId 下载报告\n try {\n AnalysisRecord saved = analysisDAO.getByFilename(analysisFilename);\n if (saved != null && saved.getId() != null) {\n Path userDir = getUserUploadDir(username);\n ReportController.archiveAnalysisSnapshot(userDir, saved.getId(), analysisJson);\n }\n } catch (Exception e) {\n System.err.println(\"[WARN] Failed to archive analysis snapshot: \" + e.getMessage());\n }\n \n // 在返回结果中添加分析记录ID\n result.put(\"analysisId\", analysisFilename);\n } catch (Exception e) {\n System.err.println(\"Failed to save analysis result: \" + e.getMessage());\n e.printStackTrace();\n }\n }\n \n return result;\n \n } catch (Exception e) {\n return ResponseUtils.error(\"数据分析失败: \" + e.getMessage());\n }\n }\n\n /**\n * AI对话接口(包含分析上下文)\n */\n @PostMapping(\"/chat\")\n @ResponseBody\n public Map chat(@RequestBody Map request) {\n String message = request.get(\"message\");\n String context = request.get(\"context\");\n String username = request.get(\"username\");\n \n if (ValidationUtils.isBlank(message)) {\n return ResponseUtils.error(\"请输入消息\");\n }\n \n if (message.length() > 1000) {\n return ResponseUtils.error(\"消息内容过长\");\n }\n \n try {\n // 如果没有传入 context,尝试获取用户最近的分析结果\n if (ValidationUtils.isBlank(context) && ValidationUtils.isNotBlank(username)) {\n try {\n AnalysisRecord record = analysisDAO.getLatestByUsername(username);\n if (record != null && record.getAnalysisResult() != null) {\n context = record.getAnalysisResult();\n }\n } catch (Exception e) {\n System.err.println(\"Failed to get analysis context: \" + e.getMessage());\n }\n }\n \n String reply = analysisService.chat(message.trim(), context);\n \n Map data = new HashMap<>();\n data.put(\"reply\", reply);\n return ResponseUtils.success(\"success\", data);\n \n } catch (Exception e) {\n return ResponseUtils.error(\"AI对话失败: \" + e.getMessage());\n }\n }\n\n /**\n * 获取用户的分析历史\n */\n @GetMapping(\"/history\")\n @ResponseBody\n public Map getHistory(@RequestParam String username) {\n if (ValidationUtils.isBlank(username)) {\n return ResponseUtils.error(\"请提供用户名\");\n }\n \n try {\n List records = analysisDAO.getHistoryByUsername(username, 10);\n \n List> historyList = new ArrayList<>();\n for (AnalysisRecord record : records) {\n Map item = new HashMap<>();\n item.put(\"id\", record.getId());\n item.put(\"filename\", record.getFilename());\n item.put(\"originalName\", record.getOriginalName());\n item.put(\"fileSize\", record.getFileSize());\n item.put(\"createdAt\", record.getCreatedAt() != null ? record.getCreatedAt().toString() : null);\n \n // 解析分析结果摘要\n if (record.getAnalysisResult() != null) {\n try {\n Map analysis = objectMapper.readValue(record.getAnalysisResult(), Map.class);\n // 兼容多种字段名\n Object totalPapers = analysis.get(\"total_records\");\n if (totalPapers == null) {\n totalPapers = analysis.get(\"total_papers\");\n }\n item.put(\"totalPapers\", totalPapers != null ? totalPapers : 0);\n item.put(\"journalCount\", analysis.get(\"journal_count\"));\n } catch (Exception e) {\n item.put(\"totalPapers\", 0);\n }\n }\n historyList.add(item);\n }\n \n Map data = new HashMap<>();\n data.put(\"history\", historyList);\n return ResponseUtils.success(\"success\", data);\n \n } catch (Exception e) {\n return ResponseUtils.error(\"获取历史记录失败: \" + e.getMessage());\n }\n }\n\n /**\n * 获取特定分析记录详情\n */\n @GetMapping(\"/detail\")\n @ResponseBody\n public Map getDetail(@RequestParam String filename) {\n if (ValidationUtils.isBlank(filename) || !ValidationUtils.isSafeFilename(filename)) {\n return ResponseUtils.error(\"无效的文件名\");\n }\n \n try {\n AnalysisRecord record = analysisDAO.getByFilename(filename);\n \n if (record == null) {\n return ResponseUtils.error(\"记录不存在\");\n }\n \n Map data = new HashMap<>();\n data.put(\"filename\", record.getFilename());\n data.put(\"originalName\", record.getOriginalName());\n data.put(\"createdAt\", record.getCreatedAt() != null ? record.getCreatedAt().toString() : null);\n \n if (record.getAnalysisResult() != null) {\n data.put(\"analysis\", objectMapper.readValue(record.getAnalysisResult(), Map.class));\n }\n \n return ResponseUtils.success(\"success\", data);\n \n } catch (Exception e) {\n return ResponseUtils.error(\"获取详情失败: \" + e.getMessage());\n }\n }\n\n /**\n * 获取 AI 配置状态\n */\n @GetMapping(\"/ai-status\")\n @ResponseBody\n public Map getAIStatus() {\n Map data = new HashMap<>();\n \n boolean isConfigured = com.paper.config.EnvConfig.hasValidDeepSeekKey();\n \n data.put(\"configured\", isConfigured);\n data.put(\"provider\", \"deepseek\");\n data.put(\"model\", com.paper.config.EnvConfig.get(\n com.paper.config.EnvConfig.DEEPSEEK_MODEL, \"deepseek-chat\"));\n \n if (!isConfigured) {\n data.put(\"message\", \"AI 功能未启用。请在 .env 文件中配置 DEEPSEEK_API_KEY\");\n }\n \n return ResponseUtils.success(\"success\", data);\n }\n\n /**\n * 获取已上传的文件列表(用户专属)\n */\n @GetMapping(\"/files\")\n @ResponseBody\n public Map listFiles(@RequestParam(required = false) String username) {\n try {\n Path uploadPath = getUserUploadDir(username);\n List fileList = new ArrayList<>();\n \n if (Files.exists(uploadPath) && Files.isDirectory(uploadPath)) {\n Files.list(uploadPath)\n .filter(Files::isRegularFile)\n .forEach(path -> fileList.add(path.getFileName().toString()));\n }\n \n Map data = new HashMap<>();\n data.put(\"files\", fileList);\n data.put(\"userDir\", uploadPath.toString());\n return ResponseUtils.success(\"success\", data);\n \n } catch (IOException e) {\n return ResponseUtils.error(\"获取文件列表失败: \" + e.getMessage());\n }\n }\n\n /**\n * 删除已上传的文件\n */\n @PostMapping(\"/delete\")\n @ResponseBody\n public Map deleteFile(\n @RequestParam String filename,\n @RequestParam(required = false) String username) {\n if (!ValidationUtils.isSafeFilename(filename)) {\n return ResponseUtils.error(\"无效的文件名\");\n }\n \n try {\n // 删除数据库记录\n try {\n analysisDAO.deleteByFilename(filename);\n } catch (Exception e) {\n System.err.println(\"Failed to delete DB record: \" + e.getMessage());\n }\n \n // 删除文件(从用户目录)\n Path filePath = getUserUploadDir(username).resolve(filename);\n if (Files.exists(filePath)) {\n Files.delete(filePath);\n return ResponseUtils.success(\"文件删除成功\");\n } else {\n return ResponseUtils.error(\"文件不存在\");\n }\n } catch (IOException e) {\n return ResponseUtils.error(\"文件删除失败: \" + e.getMessage());\n }\n }\n\n private String getFileExtension(String filename) {\n if (filename == null || !filename.contains(\".\")) {\n return \"\";\n }\n return filename.substring(filename.lastIndexOf(\".\"));\n }\n\n /**\n * 一键打包下载分析结果\n */\n @GetMapping(\"/download\")\n public ResponseEntity downloadResults(@RequestParam(required = false) String username) {\n try {\n Path userDir = getUserUploadDir(username);\n Path outputsDir = userDir.resolve(\"outputs\");\n \n if (!Files.exists(outputsDir)) {\n return ResponseEntity.notFound().build();\n }\n \n ByteArrayOutputStream baos = new ByteArrayOutputStream();\n try (ZipOutputStream zos = new ZipOutputStream(baos)) {\n // 添加主分析结果\n Path resultFile = outputsDir.resolve(\"analysis_result.json\");\n if (Files.exists(resultFile)) {\n addToZip(zos, resultFile, \"analysis_result.json\");\n }\n \n // 添加各指标详细结果\n String[] metricDirs = {\"disrupt\", \"interdisciplinary\", \"novelty\", \"theme\", \"topic\", \"keywords\"};\n for (String dir : metricDirs) {\n Path metricPath = outputsDir.resolve(dir);\n if (Files.exists(metricPath) && Files.isDirectory(metricPath)) {\n Files.walk(metricPath)\n .filter(Files::isRegularFile)\n .forEach(file -> {\n try {\n String entryName = dir + \"/\" + file.getFileName().toString();\n addToZip(zos, file, entryName);\n } catch (IOException e) {\n System.err.println(\"Failed to add file to zip: \" + file);\n }\n });\n }\n }\n \n // 添加清洗后的数据文件\n Path dataDir = userDir.resolve(\"data\");\n if (Files.exists(dataDir) && Files.isDirectory(dataDir)) {\n Files.list(dataDir)\n .filter(Files::isRegularFile)\n .filter(p -> p.toString().endsWith(\".csv\"))\n .forEach(file -> {\n try {\n String entryName = \"data/\" + file.getFileName().toString();\n addToZip(zos, file, entryName);\n } catch (IOException e) {\n System.err.println(\"Failed to add data file to zip: \" + file);\n }\n });\n }\n }\n \n byte[] zipBytes = baos.toByteArray();\n \n String zipFilename = \"analysis_results_\" + System.currentTimeMillis() + \".zip\";\n \n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);\n headers.setContentDispositionFormData(\"attachment\", zipFilename);\n headers.setContentLength(zipBytes.length);\n \n return ResponseEntity.ok()\n .headers(headers)\n .body(zipBytes);\n \n } catch (IOException e) {\n System.err.println(\"Download failed: \" + e.getMessage());\n return ResponseEntity.internalServerError().build();\n }\n }\n\n /**\n * 在线查看用户 outputs 目录(全量 CSV/JSON 列表 + 可点开下载)\n */\n @GetMapping(\"/outputs\")\n public String outputsPage(@RequestParam(required = false) String username, Model model) {\n try {\n if (ValidationUtils.isBlank(username)) {\n model.addAttribute(\"error\", \"请提供 username\");\n return \"error\";\n }\n\n Path userDir = getUserUploadDir(username);\n Path outputsDir = userDir.resolve(\"outputs\");\n if (!Files.exists(outputsDir) || !Files.isDirectory(outputsDir)) {\n model.addAttribute(\"error\", \"未找到 outputs 目录(请先运行一次分析)\");\n return \"error\";\n }\n\n List items = new ArrayList<>();\n try (Stream s = Files.walk(outputsDir)) {\n s.filter(Files::isRegularFile)\n .forEach(p -> {\n try {\n String rel = outputsDir.relativize(p).toString().replace('\\\\', '/');\n String ext = getFileExtension(rel).toLowerCase();\n if (!ALLOWED_EXTENSIONS.contains(ext)) {\n return;\n }\n long size = Files.size(p);\n FileTime ft = Files.getLastModifiedTime(p);\n String lm = ft == null ? \"\" : String.valueOf(ft);\n items.add(new OutputFileItem(rel, size, lm));\n } catch (Exception ignored) {\n }\n });\n }\n\n items.sort((a, b) -> a.path().compareToIgnoreCase(b.path()));\n\n model.addAttribute(\"username\", username);\n model.addAttribute(\"items\", items);\n model.addAttribute(\"zipUrl\", \"/analysis/download?username=\" + URLEncoder.encode(username, StandardCharsets.UTF_8));\n model.addAttribute(\"resultUrl\", \"/analysis/outputs/file?username=\" + URLEncoder.encode(username, StandardCharsets.UTF_8)\n + \"&path=\" + URLEncoder.encode(\"analysis_result.json\", StandardCharsets.UTF_8));\n return \"analysis/outputs\";\n } catch (Exception e) {\n model.addAttribute(\"error\", \"加载 outputs 失败: \" + e.getMessage());\n return \"error\";\n }\n }\n\n /**\n * 下载/查看 outputs 下的单个文件(CSV/JSON)。\n * 通过 outputsDir + 相对路径访问,避免路径穿越。\n */\n @GetMapping(\"/outputs/file\")\n public ResponseEntity downloadOutputFile(\n @RequestParam String username,\n @RequestParam String path\n ) {\n try {\n if (ValidationUtils.isBlank(username) || ValidationUtils.isBlank(path)) {\n return ResponseEntity.badRequest().build();\n }\n\n String rel = path.replace('\\\\', '/').trim();\n if (rel.startsWith(\"/\") || rel.contains(\"..\")) {\n return ResponseEntity.badRequest().build();\n }\n\n String ext = getFileExtension(rel).toLowerCase();\n if (!ALLOWED_EXTENSIONS.contains(ext)) {\n return ResponseEntity.status(403).build();\n }\n\n Path userDir = getUserUploadDir(username);\n Path outputsDir = userDir.resolve(\"outputs\").normalize();\n Path target = outputsDir.resolve(rel).normalize();\n if (!target.startsWith(outputsDir)) {\n return ResponseEntity.status(403).build();\n }\n if (!Files.exists(target) || !Files.isRegularFile(target)) {\n return ResponseEntity.notFound().build();\n }\n\n byte[] bytes = Files.readAllBytes(target);\n String filename = target.getFileName().toString();\n\n MediaType mt = \".json\".equals(ext)\n ? MediaType.APPLICATION_JSON\n : new MediaType(\"text\", \"csv\", StandardCharsets.UTF_8);\n\n HttpHeaders headers = new HttpHeaders();\n headers.setContentType(mt);\n headers.setContentDispositionFormData(\"attachment\", filename);\n headers.setContentLength(bytes.length);\n\n return ResponseEntity.ok().headers(headers).body(bytes);\n } catch (Exception e) {\n return ResponseEntity.internalServerError().build();\n }\n }\n \n /**\n * 添加文件到ZIP\n */\n private void addToZip(ZipOutputStream zos, Path file, String entryName) throws IOException {\n ZipEntry entry = new ZipEntry(entryName);\n zos.putNextEntry(entry);\n Files.copy(file, zos);\n zos.closeEntry();\n }\n \n /**\n * 清理用户目录下的旧数据文件(CSV/JSON),只保留一份\n */\n private void cleanUserDataFiles(Path userDir) {\n if (!Files.exists(userDir) || !Files.isDirectory(userDir)) {\n return;\n }\n \n try {\n Files.list(userDir)\n .filter(Files::isRegularFile)\n .filter(path -> {\n String name = path.getFileName().toString().toLowerCase();\n return name.endsWith(\".csv\") || name.endsWith(\".json\");\n })\n .forEach(path -> {\n try {\n Files.delete(path);\n System.out.println(\"<=Deleted old file: \" + path.getFileName());\n } catch (IOException e) {\n System.err.println(\"Failed to delete old file: \" + path.getFileName() + \" - \" + e.getMessage());\n }\n });\n\n // 同步清理 raw 备份(避免多次上传导致占用过大)\n Path rawDir = userDir.resolve(\"raw\");\n if (Files.exists(rawDir) && Files.isDirectory(rawDir)) {\n Files.list(rawDir)\n .filter(Files::isRegularFile)\n .forEach(path -> {\n try {\n Files.delete(path);\n } catch (IOException e) {\n System.err.println(\"Failed to delete raw backup: \" + path.getFileName() + \" - \" + e.getMessage());\n }\n });\n }\n } catch (IOException e) {\n System.err.println(\"Failed to clean user dir: \" + e.getMessage());\n }\n }\n}\n"}}, "src/main/java/com/paper/service/AIService.java": {"b2f92fc8120598fff74d514668b5aeb30af8ea63": {"t": 1785745706.553128, "n": 4530, "h": "55cb73bc3ae13c3c", "c": "package com.paper.service;\n\nimport java.io.BufferedReader;\nimport java.io.IOException;\nimport java.io.InputStreamReader;\nimport java.io.OutputStream;\nimport java.net.HttpURLConnection;\nimport java.net.URL;\nimport java.nio.charset.StandardCharsets;\nimport java.util.ArrayList;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\n\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.paper.config.EnvConfig;\n\n/**\n * AI 服务类\n * 调用 DeepSeek API 进行智能对话\n */\npublic class AIService {\n \n private static final ObjectMapper objectMapper = new ObjectMapper();\n private static final int CONNECT_TIMEOUT = 30000;\n private static final int READ_TIMEOUT = 60000;\n \n /**\n * 发送消息给 AI 并获取回复\n */\n public String chat(String message) throws IOException {\n return chat(message, null);\n }\n \n /**\n * 发送消息给 AI 并获取回复(带系统提示)\n */\n public String chat(String message, String systemPrompt) throws IOException {\n String apiKey = EnvConfig.get(EnvConfig.DEEPSEEK_API_KEY);\n \n // 检查 API Key 是否配置\n if (apiKey == null || apiKey.isEmpty() || apiKey.startsWith(\"your-\")) {\n return \"错误:DeepSeek API Key 未配置。请在 .env 文件中设置 DEEPSEEK_API_KEY\";\n }\n \n String apiBase = EnvConfig.get(EnvConfig.DEEPSEEK_API_BASE, \"https://api.deepseek.com/v1\");\n String model = EnvConfig.get(EnvConfig.DEEPSEEK_MODEL, \"deepseek-chat\");\n \n URL url = new URL(apiBase + \"/chat/completions\");\n HttpURLConnection conn = (HttpURLConnection) url.openConnection();\n \n try {\n conn.setRequestMethod(\"POST\");\n conn.setRequestProperty(\"Content-Type\", \"application/json\");\n conn.setRequestProperty(\"Authorization\", \"Bearer \" + apiKey);\n conn.setDoOutput(true);\n conn.setConnectTimeout(CONNECT_TIMEOUT);\n conn.setReadTimeout(READ_TIMEOUT);\n \n // 构建消息列表\n List> messages = new ArrayList<>();\n \n if (systemPrompt != null && !systemPrompt.isEmpty()) {\n Map systemMsg = new HashMap<>();\n systemMsg.put(\"role\", \"system\");\n systemMsg.put(\"content\", systemPrompt);\n messages.add(systemMsg);\n }\n \n Map userMsg = new HashMap<>();\n userMsg.put(\"role\", \"user\");\n userMsg.put(\"content\", message);\n messages.add(userMsg);\n \n // 构建请求体\n Map requestBody = new HashMap<>();\n requestBody.put(\"model\", model);\n requestBody.put(\"messages\", messages);\n requestBody.put(\"max_tokens\", 2000);\n \n String jsonRequest = objectMapper.writeValueAsString(requestBody);\n \n try (OutputStream os = conn.getOutputStream()) {\n os.write(jsonRequest.getBytes(StandardCharsets.UTF_8));\n }\n \n int responseCode = conn.getResponseCode();\n \n if (responseCode == HttpURLConnection.HTTP_OK) {\n return parseResponse(conn);\n } else {\n return parseError(conn, responseCode);\n }\n } finally {\n conn.disconnect();\n }\n }\n \n private String parseResponse(HttpURLConnection conn) throws IOException {\n try (BufferedReader reader = new BufferedReader(\n new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {\n StringBuilder response = new StringBuilder();\n String line;\n while ((line = reader.readLine()) != null) {\n response.append(line);\n }\n JsonNode jsonResponse = objectMapper.readTree(response.toString());\n return jsonResponse.path(\"choices\").path(0).path(\"message\").path(\"content\").asText();\n }\n }\n \n private String parseError(HttpURLConnection conn, int responseCode) throws IOException {\n try (BufferedReader reader = new BufferedReader(\n new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) {\n StringBuilder error = new StringBuilder();\n String line;\n while ((line = reader.readLine()) != null) {\n error.append(line);\n }\n return \"API 调用失败 (\" + responseCode + \"): \" + error;\n }\n }\n}\n"}}}}