feat: add account probes and mute scanning

This commit is contained in:
highkay
2026-05-28 00:08:52 +08:00
parent e50b4e1831
commit 8c0eed2214
41 changed files with 1730 additions and 172 deletions
+4
View File
@@ -717,6 +717,8 @@ Reads runtime settings and status, including:
- `env_backed`, `needs_vercel_sync`
- `toolcall` policy is fixed to `feature_match + high` and is no longer returned or editable via settings
> `runtime.account_mute_scan_interval_seconds` is a config-file field, not a hot-updated `/admin/settings` field. It controls the local long-running background `/api/v0/users/current` mute scan interval, defaults to `43200` seconds, and does not run on Vercel Serverless.
### `PUT /admin/settings`
Hot-updates runtime settings. Supported fields:
@@ -756,6 +758,8 @@ The request can send config directly, or wrapped as `{"config": {...}, "mode":"m
Query params `?mode=merge` / `?mode=replace` are also supported.
`replace` mode replaces the full config shape while preserving Vercel sync metadata. `merge` mode merges `keys`, `api_keys`, `accounts`, and `model_aliases`, and overwrites non-empty fields under `admin`, `runtime`, `responses`, and `embeddings`. Manage `compat`, `auto_delete`, and `history_split` via `/admin/settings` or the config file; legacy `toolcall` fields are ignored.
`merge` mode persists a non-zero `runtime.account_mute_scan_interval_seconds`, but changing that scan interval still requires restarting the local process before the background task uses it.
> Note: `merge` mode does not update `compat`, `auto_delete`, or `history_split`.
### `GET /admin/config/export`
+4
View File
@@ -718,6 +718,8 @@ data: {"type":"message_stop"}
- `env_backed`、`needs_vercel_sync`
- `toolcall` 策略已固定为 `feature_match + high`,不再通过 settings 返回或修改
> `runtime.account_mute_scan_interval_seconds` 是配置文件字段,不属于 `/admin/settings` 热更新范围。它控制本地长进程后台 `/api/v0/users/current` 禁言扫描间隔,默认 `43200` 秒;Vercel Serverless 不运行该后台扫描器。
### `PUT /admin/settings`
热更新运行时设置。支持更新:
@@ -757,6 +759,8 @@ data: {"type":"message_stop"}
也支持在查询参数里传 `?mode=merge` / `?mode=replace`。
`replace` 模式会按完整配置结构替换(保留 Vercel 同步元信息);`merge` 模式会合并 `keys`、`api_keys`、`accounts`、`model_aliases`,并覆盖 `admin`、`runtime`、`responses`、`embeddings` 中的非空字段。`compat`、`auto_delete`、`history_split` 建议通过 `/admin/settings` 或配置文件管理;`toolcall` 相关字段会被忽略。
`merge` 模式会把非零 `runtime.account_mute_scan_interval_seconds` 写入配置,但该扫描间隔仍需重启本地进程后才会影响后台任务。
> 注意:`merge` 模式不会更新 `compat`、`auto_delete`、`history_split`。
### `GET /admin/config/export`
+1 -1
View File
@@ -276,7 +276,7 @@ go run ./cmd/ds2api
- `keys` / `api_keys`:客户端访问密钥,`api_keys` 支持 `name` 与 `remark` 元信息,`keys` 继续兼容。
- `accounts`:DeepSeek 托管账号,支持 `email` 或 `mobile` 登录,可配置代理、名称和备注。
- `model_aliases`:OpenAI / Claude / Gemini 共用的模型 alias 映射。
- `runtime`:账号并发、队列、token 刷新、账号健康冷却与上游文件上传开关,可通过 Admin Settings 热更新。
- `runtime`:账号并发、队列、token 刷新、账号健康冷却、上游文件上传开关,以及本地后台禁言扫描间隔;其中 `account_mute_scan_interval_seconds` 只随配置/重启生效,不通过 Admin Settings 热更新。
- `auto_delete.mode`:请求结束后的远端会话清理策略,支持 `none` / `single` / `all`。
- `history_split`:多轮历史拆分策略,已全局强制开启;可调整触发阈值,避免长历史全部内联进 prompt。
+5
View File
@@ -29,6 +29,10 @@ func main() {
config.Logger.Error("server initialization failed", "error", err)
os.Exit(1)
}
backgroundCtx, stopBackground := context.WithCancel(context.Background())
defer stopBackground()
app.StartBackground(backgroundCtx)
port := strings.TrimSpace(os.Getenv("PORT"))
if port == "" {
port = "5001"
@@ -64,6 +68,7 @@ func main() {
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
sig := <-quit
config.Logger.Info("shutdown signal received", "signal", sig.String())
stopBackground()
// Graceful shutdown: allow up to 10 seconds for in-flight requests to complete.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+1
View File
@@ -65,6 +65,7 @@
"account_max_queue": 0,
"global_max_inflight": 0,
"token_refresh_interval_hours": 6,
"account_mute_scan_interval_seconds": 43200,
"disable_upstream_file_uploads": false,
"account_health_enabled": true,
"account_health_recovery_window_seconds": 300,
+42 -10
View File
@@ -2,7 +2,7 @@
本文记录需要定期跟踪的 DS2API 上游和活跃 fork,用于复查可吸收的实现、规避重复调研,并给出当前处理建议。
最近复查时间:2026-05-25。
最近复查时间:2026-05-27。
## 本地引用
@@ -15,11 +15,16 @@ git fetch https://github.com/fuwei99/ds2api +refs/heads/main:refs/remotes/review
git fetch https://github.com/ricardosantis/ds2api +refs/heads/main:refs/remotes/review/ricardosantis-main
git fetch https://github.com/tempppw01/ds2api +refs/heads/main:refs/remotes/review/tempppw01-main
git fetch https://github.com/TonyWu2333/ds2api +refs/heads/main:refs/remotes/review/TonyWu2333-main
git fetch https://github.com/1cyberlangke1/dsp +refs/heads/main:refs/remotes/review/1cyberlangke1-dsp-main
git fetch https://github.com/emptysuns/ds2api +refs/heads/main:refs/remotes/review/emptysuns-main
git fetch https://github.com/hefengfan0615/ds2api +refs/heads/main:refs/remotes/review/hefengfan0615-main
git fetch https://github.com/voktoylo/ds2api +refs/heads/main:refs/remotes/review/voktoylo-main
git fetch https://github.com/xiaotian2333/ds2api +refs/heads/main:refs/remotes/review/xiaotian2333-main
```
已下线或不可刷新仓库只保留本地旧快照,不再放入常规 fetch 清单:
- `1cyberlangke1/dsp:main` -> `refs/remotes/review/1cyberlangke1-dsp-main`:2026-05-25 与 2026-05-27 两次确认 GitHub 返回 `repository not found`,本地 `0759d7f` 仅作历史对照。
对比新增提交:
```bash
@@ -37,17 +42,21 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
| `ricardosantis/ds2api:main` | `review/ricardosantis-main` | `dc1a76c` | Heroku/Procfile/runtime/env 示例、本地部署文档 | 主要是部署文档和平台样板,价值较低;仅在需要 Heroku 文档时参考。 |
| `tempppw01/ds2api:main` | `review/tempppw01-main` | `9aaa8b4` | 默认禁用上游上传、移除默认 prompt 注入、latest user text prompts、DockerHub workflow | 上游上传禁用已作为可选 runtime 开关吸收;不采用“默认关闭”和 prompt 形状大改,除非有真实回归证据。 |
| `TonyWu2333/ds2api:main` | `review/TonyWu2333-main` | `6dbdcec` | 128k prompt 限制、模型别名微调、prompt role marker 中文化 | 128k 限制已吸收;`6dbdcec` 仅把 DeepSeek marker 换成中文文本,不建议合入,除非动态验证证明更稳。 |
| `1cyberlangke1/dsp:main` | `review/1cyberlangke1-dsp-main` | `0759d7f` | DeepSeek 指纹、device id、x-rangers-id、TLS/transport 调整;同时大规模删除 Vercel/文档/JS 桥 | 只监控上游指纹相关小块;已吸收兼容子集。该 fork 范围偏离 DS2API,不可整合。2026-05-25 复查时 GitHub fetch 返回 repository not found,当前 ref 只能视为本地旧快照。 |
| `1cyberlangke1/dsp:main` | `review/1cyberlangke1-dsp-main` | `0759d7f` | DeepSeek 指纹、device id、x-rangers-id、TLS/transport 调整;同时大规模删除 Vercel/文档/JS 桥 | 冻结为历史快照,不再默认刷新。2026-05-25 与 2026-05-27 两次 GitHub fetch 均返回 `repository not found`;已吸收兼容子集,其余范围偏离 DS2API,不可整合。 |
| `emptysuns/ds2api:main` | `review/emptysuns-main` | `2f87937` | prompt 默认策略、response replacements、tool interception 统一、SSE/stream 边界清洗、late thinking 抑制、WebUI 设置面板 | 有研究价值但改动面很大。建议作为单独专题审查:先用 raw SSE/协议测试证明具体问题,再按 `responserewrite`、`toolpolicy`、`sse normalizer` 等小块拆分引入。 |
| `hefengfan0615/ds2api:main` | `review/hefengfan0615-main` | `37f6206` | 随机 device_id、随机 DeepSeek header/TLS 指纹;后续继续发散为 Qwen2API 改造、Qwen SSE 解析、忽略 PoW、账号相关 `/v1/models` 和 WebUI 模型加载调整 | 不整合。新增 Qwen2API 方向已偏离 DS2API 的 DeepSeek upstream owner,并替换/绕过 DeepSeek SSE、PoW、登录和模型契约;账号相关模型列表 UI 可以作为独立需求重新设计,但不能从该分支直接搬。详见 [hefengfan0615 上游评估报告](./upstream-hefengfan0615-evaluation.md)。 |
| `voktoylo/ds2api:main` | `review/voktoylo-main` | `ec21187` | `/api/v0/users/current` 主动禁言扫描、`test_status=failed` 账号跳过、`503 upstream_unavailable` 自动切号并标记、账号管理未刷新/刷新所选/错误原因展示 | 已吸收高价值子集:本地长进程后台 `users/current` 禁言扫描复用持久化 `muted/mute_until` 与账号健康冷却,Vercel Node streaming 对上游 `403/429/5xx` 非 200 响应释放 lease 时附带惩罚;仍不整块合入其重复状态源和 UI 大改。 |
| `xiaotian2333/ds2api:main` | `review/xiaotian2333-main` | `6531c90` | 敏感词拦截、`users/current` 禁言检查、插入消息前换行;同时删除 toolcall/toolstream/Node stream sieve 和大量工具调用测试 | 不合入。删除工具调用链路与 OpenAI/Claude/Gemini 协议兼容目标冲突,且 `go test ./...` 当前无法编译;敏感词拦截若需要,应作为独立网关策略重新设计。 |
## 当前高价值候选
1. `fuwei99` 的 `x-hif-leim` 查询:2026-05-25 多次无凭证探针确认 `https://hif-leim.deepseek.com/query` 稳定返回 `code=0` 和 `biz_data.value`。已按 best-effort 方式重新实现到 Go completion/continue 和 Vercel Node streaming:成功则带 `x-hif-leim`,失败则软降级,且有短超时和失败退避。仍缺少真实账号 A/B 数据,后续应验证加 header 前后的 403/429/空回复分布。
2. `emptysuns` 的 stream/SSE 边界修复:包括 UTF-8 replacement 边界、Claude SSE charset、late thinking 抑制、response replacement 在 SSE 边界归一。这些直接影响流式输出质量,但必须用样本回放和三协议测试验证。
3. `emptysuns` 的 tool interception policy:有助于收敛 OpenAI/Claude/Gemini 工具拦截策略,不过会触碰现有 canonical XML tool-call 语义,不能整块搬。
4. `hefengfan0615` 早期请求外形/指纹方向:可能对 DeepSeek 上游风控有帮助,但必须先用真实登录、建会话、PoW、completion 四段探针验证成功率和错误分布;不要直接引入 `math/rand` 全局随机、不稳定 TLS 指纹,或后续 Qwen2API 改造。
5. `fuwei99` 的容器配置 bootstrap 和模型映射:可能改善容器首次启动体验和模型覆盖,但要先和本仓库 `config`/`model_aliases` 语义对齐。
1. 本地 `C:\Users\highk\Downloads\deepseek-2api-4242` 的账号探针:`/api/v0/users/current` 比 `/api/v0/client/settings` 更适合验证 DeepSeek token;`/api/v0/client/settings?scope=model&did=...` 可读取 `model_configs.value` 与 `vision.switchable`。本仓库已只把这两块吸收到 Admin/WebUI 的账号检查和运行时能力展示,不改变 public 模型目录、模型别名、prompt 兼容流或协议契约。
2. `fuwei99` 的 `x-hif-leim` 查询:2026-05-25 多次无凭证探针确认 `https://hif-leim.deepseek.com/query` 稳定返回 `code=0` 和 `biz_data.value`。已按 best-effort 方式重新实现到 Go completion/continue 和 Vercel Node streaming:成功则带 `x-hif-leim`,失败则软降级,且有短超时和失败退避。仍缺少真实账号 A/B 数据,后续应验证加 header 前后的 403/429/空回复分布。
3. `emptysuns` 的 stream/SSE 边界修复:包括 UTF-8 replacement 边界、Claude SSE charset、late thinking 抑制、response replacement 在 SSE 边界归一。这些直接影响流式输出质量,但必须用样本回放和三协议测试验证。
4. `emptysuns` 的 tool interception policy:有助于收敛 OpenAI/Claude/Gemini 工具拦截策略,不过会触碰现有 canonical XML tool-call 语义,不能整块搬。
5. `hefengfan0615` 早期请求外形/指纹方向:可能对 DeepSeek 上游风控有帮助,但必须先用真实登录、建会话、PoW、completion 四段探针验证成功率和错误分布;不要直接引入 `math/rand` 全局随机、不稳定 TLS 指纹,或后续 Qwen2API 改造。
6. `fuwei99` 的容器配置 bootstrap 和模型映射:可能改善容器首次启动体验和模型覆盖,但要先和本仓库 `config`/`model_aliases` 语义对齐。
7. `voktoylo` 的主动禁言扫描和失败账号降权:已按本仓库现有结构吸收 scanner 与 Vercel 非 200 惩罚两小块。未采用上游 `mutestate` 重复状态源、`test_status=failed` 持久跳过和账号管理页大改;这些后续只有在真实样本证明必要时再单独设计。
## 当前不建议合入
@@ -59,6 +68,29 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
6. `hefengfan0615` 的 Qwen2API 改造:包括登录 payload、session payload、completion payload、忽略 PoW、Qwen SSE 分支和模型目录的成套替换。它是另一个上游服务适配,不是 DS2API 的 DeepSeek upstream 修复。
7. `fuwei99` 的中文 prompt marker 和 `invaild-file*` current-input-file 包装/改名:会改变核心 prompt 形状和 file-reference 语义,且分支通过批量测试改写适配自身实现,不能证明对本仓库有效。
8. 未记录 fork 中的个人配置/凭证提交:`lamthien8x` 和 `dangtai2710` 当前仍主要是 `config.json` / `config.example.json` 中的个人配置或明文凭据改动,不能吸收。
9. `xiaotian2333` 的工具调用删除链路:大规模删除 `internal/toolcall`、`internal/toolstream`、Node stream sieve、prompt tool injection 和协议测试,会直接破坏当前工具调用兼容契约;不能用“禁用工具”方式修复上游风险。
## 2026-05-27 增量复查
已刷新长期监控 ref:
- 未变化:`CJackHwang` `8316cf8`、`qingdeng888` `42bc1ed`、`fuwei99` `5ee2a1f`、`ricardosantis` `dc1a76c`、`tempppw01` `9aaa8b4`、`TonyWu2333` `6dbdcec`、`emptysuns` `2f87937`、`hefengfan0615` `37f6206`。
- 无法刷新:`1cyberlangke1/dsp` 远端仍返回 `repository not found`,已从常规 fetch 清单移除;本地 `0759d7f` 只保留为旧快照。
本次新增长期监控 fork:
- `voktoylo/ds2api`:`ec21187`。相对 `CJackHwang/main` 有 7 个提交、54 个文件,主要新增 `internal/account/mutescan`、`internal/account/mutestate`、`/api/v0/users/current` 账号禁言探针、pool 跳过 failed/muted 账号、`503 upstream_unavailable` 切号标记,以及账号管理页的状态分组和刷新所选。
- 验证:隔离 worktree 跑 `go test ./...` 通过;`npm run build --prefix webui` 通过;`git diff --check refs/remotes/review/cjack-main...refs/remotes/review/voktoylo-main` 未通过,问题集中在 `webui/src/features/account/useAccountsData.js` 的尾随空白。
- 结论:有价值但只适合拆分吸收。本仓库已经有持久化 `muted/mute_until`、reactive mute detection、账号健康冷却和账号 probe,直接合入会重复状态源;已吸收主动 `users/current` 扫描和 Vercel 上游非 200 惩罚,silent block/UI 错误原因仍留作后续专题。
- `xiaotian2333/ds2api`:`6531c90`。相对 `CJackHwang/main` 有 6 个提交、126 个文件,新增 `internal/sensitivewords` 和 `users/current` 禁言检查,但同时删除工具调用、toolstream、Node stream sieve、工具 prompt 注入和大量协议测试。
- 验证:`git diff --check refs/remotes/review/cjack-main...refs/remotes/review/xiaotian2333-main` 通过;隔离 worktree 跑 `go test ./...` 未通过,代表性错误包括 `promptcompat.DefaultToolChoicePolicy`、`ToolChoicePolicy`、`buildClaudeToolPrompt`、`buildOpenAIFinalPrompt` 和 `Turn.ToolCalls` 被删除后仍被测试/调用引用。
- 结论:不纳入可合入候选。敏感词拦截可以作为独立产品策略重新设计,但不能接受以删除工具调用兼容层为代价的分支。
上次未确认的短期 fork 本次已拉取到本地:
- `6um6n7qu/ds2api`:`85d8f7f`。新增的是另一套占位入口和 `config` 包,包含 `github.com/yourusername/ds2api/internal/api` 这类不匹配本仓库 module 的 import,并混入 Synology NAS 配置语义;不纳入监控。隔离 worktree 跑 `go test ./...` 未通过,根包在 setup 阶段失败。
- `maple323/ds2api`:`f1f7dd8`。只把 `.env.example` 改名成 `config.json`,内容仍是 env 格式并含示例 admin key;`git diff --check refs/remotes/review/cjack-main...HEAD` 通过,但没有可吸收业务价值。
- `zzz449/ds2api`:`28b9b5b`。删除 Output integrity guard,并把 `.gitignore` 改成带 Markdown fence 的通用模板;隔离 worktree 跑 `go test ./internal/prompt` 编译失败,`messages_test.go` 仍引用被删除的 `outputIntegrityGuardPrompt`,不纳入监控。
## 2026-05-25 增量复查
@@ -66,7 +98,7 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
- 未变化:`CJackHwang` `8316cf8`、`qingdeng888` `42bc1ed`、`ricardosantis` `dc1a76c`、`tempppw01` `9aaa8b4`、`TonyWu2333` `6dbdcec`、`emptysuns` `2f87937`。
- 有更新:`fuwei99` 从 `08b4aa5` 到 `5ee2a1f`;`hefengfan0615` 从本地旧 ref `2fd3c0d` 到 `37f6206`。
- 无法刷新:`1cyberlangke1/dsp` 远端返回 repository not found,本地 `0759d7f` 保留为旧快照。
- 无法刷新:`1cyberlangke1/dsp` 远端返回 repository not found,本地 `0759d7f` 保留为旧快照;2026-05-27 复查后已从常规 fetch 清单移除。
`fuwei99` 增量结论:
@@ -87,7 +119,7 @@ git diff --stat refs/remotes/review/cjack-main..refs/remotes/review/<name>
- `kingleykin/ds2api`:`e8eea7d`,主要是越南语 `START_HERE` 和示例配置;不纳入监控。
- `dangtai2710/ds2api`:`dd730aa`,只改 `config.example.json` 中示例账号字段;不纳入监控。
- `leeamkim/ds2api`:`379d85a`,是 Synology/NAS 私有客户端骨架和 timeout 调整,和本仓库 DeepSeek/OpenAI/Claude/Gemini 适配主线无关;不纳入监控。
- `6um6n7qu/ds2api`、`maple323/ds2api`、`zzz449/ds2api`:GitHub API rate limit 后,本机 `git ls-remote` 多次返回 TLS EOF,本次未能确认远端 HEAD;保持“不纳入长期监控”的旧结论,但下次可从 GitHub forks API 重新拉取。
- `6um6n7qu/ds2api`、`maple323/ds2api`、`zzz449/ds2api`:GitHub API rate limit 后,本机 `git ls-remote` 多次返回 TLS EOF,本次未能确认远端 HEAD;保持“不纳入长期监控”的旧结论。2026-05-27 已重新拉取并复查,仍无可吸收价值。
## 2026-05-19 其他上游快扫
+168
View File
@@ -0,0 +1,168 @@
package mutescan
import (
"context"
"strings"
"sync"
"time"
"ds2api/internal/account"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
const (
DefaultInterval = 12 * time.Hour
MinInterval = 30 * time.Second
maxConcurrency = 4
)
type Store interface {
Accounts() []config.Account
MarkAccountMuted(identifier string, muteUntil float64) error
ClearAccountMute(identifier string) error
}
type Checker interface {
GetAccountMuteStatus(ctx context.Context, token string) (*dsclient.AccountMuteStatus, error)
}
type Penalizer interface {
Penalize(accountID string, kind account.PenaltyKind)
}
type Summary struct {
Total int
Checked int
Muted int
Cleared int
Failed int
}
type Scanner struct {
store Store
checker Checker
penalizer Penalizer
interval time.Duration
}
func New(store Store, checker Checker, penalizer Penalizer, interval time.Duration) *Scanner {
if interval <= 0 {
interval = DefaultInterval
}
if interval < MinInterval {
interval = MinInterval
}
return &Scanner{store: store, checker: checker, penalizer: penalizer, interval: interval}
}
func (s *Scanner) Start(ctx context.Context) {
if s == nil || s.store == nil || s.checker == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
go s.run(ctx)
}
func (s *Scanner) run(ctx context.Context) {
s.RefreshNow(ctx)
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.RefreshNow(ctx)
}
}
}
func (s *Scanner) RefreshNow(ctx context.Context) Summary {
if s == nil || s.store == nil || s.checker == nil {
return Summary{}
}
if ctx == nil {
ctx = context.Background()
}
accounts := s.store.Accounts()
summary := Summary{Total: len(accounts)}
sem := make(chan struct{}, maxConcurrency)
var wg sync.WaitGroup
var mu sync.Mutex
scan:
for _, acc := range accounts {
if ctx.Err() != nil {
break
}
acc := acc
identifier := acc.Identifier()
if identifier == "" || !acc.IsActive() || strings.TrimSpace(acc.Token) == "" {
continue
}
select {
case <-ctx.Done():
break scan
case sem <- struct{}{}:
}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
status, err := s.checker.GetAccountMuteStatus(ctx, acc.Token)
if err != nil {
config.Logger.Warn("[account_mute_scan] current-user probe failed", "account", identifier, "error", err)
mu.Lock()
summary.Failed++
mu.Unlock()
return
}
mu.Lock()
summary.Checked++
mu.Unlock()
if status != nil && status.Muted {
if err := s.store.MarkAccountMuted(identifier, status.MuteUntil); err != nil {
config.Logger.Warn("[account_mute_scan] failed to mark muted account", "account", identifier, "error", err)
mu.Lock()
summary.Failed++
mu.Unlock()
return
}
if s.penalizer != nil {
s.penalizer.Penalize(identifier, account.PenaltyMuted)
}
mu.Lock()
summary.Muted++
mu.Unlock()
return
}
if acc.Muted || acc.MuteUntil > 0 {
if err := s.store.ClearAccountMute(identifier); err != nil {
config.Logger.Warn("[account_mute_scan] failed to clear account mute", "account", identifier, "error", err)
mu.Lock()
summary.Failed++
mu.Unlock()
return
}
mu.Lock()
summary.Cleared++
mu.Unlock()
}
}()
}
wg.Wait()
if summary.Checked > 0 || summary.Muted > 0 || summary.Cleared > 0 || summary.Failed > 0 {
config.Logger.Info("[account_mute_scan] completed", "total", summary.Total, "checked", summary.Checked, "muted", summary.Muted, "cleared", summary.Cleared, "failed", summary.Failed)
}
return summary
}
+118
View File
@@ -0,0 +1,118 @@
package mutescan
import (
"context"
"errors"
"sync"
"testing"
"ds2api/internal/account"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
func TestScannerRefreshMarksClearsAndSkipsAccounts(t *testing.T) {
store := &fakeMuteStore{accounts: []config.Account{
{Email: "muted@example.com", Token: "tok-muted"},
{Email: "old@example.com", Token: "tok-clear", Muted: true, MuteUntil: 9999},
{Email: "err@example.com", Token: "tok-error", Muted: true, MuteUntil: 8888},
{Email: "inactive@example.com", Token: "tok-inactive", Active: boolPtr(false)},
{Email: "empty-token@example.com"},
{Token: "tok-token-only"},
}}
checker := &fakeMuteChecker{
statuses: map[string]*dsclient.AccountMuteStatus{
"tok-muted": {Muted: true, MuteUntil: 12345},
"tok-clear": {Muted: false},
},
errs: map[string]error{
"tok-error": errors.New("probe failed"),
},
}
penalizer := &fakeMutePenalizer{}
scanner := New(store, checker, penalizer, 0)
summary := scanner.RefreshNow(context.Background())
if summary.Total != len(store.accounts) || summary.Checked != 2 || summary.Muted != 1 || summary.Cleared != 1 || summary.Failed != 1 {
t.Fatalf("unexpected summary: %#v", summary)
}
if got := store.marked["muted@example.com"]; got != 12345 {
t.Fatalf("expected muted@example.com marked until 12345, got %v", got)
}
if len(store.cleared) != 1 || store.cleared[0] != "old@example.com" {
t.Fatalf("unexpected cleared accounts: %#v", store.cleared)
}
if len(penalizer.ids) != 1 || penalizer.ids[0] != "muted@example.com" {
t.Fatalf("unexpected penalties: %#v", penalizer.ids)
}
if len(checker.tokens) != 3 {
t.Fatalf("expected 3 probes, got %#v", checker.tokens)
}
}
type fakeMuteStore struct {
mu sync.Mutex
accounts []config.Account
marked map[string]float64
cleared []string
}
func (s *fakeMuteStore) Accounts() []config.Account {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]config.Account, len(s.accounts))
copy(out, s.accounts)
return out
}
func (s *fakeMuteStore) MarkAccountMuted(identifier string, muteUntil float64) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.marked == nil {
s.marked = map[string]float64{}
}
s.marked[identifier] = muteUntil
return nil
}
func (s *fakeMuteStore) ClearAccountMute(identifier string) error {
s.mu.Lock()
defer s.mu.Unlock()
s.cleared = append(s.cleared, identifier)
return nil
}
type fakeMuteChecker struct {
mu sync.Mutex
statuses map[string]*dsclient.AccountMuteStatus
errs map[string]error
tokens []string
}
func (c *fakeMuteChecker) GetAccountMuteStatus(_ context.Context, token string) (*dsclient.AccountMuteStatus, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.tokens = append(c.tokens, token)
if err := c.errs[token]; err != nil {
return nil, err
}
return c.statuses[token], nil
}
type fakeMutePenalizer struct {
mu sync.Mutex
ids []string
}
func (p *fakeMutePenalizer) Penalize(accountID string, kind account.PenaltyKind) {
if kind != account.PenaltyMuted {
return
}
p.mu.Lock()
defer p.mu.Unlock()
p.ids = append(p.ids, accountID)
}
func boolPtr(v bool) *bool {
return &v
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"errors"
"slices"
"strings"
)
type AccountCapabilityProbe struct {
Vision *bool `json:"vision,omitempty"`
Models []string `json:"models,omitempty"`
CheckedAt int64 `json:"checked_at,omitempty"`
Source string `json:"source,omitempty"`
}
func (p AccountCapabilityProbe) Clone() AccountCapabilityProbe {
out := p
if p.Vision != nil {
vision := *p.Vision
out.Vision = &vision
}
out.Models = slices.Clone(p.Models)
return out
}
type AccountRuntimeProbe struct {
TokenValid *bool `json:"token_valid,omitempty"`
TokenHTTPStatus int `json:"token_http_status,omitempty"`
TokenCode int `json:"token_code,omitempty"`
TokenBizCode int `json:"token_biz_code,omitempty"`
TokenMessage string `json:"token_message,omitempty"`
Capabilities AccountCapabilityProbe `json:"capabilities,omitempty"`
CapabilityError string `json:"capability_error,omitempty"`
CheckedAt int64 `json:"checked_at,omitempty"`
}
func (p AccountRuntimeProbe) Clone() AccountRuntimeProbe {
out := p
if p.TokenValid != nil {
valid := *p.TokenValid
out.TokenValid = &valid
}
out.Capabilities = p.Capabilities.Clone()
return out
}
func (s *Store) UpdateAccountRuntimeProbe(identifier string, probe AccountRuntimeProbe) error {
identifier = strings.TrimSpace(identifier)
s.mu.Lock()
defer s.mu.Unlock()
idx, ok := s.findAccountIndexLocked(identifier)
if !ok {
return errors.New("account not found")
}
s.setAccountRuntimeProbeLocked(s.cfg.Accounts[idx], probe, identifier)
return nil
}
func (s *Store) AccountRuntimeProbe(identifier string) (AccountRuntimeProbe, bool) {
identifier = strings.TrimSpace(identifier)
if identifier == "" {
return AccountRuntimeProbe{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
probe, ok := s.accProbe[identifier]
if !ok {
return AccountRuntimeProbe{}, false
}
return probe.Clone(), true
}
+1
View File
@@ -62,6 +62,7 @@ func runtimeConfigPresent(runtime RuntimeConfig) bool {
runtime.AccountMaxQueue > 0 ||
runtime.GlobalMaxInflight > 0 ||
runtime.TokenRefreshIntervalHours > 0 ||
runtime.AccountMuteScanIntervalSeconds > 0 ||
runtime.DisableUpstreamFileUploads != nil ||
runtime.AccountHealthEnabled != nil ||
runtime.AccountHealthRecoveryWindowSeconds > 0 ||
+1
View File
@@ -169,6 +169,7 @@ type RuntimeConfig struct {
AccountMaxQueue int `json:"account_max_queue,omitempty"`
GlobalMaxInflight int `json:"global_max_inflight,omitempty"`
TokenRefreshIntervalHours int `json:"token_refresh_interval_hours,omitempty"`
AccountMuteScanIntervalSeconds int `json:"account_mute_scan_interval_seconds,omitempty"`
DisableUpstreamFileUploads *bool `json:"disable_upstream_file_uploads,omitempty"`
AccountHealthEnabled *bool `json:"account_health_enabled,omitempty"`
AccountHealthRecoveryWindowSeconds int `json:"account_health_recovery_window_seconds,omitempty"`
+86
View File
@@ -321,6 +321,38 @@ func TestRuntimeTokenRefreshIntervalHoursUsesConfigValue(t *testing.T) {
}
}
func TestRuntimeAccountMuteScanIntervalSecondsDefaultsToTwelveHours(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"email":"u@example.com","password":"p"}]
}`)
store := LoadStore()
if got := store.RuntimeAccountMuteScanIntervalSeconds(); got != 43200 {
t.Fatalf("expected default mute scan interval 43200, got %d", got)
}
}
func TestRuntimeAccountMuteScanIntervalSecondsUsesConfigValue(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"keys":["k1"],
"accounts":[{"email":"u@example.com","password":"p"}],
"runtime":{"account_mute_scan_interval_seconds":3600}
}`)
store := LoadStore()
if got := store.RuntimeAccountMuteScanIntervalSeconds(); got != 3600 {
t.Fatalf("expected configured mute scan interval 3600, got %d", got)
}
}
func TestValidateRuntimeConfigRejectsInvalidMuteScanInterval(t *testing.T) {
err := ValidateRuntimeConfig(RuntimeConfig{AccountMuteScanIntervalSeconds: 10})
if err == nil || !strings.Contains(err.Error(), "runtime.account_mute_scan_interval_seconds") {
t.Fatalf("expected mute scan interval validation error, got %v", err)
}
}
func TestStoreUpdateAccountTokenKeepsIdentifierResolvable(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{
"accounts":[{"email":"user@example.com","password":"p"}]
@@ -424,3 +456,57 @@ func TestAccountTestStatusIsRuntimeOnlyAndNotPersisted(t *testing.T) {
t.Fatalf("expected test_status to stay out of persisted config, got: %s", content)
}
}
func TestAccountRuntimeProbeIsRuntimeOnlyAndPreservedAcrossReindex(t *testing.T) {
tmp, err := os.CreateTemp(t.TempDir(), "config-*.json")
if err != nil {
t.Fatalf("create temp config: %v", err)
}
defer func() { _ = tmp.Close() }()
if _, err := tmp.WriteString(`{"accounts":[{"email":"u@example.com","password":"p"}]}`); err != nil {
t.Fatalf("write temp config: %v", err)
}
t.Setenv("DS2API_CONFIG_JSON", "")
t.Setenv("DS2API_CONFIG_PATH", tmp.Name())
store := LoadStore()
vision := true
valid := true
probe := AccountRuntimeProbe{
TokenValid: &valid,
TokenHTTPStatus: 200,
Capabilities: AccountCapabilityProbe{
Vision: &vision,
Models: []string{"chat", "vision"},
CheckedAt: 123,
Source: "client_settings",
},
CheckedAt: 456,
}
if err := store.UpdateAccountRuntimeProbe("u@example.com", probe); err != nil {
t.Fatalf("update runtime probe: %v", err)
}
if got, ok := store.AccountRuntimeProbe("u@example.com"); !ok || got.TokenValid == nil || !*got.TokenValid {
t.Fatalf("expected runtime probe by email, got %#v (ok=%v)", got, ok)
}
if err := store.Update(func(c *Config) error {
c.Accounts[0].Remark = "updated"
return nil
}); err != nil {
t.Fatalf("update config: %v", err)
}
got, ok := store.AccountRuntimeProbe("u@example.com")
if !ok || got.Capabilities.Vision == nil || !*got.Capabilities.Vision {
t.Fatalf("expected runtime probe to survive reindex, got %#v (ok=%v)", got, ok)
}
content, err := os.ReadFile(tmp.Name())
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(content), "client_settings") || strings.Contains(string(content), "token_valid") {
t.Fatalf("expected runtime probe to stay out of persisted config, got: %s", content)
}
}
+8 -7
View File
@@ -11,13 +11,14 @@ import (
)
type Store struct {
mu sync.RWMutex
cfg Config
path string
fromEnv bool
keyMap map[string]struct{} // O(1) API key lookup index
accMap map[string]int // O(1) account lookup: identifier -> slice index
accTest map[string]string // runtime-only account test status cache
mu sync.RWMutex
cfg Config
path string
fromEnv bool
keyMap map[string]struct{} // O(1) API key lookup index
accMap map[string]int // O(1) account lookup: identifier -> slice index
accTest map[string]string // runtime-only account test status cache
accProbe map[string]AccountRuntimeProbe
}
func LoadStore() *Store {
+9
View File
@@ -159,6 +159,15 @@ func (s *Store) RuntimeTokenRefreshIntervalHours() int {
return 6
}
func (s *Store) RuntimeAccountMuteScanIntervalSeconds() int {
s.mu.RLock()
defer s.mu.RUnlock()
if s.cfg.Runtime.AccountMuteScanIntervalSeconds > 0 {
return s.cfg.Runtime.AccountMuteScanIntervalSeconds
}
return 43200
}
func (s *Store) UpstreamFileUploadsEnabled() bool {
s.mu.RLock()
defer s.mu.RUnlock()
+24
View File
@@ -3,12 +3,14 @@ package config
// rebuildIndexes must be called with the lock already held (or during init).
func (s *Store) rebuildIndexes() {
prevStatus := s.accTest
prevProbe := s.accProbe
s.keyMap = make(map[string]struct{}, len(s.cfg.Keys))
for _, k := range s.cfg.Keys {
s.keyMap[k] = struct{}{}
}
s.accMap = make(map[string]int, len(s.cfg.Accounts))
s.accTest = make(map[string]string, len(s.cfg.Accounts))
s.accProbe = make(map[string]AccountRuntimeProbe, len(s.cfg.Accounts))
for i, acc := range s.cfg.Accounts {
id := acc.Identifier()
if id != "" {
@@ -16,6 +18,9 @@ func (s *Store) rebuildIndexes() {
if status, ok := prevStatus[id]; ok {
s.setAccountTestStatusLocked(acc, status, "")
}
if probe, ok := prevProbe[id]; ok {
s.setAccountRuntimeProbeLocked(acc, probe, "")
}
}
}
}
@@ -53,3 +58,22 @@ func (s *Store) setAccountTestStatusLocked(acc Account, status, hintedIdentifier
s.accTest[hintedIdentifier] = status
}
}
func (s *Store) setAccountRuntimeProbeLocked(acc Account, probe AccountRuntimeProbe, hintedIdentifier string) {
probe = probe.Clone()
if s.accProbe == nil {
s.accProbe = make(map[string]AccountRuntimeProbe)
}
if id := acc.Identifier(); id != "" {
s.accProbe[id] = probe
}
if email := acc.Email; email != "" {
s.accProbe[email] = probe
}
if mobile := CanonicalMobileKey(acc.Mobile); mobile != "" {
s.accProbe[mobile] = probe
}
if hintedIdentifier = lower(hintedIdentifier); hintedIdentifier != "" {
s.accProbe[hintedIdentifier] = probe
}
}
+3
View File
@@ -96,6 +96,9 @@ func ValidateRuntimeConfig(runtime RuntimeConfig) error {
if err := ValidateIntRange("runtime.token_refresh_interval_hours", runtime.TokenRefreshIntervalHours, 1, 720, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_mute_scan_interval_seconds", runtime.AccountMuteScanIntervalSeconds, 30, 604800, false); err != nil {
return err
}
if err := ValidateIntRange("runtime.account_health_recovery_window_seconds", runtime.AccountHealthRecoveryWindowSeconds, 1, 86400, false); err != nil {
return err
}
+216
View File
@@ -0,0 +1,216 @@
package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
dsprotocol "ds2api/internal/deepseek/protocol"
)
type TokenValidationResult struct {
Valid bool
HTTPStatus int
Code int
BizCode int
Message string
}
type AccountCapabilities struct {
Vision *bool
Models []string
CheckedAt int64
Source string
}
type AccountMuteStatus struct {
Muted bool
MuteUntil float64
CheckedAt int64
HTTPStatus int
Code int
BizCode int
Message string
Source string
}
func (c *Client) ValidateToken(ctx context.Context, token string) (*TokenValidationResult, error) {
token = strings.TrimSpace(token)
if token == "" {
return &TokenValidationResult{Valid: false, Message: "empty token"}, nil
}
clients := c.requestClientsFromContext(ctx)
body, status, err := c.getJSONWithStatus(ctx, clients.regular, dsprotocol.DeepSeekCurrentUserURL, c.authHeaders(token))
if err != nil {
return nil, err
}
code, bizCode, msg, bizMsg := extractResponseStatus(body)
valid := status == http.StatusOK && code == 0 && bizCode == 0 && !isTokenInvalid(status, code, bizCode, msg, bizMsg)
result := &TokenValidationResult{Valid: valid, HTTPStatus: status, Code: code, BizCode: bizCode}
if !valid {
result.Message = failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status))
}
return result, nil
}
func (c *Client) GetAccountMuteStatus(ctx context.Context, token string) (*AccountMuteStatus, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("empty token")
}
clients := c.requestClientsFromContext(ctx)
body, status, err := c.getJSONWithStatus(ctx, clients.regular, dsprotocol.DeepSeekCurrentUserURL, c.authHeaders(token))
if err != nil {
return nil, err
}
code, bizCode, msg, bizMsg := extractResponseStatus(body)
info := extractMuteInfo(body)
result := &AccountMuteStatus{
Muted: info.Muted,
MuteUntil: info.Until,
CheckedAt: time.Now().Unix(),
HTTPStatus: status,
Code: code,
BizCode: bizCode,
Message: failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status)),
Source: "users_current",
}
if info.Muted {
return result, nil
}
if status != http.StatusOK || code != 0 || bizCode != 0 || isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return nil, fmt.Errorf("current user failed: %s", result.Message)
}
result.Message = ""
return result, nil
}
func (c *Client) GetAccountCapabilities(ctx context.Context, token string, accountID string) (*AccountCapabilities, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, errors.New("empty token")
}
endpoint, err := url.Parse(dsprotocol.DeepSeekClientSettingsURL)
if err != nil {
return nil, err
}
q := endpoint.Query()
q.Set("did", DeviceID(accountID))
q.Set("scope", "model")
endpoint.RawQuery = q.Encode()
clients := c.requestClientsFromContext(ctx)
body, status, err := c.getJSONWithStatus(ctx, clients.regular, endpoint.String(), c.authHeaders(token))
if err != nil {
return nil, err
}
code, bizCode, msg, bizMsg := extractResponseStatus(body)
if status != http.StatusOK || code != 0 || bizCode != 0 || isTokenInvalid(status, code, bizCode, msg, bizMsg) {
return nil, fmt.Errorf("client settings failed: %s", failureMessage(msg, bizMsg, fmt.Sprintf("HTTP %d", status)))
}
configs := extractModelConfigs(body)
modelSet := map[string]struct{}{}
var vision *bool
seenVision := false
for _, item := range configs {
modelType := stringFromProbeAny(item["model_type"])
if modelType == "" {
modelType = stringFromProbeAny(item["modelType"])
}
if modelType != "" {
modelSet[modelType] = struct{}{}
}
if modelType == "vision" {
seenVision = true
if value, ok := boolFromProbeAny(item["switchable"]); ok {
vision = boolPtr(value)
}
}
}
if len(configs) > 0 && !seenVision {
vision = boolPtr(false)
}
models := make([]string, 0, len(modelSet))
for model := range modelSet {
models = append(models, model)
}
sort.Strings(models)
return &AccountCapabilities{
Vision: vision,
Models: models,
CheckedAt: time.Now().Unix(),
Source: "client_settings",
}, nil
}
func extractModelConfigs(body map[string]any) []map[string]any {
data, _ := body["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
settings, _ := bizData["settings"].(map[string]any)
modelConfigs, _ := settings["model_configs"].(map[string]any)
switch raw := modelConfigs["value"].(type) {
case []any:
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
case string:
var decoded []map[string]any
if err := json.Unmarshal([]byte(raw), &decoded); err == nil {
return decoded
}
var rawItems []any
if err := json.Unmarshal([]byte(raw), &rawItems); err != nil {
return nil
}
out := make([]map[string]any, 0, len(rawItems))
for _, item := range rawItems {
if m, ok := item.(map[string]any); ok {
out = append(out, m)
}
}
return out
default:
return nil
}
}
func stringFromProbeAny(v any) string {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func boolFromProbeAny(v any) (bool, bool) {
switch x := v.(type) {
case bool:
return x, true
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "1":
return true, true
case "false", "0":
return false, true
}
case float64:
return x != 0, true
case int:
return x != 0, true
}
return false, false
}
func boolPtr(v bool) *bool {
return &v
}
@@ -0,0 +1,183 @@
package client
import (
"context"
"io"
"net/http"
"reflect"
"strings"
"testing"
dsprotocol "ds2api/internal/deepseek/protocol"
)
func TestValidateTokenUsesCurrentUserEndpoint(t *testing.T) {
var gotURL string
var gotAuth string
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
gotURL = req.URL.String()
gotAuth = req.Header.Get("Authorization")
return probeResponse(http.StatusOK, `{"code":0,"data":{"biz_code":0}}`, req), nil
})}
result, err := c.ValidateToken(context.Background(), "tok")
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if !result.Valid {
t.Fatalf("expected token valid, got %#v", result)
}
if gotURL != dsprotocol.DeepSeekCurrentUserURL {
t.Fatalf("unexpected endpoint: %s", gotURL)
}
if gotAuth != "Bearer tok" {
t.Fatalf("unexpected auth header: %q", gotAuth)
}
}
func TestValidateTokenMarksInvalidStatus(t *testing.T) {
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
return probeResponse(http.StatusUnauthorized, `{"code":401,"msg":"invalid token","data":{"biz_code":401}}`, req), nil
})}
result, err := c.ValidateToken(context.Background(), "bad")
if err != nil {
t.Fatalf("ValidateToken error: %v", err)
}
if result.Valid {
t.Fatalf("expected invalid token, got %#v", result)
}
if result.HTTPStatus != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", result.HTTPStatus)
}
if !strings.Contains(result.Message, "invalid token") {
t.Fatalf("expected invalid token message, got %q", result.Message)
}
}
func TestGetAccountCapabilitiesParsesModelConfigsArray(t *testing.T) {
var gotURL string
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
gotURL = req.URL.String()
return probeResponse(http.StatusOK, `{
"code":0,
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":[
{"model_type":"chat","switchable":true},
{"model_type":"vision","switchable":true},
{"model_type":"expert","switchable":false}
]}}}}
}`, req), nil
})}
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
if err != nil {
t.Fatalf("GetAccountCapabilities error: %v", err)
}
if result.Vision == nil || !*result.Vision {
t.Fatalf("expected vision switchable, got %#v", result.Vision)
}
if !reflect.DeepEqual(result.Models, []string{"chat", "expert", "vision"}) {
t.Fatalf("unexpected models: %#v", result.Models)
}
if result.Source != "client_settings" {
t.Fatalf("unexpected source: %q", result.Source)
}
if !strings.HasPrefix(gotURL, dsprotocol.DeepSeekClientSettingsURL+"?") {
t.Fatalf("unexpected endpoint: %s", gotURL)
}
if !strings.Contains(gotURL, "scope=model") || !strings.Contains(gotURL, "did=") {
t.Fatalf("expected scope and did query, got %s", gotURL)
}
}
func TestGetAccountCapabilitiesParsesModelConfigsString(t *testing.T) {
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
return probeResponse(http.StatusOK, `{
"code":0,
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":"[{\"model_type\":\"vision\",\"switchable\":\"false\"}]"}}}}
}`, req), nil
})}
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
if err != nil {
t.Fatalf("GetAccountCapabilities error: %v", err)
}
if result.Vision == nil || *result.Vision {
t.Fatalf("expected vision switchable=false, got %#v", result.Vision)
}
if !reflect.DeepEqual(result.Models, []string{"vision"}) {
t.Fatalf("unexpected models: %#v", result.Models)
}
}
func TestGetAccountCapabilitiesMarksVisionUnavailableWhenAbsent(t *testing.T) {
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
return probeResponse(http.StatusOK, `{
"code":0,
"data":{"biz_code":0,"biz_data":{"settings":{"model_configs":{"value":[
{"model_type":"chat","switchable":true},
{"model_type":"expert","switchable":true}
]}}}}
}`, req), nil
})}
result, err := c.GetAccountCapabilities(context.Background(), "tok", "u@example.com")
if err != nil {
t.Fatalf("GetAccountCapabilities error: %v", err)
}
if result.Vision == nil || *result.Vision {
t.Fatalf("expected vision unavailable, got %#v", result.Vision)
}
if !reflect.DeepEqual(result.Models, []string{"chat", "expert"}) {
t.Fatalf("unexpected models: %#v", result.Models)
}
}
func TestGetAccountMuteStatusParsesNestedChatMute(t *testing.T) {
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
return probeResponse(http.StatusOK, `{
"code":0,
"data":{"biz_code":0,"biz_data":{"chat":{"is_muted":1,"mute_until":1234.5}}}
}`, req), nil
})}
result, err := c.GetAccountMuteStatus(context.Background(), "tok")
if err != nil {
t.Fatalf("GetAccountMuteStatus error: %v", err)
}
if !result.Muted || result.MuteUntil != 1234.5 {
t.Fatalf("unexpected mute status: %#v", result)
}
if result.Source != "users_current" {
t.Fatalf("unexpected source: %q", result.Source)
}
}
func TestGetAccountMuteStatusReturnsMutedBizFailure(t *testing.T) {
c := &Client{regular: doerFunc(func(req *http.Request) (*http.Response, error) {
return probeResponse(http.StatusOK, `{
"code":0,
"data":{"biz_code":5,"biz_msg":"muted","biz_data":{"chat":{"is_muted":true,"mute_until":"4567"}}}
}`, req), nil
})}
result, err := c.GetAccountMuteStatus(context.Background(), "tok")
if err != nil {
t.Fatalf("GetAccountMuteStatus error: %v", err)
}
if !result.Muted || result.MuteUntil != 4567 {
t.Fatalf("unexpected mute status: %#v", result)
}
if result.BizCode != 5 {
t.Fatalf("expected biz_code 5, got %d", result.BizCode)
}
}
func probeResponse(status int, body string, req *http.Request) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}
}
+21 -3
View File
@@ -26,14 +26,32 @@ func extractMuteInfo(resp map[string]any) muteInfo {
_, bizCode, msg, bizMsg := extractResponseStatus(resp)
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
isMuted := intFrom(bizData["is_muted"]) == 1
chat, _ := bizData["chat"].(map[string]any)
isMuted := muteFlagFrom(bizData["is_muted"]) || muteFlagFrom(chat["is_muted"])
muteUntil := floatFrom(bizData["mute_until"])
if muteUntil == 0 {
muteUntil = floatFrom(chat["mute_until"])
}
combined := strings.ToLower(strings.TrimSpace(msg) + " " + strings.TrimSpace(bizMsg))
if bizCode == 5 || isMuted || strings.Contains(combined, "muted") {
return muteInfo{Muted: true, Until: floatFrom(bizData["mute_until"])}
if bizCode == 5 || isMuted || strings.Contains(combined, "muted") || strings.Contains(combined, "禁言") {
return muteInfo{Muted: true, Until: muteUntil}
}
return muteInfo{}
}
func muteFlagFrom(v any) bool {
switch x := v.(type) {
case bool:
return x
case string:
switch strings.ToLower(strings.TrimSpace(x)) {
case "true", "1", "yes":
return true
}
}
return intFrom(v) == 1
}
func floatFrom(v any) float64 {
switch x := v.(type) {
case float64:
+2
View File
@@ -12,6 +12,8 @@ import (
const (
DeepSeekHost = "chat.deepseek.com"
DeepSeekLoginURL = "https://chat.deepseek.com/api/v0/users/login"
DeepSeekCurrentUserURL = "https://chat.deepseek.com/api/v0/users/current"
DeepSeekClientSettingsURL = "https://chat.deepseek.com/api/v0/client/settings"
DeepSeekCreateSessionURL = "https://chat.deepseek.com/api/v0/chat_session/create"
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekHifLeimURL = "https://hif-leim.deepseek.com/query"
@@ -60,7 +60,7 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
for _, acc := range accounts[start:end] {
testStatus, _ := h.Store.AccountTestStatus(acc.Identifier())
token := strings.TrimSpace(acc.Token)
items = append(items, map[string]any{
item := map[string]any{
"identifier": acc.Identifier(),
"name": acc.Name,
"remark": acc.Remark,
@@ -75,7 +75,18 @@ func (h *Handler) listAccounts(w http.ResponseWriter, r *http.Request) {
"muted": acc.IsMuted(now),
"mute_until": acc.MuteUntil,
"last_used": acc.LastUsed,
})
}
if probe, ok := h.Store.AccountRuntimeProbe(acc.Identifier()); ok {
item["runtime_probe"] = runtimeProbeResponseMap(probe)
item["token_valid"] = boolPtrValue(probe.TokenValid)
item["token_checked_at"] = probe.CheckedAt
item["token_status"] = tokenStatusResponseMap(probe)
item["capabilities"] = capabilityProbeResponseMap(probe.Capabilities)
if probe.CapabilityError != "" {
item["capability_error"] = probe.CapabilityError
}
}
items = append(items, item)
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": total, "page": page, "page_size": pageSize, "total_pages": totalPages})
}
@@ -0,0 +1,60 @@
package accounts
import (
"slices"
"ds2api/internal/config"
)
func runtimeProbeResponseMap(probe config.AccountRuntimeProbe) map[string]any {
return map[string]any{
"token_valid": boolPtrValue(probe.TokenValid),
"token_http_status": probe.TokenHTTPStatus,
"token_code": probe.TokenCode,
"token_biz_code": probe.TokenBizCode,
"token_message": probe.TokenMessage,
"capabilities": capabilityProbeResponseMap(probe.Capabilities),
"capability_error": probe.CapabilityError,
"checked_at": probe.CheckedAt,
}
}
func tokenStatusResponseMap(probe config.AccountRuntimeProbe) map[string]any {
return map[string]any{
"valid": boolPtrValue(probe.TokenValid),
"http_status": probe.TokenHTTPStatus,
"code": probe.TokenCode,
"biz_code": probe.TokenBizCode,
"message": probe.TokenMessage,
"checked_at": probe.CheckedAt,
}
}
func capabilityProbeResponseMap(cap config.AccountCapabilityProbe) map[string]any {
return map[string]any{
"vision": boolPtrValue(cap.Vision),
"models": slices.Clone(cap.Models),
"checked_at": cap.CheckedAt,
"source": cap.Source,
}
}
func runtimeProbeHasData(probe config.AccountRuntimeProbe) bool {
return probe.TokenValid != nil ||
probe.CheckedAt != 0 ||
probe.Capabilities.Vision != nil ||
len(probe.Capabilities.Models) > 0 ||
probe.Capabilities.CheckedAt != 0 ||
probe.CapabilityError != ""
}
func boolPtrValue(v *bool) any {
if v == nil {
return nil
}
return *v
}
func boolPtr(v bool) *bool {
return &v
}
@@ -0,0 +1,107 @@
package accounts
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
)
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
message, _ := req["message"].(string)
apiKey, _ := req["api_key"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
if message == "" {
message = "你好"
}
if apiKey == "" {
keys := h.Store.Snapshot().Keys
if len(keys) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
return
}
apiKey = keys[0]
}
host := r.Host
scheme := "http"
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
b, _ := json.Marshal(payload)
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer func() {
if err := resp.Body.Close(); err != nil {
config.Logger.Warn("[admin] close self-test response body failed", "error", err)
}
}()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
_ = json.Unmarshal(body, &parsed)
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
proxyCtx := authn.WithAuth(r.Context(), authCtx)
token, err := h.DS.Login(proxyCtx, acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
if err != nil {
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}
@@ -1,17 +1,14 @@
package accounts
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
"ds2api/internal/prompt"
"ds2api/internal/promptcompat"
@@ -39,22 +36,14 @@ func (h *Handler) testSingleAccount(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
message, _ := req["message"].(string)
result := h.testAccount(r.Context(), acc, model, message)
result := h.testAccount(r.Context(), acc, accountTestOptionsFromRequest(req))
writeJSON(w, http.StatusOK, result)
}
func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
opts := accountTestOptionsFromRequest(req)
accounts := h.Store.Snapshot().Accounts
if len(accounts) == 0 {
writeJSON(w, http.StatusOK, map[string]any{"total": 0, "success": 0, "failed": 0, "results": []any{}})
@@ -64,7 +53,7 @@ func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
// Concurrent testing with a semaphore to limit parallelism.
const maxConcurrency = 5
results := runAccountTestsConcurrently(accounts, maxConcurrency, func(_ int, account config.Account) map[string]any {
return h.testAccount(r.Context(), account, model, "")
return h.testAccount(r.Context(), account, opts)
})
success := 0
@@ -76,6 +65,47 @@ func (h *Handler) testAllAccounts(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"total": len(accounts), "success": success, "failed": len(accounts) - success, "results": results})
}
type accountTestOptions struct {
Model string
Message string
Mode string
ProbeCapabilities bool
}
func accountTestOptionsFromRequest(req map[string]any) accountTestOptions {
model, _ := req["model"].(string)
message, _ := req["message"].(string)
mode, _ := req["mode"].(string)
return normalizeAccountTestOptions(accountTestOptions{
Model: model,
Message: message,
Mode: mode,
ProbeCapabilities: boolFromAny(req["probe_capabilities"]),
})
}
func normalizeAccountTestOptions(opts accountTestOptions) accountTestOptions {
opts.Model = strings.TrimSpace(opts.Model)
if opts.Model == "" {
opts.Model = "deepseek-v4-flash"
}
opts.Message = strings.TrimSpace(opts.Message)
opts.Mode = strings.ToLower(strings.TrimSpace(opts.Mode))
switch opts.Mode {
case "token", "session", "message":
default:
if opts.Message != "" {
opts.Mode = "message"
} else {
opts.Mode = "session"
}
}
if opts.Mode == "message" && opts.Message == "" {
opts.Message = "你好"
}
return opts
}
func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int, testFn func(int, config.Account) map[string]any) []map[string]any {
if maxConcurrency <= 0 {
maxConcurrency = 1
@@ -96,25 +126,36 @@ func runAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
return results
}
func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
func (h *Handler) testAccount(ctx context.Context, acc config.Account, opts accountTestOptions) map[string]any {
start := time.Now()
opts = normalizeAccountTestOptions(opts)
identifier := acc.Identifier()
runtimeProbe := config.AccountRuntimeProbe{}
result := map[string]any{
"account": identifier,
"success": false,
"response_time": 0,
"message": "",
"model": model,
"model": opts.Model,
"mode": opts.Mode,
"session_count": 0,
"config_writable": !h.Store.IsEnvBacked(),
}
defer func() {
status := "failed"
if ok, _ := result["success"].(bool); ok {
if success, _ := result["success"].(bool); success {
status = "ok"
}
_ = h.Store.UpdateAccountTestStatus(identifier, status)
if runtimeProbeHasData(runtimeProbe) {
_ = h.Store.UpdateAccountRuntimeProbe(identifier, runtimeProbe)
}
}()
if opts.Mode == "token" {
return h.testAccountTokenMode(ctx, acc, opts, result, &runtimeProbe, start)
}
token, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
@@ -124,8 +165,11 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
return result
}
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
proxyCtx := authn.WithAuth(ctx, authCtx)
acc.Token = token
proxyCtx, authCtx := accountProbeContext(ctx, acc, identifier, token)
if _, tokenProbeErr := h.attachTokenProbe(proxyCtx, token, result, &runtimeProbe); tokenProbeErr != nil {
result["token_probe_error"] = tokenProbeErr.Error()
}
sessionID, err := h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
newToken, loginErr := h.DS.Login(proxyCtx, acc)
@@ -134,11 +178,16 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
return result
}
token = newToken
acc.Token = token
authCtx.DeepSeekToken = token
authCtx.Account = acc
if err := h.Store.UpdateAccountToken(acc.Identifier(), token); err != nil {
result["message"] = "刷新 token 成功但写入运行时 token 失败: " + err.Error()
return result
}
if _, tokenProbeErr := h.attachTokenProbe(proxyCtx, token, result, &runtimeProbe); tokenProbeErr != nil {
result["token_probe_error"] = tokenProbeErr.Error()
}
sessionID, err = h.DS.CreateSession(proxyCtx, authCtx, 1)
if err != nil {
result["message"] = "创建会话失败: " + err.Error()
@@ -152,12 +201,17 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
result["session_count"] = sessionStats.FirstPageCount
}
if strings.TrimSpace(message) == "" {
if opts.ProbeCapabilities {
h.attachCapabilityProbe(proxyCtx, identifier, token, result, &runtimeProbe)
}
if opts.Mode != "message" {
result["success"] = true
result["message"] = "Token 刷新成功(登录与会话创建成功)"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
model := opts.Model
thinking, search, ok := config.GetModelConfig(model)
resolvedModel, resolved := config.ResolveModel(modelAliasSnapshotReader{
aliases: h.Store.Snapshot().ModelAliases,
@@ -176,7 +230,7 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
}
payload := promptcompat.StandardRequest{
ResolvedModel: model,
FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": message}}),
FinalPrompt: prompt.MessagesPrepare([]map[string]any{{"role": "user", "content": opts.Message}}),
Thinking: thinking,
Search: search,
}.CompletionPayload(sessionID)
@@ -203,95 +257,3 @@ func (h *Handler) testAccount(ctx context.Context, acc config.Account, model, me
}
return result
}
func (h *Handler) testAPI(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
model, _ := req["model"].(string)
message, _ := req["message"].(string)
apiKey, _ := req["api_key"].(string)
if model == "" {
model = "deepseek-v4-flash"
}
if message == "" {
message = "你好"
}
if apiKey == "" {
keys := h.Store.Snapshot().Keys
if len(keys) == 0 {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "没有可用的 API Key"})
return
}
apiKey = keys[0]
}
host := r.Host
scheme := "http"
if strings.Contains(strings.ToLower(host), "vercel") || strings.Contains(strings.ToLower(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
payload := map[string]any{"model": model, "messages": []map[string]any{{"role": "user", "content": message}}, "stream": false}
b, _ := json.Marshal(payload)
request, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s://%s/v1/chat/completions", scheme, host), bytes.NewReader(b))
request.Header.Set("Authorization", "Bearer "+apiKey)
request.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 60 * time.Second}).Do(request)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "error": err.Error()})
return
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusOK {
var parsed any
_ = json.Unmarshal(body, &parsed)
writeJSON(w, http.StatusOK, map[string]any{"success": true, "status_code": resp.StatusCode, "response": parsed})
return
}
writeJSON(w, http.StatusOK, map[string]any{"success": false, "status_code": resp.StatusCode, "response": string(body)})
}
func (h *Handler) deleteAllSessions(w http.ResponseWriter, r *http.Request) {
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
identifier, _ := req["identifier"].(string)
if strings.TrimSpace(identifier) == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"detail": "需要账号标识(identifier / email / mobile)"})
return
}
acc, ok := findAccountByIdentifier(h.Store, identifier)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]any{"detail": "账号不存在"})
return
}
// 每次先登录刷新一次 token,避免使用过期 token。
authCtx := &authn.RequestAuth{UseConfigToken: false, AccountID: acc.Identifier(), Account: acc}
proxyCtx := authn.WithAuth(r.Context(), authCtx)
token, err := h.DS.Login(proxyCtx, acc)
if err != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "登录失败: " + err.Error()})
return
}
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
// 删除所有会话
err = h.DS.DeleteAllSessionsForToken(proxyCtx, token)
if err != nil {
// token 可能过期,尝试重新登录并重试一次
newToken, loginErr := h.DS.Login(proxyCtx, acc)
if loginErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + err.Error()})
return
}
token = newToken
_ = h.Store.UpdateAccountToken(acc.Identifier(), token)
authCtx.DeepSeekToken = token
if retryErr := h.DS.DeleteAllSessionsForToken(proxyCtx, token); retryErr != nil {
writeJSON(w, http.StatusOK, map[string]any{"success": false, "message": "删除失败: " + retryErr.Error()})
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"success": true, "message": "删除成功"})
}
@@ -0,0 +1,148 @@
package accounts
import (
"context"
"fmt"
"slices"
"strings"
"time"
authn "ds2api/internal/auth"
"ds2api/internal/config"
dsclient "ds2api/internal/deepseek/client"
)
func (h *Handler) testAccountTokenMode(ctx context.Context, acc config.Account, opts accountTestOptions, result map[string]any, runtimeProbe *config.AccountRuntimeProbe, start time.Time) map[string]any {
identifier := acc.Identifier()
token := strings.TrimSpace(acc.Token)
if token != "" {
proxyCtx, _ := accountProbeContext(ctx, acc, identifier, token)
tokenResult, err := h.attachTokenProbe(proxyCtx, token, result, runtimeProbe)
if err != nil {
result["message"] = "Token 验证失败: " + err.Error()
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
if tokenResult.Valid {
if opts.ProbeCapabilities {
h.attachCapabilityProbe(proxyCtx, identifier, token, result, runtimeProbe)
}
result["success"] = true
result["message"] = "Token 验证成功"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
if strings.TrimSpace(acc.Password) == "" {
result["message"] = "Token 无效: " + tokenFailureMessage(tokenResult)
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
}
if strings.TrimSpace(acc.Password) == "" {
if token == "" {
applyTokenProbeResult(result, runtimeProbe, &dsclient.TokenValidationResult{Valid: false, Message: "empty token"})
}
result["message"] = "没有可验证的 token,且账号缺少密码,无法刷新"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
newToken, err := h.DS.Login(ctx, acc)
if err != nil {
result["message"] = "登录失败: " + err.Error()
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
if err := h.Store.UpdateAccountToken(identifier, newToken); err != nil {
result["message"] = "登录成功但写入运行时 token 失败: " + err.Error()
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
acc.Token = newToken
proxyCtx, _ := accountProbeContext(ctx, acc, identifier, newToken)
tokenResult, err := h.attachTokenProbe(proxyCtx, newToken, result, runtimeProbe)
if err != nil {
result["message"] = "Token 验证失败: " + err.Error()
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
if !tokenResult.Valid {
result["message"] = "Token 无效: " + tokenFailureMessage(tokenResult)
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
if opts.ProbeCapabilities {
h.attachCapabilityProbe(proxyCtx, identifier, newToken, result, runtimeProbe)
}
result["success"] = true
result["message"] = "Token 刷新并验证成功"
result["response_time"] = int(time.Since(start).Milliseconds())
return result
}
func accountProbeContext(ctx context.Context, acc config.Account, identifier, token string) (context.Context, *authn.RequestAuth) {
authCtx := &authn.RequestAuth{UseConfigToken: false, DeepSeekToken: token, AccountID: identifier, Account: acc}
return authn.WithAuth(ctx, authCtx), authCtx
}
func (h *Handler) attachTokenProbe(ctx context.Context, token string, result map[string]any, runtimeProbe *config.AccountRuntimeProbe) (*dsclient.TokenValidationResult, error) {
tokenResult, err := h.DS.ValidateToken(ctx, token)
if err != nil {
return nil, err
}
applyTokenProbeResult(result, runtimeProbe, tokenResult)
return tokenResult, nil
}
func applyTokenProbeResult(result map[string]any, runtimeProbe *config.AccountRuntimeProbe, tokenResult *dsclient.TokenValidationResult) {
if tokenResult == nil {
return
}
runtimeProbe.TokenValid = boolPtr(tokenResult.Valid)
runtimeProbe.TokenHTTPStatus = tokenResult.HTTPStatus
runtimeProbe.TokenCode = tokenResult.Code
runtimeProbe.TokenBizCode = tokenResult.BizCode
runtimeProbe.TokenMessage = tokenResult.Message
runtimeProbe.CheckedAt = time.Now().Unix()
result["token_valid"] = tokenResult.Valid
result["token_status"] = tokenStatusResponseMap(*runtimeProbe)
}
func (h *Handler) attachCapabilityProbe(ctx context.Context, identifier, token string, result map[string]any, runtimeProbe *config.AccountRuntimeProbe) {
capabilities, err := h.DS.GetAccountCapabilities(ctx, token, identifier)
if err != nil {
runtimeProbe.CapabilityError = err.Error()
result["capability_error"] = err.Error()
return
}
capProbe := config.AccountCapabilityProbe{
Vision: cloneBoolPtr(capabilities.Vision),
Models: slices.Clone(capabilities.Models),
CheckedAt: capabilities.CheckedAt,
Source: capabilities.Source,
}
runtimeProbe.Capabilities = capProbe
result["capabilities"] = capabilityProbeResponseMap(capProbe)
}
func cloneBoolPtr(v *bool) *bool {
if v == nil {
return nil
}
out := *v
return &out
}
func tokenFailureMessage(result *dsclient.TokenValidationResult) string {
if result == nil {
return "unknown failure"
}
if strings.TrimSpace(result.Message) != "" {
return result.Message
}
if result.HTTPStatus != 0 {
return fmt.Sprintf("HTTP %d", result.HTTPStatus)
}
return "invalid token"
}
@@ -22,6 +22,8 @@ type testingDSMock struct {
getPowCalls int
callCompletionCalls int
deleteAllSessionsCalls int
validateTokenCalls int
capabilityCalls int
deleteAllSessionsError error
deleteAllSessionsErrorOnce bool
}
@@ -62,6 +64,17 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
return &dsclient.SessionStats{Success: true}, nil
}
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
m.validateTokenCalls++
return &dsclient.TokenValidationResult{Valid: strings.TrimSpace(token) != "", HTTPStatus: http.StatusOK}, nil
}
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
m.capabilityCalls++
vision := true
return &dsclient.AccountCapabilities{Vision: &vision, Models: []string{"chat", "vision"}, CheckedAt: 123, Source: "client_settings"}, nil
}
func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
@@ -72,7 +85,7 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-flash", "")
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-flash", Mode: "session"})
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
@@ -87,6 +100,9 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
if ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
t.Fatalf("expected no completion flow calls, got getPow=%d callCompletion=%d", ds.getPowCalls, ds.callCompletionCalls)
}
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 0 {
t.Fatalf("unexpected probe calls: validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
}
updated, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected updated account")
@@ -100,6 +116,77 @@ func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
}
}
func TestTestAccount_ProbeCapabilitiesStoresRuntimeProbe(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
ds := &testingDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, accountTestOptions{
Model: "deepseek-v4-flash",
Mode: "session",
ProbeCapabilities: true,
})
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 1 {
t.Fatalf("expected token and capability probe, got validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
}
probe, ok := store.AccountRuntimeProbe("batch@example.com")
if !ok {
t.Fatal("expected runtime probe cached")
}
if probe.TokenValid == nil || !*probe.TokenValid {
t.Fatalf("expected token valid probe, got %#v", probe)
}
if probe.Capabilities.Vision == nil || !*probe.Capabilities.Vision {
t.Fatalf("expected vision capability, got %#v", probe.Capabilities)
}
if !containsString(probe.Capabilities.Models, "vision") {
t.Fatalf("expected vision model in capability list, got %#v", probe.Capabilities.Models)
}
}
func TestTestAccount_TokenModeOnlyValidatesExistingToken(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
store := config.LoadStore()
if err := store.UpdateAccountToken("batch@example.com", "seed-token"); err != nil {
t.Fatalf("seed token: %v", err)
}
ds := &testingDSMock{}
h := &Handler{Store: store, DS: ds}
acc, ok := store.FindAccount("batch@example.com")
if !ok {
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, accountTestOptions{
Model: "deepseek-v4-flash",
Mode: "token",
ProbeCapabilities: true,
})
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
}
if ds.loginCalls != 0 || ds.createSessionCalls != 0 || ds.getPowCalls != 0 || ds.callCompletionCalls != 0 {
t.Fatalf("token mode should not call login/session/completion: login=%d session=%d pow=%d completion=%d", ds.loginCalls, ds.createSessionCalls, ds.getPowCalls, ds.callCompletionCalls)
}
if ds.validateTokenCalls != 1 || ds.capabilityCalls != 1 {
t.Fatalf("expected token and capability probe, got validate=%d capability=%d", ds.validateTokenCalls, ds.capabilityCalls)
}
msg, _ := result["message"].(string)
if !strings.Contains(msg, "Token 验证成功") {
t.Fatalf("expected token validation success message, got %q", msg)
}
}
func TestDeleteAllSessions_RetryWithReloginOnDeleteFailure(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"expired-token"}]}`)
store := config.LoadStore()
@@ -167,6 +254,14 @@ func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ s
return &dsclient.SessionStats{Success: true}, nil
}
func (m *completionPayloadDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
return &dsclient.TokenValidationResult{Valid: strings.TrimSpace(token) != "", HTTPStatus: http.StatusOK}, nil
}
func (m *completionPayloadDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
}
func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) {
t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
store := config.LoadStore()
@@ -177,7 +272,7 @@ func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T)
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-pro", "hello")
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-pro", Message: "hello", Mode: "message"})
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
@@ -200,7 +295,7 @@ func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T)
t.Fatal("expected test account")
}
result := h.testAccount(context.Background(), acc, "deepseek-v4-vision", "hello")
result := h.testAccount(context.Background(), acc, accountTestOptions{Model: "deepseek-v4-vision", Message: "hello", Mode: "message"})
if ok, _ := result["success"].(bool); !ok {
t.Fatalf("expected success=true, got %#v", result)
@@ -209,3 +304,12 @@ func TestTestAccount_MessageModeUsesVisionModelTypeForVisionModel(t *testing.T)
t.Fatalf("expected model_type vision, got %#v", got)
}
}
func containsString(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
+6 -1
View File
@@ -3,6 +3,7 @@ package accounts
import (
"context"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
@@ -26,7 +27,11 @@ func RunAccountTestsConcurrently(accounts []config.Account, maxConcurrency int,
}
func (h *Handler) TestAccount(ctx context.Context, acc config.Account, model, message string) map[string]any {
return h.testAccount(ctx, acc, model, message)
opts := accountTestOptions{Model: model, Message: message}
if strings.TrimSpace(message) != "" {
opts.Mode = "message"
}
return h.testAccount(ctx, acc, opts)
}
func (h *Handler) ListAccounts(w http.ResponseWriter, r *http.Request) { h.listAccounts(w, r) }
@@ -115,6 +115,9 @@ func (h *Handler) configImport(w http.ResponseWriter, r *http.Request) {
if incoming.Runtime.TokenRefreshIntervalHours > 0 {
next.Runtime.TokenRefreshIntervalHours = incoming.Runtime.TokenRefreshIntervalHours
}
if incoming.Runtime.AccountMuteScanIntervalSeconds > 0 {
next.Runtime.AccountMuteScanIntervalSeconds = incoming.Runtime.AccountMuteScanIntervalSeconds
}
}
normalizeSettingsConfig(&next)
@@ -35,6 +35,12 @@ func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) e
func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
return &dsclient.SessionStats{}, nil
}
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
return &dsclient.TokenValidationResult{Valid: token != "", HTTPStatus: http.StatusOK}, nil
}
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
}
func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
t.Helper()
+4
View File
@@ -18,6 +18,8 @@ type ConfigStore interface {
UpdateAccountToken(identifier, token string) error
UpdateAccountTestStatus(identifier, status string) error
AccountTestStatus(identifier string) (string, bool)
UpdateAccountRuntimeProbe(identifier string, probe config.AccountRuntimeProbe) error
AccountRuntimeProbe(identifier string) (config.AccountRuntimeProbe, bool)
Update(mutator func(*config.Config) error) error
ExportJSONAndBase64() (string, string, error)
IsEnvBacked() bool
@@ -68,6 +70,8 @@ type DeepSeekCaller interface {
CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
DeleteAllSessionsForToken(ctx context.Context, token string) error
ValidateToken(ctx context.Context, token string) (*dsclient.TokenValidationResult, error)
GetAccountCapabilities(ctx context.Context, token string, accountID string) (*dsclient.AccountCapabilities, error)
}
var _ ConfigStore = (*config.Store)(nil)
@@ -82,6 +82,14 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
return &dsclient.SessionStats{}, nil
}
func (m *testingDSMock) ValidateToken(_ context.Context, token string) (*dsclient.TokenValidationResult, error) {
return &dsclient.TokenValidationResult{Valid: token != "", HTTPStatus: http.StatusOK}, nil
}
func (m *testingDSMock) GetAccountCapabilities(_ context.Context, _ string, _ string) (*dsclient.AccountCapabilities, error) {
return &dsclient.AccountCapabilities{Source: "client_settings"}, nil
}
func (h *Handler) configHandler() *adminconfig.Handler {
return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
}
@@ -104,10 +104,6 @@ func upstreamEmptyOutputDetail(contentFilter bool, text, thinking string) (int,
return shared.UpstreamEmptyOutputDetail(contentFilter, text, thinking)
}
func writeUpstreamEmptyOutputError(w http.ResponseWriter, text, thinking string, contentFilter bool) bool {
return shared.WriteUpstreamEmptyOutputError(w, text, thinking, contentFilter)
}
func shouldPenalizeUpstreamEmptyOutput(status int, code string) bool {
return shared.ShouldPenalizeUpstreamEmptyOutput(status, code)
}
@@ -119,6 +119,10 @@ async function handleVercelStream(req, res, rawBody, payload) {
if (!completionRes.ok || !completionRes.body) {
const detail = completionRes.body ? await completionRes.text() : '';
const status = completionRes.ok ? 500 : completionRes.status || 500;
const penalty = completionPenaltyForStatus(status);
if (penalty) {
await releaseLease(penalty);
}
writeOpenAIError(res, status, detail);
return;
}
@@ -396,6 +400,20 @@ function upstreamEmptyOutputDetail(contentFilter, _text, thinking) {
};
}
function completionPenaltyForStatus(status) {
const value = Number(status) || 0;
if (value === 429) {
return 'http_429';
}
if (value === 403) {
return 'http_403';
}
if (value >= 500 && value <= 599) {
return 'http_5xx';
}
return '';
}
function sendFailedChunk(res, status, message, code) {
res.write(`data: ${JSON.stringify({
status_code: status,
@@ -416,4 +434,5 @@ function sendFailedChunk(res, status, message, code) {
module.exports = {
handleVercelStream,
completionPenaltyForStatus,
};
+15 -1
View File
@@ -15,6 +15,7 @@ import (
"github.com/go-chi/chi/v5/middleware"
"ds2api/internal/account"
"ds2api/internal/account/mutescan"
"ds2api/internal/auth"
"ds2api/internal/chathistory"
"ds2api/internal/config"
@@ -35,6 +36,7 @@ type App struct {
Pool *account.Pool
Resolver *auth.Resolver
DS *dsclient.Client
MuteScan *mutescan.Scanner
Router http.Handler
}
@@ -54,6 +56,11 @@ func NewApp() (*App, error) {
} else {
config.Logger.Info("[PoW] pure Go solver ready")
}
var muteScan *mutescan.Scanner
if !config.IsVercel() {
interval := time.Duration(store.RuntimeAccountMuteScanIntervalSeconds()) * time.Second
muteScan = mutescan.New(store, dsClient, pool, interval)
}
chatHistoryStore := chathistory.New(config.ChatHistoryPath())
if err := chatHistoryStore.Err(); err != nil {
config.Logger.Warn("[chat_history] unavailable", "path", chatHistoryStore.Path(), "error", err)
@@ -111,7 +118,14 @@ func NewApp() (*App, error) {
http.NotFound(w, req)
})
return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, Router: r}, nil
return &App{Store: store, Pool: pool, Resolver: resolver, DS: dsClient, MuteScan: muteScan, Router: r}, nil
}
func (a *App) StartBackground(ctx context.Context) {
if a == nil || a.MuteScan == nil {
return
}
a.MuteScan.Start(ctx)
}
func timeout(d time.Duration) func(http.Handler) http.Handler {
+21 -5
View File
@@ -5,7 +5,7 @@ const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const handler = require('../../api/chat-stream.js');
const { handleVercelStream } = require('../../internal/js/chat-stream/vercel_stream.js');
const { handleVercelStream, completionPenaltyForStatus } = require('../../internal/js/chat-stream/vercel_stream.js');
const {
createToolSieveState,
processToolSieveChunk,
@@ -126,7 +126,7 @@ function releasePayload(fetchCalls) {
return JSON.parse(Buffer.from(releaseCall.options.body).toString('utf8'));
}
async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
async function runMockVercelStream(upstreamLines, prepareOverrides = {}, options = {}) {
const originalFetch = global.fetch;
const fetchURLs = [];
const fetchCalls = [];
@@ -144,10 +144,10 @@ async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
payload: { prompt: 'hello' },
...prepareOverrides,
};
global.fetch = async (url, options = {}) => {
global.fetch = async (url, fetchOptions = {}) => {
const textURL = String(url);
fetchURLs.push(textURL);
fetchCalls.push({ url: textURL, options });
fetchCalls.push({ url: textURL, options: fetchOptions });
if (textURL.includes('__stream_prepare=1')) {
return jsonResponse(prepareBody);
}
@@ -157,7 +157,7 @@ async function runMockVercelStream(upstreamLines, prepareOverrides = {}) {
if (textURL.includes('hif-leim.deepseek.com/query')) {
return jsonResponse({ code: 0, data: { biz_code: 0, biz_data: { value: 'node-hif-value' } } });
}
return sseResponse(upstreamLines);
return options.upstreamResponse || sseResponse(upstreamLines);
};
try {
const req = new MockStreamRequest();
@@ -218,6 +218,22 @@ test('vercel stream adds hif-leim header to DeepSeek completion request', async
assert.equal(completionCall.options.headers['x-hif-leim'], 'node-hif-value');
});
test('completionPenaltyForStatus maps retryable upstream statuses', () => {
assert.equal(completionPenaltyForStatus(429), 'http_429');
assert.equal(completionPenaltyForStatus(403), 'http_403');
assert.equal(completionPenaltyForStatus(503), 'http_5xx');
assert.equal(completionPenaltyForStatus(400), '');
assert.equal(completionPenaltyForStatus(0), '');
});
test('vercel stream penalizes lease on upstream 503 response', async () => {
const { res, fetchCalls } = await runMockVercelStream([], {}, {
upstreamResponse: new Response('busy', { status: 503 }),
});
assert.equal(res.statusCode, 503);
assert.equal(releasePayload(fetchCalls).penalty, 'http_5xx');
});
test('resolveToolcallPolicy defaults to feature-match + early emit when prepare flags missing', () => {
const policy = resolveToolcallPolicy(
{},
@@ -142,6 +142,7 @@ export default function AccountManagerContainer({ config, onRefresh, onMessage,
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
envBacked={Boolean(config?.env_backed)}
queueStatus={queueStatus}
/>
<AddKeyModal
+79 -4
View File
@@ -1,7 +1,20 @@
import { useState } from 'react'
import { ChevronLeft, ChevronRight, Check, Copy, Pencil, Play, Plus, Trash2, FolderX } from 'lucide-react'
import { AlertTriangle, ChevronLeft, ChevronRight, Check, Copy, Eye, Loader2, Pencil, Play, Plus, ShieldCheck, Timer, Trash2, FolderX } from 'lucide-react'
import clsx from 'clsx'
function normalizeID(value) {
return String(value || '').trim()
}
function queueAccountID(item) {
return normalizeID(item?.id || item?.account || item?.identifier || item?.email || item?.mobile)
}
function numberValue(value) {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
export default function AccountsTable({
t,
accounts,
@@ -31,6 +44,7 @@ export default function AccountsTable({
searchQuery,
onSearchChange,
envBacked = false,
queueStatus,
}) {
const [copiedId, setCopiedId] = useState(null)
@@ -40,6 +54,13 @@ export default function AccountsTable({
setTimeout(() => setCopiedId(null), 1500)
})
}
const queueHealthByID = new Map(
(queueStatus?.accounts || [])
.map(item => [queueAccountID(item), item])
.filter(([key]) => key)
)
return (
<div className="bg-card border border-border rounded-xl overflow-hidden shadow-sm">
<div className="p-6 border-b border-border flex flex-col md:flex-row md:items-center justify-between gap-4">
@@ -109,11 +130,20 @@ export default function AccountsTable({
const assignedProxy = proxies.find(proxy => proxy.id === acc.proxy_id)
const runtimeUnknown = envBacked && !acc.test_status
const isActive = acc.test_status === 'ok' || acc.has_token
const health = queueHealthByID.get(id) || {}
const capabilities = acc.capabilities || acc.runtime_probe?.capabilities || {}
const tokenValid = acc.token_valid ?? acc.runtime_probe?.token_valid ?? acc.token_status?.valid
const visionCapability = capabilities.vision
const cooldownRemaining = numberValue(health.cooldown_remaining)
const failureCount = numberValue(health.failure_count)
const lastFailureKind = String(health.last_failure_kind || '').trim()
const muted = Boolean(acc.muted || health.muted)
return (
<div key={i} className="p-4 flex flex-col md:flex-row md:items-center justify-between gap-4 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3 min-w-0">
<div className={clsx(
"w-2 h-2 rounded-full shrink-0",
muted ? "bg-slate-400" :
acc.test_status === 'failed' ? "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" :
isActive ? "bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]" :
runtimeUnknown ? "bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.5)]" : "bg-amber-500"
@@ -133,7 +163,7 @@ export default function AccountsTable({
{acc.remark && (
<div className="text-xs text-muted-foreground truncate mt-0.5">{acc.remark}</div>
)}
<div className="flex items-center gap-2 text-xs text-muted-foreground mt-0.5">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground mt-0.5 max-w-full">
<span>{acc.test_status === 'failed' ? t('accountManager.testStatusFailed') : isActive ? t('accountManager.sessionActive') : runtimeUnknown ? t('accountManager.runtimeStatusUnknown') : t('accountManager.reauthRequired')}</span>
{acc.token_preview && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
@@ -164,6 +194,50 @@ export default function AccountsTable({
{t('accountManager.proxyBadge', { name: assignedProxy ? (assignedProxy.name || `${assignedProxy.host}:${assignedProxy.port}`) : acc.proxy_id })}
</span>
)}
{(tokenValid === true || tokenValid === false || acc.token_checked_at) && (
<span className={clsx(
"inline-flex items-center gap-1 font-mono px-1.5 py-0.5 rounded text-[10px]",
tokenValid === false ? "bg-red-500/10 text-red-500" :
tokenValid === true ? "bg-emerald-500/10 text-emerald-500" :
"bg-muted text-muted-foreground"
)}>
<ShieldCheck className="w-3 h-3" />
{tokenValid === false ? t('accountManager.tokenInvalid') : tokenValid === true ? t('accountManager.tokenValid') : t('accountManager.tokenUnknown')}
</span>
)}
{(visionCapability === true || visionCapability === false || capabilities.checked_at) && (
<span className={clsx(
"inline-flex items-center gap-1 font-mono px-1.5 py-0.5 rounded text-[10px]",
visionCapability === true ? "bg-sky-500/10 text-sky-500" :
visionCapability === false ? "bg-muted text-muted-foreground" :
"bg-muted text-muted-foreground"
)}>
<Eye className="w-3 h-3" />
{visionCapability === false ? t('accountManager.visionUnavailable') : visionCapability === true ? t('accountManager.visionAvailable') : t('accountManager.visionUnknown')}
</span>
)}
{cooldownRemaining > 0 && (
<span className="inline-flex items-center gap-1 font-mono bg-orange-500/10 text-orange-500 px-1.5 py-0.5 rounded text-[10px]">
<Timer className="w-3 h-3" />
{t('accountManager.cooldownSeconds', { seconds: cooldownRemaining })}
</span>
)}
{failureCount > 0 && (
<span className="inline-flex items-center gap-1 font-mono bg-red-500/10 text-red-500 px-1.5 py-0.5 rounded text-[10px]">
<AlertTriangle className="w-3 h-3" />
{t('accountManager.failureCount', { count: failureCount })}
</span>
)}
{lastFailureKind && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{t('accountManager.lastFailureKind', { kind: lastFailureKind })}
</span>
)}
{muted && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
{t('accountManager.muted')}
</span>
)}
</div>
</div>
</div>
@@ -192,9 +266,10 @@ export default function AccountsTable({
<button
onClick={() => onTestAccount(id)}
disabled={testing[id]}
className="px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
className="inline-flex items-center gap-1 px-2 lg:px-3 py-1 lg:py-1.5 text-[10px] lg:text-xs font-medium border border-border rounded-md hover:bg-secondary transition-colors disabled:opacity-50"
>
{testing[id] ? t('actions.testing') : t('actions.test')}
{testing[id] ? <Loader2 className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
<span>{testing[id] ? t('actions.testing') : t('actions.test')}</span>
</button>
<button
onClick={() => onDeleteAccount(id)}
+38
View File
@@ -1,5 +1,19 @@
import { CheckCircle2, Server, ShieldCheck } from 'lucide-react'
function formatMetric(value) {
if (value === undefined || value === null || value === '') return '-'
return value
}
function MetricRow({ label, value }) {
return (
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span className="truncate">{label}</span>
<span className="font-mono text-foreground">{formatMetric(value)}</span>
</div>
)
}
export default function QueueCards({ queueStatus, t }) {
if (!queueStatus) {
return null
@@ -16,6 +30,10 @@ export default function QueueCards({ queueStatus, t }) {
<span className="text-3xl font-bold text-foreground">{queueStatus.available}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
<MetricRow
label={t('accountManager.recommendedConcurrency')}
value={queueStatus.recommended_concurrency}
/>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
@@ -26,6 +44,16 @@ export default function QueueCards({ queueStatus, t }) {
<span className="text-3xl font-bold text-foreground">{queueStatus.in_use}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.threadsUnit')}</span>
</div>
<div>
<MetricRow
label={t('accountManager.waiting')}
value={queueStatus.waiting}
/>
<MetricRow
label={t('accountManager.queueLimit')}
value={queueStatus.max_queue_size}
/>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-4 flex flex-col justify-between shadow-sm relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
@@ -36,6 +64,16 @@ export default function QueueCards({ queueStatus, t }) {
<span className="text-3xl font-bold text-foreground">{queueStatus.total}</span>
<span className="text-xs text-muted-foreground">{t('accountManager.accountsUnit')}</span>
</div>
<div>
<MetricRow
label={t('accountManager.healthCheck')}
value={queueStatus.health_enabled ? t('accountManager.enabled') : t('accountManager.disabled')}
/>
<MetricRow
label={t('accountManager.globalLimit')}
value={queueStatus.global_max_inflight}
/>
</div>
</div>
</div>
)
@@ -218,7 +218,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: accountID }),
body: JSON.stringify({ identifier: accountID, mode: 'session', probe_capabilities: true }),
})
const data = await res.json()
@@ -264,7 +264,7 @@ export function useAccountActions({ apiFetch, t, onMessage, onRefresh, config, f
const res = await apiFetch('/admin/accounts/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier: id }),
body: JSON.stringify({ identifier: id, mode: 'session', probe_capabilities: true }),
})
const data = await res.json()
results.push({ id, success: data.success, message: data.message, time: data.response_time })
+25 -8
View File
@@ -57,8 +57,8 @@
"delete": "Delete",
"copy": "Copy",
"generate": "Generate",
"test": "Refresh token",
"testing": "Refreshing...",
"test": "Check account",
"testing": "Checking...",
"loading": "Loading..."
},
"messages": {
@@ -103,14 +103,21 @@
"deleteKeyConfirm": "Are you sure you want to delete this API key?",
"deleteAccountConfirm": "Are you sure you want to delete this account?",
"invalidIdentifier": "Invalid account identifier. Operation aborted.",
"testAllConfirm": "Refresh all account tokens and verify login?",
"testAllCompleted": "Completed: {success}/{total} refreshed",
"testFailed": "Test failed: {error}",
"testAllConfirm": "Check tokens, sessions, and model capabilities for all accounts?",
"testAllCompleted": "Completed: {success}/{total} checks passed",
"testFailed": "Check failed: {error}",
"available": "Available",
"inUse": "In use",
"totalPool": "Total pool",
"accountsUnit": "accounts",
"threadsUnit": "threads",
"recommendedConcurrency": "Recommended concurrency",
"waiting": "Waiting",
"queueLimit": "Queue limit",
"healthCheck": "Health check",
"globalLimit": "Global limit",
"enabled": "Enabled",
"disabled": "Disabled",
"apiKeysTitle": "API Keys",
"apiKeysDesc": "Manage the API access key pool. Click the pencil icon on each row to edit name and remark.",
"addKey": "Add key",
@@ -123,13 +130,23 @@
"noApiKeys": "No API keys found.",
"accountsTitle": "DeepSeek Accounts",
"accountsDesc": "Manage the DeepSeek account pool with either email or mobile login, and edit name/remark.",
"testAll": "Refresh all tokens",
"testAll": "Check all accounts",
"addAccount": "Add account",
"testingAllAccounts": "Refreshing tokens for all accounts...",
"testingAllAccounts": "Checking all accounts...",
"sessionActive": "Session active",
"reauthRequired": "Retest status required",
"runtimeStatusUnknown": "Will be determined after sync",
"testStatusFailed": "Last test failed",
"tokenValid": "Token valid",
"tokenInvalid": "Token invalid",
"tokenUnknown": "Token unknown",
"visionAvailable": "Vision available",
"visionUnavailable": "Vision unavailable",
"visionUnknown": "Vision unknown",
"cooldownSeconds": "Cooldown {seconds}s",
"failureCount": "Failures {count}",
"lastFailureKind": "Reason: {kind}",
"muted": "Muted",
"noAccounts": "No accounts found.",
"modalAddKeyTitle": "Add API key",
"modalEditKeyTitle": "Edit API key",
@@ -231,7 +248,7 @@
"requestFailed": "Request failed.",
"networkError": "Network error: {error}",
"requestSuccess": "{account}: Request successful ({time}ms)",
"testSuccess": "{account}: Token refresh successful ({time}ms)",
"testSuccess": "{account}: Account check passed ({time}ms)",
"config": "Configuration",
"modelLabel": "Model",
"modelPickerHint": "Use the dropdown to pick a model. The list scrolls automatically.",
+25 -8
View File
@@ -57,8 +57,8 @@
"delete": "删除",
"copy": "复制",
"generate": "生成",
"test": "刷新 Token",
"testing": "正在刷新...",
"test": "检查账号",
"testing": "检查中...",
"loading": "加载中..."
},
"messages": {
@@ -103,14 +103,21 @@
"deleteKeyConfirm": "确定要删除此 API 密钥吗?",
"deleteAccountConfirm": "确定要删除此账号吗?",
"invalidIdentifier": "账号标识无效,无法执行操作",
"testAllConfirm": "刷新所有账号 Token 并验证登录?",
"testAllCompleted": "完成:{success}/{total} 刷新成功",
"testFailed": "测试失败: {error}",
"testAllConfirm": "检查所有账号 Token、会话与模型能力?",
"testAllCompleted": "完成:{success}/{total} 检查通过",
"testFailed": "检查失败: {error}",
"available": "可用",
"inUse": "正在使用",
"totalPool": "账号池总数",
"accountsUnit": "个账号",
"threadsUnit": "线程",
"recommendedConcurrency": "建议并发",
"waiting": "等待中",
"queueLimit": "队列上限",
"healthCheck": "健康检查",
"globalLimit": "全局上限",
"enabled": "开启",
"disabled": "关闭",
"apiKeysTitle": "API 密钥",
"apiKeysDesc": "管理 API 访问密钥池,点每行右侧铅笔可修改名称和备注",
"addKey": "添加密钥",
@@ -123,13 +130,23 @@
"noApiKeys": "未找到 API 密钥",
"accountsTitle": "DeepSeek 账号",
"accountsDesc": "管理 DeepSeek 账号池,支持邮箱或手机号登录,并可修改名称和备注",
"testAll": "刷新全部 Token",
"testAll": "检查全部账号",
"addAccount": "添加账号",
"testingAllAccounts": "正在刷新所有账号 Token...",
"testingAllAccounts": "正在检查所有账号...",
"sessionActive": "已建立会话",
"reauthRequired": "需重新测试状态",
"runtimeStatusUnknown": "状态以同步后为准",
"testStatusFailed": "上次测试失败",
"tokenValid": "Token 有效",
"tokenInvalid": "Token 失效",
"tokenUnknown": "Token 未知",
"visionAvailable": "Vision 可用",
"visionUnavailable": "Vision 不可用",
"visionUnknown": "Vision 未知",
"cooldownSeconds": "冷却 {seconds}s",
"failureCount": "失败 {count}",
"lastFailureKind": "原因: {kind}",
"muted": "已静默",
"noAccounts": "未找到任何账号",
"modalAddKeyTitle": "添加 API 密钥",
"modalEditKeyTitle": "编辑 API 密钥",
@@ -231,7 +248,7 @@
"requestFailed": "请求失败",
"networkError": "网络错误: {error}",
"requestSuccess": "{account}: 请求成功 ({time}ms)",
"testSuccess": "{account}: Token 刷新成功 ({time}ms)",
"testSuccess": "{account}: 账号检查通过 ({time}ms)",
"config": "配置",
"modelLabel": "模型",
"modelPickerHint": "使用下拉列表选择模型,长列表会自动滚动。",