package service import ( "errors" "fmt" "net/http" "strings" "github.com/LoveLosita/smartflow/backend/model" ) const ( defaultCourseImageMaxBytes = 5 * 1024 * 1024 defaultCourseImageMaxTokens = 16384 maxCourseImageDraftRows = 256 courseImageParseTemperature = 0.1 ) var ( ErrCourseImageParserUnavailable = errors.New("course image parser is not configured") ErrCourseImageTooLarge = errors.New("course image is too large") ErrCourseImageUnsupportedMIME = errors.New("course image mime type is not supported") ErrCourseImageEmpty = errors.New("course image is empty") ) type CourseImageParseConfig struct { MaxImageBytes int64 MaxTokens int } func NewCourseImageParseConfig(maxImageBytes int64, maxTokens int) CourseImageParseConfig { if maxImageBytes <= 0 { maxImageBytes = defaultCourseImageMaxBytes } if maxTokens <= 0 { maxTokens = defaultCourseImageMaxTokens } return CourseImageParseConfig{ MaxImageBytes: maxImageBytes, MaxTokens: maxTokens, } } func normalizeCourseImageParseRequest(req model.CourseImageParseRequest, cfg CourseImageParseConfig) (*model.CourseImageParseRequest, error) { req.Filename = strings.TrimSpace(req.Filename) req.MIMEType = strings.TrimSpace(strings.ToLower(req.MIMEType)) if len(req.ImageBytes) == 0 { return nil, ErrCourseImageEmpty } if int64(len(req.ImageBytes)) > cfg.MaxImageBytes { return nil, ErrCourseImageTooLarge } detected := strings.ToLower(strings.TrimSpace(http.DetectContentType(req.ImageBytes))) if req.MIMEType == "" || req.MIMEType == "application/octet-stream" { req.MIMEType = detected } if !isSupportedCourseImageMIME(req.MIMEType) { if isSupportedCourseImageMIME(detected) { req.MIMEType = detected } else { return nil, ErrCourseImageUnsupportedMIME } } if req.Filename == "" { req.Filename = "course-table" } return &req, nil } func isSupportedCourseImageMIME(mimeType string) bool { switch strings.TrimSpace(strings.ToLower(mimeType)) { case "image/jpeg", "image/png", "image/webp": return true default: return false } } func normalizeCourseImageParseResponse(resp *model.CourseImageParseResponse) (*model.CourseImageParseResponse, error) { if resp == nil { return nil, errors.New("course image parse response is nil") } resp.DraftStatus = model.CourseImageParseDraftStatus(strings.ToLower(strings.TrimSpace(string(resp.DraftStatus)))) resp.Message = strings.TrimSpace(resp.Message) resp.Warnings = normalizeWarningList(resp.Warnings) resp.Rows = normalizeCourseImageParseRows(resp.Rows, &resp.Warnings) switch resp.DraftStatus { case model.CourseImageParseDraftStatusSuccess: if len(resp.Rows) == 0 { return nil, errors.New("course image parse response has no rows in success status") } for idx := range resp.Rows { if err := validateCourseImageParseRow(&resp.Rows[idx], true); err != nil { return nil, fmt.Errorf("course image parse success row %d invalid: %w", idx+1, err) } } case model.CourseImageParseDraftStatusPartial: if len(resp.Rows) == 0 { return nil, errors.New("course image parse response has no rows in partial status") } for idx := range resp.Rows { if err := validateCourseImageParseRow(&resp.Rows[idx], false); err != nil { return nil, fmt.Errorf("course image parse partial row %d invalid: %w", idx+1, err) } } case model.CourseImageParseDraftStatusReject: resp.Rows = make([]model.CourseImageParseRow, 0) default: return nil, fmt.Errorf("unsupported draft_status: %s", resp.DraftStatus) } if resp.Message == "" { resp.Message = defaultCourseImageParseMessage(resp.DraftStatus, len(resp.Rows)) } return resp, nil } func normalizeCourseImageParseRows(rows []model.CourseImageParseRow, warnings *[]string) []model.CourseImageParseRow { if len(rows) == 0 { return make([]model.CourseImageParseRow, 0) } if len(rows) > maxCourseImageDraftRows { rows = rows[:maxCourseImageDraftRows] appendUniqueWarning(warnings, "识别结果行数超过上限,后端已截断为 256 行,请重点核对。") } normalized := make([]model.CourseImageParseRow, 0, len(rows)) for idx := range rows { row := rows[idx] row.RowID = strings.TrimSpace(row.RowID) if row.RowID == "" { row.RowID = fmt.Sprintf("row_%03d", idx+1) } row.CourseName = strings.TrimSpace(row.CourseName) row.Location = strings.TrimSpace(row.Location) row.WeekType = normalizeCourseImageWeekType(row.WeekType) row.RawText = strings.TrimSpace(row.RawText) row.RowWarnings = normalizeWarningList(row.RowWarnings) normalizeOptionalPositiveInt(&row.StartWeek) normalizeOptionalPositiveInt(&row.EndWeek) normalizeOptionalPositiveInt(&row.DayOfWeek) normalizeOptionalPositiveInt(&row.StartSection) normalizeOptionalPositiveInt(&row.EndSection) if row.Confidence < 0 { row.Confidence = 0 } if row.Confidence > 1 { row.Confidence = 1 } if row.CourseName == "" && row.StartWeek == nil && row.EndWeek == nil && row.DayOfWeek == nil && row.StartSection == nil && row.EndSection == nil && row.RawText == "" { appendUniqueWarning(warnings, fmt.Sprintf("存在空白草稿行,后端已自动忽略:%s", row.RowID)) continue } normalized = append(normalized, row) } return normalized } func validateCourseImageParseRow(row *model.CourseImageParseRow, strict bool) error { if row == nil { return errors.New("row is nil") } if strict && row.CourseName == "" { return errors.New("course_name is empty") } if strict && row.WeekType == "" { return errors.New("week_type is empty") } if row.WeekType != "" && row.WeekType != "all" && row.WeekType != "odd" && row.WeekType != "even" { return fmt.Errorf("week_type is invalid: %s", row.WeekType) } if err := validateOptionalCourseIntPair(row.StartWeek, row.EndWeek, 1, 24, "week", strict); err != nil { return err } if err := validateOptionalCourseIntPair(row.StartSection, row.EndSection, 1, 12, "section", strict); err != nil { return err } if strict && row.DayOfWeek == nil { return errors.New("day_of_week is empty") } if row.DayOfWeek != nil && (*row.DayOfWeek < 1 || *row.DayOfWeek > 7) { return fmt.Errorf("day_of_week out of range: %d", *row.DayOfWeek) } return nil } func validateOptionalCourseIntPair(start *int, end *int, min int, max int, field string, strict bool) error { if strict { if start == nil || end == nil { return fmt.Errorf("%s range is incomplete", field) } } if start == nil && end == nil { return nil } if start == nil || end == nil { return fmt.Errorf("%s range is incomplete", field) } if *start < min || *start > max { return fmt.Errorf("%s start out of range: %d", field, *start) } if *end < min || *end > max { return fmt.Errorf("%s end out of range: %d", field, *end) } if *start > *end { return fmt.Errorf("%s start is greater than end: %d > %d", field, *start, *end) } return nil } func normalizeOptionalPositiveInt(target **int) { if target == nil || *target == nil { return } if **target <= 0 { *target = nil } } func normalizeCourseImageWeekType(raw string) string { normalized := strings.ToLower(strings.TrimSpace(raw)) switch normalized { case "", "unknown", "null": return "" case "all", "every", "weekly", "each week", "每周", "全周", "全部": return "all" case "odd", "single", "单", "单周": return "odd" case "even", "double", "双", "双周": return "even" default: return normalized } } func normalizeWarningList(items []string) []string { if len(items) == 0 { return make([]string, 0) } seen := make(map[string]struct{}, len(items)) result := make([]string, 0, len(items)) for _, item := range items { trimmed := strings.TrimSpace(item) if trimmed == "" { continue } if _, ok := seen[trimmed]; ok { continue } seen[trimmed] = struct{}{} result = append(result, trimmed) } return result } func appendUniqueWarning(target *[]string, warningText string) { if target == nil { return } trimmed := strings.TrimSpace(warningText) if trimmed == "" { return } for _, existing := range *target { if strings.TrimSpace(existing) == trimmed { return } } *target = append(*target, trimmed) } func defaultCourseImageParseMessage(status model.CourseImageParseDraftStatus, rowCount int) string { switch status { case model.CourseImageParseDraftStatusSuccess: return fmt.Sprintf("已识别 %d 条课程安排,请重点核对周次、星期和节次。", rowCount) case model.CourseImageParseDraftStatusPartial: return fmt.Sprintf("已识别 %d 条课程安排,但仍存在不确定字段,请结合 warning 逐项核对。", rowCount) case model.CourseImageParseDraftStatusReject: return "图片信息不足,建议重新上传完整、清晰、包含表头和节次栏的总课表截图。" default: return "课程表图片识别已完成,请人工核对后再导入。" } }