All checks were successful
Build / build (push) Successful in 1m39s
Update the gRPC service definitions and handlers to provide more detailed information for grades and exams. - Add `GpaSummary` message to include average GPA, rank, and other academic statistics. - Expand `Grade` message with additional fields like `term`, `nature`, `category`, and `is_exam`. - Refactor `Exam` message to use `week` and `weekday` instead of `room` and `seat`. - Update `TaskHandler.getGrades` to populate the new `GpaSummary` and enriched grade details. - Update protobuf generated files to reflect schema changes.
386 lines
9.5 KiB
Go
386 lines
9.5 KiB
Go
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)
|
||
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
|
||
}
|