refactor(grpc): restructure task handling and migrate to slog
All checks were successful
Build / build (push) Successful in 5m28s
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:
146
grpc/client.go
146
grpc/client.go
@@ -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
91
grpc/convert.go
Normal 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
|
||||
}
|
||||
416
grpc/handler.go
416
grpc/handler.go
@@ -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
51
grpc/handler_classroom.go
Normal 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
51
grpc/handler_exams.go
Normal 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
66
grpc/handler_grades.go
Normal 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
108
grpc/handler_helpers.go
Normal 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
39
grpc/handler_login.go
Normal 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
43
grpc/handler_schedule.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user