refactor(grpc): restructure task handling and migrate to slog
All checks were successful
Build / build (push) Successful in 5m28s
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:
@@ -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==================================================")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user