refactor(grpc): restructure task handling and migrate to slog
All checks were successful
Build / build (push) Successful in 5m28s

Refactor the gRPC client and task handling logic to improve modularity,
error handling, and observability.

- Split `grpc/handler.go` into specialized handler files (login,
  classroom, exams, grades, schedule) to reduce complexity.
- Replace standard `log` with `log/slog` throughout the project for
  structured logging.
- Refactor `grpc/client.go` to use a thread-safe connection management
  pattern with `sync.RWMutex`.
- Remove the legacy `service` package and HTTP server implementation,
  transitioning the application to a pure gRPC runner mode.
- Clean up `config` and `pkg/errors` to remove unused utilities and
  simplify the API.
- Improve error reporting in parsers and HTTP clients by checking
  response status codes.
This commit is contained in:
2026-06-09 23:08:03 +08:00
parent ee5c36f3ac
commit accc6567f2
26 changed files with 720 additions and 1442 deletions

View File

@@ -1,41 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"os"
"schedule_converter/models"
)
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
}
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==================================================")
}

View File

@@ -1,241 +0,0 @@
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)
}

View File

@@ -1,124 +0,0 @@
package service
import (
"context"
"log/slog"
"net/http"
"time"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
type ScheduleService struct {
httpClient *http.Client
maxRetries int
retryDelay time.Duration
}
type ScheduleOption func(*ScheduleService)
func WithMaxRetries(n int) ScheduleOption {
return func(s *ScheduleService) {
s.maxRetries = n
}
}
func WithRetryDelay(d time.Duration) ScheduleOption {
return func(s *ScheduleService) {
s.retryDelay = d
}
}
func NewScheduleService(opts ...ScheduleOption) *ScheduleService {
s := &ScheduleService{
httpClient: client.Client,
maxRetries: 3,
retryDelay: 3 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func NewScheduleServiceWithClient(httpClient *http.Client, opts ...ScheduleOption) *ScheduleService {
s := &ScheduleService{
httpClient: httpClient,
maxRetries: 3,
retryDelay: 3 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *ScheduleService) FetchSchedule(ctx context.Context, username, password, semester string) ([]models.Course, error) {
log := slog.With("username", username, "semester", semester)
if err := client.GetInitialCookiesWithClient(s.httpClient); err != nil {
return nil, apperr.New("fetch_schedule", err, "获取Cookie失败")
}
var lastErr error
for attempt := 1; attempt <= s.maxRetries; attempt++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", s.maxRetries)
captchaCode, err := captcha.GetAndRecognize(s.httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
lastErr = err
time.Sleep(s.retryDelay)
continue
}
success, err := auth.LoginWithClient(s.httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录失败", "error", err, "attempt", attempt)
lastErr = err
time.Sleep(s.retryDelay)
continue
}
if success {
if semester == "" {
semester = config.Semester
}
courses, err := parser.FetchScheduleWithClient(s.httpClient, semester)
if err != nil {
return nil, apperr.New("fetch_schedule", err, "获取课表失败")
}
log.Info("获取课表成功", "course_count", len(courses))
return courses, nil
}
log.Warn("登录验证失败,稍后重试", "attempt", attempt)
time.Sleep(s.retryDelay)
}
return nil, apperr.New("fetch_schedule", lastErr, "登录失败,已重试")
}
func (s *ScheduleService) FetchDefaultSchedule(ctx context.Context) ([]models.Course, error) {
return s.FetchSchedule(ctx, config.Username, config.Password, config.Semester)
}
func (s *ScheduleService) FetchScheduleForUser(username, password, semester string) ([]models.Course, error) {
return s.FetchSchedule(context.Background(), username, password, semester)
}
func (s *ScheduleService) FetchDefaultScheduleLegacy() ([]models.Course, error) {
return s.FetchScheduleForUser(config.Username, config.Password, config.Semester)
}