fix: 修复 P1 功能正确性问题

This commit is contained in:
martin committed 2026-07-26 11:03:22 +08:00
1 parent 6665b1e113
commit 0f87322f49
13 files changed
+282 -35

No files matched your search

@@ -33,6 +33,10 @@ func NewResponseCaptureWriter(w http.ResponseWriter) *ResponseCaptureWriter {
// WriteHeader 捕获状态码并委托给底层 writer
func (w *ResponseCaptureWriter) WriteHeader(code int) {
w.mu.Lock()
if w.status != 0 {
w.mu.Unlock()
return
}
w.status = code
w.mu.Unlock()
w.ResponseWriter.WriteHeader(code)
@@ -40,6 +44,12 @@ func (w *ResponseCaptureWriter) WriteHeader(code int) {
// Write 写入客户端并同时捕获到 buffer(head + tail 环形缓冲区)
func (w *ResponseCaptureWriter) Write(b []byte) (int, error) {
w.mu.Lock()
if w.status == 0 {
w.status = http.StatusOK
}
w.mu.Unlock()
n, err := w.ResponseWriter.Write(b)
w.bytesWritten.Add(int64(n))
w.mu.Lock()
@@ -119,6 +129,13 @@ func (w *ResponseCaptureWriter) BytesWritten() int64 {
return w.bytesWritten.Load()
}
// ResponseCommitted reports whether headers or body bytes have already been sent.
func (w *ResponseCaptureWriter) ResponseCommitted() bool {
w.mu.Lock()
defer w.mu.Unlock()
return w.status != 0 || w.bytesWritten.Load() > 0
}
// ResponseHeaders 返回响应头快照(调用时从底层 ResponseWriter 提取)
func (w *ResponseCaptureWriter) ResponseHeaders() map[string]string {
headers := make(map[string]string)
@@ -0,0 +1,48 @@
package relay
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestResponseCaptureWriterCommitted(t *testing.T) {
t.Run("fresh", func(t *testing.T) {
capture := NewResponseCaptureWriter(httptest.NewRecorder())
if capture.ResponseCommitted() {
t.Fatal("fresh writer should not be committed")
}
})
t.Run("header", func(t *testing.T) {
capture := NewResponseCaptureWriter(httptest.NewRecorder())
capture.WriteHeader(http.StatusAccepted)
if !capture.ResponseCommitted() {
t.Fatal("writer should be committed after WriteHeader")
}
if got := capture.StatusCode(); got != http.StatusAccepted {
t.Fatalf("status = %d, want %d", got, http.StatusAccepted)
}
})
t.Run("body", func(t *testing.T) {
capture := NewResponseCaptureWriter(httptest.NewRecorder())
_, _ = capture.Write([]byte("partial"))
if !capture.ResponseCommitted() {
t.Fatal("writer should be committed after Write")
}
capture.WriteHeader(http.StatusInternalServerError)
if got := capture.StatusCode(); got != http.StatusOK {
t.Fatalf("status = %d, want implicit status %d", got, http.StatusOK)
}
})
}
func TestResponseCaptureWriterKeepsFirstStatus(t *testing.T) {
capture := NewResponseCaptureWriter(httptest.NewRecorder())
capture.WriteHeader(http.StatusAccepted)
capture.WriteHeader(http.StatusInternalServerError)
if got := capture.StatusCode(); got != http.StatusAccepted {
t.Fatalf("status = %d, want first status %d", got, http.StatusAccepted)
}
}
+3 -3
View File
@@ -409,7 +409,7 @@ type CostBreakdown struct {
func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName string, inputTokens, requestedMaxTokens int, isStream bool) (float64, error) {
pricing, err := GetModelPrice(ctx, tenantID, modelName)
if err != nil {
return 0.01, nil
return 0, gerror.Wrapf(err, "estimate pre-deduct: get model price")
}
// 按次计费:直接用单价
@@ -430,7 +430,7 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
Fields("max_output_tokens").
Scan(&model)
if err != nil {
return 0.01, nil
return 0, gerror.Wrapf(err, "estimate pre-deduct: query model output limit")
}
maxOutput := 4096
@@ -448,7 +448,7 @@ func EstimatePreDeductAmount(ctx context.Context, tenantID int64, modelName stri
breakdown, err := CalculateCost(ctx, tenantID, modelName, inputTokens, estimatedOutput)
if err != nil {
return 0.01, nil
return 0, gerror.Wrapf(err, "estimate pre-deduct: calculate cost")
}
if breakdown.TotalCost > 1.0 {
+33 -11
View File
@@ -3,12 +3,15 @@ package common
import (
"context"
"encoding/json"
"math"
"math/rand/v2"
"time"
"github.com/gogf/gf/v2/database/gredis"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
"golang.org/x/sync/singleflight"
)
// Cache provides a two-level caching mechanism:
@@ -20,6 +23,19 @@ import (
type Cache struct {
prefix string
ttl time.Duration
group singleflight.Group
}
func jitterTTL(ttl time.Duration) time.Duration {
if ttl <= 0 {
return ttl
}
spread := ttl / 10
if spread <= 0 {
return ttl
}
delta := time.Duration(rand.Int64N(int64(spread)*2+1)) - spread
return ttl + delta
}
// NewCache creates a new Cache instance with the given prefix and default TTL.
@@ -43,13 +59,14 @@ func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Dura
if len(ttl) > 0 && ttl[0] > 0 {
expire = ttl[0]
}
expire = jitterTTL(expire)
fullKey := c.fullKey(key)
// L1: memory cache (native Go struct storage)
gcache.Set(ctx, fullKey, value, expire)
// L2: Redis cache (JSON serialized)
ttlSeconds := int64(expire.Seconds())
ttlSeconds := int64(math.Ceil(expire.Seconds()))
jsonBytes, err := json.Marshal(value)
if err != nil {
g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err)
@@ -83,11 +100,11 @@ func (c *Cache) Get(ctx context.Context, key string) (any, bool) {
// Unmarshal JSON to get the original Go value
var raw any
if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr == nil {
gcache.Set(ctx, fullKey, raw, c.ttl)
gcache.Set(ctx, fullKey, raw, jitterTTL(c.ttl))
return raw, true
}
// Fallback: treat as plain string (backward compat)
gcache.Set(ctx, fullKey, jsonStr, c.ttl)
gcache.Set(ctx, fullKey, jsonStr, jitterTTL(c.ttl))
return jsonStr, true
}
}
@@ -119,7 +136,7 @@ func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
return false
}
// Backfill L1
gcache.Set(ctx, fullKey, target, c.ttl)
gcache.Set(ctx, fullKey, target, jitterTTL(c.ttl))
return true
}
}
@@ -133,13 +150,18 @@ func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Co
return val, nil
}
val, err := fn(ctx)
if err != nil {
return nil, err
}
c.Set(ctx, key, val)
return val, nil
val, err, _ := c.group.Do(c.fullKey(key), func() (any, error) {
if cached, ok := c.Get(ctx, key); ok {
return cached, nil
}
loaded, loadErr := fn(ctx)
if loadErr != nil {
return nil, loadErr
}
c.Set(ctx, key, loaded)
return loaded, nil
})
return val, err
}
// Delete removes a value from both L1 and L2.
+21
View File
@@ -0,0 +1,21 @@
package common
import (
"testing"
"time"
)
func TestJitterTTLRange(t *testing.T) {
base := 10 * time.Minute
minTTL := base - base/10
maxTTL := base + base/10
for range 1000 {
got := jitterTTL(base)
if got < minTTL || got > maxTTL {
t.Fatalf("jitterTTL(%v) = %v, want range [%v, %v]", base, got, minTTL, maxTTL)
}
}
if got := jitterTTL(0); got != 0 {
t.Fatalf("jitterTTL(0) = %v, want 0", got)
}
}
+11 -2
View File
@@ -139,10 +139,12 @@ func VerifyCode(ctx context.Context, email, code, purpose string) error {
UsedAt *time.Time `json:"used_at"`
}
err := dao.SysEmailVerifyCodes.Ctx(ctx).
baseQuery := dao.SysEmailVerifyCodes.Ctx(ctx).
Where("email", email).
Where("purpose", purpose).
Where("used_at IS NULL").
Where("used_at IS NULL")
err := baseQuery.
Where("expires_at > NOW()").
OrderDesc("created_at").
Limit(1).
Scan(&record)
@@ -151,6 +153,13 @@ func VerifyCode(ctx context.Context, email, code, purpose string) error {
}
if record == nil {
expiredCount, countErr := baseQuery.Where("expires_at <= NOW()").Count()
if countErr != nil {
return gerror.Wrapf(countErr, "query expired verify code")
}
if expiredCount > 0 {
return NewBusinessError(consts.CodeEmailVerifyExpired, consts.MsgEmailVerifyExpired)
}
incrVerifyFail(ctx, failKey)
return NewBusinessError(consts.CodeVerifyCodeInvalid, "验证码错误")
}
+19 -11
View File
@@ -551,22 +551,25 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (
pageSize = 20
}
m := dao.BilUsageLogs.Ctx(ctx).Where("tenant_id", tenantID)
m = m.Where("created_at >= ?", req.StartDate+" 00:00:00")
m = m.Where("created_at <= ?", req.EndDate+" 23:59:59")
m := dao.BilUsageLogs.Ctx(ctx).
As("u").
Where("u.tenant_id", tenantID).
Where("u.created_at >= ?", req.StartDate+" 00:00:00").
Where("u.created_at <= ?", req.EndDate+" 23:59:59")
selectFields := ""
groupBy := ""
switch req.GroupBy {
case "model":
selectFields = "model_name, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost"
groupBy = "model_name"
selectFields = "u.model_name, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "u.model_name"
case "key":
selectFields = "key_prefix as key_name, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost"
groupBy = "key_prefix"
m = m.LeftJoin("api_keys", "ak", "u.api_key_id = ak.id AND u.tenant_id = ak.tenant_id")
selectFields = "COALESCE(ak.key_prefix, '') as key_name, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "COALESCE(ak.key_prefix, '')"
default:
selectFields = "DATE(created_at) as date, COUNT(*) as request_count, SUM(input_tokens) as prompt_tokens, SUM(output_tokens) as completion_tokens, SUM(input_tokens+output_tokens) as total_tokens, SUM(actual_cost) as cost"
groupBy = "DATE(created_at)"
selectFields = "DATE(u.created_at) as date, COUNT(*) as request_count, SUM(u.input_tokens) as prompt_tokens, SUM(u.output_tokens) as completion_tokens, SUM(u.input_tokens+u.output_tokens) as total_tokens, SUM(u.actual_cost) as cost"
groupBy = "DATE(u.created_at)"
}
type usageRow struct {
@@ -580,8 +583,13 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (
Cost float64 `json:"cost"`
}
total, err := m.Group(groupBy).Count()
if err != nil {
return nil, err
}
var rows []usageRow
err := m.Fields(selectFields).Group(groupBy).OrderDesc(groupBy).Page(page, pageSize).Scan(&rows)
err = m.Fields(selectFields).Group(groupBy).OrderDesc(groupBy).Page(page, pageSize).Scan(&rows)
if err != nil {
return nil, err
}
@@ -606,7 +614,7 @@ func (s *sOpen) OpenUsageQuery(ctx context.Context, req *v1.OpenUsageQueryReq) (
items = append(items, item)
}
return &v1.OpenUsageQueryRes{List: items, Total: len(items), Page: page, PageSize: pageSize}, nil
return &v1.OpenUsageQueryRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
}
// ============================================================
+50 -8
View File
@@ -7,6 +7,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -25,6 +26,37 @@ import (
// modelCache 模型信息缓存(TTL 600s)
var modelCache = lcommon.NewCache("model", 600*time.Second)
const channelKeyLastUsedInterval = time.Minute
var channelKeyUsage = newChannelKeyUsageTracker()
type channelKeyUsageTracker struct {
mu sync.Mutex
touched map[int64]time.Time
}
func newChannelKeyUsageTracker() *channelKeyUsageTracker {
return &channelKeyUsageTracker{touched: make(map[int64]time.Time)}
}
func (t *channelKeyUsageTracker) claim(keyID int64, now time.Time) bool {
t.mu.Lock()
defer t.mu.Unlock()
if last, ok := t.touched[keyID]; ok && now.Sub(last) < channelKeyLastUsedInterval {
return false
}
t.touched[keyID] = now
return true
}
func (t *channelKeyUsageTracker) release(keyID int64, claimedAt time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
if last, ok := t.touched[keyID]; ok && last.Equal(claimedAt) {
delete(t.touched, keyID)
}
}
// tenantGroupModelCache 租户分组模型集合缓存(通过 lcommon.TenantGroupModelCache 共享)
// 实际缓存实例在 lcommon 包中,admin 和 relay 共用
@@ -1050,19 +1082,12 @@ func getChannelKey(ctx context.Context, channelID int64) (string, error) {
return "", common.ErrChannelUnavailable
}
// 更新最后使用时间(用于监控)
if _, err := dao.ChnChannelKeys.Ctx(ctx).
Where("id", key.ID).
Data(do.ChnChannelKeys{LastUsedAt: gtime.Now()}).
Update(); err != nil {
g.Log().Debugf(ctx, "[DataProvider] update channel key LastUsedAt failed: keyID=%d, err=%v", key.ID, err)
}
encKey := GetEncryptionKey()
decrypted, err := uc.DecryptString(encKey, key.EncryptedKey)
if err != nil {
return "", err
}
touchChannelKeyLastUsed(ctx, key.ID)
// OAuth 按需刷新
if key.KeyType == "oauth" && loauth.IsOAuthKeyData(decrypted) {
@@ -1083,6 +1108,23 @@ func getChannelKey(ctx context.Context, channelID int64) (string, error) {
return decrypted, nil
}
func touchChannelKeyLastUsed(ctx context.Context, keyID int64) {
now := time.Now()
if !channelKeyUsage.claim(keyID, now) {
return
}
_, err := dao.ChnChannelKeys.Ctx(ctx).
Where("id", keyID).
Where("last_used_at IS NULL OR last_used_at < ?", now.Add(-channelKeyLastUsedInterval)).
Data(do.ChnChannelKeys{LastUsedAt: gtime.New(now)}).
Update()
if err != nil {
channelKeyUsage.release(keyID, now)
g.Log().Debugf(ctx, "[DataProvider] update channel key LastUsedAt failed: keyID=%d, err=%v", keyID, err)
}
}
// refreshOAuthKey 刷新 OAuth 令牌并更新数据库
func refreshOAuthKey(ctx context.Context, keyID int64, oauthData *loauth.OAuthKeyData, encKey []byte) (string, error) {
var newToken *loauth.OAuthKeyData
+27
View File
@@ -4,11 +4,38 @@ import (
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"github.com/qianfree/team-api/relay/common"
)
func TestChannelKeyUsageTracker(t *testing.T) {
tracker := newChannelKeyUsageTracker()
now := time.Now()
if !tracker.claim(1, now) {
t.Fatal("first claim should update")
}
if tracker.claim(1, now.Add(channelKeyLastUsedInterval-time.Second)) {
t.Fatal("claim inside interval should be throttled")
}
if !tracker.claim(1, now.Add(channelKeyLastUsedInterval)) {
t.Fatal("claim at interval boundary should update")
}
}
func TestChannelKeyUsageTrackerRelease(t *testing.T) {
tracker := newChannelKeyUsageTracker()
now := time.Now()
if !tracker.claim(1, now) {
t.Fatal("first claim should update")
}
tracker.release(1, now)
if !tracker.claim(1, now.Add(time.Second)) {
t.Fatal("released claim should be immediately retryable")
}
}
func TestSafeUTF8Truncate(t *testing.T) {
tests := []struct {
name string
+12
View File
@@ -144,6 +144,9 @@ func WriteRelayError(w http.ResponseWriter, err error) {
g.Log().Infof(context.Background(), "[RelayError] Client disconnected during stream")
return
}
if responseCommitted(w) {
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
@@ -197,3 +200,12 @@ func WriteRelayError(w http.ResponseWriter, err error) {
})
_, _ = w.Write(errBody)
}
type responseCommitter interface {
ResponseCommitted() bool
}
func responseCommitted(w http.ResponseWriter) bool {
committer, ok := w.(responseCommitter)
return ok && committer.ResponseCommitted()
}
+3
View File
@@ -24,6 +24,9 @@ func WriteClaudeRelayError(w http.ResponseWriter, err error) {
g.Log().Infof(context.Background(), "[ClaudeRelayError] Client disconnected during stream")
return
}
if responseCommitted(w) {
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
+34
View File
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
@@ -8,6 +9,14 @@ import (
"github.com/qianfree/team-api/relay/constant"
)
type committedTestWriter struct {
*httptest.ResponseRecorder
}
func (w *committedTestWriter) ResponseCommitted() bool {
return true
}
// TestErrorWriters_SkipResponseWritten 验证当 adaptor 已置 ResponseWritten=true 时,
// 三个错误写入器都跳过二次写入(不写 header、不写 body)。
// 这是 Gemini/OpenAI「双重写入」修复的核心机制兜底。
@@ -65,3 +74,28 @@ func TestErrorWriters_WriteNormalError(t *testing.T) {
})
}
}
func TestErrorWriters_SkipCommittedResponse(t *testing.T) {
cases := []struct {
name string
fn func(http.ResponseWriter, error)
}{
{"WriteRelayError", WriteRelayError},
{"WriteClaudeRelayError", WriteClaudeRelayError},
{"WriteGeminiRelayError", WriteGeminiRelayError},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
_, _ = recorder.WriteString("data: partial\n\n")
writer := &committedTestWriter{ResponseRecorder: recorder}
before := recorder.Body.String()
c.fn(writer, errors.New("upstream stream failed"))
if got := recorder.Body.String(); got != before {
t.Fatalf("committed body changed: got %q, want %q", got, before)
}
})
}
}
+4
View File
@@ -61,6 +61,10 @@ func HandleGeminiModelDetail(ctx context.Context, tenantID int64, modelName stri
// WriteGeminiRelayError 写入 Gemini 格式的错误响应
func WriteGeminiRelayError(w http.ResponseWriter, err error) {
if responseCommitted(w) {
return
}
// adaptor 已直接写入响应体(如 Gemini 原生格式透传),跳过二次写入
var prewritten *constant.RelayError
if errors.As(err, &prewritten) && prewritten.ResponseWritten {