From 0139983fc3d59695aa06ff023e8f8e979014573c Mon Sep 17 00:00:00 2001 From: Schedule OCR Team Date: Wed, 3 Sep 2025 21:36:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E6=8F=90=E4=BA=A4=EF=BC=9A?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8OCR=E8=AF=86=E5=88=AB=E5=B0=8F=E7=A8=8B?= =?UTF-8?q?=E5=BA=8F=E5=AE=8C=E6=95=B4=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前端:微信小程序,支持OCR识别和课程管理 - 后端:Spring Boot,提供API服务和数据管理 - 功能:表格OCR识别、时间冲突检测、周次管理 - 文档:完整的使用教程和技术说明 --- .gitignore | 78 + README.md | 107 + schedule-ocr-backend/OCR解析优化说明.md | 77 + schedule-ocr-backend/pom.xml | 97 + .../ScheduleOcrBackendApplication.java | 22 + .../scheduleocr/config/BaiduOcrConfig.java | 74 + .../com/scheduleocr/config/CorsConfig.java | 65 + .../scheduleocr/config/DatabaseConfig.java | 20 + .../config/DatabaseInitializer.java | 105 + .../com/scheduleocr/config/SQLiteDialect.java | 136 + .../controller/CourseController.java | 198 + .../controller/HealthController.java | 66 + .../scheduleocr/controller/OcrController.java | 210 + .../controller/OcrTestController.java | 136 + .../java/com/scheduleocr/dto/ApiResponse.java | 104 + .../java/com/scheduleocr/dto/CourseDTO.java | 196 + .../java/com/scheduleocr/dto/OcrResult.java | 111 + .../java/com/scheduleocr/entity/Course.java | 280 + .../exception/GlobalExceptionHandler.java | 132 + .../repository/CourseRepository.java | 67 + .../scheduleocr/service/CourseService.java | 334 + .../com/scheduleocr/service/OcrService.java | 915 + .../main/resources/application-example.yml | 56 + .../src/main/resources/application.yml | 58 + .../多节次多周次解析说明.md | 107 + schedule-ocr-backend/百度OCR配置说明.md | 112 + schedule-ocr-miniprogram/miniprogram/app.json | 40 + schedule-ocr-miniprogram/miniprogram/app.ts | 18 + schedule-ocr-miniprogram/miniprogram/app.wxss | 61 + .../miniprogram/images/README.md | 17 + .../miniprogram/images/camera_icon.png | Bin 0 -> 3903 bytes .../miniprogram/images/logo.png | Bin 0 -> 6629 bytes .../miniprogram/images/tab-home-active.png | Bin 0 -> 2582 bytes .../miniprogram/images/tab-home.png | Bin 0 -> 2459 bytes .../miniprogram/images/tab-profile-active.png | Bin 0 -> 2914 bytes .../miniprogram/images/tab-profile.png | Bin 0 -> 2745 bytes .../pages/course-add/course-add.json | 5 + .../pages/course-add/course-add.ts | 383 + .../pages/course-add/course-add.wxml | 143 + .../pages/course-add/course-add.wxss | 313 + .../miniprogram/pages/index/index.json | 4 + .../miniprogram/pages/index/index.ts | 427 + .../miniprogram/pages/index/index.wxml | 111 + .../miniprogram/pages/index/index.wxss | 390 + .../pages/ocr-import/ocr-import.json | 5 + .../pages/ocr-import/ocr-import.ts | 384 + .../pages/ocr-import/ocr-import.wxml | 271 + .../pages/ocr-import/ocr-import.wxss | 536 + .../miniprogram/pages/profile/profile.json | 5 + .../miniprogram/pages/profile/profile.ts | 227 + .../miniprogram/pages/profile/profile.wxml | 89 + .../miniprogram/pages/profile/profile.wxss | 188 + .../miniprogram/utils/api.ts | 287 + .../miniprogram/utils/util.ts | 339 + schedule-ocr-miniprogram/package.json | 15 + schedule-ocr-miniprogram/project.config.json | 38 + .../project.private.config.json | 8 + schedule-ocr-miniprogram/tsconfig.json | 30 + schedule-ocr-miniprogram/typings/index.d.ts | 8 + .../typings/types/index.d.ts | 1 + .../typings/types/wx/index.d.ts | 74 + .../typings/types/wx/lib.wx.api.d.ts | 19671 ++++++++++++++++ .../typings/types/wx/lib.wx.app.d.ts | 270 + .../typings/types/wx/lib.wx.behavior.d.ts | 68 + .../typings/types/wx/lib.wx.cloud.d.ts | 924 + .../typings/types/wx/lib.wx.component.d.ts | 636 + .../typings/types/wx/lib.wx.event.d.ts | 1435 ++ .../typings/types/wx/lib.wx.page.d.ts | 259 + .../导航功能说明.md | 47 + schedule-ocr-miniprogram/测试说明.md | 199 + schedule-ocr-miniprogram/项目总结.md | 196 + ...R识别小程序需求文档_800版本.txt | 174 + 快速开始指南.md | 216 + 技术架构说明.md | 241 + 项目文档.md | 411 + 75 files changed, 33027 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 schedule-ocr-backend/OCR解析优化说明.md create mode 100644 schedule-ocr-backend/pom.xml create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java create mode 100644 schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java create mode 100644 schedule-ocr-backend/src/main/resources/application-example.yml create mode 100644 schedule-ocr-backend/src/main/resources/application.yml create mode 100644 schedule-ocr-backend/多节次多周次解析说明.md create mode 100644 schedule-ocr-backend/百度OCR配置说明.md create mode 100644 schedule-ocr-miniprogram/miniprogram/app.json create mode 100644 schedule-ocr-miniprogram/miniprogram/app.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/app.wxss create mode 100644 schedule-ocr-miniprogram/miniprogram/images/README.md create mode 100644 schedule-ocr-miniprogram/miniprogram/images/camera_icon.png create mode 100644 schedule-ocr-miniprogram/miniprogram/images/logo.png create mode 100644 schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png create mode 100644 schedule-ocr-miniprogram/miniprogram/images/tab-home.png create mode 100644 schedule-ocr-miniprogram/miniprogram/images/tab-profile-active.png create mode 100644 schedule-ocr-miniprogram/miniprogram/images/tab-profile.png create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxss create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/index/index.json create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/index/index.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/index/index.wxml create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/index/index.wxss create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/ocr-import/ocr-import.json create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/ocr-import/ocr-import.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/ocr-import/ocr-import.wxml create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/ocr-import/ocr-import.wxss create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/profile/profile.json create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/profile/profile.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/profile/profile.wxml create mode 100644 schedule-ocr-miniprogram/miniprogram/pages/profile/profile.wxss create mode 100644 schedule-ocr-miniprogram/miniprogram/utils/api.ts create mode 100644 schedule-ocr-miniprogram/miniprogram/utils/util.ts create mode 100644 schedule-ocr-miniprogram/package.json create mode 100644 schedule-ocr-miniprogram/project.config.json create mode 100644 schedule-ocr-miniprogram/project.private.config.json create mode 100644 schedule-ocr-miniprogram/tsconfig.json create mode 100644 schedule-ocr-miniprogram/typings/index.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/index.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/index.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.api.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.app.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.behavior.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.cloud.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.component.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.event.d.ts create mode 100644 schedule-ocr-miniprogram/typings/types/wx/lib.wx.page.d.ts create mode 100644 schedule-ocr-miniprogram/导航功能说明.md create mode 100644 schedule-ocr-miniprogram/测试说明.md create mode 100644 schedule-ocr-miniprogram/项目总结.md create mode 100644 大学生课表OCR识别小程序需求文档_800版本.txt create mode 100644 快速开始指南.md create mode 100644 技术架构说明.md create mode 100644 项目文档.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3154e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,78 @@ +# Java +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# IDE +.idea/ +*.iws +*.iml +*.ipr +.vscode/ +.settings/ +.project +.classpath + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +*.log +*.log.* + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Node.js (for miniprogram) +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# WeChat MiniProgram +miniprogram_npm/ +.tea/ + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# Build outputs +dist/ +build/ + +# Configuration files with sensitive data +application-prod.yml +application-local.yml + +# Backup files +*.bak +*.backup diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb34bd2 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# 课表OCR识别小程序 + +## 📋 项目简介 + +课表OCR识别小程序是一个基于人工智能的智能课表管理系统,支持拍照识别课表并自动导入课程信息。用户可以通过拍摄纸质课表或电子课表截图,系统会自动识别并解析课程信息,大大简化了课表录入的工作。 + +## 🎯 核心功能 + +- **📸 OCR智能识别**: 使用百度AI表格文字识别,专门针对课表优化 +- **📅 课程管理**: 支持手动添加、编辑、删除课程 +- **⏰ 时间冲突检测**: 智能检测时间和周次冲突 +- **📊 周视图展示**: 按周显示课程安排,支持周次切换 +- **⚙️ 学期配置**: 支持春季/秋季学期自动配置 + +## 🛠️ 技术栈 + +### 前端 (微信小程序) +- **语言**: TypeScript +- **框架**: 微信小程序原生开发 +- **特色**: 响应式设计、用户友好界面 + +### 后端 (Spring Boot) +- **语言**: Java 8+ +- **框架**: Spring Boot 2.7.x +- **数据库**: SQLite +- **OCR服务**: 百度AI表格文字识别API + +## 🚀 快速开始 + +### 环境要求 +- JDK 8+ +- Maven 3.6+ +- 微信开发者工具 +- 百度AI开放平台账号 + +### 1. 后端启动 +```bash +cd schedule-ocr-backend +mvn spring-boot:run +``` + +### 2. 前端运行 +1. 使用微信开发者工具打开 `schedule-ocr-miniprogram` 目录 +2. 编译并运行小程序 + +### 3. 配置OCR服务 +编辑 `schedule-ocr-backend/src/main/resources/application.yml`: +```yaml +baidu: + ocr: + app-id: 你的应用ID + api-key: 你的API Key + secret-key: 你的Secret Key +``` + +## 📚 文档 + +- [📖 项目文档](./项目文档.md) - 完整的项目说明和使用教程 +- [🚀 快速开始指南](./快速开始指南.md) - 5分钟快速上手 +- [🏗️ 技术架构说明](./技术架构说明.md) - 深度技术解析 + +## 📱 使用截图 + +### 主要界面 +- **首页**: 课表展示,周次切换 +- **OCR导入**: 拍照识别,结果确认 +- **课程管理**: 添加编辑,时间设置 +- **个人中心**: 学期配置,数据管理 + +## 🎨 特色功能 + +### 表格OCR识别 +- 使用百度AI表格文字识别技术 +- 识别准确率比通用OCR提高30%+ +- 保持课表的行列结构信息 +- 支持各种课表格式和布局 + +### 智能时间管理 +- 自动检测时间冲突 +- 支持单双周课程设置 +- 周次范围灵活配置 +- 学期日期自动计算 + +## 🔧 开发说明 + +### 项目结构 +``` +├── schedule-ocr-backend/ # 后端服务 +│ ├── src/main/java/ # Java源码 +│ ├── src/main/resources/ # 配置文件 +│ └── pom.xml # Maven配置 +├── schedule-ocr-miniprogram/ # 前端小程序 +│ ├── miniprogram/pages/ # 页面文件 +│ ├── miniprogram/utils/ # 工具类 +│ └── miniprogram/app.json # 小程序配置 +└── docs/ # 项目文档 +``` + +## 📞 技术支持 + +如有问题或建议,请查看项目文档或提交Issue。 + +--- + +**版本**: v1.0.0 +**开发团队**: 课表OCR团队 +**更新时间**: 2025年9月 diff --git a/schedule-ocr-backend/OCR解析优化说明.md b/schedule-ocr-backend/OCR解析优化说明.md new file mode 100644 index 0000000..1688028 --- /dev/null +++ b/schedule-ocr-backend/OCR解析优化说明.md @@ -0,0 +1,77 @@ +# OCR课程表解析优化说明 + +## 🔧 已修复的问题 + +### 1. 表格解析逻辑完善 +- ✅ 添加了 `extractCoursesFromTableGrid` 方法 +- ✅ 实现了表格网格到课程信息的转换 +- ✅ 正确处理行列映射关系 + +### 2. 课程信息解析优化 +原始OCR数据: +``` +数据库原理与应 +用(A)[04]1-16 +周(第1-2节) +曾广平 +15号楼15307 +``` + +优化后的解析逻辑: +- **课程名称**:`数据库原理与应用(A)` (自动拼接分行的课程名) +- **教师**:`曾广平` (识别纯中文姓名) +- **教室**:`15号楼15307` (识别包含"号楼"的教室信息) +- **时间**:从节次信息中提取时间段 + +### 3. 时间解析改进 +- ✅ 优先使用OCR识别的时间信息(如:08:00~08:45) +- ✅ 备用方案:根据节次映射到标准时间段 +- ✅ 支持跨节次课程的时间计算 + +### 4. 星期映射 +- ✅ 正确映射表头的星期信息到数字 +- ✅ 支持星期一到星期日的完整映射 + +## 📊 解析流程 + +### 步骤1:表格结构解析 +``` +表格单元格[0,1]: 星期一 +表格单元格[0,2]: 星期二 +表格单元格[1,1]: 数据挖掘导论... +``` + +### 步骤2:课程信息提取 +``` +原始: 数据挖掘导论 | [01]1-16周(第 | 1-2节) | 李波 | 15号楼15220 +解析: 课程=数据挖掘导论, 教师=李波, 教室=15号楼15220 +``` + +### 步骤3:时间计算 +``` +节次: 第1节 -> 时间: 08:00-08:45 +节次: 第2节 -> 时间: 08:55-09:40 +``` + +## 🎯 预期结果 + +现在应该能正确解析出课程: +1. **数据挖掘导论** - 李波 - 15号楼15220 - 星期一 08:00-08:45 +2. **计算机网络(B)** - 周斌 - 11号楼11413 - 星期二 08:00-08:45 +3. **数据库原理与应用(A)** - 曾广平 - 15号楼15307 - 星期三 08:00-08:45 +4. **UML与软件工程建模** - 刘卫平 - 11号楼11205 - 星期四 08:00-08:45 +5. **UML与软件工程建模(实验)** - 刘卫平 - 9号楼S090204 - 星期五 08:00-08:45 + +## 🔍 调试信息 + +添加了详细的调试日志: +- 表格单元格内容 +- 课程解析过程 +- 最终解析结果 + +## 📝 测试建议 + +1. 重启后端服务 +2. 使用小程序上传课程表图片 +3. 查看后端日志中的解析结果 +4. 验证课程是否正确导入到数据库 diff --git a/schedule-ocr-backend/pom.xml b/schedule-ocr-backend/pom.xml new file mode 100644 index 0000000..bd348f0 --- /dev/null +++ b/schedule-ocr-backend/pom.xml @@ -0,0 +1,97 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.scheduleocr + schedule-ocr-backend + 1.0.0 + schedule-ocr-backend + 大学生课表OCR识别小程序后端服务 + + + 8 + 8 + 8 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.xerial + sqlite-jdbc + 3.42.0.0 + + + + + com.baidu.aip + java-sdk + 4.16.8 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + commons-fileupload + commons-fileupload + 1.5 + + + + + commons-io + commons-io + 2.11.0 + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java new file mode 100644 index 0000000..9ce0529 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/ScheduleOcrBackendApplication.java @@ -0,0 +1,22 @@ +package com.scheduleocr; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * 大学生课表OCR识别小程序后端服务主启动类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@SpringBootApplication +public class ScheduleOcrBackendApplication { + + public static void main(String[] args) { + SpringApplication.run(ScheduleOcrBackendApplication.class, args); + System.out.println("================================="); + System.out.println("课表OCR后端服务启动成功!"); + System.out.println("访问地址:http://localhost:8080"); + System.out.println("================================="); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java new file mode 100644 index 0000000..5c08ad5 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/BaiduOcrConfig.java @@ -0,0 +1,74 @@ +package com.scheduleocr.config; + +import com.baidu.aip.ocr.AipOcr; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 百度OCR配置类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +@ConfigurationProperties(prefix = "baidu.ocr") +public class BaiduOcrConfig { + + /** + * 百度OCR应用ID + */ + private String appId; + + /** + * 百度OCR API Key + */ + private String apiKey; + + /** + * 百度OCR Secret Key + */ + private String secretKey; + + // Getters and Setters + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + /** + * 创建百度OCR客户端Bean + * + * @return AipOcr客户端 + */ + @Bean + public AipOcr aipOcr() { + // 初始化一个AipOcr + AipOcr client = new AipOcr(appId, apiKey, secretKey); + + // 可选:设置网络连接参数 + client.setConnectionTimeoutInMillis(2000); + client.setSocketTimeoutInMillis(60000); + + return client; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java new file mode 100644 index 0000000..a1d323b --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/CorsConfig.java @@ -0,0 +1,65 @@ +package com.scheduleocr.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 跨域配置类 + * 支持微信小程序调用后端API + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +public class CorsConfig implements WebMvcConfigurer { + + /** + * 配置跨域访问 + */ + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOriginPatterns("*") // 允许所有域名访问 + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); // 预检请求的缓存时间 + } + + /** + * CORS配置源 + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + + // 允许所有域名 + configuration.addAllowedOriginPattern("*"); + + // 允许的HTTP方法 + configuration.addAllowedMethod("GET"); + configuration.addAllowedMethod("POST"); + configuration.addAllowedMethod("PUT"); + configuration.addAllowedMethod("DELETE"); + configuration.addAllowedMethod("OPTIONS"); + + // 允许的请求头 + configuration.addAllowedHeader("*"); + + // 允许发送Cookie + configuration.setAllowCredentials(true); + + // 预检请求的缓存时间 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + + return source; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java new file mode 100644 index 0000000..d098b2c --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseConfig.java @@ -0,0 +1,20 @@ +package com.scheduleocr.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +/** + * 数据库配置类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Configuration +@EnableJpaRepositories(basePackages = "com.scheduleocr.repository") +@EnableTransactionManagement +public class DatabaseConfig { + + // SQLite数据库配置已在application.yml中完成 + // 这里主要用于启用JPA仓库和事务管理 +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java new file mode 100644 index 0000000..f5250f6 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/DatabaseInitializer.java @@ -0,0 +1,105 @@ +package com.scheduleocr.config; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.Statement; + +/** + * 数据库初始化器 + * 在应用启动时创建必要的数据库表 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Component +public class DatabaseInitializer implements CommandLineRunner { + + private static final Logger log = LoggerFactory.getLogger(DatabaseInitializer.class); + + @Autowired + private DataSource dataSource; + + @Override + public void run(String... args) throws Exception { + log.info("开始初始化数据库表..."); + + try (Connection connection = dataSource.getConnection(); + Statement statement = connection.createStatement()) { + + // 创建courses表 + String createTableSQL = "CREATE TABLE IF NOT EXISTS courses (" + + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + + "user_openid VARCHAR(100) NOT NULL, " + + "course_name VARCHAR(100) NOT NULL, " + + "teacher_name VARCHAR(50), " + + "classroom VARCHAR(100) NOT NULL, " + + "day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), " + + "start_time VARCHAR(10) NOT NULL, " + + "end_time VARCHAR(10) NOT NULL, " + + "notes VARCHAR(500), " + + "start_week INTEGER, " + + "end_week INTEGER, " + + "week_type INTEGER, " + + "create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, " + + "update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP" + + ")"; + + statement.execute(createTableSQL); + log.info("courses表创建成功"); + + // 创建索引 + String createIndexSQL1 = "CREATE INDEX IF NOT EXISTS idx_courses_user_openid ON courses(user_openid)"; + String createIndexSQL2 = "CREATE INDEX IF NOT EXISTS idx_courses_day_time ON courses(day_of_week, start_time)"; + + statement.execute(createIndexSQL1); + statement.execute(createIndexSQL2); + log.info("数据库索引创建成功"); + + // 插入一些测试数据 + insertTestData(statement); + + log.info("数据库初始化完成!"); + + } catch (Exception e) { + log.error("数据库初始化失败", e); + throw e; + } + } + + private void insertTestData(Statement statement) throws Exception { + // 检查是否已有数据 + java.sql.ResultSet resultSet = statement.executeQuery("SELECT COUNT(*) FROM courses"); + if (resultSet.next() && resultSet.getInt(1) > 0) { + log.info("数据库中已有数据,跳过测试数据插入"); + return; + } + + log.info("插入测试数据..."); + + String[] testData = { + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '高等数学', '张教授', '教学楼A101', 1, '08:00', '09:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '大学英语', '李老师', '教学楼B203', 1, '10:00', '11:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '计算机程序设计', '王教授', '实验楼C301', 3, '14:00', '15:40', '第1-16周')", + + "INSERT INTO courses (user_openid, course_name, teacher_name, classroom, day_of_week, start_time, end_time, notes) VALUES " + + "('test_user_001', '数据结构', '赵老师', '教学楼A205', 5, '08:00', '09:40', '第1-16周')" + }; + + for (String sql : testData) { + statement.execute(sql); + } + + log.info("测试数据插入完成"); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java new file mode 100644 index 0000000..41a6cc0 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/config/SQLiteDialect.java @@ -0,0 +1,136 @@ +package com.scheduleocr.config; + +import org.hibernate.dialect.Dialect; +import org.hibernate.dialect.function.SQLFunctionTemplate; +import org.hibernate.dialect.function.StandardSQLFunction; +import org.hibernate.dialect.function.VarArgsSQLFunction; +import org.hibernate.dialect.identity.IdentityColumnSupport; +import org.hibernate.dialect.identity.IdentityColumnSupportImpl; +import org.hibernate.type.StringType; + +import java.sql.Types; + +/** + * SQLite数据库方言 + * 适配Spring Boot 2.7的Hibernate版本 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class SQLiteDialect extends Dialect { + + public SQLiteDialect() { + // 注册列类型映射 + registerColumnType(Types.BIT, "integer"); + registerColumnType(Types.TINYINT, "tinyint"); + registerColumnType(Types.SMALLINT, "smallint"); + registerColumnType(Types.INTEGER, "integer"); + registerColumnType(Types.BIGINT, "bigint"); + registerColumnType(Types.FLOAT, "float"); + registerColumnType(Types.REAL, "real"); + registerColumnType(Types.DOUBLE, "double"); + registerColumnType(Types.NUMERIC, "numeric"); + registerColumnType(Types.DECIMAL, "decimal"); + registerColumnType(Types.CHAR, "char"); + registerColumnType(Types.VARCHAR, "varchar"); + registerColumnType(Types.LONGVARCHAR, "longvarchar"); + registerColumnType(Types.DATE, "date"); + registerColumnType(Types.TIME, "time"); + registerColumnType(Types.TIMESTAMP, "timestamp"); + registerColumnType(Types.BINARY, "blob"); + registerColumnType(Types.VARBINARY, "blob"); + registerColumnType(Types.LONGVARBINARY, "blob"); + registerColumnType(Types.BLOB, "blob"); + registerColumnType(Types.CLOB, "clob"); + registerColumnType(Types.BOOLEAN, "integer"); + + // 注册函数 + registerFunction("concat", new VarArgsSQLFunction(StringType.INSTANCE, "", "||", "")); + registerFunction("mod", new SQLFunctionTemplate(StringType.INSTANCE, "?1 % ?2")); + registerFunction("substr", new StandardSQLFunction("substr", StringType.INSTANCE)); + registerFunction("substring", new StandardSQLFunction("substr", StringType.INSTANCE)); + } + + @Override + public IdentityColumnSupport getIdentityColumnSupport() { + return new SQLiteIdentityColumnSupport(); + } + + @Override + public boolean hasAlterTable() { + return false; + } + + @Override + public boolean dropConstraints() { + return false; + } + + @Override + public String getDropForeignKeyString() { + return ""; + } + + @Override + public String getAddForeignKeyConstraintString(String constraintName, + String[] foreignKey, String referencedTable, String[] primaryKey, + boolean referencesPrimaryKey) { + return ""; + } + + @Override + public String getAddPrimaryKeyConstraintString(String constraintName) { + return ""; + } + + @Override + public boolean supportsIfExistsBeforeTableName() { + return true; + } + + @Override + public boolean supportsCascadeDelete() { + return false; + } + + @Override + public boolean supportsCurrentTimestampSelection() { + return true; + } + + @Override + public String getCurrentTimestampSelectString() { + return "select current_timestamp"; + } + + @Override + public boolean isCurrentTimestampSelectStringCallable() { + return false; + } + + /** + * SQLite身份列支持 + */ + public static class SQLiteIdentityColumnSupport extends IdentityColumnSupportImpl { + + @Override + public boolean supportsIdentityColumns() { + return true; + } + + @Override + public String getIdentitySelectString(String table, String column, int type) { + return "select last_insert_rowid()"; + } + + @Override + public String getIdentityColumnString(int type) { + return "integer primary key autoincrement"; + } + + @Override + public boolean hasDataTypeInIdentityColumn() { + return false; + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java new file mode 100644 index 0000000..90a586f --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/CourseController.java @@ -0,0 +1,198 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.entity.Course; +import com.scheduleocr.service.CourseService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import java.util.List; +import java.util.Optional; + +/** + * 课程管理控制器 + * 实现课程的CRUD操作API + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/courses") +@Validated +public class CourseController { + + private static final Logger log = LoggerFactory.getLogger(CourseController.class); + + @Autowired + private CourseService courseService; + + /** + * 获取课程列表 + * GET /api/courses?userOpenid=xxx&dayOfWeek=1 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几(可选,1-7) + * @return 课程列表 + */ + @GetMapping + public ApiResponse> getCourses( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid, + @RequestParam(required = false) Integer dayOfWeek, + @RequestParam(required = false) Integer currentWeek) { + + try { + List courses; + if (dayOfWeek != null) { + // 获取指定星期几的课程 + if (dayOfWeek < 1 || dayOfWeek > 7) { + return ApiResponse.error("星期几必须在1-7之间"); + } + courses = courseService.getCoursesByDay(userOpenid, dayOfWeek); + } else { + // 获取所有课程 + courses = courseService.getAllCourses(userOpenid); + } + + // 如果指定了当前周次,进行周次过滤 + if (currentWeek != null && currentWeek > 0) { + courses = courseService.filterCoursesByWeek(courses, currentWeek); + } + + return ApiResponse.success("获取课程列表成功", courses); + } catch (Exception e) { + log.error("获取课程列表失败", e); + return ApiResponse.serverError("获取课程列表失败:" + e.getMessage()); + } + } + + /** + * 根据ID获取课程详情 + * GET /api/courses/{id} + * + * @param id 课程ID + * @return 课程详情 + */ + @GetMapping("/{id}") + public ApiResponse getCourse(@PathVariable Long id) { + try { + Optional course = courseService.getCourseById(id); + if (course.isPresent()) { + return ApiResponse.success("获取课程详情成功", course.get()); + } else { + return ApiResponse.error("课程不存在"); + } + } catch (Exception e) { + log.error("获取课程详情失败,id: {}", id, e); + return ApiResponse.serverError("获取课程详情失败:" + e.getMessage()); + } + } + + /** + * 新增课程 + * POST /api/courses + * + * @param courseDTO 课程数据 + * @return 创建的课程 + */ + @PostMapping + public ApiResponse createCourse(@RequestBody @Valid CourseDTO courseDTO) { + try { + Course course = courseService.createCourse(courseDTO); + return ApiResponse.success("课程创建成功", course); + } catch (RuntimeException e) { + log.warn("课程创建失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程创建失败", e); + return ApiResponse.serverError("课程创建失败:" + e.getMessage()); + } + } + + /** + * 更新课程 + * PUT /api/courses/{id} + * + * @param id 课程ID + * @param courseDTO 课程数据 + * @return 更新的课程 + */ + @PutMapping("/{id}") + public ApiResponse updateCourse(@PathVariable Long id, + @RequestBody @Valid CourseDTO courseDTO) { + try { + Course course = courseService.updateCourse(id, courseDTO); + return ApiResponse.success("课程更新成功", course); + } catch (RuntimeException e) { + log.warn("课程更新失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程更新失败,id: {}", id, e); + return ApiResponse.serverError("课程更新失败:" + e.getMessage()); + } + } + + /** + * 删除课程 + * DELETE /api/courses/{id} + * + * @param id 课程ID + * @return 删除结果 + */ + @DeleteMapping("/{id}") + public ApiResponse deleteCourse(@PathVariable Long id) { + try { + courseService.deleteCourse(id); + return ApiResponse.success(); + } catch (RuntimeException e) { + log.warn("课程删除失败:{}", e.getMessage()); + return ApiResponse.error(e.getMessage()); + } catch (Exception e) { + log.error("课程删除失败,id: {}", id, e); + return ApiResponse.serverError("课程删除失败:" + e.getMessage()); + } + } + + /** + * 清空用户所有课程 + * DELETE /api/courses/clear?userOpenid=xxx + * + * @param userOpenid 用户openid + * @return 清空结果 + */ + @DeleteMapping("/clear") + public ApiResponse clearAllCourses( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + try { + courseService.clearAllCourses(userOpenid); + return ApiResponse.success(); + } catch (Exception e) { + log.error("清空课程表失败,userOpenid: {}", userOpenid, e); + return ApiResponse.serverError("清空课程表失败:" + e.getMessage()); + } + } + + /** + * 获取用户课程统计 + * GET /api/courses/count?userOpenid=xxx + * + * @param userOpenid 用户openid + * @return 课程统计 + */ + @GetMapping("/count") + public ApiResponse getCourseCount( + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + try { + long count = courseService.getCourseCount(userOpenid); + return ApiResponse.success("获取课程统计成功", count); + } catch (Exception e) { + log.error("获取课程统计失败,userOpenid: {}", userOpenid, e); + return ApiResponse.serverError("获取课程统计失败:" + e.getMessage()); + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java new file mode 100644 index 0000000..a135dd6 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/HealthController.java @@ -0,0 +1,66 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +/** + * 健康检查控制器 + * 提供系统状态检查接口 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api") +public class HealthController { + + /** + * 健康检查接口 + * GET /api/health + * + * @return 系统状态信息 + */ + @GetMapping("/health") + public ApiResponse> health() { + Map status = new HashMap<>(); + status.put("status", "UP"); + status.put("timestamp", LocalDateTime.now()); + status.put("service", "schedule-ocr-backend"); + status.put("version", "1.0.0"); + + return ApiResponse.success("系统运行正常", status); + } + + /** + * 系统信息接口 + * GET /api/info + * + * @return 系统详细信息 + */ + @GetMapping("/info") + public ApiResponse> info() { + Map info = new HashMap<>(); + info.put("application", "大学生课表OCR识别小程序后端服务"); + info.put("version", "1.0.0"); + info.put("description", "支持课程表管理和OCR图片识别功能"); + info.put("author", "scheduleocr"); + info.put("timestamp", LocalDateTime.now()); + + // JVM信息 + Runtime runtime = Runtime.getRuntime(); + Map jvm = new HashMap<>(); + jvm.put("totalMemory", runtime.totalMemory() / 1024 / 1024 + " MB"); + jvm.put("freeMemory", runtime.freeMemory() / 1024 / 1024 + " MB"); + jvm.put("maxMemory", runtime.maxMemory() / 1024 / 1024 + " MB"); + jvm.put("processors", runtime.availableProcessors()); + info.put("jvm", jvm); + + return ApiResponse.success("获取系统信息成功", info); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java new file mode 100644 index 0000000..2973778 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrController.java @@ -0,0 +1,210 @@ +package com.scheduleocr.controller; + +import com.scheduleocr.dto.ApiResponse; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.dto.OcrResult; +import com.scheduleocr.entity.Course; +import com.scheduleocr.service.CourseService; +import com.scheduleocr.service.OcrService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import java.util.ArrayList; +import java.util.List; + +/** + * OCR识别控制器 + * 实现课表图片识别和导入功能 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/ocr") +@Validated +public class OcrController { + + private static final Logger log = LoggerFactory.getLogger(OcrController.class); + + @Autowired + private OcrService ocrService; + + @Autowired + private CourseService courseService; + + /** + * 上传并识别课表图片 + * POST /api/ocr/upload + * + * @param file 课表图片文件 + * @param userOpenid 用户openid + * @return OCR识别结果 + */ + @PostMapping("/upload") + public ApiResponse uploadAndRecognize( + @RequestParam("file") MultipartFile file, + @RequestParam @NotBlank(message = "用户openid不能为空") String userOpenid) { + + try { + log.info("接收到OCR识别请求,用户: {}, 文件: {}", userOpenid, file.getOriginalFilename()); + + // 调用OCR服务进行识别 + OcrResult result = ocrService.recognizeSchedule(file, userOpenid); + + if (result.isSuccess()) { + return ApiResponse.success("课表识别成功", result); + } else { + return ApiResponse.error(result.getErrorMessage()); + } + + } catch (Exception e) { + log.error("OCR识别过程中发生错误", e); + return ApiResponse.serverError("OCR识别失败:" + e.getMessage()); + } + } + + /** + * 导入识别的课程到数据库 + * POST /api/ocr/import + * + * @param request 要导入的课程列表 + * @return 导入结果 + */ + @PostMapping("/import") + public ApiResponse importCourses( + @RequestBody @Valid ImportRequest request) { + + try { + log.info("开始导入课程,用户: {}, 课程数量: {}", + request.getUserOpenid(), request.getCourses().size()); + + List successCourses = new ArrayList<>(); + List errorMessages = new ArrayList<>(); + + // 逐个导入课程 + for (CourseDTO courseDTO : request.getCourses()) { + try { + // 确保userOpenid一致 + courseDTO.setUserOpenid(request.getUserOpenid()); + + Course course = courseService.createCourse(courseDTO); + successCourses.add(course); + log.debug("课程导入成功: {}", courseDTO.getCourseName()); + + } catch (Exception e) { + String errorMsg = String.format("课程[%s]导入失败: %s", + courseDTO.getCourseName(), e.getMessage()); + errorMessages.add(errorMsg); + log.warn(errorMsg); + } + } + + ImportResult result = new ImportResult(); + result.setTotalCount(request.getCourses().size()); + result.setSuccessCount(successCourses.size()); + result.setFailCount(errorMessages.size()); + result.setSuccessCourses(successCourses); + result.setErrorMessages(errorMessages); + + if (successCourses.size() > 0) { + String message = String.format("课程导入完成,成功%d门,失败%d门", + successCourses.size(), errorMessages.size()); + return ApiResponse.success(message, result); + } else { + return new ApiResponse<>(400, "所有课程导入失败", result); + } + + } catch (Exception e) { + log.error("课程导入过程中发生错误", e); + return ApiResponse.serverError("课程导入失败:" + e.getMessage()); + } + } + + /** + * 导入请求DTO + */ + public static class ImportRequest { + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + @NotEmpty(message = "课程列表不能为空") + @Valid + private List courses; + + // Getters and Setters + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public List getCourses() { + return courses; + } + + public void setCourses(List courses) { + this.courses = courses; + } + } + + /** + * 导入结果DTO + */ + public static class ImportResult { + private int totalCount; + private int successCount; + private int failCount; + private List successCourses; + private List errorMessages; + + // Getters and Setters + public int getTotalCount() { + return totalCount; + } + + public void setTotalCount(int totalCount) { + this.totalCount = totalCount; + } + + public int getSuccessCount() { + return successCount; + } + + public void setSuccessCount(int successCount) { + this.successCount = successCount; + } + + public int getFailCount() { + return failCount; + } + + public void setFailCount(int failCount) { + this.failCount = failCount; + } + + public List getSuccessCourses() { + return successCourses; + } + + public void setSuccessCourses(List successCourses) { + this.successCourses = successCourses; + } + + public List getErrorMessages() { + return errorMessages; + } + + public void setErrorMessages(List errorMessages) { + this.errorMessages = errorMessages; + } + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java new file mode 100644 index 0000000..25df989 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/controller/OcrTestController.java @@ -0,0 +1,136 @@ +package com.scheduleocr.controller; + +import com.baidu.aip.ocr.AipOcr; +import com.scheduleocr.dto.ApiResponse; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +/** + * OCR测试控制器 + * 用于测试百度OCR配置是否正确 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestController +@RequestMapping("/api/ocr-test") +public class OcrTestController { + + private static final Logger log = LoggerFactory.getLogger(OcrTestController.class); + + @Autowired + private AipOcr aipOcr; + + /** + * 测试OCR配置 + * GET /api/ocr-test/config + * + * @return OCR配置状态 + */ + @GetMapping("/config") + public ApiResponse> testOcrConfig() { + try { + Map result = new HashMap<>(); + + // 检查AipOcr是否正确初始化 + if (aipOcr == null) { + result.put("status", "FAILED"); + result.put("message", "AipOcr未正确初始化,请检查配置"); + return new ApiResponse<>(400, "OCR配置失败", result); + } + + result.put("status", "SUCCESS"); + result.put("message", "OCR客户端初始化成功"); + result.put("note", "如需完整测试,请使用 POST /api/ocr-test/simple 上传图片"); + + log.info("OCR配置测试成功"); + return ApiResponse.success("OCR配置正常", result); + + } catch (Exception e) { + log.error("OCR配置测试失败", e); + Map result = new HashMap<>(); + result.put("status", "ERROR"); + result.put("message", "OCR配置测试异常:" + e.getMessage()); + return new ApiResponse<>(500, "OCR配置测试失败", result); + } + } + + /** + * 简单OCR测试 + * POST /api/ocr-test/simple + * + * @param imageUrl 图片URL(可选,用于测试网络图片识别) + * @return OCR测试结果 + */ + @PostMapping("/simple") + public ApiResponse> simpleOcrTest( + @RequestParam(required = false) String imageUrl) { + + try { + Map result = new HashMap<>(); + + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + // 测试网络图片识别 + HashMap options = new HashMap<>(); + options.put("language_type", "CHN_ENG"); + + JSONObject response = aipOcr.basicGeneralUrl(imageUrl, options); + + if (response.has("error_code")) { + result.put("status", "API_ERROR"); + result.put("error_code", response.get("error_code")); + result.put("error_msg", response.optString("error_msg", "未知错误")); + return new ApiResponse<>(400, "OCR API调用失败", result); + } else { + result.put("status", "SUCCESS"); + result.put("message", "OCR API调用成功"); + result.put("words_result_num", response.optInt("words_result_num", 0)); + return ApiResponse.success("OCR测试成功", result); + } + } else { + result.put("status", "NO_IMAGE"); + result.put("message", "未提供图片URL,仅测试配置"); + result.put("note", "请提供imageUrl参数进行完整测试"); + return ApiResponse.success("OCR配置正常,未进行图片识别测试", result); + } + + } catch (Exception e) { + log.error("OCR简单测试失败", e); + Map result = new HashMap<>(); + result.put("status", "EXCEPTION"); + result.put("message", "OCR测试异常:" + e.getMessage()); + return new ApiResponse<>(500, "OCR测试失败", result); + } + } + + /** + * 获取OCR使用说明 + * GET /api/ocr-test/help + * + * @return 使用说明 + */ + @GetMapping("/help") + public ApiResponse> getOcrHelp() { + Map help = new HashMap<>(); + help.put("title", "百度OCR配置说明"); + help.put("step1", "访问百度智能云控制台:https://console.bce.baidu.com/"); + help.put("step2", "搜索'文字识别OCR'并创建应用"); + help.put("step3", "获取AppID、API Key、Secret Key"); + help.put("step4", "在application.yml中配置密钥信息"); + help.put("step5", "重启应用并访问 /api/ocr-test/config 测试配置"); + + Map testApis = new HashMap<>(); + testApis.put("配置测试", "GET /api/ocr-test/config"); + testApis.put("简单测试", "POST /api/ocr-test/simple?imageUrl=图片URL"); + testApis.put("完整测试", "POST /api/ocr/upload (上传图片文件)"); + help.put("test_apis", testApis); + + return ApiResponse.success("OCR使用说明", help); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java new file mode 100644 index 0000000..89a4d22 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/ApiResponse.java @@ -0,0 +1,104 @@ +package com.scheduleocr.dto; + +/** + * 统一API响应格式 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class ApiResponse { + + /** + * 响应状态码 + * 200: 成功 + * 400: 客户端错误 + * 500: 服务器错误 + */ + private Integer code; + + /** + * 响应消息 + */ + private String message; + + /** + * 响应数据 + */ + private T data; + + // Constructors + public ApiResponse() {} + + public ApiResponse(Integer code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public Integer getCode() { + return code; + } + + public void setCode(Integer code) { + this.code = code; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + /** + * 成功响应 + */ + public static ApiResponse success(T data) { + return new ApiResponse<>(200, "操作成功", data); + } + + /** + * 成功响应(无数据) + */ + public static ApiResponse success() { + return new ApiResponse<>(200, "操作成功", null); + } + + /** + * 成功响应(自定义消息) + */ + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(200, message, data); + } + + /** + * 失败响应 + */ + public static ApiResponse error(String message) { + return new ApiResponse<>(400, message, null); + } + + /** + * 失败响应(自定义状态码) + */ + public static ApiResponse error(Integer code, String message) { + return new ApiResponse<>(code, message, null); + } + + /** + * 服务器错误响应 + */ + public static ApiResponse serverError(String message) { + return new ApiResponse<>(500, message, null); + } + +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java new file mode 100644 index 0000000..620b80e --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/CourseDTO.java @@ -0,0 +1,196 @@ +package com.scheduleocr.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; + +/** + * 课程数据传输对象 + * 用于API接口的数据传输 + * + * @author scheduleocr + * @version 1.0.0 + */ +public class CourseDTO { + + /** + * 课程ID(更新时需要) + */ + private Long id; + + /** + * 用户微信openid + */ + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + /** + * 课程名称 + */ + @NotBlank(message = "课程名称不能为空") + private String courseName; + + /** + * 任课教师 + */ + private String teacherName; + + /** + * 上课地点 + */ + @NotBlank(message = "上课地点不能为空") + private String classroom; + + /** + * 星期几 (1-7) + */ + @NotNull(message = "星期几不能为空") + @Min(value = 1, message = "星期几必须在1-7之间") + @Max(value = 7, message = "星期几必须在1-7之间") + private Integer dayOfWeek; + + /** + * 开始时间 + */ + @NotBlank(message = "开始时间不能为空") + private String startTime; + + /** + * 结束时间 + */ + @NotBlank(message = "结束时间不能为空") + private String endTime; + + /** + * 备注 + */ + private String notes; + + /** + * 开始周次 + */ + private Integer startWeek; + + /** + * 结束周次 + */ + private Integer endWeek; + + /** + * 单双周标识(0=每周,1=单周,2=双周) + */ + private Integer weekType; + + // Constructors + public CourseDTO() {} + + public CourseDTO(Long id, String userOpenid, String courseName, String teacherName, + String classroom, Integer dayOfWeek, String startTime, String endTime, String notes) { + this.id = id; + this.userOpenid = userOpenid; + this.courseName = courseName; + this.teacherName = teacherName; + this.classroom = classroom; + this.dayOfWeek = dayOfWeek; + this.startTime = startTime; + this.endTime = endTime; + this.notes = notes; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public String getCourseName() { + return courseName; + } + + public void setCourseName(String courseName) { + this.courseName = courseName; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getClassroom() { + return classroom; + } + + public void setClassroom(String classroom) { + this.classroom = classroom; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public Integer getStartWeek() { + return startWeek; + } + + public void setStartWeek(Integer startWeek) { + this.startWeek = startWeek; + } + + public Integer getEndWeek() { + return endWeek; + } + + public void setEndWeek(Integer endWeek) { + this.endWeek = endWeek; + } + + public Integer getWeekType() { + return weekType; + } + + public void setWeekType(Integer weekType) { + this.weekType = weekType; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java new file mode 100644 index 0000000..c74cf00 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/dto/OcrResult.java @@ -0,0 +1,111 @@ +package com.scheduleocr.dto; + +import java.util.List; + +/** + * OCR识别结果DTO + * + * @author scheduleocr + * @version 1.0.0 + */ +public class OcrResult { + + /** + * 识别是否成功 + */ + private boolean success; + + /** + * 错误消息(如果失败) + */ + private String errorMessage; + + /** + * 原始识别文本 + */ + private String rawText; + + /** + * 解析后的课程列表 + */ + private List courses; + + /** + * 识别的文本行列表 + */ + private List textLines; + + // Constructors + public OcrResult() {} + + public OcrResult(boolean success, String errorMessage, String rawText, + List courses, List textLines) { + this.success = success; + this.errorMessage = errorMessage; + this.rawText = rawText; + this.courses = courses; + this.textLines = textLines; + } + + public boolean isSuccess() { + return success; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getRawText() { + return rawText; + } + + public void setRawText(String rawText) { + this.rawText = rawText; + } + + public List getCourses() { + return courses; + } + + public void setCourses(List courses) { + this.courses = courses; + } + + public List getTextLines() { + return textLines; + } + + public void setTextLines(List textLines) { + this.textLines = textLines; + } + + /** + * 创建成功结果 + */ + public static OcrResult success(String rawText, List textLines, List courses) { + OcrResult result = new OcrResult(); + result.setSuccess(true); + result.setRawText(rawText); + result.setTextLines(textLines); + result.setCourses(courses); + return result; + } + + /** + * 创建失败结果 + */ + public static OcrResult error(String errorMessage) { + OcrResult result = new OcrResult(); + result.setSuccess(false); + result.setErrorMessage(errorMessage); + return result; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java new file mode 100644 index 0000000..01bd387 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/entity/Course.java @@ -0,0 +1,280 @@ +package com.scheduleocr.entity; + +import javax.persistence.*; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Min; +import javax.validation.constraints.Max; +import java.time.LocalDateTime; + +/** + * 课程实体类 + * 对应数据库courses表 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Entity +@Table(name = "courses") +public class Course { + + /** + * 主键ID + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 用户微信openid标识 + */ + @Column(name = "user_openid", nullable = false, length = 100) + @NotBlank(message = "用户openid不能为空") + private String userOpenid; + + /** + * 课程名称 + */ + @Column(name = "course_name", nullable = false, length = 100) + @NotBlank(message = "课程名称不能为空") + private String courseName; + + /** + * 任课教师 + */ + @Column(name = "teacher_name", length = 50) + private String teacherName; + + /** + * 上课地点 + */ + @Column(name = "classroom", nullable = false, length = 100) + @NotBlank(message = "上课地点不能为空") + private String classroom; + + /** + * 星期几 (1-7, 1表示周一,7表示周日) + */ + @Column(name = "day_of_week", nullable = false) + @NotNull(message = "星期几不能为空") + @Min(value = 1, message = "星期几必须在1-7之间") + @Max(value = 7, message = "星期几必须在1-7之间") + private Integer dayOfWeek; + + /** + * 开始时间 (格式: HH:mm,如 "08:00") + */ + @Column(name = "start_time", nullable = false, length = 10) + @NotBlank(message = "开始时间不能为空") + private String startTime; + + /** + * 结束时间 (格式: HH:mm,如 "09:40") + */ + @Column(name = "end_time", nullable = false, length = 10) + @NotBlank(message = "结束时间不能为空") + private String endTime; + + /** + * 备注信息 + */ + @Column(name = "notes", length = 500) + private String notes; + + /** + * 开始周次(如第1周) + */ + @Column(name = "start_week") + private Integer startWeek; + + /** + * 结束周次(如第16周) + */ + @Column(name = "end_week") + private Integer endWeek; + + /** + * 单双周标识(0=每周,1=单周,2=双周) + */ + @Column(name = "week_type") + private Integer weekType; + + /** + * 创建时间 + */ + @Column(name = "create_time", nullable = false) + private LocalDateTime createTime; + + /** + * 更新时间 + */ + @Column(name = "update_time") + private LocalDateTime updateTime; + + /** + * 在保存前自动设置创建时间 + */ + @PrePersist + protected void onCreate() { + createTime = LocalDateTime.now(); + updateTime = LocalDateTime.now(); + } + + public Integer getStartWeek() { + return startWeek; + } + + public void setStartWeek(Integer startWeek) { + this.startWeek = startWeek; + } + + public Integer getEndWeek() { + return endWeek; + } + + public void setEndWeek(Integer endWeek) { + this.endWeek = endWeek; + } + + public Integer getWeekType() { + return weekType; + } + + public void setWeekType(Integer weekType) { + this.weekType = weekType; + } + + /** + * 在更新前自动设置更新时间 + */ + @PreUpdate + protected void onUpdate() { + updateTime = LocalDateTime.now(); + } + + // Constructors + public Course() {} + + public Course(Long id, String userOpenid, String courseName, String teacherName, + String classroom, Integer dayOfWeek, String startTime, String endTime, + String notes, LocalDateTime createTime, LocalDateTime updateTime) { + this.id = id; + this.userOpenid = userOpenid; + this.courseName = courseName; + this.teacherName = teacherName; + this.classroom = classroom; + this.dayOfWeek = dayOfWeek; + this.startTime = startTime; + this.endTime = endTime; + this.notes = notes; + this.createTime = createTime; + this.updateTime = updateTime; + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUserOpenid() { + return userOpenid; + } + + public void setUserOpenid(String userOpenid) { + this.userOpenid = userOpenid; + } + + public String getCourseName() { + return courseName; + } + + public void setCourseName(String courseName) { + this.courseName = courseName; + } + + public String getTeacherName() { + return teacherName; + } + + public void setTeacherName(String teacherName) { + this.teacherName = teacherName; + } + + public String getClassroom() { + return classroom; + } + + public void setClassroom(String classroom) { + this.classroom = classroom; + } + + public Integer getDayOfWeek() { + return dayOfWeek; + } + + public void setDayOfWeek(Integer dayOfWeek) { + this.dayOfWeek = dayOfWeek; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public String getNotes() { + return notes; + } + + public void setNotes(String notes) { + this.notes = notes; + } + + public LocalDateTime getCreateTime() { + return createTime; + } + + public void setCreateTime(LocalDateTime createTime) { + this.createTime = createTime; + } + + public LocalDateTime getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(LocalDateTime updateTime) { + this.updateTime = updateTime; + } + + /** + * 获取星期几的中文名称 + */ + public String getDayOfWeekName() { + String[] dayNames = {"", "周一", "周二", "周三", "周四", "周五", "周六", "周日"}; + if (dayOfWeek >= 1 && dayOfWeek <= 7) { + return dayNames[dayOfWeek]; + } + return "未知"; + } + + /** + * 获取时间段描述 + */ + public String getTimeRange() { + return startTime + " - " + endTime; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..85f9d13 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/exception/GlobalExceptionHandler.java @@ -0,0 +1,132 @@ +package com.scheduleocr.exception; + +import com.scheduleocr.dto.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import javax.validation.ConstraintViolation; +import javax.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.List; + +/** + * 全局异常处理器 + * 统一处理应用中的异常,返回标准的API响应格式 + * + * @author scheduleocr + * @version 1.0.0 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 处理参数校验异常(@Valid) + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleValidationException(MethodArgumentNotValidException e) { + log.warn("参数校验失败", e); + + List errors = new ArrayList<>(); + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.add(error.getField() + ": " + error.getDefaultMessage()); + } + + String message = "参数校验失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理参数绑定异常 + */ + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleBindException(BindException e) { + log.warn("参数绑定失败", e); + + List errors = new ArrayList<>(); + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.add(error.getField() + ": " + error.getDefaultMessage()); + } + + String message = "参数绑定失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理约束校验异常(@Validated) + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleConstraintViolationException(ConstraintViolationException e) { + log.warn("约束校验失败", e); + + List errors = new ArrayList<>(); + for (ConstraintViolation violation : e.getConstraintViolations()) { + errors.add(violation.getPropertyPath() + ": " + violation.getMessage()); + } + + String message = "约束校验失败: " + String.join(", ", errors); + return ApiResponse.error(400, message); + } + + /** + * 处理文件上传大小超限异常 + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) { + log.warn("文件上传大小超限", e); + return ApiResponse.error(400, "上传文件大小超过限制,请选择小于10MB的文件"); + } + + /** + * 处理运行时异常(业务异常) + */ + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntimeException(RuntimeException e) { + log.warn("业务异常", e); + return ApiResponse.error(e.getMessage()); + } + + /** + * 处理非法参数异常 + */ + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("非法参数异常", e); + return ApiResponse.error("参数错误: " + e.getMessage()); + } + + /** + * 处理空指针异常 + */ + @ExceptionHandler(NullPointerException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleNullPointerException(NullPointerException e) { + log.error("空指针异常", e); + return ApiResponse.serverError("系统内部错误,请稍后重试"); + } + + /** + * 处理其他未知异常 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleException(Exception e) { + log.error("系统异常", e); + return ApiResponse.serverError("系统内部错误: " + e.getMessage()); + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java new file mode 100644 index 0000000..c73bc8d --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/repository/CourseRepository.java @@ -0,0 +1,67 @@ +package com.scheduleocr.repository; + +import com.scheduleocr.entity.Course; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * 课程数据访问接口 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Repository +public interface CourseRepository extends JpaRepository { + + /** + * 根据用户openid查询所有课程 + * + * @param userOpenid 用户openid + * @return 课程列表 + */ + List findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(String userOpenid); + + /** + * 根据用户openid和星期几查询课程 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @return 课程列表 + */ + List findByUserOpenidAndDayOfWeekOrderByStartTimeAsc(String userOpenid, Integer dayOfWeek); + + /** + * 根据用户openid删除所有课程 + * + * @param userOpenid 用户openid + */ + void deleteByUserOpenid(String userOpenid); + + /** + * 检查时间冲突 + * 查询指定用户在指定星期几的所有课程(用于在Java中进行精确时间比较) + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @param excludeId 排除的课程ID(用于更新时排除自己) + * @return 可能冲突的课程列表 + */ + @Query("SELECT c FROM Course c WHERE c.userOpenid = :userOpenid " + + "AND c.dayOfWeek = :dayOfWeek " + + "AND (:excludeId IS NULL OR c.id != :excludeId)") + List findPotentialConflictCourses(@Param("userOpenid") String userOpenid, + @Param("dayOfWeek") Integer dayOfWeek, + @Param("excludeId") Long excludeId); + + /** + * 统计用户课程总数 + * + * @param userOpenid 用户openid + * @return 课程总数 + */ + long countByUserOpenid(String userOpenid); +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java new file mode 100644 index 0000000..a6f2490 --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/CourseService.java @@ -0,0 +1,334 @@ +package com.scheduleocr.service; + +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.entity.Course; +import com.scheduleocr.repository.CourseRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * 课程服务类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Service +public class CourseService { + + private static final Logger log = LoggerFactory.getLogger(CourseService.class); + + @Autowired + private CourseRepository courseRepository; + + /** + * 获取用户所有课程 + * + * @param userOpenid 用户openid + * @return 课程列表 + */ + public List getAllCourses(String userOpenid) { + log.info("获取用户课程列表,userOpenid: {}", userOpenid); + return courseRepository.findByUserOpenidOrderByDayOfWeekAscStartTimeAsc(userOpenid); + } + + /** + * 根据星期几获取课程 + * + * @param userOpenid 用户openid + * @param dayOfWeek 星期几 + * @return 课程列表 + */ + public List getCoursesByDay(String userOpenid, Integer dayOfWeek) { + log.info("获取用户指定日期课程,userOpenid: {}, dayOfWeek: {}", userOpenid, dayOfWeek); + return courseRepository.findByUserOpenidAndDayOfWeekOrderByStartTimeAsc(userOpenid, dayOfWeek); + } + + /** + * 根据ID获取课程 + * + * @param id 课程ID + * @return 课程信息 + */ + public Optional getCourseById(Long id) { + log.info("根据ID获取课程,id: {}", id); + return courseRepository.findById(id); + } + + /** + * 创建课程 + * + * @param courseDTO 课程数据 + * @return 创建的课程 + */ + @Transactional + public Course createCourse(CourseDTO courseDTO) { + log.info("创建课程,courseName: {}", courseDTO.getCourseName()); + + // 检查时间冲突 + List potentialConflicts = courseRepository.findPotentialConflictCourses( + courseDTO.getUserOpenid(), + courseDTO.getDayOfWeek(), + null + ); + + // 在Java中进行精确的时间和周次冲突检测 + List conflictCourses = potentialConflicts.stream() + .filter(course -> isTimeConflict( + courseDTO.getStartTime(), courseDTO.getEndTime(), + course.getStartTime(), course.getEndTime() + ) && isWeekConflict( + courseDTO.getStartWeek(), courseDTO.getEndWeek(), courseDTO.getWeekType(), + course.getStartWeek(), course.getEndWeek(), course.getWeekType() + )) + .collect(Collectors.toList()); + + if (!conflictCourses.isEmpty()) { + Course conflictCourse = conflictCourses.get(0); + log.warn("时间冲突检测 - 新课程: {}({}-{}), 冲突课程: {}({}-{})", + courseDTO.getCourseName(), courseDTO.getStartTime(), courseDTO.getEndTime(), + conflictCourse.getCourseName(), conflictCourse.getStartTime(), conflictCourse.getEndTime()); + throw new RuntimeException("时间冲突:该时间段已有其他课程"); + } + + Course course = new Course(); + BeanUtils.copyProperties(courseDTO, course); + + Course savedCourse = courseRepository.save(course); + log.info("课程创建成功,id: {}", savedCourse.getId()); + return savedCourse; + } + + /** + * 更新课程 + * + * @param id 课程ID + * @param courseDTO 课程数据 + * @return 更新的课程 + */ + @Transactional + public Course updateCourse(Long id, CourseDTO courseDTO) { + log.info("更新课程,id: {}", id); + + Optional existingCourse = courseRepository.findById(id); + if (!existingCourse.isPresent()) { + throw new RuntimeException("课程不存在"); + } + + // 检查时间冲突(排除自己) + List potentialConflicts = courseRepository.findPotentialConflictCourses( + courseDTO.getUserOpenid(), + courseDTO.getDayOfWeek(), + id + ); + + // 在Java中进行精确的时间和周次冲突检测 + List conflictCourses = potentialConflicts.stream() + .filter(course -> isTimeConflict( + courseDTO.getStartTime(), courseDTO.getEndTime(), + course.getStartTime(), course.getEndTime() + ) && isWeekConflict( + courseDTO.getStartWeek(), courseDTO.getEndWeek(), courseDTO.getWeekType(), + course.getStartWeek(), course.getEndWeek(), course.getWeekType() + )) + .collect(Collectors.toList()); + + if (!conflictCourses.isEmpty()) { + Course conflictCourse = conflictCourses.get(0); + log.warn("时间冲突检测 - 更新课程: {}({}-{}), 冲突课程: {}({}-{})", + courseDTO.getCourseName(), courseDTO.getStartTime(), courseDTO.getEndTime(), + conflictCourse.getCourseName(), conflictCourse.getStartTime(), conflictCourse.getEndTime()); + throw new RuntimeException("时间冲突:该时间段已有其他课程"); + } + + Course course = existingCourse.get(); + BeanUtils.copyProperties(courseDTO, course, "id", "createTime"); + + Course savedCourse = courseRepository.save(course); + log.info("课程更新成功,id: {}", savedCourse.getId()); + return savedCourse; + } + + /** + * 删除课程 + * + * @param id 课程ID + */ + @Transactional + public void deleteCourse(Long id) { + log.info("删除课程,id: {}", id); + + if (!courseRepository.existsById(id)) { + throw new RuntimeException("课程不存在"); + } + + courseRepository.deleteById(id); + log.info("课程删除成功,id: {}", id); + } + + /** + * 清空用户所有课程 + * + * @param userOpenid 用户openid + */ + @Transactional + public void clearAllCourses(String userOpenid) { + log.info("清空用户所有课程,userOpenid: {}", userOpenid); + courseRepository.deleteByUserOpenid(userOpenid); + log.info("用户课程清空成功,userOpenid: {}", userOpenid); + } + + /** + * 获取用户课程统计 + * + * @param userOpenid 用户openid + * @return 课程总数 + */ + public long getCourseCount(String userOpenid) { + return courseRepository.countByUserOpenid(userOpenid); + } + + /** + * 根据当前周次过滤课程 + * + * @param courses 课程列表 + * @param currentWeek 当前周次 + * @return 过滤后的课程列表 + */ + public List filterCoursesByWeek(List courses, Integer currentWeek) { + if (courses == null || currentWeek == null || currentWeek <= 0) { + return courses; + } + + return courses.stream() + .filter(course -> { + // 检查周次范围 + Integer startWeek = course.getStartWeek(); + Integer endWeek = course.getEndWeek(); + + // 如果没有设置周次范围,默认显示 + if (startWeek == null || endWeek == null) { + return true; + } + + // 检查当前周是否在范围内 + if (currentWeek < startWeek || currentWeek > endWeek) { + return false; + } + + // 检查单双周 + Integer weekType = course.getWeekType(); + if (weekType == null || weekType == 0) { + return true; // 每周都显示 + } else if (weekType == 1) { + return currentWeek % 2 == 1; // 单周 + } else if (weekType == 2) { + return currentWeek % 2 == 0; // 双周 + } + + return true; + }) + .collect(Collectors.toList()); + } + + /** + * 检查两个时间段是否冲突 + * + * @param startTime1 第一个时间段的开始时间 + * @param endTime1 第一个时间段的结束时间 + * @param startTime2 第二个时间段的开始时间 + * @param endTime2 第二个时间段的结束时间 + * @return 是否冲突 + */ + private boolean isTimeConflict(String startTime1, String endTime1, String startTime2, String endTime2) { + try { + // 将时间字符串转换为分钟数进行比较 + int start1 = timeToMinutes(startTime1); + int end1 = timeToMinutes(endTime1); + int start2 = timeToMinutes(startTime2); + int end2 = timeToMinutes(endTime2); + + // 时间冲突的条件:两个时间段有重叠 + // 不冲突的条件:end1 <= start2 或 end2 <= start1 + // 冲突的条件:!(end1 <= start2 || end2 <= start1) = (end1 > start2 && end2 > start1) + boolean conflict = (end1 > start2 && end2 > start1); + + log.debug("时间冲突检测: {}({}-{}) vs {}({}-{}) = {}", + start1, end1, start2, end2, + startTime1, endTime1, startTime2, endTime2, conflict); + + return conflict; + } catch (Exception e) { + log.error("时间冲突检测失败: {}", e.getMessage()); + return false; + } + } + + /** + * 将时间字符串转换为分钟数 + * + * @param timeStr 时间字符串,格式为 "HH:mm" + * @return 分钟数 + */ + private int timeToMinutes(String timeStr) { + String[] parts = timeStr.split(":"); + int hours = Integer.parseInt(parts[0]); + int minutes = Integer.parseInt(parts[1]); + return hours * 60 + minutes; + } + + /** + * 检查两个课程的周次是否冲突 + * + * @param startWeek1 第一个课程的开始周次 + * @param endWeek1 第一个课程的结束周次 + * @param weekType1 第一个课程的单双周类型 + * @param startWeek2 第二个课程的开始周次 + * @param endWeek2 第二个课程的结束周次 + * @param weekType2 第二个课程的单双周类型 + * @return 是否冲突 + */ + private boolean isWeekConflict(Integer startWeek1, Integer endWeek1, Integer weekType1, + Integer startWeek2, Integer endWeek2, Integer weekType2) { + // 设置默认值 + int start1 = startWeek1 != null ? startWeek1 : 1; + int end1 = endWeek1 != null ? endWeek1 : 16; + int type1 = weekType1 != null ? weekType1 : 0; + + int start2 = startWeek2 != null ? startWeek2 : 1; + int end2 = endWeek2 != null ? endWeek2 : 16; + int type2 = weekType2 != null ? weekType2 : 0; + + // 检查周次范围是否有重叠 + boolean weekRangeOverlap = !(end1 < start2 || end2 < start1); + + if (!weekRangeOverlap) { + log.debug("周次范围无重叠: {}-{} vs {}-{}", start1, end1, start2, end2); + return false; // 周次范围没有重叠,不冲突 + } + + // 如果周次范围有重叠,检查单双周是否冲突 + if (type1 == 0 || type2 == 0) { + // 如果任一课程是每周都有,则冲突 + log.debug("周次冲突: 存在每周课程 type1={}, type2={}", type1, type2); + return true; + } + + if (type1 == type2) { + // 如果都是单周或都是双周,则冲突 + log.debug("周次冲突: 相同单双周类型 type1={}, type2={}", type1, type2); + return true; + } + + // 一个是单周,一个是双周,不冲突 + log.debug("周次无冲突: 不同单双周类型 type1={}, type2={}", type1, type2); + return false; + } +} diff --git a/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java new file mode 100644 index 0000000..9c6552a --- /dev/null +++ b/schedule-ocr-backend/src/main/java/com/scheduleocr/service/OcrService.java @@ -0,0 +1,915 @@ +package com.scheduleocr.service; + +import com.baidu.aip.ocr.AipOcr; +import com.scheduleocr.config.BaiduOcrConfig; +import com.scheduleocr.dto.CourseDTO; +import com.scheduleocr.dto.OcrResult; +import org.json.JSONArray; +import org.json.JSONObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +/** + * OCR识别服务类 + * + * @author scheduleocr + * @version 1.0.0 + */ +@Service +public class OcrService { + + private static final Logger log = LoggerFactory.getLogger(OcrService.class); + + @Autowired + private AipOcr aipOcr; + + @Autowired + private BaiduOcrConfig baiduConfig; + + /** + * 识别课表图片 + * + * @param file 上传的图片文件 + * @param userOpenid 用户openid + * @return OCR识别结果 + */ + public OcrResult recognizeSchedule(MultipartFile file, String userOpenid) { + try { + log.info("开始OCR识别,文件名: {}, 用户: {}", file.getOriginalFilename(), userOpenid); + + // 检查文件 + if (file.isEmpty()) { + return OcrResult.error("上传文件为空"); + } + + // 检查文件类型 + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + return OcrResult.error("请上传图片文件"); + } + + // 检查文件大小(10MB限制) + if (file.getSize() > 10 * 1024 * 1024) { + return OcrResult.error("图片文件不能超过10MB"); + } + + // 调用百度OCR API + byte[] imageBytes = file.getBytes(); + + // 验证图片格式 + validateImageFormat(file, imageBytes); + + // 首先尝试表格识别API + JSONObject response = null; + try { + response = callTableRecognitionAPI(imageBytes); + log.info("使用表格识别API成功"); + } catch (Exception e) { + log.warn("表格识别API调用失败,回退到通用识别: {}", e.getMessage()); + // 回退到通用识别 + HashMap options = new HashMap<>(); + options.put("language_type", "CHN_ENG"); + options.put("detect_direction", "true"); + options.put("detect_language", "true"); + options.put("probability", "true"); + response = aipOcr.basicGeneral(imageBytes, options); + } + log.info("百度OCR响应: {}", response.toString()); + + // 格式化显示OCR识别的文本内容 + formatAndLogOCRResults(response); + + // 检查API调用是否成功 + if (response.has("error_code")) { + String errorMsg = response.optString("error_msg", "OCR识别失败"); + log.error("百度OCR API错误: {}", errorMsg); + return OcrResult.error("OCR识别失败: " + errorMsg); + } + + // 解析识别结果 + List textLines = new ArrayList<>(); + StringBuilder rawText = new StringBuilder(); + + // 检查是否是表格识别结果 + if (response.has("tables_result")) { + // 表格识别结果 + JSONArray tablesResult = response.optJSONArray("tables_result"); + if (tablesResult != null && tablesResult.length() > 0) { + JSONObject table = tablesResult.getJSONObject(0); // 取第一个表格 + JSONArray bodyArray = table.optJSONArray("body"); + if (bodyArray != null) { + for (int i = 0; i < bodyArray.length(); i++) { + JSONObject cell = bodyArray.getJSONObject(i); + String words = cell.optString("words", ""); + if (!words.trim().isEmpty()) { + textLines.add(words.trim()); + rawText.append(words.trim()).append("\n"); + } + } + } + } + } else { + // 通用OCR结果 + JSONArray wordsResult = response.optJSONArray("words_result"); + if (wordsResult != null) { + for (int i = 0; i < wordsResult.length(); i++) { + JSONObject wordInfo = wordsResult.getJSONObject(i); + String words = wordInfo.optString("words", ""); + if (!words.trim().isEmpty()) { + textLines.add(words.trim()); + rawText.append(words.trim()).append("\n"); + } + } + } + } + + // 解析课程信息 - 只使用表格识别方法 + List courses; + + if (response.has("tables_result")) { + courses = parseCoursesFromTableResult(response, userOpenid); + log.info("使用表格解析方法,成功解析出 {} 门课程", courses.size()); + } else { + log.warn("OCR响应中没有表格识别结果,无法解析课程"); + courses = new ArrayList<>(); + } + + log.info("OCR识别完成,识别到{}行文本,解析出{}门课程", textLines.size(), courses.size()); + return OcrResult.success(rawText.toString(), textLines, courses); + + } catch (IOException e) { + log.error("读取上传文件失败", e); + return OcrResult.error("读取上传文件失败: " + e.getMessage()); + } catch (Exception e) { + log.error("OCR识别过程中发生错误", e); + return OcrResult.error("OCR识别失败: " + e.getMessage()); + } + } + + /** + * 格式化并记录OCR识别结果 + */ + private void formatAndLogOCRResults(JSONObject response) { + if (response.has("words_result")) { + JSONArray wordsResult = response.getJSONArray("words_result"); + log.info("=== OCR识别文本内容 ==="); + for (int i = 0; i < wordsResult.length(); i++) { + JSONObject wordItem = wordsResult.getJSONObject(i); + String words = wordItem.getString("words"); + log.info("第{}行: {}", i + 1, words); + } + log.info("=== 共识别{}行文本 ===", wordsResult.length()); + } + } + /** + * 课程信息类 + */ + private static class CourseInfo { + String courseName; + String teacher; + String classroom; + + CourseInfo(String courseName, String teacher, String classroom) { + this.courseName = courseName; + this.teacher = teacher; + this.classroom = classroom; + } + } + + + + /** + * 调用百度OCR表格识别V2 API + */ + private JSONObject callTableRecognitionAPI(byte[] imageBytes) throws Exception { + log.info("开始调用表格识别API,图片大小: {} bytes", imageBytes.length); + + // 验证图片大小 + if (imageBytes.length > 4 * 1024 * 1024) { // 4MB限制 + throw new RuntimeException("图片文件过大,请压缩后重试(最大4MB)"); + } + if (imageBytes.length < 100) { // 最小100字节 + throw new RuntimeException("图片文件过小,请检查文件是否完整"); + } + + // 获取access_token + String accessToken = getAccessToken(); + + // 构建请求URL + String url = "https://aip.baidubce.com/rest/2.0/ocr/v1/table?access_token=" + accessToken; + + // 将图片转换为base64(不进行URL编码,直接传递) + String base64Image = Base64.getEncoder().encodeToString(imageBytes); + log.debug("Base64图片长度: {}", base64Image.length()); + + // 构建请求体 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("image", base64Image); // 直接使用base64,不进行URL编码 + params.add("cell_contents", "true"); // 输出单元格文字位置信息 + + // 设置请求头 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + // 创建请求实体 + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + // 发送请求 + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); + + log.info("表格识别API响应状态: {}", response.getStatusCode()); + + if (response.getStatusCode() == HttpStatus.OK) { + JSONObject result = new JSONObject(response.getBody()); + + // 检查API响应中的错误 + if (result.has("error_code")) { + String errorMsg = result.optString("error_msg", "未知错误"); + int errorCode = result.optInt("error_code", 0); + log.error("百度表格识别API错误 - 错误码: {}, 错误信息: {}", errorCode, errorMsg); + + // 提供更友好的错误信息 + String friendlyMsg = getFriendlyErrorMessage(errorCode, errorMsg); + throw new RuntimeException(friendlyMsg); + } + + return result; + } else { + throw new RuntimeException("表格识别API调用失败: " + response.getStatusCode()); + } + } + + /** + * 获取友好的错误信息 + */ + private String getFriendlyErrorMessage(int errorCode, String errorMsg) { + switch (errorCode) { + case 216201: + return "图片格式错误,请上传JPG、PNG、BMP格式的图片"; + case 216202: + return "图片文件过大,请压缩后重试(最大4MB)"; + case 216630: + return "识别错误,请确保图片清晰且包含表格内容"; + case 17: + return "每日调用量超限,请明天再试或升级套餐"; + case 18: + return "QPS超限,请稍后重试"; + case 19: + return "请求总量超限,请升级套餐"; + default: + return "OCR识别失败: " + errorMsg; + } + } + + /** + * 获取百度API的access_token + */ + private String getAccessToken() throws Exception { + String url = "https://aip.baidubce.com/oauth/2.0/token"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "client_credentials"); + params.add("client_id", baiduConfig.getApiKey()); + params.add("client_secret", baiduConfig.getSecretKey()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + JSONObject jsonResponse = new JSONObject(response.getBody()); + return jsonResponse.getString("access_token"); + } else { + throw new RuntimeException("获取access_token失败: " + response.getStatusCode()); + } + } + + /** + * 从表格识别结果中解析课程信息 + */ + private List parseCoursesFromTableResult(JSONObject response, String userOpenid) { + List courses = new ArrayList<>(); + + try { + JSONArray tablesResult = response.optJSONArray("tables_result"); + if (tablesResult == null || tablesResult.length() == 0) { + log.warn("表格识别结果为空"); + return courses; + } + + // 取第一个表格 + JSONObject table = tablesResult.getJSONObject(0); + JSONArray bodyArray = table.optJSONArray("body"); + if (bodyArray == null) { + log.warn("表格body为空"); + return courses; + } + + // 构建表格结构:行列映射 + Map> tableGrid = new HashMap<>(); + + // 解析每个单元格 + for (int i = 0; i < bodyArray.length(); i++) { + JSONObject cell = bodyArray.getJSONObject(i); + String words = cell.optString("words", "").trim(); + int rowStart = cell.optInt("row_start", -1); + int colStart = cell.optInt("col_start", -1); + + if (!words.isEmpty() && rowStart >= 0 && colStart >= 0) { + String rowKey = "row_" + rowStart; + String colKey = "col_" + colStart; + + tableGrid.computeIfAbsent(rowKey, k -> new HashMap<>()).put(colKey, words); + log.debug("表格单元格[{},{}]: {}", rowStart, colStart, words); + } + } + + // 解析课程信息 + courses = extractCoursesFromTableGrid(tableGrid, userOpenid); + + } catch (Exception e) { + log.error("解析表格识别结果时发生错误", e); + } + + return courses; + } + + /** + * 从表格网格中提取课程信息 + */ + private List extractCoursesFromTableGrid(Map> tableGrid, String userOpenid) { + List courses = new ArrayList<>(); + + try { + // 星期映射 + Map dayMapping = new HashMap<>(); + dayMapping.put("星期一", 1); + dayMapping.put("星期二", 2); + dayMapping.put("星期三", 3); + dayMapping.put("星期四", 4); + dayMapping.put("星期五", 5); + dayMapping.put("星期六", 6); + dayMapping.put("星期日", 7); + + // 获取表头信息(第0行) + Map headerRow = tableGrid.get("row_0"); + if (headerRow == null) { + log.warn("找不到表头行"); + return courses; + } + + // 建立列索引到星期的映射 + Map colToDayMapping = new HashMap<>(); + for (Map.Entry entry : headerRow.entrySet()) { + String colKey = entry.getKey(); + String cellValue = entry.getValue(); + + if (colKey.startsWith("col_")) { + int colIndex = Integer.parseInt(colKey.substring(4)); + Integer dayOfWeek = dayMapping.get(cellValue); + if (dayOfWeek != null) { + colToDayMapping.put(colIndex, dayOfWeek); + log.debug("列{}对应{}", colIndex, cellValue); + } + } + } + + // 遍历数据行(从第1行开始) + for (int rowIndex = 1; rowIndex <= 10; rowIndex++) { // 假设最多10行 + Map row = tableGrid.get("row_" + rowIndex); + if (row == null) continue; + + // 获取时间信息(第0列) + String timeInfo = row.get("col_0"); + if (timeInfo == null || timeInfo.isEmpty()) continue; + + // 解析节次信息 + String[] timeLines = timeInfo.split("\n"); + String periodInfo = ""; + String timeRange = ""; + + for (String line : timeLines) { + if (line.contains("第") && line.contains("节")) { + periodInfo = line.trim(); + } else if (line.contains(":") && line.contains("~")) { + timeRange = line.replace("(", "").replace(")", "").trim(); + } + } + + if (periodInfo.isEmpty()) continue; + + // 解析节次数字 + int startPeriod = extractPeriodNumber(periodInfo); + if (startPeriod <= 0) continue; + + // 遍历每一列(星期) + for (Map.Entry dayEntry : colToDayMapping.entrySet()) { + int colIndex = dayEntry.getKey(); + int dayOfWeek = dayEntry.getValue(); + + String courseInfo = row.get("col_" + colIndex); + if (courseInfo == null || courseInfo.trim().isEmpty()) continue; + + // 解析课程信息(可能包含多个节次和多个周次) + List parsedCourses = parseCourseInfo(courseInfo, userOpenid, dayOfWeek, startPeriod, timeRange); + if (parsedCourses != null && !parsedCourses.isEmpty()) { + courses.addAll(parsedCourses); + log.debug("解析到{}门课程: {} - {} - 第{}节", parsedCourses.size(), + parsedCourses.get(0).getCourseName(), getDayName(dayOfWeek), startPeriod); + } + } + } + + } catch (Exception e) { + log.error("从表格网格提取课程信息时发生错误", e); + } + + return courses; + } + + /** + * 从节次信息中提取节次数字 + */ + private int extractPeriodNumber(String periodInfo) { + try { + // 匹配"第X节"格式 + if (periodInfo.contains("第") && periodInfo.contains("节")) { + String numberStr = periodInfo.replaceAll("[^0-9]", ""); + if (!numberStr.isEmpty()) { + return Integer.parseInt(numberStr); + } + } + } catch (Exception e) { + log.debug("解析节次数字失败: {}", periodInfo); + } + return 0; + } + + /** + * 解析课程信息(支持多节次和多周次) + */ + private List parseCourseInfo(String courseInfo, String userOpenid, int dayOfWeek, int startPeriod, String timeRange) { + List courses = new ArrayList<>(); + try { + String[] lines = courseInfo.split("\n"); + if (lines.length < 1) return courses; + + // 重新组合完整的课程信息并预处理 + String fullText = String.join("", lines); + fullText = preprocessOcrText(fullText); + log.debug("完整文本: {}", fullText); + + // 同时预处理每一行 + for (int i = 0; i < lines.length; i++) { + lines[i] = preprocessOcrText(lines[i]); + } + + // 解析各个部分 + String courseName = ""; + String teacher = ""; + String classroom = ""; + String weekInfo = ""; + String classInfo = ""; + + // 增强的教室识别逻辑 + for (String line : lines) { + line = line.trim(); + // 匹配多种教室格式:号楼、S开头的实验室、纯数字教室等 + if (line.matches(".*\\d+号楼.*") || + line.matches(".*S\\d+.*") || + line.matches(".*[A-Z]\\d+.*") || + (line.matches("\\d{4,}") && line.length() <= 6)) { + classroom = line; + break; + } + } + + // 增强的教师识别逻辑 + for (int i = 0; i < lines.length; i++) { + String line = lines[i].trim(); + + // 如果找到了教室,教师通常在教室前面 + if (!classroom.isEmpty() && line.equals(classroom)) { + // 向前查找教师 + for (int j = i - 1; j >= 0; j--) { + String prevLine = lines[j].trim(); + if (isTeacherName(prevLine)) { + teacher = prevLine; + break; + } + } + break; + } + + // 直接匹配教师姓名模式 + if (isTeacherName(line)) { + teacher = line; + } + } + + // 增强的周次和节次信息收集 + for (String line : lines) { + line = line.trim(); + if (line.contains("周") || line.contains("节") || + line.matches(".*\\[\\d+\\].*") || + line.contains("单") || line.contains("双")) { + weekInfo += line + " "; + } + } + + // 增强的课程名称组合逻辑 + StringBuilder courseNameBuilder = new StringBuilder(); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty() || line.equals(teacher) || line.equals(classroom)) { + continue; + } + // 跳过明显的周次、节次、时间信息 + if (line.contains("周") || line.contains("节") || + line.matches(".*\\d{2}:\\d{2}.*") || + line.matches(".*\\[\\d+\\].*")) { + continue; + } + courseNameBuilder.append(line); + } + courseName = courseNameBuilder.toString(); + + // 从课程名称中分离出班级信息 + if (courseName.contains("[") && courseName.contains("]")) { + int startIndex = courseName.indexOf("["); + int endIndex = courseName.indexOf("]"); + if (startIndex < endIndex) { + classInfo = courseName.substring(startIndex, endIndex + 1); + courseName = courseName.substring(0, startIndex).trim(); + } + } + + weekInfo = weekInfo.trim(); + + if (courseName.isEmpty()) return courses; + + log.debug("解析课程信息 - 原始: {}", courseInfo.replaceAll("\n", " | ")); + log.debug("解析结果 - 课程: {}, 教师: {}, 教室: {}", courseName, teacher, classroom); + log.debug("周次节次信息: {}", weekInfo); + + // 解析节次信息(可能是范围,如"第1-2节") + List periods = parsePeriodRange(weekInfo); + log.debug("解析到的节次: {}", periods); + if (periods.isEmpty()) { + periods.add(startPeriod); // 默认使用传入的节次 + } + + // 解析周次信息(如"1-16周") + List weeks = parseWeekRange(weekInfo); + String weekRange = ""; + Integer startWeek = 1; + Integer endWeek = 16; + if (!weeks.isEmpty()) { + startWeek = weeks.get(0); + endWeek = weeks.get(weeks.size() - 1); + weekRange = startWeek + "-" + endWeek + "周"; + } else { + weekRange = "1-16周"; // 默认整个学期 + } + + // 解析单双周信息 + Integer weekType = parseWeekType(weekInfo); + + // 为每个节次创建独立的课程记录 + String[] timeSlots = getTimeSlots(); + + for (Integer period : periods) { + String startTime = ""; + String endTime = ""; + + // 根据节次获取对应的时间段(每个节次使用自己的时间) + if (period >= 1 && period <= timeSlots.length) { + String[] timeSlot = timeSlots[period - 1].split("-"); + startTime = timeSlot[0]; + endTime = timeSlot[1]; + } else { + log.warn("无效的节次: {}, 跳过", period); + continue; + } + + CourseDTO course = new CourseDTO(); + course.setCourseName(courseName); + course.setTeacherName(teacher); + course.setClassroom(classroom); + course.setDayOfWeek(dayOfWeek); + course.setStartTime(startTime); + course.setEndTime(endTime); + course.setUserOpenid(userOpenid); + + // 设置周次信息 + course.setStartWeek(startWeek); + course.setEndWeek(endWeek); + course.setWeekType(weekType); + + // 添加班级和周次信息到备注 + String notes = ""; + if (!classInfo.isEmpty()) { + notes += classInfo + " "; + } + notes += weekRange; + if (weekType == 1) { + notes += "(单)"; + } else if (weekType == 2) { + notes += "(双)"; + } + course.setNotes(notes.trim()); + + courses.add(course); + } + + return courses; + + } catch (Exception e) { + log.debug("解析课程信息失败: {}", courseInfo, e); + return courses; + } + } + + /** + * 判断是否为教师姓名 + */ + private boolean isTeacherName(String text) { + if (text == null || text.trim().isEmpty()) { + return false; + } + text = text.trim(); + + // 长度在2-4个字符之间 + if (text.length() < 2 || text.length() > 4) { + return false; + } + + // 全部是中文字符 + if (!text.matches("[\u4e00-\u9fa5]+")) { + return false; + } + + // 排除一些明显不是姓名的词 + String[] excludeWords = {"周一", "周二", "周三", "周四", "周五", "周六", "周日", + "上午", "下午", "晚上", "单周", "双周", "实验", "理论", "课程"}; + for (String exclude : excludeWords) { + if (text.contains(exclude)) { + return false; + } + } + + return true; + } + + /** + * 解析节次范围(如"第1-2节") + */ + private List parsePeriodRange(String info) { + List periods = new ArrayList<>(); + try { + if (info != null && info.contains("第") && info.contains("节")) { + // 匹配"第X-Y节"或"第X节",支持空格 + String periodPart = info.replaceAll(".*第\\s*([0-9-]+)\\s*节.*", "$1"); + log.debug("提取的节次部分: {}", periodPart); + + if (periodPart.contains("-")) { + String[] range = periodPart.split("-"); + if (range.length == 2) { + int start = Integer.parseInt(range[0].trim()); + int end = Integer.parseInt(range[1].trim()); + for (int i = start; i <= end; i++) { + periods.add(i); + } + } + } else if (periodPart.matches("\\d+")) { + periods.add(Integer.parseInt(periodPart)); + } + } + } catch (Exception e) { + log.debug("解析节次范围失败: {}, 错误: {}", info, e.getMessage()); + } + return periods; + } + + /** + * 解析周次范围(如"1-16周") + */ + private List parseWeekRange(String info) { + List weeks = new ArrayList<>(); + try { + if (info != null && info.contains("周")) { + log.debug("解析周次信息: {}", info); + + // 匹配多种周次格式 + Pattern[] patterns = { + Pattern.compile("(\\d+)-(\\d+)周"), // "1-16周" + Pattern.compile("\\[(\\d+)\\]\\s*(\\d+)-(\\d+)"), // "[01] 1-16" + Pattern.compile("第(\\d+)-(\\d+)周"), // "第1-16周" + Pattern.compile("(\\d+)~(\\d+)周"), // "1~16周" + Pattern.compile("(\\d+)至(\\d+)周"), // "1至16周" + Pattern.compile("(\\d+)\\s*-\\s*(\\d+)") // "1 - 16" + }; + + boolean found = false; + for (Pattern pattern : patterns) { + Matcher matcher = pattern.matcher(info); + if (matcher.find()) { + int start, end; + if (pattern.pattern().contains("\\[(\\d+)\\]")) { + // 特殊格式 "[01] 1-16" + start = Integer.parseInt(matcher.group(2)); + end = Integer.parseInt(matcher.group(3)); + } else { + start = Integer.parseInt(matcher.group(1)); + end = Integer.parseInt(matcher.group(2)); + } + log.debug("匹配到周次范围: {}-{}", start, end); + for (int i = start; i <= end; i++) { + weeks.add(i); + } + found = true; + break; + } + } + + // 如果没有匹配到范围,尝试匹配单个周次 + if (!found) { + Pattern[] singlePatterns = { + Pattern.compile("第?(\\d+)周"), + Pattern.compile("\\[(\\d+)\\]"), + Pattern.compile("(\\d+)") + }; + + for (Pattern pattern : singlePatterns) { + Matcher matcher = pattern.matcher(info); + if (matcher.find()) { + int week = Integer.parseInt(matcher.group(1)); + log.debug("匹配到单个周次: {}", week); + weeks.add(week); + found = true; + break; + } + } + } + + if (!found) { + log.debug("未能匹配到周次信息"); + } + } + } catch (Exception e) { + log.debug("解析周次范围失败: {}, 错误: {}", info, e.getMessage()); + } + return weeks; + } + + /** + * 解析单双周类型 + * @param info 包含周次信息的字符串 + * @return 0=每周,1=单周,2=双周 + */ + private Integer parseWeekType(String info) { + if (info == null) return 0; + + String lowerInfo = info.toLowerCase(); + if (lowerInfo.contains("(单)") || lowerInfo.contains("(单)") || lowerInfo.contains("单周")) { + return 1; // 单周 + } else if (lowerInfo.contains("(双)") || lowerInfo.contains("(双)") || lowerInfo.contains("双周")) { + return 2; // 双周 + } + return 0; // 每周 + } + + /** + * 获取星期名称 + */ + private String getDayName(int dayOfWeek) { + String[] dayNames = {"", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}; + if (dayOfWeek >= 1 && dayOfWeek <= 7) { + return dayNames[dayOfWeek]; + } + return "未知"; + } + + /** + * 获取时间段映射 + */ + private String[] getTimeSlots() { + return new String[]{ + "08:00-08:45", // 第1节 + "08:55-09:40", // 第2节 + "10:00-10:45", // 第3节 + "10:55-11:40", // 第4节 + "14:10-14:55", // 第5节 + "15:05-15:50", // 第6节 + "16:00-16:45", // 第7节 + "16:55-17:40", // 第8节 + "18:40-19:25", // 第9节 + "19:30-20:15", // 第10节 + "20:20-21:05" // 第11节 + }; + } + + /** + * 预处理OCR识别的文本 + */ + private String preprocessOcrText(String text) { + if (text == null) return ""; + + return text + .replaceAll("\\s+", " ") // 多个空白字符替换为单个空格 + .replaceAll("([0-9])\\s+([0-9])", "$1$2") // 数字间的空格去除 + .replaceAll("([a-zA-Z])\\s+([a-zA-Z])", "$1$2") // 字母间的空格去除 + .replaceAll("([\\u4e00-\\u9fa5])\\s+([\\u4e00-\\u9fa5])", "$1$2") // 中文字符间的空格去除 + .replaceAll("(\\d+)\\s*-\\s*(\\d+)", "$1-$2") // 数字范围中的空格去除 + .replaceAll("(\\d+)\\s*~\\s*(\\d+)", "$1~$2") // 波浪号范围中的空格去除 + .replaceAll("第\\s*(\\d+)", "第$1") // "第 X" -> "第X" + .replaceAll("(\\d+)\\s*号楼", "$1号楼") // "X 号楼" -> "X号楼" + .replaceAll("S\\s*(\\d+)", "S$1") // "S X" -> "SX" + .trim(); + } + + /** + * 验证图片格式 + */ + private void validateImageFormat(MultipartFile file, byte[] imageBytes) throws Exception { + String originalFilename = file.getOriginalFilename(); + String contentType = file.getContentType(); + + log.info("上传文件信息 - 文件名: {}, 内容类型: {}, 大小: {} bytes", + originalFilename, contentType, imageBytes.length); + + // 检查文件大小 + if (imageBytes.length > 4 * 1024 * 1024) { // 4MB + throw new RuntimeException("图片文件过大,请压缩后重试(最大4MB)"); + } + if (imageBytes.length < 100) { + throw new RuntimeException("图片文件过小,请检查文件是否完整"); + } + + // 检查文件扩展名 + if (originalFilename != null) { + String extension = originalFilename.toLowerCase(); + if (!extension.endsWith(".jpg") && !extension.endsWith(".jpeg") && + !extension.endsWith(".png") && !extension.endsWith(".bmp")) { + throw new RuntimeException("不支持的图片格式,请上传JPG、PNG或BMP格式的图片"); + } + } + + // 检查MIME类型 + if (contentType != null && !contentType.startsWith("image/")) { + throw new RuntimeException("上传的文件不是图片格式"); + } + + // 检查图片文件头(魔数) + if (!isValidImageHeader(imageBytes)) { + throw new RuntimeException("图片文件损坏或格式不正确"); + } + + log.info("图片格式验证通过"); + } + + /** + * 检查图片文件头(魔数) + */ + private boolean isValidImageHeader(byte[] imageBytes) { + if (imageBytes.length < 4) return false; + + // JPEG: FF D8 FF + if (imageBytes[0] == (byte)0xFF && imageBytes[1] == (byte)0xD8 && imageBytes[2] == (byte)0xFF) { + return true; + } + + // PNG: 89 50 4E 47 + if (imageBytes[0] == (byte)0x89 && imageBytes[1] == (byte)0x50 && + imageBytes[2] == (byte)0x4E && imageBytes[3] == (byte)0x47) { + return true; + } + + // BMP: 42 4D + if (imageBytes[0] == (byte)0x42 && imageBytes[1] == (byte)0x4D) { + return true; + } + + return false; + } + + +} diff --git a/schedule-ocr-backend/src/main/resources/application-example.yml b/schedule-ocr-backend/src/main/resources/application-example.yml new file mode 100644 index 0000000..eccdaf5 --- /dev/null +++ b/schedule-ocr-backend/src/main/resources/application-example.yml @@ -0,0 +1,56 @@ +# 百度OCR配置示例文件 +# 请复制此文件为 application-local.yml 并填入真实的密钥信息 +# 然后使用 --spring.profiles.active=local 启动应用 + +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: schedule-ocr-backend + + # SQLite数据库配置 + datasource: + url: jdbc:sqlite:schedule.db + driver-class-name: org.sqlite.JDBC + username: + password: + + # JPA配置 + jpa: + database-platform: com.scheduleocr.config.SQLiteDialect + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: com.scheduleocr.config.SQLiteDialect + + # 文件上传配置 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + +# 百度OCR配置 - 请填入真实的密钥信息 +baidu: + ocr: + # 示例:app-id: "12345678" + app-id: "YOUR_REAL_APP_ID" + # 示例:api-key: "abcdefghijklmnopqrstuvwxyz123456" + api-key: "YOUR_REAL_API_KEY" + # 示例:secret-key: "ABCDEFGHIJKLMNOPQRSTUVWXYZ123456" + secret-key: "YOUR_REAL_SECRET_KEY" + +# 日志配置 +logging: + level: + com.scheduleocr: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/schedule-ocr-backend/src/main/resources/application.yml b/schedule-ocr-backend/src/main/resources/application.yml new file mode 100644 index 0000000..4cef6ab --- /dev/null +++ b/schedule-ocr-backend/src/main/resources/application.yml @@ -0,0 +1,58 @@ +server: + port: 8080 + servlet: + context-path: / + +spring: + application: + name: schedule-ocr-backend + + # SQLite数据库配置 + datasource: + url: jdbc:sqlite:schedule.db + driver-class-name: org.sqlite.JDBC + username: + password: + + # JPA配置 + jpa: + database-platform: com.scheduleocr.config.SQLiteDialect + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: com.scheduleocr.config.SQLiteDialect + + # 文件上传配置 + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + +# 百度OCR配置 +# 方式1:直接填写密钥(不推荐,容易泄露) +# 方式2:使用环境变量(推荐) +# 请在百度智能云控制台获取以下密钥信息 +# 1. 访问:https://console.bce.baidu.com/ +# 2. 搜索"表格文字识别OCR" +# 3. 创建应用获取密钥 +baidu: + ocr: + # 百度OCR应用ID + app-id: "7029216" + # 百度OCR API Key + api-key: "P7mro32NiHTZPovHOM8lHmLA" + # 百度OCR Secret Key + secret-key: "5VAag1QcBkV73qbOMhUiamwYbTyPKsN8" + +# 日志配置 +logging: + level: + com.scheduleocr: DEBUG + org.springframework.web: DEBUG + org.hibernate.SQL: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/schedule-ocr-backend/多节次多周次解析说明.md b/schedule-ocr-backend/多节次多周次解析说明.md new file mode 100644 index 0000000..9c1f225 --- /dev/null +++ b/schedule-ocr-backend/多节次多周次解析说明.md @@ -0,0 +1,107 @@ +# 多节次多周次课程解析优化 + +## 🎯 解析目标 + +### 原始OCR数据: +``` +数据库原理与应用(A)[04]1-16周(第1-2节)曾广平 15号楼15307 +``` + +### 解析需求: +1. **课程名称**:数据库原理与应用(A) +2. **周次范围**:1-16周(表示整个学期都有这门课) +3. **节次范围**:第1-2节(表示占用两个连续时间段) +4. **教师**:曾广平 +5. **教室**:15号楼15307 + +## ✅ 优化后的解析逻辑 + +### 1. 节次范围解析 +- **输入**:`第1-2节` +- **解析**:创建两条课程记录 + - 第1节:08:00-08:45 + - 第2节:08:55-09:40 + +### 2. 周次范围解析 +- **输入**:`1-16周` +- **解析**:在备注中记录周次范围 +- **避免**:不为每个周次创建单独记录(避免数据冗余) + +### 3. 最终结果 +从一条OCR数据生成两条课程记录: + +**记录1:** +```json +{ + "courseName": "数据库原理与应用(A)", + "teacherName": "曾广平", + "classroom": "15号楼15307", + "dayOfWeek": 3, + "startTime": "08:00", + "endTime": "08:45", + "notes": "1-16周" +} +``` + +**记录2:** +```json +{ + "courseName": "数据库原理与应用(A)", + "teacherName": "曾广平", + "classroom": "15号楼15307", + "dayOfWeek": 3, + "startTime": "08:55", + "endTime": "09:40", + "notes": "1-16周" +} +``` + +## 🔧 核心方法 + +### `parsePeriodRange(String info)` +- 解析节次范围:`第1-2节` → `[1, 2]` +- 支持单节次:`第3节` → `[3]` + +### `parseWeekRange(String info)` +- 解析周次范围:`1-16周` → `[1, 2, ..., 16]` +- 用于生成备注信息 + +### `parseCourseInfo()` +- 返回类型改为 `List` +- 为每个节次创建独立的课程记录 +- 在备注中记录周次信息 + +## 📱 前端兼容性 + +### 现有前端逻辑: +- ✅ 按课程记录显示,天然支持多节次 +- ✅ 课程表网格会正确显示所有时间段的课程 +- ✅ 不需要修改前端代码 + +### 显示效果: +``` +星期三 +┌─────────────────┐ +│ 第1节 08:00-08:45 │ +│ 数据库原理与应用(A) │ +│ 曾广平 15号楼15307 │ +├─────────────────┤ +│ 第2节 08:55-09:40 │ +│ 数据库原理与应用(A) │ +│ 曾广平 15号楼15307 │ +└─────────────────┘ +``` + +## 🧪 测试验证 + +### 预期结果: +1. **OCR识别**:成功识别表格和课程信息 +2. **解析数量**:从原来的0门课程变为实际课程数量 +3. **节次处理**:连续节次的课程会创建多条记录 +4. **前端显示**:课程表正确显示所有时间段的课程 + +### 测试步骤: +1. 重启后端服务 +2. 使用小程序上传课程表图片 +3. 查看后端日志:应该显示解析出的课程数量 > 0 +4. 检查前端课程表:应该显示完整的课程安排 diff --git a/schedule-ocr-backend/百度OCR配置说明.md b/schedule-ocr-backend/百度OCR配置说明.md new file mode 100644 index 0000000..1d86f42 --- /dev/null +++ b/schedule-ocr-backend/百度OCR配置说明.md @@ -0,0 +1,112 @@ +# 百度OCR配置说明 + +## 📋 获取百度OCR密钥步骤 + +### 1. 注册百度智能云账号 +- 访问:https://cloud.baidu.com/ +- 注册并登录账号 + +### 2. 开通文字识别服务 +- 登录后访问:https://console.bce.baidu.com/ +- 搜索"文字识别OCR" +- 点击"立即使用" + +### 3. 创建应用 +- 在OCR控制台中点击"创建应用" +- 填写应用名称:`课表OCR识别` +- 选择应用类型:`通用文字识别` +- 创建成功后获取以下信息: + - **AppID**:应用ID + - **API Key**:API密钥 + - **Secret Key**:安全密钥 + +## 🔧 配置到项目 + +### 方法1:直接修改application.yml +```yaml +baidu: + ocr: + app-id: "你的AppID" + api-key: "你的API Key" + secret-key: "你的Secret Key" +``` + +### 方法2:使用环境变量(推荐) +```bash +# Windows +set BAIDU_OCR_APP_ID=你的AppID +set BAIDU_OCR_API_KEY=你的API Key +set BAIDU_OCR_SECRET_KEY=你的Secret Key + +# Linux/Mac +export BAIDU_OCR_APP_ID=你的AppID +export BAIDU_OCR_API_KEY=你的API Key +export BAIDU_OCR_SECRET_KEY=你的Secret Key +``` + +然后在application.yml中使用: +```yaml +baidu: + ocr: + app-id: ${BAIDU_OCR_APP_ID:YOUR_APP_ID} + api-key: ${BAIDU_OCR_API_KEY:YOUR_API_KEY} + secret-key: ${BAIDU_OCR_SECRET_KEY:YOUR_SECRET_KEY} +``` + +## 🧪 测试OCR配置 + +### 1. 配置测试 +```bash +curl http://localhost:8080/api/ocr-test/config +``` + +### 2. 简单功能测试 +```bash +curl -X POST "http://localhost:8080/api/ocr-test/simple?imageUrl=图片URL" +``` + +### 3. 完整功能测试 +使用Postman或其他工具上传图片到: +``` +POST http://localhost:8080/api/ocr/upload +``` + +## 💰 费用说明 + +百度OCR提供免费额度: +- **通用文字识别**:每月1000次免费 +- **高精度文字识别**:每月500次免费 +- 超出免费额度后按次计费 + +对于课表OCR小程序的使用量,免费额度通常足够。 + +## 🔍 常见问题 + +### Q1: 获取不到密钥? +- 确保已完成实名认证 +- 检查是否已开通文字识别服务 +- 确认应用创建成功 + +### Q2: API调用失败? +- 检查密钥是否正确配置 +- 确认网络连接正常 +- 查看错误码对应的具体原因 + +### Q3: 识别效果不好? +- 确保图片清晰度足够 +- 避免图片过大或过小 +- 可以尝试高精度识别接口 + +## 📞 技术支持 + +如遇到问题,可以: +1. 查看百度OCR官方文档:https://cloud.baidu.com/doc/OCR/ +2. 访问百度智能云技术论坛 +3. 联系百度智能云客服 + +## 🚀 下一步 + +配置完成后,您可以: +1. 重启应用 +2. 访问 http://localhost:8080/api/ocr-test/help 查看测试说明 +3. 开始开发微信小程序前端 diff --git a/schedule-ocr-miniprogram/miniprogram/app.json b/schedule-ocr-miniprogram/miniprogram/app.json new file mode 100644 index 0000000..4801ddd --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.json @@ -0,0 +1,40 @@ +{ + "pages": [ + "pages/index/index", + "pages/course-add/course-add", + "pages/ocr-import/ocr-import", + "pages/profile/profile" + ], + "window": { + "navigationBarTextStyle": "white", + "navigationBarTitleText": "课表助手", + "navigationBarBackgroundColor": "#1296db", + "backgroundColor": "#f8f8f8", + "backgroundTextStyle": "light" + }, + + "tabBar": { + "color": "#999999", + "selectedColor": "#1296db", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/index/index", + "text": "首页", + "iconPath": "images/tab-home.png", + "selectedIconPath": "images/tab-home-active.png" + }, + { + "pagePath": "pages/profile/profile", + "text": "个人中心", + "iconPath": "images/tab-profile.png", + "selectedIconPath": "images/tab-profile-active.png" + } + ] + }, + + "style": "v2", + "componentFramework": "glass-easel", + "lazyCodeLoading": "requiredComponents" +} \ No newline at end of file diff --git a/schedule-ocr-miniprogram/miniprogram/app.ts b/schedule-ocr-miniprogram/miniprogram/app.ts new file mode 100644 index 0000000..1af73a8 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.ts @@ -0,0 +1,18 @@ +// app.ts +App({ + globalData: {}, + onLaunch() { + // 展示本地存储能力 + const logs = wx.getStorageSync('logs') || [] + logs.unshift(Date.now()) + wx.setStorageSync('logs', logs) + + // 登录 + wx.login({ + success: res => { + console.log(res.code) + // 发送 res.code 到后台换取 openId, sessionKey, unionId + }, + }) + }, +}) \ No newline at end of file diff --git a/schedule-ocr-miniprogram/miniprogram/app.wxss b/schedule-ocr-miniprogram/miniprogram/app.wxss new file mode 100644 index 0000000..b970e0d --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/app.wxss @@ -0,0 +1,61 @@ +/**app.wxss**/ + +/* 全局样式重置 */ +page { + height: 100%; + background-color: #f8f8f8; + font-family: -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif; +} + +/* 通用容器 */ +.container { + height: 100%; + display: flex; + flex-direction: column; +} + +/* 通用文本样式 */ +.text-primary { + color: #1296db; +} + +.text-secondary { + color: #666; +} + +.text-muted { + color: #999; +} + +.text-danger { + color: #ff4757; +} + +.text-success { + color: #2ed573; +} + +/* 通用布局 */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.flex-1 { + flex: 1; +} diff --git a/schedule-ocr-miniprogram/miniprogram/images/README.md b/schedule-ocr-miniprogram/miniprogram/images/README.md new file mode 100644 index 0000000..1db30cf --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/images/README.md @@ -0,0 +1,17 @@ +# 图标说明 + +这个目录用于存放tabBar图标,需要以下图标文件: + +## 必需的图标文件 +- tab-home.png (首页图标 - 未选中) +- tab-home-active.png (首页图标 - 选中) +- tab-profile.png (个人中心图标 - 未选中) +- tab-profile-active.png (个人中心图标 - 选中) + +## 图标规格 +- 尺寸:81px * 81px +- 格式:PNG +- 背景:透明 + +## 临时解决方案 +在开发阶段,可以暂时注释掉app.json中的tabBar配置,或使用微信开发者工具的默认图标。 diff --git a/schedule-ocr-miniprogram/miniprogram/images/camera_icon.png b/schedule-ocr-miniprogram/miniprogram/images/camera_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..84960d776eb209a90e1862eb62e47c4b7fdfb422 GIT binary patch literal 3903 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 N002ovPDHLkV1mo3VD|t3 literal 0 HcmV?d00001 diff --git a/schedule-ocr-miniprogram/miniprogram/images/logo.png b/schedule-ocr-miniprogram/miniprogram/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5305a6acd6abaec50ff182de419e41ec33eb32f0 GIT binary patch literal 6629 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 literal 0 HcmV?d00001 diff --git a/schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png b/schedule-ocr-miniprogram/miniprogram/images/tab-home-active.png new file mode 100644 index 0000000000000000000000000000000000000000..14492ba92e6f5bb354b150cef9a38fcbf705d415 GIT binary patch literal 2582 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 G?0*1Vd$H00 literal 0 HcmV?d00001 diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json new file mode 100644 index 0000000..6ecee90 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "添加课程", + "navigationBarBackgroundColor": "#1296db", + "navigationBarTextStyle": "white" +} diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts new file mode 100644 index 0000000..9a42727 --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.ts @@ -0,0 +1,383 @@ +// course-add.ts + +// 课程接口定义 +interface Course { + id?: number; + courseName: string; + classroom: string; + teacherName?: string; + dayOfWeek: number; + startTime: string; + endTime: string; + notes?: string; + userOpenid?: string; + weeks?: string; // 周次信息,如 "1-16周" + timeSlot?: number; // 对应第几节课 (1-11) + startWeek?: number; // 开始周次 + endWeek?: number; // 结束周次 + weekType?: number; // 单双周标识(0=每周,1=单周,2=双周) +} + +Page({ + data: { + mode: 'add', // 'add' 或 'edit' + courseId: 0, + weekOptions: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'], + weekTypeOptions: [ + { name: '每周', value: 0 }, + { name: '单周', value: 1 }, + { name: '双周', value: 2 } + ], + timeSlots: [ + { name: '第1节', time: '08:00-08:45', value: 1 }, + { name: '第2节', time: '08:55-09:40', value: 2 }, + { name: '第3节', time: '10:00-10:45', value: 3 }, + { name: '第4节', time: '10:55-11:40', value: 4 }, + { name: '第5节', time: '14:10-14:55', value: 5 }, + { name: '第6节', time: '15:05-15:50', value: 6 }, + { name: '第7节', time: '16:00-16:45', value: 7 }, + { name: '第8节', time: '16:55-17:40', value: 8 }, + { name: '第9节', time: '18:40-19:25', value: 9 }, + { name: '第10节', time: '19:30-20:15', value: 10 }, + { name: '第11节', time: '20:20-21:05', value: 11 } + ], + formData: { + courseName: '', + classroom: '', + teacherName: '', + dayOfWeek: 0, + startTime: '', + endTime: '', + notes: '', + weeks: '', + timeSlot: 0, + startWeek: 1, + endWeek: 16, + weekType: 0 + } as Course, + canSave: false, + loading: false + }, + + onLoad(options: any) { + const mode = options.mode || 'add'; + const courseId = parseInt(options.id) || 0; + + this.setData({ + mode, + courseId + }); + + if (mode === 'edit' && courseId) { + this.loadCourseData(courseId); + } else { + // 新增模式下初始化周次文本 + this.updateWeeksText(); + } + + // 设置导航栏标题 + wx.setNavigationBarTitle({ + title: mode === 'edit' ? '编辑课程' : '添加课程' + }); + }, + + // 加载课程数据(编辑模式) + async loadCourseData(courseId: number) { + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const res = await api.course.getCourse(courseId); + + if (res.code === 200) { + const courseData = res.data; + + // 确保周次字段有默认值 + if (!courseData.startWeek) courseData.startWeek = 1; + if (!courseData.endWeek) courseData.endWeek = 16; + if (courseData.weekType === undefined) courseData.weekType = 0; + + // 确保数据类型正确 + courseData.dayOfWeek = parseInt(courseData.dayOfWeek) || 0; + + // 根据startTime和endTime确定timeSlot + courseData.timeSlot = this.getTimeSlotByTime(courseData.startTime, courseData.endTime); + + this.setData({ + formData: courseData + }); + + // 更新周次文本显示 + this.updateWeeksText(); + this.validateForm(); + } else { + api.handleError(res.message, '加载课程失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + }, + + // 根据开始时间和结束时间获取对应的时间段 + getTimeSlotByTime(startTime: string, endTime: string): number { + if (!startTime || !endTime) return 0; + + const timeRange = `${startTime}-${endTime}`; + const timeSlot = this.data.timeSlots.find(slot => slot.time === timeRange); + return timeSlot ? timeSlot.value : 0; + }, + + // 课程名称输入 + onCourseNameInput(e: any) { + this.setData({ + 'formData.courseName': e.detail.value + }); + this.validateForm(); + }, + + // 上课地点输入 + onClassroomInput(e: any) { + this.setData({ + 'formData.classroom': e.detail.value + }); + }, + + // 任课教师输入 + onTeacherNameInput(e: any) { + this.setData({ + 'formData.teacherName': e.detail.value + }); + }, + + // 星期选择 + onWeekSelect(e: any) { + const day = e.currentTarget.dataset.day; + this.setData({ + 'formData.dayOfWeek': day + }); + this.validateForm(); + }, + + // 时间段选择 + onTimeSlotSelect(e: any) { + const timeSlotIndex = e.currentTarget.dataset.index; + const timeSlot = this.data.timeSlots[timeSlotIndex]; + + if (timeSlot) { + const [startTime, endTime] = timeSlot.time.split('-'); + this.setData({ + 'formData.timeSlot': timeSlot.value, + 'formData.startTime': startTime, + 'formData.endTime': endTime + }); + this.validateForm(); + } + }, + + // 周次输入 + onWeeksInput(e: any) { + const weeksText = e.detail.value; + this.setData({ + 'formData.weeks': weeksText + }); + + // 解析周次信息 + this.parseWeeksInfo(weeksText); + }, + + // 解析周次信息 + parseWeeksInfo(weeksText: string) { + if (!weeksText) { + this.setData({ + 'formData.startWeek': 1, + 'formData.endWeek': 16, + 'formData.weekType': 0 + }); + return; + } + + // 匹配格式:1-16周、1-16周(单)、1-16周(双)等 + const weekPattern = /(\d+)-(\d+)周?\s*(\(([单双])\))?/; + const match = weeksText.match(weekPattern); + + if (match) { + const startWeek = parseInt(match[1]); + const endWeek = parseInt(match[2]); + const weekTypeText = match[4]; + + let weekType = 0; // 默认每周 + if (weekTypeText === '单') { + weekType = 1; + } else if (weekTypeText === '双') { + weekType = 2; + } + + this.setData({ + 'formData.startWeek': startWeek, + 'formData.endWeek': endWeek, + 'formData.weekType': weekType + }); + } + }, + + // 开始周次输入 + onStartWeekInput(e: any) { + const startWeek = parseInt(e.detail.value) || 1; + this.setData({ + 'formData.startWeek': startWeek + }); + this.updateWeeksText(); + }, + + // 结束周次输入 + onEndWeekInput(e: any) { + const endWeek = parseInt(e.detail.value) || 16; + this.setData({ + 'formData.endWeek': endWeek + }); + this.updateWeeksText(); + }, + + // 单双周选择 + onWeekTypeSelect(e: any) { + const weekType = e.currentTarget.dataset.type; + this.setData({ + 'formData.weekType': weekType + }); + this.updateWeeksText(); + }, + + // 更新周次文本显示 + updateWeeksText() { + const { startWeek, endWeek, weekType } = this.data.formData; + let weeksText = `${startWeek}-${endWeek}周`; + + if (weekType === 1) { + weeksText += '(单)'; + } else if (weekType === 2) { + weeksText += '(双)'; + } + + this.setData({ + 'formData.weeks': weeksText + }); + }, + + // 备注输入 + onNotesInput(e: any) { + this.setData({ + 'formData.notes': e.detail.value + }); + }, + + // 表单验证 + validateForm() { + const { courseName, dayOfWeek, timeSlot } = this.data.formData; + const canSave = !!(courseName && dayOfWeek && timeSlot); + this.setData({ canSave }); + }, + + // 保存课程 + async onSave() { + if (!this.data.canSave) { + wx.showToast({ + title: '请填写必填项', + icon: 'none' + }); + return; + } + + // 验证时间段是否选择 + if (!this.data.formData.timeSlot) { + wx.showToast({ + title: '请选择上课时间段', + icon: 'none' + }); + return; + } + + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const courseData = { + ...this.data.formData, + userOpenid: api.getUserOpenid() + }; + + let res; + if (this.data.mode === 'edit') { + res = await api.course.updateCourse(this.data.courseId, courseData); + } else { + res = await api.course.createCourse(courseData); + } + + if (res.code === 200) { + wx.showToast({ + title: this.data.mode === 'edit' ? '保存成功' : '添加成功', + icon: 'success' + }); + + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } else { + api.handleError(res.message, '操作失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + }, + + // 取消 + onCancel() { + wx.navigateBack(); + }, + + // 删除课程 + onDelete() { + wx.showModal({ + title: '确认删除', + content: '确定要删除这门课程吗?', + success: (res) => { + if (res.confirm) { + this.deleteCourse(); + } + } + }); + }, + + // 执行删除 + async deleteCourse() { + this.setData({ loading: true }); + + try { + const api = require('../../utils/api').default; + const res = await api.course.deleteCourse(this.data.courseId); + + if (res.code === 200) { + wx.showToast({ + title: '删除成功', + icon: 'success' + }); + + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } else { + api.handleError(res.message, '删除失败'); + } + } catch (error) { + const api = require('../../utils/api').default; + api.handleError(error, '网络错误'); + } finally { + this.setData({ loading: false }); + } + } +}) diff --git a/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml new file mode 100644 index 0000000..10ca3cc --- /dev/null +++ b/schedule-ocr-miniprogram/miniprogram/pages/course-add/course-add.wxml @@ -0,0 +1,143 @@ + + + + + {{mode === 'edit' ? '编辑课程' : '添加课程'}} + + + + + + + + 课程名称 * + + + + + + 上课地点 + + + + + + 任课教师 + + + + + + 星期几 * + + + {{item}} + + + + + + + 上课时间 * + + + {{item.name}} + {{item.time}} + + + + + + + 周次 + + + + + + + + + + + + + + + + + + + + {{item.name}} + + + + + + 预览: + {{formData.weeks || '1-16周'}} + + + + + + + 备注 +