refactor(grpc): restructure task handling and migrate to slog
All checks were successful
Build / build (push) Successful in 5m28s

Refactor the gRPC client and task handling logic to improve modularity,
error handling, and observability.

- Split `grpc/handler.go` into specialized handler files (login,
  classroom, exams, grades, schedule) to reduce complexity.
- Replace standard `log` with `log/slog` throughout the project for
  structured logging.
- Refactor `grpc/client.go` to use a thread-safe connection management
  pattern with `sync.RWMutex`.
- Remove the legacy `service` package and HTTP server implementation,
  transitioning the application to a pure gRPC runner mode.
- Clean up `config` and `pkg/errors` to remove unused utilities and
  simplify the API.
- Improve error reporting in parsers and HTTP clients by checking
  response status codes.
This commit is contained in:
2026-06-09 23:08:03 +08:00
parent ee5c36f3ac
commit accc6567f2
26 changed files with 720 additions and 1442 deletions

View File

@@ -4,7 +4,7 @@ import (
"context"
"fmt"
"io"
"log"
"log/slog"
"sync"
"time"
@@ -14,17 +14,20 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
const defaultHeartbeatInterval = 30 * time.Second
// Client gRPC 客户端
type Client struct {
serverAddr string
runnerID string
version string
conn *grpc.ClientConn
stream pb.RunnerHub_ConnectClient
mu sync.Mutex
handler *TaskHandler
// 配置
mu sync.RWMutex
conn *grpc.ClientConn
stream pb.RunnerHub_ConnectClient
connected bool
handler *TaskHandler
heartbeatInterval time.Duration
}
@@ -34,7 +37,7 @@ func NewClient(serverAddr, runnerID, version string) *Client {
serverAddr: serverAddr,
runnerID: runnerID,
version: version,
heartbeatInterval: 30 * time.Second,
heartbeatInterval: defaultHeartbeatInterval,
}
}
@@ -45,27 +48,31 @@ func (c *Client) SetHandler(handler *TaskHandler) {
// Connect 连接到 gRPC 服务器
func (c *Client) Connect(ctx context.Context) error {
log.Printf("正在连接 gRPC 服务器: %s", c.serverAddr)
slog.Info("正在连接 gRPC 服务器", "server", c.serverAddr)
conn, err := grpc.Dial(c.serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("连接失败: %w", err)
}
c.conn = conn
client := pb.NewRunnerHubClient(conn)
stream, err := client.Connect(ctx)
if err != nil {
conn.Close()
return fmt.Errorf("创建流失败: %w", err)
}
c.stream = stream
// 发送注册请求
c.mu.Lock()
c.conn = conn
c.stream = stream
c.connected = true
c.mu.Unlock()
if err := c.register(); err != nil {
return err
}
log.Println("gRPC 连接成功")
slog.Info("gRPC 连接已建立")
return nil
}
@@ -80,33 +87,41 @@ func (c *Client) register() error {
"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",
"TASK_TYPE_LOGIN": "true",
"TASK_TYPE_GET_EMPTY_CLASSROOM": "true",
},
},
},
}
return c.stream.Send(msg)
return c.sendMessage(msg)
}
// Run 运行主循环,接收消息
func (c *Client) Run(ctx context.Context) error {
defer c.markDisconnected()
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
msg, err := c.stream.Recv()
if err == io.EOF {
log.Println("服务器关闭了连接")
return nil
}
if err != nil {
return fmt.Errorf("接收消息失败: %w", err)
}
c.handleMessage(msg)
}
stream := c.getStream()
if stream == nil {
return fmt.Errorf("流未连接")
}
msg, err := stream.Recv()
if err == io.EOF {
slog.Info("服务器关闭了连接")
return nil
}
if err != nil {
return fmt.Errorf("接收消息失败: %w", err)
}
c.handleMessage(msg)
}
}
@@ -114,63 +129,52 @@ func (c *Client) Run(ctx context.Context) error {
func (c *Client) handleMessage(msg *pb.StreamMessage) {
switch m := msg.Message.(type) {
case *pb.StreamMessage_RegisterResponse:
log.Printf("注册成功: %s, 会话ID: %s, 心跳间隔: %d秒",
m.RegisterResponse.Message,
m.RegisterResponse.SessionId,
m.RegisterResponse.HeartbeatInterval)
slog.Info("注册成功",
"message", m.RegisterResponse.Message,
"session_id", m.RegisterResponse.SessionId,
"heartbeat_interval_sec", m.RegisterResponse.HeartbeatInterval,
)
if m.RegisterResponse.HeartbeatInterval > 0 {
c.heartbeatInterval = time.Duration(m.RegisterResponse.HeartbeatInterval) * time.Second
}
case *pb.StreamMessage_HeartbeatAck:
// 心跳确认,可以用于计算延迟
log.Printf("心跳确认,延迟: %dms", m.HeartbeatAck.Latency)
slog.Debug("心跳确认", "latency_ms", m.HeartbeatAck.Latency)
case *pb.StreamMessage_Task:
log.Printf("收到任务: %s, 类型: %v", m.Task.TaskId, m.Task.Type)
slog.Info("收到任务", "task_id", m.Task.TaskId, "type", m.Task.Type.String())
go c.handleTask(m.Task)
case *pb.StreamMessage_TaskCancel:
log.Printf("收到取消任务请求: %s, 原因: %s", m.TaskCancel.TaskId, m.TaskCancel.Reason)
slog.Info("收到取消任务请求", "task_id", m.TaskCancel.TaskId, "reason", m.TaskCancel.Reason)
if c.handler != nil {
c.handler.CancelTask(m.TaskCancel.TaskId)
}
default:
log.Printf("收到未知消息类型: %T", msg.Message)
slog.Warn("收到未知消息类型", "type", fmt.Sprintf("%T", msg.Message))
}
}
// handleTask 处理任务
func (c *Client) handleTask(task *pb.Task) {
if c.handler == nil {
log.Println("错误: 未设置任务处理器")
slog.Error("未设置任务处理器")
return
}
result := c.handler.Execute(task)
c.mu.Lock()
err := c.stream.Send(&pb.StreamMessage{
Message: &pb.StreamMessage_Result{
Result: result,
},
err := c.sendMessage(&pb.StreamMessage{
Message: &pb.StreamMessage_Result{Result: result},
})
c.mu.Unlock()
if err != nil {
log.Printf("发送任务结果失败: %v", err)
} else {
log.Printf("任务结果已发送: %s, 状态: %v", result.TaskId, result.Status)
slog.Error("发送任务结果失败", "task_id", result.TaskId, "error", err)
return
}
slog.Info("任务结果已发送", "task_id", result.TaskId, "status", result.Status.String())
}
// sendHeartbeat 发送心跳
func (c *Client) sendHeartbeat() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.stream == nil {
return fmt.Errorf("流未连接")
}
return c.stream.Send(&pb.StreamMessage{
return c.sendMessage(&pb.StreamMessage{
Message: &pb.StreamMessage_Heartbeat{
Heartbeat: &pb.Heartbeat{
Timestamp: time.Now().UnixMilli(),
@@ -190,7 +194,7 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) {
return
case <-ticker.C:
if err := c.sendHeartbeat(); err != nil {
log.Printf("发送心跳失败: %v", err)
slog.Warn("发送心跳失败", "error", err)
}
}
}
@@ -198,11 +202,43 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) {
// Close 关闭连接
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
c.connected = false
if c.stream != nil {
c.stream.CloseSend()
_ = c.stream.CloseSend()
}
if c.conn != nil {
return c.conn.Close()
err := c.conn.Close()
c.conn = nil
c.stream = nil
return err
}
return nil
}
// sendMessage 加锁发送消息,确保 stream 仍然可用
func (c *Client) sendMessage(msg *pb.StreamMessage) error {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected || c.stream == nil {
return fmt.Errorf("流未连接")
}
return c.stream.Send(msg)
}
// getStream 读锁获取 stream
func (c *Client) getStream() pb.RunnerHub_ConnectClient {
c.mu.RLock()
defer c.mu.RUnlock()
return c.stream
}
// markDisconnected 标记连接已断开,避免后续发送
func (c *Client) markDisconnected() {
c.mu.Lock()
c.connected = false
c.mu.Unlock()
}

91
grpc/convert.go Normal file
View File

@@ -0,0 +1,91 @@
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,24 +2,13 @@ package grpc
import (
"context"
"encoding/json"
"errors"
"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 {
@@ -46,40 +35,47 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
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
}
ctx := context.Background()
data, err := h.dispatch(ctx, task)
if err != nil {
if errors.Is(err, errUnknownTaskType) {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
log.Warn("未知的任务类型")
return result
}
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
}
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
log.Info("任务执行成功")
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)
}
}
func (h *TaskHandler) CancelTask(taskID string) {
h.cancelledTasks.Store(taskID, true)
slog.Info("任务已标记为取消", "task_id", taskID)
@@ -89,351 +85,3 @@ 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
}

51
grpc/handler_classroom.go Normal file
View File

@@ -0,0 +1,51 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetEmptyClassroomPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, 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)
}

51
grpc/handler_exams.go Normal file
View File

@@ -0,0 +1,51 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetExamsPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, 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)
}

66
grpc/handler_grades.go Normal file
View File

@@ -0,0 +1,66 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetGradesPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, 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)
}

108
grpc/handler_helpers.go Normal file
View File

@@ -0,0 +1,108 @@
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
}

39
grpc/handler_login.go Normal file
View File

@@ -0,0 +1,39 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
)
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.LoginPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, 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": "登录成功",
})
}

43
grpc/handler_schedule.go Normal file
View File

@@ -0,0 +1,43 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
)
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetSchedulePayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, 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)
}