From 9addc89a666afbfff7cd985e9877441058536f5f Mon Sep 17 00:00:00 2001 From: WuYuuuub <625806558@qq.com> Date: Sun, 15 Mar 2026 21:49:10 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E8=AF=86=E5=88=AB=E5=9B=BE=E7=89=87=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=AF=BE=E8=A1=A8=E7=9A=84=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.go | 44 ++++++ recognizer/recognizer.go | 297 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+) create mode 100644 recognizer/recognizer.go diff --git a/main.go b/main.go index bab6fc9..920c5b0 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "context" "flag" + "io" "log" "net/http" "os" @@ -16,6 +17,7 @@ import ( "jwts/config" grpcClient "jwts/grpc" "jwts/parser" + "jwts/recognizer" "github.com/gin-gonic/gin" ) @@ -93,6 +95,48 @@ func runHTTPServer() { }) }) + // 课表图片识别接口 + r.POST("/recognize", func(c *gin.Context) { + // 读取上传的图片文件 + file, header, err := c.Request.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()}) + return + } + defer file.Close() + + log.Printf("收到图片上传: %s, 大小: %d bytes", header.Filename, header.Size) + + // 读取图片数据 + imageData, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()}) + return + } + + // 调用识别模块 + courses, err := recognizer.RecognizeFromBytes(imageData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()}) + return + } + + // 打印识别结果 + recognizer.PrintCourses(courses) + + // 保存到JSON文件 + err = recognizer.SaveToJSON(courses, "schedule_result.json") + if err != nil { + log.Printf("保存JSON失败: %v", err) + } + + c.JSON(http.StatusOK, gin.H{ + "message": "识别成功", + "courses": courses, + "count": len(courses), + }) + }) + // 任务处理接口 r.POST("/task", func(c *gin.Context) { // 这里可以根据请求体参数决定执行什么任务 diff --git a/recognizer/recognizer.go b/recognizer/recognizer.go new file mode 100644 index 0000000..1776954 --- /dev/null +++ b/recognizer/recognizer.go @@ -0,0 +1,297 @@ +package recognizer + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "regexp" + "strings" + "time" + + "jwts/models" +) + +// 配置 - 使用您提供的API配置 +const ( + APIURL = "https://api.littlelan.cn/v1/chat/completions" + APIKey = "sk-Ll7P7vuiLum3N0pj7XaYfwHhD5UW1rDYlFoQ7F5JmeBfTewZ" + Model = "qwen3.5-plus" // 使用qwen3.5-plus模型 +) + +// APIRequest API请求结构 +type APIRequest struct { + Model string `json:"model"` + Messages []Message `json:"messages"` + MaxTokens int `json:"max_tokens"` + EnableThinking *bool `json:"enable_thinking,omitempty"` + ThinkingBudget *int `json:"thinking_budget,omitempty"` +} + +// Message 消息结构 +type Message struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +// ContentItem 内容项 +type ContentItem struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + ImageURL *ImageURL `json:"image_url,omitempty"` +} + +// ImageURL 图片URL +type ImageURL struct { + URL string `json:"url"` +} + +// APIResponse API响应结构 +type APIResponse struct { + Choices []Choice `json:"choices"` +} + +// Choice 选择 +type Choice struct { + Message Message `json:"message"` +} + +// 全局HTTP客户端 +var client *http.Client + +// 初始化HTTP客户端 +func init() { + client = &http.Client{ + Timeout: 120 * time.Second, + } + log.Println("recognizer模块已初始化") +} + +// RecognizeFromFile 从图片文件识别课表 +func RecognizeFromFile(imagePath string) ([]models.Course, error) { + fmt.Printf("[识别] 正在读取图片: %s\n", imagePath) + + // 读取图片文件 + imageData, err := os.ReadFile(imagePath) + if err != nil { + return nil, fmt.Errorf("读取图片失败: %v", err) + } + + // 将图片转为base64 + imageBase64 := base64.StdEncoding.EncodeToString(imageData) + fmt.Printf("[识别] 图片大小: %d bytes\n", len(imageData)) + + return callAPI(imageBase64) +} + +// RecognizeFromBytes 从图片数据识别课表 +func RecognizeFromBytes(imageData []byte) ([]models.Course, error) { + fmt.Printf("[识别] 正在处理图片数据, 大小: %d bytes\n", len(imageData)) + + // 将图片转为base64 + imageBase64 := base64.StdEncoding.EncodeToString(imageData) + + return callAPI(imageBase64) +} + +// RecognizeFromBase64 从base64数据识别课表 +func RecognizeFromBase64(imageBase64 string) ([]models.Course, error) { + fmt.Printf("[识别] 正在处理base64图片数据, 长度: %d\n", len(imageBase64)) + + return callAPI(imageBase64) +} + +// callAPI 调用API识别课表(带重试机制) +func callAPI(imageBase64 string) ([]models.Course, error) { + // 重试机制 + maxRetries := 3 + for retry := 0; retry < maxRetries; retry++ { + if retry > 0 { + fmt.Printf("[识别] 第 %d 次重试...\n", retry+1) + time.Sleep(5 * time.Second) // 等待5秒后重试 + } + + courses, err := callAPISingle(imageBase64) + if err == nil { + return courses, nil + } + + fmt.Printf("[识别] 尝试 %d/%d 失败: %v\n", retry+1, maxRetries, err) + + // 如果是504超时,继续重试;否则直接返回错误 + if !strings.Contains(err.Error(), "504") { + return nil, err + } + } + + return nil, fmt.Errorf("已达到最大重试次数") +} + +// callAPISingle 单次API调用 +func callAPISingle(imageBase64 string) ([]models.Course, error) { + // 构建请求 - 发送图片给AI识别 + // 禁用qwen3.5的思考模式,避免产生大量不必要的token消耗 + falseVal := false + zero := 0 + apiReq := APIRequest{ + Model: Model, + MaxTokens: 4000, + EnableThinking: &falseVal, + ThinkingBudget: &zero, + Messages: []Message{ + { + Role: "user", + Content: []ContentItem{ + { + Type: "text", + Text: `请解析以下课表图片,提取所有课程信息并以JSON数组格式返回。 + +JSON格式要求: +[{"name":"课程名","teacher":"老师名","room":"教室","schedule":[{"day":"星期X","section":[节次数组],"weeks":[周数数组]}]}] + +注意事项: +1. day使用"星期一"到"星期日" +2. section节次:上午1-2节为[1,2],上午3-4节为[3,4],下午5-6节为[5,6],下午7-8节为[7,8],晚上9-10节为[9,10],晚上11-12节为[11,12] +3. weeks周数:格式如[1,2,3,4,5,6,7,8]或[9,10,11,12,13,14,15,16] +4. 只返回JSON数组,不要任何其他文字。`, + }, + { + Type: "image_url", + ImageURL: &ImageURL{ + URL: "data:image/png;base64," + imageBase64, + }, + }, + }, + }, + }, + } + + jsonData, err := json.Marshal(apiReq) + if err != nil { + return nil, err + } + + httpReq, err := http.NewRequest("POST", APIURL, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, err + } + + httpReq.Header = http.Header{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer " + APIKey}, + } + + fmt.Println("[识别] 正在调用API进行识别...") + resp, err := client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("API调用失败: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API调用失败: %d, %s", resp.StatusCode, string(respBody)) + } + + var apiResp APIResponse + err = json.NewDecoder(resp.Body).Decode(&apiResp) + if err != nil { + return nil, fmt.Errorf("API响应解析失败: %v", err) + } + + if len(apiResp.Choices) == 0 { + return nil, fmt.Errorf("API返回为空") + } + + // 处理Content,可能是string或[]ContentItem + var resultText string + switch c := apiResp.Choices[0].Message.Content.(type) { + case string: + resultText = c + case []interface{}: + for _, item := range c { + if itemMap, ok := item.(map[string]interface{}); ok { + if text, ok := itemMap["text"].(string); ok { + resultText = text + break + } + } + } + } + + fmt.Println("[识别] API响应已接收,正在解析...") + fmt.Printf("[识别] 原始响应: %s\n", resultText) + + // 提取JSON部分 + var jsonMatch string + + // 1. 先尝试匹配 ```json ... ``` 代码块 + jsonBlockMatch := regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText) + if len(jsonBlockMatch) > 1 { + jsonMatch = jsonBlockMatch[1] + fmt.Println("[识别] 从json代码块中提取JSON") + } else { + // 2. 尝试匹配 ``` ... ``` 代码块中的数组 + jsonBlockMatch = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText) + if len(jsonBlockMatch) > 1 { + jsonMatch = jsonBlockMatch[1] + fmt.Println("[识别] 从代码块中提取JSON") + } else { + // 3. 查找最外层的JSON数组(匹配第一个 [ 到最后一个 ]) + // 使用非贪婪匹配找到完整的JSON数组 + jsonMatch = regexp.MustCompile(`(\[[\s\S]*\])`).FindString(resultText) + if jsonMatch == "" { + return nil, fmt.Errorf("未找到JSON数据,原始响应: %s", resultText) + } + fmt.Println("[识别] 从文本中提取JSON") + } + } + + // 解析JSON + var courses []models.Course + err = json.Unmarshal([]byte(jsonMatch), &courses) + if err != nil { + return nil, fmt.Errorf("JSON解析失败: %v, 数据: %s", err, jsonMatch) + } + + fmt.Printf("[识别] 成功解析 %d 门课程\n", len(courses)) + return courses, nil +} + +// SaveToJSON 保存课程数据到JSON文件 +func SaveToJSON(courses []models.Course, filename string) error { + jsonData, err := json.MarshalIndent(courses, "", " ") + if err != nil { + return err + } + + err = os.WriteFile(filename, jsonData, 0644) + if err != nil { + return err + } + + fmt.Printf("[输出] JSON结果已保存到: %s\n", filename) + return nil +} + +// PrintCourses 打印课程信息 +func PrintCourses(courses []models.Course) { + fmt.Println("\n==================================================") + fmt.Println("识别结果:") + fmt.Println("==================================================") + for i, course := range courses { + fmt.Printf("\n课程 %d: %s\n", i+1, course.Name) + fmt.Printf(" 教师: %s\n", course.Teacher) + fmt.Printf(" 教室: %s\n", course.Room) + fmt.Printf(" 时间:") + for _, sch := range course.Schedule { + fmt.Printf(" %s %v 周%v", sch.Day, sch.Section, sch.Weeks) + } + fmt.Println() + } + fmt.Println("\n==================================================") +} From ed1d252fab3b067f4ca1ab52b928ac5ab95d7615 Mon Sep 17 00:00:00 2001 From: lafay <2021211506@stu.hit.edu.cn> Date: Mon, 16 Mar 2026 13:02:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20=E5=B0=86API=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=8F=90=E5=8F=96=E5=88=B0config=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将硬编码的API配置移到config模块,支持环境变量配置 - 添加API Key验证和配置检查 - 添加图片上传大小限制(10MB) - 移除不必要的注释,简化代码 - 改进错误处理和日志记录 --- config/config.go | 4 ++++ main.go | 10 +++++----- recognizer/recognizer.go | 25 +++++++++++++------------ 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/config/config.go b/config/config.go index aa53ace..8f05893 100644 --- a/config/config.go +++ b/config/config.go @@ -11,6 +11,10 @@ var ( Password = getEnv("JWTS_PASSWORD", "") Semester = getEnv("JWTS_SEMESTER", "2025-20262") TWFID = getEnv("JWTS_TWFID", "") + + RecognizerAPIURL = getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions") + RecognizerAPIKey = getEnv("RECOGNIZER_API_KEY", "") + RecognizerModel = getEnv("RECOGNIZER_MODEL", "qwen3.5-plus") ) // getEnv 从环境变量获取配置,提供默认值 diff --git a/main.go b/main.go index 920c5b0..0a4ff33 100644 --- a/main.go +++ b/main.go @@ -97,7 +97,6 @@ func runHTTPServer() { // 课表图片识别接口 r.POST("/recognize", func(c *gin.Context) { - // 读取上传的图片文件 file, header, err := c.Request.FormFile("image") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()}) @@ -107,24 +106,25 @@ func runHTTPServer() { log.Printf("收到图片上传: %s, 大小: %d bytes", header.Filename, header.Size) - // 读取图片数据 + if header.Size > 10*1024*1024 { + c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"}) + return + } + imageData, err := io.ReadAll(file) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()}) return } - // 调用识别模块 courses, err := recognizer.RecognizeFromBytes(imageData) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()}) return } - // 打印识别结果 recognizer.PrintCourses(courses) - // 保存到JSON文件 err = recognizer.SaveToJSON(courses, "schedule_result.json") if err != nil { log.Printf("保存JSON失败: %v", err) diff --git a/recognizer/recognizer.go b/recognizer/recognizer.go index 1776954..c7b1bde 100644 --- a/recognizer/recognizer.go +++ b/recognizer/recognizer.go @@ -13,16 +13,10 @@ import ( "strings" "time" + "jwts/config" "jwts/models" ) -// 配置 - 使用您提供的API配置 -const ( - APIURL = "https://api.littlelan.cn/v1/chat/completions" - APIKey = "sk-Ll7P7vuiLum3N0pj7XaYfwHhD5UW1rDYlFoQ7F5JmeBfTewZ" - Model = "qwen3.5-plus" // 使用qwen3.5-plus模型 -) - // APIRequest API请求结构 type APIRequest struct { Model string `json:"model"` @@ -68,6 +62,11 @@ func init() { client = &http.Client{ Timeout: 120 * time.Second, } + + if config.RecognizerAPIKey == "" { + log.Println("警告: RECOGNIZER_API_KEY 未设置,图片识别功能将无法使用") + } + log.Println("recognizer模块已初始化") } @@ -133,12 +132,14 @@ func callAPI(imageBase64 string) ([]models.Course, error) { // callAPISingle 单次API调用 func callAPISingle(imageBase64 string) ([]models.Course, error) { - // 构建请求 - 发送图片给AI识别 - // 禁用qwen3.5的思考模式,避免产生大量不必要的token消耗 + if config.RecognizerAPIKey == "" { + return nil, fmt.Errorf("RECOGNIZER_API_KEY 未配置") + } + falseVal := false zero := 0 apiReq := APIRequest{ - Model: Model, + Model: config.RecognizerModel, MaxTokens: 4000, EnableThinking: &falseVal, ThinkingBudget: &zero, @@ -175,14 +176,14 @@ JSON格式要求: return nil, err } - httpReq, err := http.NewRequest("POST", APIURL, bytes.NewBuffer(jsonData)) + httpReq, err := http.NewRequest("POST", config.RecognizerAPIURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, err } httpReq.Header = http.Header{ "Content-Type": []string{"application/json"}, - "Authorization": []string{"Bearer " + APIKey}, + "Authorization": []string{"Bearer " + config.RecognizerAPIKey}, } fmt.Println("[识别] 正在调用API进行识别...")