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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
47
client/fetch.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("登录成功")
|
||||
|
||||
@@ -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
10
models/empty_classroom.go
Normal 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
20
models/exam.go
Normal 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
32
models/grade.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
126
parser/score.go
126
parser/score.go
@@ -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),
|
||||
|
||||
@@ -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
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user