diff --git a/auth/login.go b/auth/login.go index dca0267..d9714b1 100644 --- a/auth/login.go +++ b/auth/login.go @@ -1,6 +1,7 @@ package auth import ( + "context" "fmt" "io" "log/slog" @@ -14,13 +15,13 @@ import ( var nameRegexp = regexp.MustCompile(`(\w+)同学`) -// LoginWithClient 使用指定的 HTTP 客户端和凭据登录 -func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) { +// LoginWithClient 使用指定的 HTTP 客户端和凭据登录。 +func LoginWithClient(ctx context.Context, httpClient *http.Client, captchaCode, username, password string) (bool, error) { log := slog.With("username", username) log.Debug("执行登录") data := fmt.Sprintf("usercode=%s&password=%s&code=%s", username, password, captchaCode) - req, err := http.NewRequest("POST", config.Get().BaseURL+"/login", strings.NewReader(data)) + req, err := http.NewRequestWithContext(ctx, "POST", config.Get().BaseURL+"/login", strings.NewReader(data)) if err != nil { return false, err } @@ -54,4 +55,4 @@ func LoginWithClient(httpClient *http.Client, captchaCode, username, password st log.Warn("登录失败") } return false, nil -} \ No newline at end of file +} diff --git a/parser/encoding.go b/client/encoding.go similarity index 84% rename from parser/encoding.go rename to client/encoding.go index 85b56ed..f964035 100644 --- a/parser/encoding.go +++ b/client/encoding.go @@ -1,8 +1,7 @@ -package parser +package client import ( "bytes" - "encoding/json" "io" "net/http" "strings" @@ -11,7 +10,8 @@ import ( "golang.org/x/text/transform" ) -func decodeResponseBody(resp *http.Response) (string, error) { +// DecodeResponseBody 读取 HTTP 响应体并按 Content-Type 进行字符集解码(处理 GBK)。 +func DecodeResponseBody(resp *http.Response) (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { return "", err @@ -73,11 +73,3 @@ func isValidUTF8(data []byte) bool { } return true } - -func decodeJSONResponse(resp *http.Response, v any) error { - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - return json.NewDecoder(bytes.NewReader(body)).Decode(v) -} \ No newline at end of file diff --git a/client/fetch.go b/client/fetch.go new file mode 100644 index 0000000..f91877f --- /dev/null +++ b/client/fetch.go @@ -0,0 +1,47 @@ +package client + +import ( + "context" + "fmt" + "io" + "net/http" + + "schedule_converter/config" +) + +// FetchHTML 发送 HTTP 请求并返回解码后的 HTML 字符串(处理 GBK 字符集)。 +// method 为 "GET" 或 "POST";path 拼接到 config.BaseURL 之后;body 为请求体(GET 传 nil)。 +// referer 为可选的 Referer 路径(拼接到 BaseURL 之后),不需要时传 ""。 +func FetchHTML(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (string, error) { + resp, err := doRequest(ctx, httpClient, method, path, body, contentType, referer) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("请求 %s 失败, 状态码: %d", path, resp.StatusCode) + } + + return DecodeResponseBody(resp) +} + +// doRequest 构造并执行请求,设置 header(可选 Referer),透传 ctx。 +func doRequest(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (*http.Response, error) { + url := config.Get().BaseURL + path + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + if contentType != "" { + req.Header = GetHeadersWithContentType(contentType) + } else { + req.Header = GetHeaders() + } + if referer != "" { + req.Header.Set("Referer", config.Get().BaseURL+referer) + } + + return httpClient.Do(req) +} diff --git a/config/config.go b/config/config.go index 4b43557..b0c5462 100644 --- a/config/config.go +++ b/config/config.go @@ -3,17 +3,14 @@ package config import ( "log/slog" "os" - "strconv" "sync" "time" ) type Config struct { - BaseURL string - Username string - Password string - Semester string - TWFID string + BaseURL string + Semester string + TWFID string Recognizer RecognizerConfig } @@ -33,13 +30,11 @@ func Load() *Config { cfgOnce.Do(func() { cfg = &Config{ BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"), - Username: getEnv("JWTS_USERNAME", "2024212193"), - Password: getEnv("JWTS_PASSWORD", "wyb12580963"), - Semester: getEnv("JWTS_SEMESTER", "2025-20262"), + Semester: getEnv("JWTS_SEMESTER", ""), TWFID: getEnv("JWTS_TWFID", ""), Recognizer: RecognizerConfig{ APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"), - APIKey: getEnv("RECOGNIZER_API_KEY", "sk-6064466699a996b6f83e1ae8247c1cb4eeb4b72ce23cb001204335a2567d63c1"), + APIKey: getEnv("RECOGNIZER_API_KEY", ""), Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"), Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second), }, @@ -67,17 +62,6 @@ func getEnv(key, defaultValue string) string { return val } -func getEnvInt(key string, defaultValue int) int { - val := os.Getenv(key) - if val == "" { - return defaultValue - } - if i, err := strconv.Atoi(val); err == nil { - return i - } - return defaultValue -} - func getEnvDuration(key string, defaultValue time.Duration) time.Duration { val := os.Getenv(key) if val == "" { @@ -91,8 +75,6 @@ func getEnvDuration(key string, defaultValue time.Duration) time.Duration { var ( BaseURL = Get().BaseURL - Username = Get().Username - Password = Get().Password Semester = Get().Semester TWFID = Get().TWFID RecognizerAPIURL = Get().Recognizer.APIURL diff --git a/grpc/client.go b/grpc/client.go index 7adde41..0506cc7 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -78,18 +78,16 @@ func (c *Client) Connect(ctx context.Context) error { // register 发送注册请求 func (c *Client) register() error { + capabilities := make(map[string]string, len(supportedTaskTypes())) + for _, t := range supportedTaskTypes() { + capabilities[t.String()] = "true" + } msg := &pb.StreamMessage{ Message: &pb.StreamMessage_Register{ Register: &pb.RegisterRequest{ - RunnerId: c.runnerID, - Version: c.version, - Capabilities: map[string]string{ - "TASK_TYPE_GET_SCHEDULE": "true", - "TASK_TYPE_GET_GRADES": "true", - "TASK_TYPE_GET_EXAMS": "true", - "TASK_TYPE_LOGIN": "true", - "TASK_TYPE_GET_EMPTY_CLASSROOM": "true", - }, + RunnerId: c.runnerID, + Version: c.version, + Capabilities: capabilities, }, }, } diff --git a/grpc/convert.go b/grpc/convert.go deleted file mode 100644 index 6697264..0000000 --- a/grpc/convert.go +++ /dev/null @@ -1,91 +0,0 @@ -package grpc - -import ( - pb "schedule_converter/proto/runner" - - "schedule_converter/models" - "schedule_converter/parser" -) - -func convertCourses(courses []models.Course) []*pb.Course { - result := make([]*pb.Course, len(courses)) - for i, c := range courses { - schedule := make([]*pb.ScheduleTime, len(c.Schedule)) - for j, s := range c.Schedule { - schedule[j] = &pb.ScheduleTime{ - Day: s.Day, - Sections: convertToInt32(s.Section), - Weeks: convertToInt32(s.Weeks), - } - } - result[i] = &pb.Course{ - Name: c.Name, - Teacher: c.Teacher, - Room: c.Room, - Schedule: schedule, - } - } - return result -} - -func convertToInt32(arr []int) []int32 { - result := make([]int32, len(arr)) - for i, v := range arr { - result[i] = int32(v) - } - return result -} - -func convertGrades(grades []parser.GradeEntry) []*pb.Grade { - result := make([]*pb.Grade, 0, len(grades)) - for _, g := range grades { - result = append(result, &pb.Grade{ - Term: g.Term, - CourseCode: g.Code, - CourseName: g.Name, - Nature: g.Nature, - Category: g.Category, - Credit: g.Credit, - Score: g.Score, - GradePoint: g.GradePoint, - IsExam: g.IsExam, - }) - } - return result -} - -func convertExams(exams []parser.ExamEntry) []*pb.Exam { - result := make([]*pb.Exam, 0, len(exams)) - for _, e := range exams { - examTypeStr := "期末" - switch e.Type { - case parser.ExamTypeMidterm: - examTypeStr = "期中" - case parser.ExamTypeMakeup: - examTypeStr = "补考" - } - result = append(result, &pb.Exam{ - CourseName: e.Course, - ExamType: examTypeStr, - Date: e.Date, - Time: e.Time, - Week: e.Week, - Weekday: e.Weekday, - }) - } - return result -} - -func convertEmptyClassrooms(entries []parser.EmptyClassroomEntry) []*pb.EmptyClassroomEntry { - result := make([]*pb.EmptyClassroomEntry, 0, len(entries)) - for _, e := range entries { - result = append(result, &pb.EmptyClassroomEntry{ - Classroom: e.Classroom, - Weekday: e.Weekday, - Periods: e.Periods, - Term: e.Term, - Weeks: e.Weeks, - }) - } - return result -} diff --git a/grpc/handler.go b/grpc/handler.go index 3daf44c..ce0200c 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -2,21 +2,28 @@ package grpc import ( "context" + "encoding/json" "errors" "fmt" "log/slog" "sync" "time" + "schedule_converter/service" + pb "schedule_converter/proto/runner" ) type TaskHandler struct { - cancelledTasks sync.Map + service *service.Service + cancels sync.Map // taskID -> context.CancelFunc(运行中的任务) + preCancelled sync.Map // taskID -> struct{}(任务开始前就已取消) } func NewTaskHandler() *TaskHandler { - return &TaskHandler{} + return &TaskHandler{ + service: service.NewService(), + } } func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { @@ -28,14 +35,17 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { CompletedAt: time.Now().UnixMilli(), } - if h.IsCancelled(task.TaskId) { + // 任务开始前检查是否已被预取消。 + if _, cancelled := h.preCancelled.LoadAndDelete(task.TaskId); cancelled { result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED result.ErrorMessage = "任务已取消" log.Info("任务已取消") return result } - ctx := context.Background() + ctx, cancel := h.buildTaskContext(task) + defer cancel() + data, err := h.dispatch(ctx, task) if err != nil { @@ -57,31 +67,68 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { return result } -var errUnknownTaskType = fmt.Errorf("unknown task type") - -func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) { - switch task.Type { - case pb.TaskType_TASK_TYPE_GET_SCHEDULE: - return h.getSchedule(ctx, task.Payload) - case pb.TaskType_TASK_TYPE_GET_GRADES: - return h.getGrades(ctx, task.Payload) - case pb.TaskType_TASK_TYPE_GET_EXAMS: - return h.getExams(ctx, task.Payload) - case pb.TaskType_TASK_TYPE_LOGIN: - return h.login(ctx, task.Payload) - case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: - return h.getEmptyClassrooms(ctx, task.Payload) - default: - return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) +// buildTaskContext 构造任务执行 context:根据 TimeoutSeconds 设置截止时间, +// 并注册 cancel 函数,使 CancelTask 能够中断正在执行的任务。 +func (h *TaskHandler) buildTaskContext(task *pb.Task) (context.Context, context.CancelFunc) { + ctx := context.Background() + if task.TimeoutSeconds > 0 { + ctx, cancel := context.WithTimeout(ctx, time.Duration(task.TimeoutSeconds)*time.Second) + h.cancels.Store(task.TaskId, context.CancelFunc(cancel)) + return ctx, func() { + h.cancels.Delete(task.TaskId) + cancel() + } + } + ctx, cancel := context.WithCancel(ctx) + h.cancels.Store(task.TaskId, context.CancelFunc(cancel)) + return ctx, func() { + h.cancels.Delete(task.TaskId) + cancel() } } +var errUnknownTaskType = fmt.Errorf("unknown task type") + +// taskHandlers 将任务类型映射到处理方法。 +var taskHandlers = map[pb.TaskType]func(h *TaskHandler, ctx context.Context, payload []byte) ([]byte, error){ + pb.TaskType_TASK_TYPE_GET_SCHEDULE: (*TaskHandler).getSchedule, + pb.TaskType_TASK_TYPE_GET_GRADES: (*TaskHandler).getGrades, + pb.TaskType_TASK_TYPE_GET_EXAMS: (*TaskHandler).getExams, + pb.TaskType_TASK_TYPE_LOGIN: (*TaskHandler).login, + pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: (*TaskHandler).getEmptyClassrooms, +} + +// supportedTaskTypes 返回当前支持的任务类型列表,供注册 capabilities 使用。 +func supportedTaskTypes() []pb.TaskType { + types := make([]pb.TaskType, 0, len(taskHandlers)) + for t := range taskHandlers { + types = append(types, t) + } + return types +} + +func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) { + handler, ok := taskHandlers[task.Type] + if !ok { + return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) + } + return handler(h, ctx, task.Payload) +} + +// CancelTask 取消任务:若任务正在执行则中断其 context;若尚未开始则记为预取消。 func (h *TaskHandler) CancelTask(taskID string) { - h.cancelledTasks.Store(taskID, true) + if v, ok := h.cancels.LoadAndDelete(taskID); ok { + if cancel, ok := v.(context.CancelFunc); ok { + cancel() + } + return + } + // 任务尚未开始执行,标记为预取消,待 Execute 时直接返回取消。 + h.preCancelled.Store(taskID, struct{}{}) slog.Info("任务已标记为取消", "task_id", taskID) } -func (h *TaskHandler) IsCancelled(taskID string) bool { - v, ok := h.cancelledTasks.Load(taskID) - return ok && v.(bool) +// unmarshalPayload 解码任务载荷。 +func unmarshalPayload(payload []byte, v any) error { + return json.Unmarshal(payload, v) } diff --git a/grpc/handler_classroom.go b/grpc/handler_classroom.go index 30dcd01..2bbda33 100644 --- a/grpc/handler_classroom.go +++ b/grpc/handler_classroom.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" apperr "schedule_converter/pkg/errors" ) @@ -21,31 +19,11 @@ func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([ log := slog.With("username", req.Username, "semester", req.Semester) log.Info("获取空教室任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetEmptyClassrooms(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_empty_classrooms", err, "获取空教室失败") } - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - classrooms, err := parser.FetchEmptyClassrooms(httpClient, req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode) - if err != nil { - return nil, apperr.New("fetch_empty_classrooms", err, "获取空教室失败") - } - - pbClassrooms := convertEmptyClassrooms(classrooms) - - result := &pb.EmptyClassroomResultData{ - StudentId: req.Username, - Semester: req.Semester, - Classrooms: pbClassrooms, - } - - log.Info("空教室获取成功", "classroom_count", len(pbClassrooms)) + log.Info("空教室获取成功", "classroom_count", len(result.Classrooms)) return json.Marshal(result) } diff --git a/grpc/handler_exams.go b/grpc/handler_exams.go index c350959..3bf734c 100644 --- a/grpc/handler_exams.go +++ b/grpc/handler_exams.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" apperr "schedule_converter/pkg/errors" ) @@ -21,31 +19,11 @@ func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, err log := slog.With("username", req.Username, "semester", req.Semester) log.Info("获取考试安排任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetExams(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_exams", err, "获取考试安排失败") } - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - exams, err := parser.FetchAllExams(httpClient) - if err != nil { - return nil, apperr.New("fetch_exams", err, "获取考试安排失败") - } - - pbExams := convertExams(exams) - - result := &pb.ExamsResultData{ - StudentId: req.Username, - Semester: req.Semester, - Exams: pbExams, - } - - log.Info("考试安排获取成功", "exam_count", len(pbExams)) + log.Info("考试安排获取成功", "exam_count", len(result.Exams)) return json.Marshal(result) } diff --git a/grpc/handler_grades.go b/grpc/handler_grades.go index 7bad40f..0041a52 100644 --- a/grpc/handler_grades.go +++ b/grpc/handler_grades.go @@ -7,8 +7,6 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" - "schedule_converter/parser" apperr "schedule_converter/pkg/errors" ) @@ -21,46 +19,11 @@ func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, er log := slog.With("username", req.Username, "semester", req.Semester) log.Info("获取成绩任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetGrades(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_grades", err, "获取成绩失败") } - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - var gpaSummary *pb.GpaSummary - summary, grades, err := parser.FetchGPA(httpClient) - if err != nil { - log.Warn("获取GPA页面失败,尝试成绩页面", "error", err) - grades, err = parser.FetchGrades(httpClient, parser.ScoreTypeFinal) - if err != nil { - return nil, apperr.New("fetch_grades", err, "获取成绩失败") - } - } else if summary != nil { - gpaSummary = &pb.GpaSummary{ - AverageGpa: summary.AverageGPA, - Rank: summary.Rank, - ExamTotalGpa: summary.ExamTotalGPA, - ExamTotalCredits: summary.ExamTotalCredits, - FailCredits: summary.FailCredits, - Semesters: summary.Semesters, - } - } - - pbGrades := convertGrades(grades) - - result := &pb.GradesResultData{ - StudentId: req.Username, - Semester: req.Semester, - Grades: pbGrades, - GpaSummary: gpaSummary, - } - - log.Info("成绩获取成功", "grade_count", len(pbGrades)) + log.Info("成绩获取成功", "grade_count", len(result.Grades)) return json.Marshal(result) } diff --git a/grpc/handler_login.go b/grpc/handler_login.go index ad98ea0..fde3332 100644 --- a/grpc/handler_login.go +++ b/grpc/handler_login.go @@ -7,7 +7,7 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" + apperr "schedule_converter/pkg/errors" ) func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) { @@ -19,16 +19,8 @@ func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) log := slog.With("username", req.Username) log.Info("登录任务") - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid) - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err + if _, err := h.service.Login(ctx, req.Username, req.Password, req.Twfid); err != nil { + return nil, apperr.New("login", err, "登录失败") } log.Info("登录成功") diff --git a/grpc/handler_schedule.go b/grpc/handler_schedule.go index 8bd4d54..e2df216 100644 --- a/grpc/handler_schedule.go +++ b/grpc/handler_schedule.go @@ -7,7 +7,7 @@ import ( pb "schedule_converter/proto/runner" - "schedule_converter/config" + apperr "schedule_converter/pkg/errors" ) func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) { @@ -19,25 +19,11 @@ func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, log := slog.With("username", req.Username, "semester", req.Semester) log.Info("获取课表任务") - httpClient, err := createHTTPClient() + result, err := h.service.GetSchedule(ctx, &req) if err != nil { - return nil, err + return nil, apperr.New("get_schedule", err, "获取课表失败") } - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester) - if err != nil { - return nil, err - } - - result := &pb.ScheduleResultData{ - StudentId: req.Username, - Semester: req.Semester, - Courses: convertCourses(courses), - } - - log.Info("课表获取成功", "course_count", len(courses)) + log.Info("课表获取成功", "course_count", len(result.Courses)) return json.Marshal(result) } diff --git a/models/empty_classroom.go b/models/empty_classroom.go new file mode 100644 index 0000000..3aace3a --- /dev/null +++ b/models/empty_classroom.go @@ -0,0 +1,10 @@ +package models + +// EmptyClassroomEntry 空教室条目 +type EmptyClassroomEntry struct { + Classroom string `json:"classroom"` + Weekday string `json:"weekday"` + Periods string `json:"periods"` + Term string `json:"term"` + Weeks string `json:"weeks"` +} diff --git a/models/exam.go b/models/exam.go new file mode 100644 index 0000000..1a30f72 --- /dev/null +++ b/models/exam.go @@ -0,0 +1,20 @@ +package models + +// ExamType 考试类型 +type ExamType string + +const ( + ExamTypeFinal ExamType = "final" + ExamTypeMidterm ExamType = "midterm" + ExamTypeMakeup ExamType = "makeup" +) + +// ExamEntry 考试条目 +type ExamEntry struct { + Course string + Date string + Time string + Week string + Weekday string + Type ExamType +} diff --git a/models/grade.go b/models/grade.go new file mode 100644 index 0000000..fe54137 --- /dev/null +++ b/models/grade.go @@ -0,0 +1,32 @@ +package models + +// ScoreType 成绩类型 +type ScoreType string + +const ( + ScoreTypeFinal ScoreType = "final" + ScoreTypeMidterm ScoreType = "midterm" +) + +// GradeEntry 成绩条目 +type GradeEntry struct { + Term string + Code string + Name string + Nature string + Category string + Credit string + Score string + GradePoint string + IsExam string +} + +// GPASummary 学分绩概要 +type GPASummary struct { + AverageGPA string + Rank string + ExamTotalGPA string + ExamTotalCredits string + FailCredits string + Semesters string +} diff --git a/parser/empty_classroom.go b/parser/empty_classroom.go index 68f591d..d55e459 100644 --- a/parser/empty_classroom.go +++ b/parser/empty_classroom.go @@ -2,82 +2,15 @@ package parser import ( "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "github.com/PuerkitoBio/goquery" ) -type EmptyClassroomEntry struct { - Classroom string `json:"classroom"` - Weekday string `json:"weekday"` - Periods string `json:"periods"` - Term string `json:"term"` - Weeks string `json:"weeks"` -} - -type buildingInfo struct { - DM string `json:"dm"` - MC string `json:"mc"` -} - -type venueInfo struct { - DM string `json:"dm"` - MC string `json:"mc"` -} - -func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd, campusCode string) ([]EmptyClassroomEntry, error) { - url := config.Get().BaseURL + "/kjscx/queryKjs" - log := slog.With("semester", semester, "week_start", weekStart, "week_end", weekEnd) - log.Info("获取空教室数据") - - // First GET to establish session - resp, err := httpClient.Get(url) - if err != nil { - return nil, fmt.Errorf("访问空教室页面失败: %w", err) - } - resp.Body.Close() - - // POST with query parameters - data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s", - semester, weekStart, weekEnd, campusCode) - req, err := http.NewRequest("POST", url, strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - req.Header.Set("Referer", url) - - resp, err = httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("查询空教室失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("查询空教室失败, 状态码: %d", resp.StatusCode) - } - - html, err := decodeResponseBody(resp) - if err != nil { - return nil, err - } - - entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd) - if err != nil { - return nil, err - } - - log.Info("空教室数据获取完成", "count", len(entries)) - return entries, nil -} - -func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) { +// ParseEmptyClassroomHTML 解析空教室查询结果 HTML,返回空教室条目列表。 +func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) { if html == "" { return nil, nil } @@ -94,7 +27,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"} periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"} - var classrooms []EmptyClassroomEntry + var classrooms []models.EmptyClassroomEntry // Find the main data table with class="dataTable" doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) { @@ -201,7 +134,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty if weekStart != "" && weekEnd != "" { weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd) } - entry := EmptyClassroomEntry{ + entry := models.EmptyClassroomEntry{ Classroom: classroomName, Weekday: strings.Join(weekdays, ", "), Periods: strings.Join(slots, "\n"), @@ -216,69 +149,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty return classrooms, nil } -func fetchBuildings(httpClient *http.Client, campusCode string) ([]buildingInfo, error) { - url := config.Get().BaseURL + "/kjscx/queryJxlListBySjid" - slog.Info("获取楼号列表", "campus", campusCode) - - data := fmt.Sprintf("id=%s", campusCode) - req, err := http.NewRequest("POST", url, strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("获取楼号列表失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode) - } - - var buildings []buildingInfo - if err := decodeJSONResponse(resp, &buildings); err != nil { - return nil, fmt.Errorf("解析楼号列表失败: %w", err) - } - - return buildings, nil -} - -func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, error) { - url := config.Get().BaseURL + "/kjscx/queryJxcdListBySjid" - slog.Info("获取场地列表", "building", buildingCode) - - data := fmt.Sprintf("id=%s", buildingCode) - req, err := http.NewRequest("POST", url, strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("获取场地列表失败: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode) - } - - var venues []venueInfo - if err := decodeJSONResponse(resp, &venues); err != nil { - return nil, fmt.Errorf("解析场地列表失败: %w", err) - } - - return venues, nil -} - // TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing -func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) { - return parseEmptyClassroomHTML(html, semester, weekStart, weekEnd) +func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) { + return ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd) } diff --git a/parser/exam.go b/parser/exam.go index 8359e21..24ca691 100644 --- a/parser/exam.go +++ b/parser/exam.go @@ -1,107 +1,38 @@ package parser import ( - "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "github.com/PuerkitoBio/goquery" ) -type ExamType string - -const ( - ExamTypeFinal ExamType = "final" - ExamTypeMidterm ExamType = "midterm" - ExamTypeMakeup ExamType = "makeup" -) - -type ExamEntry struct { - Course string - Date string - Time string - Week string - Weekday string - Type ExamType -} - -func examTypeToCode(t ExamType) string { +// examTypeToCode 将考试类型映射为教务系统的请求码。 +func examTypeToCode(t models.ExamType) string { switch t { - case ExamTypeMidterm: + case models.ExamTypeMidterm: return "02" - case ExamTypeMakeup: + case models.ExamTypeMakeup: return "03" default: return "01" } } -func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) { - examURL := config.Get().BaseURL + "/kscx/queryKsForXs" - log := slog.With("exam_type", string(examType)) - log.Info("获取考试时间数据") - - data := fmt.Sprintf("xnxq=&kssjd=%s", examTypeToCode(examType)) - req, err := http.NewRequest("POST", examURL, strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取考试数据失败, 状态码: %d", resp.StatusCode) - } - - html, err := decodeResponseBody(resp) - if err != nil { - return nil, err - } - - exams, err := parseExamTimeHTML(html, examType) - if err != nil { - return nil, err - } - - log.Info("考试数据获取完成", "exam_count", len(exams)) - return exams, nil +// ExamTypeCode 返回考试类型对应的请求码(供 client 层构造请求使用)。 +func ExamTypeCode(t models.ExamType) string { + return examTypeToCode(t) } -func FetchAllExams(httpClient *http.Client) ([]ExamEntry, error) { - var allExams []ExamEntry - var lastErr error - for _, et := range []ExamType{ExamTypeFinal, ExamTypeMidterm, ExamTypeMakeup} { - exams, err := FetchExams(httpClient, et) - if err != nil { - slog.Warn("获取考试数据失败", "exam_type", string(et), "error", err) - lastErr = err - continue - } - allExams = append(allExams, exams...) - } - if len(allExams) == 0 && lastErr != nil { - return nil, fmt.Errorf("所有考试类型获取均失败: %w", lastErr) - } - return allExams, nil -} - -func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { +// ParseExamHTML 解析考试安排页面 HTML,返回考试条目列表。 +func ParseExamHTML(html string, examType models.ExamType) ([]models.ExamEntry, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, err } - var exams []ExamEntry + var exams []models.ExamEntry doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { rows := table.Find("tr") @@ -120,7 +51,7 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { texts := getCellTexts(cells) - exam := ExamEntry{ + exam := models.ExamEntry{ Course: safeGet(texts, 1), Date: safeGet(texts, 2), Time: safeGet(texts, 3), @@ -136,4 +67,4 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) { }) return exams, nil -} \ No newline at end of file +} diff --git a/parser/schedule.go b/parser/schedule.go index d660c11..f5beea1 100644 --- a/parser/schedule.go +++ b/parser/schedule.go @@ -1,16 +1,11 @@ package parser import ( - "fmt" - "io" - "net/http" "regexp" "sort" "strconv" "strings" - "schedule_converter/client" - "schedule_converter/config" "schedule_converter/models" "github.com/PuerkitoBio/goquery" @@ -23,32 +18,6 @@ type weekRoomEntry struct { room string } -// 获取课表 -// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表 -func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) { - // POST请求获取课表 - data := fmt.Sprintf("xnxq=%s", semester) - req, err := http.NewRequest("POST", config.Get().BaseURL+"/kbcx/queryGrkb", strings.NewReader(data)) - if err != nil { - return nil, err - } - - req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - return ParseScheduleHTML(string(body)) -} - // 解析课表HTML func ParseScheduleHTML(html string) ([]models.Course, error) { // 创建一个带Reader的Document diff --git a/parser/score.go b/parser/score.go index 14f2868..4d52e4a 100644 --- a/parser/score.go +++ b/parser/score.go @@ -1,128 +1,21 @@ package parser import ( - "fmt" - "log/slog" - "net/http" "strings" - "schedule_converter/client" - "schedule_converter/config" + "schedule_converter/models" "github.com/PuerkitoBio/goquery" ) -type ScoreType string - -const ( - ScoreTypeFinal ScoreType = "final" - ScoreTypeMidterm ScoreType = "midterm" -) - -type GradeEntry struct { - Term string - Code string - Name string - Nature string - Category string - Credit string - Score string - GradePoint string - IsExam string -} - -type GPASummary struct { - AverageGPA string - Rank string - ExamTotalGPA string - ExamTotalCredits string - FailCredits string - Semesters string -} - -func FetchGrades(httpClient *http.Client, examType ScoreType) ([]GradeEntry, error) { - var gradeURL string - switch examType { - case ScoreTypeMidterm: - gradeURL = config.Get().BaseURL + "/cjcx/queryQzcj" - default: - gradeURL = config.Get().BaseURL + "/cjcx/queryQmcj" - } - - log := slog.With("exam_type", string(examType)) - log.Info("获取成绩数据") - - req, err := http.NewRequest("GET", gradeURL, nil) - if err != nil { - return nil, err - } - req.Header = client.GetHeaders() - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("获取成绩失败, 状态码: %d", resp.StatusCode) - } - - html, err := decodeResponseBody(resp) - if err != nil { - return nil, err - } - - grades, err := parseGradesHTML(html) - if err != nil { - return nil, err - } - - log.Info("成绩获取完成", "grade_count", len(grades)) - return grades, nil -} - -func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) { - gpaURL := config.Get().BaseURL + "/xfj/queryListXfj" - slog.Info("获取学分绩数据") - - req, err := http.NewRequest("GET", gpaURL, nil) - if err != nil { - return nil, nil, err - } - req.Header = client.GetHeaders() - - resp, err := httpClient.Do(req) - if err != nil { - return nil, nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, nil, fmt.Errorf("获取学分绩失败, 状态码: %d", resp.StatusCode) - } - - html, err := decodeResponseBody(resp) - if err != nil { - return nil, nil, err - } - - summary, grades, err := parseGPAHTML(html) - if err != nil { - return nil, nil, err - } - - slog.Info("学分绩获取完成", "grade_count", len(grades)) - return summary, grades, nil -} - -func parseGradesHTML(html string) ([]GradeEntry, error) { +// ParseGradesHTML 解析成绩页面 HTML,返回成绩条目列表。 +func ParseGradesHTML(html string) ([]models.GradeEntry, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, err } - var grades []GradeEntry + var grades []models.GradeEntry doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { rows := table.Find("tr") @@ -146,7 +39,7 @@ func parseGradesHTML(html string) ([]GradeEntry, error) { isMidterm := len(texts) == 10 - entry := GradeEntry{ + entry := models.GradeEntry{ Term: safeGet(texts, 1), Code: safeGet(texts, 3), Name: safeGet(texts, 4), @@ -183,13 +76,14 @@ func parseGradesHTML(html string) ([]GradeEntry, error) { return grades, nil } -func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { +// ParseGPAHTML 解析学分绩页面 HTML,返回 GPA 概要与成绩条目列表。 +func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) { doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) if err != nil { return nil, nil, err } - summary := &GPASummary{} + summary := &models.GPASummary{} doc.Find("span").Each(func(_ int, span *goquery.Selection) { id, _ := span.Attr("id") @@ -210,7 +104,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { } }) - var grades []GradeEntry + var grades []models.GradeEntry doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) { rows := table.Find("tr") @@ -229,7 +123,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) { texts := getCellTexts(cells) - entry := GradeEntry{ + entry := models.GradeEntry{ Term: safeGet(texts, 0), Code: safeGet(texts, 1), Name: safeGet(texts, 2), diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 96ce1cf..4e1c4c1 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -7,25 +7,26 @@ import ( var ErrLoginFailed = errors.New("login failed") -type ScheduleError struct { +// AppError 携带操作名(Op)与中文提示(Msg)的错误类型,用于统一日志与错误上报。 +type AppError struct { Op string Err error Msg string } -func (e *ScheduleError) Error() string { +func (e *AppError) Error() string { if e.Msg != "" { return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err) } return fmt.Sprintf("%s: %v", e.Op, e.Err) } -func (e *ScheduleError) Unwrap() error { +func (e *AppError) Unwrap() error { return e.Err } func New(op string, err error, msg string) error { - return &ScheduleError{ + return &AppError{ Op: op, Err: err, Msg: msg, diff --git a/service/classroom.go b/service/classroom.go new file mode 100644 index 0000000..ffed707 --- /dev/null +++ b/service/classroom.go @@ -0,0 +1,77 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "strings" + + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" + + pb "schedule_converter/proto/runner" +) + +// GetEmptyClassrooms 获取空教室:登录 → GET 建立会话 → POST 查询 → 解析 → 转换为 pb 结果。 +func (s *Service) GetEmptyClassrooms(ctx context.Context, req *pb.GetEmptyClassroomPayload) (*pb.EmptyClassroomResultData, error) { + log := slog.With("username", req.Username, "semester", req.Semester) + + httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "") + if err != nil { + return nil, err + } + + kjsURL := config.Get().BaseURL + "/kjscx/queryKjs" + + // 先 GET 建立会话(沿用原有行为:直接用 client.Get,仅取 cookie,丢弃 body)。 + sessionReq, err := http.NewRequestWithContext(ctx, "GET", kjsURL, nil) + if err != nil { + return nil, apperr.New("session_classroom", err, "建立空教室会话失败") + } + sessionReq.Header = client.GetHeaders() + sessionResp, err := httpClient.Do(sessionReq) + if err != nil { + return nil, apperr.New("session_classroom", err, "访问空教室页面失败") + } + sessionResp.Body.Close() + + // POST 查询参数。 + data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s", + req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode) + html, err := client.FetchHTML(ctx, httpClient, "POST", "/kjscx/queryKjs", strings.NewReader(data), "application/x-www-form-urlencoded", "/kjscx/queryKjs") + if err != nil { + return nil, apperr.New("fetch_empty_classrooms", err, "查询空教室失败") + } + + entries, err := parser.ParseEmptyClassroomHTML(html, req.Semester, req.WeekStart, req.WeekEnd) + if err != nil { + return nil, apperr.New("parse_empty_classrooms", err, "解析空教室失败") + } + + log.Info("空教室获取成功", "classroom_count", len(entries)) + + return &pb.EmptyClassroomResultData{ + StudentId: req.Username, + Semester: req.Semester, + Classrooms: convertEmptyClassrooms(entries), + }, nil +} + +// convertEmptyClassrooms 将内部空教室模型转换为 pb.EmptyClassroomEntry 列表。 +func convertEmptyClassrooms(entries []models.EmptyClassroomEntry) []*pb.EmptyClassroomEntry { + result := make([]*pb.EmptyClassroomEntry, 0, len(entries)) + for _, e := range entries { + result = append(result, &pb.EmptyClassroomEntry{ + Classroom: e.Classroom, + Weekday: e.Weekday, + Periods: e.Periods, + Term: e.Term, + Weeks: e.Weeks, + }) + } + return result +} diff --git a/service/exams.go b/service/exams.go new file mode 100644 index 0000000..defd791 --- /dev/null +++ b/service/exams.go @@ -0,0 +1,79 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "schedule_converter/client" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" + + pb "schedule_converter/proto/runner" +) + +var allExamTypes = []models.ExamType{models.ExamTypeFinal, models.ExamTypeMidterm, models.ExamTypeMakeup} + +// GetExams 获取考试安排:登录 → 遍历各考试类型抓取并解析 → 合并 → 转换为 pb 结果。 +func (s *Service) GetExams(ctx context.Context, req *pb.GetExamsPayload) (*pb.ExamsResultData, error) { + log := slog.With("username", req.Username, "semester", req.Semester) + + httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "") + if err != nil { + return nil, err + } + + var allExams []models.ExamEntry + var lastErr error + for _, et := range allExamTypes { + data := fmt.Sprintf("xnxq=&kssjd=%s", parser.ExamTypeCode(et)) + html, ferr := client.FetchHTML(ctx, httpClient, "POST", "/kscx/queryKsForXs", strings.NewReader(data), "application/x-www-form-urlencoded", "") + if ferr != nil { + log.Warn("获取考试数据失败", "exam_type", string(et), "error", ferr) + lastErr = ferr + continue + } + exams, perr := parser.ParseExamHTML(html, et) + if perr != nil { + log.Warn("解析考试数据失败", "exam_type", string(et), "error", perr) + lastErr = perr + continue + } + allExams = append(allExams, exams...) + } + + if len(allExams) == 0 && lastErr != nil { + return nil, apperr.New("fetch_exams", lastErr, "所有考试类型获取均失败") + } + + return &pb.ExamsResultData{ + StudentId: req.Username, + Semester: req.Semester, + Exams: convertExams(allExams), + }, nil +} + +// convertExams 将内部考试模型转换为 pb.Exam 列表。 +func convertExams(exams []models.ExamEntry) []*pb.Exam { + result := make([]*pb.Exam, 0, len(exams)) + for _, e := range exams { + examTypeStr := "期末" + switch e.Type { + case models.ExamTypeMidterm: + examTypeStr = "期中" + case models.ExamTypeMakeup: + examTypeStr = "补考" + } + result = append(result, &pb.Exam{ + CourseName: e.Course, + ExamType: examTypeStr, + Date: e.Date, + Time: e.Time, + Week: e.Week, + Weekday: e.Weekday, + }) + } + return result +} diff --git a/service/grades.go b/service/grades.go new file mode 100644 index 0000000..2918a25 --- /dev/null +++ b/service/grades.go @@ -0,0 +1,84 @@ +package service + +import ( + "context" + "log/slog" + + "schedule_converter/client" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" + + pb "schedule_converter/proto/runner" +) + +// GetGrades 获取成绩:登录 → 优先 GPA 页面 → 回退到成绩页面 → 解析 → 转换为 pb 结果。 +func (s *Service) GetGrades(ctx context.Context, req *pb.GetGradesPayload) (*pb.GradesResultData, error) { + log := slog.With("username", req.Username, "semester", req.Semester) + + httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "") + if err != nil { + return nil, err + } + + var gpaSummary *pb.GpaSummary + + gpaHTML, err := client.FetchHTML(ctx, httpClient, "GET", "/xfj/queryListXfj", nil, "", "") + if err != nil { + log.Warn("获取GPA页面失败,尝试成绩页面", "error", err) + gradesHTML, ferr := client.FetchHTML(ctx, httpClient, "GET", "/cjcx/queryQmcj", nil, "", "") + if ferr != nil { + return nil, apperr.New("fetch_grades", ferr, "获取成绩失败") + } + grades, perr := parser.ParseGradesHTML(gradesHTML) + if perr != nil { + return nil, apperr.New("parse_grades", perr, "解析成绩失败") + } + return &pb.GradesResultData{ + StudentId: req.Username, + Semester: req.Semester, + Grades: convertGrades(grades), + }, nil + } + + summary, grades, err := parser.ParseGPAHTML(gpaHTML) + if err != nil { + return nil, apperr.New("parse_gpa", err, "解析学分绩失败") + } + if summary != nil { + gpaSummary = &pb.GpaSummary{ + AverageGpa: summary.AverageGPA, + Rank: summary.Rank, + ExamTotalGpa: summary.ExamTotalGPA, + ExamTotalCredits: summary.ExamTotalCredits, + FailCredits: summary.FailCredits, + Semesters: summary.Semesters, + } + } + + return &pb.GradesResultData{ + StudentId: req.Username, + Semester: req.Semester, + Grades: convertGrades(grades), + GpaSummary: gpaSummary, + }, nil +} + +// convertGrades 将内部成绩模型转换为 pb.Grade 列表。 +func convertGrades(grades []models.GradeEntry) []*pb.Grade { + result := make([]*pb.Grade, 0, len(grades)) + for _, g := range grades { + result = append(result, &pb.Grade{ + Term: g.Term, + CourseCode: g.Code, + CourseName: g.Name, + Nature: g.Nature, + Category: g.Category, + Credit: g.Credit, + Score: g.Score, + GradePoint: g.GradePoint, + IsExam: g.IsExam, + }) + } + return result +} diff --git a/service/login.go b/service/login.go new file mode 100644 index 0000000..b323797 --- /dev/null +++ b/service/login.go @@ -0,0 +1,11 @@ +package service + +import ( + "context" + "net/http" +) + +// Login 仅执行登录流程,返回已认证的 HTTP 客户端(调用方可继续用它抓取数据)。 +func (s *Service) Login(ctx context.Context, username, password, twfid string) (*http.Client, error) { + return s.newAuthenticatedClient(ctx, username, password, twfid) +} diff --git a/service/schedule.go b/service/schedule.go new file mode 100644 index 0000000..9b1ff87 --- /dev/null +++ b/service/schedule.go @@ -0,0 +1,75 @@ +package service + +import ( + "context" + "fmt" + "strings" + + "schedule_converter/client" + "schedule_converter/config" + "schedule_converter/models" + "schedule_converter/parser" + apperr "schedule_converter/pkg/errors" + + pb "schedule_converter/proto/runner" +) + +// GetSchedule 获取课表:登录 → POST 查询 → 解析 → 转换为 pb 结果。 +func (s *Service) GetSchedule(ctx context.Context, req *pb.GetSchedulePayload) (*pb.ScheduleResultData, error) { + httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "") + if err != nil { + return nil, err + } + + semester := req.Semester + if semester == "" { + semester = config.Get().Semester + } + + data := fmt.Sprintf("xnxq=%s", semester) + html, err := client.FetchHTML(ctx, httpClient, "POST", "/kbcx/queryGrkb", strings.NewReader(data), "application/x-www-form-urlencoded", "") + if err != nil { + return nil, apperr.New("fetch_schedule", err, "获取课表失败") + } + + courses, err := parser.ParseScheduleHTML(html) + if err != nil { + return nil, apperr.New("parse_schedule", err, "解析课表失败") + } + + return &pb.ScheduleResultData{ + StudentId: req.Username, + Semester: semester, + Courses: convertCourses(courses), + }, nil +} + +// convertCourses 将内部课程模型转换为 pb.Course 列表。 +func convertCourses(courses []models.Course) []*pb.Course { + result := make([]*pb.Course, len(courses)) + for i, c := range courses { + schedule := make([]*pb.ScheduleTime, len(c.Schedule)) + for j, sc := range c.Schedule { + schedule[j] = &pb.ScheduleTime{ + Day: sc.Day, + Sections: toInt32Slice(sc.Section), + Weeks: toInt32Slice(sc.Weeks), + } + } + result[i] = &pb.Course{ + Name: c.Name, + Teacher: c.Teacher, + Room: c.Room, + Schedule: schedule, + } + } + return result +} + +func toInt32Slice(arr []int) []int32 { + result := make([]int32, len(arr)) + for i, v := range arr { + result[i] = int32(v) + } + return result +} diff --git a/grpc/handler_helpers.go b/service/service.go similarity index 61% rename from grpc/handler_helpers.go rename to service/service.go index adbc745..76f1a6d 100644 --- a/grpc/handler_helpers.go +++ b/service/service.go @@ -1,8 +1,7 @@ -package grpc +package service import ( "context" - "encoding/json" "log/slog" "net/http" "net/http/cookiejar" @@ -13,8 +12,6 @@ import ( "schedule_converter/captcha" "schedule_converter/client" "schedule_converter/config" - "schedule_converter/models" - "schedule_converter/parser" apperr "schedule_converter/pkg/errors" ) @@ -23,19 +20,34 @@ const ( loginRetryInterval = 3 * time.Second ) -func createHTTPClient() (*http.Client, error) { +// Service 封装教务系统的登录与数据抓取编排逻辑。 +type Service struct{} + +// NewService 创建 service。 +func NewService() *Service { + return &Service{} +} + +// newAuthenticatedClient 创建带 cookie jar 的 HTTP 客户端,设置 TWFID cookie 并完成登录。 +func (s *Service) newAuthenticatedClient(ctx context.Context, username, password, twfid string) (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, apperr.New("create_jar", err, "创建 cookie jar 失败") } - return &http.Client{Jar: jar}, nil + httpClient := &http.Client{Jar: jar} + + if twfid == "" { + twfid = config.Get().TWFID + } + s.setTWFIDCookie(httpClient, config.Get().BaseURL, twfid) + + if err := s.login(ctx, httpClient, username, password); err != nil { + return nil, err + } + return httpClient, nil } -func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { - cfg := config.Get() - if twfid == "" { - twfid = cfg.TWFID - } +func (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { u, err := url.Parse(baseURL) if err != nil { slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err) @@ -46,7 +58,8 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { }) } -func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error { +// login 执行登录流程,包含验证码识别与重试。 +func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error { log := slog.With("username", username) if err := client.GetInitialCookiesWithClient(httpClient); err != nil { @@ -69,7 +82,7 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo continue } - success, err := auth.LoginWithClient(httpClient, captchaCode, username, password) + success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password) if err != nil { log.Warn("登录请求失败", "error", err, "attempt", attempt) time.Sleep(loginRetryInterval) @@ -86,23 +99,3 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo return apperr.ErrLoginFailed } - -func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) { - if err := executeLogin(ctx, httpClient, username, password); err != nil { - return nil, err - } - - cfg := config.Get() - if semester == "" { - semester = cfg.Semester - } - - return parser.FetchScheduleWithClient(httpClient, semester) -} - -func unmarshalPayload(payload []byte, v any) error { - if err := json.Unmarshal(payload, v); err != nil { - return apperr.New("parse_payload", err, "解析任务载荷失败") - } - return nil -}