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 +24 -0
- .env.example +0 -179
- .github/ISSUE_TEMPLATE/bug_report.yml +0 -92
- .github/ISSUE_TEMPLATE/config.yml +0 -8
- .github/workflows/docker-publish.yml +0 -81
- .github/workflows/update-version.yml +0 -51
- .gitignore +38 -91
- CONTRIBUTING.md +0 -169
- Dockerfile +36 -35
- LICENSE +22 -83
- README.md +44 -868
- cmd/credit/main.go +294 -0
- cmd/login/main.go +336 -0
- cmd/login/realm_test.go +169 -0
- cmd/server/config.go +616 -0
- cmd/server/config_test.go +816 -0
- cmd/server/credit_floor_config_test.go +48 -0
- cmd/server/main.go +550 -0
- cmd/server/wiring.go +24 -0
- cmd/signin/main.go +140 -0
- cmd/trial/main.go +133 -0
- config.default.json +98 -0
- config.example.json +84 -0
- config.py +0 -496
- credit.sh +14 -0
- darwin-install.sh +0 -55
- docker-compose.yml +11 -75
- docker-entrypoint.sh +69 -0
- docs/README_EN.md +0 -883
- docs/README_JA.md +0 -883
- front/common.js +0 -0
- front/control_panel.html +0 -0
- front/control_panel_mobile.html +0 -0
- go.mod +11 -0
- go.sum +12 -0
- install.ps1 +0 -35
- install.sh +0 -302
- internal/auth/auth.go +378 -0
- internal/auth/auth_test.go +266 -0
- internal/auth/permhint_test.go +29 -0
- internal/auth/realm_test.go +275 -0
- internal/httpauth/httpauth.go +43 -0
- internal/httpauth/httpauth_test.go +61 -0
- internal/livecfg/livecfg.go +48 -0
- internal/logfmt/logfmt.go +166 -0
- internal/logfmt/shortua_test.go +50 -0
- internal/panel/app.js +0 -0
- internal/panel/autotask.go +1265 -0
- internal/panel/autotask_lock_test.go +52 -0
- internal/panel/config.go +57 -0
.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 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
*.
|
| 7 |
-
!pyproject.toml
|
| 8 |
-
creds/
|
| 9 |
-
CLAUDE.md
|
| 10 |
-
GEMINI.md
|
| 11 |
-
.kiro
|
| 12 |
-
# Environment configuration
|
| 13 |
.env
|
|
|
|
| 14 |
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 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 |
-
#
|
| 47 |
-
|
| 48 |
-
pip-delete-this-directory.txt
|
| 49 |
|
| 50 |
-
#
|
| 51 |
-
|
| 52 |
-
|
| 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 |
-
#
|
| 65 |
-
|
| 66 |
-
.venv
|
| 67 |
-
env/
|
| 68 |
-
venv/
|
| 69 |
-
ENV/
|
| 70 |
-
env.bak/
|
| 71 |
-
venv.bak/
|
| 72 |
|
| 73 |
# IDE
|
| 74 |
-
.vscode/
|
| 75 |
.idea/
|
| 76 |
-
.
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
.
|
| 83 |
-
.
|
| 84 |
-
.
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
EXPOSE 7861
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
-
Copyright (c)
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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:
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
sdk: docker
|
| 6 |
app_port: 7861
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
-
# GeminiCLI to API
|
| 10 |
|
| 11 |
-
|
| 12 |
|
| 13 |
-
|
| 14 |
-
[
|
| 15 |
-
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
-
|
|
|
|
| 20 |
|
| 21 |
-
|
| 22 |
-
[](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 |
-
|
| 437 |
-
|
| 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 |
-
|
| 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
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 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 |
-
|
| 877 |
|
| 878 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 2 |
-
|
| 3 |
services:
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
container_name:
|
| 7 |
restart: unless-stopped
|
| 8 |
-
network_mode: host
|
| 9 |
environment:
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
-
|
| 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 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 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 |
-
[](https://www.python.org/downloads/)
|
| 6 |
-
[](../LICENSE)
|
| 7 |
-
[](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
|
| 8 |
-
|
| 9 |
-
[中文](../README.md) | English | [日本語](./README_JA.md)
|
| 10 |
-
|
| 11 |
-
## 🚀 Quick Deploy
|
| 12 |
-
|
| 13 |
-
[](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
|
| 14 |
-
[](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 |
-
[](https://www.python.org/downloads/)
|
| 6 |
-
[](../LICENSE)
|
| 7 |
-
[](https://github.com/su-kaka/gcli2api/pkgs/container/gcli2api)
|
| 8 |
-
|
| 9 |
-
[中文](../README.md) | [English](README_EN.md) | 日本語
|
| 10 |
-
|
| 11 |
-
## 🚀 クイックデプロイ
|
| 12 |
-
|
| 13 |
-
[](https://zeabur.com/templates/97VMEF?referralCode=sukaka)
|
| 14 |
-
[](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 |
+
}
|