Skip to content

Commit ce75742

Browse files
committed
新增 Chat 响应超时与流空闲超时自定义(env/面板热生效)及 /health 端点;修复空闲看门狗定时器竞态与超时错误回报不一致
1 parent 2cc759a commit ce75742

10 files changed

Lines changed: 718 additions & 36 deletions

File tree

‎README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
7474
| `QODER_DATA_PATH` | `data.json` | 数据文件路径 |
7575
| `QODER_ADMIN_PASSWORD` | `password` | 管理面板密码(覆盖 data.json 中的值) |
7676
| `QODER_SIGNATURE_SECRET` | 内置值 | 请求签名密钥 |
77+
| `QODER_CHAT_TIMEOUT_SECONDS` | `120` | Chat 响应超时:等待上游开始响应(返回响应头)的最长秒数,范围 1-3600;设置后管理面板中不可修改 |
78+
| `QODER_IDLE_TIMEOUT_SECONDS` | `300` | 流空闲超时:流式响应中持续无数据的最长等待秒数,范围 1-3600(数据持续到达时流不会被中断);设置后管理面板中不可修改 |
79+
80+
> 两个超时也可在管理面板「设置 → 超时配置」中修改,保存后即时生效(无需重启)。响应超时只约束上游「开始响应」,不会截断已经建立的流。
7781
7882
> 请求统计(`stats` 字段)随 data.json 持久化:服务每 30 秒批量写入一次,并在收到 `SIGINT`/`SIGTERM` 优雅关闭时执行最终落盘;仅统计通过密钥鉴权且请求体合法的调用,客户端主动断开不计入失败。令牌用量(总输入 / 总输出 / 缓存命中 / 实际扣费额度)从网关 usage 帧自动累计,流中断无 usage 帧时仅计次不计量。
7983
@@ -87,6 +91,8 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
8791
"port": 10081,
8892
"pat": "pt-xxxxxxxx",
8993
"password": "password",
94+
"chat_timeout_seconds": 120,
95+
"idle_timeout_seconds": 300,
9096
"api_keys": [
9197
{
9298
"id": "xxxxxxxx",
@@ -98,6 +104,19 @@ docker run -d -p 10081:10081 -v qoder2api-data:/app/data -e QODER_DATA_PATH=/app
98104
}
99105
```
100106

107+
## 健康检查
108+
109+
`GET /health` 返回服务状态(免鉴权,供容器探针 / 负载均衡使用):
110+
111+
```json
112+
{
113+
"status": "ok",
114+
"has_pat": true,
115+
"chat_timeout_seconds": 120,
116+
"idle_timeout_seconds": 300
117+
}
118+
```
119+
101120
## 使用方法
102121

103122
### 1. 配置 PAT

‎admin/admin.go‎

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"sync"
1717
"time"
1818

19+
"qoder2api/auth"
1920
"qoder2api/models"
2021
"qoder2api/stats"
2122
"qoder2api/store"
@@ -420,17 +421,32 @@ func (a *Admin) handleKeys(w http.ResponseWriter, r *http.Request) {
420421

421422
// --- Config endpoint ---
422423

424+
// timeoutEnvManaged reports whether stream timeouts are pinned by env vars,
425+
// in which case the panel must not override them (same policy as
426+
// QODER_ADMIN_PASSWORD for password changes).
427+
func timeoutEnvManaged() bool {
428+
return os.Getenv("QODER_CHAT_TIMEOUT_SECONDS") != "" ||
429+
os.Getenv("QODER_IDLE_TIMEOUT_SECONDS") != ""
430+
}
431+
423432
func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
424433
switch r.Method {
425434
case http.MethodGet:
435+
// Report the effective stream timeouts (env startup overrides are
436+
// already folded into them), not just the persisted file values.
437+
timeouts := auth.CurrentStreamTimeouts()
426438
writeJSON(w, 200, map[string]interface{}{
427-
"host": a.store.GetHost(),
428-
"port": a.store.GetPort(),
439+
"host": a.store.GetHost(),
440+
"port": a.store.GetPort(),
441+
"chat_timeout_seconds": int(timeouts.Header.Seconds()),
442+
"idle_timeout_seconds": int(timeouts.Idle.Seconds()),
429443
})
430444
case http.MethodPost:
431445
var body struct {
432-
Host string `json:"host"`
433-
Port int `json:"port"`
446+
Host string `json:"host"`
447+
Port int `json:"port"`
448+
ChatTimeoutSeconds int `json:"chat_timeout_seconds"`
449+
IdleTimeoutSeconds int `json:"idle_timeout_seconds"`
434450
}
435451
if err := readJSON(w, r, &body); err != nil {
436452
writeJSONError(w, 400, "请求格式错误")
@@ -446,14 +462,58 @@ func (a *Admin) handleConfig(w http.ResponseWriter, r *http.Request) {
446462
writeJSONError(w, 400, "端口必须在 1-65535 范围内")
447463
return
448464
}
465+
// Timeout fields are optional (0 = keep current) so legacy callers
466+
// that only send host/port keep working. Validate everything before
467+
// saving anything so a bad value cannot leave a partial update.
468+
wantsTimeouts := body.ChatTimeoutSeconds != 0 || body.IdleTimeoutSeconds != 0
469+
if wantsTimeouts && timeoutEnvManaged() {
470+
writeJSONError(w, 400, "超时由环境变量 QODER_CHAT_TIMEOUT_SECONDS / QODER_IDLE_TIMEOUT_SECONDS 管理,无法在面板中修改")
471+
return
472+
}
473+
if body.ChatTimeoutSeconds != 0 {
474+
if err := store.ValidateTimeoutSeconds(body.ChatTimeoutSeconds, store.MaxChatTimeoutSeconds); err != nil {
475+
writeJSONError(w, 400, "Chat 响应超时:"+err.Error())
476+
return
477+
}
478+
}
479+
if body.IdleTimeoutSeconds != 0 {
480+
if err := store.ValidateTimeoutSeconds(body.IdleTimeoutSeconds, store.MaxIdleTimeoutSeconds); err != nil {
481+
writeJSONError(w, 400, "流空闲超时:"+err.Error())
482+
return
483+
}
484+
}
449485
if err := a.store.SetHostPort(body.Host, body.Port); err != nil {
450486
writeJSONError(w, 500, err.Error())
451487
return
452488
}
489+
if body.ChatTimeoutSeconds != 0 {
490+
if err := a.store.SetChatTimeoutSeconds(body.ChatTimeoutSeconds); err != nil {
491+
writeJSONError(w, 500, err.Error())
492+
return
493+
}
494+
}
495+
if body.IdleTimeoutSeconds != 0 {
496+
if err := a.store.SetIdleTimeoutSeconds(body.IdleTimeoutSeconds); err != nil {
497+
writeJSONError(w, 500, err.Error())
498+
return
499+
}
500+
}
501+
// Hot-apply only when the request actually carried timeout fields:
502+
// re-applying store values unconditionally would clobber env-pinned
503+
// timeouts on a legacy host/port-only save.
504+
if wantsTimeouts {
505+
auth.SetStreamTimeouts(
506+
time.Duration(a.store.GetChatTimeoutSeconds())*time.Second,
507+
time.Duration(a.store.GetIdleTimeoutSeconds())*time.Second,
508+
)
509+
}
510+
timeouts := auth.CurrentStreamTimeouts()
453511
writeJSON(w, 200, map[string]interface{}{
454-
"host": body.Host,
455-
"port": body.Port,
456-
"restart": "修改主机或端口后需要重启服务才能生效",
512+
"host": body.Host,
513+
"port": body.Port,
514+
"chat_timeout_seconds": int(timeouts.Header.Seconds()),
515+
"idle_timeout_seconds": int(timeouts.Idle.Seconds()),
516+
"restart": "修改主机或端口后需要重启服务才能生效(超时配置已即时生效)",
457517
})
458518
default:
459519
writeJSONError(w, 405, "方法不允许")

‎admin/admin_test.go‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010
"time"
1111

12+
"qoder2api/auth"
1213
"qoder2api/models"
1314
"qoder2api/stats"
1415
"qoder2api/store"
@@ -380,3 +381,127 @@ func TestClientIPv6(t *testing.T) {
380381
t.Errorf("clientIP(1.2.3.4:5678) = %q, want 1.2.3.4", got)
381382
}
382383
}
384+
385+
// -- Config: stream timeouts --
386+
387+
func TestConfigGetIncludesTimeouts(t *testing.T) {
388+
a, _ := newAdmin(t)
389+
w := doRequest(t, a.handleConfig, "GET", "/admin/api/config", nil)
390+
if w.Code != 200 {
391+
t.Fatalf("expected 200, got %d", w.Code)
392+
}
393+
var resp map[string]interface{}
394+
json.Unmarshal(w.Body.Bytes(), &resp)
395+
if int(resp["chat_timeout_seconds"].(float64)) != store.DefaultChatTimeoutSeconds {
396+
t.Errorf("expected default chat timeout %d, got %v", store.DefaultChatTimeoutSeconds, resp["chat_timeout_seconds"])
397+
}
398+
if int(resp["idle_timeout_seconds"].(float64)) != store.DefaultIdleTimeoutSeconds {
399+
t.Errorf("expected default idle timeout %d, got %v", store.DefaultIdleTimeoutSeconds, resp["idle_timeout_seconds"])
400+
}
401+
}
402+
403+
// Saving timeouts must persist to the store and hot-apply to the auth
404+
// package without a restart.
405+
func TestConfigSetTimeoutsHotApplied(t *testing.T) {
406+
defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout)
407+
a, s := newAdmin(t)
408+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{
409+
"host": "0.0.0.0", "port": 10081,
410+
"chat_timeout_seconds": 90, "idle_timeout_seconds": 45,
411+
})
412+
if w.Code != 200 {
413+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
414+
}
415+
if s.GetChatTimeoutSeconds() != 90 || s.GetIdleTimeoutSeconds() != 45 {
416+
t.Errorf("timeouts not persisted: chat=%d idle=%d", s.GetChatTimeoutSeconds(), s.GetIdleTimeoutSeconds())
417+
}
418+
timeouts := auth.CurrentStreamTimeouts()
419+
if timeouts.Header != 90*time.Second || timeouts.Idle != 45*time.Second {
420+
t.Errorf("timeouts not hot-applied: %+v", timeouts)
421+
}
422+
// GET reflects the effective values.
423+
w = doRequest(t, a.handleConfig, "GET", "/admin/api/config", nil)
424+
var resp map[string]interface{}
425+
json.Unmarshal(w.Body.Bytes(), &resp)
426+
if int(resp["chat_timeout_seconds"].(float64)) != 90 {
427+
t.Errorf("GET should report 90, got %v", resp["chat_timeout_seconds"])
428+
}
429+
}
430+
431+
// Omitted timeout fields (0) must keep current values so legacy callers
432+
// that only send host/port keep working; out-of-range values are rejected.
433+
func TestConfigSetTimeoutValidation(t *testing.T) {
434+
defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout)
435+
a, s := newAdmin(t)
436+
s.SetChatTimeoutSeconds(90)
437+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{"host": "0.0.0.0", "port": 10081})
438+
if w.Code != 200 {
439+
t.Fatalf("legacy body should be accepted, got %d: %s", w.Code, w.Body.String())
440+
}
441+
if s.GetChatTimeoutSeconds() != 90 {
442+
t.Errorf("legacy body must not touch timeouts, got %d", s.GetChatTimeoutSeconds())
443+
}
444+
for _, body := range []map[string]interface{}{
445+
{"host": "0.0.0.0", "port": 10081, "chat_timeout_seconds": 5000},
446+
{"host": "0.0.0.0", "port": 10081, "idle_timeout_seconds": -3},
447+
} {
448+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", body)
449+
if w.Code != 400 {
450+
t.Errorf("expected 400 for %+v, got %d: %s", body, w.Code, w.Body.String())
451+
}
452+
}
453+
}
454+
455+
// When timeouts are pinned by env vars the panel must refuse changes (the
456+
// env would silently win again after the next restart).
457+
func TestConfigTimeoutsEnvManaged(t *testing.T) {
458+
t.Setenv("QODER_CHAT_TIMEOUT_SECONDS", "90")
459+
a, _ := newAdmin(t)
460+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{
461+
"host": "0.0.0.0", "port": 10081, "chat_timeout_seconds": 60,
462+
})
463+
if w.Code != 400 {
464+
t.Errorf("expected 400 when env-managed, got %d: %s", w.Code, w.Body.String())
465+
}
466+
}
467+
468+
// A legacy host/port-only save must not clobber env-pinned timeouts with the
469+
// persisted (file) values.
470+
func TestConfigLegacyBodyKeepsEnvTimeouts(t *testing.T) {
471+
auth.SetStreamTimeouts(200*time.Second, 300*time.Second)
472+
defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout)
473+
t.Setenv("QODER_CHAT_TIMEOUT_SECONDS", "200")
474+
475+
a, s := newAdmin(t)
476+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{"host": "0.0.0.0", "port": 10081})
477+
if w.Code != 200 {
478+
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
479+
}
480+
timeouts := auth.CurrentStreamTimeouts()
481+
if timeouts.Header != 200*time.Second {
482+
t.Errorf("legacy save clobbered env timeout, got %s", timeouts.Header)
483+
}
484+
if s.GetChatTimeoutSeconds() != store.DefaultChatTimeoutSeconds {
485+
t.Errorf("legacy save must not touch stored timeouts, got %d", s.GetChatTimeoutSeconds())
486+
}
487+
}
488+
489+
// A request mixing a valid and an invalid timeout must apply neither.
490+
func TestConfigPartialInvalidTimeoutAppliesNothing(t *testing.T) {
491+
defer auth.SetStreamTimeouts(auth.DefaultChatHeaderTimeout, auth.DefaultChatIdleTimeout)
492+
a, s := newAdmin(t)
493+
w := doRequest(t, a.handleConfig, "POST", "/admin/api/config", map[string]interface{}{
494+
"host": "0.0.0.0", "port": 10081,
495+
"chat_timeout_seconds": 90, "idle_timeout_seconds": 9999,
496+
})
497+
if w.Code != 400 {
498+
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
499+
}
500+
if s.GetChatTimeoutSeconds() != store.DefaultChatTimeoutSeconds {
501+
t.Errorf("valid field must not be applied when another field is invalid, got %d", s.GetChatTimeoutSeconds())
502+
}
503+
timeouts := auth.CurrentStreamTimeouts()
504+
if timeouts.Header != auth.DefaultChatHeaderTimeout {
505+
t.Errorf("hot-apply must not run on rejected input, got %s", timeouts.Header)
506+
}
507+
}

‎admin/webui.go‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -980,10 +980,25 @@ input[readonly] {
980980
<label>端口</label>
981981
<div class="field"><input type="number" id="cfgPort" placeholder="10081"></div>
982982
</div>
983+
<div id="configHint" class="hint-warn hidden"></div>
984+
</div>
985+
</div>
986+
987+
<div class="card">
988+
<div class="card-head"><div class="card-title">超时配置</div></div>
989+
<div class="card-body">
990+
<p class="hint">Chat 响应超时(秒):等待上游开始响应(返回响应头)的最长时间;流空闲超时(秒):流式响应中持续无数据的最长等待,只要数据持续到达就不会中断。保存后立即生效,无需重启。</p>
991+
<div class="row">
992+
<label>响应超时</label>
993+
<div class="field"><input type="number" id="cfgChatTimeout" placeholder="120" min="1" max="3600"></div>
994+
</div>
995+
<div class="row">
996+
<label>空闲超时</label>
997+
<div class="field"><input type="number" id="cfgIdleTimeout" placeholder="300" min="1" max="3600"></div>
998+
</div>
983999
<div class="btn-row right">
9841000
<button class="btn" type="button" onclick="saveConfig()">保存</button>
9851001
</div>
986-
<div id="configHint" class="hint-warn hidden"></div>
9871002
</div>
9881003
</div>
9891004
@@ -1379,6 +1394,8 @@ function loadConfig() {
13791394
api('/config').then(function(data) {
13801395
document.getElementById('cfgHost').value = data.host || '';
13811396
document.getElementById('cfgPort').value = data.port || '';
1397+
document.getElementById('cfgChatTimeout').value = data.chat_timeout_seconds || '';
1398+
document.getElementById('cfgIdleTimeout').value = data.idle_timeout_seconds || '';
13821399
document.getElementById('configHint').classList.add('hidden');
13831400
}).catch(function(e) { showToast(e.message, 'error'); });
13841401
}
@@ -1388,11 +1405,22 @@ function saveConfig() {
13881405
var port = parseInt(document.getElementById('cfgPort').value, 10);
13891406
if (!host) host = '0.0.0.0';
13901407
if (!port || port < 1 || port > 65535) { showToast('端口无效', 'error'); return; }
1391-
api('/config', { method: 'POST', body: JSON.stringify({ host: host, port: port }) })
1408+
var body = { host: host, port: port };
1409+
var ct = parseInt(document.getElementById('cfgChatTimeout').value, 10);
1410+
var it = parseInt(document.getElementById('cfgIdleTimeout').value, 10);
1411+
if (document.getElementById('cfgChatTimeout').value.trim() !== '') {
1412+
if (!ct || ct < 1 || ct > 3600) { showToast('Chat 响应超时需在 1-3600 秒之间', 'error'); return; }
1413+
body.chat_timeout_seconds = ct;
1414+
}
1415+
if (document.getElementById('cfgIdleTimeout').value.trim() !== '') {
1416+
if (!it || it < 1 || it > 3600) { showToast('流空闲超时需在 1-3600 秒之间', 'error'); return; }
1417+
body.idle_timeout_seconds = it;
1418+
}
1419+
api('/config', { method: 'POST', body: JSON.stringify(body) })
13921420
.then(function(data) {
1393-
showToast('配置已保存,重启后生效');
1421+
showToast('配置已保存(超时配置即时生效)');
13941422
var h = document.getElementById('configHint');
1395-
h.textContent = data.restart || '修改后需要重启服务才能生效。';
1423+
h.textContent = data.restart || '修改主机或端口后需要重启服务才能生效。';
13961424
h.classList.remove('hidden');
13971425
loadConfig();
13981426
})

0 commit comments

Comments
 (0)