Files
schedule_converter/grpc/handler.go
lan ee5c36f3ac
All checks were successful
Build / build (push) Successful in 1m31s
feat(grpc): add support for fetching empty classrooms
Implement the `TASK_TYPE_GET_EMPTY_CLASSROOM` task type to allow users to fetch available classroom information.

- Add `TASK_TYPE_GET_EMPTY_CLASSROOM` to the `TaskType` enum in protobuf.
- Define `GetEmptyClassroomPayload` and `EmptyClassroomResultData` messages.
- Implement `getEmptyClassrooms`
2026-06-02 13:59:30 +08:00

440 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package grpc
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"sync"
"time"
pb "schedule_converter/proto/runner"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
type TaskHandler struct {
cancelledTasks sync.Map
}
func NewTaskHandler() *TaskHandler {
return &TaskHandler{}
}
func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
log := slog.With("task_id", task.TaskId, "task_type", task.Type.String())
log.Info("开始执行任务")
result := &pb.TaskResult{
TaskId: task.TaskId,
CompletedAt: time.Now().UnixMilli(),
}
if h.IsCancelled(task.TaskId) {
result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED
result.ErrorMessage = "任务已取消"
log.Info("任务已取消")
return result
}
var err error
var data []byte
switch task.Type {
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
data, err = h.getSchedule(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_GRADES:
data, err = h.getGrades(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_EXAMS:
data, err = h.getExams(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_LOGIN:
data, err = h.login(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
data, err = h.getEmptyClassrooms(context.Background(), task.Payload)
default:
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type)
log.Warn("未知的任务类型")
return result
}
if err != nil {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
log.Error("任务执行失败", "error", err)
} else {
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
log.Info("任务执行成功")
}
return result
}
func (h *TaskHandler) CancelTask(taskID string) {
h.cancelledTasks.Store(taskID, true)
slog.Info("任务已标记为取消", "task_id", taskID)
}
func (h *TaskHandler) IsCancelled(taskID string) bool {
v, ok := h.cancelledTasks.Load(taskID)
return ok && v.(bool)
}
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetSchedulePayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取课表任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, 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))
return json.Marshal(result)
}
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.LoginPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
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
}
log.Info("登录成功")
return json.Marshal(map[string]any{
"success": true,
"message": "登录成功",
})
}
func createHTTPClient() (*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
}
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = cfg.TWFID
}
u, _ := url.Parse(baseURL)
httpClient.Jar.SetCookies(u, []*http.Cookie{
{Name: "TWFID", Value: twfid},
})
}
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error {
log := slog.With("username", username)
if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
return apperr.New("get_cookies", err, "获取初始 Cookie 失败")
}
cfg := config.Get()
_ = cfg.Recognizer.Timeout
for attempt := 1; attempt <= 3; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", 3)
captchaCode, err := captcha.GetAndRecognize(httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
time.Sleep(3 * time.Second)
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(3 * time.Second)
continue
}
if success {
return nil
}
log.Warn("登录验证失败", "attempt", attempt)
time.Sleep(3 * time.Second)
}
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 (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetGradesPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取成绩任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, 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))
return json.Marshal(result)
}
func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetExamsPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取考试安排任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, 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))
return json.Marshal(result)
}
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 (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetEmptyClassroomPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取空教室任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, 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))
return json.Marshal(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
}