refactor: introduce service layer and migrate gRPC handlers to use it
All checks were successful
Build / build (push) Successful in 2m17s
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:
77
service/classroom.go
Normal file
77
service/classroom.go
Normal 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
79
service/exams.go
Normal 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
84
service/grades.go
Normal 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
11
service/login.go
Normal 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
75
service/schedule.go
Normal 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
|
||||
}
|
||||
101
service/service.go
Normal file
101
service/service.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"schedule_converter/auth"
|
||||
"schedule_converter/captcha"
|
||||
"schedule_converter/client"
|
||||
"schedule_converter/config"
|
||||
apperr "schedule_converter/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
maxLoginRetries = 3
|
||||
loginRetryInterval = 3 * time.Second
|
||||
)
|
||||
|
||||
// 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 失败")
|
||||
}
|
||||
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 (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)
|
||||
return
|
||||
}
|
||||
httpClient.Jar.SetCookies(u, []*http.Cookie{
|
||||
{Name: "TWFID", Value: twfid},
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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(ctx, 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
|
||||
}
|
||||
Reference in New Issue
Block a user