diff --git a/auth/login.go b/auth/login.go index e9d32e3..dca0267 100644 --- a/auth/login.go +++ b/auth/login.go @@ -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 -} +} \ No newline at end of file diff --git a/captcha/recognize.go b/captcha/recognize.go index be07714..f822167 100644 --- a/captcha/recognize.go +++ b/captcha/recognize.go @@ -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 +} \ No newline at end of file diff --git a/client/http.go b/client/http.go index b4120d1..199da1b 100644 --- a/client/http.go +++ b/client/http.go @@ -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,31 +40,13 @@ 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, "本科教学管理服务系统") } func IsCaptchaError(body string) bool { return strings.Contains(body, "验证码") -} +} \ No newline at end of file diff --git a/config/config.go b/config/config.go index e455836..8274561 100644 --- a/config/config.go +++ b/config/config.go @@ -1,27 +1,16 @@ package config import ( - "log/slog" "os" - "strconv" "sync" - "time" ) type Config struct { - BaseURL string - Username string - Password string - Semester string - TWFID string - Recognizer RecognizerConfig -} - -type RecognizerConfig struct { - APIURL string - APIKey string - Model string - Timeout time.Duration + BaseURL string + Username string + Password string + Semester string + TWFID string } 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 -) diff --git a/go.mod b/go.mod index 233ff10..9a13fb0 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index ac4e6fe..f48cd9a 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/grpc/client.go b/grpc/client.go index c798dbd..7adde41 100644 --- a/grpc/client.go +++ b/grpc/client.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "io" - "log" + "log/slog" "sync" "time" @@ -14,17 +14,20 @@ import ( "google.golang.org/grpc/credentials/insecure" ) +const defaultHeartbeatInterval = 30 * time.Second + // Client gRPC 客户端 type Client struct { serverAddr string runnerID string version string - conn *grpc.ClientConn - stream pb.RunnerHub_ConnectClient - mu sync.Mutex - handler *TaskHandler - // 配置 + mu sync.RWMutex + conn *grpc.ClientConn + stream pb.RunnerHub_ConnectClient + connected bool + + handler *TaskHandler heartbeatInterval time.Duration } @@ -34,7 +37,7 @@ func NewClient(serverAddr, runnerID, version string) *Client { serverAddr: serverAddr, runnerID: runnerID, version: version, - heartbeatInterval: 30 * time.Second, + heartbeatInterval: defaultHeartbeatInterval, } } @@ -45,27 +48,31 @@ func (c *Client) SetHandler(handler *TaskHandler) { // Connect 连接到 gRPC 服务器 func (c *Client) Connect(ctx context.Context) error { - log.Printf("正在连接到 gRPC 服务器: %s", c.serverAddr) + slog.Info("正在连接 gRPC 服务器", "server", c.serverAddr) conn, err := grpc.Dial(c.serverAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { return fmt.Errorf("连接失败: %w", err) } - c.conn = conn client := pb.NewRunnerHubClient(conn) stream, err := client.Connect(ctx) if err != nil { + conn.Close() return fmt.Errorf("创建流失败: %w", err) } - c.stream = stream - // 发送注册请求 + c.mu.Lock() + c.conn = conn + c.stream = stream + c.connected = true + c.mu.Unlock() + if err := c.register(); err != nil { return err } - log.Println("gRPC 连接成功") + slog.Info("gRPC 连接已建立") return nil } @@ -80,33 +87,41 @@ func (c *Client) register() error { "TASK_TYPE_GET_SCHEDULE": "true", "TASK_TYPE_GET_GRADES": "true", "TASK_TYPE_GET_EXAMS": "true", - "TASK_TYPE_LOGIN": "true", - "TASK_TYPE_GET_EMPTY_CLASSROOM": "true", + "TASK_TYPE_LOGIN": "true", + "TASK_TYPE_GET_EMPTY_CLASSROOM": "true", }, }, }, } - return c.stream.Send(msg) + return c.sendMessage(msg) } // Run 运行主循环,接收消息 func (c *Client) Run(ctx context.Context) error { + defer c.markDisconnected() + for { select { case <-ctx.Done(): return ctx.Err() default: - msg, err := c.stream.Recv() - if err == io.EOF { - log.Println("服务器关闭了连接") - return nil - } - if err != nil { - return fmt.Errorf("接收消息失败: %w", err) - } - - c.handleMessage(msg) } + + stream := c.getStream() + if stream == nil { + return fmt.Errorf("流未连接") + } + + msg, err := stream.Recv() + if err == io.EOF { + slog.Info("服务器关闭了连接") + return nil + } + if err != nil { + return fmt.Errorf("接收消息失败: %w", err) + } + + c.handleMessage(msg) } } @@ -114,63 +129,52 @@ func (c *Client) Run(ctx context.Context) error { func (c *Client) handleMessage(msg *pb.StreamMessage) { switch m := msg.Message.(type) { case *pb.StreamMessage_RegisterResponse: - log.Printf("注册成功: %s, 会话ID: %s, 心跳间隔: %d秒", - m.RegisterResponse.Message, - m.RegisterResponse.SessionId, - m.RegisterResponse.HeartbeatInterval) + slog.Info("注册成功", + "message", m.RegisterResponse.Message, + "session_id", m.RegisterResponse.SessionId, + "heartbeat_interval_sec", m.RegisterResponse.HeartbeatInterval, + ) if m.RegisterResponse.HeartbeatInterval > 0 { c.heartbeatInterval = time.Duration(m.RegisterResponse.HeartbeatInterval) * time.Second } case *pb.StreamMessage_HeartbeatAck: - // 心跳确认,可以用于计算延迟 - log.Printf("心跳确认,延迟: %dms", m.HeartbeatAck.Latency) + slog.Debug("心跳确认", "latency_ms", m.HeartbeatAck.Latency) case *pb.StreamMessage_Task: - log.Printf("收到任务: %s, 类型: %v", m.Task.TaskId, m.Task.Type) + slog.Info("收到任务", "task_id", m.Task.TaskId, "type", m.Task.Type.String()) go c.handleTask(m.Task) case *pb.StreamMessage_TaskCancel: - log.Printf("收到取消任务请求: %s, 原因: %s", m.TaskCancel.TaskId, m.TaskCancel.Reason) + slog.Info("收到取消任务请求", "task_id", m.TaskCancel.TaskId, "reason", m.TaskCancel.Reason) if c.handler != nil { c.handler.CancelTask(m.TaskCancel.TaskId) } default: - log.Printf("收到未知消息类型: %T", msg.Message) + slog.Warn("收到未知消息类型", "type", fmt.Sprintf("%T", msg.Message)) } } // handleTask 处理任务 func (c *Client) handleTask(task *pb.Task) { if c.handler == nil { - log.Println("错误: 未设置任务处理器") + slog.Error("未设置任务处理器") return } result := c.handler.Execute(task) - c.mu.Lock() - err := c.stream.Send(&pb.StreamMessage{ - Message: &pb.StreamMessage_Result{ - Result: result, - }, + err := c.sendMessage(&pb.StreamMessage{ + Message: &pb.StreamMessage_Result{Result: result}, }) - c.mu.Unlock() if err != nil { - log.Printf("发送任务结果失败: %v", err) - } else { - log.Printf("任务结果已发送: %s, 状态: %v", result.TaskId, result.Status) + slog.Error("发送任务结果失败", "task_id", result.TaskId, "error", err) + return } + slog.Info("任务结果已发送", "task_id", result.TaskId, "status", result.Status.String()) } // sendHeartbeat 发送心跳 func (c *Client) sendHeartbeat() error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.stream == nil { - return fmt.Errorf("流未连接") - } - - return c.stream.Send(&pb.StreamMessage{ + return c.sendMessage(&pb.StreamMessage{ Message: &pb.StreamMessage_Heartbeat{ Heartbeat: &pb.Heartbeat{ Timestamp: time.Now().UnixMilli(), @@ -190,7 +194,7 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) { return case <-ticker.C: if err := c.sendHeartbeat(); err != nil { - log.Printf("发送心跳失败: %v", err) + slog.Warn("发送心跳失败", "error", err) } } } @@ -198,11 +202,43 @@ func (c *Client) HeartbeatLoop(ctx context.Context, interval time.Duration) { // Close 关闭连接 func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + c.connected = false if c.stream != nil { - c.stream.CloseSend() + _ = c.stream.CloseSend() } if c.conn != nil { - return c.conn.Close() + err := c.conn.Close() + c.conn = nil + c.stream = nil + return err } return nil } + +// sendMessage 加锁发送消息,确保 stream 仍然可用 +func (c *Client) sendMessage(msg *pb.StreamMessage) error { + c.mu.RLock() + defer c.mu.RUnlock() + + if !c.connected || c.stream == nil { + return fmt.Errorf("流未连接") + } + return c.stream.Send(msg) +} + +// getStream 读锁获取 stream +func (c *Client) getStream() pb.RunnerHub_ConnectClient { + c.mu.RLock() + defer c.mu.RUnlock() + return c.stream +} + +// markDisconnected 标记连接已断开,避免后续发送 +func (c *Client) markDisconnected() { + c.mu.Lock() + c.connected = false + c.mu.Unlock() +} diff --git a/grpc/convert.go b/grpc/convert.go new file mode 100644 index 0000000..6697264 --- /dev/null +++ b/grpc/convert.go @@ -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 +} diff --git a/grpc/handler.go b/grpc/handler.go index 27e5746..3daf44c 100644 --- a/grpc/handler.go +++ b/grpc/handler.go @@ -2,24 +2,13 @@ package grpc import ( "context" - "encoding/json" + "errors" "fmt" "log/slog" - "net/http" - "net/http/cookiejar" - "net/url" "sync" "time" pb "schedule_converter/proto/runner" - - "schedule_converter/auth" - "schedule_converter/captcha" - "schedule_converter/client" - "schedule_converter/config" - "schedule_converter/models" - "schedule_converter/parser" - apperr "schedule_converter/pkg/errors" ) type TaskHandler struct { @@ -46,40 +35,47 @@ func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult { return result } - var err error - var data []byte - - switch task.Type { - case pb.TaskType_TASK_TYPE_GET_SCHEDULE: - data, err = h.getSchedule(context.Background(), task.Payload) - case pb.TaskType_TASK_TYPE_GET_GRADES: - data, err = h.getGrades(context.Background(), task.Payload) - case pb.TaskType_TASK_TYPE_GET_EXAMS: - data, err = h.getExams(context.Background(), task.Payload) - case pb.TaskType_TASK_TYPE_LOGIN: - data, err = h.login(context.Background(), task.Payload) - case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: - data, err = h.getEmptyClassrooms(context.Background(), task.Payload) - default: - result.Status = pb.TaskStatus_TASK_STATUS_FAILED - result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type) - log.Warn("未知的任务类型") - return result - } + ctx := context.Background() + data, err := h.dispatch(ctx, task) if err != nil { + if errors.Is(err, errUnknownTaskType) { + result.Status = pb.TaskStatus_TASK_STATUS_FAILED + result.ErrorMessage = err.Error() + log.Warn("未知的任务类型") + return result + } result.Status = pb.TaskStatus_TASK_STATUS_FAILED result.ErrorMessage = err.Error() log.Error("任务执行失败", "error", err) - } else { - result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS - result.Data = data - log.Info("任务执行成功") + return result } + result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS + result.Data = data + log.Info("任务执行成功") return result } +var errUnknownTaskType = fmt.Errorf("unknown task type") + +func (h *TaskHandler) dispatch(ctx context.Context, task *pb.Task) ([]byte, error) { + switch task.Type { + case pb.TaskType_TASK_TYPE_GET_SCHEDULE: + return h.getSchedule(ctx, task.Payload) + case pb.TaskType_TASK_TYPE_GET_GRADES: + return h.getGrades(ctx, task.Payload) + case pb.TaskType_TASK_TYPE_GET_EXAMS: + return h.getExams(ctx, task.Payload) + case pb.TaskType_TASK_TYPE_LOGIN: + return h.login(ctx, task.Payload) + case pb.TaskType_TASK_TYPE_GET_EMPTY_CLASSROOM: + return h.getEmptyClassrooms(ctx, task.Payload) + default: + return nil, fmt.Errorf("%w: %v", errUnknownTaskType, task.Type) + } +} + func (h *TaskHandler) CancelTask(taskID string) { h.cancelledTasks.Store(taskID, true) slog.Info("任务已标记为取消", "task_id", taskID) @@ -89,351 +85,3 @@ func (h *TaskHandler) IsCancelled(taskID string) bool { v, ok := h.cancelledTasks.Load(taskID) return ok && v.(bool) } - -func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) { - var req pb.GetSchedulePayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, apperr.New("parse_payload", err, "解析任务载荷失败") - } - - log := slog.With("username", req.Username, "semester", req.Semester) - log.Info("获取课表任务") - - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester) - if err != nil { - return nil, err - } - - result := &pb.ScheduleResultData{ - StudentId: req.Username, - Semester: req.Semester, - Courses: convertCourses(courses), - } - - log.Info("课表获取成功", "course_count", len(courses)) - return json.Marshal(result) -} - -func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) { - var req pb.LoginPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, apperr.New("parse_payload", err, "解析任务载荷失败") - } - - log := slog.With("username", req.Username) - log.Info("登录任务") - - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid) - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - log.Info("登录成功") - return json.Marshal(map[string]any{ - "success": true, - "message": "登录成功", - }) -} - -func createHTTPClient() (*http.Client, error) { - jar, err := cookiejar.New(nil) - if err != nil { - return nil, apperr.New("create_jar", err, "创建 cookie jar 失败") - } - return &http.Client{Jar: jar}, nil -} - -func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) { - cfg := config.Get() - if twfid == "" { - twfid = cfg.TWFID - } - u, _ := url.Parse(baseURL) - httpClient.Jar.SetCookies(u, []*http.Cookie{ - {Name: "TWFID", Value: twfid}, - }) -} - -func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error { - log := slog.With("username", username) - - if err := client.GetInitialCookiesWithClient(httpClient); err != nil { - return apperr.New("get_cookies", err, "获取初始 Cookie 失败") - } - - cfg := config.Get() - _ = cfg.Recognizer.Timeout - - for attempt := 1; attempt <= 3; attempt++ { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - log.Info("尝试登录", "attempt", attempt, "max_retries", 3) - - captchaCode, err := captcha.GetAndRecognize(httpClient) - if err != nil { - log.Warn("验证码获取失败", "error", err, "attempt", attempt) - time.Sleep(3 * time.Second) - continue - } - - success, err := auth.LoginWithClient(httpClient, captchaCode, username, password) - if err != nil { - log.Warn("登录请求失败", "error", err, "attempt", attempt) - time.Sleep(3 * time.Second) - continue - } - - if success { - return nil - } - - log.Warn("登录验证失败", "attempt", attempt) - time.Sleep(3 * time.Second) - } - - return apperr.ErrLoginFailed -} - -func executeLoginAndFetchSchedule(ctx context.Context, httpClient *http.Client, username, password, semester string) ([]models.Course, error) { - if err := executeLogin(ctx, httpClient, username, password); err != nil { - return nil, err - } - - cfg := config.Get() - if semester == "" { - semester = cfg.Semester - } - - return parser.FetchScheduleWithClient(httpClient, semester) -} - -func (h *TaskHandler) getGrades(ctx context.Context, payload []byte) ([]byte, error) { - var req pb.GetGradesPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, apperr.New("parse_payload", err, "解析任务载荷失败") - } - - log := slog.With("username", req.Username, "semester", req.Semester) - log.Info("获取成绩任务") - - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - var gpaSummary *pb.GpaSummary - summary, grades, err := parser.FetchGPA(httpClient) - if err != nil { - log.Warn("获取GPA页面失败,尝试成绩页面", "error", err) - grades, err = parser.FetchGrades(httpClient, parser.ScoreTypeFinal) - if err != nil { - return nil, apperr.New("fetch_grades", err, "获取成绩失败") - } - } else if summary != nil { - gpaSummary = &pb.GpaSummary{ - AverageGpa: summary.AverageGPA, - Rank: summary.Rank, - ExamTotalGpa: summary.ExamTotalGPA, - ExamTotalCredits: summary.ExamTotalCredits, - FailCredits: summary.FailCredits, - Semesters: summary.Semesters, - } - } - - pbGrades := convertGrades(grades) - - result := &pb.GradesResultData{ - StudentId: req.Username, - Semester: req.Semester, - Grades: pbGrades, - GpaSummary: gpaSummary, - } - - log.Info("成绩获取成功", "grade_count", len(pbGrades)) - return json.Marshal(result) -} - -func (h *TaskHandler) getExams(ctx context.Context, payload []byte) ([]byte, error) { - var req pb.GetExamsPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, apperr.New("parse_payload", err, "解析任务载荷失败") - } - - log := slog.With("username", req.Username, "semester", req.Semester) - log.Info("获取考试安排任务") - - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - exams, err := parser.FetchAllExams(httpClient) - if err != nil { - return nil, apperr.New("fetch_exams", err, "获取考试安排失败") - } - - pbExams := convertExams(exams) - - result := &pb.ExamsResultData{ - StudentId: req.Username, - Semester: req.Semester, - Exams: pbExams, - } - - log.Info("考试安排获取成功", "exam_count", len(pbExams)) - return json.Marshal(result) -} - -func convertCourses(courses []models.Course) []*pb.Course { - result := make([]*pb.Course, len(courses)) - for i, c := range courses { - schedule := make([]*pb.ScheduleTime, len(c.Schedule)) - for j, s := range c.Schedule { - schedule[j] = &pb.ScheduleTime{ - Day: s.Day, - Sections: convertToInt32(s.Section), - Weeks: convertToInt32(s.Weeks), - } - } - result[i] = &pb.Course{ - Name: c.Name, - Teacher: c.Teacher, - Room: c.Room, - Schedule: schedule, - } - } - return result -} - -func convertToInt32(arr []int) []int32 { - result := make([]int32, len(arr)) - for i, v := range arr { - result[i] = int32(v) - } - return result -} - -func convertGrades(grades []parser.GradeEntry) []*pb.Grade { - result := make([]*pb.Grade, 0, len(grades)) - for _, g := range grades { - result = append(result, &pb.Grade{ - Term: g.Term, - CourseCode: g.Code, - CourseName: g.Name, - Nature: g.Nature, - Category: g.Category, - Credit: g.Credit, - Score: g.Score, - GradePoint: g.GradePoint, - IsExam: g.IsExam, - }) - } - return result -} - -func convertExams(exams []parser.ExamEntry) []*pb.Exam { - result := make([]*pb.Exam, 0, len(exams)) - for _, e := range exams { - examTypeStr := "期末" - switch e.Type { - case parser.ExamTypeMidterm: - examTypeStr = "期中" - case parser.ExamTypeMakeup: - examTypeStr = "补考" - } - result = append(result, &pb.Exam{ - CourseName: e.Course, - ExamType: examTypeStr, - Date: e.Date, - Time: e.Time, - Week: e.Week, - Weekday: e.Weekday, - }) - } - return result -} - -func (h *TaskHandler) getEmptyClassrooms(ctx context.Context, payload []byte) ([]byte, error) { - var req pb.GetEmptyClassroomPayload - if err := json.Unmarshal(payload, &req); err != nil { - return nil, apperr.New("parse_payload", err, "解析任务载荷失败") - } - - log := slog.With("username", req.Username, "semester", req.Semester) - log.Info("获取空教室任务") - - httpClient, err := createHTTPClient() - if err != nil { - return nil, err - } - - cfg := config.Get() - setTWFIDCookie(httpClient, cfg.BaseURL, "") - - if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil { - return nil, err - } - - classrooms, err := parser.FetchEmptyClassrooms(httpClient, req.Semester, req.WeekStart, req.WeekEnd, req.CampusCode) - if err != nil { - return nil, apperr.New("fetch_empty_classrooms", err, "获取空教室失败") - } - - pbClassrooms := convertEmptyClassrooms(classrooms) - - result := &pb.EmptyClassroomResultData{ - StudentId: req.Username, - Semester: req.Semester, - Classrooms: pbClassrooms, - } - - log.Info("空教室获取成功", "classroom_count", len(pbClassrooms)) - return json.Marshal(result) -} - -func convertEmptyClassrooms(entries []parser.EmptyClassroomEntry) []*pb.EmptyClassroomEntry { - result := make([]*pb.EmptyClassroomEntry, 0, len(entries)) - for _, e := range entries { - result = append(result, &pb.EmptyClassroomEntry{ - Classroom: e.Classroom, - Weekday: e.Weekday, - Periods: e.Periods, - Term: e.Term, - Weeks: e.Weeks, - }) - } - return result -} diff --git a/grpc/handler_classroom.go b/grpc/handler_classroom.go new file mode 100644 index 0000000..30dcd01 --- /dev/null +++ b/grpc/handler_classroom.go @@ -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) +} diff --git a/grpc/handler_exams.go b/grpc/handler_exams.go new file mode 100644 index 0000000..c350959 --- /dev/null +++ b/grpc/handler_exams.go @@ -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) +} diff --git a/grpc/handler_grades.go b/grpc/handler_grades.go new file mode 100644 index 0000000..7bad40f --- /dev/null +++ b/grpc/handler_grades.go @@ -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) +} diff --git a/grpc/handler_helpers.go b/grpc/handler_helpers.go new file mode 100644 index 0000000..adbc745 --- /dev/null +++ b/grpc/handler_helpers.go @@ -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 +} diff --git a/grpc/handler_login.go b/grpc/handler_login.go new file mode 100644 index 0000000..ad98ea0 --- /dev/null +++ b/grpc/handler_login.go @@ -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": "登录成功", + }) +} diff --git a/grpc/handler_schedule.go b/grpc/handler_schedule.go new file mode 100644 index 0000000..8bd4d54 --- /dev/null +++ b/grpc/handler_schedule.go @@ -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) +} diff --git a/main.go b/main.go index 49617a0..9094cd7 100644 --- a/main.go +++ b/main.go @@ -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() + runGRPCClient(*grpcAddr) +} + +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": "任务执行成功"}) -} +} \ No newline at end of file diff --git a/models/api.go b/models/api.go deleted file mode 100644 index 29981b7..0000000 --- a/models/api.go +++ /dev/null @@ -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"` -} diff --git a/parser/empty_classroom.go b/parser/empty_classroom.go index 8b5b01d..6d8b32d 100644 --- a/parser/empty_classroom.go +++ b/parser/empty_classroom.go @@ -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) } diff --git a/parser/exam.go b/parser/exam.go index 37bf36a..8359e21 100644 --- a/parser/exam.go +++ b/parser/exam.go @@ -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 diff --git a/parser/schedule.go b/parser/schedule.go index 8b2309b..d660c11 100644 --- a/parser/schedule.go +++ b/parser/schedule.go @@ -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 // 会拆成: diff --git a/parser/score.go b/parser/score.go index c242959..14f2868 100644 --- a/parser/score.go +++ b/parser/score.go @@ -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 @@ -254,4 +264,4 @@ func safeGet(arr []string, idx int) string { return arr[idx] } return "" -} \ No newline at end of file +} diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index c6f818d..96ce1cf 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -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) -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 75744e0..3b242e3 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -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...) -} diff --git a/service/output.go b/service/output.go deleted file mode 100644 index 4c37746..0000000 --- a/service/output.go +++ /dev/null @@ -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==================================================") -} diff --git a/service/recognizer.go b/service/recognizer.go deleted file mode 100644 index d6d7d91..0000000 --- a/service/recognizer.go +++ /dev/null @@ -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) -} diff --git a/service/schedule.go b/service/schedule.go deleted file mode 100644 index 6935b49..0000000 --- a/service/schedule.go +++ /dev/null @@ -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) -}