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

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

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

View File

@@ -3,8 +3,8 @@ package auth
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
@@ -12,17 +12,15 @@ import (
"schedule_converter/config"
)
// 登录
func Login(captchaCode string) (bool, error) {
return LoginWithClient(client.Client, captchaCode, config.Username, config.Password)
}
var nameRegexp = regexp.MustCompile(`(\w+)同学`)
// LoginWithClient 使用指定的 HTTP 客户端和凭据登录
func LoginWithClient(httpClient *http.Client, captchaCode, username, password string) (bool, error) {
fmt.Println("[4] 执行登录...")
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.BaseURL+"/login", strings.NewReader(data))
req, err := http.NewRequest("POST", config.Get().BaseURL+"/login", strings.NewReader(data))
if err != nil {
return false, err
}
@@ -40,29 +38,20 @@ func LoginWithClient(httpClient *http.Client, captchaCode, username, password st
return false, err
}
if client.CheckLoginSuccess(string(body)) {
fmt.Println(" ✓ 登录成功!")
// 提取用户名
nameMatch := regexp.MustCompile(`(\w+)同学`).FindStringSubmatch(string(body))
if len(nameMatch) > 1 {
fmt.Printf(" 欢迎, %s!\n", nameMatch[1])
}
// 保存登录后的页面
if err := os.WriteFile("login_result.html", body, 0644); err != nil {
fmt.Printf(" 警告: 无法保存登录页面: %v\n", err)
bodyStr := string(body)
if client.CheckLoginSuccess(bodyStr) {
if match := nameRegexp.FindStringSubmatch(bodyStr); len(match) > 1 {
log.Info("登录成功", "name", match[1])
} else {
fmt.Println(" 登录后页面已保存到: login_result.html")
log.Info("登录成功")
}
return true, nil
}
fmt.Println(" ✗ 登录失败")
if client.IsCaptchaError(string(body)) {
fmt.Println(" 错误: 验证码错误")
if client.IsCaptchaError(bodyStr) {
log.Warn("登录失败:验证码错误")
} else {
log.Warn("登录失败")
}
return false, nil
}

View File

@@ -3,20 +3,15 @@ package captcha
import (
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"schedule_converter/client"
"schedule_converter/config"
"github.com/PuerkitoBio/goquery"
)
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
fmt.Println("[2] 获取验证码...")
req, err := http.NewRequest("GET", config.BaseURL+"/captchaImage", nil)
req, err := http.NewRequest("GET", config.Get().BaseURL+"/captchaImage", nil)
if err != nil {
return nil, err
}
@@ -28,25 +23,18 @@ func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取验证码失败, 状态码: %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if err := os.WriteFile("captcha.png", body, 0644); err != nil {
fmt.Printf(" 警告: 无法保存验证码图片: %v\n", err)
} else {
fmt.Println(" 验证码已保存到: captcha.png")
}
return body, nil
}
func Recognize(imagePath string) (string, error) {
fmt.Println("[3] 使用固定验证码...")
return "....", nil
}
func GetAndRecognize(httpClient *http.Client) (string, error) {
_, err := SaveCaptchaImage(httpClient)
if err != nil {
@@ -58,10 +46,10 @@ func GetAndRecognize(httpClient *http.Client) (string, error) {
return "", err
}
fmt.Printf(" 识别结果: %s\n", captchaCode)
slog.Debug("验证码识别完成", "result", captchaCode)
return captchaCode, nil
}
func ParseHTML(html string) (*goquery.Document, error) {
return goquery.NewDocumentFromReader(strings.NewReader(html))
func Recognize(imagePath string) (string, error) {
return "....", nil
}

View File

@@ -3,41 +3,16 @@ package client
import (
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"schedule_converter/config"
)
var (
Client *http.Client
RecognizerClient *http.Client
once sync.Once
)
func InitClient() {
once.Do(func() {
jar, err := cookiejar.New(nil)
if err != nil {
slog.Error("创建 cookie jar 失败", "error", err)
panic(err)
}
Client = &http.Client{Jar: jar}
cfg := config.Get()
RecognizerClient = &http.Client{
Timeout: cfg.Recognizer.Timeout,
}
slog.Info("HTTP 客户端初始化完成")
})
}
const defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
func GetHeaders() http.Header {
return http.Header{
"User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
"User-Agent": []string{defaultUserAgent},
"Accept": []string{"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"},
"Accept-Language": []string{"zh-CN,zh;q=0.9,en;q=0.8"},
}
@@ -49,15 +24,10 @@ func GetHeadersWithContentType(contentType string) http.Header {
return headers
}
func GetInitialCookies() error {
return GetInitialCookiesWithClient(Client)
}
func GetInitialCookiesWithClient(httpClient *http.Client) error {
slog.Info("访问登录页面获取初始Cookie")
cfg := config.Get()
req, err := http.NewRequest("GET", cfg.BaseURL+"/loginNOCAS", nil)
req, err := http.NewRequest("GET", config.Get().BaseURL+"/loginNOCAS", nil)
if err != nil {
return err
}
@@ -70,27 +40,9 @@ func GetInitialCookiesWithClient(httpClient *http.Client) error {
defer resp.Body.Close()
slog.Info("登录页面访问完成", "status_code", resp.StatusCode)
logCookies(httpClient)
return nil
}
func logCookies(httpClient *http.Client) {
cfg := config.Get()
cookies := httpClient.Jar.Cookies(parseURL(cfg.BaseURL))
var cookieStrs []string
for _, cookie := range cookies {
cookieStrs = append(cookieStrs, cookie.Name+"="+cookie.Value)
}
slog.Debug("当前Cookies", "cookies", strings.Join(cookieStrs, "; "))
}
func parseURL(rawURL string) *url.URL {
u, _ := url.Parse(rawURL)
return u
}
func CheckLoginSuccess(body string) bool {
return strings.Contains(body, "本科教学管理服务系统")
}

View File

@@ -1,11 +1,8 @@
package config
import (
"log/slog"
"os"
"strconv"
"sync"
"time"
)
type Config struct {
@@ -14,14 +11,6 @@ type Config struct {
Password string
Semester string
TWFID string
Recognizer RecognizerConfig
}
type RecognizerConfig struct {
APIURL string
APIKey string
Model string
Timeout time.Duration
}
var (
@@ -37,16 +26,6 @@ func Load() *Config {
Password: getEnv("JWTS_PASSWORD", ""),
Semester: getEnv("JWTS_SEMESTER", "2025-20262"),
TWFID: getEnv("JWTS_TWFID", ""),
Recognizer: RecognizerConfig{
APIURL: getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions"),
APIKey: getEnv("RECOGNIZER_API_KEY", ""),
Model: getEnv("RECOGNIZER_MODEL", "qwen3.5-plus"),
Timeout: getEnvDuration("RECOGNIZER_TIMEOUT", 120*time.Second),
},
}
if cfg.Recognizer.APIKey == "" {
slog.Warn("RECOGNIZER_API_KEY not set, image recognition will not work")
}
})
return cfg
@@ -66,36 +45,3 @@ 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 == "" {
return defaultValue
}
if d, err := time.ParseDuration(val); err == nil {
return d
}
return defaultValue
}
var (
BaseURL = Get().BaseURL
Username = Get().Username
Password = Get().Password
Semester = Get().Semester
TWFID = Get().TWFID
RecognizerAPIURL = Get().Recognizer.APIURL
RecognizerAPIKey = Get().Recognizer.APIKey
RecognizerModel = Get().Recognizer.Model
)

28
go.mod
View File

@@ -4,40 +4,14 @@ go 1.25.7
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/gin-gonic/gin v1.12.0
golang.org/x/text v0.34.0
google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.10
)
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect
)

79
go.sum
View File

@@ -2,86 +2,18 @@ github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
@@ -92,18 +24,12 @@ go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiy
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -133,7 +59,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -174,7 +99,3 @@ google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

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

91
grpc/convert.go Normal file
View File

@@ -0,0 +1,91 @@
package grpc
import (
pb "schedule_converter/proto/runner"
"schedule_converter/models"
"schedule_converter/parser"
)
func convertCourses(courses []models.Course) []*pb.Course {
result := make([]*pb.Course, len(courses))
for i, c := range courses {
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
for j, s := range c.Schedule {
schedule[j] = &pb.ScheduleTime{
Day: s.Day,
Sections: convertToInt32(s.Section),
Weeks: convertToInt32(s.Weeks),
}
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
}
}
return result
}
func convertToInt32(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {
result[i] = int32(v)
}
return result
}
func convertGrades(grades []parser.GradeEntry) []*pb.Grade {
result := make([]*pb.Grade, 0, len(grades))
for _, g := range grades {
result = append(result, &pb.Grade{
Term: g.Term,
CourseCode: g.Code,
CourseName: g.Name,
Nature: g.Nature,
Category: g.Category,
Credit: g.Credit,
Score: g.Score,
GradePoint: g.GradePoint,
IsExam: g.IsExam,
})
}
return result
}
func convertExams(exams []parser.ExamEntry) []*pb.Exam {
result := make([]*pb.Exam, 0, len(exams))
for _, e := range exams {
examTypeStr := "期末"
switch e.Type {
case parser.ExamTypeMidterm:
examTypeStr = "期中"
case parser.ExamTypeMakeup:
examTypeStr = "补考"
}
result = append(result, &pb.Exam{
CourseName: e.Course,
ExamType: examTypeStr,
Date: e.Date,
Time: e.Time,
Week: e.Week,
Weekday: e.Weekday,
})
}
return result
}
func convertEmptyClassrooms(entries []parser.EmptyClassroomEntry) []*pb.EmptyClassroomEntry {
result := make([]*pb.EmptyClassroomEntry, 0, len(entries))
for _, e := range entries {
result = append(result, &pb.EmptyClassroomEntry{
Classroom: e.Classroom,
Weekday: e.Weekday,
Periods: e.Periods,
Term: e.Term,
Weeks: e.Weeks,
})
}
return result
}

View File

@@ -2,24 +2,13 @@ package grpc
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"sync"
"time"
pb "schedule_converter/proto/runner"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
type TaskHandler struct {
@@ -46,40 +35,47 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
return result
}
var err error
var data []byte
ctx := context.Background()
data, err := h.dispatch(ctx, task)
switch task.Type {
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
data, err = h.getSchedule(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_GRADES:
data, err = h.getGrades(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_EXAMS:
data, err = h.getExams(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_LOGIN:
data, err = h.login(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
data, err = h.getEmptyClassrooms(context.Background(), task.Payload)
default:
if err != nil {
if errors.Is(err, errUnknownTaskType) {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type)
result.ErrorMessage = err.Error()
log.Warn("未知的任务类型")
return result
}
if err != nil {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
log.Error("任务执行失败", "error", err)
} else {
return result
}
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
log.Info("任务执行成功")
}
return result
}
var errUnknownTaskType = fmt.Errorf("unknown task type")
func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) {
switch task.Type {
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
return h.getSchedule(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_GRADES:
return h.getGrades(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_EXAMS:
return h.getExams(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_LOGIN:
return h.login(ctx, task.Payload)
case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM:
return h.getEmptyClassrooms(ctx, task.Payload)
default:
return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type)
}
}
func (h *TaskHandler) CancelTask(taskID string) {
h.cancelledTasks.Store(taskID, true)
slog.Info("任务已标记为取消", "task_id", taskID)
@@ -89,351 +85,3 @@ func (h *TaskHandler) IsCancelled(taskID string) bool {
v, ok := h.cancelledTasks.Load(taskID)
return ok && v.(bool)
}
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetSchedulePayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取课表任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester)
if err != nil {
return nil, err
}
result := &pb.ScheduleResultData{
StudentId: req.Username,
Semester: req.Semester,
Courses: convertCourses(courses),
}
log.Info("课表获取成功", "course_count", len(courses))
return json.Marshal(result)
}
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.LoginPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username)
log.Info("登录任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid)
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
log.Info("登录成功")
return json.Marshal(map[string]any{
"success": true,
"message": "登录成功",
})
}
func createHTTPClient() (*http.Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
}
return &http.Client{Jar: jar}, nil
}
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = cfg.TWFID
}
u, _ := url.Parse(baseURL)
httpClient.Jar.SetCookies(u, []*http.Cookie{
{Name: "TWFID", Value: twfid},
})
}
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error {
log := slog.With("username", username)
if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
return apperr.New("get_cookies", err, "获取初始 Cookie 失败")
}
cfg := config.Get()
_ = cfg.Recognizer.Timeout
for attempt := 1; attempt <= 3; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", 3)
captchaCode, err := captcha.GetAndRecognize(httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
time.Sleep(3 * time.Second)
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(3 * time.Second)
continue
}
if success {
return nil
}
log.Warn("登录验证失败", "attempt", attempt)
time.Sleep(3 * time.Second)
}
return apperr.ErrLoginFailed
}
func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) {
if err := executeLogin(ctx, httpClient, username, password); err != nil {
return nil, err
}
cfg := config.Get()
if semester == "" {
semester = cfg.Semester
}
return parser.FetchScheduleWithClient(httpClient, semester)
}
func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetGradesPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取成绩任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
var gpaSummary *pb.GpaSummary
summary, grades, err := parser.FetchGPA(httpClient)
if err != nil {
log.Warn("获取GPA页面失败尝试成绩页面", "error", err)
grades, err = parser.FetchGrades(httpClient, parser.ScoreTypeFinal)
if err != nil {
return nil, apperr.New("fetch_grades", err, "获取成绩失败")
}
} else if summary != nil {
gpaSummary = &pb.GpaSummary{
AverageGpa: summary.AverageGPA,
Rank: summary.Rank,
ExamTotalGpa: summary.ExamTotalGPA,
ExamTotalCredits: summary.ExamTotalCredits,
FailCredits: summary.FailCredits,
Semesters: summary.Semesters,
}
}
pbGrades := convertGrades(grades)
result := &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: pbGrades,
GpaSummary: gpaSummary,
}
log.Info("成绩获取成功", "grade_count", len(pbGrades))
return json.Marshal(result)
}
func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetExamsPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取考试安排任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
exams, err := parser.FetchAllExams(httpClient)
if err != nil {
return nil, apperr.New("fetch_exams", err, "获取考试安排失败")
}
pbExams := convertExams(exams)
result := &pb.ExamsResultData{
StudentId: req.Username,
Semester: req.Semester,
Exams: pbExams,
}
log.Info("考试安排获取成功", "exam_count", len(pbExams))
return json.Marshal(result)
}
func convertCourses(courses []models.Course) []*pb.Course {
result := make([]*pb.Course, len(courses))
for i, c := range courses {
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
for j, s := range c.Schedule {
schedule[j] = &pb.ScheduleTime{
Day: s.Day,
Sections: convertToInt32(s.Section),
Weeks: convertToInt32(s.Weeks),
}
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
}
}
return result
}
func convertToInt32(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {
result[i] = int32(v)
}
return result
}
func convertGrades(grades []parser.GradeEntry) []*pb.Grade {
result := make([]*pb.Grade, 0, len(grades))
for _, g := range grades {
result = append(result, &pb.Grade{
Term: g.Term,
CourseCode: g.Code,
CourseName: g.Name,
Nature: g.Nature,
Category: g.Category,
Credit: g.Credit,
Score: g.Score,
GradePoint: g.GradePoint,
IsExam: g.IsExam,
})
}
return result
}
func convertExams(exams []parser.ExamEntry) []*pb.Exam {
result := make([]*pb.Exam, 0, len(exams))
for _, e := range exams {
examTypeStr := "期末"
switch e.Type {
case parser.ExamTypeMidterm:
examTypeStr = "期中"
case parser.ExamTypeMakeup:
examTypeStr = "补考"
}
result = append(result, &pb.Exam{
CourseName: e.Course,
ExamType: examTypeStr,
Date: e.Date,
Time: e.Time,
Week: e.Week,
Weekday: e.Weekday,
})
}
return result
}
func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetEmptyClassroomPayload
if err := json.Unmarshal(payload, &req); err != nil {
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取空教室任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
classrooms, err := parser.FetchEmptyClassrooms(httpClient, req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
if err != nil {
return nil, apperr.New("fetch_empty_classrooms", err, "获取空教室失败")
}
pbClassrooms := convertEmptyClassrooms(classrooms)
result := &pb.EmptyClassroomResultData{
StudentId: req.Username,
Semester: req.Semester,
Classrooms: pbClassrooms,
}
log.Info("空教室获取成功", "classroom_count", len(pbClassrooms))
return json.Marshal(result)
}
func convertEmptyClassrooms(entries []parser.EmptyClassroomEntry) []*pb.EmptyClassroomEntry {
result := make([]*pb.EmptyClassroomEntry, 0, len(entries))
for _, e := range entries {
result = append(result, &pb.EmptyClassroomEntry{
Classroom: e.Classroom,
Weekday: e.Weekday,
Periods: e.Periods,
Term: e.Term,
Weeks: e.Weeks,
})
}
return result
}

51
grpc/handler_classroom.go Normal file
View File

@@ -0,0 +1,51 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetEmptyClassroomPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取空教室任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
classrooms, err := parser.FetchEmptyClassrooms(httpClient, req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode)
if err != nil {
return nil, apperr.New("fetch_empty_classrooms", err, "获取空教室失败")
}
pbClassrooms := convertEmptyClassrooms(classrooms)
result := &pb.EmptyClassroomResultData{
StudentId: req.Username,
Semester: req.Semester,
Classrooms: pbClassrooms,
}
log.Info("空教室获取成功", "classroom_count", len(pbClassrooms))
return json.Marshal(result)
}

51
grpc/handler_exams.go Normal file
View File

@@ -0,0 +1,51 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetExamsPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取考试安排任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
exams, err := parser.FetchAllExams(httpClient)
if err != nil {
return nil, apperr.New("fetch_exams", err, "获取考试安排失败")
}
pbExams := convertExams(exams)
result := &pb.ExamsResultData{
StudentId: req.Username,
Semester: req.Semester,
Exams: pbExams,
}
log.Info("考试安排获取成功", "exam_count", len(pbExams))
return json.Marshal(result)
}

66
grpc/handler_grades.go Normal file
View File

@@ -0,0 +1,66 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetGradesPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取成绩任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
var gpaSummary *pb.GpaSummary
summary, grades, err := parser.FetchGPA(httpClient)
if err != nil {
log.Warn("获取GPA页面失败尝试成绩页面", "error", err)
grades, err = parser.FetchGrades(httpClient, parser.ScoreTypeFinal)
if err != nil {
return nil, apperr.New("fetch_grades", err, "获取成绩失败")
}
} else if summary != nil {
gpaSummary = &pb.GpaSummary{
AverageGpa: summary.AverageGPA,
Rank: summary.Rank,
ExamTotalGpa: summary.ExamTotalGPA,
ExamTotalCredits: summary.ExamTotalCredits,
FailCredits: summary.FailCredits,
Semesters: summary.Semesters,
}
}
pbGrades := convertGrades(grades)
result := &pb.GradesResultData{
StudentId: req.Username,
Semester: req.Semester,
Grades: pbGrades,
GpaSummary: gpaSummary,
}
log.Info("成绩获取成功", "grade_count", len(pbGrades))
return json.Marshal(result)
}

108
grpc/handler_helpers.go Normal file
View File

@@ -0,0 +1,108 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"time"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
const (
maxLoginRetries = 3
loginRetryInterval = 3 * time.Second
)
func createHTTPClient() (*http.Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
}
return &http.Client{Jar: jar}, nil
}
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = cfg.TWFID
}
u, err := url.Parse(baseURL)
if err != nil {
slog.Warn("解析 baseURL 失败,无法设置 TWFID cookie", "base_url", baseURL, "error", err)
return
}
httpClient.Jar.SetCookies(u, []*http.Cookie{
{Name: "TWFID", Value: twfid},
})
}
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error {
log := slog.With("username", username)
if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
return apperr.New("get_cookies", err, "获取初始 Cookie 失败")
}
for attempt := 1; attempt <= maxLoginRetries; attempt++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", maxLoginRetries)
captchaCode, err := captcha.GetAndRecognize(httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
continue
}
success, err := auth.LoginWithClient(httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录请求失败", "error", err, "attempt", attempt)
time.Sleep(loginRetryInterval)
continue
}
if success {
return nil
}
log.Warn("登录验证失败", "attempt", attempt)
time.Sleep(loginRetryInterval)
}
return apperr.ErrLoginFailed
}
func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) {
if err := executeLogin(ctx, httpClient, username, password); err != nil {
return nil, err
}
cfg := config.Get()
if semester == "" {
semester = cfg.Semester
}
return parser.FetchScheduleWithClient(httpClient, semester)
}
func unmarshalPayload(payload []byte, v any) error {
if err := json.Unmarshal(payload, v); err != nil {
return apperr.New("parse_payload", err, "解析任务载荷失败")
}
return nil
}

39
grpc/handler_login.go Normal file
View File

@@ -0,0 +1,39 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
)
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.LoginPayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username)
log.Info("登录任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid)
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
log.Info("登录成功")
return json.Marshal(map[string]any{
"success": true,
"message": "登录成功",
})
}

43
grpc/handler_schedule.go Normal file
View File

@@ -0,0 +1,43 @@
package grpc
import (
"context"
"encoding/json"
"log/slog"
pb "schedule_converter/proto/runner"
"schedule_converter/config"
)
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
var req pb.GetSchedulePayload
if err := unmarshalPayload(payload, &req); err != nil {
return nil, err
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取课表任务")
httpClient, err := createHTTPClient()
if err != nil {
return nil, err
}
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester)
if err != nil {
return nil, err
}
result := &pb.ScheduleResultData{
StudentId: req.Username,
Semester: req.Semester,
Courses: convertCourses(courses),
}
log.Info("课表获取成功", "course_count", len(courses))
return json.Marshal(result)
}

172
main.go
View File

@@ -3,26 +3,29 @@ package main
import (
"context"
"flag"
"io"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"schedule_converter/client"
"schedule_converter/config"
grpcClient "schedule_converter/grpc"
"schedule_converter/pkg/logger"
"schedule_converter/service"
)
"github.com/gin-gonic/gin"
const (
defaultGRPCAddr = "localhost:50051"
runnerIDPrefix = "runner-"
defaultVersion = "1.0.0"
initialReconnectInterval = 5 * time.Second
maxReconnectInterval = 60 * time.Second
heartbeatInterval = 30 * time.Second
)
func main() {
mode := flag.String("mode", "http", "运行模式: http 或 grpc")
grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址")
grpcAddr := flag.String("grpc-addr", defaultGRPCAddr, "gRPC 服务器地址")
debug := flag.Bool("debug", false, "启用调试模式")
flag.Parse()
@@ -32,26 +35,31 @@ func main() {
}
cfg := config.Load()
slog.Info("配置加载完成", "mode", *mode, "base_url", cfg.BaseURL)
slog.Info("配置加载完成", "base_url", cfg.BaseURL)
if *mode == "grpc" {
runGRPCClient(*grpcAddr)
} else {
runHTTPServer()
}
func resolveRunnerID() string {
if id := os.Getenv("RUNNER_ID"); id != "" {
return id
}
if hostname, err := os.Hostname(); err == nil && hostname != "" {
return runnerIDPrefix + hostname
}
return fmt.Sprintf("%s%d", runnerIDPrefix, os.Getpid())
}
func resolveVersion() string {
if v := os.Getenv("RUNNER_VERSION"); v != "" {
return v
}
return defaultVersion
}
func runGRPCClient(serverAddr string) {
runnerID := os.Getenv("RUNNER_ID")
if runnerID == "" {
hostname, _ := os.Hostname()
runnerID = "runner-" + hostname
}
version := os.Getenv("RUNNER_VERSION")
if version == "" {
version = "1.0.0"
}
runnerID := resolveRunnerID()
version := resolveVersion()
slog.Info("启动 gRPC 客户端", "runner_id", runnerID, "version", version)
@@ -67,8 +75,6 @@ func runGRPCClient(serverAddr string) {
cancel()
}()
reconnectInterval := 5 * time.Second
const maxReconnectInterval = 60 * time.Second
retryCount := 0
for {
@@ -80,9 +86,9 @@ func runGRPCClient(serverAddr string) {
}
retryCount++
currentInterval := reconnectInterval
currentInterval := initialReconnectInterval
if retryCount > 1 {
currentInterval = min(reconnectInterval*time.Duration(retryCount-1), maxReconnectInterval)
currentInterval = min(initialReconnectInterval*time.Duration(retryCount-1), maxReconnectInterval)
}
slog.Info("正在连接服务器", "attempt", retryCount, "server", serverAddr)
@@ -101,7 +107,7 @@ func runGRPCClient(serverAddr string) {
retryCount = 0
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
go c.HeartbeatLoop(heartbeatCtx, 30*time.Second)
go c.HeartbeatLoop(heartbeatCtx, heartbeatInterval)
err := c.Run(ctx)
heartbeatCancel()
@@ -112,120 +118,10 @@ func runGRPCClient(serverAddr string) {
}
if err != nil {
slog.Warn("连接断开", "error", err, "retry_after", reconnectInterval)
slog.Warn("连接断开", "error", err, "retry_after", initialReconnectInterval)
} else {
slog.Info("连接已关闭")
}
time.Sleep(reconnectInterval)
time.Sleep(initialReconnectInterval)
}
}
func runHTTPServer() {
client.InitClient()
r := gin.New()
r.Use(gin.Recovery())
r.Use(requestLogger())
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
r.POST("/recognize", handleRecognize)
r.POST("/task", handleTask)
slog.Info("HTTP 服务器启动", "port", 8080)
if err := r.Run(":8080"); err != nil {
slog.Error("服务器启动失败", "error", err)
os.Exit(1)
}
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
if query != "" {
path = path + "?" + query
}
slog.Info("HTTP请求",
"method", c.Request.Method,
"path", path,
"status", status,
"latency", latency.String(),
"client_ip", c.ClientIP(),
)
}
}
func handleRecognize(c *gin.Context) {
file, header, err := c.Request.FormFile("image")
if err != nil {
slog.Warn("图片上传失败", "error", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()})
return
}
defer file.Close()
slog.Info("收到图片上传", "filename", header.Filename, "size", header.Size)
if header.Size > 10*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"})
return
}
imageData, err := io.ReadAll(file)
if err != nil {
slog.Error("读取图片失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()})
return
}
recognizer := service.NewRecognizerService()
courses, err := recognizer.RecognizeFromBytes(c.Request.Context(), imageData)
if err != nil {
slog.Error("识别失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()})
return
}
service.PrintCourses(courses)
if err := service.SaveToJSON(courses, "schedule_result.json"); err != nil {
slog.Warn("保存JSON失败", "error", err)
}
c.JSON(http.StatusOK, gin.H{
"message": "识别成功",
"courses": courses,
"count": len(courses),
})
}
func handleTask(c *gin.Context) {
cfg := config.Get()
if cfg.Username == "" || cfg.Password == "" {
slog.Warn("环境变量未设置")
c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"})
return
}
scheduleSvc := service.NewScheduleService()
_, err := scheduleSvc.FetchSchedule(c.Request.Context(), cfg.Username, cfg.Password, cfg.Semester)
if err != nil {
slog.Error("任务执行失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
slog.Info("任务执行成功")
c.JSON(http.StatusOK, gin.H{"message": "任务执行成功"})
}

View File

@@ -1,38 +0,0 @@
package models
// APIRequest API请求结构
type APIRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
EnableThinking *bool `json:"enable_thinking,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
}
// Message 消息结构
type Message struct {
Role string `json:"role"`
Content any `json:"content"`
}
// ContentItem 内容项
type ContentItem struct {
Type string `json:"type,omitempty"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
}
// ImageURL 图片URL
type ImageURL struct {
URL string `json:"url"`
}
// APIResponse API响应结构
type APIResponse struct {
Choices []Choice `json:"choices"`
}
// Choice 选择
type Choice struct {
Message Message `json:"message"`
}

View File

@@ -2,6 +2,7 @@ package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
@@ -19,19 +20,20 @@ type EmptyClassroomEntry struct {
Weeks string `json:"weeks"`
}
type BuildingInfo struct {
type buildingInfo struct {
DM string `json:"dm"`
MC string `json:"mc"`
}
type VenueInfo struct {
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.BaseURL + "/kjscx/queryKjs"
fmt.Printf("[空教室] 正在获取空教室数据 (学期=%s, 周次=%s-%s)...\n", semester, weekStart, weekEnd)
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)
@@ -57,21 +59,25 @@ func FetchEmptyClassrooms(httpClient *http.Client, semester, weekStart, weekEnd,
}
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)
entries, err := parseEmptyClassroomHTML(html, semester, weekStart, weekEnd)
if err != nil {
return nil, err
}
fmt.Printf("[空教室] 共获取 %d 条空教室记录\n", len(entries))
log.Info("空教室数据获取完成", "count", len(entries))
return entries, nil
}
func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
func parseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]EmptyClassroomEntry, error) {
if html == "" {
return nil, nil
}
@@ -193,9 +199,9 @@ func ParseEmptyClassroomHTML(html, semester, weekStart, weekEnd string) ([]Empty
return classrooms, nil
}
func FetchBuildings(httpClient *http.Client, campusCode string) ([]BuildingInfo, error) {
url := config.BaseURL + "/kjscx/queryJxlListBySjid"
fmt.Printf("[空教室] 正在获取楼号列表 (校区=%s)...\n", campusCode)
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))
@@ -204,7 +210,7 @@ func FetchBuildings(httpClient *http.Client, campusCode string) ([]BuildingInfo,
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.BaseURL+"/kjscx/queryKjs")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
@@ -212,11 +218,11 @@ func FetchBuildings(httpClient *http.Client, campusCode string) ([]BuildingInfo,
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取楼号列表失败, 状态码: %d", resp.StatusCode)
}
var buildings []BuildingInfo
var buildings []buildingInfo
if err := decodeJSONResponse(resp, &buildings); err != nil {
return nil, fmt.Errorf("解析楼号列表失败: %w", err)
}
@@ -224,9 +230,9 @@ func FetchBuildings(httpClient *http.Client, campusCode string) ([]BuildingInfo,
return buildings, nil
}
func FetchVenues(httpClient *http.Client, buildingCode string) ([]VenueInfo, error) {
url := config.BaseURL + "/kjscx/queryJxcdListBySjid"
fmt.Printf("[空教室] 正在获取场地列表 (楼号=%s)...\n", buildingCode)
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))
@@ -235,7 +241,7 @@ func FetchVenues(httpClient *http.Client, buildingCode string) ([]VenueInfo, err
}
req.Header = client.GetHeadersWithContentType("application/x-www-form-urlencoded")
req.Header.Set("Referer", config.BaseURL+"/kjscx/queryKjs")
req.Header.Set("Referer", config.Get().BaseURL+"/kjscx/queryKjs")
resp, err := httpClient.Do(req)
if err != nil {
@@ -243,11 +249,11 @@ func FetchVenues(httpClient *http.Client, buildingCode string) ([]VenueInfo, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("获取场地列表失败, 状态码: %d", resp.StatusCode)
}
var venues []VenueInfo
var venues []venueInfo
if err := decodeJSONResponse(resp, &venues); err != nil {
return nil, fmt.Errorf("解析场地列表失败: %w", err)
}

View File

@@ -2,6 +2,7 @@ package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
@@ -40,8 +41,9 @@ func examTypeToCode(t ExamType) string {
}
func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error) {
examURL := config.BaseURL + "/kscx/queryKsForXs"
fmt.Printf("[考试] 正在获取考试时间数据 (%s)...\n", examType)
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))
@@ -57,34 +59,43 @@ func FetchExams(httpClient *http.Client, examType ExamType) ([]ExamEntry, error)
}
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)
exams, err := parseExamTimeHTML(html, examType)
if err != nil {
return nil, err
}
fmt.Printf("[考试] 共获取 %d 条考试记录 (%s)\n", len(exams), examType)
log.Info("考试数据获取完成", "exam_count", len(exams))
return exams, nil
}
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 {
fmt.Printf("[考试] 获取%s考试失败: %v\n", et, err)
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) {
func parseExamTimeHTML(html string, examType ExamType) ([]ExamEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err

View File

@@ -1,11 +1,9 @@
package parser
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"regexp"
"sort"
"strconv"
@@ -26,17 +24,11 @@ type weekRoomEntry struct {
}
// 获取课表
func FetchSchedule(httpClient *http.Client) ([]models.Course, error) {
return FetchScheduleWithClient(httpClient, config.Semester)
}
// FetchScheduleWithClient 使用指定的 HTTP 客户端和学期获取课表
func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models.Course, error) {
fmt.Println("[5] 获取课表数据...")
// POST请求获取课表
data := fmt.Sprintf("xnxq=%s", semester)
req, err := http.NewRequest("POST", config.BaseURL+"/kbcx/queryGrkb", strings.NewReader(data))
req, err := http.NewRequest("POST", config.Get().BaseURL+"/kbcx/queryGrkb", strings.NewReader(data))
if err != nil {
return nil, err
}
@@ -54,35 +46,7 @@ func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models
return nil, err
}
// 解析课表
courses, err := ParseScheduleHTML(string(body))
if err != nil {
return nil, err
}
fmt.Printf(" 共提取 %d 门课程\n", len(courses))
// 输出到JSON
jsonData, err := os.Create("schedule_result.json")
if err != nil {
return nil, err
}
defer jsonData.Close()
encoder := json.NewEncoder(jsonData)
encoder.SetIndent("", " ")
err = encoder.Encode(courses)
if err != nil {
return nil, err
}
fmt.Println(" JSON结果已保存到: schedule_result.json")
// 打印课程信息
for i, course := range courses {
fmt.Printf(" 课程 %d: %s - %s\n", i+1, course.Name, course.Teacher)
}
return courses, nil
return ParseScheduleHTML(string(body))
}
// 解析课表HTML
@@ -354,27 +318,6 @@ func buildCourseKey(name, teacher, room string) string {
return strings.TrimSpace(name) + "|" + strings.TrimSpace(teacher) + "|" + strings.TrimSpace(room)
}
// extractWeekInfo 提取一段文本中的完整周次片段
// 例如:于战华[15]周,[2-14]周 -> before=于战华, weeks=[15]周,[2-14]周, after=
func extractWeekInfo(text string) (beforeWeek, weeksPart, afterWeek string, ok bool) {
indexes := weekTokenRegexp.FindAllStringIndex(text, -1)
if len(indexes) == 0 {
return "", "", "", false
}
firstStart := indexes[0][0]
beforeWeek = strings.TrimSpace(text[:firstStart])
parts := weekTokenRegexp.FindAllString(text, -1)
weeksPart = strings.Join(parts, ",")
// 周次后(含中间)非周次内容作为教室候选
tail := strings.TrimSpace(text[firstStart:])
afterWeek = strings.TrimSpace(weekTokenRegexp.ReplaceAllString(tail, ""))
afterWeek = strings.Trim(afterWeek, ", ")
return beforeWeek, weeksPart, afterWeek, true
}
// extractWeekEntries 将一段文本拆成多个“周次-地点”片段
// 例如:李鹏程[2-8]周N楼-335[9]周N楼-331
// 会拆成:

View File

@@ -2,6 +2,7 @@ package parser
import (
"fmt"
"log/slog"
"net/http"
"strings"
@@ -39,16 +40,17 @@ type GPASummary struct {
Semesters string
}
func FetchGrades(httpClient *http.Client,ExamType ScoreType) ([]GradeEntry, error) {
func FetchGrades(httpClient *http.Client, examType ScoreType) ([]GradeEntry, error) {
var gradeURL string
switch ExamType {
switch examType {
case ScoreTypeMidterm:
gradeURL = config.BaseURL + "/cjcx/queryQzcj"
gradeURL = config.Get().BaseURL + "/cjcx/queryQzcj"
default:
gradeURL = config.BaseURL + "/cjcx/queryQmcj"
gradeURL = config.Get().BaseURL + "/cjcx/queryQmcj"
}
fmt.Printf("[成绩] 正在获取成绩数据 (%s)...\n", ExamType)
log := slog.With("exam_type", string(examType))
log.Info("获取成绩数据")
req, err := http.NewRequest("GET", gradeURL, nil)
if err != nil {
@@ -62,23 +64,27 @@ func FetchGrades(httpClient *http.Client,ExamType ScoreType) ([]GradeEntry, erro
}
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)
grades, err := parseGradesHTML(html)
if err != nil {
return nil, err
}
fmt.Printf("[成绩] 共获取 %d 条成绩记录\n", len(grades))
log.Info("成绩获取完成", "grade_count", len(grades))
return grades, nil
}
func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) {
gpaURL := config.BaseURL + "/xfj/queryListXfj"
fmt.Println("[学分绩] 正在获取学分绩数据...")
gpaURL := config.Get().BaseURL + "/xfj/queryListXfj"
slog.Info("获取学分绩数据")
req, err := http.NewRequest("GET", gpaURL, nil)
if err != nil {
@@ -92,21 +98,25 @@ func FetchGPA(httpClient *http.Client) (*GPASummary, []GradeEntry, error) {
}
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)
summary, grades, err := parseGPAHTML(html)
if err != nil {
return nil, nil, err
}
fmt.Printf("[学分绩] 共获取 %d 门课程明细\n", len(grades))
slog.Info("学分绩获取完成", "grade_count", len(grades))
return summary, grades, nil
}
func ParseGradesHTML(html string) ([]GradeEntry, error) {
func parseGradesHTML(html string) ([]GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, err
@@ -173,7 +183,7 @@ func ParseGradesHTML(html string) ([]GradeEntry, error) {
return grades, nil
}
func ParseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
func parseGPAHTML(html string) (*GPASummary, []GradeEntry, error) {
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
if err != nil {
return nil, nil, err

View File

@@ -5,16 +5,7 @@ import (
"fmt"
)
var (
ErrInvalidConfig = errors.New("invalid configuration")
ErrLoginFailed = errors.New("login failed")
ErrCaptchaFailed = errors.New("captcha recognition failed")
ErrScheduleFailed = errors.New("failed to fetch schedule")
ErrRecognitionFailed = errors.New("schedule recognition failed")
ErrUnauthorized = errors.New("unauthorized access")
ErrTimeout = errors.New("operation timeout")
ErrConnectionFailed = errors.New("connection failed")
)
var ErrLoginFailed = errors.New("login failed")
type ScheduleError struct {
Op string
@@ -40,19 +31,3 @@ func New(op string, err error, msg string) error {
Msg: msg,
}
}
func Wrap(err error, msg string) error {
return fmt.Errorf("%s: %w", msg, err)
}
func Join(errs ...error) error {
return errors.Join(errs...)
}
func Is(err, target error) bool {
return errors.Is(err, target)
}
func As(err error, target any) bool {
return errors.As(err, target)
}

View File

@@ -5,51 +5,29 @@ import (
"os"
)
var (
defaultLevel = slog.LevelInfo
logger *slog.Logger
)
var currentLevel = slog.LevelInfo
func init() {
opts := &slog.HandlerOptions{
Level: defaultLevel,
Level: currentLevel,
}
logger = slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
slog.SetDefault(logger)
}
func SetLevel(level slog.Level) {
currentLevel = level
opts := &slog.HandlerOptions{
Level: level,
Level: currentLevel,
}
logger = slog.New(slog.NewJSONHandler(os.Stdout, opts))
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
slog.SetDefault(logger)
}
func SetTextHandler() {
opts := &slog.HandlerOptions{
Level: defaultLevel,
Level: currentLevel,
}
logger = slog.New(slog.NewTextHandler(os.Stdout, opts))
logger := slog.New(slog.NewTextHandler(os.Stdout, opts))
slog.SetDefault(logger)
}
func Debug(msg string, args ...any) {
slog.Debug(msg, args...)
}
func Info(msg string, args ...any) {
slog.Info(msg, args...)
}
func Warn(msg string, args ...any) {
slog.Warn(msg, args...)
}
func Error(msg string, args ...any) {
slog.Error(msg, args...)
}
func With(args ...any) *slog.Logger {
return slog.With(args...)
}

View File

@@ -1,41 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"os"
"schedule_converter/models"
)
func SaveToJSON(courses []models.Course, filename string) error {
jsonData, err := json.MarshalIndent(courses, "", " ")
if err != nil {
return err
}
err = os.WriteFile(filename, jsonData, 0644)
if err != nil {
return err
}
fmt.Printf("[输出] JSON结果已保存到: %s\n", filename)
return nil
}
func PrintCourses(courses []models.Course) {
fmt.Println("\n==================================================")
fmt.Println("识别结果:")
fmt.Println("==================================================")
for i, course := range courses {
fmt.Printf("\n课程 %d: %s\n", i+1, course.Name)
fmt.Printf(" 教师: %s\n", course.Teacher)
fmt.Printf(" 教室: %s\n", course.Room)
fmt.Printf(" 时间:")
for _, sch := range course.Schedule {
fmt.Printf(" %s %v 周%v", sch.Day, sch.Section, sch.Weeks)
}
fmt.Println()
}
fmt.Println("\n==================================================")
}

View File

@@ -1,241 +0,0 @@
package service
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
apperr "schedule_converter/pkg/errors"
)
var (
jsonBlockRegex = regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```")
codeBlockRegex = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```")
jsonArrayRegex = regexp.MustCompile(`(\[[\s\S]*\])`)
regexPool = sync.Pool{
New: func() any {
return nil
},
}
)
type RecognizerService struct {
client *http.Client
maxRetry int
retryWait time.Duration
}
func NewRecognizerService() *RecognizerService {
return &RecognizerService{
client: client.RecognizerClient,
maxRetry: 3,
retryWait: 5 * time.Second,
}
}
func (r *RecognizerService) RecognizeFromFile(ctx context.Context, imagePath string) ([]models.Course, error) {
log := slog.With("image_path", imagePath)
log.Info("正在读取图片")
imageData, err := os.ReadFile(imagePath)
if err != nil {
return nil, apperr.New("recognize_file", err, "读取图片失败")
}
log.Info("图片读取完成", "size", len(imageData))
return r.RecognizeFromBytes(ctx, imageData)
}
func (r *RecognizerService) RecognizeFromBytes(ctx context.Context, imageData []byte) ([]models.Course, error) {
log := slog.With("size", len(imageData))
log.Info("正在处理图片数据")
imageBase64 := base64.StdEncoding.EncodeToString(imageData)
return r.callAPI(ctx, imageBase64)
}
func (r *RecognizerService) RecognizeFromBase64(ctx context.Context, imageBase64 string) ([]models.Course, error) {
slog.Info("正在处理base64图片数据", "length", len(imageBase64))
return r.callAPI(ctx, imageBase64)
}
func (r *RecognizerService) callAPI(ctx context.Context, imageBase64 string) ([]models.Course, error) {
var lastErr error
for retry := range r.maxRetry {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
if retry > 0 {
slog.Info("重试中", "attempt", retry+1, "max_retry", r.maxRetry)
time.Sleep(r.retryWait)
}
courses, err := r.callAPISingle(ctx, imageBase64)
if err == nil {
return courses, nil
}
lastErr = err
slog.Warn("API调用失败", "attempt", retry+1, "error", err)
if !strings.Contains(err.Error(), "504") {
return nil, err
}
}
return nil, apperr.New("recognize_api", lastErr, "已达到最大重试次数")
}
func (r *RecognizerService) callAPISingle(ctx context.Context, imageBase64 string) ([]models.Course, error) {
cfg := config.Get()
if cfg.Recognizer.APIKey == "" {
return nil, apperr.ErrRecognitionFailed
}
prompt := `请解析以下课表图片提取所有课程信息并以JSON数组格式返回。
JSON格式要求
[{"name":"课程名","teacher":"老师名","room":"教室","schedule":[{"day":"星期X","section":[节次数组],"weeks":[周数数组]}]}]
注意事项:
1. day使用"星期一"到"星期日"
2. section节次上午1-2节为[1,2]上午3-4节为[3,4]下午5-6节为[5,6]下午7-8节为[7,8]晚上9-10节为[9,10]晚上11-12节为[11,12]
3. weeks周数格式如[1,2,3,4,5,6,7,8]或[9,10,11,12,13,14,15,16]
4. 只返回JSON数组不要任何其他文字。`
falseVal := false
zero := 0
apiReq := models.APIRequest{
Model: cfg.Recognizer.Model,
MaxTokens: 4000,
EnableThinking: &falseVal,
ThinkingBudget: &zero,
Messages: []models.Message{
{
Role: "user",
Content: []models.ContentItem{
{Type: "text", Text: prompt},
{
Type: "image_url",
ImageURL: &models.ImageURL{
URL: "data:image/png;base64," + imageBase64,
},
},
},
},
},
}
jsonData, err := json.Marshal(apiReq)
if err != nil {
return nil, apperr.New("marshal_request", err, "")
}
req, err := http.NewRequestWithContext(ctx, "POST", cfg.Recognizer.APIURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, apperr.New("create_request", err, "")
}
req.Header = http.Header{
"Content-Type": []string{"application/json"},
"Authorization": []string{"Bearer " + cfg.Recognizer.APIKey},
}
slog.Info("调用识别API")
resp, err := r.client.Do(req)
if err != nil {
return nil, apperr.New("api_call", err, "")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API调用失败: %d, %s", resp.StatusCode, string(respBody))
}
var apiResp models.APIResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return nil, apperr.New("decode_response", err, "")
}
if len(apiResp.Choices) == 0 {
return nil, apperr.New("empty_response", apperr.ErrRecognitionFailed, "API返回为空")
}
resultText := extractContent(apiResp.Choices[0].Message.Content)
slog.Debug("API响应接收完成", "response_length", len(resultText))
jsonMatch := extractJSON(resultText)
if jsonMatch == "" {
return nil, apperr.New("extract_json", apperr.ErrRecognitionFailed,
fmt.Sprintf("未找到JSON数据原始响应: %s", resultText))
}
var courses []models.Course
if err := json.Unmarshal([]byte(jsonMatch), &courses); err != nil {
return nil, apperr.New("parse_json", err, fmt.Sprintf("数据: %s", jsonMatch))
}
slog.Info("识别完成", "course_count", len(courses))
return courses, nil
}
func extractContent(content any) string {
switch c := content.(type) {
case string:
return c
case []any:
for _, item := range c {
if itemMap, ok := item.(map[string]any); ok {
if text, ok := itemMap["text"].(string); ok {
return text
}
}
}
}
return ""
}
func extractJSON(text string) string {
if match := jsonBlockRegex.FindStringSubmatch(text); len(match) > 1 {
slog.Debug("从json代码块中提取JSON")
return match[1]
}
if match := codeBlockRegex.FindStringSubmatch(text); len(match) > 1 {
slog.Debug("从代码块中提取JSON")
return match[1]
}
if match := jsonArrayRegex.FindString(text); match != "" {
slog.Debug("从文本中提取JSON")
return match
}
return ""
}
func RecognizeScheduleFromFile(imagePath string) ([]models.Course, error) {
return NewRecognizerService().RecognizeFromFile(context.Background(), imagePath)
}
func RecognizeScheduleFromBytes(imageData []byte) ([]models.Course, error) {
return NewRecognizerService().RecognizeFromBytes(context.Background(), imageData)
}
func RecognizeScheduleFromBase64(imageBase64 string) ([]models.Course, error) {
return NewRecognizerService().RecognizeFromBase64(context.Background(), imageBase64)
}

View File

@@ -1,124 +0,0 @@
package service
import (
"context"
"log/slog"
"net/http"
"time"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
type ScheduleService struct {
httpClient *http.Client
maxRetries int
retryDelay time.Duration
}
type ScheduleOption func(*ScheduleService)
func WithMaxRetries(n int) ScheduleOption {
return func(s *ScheduleService) {
s.maxRetries = n
}
}
func WithRetryDelay(d time.Duration) ScheduleOption {
return func(s *ScheduleService) {
s.retryDelay = d
}
}
func NewScheduleService(opts ...ScheduleOption) *ScheduleService {
s := &ScheduleService{
httpClient: client.Client,
maxRetries: 3,
retryDelay: 3 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func NewScheduleServiceWithClient(httpClient *http.Client, opts ...ScheduleOption) *ScheduleService {
s := &ScheduleService{
httpClient: httpClient,
maxRetries: 3,
retryDelay: 3 * time.Second,
}
for _, opt := range opts {
opt(s)
}
return s
}
func (s *ScheduleService) FetchSchedule(ctx context.Context, username, password, semester string) ([]models.Course, error) {
log := slog.With("username", username, "semester", semester)
if err := client.GetInitialCookiesWithClient(s.httpClient); err != nil {
return nil, apperr.New("fetch_schedule", err, "获取Cookie失败")
}
var lastErr error
for attempt := 1; attempt <= s.maxRetries; attempt++ {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
log.Info("尝试登录", "attempt", attempt, "max_retries", s.maxRetries)
captchaCode, err := captcha.GetAndRecognize(s.httpClient)
if err != nil {
log.Warn("验证码获取失败", "error", err, "attempt", attempt)
lastErr = err
time.Sleep(s.retryDelay)
continue
}
success, err := auth.LoginWithClient(s.httpClient, captchaCode, username, password)
if err != nil {
log.Warn("登录失败", "error", err, "attempt", attempt)
lastErr = err
time.Sleep(s.retryDelay)
continue
}
if success {
if semester == "" {
semester = config.Semester
}
courses, err := parser.FetchScheduleWithClient(s.httpClient, semester)
if err != nil {
return nil, apperr.New("fetch_schedule", err, "获取课表失败")
}
log.Info("获取课表成功", "course_count", len(courses))
return courses, nil
}
log.Warn("登录验证失败,稍后重试", "attempt", attempt)
time.Sleep(s.retryDelay)
}
return nil, apperr.New("fetch_schedule", lastErr, "登录失败,已重试")
}
func (s *ScheduleService) FetchDefaultSchedule(ctx context.Context) ([]models.Course, error) {
return s.FetchSchedule(ctx, config.Username, config.Password, config.Semester)
}
func (s *ScheduleService) FetchScheduleForUser(username, password, semester string) ([]models.Course, error) {
return s.FetchSchedule(context.Background(), username, password, semester)
}
func (s *ScheduleService) FetchDefaultScheduleLegacy() ([]models.Course, error) {
return s.FetchScheduleForUser(config.Username, config.Password, config.Semester)
}