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

@@ -1,6 +1,7 @@
package auth
import (
"context"
"fmt"
"io"
"log/slog"
@@ -14,13 +15,13 @@ import (
var nameRegexp = regexp.MustCompile(`(\w+)同学`)
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录
func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) {
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录
func LoginWithClient(ctx context.Context, httpClient *http.Client, captchaCode, username, password string) (bool, error) {
log := slog.With("username", username)
log.Debug("执行登录")
data := fmt.Sprintf("usercode=%s&password=%s&code=%s", username, password, captchaCode)
req, err := http.NewRequest("POST", config.Get().BaseURL+"/login", strings.NewReader(data))
req, err := http.NewRequestWithContext(ctx, "POST", config.Get().BaseURL+"/login", strings.NewReader(data))
if err != nil {
return false, err
}
@@ -54,4 +55,4 @@ func LoginWithClient(httpClient *http.Client, captchaCode, username, password st
log.Warn("登录失败")
}
return false, nil
}
}

View File

@@ -1,8 +1,7 @@
package parser
package client
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strings"
@@ -11,7 +10,8 @@ import (
"golang.org/x/text/transform"
)
func decodeResponseBody(resp *http.Response) (string, error) {
// DecodeResponseBody 读取 HTTP 响应体并按 Content-Type 进行字符集解码(处理 GBK
func DecodeResponseBody(resp *http.Response) (string, error) {
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
@@ -73,11 +73,3 @@ func isValidUTF8(data []byte) bool {
}
return true
}
func decodeJSONResponse(resp *http.Response, v any) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return json.NewDecoder(bytes.NewReader(body)).Decode(v)
}

47
client/fetch.go Normal file
View File

@@ -0,0 +1,47 @@
package client
import (
"context"
"fmt"
"io"
"net/http"
"schedule_converter/config"
)
// FetchHTML 发送 HTTP 请求并返回解码后的 HTML 字符串(处理 GBK 字符集)。
// method 为 "GET" 或 "POST"path 拼接到 config.BaseURL 之后body 为请求体GET 传 nil
// referer 为可选的 Referer 路径(拼接到 BaseURL 之后),不需要时传 ""。
func FetchHTML(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (string, error) {
resp, err := doRequest(ctx, httpClient, method, path, body, contentType, referer)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("请求 %s 失败, 状态码: %d", path, resp.StatusCode)
}
return DecodeResponseBody(resp)
}
// doRequest 构造并执行请求,设置 header可选 Referer透传 ctx。
func doRequest(ctx context.Context, httpClient *http.Client, method, path string, body io.Reader, contentType, referer string) (*http.Response, error) {
url := config.Get().BaseURL + path
req, err := http.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return nil, err
}
if contentType != "" {
req.Header = GetHeadersWithContentType(contentType)
} else {
req.Header = GetHeaders()
}
if referer != "" {
req.Header.Set("Referer", config.Get().BaseURL+referer)
}
return httpClient.Do(req)
}

View File

@@ -3,17 +3,14 @@ package config
import (
"log/slog"
"os"
"strconv"
"sync"
"time"
)
type Config struct {
BaseURL string
Username string
Password string
Semester string
TWFID string
BaseURL string
Semester string
TWFID string
Recognizer RecognizerConfig
}
@@ -33,13 +30,11 @@ func Load() *Config {
cfgOnce.Do(func() {
cfg = &Config{
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
Username: getEnv("JWTS_USERNAME", "2024212193"),
Password: getEnv("JWTS_PASSWORD", "wyb12580963"),
Semester: getEnv("JWTS_SEMESTER", "2025-20262"),
Semester: getEnv("JWTS_SEMESTER", ""),
TWFID: getEnv("JWTS_TWFID", ""),
Recognizer: RecognizerConfig{
APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"),
APIKey: getEnv("RECOGNIZER_API_KEY", "sk-6064466699a996b6f83e1ae8247c1cb4eeb4b72ce23cb001204335a2567d63c1"),
APIKey: getEnv("RECOGNIZER_API_KEY", ""),
Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"),
Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second),
},
@@ -67,17 +62,6 @@ func getEnv(key, defaultValue string) string {
return val
}
func getEnvInt(key string, defaultValue int) int {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
if i, err := strconv.Atoi(val); err == nil {
return i
}
return defaultValue
}
func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
val := os.Getenv(key)
if val == "" {
@@ -91,8 +75,6 @@ func getEnvDuration(key string, defaultValue time.Duration) time.Duration {
var (
BaseURL = Get().BaseURL
Username = Get().Username
Password = Get().Password
Semester = Get().Semester
TWFID = Get().TWFID
RecognizerAPIURL = Get().Recognizer.APIURL

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

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

10
models/empty_classroom.go Normal file
View File

@@ -0,0 +1,10 @@
package models
// EmptyClassroomEntry 空教室条目
type EmptyClassroomEntry struct {
Classroom string `json:"classroom"`
Weekday string `json:"weekday"`
Periods string `json:"periods"`
Term string `json:"term"`
Weeks string `json:"weeks"`
}

20
models/exam.go Normal file
View File

@@ -0,0 +1,20 @@
package models
// ExamType 考试类型
type ExamType string
const (
ExamTypeFinal ExamType = "final"
ExamTypeMidterm ExamType = "midterm"
ExamTypeMakeup ExamType = "makeup"
)
// ExamEntry 考试条目
type ExamEntry struct {
Course string
Date string
Time string
Week string
Weekday string
Type ExamType
}

32
models/grade.go Normal file
View File

@@ -0,0 +1,32 @@
package models
// ScoreType 成绩类型
type ScoreType string
const (
ScoreTypeFinal ScoreType = "final"
ScoreTypeMidterm ScoreType = "midterm"
)
// GradeEntry 成绩条目
type GradeEntry struct {
Term string
Code string
Name string
Nature string
Category string
Credit string
Score string
GradePoint string
IsExam string
}
// GPASummary 学分绩概要
type GPASummary struct {
AverageGPA string
Rank string
ExamTotalGPA string
ExamTotalCredits string
FailCredits string
Semesters string
}

View File

@@ -2,82 +2,15 @@ package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
)
type EmptyClassroomEntry struct {
Classroom string `json:"classroom"`
Weekday string `json:"weekday"`
Periods string `json:"periods"`
Term string `json:"term"`
Weeks string `json:"weeks"`
}
type buildingInfo struct {
DM string `json:"dm"`
MC string `json:"mc"`
}
type venueInfo struct {
DM string `json:"dm"`
MC string `json:"mc"`
}
func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd, campusCode string) ([]EmptyClassroomEntry, error) {
url := config.Get().BaseURL + "/kjscx/queryKjs"
log := slog.With("semester", semester, "week_start", weekStart, "week_end", weekEnd)
log.Info("获取空教室数据")
// First GET to establish session
resp, err := httpClient.Get(url)
if err != nil {
return nil, fmt.Errorf("访问空教室页面失败: %w", err)
}
resp.Body.Close()
// POST with query parameters
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
semester, weekStart, weekEnd, campusCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", url)
resp, err = httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("查询空教室失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("查询空教室失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
if err != nil {
return nil, err
}
log.Info("空教室数据获取完成", "count", len(entries))
return entries, nil
}
func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
// ParseEmptyClassroomHTML 解析空教室查询结果 HTML返回空教室条目列表。
func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
if html == "" {
return nil, nil
}
@@ -94,7 +27,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
dayNames := [7]string{"星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"}
periodLabels := [6]string{"1,2", "3,4", "5,6", "7,8", "9,10", "11,12"}
var classrooms []EmptyClassroomEntry
var classrooms []models.EmptyClassroomEntry
// Find the main data table with class="dataTable"
doc.Find("table.dataTable").Each(func(_ int, table *goquery.Selection) {
@@ -201,7 +134,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
if weekStart != "" && weekEnd != "" {
weekStr = fmt.Sprintf("%s-%s周", weekStart, weekEnd)
}
entry := EmptyClassroomEntry{
entry := models.EmptyClassroomEntry{
Classroom: classroomName,
Weekday: strings.Join(weekdays, ", "),
Periods: strings.Join(slots, "\n"),
@@ -216,69 +149,7 @@ func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
return classrooms, nil
}
func fetchBuildings(httpClient *http.Client, campusCode string) ([]buildingInfo, error) {
url := config.Get().BaseURL + "/kjscx/queryJxlListBySjid"
slog.Info("获取楼号列表", "campus", campusCode)
data := fmt.Sprintf("id=%s", campusCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取楼号列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode)
}
var buildings []buildingInfo
if err := decodeJSONResponse(resp, &buildings); err != nil {
return nil, fmt.Errorf("解析楼号列表失败: %w", err)
}
return buildings, nil
}
func fetchVenues(httpClient *http.Client, buildingCode string) ([]venueInfo, error) {
url := config.Get().BaseURL + "/kjscx/queryJxcdListBySjid"
slog.Info("获取场地列表", "building", buildingCode)
data := fmt.Sprintf("id=%s", buildingCode)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("获取场地列表失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode)
}
var venues []venueInfo
if err := decodeJSONResponse(resp, &venues); err != nil {
return nil, fmt.Errorf("解析场地列表失败: %w", err)
}
return venues, nil
}
// TestParseEmptyClassroomHTML is a test helper function that exports the parser for testing
func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
return parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
func TestParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]models.EmptyClassroomEntry, error) {
return ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
}

View File

@@ -1,107 +1,38 @@
package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
)
type ExamType string
const (
ExamTypeFinal ExamType = "final"
ExamTypeMidterm ExamType = "midterm"
ExamTypeMakeup ExamType = "makeup"
)
type ExamEntry struct {
Course string
Date string
Time string
Week string
Weekday string
Type ExamType
}
func examTypeToCode(t ExamType) string {
// examTypeToCode 将考试类型映射为教务系统的请求码。
func examTypeToCode(t models.ExamType) string {
switch t {
case ExamTypeMidterm:
case models.ExamTypeMidterm:
return "02"
case ExamTypeMakeup:
case models.ExamTypeMakeup:
return "03"
default:
return "01"
}
}
func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) {
examURL := config.Get().BaseURL + "/kscx/queryKsForXs"
log := slog.With("exam_type", string(examType))
log.Info("获取考试时间数据")
data := fmt.Sprintf("xnxq=&kssjd=%s", examTypeToCode(examType))
req, err := http.NewRequest("POST", examURL, strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取考试数据失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
exams, err := parseExamTimeHTML(html, examType)
if err != nil {
return nil, err
}
log.Info("考试数据获取完成", "exam_count", len(exams))
return exams, nil
// ExamTypeCode 返回考试类型对应的请求码(供 client 层构造请求使用)。
func ExamTypeCode(t models.ExamType) string {
return examTypeToCode(t)
}
func FetchAllExams(httpClient *http.Client) ([]ExamEntry, error) {
var allExams []ExamEntry
var lastErr error
for _, et := range []ExamType{ExamTypeFinal, ExamTypeMidterm, ExamTypeMakeup} {
exams, err := FetchExams(httpClient, et)
if err != nil {
slog.Warn("获取考试数据失败", "exam_type", string(et), "error", err)
lastErr = err
continue
}
allExams = append(allExams, exams...)
}
if len(allExams) == 0 && lastErr != nil {
return nil, fmt.Errorf("所有考试类型获取均失败: %w", lastErr)
}
return allExams, nil
}
func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
// ParseExamHTML 解析考试安排页面 HTML返回考试条目列表。
func ParseExamHTML(html string, examType models.ExamType) ([]models.ExamEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err
}
var exams []ExamEntry
var exams []models.ExamEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr")
@@ -120,7 +51,7 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
texts := getCellTexts(cells)
exam := ExamEntry{
exam := models.ExamEntry{
Course: safeGet(texts, 1),
Date: safeGet(texts, 2),
Time: safeGet(texts, 3),
@@ -136,4 +67,4 @@ func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
})
return exams, nil
}
}

View File

@@ -1,16 +1,11 @@
package parser
import (
"fmt"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
@@ -23,32 +18,6 @@ type weekRoomEntry struct {
room string
}
// 获取课表
// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表
func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) {
// POST请求获取课表
data := fmt.Sprintf("xnxq=%s", semester)
req, err := http.NewRequest("POST", config.Get().BaseURL+"/kbcx/queryGrkb", strings.NewReader(data))
if err != nil {
return nil, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return ParseScheduleHTML(string(body))
}
// 解析课表HTML
func ParseScheduleHTML(html string) ([]models.Course, error) {
// 创建一个带Reader的Document

View File

@@ -1,128 +1,21 @@
package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
)
type ScoreType string
const (
ScoreTypeFinal ScoreType = "final"
ScoreTypeMidterm ScoreType = "midterm"
)
type GradeEntry struct {
Term string
Code string
Name string
Nature string
Category string
Credit string
Score string
GradePoint string
IsExam string
}
type GPASummary struct {
AverageGPA string
Rank string
ExamTotalGPA string
ExamTotalCredits string
FailCredits string
Semesters string
}
func FetchGrades(httpClient *http.Client, examType ScoreType) ([]GradeEntry, error) {
var gradeURL string
switch examType {
case ScoreTypeMidterm:
gradeURL = config.Get().BaseURL + "/cjcx/queryQzcj"
default:
gradeURL = config.Get().BaseURL + "/cjcx/queryQmcj"
}
log := slog.With("exam_type", string(examType))
log.Info("获取成绩数据")
req, err := http.NewRequest("GET", gradeURL, nil)
if err != nil {
return nil, err
}
req.Header = client.GetHeaders()
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取成绩失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, err
}
grades, err := parseGradesHTML(html)
if err != nil {
return nil, err
}
log.Info("成绩获取完成", "grade_count", len(grades))
return grades, nil
}
func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) {
gpaURL := config.Get().BaseURL + "/xfj/queryListXfj"
slog.Info("获取学分绩数据")
req, err := http.NewRequest("GET", gpaURL, nil)
if err != nil {
return nil, nil, err
}
req.Header = client.GetHeaders()
resp, err := httpClient.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("获取学分绩失败, 状态码: %d", resp.StatusCode)
}
html, err := decodeResponseBody(resp)
if err != nil {
return nil, nil, err
}
summary, grades, err := parseGPAHTML(html)
if err != nil {
return nil, nil, err
}
slog.Info("学分绩获取完成", "grade_count", len(grades))
return summary, grades, nil
}
func parseGradesHTML(html string) ([]GradeEntry, error) {
// ParseGradesHTML 解析成绩页面 HTML返回成绩条目列表。
func ParseGradesHTML(html string) ([]models.GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err
}
var grades []GradeEntry
var grades []models.GradeEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr")
@@ -146,7 +39,7 @@ func parseGradesHTML(html string) ([]GradeEntry, error) {
isMidterm := len(texts) == 10
entry := GradeEntry{
entry := models.GradeEntry{
Term: safeGet(texts, 1),
Code: safeGet(texts, 3),
Name: safeGet(texts, 4),
@@ -183,13 +76,14 @@ func parseGradesHTML(html string) ([]GradeEntry, error) {
return grades, nil
}
func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
// ParseGPAHTML 解析学分绩页面 HTML返回 GPA 概要与成绩条目列表。
func ParseGPAHTML(html string) (*models.GPASummary, []models.GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, nil, err
}
summary := &GPASummary{}
summary := &models.GPASummary{}
doc.Find("span").Each(func(_ int, span *goquery.Selection) {
id, _ := span.Attr("id")
@@ -210,7 +104,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
}
})
var grades []GradeEntry
var grades []models.GradeEntry
doc.Find("table.bot_line").Each(func(_ int, table *goquery.Selection) {
rows := table.Find("tr")
@@ -229,7 +123,7 @@ func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
texts := getCellTexts(cells)
entry := GradeEntry{
entry := models.GradeEntry{
Term: safeGet(texts, 0),
Code: safeGet(texts, 1),
Name: safeGet(texts, 2),

View File

@@ -7,25 +7,26 @@ import (
var ErrLoginFailed = errors.New("login failed")
type ScheduleError struct {
// AppError 携带操作名Op与中文提示Msg的错误类型用于统一日志与错误上报。
type AppError struct {
Op string
Err error
Msg string
}
func (e *ScheduleError) Error() string {
func (e *AppError) Error() string {
if e.Msg != "" {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err)
}
return fmt.Sprintf("%s: %v", e.Op, e.Err)
}
func (e *ScheduleError) Unwrap() error {
func (e *AppError) Unwrap() error {
return e.Err
}
func New(op string, err error, msg string) error {
return &ScheduleError{
return &AppError{
Op: op,
Err: err,
Msg: msg,

77
service/classroom.go Normal file
View File

@@ -0,0 +1,77 @@
package service
import (
"context"
"fmt"
"log/slog"
"net/http"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetEmptyClassrooms 获取空教室:登录 → GET 建立会话 → POST 查询 → 解析 → 转换为 pb 结果。
func (s *Service) GetEmptyClassrooms(ctx context.Context, req *pb.GetEmptyClassroomPayload) (*pb.EmptyClassroomResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
kjsURL := config.Get().BaseURL + "/kjscx/queryKjs"
// 先 GET 建立会话(沿用原有行为:直接用 client.Get仅取 cookie丢弃 body
sessionReq, err := http.NewRequestWithContext(ctx, "GET", kjsURL, nil)
if err != nil {
return nil, apperr.New("session_classroom", err, "建立空教室会话失败")
}
sessionReq.Header = client.GetHeaders()
sessionResp, err := httpClient.Do(sessionReq)
if err != nil {
return nil, apperr.New("session_classroom", err, "访问空教室页面失败")
}
sessionResp.Body.Close()
// POST 查询参数。
data := fmt.Sprintf("pageXnxq=%s&pageZc1=%s&pageZc2=%s&pageXiaoqu=%s",
req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kjscx/queryKjs", strings.NewReader(data), "application/x-www-form-urlencoded", "/kjscx/queryKjs")
if err != nil {
return nil, apperr.New("fetch_empty_classrooms", err, "查询空教室失败")
}
entries, err := parser.ParseEmptyClassroomHTML(html, req.Semester, req.WeekStart, req.WeekEnd)
if err != nil {
return nil, apperr.New("parse_empty_classrooms", err, "解析空教室失败")
}
log.Info("空教室获取成功", "classroom_count", len(entries))
return &pb.EmptyClassroomResultData{
StudentId: req.Username,
Semester: req.Semester,
Classrooms: convertEmptyClassrooms(entries),
}, nil
}
// convertEmptyClassrooms 将内部空教室模型转换为 pb.EmptyClassroomEntry 列表。
func convertEmptyClassrooms(entries []models.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
}

79
service/exams.go Normal file
View File

@@ -0,0 +1,79 @@
package service
import (
"context"
"fmt"
"log/slog"
"strings"
"schedule_converter/client"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
var allExamTypes = []models.ExamType{models.ExamTypeFinal, models.ExamTypeMidterm, models.ExamTypeMakeup}
// GetExams 获取考试安排:登录 → 遍历各考试类型抓取并解析 → 合并 → 转换为 pb 结果。
func (s *Service) GetExams(ctx context.Context, req *pb.GetExamsPayload) (*pb.ExamsResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
var allExams []models.ExamEntry
var lastErr error
for _, et := range allExamTypes {
data := fmt.Sprintf("xnxq=&kssjd=%s", parser.ExamTypeCode(et))
html, ferr := client.FetchHTML(ctx, httpClient, "POST", "/kscx/queryKsForXs", strings.NewReader(data), "application/x-www-form-urlencoded", "")
if ferr != nil {
log.Warn("获取考试数据失败", "exam_type", string(et), "error", ferr)
lastErr = ferr
continue
}
exams, perr := parser.ParseExamHTML(html, et)
if perr != nil {
log.Warn("解析考试数据失败", "exam_type", string(et), "error", perr)
lastErr = perr
continue
}
allExams = append(allExams, exams...)
}
if len(allExams) == 0 && lastErr != nil {
return nil, apperr.New("fetch_exams", lastErr, "所有考试类型获取均失败")
}
return &pb.ExamsResultData{
StudentId: req.Username,
Semester: req.Semester,
Exams: convertExams(allExams),
}, nil
}
// convertExams 将内部考试模型转换为 pb.Exam 列表。
func convertExams(exams []models.ExamEntry) []*pb.Exam {
result := make([]*pb.Exam, 0, len(exams))
for _, e := range exams {
examTypeStr := "期末"
switch e.Type {
case models.ExamTypeMidterm:
examTypeStr = "期中"
case models.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
}

84
service/grades.go Normal file
View File

@@ -0,0 +1,84 @@
package service
import (
"context"
"log/slog"
"schedule_converter/client"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetGrades 获取成绩:登录 → 优先 GPA 页面 → 回退到成绩页面 → 解析 → 转换为 pb 结果。
func (s *Service) GetGrades(ctx context.Context, req *pb.GetGradesPayload) (*pb.GradesResultData, error) {
log := slog.With("username", req.Username, "semester", req.Semester)
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
var gpaSummary *pb.GpaSummary
gpaHTML, err := client.FetchHTML(ctx, httpClient, "GET", "/xfj/queryListXfj", nil, "", "")
if err != nil {
log.Warn("获取GPA页面失败尝试成绩页面", "error", err)
gradesHTML, ferr := client.FetchHTML(ctx, httpClient, "GET", "/cjcx/queryQmcj", nil, "", "")
if ferr != nil {
return nil, apperr.New("fetch_grades", ferr, "获取成绩失败")
}
grades, perr := parser.ParseGradesHTML(gradesHTML)
if perr != nil {
return nil, apperr.New("parse_grades", perr, "解析成绩失败")
}
return &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: convertGrades(grades),
}, nil
}
summary, grades, err := parser.ParseGPAHTML(gpaHTML)
if err != nil {
return nil, apperr.New("parse_gpa", err, "解析学分绩失败")
}
if summary != nil {
gpaSummary = &pb.GpaSummary{
AverageGpa: summary.AverageGPA,
Rank: summary.Rank,
ExamTotalGpa: summary.ExamTotalGPA,
ExamTotalCredits: summary.ExamTotalCredits,
FailCredits: summary.FailCredits,
Semesters: summary.Semesters,
}
}
return &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: convertGrades(grades),
GpaSummary: gpaSummary,
}, nil
}
// convertGrades 将内部成绩模型转换为 pb.Grade 列表。
func convertGrades(grades []models.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
}

11
service/login.go Normal file
View File

@@ -0,0 +1,11 @@
package service
import (
"context"
"net/http"
)
// Login 仅执行登录流程,返回已认证的 HTTP 客户端(调用方可继续用它抓取数据)。
func (s *Service) Login(ctx context.Context, username, password, twfid string) (*http.Client, error) {
return s.newAuthenticatedClient(ctx, username, password, twfid)
}

75
service/schedule.go Normal file
View File

@@ -0,0 +1,75 @@
package service
import (
"context"
"fmt"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
pb "schedule_converter/proto/runner"
)
// GetSchedule 获取课表:登录 → POST 查询 → 解析 → 转换为 pb 结果。
func (s *Service) GetSchedule(ctx context.Context, req *pb.GetSchedulePayload) (*pb.ScheduleResultData, error) {
httpClient, err := s.newAuthenticatedClient(ctx, req.Username, req.Password, "")
if err != nil {
return nil, err
}
semester := req.Semester
if semester == "" {
semester = config.Get().Semester
}
data := fmt.Sprintf("xnxq=%s", semester)
html, err := client.FetchHTML(ctx, httpClient, "POST", "/kbcx/queryGrkb", strings.NewReader(data), "application/x-www-form-urlencoded", "")
if err != nil {
return nil, apperr.New("fetch_schedule", err, "获取课表失败")
}
courses, err := parser.ParseScheduleHTML(html)
if err != nil {
return nil, apperr.New("parse_schedule", err, "解析课表失败")
}
return &pb.ScheduleResultData{
StudentId: req.Username,
Semester: semester,
Courses: convertCourses(courses),
}, nil
}
// convertCourses 将内部课程模型转换为 pb.Course 列表。
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, sc := range c.Schedule {
schedule[j] = &pb.ScheduleTime{
Day: sc.Day,
Sections: toInt32Slice(sc.Section),
Weeks: toInt32Slice(sc.Weeks),
}
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
}
}
return result
}
func toInt32Slice(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {
result[i] = int32(v)
}
return result
}

View File

@@ -1,8 +1,7 @@
package grpc
package service
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/cookiejar"
@@ -13,8 +12,6 @@ import (
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
@@ -23,19 +20,34 @@ const (
loginRetryInterval = 3 * time.Second
)
func createHTTPClient() (*http.Client, error) {
// Service 封装教务系统的登录与数据抓取编排逻辑。
type Service struct{}
// NewService 创建 service。
func NewService() *Service {
return &Service{}
}
// newAuthenticatedClient 创建带 cookie jar 的 HTTP 客户端,设置 TWFID cookie 并完成登录。
func (s *Service) newAuthenticatedClient(ctx context.Context, username, password, twfid string) (*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
httpClient := &http.Client{Jar: jar}
if twfid == "" {
twfid = config.Get().TWFID
}
s.setTWFIDCookie(httpClient, config.Get().BaseURL, twfid)
if err := s.login(ctx, httpClient, username, password); err != nil {
return nil, err
}
return httpClient, nil
}
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = cfg.TWFID
}
func (s *Service) setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
u, err := url.Parse(baseURL)
if err != nil {
slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err)
@@ -46,7 +58,8 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
})
}
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error {
// login 执行登录流程,包含验证码识别与重试。
func (s *Service) login(ctx context.Context, httpClient *http.Client, username, password string) error {
log := slog.With("username", username)
if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
@@ -69,7 +82,7 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
success, err := auth.LoginWithClient(ctx, httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
@@ -86,23 +99,3 @@ func executeLogin(ctx context.Context, httpClient *http.Client, username, passwo
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
}