refactor: introduce service layer and migrate gRPC handlers to use it
All checks were successful
Build / build (push) Successful in 2m17s

Extract business logic from gRPC handlers into a dedicated service package.
Add context support throughout for cancellation and timeouts. Move models to
their own package, remove hardcoded credentials from config, and simplify
parsers to only handle HTML parsing.
This commit is contained in:
lafay
2026-06-16 18:33:13 +08:00
parent ae9297c11c
commit ab37aeb2fc
26 changed files with 603 additions and 683 deletions

View File

@@ -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,
},
},
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -1,108 +0,0 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
const (
maxLoginRetries = 3
loginRetryInterval = 3 * time.Second
)
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, err := url.Parse(baseURL)
if err != nil {
slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err)
return
}
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 失败")
}
for attempt := 1; attempt <= maxLoginRetries; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries)
captchaCode, err := captcha.GetAndRecognize(httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
continue
}
if success {
return nil
}
log.Warn("登录验证失败", "attempt", attempt)
time.Sleep(loginRetryInterval)
}
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
}

View File

@@ -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("登录成功")

View File

@@ -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)
}