├── .dockerignore
├── .gitignore
├── .github
├── dependabot.yml
└── workflows
│ ├── build.yml
│ └── codeql.yml
├── docker
└── docker-compose.yml
├── Dockerfile
├── config
├── config.json.example
└── README.md
├── src
├── logger
│ └── logger.go
├── utils
│ └── utils.go
├── register
│ └── register.go
├── api
│ └── api.go
├── flow
│ ├── models.go
│ ├── token_pool.go
│ ├── handler.go
│ └── flow_client.go
├── proxy
│ └── singbox.go
└── pool
│ ├── pool_client.go
│ ├── pool.go
│ └── pool_server.go
├── go.mod
└── README.md
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Git
2 | .git
3 | .gitignore
4 |
5 | # Data
6 | data/
7 |
8 | # Node.js files (no longer needed - registration is pure Go)
9 | node_modules/
10 | package-lock.json
11 | package.json
12 | main.js
13 |
14 | # IDE
15 | .idea/
16 | .vscode/
17 |
18 | # Build artifacts
19 | *.exe
20 | *.dll
21 | *.so
22 | *.dylib
23 |
24 | # Documentation
25 | README.md
26 | LICENSE
27 |
28 | # GitHub
29 | .github/
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries
2 | gemini-gateway
3 | gemini-gateway.exe
4 | *.exe
5 | *.dll
6 | *.so
7 | *.dylib
8 |
9 | # Data
10 | data/
11 | *.json
12 | !config.json.example
13 | !package.json
14 |
15 | # Node
16 | node_modules/
17 | npm-debug.log*
18 |
19 | # IDE
20 | .idea/
21 | .vscode/
22 | *.swp
23 | *.swo
24 |
25 | # OS
26 | *.zip
27 | .DS_Store
28 | Thumbs.db
29 |
30 | # Build
31 | dist/
32 | build/
33 | data/
34 | config.json
35 | package-lock.json
36 | main.js
37 | package.json
38 | flow.txt
39 | config/config.json
40 | business2api
41 | flow/
42 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | business2api:
5 | image: ghcr.io/xxxxteam/business2api:latest
6 | container_name: business2api
7 | restart: unless-stopped
8 | ports:
9 | - "8000:8000"
10 | volumes:
11 | - ./data:/app/data
12 | - ./config.json:/app/config/config.json:ro
13 | environment:
14 | - TZ=Asia/Shanghai
15 | - LISTEN_ADDR=:8000
16 | - DATA_DIR=/app/data
17 | # - PROXY=http://proxy:port
18 | # - API_KEY=your-api-key
19 | healthcheck:
20 | test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/v1/models"]
21 | interval: 30s
22 | timeout: 10s
23 | retries: 3
24 | logging:
25 | driver: json-file
26 | options:
27 | max-size: "10m"
28 | max-file: "3"
29 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM golang:1.23-alpine AS builder
3 |
4 | WORKDIR /app
5 |
6 | # Install build dependencies
7 | RUN apk add --no-cache git ca-certificates
8 |
9 | # Copy go mod files
10 | COPY go.mod go.sum ./
11 | ENV GOTOOLCHAIN=auto
12 | RUN go mod download
13 |
14 | # Copy source code
15 | COPY *.go ./
16 | COPY src/ ./src/
17 |
18 | # Build binary
19 | RUN CGO_ENABLED=0 GOOS=linux go build -tags "with_quic,with_utls" -ldflags="-s -w" -o business2api .
20 |
21 | # Runtime stage
22 | FROM alpine:latest
23 |
24 | WORKDIR /app
25 |
26 | # Install runtime dependencies (Chromium for rod browser automation)
27 | RUN apk add --no-cache \
28 | ca-certificates \
29 | tzdata \
30 | chromium \
31 | nss \
32 | freetype \
33 | harfbuzz \
34 | ttf-freefont \
35 | font-noto-cjk
36 |
37 | # Copy binary from builder
38 | COPY --from=builder /app/business2api .
39 |
40 | # Copy config template if exists
41 | COPY config.json.exampl[e] ./
42 |
43 | # Create data directory
44 | RUN mkdir -p /app/data
45 |
46 | # Environment variables
47 | ENV LISTEN_ADDR=":8000"
48 | ENV DATA_DIR="/app/data"
49 |
50 | EXPOSE 8000
51 |
52 | ENTRYPOINT ["./business2api"]
53 |
--------------------------------------------------------------------------------
/config/config.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "api_keys": ["your-api-key-here"],
3 | "listen_addr": ":8000",
4 | "data_dir": "./data",
5 | "default_config": "",
6 | "debug": false,
7 | "pool": {
8 | "target_count": 50,
9 | "min_count": 10,
10 | "check_interval_minutes": 30,
11 | "register_threads": 1,
12 | "register_headless": true,
13 | "refresh_on_startup": true,
14 | "refresh_cooldown_sec": 240,
15 | "use_cooldown_sec": 15,
16 | "max_fail_count": 3,
17 | "enable_browser_refresh": true,
18 | "browser_refresh_headless": true,
19 | "browser_refresh_max_retry": 1
20 | },
21 | "pool_server": {
22 | "enable": false,
23 | "mode": "local",
24 | "server_addr": "http://server-ip:8000",
25 | "listen_addr": ":8000",
26 | "secret": "your-secret-key",
27 | "target_count": 50,
28 | "client_threads": 2,
29 | "data_dir": "./data",
30 | "expired_action": "delete"
31 | },
32 | "proxy_pool": {
33 | "subscribes": [
34 | "http://example.com/s/example"
35 | ],
36 | "files": [],
37 | "health_check": true,
38 | "check_on_startup": true
39 | },
40 | "flow": {
41 | "enable": false,
42 | "tokens": [],
43 | "timeout": 120,
44 | "poll_interval": 3,
45 | "max_poll_attempts": 500
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "sync"
8 | "time"
9 | )
10 |
11 | // Level 日志级别
12 | type Level int
13 |
14 | const (
15 | LevelError Level = iota
16 | LevelWarn
17 | LevelInfo
18 | LevelDebug
19 | )
20 |
21 | var levelNames = map[Level]string{
22 | LevelError: "ERROR",
23 | LevelWarn: "WARN",
24 | LevelInfo: "INFO",
25 | LevelDebug: "DEBUG",
26 | }
27 |
28 | // Logger 日志记录器
29 | type Logger struct {
30 | level Level
31 | prefix string
32 | mu sync.Mutex
33 | }
34 |
35 | var (
36 | defaultLogger = &Logger{level: LevelInfo}
37 | debugMode = false
38 | )
39 |
40 | // SetDebugMode 设置调试模式
41 | func SetDebugMode(debug bool) {
42 | debugMode = debug
43 | if debug {
44 | defaultLogger.level = LevelDebug
45 | } else {
46 | defaultLogger.level = LevelInfo
47 | }
48 | }
49 |
50 | // IsDebug 是否为调试模式
51 | func IsDebug() bool {
52 | return debugMode
53 | }
54 |
55 | // SetLevel 设置日志级别
56 | func SetLevel(level Level) {
57 | defaultLogger.level = level
58 | }
59 |
60 | func (l *Logger) log(level Level, format string, args ...interface{}) {
61 | if level > l.level {
62 | return
63 | }
64 | l.mu.Lock()
65 | defer l.mu.Unlock()
66 |
67 | timestamp := time.Now().Format("15:04:05")
68 | levelStr := levelNames[level]
69 | msg := fmt.Sprintf(format, args...)
70 |
71 | if l.prefix != "" {
72 | log.Printf("[%s] [%s] [%s] %s", timestamp, levelStr, l.prefix, msg)
73 | } else {
74 | log.Printf("[%s] [%s] %s", timestamp, levelStr, msg)
75 | }
76 | }
77 |
78 | // Error 错误日志(始终输出)
79 | func Error(format string, args ...interface{}) {
80 | defaultLogger.log(LevelError, format, args...)
81 | }
82 |
83 | // Warn 警告日志(始终输出)
84 | func Warn(format string, args ...interface{}) {
85 | defaultLogger.log(LevelWarn, format, args...)
86 | }
87 |
88 | // Info 信息日志(正常模式输出)
89 | func Info(format string, args ...interface{}) {
90 | defaultLogger.log(LevelInfo, format, args...)
91 | }
92 |
93 | // Debug 调试日志(仅debug模式输出)
94 | func Debug(format string, args ...interface{}) {
95 | defaultLogger.log(LevelDebug, format, args...)
96 | }
97 |
98 | // WithPrefix 创建带前缀的子日志器
99 | func WithPrefix(prefix string) *Logger {
100 | return &Logger{
101 | level: defaultLogger.level,
102 | prefix: prefix,
103 | }
104 | }
105 |
106 | func (l *Logger) Error(format string, args ...interface{}) { l.log(LevelError, format, args...) }
107 | func (l *Logger) Warn(format string, args ...interface{}) { l.log(LevelWarn, format, args...) }
108 | func (l *Logger) Info(format string, args ...interface{}) { l.log(LevelInfo, format, args...) }
109 | func (l *Logger) Debug(format string, args ...interface{}) { l.log(LevelDebug, format, args...) }
110 |
111 | func init() {
112 | log.SetFlags(0)
113 | log.SetOutput(os.Stdout)
114 | }
115 |
--------------------------------------------------------------------------------
/src/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "compress/gzip"
6 | "crypto/tls"
7 | "encoding/json"
8 | "io"
9 | "net/http"
10 | "net/url"
11 | "time"
12 |
13 | "business2api/src/logger"
14 | "business2api/src/pool"
15 | )
16 |
17 | // ==================== HTTP 客户端 ====================
18 |
19 | var HTTPClient *http.Client
20 |
21 | // NewHTTPClient 创建 HTTP 客户端
22 | func NewHTTPClient(proxy string) *http.Client {
23 | transport := &http.Transport{
24 | TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
25 | MaxIdleConns: 100,
26 | MaxIdleConnsPerHost: 20,
27 | MaxConnsPerHost: 50,
28 | IdleConnTimeout: 90 * time.Second,
29 | DisableCompression: false,
30 | ForceAttemptHTTP2: true,
31 | }
32 |
33 | if proxy != "" {
34 | proxyURL, err := url.Parse(proxy)
35 | if err == nil {
36 | transport.Proxy = http.ProxyURL(proxyURL)
37 | }
38 | }
39 |
40 | return &http.Client{
41 | Transport: transport,
42 | Timeout: 1800 * time.Second,
43 | }
44 | }
45 |
46 | // InitHTTPClient 初始化全局 HTTP 客户端
47 | func InitHTTPClient(proxy string) {
48 | HTTPClient = NewHTTPClient(proxy)
49 | pool.HTTPClient = HTTPClient
50 | if proxy != "" {
51 | logger.Info("✅ 使用代理: %s", proxy)
52 | }
53 | }
54 |
55 | // ReadResponseBody 读取 HTTP 响应体(支持 gzip)
56 | func ReadResponseBody(resp *http.Response) ([]byte, error) {
57 | var reader io.Reader = resp.Body
58 | if resp.Header.Get("Content-Encoding") == "gzip" {
59 | gzReader, err := gzip.NewReader(resp.Body)
60 | if err != nil {
61 | return nil, err
62 | }
63 | defer gzReader.Close()
64 | reader = gzReader
65 | }
66 | return io.ReadAll(reader)
67 | }
68 |
69 | // ParseNDJSON 解析 NDJSON 格式数据
70 | func ParseNDJSON(data []byte) []map[string]interface{} {
71 | var result []map[string]interface{}
72 | lines := bytes.Split(data, []byte("\n"))
73 | for _, line := range lines {
74 | line = bytes.TrimSpace(line)
75 | if len(line) == 0 {
76 | continue
77 | }
78 | var obj map[string]interface{}
79 | if err := json.Unmarshal(line, &obj); err == nil {
80 | result = append(result, obj)
81 | }
82 | }
83 | return result
84 | }
85 |
86 | // ParseIncompleteJSONArray 解析可能不完整的 JSON 数组
87 | func ParseIncompleteJSONArray(data []byte) []map[string]interface{} {
88 | var result []map[string]interface{}
89 | if err := json.Unmarshal(data, &result); err == nil {
90 | return result
91 | }
92 |
93 | trimmed := bytes.TrimSpace(data)
94 | if len(trimmed) > 0 && trimmed[0] == '[' {
95 | if trimmed[len(trimmed)-1] != ']' {
96 | lastBrace := bytes.LastIndex(trimmed, []byte("}"))
97 | if lastBrace > 0 {
98 | fixed := append(trimmed[:lastBrace+1], ']')
99 | if err := json.Unmarshal(fixed, &result); err == nil {
100 | logger.Warn("JSON 数组不完整,已修复")
101 | return result
102 | }
103 | }
104 | }
105 | }
106 | return nil
107 | }
108 |
109 | // TruncateString 截断字符串
110 | func TruncateString(s string, maxLen int) string {
111 | if len(s) <= maxLen {
112 | return s
113 | }
114 | return s[:maxLen] + "..."
115 | }
116 |
117 | // Min 返回两个整数中的较小值
118 | func Min(a, b int) int {
119 | if a < b {
120 | return a
121 | }
122 | return b
123 | }
124 |
--------------------------------------------------------------------------------
/config/README.md:
--------------------------------------------------------------------------------
1 | # 配置说明
2 |
3 | ## 代理池配置 (`proxy_pool`)
4 |
5 | **内置 xray-core**,支持 vmess、vless、shadowsocks、trojan 等协议,自动转换为本地 socks5 代理。
6 |
7 | ```json
8 | "proxy_pool": {
9 | "proxy": "", // 备用单个代理 (http/socks5 格式)
10 | "subscribes": [ // 订阅链接列表 (支持 base64 编码)
11 | "https://example.com/sub1",
12 | "https://example.com/sub2"
13 | ],
14 | "files": [ // 本地代理文件列表
15 | "./proxies.txt"
16 | ],
17 | "health_check": true, // 是否启用健康检查
18 | "check_on_startup": false // 启动时是否检查所有节点
19 | }
20 | ```
21 |
22 | ### 支持的代理格式
23 |
24 | **代理文件/订阅内容格式** (每行一个):
25 |
26 | ```
27 | # VMess
28 | vmess://eyJ2IjoiMiIsInBzIjoi5ZCN56ewIiwiYWRkIjoic2VydmVyLmNvbSIsInBvcnQiOiI0NDMiLCJpZCI6InV1aWQiLCJhaWQiOiIwIiwic2N5IjoiYXV0byIsIm5ldCI6IndzIiwicGF0aCI6Ii9wYXRoIiwiaG9zdCI6Imhvc3QuY29tIiwidGxzIjoidGxzIn0=
29 |
30 | # VLESS
31 | vless://uuid@server.com:443?type=ws&security=tls&path=/path&host=host.com&sni=sni.com#名称
32 |
33 | # Shadowsocks
34 | ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@server.com:8388#名称
35 |
36 | # Trojan
37 | trojan://password@server.com:443?sni=sni.com#名称
38 |
39 | # 直接代理
40 | http://proxy.com:8080
41 | socks5://127.0.0.1:1080
42 | ```
43 |
44 | ---
45 |
46 | ## 号池配置 (`pool`)
47 |
48 | ```json
49 | "pool": {
50 | "target_count": 50, // 目标账号数量
51 | "min_count": 10, // 最小账号数,低于此值触发注册
52 | "check_interval_minutes": 30, // 检查间隔(分钟)
53 | "register_threads": 1, // 注册线程数
54 | "register_headless": false, // 注册时是否无头模式
55 | "refresh_on_startup": true, // 启动时是否刷新账号
56 | "refresh_cooldown_sec": 240, // 刷新冷却时间(秒)
57 | "use_cooldown_sec": 15, // 使用冷却时间(秒)
58 | "max_fail_count": 3, // 最大失败次数
59 | "enable_browser_refresh": true, // 启用浏览器刷新
60 | "browser_refresh_headless": false, // 浏览器刷新无头模式
61 | "browser_refresh_max_retry": 1 // 浏览器刷新最大重试次数
62 | }
63 | ```
64 |
65 | ---
66 |
67 | ## 号池服务器配置 (`pool_server`)
68 |
69 | ```json
70 | "pool_server": {
71 | "enable": false, // 是否启用
72 | "mode": "local", // 模式: local/server/client
73 | "server_addr": "", // 服务器地址 (客户端模式)
74 | "listen_addr": ":8000", // 监听地址 (服务器模式)
75 | "secret": "", // 认证密钥
76 | "target_count": 50, // 目标账号数
77 | "client_threads": 2, // 客户端并发线程数
78 | "data_dir": "./data", // 数据目录
79 | "expired_action": "delete" // 过期账号处理方式
80 | }
81 | ```
82 |
83 | **模式说明**:
84 | - `local`: 本地模式,独立运行
85 | - `server`: 服务器模式,提供号池服务和API
86 | - `client`: 客户端模式,连接服务器接收注册/续期任务
87 |
88 | **expired_action 说明**:
89 | - `delete`: 删除过期/失败账号
90 | - `refresh`: 尝试浏览器刷新Cookie
91 | - `queue`: 保留在队列等待重试
92 |
93 | ---
94 |
95 | ## Flow 配置 (`flow`)
96 |
97 | ```json
98 | "flow": {
99 | "enable": false, // 是否启用 Flow 视频生成
100 | "tokens": [], // Flow ST Tokens
101 | "proxy": "", // Flow 专用代理
102 | "timeout": 120, // 超时时间(秒)
103 | "poll_interval": 3, // 轮询间隔(秒)
104 | "max_poll_attempts": 500 // 最大轮询次数
105 | }
106 | ```
107 |
108 | ---
109 |
110 | ## 其他配置
111 |
112 | ```json
113 | {
114 | "api_keys": ["key1", "key2"], // API 密钥列表
115 | "listen_addr": ":8000", // 监听地址
116 | "data_dir": "./data", // 数据目录
117 | "default_config": "", // 默认 configId
118 | "debug": false, // 调试模式
119 | "proxy": "http://127.0.0.1:10808" // 全局代理 (兼容旧配置)
120 | }
121 | ```
122 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | branches: [main, master]
6 | tags:
7 | - 'v*'
8 | pull_request:
9 | branches: [main, master]
10 | workflow_dispatch:
11 |
12 | env:
13 | GO_VERSION: '1.23'
14 | REGISTRY: ghcr.io
15 | IMAGE_NAME: ${{ github.repository }}
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 | strategy:
21 | matrix:
22 | goos: [linux, darwin, windows]
23 | goarch: [amd64, arm64]
24 | exclude:
25 | - goos: windows
26 | goarch: arm64
27 |
28 | steps:
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 | with:
32 | fetch-depth: 0
33 |
34 | - name: Setup Go
35 | uses: actions/setup-go@v5
36 | with:
37 | go-version: ${{ env.GO_VERSION }}
38 | cache: false
39 |
40 | - name: Get version
41 | id: version
42 | run: |
43 | if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
44 | VERSION=${GITHUB_REF#refs/tags/v}
45 | else
46 | VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
47 | fi
48 | echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
49 | echo "Building version: $VERSION"
50 |
51 | - name: Build
52 | env:
53 | GOOS: ${{ matrix.goos }}
54 | GOARCH: ${{ matrix.goarch }}
55 | VERSION: ${{ steps.version.outputs.VERSION }}
56 | GOTOOLCHAIN: auto
57 | run: |
58 | EXT=""
59 | if [ "$GOOS" = "windows" ]; then
60 | EXT=".exe"
61 | fi
62 | CGO_ENABLED=0 go build \
63 | -tags "with_quic,with_utls" \
64 | -ldflags="-s -w -X main.Version=${VERSION}" \
65 | -o business2api-${{ matrix.goos }}-${{ matrix.goarch }}${EXT} .
66 |
67 | - name: Prepare package
68 | run: |
69 | mkdir -p dist
70 | mv business2api-* dist/
71 | cp config/config.json.example dist/
72 | cp README.md dist/
73 |
74 | - name: Upload Artifact
75 | uses: actions/upload-artifact@v4
76 | with:
77 | name: business2api-${{ matrix.goos }}-${{ matrix.goarch }}
78 | path: dist/*
79 | retention-days: 30
80 |
81 | docker:
82 | runs-on: ubuntu-latest
83 | needs: build
84 | if: github.event_name == 'push'
85 | permissions:
86 | contents: read
87 | packages: write
88 |
89 | steps:
90 | - name: Checkout
91 | uses: actions/checkout@v4
92 |
93 | - name: Set up QEMU
94 | uses: docker/setup-qemu-action@v3
95 |
96 | - name: Set up Docker Buildx
97 | uses: docker/setup-buildx-action@v3
98 |
99 | - name: Login to GitHub Container Registry
100 | uses: docker/login-action@v3
101 | with:
102 | registry: ${{ env.REGISTRY }}
103 | username: ${{ github.actor }}
104 | password: ${{ secrets.GITHUB_TOKEN }}
105 |
106 | - name: Extract metadata
107 | id: meta
108 | uses: docker/metadata-action@v5
109 | with:
110 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
111 | tags: |
112 | type=ref,event=branch
113 | type=semver,pattern={{version}}
114 | type=semver,pattern={{major}}.{{minor}}
115 | type=sha,prefix=
116 |
117 | - name: Build and push
118 | uses: docker/build-push-action@v5
119 | with:
120 | context: .
121 | platforms: linux/amd64,linux/arm64
122 | push: true
123 | tags: ${{ steps.meta.outputs.tags }}
124 | labels: ${{ steps.meta.outputs.labels }}
125 | cache-from: type=gha
126 | cache-to: type=gha,mode=max
127 |
128 | release:
129 | runs-on: ubuntu-latest
130 | needs: build
131 | if: startsWith(github.ref, 'refs/tags/v')
132 | permissions:
133 | contents: write
134 |
135 | steps:
136 | - name: Download all artifacts
137 | uses: actions/download-artifact@v4
138 | with:
139 | path: artifacts
140 |
141 | - name: Prepare release files
142 | run: |
143 | mkdir -p release
144 | for dir in artifacts/business2api-*/; do
145 | # 获取平台名称
146 | platform=$(basename "$dir")
147 | # 创建临时目录
148 | mkdir -p "tmp/$platform"
149 | cp "$dir"/* "tmp/$platform/"
150 | # 设置可执行权限
151 | for f in "tmp/$platform"/business2api-*; do
152 | if [[ ! "$f" == *.exe ]]; then
153 | chmod +x "$f"
154 | fi
155 | done
156 | # 打包
157 | tar -czvf "release/${platform}.tar.gz" -C tmp "$platform"
158 | rm -rf "tmp/$platform"
159 | done
160 | rm -rf tmp
161 |
162 | - name: Create Release
163 | uses: softprops/action-gh-release@v1
164 | with:
165 | files: release/*
166 | generate_release_notes: true
167 |
--------------------------------------------------------------------------------
/.github/workflows/codeql.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL Advanced"
13 |
14 | on:
15 | push:
16 | branches: [ "master" ]
17 | pull_request:
18 | branches: [ "master" ]
19 | schedule:
20 | - cron: '33 14 * * 3'
21 |
22 | jobs:
23 | analyze:
24 | name: Analyze (${{ matrix.language }})
25 | # Runner size impacts CodeQL analysis time. To learn more, please see:
26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql
27 | # - https://gh.io/supported-runners-and-hardware-resources
28 | # - https://gh.io/using-larger-runners (GitHub.com only)
29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements.
30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
31 | permissions:
32 | # required for all workflows
33 | security-events: write
34 |
35 | # required to fetch internal or private CodeQL packs
36 | packages: read
37 |
38 | # only required for workflows in private repositories
39 | actions: read
40 | contents: read
41 |
42 | strategy:
43 | fail-fast: false
44 | matrix:
45 | include:
46 | - language: actions
47 | build-mode: none
48 | - language: go
49 | build-mode: autobuild
50 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
51 | # Use `c-cpp` to analyze code written in C, C++ or both
52 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
53 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
54 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
55 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
56 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
57 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
58 | steps:
59 | - name: Checkout repository
60 | uses: actions/checkout@v4
61 |
62 | # Add any setup steps before running the `github/codeql-action/init` action.
63 | # This includes steps like installing compilers or runtimes (`actions/setup-node`
64 | # or others). This is typically only required for manual builds.
65 | # - name: Setup runtime (example)
66 | # uses: actions/setup-example@v1
67 |
68 | # Initializes the CodeQL tools for scanning.
69 | - name: Initialize CodeQL
70 | uses: github/codeql-action/init@v4
71 | with:
72 | languages: ${{ matrix.language }}
73 | build-mode: ${{ matrix.build-mode }}
74 | # If you wish to specify custom queries, you can do so here or in a config file.
75 | # By default, queries listed here will override any specified in a config file.
76 | # Prefix the list here with "+" to use these queries and those in the config file.
77 |
78 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
79 | # queries: security-extended,security-and-quality
80 |
81 | # If the analyze step fails for one of the languages you are analyzing with
82 | # "We were unable to automatically build your code", modify the matrix above
83 | # to set the build mode to "manual" for that language. Then modify this step
84 | # to build your code.
85 | # ℹ️ Command-line programs to run using the OS shell.
86 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
87 | - name: Run manual build steps
88 | if: matrix.build-mode == 'manual'
89 | shell: bash
90 | run: |
91 | echo 'If you are using a "manual" build mode for one or more of the' \
92 | 'languages you are analyzing, replace this with the commands to build' \
93 | 'your code, for example:'
94 | echo ' make bootstrap'
95 | echo ' make release'
96 | exit 1
97 |
98 | - name: Perform CodeQL Analysis
99 | uses: github/codeql-action/analyze@v4
100 | with:
101 | category: "/language:${{matrix.language}}"
102 |
--------------------------------------------------------------------------------
/src/register/register.go:
--------------------------------------------------------------------------------
1 | package register
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "sync"
8 | "sync/atomic"
9 | "time"
10 |
11 | "business2api/src/logger"
12 | "business2api/src/pool"
13 | )
14 |
15 | // ==================== 注册与刷新 ====================
16 |
17 | var (
18 | DataDir string
19 | TargetCount int
20 | MinCount int
21 | CheckInterval time.Duration
22 | Threads int
23 | Headless bool // 注册无头模式
24 | Proxy string // 代理
25 | )
26 |
27 | var IsRegistering int32
28 | var registeringTarget int32 // 正在注册的目标数量
29 | var registerMu sync.Mutex // 注册启动互斥锁
30 |
31 | var Stats = &RegisterStats{}
32 |
33 | type RegisterStats struct {
34 | Total int `json:"total"`
35 | Success int `json:"success"`
36 | Failed int `json:"failed"`
37 | LastError string `json:"lastError"`
38 | UpdatedAt time.Time `json:"updatedAt"`
39 | mu sync.RWMutex
40 | }
41 |
42 | func (s *RegisterStats) AddSuccess() {
43 | s.mu.Lock()
44 | defer s.mu.Unlock()
45 | s.Total++
46 | s.Success++
47 | s.UpdatedAt = time.Now()
48 | }
49 |
50 | func (s *RegisterStats) AddFailed(err string) {
51 | s.mu.Lock()
52 | defer s.mu.Unlock()
53 | s.Total++
54 | s.Failed++
55 | s.LastError = err
56 | s.UpdatedAt = time.Now()
57 | }
58 |
59 | func (s *RegisterStats) Get() map[string]interface{} {
60 | s.mu.RLock()
61 | defer s.mu.RUnlock()
62 | return map[string]interface{}{
63 | "total": s.Total,
64 | "success": s.Success,
65 | "failed": s.Failed,
66 | "last_error": s.LastError,
67 | "updated_at": s.UpdatedAt,
68 | }
69 | }
70 |
71 | // 注册结果
72 | type RegisterResult struct {
73 | Success bool `json:"success"`
74 | Email string `json:"email"`
75 | Error string `json:"error"`
76 | NeedWait bool `json:"needWait"`
77 | }
78 |
79 | // StartRegister 启动注册任务(优化并发控制)
80 | func StartRegister(count int) error {
81 | registerMu.Lock()
82 | defer registerMu.Unlock()
83 |
84 | // 再次检查当前账号数是否已满足
85 | pool.Pool.Load(DataDir)
86 | currentCount := pool.Pool.TotalCount()
87 | if currentCount >= TargetCount {
88 | logger.Info("✅ 账号数已满足: %d >= %d,无需注册", currentCount, TargetCount)
89 | return nil
90 | }
91 |
92 | // 如果已经在注册中,检查是否需要调整
93 | if atomic.LoadInt32(&IsRegistering) == 1 {
94 | currentTarget := atomic.LoadInt32(®isteringTarget)
95 | if int(currentTarget) >= count {
96 | return fmt.Errorf("注册进程已在运行,目标: %d", currentTarget)
97 | }
98 | // 更新目标数量
99 | atomic.StoreInt32(®isteringTarget, int32(count))
100 | logger.Info("🔄 注册目标已更新: %d", count)
101 | return nil
102 | }
103 |
104 | if !atomic.CompareAndSwapInt32(&IsRegistering, 0, 1) {
105 | return fmt.Errorf("注册进程已在运行")
106 | }
107 | atomic.StoreInt32(®isteringTarget, int32(count))
108 |
109 | // 获取数据目录的绝对路径
110 | dataDirAbs, _ := filepath.Abs(DataDir)
111 | if err := os.MkdirAll(dataDirAbs, 0755); err != nil {
112 | atomic.StoreInt32(&IsRegistering, 0)
113 | atomic.StoreInt32(®isteringTarget, 0)
114 | return fmt.Errorf("创建数据目录失败: %w", err)
115 | }
116 |
117 | // 使用配置的线程数
118 | threads := Threads
119 | if threads <= 0 {
120 | threads = 1
121 | }
122 | for i := 0; i < threads; i++ {
123 | go NativeRegisterWorker(i+1, dataDirAbs)
124 | }
125 |
126 | // 监控进度
127 | go func() {
128 | for {
129 | time.Sleep(10 * time.Second)
130 | pool.Pool.Load(DataDir)
131 | currentCount := pool.Pool.TotalCount()
132 | target := atomic.LoadInt32(®isteringTarget)
133 |
134 | // 检查是否达到目标(使用当前目标和全局目标的较大值)
135 | effectiveTarget := TargetCount
136 | if int(target) > effectiveTarget {
137 | effectiveTarget = int(target)
138 | }
139 |
140 | if currentCount >= effectiveTarget {
141 | logger.Info("✅ 已达到目标账号数: %d >= %d,停止注册", currentCount, effectiveTarget)
142 | atomic.StoreInt32(&IsRegistering, 0)
143 | atomic.StoreInt32(®isteringTarget, 0)
144 | return
145 | }
146 | }
147 | }()
148 |
149 | return nil
150 | }
151 |
152 | // PoolMaintainer 号池维护器
153 | func PoolMaintainer() {
154 | interval := CheckInterval
155 | if interval < time.Minute {
156 | interval = 30 * time.Minute
157 | }
158 |
159 | ticker := time.NewTicker(interval)
160 | defer ticker.Stop()
161 | CheckAndMaintainPool()
162 |
163 | for range ticker.C {
164 | CheckAndMaintainPool()
165 | }
166 | }
167 |
168 | // CheckAndMaintainPool 检查并维护号池(优化并发控制)
169 | func CheckAndMaintainPool() {
170 | // 如果正在注册中,跳过检查
171 | if atomic.LoadInt32(&IsRegistering) == 1 {
172 | logger.Debug("⏳ 注册进程运行中,跳过本次检查")
173 | return
174 | }
175 |
176 | pool.Pool.Load(DataDir)
177 |
178 | readyCount := pool.Pool.ReadyCount()
179 | pendingCount := pool.Pool.PendingCount()
180 | totalCount := pool.Pool.TotalCount()
181 |
182 | logger.Info("📊 号池检查: ready=%d, pending=%d, total=%d, 目标=%d, 最小=%d",
183 | readyCount, pendingCount, totalCount, TargetCount, MinCount)
184 |
185 | // 只有当总数小于最小数时才触发注册,避免频繁注册
186 | if totalCount < MinCount {
187 | needCount := TargetCount - totalCount
188 | logger.Info("⚠️ 账号数低于最小值 (%d < %d),需要注册 %d 个", totalCount, MinCount, needCount)
189 | if err := StartRegister(needCount); err != nil {
190 | logger.Error("❌ 启动注册失败: %v", err)
191 | }
192 | } else if totalCount < TargetCount {
193 | logger.Debug("📊 账号数未达目标 (%d < %d),但高于最小值,暂不触发注册", totalCount, TargetCount)
194 | }
195 | }
196 |
--------------------------------------------------------------------------------
/src/api/api.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 |
7 | "github.com/gin-gonic/gin"
8 | )
9 |
10 | // ChatRequest 聊天请求
11 | type ChatRequest struct {
12 | Model string `json:"model"`
13 | Messages []Message `json:"messages"`
14 | Stream bool `json:"stream"`
15 | Temperature float64 `json:"temperature,omitempty"`
16 | MaxTokens int `json:"max_tokens,omitempty"`
17 | Tools []ToolDef `json:"tools,omitempty"`
18 | }
19 |
20 | // Message 消息
21 | type Message struct {
22 | Role string `json:"role"`
23 | Content interface{} `json:"content"`
24 | }
25 |
26 | // ToolDef 工具定义
27 | type ToolDef struct {
28 | Type string `json:"type"`
29 | Function FunctionDef `json:"function"`
30 | }
31 |
32 | // FunctionDef 函数定义
33 | type FunctionDef struct {
34 | Name string `json:"name"`
35 | Description string `json:"description"`
36 | Parameters map[string]interface{} `json:"parameters,omitempty"`
37 | }
38 |
39 | var (
40 | StreamChat func(c *gin.Context, req ChatRequest)
41 | FixedModels []string
42 | )
43 | type GeminiRequest struct {
44 | Contents []GeminiContent `json:"contents"`
45 | SystemInstruction *GeminiContent `json:"systemInstruction,omitempty"`
46 | GenerationConfig map[string]interface{} `json:"generationConfig,omitempty"`
47 | GeminiTools []map[string]interface{} `json:"tools,omitempty"`
48 | }
49 |
50 | type GeminiContent struct {
51 | Role string `json:"role,omitempty"`
52 | Parts []GeminiPart `json:"parts"`
53 | }
54 |
55 | type GeminiPart struct {
56 | Text string `json:"text,omitempty"`
57 | InlineData *GeminiInlineData `json:"inlineData,omitempty"`
58 | }
59 |
60 | type GeminiInlineData struct {
61 | MimeType string `json:"mimeType"`
62 | Data string `json:"data"`
63 | }
64 |
65 | func HandleGeminiGenerate(c *gin.Context) {
66 | action := c.Param("action")
67 | if action == "" {
68 | c.JSON(400, gin.H{"error": gin.H{"code": 400, "message": "Missing model action", "status": "INVALID_ARGUMENT"}})
69 | return
70 | }
71 |
72 | // 去掉开头的 /
73 | action = strings.TrimPrefix(action, "/")
74 |
75 | // 解析模型名和动作
76 | var model string
77 | var isStream bool
78 | if idx := strings.LastIndex(action, ":"); idx > 0 {
79 | model = action[:idx]
80 | actionType := action[idx+1:]
81 | isStream = actionType == "streamGenerateContent"
82 | } else {
83 | model = action
84 | }
85 |
86 | if model == "" {
87 | model = FixedModels[0]
88 | }
89 |
90 | var geminiReq GeminiRequest
91 | if err := c.ShouldBindJSON(&geminiReq); err != nil {
92 | c.JSON(400, gin.H{"error": gin.H{"code": 400, "message": err.Error(), "status": "INVALID_ARGUMENT"}})
93 | return
94 | }
95 |
96 | var messages []Message
97 |
98 | // 处理systemInstruction
99 | if geminiReq.SystemInstruction != nil && len(geminiReq.SystemInstruction.Parts) > 0 {
100 | var sysText string
101 | for _, part := range geminiReq.SystemInstruction.Parts {
102 | if part.Text != "" {
103 | sysText += part.Text
104 | }
105 | }
106 | if sysText != "" {
107 | messages = append(messages, Message{Role: "system", Content: sysText})
108 | }
109 | }
110 | for _, content := range geminiReq.Contents {
111 | role := content.Role
112 | if role == "model" {
113 | role = "assistant"
114 | }
115 |
116 | var textParts []string
117 | var contentParts []interface{}
118 |
119 | for _, part := range content.Parts {
120 | if part.Text != "" {
121 | textParts = append(textParts, part.Text)
122 | }
123 | if part.InlineData != nil {
124 | contentParts = append(contentParts, map[string]interface{}{
125 | "type": "image_url",
126 | "image_url": map[string]string{
127 | "url": fmt.Sprintf("data:%s;base64,%s", part.InlineData.MimeType, part.InlineData.Data),
128 | },
129 | })
130 | }
131 | }
132 |
133 | if len(contentParts) > 0 {
134 | if len(textParts) > 0 {
135 | contentParts = append([]interface{}{map[string]interface{}{"type": "text", "text": strings.Join(textParts, "\n")}}, contentParts...)
136 | }
137 | messages = append(messages, Message{Role: role, Content: contentParts})
138 | } else if len(textParts) > 0 {
139 | messages = append(messages, Message{Role: role, Content: strings.Join(textParts, "\n")})
140 | }
141 | }
142 |
143 | // 流式判断:路径中包含streamGenerateContent 或 query参数 alt=sse
144 | stream := isStream || c.Query("alt") == "sse"
145 | var tools []ToolDef
146 | for _, gt := range geminiReq.GeminiTools {
147 | if funcDecls, ok := gt["functionDeclarations"].([]interface{}); ok {
148 | for _, fd := range funcDecls {
149 | if funcMap, ok := fd.(map[string]interface{}); ok {
150 | name, _ := funcMap["name"].(string)
151 | desc, _ := funcMap["description"].(string)
152 | params, _ := funcMap["parameters"].(map[string]interface{})
153 | tools = append(tools, ToolDef{
154 | Type: "function",
155 | Function: FunctionDef{
156 | Name: name,
157 | Description: desc,
158 | Parameters: params,
159 | },
160 | })
161 | }
162 | }
163 | }
164 | }
165 |
166 | req := ChatRequest{
167 | Model: model,
168 | Messages: messages,
169 | Stream: stream,
170 | Tools: tools,
171 | }
172 |
173 | StreamChat(c, req)
174 | }
175 |
176 | type ClaudeRequest struct {
177 | Model string `json:"model"`
178 | Messages []Message `json:"messages"`
179 | System string `json:"system,omitempty"`
180 | MaxTokens int `json:"max_tokens,omitempty"`
181 | Stream bool `json:"stream"`
182 | Temperature float64 `json:"temperature,omitempty"`
183 | Tools []ToolDef `json:"tools,omitempty"`
184 | }
185 | func HandleClaudeMessages(c *gin.Context) {
186 | var claudeReq ClaudeRequest
187 | if err := c.ShouldBindJSON(&claudeReq); err != nil {
188 | c.JSON(400, gin.H{"type": "error", "error": gin.H{"type": "invalid_request_error", "message": err.Error()}})
189 | return
190 | }
191 |
192 | req := ChatRequest{
193 | Model: claudeReq.Model,
194 | Messages: claudeReq.Messages,
195 | Stream: claudeReq.Stream,
196 | Temperature: claudeReq.Temperature,
197 | Tools: claudeReq.Tools,
198 | }
199 | if claudeReq.System != "" {
200 | systemMsg := Message{Role: "system", Content: claudeReq.System}
201 | req.Messages = append([]Message{systemMsg}, req.Messages...)
202 | }
203 |
204 | // 保持模型名原样,不做映射
205 | if req.Model == "" {
206 | req.Model = FixedModels[0]
207 | }
208 |
209 | StreamChat(c, req)
210 | }
211 |
--------------------------------------------------------------------------------
/src/flow/models.go:
--------------------------------------------------------------------------------
1 | package flow
2 |
3 | // ModelType 模型类型
4 | type ModelType string
5 |
6 | const (
7 | ModelTypeImage ModelType = "image"
8 | ModelTypeVideo ModelType = "video"
9 | )
10 |
11 | // VideoType 视频生成类型
12 | type VideoType string
13 |
14 | const (
15 | VideoTypeT2V VideoType = "t2v" // Text to Video
16 | VideoTypeI2V VideoType = "i2v" // Image to Video (首尾帧)
17 | VideoTypeR2V VideoType = "r2v" // Reference Images to Video (多图)
18 | )
19 |
20 | // ModelConfig 模型配置
21 | type ModelConfig struct {
22 | Type ModelType `json:"type"`
23 | ModelName string `json:"model_name,omitempty"` // 图片模型名称
24 | ModelKey string `json:"model_key,omitempty"` // 视频模型键
25 | AspectRatio string `json:"aspect_ratio"`
26 | VideoType VideoType `json:"video_type,omitempty"`
27 | SupportsImages bool `json:"supports_images"`
28 | MinImages int `json:"min_images"`
29 | MaxImages int `json:"max_images"` // 0 表示不限制
30 | }
31 |
32 | // FlowModelConfig Flow 模型配置表
33 | var FlowModelConfig = map[string]ModelConfig{
34 | // ========== 图片生成 ==========
35 | // GEM_PIX (Gemini 2.5 Flash)
36 | "gemini-2.5-flash-image-landscape": {
37 | Type: ModelTypeImage,
38 | ModelName: "GEM_PIX",
39 | AspectRatio: "IMAGE_ASPECT_RATIO_LANDSCAPE",
40 | },
41 | "gemini-2.5-flash-image-portrait": {
42 | Type: ModelTypeImage,
43 | ModelName: "GEM_PIX",
44 | AspectRatio: "IMAGE_ASPECT_RATIO_PORTRAIT",
45 | },
46 | // GEM_PIX_2 (Gemini 3.0 Pro)
47 | "gemini-3.0-pro-image-landscape": {
48 | Type: ModelTypeImage,
49 | ModelName: "GEM_PIX_2",
50 | AspectRatio: "IMAGE_ASPECT_RATIO_LANDSCAPE",
51 | },
52 | "gemini-3.0-pro-image-portrait": {
53 | Type: ModelTypeImage,
54 | ModelName: "GEM_PIX_2",
55 | AspectRatio: "IMAGE_ASPECT_RATIO_PORTRAIT",
56 | },
57 | // IMAGEN_3_5 (Imagen 4.0)
58 | "imagen-4.0-generate-preview-landscape": {
59 | Type: ModelTypeImage,
60 | ModelName: "IMAGEN_3_5",
61 | AspectRatio: "IMAGE_ASPECT_RATIO_LANDSCAPE",
62 | },
63 | "imagen-4.0-generate-preview-portrait": {
64 | Type: ModelTypeImage,
65 | ModelName: "IMAGEN_3_5",
66 | AspectRatio: "IMAGE_ASPECT_RATIO_PORTRAIT",
67 | },
68 |
69 | // ========== 文生视频 (T2V) ==========
70 | "veo_3_1_t2v_fast_portrait": {
71 | Type: ModelTypeVideo,
72 | VideoType: VideoTypeT2V,
73 | ModelKey: "veo_3_1_t2v_fast_portrait",
74 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
75 | SupportsImages: false,
76 | },
77 | "veo_3_1_t2v_fast_landscape": {
78 | Type: ModelTypeVideo,
79 | VideoType: VideoTypeT2V,
80 | ModelKey: "veo_3_1_t2v_fast",
81 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
82 | SupportsImages: false,
83 | },
84 | "veo_2_1_fast_d_15_t2v_portrait": {
85 | Type: ModelTypeVideo,
86 | VideoType: VideoTypeT2V,
87 | ModelKey: "veo_2_1_fast_d_15_t2v",
88 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
89 | SupportsImages: false,
90 | },
91 | "veo_2_1_fast_d_15_t2v_landscape": {
92 | Type: ModelTypeVideo,
93 | VideoType: VideoTypeT2V,
94 | ModelKey: "veo_2_1_fast_d_15_t2v",
95 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
96 | SupportsImages: false,
97 | },
98 | "veo_2_0_t2v_portrait": {
99 | Type: ModelTypeVideo,
100 | VideoType: VideoTypeT2V,
101 | ModelKey: "veo_2_0_t2v",
102 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
103 | SupportsImages: false,
104 | },
105 | "veo_2_0_t2v_landscape": {
106 | Type: ModelTypeVideo,
107 | VideoType: VideoTypeT2V,
108 | ModelKey: "veo_2_0_t2v",
109 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
110 | SupportsImages: false,
111 | },
112 |
113 | // ========== 首尾帧 (I2V) ==========
114 | "veo_3_1_i2v_s_fast_fl_portrait": {
115 | Type: ModelTypeVideo,
116 | VideoType: VideoTypeI2V,
117 | ModelKey: "veo_3_1_i2v_s_fast_fl",
118 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
119 | SupportsImages: true,
120 | MinImages: 1,
121 | MaxImages: 2,
122 | },
123 | "veo_3_1_i2v_s_fast_fl_landscape": {
124 | Type: ModelTypeVideo,
125 | VideoType: VideoTypeI2V,
126 | ModelKey: "veo_3_1_i2v_s_fast_fl",
127 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
128 | SupportsImages: true,
129 | MinImages: 1,
130 | MaxImages: 2,
131 | },
132 | "veo_2_1_fast_d_15_i2v_portrait": {
133 | Type: ModelTypeVideo,
134 | VideoType: VideoTypeI2V,
135 | ModelKey: "veo_2_1_fast_d_15_i2v",
136 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
137 | SupportsImages: true,
138 | MinImages: 1,
139 | MaxImages: 2,
140 | },
141 | "veo_2_1_fast_d_15_i2v_landscape": {
142 | Type: ModelTypeVideo,
143 | VideoType: VideoTypeI2V,
144 | ModelKey: "veo_2_1_fast_d_15_i2v",
145 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
146 | SupportsImages: true,
147 | MinImages: 1,
148 | MaxImages: 2,
149 | },
150 | "veo_2_0_i2v_portrait": {
151 | Type: ModelTypeVideo,
152 | VideoType: VideoTypeI2V,
153 | ModelKey: "veo_2_0_i2v",
154 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
155 | SupportsImages: true,
156 | MinImages: 1,
157 | MaxImages: 2,
158 | },
159 | "veo_2_0_i2v_landscape": {
160 | Type: ModelTypeVideo,
161 | VideoType: VideoTypeI2V,
162 | ModelKey: "veo_2_0_i2v",
163 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
164 | SupportsImages: true,
165 | MinImages: 1,
166 | MaxImages: 2,
167 | },
168 |
169 | // ========== 多图生成 (R2V) ==========
170 | "veo_3_0_r2v_fast_portrait": {
171 | Type: ModelTypeVideo,
172 | VideoType: VideoTypeR2V,
173 | ModelKey: "veo_3_0_r2v_fast",
174 | AspectRatio: "VIDEO_ASPECT_RATIO_PORTRAIT",
175 | SupportsImages: true,
176 | MinImages: 0,
177 | MaxImages: 0, // 不限制
178 | },
179 | "veo_3_0_r2v_fast_landscape": {
180 | Type: ModelTypeVideo,
181 | VideoType: VideoTypeR2V,
182 | ModelKey: "veo_3_0_r2v_fast",
183 | AspectRatio: "VIDEO_ASPECT_RATIO_LANDSCAPE",
184 | SupportsImages: true,
185 | MinImages: 0,
186 | MaxImages: 0, // 不限制
187 | },
188 | }
189 |
190 | // IsFlowModel 检查是否是 Flow 模型
191 | func IsFlowModel(model string) bool {
192 | _, ok := FlowModelConfig[model]
193 | return ok
194 | }
195 |
196 | // GetFlowModelConfig 获取 Flow 模型配置
197 | func GetFlowModelConfig(model string) (ModelConfig, bool) {
198 | cfg, ok := FlowModelConfig[model]
199 | return cfg, ok
200 | }
201 |
202 | // GetAllFlowModels 获取所有 Flow 模型名称
203 | func GetAllFlowModels() []string {
204 | models := make([]string, 0, len(FlowModelConfig))
205 | for name := range FlowModelConfig {
206 | models = append(models, name)
207 | }
208 | return models
209 | }
210 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module business2api
2 |
3 | go 1.25
4 |
5 | require (
6 | github.com/fsnotify/fsnotify v1.9.0
7 | github.com/gin-gonic/gin v1.9.1
8 | github.com/go-rod/rod v0.116.2
9 | github.com/google/uuid v1.6.0
10 | github.com/gorilla/websocket v1.5.3
11 | github.com/sagernet/sing-box v1.12.12
12 | github.com/sagernet/sing-quic v0.5.2-0.20250909083218-00a55617c0fb
13 | golang.org/x/image v0.33.0
14 | )
15 |
16 | require (
17 | filippo.io/edwards25519 v1.1.0 // indirect
18 | github.com/ajg/form v1.5.1 // indirect
19 | github.com/akutz/memconn v0.1.0 // indirect
20 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
21 | github.com/andybalholm/brotli v1.1.0 // indirect
22 | github.com/anytls/sing-anytls v0.0.11 // indirect
23 | github.com/bits-and-blooms/bitset v1.13.0 // indirect
24 | github.com/bytedance/sonic v1.9.1 // indirect
25 | github.com/caddyserver/certmagic v0.23.0 // indirect
26 | github.com/caddyserver/zerossl v0.1.3 // indirect
27 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
28 | github.com/coder/websocket v1.8.13 // indirect
29 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect
30 | github.com/cretz/bine v0.2.0 // indirect
31 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
32 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect
33 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect
34 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect
35 | github.com/gaissmai/bart v0.11.1 // indirect
36 | github.com/gin-contrib/sse v0.1.0 // indirect
37 | github.com/go-chi/chi/v5 v5.2.2 // indirect
38 | github.com/go-chi/render v1.0.3 // indirect
39 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect
40 | github.com/go-ole/go-ole v1.3.0 // indirect
41 | github.com/go-playground/locales v0.14.1 // indirect
42 | github.com/go-playground/universal-translator v0.18.1 // indirect
43 | github.com/go-playground/validator/v10 v10.14.0 // indirect
44 | github.com/gobwas/httphead v0.1.0 // indirect
45 | github.com/gobwas/pool v0.2.1 // indirect
46 | github.com/goccy/go-json v0.10.2 // indirect
47 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
48 | github.com/gofrs/uuid/v5 v5.3.2 // indirect
49 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
50 | github.com/google/btree v1.1.3 // indirect
51 | github.com/google/go-cmp v0.7.0 // indirect
52 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect
53 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect
54 | github.com/gorilla/securecookie v1.1.2 // indirect
55 | github.com/hashicorp/yamux v0.1.2 // indirect
56 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect
57 | github.com/illarion/gonotify/v2 v2.0.3 // indirect
58 | github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f // indirect
59 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect
60 | github.com/json-iterator/go v1.1.12 // indirect
61 | github.com/klauspost/compress v1.17.11 // indirect
62 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect
63 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect
64 | github.com/kr/pretty v0.3.1 // indirect
65 | github.com/leodido/go-urn v1.2.4 // indirect
66 | github.com/libdns/alidns v1.0.5-libdns.v1.beta1 // indirect
67 | github.com/libdns/cloudflare v0.2.2-0.20250708034226-c574dccb31a6 // indirect
68 | github.com/libdns/libdns v1.1.0 // indirect
69 | github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
70 | github.com/mattn/go-isatty v0.0.19 // indirect
71 | github.com/mdlayher/genetlink v1.3.2 // indirect
72 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
73 | github.com/mdlayher/sdnotify v1.0.0 // indirect
74 | github.com/mdlayher/socket v0.5.1 // indirect
75 | github.com/metacubex/tfo-go v0.0.0-20250921095601-b102db4216c0 // indirect
76 | github.com/metacubex/utls v1.8.3 // indirect
77 | github.com/mholt/acmez/v3 v3.1.2 // indirect
78 | github.com/miekg/dns v1.1.68 // indirect
79 | github.com/mitchellh/go-ps v1.0.0 // indirect
80 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
81 | github.com/modern-go/reflect2 v1.0.2 // indirect
82 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect
83 | github.com/pierrec/lz4/v4 v4.1.21 // indirect
84 | github.com/prometheus-community/pro-bing v0.4.0 // indirect
85 | github.com/quic-go/qpack v0.5.1 // indirect
86 | github.com/safchain/ethtool v0.3.0 // indirect
87 | github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a // indirect
88 | github.com/sagernet/cors v1.2.1 // indirect
89 | github.com/sagernet/fswatch v0.1.1 // indirect
90 | github.com/sagernet/gvisor v0.0.0-20250325023245-7a9c0f5725fb // indirect
91 | github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
92 | github.com/sagernet/nftables v0.3.0-beta.4 // indirect
93 | github.com/sagernet/quic-go v0.52.0-sing-box-mod.3 // indirect
94 | github.com/sagernet/sing v0.7.13 // indirect
95 | github.com/sagernet/sing-mux v0.3.3 // indirect
96 | github.com/sagernet/sing-shadowsocks v0.2.8 // indirect
97 | github.com/sagernet/sing-shadowsocks2 v0.2.1 // indirect
98 | github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 // indirect
99 | github.com/sagernet/sing-tun v0.7.3 // indirect
100 | github.com/sagernet/sing-vmess v0.2.7 // indirect
101 | github.com/sagernet/smux v1.5.34-mod.2 // indirect
102 | github.com/sagernet/tailscale v1.80.3-sing-box-1.12-mod.2 // indirect
103 | github.com/sagernet/wireguard-go v0.0.1-beta.7 // indirect
104 | github.com/sagernet/ws v0.0.0-20231204124109-acfe8907c854 // indirect
105 | github.com/stretchr/testify v1.11.1 // indirect
106 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
107 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
108 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect
109 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect
110 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
111 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect
112 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
113 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
114 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
115 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect
116 | github.com/ugorji/go/codec v1.2.11 // indirect
117 | github.com/vishvananda/netns v0.0.5 // indirect
118 | github.com/x448/float16 v0.8.4 // indirect
119 | github.com/ysmood/fetchup v0.2.3 // indirect
120 | github.com/ysmood/goob v0.4.0 // indirect
121 | github.com/ysmood/got v0.40.0 // indirect
122 | github.com/ysmood/gson v0.7.3 // indirect
123 | github.com/ysmood/leakless v0.9.0 // indirect
124 | github.com/zeebo/blake3 v0.2.4 // indirect
125 | go.uber.org/multierr v1.11.0 // indirect
126 | go.uber.org/zap v1.27.0 // indirect
127 | go.uber.org/zap/exp v0.3.0 // indirect
128 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
129 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
130 | golang.org/x/arch v0.3.0 // indirect
131 | golang.org/x/crypto v0.45.0 // indirect
132 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
133 | golang.org/x/mod v0.29.0 // indirect
134 | golang.org/x/net v0.47.0 // indirect
135 | golang.org/x/sync v0.18.0 // indirect
136 | golang.org/x/sys v0.38.0 // indirect
137 | golang.org/x/term v0.37.0 // indirect
138 | golang.org/x/text v0.31.0 // indirect
139 | golang.org/x/time v0.12.0 // indirect
140 | golang.org/x/tools v0.38.0 // indirect
141 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
142 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
143 | google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
144 | google.golang.org/grpc v1.77.0 // indirect
145 | google.golang.org/protobuf v1.36.10 // indirect
146 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
147 | gopkg.in/yaml.v3 v3.0.1 // indirect
148 | lukechampine.com/blake3 v1.4.1 // indirect
149 | )
150 |
--------------------------------------------------------------------------------
/src/flow/token_pool.go:
--------------------------------------------------------------------------------
1 | // Package flow 实现 Flow Token 池管理
2 | package flow
3 |
4 | import (
5 | "crypto/md5"
6 | "encoding/hex"
7 | "fmt"
8 | "log"
9 | "os"
10 | "path/filepath"
11 | "regexp"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/fsnotify/fsnotify"
17 | )
18 |
19 | // TokenPool Flow Token 池管理器
20 | type TokenPool struct {
21 | mu sync.RWMutex
22 | tokens map[string]*FlowToken
23 | dataDir string
24 | client *FlowClient
25 | stopChan chan struct{}
26 | watcher *fsnotify.Watcher
27 | fileIndex map[string]string // fileName -> tokenID
28 | }
29 |
30 | // NewTokenPool 创建新的 Token 池
31 | func NewTokenPool(dataDir string, client *FlowClient) *TokenPool {
32 | return &TokenPool{
33 | tokens: make(map[string]*FlowToken),
34 | dataDir: dataDir,
35 | client: client,
36 | stopChan: make(chan struct{}),
37 | fileIndex: make(map[string]string),
38 | }
39 | }
40 |
41 | // LoadFromDir 从目录加载所有 Token
42 | // 每个文件包含一个完整的 cookie,自动提取 __Secure-next-auth.session-token
43 | func (p *TokenPool) LoadFromDir() (int, error) {
44 | atDir := filepath.Join(p.dataDir, "at")
45 |
46 | // 确保目录存在
47 | if err := os.MkdirAll(atDir, 0755); err != nil {
48 | return 0, fmt.Errorf("创建目录失败: %w", err)
49 | }
50 |
51 | files, err := os.ReadDir(atDir)
52 | if err != nil {
53 | return 0, fmt.Errorf("读取目录失败: %w", err)
54 | }
55 |
56 | loaded := 0
57 | for _, f := range files {
58 | if f.IsDir() {
59 | continue
60 | }
61 |
62 | filePath := filepath.Join(atDir, f.Name())
63 | content, err := os.ReadFile(filePath)
64 | if err != nil {
65 | log.Printf("[FlowPool] 读取文件失败 %s: %v", f.Name(), err)
66 | continue
67 | }
68 |
69 | // 提取 session-token
70 | st := extractSessionToken(string(content))
71 | if st == "" {
72 | log.Printf("[FlowPool] 文件 %s 中未找到有效的 session-token", f.Name())
73 | continue
74 | }
75 |
76 | // 生成唯一ID
77 | tokenID := generateTokenID(st)
78 |
79 | p.mu.Lock()
80 | if _, exists := p.tokens[tokenID]; !exists {
81 | token := &FlowToken{
82 | ID: tokenID,
83 | ST: st,
84 | }
85 | p.tokens[tokenID] = token
86 | if p.client != nil {
87 | p.client.AddToken(token)
88 | }
89 | loaded++
90 | log.Printf("[FlowPool] 加载 Token: %s (来自 %s)", tokenID[:16]+"...", f.Name())
91 | }
92 | p.mu.Unlock()
93 | }
94 |
95 | return loaded, nil
96 | }
97 |
98 | // AddFromCookie 从完整 cookie 字符串添加 Token
99 | func (p *TokenPool) AddFromCookie(cookie string) (string, error) {
100 | st := extractSessionToken(cookie)
101 | if st == "" {
102 | return "", fmt.Errorf("cookie 中未找到有效的 session-token")
103 | }
104 |
105 | tokenID := generateTokenID(st)
106 |
107 | p.mu.Lock()
108 | defer p.mu.Unlock()
109 |
110 | if _, exists := p.tokens[tokenID]; exists {
111 | return tokenID, fmt.Errorf("Token 已存在")
112 | }
113 |
114 | token := &FlowToken{
115 | ID: tokenID,
116 | ST: st,
117 | }
118 | p.tokens[tokenID] = token
119 | if p.client != nil {
120 | p.client.AddToken(token)
121 | }
122 |
123 | // 保存到文件
124 | if err := p.saveTokenToFile(tokenID, cookie); err != nil {
125 | log.Printf("[FlowPool] 保存 Token 到文件失败: %v", err)
126 | }
127 |
128 | return tokenID, nil
129 | }
130 |
131 | // saveTokenToFile 保存 Token 到文件
132 | func (p *TokenPool) saveTokenToFile(tokenID, cookie string) error {
133 | atDir := filepath.Join(p.dataDir, "at")
134 | if err := os.MkdirAll(atDir, 0755); err != nil {
135 | return err
136 | }
137 |
138 | fileName := fmt.Sprintf("%s.txt", tokenID[:16])
139 | filePath := filepath.Join(atDir, fileName)
140 |
141 | return os.WriteFile(filePath, []byte(cookie), 0600)
142 | }
143 |
144 | // RemoveToken 移除 Token
145 | func (p *TokenPool) RemoveToken(tokenID string) error {
146 | p.mu.Lock()
147 | defer p.mu.Unlock()
148 |
149 | if _, exists := p.tokens[tokenID]; !exists {
150 | return fmt.Errorf("Token 不存在")
151 | }
152 |
153 | delete(p.tokens, tokenID)
154 |
155 | // 删除文件
156 | atDir := filepath.Join(p.dataDir, "at")
157 | files, _ := os.ReadDir(atDir)
158 | for _, f := range files {
159 | if strings.HasPrefix(f.Name(), tokenID[:16]) {
160 | os.Remove(filepath.Join(atDir, f.Name()))
161 | break
162 | }
163 | }
164 |
165 | return nil
166 | }
167 |
168 | // Count 返回 Token 数量
169 | func (p *TokenPool) Count() int {
170 | p.mu.RLock()
171 | defer p.mu.RUnlock()
172 | return len(p.tokens)
173 | }
174 |
175 | // ReadyCount 返回可用 Token 数量
176 | func (p *TokenPool) ReadyCount() int {
177 | p.mu.RLock()
178 | defer p.mu.RUnlock()
179 |
180 | count := 0
181 | for _, t := range p.tokens {
182 | if !t.Disabled && t.ErrorCount < 3 {
183 | count++
184 | }
185 | }
186 | return count
187 | }
188 |
189 | // Stats 返回统计信息
190 | func (p *TokenPool) Stats() map[string]interface{} {
191 | p.mu.RLock()
192 | defer p.mu.RUnlock()
193 |
194 | ready := 0
195 | disabled := 0
196 | errored := 0
197 |
198 | tokenInfos := make([]map[string]interface{}, 0)
199 |
200 | for _, t := range p.tokens {
201 | t.mu.RLock()
202 | info := map[string]interface{}{
203 | "id": t.ID[:16] + "...",
204 | "email": t.Email,
205 | "credits": t.Credits,
206 | "disabled": t.Disabled,
207 | "error_count": t.ErrorCount,
208 | "last_used": t.LastUsed.Format(time.RFC3339),
209 | }
210 | t.mu.RUnlock()
211 |
212 | tokenInfos = append(tokenInfos, info)
213 |
214 | if t.Disabled {
215 | disabled++
216 | } else if t.ErrorCount >= 3 {
217 | errored++
218 | } else {
219 | ready++
220 | }
221 | }
222 |
223 | return map[string]interface{}{
224 | "total": len(p.tokens),
225 | "ready": ready,
226 | "disabled": disabled,
227 | "errored": errored,
228 | "tokens": tokenInfos,
229 | }
230 | }
231 |
232 | // StartRefreshWorker 启动定期刷新 AT 的 worker
233 | func (p *TokenPool) StartRefreshWorker(interval time.Duration) {
234 | go func() {
235 | ticker := time.NewTicker(interval)
236 | defer ticker.Stop()
237 |
238 | for {
239 | select {
240 | case <-ticker.C:
241 | p.refreshAllAT()
242 | case <-p.stopChan:
243 | return
244 | }
245 | }
246 | }()
247 | log.Printf("[FlowPool] 刷新 worker 已启动,间隔: %v", interval)
248 | }
249 |
250 | // Stop 停止 Token 池
251 | func (p *TokenPool) Stop() {
252 | close(p.stopChan)
253 | if p.watcher != nil {
254 | p.watcher.Close()
255 | }
256 | }
257 |
258 | // StartWatcher 启动文件监听
259 | func (p *TokenPool) StartWatcher() error {
260 | atDir := filepath.Join(p.dataDir, "at")
261 |
262 | // 确保目录存在
263 | if err := os.MkdirAll(atDir, 0755); err != nil {
264 | return fmt.Errorf("创建目录失败: %w", err)
265 | }
266 |
267 | watcher, err := fsnotify.NewWatcher()
268 | if err != nil {
269 | return fmt.Errorf("创建文件监听器失败: %w", err)
270 | }
271 | p.watcher = watcher
272 |
273 | go p.watchLoop()
274 |
275 | if err := watcher.Add(atDir); err != nil {
276 | return fmt.Errorf("添加监听目录失败: %w", err)
277 | }
278 |
279 | log.Printf("[FlowPool] 文件监听已启动: %s", atDir)
280 | return nil
281 | }
282 |
283 | // watchLoop 文件监听循环
284 | func (p *TokenPool) watchLoop() {
285 | for {
286 | select {
287 | case event, ok := <-p.watcher.Events:
288 | if !ok {
289 | return
290 | }
291 | p.handleFileEvent(event)
292 | case err, ok := <-p.watcher.Errors:
293 | if !ok {
294 | return
295 | }
296 | log.Printf("[FlowPool] 文件监听错误: %v", err)
297 | case <-p.stopChan:
298 | return
299 | }
300 | }
301 | }
302 |
303 | // handleFileEvent 处理文件事件
304 | func (p *TokenPool) handleFileEvent(event fsnotify.Event) {
305 | fileName := filepath.Base(event.Name)
306 |
307 | // 忽略 README 和隐藏文件
308 | if strings.HasPrefix(fileName, ".") || strings.EqualFold(fileName, "README.md") {
309 | return
310 | }
311 |
312 | switch {
313 | case event.Op&fsnotify.Create == fsnotify.Create:
314 | // 新文件创建
315 | time.Sleep(100 * time.Millisecond) // 等待文件写入完成
316 | p.loadTokenFromFile(event.Name)
317 |
318 | case event.Op&fsnotify.Write == fsnotify.Write:
319 | // 文件修改
320 | time.Sleep(100 * time.Millisecond)
321 | p.loadTokenFromFile(event.Name)
322 |
323 | case event.Op&fsnotify.Remove == fsnotify.Remove:
324 | // 文件删除
325 | p.removeTokenByFile(fileName)
326 |
327 | case event.Op&fsnotify.Rename == fsnotify.Rename:
328 | // 文件重命名 (视为删除)
329 | p.removeTokenByFile(fileName)
330 | }
331 | }
332 |
333 | // loadTokenFromFile 从单个文件加载 Token
334 | func (p *TokenPool) loadTokenFromFile(filePath string) {
335 | fileName := filepath.Base(filePath)
336 |
337 | content, err := os.ReadFile(filePath)
338 | if err != nil {
339 | log.Printf("[FlowPool] 读取文件失败 %s: %v", fileName, err)
340 | return
341 | }
342 |
343 | st := extractSessionToken(string(content))
344 | if st == "" {
345 | log.Printf("[FlowPool] 文件 %s 中未找到有效的 session-token", fileName)
346 | return
347 | }
348 |
349 | tokenID := generateTokenID(st)
350 |
351 | p.mu.Lock()
352 | defer p.mu.Unlock()
353 |
354 | // 检查是否已存在
355 | if existingID, ok := p.fileIndex[fileName]; ok {
356 | if existingID == tokenID {
357 | // 同一个 Token,无需更新
358 | return
359 | }
360 | // 文件内容变了,移除旧 Token
361 | delete(p.tokens, existingID)
362 | log.Printf("[FlowPool] Token 已更新: %s", fileName)
363 | }
364 |
365 | if _, exists := p.tokens[tokenID]; !exists {
366 | token := &FlowToken{
367 | ID: tokenID,
368 | ST: st,
369 | }
370 | p.tokens[tokenID] = token
371 | p.fileIndex[fileName] = tokenID
372 | if p.client != nil {
373 | p.client.AddToken(token)
374 | }
375 | log.Printf("[FlowPool] 自动加载 Token: %s (来自 %s)", tokenID[:16]+"...", fileName)
376 |
377 | // 立即尝试刷新 AT
378 | go p.refreshSingleToken(token)
379 | }
380 | }
381 |
382 | // removeTokenByFile 根据文件名移除 Token
383 | func (p *TokenPool) removeTokenByFile(fileName string) {
384 | p.mu.Lock()
385 | defer p.mu.Unlock()
386 |
387 | tokenID, ok := p.fileIndex[fileName]
388 | if !ok {
389 | return
390 | }
391 |
392 | delete(p.tokens, tokenID)
393 | delete(p.fileIndex, fileName)
394 | log.Printf("[FlowPool] Token 已移除: %s (文件 %s 已删除)", tokenID[:16]+"...", fileName)
395 | }
396 |
397 | // refreshSingleToken 刷新单个 Token 的 AT
398 | func (p *TokenPool) refreshSingleToken(token *FlowToken) {
399 | if p.client == nil {
400 | return
401 | }
402 |
403 | resp, err := p.client.STToAT(token.ST)
404 | if err != nil {
405 | token.mu.Lock()
406 | token.ErrorCount++
407 | token.mu.Unlock()
408 | log.Printf("[FlowPool] Token %s AT 刷新失败: %v", token.ID[:16]+"...", err)
409 | return
410 | }
411 |
412 | token.mu.Lock()
413 | token.AT = resp.AccessToken
414 | if resp.Expires != "" {
415 | if t, err := time.Parse(time.RFC3339, resp.Expires); err == nil {
416 | token.ATExpires = t
417 | }
418 | }
419 | token.Email = resp.Email
420 | token.ErrorCount = 0
421 | token.Disabled = false
422 | token.mu.Unlock()
423 |
424 | log.Printf("[FlowPool] Token %s AT 已刷新, Email: %s", token.ID[:16]+"...", resp.Email)
425 | }
426 |
427 | // refreshAllAT 刷新所有 Token 的 AT
428 | func (p *TokenPool) refreshAllAT() {
429 | p.mu.RLock()
430 | tokens := make([]*FlowToken, 0, len(p.tokens))
431 | for _, t := range p.tokens {
432 | tokens = append(tokens, t)
433 | }
434 | p.mu.RUnlock()
435 |
436 | for _, token := range tokens {
437 | token.mu.Lock()
438 | // 检查是否需要刷新
439 | needRefresh := token.AT == "" || time.Now().After(token.ATExpires.Add(-5*time.Minute))
440 | token.mu.Unlock()
441 |
442 | if !needRefresh {
443 | continue
444 | }
445 |
446 | if p.client == nil {
447 | continue
448 | }
449 |
450 | resp, err := p.client.STToAT(token.ST)
451 | if err != nil {
452 | token.mu.Lock()
453 | token.ErrorCount++
454 | if token.ErrorCount >= 3 {
455 | token.Disabled = true
456 | log.Printf("[FlowPool] Token %s 刷新失败次数过多,已禁用: %v", token.ID[:16]+"...", err)
457 | }
458 | token.mu.Unlock()
459 | continue
460 | }
461 |
462 | token.mu.Lock()
463 | token.AT = resp.AccessToken
464 | if resp.Expires != "" {
465 | if t, err := time.Parse(time.RFC3339, resp.Expires); err == nil {
466 | token.ATExpires = t
467 | }
468 | }
469 | token.Email = resp.Email
470 | token.ErrorCount = 0
471 | token.Disabled = false
472 | token.mu.Unlock()
473 |
474 | log.Printf("[FlowPool] Token %s AT 已刷新, Email: %s", token.ID[:16]+"...", resp.Email)
475 | }
476 | }
477 |
478 | // extractSessionToken 从 cookie 字符串提取 __Secure-next-auth.session-token
479 | func extractSessionToken(cookie string) string {
480 | // 正则匹配 __Secure-next-auth.session-token=...
481 | // Token 可能以 ; 或空格或行尾结束
482 | patterns := []string{
483 | `__Secure-next-auth\.session-token=([^;\s]+)`,
484 | `__Secure-next-auth\.session-token=([^\s;]+)`,
485 | }
486 |
487 | for _, pattern := range patterns {
488 | re := regexp.MustCompile(pattern)
489 | matches := re.FindStringSubmatch(cookie)
490 | if len(matches) >= 2 {
491 | return strings.TrimSpace(matches[1])
492 | }
493 | }
494 |
495 | // 如果输入本身就是 token(不包含 = 的长字符串)
496 | cookie = strings.TrimSpace(cookie)
497 | if !strings.Contains(cookie, "=") && len(cookie) > 100 {
498 | return cookie
499 | }
500 |
501 | return ""
502 | }
503 |
504 | // generateTokenID 根据 ST 生成唯一 ID
505 | func generateTokenID(st string) string {
506 | hash := md5.Sum([]byte(st))
507 | return hex.EncodeToString(hash[:])
508 | }
509 |
--------------------------------------------------------------------------------
/src/flow/handler.go:
--------------------------------------------------------------------------------
1 | package flow
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "time"
8 | )
9 |
10 | // GenerationHandler Flow 生成处理器
11 | type GenerationHandler struct {
12 | client *FlowClient
13 | }
14 |
15 | // NewGenerationHandler 创建生成处理器
16 | func NewGenerationHandler(client *FlowClient) *GenerationHandler {
17 | return &GenerationHandler{client: client}
18 | }
19 |
20 | // GenerationRequest 生成请求
21 | type GenerationRequest struct {
22 | Model string `json:"model"`
23 | Prompt string `json:"prompt"`
24 | Images [][]byte `json:"images,omitempty"` // 图片字节数据
25 | Stream bool `json:"stream"`
26 | }
27 |
28 | // GenerationResult 生成结果
29 | type GenerationResult struct {
30 | Success bool `json:"success"`
31 | Type string `json:"type"` // "image" 或 "video"
32 | URL string `json:"url"`
33 | Error string `json:"error,omitempty"`
34 | Progress int `json:"progress,omitempty"`
35 | Message string `json:"message,omitempty"`
36 | }
37 |
38 | // StreamCallback 流式回调函数
39 | type StreamCallback func(chunk string)
40 |
41 | // HandleGeneration 处理生成请求
42 | func (h *GenerationHandler) HandleGeneration(req GenerationRequest, streamCb StreamCallback) (*GenerationResult, error) {
43 | // 验证模型
44 | modelConfig, ok := GetFlowModelConfig(req.Model)
45 | if !ok {
46 | return &GenerationResult{
47 | Success: false,
48 | Error: fmt.Sprintf("不支持的模型: %s", req.Model),
49 | }, nil
50 | }
51 |
52 | // 选择 Token
53 | token := h.client.SelectToken()
54 | if token == nil {
55 | return &GenerationResult{
56 | Success: false,
57 | Error: "没有可用的 Flow Token",
58 | }, nil
59 | }
60 |
61 | // 确保 AT 有效
62 | if err := h.ensureATValid(token); err != nil {
63 | return &GenerationResult{
64 | Success: false,
65 | Error: fmt.Sprintf("Token 认证失败: %v", err),
66 | }, nil
67 | }
68 |
69 | // 更新余额信息 (异步)
70 | go h.updateTokenCredits(token)
71 |
72 | // 确保 Project 存在
73 | if err := h.ensureProjectExists(token); err != nil {
74 | return &GenerationResult{
75 | Success: false,
76 | Error: fmt.Sprintf("创建项目失败: %v", err),
77 | }, nil
78 | }
79 |
80 | // 根据类型处理
81 | if modelConfig.Type == ModelTypeImage {
82 | return h.handleImageGeneration(token, modelConfig, req, streamCb)
83 | } else {
84 | return h.handleVideoGeneration(token, modelConfig, req, streamCb)
85 | }
86 | }
87 |
88 | // ensureATValid 确保 AT 有效
89 | func (h *GenerationHandler) ensureATValid(token *FlowToken) error {
90 | token.mu.Lock()
91 | defer token.mu.Unlock()
92 |
93 | // AT 还有效且未过期
94 | if token.AT != "" && time.Now().Before(token.ATExpires.Add(-5*time.Minute)) {
95 | return nil
96 | }
97 |
98 | // 刷新 AT
99 | resp, err := h.client.STToAT(token.ST)
100 | if err != nil {
101 | return err
102 | }
103 |
104 | token.AT = resp.AccessToken
105 | if resp.Expires != "" {
106 | if t, err := time.Parse(time.RFC3339, resp.Expires); err == nil {
107 | token.ATExpires = t
108 | }
109 | }
110 | token.Email = resp.Email
111 |
112 | log.Printf("[Flow] Token %s AT 已刷新, 过期时间: %v", token.ID, token.ATExpires)
113 | return nil
114 | }
115 |
116 | // updateTokenCredits 更新 Token 余额信息
117 | func (h *GenerationHandler) updateTokenCredits(token *FlowToken) {
118 | if token.AT == "" {
119 | return
120 | }
121 |
122 | resp, err := h.client.GetCredits(token.AT)
123 | if err != nil {
124 | log.Printf("[Flow] 查询余额失败: %v", err)
125 | return
126 | }
127 |
128 | token.mu.Lock()
129 | token.Credits = resp.Credits
130 | token.UserPaygateTier = resp.UserPaygateTier
131 | token.mu.Unlock()
132 |
133 | log.Printf("[Flow] Token %s 余额: %d, Tier: %s", token.ID[:16]+"...", resp.Credits, resp.UserPaygateTier)
134 | }
135 |
136 | // ensureProjectExists 确保 Project 存在
137 | func (h *GenerationHandler) ensureProjectExists(token *FlowToken) error {
138 | token.mu.Lock()
139 | defer token.mu.Unlock()
140 |
141 | if token.ProjectID != "" {
142 | return nil
143 | }
144 |
145 | projectID, err := h.client.CreateProject(token.ST, "Flow2API")
146 | if err != nil {
147 | return err
148 | }
149 |
150 | token.ProjectID = projectID
151 | log.Printf("[Flow] Token %s 创建项目: %s", token.ID, projectID)
152 | return nil
153 | }
154 |
155 | // handleImageGeneration 处理图片生成
156 | func (h *GenerationHandler) handleImageGeneration(token *FlowToken, modelConfig ModelConfig, req GenerationRequest, streamCb StreamCallback) (*GenerationResult, error) {
157 | if streamCb != nil {
158 | streamCb(h.createStreamChunk("✨ 图片生成任务已启动\n", false))
159 | }
160 |
161 | // 上传图片 (如果有)
162 | var imageInputs []map[string]interface{}
163 | if len(req.Images) > 0 {
164 | if streamCb != nil {
165 | streamCb(h.createStreamChunk(fmt.Sprintf("上传 %d 张参考图片...\n", len(req.Images)), false))
166 | }
167 |
168 | for i, imgBytes := range req.Images {
169 | mediaID, err := h.client.UploadImage(token.AT, imgBytes, modelConfig.AspectRatio)
170 | if err != nil {
171 | return &GenerationResult{
172 | Success: false,
173 | Error: fmt.Sprintf("上传图片失败: %v", err),
174 | }, nil
175 | }
176 | imageInputs = append(imageInputs, map[string]interface{}{
177 | "name": mediaID,
178 | "imageInputType": "IMAGE_INPUT_TYPE_REFERENCE",
179 | })
180 | if streamCb != nil {
181 | streamCb(h.createStreamChunk(fmt.Sprintf("已上传第 %d/%d 张图片\n", i+1, len(req.Images)), false))
182 | }
183 | }
184 | }
185 |
186 | if streamCb != nil {
187 | streamCb(h.createStreamChunk("正在生成图片...\n", false))
188 | }
189 |
190 | // 调用生成 API
191 | result, err := h.client.GenerateImage(
192 | token.AT,
193 | token.ProjectID,
194 | req.Prompt,
195 | modelConfig.ModelName,
196 | modelConfig.AspectRatio,
197 | imageInputs,
198 | )
199 | if err != nil {
200 | token.mu.Lock()
201 | token.ErrorCount++
202 | token.mu.Unlock()
203 | return &GenerationResult{
204 | Success: false,
205 | Error: fmt.Sprintf("生成图片失败: %v", err),
206 | }, nil
207 | }
208 |
209 | if result.ImageURL == "" {
210 | return &GenerationResult{
211 | Success: false,
212 | Error: "生成结果为空",
213 | }, nil
214 | }
215 |
216 | // 更新 Token 使用
217 | token.mu.Lock()
218 | token.LastUsed = time.Now()
219 | token.ErrorCount = 0
220 | token.mu.Unlock()
221 |
222 | if streamCb != nil {
223 | streamCb(h.createStreamChunk(fmt.Sprintf("", result.ImageURL), true))
224 | }
225 |
226 | return &GenerationResult{
227 | Success: true,
228 | Type: "image",
229 | URL: result.ImageURL,
230 | }, nil
231 | }
232 |
233 | // handleVideoGeneration 处理视频生成
234 | func (h *GenerationHandler) handleVideoGeneration(token *FlowToken, modelConfig ModelConfig, req GenerationRequest, streamCb StreamCallback) (*GenerationResult, error) {
235 | if streamCb != nil {
236 | streamCb(h.createStreamChunk("✨ 视频生成任务已启动\n", false))
237 | }
238 |
239 | imageCount := len(req.Images)
240 |
241 | // 验证图片数量
242 | if modelConfig.VideoType == VideoTypeT2V {
243 | if imageCount > 0 {
244 | if streamCb != nil {
245 | streamCb(h.createStreamChunk("⚠️ 文生视频模型不支持图片,将忽略图片仅使用文本提示词\n", false))
246 | }
247 | req.Images = nil
248 | imageCount = 0
249 | }
250 | } else if modelConfig.VideoType == VideoTypeI2V {
251 | if imageCount < modelConfig.MinImages || imageCount > modelConfig.MaxImages {
252 | return &GenerationResult{
253 | Success: false,
254 | Error: fmt.Sprintf("首尾帧模型需要 %d-%d 张图片,当前提供了 %d 张", modelConfig.MinImages, modelConfig.MaxImages, imageCount),
255 | }, nil
256 | }
257 | }
258 |
259 | // 上传图片
260 | var startMediaID, endMediaID string
261 | var referenceImages []map[string]interface{}
262 |
263 | if modelConfig.VideoType == VideoTypeI2V && len(req.Images) > 0 {
264 | if streamCb != nil {
265 | streamCb(h.createStreamChunk("上传首帧图片...\n", false))
266 | }
267 | var err error
268 | startMediaID, err = h.client.UploadImage(token.AT, req.Images[0], modelConfig.AspectRatio)
269 | if err != nil {
270 | return &GenerationResult{Success: false, Error: fmt.Sprintf("上传首帧失败: %v", err)}, nil
271 | }
272 |
273 | if len(req.Images) == 2 {
274 | if streamCb != nil {
275 | streamCb(h.createStreamChunk("上传尾帧图片...\n", false))
276 | }
277 | endMediaID, err = h.client.UploadImage(token.AT, req.Images[1], modelConfig.AspectRatio)
278 | if err != nil {
279 | return &GenerationResult{Success: false, Error: fmt.Sprintf("上传尾帧失败: %v", err)}, nil
280 | }
281 | }
282 | } else if modelConfig.VideoType == VideoTypeR2V && len(req.Images) > 0 {
283 | if streamCb != nil {
284 | streamCb(h.createStreamChunk(fmt.Sprintf("上传 %d 张参考图片...\n", len(req.Images)), false))
285 | }
286 | for _, imgBytes := range req.Images {
287 | mediaID, err := h.client.UploadImage(token.AT, imgBytes, modelConfig.AspectRatio)
288 | if err != nil {
289 | return &GenerationResult{Success: false, Error: fmt.Sprintf("上传图片失败: %v", err)}, nil
290 | }
291 | referenceImages = append(referenceImages, map[string]interface{}{
292 | "imageUsageType": "IMAGE_USAGE_TYPE_ASSET",
293 | "mediaId": mediaID,
294 | })
295 | }
296 | }
297 |
298 | if streamCb != nil {
299 | streamCb(h.createStreamChunk("提交视频生成任务...\n", false))
300 | }
301 |
302 | // 调用生成 API
303 | var videoResp *GenerateVideoResponse
304 | var err error
305 |
306 | userTier := token.UserPaygateTier
307 | if userTier == "" {
308 | userTier = "PAYGATE_TIER_ONE"
309 | }
310 |
311 | switch modelConfig.VideoType {
312 | case VideoTypeI2V:
313 | videoResp, err = h.client.GenerateVideoStartEnd(
314 | token.AT, token.ProjectID, req.Prompt,
315 | modelConfig.ModelKey, modelConfig.AspectRatio,
316 | startMediaID, endMediaID, userTier,
317 | )
318 | case VideoTypeR2V:
319 | videoResp, err = h.client.GenerateVideoReferenceImages(
320 | token.AT, token.ProjectID, req.Prompt,
321 | modelConfig.ModelKey, modelConfig.AspectRatio,
322 | referenceImages, userTier,
323 | )
324 | default: // T2V
325 | videoResp, err = h.client.GenerateVideoText(
326 | token.AT, token.ProjectID, req.Prompt,
327 | modelConfig.ModelKey, modelConfig.AspectRatio, userTier,
328 | )
329 | }
330 |
331 | if err != nil {
332 | token.mu.Lock()
333 | token.ErrorCount++
334 | token.mu.Unlock()
335 | return &GenerationResult{Success: false, Error: fmt.Sprintf("提交任务失败: %v", err)}, nil
336 | }
337 |
338 | if videoResp.TaskID == "" {
339 | return &GenerationResult{Success: false, Error: "任务创建失败"}, nil
340 | }
341 |
342 | if streamCb != nil {
343 | streamCb(h.createStreamChunk("视频生成中...\n", false))
344 | }
345 |
346 | // 轮询结果
347 | videoURL, err := h.pollVideoResult(token, videoResp.TaskID, videoResp.SceneID, streamCb)
348 | if err != nil {
349 | return &GenerationResult{Success: false, Error: err.Error()}, nil
350 | }
351 |
352 | // 更新 Token 使用
353 | token.mu.Lock()
354 | token.LastUsed = time.Now()
355 | token.ErrorCount = 0
356 | token.mu.Unlock()
357 |
358 | if streamCb != nil {
359 | streamCb(h.createStreamChunk(fmt.Sprintf("", videoURL), true))
360 | }
361 |
362 | return &GenerationResult{
363 | Success: true,
364 | Type: "video",
365 | URL: videoURL,
366 | }, nil
367 | }
368 |
369 | // pollVideoResult 轮询视频生成结果
370 | func (h *GenerationHandler) pollVideoResult(token *FlowToken, taskID, sceneID string, streamCb StreamCallback) (string, error) {
371 | operations := []map[string]interface{}{{
372 | "operation": map[string]interface{}{
373 | "name": taskID,
374 | },
375 | "sceneId": sceneID,
376 | }}
377 |
378 | maxAttempts := h.client.config.MaxPollAttempts
379 | pollInterval := h.client.config.PollInterval
380 |
381 | for i := 0; i < maxAttempts; i++ {
382 | time.Sleep(time.Duration(pollInterval) * time.Second)
383 |
384 | resp, err := h.client.CheckVideoStatus(token.AT, operations)
385 | if err != nil {
386 | continue
387 | }
388 |
389 | // 进度更新
390 | if streamCb != nil && i%7 == 0 {
391 | progress := min(i*100/maxAttempts, 95)
392 | streamCb(h.createStreamChunk(fmt.Sprintf("生成进度: %d%%\n", progress), false))
393 | }
394 |
395 | switch resp.Status {
396 | case "MEDIA_GENERATION_STATUS_SUCCESSFUL":
397 | if resp.VideoURL != "" {
398 | return resp.VideoURL, nil
399 | }
400 | case "MEDIA_GENERATION_STATUS_ERROR_UNKNOWN",
401 | "MEDIA_GENERATION_STATUS_ERROR_NSFW",
402 | "MEDIA_GENERATION_STATUS_ERROR_PERSON",
403 | "MEDIA_GENERATION_STATUS_ERROR_SAFETY":
404 | return "", fmt.Errorf("视频生成失败: %s", resp.Status)
405 | }
406 | }
407 |
408 | return "", fmt.Errorf("视频生成超时 (已轮询 %d 次)", maxAttempts)
409 | }
410 |
411 | // createStreamChunk 创建流式响应块
412 | func (h *GenerationHandler) createStreamChunk(content string, isFinish bool) string {
413 | chunk := map[string]interface{}{
414 | "id": fmt.Sprintf("chatcmpl-%d", time.Now().Unix()),
415 | "object": "chat.completion.chunk",
416 | "created": time.Now().Unix(),
417 | "model": "flow2api",
418 | "choices": []map[string]interface{}{{
419 | "index": 0,
420 | "delta": map[string]interface{}{},
421 | "finish_reason": nil,
422 | }},
423 | }
424 |
425 | if isFinish {
426 | chunk["choices"].([]map[string]interface{})[0]["delta"].(map[string]interface{})["content"] = content
427 | chunk["choices"].([]map[string]interface{})[0]["finish_reason"] = "stop"
428 | } else {
429 | chunk["choices"].([]map[string]interface{})[0]["delta"].(map[string]interface{})["reasoning_content"] = content
430 | }
431 |
432 | data, _ := json.Marshal(chunk)
433 | return fmt.Sprintf("data: %s\n\n", string(data))
434 | }
435 |
436 | func min(a, b int) int {
437 | if a < b {
438 | return a
439 | }
440 | return b
441 | }
442 |
--------------------------------------------------------------------------------
/src/proxy/singbox.go:
--------------------------------------------------------------------------------
1 | package proxy
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net"
8 | "net/http"
9 | "net/url"
10 | "strconv"
11 | "strings"
12 | "sync"
13 | "time"
14 |
15 | box "github.com/sagernet/sing-box"
16 | "github.com/sagernet/sing-box/include"
17 | "github.com/sagernet/sing-box/option"
18 |
19 | // 启用 QUIC 协议支持(hysteria, hysteria2, tuic)
20 | _ "github.com/sagernet/sing-quic/hysteria"
21 | _ "github.com/sagernet/sing-quic/hysteria2"
22 | _ "github.com/sagernet/sing-quic/tuic"
23 | )
24 |
25 | type SingboxManager struct {
26 | mu sync.Mutex
27 | instances map[int]*SingboxInstance
28 | basePort int
29 | ready bool
30 | }
31 |
32 | // SingboxInstance sing-box 实例
33 | type SingboxInstance struct {
34 | Port int
35 | Box *box.Box
36 | Ctx context.Context
37 | Cancel context.CancelFunc
38 | Running bool
39 | ProxyURL string
40 | Node *ProxyNode
41 | }
42 |
43 | var singboxMgr = &SingboxManager{
44 | instances: make(map[int]*SingboxInstance),
45 | basePort: 11800,
46 | ready: true,
47 | }
48 |
49 | // IsSingboxProtocol 所有协议都由 sing-box 处理
50 | func IsSingboxProtocol(protocol string) bool {
51 | switch protocol {
52 | case "vmess", "vless", "shadowsocks", "trojan", "socks", "http",
53 | "hysteria", "hysteria2", "hy2", "tuic", "wireguard", "anytls":
54 | return true
55 | }
56 | return false
57 | }
58 |
59 | func CanSingboxHandle(protocol string) bool {
60 | return IsSingboxProtocol(protocol)
61 | }
62 |
63 | func (sm *SingboxManager) IsAvailable() bool {
64 | return sm.ready
65 | }
66 |
67 | func (sm *SingboxManager) Start(node *ProxyNode) (string, error) {
68 | sm.mu.Lock()
69 | defer sm.mu.Unlock()
70 |
71 | // 分配端口
72 | port := sm.findAvailablePort()
73 | if port == 0 {
74 | return "", fmt.Errorf("无可用端口")
75 | }
76 | configJSON := sm.generateConfigJSON(node, port)
77 | ctx, cancel := context.WithCancel(context.Background())
78 | ctx = box.Context(ctx, include.InboundRegistry(), include.OutboundRegistry(),
79 | include.EndpointRegistry(), include.DNSTransportRegistry(), include.ServiceRegistry())
80 | var opts option.Options
81 | err := opts.UnmarshalJSONContext(ctx, []byte(configJSON))
82 | if err != nil {
83 | cancel()
84 | return "", fmt.Errorf("解析配置失败: %w", err)
85 | }
86 |
87 | singBox, err := box.New(box.Options{
88 | Context: ctx,
89 | Options: opts,
90 | })
91 | if err != nil {
92 | cancel()
93 | return "", fmt.Errorf("创建 sing-box 失败: %w", err)
94 | }
95 |
96 | if err := singBox.Start(); err != nil {
97 | cancel()
98 | return "", fmt.Errorf("启动 sing-box 失败: %w", err)
99 | }
100 |
101 | // 等待端口就绪
102 | proxyURL := fmt.Sprintf("http://127.0.0.1:%d", port)
103 | portReady := false
104 | for i := 0; i < 20; i++ {
105 | time.Sleep(50 * time.Millisecond)
106 | conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
107 | if err == nil {
108 | conn.Close()
109 | portReady = true
110 | break
111 | }
112 | }
113 | if !portReady {
114 | singBox.Close()
115 | cancel()
116 | return "", fmt.Errorf("端口 %d 未就绪", port)
117 | }
118 | proxyURLParsed, _ := url.Parse(proxyURL)
119 | testClient := &http.Client{
120 | Transport: &http.Transport{
121 | Proxy: http.ProxyURL(proxyURLParsed),
122 | },
123 | Timeout: 5 * time.Second,
124 | }
125 | testResp, testErr := testClient.Get("https://www.gstatic.com/generate_204")
126 | if testErr != nil {
127 | singBox.Close()
128 | cancel()
129 | return "", fmt.Errorf("连通性测试失败: %w", testErr)
130 | }
131 | testResp.Body.Close()
132 | if testResp.StatusCode != 204 && testResp.StatusCode != 200 {
133 | singBox.Close()
134 | cancel()
135 | return "", fmt.Errorf("连通性测试状态码: %d", testResp.StatusCode)
136 | }
137 |
138 | instance := &SingboxInstance{
139 | Port: port,
140 | Box: singBox,
141 | Ctx: ctx,
142 | Cancel: cancel,
143 | Running: true,
144 | ProxyURL: proxyURL,
145 | Node: node,
146 | }
147 |
148 | sm.instances[port] = instance
149 | node.LocalPort = port
150 | return proxyURL, nil
151 | }
152 |
153 | // Stop 停止实例
154 | func (sm *SingboxManager) Stop(port int) {
155 | sm.mu.Lock()
156 | defer sm.mu.Unlock()
157 |
158 | if inst, ok := sm.instances[port]; ok {
159 | if inst.Box != nil {
160 | inst.Box.Close()
161 | }
162 | if inst.Cancel != nil {
163 | inst.Cancel()
164 | }
165 | inst.Running = false
166 | delete(sm.instances, port)
167 | }
168 | }
169 |
170 | // StopAll 停止所有实例
171 | func (sm *SingboxManager) StopAll() {
172 | sm.mu.Lock()
173 | defer sm.mu.Unlock()
174 |
175 | for port, inst := range sm.instances {
176 | if inst.Box != nil {
177 | inst.Box.Close()
178 | }
179 | if inst.Cancel != nil {
180 | inst.Cancel()
181 | }
182 | delete(sm.instances, port)
183 | }
184 | }
185 |
186 | func (sm *SingboxManager) findAvailablePort() int {
187 | for port := sm.basePort; port < sm.basePort+1000; port++ {
188 | if _, exists := sm.instances[port]; !exists {
189 | conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 50*time.Millisecond)
190 | if err != nil {
191 | return port
192 | }
193 | conn.Close()
194 | }
195 | }
196 | return 0
197 | }
198 |
199 | // generateConfigJSON 生成 sing-box JSON 配置
200 | func (sm *SingboxManager) generateConfigJSON(node *ProxyNode, localPort int) string {
201 | outbound := sm.buildOutboundJSON(node)
202 |
203 | config := map[string]interface{}{
204 | "log": map[string]interface{}{
205 | "disabled": true,
206 | },
207 | "inbounds": []map[string]interface{}{
208 | {
209 | "type": "http",
210 | "tag": "http-in",
211 | "listen": "127.0.0.1",
212 | "listen_port": localPort,
213 | },
214 | },
215 | "outbounds": []interface{}{
216 | outbound,
217 | },
218 | }
219 |
220 | data, _ := json.Marshal(config)
221 | return string(data)
222 | }
223 |
224 | // buildOutboundJSON 构建 outbound JSON 配置
225 | func (sm *SingboxManager) buildOutboundJSON(node *ProxyNode) map[string]interface{} {
226 | sni := node.SNI
227 | if sni == "" {
228 | sni = node.Server
229 | }
230 |
231 | switch node.Protocol {
232 | case "hysteria2", "hy2":
233 | return map[string]interface{}{
234 | "type": "hysteria2",
235 | "tag": "proxy",
236 | "server": node.Server,
237 | "server_port": node.Port,
238 | "password": node.Password,
239 | "tls": map[string]interface{}{
240 | "enabled": true,
241 | "insecure": true,
242 | "server_name": sni,
243 | },
244 | }
245 |
246 | case "hysteria":
247 | return map[string]interface{}{
248 | "type": "hysteria",
249 | "tag": "proxy",
250 | "server": node.Server,
251 | "server_port": node.Port,
252 | "auth_str": node.Password,
253 | "tls": map[string]interface{}{
254 | "enabled": true,
255 | "insecure": true,
256 | "server_name": sni,
257 | },
258 | }
259 |
260 | case "tuic":
261 | return map[string]interface{}{
262 | "type": "tuic",
263 | "tag": "proxy",
264 | "server": node.Server,
265 | "server_port": node.Port,
266 | "uuid": node.UUID,
267 | "password": node.Password,
268 | "tls": map[string]interface{}{
269 | "enabled": true,
270 | "insecure": true,
271 | "server_name": sni,
272 | },
273 | }
274 |
275 | case "vmess":
276 | out := map[string]interface{}{
277 | "type": "vmess",
278 | "tag": "proxy",
279 | "server": node.Server,
280 | "server_port": node.Port,
281 | "uuid": node.UUID,
282 | "security": node.Security,
283 | "alter_id": node.AlterId,
284 | }
285 | if node.TLS {
286 | out["tls"] = map[string]interface{}{
287 | "enabled": true,
288 | "insecure": true,
289 | "server_name": sni,
290 | }
291 | }
292 | if transport := sm.buildTransportJSON(node); transport != nil {
293 | out["transport"] = transport
294 | }
295 | return out
296 |
297 | case "vless":
298 | out := map[string]interface{}{
299 | "type": "vless",
300 | "tag": "proxy",
301 | "server": node.Server,
302 | "server_port": node.Port,
303 | "uuid": node.UUID,
304 | }
305 | if node.Flow != "" {
306 | out["flow"] = node.Flow
307 | }
308 | if node.Security == "reality" {
309 | fp := node.Fingerprint
310 | if fp == "" {
311 | fp = "chrome"
312 | }
313 | out["tls"] = map[string]interface{}{
314 | "enabled": true,
315 | "server_name": node.SNI,
316 | "reality": map[string]interface{}{
317 | "enabled": true,
318 | "public_key": node.PublicKey,
319 | "short_id": node.ShortId,
320 | },
321 | "utls": map[string]interface{}{
322 | "enabled": true,
323 | "fingerprint": fp,
324 | },
325 | }
326 | } else if node.TLS {
327 | out["tls"] = map[string]interface{}{
328 | "enabled": true,
329 | "insecure": true,
330 | "server_name": sni,
331 | }
332 | }
333 | if transport := sm.buildTransportJSON(node); transport != nil {
334 | out["transport"] = transport
335 | }
336 | return out
337 |
338 | case "shadowsocks":
339 | return map[string]interface{}{
340 | "type": "shadowsocks",
341 | "tag": "proxy",
342 | "server": node.Server,
343 | "server_port": node.Port,
344 | "method": node.Method,
345 | "password": node.Password,
346 | }
347 |
348 | case "trojan":
349 | out := map[string]interface{}{
350 | "type": "trojan",
351 | "tag": "proxy",
352 | "server": node.Server,
353 | "server_port": node.Port,
354 | "password": node.Password,
355 | "tls": map[string]interface{}{
356 | "enabled": true,
357 | "insecure": true,
358 | "server_name": sni,
359 | },
360 | }
361 | if transport := sm.buildTransportJSON(node); transport != nil {
362 | out["transport"] = transport
363 | }
364 | return out
365 |
366 | default:
367 | return map[string]interface{}{
368 | "type": "direct",
369 | "tag": "proxy",
370 | }
371 | }
372 | }
373 |
374 | // buildTransportJSON 构建传输层 JSON 配置
375 | func (sm *SingboxManager) buildTransportJSON(node *ProxyNode) map[string]interface{} {
376 | switch node.Network {
377 | case "ws":
378 | transport := map[string]interface{}{
379 | "type": "ws",
380 | "path": node.Path,
381 | }
382 | if node.Host != "" {
383 | transport["headers"] = map[string]string{
384 | "Host": node.Host,
385 | }
386 | }
387 | return transport
388 | case "grpc":
389 | return map[string]interface{}{
390 | "type": "grpc",
391 | "service_name": node.Path,
392 | }
393 | case "httpupgrade":
394 | return map[string]interface{}{
395 | "type": "httpupgrade",
396 | "path": node.Path,
397 | "host": node.Host,
398 | }
399 | case "h2", "http":
400 | return map[string]interface{}{
401 | "type": "http",
402 | "path": node.Path,
403 | "host": []string{node.Host},
404 | }
405 | }
406 | return nil
407 | }
408 |
409 | // GetSingboxManager 获取 sing-box 管理器
410 | func GetSingboxManager() *SingboxManager {
411 | return singboxMgr
412 | }
413 |
414 | // InitSingbox 初始化 sing-box(内置 core 无需初始化)
415 | func InitSingbox() {
416 |
417 | }
418 |
419 | // TrySingboxStart 尝试用 sing-box 启动节点(xray 失败时的回退)
420 | func TrySingboxStart(node *ProxyNode) (string, error) {
421 | if !CanSingboxHandle(node.Protocol) {
422 | return "", fmt.Errorf("sing-box 不支持协议: %s", node.Protocol)
423 | }
424 | return singboxMgr.Start(node)
425 | }
426 |
427 | // StopSingbox 停止指定端口的 sing-box 实例
428 | func StopSingbox(port int) {
429 | singboxMgr.Stop(port)
430 | }
431 |
432 | // ParseProxyLinkWithSingbox 使用 sing-box 解析代理链接
433 | func ParseProxyLinkWithSingbox(link string) *ProxyNode {
434 | node := Manager.parseLine(link)
435 | if node != nil {
436 | return node
437 | }
438 |
439 | link = strings.TrimSpace(link)
440 | if strings.HasPrefix(link, "hy2://") || strings.HasPrefix(link, "hysteria2://") {
441 | return parseHysteria2(link)
442 | }
443 | if strings.HasPrefix(link, "tuic://") {
444 | return parseTUIC(link)
445 | }
446 |
447 | return nil
448 | }
449 |
450 | // parseTUIC 解析 TUIC 链接
451 | func parseTUIC(link string) *ProxyNode {
452 | origLink := link
453 | link = strings.TrimPrefix(link, "tuic://")
454 |
455 | var name string
456 | if idx := strings.LastIndex(link, "#"); idx != -1 {
457 | name, _ = url.QueryUnescape(link[idx+1:])
458 | link = link[:idx]
459 | }
460 |
461 | var params string
462 | if idx := strings.Index(link, "?"); idx != -1 {
463 | params = link[idx+1:]
464 | link = link[:idx]
465 | }
466 |
467 | atIdx := strings.LastIndex(link, "@")
468 | if atIdx == -1 {
469 | return nil
470 | }
471 |
472 | userPart := link[:atIdx]
473 | hostPart := link[atIdx+1:]
474 |
475 | var uuid, password string
476 | if colonIdx := strings.Index(userPart, ":"); colonIdx != -1 {
477 | uuid = userPart[:colonIdx]
478 | password = userPart[colonIdx+1:]
479 | } else {
480 | uuid = userPart
481 | }
482 |
483 | var server string
484 | var port int
485 | if lastColon := strings.LastIndex(hostPart, ":"); lastColon != -1 {
486 | server = hostPart[:lastColon]
487 | port, _ = strconv.Atoi(hostPart[lastColon+1:])
488 | }
489 |
490 | node := &ProxyNode{
491 | Protocol: "tuic",
492 | Name: name,
493 | Server: server,
494 | Port: port,
495 | UUID: uuid,
496 | Password: password,
497 | Raw: origLink,
498 | }
499 |
500 | for _, param := range strings.Split(params, "&") {
501 | if kv := strings.SplitN(param, "=", 2); len(kv) == 2 {
502 | switch kv[0] {
503 | case "sni":
504 | node.SNI = kv[1]
505 | case "alpn":
506 | node.ALPN = kv[1]
507 | }
508 | }
509 | }
510 |
511 | if node.Server == "" || node.Port == 0 {
512 | return nil
513 | }
514 |
515 | return node
516 | }
517 |
--------------------------------------------------------------------------------
/src/pool/pool_client.go:
--------------------------------------------------------------------------------
1 | package pool
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | "net/url"
10 | "sync"
11 | "time"
12 |
13 | "business2api/src/logger"
14 |
15 | "github.com/gorilla/websocket"
16 | )
17 |
18 | type BrowserRegisterResult struct {
19 | Success bool
20 | Email string
21 | FullName string
22 | SecureCookies []Cookie
23 | Authorization string
24 | ConfigID string
25 | CSESIDX string
26 | Error error
27 | }
28 |
29 | // RunBrowserRegisterFunc 注册函数类型
30 | type RunBrowserRegisterFunc func(headless bool, proxy string, id int) *BrowserRegisterResult
31 |
32 | // 客户端版本
33 | const ClientVersion = "2.0.0"
34 |
35 | var (
36 | RunBrowserRegister RunBrowserRegisterFunc
37 | ClientHeadless bool
38 | ClientProxy string
39 | GetClientProxy func() string // 获取代理的函数
40 | ReleaseProxy func(proxyURL string) // 释放代理的函数
41 | DefaultProxyCount = 3 // 客户端模式默认启动的代理实例数
42 | IsProxyReady func() bool // 检查代理是否就绪
43 | WaitProxyReady func(timeout time.Duration) bool // 等待代理就绪
44 | GetHealthyCount func() int // 获取健康代理数量
45 | proxyReadyTimeout = 30 * time.Second // 代理就绪超时时间(减少等待)
46 | )
47 |
48 | // PoolClient 号池客户端
49 | type PoolClient struct {
50 | config PoolServerConfig
51 | conn *websocket.Conn
52 | send chan []byte
53 | done chan struct{}
54 | reconnect chan struct{}
55 | stopPump chan struct{} // 停止当前pump
56 | mu sync.Mutex
57 | writeMu sync.Mutex // WebSocket写入锁
58 | isRunning bool
59 | taskSem chan struct{} // 任务并发信号量
60 | }
61 |
62 | // NewPoolClient 创建号池客户端
63 | func NewPoolClient(config PoolServerConfig) *PoolClient {
64 | threads := config.ClientThreads
65 | if threads <= 0 {
66 | threads = 1
67 | }
68 | return &PoolClient{
69 | config: config,
70 | send: make(chan []byte, 256),
71 | done: make(chan struct{}),
72 | reconnect: make(chan struct{}, 1),
73 | taskSem: make(chan struct{}, threads),
74 | }
75 | }
76 |
77 | // Start 启动客户端
78 | func (pc *PoolClient) Start() error {
79 | pc.mu.Lock()
80 | pc.isRunning = true
81 | pc.mu.Unlock()
82 |
83 | // 连接循环
84 | for pc.isRunning {
85 | if err := pc.connect(); err != nil {
86 | logger.Warn("连接服务器失败: %v, 5秒后重试...", err)
87 | time.Sleep(5 * time.Second)
88 | continue
89 | }
90 | pc.work()
91 | select {
92 | case <-pc.done:
93 | return nil
94 | case <-pc.reconnect:
95 | log.Printf("[PoolClient] 准备重连...")
96 | time.Sleep(2 * time.Second)
97 | }
98 | }
99 |
100 | return nil
101 | }
102 | func (pc *PoolClient) Stop() {
103 | pc.mu.Lock()
104 | pc.isRunning = false
105 | pc.mu.Unlock()
106 | close(pc.done)
107 | }
108 |
109 | // connect 连接到服务器
110 | func (pc *PoolClient) connect() error {
111 | u, err := url.Parse(pc.config.ServerAddr)
112 | if err != nil {
113 | return fmt.Errorf("解析服务器地址失败: %w", err)
114 | }
115 | wsScheme := "ws"
116 | if u.Scheme == "https" {
117 | wsScheme = "wss"
118 | }
119 | wsURL := fmt.Sprintf("%s://%s/ws", wsScheme, u.Host)
120 | if pc.config.Secret != "" {
121 | wsURL += "?secret=" + pc.config.Secret
122 | }
123 |
124 | logger.Debug("连接到 %s", wsURL)
125 |
126 | conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
127 | if err != nil {
128 | return fmt.Errorf("WebSocket连接失败: %w", err)
129 | }
130 |
131 | pc.conn = conn
132 | threads := pc.config.ClientThreads
133 | if threads <= 0 {
134 | threads = 1
135 | }
136 | pc.sendMessage(WSMessage{
137 | Type: WSMsgClientReady,
138 | Version: ClientVersion,
139 | Timestamp: time.Now().Unix(),
140 | Data: map[string]interface{}{
141 | "max_threads": threads,
142 | "client_version": ClientVersion,
143 | "protocol_version": ProtocolVersion,
144 | },
145 | })
146 |
147 | return nil
148 | }
149 | func (pc *PoolClient) work() {
150 | pc.stopPump = make(chan struct{})
151 | go pc.writePump() // 消息发送
152 | go pc.heartbeatPump() // 独立心跳保活
153 | pc.readPump() // 消息读取(阻塞)
154 | close(pc.stopPump)
155 | }
156 |
157 | func (pc *PoolClient) heartbeatPump() {
158 | ticker := time.NewTicker(15 * time.Second)
159 | defer ticker.Stop()
160 |
161 | for {
162 | select {
163 | case <-pc.done:
164 | return
165 | case <-pc.stopPump:
166 | return
167 | case <-ticker.C:
168 | // 发送心跳保持连接活跃
169 | pc.writeMu.Lock()
170 | pc.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
171 | err := pc.conn.WriteMessage(websocket.PingMessage, nil)
172 | pc.writeMu.Unlock()
173 | if err != nil {
174 | logger.Debug("[PoolClient] 心跳发送失败: %v", err)
175 | return
176 | }
177 | }
178 | }
179 | }
180 | func (pc *PoolClient) writePump() {
181 | // 任务请求间隔60秒
182 | taskTicker := time.NewTicker(60 * time.Second)
183 | defer taskTicker.Stop()
184 |
185 | for {
186 | select {
187 | case <-pc.done:
188 | return
189 | case <-pc.stopPump:
190 | return
191 | case message := <-pc.send:
192 | pc.writeMu.Lock()
193 | pc.conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
194 | err := pc.conn.WriteMessage(websocket.TextMessage, message)
195 | pc.writeMu.Unlock()
196 | if err != nil {
197 | log.Printf("[PoolClient] 发送消息失败: %v", err)
198 | pc.triggerReconnect()
199 | return
200 | }
201 | case <-taskTicker.C:
202 | // 定期请求任务
203 | pc.sendMessage(WSMessage{
204 | Type: WSMsgRequestTask,
205 | Timestamp: time.Now().Unix(),
206 | })
207 | }
208 | }
209 | }
210 |
211 | // readPump 读取消息
212 | func (pc *PoolClient) readPump() {
213 | defer func() {
214 | pc.conn.Close()
215 | pc.triggerReconnect()
216 | }()
217 |
218 | // 延长读取超时到240秒(4分钟),确保不会因为任务执行而断开
219 | pc.conn.SetReadDeadline(time.Now().Add(240 * time.Second))
220 | pc.conn.SetPongHandler(func(string) error {
221 | pc.conn.SetReadDeadline(time.Now().Add(240 * time.Second))
222 | return nil
223 | })
224 |
225 | for {
226 | _, message, err := pc.conn.ReadMessage()
227 | if err != nil {
228 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
229 | log.Printf("[PoolClient] 读取错误: %v", err)
230 | }
231 | return
232 | }
233 |
234 | // 收到消息时重置读取超时
235 | pc.conn.SetReadDeadline(time.Now().Add(240 * time.Second))
236 |
237 | var msg WSMessage
238 | if err := json.Unmarshal(message, &msg); err != nil {
239 | continue
240 | }
241 |
242 | pc.handleMessage(msg)
243 | }
244 | }
245 |
246 | // handleMessage 处理服务器消息
247 | func (pc *PoolClient) handleMessage(msg WSMessage) {
248 | switch msg.Type {
249 | case WSMsgHeartbeat:
250 | // 立即响应心跳
251 | pc.sendMessage(WSMessage{
252 | Type: WSMsgHeartbeatAck,
253 | Timestamp: time.Now().Unix(),
254 | })
255 |
256 | case WSMsgTaskRegister:
257 | // 注册任务(独立心跳线程已保活,无需额外处理)
258 | go pc.handleRegisterTask(msg.Data)
259 |
260 | case WSMsgTaskRefresh:
261 | // 续期任务
262 | go pc.handleRefreshTask(msg.Data)
263 |
264 | case WSMsgStatus:
265 | // 状态同步
266 | logger.Debug("收到状态同步: %v", msg.Data)
267 | }
268 | }
269 |
270 | // handleRegisterTask 处理注册任务(每次只处理1个)
271 | func (pc *PoolClient) handleRegisterTask(data map[string]interface{}) {
272 | // 获取信号量(控制并发数)
273 | pc.taskSem <- struct{}{}
274 | defer func() { <-pc.taskSem }()
275 |
276 | // 生成任务ID用于日志
277 | taskID := time.Now().UnixNano() % 1000
278 |
279 | logger.Info("收到注册任务 [%d]", taskID)
280 | if GetHealthyCount != nil && GetHealthyCount() >= 1 {
281 | // 已有健康代理,直接开始
282 | } else if WaitProxyReady != nil {
283 | if !WaitProxyReady(proxyReadyTimeout) {
284 | logger.Warn("代理未就绪,使用静态代理: %s", ClientProxy)
285 | }
286 | }
287 |
288 | // 获取代理(优先使用代理池)
289 | currentProxy := ClientProxy
290 | if GetClientProxy != nil {
291 | currentProxy = GetClientProxy()
292 | }
293 | logger.Info("[注册 %d] 使用代理: %s", taskID, currentProxy)
294 | result := RunBrowserRegister(ClientHeadless, currentProxy, int(taskID))
295 |
296 | // 任务完成后释放代理
297 | if ReleaseProxy != nil && currentProxy != "" && currentProxy != ClientProxy {
298 | ReleaseProxy(currentProxy)
299 | }
300 |
301 | if result.Success {
302 | // 上传账号到服务器
303 | if err := pc.uploadAccount(result, true); err != nil {
304 | logger.Error("上传注册结果失败: %v", err)
305 | pc.sendRegisterResult(false, "", err.Error())
306 | } else {
307 | logger.Info("✅ 注册成功: %s", result.Email)
308 | pc.sendRegisterResult(true, result.Email, "")
309 | }
310 | } else {
311 | errMsg := "未知错误"
312 | if result.Error != nil {
313 | errMsg = result.Error.Error()
314 | }
315 | logger.Warn("❌ 注册失败: %s", errMsg)
316 | pc.sendRegisterResult(false, "", errMsg)
317 | }
318 | }
319 |
320 | // handleRefreshTask 处理续期任务
321 | func (pc *PoolClient) handleRefreshTask(data map[string]interface{}) {
322 | // 获取信号量
323 | pc.taskSem <- struct{}{}
324 | defer func() { <-pc.taskSem }()
325 |
326 | email, _ := data["email"].(string)
327 | if email == "" {
328 | logger.Warn("续期任务缺少email")
329 | return
330 | }
331 |
332 | logger.Info("收到续期任务: %s", email)
333 |
334 | // 检查代理:如果已有健康代理则不等待
335 | if GetHealthyCount != nil && GetHealthyCount() >= 1 {
336 | // 已有健康代理,直接开始
337 | } else if WaitProxyReady != nil {
338 | if !WaitProxyReady(proxyReadyTimeout) {
339 | logger.Warn("代理未就绪,使用静态代理: %s", Proxy)
340 | }
341 | }
342 |
343 | // 构建临时账号对象
344 | acc := &Account{
345 | Data: AccountData{
346 | Email: email,
347 | },
348 | }
349 |
350 | // 从data中提取cookies
351 | if cookiesData, ok := data["cookies"].([]interface{}); ok {
352 | for _, c := range cookiesData {
353 | if cm, ok := c.(map[string]interface{}); ok {
354 | acc.Data.Cookies = append(acc.Data.Cookies, Cookie{
355 | Name: getString(cm, "name"),
356 | Value: getString(cm, "value"),
357 | Domain: getString(cm, "domain"),
358 | })
359 | }
360 | }
361 | }
362 |
363 | if auth, ok := data["authorization"].(string); ok {
364 | acc.Data.Authorization = auth
365 | }
366 | if configID, ok := data["config_id"].(string); ok {
367 | acc.ConfigID = configID
368 | }
369 | if csesidx, ok := data["csesidx"].(string); ok {
370 | acc.CSESIDX = csesidx
371 | }
372 |
373 | // 获取代理(优先使用代理池)
374 | currentProxy := Proxy
375 | if GetClientProxy != nil {
376 | currentProxy = GetClientProxy()
377 | }
378 |
379 | // 执行浏览器刷新
380 | result := RefreshCookieWithBrowser(acc, BrowserRefreshHeadless, currentProxy)
381 |
382 | // 任务完成后释放代理
383 | if ReleaseProxy != nil && currentProxy != "" && currentProxy != Proxy {
384 | ReleaseProxy(currentProxy)
385 | }
386 |
387 | if result.Success {
388 | logger.Info("✅ 账号续期成功: %s", email)
389 |
390 | // 使用刷新后的新值(如果有的话)
391 | authorization := acc.Data.Authorization
392 | if result.Authorization != "" {
393 | authorization = result.Authorization
394 | }
395 | configID := acc.ConfigID
396 | if result.ConfigID != "" {
397 | configID = result.ConfigID
398 | }
399 | csesidx := acc.CSESIDX
400 | if result.CSESIDX != "" {
401 | csesidx = result.CSESIDX
402 | }
403 |
404 | // 上传更新后的账号数据到服务器
405 | uploadReq := &AccountUploadRequest{
406 | Email: email,
407 | Cookies: result.SecureCookies,
408 | Authorization: authorization,
409 | ConfigID: configID,
410 | CSESIDX: csesidx,
411 | IsNew: false,
412 | }
413 | logger.Info("[%s] 上传续期数据: configID=%s, csesidx=%s, auth长度=%d",
414 | email, configID, csesidx, len(authorization))
415 | if err := pc.uploadAccountData(uploadReq); err != nil {
416 | logger.Warn("上传续期数据失败: %v", err)
417 | }
418 | pc.sendRefreshResult(email, true, result.SecureCookies, "")
419 | } else {
420 | errMsg := "未知错误"
421 | if result.Error != nil {
422 | errMsg = result.Error.Error()
423 | }
424 | logger.Warn("❌ 账号续期失败 %s: %s", email, errMsg)
425 | pc.sendRefreshResult(email, false, nil, errMsg)
426 | }
427 | }
428 |
429 | // sendMessage 发送消息
430 | func (pc *PoolClient) sendMessage(msg WSMessage) {
431 | data, err := json.Marshal(msg)
432 | if err != nil {
433 | return
434 | }
435 | select {
436 | case pc.send <- data:
437 | default:
438 | logger.Warn("发送队列已满")
439 | }
440 | }
441 |
442 | // uploadAccount 上传注册结果到服务器
443 | func (pc *PoolClient) uploadAccount(result *BrowserRegisterResult, isNew bool) error {
444 | // 构建cookie字符串
445 | var cookieStr string
446 | for i, c := range result.SecureCookies {
447 | if i > 0 {
448 | cookieStr += "; "
449 | }
450 | cookieStr += c.Name + "=" + c.Value
451 | }
452 |
453 | req := &AccountUploadRequest{
454 | Email: result.Email,
455 | FullName: result.FullName,
456 | Cookies: result.SecureCookies,
457 | CookieString: cookieStr,
458 | Authorization: result.Authorization,
459 | ConfigID: result.ConfigID,
460 | CSESIDX: result.CSESIDX,
461 | IsNew: isNew,
462 | }
463 | return pc.uploadAccountData(req)
464 | }
465 |
466 | // uploadAccountData 上传账号数据到服务器(带重试)
467 | func (pc *PoolClient) uploadAccountData(req *AccountUploadRequest) error {
468 | u, err := url.Parse(pc.config.ServerAddr)
469 | if err != nil {
470 | return err
471 | }
472 |
473 | uploadURL := fmt.Sprintf("%s://%s/pool/upload-account", u.Scheme, u.Host)
474 |
475 | data, err := json.Marshal(req)
476 | if err != nil {
477 | return err
478 | }
479 |
480 | maxRetries := 3
481 | var lastErr error
482 |
483 | for i := 0; i < maxRetries; i++ {
484 | if i > 0 {
485 | logger.Info("[%s] 上传重试 %d/%d...", req.Email, i+1, maxRetries)
486 | time.Sleep(time.Duration(i*2) * time.Second)
487 | }
488 |
489 | httpReq, err := http.NewRequest("POST", uploadURL, bytes.NewReader(data))
490 | if err != nil {
491 | lastErr = err
492 | continue
493 | }
494 |
495 | httpReq.Header.Set("Content-Type", "application/json")
496 | if pc.config.Secret != "" {
497 | httpReq.Header.Set("X-Pool-Secret", pc.config.Secret)
498 | }
499 |
500 | client := &http.Client{Timeout: 60 * time.Second}
501 | resp, err := client.Do(httpReq)
502 | if err != nil {
503 | lastErr = err
504 | continue
505 | }
506 |
507 | var result map[string]interface{}
508 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
509 | resp.Body.Close()
510 | lastErr = err
511 | continue
512 | }
513 | resp.Body.Close()
514 |
515 | if success, ok := result["success"].(bool); !ok || !success {
516 | errMsg, _ := result["error"].(string)
517 | lastErr = fmt.Errorf("上传失败: %s", errMsg)
518 | continue
519 | }
520 |
521 | logger.Debug("账号数据已上传: %s", req.Email)
522 | return nil
523 | }
524 |
525 | return fmt.Errorf("上传失败(重试%d次): %v", maxRetries, lastErr)
526 | }
527 |
528 | // sendRegisterResult 发送注册结果
529 | func (pc *PoolClient) sendRegisterResult(success bool, email, errMsg string) {
530 | pc.sendMessage(WSMessage{
531 | Type: WSMsgRegisterResult,
532 | Timestamp: time.Now().Unix(),
533 | Data: map[string]interface{}{
534 | "success": success,
535 | "email": email,
536 | "error": errMsg,
537 | },
538 | })
539 | }
540 |
541 | // sendRefreshResult 发送续期结果
542 | func (pc *PoolClient) sendRefreshResult(email string, success bool, cookies []Cookie, errMsg string) {
543 | pc.sendMessage(WSMessage{
544 | Type: WSMsgRefreshResult,
545 | Timestamp: time.Now().Unix(),
546 | Data: map[string]interface{}{
547 | "email": email,
548 | "success": success,
549 | "cookies": cookies,
550 | "error": errMsg,
551 | },
552 | })
553 | }
554 |
555 | // triggerReconnect 触发重连
556 | func (pc *PoolClient) triggerReconnect() {
557 | select {
558 | case pc.reconnect <- struct{}{}:
559 | default:
560 | }
561 | }
562 |
563 | // getString 安全获取字符串
564 | func getString(m map[string]interface{}, key string) string {
565 | if v, ok := m[key].(string); ok {
566 | return v
567 | }
568 | return ""
569 | }
570 |
--------------------------------------------------------------------------------
/src/flow/flow_client.go:
--------------------------------------------------------------------------------
1 | // Package flow implements VideoFX (Veo) API client for image/video generation
2 | package flow
3 |
4 | import (
5 | "bytes"
6 | "encoding/base64"
7 | "encoding/json"
8 | "fmt"
9 | "io"
10 | "math/rand"
11 | "net/http"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/google/uuid"
17 | )
18 |
19 | const (
20 | DefaultLabsBaseURL = "https://labs.google/fx/api"
21 | DefaultAPIBaseURL = "https://aisandbox-pa.googleapis.com/v1"
22 | DefaultTimeout = 120
23 | DefaultPollInterval = 3
24 | DefaultMaxPollAttempts = 500
25 | )
26 |
27 | // FlowConfig Flow 服务配置
28 | type FlowConfig struct {
29 | LabsBaseURL string `json:"labs_base_url"`
30 | APIBaseURL string `json:"api_base_url"`
31 | Timeout int `json:"timeout"`
32 | PollInterval int `json:"poll_interval"`
33 | MaxPollAttempts int `json:"max_poll_attempts"`
34 | Proxy string `json:"proxy"`
35 | }
36 |
37 | // FlowToken Flow Token (ST/AT)
38 | type FlowToken struct {
39 | ID string `json:"id"`
40 | ST string `json:"st"` // Session Token
41 | AT string `json:"at"` // Access Token
42 | ATExpires time.Time `json:"at_expires"` // AT 过期时间
43 | Email string `json:"email"`
44 | ProjectID string `json:"project_id"`
45 | Credits int `json:"credits"`
46 | UserPaygateTier string `json:"user_paygate_tier"`
47 | Disabled bool `json:"disabled"`
48 | LastUsed time.Time `json:"last_used"`
49 | ErrorCount int `json:"error_count"`
50 | mu sync.RWMutex
51 | }
52 |
53 | // FlowClient VideoFX API 客户端
54 | type FlowClient struct {
55 | config FlowConfig
56 | httpClient *http.Client
57 | tokens map[string]*FlowToken
58 | tokensMu sync.RWMutex
59 | }
60 |
61 | // NewFlowClient 创建新的 Flow 客户端
62 | func NewFlowClient(config FlowConfig) *FlowClient {
63 | if config.LabsBaseURL == "" {
64 | config.LabsBaseURL = DefaultLabsBaseURL
65 | }
66 | if config.APIBaseURL == "" {
67 | config.APIBaseURL = DefaultAPIBaseURL
68 | }
69 | if config.Timeout == 0 {
70 | config.Timeout = DefaultTimeout
71 | }
72 | if config.PollInterval == 0 {
73 | config.PollInterval = DefaultPollInterval
74 | }
75 | if config.MaxPollAttempts == 0 {
76 | config.MaxPollAttempts = DefaultMaxPollAttempts
77 | }
78 |
79 | return &FlowClient{
80 | config: config,
81 | httpClient: &http.Client{
82 | Timeout: time.Duration(config.Timeout) * time.Second,
83 | },
84 | tokens: make(map[string]*FlowToken),
85 | }
86 | }
87 |
88 | // AddToken 添加 Token
89 | func (fc *FlowClient) AddToken(token *FlowToken) {
90 | fc.tokensMu.Lock()
91 | defer fc.tokensMu.Unlock()
92 | fc.tokens[token.ID] = token
93 | }
94 |
95 | // GetToken 获取 Token
96 | func (fc *FlowClient) GetToken(id string) *FlowToken {
97 | fc.tokensMu.RLock()
98 | defer fc.tokensMu.RUnlock()
99 | return fc.tokens[id]
100 | }
101 |
102 | // SelectToken 选择可用 Token
103 | func (fc *FlowClient) SelectToken() *FlowToken {
104 | fc.tokensMu.RLock()
105 | defer fc.tokensMu.RUnlock()
106 |
107 | var best *FlowToken
108 | for _, t := range fc.tokens {
109 | if t.Disabled || t.ErrorCount >= 3 {
110 | continue
111 | }
112 | if best == nil || t.LastUsed.Before(best.LastUsed) {
113 | best = t
114 | }
115 | }
116 | return best
117 | }
118 |
119 | // makeRequest 发送 HTTP 请求
120 | func (fc *FlowClient) makeRequest(method, url string, headers map[string]string, body interface{}) (map[string]interface{}, error) {
121 | var reqBody io.Reader
122 | if body != nil {
123 | data, err := json.Marshal(body)
124 | if err != nil {
125 | return nil, fmt.Errorf("marshal request body: %w", err)
126 | }
127 | reqBody = bytes.NewReader(data)
128 | }
129 |
130 | req, err := http.NewRequest(method, url, reqBody)
131 | if err != nil {
132 | return nil, fmt.Errorf("create request: %w", err)
133 | }
134 |
135 | req.Header.Set("Content-Type", "application/json")
136 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
137 | for k, v := range headers {
138 | req.Header.Set(k, v)
139 | }
140 |
141 | resp, err := fc.httpClient.Do(req)
142 | if err != nil {
143 | return nil, fmt.Errorf("do request: %w", err)
144 | }
145 | defer resp.Body.Close()
146 |
147 | respBody, err := io.ReadAll(resp.Body)
148 | if err != nil {
149 | return nil, fmt.Errorf("read response: %w", err)
150 | }
151 |
152 | if resp.StatusCode >= 400 {
153 | return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
154 | }
155 |
156 | var result map[string]interface{}
157 | if err := json.Unmarshal(respBody, &result); err != nil {
158 | return nil, fmt.Errorf("unmarshal response: %w", err)
159 | }
160 |
161 | return result, nil
162 | }
163 |
164 | // generateSessionID 生成 sessionId
165 | func (fc *FlowClient) generateSessionID() string {
166 | return fmt.Sprintf(";%d", time.Now().UnixMilli())
167 | }
168 |
169 | // ==================== 认证相关 (使用ST) ====================
170 |
171 | // STToAT ST 转 AT
172 | func (fc *FlowClient) STToAT(st string) (*STToATResponse, error) {
173 | url := fmt.Sprintf("%s/auth/session", fc.config.LabsBaseURL)
174 | headers := map[string]string{
175 | "Cookie": fmt.Sprintf("__Secure-next-auth.session-token=%s", st),
176 | }
177 |
178 | result, err := fc.makeRequest("GET", url, headers, nil)
179 | if err != nil {
180 | return nil, err
181 | }
182 |
183 | resp := &STToATResponse{}
184 | if at, ok := result["access_token"].(string); ok {
185 | resp.AccessToken = at
186 | }
187 | if expires, ok := result["expires"].(string); ok {
188 | resp.Expires = expires
189 | }
190 | if user, ok := result["user"].(map[string]interface{}); ok {
191 | if email, ok := user["email"].(string); ok {
192 | resp.Email = email
193 | }
194 | }
195 |
196 | return resp, nil
197 | }
198 |
199 | type STToATResponse struct {
200 | AccessToken string `json:"access_token"`
201 | Expires string `json:"expires"`
202 | Email string `json:"email"`
203 | }
204 |
205 | // ==================== 项目管理 (使用ST) ====================
206 |
207 | // CreateProject 创建项目
208 | func (fc *FlowClient) CreateProject(st, title string) (string, error) {
209 | url := fmt.Sprintf("%s/trpc/project.createProject", fc.config.LabsBaseURL)
210 | headers := map[string]string{
211 | "Cookie": fmt.Sprintf("__Secure-next-auth.session-token=%s", st),
212 | }
213 | body := map[string]interface{}{
214 | "json": map[string]interface{}{
215 | "projectTitle": title,
216 | "toolName": "PINHOLE",
217 | },
218 | }
219 |
220 | result, err := fc.makeRequest("POST", url, headers, body)
221 | if err != nil {
222 | return "", err
223 | }
224 |
225 | // 解析 project_id
226 | if res, ok := result["result"].(map[string]interface{}); ok {
227 | if data, ok := res["data"].(map[string]interface{}); ok {
228 | if jsonData, ok := data["json"].(map[string]interface{}); ok {
229 | if innerRes, ok := jsonData["result"].(map[string]interface{}); ok {
230 | if projectID, ok := innerRes["projectId"].(string); ok {
231 | return projectID, nil
232 | }
233 | }
234 | }
235 | }
236 | }
237 |
238 | return "", fmt.Errorf("failed to parse project_id from response")
239 | }
240 |
241 | // DeleteProject 删除项目
242 | func (fc *FlowClient) DeleteProject(st, projectID string) error {
243 | url := fmt.Sprintf("%s/trpc/project.deleteProject", fc.config.LabsBaseURL)
244 | headers := map[string]string{
245 | "Cookie": fmt.Sprintf("__Secure-next-auth.session-token=%s", st),
246 | }
247 | body := map[string]interface{}{
248 | "json": map[string]interface{}{
249 | "projectToDeleteId": projectID,
250 | },
251 | }
252 |
253 | _, err := fc.makeRequest("POST", url, headers, body)
254 | return err
255 | }
256 |
257 | // ==================== 余额查询 (使用AT) ====================
258 |
259 | // GetCredits 查询余额
260 | func (fc *FlowClient) GetCredits(at string) (*CreditsResponse, error) {
261 | url := fmt.Sprintf("%s/credits", fc.config.APIBaseURL)
262 | headers := map[string]string{
263 | "authorization": "Bearer " + at,
264 | }
265 |
266 | result, err := fc.makeRequest("GET", url, headers, nil)
267 | if err != nil {
268 | return nil, err
269 | }
270 |
271 | resp := &CreditsResponse{}
272 | if credits, ok := result["credits"].(float64); ok {
273 | resp.Credits = int(credits)
274 | }
275 | if tier, ok := result["userPaygateTier"].(string); ok {
276 | resp.UserPaygateTier = tier
277 | }
278 |
279 | return resp, nil
280 | }
281 |
282 | type CreditsResponse struct {
283 | Credits int `json:"credits"`
284 | UserPaygateTier string `json:"userPaygateTier"`
285 | }
286 |
287 | // ==================== 图片上传 (使用AT) ====================
288 |
289 | // UploadImage 上传图片
290 | func (fc *FlowClient) UploadImage(at string, imageBytes []byte, aspectRatio string) (string, error) {
291 | // 转换视频 aspect_ratio 为图片 aspect_ratio
292 | if strings.HasPrefix(aspectRatio, "VIDEO_") {
293 | aspectRatio = strings.Replace(aspectRatio, "VIDEO_", "IMAGE_", 1)
294 | }
295 |
296 | imageBase64 := base64.StdEncoding.EncodeToString(imageBytes)
297 |
298 | url := fmt.Sprintf("%s:uploadUserImage", fc.config.APIBaseURL)
299 | headers := map[string]string{
300 | "authorization": "Bearer " + at,
301 | }
302 | body := map[string]interface{}{
303 | "imageInput": map[string]interface{}{
304 | "rawImageBytes": imageBase64,
305 | "mimeType": "image/jpeg",
306 | "isUserUploaded": true,
307 | "aspectRatio": aspectRatio,
308 | },
309 | "clientContext": map[string]interface{}{
310 | "sessionId": fc.generateSessionID(),
311 | "tool": "ASSET_MANAGER",
312 | },
313 | }
314 |
315 | result, err := fc.makeRequest("POST", url, headers, body)
316 | if err != nil {
317 | return "", err
318 | }
319 |
320 | // 解析 mediaGenerationId
321 | if mediaGen, ok := result["mediaGenerationId"].(map[string]interface{}); ok {
322 | if mediaID, ok := mediaGen["mediaGenerationId"].(string); ok {
323 | return mediaID, nil
324 | }
325 | }
326 |
327 | return "", fmt.Errorf("failed to parse mediaGenerationId")
328 | }
329 |
330 | // ==================== 图片生成 (使用AT) ====================
331 |
332 | // GenerateImage 生成图片
333 | func (fc *FlowClient) GenerateImage(at, projectID, prompt, modelName, aspectRatio string, imageInputs []map[string]interface{}) (*GenerateImageResponse, error) {
334 | url := fmt.Sprintf("%s/projects/%s/flowMedia:batchGenerateImages", fc.config.APIBaseURL, projectID)
335 | headers := map[string]string{
336 | "authorization": "Bearer " + at,
337 | }
338 |
339 | requestData := map[string]interface{}{
340 | "clientContext": map[string]interface{}{
341 | "sessionId": fc.generateSessionID(),
342 | },
343 | "seed": rand.Intn(99999) + 1,
344 | "imageModelName": modelName,
345 | "imageAspectRatio": aspectRatio,
346 | "prompt": prompt,
347 | "imageInputs": imageInputs,
348 | }
349 |
350 | body := map[string]interface{}{
351 | "requests": []map[string]interface{}{requestData},
352 | }
353 |
354 | result, err := fc.makeRequest("POST", url, headers, body)
355 | if err != nil {
356 | return nil, err
357 | }
358 |
359 | resp := &GenerateImageResponse{}
360 | if media, ok := result["media"].([]interface{}); ok && len(media) > 0 {
361 | if m, ok := media[0].(map[string]interface{}); ok {
362 | if img, ok := m["image"].(map[string]interface{}); ok {
363 | if genImg, ok := img["generatedImage"].(map[string]interface{}); ok {
364 | if fifeURL, ok := genImg["fifeUrl"].(string); ok {
365 | resp.ImageURL = fifeURL
366 | }
367 | }
368 | }
369 | }
370 | }
371 |
372 | return resp, nil
373 | }
374 |
375 | type GenerateImageResponse struct {
376 | ImageURL string `json:"image_url"`
377 | }
378 |
379 | // ==================== 视频生成 (使用AT) ====================
380 |
381 | // GenerateVideoText 文生视频
382 | func (fc *FlowClient) GenerateVideoText(at, projectID, prompt, modelKey, aspectRatio, userPaygateTier string) (*GenerateVideoResponse, error) {
383 | url := fmt.Sprintf("%s/video:batchAsyncGenerateVideoText", fc.config.APIBaseURL)
384 | headers := map[string]string{
385 | "authorization": "Bearer " + at,
386 | }
387 |
388 | sceneID := uuid.New().String()
389 | body := map[string]interface{}{
390 | "clientContext": map[string]interface{}{
391 | "sessionId": fc.generateSessionID(),
392 | "projectId": projectID,
393 | "tool": "PINHOLE",
394 | "userPaygateTier": userPaygateTier,
395 | },
396 | "requests": []map[string]interface{}{{
397 | "aspectRatio": aspectRatio,
398 | "seed": rand.Intn(99999) + 1,
399 | "textInput": map[string]interface{}{
400 | "prompt": prompt,
401 | },
402 | "videoModelKey": modelKey,
403 | "metadata": map[string]interface{}{
404 | "sceneId": sceneID,
405 | },
406 | }},
407 | }
408 |
409 | return fc.parseVideoResponse(fc.makeRequest("POST", url, headers, body))
410 | }
411 |
412 | // GenerateVideoStartEnd 首尾帧生成视频
413 | func (fc *FlowClient) GenerateVideoStartEnd(at, projectID, prompt, modelKey, aspectRatio, startMediaID, endMediaID, userPaygateTier string) (*GenerateVideoResponse, error) {
414 | url := fmt.Sprintf("%s/video:batchAsyncGenerateVideoStartAndEndImage", fc.config.APIBaseURL)
415 | headers := map[string]string{
416 | "authorization": "Bearer " + at,
417 | }
418 |
419 | sceneID := uuid.New().String()
420 | request := map[string]interface{}{
421 | "aspectRatio": aspectRatio,
422 | "seed": rand.Intn(99999) + 1,
423 | "textInput": map[string]interface{}{
424 | "prompt": prompt,
425 | },
426 | "videoModelKey": modelKey,
427 | "startImage": map[string]interface{}{
428 | "mediaId": startMediaID,
429 | },
430 | "metadata": map[string]interface{}{
431 | "sceneId": sceneID,
432 | },
433 | }
434 |
435 | // 如果有尾帧
436 | if endMediaID != "" {
437 | request["endImage"] = map[string]interface{}{
438 | "mediaId": endMediaID,
439 | }
440 | }
441 |
442 | body := map[string]interface{}{
443 | "clientContext": map[string]interface{}{
444 | "sessionId": fc.generateSessionID(),
445 | "projectId": projectID,
446 | "tool": "PINHOLE",
447 | "userPaygateTier": userPaygateTier,
448 | },
449 | "requests": []map[string]interface{}{request},
450 | }
451 |
452 | return fc.parseVideoResponse(fc.makeRequest("POST", url, headers, body))
453 | }
454 |
455 | // GenerateVideoReferenceImages 多图生成视频
456 | func (fc *FlowClient) GenerateVideoReferenceImages(at, projectID, prompt, modelKey, aspectRatio string, referenceImages []map[string]interface{}, userPaygateTier string) (*GenerateVideoResponse, error) {
457 | url := fmt.Sprintf("%s/video:batchAsyncGenerateVideoReferenceImages", fc.config.APIBaseURL)
458 | headers := map[string]string{
459 | "authorization": "Bearer " + at,
460 | }
461 |
462 | sceneID := uuid.New().String()
463 | body := map[string]interface{}{
464 | "clientContext": map[string]interface{}{
465 | "sessionId": fc.generateSessionID(),
466 | "projectId": projectID,
467 | "tool": "PINHOLE",
468 | "userPaygateTier": userPaygateTier,
469 | },
470 | "requests": []map[string]interface{}{{
471 | "aspectRatio": aspectRatio,
472 | "seed": rand.Intn(99999) + 1,
473 | "textInput": map[string]interface{}{
474 | "prompt": prompt,
475 | },
476 | "videoModelKey": modelKey,
477 | "referenceImages": referenceImages,
478 | "metadata": map[string]interface{}{
479 | "sceneId": sceneID,
480 | },
481 | }},
482 | }
483 |
484 | return fc.parseVideoResponse(fc.makeRequest("POST", url, headers, body))
485 | }
486 |
487 | func (fc *FlowClient) parseVideoResponse(result map[string]interface{}, err error) (*GenerateVideoResponse, error) {
488 | if err != nil {
489 | return nil, err
490 | }
491 |
492 | resp := &GenerateVideoResponse{}
493 | if ops, ok := result["operations"].([]interface{}); ok && len(ops) > 0 {
494 | if op, ok := ops[0].(map[string]interface{}); ok {
495 | if operation, ok := op["operation"].(map[string]interface{}); ok {
496 | if name, ok := operation["name"].(string); ok {
497 | resp.TaskID = name
498 | }
499 | }
500 | if sceneID, ok := op["sceneId"].(string); ok {
501 | resp.SceneID = sceneID
502 | }
503 | if status, ok := op["status"].(string); ok {
504 | resp.Status = status
505 | }
506 | }
507 | }
508 | if credits, ok := result["remainingCredits"].(float64); ok {
509 | resp.RemainingCredits = int(credits)
510 | }
511 |
512 | return resp, nil
513 | }
514 |
515 | type GenerateVideoResponse struct {
516 | TaskID string `json:"task_id"`
517 | SceneID string `json:"scene_id"`
518 | Status string `json:"status"`
519 | RemainingCredits int `json:"remaining_credits"`
520 | }
521 |
522 | // ==================== 任务轮询 (使用AT) ====================
523 |
524 | // CheckVideoStatus 查询视频生成状态
525 | func (fc *FlowClient) CheckVideoStatus(at string, operations []map[string]interface{}) (*VideoStatusResponse, error) {
526 | url := fmt.Sprintf("%s/video:batchCheckAsyncVideoGenerationStatus", fc.config.APIBaseURL)
527 | headers := map[string]string{
528 | "authorization": "Bearer " + at,
529 | }
530 | body := map[string]interface{}{
531 | "operations": operations,
532 | }
533 |
534 | result, err := fc.makeRequest("POST", url, headers, body)
535 | if err != nil {
536 | return nil, err
537 | }
538 |
539 | resp := &VideoStatusResponse{}
540 | if ops, ok := result["operations"].([]interface{}); ok && len(ops) > 0 {
541 | if op, ok := ops[0].(map[string]interface{}); ok {
542 | if status, ok := op["status"].(string); ok {
543 | resp.Status = status
544 | }
545 | if operation, ok := op["operation"].(map[string]interface{}); ok {
546 | if name, ok := operation["name"].(string); ok {
547 | resp.TaskID = name
548 | }
549 | if metadata, ok := operation["metadata"].(map[string]interface{}); ok {
550 | if video, ok := metadata["video"].(map[string]interface{}); ok {
551 | if fifeURL, ok := video["fifeUrl"].(string); ok {
552 | resp.VideoURL = fifeURL
553 | }
554 | }
555 | }
556 | }
557 | }
558 | }
559 |
560 | return resp, nil
561 | }
562 |
563 | type VideoStatusResponse struct {
564 | TaskID string `json:"task_id"`
565 | Status string `json:"status"`
566 | VideoURL string `json:"video_url"`
567 | }
568 |
569 | // PollVideoResult 轮询视频生成结果
570 | func (fc *FlowClient) PollVideoResult(at, taskID, sceneID string) (string, error) {
571 | operations := []map[string]interface{}{{
572 | "operation": map[string]interface{}{
573 | "name": taskID,
574 | },
575 | "sceneId": sceneID,
576 | }}
577 |
578 | for i := 0; i < fc.config.MaxPollAttempts; i++ {
579 | time.Sleep(time.Duration(fc.config.PollInterval) * time.Second)
580 |
581 | resp, err := fc.CheckVideoStatus(at, operations)
582 | if err != nil {
583 | continue
584 | }
585 |
586 | switch resp.Status {
587 | case "MEDIA_GENERATION_STATUS_SUCCESSFUL":
588 | if resp.VideoURL != "" {
589 | return resp.VideoURL, nil
590 | }
591 | case "MEDIA_GENERATION_STATUS_ERROR_UNKNOWN",
592 | "MEDIA_GENERATION_STATUS_ERROR_NSFW",
593 | "MEDIA_GENERATION_STATUS_ERROR_PERSON",
594 | "MEDIA_GENERATION_STATUS_ERROR_SAFETY":
595 | return "", fmt.Errorf("video generation failed: %s", resp.Status)
596 | }
597 | }
598 |
599 | return "", fmt.Errorf("video generation timeout after %d attempts", fc.config.MaxPollAttempts)
600 | }
601 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Business2API
2 |
3 | > 🚀 OpenAI/Gemini 兼容的 Gemini Business API 代理服务,支持账号池管理、自动注册和 Flow 图片/视频生成。
4 |
5 | [](https://github.com/XxxXTeam/business2api/actions/workflows/build.yml)
6 | [](LICENSE)
7 | [](https://golang.org)
8 |
9 | ## ✨ 功能特性
10 |
11 | | 功能 | 描述 |
12 | |------|------|
13 | | 🔌 **多 API 兼容** | OpenAI (`/v1/chat/completions`)、Gemini (`/v1beta/models`)、Claude (`/v1/messages`) |
14 | | 🏊 **智能账号池** | 自动轮询、刷新、冷却管理、401/403 自动换号 |
15 | | 🌊 **流式响应** | SSE 流式输出,支持 `stream: true` |
16 | | 🎨 **多模态** | 图片/视频输入、原生图片生成(`-image` 后缀)|
17 | | 🤖 **自动注册** | 浏览器自动化注册,支持 Windows/Linux/macOS |
18 | | 🌐 **代理池** | HTTP/SOCKS5 代理,订阅链接,健康检查 |
19 | | 📊 **遥测监控** | IP 请求统计、Token 使用量、RPM 监控 |
20 | | 🔄 **热重载** | 配置文件自动监听,无需重启 |
21 |
22 | ## 📦 支持的模型
23 |
24 | ### Gemini Business 模型
25 |
26 | | 模型 | 文本 | 图片生成 | 视频生成 | 搜索 |
27 | |------|:----:|:--------:|:--------:|:----:|
28 | | gemini-2.5-flash | ✅ | ✅ | ✅ | ✅ |
29 | | gemini-2.5-pro | ✅ | ✅ | ✅ | ✅ |
30 | | gemini-2.5-flash-preview-latest | ✅ | ✅ | ✅ | ✅ |
31 | | gemini-3-pro-preview | ✅ | ✅ | ✅ | ✅ |
32 | | gemini-3-pro | ✅ | ✅ | ✅ | ✅ |
33 | | gemini-3-flash-preview | ✅ | ✅ | ✅ | ✅ |
34 | | gemini-3-flash | ✅ | ✅ | ✅ | ✅ |
35 |
36 | ### 功能后缀
37 |
38 | 支持单个或混合后缀启用指定功能:
39 |
40 | | 后缀 | 功能 | 示例 |
41 | |------|------|------|
42 | | `-image` | 图片生成 | `gemini-2.5-flash-image` |
43 | | `-video` | 视频生成 | `gemini-2.5-flash-video` |
44 | | `-search` | 联网搜索 | `gemini-2.5-flash-search` |
45 | | 混合后缀 | 同时启用多功能 | `gemini-2.5-flash-image-search` |
46 |
47 | **说明:**
48 | - 无后缀:启用所有功能(图片/视频/搜索/工具)
49 | - 有后缀:只启用指定功能,支持任意组合如 `-image-search`、`-video-search`
50 |
51 | ### ⚠️ 限制说明
52 |
53 | | 限制 | 说明 |
54 | |------|------|
55 | | **不支持自定义工具** | Function Calling / Tools 参数会被忽略,仅支持内置工具(图片/视频生成、搜索) |
56 | | **上下文拼接实现** | 多轮对话通过拼接 `messages` 为单次请求实现,非原生会话管理 |
57 | | **无状态** | 每次请求独立,不保留会话状态,历史消息需客户端自行维护 |
58 |
59 | ---
60 |
61 |
62 | >> 公益 Demo(免费调用)
63 | > 🔗 链接:
64 | >
65 | > > API Key 获取请访问 https://business2api.openel.top/auth 获取个人专属免费APIKEY
66 |
67 |
68 | >> GLM 公益测试 API
69 | > 🔗 链接:
70 | >
71 | > XiaoMi 网页逆向公益 API
72 | > 🔗 链接:[https://xiaomi.openel.top](https://xiaomi.openel.top/)
73 | >
74 | > API Key : `sk-3d2f9b84e7f510b1a08f7b3d6c9a6a7f17fbbad5624ea29f22d9c742bf39c863`
75 |
76 |
77 |
78 | ## 快速开始
79 |
80 | ### 方式一:Docker 部署(推荐)
81 |
82 | #### 1. 使用 Docker Compose
83 |
84 | ```bash
85 | # 创建目录
86 | mkdir business2api && cd business2api
87 |
88 | # 下载必要文件
89 | wget https://raw.githubusercontent.com/XxxXTeam/business2api/master/docker/docker-compose.yml
90 | wget https://raw.githubusercontent.com/XxxXTeam/business2api/master/config/config.json.example -O config.json
91 |
92 | # 编辑配置
93 | vim config.json
94 |
95 | # 创建数据目录
96 | mkdir data
97 |
98 | # 启动服务
99 | docker compose up -d
100 | ```
101 |
102 | #### 2. 使用 Docker Run
103 |
104 | ```bash
105 | # 拉取镜像
106 | docker pull ghcr.io/xxxteam/business2api:latest
107 |
108 | # 创建配置文件
109 | wget https://raw.githubusercontent.com/XxxXTeam/business2api/master/config/config.json.example -O config.json
110 |
111 | # 运行容器
112 | docker run -d \
113 | --name business2api \
114 | -p 8000:8000 \
115 | -v $(pwd)/data:/app/data \
116 | -v $(pwd)/config.json:/app/config/config.json:ro \
117 | ghcr.io/xxxteam/business2api:latest
118 | ```
119 |
120 | ### 方式二:二进制部署
121 |
122 | #### 1. 下载预编译版本
123 |
124 | 从 [Releases](https://github.com/XxxXTeam/business2api/releases) 下载对应平台的二进制文件。
125 |
126 | ```bash
127 | # Linux amd64
128 | wget https://github.com/XxxXTeam/business2api/releases/latest/download/business2api-linux-amd64.tar.gz
129 | tar -xzf business2api-linux-amd64.tar.gz
130 | chmod +x business2api-linux-amd64
131 | ```
132 |
133 | #### 2. 从源码编译
134 |
135 | ```bash
136 | # 需要 Go 1.24+
137 | git clone https://github.com/XxxXTeam/business2api.git
138 | cd business2api
139 |
140 | # 编译
141 | go build -o business2api .
142 |
143 | # 运行
144 | ./business2api
145 | ```
146 |
147 | ### 方式三:使用 Systemd 服务
148 |
149 | ```bash
150 | # 创建服务文件
151 | sudo tee /etc/systemd/system/business2api.service << EOF
152 | [Unit]
153 | Description=Gemini Gateway Service
154 | After=network.target
155 |
156 | [Service]
157 | Type=simple
158 | User=nobody
159 | WorkingDirectory=/opt/business2api
160 | ExecStart=/opt/business2api/business2api
161 | Restart=always
162 | RestartSec=5
163 | Environment=LISTEN_ADDR=:8000
164 | Environment=DATA_DIR=/opt/business2api/data
165 |
166 | [Install]
167 | WantedBy=multi-user.target
168 | EOF
169 |
170 | # 启动服务
171 | sudo systemctl daemon-reload
172 | sudo systemctl enable business2api
173 | sudo systemctl start business2api
174 | ```
175 |
176 | ---
177 |
178 | ## 配置说明
179 |
180 | ### config.json
181 |
182 | ```json
183 | {
184 | "api_keys": ["sk-your-api-key"], // API 密钥列表,用于鉴权
185 | "listen_addr": ":8000", // 监听地址
186 | "data_dir": "./data", // 账号数据目录
187 | "default_config": "", // 默认 configId(可选)
188 | "debug": false, // 调试模式(输出详细日志)
189 |
190 | "pool": {
191 | "target_count": 50, // 目标账号数量
192 | "min_count": 10, // 最小账号数,低于此值触发注册
193 | "check_interval_minutes": 30, // 检查间隔(分钟)
194 | "register_threads": 1, // 本地注册线程数
195 | "register_headless": true, // 无头模式注册
196 | "refresh_on_startup": true, // 启动时刷新账号
197 | "refresh_cooldown_sec": 240, // 刷新冷却时间(秒)
198 | "use_cooldown_sec": 15, // 使用冷却时间(秒)
199 | "max_fail_count": 3, // 最大连续失败次数
200 | "enable_browser_refresh": true, // 启用浏览器刷新401账号
201 | "browser_refresh_headless": true, // 浏览器刷新无头模式
202 | "browser_refresh_max_retry": 1, // 浏览器刷新最大重试次数
203 | "auto_delete_401": false // 401时自动删除账号
204 | },
205 |
206 | "pool_server": {
207 | "enable": false, // 是否启用分离模式
208 | "mode": "local", // 运行模式:local/server/client
209 | "server_addr": "http://ip:8000", // 服务器地址(client模式)
210 | "listen_addr": ":8000", // 监听地址(server模式)
211 | "secret": "your-secret-key", // 通信密钥
212 | "target_count": 50, // 目标账号数(server模式)
213 | "client_threads": 2, // 客户端并发线程数
214 | "data_dir": "./data", // 数据目录(server模式)
215 | "expired_action": "delete" // 过期账号处理:delete/refresh/queue
216 | },
217 |
218 | "proxy_pool": {
219 | "subscribes": [], // 代理订阅链接列表
220 | "files": [], // 本地代理文件列表
221 | "health_check": true, // 启用健康检查
222 | "check_on_startup": true // 启动时检查
223 | }
224 | }
225 | ```
226 |
227 | ### 多 API Key 支持
228 |
229 | 支持配置多个 API Key,所有 Key 都可以用于鉴权:
230 |
231 | ```json
232 | {
233 | "api_keys": [
234 | "sk-key-1",
235 | "sk-key-2",
236 | "sk-key-3"
237 | ]
238 | }
239 | ```
240 |
241 | ### 配置热重载
242 |
243 | 服务运行时自动监听 `config/config.json` 文件变更,无需重启即可生效。
244 |
245 | **可热重载的配置项:**
246 |
247 | | 配置项 | 说明 |
248 | |----------|------|
249 | | `api_keys` | API 密钥列表 |
250 | | `debug` | 调试模式 |
251 | | `pool.refresh_cooldown_sec` | 刷新冷却时间 |
252 | | `pool.use_cooldown_sec` | 使用冷却时间 |
253 | | `pool.max_fail_count` | 最大失败次数 |
254 | | `pool.enable_browser_refresh` | 浏览器刷新开关 |
255 |
256 | **配置合并机制:** 配置文件中缺失的字段会自动使用默认值,无需手动同步示例文件。
257 |
258 | ```bash
259 | # 手动触发重载
260 | curl -X POST http://localhost:8000/admin/reload-config \
261 | -H "Authorization: Bearer sk-your-api-key"
262 | ```
263 |
264 | ---
265 |
266 | ## C/S 分离架构
267 |
268 | 支持将号池管理与API服务分离部署,适用于多节点场景。
269 |
270 | ### 架构说明
271 |
272 | ```
273 | ┌─────────────────┐ ┌─────────────────┐
274 | │ API Server │◄───────►│ Pool Server │
275 | │ (客户端模式) │ HTTP │ (服务器模式) │
276 | └─────────────────┘ └────────┬────────┘
277 | │
278 | WebSocket│
279 | │
280 | ┌────────▼────────┐
281 | │ Worker Client │
282 | │ (注册/续期) │
283 | └─────────────────┘
284 | ```
285 |
286 | ### 运行模式
287 |
288 | | 模式 | 说明 |
289 | |------|------|
290 | | `local` | 本地模式(默认),API服务和号池管理在同一进程 |
291 | | `server` | 服务器模式,提供号池服务和任务分发 |
292 | | `client` | 客户端模式,只接收任务(注册/续期),不提供API服务 |
293 |
294 | ### Server 模式配置
295 |
296 | ```json
297 | {
298 | "api_keys": ["sk-your-api-key"],
299 | "listen_addr": ":8000",
300 | "pool_server": {
301 | "enable": true,
302 | "mode": "server",
303 | "secret": "shared-secret-key",
304 | "target_count": 100,
305 | "data_dir": "./data",
306 | "expired_action": "delete"
307 | }
308 | }
309 | ```
310 |
311 | ### Client 模式配置(仅注册/续期工作节点)
312 |
313 | ```json
314 | {
315 | "pool_server": {
316 | "enable": true,
317 | "mode": "client",
318 | "server_addr": "http://server-ip:8000",
319 | "secret": "shared-secret-key",
320 | "client_threads": 3
321 | },
322 | "proxy_pool": {
323 | "subscribes": ["https://your-proxy-subscribe-url"],
324 | "health_check": true,
325 | "check_on_startup": true
326 | }
327 | }
328 | ```
329 |
330 | ### 配置项说明
331 |
332 | | 配置项 | 说明 | 默认值 |
333 | |--------|------|--------|
334 | | `client_threads` | 客户端并发任务数 | 1 |
335 | | `expired_action` | 过期账号处理方式 | delete |
336 |
337 | **expired_action 可选值:**
338 | - `delete` - 删除过期账号
339 | - `refresh` - 尝试浏览器刷新
340 | - `queue` - 保留在队列等待重试
341 |
342 | **架构说明(v2.x):**
343 | ```
344 | ┌─────────────────────────────────────────────────────┐
345 | │ Server (:8000) │
346 | │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
347 | │ │ API 服务 │ │ WS 服务 │ │ 号池管理 │ │
348 | │ │ /v1/chat/* │ │ /ws │ │ Pool Mgr │ │
349 | │ └─────────────┘ └──────┬──────┘ └─────────────┘ │
350 | └──────────────────────────┼──────────────────────────┘
351 | │ WebSocket
352 | ┌────────────┼────────────┐
353 | │ │ │
354 | ┌─────▼─────┐ ┌────▼────┐ ┌─────▼─────┐
355 | │ Client1 │ │ Client2 │ │ Client3 │
356 | │ (注册) │ │ (注册) │ │ (注册) │
357 | └───────────┘ └─────────┘ └───────────┘
358 | ```
359 |
360 | **Client 模式说明:**
361 | - 通过 WebSocket 连接 Server (`/ws`) 接收任务
362 | - 执行注册新账号任务
363 | - 执行401账号Cookie续期任务
364 | - 完成后自动回传账号数据到Server
365 | - **不提供API服务**,只作为工作节点
366 |
367 | ### 环境变量
368 |
369 | | 变量 | 说明 | 默认值 |
370 | |------|------|--------|
371 | | `LISTEN_ADDR` | 监听地址 | `:8000` |
372 | | `DATA_DIR` | 数据目录 | `./data` |
373 | | `PROXY` | 代理地址 | - |
374 | | `API_KEY` | API 密钥 | - |
375 | | `CONFIG_ID` | 默认 configId | - |
376 |
377 | ---
378 |
379 | ## API 使用
380 |
381 | ### 获取模型列表
382 |
383 | ```bash
384 | curl http://localhost:8000/v1/models \
385 | -H "Authorization: Bearer sk-your-api-key"
386 | ```
387 |
388 | ### 聊天补全
389 |
390 | ```bash
391 | curl http://localhost:8000/v1/chat/completions \
392 | -H "Content-Type: application/json" \
393 | -H "Authorization: Bearer sk-your-api-key" \
394 | -d '{
395 | "model": "gemini-2.5-flash",
396 | "messages": [
397 | {"role": "user", "content": "Hello!"}
398 | ],
399 | "stream": true
400 | }'
401 | ```
402 |
403 | ### 多模态(图片输入)
404 |
405 | ```bash
406 | curl http://localhost:8000/v1/chat/completions \
407 | -H "Content-Type: application/json" \
408 | -H "Authorization: Bearer sk-your-api-key" \
409 | -d '{
410 | "model": "gemini-2.5-flash",
411 | "messages": [
412 | {
413 | "role": "user",
414 | "content": [
415 | {"type": "text", "text": "描述这张图片"},
416 | {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
417 | ]
418 | }
419 | ]
420 | }'
421 | ```
422 |
423 | ---
424 |
425 | ## Flow 图片/视频生成
426 |
427 | Flow 集成了 Google VideoFX (Veo/Imagen) API,支持图片和视频生成。
428 |
429 | ### 配置
430 |
431 | ```json
432 | {
433 | "flow": {
434 | "enable": true,
435 | "tokens": [], // 配置文件中的 Token(可选)
436 | "proxy": "", // Flow 专用代理
437 | "timeout": 120, // 超时时间(秒)
438 | "poll_interval": 3, // 轮询间隔(秒)
439 | "max_poll_attempts": 500 // 最大轮询次数
440 | }
441 | }
442 | ```
443 |
444 | ### 获取 Flow Token
445 |
446 | **方式一:文件目录(推荐)**
447 |
448 | 将完整的 cookie 字符串保存到 `data/at/` 目录下的任意 `.txt` 文件:
449 |
450 | ```bash
451 | mkdir -p data/at
452 | echo "your-cookie-string" > data/at/account1.txt
453 | ```
454 |
455 | 服务启动时自动加载,支持文件监听自动热加载。
456 |
457 | **方式二:API 添加**
458 |
459 | ```bash
460 | curl -X POST http://localhost:8000/admin/flow/add-token \
461 | -H "Authorization: Bearer sk-xxx" \
462 | -d '{"cookie": "your-cookie-string"}'
463 | ```
464 |
465 | **Cookie 获取方法:**
466 | 1. 访问 [labs.google/fx](https://labs.google/fx) 并登录
467 | 2. 打开开发者工具 → Application → Cookies
468 | 3. 复制所有 cookie 或 `__Secure-next-auth.session-token` 的值
469 |
470 | ### Flow 模型列表
471 |
472 | | 模型 | 类型 | 说明 |
473 | |------|------|------|
474 | | `gemini-2.5-flash-image-landscape/portrait` | 图片 | Gemini 2.5 Flash 图片生成 |
475 | | `gemini-3.0-pro-image-landscape/portrait` | 图片 | Gemini 3.0 Pro 图片生成 |
476 | | `imagen-4.0-generate-preview-landscape/portrait` | 图片 | Imagen 4.0 图片生成 |
477 | | `veo_3_1_t2v_fast_landscape/portrait` | 视频 | Veo 3.1 文生视频 |
478 | | `veo_2_1_fast_d_15_t2v_landscape/portrait` | 视频 | Veo 2.1 文生视频 |
479 | | `veo_2_0_t2v_landscape/portrait` | 视频 | Veo 2.0 文生视频 |
480 | | `veo_3_1_i2v_s_fast_fl_landscape/portrait` | 视频 | Veo 3.1 图生视频 (I2V) |
481 | | `veo_2_1_fast_d_15_i2v_landscape/portrait` | 视频 | Veo 2.1 图生视频 (I2V) |
482 | | `veo_2_0_i2v_landscape/portrait` | 视频 | Veo 2.0 图生视频 (I2V) |
483 | | `veo_3_0_r2v_fast_landscape/portrait` | 视频 | Veo 3.0 多图生视频 (R2V) |
484 |
485 | ### 使用示例
486 |
487 | ```bash
488 | # 图片生成
489 | curl http://localhost:8000/v1/chat/completions \
490 | -H "Authorization: Bearer sk-xxx" \
491 | -H "Content-Type: application/json" \
492 | -d '{"model": "gemini-2.5-flash-image-landscape", "messages": [{"role": "user", "content": "一只可爱的猫咪"}], "stream": true}'
493 |
494 | # 文生视频 (T2V)
495 | curl http://localhost:8000/v1/chat/completions \
496 | -H "Authorization: Bearer sk-xxx" \
497 | -H "Content-Type: application/json" \
498 | -d '{"model": "veo_3_1_t2v_fast_landscape", "messages": [{"role": "user", "content": "猫咪在草地上追蝴蝶"}], "stream": true}'
499 |
500 | # 图生视频 (I2V) - 支持首尾帧
501 | curl http://localhost:8000/v1/chat/completions \
502 | -H "Authorization: Bearer sk-xxx" \
503 | -H "Content-Type: application/json" \
504 | -d '{
505 | "model": "veo_3_1_i2v_s_fast_fl_landscape",
506 | "messages": [{
507 | "role": "user",
508 | "content": [
509 | {"type": "text", "text": "猫咪跳跃"},
510 | {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
511 | ]
512 | }],
513 | "stream": true
514 | }'
515 | ```
516 |
517 | ---
518 |
519 | ## 🔧 常见问题与解决方案
520 |
521 | ### 注册相关
522 |
523 | | 错误 | 原因 | 解决方案 |
524 | |------|------|----------|
525 | | `无法获取验证码邮件` | 临时邮箱服务不稳定或邮件延迟 | 代理遭到拉黑,更换代理 |
526 | | `panic: nil pointer` | 浏览器启动失败或页面未加载 | 检查 Chrome 是否安装,确保有足够内存 |
527 | | `找不到提交按钮` | 页面结构变化或加载超时 | 升级到最新版本,检查网络 |
528 |
529 |
530 | ### API 相关
531 |
532 | | 错误 | 原因 | 解决方案 |
533 | |------|------|----------|
534 | | `401 Unauthorized` | API Key 无效或未配置 | 检查 `api_keys` 配置 |
535 | | `429 Too Many Requests` | 账号触发速率限制 | 增加账号池数量,调整 `use_cooldown_sec` |
536 | | `503 Service Unavailable` | 无可用账号 | 等待账号刷新或增加注册 |
537 | | `空响应` | Google 返回空内容 | 重试请求,检查 prompt 是否触发过滤 |
538 |
539 | ### WebSocket 相关
540 |
541 | | 错误 | 原因 | 解决方案 |
542 | |------|------|----------|
543 | | `客户端频繁断开` | 心跳超时或网络不稳定 | 检查网络,确保 Server 和 Client 时间同步 |
544 | | `上传注册结果失败` | Server 端口或路径错误 | 确保 `server_addr` 指向正确地址 |
545 |
546 | ### Flow 相关
547 |
548 | | 错误 | 原因 | 解决方案 |
549 | |------|------|----------|
550 | | `Flow 服务未启用` | 未配置或 Token 为空 | 检查 `flow.enable` 和 `flow.tokens` |
551 | | `Token 认证失败` | ST Token 过期 | 重新获取 Token |
552 | | `视频生成超时` | 生成时间过长 | 增加 `max_poll_attempts` |
553 |
554 | ### Docker 相关
555 |
556 | | 错误 | 原因 | 解决方案 |
557 | |------|------|----------|
558 | | `无法启动浏览器` | Docker 容器缺少 Chrome | 使用包含 Chrome 的镜像或挂载主机浏览器 |
559 | | `权限被拒绝` | 数据目录权限问题 | `chown -R 1000:1000 ./data` |
560 |
561 | ---
562 |
563 | ## 📡 API 端点一览
564 |
565 | ### 公开端点
566 |
567 | | 端点 | 方法 | 说明 |
568 | |------|------|------|
569 | | `/` | GET | 服务状态和信息 |
570 | | `/health` | GET | 健康检查 |
571 | | `/ws` | WS | WebSocket 端点 (Server 模式) |
572 |
573 | ### API 端点(需要 API Key)
574 |
575 | | 端点 | 方法 | 说明 |
576 | |------|------|------|
577 | | `/v1/models` | GET | OpenAI 格式模型列表 |
578 | | `/v1/chat/completions` | POST | OpenAI 格式聊天补全 |
579 | | `/v1/messages` | POST | Claude 格式消息 |
580 | | `/v1beta/models` | GET | Gemini 格式模型列表 |
581 | | `/v1beta/models/:model` | GET | Gemini 格式模型详情 |
582 | | `/v1beta/models/:model:generateContent` | POST | Gemini 格式生成内容 |
583 |
584 | ### 管理端点(需要 API Key)
585 |
586 | | 端点 | 方法 | 说明 |
587 | |------|------|------|
588 | | `/admin/status` | GET | 账号池状态 |
589 | | `/admin/stats` | GET | 详细 API 统计 |
590 | | `/admin/ip` | GET | IP 遥测统计(请求数/Token/RPM) |
591 | | `/admin/register` | POST | 触发注册 |
592 | | `/admin/refresh` | POST | 刷新账号池 |
593 | | `/admin/reload-config` | POST | 热重载配置文件 |
594 | | `/admin/force-refresh` | POST | 强制刷新所有账号 |
595 | | `/admin/config/cooldown` | POST | 动态调整冷却时间 |
596 | | `/admin/browser-refresh` | POST | 手动触发浏览器刷新指定账号 |
597 | | `/admin/config/browser-refresh` | POST | 配置浏览器刷新开关 |
598 | | `/admin/flow/status` | GET | Flow 服务状态 |
599 | | `/admin/flow/add-token` | POST | 添加 Flow Token |
600 | | `/admin/flow/remove-token` | POST | 移除 Flow Token |
601 | | `/admin/flow/reload` | POST | 重新加载 Flow Token |
602 |
603 | ---
604 |
605 | ## 🛠️ 开发
606 |
607 | ### 本地运行
608 |
609 | ```bash
610 | # 安装依赖
611 | go mod download
612 |
613 | # 运行
614 | go run .
615 |
616 | # 调试模式
617 | go run . -d
618 | ```
619 |
620 | ### 构建
621 |
622 | ```bash
623 | # 标准构建
624 | go build -o business2api .
625 |
626 | # 带 QUIC/uTLS 支持(推荐)
627 | go build -tags "with_quic with_utls" -o business2api .
628 |
629 | # 生产构建(压缩体积)
630 | CGO_ENABLED=0 go build -ldflags="-s -w" -tags "with_quic with_utls" -o business2api .
631 |
632 | # 多平台构建
633 | GOOS=linux GOARCH=amd64 go build -tags "with_quic with_utls" -o business2api-linux-amd64 .
634 | GOOS=windows GOARCH=amd64 go build -tags "with_quic with_utls" -o business2api-windows-amd64.exe .
635 | GOOS=darwin GOARCH=arm64 go build -tags "with_quic with_utls" -o business2api-darwin-arm64 .
636 | ```
637 |
638 | ### 项目结构
639 |
640 | ```
641 | .
642 | ├── main.go # 主程序入口
643 | ├── config/ # 配置文件
644 | │ ├── config.json.example
645 | │ └── README.md
646 | ├── src/
647 | │ ├── flow/ # Flow 图片/视频生成
648 | │ ├── logger/ # 日志模块
649 | │ ├── pool/ # 账号池管理(C/S架构)
650 | │ ├── proxy/ # 代理池管理
651 | │ ├── register/ # 浏览器自动注册
652 | │ └── utils/ # 工具函数
653 | ├── docker/ # Docker 相关
654 | │ └── docker-compose.yml
655 | └── .github/ # GitHub Actions
656 | ```
657 |
658 | ---
659 |
660 | ## 📊 IP 遥测接口
661 |
662 | 访问 `/admin/ip` 获取全部 IP 请求统计:
663 |
664 | ```bash
665 | curl http://localhost:8000/admin/ip \
666 | -H "Authorization: Bearer sk-your-api-key"
667 | ```
668 |
669 | **返回字段说明:**
670 |
671 | | 字段 | 说明 |
672 | |------|------|
673 | | `unique_ips` | 独立 IP 数量 |
674 | | `total_requests` | 总请求数 |
675 | | `total_tokens` | 总 Token 消耗 |
676 | | `total_images` | 图片生成数 |
677 | | `total_videos` | 视频生成数 |
678 | | `ips[].rpm` | 单 IP 每分钟请求数 |
679 | | `ips[].input_tokens` | 输入 Token |
680 | | `ips[].output_tokens` | 输出 Token |
681 | | `ips[].models` | 各模型使用次数 |
682 |
683 | ---
684 |
685 |
686 | ## Star History
687 |
688 | [](https://www.star-history.com/#XxxXTeam/business2api&type=date&legend=top-left)
689 |
690 | ## 📄 License
691 |
692 | MIT License
693 |
--------------------------------------------------------------------------------
/src/pool/pool.go:
--------------------------------------------------------------------------------
1 | package pool
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/base64"
7 | "encoding/json"
8 | "fmt"
9 | "log"
10 | "net/http"
11 | "os"
12 | "path/filepath"
13 | "strings"
14 | "sync"
15 | "sync/atomic"
16 | "time"
17 |
18 | "business2api/src/logger"
19 | )
20 |
21 | // ==================== 数据结构 ====================
22 |
23 | // Cookie 账号Cookie
24 | type Cookie struct {
25 | Name string `json:"name"`
26 | Value string `json:"value"`
27 | Domain string `json:"domain"`
28 | }
29 |
30 | // AccountData 账号数据
31 | type AccountData struct {
32 | Email string `json:"email"`
33 | FullName string `json:"fullName"`
34 | Authorization string `json:"authorization"`
35 | Cookies []Cookie `json:"cookies"`
36 | CookieString string `json:"cookie_string,omitempty"`
37 | ResponseHeaders map[string]string `json:"response_headers,omitempty"`
38 | Timestamp string `json:"timestamp"`
39 | ConfigID string `json:"configId,omitempty"`
40 | CSESIDX string `json:"csesidx,omitempty"`
41 | }
42 |
43 | func ParseCookieString(cookieStr string) []Cookie {
44 | var cookies []Cookie
45 | if cookieStr == "" {
46 | return cookies
47 | }
48 |
49 | parts := strings.Split(cookieStr, "; ")
50 | for _, part := range parts {
51 | part = strings.TrimSpace(part)
52 | if part == "" {
53 | continue
54 | }
55 | idx := strings.Index(part, "=")
56 | if idx > 0 {
57 | cookies = append(cookies, Cookie{
58 | Name: part[:idx],
59 | Value: part[idx+1:],
60 | Domain: ".gemini.google", // 默认域名
61 | })
62 | }
63 | }
64 | return cookies
65 | }
66 |
67 | func (a *AccountData) GetAllCookies() []Cookie {
68 | if len(a.Cookies) > 0 {
69 | return a.Cookies
70 | }
71 | if a.CookieString != "" {
72 | return ParseCookieString(a.CookieString)
73 | }
74 | return nil
75 | }
76 |
77 | // AccountStatus 账号状态
78 | type AccountStatus int
79 |
80 | const (
81 | StatusPending AccountStatus = iota // 待刷新
82 | StatusReady // 就绪可用
83 | StatusCooldown // 冷却中
84 | StatusInvalid // 失效
85 | )
86 |
87 | // Account 账号实例
88 | type Account struct {
89 | Data AccountData
90 | FilePath string
91 | JWT string
92 | JWTExpires time.Time
93 | ConfigID string
94 | CSESIDX string
95 | LastRefresh time.Time
96 | LastUsed time.Time // 最后使用时间
97 | Refreshed bool
98 | FailCount int // 连续失败次数
99 | BrowserRefreshCount int // 浏览器刷新尝试次数
100 | SuccessCount int // 成功次数
101 | TotalCount int // 总使用次数
102 | DailyCount int // 每日调用次数
103 | DailyCountDate string // 每日计数日期 (YYYY-MM-DD)
104 | Status AccountStatus
105 | Mu sync.Mutex
106 | }
107 |
108 | // SetCooldownMultiplier 设置冷却时间倍数(用于429限流)
109 | func (acc *Account) SetCooldownMultiplier(multiplier int) {
110 | acc.Mu.Lock()
111 | acc.LastUsed = time.Now().Add(UseCooldown * time.Duration(multiplier-1))
112 | acc.Mu.Unlock()
113 | }
114 |
115 | // 默认冷却时间(可通过配置覆盖)
116 | var (
117 | RefreshCooldown = 4 * time.Minute // 刷新冷却
118 | UseCooldown = 15 * time.Second // 使用冷却
119 | JWTRefreshThreshold = 60 * time.Second // JWT刷新阈值
120 | MaxFailCount = 3 // 最大连续失败次数
121 | EnableBrowserRefresh = true // 是否启用浏览器刷新
122 | BrowserRefreshHeadless = true // 浏览器刷新是否无头模式
123 | BrowserRefreshMaxRetry = 1 // 浏览器刷新最大重试次数
124 | AutoDelete401 = false // 401时是否自动删除账号
125 | DailyLimit = 3000 // 每账号每日最大调用次数
126 | DataDir string
127 | DefaultConfig string
128 | Proxy string
129 | JwtTTL = 270 * time.Second
130 | HTTPClient *http.Client
131 | )
132 |
133 | type RefreshCookieFunc func(acc *Account, headless bool, proxy string) *BrowserRefreshResult
134 | type BrowserRefreshResult struct {
135 | Success bool
136 | SecureCookies []Cookie
137 | Authorization string
138 | ConfigID string
139 | CSESIDX string
140 | ResponseHeaders map[string]string
141 | Error error
142 | }
143 |
144 | var RefreshCookieWithBrowser RefreshCookieFunc
145 |
146 | func readResponseBody(resp *http.Response) ([]byte, error) {
147 | body := make([]byte, 0)
148 | buf := make([]byte, 4096)
149 | for {
150 | n, err := resp.Body.Read(buf)
151 | if n > 0 {
152 | body = append(body, buf[:n]...)
153 | }
154 | if err != nil {
155 | break
156 | }
157 | }
158 | return body, nil
159 | }
160 |
161 | type AccountPool struct {
162 | readyAccounts []*Account
163 | pendingAccounts []*Account
164 | index uint64
165 | mu sync.RWMutex
166 | refreshInterval time.Duration
167 | refreshWorkers int
168 | stopChan chan struct{}
169 | totalSuccess int64
170 | totalFailed int64
171 | totalRequests int64
172 | }
173 |
174 | func (p *AccountPool) GetReadyAccounts() []*Account {
175 | p.mu.RLock()
176 | defer p.mu.RUnlock()
177 | return p.readyAccounts
178 | }
179 | func (p *AccountPool) GetPendingAccounts() []*Account {
180 | p.mu.RLock()
181 | defer p.mu.RUnlock()
182 | return p.pendingAccounts
183 | }
184 | func (p *AccountPool) WithLock(fn func(ready, pending []*Account)) {
185 | p.mu.RLock()
186 | defer p.mu.RUnlock()
187 | fn(p.readyAccounts, p.pendingAccounts)
188 | }
189 | func (p *AccountPool) WithWriteLock(fn func(ready, pending []*Account) ([]*Account, []*Account)) {
190 | p.mu.Lock()
191 | defer p.mu.Unlock()
192 | p.readyAccounts, p.pendingAccounts = fn(p.readyAccounts, p.pendingAccounts)
193 | }
194 |
195 | var Pool = &AccountPool{
196 | refreshInterval: 5 * time.Second,
197 | refreshWorkers: 5,
198 | stopChan: make(chan struct{}),
199 | }
200 |
201 | func SetCooldowns(refreshSec, useSec int) {
202 | if refreshSec > 0 {
203 | RefreshCooldown = time.Duration(refreshSec) * time.Second
204 | }
205 | if useSec > 0 {
206 | UseCooldown = time.Duration(useSec) * time.Second
207 | }
208 | logger.Info("⚙️ 冷却配置: 刷新=%v, 使用=%v", RefreshCooldown, UseCooldown)
209 | }
210 |
211 | // SetDailyLimit 设置每账号每日最大调用次数
212 | func SetDailyLimit(limit int) {
213 | if limit >= 0 {
214 | DailyLimit = limit
215 | if limit == 0 {
216 | logger.Info("⚙️ 每日调用限制: 无限制")
217 | } else {
218 | logger.Info("⚙️ 每日调用限制: %d次/账号", limit)
219 | }
220 | }
221 | }
222 |
223 | func (p *AccountPool) Load(dir string) error {
224 | p.mu.Lock()
225 | defer p.mu.Unlock()
226 |
227 | files, err := filepath.Glob(filepath.Join(dir, "*.json"))
228 | if err != nil {
229 | return err
230 | }
231 |
232 | existingAccounts := make(map[string]*Account)
233 | for _, acc := range p.readyAccounts {
234 | existingAccounts[acc.FilePath] = acc
235 | }
236 | for _, acc := range p.pendingAccounts {
237 | existingAccounts[acc.FilePath] = acc
238 | }
239 |
240 | var newReadyAccounts []*Account
241 | var newPendingAccounts []*Account
242 |
243 | for _, f := range files {
244 | if acc, ok := existingAccounts[f]; ok {
245 | if acc.Refreshed {
246 | newReadyAccounts = append(newReadyAccounts, acc)
247 | } else {
248 | newPendingAccounts = append(newPendingAccounts, acc)
249 | }
250 | delete(existingAccounts, f)
251 | continue
252 | }
253 |
254 | data, err := os.ReadFile(f)
255 | if err != nil {
256 | log.Printf("⚠️ 读取 %s 失败: %v", f, err)
257 | continue
258 | }
259 |
260 | var acc AccountData
261 | if err := json.Unmarshal(data, &acc); err != nil {
262 | log.Printf("⚠️ 解析 %s 失败: %v", f, err)
263 | continue
264 | }
265 |
266 | csesidx := acc.CSESIDX
267 | if csesidx == "" {
268 | csesidx = extractCSESIDX(acc.Authorization)
269 | }
270 | if csesidx == "" {
271 | log.Printf("⚠️ %s 无法获取 csesidx", f)
272 | continue
273 | }
274 |
275 | configID := acc.ConfigID
276 | if configID == "" && DefaultConfig != "" {
277 | configID = DefaultConfig
278 | }
279 |
280 | newPendingAccounts = append(newPendingAccounts, &Account{
281 | Data: acc,
282 | FilePath: f,
283 | CSESIDX: csesidx,
284 | ConfigID: configID,
285 | Refreshed: false,
286 | })
287 | }
288 |
289 | p.readyAccounts = newReadyAccounts
290 | p.pendingAccounts = newPendingAccounts
291 | return nil
292 | }
293 |
294 | // GetPendingAccount 获取待刷新账号
295 | func (p *AccountPool) GetPendingAccount() *Account {
296 | p.mu.Lock()
297 | defer p.mu.Unlock()
298 |
299 | if len(p.pendingAccounts) == 0 {
300 | return nil
301 | }
302 |
303 | acc := p.pendingAccounts[0]
304 | p.pendingAccounts = p.pendingAccounts[1:]
305 | return acc
306 | }
307 |
308 | // MarkReady 标记账号为就绪
309 | func (p *AccountPool) MarkReady(acc *Account) {
310 | p.mu.Lock()
311 | defer p.mu.Unlock()
312 | acc.Refreshed = true
313 | p.readyAccounts = append(p.readyAccounts, acc)
314 | }
315 |
316 | // MarkPending 标记账号待刷新
317 | func (p *AccountPool) MarkPending(acc *Account) {
318 | p.mu.Lock()
319 | defer p.mu.Unlock()
320 |
321 | for i, a := range p.readyAccounts {
322 | if a == acc {
323 | p.readyAccounts = append(p.readyAccounts[:i], p.readyAccounts[i+1:]...)
324 | break
325 | }
326 | }
327 |
328 | acc.Mu.Lock()
329 | acc.Refreshed = false
330 | acc.Mu.Unlock()
331 |
332 | p.pendingAccounts = append(p.pendingAccounts, acc)
333 | log.Printf("🔄 账号 %s 移至刷新池", filepath.Base(acc.FilePath))
334 | }
335 |
336 | // RemoveAccount 删除失效账号
337 | func (p *AccountPool) RemoveAccount(acc *Account) {
338 | if err := os.Remove(acc.FilePath); err != nil {
339 | log.Printf("⚠️ 删除文件失败 %s: %v", acc.FilePath, err)
340 | } else {
341 | log.Printf("🗑️ 已删除失效账号: %s", filepath.Base(acc.FilePath))
342 | }
343 | }
344 |
345 | // SaveToFile 保存账号到文件
346 | func (acc *Account) SaveToFile() error {
347 | acc.Mu.Lock()
348 | defer acc.Mu.Unlock()
349 |
350 | acc.Data.Timestamp = time.Now().Format(time.RFC3339)
351 |
352 | // 同时生成 cookie 字符串(方便调试和兼容老版本)
353 | if len(acc.Data.Cookies) > 0 {
354 | var cookieParts []string
355 | for _, c := range acc.Data.Cookies {
356 | cookieParts = append(cookieParts, fmt.Sprintf("%s=%s", c.Name, c.Value))
357 | }
358 | acc.Data.CookieString = strings.Join(cookieParts, "; ")
359 | }
360 |
361 | data, err := json.MarshalIndent(acc.Data, "", " ")
362 | if err != nil {
363 | return fmt.Errorf("序列化账号数据失败: %w", err)
364 | }
365 |
366 | if err := os.WriteFile(acc.FilePath, data, 0644); err != nil {
367 | return fmt.Errorf("写入文件失败: %w", err)
368 | }
369 | return nil
370 | }
371 |
372 | // StartPoolManager 启动号池管理器
373 | func (p *AccountPool) StartPoolManager() {
374 | for i := 0; i < p.refreshWorkers; i++ {
375 | go p.refreshWorker(i)
376 | }
377 | go p.scanWorker()
378 | }
379 |
380 | func (p *AccountPool) refreshWorker(id int) {
381 | for {
382 | select {
383 | case <-p.stopChan:
384 | return
385 | default:
386 | }
387 |
388 | acc := p.GetPendingAccount()
389 | if acc == nil {
390 | time.Sleep(time.Second)
391 | continue
392 | }
393 |
394 | // 检查冷却
395 | if time.Since(acc.LastRefresh) < RefreshCooldown {
396 | acc.Mu.Lock()
397 | acc.Refreshed = true
398 | acc.Status = StatusReady
399 | acc.Mu.Unlock()
400 | p.MarkReady(acc)
401 | continue
402 | }
403 |
404 | acc.JWTExpires = time.Time{}
405 | if err := acc.RefreshJWT(); err != nil {
406 | errMsg := err.Error()
407 |
408 | // 认证失败:根据配置决定是否删除或尝试刷新
409 | if strings.Contains(errMsg, "账号失效") ||
410 | strings.Contains(errMsg, "401") ||
411 | strings.Contains(errMsg, "403") {
412 | log.Printf("⚠️ [worker-%d] [%s] 认证失效: %v", id, acc.Data.Email, err)
413 |
414 | // 如果配置了401自动删除,直接删除账号
415 | if AutoDelete401 {
416 | log.Printf("🗑️ [worker-%d] [%s] 401自动删除已启用,移除账号", id, acc.Data.Email)
417 | acc.Mu.Lock()
418 | acc.Status = StatusInvalid
419 | acc.Mu.Unlock()
420 | p.RemoveAccount(acc)
421 | continue
422 | }
423 |
424 | // 检查是否可以进行浏览器刷新
425 | acc.Mu.Lock()
426 | browserRefreshCount := acc.BrowserRefreshCount
427 | acc.Mu.Unlock()
428 | if EnableBrowserRefresh && BrowserRefreshMaxRetry > 0 && browserRefreshCount < BrowserRefreshMaxRetry && RefreshCookieWithBrowser != nil {
429 | acc.Mu.Lock()
430 | acc.BrowserRefreshCount++
431 | acc.Mu.Unlock()
432 | refreshResult := RefreshCookieWithBrowser(acc, BrowserRefreshHeadless, Proxy)
433 |
434 | if refreshResult.Success {
435 | acc.Mu.Lock()
436 | acc.Data.Cookies = refreshResult.SecureCookies
437 | if refreshResult.Authorization != "" {
438 | acc.Data.Authorization = refreshResult.Authorization
439 | }
440 | if refreshResult.ConfigID != "" {
441 | acc.ConfigID = refreshResult.ConfigID
442 | acc.Data.ConfigID = refreshResult.ConfigID
443 | }
444 | if refreshResult.CSESIDX != "" {
445 | acc.CSESIDX = refreshResult.CSESIDX
446 | acc.Data.CSESIDX = refreshResult.CSESIDX
447 | }
448 | if len(refreshResult.ResponseHeaders) > 0 {
449 | acc.Data.ResponseHeaders = refreshResult.ResponseHeaders
450 | }
451 | acc.FailCount = 0
452 | acc.BrowserRefreshCount = 0 // 成功后重置计数
453 | acc.JWTExpires = time.Time{} // 重置JWT过期时间
454 | acc.Status = StatusPending
455 | acc.Mu.Unlock()
456 |
457 | // 保存更新后的账号
458 | if err := acc.SaveToFile(); err != nil {
459 | log.Printf("⚠️ [%s] 保存刷新后的账号失败: %v", acc.Data.Email, err)
460 | }
461 | p.mu.Lock()
462 | p.pendingAccounts = append(p.pendingAccounts, acc)
463 | p.mu.Unlock()
464 | continue
465 | } else {
466 | log.Printf("⚠️ [worker-%d] [%s] 浏览器刷新失败: %v", id, acc.Data.Email, refreshResult.Error)
467 | }
468 | } else if browserRefreshCount >= BrowserRefreshMaxRetry && BrowserRefreshMaxRetry > 0 {
469 | log.Printf("⚠️ [worker-%d] [%s] 已达浏览器刷新上限 (%d次),跳过浏览器刷新", id, acc.Data.Email, BrowserRefreshMaxRetry)
470 | }
471 | acc.Mu.Lock()
472 | acc.FailCount++
473 | failCount := acc.FailCount
474 | browserRefreshCount = acc.BrowserRefreshCount
475 | acc.Mu.Unlock()
476 | maxRetry := MaxFailCount * 3 // 401的最大重试次数更宽松
477 | if maxRetry < 10 {
478 | maxRetry = 10 // 至少重试10次
479 | }
480 | if browserRefreshCount >= BrowserRefreshMaxRetry && failCount >= maxRetry {
481 | acc.Mu.Lock()
482 | acc.Status = StatusInvalid
483 | acc.Mu.Unlock()
484 | p.RemoveAccount(acc)
485 | continue
486 | }
487 |
488 | waitTime := time.Duration(failCount*30) * time.Second
489 | if waitTime > 5*time.Minute {
490 | waitTime = 5 * time.Minute // 最大等待5分钟
491 | }
492 | log.Printf("⏳ [worker-%d] [%s] 401刷新失败 (%d/%d次),%v后重试", id, acc.Data.Email, failCount, maxRetry, waitTime)
493 | time.Sleep(waitTime)
494 |
495 | p.mu.Lock()
496 | p.pendingAccounts = append(p.pendingAccounts, acc)
497 | p.mu.Unlock()
498 | continue
499 | }
500 |
501 | // 冷却中:直接标记就绪
502 | if strings.Contains(errMsg, "刷新冷却中") {
503 | acc.Mu.Lock()
504 | acc.Refreshed = true
505 | acc.Status = StatusReady
506 | acc.Mu.Unlock()
507 | p.MarkReady(acc)
508 | continue
509 | }
510 |
511 | // 其他错误:累计失败次数
512 | acc.Mu.Lock()
513 | acc.FailCount++
514 | failCount := acc.FailCount
515 | acc.Mu.Unlock()
516 |
517 | if failCount >= MaxFailCount {
518 | log.Printf("❌ [worker-%d] [%s] 连续失败 %d 次,移除账号: %v", id, acc.Data.Email, failCount, err)
519 | acc.Mu.Lock()
520 | acc.Status = StatusInvalid
521 | acc.Mu.Unlock()
522 | p.RemoveAccount(acc)
523 | } else {
524 | log.Printf("⚠️ [worker-%d] [%s] 刷新失败 (%d/%d): %v", id, acc.Data.Email, failCount, MaxFailCount, err)
525 | // 延迟后重试
526 | time.Sleep(time.Duration(failCount) * 5 * time.Second)
527 | p.mu.Lock()
528 | p.pendingAccounts = append(p.pendingAccounts, acc)
529 | p.mu.Unlock()
530 | }
531 | } else {
532 | // 刷新成功:重置失败计数
533 | acc.Mu.Lock()
534 | acc.FailCount = 0
535 | acc.Status = StatusReady
536 | acc.Mu.Unlock()
537 |
538 | if err := acc.SaveToFile(); err != nil {
539 | log.Printf("⚠️ [%s] 写回文件失败: %v", acc.Data.Email, err)
540 | }
541 | p.MarkReady(acc)
542 | }
543 | }
544 | }
545 |
546 | func (p *AccountPool) scanWorker() {
547 | ticker := time.NewTicker(p.refreshInterval)
548 | fileScanTicker := time.NewTicker(5 * time.Minute)
549 | defer ticker.Stop()
550 | defer fileScanTicker.Stop()
551 |
552 | for {
553 | select {
554 | case <-p.stopChan:
555 | return
556 | case <-fileScanTicker.C:
557 | p.Load(DataDir)
558 | case <-ticker.C:
559 | p.RefreshExpiredAccounts()
560 | }
561 | }
562 | }
563 |
564 | // RefreshExpiredAccounts 刷新即将过期的账号
565 | func (p *AccountPool) RefreshExpiredAccounts() {
566 | p.mu.Lock()
567 | defer p.mu.Unlock()
568 |
569 | var stillReady []*Account
570 | refreshed := 0
571 | now := time.Now()
572 |
573 | for _, acc := range p.readyAccounts {
574 | acc.Mu.Lock()
575 | jwtExpires := acc.JWTExpires
576 | lastRefresh := acc.LastRefresh
577 | acc.Mu.Unlock()
578 |
579 | needsRefresh := jwtExpires.IsZero() || now.Add(JWTRefreshThreshold).After(jwtExpires)
580 | inCooldown := now.Sub(lastRefresh) < RefreshCooldown
581 |
582 | if needsRefresh && !inCooldown {
583 | acc.Mu.Lock()
584 | acc.Refreshed = false
585 | acc.Mu.Unlock()
586 | p.pendingAccounts = append(p.pendingAccounts, acc)
587 | refreshed++
588 | } else {
589 | stillReady = append(stillReady, acc)
590 | }
591 | }
592 |
593 | p.readyAccounts = stillReady
594 | if refreshed > 0 {
595 | log.Printf("🔄 扫描刷新: %d 个账号JWT即将过期", refreshed)
596 | }
597 | }
598 |
599 | func (p *AccountPool) RefreshAllAccounts() {
600 | p.mu.Lock()
601 | defer p.mu.Unlock()
602 |
603 | var stillReady []*Account
604 | refreshed, skipped := 0, 0
605 |
606 | for _, acc := range p.readyAccounts {
607 | if time.Since(acc.LastRefresh) < RefreshCooldown {
608 | stillReady = append(stillReady, acc)
609 | skipped++
610 | continue
611 | }
612 | acc.Refreshed = false
613 | acc.JWTExpires = time.Time{}
614 | p.pendingAccounts = append(p.pendingAccounts, acc)
615 | refreshed++
616 | }
617 |
618 | p.readyAccounts = stillReady
619 | if refreshed > 0 {
620 | log.Printf("🔄 全量刷新: %d 个账号已加入刷新队列,%d 个在冷却中跳过", refreshed, skipped)
621 | }
622 | }
623 |
624 | // checkAndUpdateDailyCount 检查并更新每日计数,返回是否超限
625 | func (acc *Account) checkAndUpdateDailyCount() bool {
626 | today := time.Now().Format("2006-01-02")
627 | if acc.DailyCountDate != today {
628 | // 新的一天,重置计数
629 | acc.DailyCountDate = today
630 | acc.DailyCount = 0
631 | }
632 | // 检查是否超过每日限制
633 | if DailyLimit > 0 && acc.DailyCount >= DailyLimit {
634 | return true // 超限
635 | }
636 | acc.DailyCount++
637 | return false
638 | }
639 |
640 | // GetDailyUsage 获取每日使用情况
641 | func (acc *Account) GetDailyUsage() (count int, limit int, date string) {
642 | acc.Mu.Lock()
643 | defer acc.Mu.Unlock()
644 | today := time.Now().Format("2006-01-02")
645 | if acc.DailyCountDate != today {
646 | return 0, DailyLimit, today
647 | }
648 | return acc.DailyCount, DailyLimit, acc.DailyCountDate
649 | }
650 |
651 | func (p *AccountPool) Next() *Account {
652 | p.mu.RLock()
653 | defer p.mu.RUnlock()
654 |
655 | if len(p.readyAccounts) == 0 {
656 | return nil
657 | }
658 |
659 | n := len(p.readyAccounts)
660 | startIdx := atomic.AddUint64(&p.index, 1) - 1
661 | now := time.Now()
662 |
663 | var bestAccount *Account
664 | var oldestUsed time.Time
665 | var allExceededDaily bool = true
666 |
667 | // 第一轮:找不在使用冷却中且未超日限的账号
668 | for i := 0; i < n; i++ {
669 | acc := p.readyAccounts[(startIdx+uint64(i))%uint64(n)]
670 | acc.Mu.Lock()
671 | inUseCooldown := now.Sub(acc.LastUsed) < UseCooldown
672 | lastUsed := acc.LastUsed
673 |
674 | // 检查每日限制(不更新计数)
675 | today := now.Format("2006-01-02")
676 | dailyCount := acc.DailyCount
677 | if acc.DailyCountDate != today {
678 | dailyCount = 0
679 | }
680 | exceededDaily := DailyLimit > 0 && dailyCount >= DailyLimit
681 | acc.Mu.Unlock()
682 |
683 | if exceededDaily {
684 | continue // 跳过已达每日限制的账号
685 | }
686 | allExceededDaily = false
687 |
688 | if !inUseCooldown {
689 | // 找到可用账号,标记使用时间并更新每日计数
690 | acc.Mu.Lock()
691 | acc.LastUsed = now
692 | acc.TotalCount++
693 | acc.checkAndUpdateDailyCount()
694 | acc.Mu.Unlock()
695 | atomic.AddInt64(&p.totalRequests, 1)
696 | return acc
697 | }
698 |
699 | // 记录最久未使用的账号作为备选
700 | if bestAccount == nil || lastUsed.Before(oldestUsed) {
701 | bestAccount = acc
702 | oldestUsed = lastUsed
703 | }
704 | }
705 |
706 | // 所有账号都超过每日限制
707 | if allExceededDaily {
708 | log.Printf("⚠️ 所有账号已达每日调用上限 (%d次/天)", DailyLimit)
709 | return nil
710 | }
711 |
712 | // 所有未超限的账号都在冷却中,返回最久未使用的
713 | if bestAccount != nil {
714 | bestAccount.Mu.Lock()
715 | bestAccount.LastUsed = now
716 | bestAccount.TotalCount++
717 | bestAccount.checkAndUpdateDailyCount()
718 | bestAccount.Mu.Unlock()
719 | atomic.AddInt64(&p.totalRequests, 1)
720 | log.Printf("⏳ 所有账号在使用冷却中,选择最久未用: %s", bestAccount.Data.Email)
721 | }
722 | return bestAccount
723 | }
724 |
725 | // MarkUsed 标记账号已使用(成功)
726 | func (p *AccountPool) MarkUsed(acc *Account, success bool) {
727 | if acc == nil {
728 | return
729 | }
730 | acc.Mu.Lock()
731 | defer acc.Mu.Unlock()
732 |
733 | if success {
734 | acc.SuccessCount++
735 | acc.FailCount = 0 // 重置连续失败
736 | atomic.AddInt64(&p.totalSuccess, 1)
737 | } else {
738 | acc.FailCount++
739 | atomic.AddInt64(&p.totalFailed, 1)
740 | }
741 | }
742 |
743 | // MarkNeedsRefresh 标记账号需要刷新(遇到401/403等)
744 | func (p *AccountPool) MarkNeedsRefresh(acc *Account) {
745 | if acc == nil {
746 | return
747 | }
748 | acc.Mu.Lock()
749 | acc.LastRefresh = time.Time{} // 重置刷新时间,强制刷新
750 | acc.Mu.Unlock()
751 | p.MarkPending(acc)
752 | }
753 |
754 | func (p *AccountPool) Count() int { p.mu.RLock(); defer p.mu.RUnlock(); return len(p.readyAccounts) }
755 | func (p *AccountPool) PendingCount() int {
756 | p.mu.RLock()
757 | defer p.mu.RUnlock()
758 | return len(p.pendingAccounts)
759 | }
760 | func (p *AccountPool) ReadyCount() int {
761 | p.mu.RLock()
762 | defer p.mu.RUnlock()
763 | return len(p.readyAccounts)
764 | }
765 | func (p *AccountPool) TotalCount() int {
766 | p.mu.RLock()
767 | defer p.mu.RUnlock()
768 | return len(p.readyAccounts) + len(p.pendingAccounts)
769 | }
770 |
771 | // Stats 返回号池统计信息
772 | func (p *AccountPool) Stats() map[string]interface{} {
773 | p.mu.RLock()
774 | defer p.mu.RUnlock()
775 |
776 | totalSuccess := atomic.LoadInt64(&p.totalSuccess)
777 | totalFailed := atomic.LoadInt64(&p.totalFailed)
778 | totalRequests := atomic.LoadInt64(&p.totalRequests)
779 |
780 | successRate := float64(0)
781 | if totalRequests > 0 {
782 | successRate = float64(totalSuccess) / float64(totalRequests) * 100
783 | }
784 |
785 | // 统计每日可用账号数
786 | today := time.Now().Format("2006-01-02")
787 | availableToday := 0
788 | exceededToday := 0
789 | for _, acc := range p.readyAccounts {
790 | acc.Mu.Lock()
791 | dailyCount := acc.DailyCount
792 | if acc.DailyCountDate != today {
793 | dailyCount = 0
794 | }
795 | acc.Mu.Unlock()
796 | if DailyLimit == 0 || dailyCount < DailyLimit {
797 | availableToday++
798 | } else {
799 | exceededToday++
800 | }
801 | }
802 |
803 | return map[string]interface{}{
804 | "ready": len(p.readyAccounts),
805 | "pending": len(p.pendingAccounts),
806 | "total": len(p.readyAccounts) + len(p.pendingAccounts),
807 | "available_today": availableToday,
808 | "exceeded_today": exceededToday,
809 | "total_requests": totalRequests,
810 | "total_success": totalSuccess,
811 | "total_failed": totalFailed,
812 | "success_rate": fmt.Sprintf("%.1f%%", successRate),
813 | "daily_limit": DailyLimit,
814 | "cooldowns": map[string]interface{}{
815 | "refresh_sec": int(RefreshCooldown.Seconds()),
816 | "use_sec": int(UseCooldown.Seconds()),
817 | },
818 | }
819 | }
820 |
821 | // AccountInfo 账号信息(用于API返回)
822 | type AccountInfo struct {
823 | Email string `json:"email"`
824 | Status string `json:"status"`
825 | LastRefresh time.Time `json:"last_refresh"`
826 | LastUsed time.Time `json:"last_used"`
827 | FailCount int `json:"fail_count"`
828 | SuccessCount int `json:"success_count"`
829 | TotalCount int `json:"total_count"`
830 | DailyCount int `json:"daily_count"`
831 | DailyLimit int `json:"daily_limit"`
832 | DailyRemaining int `json:"daily_remaining"`
833 | JWTExpires time.Time `json:"jwt_expires"`
834 | }
835 |
836 | // ListAccounts 列出所有账号信息
837 | func (p *AccountPool) ListAccounts() []AccountInfo {
838 | p.mu.RLock()
839 | defer p.mu.RUnlock()
840 |
841 | var accounts []AccountInfo
842 | statusNames := map[AccountStatus]string{
843 | StatusPending: "pending",
844 | StatusReady: "ready",
845 | StatusCooldown: "cooldown",
846 | StatusInvalid: "invalid",
847 | }
848 |
849 | today := time.Now().Format("2006-01-02")
850 | addAccounts := func(list []*Account) {
851 | for _, acc := range list {
852 | acc.Mu.Lock()
853 | dailyCount := acc.DailyCount
854 | if acc.DailyCountDate != today {
855 | dailyCount = 0
856 | }
857 | dailyRemaining := DailyLimit - dailyCount
858 | if DailyLimit == 0 {
859 | dailyRemaining = -1 // -1 表示无限制
860 | } else if dailyRemaining < 0 {
861 | dailyRemaining = 0
862 | }
863 | info := AccountInfo{
864 | Email: acc.Data.Email,
865 | Status: statusNames[acc.Status],
866 | LastRefresh: acc.LastRefresh,
867 | LastUsed: acc.LastUsed,
868 | FailCount: acc.FailCount,
869 | SuccessCount: acc.SuccessCount,
870 | TotalCount: acc.TotalCount,
871 | DailyCount: dailyCount,
872 | DailyLimit: DailyLimit,
873 | DailyRemaining: dailyRemaining,
874 | JWTExpires: acc.JWTExpires,
875 | }
876 | acc.Mu.Unlock()
877 | accounts = append(accounts, info)
878 | }
879 | }
880 |
881 | addAccounts(p.readyAccounts)
882 | addAccounts(p.pendingAccounts)
883 |
884 | return accounts
885 | }
886 |
887 | // ForceRefreshAll 强制刷新所有账号
888 | func (p *AccountPool) ForceRefreshAll() int {
889 | p.mu.Lock()
890 | defer p.mu.Unlock()
891 |
892 | count := 0
893 | for _, acc := range p.readyAccounts {
894 | acc.Mu.Lock()
895 | acc.Refreshed = false
896 | acc.JWTExpires = time.Time{}
897 | acc.LastRefresh = time.Time{} // 强制跳过冷却
898 | acc.Mu.Unlock()
899 | p.pendingAccounts = append(p.pendingAccounts, acc)
900 | count++
901 | }
902 | p.readyAccounts = nil
903 |
904 | log.Printf("🔄 强制刷新: %d 个账号已加入刷新队列", count)
905 | return count
906 | }
907 |
908 | func urlsafeB64Encode(data []byte) string {
909 | return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
910 | }
911 |
912 | func kqEncode(s string) string {
913 | var b []byte
914 | for _, ch := range s {
915 | v := int(ch)
916 | if v > 255 {
917 | b = append(b, byte(v&255), byte(v>>8))
918 | } else {
919 | b = append(b, byte(v))
920 | }
921 | }
922 | return urlsafeB64Encode(b)
923 | }
924 |
925 | func createJWT(keyBytes []byte, keyID, csesidx string) string {
926 | now := time.Now().Unix()
927 | header := map[string]interface{}{"alg": "HS256", "typ": "JWT", "kid": keyID}
928 | payload := map[string]interface{}{
929 | "iss": "https://business.gemini.google",
930 | "aud": "https://biz-discoveryengine.googleapis.com",
931 | "sub": fmt.Sprintf("csesidx/%s", csesidx),
932 | "iat": now, "exp": now + 300, "nbf": now,
933 | }
934 |
935 | headerJSON, _ := json.Marshal(header)
936 | payloadJSON, _ := json.Marshal(payload)
937 | message := kqEncode(string(headerJSON)) + "." + kqEncode(string(payloadJSON))
938 |
939 | h := hmac.New(sha256.New, keyBytes)
940 | h.Write([]byte(message))
941 | return message + "." + urlsafeB64Encode(h.Sum(nil))
942 | }
943 |
944 | func extractCSESIDX(auth string) string {
945 | parts := strings.Split(auth, " ")
946 | if len(parts) != 2 {
947 | return ""
948 | }
949 | jwtParts := strings.Split(parts[1], ".")
950 | if len(jwtParts) != 3 {
951 | return ""
952 | }
953 |
954 | payload, err := base64.RawURLEncoding.DecodeString(jwtParts[1])
955 | if err != nil {
956 | return ""
957 | }
958 |
959 | var claims struct {
960 | Sub string `json:"sub"`
961 | }
962 | if err := json.Unmarshal(payload, &claims); err != nil {
963 | return ""
964 | }
965 |
966 | if strings.HasPrefix(claims.Sub, "csesidx/") {
967 | return strings.TrimPrefix(claims.Sub, "csesidx/")
968 | }
969 | return ""
970 | }
971 |
972 | // ==================== 账号操作 ====================
973 |
974 | func (acc *Account) getCookie(name string) string {
975 | for _, c := range acc.Data.Cookies {
976 | if c.Name == name {
977 | return c.Value
978 | }
979 | }
980 | return ""
981 | }
982 |
983 | // RefreshJWT 刷新JWT
984 | func (acc *Account) RefreshJWT() error {
985 | acc.Mu.Lock()
986 | defer acc.Mu.Unlock()
987 |
988 | // 检查JWT是否仍有效
989 | if time.Now().Before(acc.JWTExpires) {
990 | return nil
991 | }
992 |
993 | // 检查刷新冷却
994 | if time.Since(acc.LastRefresh) < RefreshCooldown {
995 | return fmt.Errorf("刷新冷却中,剩余 %.0f 秒", (RefreshCooldown - time.Since(acc.LastRefresh)).Seconds())
996 | }
997 |
998 | // 获取必要的Cookie
999 | secureSES := acc.getCookie("__Secure-C_SES")
1000 | hostOSES := acc.getCookie("__Host-C_OSES")
1001 |
1002 | // 验证Cookie是否存在
1003 | if secureSES == "" {
1004 | return fmt.Errorf("账号失效: 缺少 __Secure-C_SES Cookie")
1005 | }
1006 |
1007 | // 构建Cookie字符串
1008 | cookie := fmt.Sprintf("__Secure-C_SES=%s", secureSES)
1009 | if hostOSES != "" {
1010 | cookie += fmt.Sprintf("; __Host-C_OSES=%s", hostOSES)
1011 | }
1012 |
1013 | // 添加其他可能需要的Cookie
1014 | for _, c := range acc.Data.Cookies {
1015 | if c.Name != "__Secure-C_SES" && c.Name != "__Host-C_OSES" {
1016 | if strings.HasPrefix(c.Name, "__Secure-") || strings.HasPrefix(c.Name, "__Host-") {
1017 | cookie += fmt.Sprintf("; %s=%s", c.Name, c.Value)
1018 | }
1019 | }
1020 | }
1021 |
1022 | req, _ := http.NewRequest("GET", "https://business.gemini.google/auth/getoxsrf", nil)
1023 | q := req.URL.Query()
1024 | q.Add("csesidx", acc.CSESIDX)
1025 | req.URL.RawQuery = q.Encode()
1026 |
1027 | req.Header.Set("Cookie", cookie)
1028 | req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
1029 | req.Header.Set("Referer", "https://business.gemini.google/")
1030 |
1031 | resp, err := HTTPClient.Do(req)
1032 | if err != nil {
1033 | return fmt.Errorf("getoxsrf 请求失败: %w", err)
1034 | }
1035 | defer resp.Body.Close()
1036 |
1037 | if resp.StatusCode != 200 {
1038 | body, _ := readResponseBody(resp)
1039 | bodyStr := string(body)
1040 | if len(bodyStr) > 200 {
1041 | bodyStr = bodyStr[:200]
1042 | }
1043 |
1044 | // 详细的错误分类
1045 | switch resp.StatusCode {
1046 | case 401, 403:
1047 | // 认证失败,Cookie可能过期
1048 | return fmt.Errorf("账号失效: %d - Cookie可能已过期", resp.StatusCode)
1049 | case 429:
1050 | // 速率限制
1051 | return fmt.Errorf("请求频率过高: %d", resp.StatusCode)
1052 | case 500, 502, 503, 504:
1053 | // 服务器错误,可能是临时的
1054 | return fmt.Errorf("服务器错误: %d, 稍后重试", resp.StatusCode)
1055 | default:
1056 | return fmt.Errorf("getoxsrf 失败: %d %s", resp.StatusCode, bodyStr)
1057 | }
1058 | }
1059 |
1060 | body, _ := readResponseBody(resp)
1061 | txt := strings.TrimPrefix(string(body), ")]}'")
1062 | txt = strings.TrimSpace(txt)
1063 |
1064 | var data struct {
1065 | XsrfToken string `json:"xsrfToken"`
1066 | KeyID string `json:"keyId"`
1067 | }
1068 | if err := json.Unmarshal([]byte(txt), &data); err != nil {
1069 | return fmt.Errorf("解析 xsrf 响应失败: %w", err)
1070 | }
1071 |
1072 | token := data.XsrfToken
1073 | switch len(token) % 4 {
1074 | case 2:
1075 | token += "=="
1076 | case 3:
1077 | token += "="
1078 | }
1079 | keyBytes, err := base64.URLEncoding.DecodeString(token)
1080 | if err != nil {
1081 | return fmt.Errorf("解码 xsrfToken 失败: %w", err)
1082 | }
1083 |
1084 | acc.JWT = createJWT(keyBytes, data.KeyID, acc.CSESIDX)
1085 | acc.JWTExpires = time.Now().Add(JwtTTL)
1086 | acc.LastRefresh = time.Now()
1087 |
1088 | if acc.ConfigID == "" {
1089 | configID, err := acc.fetchConfigID()
1090 | if err != nil {
1091 | return fmt.Errorf("获取 configId 失败: %w", err)
1092 | }
1093 | acc.ConfigID = configID
1094 | }
1095 | return nil
1096 | }
1097 |
1098 | // GetJWT 获取JWT
1099 | func (acc *Account) GetJWT() (string, string, error) {
1100 | acc.Mu.Lock()
1101 | defer acc.Mu.Unlock()
1102 | if acc.JWT == "" {
1103 | return "", "", fmt.Errorf("JWT 为空,账号未刷新")
1104 | }
1105 | return acc.JWT, acc.ConfigID, nil
1106 | }
1107 |
1108 | func (acc *Account) fetchConfigID() (string, error) {
1109 | if acc.Data.ConfigID != "" {
1110 | return acc.Data.ConfigID, nil
1111 | }
1112 | if DefaultConfig != "" {
1113 | return DefaultConfig, nil
1114 | }
1115 | return "", fmt.Errorf("未配置 configId")
1116 | }
1117 |
--------------------------------------------------------------------------------
/src/pool/pool_server.go:
--------------------------------------------------------------------------------
1 | package pool
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "os"
10 | "path/filepath"
11 | "sync"
12 | "time"
13 |
14 | "business2api/src/logger"
15 |
16 | "github.com/gorilla/websocket"
17 | )
18 |
19 | // ==================== 号池服务器(C/S架构 + WebSocket) ====================
20 | // Server: 管理端 - 负责API服务、账号分配、状态监控
21 | // Client: 工作端 - 负责注册新账号、401账号Cookie续期
22 |
23 | // PoolServerConfig 号池服务器配置
24 | type PoolServerConfig struct {
25 | Enable bool `json:"enable"` // 是否启用分离模式
26 | Mode string `json:"mode"` // 模式: "server" 或 "client"
27 | ServerAddr string `json:"server_addr"` // 服务器地址(客户端模式使用)
28 | ListenAddr string `json:"listen_addr"` // WebSocket监听地址(服务端模式使用)
29 | Secret string `json:"secret"` // 通信密钥
30 | TargetCount int `json:"target_count"` // 目标账号数量
31 | DataDir string `json:"data_dir"` // 数据目录
32 | ClientThreads int `json:"client_threads"` // 客户端并发线程数
33 | ExpiredAction string `json:"expired_action"` // 账号过期处理: "delete"=删除, "refresh"=浏览器刷新, "queue"=排队等待
34 | }
35 |
36 | // WSMessageType WebSocket消息类型
37 | type WSMessageType string
38 |
39 | const (
40 | // Server -> Client
41 | WSMsgTaskRegister WSMessageType = "task_register" // 分配注册任务
42 | WSMsgTaskRefresh WSMessageType = "task_refresh" // 分配Cookie续期任务
43 | WSMsgHeartbeat WSMessageType = "heartbeat" // 心跳
44 | WSMsgStatus WSMessageType = "status" // 状态同步
45 |
46 | // Client -> Server
47 | WSMsgRegisterResult WSMessageType = "register_result" // 注册结果
48 | WSMsgRefreshResult WSMessageType = "refresh_result" // 续期结果
49 | WSMsgHeartbeatAck WSMessageType = "heartbeat_ack" // 心跳响应
50 | WSMsgClientReady WSMessageType = "client_ready" // 客户端就绪
51 | WSMsgRequestTask WSMessageType = "request_task" // 请求任务
52 | )
53 |
54 | // 版本信息
55 | const (
56 | ProtocolVersion = "1.0"
57 | ServerVersion = "2.0.0"
58 | )
59 |
60 | // WSMessage WebSocket消息
61 | type WSMessage struct {
62 | Type WSMessageType `json:"type"`
63 | Version string `json:"version,omitempty"`
64 | Timestamp int64 `json:"timestamp"`
65 | Data map[string]interface{} `json:"data,omitempty"`
66 | }
67 |
68 | // WSClient WebSocket客户端连接
69 | type WSClient struct {
70 | ID string
71 | Conn *websocket.Conn
72 | Server *PoolServer
73 | Send chan []byte
74 | IsAlive bool
75 | LastPing time.Time
76 | MaxThreads int // 客户端最大线程数
77 | ClientVersion string // 客户端版本
78 | mu sync.Mutex
79 | }
80 |
81 | // PoolServer 号池服务器(管理端)
82 | type PoolServer struct {
83 | pool *AccountPool
84 | config PoolServerConfig
85 | clients map[string]*WSClient
86 | clientsMu sync.RWMutex
87 | upgrader websocket.Upgrader
88 |
89 | // 任务队列
90 | registerQueue chan int // 注册任务队列
91 | refreshQueue chan *Account // 续期任务队列
92 |
93 | // 轮询分配
94 | nextClientIdx int // 下一个分配任务的客户端索引
95 | }
96 |
97 | // NewPoolServer 创建号池服务器
98 | func NewPoolServer(pool *AccountPool, config PoolServerConfig) *PoolServer {
99 | return &PoolServer{
100 | pool: pool,
101 | config: config,
102 | clients: make(map[string]*WSClient),
103 | upgrader: websocket.Upgrader{
104 | CheckOrigin: func(r *http.Request) bool { return true },
105 | ReadBufferSize: 1024,
106 | WriteBufferSize: 1024,
107 | },
108 | registerQueue: make(chan int, 100),
109 | refreshQueue: make(chan *Account, 100),
110 | }
111 | }
112 |
113 | // Start 启动号池服务器(独立端口模式,已弃用)
114 | func (ps *PoolServer) Start() error {
115 | mux := http.NewServeMux()
116 |
117 | // 鉴权中间件
118 | authMiddleware := func(next http.HandlerFunc) http.HandlerFunc {
119 | return func(w http.ResponseWriter, r *http.Request) {
120 | if ps.config.Secret != "" {
121 | auth := r.Header.Get("X-Pool-Secret")
122 | if auth != ps.config.Secret {
123 | http.Error(w, "Unauthorized", http.StatusUnauthorized)
124 | return
125 | }
126 | }
127 | next(w, r)
128 | }
129 | }
130 |
131 | // WebSocket端点
132 | mux.HandleFunc("/ws", ps.handleWebSocket)
133 |
134 | // REST API端点
135 | mux.HandleFunc("/pool/next", authMiddleware(ps.handleNext))
136 | mux.HandleFunc("/pool/mark", authMiddleware(ps.handleMark))
137 | mux.HandleFunc("/pool/refresh", authMiddleware(ps.handleRefresh))
138 | mux.HandleFunc("/pool/status", authMiddleware(ps.handleStatus))
139 | mux.HandleFunc("/pool/jwt", authMiddleware(ps.handleGetJWT))
140 |
141 | // 任务分发
142 | mux.HandleFunc("/pool/queue-register", authMiddleware(ps.handleQueueRegister))
143 | mux.HandleFunc("/pool/queue-refresh", authMiddleware(ps.handleQueueRefresh))
144 |
145 | // 接收账号数据(客户端回传)
146 | mux.HandleFunc("/pool/upload-account", authMiddleware(ps.handleUploadAccount))
147 |
148 | // 启动任务分发协程
149 | go ps.taskDispatcher()
150 | // 启动心跳检测
151 | go ps.heartbeatChecker()
152 | return http.ListenAndServe(ps.config.ListenAddr, mux)
153 | }
154 |
155 | // StartBackground 启动后台任务(任务分发和心跳检测)
156 | func (ps *PoolServer) StartBackground() {
157 | go ps.taskDispatcher()
158 | go ps.heartbeatChecker()
159 | }
160 |
161 | // HandleWS 处理 WebSocket 连接(供 gin 路由使用)
162 | func (ps *PoolServer) HandleWS(w http.ResponseWriter, r *http.Request) {
163 | ps.handleWebSocket(w, r)
164 | }
165 |
166 | func (ps *PoolServer) HandleUploadAccount(w http.ResponseWriter, r *http.Request) {
167 | ps.handleUploadAccount(w, r)
168 | }
169 | func (ps *PoolServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
170 | if ps.config.Secret != "" {
171 | secret := r.URL.Query().Get("secret")
172 | if secret != ps.config.Secret {
173 | http.Error(w, "Unauthorized", http.StatusUnauthorized)
174 | return
175 | }
176 | }
177 |
178 | conn, err := ps.upgrader.Upgrade(w, r, nil)
179 | if err != nil {
180 | return
181 | }
182 |
183 | clientID := fmt.Sprintf("client_%d", time.Now().UnixNano())
184 | client := &WSClient{
185 | ID: clientID,
186 | Conn: conn,
187 | Server: ps,
188 | Send: make(chan []byte, 256),
189 | IsAlive: true,
190 | LastPing: time.Now(),
191 | }
192 |
193 | ps.clientsMu.Lock()
194 | ps.clients[clientID] = client
195 | ps.clientsMu.Unlock()
196 |
197 | logger.Info("[WS] 客户端连接: %s (当前: %d)", clientID, len(ps.clients))
198 |
199 | // 启动读写协程
200 | go client.writePump()
201 | go client.readPump()
202 | }
203 |
204 | // writePump 发送消息到客户端
205 | func (c *WSClient) writePump() {
206 | // 缩短心跳间隔到20秒,确保连接保持活跃
207 | ticker := time.NewTicker(20 * time.Second)
208 | defer func() {
209 | ticker.Stop()
210 | c.Conn.Close()
211 | }()
212 |
213 | for {
214 | select {
215 | case message, ok := <-c.Send:
216 | if !ok {
217 | c.Conn.WriteMessage(websocket.CloseMessage, []byte{})
218 | return
219 | }
220 | // 设置写入超时
221 | c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
222 | if err := c.Conn.WriteMessage(websocket.TextMessage, message); err != nil {
223 | return
224 | }
225 | case <-ticker.C:
226 | // 发送心跳
227 | msg := WSMessage{
228 | Type: WSMsgHeartbeat,
229 | Timestamp: time.Now().Unix(),
230 | }
231 | data, _ := json.Marshal(msg)
232 | c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
233 | if err := c.Conn.WriteMessage(websocket.TextMessage, data); err != nil {
234 | logger.Debug("[WS] 发送心跳失败: %s - %v", c.ID, err)
235 | return
236 | }
237 | }
238 | }
239 | }
240 |
241 | // readPump 从客户端读取消息
242 | func (c *WSClient) readPump() {
243 | defer func() {
244 | c.Server.removeClient(c.ID)
245 | c.Conn.Close()
246 | }()
247 |
248 | // 延长读取超时到180秒(3分钟),以适应长时间注册任务
249 | c.Conn.SetReadDeadline(time.Now().Add(180 * time.Second))
250 | c.Conn.SetPongHandler(func(string) error {
251 | c.Conn.SetReadDeadline(time.Now().Add(180 * time.Second))
252 | return nil
253 | })
254 | // 处理客户端的 Ping 消息,自动回复 Pong 并重置超时
255 | c.Conn.SetPingHandler(func(appData string) error {
256 | c.Conn.SetReadDeadline(time.Now().Add(180 * time.Second))
257 | c.mu.Lock()
258 | c.LastPing = time.Now()
259 | c.mu.Unlock()
260 | return c.Conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(10*time.Second))
261 | })
262 |
263 | for {
264 | _, message, err := c.Conn.ReadMessage()
265 | if err != nil {
266 | if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
267 | logger.Debug("[WS] 读取错误: %v", err)
268 | }
269 | break
270 | }
271 |
272 | var msg WSMessage
273 | if err := json.Unmarshal(message, &msg); err != nil {
274 | continue
275 | }
276 |
277 | c.handleMessage(msg)
278 | }
279 | }
280 |
281 | // handleMessage 处理客户端消息
282 | func (c *WSClient) handleMessage(msg WSMessage) {
283 | c.mu.Lock()
284 | c.LastPing = time.Now()
285 | c.mu.Unlock()
286 |
287 | // 收到任何消息都重置读取超时
288 | c.Conn.SetReadDeadline(time.Now().Add(180 * time.Second))
289 |
290 | switch msg.Type {
291 | case WSMsgHeartbeatAck:
292 | logger.Debug("[WS] 收到心跳响应: %s", c.ID)
293 |
294 | case WSMsgClientReady:
295 | if threads, ok := msg.Data["max_threads"].(float64); ok && threads > 0 {
296 | c.MaxThreads = int(threads)
297 | } else {
298 | c.MaxThreads = 1
299 | }
300 | if ver, ok := msg.Data["client_version"].(string); ok {
301 | c.ClientVersion = ver
302 | }
303 | logger.Info("[WS] 客户端 %s 就绪 (v%s, 线程:%d)", c.ID, c.ClientVersion, c.MaxThreads)
304 | c.Server.assignTask(c)
305 |
306 | case WSMsgRequestTask:
307 | logger.Debug("[WS] 客户端 %s 请求任务", c.ID)
308 | c.Server.assignTask(c)
309 |
310 | case WSMsgRegisterResult:
311 | // 注册结果
312 | c.Server.handleRegisterResult(msg.Data)
313 |
314 | case WSMsgRefreshResult:
315 | // 续期结果
316 | c.Server.handleRefreshResult(msg.Data)
317 | }
318 | }
319 |
320 | func (ps *PoolServer) removeClient(clientID string) {
321 | ps.clientsMu.Lock()
322 | defer ps.clientsMu.Unlock()
323 | if client, ok := ps.clients[clientID]; ok {
324 | close(client.Send)
325 | delete(ps.clients, clientID)
326 | logger.Info("[WS] 客户端断开: %s (剩余: %d)", clientID, len(ps.clients))
327 | }
328 | }
329 | func (ps *PoolServer) taskDispatcher() {
330 | for {
331 | select {
332 | case count := <-ps.registerQueue:
333 | // 分发注册任务(轮询分配)
334 | ps.assignTaskRoundRobin(WSMsgTaskRegister, map[string]interface{}{
335 | "count": count,
336 | })
337 |
338 | case acc := <-ps.refreshQueue:
339 | // 分发续期任务(轮询分配)
340 | ps.assignTaskRoundRobin(WSMsgTaskRefresh, map[string]interface{}{
341 | "email": acc.Data.Email,
342 | "cookies": acc.Data.Cookies,
343 | "authorization": acc.Data.Authorization,
344 | "config_id": acc.ConfigID,
345 | "csesidx": acc.CSESIDX,
346 | })
347 | }
348 | }
349 | }
350 |
351 | // assignTaskRoundRobin 轮询分配任务给单个客户端
352 | func (ps *PoolServer) assignTaskRoundRobin(msgType WSMessageType, data map[string]interface{}) bool {
353 | msg := WSMessage{
354 | Type: msgType,
355 | Timestamp: time.Now().Unix(),
356 | Data: data,
357 | }
358 | msgBytes, _ := json.Marshal(msg)
359 |
360 | ps.clientsMu.Lock()
361 | defer ps.clientsMu.Unlock()
362 |
363 | if len(ps.clients) == 0 {
364 | return false
365 | }
366 |
367 | // 获取客户端列表
368 | clientList := make([]*WSClient, 0, len(ps.clients))
369 | for _, client := range ps.clients {
370 | if client.IsAlive {
371 | clientList = append(clientList, client)
372 | }
373 | }
374 |
375 | if len(clientList) == 0 {
376 | return false
377 | }
378 |
379 | // 轮询分配
380 | ps.nextClientIdx = ps.nextClientIdx % len(clientList)
381 | client := clientList[ps.nextClientIdx]
382 | ps.nextClientIdx++
383 |
384 | select {
385 | case client.Send <- msgBytes:
386 | logger.Info("[分配] 任务 %s 分配给 %s", msgType, client.ID)
387 | return true
388 | default:
389 | // 发送队列满,尝试下一个
390 | for i := 0; i < len(clientList)-1; i++ {
391 | ps.nextClientIdx = ps.nextClientIdx % len(clientList)
392 | client = clientList[ps.nextClientIdx]
393 | ps.nextClientIdx++
394 | select {
395 | case client.Send <- msgBytes:
396 | logger.Info("[分配] 任务 %s 分配给 %s", msgType, client.ID)
397 | return true
398 | default:
399 | continue
400 | }
401 | }
402 | }
403 | return false
404 | }
405 | func (ps *PoolServer) assignTask(client *WSClient) {
406 | maxThreads := client.MaxThreads
407 | if maxThreads <= 0 {
408 | maxThreads = 1
409 | }
410 | assignedCount := 0
411 |
412 | // 如果配置了401自动删除,直接删除待续期的401账号,不下发续期任务
413 | if AutoDelete401 {
414 | ps.pool.mu.Lock()
415 | var toDelete []*Account
416 | var remaining []*Account
417 | for _, acc := range ps.pool.pendingAccounts {
418 | if !acc.Refreshed && acc.FailCount > 0 {
419 | // 401账号,标记删除
420 | toDelete = append(toDelete, acc)
421 | } else {
422 | remaining = append(remaining, acc)
423 | }
424 | }
425 | ps.pool.pendingAccounts = remaining
426 | ps.pool.mu.Unlock()
427 |
428 | // 删除401账号文件
429 | for _, acc := range toDelete {
430 | logger.Info("🗑️ [服务端] 401自动删除账号: %s", acc.Data.Email)
431 | ps.pool.RemoveAccount(acc)
432 | }
433 | } else {
434 | // 未配置自动删除,分配续期任务给节点
435 | // 计算401最大重试次数
436 | maxRetry := MaxFailCount * 3
437 | if maxRetry < 10 {
438 | maxRetry = 10
439 | }
440 |
441 | ps.pool.mu.RLock()
442 | var refreshAccounts []*Account
443 | for _, acc := range ps.pool.pendingAccounts {
444 | if !acc.Refreshed && acc.FailCount > 0 {
445 | // 跳过已达上限的账号(浏览器刷新已达上限且401失败次数超过阈值)
446 | if acc.BrowserRefreshCount >= BrowserRefreshMaxRetry && acc.FailCount >= maxRetry {
447 | continue
448 | }
449 | refreshAccounts = append(refreshAccounts, acc)
450 | if len(refreshAccounts) >= maxThreads {
451 | break
452 | }
453 | }
454 | }
455 | ps.pool.mu.RUnlock()
456 | for _, acc := range refreshAccounts {
457 | logger.Info("[WS] 分配续期任务给 %s: %s", client.ID, acc.Data.Email)
458 | msg := WSMessage{
459 | Type: WSMsgTaskRefresh,
460 | Timestamp: time.Now().Unix(),
461 | Data: map[string]interface{}{
462 | "email": acc.Data.Email,
463 | "cookies": acc.Data.Cookies,
464 | "authorization": acc.Data.Authorization,
465 | "config_id": acc.ConfigID,
466 | "csesidx": acc.CSESIDX,
467 | },
468 | }
469 | msgBytes, _ := json.Marshal(msg)
470 | select {
471 | case client.Send <- msgBytes:
472 | assignedCount++
473 | default:
474 | }
475 | }
476 | }
477 | remainingSlots := maxThreads - assignedCount
478 | if remainingSlots > 0 {
479 | currentCount := ps.pool.TotalCount()
480 | targetCount := ps.config.TargetCount
481 | needCount := targetCount - currentCount
482 |
483 | if needCount > 0 {
484 | registerCount := remainingSlots
485 | if registerCount > needCount {
486 | registerCount = needCount
487 | }
488 |
489 | logger.Info("[WS] 分配注册任务给 %s: %d个 (当前: %d, 目标: %d, 线程: %d)",
490 | client.ID, registerCount, currentCount, targetCount, maxThreads)
491 | for i := 0; i < registerCount; i++ {
492 | msg := WSMessage{
493 | Type: WSMsgTaskRegister,
494 | Timestamp: time.Now().Unix(),
495 | Data: map[string]interface{}{
496 | "count": 1,
497 | },
498 | }
499 | msgBytes, _ := json.Marshal(msg)
500 | select {
501 | case client.Send <- msgBytes:
502 | assignedCount++
503 | default:
504 | }
505 | }
506 | }
507 | }
508 |
509 | if assignedCount == 0 {
510 | logger.Debug("[WS] 无任务需要分配给 %s", client.ID)
511 | }
512 | }
513 |
514 | // heartbeatChecker 心跳检测
515 | func (ps *PoolServer) heartbeatChecker() {
516 | ticker := time.NewTicker(60 * time.Second)
517 | defer ticker.Stop()
518 |
519 | for range ticker.C {
520 | ps.clientsMu.RLock()
521 | for id, client := range ps.clients {
522 | client.mu.Lock()
523 | if time.Since(client.LastPing) > 180*time.Second {
524 | client.IsAlive = false
525 | logger.Warn("[WS] 客户端 %s 心跳超时 (last: %v ago)", id, time.Since(client.LastPing))
526 | }
527 | client.mu.Unlock()
528 | }
529 | ps.clientsMu.RUnlock()
530 | }
531 | }
532 |
533 | func (ps *PoolServer) handleRegisterResult(data map[string]interface{}) {
534 | success, _ := data["success"].(bool)
535 | email, _ := data["email"].(string)
536 |
537 | if success {
538 | logger.Info("✅ 注册成功: %s", email)
539 | // 重新加载账号
540 | ps.pool.Load(ps.config.DataDir)
541 | } else {
542 | errMsg, _ := data["error"].(string)
543 | logger.Warn("❌ 注册失败: %s", errMsg)
544 | }
545 | }
546 | func (ps *PoolServer) handleRefreshResult(data map[string]interface{}) {
547 | email, _ := data["email"].(string)
548 | success, _ := data["success"].(bool)
549 |
550 | if success {
551 | logger.Info("✅ 账号续期成功: %s", email)
552 | // 更新账号数据
553 | if cookiesData, ok := data["cookies"]; ok {
554 | ps.updateAccountCookies(email, cookiesData)
555 | }
556 | } else {
557 | errMsg, _ := data["error"].(string)
558 | logger.Warn("❌ 账号续期失败 %s: %s", email, errMsg)
559 | action := ps.config.ExpiredAction
560 | if action == "" {
561 | action = "delete" // 默认删除
562 | }
563 |
564 | switch action {
565 | case "delete":
566 | ps.deleteAccount(email)
567 | case "queue":
568 | // 保持在队列中,不做处理
569 | case "refresh":
570 | default:
571 | ps.deleteAccount(email)
572 | }
573 | }
574 | }
575 |
576 | // deleteAccount 删除账号
577 | func (ps *PoolServer) deleteAccount(email string) {
578 | ps.pool.mu.Lock()
579 | defer ps.pool.mu.Unlock()
580 |
581 | // 从 pending 队列删除
582 | for i, acc := range ps.pool.pendingAccounts {
583 | if acc.Data.Email == email {
584 | // 删除文件
585 | if acc.FilePath != "" {
586 | os.Remove(acc.FilePath)
587 | }
588 | ps.pool.pendingAccounts = append(ps.pool.pendingAccounts[:i], ps.pool.pendingAccounts[i+1:]...)
589 | logger.Info("🗑️ 已删除续期失败账号: %s", email)
590 | return
591 | }
592 | }
593 |
594 | // 从 ready 队列删除
595 | for i, acc := range ps.pool.readyAccounts {
596 | if acc.Data.Email == email {
597 | if acc.FilePath != "" {
598 | os.Remove(acc.FilePath)
599 | }
600 | ps.pool.readyAccounts = append(ps.pool.readyAccounts[:i], ps.pool.readyAccounts[i+1:]...)
601 | logger.Info("🗑️ 已删除续期失败账号: %s", email)
602 | return
603 | }
604 | }
605 | }
606 |
607 | // updateAccountCookies 更新账号Cookie
608 | func (ps *PoolServer) updateAccountCookies(email string, cookiesData interface{}) {
609 | ps.pool.mu.Lock()
610 | defer ps.pool.mu.Unlock()
611 |
612 | for _, acc := range ps.pool.readyAccounts {
613 | if acc.Data.Email == email {
614 | // 更新cookies
615 | if cookies, ok := cookiesData.([]interface{}); ok {
616 | var newCookies []Cookie
617 | for _, c := range cookies {
618 | if cm, ok := c.(map[string]interface{}); ok {
619 | newCookies = append(newCookies, Cookie{
620 | Name: cm["name"].(string),
621 | Value: cm["value"].(string),
622 | Domain: cm["domain"].(string),
623 | })
624 | }
625 | }
626 | acc.Data.Cookies = newCookies
627 | acc.Refreshed = true
628 | acc.FailCount = 0
629 | acc.SaveToFile()
630 | }
631 | return
632 | }
633 | }
634 | }
635 |
636 | // handleQueueRegister 队列注册任务
637 | func (ps *PoolServer) handleQueueRegister(w http.ResponseWriter, r *http.Request) {
638 | var req struct {
639 | Count int `json:"count"`
640 | }
641 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Count <= 0 {
642 | req.Count = 1
643 | }
644 |
645 | select {
646 | case ps.registerQueue <- req.Count:
647 | json.NewEncoder(w).Encode(map[string]interface{}{
648 | "success": true,
649 | "message": fmt.Sprintf("已添加 %d 个注册任务到队列", req.Count),
650 | })
651 | default:
652 | json.NewEncoder(w).Encode(map[string]interface{}{
653 | "success": false,
654 | "error": "任务队列已满",
655 | })
656 | }
657 | }
658 |
659 | // handleQueueRefresh 队列续期任务
660 | func (ps *PoolServer) handleQueueRefresh(w http.ResponseWriter, r *http.Request) {
661 | var req struct {
662 | Email string `json:"email"`
663 | }
664 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
665 | http.Error(w, err.Error(), http.StatusBadRequest)
666 | return
667 | }
668 |
669 | // 查找账号
670 | ps.pool.mu.RLock()
671 | var targetAcc *Account
672 | for _, acc := range ps.pool.readyAccounts {
673 | if acc.Data.Email == req.Email {
674 | targetAcc = acc
675 | break
676 | }
677 | }
678 | ps.pool.mu.RUnlock()
679 |
680 | if targetAcc == nil {
681 | json.NewEncoder(w).Encode(map[string]interface{}{
682 | "success": false,
683 | "error": "账号未找到",
684 | })
685 | return
686 | }
687 |
688 | select {
689 | case ps.refreshQueue <- targetAcc:
690 | json.NewEncoder(w).Encode(map[string]interface{}{
691 | "success": true,
692 | "message": fmt.Sprintf("已添加账号 %s 续期任务到队列", req.Email),
693 | })
694 | default:
695 | json.NewEncoder(w).Encode(map[string]interface{}{
696 | "success": false,
697 | "error": "任务队列已满",
698 | })
699 | }
700 | }
701 |
702 | // AccountResponse 账号响应
703 | type AccountResponse struct {
704 | Success bool `json:"success"`
705 | Email string `json:"email,omitempty"`
706 | JWT string `json:"jwt,omitempty"`
707 | ConfigID string `json:"config_id,omitempty"`
708 | Authorization string `json:"authorization,omitempty"`
709 | Error string `json:"error,omitempty"`
710 | }
711 |
712 | func (ps *PoolServer) handleNext(w http.ResponseWriter, r *http.Request) {
713 | acc := ps.pool.Next()
714 | if acc == nil {
715 | json.NewEncoder(w).Encode(AccountResponse{
716 | Success: false,
717 | Error: "没有可用账号",
718 | })
719 | return
720 | }
721 |
722 | jwt, configID, err := acc.GetJWT()
723 | if err != nil {
724 | json.NewEncoder(w).Encode(AccountResponse{
725 | Success: false,
726 | Email: acc.Data.Email,
727 | Error: err.Error(),
728 | })
729 | return
730 | }
731 |
732 | json.NewEncoder(w).Encode(AccountResponse{
733 | Success: true,
734 | Email: acc.Data.Email,
735 | JWT: jwt,
736 | ConfigID: configID,
737 | Authorization: acc.Data.Authorization,
738 | })
739 | }
740 |
741 | func (ps *PoolServer) handleMark(w http.ResponseWriter, r *http.Request) {
742 | var req struct {
743 | Email string `json:"email"`
744 | Success bool `json:"success"`
745 | }
746 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
747 | http.Error(w, err.Error(), http.StatusBadRequest)
748 | return
749 | }
750 |
751 | // 查找账号并标记
752 | ps.pool.mu.RLock()
753 | var targetAcc *Account
754 | for _, acc := range ps.pool.readyAccounts {
755 | if acc.Data.Email == req.Email {
756 | targetAcc = acc
757 | break
758 | }
759 | }
760 | ps.pool.mu.RUnlock()
761 |
762 | if targetAcc != nil {
763 | ps.pool.MarkUsed(targetAcc, req.Success)
764 | }
765 |
766 | json.NewEncoder(w).Encode(map[string]bool{"success": true})
767 | }
768 |
769 | func (ps *PoolServer) handleRefresh(w http.ResponseWriter, r *http.Request) {
770 | var req struct {
771 | Email string `json:"email"`
772 | }
773 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
774 | http.Error(w, err.Error(), http.StatusBadRequest)
775 | return
776 | }
777 |
778 | // 查找账号并标记需要刷新
779 | ps.pool.mu.RLock()
780 | var targetAcc *Account
781 | for _, acc := range ps.pool.readyAccounts {
782 | if acc.Data.Email == req.Email {
783 | targetAcc = acc
784 | break
785 | }
786 | }
787 | ps.pool.mu.RUnlock()
788 |
789 | if targetAcc != nil {
790 | ps.pool.MarkNeedsRefresh(targetAcc)
791 | }
792 |
793 | json.NewEncoder(w).Encode(map[string]bool{"success": true})
794 | }
795 |
796 | func (ps *PoolServer) handleStatus(w http.ResponseWriter, r *http.Request) {
797 | json.NewEncoder(w).Encode(ps.pool.Stats())
798 | }
799 |
800 | func (ps *PoolServer) handleGetJWT(w http.ResponseWriter, r *http.Request) {
801 | email := r.URL.Query().Get("email")
802 | if email == "" {
803 | http.Error(w, "缺少email参数", http.StatusBadRequest)
804 | return
805 | }
806 |
807 | ps.pool.mu.RLock()
808 | var targetAcc *Account
809 | for _, acc := range ps.pool.readyAccounts {
810 | if acc.Data.Email == email {
811 | targetAcc = acc
812 | break
813 | }
814 | }
815 | ps.pool.mu.RUnlock()
816 |
817 | if targetAcc == nil {
818 | json.NewEncoder(w).Encode(AccountResponse{
819 | Success: false,
820 | Error: "账号未找到",
821 | })
822 | return
823 | }
824 |
825 | jwt, configID, err := targetAcc.GetJWT()
826 | if err != nil {
827 | json.NewEncoder(w).Encode(AccountResponse{
828 | Success: false,
829 | Email: email,
830 | Error: err.Error(),
831 | })
832 | return
833 | }
834 |
835 | json.NewEncoder(w).Encode(AccountResponse{
836 | Success: true,
837 | Email: email,
838 | JWT: jwt,
839 | ConfigID: configID,
840 | Authorization: targetAcc.Data.Authorization,
841 | })
842 | }
843 |
844 | // ==================== 远程号池客户端 ====================
845 |
846 | // RemotePoolClient 远程号池客户端
847 | type RemotePoolClient struct {
848 | serverAddr string
849 | secret string
850 | client *http.Client
851 | mu sync.RWMutex
852 | // 本地缓存
853 | cachedAccounts map[string]*CachedAccount
854 | }
855 |
856 | // CachedAccount 缓存的账号信息
857 | type CachedAccount struct {
858 | Email string
859 | JWT string
860 | ConfigID string
861 | Authorization string
862 | FetchedAt time.Time
863 | }
864 |
865 | // NewRemotePoolClient 创建远程号池客户端
866 | func NewRemotePoolClient(serverAddr, secret string) *RemotePoolClient {
867 | return &RemotePoolClient{
868 | serverAddr: serverAddr,
869 | secret: secret,
870 | client: &http.Client{
871 | Timeout: 30 * time.Second,
872 | },
873 | cachedAccounts: make(map[string]*CachedAccount),
874 | }
875 | }
876 |
877 | // doRequest 发送请求到号池服务器
878 | func (rc *RemotePoolClient) doRequest(method, path string, body interface{}) (*http.Response, error) {
879 | var reqBody io.Reader
880 | if body != nil {
881 | data, err := json.Marshal(body)
882 | if err != nil {
883 | return nil, err
884 | }
885 | reqBody = bytes.NewReader(data)
886 | }
887 |
888 | req, err := http.NewRequest(method, rc.serverAddr+path, reqBody)
889 | if err != nil {
890 | return nil, err
891 | }
892 |
893 | req.Header.Set("Content-Type", "application/json")
894 | if rc.secret != "" {
895 | req.Header.Set("X-Pool-Secret", rc.secret)
896 | }
897 |
898 | return rc.client.Do(req)
899 | }
900 |
901 | // Next 获取下一个可用账号
902 | func (rc *RemotePoolClient) Next() (*CachedAccount, error) {
903 | resp, err := rc.doRequest("GET", "/pool/next", nil)
904 | if err != nil {
905 | return nil, fmt.Errorf("请求号池服务器失败: %w", err)
906 | }
907 | defer resp.Body.Close()
908 |
909 | var result AccountResponse
910 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
911 | return nil, fmt.Errorf("解析响应失败: %w", err)
912 | }
913 |
914 | if !result.Success {
915 | return nil, fmt.Errorf("%s", result.Error)
916 | }
917 |
918 | acc := &CachedAccount{
919 | Email: result.Email,
920 | JWT: result.JWT,
921 | ConfigID: result.ConfigID,
922 | Authorization: result.Authorization,
923 | FetchedAt: time.Now(),
924 | }
925 |
926 | // 缓存账号
927 | rc.mu.Lock()
928 | rc.cachedAccounts[result.Email] = acc
929 | rc.mu.Unlock()
930 |
931 | return acc, nil
932 | }
933 |
934 | // MarkUsed 标记账号使用结果
935 | func (rc *RemotePoolClient) MarkUsed(email string, success bool) error {
936 | resp, err := rc.doRequest("POST", "/pool/mark", map[string]interface{}{
937 | "email": email,
938 | "success": success,
939 | })
940 | if err != nil {
941 | return err
942 | }
943 | resp.Body.Close()
944 | return nil
945 | }
946 |
947 | // MarkNeedsRefresh 标记账号需要刷新
948 | func (rc *RemotePoolClient) MarkNeedsRefresh(email string) error {
949 | resp, err := rc.doRequest("POST", "/pool/refresh", map[string]interface{}{
950 | "email": email,
951 | })
952 | if err != nil {
953 | return err
954 | }
955 | resp.Body.Close()
956 | return nil
957 | }
958 |
959 | // GetStatus 获取号池状态
960 | func (rc *RemotePoolClient) GetStatus() (map[string]interface{}, error) {
961 | resp, err := rc.doRequest("GET", "/pool/status", nil)
962 | if err != nil {
963 | return nil, err
964 | }
965 | defer resp.Body.Close()
966 |
967 | var result map[string]interface{}
968 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
969 | return nil, err
970 | }
971 | return result, nil
972 | }
973 |
974 | // RefreshJWT 刷新指定账号的JWT
975 | func (rc *RemotePoolClient) RefreshJWT(email string) (*CachedAccount, error) {
976 | resp, err := rc.doRequest("GET", "/pool/jwt?email="+email, nil)
977 | if err != nil {
978 | return nil, err
979 | }
980 | defer resp.Body.Close()
981 |
982 | var result AccountResponse
983 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
984 | return nil, err
985 | }
986 |
987 | if !result.Success {
988 | return nil, fmt.Errorf("%s", result.Error)
989 | }
990 |
991 | acc := &CachedAccount{
992 | Email: result.Email,
993 | JWT: result.JWT,
994 | ConfigID: result.ConfigID,
995 | Authorization: result.Authorization,
996 | FetchedAt: time.Now(),
997 | }
998 |
999 | rc.mu.Lock()
1000 | rc.cachedAccounts[email] = acc
1001 | rc.mu.Unlock()
1002 |
1003 | return acc, nil
1004 | }
1005 |
1006 | type AccountUploadRequest struct {
1007 | Email string `json:"email"`
1008 | FullName string `json:"full_name"`
1009 | Cookies []Cookie `json:"cookies"`
1010 | CookieString string `json:"cookie_string"`
1011 | Authorization string `json:"authorization"`
1012 | ConfigID string `json:"config_id"`
1013 | CSESIDX string `json:"csesidx"`
1014 | IsNew bool `json:"is_new"`
1015 | }
1016 |
1017 | // handleUploadAccount 处理账号上传(客户端回传鉴权文件)
1018 | func (ps *PoolServer) handleUploadAccount(w http.ResponseWriter, r *http.Request) {
1019 | if r.Method != http.MethodPost {
1020 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
1021 | return
1022 | }
1023 |
1024 | var req AccountUploadRequest
1025 | if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
1026 | logger.Error("解析账号上传请求失败: %v", err)
1027 | json.NewEncoder(w).Encode(map[string]interface{}{
1028 | "success": false,
1029 | "error": "无效的请求格式",
1030 | })
1031 | return
1032 | }
1033 |
1034 | if req.Email == "" {
1035 | json.NewEncoder(w).Encode(map[string]interface{}{
1036 | "success": false,
1037 | "error": "邮箱不能为空",
1038 | })
1039 | return
1040 | }
1041 |
1042 | // 构建账号数据
1043 | accData := AccountData{
1044 | Email: req.Email,
1045 | FullName: req.FullName,
1046 | Cookies: req.Cookies,
1047 | CookieString: req.CookieString,
1048 | Authorization: req.Authorization,
1049 | ConfigID: req.ConfigID,
1050 | CSESIDX: req.CSESIDX,
1051 | Timestamp: time.Now().Format(time.RFC3339),
1052 | }
1053 |
1054 | // 保存到文件
1055 | dataDir := ps.config.DataDir
1056 | if dataDir == "" {
1057 | dataDir = "./data"
1058 | }
1059 |
1060 | // 确保目录存在
1061 | if err := os.MkdirAll(dataDir, 0755); err != nil {
1062 | logger.Error("创建数据目录失败: %v", err)
1063 | json.NewEncoder(w).Encode(map[string]interface{}{
1064 | "success": false,
1065 | "error": "服务器内部错误",
1066 | })
1067 | return
1068 | }
1069 |
1070 | // 生成文件名
1071 | filename := fmt.Sprintf("%s.json", req.Email)
1072 | filePath := filepath.Join(dataDir, filename)
1073 |
1074 | // 序列化并保存
1075 | data, err := json.MarshalIndent(accData, "", " ")
1076 | if err != nil {
1077 | logger.Error("序列化账号数据失败: %v", err)
1078 | json.NewEncoder(w).Encode(map[string]interface{}{
1079 | "success": false,
1080 | "error": "序列化失败",
1081 | })
1082 | return
1083 | }
1084 |
1085 | if err := os.WriteFile(filePath, data, 0644); err != nil {
1086 | logger.Error("保存账号文件失败: %v", err)
1087 | json.NewEncoder(w).Encode(map[string]interface{}{
1088 | "success": false,
1089 | "error": "保存失败",
1090 | })
1091 | return
1092 | }
1093 |
1094 | if req.IsNew {
1095 | logger.Info("✅ 收到新注册账号: %s", req.Email)
1096 | } else {
1097 | logger.Info("✅ 收到账号续期数据: %s", req.Email)
1098 | }
1099 |
1100 | // 先加载文件确保账号存在
1101 | ps.pool.Load(dataDir)
1102 |
1103 | // 更新内存中的账号数据
1104 | ps.pool.mu.Lock()
1105 | found := false
1106 |
1107 | // 查找并更新 pending 队列
1108 | for i, acc := range ps.pool.pendingAccounts {
1109 | if acc.Data.Email == req.Email {
1110 | acc.Data.Cookies = req.Cookies
1111 | acc.Data.CookieString = req.CookieString
1112 | acc.Data.Authorization = req.Authorization
1113 | acc.Data.ConfigID = req.ConfigID
1114 | acc.Data.CSESIDX = req.CSESIDX
1115 | acc.ConfigID = req.ConfigID
1116 | acc.CSESIDX = req.CSESIDX
1117 | acc.Refreshed = true
1118 | acc.FailCount = 0
1119 | acc.BrowserRefreshCount = 0
1120 | acc.LastRefresh = time.Now()
1121 | acc.JWTExpires = time.Time{}
1122 | // 从 pending 移除
1123 | ps.pool.pendingAccounts = append(ps.pool.pendingAccounts[:i], ps.pool.pendingAccounts[i+1:]...)
1124 | ps.pool.mu.Unlock()
1125 | // 加入 ready 队列
1126 | ps.pool.MarkReady(acc)
1127 | found = true
1128 | goto respond
1129 | }
1130 | }
1131 |
1132 | // 查找并更新 ready 队列
1133 | for _, acc := range ps.pool.readyAccounts {
1134 | if acc.Data.Email == req.Email {
1135 | acc.Mu.Lock()
1136 | acc.Data.Cookies = req.Cookies
1137 | acc.Data.CookieString = req.CookieString
1138 | acc.Data.Authorization = req.Authorization
1139 | acc.Data.ConfigID = req.ConfigID
1140 | acc.Data.CSESIDX = req.CSESIDX
1141 | acc.ConfigID = req.ConfigID
1142 | acc.CSESIDX = req.CSESIDX
1143 | acc.FailCount = 0
1144 | acc.BrowserRefreshCount = 0
1145 | acc.LastRefresh = time.Now()
1146 | acc.JWTExpires = time.Time{}
1147 | acc.Mu.Unlock()
1148 | found = true
1149 | break
1150 | }
1151 | }
1152 | ps.pool.mu.Unlock()
1153 |
1154 | if !found {
1155 | logger.Warn("⚠️ [%s] 账号已保存但未在内存中找到", req.Email)
1156 | }
1157 |
1158 | respond:
1159 |
1160 | json.NewEncoder(w).Encode(map[string]interface{}{
1161 | "success": true,
1162 | "message": fmt.Sprintf("账号 %s 已保存", req.Email),
1163 | })
1164 | }
1165 |
1166 | func (rc *RemotePoolClient) UploadAccount(acc *AccountUploadRequest) error {
1167 | data, err := json.Marshal(acc)
1168 | if err != nil {
1169 | return err
1170 | }
1171 |
1172 | resp, err := rc.doRequest("POST", "/pool/upload-account", bytes.NewReader(data))
1173 | if err != nil {
1174 | return err
1175 | }
1176 | defer resp.Body.Close()
1177 |
1178 | var result map[string]interface{}
1179 | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
1180 | return err
1181 | }
1182 |
1183 | if success, ok := result["success"].(bool); !ok || !success {
1184 | errMsg, _ := result["error"].(string)
1185 | return fmt.Errorf("上传失败: %s", errMsg)
1186 | }
1187 | return nil
1188 | }
1189 |
1190 | type ClientInfo struct {
1191 | ID string `json:"id"`
1192 | Version string `json:"version"`
1193 | Threads int `json:"threads"`
1194 | IsAlive bool `json:"is_alive"`
1195 | LastPing int64 `json:"last_ping"`
1196 | }
1197 |
1198 | func (ps *PoolServer) GetClientsInfo() []ClientInfo {
1199 | ps.clientsMu.RLock()
1200 | defer ps.clientsMu.RUnlock()
1201 |
1202 | clients := make([]ClientInfo, 0, len(ps.clients))
1203 | for id, c := range ps.clients {
1204 | clients = append(clients, ClientInfo{
1205 | ID: id,
1206 | Version: c.ClientVersion,
1207 | Threads: c.MaxThreads,
1208 | IsAlive: c.IsAlive,
1209 | LastPing: c.LastPing.Unix(),
1210 | })
1211 | }
1212 | return clients
1213 | }
1214 | func (ps *PoolServer) GetClientCount() int {
1215 | ps.clientsMu.RLock()
1216 | defer ps.clientsMu.RUnlock()
1217 | return len(ps.clients)
1218 | }
1219 |
1220 | func (ps *PoolServer) GetTotalThreads() int {
1221 | ps.clientsMu.RLock()
1222 | defer ps.clientsMu.RUnlock()
1223 | total := 0
1224 | for _, c := range ps.clients {
1225 | total += c.MaxThreads
1226 | }
1227 | return total
1228 | }
1229 |
--------------------------------------------------------------------------------