fix(review): 处理 PR #5666 四个阻断项(B1-B4)

- B1: 移除根目录 docker-compose.yml(个人镜像测试产物混入)
- B2: 新增迁移 224 放宽 user_platform_quotas CHECK 至 8 平台,补
  BulkInsertInitial 国产平台集成测试与迁移内容断言测试
- B3: CC/Responses×anthropic 四个上游读循环接入间隔超时泵
  (gateway.stream_data_interval_timeout,默认 180s):上游挂住 SSE
  不发数据也不断连时结束排水/组装、关闭 resp.Body 归还连接池位,
  排水超时仍按已累计 usage 返回(与 messages 主路径同语义)
- B4: 配额/余额探测发起前过 cnValidateProbeURL(与网关转发同一套
  security.url_allowlist 策略),被拒时零出站、API key 不离开本机
This commit is contained in:
Randark
2026-08-17 04:07:06 +00:00
parent 901a0439f1
commit 4b667ccd45
12 changed files with 795 additions and 354 deletions
@@ -98,6 +98,35 @@ func TestUserPlatformQuotaRepository_BulkInsertInitial_GrokAllowed(t *testing.T)
require.InDelta(t, 9.0, *rec.DailyLimitUSD, 1e-9)
}
// TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed 回归迁移 224:
// kimi/zhipu/deepseek 平台必须能写入 user_platform_quotas(CHECK 约束已含国产供应商)。
// 历史 bug:三个平台不在约束内 → 注册预填充 8 平台默认配额时整条多行 INSERT 中止 →
// fail-open 吞错 → 新用户拿到零条配额记录(缺失配额行 = 无限额)。
func TestUserPlatformQuotaRepository_BulkInsertInitial_CNProvidersAllowed(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
userID := mustCreateUserForQuota(t, client)
repo := NewUserPlatformQuotaRepository(client)
daily := 12.0
records := []UserPlatformQuotaRecord{
{UserID: userID, Platform: "kimi", DailyLimitUSD: &daily},
{UserID: userID, Platform: "zhipu"},
{UserID: userID, Platform: "deepseek"},
}
require.NoError(t, repo.BulkInsertInitial(txCtx, records),
"kimi/zhipu/deepseek 平台应可写入(迁移 224 后 CHECK 约束已含国产供应商)")
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
rec, err := repo.GetByUserPlatform(txCtx, userID, platform)
require.NoError(t, err)
require.NotNil(t, rec, "%s 配额行应已写入", platform)
}
}
func TestUserPlatformQuotaRepository_GetByUserPlatform(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
@@ -125,6 +125,13 @@ func (s *CNProviderBalanceService) queryBalance(ctx context.Context, accountID i
}
targetURL := cnBalanceURL(account)
// 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
// DeepSeek 端点由账号 base_url 衍生,不得把 API key 发往策略外主机。
validatedURL, err := cnValidateProbeURL(s.cfg, targetURL)
if err != nil {
return nil, infraerrors.New(http.StatusForbidden, "CN_BALANCE_URL_REJECTED", err.Error())
}
targetURL = validatedURL
proxyURL := s.resolveProxyURL(ctx, account)
callCtx, cancel := context.WithTimeout(ctx, cnBalanceUpstreamTimeout)
defer cancel()
@@ -0,0 +1,48 @@
package service
// CN 供应商探测端点的出站 URL 安全策略校验(配额/余额探测共用)。
//
// 背景(review B4):这两条探测路径会把账号 API key 发往 base_url 衍生端点,
// 此前完全绕过 security.url_allowlist——在加固部署里构成任意外发与内网探测
// 面(本项目此前发生过账号测试 SSRF 生产事件)。与网关转发
// (validateUpstreamBaseURL)、Grok 探测(grokOperatorPolicyValidator)一致,
// 探测发起前必须过同一套运营者策略。
import (
"errors"
"fmt"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
)
// cnValidateProbeURL 按全局出站 URL 安全策略校验探测端点,返回规范化 URL。
// 白名单开启时强制 UpstreamHosts(阻断私网与未列名主机);关闭时仅做格式
// 校验(HTTP 允许与否跟随配置);cfg 为 nil 时退化为纯格式校验。
func cnValidateProbeURL(cfg *config.Config, raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", errors.New("probe url is required")
}
if cfg != nil && cfg.Security.URLAllowlist.Enabled {
normalized, err := urlvalidator.ValidateHTTPSURL(trimmed, urlvalidator.ValidationOptions{
AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
RequireAllowlist: true,
AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
})
if err != nil {
return "", fmt.Errorf("probe target rejected by URL security policy: %w", err)
}
return normalized, nil
}
var allowInsecureHTTP bool
if cfg != nil {
allowInsecureHTTP = cfg.Security.URLAllowlist.AllowInsecureHTTP
}
normalized, err := urlvalidator.ValidateURLFormat(trimmed, allowInsecureHTTP)
if err != nil {
return "", fmt.Errorf("probe target rejected by URL security policy: %w", err)
}
return normalized, nil
}
@@ -0,0 +1,129 @@
package service
// CN 供应商探测端点 URL 安全策略回归测试(review B4):
// 配额/余额探测不得绕过 security.url_allowlist——base_url 衍生的探测端点
// 必须先过运营者策略,被拒绝时不得发起任何上游请求(API key 不出站)。
import (
"context"
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
func cnProbeAllowlistConfig(hosts ...string) *config.Config {
return &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{
Enabled: true,
UpstreamHosts: hosts,
},
},
}
}
func TestCNValidateProbeURL_AllowlistPolicy(t *testing.T) {
cfg := cnProbeAllowlistConfig("api.moonshot.cn", "api.deepseek.com")
// 白名单内主机放行(保留完整路径)。
ok, err := cnValidateProbeURL(cfg, "https://api.moonshot.cn/v1/users/me/balance")
require.NoError(t, err)
require.Equal(t, "https://api.moonshot.cn/v1/users/me/balance", ok)
// 白名单外主机拒绝。
_, err = cnValidateProbeURL(cfg, "https://relay.attacker.example/v1/usages")
require.Error(t, err)
require.Contains(t, err.Error(), "rejected by URL security policy")
// 私网主机拒绝(内网探测面)。
_, err = cnValidateProbeURL(cfg, "http://169.254.169.254/latest/meta-data")
require.Error(t, err)
// 白名单关闭:仅格式校验,任意 https 主机放行。
formatOnly, err := cnValidateProbeURL(&config.Config{
Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}},
}, "https://relay.attacker.example/v1/usages")
require.NoError(t, err)
require.Equal(t, "https://relay.attacker.example/v1/usages", formatOnly)
}
// recordingHTTPUpstream 断言探测被策略拒绝时没有任何上游请求发出。
type recordingHTTPUpstream struct{ calls int }
func (u *recordingHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
u.calls++
return nil, context.DeadlineExceeded
}
func (u *recordingHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*http.Response, error) {
u.calls++
return nil, context.DeadlineExceeded
}
type fakeCNProbeAccountRepo struct {
AccountRepository
account *Account
}
func (r *fakeCNProbeAccountRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
return r.account, nil
}
// kimi coding 账号的 base_url 指向中转(含 api.kimi.com/coding 路径段即可被识别
// 为 kimi coding plan)→ 衍生额度端点落在中转主机上,白名单未列名必须拒绝。
func TestCNProviderQuotaService_RejectsURLBlockedByPolicy(t *testing.T) {
repo := &fakeCNProbeAccountRepo{account: &Account{
ID: 1, Platform: PlatformKimi, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{
"account_mode": "coding",
"api_key": "sk-test",
"base_url": "https://relay.attacker.example/api.kimi.com/coding",
},
}}
upstream := &recordingHTTPUpstream{}
svc := NewCNProviderQuotaService(repo, nil, upstream, cnProbeAllowlistConfig("api.kimi.com"))
_, err := svc.QueryUsage(context.Background(), 1)
require.Error(t, err)
require.Contains(t, err.Error(), "CN_QUOTA_URL_REJECTED")
require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target")
}
// deepseek payg 账号自定义 base_url → 余额端点落在中转主机上,必须先过策略。
func TestCNProviderBalanceService_RejectsURLBlockedByPolicy(t *testing.T) {
repo := &fakeCNProbeAccountRepo{account: &Account{
ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{
"account_mode": "payg",
"api_key": "sk-test",
"base_url": "https://relay.attacker.example",
},
}}
upstream := &recordingHTTPUpstream{}
svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com"))
_, err := svc.QueryBalance(context.Background(), 2)
require.Error(t, err)
require.Contains(t, err.Error(), "CN_BALANCE_URL_REJECTED")
require.Zero(t, upstream.calls, "probe must not issue any upstream request when URL policy rejects the target")
}
// 白名单包含官方主机的正常路径:URL 校验通过后才发出上游请求(此处允许到达
// httpUpstream 层即视为通过校验,不发真实网络)。
func TestCNProviderBalanceService_OfficialHostPassesValidation(t *testing.T) {
repo := &fakeCNProbeAccountRepo{account: &Account{
ID: 3, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Status: StatusActive,
Credentials: map[string]any{
"account_mode": "payg",
"api_key": "sk-test",
},
}}
upstream := &recordingHTTPUpstream{}
svc := NewCNProviderBalanceService(repo, nil, upstream, cnProbeAllowlistConfig("api.deepseek.com"))
_, _ = svc.QueryBalance(context.Background(), 3)
require.Equal(t, 1, upstream.calls, "official host must pass URL policy and reach the upstream layer")
}
@@ -144,6 +144,14 @@ func (s *CNProviderQuotaService) queryUsage(ctx context.Context, accountID int64
authHeader = apiKey // 智谱额度端点鉴权不加 Bearer 前缀
}
// 探测发起前过出站 URL 安全策略(与网关转发/Grok 探测同一套校验):
// 端点多由账号 base_url 衍生,不得把 API key 发往策略外主机。
validatedURL, err := cnValidateProbeURL(s.cfg, targetURL)
if err != nil {
return nil, infraerrors.New(http.StatusForbidden, "CN_QUOTA_URL_REJECTED", err.Error())
}
targetURL = validatedURL
proxyURL := s.resolveProxyURL(ctx, account)
callCtx, cancel := context.WithTimeout(ctx, cnQuotaUpstreamTimeout)
defer cancel()
@@ -0,0 +1,126 @@
package service
// 国产供应商 Anthropic 协议转换路径的上游 SSE 行泵。
//
// 这两条转换链(CC×anthropic / Responses×anthropic)的上游 ctx 是
// WithoutCancel(detachStreamUpstreamContext)、http.Client 无整体 Timeout,
// 客户端断开后的排水阶段若上游挂住 SSE(不发数据也不断连),
// scanner.Scan() 将永久阻塞:goroutine 钉死、resp.Body 不归还、连接池位
// 被占用、usage 永不落库。
//
// 与 handleAnthropicStreamingResponse / readOpenAICompatBufferedTerminal 的
// 同类排水一致,本泵用 gateway.stream_data_interval_timeout(默认 180s)作为
// 逐行读间隔上限,超时即向调用方返回 errAnthropicNativeStreamIdle,由调用方
// 关闭 resp.Body 解除阻塞的读并结束排水。
import (
"bufio"
"errors"
"io"
"time"
)
// errAnthropicNativeStreamIdle 表示上游流读间隔超时(见上方文件注释)。
var errAnthropicNativeStreamIdle = errors.New("stream data interval timeout")
// anthropicNativeLineEvent 是行泵交付的单次读取结果:line 为一行 SSE 文本,
// err 为 scanner 读错误(流自然结束时 next 返回 io.EOF,不经过本字段)。
type anthropicNativeLineEvent struct {
line string
err error
}
// anthropicNativeLinePump 以独立 goroutine 泵送 scanner 的行,并对逐行到达
// 间隔施加 interval 上限(<=0 表示禁用,保持无界读的旧行为)。
type anthropicNativeLinePump struct {
events chan anthropicNativeLineEvent
done chan struct{}
timer *time.Timer
interval time.Duration
}
// newAnthropicNativeLinePump 启动泵 goroutine;调用方 defer pump.stop()。
func newAnthropicNativeLinePump(scanner *bufio.Scanner, interval time.Duration) *anthropicNativeLinePump {
p := &anthropicNativeLinePump{
events: make(chan anthropicNativeLineEvent, 16),
done: make(chan struct{}),
interval: interval,
}
if interval > 0 {
p.timer = time.NewTimer(interval)
}
go func() {
defer close(p.events)
for scanner.Scan() {
select {
case p.events <- anthropicNativeLineEvent{line: scanner.Text()}:
case <-p.done:
return
}
}
if err := scanner.Err(); err != nil {
select {
case p.events <- anthropicNativeLineEvent{err: err}:
case <-p.done:
}
}
}()
return p
}
// next 阻塞返回下一行。返回 io.EOF 表示上游正常收流;errAnthropicNativeStreamIdle
// 表示 interval 内无任何数据到达(计时从收到上一行时起算,事件处理耗时不算入,
// 与 readOpenAICompatBufferedTerminal 的 resetTimeout 语义一致)。
func (p *anthropicNativeLinePump) next() (string, error) {
var timeoutCh <-chan time.Time
if p.timer != nil {
timeoutCh = p.timer.C
}
select {
case ev, ok := <-p.events:
if !ok {
return "", io.EOF
}
p.resetTimer()
return ev.line, ev.err
case <-timeoutCh:
return "", errAnthropicNativeStreamIdle
}
}
// resetTimer 在收到一行后重启间隔计时器。
func (p *anthropicNativeLinePump) resetTimer() {
if p.timer == nil {
return
}
if !p.timer.Stop() {
select {
case <-p.timer.C:
default:
}
}
p.timer.Reset(p.interval)
}
// stop 终止泵 goroutine。注意:goroutine 若正阻塞在 scanner.Read 上,需由
// 调用方关闭 resp.Body(间隔超时分支已做)才能真正退出。
func (p *anthropicNativeLinePump) stop() {
close(p.done)
if p.timer != nil {
if !p.timer.Stop() {
select {
case <-p.timer.C:
default:
}
}
}
}
// anthropicNativeStreamInterval 返回本组转换路径适用的读间隔上限;
// gateway.stream_data_interval_timeout <= 0 时视为禁用。
func (s *OpenAIGatewayService) anthropicNativeStreamInterval() time.Duration {
if s.cfg != nil && s.cfg.Gateway.StreamDataIntervalTimeout > 0 {
return time.Duration(s.cfg.Gateway.StreamDataIntervalTimeout) * time.Second
}
return 0
}
@@ -0,0 +1,244 @@
package service
// 国产供应商 Anthropic 协议转换路径的上游读间隔超时回归测试(B3):
// 上游挂住 SSE(不发数据也不断连)时,CC×anthropic / Responses×anthropic
// 的读循环必须按 gateway.stream_data_interval_timeout 结束,而不是永久阻塞。
import (
"bufio"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
)
func newNativeAnthropicHangTestService(intervalSec int) *OpenAIGatewayService {
return &OpenAIGatewayService{
cfg: &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: intervalSec,
MaxLineSize: defaultMaxLineSize,
},
},
}
}
func newHangingUpstreamResponse() (*http.Response, *io.PipeReader, *io.PipeWriter) {
pr, pw := io.Pipe()
return &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}, pr, pw
}
// miniAnthropicSSEStream 是一段最小可转换的 Anthropic 事件流。
func miniAnthropicSSEStream() string {
return strings.Join([]string{
"event: message_start",
`data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","content":[],"model":"glm-4.7","usage":{"input_tokens":10,"output_tokens":1}}}`,
"",
"event: content_block_start",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
"",
"event: content_block_delta",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}`,
"",
"event: content_block_stop",
`data: {"type":"content_block_stop","index":0}`,
"",
"event: message_delta",
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}`,
"",
"event: message_stop",
`data: {"type":"message_stop"}`,
"",
"",
}, "\n")
}
func TestAnthropicNativeLinePump_TimesOutWithoutData(t *testing.T) {
pr, _ := io.Pipe()
scanner := bufio.NewScanner(pr)
defer func() { _ = pr.Close() }()
pump := newAnthropicNativeLinePump(scanner, 50*time.Millisecond)
defer pump.stop()
start := time.Now()
_, err := pump.next()
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected interval timeout, got %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("timeout not respected: %v", elapsed)
}
}
func TestAnthropicNativeLinePump_DataResetsTimer(t *testing.T) {
pr, pw := io.Pipe()
scanner := bufio.NewScanner(pr)
pump := newAnthropicNativeLinePump(scanner, 1*time.Second)
defer pump.stop()
go func() {
_, _ = pw.Write([]byte("event: ping\n"))
// 保持流打开且不再发数据:第二次 next 必须超时。
time.Sleep(3 * time.Second)
_ = pw.Close()
}()
defer func() { _ = pr.Close() }()
line, err := pump.next()
if err != nil || line != "event: ping" {
t.Fatalf("expected first line, got %q err=%v", line, err)
}
start := time.Now()
_, err = pump.next()
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected interval timeout after data stops, got %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("timeout not respected: %v", elapsed)
}
}
func TestCCStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(1)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
resp, pr, pw := newHangingUpstreamResponse()
start := time.Now()
res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, true)
_ = pw.Close()
_ = pr.Close()
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected stream timeout error, got %v", err)
}
if res == nil {
t.Fatalf("expected result carrying accumulated usage")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("handler did not respect interval bound: %v", elapsed)
}
}
func TestCCBufferedFromNativeAnthropic_HangTimesOut(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(1)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
resp, pr, pw := newHangingUpstreamResponse()
start := time.Now()
_, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start)
_ = pw.Close()
_ = pr.Close()
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected stream timeout error, got %v", err)
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("handler did not respect interval bound: %v", elapsed)
}
if !strings.Contains(rec.Body.String(), "Upstream stream data interval timeout") {
t.Fatalf("expected 502 error body, got %q", rec.Body.String())
}
}
func TestResponsesStreamingFromNativeAnthropic_HangTimesOut(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(1)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
resp, pr, pw := newHangingUpstreamResponse()
start := time.Now()
res, err := svc.handleResponsesStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, start, apicompat.ResponsesClientToolMapping{})
_ = pw.Close()
_ = pr.Close()
if err == nil || !strings.Contains(err.Error(), "stream data interval timeout") {
t.Fatalf("expected stream timeout error, got %v", err)
}
if res == nil {
t.Fatalf("expected result carrying accumulated usage")
}
if elapsed := time.Since(start); elapsed > 5*time.Second {
t.Fatalf("handler did not respect interval bound: %v", elapsed)
}
}
func TestCCStreamingFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(5)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
resp, pr, pw := newHangingUpstreamResponse()
go func() {
_, _ = pw.Write([]byte(miniAnthropicSSEStream()))
_ = pw.Close()
}()
defer func() { _ = pr.Close() }()
res, err := svc.handleCCStreamingFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now(), true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil {
t.Fatalf("expected result")
}
body := rec.Body.String()
if !strings.Contains(body, "Hello") {
t.Fatalf("expected converted text chunk, got %q", body)
}
if !strings.Contains(body, "data: [DONE]") {
t.Fatalf("expected [DONE] terminator, got %q", body)
}
}
func TestCCBufferedFromNativeAnthropic_HappyPathStillConverts(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newNativeAnthropicHangTestService(5)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
resp, pr, pw := newHangingUpstreamResponse()
go func() {
_, _ = pw.Write([]byte(miniAnthropicSSEStream()))
_ = pw.Close()
}()
defer func() { _ = pr.Close() }()
res, err := svc.handleCCBufferedFromNativeAnthropic(resp, c, "glm-4.7", "glm-4.7", "glm-4.7", nil, time.Now())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res == nil {
t.Fatalf("expected result")
}
body := rec.Body.String()
if !strings.Contains(body, "Hello") {
t.Fatalf("expected converted text in buffered response, got %q", body)
}
if res.Usage.InputTokens != 10 || res.Usage.OutputTokens != 5 {
t.Fatalf("expected usage 10/5, got %+v", res.Usage)
}
}
@@ -14,6 +14,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -163,18 +164,53 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic(
var finalResp *apicompat.AnthropicResponse
var usage ClaudeUsage
for scanner.Scan() {
line := scanner.Text()
// 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。
streamInterval := s.anthropicNativeStreamInterval()
pump := newAnthropicNativeLinePump(scanner, streamInterval)
defer pump.stop()
logReadErr := func(err error) {
if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai cc via native anthropic buffered: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
onIdle := func() (*OpenAIForwardResult, error) {
_ = resp.Body.Close()
logger.L().Warn("openai cc via native anthropic buffered: data interval timeout",
zap.String("request_id", requestID),
zap.Duration("interval", streamInterval),
)
writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout")
return nil, fmt.Errorf("stream data interval timeout")
}
for {
line, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
// SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等 Anthropic 兼容上游
// 返回紧凑格式,严格匹配 "event: " 会丢弃全部事件(#4653 同根因)。
if _, ok := extractOpenAISSEEventLine(line); !ok {
continue
}
if !scanner.Scan() {
dataLine, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
payload, ok := extractOpenAISSEDataLine(scanner.Text())
payload, ok := extractOpenAISSEDataLine(dataLine)
if !ok {
continue
}
@@ -214,15 +250,6 @@ func (s *OpenAIGatewayService) handleCCBufferedFromNativeAnthropic(
}
}
if err := scanner.Err(); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai cc via native anthropic buffered: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
if finalResp == nil {
writeChatCompletionsError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response")
return nil, fmt.Errorf("upstream stream ended without response")
@@ -322,6 +349,34 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic(
}
}
// 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束排水。上游 ctx 为
// WithoutCancel 且 http.Client 无整体 Timeout,无此界限则客户端断开后
// scanner.Scan() 永久阻塞(见 anthropic native pump 文件注释)。
streamInterval := s.anthropicNativeStreamInterval()
pump := newAnthropicNativeLinePump(scanner, streamInterval)
defer pump.stop()
logReadErr := func(err error) {
if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai cc via native anthropic stream: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
// onIdle 关闭上游连接(解除阻塞的读、归还连接池位),并按已累计 usage
// 返回——与 messages 主路径 "stream usage incomplete after timeout" 同语义。
onIdle := func() (*OpenAIForwardResult, error) {
_ = resp.Body.Close()
if !clientDisconnected {
logger.L().Warn("openai cc via native anthropic stream: data interval timeout",
zap.String("request_id", requestID),
zap.Duration("interval", streamInterval),
)
}
return resultWithUsage(), fmt.Errorf("stream data interval timeout")
}
writeChunk := func(chunk apicompat.ChatCompletionsChunk) bool {
if clientDisconnected {
return false // 已断开:不再写客户端,只排水上游累计 usage
@@ -372,16 +427,29 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic(
return false
}
for scanner.Scan() {
line := scanner.Text()
for {
line, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
if _, ok := extractOpenAISSEEventLine(line); !ok {
continue
}
if !scanner.Scan() {
dataLine, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
// EOF / 读错误:事件行后流终止,进入 finalize。
logReadErr(rerr)
break
}
payload, ok := extractOpenAISSEDataLine(scanner.Text())
payload, ok := extractOpenAISSEDataLine(dataLine)
if !ok {
continue
}
@@ -396,15 +464,6 @@ func (s *OpenAIGatewayService) handleCCStreamingFromNativeAnthropic(
}
}
if err := scanner.Err(); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai cc via native anthropic stream: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
// Finalize both state machines(客户端已断开时仍执行,保证 usage 汇总完整)。
finalResEvents := apicompat.FinalizeAnthropicResponsesStream(anthState)
for _, resEvt := range finalResEvents {
@@ -14,6 +14,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@@ -166,17 +167,52 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic(
var finalResp *apicompat.AnthropicResponse
var usage ClaudeUsage
for scanner.Scan() {
line := scanner.Text()
// 读间隔上限:上游挂住 SSE 时中止组装(缓冲路径尚未提交响应头,可回 502)。
streamInterval := s.anthropicNativeStreamInterval()
pump := newAnthropicNativeLinePump(scanner, streamInterval)
defer pump.stop()
logReadErr := func(err error) {
if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai responses via native anthropic buffered: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
onIdle := func() (*OpenAIForwardResult, error) {
_ = resp.Body.Close()
logger.L().Warn("openai responses via native anthropic buffered: data interval timeout",
zap.String("request_id", requestID),
zap.Duration("interval", streamInterval),
)
writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream data interval timeout")
return nil, fmt.Errorf("stream data interval timeout")
}
for {
line, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
// SSE 规范允许 `event:xxx`(冒号后无空格):Kimi 等上游返回紧凑格式。
if _, ok := extractOpenAISSEEventLine(line); !ok {
continue
}
if !scanner.Scan() {
dataLine, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
payload, ok := extractOpenAISSEDataLine(scanner.Text())
payload, ok := extractOpenAISSEDataLine(dataLine)
if !ok {
continue
}
@@ -216,15 +252,6 @@ func (s *OpenAIGatewayService) handleResponsesBufferedFromNativeAnthropic(
}
}
if err := scanner.Err(); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai responses via native anthropic buffered: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
if finalResp == nil {
writeResponsesError(c, http.StatusBadGateway, "server_error", "Upstream stream ended without a response")
return nil, fmt.Errorf("upstream stream ended without response")
@@ -326,6 +353,30 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
}
}
// 读间隔上限:上游挂住 SSE(不发数据也不断连)时结束转换循环。上游 ctx 为
// WithoutCancel 且 http.Client 无整体 Timeout,无此界限则 scanner.Scan()
// 永久阻塞(见 anthropic native pump 文件注释)。
streamInterval := s.anthropicNativeStreamInterval()
pump := newAnthropicNativeLinePump(scanner, streamInterval)
defer pump.stop()
logReadErr := func(err error) {
if !errors.Is(err, io.EOF) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai responses via native anthropic stream: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
onIdle := func() (*OpenAIForwardResult, error) {
_ = resp.Body.Close()
logger.L().Warn("openai responses via native anthropic stream: data interval timeout",
zap.String("request_id", requestID),
zap.Duration("interval", streamInterval),
)
return resultWithUsage(), fmt.Errorf("stream data interval timeout")
}
processAnthropicEvent := func(event *apicompat.AnthropicStreamEvent) bool {
if firstChunk {
firstChunk = false
@@ -365,16 +416,28 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
return false
}
for scanner.Scan() {
line := scanner.Text()
for {
line, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
if _, ok := extractOpenAISSEEventLine(line); !ok {
continue
}
if !scanner.Scan() {
dataLine, rerr := pump.next()
if rerr != nil {
if errors.Is(rerr, errAnthropicNativeStreamIdle) {
return onIdle()
}
logReadErr(rerr)
break
}
payload, ok := extractOpenAISSEDataLine(scanner.Text())
payload, ok := extractOpenAISSEDataLine(dataLine)
if !ok {
continue
}
@@ -389,15 +452,6 @@ func (s *OpenAIGatewayService) handleResponsesStreamingFromNativeAnthropic(
}
}
if err := scanner.Err(); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
logger.L().Warn("openai responses via native anthropic stream: read error",
zap.Error(err),
zap.String("request_id", requestID),
)
}
}
// Finalize state machine(客户端已断开时仍执行,保证 usage 汇总完整)。
if finalEvents := apicompat.FinalizeAnthropicResponsesStream(state); len(finalEvents) > 0 {
for _, evt := range finalEvents {
@@ -0,0 +1,17 @@
-- 把 kimi/zhipu/deepseek 平台加入 user_platform_quotas.platform 的 CHECK 约束。
--
-- 背景:国产供应商进入 AllowedQuotaPlatforms(internal/service/domain_constants.go),
-- 注册时 GetDefaultPlatformQuotas 会为全部 8 平台预填充默认配额行,但 157 号迁移的
-- CHECK 仍只允许 5 平台。BulkInsertInitial 是单条多行 INSERT,任一违约行会中止整条
-- 语句 → 注册路径 fail-open 吞错 → 新用户拿到零条配额记录(含原有 5 平台,缺失配额
-- 行 = 无限额)。与 157 头注释记载的 grok 同型事故一致。
--
-- 修复:把约束与代码平台列表(PlatformKimi/PlatformZhipu/PlatformDeepseek)对齐。
-- DROP ... IF EXISTS 保证可重入;新约束是旧约束的超集,存量行(仅 5 平台)瞬时校验通过。
ALTER TABLE user_platform_quotas
DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check;
ALTER TABLE user_platform_quotas
ADD CONSTRAINT user_platform_quotas_platform_check
CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek'));
@@ -0,0 +1,22 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// TestUserPlatformQuotasCNProvidersMigration 校验 224 号迁移把 kimi/zhipu/deepseek
// 加入 user_platform_quotas.platform 的 CHECK 约束(对照 157 号 grok 迁移)。
// 约束未放宽时,注册预填充 8 平台默认配额会整条 INSERT 中止 → 新用户零配额行
//(缺失配额行 = 无限额),管理端设置国产平台配额直接 500。
func TestUserPlatformQuotasCNProvidersMigration(t *testing.T) {
content, err := FS.ReadFile("224_user_platform_quotas_add_cn_providers.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql, "DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check")
require.Contains(t, sql,
"CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok', 'kimi', 'zhipu', 'deepseek'))")
}