refactor(grpc): restructure task handling and migrate to slog
All checks were successful
Build / build (push) Successful in 5m28s
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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "本科教学管理服务系统")
|
||||
}
|
||||
|
||||
@@ -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
28
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
|
||||
)
|
||||
|
||||
79
go.sum
79
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=
|
||||
|
||||
124
grpc/client.go
124
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
|
||||
|
||||
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 {
|
||||
@@ -108,69 +124,57 @@ 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
91
grpc/convert.go
Normal 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
|
||||
}
|
||||
406
grpc/handler.go
406
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,38 +35,45 @@ 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
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -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
51
grpc/handler_classroom.go
Normal 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
51
grpc/handler_exams.go
Normal 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
66
grpc/handler_grades.go
Normal 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
108
grpc/handler_helpers.go
Normal 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
39
grpc/handler_login.go
Normal 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
43
grpc/handler_schedule.go
Normal 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
172
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()
|
||||
}
|
||||
|
||||
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": "任务执行成功"})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// 会拆成:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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==================================================")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user