All checks were successful
Build / build (push) Successful in 2m14s
Implement new gRPC task handlers for retrieving student grades and exam schedules. This includes adding new parser logic for processing score and exam data, and updating the client capabilities to advertise support for these new task types. - Add `getGrades` and `getExams` handlers in `grpc/handler.go` - Add `parser/encoding.go`, `parser/exam.go`, and `parser/score.go` for data parsing - Update `grpc/client.go` to include `TASK_TYPE_GET_GRADES` and `TASK_TYPE_GET_EXAMS` in capabilities
393 lines
9.5 KiB
Go
393 lines
9.5 KiB
Go
package grpc
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"net/http/cookiejar"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"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
|
||
}
|
||
|
||
_, 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, "获取成绩失败")
|
||
}
|
||
}
|
||
|
||
pbGrades := convertGrades(grades)
|
||
|
||
result := &pb.GradesResultData{
|
||
StudentId: req.Username,
|
||
Semester: req.Semester,
|
||
Grades: pbGrades,
|
||
}
|
||
|
||
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 {
|
||
var credit float32
|
||
if v, err := parseFloat(g.Credit); err == nil {
|
||
credit = v
|
||
}
|
||
var gradePoint float32
|
||
if v, err := parseFloat(g.GradePoint); err == nil {
|
||
gradePoint = v
|
||
}
|
||
result = append(result, &pb.Grade{
|
||
CourseCode: g.Code,
|
||
CourseName: g.Name,
|
||
Credit: credit,
|
||
Grade: g.Score,
|
||
GradePoint: gradePoint,
|
||
})
|
||
}
|
||
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,
|
||
Room: e.Week,
|
||
Seat: e.Weekday,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func parseFloat(s string) (float32, error) {
|
||
s = strings.TrimSpace(s)
|
||
if s == "" || s == "-" {
|
||
return 0, fmt.Errorf("empty")
|
||
}
|
||
v, err := strconv.ParseFloat(s, 32)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return float32(v), nil
|
||
}
|