package service import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "regexp" "strings" "sync" "time" "schedule_converter/client" "schedule_converter/config" "schedule_converter/models" apperr "schedule_converter/pkg/errors" ) var ( jsonBlockRegex = regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```") codeBlockRegex = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```") jsonArrayRegex = regexp.MustCompile(`(\[[\s\S]*\])`) regexPool = sync.Pool{ New: func() any { return nil }, } ) type RecognizerService struct { client *http.Client maxRetry int retryWait time.Duration } func NewRecognizerService() *RecognizerService { return &RecognizerService{ client: client.RecognizerClient, maxRetry: 3, retryWait: 5 * time.Second, } } func (r *RecognizerService) RecognizeFromFile(ctx context.Context, imagePath string) ([]models.Course, error) { log := slog.With("image_path", imagePath) log.Info("正在读取图片") imageData, err := os.ReadFile(imagePath) if err != nil { return nil, apperr.New("recognize_file", err, "读取图片失败") } log.Info("图片读取完成", "size", len(imageData)) return r.RecognizeFromBytes(ctx, imageData) } func (r *RecognizerService) RecognizeFromBytes(ctx context.Context, imageData []byte) ([]models.Course, error) { log := slog.With("size", len(imageData)) log.Info("正在处理图片数据") imageBase64 := base64.StdEncoding.EncodeToString(imageData) return r.callAPI(ctx, imageBase64) } func (r *RecognizerService) RecognizeFromBase64(ctx context.Context, imageBase64 string) ([]models.Course, error) { slog.Info("正在处理base64图片数据", "length", len(imageBase64)) return r.callAPI(ctx, imageBase64) } func (r *RecognizerService) callAPI(ctx context.Context, imageBase64 string) ([]models.Course, error) { var lastErr error for retry := range r.maxRetry { select { case <-ctx.Done(): return nil, ctx.Err() default: } if retry > 0 { slog.Info("重试中", "attempt", retry+1, "max_retry", r.maxRetry) time.Sleep(r.retryWait) } courses, err := r.callAPISingle(ctx, imageBase64) if err == nil { return courses, nil } lastErr = err slog.Warn("API调用失败", "attempt", retry+1, "error", err) if !strings.Contains(err.Error(), "504") { return nil, err } } return nil, apperr.New("recognize_api", lastErr, "已达到最大重试次数") } func (r *RecognizerService) callAPISingle(ctx context.Context, imageBase64 string) ([]models.Course, error) { cfg := config.Get() if cfg.Recognizer.APIKey == "" { return nil, apperr.ErrRecognitionFailed } prompt := `请解析以下课表图片,提取所有课程信息并以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数组,不要任何其他文字。` falseVal := false zero := 0 apiReq := models.APIRequest{ Model: cfg.Recognizer.Model, MaxTokens: 4000, EnableThinking: &falseVal, ThinkingBudget: &zero, Messages: []models.Message{ { Role: "user", Content: []models.ContentItem{ {Type: "text", Text: prompt}, { Type: "image_url", ImageURL: &models.ImageURL{ URL: "data:image/png;base64," + imageBase64, }, }, }, }, }, } jsonData, err := json.Marshal(apiReq) if err != nil { return nil, apperr.New("marshal_request", err, "") } req, err := http.NewRequestWithContext(ctx, "POST", cfg.Recognizer.APIURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, apperr.New("create_request", err, "") } req.Header = http.Header{ "Content-Type": []string{"application/json"}, "Authorization": []string{"Bearer " + cfg.Recognizer.APIKey}, } slog.Info("调用识别API") resp, err := r.client.Do(req) if err != nil { return nil, apperr.New("api_call", 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 models.APIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { return nil, apperr.New("decode_response", err, "") } if len(apiResp.Choices) == 0 { return nil, apperr.New("empty_response", apperr.ErrRecognitionFailed, "API返回为空") } resultText := extractContent(apiResp.Choices[0].Message.Content) slog.Debug("API响应接收完成", "response_length", len(resultText)) jsonMatch := extractJSON(resultText) if jsonMatch == "" { return nil, apperr.New("extract_json", apperr.ErrRecognitionFailed, fmt.Sprintf("未找到JSON数据,原始响应: %s", resultText)) } var courses []models.Course if err := json.Unmarshal([]byte(jsonMatch), &courses); err != nil { return nil, apperr.New("parse_json", err, fmt.Sprintf("数据: %s", jsonMatch)) } slog.Info("识别完成", "course_count", len(courses)) return courses, nil } func extractContent(content any) string { switch c := content.(type) { case string: return c case []any: for _, item := range c { if itemMap, ok := item.(map[string]any); ok { if text, ok := itemMap["text"].(string); ok { return text } } } } return "" } func extractJSON(text string) string { if match := jsonBlockRegex.FindStringSubmatch(text); len(match) > 1 { slog.Debug("从json代码块中提取JSON") return match[1] } if match := codeBlockRegex.FindStringSubmatch(text); len(match) > 1 { slog.Debug("从代码块中提取JSON") return match[1] } if match := jsonArrayRegex.FindString(text); match != "" { slog.Debug("从文本中提取JSON") return match } return "" } func RecognizeScheduleFromFile(imagePath string) ([]models.Course, error) { return NewRecognizerService().RecognizeFromFile(context.Background(), imagePath) } func RecognizeScheduleFromBytes(imageData []byte) ([]models.Course, error) { return NewRecognizerService().RecognizeFromBytes(context.Background(), imageData) } func RecognizeScheduleFromBase64(imageBase64 string) ([]models.Course, error) { return NewRecognizerService().RecognizeFromBase64(context.Background(), imageBase64) }