feat(sora): 对齐sora2api分镜角色去水印与挑战错误治理
This commit is contained in:
170
backend/internal/util/soraerror/soraerror.go
Normal file
170
backend/internal/util/soraerror/soraerror.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package soraerror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
cfRayPattern = regexp.MustCompile(`(?i)cf-ray[:\s=]+([a-z0-9-]+)`)
|
||||
cRayPattern = regexp.MustCompile(`(?i)cRay:\s*'([a-z0-9-]+)'`)
|
||||
htmlChallenge = []string{
|
||||
"window._cf_chl_opt",
|
||||
"just a moment",
|
||||
"enable javascript and cookies to continue",
|
||||
"__cf_chl_",
|
||||
"challenge-platform",
|
||||
}
|
||||
)
|
||||
|
||||
// IsCloudflareChallengeResponse reports whether the upstream response matches Cloudflare challenge behavior.
|
||||
func IsCloudflareChallengeResponse(statusCode int, headers http.Header, body []byte) bool {
|
||||
if statusCode != http.StatusForbidden && statusCode != http.StatusTooManyRequests {
|
||||
return false
|
||||
}
|
||||
|
||||
if headers != nil && strings.EqualFold(strings.TrimSpace(headers.Get("cf-mitigated")), "challenge") {
|
||||
return true
|
||||
}
|
||||
|
||||
preview := strings.ToLower(TruncateBody(body, 4096))
|
||||
for _, marker := range htmlChallenge {
|
||||
if strings.Contains(preview, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
contentType := ""
|
||||
if headers != nil {
|
||||
contentType = strings.ToLower(strings.TrimSpace(headers.Get("content-type")))
|
||||
}
|
||||
if strings.Contains(contentType, "text/html") &&
|
||||
(strings.Contains(preview, "<html") || strings.Contains(preview, "<!doctype html")) &&
|
||||
(strings.Contains(preview, "cloudflare") || strings.Contains(preview, "challenge")) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ExtractCloudflareRayID extracts cf-ray from headers or response body.
|
||||
func ExtractCloudflareRayID(headers http.Header, body []byte) string {
|
||||
if headers != nil {
|
||||
rayID := strings.TrimSpace(headers.Get("cf-ray"))
|
||||
if rayID != "" {
|
||||
return rayID
|
||||
}
|
||||
rayID = strings.TrimSpace(headers.Get("Cf-Ray"))
|
||||
if rayID != "" {
|
||||
return rayID
|
||||
}
|
||||
}
|
||||
|
||||
preview := TruncateBody(body, 8192)
|
||||
if matches := cfRayPattern.FindStringSubmatch(preview); len(matches) >= 2 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
if matches := cRayPattern.FindStringSubmatch(preview); len(matches) >= 2 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FormatCloudflareChallengeMessage appends cf-ray info when available.
|
||||
func FormatCloudflareChallengeMessage(base string, headers http.Header, body []byte) string {
|
||||
rayID := ExtractCloudflareRayID(headers, body)
|
||||
if rayID == "" {
|
||||
return base
|
||||
}
|
||||
return fmt.Sprintf("%s (cf-ray: %s)", base, rayID)
|
||||
}
|
||||
|
||||
// ExtractUpstreamErrorCodeAndMessage extracts structured error code/message from common JSON layouts.
|
||||
func ExtractUpstreamErrorCodeAndMessage(body []byte) (string, string) {
|
||||
trimmed := strings.TrimSpace(string(body))
|
||||
if trimmed == "" {
|
||||
return "", ""
|
||||
}
|
||||
if !json.Valid([]byte(trimmed)) {
|
||||
return "", truncateMessage(trimmed, 256)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(trimmed), &payload); err != nil {
|
||||
return "", truncateMessage(trimmed, 256)
|
||||
}
|
||||
|
||||
code := firstNonEmpty(
|
||||
extractNestedString(payload, "error", "code"),
|
||||
extractRootString(payload, "code"),
|
||||
)
|
||||
message := firstNonEmpty(
|
||||
extractNestedString(payload, "error", "message"),
|
||||
extractRootString(payload, "message"),
|
||||
extractNestedString(payload, "error", "detail"),
|
||||
extractRootString(payload, "detail"),
|
||||
)
|
||||
return strings.TrimSpace(code), truncateMessage(strings.TrimSpace(message), 512)
|
||||
}
|
||||
|
||||
// TruncateBody truncates body text for logging/inspection.
|
||||
func TruncateBody(body []byte, max int) string {
|
||||
if max <= 0 {
|
||||
max = 512
|
||||
}
|
||||
raw := strings.TrimSpace(string(body))
|
||||
if len(raw) <= max {
|
||||
return raw
|
||||
}
|
||||
return raw[:max] + "...(truncated)"
|
||||
}
|
||||
|
||||
func truncateMessage(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "...(truncated)"
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractRootString(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func extractNestedString(m map[string]any, parent, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
node, ok := m[parent]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
child, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
s, _ := child[key].(string)
|
||||
return s
|
||||
}
|
||||
47
backend/internal/util/soraerror/soraerror_test.go
Normal file
47
backend/internal/util/soraerror/soraerror_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package soraerror
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsCloudflareChallengeResponse(t *testing.T) {
|
||||
headers := make(http.Header)
|
||||
headers.Set("cf-mitigated", "challenge")
|
||||
require.True(t, IsCloudflareChallengeResponse(http.StatusForbidden, headers, []byte(`{"ok":false}`)))
|
||||
|
||||
require.True(t, IsCloudflareChallengeResponse(http.StatusTooManyRequests, nil, []byte(`<!DOCTYPE html><title>Just a moment...</title><script>window._cf_chl_opt={};</script>`)))
|
||||
require.False(t, IsCloudflareChallengeResponse(http.StatusBadGateway, nil, []byte(`<!DOCTYPE html><title>Just a moment...</title>`)))
|
||||
}
|
||||
|
||||
func TestExtractCloudflareRayID(t *testing.T) {
|
||||
headers := make(http.Header)
|
||||
headers.Set("cf-ray", "9d01b0e9ecc35829-SEA")
|
||||
require.Equal(t, "9d01b0e9ecc35829-SEA", ExtractCloudflareRayID(headers, nil))
|
||||
|
||||
body := []byte(`<script>window._cf_chl_opt={cRay: '9cff2d62d83bb98d'};</script>`)
|
||||
require.Equal(t, "9cff2d62d83bb98d", ExtractCloudflareRayID(nil, body))
|
||||
}
|
||||
|
||||
func TestExtractUpstreamErrorCodeAndMessage(t *testing.T) {
|
||||
code, msg := ExtractUpstreamErrorCodeAndMessage([]byte(`{"error":{"code":"cf_shield_429","message":"rate limited"}}`))
|
||||
require.Equal(t, "cf_shield_429", code)
|
||||
require.Equal(t, "rate limited", msg)
|
||||
|
||||
code, msg = ExtractUpstreamErrorCodeAndMessage([]byte(`{"code":"unsupported_country_code","message":"not available"}`))
|
||||
require.Equal(t, "unsupported_country_code", code)
|
||||
require.Equal(t, "not available", msg)
|
||||
|
||||
code, msg = ExtractUpstreamErrorCodeAndMessage([]byte(`plain text`))
|
||||
require.Equal(t, "", code)
|
||||
require.Equal(t, "plain text", msg)
|
||||
}
|
||||
|
||||
func TestFormatCloudflareChallengeMessage(t *testing.T) {
|
||||
headers := make(http.Header)
|
||||
headers.Set("cf-ray", "9d03b68c086027a1-SEA")
|
||||
msg := FormatCloudflareChallengeMessage("blocked", headers, nil)
|
||||
require.Equal(t, "blocked (cf-ray: 9d03b68c086027a1-SEA)", msg)
|
||||
}
|
||||
Reference in New Issue
Block a user