refactor: 重构代码架构并使用 Modern Go 特性

- 重命名模块 jwts -> schedule_converter
- 新增 service 包,统一业务逻辑层
- 新增 pkg/logger 包,使用 slog 结构化日志
- 新增 pkg/errors 包,统一错误处理
- 拆分 captcha 包,图片识别移至 service/recognizer
- 清理 models 包,只保留数据结构定义
- 消除 grpc/handler.go 中的重复代码
- 简化 main.go,使用 slog 和 context
- 配置支持单例模式和结构化配置
- 使用 any 替代 interface{}
- 使用 sync.Map 替代 map + RWMutex
This commit is contained in:
lafay
2026-03-16 13:40:53 +08:00
parent 294151cd40
commit 74438c1f7f
20 changed files with 1183 additions and 602 deletions

View File

@@ -0,0 +1,291 @@
---
name: use-modern-go
description: Apply modern Go syntax guidelines based on project's Go version. Use when user ask for modern Go code guidelines.
---
# Modern Go Guidelines
## Detected Go Version
!`grep -rh "^go " --include="go.mod" . 2>/dev/null | cut -d' ' -f2 | sort | uniq -c | sort -nr | head -1 | xargs | cut -d' ' -f2 | grep . || echo unknown`
## How to Use This Skill
DO NOT search for go.mod files or try to detect the version yourself. Use ONLY the version shown above.
**If version detected (not "unknown"):**
- Say: "This project is using Go X.XX, so Ill stick to modern Go best practices and freely use language features up to and including this version. If youd prefer a different target version, just let me know."
- Do NOT list features, do NOT ask for confirmation
**If version is "unknown":**
- Say: "Could not detect Go version in this repository"
- Use AskUserQuestion: "Which Go version should I target?" → [1.23] / [1.24] / [1.25] / [1.26]
**When writing Go code**, use ALL features from this document up to the target version:
- Prefer modern built-ins and packages (`slices`, `maps`, `cmp`) over legacy patterns
- Never use features from newer Go versions than the target
- Never use outdated patterns when a modern alternative is available
---
## Features by Go Version
### Go 1.0+
- `time.Since`: `time.Since(start)` instead of `time.Now().Sub(start)`
### Go 1.8+
- `time.Until`: `time.Until(deadline)` instead of `deadline.Sub(time.Now())`
### Go 1.13+
- `errors.Is`: `errors.Is(err, target)` instead of `err == target` (works with wrapped errors)
### Go 1.18+
- `any`: Use `any` instead of `interface{}`
- `bytes.Cut`: `before, after, found := bytes.Cut(b, sep)` instead of Index+slice
- `strings.Cut`: `before, after, found := strings.Cut(s, sep)`
### Go 1.19+
- `fmt.Appendf`: `buf = fmt.Appendf(buf, "x=%d", x)` instead of `[]byte(fmt.Sprintf(...))`
- `atomic.Bool`/`atomic.Int64`/`atomic.Pointer[T]`: Type-safe atomics instead of `atomic.StoreInt32`
```go
var flag atomic.Bool
flag.Store(true)
if flag.Load() { ... }
var ptr atomic.Pointer[Config]
ptr.Store(cfg)
```
### Go 1.20+
- `strings.Clone`: `strings.Clone(s)` to copy string without sharing memory
- `bytes.Clone`: `bytes.Clone(b)` to copy byte slice
- `strings.CutPrefix/CutSuffix`: `if rest, ok := strings.CutPrefix(s, "pre:"); ok { ... }`
- `errors.Join`: `errors.Join(err1, err2)` to combine multiple errors
- `context.WithCancelCause`: `ctx, cancel := context.WithCancelCause(parent)` then `cancel(err)`
- `context.Cause`: `context.Cause(ctx)` to get the error that caused cancellation
### Go 1.21+
**Built-ins:**
- `min`/`max`: `max(a, b)` instead of if/else comparisons
- `clear`: `clear(m)` to delete all map entries, `clear(s)` to zero slice elements
**slices package:**
- `slices.Contains`: `slices.Contains(items, x)` instead of manual loops
- `slices.Index`: `slices.Index(items, x)` returns index (-1 if not found)
- `slices.IndexFunc`: `slices.IndexFunc(items, func(item T) bool { return item.ID == id })`
- `slices.SortFunc`: `slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })`
- `slices.Sort`: `slices.Sort(items)` for ordered types
- `slices.Max`/`slices.Min`: `slices.Max(items)` instead of manual loop
- `slices.Reverse`: `slices.Reverse(items)` instead of manual swap loop
- `slices.Compact`: `slices.Compact(items)` removes consecutive duplicates in-place
- `slices.Clip`: `slices.Clip(s)` removes unused capacity
- `slices.Clone`: `slices.Clone(s)` creates a copy
**maps package:**
- `maps.Clone`: `maps.Clone(m)` instead of manual map iteration
- `maps.Copy`: `maps.Copy(dst, src)` copies entries from src to dst
- `maps.DeleteFunc`: `maps.DeleteFunc(m, func(k K, v V) bool { return condition })`
**sync package:**
- `sync.OnceFunc`: `f := sync.OnceFunc(func() { ... })` instead of `sync.Once` + wrapper
- `sync.OnceValue`: `getter := sync.OnceValue(func() T { return computeValue() })`
**context package:**
- `context.AfterFunc`: `stop := context.AfterFunc(ctx, cleanup)` runs cleanup on cancellation
- `context.WithTimeoutCause`: `ctx, cancel := context.WithTimeoutCause(parent, d, err)`
- `context.WithDeadlineCause`: Similar with deadline instead of duration
### Go 1.22+
**Loops:**
- `for i := range n`: `for i := range len(items)` instead of `for i := 0; i < len(items); i++`
- Loop variables are now safe to capture in goroutines (each iteration has its own copy)
**cmp package:**
- `cmp.Or`: `cmp.Or(flag, env, config, "default")` returns first non-zero value
```go
// Instead of:
name := os.Getenv("NAME")
if name == "" {
name = "default"
}
// Use:
name := cmp.Or(os.Getenv("NAME"), "default")
```
**reflect package:**
- `reflect.TypeFor`: `reflect.TypeFor[T]()` instead of `reflect.TypeOf((*T)(nil)).Elem()`
**net/http:**
- Enhanced `http.ServeMux` patterns: `mux.HandleFunc("GET /api/{id}", handler)` with method and path params
- `r.PathValue("id")` to get path parameters
### Go 1.23+
- `maps.Keys(m)` / `maps.Values(m)` return iterators
- `slices.Collect(iter)` not manual loop to build slice from iterator
- `slices.Sorted(iter)` to collect and sort in one step
```go
keys := slices.Collect(maps.Keys(m)) // not: for k := range m { keys = append(keys, k) }
sortedKeys := slices.Sorted(maps.Keys(m)) // collect + sort
for k := range maps.Keys(m) { process(k) } // iterate directly
```
**time package**
- `time.Tick`: Use `time.Tick` freely — as of Go 1.23, the garbage collector can recover unreferenced tickers, even if they haven't been stopped. The Stop method is no longer necessary to help the garbage collector. There is no longer any reason to prefer NewTicker when Tick will do.
### Go 1.24+
- `t.Context()` not `context.WithCancel(context.Background())` in tests.
ALWAYS use t.Context() when a test function needs a context.
Before:
```go
func TestFoo(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := doSomething(ctx)
}
```
After:
```go
func TestFoo(t *testing.T) {
ctx := t.Context()
result := doSomething(ctx)
}
```
- `omitzero` not `omitempty` in JSON struct tags.
ALWAYS use omitzero for time.Duration, time.Time, structs, slices, maps.
Before:
```go
type Config struct {
Timeout time.Duration `json:"timeout,omitempty"` // doesn't work for Duration!
}
```
After:
```go
type Config struct {
Timeout time.Duration `json:"timeout,omitzero"`
}
```
- `b.Loop()` not `for i := 0; i < b.N; i++` in benchmarks.
ALWAYS use b.Loop() for the main loop in benchmark functions.
Before:
```go
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
doWork()
}
}
```
After:
```go
func BenchmarkFoo(b *testing.B) {
for b.Loop() {
doWork()
}
}
```
- `strings.SplitSeq` not `strings.Split` when iterating.
ALWAYS use SplitSeq/FieldsSeq when iterating over split results in a for-range loop.
Before:
```go
for _, part := range strings.Split(s, ",") {
process(part)
}
```
After:
```go
for part := range strings.SplitSeq(s, ",") {
process(part)
}
```
Also: `strings.FieldsSeq`, `bytes.SplitSeq`, `bytes.FieldsSeq`.
### Go 1.25+
- `wg.Go(fn)` not `wg.Add(1)` + `go func() { defer wg.Done(); ... }()`.
ALWAYS use wg.Go() when spawning goroutines with sync.WaitGroup.
Before:
```go
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait()
```
After:
```go
var wg sync.WaitGroup
for _, item := range items {
wg.Go(func() {
process(item)
})
}
wg.Wait()
```
### Go 1.26+
- `new(val)` not `x := val; &x` — returns pointer to any value.
Go 1.26 extends new() to accept expressions, not just types.
Type is inferred: new(0) → *int, new("s") → *string, new(T{}) → *T.
DO NOT use `x := val; &x` pattern — always use new(val) directly.
DO NOT use redundant casts like new(int(0)) — just write new(0).
Common use case: struct fields with pointer types.
Before:
```go
timeout := 30
debug := true
cfg := Config{
Timeout: &timeout,
Debug: &debug,
}
```
After:
```go
cfg := Config{
Timeout: new(30), // *int
Debug: new(true), // *bool
}
```
- `errors.AsType[T](err)` not `errors.As(err, &target)`.
ALWAYS use errors.AsType when checking if error matches a specific type.
Before:
```go
var pathErr *os.PathError
if errors.As(err, &pathErr) {
handle(pathErr)
}
```
After:
```go
if pathErr, ok := errors.AsType[*os.PathError](err); ok {
handle(pathErr)
}
```

View File

@@ -8,8 +8,8 @@ import (
"regexp"
"strings"
"jwts/client"
"jwts/config"
"schedule_converter/client"
"schedule_converter/config"
)
// 登录

View File

@@ -1,26 +1,18 @@
package captcha
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
"jwts/client"
"jwts/config"
"jwts/models"
"schedule_converter/client"
"schedule_converter/config"
"github.com/PuerkitoBio/goquery"
)
// 保存验证码图片
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
fmt.Println("[2] 获取验证码...")
@@ -50,16 +42,13 @@ func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
return body, nil
}
// 识别验证码 - 使用固定验证码用于测试
func Recognize(imagePath string) (string, error) {
fmt.Println("[3] 使用固定验证码...")
// 固定验证码用于测试
return "....", nil
}
// 获取并识别验证码
func GetAndRecognize(client *http.Client) (string, error) {
_, err := SaveCaptchaImage(client)
func GetAndRecognize(httpClient *http.Client) (string, error) {
_, err := SaveCaptchaImage(httpClient)
if err != nil {
return "", err
}
@@ -73,196 +62,6 @@ func GetAndRecognize(client *http.Client) (string, error) {
return captchaCode, nil
}
// 用于解析HTML登录页面
func ParseHTML(html string) (*goquery.Document, error) {
return goquery.NewDocumentFromReader(strings.NewReader(html))
}
// RecognizeScheduleFromFile 从图片文件识别课表
func RecognizeScheduleFromFile(imagePath string) ([]models.Course, error) {
fmt.Printf("[识别] 正在读取图片: %s\n", imagePath)
imageData, err := os.ReadFile(imagePath)
if err != nil {
return nil, fmt.Errorf("读取图片失败: %v", err)
}
imageBase64 := base64.StdEncoding.EncodeToString(imageData)
fmt.Printf("[识别] 图片大小: %d bytes\n", len(imageData))
return callScheduleAPI(imageBase64)
}
// RecognizeScheduleFromBytes 从图片数据识别课表
func RecognizeScheduleFromBytes(imageData []byte) ([]models.Course, error) {
fmt.Printf("[识别] 正在处理图片数据, 大小: %d bytes\n", len(imageData))
imageBase64 := base64.StdEncoding.EncodeToString(imageData)
return callScheduleAPI(imageBase64)
}
// RecognizeScheduleFromBase64 从base64数据识别课表
func RecognizeScheduleFromBase64(imageBase64 string) ([]models.Course, error) {
fmt.Printf("[识别] 正在处理base64图片数据, 长度: %d\n", len(imageBase64))
return callScheduleAPI(imageBase64)
}
// callScheduleAPI 调用API识别课表带重试机制
func callScheduleAPI(imageBase64 string) ([]models.Course, error) {
maxRetries := 3
for retry := 0; retry < maxRetries; retry++ {
if retry > 0 {
fmt.Printf("[识别] 第 %d 次重试...\n", retry+1)
time.Sleep(5 * time.Second)
}
courses, err := callScheduleAPISingle(imageBase64)
if err == nil {
return courses, nil
}
fmt.Printf("[识别] 尝试 %d/%d 失败: %v\n", retry+1, maxRetries, err)
if !strings.Contains(err.Error(), "504") {
return nil, err
}
}
return nil, fmt.Errorf("已达到最大重试次数")
}
// callScheduleAPISingle 单次API调用
func callScheduleAPISingle(imageBase64 string) ([]models.Course, error) {
if config.RecognizerAPIKey == "" {
return nil, fmt.Errorf("RECOGNIZER_API_KEY 未配置")
}
falseVal := false
zero := 0
apiReq := models.APIRequest{
Model: config.RecognizerModel,
MaxTokens: 4000,
EnableThinking: &falseVal,
ThinkingBudget: &zero,
Messages: []models.Message{
{
Role: "user",
Content: []models.ContentItem{
{
Type: "text",
Text: `请解析以下课表图片提取所有课程信息并以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数组不要任何其他文字。`,
},
{
Type: "image_url",
ImageURL: &models.ImageURL{
URL: "data:image/png;base64," + imageBase64,
},
},
},
},
},
}
jsonData, err := json.Marshal(apiReq)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequest("POST", config.RecognizerAPIURL, bytes.NewBuffer(jsonData))
if err != nil {
return nil, err
}
httpReq.Header = http.Header{
"Content-Type": []string{"application/json"},
"Authorization": []string{"Bearer " + config.RecognizerAPIKey},
}
fmt.Println("[识别] 正在调用API进行识别...")
resp, err := client.RecognizerClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("API调用失败: %v", 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
err = json.NewDecoder(resp.Body).Decode(&apiResp)
if err != nil {
return nil, fmt.Errorf("API响应解析失败: %v", err)
}
if len(apiResp.Choices) == 0 {
return nil, fmt.Errorf("API返回为空")
}
var resultText string
switch c := apiResp.Choices[0].Message.Content.(type) {
case string:
resultText = c
case []interface{}:
for _, item := range c {
if itemMap, ok := item.(map[string]interface{}); ok {
if text, ok := itemMap["text"].(string); ok {
resultText = text
break
}
}
}
}
fmt.Println("[识别] API响应已接收正在解析...")
fmt.Printf("[识别] 原始响应: %s\n", resultText)
var jsonMatch string
jsonBlockMatch := regexp.MustCompile("(?s)```json\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText)
if len(jsonBlockMatch) > 1 {
jsonMatch = jsonBlockMatch[1]
fmt.Println("[识别] 从json代码块中提取JSON")
} else {
jsonBlockMatch = regexp.MustCompile("(?s)```\\s*(\\[[\\s\\S]*?\\])\\s*```").FindStringSubmatch(resultText)
if len(jsonBlockMatch) > 1 {
jsonMatch = jsonBlockMatch[1]
fmt.Println("[识别] 从代码块中提取JSON")
} else {
jsonMatch = regexp.MustCompile(`(\[[\s\S]*\])`).FindString(resultText)
if jsonMatch == "" {
return nil, fmt.Errorf("未找到JSON数据原始响应: %s", resultText)
}
fmt.Println("[识别] 从文本中提取JSON")
}
}
var courses []models.Course
err = json.Unmarshal([]byte(jsonMatch), &courses)
if err != nil {
return nil, fmt.Errorf("JSON解析失败: %v, 数据: %s", err, jsonMatch)
}
fmt.Printf("[识别] 成功解析 %d 门课程\n", len(courses))
return courses, nil
}
func init() {
if config.RecognizerAPIKey == "" {
log.Println("警告: RECOGNIZER_API_KEY 未设置,图片识别功能将无法使用")
}
log.Println("captcha模块已初始化")
}

View File

@@ -1,36 +1,40 @@
package client
import (
"fmt"
"log"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"
"sync"
"jwts/config"
"schedule_converter/config"
)
var Client *http.Client
var RecognizerClient *http.Client
var (
Client *http.Client
RecognizerClient *http.Client
once sync.Once
)
func InitClient() {
once.Do(func() {
jar, err := cookiejar.New(nil)
if err != nil {
log.Fatalf("Failed to create cookie jar: %v", err)
slog.Error("创建 cookie jar 失败", "error", err)
panic(err)
}
Client = &http.Client{
Jar: jar,
Client = &http.Client{Jar: jar}
cfg := config.Get()
RecognizerClient = &http.Client{
Timeout: cfg.Recognizer.Timeout,
}
RecognizerClient = &http.Client{
Timeout: 120 * time.Second,
}
slog.Info("HTTP 客户端初始化完成")
})
}
// 设置请求头
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"},
@@ -39,56 +43,58 @@ func GetHeaders() http.Header {
}
}
// 获取带Content-Type的请求头
func GetHeadersWithContentType(contentType string) http.Header {
headers := GetHeaders()
headers["Content-Type"] = []string{contentType}
headers.Set("Content-Type", contentType)
return headers
}
// 步骤1: 获取初始Cookie
func GetInitialCookies() error {
fmt.Println("[1] 访问登录页面获取初始Cookie...")
return GetInitialCookiesWithClient(Client)
}
req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil)
func GetInitialCookiesWithClient(httpClient *http.Client) error {
slog.Info("访问登录页面获取初始Cookie")
cfg := config.Get()
req, err := http.NewRequest("GET", cfg.BaseURL+"/loginNOCAS", nil)
if err != nil {
return err
}
req.Header = GetHeaders()
resp, err := Client.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
fmt.Printf(" 登录页面状态码: %d\n", resp.StatusCode)
PrintCookies()
slog.Info("登录页面访问完成", "status_code", resp.StatusCode)
logCookies(httpClient)
return nil
}
// 打印Cookies
func PrintCookies() {
fmt.Printf(" 当前Cookies: ")
for _, cookie := range Client.Jar.Cookies(parseURL(config.BaseURL)) {
fmt.Printf("%s=%s; ", cookie.Name, cookie.Value)
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)
}
fmt.Println()
slog.Debug("当前Cookies", "cookies", strings.Join(cookieStrs, "; "))
}
// 解析URL
func parseURL(rawURL string) *url.URL {
u, _ := url.Parse(rawURL)
return u
}
// 检查登录是否成功
func CheckLoginSuccess(body string) bool {
return strings.Contains(body, "本科教学管理服务系统")
}
// 检查是否验证码错误
func IsCaptchaError(body string) bool {
return strings.Contains(body, "验证码")
}

View File

@@ -1,27 +1,101 @@
package config
import (
"log/slog"
"os"
"strconv"
"sync"
"time"
)
// 配置变量
type Config struct {
BaseURL string
Username string
Password string
Semester string
TWFID string
Recognizer RecognizerConfig
}
type RecognizerConfig struct {
APIURL string
APIKey string
Model string
Timeout time.Duration
}
var (
BaseURL = getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn")
Username = getEnv("JWTS_USERNAME", "")
Password = getEnv("JWTS_PASSWORD", "")
Semester = getEnv("JWTS_SEMESTER", "2025-20262")
TWFID = getEnv("JWTS_TWFID", "")
RecognizerAPIURL = getEnv("RECOGNIZER_API_URL", "https://api.littlelan.cn/v1/chat/completions")
RecognizerAPIKey = getEnv("RECOGNIZER_API_KEY", "")
RecognizerModel = getEnv("RECOGNIZER_MODEL", "qwen3.5-plus")
cfg *Config
cfgOnce sync.Once
)
// getEnv 从环境变量获取配置,提供默认值
func Load() *Config {
cfgOnce.Do(func() {
cfg = &Config{
BaseURL: getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn"),
Username: getEnv("JWTS_USERNAME", ""),
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
}
func Get() *Config {
if cfg == nil {
return Load()
}
return cfg
}
func getEnv(key, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
val := os.Getenv(key)
if val == "" {
return defaultValue
}
return value
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
)

2
go.mod
View File

@@ -1,4 +1,4 @@
module jwts
module schedule_converter
go 1.25.7

View File

@@ -8,7 +8,7 @@ import (
"sync"
"time"
pb "jwts/proto/runner"
pb "schedule_converter/proto/runner"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"

View File

@@ -1,262 +1,161 @@
package grpc
import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"time"
pb "jwts/proto/runner"
pb "schedule_converter/proto/runner"
"jwts/auth"
"jwts/captcha"
"jwts/config"
"jwts/models"
"jwts/parser"
"schedule_converter/auth"
"schedule_converter/captcha"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"schedule_converter/parser"
apperr "schedule_converter/pkg/errors"
)
// TaskHandler 任务处理器
type TaskHandler struct {
cancelledTasks map[string]bool
mu sync.RWMutex
cancelledTasks sync.Map
}
// NewTaskHandler 创建新的任务处理器
func NewTaskHandler() *TaskHandler {
return &TaskHandler{
cancelledTasks: make(map[string]bool),
}
return &TaskHandler{}
}
// Execute 执行任务
func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
log := slog.With("task_id", task.TaskId, "task_type", task.Type.String())
log.Info("开始执行任务")
result := &pb.TaskResult{
TaskId: task.TaskId,
CompletedAt: time.Now().UnixMilli(),
}
// 检查任务是否已取消
if h.IsCancelled(task.TaskId) {
result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED
result.ErrorMessage = "任务已取消"
log.Info("任务已取消")
return result
}
var err error
var data []byte
switch task.Type {
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
data, err := h.getSchedule(task.Payload)
if err != nil {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
} else {
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
}
data, err = h.getSchedule(context.Background(), task.Payload)
case pb.TaskType_TASK_TYPE_LOGIN:
data, err := h.login(task.Payload)
if err != nil {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
} else {
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
}
data, err = h.login(context.Background(), task.Payload)
default:
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type)
log.Warn("未知的任务类型")
return result
}
if err != nil {
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
result.ErrorMessage = err.Error()
log.Error("任务执行失败", "error", err)
} else {
result.Status = pb.TaskStatus_TASK_STATUS_SUCCESS
result.Data = data
log.Info("任务执行成功")
}
return result
}
// CancelTask 取消任务
func (h *TaskHandler) CancelTask(taskID string) {
h.mu.Lock()
defer h.mu.Unlock()
h.cancelledTasks[taskID] = true
log.Printf("任务已标记为取消: %s", taskID)
h.cancelledTasks.Store(taskID, true)
slog.Info("任务已标记为取消", "task_id", taskID)
}
// IsCancelled 检查任务是否已取消
func (h *TaskHandler) IsCancelled(taskID string) bool {
h.mu.RLock()
defer h.mu.RUnlock()
return h.cancelledTasks[taskID]
v, ok := h.cancelledTasks.Load(taskID)
return ok && v.(bool)
}
// getSchedule 获取课表
func (h *TaskHandler) getSchedule(payload []byte) ([]byte, error) {
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, fmt.Errorf("解析任务载荷失败: %w", err)
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
semester := strings.TrimSpace(req.Semester)
if semester == "" {
semester = config.Semester
}
log := slog.With("username", req.Username, "semester", req.Semester)
log.Info("获取课表任务")
log.Printf("获取课表: 用户=%s, 学期=%s", req.Username, semester)
// 创建新的 HTTP 客户端
jar, err := cookiejar.New(nil)
httpClient, err := createHTTPClient()
if err != nil {
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
}
httpClient := &http.Client{
Jar: jar,
return nil, err
}
// 设置 TWFID cookie (使用默认配置)
setTWFIDCookie(httpClient, config.BaseURL, "")
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, "")
// 步骤1: 获取初始 Cookie
if err := getInitialCookies(httpClient); err != nil {
return nil, fmt.Errorf("获取初始 Cookie 失败: %w", err)
}
// 重试登录
maxRetries := 3
var loginSuccess bool
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("尝试登录 (第%d次)...", attempt)
// 获取并识别验证码
captchaCode, err := captcha.GetAndRecognize(httpClient)
courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester)
if err != nil {
log.Printf(" 验证码获取失败: %v", err)
continue
return nil, err
}
// 登录
success, err := loginWithCredentials(httpClient, captchaCode, req.Username, req.Password)
if err != nil {
log.Printf(" 登录失败: %v", err)
continue
}
if success {
loginSuccess = true
break
}
log.Println(" 登录失败3秒后重试...")
time.Sleep(3 * time.Second)
}
if !loginSuccess {
return nil, fmt.Errorf("登录失败,已重试 %d 次", maxRetries)
}
// 获取课表
courses, err := parser.FetchScheduleWithClient(httpClient, semester)
if err != nil {
return nil, fmt.Errorf("获取课表失败: %w", err)
}
// 构建结果
result := &pb.ScheduleResultData{
StudentId: req.Username,
Semester: semester,
Semester: req.Semester,
Courses: convertCourses(courses),
}
log.Info("课表获取成功", "course_count", len(courses))
return json.Marshal(result)
}
// login 登录验证
func (h *TaskHandler) login(payload []byte) ([]byte, error) {
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, fmt.Errorf("解析任务载荷失败: %w", err)
return nil, apperr.New("parse_payload", err, "解析任务载荷失败")
}
log.Printf("登录验证: 用户=%s", req.Username)
log := slog.With("username", req.Username)
log.Info("登录任务")
// 创建新的 HTTP 客户端
jar, err := cookiejar.New(nil)
httpClient, err := createHTTPClient()
if err != nil {
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
}
httpClient := &http.Client{
Jar: jar,
return nil, err
}
// 设置 TWFID cookie
setTWFIDCookie(httpClient, config.BaseURL, req.Twfid)
cfg := config.Get()
setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid)
// 步骤1: 获取初始 Cookie
if err := getInitialCookies(httpClient); err != nil {
return nil, fmt.Errorf("获取初始 Cookie 失败: %w", err)
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
return nil, err
}
// 重试登录
maxRetries := 3
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("尝试登录 (第%d次)...", attempt)
// 获取并识别验证码
captchaCode, err := captcha.GetAndRecognize(httpClient)
if err != nil {
log.Printf(" 验证码获取失败: %v", err)
continue
}
// 登录
success, err := loginWithCredentials(httpClient, captchaCode, req.Username, req.Password)
if err != nil {
log.Printf(" 登录失败: %v", err)
continue
}
if success {
return json.Marshal(map[string]interface{}{
log.Info("登录成功")
return json.Marshal(map[string]any{
"success": true,
"message": "登录成功",
})
}
log.Println(" 登录失败3秒后重试...")
time.Sleep(3 * time.Second)
}
return nil, fmt.Errorf("登录失败,已重试 %d 次", maxRetries)
}
// getInitialCookies 获取初始 Cookie
func getInitialCookies(httpClient *http.Client) error {
log.Println("[1] 访问登录页面获取初始 Cookie...")
req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil)
func createHTTPClient() (*http.Client, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return err
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
}
req.Header = getHeaders()
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
log.Printf(" 登录页面状态码: %d", resp.StatusCode)
return nil
return &http.Client{Jar: jar}, nil
}
// loginWithCredentials 使用凭据登录
func loginWithCredentials(httpClient *http.Client, captchaCode, username, password string) (bool, error) {
return auth.LoginWithClient(httpClient, captchaCode, username, password)
}
// setTWFIDCookie 设置 TWFID Cookie
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
cfg := config.Get()
if twfid == "" {
twfid = config.TWFID
twfid = cfg.TWFID
}
u, _ := url.Parse(baseURL)
httpClient.Jar.SetCookies(u, []*http.Cookie{
@@ -264,38 +163,84 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
})
}
// getHeaders 获取请求头
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"},
"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"},
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)
}
// convertCourses 转换课程格式
func convertCourses(courses []models.Course) []*pb.Course {
var result []*pb.Course
for _, c := range courses {
var schedule []*pb.ScheduleTime
for _, s := range c.Schedule {
schedule = append(schedule, &pb.ScheduleTime{
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 = append(result, &pb.Course{
}
result[i] = &pb.Course{
Name: c.Name,
Teacher: c.Teacher,
Room: c.Room,
Schedule: schedule,
})
}
}
return result
}
// convertToInt32 将 []int 转换为 []int32
func convertToInt32(arr []int) []int32 {
result := make([]int32, len(arr))
for i, v := range arr {

180
main.go
View File

@@ -4,20 +4,18 @@ import (
"context"
"flag"
"io"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"jwts/auth"
"jwts/captcha"
"jwts/client"
"jwts/config"
grpcClient "jwts/grpc"
"jwts/models"
"jwts/parser"
"schedule_converter/client"
"schedule_converter/config"
grpcClient "schedule_converter/grpc"
"schedule_converter/pkg/logger"
"schedule_converter/service"
"github.com/gin-gonic/gin"
)
@@ -25,8 +23,17 @@ import (
func main() {
mode := flag.String("mode", "http", "运行模式: http 或 grpc")
grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址")
debug := flag.Bool("debug", false, "启用调试模式")
flag.Parse()
if *debug {
logger.SetLevel(slog.LevelDebug)
logger.SetTextHandler()
}
cfg := config.Load()
slog.Info("配置加载完成", "mode", *mode, "base_url", cfg.BaseURL)
if *mode == "grpc" {
runGRPCClient(*grpcAddr)
} else {
@@ -34,7 +41,6 @@ func main() {
}
}
// runGRPCClient 运行 gRPC 客户端模式
func runGRPCClient(serverAddr string) {
runnerID := os.Getenv("RUNNER_ID")
if runnerID == "" {
@@ -47,7 +53,7 @@ func runGRPCClient(serverAddr string) {
version = "1.0.0"
}
log.Printf("启动 gRPC 客户端, Runner ID: %s, 版本: %s", runnerID, version)
slog.Info("启动 gRPC 客户端", "runner_id", runnerID, "version", version)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -56,8 +62,8 @@ func runGRPCClient(serverAddr string) {
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("正在关闭...")
sig := <-sigChan
slog.Info("收到信号,正在关闭...", "signal", sig)
cancel()
}()
@@ -66,65 +72,97 @@ func runGRPCClient(serverAddr string) {
for {
select {
case <-ctx.Done():
log.Println("客户端已停止")
slog.Info("客户端已停止")
return
default:
}
client := grpcClient.NewClient(serverAddr, runnerID, version)
c := grpcClient.NewClient(serverAddr, runnerID, version)
handler := grpcClient.NewTaskHandler()
client.SetHandler(handler)
c.SetHandler(handler)
if err := client.Connect(ctx); err != nil {
log.Printf("连接失败: %v, %v 后重试...", err, reconnectInterval)
if err := c.Connect(ctx); err != nil {
slog.Warn("连接失败,稍后重试", "error", err, "retry_after", reconnectInterval)
time.Sleep(reconnectInterval)
continue
}
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
go client.HeartbeatLoop(heartbeatCtx, 30*time.Second)
go c.HeartbeatLoop(heartbeatCtx, 30*time.Second)
err := client.Run(ctx)
err := c.Run(ctx)
heartbeatCancel()
client.Close()
c.Close()
if ctx.Err() != nil {
return
}
if err != nil {
log.Printf("连接断开: %v, %v 后重连...", err, reconnectInterval)
slog.Warn("连接断开,稍后重连", "error", err, "retry_after", reconnectInterval)
} else {
log.Printf("连接已关闭, %v 后重连...", reconnectInterval)
slog.Info("连接已关闭,稍后重连", "retry_after", reconnectInterval)
}
time.Sleep(reconnectInterval)
}
}
// runHTTPServer 运行 HTTP 服务器模式
func runHTTPServer() {
// 初始化客户端
client.InitClient()
r := gin.Default()
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",
})
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// 课表图片识别接口
r.POST("/recognize", func(c *gin.Context) {
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()
log.Printf("收到图片上传: %s, 大小: %d bytes", header.Filename, header.Size)
slog.Info("收到图片上传", "filename", header.Filename, "size", header.Size)
if header.Size > 10*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"})
@@ -133,21 +171,23 @@ func runHTTPServer() {
imageData, err := io.ReadAll(file)
if err != nil {
slog.Error("读取图片失败", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()})
return
}
courses, err := captcha.RecognizeScheduleFromBytes(imageData)
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
}
models.PrintCourses(courses)
service.PrintCourses(courses)
err = models.SaveToJSON(courses, "schedule_result.json")
if err != nil {
log.Printf("保存JSON失败: %v", err)
if err := service.SaveToJSON(courses, "schedule_result.json"); err != nil {
slog.Warn("保存JSON失败", "error", err)
}
c.JSON(http.StatusOK, gin.H{
@@ -155,72 +195,24 @@ func runHTTPServer() {
"courses": courses,
"count": len(courses),
})
})
}
// 任务处理接口
r.POST("/task", func(c *gin.Context) {
// 这里可以根据请求体参数决定执行什么任务
// 目前默认执行原有的登录和获取课表逻辑
if config.Username == "" || config.Password == "" {
func handleTask(c *gin.Context) {
cfg := config.Get()
if cfg.Username == "" || cfg.Password == "" {
slog.Warn("环境变量未设置")
c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"})
return
}
// 执行任务
err := runTask()
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": "任务执行成功"})
})
// 启动服务
log.Println("Server starting on :8080")
if err := r.Run(":8080"); err != nil {
log.Fatalf("Failed to run server: %v", err)
}
}
func runTask() error {
// 步骤1: 获取初始Cookie
err := client.GetInitialCookies()
if err != nil {
log.Printf("获取Cookie失败: %v", err)
return err
}
// 重试登录
maxRetries := 3
for attempt := 1; attempt <= maxRetries; attempt++ {
log.Printf("尝试登录 (第%d次)...", attempt)
// 获取并识别验证码
captchaCode, err := captcha.GetAndRecognize(client.Client)
if err != nil {
log.Printf(" 验证码获取失败: %v", err)
continue
}
// 登录
success, err := auth.Login(captchaCode)
if err != nil {
log.Printf(" 登录失败: %v", err)
continue
}
if success {
// 获取课表
_, err = parser.FetchSchedule(client.Client)
if err != nil {
log.Printf("获取课表失败: %v", err)
}
return nil
}
log.Println(" 登录失败3秒后重试...")
}
return nil
}

View File

@@ -12,7 +12,7 @@ type APIRequest struct {
// Message 消息结构
type Message struct {
Role string `json:"role"`
Content interface{} `json:"content"`
Content any `json:"content"`
}
// ContentItem 内容项

View File

@@ -11,9 +11,9 @@ import (
"strconv"
"strings"
"jwts/client"
"jwts/config"
"jwts/models"
"schedule_converter/client"
"schedule_converter/config"
"schedule_converter/models"
"github.com/PuerkitoBio/goquery"
)
@@ -54,12 +54,6 @@ func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models
return nil, err
}
if err := os.WriteFile("grkb.html", body, 0644); err != nil {
fmt.Printf(" 警告: 无法保存课表页面: %v\n", err)
} else {
fmt.Println(" 课表页面已保存到: grkb.html")
}
// 解析课表
courses, err := ParseScheduleHTML(string(body))
if err != nil {

View File

@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"jwts/models"
"schedule_converter/models"
)
func TestParseScheduleHTML_FromTestHTML_MergedWeeks(t *testing.T) {

58
pkg/errors/errors.go Normal file
View File

@@ -0,0 +1,58 @@
package errors
import (
"errors"
"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")
)
type ScheduleError struct {
Op string
Err error
Msg string
}
func (e *ScheduleError) Error() string {
if e.Msg != "" {
return fmt.Sprintf("%s: %s: %v", e.Op, e.Msg, e.Err)
}
return fmt.Sprintf("%s: %v", e.Op, e.Err)
}
func (e *ScheduleError) Unwrap() error {
return e.Err
}
func New(op string, err error, msg string) error {
return &ScheduleError{
Op: op,
Err: err,
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)
}

55
pkg/logger/logger.go Normal file
View File

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

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v6.33.1
// protoc-gen-go v1.36.9
// protoc v3.19.4
// source: proto/runner/runner.proto
package runner
@@ -1735,7 +1735,7 @@ const file_proto_runner_runner_proto_rawDesc = "" +
"\x13TASK_STATUS_TIMEOUT\x10\x03\x12\x19\n" +
"\x15TASK_STATUS_CANCELLED\x10\x042H\n" +
"\tRunnerHub\x12;\n" +
"\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B\x1aZ\x18jwts/proto/runner;runnerb\x06proto3"
"\aConnect\x12\x15.runner.StreamMessage\x1a\x15.runner.StreamMessage(\x010\x01B(Z&schedule_converter/proto/runner;runnerb\x06proto3"
var (
file_proto_runner_runner_proto_rawDescOnce sync.Once

View File

@@ -2,7 +2,7 @@ syntax = "proto3";
package runner;
option go_package = "jwts/proto/runner;runner";
option go_package = "schedule_converter/proto/runner;runner";
// ============================================================
// 服务定义

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.1
// - protoc v6.33.1
// - protoc-gen-go-grpc v1.5.1
// - protoc v3.19.4
// source: proto/runner/runner.proto
package runner
@@ -76,7 +76,7 @@ type RunnerHubServer interface {
type UnimplementedRunnerHubServer struct{}
func (UnimplementedRunnerHubServer) Connect(grpc.BidiStreamingServer[StreamMessage, StreamMessage]) error {
return status.Error(codes.Unimplemented, "method Connect not implemented")
return status.Errorf(codes.Unimplemented, "method Connect not implemented")
}
func (UnimplementedRunnerHubServer) mustEmbedUnimplementedRunnerHubServer() {}
func (UnimplementedRunnerHubServer) testEmbeddedByValue() {}
@@ -89,7 +89,7 @@ type UnsafeRunnerHubServer interface {
}
func RegisterRunnerHubServer(s grpc.ServiceRegistrar, srv RunnerHubServer) {
// If the following call panics, it indicates UnimplementedRunnerHubServer was
// If the following call pancis, it indicates UnimplementedRunnerHubServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.

View File

@@ -1,12 +1,14 @@
package models
package service
import (
"encoding/json"
"fmt"
"os"
"schedule_converter/models"
)
func SaveToJSON(courses []Course, filename string) error {
func SaveToJSON(courses []models.Course, filename string) error {
jsonData, err := json.MarshalIndent(courses, "", " ")
if err != nil {
return err
@@ -21,7 +23,7 @@ func SaveToJSON(courses []Course, filename string) error {
return nil
}
func PrintCourses(courses []Course) {
func PrintCourses(courses []models.Course) {
fmt.Println("\n==================================================")
fmt.Println("识别结果:")
fmt.Println("==================================================")

241
service/recognizer.go Normal file
View File

@@ -0,0 +1,241 @@
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)
}

124
service/schedule.go Normal file
View File

@@ -0,0 +1,124 @@
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)
}