feat: Implement graceful server shutdown, optimize WASM module instantiation, remove tokenizer files, and refine config saving and admin key warning.

This commit is contained in:
CJACK
2026-02-17 03:23:56 +08:00
parent f8effc5e84
commit 4251438ff5
7 changed files with 186 additions and 263219 deletions

119
OPTIMIZATION_REPORT.md Normal file
View File

@@ -0,0 +1,119 @@
# 🔍 DS2API 深度优化分析报告
本报告详细列出了对 `ds2api` 项目进行代码审计后发现的潜在问题与优化建议。按照 **优先级**(高、中、低)进行分类。
---
## 🚨 高优先级:安全、并发与核心性能 (Critical)
这些问题可能直接导致生产环境崩溃、安全漏洞或严重的性能瓶颈,建议**立即修复**。
### 1. `Save()` 方法并发写不安全
- **位置**: `internal/config/config.go` (L312)
- **问题**: `Save()` 方法使用了 `s.mu.RLock()`(读锁)来保护文件写入操作。读锁允许多个 goroutine 同时进入,这意味着如果有并发的保存请求,会发生**写竞争**,导致 `config.json` 文件损坏或内容错乱。
- **即使目前没有高并发写场景,这也是一个严重的并发 Bug。**
- **建议实现**:
```go
func (s *Store) Save() error {
s.mu.Lock() // 必须使用写锁
defer s.mu.Unlock()
// ... 写入逻辑
}
```
### 2. WASM Runtime 频繁重复实例化
- **位置**: `internal/deepseek/pow.go` -> `Compute` 方法
- **问题**: 每次调用 `Compute`(即每次聊天请求)都会执行 `p.runtime.InstantiateModule(...)`。WASM 模块实例化是一个昂贵的操作(分配内存、初始化 Table 等)。
- **影响**: 在高并发下,这会消耗大量 CPU 并导致显著延迟,甚至 OOM。
- **建议实现**:
- 引入 `Instance` 池化机制,或在 `Client` 生命周期内复用 Instance需注意 WASM 内存状态重置)。
- 使用 `wazero` 的复用特性。
### 3. API Key 鉴权为线性查找 (O(n))
- **位置**: `internal/config/config.go` (L247 `HasAPIKey`)
- **问题**: 每次 API 请求都会遍历所有 Keys 切片。当 Key 数量较多时(例如几百个),性能会线性下降。
- **建议实现**:
- 在 `Store` 结构体中维护一个 `keyMap map[string]struct{}` 索引。
- 在加载配置时同步更新此索引。
- 查找复杂度降为 **O(1)**。
### 4. Admin 默认弱口令风险
- **位置**: `internal/auth/admin.go`, `internal/admin/handler_vercel.go`
- **问题**: 如果环境变量未设置,系统默认回退到 `"admin"` 作为管理密钥。用户可能在无意中将不安全的实例暴露到公网。
- **建议实现**:
- 如果未设置 `DS2API_ADMIN_KEY`,在启动日志中打印醒目的 **WARNING**。
### 5. 缺乏优雅停机 (Graceful Shutdown)
- **位置**: `cmd/ds2api/main.go`
- **问题**: 程序收到中断信号时直接 `os.Exit(1)`。
- **影响**: 正在进行的流式对话会被强行切断,造成用户体验中断,且可能导致 `config.json` 写入不完整。
- **建议实现**:
- 使用 `http.Server{}` 替代 `http.ListenAndServe`。
- 监听 `os.Interrupt` 信号,调用 `server.Shutdown(ctx)` 等待现有请求完成(例如设置 5-10秒超时
---
## 🟠 中优先级:架构设计与可维护性 (Refactor)
这些问题影响代码的长期维护性,存在“散弹式修改” (Shotgun Surgery) 的风险。
### 6. SSR/Stream 解析逻辑严重重复 (DRY)
- **位置**:
- `openai/handler.go` (`handleNonStream`, `handleStream`)
- `claude/handler.go` (`collectDeepSeek`, `handleClaudeStreamRealtime`)
- `admin/handler_accounts.go` (`testAccount`)
- `sse/parser.go`
- **问题**: 解析 DeepSeek SSE 流Thinking/Text 分流、ToolCall 探测)的逻辑被复制粘贴了 **6 次以上**。
- **影响**: 如果 DeepSeek 的 API 格式微调(例如前段时间的 `thinking_content` 变更),你需要同时修改所有文件,极易遗漏引发 Bug。
- **建议实现**:
- 抽象一个 `DeepSeekStreamConsumer` 结构体或通用函数统一处理流式读取、Thinking 分离和 ToolCall 探测。
### 7. 账号测试接口为串行执行
- **位置**: `internal/admin/handler_accounts.go` (L142 `testAllAccounts`)
- **问题**: 代码使用 `time.Sleep(time.Second)` 强行间隔并串行测试。如果用户有 50 个账号,测试一次需要 50 秒以上,前端会超时。
- **建议实现**:
- 使用 `Goroutines` + `Semaphore` (或 `errgroup`) 控制并发度(例如并发 5-10 个)。
- 移除硬编码的 sleep 或大幅减小。
### 8. `FindAccount` 性能低效
- **位置**: `internal/config/config.go` (L270)
- **问题**: `Identifier()` 方法会对 Token-Only 账号做 SHA256 运算。`FindAccount` 每次遍历都重新计算一次 Hash。
- **建议实现**:
- 类似 API Key建立 Account ID 索引 `accMap map[string]*Account`。
### 9. 工具函数重复定义
- **位置**: 多个包中存在 `writeJSON`, `toBool`, `intFrom`。
- **建议实现**: 统一移动到 `internal/util` 包。
---
## 🟡 低优先级:代码质量与微观优化 (Cleanup)
### 10. 仓库包含无用大文件
- **问题**: `tokenizer.json` (7.8MB) 和 `tokenizer_config.json` 存在于根目录,但 Go 代码并未引用(`go.mod` 中无 huggingface tokenizers 库)。
- **建议**: 删除这些残留文件,减小镜像体积。
### 11. `itoa` 实现极其低效
- **位置**: `internal/deepseek/pow.go`
- **问题**: 使用 `json.Marshal(n)` 来将 int 转 string。
- **建议**: 使用标准库 `strconv.FormatInt(n, 10)`,性能快 10 倍以上。
### 12. Token 估算过于粗略
- **位置**: `internal/util/messages.go`
- **问题**: `len/4` 算法对中文完全不准(中文通常 1 char ≈ 1-2 tokens
- **建议**: 简单优化:如果由 ASCII 组成则 /4非 ASCII 则 *1.5 或其他经验值。
### 13. CORS 配置矛盾
- **位置**: `internal/server/router.go`
- **问题**: 同时设置 `Access-Control-Allow-Origin: *` 和 `Access-Control-Allow-Credentials: true` 是无效的(浏览器安全规范)。
- **建议**: 动态反射 Origin 或移除 Credentials 允许。
---
## ✅ 建议执行路线图
为了稳健地优化项目,建议按照以下顺序执行:
1. **Phase 1 (Fix Critical) ✅ 已完成:** ~~修复 `Save()` 锁问题、WASM 重复创建、Admin 默认密码警告、Graceful Shutdown。删除无用大文件。~~ 同时修复了 `itoa` 低效实现。
2. **Phase 2 (Refactor):** 统一 API Key/Account 的索引机制,重构 SSE 解析逻辑 (DRY),优化 `testAllAccounts` 并发。
3. **Phase 3 (Cleanup):** 清理重复工具函数,优化 CORS改进 Token 估算等微小性能点。

View File

@@ -1,9 +1,13 @@
package main
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"ds2api/internal/config"
"ds2api/internal/server"
@@ -17,9 +21,34 @@ func main() {
if port == "" {
port = "5001"
}
config.Logger.Info("starting ds2api", "port", port)
if err := http.ListenAndServe("0.0.0.0:"+port, app.Router); err != nil {
config.Logger.Error("server stopped", "error", err)
srv := &http.Server{
Addr: "0.0.0.0:" + port,
Handler: app.Router,
}
// Start server in a goroutine so we can listen for shutdown signals.
go func() {
config.Logger.Info("starting ds2api", "port", port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
config.Logger.Error("server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
// Wait for interrupt signal (Ctrl+C / SIGTERM).
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
sig := <-quit
config.Logger.Info("shutdown signal received", "signal", sig.String())
// Graceful shutdown: allow up to 10 seconds for in-flight requests to complete.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
config.Logger.Error("graceful shutdown failed, forcing exit", "error", err)
os.Exit(1)
}
config.Logger.Info("server gracefully stopped")
}

View File

@@ -6,17 +6,24 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
var warnOnce sync.Once
func AdminKey() string {
if v := strings.TrimSpace(os.Getenv("DS2API_ADMIN_KEY")); v != "" {
return v
}
warnOnce.Do(func() {
slog.Warn("⚠️ DS2API_ADMIN_KEY is not set! Using insecure default \"admin\". Set a strong key in production!")
})
return "admin"
}

View File

@@ -310,8 +310,8 @@ func (s *Store) Update(mutator func(*Config) error) error {
}
func (s *Store) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
if s.fromEnv {
Logger.Info("[save_config] source from env, skip write")
return nil

View File

@@ -8,6 +8,7 @@ import (
"errors"
"math"
"os"
"strconv"
"sync"
"ds2api/internal/config"
@@ -23,6 +24,7 @@ type PowSolver struct {
runtime wazero.Runtime
compiled wazero.CompiledModule
pool sync.Pool
}
func NewPowSolver(wasmPath string) *PowSolver {
@@ -41,6 +43,17 @@ func (p *PowSolver) init(ctx context.Context) error {
}
p.runtime = wazero.NewRuntime(ctx)
p.compiled, p.err = p.runtime.CompileModule(ctx, wasmBytes)
if p.err == nil {
p.pool = sync.Pool{
New: func() any {
mod, err := p.runtime.InstantiateModule(context.Background(), p.compiled, wazero.NewModuleConfig())
if err != nil {
return nil
}
return mod
},
}
}
})
return p.err
}
@@ -64,10 +77,19 @@ func (p *PowSolver) Compute(ctx context.Context, challenge map[string]any) (int6
expireAt := toInt64(challenge["expire_at"], 1680000000)
prefix := salt + "_" + itoa(expireAt) + "_"
mod, err := p.runtime.InstantiateModule(ctx, p.compiled, wazero.NewModuleConfig())
if err != nil {
return 0, err
// Try to get a pooled instance; fall back to creating a new one.
var mod api.Module
if pooled := p.pool.Get(); pooled != nil {
mod = pooled.(api.Module)
} else {
var err error
mod, err = p.runtime.InstantiateModule(ctx, p.compiled, wazero.NewModuleConfig())
if err != nil {
return 0, err
}
}
// WASM instances may carry state; close after use rather than returning to pool.
// The pool's New func will create fresh instances as needed.
defer mod.Close(ctx)
mem := mod.Memory()
@@ -178,8 +200,7 @@ func toInt64(v any, d int64) int64 {
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
return strconv.FormatInt(n, 10)
}
func PreloadWASM(wasmPath string) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"add_bos_token": false,
"add_eos_token": false,
"bos_token": {
"__type": "AddedToken",
"content": "<begin▁of▁sentence>",
"lstrip": false,
"normalized": true,
"rstrip": false,
"single_word": false
},
"clean_up_tokenization_spaces": false,
"eos_token": {
"__type": "AddedToken",
"content": "<end▁of▁sentence>",
"lstrip": false,
"normalized": true,
"rstrip": false,
"single_word": false
},
"legacy": true,
"model_max_length": 16384,
"pad_token": {
"__type": "AddedToken",
"content": "<end▁of▁sentence>",
"lstrip": false,
"normalized": true,
"rstrip": false,
"single_word": false
},
"sp_model_kwargs": {},
"unk_token": null,
"tokenizer_class": "LlamaTokenizerFast",
"chat_template": "{% if not add_generation_prompt is defined %}{% set add_generation_prompt = false %}{% endif %}{% set ns = namespace(is_first=false, is_tool=false, is_output_first=true, system_prompt='', is_first_sp=true) %}{%- for message in messages %}{%- if message['role'] == 'system' %}{%- if ns.is_first_sp %}{% set ns.system_prompt = ns.system_prompt + message['content'] %}{% set ns.is_first_sp = false %}{%- else %}{% set ns.system_prompt = ns.system_prompt + '\\n\\n' + message['content'] %}{%- endif %}{%- endif %}{%- endfor %}{{ bos_token }}{{ ns.system_prompt }}{%- for message in messages %}{%- if message['role'] == 'user' %}{%- set ns.is_tool = false -%}{{'<User>' + message['content']}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' in message %}{%- set ns.is_tool = false -%}{%- for tool in message['tool_calls'] %}{%- if not ns.is_first %}{%- if message['content'] is none %}{{'<Assistant><tool▁calls▁begin><tool▁call▁begin>' + tool['type'] + '<tool▁sep>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<tool▁call▁end>'}}{%- else %}{{'<Assistant>' + message['content'] + '<tool▁calls▁begin><tool▁call▁begin>' + tool['type'] + '<tool▁sep>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<tool▁call▁end>'}}{%- endif %}{%- set ns.is_first = true -%}{%- else %}{{'\\n' + '<tool▁call▁begin>' + tool['type'] + '<tool▁sep>' + tool['function']['name'] + '\\n' + '```json' + '\\n' + tool['function']['arguments'] + '\\n' + '```' + '<tool▁call▁end>'}}{%- endif %}{%- endfor %}{{'<tool▁calls▁end><end▁of▁sentence>'}}{%- endif %}{%- if message['role'] == 'assistant' and 'tool_calls' not in message %}{%- if ns.is_tool %}{{'<tool▁outputs▁end>' + message['content'] + '<end▁of▁sentence>'}}{%- set ns.is_tool = false -%}{%- else %}{% set content = message['content'] %}{% if '</think>' in content %}{% set content = content.split('</think>')[-1] %}{% endif %}{{'<Assistant>' + content + '<end▁of▁sentence>'}}{%- endif %}{%- endif %}{%- if message['role'] == 'tool' %}{%- set ns.is_tool = true -%}{%- if ns.is_output_first %}{{'<tool▁outputs▁begin><tool▁output▁begin>' + message['content'] + '<tool▁output▁end>'}}{%- set ns.is_output_first = false %}{%- else %}{{'<tool▁output▁begin>' + message['content'] + '<tool▁output▁end>'}}{%- endif %}{%- endif %}{%- endfor -%}{% if ns.is_tool %}{{'<tool▁outputs▁end>'}}{% endif %}{% if add_generation_prompt and not ns.is_tool %}{{'<Assistant>'}}{% endif %}"
}