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:
291
.opencode/skills/use-modern-go/SKILL.md
Normal file
291
.opencode/skills/use-modern-go/SKILL.md
Normal 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 I’ll stick to modern Go best practices and freely use language features up to and including this version. If you’d 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)
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"jwts/client"
|
"schedule_converter/client"
|
||||||
"jwts/config"
|
"schedule_converter/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
|
|||||||
@@ -1,26 +1,18 @@
|
|||||||
package captcha
|
package captcha
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"jwts/client"
|
"schedule_converter/client"
|
||||||
"jwts/config"
|
"schedule_converter/config"
|
||||||
"jwts/models"
|
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 保存验证码图片
|
|
||||||
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
|
func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
|
||||||
fmt.Println("[2] 获取验证码...")
|
fmt.Println("[2] 获取验证码...")
|
||||||
|
|
||||||
@@ -50,16 +42,13 @@ func SaveCaptchaImage(httpClient *http.Client) ([]byte, error) {
|
|||||||
return body, nil
|
return body, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 识别验证码 - 使用固定验证码用于测试
|
|
||||||
func Recognize(imagePath string) (string, error) {
|
func Recognize(imagePath string) (string, error) {
|
||||||
fmt.Println("[3] 使用固定验证码...")
|
fmt.Println("[3] 使用固定验证码...")
|
||||||
// 固定验证码用于测试
|
|
||||||
return "....", nil
|
return "....", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取并识别验证码
|
func GetAndRecognize(httpClient *http.Client) (string, error) {
|
||||||
func GetAndRecognize(client *http.Client) (string, error) {
|
_, err := SaveCaptchaImage(httpClient)
|
||||||
_, err := SaveCaptchaImage(client)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -73,196 +62,6 @@ func GetAndRecognize(client *http.Client) (string, error) {
|
|||||||
return captchaCode, nil
|
return captchaCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用于解析HTML(登录页面)
|
|
||||||
func ParseHTML(html string) (*goquery.Document, error) {
|
func ParseHTML(html string) (*goquery.Document, error) {
|
||||||
return goquery.NewDocumentFromReader(strings.NewReader(html))
|
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模块已初始化")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,36 +1,40 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log/slog"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"sync"
|
||||||
|
|
||||||
"jwts/config"
|
"schedule_converter/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Client *http.Client
|
var (
|
||||||
|
Client *http.Client
|
||||||
var RecognizerClient *http.Client
|
RecognizerClient *http.Client
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
func InitClient() {
|
func InitClient() {
|
||||||
|
once.Do(func() {
|
||||||
jar, err := cookiejar.New(nil)
|
jar, err := cookiejar.New(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create cookie jar: %v", err)
|
slog.Error("创建 cookie jar 失败", "error", err)
|
||||||
|
panic(err)
|
||||||
}
|
}
|
||||||
Client = &http.Client{
|
Client = &http.Client{Jar: jar}
|
||||||
Jar: jar,
|
|
||||||
|
cfg := config.Get()
|
||||||
|
RecognizerClient = &http.Client{
|
||||||
|
Timeout: cfg.Recognizer.Timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
RecognizerClient = &http.Client{
|
slog.Info("HTTP 客户端初始化完成")
|
||||||
Timeout: 120 * time.Second,
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置请求头
|
|
||||||
func GetHeaders() http.Header {
|
func GetHeaders() http.Header {
|
||||||
return 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{"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 {
|
func GetHeadersWithContentType(contentType string) http.Header {
|
||||||
headers := GetHeaders()
|
headers := GetHeaders()
|
||||||
headers["Content-Type"] = []string{contentType}
|
headers.Set("Content-Type", contentType)
|
||||||
return headers
|
return headers
|
||||||
}
|
}
|
||||||
|
|
||||||
// 步骤1: 获取初始Cookie
|
|
||||||
func GetInitialCookies() error {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header = GetHeaders()
|
req.Header = GetHeaders()
|
||||||
|
|
||||||
resp, err := Client.Do(req)
|
resp, err := httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
fmt.Printf(" 登录页面状态码: %d\n", resp.StatusCode)
|
slog.Info("登录页面访问完成", "status_code", resp.StatusCode)
|
||||||
PrintCookies()
|
logCookies(httpClient)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打印Cookies
|
func logCookies(httpClient *http.Client) {
|
||||||
func PrintCookies() {
|
cfg := config.Get()
|
||||||
fmt.Printf(" 当前Cookies: ")
|
cookies := httpClient.Jar.Cookies(parseURL(cfg.BaseURL))
|
||||||
for _, cookie := range Client.Jar.Cookies(parseURL(config.BaseURL)) {
|
|
||||||
fmt.Printf("%s=%s; ", cookie.Name, cookie.Value)
|
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 {
|
func parseURL(rawURL string) *url.URL {
|
||||||
u, _ := url.Parse(rawURL)
|
u, _ := url.Parse(rawURL)
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查登录是否成功
|
|
||||||
func CheckLoginSuccess(body string) bool {
|
func CheckLoginSuccess(body string) bool {
|
||||||
return strings.Contains(body, "本科教学管理服务系统")
|
return strings.Contains(body, "本科教学管理服务系统")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否验证码错误
|
|
||||||
func IsCaptchaError(body string) bool {
|
func IsCaptchaError(body string) bool {
|
||||||
return strings.Contains(body, "验证码")
|
return strings.Contains(body, "验证码")
|
||||||
}
|
}
|
||||||
|
|||||||
102
config/config.go
102
config/config.go
@@ -1,27 +1,101 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"os"
|
"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 (
|
var (
|
||||||
BaseURL = getEnv("JWTS_BASE_URL", "http://jwts.hitwh.edu.cn")
|
cfg *Config
|
||||||
Username = getEnv("JWTS_USERNAME", "")
|
cfgOnce sync.Once
|
||||||
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")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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 {
|
func getEnv(key, defaultValue string) string {
|
||||||
value := os.Getenv(key)
|
val := os.Getenv(key)
|
||||||
if value == "" {
|
if val == "" {
|
||||||
return defaultValue
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "jwts/proto/runner"
|
pb "schedule_converter/proto/runner"
|
||||||
|
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
|||||||
313
grpc/handler.go
313
grpc/handler.go
@@ -1,262 +1,161 @@
|
|||||||
package grpc
|
package grpc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
pb "jwts/proto/runner"
|
pb "schedule_converter/proto/runner"
|
||||||
|
|
||||||
"jwts/auth"
|
"schedule_converter/auth"
|
||||||
"jwts/captcha"
|
"schedule_converter/captcha"
|
||||||
"jwts/config"
|
"schedule_converter/client"
|
||||||
"jwts/models"
|
"schedule_converter/config"
|
||||||
"jwts/parser"
|
"schedule_converter/models"
|
||||||
|
"schedule_converter/parser"
|
||||||
|
apperr "schedule_converter/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskHandler 任务处理器
|
|
||||||
type TaskHandler struct {
|
type TaskHandler struct {
|
||||||
cancelledTasks map[string]bool
|
cancelledTasks sync.Map
|
||||||
mu sync.RWMutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTaskHandler 创建新的任务处理器
|
|
||||||
func NewTaskHandler() *TaskHandler {
|
func NewTaskHandler() *TaskHandler {
|
||||||
return &TaskHandler{
|
return &TaskHandler{}
|
||||||
cancelledTasks: make(map[string]bool),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute 执行任务
|
|
||||||
func (h *TaskHandler) Execute(task *pb.Task) *pb.TaskResult {
|
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{
|
result := &pb.TaskResult{
|
||||||
TaskId: task.TaskId,
|
TaskId: task.TaskId,
|
||||||
CompletedAt: time.Now().UnixMilli(),
|
CompletedAt: time.Now().UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查任务是否已取消
|
|
||||||
if h.IsCancelled(task.TaskId) {
|
if h.IsCancelled(task.TaskId) {
|
||||||
result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED
|
result.Status = pb.TaskStatus_TASK_STATUS_CANCELLED
|
||||||
result.ErrorMessage = "任务已取消"
|
result.ErrorMessage = "任务已取消"
|
||||||
|
log.Info("任务已取消")
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
var data []byte
|
||||||
|
|
||||||
switch task.Type {
|
switch task.Type {
|
||||||
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
|
case pb.TaskType_TASK_TYPE_GET_SCHEDULE:
|
||||||
data, err := h.getSchedule(task.Payload)
|
data, err = h.getSchedule(context.Background(), 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
|
|
||||||
}
|
|
||||||
case pb.TaskType_TASK_TYPE_LOGIN:
|
case pb.TaskType_TASK_TYPE_LOGIN:
|
||||||
data, err := h.login(task.Payload)
|
data, err = h.login(context.Background(), 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
|
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
|
result.Status = pb.TaskStatus_TASK_STATUS_FAILED
|
||||||
result.ErrorMessage = fmt.Sprintf("未知的任务类型: %v", task.Type)
|
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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// CancelTask 取消任务
|
|
||||||
func (h *TaskHandler) CancelTask(taskID string) {
|
func (h *TaskHandler) CancelTask(taskID string) {
|
||||||
h.mu.Lock()
|
h.cancelledTasks.Store(taskID, true)
|
||||||
defer h.mu.Unlock()
|
slog.Info("任务已标记为取消", "task_id", taskID)
|
||||||
h.cancelledTasks[taskID] = true
|
|
||||||
log.Printf("任务已标记为取消: %s", taskID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsCancelled 检查任务是否已取消
|
|
||||||
func (h *TaskHandler) IsCancelled(taskID string) bool {
|
func (h *TaskHandler) IsCancelled(taskID string) bool {
|
||||||
h.mu.RLock()
|
v, ok := h.cancelledTasks.Load(taskID)
|
||||||
defer h.mu.RUnlock()
|
return ok && v.(bool)
|
||||||
return h.cancelledTasks[taskID]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSchedule 获取课表
|
func (h *TaskHandler) getSchedule(ctx context.Context, payload []byte) ([]byte, error) {
|
||||||
func (h *TaskHandler) getSchedule(payload []byte) ([]byte, error) {
|
|
||||||
var req pb.GetSchedulePayload
|
var req pb.GetSchedulePayload
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
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)
|
log := slog.With("username", req.Username, "semester", req.Semester)
|
||||||
if semester == "" {
|
log.Info("获取课表任务")
|
||||||
semester = config.Semester
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("获取课表: 用户=%s, 学期=%s", req.Username, semester)
|
httpClient, err := createHTTPClient()
|
||||||
|
|
||||||
// 创建新的 HTTP 客户端
|
|
||||||
jar, err := cookiejar.New(nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Jar: jar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 TWFID cookie (使用默认配置)
|
cfg := config.Get()
|
||||||
setTWFIDCookie(httpClient, config.BaseURL, "")
|
setTWFIDCookie(httpClient, cfg.BaseURL, "")
|
||||||
|
|
||||||
// 步骤1: 获取初始 Cookie
|
courses, err := executeLoginAndFetchSchedule(ctx, httpClient, req.Username, req.Password, req.Semester)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf(" 验证码获取失败: %v", err)
|
return nil, err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录
|
|
||||||
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{
|
result := &pb.ScheduleResultData{
|
||||||
StudentId: req.Username,
|
StudentId: req.Username,
|
||||||
Semester: semester,
|
Semester: req.Semester,
|
||||||
Courses: convertCourses(courses),
|
Courses: convertCourses(courses),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Info("课表获取成功", "course_count", len(courses))
|
||||||
return json.Marshal(result)
|
return json.Marshal(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// login 登录验证
|
func (h *TaskHandler) login(ctx context.Context, payload []byte) ([]byte, error) {
|
||||||
func (h *TaskHandler) login(payload []byte) ([]byte, error) {
|
|
||||||
var req pb.LoginPayload
|
var req pb.LoginPayload
|
||||||
if err := json.Unmarshal(payload, &req); err != nil {
|
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 客户端
|
httpClient, err := createHTTPClient()
|
||||||
jar, err := cookiejar.New(nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("创建 cookie jar 失败: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
httpClient := &http.Client{
|
|
||||||
Jar: jar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置 TWFID cookie
|
cfg := config.Get()
|
||||||
setTWFIDCookie(httpClient, config.BaseURL, req.Twfid)
|
setTWFIDCookie(httpClient, cfg.BaseURL, req.Twfid)
|
||||||
|
|
||||||
// 步骤1: 获取初始 Cookie
|
if err := executeLogin(ctx, httpClient, req.Username, req.Password); err != nil {
|
||||||
if err := getInitialCookies(httpClient); err != nil {
|
return nil, err
|
||||||
return nil, fmt.Errorf("获取初始 Cookie 失败: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重试登录
|
log.Info("登录成功")
|
||||||
maxRetries := 3
|
return json.Marshal(map[string]any{
|
||||||
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{}{
|
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "登录成功",
|
"message": "登录成功",
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
log.Println(" 登录失败,3秒后重试...")
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("登录失败,已重试 %d 次", maxRetries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInitialCookies 获取初始 Cookie
|
func createHTTPClient() (*http.Client, error) {
|
||||||
func getInitialCookies(httpClient *http.Client) error {
|
jar, err := cookiejar.New(nil)
|
||||||
log.Println("[1] 访问登录页面获取初始 Cookie...")
|
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", config.BaseURL+"/loginNOCAS", nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, apperr.New("create_jar", err, "创建 cookie jar 失败")
|
||||||
}
|
}
|
||||||
req.Header = getHeaders()
|
return &http.Client{Jar: jar}, nil
|
||||||
|
|
||||||
resp, err := httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
log.Printf(" 登录页面状态码: %d", resp.StatusCode)
|
|
||||||
return 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) {
|
func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
|
||||||
|
cfg := config.Get()
|
||||||
if twfid == "" {
|
if twfid == "" {
|
||||||
twfid = config.TWFID
|
twfid = cfg.TWFID
|
||||||
}
|
}
|
||||||
u, _ := url.Parse(baseURL)
|
u, _ := url.Parse(baseURL)
|
||||||
httpClient.Jar.SetCookies(u, []*http.Cookie{
|
httpClient.Jar.SetCookies(u, []*http.Cookie{
|
||||||
@@ -264,38 +163,84 @@ func setTWFIDCookie(httpClient *http.Client, baseURL, twfid string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getHeaders 获取请求头
|
func executeLogin(ctx context.Context, httpClient *http.Client, username, password string) error {
|
||||||
func getHeaders() http.Header {
|
log := slog.With("username", username)
|
||||||
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"},
|
if err := client.GetInitialCookiesWithClient(httpClient); err != nil {
|
||||||
"Accept": []string{"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"},
|
return apperr.New("get_cookies", err, "获取初始 Cookie 失败")
|
||||||
"Accept-Language": []string{"zh-CN,zh;q=0.9,en;q=0.8"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func convertCourses(courses []models.Course) []*pb.Course {
|
||||||
var result []*pb.Course
|
result := make([]*pb.Course, len(courses))
|
||||||
for _, c := range courses {
|
for i, c := range courses {
|
||||||
var schedule []*pb.ScheduleTime
|
schedule := make([]*pb.ScheduleTime, len(c.Schedule))
|
||||||
for _, s := range c.Schedule {
|
for j, s := range c.Schedule {
|
||||||
schedule = append(schedule, &pb.ScheduleTime{
|
schedule[j] = &pb.ScheduleTime{
|
||||||
Day: s.Day,
|
Day: s.Day,
|
||||||
Sections: convertToInt32(s.Section),
|
Sections: convertToInt32(s.Section),
|
||||||
Weeks: convertToInt32(s.Weeks),
|
Weeks: convertToInt32(s.Weeks),
|
||||||
})
|
|
||||||
}
|
}
|
||||||
result = append(result, &pb.Course{
|
}
|
||||||
|
result[i] = &pb.Course{
|
||||||
Name: c.Name,
|
Name: c.Name,
|
||||||
Teacher: c.Teacher,
|
Teacher: c.Teacher,
|
||||||
Room: c.Room,
|
Room: c.Room,
|
||||||
Schedule: schedule,
|
Schedule: schedule,
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertToInt32 将 []int 转换为 []int32
|
|
||||||
func convertToInt32(arr []int) []int32 {
|
func convertToInt32(arr []int) []int32 {
|
||||||
result := make([]int32, len(arr))
|
result := make([]int32, len(arr))
|
||||||
for i, v := range arr {
|
for i, v := range arr {
|
||||||
|
|||||||
180
main.go
180
main.go
@@ -4,20 +4,18 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"jwts/auth"
|
"schedule_converter/client"
|
||||||
"jwts/captcha"
|
"schedule_converter/config"
|
||||||
"jwts/client"
|
grpcClient "schedule_converter/grpc"
|
||||||
"jwts/config"
|
"schedule_converter/pkg/logger"
|
||||||
grpcClient "jwts/grpc"
|
"schedule_converter/service"
|
||||||
"jwts/models"
|
|
||||||
"jwts/parser"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -25,8 +23,17 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
mode := flag.String("mode", "http", "运行模式: http 或 grpc")
|
mode := flag.String("mode", "http", "运行模式: http 或 grpc")
|
||||||
grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址")
|
grpcAddr := flag.String("grpc-addr", "localhost:50051", "gRPC 服务器地址")
|
||||||
|
debug := flag.Bool("debug", false, "启用调试模式")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
if *debug {
|
||||||
|
logger.SetLevel(slog.LevelDebug)
|
||||||
|
logger.SetTextHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
slog.Info("配置加载完成", "mode", *mode, "base_url", cfg.BaseURL)
|
||||||
|
|
||||||
if *mode == "grpc" {
|
if *mode == "grpc" {
|
||||||
runGRPCClient(*grpcAddr)
|
runGRPCClient(*grpcAddr)
|
||||||
} else {
|
} else {
|
||||||
@@ -34,7 +41,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runGRPCClient 运行 gRPC 客户端模式
|
|
||||||
func runGRPCClient(serverAddr string) {
|
func runGRPCClient(serverAddr string) {
|
||||||
runnerID := os.Getenv("RUNNER_ID")
|
runnerID := os.Getenv("RUNNER_ID")
|
||||||
if runnerID == "" {
|
if runnerID == "" {
|
||||||
@@ -47,7 +53,7 @@ func runGRPCClient(serverAddr string) {
|
|||||||
version = "1.0.0"
|
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())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -56,8 +62,8 @@ func runGRPCClient(serverAddr string) {
|
|||||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-sigChan
|
sig := <-sigChan
|
||||||
log.Println("正在关闭...")
|
slog.Info("收到信号,正在关闭...", "signal", sig)
|
||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -66,65 +72,97 @@ func runGRPCClient(serverAddr string) {
|
|||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Println("客户端已停止")
|
slog.Info("客户端已停止")
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
client := grpcClient.NewClient(serverAddr, runnerID, version)
|
c := grpcClient.NewClient(serverAddr, runnerID, version)
|
||||||
handler := grpcClient.NewTaskHandler()
|
handler := grpcClient.NewTaskHandler()
|
||||||
client.SetHandler(handler)
|
c.SetHandler(handler)
|
||||||
|
|
||||||
if err := client.Connect(ctx); err != nil {
|
if err := c.Connect(ctx); err != nil {
|
||||||
log.Printf("连接失败: %v, %v 后重试...", err, reconnectInterval)
|
slog.Warn("连接失败,稍后重试", "error", err, "retry_after", reconnectInterval)
|
||||||
time.Sleep(reconnectInterval)
|
time.Sleep(reconnectInterval)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
heartbeatCtx, heartbeatCancel := context.WithCancel(ctx)
|
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()
|
heartbeatCancel()
|
||||||
client.Close()
|
c.Close()
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("连接断开: %v, %v 后重连...", err, reconnectInterval)
|
slog.Warn("连接断开,稍后重连", "error", err, "retry_after", reconnectInterval)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("连接已关闭, %v 后重连...", reconnectInterval)
|
slog.Info("连接已关闭,稍后重连", "retry_after", reconnectInterval)
|
||||||
}
|
}
|
||||||
time.Sleep(reconnectInterval)
|
time.Sleep(reconnectInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runHTTPServer 运行 HTTP 服务器模式
|
|
||||||
func runHTTPServer() {
|
func runHTTPServer() {
|
||||||
// 初始化客户端
|
|
||||||
client.InitClient()
|
client.InitClient()
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
r.Use(requestLogger())
|
||||||
|
|
||||||
// 健康检查接口
|
|
||||||
r.GET("/ping", func(c *gin.Context) {
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||||
"message": "pong",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 课表图片识别接口
|
r.POST("/recognize", handleRecognize)
|
||||||
r.POST("/recognize", func(c *gin.Context) {
|
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")
|
file, header, err := c.Request.FormFile("image")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Warn("图片上传失败", "error", err)
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请上传图片文件", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
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 {
|
if header.Size > 10*1024*1024 {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "图片文件过大", "max_size": "10MB"})
|
||||||
@@ -133,21 +171,23 @@ func runHTTPServer() {
|
|||||||
|
|
||||||
imageData, err := io.ReadAll(file)
|
imageData, err := io.ReadAll(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("读取图片失败", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取图片失败", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
courses, err := captcha.RecognizeScheduleFromBytes(imageData)
|
recognizer := service.NewRecognizerService()
|
||||||
|
courses, err := recognizer.RecognizeFromBytes(c.Request.Context(), imageData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("识别失败", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "识别失败", "details": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
models.PrintCourses(courses)
|
service.PrintCourses(courses)
|
||||||
|
|
||||||
err = models.SaveToJSON(courses, "schedule_result.json")
|
if err := service.SaveToJSON(courses, "schedule_result.json"); err != nil {
|
||||||
if err != nil {
|
slog.Warn("保存JSON失败", "error", err)
|
||||||
log.Printf("保存JSON失败: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
@@ -155,72 +195,24 @@ func runHTTPServer() {
|
|||||||
"courses": courses,
|
"courses": courses,
|
||||||
"count": len(courses),
|
"count": len(courses),
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
|
|
||||||
// 任务处理接口
|
func handleTask(c *gin.Context) {
|
||||||
r.POST("/task", func(c *gin.Context) {
|
cfg := config.Get()
|
||||||
// 这里可以根据请求体参数决定执行什么任务
|
if cfg.Username == "" || cfg.Password == "" {
|
||||||
// 目前默认执行原有的登录和获取课表逻辑
|
slog.Warn("环境变量未设置")
|
||||||
if config.Username == "" || config.Password == "" {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "环境变量未设置"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行任务
|
scheduleSvc := service.NewScheduleService()
|
||||||
err := runTask()
|
_, err := scheduleSvc.FetchSchedule(c.Request.Context(), cfg.Username, cfg.Password, cfg.Semester)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("任务执行失败", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Info("任务执行成功")
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "任务执行成功"})
|
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type APIRequest struct {
|
|||||||
// Message 消息结构
|
// Message 消息结构
|
||||||
type Message struct {
|
type Message struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content interface{} `json:"content"`
|
Content any `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ContentItem 内容项
|
// ContentItem 内容项
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"jwts/client"
|
"schedule_converter/client"
|
||||||
"jwts/config"
|
"schedule_converter/config"
|
||||||
"jwts/models"
|
"schedule_converter/models"
|
||||||
|
|
||||||
"github.com/PuerkitoBio/goquery"
|
"github.com/PuerkitoBio/goquery"
|
||||||
)
|
)
|
||||||
@@ -54,12 +54,6 @@ func FetchScheduleWithClient(httpClient *http.Client, semester string) ([]models
|
|||||||
return nil, err
|
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))
|
courses, err := ParseScheduleHTML(string(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"jwts/models"
|
"schedule_converter/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseScheduleHTML_FromTestHTML_MergedWeeks(t *testing.T) {
|
func TestParseScheduleHTML_FromTestHTML_MergedWeeks(t *testing.T) {
|
||||||
|
|||||||
58
pkg/errors/errors.go
Normal file
58
pkg/errors/errors.go
Normal 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
55
pkg/logger/logger.go
Normal 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...)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.36.9
|
||||||
// protoc v6.33.1
|
// protoc v3.19.4
|
||||||
// source: proto/runner/runner.proto
|
// source: proto/runner/runner.proto
|
||||||
|
|
||||||
package runner
|
package runner
|
||||||
@@ -1735,7 +1735,7 @@ const file_proto_runner_runner_proto_rawDesc = "" +
|
|||||||
"\x13TASK_STATUS_TIMEOUT\x10\x03\x12\x19\n" +
|
"\x13TASK_STATUS_TIMEOUT\x10\x03\x12\x19\n" +
|
||||||
"\x15TASK_STATUS_CANCELLED\x10\x042H\n" +
|
"\x15TASK_STATUS_CANCELLED\x10\x042H\n" +
|
||||||
"\tRunnerHub\x12;\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 (
|
var (
|
||||||
file_proto_runner_runner_proto_rawDescOnce sync.Once
|
file_proto_runner_runner_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ syntax = "proto3";
|
|||||||
|
|
||||||
package runner;
|
package runner;
|
||||||
|
|
||||||
option go_package = "jwts/proto/runner;runner";
|
option go_package = "schedule_converter/proto/runner;runner";
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 服务定义
|
// 服务定义
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v6.33.1
|
// - protoc v3.19.4
|
||||||
// source: proto/runner/runner.proto
|
// source: proto/runner/runner.proto
|
||||||
|
|
||||||
package runner
|
package runner
|
||||||
@@ -76,7 +76,7 @@ type RunnerHubServer interface {
|
|||||||
type UnimplementedRunnerHubServer struct{}
|
type UnimplementedRunnerHubServer struct{}
|
||||||
|
|
||||||
func (UnimplementedRunnerHubServer) Connect(grpc.BidiStreamingServer[StreamMessage, StreamMessage]) error {
|
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) mustEmbedUnimplementedRunnerHubServer() {}
|
||||||
func (UnimplementedRunnerHubServer) testEmbeddedByValue() {}
|
func (UnimplementedRunnerHubServer) testEmbeddedByValue() {}
|
||||||
@@ -89,7 +89,7 @@ type UnsafeRunnerHubServer interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RegisterRunnerHubServer(s grpc.ServiceRegistrar, srv RunnerHubServer) {
|
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
|
// embedded by pointer and is nil. This will cause panics if an
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
// unimplemented method is ever invoked, so we test this at initialization
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
// time to prevent it from happening at runtime later due to I/O.
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package models
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"schedule_converter/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SaveToJSON(courses []Course, filename string) error {
|
func SaveToJSON(courses []models.Course, filename string) error {
|
||||||
jsonData, err := json.MarshalIndent(courses, "", " ")
|
jsonData, err := json.MarshalIndent(courses, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -21,7 +23,7 @@ func SaveToJSON(courses []Course, filename string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintCourses(courses []Course) {
|
func PrintCourses(courses []models.Course) {
|
||||||
fmt.Println("\n==================================================")
|
fmt.Println("\n==================================================")
|
||||||
fmt.Println("识别结果:")
|
fmt.Println("识别结果:")
|
||||||
fmt.Println("==================================================")
|
fmt.Println("==================================================")
|
||||||
241
service/recognizer.go
Normal file
241
service/recognizer.go
Normal 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
124
service/schedule.go
Normal 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user