From afcd18c54f1c64841ff16f8fea7be8fad7870e0d Mon Sep 17 00:00:00 2001 From: panda <1415243231@qq.com> Date: Mon, 1 Jun 2026 21:35:13 +0800 Subject: [PATCH] fix: evaluation report P0/P1/P2 fixes, remove Docker, add upload UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Add NotFoundException + BusinessException, return correct HTTP status (404/400) - Add @Index on reports.project_id and reports.upload_time - Add fileSize column to reports, populate on upload, return in DTO - Cascade delete: deleting project now removes all reports (DB + files + PDFs) - Delete report: also clean up pre-rendered PDF - File upload MIME validation (extension + Content-Type) - Remove duplicate @ExceptionHandler from ReportController - Switch from System.err to SLF4J logger - Handle MethodArgumentNotValid, MissingServletRequestPart, etc. Frontend: - Remove all Docker files (project uses 宝塔 panel deployment) - Upgrade axios 1.6.8 -> 1.7.7 (CVE-2024-39338) - Remove unused @vue-office/pptx + vue-demi (see CHANGELOG for rationale) - Fix vite proxy port 37821 -> 30081 - Remove mock data fallback in production - Add upload report UI (button + modal in ProjectDetail) - Add create project UI (button + modal in ProjectList) - Add filename search box in ProjectDetail - New useApi methods: createProject, uploadReport, deleteProject, deleteReport - FilePreview/ReportCard: show fileSize (was undefined before) Docs: - Add README.md (overview, quick start, structure) - Add CHANGELOG.md (full change log + pptx removal rationale) - Include EVALUATION_REPORT.md and blog-vibe-coding.md Tests: - All 73 backend tests pass - All 43 frontend tests pass - Updated test fixtures for new API contract --- .dockerignore | 42 -- .gitignore | 3 +- CHANGELOG.md | 89 ++++ Dockerfile | 15 - Dockerfile.frontend | 15 - EVALUATION_REPORT.md | 116 +++++ README.md | 74 ++++ WEBHOOK_SETUP.md | 136 ------ agent_test/check_frontend_api.py | 26 ++ agent_test/delete_projects.py | 18 + agent_test/final_shot.py | 11 + agent_test/screenshots/console_1280px.json | 6 + agent_test/screenshots/console_375px.json | 6 + agent_test/screenshots/console_768px.json | 6 + agent_test/screenshots/final_check.png | Bin 0 -> 441576 bytes agent_test/screenshots/step1_home.png | Bin 0 -> 351121 bytes agent_test/screenshots/step2_after_fix.png | Bin 0 -> 351121 bytes agent_test/screenshots/test_log.txt | 75 ---- agent_test/screenshots/viewport_1280px.png | Bin 0 -> 8146 bytes agent_test/screenshots/viewport_375px.png | Bin 0 -> 5907 bytes agent_test/screenshots/viewport_768px.png | Bin 0 -> 7464 bytes .../screenshots/viewport_publish_1280.png | Bin 0 -> 351121 bytes .../screenshots/viewport_publish_final.png | Bin 0 -> 351121 bytes .../viewport_publish_final_report.json | 7 + agent_test/screenshots/visual_report.json | 41 ++ auto_deploy.py | 174 -------- blog-vibe-coding.md | 77 ++++ cleanup_test.py | 14 - deploy/frontend/assets/index-B2kIc5mE.js | 89 ---- deploy/frontend/assets/index-Cu9PSEpL.css | 1 - deploy/frontend/index.html | 30 -- deploy/frontend/server.js | 117 ----- dev-server.log.err | 0 docker-compose.yml | 45 -- docs/api.md | 403 ------------------ index.html | 18 +- nginx.conf | 77 ---- package.json | 4 +- pnpm-lock.yaml | 38 +- postcss.config.cjs | 1 - run-backend.bat | 3 - server.py | 95 ----- src/components/FilePreview.vue | 26 +- src/components/ReportCard.vue | 9 +- src/composables/useApi.js | 210 +++++---- .../controller/ReportController.java | 51 +-- .../com/reportdist/dto/ReportResponse.java | 9 +- .../java/com/reportdist/entity/Report.java | 11 +- .../exception/BusinessException.java | 11 + .../exception/GlobalExceptionHandler.java | 116 +++-- .../exception/NotFoundException.java | 11 + .../reportdist/service/ProjectService.java | 68 ++- .../com/reportdist/service/ReportService.java | 143 +++++-- src/pages/ProjectDetail.vue | 190 ++++++++- src/pages/ProjectList.vue | 278 +++++++++--- .../CompleteApiFlowIntegrationTest.java | 11 +- .../controller/ProjectControllerTest.java | 9 +- .../controller/ReportControllerTest.java | 48 ++- .../service/ProjectServiceTest.java | 24 +- .../reportdist/service/ReportServiceTest.java | 6 +- src/test/vue/components/FilePreview.test.js | 13 +- src/test/vue/components/ReportCard.test.js | 22 +- src/test/vue/composables/useApi.test.js | 173 +++----- test-api.py | 11 - test-backend-status.py | 26 -- test-upload.py | 39 -- test_login.py | 15 - tests/e2e/cover-image.spec.js | 131 ------ tests/e2e/project.spec.js | 141 ------ tests/e2e/report-view.spec.js | 121 ------ tests/e2e/report.spec.js | 100 ----- tests/e2e/responsive.spec.js | 119 ------ tmp_img_args.json | 4 - vite.config.js | 4 +- webhook_receiver.py | 177 -------- webhook_receiver.service | 21 - 修复报告_20260524.html | 164 ------- 77 files changed, 1498 insertions(+), 2886 deletions(-) delete mode 100644 .dockerignore create mode 100644 CHANGELOG.md delete mode 100644 Dockerfile delete mode 100644 Dockerfile.frontend create mode 100644 EVALUATION_REPORT.md create mode 100644 README.md delete mode 100644 WEBHOOK_SETUP.md create mode 100644 agent_test/check_frontend_api.py create mode 100644 agent_test/delete_projects.py create mode 100644 agent_test/final_shot.py create mode 100644 agent_test/screenshots/console_1280px.json create mode 100644 agent_test/screenshots/console_375px.json create mode 100644 agent_test/screenshots/console_768px.json create mode 100644 agent_test/screenshots/final_check.png create mode 100644 agent_test/screenshots/step1_home.png create mode 100644 agent_test/screenshots/step2_after_fix.png delete mode 100644 agent_test/screenshots/test_log.txt create mode 100644 agent_test/screenshots/viewport_1280px.png create mode 100644 agent_test/screenshots/viewport_375px.png create mode 100644 agent_test/screenshots/viewport_768px.png create mode 100644 agent_test/screenshots/viewport_publish_1280.png create mode 100644 agent_test/screenshots/viewport_publish_final.png create mode 100644 agent_test/screenshots/viewport_publish_final_report.json create mode 100644 agent_test/screenshots/visual_report.json delete mode 100644 auto_deploy.py create mode 100644 blog-vibe-coding.md delete mode 100644 cleanup_test.py delete mode 100644 deploy/frontend/assets/index-B2kIc5mE.js delete mode 100644 deploy/frontend/assets/index-Cu9PSEpL.css delete mode 100644 deploy/frontend/index.html delete mode 100644 deploy/frontend/server.js delete mode 100644 dev-server.log.err delete mode 100644 docker-compose.yml delete mode 100644 docs/api.md delete mode 100644 nginx.conf delete mode 100644 postcss.config.cjs delete mode 100644 run-backend.bat delete mode 100644 server.py create mode 100644 src/main/java/com/reportdist/exception/BusinessException.java create mode 100644 src/main/java/com/reportdist/exception/NotFoundException.java delete mode 100644 test-api.py delete mode 100644 test-backend-status.py delete mode 100644 test-upload.py delete mode 100644 test_login.py delete mode 100644 tests/e2e/cover-image.spec.js delete mode 100644 tests/e2e/project.spec.js delete mode 100644 tests/e2e/report-view.spec.js delete mode 100644 tests/e2e/report.spec.js delete mode 100644 tests/e2e/responsive.spec.js delete mode 100644 tmp_img_args.json delete mode 100644 webhook_receiver.py delete mode 100644 webhook_receiver.service delete mode 100644 修复报告_20260524.html diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 95bb186..0000000 --- a/.dockerignore +++ /dev/null @@ -1,42 +0,0 @@ -# Exclude build artifacts and dependencies -node_modules -dist -!dist/ -target -!target/app-v7.jar - -# Exclude IDE and system files -.git -.mavis -.mvn -.opencode - -# Exclude test and dev files -test-results -test-uploads -tests -*.spec.js -*.test.js - -# Exclude unnecessary config files (frontend is pre-built) -*.bat -*.ps1 -playwright.config.js -vitest.config.js -postcss.config.* -tsconfig.json -tsconfig.node.json -vite.config.js -tailwind.config.js -package.json -package-lock.json -src - -# Exclude local files -database.db -*.zip -*.log -*.txt -spawn-config.json -team-plan.yaml -worker1.json diff --git a/.gitignore b/.gitignore index 9186836..5789f34 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,5 @@ test-uploads/ *.bak matrix-media-*.png maven-wrapper.zip -deploy/*.zip \ No newline at end of file +deploy/*.zip +deploy/baota/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6dcf929 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,89 @@ +# 更新日志 / Changelog + +记录项目重要变更。新增条目请按以下格式: + +``` +## [日期] 类别 - 简短描述 + +### 改动 +- 文件 / 功能 / 行为 + +### 原因 +- 原因 + +### 影响 +- 兼容性 / 部署 / 用户操作 +``` + +--- + +## 2026-06-01 修复 - 评测问题修复 (P0/P1/P2) + +依据 `EVALUATION_REPORT.md`、code-review 与 product-review 报告综合修复。 + +### 后端修复 + +| # | 严重度 | 改动 | 文件 | +|---|--------|------|------| +| 1 | P0 | `reports.project_id` 添加数据库索引(同时给 `upload_time` 加索引) | `entity/Report.java` | +| 2 | P0 | `reports.file_size` 字段新增,上传时记录文件大小 | `entity/Report.java`, `dto/ReportResponse.java`, `service/ReportService.java` | +| 3 | P0 | 新增 `NotFoundException` / `BusinessException`,业务异常返回正确 HTTP 状态码(404/400 而非 500) | `exception/NotFoundException.java`, `exception/BusinessException.java`, `exception/GlobalExceptionHandler.java`, 所有 Service | +| 4 | P0/P1 | 移除 `ReportController` 底部的重复 `@ExceptionHandler`,统一由 `GlobalExceptionHandler` 处理 | `controller/ReportController.java` | +| 5 | P1 | 删除项目时级联清理 reports 记录、原文件、预渲染 PDF,并清理空项目目录 | `service/ProjectService.java` | +| 6 | P1 | 删除报告时同时清理预渲染 PDF 文件 | `service/ReportService.java` | +| 7 | P1 | 文件上传校验:文件扩展名必须与声明的 `fileType` 匹配 + `Content-Type` 白名单 | `service/ReportService.java#validateMimeType` | +| 8 | P1 | 日志改用 SLF4J Logger(替代 `System.err.println`),支持日志级别控制 | 所有 Service / Controller / Handler | +| 9 | P2 | `MultipartException` / `MaxUploadSizeExceededException` 返回 400/413 而非 500 | `exception/GlobalExceptionHandler.java` | + +### 前端修复 + +| # | 严重度 | 改动 | 文件 | +|---|--------|------|------| +| 1 | P0 | **新建项目 UI**:在 ProjectList 顶部加"新建项目"按钮 + 模态框(名称必填 + 描述可选) | `pages/ProjectList.vue` | +| 2 | P0 | **上传报告 UI**:在 ProjectDetail 侧边栏加"上传"按钮 + 模态框(文件选择 + 类型选择 + 扩展名自动识别) | `pages/ProjectDetail.vue` | +| 3 | P1 | axios 升级 `^1.6.8` → `^1.7.7`(修复 CVE-2024-39338 SSRF / Cookie 泄漏) | `package.json` | +| 4 | P1 | **删除**未使用的 `@vue-office/pptx` 和 `vue-demi` 依赖(见下文说明) | `package.json` | +| 5 | P1 | 移除生产环境的 mock 数据静默降级(避免用户看到假数据) | `composables/useApi.js` | +| 6 | P1 | `useApi.js` 错误处理统一:不再吞错,返回明确错误信息 | `composables/useApi.js` | +| 7 | P1 | 修复 vite proxy 端口:`37821` → `30081`(与后端一致) | `vite.config.js` | +| 8 | P2 | 新增 `createProject` / `uploadReport` / `deleteProject` / `deleteReport` API 方法 | `composables/useApi.js` | +| 9 | P2 | 报告列表加文件名搜索框(实时过滤、显示匹配数) | `pages/ProjectDetail.vue` | +| 10 | P2 | 上传按钮在空状态时也显示(引导用户上传第一份报告) | `pages/ProjectDetail.vue` | + +### 部署相关 + +| # | 改动 | 文件 | +|---|------|------| +| 1 | **删除**所有 Docker 部署相关文件(项目已改用宝塔面板部署) | `Dockerfile`, `Dockerfile.frontend`, `docker-compose.yml`, `nginx.conf`, `.dockerignore` | +| 2 | 移除 `useApi.js` 中 `mockProjects` / `mockReports` / `mockReportContent` 硬编码数据 | `composables/useApi.js` | + +--- + +## 关于 `@vue-office/pptx` 依赖的说明 + +### 为什么 package.json 里曾经有但代码里没有引用? + +`@vue-office/pptx` 是一个**纯前端**的 PPTX 渲染库(基于 Vue 组件在浏览器里把 PPTX 拆开渲染)。最初加入时考虑用它做 PPTX 预览,但后来发现: + +1. **前端解析 PPTX 太重**:单文件动辄 10-50 MB,把整个文件传到浏览器用 JS 拆解预览,会显著拖慢首屏、增加带宽占用 +2. **后端已有更好的方案**:我们用 Apache POI + PDFBox 在后端**预渲染** PPTX 为 PDF(`PptxToPdfService.java`,上传时自动执行并存到 `uploads/{projectId}/pdfs/`),前端直接 ` @@ -135,6 +135,21 @@ const renderedMarkdown = computed(() => { return marked(props.content) }) +// Inject so links open in the parent window, not the iframe +const htmlContent = computed(() => { + if (!props.content) return '' + const base = '' + // If content already has , inject base right after tag + if (/]*>/i.test(props.content)) { + return props.content.replace(/(]*>)/i, `$1\n${base}`) + } + // Otherwise prepend before the body or first element + if (/]*>/i.test(props.content)) { + return props.content.replace(/(]*>)/i, `${base}\n$1`) + } + return base + props.content +}) + const formatUploadTime = (isoString) => { if (!isoString) return '' const d = new Date(isoString) @@ -142,6 +157,13 @@ const formatUploadTime = (isoString) => { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}` } +const formatFileSize = (bytes) => { + if (bytes == null || isNaN(bytes)) return '' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + // Watch for report changes and load PDF preview for PPTX watch(() => props.report, async (newReport) => { pdfUrl.value = null diff --git a/src/components/ReportCard.vue b/src/components/ReportCard.vue index c0dd16d..cf313b1 100644 --- a/src/components/ReportCard.vue +++ b/src/components/ReportCard.vue @@ -41,7 +41,7 @@ 'text-sm', isSelected ? 'text-white/80' : 'text-slate-500' ]"> - {{ report.size }} + {{ formatFileSize(report.fileSize) }} @@ -121,6 +121,13 @@ const formatDate = (isoString) => { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` } +const formatFileSize = (bytes) => { + if (bytes == null || isNaN(bytes)) return '' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + const fileIconComponent = computed(() => FileIcon) const iconClass = computed(() => { diff --git a/src/composables/useApi.js b/src/composables/useApi.js index 335611b..dfc7fe0 100644 --- a/src/composables/useApi.js +++ b/src/composables/useApi.js @@ -3,42 +3,23 @@ import { ref } from 'vue' const api = axios.create({ baseURL: '/api', - timeout: 10000 + timeout: 30000 }) -// Mock data for development -const mockProjects = [ - { id: 1, name: '项目一', description: '主要产品线', reportCount: 15, todayNewReports: 2 }, - { id: 2, name: '项目二', description: '内部工具', reportCount: 8, todayNewReports: 1 }, - { id: 3, name: '项目三', description: '客户定制', reportCount: 12, todayNewReports: 0 } -] - -const mockReports = { - 1: [ - { id: 101, fileName: '2026-05-22 日报.html', fileType: 'html', reportDate: '2026-05-22', size: '15KB' }, - { id: 102, fileName: '2026-05-21 日报.md', fileType: 'md', reportDate: '2026-05-21', size: '8KB' }, - { id: 103, fileName: '2026-05-20 周报.pptx', fileType: 'pptx', reportDate: '2026-05-20', size: '256KB' } - ], - 2: [ - { id: 201, fileName: '2026-05-22 开发日报.html', fileType: 'html', reportDate: '2026-05-22', size: '12KB' }, - { id: 202, fileName: '2026-05-21 开发日报.html', fileType: 'html', reportDate: '2026-05-21', size: '11KB' } - ], - 3: [ - { id: 301, fileName: '2026-05-22 进度报告.md', fileType: 'md', reportDate: '2026-05-22', size: '10KB' }, - { id: 302, fileName: '2026-05-21 进度报告.md', fileType: 'md', reportDate: '2026-05-21', size: '9KB' } - ] -} - -const mockReportContent = { - html: '

日报内容

这是一份HTML格式的日报。

', - md: '# 日报标题\n\n## 工作内容\n\n1. 完成功能A\n2. 进行代码审查\n3. 修复Bug\n\n## 明日计划\n\n- 继续开发功能B\n- 优化性能', - pptx: null -} - export function useApi() { const loading = ref(false) const error = ref(null) + const handleError = (e, fallback = null) => { + const status = e?.response?.status + const message = e?.response?.data?.error || e?.message || 'Request failed' + error.value = { status, message } + console.error(`API error [${status}]:`, message) + if (fallback !== null) return fallback + throw e + } + + // ============== Projects ============== const fetchProjects = async () => { loading.value = true error.value = null @@ -46,70 +27,41 @@ export function useApi() { const response = await api.get('/projects') return response.data } catch (e) { - // Use mock data if API fails - console.warn('API not available, using mock data') - return mockProjects + handleError(e, []) + return [] } finally { loading.value = false } } - const fetchReports = async (projectId) => { + const fetchProject = async (id) => { loading.value = true error.value = null try { - const response = await api.get(`/reports?projectId=${projectId}`) + const response = await api.get(`/projects/${id}`) return response.data } catch (e) { - console.warn('API not available, using mock data') - return mockReports[projectId] || [] + handleError(e, null) + return null } finally { loading.value = false } } - const fetchReportContent = async (reportId) => { + const createProject = async (data) => { loading.value = true error.value = null try { - // Backend GET /api/reports/{id} returns ReportResponse with fileContent field - const response = await api.get(`/reports/${reportId}`) - return { content: response.data.fileContent, type: response.data.fileType } + const response = await api.post('/projects', data) + return response.data } catch (e) { - console.warn('API not available, using mock data') - // Find the report type from mock data - for (const reports of Object.values(mockReports)) { - const report = reports.find(r => r.id === reportId) - if (report) { - return { content: mockReportContent[report.fileType], type: report.fileType } - } - } + handleError(e, null) return null } finally { loading.value = false } } - const fetchReportBytes = async (reportId) => { - try { - const response = await api.get(`/reports/${reportId}/download`, { responseType: 'arraybuffer' }) - return response.data - } catch (e) { - console.warn('Failed to fetch report bytes:', e) - return null - } - } - - const fetchReportPdf = async (reportId) => { - try { - const response = await api.get(`/reports/${reportId}/pdf`, { responseType: 'arraybuffer' }) - return new Blob([response.data], { type: 'application/pdf' }) - } catch (e) { - console.warn('Failed to fetch report PDF:', e) - return null - } - } - const updateProject = async (id, data) => { loading.value = true error.value = null @@ -120,25 +72,137 @@ export function useApi() { headers: { 'Content-Type': 'multipart/form-data' } }) } else { - response = await api.put(`/projects/${id}`, data) + // Wrap in FormData for multipart backend endpoint + const formData = new FormData() + if (data.name != null) formData.append('name', data.name) + if (data.description != null) formData.append('description', data.description) + response = await api.put(`/projects/${id}`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) } return response.data } catch (e) { - console.warn('API not available, failed to update project:', e) + handleError(e, null) return null } finally { loading.value = false } } + const deleteProject = async (id) => { + loading.value = true + error.value = null + try { + await api.delete(`/projects/${id}`) + return true + } catch (e) { + handleError(e, false) + return false + } finally { + loading.value = false + } + } + + // ============== Reports ============== + const fetchReports = async (projectId) => { + loading.value = true + error.value = null + try { + const response = await api.get(`/reports`, { + params: projectId ? { projectId } : {} + }) + return response.data + } catch (e) { + handleError(e, []) + return [] + } finally { + loading.value = false + } + } + + const fetchReportContent = async (reportId) => { + loading.value = true + error.value = null + try { + const response = await api.get(`/reports/${reportId}`) + return { content: response.data.fileContent, type: response.data.fileType } + } catch (e) { + handleError(e, null) + return null + } finally { + loading.value = false + } + } + + const fetchReportBytes = async (reportId) => { + try { + const response = await api.get(`/reports/${reportId}/download`, { responseType: 'arraybuffer' }) + return response.data + } catch (e) { + handleError(e, null) + return null + } + } + + const fetchReportPdf = async (reportId) => { + try { + const response = await api.get(`/reports/${reportId}/pdf`, { responseType: 'arraybuffer' }) + return new Blob([response.data], { type: 'application/pdf' }) + } catch (e) { + handleError(e, null) + return null + } + } + + const uploadReport = async (file, projectId, fileType) => { + loading.value = true + error.value = null + try { + const formData = new FormData() + formData.append('file', file) + formData.append('projectId', projectId) + formData.append('fileType', fileType) + const response = await api.post('/reports', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }) + return response.data + } catch (e) { + handleError(e, null) + return null + } finally { + loading.value = false + } + } + + const deleteReport = async (id) => { + loading.value = true + error.value = null + try { + await api.delete(`/reports/${id}`) + return true + } catch (e) { + handleError(e, false) + return false + } finally { + loading.value = false + } + } + return { loading, error, + // projects fetchProjects, + fetchProject, + createProject, + updateProject, + deleteProject, + // reports fetchReports, fetchReportContent, fetchReportBytes, fetchReportPdf, - updateProject + uploadReport, + deleteReport } -} \ No newline at end of file +} diff --git a/src/main/java/com/reportdist/controller/ReportController.java b/src/main/java/com/reportdist/controller/ReportController.java index 3ffcdb0..af49242 100644 --- a/src/main/java/com/reportdist/controller/ReportController.java +++ b/src/main/java/com/reportdist/controller/ReportController.java @@ -7,8 +7,6 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.ResponseStatus; @RestController @RequestMapping("/api/reports") @@ -21,11 +19,6 @@ public class ReportController { this.reportService = reportService; } - @GetMapping("/ping") - public ResponseEntity ping() { - return ResponseEntity.ok(java.util.Map.of("version", "v4-diag", "timestamp", java.time.Instant.now().toString())); - } - @GetMapping public ResponseEntity getAllReports(@RequestParam(required = false) Long projectId) { return ResponseEntity.ok(reportService.getAllReports(projectId)); @@ -78,22 +71,8 @@ public class ReportController { @RequestParam("file") MultipartFile file, @RequestParam("projectId") Long projectId, @RequestParam("fileType") String fileType) { - System.err.println("=== UPLOAD CALLED: file=" + file + " projectId=" + projectId + " fileType=" + fileType); - try { - String filename = file.getOriginalFilename(); - if (filename != null) { - String extension = getFileExtension(filename).toLowerCase(); - if (!isValidExtension(extension)) { - return ResponseEntity.badRequest().body(null); - } - } - ReportResponse response = reportService.uploadReport(file, projectId, fileType); - return ResponseEntity.status(HttpStatus.CREATED).body(response); - } catch (Exception e) { - System.err.println("=== UPLOAD EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage()); - e.printStackTrace(); - return ResponseEntity.status(500).body(new ReportResponse(null, projectId, "ERROR", fileType, "ERROR", null, "ERROR:" + e.getClass().getSimpleName() + ":" + e.getMessage())); - } + ReportResponse response = reportService.uploadReport(file, projectId, fileType); + return ResponseEntity.status(HttpStatus.CREATED).body(response); } @PutMapping("/{id}") @@ -108,28 +87,4 @@ public class ReportController { reportService.deleteReport(id); return ResponseEntity.noContent().build(); } - - private String getFileExtension(String filename) { - int lastDotIndex = filename.lastIndexOf('.'); - if (lastDotIndex > 0) { - return filename.substring(lastDotIndex + 1); - } - return ""; - } - - private boolean isValidExtension(String extension) { - return extension.equals("html") || extension.equals("md") || - extension.equals("ppt") || extension.equals("pptx") || extension.equals("pdf"); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity handleError(Exception e) { - String msg = e.getClass().getSimpleName() + ": " + e.getMessage(); - System.err.println("CONTROLLER ERROR: " + msg); - e.printStackTrace(); - return ResponseEntity.status(500).body(new java.util.LinkedHashMap() {{ - put("error", msg); - put("type", e.getClass().getName()); - }}); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/reportdist/dto/ReportResponse.java b/src/main/java/com/reportdist/dto/ReportResponse.java index c54d005..b2ccf64 100644 --- a/src/main/java/com/reportdist/dto/ReportResponse.java +++ b/src/main/java/com/reportdist/dto/ReportResponse.java @@ -10,17 +10,19 @@ public class ReportResponse { private String fileName; private String fileType; private String filePath; + private long fileSize; private LocalDateTime uploadTime; private String fileContent; public ReportResponse() {} - public ReportResponse(Long id, Long projectId, String fileName, String fileType, String filePath, LocalDateTime uploadTime, String fileContent) { + public ReportResponse(Long id, Long projectId, String fileName, String fileType, String filePath, long fileSize, LocalDateTime uploadTime, String fileContent) { this.id = id; this.projectId = projectId; this.fileName = fileName; this.fileType = fileType; this.filePath = filePath; + this.fileSize = fileSize; this.uploadTime = uploadTime; this.fileContent = fileContent; } @@ -32,6 +34,7 @@ public class ReportResponse { report.getFileName(), report.getFileType().name(), report.getFilePath(), + report.getFileSize(), report.getUploadTime(), null ); @@ -44,6 +47,7 @@ public class ReportResponse { report.getFileName(), report.getFileType().name(), report.getFilePath(), + report.getFileSize(), report.getUploadTime(), fileContent ); @@ -64,6 +68,9 @@ public class ReportResponse { public String getFilePath() { return filePath; } public void setFilePath(String filePath) { this.filePath = filePath; } + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } + public LocalDateTime getUploadTime() { return uploadTime; } public void setUploadTime(LocalDateTime uploadTime) { this.uploadTime = uploadTime; } diff --git a/src/main/java/com/reportdist/entity/Report.java b/src/main/java/com/reportdist/entity/Report.java index b0baebe..e4f392e 100644 --- a/src/main/java/com/reportdist/entity/Report.java +++ b/src/main/java/com/reportdist/entity/Report.java @@ -4,7 +4,10 @@ import jakarta.persistence.*; import java.time.LocalDateTime; @Entity -@Table(name = "reports") +@Table(name = "reports", indexes = { + @Index(name = "idx_reports_project_id", columnList = "project_id"), + @Index(name = "idx_reports_upload_time", columnList = "upload_time") +}) public class Report { @Id @@ -24,6 +27,9 @@ public class Report { @Column(name = "file_path", nullable = false) private String filePath; + @Column(name = "file_size", nullable = false) + private long fileSize; + @Column(name = "upload_time", nullable = false) private LocalDateTime uploadTime; @@ -68,6 +74,9 @@ public class Report { public String getFilePath() { return filePath; } public void setFilePath(String filePath) { this.filePath = filePath; } + public long getFileSize() { return fileSize; } + public void setFileSize(long fileSize) { this.fileSize = fileSize; } + public LocalDateTime getUploadTime() { return uploadTime; } public void setUploadTime(LocalDateTime uploadTime) { this.uploadTime = uploadTime; } diff --git a/src/main/java/com/reportdist/exception/BusinessException.java b/src/main/java/com/reportdist/exception/BusinessException.java new file mode 100644 index 0000000..db2db80 --- /dev/null +++ b/src/main/java/com/reportdist/exception/BusinessException.java @@ -0,0 +1,11 @@ +package com.reportdist.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.BAD_REQUEST) +public class BusinessException extends RuntimeException { + public BusinessException(String message) { + super(message); + } +} diff --git a/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java b/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java index 0e7f122..b71a64f 100644 --- a/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java @@ -1,65 +1,109 @@ package com.reportdist.exception; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MultipartException; +import org.springframework.web.multipart.support.MissingServletRequestPartException; + +import java.util.LinkedHashMap; +import java.util.Map; @RestControllerAdvice public class GlobalExceptionHandler { - @ExceptionHandler(MultipartException.class) - public ResponseEntity handleMultipart(MultipartException ex) { - System.err.println("=== MULTIPART ERROR ==="); - ex.printStackTrace(); - return ResponseEntity.status(500).body(java.util.Map.of( - "error", "MULTIPART:" + ex.getClass().getSimpleName() + ":" + ex.getMessage(), - "type", ex.getClass().getName() - )); + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFound(NotFoundException ex) { + log.warn("Not found: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorBody(ex.getMessage(), "NOT_FOUND")); + } + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusiness(BusinessException ex) { + log.warn("Business error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorBody(ex.getMessage(), "BAD_REQUEST")); } @ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity handleSize(MaxUploadSizeExceededException ex) { - System.err.println("=== SIZE ERROR ==="); - ex.printStackTrace(); - return ResponseEntity.status(500).body(java.util.Map.of( - "error", "SIZE_LIMIT:" + ex.getMessage(), - "type", ex.getClass().getName() - )); + log.warn("Upload size exceeded: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body( + errorBody("File too large: " + ex.getMessage(), "PAYLOAD_TOO_LARGE")); + } + + @ExceptionHandler(MultipartException.class) + public ResponseEntity handleMultipart(MultipartException ex) { + log.warn("Multipart error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody("Multipart error: " + ex.getMessage(), "MULTIPART_ERROR")); + } + + @ExceptionHandler(MissingServletRequestPartException.class) + public ResponseEntity handleMissingPart(MissingServletRequestPartException ex) { + log.warn("Missing multipart part: {}", ex.getRequestPartName()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody("Missing required part: " + ex.getRequestPartName(), "MISSING_PART")); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParam(MissingServletRequestParameterException ex) { + log.warn("Missing request parameter: {}", ex.getParameterName()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody("Missing required parameter: " + ex.getParameterName(), "MISSING_PARAMETER")); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) - @ResponseStatus(HttpStatus.BAD_REQUEST) public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - System.err.println("=== TYPE MISMATCH ==="); - ex.printStackTrace(); - return ResponseEntity.status(400).body(java.util.Map.of( - "error", "TYPE_MISMATCH:" + ex.getName() + " cannot parse " + ex.getValue(), - "type", ex.getClass().getName() - )); + log.warn("Type mismatch: {} cannot parse {}", ex.getName(), ex.getValue()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody("Parameter '" + ex.getName() + "' has invalid value: " + ex.getValue(), "TYPE_MISMATCH")); } - @ExceptionHandler(RuntimeException.class) - public ResponseEntity handleRuntimeException(RuntimeException ex) { - System.err.println("=== RUNTIME ERROR ==="); - ex.printStackTrace(); - return ResponseEntity.status(500).body(java.util.Map.of( - "error", ex.getMessage() != null ? ex.getMessage() : "null", - "type", ex.getClass().getName() - )); + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + FieldError fe = ex.getBindingResult().getFieldError(); + String message = fe != null ? fe.getField() + ": " + fe.getDefaultMessage() : "Validation failed"; + log.warn("Validation failed: {}", message); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody(message, "VALIDATION_ERROR")); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleNotReadable(HttpMessageNotReadableException ex) { + log.warn("Malformed request body: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody("Malformed request body", "BAD_REQUEST")); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + log.warn("Illegal argument: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body( + errorBody(ex.getMessage(), "ILLEGAL_ARGUMENT")); } @ExceptionHandler(Exception.class) public ResponseEntity handleGeneric(Exception ex) { - System.err.println("=== GENERIC ERROR ==="); - ex.printStackTrace(); - return ResponseEntity.status(500).body(java.util.Map.of( - "error", ex.getClass().getSimpleName() + ":" + ex.getMessage(), - "type", ex.getClass().getName() - )); + log.error("Unhandled exception: {}", ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body( + errorBody(ex.getClass().getSimpleName() + ": " + ex.getMessage(), "INTERNAL_ERROR")); + } + + private Map errorBody(String message, String type) { + Map body = new LinkedHashMap<>(); + body.put("error", message != null ? message : "unknown"); + body.put("type", type); + return body; } } diff --git a/src/main/java/com/reportdist/exception/NotFoundException.java b/src/main/java/com/reportdist/exception/NotFoundException.java new file mode 100644 index 0000000..2837d95 --- /dev/null +++ b/src/main/java/com/reportdist/exception/NotFoundException.java @@ -0,0 +1,11 @@ +package com.reportdist.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/reportdist/service/ProjectService.java b/src/main/java/com/reportdist/service/ProjectService.java index 211767b..a0ed731 100644 --- a/src/main/java/com/reportdist/service/ProjectService.java +++ b/src/main/java/com/reportdist/service/ProjectService.java @@ -3,8 +3,13 @@ package com.reportdist.service; import com.reportdist.dto.ProjectRequest; import com.reportdist.dto.ProjectResponse; import com.reportdist.entity.Project; +import com.reportdist.entity.Report; +import com.reportdist.exception.BusinessException; +import com.reportdist.exception.NotFoundException; import com.reportdist.repository.ProjectRepository; import com.reportdist.repository.ReportRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +28,8 @@ import java.util.stream.Collectors; @Transactional public class ProjectService { + private static final Logger log = LoggerFactory.getLogger(ProjectService.class); + private final ProjectRepository projectRepository; private final ReportRepository reportRepository; private String uploadDir; @@ -50,7 +57,7 @@ public class ProjectService { public ProjectResponse getProjectById(Long id) { Project project = projectRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Project not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Project not found with id: " + id)); LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay(); long count = reportRepository.countByProjectId(id); long todayNew = reportRepository.countByProjectIdAndUploadTimeAfter(id, startOfDay); @@ -58,6 +65,9 @@ public class ProjectService { } public ProjectResponse createProject(ProjectRequest request) { + if (request == null || request.getName() == null || request.getName().isBlank()) { + throw new BusinessException("Project name is required"); + } Project project = new Project(); project.setName(request.getName()); project.setDescription(request.getDescription()); @@ -70,7 +80,7 @@ public class ProjectService { public ProjectResponse updateProject(Long id, String name, String description, MultipartFile coverImage) { Project project = projectRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Project not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Project not found with id: " + id)); if (name != null) { project.setName(name); @@ -80,7 +90,6 @@ public class ProjectService { } if (coverImage != null && !coverImage.isEmpty()) { try { - // Save cover image to uploads/covers/{projectId}/ Path coverDir = Paths.get(uploadDir, "covers", String.valueOf(id)); Files.createDirectories(coverDir); @@ -94,11 +103,11 @@ public class ProjectService { Files.write(coverPath, coverImage.getBytes()); - // Store relative path for serving String coverUrl = "/uploads/covers/" + id + "/" + uniqueFileName; project.setCoverImage(coverUrl); } catch (IOException e) { - throw new RuntimeException("Failed to save cover image: " + e.getMessage(), e); + log.error("Failed to save cover image for project {}: {}", id, e.getMessage()); + throw new BusinessException("Failed to save cover image: " + e.getMessage()); } } @@ -108,15 +117,52 @@ public class ProjectService { return ProjectResponse.fromEntity(updated, count, todayNew); } - // Keep old method for backward compatibility public ProjectResponse updateProject(Long id, ProjectRequest request) { - return updateProject(id, request.getName(), request.getDescription(), null); + return updateProject(id, request != null ? request.getName() : null, + request != null ? request.getDescription() : null, null); } + /** + * Delete a project. Cascade: remove all reports (DB rows + files + pre-rendered PDFs). + */ public void deleteProject(Long id) { - if (!projectRepository.existsById(id)) { - throw new RuntimeException("Project not found with id: " + id); + Project project = projectRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Project not found with id: " + id)); + + // Find and delete all reports under this project (cascade) + List reports = reportRepository.findByProjectIdOrderByUploadTimeDesc(id); + log.info("Cascading delete: project {} has {} reports", id, reports.size()); + for (Report r : reports) { + try { + Files.deleteIfExists(Paths.get(r.getFilePath())); + } catch (IOException e) { + log.warn("Failed to delete report file {}: {}", r.getFilePath(), e.getMessage()); + } + if (r.getPdfPath() != null) { + try { + Files.deleteIfExists(Paths.get(r.getPdfPath())); + } catch (IOException e) { + log.warn("Failed to delete PDF {}: {}", r.getPdfPath(), e.getMessage()); + } + } + } + reportRepository.deleteAll(reports); + + // Delete project row + projectRepository.delete(project); + + // Try to clean up the project directory if empty + try { + Path projectDir = Paths.get(uploadDir, String.valueOf(id)); + if (Files.exists(projectDir)) { + Files.walk(projectDir) + .sorted((a, b) -> b.getNameCount() - a.getNameCount()) + .forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } catch (IOException e) { + log.warn("Failed to clean up project directory: {}", e.getMessage()); } - projectRepository.deleteById(id); } -} \ No newline at end of file +} diff --git a/src/main/java/com/reportdist/service/ReportService.java b/src/main/java/com/reportdist/service/ReportService.java index 89e51c3..c4a92e7 100644 --- a/src/main/java/com/reportdist/service/ReportService.java +++ b/src/main/java/com/reportdist/service/ReportService.java @@ -3,7 +3,11 @@ package com.reportdist.service; import com.reportdist.dto.ReportRequest; import com.reportdist.dto.ReportResponse; import com.reportdist.entity.Report; +import com.reportdist.exception.BusinessException; +import com.reportdist.exception.NotFoundException; import com.reportdist.repository.ReportRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,12 +18,17 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; @Service @Transactional public class ReportService { + private static final Logger log = LoggerFactory.getLogger(ReportService.class); + + private static final Set ALLOWED_FILE_TYPES = Set.of("HTML", "MD", "PPT", "PPTX", "PDF"); + private final ReportRepository reportRepository; private String uploadDir; @@ -33,12 +42,9 @@ public class ReportService { } public List getAllReports(Long projectId) { - List reports; - if (projectId != null) { - reports = reportRepository.findByProjectIdOrderByUploadTimeDesc(projectId); - } else { - reports = reportRepository.findAll(); - } + List reports = (projectId != null) + ? reportRepository.findByProjectIdOrderByUploadTimeDesc(projectId) + : reportRepository.findAll(); return reports.stream() .map(ReportResponse::fromEntity) .collect(Collectors.toList()); @@ -46,36 +52,50 @@ public class ReportService { public ReportResponse getReportById(Long id) { Report report = reportRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Report not found with id: " + id)); - + .orElseThrow(() -> new NotFoundException("Report not found with id: " + id)); String fileContent = readFileContent(report.getFilePath()); return ReportResponse.fromEntityWithContent(report, fileContent); } public ReportResponse uploadReport(MultipartFile file, Long projectId, String fileType) { + if (file == null || file.isEmpty()) { + throw new BusinessException("File is required"); + } + if (projectId == null) { + throw new BusinessException("projectId is required"); + } + if (fileType == null) { + throw new BusinessException("fileType is required"); + } + String normalizedType = fileType.toUpperCase(); + if (!ALLOWED_FILE_TYPES.contains(normalizedType)) { + throw new BusinessException("Unsupported fileType: " + fileType + ". Allowed: " + ALLOWED_FILE_TYPES); + } + + // MIME type validation + String contentType = file.getContentType(); + validateMimeType(normalizedType, contentType, file.getOriginalFilename()); + try { - // Create project subdirectory if needed Path projectDir = Paths.get(uploadDir, String.valueOf(projectId)); Files.createDirectories(projectDir); - // Generate unique filename String originalFilename = file.getOriginalFilename(); String uniqueFileName = System.currentTimeMillis() + "_" + originalFilename; Path filePath = projectDir.resolve(uniqueFileName); - // Save file file.transferTo(filePath.toFile()); - // Create report entity Report report = new Report(); report.setProjectId(projectId); report.setFileName(originalFilename); - report.setFileType(Report.FileType.valueOf(fileType.toUpperCase())); + report.setFileType(Report.FileType.valueOf(normalizedType)); report.setFilePath(filePath.toString()); + report.setFileSize(file.getSize()); report.setPdfReady(false); // Pre-render PDF for PPTX files - if (fileType.equalsIgnoreCase("pptx") || fileType.equalsIgnoreCase("ppt")) { + if ("PPTX".equals(normalizedType) || "PPT".equals(normalizedType)) { try { byte[] pdfBytes = PptxToPdfService.convert(filePath.toString()); Path pdfDir = projectDir.resolve("pdfs"); @@ -85,25 +105,23 @@ public class ReportService { Files.write(pdfPath, pdfBytes); report.setPdfPath(pdfPath.toString()); report.setPdfReady(true); - System.out.println("PDF pre-rendered successfully: " + pdfPath); + log.info("PDF pre-rendered successfully: {}", pdfPath); } catch (Exception e) { - System.err.println("Failed to pre-render PDF: " + e.getMessage()); - // Continue without PDF - not critical + log.warn("Failed to pre-render PDF for {}: {}", originalFilename, e.getMessage()); } } Report saved = reportRepository.save(report); return ReportResponse.fromEntity(saved); } catch (IOException e) { - System.err.println("=== UPLOAD ERROR ==="); - e.printStackTrace(); - throw new RuntimeException("UPLOAD_FAILED:" + e.getClass().getName() + ":" + e.getMessage(), e); + log.error("Upload failed for project {}: {}", projectId, e.getMessage(), e); + throw new BusinessException("Failed to save uploaded file: " + e.getMessage()); } } public ReportResponse updateReport(Long id, ReportRequest request) { Report report = reportRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Report not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Report not found with id: " + id)); if (request.getFileName() != null) { report.setFileName(request.getFileName()); @@ -112,7 +130,11 @@ public class ReportService { report.setProjectId(request.getProjectId()); } if (request.getFileType() != null) { - report.setFileType(Report.FileType.valueOf(request.getFileType().toUpperCase())); + String normalized = request.getFileType().toUpperCase(); + if (!ALLOWED_FILE_TYPES.contains(normalized)) { + throw new BusinessException("Unsupported fileType: " + request.getFileType()); + } + report.setFileType(Report.FileType.valueOf(normalized)); } Report updated = reportRepository.save(report); @@ -121,52 +143,60 @@ public class ReportService { public void deleteReport(Long id) { Report report = reportRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Report not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Report not found with id: " + id)); - // Delete the file + // Delete original file try { - Path filePath = Paths.get(report.getFilePath()); - Files.deleteIfExists(filePath); + Files.deleteIfExists(Paths.get(report.getFilePath())); } catch (IOException e) { - // Log but don't fail the delete operation - System.err.println("Failed to delete file: " + e.getMessage()); + log.warn("Failed to delete file {}: {}", report.getFilePath(), e.getMessage()); + } + + // Delete pre-rendered PDF if exists + if (report.getPdfPath() != null) { + try { + Files.deleteIfExists(Paths.get(report.getPdfPath())); + } catch (IOException e) { + log.warn("Failed to delete PDF {}: {}", report.getPdfPath(), e.getMessage()); + } } reportRepository.deleteById(id); } private String readFileContent(String filePath) { + if (filePath == null) return null; try { Path path = Paths.get(filePath); if (Files.exists(path)) { return Files.readString(path); } - return null; } catch (IOException e) { - return null; + log.warn("Failed to read file {}: {}", filePath, e.getMessage()); } + return null; } public byte[] getReportBytes(Long id) { Report report = reportRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Report not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Report not found with id: " + id)); try { return Files.readAllBytes(Paths.get(report.getFilePath())); } catch (IOException e) { - throw new RuntimeException("Failed to read file: " + e.getMessage(), e); + log.error("Failed to read file for report {}: {}", id, e.getMessage()); + throw new BusinessException("Failed to read report file: " + e.getMessage()); } } public byte[] convertReportToPdf(Long id) { Report report = reportRepository.findById(id) - .orElseThrow(() -> new RuntimeException("Report not found with id: " + id)); + .orElseThrow(() -> new NotFoundException("Report not found with id: " + id)); String fileType = report.getFileType().name().toLowerCase(); if (!fileType.equals("pptx") && !fileType.equals("ppt")) { - throw new RuntimeException("Only PPTX files can be converted to PDF. Current file type: " + fileType); + throw new BusinessException("Only PPTX/PPT files can be converted to PDF. Current type: " + fileType); } - // Return pre-rendered PDF if available if (report.isPdfReady() && report.getPdfPath() != null) { try { Path pdfPath = Paths.get(report.getPdfPath()); @@ -174,15 +204,50 @@ public class ReportService { return Files.readAllBytes(pdfPath); } } catch (IOException e) { - System.err.println("Failed to read pre-rendered PDF: " + e.getMessage()); + log.warn("Failed to read pre-rendered PDF {}: {}", report.getPdfPath(), e.getMessage()); } } - // Fallback: render on demand try { return PptxToPdfService.convert(report.getFilePath()); } catch (IOException e) { - throw new RuntimeException("Failed to convert PPTX to PDF: " + e.getMessage(), e); + log.error("Failed to convert PPTX to PDF for report {}: {}", id, e.getMessage()); + throw new BusinessException("Failed to convert PPTX to PDF: " + e.getMessage()); } } -} \ No newline at end of file + + /** + * Validate that the file's MIME type / extension matches the declared fileType. + * Prevents attackers from uploading e.g. an executable renamed as .html. + */ + private void validateMimeType(String normalizedType, String contentType, String filename) { + String lower = filename == null ? "" : filename.toLowerCase(); + boolean extOk = switch (normalizedType) { + case "HTML" -> lower.endsWith(".html") || lower.endsWith(".htm"); + case "MD" -> lower.endsWith(".md") || lower.endsWith(".markdown"); + case "PDF" -> lower.endsWith(".pdf"); + case "PPTX" -> lower.endsWith(".pptx"); + case "PPT" -> lower.endsWith(".ppt"); + default -> false; + }; + if (!extOk) { + throw new BusinessException("File extension does not match declared fileType " + normalizedType); + } + + // Optional: check Content-Type header if provided + if (contentType != null && !contentType.isBlank()) { + boolean mimeOk = switch (normalizedType) { + case "HTML" -> contentType.startsWith("text/html") || contentType.equals("application/octet-stream"); + case "MD" -> contentType.startsWith("text/markdown") || contentType.startsWith("text/plain") + || contentType.equals("application/octet-stream"); + case "PDF" -> contentType.equals("application/pdf") || contentType.equals("application/octet-stream"); + case "PPTX" -> contentType.contains("presentationml") || contentType.equals("application/octet-stream"); + case "PPT" -> contentType.equals("application/vnd.ms-powerpoint") || contentType.equals("application/octet-stream"); + default -> false; + }; + if (!mimeOk) { + throw new BusinessException("Content-Type '" + contentType + "' does not match fileType " + normalizedType); + } + } + } +} diff --git a/src/pages/ProjectDetail.vue b/src/pages/ProjectDetail.vue index b615d99..be4e3f5 100644 --- a/src/pages/ProjectDetail.vue +++ b/src/pages/ProjectDetail.vue @@ -69,20 +69,61 @@
+ +
+
+
+ + + + +
+ +
+
+ 找到 {{ filteredReports.length }} 份报告(总共 {{ sortedReports.length }} 份) +
+
+
-
+

暂无报告

+ +
+
+

没有匹配 "{{ searchQuery }}" 的报告

@@ -131,7 +240,7 @@ import FilePreview from '../components/FilePreview.vue' import { useApi } from '../composables/useApi' const route = useRoute() -const { loading, fetchProjects, fetchReports, fetchReportContent, updateProject } = useApi() +const { loading, fetchProjects, fetchReports, fetchReportContent, updateProject, uploadReport } = useApi() const projects = ref([]) const reports = ref([]) @@ -147,6 +256,14 @@ const sortedReports = computed(() => { return tb - ta }) }) + +// Search filter (case-insensitive, matches fileName) +const searchQuery = ref('') +const filteredReports = computed(() => { + if (!searchQuery.value.trim()) return sortedReports.value + const q = searchQuery.value.trim().toLowerCase() + return sortedReports.value.filter(r => r.fileName && r.fileName.toLowerCase().includes(q)) +}) const sidebarCollapsed = ref(false) const editName = ref('') const coverImageFile = ref(null) @@ -220,6 +337,73 @@ const saveEdit = async () => { } } +// ============== Upload report ============== +const showUploadModal = ref(false) +const uploading = ref(false) +const uploadError = ref('') +const fileInputRef = ref(null) +const uploadForm = ref({ file: null, fileType: '' }) + +const onFileSelect = (event) => { + const file = event.target.files[0] + uploadForm.value.file = file + uploadError.value = '' + // Auto-detect file type from extension + if (file) { + const ext = file.name.toLowerCase().split('.').pop() + const typeMap = { html: 'HTML', htm: 'HTML', md: 'MD', markdown: 'MD', pdf: 'PDF', pptx: 'PPTX', ppt: 'PPT' } + if (typeMap[ext]) { + uploadForm.value.fileType = typeMap[ext] + } + } +} + +const closeUploadModal = () => { + showUploadModal.value = false + uploadError.value = '' + uploadForm.value = { file: null, fileType: '' } + if (fileInputRef.value) fileInputRef.value.value = '' +} + +const submitUpload = async () => { + uploadError.value = '' + if (!uploadForm.value.file) { + uploadError.value = '请选择文件' + return + } + if (!uploadForm.value.fileType) { + uploadError.value = '请选择文件类型' + return + } + uploading.value = true + try { + const result = await uploadReport(uploadForm.value.file, route.params.id, uploadForm.value.fileType) + if (result && result.id) { + closeUploadModal() + // Reload the report list + reports.value = await fetchReports(route.params.id) + } else { + uploadError.value = '上传失败,请重试' + } + } catch (e) { + uploadError.value = e?.response?.data?.error || '上传失败' + } finally { + uploading.value = false + } +} + +const formatFileSize = (bytes) => { + if (!bytes) return '0 B' + const units = ['B', 'KB', 'MB', 'GB'] + let i = 0 + let size = bytes + while (size >= 1024 && i < units.length - 1) { + size /= 1024 + i++ + } + return `${size.toFixed(size >= 10 || i === 0 ? 0 : 1)} ${units[i]}` +} + watch(() => route.params.id, loadData) onMounted(loadData) diff --git a/src/pages/ProjectList.vue b/src/pages/ProjectList.vue index fcab9fe..2a14b4f 100644 --- a/src/pages/ProjectList.vue +++ b/src/pages/ProjectList.vue @@ -88,14 +88,24 @@

所有项目

+
- +
@@ -143,24 +185,102 @@

暂无项目

-

创建一个新项目开始管理您的日报

+

创建一个新项目开始管理您的日报

+ + + + +
+
+
+

新建项目

+ +
+
+
+ + +
+
+ +