a3216 commited on
Commit
8bab601
·
1 Parent(s): 6d61ecf

feat(hf): 部署 WorkBuddy2API Panel 到 HF Spaces(存储桶持久化)

Browse files

替换原本的 gcli2api 应用;原内容完整保留在 tag backup-gcli2api-legacy-20261003。

- Dockerfile: HF Spaces 形态(root 运行、监听 7861、入口脚本驱动)
- docker-entrypoint.sh: 卷探测 + 写/rename 自检 + api_key 为空时拒绝启动
- config.default.json: 镜像内模板(不含 api_key,公开仓库不放密钥)
- 持久化布局: /app/storage/{config.json,auths/,data/} 全部由私有 storage bucket 卷提供
- README.md: HF 卡片元数据 + 部署与运维说明

This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ auths/
2
+ data/
3
+ storage/
4
+ *.tmp
5
+ .git/
6
+ .github/
7
+ .gitignore
8
+ docs/
9
+ scripts/__pycache__/
10
+ # 根目录已提交/未提交的二进制产物不进构建上下文(与 .gitignore 对齐)
11
+ /login
12
+ /credit
13
+ /wb2api
14
+ /signin_bin
15
+ /wb2api.exe
16
+ /login.exe
17
+ /credit.exe
18
+ # 真实配置不进镜像;镜像内模板是 config.default.json
19
+ config.json
20
+ PLAN.md
21
+ SPEC.md
22
+ LOOP.md
23
+ README.md
24
+ README.upstream.md
.env.example DELETED
@@ -1,179 +0,0 @@
1
- # ================================================================
2
- # GCLI2API 环境变量配置示例文件
3
- # 复制此文件为 .env 并根据需要修改配置值
4
- # ================================================================
5
-
6
- # ================================================================
7
- # 服务器配置
8
- # ================================================================
9
-
10
- # 服务器监听地址
11
- # 默认: 0.0.0.0 (监听所有网络接口)
12
- HOST=0.0.0.0
13
-
14
- # 服务器端口
15
- # 默认: 7861
16
- PORT=7861
17
-
18
- # ================================================================
19
- # 密码配置 (支持分离密码)
20
- # ================================================================
21
-
22
- # 聊天API访问密码 (用于OpenAI和Gemini API端点认证)
23
- # 默认: 继承通用密码或 pwd
24
- API_PASSWORD=your_api_password
25
-
26
- # 控制面板访问密码 (用于Web界面登录认证)
27
- # 默认: 继承通用密码或 pwd
28
- PANEL_PASSWORD=your_panel_password
29
-
30
- # 通用访问密码 (兼容性保留)
31
- # 设置后会覆盖上述两个专用密码,优先级最高
32
- # 如果只想使用一个密码,设置此项即可
33
- # 默认: pwd
34
- PASSWORD=pwd
35
-
36
- # ================================================================
37
- # 存储配置
38
- # ================================================================
39
-
40
- # 存储后端优先级: PostgreSQL > MongoDB > 本地sqlite文件存储
41
- # 系统会自动选择可用的最高优先级存储后端
42
-
43
- # PostgreSQL 分布式存储模式配置 (最高优先级)
44
- # 设置 POSTGRESQL_URI 后自动启用 PostgreSQL 模式
45
- # 本地 PostgreSQL: postgresql://user:password@localhost:5432/gcli2api
46
- # 带 SSL: postgresql://user:password@host:5432/gcli2api?sslmode=require
47
- # 默认: 无 (不启用 PostgreSQL 存储)
48
- POSTGRESQL_URI=postgresql://user:password@localhost:5432/gcli2api
49
-
50
- # MongoDB 分布式存储模式配置 (第二优先级)
51
- # 设置 MONGODB_URI 后自动启用 MongoDB 模式,不再使用本地文件存储
52
-
53
- # Redis 缓存存储配置
54
- # 设置 REDIS_URL 后自动启用 Redis 模式,性能最佳,可大幅降低 MongoDB 的读写压力
55
- # 本地 Redis: redis://127.0.0.1:6379/0
56
- # 带密码: redis://:password@127.0.0.1:6379/0
57
- # 默认: 无 (不启用 Redis 缓存)
58
- REDIS_URL=redis://127.0.0.1:6379/0
59
-
60
- # MongoDB 连接字符串 (设置后启用 MongoDB 分布式存储模式)
61
- # 本地 MongoDB: mongodb://localhost:27017
62
- # 带认证: mongodb://admin:password@localhost:27017/admin
63
- # MongoDB Atlas: mongodb+srv://username:password@cluster.mongodb.net
64
- # 副本集: mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=rs0
65
- # 默认: 无 (使用本地文件存储)
66
- MONGODB_URI=mongodb://localhost:27017
67
-
68
- # MongoDB 数据库名称 (仅在启用 MongoDB 模式时有效)
69
- # 默认: gcli2api
70
- MONGODB_DATABASE=gcli2api
71
-
72
- # ================================================================
73
- # Google API 配置
74
- # ================================================================
75
-
76
- # 凭证文件目录 (仅在文件存储模式下使用)
77
- # 默认: ./creds
78
- CREDENTIALS_DIR=./creds
79
-
80
-
81
- # 代理配置 (可选)
82
- # 支持 http, https, socks5 代理
83
- # 格式: http://proxy:port, https://proxy:port, socks5://proxy:port
84
- PROXY=http://localhost:7890
85
-
86
- # Google API 代理 URL 配置 (可选)
87
-
88
- # Google Code Assist API 端点
89
- # 默认: https://cloudcode-pa.googleapis.com
90
- CODE_ASSIST_ENDPOINT=https://cloudcode-pa.googleapis.com
91
- # 用于Google OAuth2认证的代理URL
92
- # 默认: https://oauth2.googleapis.com
93
- OAUTH_PROXY_URL=https://oauth2.googleapis.com
94
-
95
- # 用于Google APIs调用的代理URL
96
- # 默认: https://www.googleapis.com
97
- GOOGLEAPIS_PROXY_URL=https://www.googleapis.com
98
-
99
- # 用于Google Cloud Resource Manager API的URL
100
- # 默认: https://cloudresourcemanager.googleapis.com
101
- RESOURCE_MANAGER_API_URL=https://cloudresourcemanager.googleapis.com
102
-
103
- # 用于Google Cloud Service Usage API的URL
104
- # 默认: https://serviceusage.googleapis.com
105
- SERVICE_USAGE_API_URL=https://serviceusage.googleapis.com
106
-
107
- # 用于Google Antigravity API的URL (反重力模式)
108
- # 默认: https://daily-cloudcode-pa.googleapis.com
109
- ANTIGRAVITY_API_URL=https://daily-cloudcode-pa.googleapis.com
110
-
111
- # ================================================================
112
- # 错误处理和重试配置
113
- # ================================================================
114
-
115
- # 是否启用自动封禁功能
116
- # 当凭证返回特定错误码时自动禁用该凭证
117
- # 默认: false
118
- AUTO_BAN=false
119
-
120
- # 自动封禁的错误码列表 (逗号分隔)
121
- # 默认: 400,403
122
- AUTO_BAN_ERROR_CODES=403
123
-
124
- # 是否启用 429 错误重试
125
- # 默认: true
126
- RETRY_429_ENABLED=true
127
-
128
- # 429 错误最大重试次数
129
- # 默认: 5
130
- RETRY_429_MAX_RETRIES=5
131
-
132
- # 429 错误重试间隔 (秒)
133
- # 默认: 1
134
- RETRY_429_INTERVAL=1
135
-
136
- # ================================================================
137
- # 日志配置
138
- # ================================================================
139
-
140
- # 日志级别
141
- # 可选值: debug, info, warning, error, critical
142
- # 默认: info
143
- LOG_LEVEL=info
144
-
145
- # 日志文件路径
146
- # 默认: log.txt
147
- LOG_FILE=log.txt
148
-
149
- # ================================================================
150
- # 高级功能配置
151
- # ================================================================
152
-
153
- # 流式抗截断最大尝试次数
154
- # 用于 "流式抗截断/" 前缀的模型
155
- # 默认: 3
156
- ANTI_TRUNCATION_MAX_ATTEMPTS=3
157
-
158
- # ================================================================
159
- # 环境变量使用说明
160
- # ================================================================
161
-
162
- # 1. 存储模式配置 (按优先级自动选择):
163
- # - PostgreSQL 分布式模式 (最高优先级): 设置 POSTGRESQL_URI,数据存储在 PostgreSQL 数据库
164
- # - Redis 缓存: 同时设置 REDIS_URI和 MONGODB_URI时,数据缓存在 Redis 数据库,持久化在MONGODB,性能最佳
165
- # - MongoDB 分布式模式: 设置 MONGODB_URI,数据存储在 MongoDB 数据库
166
- # - 文件存储模式 (默认): 不设置上述 URI,数据存储在本地 creds/ 目录
167
- # - 自动切换: 系统根据可用的存储配置自动选择最高优先级的存储后端
168
-
169
- # 2. 密码配置优先级:
170
- # a) PASSWORD 环境变量 (最高优先级,设置后覆盖其他密码)
171
- # b) API_PASSWORD / PANEL_PASSWORD 环境变量 (专用密码)
172
- # c) 默认值 "pwd"
173
- #
174
- # 3. 通用配置优先级:
175
- # 环境变量 > 默认值
176
-
177
- # 4. 布尔值环境变量:
178
- # true/1/yes/on 表示启用
179
- # false/0/no/off 表示禁用
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/ISSUE_TEMPLATE/bug_report.yml DELETED
@@ -1,92 +0,0 @@
1
- name: Bug 报告
2
- description: 报告项目使用中遇到的问题
3
- title: "[Bug]: "
4
- labels: ["bug", "待处理"]
5
- body:
6
- - type: markdown
7
- attributes:
8
- value: |
9
- ## 感谢你的反馈!
10
- 请填写以下信息以帮助我们更快定位问题。
11
-
12
- - type: checkboxes
13
- id: checklist
14
- attributes:
15
- label: 提交前确认
16
- options:
17
- - label: 我已经搜索过现有的 issues,确认这不是重复问题
18
- required: true
19
- - label: 我已经阅读过项目文档
20
- required: true
21
-
22
- - type: dropdown
23
- id: latest-version
24
- attributes:
25
- label: 是否是最新版
26
- description: 请确认你使用的是否是最新版本
27
- options:
28
- - 是,使用最新版
29
- - 否,使用旧版本
30
- validations:
31
- required: true
32
-
33
- - type: input
34
- id: channel
35
- attributes:
36
- label: 调用的是哪个渠道
37
- description: 例如 geminicli 或者 antigravity
38
- placeholder: "例如: geminicli"
39
- validations:
40
- required: true
41
-
42
- - type: input
43
- id: model
44
- attributes:
45
- label: 调用的是哪个模型
46
- description: 例如 gemini-2.5-flash
47
- placeholder: "例如: gemini-2.5-flash"
48
- validations:
49
- required: true
50
-
51
- - type: dropdown
52
- id: format
53
- attributes:
54
- label: 调用的是哪个格式
55
- description: 选择你使用的 API 格式
56
- options:
57
- - gemini 格式
58
- - openai 格式
59
- - claude 格式
60
- - 其他格式
61
- validations:
62
- required: true
63
-
64
- - type: textarea
65
- id: error-content
66
- attributes:
67
- label: 具体报错内容
68
- description: 请粘贴完整的错误信息或截图
69
- placeholder: |
70
- 请在这里粘贴完整的错误日志或堆栈信息
71
- render: shell
72
- validations:
73
- required: true
74
-
75
- - type: textarea
76
- id: error-description
77
- attributes:
78
- label: 错误描述
79
- description: 详细描述问题的发生场景、预期行为和实际行为
80
- placeholder: |
81
- 1. 我在做什么操作时遇到了这个问题
82
- 2. 我期望的结果是...
83
- 3. 但实际上发生了...
84
- validations:
85
- required: true
86
-
87
- - type: textarea
88
- id: additional-context
89
- attributes:
90
- label: 补充信息(可选)
91
- description: 其他任何有助于解决问题的信息
92
- placeholder: 例如:操作系统、Python 版本、相关配置等
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/ISSUE_TEMPLATE/config.yml DELETED
@@ -1,8 +0,0 @@
1
- blank_issues_enabled: false
2
- contact_links:
3
- - name: 使用问题讨论
4
- url: https://github.com/su-kaka/gcli2api/issues
5
- about: 如果是使用方面的问题,请在 issues 中提问
6
- - name: 项目文档
7
- url: https://github.com/su-kaka/gcli2api
8
- about: 查看完整文档和使用指南
 
 
 
 
 
 
 
 
 
.github/workflows/docker-publish.yml DELETED
@@ -1,81 +0,0 @@
1
- name: Docker Build and Publish
2
-
3
- on:
4
- workflow_run:
5
- workflows: ["Update Version File"]
6
- types:
7
- - completed
8
- branches:
9
- - master
10
- - main
11
- push:
12
- tags:
13
- - 'v*'
14
- pull_request:
15
- branches:
16
- - master
17
- - main
18
- workflow_dispatch:
19
-
20
- env:
21
- REGISTRY: ghcr.io
22
- IMAGE_NAME: ${{ github.repository }}
23
-
24
- jobs:
25
- build-and-push:
26
- runs-on: ubuntu-latest
27
- # 只在 workflow_run 成功时运行,或者非 workflow_run 触发时运行
28
- if: ${{ github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' }}
29
- permissions:
30
- contents: read
31
- packages: write
32
-
33
- steps:
34
- - name: Checkout repository
35
- uses: actions/checkout@v4
36
- with:
37
- # workflow_run 触发时需要获取最新的代码(包括 version.txt 的更新)
38
- ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref }}
39
-
40
- - name: Set up QEMU
41
- uses: docker/setup-qemu-action@v3
42
-
43
- - name: Set up Docker Buildx
44
- uses: docker/setup-buildx-action@v3
45
-
46
- - name: Log in to GitHub Container Registry
47
- uses: docker/login-action@v3
48
- with:
49
- registry: ${{ env.REGISTRY }}
50
- username: ${{ github.actor }}
51
- password: ${{ secrets.GITHUB_TOKEN }}
52
-
53
- - name: Extract metadata
54
- id: meta
55
- uses: docker/metadata-action@v5
56
- with:
57
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
58
- tags: |
59
- type=ref,event=branch
60
- type=ref,event=tag
61
- type=ref,event=pr
62
- type=raw,value=latest,enable={{is_default_branch}}
63
- type=sha,prefix={{branch}}-
64
- type=semver,pattern={{version}}
65
- type=semver,pattern={{major}}.{{minor}}
66
- type=semver,pattern={{major}}
67
-
68
- - name: Build and push Docker image
69
- uses: docker/build-push-action@v5
70
- with:
71
- context: .
72
- platforms: linux/amd64,linux/arm64
73
- push: ${{ github.event_name != 'pull_request' }}
74
- tags: ${{ steps.meta.outputs.tags }}
75
- labels: ${{ steps.meta.outputs.labels }}
76
- cache-from: type=gha
77
- cache-to: type=gha,mode=max
78
- build-args: |
79
- BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
80
- VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
81
- REVISION=${{ github.sha }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.github/workflows/update-version.yml DELETED
@@ -1,51 +0,0 @@
1
- name: Update Version File
2
-
3
- on:
4
- push:
5
- branches:
6
- - master
7
- - main
8
-
9
- jobs:
10
- update-version:
11
- runs-on: ubuntu-latest
12
- permissions:
13
- contents: write
14
-
15
- steps:
16
- - name: Checkout repository
17
- uses: actions/checkout@v4
18
- with:
19
- fetch-depth: 0
20
- token: ${{ secrets.GITHUB_TOKEN }}
21
-
22
- - name: Update version.txt
23
- run: |
24
- # 获取最新commit信息
25
- FULL_HASH=$(git log -1 --format=%H)
26
- SHORT_HASH=$(git log -1 --format=%h)
27
- MESSAGE=$(git log -1 --format=%s)
28
- DATE=$(git log -1 --format=%ci)
29
-
30
- # 写入version.txt
31
- echo "full_hash=$FULL_HASH" > version.txt
32
- echo "short_hash=$SHORT_HASH" >> version.txt
33
- echo "message=$MESSAGE" >> version.txt
34
- echo "date=$DATE" >> version.txt
35
-
36
- echo "Version file updated:"
37
- cat version.txt
38
-
39
- - name: Commit version.txt if changed
40
- run: |
41
- git config --local user.email "github-actions[bot]@users.noreply.github.com"
42
- git config --local user.name "github-actions[bot]"
43
-
44
- # 检查是否有变化
45
- if git diff --quiet version.txt; then
46
- echo "No changes to version.txt"
47
- else
48
- git add version.txt
49
- git commit -m "chore: update version.txt [skip ci]"
50
- git push
51
- fi
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -1,99 +1,46 @@
1
- # Credential files - should never be committed
2
- *.json
3
- !package.json
4
- !package-lock.json
5
- !tsconfig.json
6
- *.toml
7
- !pyproject.toml
8
- creds/
9
- CLAUDE.md
10
- GEMINI.md
11
- .kiro
12
- # Environment configuration
13
  .env
 
14
 
15
- # Python
16
- uv.lock
17
- .python-version
18
- __pycache__/
19
- *.py[cod]
20
- *$py.class
21
- *.so
22
- .Python
23
- build/
24
- develop-eggs/
25
- dist/
26
- downloads/
27
- eggs/
28
- .eggs/
29
- lib/
30
- lib64/
31
- parts/
32
- sdist/
33
- var/
34
- wheels/
35
- pip-wheel-metadata/
36
- share/python-wheels/
37
- *.egg-info/
38
- .installed.cfg
39
- *.egg
40
- MANIFEST
41
-
42
- # PyInstaller
43
- *.manifest
44
- *.spec
45
 
46
- # Installer logs
47
- pip-log.txt
48
- pip-delete-this-directory.txt
49
 
50
- # Unit test / coverage reports
51
- htmlcov/
52
- .tox/
53
- .nox/
54
- .coverage
55
- .coverage.*
56
- .cache
57
- nosetests.xml
58
- coverage.xml
59
- *.cover
60
- *.py,cover
61
- .hypothesis/
62
- .pytest_cache/
63
 
64
- # Virtual environments
65
- .env
66
- .venv
67
- env/
68
- venv/
69
- ENV/
70
- env.bak/
71
- venv.bak/
72
 
73
  # IDE
74
- .vscode/
75
  .idea/
76
- .claude/
77
- *.swp
78
- *.swo
79
- *~
80
-
81
- # OS
82
- .DS_Store
83
- .DS_Store?
84
- ._*
85
- .Spotlight-V100
86
- .Trashes
87
- ehthumbs.db
88
- Thumbs.db
89
-
90
- # Logs
91
- *.log
92
- log.txt
93
-
94
- # Temporary files
95
- *.tmp
96
- *.temp
97
- *.bak
98
-
99
- tools/
 
1
+ # Auth & secrets
2
+ auths/
3
+ data/
4
+ *.key
5
+ *.pem
6
+ *.env
 
 
 
 
 
 
7
  .env
8
+ .env.*
9
 
10
+ # Binaries (anchored to repo root only — bare "login"/"credit" would also ignore cmd/login/, cmd/credit/)
11
+ /login
12
+ /credit
13
+ /wb2api
14
+ /signin_bin
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ # Config with secrets (use config.example.json)
17
+ config.json
 
18
 
19
+ # Build artifacts
20
+ *.tmp
21
+ __pycache__/
 
 
 
 
 
 
 
 
 
 
22
 
23
+ # Backups (contains auth tokens)
24
+ backups/
 
 
 
 
 
 
25
 
26
  # IDE
 
27
  .idea/
28
+ .vscode/
29
+ LOOP.md
30
+ PLAN.md
31
+ SPEC.md
32
+ docs/
33
+
34
+ # 设计/计划文档(但 README.md 必须进版本库)
35
+ *.md
36
+ !README.md
37
+
38
+ # Windows binaries
39
+ /wb2api.exe
40
+ /login.exe
41
+ /credit.exe
42
+
43
+ # 本地验证工具(判据逆向过程脚本,结论沉淀于 data/desktop-task-protocol.md)
44
+ /cmd/desktopverify*/
45
+ *.zip
46
+ server.exe
 
 
 
 
 
CONTRIBUTING.md DELETED
@@ -1,169 +0,0 @@
1
- # Contributing to gcli2api
2
-
3
- First off, thank you for considering contributing to gcli2api! It's people like you that make gcli2api such a great tool.
4
-
5
- ## Code of Conduct
6
-
7
- This project is intended for personal learning and research purposes only. By participating, you are expected to uphold this code and respect the CNC-1.0 license restrictions on commercial use.
8
-
9
- ## How Can I Contribute?
10
-
11
- ### Reporting Bugs
12
-
13
- Before creating bug reports, please check the existing issues to avoid duplicates. When you create a bug report, include as many details as possible:
14
-
15
- * **Use a clear and descriptive title**
16
- * **Describe the exact steps to reproduce the problem**
17
- * **Provide specific examples** - Include code snippets, configuration files, or log outputs
18
- * **Describe the behavior you observed** and what you expected to see
19
- * **Include environment details**: OS, Python version, Docker version (if applicable)
20
-
21
- ### Suggesting Enhancements
22
-
23
- Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, include:
24
-
25
- * **Use a clear and descriptive title**
26
- * **Provide a detailed description** of the suggested enhancement
27
- * **Explain why this enhancement would be useful**
28
- * **List any alternative solutions** you've considered
29
-
30
- ### Pull Requests
31
-
32
- 1. Fork the repo and create your branch from `master`
33
- 2. If you've added code that should be tested, add tests
34
- 3. If you've changed APIs, update the documentation
35
- 4. Ensure the test suite passes
36
- 5. Make sure your code follows the existing style
37
- 6. Write a clear commit message
38
-
39
- ## Development Setup
40
-
41
- ### Prerequisites
42
-
43
- * Python 3.12 or higher
44
- * pip or uv package manager
45
-
46
- ### Setting Up Your Development Environment
47
-
48
- ```bash
49
- # Clone your fork
50
- git clone https://github.com/YOUR_USERNAME/gcli2api.git
51
- cd gcli2api
52
-
53
- # Install development dependencies
54
- make install-dev
55
- # or
56
- pip install -e ".[dev]"
57
-
58
- # Copy environment example
59
- cp .env.example .env
60
- # Edit .env with your configuration
61
- ```
62
-
63
- ### Development Workflow
64
-
65
- ```bash
66
- # Run tests
67
- make test
68
-
69
- # Format code
70
- make format
71
-
72
- # Run linters
73
- make lint
74
-
75
- # Run the application locally
76
- make run
77
- ```
78
-
79
- ### Testing
80
-
81
- We use pytest for testing. All new features should include appropriate tests.
82
-
83
- ```bash
84
- # Run all tests
85
- make test
86
-
87
- # Run with coverage
88
- make test-cov
89
-
90
- # Run specific test file
91
- python -m pytest test_tool_calling.py -v
92
- ```
93
-
94
- ### Code Style
95
-
96
- * We use [Black](https://black.readthedocs.io/) for code formatting (line length: 100)
97
- * We use [flake8](https://flake8.pycqa.org/) for linting
98
- * We use [mypy](http://mypy-lang.org/) for type checking (optional, but encouraged)
99
-
100
- ```bash
101
- # Format your code before committing
102
- make format
103
-
104
- # Check if code is properly formatted
105
- make format-check
106
-
107
- # Run linters
108
- make lint
109
- ```
110
-
111
- ## Project Structure
112
-
113
- ```
114
- gcli2api/
115
- ├── src/ # Main source code
116
- │ ├── auth.py # Authentication and OAuth
117
- │ ├── credential_manager.py # Credential rotation
118
- │ ├── openai_router.py # OpenAI-compatible endpoints
119
- │ ├── gemini_router.py # Gemini native endpoints
120
- │ ├── openai_transfer.py # Format conversion
121
- │ ├── storage/ # Storage backends (Redis, MongoDB, Postgres, File)
122
- │ └── ...
123
- ├── front/ # Frontend static files
124
- ├── tests/ # Test directory (to be created)
125
- ├── test_*.py # Test files (root level)
126
- ├── web.py # Main application entry point
127
- ├── config.py # Configuration management
128
- └── requirements.txt # Production dependencies
129
- ```
130
-
131
- ## Coding Guidelines
132
-
133
- ### Python Style
134
-
135
- * Follow PEP 8 guidelines
136
- * Use type hints where appropriate
137
- * Write docstrings for classes and functions
138
- * Keep functions focused and concise
139
-
140
- ### Commit Messages
141
-
142
- * Use the present tense ("Add feature" not "Added feature")
143
- * Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
144
- * Limit the first line to 72 characters or less
145
- * Reference issues and pull requests liberally after the first line
146
-
147
- ### Documentation
148
-
149
- * Update the README.md if you change functionality
150
- * Comment your code where necessary
151
- * Update the .env.example if you add new configuration options
152
-
153
- ## License
154
-
155
- By contributing to gcli2api, you agree that your contributions will be licensed under the CNC-1.0 license. This is a strict anti-commercial license - see [LICENSE](LICENSE) for details.
156
-
157
- ### Important License Restrictions
158
-
159
- * ❌ No commercial use
160
- * ❌ No use by companies with revenue > $1M USD
161
- * ❌ No use by VC-backed or publicly traded companies
162
- * ✅ Personal learning, research, and educational use only
163
- * ✅ Open source integration (must follow same license)
164
-
165
- ## Questions?
166
-
167
- Feel free to open an issue with your question or reach out to the maintainers.
168
-
169
- Thank you for contributing! 🎉
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile CHANGED
@@ -1,38 +1,39 @@
1
- # Multi-stage build for gcli2api
2
- FROM python:3.13-slim as base
3
-
4
- # Set environment variables
5
- ENV PYTHONUNBUFFERED=1 \
6
- PYTHONDONTWRITEBYTECODE=1 \
7
- PIP_NO_CACHE_DIR=1 \
8
- PIP_DISABLE_PIP_VERSION_CHECK=1 \
9
- TZ=Asia/Shanghai
10
-
11
- # Install tzdata and set timezone
12
- # Added: nodejs (for afp.wasm 音频指纹生成), ffmpeg (音频转码)
13
- RUN apt-get update && \
14
- apt-get install -y --no-install-recommends \
15
- tzdata \
16
- nodejs \
17
- ffmpeg \
18
- && ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
19
- echo "Asia/Shanghai" > /etc/timezone && \
20
- apt-get clean && \
21
- rm -rf /var/lib/apt/lists/*
22
-
23
- WORKDIR /app
24
-
25
- # Copy only requirements first for better caching
26
- COPY requirements.txt .
27
-
28
- # Install Python dependencies
29
- RUN pip install --no-cache-dir -r requirements.txt
30
-
31
- # Copy application code
32
  COPY . .
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- # Expose port
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  EXPOSE 7861
36
-
37
- # Default command
38
- CMD ["python", "web.py"]
 
1
+ # syntax=docker/dockerfile:1
2
+ # ---------------------------------------------------------------- build stage
3
+ FROM golang:1.23-alpine AS build
4
+ WORKDIR /src
5
+ COPY go.mod go.sum ./
6
+ RUN go mod download
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  COPY . .
8
+ # 全静态二进制:镜像里直接跑脚本,无需 libc。
9
+ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/wb2api ./cmd/server \
10
+ && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/signin_bin ./cmd/signin \
11
+ && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/login ./cmd/login \
12
+ && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/credit ./cmd/credit
13
+
14
+ # -------------------------------------------------------------- runtime stage
15
+ FROM alpine:3.20
16
+ # python3:login.sh 的 JSON 解析 + 入口脚本的配置校验;tzdata:Asia/Shanghai。
17
+ RUN apk add --no-cache wget ca-certificates tzdata python3 bash \
18
+ && mkdir -p /app/auths /app/data
19
+ ENV TZ=Asia/Shanghai
20
+ WORKDIR /app
21
 
22
+ COPY --from=build /out/wb2api /app/wb2api
23
+ COPY --from=build /out/signin_bin /app/signin_bin
24
+ COPY --from=build /out/login /app/login
25
+ COPY --from=build /out/credit /app/credit
26
+ COPY login.sh signin.sh credit.sh /app/
27
+ COPY scripts/probe_active.py /app/scripts/probe_active.py
28
+ # config.default.json 只是「镜像内模板」:仅当持久卷上还没有 config.json 时被拷过去一次。
29
+ # 真正的运行配置住在私有 Storage Bucket 上(见 docker-entrypoint.sh),公开仓库里不放 api_key。
30
+ COPY config.default.json /app/config.json
31
+ COPY docker-entrypoint.sh /app/docker-entrypoint.sh
32
+ RUN sed -i 's/\r$//' /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh \
33
+ && chmod 755 /app/login.sh /app/signin.sh /app/credit.sh /app/docker-entrypoint.sh
34
+
35
+ # 以 root 运行:Storage Bucket 卷由挂载器创建,非 root 用户可能写不进去。
36
  EXPOSE 7861
37
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
38
+ CMD wget -qO- http://127.0.0.1:7861/healthz || exit 1
39
+ ENTRYPOINT ["/app/docker-entrypoint.sh"]
LICENSE CHANGED
@@ -1,83 +1,22 @@
1
- Cooperative Non-Commercial License (CNC-1.0)
2
-
3
- Copyright (c) 2024 gcli2api contributors
4
-
5
- Permission is hereby granted, free of charge, to any person or organization
6
- obtaining a copy of this software and associated documentation files (the
7
- "Software"), to use, copy, modify, merge, publish, distribute, and/or
8
- sublicense the Software, subject to the following conditions:
9
-
10
- TERMS AND CONDITIONS:
11
-
12
- 1. NON-COMMERCIAL USE ONLY
13
- The Software may only be used for non-commercial purposes. Commercial use
14
- is strictly prohibited without explicit written permission from the
15
- copyright holders.
16
-
17
- 2. DEFINITION OF COMMERCIAL USE
18
- "Commercial use" includes but is not limited to:
19
- a) Using the Software to provide paid services or products
20
- b) Integrating the Software into commercial products or services
21
- c) Using the Software in any business operation that generates revenue
22
- d) Offering the Software as part of a paid subscription or service
23
- e) Using the Software to compete with the original project commercially
24
-
25
- 3. COPYLEFT REQUIREMENT
26
- Any derivative works, modifications, or substantial portions of the Software
27
- must be licensed under the same or substantially similar terms. This ensures
28
- that all derivatives remain non-commercial and freely available.
29
-
30
- 4. SOURCE CODE AVAILABILITY
31
- If you distribute the Software or any derivative works, you must make the
32
- complete source code available under the same license terms at no charge.
33
-
34
- 5. ATTRIBUTION REQUIREMENT
35
- You must retain all copyright notices, license notices, and attribution
36
- statements in all copies or substantial portions of the Software.
37
-
38
- 6. ANTI-CORPORATE CLAUSE
39
- This Software may not be used by corporations with annual revenue exceeding
40
- $1 million USD, venture capital backed companies, or publicly traded
41
- companies without explicit written permission from the copyright holders.
42
-
43
- 7. EDUCATIONAL AND RESEARCH EXEMPTION
44
- Use by educational institutions, non-profit research organizations, and
45
- individual researchers for educational or research purposes is explicitly
46
- permitted and encouraged.
47
-
48
- 8. MODIFICATION AND CONTRIBUTION
49
- Modifications and contributions to the Software are welcomed and encouraged,
50
- provided they comply with these license terms. Contributors grant the same
51
- license to their contributions.
52
-
53
- 9. PATENT GRANT
54
- Each contributor grants you a non-exclusive, worldwide, royalty-free patent
55
- license to make, have made, use, offer to sell, sell, import, and otherwise
56
- transfer the Work for non-commercial purposes only.
57
-
58
- 10. TERMINATION
59
- This license automatically terminates if you violate any of its terms.
60
- Upon termination, you must cease all use and distribution of the Software
61
- and destroy all copies in your possession.
62
-
63
- 11. LIABILITY DISCLAIMER
64
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
65
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
66
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
67
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
68
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
69
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
70
- SOFTWARE.
71
-
72
- 12. JURISDICTION
73
- This license shall be governed by and construed in accordance with the laws
74
- of the jurisdiction where the copyright holder resides.
75
-
76
- SUMMARY:
77
- This license allows free use, modification, and distribution of the Software
78
- for non-commercial purposes only. It explicitly prohibits commercial use and
79
- ensures that all derivatives remain freely available under the same terms.
80
- The license promotes cooperative development while preventing commercial
81
- exploitation of the community's work.
82
-
83
- For commercial licensing inquiries, please contact the copyright holders.
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sliverkiss (original project: https://github.com/Sliverkiss/workbuddy2api)
4
+ Copyright (c) 2026 linguo2625469 (this fork: https://github.com/linguo2625469/workbuddy2api-panel)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,891 +1,67 @@
1
  ---
2
- title: gcli2api
3
- colorFrom: blue
4
- colorTo: green
 
5
  sdk: docker
6
  app_port: 7861
7
  pinned: false
 
8
  ---
9
- # GeminiCLI to API
10
 
11
- **将 GeminiCLI 和 Antigravity 转换为 OpenAI 、GEMINI 和 Claude API 兼容接口**
12
 
13
- [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
14
- [![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](LICENSE)
15
- [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
16
 
17
- [English](docs/README_EN.md) | 中文 | [日本語](docs/README_JA.md)
18
 
19
- ## 🚀 快速部署
 
20
 
21
- [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
22
- [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
23
- ---
24
-
25
- ## 安装指南
26
-
27
- ### Termux 环境
28
-
29
- **初始安装**
30
- ```bash
31
- curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
32
- ```
33
-
34
- **重启服务**
35
- ```bash
36
- cd gcli2api
37
- bash termux-start.sh
38
- ```
39
-
40
- ### Windows 环境
41
-
42
- **初始安装**
43
- ```powershell
44
- iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
45
- ```
46
-
47
- **重启服务**
48
- 双击执行 `start.bat`
49
-
50
- ### Linux 环境
51
-
52
- **初始安装**
53
- ```bash
54
- curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
55
- ```
56
-
57
- **重启服务**
58
- ```bash
59
- cd gcli2api
60
- bash start.sh
61
- ```
62
-
63
- ### macOS 环境
64
-
65
- **初始安装**
66
- ```bash
67
- curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
68
- ```
69
-
70
- **重启服务**
71
- ```bash
72
- cd gcli2api
73
- bash start.sh
74
- ```
75
-
76
- ### Docker 环境
77
-
78
- **Docker 运行命令**
79
- ```bash
80
- # 使用通用密码
81
- docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
82
-
83
- # 使用分离密码
84
- docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
85
- ```
86
-
87
- **Docker Mac**
88
- ```bash
89
- # 使用通用密码
90
- docker run -d \
91
- --name gcli2api \
92
- -p 7861:7861 \
93
- -p 8080:8080 \
94
- -e PASSWORD=pwd \
95
- -e PORT=7861 \
96
- -v "$(pwd)/data/creds":/app/creds \
97
- ghcr.io/su-kaka/gcli2api:latest
98
- ```
99
-
100
- ```bash
101
- # 使用分离密码
102
- docker run -d \
103
- --name gcli2api \
104
- -p 7861:7861 \
105
- -p 8080:8080 \
106
- -e API_PASSWORD=api_pwd \
107
- -e PANEL_PASSWORD=panel_pwd \
108
- -e PORT=7861 \
109
- -v $(pwd)/data/creds:/app/creds \
110
- ghcr.io/su-kaka/gcli2api:latest
111
- ```
112
-
113
- **Docker Compose 运行命令**
114
- 1. 将以下内容保存为 `docker-compose.yml` 文件:
115
- ```yaml
116
- version: '3.8'
117
-
118
- services:
119
- gcli2api:
120
- image: ghcr.io/su-kaka/gcli2api:latest
121
- container_name: gcli2api
122
- restart: unless-stopped
123
- network_mode: host
124
- environment:
125
- # 使用通用密码(推荐用于简单部署)
126
- - PASSWORD=pwd
127
- - PORT=7861
128
- # 或使用分离密码(推荐用于生产环境)
129
- # - API_PASSWORD=your_api_password
130
- # - PANEL_PASSWORD=your_panel_password
131
- volumes:
132
- - ./data/creds:/app/creds
133
- healthcheck:
134
- test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
135
- interval: 30s
136
- timeout: 10s
137
- retries: 3
138
- start_period: 40s
139
- ```
140
- 2. 启动服务:
141
- ```bash
142
- docker-compose up -d
143
- ```
144
-
145
- ## 核心功能
146
-
147
- ### 🔄 API 端点和格式支持
148
-
149
- **多端点多格式支持**
150
- - **OpenAI 兼容端点**:`/v1/chat/completions` 和 `/v1/models`
151
- - 支持标准 OpenAI 格式(messages 结构)
152
- - 支持 Gemini 原生格式(contents 结构)
153
- - 自动格式检测和转换,无需手动切换
154
- - 支持多模态输入(文本 + 图像)
155
- - **Gemini 原生端点**:`/v1/models/{model}:generateContent` 和 `streamGenerateContent`
156
- - 支持完整的 Gemini 原生 API 规范
157
- - 多种认证方式:Bearer Token、x-goog-api-key 头部、URL 参数 key
158
- - **Claude 格式兼容**:完整支持 Claude API 格式
159
- - 端点:`/v1/messages`(遵循 Claude API 规范)
160
- - 支持 Claude 标准的 messages 格式
161
- - 支持 system 参数和 Claude 特有功能
162
- - 自动转换为后端支持的格式
163
- - **Antigravity API 支持**:同时支持 OpenAI、Gemini 和 Claude 格式
164
- - OpenAI 格式端点:`/antigravity/v1/chat/completions`
165
- - Gemini 格式端点:`/antigravity/v1/models/{model}:generateContent` 和 `streamGenerateContent`
166
- - Claude 格式端点:`/antigravity/v1/messages`
167
- - 支持所有 Antigravity 模型(Claude、Gemini 等)
168
- - 自动模型名称映射和思维模式检测
169
-
170
- ### 🔐 认证和安全管理
171
-
172
- **灵活的密码管理**
173
- - **分离密码支持**:API 密码(聊天端点)和控制面板密码可独立设置
174
- - **多种认证方式**:支持 Authorization Bearer、x-goog-api-key 头部、URL 参数等
175
- - **JWT Token 认证**:控制面板支持 JWT 令牌认证
176
- - **用户邮箱获取**:自动获取和显示 Google 账户邮箱地址
177
-
178
- ### 📊 智能凭证管理系统
179
-
180
- **高级凭证管理**
181
- - 多个 Google OAuth 凭证自动轮换
182
- - 通过冗余认证增强稳定性
183
- - 负载均衡与并发请求支持
184
- - 自动故障检测和凭证禁用
185
- - 凭证使用统计和配额管理
186
- - 支持手动启用/禁用凭证文件
187
- - 批量凭证文件操作(启用、禁用、删除)
188
-
189
- **凭证状态监控**
190
- - 实时凭证健康检查
191
- - 错误码追踪(429、403、500 等)
192
- - 自动封禁机制(可配置)
193
-
194
- ### 🌊 流式传输和响应处理
195
-
196
- **多种流式支持**
197
- - 真正的实时流式响应
198
- - 假流式模式(用于兼容性)
199
- - 流式抗截断功能(防止回答被截断)
200
- - 异步任务管理和超时处理
201
-
202
- **响应优化**
203
- - 思维链(Thinking)内容分离
204
- - 推理过程(reasoning_content)处理
205
- - 多轮对话上下文管理
206
- - 兼容性模式(将 system 消息转换为 user 消息)
207
-
208
- ### 🎛️ Web 管理控制台
209
-
210
- **全功能 Web 界面**
211
- - OAuth 认证流程管理(支持 GCLI 和 Antigravity 双模式)
212
- - 凭证文件上传、下载、管理
213
- - 实时日志查看(WebSocket)
214
- - 系统配置管理
215
- - 使用统计和监控面板
216
- - 移动端适配界面
217
-
218
- **批量操作支持**
219
- - ZIP 文件批量上传凭证(GCLI 和 Antigravity)
220
- - 批量启用/禁用/删除凭证
221
- - 批量获取用户邮箱
222
- - 批量配置管理
223
- - 统一批量上传界面管理所有凭证类型
224
-
225
- ### 📈 使用监控
226
-
227
- **实时监控**
228
- - WebSocket 实时日志流
229
- - 系统状态监控
230
- - 凭证健康状态
231
-
232
- ### 🔧 高级配置和自定义
233
-
234
- **网络和代理配置**
235
- - HTTP/HTTPS 代理支持
236
- - 代理端点配置(OAuth、Google APIs、元数据服务)
237
- - 超时和重试配置
238
- - 网络错误处理和恢复
239
-
240
- **性能和稳定性配置**
241
- - 429 错误自动重试(可配置间隔和次数)
242
- - 抗截断最大重试次数
243
-
244
- **日志和调试**
245
- - 多级日志系统(DEBUG、INFO、WARNING、ERROR)
246
- - 日志文件管理
247
- - 实时日志流
248
- - 日志下载和清空
249
-
250
- ### 🔄 环境变量和配置管理
251
-
252
- **灵活的配置方式**
253
- - 环境变量配置
254
- - 热配置更新(部分配置项)
255
- - 配置锁定(环境变量优先级)
256
-
257
- ## 支持的模型
258
-
259
- 所有模型均具备 1M 上下文窗口容量。每个凭证文件提供 1000 次请求额度。
260
-
261
- ### 🤖 基础模型
262
- - `gemini-2.5-pro`
263
- - `gemini-3-pro-preview`
264
- - `gemini-3.1-pro-preview`
265
-
266
- ### 🧠 思维模型(Thinking Models)
267
- - `gemini-2.5-pro-high`:思考模式
268
- - `gemini-2.5-pro-low`:低思考模式
269
- - 支持自定义思考预算配置
270
- - 自动分离思维内容和最终回答
271
-
272
- ### 🔍 搜索增强模型
273
- - `gemini-2.5-pro-search`:集成搜索功能的模型
274
-
275
- ### 🖼️ 图像生成模型(Antigravity)
276
- - `gemini-3.1-flash-image`:基础图像生成模型
277
- - **分辨率后缀**:
278
- - `-2k`:2K 分辨率
279
- - `-4k`:4K 高清分辨率
280
- - **比例后缀**:
281
- - `-1x1`:正方形(头像)
282
- - `-16x9`:横屏(电脑壁纸)
283
- - `-9x16`:竖屏(手机壁纸)
284
- - `-21x9`:超宽屏(带鱼屏)
285
- - `-4x3`:传统显示器
286
- - `-3x4`:竖版海报
287
- - **组合使用示例**:
288
- - `gemini-3.1-flash-image-4k-16x9`:4K 横屏
289
- - `gemini-3.1-flash-image-2k-9x16`:2K 竖屏
290
- - 不指定比例时,API 自动决定横竖比例
291
-
292
- ### 🌊 特殊功能变体
293
- - **假流式模式**:在任何模型名称后添加 `-假流式` 后缀
294
- - 例:`gemini-2.5-pro-假流式`
295
- - 用于需要流式响应但服务端不支持真流式的场景
296
- - **流式抗截断模式**:在模型名称前添加 `流式抗截断/` 前缀
297
- - 例:`流式抗截断/gemini-2.5-pro`
298
- - 自动检测响应截断并重试,确保完整回答
299
-
300
- ### 🔧 模型��能自动检测
301
- - 系统自动识别模型名称中的功能标识
302
- - 透明地处理功能模式转换
303
- - 支持功能组合使用
304
-
305
- ---
306
-
307
- ## 配置说明
308
-
309
- 1. 访问 `http://127.0.0.1:7861` (默认端口,可通过 PORT 环境变量修改)
310
- 2. 完成 OAuth 认证流程(默认密码:`pwd`,可通过环境变量修改)
311
- - **GCLI 模式**:用于获取 Google Cloud Gemini API 凭证
312
- - **Antigravity 模式**:用于获取 Google Antigravity API 凭证
313
- 3. 配置客户端:
314
-
315
- **OpenAI 兼容客户端:**
316
- - **端点地址**:`http://127.0.0.1:7861/v1`
317
- - **API 密钥**:`pwd`(默认值,可通过 API_PASSWORD 或 PASSWORD 环境变量修改)
318
-
319
- **Gemini 原生客户端:**
320
- - **端点地址**:`http://127.0.0.1:7861`
321
- - **认证方式**:
322
- - `Authorization: Bearer your_api_password`
323
- - `x-goog-api-key: your_api_password`
324
- - URL 参数:`?key=your_api_password`
325
-
326
- ### 🌟 双认证模式支持
327
-
328
- **GCLI 认证模式**
329
- - 标准的 Google Cloud Gemini API 认证
330
- - 支持 OAuth2.0 认证流程
331
- - 自动启用必需的 Google Cloud API
332
-
333
- **Antigravity 认证模式**
334
- - Google Antigravity API 专用认证
335
- - 独立的凭证管理系统
336
- - 支持批量上传和管理
337
- - 与 GCLI 凭证完全隔离
338
-
339
- **统一管理界面**
340
- - 在"批量上传"标签页中可一次性管理两种凭证
341
- - 上半部分:GCLI 凭证批量上传(蓝色主题)
342
- - 下半部分:Antigravity 凭证批量上传(绿色主题)
343
- - 各自独立的凭证管理标签页
344
-
345
- ## 💾 数据存储模式
346
-
347
- ### 🌟 存储后端支持
348
-
349
- gcli2api 支持两种存储后端:**本地 SQLite(默认)** 和 **MongoDB(云端分布式存储)**
350
-
351
- ### 📁 本地 SQLite 存储(默认)
352
-
353
- **默认存储方式**
354
- - 无需配置,开箱即用
355
- - 数据存储在本地 SQLite 数据库中
356
- - 适合单机部署和个人使用
357
- - 自动创建和管理数据库文件
358
-
359
- ### 🍃 MongoDB 云端存储模式
360
-
361
- **云端分布式存储方案**
362
-
363
- 当需要多实例部署或云端存储时,可以启用 MongoDB 存储模式。
364
-
365
- ### ⚙️ 启用 MongoDB 模式
366
-
367
- **步骤 1: 配置 MongoDB 连接**
368
- ```bash
369
- # 本地 MongoDB
370
- export MONGODB_URI="mongodb://localhost:27017"
371
-
372
- # MongoDB Atlas 云服务
373
- export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
374
-
375
- # 带认证的 MongoDB
376
- export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
377
-
378
- # 可选:自定义数据库名称(默认: gcli2api)
379
- export MONGODB_DATABASE="my_gcli_db"
380
- ```
381
-
382
- **步骤 2: 启动应用**
383
- ```bash
384
- # 应用会自动检测 MongoDB 配置并使用 MongoDB 存储
385
- python web.py
386
- ```
387
-
388
- **Docker 环境使用 MongoDB**
389
- ```bash
390
- # 单机 MongoDB 部署
391
- docker run -d --name gcli2api \
392
- -e MONGODB_URI="mongodb://mongodb:27017" \
393
- -e API_PASSWORD=your_password \
394
- --network your_network \
395
- ghcr.io/su-kaka/gcli2api:latest
396
-
397
- # 使用 MongoDB Atlas
398
- docker run -d --name gcli2api \
399
- -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
400
- -e API_PASSWORD=your_password \
401
- -p 7861:7861 \
402
- ghcr.io/su-kaka/gcli2api:latest
403
- ```
404
-
405
- **Docker Compose 示例**
406
- ```yaml
407
- version: '3.8'
408
-
409
- services:
410
- mongodb:
411
- image: mongo:7
412
- container_name: gcli2api-mongodb
413
- restart: unless-stopped
414
- environment:
415
- MONGO_INITDB_ROOT_USERNAME: admin
416
- MONGO_INITDB_ROOT_PASSWORD: password123
417
- volumes:
418
- - mongodb_data:/data/db
419
- ports:
420
- - "27017:27017"
421
-
422
- gcli2api:
423
- image: ghcr.io/su-kaka/gcli2api:latest
424
- container_name: gcli2api
425
- restart: unless-stopped
426
- depends_on:
427
- - mongodb
428
- environment:
429
- - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
430
- - MONGODB_DATABASE=gcli2api
431
- - API_PASSWORD=your_api_password
432
- - PORT=7861
433
- ports:
434
- - "7861:7861"
435
 
436
- volumes:
437
- mongodb_data:
438
- ```
439
-
440
-
441
- ### 🔧 高级配置
442
-
443
- **MongoDB 连接优化**
444
- ```bash
445
- # 连接池和超时配置
446
- export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
447
-
448
- # 副本集配置
449
- export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
450
-
451
- # 读写分离配置
452
- export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
453
- ```
454
-
455
- ### 环境变量配置
456
-
457
- **基础配置**
458
- - `PORT`: 服务端口(默认:7861)
459
- - `HOST`: 服务器监听地址(默认:0.0.0.0)
460
-
461
- **密码配置**
462
- - `API_PASSWORD`: 聊天 API 访问密码(默认:继承 PASSWORD 或 pwd)
463
- - `PANEL_PASSWORD`: 控制面板访问密码(默认:继承 PASSWORD 或 pwd)
464
- - `PASSWORD`: 通用密码,设置后覆盖上述两个(默认:pwd)
465
-
466
- **性能和稳定性配置**
467
- - `RETRY_429_ENABLED`: 启用 429 错误自动重试(默认:true)
468
- - `RETRY_429_MAX_RETRIES`: 429 错误最大重试次数(默认:3)
469
- - `RETRY_429_INTERVAL`: 429 错误重试间隔,秒(默认:1.0)
470
- - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 抗截断最大重试次数(默认:3)
471
-
472
- **网络和代理配置**
473
- - `PROXY`: HTTP/HTTPS 代理地址(格式:`http://host:port`)
474
- - `OAUTH_PROXY_URL`: OAuth 认证代理端点
475
- - `GOOGLEAPIS_PROXY_URL`: Google APIs 代理端点
476
- - `METADATA_SERVICE_URL`: 元数据服务代理端点
477
-
478
- **自动化配置**
479
- - `AUTO_BAN`: 启用凭证自动封禁(默认:true)
480
- - `AUTO_LOAD_ENV_CREDS`: 启动时自动加载环境变量凭证(默认:false)
481
-
482
- **兼容性配置**
483
- - `COMPATIBILITY_MODE`: 启用兼容性模式,将 system 消息转为 user 消息(默认:false)
484
-
485
- **日志配置**
486
- - `LOG_LEVEL`: 日志级别(DEBUG/INFO/WARNING/ERROR,默认:INFO)
487
- - `LOG_FILE`: 日志文件路径(默认:log.txt)
488
-
489
- **存储配置**
490
-
491
- **SQLite 配置(默认)**
492
- - 无需配置,自动使用本地 SQLite 数据库
493
- - 数据库文件自动创建在项目目录
494
-
495
- **MongoDB 配置(可选云端存储)**
496
- - `MONGODB_URI`: MongoDB 连接字符串(设置后启用 MongoDB 模式)
497
- - `MONGODB_DATABASE`: MongoDB 数据库名称(默认:gcli2api)
498
-
499
- **Docker 使用示例**
500
- ```bash
501
- # 使用通用密码
502
- docker run -d --name gcli2api \
503
- -e PASSWORD=mypassword \
504
- -e PORT=7861 \
505
- ghcr.io/su-kaka/gcli2api:latest
506
-
507
- # 使用分离密码
508
- docker run -d --name gcli2api \
509
- -e API_PASSWORD=my_api_password \
510
- -e PANEL_PASSWORD=my_panel_password \
511
- -e PORT=7861 \
512
- ghcr.io/su-kaka/gcli2api:latest
513
- ```
514
-
515
- 注意:当设置了凭证环境变量时,系统将优先使用环境变量中的凭证,忽略 `creds` 目录中的文件。
516
-
517
- ### API 使用方式
518
-
519
- 本服务支持三套完整的 API 端点:
520
-
521
- #### 1. OpenAI 兼容端点(GCLI)
522
-
523
- **端点:** `/v1/chat/completions`
524
- **认证:** `Authorization: Bearer your_api_password`
525
-
526
- 支持两种请求格式,会自动检测并处理:
527
-
528
- **OpenAI 格式:**
529
- ```json
530
- {
531
- "model": "gemini-2.5-pro",
532
- "messages": [
533
- {"role": "system", "content": "You are a helpful assistant"},
534
- {"role": "user", "content": "Hello"}
535
- ],
536
- "temperature": 0.7,
537
- "stream": true
538
- }
539
- ```
540
-
541
- **Gemini 原生格式:**
542
- ```json
543
- {
544
- "model": "gemini-2.5-pro",
545
- "contents": [
546
- {"role": "user", "parts": [{"text": "Hello"}]}
547
- ],
548
- "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
549
- "generationConfig": {
550
- "temperature": 0.7
551
- }
552
- }
553
- ```
554
-
555
- #### 2. Gemini 原生端点(GCLI)
556
-
557
- **非流式端点:** `/v1/models/{model}:generateContent`
558
- **流式端点:** `/v1/models/{model}:streamGenerateContent`
559
- **模型列表:** `/v1/models`
560
-
561
- **认证方式(任选一种):**
562
- - `Authorization: Bearer your_api_password`
563
- - `x-goog-api-key: your_api_password`
564
- - URL 参数:`?key=your_api_password`
565
-
566
- **请求示例:**
567
- ```bash
568
- # 使用 x-goog-api-key 头部
569
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
570
- -H "x-goog-api-key: your_api_password" \
571
- -H "Content-Type: application/json" \
572
- -d '{
573
- "contents": [
574
- {"role": "user", "parts": [{"text": "Hello"}]}
575
- ]
576
- }'
577
-
578
- # 使用 URL 参数
579
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
580
- -H "Content-Type: application/json" \
581
- -d '{
582
- "contents": [
583
- {"role": "user", "parts": [{"text": "Hello"}]}
584
- ]
585
- }'
586
- ```
587
-
588
- #### 3. Claude API 格式端点
589
-
590
- **端点:** `/v1/messages`
591
- **认证:** `x-api-key: your_api_password` 或 `Authorization: Bearer your_api_password`
592
-
593
- **请求示例:**
594
- ```bash
595
- curl -X POST "http://127.0.0.1:7861/v1/messages" \
596
- -H "x-api-key: your_api_password" \
597
- -H "anthropic-version: 2023-06-01" \
598
- -H "Content-Type: application/json" \
599
- -d '{
600
- "model": "gemini-2.5-pro",
601
- "max_tokens": 1024,
602
- "messages": [
603
- {"role": "user", "content": "Hello, Claude!"}
604
- ]
605
- }'
606
- ```
607
-
608
- **支持 system 参数:**
609
- ```json
610
- {
611
- "model": "gemini-2.5-pro",
612
- "max_tokens": 1024,
613
- "system": "You are a helpful assistant",
614
- "messages": [
615
- {"role": "user", "content": "Hello"}
616
- ]
617
- }
618
- ```
619
-
620
- **说明:**
621
- - 完全兼容 Claude API 格式规范
622
- - 自动转换为 Gemini 格式调用后端
623
- - 支持 Claude 的所有标准参数
624
- - 响应格式符合 Claude API 规范
625
-
626
- #### 4. Antigravity API 端点
627
-
628
- **支持三种格式:OpenAI、Gemini 和 Claude**
629
-
630
- ##### Antigravity OpenAI 格式端点
631
 
632
- **端点:** `/antigravity/v1/chat/completions`
633
- **认证:** `Authorization: Bearer your_api_password`
634
 
635
- **请求示例:**
636
- ```bash
637
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
638
- -H "Authorization: Bearer your_api_password" \
639
- -H "Content-Type: application/json" \
640
- -d '{
641
- "model": "claude-sonnet-4-5",
642
- "messages": [
643
- {"role": "user", "content": "Hello"}
644
- ],
645
- "stream": true
646
- }'
647
- ```
648
-
649
- ##### Antigravity Gemini 格式端点
650
-
651
- **非流式端点:** `/antigravity/v1/models/{model}:generateContent`
652
- **流式端点:** `/antigravity/v1/models/{model}:streamGenerateContent`
653
-
654
- **认证方式(任选一种):**
655
- - `Authorization: Bearer your_api_password`
656
- - `x-goog-api-key: your_api_password`
657
- - URL 参数:`?key=your_api_password`
658
-
659
- **请求示例:**
660
- ```bash
661
- # Gemini 格式非流式请求
662
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
663
- -H "x-goog-api-key: your_api_password" \
664
- -H "Content-Type: application/json" \
665
- -d '{
666
- "contents": [
667
- {"role": "user", "parts": [{"text": "Hello"}]}
668
- ],
669
- "generationConfig": {
670
- "temperature": 0.7
671
- }
672
- }'
673
-
674
- # Gemini 格式流式请求
675
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
676
- -H "Content-Type: application/json" \
677
- -d '{
678
- "contents": [
679
- {"role": "user", "parts": [{"text": "Hello"}]}
680
- ]
681
- }'
682
- ```
683
-
684
- ##### Antigravity Claude 格式端点
685
-
686
- **端点:** `/antigravity/v1/messages`
687
- **认证:** `x-api-key: your_api_password`
688
-
689
- **请求示例:**
690
- ```bash
691
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
692
- -H "x-api-key: your_api_password" \
693
- -H "anthropic-version: 2023-06-01" \
694
- -H "Content-Type: application/json" \
695
- -d '{
696
- "model": "claude-sonnet-4-5",
697
- "max_tokens": 1024,
698
- "messages": [
699
- {"role": "user", "content": "Hello"}
700
- ]
701
- }'
702
- ```
703
-
704
- **支持的 Antigravity 模型:**
705
- - Claude 系列:`claude-sonnet-4-5`、`claude-opus-4-5` 等
706
- - Gemini 系列:`gemini-2.5-flash`、`gemini-2.5-pro` 等
707
- - 自动支持思维模型(thinking models)
708
-
709
- **Gemini 原生示例:**
710
  ```python
711
- from io import BytesIO
712
- from PIL import Image
713
- from google.genai import Client
714
- from google.genai.types import HttpOptions
715
- from google.genai import types
716
- # The client gets the API key from the environment variable `GEMINI_API_KEY`.
717
-
718
- client = Client(
719
- api_key="pwd",
720
- http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
721
- )
722
-
723
- prompt = (
724
- """
725
- 画一只猫
726
- """
727
  )
728
-
729
- response = client.models.generate_content(
730
- model="gemini-3.1-flash-image",
731
- contents=[prompt],
732
- config=types.GenerateContentConfig(
733
- image_config=types.ImageConfig(
734
- aspect_ratio="16:9",
735
- )
736
- )
737
- )
738
- for part in response.candidates[0].content.parts:
739
- if part.text is not None:
740
- print(part.text)
741
- elif part.inline_data is not None:
742
- image = Image.open(BytesIO(part.inline_data.data))
743
- image.save("generated_image.png")
744
-
745
- ```
746
-
747
- **说明:**
748
- - OpenAI 端点返回 OpenAI 兼容格式
749
- - Gemini 端点返回 Gemini 原生格式
750
- - 两种端点使用相同的 API 密码
751
-
752
- ## 📋 完整 API 参考
753
-
754
- ### Web 控制台 API
755
-
756
- **认证端点**
757
- - `POST /auth/login` - 用户登录
758
- - `POST /auth/start` - 开始 OAuth 认证(支持 GCLI 和 Antigravity 模式)
759
- - `POST /auth/callback` - 处理 OAuth 回调
760
- - `POST /auth/callback-url` - 从回调 URL 直接完成认证
761
- - `GET /auth/status/{project_id}` - 检查认证状态
762
-
763
- **凭证管理端点**(支持 `mode=geminicli` 或 `mode=antigravity` 参数)
764
- - `POST /creds/upload` - 批量上传凭证文件(支持 JSON 和 ZIP)
765
- - `GET /creds/status` - 获取凭证状态列表(支持分页和筛选)
766
- - `GET /creds/detail/{filename}` - 获取单个凭证详情
767
- - `POST /creds/action` - 单个凭证操作(启用/禁用/删除)
768
- - `POST /creds/batch-action` - 批量凭证操作
769
- - `GET /creds/download/{filename}` - 下载单个凭证文件
770
- - `GET /creds/download-all` - 打包下载所有凭证
771
- - `POST /creds/fetch-email/{filename}` - 获取用户邮箱
772
- - `POST /creds/refresh-all-emails` - 批量刷新用户邮箱
773
- - `POST /creds/deduplicate-by-email` - 按邮箱去重凭证
774
- - `POST /creds/verify-project/{filename}` - 检验凭证 Project ID
775
- - `GET /creds/quota/{filename}` - 获取凭证额度信息(仅 Antigravity)
776
-
777
- **配置管理端点**
778
- - `GET /config/get` - 获取当前配置
779
- - `POST /config/save` - 保存配置
780
-
781
- **日志管理端点**
782
- - `POST /logs/clear` - 清空日志
783
- - `GET /logs/download` - 下载日志文件
784
- - `WebSocket /logs/stream` - 实时日志流
785
-
786
- **版本信息端点**
787
- - `GET /version/info` - 获取版本信息(可选 `check_update=true` 参数检查更新)
788
-
789
- ### 聊天 API 功能特性
790
-
791
- **多模态支持**
792
- ```json
793
- {
794
- "model": "gemini-2.5-pro",
795
- "messages": [
796
- {
797
- "role": "user",
798
- "content": [
799
- {"type": "text", "text": "描述这张图片"},
800
- {
801
- "type": "image_url",
802
- "image_url": {
803
- "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
804
- }
805
- }
806
- ]
807
- }
808
- ]
809
- }
810
- ```
811
-
812
- **思维模式支持**
813
- ```json
814
- {
815
- "model": "gemini-2.5-pro-maxthinking",
816
- "messages": [
817
- {"role": "user", "content": "复杂数学问题"}
818
- ]
819
- }
820
- ```
821
-
822
- 响应将包含分离的思维内容:
823
- ```json
824
- {
825
- "choices": [{
826
- "message": {
827
- "role": "assistant",
828
- "content": "最终答案",
829
- "reasoning_content": "详细的思考过程..."
830
- }
831
- }]
832
- }
833
- ```
834
-
835
- **流式抗截断使用**
836
- ```json
837
- {
838
- "model": "流式抗截断/gemini-2.5-pro",
839
- "messages": [
840
- {"role": "user", "content": "写一篇长文章"}
841
- ],
842
- "stream": true
843
- }
844
  ```
845
 
846
- **兼容性模式**
847
- ```bash
848
- # 启用兼容性模式
849
- export COMPATIBILITY_MODE=true
850
- ```
851
- 此模式下,所有 `system` 消息会转换为 `user` 消息,提高与某些客户端的兼容性。
852
-
853
- ---
854
-
855
- ## 💬 交流群
856
-
857
- 欢迎加入 QQ 群交流讨论!
858
-
859
- **QQ 群号:1083250744**
860
-
861
- <img src="docs/qq群.jpg" width="200" alt="QQ群二维码">
862
-
863
- ---
864
-
865
- ## 许可证与免责声明
866
-
867
- 本项目仅供学习和研究用途。使用本项目表示您同意:
868
- - 不将本项目用于任何商业用途
869
- - 承担使用本项目的所有风险和责任
870
- - 遵守相关的服务条款和法律法规
871
 
872
- 项目作者对因使用本项目而产生的任何直接或间接损失不承担责任。
873
 
874
- ## ⚠️ 许可证声明
 
 
 
 
875
 
876
- **本项目采用 Cooperative Non-Commercial License (CNC-1.0)**
877
 
878
- 这是一个反商业化的严格开源协议,详情请查看 [LICENSE](LICENSE) 文件。
 
 
 
 
 
 
879
 
880
- ### ✅ 允许的用途:
881
- - 个人学习、研究、教育用途
882
- - 非营利组织使用
883
- - 开源项目集成(需遵循相同协议)
884
- - 学术研究和论文发表
885
 
886
- ### ❌ 禁止的用途:
887
- - 任何形式的商业使用
888
- - 年收入超过100万美元的企业使用
889
- - 风投支持或公开交易的公司使用
890
- - 提供付费服务或产品
891
- - 商业竞争用途
 
1
  ---
2
+ title: WorkBuddy2API Panel
3
+ emoji: 🧩
4
+ colorFrom: indigo
5
+ colorTo: blue
6
  sdk: docker
7
  app_port: 7861
8
  pinned: false
9
+ short_description: WorkBuddy 账号池网关,带面板,数据落私有存储桶
10
  ---
 
11
 
12
+ # WorkBuddy2API Panel — Hugging Face Spaces 部署
13
 
14
+ 把腾讯 WorkBuddy 账号变成 OpenAI 兼容 API 的多账号网关(上游:
15
+ [linguo2625469/workbuddy2api-panel](https://github.com/linguo2625469/workbuddy2api-panel))。
16
+ 本目录是**面向 HF Spaces 的部署分支**:唯一的改动是「持久化」。
17
 
18
+ ## 这个分支改了什么
19
 
20
+ Spaces 的容器根文件系统是 ephemeral 的 —— 重启、休眠唤醒、重新构建都会清空。
21
+ 原版把账号和状态放在容器内的 `./auths`、`./data`,在 Spaces 上等于每次重启都要重新登录账号。
22
 
23
+ 本分支的做法:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ - 一个**私有 Storage Bucket** 以卷的形式挂载到容器 `/app/storage`(读写);
26
+ - `docker-entrypoint.sh` 把运行配置指到卷上:
27
+ - `/app/storage/config.json` — 运行配置(含 `api_key`,私有卷上,公开仓库里没有)
28
+ - `/app/storage/auths/*.json` — 账号凭证
29
+ - `/app/storage/data/{state,usage,model,output_probes}.json`、`data/request-logs/` — 运行状态与请求归档
30
+ - 启动时做一次「写 + rename」自检(程序所有落盘都走 tmp+rename,FUSE 卷不支持 rename 的话持久化就是假的);
31
+ - **安全闸门**:`api_key` 为空等于完全关闭鉴权,入口脚本检测到空 key 会直接拒绝启动。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
+ ## 一���性配置(已经做完,此处仅为记录)
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  ```python
36
+ from huggingface_hub import HfApi, Volume
37
+ api = HfApi()
38
+ api.create_bucket("a3216/wb2api-storage", private=True, exist_ok=True)
39
+ api.set_space_volumes(
40
+ "a3216/gcli2api",
41
+ volumes=[Volume(type="bucket", source="a3216/wb2api-storage", mount_path="/app/storage")],
 
 
 
 
 
 
 
 
 
 
42
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  ```
44
 
45
+ `set_space_volumes` 是**替换**语义;要追加卷,先读 `get_space_runtime(...).volumes` 再一起传进去。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
+ ## 环境变量
48
 
49
+ | 变量 | 作用 |
50
+ | --- | --- |
51
+ | `WB2A_API_KEY` | 覆盖配置文件里的 `api_key`(本项目默认不设,key 存在卷上的 config.json 里) |
52
+ | `WB2A_PERSIST_DIR` | 卷挂载点,默认 `/app/storage` |
53
+ | `WB2A_LISTEN` / `WB2A_AUTH_DIR` / `WB2A_STATE_FILE` … | 上游已有的同名覆盖,见上游 README |
54
 
55
+ ## 运维
56
 
57
+ - **改 API 密钥**:面板「配置」页改完即热生效,并写回 `/app/storage/config.json`,重启不丢。
58
+ - **加账号**:面板里走 OAuth 添加,或把 `workbuddy-*.json` 丢进桶的 `auths/`。
59
+ - **备份**:桶本身就是备份。本地拉一份:
60
+ `hf buckets sync hf://buckets/a3216/wb2api-storage ./backup`
61
+ - **看日志**:`hf spaces logs a3216/gcli2api`(构建日志加 `--build`)。
62
+ - **免费硬件会自动休眠**:cpu-basic 上 48 小时没有 HTTP 流量就被暂停,下次有人访问自动唤醒。
63
+ 唤醒后账号和状态从桶里恢复;网关的定时任务(签到/领积分)在暂停期间不会执行。
64
 
65
+ ## 说明
 
 
 
 
66
 
67
+ 上游项目与本文档的许可证见 `LICENSE`(上游为 MIT,本分支的部署脚本同样遵循)。
 
 
 
 
 
cmd/credit/main.go ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // credit.go — WorkBuddy 积分查询(全部账号 + 总计),JSON 输出到 stdout。
2
+ //
3
+ // 用法:
4
+ //
5
+ // go run ./cmd/credit # 或编译后 ./credit
6
+ //
7
+ // 输出结构:
8
+ //
9
+ // {"service":"workbuddy","ts":N,
10
+ // "total":{"remain":N,"used":N,"size":N,"accounts":N,"ok":N,"failed":N},
11
+ // "accounts":[{"uid","nickname","remain","used","size","packages","ok","error?"}]}
12
+ //
13
+ // 接口与聚合逻辑:POST codebuddy.cn/v2/billing/meter/get-user-resource,聚合所有 package 的
14
+ // Cycle* 字段,TotalDosage 作 size 下限。
15
+ package main
16
+
17
+ import (
18
+ "bytes"
19
+ "encoding/json"
20
+ "fmt"
21
+ "net/http"
22
+ "os"
23
+ "path/filepath"
24
+ "sort"
25
+ "strings"
26
+ "time"
27
+ )
28
+
29
+ const billingBaseCN = "https://www.codebuddy.cn"
30
+
31
+ // billingBaseGlobal 国际版计费域。global 账号打 CN 域会得到 401:
32
+ // www.codebuddy.cn 不认 workbuddy.ai 的 token(实测 401,workbuddy.ai 同 token 为 code=0)。
33
+ const billingBaseGlobal = "https://www.workbuddy.ai"
34
+
35
+ // billingBaseFor 按账号 realm 选择计费域。
36
+ //
37
+ // 判定口径与 internal/auth.Realm() 一致:显式 realm=global 或 domain 落在
38
+ // workbuddy.ai 家族,都按国际版处理。cmd/credit 早先对所有账号硬编码 CN 域,
39
+ // 导致 global 账号余额查询恒返回 401(面板显示的是池内缓存值,不是实时的)。
40
+ func billingBaseFor(af *authFile) string {
41
+ if strings.EqualFold(strings.TrimSpace(af.Auth.Realm), "global") {
42
+ return billingBaseGlobal
43
+ }
44
+ d := strings.ToLower(strings.TrimSpace(af.Auth.Domain))
45
+ if d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai") {
46
+ return billingBaseGlobal
47
+ }
48
+ return billingBaseCN
49
+ }
50
+
51
+ type authFile struct {
52
+ Auth struct {
53
+ AccessToken string `json:"accessToken"`
54
+ Domain string `json:"domain"`
55
+ Realm string `json:"realm"`
56
+ } `json:"auth"`
57
+ Account struct {
58
+ UID string `json:"uid"`
59
+ EnterpriseID string `json:"enterpriseId"`
60
+ Nickname string `json:"nickname"`
61
+ } `json:"account"`
62
+ }
63
+
64
+ type accountResult struct {
65
+ UID string `json:"uid"`
66
+ Nickname string `json:"nickname"`
67
+ Remain *int64 `json:"remain"`
68
+ Used *int64 `json:"used"`
69
+ Size *int64 `json:"size"`
70
+ Packages int `json:"packages,omitempty"`
71
+ OK bool `json:"ok"`
72
+ Error string `json:"error,omitempty"`
73
+ }
74
+
75
+ type resourcePackage struct {
76
+ CapacityRemain int64 `json:"CapacityRemain"`
77
+ CapacityUsed int64 `json:"CapacityUsed"`
78
+ CapacitySize int64 `json:"CapacitySize"`
79
+ CycleCapacityRemain int64 `json:"CycleCapacityRemain"`
80
+ CycleCapacityUsed int64 `json:"CycleCapacityUsed"`
81
+ CycleCapacitySize int64 `json:"CycleCapacitySize"`
82
+ }
83
+
84
+ // packageRemainUsed 与 billing.go:203-258 一致
85
+ func packageRemainUsed(a resourcePackage) (remain, used, size int64) {
86
+ if a.CycleCapacitySize > 0 {
87
+ remain = a.CycleCapacityRemain
88
+ size = a.CycleCapacitySize
89
+ if remain < 0 {
90
+ remain = 0
91
+ }
92
+ if remain > size {
93
+ remain = size
94
+ }
95
+ used = size - remain
96
+ if a.CycleCapacityUsed > used {
97
+ used = a.CycleCapacityUsed
98
+ if size >= used {
99
+ remain = size - used
100
+ }
101
+ }
102
+ return remain, used, size
103
+ }
104
+ remain = a.CapacityRemain
105
+ used = a.CapacityUsed
106
+ size = a.CapacitySize
107
+ if used == 0 && size > remain {
108
+ used = size - remain
109
+ }
110
+ return remain, used, size
111
+ }
112
+
113
+ func fetchUserResource(af *authFile) (remain, used, size int64, packs int, err error) {
114
+ now := time.Now()
115
+ body, _ := json.Marshal(map[string]any{
116
+ "PageNumber": 1,
117
+ "PageSize": 100,
118
+ "ProductCode": "p_tcaca",
119
+ "Status": []int{0, 3},
120
+ "PackageEndTimeRangeBegin": now.Format("2006-01-02 15:04:05"),
121
+ "PackageEndTimeRangeEnd": now.Add(365 * 101 * 24 * time.Hour).Format("2006-01-02 15:04:05"),
122
+ })
123
+ base := billingBaseFor(af)
124
+ // global 域无 /v2 前缀(与 internal/upstream 的 billingMeterPaths 同口径)。
125
+ path := "/v2/billing/meter/get-user-resource"
126
+ if base == billingBaseGlobal {
127
+ path = "/billing/meter/get-user-resource"
128
+ }
129
+ req, err := http.NewRequest(http.MethodPost, base+path, bytes.NewReader(body))
130
+ if err != nil {
131
+ return 0, 0, 0, 0, err
132
+ }
133
+ req.Header.Set("Authorization", "Bearer "+af.Auth.AccessToken)
134
+ req.Header.Set("Accept", "application/json")
135
+ req.Header.Set("Content-Type", "application/json")
136
+ if af.Account.UID != "" {
137
+ req.Header.Set("X-User-Id", af.Account.UID)
138
+ }
139
+ if af.Account.EnterpriseID != "" {
140
+ req.Header.Set("X-Enterprise-Id", af.Account.EnterpriseID)
141
+ req.Header.Set("X-Tenant-Id", af.Account.EnterpriseID)
142
+ }
143
+ if af.Auth.Domain != "" {
144
+ req.Header.Set("X-Domain", af.Auth.Domain)
145
+ }
146
+ client := &http.Client{Timeout: 20 * time.Second}
147
+ resp, err := client.Do(req)
148
+ if err != nil {
149
+ return 0, 0, 0, 0, err
150
+ }
151
+ defer resp.Body.Close()
152
+ if resp.StatusCode >= 400 {
153
+ return 0, 0, 0, 0, fmt.Errorf("http %d", resp.StatusCode)
154
+ }
155
+ var env struct {
156
+ Code int `json:"code"`
157
+ Msg string `json:"msg"`
158
+ Data struct {
159
+ Response struct {
160
+ Data struct {
161
+ TotalDosage int64 `json:"TotalDosage"`
162
+ Accounts []resourcePackage `json:"Accounts"`
163
+ } `json:"Data"`
164
+ } `json:"Response"`
165
+ } `json:"data"`
166
+ }
167
+ if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
168
+ return 0, 0, 0, 0, err
169
+ }
170
+ if env.Code != 0 {
171
+ return 0, 0, 0, 0, fmt.Errorf("code=%d %s", env.Code, env.Msg)
172
+ }
173
+ for _, a := range env.Data.Response.Data.Accounts {
174
+ r, u, s := packageRemainUsed(a)
175
+ remain += r
176
+ used += u
177
+ size += s
178
+ }
179
+ packs = len(env.Data.Response.Data.Accounts)
180
+ if size > 0 {
181
+ if derived := size - remain; derived > used {
182
+ used = derived
183
+ }
184
+ }
185
+ if dosage := env.Data.Response.Data.TotalDosage; dosage > size {
186
+ size = dosage
187
+ if derived := size - remain; derived > used {
188
+ used = derived
189
+ }
190
+ }
191
+ return remain, used, size, packs, nil
192
+ }
193
+
194
+ func main() {
195
+ pretty := len(os.Args) > 1 && os.Args[1] == "-pretty"
196
+ authDir := "./auths"
197
+ if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
198
+ authDir = v
199
+ }
200
+ files, _ := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
201
+ sort.Strings(files)
202
+
203
+ accounts := make([]accountResult, 0, len(files))
204
+ for _, f := range files {
205
+ var af authFile
206
+ raw, err := os.ReadFile(f)
207
+ if err != nil || json.Unmarshal(raw, &af) != nil {
208
+ continue
209
+ }
210
+ res := accountResult{UID: af.Account.UID, Nickname: af.Account.Nickname}
211
+ if af.Auth.AccessToken == "" {
212
+ res.Error = "no accessToken"
213
+ accounts = append(accounts, res)
214
+ continue
215
+ }
216
+ remain, used, size, packs, err := fetchUserResource(&af)
217
+ if err != nil {
218
+ res.Error = err.Error()
219
+ } else {
220
+ res.Remain = &remain
221
+ res.Used = &used
222
+ res.Size = &size
223
+ res.Packages = packs
224
+ res.OK = true
225
+ }
226
+ accounts = append(accounts, res)
227
+ time.Sleep(200 * time.Millisecond)
228
+ }
229
+
230
+ var totalRemain, totalUsed, totalSize int64
231
+ okCount := 0
232
+ for _, a := range accounts {
233
+ if a.OK {
234
+ okCount++
235
+ if a.Remain != nil {
236
+ totalRemain += *a.Remain
237
+ }
238
+ if a.Used != nil {
239
+ totalUsed += *a.Used
240
+ }
241
+ if a.Size != nil {
242
+ totalSize += *a.Size
243
+ }
244
+ }
245
+ }
246
+ out := map[string]any{
247
+ "service": "workbuddy",
248
+ "ts": time.Now().Unix(),
249
+ "total": map[string]any{
250
+ "remain": totalRemain,
251
+ "used": totalUsed,
252
+ "size": totalSize,
253
+ "accounts": len(accounts),
254
+ "ok": okCount,
255
+ "failed": len(accounts) - okCount,
256
+ },
257
+ "accounts": accounts,
258
+ }
259
+ if pretty {
260
+ printPretty(accounts, totalRemain, totalUsed, totalSize, okCount)
261
+ return
262
+ }
263
+ raw, _ := json.Marshal(out)
264
+ fmt.Println(string(raw))
265
+ }
266
+
267
+ // printPretty 人类可读日报:四行汇总,无账号明细。
268
+ func printPretty(accounts []accountResult, totalRemain, totalUsed, totalSize int64, okCount int) {
269
+ withBalance := 0
270
+ var failed []string
271
+ for _, a := range accounts {
272
+ if a.OK && a.Remain != nil && *a.Remain > 0 {
273
+ withBalance++
274
+ }
275
+ if !a.OK {
276
+ name := a.Nickname
277
+ if name == "" && len(a.UID) >= 8 {
278
+ name = a.UID[:8]
279
+ }
280
+ failed = append(failed, name+" "+a.Error)
281
+ }
282
+ }
283
+ pct := int64(0)
284
+ if totalSize > 0 {
285
+ pct = totalRemain * 100 / totalSize
286
+ }
287
+ fmt.Printf("📊 WorkBuddy 积分日报\n")
288
+ fmt.Printf("账号: %d/%d\n", withBalance, len(accounts))
289
+ fmt.Printf("总计: %d/%d\n", totalRemain, totalSize)
290
+ fmt.Printf("剩余: %d%%\n", pct)
291
+ for _, f := range failed {
292
+ fmt.Printf("⚠️ %s\n", f)
293
+ }
294
+ }
cmd/login/main.go ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // login.go — WorkBuddy OAuth 登录(设备授权流程,CN realm;--realm=global 供国际版)。
2
+ //
3
+ // 两个子命令,由 login.sh 顺序驱动:
4
+ //
5
+ // login [--realm=cn|global] url → POST /v2/plugin/auth/state?platform=CLI 拿 state+authUrl,
6
+ // state 落 /tmp/wb2api-login-state.json,stdout 打印授权 URL
7
+ // login [--realm=cn|global] poll → 读 state,GET /v2/plugin/auth/token?state= 一次,
8
+ // 成功再 GET /v2/plugin/login/account?state= 拿 uid/nickname,
9
+ // stdout 打印完整 token+account JSON(含 realm 键)
10
+ //
11
+ // --realm 默认 cn。按 realm 切换上游端点与 Origin/Referer:
12
+ //
13
+ // cn → https://copilot.tencent.com(Origin: https://www.codebuddy.cn)
14
+ // global → https://www.workbuddy.ai(Origin: https://www.workbuddy.ai)
15
+ //
16
+ // state 落盘带 realm,poll 读回校验与命令行 --realm 一致(防混域)。
17
+ // 无 PKCE(workbuddy 设备流由服务端签发 state)。
18
+ package main
19
+
20
+ import (
21
+ "bufio"
22
+ "bytes"
23
+ "encoding/json"
24
+ "fmt"
25
+ "io"
26
+ "net/http"
27
+ "net/http/cookiejar"
28
+ "os"
29
+ "path/filepath"
30
+ "strings"
31
+ "time"
32
+
33
+ auth2 "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
34
+ )
35
+
36
+ // 上游常量:CN → copilot.tencent.com(Origin 为 codebuddy.cn);global → www.workbuddy.ai
37
+ // (base 与 Origin/Referer 同域)。端点 URL 由 realmConfig 按 realm 动态拼出,不再硬编码。
38
+ const (
39
+ upstreamBaseCN = "https://copilot.tencent.com"
40
+ upstreamBaseGlobal = "https://www.workbuddy.ai"
41
+ clientUA = "CLI/2.63.2 CodeBuddy/2.63.2"
42
+ originRefererCN = "https://www.codebuddy.cn"
43
+ originRefererGlobal = "https://www.workbuddy.ai"
44
+ )
45
+
46
+ // 登录 state 落盘路径(var 便于测试替换临时文件)
47
+ // Portable across OSes: the upstream hardcoded "/tmp/...", which on
48
+ // Windows resolves to <drive>:\tmp\... and aborts the OAuth flow with
49
+ // "The system cannot find the path specified". os.TempDir() is /tmp on Linux.
50
+ var stateFile = filepath.Join(os.TempDir(), "wb2api-login-state.json")
51
+
52
+ // exitFunc 供测试替换(默认 os.Exit;测试持临时替换为 panic 以进程内捕获 fatal)。
53
+ var exitFunc = os.Exit
54
+
55
+ // realmConfig 按 realm 返回上游 base 与 Origin/Referer origin:global →
56
+ // (www.workbuddy.ai, www.workbuddy.ai);cn/非法/缺省 → (copilot.tencent.com, codebuddy.cn)。
57
+ func realmConfig(realm string) (base, origin string) {
58
+ if realm == realmGlobal {
59
+ return upstreamBaseGlobal, originRefererGlobal
60
+ }
61
+ return upstreamBaseCN, originRefererCN
62
+ }
63
+
64
+ // commonHeaders 按 origin 设置通用请求头(Origin/Referer 随 realm 变化)。
65
+ // 返回 func(*http.Request),由调用方按 realm 选定的 origin 构造一次后复用。
66
+ func commonHeaders(origin string) func(*http.Request) {
67
+ return func(req *http.Request) {
68
+ req.Header.Set("Content-Type", "application/json")
69
+ req.Header.Set("Accept", "application/json, text/plain, */*")
70
+ req.Header.Set("X-Requested-With", "XMLHttpRequest")
71
+ req.Header.Set("Origin", origin)
72
+ req.Header.Set("Referer", origin+"/")
73
+ req.Header.Set("User-Agent", clientUA)
74
+ }
75
+ }
76
+
77
+ // apiEnvelope 与 main.go:429-433 一致
78
+ type apiEnvelope struct {
79
+ Code int `json:"code"`
80
+ Msg string `json:"msg"`
81
+ Data json.RawMessage `json:"data"`
82
+ }
83
+
84
+ // doJSON 与 oauth.go:33-66 一致:{code,msg,data} 信封,code!=0 → error
85
+ func doJSON(client *http.Client, method, fullURL string, headers func(*http.Request), body io.Reader) (json.RawMessage, int, error) {
86
+ req, err := http.NewRequest(method, fullURL, body)
87
+ if err != nil {
88
+ return nil, 0, err
89
+ }
90
+ if headers != nil {
91
+ headers(req)
92
+ } else {
93
+ // 缺省头:CN origin(与原 commonHeaders() 行为一致,零回归)
94
+ commonHeaders(originRefererCN)(req)
95
+ }
96
+ resp, err := client.Do(req)
97
+ if err != nil {
98
+ return nil, 0, err
99
+ }
100
+ defer resp.Body.Close()
101
+ raw, _ := io.ReadAll(resp.Body)
102
+ if resp.StatusCode >= 400 {
103
+ return nil, resp.StatusCode, fmt.Errorf("http_error: upstream %d", resp.StatusCode)
104
+ }
105
+ if resp.StatusCode >= 300 {
106
+ return nil, resp.StatusCode, fmt.Errorf("http_error: upstream redirect %d", resp.StatusCode)
107
+ }
108
+ var env apiEnvelope
109
+ if err := json.Unmarshal(raw, &env); err != nil {
110
+ return nil, resp.StatusCode, fmt.Errorf("parse failed: %w", err)
111
+ }
112
+ if env.Code != 0 {
113
+ return nil, resp.StatusCode, fmt.Errorf("code=%d msg=%s", env.Code, env.Msg)
114
+ }
115
+ return env.Data, resp.StatusCode, nil
116
+ }
117
+
118
+ func fatal(format string, args ...any) {
119
+ fmt.Fprintf(os.Stderr, "login: "+format+"\n", args...)
120
+ exitFunc(1)
121
+ }
122
+
123
+ type loginState struct {
124
+ State string `json:"state"`
125
+ Realm string `json:"realm,omitempty"` // url 落盘时写回的 realm,poll 读回校验防混域
126
+ }
127
+
128
+ // realm 取值枚举(与 internal/auth 的 Realm() 归一化输出一致)。
129
+ const (
130
+ realmCN = "cn"
131
+ realmGlobal = "global"
132
+ )
133
+
134
+ // parseRealmArgs 解析开头的 --realm=cn|global(或分离式 --realm <v>)flag,缺省 cn。
135
+ // 大小写不敏感归一化;非法值/缺值报错。桌椅剩余参数(子命令)顺序不变。
136
+ func parseRealmArgs(args []string) (realm string, rest []string, err error) {
137
+ realm = realmCN
138
+ for i := 0; i < len(args); i++ {
139
+ a := args[i]
140
+ switch {
141
+ case a == "--realm":
142
+ if i+1 >= len(args) {
143
+ return "", nil, fmt.Errorf("--realm requires a value")
144
+ }
145
+ v := strings.ToLower(strings.TrimSpace(args[i+1]))
146
+ if v != realmCN && v != realmGlobal {
147
+ return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", args[i+1])
148
+ }
149
+ realm = v
150
+ i++
151
+ case strings.HasPrefix(a, "--realm="):
152
+ v := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(a, "--realm=")))
153
+ if v != realmCN && v != realmGlobal {
154
+ return "", nil, fmt.Errorf("invalid --realm %q (want cn|global)", v)
155
+ }
156
+ realm = v
157
+ default:
158
+ rest = append(rest, a)
159
+ }
160
+ }
161
+ return realm, rest, nil
162
+ }
163
+
164
+ // resolveRealmInput 把交互式选域的一行输入归一化为 realm(纯函数,login.sh 交互分支
165
+ // 的核心决策,可测)。规则:
166
+ //
167
+ // "1"/"cn"(大小写不敏感)/""(回车默认)→ cn
168
+ // "2"/"global" → global
169
+ // 其他 → ("", false)(调用方回默认 cn)
170
+ func resolveRealmInput(input string) (string, bool) {
171
+ switch strings.ToLower(strings.TrimSpace(input)) {
172
+ case "", "1", "cn":
173
+ return realmCN, true
174
+ case "2", "global":
175
+ return realmGlobal, true
176
+ }
177
+ return "", false
178
+ }
179
+
180
+ // promptRealm 交互式选域:向 out 打印选项提示(out 接 stderr,stdout 留给 realm 本身),
181
+ // 从 in 读一行,返回归一化 realm。非法输入警告后回落 cn;EOF(非交互/管道)回落 cn。
182
+ func promptRealm(in io.Reader, out io.Writer) string {
183
+ fmt.Fprintln(out, "选择登录版本: 1) 国内版(cn) 2) 国际版(global) [默认 1/cn]: ")
184
+ line, err := bufio.NewReader(in).ReadString('\n')
185
+ if err != nil && line == "" {
186
+ // EOF/非交互 → 回落默认 cn
187
+ return realmCN
188
+ }
189
+ if realm, ok := resolveRealmInput(line); ok {
190
+ return realm
191
+ }
192
+ fmt.Fprintln(out, "无效选择,默认国内版 cn")
193
+ return realmCN
194
+ }
195
+
196
+ // validateRealmMatch 校验 state 文件 realm 与命令行 --realm 一致(防混域):
197
+ // state 无 realm(旧文件)放行;非空且不一致 → error。
198
+ func validateRealmMatch(stateRealm, cliRealm string) error {
199
+ if stateRealm != "" && stateRealm != cliRealm {
200
+ return fmt.Errorf("realm mismatch: state file realm=%q, command --realm=%q(url 与 poll 需同一 realm)", stateRealm, cliRealm)
201
+ }
202
+ return nil
203
+ }
204
+
205
+ // runURL 执行 url 子命令:向 upstreamBase 的 state 端点 POST 取授权 URL,
206
+ // state 落盘(带 realm),stdout 打印 authURL。out 接 stdout;stateFile 为落盘路径
207
+ // (可注入临时文件便于测试)。空 realm 视为缺省(调用方已归一)。
208
+ func runURL(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
209
+ headers := commonHeaders(origin)
210
+ data, _, err := doJSON(client, http.MethodPost, base+"/v2/plugin/auth/state?platform=CLI", headers, bytes.NewReader([]byte("{}")))
211
+ if err != nil {
212
+ fatal("auth state failed: %v", err)
213
+ }
214
+ var st struct {
215
+ State string `json:"state"`
216
+ AuthURL string `json:"authUrl"`
217
+ }
218
+ if err := json.Unmarshal(data, &st); err != nil || st.State == "" || st.AuthURL == "" {
219
+ fatal("auth state: missing state or authUrl")
220
+ }
221
+ raw, _ := json.Marshal(loginState{State: st.State, Realm: realm})
222
+ if err := os.WriteFile(statePath, raw, 0o600); err != nil {
223
+ fatal("write state: %v", err)
224
+ }
225
+ fmt.Fprintln(out, st.AuthURL)
226
+ }
227
+
228
+ // runPoll 执行 poll 子命令:读 state 文件(realm 校验),向 upstreamBase 的 token 端点
229
+ // GET 一次,成功再 GET login/account(带 Bearer),stdout 打印完整 token+account JSON。
230
+ // statePath 可注入临时文件便于测试。
231
+ func runPoll(base, origin, realm, statePath string, client *http.Client, out io.Writer) {
232
+ raw, err := os.ReadFile(statePath)
233
+ if err != nil {
234
+ fatal("read state: %v (先跑 login url)", err)
235
+ }
236
+ var ls loginState
237
+ if err := json.Unmarshal(raw, &ls); err != nil {
238
+ fatal("parse state: %v", err)
239
+ }
240
+ // 防混域:state 落盘 realm 与命令行 --realm 不一致则拒绝(url 与 poll 必须同域)
241
+ if err := validateRealmMatch(ls.Realm, realm); err != nil {
242
+ fatal("%v", err)
243
+ }
244
+ headers := commonHeaders(origin)
245
+ // handlePollLogin (oauth.go:108-162):auth/token 是权威登录状态端点,
246
+ // pending 时业务 code 非 0("login ing"),完成时 code=0 + token bundle
247
+ tokRaw, status, errTok := doJSON(client, http.MethodGet, base+"/v2/plugin/auth/token?state="+ls.State, headers, nil)
248
+ if errTok != nil {
249
+ if status == 0 || status >= 500 {
250
+ fatal("token endpoint error: %v", errTok)
251
+ }
252
+ fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
253
+ }
254
+ var tok struct {
255
+ AccessToken string `json:"accessToken"`
256
+ RefreshToken string `json:"refreshToken"`
257
+ ExpiresIn int64 `json:"expiresIn"`
258
+ Domain string `json:"domain"`
259
+ }
260
+ if err := json.Unmarshal(tokRaw, &tok); err != nil || tok.AccessToken == "" {
261
+ fatal("登录未完成(waiting for login)。请确认已在浏览器完成登录再按 y")
262
+ }
263
+ // login/account 拿 uid/nickname(带 Bearer)
264
+ var acct struct {
265
+ UID string `json:"uid"`
266
+ EnterpriseID string `json:"enterpriseId"`
267
+ Nickname string `json:"nickname"`
268
+ }
269
+ acctHeaders := func(r *http.Request) {
270
+ headers(r)
271
+ r.Header.Set("Authorization", "Bearer "+tok.AccessToken)
272
+ }
273
+ if acctRaw, _, errAcct := doJSON(client, http.MethodGet, base+"/v2/plugin/login/account?state="+ls.State, acctHeaders, nil); errAcct == nil {
274
+ _ = json.Unmarshal(acctRaw, &acct)
275
+ }
276
+ oraw, _ := json.Marshal(buildLoginOutput(tok, realm, acct))
277
+ fmt.Fprintln(out, string(oraw))
278
+ os.Remove(statePath)
279
+ }
280
+
281
+ // buildLoginOutput 组装 poll 输出的完整 JSON(login.sh 据此落盘 auth 文件)。
282
+ // realm 永不空:显式 --realm 优先(ResolveRealm 处理),否则按上游返回的 domain 推断——
283
+ // 保证登录落盘的 auth 文件恒带 realm 键。
284
+ func buildLoginOutput(tok struct {
285
+ AccessToken string `json:"accessToken"`
286
+ RefreshToken string `json:"refreshToken"`
287
+ ExpiresIn int64 `json:"expiresIn"`
288
+ Domain string `json:"domain"`
289
+ }, realm string, acct struct {
290
+ UID string `json:"uid"`
291
+ EnterpriseID string `json:"enterpriseId"`
292
+ Nickname string `json:"nickname"`
293
+ }) map[string]any {
294
+ return map[string]any{
295
+ "access_token": tok.AccessToken,
296
+ "refresh_token": tok.RefreshToken,
297
+ "expires_in": tok.ExpiresIn,
298
+ "domain": tok.Domain,
299
+ "realm": auth2.ResolveRealm(realm, tok.Domain),
300
+ "uid": acct.UID,
301
+ "enterprise_id": acct.EnterpriseID,
302
+ "nickname": acct.Nickname,
303
+ }
304
+ }
305
+
306
+ func main() {
307
+ realm, rest, err := parseRealmArgs(os.Args[1:])
308
+ if err != nil {
309
+ fatal("%v (usage: login [--realm=cn|global] <url|poll|realm>)", err)
310
+ }
311
+ if len(rest) < 1 {
312
+ fatal("usage: login [--realm=cn|global] <url|poll>")
313
+ }
314
+ // 每个流程独立 cookie jar(oauth.go:22-29:多账号登录互不串会话)
315
+ jar, _ := cookiejar.New(nil)
316
+ client := &http.Client{Timeout: 30 * time.Second, Jar: jar}
317
+
318
+ base, origin := realmConfig(realm)
319
+
320
+ switch rest[0] {
321
+ case "url":
322
+ runURL(base, origin, realm, stateFile, client, os.Stdout)
323
+
324
+ case "poll":
325
+ runPoll(base, origin, realm, stateFile, client, os.Stdout)
326
+
327
+ case "realm":
328
+ // 交互式选域(login.sh 无 --realm 传参且 stdin 为 tty 时调用)。
329
+ // 提示打到 stderr,stdout 只输出归一化 realm,供 $( ) 捕获。
330
+ realm := promptRealm(os.Stdin, os.Stderr)
331
+ fmt.Println(realm)
332
+
333
+ default:
334
+ fatal("unknown subcommand %q (want url|poll|realm)", rest[0])
335
+ }
336
+ }
cmd/login/realm_test.go ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "bytes"
5
+ "reflect"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // TestParseRealmArgs 覆盖 --realm 参数解析:缺省 cn、等号/分离式写法、
11
+ // 非法值/缺值报错、大小写归一、剥离 flag 后剩余参数保持相对顺序。
12
+ func TestParseRealmArgs(t *testing.T) {
13
+ cases := []struct {
14
+ name string
15
+ args []string
16
+ wantRealm string
17
+ wantRest []string
18
+ wantErr bool
19
+ }{
20
+ {name: "缺省 cn", args: []string{"url"}, wantRealm: "cn", wantRest: []string{"url"}},
21
+ {name: "等号 global 在前", args: []string{"--realm=global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
22
+ {name: "等号 cn 在后", args: []string{"poll", "--realm=cn"}, wantRealm: "cn", wantRest: []string{"poll"}},
23
+ {name: "分离式 global", args: []string{"--realm", "global", "url"}, wantRealm: "global", wantRest: []string{"url"}},
24
+ {name: "非法值报错", args: []string{"--realm=foo", "url"}, wantErr: true},
25
+ {name: "分离式缺值报错", args: []string{"--realm", "url"}, wantErr: true},
26
+ {name: "大小写归一", args: []string{"--realm=GLOBAL", "url"}, wantRealm: "global", wantRest: []string{"url"}},
27
+ }
28
+ for _, c := range cases {
29
+ t.Run(c.name, func(t *testing.T) {
30
+ realm, rest, err := parseRealmArgs(c.args)
31
+ if c.wantErr {
32
+ if err == nil {
33
+ t.Fatalf("parseRealmArgs(%v) err=nil, want error", c.args)
34
+ }
35
+ return
36
+ }
37
+ if err != nil {
38
+ t.Fatalf("parseRealmArgs(%v) err=%v", c.args, err)
39
+ }
40
+ if realm != c.wantRealm {
41
+ t.Errorf("realm=%q want %q", realm, c.wantRealm)
42
+ }
43
+ if !reflect.DeepEqual(rest, c.wantRest) {
44
+ t.Errorf("rest=%v want %v", rest, c.wantRest)
45
+ }
46
+ })
47
+ }
48
+ }
49
+
50
+ // TestResolveRealmInput 覆盖交互式选域的输入→realm 映射(login.sh 交互分支的核心决策):
51
+ // "1"/"cn"/""(回车默认)→ cn;"2"/"global" → global;大小写不敏感;非法 → ("",false)。
52
+ func TestResolveRealmInput(t *testing.T) {
53
+ cases := []struct {
54
+ input string
55
+ want string
56
+ }{
57
+ {input: "1", want: "cn"},
58
+ {input: "cn", want: "cn"},
59
+ {input: "CN", want: "cn"},
60
+ {input: "", want: "cn"}, // 回车默认
61
+ {input: "2", want: "global"},
62
+ {input: "global", want: "global"},
63
+ {input: "GLOBAL", want: "global"},
64
+ }
65
+ for _, c := range cases {
66
+ got, ok := resolveRealmInput(c.input)
67
+ if !ok {
68
+ t.Errorf("resolveRealmInput(%q) ok=false want true", c.input)
69
+ continue
70
+ }
71
+ if got != c.want {
72
+ t.Errorf("resolveRealmInput(%q)=%q want %q", c.input, got, c.want)
73
+ }
74
+ }
75
+ // 非法输入 → (false)。
76
+ for _, bad := range []string{"3", "cnn", "globalx", "foo"} {
77
+ if got, ok := resolveRealmInput(bad); ok {
78
+ t.Errorf("resolveRealmInput(%q)=(%q,true) want (_,false)", bad, got)
79
+ }
80
+ }
81
+ }
82
+
83
+ // TestPromptRealm 覆盖 promptRealm 的 I/O 行为(out 将接 os.Stderr,stdout 只出 realm):
84
+ //
85
+ // 1 / 2 / 回车默认 → 分别输出来 cn / global / cn;均打印"选择登录版本"提示;
86
+ // 非法输入 → 警告并回落 cn;EOF(非交互直接管道)→ 回落 cn。
87
+ func TestPromptRealm(t *testing.T) {
88
+ cases := []struct {
89
+ name string
90
+ input string
91
+ want string
92
+ wantHint bool // 输出中出现"选择登录版本"提示
93
+ }{
94
+ {name: "选 cn", input: "1\n", want: "cn", wantHint: true},
95
+ {name: "选 global", input: "2\n", want: "global", wantHint: true},
96
+ {name: "回车默认 cn", input: "\n", want: "cn", wantHint: true},
97
+ {name: "非法回落 cn", input: "foo\n", want: "cn", wantHint: true},
98
+ {name: "EOF 回落 cn", input: "", want: "cn", wantHint: true},
99
+ }
100
+ for _, c := range cases {
101
+ t.Run(c.name, func(t *testing.T) {
102
+ var out bytes.Buffer
103
+ got := promptRealm(strings.NewReader(c.input), &out)
104
+ if got != c.want {
105
+ t.Errorf("promptRealm(%q)=%q want %q", c.input, got, c.want)
106
+ }
107
+ if c.wantHint && !strings.Contains(out.String(), "选择登录版本") {
108
+ t.Errorf("prompt should contain '选择登录版本', got %q", out.String())
109
+ }
110
+ })
111
+ }
112
+ }
113
+
114
+ // TestRealmSubcommandSelection 通过命令形式验证 realm 子命令(login.sh 交互分支调用):
115
+ // 子命令剥离 --realm,剩余参数为首个 "realm"。
116
+ func TestRealmSubcommandSelection(t *testing.T) {
117
+ realm, rest, err := parseRealmArgs([]string{"realm"})
118
+ if err != nil {
119
+ t.Fatalf("parseRealmArgs(realm) err=%v", err)
120
+ }
121
+ if realm != "cn" {
122
+ t.Errorf("realm default=%q want cn", realm)
123
+ }
124
+ if len(rest) != 1 || rest[0] != "realm" {
125
+ t.Errorf("rest=%v want [realm]", rest)
126
+ }
127
+ }
128
+
129
+ // TestBuildLoginOutputRealmAlwaysSet 登录产物(login.sh 据此落盘 auth 文件)恒含 realm 键:
130
+ // 显式 --realm 优先,缺省时按 domain 推断(echo 出的 global 账号即便未显式指定也带 global)。
131
+ // 这是「登录落盘永远带 realm 标识」契约的测试载体。
132
+ func TestBuildLoginOutputRealmAlwaysSet(t *testing.T) {
133
+ tok := struct {
134
+ AccessToken string `json:"accessToken"`
135
+ RefreshToken string `json:"refreshToken"`
136
+ ExpiresIn int64 `json:"expiresIn"`
137
+ Domain string `json:"domain"`
138
+ }{AccessToken: "at", RefreshToken: "rt", ExpiresIn: 3600, Domain: "www.codebuddy.cn"}
139
+ acct := struct {
140
+ UID string `json:"uid"`
141
+ EnterpriseID string `json:"enterpriseId"`
142
+ Nickname string `json:"nickname"`
143
+ }{UID: "u1", Nickname: "n1"}
144
+
145
+ cases := []struct {
146
+ name string
147
+ realm string
148
+ domain string
149
+ wantRealm string
150
+ }{
151
+ {name: "显式 global 优先", realm: "global", domain: "www.codebuddy.cn", wantRealm: "global"},
152
+ {name: "显式 cn 优先", realm: "cn", domain: "www.workbuddy.ai", wantRealm: "cn"},
153
+ {name: "缺省按 domain 推断 global", realm: "", domain: "www.workbuddy.ai", wantRealm: "global"},
154
+ {name: "缺省空 domain 回落 cn", realm: "", domain: "", wantRealm: "cn"},
155
+ }
156
+ for _, c := range cases {
157
+ t.Run(c.name, func(t *testing.T) {
158
+ tok.Domain = c.domain
159
+ out := buildLoginOutput(tok, c.realm, acct)
160
+ m, ok := out["realm"].(string)
161
+ if !ok {
162
+ t.Fatalf("missing realm key in login output: %v", out)
163
+ }
164
+ if m != c.wantRealm {
165
+ t.Errorf("realm=%q want %q", m, c.wantRealm)
166
+ }
167
+ })
168
+ }
169
+ }
cmd/server/config.go ADDED
@@ -0,0 +1,616 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // config.go 加载 JSON 配置 + 环境变量覆盖。
2
+ package main
3
+
4
+ import (
5
+ "crypto/rand"
6
+ "encoding/base64"
7
+ "encoding/json"
8
+ "fmt"
9
+ "os"
10
+ "path/filepath"
11
+ "strconv"
12
+ "strings"
13
+ "time"
14
+
15
+ "github.com/linguo2625469/workbuddy2api-panel/internal/prompt"
16
+ )
17
+
18
+ // Config 顶层配置。
19
+ type Config struct {
20
+ Listen string `json:"listen"` // ":7863"
21
+ APIKey string `json:"api_key"` // 空 = 不鉴权
22
+ AuthDir string `json:"auth_dir"` // ./auths
23
+ StateFile string `json:"state_file"` // ./data/state.json
24
+
25
+ Panel struct {
26
+ // PackageDetailLimit 积分构成页单账号默认展示的最近到期包数;<=0 回落 5。
27
+ PackageDetailLimit int `json:"package_detail_limit"`
28
+ } `json:"panel"`
29
+
30
+ Logging struct {
31
+ // RequestArchiveEnabled 请求元数据 JSONL 归档开关,缺省 true。
32
+ RequestArchiveEnabled bool `json:"request_archive_enabled"`
33
+ // RequestRetentionDays 归档保留天数,缺省 7;<=0 回落默认。
34
+ RequestRetentionDays int `json:"request_retention_days"`
35
+ // RequestArchiveMaxMB 归档总上限(MiB),缺省 100;<=0 回落默认。
36
+ RequestArchiveMaxMB int `json:"request_archive_max_mb"`
37
+ // RequestClientInfo 是否在请求日志(归档事件 + stdout 流水行 + 面板运行
38
+ // 日志)里记录调用来源:客户端 IP 与 User-Agent。缺省 true。
39
+ //
40
+ // 为什么做成开关而不是恒开:来源信息是排查"谁在打网关"的第一手线索,
41
+ // 但它比 token 计数敏感(IP 属个人信息),共享部署/多租户场景可能需要
42
+ // 关掉。关闭后 Event.ClientIP/UserAgent 保持为空,归档里不出现该字段。
43
+ // 热生效(经 livecfg 快照),无需重启。
44
+ RequestClientInfo bool `json:"request_client_info"`
45
+ } `json:"logging"`
46
+
47
+ Cooldown struct {
48
+ // hard_credit / err_threshold / err_cooldown 三个历史键已退役:
49
+ // 硬冷却固定为次日 04:00(CooldownUntilTomorrow4AM),连续错误语义并入熔断器。
50
+ // 旧 config 中的这些键因 JSON 未知字段而自然忽略,不报错。
51
+ SoftRate string `json:"soft_rate"` // "600s",软限流冷却基数
52
+ // SoftRateMax 软冷却指数退避的封顶,默认 "2h"。
53
+ // 空值回落默认,非法值报错(处理风格同 soft_rate)。
54
+ SoftRateMax string `json:"soft_rate_max"` // "2h"
55
+ } `json:"cooldown"`
56
+
57
+ Schedule struct {
58
+ CheckinHours []int `json:"checkin_hours"` // [9,21]
59
+ TravelHours []int `json:"travel_hours"` // [9,21]
60
+ ActivityHours []int `json:"activity_hours"` // [10]
61
+ KeepaliveHours []int `json:"keepalive_hours"` // [22]
62
+ BlackcatHours []int `json:"blackcat_hours"` // [23] 夜猫子窗口(23:00–08:00 计数)
63
+ GrowthHours []int `json:"growth_hours"` // [1] 成长任务队列(Sequential 族每日零点解锁,01:00 自动扫描执行)
64
+ // CheckinEnabled/TravelEnabled/ActivityEnabled/KeepaliveEnabled/BlackcatEnabled 显式禁用开关(缺省 true)。
65
+ //
66
+ // 为什么用独立 bool 而不是空数组/哨兵值表意"禁用":
67
+ // - 空数组与 null 在老语义里已被"未配置 → 回落默认"占用,改判会静默翻转
68
+ // 所有老 config 的行为(用户只想删掉一行,结果关掉了签到);bool 缺省 true
69
+ // 则对老配置零影响,向后完全兼容。
70
+ // - 开关与取值解耦:禁用时仍保留用户显式配的小时,重新启用无需补配。
71
+ // - 无需猜测哨兵([-1] 之类),非法小时一律报错并提示改用本开关。
72
+ // 旧 config 里的该键因 JSON 未知字段而自然忽略,不报错。
73
+ CheckinEnabled bool `json:"checkin_enabled"` // 缺省 true;false = 关签到
74
+ TravelEnabled bool `json:"travel_enabled"` // 缺省 true;false = 完全停猫猫旅行
75
+ ActivityEnabled bool `json:"activity_enabled"` // 缺省 true;false = 停活跃上报
76
+ KeepaliveEnabled bool `json:"keepalive_enabled"` // 缺省 true;false = 关 token 保活
77
+ BlackcatEnabled bool `json:"blackcat_enabled"` // 缺省 true;false = 关夜猫子
78
+ GrowthEnabled bool `json:"growth_enabled"` // 缺省 true;false = 关成长任务自动排程
79
+
80
+ // 余额后台周期刷新:两次签到时点之间 credits 也能保持新鲜(面板/状态观测用)。
81
+ // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。
82
+ BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭
83
+ BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5
84
+ } `json:"schedule"`
85
+
86
+ Global struct {
87
+ // Enabled global realm 路由开关。缺省 true:Realm() 正常把 realm=global/
88
+ // domain=workbuddy.ai 的账号判为 global 并路由 global base/路径。
89
+ // 显式 "enabled": false 关闭(逃生门,纯 CN 锁定:即便 auth 写了 realm=global
90
+ // 也不路由,auth.Realm() 双保险的第一道闸)。纯 CN 部署行为不变:CN 账号
91
+ // 恒判 cn,global base 只在 realm=global 的账号上被使用。
92
+ Enabled bool `json:"enabled"`
93
+ // ChatBase / BillingBase 国际版上游 base 覆盖;空 = 回落内置默认
94
+ // https://www.workbuddy.ai(internal/upstream.defaultGlobalBase)。
95
+ ChatBase string `json:"chat_base"`
96
+ BillingBase string `json:"billing_base"`
97
+ } `json:"global"`
98
+
99
+ Upstream struct {
100
+ // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。
101
+ TimeoutSeconds int `json:"timeout_seconds"`
102
+ // HeaderTimeoutSeconds 聊天 SSE 首字节前(响应头)上限;<=0 回落 TimeoutSeconds。
103
+ HeaderTimeoutSeconds int `json:"header_timeout_seconds"`
104
+ // IdleTimeoutSeconds 聊天 SSE 流中空闲上限(活跃吐数据续命不掐);<=0 回落默认 300。
105
+ IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
106
+ // UserAgent 出站 User-Agent 显式覆盖(非空时全路径生效,优先于默认三段式)。
107
+ // 全部出站请求生效:chat/refresh/checkin/balance/report/travel/FetchModels。
108
+ // 默认值已对齐官方 WorkBuddy 桌面形态(三段式),用户仍可配完全自定义值改写。
109
+ UserAgent string `json:"user_agent"`
110
+ // ClientVersion WorkBuddy 客户端版本段(出站 UA 的 `WorkBuddy/<ver>` 与归属头
111
+ // X-IDE-Version)。空 = 内置默认(对齐官方 5.5.4 分发包)。
112
+ ClientVersion string `json:"client_version"`
113
+ // CliVersion 出站 UA 中 `CLI/<ver>` 段的版本。空 = 内置默认(官方内置 CLI 2.137.1)。
114
+ CliVersion string `json:"cli_version"`
115
+ // ClientName 用量归属头取值(X-Product / X-IDE-Name / X-IDE-Type / X-IDE-Version)。
116
+ // 空 = 旧行为 X-Product="SaaS" 不设 X-IDE-*;配 "WorkBuddy" 则四头跟随。
117
+ ClientName string `json:"client_name"`
118
+ // DeviceToken 设备风控 Token(X-Device-Token 头)全局兜底;空 = 不注入。
119
+ // 每号 auth 文件的 device_token 键优先于本项。
120
+ DeviceToken string `json:"device_token"`
121
+ // DeviceTokenFile device token 文件路径兜底(宿主落盘的桌面端 token,5 分钟读取缓存)。
122
+ DeviceTokenFile string `json:"device_token_file"`
123
+ // PassthroughIP 是否透传客户端 IP 给上游(默认 false,反代安全边界)。
124
+ PassthroughIP bool `json:"passthrough_ip"`
125
+ } `json:"upstream"`
126
+
127
+ Features struct {
128
+ // SanitizeBlacklistFingerprints 出站请求体黑名单指纹脱敏(默认 true;false 完全还原)。
129
+ SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"`
130
+ } `json:"features"`
131
+
132
+ Prompt struct {
133
+ // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded);
134
+ // custom = 网关用自有系统提示词替换客户端 system/developer;
135
+ // append = 两者并用:开头连续 system/developer 块后插网关 system,既有消息逐字不动(issue #129)。
136
+ Mode string `json:"mode"` // "passthrough" / "custom" / "append"
137
+ // File 提示词文件路径;空 = 内置默认 defaultprompt.md;
138
+ // 路径非空但不可读 → 启动报错(fail fast,避免静默回落到内置默认)。
139
+ File string `json:"file"`
140
+ } `json:"prompt"`
141
+
142
+ // PromptText 解析后的系统提示词文本(custom/append 模式使用)。
143
+ PromptText string `json:"-"`
144
+
145
+ Upstash struct {
146
+ URL string `json:"url"` // 空 = 纯内存模式;支持完整 rediss:// URL 或 https://xxx.upstash.io host
147
+ Token string `json:"token"` // url 非完整连接串时用于组装 rediss://default:<token>@<host>:6379
148
+ } `json:"upstash"`
149
+
150
+ Pool struct {
151
+ MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限
152
+ MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2
153
+ BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3
154
+ BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m"
155
+ BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h"
156
+ // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈
157
+ // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。
158
+ DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5
159
+ DegradeCooldown string `json:"degrade_cooldown"` // 降权时长(固定,非指数退避),默认 "10m"
160
+ DegradeCooldownMax string `json:"degrade_cooldown_max"` // 降权时长的上限钳制,默认 "2h"(仅当 cooldown 超该值才钳制)
161
+ IdleWeightPerHour float64 `json:"idle_weight_per_hour"` // 闲置补偿:每小时未用 +0.5 权重
162
+ IdleWeightMax float64 `json:"idle_weight_max"` // 闲置补偿封顶,默认 5.0
163
+ // PreferExpiring 最早到期优先路由开关,默认 true。开启且 expiring_soon 窗口内
164
+ // 存在有效批次时,按最早到期时间排序;关闭后完全不使用到期信息选号。
165
+ PreferExpiring bool `json:"prefer_expiring"`
166
+ // ExpiringSoon 快过期积分窗口(如 "168h"=7天):签到/余额刷新时,到期时间在
167
+ // 此窗口内的积分进入优先集,再按最早到期排序。空/0 = 禁用该路由门槛。
168
+ ExpiringSoon string `json:"expiring_soon"`
169
+ // CostExploreInterval costTier 条件探索窗口(issue #136 方案 a′):tier 0
170
+ // 垄断层存在且 tier 1 有成员时,距上次探索 ≥ 窗口则本次 pick 生效层切
171
+ // tier 1-only(探索=搭车改道,零新增上游请求;成功即毕业,失败走既有
172
+ // 错误策略)。默认 "30m"(≤48 次/天/模型);"0" 关停(完全回到现状行为);
173
+ // 空值回落默认。
174
+ CostExploreInterval string `json:"cost_explore_interval"`
175
+ // CreditFloor 积分保底:账号余额低于该值时,对实测收费模型(tier 2)不再
176
+ // 参与选号——防止收费请求把余额打穿、连免费模型都 402 冷却到次日签到。
177
+ // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。
178
+ // 默认 0 = 关闭;负值钳 0。
179
+ CreditFloor int64 `json:"credit_floor"`
180
+ } `json:"pool"`
181
+
182
+ SessionSticky struct {
183
+ Enabled bool `json:"enabled"` // 默认 true
184
+ TTL string `json:"ttl"` // 会话绑定 TTL,默认 "30m"
185
+ GCInterval string `json:"gc_interval"` // 会话 GC 周期,默认 "5m"
186
+ } `json:"session_sticky"`
187
+
188
+ // 解析后
189
+ SoftRateDur time.Duration `json:"-"`
190
+ SoftRateMaxDur time.Duration `json:"-"`
191
+ BreakerCooldownDur time.Duration `json:"-"`
192
+ BreakerCooldownMaxD time.Duration `json:"-"`
193
+ DegradeCooldownDur time.Duration `json:"-"`
194
+ DegradeCooldownMaxD time.Duration `json:"-"`
195
+ SessionTTL time.Duration `json:"-"`
196
+ SessionGCInterval time.Duration `json:"-"`
197
+ BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false)
198
+ ExpiringSoonDur time.Duration `json:"-"`
199
+ // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。
200
+ CostExploreIntervalDur time.Duration `json:"-"`
201
+ }
202
+
203
+ // Default 默认配置。
204
+ func Default() *Config {
205
+ c := &Config{
206
+ Listen: ":7863",
207
+ APIKey: "",
208
+ AuthDir: "./auths",
209
+ StateFile: "./data/state.json",
210
+ }
211
+ c.Cooldown.SoftRate = "600s"
212
+ c.Cooldown.SoftRateMax = "2h"
213
+ c.Panel.PackageDetailLimit = 5
214
+ c.Logging.RequestArchiveEnabled = true
215
+ c.Logging.RequestRetentionDays = 7
216
+ c.Logging.RequestArchiveMaxMB = 100
217
+ // 缺省 true 靠显式赋值实现(同 Schedule 开关):JSON 里键缺席时字段保留此值,
218
+ // 只有显式 false 才关闭来源记录。
219
+ c.Logging.RequestClientInfo = true
220
+ c.Schedule.CheckinHours = []int{9, 21}
221
+ c.Schedule.TravelHours = []int{9, 21}
222
+ c.Schedule.ActivityHours = []int{10}
223
+ c.Schedule.KeepaliveHours = []int{22}
224
+ c.Schedule.BlackcatHours = []int{23}
225
+ c.Schedule.GrowthHours = []int{1}
226
+ // 开关「缺省 true」靠这几行实现:Load 先取 Default() 再 json.Unmarshal 覆盖,
227
+ // 键缺席(或为 null)时字段原样保留 true,只有显式 false 才关。
228
+ c.Schedule.CheckinEnabled = true
229
+ c.Schedule.GrowthEnabled = true
230
+ c.Schedule.TravelEnabled = true
231
+ c.Schedule.ActivityEnabled = true
232
+ c.Schedule.KeepaliveEnabled = true
233
+ c.Schedule.BlackcatEnabled = true
234
+ c.Schedule.BalanceRefreshEnabled = true
235
+ c.Schedule.BalanceRefreshMinutes = 5
236
+ c.Upstream.TimeoutSeconds = 120
237
+ // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。
238
+ c.Upstream.HeaderTimeoutSeconds = 0
239
+ c.Upstream.IdleTimeoutSeconds = 0
240
+ // Global.Enabled 缺省 true(纯 CN 行为不变:CN 账号恒判 cn,global base 不被使用);
241
+ // ChatBase/BillingBase 缺省空(回落内置默认)。
242
+ c.Global.Enabled = true
243
+ c.Features.SanitizeBlacklistFingerprints = true
244
+ c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择)
245
+ c.Pool.MaxInFlight = 3
246
+ // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复
247
+ // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键
248
+ // 的 0 没有合理语义,回退分档默认最稳)。
249
+ c.Pool.MaxInFlightGlobal = 2
250
+ c.Pool.BreakerThreshold = 3
251
+ c.Pool.BreakerCooldown = "30m"
252
+ c.Pool.BreakerCooldownMax = "6h"
253
+ c.Pool.DegradeThreshold = 5
254
+ c.Pool.DegradeCooldown = "10m"
255
+ c.Pool.DegradeCooldownMax = "2h"
256
+ c.Pool.IdleWeightPerHour = 0.5
257
+ c.Pool.IdleWeightMax = 5.0
258
+ c.Pool.PreferExpiring = true
259
+ c.Pool.ExpiringSoon = "168h" // 快过期窗口默认 7 天:官方活动奖励积分多在两周内过期
260
+ // costTier 探索默认 30m(issue #136:垄断破除 + 搭车改道零新增请求);"0" 关停。
261
+ c.Pool.CostExploreInterval = "30m"
262
+ c.SessionSticky.Enabled = true
263
+ c.SessionSticky.TTL = "30m"
264
+ c.SessionSticky.GCInterval = "5m"
265
+ return c
266
+ }
267
+
268
+ // Load 从文件读,再用 WB2A_* env 覆盖。
269
+ func Load(path string) (*Config, error) {
270
+ c := Default()
271
+ if path != "" {
272
+ // 目录检查:Docker bind mount 在宿主机文件缺失时会静默创建同名目录,
273
+ // 直接 ReadFile 会报 "Incorrect function" 之类晦涩错误,这里给出可操作提示。
274
+ if st, statErr := os.Stat(path); statErr == nil && st.IsDir() {
275
+ return nil, fmt.Errorf("config %s 是目录而非文件——"+
276
+ "Docker 部署时若宿主机缺少 config.json,bind mount 会创建同名目录。"+
277
+ "请先 `cp config.example.json config.json` 或删除该目录(程序会自动生成配置)", path)
278
+ }
279
+ raw, err := os.ReadFile(path)
280
+ if err != nil {
281
+ return nil, fmt.Errorf("read config: %w", err)
282
+ }
283
+ if _, err := ParseConfigInto(raw, c); err != nil {
284
+ return nil, err
285
+ }
286
+ }
287
+ applyEnv(c)
288
+ if err := c.normalize(); err != nil {
289
+ return nil, err
290
+ }
291
+ return c, nil
292
+ }
293
+
294
+ // ParseConfigInto 把 JSON 覆盖到 c 上并 normalize(不做 env、不读文件)。
295
+ // 面板保存配置走这条路径:与 Load 完全同一套解析/校验逻辑,避免两处漂移。
296
+ func ParseConfigInto(raw []byte, c *Config) (*Config, error) {
297
+ if err := json.Unmarshal(raw, c); err != nil {
298
+ return nil, fmt.Errorf("parse config: %w", err)
299
+ }
300
+ if err := c.normalize(); err != nil {
301
+ return nil, err
302
+ }
303
+ return c, nil
304
+ }
305
+
306
+ // ParseConfig 基于默认值解析一段配置 JSON(等价于 Load 的文件分支,但不读环境变量)。
307
+ func ParseConfig(raw []byte) (*Config, error) {
308
+ return ParseConfigInto(raw, Default())
309
+ }
310
+
311
+ // WriteDefault 在 path 落一份推荐配置(首次运行自动生成,双击即开免手工复制样例)。
312
+ // 值取自 Default()(含超时/熔断/签到排程等推荐值),api_key 用 crypto/rand 随机生成:
313
+ // 安全默认优于示例占位符(listen 绑定 0.0.0.0,空 key 会把网关裸暴露给局域网)。
314
+ // 返回生成的 key 供启动日志透出。已存在时经 O_EXCL 原子拒绝,绝不改写用户配置。
315
+ func WriteDefault(path string) (string, error) {
316
+ raw := make([]byte, 18)
317
+ if _, err := rand.Read(raw); err != nil {
318
+ return "", fmt.Errorf("gen api_key: %w", err)
319
+ }
320
+ key := "sk-" + base64.RawURLEncoding.EncodeToString(raw)
321
+ c := Default()
322
+ c.APIKey = key
323
+ _ = c.normalize() // Default() 全合法,normalize 仅补齐 header/idle 超时的展示值
324
+ out, err := json.MarshalIndent(c, "", " ")
325
+ if err != nil {
326
+ return "", fmt.Errorf("marshal config: %w", err)
327
+ }
328
+ if dir := filepath.Dir(path); dir != "" && dir != "." {
329
+ if err := os.MkdirAll(dir, 0o755); err != nil {
330
+ return "", fmt.Errorf("mkdir config dir: %w", err)
331
+ }
332
+ }
333
+ // O_EXCL 原子拒绝覆盖:即使调用方漏判"不存在",也绝不悄悄改写用户已有配置。
334
+ f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
335
+ if err != nil {
336
+ return "", fmt.Errorf("write config: %w", err)
337
+ }
338
+ defer f.Close()
339
+ if _, err := f.Write(out); err != nil {
340
+ return "", fmt.Errorf("write config: %w", err)
341
+ }
342
+ return key, nil
343
+ }
344
+
345
+ func applyEnv(c *Config) {
346
+ if v := os.Getenv("WB2A_LISTEN"); v != "" {
347
+ c.Listen = v
348
+ }
349
+ if v := os.Getenv("WB2A_API_KEY"); v != "" {
350
+ c.APIKey = v
351
+ }
352
+ if v := os.Getenv("WB2A_AUTH_DIR"); v != "" {
353
+ c.AuthDir = v
354
+ }
355
+ if v := os.Getenv("WB2A_STATE_FILE"); v != "" {
356
+ c.StateFile = v
357
+ }
358
+ if v := os.Getenv("WB2A_SOFT_RATE"); v != "" {
359
+ c.Cooldown.SoftRate = v
360
+ }
361
+ if v := os.Getenv("WB2A_SOFT_RATE_MAX"); v != "" {
362
+ c.Cooldown.SoftRateMax = v
363
+ }
364
+ if v := os.Getenv("WB2A_TIMEOUT_SECONDS"); v != "" {
365
+ if n, err := strconv.Atoi(v); err == nil {
366
+ c.Upstream.TimeoutSeconds = n
367
+ }
368
+ }
369
+ if v := os.Getenv("WB2A_HEADER_TIMEOUT_SECONDS"); v != "" {
370
+ if n, err := strconv.Atoi(v); err == nil {
371
+ c.Upstream.HeaderTimeoutSeconds = n
372
+ }
373
+ }
374
+ if v := os.Getenv("WB2A_IDLE_TIMEOUT_SECONDS"); v != "" {
375
+ if n, err := strconv.Atoi(v); err == nil {
376
+ c.Upstream.IdleTimeoutSeconds = n
377
+ }
378
+ }
379
+ if v := os.Getenv("WB2A_USER_AGENT"); v != "" {
380
+ c.Upstream.UserAgent = v
381
+ }
382
+ if v := os.Getenv("WB2A_CLIENT_VERSION"); v != "" {
383
+ c.Upstream.ClientVersion = v
384
+ }
385
+ if v := os.Getenv("WB2A_CLI_VERSION"); v != "" {
386
+ c.Upstream.CliVersion = v
387
+ }
388
+ if v := os.Getenv("WB2A_CLIENT_NAME"); v != "" {
389
+ c.Upstream.ClientName = v
390
+ }
391
+ if v := os.Getenv("WB2A_DEVICE_TOKEN"); v != "" {
392
+ c.Upstream.DeviceToken = v
393
+ }
394
+ if v := os.Getenv("WB2A_DEVICE_TOKEN_FILE"); v != "" {
395
+ c.Upstream.DeviceTokenFile = v
396
+ }
397
+ if v := os.Getenv("WB2A_PASSTHROUGH_IP"); v != "" {
398
+ if b, err := strconv.ParseBool(v); err == nil {
399
+ c.Upstream.PassthroughIP = b
400
+ }
401
+ }
402
+ if v := os.Getenv("WB2A_SANITIZE_FINGERPRINTS"); v != "" {
403
+ if b, err := strconv.ParseBool(v); err == nil {
404
+ c.Features.SanitizeBlacklistFingerprints = b
405
+ }
406
+ }
407
+ if v := os.Getenv("WB2A_PROMPT_MODE"); v != "" {
408
+ c.Prompt.Mode = v
409
+ }
410
+ if v := os.Getenv("WB2A_PROMPT_FILE"); v != "" {
411
+ c.Prompt.File = v
412
+ }
413
+ if v := os.Getenv("WB2A_EXPIRING_SOON"); v != "" {
414
+ c.Pool.ExpiringSoon = v
415
+ }
416
+ if v := os.Getenv("WB2A_PREFER_EXPIRING"); v != "" {
417
+ if b, err := strconv.ParseBool(v); err == nil {
418
+ c.Pool.PreferExpiring = b
419
+ }
420
+ }
421
+ }
422
+
423
+ func (c *Config) normalize() error {
424
+ var err error
425
+ if c.Panel.PackageDetailLimit <= 0 {
426
+ c.Panel.PackageDetailLimit = 5
427
+ }
428
+ if c.Logging.RequestRetentionDays <= 0 {
429
+ c.Logging.RequestRetentionDays = 7
430
+ }
431
+ if c.Logging.RequestArchiveMaxMB <= 0 {
432
+ c.Logging.RequestArchiveMaxMB = 100
433
+ }
434
+ if c.SoftRateDur, err = time.ParseDuration(c.Cooldown.SoftRate); err != nil {
435
+ return fmt.Errorf("cooldown.soft_rate: %w", err)
436
+ }
437
+ // 空值回落默认 2h(Default() 已置值;此兜底覆盖显式 "" 与 Default() 被绕过的场景)。
438
+ if c.Cooldown.SoftRateMax == "" {
439
+ c.Cooldown.SoftRateMax = "2h"
440
+ }
441
+ if c.SoftRateMaxDur, err = time.ParseDuration(c.Cooldown.SoftRateMax); err != nil {
442
+ return fmt.Errorf("cooldown.soft_rate_max: %w", err)
443
+ }
444
+ if c.BreakerCooldownDur, err = time.ParseDuration(c.Pool.BreakerCooldown); err != nil {
445
+ return fmt.Errorf("pool.breaker_cooldown: %w", err)
446
+ }
447
+ if c.BreakerCooldownMaxD, err = time.ParseDuration(c.Pool.BreakerCooldownMax); err != nil {
448
+ return fmt.Errorf("pool.breaker_cooldown_max: %w", err)
449
+ }
450
+ if c.DegradeCooldownDur, err = time.ParseDuration(c.Pool.DegradeCooldown); err != nil {
451
+ return fmt.Errorf("pool.degrade_cooldown: %w", err)
452
+ }
453
+ if c.DegradeCooldownMaxD, err = time.ParseDuration(c.Pool.DegradeCooldownMax); err != nil {
454
+ return fmt.Errorf("pool.degrade_cooldown_max: %w", err)
455
+ }
456
+ if c.SessionTTL, err = time.ParseDuration(c.SessionSticky.TTL); err != nil {
457
+ return fmt.Errorf("session_sticky.ttl: %w", err)
458
+ }
459
+ if c.SessionGCInterval, err = time.ParseDuration(c.SessionSticky.GCInterval); err != nil {
460
+ return fmt.Errorf("session_sticky.gc_interval: %w", err)
461
+ }
462
+ // 快过期窗口:空 = 禁用(ExpiringSoonDur 0);非空必须可解析(拼写错误 fail fast)。
463
+ if c.Pool.ExpiringSoon != "" {
464
+ if c.ExpiringSoonDur, err = time.ParseDuration(c.Pool.ExpiringSoon); err != nil {
465
+ return fmt.Errorf("pool.expiring_soon: %w", err)
466
+ }
467
+ }
468
+ if c.ExpiringSoonDur < 0 {
469
+ c.ExpiringSoonDur = 0
470
+ c.Pool.ExpiringSoon = "0"
471
+ }
472
+ // costTier 探索窗口(issue #136):空值回落默认 30m(Default 已置;此兜底覆盖
473
+ // 显式 "");"0" 是合法值(关停,完全回到现状行为),不回落;负值钳 0 同关停
474
+ //("−5m" 无合理语义)。
475
+ if c.Pool.CostExploreInterval == "" {
476
+ c.Pool.CostExploreInterval = "30m"
477
+ }
478
+ if c.CostExploreIntervalDur, err = time.ParseDuration(c.Pool.CostExploreInterval); err != nil {
479
+ return fmt.Errorf("pool.cost_explore_interval: %w", err)
480
+ }
481
+ if c.CostExploreIntervalDur < 0 {
482
+ c.CostExploreIntervalDur = 0
483
+ }
484
+ // 积分保底:负值钳 0(= 关闭)。0 是合法默认(关闭),无需空值回落。
485
+ if c.Pool.CreditFloor < 0 {
486
+ c.Pool.CreditFloor = 0
487
+ }
488
+ if c.Pool.BreakerThreshold <= 0 {
489
+ c.Pool.BreakerThreshold = 3
490
+ }
491
+ // 连败降权参数缺省归一(非法/未设置回落默认,与 breaker_threshold 同风格)。
492
+ if c.Pool.DegradeThreshold <= 0 {
493
+ c.Pool.DegradeThreshold = 5
494
+ }
495
+ if c.Pool.DegradeCooldown == "" {
496
+ c.Pool.DegradeCooldown = "10m"
497
+ }
498
+ if c.Pool.DegradeCooldownMax == "" {
499
+ c.Pool.DegradeCooldownMax = "2h"
500
+ }
501
+ // global 在途分档:0/负数视为未设置回落默认 2(WAF 403 修复 P1-1)。
502
+ if c.Pool.MaxInFlightGlobal <= 0 {
503
+ c.Pool.MaxInFlightGlobal = 2
504
+ }
505
+ if c.Pool.IdleWeightPerHour <= 0 {
506
+ c.Pool.IdleWeightPerHour = 0.5
507
+ }
508
+ if c.Pool.IdleWeightMax <= 0 {
509
+ c.Pool.IdleWeightMax = 5.0
510
+ }
511
+ if c.Upstream.TimeoutSeconds <= 0 {
512
+ c.Upstream.TimeoutSeconds = 120
513
+ }
514
+ // header 缺省回落 timeout(保"首字节前换号"既有语义);idle 缺省走内置大值。
515
+ // 任务书约定:0 一律视为"未设置"走默认,真正的"禁用"留待后续(避免歧义)。
516
+ if c.Upstream.HeaderTimeoutSeconds <= 0 {
517
+ c.Upstream.HeaderTimeoutSeconds = c.Upstream.TimeoutSeconds
518
+ }
519
+ if c.Upstream.IdleTimeoutSeconds <= 0 {
520
+ c.Upstream.IdleTimeoutSeconds = 300
521
+ }
522
+ if !strings.HasPrefix(c.Listen, ":") && !strings.Contains(c.Listen, ":") {
523
+ c.Listen = ":" + c.Listen
524
+ }
525
+ // 空数组与 null 反序列化后覆盖掉 Default() 的排程值(键缺席才保留),在此补齐。
526
+ // 空 = 未配置 → 回落默认;「禁用」一律走 *_enabled=false,两者互不混淆。
527
+ if len(c.Schedule.CheckinHours) == 0 {
528
+ c.Schedule.CheckinHours = []int{9, 21}
529
+ }
530
+ if len(c.Schedule.TravelHours) == 0 {
531
+ c.Schedule.TravelHours = []int{9, 21}
532
+ }
533
+ if len(c.Schedule.ActivityHours) == 0 {
534
+ c.Schedule.ActivityHours = []int{10}
535
+ }
536
+ if len(c.Schedule.KeepaliveHours) == 0 {
537
+ c.Schedule.KeepaliveHours = []int{22}
538
+ }
539
+ if len(c.Schedule.BlackcatHours) == 0 {
540
+ c.Schedule.BlackcatHours = []int{23}
541
+ }
542
+ if len(c.Schedule.GrowthHours) == 0 {
543
+ c.Schedule.GrowthHours = []int{1}
544
+ }
545
+ // 余额后台刷新:启用时 minutes<=0 回落默认 5;关闭时 interval 保持 0(不启动)。
546
+ if c.Schedule.BalanceRefreshEnabled {
547
+ if c.Schedule.BalanceRefreshMinutes <= 0 {
548
+ c.Schedule.BalanceRefreshMinutes = 5
549
+ }
550
+ c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute
551
+ }
552
+ if err := c.validateScheduleHours(); err != nil {
553
+ return err
554
+ }
555
+ return c.normalizePrompt()
556
+ }
557
+
558
+ // normalizePrompt 校验 prompt.mode 并按 file 加载提示词文本(custom/append 模式)。
559
+ //
560
+ // mode 非法(非 passthrough/custom/append)启动报错,避免静默回落到某一分支;
561
+ // custom/append 模式下 file 非空但不可读 → 报错(fail fast),file 空 → 用内置默认
562
+ // (两模式共用同一加载路径,PromptText 均非空)。
563
+ // passthrough 模式不加载文本(透传客户端原始 system,文本在降级时用 prompt.Degraded)。
564
+ func (c *Config) normalizePrompt() error {
565
+ switch m := strings.ToLower(strings.TrimSpace(c.Prompt.Mode)); m {
566
+ case "", "passthrough":
567
+ c.Prompt.Mode = "passthrough"
568
+ case "custom":
569
+ c.Prompt.Mode = "custom"
570
+ case "append":
571
+ c.Prompt.Mode = "append"
572
+ default:
573
+ return fmt.Errorf("prompt.mode: %q 不是合法值(passthrough / custom / append)", c.Prompt.Mode)
574
+ }
575
+ if c.Prompt.Mode == "custom" || c.Prompt.Mode == "append" {
576
+ text, err := prompt.Load(c.Prompt.Mode, c.Prompt.File)
577
+ if err != nil {
578
+ return err
579
+ }
580
+ c.PromptText = text
581
+ }
582
+ return nil
583
+ }
584
+
585
+ // validateScheduleHours 校验排程小时落在 0-23。
586
+ //
587
+ // 为什么不用 `[-1]` 之类的哨兵值表意"禁用":非法小时被静默吞掉时,用户以为关掉了签到,
588
+ // 实际可能被当成另一个整点照常执行;这里直接快速失败,并在错误信息里指向正确的开关
589
+ // (checkin_enabled / keepalive_enabled),避免用户靠猜哨兵值来配。
590
+ func (c *Config) validateScheduleHours() error {
591
+ if err := checkHourRange("schedule.checkin_hours", "checkin_enabled", c.Schedule.CheckinHours); err != nil {
592
+ return err
593
+ }
594
+ if err := checkHourRange("schedule.travel_hours", "travel_enabled", c.Schedule.TravelHours); err != nil {
595
+ return err
596
+ }
597
+ if err := checkHourRange("schedule.activity_hours", "activity_enabled", c.Schedule.ActivityHours); err != nil {
598
+ return err
599
+ }
600
+ if err := checkHourRange("schedule.keepalive_hours", "keepalive_enabled", c.Schedule.KeepaliveHours); err != nil {
601
+ return err
602
+ }
603
+ if err := checkHourRange("schedule.blackcat_hours", "blackcat_enabled", c.Schedule.BlackcatHours); err != nil {
604
+ return err
605
+ }
606
+ return checkHourRange("schedule.growth_hours", "growth_enabled", c.Schedule.GrowthHours)
607
+ }
608
+
609
+ func checkHourRange(field, switchKey string, hours []int) error {
610
+ for _, h := range hours {
611
+ if h < 0 || h > 23 {
612
+ return fmt.Errorf("%s: %d 不是合法小时(0-23);如要关闭该任务请设 schedule.%s=false", field, h, switchKey)
613
+ }
614
+ }
615
+ return nil
616
+ }
cmd/server/config_test.go ADDED
@@ -0,0 +1,816 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+ )
11
+
12
+ func TestDefault(t *testing.T) {
13
+ c := Default()
14
+ if c.Listen != ":7863" {
15
+ t.Errorf("listen=%s", c.Listen)
16
+ }
17
+ if err := c.normalize(); err != nil {
18
+ t.Fatalf("normalize: %v", err)
19
+ }
20
+ if c.SoftRateDur.Seconds() != 600 {
21
+ t.Errorf("soft=%v want 600s", c.SoftRateDur)
22
+ }
23
+ }
24
+
25
+ func TestPanelPackageDetailLimit(t *testing.T) {
26
+ c := Default()
27
+ if err := c.normalize(); err != nil {
28
+ t.Fatalf("normalize: %v", err)
29
+ }
30
+ if c.Panel.PackageDetailLimit != 5 {
31
+ t.Fatalf("default package_detail_limit=%d want 5", c.Panel.PackageDetailLimit)
32
+ }
33
+
34
+ configured, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":8}}`))
35
+ if err != nil {
36
+ t.Fatalf("parse configured limit: %v", err)
37
+ }
38
+ if configured.Panel.PackageDetailLimit != 8 {
39
+ t.Fatalf("configured package_detail_limit=%d want 8", configured.Panel.PackageDetailLimit)
40
+ }
41
+
42
+ fallback, err := ParseConfig([]byte(`{"panel":{"package_detail_limit":0}}`))
43
+ if err != nil {
44
+ t.Fatalf("parse fallback limit: %v", err)
45
+ }
46
+ if fallback.Panel.PackageDetailLimit != 5 {
47
+ t.Fatalf("fallback package_detail_limit=%d want 5", fallback.Panel.PackageDetailLimit)
48
+ }
49
+ }
50
+
51
+ func TestLoggingDefaults(t *testing.T) {
52
+ c := Default()
53
+ if err := c.normalize(); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ if !c.Logging.RequestArchiveEnabled || c.Logging.RequestRetentionDays != 7 || c.Logging.RequestArchiveMaxMB != 100 {
57
+ t.Fatalf("logging defaults = %+v", c.Logging)
58
+ }
59
+ // 来源记录(IP/UA)缺省开启:键缺席时必须保持 true,只有显式 false 才关闭。
60
+ if !c.Logging.RequestClientInfo {
61
+ t.Fatalf("request_client_info default = false, want true: %+v", c.Logging)
62
+ }
63
+ configured, err := ParseConfig([]byte(`{"logging":{"request_archive_enabled":false,"request_retention_days":30,"request_archive_max_mb":500}}`))
64
+ if err != nil {
65
+ t.Fatal(err)
66
+ }
67
+ if configured.Logging.RequestArchiveEnabled || configured.Logging.RequestRetentionDays != 30 || configured.Logging.RequestArchiveMaxMB != 500 {
68
+ t.Fatalf("configured logging = %+v", configured.Logging)
69
+ }
70
+ off, err := ParseConfig([]byte(`{"logging":{"request_client_info":false}}`))
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ if off.Logging.RequestClientInfo {
75
+ t.Fatalf("explicit false ignored: %+v", off.Logging)
76
+ }
77
+ fallback, err := ParseConfig([]byte(`{"logging":{"request_retention_days":0,"request_archive_max_mb":0}}`))
78
+ if err != nil {
79
+ t.Fatal(err)
80
+ }
81
+ if fallback.Logging.RequestRetentionDays != 7 || fallback.Logging.RequestArchiveMaxMB != 100 {
82
+ t.Fatalf("logging fallback = %+v", fallback.Logging)
83
+ }
84
+ }
85
+
86
+ func TestLoadFile(t *testing.T) {
87
+ dir := t.TempDir()
88
+ fp := filepath.Join(dir, "c.json")
89
+ os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
90
+ c, err := Load(fp)
91
+ if err != nil {
92
+ t.Fatal(err)
93
+ }
94
+ if c.Listen != ":9999" || c.APIKey != "k" {
95
+ t.Errorf("c=%+v", c)
96
+ }
97
+ }
98
+
99
+ func TestEnvOverride(t *testing.T) {
100
+ t.Setenv("WB2A_LISTEN", ":7777")
101
+ t.Setenv("WB2A_API_KEY", "envkey")
102
+ c, err := Load("")
103
+ if err != nil {
104
+ t.Fatal(err)
105
+ }
106
+ if c.Listen != ":7777" || c.APIKey != "envkey" {
107
+ t.Errorf("c=%+v", c)
108
+ }
109
+ }
110
+
111
+ func TestBadDuration(t *testing.T) {
112
+ dir := t.TempDir()
113
+ fp := filepath.Join(dir, "c.json")
114
+ os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"not-a-duration"}}`), 0o600)
115
+ if _, err := Load(fp); err == nil {
116
+ t.Fatal("want error for bad duration")
117
+ }
118
+ }
119
+
120
+ func TestHardCreditKeyIgnored(t *testing.T) {
121
+ // 退役的 hard_credit 键作为 JSON 未知字段被自然忽略,不报错。
122
+ dir := t.TempDir()
123
+ fp := filepath.Join(dir, "c.json")
124
+ os.WriteFile(fp, []byte(`{"cooldown":{"hard_credit":"not-a-duration","soft_rate":"30s"}}`), 0o600)
125
+ c, err := Load(fp)
126
+ if err != nil {
127
+ t.Fatalf("hard_credit must be ignored (not validated): %v", err)
128
+ }
129
+ if c.SoftRateDur.Seconds() != 30 {
130
+ t.Errorf("soft_rate=%v want 30s", c.SoftRateDur)
131
+ }
132
+ }
133
+
134
+ func TestNewPoolConfigDefaults(t *testing.T) {
135
+ c := Default()
136
+ if err := c.normalize(); err != nil {
137
+ t.Fatalf("normalize: %v", err)
138
+ }
139
+ if c.Pool.MaxInFlight != 3 {
140
+ t.Errorf("max_in_flight=%d want 3", c.Pool.MaxInFlight)
141
+ }
142
+ if c.Pool.BreakerThreshold != 3 {
143
+ t.Errorf("breaker_threshold=%d want 3", c.Pool.BreakerThreshold)
144
+ }
145
+ if c.BreakerCooldownDur.Minutes() != 30 {
146
+ t.Errorf("breaker_cooldown=%v want 30m", c.BreakerCooldownDur)
147
+ }
148
+ if c.BreakerCooldownMaxD.Hours() != 6 {
149
+ t.Errorf("breaker_cooldown_max=%v want 6h", c.BreakerCooldownMaxD)
150
+ }
151
+ if c.Pool.IdleWeightPerHour != 0.5 || c.Pool.IdleWeightMax != 5.0 {
152
+ t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
153
+ }
154
+ if !c.Pool.PreferExpiring || c.ExpiringSoonDur != 7*24*time.Hour {
155
+ t.Errorf("expiring defaults: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
156
+ }
157
+ if c.SoftRateMaxDur.Hours() != 2 {
158
+ t.Errorf("soft_rate_max=%v want 2h", c.SoftRateMaxDur)
159
+ }
160
+ if !c.SessionSticky.Enabled {
161
+ t.Error("session_sticky.enabled want true")
162
+ }
163
+ if c.SessionTTL.Minutes() != 30 || c.SessionGCInterval.Minutes() != 5 {
164
+ t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
165
+ }
166
+ if c.Upstash.URL != "" || c.Upstash.Token != "" {
167
+ t.Errorf("upstash default should be empty: %+v", c.Upstash)
168
+ }
169
+ }
170
+
171
+ func TestPoolConfigParsedFromFile(t *testing.T) {
172
+ dir := t.TempDir()
173
+ fp := filepath.Join(dir, "c.json")
174
+ os.WriteFile(fp, []byte(`{
175
+ "upstash":{"url":"https://foo.upstash.io","token":"tok"},
176
+ "pool":{
177
+ "max_in_flight":5,
178
+ "breaker_threshold":4,
179
+ "breaker_cooldown":"10m",
180
+ "breaker_cooldown_max":"2h",
181
+ "idle_weight_per_hour":0.7,
182
+ "idle_weight_max":8.0,
183
+ "prefer_expiring":false,
184
+ "expiring_soon":"72h"
185
+ },
186
+ "session_sticky":{"enabled":false,"ttl":"1h","gc_interval":"2m"}
187
+ }`), 0o600)
188
+ c, err := Load(fp)
189
+ if err != nil {
190
+ t.Fatal(err)
191
+ }
192
+ if c.Upstash.URL != "https://foo.upstash.io" || c.Upstash.Token != "tok" {
193
+ t.Errorf("upstash=%+v", c.Upstash)
194
+ }
195
+ if c.Pool.MaxInFlight != 5 || c.Pool.BreakerThreshold != 4 {
196
+ t.Errorf("pool=%+v", c.Pool)
197
+ }
198
+ if c.BreakerCooldownDur.Minutes() != 10 || c.BreakerCooldownMaxD.Hours() != 2 {
199
+ t.Errorf("breaker durations=%v/%v", c.BreakerCooldownDur, c.BreakerCooldownMaxD)
200
+ }
201
+ if c.Pool.IdleWeightPerHour != 0.7 || c.Pool.IdleWeightMax != 8.0 {
202
+ t.Errorf("idle weights=%v/%v", c.Pool.IdleWeightPerHour, c.Pool.IdleWeightMax)
203
+ }
204
+ if c.Pool.PreferExpiring || c.ExpiringSoonDur != 72*time.Hour {
205
+ t.Errorf("expiring override: enabled=%v window=%v", c.Pool.PreferExpiring, c.ExpiringSoonDur)
206
+ }
207
+ if c.SessionSticky.Enabled {
208
+ t.Error("session_sticky.enabled want false from file")
209
+ }
210
+ if c.SessionTTL.Hours() != 1 || c.SessionGCInterval.Minutes() != 2 {
211
+ t.Errorf("session durations=%v/%v", c.SessionTTL, c.SessionGCInterval)
212
+ }
213
+ }
214
+
215
+ func TestSoftRateMaxParsedFromFile(t *testing.T) {
216
+ dir := t.TempDir()
217
+ fp := filepath.Join(dir, "c.json")
218
+ os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"5m","soft_rate_max":"45m"}}`), 0o600)
219
+ c, err := Load(fp)
220
+ if err != nil {
221
+ t.Fatal(err)
222
+ }
223
+ if c.SoftRateDur.Minutes() != 5 {
224
+ t.Errorf("soft_rate=%v want 5m", c.SoftRateDur)
225
+ }
226
+ if c.SoftRateMaxDur.Minutes() != 45 {
227
+ t.Errorf("soft_rate_max=%v want 45m", c.SoftRateMaxDur)
228
+ }
229
+ }
230
+
231
+ func TestLegacyConfigKeepsPreferExpiringEnabled(t *testing.T) {
232
+ dir := t.TempDir()
233
+ fp := filepath.Join(dir, "c.json")
234
+ os.WriteFile(fp, []byte(`{"pool":{"idle_weight_max":3}}`), 0o600)
235
+ c, err := Load(fp)
236
+ if err != nil {
237
+ t.Fatal(err)
238
+ }
239
+ if !c.Pool.PreferExpiring {
240
+ t.Fatal("missing prefer_expiring must preserve default true")
241
+ }
242
+ }
243
+
244
+ func TestNegativeExpiringSoonClampsToDisabled(t *testing.T) {
245
+ dir := t.TempDir()
246
+ fp := filepath.Join(dir, "c.json")
247
+ os.WriteFile(fp, []byte(`{"pool":{"expiring_soon":"-1h"}}`), 0o600)
248
+ c, err := Load(fp)
249
+ if err != nil {
250
+ t.Fatal(err)
251
+ }
252
+ if c.ExpiringSoonDur != 0 || c.Pool.ExpiringSoon != "0" {
253
+ t.Fatalf("negative window=%v/%q want 0/0", c.ExpiringSoonDur, c.Pool.ExpiringSoon)
254
+ }
255
+ }
256
+
257
+ func TestSoftRateMaxEmptyFallsBackToDefault(t *testing.T) {
258
+ // 键缺席 → Default() 的 2h 保留(空串无法 ParseDuration)。
259
+ dir := t.TempDir()
260
+ fp := filepath.Join(dir, "c.json")
261
+ os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate":"90s"}}`), 0o600)
262
+ c, err := Load(fp)
263
+ if err != nil {
264
+ t.Fatal(err)
265
+ }
266
+ if c.SoftRateMaxDur.Hours() != 2 {
267
+ t.Errorf("soft_rate_max=%v want 2h fallback", c.SoftRateMaxDur)
268
+ }
269
+ }
270
+
271
+ func TestBadSoftRateMax(t *testing.T) {
272
+ dir := t.TempDir()
273
+ fp := filepath.Join(dir, "c.json")
274
+ os.WriteFile(fp, []byte(`{"cooldown":{"soft_rate_max":"oops"}}`), 0o600)
275
+ if _, err := Load(fp); err == nil {
276
+ t.Fatal("want error for bad soft_rate_max")
277
+ }
278
+ }
279
+
280
+ func TestBadBreakerCooldown(t *testing.T) {
281
+ dir := t.TempDir()
282
+ fp := filepath.Join(dir, "c.json")
283
+ os.WriteFile(fp, []byte(`{"pool":{"breaker_cooldown":"oops"}}`), 0o600)
284
+ if _, err := Load(fp); err == nil {
285
+ t.Fatal("want error for bad breaker_cooldown")
286
+ }
287
+ }
288
+
289
+ func TestUpstreamTimeoutDefaults(t *testing.T) {
290
+ // 默认:header 回落 timeout,idle 回落 300。
291
+ c := Default()
292
+ if err := c.normalize(); err != nil {
293
+ t.Fatalf("normalize: %v", err)
294
+ }
295
+ if c.Upstream.TimeoutSeconds != 120 {
296
+ t.Errorf("timeout_seconds=%d want 120", c.Upstream.TimeoutSeconds)
297
+ }
298
+ if c.Upstream.HeaderTimeoutSeconds != 120 {
299
+ t.Errorf("header_timeout_seconds=%d want fallback 120", c.Upstream.HeaderTimeoutSeconds)
300
+ }
301
+ if c.Upstream.IdleTimeoutSeconds != 300 {
302
+ t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
303
+ }
304
+ }
305
+
306
+ func TestUpstreamHeaderFallsBackToTimeout(t *testing.T) {
307
+ // 只设 timeout_seconds:header 回落同值,idle 回落 300。
308
+ dir := t.TempDir()
309
+ fp := filepath.Join(dir, "c.json")
310
+ os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":60}}`), 0o600)
311
+ c, err := Load(fp)
312
+ if err != nil {
313
+ t.Fatal(err)
314
+ }
315
+ if c.Upstream.HeaderTimeoutSeconds != 60 {
316
+ t.Errorf("header_timeout_seconds=%d want fallback 60", c.Upstream.HeaderTimeoutSeconds)
317
+ }
318
+ if c.Upstream.IdleTimeoutSeconds != 300 {
319
+ t.Errorf("idle_timeout_seconds=%d want fallback 300", c.Upstream.IdleTimeoutSeconds)
320
+ }
321
+ }
322
+
323
+ func TestUpstreamExplicitHeaderIdle(t *testing.T) {
324
+ dir := t.TempDir()
325
+ fp := filepath.Join(dir, "c.json")
326
+ os.WriteFile(fp, []byte(`{"upstream":{"timeout_seconds":120,"header_timeout_seconds":30,"idle_timeout_seconds":600}}`), 0o600)
327
+ c, err := Load(fp)
328
+ if err != nil {
329
+ t.Fatal(err)
330
+ }
331
+ if c.Upstream.HeaderTimeoutSeconds != 30 {
332
+ t.Errorf("header_timeout_seconds=%d want 30", c.Upstream.HeaderTimeoutSeconds)
333
+ }
334
+ if c.Upstream.IdleTimeoutSeconds != 600 {
335
+ t.Errorf("idle_timeout_seconds=%d want 600", c.Upstream.IdleTimeoutSeconds)
336
+ }
337
+ }
338
+
339
+ func TestUpstreamEnvOverride(t *testing.T) {
340
+ t.Setenv("WB2A_HEADER_TIMEOUT_SECONDS", "45")
341
+ t.Setenv("WB2A_IDLE_TIMEOUT_SECONDS", "900")
342
+ c, err := Load("")
343
+ if err != nil {
344
+ t.Fatal(err)
345
+ }
346
+ if c.Upstream.HeaderTimeoutSeconds != 45 {
347
+ t.Errorf("header_timeout_seconds=%d want env 45", c.Upstream.HeaderTimeoutSeconds)
348
+ }
349
+ if c.Upstream.IdleTimeoutSeconds != 900 {
350
+ t.Errorf("idle_timeout_seconds=%d want env 900", c.Upstream.IdleTimeoutSeconds)
351
+ }
352
+ }
353
+
354
+ // TestRetiredTravelIntervalKeyIgnored 退役的 travel_interval_minutes 键按未知字段忽略,不报错。
355
+ func TestRetiredTravelIntervalKeyIgnored(t *testing.T) {
356
+ dir := t.TempDir()
357
+ fp := filepath.Join(dir, "c.json")
358
+ os.WriteFile(fp, []byte(`{"schedule":{"travel_interval_minutes":15,"checkin_hours":[9]}}`), 0o600)
359
+ c, err := Load(fp)
360
+ if err != nil {
361
+ t.Fatalf("retired key should not fail load: %v", err)
362
+ }
363
+ if len(c.Schedule.CheckinHours) != 1 || c.Schedule.CheckinHours[0] != 9 {
364
+ t.Errorf("checkin_hours=%v want [9](同段其余键照常生效)", c.Schedule.CheckinHours)
365
+ }
366
+ }
367
+
368
+ // TestScheduleEnabledByDefault 四个任务的 enabled 开关默认均为 true:
369
+ // 老 config 不写这些键,行为必须与从前完全一致。
370
+ func TestScheduleEnabledByDefault(t *testing.T) {
371
+ c := Default()
372
+ if err := c.normalize(); err != nil {
373
+ t.Fatalf("normalize: %v", err)
374
+ }
375
+ if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
376
+ t.Errorf("enabled defaults want true/true, got %v/%v",
377
+ c.Schedule.CheckinEnabled, c.Schedule.KeepaliveEnabled)
378
+ }
379
+ if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
380
+ t.Errorf("travel/activity enabled defaults want true/true, got %v/%v",
381
+ c.Schedule.TravelEnabled, c.Schedule.ActivityEnabled)
382
+ }
383
+ if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
384
+ t.Errorf("travel_hours=%v want [9,21]", c.Schedule.TravelHours)
385
+ }
386
+ if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
387
+ t.Errorf("activity_hours=%v want [10]", c.Schedule.ActivityHours)
388
+ }
389
+ }
390
+
391
+ // TestScheduleLegacyConfigKeepsRunning 老 config(只写签到/保活小时数组,无新键)加载后仍是启用态,
392
+ // 新开关缺省 true、新 hours 回落默认——对老配置零影响。
393
+ func TestScheduleLegacyConfigKeepsRunning(t *testing.T) {
394
+ dir := t.TempDir()
395
+ fp := filepath.Join(dir, "c.json")
396
+ os.WriteFile(fp, []byte(`{"schedule":{"checkin_hours":[9,21],"keepalive_hours":[22]}}`), 0o600)
397
+ c, err := Load(fp)
398
+ if err != nil {
399
+ t.Fatal(err)
400
+ }
401
+ if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
402
+ t.Errorf("legacy config must stay enabled: %+v", c.Schedule)
403
+ }
404
+ if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
405
+ t.Errorf("new switches must default true on legacy config: %+v", c.Schedule)
406
+ }
407
+ if len(c.Schedule.CheckinHours) != 2 {
408
+ t.Errorf("checkin_hours=%v", c.Schedule.CheckinHours)
409
+ }
410
+ // 新 hours 缺省 → 回落默认(非空)。
411
+ if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
412
+ t.Errorf("travel_hours=%v want default [9,21]", c.Schedule.TravelHours)
413
+ }
414
+ if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
415
+ t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
416
+ }
417
+ }
418
+
419
+ // TestScheduleExplicitDisable 显式 checkin_enabled=false 即可真正关掉签到
420
+ // (issue #27 边界:此前无论怎么配小时都关不掉)。
421
+ func TestScheduleExplicitDisable(t *testing.T) {
422
+ dir := t.TempDir()
423
+ fp := filepath.Join(dir, "c.json")
424
+ os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"keepalive_enabled":false}}`), 0o600)
425
+ c, err := Load(fp)
426
+ if err != nil {
427
+ t.Fatal(err)
428
+ }
429
+ if c.Schedule.CheckinEnabled || c.Schedule.KeepaliveEnabled {
430
+ t.Errorf("want both disabled: %+v", c.Schedule)
431
+ }
432
+ // 小时数组仍回落默认值(禁用与默认值互不干扰:重新启用无需补配小时)。
433
+ if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
434
+ t.Errorf("checkin_hours=%v want default [9 21] even when disabled", c.Schedule.CheckinHours)
435
+ }
436
+ if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
437
+ t.Errorf("keepalive_hours=%v want default [22] even when disabled", c.Schedule.KeepaliveHours)
438
+ }
439
+ }
440
+
441
+ // TestScheduleTravelActivityExplicitDisable 显式关闭旅行/活跃上报开关。
442
+ func TestScheduleTravelActivityExplicitDisable(t *testing.T) {
443
+ dir := t.TempDir()
444
+ fp := filepath.Join(dir, "c.json")
445
+ os.WriteFile(fp, []byte(`{"schedule":{"travel_enabled":false,"activity_enabled":false}}`), 0o600)
446
+ c, err := Load(fp)
447
+ if err != nil {
448
+ t.Fatal(err)
449
+ }
450
+ if c.Schedule.TravelEnabled || c.Schedule.ActivityEnabled {
451
+ t.Errorf("want travel/activity disabled: %+v", c.Schedule)
452
+ }
453
+ // 签到/保活开关缺省 true(互不干扰)。
454
+ if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
455
+ t.Errorf("checkin/keepalive should stay enabled: %+v", c.Schedule)
456
+ }
457
+ // hours 仍回落默认。
458
+ if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
459
+ t.Errorf("travel_hours=%v want default [9,21] even when disabled", c.Schedule.TravelHours)
460
+ }
461
+ if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
462
+ t.Errorf("activity_hours=%v want default [10] even when disabled", c.Schedule.ActivityHours)
463
+ }
464
+ }
465
+
466
+ // TestScheduleTravelActivityInvalidHoursRejected 旅行/活跃非法小时报错并指向正确开关。
467
+ func TestScheduleTravelActivityInvalidHoursRejected(t *testing.T) {
468
+ cases := []struct{ body, wantSwitch string }{
469
+ {`{"schedule":{"travel_hours":[25]}}`, "travel_enabled"},
470
+ {`{"schedule":{"travel_hours":[-1]}}`, "travel_enabled"},
471
+ {`{"schedule":{"activity_hours":[24]}}`, "activity_enabled"},
472
+ {`{"schedule":{"activity_hours":[-1]}}`, "activity_enabled"},
473
+ }
474
+ for _, tc := range cases {
475
+ dir := t.TempDir()
476
+ fp := filepath.Join(dir, "c.json")
477
+ os.WriteFile(fp, []byte(tc.body), 0o600)
478
+ _, err := Load(fp)
479
+ if err == nil {
480
+ t.Fatalf("want error for %s", tc.body)
481
+ }
482
+ if !strings.Contains(err.Error(), tc.wantSwitch) {
483
+ t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
484
+ }
485
+ }
486
+ }
487
+
488
+ // TestScheduleTravelActivityExplicitHours 显式配置旅行/活跃小时。
489
+ func TestScheduleTravelActivityExplicitHours(t *testing.T) {
490
+ dir := t.TempDir()
491
+ fp := filepath.Join(dir, "c.json")
492
+ os.WriteFile(fp, []byte(`{"schedule":{"travel_hours":[9,21],"activity_hours":[11]}}`), 0o600)
493
+ c, err := Load(fp)
494
+ if err != nil {
495
+ t.Fatal(err)
496
+ }
497
+ if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
498
+ t.Errorf("travel_hours=%v want [9 21]", c.Schedule.TravelHours)
499
+ }
500
+ if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 11 {
501
+ t.Errorf("activity_hours=%v want [11]", c.Schedule.ActivityHours)
502
+ }
503
+ }
504
+
505
+ // TestScheduleDisableKeepsExplicitHours 禁用不擦除用户配置的小时(便于原样恢复)。
506
+ func TestScheduleDisableKeepsExplicitHours(t *testing.T) {
507
+ dir := t.TempDir()
508
+ fp := filepath.Join(dir, "c.json")
509
+ os.WriteFile(fp, []byte(`{"schedule":{"checkin_enabled":false,"checkin_hours":[10,14]}}`), 0o600)
510
+ c, err := Load(fp)
511
+ if err != nil {
512
+ t.Fatal(err)
513
+ }
514
+ if c.Schedule.CheckinEnabled {
515
+ t.Error("checkin should be disabled")
516
+ }
517
+ if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 10 || c.Schedule.CheckinHours[1] != 14 {
518
+ t.Errorf("explicit hours must be preserved: %v", c.Schedule.CheckinHours)
519
+ }
520
+ }
521
+
522
+ // TestScheduleEmptyHoursFallsBackToDefault 空数组 / null / 缺省都视同「未配置」→ 回落默认。
523
+ func TestScheduleEmptyHoursFallsBackToDefault(t *testing.T) {
524
+ cases := map[string]string{
525
+ "absent": `{}`,
526
+ "empty": `{"schedule":{}}`,
527
+ "null": `{"schedule":{"checkin_hours":null,"keepalive_hours":null,"travel_hours":null,"activity_hours":null}}`,
528
+ "emptyarr": `{"schedule":{"checkin_hours":[],"keepalive_hours":[],"travel_hours":[],"activity_hours":[]}}`,
529
+ }
530
+ for name, body := range cases {
531
+ t.Run(name, func(t *testing.T) {
532
+ dir := t.TempDir()
533
+ fp := filepath.Join(dir, "c.json")
534
+ os.WriteFile(fp, []byte(body), 0o600)
535
+ c, err := Load(fp)
536
+ if err != nil {
537
+ t.Fatal(err)
538
+ }
539
+ if len(c.Schedule.CheckinHours) != 2 || c.Schedule.CheckinHours[0] != 9 || c.Schedule.CheckinHours[1] != 21 {
540
+ t.Errorf("checkin_hours=%v want default [9 21]", c.Schedule.CheckinHours)
541
+ }
542
+ if len(c.Schedule.KeepaliveHours) != 1 || c.Schedule.KeepaliveHours[0] != 22 {
543
+ t.Errorf("keepalive_hours=%v want default [22]", c.Schedule.KeepaliveHours)
544
+ }
545
+ if len(c.Schedule.TravelHours) != 2 || c.Schedule.TravelHours[0] != 9 || c.Schedule.TravelHours[1] != 21 {
546
+ t.Errorf("travel_hours=%v want default [9 21]", c.Schedule.TravelHours)
547
+ }
548
+ if len(c.Schedule.ActivityHours) != 1 || c.Schedule.ActivityHours[0] != 10 {
549
+ t.Errorf("activity_hours=%v want default [10]", c.Schedule.ActivityHours)
550
+ }
551
+ if !c.Schedule.CheckinEnabled || !c.Schedule.KeepaliveEnabled {
552
+ t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
553
+ }
554
+ if !c.Schedule.TravelEnabled || !c.Schedule.ActivityEnabled {
555
+ t.Errorf("empty hours must not imply disabled: %+v", c.Schedule)
556
+ }
557
+ })
558
+ }
559
+ }
560
+
561
+ // TestScheduleInvalidHourRejected 非法小时快速��败:指向正确的禁用开关,避免用户
562
+ // 猜测哨兵值([-1] 之类)被静默当成"改到别的整点"。
563
+ func TestScheduleInvalidHourRejected(t *testing.T) {
564
+ cases := []struct{ body, wantSwitch string }{
565
+ {`{"schedule":{"checkin_hours":[25]}}`, "checkin_enabled"},
566
+ {`{"schedule":{"checkin_hours":[-1]}}`, "checkin_enabled"},
567
+ {`{"schedule":{"keepalive_hours":[-1]}}`, "keepalive_enabled"},
568
+ }
569
+ for _, tc := range cases {
570
+ dir := t.TempDir()
571
+ fp := filepath.Join(dir, "c.json")
572
+ os.WriteFile(fp, []byte(tc.body), 0o600)
573
+ _, err := Load(fp)
574
+ if err == nil {
575
+ t.Fatalf("want error for %s", tc.body)
576
+ }
577
+ if !strings.Contains(err.Error(), tc.wantSwitch) {
578
+ t.Errorf("error for %s should point at schedule.%s: %v", tc.body, tc.wantSwitch, err)
579
+ }
580
+ }
581
+ }
582
+
583
+ func TestBadSessionTTL(t *testing.T) {
584
+ dir := t.TempDir()
585
+ fp := filepath.Join(dir, "c.json")
586
+ os.WriteFile(fp, []byte(`{"session_sticky":{"ttl":"oops"}}`), 0o600)
587
+ if _, err := Load(fp); err == nil {
588
+ t.Fatal("want error for bad session_sticky.ttl")
589
+ }
590
+ }
591
+
592
+ func TestWriteDefault(t *testing.T) {
593
+ dir := t.TempDir()
594
+ fp := filepath.Join(dir, "sub", "config.json") // 顺带验证父目录自动创建
595
+ key, err := WriteDefault(fp)
596
+ if err != nil {
597
+ t.Fatal(err)
598
+ }
599
+ // key 形如 sk-<24字符随机串>,两次生成不重复
600
+ if !strings.HasPrefix(key, "sk-") || len(key) < 20 {
601
+ t.Errorf("key=%q want sk-<random>", key)
602
+ }
603
+ if key2, _ := WriteDefault(filepath.Join(dir, "another.json")); key2 == key {
604
+ t.Errorf("two generated keys identical: %q", key)
605
+ }
606
+ // 落盘文件可被 Load 正常加载,推荐值齐备且 api_key 生效
607
+ c, err := Load(fp)
608
+ if err != nil {
609
+ t.Fatalf("load generated config: %v", err)
610
+ }
611
+ if c.APIKey != key {
612
+ t.Errorf("api_key=%q want %q", c.APIKey, key)
613
+ }
614
+ if c.Listen != ":7863" || c.AuthDir != "./auths" || c.StateFile != "./data/state.json" {
615
+ t.Errorf("generated defaults off: %+v", c)
616
+ }
617
+ if len(c.Schedule.CheckinHours) == 0 || !c.Schedule.CheckinEnabled {
618
+ t.Errorf("generated schedule off: %+v", c.Schedule)
619
+ }
620
+ // 已存在的文件不覆盖:二次写入同一路径必须报错
621
+ if _, err := WriteDefault(fp); err == nil {
622
+ t.Error("WriteDefault must refuse to overwrite existing file")
623
+ }
624
+ }
625
+
626
+ func TestBalanceRefreshDefaults(t *testing.T) {
627
+ // 缺省:启用 + 30 分钟
628
+ c := Default()
629
+ if err := c.normalize(); err != nil {
630
+ t.Fatal(err)
631
+ }
632
+ if !c.Schedule.BalanceRefreshEnabled || c.BalanceRefreshInterval != 5*time.Minute {
633
+ t.Errorf("default balance refresh: enabled=%v interval=%v", c.Schedule.BalanceRefreshEnabled, c.BalanceRefreshInterval)
634
+ }
635
+ // 显式配置 10 分钟
636
+ dir := t.TempDir()
637
+ fp := filepath.Join(dir, "c.json")
638
+ os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":10}}`), 0o600)
639
+ c2, err := Load(fp)
640
+ if err != nil {
641
+ t.Fatal(err)
642
+ }
643
+ if c2.BalanceRefreshInterval != 10*time.Minute {
644
+ t.Errorf("interval=%v want 10m", c2.BalanceRefreshInterval)
645
+ }
646
+ // 显式关闭:interval 归零(不启动)
647
+ os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_enabled":false}}`), 0o600)
648
+ c3, err := Load(fp)
649
+ if err != nil {
650
+ t.Fatal(err)
651
+ }
652
+ if c3.BalanceRefreshInterval != 0 {
653
+ t.Errorf("disabled interval=%v want 0", c3.BalanceRefreshInterval)
654
+ }
655
+ // 启用但 minutes<=0 → 回落默认 30
656
+ os.WriteFile(fp, []byte(`{"schedule":{"balance_refresh_minutes":-5}}`), 0o600)
657
+ c4, err := Load(fp)
658
+ if err != nil {
659
+ t.Fatal(err)
660
+ }
661
+ if c4.BalanceRefreshInterval != 5*time.Minute {
662
+ t.Errorf("fallback interval=%v want 30m", c4.BalanceRefreshInterval)
663
+ }
664
+ }
665
+
666
+ // TestPromptDefaultPassthrough 默认 prompt.mode=passthrough(对齐上游:透传客户端
667
+ // 原始 system 是更保守的缺省);custom 由用户显式选择,此时 PromptText 为内置默认(非空)。
668
+ func TestPromptDefaultPassthrough(t *testing.T) {
669
+ c, err := Load("")
670
+ if err != nil {
671
+ t.Fatal(err)
672
+ }
673
+ if c.Prompt.Mode != "passthrough" {
674
+ t.Errorf("prompt.mode=%q want passthrough", c.Prompt.Mode)
675
+ }
676
+ // passthrough 不加载提示词文本(透传客户端 system);切 custom 时 normalize 会加载。
677
+ }
678
+
679
+ // TestPromptExplicitPassthrough passthrough 模式不加载文本(透传客户端原始 system)。
680
+ func TestPromptExplicitPassthrough(t *testing.T) {
681
+ dir := t.TempDir()
682
+ fp := filepath.Join(dir, "c.json")
683
+ os.WriteFile(fp, []byte(`{"prompt":{"mode":"passthrough"}}`), 0o600)
684
+ c, err := Load(fp)
685
+ if err != nil {
686
+ t.Fatal(err)
687
+ }
688
+ if c.Prompt.Mode != "passthrough" {
689
+ t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
690
+ }
691
+ if c.PromptText != "" {
692
+ t.Errorf("passthrough should not load PromptText, got len=%d", len(c.PromptText))
693
+ }
694
+ }
695
+
696
+ // TestPromptInvalidMode 非法 mode 启动报错。
697
+ func TestPromptInvalidMode(t *testing.T) {
698
+ dir := t.TempDir()
699
+ fp := filepath.Join(dir, "c.json")
700
+ os.WriteFile(fp, []byte(`{"prompt":{"mode":"bogus"}}`), 0o600)
701
+ if _, err := Load(fp); err == nil {
702
+ t.Fatal("want error for invalid prompt.mode")
703
+ }
704
+ }
705
+
706
+ // TestPromptFileMissing 文件路径非空但不���在 → 启动报错(fail fast)。
707
+ func TestPromptFileMissing(t *testing.T) {
708
+ dir := t.TempDir()
709
+ fp := filepath.Join(dir, "c.json")
710
+ os.WriteFile(fp, []byte(`{"prompt":{"mode":"custom","file":"/nonexistent/p.md"}}`), 0o600)
711
+ if _, err := Load(fp); err == nil {
712
+ t.Fatal("want error for missing prompt file")
713
+ }
714
+ }
715
+
716
+ // TestPromptFileOverride 自定义 file 覆盖内置默认。
717
+ func TestPromptFileOverride(t *testing.T) {
718
+ dir := t.TempDir()
719
+ pf := filepath.Join(dir, "my.md")
720
+ want := "我的自定义人格入口"
721
+ os.WriteFile(pf, []byte(want), 0o600)
722
+ cf := filepath.Join(dir, "c.json")
723
+ // 用 json.Marshal 拼路径:Windows 反斜杠必须转义,手工字符串拼接会产出非法 JSON。
724
+ cfgJSON, err := json.Marshal(map[string]any{"prompt": map[string]any{"mode": "custom", "file": pf}})
725
+ if err != nil {
726
+ t.Fatal(err)
727
+ }
728
+ os.WriteFile(cf, cfgJSON, 0o600)
729
+ c, err := Load(cf)
730
+ if err != nil {
731
+ t.Fatal(err)
732
+ }
733
+ if c.PromptText != want {
734
+ t.Errorf("PromptText=%q want %q", c.PromptText, want)
735
+ }
736
+ }
737
+
738
+ // TestPromptEnvOverride env 覆盖 prompt.mode 与 prompt.file。
739
+ func TestPromptEnvOverride(t *testing.T) {
740
+ t.Setenv("WB2A_PROMPT_MODE", "passthrough")
741
+ c, err := Load("")
742
+ if err != nil {
743
+ t.Fatal(err)
744
+ }
745
+ if c.Prompt.Mode != "passthrough" {
746
+ t.Errorf("mode=%q want passthrough", c.Prompt.Mode)
747
+ }
748
+ }
749
+
750
+ // TestPromptLegacyConfigNoImpact 旧 config(无 prompt 段)零影响:mode 缺省 passthrough。
751
+ func TestPromptLegacyConfigNoImpact(t *testing.T) {
752
+ dir := t.TempDir()
753
+ fp := filepath.Join(dir, "c.json")
754
+ os.WriteFile(fp, []byte(`{"listen":":9999","api_key":"k"}`), 0o600)
755
+ c, err := Load(fp)
756
+ if err != nil {
757
+ t.Fatal(err)
758
+ }
759
+ if c.Prompt.Mode != "passthrough" {
760
+ t.Errorf("legacy config should default to passthrough, got %q", c.Prompt.Mode)
761
+ }
762
+ if c.Listen != ":9999" {
763
+ t.Errorf("listen=%q", c.Listen)
764
+ }
765
+ }
766
+
767
+ // TestUpstreamUserAgentConfig 配置 upstream.user_agent 与 env WB2A_USER_AGENT 均生效,
768
+ // 缺省空串保持现状(headers 层回落到 clientUA)。
769
+ func TestUpstreamUserAgentConfig(t *testing.T) {
770
+ // JSON 配置
771
+ dir := t.TempDir()
772
+ fp := filepath.Join(dir, "c.json")
773
+ os.WriteFile(fp, []byte(`{"upstream":{"user_agent":"WorkBuddy/1.2.3"}}`), 0o600)
774
+ c, err := Load(fp)
775
+ if err != nil {
776
+ t.Fatal(err)
777
+ }
778
+ if c.Upstream.UserAgent != "WorkBuddy/1.2.3" {
779
+ t.Errorf("user_agent=%q want WorkBuddy/1.2.3", c.Upstream.UserAgent)
780
+ }
781
+ // 缺省为空
782
+ if c2, err := Load(""); err != nil || c2.Upstream.UserAgent != "" {
783
+ t.Errorf("default user_agent=%q want empty (err=%v)", c2.Upstream.UserAgent, err)
784
+ }
785
+ // env 覆盖
786
+ t.Setenv("WB2A_USER_AGENT", "EnvAgent/9")
787
+ c3, err := Load("")
788
+ if err != nil {
789
+ t.Fatal(err)
790
+ }
791
+ if c3.Upstream.UserAgent != "EnvAgent/9" {
792
+ t.Errorf("env user_agent=%q want EnvAgent/9", c3.Upstream.UserAgent)
793
+ }
794
+ }
795
+
796
+ // TestLoadConfigPathIsDirectory config 路径是目录时给出可操作提示(Docker bind mount 陷阱)。
797
+ // 复现:compose 挂载 ./config.json 但宿主机缺该文件 → Docker 创建同名目录 → 启动失败。
798
+ // 旧行为只报 "read config: ... Incorrect function" 之类晦涩错误,无从排查。
799
+ func TestLoadConfigPathIsDirectory(t *testing.T) {
800
+ dir := t.TempDir()
801
+ asDir := filepath.Join(dir, "config.json")
802
+ if err := os.Mkdir(asDir, 0o755); err != nil {
803
+ t.Fatal(err)
804
+ }
805
+ _, err := Load(asDir)
806
+ if err == nil {
807
+ t.Fatal("want error when config path is a directory")
808
+ }
809
+ msg := err.Error()
810
+ if !strings.Contains(msg, "是目录") {
811
+ t.Errorf("error should explain it is a directory: %v", err)
812
+ }
813
+ if !strings.Contains(msg, "config.example.json") {
814
+ t.Errorf("error should suggest the fix (cp config.example.json): %v", err)
815
+ }
816
+ }
cmd/server/credit_floor_config_test.go ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // credit_floor_config_test.go pool.credit_floor 配置测试:
2
+ // 默认 0(关闭,零回归)/ 文件覆盖 / 负值钳 0 / 大值合法。
3
+ package main
4
+
5
+ import (
6
+ "os"
7
+ "path/filepath"
8
+ "testing"
9
+ )
10
+
11
+ // TestCreditFloorDefault 键缺席 → 默认 0(保底关闭,行为与引入前一致)。
12
+ func TestCreditFloorDefault(t *testing.T) {
13
+ c := Default()
14
+ if err := c.normalize(); err != nil {
15
+ t.Fatalf("normalize: %v", err)
16
+ }
17
+ if c.Pool.CreditFloor != 0 {
18
+ t.Errorf("credit_floor=%d want 0 (default off)", c.Pool.CreditFloor)
19
+ }
20
+ }
21
+
22
+ // TestCreditFloorParsedFromFile 显式配置覆盖默认。
23
+ func TestCreditFloorParsedFromFile(t *testing.T) {
24
+ dir := t.TempDir()
25
+ fp := filepath.Join(dir, "c.json")
26
+ os.WriteFile(fp, []byte(`{"pool":{"credit_floor":100}}`), 0o600)
27
+ c, err := Load(fp)
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ if c.Pool.CreditFloor != 100 {
32
+ t.Errorf("credit_floor=%d want 100", c.Pool.CreditFloor)
33
+ }
34
+ }
35
+
36
+ // TestCreditFloorNegativeClamped 负值钳 0(非法即关闭,不报错:老配置误写不炸启动)。
37
+ func TestCreditFloorNegativeClamped(t *testing.T) {
38
+ dir := t.TempDir()
39
+ fp := filepath.Join(dir, "c.json")
40
+ os.WriteFile(fp, []byte(`{"pool":{"credit_floor":-5}}`), 0o600)
41
+ c, err := Load(fp)
42
+ if err != nil {
43
+ t.Fatal(err)
44
+ }
45
+ if c.Pool.CreditFloor != 0 {
46
+ t.Errorf("credit_floor=%d want 0 (negative clamped)", c.Pool.CreditFloor)
47
+ }
48
+ }
cmd/server/main.go ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // main.go workbuddy2api 入口:加载配置、构建 pool、起调度器与 HTTP 服务。
2
+ package main
3
+
4
+ import (
5
+ "context"
6
+ "encoding/json"
7
+ "errors"
8
+ "flag"
9
+ "fmt"
10
+ "io"
11
+ "io/fs"
12
+ "log"
13
+ "net/http"
14
+ "os"
15
+ "os/signal"
16
+ "path/filepath"
17
+ "syscall"
18
+ "time"
19
+
20
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
21
+ "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg"
22
+ "github.com/linguo2625469/workbuddy2api-panel/internal/panel"
23
+ "github.com/linguo2625469/workbuddy2api-panel/internal/pool"
24
+ "github.com/linguo2625469/workbuddy2api-panel/internal/redisstore"
25
+ "github.com/linguo2625469/workbuddy2api-panel/internal/reqlog"
26
+ "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler"
27
+ "github.com/linguo2625469/workbuddy2api-panel/internal/server"
28
+ "github.com/linguo2625469/workbuddy2api-panel/internal/session"
29
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
30
+ "github.com/linguo2625469/workbuddy2api-panel/internal/usage"
31
+ )
32
+
33
+ // appVersion 网关版本(fork 版:面板 + 任务体系),透出到 /panel/api/overview。
34
+ const appVersion = "1.11.11-panel"
35
+
36
+ // usagePathFor 由 state 文件路径推出用量文件路径:同目录、文件名 usage.json。
37
+ // 这样 config 里改 state_file 时用量数据跟着走,不需要额外配置项。
38
+ func usagePathFor(stateFile string) string { return stateSibling(stateFile, "usage.json") }
39
+
40
+ // stateSibling 返回与 state 文件同目录的指定文件名路径(相对路径场景回落当前目录)。
41
+ // usage.json(用量记录)与 output_probes.json(模型上限探测)共用本规则。
42
+ func stateSibling(stateFile, name string) string {
43
+ dir := filepath.Dir(stateFile)
44
+ if dir == "" || dir == "." {
45
+ return name
46
+ }
47
+ return filepath.Join(dir, name)
48
+ }
49
+
50
+ func main() {
51
+ cfgPath := flag.String("config", "config.json", "配置文件路径(默认当前目录 config.json;不存在时自动生成推荐配置)")
52
+ flag.Parse()
53
+
54
+ cfg, err := Load(*cfgPath)
55
+ if err != nil {
56
+ // errors.Is 才能看穿 Load 里 fmt.Errorf("%w") 的包装;os.IsNotExist 不行。
57
+ if errors.Is(err, fs.ErrNotExist) {
58
+ // 首次运行:目录下没有配置 → 自动落一份推荐配置(含随机 api_key)再加载。
59
+ // 双击 exe / 裸跑 docker 即开,无需先手工复制样例。
60
+ if key, werr := WriteDefault(*cfgPath); werr == nil {
61
+ log.Printf("config %s 不存在,已生成推荐配置(api_key=%s,记录在该文件里,可自行修改)", *cfgPath, key)
62
+ cfg, err = Load(*cfgPath)
63
+ }
64
+ if err != nil {
65
+ // 生成失败(目录只读等):退回纯默认 + env(旧行为兜底),不阻塞启动。
66
+ log.Printf("config %s not found (auto-generate failed), using defaults+env: %v", *cfgPath, err)
67
+ cfg, err = Load("")
68
+ }
69
+ }
70
+ if err != nil {
71
+ log.Fatalf("load config: %v", err)
72
+ }
73
+ }
74
+
75
+ auths, err := auth.LoadDir(cfg.AuthDir)
76
+ if err != nil {
77
+ log.Fatalf("load auths: %v", err)
78
+ }
79
+ log.Printf("loaded %d account(s) from %s", len(auths), cfg.AuthDir)
80
+
81
+ // redisstore:未配置/连接失败 → Noop(纯内存模式,一切功能照常)。
82
+ store := redisstore.New(cfg.Upstash.URL, cfg.Upstash.Token)
83
+
84
+ p := pool.New(cfg.StateFile)
85
+ // 停机序:先 pool.Close()(最后一次 Flush → SaveState 已提交到 store),
86
+ // 再 store.Close() 排空在途异步写(最后一笔 Redis 镜像必须写完才关连接)。
87
+ defer func() {
88
+ p.Close()
89
+ _ = store.Close()
90
+ }()
91
+ p.SetStore(store)
92
+ p.RestoreFromSnapshot() // 择新恢复:Redis 快照比本地新才采用,否则本地优先
93
+ p.SyncToDir(auths) // 与 auths 目录对齐:新账号加入、已删除文件账号剔除(状态保留)
94
+
95
+ // 熔断器 + 在途上限(含 global 分档)+ 连败降权 + 闲置补偿调优(从 config 注入,
96
+ // 非正值回退默认)。
97
+ p.SetBreaker(cfg.Pool.BreakerThreshold, cfg.BreakerCooldownDur, cfg.BreakerCooldownMaxD)
98
+ p.SetMaxInFlight(cfg.Pool.MaxInFlight)
99
+ p.SetMaxInFlightGlobal(cfg.Pool.MaxInFlightGlobal) // global 域 WAF 风控分档(P1-1)
100
+ p.SetDegrade(cfg.Pool.DegradeThreshold, cfg.DegradeCooldownDur, cfg.DegradeCooldownMaxD)
101
+ p.SetSoftRateMax(cfg.SoftRateMaxDur) // 软冷却指数退避封顶(soft_rate_max,默认 2h)
102
+ p.SetCostExploreInterval(cfg.CostExploreIntervalDur) // costTier 探索窗口(issue #136,默认 30m;0 关停)
103
+ p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭)
104
+ p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax)
105
+ p.SetPreferExpiring(cfg.Pool.PreferExpiring)
106
+
107
+ // 会话粘性路由(可配关闭)。
108
+ var sessRouter *session.Router
109
+ redisMode := "noop"
110
+ if _, ok := store.(redisstore.Noop); !ok {
111
+ redisMode = "upstash"
112
+ }
113
+ if cfg.SessionSticky.Enabled {
114
+ sessRouter = session.New(session.Config{
115
+ TTL: cfg.SessionTTL,
116
+ GCInterval: cfg.SessionGCInterval,
117
+ Store: store,
118
+ Available: p.AvailableUIDs,
119
+ // realm 感知闭包:带前缀模型名按 realm 过滤可用账号(跨 realm 不泄漏);
120
+ // 裸名走 cn(现状零回归)。闭包内部 resolveModel 剥前缀,再按 realm 过滤。
121
+ AvailableForModel: realmAwareAvailableForModel(p),
122
+ })
123
+ sessRouter.LoadFromStore() // 启动时从 Redis 恢复粘性(读操作仅此处)
124
+ sessRouter.StartGC()
125
+ defer sessRouter.StopGC()
126
+ }
127
+ sessCount := func() int {
128
+ if sessRouter != nil {
129
+ return sessRouter.Count()
130
+ }
131
+ return 0
132
+ }
133
+
134
+ up := upstream.New()
135
+
136
+ // 积分保底的「收费」兜底判据:接上游模型目录的积分倍率表。本地实测台账无观测
137
+ // 时用它判收费——否则「没学过」恒等于「放行」,高价新模型会把触底号一笔打穿
138
+ // (kimi-k3-1 实案:全池无观测 → 保底全放行 → 两笔打穿并硬冷却到次日 04:00)。
139
+ // 位于 up 装配之后:倍率表由探测下发,闭包每次调用读实时快照。
140
+ p.SetModelRateOf(func(realm, model string) string { return up.ModelRate(realm, model) })
141
+
142
+ // 短 RPC 总时长上限(refresh/checkin/balance/FetchModels),语义不变。
143
+ up.HTTP.Timeout = time.Duration(cfg.Upstream.TimeoutSeconds) * time.Second
144
+ // 聊天 SSE 首字节前(响应头)上限:cfg 已 normalize(缺省回落 timeout_seconds)。
145
+ up.HeaderTimeout = time.Duration(cfg.Upstream.HeaderTimeoutSeconds) * time.Second
146
+ if tr, ok := up.ChatHTTP.Transport.(*http.Transport); ok {
147
+ tr.ResponseHeaderTimeout = up.HeaderTimeout
148
+ }
149
+ // 聊天 SSE 流中空闲上限(S3 空闲监控读取)。
150
+ up.IdleTimeout = time.Duration(cfg.Upstream.IdleTimeoutSeconds) * time.Second
151
+ up.SanitizeFingerprints.Store(cfg.Features.SanitizeBlacklistFingerprints)
152
+ // 出站 UA 与归属头(issue #42 + 上游同步):
153
+ // UserAgent 非空则完全覆盖;ClientVersion/CliVersion 缺省对齐官方形态;
154
+ // ClientName 非空时 chat 路径注入 X-IDE-* 四头(用量归因对齐官方桌面端)。
155
+ up.UserAgent = cfg.Upstream.UserAgent
156
+ up.ClientVersion = cfg.Upstream.ClientVersion
157
+ up.CliVersion = cfg.Upstream.CliVersion
158
+ up.ClientName = cfg.Upstream.ClientName
159
+ up.DeviceToken = cfg.Upstream.DeviceToken
160
+ up.DeviceTokenFile = cfg.Upstream.DeviceTokenFile
161
+ up.PassthroughIP = cfg.Upstream.PassthroughIP
162
+ // global realm 路由(config global 段):上游侧开关(第一道闸)+ base 覆盖;
163
+ // auth 侧开关(auth.SetGlobalEnabled)是第二道闸,两者同 config global.enabled。
164
+ up.GlobalEnabled = cfg.Global.Enabled
165
+ up.ChatBaseGlobal = cfg.Global.ChatBase
166
+ up.BillingBaseGlobal = cfg.Global.BillingBase
167
+ auth.SetGlobalEnabled(cfg.Global.Enabled)
168
+ // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级):
169
+ // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动
170
+ // 回落仓库内嵌种子;models.dev 按需拉取成功后原子写回。
171
+ upstream.SetModelCatalogPath(stateSibling(cfg.StateFile, "model.json"))
172
+
173
+ sch := scheduler.New(scheduler.Config{
174
+ Pool: p,
175
+ Upstream: up,
176
+ CheckinHours: cfg.Schedule.CheckinHours,
177
+ TravelHours: cfg.Schedule.TravelHours,
178
+ ActivityHours: cfg.Schedule.ActivityHours,
179
+ KeepaliveHours: cfg.Schedule.KeepaliveHours,
180
+ BlackcatHours: cfg.Schedule.BlackcatHours,
181
+ GrowthHours: cfg.Schedule.GrowthHours,
182
+ // 快过期积分优先消耗:签到/余额刷新按此窗口分桶(issue:积分过期)。
183
+ ExpiringSoonWindow: cfg.ExpiringSoonDur,
184
+ CheckinDisabled: !cfg.Schedule.CheckinEnabled,
185
+ TravelDisabled: !cfg.Schedule.TravelEnabled,
186
+ ActivityDisabled: !cfg.Schedule.ActivityEnabled,
187
+ KeepaliveDisabled: !cfg.Schedule.KeepaliveEnabled,
188
+ BlackcatDisabled: !cfg.Schedule.BlackcatEnabled,
189
+ GrowthDisabled: !cfg.Schedule.GrowthEnabled,
190
+ })
191
+ switch {
192
+ case !cfg.Schedule.CheckinEnabled:
193
+ log.Printf("签到已禁用(schedule.checkin_enabled=false)")
194
+ default:
195
+ log.Printf("签到已启用:%v 点(签到 + 余额查询解冻)", cfg.Schedule.CheckinHours)
196
+ }
197
+ switch {
198
+ case !cfg.Schedule.TravelEnabled:
199
+ log.Printf("猫猫旅行已禁用(schedule.travel_enabled=false)")
200
+ default:
201
+ log.Printf("猫猫旅行已启用:%v 点(独立排程:领养 / 派出 / 领奖)", cfg.Schedule.TravelHours)
202
+ }
203
+ switch {
204
+ case !cfg.Schedule.ActivityEnabled:
205
+ log.Printf("活跃上报已禁用(schedule.activity_enabled=false)")
206
+ default:
207
+ log.Printf("活跃上报已启用:%v 点(每日 1 次,点亮连登 + 解锁 first_buddy)", cfg.Schedule.ActivityHours)
208
+ }
209
+ if !cfg.Schedule.KeepaliveEnabled {
210
+ log.Printf("token 保活已禁用(schedule.keepalive_enabled=false)")
211
+ } else {
212
+ log.Printf("token 保活已启用:%v 点", cfg.Schedule.KeepaliveHours)
213
+ }
214
+ switch {
215
+ case !cfg.Schedule.BlackcatEnabled:
216
+ log.Printf("夜猫子已禁用(schedule.blackcat_enabled=false)")
217
+ default:
218
+ log.Printf("夜猫子已启用:%v 点(23:00–08:00 窗口 glm-5.2 对话补足)", cfg.Schedule.BlackcatHours)
219
+ }
220
+ switch {
221
+ case !cfg.Schedule.BalanceRefreshEnabled:
222
+ log.Printf("余额后台刷新已禁用(schedule.balance_refresh_enabled=false)")
223
+ case cfg.BalanceRefreshInterval > 0:
224
+ log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval)
225
+ }
226
+
227
+ // 管理面板日志镜像:标准 log(stderr)与 chat 表格日志(stdout)双路复制进
228
+ // 面板环形缓冲,供 /panel/api/logs 读取;控制台输出行为完全不变。
229
+ // live 承载可热改字段(api_key/soft_rate/脱敏开关),面板保存配置时在线替换。
230
+ live := livecfg.New(livecfg.Snapshot{
231
+ APIKey: cfg.APIKey,
232
+ SoftCooldown: cfg.SoftRateDur,
233
+ SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints,
234
+ RecordClientInfo: cfg.Logging.RequestClientInfo,
235
+ })
236
+ // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。
237
+ // datapath 由 state 文件路径推出,避免再加一个配置项。
238
+ usagePath := usagePathFor(cfg.StateFile)
239
+ rec := usage.New(usagePath)
240
+ rec.Start()
241
+ defer rec.Stop()
242
+ log.Printf("[usage] 逐请求用量记录已启用: %s (%s)", usagePath, rec.Describe())
243
+
244
+ // 请求指标始终启用;JSONL 归档只写脱敏元数据,写盘失败不影响聊天请求。
245
+ requestLog := reqlog.New(reqlog.Config{
246
+ Dir: stateSibling(cfg.StateFile, "request-logs"),
247
+ Enabled: cfg.Logging.RequestArchiveEnabled,
248
+ RetentionDays: cfg.Logging.RequestRetentionDays,
249
+ MaxBytes: int64(cfg.Logging.RequestArchiveMaxMB) << 20,
250
+ })
251
+ defer requestLog.Close()
252
+ rs := requestLog.Snapshot().Archive
253
+ if rs.Enabled {
254
+ log.Printf("[reqlog] 请求指标已启用;JSONL 归档 %s(保留 %d 天,上限 %d MiB)",
255
+ rs.Dir, cfg.Logging.RequestRetentionDays, cfg.Logging.RequestArchiveMaxMB)
256
+ } else {
257
+ log.Printf("[reqlog] 请求指标已启用;JSONL 归档已关闭")
258
+ }
259
+
260
+ pn := panel.New(panel.Config{
261
+ Pool: p,
262
+ Usage: rec,
263
+ RequestLog: requestLog,
264
+ Upstream: up,
265
+ Scheduler: sch,
266
+ AuthDir: cfg.AuthDir,
267
+ APIKey: cfg.APIKey,
268
+ RedisMode: redisMode,
269
+ StickyCount: sessCount,
270
+ Version: appVersion,
271
+ Live: live,
272
+ // 模型上限探测数据(scripts/probe_max_tokens.py --panel-out 写入):
273
+ // 与 state 文件同目录,缺省 data/output_probes.json。
274
+ ProbeFile: stateSibling(cfg.StateFile, "output_probes.json"),
275
+ ConfigPath: *cfgPath,
276
+ LoadConfig: func() (any, error) {
277
+ return Load(*cfgPath)
278
+ },
279
+ SaveConfig: func(raw []byte) ([]string, error) {
280
+ return saveConfig(raw, *cfgPath, live, p, up, sch)
281
+ },
282
+ })
283
+ // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后
284
+ // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。
285
+ sch.SetGrowthHook(pn.RunGrowthQueueOnce)
286
+ log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs()))
287
+ server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs()))
288
+
289
+ h := server.NewHandler(server.Config{
290
+ Pool: p,
291
+ Upstream: up,
292
+ APIKey: cfg.APIKey,
293
+ Session: sessRouter,
294
+ StickyCount: sessCount,
295
+ RedisMode: redisMode,
296
+ SoftCooldown: cfg.SoftRateDur,
297
+ Panel: pn,
298
+ Live: live,
299
+ Usage: rec,
300
+ RequestLog: requestLog,
301
+ PromptMode: cfg.Prompt.Mode,
302
+ PromptText: cfg.PromptText,
303
+ // 来源记录开关经 livecfg 热生效;此处同时填静态字段,供 Live 为 nil 的
304
+ // 裸用/测试路径拿到同一缺省值。
305
+ RecordClientInfo: cfg.Logging.RequestClientInfo,
306
+ // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。
307
+ GlobalEnabled: cfg.Global.Enabled,
308
+ })
309
+
310
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
311
+ defer stop()
312
+ go sch.Run(ctx)
313
+ sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval)
314
+
315
+ // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时
316
+ // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前,
317
+ // ModelRate 恒返回空串——积分保底的目录兜底在这段空窗期内形同虚设,触底号
318
+ // 会被当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费
319
+ // 模型归零;倍率表当时尚未建立)。
320
+ // 异步执行:不阻塞监听启动;失败仅记日志(下一轮懒触发或本轮重试仍可补上)。
321
+ go warmModelRates(ctx, up, p)
322
+
323
+ srv := &http.Server{
324
+ Addr: cfg.Listen,
325
+ Handler: h,
326
+ ReadHeaderTimeout: 30 * time.Second,
327
+ // ReadTimeout 覆盖整个请求读取(含 body):防慢速 body 拖死连接。
328
+ // 请求体已无网关侧上限(max_body_mb 移除),60s 按常规带宽的数十 MB
329
+ // 上传余量取值;超大 body 慢速上传若超时,由客户端重试。
330
+ ReadTimeout: 60 * time.Second,
331
+ // IdleTimeout keep-alive 空闲连接回收:配合 chat 出站 ctx 传播防连接泄漏堆积。
332
+ // 注意:SSE 流式响应期间连接非空闲,不受此项掐断;不设全局 WriteTimeout
333
+ // (长流式生成合法时长可达数分钟,全局 WriteTimeout 会误杀在途 SSE)。
334
+ IdleTimeout: 120 * time.Second,
335
+ }
336
+ go func() {
337
+ <-ctx.Done()
338
+ p.Flush() // 信号触发:先落盘再做优雅停机
339
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
340
+ defer cancel()
341
+ _ = srv.Shutdown(shutdownCtx)
342
+ }()
343
+
344
+ log.Printf("workbuddy2api listening on %s (api_key=%v),管理面板 http://127.0.0.1%s/panel/", cfg.Listen, cfg.APIKey != "", panelListenPath(cfg.Listen))
345
+ if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
346
+ log.Fatalf("http: %v", err)
347
+ }
348
+ log.Printf("bye")
349
+ }
350
+
351
+ // warmModelRates 启动预热各域模型积分倍率表(供积分保底的目录兜底判定)。
352
+ //
353
+ // 为什么需要:倍率表只在 FetchModels(CN)/ FetchGlobalModelInfos(global)成功时
354
+ // 填充,两者都是懒触发(被 /v1/models 或面板模型页访问才跑)。重启后到首次触发
355
+ // 之间的空窗期里 ModelRate 恒返回空串,保底的目录兜底判不出收费,触底号会被
356
+ // 当成「收费未知」放行并打穿(实测:重启后 2 分钟,97 分的账号打收费模型归零)。
357
+ //
358
+ // 失败处理:单域失败只记 WARN(不阻塞、不致命——后续懒触发仍会补上);global 域
359
+ // 仅在其路由开关开启时预热(逃生门关锁时按 CN 处理,无需探测)。
360
+ func warmModelRates(ctx context.Context, up *upstream.Client, p *pool.Pool) {
361
+ // 预热不得拖住进程退出:ctx 取消(SIGINT/SIGTERM)时立刻放弃剩余域。
362
+ if ctx.Err() != nil {
363
+ return
364
+ }
365
+ // CN:有可用 CN 账号才拉(与面板 models 同口径,避免无谓上游调用)。
366
+ if uids := p.AvailableUIDsForRealm("cn"); len(uids) > 0 {
367
+ if a := p.AuthByUID(uids[0]); a != nil {
368
+ if _, err := up.FetchModels(a); err != nil {
369
+ log.Printf("WARN: [upstream] warm model rates (cn): %v", err)
370
+ } else {
371
+ log.Printf("[upstream] warm model rates: cn ok")
372
+ }
373
+ }
374
+ }
375
+ // global:独立目录端点(workbuddy.ai),倍率按 "global" 域键存储。
376
+ if up.GlobalEnabled && ctx.Err() == nil {
377
+ if uids := p.AvailableUIDsForRealm("global"); len(uids) > 0 {
378
+ if a := p.AuthByUID(uids[0]); a != nil {
379
+ // FetchGlobalModelInfos 无错误返回(内部负缓存自行节流),
380
+ // 仅按结果条数判断是否拿到目录。
381
+ if infos := up.FetchGlobalModelInfos(a); len(infos) == 0 {
382
+ log.Printf("WARN: [upstream] warm model rates (global): empty model list")
383
+ } else {
384
+ log.Printf("[upstream] warm model rates: global ok (%d models)", len(infos))
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ // panelListenPath 从 listen 地址提取 ":port" 形式,用于启动日志拼面板 URL
392
+ // (":7863" 或 "0.0.0.0:7863" → ":7863";异常输入原样返回)。
393
+ func panelListenPath(listen string) string {
394
+ for i := len(listen) - 1; i >= 0; i-- {
395
+ if listen[i] == ':' {
396
+ return listen[i:]
397
+ }
398
+ }
399
+ return listen
400
+ }
401
+
402
+ // saveConfig 面板保存配置:校验 → 落盘 → 热应用 → 返回需重启的字段列表。
403
+ //
404
+ // 热生效范围(设计取舍):
405
+ // - api_key / cooldown.soft_rate / features.sanitize_blacklist_fingerprints → livecfg 快照
406
+ // - pool.* → pool.SetBreaker/SetMaxInFlight/SetSoftRateMax/SetWeights/SetCostExploreInterval/SetPreferExpiring/SetCreditFloor
407
+ // - schedule.* → scheduler.Reconfigure/SetBalanceInterval/SetExpiringSoonWindow
408
+ //
409
+ // 需重启(涉及监听地址、HTTP client 超时、auth_dir 等装配期依赖):
410
+ // - listen / auth_dir / state_file / upstream.* / upstash.* / session_sticky.*(TTL 类)
411
+ //
412
+ // 落盘用"先写 tmp 再 rename"原子替换,且优先保留磁盘上的原始 JSON 结构(只改
413
+ // 面板表单覆盖到的键),避免把用户手写的注释性字段/未知键洗掉——这里直接整体
414
+ // 序列化校验后的配置,未知键在 json.Unmarshal 时已丢失,故先合并原始 map。
415
+ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up *upstream.Client, sch *scheduler.Scheduler) ([]string, error) {
416
+ // 1) 解析原始 JSON 为 map(保留用户手写的未知键),再叠加面板提交的键。
417
+ oldRaw, err := os.ReadFile(path)
418
+ if err != nil {
419
+ return nil, fmt.Errorf("read current config: %w", err)
420
+ }
421
+ var cur, incoming map[string]any
422
+ if err := json.Unmarshal(oldRaw, &cur); err != nil {
423
+ cur = map[string]any{}
424
+ }
425
+ if err := json.Unmarshal(raw, &incoming); err != nil {
426
+ return nil, fmt.Errorf("parse submitted config: %w", err)
427
+ }
428
+ merged := mergeConfigMaps(cur, incoming)
429
+
430
+ // 2) 校验(与启动同一套 Default+normalize),失败直接返回、不落盘。
431
+ newCfg, err := ParseConfig(mergedJSON(merged))
432
+ if err != nil {
433
+ return nil, err
434
+ }
435
+
436
+ // 3) 落盘(原子替换)。
437
+ out, err := json.MarshalIndent(merged, "", " ")
438
+ if err != nil {
439
+ return nil, fmt.Errorf("marshal config: %w", err)
440
+ }
441
+ tmp := path + ".tmp"
442
+ if err := os.WriteFile(tmp, out, 0o600); err != nil {
443
+ return nil, fmt.Errorf("write config: %w", err)
444
+ }
445
+ if err := os.Rename(tmp, path); err != nil {
446
+ // A single-file Docker bind mount cannot be renamed over its mount
447
+ // target (Linux returns EBUSY / "device or resource busy"). Keep the
448
+ // atomic path for regular files, but update the mounted file in place
449
+ // for this specific deployment shape.
450
+ if !errors.Is(err, syscall.EBUSY) {
451
+ return nil, fmt.Errorf("replace config: %w", err)
452
+ }
453
+ f, openErr := os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0o600)
454
+ if openErr != nil {
455
+ _ = os.Remove(tmp)
456
+ return nil, fmt.Errorf("replace config (bind mount fallback): %w", openErr)
457
+ }
458
+ _, writeErr := f.Write(out)
459
+ if writeErr == nil {
460
+ writeErr = f.Sync()
461
+ }
462
+ closeErr := f.Close()
463
+ // 写失败时保留 tmp(挂载文件已被 O_TRUNC 破坏,tmp 里是完整新内容,
464
+ // 可手工恢复);写成功才清理。
465
+ if writeErr != nil {
466
+ return nil, fmt.Errorf("replace config (bind mount fallback, 完整新内容保留在 %s): %w", tmp, writeErr)
467
+ }
468
+ _ = os.Remove(tmp)
469
+ if closeErr != nil {
470
+ return nil, fmt.Errorf("replace config (bind mount fallback): %w", closeErr)
471
+ }
472
+ }
473
+
474
+ // 4) 热应用:能立即生效的字段全部应用,并列出仍需重启的字段。
475
+ live.Store(livecfg.Snapshot{
476
+ APIKey: newCfg.APIKey,
477
+ SoftCooldown: newCfg.SoftRateDur,
478
+ SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints,
479
+ RecordClientInfo: newCfg.Logging.RequestClientInfo,
480
+ })
481
+ up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints)
482
+ p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD)
483
+ p.SetMaxInFlight(newCfg.Pool.MaxInFlight)
484
+ p.SetMaxInFlightGlobal(newCfg.Pool.MaxInFlightGlobal)
485
+ p.SetDegrade(newCfg.Pool.DegradeThreshold, newCfg.DegradeCooldownDur, newCfg.DegradeCooldownMaxD)
486
+ p.SetSoftRateMax(newCfg.SoftRateMaxDur)
487
+ p.SetCostExploreInterval(newCfg.CostExploreIntervalDur) // costTier 探索窗口热生效(0 关停)
488
+ p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭)
489
+ p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax)
490
+ p.SetPreferExpiring(newCfg.Pool.PreferExpiring)
491
+ sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur)
492
+ sch.Reconfigure(
493
+ newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours,
494
+ newCfg.Schedule.ActivityHours, newCfg.Schedule.KeepaliveHours, newCfg.Schedule.BlackcatHours,
495
+ newCfg.Schedule.GrowthHours,
496
+ !newCfg.Schedule.CheckinEnabled, !newCfg.Schedule.TravelEnabled,
497
+ !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled,
498
+ !newCfg.Schedule.GrowthEnabled)
499
+ sch.SetBalanceInterval(newCfg.BalanceRefreshInterval)
500
+
501
+ return restartRequiredFields(newCfg), nil
502
+ }
503
+
504
+ // restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。
505
+ // 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。
506
+ func restartRequiredFields(c *Config) []string {
507
+ var out []string
508
+ // 这些字段在进程内被监听地址/HTTP client/目录句柄等装配期对象捕获。
509
+ if c.Listen != "" {
510
+ out = append(out, "listen")
511
+ }
512
+ if c.AuthDir != "" {
513
+ out = append(out, "auth_dir")
514
+ }
515
+ if c.StateFile != "" {
516
+ out = append(out, "state_file")
517
+ }
518
+ out = append(out, "upstream.timeout_seconds", "upstream.header_timeout_seconds", "upstream.idle_timeout_seconds")
519
+ if c.Upstash.URL != "" || c.Upstash.Token != "" {
520
+ out = append(out, "upstash")
521
+ }
522
+ out = append(out, "session_sticky.ttl", "session_sticky.gc_interval")
523
+ out = append(out, "logging.request_archive_enabled", "logging.request_retention_days", "logging.request_archive_max_mb")
524
+ return out
525
+ }
526
+
527
+ // mergeConfigMaps 把 incoming 深合并进 cur(原地),返回 cur。
528
+ // 对嵌套对象逐键覆盖而不是整体替换:面板表单只提交它管理的键,
529
+ // 未提交的兄弟键(含用户手写的未知键)保持原样。
530
+ func mergeConfigMaps(cur, incoming map[string]any) map[string]any {
531
+ for k, v := range incoming {
532
+ if inMap, ok := v.(map[string]any); ok {
533
+ if curMap, ok := cur[k].(map[string]any); ok {
534
+ cur[k] = mergeConfigMaps(curMap, inMap)
535
+ continue
536
+ }
537
+ }
538
+ cur[k] = v
539
+ }
540
+ return cur
541
+ }
542
+
543
+ // mergedJSON 把合并后的 map 序列化回 JSON(供 ParseConfig 校验)。
544
+ func mergedJSON(m map[string]any) []byte {
545
+ b, err := json.Marshal(m)
546
+ if err != nil {
547
+ return []byte("{}")
548
+ }
549
+ return b
550
+ }
cmd/server/wiring.go ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package main
2
+
3
+ import (
4
+ "github.com/linguo2625469/workbuddy2api-panel/internal/pool"
5
+ "github.com/linguo2625469/workbuddy2api-panel/internal/server"
6
+ )
7
+
8
+ // realmAwareAvailableForModel 构造会话粘性路由按模型可用口径的 realm 感知闭包。
9
+ //
10
+ // 粘性分配的模型名可能带 realm 前缀("global:gpt-5.4" / "cn:glm-5.2"):必须按前缀剥出
11
+ // realm + bareModel,再交给分池选号域过滤——否则裸名取池子全集,global 号会被粘性分配给
12
+ // CN 前缀请求(跨 realm 泄漏)。裸名/显式 cn → cn 集合;global: → global 集合。
13
+ //
14
+ // realm 为空串时 pool.WeightedAvailableUIDsForModelRealm 退化为现状
15
+ // (AvailableUIDsForModel),老调用(无前缀模型名)语义零改动。
16
+ //
17
+ // 返回列表可能对快过期账号重复同一 UID,作为虚拟实例权重;会话哈希分配无需感知
18
+ // 权重细节,已有绑定的快路径仍直接返回原账号,不做迁移。
19
+ func realmAwareAvailableForModel(p *pool.Pool) func(model string) []string {
20
+ return func(model string) []string {
21
+ realm, bare := server.ResolveModel(model)
22
+ return p.WeightedAvailableUIDsForModelRealm(bare, realm)
23
+ }
24
+ }
cmd/signin/main.go ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // signin 一次性批量签到工具:遍历 ./auths/workbuddy-*.json 全部账号,
2
+ // 自动 RefreshToken(过期时),逐个调 daily-checkin,顺手查余额。
3
+ package main
4
+
5
+ import (
6
+ "fmt"
7
+ "log"
8
+ "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
+
13
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
14
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
15
+ )
16
+
17
+ type row struct {
18
+ file string
19
+ uid string
20
+ nick string
21
+ status string // OK | ALREADY | FAIL | AUTH_INVALID | LOAD_ERR
22
+ detail string
23
+ remain int64
24
+ hasQuota bool
25
+ }
26
+
27
+ func main() {
28
+ dir := "auths"
29
+ if len(os.Args) > 1 {
30
+ dir = os.Args[1]
31
+ }
32
+ files, err := filepath.Glob(filepath.Join(dir, "workbuddy-*.json"))
33
+ if err != nil || len(files) == 0 {
34
+ fmt.Fprintf(os.Stderr, "no auth files in %s\n", dir)
35
+ os.Exit(1)
36
+ }
37
+ sort.Strings(files)
38
+ up := upstream.New()
39
+
40
+ var rows []row
41
+ okN, alreadyN, failN := 0, 0, 0
42
+ for _, f := range files {
43
+ r := row{file: filepath.Base(f)}
44
+ raw, err := os.ReadFile(f)
45
+ if err != nil {
46
+ r.status, r.detail = "LOAD_ERR", err.Error()
47
+ rows = append(rows, r)
48
+ failN++
49
+ continue
50
+ }
51
+ a, err := auth.Parse(raw)
52
+ if err != nil {
53
+ r.status, r.detail = "LOAD_ERR", err.Error()
54
+ rows = append(rows, r)
55
+ failN++
56
+ continue
57
+ }
58
+ a.FilePath = f
59
+ r.uid, r.nick = a.UID, a.Nickname
60
+
61
+ // refresh 过期 token
62
+ if a.NeedsRefresh(2 * 3600) {
63
+ if err := up.RefreshToken(a); err != nil {
64
+ if ue, ok := err.(*upstream.Error); ok && ue.Kind == upstream.ErrSessionDead {
65
+ r.status = "AUTH_INVALID"
66
+ } else {
67
+ r.status = "FAIL"
68
+ }
69
+ r.detail = "refresh: " + short(err.Error())
70
+ rows = append(rows, r)
71
+ failN++
72
+ continue
73
+ }
74
+ // refresh 后写回文件(权限问题已修复);落盘失败必须暴露,否则重启回旧 token
75
+ if err := a.SaveAtomic(); err != nil {
76
+ log.Printf("signin %s save: %v", a.UID, err)
77
+ }
78
+ }
79
+
80
+ err = up.DailyCheckin(a)
81
+ switch {
82
+ case err == nil:
83
+ r.status = "OK"
84
+ okN++
85
+ default:
86
+ // DailyCheckin 已签到返回 code!=0 错误
87
+ if isAlready(err.Error()) {
88
+ r.status = "ALREADY"
89
+ r.detail = short(err.Error())
90
+ alreadyN++
91
+ } else {
92
+ r.status = "FAIL"
93
+ r.detail = short(err.Error())
94
+ failN++
95
+ }
96
+ }
97
+ // 顺手查余额
98
+ if remain, _, qerr := up.UserResource(a); qerr == nil {
99
+ r.remain, r.hasQuota = remain, true
100
+ }
101
+ rows = append(rows, r)
102
+ }
103
+
104
+ // 报告
105
+ fmt.Printf("uid | nick | status | remain | detail\n")
106
+ fmt.Printf("-------------------------------------+-------------+--------------+--------+------------------------------\n")
107
+ for _, r := range rows {
108
+ remain := "-"
109
+ if r.hasQuota {
110
+ remain = fmt.Sprintf("%d", r.remain)
111
+ }
112
+ fmt.Printf("%-36s | %-11s | %-12s | %-6s | %s\n",
113
+ trunc(r.uid, 36), trunc(r.nick, 11), r.status, remain, r.detail)
114
+ }
115
+ fmt.Printf("\ntotal=%d ok=%d already=%d fail=%d\n", len(rows), okN, alreadyN, failN)
116
+ }
117
+
118
+ // 已签判定:code 非 0 且含 "已签到"/"already"/"checkin" 等字样
119
+ func isAlready(msg string) bool {
120
+ s := strings.ToLower(msg)
121
+ return strings.Contains(s, "已签到") ||
122
+ strings.Contains(s, "already") ||
123
+ strings.Contains(s, "checkin") ||
124
+ strings.Contains(s, "code=400")
125
+ }
126
+
127
+ func trunc(s string, n int) string {
128
+ if len(s) > n {
129
+ return s[:n]
130
+ }
131
+ return s
132
+ }
133
+
134
+ func short(s string) string {
135
+ s = strings.ReplaceAll(s, "\n", " ")
136
+ if len(s) > 60 {
137
+ return s[:60]
138
+ }
139
+ return s
140
+ }
cmd/trial/main.go ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // trial 一次性批量领取 global trial 加油包:遍历 auths 下全部账号,
2
+ // 仅对 global 账号执行 /billing/ide/trial(CN 无此端点,明确提示不适用)。
3
+ //
4
+ // 用法:
5
+ //
6
+ // # 本地:在项目根目录(需 config.json + auths/ + data/)直接 run
7
+ // go run ./cmd/trial
8
+ //
9
+ // # 容器内:先 cp 进去再 exec
10
+ // docker cp trial workbuddy2api:/tmp/trial
11
+ // docker exec -w /app workbuddy2api /tmp/trial
12
+ //
13
+ // 结果逐账号输出到 stdout:
14
+ //
15
+ // uid | nick | status | detail
16
+ // ----+------+--------+-------
17
+ // ... | GLOBAL | OK | trial granted
18
+ // ... | GLOBAL | ALREADY | 已领取过(幂等,不算失败)
19
+ // ... | CN | N/A | not applicable
20
+ package main
21
+
22
+ import (
23
+ "fmt"
24
+ "os"
25
+ "path/filepath"
26
+ "sort"
27
+
28
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
29
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
30
+ )
31
+
32
+ // classifyTrial 归一化 ClaimTrial 结果(纯函数,供 main 循环与测试直接断言):
33
+ // err → FAIL;claimed → OK;否则(幂等码已领)→ ALREADY。
34
+ func classifyTrial(claimed bool, err error) (trialStatus, string) {
35
+ switch {
36
+ case err != nil:
37
+ return trialFailed, err.Error()
38
+ case claimed:
39
+ return trialOK, "trial granted"
40
+ default:
41
+ return trialAlready, "already claimed (idempotent)"
42
+ }
43
+ }
44
+
45
+ // trialStatus 单账号 trial 领取结果状态。
46
+ type trialStatus string
47
+
48
+ const (
49
+ trialOK trialStatus = "OK"
50
+ trialAlready trialStatus = "ALREADY"
51
+ trialNotApp trialStatus = "N/A" // CN 账号不适用
52
+ trialFailed trialStatus = "FAIL"
53
+ )
54
+
55
+ type trialRow struct {
56
+ uid string
57
+ nick string
58
+ status trialStatus
59
+ detail string
60
+ }
61
+
62
+ func main() {
63
+ authDir := "auths"
64
+ if len(os.Args) > 1 {
65
+ authDir = os.Args[1]
66
+ }
67
+ files, err := filepath.Glob(filepath.Join(authDir, "workbuddy-*.json"))
68
+ if err != nil || len(files) == 0 {
69
+ fmt.Fprintf(os.Stderr, "no auth files in %s\n", authDir)
70
+ os.Exit(1)
71
+ }
72
+ sort.Strings(files)
73
+
74
+ up := upstream.New()
75
+ // trial 是 global 专属端点:必须开启 global realm 路由,否则 upstream.New() 的
76
+ // GlobalEnabled 零值 false 会把请求路由到 CN base(codebuddy.cn)而必然失败。
77
+ up.GlobalEnabled = true
78
+ var rows []trialRow
79
+ for _, f := range files {
80
+ r := trialRow{uid: filepath.Base(f)}
81
+ raw, err := os.ReadFile(f)
82
+ if err != nil {
83
+ r.status, r.detail = trialFailed, "load: "+err.Error()
84
+ rows = append(rows, r)
85
+ continue
86
+ }
87
+ a, err := auth.Parse(raw)
88
+ if err != nil {
89
+ r.status, r.detail = trialFailed, "parse: "+err.Error()
90
+ rows = append(rows, r)
91
+ continue
92
+ }
93
+ a.FilePath = f
94
+ r.uid, r.nick = a.UID, a.Nickname
95
+
96
+ // 仅 global 账号适用:CN 明确提示不适用,不发任何请求。
97
+ if !a.IsGlobal() {
98
+ r.status, r.detail = trialNotApp, "CN account not applicable"
99
+ rows = append(rows, r)
100
+ continue
101
+ }
102
+
103
+ r.status, r.detail = classifyTrial(up.ClaimTrial(a))
104
+ rows = append(rows, r)
105
+ }
106
+
107
+ var okN, alreadyN, notAppN, failN int
108
+ fmt.Printf("uid | nick | status | detail\n")
109
+ fmt.Printf("-------------------------------------+-------------+---------+------------------------------\n")
110
+ for _, r := range rows {
111
+ fmt.Printf("%-36s | %-11s | %-7s | %s\n",
112
+ trunc(r.uid, 36), trunc(r.nick, 11), r.status, r.detail)
113
+ switch r.status {
114
+ case trialOK:
115
+ okN++
116
+ case trialAlready:
117
+ alreadyN++
118
+ case trialNotApp:
119
+ notAppN++
120
+ default:
121
+ failN++
122
+ }
123
+ }
124
+ fmt.Printf("\ntotal=%d ok=%d already=%d na=%d fail=%d\n",
125
+ len(rows), okN, alreadyN, notAppN, failN)
126
+ }
127
+
128
+ func trunc(s string, n int) string {
129
+ if len(s) > n {
130
+ return s[:n]
131
+ }
132
+ return s
133
+ }
config.default.json ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "listen": ":7861",
3
+ "api_key": "",
4
+ "auth_dir": "/app/storage/auths",
5
+ "state_file": "/app/storage/data/state.json",
6
+ "panel": {
7
+ "package_detail_limit": 5
8
+ },
9
+ "logging": {
10
+ "request_archive_enabled": true,
11
+ "request_retention_days": 7,
12
+ "request_archive_max_mb": 100,
13
+ "request_client_info": true
14
+ },
15
+ "cooldown": {
16
+ "soft_rate": "600s",
17
+ "soft_rate_max": "2h"
18
+ },
19
+ "schedule": {
20
+ "checkin_hours": [
21
+ 9,
22
+ 21
23
+ ],
24
+ "travel_hours": [
25
+ 9,
26
+ 21
27
+ ],
28
+ "activity_hours": [
29
+ 10
30
+ ],
31
+ "keepalive_hours": [
32
+ 22
33
+ ],
34
+ "blackcat_hours": [
35
+ 23
36
+ ],
37
+ "growth_hours": [
38
+ 1
39
+ ],
40
+ "checkin_enabled": true,
41
+ "travel_enabled": true,
42
+ "activity_enabled": true,
43
+ "keepalive_enabled": true,
44
+ "blackcat_enabled": true,
45
+ "growth_enabled": true,
46
+ "balance_refresh_enabled": true,
47
+ "balance_refresh_minutes": 5
48
+ },
49
+ "global": {
50
+ "enabled": true,
51
+ "chat_base": "",
52
+ "billing_base": ""
53
+ },
54
+ "upstream": {
55
+ "timeout_seconds": 120,
56
+ "header_timeout_seconds": 120,
57
+ "idle_timeout_seconds": 300,
58
+ "user_agent": "",
59
+ "client_version": "",
60
+ "cli_version": "",
61
+ "client_name": "",
62
+ "device_token": "",
63
+ "device_token_file": "",
64
+ "passthrough_ip": false
65
+ },
66
+ "features": {
67
+ "sanitize_blacklist_fingerprints": true
68
+ },
69
+ "prompt": {
70
+ "mode": "passthrough",
71
+ "file": ""
72
+ },
73
+ "upstash": {
74
+ "url": "",
75
+ "token": ""
76
+ },
77
+ "pool": {
78
+ "max_in_flight": 3,
79
+ "max_in_flight_global": 2,
80
+ "breaker_threshold": 3,
81
+ "breaker_cooldown": "30m",
82
+ "breaker_cooldown_max": "6h",
83
+ "degrade_threshold": 5,
84
+ "degrade_cooldown": "10m",
85
+ "degrade_cooldown_max": "2h",
86
+ "idle_weight_per_hour": 0.5,
87
+ "idle_weight_max": 5,
88
+ "prefer_expiring": true,
89
+ "expiring_soon": "168h",
90
+ "cost_explore_interval": "30m",
91
+ "credit_floor": 0
92
+ },
93
+ "session_sticky": {
94
+ "enabled": true,
95
+ "ttl": "30m",
96
+ "gc_interval": "5m"
97
+ }
98
+ }
config.example.json ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "listen": ":7863",
3
+ "api_key": "test_key",
4
+ "auth_dir": "./auths",
5
+ "state_file": "./data/state.json",
6
+ "panel": {
7
+ "package_detail_limit": 5
8
+ },
9
+ "logging": {
10
+ "request_archive_enabled": true,
11
+ "request_retention_days": 7,
12
+ "request_archive_max_mb": 100,
13
+ "request_client_info": true
14
+ },
15
+ "cooldown": {
16
+ "soft_rate": "600s",
17
+ "soft_rate_max": "2h"
18
+ },
19
+ "schedule": {
20
+ "checkin_hours": [9, 21],
21
+ "growth_hours": [1],
22
+ "travel_hours": [9, 21],
23
+ "activity_hours": [10],
24
+ "keepalive_hours": [22],
25
+ "blackcat_hours": [23],
26
+ "checkin_enabled": true,
27
+ "growth_enabled": true,
28
+ "travel_enabled": true,
29
+ "activity_enabled": true,
30
+ "keepalive_enabled": true,
31
+ "blackcat_enabled": true,
32
+ "balance_refresh_enabled": true,
33
+ "balance_refresh_minutes": 5
34
+ },
35
+ "global": {
36
+ "enabled": true,
37
+ "chat_base": "",
38
+ "billing_base": ""
39
+ },
40
+ "upstream": {
41
+ "timeout_seconds": 120,
42
+ "header_timeout_seconds": 120,
43
+ "idle_timeout_seconds": 300,
44
+ "user_agent": "",
45
+ "client_version": "",
46
+ "cli_version": "",
47
+ "client_name": "",
48
+ "device_token": "",
49
+ "device_token_file": "",
50
+ "passthrough_ip": false
51
+ },
52
+ "features": {
53
+ "sanitize_blacklist_fingerprints": true
54
+ },
55
+ "prompt": {
56
+ "mode": "passthrough",
57
+ "file": ""
58
+ },
59
+ "upstash": {
60
+ "url": "",
61
+ "token": ""
62
+ },
63
+ "pool": {
64
+ "max_in_flight": 3,
65
+ "max_in_flight_global": 2,
66
+ "breaker_threshold": 3,
67
+ "breaker_cooldown": "30m",
68
+ "breaker_cooldown_max": "6h",
69
+ "degrade_threshold": 5,
70
+ "degrade_cooldown": "10m",
71
+ "degrade_cooldown_max": "2h",
72
+ "idle_weight_per_hour": 0.5,
73
+ "idle_weight_max": 5.0,
74
+ "prefer_expiring": true,
75
+ "expiring_soon": "168h",
76
+ "cost_explore_interval": "30m",
77
+ "credit_floor": 100
78
+ },
79
+ "session_sticky": {
80
+ "enabled": true,
81
+ "ttl": "30m",
82
+ "gc_interval": "5m"
83
+ }
84
+ }
config.py DELETED
@@ -1,496 +0,0 @@
1
- """
2
- Configuration constants for the Geminicli2api proxy server.
3
- Centralizes all configuration to avoid duplication across modules.
4
-
5
- - 启动时加载一次配置到内存
6
- - 修改配置时调用 reload_config() 重新从数据库加载
7
- """
8
-
9
- import os
10
- from typing import Any, Optional
11
-
12
- # 全局配置缓存
13
- _config_cache: dict[str, Any] = {}
14
- _config_initialized = False
15
-
16
- # Client Configuration
17
-
18
- # 需要自动封禁的错误码 (默认值,可通过环境变量或配置覆盖)
19
- AUTO_BAN_ERROR_CODES = [403]
20
-
21
- # ====================== 环境变量映射表 ======================
22
- # 统一维护环境变量名和配置键名的映射关系
23
- # 格式: "环境变量名": "配置键名"
24
- ENV_MAPPINGS = {
25
- "CODE_ASSIST_ENDPOINT": "code_assist_endpoint",
26
- "CREDENTIALS_DIR": "credentials_dir",
27
- "PROXY": "proxy",
28
- "OAUTH_PROXY_URL": "oauth_proxy_url",
29
- "GOOGLEAPIS_PROXY_URL": "googleapis_proxy_url",
30
- "RESOURCE_MANAGER_API_URL": "resource_manager_api_url",
31
- "SERVICE_USAGE_API_URL": "service_usage_api_url",
32
- "ANTIGRAVITY_API_URL": "antigravity_api_url",
33
- "AUTO_BAN": "auto_ban_enabled",
34
- "AUTO_BAN_ERROR_CODES": "auto_ban_error_codes",
35
- "RETRY_429_MAX_RETRIES": "retry_429_max_retries",
36
- "RETRY_429_ENABLED": "retry_429_enabled",
37
- "RETRY_429_INTERVAL": "retry_429_interval",
38
- "ANTI_TRUNCATION_MAX_ATTEMPTS": "anti_truncation_max_attempts",
39
- "COMPATIBILITY_MODE": "compatibility_mode_enabled",
40
- "RETURN_THOUGHTS_TO_FRONTEND": "return_thoughts_to_frontend",
41
- "ANTIGRAVITY_STREAM2NOSTREAM": "antigravity_stream2nostream",
42
- "ANTIGRAVITY_SWITCH_CREDENTIAL": "antigravity_switch_credential_enabled",
43
- "HOST": "host",
44
- "PORT": "port",
45
- "API_PASSWORD": "api_password",
46
- "PANEL_PASSWORD": "panel_password",
47
- "PASSWORD": "password",
48
- "KEEPALIVE_URL": "keepalive_url",
49
- "KEEPALIVE_INTERVAL": "keepalive_interval",
50
- }
51
-
52
-
53
- # ====================== 配置系统 ======================
54
-
55
- async def init_config():
56
- """初始化配置缓存(启动时调用一次)"""
57
- global _config_cache, _config_initialized
58
-
59
- if _config_initialized:
60
- return
61
-
62
- try:
63
- from src.storage_adapter import get_storage_adapter
64
- storage_adapter = await get_storage_adapter()
65
- _config_cache = await storage_adapter.get_all_config()
66
- _config_initialized = True
67
- except Exception:
68
- # 初始化失败时使用空缓存
69
- _config_cache = {}
70
- _config_initialized = True
71
-
72
-
73
- async def reload_config():
74
- """重新加载配置(修改配置后调用)"""
75
- global _config_cache, _config_initialized
76
-
77
- try:
78
- from src.storage_adapter import get_storage_adapter
79
- storage_adapter = await get_storage_adapter()
80
-
81
- # 如果后端支持 reload_config_cache,调用它
82
- if hasattr(storage_adapter._backend, 'reload_config_cache'):
83
- await storage_adapter._backend.reload_config_cache()
84
-
85
- # 重新加载配置缓存
86
- _config_cache = await storage_adapter.get_all_config()
87
- _config_initialized = True
88
- except Exception:
89
- pass
90
-
91
-
92
- def _get_cached_config(key: str, default: Any = None) -> Any:
93
- """从内存缓存获取配置(同步)"""
94
- return _config_cache.get(key, default)
95
-
96
-
97
- async def get_config_value(key: str, default: Any = None, env_var: Optional[str] = None) -> Any:
98
- """Get configuration value with priority: ENV > Storage > default."""
99
- # 确保配置已初始化
100
- if not _config_initialized:
101
- await init_config()
102
-
103
- # Priority 1: Environment variable
104
- if env_var and os.getenv(env_var):
105
- return os.getenv(env_var)
106
-
107
- # Priority 2: Memory cache
108
- value = _get_cached_config(key)
109
- if value is not None:
110
- return value
111
-
112
- return default
113
-
114
-
115
- # Configuration getters - all async
116
- async def get_proxy_config():
117
- """Get proxy configuration."""
118
- proxy_url = await get_config_value("proxy", env_var="PROXY")
119
- return proxy_url if proxy_url else None
120
-
121
-
122
- async def get_auto_ban_enabled() -> bool:
123
- """Get auto ban enabled setting."""
124
- env_value = os.getenv("AUTO_BAN")
125
- if env_value:
126
- return env_value.lower() in ("true", "1", "yes", "on")
127
-
128
- return bool(await get_config_value("auto_ban_enabled", False))
129
-
130
-
131
- async def get_auto_ban_error_codes() -> list:
132
- """
133
- Get auto ban error codes.
134
-
135
- Environment variable: AUTO_BAN_ERROR_CODES (comma-separated, e.g., "400,403")
136
- Database config key: auto_ban_error_codes
137
- Default: [400, 403]
138
- """
139
- env_value = os.getenv("AUTO_BAN_ERROR_CODES")
140
- if env_value:
141
- try:
142
- return [int(code.strip()) for code in env_value.split(",") if code.strip()]
143
- except ValueError:
144
- pass
145
-
146
- codes = await get_config_value("auto_ban_error_codes")
147
- if codes and isinstance(codes, list):
148
- return codes
149
- return AUTO_BAN_ERROR_CODES
150
-
151
-
152
- async def get_retry_429_max_retries() -> int:
153
- """Get max retries for 429 errors."""
154
- env_value = os.getenv("RETRY_429_MAX_RETRIES")
155
- if env_value:
156
- try:
157
- return int(env_value)
158
- except ValueError:
159
- pass
160
-
161
- return int(await get_config_value("retry_429_max_retries", 5))
162
-
163
-
164
- async def get_retry_429_enabled() -> bool:
165
- """Get 429 retry enabled setting."""
166
- env_value = os.getenv("RETRY_429_ENABLED")
167
- if env_value:
168
- return env_value.lower() in ("true", "1", "yes", "on")
169
-
170
- return bool(await get_config_value("retry_429_enabled", True))
171
-
172
-
173
- async def get_retry_429_interval() -> float:
174
- """Get 429 retry interval in seconds."""
175
- env_value = os.getenv("RETRY_429_INTERVAL")
176
- if env_value:
177
- try:
178
- return float(env_value)
179
- except ValueError:
180
- pass
181
-
182
- return float(await get_config_value("retry_429_interval", 1))
183
-
184
-
185
- async def get_anti_truncation_max_attempts() -> int:
186
- """
187
- Get maximum attempts for anti-truncation continuation.
188
-
189
- Environment variable: ANTI_TRUNCATION_MAX_ATTEMPTS
190
- Database config key: anti_truncation_max_attempts
191
- Default: 3
192
- """
193
- env_value = os.getenv("ANTI_TRUNCATION_MAX_ATTEMPTS")
194
- if env_value:
195
- try:
196
- return int(env_value)
197
- except ValueError:
198
- pass
199
-
200
- return int(await get_config_value("anti_truncation_max_attempts", 3))
201
-
202
-
203
- # Server Configuration
204
- async def get_server_host() -> str:
205
- """
206
- Get server host setting.
207
-
208
- Environment variable: HOST
209
- Database config key: host
210
- Default: 0.0.0.0
211
- """
212
- return str(await get_config_value("host", "0.0.0.0", "HOST"))
213
-
214
-
215
- async def get_server_port() -> int:
216
- """
217
- Get server port setting.
218
-
219
- Environment variable: PORT
220
- Database config key: port
221
- Default: 7861
222
- """
223
- env_value = os.getenv("PORT")
224
- if env_value:
225
- try:
226
- return int(env_value)
227
- except ValueError:
228
- pass
229
-
230
- return int(await get_config_value("port", 7861))
231
-
232
-
233
- async def get_api_password() -> str:
234
- """
235
- Get API password setting for chat endpoints.
236
-
237
- Environment variable: API_PASSWORD
238
- Database config key: api_password
239
- Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
240
- """
241
- # 优先使用 API_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
242
- api_password = await get_config_value("api_password", None, "API_PASSWORD")
243
- if api_password is not None:
244
- return str(api_password)
245
-
246
- # 兼容性:使用通用密码
247
- return str(await get_config_value("password", "pwd", "PASSWORD"))
248
-
249
-
250
- async def get_panel_password() -> str:
251
- """
252
- Get panel password setting for web interface.
253
-
254
- Environment variable: PANEL_PASSWORD
255
- Database config key: panel_password
256
- Default: Uses PASSWORD env var for compatibility, otherwise 'pwd'
257
- """
258
- # 优先使用 PANEL_PASSWORD,如果没有则使用通用 PASSWORD 保证兼容性
259
- panel_password = await get_config_value("panel_password", None, "PANEL_PASSWORD")
260
- if panel_password is not None:
261
- return str(panel_password)
262
-
263
- # 兼容性:使用通用密码
264
- return str(await get_config_value("password", "pwd", "PASSWORD"))
265
-
266
-
267
- async def get_server_password() -> str:
268
- """
269
- Get server password setting (deprecated, use get_api_password or get_panel_password).
270
-
271
- Environment variable: PASSWORD
272
- Database config key: password
273
- Default: pwd
274
- """
275
- return str(await get_config_value("password", "pwd", "PASSWORD"))
276
-
277
-
278
- async def get_credentials_dir() -> str:
279
- """
280
- Get credentials directory setting.
281
-
282
- Environment variable: CREDENTIALS_DIR
283
- Database config key: credentials_dir
284
- Default: ./creds
285
- """
286
- return str(await get_config_value("credentials_dir", "./creds", "CREDENTIALS_DIR"))
287
-
288
-
289
- async def get_code_assist_endpoint() -> str:
290
- """
291
- Get Code Assist endpoint setting.
292
-
293
- Environment variable: CODE_ASSIST_ENDPOINT
294
- Database config key: code_assist_endpoint
295
- Default: https://cloudcode-pa.googleapis.com
296
- """
297
- return str(
298
- await get_config_value(
299
- "code_assist_endpoint", "https://cloudcode-pa.googleapis.com", "CODE_ASSIST_ENDPOINT"
300
- )
301
- )
302
-
303
-
304
- async def get_compatibility_mode_enabled() -> bool:
305
- """
306
- Get compatibility mode setting.
307
-
308
- 兼容性模式:启用后所有system消息全部转换成user,停用system_instructions。
309
- 该选项可能会降低模型理解能力,但是能避免流式空回的情况。
310
-
311
- Environment variable: COMPATIBILITY_MODE
312
- Database config key: compatibility_mode_enabled
313
- Default: False
314
- """
315
- env_value = os.getenv("COMPATIBILITY_MODE")
316
- if env_value:
317
- return env_value.lower() in ("true", "1", "yes", "on")
318
-
319
- return bool(await get_config_value("compatibility_mode_enabled", False))
320
-
321
-
322
- async def get_return_thoughts_to_frontend() -> bool:
323
- """
324
- Get return thoughts to frontend setting.
325
-
326
- 控制是否将思维链返回到前端。
327
- 启用后,思维链会在��应中返回;禁用后,思维链会在响应中被过滤掉。
328
-
329
- Environment variable: RETURN_THOUGHTS_TO_FRONTEND
330
- Database config key: return_thoughts_to_frontend
331
- Default: True
332
- """
333
- env_value = os.getenv("RETURN_THOUGHTS_TO_FRONTEND")
334
- if env_value:
335
- return env_value.lower() in ("true", "1", "yes", "on")
336
-
337
- return bool(await get_config_value("return_thoughts_to_frontend", True))
338
-
339
-
340
- async def get_antigravity_stream2nostream() -> bool:
341
- """
342
- Get use stream for non-stream setting.
343
-
344
- 控制antigravity非流式请求是否使用流式API并收集为完整响应。
345
- 启用后,非流式请求将在后端使用流式API,然后收集所有块后再返回完整响应。
346
-
347
- Environment variable: ANTIGRAVITY_STREAM2NOSTREAM
348
- Database config key: antigravity_stream2nostream
349
- Default: True
350
- """
351
- env_value = os.getenv("ANTIGRAVITY_STREAM2NOSTREAM")
352
- if env_value:
353
- return env_value.lower() in ("true", "1", "yes", "on")
354
-
355
- return bool(await get_config_value("antigravity_stream2nostream", True))
356
-
357
-
358
- async def get_antigravity_switch_credential_enabled() -> bool:
359
- """
360
- Get antigravity switch credential setting.
361
-
362
- 控制antigravity在重试时是否切换凭证。
363
- 禁用时会持续使用当前凭证,直到该凭证对当前模型进入CD或被禁用。
364
-
365
- Environment variable: ANTIGRAVITY_SWITCH_CREDENTIAL
366
- Database config key: antigravity_switch_credential_enabled
367
- Default: False
368
- """
369
- env_value = os.getenv("ANTIGRAVITY_SWITCH_CREDENTIAL")
370
- if env_value:
371
- return env_value.lower() in ("true", "1", "yes", "on")
372
-
373
- return bool(await get_config_value("antigravity_switch_credential_enabled", False))
374
-
375
-
376
- async def get_oauth_proxy_url() -> str:
377
- """
378
- Get OAuth proxy URL setting.
379
-
380
- 用于Google OAuth2认证的代理URL。
381
-
382
- Environment variable: OAUTH_PROXY_URL
383
- Database config key: oauth_proxy_url
384
- Default: https://oauth2.googleapis.com
385
- """
386
- return str(
387
- await get_config_value(
388
- "oauth_proxy_url", "https://oauth2.googleapis.com", "OAUTH_PROXY_URL"
389
- )
390
- )
391
-
392
-
393
- async def get_googleapis_proxy_url() -> str:
394
- """
395
- Get Google APIs proxy URL setting.
396
-
397
- 用于Google APIs调用的代理URL。
398
-
399
- Environment variable: GOOGLEAPIS_PROXY_URL
400
- Database config key: googleapis_proxy_url
401
- Default: https://www.googleapis.com
402
- """
403
- return str(
404
- await get_config_value(
405
- "googleapis_proxy_url", "https://www.googleapis.com", "GOOGLEAPIS_PROXY_URL"
406
- )
407
- )
408
-
409
-
410
- async def get_resource_manager_api_url() -> str:
411
- """
412
- Get Google Cloud Resource Manager API URL setting.
413
-
414
- 用于Google Cloud Resource Manager API的URL。
415
-
416
- Environment variable: RESOURCE_MANAGER_API_URL
417
- Database config key: resource_manager_api_url
418
- Default: https://cloudresourcemanager.googleapis.com
419
- """
420
- return str(
421
- await get_config_value(
422
- "resource_manager_api_url",
423
- "https://cloudresourcemanager.googleapis.com",
424
- "RESOURCE_MANAGER_API_URL",
425
- )
426
- )
427
-
428
-
429
- async def get_service_usage_api_url() -> str:
430
- """
431
- Get Google Cloud Service Usage API URL setting.
432
-
433
- 用于Google Cloud Service Usage API的URL。
434
-
435
- Environment variable: SERVICE_USAGE_API_URL
436
- Database config key: service_usage_api_url
437
- Default: https://serviceusage.googleapis.com
438
- """
439
- return str(
440
- await get_config_value(
441
- "service_usage_api_url", "https://serviceusage.googleapis.com", "SERVICE_USAGE_API_URL"
442
- )
443
- )
444
-
445
-
446
- async def get_antigravity_api_url() -> str:
447
- """
448
- Get Antigravity API URL setting.
449
-
450
- 用于Google Antigravity API的URL。
451
-
452
- Environment variable: ANTIGRAVITY_API_URL
453
- Database config key: antigravity_api_url
454
- Default: https://daily-cloudcode-pa.googleapis.com
455
- """
456
- return str(
457
- await get_config_value(
458
- "antigravity_api_url",
459
- "https://daily-cloudcode-pa.googleapis.com",
460
- "ANTIGRAVITY_API_URL",
461
- )
462
- )
463
-
464
-
465
- async def get_keepalive_url() -> str:
466
- """
467
- Get keep-alive URL setting.
468
-
469
- 配置后保活服务会定期向该URL发送GET请求。
470
- 留空表示禁用保活服务。
471
-
472
- Environment variable: KEEPALIVE_URL
473
- Database config key: keepalive_url
474
- Default: "" (disabled)
475
- """
476
- return str(await get_config_value("keepalive_url", "", "KEEPALIVE_URL"))
477
-
478
-
479
- async def get_keepalive_interval() -> int:
480
- """
481
- Get keep-alive interval in seconds.
482
-
483
- 保活请求发送间隔(秒)。
484
-
485
- Environment variable: KEEPALIVE_INTERVAL
486
- Database config key: keepalive_interval
487
- Default: 60
488
- """
489
- env_value = os.getenv("KEEPALIVE_INTERVAL")
490
- if env_value:
491
- try:
492
- return int(env_value)
493
- except ValueError:
494
- pass
495
-
496
- return int(await get_config_value("keepalive_interval", 60))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
credit.sh ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # credit.sh — WorkBuddy 积分日报(默认美化输出)
3
+ #
4
+ # 用法:
5
+ # ./credit.sh # 人类可读日报
6
+ # ./credit.sh -json # 原始 JSON
7
+ #
8
+ # 二进制升级: go build -o credit ./cmd/credit
9
+ set -euo pipefail
10
+ cd "$(dirname "$0")"
11
+ if [[ "${1:-}" == "-json" ]]; then
12
+ exec ./credit
13
+ fi
14
+ exec ./credit -pretty
darwin-install.sh DELETED
@@ -1,55 +0,0 @@
1
- #!/bin/bash
2
- # macOS 安装脚本 (支持 Intel 和 Apple Silicon)
3
-
4
- # 确保 Homebrew 已安装
5
- if ! command -v brew &> /dev/null; then
6
- echo "未检测到 Homebrew,开始安装..."
7
- /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
8
-
9
- # 检测 Homebrew 安装路径并设置环境变量
10
- if [[ -f "/opt/homebrew/bin/brew" ]]; then
11
- # Apple Silicon Mac
12
- eval "$(/opt/homebrew/bin/brew shellenv)"
13
- elif [[ -f "/usr/local/bin/brew" ]]; then
14
- # Intel Mac
15
- eval "$(/usr/local/bin/brew shellenv)"
16
- fi
17
- fi
18
-
19
- # 更新 brew 并安装 git
20
- brew update
21
- brew install git
22
-
23
- # 安装 uv (Python 环境管理工具)
24
- curl -Ls https://astral.sh/uv/install.sh | sh
25
-
26
- # 确保 uv 在 PATH 中
27
- export PATH="$HOME/.local/bin:$PATH"
28
-
29
- # 克隆或进入项目目录
30
- if [ -f "./web.py" ]; then
31
- # 已经在目标目录
32
- :
33
- elif [ -f "./gcli2api/web.py" ]; then
34
- cd ./gcli2api
35
- else
36
- git clone https://github.com/su-kaka/gcli2api.git
37
- cd ./gcli2api
38
- fi
39
-
40
- # 拉取最新代码
41
- git pull
42
-
43
- # 创建并同步虚拟环境
44
- uv sync
45
-
46
- # 激活虚拟环境
47
- if [ -f ".venv/bin/activate" ]; then
48
- source .venv/bin/activate
49
- else
50
- echo "❌ 未找到虚拟环境,请检查 uv 是否安装成功"
51
- exit 1
52
- fi
53
-
54
- # 启动项目
55
- python3 web.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker-compose.yml CHANGED
@@ -1,79 +1,15 @@
1
- version: '3.8'
2
-
3
  services:
4
- gcli2api:
5
- image: ghcr.io/su-kaka/gcli2api:latest
6
- container_name: gcli2api
7
  restart: unless-stopped
8
- network_mode: host
9
  environment:
10
- # Password configuration (choose one)
11
- # Option 1: Use common password
12
- - PASSWORD=${PASSWORD:-pwd}
13
- # Option 2: Use separate passwords (uncomment if needed)
14
- # - API_PASSWORD=${API_PASSWORD:-your_api_password}
15
- # - PANEL_PASSWORD=${PANEL_PASSWORD:-your_panel_password}
16
-
17
- # Server configuration
18
- - PORT=${PORT:-7861}
19
- - HOST=${HOST:-0.0.0.0}
20
-
21
- # Optional: Google credentials from environment
22
- # - GOOGLE_CREDENTIALS=${GOOGLE_CREDENTIALS}
23
-
24
- # Optional: Logging configuration
25
- # - LOG_LEVEL=${LOG_LEVEL:-info}
26
-
27
- # Optional: Redis configuration (for distributed storage)
28
- # - REDIS_URI=${REDIS_URI}
29
- # - REDIS_DATABASE=${REDIS_DATABASE:-0}
30
-
31
- # Optional: MongoDB configuration (for distributed storage)
32
- # - MONGODB_URI=${MONGODB_URI}
33
- # - MONGODB_DATABASE=${MONGODB_DATABASE:-gcli2api}
34
-
35
- # Optional: PostgreSQL configuration (for distributed storage)
36
- # - POSTGRES_DSN=${POSTGRES_DSN}
37
-
38
- # Optional: Proxy configuration
39
- # - PROXY=${PROXY}
40
  volumes:
41
- - ./data/creds:/app/creds
42
-
43
- # Example with Redis for distributed storage
44
- # redis:
45
- # image: redis:7-alpine
46
- # container_name: gcli2api-redis
47
- # restart: unless-stopped
48
- # ports:
49
- # - "6379:6379"
50
- # volumes:
51
- # - redis_data:/data
52
- # command: redis-server --appendonly yes
53
- # healthcheck:
54
- # test: ["CMD", "redis-cli", "ping"]
55
- # interval: 10s
56
- # timeout: 3s
57
- # retries: 3
58
-
59
- # Example with MongoDB for distributed storage
60
- # mongodb:
61
- # image: mongo:7
62
- # container_name: gcli2api-mongodb
63
- # restart: unless-stopped
64
- # environment:
65
- # MONGO_INITDB_ROOT_USERNAME: ${MONGO_USER:-admin}
66
- # MONGO_INITDB_ROOT_PASSWORD: ${MONGO_PASSWORD:-password}
67
- # ports:
68
- # - "27017:27017"
69
- # volumes:
70
- # - mongodb_data:/data/db
71
- # healthcheck:
72
- # test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
73
- # interval: 10s
74
- # timeout: 5s
75
- # retries: 3
76
-
77
- #volumes:
78
- # redis_data:
79
- # mongodb_data:
 
1
+ # 本地 / 自建服务器跑法(不用 HF 时)。HF Spaces 上不需要这个文件。
 
2
  services:
3
+ wb2api:
4
+ build: .
5
+ container_name: workbuddy2api
6
  restart: unless-stopped
 
7
  environment:
8
+ - TZ=Asia/Shanghai
9
+ ports:
10
+ - "7861:7861"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  volumes:
12
+ # 一个目录装下全部持久化状态,和 HF 上挂桶的布局保持一致:
13
+ # storage/config.json、storage/auths/、storage/data/
14
+ - ./storage:/app/storage
15
+ - ./storage/config.json:/app/config.json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker-entrypoint.sh ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # WorkBuddy2API Panel — Hugging Face Spaces 入口脚本
3
+ #
4
+ # 持久化模型:
5
+ # HF Storage Bucket(私有) -> 挂载到容器 /app/storage
6
+ # /app/storage/config.json 运行配置(含 api_key,私有卷上,不进公开仓库)
7
+ # /app/storage/auths/*.json 账号凭证
8
+ # /app/storage/data/*.json 水位/用量/模型探测/请求归档
9
+ #
10
+ # 容器根文件系统是 ephemeral 的:重启、休眠唤醒、重新构建都会重置。
11
+ # 所以配置和账号必须待在卷上,否则每次重启都要重新登录账号。
12
+ set -eu
13
+
14
+ PERSIST="${WB2A_PERSIST_DIR:-/app/storage}"
15
+ TEMPLATE=/app/config.json
16
+ CFG="$TEMPLATE"
17
+
18
+ echo "[entrypoint] boot at $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
19
+
20
+ if [ -d "$PERSIST" ]; then
21
+ echo "[entrypoint] persistent volume detected at $PERSIST"
22
+ mkdir -p "$PERSIST/auths" "$PERSIST/data"
23
+
24
+ # 落盘自检:程序全部走「写 .tmp 再 rename」,rename 不可用的话持久化是假的
25
+ if echo probe > "$PERSIST/.wb2a-write-probe" 2>/dev/null \
26
+ && mv "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null \
27
+ && rm -f "$PERSIST/.wb2a-rename-probe"; then
28
+ echo "[entrypoint] storage self-test: write+rename OK"
29
+ else
30
+ echo "[entrypoint] storage self-test: FAILED — write or rename unsupported on $PERSIST" >&2
31
+ rm -f "$PERSIST/.wb2a-write-probe" "$PERSIST/.wb2a-rename-probe" 2>/dev/null || true
32
+ fi
33
+
34
+ if [ ! -f "$PERSIST/config.json" ]; then
35
+ if [ -f "$TEMPLATE" ]; then
36
+ echo "[entrypoint] no config.json on the volume -> seeding from image template"
37
+ cp "$TEMPLATE" "$PERSIST/config.json"
38
+ else
39
+ echo "[entrypoint] no config.json on the volume and no image template" >&2
40
+ fi
41
+ fi
42
+ [ -f "$PERSIST/config.json" ] && CFG="$PERSIST/config.json"
43
+ else
44
+ echo "[entrypoint] WARNING: no persistent volume at $PERSIST — everything written will be LOST on restart" >&2
45
+ mkdir -p /app/auths /app/data
46
+ fi
47
+
48
+ # 安全闸门:api_key 为空 == 完全不鉴权(httpauth.VerifyBearer 语义)。
49
+ # 空间是公网可达的,绝不允许以裸奔状态启动。
50
+ if [ -n "${WB2A_API_KEY:-}" ]; then
51
+ echo "[entrypoint] api_key supplied via WB2A_API_KEY env"
52
+ else
53
+ KEY=$(python3 -c 'import json,sys
54
+ try:
55
+ print(json.load(open(sys.argv[1])).get("api_key") or "")
56
+ except Exception:
57
+ print("")' "$CFG" 2>/dev/null || true)
58
+ if [ -z "$KEY" ]; then
59
+ echo "[entrypoint] FATAL: api_key is empty in $CFG and WB2A_API_KEY is not set." >&2
60
+ echo "[entrypoint] Refusing to start: an empty api_key disables authentication on a public Space." >&2
61
+ echo "[entrypoint] Fix: write a config.json containing an api_key onto the bucket, or set the WB2A_API_KEY secret." >&2
62
+ exit 1
63
+ fi
64
+ echo "[entrypoint] api_key loaded from config file"
65
+ fi
66
+
67
+ echo "[entrypoint] config: $CFG"
68
+ echo "[entrypoint] auth_dir: $(dirname "$CFG")/auths (see config)"
69
+ exec /app/wb2api -config "$CFG"
docs/README_EN.md DELETED
@@ -1,883 +0,0 @@
1
- # GeminiCLI to API
2
-
3
- **Convert GeminiCLI and Antigravity to OpenAI, GEMINI, and Claude API Compatible Interfaces**
4
-
5
- [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
6
- [![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](../LICENSE)
7
- [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
8
-
9
- [中文](../README.md) | English | [日本語](./README_JA.md)
10
-
11
- ## 🚀 Quick Deploy
12
-
13
- [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
14
- [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
15
- ---
16
-
17
- ## Installation Guide
18
-
19
- ### Termux Environment
20
-
21
- **Initial Installation**
22
- ```bash
23
- curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
24
- ```
25
-
26
- **Restart Service**
27
- ```bash
28
- cd gcli2api
29
- bash termux-start.sh
30
- ```
31
-
32
- ### Windows Environment
33
-
34
- **Initial Installation**
35
- ```powershell
36
- iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
37
- ```
38
-
39
- **Restart Service**
40
- Double-click to execute `start.bat`
41
-
42
- ### Linux Environment
43
-
44
- **Initial Installation**
45
- ```bash
46
- curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
47
- ```
48
-
49
- **Restart Service**
50
- ```bash
51
- cd gcli2api
52
- bash start.sh
53
- ```
54
-
55
- ### macOS Environment
56
-
57
- **Initial Installation**
58
- ```bash
59
- curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
60
- ```
61
-
62
- **Restart Service**
63
- ```bash
64
- cd gcli2api
65
- bash start.sh
66
- ```
67
-
68
- ### Docker Environment
69
-
70
- **Docker Run Command**
71
- ```bash
72
- # Using universal password
73
- docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
74
-
75
- # Using separate passwords
76
- docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
77
- ```
78
-
79
- **Docker Mac**
80
- ```bash
81
- # Using universal password
82
- docker run -d \
83
- --name gcli2api \
84
- -p 7861:7861 \
85
- -p 8080:8080 \
86
- -e PASSWORD=pwd \
87
- -e PORT=7861 \
88
- -v "$(pwd)/data/creds":/app/creds \
89
- ghcr.io/su-kaka/gcli2api:latest
90
- ```
91
-
92
- ```bash
93
- # Using separate passwords
94
- docker run -d \
95
- --name gcli2api \
96
- -p 7861:7861 \
97
- -p 8080:8080 \
98
- -e API_PASSWORD=api_pwd \
99
- -e PANEL_PASSWORD=panel_pwd \
100
- -e PORT=7861 \
101
- -v $(pwd)/data/creds:/app/creds \
102
- ghcr.io/su-kaka/gcli2api:latest
103
- ```
104
-
105
- **Docker Compose Run Command**
106
- 1. Save the following content as `docker-compose.yml` file:
107
- ```yaml
108
- version: '3.8'
109
-
110
- services:
111
- gcli2api:
112
- image: ghcr.io/su-kaka/gcli2api:latest
113
- container_name: gcli2api
114
- restart: unless-stopped
115
- network_mode: host
116
- environment:
117
- # Using universal password (recommended for simple deployment)
118
- - PASSWORD=pwd
119
- - PORT=7861
120
- # Or use separate passwords (recommended for production)
121
- # - API_PASSWORD=your_api_password
122
- # - PANEL_PASSWORD=your_panel_password
123
- volumes:
124
- - ./data/creds:/app/creds
125
- healthcheck:
126
- test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
127
- interval: 30s
128
- timeout: 10s
129
- retries: 3
130
- start_period: 40s
131
- ```
132
- 2. Start the service:
133
- ```bash
134
- docker-compose up -d
135
- ```
136
-
137
- ## Core Features
138
-
139
- ### 🔄 API Endpoints and Format Support
140
-
141
- **Multi-endpoint Multi-format Support**
142
- - **OpenAI Compatible Endpoints**: `/v1/chat/completions` and `/v1/models`
143
- - Supports standard OpenAI format (messages structure)
144
- - Supports Gemini native format (contents structure)
145
- - Automatic format detection and conversion, no manual switching required
146
- - Supports multimodal input (text + images)
147
- - **Gemini Native Endpoints**: `/v1/models/{model}:generateContent` and `streamGenerateContent`
148
- - Supports complete Gemini native API specifications
149
- - Multiple authentication methods: Bearer Token, x-goog-api-key header, URL parameter key
150
- - **Claude Format Compatibility**: Full support for Claude API format
151
- - Endpoint: `/v1/messages` (follows Claude API specification)
152
- - Supports Claude standard messages format
153
- - Supports system parameter and Claude-specific features
154
- - Automatically converts to backend-supported format
155
- - **Antigravity API Support**: Supports OpenAI, Gemini, and Claude formats
156
- - OpenAI format endpoint: `/antigravity/v1/chat/completions`
157
- - Gemini format endpoint: `/antigravity/v1/models/{model}:generateContent` and `streamGenerateContent`
158
- - Claude format endpoint: `/antigravity/v1/messages`
159
- - Supports all Antigravity models (Claude, Gemini, etc.)
160
- - Automatic model name mapping and thinking mode detection
161
-
162
- ### 🔐 Authentication and Security Management
163
-
164
- **Flexible Password Management**
165
- - **Separate Password Support**: API password (chat endpoints) and control panel password can be set independently
166
- - **Multiple Authentication Methods**: Supports Authorization Bearer, x-goog-api-key header, URL parameters, etc.
167
- - **JWT Token Authentication**: Control panel supports JWT token authentication
168
- - **User Email Retrieval**: Automatically retrieves and displays Google account email addresses
169
-
170
- ### 📊 Intelligent Credential Management System
171
-
172
- **Advanced Credential Management**
173
- - Multiple Google OAuth credential automatic rotation
174
- - Enhanced stability through redundant authentication
175
- - Load balancing and concurrent request support
176
- - Automatic failure detection and credential disabling
177
- - Credential usage statistics and quota management
178
- - Support for manual enable/disable credential files
179
- - Batch credential file operations (enable, disable, delete)
180
-
181
- **Credential Status Monitoring**
182
- - Real-time credential health checks
183
- - Error code tracking (429, 403, 500, etc.)
184
- - Automatic banning mechanism (configurable)
185
-
186
- ### 🌊 Streaming and Response Processing
187
-
188
- **Multiple Streaming Support**
189
- - True real-time streaming responses
190
- - Fake streaming mode (for compatibility)
191
- - Streaming anti-truncation feature (prevents answer truncation)
192
- - Asynchronous task management and timeout handling
193
-
194
- **Response Optimization**
195
- - Thinking chain content separation
196
- - Reasoning process (reasoning_content) handling
197
- - Multi-turn conversation context management
198
- - Compatibility mode (converts system messages to user messages)
199
-
200
- ### 🎛️ Web Management Console
201
-
202
- **Full-featured Web Interface**
203
- - OAuth authentication flow management (supports GCLI and Antigravity dual modes)
204
- - Credential file upload, download, and management
205
- - Real-time log viewing (WebSocket)
206
- - System configuration management
207
- - Usage statistics and monitoring dashboard
208
- - Mobile-friendly interface
209
-
210
- **Batch Operation Support**
211
- - ZIP file batch credential upload (GCLI and Antigravity)
212
- - Batch enable/disable/delete credentials
213
- - Batch user email retrieval
214
- - Batch configuration management
215
- - Unified batch upload interface for all credential types
216
-
217
- ### 📈 Usage Monitoring
218
-
219
- **Real-time Monitoring**
220
- - WebSocket real-time log streams
221
- - System status monitoring
222
- - Credential health status
223
-
224
- ### 🔧 Advanced Configuration and Customization
225
-
226
- **Network and Proxy Configuration**
227
- - HTTP/HTTPS proxy support
228
- - Proxy endpoint configuration (OAuth, Google APIs, metadata service)
229
- - Timeout and retry configuration
230
- - Network error handling and recovery
231
-
232
- **Performance and Stability Configuration**
233
- - 429 error automatic retry (configurable interval and attempts)
234
- - Anti-truncation maximum retry attempts
235
-
236
- **Logging and Debugging**
237
- - Multi-level logging system (DEBUG, INFO, WARNING, ERROR)
238
- - Log file management
239
- - Real-time log streams
240
- - Log download and clearing
241
-
242
- ### 🔄 Environment Variables and Configuration Management
243
-
244
- **Flexible Configuration Methods**
245
- - Environment variable configuration
246
- - Hot configuration updates (partial configuration items)
247
- - Configuration locking (environment variable priority)
248
-
249
- ## Supported Models
250
-
251
- All models have 1M context window capacity. Each credential file provides 1000 request quota.
252
-
253
- ### 🤖 Base Models
254
- - `gemini-2.5-pro`
255
- - `gemini-3-pro-preview`
256
- - `gemini-3.1-pro-preview`
257
-
258
- ### 🧠 Thinking Models
259
- - `gemini-2.5-pro-high`: Thinking mode
260
- - `gemini-2.5-pro-low`: Low thinking mode
261
- - Supports custom thinking budget configuration
262
- - Automatic separation of thinking content and final answers
263
-
264
- ### 🔍 Search-Enhanced Models
265
- - `gemini-2.5-pro-search`: Model with integrated search functionality
266
-
267
- ### 🖼️ Image Generation Models (Antigravity)
268
- - `gemini-3.1-flash-image`: Base image generation model
269
- - **Resolution Suffixes**:
270
- - `-2k`: 2K resolution
271
- - `-4k`: 4K HD resolution
272
- - **Aspect Ratio Suffixes**:
273
- - `-1x1`: Square (avatar)
274
- - `-16x9`: Landscape (desktop wallpaper)
275
- - `-9x16`: Portrait (mobile wallpaper)
276
- - `-21x9`: Ultra-wide (ultrawide monitor)
277
- - `-4x3`: Traditional display
278
- - `-3x4`: Portrait poster
279
- - **Combination Examples**:
280
- - `gemini-3.1-flash-image-4k-16x9`: 4K landscape
281
- - `gemini-3.1-flash-image-2k-9x16`: 2K portrait
282
- - When no ratio is specified, the API automatically decides the aspect ratio
283
-
284
- ### 🌊 Special Feature Variants
285
- - **Fake Streaming Mode**: Add `-假流式` suffix to any model name
286
- - Example: `gemini-2.5-pro-假流式`
287
- - For scenarios requiring streaming responses but server doesn't support true streaming
288
- - **Streaming Anti-truncation Mode**: Add `流式抗截断/` prefix to model name
289
- - Example: `流式抗截断/gemini-2.5-pro`
290
- - Automatically detects response truncation and retries to ensure complete answers
291
-
292
- ### 🔧 Automatic Model Feature Detection
293
- - System automatically recognizes feature identifiers in model names
294
- - Transparently handles feature mode transitions
295
- - Supports feature combination usage
296
-
297
- ---
298
-
299
- ## Configuration Instructions
300
-
301
- 1. Visit `http://127.0.0.1:7861` (default port, modifiable via PORT environment variable)
302
- 2. Complete OAuth authentication flow (default password: `pwd`, modifiable via environment variables)
303
- - **GCLI Mode**: For obtaining Google Cloud Gemini API credentials
304
- - **Antigravity Mode**: For obtaining Google Antigravity API credentials
305
- 3. Configure client:
306
-
307
- **OpenAI Compatible Client:**
308
- - **Endpoint Address**: `http://127.0.0.1:7861/v1`
309
- - **API Key**: `pwd` (default value, modifiable via API_PASSWORD or PASSWORD environment variables)
310
-
311
- **Gemini Native Client:**
312
- - **Endpoint Address**: `http://127.0.0.1:7861`
313
- - **Authentication Methods**:
314
- - `Authorization: Bearer your_api_password`
315
- - `x-goog-api-key: your_api_password`
316
- - URL parameter: `?key=your_api_password`
317
-
318
- ### 🌟 Dual Authentication Mode Support
319
-
320
- **GCLI Authentication Mode**
321
- - Standard Google Cloud Gemini API authentication
322
- - Supports OAuth2.0 authentication flow
323
- - Automatically enables required Google Cloud APIs
324
-
325
- **Antigravity Authentication Mode**
326
- - Dedicated authentication for Google Antigravity API
327
- - Independent credential management system
328
- - Supports batch upload and management
329
- - Completely isolated from GCLI credentials
330
-
331
- **Unified Management Interface**
332
- - Manage both credential types in the "Batch Upload" tab
333
- - Upper section: GCLI credential batch upload (blue theme)
334
- - Lower section: Antigravity credential batch upload (green theme)
335
- - Separate credential management tabs for each type
336
-
337
- ## 💾 Data Storage Mode
338
-
339
- ### 🌟 Storage Backend Support
340
-
341
- gcli2api supports two storage backends: **Local SQLite (Default)** and **MongoDB (Cloud Distributed Storage)**
342
-
343
- ### 📁 Local SQLite Storage (Default)
344
-
345
- **Default Storage Method**
346
- - No configuration required, works out of the box
347
- - Data is stored in a local SQLite database
348
- - Suitable for single-machine deployment and personal use
349
- - Automatically creates and manages database files
350
-
351
- ### 🍃 MongoDB Cloud Storage Mode
352
-
353
- **Cloud Distributed Storage Solution**
354
-
355
- When multi-instance deployment or cloud storage is needed, MongoDB storage mode can be enabled.
356
-
357
- ### ⚙️ Enable MongoDB Mode
358
-
359
- **Step 1: Configure MongoDB Connection**
360
- ```bash
361
- # Local MongoDB
362
- export MONGODB_URI="mongodb://localhost:27017"
363
-
364
- # MongoDB Atlas cloud service
365
- export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
366
-
367
- # MongoDB with authentication
368
- export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
369
-
370
- # Optional: Custom database name (default: gcli2api)
371
- export MONGODB_DATABASE="my_gcli_db"
372
- ```
373
-
374
- **Step 2: Start Application**
375
- ```bash
376
- # Application will automatically detect MongoDB configuration and use MongoDB storage
377
- python web.py
378
- ```
379
-
380
- **Docker Environment using MongoDB**
381
- ```bash
382
- # Single MongoDB deployment
383
- docker run -d --name gcli2api \
384
- -e MONGODB_URI="mongodb://mongodb:27017" \
385
- -e API_PASSWORD=your_password \
386
- --network your_network \
387
- ghcr.io/su-kaka/gcli2api:latest
388
-
389
- # Using MongoDB Atlas
390
- docker run -d --name gcli2api \
391
- -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
392
- -e API_PASSWORD=your_password \
393
- -p 7861:7861 \
394
- ghcr.io/su-kaka/gcli2api:latest
395
- ```
396
-
397
- **Docker Compose Example**
398
- ```yaml
399
- version: '3.8'
400
-
401
- services:
402
- mongodb:
403
- image: mongo:7
404
- container_name: gcli2api-mongodb
405
- restart: unless-stopped
406
- environment:
407
- MONGO_INITDB_ROOT_USERNAME: admin
408
- MONGO_INITDB_ROOT_PASSWORD: password123
409
- volumes:
410
- - mongodb_data:/data/db
411
- ports:
412
- - "27017:27017"
413
-
414
- gcli2api:
415
- image: ghcr.io/su-kaka/gcli2api:latest
416
- container_name: gcli2api
417
- restart: unless-stopped
418
- depends_on:
419
- - mongodb
420
- environment:
421
- - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
422
- - MONGODB_DATABASE=gcli2api
423
- - API_PASSWORD=your_api_password
424
- - PORT=7861
425
- ports:
426
- - "7861:7861"
427
-
428
- volumes:
429
- mongodb_data:
430
- ```
431
-
432
-
433
- ### 🔧 Advanced Configuration
434
-
435
- **MongoDB Connection Optimization**
436
- ```bash
437
- # Connection pool and timeout configuration
438
- export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
439
-
440
- # Replica set configuration
441
- export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
442
-
443
- # Read-write separation configuration
444
- export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
445
- ```
446
-
447
- ### Environment Variable Configuration
448
-
449
- **Basic Configuration**
450
- - `PORT`: Service port (default: 7861)
451
- - `HOST`: Server listen address (default: 0.0.0.0)
452
-
453
- **Password Configuration**
454
- - `API_PASSWORD`: Chat API access password (default: inherits PASSWORD or pwd)
455
- - `PANEL_PASSWORD`: Control panel access password (default: inherits PASSWORD or pwd)
456
- - `PASSWORD`: Universal password, overrides the above two when set (default: pwd)
457
-
458
- **Performance and Stability Configuration**
459
- - `RETRY_429_ENABLED`: Enable 429 error automatic retry (default: true)
460
- - `RETRY_429_MAX_RETRIES`: Maximum retry attempts for 429 errors (default: 3)
461
- - `RETRY_429_INTERVAL`: Retry interval for 429 errors, in seconds (default: 1.0)
462
- - `ANTI_TRUNCATION_MAX_ATTEMPTS`: Maximum retry attempts for anti-truncation (default: 3)
463
-
464
- **Network and Proxy Configuration**
465
- - `PROXY`: HTTP/HTTPS proxy address (format: `http://host:port`)
466
- - `OAUTH_PROXY_URL`: OAuth authentication proxy endpoint
467
- - `GOOGLEAPIS_PROXY_URL`: Google APIs proxy endpoint
468
- - `METADATA_SERVICE_URL`: Metadata service proxy endpoint
469
-
470
- **Automation Configuration**
471
- - `AUTO_BAN`: Enable automatic credential banning (default: true)
472
- - `AUTO_LOAD_ENV_CREDS`: Automatically load environment variable credentials at startup (default: false)
473
-
474
- **Compatibility Configuration**
475
- - `COMPATIBILITY_MODE`: Enable compatibility mode, converts system messages to user messages (default: false)
476
-
477
- **Logging Configuration**
478
- - `LOG_LEVEL`: Log level (DEBUG/INFO/WARNING/ERROR, default: INFO)
479
- - `LOG_FILE`: Log file path (default: log.txt)
480
-
481
- **Storage Configuration**
482
-
483
- **SQLite Configuration (Default)**
484
- - No configuration required, automatically uses local SQLite database
485
- - Database files are automatically created in the project directory
486
-
487
- **MongoDB Configuration (Optional Cloud Storage)**
488
- - `MONGODB_URI`: MongoDB connection string (enables MongoDB mode when set)
489
- - `MONGODB_DATABASE`: MongoDB database name (default: gcli2api)
490
-
491
- **Docker Usage Example**
492
- ```bash
493
- # Using universal password
494
- docker run -d --name gcli2api \
495
- -e PASSWORD=mypassword \
496
- -e PORT=7861 \
497
- ghcr.io/su-kaka/gcli2api:latest
498
-
499
- # Using separate passwords
500
- docker run -d --name gcli2api \
501
- -e API_PASSWORD=my_api_password \
502
- -e PANEL_PASSWORD=my_panel_password \
503
- -e PORT=7861 \
504
- ghcr.io/su-kaka/gcli2api:latest
505
- ```
506
-
507
- Note: When credential environment variables are set, the system will prioritize using credentials from environment variables and ignore files in the `creds` directory.
508
-
509
- ### API Usage Methods
510
-
511
- This service supports multiple complete sets of API endpoints:
512
-
513
- #### 1. OpenAI Compatible Endpoints (GCLI)
514
-
515
- **Endpoint:** `/v1/chat/completions`
516
- **Authentication:** `Authorization: Bearer your_api_password`
517
-
518
- Supports two request formats with automatic detection and processing:
519
-
520
- **OpenAI Format:**
521
- ```json
522
- {
523
- "model": "gemini-2.5-pro",
524
- "messages": [
525
- {"role": "system", "content": "You are a helpful assistant"},
526
- {"role": "user", "content": "Hello"}
527
- ],
528
- "temperature": 0.7,
529
- "stream": true
530
- }
531
- ```
532
-
533
- **Gemini Native Format:**
534
- ```json
535
- {
536
- "model": "gemini-2.5-pro",
537
- "contents": [
538
- {"role": "user", "parts": [{"text": "Hello"}]}
539
- ],
540
- "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
541
- "generationConfig": {
542
- "temperature": 0.7
543
- }
544
- }
545
- ```
546
-
547
- #### 2. Gemini Native Endpoints (GCLI)
548
-
549
- **Non-streaming Endpoint:** `/v1/models/{model}:generateContent`
550
- **Streaming Endpoint:** `/v1/models/{model}:streamGenerateContent`
551
- **Model List:** `/v1/models`
552
-
553
- **Authentication Methods (choose one):**
554
- - `Authorization: Bearer your_api_password`
555
- - `x-goog-api-key: your_api_password`
556
- - URL parameter: `?key=your_api_password`
557
-
558
- **Request Examples:**
559
- ```bash
560
- # Using x-goog-api-key header
561
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
562
- -H "x-goog-api-key: your_api_password" \
563
- -H "Content-Type: application/json" \
564
- -d '{
565
- "contents": [
566
- {"role": "user", "parts": [{"text": "Hello"}]}
567
- ]
568
- }'
569
-
570
- # Using URL parameter
571
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
572
- -H "Content-Type: application/json" \
573
- -d '{
574
- "contents": [
575
- {"role": "user", "parts": [{"text": "Hello"}]}
576
- ]
577
- }'
578
- ```
579
-
580
- #### 3. Claude API Format Endpoints
581
-
582
- **Endpoint:** `/v1/messages`
583
- **Authentication:** `x-api-key: your_api_password` or `Authorization: Bearer your_api_password`
584
-
585
- **Request Example:**
586
- ```bash
587
- curl -X POST "http://127.0.0.1:7861/v1/messages" \
588
- -H "x-api-key: your_api_password" \
589
- -H "anthropic-version: 2023-06-01" \
590
- -H "Content-Type: application/json" \
591
- -d '{
592
- "model": "gemini-2.5-pro",
593
- "max_tokens": 1024,
594
- "messages": [
595
- {"role": "user", "content": "Hello, Claude!"}
596
- ]
597
- }'
598
- ```
599
-
600
- **Support for system parameter:**
601
- ```json
602
- {
603
- "model": "gemini-2.5-pro",
604
- "max_tokens": 1024,
605
- "system": "You are a helpful assistant",
606
- "messages": [
607
- {"role": "user", "content": "Hello"}
608
- ]
609
- }
610
- ```
611
-
612
- **Notes:**
613
- - Fully compatible with Claude API format specification
614
- - Automatically converts to Gemini format for backend calls
615
- - Supports all Claude standard parameters
616
- - Response format follows Claude API specification
617
-
618
- #### 4. Antigravity API Endpoints
619
-
620
- **Supports three formats: OpenAI, Gemini, and Claude**
621
-
622
- ##### Antigravity OpenAI Format Endpoints
623
-
624
- **Endpoint:** `/antigravity/v1/chat/completions`
625
- **Authentication:** `Authorization: Bearer your_api_password`
626
-
627
- **Request Example:**
628
- ```bash
629
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
630
- -H "Authorization: Bearer your_api_password" \
631
- -H "Content-Type: application/json" \
632
- -d '{
633
- "model": "claude-sonnet-4-5",
634
- "messages": [
635
- {"role": "user", "content": "Hello"}
636
- ],
637
- "stream": true
638
- }'
639
- ```
640
-
641
- ##### Antigravity Gemini Format Endpoints
642
-
643
- **Non-streaming Endpoint:** `/antigravity/v1/models/{model}:generateContent`
644
- **Streaming Endpoint:** `/antigravity/v1/models/{model}:streamGenerateContent`
645
-
646
- **Authentication Methods (choose one):**
647
- - `Authorization: Bearer your_api_password`
648
- - `x-goog-api-key: your_api_password`
649
- - URL parameter: `?key=your_api_password`
650
-
651
- **Request Examples:**
652
- ```bash
653
- # Gemini format non-streaming request
654
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
655
- -H "x-goog-api-key: your_api_password" \
656
- -H "Content-Type: application/json" \
657
- -d '{
658
- "contents": [
659
- {"role": "user", "parts": [{"text": "Hello"}]}
660
- ],
661
- "generationConfig": {
662
- "temperature": 0.7
663
- }
664
- }'
665
-
666
- # Gemini format streaming request
667
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
668
- -H "Content-Type: application/json" \
669
- -d '{
670
- "contents": [
671
- {"role": "user", "parts": [{"text": "Hello"}]}
672
- ]
673
- }'
674
- ```
675
-
676
- ##### Antigravity Claude Format Endpoints
677
-
678
- **Endpoint:** `/antigravity/v1/messages`
679
- **Authentication:** `x-api-key: your_api_password`
680
-
681
- **Request Example:**
682
- ```bash
683
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
684
- -H "x-api-key: your_api_password" \
685
- -H "anthropic-version: 2023-06-01" \
686
- -H "Content-Type: application/json" \
687
- -d '{
688
- "model": "claude-sonnet-4-5",
689
- "max_tokens": 1024,
690
- "messages": [
691
- {"role": "user", "content": "Hello"}
692
- ]
693
- }'
694
- ```
695
-
696
- **Supported Antigravity Models:**
697
- - Claude series: `claude-sonnet-4-5`, `claude-opus-4-5`, etc.
698
- - Gemini series: `gemini-2.5-flash`, `gemini-2.5-pro`, etc.
699
- - Automatically supports thinking models
700
-
701
- **Gemini Native Example:**
702
- ```python
703
- from io import BytesIO
704
- from PIL import Image
705
- from google.genai import Client
706
- from google.genai.types import HttpOptions
707
- from google.genai import types
708
- # The client gets the API key from the environment variable `GEMINI_API_KEY`.
709
-
710
- client = Client(
711
- api_key="pwd",
712
- http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
713
- )
714
-
715
- prompt = (
716
- """
717
- Draw a cat
718
- """
719
- )
720
-
721
- response = client.models.generate_content(
722
- model="gemini-3.1-flash-image",
723
- contents=[prompt],
724
- config=types.GenerateContentConfig(
725
- image_config=types.ImageConfig(
726
- aspect_ratio="16:9",
727
- )
728
- )
729
- )
730
- for part in response.candidates[0].content.parts:
731
- if part.text is not None:
732
- print(part.text)
733
- elif part.inline_data is not None:
734
- image = Image.open(BytesIO(part.inline_data.data))
735
- image.save("generated_image.png")
736
-
737
- ```
738
-
739
- **Notes:**
740
- - OpenAI endpoints return OpenAI-compatible format
741
- - Gemini endpoints return Gemini native format
742
- - Both endpoints use the same API password
743
-
744
- ## 📋 Complete API Reference
745
-
746
- ### Web Console API
747
-
748
- **Authentication Endpoints**
749
- - `POST /auth/login` - User login
750
- - `POST /auth/start` - Start OAuth authentication (supports GCLI and Antigravity modes)
751
- - `POST /auth/callback` - Handle OAuth callback
752
- - `POST /auth/callback-url` - Complete authentication directly from callback URL
753
- - `GET /auth/status/{project_id}` - Check authentication status
754
-
755
- **Credential Management Endpoints** (supports `mode=geminicli` or `mode=antigravity` parameter)
756
- - `POST /creds/upload` - Batch upload credential files (supports JSON and ZIP)
757
- - `GET /creds/status` - Get credential status list (supports pagination and filtering)
758
- - `GET /creds/detail/{filename}` - Get single credential details
759
- - `POST /creds/action` - Single credential operation (enable/disable/delete)
760
- - `POST /creds/batch-action` - Batch credential operations
761
- - `GET /creds/download/{filename}` - Download single credential file
762
- - `GET /creds/download-all` - Package download all credentials
763
- - `POST /creds/fetch-email/{filename}` - Get user email
764
- - `POST /creds/refresh-all-emails` - Batch refresh user emails
765
- - `POST /creds/deduplicate-by-email` - Deduplicate credentials by email
766
- - `POST /creds/verify-project/{filename}` - Verify credential Project ID
767
- - `GET /creds/quota/{filename}` - Get credential quota information (Antigravity only)
768
-
769
- **Configuration Management Endpoints**
770
- - `GET /config/get` - Get current configuration
771
- - `POST /config/save` - Save configuration
772
-
773
- **Log Management Endpoints**
774
- - `POST /logs/clear` - Clear logs
775
- - `GET /logs/download` - Download log file
776
- - `WebSocket /logs/stream` - Real-time log stream
777
-
778
- **Version Information Endpoints**
779
- - `GET /version/info` - Get version information (optional `check_update=true` parameter to check for updates)
780
-
781
- ### Chat API Features
782
-
783
- **Multimodal Support**
784
- ```json
785
- {
786
- "model": "gemini-2.5-pro",
787
- "messages": [
788
- {
789
- "role": "user",
790
- "content": [
791
- {"type": "text", "text": "Describe this image"},
792
- {
793
- "type": "image_url",
794
- "image_url": {
795
- "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
796
- }
797
- }
798
- ]
799
- }
800
- ]
801
- }
802
- ```
803
-
804
- **Thinking Mode Support**
805
- ```json
806
- {
807
- "model": "gemini-2.5-pro-high",
808
- "messages": [
809
- {"role": "user", "content": "Complex math problem"}
810
- ]
811
- }
812
- ```
813
-
814
- Response will include separated thinking content:
815
- ```json
816
- {
817
- "choices": [{
818
- "message": {
819
- "role": "assistant",
820
- "content": "Final answer",
821
- "reasoning_content": "Detailed thought process..."
822
- }
823
- }]
824
- }
825
- ```
826
-
827
- **Streaming Anti-truncation Usage**
828
- ```json
829
- {
830
- "model": "流式抗截断/gemini-2.5-pro",
831
- "messages": [
832
- {"role": "user", "content": "Write a long article"}
833
- ],
834
- "stream": true
835
- }
836
- ```
837
-
838
- **Compatibility Mode**
839
- ```bash
840
- # Enable compatibility mode
841
- export COMPATIBILITY_MODE=true
842
- ```
843
- In this mode, all `system` messages are converted to `user` messages, improving compatibility with certain clients.
844
-
845
- ---
846
-
847
- ## 💬 Community
848
-
849
- Welcome to join the QQ group for discussion!
850
-
851
- **QQ Group: 1083250744**
852
-
853
- <img src="qq群.jpg" width="200" alt="QQ Group QR Code">
854
-
855
- ---
856
-
857
- ## License and Disclaimer
858
-
859
- This project is for learning and research purposes only. Using this project indicates that you agree to:
860
- - Not use this project for any commercial purposes
861
- - Bear all risks and responsibilities of using this project
862
- - Comply with relevant terms of service and legal regulations
863
-
864
- The project authors are not responsible for any direct or indirect losses arising from the use of this project.
865
-
866
- ## ⚠️ License Declaration
867
-
868
- **This project is licensed under the Cooperative Non-Commercial License (CNC-1.0)**
869
-
870
- This is a strict anti-commercial open source license. Please refer to the [LICENSE](../LICENSE) file for details.
871
-
872
- ### ✅ Permitted Uses:
873
- - Personal learning, research, and educational purposes
874
- - Non-profit organization use
875
- - Open source project integration (must comply with the same license)
876
- - Academic research and publication
877
-
878
- ### ❌ Prohibited Uses:
879
- - Any form of commercial use
880
- - Enterprise use with annual revenue exceeding $1 million
881
- - Venture capital-backed or publicly traded companies
882
- - Providing paid services or products
883
- - Commercial competitive use
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docs/README_JA.md DELETED
@@ -1,883 +0,0 @@
1
- # GeminiCLI to API
2
-
3
- **GeminiCLIおよびAntigravityをOpenAI、GEMINI、Claude API互換インターフェースに変換**
4
-
5
- [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
6
- [![License: CNC-1.0](https://img.shields.io/badge/License-CNC--1.0-red.svg)](../LICENSE)
7
- [![Docker](https://img.shields.io/badge/docker-available-blue.svg)](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
8
-
9
- [中文](../README.md) | [English](README_EN.md) | 日本語
10
-
11
- ## 🚀 クイックデプロイ
12
-
13
- [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
14
- [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/su-kaka/gcli2api)
15
- ---
16
-
17
- ## インストールガイド
18
-
19
- ### Termux環境
20
-
21
- **初期インストール**
22
- ```bash
23
- curl -o termux-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/termux-install.sh" && chmod +x termux-install.sh && ./termux-install.sh
24
- ```
25
-
26
- **サービス再起動**
27
- ```bash
28
- cd gcli2api
29
- bash termux-start.sh
30
- ```
31
-
32
- ### Windows環境
33
-
34
- **初期インストール**
35
- ```powershell
36
- iex (iwr "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.ps1" -UseBasicParsing).Content
37
- ```
38
-
39
- **サービス再起動**
40
- `start.bat` をダブルクリックして実行
41
-
42
- ### Linux環境
43
-
44
- **初期インストール**
45
- ```bash
46
- curl -o install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/install.sh" && chmod +x install.sh && ./install.sh
47
- ```
48
-
49
- **サービス再起動**
50
- ```bash
51
- cd gcli2api
52
- bash start.sh
53
- ```
54
-
55
- ### macOS環境
56
-
57
- **初期インストール**
58
- ```bash
59
- curl -o darwin-install.sh "https://raw.githubusercontent.com/su-kaka/gcli2api/refs/heads/master/darwin-install.sh" && chmod +x darwin-install.sh && ./darwin-install.sh
60
- ```
61
-
62
- **サービス再起動**
63
- ```bash
64
- cd gcli2api
65
- bash start.sh
66
- ```
67
-
68
- ### Docker環境
69
-
70
- **Docker Runコマンド**
71
- ```bash
72
- # 共通パスワードを使用
73
- docker run -d --name gcli2api --network host -e PASSWORD=pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
74
-
75
- # 個別パスワードを使用
76
- docker run -d --name gcli2api --network host -e API_PASSWORD=api_pwd -e PANEL_PASSWORD=panel_pwd -e PORT=7861 -v $(pwd)/data/creds:/app/creds ghcr.io/su-kaka/gcli2api:latest
77
- ```
78
-
79
- **Docker Mac**
80
- ```bash
81
- # 共通パスワードを使用
82
- docker run -d \
83
- --name gcli2api \
84
- -p 7861:7861 \
85
- -p 8080:8080 \
86
- -e PASSWORD=pwd \
87
- -e PORT=7861 \
88
- -v "$(pwd)/data/creds":/app/creds \
89
- ghcr.io/su-kaka/gcli2api:latest
90
- ```
91
-
92
- ```bash
93
- # 個別パスワードを使用
94
- docker run -d \
95
- --name gcli2api \
96
- -p 7861:7861 \
97
- -p 8080:8080 \
98
- -e API_PASSWORD=api_pwd \
99
- -e PANEL_PASSWORD=panel_pwd \
100
- -e PORT=7861 \
101
- -v $(pwd)/data/creds:/app/creds \
102
- ghcr.io/su-kaka/gcli2api:latest
103
- ```
104
-
105
- **Docker Compose Runコマンド**
106
- 1. 以下の内容を `docker-compose.yml` ファイルとして保存:
107
- ```yaml
108
- version: '3.8'
109
-
110
- services:
111
- gcli2api:
112
- image: ghcr.io/su-kaka/gcli2api:latest
113
- container_name: gcli2api
114
- restart: unless-stopped
115
- network_mode: host
116
- environment:
117
- # 共通パスワードを使用(シンプルなデプロイに推奨)
118
- - PASSWORD=pwd
119
- - PORT=7861
120
- # または個別パスワードを使用(本番環境に推奨)
121
- # - API_PASSWORD=your_api_password
122
- # - PANEL_PASSWORD=your_panel_password
123
- volumes:
124
- - ./data/creds:/app/creds
125
- healthcheck:
126
- test: ["CMD-SHELL", "python -c \"import sys, urllib.request, os; port = os.environ.get('PORT', '7861'); req = urllib.request.Request(f'http://localhost:{port}/v1/models', headers={'Authorization': 'Bearer ' + os.environ.get('PASSWORD', 'pwd')}); sys.exit(0 if urllib.request.urlopen(req, timeout=5).getcode() == 200 else 1)\""]
127
- interval: 30s
128
- timeout: 10s
129
- retries: 3
130
- start_period: 40s
131
- ```
132
- 2. サービスを起動:
133
- ```bash
134
- docker-compose up -d
135
- ```
136
-
137
- ## コア機能
138
-
139
- ### 🔄 APIエンドポイントとフォーマット対応
140
-
141
- **マルチエンドポイント・マルチフォーマット対応**
142
- - **OpenAI互換エンドポイント**: `/v1/chat/completions` および `/v1/models`
143
- - 標準OpenAIフォーマット(messages構造)に対応
144
- - Geminiネイティブフォーマット(contents構造)に対応
145
- - フォーマットの自動検出・変換、手動切替不要
146
- - マルチモーダル入力に対応(テキスト+画像)
147
- - **Geminiネイティブエンドポイント**: `/v1/models/{model}:generateContent` および `streamGenerateContent`
148
- - Geminiネイティブ API仕様に完全対応
149
- - 複数の認証方式: Bearer Token、x-goog-api-keyヘッダー、URLパラメータkey
150
- - **Claudeフォーマット互換**: Claude APIフォーマットに完全��応
151
- - エンドポイント: `/v1/messages`(Claude API仕様に準拠)
152
- - Claude標準messagesフォーマットに対応
153
- - systemパラメータおよびClaude固有機能に対応
154
- - バックエンド対応フォーマットへの自動変換
155
- - **Antigravity API対応**: OpenAI、Gemini、Claudeフォーマットに対応
156
- - OpenAIフォーマットエンドポイント: `/antigravity/v1/chat/completions`
157
- - Geminiフォーマットエンドポイント: `/antigravity/v1/models/{model}:generateContent` および `streamGenerateContent`
158
- - Claudeフォーマットエンドポイント: `/antigravity/v1/messages`
159
- - 全Antigravityモデルに対応(Claude、Geminiなど)
160
- - モデル名の自動マッピングおよびThinkingモード検出
161
-
162
- ### 🔐 認証とセキュリティ管理
163
-
164
- **柔軟なパスワード管理**
165
- - **個別パスワード対応**: APIパスワード(チャットエンドポイント)とコントロールパネルパスワードを個別に設定可能
166
- - **複数の認証方式**: Authorization Bearer、x-goog-api-keyヘッダー、URLパラメータなどに対応
167
- - **JWTトークン認証**: コントロールパネルはJWTトークン認証に対応
168
- - **ユーザーメール取得**: Googleアカウントのメールアドレスを自動取得・表示
169
-
170
- ### 📊 インテリジェントなクレデンシャル管理システム
171
-
172
- **高度なクレデンシャル管理**
173
- - 複数のGoogle OAuthクレデンシャルの自動ローテーション
174
- - 冗長認証による安定性の向上
175
- - ロードバランシングと同時リクエスト対応
176
- - 自動障害検出とクレデンシャル無効化
177
- - クレデンシャル使用統計とクォータ管理
178
- - クレデンシャルファイルの手動有効化/無効化に対応
179
- - クレデンシャルファイルの一括操作(有効化、無効化、削除)
180
-
181
- **クレデンシャルステータス監視**
182
- - リアルタイムのクレデンシャルヘルスチェック
183
- - エラーコードの追跡(429、403、500など)
184
- - 自動BAN機能(設定可能)
185
-
186
- ### 🌊 ストリーミングとレスポンス処理
187
-
188
- **複数のストリーミング対応**
189
- - リアルタイムストリーミングレスポンス
190
- - 疑似ストリーミングモード(互換性向上用)
191
- - ストリーミング途切れ防止機能(回答の途切れを防止)
192
- - 非同期タスク管理とタイムアウト処理
193
-
194
- **レスポンス最適化**
195
- - 思考チェーン内容の分離
196
- - 推論プロセス(reasoning_content)の処理
197
- - マルチターン会話のコンテキスト管理
198
- - 互換モード(systemメッセージをuserメッセージに変換)
199
-
200
- ### 🎛️ Web管理コンソール
201
-
202
- **フル機能のWebインターフェース**
203
- - OAuth認証フロー管理(GCLIおよびAntigravityデュアルモード対応)
204
- - クレデンシャルファイルのアップロード、ダウンロード、管理
205
- - リアルタイムログ表示(WebSocket)
206
- - システム設定管理
207
- - 使用統計と監視ダッシュボード
208
- - モバイル対応インターフェース
209
-
210
- **一括操作対応**
211
- - ZIPファイルによるクレデンシャル一括アップロード(GCLIおよびAntigravity)
212
- - クレデンシャルの一括有効化/無効化/削除
213
- - ユーザーメールの一括取得
214
- - 設定の一括管理
215
- - 全クレデンシャルタイプ統合一括アップロードインターフェース
216
-
217
- ### 📈 使用状況モニタリング
218
-
219
- **リアルタイム監視**
220
- - WebSocketリアルタイムログストリーム
221
- - システムステータス監視
222
- - クレデンシャルヘルスステータス
223
-
224
- ### 🔧 高度な設定とカスタマイズ
225
-
226
- **ネットワークとプロキシ設定**
227
- - HTTP/HTTPSプロキシ対応
228
- - プロキシエンドポイント設定(OAuth、Google APIs、メタデータサービス)
229
- - タイムアウトとリトライ設定
230
- - ネットワークエラー処理とリカバリ
231
-
232
- **パフォーマンスと安定性の設定**
233
- - 429エラーの自動リトライ(間隔と回数を設定可能)
234
- - 途切れ防止の最大リトライ回数
235
-
236
- **ログとデバッグ**
237
- - マルチレベルログシステム(DEBUG、INFO、WARNING、ERROR)
238
- - ログファイル管理
239
- - リアルタイムログストリーム
240
- - ログのダウンロードとクリア
241
-
242
- ### 🔄 環境変数と設定管理
243
-
244
- **柔軟な設定方法**
245
- - 環境変数による設定
246
- - ホット設定更新(一部設定項目)
247
- - 設定ロック(環境変数優先)
248
-
249
- ## 対応モデル
250
-
251
- 全モデルが100万トークンのコンテキストウィンドウに対応。各クレデンシャルファイルで1000リクエストのクォータを提供。
252
-
253
- ### 🤖 基本モデル
254
- - `gemini-2.5-pro`
255
- - `gemini-3-pro-preview`
256
- - `gemini-3.1-pro-preview`
257
-
258
- ### 🧠 Thinkingモデル
259
- - `gemini-2.5-pro-high`: Thinkingモード
260
- - `gemini-2.5-pro-low`: 低Thinkingモード
261
- - カスタムThinkingバジェット設定に対応
262
- - 思考内���と最終回答の自動分離
263
-
264
- ### 🔍 検索拡張モデル
265
- - `gemini-2.5-pro-search`: 検索機能統合モデル
266
-
267
- ### 🖼️ 画像生成モデル(Antigravity)
268
- - `gemini-3.1-flash-image`: 基本画像生成モデル
269
- - **解像度サフィックス**:
270
- - `-2k`: 2K解像度
271
- - `-4k`: 4K HD解像度
272
- - **アスペクト比サフィックス**:
273
- - `-1x1`: 正方形(アバター)
274
- - `-16x9`: 横長(デスクトップ壁紙)
275
- - `-9x16`: 縦長(モバイル壁紙)
276
- - `-21x9`: ウルトラワイド(ウルトラワイドモニター)
277
- - `-4x3`: 従来のディスプレイ
278
- - `-3x4`: 縦型ポスター
279
- - **組み合わせ例**:
280
- - `gemini-3.1-flash-image-4k-16x9`: 4K横長
281
- - `gemini-3.1-flash-image-2k-9x16`: 2K縦長
282
- - 比率未指定時はAPIが自動的にアスペクト比を決定
283
-
284
- ### 🌊 特殊機能バリアント
285
- - **疑似ストリーミングモード**: 任意のモデル名に `-假流式` サフィックスを追加
286
- - 例: `gemini-2.5-pro-假流式`
287
- - ストリーミングレスポンスが必要だがサーバーが真のストリーミングに対応していない場合に使用
288
- - **ストリーミング途切れ防止モード**: モデル名に `流式抗截断/` プレフィックスを追加
289
- - 例: `流式抗截断/gemini-2.5-pro`
290
- - レスポンスの途切れを自動検出しリトライして完全な回答を保証
291
-
292
- ### 🔧 モデル機能の自動検出
293
- - システムがモデル名内の機能識別子を自動認識
294
- - 機能モード切替を透過的に処理
295
- - 機能の組み合わせ使用に対応
296
-
297
- ---
298
-
299
- ## 設定手順
300
-
301
- 1. `http://127.0.0.1:7861` にアクセス(デフォルトポート、PORT環境変数で変更可能)
302
- 2. OAuth認証フローを完了(デフォルトパスワード: `pwd`、環境変数で変更可能)
303
- - **GCLIモード**: Google Cloud Gemini APIクレデンシャルの取得用
304
- - **Antigravityモード**: Google Antigravity APIクレデンシャルの取得用
305
- 3. クライアントを設定:
306
-
307
- **OpenAI互換クライアント:**
308
- - **エンドポイントアドレス**: `http://127.0.0.1:7861/v1`
309
- - **APIキー**: `pwd`(デフォルト値、API_PASSWORDまたはPASSWORD環境変数で変更可能)
310
-
311
- **Geminiネイティブクライアント:**
312
- - **エンドポイントアドレス**: `http://127.0.0.1:7861`
313
- - **認証方式**:
314
- - `Authorization: Bearer your_api_password`
315
- - `x-goog-api-key: your_api_password`
316
- - URLパラメータ: `?key=your_api_password`
317
-
318
- ### 🌟 デュアル認証モード対応
319
-
320
- **GCLI認証モード**
321
- - 標準Google Cloud Gemini API認証
322
- - OAuth2.0認証フローに対応
323
- - 必要なGoogle Cloud APIを自動的に有効化
324
-
325
- **Antigravity認証モード**
326
- - Google Antigravity API専用認証
327
- - 独立したクレデンシャル管理システム
328
- - 一括アップロードと管理に対応
329
- - GCLIクレデンシャルとは完全に分離
330
-
331
- **統合管理インターフェース**
332
- - 「一括アップロード」タブで両方のクレデンシャルタイプを管理
333
- - 上部セクション: GCLIクレデンシャル一括アップロード(青テーマ)
334
- - 下部セクション: Antigravityクレデンシャル一括アップロード(緑テーマ)
335
- - 各タイプ別のクレデンシャル管理タブ
336
-
337
- ## 💾 データストレージモード
338
-
339
- ### 🌟 ストレージバックエンド対応
340
-
341
- gcli2apiは2つのストレージバックエンドに対応: **ローカルSQLite(デフォルト)** と **MongoDB(クラウド分散ストレージ)**
342
-
343
- ### 📁 ローカルSQLiteストレージ(デフォルト)
344
-
345
- **デフォルトストレージ方式**
346
- - 設定不要、すぐに利用可能
347
- - データはローカルSQLiteデータベースに保存
348
- - 単一マシンデプロイおよび個人利用に最適
349
- - データベースファイルの自動作成・管理
350
-
351
- ### 🍃 MongoDBクラウドストレージモード
352
-
353
- **クラウド分散ストレージソリューション**
354
-
355
- マルチインスタンスデプロイやクラウドストレージが必要な場合、MongoDBストレージモードを有効にできます。
356
-
357
- ### ⚙️ MongoDBモードの有効化
358
-
359
- **ステップ1: MongoDB接続の設定**
360
- ```bash
361
- # ローカルMongoDB
362
- export MONGODB_URI="mongodb://localhost:27017"
363
-
364
- # MongoDB Atlasクラウドサービス
365
- export MONGODB_URI="mongodb+srv://username:password@cluster.mongodb.net"
366
-
367
- # 認証付きMongoDB
368
- export MONGODB_URI="mongodb://admin:password@localhost:27017/admin"
369
-
370
- # オプション: カスタムデータベース名(デフォルト: gcli2api)
371
- export MONGODB_DATABASE="my_gcli_db"
372
- ```
373
-
374
- **ステップ2: アプリケーションの起動**
375
- ```bash
376
- # アプリケーションがMongoDB設定を自動検出し、MongoDBストレージを使用します
377
- python web.py
378
- ```
379
-
380
- **Docker環境でのMongoDB使用**
381
- ```bash
382
- # 単一MongoDBデプロイ
383
- docker run -d --name gcli2api \
384
- -e MONGODB_URI="mongodb://mongodb:27017" \
385
- -e API_PASSWORD=your_password \
386
- --network your_network \
387
- ghcr.io/su-kaka/gcli2api:latest
388
-
389
- # MongoDB Atlasの使用
390
- docker run -d --name gcli2api \
391
- -e MONGODB_URI="mongodb+srv://user:pass@cluster.mongodb.net/gcli2api" \
392
- -e API_PASSWORD=your_password \
393
- -p 7861:7861 \
394
- ghcr.io/su-kaka/gcli2api:latest
395
- ```
396
-
397
- **Docker Composeの例**
398
- ```yaml
399
- version: '3.8'
400
-
401
- services:
402
- mongodb:
403
- image: mongo:7
404
- container_name: gcli2api-mongodb
405
- restart: unless-stopped
406
- environment:
407
- MONGO_INITDB_ROOT_USERNAME: admin
408
- MONGO_INITDB_ROOT_PASSWORD: password123
409
- volumes:
410
- - mongodb_data:/data/db
411
- ports:
412
- - "27017:27017"
413
-
414
- gcli2api:
415
- image: ghcr.io/su-kaka/gcli2api:latest
416
- container_name: gcli2api
417
- restart: unless-stopped
418
- depends_on:
419
- - mongodb
420
- environment:
421
- - MONGODB_URI=mongodb://admin:password123@mongodb:27017/admin
422
- - MONGODB_DATABASE=gcli2api
423
- - API_PASSWORD=your_api_password
424
- - PORT=7861
425
- ports:
426
- - "7861:7861"
427
-
428
- volumes:
429
- mongodb_data:
430
- ```
431
-
432
-
433
- ### 🔧 高度な設定
434
-
435
- **MongoDB接続の最適化**
436
- ```bash
437
- # コネクションプールとタイムアウト設定
438
- export MONGODB_URI="mongodb://localhost:27017?maxPoolSize=10&serverSelectionTimeoutMS=5000"
439
-
440
- # レプリカセット設定
441
- export MONGODB_URI="mongodb://host1:27017,host2:27017,host3:27017/gcli2api?replicaSet=myReplicaSet"
442
-
443
- # リード・ライト分離設定
444
- export MONGODB_URI="mongodb://localhost:27017/gcli2api?readPreference=secondaryPreferred"
445
- ```
446
-
447
- ### 環境変数設定
448
-
449
- **基本設定**
450
- - `PORT`: サービスポート(デフォルト: 7861)
451
- - `HOST`: サーバーリッスンアドレス(デフォルト: 0.0.0.0)
452
-
453
- **パスワード設定**
454
- - `API_PASSWORD`: チャットAPIアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
455
- - `PANEL_PASSWORD`: コントロールパネルアクセスパスワード(デフォルト: PASSWORDまたはpwdを継承)
456
- - `PASSWORD`: 共通パスワード、設定時に上記2つを上書き(デフォルト: pwd)
457
-
458
- **パフォーマンスと安定性の設定**
459
- - `RETRY_429_ENABLED`: 429エラー自動リトライの有効化(デフォルト: true)
460
- - `RETRY_429_MAX_RETRIES`: 429エラーの最大リトライ回数(デフォルト: 3)
461
- - `RETRY_429_INTERVAL`: 429エラーのリトライ間隔、秒単位(デフォルト: 1.0)
462
- - `ANTI_TRUNCATION_MAX_ATTEMPTS`: 途切れ防止の最大リトライ回数(デフォルト: 3)
463
-
464
- **ネットワークとプロキシ設定**
465
- - `PROXY`: HTTP/HTTPSプロキシアドレス(形式: `http://host:port`)
466
- - `OAUTH_PROXY_URL`: OAuth認証プロキシエンドポイント
467
- - `GOOGLEAPIS_PROXY_URL`: Google APIsプロキシエンドポイント
468
- - `METADATA_SERVICE_URL`: メタデータサービスプロキシエンドポイント
469
-
470
- **自動化設定**
471
- - `AUTO_BAN`: クレデンシャル自動BANの有効化(デフォルト: true)
472
- - `AUTO_LOAD_ENV_CREDS`: 起動時に環境変数クレデンシャルを自動ロード(デフォルト: false)
473
-
474
- **互換性設定**
475
- - `COMPATIBILITY_MODE`: 互換モードの有効化、systemメッセージをuserメッセージに変換(デフォルト: false)
476
-
477
- **ログ設定**
478
- - `LOG_LEVEL`: ログレベル(DEBUG/INFO/WARNING/ERROR、デフォルト: INFO)
479
- - `LOG_FILE`: ログファイルパス(デフォルト: log.txt)
480
-
481
- **ストレージ設定**
482
-
483
- **SQLite設定(デフォルト)**
484
- - 設定不要、自動的にローカルSQLiteデータベースを使用
485
- - データベースファイルはプロジェクトディレクトリに自動作成
486
-
487
- **MongoDB設定(オプションのクラウドストレージ)**
488
- - `MONGODB_URI`: MongoDB接続文字列(設定時にMongoDBモードを有効化)
489
- - `MONGODB_DATABASE`: MongoDBデータベース名(デフォルト: gcli2api)
490
-
491
- **Docker使用例**
492
- ```bash
493
- # 共通パスワードを使用
494
- docker run -d --name gcli2api \
495
- -e PASSWORD=mypassword \
496
- -e PORT=7861 \
497
- ghcr.io/su-kaka/gcli2api:latest
498
-
499
- # 個別パスワードを使用
500
- docker run -d --name gcli2api \
501
- -e API_PASSWORD=my_api_password \
502
- -e PANEL_PASSWORD=my_panel_password \
503
- -e PORT=7861 \
504
- ghcr.io/su-kaka/gcli2api:latest
505
- ```
506
-
507
- 注意: クレデンシャル環境変数が設定されている場合、システムは環境変数のクレデンシャルを優先的に使用し、`creds` ディレクトリ内のファイルを無視します。
508
-
509
- ### API使用方法
510
-
511
- 本サービスは複数の完全なAPIエンドポイントセットに対応しています:
512
-
513
- #### 1. OpenAI互換エンドポイント(GCLI)
514
-
515
- **エンドポイント:** `/v1/chat/completions`
516
- **認証:** `Authorization: Bearer your_api_password`
517
-
518
- 2つのリクエストフォーマットに対応し、自動検出・処理を行います:
519
-
520
- **OpenAIフォーマット:**
521
- ```json
522
- {
523
- "model": "gemini-2.5-pro",
524
- "messages": [
525
- {"role": "system", "content": "You are a helpful assistant"},
526
- {"role": "user", "content": "Hello"}
527
- ],
528
- "temperature": 0.7,
529
- "stream": true
530
- }
531
- ```
532
-
533
- **Geminiネイティブフォーマット:**
534
- ```json
535
- {
536
- "model": "gemini-2.5-pro",
537
- "contents": [
538
- {"role": "user", "parts": [{"text": "Hello"}]}
539
- ],
540
- "systemInstruction": {"parts": [{"text": "You are a helpful assistant"}]},
541
- "generationConfig": {
542
- "temperature": 0.7
543
- }
544
- }
545
- ```
546
-
547
- #### 2. Geminiネイティブエンドポイント(GCLI)
548
-
549
- **非ストリーミングエンドポイント:** `/v1/models/{model}:generateContent`
550
- **ストリーミングエンドポイント:** `/v1/models/{model}:streamGenerateContent`
551
- **モデル一覧:** `/v1/models`
552
-
553
- **認証方式(いずれか1つを選択):**
554
- - `Authorization: Bearer your_api_password`
555
- - `x-goog-api-key: your_api_password`
556
- - URLパラメータ: `?key=your_api_password`
557
-
558
- **リクエスト例:**
559
- ```bash
560
- # x-goog-api-keyヘッダーを使用
561
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:generateContent" \
562
- -H "x-goog-api-key: your_api_password" \
563
- -H "Content-Type: application/json" \
564
- -d '{
565
- "contents": [
566
- {"role": "user", "parts": [{"text": "Hello"}]}
567
- ]
568
- }'
569
-
570
- # URLパラメータを使用
571
- curl -X POST "http://127.0.0.1:7861/v1/models/gemini-2.5-pro:streamGenerateContent?key=your_api_password" \
572
- -H "Content-Type: application/json" \
573
- -d '{
574
- "contents": [
575
- {"role": "user", "parts": [{"text": "Hello"}]}
576
- ]
577
- }'
578
- ```
579
-
580
- #### 3. Claude APIフォーマットエンドポイント
581
-
582
- **エンドポイント:** `/v1/messages`
583
- **認証:** `x-api-key: your_api_password` または `Authorization: Bearer your_api_password`
584
-
585
- **リクエスト例:**
586
- ```bash
587
- curl -X POST "http://127.0.0.1:7861/v1/messages" \
588
- -H "x-api-key: your_api_password" \
589
- -H "anthropic-version: 2023-06-01" \
590
- -H "Content-Type: application/json" \
591
- -d '{
592
- "model": "gemini-2.5-pro",
593
- "max_tokens": 1024,
594
- "messages": [
595
- {"role": "user", "content": "Hello, Claude!"}
596
- ]
597
- }'
598
- ```
599
-
600
- **systemパラメータの対応:**
601
- ```json
602
- {
603
- "model": "gemini-2.5-pro",
604
- "max_tokens": 1024,
605
- "system": "You are a helpful assistant",
606
- "messages": [
607
- {"role": "user", "content": "Hello"}
608
- ]
609
- }
610
- ```
611
-
612
- **注意事項:**
613
- - Claude APIフォーマット仕様に完全互換
614
- - バックエンド呼び出し時にGeminiフォーマットへ自動変換
615
- - すべてのClaude標準パラメータに対応
616
- - レスポンスフォーマットはClaude API仕様に準拠
617
-
618
- #### 4. Antigravity APIエンドポイント
619
-
620
- **OpenAI、Gemini、Claudeの3フォーマットに対応**
621
-
622
- ##### Antigravity OpenAIフォーマットエンドポイント
623
-
624
- **エンドポイント:** `/antigravity/v1/chat/completions`
625
- **認証:** `Authorization: Bearer your_api_password`
626
-
627
- **リクエスト例:**
628
- ```bash
629
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/chat/completions" \
630
- -H "Authorization: Bearer your_api_password" \
631
- -H "Content-Type: application/json" \
632
- -d '{
633
- "model": "claude-sonnet-4-5",
634
- "messages": [
635
- {"role": "user", "content": "Hello"}
636
- ],
637
- "stream": true
638
- }'
639
- ```
640
-
641
- ##### Antigravity Geminiフォーマットエンドポイント
642
-
643
- **非ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:generateContent`
644
- **ストリーミングエンドポイント:** `/antigravity/v1/models/{model}:streamGenerateContent`
645
-
646
- **認証方式(いずれか1つを選択):**
647
- - `Authorization: Bearer your_api_password`
648
- - `x-goog-api-key: your_api_password`
649
- - URLパラメータ: `?key=your_api_password`
650
-
651
- **リクエスト例:**
652
- ```bash
653
- # Geminiフォーマット非ストリーミングリクエスト
654
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/claude-sonnet-4-5:generateContent" \
655
- -H "x-goog-api-key: your_api_password" \
656
- -H "Content-Type: application/json" \
657
- -d '{
658
- "contents": [
659
- {"role": "user", "parts": [{"text": "Hello"}]}
660
- ],
661
- "generationConfig": {
662
- "temperature": 0.7
663
- }
664
- }'
665
-
666
- # Geminiフォーマットストリーミングリクエスト
667
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/models/gemini-2.5-flash:streamGenerateContent?key=your_api_password" \
668
- -H "Content-Type: application/json" \
669
- -d '{
670
- "contents": [
671
- {"role": "user", "parts": [{"text": "Hello"}]}
672
- ]
673
- }'
674
- ```
675
-
676
- ##### Antigravity Claudeフォーマットエンドポイント
677
-
678
- **エンドポイント:** `/antigravity/v1/messages`
679
- **認証:** `x-api-key: your_api_password`
680
-
681
- **リクエスト例:**
682
- ```bash
683
- curl -X POST "http://127.0.0.1:7861/antigravity/v1/messages" \
684
- -H "x-api-key: your_api_password" \
685
- -H "anthropic-version: 2023-06-01" \
686
- -H "Content-Type: application/json" \
687
- -d '{
688
- "model": "claude-sonnet-4-5",
689
- "max_tokens": 1024,
690
- "messages": [
691
- {"role": "user", "content": "Hello"}
692
- ]
693
- }'
694
- ```
695
-
696
- **対応Antigravityモデル:**
697
- - Claudeシリーズ: `claude-sonnet-4-5`、`claude-opus-4-5` など
698
- - Geminiシリーズ: `gemini-2.5-flash`、`gemini-2.5-pro` など
699
- - Thinkingモデルに自動対��
700
-
701
- **Geminiネイティブの例:**
702
- ```python
703
- from io import BytesIO
704
- from PIL import Image
705
- from google.genai import Client
706
- from google.genai.types import HttpOptions
707
- from google.genai import types
708
- # クライアントは環境変数 `GEMINI_API_KEY` からAPIキーを取得します。
709
-
710
- client = Client(
711
- api_key="pwd",
712
- http_options=HttpOptions(base_url="http://127.0.0.1:7861"),
713
- )
714
-
715
- prompt = (
716
- """
717
- 猫を描いて
718
- """
719
- )
720
-
721
- response = client.models.generate_content(
722
- model="gemini-3.1-flash-image",
723
- contents=[prompt],
724
- config=types.GenerateContentConfig(
725
- image_config=types.ImageConfig(
726
- aspect_ratio="16:9",
727
- )
728
- )
729
- )
730
- for part in response.candidates[0].content.parts:
731
- if part.text is not None:
732
- print(part.text)
733
- elif part.inline_data is not None:
734
- image = Image.open(BytesIO(part.inline_data.data))
735
- image.save("generated_image.png")
736
-
737
- ```
738
-
739
- **注意事項:**
740
- - OpenAIエンドポイントはOpenAI互換フォーマットを返します
741
- - GeminiエンドポイントはGeminiネイティブフォーマットを返します
742
- - 両エンドポイントとも同一のAPIパスワードを使用
743
-
744
- ## 📋 完全なAPIリファレンス
745
-
746
- ### Webコンソール API
747
-
748
- **認証エンドポイント**
749
- - `POST /auth/login` - ユーザーログイン
750
- - `POST /auth/start` - OAuth認証の開始(GCLIおよびAntigravityモード対応)
751
- - `POST /auth/callback` - OAuthコールバックの処理
752
- - `POST /auth/callback-url` - コールバックURLから直接認証を完了
753
- - `GET /auth/status/{project_id}` - 認証ステータスの確認
754
-
755
- **クレデンシャル管理エンドポイント**(`mode=geminicli` または `mode=antigravity` パラメータ対応)
756
- - `POST /creds/upload` - クレデンシャルファイルの一括アップロード(JSONおよびZIP対応)
757
- - `GET /creds/status` - クレデンシャルステータス一覧の取得(ページネーションとフィルタリング対応)
758
- - `GET /creds/detail/{filename}` - 単一クレデンシャルの詳細取得
759
- - `POST /creds/action` - 単一クレデンシャル操作(有効化/無効化/削除)
760
- - `POST /creds/batch-action` - クレデンシャルの一括操作
761
- - `GET /creds/download/{filename}` - 単一クレデンシャルファイルのダウンロード
762
- - `GET /creds/download-all` - 全クレデンシャルの一括ダウンロード
763
- - `POST /creds/fetch-email/{filename}` - ユーザーメールの取得
764
- - `POST /creds/refresh-all-emails` - ユーザーメールの一括更新
765
- - `POST /creds/deduplicate-by-email` - メールによるクレデンシャルの重複排除
766
- - `POST /creds/verify-project/{filename}` - クレデンシャルのProject ID検証
767
- - `GET /creds/quota/{filename}` - クレデンシャルのクォータ情報取得(Antigravityのみ)
768
-
769
- **設定管理エンドポイント**
770
- - `GET /config/get` - 現在の設定の取得
771
- - `POST /config/save` - 設定の保存
772
-
773
- **ログ管理エンドポイント**
774
- - `POST /logs/clear` - ログのクリア
775
- - `GET /logs/download` - ログファイルのダウンロード
776
- - `WebSocket /logs/stream` - リアルタイムログストリーム
777
-
778
- **バージョン情報エンドポイント**
779
- - `GET /version/info` - バージョン情報の取得(オプション `check_update=true` パラメータで更新確認)
780
-
781
- ### チャットAPI機能
782
-
783
- **マルチモーダル対応**
784
- ```json
785
- {
786
- "model": "gemini-2.5-pro",
787
- "messages": [
788
- {
789
- "role": "user",
790
- "content": [
791
- {"type": "text", "text": "この画像を説明してください"},
792
- {
793
- "type": "image_url",
794
- "image_url": {
795
- "url": "data:image/jpeg;base64,/9j/4AAQSkZJRgABA..."
796
- }
797
- }
798
- ]
799
- }
800
- ]
801
- }
802
- ```
803
-
804
- **Thinkingモード対応**
805
- ```json
806
- {
807
- "model": "gemini-2.5-pro-high",
808
- "messages": [
809
- {"role": "user", "content": "複雑な数学の問題"}
810
- ]
811
- }
812
- ```
813
-
814
- レスポンスには分離された思考内容が含まれます:
815
- ```json
816
- {
817
- "choices": [{
818
- "message": {
819
- "role": "assistant",
820
- "content": "最終回答",
821
- "reasoning_content": "詳細な思考プロセス..."
822
- }
823
- }]
824
- }
825
- ```
826
-
827
- **ストリーミング途切れ防止の使用方法**
828
- ```json
829
- {
830
- "model": "流式抗截断/gemini-2.5-pro",
831
- "messages": [
832
- {"role": "user", "content": "長い記事を書いてください"}
833
- ],
834
- "stream": true
835
- }
836
- ```
837
-
838
- **互換モード**
839
- ```bash
840
- # 互換モードを有効化
841
- export COMPATIBILITY_MODE=true
842
- ```
843
- このモードでは、すべての `system` メッセージが `user` メッセージに変換され、特定のクライアントとの互換性が向上します。
844
-
845
- ---
846
-
847
- ## 💬 コミュニティ
848
-
849
- QQグループへの参加をお待ちしています!
850
-
851
- **QQグループ: 1083250744**
852
-
853
- <img src="qq群.jpg" width="200" alt="QQグループQRコード">
854
-
855
- ---
856
-
857
- ## ライセンスと免責事項
858
-
859
- 本プロジェクトは学��および研究目的のみに使用できます。本プロジェクトの使用は、以下に同意したことを意味します:
860
- - 本プロジェクトをいかなる商用目的にも使用しないこと
861
- - 本プロジェクトの使用に伴うすべてのリスクと責任を負うこと
862
- - 関連するサービス利用規約および法的規制を遵守すること
863
-
864
- プロジェクトの作者は、本プロジェクトの使用から生じるいかなる直接的または間接的な損害についても責任を負いません。
865
-
866
- ## ⚠️ ライセンスについて
867
-
868
- **本プロジェクトはCooperative Non-Commercial License (CNC-1.0) の下でライセンスされています**
869
-
870
- これは厳格な非商用オープンソースライセンスです。詳細は [LICENSE](../LICENSE) ファイルをご参照ください。
871
-
872
- ### ✅ 許可される用途:
873
- - 個人の学習、研究、教育目的
874
- - 非営利団体での利用
875
- - オープンソースプロジェクトへの統合(同一ライセンスの遵守が必要)
876
- - 学術研究および論文発表
877
-
878
- ### ❌ 禁止される用途:
879
- - あらゆる形態の商用利用
880
- - 年間売上が100万ドルを超える企業での利用
881
- - ベンチャーキャピタルの出資を受けた企業または上場企業
882
- - 有料サービスまたは製品の提供
883
- - 商業的な競合利用
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
front/common.js DELETED
The diff for this file is too large to render. See raw diff
 
front/control_panel.html DELETED
The diff for this file is too large to render. See raw diff
 
front/control_panel_mobile.html DELETED
The diff for this file is too large to render. See raw diff
 
go.mod ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ module github.com/linguo2625469/workbuddy2api-panel
2
+
3
+ go 1.22.5
4
+
5
+ require (
6
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
7
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
8
+ github.com/redis/go-redis/v9 v9.18.0 // indirect
9
+ go.uber.org/atomic v1.11.0 // indirect
10
+ golang.org/x/sys v0.30.0 // indirect
11
+ )
go.sum ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
2
+ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
3
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
4
+ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
5
+ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
6
+ github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
7
+ github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
8
+ github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
9
+ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
10
+ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
11
+ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
12
+ golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
install.ps1 DELETED
@@ -1,35 +0,0 @@
1
- # 检测是否为管理员
2
- $IsElevated = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).
3
- IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
4
-
5
- # Skip Scoop install if already present to avoid stopping the script
6
- if (Get-Command scoop -ErrorAction SilentlyContinue) {
7
- Write-Host "Scoop is already installed. Skipping installation."
8
- } else {
9
- Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser -Force
10
- if ($IsElevated) {
11
- # 管理员:使用官方一行命令并传入 -RunAsAdmin
12
- Invoke-Expression "& {$(Invoke-RestMethod get.scoop.sh)} -RunAsAdmin"
13
- } else {
14
- # 普通用户安装
15
- Invoke-WebRequest -useb get.scoop.sh | Invoke-Expression
16
- }
17
- }
18
-
19
- scoop install git uv
20
- if (Test-Path -LiteralPath "./web.py") {
21
- # Already in target directory; skip clone and cd
22
- }
23
- elseif (Test-Path -LiteralPath "./gcli2api/web.py") {
24
- Set-Location ./gcli2api
25
- }
26
- else {
27
- git clone https://github.com/su-kaka/gcli2api.git
28
- Set-Location ./gcli2api
29
- }
30
- # Create relocatable virtual environment to ensure portability
31
- $env:UV_VENV_CLEAR = "1"
32
- uv venv --relocatable
33
- uv sync
34
- .venv/Scripts/activate.ps1
35
- python web.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
install.sh DELETED
@@ -1,302 +0,0 @@
1
- #!/bin/bash
2
- set -e # Exit on error
3
- set -u # Exit on undefined variable
4
- set -o pipefail # Exit on pipe failure
5
-
6
- # Color codes for output
7
- RED='\033[0;31m'
8
- GREEN='\033[0;32m'
9
- YELLOW='\033[1;33m'
10
- BLUE='\033[0;34m'
11
- NC='\033[0m' # No Color
12
-
13
- # Logging functions
14
- log_info() {
15
- echo -e "${GREEN}[INFO]${NC} $1"
16
- }
17
-
18
- log_error() {
19
- echo -e "${RED}[ERROR]${NC} $1" >&2
20
- }
21
-
22
- log_warn() {
23
- echo -e "${YELLOW}[WARN]${NC} $1"
24
- }
25
-
26
- log_debug() {
27
- echo -e "${BLUE}[DEBUG]${NC} $1"
28
- }
29
-
30
- # Cleanup function for error handling
31
- cleanup() {
32
- local exit_code=$?
33
- if [ $exit_code -ne 0 ]; then
34
- log_error "Installation failed with exit code $exit_code"
35
- fi
36
- exit $exit_code
37
- }
38
-
39
- trap cleanup EXIT
40
-
41
- # Detect OS and distribution
42
- detect_os() {
43
- log_info "Detecting operating system..."
44
-
45
- if [[ "$OSTYPE" == "linux-gnu"* ]]; then
46
- if [ -f /etc/os-release ]; then
47
- . /etc/os-release
48
- OS_NAME=$ID
49
- OS_VERSION=$VERSION_ID
50
- log_info "Detected: $NAME $VERSION_ID"
51
- elif [ -f /etc/lsb-release ]; then
52
- . /etc/lsb-release
53
- OS_NAME=$DISTRIB_ID
54
- OS_VERSION=$DISTRIB_RELEASE
55
- log_info "Detected: $DISTRIB_ID $DISTRIB_RELEASE"
56
- else
57
- OS_NAME="linux"
58
- OS_VERSION="unknown"
59
- log_warn "Could not determine specific Linux distribution"
60
- fi
61
- elif [[ "$OSTYPE" == "darwin"* ]]; then
62
- OS_NAME="macos"
63
- OS_VERSION=$(sw_vers -productVersion)
64
- log_info "Detected: macOS $OS_VERSION"
65
- elif [[ "$OSTYPE" == "freebsd"* ]]; then
66
- OS_NAME="freebsd"
67
- OS_VERSION=$(freebsd-version)
68
- log_info "Detected: FreeBSD $OS_VERSION"
69
- else
70
- log_error "Unsupported operating system: $OSTYPE"
71
- exit 1
72
- fi
73
- }
74
-
75
- # Check for root privileges (only for Linux package managers that need it)
76
- check_root_if_needed() {
77
- if [[ "$OS_NAME" == "ubuntu" ]] || [[ "$OS_NAME" == "debian" ]] || [[ "$OS_NAME" == "linuxmint" ]] || [[ "$OS_NAME" == "kali" ]]; then
78
- if [ "$EUID" -ne 0 ]; then
79
- log_error "This script requires root privileges for apt. Please run with sudo."
80
- exit 1
81
- fi
82
- elif [[ "$OS_NAME" == "fedora" ]] || [[ "$OS_NAME" == "rhel" ]] || [[ "$OS_NAME" == "centos" ]] || [[ "$OS_NAME" == "rocky" ]] || [[ "$OS_NAME" == "almalinux" ]]; then
83
- if [ "$EUID" -ne 0 ]; then
84
- log_error "This script requires root privileges for dnf/yum. Please run with sudo."
85
- exit 1
86
- fi
87
- elif [[ "$OS_NAME" == "arch" ]] || [[ "$OS_NAME" == "manjaro" ]]; then
88
- if [ "$EUID" -ne 0 ]; then
89
- log_error "This script requires root privileges for pacman. Please run with sudo."
90
- exit 1
91
- fi
92
- fi
93
- }
94
-
95
- # Update package manager
96
- update_packages() {
97
- log_info "Updating package manager..."
98
-
99
- case "$OS_NAME" in
100
- ubuntu|debian|linuxmint|kali|pop)
101
- if ! apt update; then
102
- log_error "Failed to update apt package lists"
103
- exit 1
104
- fi
105
- ;;
106
- fedora|rhel|centos|rocky|almalinux)
107
- if command -v dnf &> /dev/null; then
108
- if ! dnf check-update; then
109
- # dnf check-update returns 100 if updates are available, which is not an error
110
- if [ $? -ne 100 ]; then
111
- log_warn "dnf check-update returned non-standard exit code"
112
- fi
113
- fi
114
- else
115
- if ! yum check-update; then
116
- if [ $? -ne 100 ]; then
117
- log_warn "yum check-update returned non-standard exit code"
118
- fi
119
- fi
120
- fi
121
- ;;
122
- arch|manjaro)
123
- if ! pacman -Syu; then
124
- log_error "Failed to update pacman database"
125
- exit 1
126
- fi
127
- ;;
128
- macos)
129
- if command -v brew &> /dev/null; then
130
- log_info "Updating Homebrew..."
131
- brew update
132
- else
133
- log_warn "Homebrew not installed. Skipping package manager update."
134
- fi
135
- ;;
136
- *)
137
- log_warn "Unknown package manager for $OS_NAME. Skipping update."
138
- ;;
139
- esac
140
- }
141
-
142
- # Install git based on OS
143
- install_git() {
144
- if ! command -v git &> /dev/null; then
145
- log_info "Installing git..."
146
-
147
- case "$OS_NAME" in
148
- ubuntu|debian|linuxmint|kali|pop)
149
- if ! apt install git -y; then
150
- log_error "Failed to install git"
151
- exit 1
152
- fi
153
- ;;
154
- fedora|rhel|centos|rocky|almalinux)
155
- if command -v dnf &> /dev/null; then
156
- if ! dnf install git -y; then
157
- log_error "Failed to install git"
158
- exit 1
159
- fi
160
- else
161
- if ! yum install git -y; then
162
- log_error "Failed to install git"
163
- exit 1
164
- fi
165
- fi
166
- ;;
167
- arch|manjaro)
168
- if ! pacman -S git --noconfirm; then
169
- log_error "Failed to install git"
170
- exit 1
171
- fi
172
- ;;
173
- macos)
174
- if command -v brew &> /dev/null; then
175
- if ! brew install git; then
176
- log_error "Failed to install git"
177
- exit 1
178
- fi
179
- else
180
- log_error "Homebrew is required for macOS. Install from https://brew.sh/"
181
- exit 1
182
- fi
183
- ;;
184
- *)
185
- log_error "Don't know how to install git on $OS_NAME"
186
- exit 1
187
- ;;
188
- esac
189
- else
190
- log_info "Git is already installed ($(git --version))"
191
- fi
192
- }
193
-
194
- # Detect OS first
195
- detect_os
196
-
197
- # Check root if needed
198
- check_root_if_needed
199
-
200
- log_info "Starting installation process..."
201
-
202
- # Update package lists
203
- update_packages
204
-
205
- # Install git
206
- install_git
207
-
208
- # Install uv if not present
209
- if ! command -v uv &> /dev/null; then
210
- log_info "Installing uv package manager..."
211
- if ! curl -Ls https://astral.sh/uv/install.sh | sh; then
212
- log_error "Failed to install uv"
213
- exit 1
214
- fi
215
-
216
- # Source environment
217
- if [ -f "$HOME/.local/bin/env" ]; then
218
- source "$HOME/.local/bin/env"
219
- elif [ -f "$HOME/.cargo/env" ]; then
220
- source "$HOME/.cargo/env"
221
- fi
222
-
223
- # Verify uv installation
224
- if ! command -v uv &> /dev/null; then
225
- log_error "uv installation failed - command not found after install"
226
- exit 1
227
- fi
228
- else
229
- log_info "uv is already installed"
230
- fi
231
-
232
- # Determine working directory
233
- log_info "Checking project directory..."
234
- if [ -f "./web.py" ]; then
235
- log_info "Already in target directory"
236
- elif [ -f "./gcli2api/web.py" ]; then
237
- log_info "Changing to gcli2api directory"
238
- cd ./gcli2api || exit 1
239
- else
240
- log_info "Cloning repository..."
241
- if [ -d "./gcli2api" ]; then
242
- log_warn "gcli2api directory exists but web.py not found. Removing and re-cloning..."
243
- rm -rf ./gcli2api
244
- fi
245
-
246
- if ! git clone https://github.com/su-kaka/gcli2api.git; then
247
- log_error "Failed to clone repository"
248
- exit 1
249
- fi
250
-
251
- cd ./gcli2api || exit 1
252
- fi
253
-
254
- # Update repository if it's a git repo
255
- if [ -d ".git" ]; then
256
- log_info "Updating repository..."
257
- if ! git pull; then
258
- log_warn "Git pull failed, continuing anyway..."
259
- fi
260
- else
261
- log_warn "Not a git repository, skipping update"
262
- fi
263
-
264
- # Create relocatable virtual environment to ensure portability
265
- log_info "Creating relocatable virtual environment..."
266
- export UV_VENV_CLEAR=1
267
- if ! uv venv --relocatable; then
268
- log_error "Failed to create virtual environment"
269
- exit 1
270
- fi
271
-
272
- # Sync dependencies
273
- log_info "Syncing dependencies with uv..."
274
- if ! uv sync; then
275
- log_error "Failed to sync dependencies"
276
- exit 1
277
- fi
278
-
279
- # Activate virtual environment
280
- log_info "Activating virtual environment..."
281
- if [ -f ".venv/bin/activate" ]; then
282
- source .venv/bin/activate
283
- else
284
- log_error "Virtual environment not found at .venv/bin/activate"
285
- exit 1
286
- fi
287
-
288
- # Verify Python is available
289
- if ! command -v python3 &> /dev/null; then
290
- log_error "python3 not found in virtual environment"
291
- exit 1
292
- fi
293
-
294
- # Check if web.py exists
295
- if [ ! -f "web.py" ]; then
296
- log_error "web.py not found in current directory"
297
- exit 1
298
- fi
299
-
300
- # Start the application
301
- log_info "Starting application..."
302
- python3 web.py
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
internal/auth/auth.go ADDED
@@ -0,0 +1,378 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package auth 解析 WorkBuddy auth 文件(嵌套形/扁平形双形态),
2
+ // 提供 refresh 后的原子写回。
3
+ package auth
4
+
5
+ import (
6
+ "encoding/json"
7
+ "errors"
8
+ "fmt"
9
+ "io/fs"
10
+ "log"
11
+ "os"
12
+ "path/filepath"
13
+ "strings"
14
+ "sync"
15
+ "sync/atomic"
16
+ "time"
17
+
18
+ "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
19
+ )
20
+
21
+ // Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或手写扁平形)。
22
+ type Auth struct {
23
+ // mu 串行化 RefreshToken 写与 SaveAtomic 读,防止并发写回半更新 token。
24
+ mu sync.Mutex
25
+
26
+ AccessToken string
27
+ RefreshToken string
28
+ ExpiresAt int64 // Unix 秒
29
+ Domain string
30
+ // realm 账号域("cn" / "global"),落盘于 auth.realm(嵌套形)或顶层 realm(扁平形)。
31
+ // 空 = 缺省:Realm() 按 domain 后缀回落,最终恒非空。
32
+ //
33
+ // 命名注记:Go 不允许字段与方法同名,持久化字段用未导出 realm,计算访问器用
34
+ // 导出的 Realm()(跨包调用全部走方法)。Parse/SaveAtomic/login 在包内读写字段。
35
+ realm string
36
+ UID string
37
+ EnterpriseID string
38
+ Nickname string
39
+ FilePath string // 来源文件;refresh 后原子写回此处
40
+
41
+ // DeviceToken 设备风控 Token(X-Device-Token 头),来源 auth 文件的 device_token 键。
42
+ // 缺省为空 = 不注入该头(容器内无桌面端 Turing SDK 的常见部署)。
43
+ // 手写扁平形 auth 文件可直接写 "device_token": "...";插件 OAuth 嵌套形
44
+ // 顶层 device_token 也会被解析(与桌面端共用状态文件的部署方式)。
45
+ DeviceToken string
46
+ }
47
+
48
+ // Lock 供同进程内其他包(upstream.RefreshToken)在改写 Auth 字段期间加锁。
49
+ func (a *Auth) Lock() { a.mu.Lock() }
50
+
51
+ // Unlock 释放 a.Lock 获取的锁。
52
+ func (a *Auth) Unlock() { a.mu.Unlock() }
53
+
54
+ // AccessTokenValue 加锁读取 AccessToken(出站请求头一律经此取值,勿直读字段)。
55
+ //
56
+ // 为什么必须加锁:RefreshToken 在 a.mu 内改写 AccessToken/RefreshToken/Domain/ExpiresAt
57
+ // (client.go「第 2 段(锁内):校验快照一致后写回」),而所有出站请求头构造
58
+ // (ChatHeaders / BillingHeaders / fetchEnterpriseModels / fetchV3Models /
59
+ // global_models)与调度器的 token 检查都在锁外直读这些字段。生产上两侧真会并发:
60
+ // Scheduler.RunKeepaliveNow 定时对**每个**非禁用账号刷新(与是否有在途请求无关),
61
+ // 而 handler 正基于**同一个** *auth.Auth 指针构造请求头(Pool.AuthByUID/List 返回的
62
+ // 就是池内同一个对象)。无同步直读构成数据竞争(go test -race 实证)。
63
+ func (a *Auth) AccessTokenValue() string {
64
+ if a == nil {
65
+ return ""
66
+ }
67
+ a.mu.Lock()
68
+ defer a.mu.Unlock()
69
+ return a.AccessToken
70
+ }
71
+
72
+ // DomainValue 加锁读取 Domain(同 AccessTokenValue:RefreshToken 在锁内改写它)。
73
+ func (a *Auth) DomainValue() string {
74
+ if a == nil {
75
+ return ""
76
+ }
77
+ a.mu.Lock()
78
+ defer a.mu.Unlock()
79
+ return a.Domain
80
+ }
81
+
82
+ // RefreshTokenValue 加锁读取 RefreshToken(同 AccessTokenValue:RefreshToken 在锁内
83
+ // 改写它)。调度器的「有无凭证」前置守卫(checkin/keepalive/travel 的
84
+ // `a.RefreshToken == ""`)必须经此取值,勿直读字段。
85
+ func (a *Auth) RefreshTokenValue() string {
86
+ if a == nil {
87
+ return ""
88
+ }
89
+ a.mu.Lock()
90
+ defer a.mu.Unlock()
91
+ return a.RefreshToken
92
+ }
93
+
94
+ // globalEnabled 全局开关:global realm 是否路由(D5 双保险)。
95
+ // 默认开启(与 config global.enabled 缺省 true 一致):Realm() 正常按显式 realm/
96
+ // domain 判定 global/cn。显式 SetGlobalEnabled(false)(config "enabled": false)关闭
97
+ // → 逃生门:纯 CN 部署,即便 auth 文件写了 realm=global 或 domain 为 .workbuddy.ai
98
+ // 也恒判 cn——「关了才锁死」的单一闸口集中收敛在 Realm()/IsGlobal() 里。
99
+ var globalEnabled atomic.Bool
100
+
101
+ func init() { globalEnabled.Store(true) }
102
+
103
+ // SetGlobalEnabled 注入 global realm 路由开关(false = 锁死纯 CN,逃生门)。
104
+ func SetGlobalEnabled(enabled bool) { globalEnabled.Store(enabled) }
105
+
106
+ // GlobalEnabled 报告 global realm 路由开关当前状态(测试/运维观测)。
107
+ func GlobalEnabled() bool { return globalEnabled.Load() }
108
+
109
+ // Realm 返回账号的归一化域:显式 Realm=="global" 或 domain 后缀 .workbuddy.ai → "global",
110
+ // 否则 "cn"。显式 global 优先于 domain 回落(D1)。
111
+ // 全局开关 SetGlobalEnabled(false) 时恒 "cn"(逃生门:纯 CN 锁定,不影响默认行为)。
112
+ // 空 realm + 空 domain → "cn"(老 CN 凭证零回归)。
113
+ func (a *Auth) Realm() string {
114
+ a.mu.Lock()
115
+ defer a.mu.Unlock()
116
+ return a.realmLocked()
117
+ }
118
+
119
+ // realmLocked Realm 的无锁内部实现:仅限**已持 a.mu** 的调用方使用(sync.Mutex 不可重入,
120
+ // 锁内再调 Realm() 会自锁)。realm 由 BackfillRealm 改写、Domain 由 RefreshToken 在锁内
121
+ // 改写,故读取必须与写方同锁(理由见 AccessTokenValue 注释)。
122
+ func (a *Auth) realmLocked() string {
123
+ if !globalEnabled.Load() {
124
+ return "cn"
125
+ }
126
+ if strings.TrimSpace(a.realm) == "global" || isGlobalDomain(a.Domain) {
127
+ return "global"
128
+ }
129
+ return "cn"
130
+ }
131
+
132
+ // ResolveRealm 归一化 realm(cn/global):显式非空优先,否则按原始 domain 推断
133
+ // (isGlobalDomain)。不受逃生门影响(逃生门是路由锁,不应影响标识判定);
134
+ // domain 也为空 → "cn"(老 CN 凭证零回归)。
135
+ func ResolveRealm(explicit, domain string) string {
136
+ if r := strings.TrimSpace(explicit); r != "" {
137
+ return r
138
+ }
139
+ if isGlobalDomain(domain) {
140
+ return "global"
141
+ }
142
+ return "cn"
143
+ }
144
+
145
+ // BackfillRealm 为缺省 realm 标识的账号持久化补标识:a.realm 为空时按「原始 domain 推断」
146
+ // 写回(cn/global),返回 (是否有变更, 归一化后的 realm)。已有标识不动(幂等)。
147
+ //
148
+ // 注意用 isGlobalDomain(a.Domain) 直接推断,而非 Realm()——Realm() 在逃生门
149
+ // (SetGlobalEnabled(false))下恒降级 cn,把 global 账号写死成 cn 会永久污染凭证
150
+ // (逃生门是纯 CN 部署的临时锁,不应改写落盘数据)。domain 也为空时写 "cn"(老 CN 凭证)。
151
+ func (a *Auth) BackfillRealm() (bool, string) {
152
+ a.mu.Lock()
153
+ defer a.mu.Unlock()
154
+ if strings.TrimSpace(a.realm) != "" {
155
+ return false, a.realm
156
+ }
157
+ r := ResolveRealm("", a.Domain)
158
+ a.realm = r
159
+ return true, r
160
+ }
161
+
162
+ // RealmStored 直读持久化的 realm 标识(可能为空 = 未 backfill 的旧文件,Realm() 会 fallback)。
163
+ func (a *Auth) RealmStored() string {
164
+ a.mu.Lock()
165
+ defer a.mu.Unlock()
166
+ return a.realm
167
+ }
168
+
169
+ // BackfillRealmFor 显式写入 realm 标识(包外登录路径使用:panel login 已知用户选了
170
+ // global,直接落盘 realm=global,不依赖 domain 后缀推断)。realm 需为 cn/global,
171
+ // 非法值报错(防写脏)。返回是否发生变更。
172
+ func BackfillRealmFor(a *Auth, realm string) (bool, error) {
173
+ if a == nil {
174
+ return false, fmt.Errorf("nil auth")
175
+ }
176
+ switch strings.TrimSpace(realm) {
177
+ case "cn", "global":
178
+ default:
179
+ return false, fmt.Errorf("realm must be cn/global, got %q", realm)
180
+ }
181
+ a.mu.Lock()
182
+ defer a.mu.Unlock()
183
+ if a.realm == realm {
184
+ return false, nil
185
+ }
186
+ a.realm = realm
187
+ return true, nil
188
+ }
189
+
190
+ // IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。
191
+ func (a *Auth) IsGlobal() bool { return a.Realm() == "global" }
192
+
193
+ // isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。
194
+ // 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。
195
+ func isGlobalDomain(d string) bool {
196
+ d = strings.ToLower(strings.TrimSpace(d))
197
+ return d == "workbuddy.ai" || strings.HasSuffix(d, ".workbuddy.ai")
198
+ }
199
+
200
+ // NeedsRefresh 报告 token 是否将在 within 内过期(或已过期/无 expiry)。
201
+ func (a *Auth) NeedsRefresh(within time.Duration) bool {
202
+ a.mu.Lock()
203
+ defer a.mu.Unlock()
204
+ if a.ExpiresAt <= 0 {
205
+ return true
206
+ }
207
+ return time.Now().Add(within).Unix() >= a.ExpiresAt
208
+ }
209
+
210
+ // Parse 兼容两种磁盘形态:
211
+ //
212
+ // 嵌套形 {"auth":{...},"account":{...}} (插件 OAuth 输出)
213
+ // 扁平形 {"accessToken":...,"uid":...} (手写/旧版)
214
+ func Parse(raw []byte) (*Auth, error) {
215
+ if len(raw) == 0 {
216
+ return nil, fmt.Errorf("empty auth storage")
217
+ }
218
+ var probe map[string]json.RawMessage
219
+ if err := json.Unmarshal(raw, &probe); err != nil {
220
+ return nil, fmt.Errorf("storage_parse_error: %w", err)
221
+ }
222
+ var a Auth
223
+ if _, nested := probe["auth"]; nested {
224
+ var n struct {
225
+ Auth struct {
226
+ AccessToken string `json:"accessToken"`
227
+ RefreshToken string `json:"refreshToken"`
228
+ ExpiresAt int64 `json:"expiresAt"`
229
+ Domain string `json:"domain"`
230
+ Realm string `json:"realm"`
231
+ } `json:"auth"`
232
+ Account struct {
233
+ UID string `json:"uid"`
234
+ EnterpriseID string `json:"enterpriseId"`
235
+ Nickname string `json:"nickname"`
236
+ } `json:"account"`
237
+ // DeviceToken 顶层 device_token(嵌套形与扁平形共用;手写时无需嵌进 auth 对象)。
238
+ DeviceToken string `json:"device_token"`
239
+ }
240
+ if err := json.Unmarshal(raw, &n); err != nil {
241
+ return nil, fmt.Errorf("storage_parse_error: %w", err)
242
+ }
243
+ a = Auth{
244
+ AccessToken: n.Auth.AccessToken,
245
+ RefreshToken: n.Auth.RefreshToken,
246
+ ExpiresAt: n.Auth.ExpiresAt,
247
+ Domain: n.Auth.Domain,
248
+ realm: n.Auth.Realm,
249
+ UID: n.Account.UID,
250
+ EnterpriseID: n.Account.EnterpriseID,
251
+ Nickname: n.Account.Nickname,
252
+ DeviceToken: n.DeviceToken,
253
+ }
254
+ } else {
255
+ var f struct {
256
+ AccessToken string `json:"accessToken"`
257
+ RefreshToken string `json:"refreshToken"`
258
+ ExpiresAt int64 `json:"expiresAt"`
259
+ Domain string `json:"domain"`
260
+ Realm string `json:"realm"`
261
+ UID string `json:"uid"`
262
+ EnterpriseID string `json:"enterpriseId"`
263
+ Nickname string `json:"nickname"`
264
+ DeviceToken string `json:"device_token"`
265
+ }
266
+ if err := json.Unmarshal(raw, &f); err != nil {
267
+ return nil, fmt.Errorf("storage_parse_error: %w", err)
268
+ }
269
+ a = Auth{
270
+ AccessToken: f.AccessToken,
271
+ RefreshToken: f.RefreshToken,
272
+ ExpiresAt: f.ExpiresAt,
273
+ Domain: f.Domain,
274
+ realm: f.Realm,
275
+ UID: f.UID,
276
+ EnterpriseID: f.EnterpriseID,
277
+ Nickname: f.Nickname,
278
+ DeviceToken: f.DeviceToken,
279
+ }
280
+ }
281
+ if strings.TrimSpace(a.AccessToken) == "" {
282
+ return nil, fmt.Errorf("parse_error: missing accessToken")
283
+ }
284
+ return &a, nil
285
+ }
286
+
287
+ // SaveAtomic 以嵌套形原子写回 FilePath(tmp + rename),保持嵌套形(插件可读)格式。
288
+ // 全程持 a.mu:防止与 RefreshToken 修改 token 字段并发,杜绝写回半更新。
289
+ // 防御:accessToken 为空时拒绝写回,避免误用空凭证覆盖有效文件。
290
+ func (a *Auth) SaveAtomic() error {
291
+ a.mu.Lock()
292
+ defer a.mu.Unlock()
293
+ if strings.TrimSpace(a.AccessToken) == "" {
294
+ return fmt.Errorf("save refused: empty accessToken (uid=%s)", a.UID)
295
+ }
296
+ if a.FilePath == "" {
297
+ return fmt.Errorf("no FilePath set")
298
+ }
299
+ doc := map[string]any{
300
+ "auth": map[string]any{
301
+ "accessToken": a.AccessToken,
302
+ "refreshToken": a.RefreshToken,
303
+ "expiresAt": a.ExpiresAt,
304
+ "domain": a.Domain,
305
+ "realm": a.realm,
306
+ },
307
+ "account": map[string]any{
308
+ "uid": a.UID,
309
+ "enterpriseId": a.EnterpriseID,
310
+ "nickname": a.Nickname,
311
+ },
312
+ }
313
+ // DeviceToken 非空才写回顶层 device_token:避免在无该字段的旧文件里引入空键
314
+ // (保持与插件 OAuth 输出形状一致,插件读取忽略未知键)。
315
+ if a.DeviceToken != "" {
316
+ doc["device_token"] = a.DeviceToken
317
+ }
318
+ raw, err := json.MarshalIndent(doc, "", " ")
319
+ if err != nil {
320
+ return err
321
+ }
322
+ tmp := a.FilePath + ".tmp"
323
+ if err := os.WriteFile(tmp, raw, 0o600); err != nil {
324
+ // Docker bind-mount 权限问题的典型现场:容器内 app 用户(uid 10001)
325
+ // 对宿主机挂载目录无写权限。给出可操作指引而不是裸 syscall 错误。
326
+ msg := fmt.Sprintf("写入 %s 失败: %v", tmp, err)
327
+ if errors.Is(err, fs.ErrPermission) {
328
+ msg += "\n(Docker 部署:容器内用户对宿主机挂载目录无写权限。解法任选:" +
329
+ "1) 以本机 uid 运行容器:PUID=$(id -u) PGID=$(id -g) docker compose up -d;" +
330
+ "2) sudo chown -R 10001:10001 ./auths ./data ./config.json;" +
331
+ "3) compose 设 user: \"0:0\" 以 root 运行)"
332
+ }
333
+ return errors.New(msg)
334
+ }
335
+ return os.Rename(tmp, a.FilePath)
336
+ }
337
+
338
+ // LoadDir 扫描并解析 dir 下 workbuddy*.json;解析失败的文件静默跳过(启动日志由调用方统计)。
339
+ // 顺带做 realm 标识存量迁移:对空 realm 的 auth 自动 backfill(原始 domain 推断)并 SaveAtomic
340
+ // 落盘,一次性把旧文件补上 realm 键。单个文件写失败不阻断启动(log WARN 继续),
341
+ // 避免历史 auth 目录个别文件不可写时整个服务起不来。
342
+ func LoadDir(dir string) ([]*Auth, error) {
343
+ files, err := filepath.Glob(filepath.Join(dir, "workbuddy*.json"))
344
+ if err != nil {
345
+ return nil, err
346
+ }
347
+ // seenUID 重复 UID 检测:同 UID 出现在多个文件时(双 realm 同名 UID 概率近零)
348
+ // 打 WARN 告警含两文件路径,由「后载入者胜出」保持现状行为(不改变加载结果)。
349
+ seenUID := make(map[string]string, len(files))
350
+ var out []*Auth
351
+ for _, f := range files {
352
+ raw, err := os.ReadFile(f)
353
+ if err != nil {
354
+ continue
355
+ }
356
+ a, err := Parse(raw)
357
+ if err != nil {
358
+ continue
359
+ }
360
+ a.FilePath = f
361
+ if prev, ok := seenUID[a.UID]; ok {
362
+ log.Printf("WARN: uid %s duplicated across %s and %s — 后者覆盖(不同 realm 同名 UID?)",
363
+ logfmt.Label(a.UID, a.Nickname), prev, f)
364
+ }
365
+ seenUID[a.UID] = f
366
+ if a.RealmStored() == "" {
367
+ if changed, r := a.BackfillRealm(); changed {
368
+ if err := a.SaveAtomic(); err != nil {
369
+ log.Printf("WARN: auth %s realm backfill save: %v", logfmt.Label(a.UID, a.Nickname), err)
370
+ } else if r == "global" {
371
+ log.Printf("auth %s 存量迁移: 补 realm=global(domain=%s)", logfmt.Label(a.UID, a.Nickname), a.Domain)
372
+ }
373
+ }
374
+ }
375
+ out = append(out, a)
376
+ }
377
+ return out, nil
378
+ }
internal/auth/auth_test.go ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "io"
5
+ "log"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ )
11
+
12
+ func TestParseNested(t *testing.T) {
13
+ raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"domain":""},"account":{"uid":"u1","enterpriseId":"e1","nickname":"n1"}}`)
14
+ sa, err := Parse(raw)
15
+ if err != nil {
16
+ t.Fatalf("nested parse err: %v", err)
17
+ }
18
+ if sa.AccessToken != "at" || sa.RefreshToken != "rt" || sa.ExpiresAt != 1753600000 {
19
+ t.Errorf("tokens: %+v", sa)
20
+ }
21
+ if sa.UID != "u1" || sa.EnterpriseID != "e1" || sa.Nickname != "n1" {
22
+ t.Errorf("account: %+v", sa)
23
+ }
24
+ }
25
+
26
+ func TestParseFlat(t *testing.T) {
27
+ raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1753600000,"uid":"u2","nickname":"n2"}`)
28
+ sa, err := Parse(raw)
29
+ if err != nil || sa.UID != "u2" || sa.AccessToken != "at" {
30
+ t.Fatalf("flat: %+v %v", sa, err)
31
+ }
32
+ }
33
+
34
+ func TestParseMissingToken(t *testing.T) {
35
+ if _, err := Parse([]byte(`{"uid":"u3"}`)); err == nil {
36
+ t.Fatal("want error for missing accessToken")
37
+ }
38
+ }
39
+
40
+ func TestSaveAtomicRoundtrip(t *testing.T) {
41
+ dir := t.TempDir()
42
+ fp := filepath.Join(dir, "workbuddy-u1.json")
43
+ a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1753600000,
44
+ UID: "u1", EnterpriseID: "e1", Nickname: "n1", FilePath: fp}
45
+ if err := a.SaveAtomic(); err != nil {
46
+ t.Fatalf("save: %v", err)
47
+ }
48
+ if _, err := os.Stat(fp + ".tmp"); !os.IsNotExist(err) {
49
+ t.Error("tmp file should not remain")
50
+ }
51
+ raw, err := os.ReadFile(fp)
52
+ if err != nil {
53
+ t.Fatalf("read: %v", err)
54
+ }
55
+ b, err := Parse(raw)
56
+ if err != nil {
57
+ t.Fatalf("reparse: %v", err)
58
+ }
59
+ if b.AccessToken != "at" || b.UID != "u1" || b.EnterpriseID != "e1" {
60
+ t.Errorf("roundtrip: %+v", b)
61
+ }
62
+ }
63
+
64
+ // TestLoadDirLoadsAllValid 不再按 region 过滤:所有可解析的 auth 文件都被加载,
65
+ // 解析失败的文件静默跳过。
66
+ func TestLoadDirLoadsAllValid(t *testing.T) {
67
+ dir := t.TempDir()
68
+ cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"cn1"}}`
69
+ other := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"example.com"},"account":{"uid":"u2"}}`
70
+ bad := `not json`
71
+ os.WriteFile(filepath.Join(dir, "workbuddy-cn1.json"), []byte(cn), 0o600)
72
+ os.WriteFile(filepath.Join(dir, "workbuddy-u2.json"), []byte(other), 0o600)
73
+ os.WriteFile(filepath.Join(dir, "workbuddy-bad.json"), []byte(bad), 0o600)
74
+
75
+ list, err := LoadDir(dir)
76
+ if err != nil {
77
+ t.Fatalf("load: %v", err)
78
+ }
79
+ if len(list) != 2 {
80
+ t.Fatalf("want 2 valid accounts, got %+v", list)
81
+ }
82
+ for _, a := range list {
83
+ if a.FilePath == "" {
84
+ t.Error("FilePath not set")
85
+ }
86
+ }
87
+ }
88
+
89
+ func TestNeedsRefresh(t *testing.T) {
90
+ a := &Auth{ExpiresAt: 0}
91
+ if !a.NeedsRefresh(0) {
92
+ t.Error("zero expiry should need refresh")
93
+ }
94
+ a.ExpiresAt = 9999999999
95
+ if a.NeedsRefresh(0) {
96
+ t.Error("far future should not need refresh")
97
+ }
98
+ }
99
+
100
+ // TestParseDeviceToken 嵌套形与扁平形 auth 文件的顶层 device_token 键均被解析。
101
+ func TestParseDeviceToken(t *testing.T) {
102
+ nested := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":""},"account":{"uid":"u1"},"device_token":"dev-tok-nested"}`)
103
+ sa, err := Parse(nested)
104
+ if err != nil {
105
+ t.Fatalf("nested parse: %v", err)
106
+ }
107
+ if sa.DeviceToken != "dev-tok-nested" {
108
+ t.Errorf("nested DeviceToken = %q want %q", sa.DeviceToken, "dev-tok-nested")
109
+ }
110
+
111
+ flat := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","device_token":"dev-tok-flat"}`)
112
+ fa, err := Parse(flat)
113
+ if err != nil {
114
+ t.Fatalf("flat parse: %v", err)
115
+ }
116
+ if fa.DeviceToken != "dev-tok-flat" {
117
+ t.Errorf("flat DeviceToken = %q want %q", fa.DeviceToken, "dev-tok-flat")
118
+ }
119
+ }
120
+
121
+ // TestSaveAtomicPreservesDeviceToken SaveAtomic 写回后顶层 device_token 被保留并重新解析回来。
122
+ func TestSaveAtomicPreservesDeviceToken(t *testing.T) {
123
+ dir := t.TempDir()
124
+ fp := filepath.Join(dir, "workbuddy-dt.json")
125
+ a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
126
+ UID: "u1", DeviceToken: "persisted-tok", FilePath: fp}
127
+ if err := a.SaveAtomic(); err != nil {
128
+ t.Fatalf("save: %v", err)
129
+ }
130
+ raw, err := os.ReadFile(fp)
131
+ if err != nil {
132
+ t.Fatalf("read: %v", err)
133
+ }
134
+ b, err := Parse(raw)
135
+ if err != nil {
136
+ t.Fatalf("reparse: %v", err)
137
+ }
138
+ if b.DeviceToken != "persisted-tok" {
139
+ t.Errorf("roundtrip DeviceToken = %q want %q", b.DeviceToken, "persisted-tok")
140
+ }
141
+ }
142
+
143
+ // TestLoadDirBackfillsRealm 存量迁移:LoadDir 加载目录时对空 realm 的 auth 自动
144
+ // backfill + SaveAtomic;已有 realm 的保持原值(不被 domain 覆盖);文件全部带标识。
145
+ func TestLoadDirBackfillsRealm(t *testing.T) {
146
+ t.Parallel()
147
+ dir := t.TempDir()
148
+ fixtures := map[string]string{
149
+ "workbuddy-g1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`,
150
+ "workbuddy-c1.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":""},"account":{"uid":"c1"}}`,
151
+ // 已有 realm 的不因 domain 变化被覆盖:global domain + 显式 cn → 保持 cn
152
+ "workbuddy-c2.json": `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai","realm":"cn"},"account":{"uid":"c2"}}`,
153
+ }
154
+ for name, body := range fixtures {
155
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ }
159
+
160
+ list, err := LoadDir(dir)
161
+ if err != nil {
162
+ t.Fatalf("load: %v", err)
163
+ }
164
+ if len(list) != 3 {
165
+ t.Fatalf("want 3 accounts, got %d", len(list))
166
+ }
167
+ want := map[string]string{"g1": "global", "c1": "cn", "c2": "cn"}
168
+ for _, a := range list {
169
+ // 内存态已补标识
170
+ if got := a.RealmStored(); got != want[a.UID] {
171
+ t.Errorf("uid=%s in-memory realm=%q want %q", a.UID, got, want[a.UID])
172
+ }
173
+ // 落盘文件也带 realm 键
174
+ raw, err := os.ReadFile(a.FilePath)
175
+ if err != nil {
176
+ t.Fatalf("read %s: %v", a.FilePath, err)
177
+ }
178
+ b, err := Parse(raw)
179
+ if err != nil {
180
+ t.Fatalf("reparse %s: %v", a.FilePath, err)
181
+ }
182
+ if got := b.RealmStored(); got != want[a.UID] {
183
+ t.Errorf("uid=%s on-disk realm=%q want %q", a.UID, got, want[a.UID])
184
+ }
185
+ }
186
+ }
187
+
188
+ // TestLoadDirBackfillWriteFailureDoesNotBlock 单个文件 backfill 落盘失败(tmp 预置目录
189
+ // 使 WriteFile 失败)不阻断启动:其他文件照常迁移,LoadDir 不向上抛错。
190
+ // (历史纯 CN auth 目录一次性迁移时,个别文件不可写不应让整个服务起不来。)
191
+ func TestLoadDirBackfillWriteFailureDoesNotBlock(t *testing.T) {
192
+ t.Parallel()
193
+ dir := t.TempDir()
194
+ good := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"g1"}}`
195
+ if err := os.WriteFile(filepath.Join(dir, "workbuddy-g1.json"), []byte(good), 0o600); err != nil {
196
+ t.Fatal(err)
197
+ }
198
+ // 预置同名 .tmp 目录 → SaveAtomic 的 os.WriteFile(".tmp") 报 is a directory。
199
+ if err := os.Mkdir(filepath.Join(dir, "workbuddy-c1.json.tmp"), 0o700); err != nil {
200
+ t.Fatal(err)
201
+ }
202
+ bad := `{"auth":{"accessToken":"at","refreshToken":"r","expiresAt":1},"account":{"uid":"c1"}}`
203
+ if err := os.WriteFile(filepath.Join(dir, "workbuddy-c1.json"), []byte(bad), 0o600); err != nil {
204
+ t.Fatal(err)
205
+ }
206
+
207
+ list, err := LoadDir(dir)
208
+ if err != nil {
209
+ t.Fatalf("load err=%v want nil (write failure must not block startup)", err)
210
+ }
211
+ if len(list) != 2 {
212
+ t.Fatalf("want 2 accounts loaded, got %d", len(list))
213
+ }
214
+ // 好文件迁移成功
215
+ raw, _ := os.ReadFile(filepath.Join(dir, "workbuddy-g1.json"))
216
+ b, _ := Parse(raw)
217
+ if b.RealmStored() != "global" {
218
+ t.Errorf("good file realm=%q want global (migration should succeed)", b.RealmStored())
219
+ }
220
+ }
221
+
222
+ // TestLoadDirDuplicateUIDWarning 同 UID 双 realm auth 文件(概率近零的 EDGE):LoadDir
223
+ // 检测到重复 UID 时打 WARN(含两文件路径),且不改变加载行为——后载入者胜出(返回 1 个、
224
+ // 不 panic、realm 为后载入者值)。LoadDir 现在有额外 seenUID 副作用,逐字验证 WARN。
225
+ func TestLoadDirDuplicateUIDWarning(t *testing.T) {
226
+ dir := t.TempDir()
227
+ // 同一 UID u9 的两个文件:cn realm 文件按文件名排序在前(workbuddy-a-...),
228
+ // global realm 文件在后 → 后载入者(global)胜出。
229
+ cn := `{"auth":{"accessToken":"at1","refreshToken":"r","expiresAt":1,"domain":"www.codebuddy.cn"},"account":{"uid":"u9"}}`
230
+ gl := `{"auth":{"accessToken":"at2","refreshToken":"r","expiresAt":1,"domain":"www.workbuddy.ai"},"account":{"uid":"u9"}}`
231
+ if err := os.WriteFile(filepath.Join(dir, "workbuddy-a-cn.json"), []byte(cn), 0o600); err != nil {
232
+ t.Fatal(err)
233
+ }
234
+ if err := os.WriteFile(filepath.Join(dir, "workbuddy-z-global.json"), []byte(gl), 0o600); err != nil {
235
+ t.Fatal(err)
236
+ }
237
+
238
+ // 捕获 log 输出(本测试不 t.Parallel:log.SetOutput 是进程级全局,需串行)。
239
+ old := log.Writer()
240
+ r, w, err := os.Pipe()
241
+ if err != nil {
242
+ t.Fatal(err)
243
+ }
244
+ log.SetOutput(w)
245
+
246
+ list, err := LoadDir(dir)
247
+ _ = w.Close()
248
+ raw, _ := io.ReadAll(r)
249
+ log.SetOutput(old)
250
+
251
+ if err != nil {
252
+ t.Fatalf("load err=%v", err)
253
+ }
254
+ // 行为稳定(不改加载结果):LoadDir 返回全部可解析文件(去重发生在 pool.SyncToDir
255
+ // 的 UID 键 upsert),不 panic。
256
+ if len(list) != 2 {
257
+ t.Fatalf("want 2 accounts loaded (dedup later in pool), got %d", len(list))
258
+ }
259
+ // WARN 已触发且含两文件路径。
260
+ if !strings.Contains(string(raw), "WARN: uid") ||
261
+ !strings.Contains(string(raw), "duplicated") ||
262
+ !strings.Contains(string(raw), "workbuddy-a-cn.json") ||
263
+ !strings.Contains(string(raw), "workbuddy-z-global.json") {
264
+ t.Errorf("expected WARN with both paths, got output: %s", string(raw))
265
+ }
266
+ }
internal/auth/permhint_test.go ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "runtime"
7
+ "strings"
8
+ "testing"
9
+ )
10
+
11
+ // TestSaveAtomicPermissionHint 无写权限目录下保存,错误应包含 Docker chown 指引。
12
+ func TestSaveAtomicPermissionHint(t *testing.T) {
13
+ if runtime.GOOS == "windows" || os.Geteuid() == 0 {
14
+ t.Skip("Windows 无 POSIX 权限语义 / root 无权限限制,跳过")
15
+ }
16
+ dir := t.TempDir()
17
+ ro := filepath.Join(dir, "ro")
18
+ os.MkdirAll(ro, 0o555) // 只读目录
19
+ defer os.Chmod(ro, 0o755)
20
+ a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
21
+ UID: "u1", FilePath: filepath.Join(ro, "workbuddy-u1.json")}
22
+ err := a.SaveAtomic()
23
+ if err == nil {
24
+ t.Fatal("只读目录保存应失败")
25
+ }
26
+ if !strings.Contains(err.Error(), "chown") || !strings.Contains(err.Error(), "10001") {
27
+ t.Errorf("权限错误应包含 Docker 指引,实际: %v", err)
28
+ }
29
+ }
internal/auth/realm_test.go ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package auth
2
+
3
+ import (
4
+ "os"
5
+ "path/filepath"
6
+ "testing"
7
+ )
8
+
9
+ // withGlobalEnabled 临时打开 global realm 开关(生产缺省即开,此辅助仅显式确保),
10
+ // 测试结束复位为开启态(缺省)。
11
+ func withGlobalEnabled(t *testing.T) {
12
+ t.Helper()
13
+ globalEnabled.Store(true)
14
+ t.Cleanup(func() { globalEnabled.Store(true) })
15
+ }
16
+
17
+ // withGlobalDisabled 临时关闭 global realm 开关(逃生门),测试结束复位为开启态(缺省)。
18
+ func withGlobalDisabled(t *testing.T) {
19
+ t.Helper()
20
+ globalEnabled.Store(false)
21
+ t.Cleanup(func() { globalEnabled.Store(true) })
22
+ }
23
+
24
+ func TestRealmExplicitGlobal(t *testing.T) {
25
+ withGlobalEnabled(t)
26
+ a := &Auth{realm: "global"}
27
+ if got := a.Realm(); got != "global" {
28
+ t.Errorf("Realm()=%q want global", got)
29
+ }
30
+ if !a.IsGlobal() {
31
+ t.Error("IsGlobal()=false want true")
32
+ }
33
+ }
34
+
35
+ func TestRealmExplicitCN(t *testing.T) {
36
+ a := &Auth{realm: "cn"}
37
+ if got := a.Realm(); got != "cn" {
38
+ t.Errorf("Realm()=%q want cn", got)
39
+ }
40
+ if a.IsGlobal() {
41
+ t.Error("IsGlobal()=true want false")
42
+ }
43
+ }
44
+
45
+ func TestRealmDomainFallback(t *testing.T) {
46
+ withGlobalEnabled(t)
47
+ cases := []struct{ domain, want string }{
48
+ {"www.workbuddy.ai", "global"},
49
+ {"workbuddy.ai", "global"},
50
+ {"sub.workbuddy.ai", "global"},
51
+ {"www.codebuddy.cn", "cn"},
52
+ {"", "cn"},
53
+ }
54
+ for _, c := range cases {
55
+ a := &Auth{Domain: c.domain}
56
+ if got := a.Realm(); got != c.want {
57
+ t.Errorf("Domain=%q Realm()=%q want %q", c.domain, got, c.want)
58
+ }
59
+ }
60
+ }
61
+
62
+ func TestRealmEmptyFallsBackToCN(t *testing.T) {
63
+ // 开关缺省开启(零回归前提):空 realm + 空 domain → cn(老 CN 凭证的核心)。
64
+ a := &Auth{}
65
+ if got := a.Realm(); got != "cn" {
66
+ t.Errorf("Realm()=%q want cn", got)
67
+ }
68
+ // 显式 global → global(缺省开启,Realm() 不再因"未配置"而恒 cn)。
69
+ ag := &Auth{realm: "global"}
70
+ if got := ag.Realm(); got != "global" {
71
+ t.Errorf("Realm()=%q want global", got)
72
+ }
73
+ // domain 回落照常(缺省开启识别 workbuddy.ai)。
74
+ ad := &Auth{Domain: "www.workbuddy.ai"}
75
+ if got := ad.Realm(); got != "global" {
76
+ t.Errorf("Realm()=%q want global", got)
77
+ }
78
+ }
79
+
80
+ // TestRealmDefaultOnForCNZeroRegression 开关缺省开启时,老 CN 凭证(无 realm、无 domain)
81
+ // Realm() 恒为 cn——「默认开启」不影响纯 CN 部署行为。
82
+ func TestRealmDefaultOnForCNZeroRegression(t *testing.T) {
83
+ withGlobalEnabled(t)
84
+ cases := []*Auth{
85
+ {},
86
+ {Domain: "www.codebuddy.cn"},
87
+ {Domain: "codebuddy.cn"},
88
+ {realm: "cn"},
89
+ {realm: "cn", Domain: "www.codebuddy.cn"},
90
+ }
91
+ for _, a := range cases {
92
+ if got := a.Realm(); got != "cn" {
93
+ t.Errorf("%+v Realm()=%q want cn", a, got)
94
+ }
95
+ if a.IsGlobal() {
96
+ t.Errorf("%+v IsGlobal()=true want false", a)
97
+ }
98
+ }
99
+ }
100
+
101
+ // TestRealmExplicitOffEscapeHatch 逃生门:SetGlobalEnabled(false) 后恒 cn,
102
+ // 即便 realm=global / domain=workbuddy.ai(纯 CN 锁定,与旧缺省行为等价)。
103
+ func TestRealmExplicitOffEscapeHatch(t *testing.T) {
104
+ withGlobalDisabled(t)
105
+ cases := []struct {
106
+ auth *Auth
107
+ }{
108
+ {&Auth{realm: "global"}},
109
+ {&Auth{realm: "global", Domain: "www.workbuddy.ai"}},
110
+ {&Auth{Domain: "www.workbuddy.ai"}},
111
+ }
112
+ for _, tc := range cases {
113
+ if got := tc.auth.Realm(); got != "cn" {
114
+ t.Errorf("%+v Realm()=%q want cn (switch off)", tc.auth, got)
115
+ }
116
+ if tc.auth.IsGlobal() {
117
+ t.Errorf("%+v IsGlobal()=true want false (switch off)", tc.auth)
118
+ }
119
+ }
120
+ }
121
+
122
+ func TestParseNestedRealm(t *testing.T) {
123
+ raw := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1,"domain":"www.workbuddy.ai","realm":"global"},"account":{"uid":"u1"}}`)
124
+ sa, err := Parse(raw)
125
+ if err != nil {
126
+ t.Fatalf("nested parse err: %v", err)
127
+ }
128
+ if sa.realm != "global" {
129
+ t.Errorf("nested realm=%q want global", sa.realm)
130
+ }
131
+ // 嵌套形显式 realm 空 → 读不到,靠 domain 回落。
132
+ raw2 := []byte(`{"auth":{"accessToken":"at","refreshToken":"rt","expiresAt":1},"account":{"uid":"u1"}}`)
133
+ sa2, err := Parse(raw2)
134
+ if err != nil {
135
+ t.Fatalf("nested no-realm parse err: %v", err)
136
+ }
137
+ if sa2.realm != "" {
138
+ t.Errorf("nested missing realm key should be zero, got %q", sa2.realm)
139
+ }
140
+ }
141
+
142
+ func TestParseFlatRealm(t *testing.T) {
143
+ withGlobalEnabled(t)
144
+ raw := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u2","realm":"global"}`)
145
+ fa, err := Parse(raw)
146
+ if err != nil {
147
+ t.Fatalf("flat parse err: %v", err)
148
+ }
149
+ if fa.realm != "global" {
150
+ t.Errorf("flat realm=%q want global", fa.realm)
151
+ }
152
+ if !fa.IsGlobal() {
153
+ t.Error("flat global account IsGlobal()=false want true")
154
+ }
155
+ // 扁平形缺 realm 键 → 零值 → CN。
156
+ flatCN := []byte(`{"accessToken":"at","refreshToken":"rt","expiresAt":1,"uid":"u3"}`)
157
+ fc, err := Parse(flatCN)
158
+ if err != nil {
159
+ t.Fatalf("flat cn parse err: %v", err)
160
+ }
161
+ if fc.realm != "" {
162
+ t.Errorf("flat missing realm should be zero, got %q", fc.realm)
163
+ }
164
+ }
165
+
166
+ func TestSaveAtomicWritesRealm(t *testing.T) {
167
+ withGlobalEnabled(t)
168
+ dir := t.TempDir()
169
+ fp := filepath.Join(dir, "workbuddy-global.json")
170
+ a := &Auth{AccessToken: "at", RefreshToken: "rt", ExpiresAt: 1,
171
+ UID: "g1", realm: "global", FilePath: fp}
172
+ if err := a.SaveAtomic(); err != nil {
173
+ t.Fatalf("save: %v", err)
174
+ }
175
+ raw, err := os.ReadFile(fp)
176
+ if err != nil {
177
+ t.Fatalf("read: %v", err)
178
+ }
179
+ b, err := Parse(raw)
180
+ if err != nil {
181
+ t.Fatalf("reparse: %v", err)
182
+ }
183
+ if b.realm != "global" {
184
+ t.Errorf("roundtrip realm=%q want global", b.realm)
185
+ }
186
+ if !b.IsGlobal() {
187
+ t.Error("roundtrip global account IsGlobal()=false")
188
+ }
189
+ }
190
+
191
+ // TestBackfillRealmDomain CN/global 按原始 domain 推断(backfill 为导出空 realm 字段服务)。
192
+ func TestBackfillRealmDomain(t *testing.T) {
193
+ t.Parallel() // 不触碰全局开关
194
+ cases := []struct {
195
+ domain string
196
+ want string
197
+ }{
198
+ {"www.workbuddy.ai", "global"},
199
+ {"workbuddy.ai", "global"},
200
+ {"sub.workbuddy.ai", "global"},
201
+ {"www.codebuddy.cn", "cn"},
202
+ {"codebuddy.cn", "cn"},
203
+ {"", "cn"}, // 空 domain + 空 realm → cn(老 CN 凭证核心)
204
+ }
205
+ for _, c := range cases {
206
+ a := &Auth{Domain: c.domain}
207
+ changed, got := a.BackfillRealm()
208
+ if !changed {
209
+ t.Errorf("Domain=%q backfill changed=false want true", c.domain)
210
+ }
211
+ assertRealmStored(a, c.want, t)
212
+ if got != c.want {
213
+ t.Errorf("Domain=%q backfill got realm=%q want %q", c.domain, got, c.want)
214
+ }
215
+ }
216
+ }
217
+
218
+ // TestBackfillRealmEscapeHatchFree 逃生门关闭时 backfill 仍按原始 domain 推断(不受 Realm() 降级影响)。
219
+ func TestBackfillRealmEscapeHatchFree(t *testing.T) {
220
+ withGlobalDisabled(t) // 逃生门关闭:Realm() 恒 cn,但 backfill 不得被污染
221
+ a := &Auth{Domain: "www.workbuddy.ai"}
222
+ if g := a.Realm(); g != "cn" {
223
+ t.Fatalf("precondition Realm()=%q want cn (escape hatch on)", g)
224
+ }
225
+ changed, got := a.BackfillRealm()
226
+ if !changed {
227
+ t.Fatal("backfill changed=false want true (escape hatch free)")
228
+ }
229
+ assertRealmStored(a, "global", t)
230
+ if got != "global" {
231
+ t.Errorf("backfill got=%q want global (escape hatch must not corrupt backfill)", got)
232
+ }
233
+ }
234
+
235
+ // TestBackfillRealmIdempotent 已有 realm 标识的文件不做修改(幂等)。
236
+ func TestBackfillRealmIdempotent(t *testing.T) {
237
+ t.Parallel()
238
+ a := &Auth{Domain: "www.workbuddy.ai", realm: "cn"} // 已有 cn,domain 会推断 global——绝不覆盖
239
+ changed, got := a.BackfillRealm()
240
+ if changed {
241
+ t.Errorf("backfill changed=true want false (existing realm must win)")
242
+ }
243
+ if got != "cn" {
244
+ t.Errorf("backfill got=%q want cn (existing realm preserved)", got)
245
+ }
246
+ assertRealmStored(a, "cn", t)
247
+ }
248
+
249
+ func assertRealmStored(a *Auth, want string, t *testing.T) {
250
+ t.Helper()
251
+ if a.realm != want {
252
+ t.Errorf("stored realm field=%q want %q", a.realm, want)
253
+ }
254
+ }
255
+
256
+ // TestResolveRealm 纯函数归一化显式 realm,缺失时按 domain 推断(与 BackfillRealm
257
+ // 共用同一来源;不受逃生门影响)。显式值优先于 domain 推断。
258
+ func TestResolveRealm(t *testing.T) {
259
+ t.Parallel() // 纯函数:不触碰全局开关
260
+ cases := []struct {
261
+ explicit, domain, want string
262
+ }{
263
+ {"global", "www.codebuddy.cn", "global"}, // 显式优先:cn domain 也写 global
264
+ {"cn", "www.workbuddy.ai", "cn"}, // 显式优先:global domain 也写 cn
265
+ {"", "www.workbuddy.ai", "global"}, // 缺省按 domain 推断
266
+ {"", "workbuddy.ai", "global"},
267
+ {"", "codebuddy.cn", "cn"},
268
+ {"", "", "cn"}, // 空 domain → cn(老 CN 凭证零回归)
269
+ }
270
+ for _, c := range cases {
271
+ if got := ResolveRealm(c.explicit, c.domain); got != c.want {
272
+ t.Errorf("ResolveRealm(%q,%q)=%q want %q", c.explicit, c.domain, got, c.want)
273
+ }
274
+ }
275
+ }
internal/httpauth/httpauth.go ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package httpauth 网关与面板共用的 Bearer 鉴权原语。
2
+ //
3
+ // 单独成包的原因:server(/v1/*、/status)与 panel(/panel/api/*)两处鉴权
4
+ // 必须完全同口径——此前各自复制了一份"字符串直接比较"的实现,既容易漂移,
5
+ // 又都带计时侧信道。统一到这里后,口径只有一份,且天然常量时间比较。
6
+ package httpauth
7
+
8
+ import (
9
+ "crypto/sha256"
10
+ "crypto/subtle"
11
+ "net/http"
12
+ "strings"
13
+ )
14
+
15
+ // bearerPrefix 认证方案前缀(大小写敏感,与 HTTP 规范及既有实现一致)。
16
+ const bearerPrefix = "Bearer "
17
+
18
+ // VerifyBearer 校验请求头是否携带正确的 Bearer 密钥。
19
+ //
20
+ // key 为空表示"未启用鉴权",恒返回 true(调用方据此放行)。
21
+ // 比较用 SHA-256 摘要 + subtle.ConstantTimeCompare:
22
+ // - 常量时间,不因前缀匹配长度而泄露信息;
23
+ // - 先摘要再比较,长度差异被吸收进摘要(不会因长度不同提前返回);
24
+ // - 摘要本身不可逆,即便有侧信道也拿不到密钥原文。
25
+ func VerifyBearer(r *http.Request, key string) bool {
26
+ if key == "" {
27
+ return true
28
+ }
29
+ authz := r.Header.Get("Authorization")
30
+ if !strings.HasPrefix(authz, bearerPrefix) {
31
+ // 缺头/方案不对:仍走一次摘要比较,保持耗时形状一致。
32
+ subtle.ConstantTimeCompare(digest(""), digest(key))
33
+ return false
34
+ }
35
+ tok := authz[len(bearerPrefix):]
36
+ return subtle.ConstantTimeCompare(digest(tok), digest(key)) == 1
37
+ }
38
+
39
+ // digest 返回 s 的 SHA-256(定长 32 字节,供常量时间比较)。
40
+ func digest(s string) []byte {
41
+ sum := sha256.Sum256([]byte(s))
42
+ return sum[:]
43
+ }
internal/httpauth/httpauth_test.go ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package httpauth
2
+
3
+ import (
4
+ "net/http"
5
+ "net/http/httptest"
6
+ "testing"
7
+ )
8
+
9
+ func req(authz string) *http.Request {
10
+ r := httptest.NewRequest("GET", "/", nil)
11
+ if authz != "" {
12
+ r.Header.Set("Authorization", authz)
13
+ }
14
+ return r
15
+ }
16
+
17
+ func TestVerifyBearer(t *testing.T) {
18
+ cases := []struct {
19
+ name string
20
+ key string
21
+ authz string
22
+ want bool
23
+ }{
24
+ {"空 key 放行(未启用鉴权)", "", "", true},
25
+ {"空 key 也放行任意头", "", "Bearer whatever", true},
26
+ {"正确 key", "sk-abc123", "Bearer sk-abc123", true},
27
+ {"错误 key", "sk-abc123", "Bearer sk-wrong", false},
28
+ {"缺 Authorization 头", "sk-abc123", "", false},
29
+ {"缺 Bearer 前缀", "sk-abc123", "sk-abc123", false},
30
+ {"前缀大小写不符(规范要求精确)", "sk-abc123", "bearer sk-abc123", false},
31
+ {"多余空格", "sk-abc123", "Bearer sk-abc123", false},
32
+ {"前缀相同但内容短", "sk-abc123", "Bearer sk-abc12", false},
33
+ {"前缀相同但内容长", "sk-abc123", "Bearer sk-abc1234", false},
34
+ {"key 恰好是前缀", "sk-abc", "Bearer sk-abcdef", false},
35
+ }
36
+ for _, c := range cases {
37
+ t.Run(c.name, func(t *testing.T) {
38
+ if got := VerifyBearer(req(c.authz), c.key); got != c.want {
39
+ t.Errorf("VerifyBearer(key=%q, authz=%q) = %v, want %v", c.key, c.authz, got, c.want)
40
+ }
41
+ })
42
+ }
43
+ }
44
+
45
+ // TestVerifyBearerWithoutHeaderStillCompares 缺头路径不应因"提前返回"而暴露形状差异:
46
+ // 这里只验证它确实返回 false 且不 panic(常量时间的性质无法用单测断言,靠实现保证)。
47
+ func TestVerifyBearerWithoutHeaderStillCompares(t *testing.T) {
48
+ if VerifyBearer(req(""), "any-key") {
49
+ t.Error("missing header must not pass")
50
+ }
51
+ }
52
+
53
+ func TestDigestIsFixedLength(t *testing.T) {
54
+ // 不同长度输入摘要后应等长(这是常量时间比较的前提)
55
+ if len(digest("")) != len(digest("a-much-longer-secret-value")) {
56
+ t.Error("digest length must not depend on input length")
57
+ }
58
+ if len(digest("x")) != 32 {
59
+ t.Errorf("sha256 digest length = %d, want 32", len(digest("x")))
60
+ }
61
+ }
internal/livecfg/livecfg.go ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package livecfg 运行期可变配置的并发安全持有者。
2
+ //
3
+ // 背景:进程启动时读入的配置是普通字段(读多写零),但管理面板允许在线改配置,
4
+ // 于是少量"可热生效"的字段需要有并发安全的读写点。此处用不可变快照 + atomic 指针:
5
+ // 读方 Load 拿到一致视图,写方 Store 整体替换,无锁无数据竞争。
6
+ //
7
+ // 只承载**读路径深、热改需求强**的少数字段;池参数/排程参数等各有既有 setter
8
+ // (pool.SetBreaker、scheduler.Reconfigure 等),不重复收编到这里。
9
+ package livecfg
10
+
11
+ import (
12
+ "sync/atomic"
13
+ "time"
14
+ )
15
+
16
+ // Snapshot 一次读取的不可变配置视图。
17
+ type Snapshot struct {
18
+ APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权
19
+ SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认)
20
+ SanitizeFingerprints bool // 出站请求体指纹脱敏
21
+ RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA)
22
+ }
23
+
24
+ // Holder 原子持有当前快照。
25
+ type Holder struct {
26
+ p atomic.Pointer[Snapshot]
27
+ }
28
+
29
+ // New 以初始快照构建。
30
+ func New(s Snapshot) *Holder {
31
+ h := &Holder{}
32
+ h.Store(s)
33
+ return h
34
+ }
35
+
36
+ // Load 返回当前快照(Holder 为 nil 或从未 Store 时返回零值快照,调用方无需判空)。
37
+ func (h *Holder) Load() Snapshot {
38
+ if h == nil {
39
+ return Snapshot{}
40
+ }
41
+ if s := h.p.Load(); s != nil {
42
+ return *s
43
+ }
44
+ return Snapshot{}
45
+ }
46
+
47
+ // Store 整体替换快照。
48
+ func (h *Holder) Store(s Snapshot) { h.p.Store(&s) }
internal/logfmt/logfmt.go ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Package logfmt 统一网关日志的 uid 截断与模块前缀约定。
2
+ //
3
+ // 约定:
4
+ // - uid 统一截 8 位:与 chat 流水行(internal/server/logging.go uidPrefix)对齐,
5
+ // 日志行只留 uid 前 8 位。全量 uid 可从 data/state.json 查(54 个号无 8 位前缀碰撞)。
6
+ // - 模块前缀:调度四类已有天然前缀(travel/activity/checkin/keepalive)保持;
7
+ // 其他补 [pool]/[auth]/[server] 等 [mod] 方括号前缀,redisstore/session 已有保持。
8
+ // - 级别语义:正常流转不打级别字样(保持简洁);可疑/降级/失败行加 WARN:/ERR: 前缀。
9
+ //
10
+ // 本包不引入日志库,只提供 UID8 截断 / Label 账号标签 / Pad 显示宽对齐三个纯字符串
11
+ // helper,供各包替代裸写 [:8] 与手算表格列宽(防 uid 短于 8 越界、防中文昵称错位)。
12
+ package logfmt
13
+
14
+ import (
15
+ "strings"
16
+ "unicode/utf8"
17
+ )
18
+
19
+ // Truncate 截断字符串到 n 字节上限(先 TrimSpace,与旧 upstream/内部实现口径
20
+ // 一致),切点落在多字节字符中间时回退到 UTF-8 rune 边界——错误 body 多为中文
21
+ // ("将在 … 重置"),按字节切会出半截序列乱码。短于 n 原样返回;n<=0 返回空串。
22
+ func Truncate(s string, n int) string {
23
+ if n <= 0 {
24
+ return ""
25
+ }
26
+ s = strings.TrimSpace(s)
27
+ if len(s) > n {
28
+ // s[n] 是切点后的首字节:是 rune 的后续字节(continuation)说明切点落在
29
+ // 多字节字符中间,逐字节回退到 rune 边界(该字符整个让出)。
30
+ for n > 0 && !utf8.RuneStart(s[n]) {
31
+ n--
32
+ }
33
+ return s[:n]
34
+ }
35
+ return s
36
+ }
37
+
38
+ // UID8 返回 uid 的前 8 位;空 uid 返回 "-"(与 server.uidPrefix 对齐)。
39
+ //
40
+ // 用于调度类与非调度类日志行,把 <task> <full-uid>: ... 改为 <task> <uid8>: ...
41
+ // 全量 uid 留在 state.json 供排查,日志里 8 位足够唯一定位。
42
+ func UID8(uid string) string {
43
+ if uid == "" {
44
+ return "-"
45
+ }
46
+ if len(uid) > 8 {
47
+ return uid[:8]
48
+ }
49
+ return uid
50
+ }
51
+
52
+ // Label 返回日志里的账号标签,形如 "示例昵称甲(a1b2c3d4)";昵称为空时退回 "a1b2c3d4"。
53
+ //
54
+ // 为什么需要:uid8 是机器标识,排障时人眼无法直接判断"刚才那个 429/6004 是哪个号",
55
+ // 必须再拿 uid8 去 auths/ 或 data/state.json 反查昵称,一条日志要多跳一步。昵称随
56
+ // 登录落在 auths/<uid>.json 的 account.nickname,这里把它与 uid8 拼成可直接辨认的
57
+ // 标签——昵称认人、uid8 供 grep,两者都保留。
58
+ //
59
+ // uid 与 nick 同时为空时返回 "-"(与 UID8 口径一致,避免打出 "(-)")。
60
+ func Label(uid, nick string) string {
61
+ short := UID8(uid)
62
+ nick = strings.TrimSpace(nick)
63
+ if nick == "" {
64
+ return short
65
+ }
66
+ return nick + "(" + short + ")"
67
+ }
68
+
69
+ // DisplayWidth 返回 s 的终端显示列宽:CJK / 全角 / emoji 记 2 列,其余记 1 列。
70
+ //
71
+ // 存在意义:账号昵称是用户自定的中文("猫" 是 3 字节但占 2 列,"sample" 是 6 字节占
72
+ // 6 列),用 len()(字节数)做表格对齐会导致列宽忽宽忽窄。Go 标准库没有显示宽度函数,
73
+ // 本仓库不引 go-runewidth(保持零第三方依赖),故内置这份覆盖常见宽字符区段的判定。
74
+ func DisplayWidth(s string) int {
75
+ w := 0
76
+ for _, r := range s {
77
+ w += runeWidth(r)
78
+ }
79
+ return w
80
+ }
81
+
82
+ // Pad 把 s 右补空格到 width 显示列宽;已超宽或 width<=0 时原样返回(不截断)。
83
+ // 只补不截:截断会丢信息,超宽时让该行自然变宽,保持内容完整。
84
+ func Pad(s string, width int) string {
85
+ if width <= 0 {
86
+ return s
87
+ }
88
+ if d := width - DisplayWidth(s); d > 0 {
89
+ return s + strings.Repeat(" ", d)
90
+ }
91
+ return s
92
+ }
93
+
94
+ // maxShortUALen ShortUA 的返回上限(显示列宽足够放下 "WorkBuddy/5.5.6"、
95
+ // "python-requests/2.31.0" 这类常见客户端标签)。
96
+ const maxShortUALen = 40
97
+
98
+ // shortUAEngines UA 里只说明渲染引擎、不说明"是什么客户端"的通用 token:浏览器 UA
99
+ // 恒定包含它们,拿它当客户端标签等于没信息。
100
+ var shortUAEngines = map[string]bool{
101
+ "mozilla": true, "applewebkit": true, "gecko": true, "khtml": true,
102
+ "like": true, "safari": true, "compatible": true, "msie": true, "trident": true,
103
+ }
104
+
105
+ // ShortUA 从 User-Agent 提取便于人眼识别的客户端标签("curl/8.4.0"、
106
+ // "WorkBuddy/5.5.6"、"Chrome/120.0.0.0")。
107
+ //
108
+ // 为什么需要:面板「运行日志」与 stdout 流水行都要展示调用来源,而完整 UA 动辄
109
+ // 120+ 字符(浏览器尤其),直接铺进表格会把其它列挤没。这里只留"是什么客户端",
110
+ // 完整 UA 仍存在 reqlog.Event.UserAgent 里供面板悬停查看。
111
+ //
112
+ // 规则:取第一个形如 name/version 且 name 不是渲染引擎的 token;没有则回落整串
113
+ // 的前 maxShortUALen 字节(纯产品名 UA,如 "node")。空 UA 返回空串。
114
+ func ShortUA(ua string) string {
115
+ ua = strings.TrimSpace(ua)
116
+ if ua == "" {
117
+ return ""
118
+ }
119
+ for _, tok := range strings.Fields(ua) {
120
+ tok = strings.Trim(tok, "(),;")
121
+ name, _, ok := strings.Cut(tok, "/")
122
+ if !ok || name == "" {
123
+ continue
124
+ }
125
+ if shortUAEngines[strings.ToLower(name)] {
126
+ continue
127
+ }
128
+ return Truncate(tok, maxShortUALen)
129
+ }
130
+ return Truncate(ua, maxShortUALen)
131
+ }
132
+
133
+ // runeWidth 单个 rune 的显示列宽。区段判定取自 Unicode East Asian Width 的
134
+ // Wide/Fullwidth 集合(与 go-runewidth 的默认表口径一致),只保留实际会用到的段。
135
+ func runeWidth(r rune) int {
136
+ switch {
137
+ case r == 0:
138
+ return 0
139
+ case r < 0x20 || (r >= 0x7f && r < 0xa0):
140
+ // 控制字符(含 DEL/C1)不占位:日志里若混入 \t \r 不破坏列宽计算。
141
+ return 0
142
+ case r < 0x1100:
143
+ return 1
144
+ case r <= 0x115f: // Hangul Jamo 初声
145
+ return 2
146
+ case r == 0x2329 || r == 0x232a:
147
+ return 2
148
+ case r >= 0x2e80 && r <= 0xa4cf && r != 0x303f: // CJK 部首…Yi(303f 是窄字符)
149
+ return 2
150
+ case r >= 0xac00 && r <= 0xd7a3: // Hangul 音节
151
+ return 2
152
+ case r >= 0xf900 && r <= 0xfaff: // CJK 兼容表意
153
+ return 2
154
+ case r >= 0xfe30 && r <= 0xfe6f: // CJK 兼容形式
155
+ return 2
156
+ case r >= 0xff00 && r <= 0xff60: // 全角 ASCII
157
+ return 2
158
+ case r >= 0xffe0 && r <= 0xffe6: // 全角符号
159
+ return 2
160
+ case r >= 0x1f300 && r <= 0x1f9ff: // emoji
161
+ return 2
162
+ case r >= 0x20000 && r <= 0x3fffd: // CJK 扩展 B 及以后
163
+ return 2
164
+ }
165
+ return 1
166
+ }
internal/logfmt/shortua_test.go ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package logfmt
2
+
3
+ import "testing"
4
+
5
+ func TestShortUA(t *testing.T) {
6
+ cases := []struct {
7
+ name string
8
+ ua string
9
+ want string
10
+ }{
11
+ {"empty", "", ""},
12
+ {"blank", " ", ""},
13
+ {"curl", "curl/8.4.0", "curl/8.4.0"},
14
+ {"python", "python-requests/2.31.0", "python-requests/2.31.0"},
15
+ {"openai", "OpenAI/Python 1.30.0", "OpenAI/Python"},
16
+ {"workbuddy", "WorkBuddy/5.5.6 WorkBuddy/5.5.6 CLI/2.137.1", "WorkBuddy/5.5.6"},
17
+ {
18
+ "chrome skips engine tokens",
19
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
20
+ "Chrome/120.0.0.0",
21
+ },
22
+ {
23
+ "firefox",
24
+ "Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
25
+ "Firefox/121.0",
26
+ },
27
+ {"no version token", "node", "node"},
28
+ {"only engine tokens falls back to whole", "Mozilla/5.0 AppleWebKit/537.36", "Mozilla/5.0 AppleWebKit/537.36"},
29
+ }
30
+ for _, tc := range cases {
31
+ t.Run(tc.name, func(t *testing.T) {
32
+ if got := ShortUA(tc.ua); got != tc.want {
33
+ t.Fatalf("ShortUA(%q) = %q want %q", tc.ua, got, tc.want)
34
+ }
35
+ })
36
+ }
37
+ }
38
+
39
+ // ShortUA 的返回值必须有界:UA 是客户端可控自由文本,超长值不能原样带进日志列。
40
+ func TestShortUABounded(t *testing.T) {
41
+ long := "VeryLongClientNameThatKeepsGoingAndGoing/1.2.3"
42
+ if got := ShortUA(long); len(got) > maxShortUALen {
43
+ t.Fatalf("len(ShortUA) = %d want <= %d (%q)", len(got), maxShortUALen, got)
44
+ }
45
+ // 没有 name/version token 时回落整串,同样受上限约束。
46
+ got := ShortUA("x" + string(make([]byte, 0)) + "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy")
47
+ if len(got) > maxShortUALen {
48
+ t.Fatalf("fallback not truncated: %d", len(got))
49
+ }
50
+ }
internal/panel/app.js ADDED
The diff for this file is too large to render. See raw diff
 
internal/panel/autotask.go ADDED
@@ -0,0 +1,1265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // autotask.go 面板「一键完成」任务的动作实现。
2
+ //
3
+ // 设计依据:上游 scripts/task_*.py 实测结论 + 2026-09-12 桌面指纹协议逆向
4
+ // (data/desktop-task-protocol.md)——
5
+ // - first_buddy(+300 分):report(前置解锁)→ agreement → buddy/first
6
+ // - chat_5(+100 分):累计 5 条 chat_request_send 上报
7
+ // - Model_chat_GLM5.2(+100 分):accept → 用 glm-5.2 真实对话一次 → 上报(模型字段对齐)
8
+ // - RichMeow_Chat:桌面指纹(workbuddy-desktop)完整对话事件链,纯 API 可点亮(三账号实测)
9
+ // - Buddy_App / Buddy_App_QQ:buddyapp 五连事件,纯 API 可点亮(两账号实测)
10
+ // - automation_1:automated_task_create_suc 事件,纯 API 可点亮(两账号实测)
11
+ // - Library_read:web 域 web_element_click(library_doc_intro_click)(三账号实测)
12
+ // - template_5 / playbook_prompt / create_canvas:asar 逆向出的判据事件
13
+ // (template_used / playbook_prompt_send / wbx_design_canvas_*),纯 API 可点亮(三账号实测)
14
+ // - expert_5 / Expert_team_use_3:真实专家列表 + 召唤链 + 真实 chat(服务端 requestId)
15
+ // - expert_actual_use(三账号实测)
16
+ // - Hp_Appearance:appearance/set + appearance_skin_apply 事件(两账号实测)
17
+ //
18
+ // 仍未破解:skill_1(疑似要求真实 Skill 工具调用)。
19
+ // 不做:Expert_lighthouse(需真实连接器授权)、Expert_Philanthropy(真实捐款)。
20
+ //
21
+ // 所有动作幂等:已 claimed/已达标的任务直接跳过,不重复消耗上游配额。
22
+ package panel
23
+
24
+ import (
25
+ "context"
26
+ "encoding/json"
27
+ "fmt"
28
+ "io"
29
+ "log"
30
+ "math/rand/v2"
31
+ "net/http"
32
+ "strings"
33
+ "time"
34
+
35
+ "github.com/linguo2625469/workbuddy2api-panel/internal/auth"
36
+ "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt"
37
+ "github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
38
+ )
39
+
40
+ // autoAction 一个可自动化的任务动作。
41
+ type autoAction struct {
42
+ TaskCode string // 目标任务 code
43
+ Desc string // 展示用说明
44
+ Attempt bool // true = 尝试型(上游未证实可脚本化,跑了可能不点亮)
45
+ run func(p *Panel, a *auth.Auth) (string, error)
46
+ }
47
+
48
+ // autoActions 已实现的任务动作表(顺序即执行顺序:先解锁依赖项)。
49
+ // first_buddy 依赖活跃上报解锁,故 chat_5/first_buddy 的执行都自带 report 步骤。
50
+ var autoActions = []autoAction{
51
+ {
52
+ TaskCode: "chat_5",
53
+ Desc: "上报 5 条对话活跃事件(自动补足差额)",
54
+ run: runChat5,
55
+ },
56
+ {
57
+ TaskCode: "first_buddy",
58
+ Desc: "上报解锁 → 同意协议 → 领取第一只 Buddy(+300 分)",
59
+ run: runFirstBuddy,
60
+ },
61
+ {
62
+ TaskCode: "Model_chat_GLM5.2",
63
+ Desc: "接受任务 → glm-5.2 真实对话一次 → 对齐模型上报",
64
+ run: runModelChat,
65
+ },
66
+ {
67
+ TaskCode: "RichMeow_Chat",
68
+ Desc: "桌面指纹事件链上报(已验证:纯 API 可点亮,三账号实测)",
69
+ run: runRichMeow,
70
+ },
71
+ {
72
+ TaskCode: "Buddy_App",
73
+ Desc: "上报「进入 Buddy 应用」事件链(已验证:纯 API 可点亮)",
74
+ run: runBuddyApp,
75
+ },
76
+ {
77
+ TaskCode: "Buddy_App_QQ",
78
+ Desc: "上报「进入企鹅教师助手」事件链(已验证:纯 API 可点亮)",
79
+ run: runBuddyApp,
80
+ },
81
+ {
82
+ TaskCode: "automation_1",
83
+ Desc: "上报「定时任务创建」事件(已验证:纯 API 可点亮)",
84
+ run: runAutomationCreate,
85
+ },
86
+ {
87
+ TaskCode: "Library_read",
88
+ Desc: "上报「读资料库介绍」事件(已验证:纯 API 可点亮)",
89
+ run: runLibraryRead,
90
+ },
91
+ {
92
+ TaskCode: "template_5",
93
+ Desc: "上报「使用模板创建任务」事件组 ×5(已验证:三账号点亮)",
94
+ run: runTemplateUse,
95
+ },
96
+ {
97
+ TaskCode: "playbook_prompt",
98
+ Desc: "上报「灵感案例做同款发送 Prompt」事件组(已验证:三账号点亮)",
99
+ run: runPlaybookPrompt,
100
+ },
101
+ {
102
+ TaskCode: "create_canvas",
103
+ Desc: "上报「设计创意画布创建」事件组(已验证:三账号点亮,+300 分)",
104
+ run: runCreateCanvas,
105
+ },
106
+ {
107
+ TaskCode: "expert_5",
108
+ Desc: "真实专家召唤+使用链 ×5(专家市场列表+真实 chat,已验证:三账号点亮)",
109
+ run: runExpertUse,
110
+ },
111
+ {
112
+ TaskCode: "Expert_team_use_3",
113
+ Desc: "真实专家团召唤+使用链 ×3(已验证:三账号点亮)",
114
+ run: runExpertTeamUse,
115
+ },
116
+ {
117
+ TaskCode: "Hp_Appearance",
118
+ Desc: "设置主题 API + 皮肤生效事件(已验证:两账号点亮)",
119
+ run: runAppearance,
120
+ },
121
+ {
122
+ TaskCode: "skill_1",
123
+ Desc: "真实对话 + skill_info 技能加载事件(已验证:人杰2 点亮)",
124
+ run: runSkillFresh,
125
+ },
126
+ {
127
+ TaskCode: "Expert_lighthouse",
128
+ Desc: "真实轻量云专家召唤+使用链(chat 链带 has_expert,已验证:两账号点亮)",
129
+ run: runExpertLighthouse,
130
+ },
131
+ {
132
+ TaskCode: "black_cat",
133
+ Desc: "夜猫子:23:00–08:00 窗口内 glm-5.2 对话补足(窗口外提示稍后再试)",
134
+ Attempt: true,
135
+ run: runBlackCat,
136
+ },
137
+ {
138
+ TaskCode: "school_season",
139
+ Desc: "校园日(小程序口径):accept → mini 对话+activityId 上报 → 领奖(+100c+5e)",
140
+ run: runSchoolSeason,
141
+ },
142
+ {
143
+ TaskCode: "Sequential_Tasks_1",
144
+ Desc: "小程序首对话(小程序口径):accept → mini 对话上报 → 领奖(+100c+5e)",
145
+ run: runSequentialChat,
146
+ },
147
+ {
148
+ TaskCode: "Sequential_Tasks_2",
149
+ Desc: "小程序选专家对话(小程序口径):市场专家 id → accept → expert_actual_use 上报 → 领奖(+200c+5e)",
150
+ run: runMiniExpert,
151
+ },
152
+ {
153
+ TaskCode: "Sequential_Tasks_3",
154
+ Desc: "小程序五次对话(小程序口径):accept → mini 对话上报 ×5(自动补差额)→ 领奖(+300c+5e)",
155
+ run: runSequentialChat5,
156
+ },
157
+ {
158
+ TaskCode: "Sequential_Tasks_4",
159
+ Desc: "小程序定时任务(预留,每日零点解锁一环):accept → 定时任务创建事件(PC 同源)→ 领奖(判据待解锁验证)",
160
+ run: runSequentialAutomation,
161
+ },
162
+ {
163
+ TaskCode: "Sequential_Tasks_5",
164
+ Desc: "小程序使用 GLM5.2(预留):accept → 带模型字段的 mini 对话上报 → 领奖(判据待解锁验证)",
165
+ run: runSequentialModelChat,
166
+ },
167
+ {
168
+ TaskCode: "Sequential_Tasks_6",
169
+ Desc: "小程序十次对话(预留):accept → mini 对话上报 ×target(自动补差额)→ 领奖",
170
+ run: runSequentialChat10,
171
+ },
172
+ {
173
+ TaskCode: "Sequential_Tasks_7",
174
+ Desc: "体验灵感功能(预留,疑 PC 口径 +500c+5e):accept → 灵感事件组(PC+mp 双形态)→ 领奖(判据待解锁验证)",
175
+ run: runSequentialPlaybook,
176
+ },
177
+ }
178
+
179
+ // autoActionFor 查任务对应的动作;无则返回 nil(不可自动化)。
180
+ func autoActionFor(code string) *autoAction {
181
+ for i := range autoActions {
182
+ if autoActions[i].TaskCode == strings.TrimSpace(code) {
183
+ return &autoActions[i]
184
+ }
185
+ }
186
+ return nil
187
+ }
188
+
189
+ // autoActionIndex 任务在 autoActions 中的顺序(队列执行按依赖序排;未知返回大值)。
190
+ func autoActionIndex(code string) int {
191
+ for i := range autoActions {
192
+ if autoActions[i].TaskCode == code {
193
+ return i
194
+ }
195
+ }
196
+ return 1 << 20
197
+ }
198
+
199
+ // mpTaskCodes 小程序口径专属下发的成长任务:默认(无 mp 头)列表不出现,
200
+ // accept/claim 均要求 X-Client-Platform: miniprogram。新任务出现时在此登记。
201
+ var mpTaskCodes = map[string]bool{
202
+ "school_season": true, // 校园日(mini chat + activityId)
203
+ "Sequential_Tasks_1": true, // 小程序首对话(mini chat,无 activityId)
204
+ "Sequential_Tasks_2": true, // 小程序选中专家并完成有效对话(mp 指纹 expert_actual_use)
205
+ "Sequential_Tasks_3": true, // 小程序完成 5 次对话(与 Tasks_1 同形状,target=5 逐条累加)
206
+ // Tasks_4..7 存在性已实测(accept 返回 prerequisite not met 链式依赖;Tasks_8 not
207
+ // found 封顶)。链条每日零点解锁一环(task locked until 次日),判据为 issue #42
208
+ // 描述 + mpsrc 事件形状预置,解锁后逐个实测校正。
209
+ "Sequential_Tasks_4": true,
210
+ "Sequential_Tasks_5": true,
211
+ "Sequential_Tasks_6": true,
212
+ "Sequential_Tasks_7": true,
213
+ }
214
+
215
+ // isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。
216
+ func isMPTaskCode(code string) bool { return mpTaskCodes[code] }
217
+
218
+ // taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。
219
+ // 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码,
220
+ // 未知码不多打一次上游)。
221
+ func (p *Panel) taskByCode(a *auth.Auth, code string) (*upstream.Task, error) {
222
+ tasks, err := p.cfg.Upstream.ListTasks(a)
223
+ if err != nil {
224
+ return nil, err
225
+ }
226
+ for i := range tasks {
227
+ if tasks[i].TaskCode == code {
228
+ return &tasks[i], nil
229
+ }
230
+ }
231
+ if isMPTaskCode(code) {
232
+ return p.taskByCodeMP(a, code)
233
+ }
234
+ return nil, nil
235
+ }
236
+
237
+ // claimPollAttempts / claimPollGap 达标回读的有界轮询参数。
238
+ // 背景:上游计分是**异步**的——行为事件上报后进度要数秒才刷新(实测 Model_chat
239
+ // 对话完成后立即回读仍是 0/1,约 5-8 秒后才变 1/1)。一次性回读会误判"未达标",
240
+ // 从而跳过自动领奖。这里最多轮询 N 次、每次间隔 gap,总预算约 12 秒。
241
+ var (
242
+ claimPollAttempts = 4
243
+ claimPollGap = 3 * time.Second
244
+ )
245
+
246
+ // taskByCodeWaiting 回读任务,若未达标则在有界预算内轮询等待(上游异步计分)。
247
+ // 已达标(claimable)立即返回;预算耗尽返回最后一次结果(可能仍未达标)。
248
+ func (p *Panel) taskByCodeWaiting(a *auth.Auth, code string) (*upstream.Task, error) {
249
+ t, err := p.taskByCode(a, code)
250
+ if err != nil || t == nil {
251
+ return t, err
252
+ }
253
+ if t.Claimable || t.Claimed {
254
+ return t, nil
255
+ }
256
+ for i := 1; i < claimPollAttempts; i++ {
257
+ time.Sleep(claimPollGap)
258
+ t2, err2 := p.taskByCode(a, code)
259
+ if err2 != nil {
260
+ return t, nil // 轮询期间的查询失败不覆盖已拿到的结果
261
+ }
262
+ if t2 != nil {
263
+ t = t2
264
+ if t.Claimable || t.Claimed {
265
+ return t, nil
266
+ }
267
+ }
268
+ }
269
+ return t, nil
270
+ }
271
+
272
+ // taskByCodeMP 以小程序口径拉取任务列表并定位单个任务;未找到返回 nil。
273
+ // 小程序限定任务(school_season / Sequential_Tasks_1)在默认口径列表不出现。
274
+ func (p *Panel) taskByCodeMP(a *auth.Auth, code string) (*upstream.Task, error) {
275
+ tasks, err := p.cfg.Upstream.ListTasksMP(a)
276
+ if err != nil {
277
+ return nil, err
278
+ }
279
+ for i := range tasks {
280
+ if tasks[i].TaskCode == code {
281
+ return &tasks[i], nil
282
+ }
283
+ }
284
+ return nil, nil
285
+ }
286
+
287
+ // acceptWithVerifyMP accept 并回读验证登记生效:上游存在 200+OK 但 accept 未真正
288
+ // 登记的形态(此时上报事件全部不归账,任务永远点不亮,上游 task_runner c793ae3
289
+ // 实测)——判定以回读 accept_status 为准,未生效重试一次。
290
+ func (p *Panel) acceptWithVerifyMP(a *auth.Auth, code string) bool {
291
+ for attempt := 1; attempt <= 2; attempt++ {
292
+ if err := p.cfg.Upstream.AcceptTasksMP(a, []string{code}); err != nil {
293
+ log.Printf("autotask %s %s: accept 尝试%d: %v", logfmt.Label(a.UID, a.Nickname), code, attempt, err)
294
+ continue
295
+ }
296
+ time.Sleep(mpActionGap)
297
+ t, err := p.taskByCodeMP(a, code)
298
+ if err == nil && t != nil && t.AcceptStatus != "not_accepted" && t.AcceptStatus != "" {
299
+ return true
300
+ }
301
+ log.Printf("autotask %s %s: accept 尝试%d 未登记生效(回读=%q)", logfmt.Label(a.UID, a.Nickname), code, attempt, acceptStatusOr(t))
302
+ }
303
+ return false
304
+ }
305
+
306
+ // acceptStatusOr 安全读取任务 accept_status(nil 任务返回 "?")。
307
+ func acceptStatusOr(t *upstream.Task) string {
308
+ if t == nil {
309
+ return "?"
310
+ }
311
+ if t.AcceptStatus == "" {
312
+ return "?"
313
+ }
314
+ return t.AcceptStatus
315
+ }
316
+
317
+ // mpActionGap mp 任务写动作间隔(accept/上报/领奖之间,防频控)。
318
+ var mpActionGap = 2 * time.Second
319
+
320
+ // mpChatEventGap mp 对话事件(chat_request_send)的真人节奏间隔。上游对
321
+ // Sequential_Tasks_3「5 次有效对话」有反作弊校验:数秒级连发的事件会先被计入
322
+ // 进度(回读 5/5、accept_status 甚至短暂转 completed),随后被判定无效整体回滚
323
+ // (进度回落、claim 返回 400 "task not completed")——2026-09-26 实测 2s 连发
324
+ // 4 条全灭,45s 间隔逐条上报全存活且 claim +300c+5e 成功。每条上报前
325
+ // sleep gap + 0~10s 抖动;首条也等(上一轮残留进度被回滚后立即重报同样无效)。
326
+ var mpChatEventGap = 45 * time.Second
327
+
328
+ // runMPMiniChatTask growth 域小程序限定任务通用闭环:
329
+ // mp 查询 → accept(带登记回读验证)→ mini chat 事件上报(withActivityId 决定
330
+ // 是否带开学季 activityId:school_season 必带,Sequential_Tasks_1 不带——服务端按
331
+ // source=mini_program 指纹关联)→ 回读 → 达标即领奖。
332
+ func (p *Panel) runMPMiniChatTask(a *auth.Auth, code string, withActivityId bool) (string, error) {
333
+ t, err := p.taskByCodeMP(a, code)
334
+ if err != nil {
335
+ return "", err
336
+ }
337
+ if t == nil {
338
+ return "mp 口径未下发该任务(活动可能已结束)", nil
339
+ }
340
+ if t.Claimed {
341
+ return "已领取", nil
342
+ }
343
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
344
+ if !p.acceptWithVerifyMP(a, code) {
345
+ return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
346
+ }
347
+ // accept 前的任务进度为 null(target 下发 0),兜底 target=1 会少报——
348
+ // Tasks_6 首轮实测:accept 后真实 target=10,只补 1 条就误判达标去领奖
349
+ // (claim 400 task not completed)。接受后回读一次拿真实 target/current。
350
+ if t2, err := p.taskByCodeMP(a, code); err == nil && t2 != nil {
351
+ t = t2
352
+ }
353
+ }
354
+ // 已达标(含 completed 未领):直接领奖。
355
+ target := t.Target
356
+ if target <= 0 {
357
+ target = 1
358
+ }
359
+ if t.Current >= target || t.AcceptStatus == "completed" {
360
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
361
+ if err != nil {
362
+ return "", err
363
+ }
364
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
365
+ }
366
+ // 判据上报:按差额补 mini chat 事件。每条前 sleep mpChatEventGap+抖动——
367
+ // 连发会被上游反作弊判无效(见 mpChatEventGap 注释),宁可慢不可白报。
368
+ need := target - t.Current
369
+ for i := int64(0); i < need; i++ {
370
+ time.Sleep(mpChatEventGap + time.Duration(rand.Int64N(int64(10*time.Second))))
371
+ conv := fmt.Sprintf("wb2api-mp-%d-%d", time.Now().UnixMilli(), i)
372
+ var ev map[string]any
373
+ if withActivityId {
374
+ ev = upstream.SchoolSeasonChatEvent(conv)
375
+ } else {
376
+ ev = upstream.SchoolChatTimesEvents(conv)
377
+ }
378
+ if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
379
+ return fmt.Sprintf("完成 %d/%d 次上报后中断: %v", i, need, err), nil
380
+ }
381
+ }
382
+ // 回读(��步计分,有界轮询复用 claimPoll 预算的紧凑版:两轮各隔 3s)。
383
+ for i := 0; i < 2; i++ {
384
+ time.Sleep(claimPollGap)
385
+ t2, err2 := p.taskByCodeMP(a, code)
386
+ if err2 != nil || t2 == nil {
387
+ continue
388
+ }
389
+ t = t2
390
+ if t.Claimable || t.Claimed || t.Current >= target {
391
+ break
392
+ }
393
+ }
394
+ if t.Claimed {
395
+ return "本轮已入账(claimed)", nil
396
+ }
397
+ if t.Current < target {
398
+ return fmt.Sprintf("已上报 %d 次但进度未达 %d/%d(异步计分未归账,下次重试)", need, t.Current, target), nil
399
+ }
400
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
401
+ if err != nil {
402
+ return "", err
403
+ }
404
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
405
+ }
406
+
407
+ // runSchoolSeason 完成 school_season「校园日」(growth 域小程序限定)。
408
+ // 判据 = mini chat_request_send + activityId=school_open_day_2026(无 activityId
409
+ // 不点亮,与 school 域开学季同活动关联;上游 task_runner e2e 实测 +100c+5e)。
410
+ func runSchoolSeason(p *Panel, a *auth.Auth) (string, error) {
411
+ return p.runMPMiniChatTask(a, "school_season", true)
412
+ }
413
+
414
+ // runSequentialChat 完成 Sequential_Tasks_1「小程序内完成 1 次有效对话」。
415
+ // 判据 = mini chat_request_send(无 activityId,服务端按 source=mini_program
416
+ // 指纹关联;上游 task_runner 实测 +100c+5e)。
417
+ func runSequentialChat(p *Panel, a *auth.Auth) (string, error) {
418
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_1", false)
419
+ }
420
+
421
+ // runSequentialChat5 完成 Sequential_Tasks_3「在小程序内完成 5 次有效对话」。
422
+ // 判据与 Sequential_Tasks_1 同形状(mini 指纹 chat_request_send,无 activityId),
423
+ // 仅 target=5——服务端按上报条数累加进度,但**要求真人节奏**:连发事件先计数
424
+ // 后被反作弊回滚(claim 400 "task not completed"),由 mpChatEventGap 间隔保证
425
+ // (2026-09-26 实测:45s 间隔补满 5/5 → claim +300c+5e 成功,领后 accept_status
426
+ // =claimed 稳定不回滚)。
427
+ func runSequentialChat5(p *Panel, a *auth.Auth) (string, error) {
428
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_3", false)
429
+ }
430
+
431
+ // runSequentialChat10 完成 Sequential_Tasks_6「在小程序内完成 10 次有效对话」(预留)。
432
+ // 判据假定与 Tasks_1/3 同形状(mini chat_request_send),target 由任务自带(回读),
433
+ // runMPMiniChatTask 按差额补报——issue #42 称 target=10,以解锁后实际下发为准。
434
+ // 真人节奏间隔同样适用(mpChatEventGap):9 条 × ~50s ≈ 8 分钟/账号,夜间队列可接受。
435
+ func runSequentialChat10(p *Panel, a *auth.Auth) (string, error) {
436
+ return p.runMPMiniChatTask(a, "Sequential_Tasks_6", false)
437
+ }
438
+
439
+ // runSequentialEventTask Sequential 链预留任务通用骨架:mp 查询 → accept(带验证)
440
+ // → 判据事件上报(primary;未点亮且 fallback 非空时补一轮)→ 回读 → 达标领奖。
441
+ // 每日零点解锁一环:locked 期间 accept 不落账,返回等下次调度(无需人工干预)。
442
+ func (p *Panel) runSequentialEventTask(a *auth.Auth, code string, primary, fallback func() error) (string, error) {
443
+ t, err := p.taskByCodeMP(a, code)
444
+ if err != nil {
445
+ return "", err
446
+ }
447
+ if t == nil {
448
+ return "mp 口径未下发该任务(前置任务未完成或活动未开始)", nil
449
+ }
450
+ if t.Claimed {
451
+ return "已领取", nil
452
+ }
453
+ target := t.Target
454
+ if target <= 0 {
455
+ target = 1
456
+ }
457
+ if t.Current >= target || t.AcceptStatus == "completed" {
458
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
459
+ if err != nil {
460
+ return "", err
461
+ }
462
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
463
+ }
464
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
465
+ if !p.acceptWithVerifyMP(a, code) {
466
+ return "accept 未登记生效(任务可能处于每日锁定窗口,等解锁后自动重试)", nil
467
+ }
468
+ }
469
+ if err := primary(); err != nil {
470
+ return fmt.Sprintf("判据上报失败: %v", err), nil
471
+ }
472
+ // 回读(两轮各隔 3s);未点亮且有 fallback 时补报一轮再读。
473
+ for round := 0; round < 2; round++ {
474
+ time.Sleep(claimPollGap)
475
+ t2, err2 := p.taskByCodeMP(a, code)
476
+ if err2 != nil || t2 == nil {
477
+ continue
478
+ }
479
+ t = t2
480
+ if t.Claimable || t.Claimed || t.Current >= target {
481
+ break
482
+ }
483
+ if round == 0 && fallback != nil {
484
+ if err := fallback(); err != nil {
485
+ return fmt.Sprintf("备选判据上报失败: %v", err), nil
486
+ }
487
+ }
488
+ }
489
+ if t.Claimed {
490
+ return "本轮已入账(claimed)", nil
491
+ }
492
+ if t.Current < target {
493
+ return "已上报但进度未点亮(判据形态待解锁后校正,下次重试)", nil
494
+ }
495
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
496
+ if err != nil {
497
+ return "", err
498
+ }
499
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
500
+ }
501
+
502
+ // runSequentialAutomation 完成 Sequential_Tasks_4「创建定时任务」(预留)。
503
+ // mp 源码无 automation 事件发射点 → 判据疑为 PC 口径:复用 automation_1 同源
504
+ // 事件(DesktopAutomationCreateEvent,PC 任务三账号实测点亮)。
505
+ func runSequentialAutomation(p *Panel, a *auth.Auth) (string, error) {
506
+ return p.runSequentialEventTask(a, "Sequential_Tasks_4",
507
+ func() error {
508
+ return p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopAutomationCreateEvent("wb2api 自动化"))
509
+ }, nil)
510
+ }
511
+
512
+ // runSequentialModelChat 完成 Sequential_Tasks_5「使用 GLM5.2」(预留)。
513
+ // primary:mp 对话事件带 requestModelId/requestModelName=glm-5.2(mpsrc main 32904
514
+ // 发射点实测形状);fallback:PC 域模型活跃上报(Model_chat_GLM5.2 同源)。
515
+ func runSequentialModelChat(p *Panel, a *auth.Auth) (string, error) {
516
+ return p.runSequentialEventTask(a, "Sequential_Tasks_5",
517
+ func() error {
518
+ conv := fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli())
519
+ return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniChatModelEvent(conv, "glm-5.2", "GLM-5.2"))
520
+ },
521
+ func() error {
522
+ return p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-mp-glm-%d", time.Now().UnixMilli()), "", "glm-5.2", "GLM-5.2")
523
+ })
524
+ }
525
+
526
+ // runSequentialPlaybook 完成 Sequential_Tasks_7「体验灵感功能」(预留,疑 PC 口径)。
527
+ // primary:PC 灵感事件组(DesktopPlaybookPromptSequence,playbook_prompt 三账号
528
+ // 实测点亮);fallback:mp 指纹灵感事件组(MiniPlaybookEvents,mpsrc 形状)。
529
+ func runSequentialPlaybook(p *Panel, a *auth.Auth) (string, error) {
530
+ ms := time.Now().UnixMilli()
531
+ return p.runSequentialEventTask(a, "Sequential_Tasks_7",
532
+ func() error {
533
+ conv := fmt.Sprintf("wb2api-pb-%d", ms)
534
+ req := fmt.Sprintf("wb2api-pb-req-%d", ms)
535
+ return p.cfg.Upstream.ReportDesktopEvent(a,
536
+ upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...)
537
+ },
538
+ func() error {
539
+ return p.cfg.Upstream.ReportMPEvent(a, upstream.MiniPlaybookEvents("pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")...)
540
+ })
541
+ }
542
+
543
+ // runMiniExpert 完成 Sequential_Tasks_2「在小程序内选中专家并完成有效对话」。
544
+ // 判据 = mp 指纹 expert_actual_use(**不带** activityId/conversationId、
545
+ // extVersion=2.2.8、type=send_message——小程序源码实测形状,与 school 域 expert
546
+ // 事件两套口径勿混;上游 task_runner 实测上报即 completed,claim +200c+5e)。
547
+ // 专家 id 必须是市场真实 ex_ id(空 id 服务端不入账)→ **accept 之前**先解析市场
548
+ // 列表:拉不到就整任务不动作,避免留下「已登记未上报」的半程态(上游 9a26ae7
549
+ // 的 ids 前置判定同款)。复用既有 MarketExpertList(expert_5 任务同源,实测可用)。
550
+ func runMiniExpert(p *Panel, a *auth.Auth) (string, error) {
551
+ const code = "Sequential_Tasks_2"
552
+ t, err := p.taskByCodeMP(a, code)
553
+ if err != nil {
554
+ return "", err
555
+ }
556
+ if t == nil {
557
+ return "mp 口径未下发该任务(活动可能已结束)", nil
558
+ }
559
+ if t.Claimed {
560
+ return "已领取", nil
561
+ }
562
+ target := t.Target
563
+ if target <= 0 {
564
+ target = 1 // 未 accept 的 mp 任务 progress 为 null,target 兜底(上游实测)
565
+ }
566
+ // 已达标(含 completed 未领):直接领奖。
567
+ if t.Current >= target || t.AcceptStatus == "completed" {
568
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
569
+ if err != nil {
570
+ return "", err
571
+ }
572
+ return fmt.Sprintf("已领取奖励(+%dc +%de)", credit, energy), nil
573
+ }
574
+ // 判据载体前置(accept 之前):市场真实专家 id。
575
+ experts, merr := p.cfg.Upstream.MarketExpertList(a, "")
576
+ if merr != nil || len(experts) == 0 {
577
+ return fmt.Sprintf("专家市场不可用(%v),跳过以防半程态", merr), nil
578
+ }
579
+ e := experts[0]
580
+ name := e.DisplayNameZH
581
+ if name == "" {
582
+ name = e.ProfessionZH
583
+ }
584
+ if t.AcceptStatus == "not_accepted" || t.AcceptStatus == "" {
585
+ if !p.acceptWithVerifyMP(a, code) {
586
+ return "accept 未登记生效(上游 200+OK 但未落账形态),待下次重试", nil
587
+ }
588
+ }
589
+ ev := upstream.MiniExpertUseEvent(e.ExpertID, name, e.ExpertType)
590
+ if err := p.cfg.Upstream.ReportMPEvent(a, ev); err != nil {
591
+ return fmt.Sprintf("上报 expert_actual_use 失败: %v", err), nil
592
+ }
593
+ // 回读(异步计分,两轮各隔 3s——与 runMPMiniChatTask 同预算)。
594
+ for i := 0; i < 2; i++ {
595
+ time.Sleep(claimPollGap)
596
+ t2, err2 := p.taskByCodeMP(a, code)
597
+ if err2 != nil || t2 == nil {
598
+ continue
599
+ }
600
+ t = t2
601
+ if t.Claimable || t.Claimed || t.Current >= target {
602
+ break
603
+ }
604
+ }
605
+ if t.Claimed {
606
+ return "本轮已入账(claimed)", nil
607
+ }
608
+ if t.Current < target {
609
+ return "已上报但进度未归账(异步计分,下次重试)", nil
610
+ }
611
+ credit, energy, err := p.cfg.Upstream.ClaimRewardMP(a, code)
612
+ if err != nil {
613
+ return "", err
614
+ }
615
+ return fmt.Sprintf("任务点亮并领取奖励(+%dc +%de)", credit, energy), nil
616
+ }
617
+
618
+ // accountTaskAuto 一键完成单个任务:执行对应动作 → 回读进度 → 汇报结果。
619
+ func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) {
620
+ uid := r.PathValue("uid")
621
+ a := p.accountByUID(w, uid)
622
+ if a == nil {
623
+ return
624
+ }
625
+ var body struct {
626
+ TaskCode string `json:"task_code"`
627
+ }
628
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.TaskCode == "" {
629
+ writeErr(w, http.StatusBadRequest, "task_code required")
630
+ return
631
+ }
632
+ act := autoActionFor(body.TaskCode)
633
+ if act == nil {
634
+ writeErr(w, http.StatusNotImplemented,
635
+ "该任务需要客户端内交互(无对应接口),无法自动完成;请按任务说明在官方客户端操作")
636
+ return
637
+ }
638
+ // per-account 互斥:同账号的任务动作正在跑(单任务或全量)时直接 409,
639
+ // 不并发重跑(动作幂等但 expert/skill 系含真实对话,重跑浪费配额)。
640
+ if !p.tryLockAccount(uid) {
641
+ writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试")
642
+ return
643
+ }
644
+ defer p.unlockAccount(uid)
645
+ // 前置读取:已完成的任务直接跳过(幂等,不浪费上游调用)。
646
+ // taskByCode 已双口径(mp 专属码自动回落 mp 列表)。
647
+ before, err := p.taskByCode(a, act.TaskCode)
648
+ if err != nil {
649
+ writeErr(w, http.StatusBadGateway, "list tasks: "+err.Error())
650
+ return
651
+ }
652
+ if before == nil {
653
+ writeErr(w, http.StatusNotFound, "该账号没有此任务")
654
+ return
655
+ }
656
+ isMP := isMPTaskCode(act.TaskCode)
657
+ if before.Claimed {
658
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "skipped": true, "message": "该任务已领取过奖励"})
659
+ return
660
+ }
661
+ msg, err := act.run(p, a)
662
+ if err != nil {
663
+ writeErr(w, http.StatusBadGateway, "执行失败: "+err.Error())
664
+ return
665
+ }
666
+ // 回读验证:上报 200 ≠ 计分(上游可能静默丢弃 + 计分异步),
667
+ // 用有界轮询等异步计时落定,再决定是否自动领奖(mp 任务同口径回读)。
668
+ var after *upstream.Task
669
+ var aerr error
670
+ if isMP {
671
+ after, aerr = p.taskByCodeMP(a, act.TaskCode)
672
+ } else {
673
+ after, aerr = p.taskByCodeWaiting(a, act.TaskCode)
674
+ }
675
+ progressBefore, progressAfter := taskProgressText(before), ""
676
+ claimable := false
677
+ if aerr == nil && after != nil {
678
+ progressAfter = taskProgressText(after)
679
+ claimable = after.Claimable
680
+ }
681
+ resp := map[string]any{
682
+ "ok": true,
683
+ "message": msg,
684
+ "progress_before": progressBefore,
685
+ "progress_after": progressAfter,
686
+ "claimable": claimable,
687
+ "attempt": act.Attempt,
688
+ "verify_supported": true,
689
+ }
690
+ // 达标即自动领奖(Web 端 claim):把"完成→领奖"收敛成一步,无需用户再点一次。
691
+ if claimable {
692
+ var credit, energy int64
693
+ var cerr error
694
+ if isMP {
695
+ credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
696
+ } else {
697
+ credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
698
+ }
699
+ if cerr == nil {
700
+ resp["claimed"] = true
701
+ resp["credit"] = credit
702
+ resp["energy"] = energy
703
+ if credit > 0 || energy > 0 {
704
+ resp["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
705
+ } else {
706
+ resp["message"] = msg + ";奖励此前已领取"
707
+ }
708
+ } else {
709
+ resp["claim_error"] = cerr.Error()
710
+ resp["message"] = msg + ";达标但领奖失败,可在任务列表手动点「领取」重试"
711
+ }
712
+ }
713
+ log.Printf("panel: 任务动作 uid=%s code=%s progress %s -> %s claimable=%v claimed=%v",
714
+ uid, act.TaskCode, progressBefore, progressAfter, claimable, resp["claimed"])
715
+ writeJSON(w, http.StatusOK, resp)
716
+ }
717
+
718
+ // taskProgressText 任务进度的可读表示(回读对比用)。
719
+ func taskProgressText(t *upstream.Task) string {
720
+ if t == nil {
721
+ return "?"
722
+ }
723
+ if t.Target > 0 {
724
+ return fmt.Sprintf("%d/%d", t.Current, t.Target)
725
+ }
726
+ if t.Claimed {
727
+ return "claimed"
728
+ }
729
+ return t.AcceptStatus
730
+ }
731
+
732
+ // truncateStr 截断错误文本(避免把上游长响应原样透给前端)。
733
+ func truncateStr(s string, n int) string {
734
+ if len(s) <= n {
735
+ return s
736
+ }
737
+ return s[:n] + "…"
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // 各任务动作实现
742
+ // ---------------------------------------------------------------------------
743
+
744
+ // reportGap 连续上报之间的间隔(对齐上游脚本实测的 1.05s 口径,避免风控)。
745
+ var reportGap = 1050 * time.Millisecond
746
+
747
+ // runChat5 补足 chat_5 的进度:按差额上报 chat_request_send。
748
+ func runChat5(p *Panel, a *auth.Auth) (string, error) {
749
+ t, err := p.taskByCode(a, "chat_5")
750
+ if err != nil {
751
+ return "", err
752
+ }
753
+ if t == nil {
754
+ return "", fmt.Errorf("任务不存在")
755
+ }
756
+ target := t.Target
757
+ if target <= 0 {
758
+ target = 5
759
+ }
760
+ need := target - t.Current
761
+ if need <= 0 {
762
+ return "进度已达标,无需上报", nil
763
+ }
764
+ for i := int64(0); i < need; i++ {
765
+ cid := fmt.Sprintf("wb2api-chat5-%d-%d", time.Now().UnixMilli(), i)
766
+ if err := p.cfg.Upstream.ReportChatActivity(a, cid, ""); err != nil {
767
+ return fmt.Sprintf("上报第 %d/%d 条失败: %v", i+1, need, err), nil
768
+ }
769
+ if i < need-1 {
770
+ time.Sleep(reportGap)
771
+ }
772
+ }
773
+ return fmt.Sprintf("已补报 %d 条对话事件", need), nil
774
+ }
775
+
776
+ // runFirstBuddy 领养:report(解锁前置)→ agreement → first。
777
+ func runFirstBuddy(p *Panel, a *auth.Auth) (string, error) {
778
+ if err := p.cfg.Upstream.ReportChatActivity(a, fmt.Sprintf("wb2api-adopt-%d", time.Now().UnixMilli()), ""); err != nil {
779
+ return "", fmt.Errorf("前置上报: %w", err)
780
+ }
781
+ time.Sleep(reportGap) // 给上游事件处理留时间(脚本实测口径)
782
+ if err := p.cfg.Upstream.BuddyAgreement(a); err != nil {
783
+ return "", fmt.Errorf("同意协议: %w", err)
784
+ }
785
+ if err := p.cfg.Upstream.BuddyFirst(a); err != nil {
786
+ if upstream.IsBuddyTaskIncomplete(err) {
787
+ return "前置已上报,但领养门槛未过(上游要求当日活跃),请稍后重试", nil
788
+ }
789
+ return "", fmt.Errorf("领取 Buddy: %w", err)
790
+ }
791
+ return "已领取 Buddy(+300 分 +8 能量)", nil
792
+ }
793
+
794
+ // runModelChat 完成 Model_chat_GLM5.2:accept → 真实对话 → 对齐模型上报。
795
+ func runModelChat(p *Panel, a *auth.Auth) (string, error) {
796
+ const code, modelID, modelName = "Model_chat_GLM5.2", "glm-5.2", "GLM-5.2"
797
+ // 1. accept(报名;失败不阻塞——行为事件才是判据)
798
+ if err := p.cfg.Upstream.AcceptTasks(a, []string{code}); err != nil {
799
+ log.Printf("panel: accept %s: %v(继续走行为链路)", code, err)
800
+ }
801
+ time.Sleep(reportGap)
802
+ // 2. 真实对话一次(判据的最直接证据)
803
+ body, _ := json.Marshal(map[string]any{
804
+ "model": modelID,
805
+ "messages": []map[string]any{
806
+ {"role": "user", "content": "hi,请回复一句话"},
807
+ },
808
+ "stream": true,
809
+ })
810
+ rc, status, respBody, err := p.cfg.Upstream.ChatStream(a, body, "", upstream.ChatMeta{})
811
+ if err != nil {
812
+ return "", fmt.Errorf("对话请求: %w", err)
813
+ }
814
+ if status >= 400 {
815
+ rc.Close()
816
+ return "", fmt.Errorf("对话失败 http=%d: %s", status, truncateStr(string(respBody), 160))
817
+ }
818
+ // 读干 SSE(网关对上游强制 flow:不读完会残留连接)
819
+ _, _ = io.Copy(io.Discard, io.LimitReader(rc, 1<<20))
820
+ rc.Close()
821
+ time.Sleep(reportGap)
822
+ // 3. 对齐模型的上报(触发进度)
823
+ if err := p.cfg.Upstream.ReportChatActivityModel(a, fmt.Sprintf("wb2api-glm52-%d", time.Now().UnixMilli()), "", modelID, modelName); err != nil {
824
+ return "对话已完成,但进度上报失败:" + err.Error(), nil
825
+ }
826
+ return "已完成 glm-5.2 对话并上报", nil
827
+ }
828
+
829
+ // runRichMeow 完成 RichMeow_Chat(桌面端对话1次)。
830
+ // 2026-09-12 三账号实测验证:以桌面指纹(extName=workbuddy-desktop)向
831
+ // copilot.tencent.com/v2/report 上报完整对话事件链(agent_task_created →
832
+ // chat_message_response isSuccessful=true 等 6 事件),纯 API 即可点亮并领奖
833
+ // (紫川/人杰2 两账号无桌面客户端登录状态下 3 秒内 0/1 → 1/1)。
834
+ // Hp_Appearance 的纯 API set 不计分(需客户端在主题下活跃),区别对待。
835
+ func runRichMeow(p *Panel, a *auth.Auth) (string, error) {
836
+ ms := time.Now().UnixMilli()
837
+ conv := fmt.Sprintf("wb2api-rm-%d", ms)
838
+ req := fmt.Sprintf("wb2api-rm-req-%d", ms)
839
+ msg := fmt.Sprintf("req-%d-user", ms)
840
+ events := upstream.DesktopChatSequence(conv, req, msg, "fast-model", "fast-model")
841
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
842
+ return "", err
843
+ }
844
+ return "已按桌面端指纹上报完整对话事件链(agent_task_created→chat_response)", nil
845
+ }
846
+
847
+ // runBuddyApp 完成 Buddy_App / Buddy_App_QQ(进入 Buddy 应用)。
848
+ // 2026-09-12 两账号实测:buddyapp 五连事件(discover→show→enter→auth_confirm→
849
+ // bind_skip)以 workbuddy-desktop 指纹上报即点亮,服务端不校验真实授权。
850
+ // 用企鹅教师助手(Buddy_App_QQ 判据应用)作载体,同一组事件同时满足
851
+ // Buddy_App「进入任一应用」——两个表项共用本 run,幂等由任务状态跳过兜底。
852
+ func runBuddyApp(p *Panel, a *auth.Auth) (string, error) {
853
+ events := upstream.DesktopBuddyAppSequence("cb_y5Dy46tPQGGWtueMxXbe", "企鹅教师助手")
854
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
855
+ return "", err
856
+ }
857
+ return "已上报 buddyapp 进入五连事件(同时覆盖 Buddy_App 与 Buddy_App_QQ)", nil
858
+ }
859
+
860
+ // runAutomationCreate 完成 automation_1(设置自动化任务)。
861
+ // 2026-09-12 两账号实测:automated_task_create_suc 事件纯 API 上报即点亮,
862
+ // 无需真实创建定时任务。
863
+ func runAutomationCreate(p *Panel, a *auth.Auth) (string, error) {
864
+ if err := p.cfg.Upstream.ReportDesktopEvent(a,
865
+ upstream.DesktopAutomationCreateEvent("wb2api 自动化")); err != nil {
866
+ return "", err
867
+ }
868
+ return "已上报定时任务创建事件", nil
869
+ }
870
+
871
+ // runLibraryRead 完成 Library_read(体验资料库)。
872
+ // 2026-09-12 三账号实测:web 域 /v2/report 上报 web_element_click
873
+ // (elementId=library_doc_intro_click) 即点亮(space 的 open/WS/inlong 都不是判据)。
874
+ func runLibraryRead(p *Panel, a *auth.Auth) (string, error) {
875
+ const docURL = "https://www.workbuddy.cn/space/d/o0KWYeynteVv06UnAZqIFm"
876
+ if err := p.cfg.Upstream.ReportWebEvent(a, "web_element_click", docURL,
877
+ "library_doc_intro_click", "WorkBuddy资料库介绍"); err != nil {
878
+ return "", err
879
+ }
880
+ return "已上报资料库介绍阅读事件", nil
881
+ }
882
+
883
+ // runBlackCat 完成 black_cat(夜猫子,夜间 23:00–08:00 计数)。
884
+ // 判据 = 夜间窗口内 glm-5.2 真实对话 + chat 事件上报(WorkBuddy-Daily 实测口径)。
885
+ // 窗口外不做(提示等排程);网关 blackcat_hours(默认 23 点)排程会自动补足。
886
+ func runBlackCat(p *Panel, a *auth.Auth) (string, error) {
887
+ if !upstream.InNightWindow(time.Now()) {
888
+ return "当前不在 23:00–08:00 计数窗口,行为不计分;网关会在每日 23 点自动补足", nil
889
+ }
890
+ need, err := p.cfg.Upstream.BlackcatNeed(a)
891
+ if err != nil {
892
+ return "", err
893
+ }
894
+ if need <= 0 {
895
+ return "进度已达标,无需补足", nil
896
+ }
897
+ ok, err := p.cfg.Upstream.RunNightChats(a, int(need))
898
+ if err != nil {
899
+ return fmt.Sprintf("完成 %d/%d 次后中断: %v", ok, need, err), nil
900
+ }
901
+ return fmt.Sprintf("已完成 %d 次夜间对话并上报", ok), nil
902
+ }
903
+
904
+ // runSkillFresh 完成 skill_1(尝鲜热门技能)。
905
+ // 2026-09-12 判据(紫川手动完成抓包 row 209):`skill_info` 事件(桌面指纹)——
906
+ // {id:<技能名>, skillId, skillVersion, toolStatus:"success", fileCount,
907
+ // source:"workbuddy-desktop"} JOIN 真实会话(conversationId/requestId=服务端
908
+ // id)。此前的 skill_request_send/skill_installed/skill_action 全是错误方向。
909
+ // 人杰2 实测 0/1 → 1/1 点亮。
910
+ func runSkillFresh(p *Panel, a *auth.Auth) (string, error) {
911
+ conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, "")
912
+ if err != nil {
913
+ return "", fmt.Errorf("真实对话: %w", err)
914
+ }
915
+ msgID := "msg-" + req[len(req)-8:]
916
+ events := upstream.DesktopChatSequence(conv, req, msgID, "fast-model", "fast-model")
917
+ for _, ev := range events {
918
+ if ev["eventCode"] == "chat_message_response" {
919
+ ev["finishReason"] = "tool_calls" // 模型发起工具调用(技能加载)语义
920
+ }
921
+ }
922
+ events = append(events, upstream.DesktopEvent{
923
+ "eventCode": "skill_info",
924
+ "id": "润泽小馆·日报撰写",
925
+ "skillId": "skill_2097350077599879168",
926
+ "skillVersion": "1.0.0",
927
+ "toolStatus": "success",
928
+ "fileCount": 56,
929
+ "source": "workbuddy-desktop",
930
+ "conversationId": conv, "requestId": req, "messageId": msgID,
931
+ "requestModelId": "fast-model", "requestModelName": "fast-model",
932
+ "traceId": req,
933
+ })
934
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
935
+ return "", fmt.Errorf("skill_info 事件: %w", err)
936
+ }
937
+ return "已上报真实对话 + skill_info 技能加载事件", nil
938
+ }
939
+
940
+ // runExpertLighthouse 完成 Expert_lighthouse(体验「腾讯轻量云」专家)。
941
+ // 2026-09-12 判据(真实样本 Sunny row 868):与 expert_5 同构,但两处差异——
942
+ // chat 链的 agent_task_created 需带 has_expert:true + expert_id(我们默认 false),
943
+ // expert_actual_use 的 mode 为 "LOCAL"(非 craft)。id 固定为轻量云专家
944
+ // ex_2cvvUZQhDyeJ;requestId 必须是真实 chat 的服务端 id。紫川/人杰2 实测点亮。
945
+ func runExpertLighthouse(p *Panel, a *auth.Auth) (string, error) {
946
+ const lhID = "ex_2cvvUZQhDyeJ"
947
+ lh := upstream.MarketExpert{
948
+ ExpertID: lhID, ExpertType: "agent",
949
+ DisplayNameZH: "腾讯轻量云专家", ProfessionZH: "腾讯轻量云专家", Version: "1.0.2",
950
+ }
951
+ // 市场列表若命中真实条目则用其信息(version 等以服务端为准)。
952
+ if experts, err := p.cfg.Upstream.MarketExpertList(a, "agent"); err == nil {
953
+ for _, e := range experts {
954
+ if e.ExpertID == lhID {
955
+ lh = e
956
+ break
957
+ }
958
+ }
959
+ }
960
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopExpertSummonSequence(lh)...); err != nil {
961
+ return "", fmt.Errorf("召唤链: %w", err)
962
+ }
963
+ conv, req, err := p.cfg.Upstream.DesktopChatWithExpert(a, lhID)
964
+ if err != nil {
965
+ return "", fmt.Errorf("真实对话: %w", err)
966
+ }
967
+ events := upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model")
968
+ for _, ev := range events {
969
+ if ev["eventCode"] == "agent_task_created" {
970
+ ev["has_expert"] = true
971
+ ev["expert_id"] = lh.ExpertID
972
+ ev["expert_name"] = lh.DisplayNameZH
973
+ ev["expert_industry_id"] = ""
974
+ }
975
+ }
976
+ events = append(events, upstream.DesktopExpertActualUseLocal(lh, conv, req))
977
+ // 对齐真实样本细节:轻量云专家 actual_use 的 type 为空、cost=0。
978
+ events[len(events)-1]["type"] = ""
979
+ events[len(events)-1]["cost"] = 0
980
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
981
+ return "", fmt.Errorf("使用事件: %w", err)
982
+ }
983
+ return "已上报轻量云专家召唤+使用链(真实对话 requestId)", nil
984
+ }
985
+
986
+ // runAppearance 完成 Hp_Appearance(换主题)。
987
+ // 2026-09-12 紫川/人杰2 实测(desktopverify12):判据是 appearance_skin_apply
988
+ // 事件 {action:"apply",source:"settings_close",id:<resourceKey>,...}(客户端在
989
+ // 主题生效状态下离开设置页时上报)——早期"纯 API set 不计分"的结论不准确,
990
+ // 真相是当时只调了 appearance/set 没发事件。组合:set API 留痕 + 事件上报。
991
+ func runAppearance(p *Panel, a *auth.Auth) (string, error) {
992
+ const themeKey = "theme-tkmw7j" // 和平精英激战金秋(Hp_Appearance 判据主题)
993
+ if err := p.cfg.Upstream.SetAppearanceTheme(a, themeKey); err != nil {
994
+ return "", fmt.Errorf("设置主题: %w", err)
995
+ }
996
+ time.Sleep(2 * time.Second)
997
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, upstream.DesktopEvent{
998
+ "eventCode": "appearance_skin_apply", "action": "apply", "source": "settings_close",
999
+ "id": themeKey, "vipLevel": 0, "series": "", "type": "unknown",
1000
+ }); err != nil {
1001
+ return "", err
1002
+ }
1003
+ return "已设置主题并上报皮肤生效事件", nil
1004
+ }
1005
+
1006
+ // runTemplateUse 完成 template_5(使用 5 个模板创建任务)。
1007
+ // 2026-09-12 三账号实测:agent_task_created_with_template + template_used 事件组
1008
+ // (JOIN chat 链)一次上报 5 组即 5/5 点亮。template_id 服务端不校验真实性。
1009
+ func runTemplateUse(p *Panel, a *auth.Auth) (string, error) {
1010
+ templates := [][2]string{{"1", "深度研究"}, {"2", "周报生成"}, {"3", "竞品分析"}, {"4", "活动策划"}, {"5", "代码评审"}}
1011
+ for i, tp := range templates {
1012
+ ms := time.Now().UnixMilli()
1013
+ conv := fmt.Sprintf("wb2api-tpl-%d-%d", ms, i)
1014
+ req := fmt.Sprintf("wb2api-tpl-req-%d-%d", ms, i)
1015
+ events := upstream.DesktopTemplateUseSequence(conv, req, tp[0], tp[1])
1016
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
1017
+ return fmt.Sprintf("第 %d 组模板事件上报失败: %v", i+1, err), nil
1018
+ }
1019
+ time.Sleep(300 * time.Millisecond)
1020
+ }
1021
+ return "已上报 template_used ×5", nil
1022
+ }
1023
+
1024
+ // runPlaybookPrompt 完成 playbook_prompt(灵感案例 Dialog 中发送 Prompt)。
1025
+ // 判据是 playbook_prompt_send(Dialog 发送)而非卡片曝光/点击——asar 逆向确认。
1026
+ func runPlaybookPrompt(p *Panel, a *auth.Auth) (string, error) {
1027
+ ms := time.Now().UnixMilli()
1028
+ conv := fmt.Sprintf("wb2api-pb-%d", ms)
1029
+ req := fmt.Sprintf("wb2api-pb-req-%d", ms)
1030
+ events := upstream.DesktopPlaybookPromptSequence(conv, req, "pm-gtm-launch-plan", "新产品上市 GTM 发布计划一页纸")
1031
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
1032
+ return "", err
1033
+ }
1034
+ return "已上报 playbook_cta_click + playbook_prompt_send", nil
1035
+ }
1036
+
1037
+ // runCreateCanvas 完成 create_canvas(设计创意模式创建画布,+300 分)。
1038
+ // 判据是 wbx_design_canvas_task_create/open(Ardot create_design 工具完成遥测)。
1039
+ func runCreateCanvas(p *Panel, a *auth.Auth) (string, error) {
1040
+ ms := time.Now().UnixMilli()
1041
+ conv := fmt.Sprintf("wb2api-canvas-%d", ms)
1042
+ req := fmt.Sprintf("wb2api-canvas-req-%d", ms)
1043
+ events := upstream.DesktopDesignCanvasSequence(conv, req)
1044
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
1045
+ return "", err
1046
+ }
1047
+ return "已上报 wbx_design_canvas_task_create/open", nil
1048
+ }
1049
+
1050
+ // expertSummonGap 专家召唤链的间隔(真实使用节奏,v11 实测 8s 成功率 100%)。
1051
+ const expertSummonGap = 6 * time.Second
1052
+
1053
+ // runExpertUse 完成 expert_5(使用 5 个平台专家)。
1054
+ // 2026-09-12 三账号实测公式:真实专家列表(id 必须真实存在)→ 召唤链
1055
+ // (summon_click/summoned)→ 真实 chat 拿服务端 requestId → expert_actual_use。
1056
+ // 自造专家 id 或自造 requestId 均不计数。
1057
+ func runExpertUse(p *Panel, a *auth.Auth) (string, error) {
1058
+ return runExpertBatch(p, a, "agent", 5)
1059
+ }
1060
+
1061
+ // runExpertTeamUse 完成 Expert_team_use_3(使用 3 个专家团,expertType=team)。
1062
+ func runExpertTeamUse(p *Panel, a *auth.Auth) (string, error) {
1063
+ return runExpertBatch(p, a, "team", 3)
1064
+ }
1065
+
1066
+ // runExpertBatch 专家召唤+使用的公共实现。失败逐个继续,返回汇总信息。
1067
+ func runExpertBatch(p *Panel, a *auth.Auth, expertType string, count int) (string, error) {
1068
+ experts, err := p.cfg.Upstream.MarketExpertList(a, expertType)
1069
+ if err != nil {
1070
+ return "", fmt.Errorf("拉取专家列表: %w", err)
1071
+ }
1072
+ if len(experts) == 0 {
1073
+ return "", fmt.Errorf("专家市场列表为空")
1074
+ }
1075
+ ok, fail := 0, 0
1076
+ for i, e := range experts {
1077
+ if ok >= count {
1078
+ break
1079
+ }
1080
+ // 召唤链(web_element_click + summon_click + summoned)。
1081
+ summonEvents := upstream.DesktopExpertSummonSequence(e)
1082
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, summonEvents...); err != nil {
1083
+ fail++
1084
+ continue
1085
+ }
1086
+ // 真实 chat(带 X-Expert-Id)→ 服务端 requestId。
1087
+ conv, req, cerr := p.cfg.Upstream.DesktopChatWithExpert(a, e.ExpertID)
1088
+ if cerr != nil {
1089
+ fail++
1090
+ continue
1091
+ }
1092
+ // 使用事件(JOIN 服务端 requestId)+ chat 链。
1093
+ events := append(upstream.DesktopChatSequence(conv, req, "msg-"+req[len(req)-8:], "fast-model", "fast-model"),
1094
+ upstream.DesktopExpertActualUseEvent(e, conv, req))
1095
+ if err := p.cfg.Upstream.ReportDesktopEvent(a, events...); err != nil {
1096
+ fail++
1097
+ continue
1098
+ }
1099
+ ok++
1100
+ if i < len(experts)-1 {
1101
+ time.Sleep(expertSummonGap)
1102
+ }
1103
+ }
1104
+ _ = fail
1105
+ return fmt.Sprintf("已对 %d 位真实专家完成召唤+使用链(类型 %s)", ok, expertType), nil
1106
+ }
1107
+
1108
+ // ---------------------------------------------------------------------------
1109
+ // 全量自动完成
1110
+ // ---------------------------------------------------------------------------
1111
+
1112
+ // runAutoAll 对单账号依次执行所有可自动化任务,返回逐项结果。
1113
+ // 供「一键完成全部可自动任务」使用;单项失败不影响后续项。
1114
+ //
1115
+ // 流程:先把所有未接受的任务批量 accept(规范状态机;上游脚本建议"先 accept"),
1116
+ // 再逐项执行行为链路。accept 不是进度产生的必要条件,但让后续状态流转规范。
1117
+ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any {
1118
+ var out []map[string]any
1119
+
1120
+ // 阶段 0:批量接受尚未接受的任务(失败不阻塞——行为事件才是进度唯一判据)。
1121
+ if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil {
1122
+ var codes []string
1123
+ for _, t := range tasks {
1124
+ if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
1125
+ codes = append(codes, t.TaskCode)
1126
+ }
1127
+ }
1128
+ if len(codes) > 0 {
1129
+ if err := p.cfg.Upstream.AcceptTasks(a, codes); err != nil {
1130
+ out = append(out, map[string]any{
1131
+ "task_code": "(批量接受)", "status": "error",
1132
+ "message": "接受任务失败(不阻塞后续): " + err.Error(),
1133
+ })
1134
+ } else {
1135
+ out = append(out, map[string]any{
1136
+ "task_code": "(批量接受)", "status": "done",
1137
+ "message": fmt.Sprintf("已接受 %d 个任务", len(codes)),
1138
+ })
1139
+ time.Sleep(reportGap)
1140
+ }
1141
+ }
1142
+ }
1143
+
1144
+ // 阶段 0b:小程序口径任务单独接受(默认列表不含 mp 码;失败不阻塞)。
1145
+ if mpTasks, err := p.cfg.Upstream.ListTasksMP(a); err == nil {
1146
+ var mpCodes []string
1147
+ for _, t := range mpTasks {
1148
+ if !t.Claimed && !t.Locked && t.AcceptStatus != "accepted" && t.AcceptStatus != "completed" {
1149
+ mpCodes = append(mpCodes, t.TaskCode)
1150
+ }
1151
+ }
1152
+ if len(mpCodes) > 0 {
1153
+ if err := p.cfg.Upstream.AcceptTasksMP(a, mpCodes); err != nil {
1154
+ out = append(out, map[string]any{
1155
+ "task_code": "(批量接受-mp)", "status": "error",
1156
+ "message": "接受小程序任务失败(不阻塞后续): " + err.Error(),
1157
+ })
1158
+ } else {
1159
+ out = append(out, map[string]any{
1160
+ "task_code": "(批量接受-mp)", "status": "done",
1161
+ "message": fmt.Sprintf("已接受 %d 个小程序任务", len(mpCodes)),
1162
+ })
1163
+ time.Sleep(reportGap)
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ for _, act := range autoActions {
1169
+ item := map[string]any{"task_code": act.TaskCode, "desc": act.Desc}
1170
+ before, err := p.taskByCode(a, act.TaskCode)
1171
+ if err != nil {
1172
+ item["status"] = "error"
1173
+ item["message"] = "查询失败: " + err.Error()
1174
+ out = append(out, item)
1175
+ continue
1176
+ }
1177
+ if before == nil {
1178
+ item["status"] = "skipped"
1179
+ item["message"] = "该账号无此任务"
1180
+ out = append(out, item)
1181
+ continue
1182
+ }
1183
+ if before.Claimed || before.Current >= before.Target && before.Target > 0 {
1184
+ item["status"] = "skipped"
1185
+ item["message"] = "已完成(" + taskProgressText(before) + ")"
1186
+ out = append(out, item)
1187
+ continue
1188
+ }
1189
+ msg, err := act.run(p, a)
1190
+ if err != nil {
1191
+ item["status"] = "error"
1192
+ item["message"] = err.Error()
1193
+ out = append(out, item)
1194
+ continue
1195
+ }
1196
+ var after *upstream.Task
1197
+ if isMPTaskCode(act.TaskCode) {
1198
+ after, _ = p.taskByCodeMP(a, act.TaskCode)
1199
+ } else {
1200
+ after, _ = p.taskByCodeWaiting(a, act.TaskCode)
1201
+ }
1202
+ item["status"] = "done"
1203
+ item["message"] = msg
1204
+ item["progress_after"] = taskProgressText(after)
1205
+ // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。
1206
+ // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。
1207
+ if after != nil && after.Claimable {
1208
+ item["claimable"] = true
1209
+ var credit, energy int64
1210
+ var cerr error
1211
+ if isMPTaskCode(act.TaskCode) {
1212
+ credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode)
1213
+ } else {
1214
+ credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode)
1215
+ }
1216
+ if cerr == nil {
1217
+ item["claimed"] = true
1218
+ item["credit"] = credit
1219
+ item["energy"] = energy
1220
+ if credit > 0 || energy > 0 {
1221
+ item["message"] = msg + fmt.Sprintf(";已自动领奖 +%d 分 +%d 能", credit, energy)
1222
+ } else {
1223
+ item["message"] = msg + ";奖励此前已领取"
1224
+ }
1225
+ } else {
1226
+ item["claim_error"] = cerr.Error()
1227
+ item["message"] = msg + ";达标但领奖失败(可在列表手动重试)"
1228
+ }
1229
+ }
1230
+ out = append(out, item)
1231
+ time.Sleep(reportGap) // 项间节流
1232
+ }
1233
+ return out
1234
+ }
1235
+
1236
+ // accountTaskAutoAll 一键完成该账号全部可自动任务。
1237
+ func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) {
1238
+ uid := r.PathValue("uid")
1239
+ a := p.accountByUID(w, uid)
1240
+ if a == nil {
1241
+ return
1242
+ }
1243
+ // per-account 互斥(与单任务动作共用一把锁):重复点击 409。
1244
+ if !p.tryLockAccount(uid) {
1245
+ writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试")
1246
+ return
1247
+ }
1248
+ // 用 context 兜底超时(多项任务串联 + 每项含真实对话,可能耗时较长)。
1249
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
1250
+ defer cancel()
1251
+ done := make(chan []map[string]any, 1)
1252
+ go func() {
1253
+ defer p.unlockAccount(uid) // 流水线真正结束(而非 HTTP 超时返回)才放锁
1254
+ done <- p.runAutoAll(a)
1255
+ }()
1256
+ select {
1257
+ case results := <-done:
1258
+ log.Printf("panel: 一键完成可自动任务 uid=%s 共 %d 项", uid, len(results))
1259
+ writeJSON(w, http.StatusOK, map[string]any{"ok": true, "results": results})
1260
+ case <-ctx.Done():
1261
+ // HTTP 侧超时返回,但后台流水线仍在跑——锁在流水线 goroutine 内释放,
1262
+ // 期间重复点击会被 409 挡住,不会出现两轮并发。
1263
+ writeErr(w, http.StatusGatewayTimeout, "执行超时(任务仍在后台继续)")
1264
+ }
1265
+ }
internal/panel/autotask_lock_test.go ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package panel
2
+
3
+ import (
4
+ "testing"
5
+ )
6
+
7
+ // TestTaskAccountLockSameAccountExclusive 同一账号的任务锁互斥:第二次 tryLock 必须失败,
8
+ // 解锁后可再次获取。这是「重复点一键完成不并发重跑」的核心保障。
9
+ func TestTaskAccountLockSameAccountExclusive(t *testing.T) {
10
+ p := &Panel{}
11
+ uid := "u1"
12
+
13
+ if !p.tryLockAccount(uid) {
14
+ t.Fatal("首次加锁应成功")
15
+ }
16
+ if p.tryLockAccount(uid) {
17
+ t.Fatal("同账号第二次加锁应失败(互斥)")
18
+ }
19
+ p.unlockAccount(uid)
20
+
21
+ if !p.tryLockAccount(uid) {
22
+ t.Fatal("解锁后应可再次加锁")
23
+ }
24
+ p.unlockAccount(uid)
25
+ }
26
+
27
+ // TestTaskAccountLockDifferentAccountsIndependent 不同账号的锁互不影响(并行照旧)。
28
+ func TestTaskAccountLockDifferentAccountsIndependent(t *testing.T) {
29
+ p := &Panel{}
30
+ if !p.tryLockAccount("u1") {
31
+ t.Fatal("u1 加锁应成功")
32
+ }
33
+ if !p.tryLockAccount("u2") {
34
+ t.Fatal("u2 加锁应成功(不同账号不互斥)")
35
+ }
36
+ p.unlockAccount("u2")
37
+ p.unlockAccount("u1")
38
+ }
39
+
40
+ // TestTaskAccountLockCrossEntryShared 单任务 auto 与全量 auto_all 共用同一把账号锁
41
+ // (在 handler 层都走 tryLockAccount,这里验证锁命名空间一致)。
42
+ func TestTaskAccountLockCrossEntryShared(t *testing.T) {
43
+ p := &Panel{}
44
+ if !p.tryLockAccount("u1") {
45
+ t.Fatal("u1 加锁应成功")
46
+ }
47
+ // 模拟全量入口对同一 uid 加锁——必须被挡(否则两入口可并发)。
48
+ if p.tryLockAccount("u1") {
49
+ t.Fatal("同 uid 跨入口加锁应失败(共用锁)")
50
+ }
51
+ p.unlockAccount("u1")
52
+ }
internal/panel/config.go ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // config.go 面板配置页接口:读取当前配置、校验并保存(热生效 + 重启项标注)。
2
+ //
3
+ // 分工:cmd/server 持有 Config 类型与校验逻辑(Load/normalize),此处只做
4
+ // HTTP 编排——GET 回显、POST 透传给注入的 SaveConfig 闭包(由 main 完成
5
+ // "校验 → 落盘 → 热应用 → 返回需重启字段列表")。
6
+ package panel
7
+
8
+ import (
9
+ "io"
10
+ "log"
11
+ "net/http"
12
+ )
13
+
14
+ // getConfig 返回当前配置文件内容与路径(前端按 schema 渲染表单)。
15
+ func (p *Panel) getConfig(w http.ResponseWriter, r *http.Request) {
16
+ if p.cfg.LoadConfig == nil {
17
+ writeErr(w, http.StatusNotImplemented, "config api not available")
18
+ return
19
+ }
20
+ cfg, err := p.cfg.LoadConfig()
21
+ if err != nil {
22
+ writeErr(w, http.StatusInternalServerError, "load config: "+err.Error())
23
+ return
24
+ }
25
+ writeJSON(w, http.StatusOK, map[string]any{
26
+ "ok": true,
27
+ "path": p.cfg.ConfigPath,
28
+ "config": cfg,
29
+ })
30
+ }
31
+
32
+ // saveConfig 保存配置:body 直接是配置 JSON(前端按 schema 组装完整对象)。
33
+ // SaveConfig 闭包内部完成校验+落盘+热应用;校验失败返回 400 且不写盘。
34
+ func (p *Panel) saveConfig(w http.ResponseWriter, r *http.Request) {
35
+ if p.cfg.SaveConfig == nil {
36
+ writeErr(w, http.StatusNotImplemented, "config api not available")
37
+ return
38
+ }
39
+ raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
40
+ if err != nil {
41
+ writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
42
+ return
43
+ }
44
+ restartRequired, err := p.cfg.SaveConfig(raw)
45
+ if err != nil {
46
+ writeErr(w, http.StatusBadRequest, err.Error())
47
+ return
48
+ }
49
+ if restartRequired == nil {
50
+ restartRequired = []string{}
51
+ }
52
+ log.Printf("panel: 配置已保存(热生效完成;需重启字段 %d 个)", len(restartRequired))
53
+ writeJSON(w, http.StatusOK, map[string]any{
54
+ "ok": true,
55
+ "restart_required": restartRequired,
56
+ })
57
+ }