From dc6622c160a81f341308c70385240784861631ea Mon Sep 17 00:00:00 2001 From: watsonk1998 <1515673657@qq.com> Date: Fri, 16 Oct 2026 14:10:25 +0800 Subject: [PATCH 01/88] fix(upstream): always send the conversation channel header trio --- internal/server/conversation_headers_test.go | 40 ++++++++++ .../upstream/conversation_headers_test.go | 77 +++++++++++++++++++ internal/upstream/headers.go | 13 ++-- 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 internal/server/conversation_headers_test.go create mode 100644 internal/upstream/conversation_headers_test.go diff --git a/internal/server/conversation_headers_test.go b/internal/server/conversation_headers_test.go new file mode 100644 index 00000000..2d6041fe --- /dev/null +++ b/internal/server/conversation_headers_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +func TestChatWithoutConversationKeyPassesChannelValidation(t *testing.T) { + up := &upstream.Client{ + HTTP: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + status, body := http.StatusOK, sseOK + for _, name := range []string{"X-Conversation-ID", "X-Conversation-Request-ID", "X-Conversation-Message-ID"} { + if r.Header.Get(name) == "" { + status, body = http.StatusBadRequest, `{"code":11128,"msg":"conversation id missing"}` + } + } + return &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(body))}, nil + })}, + ChatBaseGlobal: "https://fake.example", GlobalEnabled: true, + } + a, err := auth.Parse([]byte(`{"uid":"global-test","accessToken":"test-token","realm":"global","expiresAt":9999999999}`)) + if err != nil { + t.Fatal(err) + } + h := NewHandler(Config{Pool: testPoolWith(a), Upstream: up, GlobalEnabled: true}) + // A user identifier is not a conversation key; this also disables the + // session-key derivation used for otherwise unlabelled text conversations. + body := `{"model":"global:deepseek-v4.1-flash","stream":true,"metadata":{"user_id":"same-user"},"messages":[{"role":"user","content":"hi"}]}` + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "[DONE]") { + t.Fatalf("unlabelled conversation rejected: status=%d body=%s", rec.Code, rec.Body) + } +} diff --git a/internal/upstream/conversation_headers_test.go b/internal/upstream/conversation_headers_test.go new file mode 100644 index 00000000..d6f6c31e --- /dev/null +++ b/internal/upstream/conversation_headers_test.go @@ -0,0 +1,77 @@ +package upstream + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// The international chat endpoint rejects an incomplete conversation header trio +// with 400/code 11128, including calls made with a zero-value ChatMeta. +func TestChatStreamConversationChannelHeaders(t *testing.T) { + for _, realm := range []string{"cn", "global"} { + for _, tc := range []struct { + name string + meta ChatMeta + }{ + {name: "zero metadata"}, + {name: "request ID only", meta: ChatMeta{ConversationRequestID: "existing-turn"}}, + {name: "explicit conversation", meta: ChatMeta{ConversationID: "existing-dialog", ConversationRequestID: "existing-turn"}}, + } { + t.Run(realm+"/"+tc.name, func(t *testing.T) { + headers := make(chan http.Header, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers <- r.Header.Clone() + for _, name := range []string{"X-Conversation-ID", "X-Conversation-Request-ID", "X-Conversation-Message-ID"} { + if r.Header.Get(name) == "" { + w.WriteHeader(http.StatusBadRequest) + io.WriteString(w, `{"code":11128,"msg":"conversation id missing"}`) + return + } + } + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: {\"choices\":[{\"delta\":{\"content\":\"pong\"}}]}\n\ndata: [DONE]\n\n") + })) + defer srv.Close() + c := New() + c.ChatBaseCN, c.ChatBaseGlobal = srv.URL, srv.URL + a, err := auth.Parse([]byte(`{"uid":"test","accessToken":"test-token","realm":"` + realm + `"}`)) + if err != nil { + t.Fatal(err) + } + var previousID string + for i := 0; i < 2; i++ { + rc, status, body, err := c.ChatStream(a, []byte(`{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}`), "", tc.meta) + if err != nil || status != http.StatusOK { + t.Fatalf("chat: status=%d body=%s err=%v", status, body, err) + } + stream, err := io.ReadAll(rc) + rc.Close() + if err != nil || !strings.Contains(string(stream), "pong") || !strings.Contains(string(stream), "[DONE]") { + t.Fatalf("incomplete chat stream: %s err=%v", stream, err) + } + h := <-headers + id := h.Get("X-Conversation-ID") + if tc.meta.ConversationID != "" { + if id != tc.meta.ConversationID { + t.Fatalf("explicit conversation ID changed: %q", id) + } + } else if len(id) != 32 || !validTraceID(id) || id == previousID { + t.Fatalf("fallback ID must be fresh 32 hex: %q (previous %q)", id, previousID) + } + if tc.meta.ConversationRequestID != "" && h.Get("X-Conversation-Request-ID") != tc.meta.ConversationRequestID { + t.Fatal("explicit request ID changed") + } + if h.Get("X-Conversation-Message-ID") != h.Get("X-Request-ID") { + t.Fatal("message and request IDs lost their existing relationship") + } + previousID = id + } + }) + } + } +} diff --git a/internal/upstream/headers.go b/internal/upstream/headers.go index a121a8ea..912cffac 100644 --- a/internal/upstream/headers.go +++ b/internal/upstream/headers.go @@ -185,7 +185,7 @@ func (c *Client) injectCodeBuddyRequest(req *http.Request) { // 复用同值;TraceID 透传入站值(空 = 回落 conversationRequestID)。 // messageID(消息级,每条独立)由 ChatHeaders 内部生成,无需外部可见。 type ChatMeta struct { - ConversationID string // X-Conversation-ID:body 提取的入站值,空则不发(透传优先,不伪造) + ConversationID string // X-Conversation-ID:透传入站值,空则生成请求级兜底 ConversationRequestID string // X-Conversation-Request-ID / X-Root-Request-ID:聚合主键,必发 TraceID string // X-Trace-ID:入站透传值,空则回落 conversationRequestID } @@ -256,8 +256,9 @@ func (c *Client) injectGlobalChatHeaders(req *http.Request, a *auth.Auth) { // injectConversationHeaders 注入官方客户端会话头族(issue #35 后台聚合)。 // 头族分四层,各司其职: -// - X-Conversation-ID:会话级,多轮稳定(body 的 conversationId)。空则不发—— -// 透传客户端原值优先,客户端没给就不伪造,避免误导后台建错会话。 +// - X-Conversation-ID:优先透传调用方的会话 ID;缺失时生成请求级兜底。 +// 国际版渠道校验要求三个 X-Conversation-* 头齐全,缺任一个会报 +// 400 code=11128 "conversation id missing"(issue #187 / #188)。 // - X-Conversation-Request-ID:**对话轮级聚合主键**,必发。一次 user send 内的 // 所有 tool call/重试/换号/降级复用同一个 → 后台按它聚合成一条(不再碎片化)。 // - X-Conversation-Message-ID = X-Request-ID:消息级,每条独立(32 位 hex)。 @@ -274,9 +275,11 @@ func (c *Client) injectConversationHeaders(req *http.Request, meta ChatMeta) { convReqID = session.NewMessageID() } messageID := session.NewMessageID() - if meta.ConversationID != "" { - req.Header.Set("X-Conversation-ID", meta.ConversationID) + conversationID := meta.ConversationID + if conversationID == "" { + conversationID = session.NewMessageID() } + req.Header.Set("X-Conversation-ID", conversationID) req.Header.Set("X-Conversation-Request-ID", convReqID) req.Header.Set("X-Conversation-Message-ID", messageID) req.Header.Set("X-Request-ID", messageID) From 679e2400cf5f9681eb13b11cfa54c1d08d01cdf8 Mon Sep 17 00:00:00 2001 From: robin <527437300@qq.com> Date: Sat, 17 Oct 2026 01:25:15 +0800 Subject: [PATCH 02/88] =?UTF-8?q?fix(server):=20request=20illegal=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=B0=B8=E4=B9=85=E7=A6=81=E7=94=A8=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=EF=BC=8C=E6=94=B9=E4=B8=BA=E8=BD=AF=E5=86=B7=E5=8D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游对「渠道校验失败」的响应是 HTTP 400 code 11-128 "request illegal",body 自带 displayMsg「请求被拦截,请重新发送」与 displayTips「渠道校验失败,可能不是从官方 客户端发出的,请用官方客户端重新发送」——上游明示这是可重试的请求级拦截, 不是账号级封禁。 但 applyErrorPolicy 在 ErrAccountFault 分支按 msg 字面量匹配 "request illegal", 直接调用 Pool.Disable(),把账号永久踢出池子并要求人工解冻: h.cfg.Pool.Disable(uid, "account banned by upstream (11140 request illegal), re-login required") 实测该账号凭证/余额/JWT 全部正常,同一套出站头部换健康账号即可 200,换任何模型、 任何 header 变体都稳定复现——账号本身没有任何问题,禁用纯属误判。 改为软冷却:账号暂时出池、自动轮换,上游解除标记后自愈,绝不永久禁用。 触发该 400 的根因(缺 X-Conversation-ID)已由 #190 在 internal/upstream/headers.go 层修复;本 PR 是互补的防御面:即使将来上游换个措辞、或出现别的触发路径,误判的 代价也从「账号永久禁用、必须人工解冻」降为「短暂轮换、自动恢复」。 Refs #190 Refs #197 --- internal/server/handler.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 1c96d5f4..da160288 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -1384,12 +1384,18 @@ func (h *Handler) applyErrorPolicy(uid string, kind upstream.ErrKind, body, mode h.cfg.Pool.Cooldown(uid, pool.CoolSoft, notFoundCooldown, "upstream 404") case upstream.ErrAccountFault: // 账号级授权/配额故障按 msg 分野(口径与 Classify 的 accountFaultMarkers 一致): - // - "request illegal"(code 11140)→ 账号级**授权封禁**:硬禁用(Disable)。 + // - "request illegal"(业务码 11-128 / 11140)→ 上游**渠道校验拦截**,不是账号封禁。 + // 上游 body 自带 displayMsg「请求被拦截,请重新发送」与 displayTips「渠道校验失败, + // 可能不是从官方客户端发出的,请用官方客户端重新发送」——上游明示这是可重试的请求级 + // 拦截。实测该账号凭证/余额/JWT 全部正常,同一套出站头部换健康账号即可 200,换任何 + // 模型、任何 header 变体都稳定复现,属上游按账号下发的风控标记。历史实现在此处 + // Disable,会把仍可用的账号永久踢出池子并要求人工解冻(issue #197)。改为软冷却: + // 账号暂时出池、自动轮换,上游解除标记后自愈,绝不永久禁用。 // - 14017(trial not activated)→ register 未完成,补完 register 后可能自愈, // **保持软冷却**(禁用会让用户补完 register 后仍无法用)。 // 大小写不敏感(与 Classify 的 marker 匹配同口径)。 if strings.Contains(strings.ToLower(body), "request illegal") { - h.cfg.Pool.Disable(uid, "account banned by upstream (11140 request illegal), re-login required") + h.cfg.Pool.CooldownSoftRate(uid, h.softCooldown(), time.Time{}, "channel verification blocked (11-128 request illegal)") return } h.cfg.Pool.Cooldown(uid, pool.CoolSoft, h.softCooldown(), "account fault (14017)") From 8b7d43bb92353dc7d0e9d6f3f0087d92282a6aad Mon Sep 17 00:00:00 2001 From: louddong Date: Sun, 18 Oct 2026 08:25:08 +0800 Subject: [PATCH 03/88] =?UTF-8?q?fix(upstream):=20=E5=90=8C=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=20refresh=20=E5=8D=95=E9=A3=9E=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20RT=20=E5=A4=8D=E7=94=A8=E6=92=9E=2012153=20?= =?UTF-8?q?=E6=8E=89=E7=99=BB=E5=BD=95=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游对 refreshToken 反重放:同一 RT 被消费后再用会返回 12153 并整条 RT 链 作废,账号从此恒 401、只能人工重新登录。而 scheduler keepalive、handler 临近 过期刷新、signin 工具都会并发触发同一账号的 refresh(原实现把 I/O 放在锁外, a.mu 挡不住并发),于是掉登录概率随并发升高。 - RefreshToken 按 UID 做 single-flight:并发调用等待并复用首个结果,不重复打 上游;不同账号仍并行,不拖慢 keepalive 全量刷新。 - 12153 兜底:磁盘 auth 文件已被其他进程(server + signin 双进程部署)rotate 过时,采纳磁盘 token 重试一次,跨进程竞态也能自救。 - 新增用例:并发单飞、按账号隔离、磁盘 token 采纳、无变化不重试; go test -race ./internal/upstream 通过。 --- internal/upstream/client.go | 99 +++++++++- .../upstream/refresh_singleflight_test.go | 174 ++++++++++++++++++ 2 files changed, 267 insertions(+), 6 deletions(-) create mode 100644 internal/upstream/refresh_singleflight_test.go diff --git a/internal/upstream/client.go b/internal/upstream/client.go index ed4137ba..79549feb 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -12,6 +12,7 @@ import ( "log" "net/http" "net/url" + "os" "regexp" "sort" "strconv" @@ -681,6 +682,13 @@ type Client struct { // false 时即便用户 auth 写了 realm=global 也**不**路由到 global base—— // chatBase/billingBase 返回 CN base,路径也走 CN(双保险,与 auth.Realm() 的开关闸呼应)。 GlobalEnabled bool + + // refreshMu/refreshInFlight 同账号 refresh 单飞(按 UID 分桶):同一时刻只允许 + // 一个 refresh 在途,其余调用等待并复用首个结果。上游对 refreshToken 反重放, + // 并发第二发会带着已被消费的 RT 撞 12153 并整链作废(issue #215:账号从此恒 + // 401,只能人工重登)。单飞在这里是保号手段,不只是省流量。 + refreshMu sync.Mutex + refreshInFlight map[string]*refreshFlight } // New 生产默认值。Transport 由 newTransport() 集中构造(连接层加固:真正禁 h2 / @@ -901,13 +909,92 @@ const refreshTokenExpiresInMax = 10 * 365 * 24 * time.Hour // RefreshToken 刷新 access token;成功时更新 a 的字段(缺省值保留旧值), // 调用方负责 SaveAtomic。 // -// 并发安全模型(两段式,缩小持锁窗口): -// - 锁内仅做「读 refreshToken 快照」与「校验未变后写回新 token」两小段内存操作; -// - 网络 I/O(doJSON)在**锁外**执行,带 30s ctx 超时——避免上游 hang 时长时间 -// 独占 a.mu,阻塞同账号的 SaveAtomic / 其他刷新(issue:持锁 120s I/O)。 -// - 写回前重新校验快照一致性:若锁外期间另一 goroutine 已完成刷新(refreshToken -// 已变),本次结果直接采用(新 token 已生效),不再重复写回。 +// 并发安全模型(单飞 + 两段式): +// - **单飞**:同一账号(UID)同一时刻只允许一个 refresh 在途,其余调用等待并 +// 原样复用首个结果。上游对 refreshToken 有反重放:RT 被消费后再用会撞 12153 +// (refresh token is invalid)并把整条 RT 链作废,账号从此恒 401、只能人工 +// 重新登录(issue #215:scheduler keepalive / handler 临近过期刷新 / signin +// 会并发触发同一账号)。所以单飞在这里是保号手段,而不只是省流量。 +// - 首个调用内部仍是两段式:锁内读快照 → 锁外 I/O(30s)→ 锁内校验快照一致后 +// 写回;写回前若发现另一刷新已完成(两 token 同时变化)则直接采用。 +// - 12153 兜底:若磁盘 auth 文件已被其他进程(server + signin 双进程部署)刷新 +// 过,则采纳磁盘 token 重试一次——进程内单飞挡不住跨进程竞态。 func (c *Client) RefreshToken(a *auth.Auth) error { + key := a.UID + if key == "" { + // 无 UID 的临时 auth:退化为按对象指针去重,仍保证同一对象串行。 + key = fmt.Sprintf("%p", a) + } + c.refreshMu.Lock() + if c.refreshInFlight == nil { + c.refreshInFlight = make(map[string]*refreshFlight) + } + if f, ok := c.refreshInFlight[key]; ok { + c.refreshMu.Unlock() + <-f.done + return f.err + } + f := &refreshFlight{done: make(chan struct{})} + c.refreshInFlight[key] = f + c.refreshMu.Unlock() + + err := c.refreshTokenOnce(a) + if err != nil && isSessionDead(err) && adoptNewerTokensFromDisk(a) { + // 跨进程竞态:磁盘 RT 已被别的进程 rotate 过,用新 RT 再试一次。 + err = c.refreshTokenOnce(a) + } + + // 先写结果再关 channel:等待者 <-f.done 之后读到的一定是最终 err。 + f.err = err + close(f.done) + c.refreshMu.Lock() + delete(c.refreshInFlight, key) + c.refreshMu.Unlock() + return err +} + +// refreshFlight 是同一账号一次 refresh 的单飞票据:done 关闭即结果就绪。 +type refreshFlight struct { + done chan struct{} + err error +} + +// isSessionDead 判定错误是否为上游 12153(会话 / RT 失效)。 +func isSessionDead(err error) bool { + var ue *Error + return errors.As(err, &ue) && ue.Kind == ErrSessionDead +} + +// adoptNewerTokensFromDisk 在 refresh 失败后读回 auth 文件:若磁盘 RT 与内存不同 +// (另一进程已刷新并写回),采纳磁盘 token 并返回 true,供调用方重试一次。 +// 读文件失败、解析失败、RT 一致均返回 false(不改内存状态),保持失败语义。 +func adoptNewerTokensFromDisk(a *auth.Auth) bool { + if a.FilePath == "" { + return false + } + raw, err := os.ReadFile(a.FilePath) + if err != nil { + return false + } + disk, err := auth.Parse(raw) + if err != nil || strings.TrimSpace(disk.RefreshToken) == "" { + return false + } + a.Lock() + defer a.Unlock() + if disk.RefreshToken == a.RefreshToken { + return false + } + a.AccessToken = disk.AccessToken + a.RefreshToken = disk.RefreshToken + if disk.ExpiresAt > 0 { + a.ExpiresAt = disk.ExpiresAt + } + return true +} + +// refreshTokenOnce 是单次刷新本体(调用方 RefreshToken 已保证同账号串行)。 +func (c *Client) refreshTokenOnce(a *auth.Auth) error { // 第 1 段(锁内):读快照。 a.Lock() rtSnapshot := a.RefreshToken diff --git a/internal/upstream/refresh_singleflight_test.go b/internal/upstream/refresh_singleflight_test.go new file mode 100644 index 00000000..2af21f56 --- /dev/null +++ b/internal/upstream/refresh_singleflight_test.go @@ -0,0 +1,174 @@ +package upstream + +import ( + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TestRefreshTokenSingleFlightPerAccount 验证同一账号并发 refresh 只打一次上游: +// 上游对 refreshToken 反重放,第二发带已消费的 RT 会撞 12153 并把整条 RT 链作废 +// (issue #215,账号只能人工重登)。单飞失败时本用例会看到 hits>1 且集中报 12153。 +func TestRefreshTokenSingleFlightPerAccount(t *testing.T) { + var hits, inFlight, maxInFlight int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&hits, 1) + n := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if n <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) // 拉长窗口:无单飞时 8 个调用必然并发 + atomic.AddInt32(&inFlight, -1) + return jsonResp(200, `{"code":0,"msg":"ok","data":{"accessToken":"at1","refreshToken":"rt1","expiresIn":3600}}`), nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1} + + const callers = 8 + var wg sync.WaitGroup + errs := make([]error, callers) + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs[i] = c.RefreshToken(a) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("caller %d: %v", i, err) + } + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("upstream refresh hits = %d, want 1 (single-flight)", got) + } + if got := atomic.LoadInt32(&maxInFlight); got != 1 { + t.Errorf("max concurrent refresh = %d, want 1", got) + } + if a.AccessToken != "at1" || a.RefreshToken != "rt1" { + t.Errorf("tokens not updated: at=%q rt=%q", a.AccessToken, a.RefreshToken) + } +} + +// TestRefreshTokenSingleFlightIsPerAccount 验证单飞按账号隔离:不同 UID 的刷新 +// 并行执行,不被同一把锁串行化(否则 keepalive 全量刷新会退化成串行慢刷)。 +func TestRefreshTokenSingleFlightIsPerAccount(t *testing.T) { + var inFlight, maxInFlight int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + n := atomic.AddInt32(&inFlight, 1) + for { + old := atomic.LoadInt32(&maxInFlight) + if n <= old || atomic.CompareAndSwapInt32(&maxInFlight, old, n) { + break + } + } + time.Sleep(50 * time.Millisecond) + atomic.AddInt32(&inFlight, -1) + return jsonResp(200, `{"code":0,"data":{"accessToken":"at1","refreshToken":"rt1","expiresIn":3600}}`), nil + }) + accounts := []*auth.Auth{ + {UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + {UID: "uid-b", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + {UID: "uid-c", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1}, + } + var wg sync.WaitGroup + for _, a := range accounts { + wg.Add(1) + go func(a *auth.Auth) { + defer wg.Done() + if err := c.RefreshToken(a); err != nil { + t.Errorf("refresh %s: %v", a.UID, err) + } + }(a) + } + wg.Wait() + if got := atomic.LoadInt32(&maxInFlight); got < 2 { + t.Errorf("max concurrent refresh across accounts = %d, want >= 2 (per-account single-flight)", got) + } +} + +// TestRefreshAdoptsNewerDiskTokenAfterSessionDead 验证跨进程兜底:内存 RT 已被上游 +// 判 12153,但磁盘上的 auth 文件已被另一进程(如 signin 工具)rotate 过时,采纳 +// 磁盘 token 重试一次即可自救,而不是直接把账号判死。 +func TestRefreshAdoptsNewerDiskTokenAfterSessionDead(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "uid-a.json") + disk := `{"auth":{"accessToken":"at-disk","refreshToken":"rt-disk","expiresAt":1999999999,"domain":"copilot.tencent.com","realm":"cn"},"account":{"uid":"uid-a"}}` + if err := os.WriteFile(fp, []byte(disk), 0o600); err != nil { + t.Fatal(err) + } + + var mu sync.Mutex + var seen []string + c := testClient(func(r *http.Request) (*http.Response, error) { + rt := r.Header.Get("X-Refresh-Token") + mu.Lock() + seen = append(seen, rt) + mu.Unlock() + if rt != "rt-disk" { + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"refresh token is invalid"}`)), + }, nil + } + return jsonResp(200, `{"code":0,"data":{"accessToken":"at2","refreshToken":"rt2","expiresIn":3600}}`), nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1, FilePath: fp} + + if err := c.RefreshToken(a); err != nil { + t.Fatalf("refresh should recover from disk token: %v", err) + } + if a.RefreshToken != "rt2" || a.AccessToken != "at2" { + t.Errorf("tokens not updated after retry: at=%q rt=%q", a.AccessToken, a.RefreshToken) + } + mu.Lock() + defer mu.Unlock() + if len(seen) != 2 || seen[0] != "rt0" || seen[1] != "rt-disk" { + t.Errorf("refresh attempts = %v, want [rt0 rt-disk]", seen) + } +} + +// TestRefreshDoesNotRetryWhenDiskTokenUnchanged 验证兜底不改变失败语义:磁盘 RT 与 +// 内存一致(无跨进程刷新)时,12153 原样返回,不会无限重试打上游。 +func TestRefreshDoesNotRetryWhenDiskTokenUnchanged(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "uid-a.json") + disk := `{"auth":{"accessToken":"at0","refreshToken":"rt0","expiresAt":1,"domain":"copilot.tencent.com","realm":"cn"},"account":{"uid":"uid-a"}}` + if err := os.WriteFile(fp, []byte(disk), 0o600); err != nil { + t.Fatal(err) + } + var hits int32 + c := testClient(func(r *http.Request) (*http.Response, error) { + atomic.AddInt32(&hits, 1) + return &http.Response{ + StatusCode: 401, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"code":12153,"msg":"refresh token is invalid"}`)), + }, nil + }) + a := &auth.Auth{UID: "uid-a", AccessToken: "at0", RefreshToken: "rt0", ExpiresAt: 1, FilePath: fp} + + err := c.RefreshToken(a) + if err == nil { + t.Fatal("want error") + } + if !isSessionDead(err) { + t.Errorf("want ErrSessionDead, got %v", err) + } + if got := atomic.LoadInt32(&hits); got != 1 { + t.Errorf("refresh attempts = %d, want 1 (no retry when disk token unchanged)", got) + } +} From cee697a1f8897e7112faecfc85361413be65f3f5 Mon Sep 17 00:00:00 2001 From: ftf112233 <3391429706@qq.com> Date: Sat, 17 Oct 2026 10:49:24 +0800 Subject: [PATCH 04/88] =?UTF-8?q?fix(pool):=20=E8=BD=AF=E5=86=B7=E5=8D=B4?= =?UTF-8?q?=E5=88=B0=E6=9C=9F=E5=90=8E=E4=B8=8D=E5=86=8D=E9=80=8F=E5=87=BA?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=20reason=EF=BC=8C=E4=BF=AE=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E3=80=8C=E5=8F=AF=E7=94=A8+=E5=8E=86=E5=8F=B2429=E3=80=8D?= =?UTF-8?q?=E5=B9=B6=E6=8E=92=EF=BC=88issue=20#171=20=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E4=B8=80=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 账号级软冷却按 until 到期恢复,但 until/coolKind/reason 字段按设计不主动 清理(NoteSuccess 注释:不碰 until/coolKind,各自到期)。statusOf 无条件 透出 Reason,面板 renderAccounts 又无条件渲染,导致恢复数天的账号在 「可用」旁长期挂着灰色「429 rate limit」,运维无法判断是历史残留还是当前 限流。 按 issue 建议的服务端方案:statusOf 仅在 Cooling/Disabled 时透出 Reason (禁用账号另有 DisabledReason 透出,零回归)。问题二(模型级 6004 双口径) 独立另单处理。 测试:新增 TestStatusReasonHiddenAfterCooldownExpires 钉住「冷却中透出 / 到期不透出」;go build/vet/test ./... 全部通过。 Refs #171 --- internal/pool/pool_test.go | 22 ++++++++++++++++++++++ internal/pool/state.go | 9 ++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go index fe8ec627..bb438240 100644 --- a/internal/pool/pool_test.go +++ b/internal/pool/pool_test.go @@ -327,6 +327,28 @@ func TestDisablePersists(t *testing.T) { } } +// issue #171 问题一:软冷却到期后 until/coolKind/reason 字段按设计不清理, +// statusOf 不应再把过期 reason 透出给面板(避免「可用」旁挂着历史 429)。 +func TestStatusReasonHiddenAfterCooldownExpires(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "u1"}) + // 冷却中:reason 正常透出。 + p.Cooldown("u1", CoolSoft, time.Hour, "429 rate limit") + st, ok := p.Status("u1") + if !ok || !st.Cooling || st.Reason != "429 rate limit" { + t.Fatalf("cooling status=%+v ok=%v", st, ok) + } + // 冷却到期、字段残留(NoteSuccess 不碰 until/coolKind/reason):不再透出。 + p.Cooldown("u1", CoolSoft, -time.Minute, "429 rate limit") + st, ok = p.Status("u1") + if !ok || st.Cooling { + t.Fatalf("expired cooldown should not be cooling: %+v", st) + } + if st.Reason != "" { + t.Fatalf("expired cooldown reason leaked to status: %q", st.Reason) + } +} + func TestReenableIfCredits(t *testing.T) { p := New("") p.Add(&auth.Auth{UID: "u1"}) diff --git a/internal/pool/state.go b/internal/pool/state.go index 65862878..b890c53f 100644 --- a/internal/pool/state.go +++ b/internal/pool/state.go @@ -555,7 +555,6 @@ func (p *Pool) statusOf(uid string, e *entry) Status { CreditsEarliestExpiry: e.creditsEarliestExpiry, CreditsEarliestRemaining: e.creditsEarliestRemaining, Cooling: now.Before(e.until) || now.Before(e.breakerUntil), - Reason: e.reason, Disabled: e.disabled, Paused: e.paused, SuccessCount: e.successCount, @@ -573,6 +572,14 @@ func (p *Pool) statusOf(uid string, e *entry) Status { BreakerFails: e.fails, BreakerUntil: e.breakerUntil, } + // reason 仅在冷却/禁用中透出(issue #171):软冷却按 until 到期恢复, + // 但 until/coolKind/reason 字段按设计不主动清理(NoteSuccess 注释: + // 「不碰 until/coolKind,那些是即时冷却,各自到期」)。无条件透出会让 + // 面板把过期数天的「429 rate limit」与「可用」并排展示,运维无法判断 + // 是历史残留还是当前限流。禁用原因仍由 DisabledReason 透出。 + if st.Cooling || st.Disabled { + st.Reason = e.reason + } if st.Disabled { // 禁用账号透出禁用原因(运维看不到为什么死)。 st.DisabledReason = e.reason From c8e920d2d24f26284f0ccee5e4b1969b834243e2 Mon Sep 17 00:00:00 2001 From: ftf112233 <3391429706@qq.com> Date: Sat, 17 Oct 2026 02:47:33 +0800 Subject: [PATCH 05/88] =?UTF-8?q?fix(server):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=91=8A=E8=AD=A6=E5=81=87=E9=98=B3=E6=80=A7=E2=80=94=E2=80=94?= =?UTF-8?q?miss=20=E6=98=BE=E5=BC=8F=E5=AD=98=E5=9C=A8=E6=97=B6=E5=AF=B9?= =?UTF-8?q?=E7=A7=B0=E5=8F=8D=E6=8E=A8=20hit=EF=BC=88issue=20#170=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上游在接近全命中时可能回 hit=0 + miss=3(prompt 数万的矛盾组合), CacheTokens 只做「hit→miss」单向推导,hit<=0 直接触发「大前缀整段未命中」 WARN,命中率 99.99% 也被告警。 按 issue #170 建议的选项 1 把反向推导补对称: - 流式 CacheTokens:miss 显式存在而 hit 为 0/缺失且 prompt > miss 时, 按 prompt - miss 反推 hit;prompt <= miss 保持 hit=0(真实全 miss)。 - 非流式提取收敛为 cacheTokensFromUsage,口径与流式一致;上游新增 UsagePromptTokens 供反推取 prompt_tokens。 测试:新增 TestStatsReaderCacheTokensDeriveHit / TestCacheTokensFromUsage / TestCacheMissSignalDerivedHitNoWarn;go build/vet/test ./... 全部通过。 Refs #170 --- internal/server/cache_stats_test.go | 75 +++++++++++++++++++++++++++++ internal/server/handler.go | 5 +- internal/server/logging.go | 30 +++++++++++- internal/upstream/usage.go | 8 +++ 4 files changed, 113 insertions(+), 5 deletions(-) diff --git a/internal/server/cache_stats_test.go b/internal/server/cache_stats_test.go index 00479bd1..2f149b5e 100644 --- a/internal/server/cache_stats_test.go +++ b/internal/server/cache_stats_test.go @@ -42,6 +42,81 @@ func TestStatsReaderCacheTokens(t *testing.T) { } } +// issue #170:上游在接近全命中时可能回 hit=0 + miss=3(+ prompt 很大)——按 +// prompt - miss 对称反推 hit,不再误判为整段未命中;hit 字段完全缺失但 miss +// 存在时同理。prompt <= miss 时保持 hit=0(真实的整段未命中,不反推)。 +func TestStatsReaderCacheTokensDeriveHit(t *testing.T) { + // hit=0 + miss=3 + prompt=52703 → hit 反推为 52700。 + s := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s, + `data: {"usage":{"prompt_tokens":52703,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":3}}`, + ) + if hit, miss, ok := s.CacheTokens(); !ok || hit != 52700 || miss != 3 { + t.Fatalf("derived hit: hit=%d miss=%d ok=%v, want 52700/3/true", hit, miss, ok) + } + + // hit 字段完全缺失、只有 miss → 同样反推。 + s2 := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s2, + `data: {"usage":{"prompt_tokens":8500,"prompt_cache_miss_tokens":100}}`, + ) + if hit, miss, ok := s2.CacheTokens(); !ok || hit != 8400 || miss != 100 { + t.Fatalf("miss-only derive: hit=%d miss=%d ok=%v, want 8400/100/true", hit, miss, ok) + } + + // prompt == miss → 真实整段未命中,hit 保持 0。 + s3 := newChatStatsReaderSince(strings.NewReader(""), time.Now()) + feedStatsLines(s3, + `data: {"usage":{"prompt_tokens":8500,"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":8500}}`, + ) + if hit, miss, ok := s3.CacheTokens(); !ok || hit != 0 || miss != 8500 { + t.Fatalf("full miss keep hit=0: hit=%d miss=%d ok=%v, want 0/8500/true", hit, miss, ok) + } +} + +// issue #170 非流式侧:cacheTokensFromUsage 与流式推导口径对称。 +func TestCacheTokensFromUsage(t *testing.T) { + // hit 显式为 0、miss=3、prompt 很大 → 反推 hit=prompt-miss,不误判。 + usage := map[string]any{"prompt_tokens": 52703.0, "prompt_cache_hit_tokens": 0.0, "prompt_cache_miss_tokens": 3.0} + if hit, miss, ok := cacheTokensFromUsage(usage); !ok || hit != 52700 || miss != 3 { + t.Fatalf("hit=%d miss=%d ok=%v, want 52700/3/true", hit, miss, ok) + } + // 正常命中:hit 为正,原样返回。 + usage2 := map[string]any{"prompt_tokens": 1000.0, "prompt_cache_hit_tokens": 900.0, "prompt_cache_miss_tokens": 100.0} + if hit, miss, ok := cacheTokensFromUsage(usage2); !ok || hit != 900 || miss != 100 { + t.Fatalf("hit=%d miss=%d ok=%v, want 900/100/true", hit, miss, ok) + } + // 真实整段未命中(miss == prompt)→ hit 保持 0。 + usage3 := map[string]any{"prompt_tokens": 8500.0, "prompt_cache_miss_tokens": 8500.0} + if hit, miss, ok := cacheTokensFromUsage(usage3); !ok || hit != 0 || miss != 8500 { + t.Fatalf("hit=%d miss=%d ok=%v, want 0/8500/true", hit, miss, ok) + } + // 无任何缓存维度 → ok=false。 + usage4 := map[string]any{"prompt_tokens": 100.0} + if _, _, ok := cacheTokensFromUsage(usage4); ok { + t.Fatal("无缓存字段应 ok=false") + } +} + +// 反推后 hit>0 的观测应只标记 everHit、不触发 WARN(钉住假阳性修复的下游效果)。 +func TestCacheMissSignalDerivedHitNoWarn(t *testing.T) { + old := cacheMissWarnEvery + cacheMissWarnEvery = time.Hour + t.Cleanup(func() { + cacheMissWarnEvery = old + cacheMissWarn = &cacheMissSignal{everHit: map[string]bool{}, last: map[string]time.Time{}} + }) + cacheMissWarn = &cacheMissSignal{everHit: map[string]bool{}, last: map[string]time.Time{}} + + // 先标记模型可命中,再送入「hit=0 + miss=3 + prompt 大」经反推后的观测 + // (hit=52697>0):不应触发 WARN。 + cacheMissWarn.noteCacheTokens("gpt-6-luna", 52703, 52000, 703) + cacheMissWarn.noteCacheTokens("gpt-6-luna", 52703, 52700, 3) + if !cacheMissWarn.last["gpt-6-luna"].IsZero() { + t.Fatal("反推后 hit>0 的观测不应触发 WARN(issue #170 假阳性)") + } +} + // WARN 信号状态机:先命中、再大前缀整段未命中 → 触发一次;冷却内重复未命中 // 不再触发;从未命中的模型(首请求天然全 miss)与小前缀不触发。 func TestCacheMissSignal(t *testing.T) { diff --git a/internal/server/handler.go b/internal/server/handler.go index da160288..9cf11b03 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -1087,9 +1087,8 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { } credit, total, hasCredit := usageCreditTotal(resp) if usage, ok := resp["usage"].(map[string]any); ok { - if hit, okH := upstream.UsageCacheHitTokens(usage); okH { - miss, _ := upstream.UsageCacheMissTokens(usage) - st.cacheHit, st.cacheMiss, st.hasCache = int64(hit), int64(miss), true + if hit, miss, okC := cacheTokensFromUsage(usage); okC { + st.cacheHit, st.cacheMiss, st.hasCache = hit, miss, true } } recordAttempt(acct.UID, usageDeltaFromResponse(resp), credit, hasCredit, attemptStarted, 0) diff --git a/internal/server/logging.go b/internal/server/logging.go index c6577baa..1e9c595a 100644 --- a/internal/server/logging.go +++ b/internal/server/logging.go @@ -209,9 +209,12 @@ func (s *chatStatsReader) parseSSELine(line string) { } // CacheTokens 返回末帧 usage 的缓存命中 / 未命中 token 数。miss 缺失时按 -// prompt - hit 推导;hit 与 miss 均不可得时 ok=false(不参与命中率统计)。 +// prompt - hit 推导;对称地,miss 显式存在而 hit 为 0 / 缺失时按 prompt - miss +// 反推 hit(issue #170:上游在「接近全命中」时可能回 hit=0 + miss=3 这类组合, +// 不反推会被误判为整段未命中)。两个方向都推导不出时 ok=false(不参与命中率 +// 统计)。 func (s *chatStatsReader) CacheTokens() (hit, miss int64, ok bool) { - if !s.hasCacheHit { + if !s.hasCacheHit && !s.hasCacheMiss { return 0, 0, false } hit = int64(s.cacheHit) @@ -221,6 +224,10 @@ func (s *chatStatsReader) CacheTokens() (hit, miss int64, ok bool) { return hit, 0, true } miss = int64(s.promptTokens - s.cacheHit) + } else if s.cacheHit == 0 && s.hasPromptTokens && s.promptTokens > s.cacheMiss { + // hit 为 0 / 缺失但 miss 可信(prompt > miss,即确有命中量)→ 反推。 + // prompt <= miss 时保持 hit=0,那是真实的整段未命中。 + hit = int64(s.promptTokens - s.cacheMiss) } return hit, miss, true } @@ -278,6 +285,25 @@ func parseModelFromBody(body []byte) string { return obj.Model } +// cacheTokensFromUsage 从非流式聚合响应的 usage 里提取缓存命中 / 未命中 token +// 数,与流式侧 CacheTokens 的推导口径对称:hit 不可得(缺失或 0)但 miss 显式 +// 存在时,按 prompt - miss 反推 hit(issue #170);prompt <= miss 时保持 hit=0 +// (真实的整段未命中)。两个方向都不可得时 ok=false。 +func cacheTokensFromUsage(usage map[string]any) (hit, miss int64, ok bool) { + h, okH := upstream.UsageCacheHitTokens(usage) + m, okM := upstream.UsageCacheMissTokens(usage) + switch { + case okH: + return int64(h), int64(m), true + case okM: + if p, okP := upstream.UsagePromptTokens(usage); okP && p > m { + return int64(p - m), int64(m), true + } + return 0, int64(m), true + } + return 0, 0, false +} + // usageDeltaFromResponse 从非流式聚合响应中提取明确存在的 token 字段。 func usageDeltaFromResponse(resp map[string]any) pool.TokenUsageDelta { delta := pool.TokenUsageDelta{} diff --git a/internal/upstream/usage.go b/internal/upstream/usage.go index 74a757fc..57c3c9ff 100644 --- a/internal/upstream/usage.go +++ b/internal/upstream/usage.go @@ -100,6 +100,14 @@ func cloneUsageDetails(usage map[string]any, key string) map[string]any { return out } +// UsagePromptTokens 返回 usage 顶层的 prompt_tokens;缺失或非数值时 ok=false。 +func UsagePromptTokens(usage map[string]any) (float64, bool) { + if usage == nil { + return 0, false + } + return usageNumber(usage, "prompt_tokens") +} + // UsageCacheHitTokens 返回 usage 里的缓存命中 token 数(多别名取最优,口径与 // 回写给客户端的 normalizeUsageCacheAliases 一致)。供网关统计层(usage 桶 / // reqlog)观测命中率使用;usage 缺失该维度时 ok=false。 From 76c3bdbb00460667f17824ac4b3c132f4d4ff932 Mon Sep 17 00:00:00 2001 From: SunshineR04 <2036194266@qq.com> Date: Sat, 17 Oct 2026 21:58:04 +0800 Subject: [PATCH 06/88] =?UTF-8?q?fix(panel):=20=E5=88=B0=E6=9C=9F=E6=8F=90?= =?UTF-8?q?=E9=86=92=E5=BF=AB=E7=85=A7=E4=B8=8E=E3=80=8C=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E3=80=8D=E5=90=8C=E5=8F=A3=E5=BE=84=EF=BC=8C=E6=8C=89=E6=97=A5?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=88=B0=E6=9C=9F=E6=89=B9=E6=AC=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:首页快照只读 CycleEndTime(体验版包的月度周期重置日,如 2026-10-31),而「检查」读 DeductionEndTime(真实失效时刻,2034/2035 年);且快照只存最早单批、前端按精确时间戳 ceil 天数,导致日期、该批数量、今天/明天文案全部错位。 - upstream:新增 UserResourceSnapshot,统一 DeductionEndTime → ExpiredTime → PackageEndTime → CycleEndTime 优先级,返回按自然日聚合的 ExpiryBucket(同日多包合并、升序)。 - pool:Status/state 持久化 credits_expiry_buckets(state.json + Redis 快照),NoteModelCost 按 FEFO 扣减批次并顺延最早批次;SetCreditsSnapshot 为新写入口,保留 SetCreditsDetailed 兼容。 - scheduler/panel:签到、单号余额、后台余额刷新均写入完整批次;StartBalanceRefresh 启动立即刷一轮。 - 前端:快照把批次还原成 packages 形态后复用 expiryRowsHtml,与「检查」逐字节一致(同日按昵称/uid 稳定排序);overview 新增 balance_refresh_sec/balance_refreshed_at,卡片标注「每 5 分钟后台刷新,x 分钟前更新」。 - 测试:upstream 口径/同日合并、pool FEFO+持久化往返、overview 接线、前端快照与检查 parity 与节奏标注,全量 go test ./... 通过。 --- internal/panel/app.js | 117 +++++++++++++++---- internal/panel/frontend_test.go | 151 +++++++++++++++++++++++++ internal/panel/overview_expiry_test.go | 64 +++++++++++ internal/panel/panel.go | 55 ++++++++- internal/pool/cooldown.go | 123 +++++++++++++++----- internal/pool/entry.go | 20 +++- internal/pool/persist.go | 8 ++ internal/pool/pool_test.go | 54 +++++++++ internal/pool/state.go | 67 +++++++++-- internal/scheduler/scheduler.go | 64 ++++++++++- internal/upstream/client.go | 110 ++++++++++++++---- internal/upstream/sse_test.go | 65 +++++++++++ 12 files changed, 806 insertions(+), 92 deletions(-) create mode 100644 internal/panel/overview_expiry_test.go diff --git a/internal/panel/app.js b/internal/panel/app.js index 3d854028..2bfcbf85 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -514,9 +514,11 @@ async function loadOverview(quiet) { $('subMeta').textContent = '运行 ' + (up >= 86400 ? Math.floor(up / 86400) + ' 天 ' : '') + Math.floor(up % 86400 / 3600) + ' 时 ' + Math.floor(up % 3600 / 60) + ' 分'; renderAccounts(d.accounts || []); renderModelLocks(d.model_locks); - // 到期卡片跟随首页统计同步刷新(用同一份响应里的到期快照,不额外发请求)。 - // 已有逐包明细缓存时不再覆盖——明细信息量更大,别把它降级成快照。 - if ($('expBox') && !lastPackages) renderExpirySnapshot(d); + // 到期卡片跟随首页统计同步刷新(用同一份响应里的按日批次,不额外发请求)。 + // 2 分钟内的实时明细最新且更全,保留;过期后由 state 快照接管,避免点过一次 + // 「检查」之后卡片永远停在那一刻的旧数据。 + const liveFresh = lastPackages && (Date.now() - lastPackagesAt) < EXP_FRESH_MS; + if ($('expBox') && !liveFresh) renderExpirySnapshot(d); } catch (e) { if (!quiet) toast(e.message, 'err'); } } @@ -2788,8 +2790,11 @@ function expDaysLeft(dateStr, today) { return Math.round((new Date(dateStr + 'T00:00:00') - today) / 86400000); } -function renderExpiry(d) { - const list = (d.accounts || []); +// expiryRowsHtml 到期提醒的逐账号行渲染。**「检查」与首页快照共用这一个函数**, +// 保证两条数据路径(逐包明细 / state 里的按日批次)输出完全一致:排序、今天/明天 +// 文案、日均需耗、7 天内合计、随后批次都只有一份实现。 +// list 每项形态:{ uid, nickname, packages:[{end_time, remain}], error? }。 +function expiryRowsHtml(list) { const today = new Date(); today.setHours(0, 0, 0, 0); // 按「最近到期」升序排(issue #125)。 // @@ -2809,21 +2814,29 @@ function renderExpiry(d) { return { a, key }; }); keyed.sort((x, y) => { - if (x.key === y.key) return 0; - if (x.key === null) return 1; - if (y.key === null) return -1; - return x.key < y.key ? -1 : 1; + if (x.key !== y.key) { + if (x.key === null) return 1; + if (y.key === null) return -1; + return x.key < y.key ? -1 : 1; + } + if (x.key === null) return 0; + // 同日到期的账号按昵称/uid 兜底排序:两条数据路径(检查的余额降序、 + // overview 的 uid 升序)输入顺序不同,加固定 tie-break 才能保证输出一致。 + const ax = String(x.a.nickname || x.a.uid || ''); + const ay = String(y.a.nickname || y.a.uid || ''); + return ax < ay ? -1 : ax > ay ? 1 : 0; }); - const rows = keyed.map(({ a }) => { + return keyed.map(({ a }) => { + const name = a.nickname || String(a.uid || '').slice(0, 8); if (a.error) { return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '查询失败:' + esc(a.error) + '
'; } const bs = expBatches(a.packages).filter(b => expDaysLeft(b.date, today) >= 0); if (!bs.length) { return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '7 天内无到期积分
'; } const first = bs[0]; @@ -2840,7 +2853,7 @@ function renderExpiry(d) { const rest = bs.slice(1, 4).map(b => '随后 ' + esc(b.date.slice(5)) + ' · ' + fmtTok(b.remain)).join(' ') + more; return '
' + - '' + esc(a.nickname || a.uid.slice(0, 8)) + '' + + '' + esc(name) + '' + '最近到期 ' + esc(first.date) + '(' + dayWord + ')· 该批 ' + fmtTok(first.remain) + ' 积分 · 到期前日均需耗 ≥' + fmtTok(daily) + '' + @@ -2848,6 +2861,11 @@ function renderExpiry(d) { (rest ? '
' + rest + '
' : '') + '
'; }).join(''); +} + +function renderExpiry(d) { + const list = (d.accounts || []); + const rows = expiryRowsHtml(list); $('expList').innerHTML = rows || '
没有账号
'; // 数据新鲜度透明化:走缓存时标注年龄,免得把旧数据误当实时。 const ageMin = lastPackages ? Math.floor((Date.now() - lastPackagesAt) / 60000) : 0; @@ -2857,15 +2875,47 @@ function renderExpiry(d) { $('expBox').hidden = false; } -/* 快照版到期提醒:不查上游,直接用 /overview 已有的 credits_earliest_expiry / - credits_earliest_remaining(由签到与余额后台刷新写入 state.json,默认每 5 分钟 - 更新一次)。打开面板即可看到「最近哪批要过期」,成本与首页统计条相同为零。 +/* 快照版到期提醒:不查上游,直接用 /overview 里的 credits_expiry_buckets。 + 后端每次余额刷新(默认每 5 分钟)按自然日聚合并写入 state.json,口径与 + 「检查」的逐包按日汇总完全相同;这里把批次还原成 packages 形态后直接复用 + expiryRowsHtml,因此排序/今天明天/日均需耗/7 天内合计/随后批次全部一致。 - 与 renderExpiry(逐包明细版)的差异:这里只有**最早一批**的日期与数量,没有 - 后续批次、也没法算「7 天内合计」——那是 packages 接口才有的信息。因此卡片上 - 保留「检查」按钮作为增强入口,用户想要完整明细再显式触发一次上游查询。 */ + 旧 state(升级后尚未跑过一轮余额刷新,或 Redis 快照来自旧版本)没有 buckets, + 退回只显示最早一批,保证卡片不空白;下一轮刷新后自动变成完整视图。 */ function renderExpirySnapshot(d) { const list = (d && d.accounts) || []; + const hasBuckets = list.some(a => Array.isArray(a.credits_expiry_buckets) && a.credits_expiry_buckets.length); + if (hasBuckets) { + const isoDay = v => { + const dt = new Date(v); + if (isNaN(dt.getTime())) return ''; + const p2 = n => String(n).padStart(2, '0'); + return dt.getFullYear() + '-' + p2(dt.getMonth() + 1) + '-' + p2(dt.getDate()); + }; + // 逐账号取批次;升级过渡期/单号刷新失败的账号可能还没有 buckets,用旧的 + // 「最早一批」字段兜底,避免在同一次渲染里被误报成「无到期」。 + const packagesOf = a => { + const bs = a.credits_expiry_buckets || []; + if (bs.length) { + return bs.map(b => ({ end_time: (b.date || '') + 'T00:00:00', remain: b.remain })); + } + const day = isoDay(a.credits_earliest_expiry); + return (day && a.credits_earliest_remaining) + ? [{ end_time: day + 'T00:00:00', remain: a.credits_earliest_remaining }] + : []; + }; + renderExpiry({ + accounts: list.map(a => ({ + uid: a.uid, + nickname: a.nickname, + realm: a.realm, + packages: packagesOf(a), + })), + }); + $('expNote').textContent = snapshotNoteText(list.length, d); + return; + } + // 旧 state 兜底:只有最早一批的日期与数量。 const today = new Date(); today.setHours(0, 0, 0, 0); const keyed = list.map(a => { // 后端给的是 RFC3339 带时区;无到期信息(长期包/未刷新)的账号排在最后。 @@ -2889,12 +2939,16 @@ function renderExpirySnapshot(d) { '' + nm + '' + '无近期到期批次'; } - const days = Math.max(0, Math.ceil((ms - today.getTime()) / 86400000)); + // 与 expDaysLeft 同口径:按本地自然日算,而不是对精确时间戳 ceil(后者会把 + // 明天/今天各多算一天,日均需耗也随之少一半)。 + const dt = new Date(ms); + const p2 = n => String(n).padStart(2, '0'); + const when = dt.getFullYear() + '-' + p2(dt.getMonth() + 1) + '-' + p2(dt.getDate()); + const days = Math.max(0, Math.round((new Date(when + 'T00:00:00') - today) / 86400000)); const remain = Number(a.credits_earliest_remaining || 0); const daily = Math.ceil(remain / Math.max(1, days)); const cls = days <= 3 ? 'var(--bad)' : days <= 7 ? 'var(--warn)' : 'var(--ok)'; const dayWord = days === 0 ? '今天到期' : days === 1 ? '明天到期' : days + ' 天后到期'; - const when = new Date(ms).toISOString().slice(0, 10); return '
' + '' + nm + '' + '最近到期 ' + esc(when) + '(' + dayWord + @@ -2904,10 +2958,29 @@ function renderExpirySnapshot(d) { '
'; }).join(''); $('expList').innerHTML = rows || '
没有账号
'; - $('expNote').textContent = list.length + ' 个账号 · 快照(来自本地状态,点「检查」拉实时明细)'; + $('expNote').textContent = snapshotNoteText(list.length, d); $('expBox').hidden = false; } +// snapshotNoteText 快照卡片的数据新鲜度标注。界面本身 5 秒轮询一次,但卡片数据 +// 只随余额后台刷新更新(默认每 5 分钟)——不标出来的话,5 秒级的界面刷新会让人 +// 误以为数据也是 5 秒级新鲜。放在 renderExpirySnapshot 之后:前端 harness 按 +// 「renderExpirySnapshot 起」切片求值,函数声明提升让它在同一切片内可用。 +function snapshotNoteText(count, d) { + const iv = Number(d && d.balance_refresh_sec) || 0; + const at = d && d.balance_refreshed_at ? new Date(d.balance_refreshed_at).getTime() : 0; + if (iv <= 0) { + return count + ' 个账号 · 快照(来自本地状态 · 未开启后台刷新,点「检查」拉实时明细)'; + } + const every = iv % 60 === 0 ? iv / 60 + ' 分钟' : iv + ' 秒'; + let tail = '每 ' + every + '后台刷新'; + if (at && !isNaN(at)) { + const min = Math.floor((Date.now() - at) / 60000); + tail += ',' + (min < 1 ? '刚刚更新' : min + ' 分钟前更新'); + } + return count + ' 个账号 · 快照(' + tail + ',点「检查」拉实时明细)'; +} + async function loadExpiry(force) { if (!$('expBox')) return; if (expFetching) return; diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index d36912ce..d51fd3d3 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -914,6 +914,157 @@ process.stdout.write(JSON.stringify({ } } +// TestAppJSExpirySnapshotMatchesLive 钉住「打开面板看到的」与「点检查后看到的」逐字节 +// 一致(issue:首页快照与检查用了两套到期口径)。 +// +// 做法:给同一逻辑数据的两条形态——packages(检查)与 credits_expiry_buckets(overview +// 快照)——分别渲染,比较 expList.innerHTML。相等意味着排序、日期、该批合计、日均 +// 需耗、7 天内合计、随后批次全部一致,而不是只对齐了某一个字段。 +func TestAppJSExpirySnapshotMatchesLive(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry parity test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function expBatches'); +const end = src.indexOf('async function loadExpiry'); +if (start < 0 || end < 0 || end < start) throw new Error('expiry region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', children: [], textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, + lastPackages: null, lastPackagesAt: 0, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.renderExpiry = renderExpiry; this.renderExpirySnapshot = renderExpirySnapshot;', ctx); +const iso = n => { + const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + n); + const p = x => String(x).padStart(2, '0'); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); +}; +// 同一天多个包:检查侧是 3 + 100 + 80,快照侧已聚合成 180。 +const live = { accounts: [ + { uid: 'a', nickname: '同日多批', packages: [ + { end_time: iso(0) + 'T09:00:00', remain: 3 }, + { end_time: iso(1) + 'T09:00:00', remain: 100 }, + { end_time: iso(1) + 'T12:00:00', remain: 80 }, + { end_time: iso(2) + 'T09:00:00', remain: 50 }, + ] }, + { uid: 'b', nickname: '远期', packages: [ + { end_time: iso(16) + 'T22:25:00', remain: 1600 }, + { end_time: iso(17) + 'T09:00:00', remain: 400 }, + ] }, + { uid: 'c', nickname: '无到期', packages: [] }, + { uid: 'd', nickname: '明天', packages: [{ end_time: iso(1) + 'T10:00:00', remain: 77 }] }, +] }; +const snap = { accounts: [ + { uid: 'b', nickname: '远期', credits_expiry_buckets: [ + { date: iso(16), remain: 1600 }, + { date: iso(17), remain: 400 }, + ] }, + { uid: 'a', nickname: '同日多批', credits_expiry_buckets: [ + { date: iso(2), remain: 50 }, + { date: iso(1), remain: 180 }, + { date: iso(0), remain: 3 }, + ] }, + { uid: 'c', nickname: '无到期', credits_expiry_buckets: [] }, + { uid: 'd', nickname: '明天', credits_expiry_buckets: [{ date: iso(1), remain: 77 }] }, +] }; +ctx.renderExpiry(live); +const liveHTML = sinks['expList'].innerHTML; +sinks['expList'].innerHTML = ''; +ctx.renderExpirySnapshot(snap); +const snapHTML = sinks['expList'].innerHTML; +process.stdout.write(JSON.stringify({ + equal: liveHTML === snapHTML, + today: liveHTML.includes('今天到期'), + tomorrow: liveHTML.includes('明天到期'), + weekSum: liveHTML.includes('7 天内合计'), + len: liveHTML.length, +}));` + f, err := os.CreateTemp(t.TempDir(), "expparity-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry parity node test failed: %v\n%s", err, out) + } + const want = `{"equal":true,"today":true,"tomorrow":true,"weekSum":true,"len":` + got := strings.TrimSpace(string(out)) + if !strings.HasPrefix(got, want) { + t.Fatalf("expiry parity=%s\nwant prefix %s(快照与检查必须逐字节一致)", got, want) + } +} + +// TestAppJSExpirySnapshotNoteCadence 到期卡片必须如实标注数据节奏与年龄。 +// 界面本身 5 秒轮询一次 overview,但卡片数据只随余额后台刷新更新(默认每 5 分钟); +// 不标注的话,5 秒级的界面刷新会让人误以为数据也是 5 秒级新鲜。 +func TestAppJSExpirySnapshotNoteCadence(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; expiry note test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +// 从 expBatches 切起:renderExpirySnapshot 的按日批次分支要调 renderExpiry, +// 只切它自己会 ReferenceError。 +const start = src.indexOf('function expBatches'); +const end = src.indexOf('async function loadExpiry'); +if (start < 0 || end < 0 || end < start) throw new Error('expiry region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', children: [], textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, Map, Array, Object, isNaN, + lastPackages: null, lastPackagesAt: 0, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.renderExpirySnapshot = renderExpirySnapshot;', ctx); +const refreshedAt = new Date(Date.now() - 3 * 60000).toISOString(); +ctx.renderExpirySnapshot({ accounts: [ + { uid: 'a', nickname: 'n', credits_expiry_buckets: [{ date: '2099-01-01', remain: 1 }] }, +], balance_refresh_sec: 300, balance_refreshed_at: refreshedAt }); +const withCadence = sinks['expNote'].textContent; +ctx.renderExpirySnapshot({ accounts: [ + { uid: 'a', nickname: 'n', credits_expiry_buckets: [{ date: '2099-01-01', remain: 1 }] }, +] }); +const withoutCadence = sinks['expNote'].textContent; +process.stdout.write(JSON.stringify({ + cadence: withCadence.includes('每 5 分钟后台刷新') && withCadence.includes('3 分钟前更新'), + noCadence: withoutCadence.includes('未开启后台刷新'), + note: withCadence, +}));` + f, err := os.CreateTemp(t.TempDir(), "expnote-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("expiry note node test failed: %v\n%s", err, out) + } + const want = `{"cadence":true,"noCadence":true,"note":` + if !strings.HasPrefix(strings.TrimSpace(string(out)), want) { + t.Fatalf("expiry note=%s\nwant prefix %s", strings.TrimSpace(string(out)), want) + } +} + // TestPanelIndexExpiryBoxNotHidden 到期卡片在 HTML 骨架里不得带 hidden 属性(issue #137)。 // // 与上一个用例互补:app.js 侧有 renderExpirySnapshot 兜底,HTML 侧若仍写死 hidden, diff --git a/internal/panel/overview_expiry_test.go b/internal/panel/overview_expiry_test.go new file mode 100644 index 00000000..89150ed0 --- /dev/null +++ b/internal/panel/overview_expiry_test.go @@ -0,0 +1,64 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" +) + +// TestOverviewIncludesExpiryBuckets 钉住面板接线:池内写入的按日到期批次必须原样 +// 出现在 /overview 响应里,否则前端快照只能退回旧的「最早一批」字段(口径错误)。 +// 同时钉住余额刷新节奏/时刻两个字段——前端据此标注「每 5 分钟刷新,x 分钟前更新」。 +func TestOverviewIncludesExpiryBuckets(t *testing.T) { + pl := pool.New("") + sch := scheduler.New(scheduler.Config{Pool: pl}) + sch.SetBalanceInterval(5 * time.Minute) + sch.RunBalanceRefreshNow() // 空池 → 不打上游,只落完成时刻 + + p := New(Config{Version: "test", Pool: pl, Scheduler: sch}) + pl.Add(&auth.Auth{UID: "u1", Nickname: "n1"}) + now := time.Now() + at1 := now.Add(24 * time.Hour) + at2 := now.Add(48 * time.Hour) + pl.SetCreditsSnapshot("u1", pool.CreditsSnapshot{ + Remain: 300, Total: 300, Expiring: 100, + ExpiryBuckets: []pool.ExpiryBucket{ + {Date: at1.Format("2006-01-02"), ExpireAt: at1, Remain: 100}, + {Date: at2.Format("2006-01-02"), ExpireAt: at2, Remain: 200}, + }, + }) + + rec := httptest.NewRecorder() + p.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/panel/api/overview", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("overview status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + BalanceRefreshSec int `json:"balance_refresh_sec"` + BalanceRefreshedAt time.Time `json:"balance_refreshed_at"` + Accounts []struct { + CreditsExpiryBuckets []pool.ExpiryBucket `json:"credits_expiry_buckets"` + } `json:"accounts"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode overview: %v", err) + } + if len(resp.Accounts) != 1 || len(resp.Accounts[0].CreditsExpiryBuckets) != 2 { + t.Fatalf("overview accounts=%+v want 2 expiry buckets", resp.Accounts) + } + if got := resp.Accounts[0].CreditsExpiryBuckets[0]; got.Remain != 100 || got.Date != at1.Format("2006-01-02") { + t.Fatalf("first bucket=%+v", got) + } + if resp.BalanceRefreshSec != 300 { + t.Fatalf("balance_refresh_sec=%d want 300", resp.BalanceRefreshSec) + } + if resp.BalanceRefreshedAt.IsZero() { + t.Fatal("balance_refreshed_at 为零值:前端无法标注数据年龄") + } +} diff --git a/internal/panel/panel.go b/internal/panel/panel.go index e1379825..3a065acb 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -224,6 +224,19 @@ func (p *Panel) expiringSoonWindow() time.Duration { return p.cfg.Scheduler.ExpiringSoonWindow() } +// poolExpiryBuckets 把 upstream 的按日到期批次转换为 pool 结构。两个包字段同构, +// 手工转换是为了不让底层 pool 反向依赖 upstream。 +func poolExpiryBuckets(in []upstream.ExpiryBucket) []pool.ExpiryBucket { + if len(in) == 0 { + return nil + } + out := make([]pool.ExpiryBucket, len(in)) + for i, b := range in { + out[i] = pool.ExpiryBucket{Date: b.Date, ExpireAt: b.ExpireAt, Remain: b.Remain} + } + return out +} + // --------------------------------------------------------------------------- // 只读接口 // --------------------------------------------------------------------------- @@ -253,9 +266,29 @@ func (p *Panel) overview(w http.ResponseWriter, r *http.Request) { // model_locks 模型级限流全清单(哪些模型不能用、锁了几个号、还要锁多久): // 与 accounts 的账号池视图互补,前端「模型锁池」表直接渲染。无锁时为 null。 "model_locks": p.cfg.Pool.ModelLockView(), + // 到期提醒快照的节奏与年龄:界面轮询是 5 秒级,但卡片数据只随余额后台刷新 + // 更新(默认每 5 分钟)——把这两个数透出,前端才能如实标注「x 分钟前更新」。 + "balance_refresh_sec": p.balanceRefreshSec(), + "balance_refreshed_at": p.balanceRefreshedAt(), }) } +// balanceRefreshSec 余额后台刷新间隔(秒);无调度器/未开启时为 0。 +func (p *Panel) balanceRefreshSec() int { + if p.cfg.Scheduler == nil { + return 0 + } + return int(p.cfg.Scheduler.BalanceInterval() / time.Second) +} + +// balanceRefreshedAt 最近一次后台余额刷新完成时刻;无调度器或尚未跑过为零值。 +func (p *Panel) balanceRefreshedAt() time.Time { + if p.cfg.Scheduler == nil { + return time.Time{} + } + return p.cfg.Scheduler.BalanceRefreshedAt() +} + // logsHandler 返回日志环形缓冲快照(时间升序,含频道标记 chat/task/sys)。 func (p *Panel) logsHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"entries": p.logs.Snapshot()}) @@ -540,14 +573,21 @@ func (p *Panel) accountCheckin(w http.ResponseWriter, r *http.Request) { if checkinMsg != "" { resp["checkin_message"] = checkinMsg } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := p.cfg.Upstream.UserResourceSnapshot(a, p.expiringSoonWindow()) if err != nil { resp["balance_error"] = err.Error() writeJSON(w, http.StatusOK, resp) return } p.cfg.Pool.ReenableIfCredits(uid, remain, total) - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + p.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) resp["credits"] = remain resp["credits_total"] = total log.Printf("panel: checkin uid=%s msg=%q credits=%d/%d", uid, checkinMsg, remain, total) @@ -562,12 +602,19 @@ func (p *Panel) accountBalance(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "account not found") return } - remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := p.cfg.Upstream.UserResourceSnapshot(a, p.expiringSoonWindow()) if err != nil { writeErr(w, http.StatusBadGateway, "user resource: "+err.Error()) return } - p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + p.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) writeJSON(w, http.StatusOK, map[string]any{"ok": true, "credits": remain, "credits_total": total}) } diff --git a/internal/pool/cooldown.go b/internal/pool/cooldown.go index 90c29eb7..ae6e669c 100644 --- a/internal/pool/cooldown.go +++ b/internal/pool/cooldown.go @@ -5,6 +5,7 @@ package pool import ( "log" + "sort" "strings" "time" ) @@ -63,40 +64,106 @@ func (p *Pool) NoteCheckinDone(uid string) { } } -// SetCreditsDetailed 更新账号余额/总额、配置窗口内的快过架子集,以及最早未来 -// 到期批次。earliestAt 为零或不在未来时清空最早批次;expiring/earliestRemaining -// 均钳到 [0, credits],避免上游脏数据污染选号。 -func (p *Pool) SetCreditsDetailed(uid string, credits, total, expiring int64, earliestAt time.Time, earliestRemaining int64) { +// CreditsSnapshot 一次余额查询的完整结果(含按日到期批次)。 +// 调度器/面板把 upstream.UserResourceSnapshot 的输出转换成本结构后写入池。 +type CreditsSnapshot struct { + Remain int64 + Total int64 + Expiring int64 + EarliestAt time.Time + EarliestRemaining int64 + ExpiryBuckets []ExpiryBucket +} + +// SetCreditsSnapshot 更新账号余额/总额、窗口内快过期子集、最早到期批次与按日到期批次。 +// 有 buckets 时以 buckets 为准重算最早批次(同一天多个包合计);无 buckets 时沿用 +// 入参 earliestAt/earliestRemaining(兼容旧调用与测试)。所有数值钳到 [0, credits], +// 避免上游脏数据污染选号。 +func (p *Pool) SetCreditsSnapshot(uid string, s CreditsSnapshot) { p.mu.Lock() defer p.mu.Unlock() - if e, ok := p.byUID[uid]; ok { - if credits < 0 { - credits = 0 - } - if expiring < 0 { - expiring = 0 - } - if expiring > credits { - expiring = credits - } - now := time.Now() - if earliestRemaining < 0 { - earliestRemaining = 0 + e, ok := p.byUID[uid] + if !ok { + return + } + credits := s.Remain + if credits < 0 { + credits = 0 + } + expiring := s.Expiring + if expiring < 0 { + expiring = 0 + } + if expiring > credits { + expiring = credits + } + now := time.Now() + buckets := sanitizeExpiryBuckets(s.ExpiryBuckets, now) + earliestAt, earliestRemaining := s.EarliestAt, s.EarliestRemaining + if len(buckets) > 0 { + earliestAt = buckets[0].ExpireAt + earliestRemaining = buckets[0].Remain + } + if earliestRemaining < 0 { + earliestRemaining = 0 + } + if earliestRemaining > credits { + earliestRemaining = credits + } + if earliestAt.IsZero() || !earliestAt.After(now) || earliestRemaining == 0 { + earliestAt = time.Time{} + earliestRemaining = 0 + } + e.credits = credits + e.creditsTotal = s.Total + e.creditsExpiring = expiring + e.creditsEarliestExpiry = earliestAt + e.creditsEarliestRemaining = earliestRemaining + e.expiryBuckets = buckets + p.dirty.Store(true) +} + +// SetCreditsDetailed 旧入口:只有聚合余额与最早一批,没有按日批次。 +// 保留给测试与只关心最早批次的调用方;生产路径统一走 SetCreditsSnapshot。 +func (p *Pool) SetCreditsDetailed(uid string, credits, total, expiring int64, earliestAt time.Time, earliestRemaining int64) { + p.SetCreditsSnapshot(uid, CreditsSnapshot{ + Remain: credits, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + }) +} + +// sanitizeExpiryBuckets 深拷贝并清洗:只保留有日期、未过期、正剩余的桶,按日期升序。 +// ExpireAt 缺失时按日期补当天 00:00(本地时区),保证排序与最早批次仍可用。 +func sanitizeExpiryBuckets(in []ExpiryBucket, now time.Time) []ExpiryBucket { + if len(in) == 0 { + return nil + } + today := now.Format("2006-01-02") + out := make([]ExpiryBucket, 0, len(in)) + for _, b := range in { + if b.Remain <= 0 || b.Date == "" || b.Date < today { + continue } - if earliestRemaining > credits { - earliestRemaining = credits + if b.ExpireAt.IsZero() { + if t, err := time.ParseInLocation("2006-01-02", b.Date, time.Local); err == nil { + b.ExpireAt = t + } } - if earliestAt.IsZero() || !earliestAt.After(now) || earliestRemaining == 0 { - earliestAt = time.Time{} - earliestRemaining = 0 + if b.ExpireAt.IsZero() || !b.ExpireAt.After(now) { + continue } - e.credits = credits - e.creditsTotal = total - e.creditsExpiring = expiring - e.creditsEarliestExpiry = earliestAt - e.creditsEarliestRemaining = earliestRemaining - p.dirty.Store(true) + out = append(out, b) } + sort.Slice(out, func(i, j int) bool { + if out[i].Date != out[j].Date { + return out[i].Date < out[j].Date + } + return out[i].ExpireAt.Before(out[j].ExpireAt) + }) + return out } // ClearExpiringSnapshots 清空所有账号的快过期/最早到期缓存。配置窗口改变时调用, diff --git a/internal/pool/entry.go b/internal/pool/entry.go index a29d339c..c9b2cb63 100644 --- a/internal/pool/entry.go +++ b/internal/pool/entry.go @@ -59,6 +59,14 @@ type TokenUsageDelta struct { TokensPerSecond float64 } +// ExpiryBucket 单个自然日到期的积分合计(面板「到期提醒」快照用)。 +// 与 upstream.ExpiryBucket 同构;单独定义是为了不让底层 pool 反向依赖 upstream。 +type ExpiryBucket struct { + Date string `json:"date"` + ExpireAt time.Time `json:"expires_at,omitempty"` + Remain int64 `json:"remain"` +} + // Status 单个账号对外暴露的状态(脱敏)。 type Status struct { UID string `json:"uid"` @@ -71,7 +79,11 @@ type Status struct { CreditsExpiring int64 `json:"credits_expiring,omitempty"` CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"` CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"` - Cooling bool `json:"cooling"` + // CreditsExpiryBuckets 全部未来到期批次按自然日聚合(date/expires_at/remain)。 + // 面板首页「积分到期提醒」快照直接用它渲染,与「检查」的逐包按日汇总同口径; + // 只有最早一批的旧字段无法表达「同一天多个包」「后续批次」「7 天内合计」。 + CreditsExpiryBuckets []ExpiryBucket `json:"credits_expiry_buckets,omitempty"` + Cooling bool `json:"cooling"` CoolKind string `json:"cool_kind,omitempty"` CoolRemaining int64 `json:"cool_remaining_sec,omitempty"` Until time.Time `json:"until,omitempty"` @@ -189,6 +201,9 @@ type entry struct { creditsExpiring int64 creditsEarliestExpiry time.Time creditsEarliestRemaining int64 + // expiryBuckets 全部未来到期批次按自然日聚合(升序)。供面板快照展示与 + // NoteModelCost 的 FEFO 近似扣减;由 SetCreditsSnapshot 整体替换。 + expiryBuckets []ExpiryBucket successCount int64 // 累计成功 errTotal int64 // 累计错误(供成功率权重 successRate = successCount/(successCount+errTotal),不清零) lastErr time.Time // 最近一次错误时间 @@ -463,6 +478,9 @@ type stateAccount struct { // 与 CreditsExpiring 一并持久化,重启后首次请求仍可沿用最近一次余额快照。 CreditsEarliestExpiry time.Time `json:"credits_earliest_expiry,omitempty"` CreditsEarliestRemaining int64 `json:"credits_earliest_remaining,omitempty"` + // CreditsExpiryBuckets 全部未来到期批次按自然日聚合。持久化后重启即可用完整 + // 快照渲染首页卡片;恢复时惰性剔除已过期/零剩余条目。 + CreditsExpiryBuckets []ExpiryBucket `json:"credits_expiry_buckets,omitempty"` // ModelCooldowns 模型级独立冷却表(model → 冷却记录:6004 重置墙钟 / 11102 // 负缓存退避)。持久化:6004 对齐上游重置墙钟后单模型冷却可长达数小时, // 跨重启是常态;不持久化会导致 healthyForModel 重启失忆、重新踩雷区。 diff --git a/internal/pool/persist.go b/internal/pool/persist.go index eb7b02bb..069ed150 100644 --- a/internal/pool/persist.go +++ b/internal/pool/persist.go @@ -176,6 +176,13 @@ func (p *Pool) applyAccountsLocked(accounts map[string]stateAccount) { e.creditsEarliestExpiry = time.Time{} e.creditsEarliestRemaining = 0 } + // 按日到期批次:惰性剔除已过期/零剩余条目;有批次时以批次为准同步最早批次, + // 保证重启后首页快照与「检查」仍同口径。 + e.expiryBuckets = sanitizeExpiryBuckets(s.CreditsExpiryBuckets, now) + if len(e.expiryBuckets) > 0 { + e.creditsEarliestExpiry = e.expiryBuckets[0].ExpireAt + e.creditsEarliestRemaining = e.expiryBuckets[0].Remain + } // 熔断器持久化恢复:breakerUntil 未过期才恢复(过期不复活),retryCount 仅在 // 熔断仍有效时保留(否则归零,不保留无用退避指数)。 if s.BreakerUntil != nil && now.Before(*s.BreakerUntil) { @@ -303,6 +310,7 @@ func (p *Pool) stateOverviewLocked() stateFile { CreditsExpiring: e.creditsExpiring, CreditsEarliestExpiry: e.creditsEarliestExpiry, CreditsEarliestRemaining: e.creditsEarliestRemaining, + CreditsExpiryBuckets: append([]ExpiryBucket(nil), e.expiryBuckets...), } // 熔断截止:仅未过期才落盘(指针 nil 才能被 omitempty 真省略)。 if !e.breakerUntil.IsZero() && now.Before(e.breakerUntil) { diff --git a/internal/pool/pool_test.go b/internal/pool/pool_test.go index bb438240..bc5a8950 100644 --- a/internal/pool/pool_test.go +++ b/internal/pool/pool_test.go @@ -1867,6 +1867,60 @@ func TestCreditExpirySnapshotConsumptionAndClear(t *testing.T) { } } +// TestExpiryBucketsFEFOAndPersistence 钉住按日到期批次的三件事: +// 1. 最早批次以 buckets 为准(同日多包合计已在写入前完成),而不是入参单批值; +// 2. 两次余额刷新之间按 FEFO 扣减,扣穿第一桶后最早批次顺延到下一桶; +// 3. state.json / Redis 快照往返不丢批次,重启后首页快照仍与「检查」同口径。 +func TestExpiryBucketsFEFOAndPersistence(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "u1"}) + now := time.Now() + at1 := now.Add(24 * time.Hour) + at2 := now.Add(48 * time.Hour) + p.SetCreditsSnapshot("u1", CreditsSnapshot{ + Remain: 300, Total: 300, Expiring: 100, + // 故意给一个与 buckets 不一致的最早值:buckets 必须优先。 + EarliestAt: at1, EarliestRemaining: 999, + ExpiryBuckets: []ExpiryBucket{ + {Date: at1.Format("2006-01-02"), ExpireAt: at1, Remain: 100}, + {Date: at2.Format("2006-01-02"), ExpireAt: at2, Remain: 200}, + }, + }) + st, _ := p.Status("u1") + if len(st.CreditsExpiryBuckets) != 2 || st.CreditsEarliestRemaining != 100 || !st.CreditsEarliestExpiry.Equal(at1) { + t.Fatalf("initial buckets=%+v earliest=%v/%d", st.CreditsExpiryBuckets, st.CreditsEarliestExpiry, st.CreditsEarliestRemaining) + } + + // 扣 150:第一桶扣穿(100),第二桶剩 150,最早批次顺延到 at2。 + p.NoteModelCost("u1", "m", 150, 1000) + st, _ = p.Status("u1") + if st.Credits != 150 || len(st.CreditsExpiryBuckets) != 1 || st.CreditsExpiryBuckets[0].Remain != 150 { + t.Fatalf("after consume buckets=%+v credits=%d", st.CreditsExpiryBuckets, st.Credits) + } + if !st.CreditsEarliestExpiry.Equal(at2) || st.CreditsEarliestRemaining != 150 { + t.Fatalf("earliest after consume=%v/%d want %v/150", st.CreditsEarliestExpiry, st.CreditsEarliestRemaining, at2) + } + + // 持久化往返。 + p.mu.Lock() + sf := p.stateOverviewLocked() + p.mu.Unlock() + if got := sf.Accounts["u1"].CreditsExpiryBuckets; len(got) != 1 || got[0].Remain != 150 { + t.Fatalf("persisted buckets=%+v", got) + } + p2 := New("") + p2.mu.Lock() + p2.applyAccountsLocked(sf.Accounts) + p2.mu.Unlock() + st2, ok := p2.Status("u1") + if !ok || len(st2.CreditsExpiryBuckets) != 1 || st2.CreditsExpiryBuckets[0].Remain != 150 { + t.Fatalf("restored buckets=%+v", st2.CreditsExpiryBuckets) + } + if !st2.CreditsEarliestExpiry.Equal(at2) || st2.CreditsEarliestRemaining != 150 { + t.Fatalf("restored earliest=%v/%d", st2.CreditsEarliestExpiry, st2.CreditsEarliestRemaining) + } +} + // ---------- 暂停选号(paused)---------- // // paused 与 disabled 正交:两者都退出选号候选,但 paused 是「临时让位」—— diff --git a/internal/pool/state.go b/internal/pool/state.go index b890c53f..bed28497 100644 --- a/internal/pool/state.go +++ b/internal/pool/state.go @@ -139,11 +139,12 @@ func (p *Pool) ReenableIfCredits(uid string, remain, total int64) { e.credits = remain e.creditsTotal = total } - // ReenableIfCredits 只有聚合余额上下文;到期明细必须由 SetCreditsDetailed + // ReenableIfCredits 只有聚合余额上下文;到期明细必须由 SetCreditsSnapshot // 重新写入,不能沿用旧窗口/旧批次的缓存。 e.creditsExpiring = 0 e.creditsEarliestExpiry = time.Time{} e.creditsEarliestRemaining = 0 + e.expiryBuckets = nil p.dirty.Store(true) } } @@ -233,14 +234,7 @@ func (p *Pool) NoteModelCost(uid, model string, credit float64, tokens int) { e.creditsExpiring -= d } } - if e.creditsEarliestRemaining > 0 { - if d >= e.creditsEarliestRemaining { - e.creditsEarliestRemaining = 0 - e.creditsEarliestExpiry = time.Time{} - } else { - e.creditsEarliestRemaining -= d - } - } + p.deductExpiryLocked(e, d) } if e.modelCost == nil { e.modelCost = make(map[string]modelCostEntry) @@ -264,6 +258,58 @@ func (p *Pool) NoteModelCost(uid, model string, credit float64, tokens int) { p.dirty.Store(true) // 账本已持久化:写入口统一置脏 } +// deductExpiryLocked 按 FEFO 近似扣减到期快照:先从最早的自然日桶扣,扣穿再顺延。 +// 有 buckets 时同步重算 earliestAt/earliestRemaining(下一桶的 ExpireAt 成为新最早批次), +// 使两次余额刷新之间的首页快照仍随用量收敛;无 buckets(旧状态/测试只写了聚合字段) +// 时退回只扣最早一批。调用方必须已持有 p.mu。 +func (p *Pool) deductExpiryLocked(e *entry, d int64) { + if d <= 0 { + return + } + if len(e.expiryBuckets) == 0 { + if e.creditsEarliestRemaining > 0 { + if d >= e.creditsEarliestRemaining { + e.creditsEarliestRemaining = 0 + e.creditsEarliestExpiry = time.Time{} + } else { + e.creditsEarliestRemaining -= d + } + } + return + } + left := d + for i := range e.expiryBuckets { + if left == 0 { + break + } + b := &e.expiryBuckets[i] + if b.Remain <= 0 { + continue + } + take := b.Remain + if take > left { + take = left + } + b.Remain -= take + left -= take + } + // 清掉被扣穿的桶(保持升序;切片原地过滤)。 + out := e.expiryBuckets[:0] + for _, b := range e.expiryBuckets { + if b.Remain > 0 { + out = append(out, b) + } + } + e.expiryBuckets = out + if len(e.expiryBuckets) > 0 { + e.creditsEarliestExpiry = e.expiryBuckets[0].ExpireAt + e.creditsEarliestRemaining = e.expiryBuckets[0].Remain + } else { + e.creditsEarliestExpiry = time.Time{} + e.creditsEarliestRemaining = 0 + } +} + // RecordTokenUsage 记录一次实际发起的聊天账号尝试及上游返回的 usage 增量。 // usage 字段缺失时仍累计请求次数,但只累计明确存在的 token 字段。 func (p *Pool) RecordTokenUsage(uid string, delta TokenUsageDelta) { @@ -554,6 +600,9 @@ func (p *Pool) statusOf(uid string, e *entry) Status { CreditsExpiring: e.creditsExpiring, CreditsEarliestExpiry: e.creditsEarliestExpiry, CreditsEarliestRemaining: e.creditsEarliestRemaining, + // 拷贝一份:List 返回后 JSON 序列化在锁外进行,不能与 NoteModelCost 的 + // 原地扣减共享底层数组(数据竞争)。 + CreditsExpiryBuckets: append([]ExpiryBucket(nil), e.expiryBuckets...), Cooling: now.Before(e.until) || now.Before(e.breakerUntil), Disabled: e.disabled, Paused: e.paused, diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 163694fa..dce8661c 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -82,6 +82,11 @@ type Scheduler struct { // balanceInterval 余额刷新间隔(纳秒,0=暂停)。atomic 读写:执行循环每轮读当前值, // SetBalanceInterval 可任意时刻热改(面板保存配置)。 balanceInterval atomic.Int64 + + // lastBalanceRefresh 最近一次后台余额刷新的完成时刻(Unix 纳秒,0=尚未跑过)。 + // 供面板「到期提醒」标注数据年龄:卡片展示的是这份快照,必须让年龄可读, + // 否则界面 5 秒轮询会让人误以为数据也是 5 秒级新鲜。 + lastBalanceRefresh atomic.Int64 } // New 构建。 @@ -485,14 +490,22 @@ func (s *Scheduler) RunCheckinNow() { log.Printf("checkin %s: 签到成功", logfmt.Label(st.UID, st.Nickname)) } } - // 分桶查余额:配置窗口内的积分单独标记,同时记录最早未来到期批次。 - remain, total, expiring, earliestAt, earliestRemaining, err := s.cfg.Upstream.UserResourceDetailedWithExpiry(a, expiringSoon) + // 分桶查余额:配置窗口内的积分单独标记,同时记录最早未来到期批次与 + // 按自然日聚合的全部批次(首页快照与「检查」共用同一口径)。 + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := s.cfg.Upstream.UserResourceSnapshot(a, expiringSoon) if err != nil { log.Printf("user-resource %s: %v", logfmt.Label(st.UID, st.Nickname), err) continue } s.cfg.Pool.ReenableIfCredits(st.UID, remain, total) - s.cfg.Pool.SetCreditsDetailed(st.UID, remain, total, expiring, earliestAt, earliestRemaining) + s.cfg.Pool.SetCreditsSnapshot(st.UID, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) } s.RunStreakBonusNow() } @@ -604,6 +617,19 @@ func (s *Scheduler) RunKeepaliveNow() { } } +// poolExpiryBuckets 把 upstream 的按日到期批次转换为 pool 结构。两个包字段同构, +// 手工转换是为了不让底层 pool 反向依赖 upstream。 +func poolExpiryBuckets(in []upstream.ExpiryBucket) []pool.ExpiryBucket { + if len(in) == 0 { + return nil + } + out := make([]pool.ExpiryBucket, len(in)) + for i, b := range in { + out[i] = pool.ExpiryBucket{Date: b.Date, ExpireAt: b.ExpireAt, Remain: b.Remain} + } + return out +} + // RunBalanceRefreshNow 并发查询余额并更新池内 credits。 // 账号范围:默认跳过禁用账号;schedule.include_disabled_in_tasks 打开时一并刷新 // (轮换用法下据此判断下一个该启用谁)。注意 ReenableIfCredits 对 disabled 是 @@ -626,16 +652,24 @@ func (s *Scheduler) RunBalanceRefreshNow() { wg.Add(1) go func(a *auth.Auth, uid string) { defer wg.Done() - remain, total, expiring, earliestAt, earliestRemaining, err := s.cfg.Upstream.UserResourceDetailedWithExpiry(a, expiringSoon) + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := s.cfg.Upstream.UserResourceSnapshot(a, expiringSoon) if err != nil { log.Printf("balance %s: %v", logfmt.Label(uid, a.Nickname), err) return } s.cfg.Pool.ReenableIfCredits(uid, remain, total) - s.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) - }(a, st.UID) + s.cfg.Pool.SetCreditsSnapshot(uid, pool.CreditsSnapshot{ + Remain: remain, + Total: total, + Expiring: expiring, + EarliestAt: earliestAt, + EarliestRemaining: earliestRemaining, + ExpiryBuckets: poolExpiryBuckets(buckets), + }) + }(a, st.UID) } wg.Wait() + s.lastBalanceRefresh.Store(time.Now().UnixNano()) } // StartBalanceRefresh 后台周期性余额刷新(独立 ticker goroutine,ctx 取消即停)。 @@ -648,6 +682,10 @@ func (s *Scheduler) StartBalanceRefresh(ctx context.Context, interval time.Durat } s.balanceInterval.Store(int64(interval)) go func() { + // 启动即刷一轮:进程重启(含升级到带按日批次的版本)后旧 state 可能没有 + // credits_expiry_buckets;不等第一个 ticker,首页快照就能拿到与「检查」 + // 同口径的完整数据,避免升级后前 5 分钟仍显示旧口径。 + s.RunBalanceRefreshNow() var logged time.Duration for { cur := time.Duration(s.balanceInterval.Load()) @@ -686,3 +724,17 @@ func (s *Scheduler) SetBalanceInterval(d time.Duration) { s.balanceInterval.Store(int64(d)) poke(s.rearmBalance) } + +// BalanceInterval 当前余额后台刷新间隔(0=暂停)。面板 overview 据此告诉用户 +// 「到期提醒」卡片的数据节奏,而不是让 5 秒界面轮询冒充数据新鲜度。 +func (s *Scheduler) BalanceInterval() time.Duration { + return time.Duration(s.balanceInterval.Load()) +} + +// BalanceRefreshedAt 最近一次后台余额刷新的完成时刻(零值 = 尚未跑过)。 +func (s *Scheduler) BalanceRefreshedAt() time.Time { + if v := s.lastBalanceRefresh.Load(); v != 0 { + return time.Unix(0, v) + } + return time.Time{} +} diff --git a/internal/upstream/client.go b/internal/upstream/client.go index 79549feb..ffba7e1a 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -1883,6 +1883,33 @@ type CreditPackage struct { Cycle bool `json:"cycle,omitempty"` } +// ExpiryBucket 单个自然日到期的积分合计(按真实失效时刻 DeductionEndTime 聚合)。 +// 面板首页「积分到期提醒」快照与「检查」(逐包明细)共用同一口径: +// - Date 为该日(UTC+8,YYYY-MM-DD),前端按这个日期分组展示; +// - ExpireAt 是该日最早一批的精确时刻,供「最早到期优先」选号路由使用; +// - Remain 是该日所有正余额包的剩余量之和。 +type ExpiryBucket struct { + Date string `json:"date"` + ExpireAt time.Time `json:"expires_at,omitempty"` + Remain int64 `json:"remain"` +} + +// packageEffectiveEnd 返回套餐的真实失效时刻:DeductionEndTime(可抵扣窗口结束, +// epoch 毫秒)优先,缺失依次回落 ExpiredTime / PackageEndTime / CycleEndTime。 +// 与 CreditPackages 的字段优先级必须一致——快照与逐包明细两条路径口径分叉过一次 +// (快照把体验版的周期重置日 10-31 当成到期日,实际 DeductionEndTime 在 2035 年)。 +func packageEffectiveEnd(deductionEndMS int64, expired, packageEnd, cycleEnd string) (time.Time, bool) { + if deductionEndMS > 0 { + return time.UnixMilli(deductionEndMS).In(softRateResetLoc), true + } + for _, raw := range []string{expired, packageEnd, cycleEnd} { + if t, ok := parsePackageEndTime(raw); ok { + return t, true + } + } + return time.Time{}, false +} + // CreditPackages 返回账号当前的逐包构成。remain/size 为各包求和。 // // 字段选择与 UserResourceDetailed 的聚合口径一致:CycleCapacitySize > 0 时按 @@ -1949,25 +1976,19 @@ func (c *Client) CreditPackages(a *auth.Auth) ([]CreditPackage, int64, int64, er SubProductCode: p.SubProductCode, SubProductName: p.SubProductName, } + // EndTime 先给一个可读回落值(解析失败时前端仍可按字符串取日期); + // 真实失效时刻统一由 packageEffectiveEnd 判定,与快照路径同口径。 switch { - case p.DeductionEndTime > 0: - // 真失效时刻(可抵扣窗口结束),语义见上方字段注释:判「用不完就没了」 - // 用它而不是周期边界。epoch 毫秒 → RFC3339,与 CycleEndTime 字符串口径 - // 共存(前端统一 slice(0,10) 取日期)。ExpiresAt 直接用原始毫秒—— - // RFC3339 不是 packageEndLayout 形态,交给下方解析会静默失败得 0。 - cp.EndTime = time.UnixMilli(p.DeductionEndTime).Format(time.RFC3339) - cp.ExpiresAt = p.DeductionEndTime case p.ExpiredTime != "": cp.EndTime = p.ExpiredTime case p.PackageEndTime != "": cp.EndTime = p.PackageEndTime - default: + case p.CycleEndTime != "": cp.EndTime = p.CycleEndTime } - if cp.ExpiresAt == 0 && cp.EndTime != "" { - if end, perr := time.ParseInLocation(packageEndLayout, cp.EndTime, softRateResetLoc); perr == nil { - cp.ExpiresAt = end.UnixMilli() - } + if end, ok := packageEffectiveEnd(p.DeductionEndTime, p.ExpiredTime, p.PackageEndTime, p.CycleEndTime); ok { + cp.EndTime = end.Format(time.RFC3339) + cp.ExpiresAt = end.UnixMilli() } // CreateTime 是 epoch 毫秒;0 表示上游没给,留空而不是伪造 1970。 if p.CreateTime > 0 { @@ -2133,13 +2154,34 @@ func (c *Client) enterpriseResource(a *auth.Auth, soon time.Duration) (remain, t } // UserResourceDetailedWithExpiry 在 UserResourceDetailed 基础上返回最早未来到期批次: -// earliestAt 是最早的可用到期时刻,earliestRemaining 是同一时刻所有正余额包的剩余量之和。 -// 已过期、剩余为 0、缺少或无法解析到期时间的包都不会成为最早批次;无有效批次时返回零值。 +// earliestAt 是最早的可用到期时刻,earliestRemaining 是该自然日全部正余额包的剩余量 +// 之和(与面板「检查」按日汇总的口径一致)。已过期、剩余为 0、缺少或无法解析到期时间 +// 的包都不会成为最早批次;无有效批次时返回零值。 func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, err error) { + remain, total, expiring, earliestAt, earliestRemaining, _, err = c.UserResourceSnapshot(a, soon) + return +} + +// UserResourceSnapshot 在 UserResourceDetailedWithExpiry 基础上额外返回按自然日聚合的 +// 到期批次(buckets)。日期/剩余量口径与面板「检查」(CreditPackages 前端按日汇总) +// 完全一致,首页快照据此可零上游请求渲染出同样的内容。 +// +// 关键:到期时刻统一走 packageEffectiveEnd(DeductionEndTime 优先)。此前本函数只读 +// CycleEndTime,把「个人体验版」的月度周期重置日(如 2026-10-31)当成了到期日,而它 +// 真实的可抵扣窗口到 2034/2035 年——首页快照因此长期错报最近到期时间与数量。 +func (c *Client) UserResourceSnapshot(a *auth.Auth, soon time.Duration) (remain, total, expiring int64, earliestAt time.Time, earliestRemaining int64, buckets []ExpiryBucket, err error) { // 企业版额度不在「个人资源包」体系内:get-user-resource* 对 enterpriseId 非空账号 // 恒返回空 Accounts(实测 code 0 且 Accounts null),故分流到企业口径端点。 if a.IsEnterprise() { - return c.enterpriseResource(a, soon) + remain, total, expiring, earliestAt, earliestRemaining, err = c.enterpriseResource(a, soon) + if err == nil && earliestRemaining > 0 && !earliestAt.IsZero() && earliestAt.After(time.Now()) { + buckets = []ExpiryBucket{{ + Date: earliestAt.In(softRateResetLoc).Format("2006-01-02"), + ExpireAt: earliestAt, + Remain: earliestRemaining, + }} + } + return } now := time.Now() body := map[string]any{ @@ -2159,7 +2201,7 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration return e }) if err != nil { - return 0, 0, 0, time.Time{}, 0, err + return 0, 0, 0, time.Time{}, 0, nil, err } var resp struct { Response struct { @@ -2167,6 +2209,9 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration Accounts []struct { PackageName string `json:"PackageName"` CycleEndTime string `json:"CycleEndTime"` // "2006-01-02 15:04:05",缺省/空 = 无到期 + ExpiredTime string `json:"ExpiredTime"` + PackageEndTime string `json:"PackageEndTime"` + DeductionEndTime int64 `json:"DeductionEndTime"` // 可抵扣窗口结束(epoch 毫秒),真失效时刻 CapacitySize int64 `json:"CapacitySize"` CapacityRemain int64 `json:"CapacityRemain"` CapacityUsed int64 `json:"CapacityUsed"` @@ -2178,8 +2223,9 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration } `json:"Response"` } if err := json.Unmarshal(data, &resp); err != nil { - return 0, 0, 0, time.Time{}, 0, fmt.Errorf("resource parse: %w", err) + return 0, 0, 0, time.Time{}, 0, nil, fmt.Errorf("resource parse: %w", err) } + byDate := make(map[string]*ExpiryBucket) for _, acct := range resp.Response.Data.Accounts { r, _, size := packageRemainUsed(respAccount{ CapacityRemain: acct.CapacityRemain, @@ -2200,22 +2246,42 @@ func (c *Client) UserResourceDetailedWithExpiry(a *auth.Auth, soon time.Duration if r <= 0 { continue } - end, ok := parsePackageEndTime(acct.CycleEndTime) + end, ok := packageEffectiveEnd(acct.DeductionEndTime, acct.ExpiredTime, acct.PackageEndTime, acct.CycleEndTime) if !ok || !end.After(now) { continue } if earliestAt.IsZero() || end.Before(earliestAt) { earliestAt = end - earliestRemaining = r - } else if end.Equal(earliestAt) { - earliestRemaining += r } + // 按自然日(UTC+8)聚合:与 CreditPackages → 前端 end_time.slice(0,10) 同口径。 + day := end.In(softRateResetLoc).Format("2006-01-02") + b, seen := byDate[day] + if !seen { + b = &ExpiryBucket{Date: day, ExpireAt: end} + byDate[day] = b + } else if end.Before(b.ExpireAt) { + b.ExpireAt = end + } + b.Remain += r // 分桶:仅 soon>0 且确实在窗口内 → expiring。 if soon > 0 && !end.After(now.Add(soon)) { expiring += r } } - return remain, total, expiring, earliestAt, earliestRemaining, nil + dates := make([]string, 0, len(byDate)) + for d := range byDate { + dates = append(dates, d) + } + sort.Strings(dates) + buckets = make([]ExpiryBucket, 0, len(dates)) + for _, d := range dates { + buckets = append(buckets, *byDate[d]) + } + // earliestRemaining 是该自然日全部正余额包的合计(不是单个包的剩余量)。 + if len(buckets) > 0 { + earliestRemaining = buckets[0].Remain + } + return remain, total, expiring, earliestAt, earliestRemaining, buckets, nil } // respAccount 供 packageRemainUsed 解析的套餐字段(CreditPackages 的逐包结构同构)。 diff --git a/internal/upstream/sse_test.go b/internal/upstream/sse_test.go index 1bd1b1c2..8700d4d8 100644 --- a/internal/upstream/sse_test.go +++ b/internal/upstream/sse_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -933,6 +934,70 @@ func TestCreditPackagesExpiryTimestamp(t *testing.T) { } } +// TestUserResourceSnapshotDeductionEndTime 钉住快照与「检查」同口径: +// 1. 体验版包必须用 DeductionEndTime(真失效时刻),不能把 CycleEndTime(周期 +// 重置日)当成到期日——线上表现就是 10-31 错报、实际 2035 年才失效; +// 2. 同一天多个包要合并成一个自然日批次,earliestRemaining 是该日合计; +// 3. CreditPackages(检查)与 UserResourceSnapshot(首页快照)对同一个包给出 +// 同一个失效时刻。 +func TestUserResourceSnapshotDeductionEndTime(t *testing.T) { + now := time.Now().In(softRateResetLoc) + today := now + day2 := time.Date(today.Year(), today.Month(), today.Day()+2, 9, 0, 0, 0, softRateResetLoc) + day2Later := day2.Add(3 * time.Hour) // 同一天 12:00 + cycleOnly := day2.Add(14 * 24 * time.Hour) + far := time.Date(today.Year()+9, today.Month(), today.Day(), 9, 0, 0, 0, softRateResetLoc) + payload := `{"code":0,"data":{"Response":{"Data":{"Accounts":[` + + `{"PackageName":"gift-a","CycleCapacitySize":100,"CycleCapacityRemain":100,"CycleEndTime":"` + day2.Format(packageEndLayout) + `"},` + + `{"PackageName":"gift-b","CycleCapacitySize":50,"CycleCapacityRemain":50,"CycleEndTime":"` + day2Later.Format(packageEndLayout) + `"},` + + `{"PackageName":"experience","CycleCapacitySize":500,"CycleCapacityRemain":488,"CycleEndTime":"` + + cycleOnly.Format(packageEndLayout) + `","DeductionEndTime":` + strconv.FormatInt(far.UnixMilli(), 10) + `}` + + `]}}}}` + c := testClient(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/v2/billing/meter/get-user-resource") { + return nil, errors.New("wrong path: " + r.URL.Path) + } + return jsonResp(200, payload), nil + }) + + remain, total, expiring, earliestAt, earliestRemaining, buckets, err := c.UserResourceSnapshot( + &auth.Auth{AccessToken: "at", UID: "u1"}, 72*time.Hour, + ) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if remain != 638 || total != 650 { + t.Fatalf("remain/total=%d/%d want 638/650", remain, total) + } + // 体验版不在 72h 窗口内;只有 day2 的两个包计入。 + if expiring != 150 { + t.Fatalf("expiring=%d want 150", expiring) + } + if !earliestAt.Equal(day2) || earliestRemaining != 150 { + t.Fatalf("earliest=%v/%d want %v/150(同日两包应合计)", earliestAt, earliestRemaining, day2) + } + if len(buckets) != 2 { + t.Fatalf("buckets=%+v want 2 个自然日", buckets) + } + day := day2.In(softRateResetLoc).Format("2006-01-02") + if buckets[0].Date != day || buckets[0].Remain != 150 || !buckets[0].ExpireAt.Equal(day2) { + t.Fatalf("bucket[0]=%+v want date=%s remain=150 expire=%v", buckets[0], day, day2) + } + if buckets[1].Remain != 488 || !buckets[1].ExpireAt.Equal(far) { + t.Fatalf("experience bucket=%+v want remain=488 expire=%v(不是 CycleEndTime %v)", buckets[1], far, cycleOnly) + } + + packs, _, _, err := c.CreditPackages(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("packages: %v", err) + } + for _, p := range packs { + if p.Name == "experience" && p.ExpiresAt != far.UnixMilli() { + t.Fatalf("CreditPackages experience expires_at=%d want %d(两条路径必须同口径)", p.ExpiresAt, far.UnixMilli()) + } + } +} + // --- 标记修复(流式端到端,见 dsml.go)------------------------------------- // upstreamSSEWithContent 用给定的正文分片拼一条上游 SSE:role 首帧 + N 个正文帧 From 618845d75acfab59acd3a0f2ff205e31e1fc9981 Mon Sep 17 00:00:00 2001 From: jiang An <755243672@qq.com> Date: Mon, 12 Oct 2026 15:13:58 +0800 Subject: [PATCH 07/88] Add files via upload (cherry picked from commit 19c355a0a4166dc4364a9506086e66305ce1fb6f) --- internal/upstream/payload.go | 52 ++++++++++++++ internal/upstream/payload_test.go | 111 ++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/internal/upstream/payload.go b/internal/upstream/payload.go index abd48de2..d18d1713 100644 --- a/internal/upstream/payload.go +++ b/internal/upstream/payload.go @@ -52,6 +52,7 @@ func PrepareBodyOptWithEffortsAndDefault(src []byte, sanitize bool, efforts map[ normalizeToolPatterns(obj) normalizeRoles(obj) normalizeImageURL(obj) + dropReasoningContentParts(obj) // tool 配对三步(见 tool_pairing.go):先合并再重排再清理。所有模型一律执行(独立于 // deepseek-only 的 sanitize 开关)。这是「让请求通过」的安全网——不完整配对的 // tool_calls/tool 结果会让上游对之后每条消息都返 400,必须先行剔除; @@ -291,6 +292,57 @@ func normalizeImageURL(obj map[string]any) { } } +// dropReasoningContentParts 剔除 chat content 数组里的 reasoning_text / reasoning 片段。 +// +// Codex 只走 /v1/responses:new-api 的 responses→chat 转换器 +// (openai_responses_to_openai_chat_completions)会把助手历史里的思考片段原样透传进 +// /v1/chat/completions。上游 content 类型白名单不含 reasoning_text,命中即 HTTP 400 +// code=11101 "unsupported content type at index 0: reasoning_text"。 +// +// 思考片段只是模型私有推理,下游续写不需要,剔除无副作用: +// - 删除 type 为 reasoning_text / reasoning 的片段(TrimSpace 后比较) +// - 若删除后 content 为空且该消息无 tool_calls,整条消息一并删除 +func dropReasoningContentParts(obj map[string]any) { + msgs, ok := obj["messages"].([]any) + if !ok { + return + } + out := make([]any, 0, len(msgs)) + for _, rawMsg := range msgs { + msg, ok := rawMsg.(map[string]any) + if !ok { + out = append(out, rawMsg) + continue + } + parts, ok := msg["content"].([]any) + if !ok { + out = append(out, rawMsg) + continue + } + kept := make([]any, 0, len(parts)) + for _, rawPart := range parts { + part, ok := rawPart.(map[string]any) + if !ok { + kept = append(kept, rawPart) + continue + } + ptype, _ := part["type"].(string) + if strings.TrimSpace(ptype) == "reasoning_text" || strings.TrimSpace(ptype) == "reasoning" { + continue + } + kept = append(kept, rawPart) + } + if len(kept) == 0 { + if _, hasToolCalls := msg["tool_calls"]; !hasToolCalls { + continue + } + } + msg["content"] = kept + out = append(out, rawMsg) + } + obj["messages"] = out +} + // ensureConsoleSystem global realm 兜底 system 注入(吸收 PR #45,防 console 域上游 code 11-128): // 首条消息非 system 时在 messages 最前补一条 fallback system("You are a helpful assistant.")。 // 仅对 global 请求调用(CN 现状不动;即使首条就是 system 也不重复注入)。 diff --git a/internal/upstream/payload_test.go b/internal/upstream/payload_test.go index 250b6f7b..bb370f7e 100644 --- a/internal/upstream/payload_test.go +++ b/internal/upstream/payload_test.go @@ -465,3 +465,114 @@ func TestClampGPTMinMaxTokens(t *testing.T) { }) } } + +// TestDropReasoningContentParts 覆盖思考片段剥离规则本身(直调,隔离其它归一化步骤)。 +// 背景:Codex 只走 /v1/responses,new-api 的 responses→chat 转换器会把助手历史里的 +// reasoning_text 原样透传进 /v1/chat/completions;上游 content 类型白名单不含它, +// 命中即 HTTP 400 code=11101 "unsupported content type at index 0: reasoning_text"。 +func TestDropReasoningContentParts(t *testing.T) { + cases := []struct { + name string + body string + want string // 期望的 messages JSON(null 表示字段缺失) + }{ + { + "reasoning_text 剥离、text 保留", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"},{"type":"text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"text","text":"ans"}]}]`, + }, + { + "reasoning 别名同样剥离", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning","text":"think"},{"type":"output_text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"output_text","text":"ans"}]}]`, + }, + { + "type 前后空白同样命中", + `{"messages":[{"role":"assistant","content":[{"type":" reasoning_text ","text":"think"},{"type":"text","text":"ans"}]}]}`, + `[{"role":"assistant","content":[{"type":"text","text":"ans"}]}]`, + }, + { + "剥离后 content 为空且无 tool_calls:整条消息删除", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"}]},{"role":"user","content":"hi"}]}`, + `[{"role":"user","content":"hi"}]`, + }, + { + "剥离后 content 为空但有 tool_calls:消息保留(空数组 content)", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"think"}],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec_command","arguments":"{}"}}]}]}`, + `[{"role":"assistant","content":[],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec_command","arguments":"{}"}}]}]`, + }, + { + "字符串 content 不受影响", + `{"messages":[{"role":"assistant","content":"plain"},{"role":"user","content":"hi"}]}`, + `[{"role":"assistant","content":"plain"},{"role":"user","content":"hi"}]`, + }, + { + "非对象片段原样保留", + `{"messages":[{"role":"assistant","content":["raw",{"type":"text","text":"t"}]}]}`, + `[{"role":"assistant","content":["raw",{"type":"text","text":"t"}]}]`, + }, + { + "messages 缺失不 panic", + `{"model":"deepseek-v4.1-flash"}`, + `null`, + }, + { + "messages 非数组不 panic", + `{"messages":"weird"}`, + `"weird"`, + }, + { + "全部消息均被剥离:messages 变空数组", + `{"messages":[{"role":"assistant","content":[{"type":"reasoning_text","text":"t"}]}]}`, + `[]`, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + obj, err := decodeBody([]byte(c.body)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + dropReasoningContentParts(obj) + var want any + if err := json.Unmarshal([]byte(c.want), &want); err != nil { + t.Fatalf("unmarshal want: %v", err) + } + if !reflect.DeepEqual(obj["messages"], want) { + t.Fatalf("messages = %#v, want %#v", obj["messages"], want) + } + }) + } +} + +// TestPrepareBodyDropsReasoningTextParts 全链路断言:PrepareBodyOptWithEfforts 管线已挂接 +// 剥离步骤,Codex 历史报文里的 reasoning_text 不再出站(sanitize 两态一致)。 +func TestPrepareBodyDropsReasoningTextParts(t *testing.T) { + body := `{"model":"deepseek-v4.1-flash","messages":[ + {"role":"user","content":"hi"}, + {"role":"assistant","content":[{"type":"reasoning_text","text":"think"},{"type":"text","text":"ans"}]}, + {"role":"user","content":"again"} + ]}` + for _, sanitize := range []bool{false, true} { + out := PrepareBodyOptWithEfforts([]byte(body), sanitize, nil) + if strings.Contains(string(out), "reasoning_text") { + t.Fatalf("sanitize=%v: reasoning_text 未剥离: %s", sanitize, out) + } + obj, err := decodeBody(out) + if err != nil { + t.Fatalf("sanitize=%v unmarshal: %v", sanitize, err) + } + msgs := obj["messages"].([]any) + if len(msgs) != 3 { + t.Fatalf("sanitize=%v: messages 数 = %d, want 3", sanitize, len(msgs)) + } + content := msgs[1].(map[string]any)["content"].([]any) + if len(content) != 1 { + t.Fatalf("sanitize=%v: 助手 content 片段数 = %d, want 1", sanitize, len(content)) + } + if got := content[0].(map[string]any)["text"]; got != "ans" { + t.Fatalf("sanitize=%v: 保留片段 text = %v, want ans", sanitize, got) + } + } +} From 69071f249d279155043c364b8b847bebfb678522 Mon Sep 17 00:00:00 2001 From: YahooYuan666 Date: Fri, 16 Oct 2026 00:51:47 +0800 Subject: [PATCH 08/88] fix(upstream): classify 6021 queue.full as channel-level busy, stop pool-wide cooldown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起因(2026-10-15 实案):腾讯 CodeBuddy 推理集群队列打满时返回 HTTP 429 + {"code":6021,"msg":"queue.full.title"}。旧 Classify 未识别 6021, 落入通用 status==429 兜底 → ErrSoftRate → 逐号 CooldownSoftRate(600s) + 轮转换号: 2 秒内把整池 DeepSeek 健康号全部误杀冷却,且换号白撞把无效请求放大 MaxRotate 倍。 这是渠道级容量事件,不是账号问题——同一时刻换任何账号都撞同一堵墙。 处置语义: - 识别:Classify 在 status==429 兜底之前判 6021(结构化 code 主判据 + "queue.full" 文案外卡,与 14018 的具体优先哲学一致),新增 ErrUpstreamBusy。 - 不罚号:applyErrorPolicy 零动作(无冷却/熔断/NoteError/连败),账号余额 与 session 本就健康。 - 不轮转:chatCompletions 内 fail-fast,503 + upstream_busy 透传上游原文 (含 requestId),客户端 SDK 按退避自然重试。 - hint:GatewayHint 增加 upstream_busy 文案(明确「非账号问题、等待后重试」)。 测试:Classify 用例 7 条(code/嵌套/文案/换状态码/防过宽)+ handler 端到端 1 条(零轮转/零惩罚/503+upstream_busy)。go test ./... 全绿。 (cherry picked from commit f14f3742efb56a76376df191ff089665bbe136ee) --- internal/server/handler.go | 29 +++++++++++++++++++++ internal/server/handler_test.go | 43 ++++++++++++++++++++++++++++++++ internal/upstream/client.go | 40 +++++++++++++++++++++++------ internal/upstream/client_test.go | 12 +++++++++ internal/upstream/hint.go | 4 +++ 5 files changed, 120 insertions(+), 8 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 9cf11b03..fbb19950 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -942,6 +942,24 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.outcome = reqlog.OutcomeHTTPError return } + // 6021「queue.full.title」渠道级容量满载:**不罚号不轮转**,立即透传 + // 上游原文回客户端(503 + upstream_busy)。上游集群排队打满时换任何 + // 账号都撞同一堵墙——按限流处置会逐号软冷却,实测 2 秒绞杀整池健康号 + // (2026-10-15 实案),且换号白撞把无效请求放大 MaxRotate 倍。客户端 + // 按 SDK 退避重试即可;等待窗口过去渠道恢复即成功。 + if kind == upstream.ErrUpstreamBusy { + h.applyErrorPolicy(acct.UID, kind, string(respBody), bareModel, uerr) + fail(acct.UID) + msg := string(respBody) + if strings.TrimSpace(msg) == "" { + msg = "upstream capacity is full (queue.full); retry after a short wait" + } + writeOpenAIErrorHint(w, http.StatusServiceUnavailable, "upstream_busy", msg, + h.hintOf(upstream.ErrUpstreamBusy, string(respBody), bareModel, reqHasImage, uerr)) + st.status = http.StatusServiceUnavailable + st.outcome = reqlog.OutcomeHTTPError + return + } // lastErr 携带完整 body(uerr.Msg 在 upstream 侧截断 200 字符,透传语义 // 要求原文全量)+ Kind/RetryAfter(末端映射与冷却时长共用)。 lastErr = &upstream.Error{Kind: kind, Status: status, Msg: string(respBody), RetryAfter: uerr.RetryAfter} @@ -1150,6 +1168,12 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { code = "waf_ip_blocked" msg = "waf ip-level block: upstream firewall is blocking the gateway IP, rotation stopped; retry after the block window expires" } + case upstream.ErrUpstreamBusy: + // 6021 queue.full 渠道级容量满载(防御路径:正常已在轮转循环内 + // fail-fast 拦截)。503 + 可重试语义——上游原文(下方统一透传) + // 含 requestId,客户端按 SDK 退避重试即可。 + code = "upstream_busy" + msg = "upstream capacity is full (queue.full); retry after a short wait" case upstream.ErrModelBlocked: // 11102「该后端无此模型」:上游原文(下方统一透传)已经写清了原因,但 // code 此前停在默认的 no_healthy_account —— 那是"服务端过载"的语义, @@ -1417,6 +1441,11 @@ func (h *Handler) applyErrorPolicy(uid string, kind upstream.ErrKind, body, mode // 不罚账号(无冷却/熔断/NoteError,同 ErrContentBlocked 待遇);chatCompletions // 已 fail-fast 400 透传原文、终止轮转——「换号可能有不同模型权限」属 11102 // (ErrModelBlocked)的分类域,与本类无关。 + case upstream.ErrUpstreamBusy: + // 6021「queue.full.title」渠道级容量满载:账号余额健康、未限流、session 未死, + // 换号也撞同一堵墙。零动作(不冷却/不熔断/不 NoteError/不喂连败,同 + // ErrContentBlocked 待遇);chatCompletions 已 fail-fast 503 透传原文、 + // 终止轮转——该分支只为文档完备。 case upstream.ErrModelBlocked: // 11102「该后端无此模型」:(账号, 模型) 负缓存避让。复用 modelCooldowns 机制 // (与 6004 同域),选号侧 healthyForModel 对该账号自动避开该模型。 diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 0ccab5bd..85d532e8 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -167,6 +167,49 @@ func TestChatBadParamsFailsFastWithoutPenalty(t *testing.T) { } } +// TestChatQueueFullFailsFastWithoutPenalty 上游 429 + code 6021「queue.full.title」 +// 是渠道级容量满载(推理集群排队打满),**不是账号问题**(2026-10-15 实案:按 +// 限流逐号软冷却,2 秒绞杀整池健康号)。端到端断言:不轮转(只打一次上游)、 +// 直接回 503 + upstream_busy(可重试语义)、账号零惩罚(无冷却/无熔断/无连败)。 +func TestChatQueueFullFailsFastWithoutPenalty(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + return 429, `{"code":6021,"msg":"queue.full.title","requestId":"b1bc87ea3a771e835e388328713d47cf"}`, false + }) + p := testPoolWith( + &auth.Auth{UID: "busy1", AccessToken: "at-busy1", ExpiresAt: 9999999999}, + &auth.Auth{UID: "busy2", AccessToken: "at-busy2", ExpiresAt: 9999999999}, + ) + p.SetCredits("busy1", 2000, 0) // 确定性源 r=0 → 先选 busy1 + p.SetCredits("busy2", 1000, 0) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"glm-5.2","messages":[]}`))) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("code=%d body=%s (want 503: channel-level busy, client should back off and retry)", rec.Code, rec.Body) + } + if calls["Bearer at-busy1"] != 1 || calls["Bearer at-busy2"] != 0 { + t.Errorf("calls=%v want busy1 1 次、busy2 0 次(零轮转)", calls) + } + // 账号完好:无冷却、无熔断计数、无连败降权。 + st, _ := p.Status("busy1") + if st.Cooling || st.Disabled || st.ErrTotal != 0 || st.BreakerFails != 0 { + t.Errorf("ErrUpstreamBusy must not penalize account: %+v", st) + } + // 响应体:上游原文透传(requestId 在场)+ upstream_busy code。 + body := rec.Body.String() + if !strings.Contains(body, "6021") || !strings.Contains(body, "queue.full.title") { + t.Errorf("503 message should carry upstream 6021 info: %s", body) + } + if !strings.Contains(body, "upstream_busy") { + t.Errorf("503 body should carry upstream_busy code: %s", body) + } + if strings.Contains(body, "no_healthy_account") { + t.Errorf("channel-busy failure must not be reported as account exhaustion: %s", body) + } +} + // TestChatBadParams400CarriesUpstreamBody 11101 的 400 响应必须包含上游原始 // 11101 信息(含 requestId,客户端据此排查),且不再出现空洞的 no_healthy_account。 func TestChatBadParams400CarriesUpstreamBody(t *testing.T) { diff --git a/internal/upstream/client.go b/internal/upstream/client.go index ffba7e1a..d3865258 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -42,6 +42,7 @@ const ( ErrWafBlock // 403 + 非业务信封体(APISIX WAF 拦截页/空体)→ 账号软冷却 + 抖动退避 ErrPromptTooLong // 11115「prompt is too long」→ 请求级错误(上下文超限是请求的问题非账号的问题):不罚号、不轮转,末端透传原文 ErrImageInvalid // 图片请求格式/数据无效 → 请求级错误:不罚号、不轮转,末端透传原文 + ErrUpstreamBusy // 6021「queue.full.title」渠道级容量满载(非账号问题)→ 不罚号、不轮转,末端 503 + upstream_busy ErrClient // 其他 4xx / 业务错误 ) @@ -69,6 +70,8 @@ func (k ErrKind) String() string { return "prompt_too_long" case ErrImageInvalid: return "image_invalid" + case ErrUpstreamBusy: + return "upstream_busy" case ErrAccountFault: return "account_fault" case ErrClient: @@ -123,6 +126,14 @@ var softRateMarkers = []string{ var sessionDeadMarkers = []string{"Offline user session not found", "12153"} +// isQueueFullBody 判定渠道级容量满载文案(6021 queue.full.title 家族)。 +// 该形态表明上游推理集群排队队列打满,与具体账号无关;换号在任何时刻都会撞 +// 同一堵墙,正确处置是不罚号不轮转(见 Classify 的 6021 分支)。子串口径但 +// 用完整固定文案("queue.full"),避免与普通限流文案误撞。 +func isQueueFullBody(lower string) bool { + return strings.Contains(lower, "queue.full") || strings.Contains(lower, "queue full") +} + // accountFaultMarkers 账号级授权/配额故障关键词(大小写不敏感子串匹配)。 // // 定位:这类错误是**账号本身状态**决定的本机故障,不是请求格式、不是临时限流、 @@ -472,21 +483,25 @@ func ParseRateReset(body string) (time.Time, bool) { // 不受影响。 // 4. 429 + code 14018 —— 明确的账号积分耗尽,归 ErrHardCredit(issue #175)。 // 只认结构化业务码,不靠可能跨计费/限流两界的文案猜测。 -// 5. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带 +// 5. 429 + 6021 queue.full —— 渠道级容量满载(上游集群排队打满),归 +// ErrUpstreamBusy:非账号问题,不罚号不轮转(2026-10-15 实案:按限流处置 +// 2 秒绞杀整池 DeepSeek 号)。判在 status==429 兜底**之前**——它是比 +// 「限流」更具体的渠道级信号(同 14018 的具体优先哲学)。 +// 6. status==429 —— 限流状态码兜底(先于 hardMarkers):429 body 高频携带 // "quota exceeded"/"额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把 // 限流误归 ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词 // 更权威的信号;真正的余额耗尽由 402(第 1 层)或 14018(第 4 层)捕获, -// 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 6 层)。 -// 6. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。 -// 7. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。 +// 非 429 状态码的 quota 措辞仍走下方 hardMarkers(第 7 层)。 +// 7. hardMarkers —— 非 429 响应携带计费关键词(200 业务信封 / 403 信封等)。 +// 8. softRateMarkers —— 非 429 状态码携带限流文案(issue #28 修复点)。 // 位于此处可覆盖 200/400/403/5xx 各状态码。 -// 8. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底 +// 9. 11115 —— 「prompt is too long」请求级语义:判在 404/5xx 与通用 4xx 兜底 // 之前(404 上打 11115 若落 ErrNotFound 会误冷却账号——上下文超限与账号无关)。 -// 9. 404 / 5xx —— 与限流无关的常规分类。 -// 10. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF +// 10. 404 / 5xx —— 与限流无关的常规分类。 +// 11. IsWafBlocked —— 403 且无业务信封(HTML 拦截页/空体/纯文本):APISIX WAF // 拦截形态。判在通用 4xx 兜底**之前**:此前该形态落 ErrClient → 只换号不罚 → // 连环 403。带业务信封的 403 已被上方各层捕获,走不到本层。 -// 11. 内容策略/参数错误/其他 4xx —— 通用兜底。 +// 12. 内容策略/参数错误/其他 4xx —— 通用兜底。 func Classify(status int, body string) ErrKind { // 11102「该后端无此模型」须最先判:它是「模型在后端不存在」的确定性答复,语义比 // 计费/限流都更具体——若不先判,msg 里的 "service info not found" 会被更宽的 @@ -519,6 +534,15 @@ func Classify(status int, body string) ErrKind { if status == http.StatusTooManyRequests && hasBusinessCode(body, "14018") { return ErrHardCredit } + // 6021「queue.full.title」是**渠道级容量满载**(上游推理集群排队队列打满), + // 不是账号问题:同一时刻换任何账号都会撞同一堵墙。必须先于通用 429 兜底, + // 否则会按 ErrSoftRate 逐号软冷却——实测 2 秒内即可把整池健康号全部误杀 + // 冷却(2026-10-15 实案),且换号白撞放大请求量。429 + 结构化 code 为主判据 + // (与 14018 同口径);"queue.full" 固定文案单列为外卡——该文案是上游 i18n + // key 原文,语义唯一,非 429 状态码出现同样归本类(防上游换状态码)。 + if (status == http.StatusTooManyRequests && hasBusinessCode(body, "6021")) || isQueueFullBody(lower) { + return ErrUpstreamBusy + } // status==429 先于 hardMarkers:限流响应 body 高频携带 "quota exceeded"/ // "额度不足" 等跨计费/限流两界的措辞,hardMarkers 先判会把限流误归 // ErrHardCredit 硬冷却到次日 04:00,白扔号约 12h。状态码是比关键词更权威的 diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 0d67bd7b..2439635b 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -95,6 +95,18 @@ func TestClassify(t *testing.T) { {429, `{"error":{"data":{"code":"14018","msg":"Credits exhausted"}}}`, ErrHardCredit}, {429, `{"requestId":"14018","msg":"Credits exhausted"}`, ErrSoftRate}, {429, `{"code":1,"msg":"Credits exhausted"}`, ErrSoftRate}, + // 6021「queue.full.title」渠道级容量满载(2026-10-15 实案):不是账号问题, + // 必须归 ErrUpstreamBusy(不罚号不轮转)——按 ErrSoftRate 逐号软冷却会在 + // 秒级把整池健康号误杀。结构化 code 与固定文案双判据。 + {429, `{"code":6021,"msg":"queue.full.title","requestId":"b1bc87ea3a771e835e388328713d47cf"}`, ErrUpstreamBusy}, + {429, `{"error":{"data":{"code":6021,"msg":"queue.full.title"}}}`, ErrUpstreamBusy}, + {429, `queue.full.title`, ErrUpstreamBusy}, + {429, `{"code":6021,"msg":"queue full"}`, ErrUpstreamBusy}, + // 文案外卡不绑 429(上游若换状态码仍须识别为渠道满载,不得落 hardMarkers 族)。 + {500, `{"code":6021,"msg":"queue.full.title"}`, ErrUpstreamBusy}, + // 防过宽:普通限流文案不带 queue.full,仍走软限流。 + {429, `{"code":1,"msg":"too many requests"}`, ErrSoftRate}, + {429, `{"requestId":"6021","msg":"rate limit"}`, ErrSoftRate}, // WAF 403(P0-1):403 + 无业务信封(无 "code":/"msg": 字段)→ ErrWafBlock。 // 空体 / HTML 拦截页 / 纯文本 / 非信封 JSON 均命中。 {403, ``, ErrWafBlock}, diff --git a/internal/upstream/hint.go b/internal/upstream/hint.go index 55bfa248..99dfaf2a 100644 --- a/internal/upstream/hint.go +++ b/internal/upstream/hint.go @@ -59,6 +59,10 @@ func GatewayHint(kind ErrKind, msg string, ctx HintContext) string { return "account session expired at upstream; the account is disabled until re-login" case ErrHardCredit: return "account credits exhausted at upstream; waiting for daily check-in to restore" + case ErrUpstreamBusy: + // 6021 queue.full:渠道级容量满载。措辞明确「不是账号问题、换号无意义」, + // 指引客户端等待后重试(SDK 指数退避天然适配)。 + return "upstream capacity is full (queue.full); this is a channel-level issue, not an account problem; retry after a short wait" case ErrModelBlocked: return "upstream has no such model on this backend; switch model or retry on another account" case ErrContentBlocked: From 7759e815dd7c3a69c712012733f57b1b05bb389d Mon Sep 17 00:00:00 2001 From: zhiqiangme <90136554+zhiqiangme@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:52:14 -0700 Subject: [PATCH 09/88] =?UTF-8?q?fix(server):=20/v1/models=20=E6=8B=89?= =?UTF-8?q?=E5=8F=96=20CN=20=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E8=AE=B0=E5=BD=95=20WARN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前失败只进负缓存、不留日志,CN 域不可达时网关只列 global 模型却毫无线索。 负缓存期内直接返回,不会刷屏。 (cherry picked from commit b54dabec6f025166946ca72f70c282a4996fae43) --- internal/server/handler.go | 7 ++++ internal/server/models_fetch_log_test.go | 48 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 internal/server/models_fetch_log_test.go diff --git a/internal/server/handler.go b/internal/server/handler.go index fbb19950..fd4fb89e 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -481,6 +481,13 @@ func (h *Handler) fetchDynamicModels() []upstream.ModelInfo { // 拉取失败只进负缓存(5min lastFail),不 NoteError:NoteError 喂的是 chat // 熔断器,models 端点偶发 5xx 跨界惩罚 chat 通道健康的账号; // models 拉取失败 ≠ 账号 chat 不可用。 + // 只在真正打上游失败时记一行(负缓存期内前面已直接 return,不会刷屏): + // 此前这里静默,CN 域不可达时 /v1/models 只剩 global 模型却毫无线索。 + if err == nil { + err = fmt.Errorf("models api returned empty list") + } + log.Printf("WARN: [server] /v1/models: CN 模型目录拉取失败(%s 内不再重试,cn: 模型暂不列出): %v", + modelsFetchFailCooldown, err) dynamicModelsCache.Lock() dynamicModelsCache.lastFail = time.Now() dynamicModelsCache.Unlock() diff --git a/internal/server/models_fetch_log_test.go b/internal/server/models_fetch_log_test.go new file mode 100644 index 00000000..a3037350 --- /dev/null +++ b/internal/server/models_fetch_log_test.go @@ -0,0 +1,48 @@ +package server + +import ( + "bytes" + "log" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// CN 模型目录拉取失败必须留下一行 WARN(此前静默,排障无线索),且负缓存期内 +// 的后续请求不重复记录。 +func TestModelsFetchFailureLogsOncePerCooldown(t *testing.T) { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = nil + dynamicModelsCache.fetched = time.Time{} + dynamicModelsCache.lastFail = time.Time{} + dynamicModelsCache.Unlock() + + var buf bytes.Buffer + prev := log.Writer() + log.SetOutput(&buf) + t.Cleanup(func() { log.SetOutput(prev) }) + + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return 500, `boom`, false + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up}) + + for i := 0; i < 3; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if rec.Code != 200 { + t.Fatalf("req %d: code=%d", i, rec.Code) + } + } + const marker = "/v1/models: CN 模型目录拉取失败" + if got := strings.Count(buf.String(), marker); got != 1 { + t.Fatalf("want 1 failure log line, got %d; log:\n%s", got, buf.String()) + } + if !strings.Contains(buf.String(), "status 500") { + t.Errorf("failure log should carry upstream error; log:\n%s", buf.String()) + } +} From 5a69e1ffe5e161e6c1d12d5c881984cc9ca0a57a Mon Sep 17 00:00:00 2001 From: vu1Art1st Date: Sun, 18 Oct 2026 23:52:32 +0800 Subject: [PATCH 10/88] =?UTF-8?q?feat(panel):=20=E5=B1=95=E7=A4=BA=20Globa?= =?UTF-8?q?l=20=E6=A8=A1=E5=9E=8B=E5=8F=AF=E7=94=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在模型与档位页面展示 Global 可用性标签,依据模型锁与最近真实请求结果区分可用、无权限、限流、上游错误和待验证;不新增上游推理探测。 (cherry picked from commit 74f85837ce2d8049b68f0a106ba1b8d460f39b6c) --- internal/panel/app.js | 76 +++++++++++++++++++++++++++++++-- internal/panel/frontend_test.go | 52 ++++++++++++++++++++++ internal/panel/index.html | 3 ++ 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/internal/panel/app.js b/internal/panel/app.js index 2bfcbf85..af399877 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -9,6 +9,7 @@ let refTimer = null; /* 视图级筛选状态(模块级声明放在文件顶部,避免顶层 go() 早于声明执行时踩 TDZ)。 */ let mdFilter = { q: '', realm: '', cap: '', effort: '', promo: '', sort: 'default' }; let mdAll = [], mdProbes = {}, mdProbeOf = () => undefined; +let mdLocks = [], mdRecent = []; let reqFilter = { q: '', outcome: '' }; let reqEntries = []; let usDim = 'account', usCreditDim = 'account', usSort = 'total'; @@ -629,10 +630,18 @@ async function loadModels() { const tb = $('mdBody'); tb.innerHTML = '
正在向上游查询…
'; try { - // 探测数据是可选增强:拉取失败不影响模型列表本身 - const [d, pr] = await Promise.all([api('models'), api('model_probes').catch(() => ({}))]); + // 探测、模型锁与最近请求都是本地可选增强:任一失败不影响模型目录本身。 + // 可用性标签不额外发推理请求,只用模型锁和最近真实请求结果合成。 + const [d, pr, st, rm] = await Promise.all([ + api('models'), + api('model_probes').catch(() => ({})), + api('status').catch(() => ({})), + api('request_metrics').catch(() => ({})), + ]); mdAll = d.models || []; mdProbes = pr.probes || {}; + mdLocks = Array.isArray(st.model_locks) ? st.model_locks : []; + mdRecent = Array.isArray(rm.recent) ? rm.recent : []; if (!mdAll.length) { tb.innerHTML = '
上游未返回模型
'; $('mdCount').textContent = ''; @@ -709,6 +718,67 @@ function mdSortList(list, f) { return out; } +// mdAvailabilityOf 合成 Global 模型当前可用性:模型锁优先于最近请求结果。 +// 只返回有真实本地证据的状态;目录中存在但从未请求过的模型标为“待验证”, +// 避免把“上游列出了模型”误当成“当前账号有权限”。 +function mdAvailabilityOf(m, locks, recent) { + const id = String((m && m.id) || ''); + if (!id.startsWith('global:')) return null; + locks = Array.isArray(locks) ? locks : mdLocks; + recent = Array.isArray(recent) ? recent : mdRecent; + const bare = id.slice('global:'.length); + const lock = locks.find(r => r && r.realm === 'global' && r.model === bare); + if (lock) { + const reason = String(lock.reason || '').trim(); + const lower = reason.toLowerCase(); + if (lock.state === 'partial') { + return { key: 'partial', label: '部分可用', cls: 'warn', + tip: '仍有 ' + Number(lock.servable || 0) + ' 个 global 账号可服务;已锁 ' + Number(lock.locked || 0) + ' 个' + (reason ? ';' + reason : '') }; + } + if (lock.state === 'starved') { + return { key: 'starved', label: '账号暂不可用', cls: 'mute', + tip: '没有账号能服务该模型,但并非该模型自身被锁;请检查账号冷却/在途占用' }; + } + if (lower.startsWith('11158') || lower.includes('not allowed for user type')) { + return { key: 'denied', label: '无权限', cls: 'bad', + tip: '当前 global 账号类型不支持该模型' + (reason ? ';' + reason : '') }; + } + if (lower.startsWith('11102') || lower.includes('service info not found')) { + return { key: 'missing', label: '上游无此模型', cls: 'bad', + tip: '上游目录残留,实际请求返回模型不存在' + (reason ? ';' + reason : '') }; + } + return { key: 'limited', label: '限流/不可用', cls: 'warn', + tip: '模型级冷却中' + (reason ? ';' + reason : '') }; + } + const ev = recent.find(e => e && e.model === id); + if (ev) { + const status = Number(ev.status || 0); + const when = ev.time ? new Date(ev.time).toLocaleString('zh-CN', { hour12: false }) : ''; + if (ev.outcome === 'success' || (status >= 200 && status < 300)) { + return { key: 'ok', label: '可用', cls: 'ok', + tip: '最近一次真实请求成功' + (when ? ' · ' + when : '') }; + } + if (status === 429) { + return { key: 'rate', label: '限流', cls: 'warn', + tip: '最近一次请求被限流' + (when ? ' · ' + when : '') }; + } + if (status >= 500) { + return { key: 'upstream', label: '上游错误', cls: 'bad', + tip: '最近一次请求上游错误 HTTP ' + status + (when ? ' · ' + when : '') }; + } + return { key: 'failed', label: '最近失败', cls: 'bad', + tip: '最近一次请求失败 HTTP ' + (status || '—') + (when ? ' · ' + when : '') }; + } + return { key: 'unknown', label: '待验证', cls: 'mute', + tip: '尚无模型锁或近期真实请求记录;目录存在不代表当前账号有权限' }; +} + +function mdAvailabilityTag(m) { + const av = mdAvailabilityOf(m); + if (!av) return ''; + return ' ' + esc(av.label) + ''; +} + // mdRowHtml 单个模型行(纯渲染,便于独立测试)。 function mdRowHtml(m, pr) { const eff = (m.supported_efforts || []).slice(); @@ -723,7 +793,7 @@ function mdRowHtml(m, pr) { if (m.supports_reasoning && !m.can_disable_thinking) caps.push('思考常开'); const capHtml = caps.length ? '
' + caps.join(' ') + '
' : ''; const tip = m.description ? ' title="' + esc(m.description) + '"' : ''; - return '
' + esc(m.id) + '
' + esc(m.name || '') + '
' + capHtml + '' + + return '
' + esc(m.id) + '' + mdAvailabilityTag(m) + '
' + esc(m.name || '') + '
' + capHtml + '' + '' + rateCell(m) + '' + '' + (m.default_effort ? '' + esc(m.default_effort) + '' : '—') + '' + '' + effs + '' + diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index d51fd3d3..0398607f 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -398,6 +398,58 @@ process.stdout.write(JSON.stringify({ } } +// Global 模型可用性标签:模型锁优先于最近请求;无本地证据时保持“待验证”, +// 不能把上游目录里出现模型误判为当前账号有权限。 +func TestAppJSModelAvailability(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; model availability test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function mdAvailabilityOf'); +const end = src.indexOf('function mdRowHtml'); +if (start < 0 || end < 0) throw new Error('model availability helpers not found'); +const ctx = { esc: s => String(s) }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.mdAvailabilityOf=mdAvailabilityOf; this.mdAvailabilityTag=mdAvailabilityTag;', ctx); +const model = id => ({ id }); +const state = (m, locks, recent) => ctx.mdAvailabilityOf(m, locks, recent).key; +const success = [{ model: 'global:ok', status: 200, outcome: 'success', time: '2026-10-18T12:00:00Z' }]; +ctx.mdLocks = [{ realm: 'global', model: 'gpt-5.6-sol', state: 'locked', reason: '11158 model not allowed for account type' }]; +ctx.mdRecent = []; +const deniedTag = ctx.mdAvailabilityTag(model('global:gpt-5.6-sol')); +process.stdout.write(JSON.stringify({ + cn: ctx.mdAvailabilityOf(model('cn:glm-5.3'), [], []), + unknown: state(model('global:unknown'), [], []), + denied: state(model('global:gpt-5.6-sol'), [{ realm: 'global', model: 'gpt-5.6-sol', state: 'locked', reason: '11158 model not allowed for account type' }], []), + missing: state(model('global:auto-chat'), [{ realm: 'global', model: 'auto-chat', state: 'locked', reason: '11102 service info not found' }], []), + partial: state(model('global:glm-5.3'), [{ realm: 'global', model: 'glm-5.3', state: 'partial', reason: '6004 model rate limit' }], []), + starved: state(model('global:glm-5.3'), [{ realm: 'global', model: 'glm-5.3', state: 'starved' }], []), + rate: state(model('global:glm-5.3'), [], [{ model: 'global:glm-5.3', status: 429 }]), + upstream: state(model('global:glm-5.3'), [], [{ model: 'global:glm-5.3', status: 503 }]), + ok: state(model('global:ok'), [], success), + deniedTag: deniedTag.includes('无权限') && deniedTag.includes('tag bad'), +}));` + f, err := os.CreateTemp(t.TempDir(), "model-availability-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("model availability node test failed: %v\n%s", err, out) + } + const want = `{"cn":null,"unknown":"unknown","denied":"denied","missing":"missing","partial":"partial","starved":"starved","rate":"rate","upstream":"upstream","ok":"ok","deniedTag":true}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("model availability=%s want %s", out, want) + } +} + // 用量时序图的柱体类名不得叫 bar:账号池的积分条是 .bar{height:3px},而 SVG2 里 // height 是 rect 的 CSS 几何属性——同名类会把每根柱子压成 3px 高,图看起来"没数据"。 // 这个坑只能在浏览器里看出来,所以在这里钉住类名。 diff --git a/internal/panel/index.html b/internal/panel/index.html index 8dc7a5f7..47fd5ac5 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -236,6 +236,8 @@ #taskTable .who .nm, #taskTable .who .id { white-space: normal; word-break: break-all; } .who .nm { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .who .id { font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); margin-top: 1px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.acc.md .who .nm { display: flex; align-items: center; gap: 6px; min-width: 0; } +.acc.md .who .nm .mid { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .realm-tag { display: inline-block; margin-left: 6px; padding: 1px 5px; border-radius: 4px; font: 500 10px/1 var(--sans); color: var(--accent); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); vertical-align: 1px; } .realm-row { display: flex; align-items: center; gap: 14px; padding: 4px 0 10px; } .realm-opt { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink-2); cursor: pointer; } @@ -275,6 +277,7 @@ .tag.warn { background: var(--warn-soft); color: var(--warn); } .tag.bad { background: var(--bad-soft); color: var(--bad); } .tag.mute { background: var(--surface-2); color: var(--ink-3); border: 1px solid var(--line); } +.md-avail { flex: none; padding: 0 6px; font-size: 10.5px; line-height: 16px; } .rate-limits { display: flex; flex-direction: column; gap: 4px; margin-top: 6px; min-width: 210px; } .rate-limit { display: flex; align-items: baseline; gap: 6px; padding: 4px 7px; From f3af4266c3ea69eb2cdb06186e3fbce4e8a6803a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E4=B8=80=E5=B9=B3?= <2503096804@qq.com> Date: Fri, 16 Oct 2026 01:38:52 +0000 Subject: [PATCH 11/88] =?UTF-8?q?panel:=20=E3=80=8C=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=85=A8=E9=83=A8=E3=80=8D=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=80=E5=B9=B6=E9=97=A8=E6=8E=A7=E4=BC=81=E4=B8=9A=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E4=BC=9A=E8=AF=9D=EF=BC=88=E8=A1=A5=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit accountTaskAutoAll 与单任务入口共用同一套成长任务动作,此前漏挡: 企业版 / 企业切换会话(token_source=enterprise_switch)下同样会白跑 全部任务并消耗真实对话配额。下游 fork 吸收本 PR 时指出该缺口,一并修复。 (cherry picked from commit f09c844c4e7794686e6aaaae88b9564613f30e53) --- internal/panel/autotask.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index bfe1a2f8..c76ce3df 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -1240,6 +1240,14 @@ func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 企业上下文门控:与 accountTaskAuto 同口径(企业版 / 企业切换会话下成长任务 + // 行为事件被上游静默丢弃,跑全量只会白耗真实对话配额)。「一键完成全部」与 + // 单任务入口共用同一套动作,必须一并挡上(下游 fork 吸收本 PR 时发现的缺口)。 + if a.IsEnterpriseSession() { + writeErr(w, http.StatusUnprocessableEntity, + "该账号为企业版/企业切换会话(token_source=enterprise_switch),无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + return + } // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 if !p.tryLockAccount(uid) { writeErr(w, http.StatusConflict, "该账号有任务动作正在执行中,请等本轮结束后再试") From 884909fb1eff3906e1ef1d7c49c082ce4f76975c Mon Sep 17 00:00:00 2001 From: mapxn <16299874+mapxn@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:55:48 +0800 Subject: [PATCH 12/88] feat(models): add models.hidden blacklist (list filtering + chat gating) (cherry picked from commit 868fda2a6a45944631756db8586a53c6afe792f0) --- README.md | 3 +- cmd/server/config.go | 7 +++ cmd/server/main.go | 5 ++ config.example.json | 4 ++ internal/livecfg/livecfg.go | 30 +++++++++++ internal/livecfg/livecfg_test.go | 53 ++++++++++++++++++ internal/server/handler.go | 23 ++++++++ internal/server/handler_test.go | 92 ++++++++++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 internal/livecfg/livecfg_test.go diff --git a/README.md b/README.md index 1d2dfaf6..17f042ff 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,7 @@ curl -s http://localhost:7863/v1/chat/completions \ | `upstream.idle_timeout_seconds` | `300` | 聊天流中空闲上限(活跃续命,静默断流) | | `upstream.user_agent` | 空 | 出站 User-Agent 覆盖(空 = 现状 `CLI/2.63.2 CodeBuddy/2.63.2`)。官网「使用端」列按出站 UA 服务端归因;官方 WorkBuddy 桌面 UA 为 `WorkBuddy/`,需要时可配 | | `features.sanitize_blacklist_fingerprints` | `true` | 出站请求体黑名单指纹脱敏 | +| `models.hidden` | `[]` | 模型黑名单:不对外暴露的模型。裸名=两域都藏;`"cn:xxx"`/`"global:xxx"`=只藏该域。`GET /v1/models` 列表过滤 + `POST /v1/chat/completions` 点名拒绝(400 `model_unavailable`)。面板保存后热生效 | | `prompt.mode` | `custom` | 系统提示词模式:`custom` = 网关用自有提示词替换客户端 system;`append` = 开头连续 system/developer 块后插网关提示词(既有消息逐字不动);`passthrough` = 透传客户端原始 system(降级重试仍切中性提示词) | | `prompt.file` | 空 | 提示词文件路径;空 = 内置默认(约 2KB);路径非空但不可读 → 启动报错 | | `upstash.url` / `upstash.token` | 空 | 空 = 纯内存模式(Noop 降级,功能照常) | @@ -658,7 +659,7 @@ http://127.0.0.1:7863/panel/ 来源信息比 token 计数敏感(IP 属个人信息),因此由 `logging.request_client_info` 控制,**缺省开启、热生效、面板「配置 → 日志」可关**;关闭后归档与面板都不再出现来源字段(老归档里没有该字段的条目显示 `—`)。 **配置热生效**:保存配置后,`api_key`、`cooldown.soft_rate`、`features.sanitize_blacklist_fingerprints`、 -`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**; +`models.hidden`、`logging.request_client_info`、`pool.*`(熔断/在途/权重)、`schedule.*`(时点/开关/余额刷新间隔)**立即生效,无需重启**; 涉及进程装配期依赖的字段(`listen`、`auth_dir`、`state_file`、`upstream.*`、`upstash.*`、`session_sticky.ttl`) 保存后会提示"需重启进程生效"。配置写入采用「深合并且原子替换」:只更新面板表单覆盖的键, 用户手写的未知键与其余字段原样保留。 diff --git a/cmd/server/config.go b/cmd/server/config.go index 1c9d5b2a..c7eb9db1 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -154,6 +154,13 @@ type Config struct { SanitizeBlacklistFingerprints bool `json:"sanitize_blacklist_fingerprints"` } `json:"features"` + Models struct { + // Hidden 不对外暴露的模型黑名单。裸名=两域都藏;"cn:xxx"/"global:xxx"=只藏该域。 + // 生效点:GET /v1/models 列表过滤 + POST /v1/chat/completions 点名拒绝。 + // 面板保存后经 livecfg 热生效,无需重启。 + Hidden []string `json:"hidden"` + } `json:"models"` + Prompt struct { // Mode passthrough(默认)= 透传客户端原始 system(降级重试仍会切到 Degraded); // custom = 网关用自有系统提示词替换客户端 system/developer; diff --git a/cmd/server/main.go b/cmd/server/main.go index f2056dbc..c2ef1c58 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -237,6 +237,7 @@ func main() { SoftCooldown: cfg.SoftRateDur, SanitizeFingerprints: cfg.Features.SanitizeBlacklistFingerprints, RecordClientInfo: cfg.Logging.RequestClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(cfg.Models.Hidden), }) // 用量记录器:与 state 文件同目录,随 state_file 配置一起搬移。 // datapath 由 state 文件路径推出,避免再加一个配置项。 @@ -310,6 +311,9 @@ func main() { RecordClientInfo: cfg.Logging.RequestClientInfo, // handler 侧第三道闸(global realm):false(显式逃生门)时不列 global: 模型名。 GlobalEnabled: cfg.Global.Enabled, + // 模型黑名单原始条目;运行期以 livecfg 快照为准(热生效),此处供 + // Live 为 nil 的裸用/测试路径。 + ModelsHidden: cfg.Models.Hidden, }) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) @@ -484,6 +488,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up SoftCooldown: newCfg.SoftRateDur, SanitizeFingerprints: newCfg.Features.SanitizeBlacklistFingerprints, RecordClientInfo: newCfg.Logging.RequestClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(newCfg.Models.Hidden), }) up.SanitizeFingerprints.Store(newCfg.Features.SanitizeBlacklistFingerprints) p.SetBreaker(newCfg.Pool.BreakerThreshold, newCfg.BreakerCooldownDur, newCfg.BreakerCooldownMaxD) diff --git a/config.example.json b/config.example.json index 4461074d..3ceb489f 100644 --- a/config.example.json +++ b/config.example.json @@ -56,6 +56,10 @@ "features": { "sanitize_blacklist_fingerprints": true }, + "models": { + "_comment": "hidden: 不对外暴露的模型黑名单。裸名=两域都藏;\"cn:xxx\"/\"global:xxx\"=只藏该域。/v1/models 列表过滤 + chat 点名拒绝,面板保存后热生效。", + "hidden": [] + }, "prompt": { "mode": "passthrough", "file": "" diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go index 0db0c341..9d97da3c 100644 --- a/internal/livecfg/livecfg.go +++ b/internal/livecfg/livecfg.go @@ -9,6 +9,7 @@ package livecfg import ( + "strings" "sync/atomic" "time" ) @@ -19,6 +20,7 @@ type Snapshot struct { SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) SanitizeFingerprints bool // 出站请求体指纹脱敏 RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) + HiddenModels map[string]struct{} // 精确隐藏的模型 id(已归一化为 "cn:x"/"global:x" 全量形式);nil = 不隐藏 } // Holder 原子持有当前快照。 @@ -46,3 +48,31 @@ func (h *Holder) Load() Snapshot { // Store 整体替换快照。 func (h *Holder) Store(s Snapshot) { h.p.Store(&s) } + +// NormalizeHiddenModels 把用户配置的 hidden 条目归一化为精确 id 集合。 +// +// 条目带合法 "cn:"/"global:" 前缀 → 只隐藏该域;裸名 → 两域都隐藏。 +// 大小写敏感(与 internal/server.resolveModel 的前缀规则对称:取第一个 ":", +// 前段恰为 "cn"/"global" 才剥离)。返回的 map 只读共享,调用方不得修改。 +func NormalizeHiddenModels(hidden []string) map[string]struct{} { + if len(hidden) == 0 { + return nil + } + out := make(map[string]struct{}, len(hidden)*2) + for _, m := range hidden { + m = strings.TrimSpace(m) + if m == "" { + continue + } + if prefix, bare, ok := strings.Cut(m, ":"); ok && (prefix == "cn" || prefix == "global") { + out[prefix+":"+bare] = struct{}{} + } else { + out["cn:"+m] = struct{}{} + out["global:"+m] = struct{}{} + } + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/internal/livecfg/livecfg_test.go b/internal/livecfg/livecfg_test.go new file mode 100644 index 00000000..6c687eb6 --- /dev/null +++ b/internal/livecfg/livecfg_test.go @@ -0,0 +1,53 @@ +package livecfg + +import "testing" + +// TestNormalizeHiddenModels 归一化语义: +// - nil/空/全空白 → nil(不隐藏) +// - 裸名 → 两域都藏("cn:x" + "global:x") +// - "cn:"/"global:" 前缀 → 只藏该域 +// - 非 cn/global 前缀(如 "xx:yy")视为裸名整体(与 resolveModel 对称: +// 前缀不合法时 realm=cn、bare=原串,网关侧检查 "cn:xx:yy" 命中) +func TestNormalizeHiddenModels(t *testing.T) { + if got := NormalizeHiddenModels(nil); got != nil { + t.Errorf("nil → %v, want nil", got) + } + if got := NormalizeHiddenModels([]string{}); got != nil { + t.Errorf("empty → %v, want nil", got) + } + if got := NormalizeHiddenModels([]string{" ", ""}); got != nil { + t.Errorf("all blank → %v, want nil", got) + } + + got := NormalizeHiddenModels([]string{"deepseek-v3"}) + for _, want := range []string{"cn:deepseek-v3", "global:deepseek-v3"} { + if _, ok := got[want]; !ok { + t.Errorf("bare name: missing %q in %v", want, got) + } + } + if len(got) != 2 { + t.Errorf("bare name: got %d entries %v, want 2", len(got), got) + } + + got = NormalizeHiddenModels([]string{"cn:pro-model"}) + if _, ok := got["cn:pro-model"]; !ok { + t.Errorf("cn: prefix: missing %q in %v", "cn:pro-model", got) + } + if _, ok := got["global:pro-model"]; ok { + t.Errorf("cn: prefix: %q must not hide global realm", "global:pro-model") + } + + // 前后空白被裁掉 + got = NormalizeHiddenModels([]string{" glm-5.2 "}) + if _, ok := got["cn:glm-5.2"]; !ok { + t.Errorf("trimmed bare name: missing %q in %v", "cn:glm-5.2", got) + } + + // 非法前缀视为裸名整体 + got = NormalizeHiddenModels([]string{"xx:yy"}) + for _, want := range []string{"cn:xx:yy", "global:xx:yy"} { + if _, ok := got[want]; !ok { + t.Errorf("odd prefix: missing %q in %v", want, got) + } + } +} diff --git a/internal/server/handler.go b/internal/server/handler.go index fd4fb89e..bbc6a779 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -62,6 +62,10 @@ type Config struct { // (modelList 不列 global 名单)。 GlobalEnabled bool + // ModelsHidden 模型黑名单原始条目(config models.hidden;Live 为 nil 的 + // 裸用/测试路径经 loadLive() 归一化后使用)。 + ModelsHidden []string + // Usage 逐请求用量记录器(可选;nil = 不记录)。 // 在 recordAttempt 这一唯一汇聚点调用,因此流式/非流式、成功/失败都会计入, // 且与 pool 的每账号累计器同源,两条口径不会漂移。 @@ -85,6 +89,7 @@ func (h *Handler) loadLive() livecfg.Snapshot { APIKey: h.cfg.APIKey, SoftCooldown: h.cfg.SoftCooldown, RecordClientInfo: h.cfg.RecordClientInfo, + HiddenModels: livecfg.NormalizeHiddenModels(h.cfg.ModelsHidden), } } @@ -359,7 +364,12 @@ func applyModelInfoFields(entry map[string]any, mi upstream.ModelInfo) map[strin // 纯动态:动态拉取失败/无号 → 该域空列表,无静态兜底。 func (h *Handler) modelList() []map[string]any { out := make([]map[string]any, 0) + // models.hidden 黑名单:快照外提,避免逐条目重复 Load。 + hidden := h.loadLive().HiddenModels for _, mi := range h.fetchDynamicModels() { + if _, ok := hidden["cn:"+mi.ID]; ok { + continue + } entry := map[string]any{ "id": "cn:" + mi.ID, "object": "model", @@ -403,6 +413,9 @@ func (h *Handler) modelList() []map[string]any { } globalEfforts, globalDefaults := h.cfg.Upstream.GlobalEffortSnapshot() for _, id := range globalIDs { + if _, ok := hidden["global:"+id]; ok { + continue + } entry := map[string]any{ "id": "global:" + id, "object": "model", @@ -558,6 +571,16 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { } defer st.done() + // models.hidden 黑名单:点名调用直接拒绝,不打上游、不占选号。 + // 与 /v1/models 的列表过滤对称(只藏列表不够,知道名字仍可硬调)。 + if _, hide := h.loadLive().HiddenModels[realm+":"+bareModel]; hide { + writeOpenAIErrorHint(w, http.StatusBadRequest, "model_unavailable", + "model is hidden by gateway configuration, try another model", "") + st.status = http.StatusBadRequest + st.outcome = reqlog.OutcomeHTTPError + return + } + tried := map[string]bool{} var lastErr error // modelBlock 记录本次是否因「模型级冷却」而选不到号(下方 acct==nil 分支填充)。 diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 85d532e8..8ec222d3 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -1875,3 +1875,95 @@ func TestChatMarkupRepairStrictModeRejectsUndeclaredTool(t *testing.T) { t.Fatalf("未声明的工具名不得转换: %#v", msg["tool_calls"]) } } + +// TestChatHiddenModelRejected models.hidden 黑名单:点名调用直接 400 拒绝, +// 不打上游、不占选号;未隐藏的模型照常放行。 +func TestChatHiddenModelRejected(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + return 200, sseOK, true + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"glm-5.2", "global:secret-model"}}) + + post := func(model string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + body := []byte(`{"model":"` + model + `","messages":[]}`) + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body))) + return rec + } + + // 裸名隐藏 → 裸名 / cn: / global: 三种写法都拒绝 + for _, model := range []string{"glm-5.2", "cn:glm-5.2", "global:glm-5.2"} { + rec := post(model) + if rec.Code != http.StatusBadRequest { + t.Errorf("model %q: code=%d, want 400", model, rec.Code) + } + if !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Errorf("model %q: body=%s, want model_unavailable", model, rec.Body.String()) + } + } + // 前缀精确隐藏:只藏 global 域 + if rec := post("global:secret-model"); rec.Code != http.StatusBadRequest { + t.Errorf("global:secret-model: code=%d, want 400", rec.Code) + } + if calls != 0 { + t.Errorf("hidden models must not reach upstream: calls=%d", calls) + } + + // cn:secret-model 不在黑名单 → 放行并打到上游 + rec := post("cn:secret-model") + if rec.Code != http.StatusOK { + t.Fatalf("cn:secret-model: code=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + if calls != 1 { + t.Errorf("allowed model: upstream calls=%d, want 1", calls) + } +} + +// TestModelsHiddenFiltered models.hidden 黑名单:/v1/models 列表里过滤掉隐藏模型。 +// 用包级 dynamicModelsCache 预置目录,避免联网拉取。 +func TestModelsHiddenFiltered(t *testing.T) { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = []upstream.ModelInfo{{ID: "glm-5.2"}, {ID: "deepseek-v3"}} + dynamicModelsCache.fetched = time.Now() + dynamicModelsCache.Unlock() + defer func() { + dynamicModelsCache.Lock() + dynamicModelsCache.ids = nil + dynamicModelsCache.fetched = time.Time{} + dynamicModelsCache.lastFail = time.Time{} + dynamicModelsCache.Unlock() + }() + + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + return 200, `{"data":[]}`, false + }) + p := testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at1", ExpiresAt: 9999999999}) + h := NewHandler(Config{Pool: p, Upstream: up, ModelsHidden: []string{"deepseek-v3"}}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s, want 200", rec.Code, rec.Body.String()) + } + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode /v1/models: %v", err) + } + ids := map[string]bool{} + for _, m := range resp.Data { + ids[m.ID] = true + } + if !ids["cn:glm-5.2"] { + t.Errorf("visible model missing from list: ids=%v", ids) + } + if ids["cn:deepseek-v3"] { + t.Errorf("hidden model leaked into /v1/models: ids=%v", ids) + } +} From 05f6be9fc76fe407ee3631c322769590bda74ba6 Mon Sep 17 00:00:00 2001 From: zkmydgth Date: Mon, 19 Oct 2026 23:31:57 +0800 Subject: [PATCH 13/88] =?UTF-8?q?feat(pool):=20=E8=A3=B8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=90=8D=E8=B7=A8=E5=9F=9F=E5=9B=9E=E9=80=80=E5=BC=80=E5=85=B3?= =?UTF-8?q?=EF=BC=88issue=20#135=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit c90703e142b122a74aa90960eca555893e2f54eb) --- README.md | 1 + cmd/server/config.go | 6 + .../cross_domain_fallback_config_test.go | 36 +++++ cmd/server/main.go | 2 + config.example.json | 3 +- internal/pool/pool.go | 19 +++ internal/pool/realm.go | 30 ++++ internal/server/cross_domain_fallback_test.go | 130 ++++++++++++++++++ internal/server/handler.go | 31 ++++- internal/server/resolve_model.go | 27 +++- 10 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 cmd/server/cross_domain_fallback_config_test.go create mode 100644 internal/server/cross_domain_fallback_test.go diff --git a/README.md b/README.md index 17f042ff..368baac6 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,7 @@ curl -s http://localhost:7863/v1/chat/completions \ | `pool.degrade_cooldown` / `pool.degrade_cooldown_max` | `10m` / `2h` | 连败降权时长与上限钳制 | | `pool.cost_explore_interval` | `30m` | costTier 条件探索窗口:免费层垄断且存在未知号时,每窗口把一个真实请求搭车改道给未知号(零新增上游请求;成功即毕业,失败走既有错误策略)。`0` = 关停 | | `pool.credit_floor` | `100` | **积分保底**:账号余额低于该值时,对**实测收费**模型(tier 2,账本 6h 内有效观测)不再参与选号——防止收费模型把余额打穿、连免费模型都 402 冷却到次日签到(最坏约 11.5 小时不可用)。tier 0(实测免费)/ tier 1(无观测)**不受限**:保底保的是「留余额给免费模型用」,且 tier 1 若拦会让账本过期 / 重启清零的触底号死锁在「学不回来」。含会话粘性路径(粘性号触底则解绑换号)。全池触底且全 tier 2 时选号返回空(网关回 503),**不放行**。签到回血越过 floor 即刻自动恢复。`0` = 关闭 | +| `pool.cross_domain_fallback` | `false` | **裸模型名跨域回退**(issue #135):开启后,请求 `model` 不带 `cn:` / `global:` 前缀时不再等价于 `cn:<模型>`——首选域(cn)选不到可用号(账号级冷却/熔断/在途占满,或该域账号对这个模型 11102 后被模型级负缓存避开)会自动改用另一域再选一次,客户端只写一个模型名即可跟随账号池切换。显式 `cn:` / `global:` 前缀**永不回退**(写哪个域就用哪个域)。跨域会消耗另一个域的额度,故默认关闭 | | `pool.breaker_threshold` | `3` | 连续失败触发熔断阈值 | | `pool.breaker_cooldown` | `30m` | 熔断基础退避时长 | | `pool.breaker_cooldown_max` | `6h` | 熔断指数退避封顶 | diff --git a/cmd/server/config.go b/cmd/server/config.go index c7eb9db1..adc4f356 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -212,6 +212,12 @@ type Config struct { // tier 0(免费)/ tier 1(无观测)不受限;签到回血越过 floor 自动恢复。 // 默认 0 = 关闭;负值钳 0。 CreditFloor int64 `json:"credit_floor"` + // CrossDomainFallback 裸模型名跨域回退开关(issue #135),默认 false(零回归)。 + // 开启后:请求模型名不带 realm 前缀时,首选域(cn)选不到可用账号(账号级冷却/ + // 熔断/在途占满,或该域账号对这个模型 11102 后进入模型级负缓存)会自动改用 + // 另一域再选一次;显式 cn:/global: 前缀不受影响,行为与引入前完全一致。 + // 语义取舍:跨域会消耗另一个域的额度,属策略选择,故默认关闭、显式开启。 + CrossDomainFallback bool `json:"cross_domain_fallback"` } `json:"pool"` SessionSticky struct { diff --git a/cmd/server/cross_domain_fallback_config_test.go b/cmd/server/cross_domain_fallback_config_test.go new file mode 100644 index 00000000..addf20dc --- /dev/null +++ b/cmd/server/cross_domain_fallback_config_test.go @@ -0,0 +1,36 @@ +// cross_domain_fallback_config_test.go pool.cross_domain_fallback 配置测试: +// 默认 false(零回归)/ 文件覆盖为 true。 +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestCrossDomainFallbackDefault 键缺席 → 默认 false(裸名仍是 cn 单域,行为与引入前一致)。 +func TestCrossDomainFallbackDefault(t *testing.T) { + c := Default() + if err := c.normalize(); err != nil { + t.Fatalf("normalize: %v", err) + } + if c.Pool.CrossDomainFallback { + t.Error("cross_domain_fallback 缺省必须为 false") + } +} + +// TestCrossDomainFallbackParsedFromFile 显式配置覆盖默认。 +func TestCrossDomainFallbackParsedFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "c.json") + if err := os.WriteFile(fp, []byte(`{"pool":{"cross_domain_fallback":true}}`), 0o600); err != nil { + t.Fatal(err) + } + c, err := Load(fp) + if err != nil { + t.Fatal(err) + } + if !c.Pool.CrossDomainFallback { + t.Error("cross_domain_fallback=true 未生效") + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index c2ef1c58..a59daa66 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -103,6 +103,7 @@ func main() { p.SetCreditFloor(cfg.Pool.CreditFloor) // 积分保底(默认 0 = 关闭) p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) p.SetPreferExpiring(cfg.Pool.PreferExpiring) + p.SetCrossDomainFallback(cfg.Pool.CrossDomainFallback) // 裸名跨域回退(issue #135,默认关闭) // 会话粘性路由(可配关闭)。 var sessRouter *session.Router @@ -500,6 +501,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up p.SetCreditFloor(newCfg.Pool.CreditFloor) // 积分保底热生效(0 = 关闭) p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) p.SetPreferExpiring(newCfg.Pool.PreferExpiring) + p.SetCrossDomainFallback(newCfg.Pool.CrossDomainFallback) // 裸名跨域回退热生效(issue #135) sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) sch.Reconfigure( newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, diff --git a/config.example.json b/config.example.json index 3ceb489f..3eedbffe 100644 --- a/config.example.json +++ b/config.example.json @@ -82,7 +82,8 @@ "prefer_expiring": true, "expiring_soon": "168h", "cost_explore_interval": "30m", - "credit_floor": 100 + "credit_floor": 100, + "cross_domain_fallback": false }, "session_sticky": { "enabled": true, diff --git a/internal/pool/pool.go b/internal/pool/pool.go index 3b0b0bc3..be481698 100644 --- a/internal/pool/pool.go +++ b/internal/pool/pool.go @@ -60,6 +60,10 @@ type Pool struct { // preferExpiring 最早到期优先路由开关(默认 true)。开启且快过期窗口内存在有效 // 批次时,选号在成本层内先按最早到期排序;关闭后只使用普通加权路由。 preferExpiring bool + // crossDomainFallback 裸模型名跨域回退开关(默认 false = 零回归,issue #135)。 + // 仅由选号调用方(server handler)读取:开启后,未带 realm 前缀的请求在首选域 + // 选不到可用号时改用另一域再选一次;显式 cn:/global: 前缀不受影响。 + crossDomainFallback bool // maxInFlight 单账号最大在途请求数;0 = 不限(租约关闭)。 maxInFlight int // maxInFlightGlobal global 域单账号在途上限分档(WAF 403 修复 P1-1:global 域 @@ -193,6 +197,21 @@ func (p *Pool) SetPreferExpiring(enabled bool) { p.preferExpiring = enabled } +// SetCrossDomainFallback 注入裸模型名跨域回退开关(issue #135;false = 零回归)。 +// 与其它 pool.* 参数一致,面板保存配置后经同一条热应用路径立即生效。 +func (p *Pool) SetCrossDomainFallback(enabled bool) { + p.mu.Lock() + defer p.mu.Unlock() + p.crossDomainFallback = enabled +} + +// CrossDomainFallback 报告裸模型名跨域回退开关当前状态(选号侧只读,热改安全)。 +func (p *Pool) CrossDomainFallback() bool { + p.mu.RLock() + defer p.mu.RUnlock() + return p.crossDomainFallback +} + // SetDegrade 注入连败降权参数(main 从 config 解析后调用,issue #114)。 // 非正值保留原值(用默认,见 defaultDegrade*),风格同 SetBreaker/SetSoftRateMax。 func (p *Pool) SetDegrade(threshold int, cooldown, cooldownMax time.Duration) { diff --git a/internal/pool/realm.go b/internal/pool/realm.go index d82ca93e..43e4d217 100644 --- a/internal/pool/realm.go +++ b/internal/pool/realm.go @@ -33,6 +33,36 @@ func (p *Pool) AvailableUIDsForRealm(realm string) []string { return uids } +// ServableForModelRealm 返回 realm 内此刻**真正能服务该模型**的账号数。 +// +// 与 pick 的候选口径一致(healthyForModel 的 6004/11102 模型级冷却豁免、在途租约、 +// 积分保底都计入),但**不含 pick 的全冷却兜底**——跨域回退(issue #135)要的正是 +// 「这个域此刻有没有健康号」,兜底把冷却号捞回来的乐观语义会掩盖这一点。 +// +// 只读:持 RLock 遍历,不改任何状态。realm=="" 时统计全池(与既有 realm 语义一致)。 +func (p *Pool) ServableForModelRealm(model, realm string) int { + p.mu.RLock() + defer p.mu.RUnlock() + now := time.Now() + n := 0 + for _, e := range p.byUID { + if realm != "" && e.a.Realm() != realm { + continue + } + if !e.healthyForModel(now, model) { + continue + } + if p.inFlightFull(e) { + continue + } + if p.floorBlockedForRealmModel(e, model, realm, now) { + continue + } + n++ + } + return n +} + // WeightedAvailableUIDsForModelRealm 返回带虚拟实例权重的可用账号列表。 // // 普通账号出现 1 次;有效快过期账号出现 expiringVirtualSlots 次。调用方继续按 diff --git a/internal/server/cross_domain_fallback_test.go b/internal/server/cross_domain_fallback_test.go new file mode 100644 index 00000000..ecbdd18e --- /dev/null +++ b/internal/server/cross_domain_fallback_test.go @@ -0,0 +1,130 @@ +// cross_domain_fallback_test.go 裸模型名跨域回退(issue #135)端到端测试: +// 开关默认关闭 = 零回归;开启后「首选域选不到号」与「首选域 11102」两条路径都要 +// 回退到另一域;显式 cn:/global: 前缀永不回退。 +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" +) + +// newRealmPool 构造 cn + global 各一个账号的池子。 +// global 号按 domain 后缀归一化(www.workbuddy.ai → global);cn 号积分更高, +// 配合 testPoolWith 的确定性随机源,断言「先试 cn、失败后才回退 global」。 +func newRealmPool(t *testing.T) *pool.Pool { + t.Helper() + p := testPoolWith( + &auth.Auth{UID: "cn1", AccessToken: "at-cn", ExpiresAt: 9999999999}, + &auth.Auth{UID: "gl1", AccessToken: "at-gl", Domain: "www.workbuddy.ai", ExpiresAt: 9999999999}, + ) + p.SetCredits("cn1", 2000, 0) + p.SetCredits("gl1", 1000, 0) + return p +} + +// 11102「该后端无此模型」是 issue #135 的核心场景:模型只存在于 global 目录, +// 裸名请求先被送到 cn 号 → 11102;开启回退后应改用 global 号拿到 200。 +func TestBareModelCrossDomainFallbackOnModelBlocked(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s (want 200: 裸名在 cn 11102 后必须回退 global)", rec.Code, rec.Body) + } + if calls["Bearer at-cn"] != 1 || calls["Bearer at-gl"] != 1 { + t.Errorf("calls=%v want cn 1 次(11102)→ global 1 次(回退成功)", calls) + } +} + +// 开关缺省关闭:行为与引入前完全一致——11102 后不跨域,回 400 model_unavailable。 +func TestBareModelCrossDomainFallbackDisabledByDefault(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + if p.CrossDomainFallback() { + t.Fatal("cross_domain_fallback 缺省必须为 false(零回归)") + } + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Fatalf("code=%d body=%s (want 400 model_unavailable)", rec.Code, rec.Body) + } + if calls["Bearer at-gl"] != 0 { + t.Errorf("calls=%v want global 0 次(开关关闭不得跨域)", calls) + } +} + +// 显式 realm 前缀是确定性语义:开关开启也不回退(写 cn: 就只用 cn 号)。 +func TestExplicitRealmNeverFallsBack(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + if authz == "Bearer at-cn" { + return 400, `{"code":11102,"msg":"model [deepseek-v4.1-flash-sg] service info not found"}`, false + } + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"cn:deepseek-v4.1-flash-sg","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "model_unavailable") { + t.Fatalf("code=%d body=%s (want 400: 显式 cn: 前缀不得回退)", rec.Code, rec.Body) + } + if calls["Bearer at-gl"] != 0 { + t.Errorf("calls=%v want global 0 次(显式前缀零回退)", calls) + } +} + +// 第二条触发路径:首选域整域不可用(账号级冷却导致选号返回 nil),同样要回退。 +func TestBareModelCrossDomainFallbackOnNoHealthyAccount(t *testing.T) { + calls := map[string]int{} + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls[authz]++ + return 200, sseOK, true + }) + p := newRealmPool(t) + p.SetCrossDomainFallback(true) + p.Cooldown("cn1", pool.CoolSoft, time.Hour, "test: account-level cooldown") + h := NewHandler(Config{Pool: p, Upstream: up}) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"deepseek-v4.1-flash","messages":[{"role":"user","content":"hi"}]}`))) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d body=%s (want 200: cn 整域冷却时裸名应回退 global)", rec.Code, rec.Body) + } + if calls["Bearer at-cn"] != 0 || calls["Bearer at-gl"] != 1 { + t.Errorf("calls=%v want cn 0 次、global 1 次", calls) + } +} diff --git a/internal/server/handler.go b/internal/server/handler.go index bbc6a779..86840137 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -555,11 +555,16 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { // realm 前缀解析(D6):model 名可能带 "[realm:]" 前缀。剥出 realm + bareModel, // bareModel 用于选号/粘性/出站 body 重写(前缀是网关侧路由协议,上游只认裸名)。 // 裸名 → ("cn", 原串),CN 现状零回归。 - realm, bareModel := resolveModel(peek.Model) + realm, bareModel, realmExplicit := resolveModelDetail(peek.Model) modelRate := "" if h.cfg.Upstream != nil { modelRate = h.cfg.Upstream.ModelRate(realm, bareModel) } + // 跨域回退(issue #135):只有裸模型名 + 显式开启 pool.cross_domain_fallback 时生效。 + // 语义 = 「裸名不绑定单一域」:首选域(裸名默认 cn)选不到可用号(账号级冷却/熔断/ + // 在途占满,或该域账号对这个模型 11102 后进入模型级负缓存)时,改用另一域再选一次; + // 显式 cn:/global: 前缀保持原语义,永不回退。 + crossRealm := !realmExplicit && h.cfg.Pool.CrossDomainFallback() // 请求级统计:出口即打一行表格日志(任何路径都会走到)。 st := newChatStat(time.Now(), body, peek.Stream) @@ -774,7 +779,9 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { var acct *auth.Auth if stickyUID != "" { acct = h.cfg.Pool.PickByUIDForModel(stickyUID, bareModel) - if acct == nil || (realm != "" && acct.Realm() != realm) { + // 跨域回退开启且请求为裸名时,粘性号可以落在任一域(回退成功后绑定的是另一域 + // 的账号,下一轮不该被 realm 校验立刻解绑)。 + if acct == nil || (realm != "" && acct.Realm() != realm && !crossRealm) { // 粘性号在当前模型不可用(冷却/占满/该模型被 6004 限额)或 realm 不符 → 解绑, // 本次回落普通轮换。 unbindSticky() @@ -784,7 +791,20 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { if acct == nil { // 模型感知 + realm 感知选号:模型非空时启用 6004 模型级冷却豁免 // (healthyForModel),realm 谓词过滤跨域账号。 - acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, realm) + pickRealm := realm + if crossRealm && h.cfg.Pool.ServableForModelRealm(bareModel, realm) == 0 && + h.cfg.Pool.ServableForModelRealm(bareModel, otherRealm(realm)) > 0 { + // 首选域此刻没有真正可服务的号(11102 模型级负缓存 / 账号级冷却 / + // 在途占满),而另一域有 → 直接换域选号(issue #135)。先看健康度再选, + // 避免拿全冷却兜底捞回来的旧域账号白撞一次上游。 + pickRealm = otherRealm(realm) + } + acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, pickRealm) + if acct == nil && crossRealm { + // 该域选不到(并发抢名额/轮换耗尽/另一域刚冷却完)→ 另一域再试一次, + // 保持尽力而为语义;tried 已记录失败号,轮转仍由 MaxRotate 封顶。 + acct = h.cfg.Pool.PickExcludingForRealm(tried, bareModel, otherRealm(pickRealm)) + } } if acct == nil { st.status = http.StatusServiceUnavailable @@ -799,6 +819,11 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.uid = acct.UID // 同步昵称:请求流水行只写 uid8 时无法直观看是哪一号,昵称随本次选号带入日志行。 st.nick = acct.Nickname + if crossRealm && h.cfg.Upstream != nil && acct.Realm() != realm { + // 跨域回退命中:按实际服务域取积分倍率,用量/账本记的是真实域口径, + // 而不是请求前缀声明的域。 + modelRate = h.cfg.Upstream.ModelRate(acct.Realm(), bareModel) + } tried[acct.UID] = true // 占用在途名额:Pick 已跳过满额账号,此处 CAS 兜底并发抢名额的竞态。 diff --git a/internal/server/resolve_model.go b/internal/server/resolve_model.go index 3a92352e..3752cdb1 100644 --- a/internal/server/resolve_model.go +++ b/internal/server/resolve_model.go @@ -2,25 +2,40 @@ package server import "strings" -// resolveModel 解析模型名协议(PLAN D6): +// resolveModelDetail 解析模型名协议(PLAN D6),并区分「realm 是否由调用方显式写出」: // // 分布式前缀: "[realm:]model" // // 取第一个 ":",前段恰为 "cn"/"global" 才剥离;否则视为裸名,realm=cn、bare=原串。 // 大小写敏感(前缀必须是精确的小写枚举)。bare 即出站/选号/账本使用的裸模型名。 // -// 导出为 ResolveModel(cmd/server/main.go 粘性闭包需要),包内简写 resolveModel。 -func resolveModel(model string) (realm, bare string) { +// 第三个返回值 realmExplicit 供跨域回退(issue #135)判定:只有裸名(false)才允许 +// 在首选域选不到号时改用另一域;显式 cn:/global: 前缀永不被回退改写。 +func resolveModelDetail(model string) (realm, bare string, realmExplicit bool) { idx := strings.IndexByte(model, ':') if idx < 0 { - return "cn", model + return "cn", model, false } prefix := model[:idx] if prefix != "cn" && prefix != "global" { - return "cn", model + return "cn", model, false } - return prefix, model[idx+1:] + return prefix, model[idx+1:], true +} + +// resolveModel 是 resolveModelDetail 的两返回值形态(既有调用方语义零改动)。 +func resolveModel(model string) (realm, bare string) { + realm, bare, _ = resolveModelDetail(model) + return realm, bare } // ResolveModel 是 resolveModel 的导出面(跨包调用)。 func ResolveModel(model string) (realm, bare string) { return resolveModel(model) } + +// otherRealm 返回另一个选号域("cn" ↔ "global"),供跨域回退使用。 +func otherRealm(realm string) string { + if realm == "global" { + return "cn" + } + return "global" +} From 478c49b5333e048512d5ec7d43e05c0dc2560895 Mon Sep 17 00:00:00 2001 From: zhiqiangme <90136554+zhiqiangme@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:52:14 -0700 Subject: [PATCH 14/88] =?UTF-8?q?feat(config):=20=E6=96=B0=E5=A2=9E=20cn.c?= =?UTF-8?q?hat=5Fbase=20=E8=A6=86=E7=9B=96=E5=9B=BD=E5=86=85=E7=89=88?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位, CN 模型目录与对话全部 EOF,/v1/models 只剩 global 模型。官方 WorkBuddy 桌面端 已改用 https://www.workbuddy.cn,实测该域海外出口可达,模型目录与 /v2/chat/completions 均正常。 仿照 global.chat_base:空 = 回落内置默认,老配置零影响;只在装配期生效, 已列入面板保存后的需重启字段。 (cherry picked from commit 7fcc20a2ebb7b1e45c7da5955cf75e0de21bd81f) --- cmd/server/cn_chat_base_test.go | 46 +++++++++++++++++++++++++++++++++ cmd/server/config.go | 9 +++++++ cmd/server/main.go | 11 ++++++++ config.example.json | 3 +++ 4 files changed, 69 insertions(+) create mode 100644 cmd/server/cn_chat_base_test.go diff --git a/cmd/server/cn_chat_base_test.go b/cmd/server/cn_chat_base_test.go new file mode 100644 index 00000000..27080755 --- /dev/null +++ b/cmd/server/cn_chat_base_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "slices" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 老配置(无 cn 段)必须保留内置 CN base,零回归。 +func TestCNChatBaseDefaultKeepsBuiltin(t *testing.T) { + cfg, err := ParseConfig([]byte(`{}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + want := up.ChatBaseCN + applyCNChatBase(up, cfg) + if up.ChatBaseCN != want { + t.Fatalf("ChatBaseCN=%q want builtin %q", up.ChatBaseCN, want) + } +} + +func TestCNChatBaseOverride(t *testing.T) { + cfg, err := ParseConfig([]byte(`{"cn":{"chat_base":"https://www.workbuddy.cn"}}`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + up := upstream.New() + applyCNChatBase(up, cfg) + if up.ChatBaseCN != "https://www.workbuddy.cn" { + t.Fatalf("ChatBaseCN=%q want override", up.ChatBaseCN) + } + // global base 不受 cn 覆盖影响。 + if up.ChatBaseGlobal != "" { + t.Fatalf("ChatBaseGlobal=%q want untouched", up.ChatBaseGlobal) + } +} + +// cn.chat_base 只在装配期生效,面板保存后须提示重启。 +func TestCNChatBaseRequiresRestart(t *testing.T) { + cfg := Default() + if !slices.Contains(restartRequiredFields(cfg), "cn.chat_base") { + t.Fatal("cn.chat_base missing from restart-required fields") + } +} diff --git a/cmd/server/config.go b/cmd/server/config.go index adc4f356..01eb00b2 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -121,6 +121,15 @@ type Config struct { BillingBase string `json:"billing_base"` } `json:"global"` + CN struct { + // ChatBase 国内版 chat/模型目录上游 base 覆盖;空 = 回落内置默认 + // https://copilot.tencent.com(internal/upstream.New 的 ChatBaseCN)。 + // copilot.tencent.com 对海外出口(如 Clash 全局 + TUN)的 TLS 握手直接复位, + // CN 模型目录与对话全部 EOF;官方 WorkBuddy 桌面端已改用 https://www.workbuddy.cn, + // 该域海外出口可达,/v2/chat/completions、/v3/config 与模型目录路径同构。 + ChatBase string `json:"chat_base"` + } `json:"cn"` + Upstream struct { // TimeoutSeconds 短 RPC(refresh/checkin/balance/FetchModels)总时长上限,默认 120。 TimeoutSeconds int `json:"timeout_seconds"` diff --git a/cmd/server/main.go b/cmd/server/main.go index a59daa66..90159e4e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -165,6 +165,7 @@ func main() { up.GlobalEnabled = cfg.Global.Enabled up.ChatBaseGlobal = cfg.Global.ChatBase up.BillingBaseGlobal = cfg.Global.BillingBase + applyCNChatBase(up, cfg) auth.SetGlobalEnabled(cfg.Global.Enabled) // model.json 本地缓存接线(context_length/max_output_tokens 四级查找链第 3 级): // 数据目录与 state.json 同风格(Docker volume 持久化路径)。首次缺失/损坏自动 @@ -516,6 +517,14 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up return restartRequiredFields(newCfg), nil } +// applyCNChatBase 把 config cn.chat_base 装配进上游 client;空则保留内置默认, +// 老配置(无 cn 段)行为不变。 +func applyCNChatBase(up *upstream.Client, cfg *Config) { + if cfg.CN.ChatBase != "" { + up.ChatBaseCN = cfg.CN.ChatBase + } +} + // restartRequiredFields 返回本次改动中无法热生效、需要重启进程的字段名。 // 恒返回完整清单中的"与当前进程装配期依赖相关"的项——面板据此提示用户。 func restartRequiredFields(c *Config) []string { @@ -535,6 +544,8 @@ func restartRequiredFields(c *Config) []string { // 之后不再读取——不在 livecfg 热快照里,也无法热改。此前漏列,导致面板改完 // 显示"已保存"却不提示需要重启,用户以为没生效(issue #102 附带发现 2)。 out = append(out, "upstream.user_agent") + // cn.chat_base 同样只在装配期写进 up.ChatBaseCN(请求路径并发读取,不做热改)。 + out = append(out, "cn.chat_base") if c.Upstash.URL != "" || c.Upstash.Token != "" { out = append(out, "upstash") } diff --git a/config.example.json b/config.example.json index 3eedbffe..8c9ff131 100644 --- a/config.example.json +++ b/config.example.json @@ -41,6 +41,9 @@ "chat_base": "", "billing_base": "" }, + "cn": { + "chat_base": "" + }, "upstream": { "timeout_seconds": 120, "header_timeout_seconds": 120, From 8e79304dbb6b6296ca957f203079937ae3c33215 Mon Sep 17 00:00:00 2001 From: vu1Art1st Date: Sun, 18 Oct 2026 23:52:04 +0800 Subject: [PATCH 15/88] =?UTF-8?q?fix(upstream):=20=E8=AF=86=E5=88=AB=20111?= =?UTF-8?q?58=20=E6=A8=A1=E5=9E=8B=E6=9D=83=E9=99=90=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 11158 归类为账号与模型级确定性拒绝,按 realm 聚合模型锁,并在全域无权限时返回 400 model_unavailable,避免误报 503。补充分类、锁池和端到端回归测试。 (cherry picked from commit 6ee5491b9081bc06639d96afbed42055c7465224) --- internal/pool/cooldown.go | 19 ++++++++--- internal/pool/modelblocked_test.go | 31 +++++++++++++++++ internal/server/handler.go | 13 +++---- internal/server/handler_test.go | 44 ++++++++++++++++++++++++ internal/upstream/client.go | 54 +++++++++++++++++++++++------- internal/upstream/client_test.go | 5 +++ 6 files changed, 143 insertions(+), 23 deletions(-) diff --git a/internal/pool/cooldown.go b/internal/pool/cooldown.go index ae6e669c..70c1dfe1 100644 --- a/internal/pool/cooldown.go +++ b/internal/pool/cooldown.go @@ -340,10 +340,9 @@ func (p *Pool) BlockModelBackoff(uid, model, reason string) { p.dirty.Store(true) } -// BlockModelClear 清除 (账号, 模型) 的 11102 负缓存条目(该模型实测又通了)。半开探测 -// 或正常请求对该模型成功后调用(handler 成功路径)。只清 11102 条目、不碰 6004 独立 -// 冷却表——6004 有自身上游重置墙钟语义,成功不该抹掉。reason 前缀判定区分两者: -// 11102 条目的 reason 恒以 "11102" 开头(见 upstream.BlockModelReason)。 +// BlockModelClear 清除 (账号, 模型) 的确定性模型拒绝负缓存(11102/11158;该模型 +// 实测又通了)。半开探测或正常请求对该模型成功后调用(handler 成功路径)。只清 +// 这两类条目、不碰 6004 独立冷却表——6004 有自身上游重置墙钟语义,成功不该抹掉。 func (p *Pool) BlockModelClear(uid, model string) { if uid == "" || model == "" { return @@ -355,7 +354,7 @@ func (p *Pool) BlockModelClear(uid, model string) { return } mc, exists := e.modelCooldowns[model] - if !exists || !strings.HasPrefix(mc.Reason, "11102") { + if !exists || (!strings.HasPrefix(mc.Reason, "11102") && !strings.HasPrefix(mc.Reason, "11158")) { return } delete(e.modelCooldowns, model) @@ -388,6 +387,13 @@ type ModelBlockStatus struct { // 口径必须与选号一致:用 modelCooled 而非直接查 map,这样 AuditOnly 条目 // (只审计不拦截)不会被误报成阻塞。 func (p *Pool) ModelBlocked(model string) ModelBlockStatus { + return p.ModelBlockedForRealm(model, "") +} + +// ModelBlockedForRealm 与 ModelBlocked 同口径,但只统计指定 realm 的可选账号。 +// 模型请求已按 realm 分池时(如 global:gpt-5.6-sol 只走 global 账号),另一域的 +// 未冷却账号不构成“还能服务”的证明,否则会把本域全锁误报成 no_healthy_account。 +func (p *Pool) ModelBlockedForRealm(model, realm string) ModelBlockStatus { if model == "" { return ModelBlockStatus{} } @@ -397,6 +403,9 @@ func (p *Pool) ModelBlocked(model string) ModelBlockStatus { var st ModelBlockStatus for _, e := range p.byUID { + if realm != "" && e.a.Realm() != realm { + continue + } // 暂停选号(paused)号与禁用号同口径跳过:它此刻不可选,「能服务」的 // 证明不成立——否则一个暂停的健康号会掩盖「其余号全被模型级冷却挡住」。 if e.disabled || e.paused { diff --git a/internal/pool/modelblocked_test.go b/internal/pool/modelblocked_test.go index 40e240cc..be8b85c5 100644 --- a/internal/pool/modelblocked_test.go +++ b/internal/pool/modelblocked_test.go @@ -1,6 +1,7 @@ package pool import ( + "strings" "testing" "time" @@ -68,6 +69,36 @@ func TestModelBlockedOneAccountStillServes(t *testing.T) { } } +// TestModelBlockedForRealmIgnoresOtherRealm 模型请求分池后,另一域的账号不能 +// 掩盖本域全锁:global 两个号都被 11158 锁住时,即使 CN 号未锁也应返回 Blocked。 +func TestModelBlockedForRealmIgnoresOtherRealm(t *testing.T) { + p := New("") + p.Add(&auth.Auth{UID: "cn1", Domain: "www.codebuddy.cn"}) + p.Add(&auth.Auth{UID: "g1", Domain: "www.workbuddy.ai"}) + p.Add(&auth.Auth{UID: "g2", Domain: "www.workbuddy.ai"}) + p.BlockModelBackoff("g1", "gpt-5.6-sol", "11158 model not allowed for account type") + p.BlockModelBackoff("g2", "gpt-5.6-sol", "11158 model not allowed for account type") + + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); !st.Blocked || st.Count != 2 { + t.Fatalf("global 域应判定为全锁,得到 %+v", st) + } else if !strings.HasPrefix(st.Reason, "11158") { + t.Fatalf("模型锁原因应保留 11158,得到 %q", st.Reason) + } + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "cn"); st.Blocked { + t.Fatalf("cn 域仍有可用账号,不应判定为全锁:%+v", st) + } + // 旧入口保持跨域口径,便于非分池调用方向后兼容。 + if st := p.ModelBlocked("gpt-5.6-sol"); st.Blocked { + t.Fatalf("跨域仍有 cn1 可用,旧入口不应报全锁:%+v", st) + } + // 11158 与 11102 一样是确定性负缓存,权限恢复后成功请求必须能清除。 + p.BlockModelClear("g1", "gpt-5.6-sol") + p.BlockModelClear("g2", "gpt-5.6-sol") + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); st.Blocked { + t.Fatalf("清除 11158 后不应再阻塞:%+v", st) + } +} + // TestModelBlockedDisabledDoesNotCount 禁用号不参与判定:它不可用与模型无关, // 既不构成"还能服务"的证明,也不该被算进被挡账号数。 func TestModelBlockedDisabledDoesNotCount(t *testing.T) { diff --git a/internal/server/handler.go b/internal/server/handler.go index 86840137..8271fd7e 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -810,10 +810,10 @@ func (h *Handler) chatCompletions(w http.ResponseWriter, r *http.Request) { st.status = http.StatusServiceUnavailable // 记下"是不是模型级阻塞"。选号返回 nil 有两种完全不同的成因: // (a) 池子真的没有可用号(账号级冷却/在途占满/积分保底); - // (b) 号都在,但每个号都对这个模型处于模型级冷却(11102/6004)。 + // (b) 号都在,但每个号都对这个模型处于模型级冷却(11102/11158/6004)。 // 两者此前都报 no_healthy_account,导致「模型不可用」被读成「号全挂了」。 // 在这里取一次快照,供下方错误构造区分(issue #102 附带发现 1)。 - modelBlock = h.cfg.Pool.ModelBlocked(bareModel) + modelBlock = h.cfg.Pool.ModelBlockedForRealm(bareModel, realm) break } st.uid = acct.UID @@ -1399,7 +1399,7 @@ func isUpstreamTimeout(err error, clientGone bool) bool { // - ErrImageInvalid → 图片格式/数据无效:请求的问题不是账号的问题(同一 body // 换任何号都会得到相同的解析错误)。零动作(不冷却/不熔断/不 NoteError、 // 不喂连败),chatCompletions 已直接透传原文返回不轮转。 -// - ErrModelBlocked → BlockModelBackoff:(账号, 模型) 11102 负缓存避让。 +// - ErrModelBlocked → BlockModelBackoff:(账号, 模型) 11102/11158 负缓存避让。 // - ErrServer → NoteError:喂单一连续失败计数器 fails + 累计错误 errTotal, // 达到 breakerThreshold 触发熔断(指数退避)。 // - 其他(default:ErrClient/ErrNone)→ 只换号不罚(防雪崩),不喂熔断;ErrClient @@ -1502,10 +1502,11 @@ func (h *Handler) applyErrorPolicy(uid string, kind upstream.ErrKind, body, mode // ErrContentBlocked 待遇);chatCompletions 已 fail-fast 503 透传原文、 // 终止轮转——该分支只为文档完备。 case upstream.ErrModelBlocked: - // 11102「该后端无此模型」:(账号, 模型) 负缓存避让。复用 modelCooldowns 机制 - // (与 6004 同域),选号侧 healthyForModel 对该账号自动避开该模型。 + // 11102「该后端无此模型」/11158「该账号类型无此模型权限」: + // (账号, 模型) 负缓存避让。复用 modelCooldowns 机制(与 6004 同域), + // 选号侧 healthyForModel 对该账号自动避开该模型。 // 立即换号(本轮 continue),该账号该模型冷却,下次选号避开。 - h.cfg.Pool.BlockModelBackoff(uid, model, upstream.ModelBlockReason) + h.cfg.Pool.BlockModelBackoff(uid, model, upstream.ModelBlockReasonFor(body)) default: // 其余(ErrClient/ErrNone):只换号不罚(防雪崩),不喂熔断。 // ErrClient(未知 4xx)喂连败计数(issue #114):连续 N 次该形态失败 → diff --git a/internal/server/handler_test.go b/internal/server/handler_test.go index 8ec222d3..82e35486 100644 --- a/internal/server/handler_test.go +++ b/internal/server/handler_test.go @@ -883,6 +883,50 @@ func TestChatHTTP4xxClientDoesNotPenalize(t *testing.T) { } } +// TestChatModelForbiddenByAccountTypeReturns400 复现 global:gpt-5.6-sol 在免费账号上 +// 的 11158:网关应仅轮转 global 域、写 (账号,模型) 避让,最终返回明确的 400 +// model_unavailable,而不是被 CN 账号掩盖成 503 no_healthy_account。 +func TestChatModelForbiddenByAccountTypeReturns400(t *testing.T) { + var calls int + up := newFakeUpstream(t, func(authz string) (int, string, bool) { + calls++ + return 400, `{"code":11158,"msg":"model [gpt-5.6-sol] is not allowed for user type [personal_free_user]","requestId":"req-11158"}`, false + }) + p := testPoolWith( + &auth.Auth{UID: "cn1", Domain: "www.codebuddy.cn", AccessToken: "at-cn", ExpiresAt: 9999999999}, + &auth.Auth{UID: "g1", Domain: "www.workbuddy.ai", AccessToken: "at-global", ExpiresAt: 9999999999}, + ) + h := NewHandler(Config{Pool: p, Upstream: up}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("POST", "/v1/chat/completions", + strings.NewReader(`{"model":"global:gpt-5.6-sol","messages":[]}`))) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body) + } + if calls != 1 { + t.Fatalf("calls=%d want 1(只能尝试 global 账号,不能跨域轮转)", calls) + } + var resp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("bad response JSON: %v body=%s", err, rec.Body) + } + if resp.Error.Code != "model_unavailable" { + t.Errorf("error.code=%q want model_unavailable", resp.Error.Code) + } + if !strings.Contains(resp.Error.Message, "11158") { + t.Errorf("响应应透传上游 11158 原文: %q", resp.Error.Message) + } + if st := p.ModelBlockedForRealm("gpt-5.6-sol", "global"); !st.Blocked || st.Count != 1 { + t.Fatalf("global 账号应写入模型级避让: %+v", st) + } +} + func TestModelsEndpoint(t *testing.T) { h := NewHandler(Config{Pool: testPoolWith(&auth.Auth{UID: "u1", AccessToken: "at", ExpiresAt: 9999999999}), Upstream: upstream.New()}) req := httptest.NewRequest("GET", "/v1/models", nil) diff --git a/internal/upstream/client.go b/internal/upstream/client.go index d3865258..d240b8b3 100644 --- a/internal/upstream/client.go +++ b/internal/upstream/client.go @@ -252,40 +252,73 @@ func IsModelRateLimit(body string) bool { // modelBlockCode 明确指向「该后端无此模型」的业务 code。 const modelBlockCode = "11102" +// modelForbiddenCode 明确指向「该账号类型无权使用该模型」的业务 code。 +// 同一模型在不同账号类型上权限不同,因此与 11102 同属 (账号, 模型) 级 +// 负缓存:换有权限的账号仍可能成功,全部账号都拒绝时再向客户端报模型不可用。 +const modelForbiddenCode = "11158" + // modelBlockMsgMarker 11102 答复的确定性文案(官方 error message 固定短语)。 // 只收这个窄短语,不收 "model ... not found" 宽正则——后者会误伤其他业务的 // not found 措辞。 const modelBlockMsgMarker = "service info not found" +const modelForbiddenMsgMarker = "is not allowed for user type" + // ModelBlockReason 11102 负缓存条目在 pool.modelCooldowns 里的 reason 前缀。 // handler 写 BlockModelBackoff;pool.BlockModelClear 按 "11102" 前缀识别条目 // (与 6004 条目的 "6004 model rate limit" reason 互不干扰)。 const ModelBlockReason = "11102 model not available" -// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 的确定性答复。 +// ModelForbiddenReason 11158 负缓存条目的 reason 前缀。 +// pool.BlockModelClear 同时识别 11102/11158 两种确定性的模型级拒绝。 +const ModelForbiddenReason = "11158 model not allowed for account type" + +// IsModelBlocked 报告 body 是否是「该后端无此模型」(11102) 或「该账号类型无权 +// 使用该模型」(11158) 的确定性答复。 // // 只比对 code/msg 等独立字段,绝不做整段文本子串匹配:错误体还带 requestId 等字段, -// 拿整段文本匹配会把 "11102" 撞在 ID 上、误避让一个本来能用的模型。判定 = -// code 字段精确等于 "11102",或 msg/message 字段命中窄短语 "service info not -// found"(两者任一命中即真)。只看 400/404:429 带 11102 属限流语义。 +// 拿整段文本匹配会把 code 撞在 ID 上、误避让一个本来能用的模型。判定 = +// code 字段精确等于 "11102"/"11158",或 msg/message 字段命中对应窄短语。 +// 只看 400/404:429 带这些 code 仍属限流语义。 // 字段遍历覆盖顶层与 error 子对象两层。 func IsModelBlocked(status int, body string) bool { if (status != http.StatusBadRequest && status != http.StatusNotFound) || body == "" { return false } - // 轻量预检:body 既无 "11102" 又无 marker 时直接短路(大多数 4xx 零分配返回)。 - if !strings.Contains(body, modelBlockCode) && !strings.Contains(strings.ToLower(body), modelBlockMsgMarker) { + lower := strings.ToLower(body) + // 轻量预检:body 无任何已知 code/marker 时直接短路(大多数 4xx 零分配返回)。 + if !strings.Contains(body, modelBlockCode) && !strings.Contains(body, modelForbiddenCode) && + !strings.Contains(lower, modelBlockMsgMarker) && !strings.Contains(lower, modelForbiddenMsgMarker) { return false } + code, msg := modelBlockFields(body) + if code == modelBlockCode || code == modelForbiddenCode { + return true + } + lowerMsg := strings.ToLower(msg) + return strings.Contains(lowerMsg, modelBlockMsgMarker) || strings.Contains(lowerMsg, modelForbiddenMsgMarker) +} + +// ModelBlockReasonFor 返回确定性模型拒绝对应的负缓存 reason。 +// 11158 是账号类型权限问题;其余已由 IsModelBlocked 确认的形态沿用 11102。 +func ModelBlockReasonFor(body string) string { + code, msg := modelBlockFields(body) + if code == modelForbiddenCode || strings.Contains(strings.ToLower(msg), modelForbiddenMsgMarker) { + return ModelForbiddenReason + } + return ModelBlockReason +} + +// modelBlockFields 提取顶层或 error 子对象中的业务 code 与消息。 +func modelBlockFields(body string) (code, msg string) { var root map[string]any if err := json.Unmarshal([]byte(body), &root); err != nil { - return false + return "", "" } nodes := []map[string]any{root} if inner, ok := root["error"].(map[string]any); ok { nodes = append(nodes, inner) } - code, msg := "", "" for _, node := range nodes { for _, key := range []string{"code", "errCode", "error_code"} { if v, ok := node[key]; ok && v != nil && code == "" { @@ -298,10 +331,7 @@ func IsModelBlocked(status int, body string) bool { } } } - if code == modelBlockCode { - return true - } - return strings.Contains(strings.ToLower(msg), modelBlockMsgMarker) + return code, msg } // hasBusinessCode reports whether a JSON error envelope contains an exact diff --git a/internal/upstream/client_test.go b/internal/upstream/client_test.go index 2439635b..6e41820a 100644 --- a/internal/upstream/client_test.go +++ b/internal/upstream/client_test.go @@ -71,6 +71,11 @@ func TestClassify(t *testing.T) { {404, `{"code":11102,"msg":"model [deepseek-v3-2-volc] service info not found"}`, ErrModelBlocked}, {400, `{"error":{"code":"11102","message":"model service info not found"}}`, ErrModelBlocked}, {400, `{"msg":"service info not found"}`, ErrModelBlocked}, + // 11158 是账号类型级模型权限拒绝:同一模型换有权限的账号仍可能成功, + // 按 (账号, 模型) 避让;不能落入 ErrClient 轮转后误报 503。 + {400, `{"code":11158,"msg":"model [gpt-5.6-sol] is not allowed for user type [personal_free_user]"}`, ErrModelBlocked}, + {400, `{"error":{"code":"11158","message":"model is not allowed for user type"}}`, ErrModelBlocked}, + {429, `{"code":11158,"msg":"model is not allowed for user type"}`, ErrSoftRate}, // 11102 撞在 requestId 上不算(不得误避让可用模型)。 {404, `{"requestId":"11102","msg":"ok"}`, ErrNotFound}, // 429 + 11102 → 限流语义(ErrSoftRate),不是模型不存在。 From 667fa0b250de4d14133440e6b5faa58765861184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9B=BE=E4=B8=80=E5=B9=B3?= <2503096804@qq.com> Date: Mon, 12 Oct 2026 09:44:26 +0000 Subject: [PATCH 16/88] =?UTF-8?q?auth:=20=E8=AF=86=E5=88=AB=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D=EF=BC=88token=5Fs?= =?UTF-8?q?ource=3Denterprise=5Fswitch=EF=BC=89=EF=BC=8C=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E7=B1=BB=E6=88=90=E9=95=BF=E4=BB=BB=E5=8A=A1=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=E5=B9=B6=E6=8F=90=E7=A4=BA=E9=87=8D=E7=99=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:浏览器处于企业切换状态时完成 OAuth 授权,签发的 access/refresh token JWT 带 token_source=enterprise_switch。该形态与真企业版(enterpriseId 非空) 不同:成长任务列表 / 额度查询 / 领养 Buddy 等读端点与直连 API 照常,但 /v2/report 行为事件返回 200 却被上游静默丢弃(实测 2026-10-12,同部署双账号 A/B 对照:15 个成长任务全部 0/x,重跑并 90s 内逐 15s 回读进度从不跳动)。 网关凭 IsEnterprise()(仅看 enterpriseId)误判为个人号,队列照常执行—— 每轮白跑全部任务(含 expert_5 等真实对话配额),且无任何报错可查。 修复: - auth: 新增 IsEnterpriseSession()(现解码 JWT 判 token_source,不缓存—— refresh 会整体换发 token,切换状态可能随重新授权翻转);刻意与 IsEnterprise()(计费口径)隔离,额度查询仍走个人口径 - scheduler: 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子四处门控换用 IsEnterpriseSession()(已证实零收益);签到不动(未观测到静默失败, 且失败为显式报错) - panel: 任务队列构建跳过该形态账号并打 WARN;单任务/一键完成接口 422 拒绝并附重登指引;登录热加载新账号时当场 WARN 预警 - 保持不动:任务列表扫描、额度查询、选号、保活(该形态下均可用) 测试:新增 auth/enterprise_session_test.go(8 用例覆盖 token 形态矩阵) 与 scheduler/enterprise_session_gate_test.go(五任务路径 A/B 对照, 含"签到不门控"反向断言);go build / go vet / go test ./... 全绿。 (cherry picked from commit 92827796a2d396ac06b465e615e0e1fccae0c980) --- internal/auth/auth.go | 48 ++++++++++ internal/auth/enterprise_session_test.go | 54 +++++++++++ internal/panel/autotask.go | 9 ++ internal/panel/login.go | 6 ++ internal/panel/taskcenter.go | 8 ++ internal/scheduler/blackcat.go | 4 +- .../scheduler/enterprise_session_gate_test.go | 95 +++++++++++++++++++ internal/scheduler/scheduler.go | 4 +- internal/scheduler/streak.go | 4 +- internal/scheduler/travel.go | 4 +- 10 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 internal/auth/enterprise_session_test.go create mode 100644 internal/scheduler/enterprise_session_gate_test.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go index ea1bef80..56de61c8 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -3,6 +3,7 @@ package auth import ( + "encoding/base64" "encoding/json" "errors" "fmt" @@ -209,6 +210,53 @@ func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } // 无需加锁——Realm() 加锁是因为 realm/Domain 会被 RefreshToken 在锁内改写。 func (a *Auth) IsEnterprise() bool { return strings.TrimSpace(a.EnterpriseID) != "" } +// IsEnterpriseSession 报告当前凭证是否处于「企业上下文」: +// 账号本身是企业版(IsEnterprise),或 AccessToken 签发自**企业切换会话** +// (JWT payload 带 token_source=enterprise_switch——浏览器在企业空间状态下 +// 完成 OAuth 授权时签发)。 +// +// 为什么单独一站:企业切换会话与真企业版的上游表现**不同**(实测 2026-10-12, +// 同一时刻与个人号 A/B 对照)——成长任务列表 / 额度查询 / 领养 Buddy 等**读端点 +// 与直连 API 照常**,但 /v2/report 行为事件**返回 200 却被静默丢弃**(15 个成长 +// 任务全部 0/x,重跑并 90s 内逐 15s 回读进度从不跳动),连登 / 签到同样无效。 +// 即「读得见、记不上账」:不跳过则队列每轮白跑全部任务(含真实对话配额)且 +// 无任何报错。因此行为类任务(活跃上报 / 连登 / 旅行 / 夜猫子 / 成长任务执行) +// 的门控应使用本方法;签到与任务列表扫描不在此列(签到失败是显式报错, +// 列表扫描实测可用)。额度查询 / 选号 / 保活照常(按 IsEnterprise 口径)。 +// +// 每次现解码 JWT 而非 Parse 时缓存:RefreshToken 会整体换发 token, +// 企业切换状态可能随重新授权翻转,缓存会读到过期形态。解码成本仅一次 +// base64 + 小 JSON(每调度轮每账号数次),可忽略。 +func (a *Auth) IsEnterpriseSession() bool { + if a.IsEnterprise() { + return true + } + return jwtTokenSource(a.AccessTokenValue()) == "enterprise_switch" +} + +// jwtTokenSource 解出 JWT payload 中的 token_source 声明;非 JWT / 解码失败 +// 一律返回 ""(老 token 与非标准形态按无此声明处理,零回归)。先按无填充 +// base64url 解(JWT 标准),失败再试带填充形态(兼容非标准签发方)。 +func jwtTokenSource(token string) string { + parts := strings.Split(token, ".") + if len(parts) < 2 { + return "" + } + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if raw, err = base64.URLEncoding.DecodeString(parts[1]); err != nil { + return "" + } + } + var payload struct { + TokenSource string `json:"token_source"` + } + if err := json.Unmarshal(raw, &payload); err != nil { + return "" + } + return payload.TokenSource +} + // isGlobalDomain 判定 domain 是否指向 www.workbuddy.ai 家族。 // 同时接受裸域 workbuddy.ai 与任意子域(HasSuffix("www.workbuddy.ai") 或裸域本身)。 func isGlobalDomain(d string) bool { diff --git a/internal/auth/enterprise_session_test.go b/internal/auth/enterprise_session_test.go new file mode 100644 index 00000000..84342edd --- /dev/null +++ b/internal/auth/enterprise_session_test.go @@ -0,0 +1,54 @@ +package auth + +import ( + "encoding/base64" + "testing" +) + +// mustJWT 用给定 payload 拼一个最小 JWT(header.payload.signature)。 +// IsEnterpriseSession 不校验签名,只解 payload,故签名段可为任意占位。 +func mustJWT(t *testing.T, payload string) string { + t.Helper() + return "hdr." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".sig" +} + +// TestIsEnterpriseSession 企业切换会话判定:JWT token_source=enterprise_switch +// 视为企业上下文;无该声明 / 非 JWT / 空 token 一律否(老凭证零回归); +// enterpriseId 非空短路为真(与 IsEnterprise 同口径)。 +func TestIsEnterpriseSession(t *testing.T) { + cases := []struct { + name string + token string + want bool + }{ + {"enterprise_switch", mustJWT(t, `{"sub":"u1","token_source":"enterprise_switch"}`), true}, + {"无 token_source 声明", mustJWT(t, `{"sub":"u1","scope":"openid profile"}`), false}, + {"其他 token_source", mustJWT(t, `{"sub":"u1","token_source":"password"}`), false}, + {"带填充 base64url", "hdr." + base64.URLEncoding.EncodeToString([]byte(`{"token_source":"enterprise_switch"}`)) + ".sig", true}, + {"payload 非 JSON", "hdr." + base64.RawURLEncoding.EncodeToString([]byte("not-json")) + ".sig", false}, + {"非 JWT 形态", "opaque-token", false}, + {"空 token", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: c.token} + if got := a.IsEnterpriseSession(); got != c.want { + t.Errorf("IsEnterpriseSession()=%v want %v", got, c.want) + } + }) + } + + t.Run("enterpriseId 非空短路为真", func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: "e1", AccessToken: mustJWT(t, `{"sub":"u1"}`)} + if !a.IsEnterpriseSession() { + t.Error("IsEnterprise()=true 时 IsEnterpriseSession() 应恒为 true") + } + }) + + t.Run("企业切换会话不影响 IsEnterprise(计费口径隔离)", func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: mustJWT(t, `{"token_source":"enterprise_switch"}`)} + if a.IsEnterprise() { + t.Error("仅 token_source=enterprise_switch 不应翻转 IsEnterprise(额度查询仍走个人口径)") + } + }) +} diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index c76ce3df..f91460f8 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -622,6 +622,15 @@ func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { if a == nil { return } + // 企业上下文门控(企业版 / 企业切换会话):行为事件会被上游静默丢弃或 + // 直接 403,执行任何成长任务都是零收益白跑(含真实对话配额)——直接拒绝 + // 并给出修复路径,不再让用户对着「已上报但进度 0/x」猜原因 + // (见 auth.IsEnterpriseSession)。 + if a.IsEnterpriseSession() { + writeErr(w, http.StatusUnprocessableEntity, + "该账号为企业版/企业切换会话(token_source=enterprise_switch),无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + return + } var body struct { TaskCode string `json:"task_code"` } diff --git a/internal/panel/login.go b/internal/panel/login.go index a03bfbe1..72cf538c 100644 --- a/internal/panel/login.go +++ b/internal/panel/login.go @@ -280,6 +280,12 @@ func (p *Panel) loginPoll(w http.ResponseWriter, r *http.Request) { delete(p.logins, state) p.loginMu.Unlock() log.Printf("panel: 新账号已热加载 uid=%s nickname=%q realm=%s(免重启生效)", acct.UID, acct.Nickname, sess.realm) + // 企业切换会话预警:该形态下 /v2/report 行为事件被上游静默丢弃(200 不计分), + // 成长任务全部无法点亮(签到/列表/额度/领养照常)。当场给出修复路径, + // 避免用户事后对着「已上报但进度 0/x」排查(见 auth.IsEnterpriseSession)。 + if a.IsEnterpriseSession() && !a.IsEnterprise() { + log.Printf("panel: WARN uid=%s 的凭证为企业切换会话(token_source=enterprise_switch):成长任务行为事件会被上游静默丢弃;建议移除该账号,在浏览器退出企业切换(个人身份)后重新登录", acct.UID) + } writeJSON(w, http.StatusOK, map[string]any{ "done": true, "uid": acct.UID, diff --git a/internal/panel/taskcenter.go b/internal/panel/taskcenter.go index 6d50e5b4..f63ac68d 100644 --- a/internal/panel/taskcenter.go +++ b/internal/panel/taskcenter.go @@ -223,9 +223,17 @@ func (p *Panel) startGrowthQueue(concurrency int, growth bool) (started bool, to return } // 企业版门控:同上(企业版无个人成长体系,任务端点上游一律 403)。 + // 企业切换会话(token_source=enterprise_switch)同跳:任务列表读得到, + // 但执行后行为事件被上游静默丢弃(200 不计分)——每项都是零收益白跑 + // (含 expert_5 等真实对话配额),故不入队并给出重登指引(见 + // auth.IsEnterpriseSession)。 if a.IsEnterprise() { return } + if a.IsEnterpriseSession() { + log.Printf("panel: WARN 队列跳过 uid=%s nickname=%q:凭证为企业切换会话(token_source=enterprise_switch),成长任务行为事件会被上游静默丢弃;请移除该账号,在浏览器退出企业切换(个人身份)后重新登录", a.UID, a.Nickname) + return + } if growth { if tasks, err := p.cfg.Upstream.ListTasks(a); err == nil { for _, t := range tasks { diff --git a/internal/scheduler/blackcat.go b/internal/scheduler/blackcat.go index 3b0a432a..c8f87435 100644 --- a/internal/scheduler/blackcat.go +++ b/internal/scheduler/blackcat.go @@ -38,8 +38,8 @@ func (s *Scheduler) RunBlackcatNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(growth 403 / claim-gift 400「企业账号不支持该操作」) + if a.IsEnterpriseSession() { + continue // 企业版 / 企业切换会话门控:无成长体系(growth 403 / claim-gift 400「企业账号不支持该操作」;切换会话不计分) } need, err := s.cfg.Upstream.BlackcatNeed(a) if err != nil { diff --git a/internal/scheduler/enterprise_session_gate_test.go b/internal/scheduler/enterprise_session_gate_test.go new file mode 100644 index 00000000..50022416 --- /dev/null +++ b/internal/scheduler/enterprise_session_gate_test.go @@ -0,0 +1,95 @@ +package scheduler + +import ( + "encoding/base64" + "testing" + "time" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// sessionTestScheduler 与 enterpriseTestScheduler 同构:switched=true 时账号的 +// AccessToken 为带 token_source=enterprise_switch 的 JWT(企业**切换会话**, +// enterpriseId 为空——网关无法凭 enterpriseId 识别的形态);false 为个人号对照组。 +func sessionTestScheduler(t *testing.T, switched bool) (*Scheduler, *recorderUpstream) { + t.Helper() + r := &recorderUpstream{} + srv := r.server() + t.Cleanup(srv.Close) + + token := "at" + if switched { + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"u1","token_source":"enterprise_switch"}`)) + token = "hdr." + payload + ".sig" + } + a := &auth.Auth{UID: "u1", AccessToken: token, RefreshToken: "rt", ExpiresAt: 9999999999} + p := pool.New("") + p.Add(a) + + up := &upstream.Client{ + HTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + WebBaseCN: srv.URL, + } + s := New(Config{Pool: p, Upstream: up}) + return s, r +} + +// TestEnterpriseSessionGateSkipsBehaviorTasks 企业切换会话的行为类任务门控: +// /v2/report 等行为事件对该形态返回 200 却被上游静默丢弃(实测 2026-10-12, +// 15 个成长任务全部 0/x 且 90s 内进度从不跳动),执行即零收益白跑—— +// 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子必须一个上游调用都不发; +// 签到不在此列(对该形态未观测到静默失败,且失败是显式报错)。 +// 每个子用例配个人号对照,证明"没发请求"是门控生效而非构造有误。 +func TestEnterpriseSessionGateSkipsBehaviorTasks(t *testing.T) { + // 与 TestEnterpriseGateSkipsGrowthTasks 同款:把夜猫子窗口钉在窗口内。 + restoreClock := nowForNightWindow + nowForNightWindow = func() time.Time { return time.Date(2026, 10, 12, 23, 30, 0, 0, time.Local) } + t.Cleanup(func() { nowForNightWindow = restoreClock }) + + cases := []struct { + name string + run func(*Scheduler) + // endpoint 是该任务在**个人号**上必然发起的上游路径(对照组判据)。 + endpoint string + // gated=true 期望切换会话不发该请求;false 期望照常发(不门控)。 + gated bool + }{ + {"签到", func(s *Scheduler) { s.RunCheckinNow() }, "/daily-checkin", false}, + {"活跃上报", func(s *Scheduler) { s.RunActivityNow() }, "/v2/report", true}, + {"猫猫旅行", func(s *Scheduler) { s.RunTravelNow() }, "/activity/growth/buddy/info", true}, + {"夜猫子", func(s *Scheduler) { s.RunBlackcatNow() }, "/v2/activity/growth/tasks", true}, + {"连登管家", func(s *Scheduler) { s.RunStreakBonusNow() }, "/activity/growth/streak", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sSess, rSess := sessionTestScheduler(t, true) + c.run(sSess) + if c.gated && rSess.has(c.endpoint) { + t.Errorf("企业切换会话不应发起 %s,实际请求=%v", c.endpoint, rSess.snapshot()) + } + if !c.gated && !rSess.has(c.endpoint) { + t.Errorf("企业切换会话应照常发起 %s(不门控),实际请求=%v", c.endpoint, rSess.snapshot()) + } + + sPer, rPer := sessionTestScheduler(t, false) + c.run(sPer) + if !rPer.has(c.endpoint) { + t.Errorf("个人号对照应发起 %s,实际请求=%v", c.endpoint, rPer.snapshot()) + } + }) + } +} + +// TestEnterpriseSessionKeepsKeepalive 切换会话照常保活(token 会过期,会话 +// 形态不影响选号/刷新能力)。 +func TestEnterpriseSessionKeepsKeepalive(t *testing.T) { + s, r := sessionTestScheduler(t, true) + s.RunKeepaliveNow() + if !r.has("/token/refresh") { + t.Errorf("企业切换会话必须照常保活,实际请求=%v", r.snapshot()) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index dce8661c..428cc232 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -539,7 +539,9 @@ func (s *Scheduler) runActivity(ctx context.Context) { // 点亮 growth 连登天数 / 解锁 first_buddy 领养——这两者对企业号都是 403 // (「growth system is only available for personal users」),即**零收益**。 // 该类"静默无效"最易被忽略(日志无异常),故显式跳过,不给上游多发一次请求。 - if a.IsEnterprise() { + // 企业切换会话(token_source=enterprise_switch)同跳:report 返回 200 却被 + // 上游静默丢弃(连登不涨),同样是零收益(见 auth.IsEnterpriseSession)。 + if a.IsEnterpriseSession() { continue } // global 账号同样上报(PR #45 实测国际版 /v2/report 在 workbuddy.ai 上 code=0 OK, diff --git a/internal/scheduler/streak.go b/internal/scheduler/streak.go index af3ca7af..50951b10 100644 --- a/internal/scheduler/streak.go +++ b/internal/scheduler/streak.go @@ -29,8 +29,8 @@ func (s *Scheduler) RunStreakBonusNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(/activity/growth/streak 上游一律 403) + if a.IsEnterpriseSession() { + continue // 企业版 / 企业切换会话门控:无成长体系(/activity/growth/streak 企业号 403;切换会话 200 但不计分) } s.streakBonusAccount(a) } diff --git a/internal/scheduler/travel.go b/internal/scheduler/travel.go index 9cc19ca8..a67de59a 100644 --- a/internal/scheduler/travel.go +++ b/internal/scheduler/travel.go @@ -59,8 +59,8 @@ func (s *Scheduler) RunTravelNow() { if a.IsGlobal() { continue // D4 门控:global 无 CN 任务体系,不发起任何上游调用 } - if a.IsEnterprise() { - continue // 企业版门控:无成长体系(/activity/growth/* 上游一律 403),不发起任何上游调用 + if a.IsEnterpriseSession() { + continue // 企业版 / 企业切换会话门控:无成长体系(growth 端点企业号 403;切换会话不计分),不发起任何上游调用 } if !first { time.Sleep(travelAccountDelay) From 48f2eba47ee24afbbced64ccb7b5734a27957e72 Mon Sep 17 00:00:00 2001 From: ftf112233 <3391429706@qq.com> Date: Sat, 17 Oct 2026 11:11:36 +0800 Subject: [PATCH 17/88] =?UTF-8?q?fix(panel):=20=E4=B8=80=E9=94=AE=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E9=81=87=E3=80=8C=E5=B7=B2=E8=BE=BE=E6=A0=87=E6=9C=AA?= =?UTF-8?q?=E9=A2=86=E3=80=8D=E5=85=88=E8=A1=A5=E9=A2=86=E5=86=8D=E8=B7=B3?= =?UTF-8?q?=E8=BF=87=EF=BC=88issue=20#174=20=E9=A1=BA=E5=B8=A6=E9=A1=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runAutoAll 对 before.Current >= before.Target 的任务直接 skipped 跳过,从不 领奖;而经真实对话等链路点亮的任务不经过任何自动动作链路(也不在 25 个 autoActions 白名单里),积分因此长期卡在 2000/2100/2300 上不去 4000(#174)。 改动: - skip 前先按口径领奖(新 helper claimTaskReward:mp 码走 ClaimRewardMP, 其余 ClaimReward,两处调用共用)。claim 幂等(重复领 already_claimed → 0 分 0 能),判据只用 progress 口径 Current>=Target,不依赖 accept_status=="completed"(回读会短暂转 completed 后回滚,据此领奖会误领)。 - 补领结果写回 item(claimed / credit / energy / claim_error),文案追加 「已补领 +x 分 +y 能」或「奖励此前已领取」。 测试:新增 internal/panel/autotask_claim_test.go(httptest 假上游跑真实 runAutoAll 链路)——达标未领必须打一次领奖且 claimed=true/100/5;已领取的 任务不再打领奖端点。已验证去掉修复时该测试失败(非假绿)。 go build/vet/test ./... 全部通过。 Refs #174 (cherry picked from commit 5e6d4130d84e2f5ce47a97e1135d48fa3b496756) --- internal/panel/autotask.go | 45 +++++++--- internal/panel/autotask_claim_test.go | 113 ++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 internal/panel/autotask_claim_test.go diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index f91460f8..2cd5b03e 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -215,6 +215,15 @@ var mpTaskCodes = map[string]bool{ // isMPTaskCode 报告任务是否小程序口径专属(决定回读/接受/领奖走 mp 变体)。 func isMPTaskCode(code string) bool { return mpTaskCodes[code] } +// claimTaskReward 按任务码口径领奖:mp 码走 chat 域 mp 端点,其余走 Web 端。 +// claim 幂等(重复领返回 already_claimed → 0 分 0 能)。 +func claimTaskReward(p *Panel, a *auth.Auth, code string) (credit, energy int64, err error) { + if isMPTaskCode(code) { + return p.cfg.Upstream.ClaimRewardMP(a, code) + } + return p.cfg.Upstream.ClaimReward(a, code) +} + // taskByCode 拉取任务列表并定位单个任务;未找到返回 nil(不视为错误)。 // 双口径:mp 专属任务在默认列表查不到,自动回落 mp 列表(仅对已登记的 mp 码, // 未知码不多打一次上游)。 @@ -1191,7 +1200,29 @@ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { } if before.Claimed || before.Current >= before.Target && before.Target > 0 { item["status"] = "skipped" - item["message"] = "已完成(" + taskProgressText(before) + ")" + msg := "已完成(" + taskProgressText(before) + ")" + // 达标未领:先补领再跳过(issue #174)。经真实对话等链路点亮的 + // 任务不经过任何自动动作,若这里只 skip,奖永远没人领(积分卡在 + // 半路)。claim 幂等(重复领返回 already_claimed → 0 分 0 能), + // 宁可多试一轮也不漏领;判据用 progress(Current>=Target),不 + // 依赖 accept_status=="completed"(回读会短暂转 completed 后回滚, + // 据此领奖会误领)。 + if !before.Claimed { + credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) + switch { + case cerr != nil: + item["claim_error"] = cerr.Error() + msg += ";补领失败(可在列表手动重试)" + case credit > 0 || energy > 0: + item["claimed"] = true + item["credit"] = credit + item["energy"] = energy + msg += fmt.Sprintf(";已补领 +%d 分 +%d 能", credit, energy) + default: + msg += ";奖励此前已领取" + } + } + item["message"] = msg out = append(out, item) continue } @@ -1213,15 +1244,9 @@ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { item["progress_after"] = taskProgressText(after) // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。 // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 - if after != nil && after.Claimable { - item["claimable"] = true - var credit, energy int64 - var cerr error - if isMPTaskCode(act.TaskCode) { - credit, energy, cerr = p.cfg.Upstream.ClaimRewardMP(a, act.TaskCode) - } else { - credit, energy, cerr = p.cfg.Upstream.ClaimReward(a, act.TaskCode) - } + if after != nil && after.Claimable { + item["claimable"] = true + credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) if cerr == nil { item["claimed"] = true item["credit"] = credit diff --git a/internal/panel/autotask_claim_test.go b/internal/panel/autotask_claim_test.go new file mode 100644 index 00000000..d34d409c --- /dev/null +++ b/internal/panel/autotask_claim_test.go @@ -0,0 +1,113 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// TestRunAutoAllClaimsDoneButUnclaimed issue #174 顺带项:runAutoAll 遇到 +// 「已达标但未领取」的任务时,必须先把奖补领掉再标记 skipped——真实对话等 +// 链路点亮的任务不经过任何自动动作,若只 skip 不领,积分永远卡在半路。 +func TestRunAutoAllClaimsDoneButUnclaimed(t *testing.T) { + old := reportGap + reportGap = 0 // 项间节流在测试里无意义,避免拖慢 + t.Cleanup(func() { reportGap = old }) + + var claims int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + // 唯一存在且已达标(5/5)未完成领取的任务;accept_status=accepted, + // 因此阶段 0 不会再产生 accept 请求。 + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"tasks": []map[string]any{ + {"task_code": "chat_5", "title": "对话 5 次", "target": 5, "current": 5, + "accept_status": "accepted", "reward_credit": 100, "reward_energy": 5}, + }}, + }) + case strings.HasPrefix(r.URL.Path, "/activity/growth/tasks/") && strings.HasSuffix(r.URL.Path, "/claim"): + atomic.AddInt32(&claims, 1) + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"already_claimed": false, "credit": 100, "energy": 5}, + }) + default: + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + } + })) + defer srv.Close() + + p := &Panel{cfg: Config{Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }}} + out := p.runAutoAll(&auth.Auth{AccessToken: "at", UID: "u1"}) + + var hit map[string]any + for _, item := range out { + if item["task_code"] == "chat_5" { + hit = item + } + } + if hit == nil { + t.Fatalf("runAutoAll 未处理 chat_5,out=%v", out) + } + if got := atomic.LoadInt32(&claims); got != 1 { + t.Fatalf("claim 调用次数 = %d, want 1(达标未领必须补领)", got) + } + if hit["claimed"] != true { + t.Errorf("claimed = %v, want true;out=%+v", hit["claimed"], hit) + } + if hit["credit"] != int64(100) || hit["energy"] != int64(5) { + t.Errorf("credit/energy = %v/%v, want 100/5", hit["credit"], hit["energy"]) + } + // 仍是 skipped(进度早已达标),但文案要体现补领结果。 + if hit["status"] != "skipped" { + t.Errorf("status = %v, want skipped", hit["status"]) + } + if msg, _ := hit["message"].(string); !strings.Contains(msg, "已补领") { + t.Errorf("message = %q, 应含「已补领」", msg) + } +} + +// TestRunAutoAllSkipsClaimedTask 已领取的任务不再触发领奖请求(幂等入口不应 +// 无意义地打上游)。 +func TestRunAutoAllSkipsClaimedTask(t *testing.T) { + old := reportGap + reportGap = 0 + t.Cleanup(func() { reportGap = old }) + + var claims int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + json.NewEncoder(w).Encode(map[string]any{ + "code": 0, "msg": "OK", + "data": map[string]any{"tasks": []map[string]any{ + {"task_code": "chat_5", "target": 5, "current": 5, "accept_status": "claimed"}, + }}, + }) + case strings.HasSuffix(r.URL.Path, "/claim"): + atomic.AddInt32(&claims, 1) + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + default: + json.NewEncoder(w).Encode(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{}}) + } + })) + defer srv.Close() + + p := &Panel{cfg: Config{Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }}} + p.runAutoAll(&auth.Auth{AccessToken: "at", UID: "u1"}) + if got := atomic.LoadInt32(&claims); got != 0 { + t.Fatalf("已领取任务不应再打领奖端点,got %d", got) + } +} From d89c3253c209c80af4d23718c70cd8c14343b2f1 Mon Sep 17 00:00:00 2001 From: ftf112233 <3391429706@qq.com> Date: Sat, 17 Oct 2026 11:35:52 +0800 Subject: [PATCH 18/88] =?UTF-8?q?feat(panel):=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=A1=A5=E9=A2=86=E6=B8=85=E6=89=AB=E2=80=94=E2=80=94=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E5=B9=82=E7=AD=89=E8=A1=A5=E9=A2=86=E3=80=8C=E5=B7=B2?= =?UTF-8?q?=E8=BE=BE=E6=A0=87=E6=9C=AA=E9=A2=86=E3=80=8D=E7=9A=84=E6=88=90?= =?UTF-8?q?=E9=95=BF=E4=BB=BB=E5=8A=A1=E5=A5=96=E5=8A=B1=EF=BC=88issue=20#?= =?UTF-8?q?174=20=E4=B8=BB=E4=BD=93=E9=93=BE=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三条现有链路都不负责「把已达标的奖领掉」:growthPending 要求任务必须有自动 动作(25 个 autoActions 白名单),不在白名单的达标任务永远扫不进队列;队列 路径要先重跑行为动作且一天只跑一次;一键完成对达标任务直接跳过。于是积分 卡在 2000/2100/2300 上不去 4000。 新增一条职责单一的周期幂等清扫:只 accept 未接受的任务、只 claim 已达标未 领的任务,不跑任何对话/上报行为。 - internal/panel/tasksweep.go:RunTaskSweepOnce 并发扫全部非禁用账号(账号 串行),默认列表 + mp 列表按 task_code 去重合并;accept 按默认/mp 口径 分开提交(端标记不同);claim 按 Task.Claimable 走既有的 claimTaskReward。 判据只用 progress 口径,不用 accept_status=="completed"(会回滚→误领); claim 幂等(重复领 already_claimed → 0 分 0 能)。global / 企业版账号零 上游请求;命中 per-account 锁(正在跑动作)的账号整轮跳过、不打断链路。 - scheduler:TaskSweepHook + StartTaskSweep 独立 ticker + SetTaskSweepInterval 热改(同 StartBalanceRefresh 结构,关=0 时不启动)。 - config/main:schedule.task_sweep_enabled(缺省 true)/ task_sweep_minutes (缺省 15,<=0 回落),normalize 计算 TaskSweepInterval,启动日志与面板 保存配置时的热更新。 - 面板配置页:新增开关 + 间隔输入框(app.js CFG_MAP + index.html 控件)。 测试(internal/panel/tasksweep_test.go,httptest 假上游跑真实链路): default/mp 分流与计数、两轮幂等(第二轮 0 次领奖请求)、global/企业版零请求、 单项领奖失败不阻塞其余、忙账号跳过且解锁后自动补。 go build/vet/test ./... 全部通过。 Refs #174 (cherry picked from commit 162762fc0a24a8481e52cebee0a527fc7722cd03) --- cmd/server/config.go | 17 ++ cmd/server/main.go | 11 ++ internal/panel/app.js | 1 + internal/panel/index.html | 8 + internal/panel/tasksweep.go | 182 ++++++++++++++++++++ internal/panel/tasksweep_test.go | 274 +++++++++++++++++++++++++++++++ internal/scheduler/scheduler.go | 89 +++++++++- 7 files changed, 580 insertions(+), 2 deletions(-) create mode 100644 internal/panel/tasksweep.go create mode 100644 internal/panel/tasksweep_test.go diff --git a/cmd/server/config.go b/cmd/server/config.go index 01eb00b2..812e2da1 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -106,6 +106,13 @@ type Config struct { // 解冻语义同签到(余额 > 0 的冷却账号自动解冻),但不做签到不刷 token。 BalanceRefreshEnabled bool `json:"balance_refresh_enabled"` // 缺省 true;false = 关闭 BalanceRefreshMinutes int `json:"balance_refresh_minutes"` // 缺省 5;<=0 回落 5 + + // 任务补领清扫(issue #174):周期把「已达标但未领奖」的成长任务领掉—— + // 这条链路此前完全缺失(成长队列要求有自动动作、一键完成对达标任务直接跳过), + // 导致积分长期卡在 2000/2100/2300 上不去 4000。 + // 只做 accept/claim 两条幂等请求,不跑任何行为链路;分钟级开销可忽略。 + TaskSweepEnabled bool `json:"task_sweep_enabled"` // 缺省 true;false = 关闭 + TaskSweepMinutes int `json:"task_sweep_minutes"` // 缺省 15;<=0 回落 15 } `json:"schedule"` Global struct { @@ -245,6 +252,7 @@ type Config struct { SessionTTL time.Duration `json:"-"` SessionGCInterval time.Duration `json:"-"` BalanceRefreshInterval time.Duration `json:"-"` // 0 = 不启动(enabled=false) + TaskSweepInterval time.Duration `json:"-"` // 0 = 不启动(task_sweep_enabled=false) ExpiringSoonDur time.Duration `json:"-"` // CostExploreIntervalDur 解析后的 costTier 探索窗口(issue #136);0 = 关停。 CostExploreIntervalDur time.Duration `json:"-"` @@ -286,6 +294,8 @@ func Default() *Config { c.Schedule.BlackcatEnabled = true c.Schedule.BalanceRefreshEnabled = true c.Schedule.BalanceRefreshMinutes = 5 + c.Schedule.TaskSweepEnabled = true + c.Schedule.TaskSweepMinutes = 15 c.Upstream.TimeoutSeconds = 120 // HeaderTimeoutSeconds/IdleTimeoutSeconds 默认 0(未设置态),回落见 normalize()。 c.Upstream.HeaderTimeoutSeconds = 0 @@ -613,6 +623,13 @@ func (c *Config) normalize() error { } c.BalanceRefreshInterval = time.Duration(c.Schedule.BalanceRefreshMinutes) * time.Minute } + // 任务补领清扫:启用时 minutes<=0 回落默认 15;关闭时 interval 保持 0(不启动)。 + if c.Schedule.TaskSweepEnabled { + if c.Schedule.TaskSweepMinutes <= 0 { + c.Schedule.TaskSweepMinutes = 15 + } + c.TaskSweepInterval = time.Duration(c.Schedule.TaskSweepMinutes) * time.Minute + } if err := c.validateScheduleHours(); err != nil { return err } diff --git a/cmd/server/main.go b/cmd/server/main.go index 90159e4e..2885c2c6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -227,6 +227,12 @@ func main() { case cfg.BalanceRefreshInterval > 0: log.Printf("余额后台刷新:每 %s(签到时点照常额外刷新)", cfg.BalanceRefreshInterval) } + switch { + case !cfg.Schedule.TaskSweepEnabled: + log.Printf("任务补领清扫已禁用(schedule.task_sweep_enabled=false)") + case cfg.TaskSweepInterval > 0: + log.Printf("任务补领清扫:每 %s(补领已达标未领的成长任务奖励)", cfg.TaskSweepInterval) + } if cfg.Schedule.IncludeDisabledInTasks { log.Printf("保号任务覆盖禁用账号(schedule.include_disabled_in_tasks=true):禁用号仍签到 / 活跃 / 保活 / 刷新余额,但不参与选号") } @@ -291,6 +297,9 @@ func main() { // 成长任务队列每日自动执行(与「执行全部待办」同管线):Sequential 族零点解锁后 // 无需手动扫描;hook 返回即启动(异步执行),已在跑时内部跳过。 sch.SetGrowthHook(pn.RunGrowthQueueOnce) + // 清扫回调返回统计汇总(测试/日志可观测),调度器 hook 只要求 func(): + // 这里收掉返回值——汇总已由 panel 侧内部日志输出。 + sch.SetTaskSweepHook(func() { pn.RunTaskSweepOnce() }) log.SetOutput(io.MultiWriter(os.Stderr, pn.Logs())) server.SetChatLogOutput(io.MultiWriter(os.Stdout, pn.Logs())) @@ -322,6 +331,7 @@ func main() { defer stop() go sch.Run(ctx) sch.StartBalanceRefresh(ctx, cfg.BalanceRefreshInterval) + sch.StartTaskSweep(ctx, cfg.TaskSweepInterval) // 启动即预热模型积分倍率表:倍率只在 FetchModels/FetchGlobalModelInfos 成功时 // 填充(两者均懒触发),重启后到首次 /v1/models 或面板模型页被访问之前, @@ -512,6 +522,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up !newCfg.Schedule.ActivityEnabled, !newCfg.Schedule.KeepaliveEnabled, !newCfg.Schedule.BlackcatEnabled, !newCfg.Schedule.GrowthEnabled) sch.SetBalanceInterval(newCfg.BalanceRefreshInterval) + sch.SetTaskSweepInterval(newCfg.TaskSweepInterval) sch.SetIncludeDisabledInTasks(newCfg.Schedule.IncludeDisabledInTasks) return restartRequiredFields(newCfg), nil diff --git a/internal/panel/app.js b/internal/panel/app.js index af399877..b76e1869 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -1045,6 +1045,7 @@ const CFG_MAP = { activity_hours: ['schedule', 'activity_hours'], activity_enabled: ['schedule', 'activity_enabled'], keepalive_hours: ['schedule', 'keepalive_hours'], keepalive_enabled: ['schedule', 'keepalive_enabled'], balance_refresh_enabled: ['schedule', 'balance_refresh_enabled'], balance_refresh_minutes: ['schedule', 'balance_refresh_minutes'], + task_sweep_enabled: ['schedule', 'task_sweep_enabled'], task_sweep_minutes: ['schedule', 'task_sweep_minutes'], include_disabled_in_tasks: ['schedule', 'include_disabled_in_tasks'], max_in_flight: ['pool', 'max_in_flight'], max_in_flight_global: ['pool', 'max_in_flight_global'], breaker_threshold: ['pool', 'breaker_threshold'], diff --git a/internal/panel/index.html b/internal/panel/index.html index 47fd5ac5..e8c5994d 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -966,6 +966,14 @@

积分扣除历史

每日到点自动「扫描 + 执行全部待办」(Sequential 任务链零点解锁后自动推进,建议零点后)
+
+
任务补领清扫 +
+ +
+
周期扫描全部账号,把「进度已达标但尚未领奖」的成长任务补领掉(不含自动动作的口令任务也能领),并顺带接受未接受的任务。只发 accept / claim 两条幂等请求,不跑任何对话或上报行为,开销可忽略。
用途:真实对话点亮的任务不在自动动作白名单里,成长队列与「一键完成」都不会去领它——积分因此卡在半路上不去(#174)。
+
保号任务覆盖已禁用账号
禁用只关选号:打开后已禁用的账号仍会签到 / 活跃上报 / 保活 / 刷新余额(依旧不参与选号)。适合「一次只放开一个账号、用禁用做流量开关」的轮换养号用法,默认关闭。
若只想让单个账号临时退出选号但保留保号,直接用账号行的「暂停选号」按钮(账号级,无需打开本开关)。
diff --git a/internal/panel/tasksweep.go b/internal/panel/tasksweep.go new file mode 100644 index 00000000..25a00ae5 --- /dev/null +++ b/internal/panel/tasksweep.go @@ -0,0 +1,182 @@ +// tasksweep.go 周期幂等「补领清扫」。 +// +// 职责单一——只 accept 尚未接受的任务、只 claim 已达标未领的任务,**不跑任何 +// 行为链路**(不真实对话、不上报事件)。来源见 issue #174: +// +// - growthPending 要求任务必须有自动动作(25 个 autoActions 白名单),不在 +// 白名单的任务即使达标也永远扫不进队列、永远不领奖; +// - 队列路径要先重跑一遍行为动作(可能失败/前置条件已变),且一天只跑一次; +// - 「一键完成」对已达标任务直接跳过不领。 +// +// 三条链路都不负责「把已达标的奖励领掉」,于是积分卡在 2000/2100/2300 上不去 4000。 +// 本文件补的就是这条缺失链路:分钟级周期、幂等、可开关。 +// +// 设计要点(与 issue 一致): +// - 判据只用 progress 口径 Task.Claimable(!claimed && target>0 && current>=target), +// **不用 accept_status == "completed"**:它在回读时会短暂转 completed 后整体回滚, +// 据此领奖会误领。 +// - claim 幂等(重复领返回 already_claimed → 0 分 0 能),宁可多试一轮也不漏领。 +// - 与单任务 / 一键完成 / 成长队列共用 per-account 锁:正在跑动作的账号本轮跳过, +// 不打断正在进行的动作链路。 +// - global(无 CN 成长体系)与企业版(上游 403 growth system is only available +// for personal users)账号都不发起任何上游请求。 +package panel + +import ( + "log" + "sync" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// TaskSweepStats 一轮补领清扫的汇总(供日志与潜在的未来 HTTP 回执)。 +type TaskSweepStats struct { + Accounts int `json:"accounts"` // 参与清扫的账号数 + Skipped int `json:"skipped"` // 因正在执行任务动作而跳过的账号数 + Accepted int `json:"accepted"` // 本轮接受成功的任务数 + Claimed int `json:"claimed"` // 本轮领奖成功的任务数 + Credits int64 `json:"credits"` // 本轮到账积分 + Energy int64 `json:"energy"` // 本轮到账能量 + Errors int `json:"errors"` // 本轮出错(不中断)的次数 +} + +// RunTaskSweepOnce 对所有账号做一轮幂等补领清扫(账号间并发、账号内串行)。 +// 由 scheduler 的 ticker 调用(详见 scheduler.StartTaskSweep)。 +func (p *Panel) RunTaskSweepOnce() TaskSweepStats { + var st TaskSweepStats + var mu sync.Mutex + var wg sync.WaitGroup + + for _, ps := range p.cfg.Pool.List() { + if ps.Disabled { + continue // 禁用账号不参与(与任务中心扫描同口径) + } + a := p.cfg.Pool.AuthByUID(ps.UID) + if a == nil || a.IsGlobal() || a.IsEnterprise() { + continue // 无个人成长体系:零上游请求 + } + wg.Add(1) + go func(a *auth.Auth, uid string) { + defer wg.Done() + r := p.sweepAccount(a, uid) + mu.Lock() + defer mu.Unlock() + st.Accounts += r.accounts + st.Skipped += r.skipped + st.Accepted += r.accepted + st.Claimed += r.claimed + st.Credits += r.credit + st.Energy += r.energy + st.Errors += r.errors + }(a, ps.UID) + } + wg.Wait() + + if st.Claims() > 0 || st.Errors > 0 || st.Accepted > 0 { + log.Printf("panel: 任务补领清扫:%d 个账号(跳过 %d)· 接受 %d · 领奖 %d · +%d 分 +%d 能 · 出错 %d", + st.Accounts, st.Skipped, st.Accepted, st.Claimed, st.Credits, st.Energy, st.Errors) + } + return st +} + +// Claims 本轮实际到账奖励的任务数(与 Claimed 一致,语义化别名)。 +func (s TaskSweepStats) Claims() int { return s.Claimed } + +// sweepResult 单账号清扫结果(RunTaskSweepOnce 汇总用,非导出)。 +type sweepResult struct { + accounts, skipped, accepted, claimed, errors int + credit, energy int64 +} + +// sweepAccount 单账号一轮清扫。命中账号锁(正在跑任务动作)时整账号跳过: +// 不打断进行中的链路,下一轮自然补上。 +func (p *Panel) sweepAccount(a *auth.Auth, uid string) (res sweepResult) { + res.accounts = 1 + if !p.tryLockAccount(uid) { + res.skipped = 1 + return res + } + defer p.unlockAccount(uid) + + tasks, err := p.cfg.Upstream.ListTasks(a) + if err != nil { + res.errors++ + return res + } + // 小程序口径列表(school_season / Sequential_Tasks_1 等仅在 mp 头下发): + // 与默认口径按 task_code 去重合并;mp 列表失败静默(活动结束 / 无 mp 任务)。 + mpOnly := map[string]bool{} + if mpTasks, mErr := p.cfg.Upstream.ListTasksMP(a); mErr == nil { + seen := map[string]bool{} + for _, t := range tasks { + seen[t.TaskCode] = true + } + for _, t := range mpTasks { + if seen[t.TaskCode] { + continue + } + mpOnly[t.TaskCode] = true + tasks = append(tasks, t) + } + } + + // 阶段一:接受未接受的任务。默认口径与 mp 口径必须**分开提交**(端标记不同, + // 混提交会让 mp 任务报 task not found)。 + var codes, mpCodes []string + for _, t := range tasks { + if t.Claimed || t.Locked { + continue + } + if t.AcceptStatus == "accepted" || t.AcceptStatus == "completed" { + continue + } + if isMPTaskCode(t.TaskCode) || mpOnly[t.TaskCode] { + mpCodes = append(mpCodes, t.TaskCode) + } else { + codes = append(codes, t.TaskCode) + } + } + acceptCodes := func(cs []string, isMP bool) { + if len(cs) == 0 { + return + } + var aErr error + if isMP { + aErr = p.cfg.Upstream.AcceptTasksMP(a, cs) + } else { + aErr = p.cfg.Upstream.AcceptTasks(a, cs) + } + if aErr != nil { + res.errors++ + return + } + res.accepted += len(cs) + } + acceptCodes(codes, false) + acceptCodes(mpCodes, true) + + // 阶段二:领取已达标的奖励。claim 幂等(重复领 already_claimed → 0 分 0 能), + // 单项出错只记录、不中断后续任务。 + for _, t := range tasks { + if !t.Claimable { + continue + } + // mp 口径任务必须走 mp 变体领奖端点(缺 mp 头上游返回 task not found); + // 其余走 Web 域端点——与 taskcenter / autotask 的领奖口径一致。 + var c, e int64 + var cErr error + if isMPTaskCode(t.TaskCode) || mpOnly[t.TaskCode] { + c, e, cErr = p.cfg.Upstream.ClaimRewardMP(a, t.TaskCode) + } else { + c, e, cErr = p.cfg.Upstream.ClaimReward(a, t.TaskCode) + } + if cErr != nil { + res.errors++ + continue + } + res.credit += c + res.energy += e + res.claimed++ + } + return res +} diff --git a/internal/panel/tasksweep_test.go b/internal/panel/tasksweep_test.go new file mode 100644 index 00000000..e1bda37d --- /dev/null +++ b/internal/panel/tasksweep_test.go @@ -0,0 +1,274 @@ +package panel + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// sweepTask 构造假上游的一个任务条目。 +func sweepTask(code string, target, current int64, acceptStatus string, extra map[string]any) map[string]any { + t := map[string]any{ + "task_code": code, "title": code, "target": target, + "current": current, "accept_status": acceptStatus, + "reward_credit": 100, "reward_energy": 5, + } + for k, v := range extra { + t[k] = v + } + return t +} + +// sweepBackend 假上游:默认任务列表 + mp 列表 + accept + claim,带请求计数。 +type sweepBackend struct { + listFn func(isMP bool) []map[string]any + acceptFn func(codes []string, isMP bool) (int, string) // (status, body) + claimFn func(code string) (int, map[string]any) // (status, data) + + reqs atomic.Int32 + accepts atomic.Int32 + claims atomic.Int32 +} + +func sweepTasksBody(tasks []map[string]any) string { + b, _ := json.Marshal(map[string]any{"code": 0, "msg": "OK", "data": map[string]any{"tasks": tasks}}) + return string(b) +} + +func (b *sweepBackend) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + b.reqs.Add(1) + isMP := r.Header.Get("X-Client-Platform") == "miniprogram" + switch { + case r.URL.Path == "/v2/activity/growth/tasks": + if b.listFn != nil { + w.Write([]byte(sweepTasksBody(b.listFn(isMP)))) + return + } + w.Write([]byte(sweepTasksBody(nil))) + case strings.HasSuffix(r.URL.Path, "/accept"): + var body struct { + TaskCodes []string `json:"task_codes"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + for range body.TaskCodes { + b.accepts.Add(1) + } + deferFN := func() { + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + if b.acceptFn == nil { + deferFN() + return + } + if status, msg := b.acceptFn(body.TaskCodes, isMP); status != 0 { + w.WriteHeader(status) + w.Write([]byte(msg)) + return + } + deferFN() + case strings.HasSuffix(r.URL.Path, "/claim"): + code := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/activity/growth/tasks/"), "/claim") + b.claims.Add(1) + if b.claimFn == nil { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"already_claimed":true}}`)) + return + } + status, data := b.claimFn(code) + if status != 0 { + w.WriteHeader(status) + } + if data == nil { + data = map[string]any{"already_claimed": true} + } + out, _ := json.Marshal(map[string]any{"code": 0, "msg": "OK", "data": data}) + w.Write(out) + default: + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + } +} + +// newSweepPanel 用给定假后端与账号搭一个可被 RunTaskSweepOnce 直接调用的 Panel。 +func newSweepPanel(t *testing.T, b *sweepBackend, accounts ...*auth.Auth) *Panel { + t.Helper() + srv := httptest.NewServer(b.handler()) + t.Cleanup(srv.Close) + p := pool.New("") + for _, a := range accounts { + p.Add(a) + } + return &Panel{cfg: Config{ + Pool: p, + Upstream: &upstream.Client{ + HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL, + }, + }} +} + +func claimOK(data map[string]any) (int, map[string]any) { return 0, data } + +// TestTaskSweepAcceptAndClaim 单轮清扫:未接受的任务走 accept(默认/mp 分开提交)、 +// 已达标的走 claim,其余(已领取 / locked / 未达标)不产生任何请求。 +func TestTaskSweepAcceptAndClaim(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return []map[string]any{ + sweepTask("school_season", 1, 1, "accepted", nil), // mp 口径达标任务 + } + } + return []map[string]any{ + sweepTask("chat_5", 5, 5, "accepted", nil), // 达标未领 → claim + sweepTask("invite_1", 1, 0, "not_accepted", nil), // 未接受 → accept + sweepTask("locked_1", 3, 0, "not_accepted", map[string]any{"locked": true}), + sweepTask("halfway", 10, 3, "accepted", nil), // 未达标 → 不动 + sweepTask("done_1", 5, 5, "claimed", nil), // 已领取 → 不动 + } + }, + claimFn: func(code string) (int, map[string]any) { + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + st := p.RunTaskSweepOnce() + + if st.Accounts != 1 || st.Skipped != 0 { + t.Fatalf("accounts=%d skipped=%d, want 1/0", st.Accounts, st.Skipped) + } + // invite_1 需要 accept(mp 列表里的 school_season 已 accepted,不再接受)。 + if st.Accepted != 1 { + t.Errorf("accepted = %d, want 1(只有 invite_1 未接受)", st.Accepted) + } + // chat_5 与 school_season 均达标未领 → 各领一次。 + if st.Claimed != 2 || st.Credits != 200 || st.Energy != 10 { + t.Errorf("claimed=%d credits=%d energy=%d, want 2/200/10", st.Claimed, st.Credits, st.Energy) + } + if st.Errors != 0 { + t.Errorf("errors = %d, want 0", st.Errors) + } + if got := b.claims.Load(); got != 2 { + t.Errorf("claim 请求数 = %d, want 2", got) + } +} + +// TestTaskSweepIdempotent 两轮幂等:第二轮任务已被标记为 claimed(不再 Claimable), +// 不应重复打领奖端点;即便上游返回 already_claimed 也不计错误。 +func TestTaskSweepIdempotent(t *testing.T) { + claimed := map[string]bool{} + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + status := "accepted" + if claimed["chat_5"] { + status = "claimed" + } + return []map[string]any{sweepTask("chat_5", 5, 5, status, nil)} + }, + claimFn: func(code string) (int, map[string]any) { + claimed[code] = true + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + + first := p.RunTaskSweepOnce() + if first.Claimed != 1 || first.Credits != 100 { + t.Fatalf("first round: claimed=%d credits=%d, want 1/100", first.Claimed, first.Credits) + } + // 第二轮:状态已 claimed → 不再 Claimable → 领奖端点 0 次调用。 + b.claims.Store(0) + second := p.RunTaskSweepOnce() + if second.Claimed != 0 || second.Errors != 0 { + t.Errorf("second round: claimed=%d errors=%d, want 0/0", second.Claimed, second.Errors) + } + if got := b.claims.Load(); got != 0 { + t.Errorf("第二轮不应再打领奖端点,got %d", got) + } +} + +// TestTaskSweepSkipsGlobalAndEnterprise global / 企业版账号无个人成长体系: +// 零上游请求(与任务中心扫描同一门控)。 +func TestTaskSweepSkipsGlobalAndEnterprise(t *testing.T) { + b := &sweepBackend{} + p := newSweepPanel(t, b, + &auth.Auth{UID: "g1", AccessToken: "at", Domain: "www.workbuddy.ai"}, + &auth.Auth{UID: "e1", AccessToken: "at", EnterpriseID: "ent-1"}, + ) + st := p.RunTaskSweepOnce() + if st.Accounts != 0 || st.Errors != 0 || st.Claimed != 0 { + t.Errorf("stats = %+v, want 全零", st) + } + if got := b.reqs.Load(); got != 0 { + t.Fatalf("global/企业版账号不应产生任何上游请求,got %d", got) + } +} + +// TestTaskSweepClaimErrorDoesNotBlock 单个领奖失败只计 errors,不中断后续任务, +// 也不掩盖其他任务的成功。 +func TestTaskSweepClaimErrorDoesNotBlock(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + return []map[string]any{ + sweepTask("bad_1", 5, 5, "accepted", nil), + sweepTask("good_1", 5, 5, "accepted", nil), + } + }, + claimFn: func(code string) (int, map[string]any) { + if code == "bad_1" { + return 400, map[string]any{"code": 400, "msg": "task not completed"} + } + return claimOK(map[string]any{"already_claimed": false, "credit": 100, "energy": 5}) + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + st := p.RunTaskSweepOnce() + if st.Errors != 1 { + t.Errorf("errors = %d, want 1", st.Errors) + } + if st.Claimed != 1 || st.Credits != 100 { + t.Errorf("claimed=%d credits=%d, want 1/100(失败项不阻挡成功项)", st.Claimed, st.Credits) + } +} + +// TestTaskSweepSkipsBusyAccount 账号正在跑任务动作(持有 per-account 锁)时整账号 +// 跳过:不打断进行中的链路,下一轮自然补。 +func TestTaskSweepSkipsBusyAccount(t *testing.T) { + b := &sweepBackend{ + listFn: func(isMP bool) []map[string]any { + if isMP { + return nil + } + return []map[string]any{sweepTask("chat_5", 5, 5, "accepted", nil)} + }, + } + p := newSweepPanel(t, b, &auth.Auth{UID: "u1", AccessToken: "at"}) + if !p.tryLockAccount("u1") { + t.Fatal("模拟占用失败") + } + st := p.RunTaskSweepOnce() + if st.Skipped != 1 || st.Claimed != 0 { + t.Errorf("stats = %+v, want skipped=1 claimed=0", st) + } + if got := b.reqs.Load(); got != 0 { + t.Fatalf("忙账号不应产生上游请求,got %d", got) + } + p.unlockAccount("u1") + // 解锁后同一轮即可正常清扫(下一周期自动补)。 + st = p.RunTaskSweepOnce() + if st.Skipped != 0 || st.Claimed != 1 { + t.Errorf("解锁后 stats = %+v, want skipped=0 claimed=1", st) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 428cc232..5d07387d 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -61,6 +61,11 @@ type Config struct { // 待办并执行,与面板「执行全部待办」按钮同管线)。调度器只管时点不管实现—— // panel 在 scheduler 之后构造,用 SetGrowthHook 事后挂载;nil 时到点跳过。 GrowthHook func() + + // TaskSweepHook 任务补领清扫回调(panel.RunTaskSweepOnce:把「已达标未领」 + // 的任务奖励领掉,不跑任何行为链路)。与 GrowthHook 同款延迟挂载;nil 时 + // ticker 到点跳过。见 issue #174:成长队列与「一键完成」都不负责这条补领链路。 + TaskSweepHook func() } // Scheduler 调度器。 @@ -73,11 +78,12 @@ type Scheduler struct { adoptTried map[string]string // schedMu 保护排程参数(时点/开关);Reconfigure 可在运行期热改(面板保存配置时调用)。 - // rearmSchedule/rearmBalance 是「排程已变,立即重算」通知:Run 与余额刷新循环各自消费, - // 分别用独立 channel(同 channel 被两个 select 消费会丢信号)。 + // rearmSchedule/rearmBalance/rearmSweep 是「排程已变,立即重算」通知:Run 与 + // 各周期循环各自消费,分别用独立 channel(同 channel 被多个 select 消费会丢信号)。 schedMu sync.Mutex rearmSchedule chan struct{} rearmBalance chan struct{} + rearmSweep chan struct{} // balanceInterval 余额刷新间隔(纳秒,0=暂停)。atomic 读写:执行循环每轮读当前值, // SetBalanceInterval 可任意时刻热改(面板保存配置)。 @@ -87,6 +93,10 @@ type Scheduler struct { // 供面板「到期提醒」标注数据年龄:卡片展示的是这份快照,必须让年龄可读, // 否则界面 5 秒轮询会让人误以为数据也是 5 秒级新鲜。 lastBalanceRefresh atomic.Int64 + + // sweepInterval 任务补领清扫间隔(纳秒,0=暂停)。语义同 balanceInterval: + // SetTaskSweepInterval 可热改(面板保存配置后下一轮生效)。 + sweepInterval atomic.Int64 } // New 构建。 @@ -111,6 +121,7 @@ func New(cfg Config) *Scheduler { adoptTried: make(map[string]string), rearmSchedule: make(chan struct{}, 1), rearmBalance: make(chan struct{}, 1), + rearmSweep: make(chan struct{}, 1), } } @@ -718,6 +729,80 @@ func (s *Scheduler) StartBalanceRefresh(ctx context.Context, interval time.Durat }() } +// StartTaskSweep 后台周期性任务补领清扫(独立 ticker goroutine,ctx 取消即停)。 +// 与 StartBalanceRefresh 同款结构;interval<=0 不启动(面板关闭开关时 main 不 +// 调用即可),运行期可用 SetTaskSweepInterval 热改间隔(下一轮生效)。 +// +// 与成长队列(GrowthHook,小时制)的区别:队列执行的是**行为链路**(真实对话、 +// 事件上报)成本高;清扫只做 accept/claim 两条幂等读改请求,分钟级跑也几乎零 +// 负担,却能补上「达标的任务没人领奖」这条长期缺失的链路(issue #174)。 +func (s *Scheduler) StartTaskSweep(ctx context.Context, interval time.Duration) { + if interval <= 0 { + return + } + s.sweepInterval.Store(int64(interval)) + go func() { + var logged time.Duration + for { + cur := time.Duration(s.sweepInterval.Load()) + if cur != logged { + log.Printf("scheduler: 任务补领清扫每 %s(暂停中显示 0s)", cur) + logged = cur + } + if cur <= 0 { + // 被热改暂停:等重排通知(重新启用时唤醒)或退出。 + select { + case <-ctx.Done(): + return + case <-s.rearmSweep: + continue + } + } + timer := time.NewTimer(cur) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-s.rearmSweep: + timer.Stop() // 间隔已变:立刻按新值重算 + case <-timer.C: + s.schedMu.Lock() + hook := s.cfg.TaskSweepHook + s.schedMu.Unlock() + if hook != nil { + hook() + } + } + } + }() +} + +// RunTaskSweepNow 立刻跑一轮任务补领清扫(手动触发/测试用)。 +func (s *Scheduler) RunTaskSweepNow() { + s.schedMu.Lock() + hook := s.cfg.TaskSweepHook + s.schedMu.Unlock() + if hook != nil { + hook() + } +} + +// SetTaskSweepHook 挂载/替换任务补领清扫回调(panel 构造晚于 scheduler,事后接线)。 +func (s *Scheduler) SetTaskSweepHook(fn func()) { + s.schedMu.Lock() + s.cfg.TaskSweepHook = fn + s.schedMu.Unlock() +} + +// SetTaskSweepInterval 热改任务补领清扫间隔;<=0 表示暂停循环(面板关闭该开关时)。 +func (s *Scheduler) SetTaskSweepInterval(d time.Duration) { + if d < 0 { + d = 0 + } + s.sweepInterval.Store(int64(d)) + poke(s.rearmSweep) +} + // SetBalanceInterval 热改余额刷新间隔;<=0 表示暂停循环(面板关闭该开关时)。 func (s *Scheduler) SetBalanceInterval(d time.Duration) { if d < 0 { From 8549291a4eadcb81b631d2b6b0605dca9946e7e7 Mon Sep 17 00:00:00 2001 From: folltoshe Date: Mon, 19 Oct 2026 01:12:46 +0800 Subject: [PATCH 19/88] feat: add support for proxy from environment variables (cherry picked from commit 505d5d7a5fd1bd5de139961453afe84af363615a) --- internal/upstream/transport.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/upstream/transport.go b/internal/upstream/transport.go index ac5920b0..1b939d27 100644 --- a/internal/upstream/transport.go +++ b/internal/upstream/transport.go @@ -84,6 +84,8 @@ func newTransport() *http.Transport { MaxIdleConnsPerHost: maxIdleConnsPerHost, IdleConnTimeout: idleConnTimeout, ResponseHeaderTimeout: responseHeaderTimeout, + // 自动读取环境变量中的代理配置 + Proxy: http.ProxyFromEnvironment, } } From 48a9c38843b063f2101fde2a3b771704996a51da Mon Sep 17 00:00:00 2001 From: PE-agent dev Date: Fri, 16 Oct 2026 18:11:14 +0800 Subject: [PATCH 20/88] =?UTF-8?q?feat(compensation):=20=E8=A1=A5=E5=81=BF?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=AF=E8=A7=82=E6=B5=8B=E5=8C=96=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=85=88=E6=9F=A5=E5=90=8E=E9=A2=86=20+=20=E6=88=AA?= =?UTF-8?q?=E6=AD=A2=E5=80=92=E8=AE=A1=E6=97=B6=20+=20=E9=9D=A2=E6=9D=BF?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=88issue=20#189=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 官方在「套餐与积分」页挂限时补偿横幅(例:「DeepSeek 模型服务异常额外补偿 500 积分」,带领取截止日),账号侧可见,但网关此前是**盲领**: if credit, err := ClaimCompensation(a); err == nil { log.Printf(...) } 于是「没有补偿」「已经领过」「**领取真的失败**」三种情形在日志里完全同构。 补偿带截止日,签到排程一旦停摆(签到被禁用 / 机器关机 / 账号被禁用), 补偿就静默过期,用户全程无感知。 本 PR 的命题是「不要静默失败」,因此所有分支都要求**要么成功、要么显形**: 改动: - upstream: 新增 GetCompensationStatus()(POST /billing/meter/compensation-status) 与 CompensationStatus.Pending() 三与门。active=false 是「当前无活动」的 正常 200,不是错误——这条语义搞错会让日常无活动的账号把排程日志刷成一片错误。 - scheduler: 签到排程的盲领改为「先查状态 → 待领才领」,判据直接用 upstream 的 Pending()(唯一权威实现,避免同一与门散落三份拷贝);签到排程与 面板按钮共用 claimOne,两条路径的分支与日志不会漂移。 - scheduler: 并发查询按**池顺序**写回(预分配等长切片、各写各自下标,不用锁—— 不同下标是互不重叠的内存,且与 /panel/api/packages 的既有写法一致); 参与账号与被门控跳过账号合成**同一个有序切片**,跳过行因此也带得上昵称。 - panel: 新增 GET /panel/api/compensation(只读)、POST accounts/{uid}/compensation、 POST compensation_all。批量入口返回 summary{claimed,skipped,errored,credit}: **必须先看 errored 再看 claimed**,否则上游全挂时(每个账号都走 error 分支) 会被说成「当前没有待领补偿」,把故障说成「没活动」——比不说更糟。 - 前端: 账号池新增「官方补偿」卡片——待领账号 + 领取截止倒计时(≤48h 标红)+ 逐号/全量领取按钮;**查询失败的账号渲染为红色行且强制显形**,混合态下 备注同时说明待领数与失败数。取数与呈现分离:取数只在进页面与手动点击时发生 (补偿状态没有 overview 快照可吃,挂进 5s 轮询就是每 5 秒轰炸上游),倒计时 走动靠 refreshVisible 对缓存做本地重渲染,不发请求。 门控沿用 streakBonusAccount 同口径:global(D4)与 enterprise 跳过,disabled 跳过; paused 不跳过(纯领奖 RPC,与「让位防风控」的选号语义无关,旅行/成长任务亦然)。 兼容性:不改 config.example.json、不改任何既有配置键、不改端点路径与请求头。 对既有行为的影响仅一处:streakBonusAccount 每号每天多一次 compensation-status 查询 (与同批次签到/旅行/连登查询同量级)。 测试:15 个测试函数 + 21 个子用例(upstream 5 / scheduler 6 / 面板前端 4),另有 既有的面板 JS 语法、顶层冒烟、无内联脚本、CFG_MAP 一致性用例一并通过。其中: - TestAppJSCompGateMutationDiscriminates 做**变异测试**:把 compPending 的 6 条判据 逐条从 app.js 副本里删掉,本用例必须 FAIL。首版样本造得不好(有 status 缺失的行 会被 r.status 子句短路,删掉 !r.skip/!r.error 照样全绿),已补齐判别力样本。 - TestAppJSRenderCompensationErrorVisibility 钉住「全失败必须显形」,否则本 PR 的 命题在自己的 UI 里被推翻。 - TestCompensationStatusAllKeepsPoolOrder 跑 20 轮逐位比对 uid 序列,钉住并发改造后 的行序稳定(修的是 #1:原实现按 goroutine 完成顺序 append,行序随机漂移)。 - TestEnterpriseGateSkipsCompensationEntries 覆盖**面板直接触发**的三个新入口—— 既有的门控测试只覆盖五类排程任务,两个入口都要钉住。 go build ./... · go vet ./... · go test -count=1 ./... 全绿(CI 同款:ubuntu + node)。 (cherry picked from commit d48b7158d24b9bea9f7751ac30201fb9b93f9a11) --- README.md | 4 + internal/panel/app.js | 176 ++++++++++- internal/panel/compensation_frontend_test.go | 285 ++++++++++++++++++ internal/panel/index.html | 20 ++ internal/panel/panel.go | 89 ++++++ internal/scheduler/compensation.go | 277 +++++++++++++++++ internal/scheduler/compensation_test.go | 297 +++++++++++++++++++ internal/scheduler/streak.go | 8 +- internal/upstream/compensation.go | 70 +++++ internal/upstream/compensation_test.go | 122 ++++++++ 10 files changed, 1344 insertions(+), 4 deletions(-) create mode 100644 internal/panel/compensation_frontend_test.go create mode 100644 internal/scheduler/compensation.go create mode 100644 internal/scheduler/compensation_test.go create mode 100644 internal/upstream/compensation.go create mode 100644 internal/upstream/compensation_test.go diff --git a/README.md b/README.md index 368baac6..6bd6b71b 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ WorkBuddy2API 是一个自托管的 **OpenAI 兼容反向代理网关**,将腾 | **浏览器内 OAuth 添加账号** | 面板「添加账号」按钮完成设备授权 → 凭证落盘 → **热加载进池(免重启)**,替代命令行 `login.sh` 流程 | | **在线配置编辑(热生效)** | 面板直接改 `config.json`:API 密钥 / `soft_rate` / 脱敏开关 / 池参数 / 任务排程**立即生效**;装配期字段(listen 等)保存后提示需重启。写入采用深合并 + 原子替换,保留未知键 | | **积分任务体系** | 任务列表 / 接受 / 领取接口 + 面板弹窗;「一键完成」覆盖 **17 个任务**(对话 / 领养 / 桌面行为链 / 模板 / 灵感案例 / 画布 / 专家召唤 / 技能尝鲜 / 主题 / 资料库 / 夜猫子等),推进进度、等待异步计分落定后**自动领奖**,纯 API 零客户端依赖 | +| **限时补偿可观测(issue #189)** | 官方补偿(例:「模型服务异常额外补偿 500 积分」)在账号侧可见,网关改为**先查 `compensation-status` 再决定是否领**:面板「官方补偿」卡片列出待领账号 + 领取截止倒计时(≤48h 标红)+ 逐号/全量领取按钮;**查询失败的账号在卡片上显形为红色行,不会与「没有补偿」混为一谈**;每日签到排程同步走「查→领」,**状态查询失败与领取失败一律显式告警**(旧实现把「没有 / 已领 / 领取失败」三态压成同一形态,带截止日的补偿会静默过期) | | **首启自动生成配置** | 目录下无 `config.json` 时自动生成推荐配置(含 `crypto/rand` 随机 `api_key`),双击即开 | | **粘性会话内容回退** | 客户端不发 `conversation_id` 时,用 `system + 首条 user` 哈希派生会话键(`d-` 前缀),通用 OpenAI 客户端也能享受粘性 | | **余额后台刷新** | `schedule.balance_refresh_minutes`(默认 5)周期查余额并更新池,冷却账号余额恢复自动解冻 | @@ -565,6 +566,7 @@ WARN: [server] stream ...: saw M native tool call block(s) but repaired none (to |---|---| | `POST /v2/billing/meter/daily-checkin`(签到) | `400 code 10001`「企业账号不支持该操作」 | | `POST /billing/meter/claim-gift` / `claim-compensation`(夜猫子领奖) | `400 code 10001` 同上 | +| `POST /billing/meter/compensation-status`(补偿状态查询) | 网关**在门控处一并跳过**(与 claim-compensation 同属无补偿体系的调用) | | `GET /activity/growth/*`(连登 / 旅行 / 热力图 / 抽奖)与 `GET /v2/activity/growth/tasks`(成长任务) | `403`「growth system is only available for personal users」 | 因此网关对企业号**不发起**这五类调用(签到 / 活跃上报 / 猫猫旅行 / 夜猫子 / 连登管家),面板也不渲染「签到」「任务」按钮(只留「额度」)。判定(`auth.Auth.IsEnterprise()`)与既有 `IsGlobal()`(D4 门控)在**同一批引用处并列书写**,`upstream` 侧零改动。 @@ -673,6 +675,7 @@ http://127.0.0.1:7863/panel/ |---|---| | 只读 | `GET overview` · `GET usage`(`hours` 或 `from`/`to`)· `GET packages` · `GET models` · `GET model_probes` · `GET logs` · `GET request_metrics` · `GET request_logs`(`limit` / `outcome` / `account` / `model` / `client_ip` / `user_agent` / `from` / `to`)· `GET config` · `GET tasks/queue` · `GET school/vouchers` | | 账号运维 | `POST accounts/{uid}/checkin` · `balance` · `revive` · `disable` · `remove`;`POST checkin_all` · `travel_all` · `activity_all` · `keepalive_all` · `balance_all` | +| 补偿 | `GET compensation`(逐号查补偿状态,**只读**);`POST accounts/{uid}/compensation`(单号领);`POST compensation_all`(全量领,返回 `summary{claimed,skipped,errored,credit}`——**先看 `errored` 再看 `claimed`**,否则会把「全部查询失败」说成「没有待领补偿」) | | 任务 | `GET accounts/{uid}/tasks`;`POST accounts/{uid}/tasks/accept` · `accept_all` · `claim` · `auto` · `auto_all`;`POST tasks/scan_all` · `tasks/run_queue` | | 添加账号 | `POST login/start` · `GET login/poll` · `GET login/regions` · `POST import/cockpit` | | 写入 | `POST config`(校验 → 落盘 → 热应用,返回需重启字段清单)· `POST usage/save`(立即把内存用量落盘) | @@ -727,6 +730,7 @@ http://127.0.0.1:7863/panel/ | `plugin/auth/token/refresh` | POST | token 刷新 | | `billing/meter/daily-checkin` | POST | 每日签到 | | `billing/meter/get-user-resource` | POST | 余额查询 | +| `billing/meter/compensation-status` | POST | **补偿活动状态**(`active` / `claimed` / `credit_num` / `validity_period` / `end_time`);`active=false` 是「当前无活动」的正常 200,不算错误 | | `report` | POST | 对话活跃上报(`chat_request_send` 事件数组,必须含 `userId`;点亮连登 / 解锁领养) | | `plugin/auth/state?platform=CLI` | POST | OAuth 取授权 URL | | `plugin/auth/token?state=` | GET | OAuth 轮询取 token | diff --git a/internal/panel/app.js b/internal/panel/app.js index b76e1869..6b2b398e 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -1286,14 +1286,188 @@ $('btnRefresh').onclick = async () => { if (view === 'logs') loadLogs(); }; +/* ── 官方补偿(issue #189)───────────────────────────────────────── + 「查状态 → 待领才领」。 + + 取数与呈现刻意分开: + - 取数只有手动触发(进页面一次 + 「检查」/「领取」后)。补偿状态没有 + overview 快照可吃,必须逐号打上游,挂进 5s 轮询就是每 5 秒轰炸上游。 + - 呈现走 compCache 的本地重渲染(refreshVisible 每轮调一次),只为让 + 倒计时走动 —— 不发任何请求。 + 之前这里有个「5 分钟新鲜窗口」分支,但它唯一的非强制调用点是 start(), + 而那里 compCache 必为 null,所以该分支永远不会命中:注释宣称「防轰炸」, + 实际是「根本不刷新」。现已删除,别再加回来。 + + 声明位置在本段(轮询区之前)是必需的:start() 会调用 loadCompensation(), + 若这些 let 写在 start() 之后,顶层求值会在 TDZ 上炸—— + TestAppJSTopLevelSmoke 正是为这类问题设的闸门。 */ +let compCache = null; +let compInFlight = null; // 在途请求的 promise(非布尔标志:布尔标志配 force 刷新会静默空转) + +// compPending 挑出「可领未领」的账号。 +// 纯函数:判据独立于渲染,node 侧单测单独钉住。 +// 判据与后端 upstream.CompensationStatus.Pending() 对齐——两端各留一份实现是 +// 有意为之(前端要在渲染前先算总数),但**必须同步**,改一处就要改另一处。 +function compPending(rows) { + return (rows || []).filter(r => r && !r.skip && !r.error && r.status && + r.status.active && !r.status.claimed && (r.status.credit_num || 0) > 0); +} + +// compErrored 挑出「查询失败」的账号。 +// 单独拎出来,是因为它们**必须显形**:上游 5xx / token 过期时所有账号都会落到 +// 这里,若和「没有补偿」一起被过滤掉,卡片就会整块消失,用户看到的是「什么都没有」 +// ——而真相是「一个都没查成」。这正是本 PR 要消灭的静默失败。 +function compErrored(rows) { + return (rows || []).filter(r => r && r.error); +} + +// compTotalPending 待领总额。 +function compTotalPending(rows) { + return compPending(rows).reduce((s, r) => s + (r.status.credit_num || 0), 0); +} + +// compDeadlineText 领取截止倒计时文案。 +// 上游 end_time 形如 "2026-10-22 23:59:59" 且**不带时区**,按浏览器本地时区解析 +// (与后端积分到期的处理同口径)。urgent=true 表示距截止 <=48h,前端据此染色告警。 +function compDeadlineText(endTime, now) { + const raw = String(endTime == null ? '' : endTime).trim(); + if (!raw) return { text: '无截止信息', urgent: false }; + const t = new Date(raw.replace(' ', 'T')).getTime(); + if (!isFinite(t)) return { text: raw, urgent: false }; + const ms = t - (now || Date.now()); + if (ms <= 0) return { text: '已截止', urgent: true }; + const d = Math.floor(ms / 86400000); + const h = Math.floor(ms % 86400000 / 3600000); + const text = d > 0 ? (d + ' 天 ' + h + ' 小时') : (Math.max(1, h) + ' 小时'); + return { text: '剩 ' + text, urgent: ms <= 2 * 86400000 }; +} + +function renderCompensation(d) { + const rows = (d && d.accounts) || []; + const box = $('compBox'); + if (!box) return; + const active = rows.filter(r => r && !r.error && r.status && r.status.active); + const failed = compErrored(rows); + // 「有失败就必须显形」——哪怕一条有效数据都没有。 + if (!active.length && !failed.length) { box.hidden = true; return; } + box.hidden = false; + + const now = Date.now(); + const okRows = active.map(r => { + const s = r.status || {}; + const done = !!s.claimed; + const dl = compDeadlineText(s.end_time, now); + const name = esc(r.nickname || String(r.uid).slice(0, 8)); + return '
' + name + '' + + (done + ? '已领取' + : '待领取' + + '' + esc(dl.text) + '') + + '' + esc(fmtTok(s.credit_num || 0)) + ' 积分' + + (done ? '' : '') + + '
'; + }).join(''); + // err 文本来自上游响应体,是外部可控字符串,必须 esc。 + const errRows = failed.map(r => + '
' + esc(r.nickname || String(r.uid).slice(0, 8)) + '' + + '查询失败' + + '' + esc(String(r.error).slice(0, 120)) + '
' + ).join(''); + + $('compList').innerHTML = okRows + errRows; + + const n = compPending(rows).length; + let note = n ? (n + ' 个账号待领 · 共 ' + fmtTok(compTotalPending(rows)) + ' 积分') : '当前无待领补偿'; + // 混合态下也要把失败数说出来,否则 note 仍读作「没事」。 + if (failed.length) note += ' · ' + failed.length + ' 个账号查询失败'; + $('compNote').textContent = note; +} + +// renderCachedCompensation 从缓存重渲染(不发请求),让倒计时走动。 +// 由 refreshVisible 每轮调用;无缓存(还没查过)时什么都不做。 +function renderCachedCompensation() { + if (compCache) renderCompensation(compCache); +} + +async function loadCompensation() { + // 已有在途请求就等它跑完,而不是静默返回——领取后紧接着的刷新必须能看到新状态, + // 否则用户点了「领取」但列表纹丝不动。 + if (compInFlight) { await compInFlight; return; } + compInFlight = (async () => { + try { + const d = await api('compensation'); + compCache = d; + renderCompensation(d); + } catch (e) { + const box = $('compBox'); + if (box) box.hidden = false; + if ($('compList')) $('compList').innerHTML = + '
查询失败:' + esc(e.message) + '
'; + if ($('compNote')) $('compNote').textContent = ''; + } + })(); + try { + await compInFlight; + } finally { + compInFlight = null; // 必须 finally:否则一次异常就让此后所有刷新永久失效 + } +} + +// 领取按钮走事件委托:列表每次重渲染都整块换 innerHTML,绑在具体节点上必丢。 +if ($('compList')) $('compList').addEventListener('click', async e => { + const b = e.target.closest('[data-comp-uid]'); + if (!b) return; + b.disabled = true; + try { + const r = await api('accounts/' + encodeURIComponent(b.dataset.compUid) + '/compensation', { method: 'POST' }); + const res = r.result || {}; + if (res.claimed) toast('已领取 ' + fmtTok(res.credit || 0) + ' 积分', 'ok'); + else if (res.error) toast('查询失败,未能确认是否有补偿', 'err'); + else if (res.skip) toast(res.skip_msg || '无需领取', ''); + else toast('领取失败:未知结果', 'err'); + await loadCompensation(); + loadOverview(true); + } catch (err) { + toast('领取失败:' + err.message, 'err'); + } +}); + +if ($('btnCompRefresh')) $('btnCompRefresh').onclick = () => loadCompensation(); + +if ($('btnCompClaimAll')) $('btnCompClaimAll').onclick = async () => { + const b = $('btnCompClaimAll'); + b.disabled = true; + try { + const r = await api('compensation_all', { method: 'POST' }); + const s = r.summary || {}; + // 顺序要紧:先看有没有账号没拿到结论,再看有没有领到。 + // 只按 claimed 判定的话,「全部查询失败」会被说成「当前没有待领补偿」, + // 把故障说成了「没活动」——比不说更糟。 + if (s.errored) { + toast(s.errored + ' 个账号查询失败,未能确认是否有补偿', 'err'); + } else if (s.claimed) { + toast('已领取 ' + s.claimed + ' 个账号,共 ' + fmtTok(s.credit || 0) + ' 积分', 'ok'); + } else { + toast('当前没有待领补偿', ''); + } + await loadCompensation(); + loadOverview(true); + } catch (e) { + toast('领取失败:' + e.message, 'err'); + } finally { + b.disabled = false; + } +}; + /* ── 轮询 ─────────────────────────────────────────────────────────── */ function refreshVisible() { - if (view === 'accounts') loadOverview(true); + if (view === 'accounts') { loadOverview(true); renderCachedCompensation(); } else if (view === 'logs') loadLogs(); else if (view === 'taskscenter') reattachQueueView(); } function start() { loadOverview(true); + loadCompensation(); // 补偿状态:首次进页面查一次(之后靠新鲜窗口) if (refTimer) clearInterval(refTimer); refTimer = setInterval(refreshVisible, 5000); checkAuthGate(); diff --git a/internal/panel/compensation_frontend_test.go b/internal/panel/compensation_frontend_test.go new file mode 100644 index 00000000..fe3cfe99 --- /dev/null +++ b/internal/panel/compensation_frontend_test.go @@ -0,0 +1,285 @@ +package panel + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +// 本文件覆盖「官方补偿」卡片(issue #189)的前端纯函数与渲染。 +// +// 为什么要有:compPending 是「能不能领」的唯一判据,compDeadlineText 决定用户 +// 会不会错过截止日,renderCompensation 决定**失败会不会显形**——三者都只在浏览器里 +// 生效,Go 侧不执行 JS,编译与后端测试对它们完全失明。判据写反或让错误静默隐藏, +// 都不报错、只能肉眼看,故在此用 node harness 钉死。 + +func TestAppJSCompPendingGate(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation gate test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function compDeadlineText'); +if (start < 0 || end < 0 || end < start) throw new Error('compensation region not found'); +const ctx = { Array, Object, Number, String }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + + '\nthis.compPending = compPending; this.compTotalPending = compTotalPending; this.compErrored = compErrored;', ctx); + +const rows = [ + { uid: 'a', status: { active: true, claimed: false, credit_num: 500 } }, // 待领 + { uid: 'b', status: { active: true, claimed: true, credit_num: 500 } }, // 已领 + { uid: 'c', status: { active: false, claimed: false, credit_num: 0 } }, // 无活动 + { uid: 'd', status: { active: true, claimed: false, credit_num: 0 } }, // 额度为 0 + // ↓ 下面两行是**判别力用例**:状态本身完全「待领」,只有 skip / error 能排除它们。 + // 若样本里没有 status(或 status 非 pending),r.status 子句会先行短路排除, + // 于是删掉 !r.skip / !r.error 本用例仍然通过——等于没测到那两条判据。 + { uid: 'e', skip: 'enterprise', skip_msg: '企业版', + status: { active: true, claimed: false, credit_num: 500 } }, + { uid: 'f', error: 'boom', + status: { active: true, claimed: false, credit_num: 500 } }, + null, +];process.stdout.write(JSON.stringify({ + uids: ctx.compPending(rows).map(r => r.uid), + total: ctx.compTotalPending(rows), + errored: ctx.compErrored(rows).map(r => r.uid), + emptyOk: ctx.compPending(null).length === 0 && ctx.compPending(undefined).length === 0, + dirtyOk: ctx.compPending([null, undefined]).length === 0, +}));` + f, err := os.CreateTemp(t.TempDir(), "compgate-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation gate node test failed: %v\n%s", err, out) + } + want := `{"uids":["a"],"total":500,"errored":["f"],"emptyOk":true,"dirtyOk":true}` + if strings.TrimSpace(string(out)) != want { + t.Fatalf("compensation gate=%s\nwant %s", strings.TrimSpace(string(out)), want) + } +} + +// TestAppJSCompGateMutationDiscriminates 反向证明:把 !r.skip / !r.error 两条判据 +// 从 app.js 副本里删掉,本用例必须 FAIL。 +// +// 这类「变异测试」是本文件存在的主要理由——正向断言全绿并不代表测到了东西, +// 若样本造得不好(比如被测行没有 status 字段),判据会被 r.status 短路吃掉, +// 删除判据照样全绿。review 时请连同 app.js 副本一起看。 +func TestAppJSCompGateMutationDiscriminates(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation mutation test skipped") + } + mutants := map[string]struct{ from, to string }{ + "drop !r.skip": {"!r.skip && ", ""}, + "drop !r.error": {"!r.error && ", ""}, + "drop r.active": {"r.status.active && ", ""}, + "drop !claimed": {"!r.status.claimed && ", ""}, + "drop credit>0": {"(r.status.credit_num || 0) > 0", "true"}, + "drop whole status":{"r.status &&", "true &&"}, + } + for name, m := range mutants { + t.Run(name, func(t *testing.T) { + orig, err := os.ReadFile("app.js") + if err != nil { + t.Fatal(err) + } + mutated := strings.Replace(string(orig), m.from, m.to, 1) + if mutated == string(orig) { + t.Fatalf("变异未生效:app.js 里找不到 %q(判据已改写?)", m.from) + } + tmp := t.TempDir() + mutFile := tmp + "/app.js" + if err := os.WriteFile(mutFile, []byte(mutated), 0o600); err != nil { + t.Fatal(err) + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function compDeadlineText'); +const ctx = { Array, Object, Number, String }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.compPending = compPending;', ctx); +const rows = [ + { uid: 'a', status: { active: true, claimed: false, credit_num: 500 } }, // 唯一应保留 + { uid: 'g', status: { active: true, claimed: true, credit_num: 500 } }, // 删 !claimed 会漏进来 + { uid: 'h', status: { active: true, claimed: false, credit_num: 0 } }, // 删 credit>0 会漏进来 + { uid: 'i', status: { active: false, claimed: false, credit_num: 500 } }, // 删 active 会漏进来 + { uid: 'e', skip: 'enterprise', status: { active: true, claimed: false, credit_num: 500 } }, + { uid: 'f', error: 'boom', status: { active: true, claimed: false, credit_num: 500 } }, + // 完全没有 status 的脏行:r.status 守卫挡住它(否则 r.status.active 抛 TypeError, + // node 非零退出也算「变异被抓到」)。这条判据测的是崩溃安全而非过滤结果。 + { uid: 'k', nickname: '脏数据' }, +]; +process.stdout.write(JSON.stringify(ctx.compPending(rows).map(r => r.uid)));` + sf := tmp + "/m.cjs" + if err := os.WriteFile(sf, []byte(script), 0o600); err != nil { + t.Fatal(err) + } + out, err := exec.Command(node, sf, mutFile).CombinedOutput() + // 变异被「抓到」有两种形态:① 跑通了但输出变了(判据被删 → 多出/少了 + // 账号);② 直接崩了(如删掉 r.status 空值守卫后 TypeError)。两者都算 + // 有判别力;只有「跑通且输出与基线完全一致」才是假通过。 + if err != nil { + return // 崩溃 = 检出 + } + if got := strings.TrimSpace(string(out)); got == `["a"]` { + t.Fatalf("变异 %s 后 compPending 结果不变 = 本用例对该判据无判别力(假通过)", name) + } + }) + } +} + +func TestAppJSCompDeadlineText(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation deadline test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compDeadlineText'); +const end = src.indexOf('function renderCompensation'); +if (start < 0 || end < 0 || end < start) throw new Error('deadline region not found'); +const ctx = { Date, Math, Number, String, isFinite }; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.f = compDeadlineText;', ctx); + +// 上游 end_time 是不带时区的 "YYYY-MM-DD HH:mm:ss",按本地时区格式化造输入, +// 这样用例在任何 TZ 下都自洽(写死日期会在换季/换区后自己失效)。 +const p = n => String(n).padStart(2, '0'); +const mkEnd = ms => { const d = new Date(ms); + return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }; +const base = new Date(2026, 9, 22, 23, 59, 59).getTime(); // 2026-10-22 23:59:59 本地时 +const H = 3600000, D = 86400000; +process.stdout.write(JSON.stringify({ + six: ctx.f(mkEnd(base), base - 6 * H), // 只剩 6 小时 → 临期 + three: ctx.f(mkEnd(base), base - 3 * D), // 还剩 3 天 → 不告警 + past: ctx.f(mkEnd(base), base + H), // 已过截止 + empty: ctx.f('', base), // 上游没给 end_time + junk: ctx.f('not-a-date', base), // 解析失败不得抛 + undef: ctx.f(null, base), +}));` + f, err := os.CreateTemp(t.TempDir(), "compdl-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation deadline node test failed: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) + for _, must := range []string{ + `"text":"剩 6 小时","urgent":true`, // ≤48h 必须标临期 + `"text":"剩 3 天 0 小时","urgent":false`, // 3 天不算临期 + `"text":"已截止","urgent":true`, + `"text":"无截止信息","urgent":false`, + `"text":"not-a-date","urgent":false`, // 脏值原样透出,不抛异常 + } { + if !strings.Contains(got, must) { + t.Fatalf("compensation deadline 缺少 %s\n实际输出: %s", must, got) + } + } +} + +// TestAppJSRenderCompensationErrorVisibility 钉住本 PR 的核心命题: +// **查询失败的账号必须显形**。上游 5xx / token 过期时所有账号都会落到 error 分支, +// 若渲染把它们和「没有补偿」一起过滤掉,卡片整块消失,用户看到「什么都没有」—— +// 而真相是「一个都没查成」。这正是 issue #189 要消灭的静默失败。 +func TestAppJSRenderCompensationErrorVisibility(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed; compensation render test skipped") + } + script := `const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const start = src.indexOf('function compPending'); +const end = src.indexOf('function renderCachedCompensation'); +if (start < 0 || end < 0 || end < start) throw new Error('render region not found'); +const sinks = {}; +const mk = id => (sinks[id] = { innerHTML: '', textContent: '', hidden: true }); +const ctx = { + esc: s => String(s == null ? '' : s), + fmtTok: v => String(v == null ? 0 : v), + $: id => (sinks[id] || mk(id)), + Date, Math, Number, String, isFinite, Array, Object, +}; +vm.createContext(ctx); +vm.runInContext(src.slice(start, end) + '\nthis.render = renderCompensation;', ctx); + +const out = {}; +// ① 全部查询失败 → 卡片必须可见,且列出失败原因 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', error: 'boom-1' }, + { uid: 'b2', nickname: '乙', error: 'boom-2' }, +] }); +out.allFailedVisible = sinks['compBox'].hidden === false; +out.allFailedNote = sinks['compNote'].textContent; +out.allFailedShows = sinks['compList'].innerHTML.includes('boom-1'); + +// ② 无活动且无失败 → 卡片隐藏(不能变成常驻空卡片) +ctx.render({ accounts: [{ uid: 'a1', nickname: '甲', status: { active: false, claimed: false, credit_num: 0 } }] }); +out.quietHidden = sinks['compBox'].hidden === true; + +// ③ 混合态:一条待领 + 一条失败 → 可见,且 note 同时提到待领数与失败数 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', status: { active: true, claimed: false, credit_num: 500, end_time: '2026-10-22 23:59:59' } }, + { uid: 'b2', nickname: '乙', error: 'boom-2' }, +] }); +out.mixedVisible = sinks['compBox'].hidden === false; +out.mixedNote = sinks['compNote'].textContent; +out.mixedHasBtn = sinks['compList'].innerHTML.includes('data-comp-uid'); + +// ④ 已领取 → 无「领取」按钮 +ctx.render({ accounts: [ + { uid: 'a1', nickname: '甲', status: { active: true, claimed: true, credit_num: 500 } }, +] }); +out.claimedNoBtn = !sinks['compList'].innerHTML.includes('data-comp-uid'); + +process.stdout.write(JSON.stringify(out));` + f, err := os.CreateTemp(t.TempDir(), "comprender-*.cjs") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(script); err != nil { + t.Fatal(err) + } + f.Close() + out, err := exec.Command(node, f.Name(), "app.js").CombinedOutput() + if err != nil { + t.Fatalf("compensation render node test failed: %v\n%s", err, out) + } + got := strings.TrimSpace(string(out)) + for _, must := range []string{ + `"allFailedVisible":true`, // ① 全失败必须显形 + `"allFailedShows":true`, // ① 且要带出失败原因 + `"quietHidden":true`, // ② 真没活动才隐藏 + `"mixedVisible":true`, // ③ 混合态可见 + `"mixedHasBtn":true`, // ③ 待领行仍有按钮 + `"claimedNoBtn":true`, // ④ 已领无按钮 + } { + if !strings.Contains(got, must) { + t.Fatalf("render 断言缺少 %s\n实际输出: %s", must, got) + } + } + // note 必须在混合态里同时说出「待领」与「查询失败」两件事。 + if !strings.Contains(got, "查询失败") { + t.Errorf("note 未提示查询失败: %s", got) + } +} diff --git a/internal/panel/index.html b/internal/panel/index.html index e8c5994d..fb24b695 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -203,6 +203,10 @@ .box > header select, .box > header input { width: auto; } .box > header h3 { white-space: nowrap; } .box .pad { padding: 17px; } +/* 补偿活动行:昵称 / 状态标 / 截止倒计时 / 额度 / 领取按钮,单行紧凑排布。 + 沿用 .tag 体系染色(ok=已领、warn=待领、bad=临期、mute=中性信息)。 */ +.comp-row { display: flex; align-items: center; gap: 9px; padding: 7px 0; border-bottom: 1px solid var(--line-soft); } +.comp-row:last-child { border-bottom: none; } /* ── 账号表(签名元素:健康色条 + 积分量条)───────────────────────── */ .tbl-wrap { overflow-x: auto; } @@ -665,6 +669,22 @@

积分到期提醒按最近到期批次估算 · 日均
+ + +

账号池

diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 3a065acb..3edcf719 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -186,6 +186,9 @@ func (p *Panel) routes() { p.mux.HandleFunc("GET /panel/api/model_probes", p.withAuth(p.modelProbes)) p.mux.HandleFunc("GET /panel/api/config", p.withAuth(p.getConfig)) p.mux.HandleFunc("POST /panel/api/config", p.withAuth(p.saveConfig)) + p.mux.HandleFunc("GET /panel/api/compensation", p.withAuth(p.compensation)) + p.mux.HandleFunc("POST /panel/api/accounts/{uid}/compensation", p.withAuth(p.accountCompensationClaim)) + p.mux.HandleFunc("POST /panel/api/compensation_all", p.withAuth(p.compensationAll)) } // ServeHTTP 统一入口:先写安全响应头再分发,保证页面、静态资源、API @@ -703,6 +706,92 @@ func (p *Panel) balanceAll(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "accounts": p.cfg.Pool.List()}) } +// compensation 查询全部账号的补偿活动状态(**只读**,不发起任何领取)。 +// +// 与 balanceAll 同构:同步等待而非「触发后盲刷」——账号量小(个位数), +// 面板拿到响应即可直接渲染横幅,不用再轮询。 +func (p *Panel) compensation(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "accounts": p.cfg.Scheduler.CompensationStatusAll(), + }) +} + +// accountCompensationClaim 单号领取补偿:查状态 → 待领才领(幂等)。 +// 领完顺带刷一次余额,面板上的积分数字立即反映到账。 +func (p *Panel) accountCompensationClaim(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + uid := r.PathValue("uid") + res, err := p.cfg.Scheduler.ClaimCompensationFor(uid) + if err != nil { + writeErr(w, http.StatusNotFound, err.Error()) + return + } + if res.Claimed { + p.refreshCreditsQuiet(uid) + } + // ok 反映「是否拿到确定结论」:error 非空即 false。注意前端 api() 只看 + // HTTP 状态码,这个字段是给日志/调用方看的,真正的分流依据是 result.error。 + writeJSON(w, http.StatusOK, map[string]any{"ok": res.Error == "", "result": res}) +} + +// compensationAll 手动全量领取补偿(逐号查状态后领取;无待领的号静默跳过)。 +func (p *Panel) compensationAll(w http.ResponseWriter, r *http.Request) { + if p.cfg.Scheduler == nil { + writeErr(w, http.StatusNotImplemented, "scheduler not available") + return + } + results := p.cfg.Scheduler.ClaimCompensationNow() + for _, res := range results { + if res.Claimed { + p.refreshCreditsQuiet(res.UID) + } + } + summary := scheduler.SummarizeCompensation(results) + log.Printf("panel: 手动全量补偿领取完成 合计=%d 领取=%d(+%dc) 跳过=%d 失败=%d", + summary.Total, summary.Claimed, summary.Credit, summary.Skipped, summary.Errored) + // ok=false 表示有账号没拿到确定结论;前端据此提示「查询失败」而不是 + // 「没有待领补偿」——两者语义完全相反,不能混。 + writeJSON(w, http.StatusOK, map[string]any{ + "ok": summary.OK(), + "summary": summary, + "results": results, + }) +} + +// b2i bool → int(汇总计数用)。 +func b2i(b bool) int { + if b { + return 1 + } + return 0 +} + +// refreshCreditsQuiet 领取成功后刷新该号余额,失败不影响领取结果回传。 +// +// 失败只记日志不返回:领取本身已经成功并落到上游,余额刷新失败只是展示滞后, +// 前端紧接着还会拉一次 overview。静默吞掉会让「积分没变」看起来像没领到。 +func (p *Panel) refreshCreditsQuiet(uid string) { + a := p.cfg.Pool.AuthByUID(uid) + if a == nil { + return + } + remain, total, expiring, earliestAt, earliestRemaining, err := + p.cfg.Upstream.UserResourceDetailedWithExpiry(a, p.expiringSoonWindow()) + if err != nil { + log.Printf("panel: compensation 后刷新余额失败 uid=%s: %v", uid, err) + return + } + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) +} + // syncNicknames 手动刷新时的昵称同步(issue #94:上游改名免重登)。 // 只在面板手动「刷新」路径调用——后台余额定时器不触发(用户明确要求资料接口 // 仅手动触达)。逐号拉 /console/account,只取 nickname(手机号等敏感字段在 diff --git a/internal/scheduler/compensation.go b/internal/scheduler/compensation.go new file mode 100644 index 00000000..fd0e4f1a --- /dev/null +++ b/internal/scheduler/compensation.go @@ -0,0 +1,277 @@ +// compensation.go 补偿活动的可观测化执行器(issue #189)。 +// +// 承接 internal/upstream/compensation.go 补上的状态查询,把签到排程里那次「盲领」 +// 改成「先查状态 → 待领才领」。解决三个原本无法区分的情形: +// +// ① 没有补偿 ② 已经领过 ③ 真的领取失败 +// +// 旧写法 `if credit, err := ...; err == nil { log }` 让三者同构,补偿带截止日 +// (实测 2026-10-16 那次是 10/22),签到停摆就会静默过期。本文件让「可领未领」 +// 在日志和面板上都能显式看见。 +package scheduler + +import ( + "errors" + "log" + "sync" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/logfmt" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// 补偿任务的跳过原因(面板据此渲染,勿随意改字面量)。 +// +// 注意没有「paused」:暂停选号**不**跳过补偿——补偿是纯领奖 RPC,与「让位防风控」 +// 的选号语义无关,旅行/成长任务亦然。这里显式不设该枚举,免得后人手滑补一个 +// 把暂停号错误地排除掉。 +const ( + compSkipDisabled = "disabled" // 账号已禁用,等人工 revive + compSkipNoAuth = "no_auth" // 无凭证 + compSkipGlobal = "global" // D4 门控:global 无 CN 任务体系 + compSkipEnterprise = "enterprise" // 企业版门控:claim-compensation 返回 10001 + compSkipInactive = "no_active" // 当前无生效中的补偿活动 + compSkipClaimed = "claimed" // 本次活动已领过 + compSkipZeroCredit = "zero_credit" // 活动在,但额度为 0 +) + +// CompensationRow 单账号的补偿状态行(面板 GET /panel/api/compensation)。 +type CompensationRow struct { + UID string `json:"uid"` + Nickname string `json:"nickname,omitempty"` + Status upstream.CompensationStatus `json:"status"` + Skip string `json:"skip,omitempty"` // 非空 = 本次未发起上游查询/领取 + SkipMsg string `json:"skip_msg,omitempty"` // 跳过的中文说明 + Error string `json:"error,omitempty"` +} + +// CompensationResult 单账号的领取结果(面板 POST /panel/api/compensation_all)。 +type CompensationResult struct { + UID string `json:"uid"` + Nickname string `json:"nickname,omitempty"` + Status upstream.CompensationStatus `json:"status"` + Claimed bool `json:"claimed"` + Credit int64 `json:"credit,omitempty"` + Skip string `json:"skip,omitempty"` + SkipMsg string `json:"skip_msg,omitempty"` + Error string `json:"error,omitempty"` +} + +// compTarget 一次遍历里某个账号的处理目标。 +// +// 参与账号与被门控跳过的账号合成**同一个有序切片**(而不是 jobs + skips 两个 +// 容器)——后者会让跳过行只能从 map 取出,既丢掉昵称,又让面板行序随机漂移 +// (池顺序是按 UID 稳定排序的,面板逐行对齐依赖它)。 +type compTarget struct { + uid string + nick string + a *auth.Auth // nil = 被门控跳过 + skip string + msg string +} + +// compTargets 按池顺序枚举全部账号并完成门控判定。 +// +// 门控与 streakBonusAccount 同口径:D4 global 跳过;企业版跳过(上游对 +// claim-compensation 一律 400「企业账号不支持该操作」,与 claim-gift 同批实测); +// disabled 跳过(凭证可能已失效,发起无意义)。paused **不跳过**(见上方枚举说明)。 +func (s *Scheduler) compTargets() []compTarget { + list := s.cfg.Pool.List() + out := make([]compTarget, 0, len(list)) + for _, st := range list { + t := compTarget{uid: st.UID, nick: st.Nickname} + if st.Disabled { + t.skip, t.msg = compSkipDisabled, "账号已禁用" + out = append(out, t) + continue + } + a := s.cfg.Pool.AuthByUID(st.UID) + if a == nil || a.AccessTokenValue() == "" { + t.skip, t.msg = compSkipNoAuth, "无凭证" + out = append(out, t) + continue + } + if a.IsGlobal() { + t.skip, t.msg = compSkipGlobal, "国际版无 CN 补偿体系" + out = append(out, t) + continue + } + if a.IsEnterprise() { + t.skip, t.msg = compSkipEnterprise, "企业版无补偿体系" + out = append(out, t) + continue + } + t.a = a + out = append(out, t) + } + return out +} + +// CompensationStatusAll 并发查全部账号的补偿状态(只读,不领取)。 +// +// 输出与池顺序一致:先按 compTargets 预分配等长切片,每个 goroutine 只写自己那 +// 一个下标——不同下标是互不重叠的内存,无需互斥锁即可避免竞态(也避免了 +// 「goroutine 完成顺序 = 返回顺序」导致的行序漂移)。并发上限 3 与既有 +// /panel/api/packages 的账号遍历一致。 +func (s *Scheduler) CompensationStatusAll() []CompensationRow { + targets := s.compTargets() + rows := make([]CompensationRow, len(targets)) + + sem := make(chan struct{}, 3) + var wg sync.WaitGroup + for i, t := range targets { + if t.a == nil { + rows[i] = CompensationRow{UID: t.uid, Nickname: t.nick, Skip: t.skip, SkipMsg: t.msg} + continue + } + wg.Add(1) + go func(i int, t compTarget) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + row := CompensationRow{UID: t.uid, Nickname: t.nick} + st, err := s.cfg.Upstream.GetCompensationStatus(t.a) + if err != nil { + row.Error = err.Error() + } else { + row.Status = st + } + rows[i] = row + }(i, t) + } + wg.Wait() + return rows +} + +// ClaimCompensationNow 逐号「查状态 → 待领则领」,返回逐号结果。 +// +// 供面板手动按钮与签到排程共用;单号失败不影响其余账号。 +// +// 这里刻意**串行**而非并发:ClaimCompensation 对同一活动每号仅一次(重复领会拿到 +// 业务错误),串行让「前一个号失败」不影响后一个号的判定,且账号量是个位数。 +func (s *Scheduler) ClaimCompensationNow() []CompensationResult { + targets := s.compTargets() + out := make([]CompensationResult, 0, len(targets)) + for _, t := range targets { + if t.a == nil { + out = append(out, CompensationResult{UID: t.uid, Nickname: t.nick, Skip: t.skip, SkipMsg: t.msg}) + continue + } + out = append(out, s.claimOne(t.a)) + } + return out +} + +// ClaimCompensationFor 单号领取补偿(面板单号按钮);账号不在池中返回 error。 +// +// 门控与 compTargets 同一套判据,但直接查 Pool.Status/AuthByUID(均为 O(1) 的 +// 加锁查询,无副作用),不扫全表——pool 本来就提供单号查询,没必要造 O(n)。 +func (s *Scheduler) ClaimCompensationFor(uid string) (CompensationResult, error) { + st, ok := s.cfg.Pool.Status(uid) + if !ok { + return CompensationResult{UID: uid}, errors.New("account not found") + } + skip := func(reason, msg string) (CompensationResult, error) { + return CompensationResult{UID: uid, Nickname: st.Nickname, Skip: reason, SkipMsg: msg}, nil + } + if st.Disabled { + return skip(compSkipDisabled, "账号已禁用") + } + a := s.cfg.Pool.AuthByUID(uid) + if a == nil || a.AccessTokenValue() == "" { + return skip(compSkipNoAuth, "无凭证") + } + if a.IsGlobal() { + return skip(compSkipGlobal, "国际版无 CN 补偿体系") + } + if a.IsEnterprise() { + return skip(compSkipEnterprise, "企业版无补偿体系") + } + return s.claimOne(a), nil +} + +// claimOne 单账号「查状态 → 待领才领」的全部分支与日志。 +func (s *Scheduler) claimOne(a *auth.Auth) CompensationResult { + r := CompensationResult{UID: a.UID, Nickname: a.Nickname} + + st, err := s.cfg.Upstream.GetCompensationStatus(a) + if err != nil { + // 查状态失败是真问题(传输/鉴权/上游),必须显式记日志—— + // 旧盲领写法会把这类失败静默吞成「无事发生」。 + r.Error = err.Error() + log.Printf("compensation %s: 查状态失败: %v", logfmt.Label(a.UID, a.Nickname), err) + return r + } + r.Status = st + + // 判据用 upstream 的 Pending()(唯一权威实现),不要再在本文件复刻一份 + // active/claimed/credit 的与门——此前三处拷贝(这里 / Pending() / 前端 + // compPending)已经开始漂移。下方 switch 只为「不领」的三种形态给出不同文案, + // 不参与判定。 + if !st.Pending() { + switch { + case !st.Active: + r.Skip, r.SkipMsg = compSkipInactive, "当前无生效补偿" + case st.Claimed: + r.Skip, r.SkipMsg = compSkipClaimed, "本次活动已领取" + default: + r.Skip, r.SkipMsg = compSkipZeroCredit, "活动在但额度为 0" + } + return r + } + + credit, err := s.cfg.Upstream.ClaimCompensation(a) + if err != nil { + r.Error = err.Error() + log.Printf("compensation %s: 待领 %dc 但领取失败: %v", + logfmt.Label(a.UID, a.Nickname), st.CreditNum, err) + return r + } + r.Claimed, r.Credit = true, credit + log.Printf("compensation %s: 🎊 补偿领取 +%dc(截止 %s)", + logfmt.Label(a.UID, a.Nickname), credit, st.EndTime) + return r +} + +// CompensationSummary 批量领取的汇总计数。 +// +// 单独统计 Errored 是为了让调用方能区分「没有可领」与「一个都没查成」:上游故障 +// 或 token 全过期时,每个账号都会走 claimOne 的 error 分支。若只按 Claimed 计数, +// 面板会把「全部查询失败」渲染成「当前没有待领补偿」——正是本 PR 要消灭的那类 +// 静默失败,只是换了个位置。调用方必须先看 Errored 再看 Claimed。 +type CompensationSummary struct { + Total int `json:"total"` + Claimed int `json:"claimed"` + Skipped int `json:"skipped"` + Errored int `json:"errored"` + Credit int64 `json:"credit"` +} + +// OK 是否全部账号都拿到了确定结论(无错误)。 +func (s CompensationSummary) OK() bool { return s.Errored == 0 } + +// SummarizeCompensation 汇总逐号结果。 +func SummarizeCompensation(results []CompensationResult) CompensationSummary { + var s CompensationSummary + s.Total = len(results) + for _, r := range results { + switch { + case r.Error != "": + s.Errored++ + case r.Claimed: + s.Claimed++ + s.Credit += r.Credit + default: + s.Skipped++ + } + } + return s +} + +// claimCompensationQuiet 签到排程用:待领才领,无待领不记日志(避免每日刷屏), +// 但**真实失败一律记日志**——这正是旧盲领实现丢掉的信息。 +// +// 与面板手动按钮共用 claimOne,避免两条路径的分支/日志漂移。 +func (s *Scheduler) claimCompensationQuiet(a *auth.Auth) { + _ = s.claimOne(a) +} diff --git a/internal/scheduler/compensation_test.go b/internal/scheduler/compensation_test.go new file mode 100644 index 00000000..1e478e0e --- /dev/null +++ b/internal/scheduler/compensation_test.go @@ -0,0 +1,297 @@ +package scheduler + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// compensationRecorder 记录上游路径,并返回可编排的补偿状态。 +type compensationRecorder struct { + mu sync.Mutex + // statusBody 是 compensation-status 返回的 data 部分;空则用默认的「待领取 500c」。 + statusBody string + claimHits int + paths []string +} + +func (r *compensationRecorder) server(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + r.paths = append(r.paths, req.URL.Path) + switch { + case strings.HasSuffix(req.URL.Path, "/compensation-status"): + body := r.statusBody + if body == "" { + body = `{"active":true,"claimed":false,"credit_num":500,"end_time":"2026-10-22 23:59:59"}` + } + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":` + body + `}`)) + case strings.HasSuffix(req.URL.Path, "/claim-compensation"): + r.claimHits++ + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":500}}`)) + default: + r.mu.Unlock() + w.Write([]byte(`{"code":0,"msg":"OK","data":{}}`)) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func (r *compensationRecorder) claims() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.claimHits +} + +func (r *compensationRecorder) pathsSnapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, len(r.paths)) + copy(out, r.paths) + return out +} + +// compensationScheduler 构造带指定账号的调度器。 +func compensationScheduler(t *testing.T, rec *compensationRecorder, accts ...*auth.Auth) *Scheduler { + t.Helper() + srv := rec.server(t) + p := pool.New("") + for _, a := range accts { + p.Add(a) + } + up := &upstream.Client{ + HTTP: srv.Client(), + ChatBaseCN: srv.URL, + BillingBaseCN: srv.URL, + WebBaseCN: srv.URL, + } + return New(Config{Pool: p, Upstream: up}) +} + +// TestCompensationStatusAllKeepsPoolOrder 钉住行序(S10)。 +// +// 这条不是为了好看:面板按返回顺序渲染「官方补偿」卡片,若顺序随机,同一批账号 +// 每轮刷新都在换位置,用户对不上哪行是自己那个号。实现上必须**按池顺序**返回, +// 与 Pool.List()(按 UID 稳定排序)一致——并发查询只能在各自下标上写,不能靠 +// 完成顺序 append。 +func TestCompensationStatusAllKeepsPoolOrder(t *testing.T) { + rec := &compensationRecorder{} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "甲", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u2", AccessToken: "at2", Nickname: "乙", ExpiresAt: 9999999999}, + &auth.Auth{UID: "u3", AccessToken: "at3", Nickname: "丙", ExpiresAt: 9999999999}, + ) + + want := []string{"u1", "u2", "u3"} // Pool.List() 按 UID 排序 + for i := 0; i < 20; i++ { // 多跑几轮:单轮撞对的概率掩盖不了竞态 + rows := s.CompensationStatusAll() + if len(rows) != len(want) { + t.Fatalf("行数=%d want %d", len(rows), len(want)) + } + for j, uid := range want { + if rows[j].UID != uid { + t.Fatalf("第 %d 轮第 %d 行 uid=%s want %s(顺序漂移)", i, j, rows[j].UID, uid) + } + } + } +} + +// TestCompensationSkippedRowsCarryNickname 跳过行必须带昵称(S9 相关)。 +// +// 跳过行不参与上游查询,若不返回昵称,面板只能显示截断的 UID,用户根本认不出 +// 是哪个号被门控挡掉了。 +func TestCompensationSkippedRowsCarryNickname(t *testing.T) { + rec := &compensationRecorder{} + ent := &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "企业号", ExpiresAt: 9999999999} + ent.EnterpriseID = "ent-1" + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u2", AccessToken: "at2", Nickname: "个人号", ExpiresAt: 9999999999}, + ent, + ) + + rows := s.CompensationStatusAll() + if len(rows) != 2 { + t.Fatalf("行数=%d want 2", len(rows)) + } + for i := range rows { + if rows[i].UID != "u1" { + continue + } + if rows[i].Skip != compSkipEnterprise { + t.Errorf("企业号 skip=%q want %q", rows[i].Skip, compSkipEnterprise) + } + if rows[i].Nickname != "企业号" { + t.Errorf("跳过行昵称=%q want 企业号(面板靠它显示账号)", rows[i].Nickname) + } + if got := rec.pathsSnapshot(); len(got) != 1 { + t.Errorf("企业号不应发起上游查询,实际请求=%v(个人号 1 次才对)", got) + } + } +} + +// TestEnterpriseGateSkipsCompensationEntries 企业版在**面板直接触发的三个新入口** +// 上同样不得发起任何上游调用。 +// +// 与 TestEnterpriseGateSkipsGrowthTasks 互补:那个用例覆盖五类排程任务,本用例 +// 覆盖面板按钮路径——门控写在一处不够,两个入口都要钉住。 +func TestEnterpriseGateSkipsCompensationEntries(t *testing.T) { + cases := []struct { + name string + run func(*Scheduler) + }{ + {"批量状态查询", func(s *Scheduler) { s.CompensationStatusAll() }}, + {"批量领取", func(s *Scheduler) { s.ClaimCompensationNow() }}, + {"单号领取", func(s *Scheduler) { + if _, err := s.ClaimCompensationFor("u1"); err != nil { + t.Errorf("单号领取不该报错: %v", err) + } + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + // 企业号 + recEnt := &compensationRecorder{} + ent := &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "企业号", ExpiresAt: 9999999999} + ent.EnterpriseID = "ent-1" + c.run(compensationScheduler(t, recEnt, ent)) + if got := recEnt.pathsSnapshot(); len(got) != 0 { + t.Errorf("企业号不应发起任何上游调用,实际=%v", got) + } + + // 个人号对照组:必须确实有动作,否则「没发请求」可能只是构造有误。 + recPer := &compensationRecorder{} + c.run(compensationScheduler(t, recPer, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "个人号", ExpiresAt: 9999999999})) + if len(recPer.pathsSnapshot()) == 0 { + t.Errorf("个人号对照应有上游调用,实际=%v", recPer.pathsSnapshot()) + } + }) + } +} + +// TestClaimCompensationForRejectsDisabled 单号入口必须挡住已禁用账号, +// 否则出现「批量按钮跳过禁用号、手动按钮却能领」的割裂。 +func TestClaimCompensationForRejectsDisabled(t *testing.T) { + rec := &compensationRecorder{} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "禁用号", ExpiresAt: 9999999999}) + + s.cfg.Pool.Disable("u1", "测试禁用") // 置为 disabled + + res, err := s.ClaimCompensationFor("u1") + if err != nil { + t.Fatalf("不应报错: %v", err) + } + if res.Skip != compSkipDisabled { + t.Errorf("skip=%q want %q", res.Skip, compSkipDisabled) + } + if rec.claims() != 0 { + t.Errorf("禁用号不应被领取,实际 claim 调用 %d 次", rec.claims()) + } + if got := rec.pathsSnapshot(); len(got) != 0 { + t.Errorf("禁用号不应发起任何上游调用,实际=%v", got) + } +} + +// TestClaimCompensationOnlyWhenPending 「先查后领」的核心判据:只有 +// active && !claimed && credit_num>0 才真的调领取接口。 +func TestClaimCompensationOnlyWhenPending(t *testing.T) { + cases := []struct { + name string + body string + wantClaim bool + wantSkip string + }{ + {"待领取", `{"active":true,"claimed":false,"credit_num":500}`, true, ""}, + {"已领取", `{"active":true,"claimed":true,"credit_num":500}`, false, compSkipClaimed}, + {"无活动", `{"active":false,"claimed":false,"credit_num":0}`, false, compSkipInactive}, + {"额度为零", `{"active":true,"claimed":false,"credit_num":0}`, false, compSkipZeroCredit}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := &compensationRecorder{statusBody: tc.body} + s := compensationScheduler(t, rec, + &auth.Auth{UID: "u1", AccessToken: "at1", Nickname: "甲", ExpiresAt: 9999999999}) + + res := s.claimOne(s.cfg.Pool.AuthByUID("u1")) + if tc.wantClaim { + if !res.Claimed || res.Credit != 500 { + t.Errorf("应领取 500c,实际 claimed=%v credit=%d", res.Claimed, res.Credit) + } + if rec.claims() != 1 { + t.Errorf("应调用领取 1 次,实际 %d 次", rec.claims()) + } + } else { + if res.Claimed { + t.Error("不应标记为已领取") + } + if res.Skip != tc.wantSkip { + t.Errorf("skip=%q want %q", res.Skip, tc.wantSkip) + } + if rec.claims() != 0 { + t.Errorf("不应调用领取接口,实际 %d 次", rec.claims()) + } + } + }) + } +} + +// TestSummarizeCompensationDistinguishesError 汇总必须把「查询失败」与 +// 「没有可领」分开统计——这是本 PR 最重要的一条语义:上游全挂时每个账号都走 +// error 分支,若只按 claimed 计数,调用方会把「一个都没查成」说成「没有待领补偿」。 +func TestSummarizeCompensationDistinguishesError(t *testing.T) { + cases := []struct { + name string + results []CompensationResult + want CompensationSummary + }{ + { + "全部查询失败", + []CompensationResult{ + {UID: "u1", Error: "boom"}, + {UID: "u2", Error: "boom"}, + }, + CompensationSummary{Total: 2, Errored: 2}, + }, + { + "无待领(已领完)", + []CompensationResult{ + {UID: "u1", Skip: compSkipClaimed}, + {UID: "u2", Skip: compSkipClaimed}, + }, + CompensationSummary{Total: 2, Skipped: 2}, + }, + { + "混合:两个领到、一个失败", + []CompensationResult{ + {UID: "u1", Claimed: true, Credit: 500}, + {UID: "u2", Claimed: true, Credit: 500}, + {UID: "u3", Error: "boom"}, + }, + CompensationSummary{Total: 3, Claimed: 2, Errored: 1, Credit: 1000}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := SummarizeCompensation(tc.results) + if got != tc.want { + t.Errorf("汇总=%+v want %+v", got, tc.want) + } + // 全失败时 OK() 必须为 false,否则调用方会当成「一切正常,只是没活动」。 + wantOK := tc.want.Errored == 0 + if got.OK() != wantOK { + t.Errorf("OK()=%v want %v(errored=%d)", got.OK(), wantOK, got.Errored) + } + }) + } +} diff --git a/internal/scheduler/streak.go b/internal/scheduler/streak.go index 50951b10..90f4b368 100644 --- a/internal/scheduler/streak.go +++ b/internal/scheduler/streak.go @@ -44,9 +44,11 @@ func (s *Scheduler) streakBonusAccount(a *auth.Auth) { if credit, err := s.cfg.Upstream.ClaimGift(a); err == nil { log.Printf("streak-bonus %s: 🎊 新手礼包 +%dc", logfmt.Label(a.UID, a.Nickname), credit) } - if credit, err := s.cfg.Upstream.ClaimCompensation(a); err == nil { - log.Printf("streak-bonus %s: 🎊 补偿领取 +%dc", logfmt.Label(a.UID, a.Nickname), credit) - } + // 补偿改为「先查状态 → 待领才领」(issue #189):旧写法 `if credit, err := + // ClaimCompensation(...); err == nil` 把「没有补偿」「已经领过」与「领取真的 + // 失败」压成同一形态,补偿带截止日(实测那次为 10/22)时签到一旦停摆就静默 + // 过期。现在无待领不记日志,但状态查询失败与领取失败一律显式告警。 + s.claimCompensationQuiet(a) full, err := s.cfg.Upstream.GrowthStreakFull(a) if err != nil { diff --git a/internal/upstream/compensation.go b/internal/upstream/compensation.go new file mode 100644 index 00000000..72e6af1b --- /dev/null +++ b/internal/upstream/compensation.go @@ -0,0 +1,70 @@ +// compensation.go 官方补偿活动的状态查询(/billing/meter/compensation-status)。 +// +// 背景(issue #189,2026-10-16):官方在「套餐与积分」页挂限时补偿横幅 +// (例:「DeepSeek 模型服务异常额外补偿 500 积分」,带领取截止日),账号侧看得见, +// 网关这边却是**盲领**——ClaimCompensation 只在每日签到后顺带调一次,且写法是 +// +// if credit, err := ...; err == nil { log.Printf(...) } +// +// 于是三种完全不同的情形在日志里同构:① 没有补偿 ② 已经领过 ③ **真的领取失败** +// (401/5xx/网络)。补偿带截止日,签到被禁用 / 机器关机 / 账号被禁用时就会静默 +// 过期,用户全程无感知。本文件补上「先查状态再决定是否领」所缺的那一半。 +// +// 上游口径(实测 2026-10-16,cn 域 CLI 侧与 Web 侧均返回一致): +// +// {"code":0,"msg":"OK","data":{ +// "claimed":false, // 是否已领 +// "active":true, // 当前是否有生效中的补偿活动 +// "credit_num":500, // 可领额度 +// "validity_period":1, // 发放积分的有效期(单位上游未明示,前端也不渲染) +// "end_time":"2026-10-22 23:59:59"}} // 领取截止 +// +// active=false 是「当前无补偿活动」的正常 200 返回,不是错误——故此接口只在 +// 传输失败 / 非 2xx / 业务 code 非 0 时返回 error。 +package upstream + +import ( + "encoding/json" + "net/http" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// CompensationStatus 单账号的补偿活动状态。 +type CompensationStatus struct { + // Active 当前是否有生效中的补偿活动(无活动时为 false,属正常返回)。 + Active bool `json:"active"` + // Claimed 本次活动是否已领取。 + Claimed bool `json:"claimed"` + // CreditNum 待领额度(积分)。 + CreditNum int64 `json:"credit_num"` + // ValidityPeriod 发放积分的有效期倍数。上游未明示单位,且前端 UI 从不渲染 + // 该字段(仅后端返回),故此处只透传不做换算,避免臆造语义。 + ValidityPeriod int64 `json:"validity_period"` + // EndTime 领取截止时间(上游为 "2006-01-02 15:04:05" 无时区字符串)。 + EndTime string `json:"end_time"` +} + +// Pending 是否存在「可领且未领」的补偿——即该调用 ClaimCompensation 的前置判据。 +// +// 三者与门:active 为 false 是没有活动;claimed 为 true 是已领完(再调只会拿到 +// 业务错误);credit_num <= 0 是活动存在但无额度。三种都归为「无需领取」。 +func (s CompensationStatus) Pending() bool { + return s.Active && !s.Claimed && s.CreditNum > 0 +} + +// GetCompensationStatus 查补偿活动状态。 +// +// 走 billingJSON(billingBase),与既有 ClaimCompensation / ClaimGift 同域同头; +// 实测 CLI 域与 Web 域对该端点返回一致,故不必像 growth 领奖那样切 webBase。 +func (c *Client) GetCompensationStatus(a *auth.Auth) (CompensationStatus, error) { + data, err := c.billingJSON(a, http.MethodPost, "/billing/meter/compensation-status", map[string]any{}) + if err != nil { + return CompensationStatus{}, err + } + var st CompensationStatus + if err := json.Unmarshal(data, &st); err != nil { + return CompensationStatus{}, err + } + return st, nil +} diff --git a/internal/upstream/compensation_test.go b/internal/upstream/compensation_test.go new file mode 100644 index 00000000..f27d70fd --- /dev/null +++ b/internal/upstream/compensation_test.go @@ -0,0 +1,122 @@ +package upstream + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// compensationServer 起一个只回固定 body 的 billing 桩,返回指向它的 Client。 +func compensationServer(t *testing.T, body string) *Client { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/billing/meter/compensation-status" { + t.Errorf("path=%s want /billing/meter/compensation-status", r.URL.Path) + } + if r.Method != http.MethodPost { + t.Errorf("method=%s want POST", r.Method) + } + w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} +} + +// TestGetCompensationStatusParsesAllFields 全字段解析(issue #189 实测口径)。 +func TestGetCompensationStatusParsesAllFields(t *testing.T) { + c := compensationServer(t, `{"code":0,"msg":"OK","data":{"claimed":false,"active":true,`+ + `"credit_num":500,"validity_period":1,"end_time":"2026-10-22 23:59:59"}}`) + + st, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("GetCompensationStatus: %v", err) + } + if !st.Active { + t.Error("Active=false want true") + } + if st.Claimed { + t.Error("Claimed=true want false") + } + if st.CreditNum != 500 { + t.Errorf("CreditNum=%d want 500", st.CreditNum) + } + if st.ValidityPeriod != 1 { + t.Errorf("ValidityPeriod=%d want 1", st.ValidityPeriod) + } + if st.EndTime != "2026-10-22 23:59:59" { + t.Errorf("EndTime=%q want 2026-10-22 23:59:59", st.EndTime) + } + if !st.Pending() { + t.Error("Pending()=false want true(active 且未领且有额度)") + } +} + +// TestGetCompensationStatusNoActivityIsNotError 「当前无补偿活动」是 200 正常返回, +// 不是错误——这条语义一旦搞错,日常无活动的账号会把排程日志刷成一片错误。 +func TestGetCompensationStatusNoActivityIsNotError(t *testing.T) { + c := compensationServer(t, `{"code":0,"msg":"OK","data":{"claimed":false,"active":false,"credit_num":0}}`) + + st, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}) + if err != nil { + t.Fatalf("无活动不应返回 error,实际: %v", err) + } + if st.Active { + t.Error("Active=true want false") + } + if st.Pending() { + t.Error("Pending()=true want false(无活动)") + } +} + +// TestGetCompensationStatusServerError HTTP 非 2xx → error。 +func TestGetCompensationStatusServerError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`boom`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on 500") + } +} + +// TestGetCompensationStatusBusinessCode 业务 code 非 0 → error。 +func TestGetCompensationStatusBusinessCode(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"code":10001,"msg":"企业账号不支持该操作"}`)) + })) + defer srv.Close() + + c := &Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL} + if _, err := c.GetCompensationStatus(&auth.Auth{AccessToken: "at", UID: "u1"}); err == nil { + t.Fatal("want error on non-zero business code") + } +} + +// TestCompensationStatusPendingGate 三条与门各自独立成立,避免出现 +// 「active 但已领」「活动在但额度为 0」被误判成待领而反复调领取接口。 +func TestCompensationStatusPendingGate(t *testing.T) { + cases := []struct { + name string + st CompensationStatus + want bool + }{ + {"无活动", CompensationStatus{Active: false, Claimed: false, CreditNum: 500}, false}, + {"已领取", CompensationStatus{Active: true, Claimed: true, CreditNum: 500}, false}, + {"额度为零", CompensationStatus{Active: true, Claimed: false, CreditNum: 0}, false}, + {"待领取", CompensationStatus{Active: true, Claimed: false, CreditNum: 500}, true}, + {"全零值", CompensationStatus{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.st.Pending(); got != tc.want { + t.Errorf("Pending()=%v want %v(%+v)", got, tc.want, tc.st) + } + }) + } +} From b717336b0f1d7980df01aa7c08f68668bf213f4e Mon Sep 17 00:00:00 2001 From: Xiao Feng Date: Fri, 16 Oct 2026 16:40:38 +0800 Subject: [PATCH 21/88] =?UTF-8?q?feat(panel):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E9=9D=99=E6=80=81=20API=20Key=20=E8=B4=A6=E5=8F=B7=EF=BC=88?= =?UTF-8?q?=E6=97=A0=E6=88=90=E9=95=BF=E4=BD=93=E7=B3=BB=20/=20=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=8F=A3=E5=BE=84=E9=A2=9D=E5=BA=A6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板「添加账号」新增 API Key 入口:CodeBuddy 平台签发的 ck_/pt_ 静态 Key 可直接 粘贴入池,无需 OAuth 设备流。判定只看 access token 前缀(官方 CLI 即以此判 tokenType=ApiKey),落盘形态与 OAuth 账号一致(refreshToken 为空)。 - auth: 新增 IsAPIKey / UsesEnterpriseBilling / SupportsPersonalGrowth; NeedsRefresh 对静态 Key 恒 false——refresh 端点只认 OAuth 设备流,否则请求 路径会先刷新失败、把好号当坏号冷却换号 - upstream: 额度查询分流改用 UsesEnterpriseBilling(个人 get-user-resource 对 静态 Key 恒返回空 Accounts);chat 出站对齐官方 CLI 双头 (Authorization + X-API-Key);不注入企业身份头 - scheduler: 签到 / 活跃上报 / 连登管家 / 猫猫旅行 / 夜猫子门控改用 SupportsPersonalGrowth;签到早退守卫放过静态 Key,保证企业口径余额刷新照跑 - panel: 新增 POST /panel/api/accounts/add_apikey(探测额度 → 落盘 → 热加载); 单账号任务类接口对静态 Key 返回 422 并附说明,不再静默空跑;账号列表加 「API Key」徽标、隐藏成长类按钮、到期列显示「静态凭证」;昵称同步跳过静态 Key - import: 必填判据收敛为 uid + access_token,refresh_token 可为空(issue #165) (cherry picked from commit 7176aac4c3df288e32921055d447c53cfa4d1d24) --- README.md | 17 +- internal/auth/apikey_test.go | 103 ++++++++++ internal/auth/auth.go | 58 ++++++ internal/panel/apikey.go | 133 +++++++++++++ internal/panel/apikey_test.go | 183 ++++++++++++++++++ internal/panel/app.js | 45 ++++- internal/panel/autotask.go | 22 +-- internal/panel/import.go | 5 +- internal/panel/import_test.go | 62 ++++++ internal/panel/index.html | 11 ++ internal/panel/panel.go | 11 ++ internal/panel/taskcenter.go | 18 +- internal/panel/tasks.go | 10 + internal/pool/entry.go | 4 + internal/pool/state.go | 1 + internal/scheduler/apikey_gate_test.go | 113 +++++++++++ internal/scheduler/blackcat.go | 4 +- internal/scheduler/scheduler.go | 14 +- internal/scheduler/streak.go | 4 +- internal/scheduler/travel.go | 4 +- internal/upstream/apikey_headers_test.go | 52 +++++ internal/upstream/client.go | 7 +- internal/upstream/enterprise_resource_test.go | 32 +++ internal/upstream/headers.go | 5 + 24 files changed, 876 insertions(+), 42 deletions(-) create mode 100644 internal/auth/apikey_test.go create mode 100644 internal/panel/apikey.go create mode 100644 internal/panel/apikey_test.go create mode 100644 internal/panel/import_test.go create mode 100644 internal/scheduler/apikey_gate_test.go create mode 100644 internal/upstream/apikey_headers_test.go diff --git a/README.md b/README.md index 6bd6b71b..b522a780 100644 --- a/README.md +++ b/README.md @@ -577,6 +577,21 @@ WARN: [server] stream ...: saw M native tool call block(s) but repaired none (to > 成员账号**无权查询企业池总额度**(实测:池端点在成员 token 下返回 `403 not_authorized`,`/console/accounts` 显示 `isAdmin=false`)。面板展示的只是**该账号被分配的额度**;企业池余额只有企业管理员在管理后台可见。 +#### 静态 API Key 账号(面板「添加账号 → API Key」) + +CodeBuddy 平台签发的**静态 API Key**(`ck_` / `pt_` 前缀,控制台 API Key 页签发,官方用途是给 CodeBuddy CLI 用)可以直接当账号用:面板「添加账号 → API Key」粘贴即可,无需浏览器登录。落盘仍是 `auths/workbuddy-.json`(`accessToken` 存 Key、`refreshToken` 为空、`expiresAt` 0、`enterpriseId` 留空),uid 缺省取 Key 的 `` 段拼成 `apikey-`。 + +判定(`auth.Auth.IsAPIKey()`)只看 access token 前缀——官方 CLI 的判据是 `tokenType === "ApiKey"` 且 token 以 `ck_` / `pt_` 开头,落盘形态里前缀是唯一稳定、跨进程一致的判据。它与企业版共用「无个人成长体系」门控(`auth.Auth.SupportsPersonalGrowth()`),但**不等同**企业版:没有企业身份,不注入 `X-Enterprise-Id` / `X-Tenant-Id`。 + +| 能力 | 静态 API Key 账号 | +|---|---| +| chat / 选号派发 | 照常;出站按官方 CLI 双头 `Authorization: Bearer ` + `X-API-Key: ` | +| token 刷新 / 保活 | **不适用**(refresh 端点只认 OAuth 设备流):`NeedsRefresh()` 恒 false,不会因为"刷新失败"把好号冷却换号 | +| 签到 / 活跃上报 / 成长任务 / 连登管家 / 猫猫旅行 / 夜猫子 | **全部不适用**(无个人账号身份,上游 403 / 400「企业账号不支持该操作」):排程一个请求都不发;面板不渲染这些按钮;单账号任务类接口(`checkin` / `tasks` / `tasks/accept_all` / `tasks/auto` / `tasks/auto_all`)返回 `422` 并附说明,而不是执行后静默无效 | +| 额度 | 走**企业口径** `POST /v2/billing/meter/get-enterprise-user-usage`(个人 `get-user-resource` 对静态 Key 返回空 `Accounts`):由余额定时器(`schedule.balance_refresh_*`)与面板「额度」按钮刷新,不依赖 refresh token | + +导入 JSON(cockpit 形态)同样接受 `refresh_token` 为空的记录:必填判据只要求 `uid` + `access_token` 非空(issue #165 的 access-token-only 场景,静态 Key 凭证本就没有 refresh token)。 + #### 连登管家(签到排程末尾自动执行) 成长中心的连登档位(连续登录 7/14/28 天)兑换后发放积分 / 能量 / 补签卡 / **抽奖次数**,抽奖次数只能从兑换获得。管家在每日签到后自动跑一遍闭环(幂等,未解锁静默跳过): @@ -677,7 +692,7 @@ http://127.0.0.1:7863/panel/ | 账号运维 | `POST accounts/{uid}/checkin` · `balance` · `revive` · `disable` · `remove`;`POST checkin_all` · `travel_all` · `activity_all` · `keepalive_all` · `balance_all` | | 补偿 | `GET compensation`(逐号查补偿状态,**只读**);`POST accounts/{uid}/compensation`(单号领);`POST compensation_all`(全量领,返回 `summary{claimed,skipped,errored,credit}`——**先看 `errored` 再看 `claimed`**,否则会把「全部查询失败」说成「没有待领补偿」) | | 任务 | `GET accounts/{uid}/tasks`;`POST accounts/{uid}/tasks/accept` · `accept_all` · `claim` · `auto` · `auto_all`;`POST tasks/scan_all` · `tasks/run_queue` | -| 添加账号 | `POST login/start` · `GET login/poll` · `GET login/regions` · `POST import/cockpit` | +| 添加账号 | `POST login/start` · `GET login/poll` · `GET login/regions` · `POST import/cockpit` · `POST accounts/add_apikey`(静态 API Key,见上) | | 写入 | `POST config`(校验 → 落盘 → 热应用,返回需重启字段清单)· `POST usage/save`(立即把内存用量落盘) | 账号运维操作均落到池既有入口(`Revive`/`Disable`/`Remove` 等),与 `/status` 观测口径一致。 diff --git a/internal/auth/apikey_test.go b/internal/auth/apikey_test.go new file mode 100644 index 00000000..4c5e0c6b --- /dev/null +++ b/internal/auth/apikey_test.go @@ -0,0 +1,103 @@ +package auth + +import ( + "testing" + "time" +) + +// TestIsAPIKeyMatrix 静态 API Key 判定矩阵:只看 AccessToken 前缀(ck_/pt_), +// JWT 与空 token 都不是 API Key。该判定驱动额度口径分流与成长体系门控。 +func TestIsAPIKeyMatrix(t *testing.T) { + cases := []struct { + name string + tok string + want bool + }{ + {"ck_ 前缀", "ck_9f3a2b1c.deadbeef", true}, + {"pt_ 前缀", "pt_abc123.secret", true}, + {"JWT 形态", "eyJhbGciOiJIUzI1NiJ9.payload.sig", false}, + {"空 token", "", false}, + {"裸前缀无分隔", "ck_", true}, // 官方判据是前缀,不做形态完整性校验 + {"大小写敏感(CK_ 不算)", "CK_abc.def", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", AccessToken: c.tok} + if got := a.IsAPIKey(); got != c.want { + t.Errorf("token=%q IsAPIKey()=%v want %v", c.tok, got, c.want) + } + }) + } +} + +// TestAPIKeyNeverNeedsRefresh 静态 Key 是永久凭证、无法 refresh(refresh 端点只认 +// OAuth 设备流),故无论 expiry 形态都恒 false——否则 server 路径会先 refresh、失败 +// 后把好号当坏号冷却换号。 +func TestAPIKeyNeverNeedsRefresh(t *testing.T) { + now := time.Now().Unix() + for _, exp := range []int64{0, now - 10000, now + 60, now + 10*24*3600} { + a := &Auth{UID: "u1", AccessToken: "ck_abc.def", ExpiresAt: exp} + if a.NeedsRefresh(time.Hour) { + t.Errorf("API Key(ExpiresAt=%d)不应需要 refresh", exp) + } + } + // 对照:普通 OAuth 账号照旧按 expiry 判定(零回归)。 + expired := &Auth{UID: "u2", AccessToken: "at", ExpiresAt: now - 10} + if !expired.NeedsRefresh(time.Hour) { + t.Error("已过期的 OAuth 账号应需要 refresh") + } + noExpiry := &Auth{UID: "u3", AccessToken: "at", ExpiresAt: 0} + if !noExpiry.NeedsRefresh(time.Hour) { + t.Error("无 expiry 的 OAuth 账号应需要 refresh") + } + valid := &Auth{UID: "u4", AccessToken: "at", ExpiresAt: now + 10*3600} + if valid.NeedsRefresh(time.Hour) { + t.Error("未到期的 OAuth 账号不应需要 refresh") + } +} + +// TestUsesEnterpriseBilling 额度口径分流:企业版与静态 API Key 走企业口径端点, +// 个人号走个人资源包端点。 +func TestUsesEnterpriseBilling(t *testing.T) { + cases := []struct { + name string + ent string + tok string + want bool + }{ + {"企业版", "ent-1", "at", true}, + {"静态 API Key", "", "ck_abc.def", true}, + {"个人号(对照)", "", "at", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: c.ent, AccessToken: c.tok} + if got := a.UsesEnterpriseBilling(); got != c.want { + t.Errorf("UsesEnterpriseBilling()=%v want %v", got, c.want) + } + }) + } +} + +// TestSupportsPersonalGrowth 个人成长体系门控:企业版与静态 API Key 均无该体系, +// 只有个人号支持(对照)。 +func TestSupportsPersonalGrowth(t *testing.T) { + cases := []struct { + name string + ent string + tok string + want bool + }{ + {"个人号", "", "at", true}, + {"企业版", "ent-1", "at", false}, + {"静态 API Key", "", "ck_abc.def", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + a := &Auth{UID: "u1", EnterpriseID: c.ent, AccessToken: c.tok} + if got := a.SupportsPersonalGrowth(); got != c.want { + t.Errorf("SupportsPersonalGrowth()=%v want %v", got, c.want) + } + }) + } +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 56de61c8..a42b86f5 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -191,6 +191,59 @@ func BackfillRealmFor(a *Auth, realm string) (bool, error) { // IsGlobal 报告账号是否属于 global realm(= Realm() == "global")。 func (a *Auth) IsGlobal() bool { return a.Realm() == "global" } +// apiKeyPrefixes 静态 API Key 的前缀集合:ck_(CodeBuddy 控制台签发)与 +// pt_(官方 CLI @tencent-ai/codebuddy-code 另有该前缀)。 +// 官方 CLI 的判据是 tokenType === "ApiKey" 且 token 以这两个前缀之一开头; +// auth 文件不持久化 tokenType(refresh 只认 OAuth 设备流,静态 Key 无法刷新), +// 前缀是落盘形态里唯一稳定、跨进程一致的判据。 +var apiKeyPrefixes = []string{"ck_", "pt_"} + +// isAPIKeyToken 判 token 是否以静态 API Key 前缀开头。 +func isAPIKeyToken(at string) bool { + for _, p := range apiKeyPrefixes { + if strings.HasPrefix(at, p) { + return true + } + } + return false +} + +// isAPIKeyLocked IsAPIKey 的无锁内部实现:仅限**已持 a.mu** 的调用方使用 +// (sync.Mutex 不可重入,锁内再调 AccessTokenValue 会自锁)。 +func (a *Auth) isAPIKeyLocked() bool { return isAPIKeyToken(a.AccessToken) } + +// IsAPIKey 报告账号是否为静态 API Key 账号(AccessToken 以 ck_/pt_ 开头)。 +// 经 AccessTokenValue() 加锁读取:RefreshToken 会在锁内改写 AccessToken。 +// +// 语义:静态 Key 直接可用(Authorization: Bearer ),但**无个人成长体系** +// (签到/成长任务/连登/旅行/夜猫子一律不适用),且额度只能走企业口径端点 +// (个人 get-user-resource 对静态 Key 返回空 Accounts),见 UsesEnterpriseBilling。 +func (a *Auth) IsAPIKey() bool { + if a == nil { + return false + } + return isAPIKeyToken(a.AccessTokenValue()) +} + +// UsesEnterpriseBilling 报告账号的额度查询是否走企业口径端点 +// (/v2/billing/meter/get-enterprise-user-usage)。 +// 企业版与静态 API Key 账号同属此列:个人 get-user-resource 对二者均返回空 +// Accounts(企业账号无个人资源包;静态 Key 无个人账号身份),额度必须走企业口径。 +func (a *Auth) UsesEnterpriseBilling() bool { return a.IsEnterprise() || a.IsAPIKey() } + +// SupportsPersonalGrowth 报告账号是否具备个人成长体系(签到 / 成长任务 / 连登管家 / +// 猫猫旅行 / 夜猫子)。以下三类账号都没有该体系: +// - 企业版(IsEnterprise):上游一律拒绝(400 code 10001 / 403 growth-only); +// - 静态 API Key 账号(IsAPIKey):根本没有个人账号身份; +// - 企业切换会话(token_source=enterprise_switch,见 IsEnterpriseSession): +// 读端点照常但行为事件被上游静默丢弃(200 不计分)——同样是零收益白跑。 +// +// scheduler 五类任务与 panel 成长入口据此并列门控,避免对注定失败的端点空发请求。 +// 注意:读端点(签到报错、任务列表扫描)不在此列——它们的失败是显式报错。 +func (a *Auth) SupportsPersonalGrowth() bool { + return !a.IsEnterprise() && !a.IsAPIKey() && !a.IsEnterpriseSession() +} + // IsEnterprise 报告账号是否为企业版(auth 文件带非空 enterpriseId)。 // // 企业版**没有个人成长体系**:签到 / 成长任务 / 连登管家 / 猫猫旅行 / 夜猫子 @@ -268,6 +321,11 @@ func isGlobalDomain(d string) bool { func (a *Auth) NeedsRefresh(within time.Duration) bool { a.mu.Lock() defer a.mu.Unlock() + // 静态 API Key 永不刷新(refresh 端点只认 OAuth 设备流):恒 false,否则 + // server 请求路径会先 refresh、失败后把该号当坏号冷却换号——Key 本身是好的。 + if a.isAPIKeyLocked() { + return false + } if a.ExpiresAt <= 0 { return true } diff --git a/internal/panel/apikey.go b/internal/panel/apikey.go new file mode 100644 index 00000000..c5dac80f --- /dev/null +++ b/internal/panel/apikey.go @@ -0,0 +1,133 @@ +// apikey.go 面板「添加 API Key 账号」:CodeBuddy 平台签发的静态 API Key +// (ck_/pt_ 前缀)直接可用,无需 OAuth 设备流。 +// +// 落盘形态与 OAuth 账号完全一致(auths/workbuddy-.json,嵌套形,0600), +// 区别仅在没有 refreshToken / expiresAt 与 enterpriseId——静态凭证不可刷新, +// 额度走企业口径端点(见 upstream.UserResourceDetailedWithExpiry 的分流)。 +package panel + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" +) + +// apiKeyUIDMaxLen 派生/显式 uid 的长度上限(与 validUID 同口径:拼文件名的兜底)。 +const apiKeyUIDMaxLen = 64 + +// apiKeyPrefixes 静态 API Key 前缀集合,用于派生 uid 时剥前缀。 +// 前缀合法性判定走 auth.IsAPIKey()(单一判据来源),此处仅作字符串裁剪。 +var apiKeyPrefixes = []string{"ck_", "pt_"} + +// addAPIKey 添加一个静态 API Key 账号。 +// +// POST /panel/api/accounts/add_apikey +// body: {"access_token":"ck_...","nickname":"","uid":""} +// +// 流程:校验前缀 → 解析/派生 uid → 用企业额度端点探测 key 是否有效(无效即 400, +// 不落盘)→ 落盘 → 热加载进池并写入探测到的额度。 +func (p *Panel) addAPIKey(w http.ResponseWriter, r *http.Request) { + var body struct { + AccessToken string `json:"access_token"` + Nickname string `json:"nickname"` + UID string `json:"uid"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, http.StatusBadRequest, "invalid json: "+err.Error()) + return + } + key := strings.TrimSpace(body.AccessToken) + // 前缀判据复用 auth:构造一个只带 token 的临时 Auth 走 IsAPIKey(避免前缀 + // 集合在 panel 与 auth 两处各写一遍)。 + if !(&auth.Auth{AccessToken: key}).IsAPIKey() { + writeErr(w, http.StatusBadRequest, "access_token 必须是 ck_/pt_ 前缀的 API Key") + return + } + uid, err := resolveAPIKeyUID(body.UID, key) + if err != nil { + writeErr(w, http.StatusBadRequest, err.Error()) + return + } + + // 探测 key 是否有效:静态 Key 的额度只能走企业口径端点(个人 get-user-resource + // 对它返回空 Accounts)。探测失败(无效 Key / 上游报错)即 400,不落盘。 + probe := &auth.Auth{AccessToken: key} + remain, total, expiring, earliestAt, earliestRemaining, err := p.cfg.Upstream.UserResourceDetailedWithExpiry(probe, p.expiringSoonWindow()) + if err != nil { + writeErr(w, http.StatusBadRequest, "API Key 无效或额度查询失败:"+err.Error()) + return + } + + a := &auth.Auth{ + AccessToken: key, + // RefreshToken 留空:静态 Key 无法 refresh(refresh 端点只认 OAuth 设备流); + // ExpiresAt 留 0(静态凭证无过期),EnterpriseID 留空(无企业身份,也不注入 + // X-Enterprise-Id / X-Tenant-Id)。 + UID: uid, + Nickname: strings.TrimSpace(body.Nickname), + FilePath: filepath.Join(p.cfg.AuthDir, fmt.Sprintf("workbuddy-%s.json", uid)), + } + // realm 按现有约定补全(静态 Key 恒 CN;与 LoadDir 存量迁移同口径)。 + _, _ = a.BackfillRealm() + if err := os.MkdirAll(p.cfg.AuthDir, 0o755); err != nil { + writeErr(w, http.StatusInternalServerError, "mkdir auth dir: "+err.Error()) + return + } + if err := a.SaveAtomic(); err != nil { + writeErr(w, http.StatusInternalServerError, "save auth: "+err.Error()) + return + } + p.cfg.Pool.Add(a) // upsert:已存在则覆盖凭证、保留原池状态 + p.cfg.Pool.SetCreditsDetailed(uid, remain, total, expiring, earliestAt, earliestRemaining) + log.Printf("panel: 添加 API Key 账号 uid=%s nickname=%q credits=%d/%d", uid, a.Nickname, remain, total) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "uid": uid, "nickname": a.Nickname, "api_key": true}) +} + +// resolveAPIKeyUID 解析落盘用 uid:显式传入时按字符白名单校验(拒绝路径穿越); +// 缺省时取 key 的 段(ck_. 的 id)拼成 apikey-,非法字符剔除。 +func resolveAPIKeyUID(explicit, key string) (string, error) { + if e := strings.TrimSpace(explicit); e != "" { + if !validImportUID(e) { + return "", fmt.Errorf("uid 只能包含字母、数字、下划线、连字符(≤%d 字符)", apiKeyUIDMaxLen) + } + return e, nil + } + id := apiKeyID(key) + if id == "" { + return "", fmt.Errorf("无法从 key 解析 id 段,请显式指定 uid") + } + uid := "apikey-" + id + if len(uid) > apiKeyUIDMaxLen { + uid = uid[:apiKeyUIDMaxLen] + } + return uid, nil +} + +// apiKeyID 取 ck_. 的 段(剥前缀、截到首个点之前),只保留 +// [A-Za-z0-9_-]——拼文件名前必须净化,避免路径穿越。 +func apiKeyID(key string) string { + s := key + for _, p := range apiKeyPrefixes { + if strings.HasPrefix(s, p) { + s = s[len(p):] + break + } + } + if i := strings.IndexByte(s, '.'); i >= 0 { + s = s[:i] + } + var b strings.Builder + for _, c := range s { + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': + b.WriteRune(c) + } + } + return b.String() +} diff --git a/internal/panel/apikey_test.go b/internal/panel/apikey_test.go new file mode 100644 index 00000000..9fef98a0 --- /dev/null +++ b/internal/panel/apikey_test.go @@ -0,0 +1,183 @@ +package panel + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" +) + +// panelWithUpstream 构造带 mock 上游与临时 auth 目录的面板(APIKey 留空 = 不鉴权)。 +func panelWithUpstream(t *testing.T, handler http.HandlerFunc) *Panel { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return New(Config{ + Version: "test", + Pool: pool.New(""), + Upstream: &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL, WebBaseCN: srv.URL}, + AuthDir: t.TempDir(), + }) +} + +// postJSON 发一次 JSON POST 到面板。 +func postJSON(t *testing.T, p *Panel, path string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec +} + +// authFiles 返回 auth 目录下的文件名列表(断言"未落盘"用)。 +func authFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + var out []string + for _, e := range entries { + out = append(out, e.Name()) + } + return out +} + +// TestAddAPIKeyRejectsBadPrefix 非 ck_/pt_ 前缀直接 400,且不打上游、不落盘。 +func TestAddAPIKeyRejectsBadPrefix(t *testing.T) { + upstreamCalled := false + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + upstreamCalled = true + w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": "eyJhbGciOiJIUzI1NiJ9.x.y"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body.String()) + } + if upstreamCalled { + t.Error("前缀非法时不应探测上游") + } + if files := authFiles(t, p.cfg.AuthDir); len(files) != 0 { + t.Errorf("前缀非法时不应落盘,实际=%v", files) + } +} + +// TestAddAPIKeyRejectsInvalidKey 上游报错(key 无效)时 400 并带上游文案,不落盘。 +func TestAddAPIKeyRejectsInvalidKey(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":10001,"msg":"invalid api key"}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": "ck_bad.secret"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("code=%d want 400 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + msg, _ := resp["error"].(string) + if !strings.Contains(msg, "invalid api key") { + t.Errorf("应带上游错误文案,实际 error=%q", msg) + } + if files := authFiles(t, p.cfg.AuthDir); len(files) != 0 { + t.Errorf("key 无效时不应落盘,实际=%v", files) + } +} + +// TestAddAPIKeySuccess 正常路径:探测通过 → 落盘(auth.LoadDir 兼容)→ 热加载进池 +// 并写入企业口径额度 → 返回 uid/nickname/api_key。 +func TestAddAPIKeySuccess(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/get-enterprise-user-usage") { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":812.45,"limitNum":2000,` + + `"cycleEndTime":"2099-10-26 23:59:59","cycleResetTime":"2099-10-27 00:00:00"}}`)) + return + } + w.Write([]byte(`{"code":0,"msg":"ok","data":{}}`)) + }) + const key = "ck_9f3a2b1c.deadbeef" + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{"access_token": key, "nickname": "钥匙"}) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d want 200 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["uid"] != "apikey-9f3a2b1c" || resp["nickname"] != "钥匙" || resp["api_key"] != true { + t.Errorf("响应异常:%v", resp) + } + + // 落盘形态必须与 auth.LoadDir/Parse 兼容。 + fp := filepath.Join(p.cfg.AuthDir, "workbuddy-apikey-9f3a2b1c.json") + raw, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("read saved auth: %v", err) + } + a, err := auth.Parse(raw) + if err != nil { + t.Fatalf("reparse: %v", err) + } + if a.AccessToken != key || a.RefreshToken != "" || a.ExpiresAt != 0 || a.EnterpriseID != "" { + t.Errorf("落盘字段异常:%+v", a) + } + if a.UID != "apikey-9f3a2b1c" || !a.IsAPIKey() { + t.Errorf("落盘 uid/判定异常:uid=%q isAPIKey=%v", a.UID, a.IsAPIKey()) + } + // Windows 的权限位只有只读位有语义(os.WriteFile 的 0600 落成 0666), + // 该断言只在 POSIX 平台有意义——容器与 CI 都是 Linux,仍能守住 0600。 + if runtime.GOOS != "windows" { + if fi, err := os.Stat(fp); err == nil && fi.Mode().Perm() != 0o600 { + t.Errorf("auth 文件权限=%v want 0600", fi.Mode().Perm()) + } + } + loaded, err := auth.LoadDir(p.cfg.AuthDir) + if err != nil || len(loaded) != 1 { + t.Fatalf("LoadDir 应加载 1 个账号:n=%d err=%v", len(loaded), err) + } + + // 热加载进池 + 企业口径额度写入。 + st, ok := p.cfg.Pool.Status("apikey-9f3a2b1c") + if !ok { + t.Fatal("账号未热加载进池") + } + if !st.APIKey { + t.Error("Status.APIKey 应为 true") + } + if st.Credits != 1188 || st.CreditsTotal != 2000 { + t.Errorf("额度映射错误:credits=%d total=%d want 1188/2000", st.Credits, st.CreditsTotal) + } +} + +// TestAddAPIKeyExplicitUID 显式 uid 走字符白名单校验:合法值原样使用,非法值 400。 +func TestAddAPIKeyExplicitUID(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"OK","data":{"credit":0,"limitNum":100}}`)) + }) + rec := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{ + "access_token": "pt_abc.secret", "uid": "my-uid_1", + }) + if rec.Code != http.StatusOK { + t.Fatalf("合法 uid 应 200,实际 code=%d body=%s", rec.Code, rec.Body.String()) + } + if _, ok := p.cfg.Pool.Status("my-uid_1"); !ok { + t.Error("显式 uid 未生效") + } + + rec2 := postJSON(t, p, "/panel/api/accounts/add_apikey", map[string]any{ + "access_token": "ck_abc.secret", "uid": "../../evil", + }) + if rec2.Code != http.StatusBadRequest { + t.Errorf("非法 uid 应 400,实际 code=%d body=%s", rec2.Code, rec2.Body.String()) + } +} diff --git a/internal/panel/app.js b/internal/panel/app.js index 6b2b398e..c13bda58 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -415,6 +415,9 @@ function renderAccounts(list) { ' ' + c.model + ':' + (c.cost_per_1k <= 0 ? '免费' : c.cost_per_1k)).join('\n'); } const frozen = s.disabled || cool > 0; + // 企业版与静态 API Key 账号均无个人成长体系:隐藏签到/任务按钮,只留「额度」 + // (走企业口径 get-enterprise-user-usage)。API Key 账号另有「API Key」徽标。 + const noGrowth = s.enterprise || s.api_key; const tu = s.token_usage || {}; const req = tu.request_count || 0; const totalTok = formatTokenCount(tu.total_tokens); @@ -424,7 +427,7 @@ function renderAccounts(list) { const usageTitle = '最近一次:' + req + ' 次 / ' + totalTok + ' / 延迟 ' + latency + ' / ' + rate; return '' + '' + - '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + (s.enterprise ? ' 企业版' : '') + '
' + esc(short) + '
' + + '
' + (s.nickname ? esc(s.nickname) : '未命名') + (s.realm === 'global' ? ' 国际版' : '') + (s.enterprise ? ' 企业版' : '') + (s.api_key ? ' API Key' : '') + '
' + esc(short) + '
' + '' + tag + note + rateLimits + '' + '
' + cred + '
' + '' + (s.success_count || 0) + ' / ' + (s.err_total || 0) + '' + @@ -437,17 +440,17 @@ function renderAccounts(list) { '' + '' + ago(s.last_success) + '' + '' + - // 企业版无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403): + // 企业版与静态 API Key 账号无个人成长体系(签到 400「企业账号不支持该操作」/ 成长任务 403): // 不渲染「签到」「任务」按钮,只留「额度」——点它走 /balance,企业额度由 // upstream 的 get-enterprise-user-usage 口径填充。 - (s.enterprise ? '' : + (noGrowth ? '' : '') + - '' + - (s.enterprise ? '' : + '' + + (noGrowth ? '' : '') + (frozen ? '' : (s.paused ? '' - : '')) + + : '')) + (s.disabled ? '' : '') + '' + ''; @@ -1190,6 +1193,7 @@ function openAdd() { $('addLoad').hidden = true; $('addReady').hidden = true; $('addDone').hidden = true; $('addErr').hidden = true; $('importDone').hidden = true; $('importErr').hidden = true; + $('apiKeyDone').hidden = true; $('apiKeyErr').hidden = true; $('btnCopyUrl').hidden = true; $('btnOpenUrl').hidden = true; $('btnStartLogin').hidden = false; $('btnStartLogin').disabled = false; stopPoll(); @@ -1198,6 +1202,11 @@ function switchAddTab(tab) { document.querySelectorAll('#addTabs .tab').forEach(b => b.classList.toggle('on', b.dataset.tab === tab)); $('addTabLogin').hidden = tab !== 'login'; $('addTabImport').hidden = tab !== 'import'; + $('addTabApiKey').hidden = tab !== 'apikey'; + // 页脚按钮随标签切:登录页显示「获取授权链接」(登录进行中由 startAddLogin 隐藏), + // API Key 页显示「添加 API Key」;导入页无页脚按钮。 + $('btnAddApiKey').hidden = tab !== 'apikey'; + $('btnStartLogin').hidden = tab !== 'login' || loginState != null; } document.querySelectorAll('#addTabs .tab').forEach(b => { b.onclick = () => switchAddTab(b.dataset.tab); @@ -1246,6 +1255,30 @@ $('btnStartLogin').onclick = startAddLogin; $('btnOpenUrl').onclick = () => open($('addUrl').textContent, '_blank'); $('btnCopyUrl').onclick = () => navigator.clipboard.writeText($('addUrl').textContent) .then(() => toast('链接已复制', 'ok'), () => toast('复制失败,请手动选择复制', 'err')); +$('btnAddApiKey').onclick = async () => { + const token = ($('apiKeyInput').value || '').trim(); + $('apiKeyDone').hidden = true; $('apiKeyErr').hidden = true; + if (!token) { $('apiKeyErr').hidden = false; $('apiKeyErr').textContent = '请填写 API Key'; return; } + const btn = $('btnAddApiKey'); + btn.disabled = true; + try { + const r = await api('accounts/add_apikey', { + method: 'POST', + body: JSON.stringify({ + access_token: token, + nickname: ($('apiKeyNickname').value || '').trim(), + uid: ($('apiKeyUid').value || '').trim(), + }), + }); + $('apiKeyDone').hidden = false; + $('apiKeyDone').textContent = '已添加 ' + (r.nickname || r.uid) + '(API Key 账号),账号已载入池中'; + $('apiKeyInput').value = ''; + setTimeout(() => { closeAdd(); loadOverview(true); }, 1600); + } catch (e) { + $('apiKeyErr').hidden = false; + $('apiKeyErr').textContent = '添加失败:' + e.message; + } finally { btn.disabled = false; } +}; $('importFile').onchange = async () => { const file = $('importFile').files[0]; if (!file) return; diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index 2cd5b03e..ce82d02c 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -631,13 +631,13 @@ func (p *Panel) accountTaskAuto(w http.ResponseWriter, r *http.Request) { if a == nil { return } - // 企业上下文门控(企业版 / 企业切换会话):行为事件会被上游静默丢弃或 - // 直接 403,执行任何成长任务都是零收益白跑(含真实对话配额)——直接拒绝 - // 并给出修复路径,不再让用户对着「已上报但进度 0/x」猜原因 - // (见 auth.IsEnterpriseSession)。 - if a.IsEnterpriseSession() { + // 企业上下文门控(企业版 / 企业切换会话 / 静态 API Key 账号):行为事件会被上游 + // 静默丢弃或直接 403,执行任何成长任务都是零收益白跑(含真实对话配额)——直接 + // 拒绝并给出修复路径,不再让用户对着「已上报但进度 0/x」猜原因 + // (见 auth.SupportsPersonalGrowth / auth.IsEnterpriseSession)。 + if !a.SupportsPersonalGrowth() { writeErr(w, http.StatusUnprocessableEntity, - "该账号为企业版/企业切换会话(token_source=enterprise_switch),无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + "该账号为企业版/企业切换会话(token_source=enterprise_switch)或 API Key 账号,无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") return } var body struct { @@ -1274,12 +1274,12 @@ func (p *Panel) accountTaskAutoAll(w http.ResponseWriter, r *http.Request) { if a == nil { return } - // 企业上下文门控:与 accountTaskAuto 同口径(企业版 / 企业切换会话下成长任务 - // 行为事件被上游静默丢弃,跑全量只会白耗真实对话配额)。「一键完成全部」与 - // 单任务入口共用同一套动作,必须一并挡上(下游 fork 吸收本 PR 时发现的缺口)。 - if a.IsEnterpriseSession() { + // 企业上下文门控:与 accountTaskAuto 同口径(企业版 / 企业切换会话 / API Key + // 账号下成长任务行为事件被上游静默丢弃,跑全量只会白耗真实对话配额)。 + // 「一键完成全部」与单任务入口共用同一套动作,必须一并挡上。 + if !a.SupportsPersonalGrowth() { writeErr(w, http.StatusUnprocessableEntity, - "该账号为企业版/企业切换会话(token_source=enterprise_switch),无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") + "该账号为企业版/企业切换会话(token_source=enterprise_switch)或 API Key 账号,无个人成长体系,成长任务行为事件会被上游忽略;请移除账号后以个人身份重新登录") return } // per-account 互斥(与单任务动作共用一把锁):重复点击 409。 diff --git a/internal/panel/import.go b/internal/panel/import.go index 3ac7050e..902e93ae 100644 --- a/internal/panel/import.go +++ b/internal/panel/import.go @@ -76,7 +76,10 @@ func (p *Panel) importCockpit(w http.ResponseWriter, r *http.Request) { uid := strings.TrimSpace(acc.UID) at := strings.TrimSpace(acc.AccessToken) rt := strings.TrimSpace(acc.RefreshToken) - if uid == "" || at == "" || rt == "" { + // 必填判据只要求 uid + access_token 非空:refresh_token 可以缺(issue #165 的 + // access-token-only 导出——例如静态 API Key 凭证,本就没有 refresh token)。 + // 缺 refresh_token 的账号仍可接流量与刷新额度,只是不参与保活刷新。 + if uid == "" || at == "" { skipped++ errs = append(errs, fmt.Sprintf("missing required fields (id=%s)", acc.ID)) continue diff --git a/internal/panel/import_test.go b/internal/panel/import_test.go new file mode 100644 index 00000000..f79d9de8 --- /dev/null +++ b/internal/panel/import_test.go @@ -0,0 +1,62 @@ +package panel + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" +) + +// postImport 以 multipart 表单发一次 cockpit 导入。 +func postImport(t *testing.T, p *Panel, payload string) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, err := mw.CreateFormFile("file", "accounts.json") + if err != nil { + t.Fatal(err) + } + if _, err := fw.Write([]byte(payload)); err != nil { + t.Fatal(err) + } + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/panel/api/import/cockpit", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + rec := httptest.NewRecorder() + p.ServeHTTP(rec, req) + return rec +} + +// TestImportCockpitAllowsEmptyRefreshToken 必填判据收敛为 uid + access_token: +// refresh_token 为空的记录不再被跳过(issue #165 的 access-token-only 场景, +// 如静态 API Key 凭证)。缺 access_token 的记录仍应被跳过。 +func TestImportCockpitAllowsEmptyRefreshToken(t *testing.T) { + p := panelWithUpstream(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"code":0,"msg":"ok","data":{"Response":{"Data":{"Accounts":[]}}}}`)) + }) + payload := `[` + + `{"id":"a1","uid":"u-no-rt","access_token":"at-1","refresh_token":""},` + + `{"id":"a2","uid":"u-no-at","access_token":""}` + + `]` + rec := postImport(t, p, payload) + if rec.Code != http.StatusOK { + t.Fatalf("code=%d want 200 body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &resp) + if resp["imported"] != float64(1) || resp["skipped"] != float64(1) { + t.Fatalf("imported/skipped=%v/%v want 1/1 body=%s", resp["imported"], resp["skipped"], rec.Body.String()) + } + a := p.cfg.Pool.AuthByUID("u-no-rt") + if a == nil { + t.Fatal("refresh_token 为空的记录应被导入") + } + if a.RefreshTokenValue() != "" { + t.Errorf("refreshToken 应为空,实际=%q", a.RefreshTokenValue()) + } + if _, ok := p.cfg.Pool.Status("u-no-at"); ok { + t.Error("缺 access_token 的记录不应被导入") + } +} diff --git a/internal/panel/index.html b/internal/panel/index.html index fb24b695..082ff776 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -1228,6 +1228,7 @@

开学季 · 我的券码

添加账号

+
@@ -1251,6 +1252,15 @@

添加账号

+ + +
+
+
+

账号备注

+
备注只存在本机浏览器(localStorage: wb2api.remarks.v1),不写回后端;账号池与用量页会同时显示「备注 + 原昵称」。留空即清除该账号备注。
+
+
+
+
+
+ + + + +
+
+
+
From 9c0760cc090874b73e469538320539e06a698605 Mon Sep 17 00:00:00 2001 From: ftf112233 <3391429706@qq.com> Date: Sat, 17 Oct 2026 10:53:20 +0800 Subject: [PATCH 24/88] =?UTF-8?q?fix(panel):=20=E6=A8=A1=E5=9E=8B=E7=BA=A7?= =?UTF-8?q?=206004=20=E5=8F=8C=E6=97=B6=E9=92=9F=E2=80=94=E2=80=94until=resetAt / model_unavailable)展示不变。 测试:更新 TestAppJSRateLimitMeta 期望值钉住新口径;node --check 通过, go build/vet/test ./... 全部通过。 Refs #171 (cherry picked from commit 20c3997239c67423623e6b3fb07abb0d6f6d3cf6) --- internal/panel/app.js | 16 ++++++++++++---- internal/panel/frontend_test.go | 3 ++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/panel/app.js b/internal/panel/app.js index 976c5045..530639aa 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -263,13 +263,21 @@ function rateLimitMeta(row, now) { title: model + '\n模型当前不可用' + (deadline ? '\n最早重试:' + fmtLocalDateTime(deadline) : ''), }; } - let detail = resetAt - ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') - : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); const title = [model, resetAt ? '上游重置:' + fmtLocalDateTime(resetAt) : '上游重置:时间未知']; if (until && resetAt && until < resetAt) { - detail += ' · 网关最快 ' + dur(Math.max(0, Math.round((until - now) / 1000))) + ' 后重试'; + // 双时钟(issue #171 问题二):黄框出现/消失由 until 决定(网关为防超长 + // 限流拖死全池,把重试封顶在 now+softRateMax),主文字却按 resetAt 显示, + // 导致「写的解封时间」与「实际掉框时刻」不一致。此时主文字同时明示两个 + // 时刻与口径,倒计时跟随 until(与实际掉框一致,也与「模型与档位」页 + // 锁池表的 until 口径对齐)。 + const retryIn = until > now ? Math.round((until - now) / 1000) : 0; + detail = '网关 ' + fmtLocalDateTime(until) + ' 起重试' + (retryIn ? '(剩余 ' + dur(retryIn) + ')' : '') + + ' · 上游称 ' + fmtLocalDateTime(resetAt) + ' 恢复'; title.push('网关最早重试:' + fmtLocalDateTime(until)); + } else { + detail = resetAt + ? '预计 ' + fmtLocalDateTime(resetAt) + ' 解封' + (remaining ? '(剩余 ' + dur(remaining) + ')' : '') + : (until ? '预计 ' + fmtLocalDateTime(until) + ' 恢复(剩余 ' + dur(remaining) + ')' : '预计解封时间未知'); } return { model, kind, detail, title: title.join('\n') }; } diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index 881ed0b1..e54b25d0 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -212,7 +212,8 @@ process.stdout.write(JSON.stringify({ if err != nil { t.Fatalf("rate-limit formatting node test failed: %v\n%s", err, out) } - const want = `{"rate":"预计 2026-09-28 16:00 解封(剩余 2时00分) · 网关最快 1时00分 后重试","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}` + // issue #171 问题二:until < resetAt 时主文字双时刻明示、倒计时跟随 until。 + const want = `{"rate":"网关 2026-09-28 15:00 起重试(剩余 1时00分) · 上游称 2026-09-28 16:00 恢复","unavailable":"预计 1时00分 后重试","unknown":"预计解封时间未知"}` if strings.TrimSpace(string(out)) != want { t.Fatalf("rate-limit formatting=%s want %s", out, want) } From efd53f8698080afb7b3b611ebd30a784cdf2d046 Mon Sep 17 00:00:00 2001 From: SDCOM-0415 <127208666+SDCOM-0415@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:41:09 +0800 Subject: [PATCH 25/88] =?UTF-8?q?feat(pool):=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E5=9C=B0=E5=8C=BA=E7=8B=AC=E7=AB=8B=E9=85=8D=E7=BD=AE=E8=BD=AE?= =?UTF-8?q?=E8=AF=A2=E5=92=8C=E9=A1=BA=E5=BA=8F=E8=80=97=E5=B0=BD=E8=B0=83?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (cherry picked from commit a8ff7556778644ccdc18ff89838137db1dcf6fd4) --- README.md | 31 +++ cmd/server/config.go | 17 +- cmd/server/main.go | 2 + cmd/server/strategy_config_test.go | 92 ++++++++ config.example.json | 8 + internal/livecfg/livecfg.go | 8 +- internal/panel/app.js | 9 +- internal/panel/autotask.go | 6 +- internal/panel/compensation_frontend_test.go | 14 +- internal/panel/frontend_test.go | 44 ++++ internal/panel/import.go | 34 +-- internal/panel/index.html | 21 ++ internal/panel/model_probes_test.go | 6 +- internal/panel/overview_expiry_test.go | 6 +- internal/panel/panel.go | 4 +- internal/pool/entry.go | 3 +- internal/pool/persist.go | 4 +- internal/pool/pick.go | 17 ++ internal/pool/pool.go | 6 +- internal/pool/state.go | 34 +-- internal/pool/strategy.go | 173 ++++++++++++++ internal/pool/strategy_test.go | 230 +++++++++++++++++++ internal/scheduler/scheduler.go | 2 +- internal/server/handler.go | 6 +- internal/server/handler_strategy_test.go | 58 +++++ internal/session/session.go | 16 ++ internal/session/session_test.go | 27 +++ 27 files changed, 810 insertions(+), 68 deletions(-) create mode 100644 cmd/server/strategy_config_test.go create mode 100644 internal/pool/strategy.go create mode 100644 internal/pool/strategy_test.go create mode 100644 internal/server/handler_strategy_test.go diff --git a/README.md b/README.md index b522a780..1a9d7b29 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,9 @@ curl -s http://localhost:7863/v1/chat/completions \ | `pool.breaker_cooldown_max` | `6h` | 熔断指数退避封顶 | | `pool.idle_weight_per_hour` | `0.5` | 闲置补偿:每小时未使用 +0.5 权重 | | `pool.idle_weight_max` | `5.0` | 闲置补偿权重封顶 | +| `pool.strategy.domestic` | `weighted_random` | 国内版账号池调度:`weighted_random` / `round_robin` / `sequential_exhaust`,支持热更新 | +| `pool.strategy.international` | `weighted_random` | 国际版账号池独立调度策略,同上;不改变既有地区路由 | +| `pool.account_order.domestic` / `international` | `[]` | 对应地区的 UID 优先顺序;未列出账号按 UID 排序追加;只用于轮询和顺序耗尽 | | `pool.prefer_expiring` | `true` | 快过期积分加权:窗口内仍有有效快过期批次的账号,选号权重 ×3(虚拟实例);不按到期时间排序、与批次金额无关;是软偏好,弱于会话粘性与模型成本分层(#101) | | `pool.expiring_soon` | `168h` | 快过期加权窗口:仅窗口内仍有有效批次的账号命中上述 ×3;窗口开大 → 命中账号变多、偏好被稀释;留空或 `0` 关闭 | | `session_sticky.enabled` | `true` | 会话粘性路由开关 | @@ -961,3 +964,31 @@ sudo chown -R 10001:10001 ./auths ./data ./config.json - 允许任意使用、复制、修改、合并、发布、分发、再授权及销售 - 再分发(源码或二进制形式)时,请保留原仓库的 MIT 版权声明与许可声明(如在 NOTICE 或 README 中注明原始出处 `https://github.com/Sliverkiss/workbuddy2api`) - 本项目不授予任何上游(CodeBuddy / 腾讯)接口或服务的权利;使用者仍需自行遵守上游服务条款 + +### 按地区配置账号池调度 + +国内版(`cn`)与国际版(`global`)可独立选择策略,例如: + +```json +{ + "pool": { + "strategy": { + "domestic": "weighted_random", + "international": "sequential_exhaust" + }, + "account_order": { + "international": ["uid1", "uid2", "uid3"] + } + } +} +``` + +- **加权随机**:保留原有成本分层、Top5 加权、闲置补偿和快过期积分权重。旧配置缺少策略时,两域均使用此默认值。 +- **轮询**:在当前最优成本层的全部可用账号之间按顺序轮换,不截断 Top5。跳过禁用、暂停、对应模型冷却、积分保底拦截及在途占满的账号。 +- **顺序耗尽**:持续使用当前账号,直到对应模型冷却、账号不可用、积分保底拦截或成本层优先级变化,再向后切换。恢复的早序账号不会抢占仍可用的当前账号,后续轮转可再次使用。请求级重试和在途占满可暂用其他账号,但不推进主账号;普通失败仍按现有熔断规则处理。 + +两种有序策略仍遵守会话粘性和成本探索,轮询指针 / 顺序耗尽主账号按 `(realm, model)` 独立维护,通过既有本地状态与 Redis 快照机制恢复。全部候选冷却时返回无可用账号,等待既有恢复机制,不提前试探冷却账号;加权随机继续保留原有全冷却兜底。选号和指针更新持池锁,在途租约仍由既有 Acquire/Release 保证。 + +Web 面板「账号池与流量治理」可分别配置策略和账号顺序,保存后热生效。闲置补偿与快过期积分加权仅影响加权随机。无地区上下文的旧 Pool 入口保留原行为;策略不会改变路由或自动跨地区借号。 + +本功能不假定国际版免费模型已采用五小时限额;限流及恢复时间继续依据现有上游识别逻辑处理,不硬编码五小时。 diff --git a/cmd/server/config.go b/cmd/server/config.go index 812e2da1..0cd3ca74 100644 --- a/cmd/server/config.go +++ b/cmd/server/config.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" "github.com/linguo2625469/workbuddy2api-panel/internal/prompt" ) @@ -196,11 +197,13 @@ type Config struct { } `json:"upstash"` Pool struct { - MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 - MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 - BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 - BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" - BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" + Strategy pool.RegionStrategies `json:"strategy"` + AccountOrder pool.RegionAccountOrder `json:"account_order,omitempty"` + MaxInFlight int `json:"max_in_flight"` // 单账号最大在途请求数,0 = 不限 + MaxInFlightGlobal int `json:"max_in_flight_global"` // global 域单账号在途上限(WAF 风控紧域压低并发),0 = 回落默认 2 + BreakerThreshold int `json:"breaker_threshold"` // 连续失败次数触发熔断,默认 3 + BreakerCooldown string `json:"breaker_cooldown"` // 基础熔断时长,默认 "30m" + BreakerCooldownMax string `json:"breaker_cooldown_max"` // 指数退避封顶,默认 "6h" // 连败降权(issue #114):ErrClient/传输层这类「不罚号」失败连续计数,达阈 // 临时出池。与冷却/熔断并存取更长者不叠加。默认 5 次 / 10m。 DegradeThreshold int `json:"degrade_threshold"` // 连败次数触发降权,默认 5 @@ -305,6 +308,7 @@ func Default() *Config { c.Global.Enabled = true c.Features.SanitizeBlacklistFingerprints = true c.Prompt.Mode = "passthrough" // 缺省 passthrough:透传客户端原始 system(对齐上游;custom 由用户显式选择) + c.Pool.Strategy = pool.RegionStrategies{Domestic: pool.WeightedRandom, International: pool.WeightedRandom} c.Pool.MaxInFlight = 3 // MaxInFlightGlobal 缺省 2:global 域 WAF 风控更紧,压低单号并发(WAF 403 修复 // P1-1);0/负数 normalize 回落默认(与 max_in_flight 的 0=不限语义不同,分档键 @@ -485,6 +489,9 @@ func applyEnv(c *Config) { func (c *Config) normalize() error { var err error + if c.Pool.Strategy, err = c.Pool.Strategy.Normalize(); err != nil { + return fmt.Errorf("pool.strategy: %w", err) + } if c.Panel.PackageDetailLimit <= 0 { c.Panel.PackageDetailLimit = 5 } diff --git a/cmd/server/main.go b/cmd/server/main.go index 2885c2c6..a0854968 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -104,6 +104,7 @@ func main() { p.SetWeights(cfg.Pool.IdleWeightPerHour, cfg.Pool.IdleWeightMax) p.SetPreferExpiring(cfg.Pool.PreferExpiring) p.SetCrossDomainFallback(cfg.Pool.CrossDomainFallback) // 裸名跨域回退(issue #135,默认关闭) + _ = p.SetScheduling(cfg.Pool.Strategy, cfg.Pool.AccountOrder) // 会话粘性路由(可配关闭)。 var sessRouter *session.Router @@ -513,6 +514,7 @@ func saveConfig(raw []byte, path string, live *livecfg.Holder, p *pool.Pool, up p.SetWeights(newCfg.Pool.IdleWeightPerHour, newCfg.Pool.IdleWeightMax) p.SetPreferExpiring(newCfg.Pool.PreferExpiring) p.SetCrossDomainFallback(newCfg.Pool.CrossDomainFallback) // 裸名跨域回退热生效(issue #135) + _ = p.SetScheduling(newCfg.Pool.Strategy, newCfg.Pool.AccountOrder) sch.SetExpiringSoonWindow(newCfg.ExpiringSoonDur) sch.Reconfigure( newCfg.Schedule.CheckinHours, newCfg.Schedule.TravelHours, diff --git a/cmd/server/strategy_config_test.go b/cmd/server/strategy_config_test.go new file mode 100644 index 00000000..f8e94520 --- /dev/null +++ b/cmd/server/strategy_config_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "github.com/linguo2625469/workbuddy2api-panel/internal/auth" + "github.com/linguo2625469/workbuddy2api-panel/internal/livecfg" + "github.com/linguo2625469/workbuddy2api-panel/internal/pool" + "github.com/linguo2625469/workbuddy2api-panel/internal/scheduler" + "github.com/linguo2625469/workbuddy2api-panel/internal/upstream" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRegionStrategyConfigCompatibility(t *testing.T) { + for _, raw := range []string{`{}`, `{"pool":{}}`, `{"pool":{"strategy":{"domestic":""}}}`} { + c, err := ParseConfig([]byte(raw)) + if err != nil { + t.Fatal(err) + } + if c.Pool.Strategy.Domestic != pool.WeightedRandom || c.Pool.Strategy.International != pool.WeightedRandom { + t.Fatalf("legacy defaults: %+v", c.Pool.Strategy) + } + } + c, err := ParseConfig([]byte(`{"pool":{"strategy":{"domestic":" ROUND_ROBIN ","international":"sequential_exhaust"},"account_order":{"international":["b","a"]}}}`)) + if err != nil { + t.Fatal(err) + } + if c.Pool.Strategy.Domestic != pool.RoundRobin || c.Pool.Strategy.International != pool.SequentialExhaust || c.Pool.AccountOrder.International[0] != "b" { + t.Fatalf("config=%+v", c.Pool) + } + for _, raw := range []string{`{"pool":{"strategy":{"domestic":"bad"}}}`, `{"pool":{"strategy":{"international":"bad"}}}`, `{"pool":{"strategy":"round_robin"}}`} { + _, err := ParseConfig([]byte(raw)) + if err == nil || !strings.Contains(err.Error(), "strategy") { + t.Fatalf("invalid config err=%v", err) + } + } +} + +func TestSaveRegionSchedulingHotAppliesAndRejectsInvalid(t *testing.T) { + fp := filepath.Join(t.TempDir(), "config.json") + original := []byte(`{"pool":{"strategy":{"international":"sequential_exhaust"}},"custom":"keep"}`) + if err := os.WriteFile(fp, original, 0600); err != nil { + t.Fatal(err) + } + p := pool.New("") + p.Add(&auth.Auth{UID: "a"}) + p.Add(&auth.Auth{UID: "b"}) + live := livecfg.New(livecfg.Snapshot{}) + up := upstream.New() + sch := scheduler.New(scheduler.Config{}) + raw := []byte(`{"pool":{"strategy":{"domestic":"round_robin"},"account_order":{"domestic":["b","a"]}}}`) + restart, err := saveConfig(raw, fp, live, p, up, sch) + if err != nil { + t.Fatal(err) + } + for _, key := range restart { + if strings.HasPrefix(key, "pool.") { + t.Fatalf("strategy requires restart: %s", key) + } + } + for _, uid := range []string{"b", "a", "b"} { + if a := p.PickExcludingForRealm(nil, "m", "cn"); a == nil || a.UID != uid { + t.Fatalf("hot pick=%v want %s", a, uid) + } + } + saved, err := os.ReadFile(fp) + if err != nil { + t.Fatal(err) + } + cfg, err := ParseConfig(saved) + if err != nil { + t.Fatal(err) + } + if cfg.Pool.Strategy.International != pool.SequentialExhaust || !strings.Contains(string(saved), "keep") { + t.Fatalf("merge lost config: %s", saved) + } + _, err = saveConfig([]byte(`{"pool":{"strategy":{"domestic":"bad"}}}`), fp, live, p, up, sch) + if err == nil { + t.Fatal("invalid strategy accepted") + } + after, err := os.ReadFile(fp) + if err != nil { + t.Fatal(err) + } + if string(saved) != string(after) { + t.Fatal("invalid save changed file") + } + if a := p.PickExcludingForRealm(nil, "m", "cn"); a == nil || a.UID != "a" { + t.Fatalf("invalid save changed strategy: %v", a) + } +} diff --git a/config.example.json b/config.example.json index 8c9ff131..367c9f94 100644 --- a/config.example.json +++ b/config.example.json @@ -72,6 +72,14 @@ "token": "" }, "pool": { + "strategy": { + "domestic": "weighted_random", + "international": "weighted_random" + }, + "account_order": { + "domestic": [], + "international": [] + }, "max_in_flight": 3, "max_in_flight_global": 2, "breaker_threshold": 3, diff --git a/internal/livecfg/livecfg.go b/internal/livecfg/livecfg.go index 9d97da3c..ed51bb06 100644 --- a/internal/livecfg/livecfg.go +++ b/internal/livecfg/livecfg.go @@ -16,10 +16,10 @@ import ( // Snapshot 一次读取的不可变配置视图。 type Snapshot struct { - APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 - SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) - SanitizeFingerprints bool // 出站请求体指纹脱敏 - RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) + APIKey string // 网关/面板共同鉴权密钥;空 = 不鉴权 + SoftCooldown time.Duration // 429 软冷却基数(<=0 时调用方回退内置默认) + SanitizeFingerprints bool // 出站请求体指纹脱敏 + RecordClientInfo bool // 请求日志是否记录调用来源(客户端 IP / UA) HiddenModels map[string]struct{} // 精确隐藏的模型 id(已归一化为 "cn:x"/"global:x" 全量形式);nil = 不隐藏 } diff --git a/internal/panel/app.js b/internal/panel/app.js index 530639aa..60a06c86 100644 --- a/internal/panel/app.js +++ b/internal/panel/app.js @@ -1052,6 +1052,10 @@ $('btnLogPin').onclick = () => { /* ── 配置 ─────────────────────────────────────────────────────────── */ const CFG_MAP = { + strategy_domestic: ['pool', 'strategy', 'domestic'], + strategy_international: ['pool', 'strategy', 'international'], + domestic_account_order: ['pool', 'account_order', 'domestic'], + international_account_order: ['pool', 'account_order', 'international'], listen: ['listen'], api_key: ['api_key'], package_detail_limit: ['panel', 'package_detail_limit'], checkin_hours: ['schedule', 'checkin_hours'], checkin_enabled: ['schedule', 'checkin_enabled'], growth_hours: ['schedule', 'growth_hours'], growth_enabled: ['schedule', 'growth_enabled'], @@ -1111,7 +1115,7 @@ async function loadConfig() { const v = dig(cfgLoaded, path); if (el.type === 'checkbox') el.checked = !!v; else if (Array.isArray(v)) el.value = v.join(', '); - else el.value = v == null ? '' : v; + else el.value = v == null ? (name.startsWith('strategy_') ? 'weighted_random' : '') : v; } markDurationFields(); // 回填后重置校验态(清掉残留红框;现值来自后端必然合法) $('cfgNote').textContent = ''; @@ -1128,7 +1132,8 @@ function collectConfig() { else { const raw = el.value.trim(); // 覆盖型字段空串照发(见 CLEARABLE_CFG);其余空 = 不下发。 - if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined; + if (name.endsWith('_account_order')) v = raw.split(/[,,\s]+/).filter(Boolean); + else if (raw === '') v = CLEARABLE_CFG.has(name) ? '' : undefined; else if (name.endsWith('_hours')) v = raw.split(/[,,\s]+/).filter(Boolean).map(Number); else v = raw; } diff --git a/internal/panel/autotask.go b/internal/panel/autotask.go index a89fb196..2b6708a1 100644 --- a/internal/panel/autotask.go +++ b/internal/panel/autotask.go @@ -1244,9 +1244,9 @@ func (p *Panel) runAutoAll(a *auth.Auth) []map[string]any { item["progress_after"] = taskProgressText(after) // 进度达标即自动领奖(mp 任务走 chat 域 mp 口径,其余 Web 端接口)。 // 领奖失败不掩盖主流程结果:status 仍为 done,附加 claim_error 供前端提示。 - if after != nil && after.Claimable { - item["claimable"] = true - credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) + if after != nil && after.Claimable { + item["claimable"] = true + credit, energy, cerr := claimTaskReward(p, a, act.TaskCode) if cerr == nil { item["claimed"] = true item["credit"] = credit diff --git a/internal/panel/compensation_frontend_test.go b/internal/panel/compensation_frontend_test.go index fe3cfe99..520f21a2 100644 --- a/internal/panel/compensation_frontend_test.go +++ b/internal/panel/compensation_frontend_test.go @@ -80,12 +80,12 @@ func TestAppJSCompGateMutationDiscriminates(t *testing.T) { t.Skip("node not installed; compensation mutation test skipped") } mutants := map[string]struct{ from, to string }{ - "drop !r.skip": {"!r.skip && ", ""}, - "drop !r.error": {"!r.error && ", ""}, - "drop r.active": {"r.status.active && ", ""}, - "drop !claimed": {"!r.status.claimed && ", ""}, - "drop credit>0": {"(r.status.credit_num || 0) > 0", "true"}, - "drop whole status":{"r.status &&", "true &&"}, + "drop !r.skip": {"!r.skip && ", ""}, + "drop !r.error": {"!r.error && ", ""}, + "drop r.active": {"r.status.active && ", ""}, + "drop !claimed": {"!r.status.claimed && ", ""}, + "drop credit>0": {"(r.status.credit_num || 0) > 0", "true"}, + "drop whole status": {"r.status &&", "true &&"}, } for name, m := range mutants { t.Run(name, func(t *testing.T) { @@ -185,7 +185,7 @@ process.stdout.write(JSON.stringify({ } got := strings.TrimSpace(string(out)) for _, must := range []string{ - `"text":"剩 6 小时","urgent":true`, // ≤48h 必须标临期 + `"text":"剩 6 小时","urgent":true`, // ≤48h 必须标临期 `"text":"剩 3 天 0 小时","urgent":false`, // 3 天不算临期 `"text":"已截止","urgent":true`, `"text":"无截止信息","urgent":false`, diff --git a/internal/panel/frontend_test.go b/internal/panel/frontend_test.go index e54b25d0..5ab8efc1 100644 --- a/internal/panel/frontend_test.go +++ b/internal/panel/frontend_test.go @@ -1355,3 +1355,47 @@ func TestPanelRemarkAndFoldWiring(t *testing.T) { } } } + +// 用实际表单函数验证地区策略回填、独立提交以及清空账号顺序。 +func TestRegionSchedulingFormRoundTrip(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not available") + } + script := `const fs=require('fs'), vm=require('vm'), assert=require('assert'); + const src=fs.readFileSync(process.argv[2],'utf8'); + const html=fs.readFileSync('index.html','utf8'); + const elements={}; + for(const name of ['strategy_domestic','strategy_international','domestic_account_order','international_account_order']) { + assert(html.includes('name="'+name+'"'), 'missing form field '+name); + elements[name]={type:name.startsWith('strategy_')?'select-one':'text',value:''}; + } + const cfg={pool:{strategy:{domestic:'round_robin',international:'sequential_exhaust'},account_order:{domestic:['c','a'],international:['y','x']}}}; + const nodes={cfgForm:{elements},cfgPath:{},cfgNote:{}}; + const ctx={cfgLoaded:null,api:async()=>({config:cfg}),$:id=>nodes[id],markDurationFields(){},toast(message){throw Error(message)}}; + vm.createContext(ctx); + vm.runInContext(src.slice(src.indexOf('const CFG_MAP ='),src.indexOf('/* Go 时长字段即时校验')),ctx); + (async()=>{ + await ctx.loadConfig(); + assert.equal(elements.strategy_domestic.value,'round_robin'); + assert.equal(elements.strategy_international.value,'sequential_exhaust'); + assert.equal(elements.domestic_account_order.value,'c, a'); + elements.strategy_domestic.value='weighted_random'; + elements.international_account_order.value=''; + const result=ctx.collectConfig(); + assert.equal(result.pool.strategy.domestic,'weighted_random'); + assert.equal(result.pool.strategy.international,'sequential_exhaust'); + assert.equal(JSON.stringify(result.pool.account_order.domestic),'["c","a"]'); + assert.equal(JSON.stringify(result.pool.account_order.international),'[]'); + delete cfg.pool.strategy; await ctx.loadConfig(); + assert.equal(elements.strategy_domestic.value,'weighted_random'); + assert.equal(elements.strategy_international.value,'weighted_random'); + })().catch(e=>{console.error(e);process.exitCode=1});` + fp := filepath.Join(t.TempDir(), "region-form.cjs") + if err = os.WriteFile(fp, []byte(script), 0600); err != nil { + t.Fatal(err) + } + if out, err := exec.Command(node, fp, "app.js").CombinedOutput(); err != nil { + t.Fatalf("form round trip: %v\n%s", err, out) + } +} diff --git a/internal/panel/import.go b/internal/panel/import.go index 902e93ae..dee71103 100644 --- a/internal/panel/import.go +++ b/internal/panel/import.go @@ -15,23 +15,23 @@ import ( // cockpitAccount 映射 cockpit tools 导出格式的单个账号。 type cockpitAccount struct { - ID string `json:"id"` - Email string `json:"email"` - UID string `json:"uid"` - Nickname string `json:"nickname"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - TokenType string `json:"token_type"` - ExpiresAt int64 `json:"expires_at"` - Domain string `json:"domain"` - DosageNotify string `json:"dosage_notify_code"` - PaymentType string `json:"payment_type"` - Status string `json:"status"` - UsageUpdatedAt int64 `json:"usage_updated_at"` - LastCheckin int64 `json:"last_checkin_time"` - CheckinStreak int `json:"checkin_streak"` - CreatedAt int64 `json:"created_at"` - LastUsed int64 `json:"last_used"` + ID string `json:"id"` + Email string `json:"email"` + UID string `json:"uid"` + Nickname string `json:"nickname"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresAt int64 `json:"expires_at"` + Domain string `json:"domain"` + DosageNotify string `json:"dosage_notify_code"` + PaymentType string `json:"payment_type"` + Status string `json:"status"` + UsageUpdatedAt int64 `json:"usage_updated_at"` + LastCheckin int64 `json:"last_checkin_time"` + CheckinStreak int `json:"checkin_streak"` + CreatedAt int64 `json:"created_at"` + LastUsed int64 `json:"last_used"` } // importCockpit 接收 cockpit tools 导出的 JSON 文件,批量导入账号到池中。 diff --git a/internal/panel/index.html b/internal/panel/index.html index fedc36a6..9da35b7c 100644 --- a/internal/panel/index.html +++ b/internal/panel/index.html @@ -1015,6 +1015,27 @@

积分扣除历史

账号池与流量治理

+
+ + +
+
+ + +
+
按地区独立热生效;未列出的账号按 UID 排序追加。指针按地区和模型分别保存。会话粘性、模型成本分层优先;有序策略等待冷却恢复,不提前试探。临时重试或在途占满不推进顺序耗尽的主账号。闲置与快过期积分权重仅用于加权随机。