Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c52b8b29b | |||
| 0ff9c35e62 | |||
| 0bbcaa8999 | |||
| 1e9ff8a0de | |||
| 9a2e60dff2 | |||
| b596de739d | |||
| 45d54c1613 | |||
| 086044650d | |||
| 0c7aceb831 | |||
| b2e25b7df2 | |||
| 230a3592f8 | |||
| afb470e405 | |||
| 1588027084 | |||
| 38bf2d8daa | |||
| e8c836d705 | |||
| e79cee1e9e | |||
| 63ead2bf7f | |||
| 5b86ce0d70 | |||
| 74985fa877 | |||
| 1d32037364 | |||
| dc245ae764 | |||
| f8add4ca49 | |||
| 65f8afe922 | |||
| 5bc4c74813 | |||
| 30025aeba3 | |||
| c91ba0c4eb | |||
| f223db9330 | |||
| 9e283ab10b | |||
| a8b7c92e5f | |||
| 6b6c9904ac | |||
| 1011934987 | |||
| bc8110ce36 | |||
| ad224ecf5b | |||
| a64f26d1d2 | |||
| 3360882642 | |||
| b37b6d80b3 | |||
| 3d850d38b6 | |||
| 349d5429ca | |||
| 465c5edab9 | |||
| ff06067a18 | |||
| 51ca897cf4 | |||
| 1288028181 | |||
| 2a528d46cb | |||
| 583da45296 | |||
| b302be30e3 | |||
| 88437a1869 | |||
| b08febaa3c | |||
| 92a0959448 | |||
| 49bc3a1175 | |||
| 0354c38bef | |||
| ebbe315533 | |||
| fddf54ccc5 | |||
| b9bc6f0e21 | |||
| f2c7647ecf | |||
| 19f1821fc8 | |||
| 8e5e89bb5b | |||
| e13d673454 | |||
| ae6a03364d | |||
| 006e801652 | |||
| 6f11d19877 | |||
| 58ba867dd6 |
@@ -33,16 +33,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -91,16 +93,18 @@ jobs:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -146,16 +150,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
|
||||
@@ -35,3 +35,4 @@ data/
|
||||
.test
|
||||
token_estimator_test.go
|
||||
skills-lock.json
|
||||
.playwright-mcp
|
||||
|
||||
+18
-16
@@ -1,22 +1,24 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/default/package.json .
|
||||
COPY web/default/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/default .
|
||||
COPY ./VERSION .
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/default ./default
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/classic/package.json .
|
||||
COPY web/classic/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/classic .
|
||||
COPY ./VERSION .
|
||||
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/classic ./classic
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /build/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
|
||||
@@ -37,7 +37,7 @@ func checkWriter(writer io.Writer) stringWriter {
|
||||
// W3C Working Draft 29 October 2009
|
||||
// http://www.w3.org/TR/2009/WD-eventsource-20091029/
|
||||
|
||||
var contentType = []string{"text/event-stream"}
|
||||
var writeContentType = []string{"text/event-stream"}
|
||||
var noCache = []string{"no-cache"}
|
||||
|
||||
var fieldReplacer = strings.NewReplacer(
|
||||
@@ -79,7 +79,7 @@ func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||
r.Mutex.Lock()
|
||||
defer r.Mutex.Unlock()
|
||||
header := w.Header()
|
||||
header["Content-Type"] = contentType
|
||||
header["Content-Type"] = writeContentType
|
||||
|
||||
if _, exist := header["Cache-Control"]; !exist {
|
||||
header["Cache-Control"] = noCache
|
||||
|
||||
+19
-1
@@ -110,11 +110,29 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
// disk-backed JSON: stream-decode directly from the file to avoid
|
||||
// materializing the entire payload back into a transient []byte
|
||||
// (diskStorage.Bytes() would ReadFull the whole file into the heap).
|
||||
if storage.IsDisk() && strings.HasPrefix(contentType, "application/json") {
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
if err := DecodeJson(storage, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -20,6 +21,16 @@ var (
|
||||
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
||||
)
|
||||
|
||||
const LocalLogContentLimit = 2048
|
||||
|
||||
// LocalLogPreview limits log-only content unless debug logging is enabled.
|
||||
func LocalLogPreview(content string) string {
|
||||
if DebugEnabled || len(content) <= LocalLogContentLimit {
|
||||
return content
|
||||
}
|
||||
return fmt.Sprintf("%s... [truncated, original_length=%d, limit=%d]", content[:LocalLogContentLimit], len(content), LocalLogContentLimit)
|
||||
}
|
||||
|
||||
func GetStringIfEmpty(str string, defaultValue string) string {
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
|
||||
@@ -57,7 +57,24 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
|
||||
return normalized
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string, isStream bool) testResult {
|
||||
func resolveChannelTestUserID(c *gin.Context) (int, error) {
|
||||
if c != nil {
|
||||
if userID := c.GetInt("id"); userID > 0 {
|
||||
return userID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var rootUser model.User
|
||||
if err := model.DB.Select("id").Where("role = ?", common.RoleRootUser).First(&rootUser).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to resolve channel test user: %w", err)
|
||||
}
|
||||
if rootUser.Id == 0 {
|
||||
return 0, errors.New("failed to resolve channel test user")
|
||||
}
|
||||
return rootUser.Id, nil
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
|
||||
tik := time.Now()
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
@@ -143,7 +160,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
cache, err := model.GetUserCache(1)
|
||||
cache, err := model.GetUserCache(testUserID)
|
||||
if err != nil {
|
||||
return testResult{
|
||||
localErr: err,
|
||||
@@ -151,13 +168,13 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
}
|
||||
}
|
||||
cache.WriteContext(c)
|
||||
c.Set("id", 1)
|
||||
c.Set("id", testUserID)
|
||||
|
||||
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("channel", channel.Type)
|
||||
c.Set("base_url", channel.GetBaseURL())
|
||||
group, _ := model.GetUserGroup(1, false)
|
||||
group, _ := model.GetUserGroup(testUserID, false)
|
||||
c.Set("group", group)
|
||||
|
||||
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
|
||||
@@ -484,7 +501,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
other := buildTestLogOther(c, info, priceData, usage, tieredResult)
|
||||
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
|
||||
model.RecordConsumeLog(c, testUserID, model.RecordConsumeLogParams{
|
||||
ChannelId: channel.Id,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
@@ -834,8 +851,13 @@ func TestChannel(c *gin.Context) {
|
||||
testModel := c.Query("model")
|
||||
endpointType := c.Query("endpoint_type")
|
||||
isStream, _ := strconv.ParseBool(c.Query("stream"))
|
||||
testUserID, err := resolveChannelTestUserID(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testModel, endpointType, isStream)
|
||||
result := testChannel(channel, testUserID, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
@@ -872,6 +894,10 @@ var testAllChannelsLock sync.Mutex
|
||||
var testAllChannelsRunning bool = false
|
||||
|
||||
func testAllChannels(notify bool) error {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
testAllChannelsLock.Lock()
|
||||
if testAllChannelsRunning {
|
||||
@@ -902,7 +928,7 @@ func testAllChannels(notify bool) error {
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
result := testChannel(channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
|
||||
|
||||
@@ -1218,7 +1218,7 @@ func CopyChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// insert
|
||||
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
|
||||
if err := clone.Insert(); err != nil {
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
return
|
||||
|
||||
@@ -69,3 +69,14 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
|
||||
require.Equal(t, "base", other["matched_tier"])
|
||||
require.NotEmpty(t, other["expr_b64"])
|
||||
}
|
||||
|
||||
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("id", 2)
|
||||
|
||||
userID, err := resolveChannelTestUserID(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, userID)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ func GetStatus(c *gin.Context) {
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_login_enabled": common.PasswordLoginEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
|
||||
+120
-43
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -109,9 +110,102 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func ListModels(c *gin.Context, modelType int) {
|
||||
userOpenAiModels := make([]dto.OpenAIModels, 0)
|
||||
func channelOwnerName(channelType int) string {
|
||||
apiType, success := common.ChannelType2APIType(channelType)
|
||||
if !success {
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
adaptor := relay.GetAdaptor(apiType)
|
||||
if adaptor == nil {
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: channelType,
|
||||
}})
|
||||
if name := strings.TrimSpace(adaptor.GetChannelName()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
|
||||
func getPreferredModelOwners(modelNames []string, groups []string) map[string]string {
|
||||
channelTypes, err := model.GetPreferredModelOwnerChannelTypes(modelNames, groups)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("GetPreferredModelOwnerChannelTypes error: %v", err))
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
ownerByChannelType := make(map[int]string)
|
||||
owners := make(map[string]string, len(channelTypes))
|
||||
for modelName, channelType := range channelTypes {
|
||||
owner, ok := ownerByChannelType[channelType]
|
||||
if !ok {
|
||||
owner = channelOwnerName(channelType)
|
||||
ownerByChannelType[channelType] = owner
|
||||
}
|
||||
if owner != "" {
|
||||
owners[modelName] = owner
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func buildOpenAIModel(modelName string, ownerByModel map[string]string) dto.OpenAIModels {
|
||||
var oaiModel dto.OpenAIModels
|
||||
if staticModel, ok := openAIModelsMap[modelName]; ok {
|
||||
oaiModel = staticModel
|
||||
} else {
|
||||
oaiModel = dto.OpenAIModels{
|
||||
Id: modelName,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
}
|
||||
}
|
||||
if owner, ok := ownerByModel[modelName]; ok && owner != "" {
|
||||
oaiModel.OwnedBy = owner
|
||||
}
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
|
||||
return oaiModel
|
||||
}
|
||||
|
||||
type modelListGroups struct {
|
||||
userGroup string
|
||||
tokenGroup string
|
||||
ownerGroups []string
|
||||
}
|
||||
|
||||
func getModelListGroups(c *gin.Context) (modelListGroups, error) {
|
||||
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") {
|
||||
var err error
|
||||
userGroup, err = model.GetUserGroup(c.GetInt("id"), false)
|
||||
if err != nil {
|
||||
return modelListGroups{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if tokenGroup == "auto" {
|
||||
return modelListGroups{
|
||||
userGroup: userGroup,
|
||||
tokenGroup: tokenGroup,
|
||||
ownerGroups: service.GetUserAutoGroup(userGroup),
|
||||
}, nil
|
||||
}
|
||||
|
||||
group := userGroup
|
||||
if tokenGroup != "" {
|
||||
group = tokenGroup
|
||||
}
|
||||
return modelListGroups{
|
||||
userGroup: userGroup,
|
||||
tokenGroup: tokenGroup,
|
||||
ownerGroups: []string{group},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ListModels(c *gin.Context, modelType int) {
|
||||
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
|
||||
if !acceptUnsetRatioModel {
|
||||
userId := c.GetInt("id")
|
||||
@@ -123,6 +217,16 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
}
|
||||
|
||||
userModelNames := make([]string, 0)
|
||||
groups, err := getModelListGroups(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "get user group failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
ownerGroups := groups.ownerGroups
|
||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||
if modelLimitEnable {
|
||||
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
|
||||
@@ -138,37 +242,12 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[allowModel]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
} else {
|
||||
userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{
|
||||
Id: allowModel,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
SupportedEndpointTypes: model.GetModelSupportEndpointTypes(allowModel),
|
||||
})
|
||||
}
|
||||
userModelNames = append(userModelNames, allowModel)
|
||||
}
|
||||
} else {
|
||||
userId := c.GetInt("id")
|
||||
userGroup, err := model.GetUserGroup(userId, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "get user group failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
group := userGroup
|
||||
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
|
||||
if tokenGroup != "" {
|
||||
group = tokenGroup
|
||||
}
|
||||
var models []string
|
||||
if tokenGroup == "auto" {
|
||||
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
|
||||
if groups.tokenGroup == "auto" {
|
||||
for _, autoGroup := range ownerGroups {
|
||||
groupModels := model.GetGroupEnabledModels(autoGroup)
|
||||
for _, g := range groupModels {
|
||||
if !common.StringsContains(models, g) {
|
||||
@@ -177,7 +256,7 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
models = model.GetGroupEnabledModels(group)
|
||||
models = model.GetGroupEnabledModels(ownerGroups[0])
|
||||
}
|
||||
for _, modelName := range models {
|
||||
if !acceptUnsetRatioModel {
|
||||
@@ -185,21 +264,19 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[modelName]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
} else {
|
||||
userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{
|
||||
Id: modelName,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
SupportedEndpointTypes: model.GetModelSupportEndpointTypes(modelName),
|
||||
})
|
||||
}
|
||||
userModelNames = append(userModelNames, modelName)
|
||||
}
|
||||
}
|
||||
|
||||
ownerByModel := map[string]string{}
|
||||
if len(ownerGroups) > 0 {
|
||||
ownerByModel = getPreferredModelOwners(userModelNames, ownerGroups)
|
||||
}
|
||||
userOpenAiModels := make([]dto.OpenAIModels, 0, len(userModelNames))
|
||||
for _, modelName := range userModelNames {
|
||||
userOpenAiModels = append(userOpenAiModels, buildOpenAIModel(modelName, ownerByModel))
|
||||
}
|
||||
|
||||
switch modelType {
|
||||
case constant.ChannelTypeAnthropic:
|
||||
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelOwnerNameUsesAdaptorChannelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
channelType int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openai",
|
||||
channelType: constant.ChannelTypeOpenAI,
|
||||
expected: "openai",
|
||||
},
|
||||
{
|
||||
name: "codex",
|
||||
channelType: constant.ChannelTypeCodex,
|
||||
expected: "codex",
|
||||
},
|
||||
{
|
||||
name: "openrouter",
|
||||
channelType: constant.ChannelTypeOpenRouter,
|
||||
expected: "openrouter",
|
||||
},
|
||||
{
|
||||
name: "azure fallback",
|
||||
channelType: constant.ChannelTypeAzure,
|
||||
expected: "azure",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.expected, channelOwnerName(tt.channelType))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIModelOverridesOwnedBy(t *testing.T) {
|
||||
modelItem := buildOpenAIModel("gpt-5.4", map[string]string{"gpt-5.4": "openai"})
|
||||
require.Equal(t, "gpt-5.4", modelItem.Id)
|
||||
require.Equal(t, "openai", modelItem.OwnedBy)
|
||||
}
|
||||
|
||||
func TestBuildOpenAIModelFallsBackToCustomForUnknownModels(t *testing.T) {
|
||||
modelItem := buildOpenAIModel("custom-test-model", nil)
|
||||
require.Equal(t, "custom-test-model", modelItem.Id)
|
||||
require.Equal(t, "custom", modelItem.OwnedBy)
|
||||
}
|
||||
|
||||
func TestGetModelListGroupsUsesUserGroupWhenTokenGroupIsEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
|
||||
groups, err := getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "default", groups.userGroup)
|
||||
require.Empty(t, groups.tokenGroup)
|
||||
require.Equal(t, []string{"default"}, groups.ownerGroups)
|
||||
}
|
||||
|
||||
func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "vip")
|
||||
|
||||
groups, err := getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "default", groups.userGroup)
|
||||
require.Equal(t, "vip", groups.tokenGroup)
|
||||
require.Equal(t, []string{"vip"}, groups.ownerGroups)
|
||||
}
|
||||
+1
-10
@@ -42,15 +42,6 @@ func isPositiveOptionValue(value string) bool {
|
||||
return err == nil && floatValue > 0
|
||||
}
|
||||
|
||||
func isVisiblePublicKeyOption(key string) bool {
|
||||
switch key {
|
||||
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return
|
||||
@@ -95,7 +86,7 @@ func GetOptions(c *gin.Context) {
|
||||
strings.HasSuffix(k, "Key") ||
|
||||
strings.HasSuffix(k, "secret") ||
|
||||
strings.HasSuffix(k, "api_key")
|
||||
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
|
||||
if isSensitiveKey {
|
||||
continue
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
|
||||
@@ -77,24 +77,15 @@ func isWaffoPancakeTopUpEnabled() bool {
|
||||
if !isPaymentComplianceConfirmed() {
|
||||
return false
|
||||
}
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return isWaffoPancakeWebhookConfigured() &&
|
||||
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
// Presence-of-credentials = enabled. Webhook public keys ship inside
|
||||
// the SDK; mode (test/prod) is read from each event.
|
||||
return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookConfigured() bool {
|
||||
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
|
||||
}
|
||||
|
||||
return currentWebhookKey != ""
|
||||
return isWaffoPancakeTopUpEnabled()
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookEnabled() bool {
|
||||
|
||||
@@ -114,47 +114,32 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
|
||||
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
confirmPaymentComplianceForTest(t)
|
||||
originalEnabled := setting.WaffoPancakeEnabled
|
||||
originalSandbox := setting.WaffoPancakeSandbox
|
||||
originalMerchantID := setting.WaffoPancakeMerchantID
|
||||
originalPrivateKey := setting.WaffoPancakePrivateKey
|
||||
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
|
||||
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
|
||||
originalStoreID := setting.WaffoPancakeStoreID
|
||||
originalProductID := setting.WaffoPancakeProductID
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoPancakeEnabled = originalEnabled
|
||||
setting.WaffoPancakeSandbox = originalSandbox
|
||||
setting.WaffoPancakeMerchantID = originalMerchantID
|
||||
setting.WaffoPancakePrivateKey = originalPrivateKey
|
||||
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
|
||||
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
|
||||
setting.WaffoPancakeStoreID = originalStoreID
|
||||
setting.WaffoPancakeProductID = originalProductID
|
||||
})
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = false
|
||||
setting.WaffoPancakeMerchantID = "merchant"
|
||||
// Presence of all three credentials enables the gateway. Webhook public
|
||||
// keys are bundled in the SDK and there is no separate Enabled toggle —
|
||||
// clear any of the three fields to disable.
|
||||
setting.WaffoPancakeMerchantID = ""
|
||||
setting.WaffoPancakePrivateKey = "private"
|
||||
setting.WaffoPancakeStoreID = "store"
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
setting.WaffoPancakeWebhookPublicKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookPublicKey = "public"
|
||||
setting.WaffoPancakeMerchantID = "merchant"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = false
|
||||
setting.WaffoPancakeProductID = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = true
|
||||
setting.WaffoPancakeWebhookTestKey = ""
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
setting.WaffoPancakePrivateKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookTestKey = "test_public"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
@@ -354,7 +354,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
}
|
||||
|
||||
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(err) && channelError.AutoBan {
|
||||
|
||||
@@ -22,6 +22,10 @@ type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
type SubscriptionBalancePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
@@ -37,6 +41,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -92,6 +97,25 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
func SubscriptionRequestBalancePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
var req SubscriptionBalancePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.PurchaseSubscriptionWithBalance(userId, req.PlanId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
@@ -102,6 +126,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -140,6 +165,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.AllowBalancePay == nil {
|
||||
req.Plan.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
@@ -248,6 +276,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"waffo_pancake_product_id": req.Plan.WaffoPancakeProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
@@ -255,6 +284,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if req.Plan.AllowBalancePay != nil {
|
||||
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionWaffoPancakePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
if !requirePaymentCompliance(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var req SubscriptionWaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
|
||||
return
|
||||
}
|
||||
// Plan targets its own Pancake product, so we only require credentials
|
||||
// here — not the gateway-level WaffoPancakeProductID.
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" {
|
||||
common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook
|
||||
// dispatch in WaffoPancakeWebhook.
|
||||
tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6))
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresInSeconds := 45 * 60
|
||||
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
||||
ProductID: plan.WaffoPancakeProductId,
|
||||
BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id),
|
||||
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
||||
Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2),
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
order.Status = common.TopUpStatusFailed
|
||||
_ = order.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"token": session.Token,
|
||||
"token_expires_at": session.TokenExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
+21
-20
@@ -52,6 +52,27 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Waffo Pancake displayed above the legacy Waffo gateway.
|
||||
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
|
||||
if enableWaffoPancake {
|
||||
hasWaffoPancake := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == model.PaymentMethodWaffoPancake {
|
||||
hasWaffoPancake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaffoPancake {
|
||||
payMethods = append(payMethods, map[string]string{
|
||||
"name": "Waffo Pancake",
|
||||
"type": model.PaymentMethodWaffoPancake,
|
||||
"color": "rgba(var(--semi-orange-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了 Waffo 支付,添加到支付方法列表
|
||||
enableWaffo := isWaffoTopUpEnabled()
|
||||
if enableWaffo {
|
||||
@@ -74,26 +95,6 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
|
||||
if enableWaffoPancake {
|
||||
hasWaffoPancake := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == model.PaymentMethodWaffoPancake {
|
||||
hasWaffoPancake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaffoPancake {
|
||||
payMethods = append(payMethods, map[string]string{
|
||||
"name": "Waffo Pancake",
|
||||
"type": model.PaymentMethodWaffoPancake,
|
||||
"color": "rgba(var(--semi-orange-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
|
||||
@@ -96,33 +96,257 @@ func getWaffoPancakeBuyerEmail(user *model.User) string {
|
||||
if user != nil && strings.TrimSpace(user.Email) != "" {
|
||||
return user.Email
|
||||
}
|
||||
if user != nil {
|
||||
return fmt.Sprintf("%d@new-api.local", user.Id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getWaffoPancakeReturnURL() string {
|
||||
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
|
||||
return setting.WaffoPancakeReturnURL
|
||||
// The admin config endpoints below accept typed-but-not-yet-saved creds in
|
||||
// the body and fall back to persisted creds when the body is blank (see
|
||||
// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap.
|
||||
|
||||
type waffoPancakeCredsRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type saveWaffoPancakeRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
StoreID string `json:"store_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
type createWaffoPancakePairRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
}
|
||||
|
||||
// SaveWaffoPancake atomically persists all five operator-controlled fields.
|
||||
// Catalog / pair endpoints are transient — only this one writes the OptionMap.
|
||||
func SaveWaffoPancake(c *gin.Context) {
|
||||
var req saveWaffoPancakeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
return paymentReturnPath("/console/topup?show_history=true")
|
||||
if err := service.SaveWaffoPancakeConfig(
|
||||
c.Request.Context(),
|
||||
req.MerchantID,
|
||||
req.PrivateKey,
|
||||
req.ReturnURL,
|
||||
req.StoreID,
|
||||
req.ProductID,
|
||||
); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
|
||||
req.StoreID, req.ProductID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"product_id": setting.WaffoPancakeProductID,
|
||||
"store_id": setting.WaffoPancakeStoreID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved
|
||||
// values, for verification) and falls back to persisted creds when the body
|
||||
// is blank (so returning admins don't have to re-paste the private key,
|
||||
// which is stripped from GET /api/option/).
|
||||
func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) {
|
||||
m := strings.TrimSpace(bodyMerchantID)
|
||||
k := strings.TrimSpace(bodyPrivateKey)
|
||||
if m == "" && k == "" {
|
||||
return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey
|
||||
}
|
||||
return m, k
|
||||
}
|
||||
|
||||
// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round-
|
||||
// trip. Surfaces an orphan-store flag when the product half fails so the
|
||||
// frontend can preselect / retry without losing context.
|
||||
func CreateWaffoPancakePair(c *gin.Context) {
|
||||
var req createWaffoPancakePairRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
return
|
||||
}
|
||||
result, err := service.CreateWaffoPancakePrimaryPair(
|
||||
c.Request.Context(), merchantID, privateKey, req.ReturnURL,
|
||||
)
|
||||
if err != nil {
|
||||
orphan := result != nil && result.OrphanStore
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
|
||||
orphan, func() string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
return result.StoreID
|
||||
}(), err.Error(),
|
||||
))
|
||||
data := gin.H{"error": err.Error()}
|
||||
if orphan {
|
||||
data["store_id"] = result.StoreID
|
||||
data["store_name"] = result.StoreName
|
||||
data["orphan_store"] = true
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"store_id": result.StoreID,
|
||||
"store_name": result.StoreName,
|
||||
"product_id": result.ProductID,
|
||||
"product_name": result.ProductName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts.
|
||||
// Doubles as a credential probe (a successful 200 proves the resolved creds
|
||||
// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution.
|
||||
func ListWaffoPancakeCatalog(c *gin.Context) {
|
||||
var req waffoPancakeCredsRequest
|
||||
// An empty body means "use persisted creds"; only fail on malformed JSON.
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
|
||||
}
|
||||
|
||||
type createWaffoPancakeSubscriptionProductRequest struct {
|
||||
Name string `json:"name"`
|
||||
Amount string `json:"amount"`
|
||||
}
|
||||
|
||||
// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not
|
||||
// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan)
|
||||
// sized to a plan's `name` + `amount`, using persisted Pancake credentials
|
||||
// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved
|
||||
// plans can mint a product too.
|
||||
func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
|
||||
var req createWaffoPancakeSubscriptionProductRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Amount) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"})
|
||||
return
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
return
|
||||
}
|
||||
productID, err := service.CreateWaffoPancakeProductForPlan(
|
||||
c.Request.Context(),
|
||||
merchantID,
|
||||
privateKey,
|
||||
storeID,
|
||||
req.Name,
|
||||
req.Amount,
|
||||
setting.WaffoPancakeReturnURL,
|
||||
)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
|
||||
storeID, req.Name, req.Amount, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"product_id": productID,
|
||||
"product_name": req.Name,
|
||||
"store_id": storeID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts
|
||||
// in the saved Pancake store, for the subscription-plan dropdown. The name
|
||||
// reflects new-api's plan concept; under the hood it's still OnetimeProducts.
|
||||
func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"})
|
||||
return
|
||||
}
|
||||
products := []service.WaffoPancakeCatalogProduct{}
|
||||
for _, store := range catalog.Stores {
|
||||
if store.ID == storeID {
|
||||
products = store.OnetimeProducts
|
||||
break
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"store_id": storeID,
|
||||
"products": products,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func getWaffoPancakeBuyerIdentity(user *model.User) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
return service.WaffoPancakeBuyerIdentityFromUserID(user.Id)
|
||||
}
|
||||
|
||||
func RequestWaffoPancakePay(c *gin.Context) {
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
|
||||
return
|
||||
}
|
||||
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
|
||||
}
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
|
||||
strings.TrimSpace(currentWebhookKey) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
|
||||
if !isWaffoPancakeTopUpEnabled() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
|
||||
return
|
||||
}
|
||||
@@ -175,18 +399,15 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
expiresInSeconds := 45 * 60
|
||||
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
||||
StoreID: setting.WaffoPancakeStoreID,
|
||||
ProductID: setting.WaffoPancakeProductID,
|
||||
ProductType: "onetime",
|
||||
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
|
||||
ProductID: setting.WaffoPancakeProductID,
|
||||
BuyerIdentity: getWaffoPancakeBuyerIdentity(user),
|
||||
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
||||
Amount: formatWaffoPancakeAmount(payMoney),
|
||||
TaxIncluded: false,
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
SuccessURL: getWaffoPancakeReturnURL(),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
@@ -200,10 +421,12 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"token": session.Token,
|
||||
"token_expires_at": session.TokenExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -215,6 +438,19 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// :env splits test vs prod traffic at the routing layer — operator
|
||||
// registers each URL in the matching webhook slot in Pancake's dashboard.
|
||||
// We then enforce event.mode == expectedEnv to catch mis-registrations.
|
||||
expectedEnv := strings.TrimSpace(c.Param("env"))
|
||||
if expectedEnv != "test" && expectedEnv != "prod" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
|
||||
expectedEnv, c.Request.RequestURI, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusNotFound, "unknown env")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
@@ -232,15 +468,57 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
if event.NormalizedEventType() != "order.completed" {
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no;
|
||||
// OrderID is Pancake's internal ORD_* (logs only).
|
||||
rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-")
|
||||
|
||||
if isSubscription {
|
||||
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
LockOrder(tradeNo)
|
||||
defer UnlockOrder(tradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusInternalServerError, "retry")
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
|
||||
// LogError (not LogWarn): covers order-not-found and buyer-identity
|
||||
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
|
||||
// retry a permanently-unresolvable webhook.
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
+13
-1
@@ -251,8 +251,20 @@ func GetAllUsers(c *gin.Context) {
|
||||
func SearchUsers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
var role *int
|
||||
if roleStr := c.Query("role"); roleStr != "" {
|
||||
if parsed, err := strconv.Atoi(roleStr); err == nil {
|
||||
role = &parsed
|
||||
}
|
||||
}
|
||||
var status *int
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
if parsed, err := strconv.Atoi(statusStr); err == nil {
|
||||
status = &parsed
|
||||
}
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -60,6 +60,8 @@ require (
|
||||
gorm.io/gorm v1.25.2
|
||||
)
|
||||
|
||||
require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1
|
||||
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
|
||||
@@ -308,6 +308,12 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
|
||||
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_FRONTEND_DEFAULT_PORT ?= 5173
|
||||
DEV_FRONTEND_CLASSIC_PORT ?= 5174
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
@@ -14,11 +16,13 @@ all: build-all-frontends start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building default frontend..."
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-frontend-classic:
|
||||
@echo "Building classic frontend..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-all-frontends: build-frontend build-frontend-classic
|
||||
|
||||
@@ -35,12 +39,35 @@ dev-api-rebuild:
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||
@echo "Starting both frontend dev servers..."
|
||||
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
|
||||
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
|
||||
@cd ./web && bun install
|
||||
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
|
||||
default_pid=$$!; \
|
||||
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
|
||||
classic_pid=$$!; \
|
||||
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
|
||||
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if ! kill -0 $$default_pid 2>/dev/null; then \
|
||||
wait $$default_pid; \
|
||||
status=$$?; \
|
||||
kill $$classic_pid 2>/dev/null; \
|
||||
wait $$classic_pid 2>/dev/null; \
|
||||
exit $$status; \
|
||||
fi; \
|
||||
wait $$classic_pid; \
|
||||
status=$$?; \
|
||||
kill $$default_pid 2>/dev/null; \
|
||||
wait $$default_pid 2>/dev/null; \
|
||||
exit $$status
|
||||
|
||||
dev-web-classic:
|
||||
@echo "Starting classic frontend dev server..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
@cd ./web && bun install
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type ModelRequest struct {
|
||||
@@ -170,6 +172,14 @@ func Distribute() func(c *gin.Context) {
|
||||
// - application/x-www-form-urlencoded
|
||||
// - multipart/form-data
|
||||
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") {
|
||||
modelRequest, err := getModelFromJSONBody(c)
|
||||
if err != nil {
|
||||
return nil, errors.New(i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||
}
|
||||
return modelRequest, nil
|
||||
}
|
||||
|
||||
var modelRequest ModelRequest
|
||||
err := common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
if err != nil {
|
||||
@@ -178,6 +188,50 @@ func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
return &modelRequest, nil
|
||||
}
|
||||
|
||||
func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !gjson.ValidBytes(requestBody) {
|
||||
return nil, errors.New("invalid JSON request body")
|
||||
}
|
||||
|
||||
values := gjson.GetManyBytes(requestBody, "model", "group")
|
||||
model, err := getJSONStringValue(values[0], "model")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, err := getJSONStringValue(values[1], "group")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return nil, seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
|
||||
return &ModelRequest{
|
||||
Model: model,
|
||||
Group: group,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getJSONStringValue(result gjson.Result, field string) (string, error) {
|
||||
if !result.Exists() || result.Type == gjson.Null {
|
||||
return "", nil
|
||||
}
|
||||
if result.Type != gjson.String {
|
||||
return "", fmt.Errorf("field %s must be a string", field)
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
var modelRequest ModelRequest
|
||||
shouldSelectChannel := true
|
||||
|
||||
+33
-2
@@ -643,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
if len(keys) == 0 {
|
||||
channel.Status = status
|
||||
} else {
|
||||
var keyIndex int
|
||||
keyIndex := -1
|
||||
for i, key := range keys {
|
||||
if key == usingKey {
|
||||
keyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyIndex < 0 {
|
||||
if usingKey != "" {
|
||||
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
|
||||
return
|
||||
}
|
||||
channel.Status = status
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = reason
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.MultiKeyStatusList == nil {
|
||||
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||
}
|
||||
@@ -666,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
|
||||
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
|
||||
}
|
||||
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
|
||||
channel.Status = common.ChannelStatusAutoDisabled
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = "All keys are disabled"
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
} else if status == common.ChannelStatusEnabled {
|
||||
channel.Status = common.ChannelStatusEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
|
||||
for i := range keys {
|
||||
if statusList == nil {
|
||||
return true
|
||||
}
|
||||
status, ok := statusList[i]
|
||||
if !ok || status == common.ChannelStatusEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
|
||||
if common.MemoryCacheEnabled {
|
||||
channelStatusLock.Lock()
|
||||
@@ -687,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
beforeStatus := channelCache.Status
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channelCache.Status {
|
||||
CacheUpdateChannelStatus(channelId, channelCache.Status)
|
||||
}
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
|
||||
+65
-49
@@ -17,25 +17,39 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
|
||||
if value == "" {
|
||||
return tx, nil
|
||||
}
|
||||
if strings.Contains(value, "%") {
|
||||
pattern, err := sanitizeLikePattern(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern), nil
|
||||
}
|
||||
return tx.Where(column+" = ?", value), nil
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
@@ -146,7 +160,7 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
|
||||
|
||||
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
|
||||
isStream bool, group string, other map[string]interface{}) {
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
@@ -309,9 +323,15 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.username", username)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
@@ -334,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -392,8 +412,12 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
tx = applyLogContainsFilter(tx, "logs.model_name", modelName)
|
||||
tx = applyLogContainsFilter(tx, "logs.token_name", tokenName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
}
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
@@ -430,42 +454,34 @@ type Stat struct {
|
||||
Tpm int `json:"tpm"`
|
||||
}
|
||||
|
||||
func logContainsPattern(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_")
|
||||
return "%" + replacer.Replace(input) + "%", true
|
||||
}
|
||||
|
||||
func applyLogContainsFilter(tx *gorm.DB, column string, value string) *gorm.DB {
|
||||
pattern, ok := logContainsPattern(value)
|
||||
if !ok {
|
||||
return tx
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern)
|
||||
}
|
||||
|
||||
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
|
||||
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
|
||||
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
tx = applyLogContainsFilter(tx, "username", username)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "username", username)
|
||||
tx = applyLogContainsFilter(tx, "token_name", tokenName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "token_name", tokenName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("token_name = ?", tokenName)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("created_at >= ?", startTimestamp)
|
||||
}
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
tx = applyLogContainsFilter(tx, "model_name", modelName)
|
||||
rpmTpmQuery = applyLogContainsFilter(rpmTpmQuery, "model_name", modelName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("channel_id = ?", channel)
|
||||
|
||||
@@ -397,8 +397,10 @@ func ensureSubscriptionPlanTableSQLite() error {
|
||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
|
||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
|
||||
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
|
||||
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
|
||||
@@ -430,8 +432,10 @@ PRIMARY KEY (` + "`id`" + `)
|
||||
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
||||
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
|
||||
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
|
||||
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
|
||||
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
|
||||
@@ -135,6 +136,62 @@ func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeLookupValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func GetPreferredModelOwnerChannelTypes(modelNames []string, groups []string) (map[string]int, error) {
|
||||
result := make(map[string]int)
|
||||
modelNames = normalizeLookupValues(modelNames)
|
||||
if len(modelNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Model string
|
||||
ChannelType int
|
||||
}
|
||||
var rows []row
|
||||
|
||||
query := DB.Table("abilities").
|
||||
Select("abilities.model as model, channels.type as channel_type").
|
||||
Joins("JOIN channels ON abilities.channel_id = channels.id").
|
||||
Where("abilities.model IN ? AND abilities.enabled = ? AND channels.status = ?", modelNames, true, common.ChannelStatusEnabled).
|
||||
Order("COALESCE(abilities.priority, 0) DESC").
|
||||
Order("abilities.weight DESC").
|
||||
Order("abilities.channel_id ASC")
|
||||
|
||||
groups = normalizeLookupValues(groups)
|
||||
if len(groups) > 0 {
|
||||
query = query.Where("abilities."+commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
if _, ok := result[r.Model]; ok {
|
||||
continue
|
||||
}
|
||||
result[r.Model] = r.ChannelType
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
|
||||
var models []*Model
|
||||
db := DB.Model(&Model{})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func clearPreferredOwnerTables(t *testing.T) {
|
||||
t.Helper()
|
||||
require.NoError(t, DB.Exec("DELETE FROM abilities").Error)
|
||||
require.NoError(t, DB.Exec("DELETE FROM channels").Error)
|
||||
}
|
||||
|
||||
func insertPreferredOwnerCandidate(
|
||||
t *testing.T,
|
||||
channelID int,
|
||||
modelName string,
|
||||
group string,
|
||||
channelType int,
|
||||
priority int64,
|
||||
weight uint,
|
||||
channelStatus int,
|
||||
abilityEnabled bool,
|
||||
) {
|
||||
t.Helper()
|
||||
require.NoError(t, DB.Create(&Channel{
|
||||
Id: channelID,
|
||||
Type: channelType,
|
||||
Key: fmt.Sprintf("key-%d", channelID),
|
||||
Status: channelStatus,
|
||||
Name: fmt.Sprintf("channel-%d", channelID),
|
||||
}).Error)
|
||||
require.NoError(t, DB.Create(&Ability{
|
||||
Group: group,
|
||||
Model: modelName,
|
||||
ChannelId: channelID,
|
||||
Enabled: abilityEnabled,
|
||||
Priority: &priority,
|
||||
Weight: weight,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
func TestGetPreferredModelOwnerChannelTypes(t *testing.T) {
|
||||
const modelName = "gpt-5.4"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
groups []string
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
name: "openai only",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 0, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "codex only",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 0, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "priority wins",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 100, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 2, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "weight wins when priority is equal",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 20, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "channel id stabilizes exact ties",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 10, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "group filter excludes other groups",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "vip", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "disabled candidates are ignored",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, false)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusManuallyDisabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearPreferredOwnerTables(t)
|
||||
tt.setup(t)
|
||||
|
||||
owners, err := GetPreferredModelOwnerChannelTypes([]string{modelName}, tt.groups)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := owners[modelName]
|
||||
require.Equal(t, tt.found, ok)
|
||||
if tt.found {
|
||||
require.Equal(t, tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+38
-19
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/performance_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
@@ -106,18 +107,13 @@ func InitOptionMap() {
|
||||
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
|
||||
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
|
||||
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
|
||||
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
|
||||
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
|
||||
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
|
||||
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
|
||||
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
|
||||
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
|
||||
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
|
||||
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
|
||||
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
|
||||
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
|
||||
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
|
||||
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
|
||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||
@@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error {
|
||||
return updateOptionMap(key, value)
|
||||
}
|
||||
|
||||
// UpdateOptionsBulk persists multiple key/value pairs in a single database
|
||||
// transaction, then dispatches them through updateOptionMap in one pass. If
|
||||
// any DB write fails the whole transaction rolls back and no in-memory state
|
||||
// is touched — safe for callers that must commit a set of related options
|
||||
// atomically (e.g. payment gateway binding).
|
||||
func UpdateOptionsBulk(values map[string]string) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range values {
|
||||
option := Option{Key: k}
|
||||
if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
option.Value = v
|
||||
if err := tx.Save(&option).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range values {
|
||||
if err := updateOptionMap(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateOptionMap(key string, value string) (err error) {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
defer common.OptionMapRWMutex.Unlock()
|
||||
@@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoMinTopUp":
|
||||
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
|
||||
case "WaffoPancakeEnabled":
|
||||
setting.WaffoPancakeEnabled = value == "true"
|
||||
case "WaffoPancakeSandbox":
|
||||
setting.WaffoPancakeSandbox = value == "true"
|
||||
case "WaffoPancakeMerchantID":
|
||||
setting.WaffoPancakeMerchantID = value
|
||||
case "WaffoPancakePrivateKey":
|
||||
setting.WaffoPancakePrivateKey = value
|
||||
case "WaffoPancakeWebhookPublicKey":
|
||||
setting.WaffoPancakeWebhookPublicKey = value
|
||||
case "WaffoPancakeWebhookTestKey":
|
||||
setting.WaffoPancakeWebhookTestKey = value
|
||||
case "WaffoPancakeReturnURL":
|
||||
setting.WaffoPancakeReturnURL = value
|
||||
case "WaffoPancakeStoreID":
|
||||
setting.WaffoPancakeStoreID = value
|
||||
case "WaffoPancakeProductID":
|
||||
setting.WaffoPancakeProductID = value
|
||||
case "WaffoPancakeReturnURL":
|
||||
setting.WaffoPancakeReturnURL = value
|
||||
case "WaffoPancakeCurrency":
|
||||
setting.WaffoPancakeCurrency = value
|
||||
case "WaffoPancakeUnitPrice":
|
||||
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoPancakeMinTopUp":
|
||||
|
||||
+117
-2
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/samber/hot"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -159,8 +160,11 @@ type SubscriptionPlan struct {
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
|
||||
|
||||
// Max purchases per user (0 = unlimited)
|
||||
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||
@@ -191,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SubscriptionPlan) NormalizeDefaults() {
|
||||
if p.AllowBalancePay == nil {
|
||||
p.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||
type SubscriptionOrder struct {
|
||||
Id int `json:"id"`
|
||||
@@ -358,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
key := subscriptionPlanCacheKey(id)
|
||||
if key != "" {
|
||||
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
||||
cached.NormalizeDefaults()
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
@@ -369,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.NormalizeDefaults()
|
||||
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
||||
return &plan, nil
|
||||
}
|
||||
@@ -664,6 +676,109 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
|
||||
if priceAmount <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if common.QuotaPerUnit <= 0 {
|
||||
return 0, errors.New("额度单位配置错误")
|
||||
}
|
||||
quota := decimal.NewFromFloat(priceAmount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Ceil().
|
||||
IntPart()
|
||||
return int(quota), nil
|
||||
}
|
||||
|
||||
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
|
||||
func PurchaseSubscriptionWithBalance(userId int, planId int) error {
|
||||
if userId <= 0 || planId <= 0 {
|
||||
return errors.New("invalid userId or planId")
|
||||
}
|
||||
|
||||
var logPlanTitle string
|
||||
var logMoney float64
|
||||
var chargedQuota int
|
||||
var upgradeGroup string
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
plan, err := getSubscriptionPlanByIdTx(tx, planId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !plan.Enabled {
|
||||
return errors.New("套餐未启用")
|
||||
}
|
||||
if plan.PriceAmount < 0 {
|
||||
return errors.New("套餐价格不能为负数")
|
||||
}
|
||||
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
|
||||
return errors.New("该套餐不允许使用余额兑换")
|
||||
}
|
||||
|
||||
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if requiredQuota > 0 && user.Quota < requiredQuota {
|
||||
return errors.New("余额不足")
|
||||
}
|
||||
if requiredQuota > 0 {
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).
|
||||
Update("quota", gorm.Expr("quota - ?", requiredQuota)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := common.GetTimestamp()
|
||||
tradeNo := fmt.Sprintf("SUBBALUSR%dNO%s%d", userId, common.GetRandomString(6), time.Now().UnixNano())
|
||||
order := &SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: PaymentMethodBalance,
|
||||
PaymentProvider: PaymentProviderBalance,
|
||||
Status: common.TopUpStatusSuccess,
|
||||
CreateTime: now,
|
||||
CompleteTime: now,
|
||||
ProviderPayload: fmt.Sprintf("charged_quota=%d", requiredQuota),
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logPlanTitle = plan.Title
|
||||
logMoney = plan.PriceAmount
|
||||
chargedQuota = requiredQuota
|
||||
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chargedQuota > 0 {
|
||||
if err := cacheDecrUserQuota(userId, int64(chargedQuota)); err != nil {
|
||||
common.SysLog("failed to decrease user quota cache after subscription balance purchase: " + err.Error())
|
||||
}
|
||||
}
|
||||
if upgradeGroup != "" {
|
||||
_ = UpdateUserGroupCache(userId, upgradeGroup)
|
||||
}
|
||||
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
|
||||
RecordLog(userId, LogTypeTopup, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestMain(m *testing.M) {
|
||||
&Token{},
|
||||
&Log{},
|
||||
&Channel{},
|
||||
&Ability{},
|
||||
&TopUp{},
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
@@ -60,6 +61,7 @@ func truncateTables(t *testing.T) {
|
||||
DB.Exec("DELETE FROM tokens")
|
||||
DB.Exec("DELETE FROM logs")
|
||||
DB.Exec("DELETE FROM channels")
|
||||
DB.Exec("DELETE FROM abilities")
|
||||
DB.Exec("DELETE FROM top_ups")
|
||||
DB.Exec("DELETE FROM subscription_orders")
|
||||
DB.Exec("DELETE FROM subscription_plans")
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
PaymentMethodCreem = "creem"
|
||||
PaymentMethodWaffo = "waffo"
|
||||
PaymentMethodWaffoPancake = "waffo_pancake"
|
||||
PaymentMethodBalance = "balance"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
PaymentProviderCreem = "creem"
|
||||
PaymentProviderWaffo = "waffo"
|
||||
PaymentProviderWaffoPancake = "waffo_pancake"
|
||||
PaymentProviderBalance = "balance"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+31
-17
@@ -225,7 +225,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) {
|
||||
func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) {
|
||||
var users []*User
|
||||
var total int64
|
||||
var err error
|
||||
@@ -246,28 +246,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
|
||||
|
||||
// 构建搜索条件
|
||||
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
|
||||
likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"}
|
||||
|
||||
// 尝试将关键字转换为整数ID
|
||||
keywordInt, err := strconv.Atoi(keyword)
|
||||
if err == nil {
|
||||
// 如果是数字,同时搜索ID和其他字段
|
||||
likeCondition = "id = ? OR " + likeCondition
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
} else {
|
||||
// 非数字关键字,只搜索字符串字段
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
likeArgs = append([]interface{}{keywordInt}, likeArgs...)
|
||||
}
|
||||
|
||||
query = query.Where("("+likeCondition+")", likeArgs...)
|
||||
if group != "" {
|
||||
query = query.Where(commonGroupCol+" = ?", group)
|
||||
}
|
||||
if role != nil {
|
||||
query = query.Where("role = ?", *role)
|
||||
}
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
@@ -987,6 +984,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
|
||||
//}
|
||||
}
|
||||
|
||||
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
|
||||
if quota == 0 && usedQuota == 0 && requestCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
|
||||
"request_count": gorm.Expr("request_count + ?", requestCount),
|
||||
},
|
||||
).Error
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserUsedQuota(id int, quota int) {
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
|
||||
+26
-11
@@ -67,33 +67,48 @@ func batchUpdate() {
|
||||
}
|
||||
|
||||
common.SysLog("batch update started")
|
||||
stores := make([]map[int]int, BatchUpdateTypeCount)
|
||||
for i := 0; i < BatchUpdateTypeCount; i++ {
|
||||
batchUpdateLocks[i].Lock()
|
||||
store := batchUpdateStores[i]
|
||||
stores[i] = batchUpdateStores[i]
|
||||
batchUpdateStores[i] = make(map[int]int)
|
||||
batchUpdateLocks[i].Unlock()
|
||||
// TODO: maybe we can combine updates with same key?
|
||||
}
|
||||
|
||||
for i, store := range stores {
|
||||
if i == BatchUpdateTypeUserQuota || i == BatchUpdateTypeUsedQuota || i == BatchUpdateTypeRequestCount {
|
||||
continue
|
||||
}
|
||||
for key, value := range store {
|
||||
switch i {
|
||||
case BatchUpdateTypeUserQuota:
|
||||
err := increaseUserQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeTokenQuota:
|
||||
err := increaseTokenQuota(key, value)
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update token quota: " + err.Error())
|
||||
}
|
||||
case BatchUpdateTypeUsedQuota:
|
||||
updateUserUsedQuota(key, value)
|
||||
case BatchUpdateTypeRequestCount:
|
||||
updateUserRequestCount(key, value)
|
||||
case BatchUpdateTypeChannelUsedQuota:
|
||||
updateChannelUsedQuota(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userQuotaStore := stores[BatchUpdateTypeUserQuota]
|
||||
usedQuotaStore := stores[BatchUpdateTypeUsedQuota]
|
||||
requestCountStore := stores[BatchUpdateTypeRequestCount]
|
||||
|
||||
userIDs := make(map[int]struct{}, len(userQuotaStore)+len(usedQuotaStore)+len(requestCountStore))
|
||||
for key := range userQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range usedQuotaStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range requestCountStore {
|
||||
userIDs[key] = struct{}{}
|
||||
}
|
||||
for key := range userIDs {
|
||||
updateUserQuotaUsedQuotaAndRequestCount(key, userQuotaStore[key], usedQuotaStore[key], requestCountStore[key])
|
||||
}
|
||||
common.SysLog("batch update finished")
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,23 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// applyUpstreamContentLength populates req.ContentLength when the upstream
|
||||
// body is wrapped in a BodyStorage (see relay/common/outbound_body.go).
|
||||
//
|
||||
// net/http.NewRequest only auto-detects ContentLength for *bytes.Reader,
|
||||
// *bytes.Buffer and *strings.Reader. When the body is a type-erased io.Reader
|
||||
// (which is the case for ReaderOnly(BodyStorage)), the Content-Length header
|
||||
// would otherwise be omitted, forcing chunked transfer encoding and breaking
|
||||
// some upstreams that require an explicit Content-Length.
|
||||
func applyUpstreamContentLength(req *http.Request, info *common.RelayInfo) {
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
if info.UpstreamRequestBodySize > 0 && req.ContentLength <= 0 {
|
||||
req.ContentLength = info.UpstreamRequestBodySize
|
||||
}
|
||||
}
|
||||
|
||||
func SetupApiRequestHeader(info *common.RelayInfo, c *gin.Context, req *http.Header) {
|
||||
if info.RelayMode == constant.RelayModeAudioTranscription || info.RelayMode == constant.RelayModeAudioTranslation {
|
||||
// multipart/form-data
|
||||
@@ -297,6 +314,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
headers := req.Header
|
||||
err = a.SetupRequestHeader(c, &headers, info)
|
||||
if err != nil {
|
||||
@@ -326,6 +344,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
// set form data
|
||||
req.Header.Set("Content-Type", c.Request.Header.Get("Content-Type"))
|
||||
headers := req.Header
|
||||
@@ -522,6 +541,7 @@ func DoTaskApiRequest(a TaskAdaptor, c *gin.Context, info *common.RelayInfo, req
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("new request failed: %w", err)
|
||||
}
|
||||
applyUpstreamContentLength(req, info)
|
||||
req.GetBody = func() (io.ReadCloser, error) {
|
||||
return io.NopCloser(requestBody), nil
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ var awsModelIDMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "anthropic.claude-opus-4-5-20251101-v1:0",
|
||||
"claude-opus-4-6": "anthropic.claude-opus-4-6-v1",
|
||||
"claude-opus-4-7": "anthropic.claude-opus-4-7",
|
||||
"claude-opus-4-8": "anthropic.claude-opus-4-8",
|
||||
// Nova models
|
||||
"nova-micro-v1:0": "amazon.nova-micro-v1:0",
|
||||
"nova-lite-v1:0": "amazon.nova-lite-v1:0",
|
||||
@@ -97,6 +98,11 @@ var awsModelCanCrossRegionMap = map[string]map[string]bool{
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
"eu": true,
|
||||
},
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||
"us": true,
|
||||
"ap": true,
|
||||
|
||||
@@ -33,6 +33,13 @@ var ModelList = []string{
|
||||
"claude-opus-4-7-medium",
|
||||
"claude-opus-4-7-low",
|
||||
"claude-opus-4-7-thinking",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-8-max",
|
||||
"claude-opus-4-8-xhigh",
|
||||
"claude-opus-4-8-high",
|
||||
"claude-opus-4-8-medium",
|
||||
"claude-opus-4-8-low",
|
||||
"claude-opus-4-8-thinking",
|
||||
}
|
||||
|
||||
var ChannelName = "claude"
|
||||
|
||||
@@ -154,14 +154,17 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") || strings.HasPrefix(textRequest.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
|
||||
claudeRequest.Model = baseModel
|
||||
claudeRequest.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
claudeRequest.Thinking.Display = "summarized"
|
||||
claudeRequest.Temperature = nil
|
||||
@@ -175,8 +178,9 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
|
||||
strings.HasSuffix(textRequest.Model, "-thinking") {
|
||||
|
||||
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
claudeRequest.Temperature = nil
|
||||
@@ -442,10 +446,7 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
|
||||
tools := make([]dto.ToolCallResponse, 0)
|
||||
fcIdx := 0
|
||||
if claudeResponse.Index != nil {
|
||||
fcIdx = *claudeResponse.Index - 1
|
||||
if fcIdx < 0 {
|
||||
fcIdx = 0
|
||||
}
|
||||
fcIdx = *claudeResponse.Index
|
||||
}
|
||||
var choice dto.ChatCompletionsStreamResponseChoice
|
||||
if claudeResponse.Type == "message_start" {
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func commonPointer[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
func TestFormatClaudeResponseInfo_MessageStart(t *testing.T) {
|
||||
claudeInfo := &ClaudeResponseInfo{
|
||||
Usage: &dto.Usage{},
|
||||
@@ -310,6 +314,58 @@ func TestRequestOpenAI2ClaudeMessage_IgnoresUnsupportedFileContent(t *testing.T)
|
||||
require.Equal(t, "see attachment", *content[0].Text)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-high",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-opus-4-8-thinking",
|
||||
Temperature: commonPointer(0.7),
|
||||
TopP: commonPointer(0.9),
|
||||
TopK: commonPointer(40),
|
||||
Messages: []dto.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: "hello",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
claudeRequest, err := RequestOpenAI2ClaudeMessage(nil, request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
|
||||
require.NotNil(t, claudeRequest.Thinking)
|
||||
require.Equal(t, "adaptive", claudeRequest.Thinking.Type)
|
||||
require.Equal(t, "summarized", claudeRequest.Thinking.Display)
|
||||
require.JSONEq(t, `{"effort":"high"}`, string(claudeRequest.OutputConfig))
|
||||
require.Nil(t, claudeRequest.Temperature)
|
||||
require.Nil(t, claudeRequest.TopP)
|
||||
require.Nil(t, claudeRequest.TopK)
|
||||
}
|
||||
|
||||
func TestRequestOpenAI2ClaudeMessage_SupportsPDFFileContent(t *testing.T) {
|
||||
request := dto.GeneralOpenAIRequest{
|
||||
Model: "claude-3-5-sonnet",
|
||||
|
||||
@@ -1079,17 +1079,47 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
FinishReason: constant.FinishReasonStop,
|
||||
}
|
||||
if len(candidate.Content.Parts) > 0 {
|
||||
var texts []string
|
||||
// 使用 strings.Builder 直接累积最终 content,避免:
|
||||
// 1) 每张 inline image 生成一次中间 "" 字符串
|
||||
// 2) 末尾 strings.Join 再分配一份等大缓冲
|
||||
// Gemini 图片返回时 InlineData.Data 可能是数 MB 的 base64,
|
||||
// 上述两份临时分配在高并发下会显著放大堆驻留。
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
// 媒体内容
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
imgText := ""
|
||||
texts = append(texts, imgText)
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
} else {
|
||||
// 其他媒体类型,直接显示链接
|
||||
texts = append(texts, fmt.Sprintf("[media](data:%s;base64,%s)", part.InlineData.MimeType, part.InlineData.Data))
|
||||
writeSep()
|
||||
content.WriteString("[media](data:")
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
choice.FinishReason = constant.FinishReasonToolCalls
|
||||
@@ -1100,13 +1130,22 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
choice.Message.ReasoningContent = &part.Text
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```")
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```")
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```")
|
||||
} else {
|
||||
// 过滤掉空行
|
||||
if part.Text != "\n" {
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1115,7 +1154,7 @@ func responseGeminiChat2OpenAI(c *gin.Context, response *dto.GeminiChatResponse)
|
||||
choice.Message.SetToolCalls(toolCalls)
|
||||
isToolCall = true
|
||||
}
|
||||
choice.Message.SetStringContent(strings.Join(texts, "\n"))
|
||||
choice.Message.SetStringContent(content.String())
|
||||
|
||||
}
|
||||
if candidate.FinishReason != nil {
|
||||
@@ -1169,7 +1208,25 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
//Role: "assistant",
|
||||
},
|
||||
}
|
||||
var texts []string
|
||||
// 使用 strings.Builder 直接累积 delta content,避免每张 image / 每个
|
||||
// 文本片段都先 `+` 拼出一份临时 string,再 strings.Join 再拷贝一遍。
|
||||
var content strings.Builder
|
||||
var inlineGrow int
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
inlineGrow += len(part.InlineData.MimeType) + len(part.InlineData.Data) + 32
|
||||
}
|
||||
}
|
||||
if inlineGrow > 0 {
|
||||
content.Grow(inlineGrow)
|
||||
}
|
||||
appended := 0
|
||||
writeSep := func() {
|
||||
if appended > 0 {
|
||||
content.WriteByte('\n')
|
||||
}
|
||||
appended++
|
||||
}
|
||||
isTools := false
|
||||
isThought := false
|
||||
if candidate.FinishReason != nil {
|
||||
@@ -1207,8 +1264,12 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if part.InlineData != nil {
|
||||
if strings.HasPrefix(part.InlineData.MimeType, "image") {
|
||||
imgText := ""
|
||||
texts = append(texts, imgText)
|
||||
writeSep()
|
||||
content.WriteString("
|
||||
content.WriteString(part.InlineData.MimeType)
|
||||
content.WriteString(";base64,")
|
||||
content.WriteString(part.InlineData.Data)
|
||||
content.WriteByte(')')
|
||||
}
|
||||
} else if part.FunctionCall != nil {
|
||||
isTools = true
|
||||
@@ -1219,23 +1280,33 @@ func streamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
|
||||
} else if part.Thought {
|
||||
isThought = true
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
} else {
|
||||
if part.ExecutableCode != nil {
|
||||
texts = append(texts, "```"+part.ExecutableCode.Language+"\n"+part.ExecutableCode.Code+"\n```\n")
|
||||
writeSep()
|
||||
content.WriteString("```")
|
||||
content.WriteString(part.ExecutableCode.Language)
|
||||
content.WriteByte('\n')
|
||||
content.WriteString(part.ExecutableCode.Code)
|
||||
content.WriteString("\n```\n")
|
||||
} else if part.CodeExecutionResult != nil {
|
||||
texts = append(texts, "```output\n"+part.CodeExecutionResult.Output+"\n```\n")
|
||||
writeSep()
|
||||
content.WriteString("```output\n")
|
||||
content.WriteString(part.CodeExecutionResult.Output)
|
||||
content.WriteString("\n```\n")
|
||||
} else {
|
||||
if part.Text != "\n" {
|
||||
texts = append(texts, part.Text)
|
||||
writeSep()
|
||||
content.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isThought {
|
||||
choice.Delta.SetReasoningContent(strings.Join(texts, "\n"))
|
||||
choice.Delta.SetReasoningContent(content.String())
|
||||
} else {
|
||||
choice.Delta.SetContentString(strings.Join(texts, "\n"))
|
||||
choice.Delta.SetContentString(content.String())
|
||||
}
|
||||
if isTools {
|
||||
choice.FinishReason = &constant.FinishReasonToolCalls
|
||||
@@ -1339,6 +1410,14 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
response.Id = id
|
||||
response.Created = createAt
|
||||
response.Model = info.UpstreamModelName
|
||||
if response.IsToolCall() {
|
||||
finishReason = constant.FinishReasonToolCalls
|
||||
if info.RelayFormat == types.RelayFormatClaude {
|
||||
for choiceIdx := range response.Choices {
|
||||
response.Choices[choiceIdx].FinishReason = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
for choiceIdx := range response.Choices {
|
||||
choiceKey := response.Choices[choiceIdx].Index
|
||||
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
|
||||
@@ -1399,7 +1478,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
logger.LogError(c, err.Error())
|
||||
}
|
||||
if isStop {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
if info.RelayFormat != types.RelayFormatClaude {
|
||||
_ = handleStream(c, info, helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -1409,6 +1490,10 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
}
|
||||
|
||||
response := helper.GenerateFinalUsageResponse(id, createAt, info.UpstreamModelName, *usage)
|
||||
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil && !info.ClaudeConvertInfo.Done {
|
||||
response = helper.GenerateStopResponse(id, createAt, info.UpstreamModelName, finishReason)
|
||||
response.Usage = usage
|
||||
}
|
||||
handleErr := handleFinalStream(c, info, response)
|
||||
if handleErr != nil {
|
||||
common.SysLog("send final response failed: " + handleErr.Error())
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -92,78 +91,28 @@ func ProcessStreamResponse(streamResponse dto.ChatCompletionsStreamResponse, res
|
||||
return nil
|
||||
}
|
||||
|
||||
func processTokens(relayMode int, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error {
|
||||
streamResp := "[" + strings.Join(streamItems, ",") + "]"
|
||||
|
||||
func processTokenData(relayMode int, data string, responseTextBuilder *strings.Builder, toolCount *int) error {
|
||||
switch relayMode {
|
||||
case relayconstant.RelayModeChatCompletions:
|
||||
return processChatCompletions(streamResp, streamItems, responseTextBuilder, toolCount)
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
return ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount)
|
||||
case relayconstant.RelayModeCompletions:
|
||||
return processCompletions(streamResp, streamItems, responseTextBuilder)
|
||||
var streamResponse dto.CompletionsStreamResponse
|
||||
if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
processCompletionsStreamResponse(streamResponse, responseTextBuilder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processChatCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder, toolCount *int) error {
|
||||
var streamResponses []dto.ChatCompletionsStreamResponse
|
||||
if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil {
|
||||
// 一次性解析失败,逐个解析
|
||||
common.SysLog("error unmarshalling stream response: " + err.Error())
|
||||
for _, item := range streamItems {
|
||||
var streamResponse dto.ChatCompletionsStreamResponse
|
||||
if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ProcessStreamResponse(streamResponse, responseTextBuilder, toolCount); err != nil {
|
||||
common.SysLog("error processing stream response: " + err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
func processCompletionsStreamResponse(streamResponse dto.CompletionsStreamResponse, responseTextBuilder *strings.Builder) {
|
||||
for _, choice := range streamResponse.Choices {
|
||||
responseTextBuilder.WriteString(choice.Text)
|
||||
}
|
||||
|
||||
// 批量处理所有响应
|
||||
for _, streamResponse := range streamResponses {
|
||||
for _, choice := range streamResponse.Choices {
|
||||
responseTextBuilder.WriteString(choice.Delta.GetContentString())
|
||||
responseTextBuilder.WriteString(choice.Delta.GetReasoningContent())
|
||||
if choice.Delta.ToolCalls != nil {
|
||||
if len(choice.Delta.ToolCalls) > *toolCount {
|
||||
*toolCount = len(choice.Delta.ToolCalls)
|
||||
}
|
||||
for _, tool := range choice.Delta.ToolCalls {
|
||||
responseTextBuilder.WriteString(tool.Function.Name)
|
||||
responseTextBuilder.WriteString(tool.Function.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func processCompletions(streamResp string, streamItems []string, responseTextBuilder *strings.Builder) error {
|
||||
var streamResponses []dto.CompletionsStreamResponse
|
||||
if err := json.Unmarshal(common.StringToByteSlice(streamResp), &streamResponses); err != nil {
|
||||
// 一次性解析失败,逐个解析
|
||||
common.SysLog("error unmarshalling stream response: " + err.Error())
|
||||
for _, item := range streamItems {
|
||||
var streamResponse dto.CompletionsStreamResponse
|
||||
if err := json.Unmarshal(common.StringToByteSlice(item), &streamResponse); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, choice := range streamResponse.Choices {
|
||||
responseTextBuilder.WriteString(choice.Text)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 批量处理所有响应
|
||||
for _, streamResponse := range streamResponses {
|
||||
for _, choice := range streamResponse.Choices {
|
||||
responseTextBuilder.WriteString(choice.Text)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleLastResponse(lastStreamData string, responseId *string, createAt *int64,
|
||||
|
||||
@@ -119,7 +119,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
var responseTextBuilder strings.Builder
|
||||
var toolCount int
|
||||
var usage = &dto.Usage{}
|
||||
var streamItems []string // store stream items
|
||||
var lastStreamData string
|
||||
var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
|
||||
|
||||
@@ -140,7 +139,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
}
|
||||
|
||||
lastStreamData = data
|
||||
streamItems = append(streamItems, data)
|
||||
if err := processTokenData(info.RelayMode, data, &responseTextBuilder, &toolCount); err != nil {
|
||||
logger.LogError(c, "error processing stream token data: "+err.Error())
|
||||
sr.Error(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -175,11 +177,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
|
||||
}
|
||||
}
|
||||
|
||||
// 处理token计算
|
||||
if err := processTokens(info.RelayMode, streamItems, &responseTextBuilder, &toolCount); err != nil {
|
||||
logger.LogError(c, "error processing tokens: "+err.Error())
|
||||
}
|
||||
|
||||
if !containStreamUsage {
|
||||
usage = service.ResponseText2Usage(c, responseTextBuilder.String(), info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
usage.CompletionTokens += toolCount * 7
|
||||
|
||||
@@ -45,6 +45,7 @@ var claudeModelMap = map[string]string{
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-5@20251101",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
"claude-opus-4-7": "claude-opus-4-7",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
|
||||
const anthropicVersion = "vertex-2023-10-16"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -125,7 +124,14 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
var requestBody io.Reader = body
|
||||
|
||||
var httpResp *http.Response
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
+17
-7
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -54,14 +53,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") || strings.HasPrefix(request.Model, "claude-opus-4-7")) {
|
||||
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
|
||||
request.Model = baseModel
|
||||
request.Thinking = &dto.Thinking{
|
||||
Type: "adaptive",
|
||||
}
|
||||
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects non-default temperature/top_p/top_k with 400
|
||||
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
|
||||
// and defaults display to "omitted"; restore the 4.6 visible summary.
|
||||
request.Thinking.Display = "summarized"
|
||||
request.Temperature = nil
|
||||
@@ -75,8 +77,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
strings.HasSuffix(request.Model, "-thinking") {
|
||||
if request.Thinking == nil {
|
||||
baseModel := strings.TrimSuffix(request.Model, "-thinking")
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") {
|
||||
// Opus 4.7 rejects thinking.type="enabled"; use adaptive at high effort.
|
||||
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
|
||||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
|
||||
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
|
||||
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
|
||||
request.Temperature = nil
|
||||
@@ -179,7 +182,14 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
)
|
||||
|
||||
// NewOutboundJSONBody wraps the already-marshaled upstream request body into a
|
||||
// BodyStorage. When disk cache is enabled and the payload exceeds the configured
|
||||
// threshold, the data is written to a temp file and the original []byte can be
|
||||
// GC'd, significantly reducing the heap residency while waiting for the
|
||||
// upstream provider to respond (the dominant cost for large base64 payloads).
|
||||
//
|
||||
// In memory mode the underlying memoryStorage reuses the same backing array,
|
||||
// so this is equivalent to bytes.NewReader(data) in terms of memory usage.
|
||||
//
|
||||
// The caller MUST invoke closer.Close() once the upstream call has finished
|
||||
// (typically via defer) to release the disk file / memory accounting.
|
||||
//
|
||||
// The returned reader is wrapped with common.ReaderOnly to prevent the HTTP
|
||||
// transport from prematurely closing the underlying BodyStorage. The returned
|
||||
// size is meant to be propagated to http.Request.ContentLength because the
|
||||
// type-erased io.Reader prevents net/http from auto-detecting it.
|
||||
func NewOutboundJSONBody(data []byte) (body io.Reader, size int64, closer io.Closer, err error) {
|
||||
storage, err := common.CreateBodyStorage(data)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
return common.ReaderOnly(storage), storage.Size(), storage, nil
|
||||
}
|
||||
+168
-133
@@ -153,9 +153,8 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新方法
|
||||
result, err := applyOperations(string(workingJSON), operations, conditionContext)
|
||||
return []byte(result), err
|
||||
// 使用新方法(基于 []byte,避免整包 string 拷贝)
|
||||
return applyOperations(workingJSON, operations, conditionContext)
|
||||
}
|
||||
|
||||
// 直接使用旧方法
|
||||
@@ -510,13 +509,13 @@ func tryParseOperations(paramOverride map[string]interface{}) ([]ParamOperation,
|
||||
return operations, true
|
||||
}
|
||||
|
||||
func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) {
|
||||
func checkConditions(data []byte, contextJSON string, conditions []ConditionOperation, logic string) (bool, error) {
|
||||
if len(conditions) == 0 {
|
||||
return true, nil // 没有条件,直接通过
|
||||
}
|
||||
results := make([]bool, len(conditions))
|
||||
for i, condition := range conditions {
|
||||
result, err := checkSingleCondition(jsonStr, contextJSON, condition)
|
||||
result, err := checkSingleCondition(data, contextJSON, condition)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -529,10 +528,10 @@ func checkConditions(jsonStr, contextJSON string, conditions []ConditionOperatio
|
||||
return lo.SomeBy(results, func(item bool) bool { return item }), nil
|
||||
}
|
||||
|
||||
func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperation) (bool, error) {
|
||||
func checkSingleCondition(data []byte, contextJSON string, condition ConditionOperation) (bool, error) {
|
||||
// 处理负数索引
|
||||
path := processNegativeIndex(jsonStr, condition.Path)
|
||||
value := gjson.Get(jsonStr, path)
|
||||
path := processNegativeIndex(data, condition.Path)
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() && contextJSON != "" {
|
||||
value = gjson.Get(contextJSON, condition.Path)
|
||||
}
|
||||
@@ -561,7 +560,7 @@ func checkSingleCondition(jsonStr, contextJSON string, condition ConditionOperat
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func processNegativeIndex(jsonStr string, path string) string {
|
||||
func processNegativeIndex(data []byte, path string) string {
|
||||
matches := negativeIndexRegexp.FindAllStringSubmatch(path, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
@@ -578,7 +577,7 @@ func processNegativeIndex(jsonStr string, path string) string {
|
||||
arrayPath = arrayPath[:len(arrayPath)-1]
|
||||
}
|
||||
|
||||
array := gjson.Get(jsonStr, arrayPath)
|
||||
array := gjson.GetBytes(data, arrayPath)
|
||||
if array.IsArray() {
|
||||
length := len(array.Array())
|
||||
actualIndex := length + index
|
||||
@@ -667,36 +666,76 @@ func compareNumeric(jsonValue, targetValue gjson.Result, operator string) (bool,
|
||||
}
|
||||
}
|
||||
|
||||
// applyOperationsLegacy 原参数覆盖方法
|
||||
// applyOperationsLegacy 原参数覆盖方法。
|
||||
//
|
||||
// 旧实现把整个 jsonData unmarshal 成 map[string]interface{} 再 marshal 回来,
|
||||
// 对包含大 base64 字段(如 Gemini inlineData.data)的请求会放大数倍内存
|
||||
// (interface 装箱、map bucket、再次 marshal)。
|
||||
// 这里改成在 []byte 上直接调用 sjson.SetBytes,按顶层 key 逐个写入,
|
||||
// 不再把 payload 解码到 map[string]interface{}。
|
||||
//
|
||||
// 语义保持:每个 paramOverride 顶层 key 视为字面 key(不解析点号路径),
|
||||
// 与旧的 reqMap[key] = value 一致。包含 `.` `*` `?` `\` 的 key 会被转义,
|
||||
// 防止被 sjson 当作嵌套路径或通配符。
|
||||
func applyOperationsLegacy(jsonData []byte, paramOverride map[string]interface{}, auditRecorder *paramOverrideAuditRecorder) ([]byte, error) {
|
||||
reqMap := make(map[string]interface{})
|
||||
err := common.Unmarshal(jsonData, &reqMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if len(paramOverride) == 0 {
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
result := jsonData
|
||||
for key, value := range paramOverride {
|
||||
reqMap[key] = value
|
||||
escaped := escapeSjsonLiteralKey(key)
|
||||
next, err := sjson.SetBytes(result, escaped, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = next
|
||||
auditRecorder.recordOperation("set", key, "", "", value)
|
||||
}
|
||||
|
||||
return common.Marshal(reqMap)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyOperations(jsonStr string, operations []ParamOperation, conditionContext map[string]interface{}) (string, error) {
|
||||
// escapeSjsonLiteralKey 把可能被 sjson 误判为路径或通配符的字符转义,
|
||||
// 用于把字面 key 安全地传给 sjson.SetBytes / sjson.DeleteBytes。
|
||||
func escapeSjsonLiteralKey(key string) string {
|
||||
if !strings.ContainsAny(key, ".*?\\") {
|
||||
return key
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.Grow(len(key) + 4)
|
||||
for i := 0; i < len(key); i++ {
|
||||
c := key[i]
|
||||
switch c {
|
||||
case '.', '*', '?', '\\':
|
||||
sb.WriteByte('\\')
|
||||
}
|
||||
sb.WriteByte(c)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// applyOperations 在 []byte 上原地应用所有 param override 操作。
|
||||
//
|
||||
// 旧实现走 string-based gjson/sjson,在 ApplyParamOverride 入口会做
|
||||
// string(jsonData) 与最终 []byte(result) 各一次整包拷贝,对大 base64
|
||||
// payload 来说每次重试都额外多花 2 倍 body 体积的临时内存。
|
||||
// 这里改成全程在 []byte 上工作,sjson.SetBytes / gjson.GetBytes 都是
|
||||
// 直接读写 []byte,每个操作只会产生一份新 buffer。
|
||||
func applyOperations(jsonData []byte, operations []ParamOperation, conditionContext map[string]interface{}) ([]byte, error) {
|
||||
context := ensureContextMap(conditionContext)
|
||||
auditRecorder := getParamOverrideAuditRecorder(context)
|
||||
contextJSON, err := marshalContextJSON(context)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal condition context: %v", err)
|
||||
return nil, fmt.Errorf("failed to marshal condition context: %v", err)
|
||||
}
|
||||
|
||||
result := jsonStr
|
||||
result := jsonData
|
||||
for _, op := range operations {
|
||||
// 检查条件是否满足
|
||||
ok, err := checkConditions(result, contextJSON, op.Conditions, op.Logic)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue // 条件不满足,跳过当前操作
|
||||
@@ -707,7 +746,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
if isPathBasedOperation(op.Mode) {
|
||||
opPaths, err = resolveOperationPaths(result, opPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
if len(opPaths) == 0 {
|
||||
continue
|
||||
@@ -725,10 +764,10 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
}
|
||||
case "set":
|
||||
for _, path := range opPaths {
|
||||
if op.KeepOrigin && gjson.Get(result, path).Exists() {
|
||||
if op.KeepOrigin && gjson.GetBytes(result, path).Exists() {
|
||||
continue
|
||||
}
|
||||
result, err = sjson.Set(result, path, op.Value)
|
||||
result, err = sjson.SetBytes(result, path, op.Value)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
@@ -743,7 +782,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
}
|
||||
case "copy":
|
||||
if op.From == "" || op.To == "" {
|
||||
return "", fmt.Errorf("copy from/to is required")
|
||||
return nil, fmt.Errorf("copy from/to is required")
|
||||
}
|
||||
opFrom := processNegativeIndex(result, op.From)
|
||||
opTo := processNegativeIndex(result, op.To)
|
||||
@@ -843,9 +882,9 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
auditRecorder.recordOperation("return_error", op.Path, "", "", op.Value)
|
||||
returnErr, parseErr := parseParamOverrideReturnError(op.Value)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
return nil, parseErr
|
||||
}
|
||||
return "", returnErr
|
||||
return nil, returnErr
|
||||
case "prune_objects":
|
||||
for _, path := range opPaths {
|
||||
result, err = pruneObjects(result, path, contextJSON, op.Value)
|
||||
@@ -902,7 +941,7 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
case "pass_headers":
|
||||
headerNames, parseErr := parseHeaderPassThroughNames(op.Value)
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
return nil, parseErr
|
||||
}
|
||||
for _, headerName := range headerNames {
|
||||
if err = copyHeaderInContext(context, headerName, headerName, op.KeepOrigin); err != nil {
|
||||
@@ -924,10 +963,10 @@ func applyOperations(jsonStr string, operations []ParamOperation, conditionConte
|
||||
contextJSON, err = marshalContextJSON(context)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unknown operation: %s", op.Mode)
|
||||
return nil, fmt.Errorf("unknown operation: %s", op.Mode)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("operation %s failed: %w", op.Mode, err)
|
||||
return nil, fmt.Errorf("operation %s failed: %w", op.Mode, err)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
@@ -1361,11 +1400,11 @@ func parseSyncTarget(spec string) (syncTarget, error) {
|
||||
}
|
||||
}
|
||||
|
||||
func readSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget) (interface{}, bool, error) {
|
||||
func readSyncTargetValue(data []byte, context map[string]interface{}, target syncTarget) (interface{}, bool, error) {
|
||||
switch target.kind {
|
||||
case "json":
|
||||
path := processNegativeIndex(jsonStr, target.key)
|
||||
value := gjson.Get(jsonStr, path)
|
||||
path := processNegativeIndex(data, target.key)
|
||||
value := gjson.GetBytes(data, path)
|
||||
if !value.Exists() || value.Type == gjson.Null {
|
||||
return nil, false, nil
|
||||
}
|
||||
@@ -1384,52 +1423,52 @@ func readSyncTargetValue(jsonStr string, context map[string]interface{}, target
|
||||
}
|
||||
}
|
||||
|
||||
func writeSyncTargetValue(jsonStr string, context map[string]interface{}, target syncTarget, value interface{}) (string, error) {
|
||||
func writeSyncTargetValue(data []byte, context map[string]interface{}, target syncTarget, value interface{}) ([]byte, error) {
|
||||
switch target.kind {
|
||||
case "json":
|
||||
path := processNegativeIndex(jsonStr, target.key)
|
||||
nextJSON, err := sjson.Set(jsonStr, path, value)
|
||||
path := processNegativeIndex(data, target.key)
|
||||
nextJSON, err := sjson.SetBytes(data, path, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return nextJSON, nil
|
||||
case "header":
|
||||
if err := setHeaderOverrideInContext(context, target.key, value, false); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported sync_fields target kind: %s", target.kind)
|
||||
return nil, fmt.Errorf("unsupported sync_fields target kind: %s", target.kind)
|
||||
}
|
||||
}
|
||||
|
||||
func syncFieldsBetweenTargets(jsonStr string, context map[string]interface{}, fromSpec string, toSpec string) (string, error) {
|
||||
func syncFieldsBetweenTargets(data []byte, context map[string]interface{}, fromSpec string, toSpec string) ([]byte, error) {
|
||||
fromTarget, err := parseSyncTarget(fromSpec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
toTarget, err := parseSyncTarget(toSpec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fromValue, fromExists, err := readSyncTargetValue(jsonStr, context, fromTarget)
|
||||
fromValue, fromExists, err := readSyncTargetValue(data, context, fromTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
toValue, toExists, err := readSyncTargetValue(jsonStr, context, toTarget)
|
||||
toValue, toExists, err := readSyncTargetValue(data, context, toTarget)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// If one side exists and the other side is missing, sync the missing side.
|
||||
if fromExists && !toExists {
|
||||
return writeSyncTargetValue(jsonStr, context, toTarget, fromValue)
|
||||
return writeSyncTargetValue(data, context, toTarget, fromValue)
|
||||
}
|
||||
if toExists && !fromExists {
|
||||
return writeSyncTargetValue(jsonStr, context, fromTarget, toValue)
|
||||
return writeSyncTargetValue(data, context, fromTarget, toValue)
|
||||
}
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func ensureMapKeyInContext(context map[string]interface{}, key string) map[string]interface{} {
|
||||
@@ -1503,24 +1542,24 @@ func syncRuntimeHeaderOverrideFromContext(info *RelayInfo, context map[string]in
|
||||
info.UseRuntimeHeadersOverride = true
|
||||
}
|
||||
|
||||
func moveValue(jsonStr, fromPath, toPath string) (string, error) {
|
||||
sourceValue := gjson.Get(jsonStr, fromPath)
|
||||
func moveValue(data []byte, fromPath, toPath string) ([]byte, error) {
|
||||
sourceValue := gjson.GetBytes(data, fromPath)
|
||||
if !sourceValue.Exists() {
|
||||
return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
return data, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
}
|
||||
result, err := sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||
result, err := sjson.SetBytes(data, toPath, sourceValue.Value())
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return sjson.Delete(result, fromPath)
|
||||
return sjson.DeleteBytes(result, fromPath)
|
||||
}
|
||||
|
||||
func copyValue(jsonStr, fromPath, toPath string) (string, error) {
|
||||
sourceValue := gjson.Get(jsonStr, fromPath)
|
||||
func copyValue(data []byte, fromPath, toPath string) ([]byte, error) {
|
||||
sourceValue := gjson.GetBytes(data, fromPath)
|
||||
if !sourceValue.Exists() {
|
||||
return jsonStr, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
return data, fmt.Errorf("source path does not exist: %s", fromPath)
|
||||
}
|
||||
return sjson.Set(jsonStr, toPath, sourceValue.Value())
|
||||
return sjson.SetBytes(data, toPath, sourceValue.Value())
|
||||
}
|
||||
|
||||
func isPathBasedOperation(mode string) bool {
|
||||
@@ -1532,16 +1571,16 @@ func isPathBasedOperation(mode string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOperationPaths(jsonStr, path string) ([]string, error) {
|
||||
func resolveOperationPaths(data []byte, path string) ([]string, error) {
|
||||
if !strings.Contains(path, "*") {
|
||||
return []string{path}, nil
|
||||
}
|
||||
return expandWildcardPaths(jsonStr, path)
|
||||
return expandWildcardPaths(data, path)
|
||||
}
|
||||
|
||||
func expandWildcardPaths(jsonStr, path string) ([]string, error) {
|
||||
func expandWildcardPaths(data []byte, path string) ([]string, error) {
|
||||
var root interface{}
|
||||
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||
if err := common.Unmarshal(data, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1602,28 +1641,28 @@ func collectWildcardPaths(node interface{}, segments []string, prefix []string)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteValue(jsonStr, path string) (string, error) {
|
||||
func deleteValue(data []byte, path string) ([]byte, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Delete(jsonStr, path)
|
||||
return sjson.DeleteBytes(data, path)
|
||||
}
|
||||
|
||||
func modifyValue(jsonStr, path string, value interface{}, keepOrigin, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyValue(data []byte, path string, value interface{}, keepOrigin, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
switch {
|
||||
case current.IsArray():
|
||||
return modifyArray(jsonStr, path, value, isPrepend)
|
||||
return modifyArray(data, path, value, isPrepend)
|
||||
case current.Type == gjson.String:
|
||||
return modifyString(jsonStr, path, value, isPrepend)
|
||||
return modifyString(data, path, value, isPrepend)
|
||||
case current.Type == gjson.JSON:
|
||||
return mergeObjects(jsonStr, path, value, keepOrigin)
|
||||
return mergeObjects(data, path, value, keepOrigin)
|
||||
}
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyArray(data []byte, path string, value interface{}, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
var newArray []interface{}
|
||||
// 添加新值
|
||||
addValue := func() {
|
||||
@@ -1647,11 +1686,11 @@ func modifyArray(jsonStr, path string, value interface{}, isPrepend bool) (strin
|
||||
addOriginal()
|
||||
addValue()
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newArray)
|
||||
return sjson.SetBytes(data, path, newArray)
|
||||
}
|
||||
|
||||
func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func modifyString(data []byte, path string, value interface{}, isPrepend bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
var newStr string
|
||||
if isPrepend {
|
||||
@@ -1659,17 +1698,17 @@ func modifyString(jsonStr, path string, value interface{}, isPrepend bool) (stri
|
||||
} else {
|
||||
newStr = current.String() + valueStr
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newStr)
|
||||
return sjson.SetBytes(data, path, newStr)
|
||||
}
|
||||
|
||||
func trimStringValue(jsonStr, path string, value interface{}, isPrefix bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func trimStringValue(data []byte, path string, value interface{}, isPrefix bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
return jsonStr, fmt.Errorf("trim value is required")
|
||||
return data, fmt.Errorf("trim value is required")
|
||||
}
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
|
||||
@@ -1679,69 +1718,69 @@ func trimStringValue(jsonStr, path string, value interface{}, isPrefix bool) (st
|
||||
} else {
|
||||
newStr = strings.TrimSuffix(current.String(), valueStr)
|
||||
}
|
||||
return sjson.Set(jsonStr, path, newStr)
|
||||
return sjson.SetBytes(data, path, newStr)
|
||||
}
|
||||
|
||||
func ensureStringAffix(jsonStr, path string, value interface{}, isPrefix bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func ensureStringAffix(data []byte, path string, value interface{}, isPrefix bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
return jsonStr, fmt.Errorf("ensure value is required")
|
||||
return data, fmt.Errorf("ensure value is required")
|
||||
}
|
||||
valueStr := fmt.Sprintf("%v", value)
|
||||
if valueStr == "" {
|
||||
return jsonStr, fmt.Errorf("ensure value is required")
|
||||
return data, fmt.Errorf("ensure value is required")
|
||||
}
|
||||
|
||||
currentStr := current.String()
|
||||
if isPrefix {
|
||||
if strings.HasPrefix(currentStr, valueStr) {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Set(jsonStr, path, valueStr+currentStr)
|
||||
return sjson.SetBytes(data, path, valueStr+currentStr)
|
||||
}
|
||||
|
||||
if strings.HasSuffix(currentStr, valueStr) {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
return sjson.Set(jsonStr, path, currentStr+valueStr)
|
||||
return sjson.SetBytes(data, path, currentStr+valueStr)
|
||||
}
|
||||
|
||||
func transformStringValue(jsonStr, path string, transform func(string) string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func transformStringValue(data []byte, path string, transform func(string) string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
return sjson.Set(jsonStr, path, transform(current.String()))
|
||||
return sjson.SetBytes(data, path, transform(current.String()))
|
||||
}
|
||||
|
||||
func replaceStringValue(jsonStr, path, from, to string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func replaceStringValue(data []byte, path, from, to string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
if from == "" {
|
||||
return jsonStr, fmt.Errorf("replace from is required")
|
||||
return data, fmt.Errorf("replace from is required")
|
||||
}
|
||||
return sjson.Set(jsonStr, path, strings.ReplaceAll(current.String(), from, to))
|
||||
return sjson.SetBytes(data, path, strings.ReplaceAll(current.String(), from, to))
|
||||
}
|
||||
|
||||
func regexReplaceStringValue(jsonStr, path, pattern, replacement string) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func regexReplaceStringValue(data []byte, path, pattern, replacement string) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
if current.Type != gjson.String {
|
||||
return jsonStr, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
return data, fmt.Errorf("operation not supported for type: %v", current.Type)
|
||||
}
|
||||
if pattern == "" {
|
||||
return jsonStr, fmt.Errorf("regex pattern is required")
|
||||
return data, fmt.Errorf("regex pattern is required")
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return jsonStr, err
|
||||
return data, err
|
||||
}
|
||||
return sjson.Set(jsonStr, path, re.ReplaceAllString(current.String(), replacement))
|
||||
return sjson.SetBytes(data, path, re.ReplaceAllString(current.String(), replacement))
|
||||
}
|
||||
|
||||
type pruneObjectsOptions struct {
|
||||
@@ -1750,37 +1789,33 @@ type pruneObjectsOptions struct {
|
||||
recursive bool
|
||||
}
|
||||
|
||||
func pruneObjects(jsonStr, path, contextJSON string, value interface{}) (string, error) {
|
||||
func pruneObjects(data []byte, path, contextJSON string, value interface{}) ([]byte, error) {
|
||||
options, err := parsePruneObjectsOptions(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if path == "" {
|
||||
var root interface{}
|
||||
if err := common.Unmarshal([]byte(jsonStr), &root); err != nil {
|
||||
return "", err
|
||||
if err := common.Unmarshal(data, &root); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cleaned, _, err := pruneObjectsNode(root, options, contextJSON, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cleanedBytes, err := common.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(cleanedBytes), nil
|
||||
return common.Marshal(cleaned)
|
||||
}
|
||||
|
||||
target := gjson.Get(jsonStr, path)
|
||||
target := gjson.GetBytes(data, path)
|
||||
if !target.Exists() {
|
||||
return jsonStr, nil
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var targetNode interface{}
|
||||
if target.Type == gjson.JSON {
|
||||
if err := common.Unmarshal([]byte(target.Raw), &targetNode); err != nil {
|
||||
return "", err
|
||||
if err := common.UnmarshalJsonStr(target.Raw, &targetNode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
targetNode = target.Value()
|
||||
@@ -1788,13 +1823,13 @@ func pruneObjects(jsonStr, path, contextJSON string, value interface{}) (string,
|
||||
|
||||
cleaned, _, err := pruneObjectsNode(targetNode, options, contextJSON, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
cleanedBytes, err := common.Marshal(cleaned)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
return sjson.SetRaw(jsonStr, path, string(cleanedBytes))
|
||||
return sjson.SetRawBytes(data, path, cleanedBytes)
|
||||
}
|
||||
|
||||
func parsePruneObjectsOptions(value interface{}) (pruneObjectsOptions, error) {
|
||||
@@ -1970,16 +2005,16 @@ func shouldPruneObject(node map[string]interface{}, options pruneObjectsOptions,
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return checkConditions(string(nodeBytes), contextJSON, options.conditions, options.logic)
|
||||
return checkConditions(nodeBytes, contextJSON, options.conditions, options.logic)
|
||||
}
|
||||
|
||||
func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (string, error) {
|
||||
current := gjson.Get(jsonStr, path)
|
||||
func mergeObjects(data []byte, path string, value interface{}, keepOrigin bool) ([]byte, error) {
|
||||
current := gjson.GetBytes(data, path)
|
||||
var currentMap, newMap map[string]interface{}
|
||||
|
||||
// 解析当前值
|
||||
if err := common.Unmarshal([]byte(current.Raw), ¤tMap); err != nil {
|
||||
return "", err
|
||||
// 解析当前值(current.Raw 是 data 的子串,避免再分配一份)
|
||||
if err := common.UnmarshalJsonStr(current.Raw, ¤tMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 解析新值
|
||||
switch v := value.(type) {
|
||||
@@ -1988,7 +2023,7 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
|
||||
default:
|
||||
jsonBytes, _ := common.Marshal(v)
|
||||
if err := common.Unmarshal(jsonBytes, &newMap); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// 合并
|
||||
@@ -2001,7 +2036,7 @@ func mergeObjects(jsonStr, path string, value interface{}, keepOrigin bool) (str
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
return sjson.Set(jsonStr, path, result)
|
||||
return sjson.SetBytes(data, path, result)
|
||||
}
|
||||
|
||||
// BuildParamOverrideContext 提供 ApplyParamOverride 可用的上下文信息。
|
||||
|
||||
@@ -2054,6 +2054,17 @@ func TestRemoveDisabledFieldsDefaultFiltering(t *testing.T) {
|
||||
assertJSONEqual(t, `{"cache_control":{"type":"ephemeral"},"store":true}`, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsNoControlledFieldsKeepsBody(t *testing.T) {
|
||||
input := `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`
|
||||
settings := dto.ChannelOtherSettings{}
|
||||
|
||||
out, err := RemoveDisabledFields([]byte(input), settings, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveDisabledFields returned error: %v", err)
|
||||
}
|
||||
require.Equal(t, input, string(out))
|
||||
}
|
||||
|
||||
func TestRemoveDisabledFieldsAllowInferenceGeo(t *testing.T) {
|
||||
input := `{
|
||||
"inference_geo":"eu",
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type ThinkingContentInfo struct {
|
||||
@@ -153,6 +154,13 @@ type RelayInfo struct {
|
||||
UseRuntimeHeadersOverride bool
|
||||
ParamOverrideAudit []string
|
||||
|
||||
// UpstreamRequestBodySize is the byte size of the marshaled upstream request
|
||||
// body. It is set when the body is wrapped in a BodyStorage (see
|
||||
// relay/common/outbound_body.go), so that DoApiRequest can populate
|
||||
// http.Request.ContentLength manually (net/http only auto-detects it for
|
||||
// *bytes.Reader/Buffer/strings.Reader). 0 means "let net/http decide".
|
||||
UpstreamRequestBodySize int64
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
// TieredBillingSnapshot is a frozen snapshot of tiered billing rules
|
||||
@@ -785,6 +793,9 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelPassThroughEnabled {
|
||||
return jsonData, nil
|
||||
}
|
||||
if !hasRemovableDisabledField(jsonData, channelOtherSettings) {
|
||||
return jsonData, nil
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := common.Unmarshal(jsonData, &data); err != nil {
|
||||
@@ -851,6 +862,25 @@ func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOther
|
||||
return jsonDataAfter, nil
|
||||
}
|
||||
|
||||
func hasRemovableDisabledField(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) bool {
|
||||
values := gjson.GetManyBytes(
|
||||
jsonData,
|
||||
"service_tier",
|
||||
"inference_geo",
|
||||
"speed",
|
||||
"store",
|
||||
"safety_identifier",
|
||||
"stream_options.include_obfuscation",
|
||||
)
|
||||
|
||||
return (!channelOtherSettings.AllowServiceTier && values[0].Exists()) ||
|
||||
(!channelOtherSettings.AllowInferenceGeo && values[1].Exists()) ||
|
||||
(!channelOtherSettings.AllowSpeed && values[2].Exists()) ||
|
||||
(channelOtherSettings.DisableStore && values[3].Exists()) ||
|
||||
(!channelOtherSettings.AllowSafetyIdentifier && values[4].Exists()) ||
|
||||
(!channelOtherSettings.AllowIncludeObfuscation && values[5].Exists())
|
||||
}
|
||||
|
||||
// RemoveGeminiDisabledFields removes disabled fields from Gemini request JSON data
|
||||
// Currently supports removing functionResponse.id field which Vertex AI does not support
|
||||
func RemoveGeminiDisabledFields(jsonData []byte) ([]byte, error) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -176,7 +175,14 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
|
||||
logger.LogDebug(c, "text request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
var httpResp *http.Response
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -59,7 +58,14 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "converted embedding request body: %s", jsonData)
|
||||
var requestBody io.Reader = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
var requestBody io.Reader = body
|
||||
statusCodeMappingStr := c.GetString("status_code_mapping")
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
if err != nil {
|
||||
|
||||
+16
-3
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -165,7 +164,14 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
|
||||
logger.LogDebug(c, "Gemini request body: %s", jsonData)
|
||||
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
@@ -263,7 +269,14 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
|
||||
}
|
||||
}
|
||||
logger.LogDebug(c, "Gemini embedding request body: %s", jsonData)
|
||||
requestBody = bytes.NewReader(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
if err != nil {
|
||||
|
||||
+11
-4
@@ -77,7 +77,14 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "image request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +140,9 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type
|
||||
usage.(*dto.Usage).PromptTokens = 1
|
||||
}
|
||||
|
||||
quality := "standard"
|
||||
if request.Quality == "hd" {
|
||||
quality = "hd"
|
||||
quality := request.Quality
|
||||
if quality == "" {
|
||||
quality = "standard"
|
||||
}
|
||||
|
||||
var logContent []string
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -69,7 +68,14 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "Rerank request body: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
resp, err := adaptor.DoRequest(c, info, requestBody)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -104,7 +103,14 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
|
||||
}
|
||||
|
||||
logger.LogDebug(c, "requestBody: %s", jsonData)
|
||||
requestBody = bytes.NewBuffer(jsonData)
|
||||
body, size, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
defer closer.Close()
|
||||
jsonData = nil
|
||||
info.UpstreamRequestBodySize = size
|
||||
requestBody = body
|
||||
}
|
||||
|
||||
var httpResp *http.Response
|
||||
|
||||
+12
-3
@@ -56,7 +56,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
|
||||
apiRouter.POST("/creem/webhook", controller.CreemWebhook)
|
||||
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
|
||||
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
|
||||
// :env separates test vs prod URLs so the operator can register each
|
||||
// in Pancake's matching webhook slot; handler enforces env match.
|
||||
apiRouter.POST("/waffo-pancake/webhook/:env", controller.WaffoPancakeWebhook)
|
||||
|
||||
// Universal secure verification routes
|
||||
apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify)
|
||||
@@ -100,8 +102,8 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.RequestCreemPay)
|
||||
selfRoute.POST("/waffo/amount", controller.RequestWaffoAmount)
|
||||
selfRoute.POST("/waffo/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPay)
|
||||
//selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
|
||||
//selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
|
||||
selfRoute.POST("/waffo-pancake/amount", controller.RequestWaffoPancakeAmount)
|
||||
selfRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.RequestWaffoPancakePay)
|
||||
selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
|
||||
selfRoute.PUT("/setting", controller.UpdateUserSetting)
|
||||
|
||||
@@ -151,9 +153,11 @@ func SetApiRouter(router *gin.Engine) {
|
||||
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
|
||||
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
|
||||
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
|
||||
subscriptionRoute.POST("/balance/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestBalancePay)
|
||||
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
|
||||
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
|
||||
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
|
||||
subscriptionRoute.POST("/waffo-pancake/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestWaffoPancakePay)
|
||||
}
|
||||
subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
|
||||
subscriptionAdminRoute.Use(middleware.AdminAuth())
|
||||
@@ -186,6 +190,11 @@ func SetApiRouter(router *gin.Engine) {
|
||||
optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache)
|
||||
optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio)
|
||||
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
|
||||
optionRoute.POST("/waffo-pancake/catalog", controller.ListWaffoPancakeCatalog)
|
||||
optionRoute.POST("/waffo-pancake/pair", controller.CreateWaffoPancakePair)
|
||||
optionRoute.POST("/waffo-pancake/save", controller.SaveWaffoPancake)
|
||||
optionRoute.POST("/waffo-pancake/subscription-product", controller.CreateWaffoPancakeSubscriptionProduct)
|
||||
optionRoute.POST("/waffo-pancake/subscription-product-options", controller.ListWaffoPancakeSubscriptionProductOptions)
|
||||
}
|
||||
|
||||
// Custom OAuth provider management (root only)
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ func formatNotifyType(channelId int, status int) string {
|
||||
|
||||
// disable & notify
|
||||
func DisableChannel(channelError types.ChannelError, reason string) {
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, reason))
|
||||
common.SysLog(fmt.Sprintf("通道「%s」(#%d)发生错误,准备禁用,原因:%s", channelError.ChannelName, channelError.ChannelId, common.LocalLogPreview(reason)))
|
||||
|
||||
// 检查是否启用自动禁用功能
|
||||
if !channelError.AutoBan {
|
||||
|
||||
+5
-3
@@ -92,11 +92,13 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
|
||||
}
|
||||
CloseResponseBodyGracefully(resp)
|
||||
var errResponse dto.GeneralErrorResponse
|
||||
responseBodyText := string(responseBody)
|
||||
responseBodyPreview := common.LocalLogPreview(responseBodyText)
|
||||
buildErrWithBody := func(message string) error {
|
||||
if message == "" {
|
||||
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody))
|
||||
return fmt.Errorf("bad response status code %d, body: %s", resp.StatusCode, responseBodyText)
|
||||
}
|
||||
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, string(responseBody))
|
||||
return fmt.Errorf("bad response status code %d, message: %s, body: %s", resp.StatusCode, message, responseBodyText)
|
||||
}
|
||||
|
||||
err = common.Unmarshal(responseBody, &errResponse)
|
||||
@@ -104,7 +106,7 @@ func RelayErrorHandler(ctx context.Context, resp *http.Response, showBodyWhenFai
|
||||
if showBodyWhenFail {
|
||||
newApiErr.Err = buildErrWithBody("")
|
||||
} else {
|
||||
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, string(responseBody)))
|
||||
logger.LogError(ctx, fmt.Sprintf("bad response status code %d, body: %s", resp.StatusCode, responseBodyPreview))
|
||||
newApiErr.Err = fmt.Errorf("bad response status code %d", resp.StatusCode)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -55,3 +63,99 @@ func TestResetStatusCode(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerTruncatesInvalidJSONBodyInLog(t *testing.T) {
|
||||
withDebugEnabled(t, false)
|
||||
|
||||
body := strings.Repeat("b", common.LocalLogContentLimit+256)
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
common.LogWriterMu.Lock()
|
||||
oldWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logBuffer
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = oldWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, "bad response status code 500", newAPIError.Error())
|
||||
require.Contains(t, logBuffer.String(), "[truncated")
|
||||
require.Contains(t, logBuffer.String(), fmt.Sprintf("original_length=%d", len(body)))
|
||||
require.NotContains(t, logBuffer.String(), strings.Repeat("b", common.LocalLogContentLimit+1))
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsStructuredErrorMessage(t *testing.T) {
|
||||
message := strings.Repeat("c", common.LocalLogContentLimit+256)
|
||||
body := `{"message":"` + message + `"}`
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, message, newAPIError.Error())
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsOpenAIErrorMessage(t *testing.T) {
|
||||
message := strings.Repeat("d", common.LocalLogContentLimit+256)
|
||||
body := `{"error":{"message":"` + message + `","type":"server_error","code":"server_error"}}`
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.Equal(t, message, newAPIError.Error())
|
||||
}
|
||||
|
||||
func TestRelayErrorHandlerKeepsInvalidJSONBodyInDebugLog(t *testing.T) {
|
||||
withDebugEnabled(t, true)
|
||||
|
||||
body := strings.Repeat("e", common.LocalLogContentLimit+256)
|
||||
var logBuffer bytes.Buffer
|
||||
|
||||
common.LogWriterMu.Lock()
|
||||
oldWriter := gin.DefaultErrorWriter
|
||||
gin.DefaultErrorWriter = &logBuffer
|
||||
common.LogWriterMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
common.LogWriterMu.Lock()
|
||||
gin.DefaultErrorWriter = oldWriter
|
||||
common.LogWriterMu.Unlock()
|
||||
})
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
|
||||
newAPIError := RelayErrorHandler(context.Background(), resp, false)
|
||||
|
||||
require.NotNil(t, newAPIError)
|
||||
require.NotContains(t, logBuffer.String(), "[truncated")
|
||||
require.Contains(t, logBuffer.String(), body)
|
||||
}
|
||||
|
||||
func withDebugEnabled(t *testing.T, enabled bool) {
|
||||
t.Helper()
|
||||
|
||||
oldDebug := common.DebugEnabled
|
||||
common.DebugEnabled = enabled
|
||||
t.Cleanup(func() {
|
||||
common.DebugEnabled = oldDebug
|
||||
})
|
||||
}
|
||||
|
||||
+406
-321
@@ -1,398 +1,483 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
pancake "github.com/waffo-com/waffo-pancake-sdk-go"
|
||||
)
|
||||
|
||||
const (
|
||||
waffoPancakeAuthBaseURL = "https://waffo-pancake-auth-service.vercel.app"
|
||||
waffoPancakeCheckoutPath = "/v1/actions/checkout/create-session"
|
||||
waffoPancakeDefaultTolerance = 5 * time.Minute
|
||||
)
|
||||
|
||||
// WaffoPancakePriceSnapshot is the per-session price override sent with checkout.
|
||||
type WaffoPancakePriceSnapshot struct {
|
||||
Amount string `json:"amount"`
|
||||
TaxIncluded bool `json:"taxIncluded"`
|
||||
TaxCategory string `json:"taxCategory"`
|
||||
Amount string
|
||||
TaxCategory string
|
||||
}
|
||||
|
||||
// WaffoPancakeCreateSessionParams is the input to CreateWaffoPancakeCheckoutSession.
|
||||
// BuyerIdentity must be stable per user (see WaffoPancakeBuyerIdentityFromUserID).
|
||||
// OrderMerchantExternalID = our trade_no; Pancake echoes it back in webhooks.
|
||||
type WaffoPancakeCreateSessionParams struct {
|
||||
StoreID string `json:"storeId"`
|
||||
ProductID string `json:"productId"`
|
||||
ProductType string `json:"productType"`
|
||||
Currency string `json:"currency"`
|
||||
PriceSnapshot *WaffoPancakePriceSnapshot `json:"priceSnapshot,omitempty"`
|
||||
BuyerEmail string `json:"buyerEmail,omitempty"`
|
||||
SuccessURL string `json:"successUrl,omitempty"`
|
||||
ExpiresInSeconds *int `json:"expiresInSeconds,omitempty"`
|
||||
ProductID string
|
||||
BuyerIdentity string
|
||||
PriceSnapshot *WaffoPancakePriceSnapshot
|
||||
BuyerEmail string
|
||||
ExpiresInSeconds *int
|
||||
OrderMerchantExternalID string
|
||||
}
|
||||
|
||||
// WaffoPancakeCheckoutSession is the response of CreateWaffoPancakeCheckoutSession.
|
||||
// CheckoutURL already carries the `#token=...` fragment; Token / TokenExpiresAt
|
||||
// are exposed separately for self-service flows driven from new-api's own UI.
|
||||
type WaffoPancakeCheckoutSession struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
CheckoutURL string `json:"checkoutUrl"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
OrderID string `json:"orderId"`
|
||||
SessionID string
|
||||
CheckoutURL string
|
||||
ExpiresAt string
|
||||
OrderID string
|
||||
Token string
|
||||
TokenExpiresAt string
|
||||
}
|
||||
|
||||
type waffoPancakeAPIError struct {
|
||||
Message string `json:"message"`
|
||||
Layer string `json:"layer"`
|
||||
// WaffoPancakeWebhookEvent mirrors the SDK's WebhookEvent shape using plain
|
||||
// strings so controllers don't have to import the SDK package.
|
||||
type WaffoPancakeWebhookEvent struct {
|
||||
ID string
|
||||
Timestamp string
|
||||
EventType string
|
||||
EventID string
|
||||
StoreID string
|
||||
Mode string
|
||||
Data WaffoPancakeWebhookData
|
||||
}
|
||||
|
||||
type waffoPancakeCreateSessionResponse struct {
|
||||
Data *WaffoPancakeCheckoutSession `json:"data"`
|
||||
Errors []waffoPancakeAPIError `json:"errors"`
|
||||
type WaffoPancakeWebhookData struct {
|
||||
// OrderID = Pancake ORD_* (logs); OrderMerchantExternalID = our trade_no (lookup).
|
||||
OrderID string
|
||||
OrderMerchantExternalID string
|
||||
BuyerEmail string
|
||||
Currency string
|
||||
Amount string
|
||||
TaxAmount string
|
||||
ProductName string
|
||||
MerchantProvidedBuyerIdentity string
|
||||
}
|
||||
|
||||
type waffoPancakeWebhookData struct {
|
||||
ID string `json:"id"`
|
||||
OrderID string `json:"orderId"`
|
||||
BuyerEmail string `json:"buyerEmail"`
|
||||
Currency string `json:"currency"`
|
||||
Amount dto.StringValue `json:"amount"`
|
||||
TaxAmount dto.StringValue `json:"taxAmount"`
|
||||
ProductName string `json:"productName"`
|
||||
}
|
||||
|
||||
type waffoPancakeWebhookEvent struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
EventType string `json:"eventType"`
|
||||
EventID string `json:"eventId"`
|
||||
StoreID string `json:"storeId"`
|
||||
Mode string `json:"mode"`
|
||||
Data waffoPancakeWebhookData `json:"data"`
|
||||
}
|
||||
|
||||
func (e *waffoPancakeWebhookEvent) NormalizedEventType() string {
|
||||
// NormalizedEventType returns the event type or empty string for a nil event.
|
||||
func (e *WaffoPancakeWebhookEvent) NormalizedEventType() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.EventType
|
||||
}
|
||||
|
||||
// newWaffoPancakeClient builds an SDK client from persisted settings. The
|
||||
// runtime checkout / webhook paths use this; configuration endpoints use
|
||||
// newWaffoPancakeClientFromCreds so the operator can verify typed-but-not-
|
||||
// yet-saved credentials.
|
||||
func newWaffoPancakeClient() (*pancake.Client, error) {
|
||||
return pancake.New(pancake.Config{
|
||||
MerchantID: setting.WaffoPancakeMerchantID,
|
||||
PrivateKey: setting.WaffoPancakePrivateKey,
|
||||
})
|
||||
}
|
||||
|
||||
func newWaffoPancakeClientFromCreds(merchantID, privateKey string) (*pancake.Client, error) {
|
||||
if strings.TrimSpace(merchantID) == "" || strings.TrimSpace(privateKey) == "" {
|
||||
return nil, fmt.Errorf("merchant id and private key are required")
|
||||
}
|
||||
return pancake.New(pancake.Config{
|
||||
MerchantID: merchantID,
|
||||
PrivateKey: privateKey,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateWaffoPancakeCheckoutSession creates an Authenticated-mode checkout
|
||||
// session: the order is bound to BuyerIdentity (stable per user) so it stays
|
||||
// attributable even if the buyer edits the email on Waffo's checkout form.
|
||||
func CreateWaffoPancakeCheckoutSession(ctx context.Context, params *WaffoPancakeCreateSessionParams) (*WaffoPancakeCheckoutSession, error) {
|
||||
if params == nil {
|
||||
return nil, fmt.Errorf("missing checkout params")
|
||||
}
|
||||
|
||||
body, err := common.Marshal(params)
|
||||
if strings.TrimSpace(params.BuyerIdentity) == "" {
|
||||
return nil, fmt.Errorf("missing buyer identity")
|
||||
}
|
||||
if strings.TrimSpace(params.OrderMerchantExternalID) == "" {
|
||||
return nil, fmt.Errorf("missing order merchant external id")
|
||||
}
|
||||
client, err := newWaffoPancakeClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal Waffo Pancake checkout payload: %w", err)
|
||||
return nil, fmt.Errorf("build Waffo Pancake client: %w", err)
|
||||
}
|
||||
|
||||
privateKey, err := normalizeRSAPrivateKey(setting.WaffoPancakePrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
sdkParams := pancake.AuthenticatedCheckoutParams{
|
||||
CreateCheckoutSessionParams: pancake.CreateCheckoutSessionParams{
|
||||
ProductID: params.ProductID,
|
||||
Currency: "USD",
|
||||
BuyerEmail: optionalString(params.BuyerEmail),
|
||||
ExpiresInSeconds: params.ExpiresInSeconds,
|
||||
OrderMerchantExternalID: optionalString(params.OrderMerchantExternalID),
|
||||
},
|
||||
BuyerIdentity: params.BuyerIdentity,
|
||||
}
|
||||
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
signature, err := signWaffoPancakeRequest(http.MethodPost, waffoPancakeCheckoutPath, timestamp, string(body), privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, waffoPancakeAuthBaseURL+waffoPancakeCheckoutPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build Waffo Pancake checkout request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Merchant-Id", setting.WaffoPancakeMerchantID)
|
||||
req.Header.Set("X-Timestamp", timestamp)
|
||||
req.Header.Set("X-Signature", signature)
|
||||
if setting.WaffoPancakeSandbox {
|
||||
req.Header.Set("X-Environment", "test")
|
||||
} else {
|
||||
req.Header.Set("X-Environment", "prod")
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request Waffo Pancake checkout session: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Waffo Pancake checkout response: %w", err)
|
||||
}
|
||||
|
||||
var result waffoPancakeCreateSessionResponse
|
||||
if err := common.Unmarshal(responseBody, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode Waffo Pancake checkout response: %w", err)
|
||||
}
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Waffo Pancake error (%d): %s", resp.StatusCode, result.Errors[0].Message)
|
||||
if params.PriceSnapshot != nil {
|
||||
sdkParams.PriceSnapshot = &pancake.PriceInfo{
|
||||
Amount: params.PriceSnapshot.Amount,
|
||||
TaxCategory: pancake.TaxCategory(params.PriceSnapshot.TaxCategory),
|
||||
}
|
||||
return nil, fmt.Errorf("Waffo Pancake checkout request failed with status %d", resp.StatusCode)
|
||||
}
|
||||
if len(result.Errors) > 0 {
|
||||
return nil, fmt.Errorf("Waffo Pancake error: %s", result.Errors[0].Message)
|
||||
|
||||
session, err := client.Checkout.Authenticated.Create(ctx, sdkParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Data == nil || result.Data.CheckoutURL == "" || strings.TrimSpace(result.Data.SessionID) == "" {
|
||||
if session == nil || strings.TrimSpace(session.CheckoutURL) == "" || strings.TrimSpace(session.SessionID) == "" {
|
||||
return nil, fmt.Errorf("Waffo Pancake returned empty checkout session")
|
||||
}
|
||||
return result.Data, nil
|
||||
return &WaffoPancakeCheckoutSession{
|
||||
SessionID: session.SessionID,
|
||||
CheckoutURL: session.CheckoutURL,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
Token: session.Token,
|
||||
TokenExpiresAt: session.TokenExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*waffoPancakeWebhookEvent, error) {
|
||||
environment := resolveWaffoPancakeWebhookEnvironment(payload)
|
||||
return verifyWaffoPancakeWebhook(payload, signatureHeader, environment)
|
||||
func optionalString(s string) *string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
v := s
|
||||
return &v
|
||||
}
|
||||
|
||||
func ResolveWaffoPancakeTradeNo(event *waffoPancakeWebhookEvent) (string, error) {
|
||||
// WaffoPancakeBuyerIdentityFromUserID renders the canonical buyer identity
|
||||
// for checkout. Webhook handlers compare against the value rendered here to
|
||||
// reject identity mismatches, so both call sites must use this function.
|
||||
func WaffoPancakeBuyerIdentityFromUserID(userID int) string {
|
||||
return fmt.Sprintf("new-api-user-%d", userID)
|
||||
}
|
||||
|
||||
// VerifyConfiguredWaffoPancakeWebhook verifies the signature header. The SDK
|
||||
// picks the matching test / prod public key from the payload's `mode` field.
|
||||
func VerifyConfiguredWaffoPancakeWebhook(payload string, signatureHeader string) (*WaffoPancakeWebhookEvent, error) {
|
||||
evt, err := pancake.VerifyWebhookTyped[pancake.WebhookEventData](payload, signatureHeader, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
identity := ""
|
||||
if evt.Data.MerchantProvidedBuyerIdentity != nil {
|
||||
identity = *evt.Data.MerchantProvidedBuyerIdentity
|
||||
}
|
||||
externalID := ""
|
||||
if evt.Data.OrderMerchantExternalID != nil {
|
||||
externalID = *evt.Data.OrderMerchantExternalID
|
||||
}
|
||||
return &WaffoPancakeWebhookEvent{
|
||||
ID: evt.ID,
|
||||
Timestamp: evt.Timestamp,
|
||||
EventType: evt.EventType,
|
||||
EventID: evt.EventID,
|
||||
StoreID: evt.StoreID,
|
||||
Mode: string(evt.Mode),
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: evt.Data.OrderID,
|
||||
OrderMerchantExternalID: externalID,
|
||||
BuyerEmail: evt.Data.BuyerEmail,
|
||||
Currency: evt.Data.Currency,
|
||||
Amount: evt.Data.Amount,
|
||||
TaxAmount: evt.Data.TaxAmount,
|
||||
ProductName: evt.Data.ProductName,
|
||||
MerchantProvidedBuyerIdentity: identity,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveWaffoPancakeTradeNo maps a verified webhook event to a local TopUp
|
||||
// trade_no via OrderMerchantExternalID, and rejects buyer-identity mismatches.
|
||||
func ResolveWaffoPancakeTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
|
||||
if event == nil {
|
||||
return "", fmt.Errorf("missing webhook event")
|
||||
}
|
||||
|
||||
if tradeNo := strings.TrimSpace(event.Data.OrderID); tradeNo != "" {
|
||||
topUp := model.GetTopUpByTradeNo(tradeNo)
|
||||
if topUp != nil && topUp.PaymentMethod == model.PaymentMethodWaffoPancake {
|
||||
return tradeNo, nil
|
||||
}
|
||||
return "", fmt.Errorf("waffo pancake order not found for webhook orderId=%s", tradeNo)
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
if tradeNo == "" {
|
||||
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("missing webhook orderId")
|
||||
topUp := model.GetTopUpByTradeNo(tradeNo)
|
||||
if topUp == nil || topUp.PaymentProvider != model.PaymentProviderWaffoPancake {
|
||||
return "", fmt.Errorf("waffo pancake order not found for tradeNo=%s", tradeNo)
|
||||
}
|
||||
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(topUp.UserId)
|
||||
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
||||
if actualIdentity != expectedIdentity {
|
||||
return "", fmt.Errorf(
|
||||
"waffo pancake buyer identity mismatch for tradeNo=%s: expected=%q actual=%q",
|
||||
tradeNo,
|
||||
expectedIdentity,
|
||||
actualIdentity,
|
||||
)
|
||||
}
|
||||
return tradeNo, nil
|
||||
}
|
||||
|
||||
func normalizeRSAPrivateKey(raw string) (string, error) {
|
||||
return normalizePEMKey(raw, "PRIVATE KEY", "RSA PRIVATE KEY")
|
||||
// ResolveWaffoPancakeSubscriptionTradeNo is the SubscriptionOrder counterpart
|
||||
// of ResolveWaffoPancakeTradeNo.
|
||||
func ResolveWaffoPancakeSubscriptionTradeNo(event *WaffoPancakeWebhookEvent) (string, error) {
|
||||
if event == nil {
|
||||
return "", fmt.Errorf("missing webhook event")
|
||||
}
|
||||
tradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
if tradeNo == "" {
|
||||
return "", fmt.Errorf("missing webhook orderMerchantExternalId")
|
||||
}
|
||||
order := model.GetSubscriptionOrderByTradeNo(tradeNo)
|
||||
if order == nil || order.PaymentProvider != model.PaymentProviderWaffoPancake {
|
||||
return "", fmt.Errorf("waffo pancake subscription order not found for tradeNo=%s", tradeNo)
|
||||
}
|
||||
expectedIdentity := WaffoPancakeBuyerIdentityFromUserID(order.UserId)
|
||||
actualIdentity := strings.TrimSpace(event.Data.MerchantProvidedBuyerIdentity)
|
||||
if actualIdentity != expectedIdentity {
|
||||
return "", fmt.Errorf(
|
||||
"waffo pancake buyer identity mismatch for subscription tradeNo=%s: expected=%q actual=%q",
|
||||
tradeNo,
|
||||
expectedIdentity,
|
||||
actualIdentity,
|
||||
)
|
||||
}
|
||||
return tradeNo, nil
|
||||
}
|
||||
|
||||
func normalizeRSAPublicKey(raw string) (string, error) {
|
||||
return normalizePEMKey(raw, "PUBLIC KEY", "RSA PUBLIC KEY")
|
||||
}
|
||||
// Deterministic default names for "+ Create": stable bodies mean stable
|
||||
// X-Idempotency-Key, which lets Pancake dedupe retries server-side.
|
||||
const (
|
||||
defaultWaffoPancakeStoreName = "new-api-store"
|
||||
defaultWaffoPancakeProductName = "new-api-charge-product"
|
||||
)
|
||||
|
||||
func normalizePEMKey(raw string, pkcs8Type string, pkcs1Type string) (string, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return "", fmt.Errorf("%s is empty", strings.ToLower(pkcs8Type))
|
||||
}
|
||||
|
||||
normalized := strings.TrimSpace(strings.ReplaceAll(raw, `\n`, "\n"))
|
||||
if strings.Contains(normalized, "BEGIN ") {
|
||||
block, _ := pem.Decode([]byte(normalized))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid PEM encoded %s", strings.ToLower(pkcs8Type))
|
||||
}
|
||||
return string(pem.EncodeToMemory(block)), nil
|
||||
}
|
||||
|
||||
der, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(normalized, "\n", ""))
|
||||
// CreateWaffoPancakePrimaryStore creates a Pancake Store using in-flight
|
||||
// (not-yet-persisted) credentials and returns the new store ID.
|
||||
func CreateWaffoPancakePrimaryStore(ctx context.Context, merchantID, privateKey string) (string, error) {
|
||||
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid base64 encoded %s: %w", strings.ToLower(pkcs8Type), err)
|
||||
return "", err
|
||||
}
|
||||
|
||||
pemType := pkcs8Type
|
||||
if pkcs8Type == "PRIVATE KEY" {
|
||||
if _, err := x509.ParsePKCS8PrivateKey(der); err != nil {
|
||||
if _, err := x509.ParsePKCS1PrivateKey(der); err == nil {
|
||||
pemType = pkcs1Type
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid RSA private key")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err := x509.ParsePKIXPublicKey(der); err != nil {
|
||||
if _, err := x509.ParsePKCS1PublicKey(der); err == nil {
|
||||
pemType = pkcs1Type
|
||||
} else {
|
||||
return "", fmt.Errorf("invalid RSA public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: pemType, Bytes: der})), nil
|
||||
}
|
||||
|
||||
func signWaffoPancakeRequest(method string, path string, timestamp string, body string, privateKeyPEM string) (string, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid RSA private key PEM")
|
||||
}
|
||||
|
||||
var privateKey *rsa.PrivateKey
|
||||
switch block.Type {
|
||||
case "PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse PKCS#8 private key: %w", err)
|
||||
}
|
||||
parsed, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not RSA")
|
||||
}
|
||||
privateKey = parsed
|
||||
case "RSA PRIVATE KEY":
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse PKCS#1 private key: %w", err)
|
||||
}
|
||||
privateKey = key
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported private key type: %s", block.Type)
|
||||
}
|
||||
|
||||
canonicalRequest := buildWaffoPancakeCanonicalRequest(method, path, timestamp, body)
|
||||
digest := sha256.Sum256([]byte(canonicalRequest))
|
||||
signature, err := rsa.SignPKCS1v15(nil, privateKey, crypto.SHA256, digest[:])
|
||||
storeRes, err := client.Stores.Create(ctx, pancake.CreateStoreParams{
|
||||
Name: defaultWaffoPancakeStoreName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign Waffo Pancake request: %w", err)
|
||||
return "", fmt.Errorf("create Waffo Pancake store: %w", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
return storeRes.Store.ID, nil
|
||||
}
|
||||
|
||||
func buildWaffoPancakeCanonicalRequest(method string, path string, timestamp string, body string) string {
|
||||
bodyHash := sha256.Sum256([]byte(body))
|
||||
return fmt.Sprintf(
|
||||
"%s\n%s\n%s\n%s",
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
timestamp,
|
||||
base64.StdEncoding.EncodeToString(bodyHash[:]),
|
||||
)
|
||||
}
|
||||
|
||||
func verifyWaffoPancakeWebhook(payload string, signatureHeader string, environment string) (*waffoPancakeWebhookEvent, error) {
|
||||
if signatureHeader == "" {
|
||||
return nil, fmt.Errorf("missing X-Waffo-Signature header")
|
||||
// CreateWaffoPancakeProductForPlan mints (and publishes) a Pancake
|
||||
// OnetimeProduct priced at `amount` USD, used as a subscription plan's
|
||||
// SubscriptionPlan.WaffoPancakeProductId.
|
||||
//
|
||||
// OnetimeProduct (not SubscriptionProduct) because new-api has no renewal-
|
||||
// event handling; Pancake auto-renewing without new-api extending user
|
||||
// access would be a UX divergence. Revisit if renewal handling is added.
|
||||
func CreateWaffoPancakeProductForPlan(ctx context.Context, merchantID, privateKey, storeID, name, amount, returnURL string) (string, error) {
|
||||
storeID = strings.TrimSpace(storeID)
|
||||
if storeID == "" {
|
||||
return "", fmt.Errorf("store id is required to create a product")
|
||||
}
|
||||
|
||||
timestampPart, signaturePart := parseWaffoPancakeSignatureHeader(signatureHeader)
|
||||
if timestampPart == "" || signaturePart == "" {
|
||||
return nil, fmt.Errorf("malformed X-Waffo-Signature header")
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("plan name is required")
|
||||
}
|
||||
|
||||
timestampMs, err := strconv.ParseInt(timestampPart, 10, 64)
|
||||
amount = strings.TrimSpace(amount)
|
||||
if amount == "" {
|
||||
return "", fmt.Errorf("plan price is required")
|
||||
}
|
||||
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid timestamp in X-Waffo-Signature header")
|
||||
return "", err
|
||||
}
|
||||
if math.Abs(float64(time.Now().UnixMilli()-timestampMs)) > float64(waffoPancakeDefaultTolerance.Milliseconds()) {
|
||||
return nil, fmt.Errorf("webhook timestamp outside tolerance window")
|
||||
}
|
||||
|
||||
signatureInput := fmt.Sprintf("%s.%s", timestampPart, payload)
|
||||
if err := verifyWaffoPancakeWebhookWithKey(signatureInput, signaturePart, resolveWaffoPancakeWebhookPublicKey(environment)); err != nil {
|
||||
return nil, fmt.Errorf("invalid webhook signature")
|
||||
}
|
||||
|
||||
var event waffoPancakeWebhookEvent
|
||||
if err := common.Unmarshal([]byte(payload), &event); err != nil {
|
||||
return nil, fmt.Errorf("parse Waffo Pancake webhook payload: %w", err)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func parseWaffoPancakeSignatureHeader(header string) (string, string) {
|
||||
var timestampPart string
|
||||
var signaturePart string
|
||||
for _, pair := range strings.Split(header, ",") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(pair), "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "t":
|
||||
timestampPart = value
|
||||
case "v1":
|
||||
signaturePart = value
|
||||
}
|
||||
}
|
||||
return timestampPart, signaturePart
|
||||
}
|
||||
|
||||
func resolveWaffoPancakeWebhookEnvironment(payload string) string {
|
||||
var envelope struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := common.Unmarshal([]byte(payload), &envelope); err != nil {
|
||||
if setting.WaffoPancakeSandbox {
|
||||
return "test"
|
||||
}
|
||||
return "prod"
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(envelope.Mode)) {
|
||||
case "test":
|
||||
return "test"
|
||||
case "prod":
|
||||
return "prod"
|
||||
default:
|
||||
if setting.WaffoPancakeSandbox {
|
||||
return "test"
|
||||
}
|
||||
return "prod"
|
||||
}
|
||||
}
|
||||
|
||||
func resolveWaffoPancakeWebhookPublicKey(environment string) string {
|
||||
if environment == "prod" {
|
||||
return strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
|
||||
}
|
||||
return strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
|
||||
}
|
||||
|
||||
func verifyWaffoPancakeWebhookWithKey(signatureInput string, signaturePart string, rawPublicKey string) error {
|
||||
publicKeyPEM, err := normalizeRSAPublicKey(rawPublicKey)
|
||||
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
|
||||
StoreID: storeID,
|
||||
Name: name,
|
||||
Prices: pancake.Prices{
|
||||
"USD": {
|
||||
Amount: amount,
|
||||
TaxCategory: pancake.TaxCategory("saas"),
|
||||
},
|
||||
},
|
||||
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return "", fmt.Errorf("create Waffo Pancake plan product: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode([]byte(publicKeyPEM))
|
||||
if block == nil {
|
||||
return fmt.Errorf("invalid RSA public key PEM")
|
||||
productID := prodRes.Product.ID
|
||||
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
|
||||
return "", fmt.Errorf("publish Waffo Pancake plan product: %w", err)
|
||||
}
|
||||
return productID, nil
|
||||
}
|
||||
|
||||
var publicKey *rsa.PublicKey
|
||||
switch block.Type {
|
||||
case "PUBLIC KEY":
|
||||
key, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PKIX public key: %w", err)
|
||||
}
|
||||
parsed, ok := key.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("public key is not RSA")
|
||||
}
|
||||
publicKey = parsed
|
||||
case "RSA PUBLIC KEY":
|
||||
key, err := x509.ParsePKCS1PublicKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PKCS#1 public key: %w", err)
|
||||
}
|
||||
publicKey = key
|
||||
default:
|
||||
return fmt.Errorf("unsupported public key type: %s", block.Type)
|
||||
// CreateWaffoPancakePrimaryProduct mints (and publishes) the wallet-top-up
|
||||
// OnetimeProduct under storeID. Per-checkout price overrides via PriceSnapshot
|
||||
// are what make the "1.00" seed price irrelevant at runtime.
|
||||
func CreateWaffoPancakePrimaryProduct(ctx context.Context, merchantID, privateKey, storeID, returnURL string) (string, error) {
|
||||
storeID = strings.TrimSpace(storeID)
|
||||
if storeID == "" {
|
||||
return "", fmt.Errorf("store id is required to create a product")
|
||||
}
|
||||
|
||||
signature, err := base64.StdEncoding.DecodeString(signaturePart)
|
||||
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode webhook signature: %w", err)
|
||||
return "", err
|
||||
}
|
||||
prodRes, err := client.OnetimeProducts.Create(ctx, pancake.CreateOnetimeProductParams{
|
||||
StoreID: storeID,
|
||||
Name: defaultWaffoPancakeProductName,
|
||||
Prices: pancake.Prices{
|
||||
"USD": {
|
||||
Amount: "1.00", // overridden at checkout via PriceSnapshot
|
||||
TaxCategory: pancake.TaxCategory("saas"),
|
||||
},
|
||||
},
|
||||
SuccessURL: optionalString(strings.TrimSpace(returnURL)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create Waffo Pancake product: %w", err)
|
||||
}
|
||||
productID := prodRes.Product.ID
|
||||
if _, err := client.OnetimeProducts.Publish(ctx, pancake.PublishOnetimeProductParams{ID: productID}); err != nil {
|
||||
return "", fmt.Errorf("publish Waffo Pancake product: %w", err)
|
||||
}
|
||||
return productID, nil
|
||||
}
|
||||
|
||||
digest := sha256.Sum256([]byte(signatureInput))
|
||||
if err := rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, digest[:], signature); err != nil {
|
||||
return fmt.Errorf("verify webhook signature: %w", err)
|
||||
// WaffoPancakePairResult is the response of CreateWaffoPancakePrimaryPair.
|
||||
// When OrphanStore is true the store was created but the product wasn't,
|
||||
// so the caller can surface a partial-failure message with StoreID.
|
||||
type WaffoPancakePairResult struct {
|
||||
StoreID string
|
||||
StoreName string
|
||||
ProductID string
|
||||
ProductName string
|
||||
OrphanStore bool
|
||||
}
|
||||
|
||||
// CreateWaffoPancakePrimaryPair mints a Store + OnetimeProduct in one
|
||||
// round-trip — the canonical "+ Create" entry point. Nothing is persisted
|
||||
// to settings; the operator's final Save commits the chosen IDs.
|
||||
func CreateWaffoPancakePrimaryPair(ctx context.Context, merchantID, privateKey, returnURL string) (*WaffoPancakePairResult, error) {
|
||||
storeID, err := CreateWaffoPancakePrimaryStore(ctx, merchantID, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
productID, err := CreateWaffoPancakePrimaryProduct(ctx, merchantID, privateKey, storeID, returnURL)
|
||||
if err != nil {
|
||||
return &WaffoPancakePairResult{
|
||||
StoreID: storeID,
|
||||
StoreName: defaultWaffoPancakeStoreName,
|
||||
OrphanStore: true,
|
||||
}, fmt.Errorf("store created at %s but product creation failed: %w", storeID, err)
|
||||
}
|
||||
return &WaffoPancakePairResult{
|
||||
StoreID: storeID,
|
||||
StoreName: defaultWaffoPancakeStoreName,
|
||||
ProductID: productID,
|
||||
ProductName: defaultWaffoPancakeProductName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveWaffoPancakeConfig persists the operator-controlled fields atomically
|
||||
// at the end of the configuration flow via model.UpdateOptionsBulk (single
|
||||
// DB transaction). A blank privateKey is treated as "keep current"
|
||||
// (Stripe-style API-secret UX) and is omitted from the bulk payload.
|
||||
func SaveWaffoPancakeConfig(ctx context.Context, merchantID, privateKey, returnURL, storeID, productID string) error {
|
||||
merchantID = strings.TrimSpace(merchantID)
|
||||
storeID = strings.TrimSpace(storeID)
|
||||
productID = strings.TrimSpace(productID)
|
||||
if merchantID == "" || storeID == "" || productID == "" {
|
||||
return fmt.Errorf("merchant id, store id, and product id are required to save")
|
||||
}
|
||||
values := map[string]string{
|
||||
"WaffoPancakeMerchantID": merchantID,
|
||||
"WaffoPancakeReturnURL": strings.TrimSpace(returnURL),
|
||||
"WaffoPancakeStoreID": storeID,
|
||||
"WaffoPancakeProductID": productID,
|
||||
}
|
||||
if pk := strings.TrimSpace(privateKey); pk != "" {
|
||||
values["WaffoPancakePrivateKey"] = pk
|
||||
}
|
||||
if err := model.UpdateOptionsBulk(values); err != nil {
|
||||
return fmt.Errorf("persist Waffo Pancake config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type WaffoPancakeCatalogProduct struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// WaffoPancakeCatalogStore nests its OnetimeProducts so the UI can render a
|
||||
// dependent store→product select without a second round-trip.
|
||||
type WaffoPancakeCatalogStore struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ProdEnabled bool `json:"prodEnabled"`
|
||||
OnetimeProducts []WaffoPancakeCatalogProduct `json:"onetimeProducts"`
|
||||
}
|
||||
|
||||
type WaffoPancakeCatalog struct {
|
||||
Stores []WaffoPancakeCatalogStore `json:"stores"`
|
||||
}
|
||||
|
||||
// ListWaffoPancakeCatalog queries Pancake's GraphQL `stores` for the
|
||||
// merchant's stores + onetime products. A successful call also proves
|
||||
// the supplied credentials authenticate (doubles as a credential probe).
|
||||
func ListWaffoPancakeCatalog(ctx context.Context, merchantID, privateKey string) (*WaffoPancakeCatalog, error) {
|
||||
client, err := newWaffoPancakeClientFromCreds(merchantID, privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type queryShape struct {
|
||||
Stores []WaffoPancakeCatalogStore `json:"stores"`
|
||||
}
|
||||
// `limit: 100` because the API returns a single store when limit is
|
||||
// omitted, even for multi-store merchants. Bump to paginated fetches
|
||||
// (via `offset`) if real catalogs ever cross the cap.
|
||||
resp, err := pancake.GraphQLQuery[queryShape](ctx, client, pancake.GraphQLParams{
|
||||
Query: `query {
|
||||
stores(limit: 100) {
|
||||
id
|
||||
name
|
||||
status
|
||||
prodEnabled
|
||||
onetimeProducts {
|
||||
id
|
||||
name
|
||||
status
|
||||
}
|
||||
}
|
||||
}`,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query Waffo Pancake catalog: %w", err)
|
||||
}
|
||||
if len(resp.Errors) > 0 {
|
||||
return nil, fmt.Errorf("waffo pancake catalog query returned %d errors: %s",
|
||||
len(resp.Errors), resp.Errors[0].Message)
|
||||
}
|
||||
// Drop non-active products. Operators should only see items they can
|
||||
// actually bind without later hitting "product unavailable" at checkout.
|
||||
stores := resp.Data.Stores
|
||||
for i := range stores {
|
||||
active := stores[i].OnetimeProducts[:0]
|
||||
for _, p := range stores[i].OnetimeProducts {
|
||||
if strings.EqualFold(strings.TrimSpace(p.Status), "active") {
|
||||
active = append(active, p)
|
||||
}
|
||||
}
|
||||
stores[i].OnetimeProducts = active
|
||||
}
|
||||
return &WaffoPancakeCatalog{Stores: stores}, nil
|
||||
}
|
||||
|
||||
+200
-78
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -8,7 +9,6 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
@@ -29,7 +29,7 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
|
||||
model.DB = db
|
||||
model.LOG_DB = db
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.TopUp{}, &model.SubscriptionOrder{}))
|
||||
|
||||
t.Cleanup(func() {
|
||||
sqlDB, err := db.DB()
|
||||
@@ -41,44 +41,101 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB {
|
||||
return db
|
||||
}
|
||||
|
||||
func TestWaffoPancakeCreateSessionResponseParsesDocumentedPayload(t *testing.T) {
|
||||
var result waffoPancakeCreateSessionResponse
|
||||
err := common.Unmarshal([]byte(`{
|
||||
"data": {
|
||||
"sessionId": "cs_550e8400-e29b-41d4-a716-446655440000",
|
||||
"checkoutUrl": "https://checkout.waffo.ai/my-store-abc123/checkout/cs_550e8400-e29b-41d4-a716-446655440000",
|
||||
"expiresAt": "2026-01-22T10:30:00.000Z"
|
||||
}
|
||||
}`), &result)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result.Data)
|
||||
require.Equal(t, "cs_550e8400-e29b-41d4-a716-446655440000", result.Data.SessionID)
|
||||
require.Empty(t, result.Data.OrderID)
|
||||
func TestCreateWaffoPancakeCheckoutSession_RequiresOrderMerchantExternalID(t *testing.T) {
|
||||
session, err := CreateWaffoPancakeCheckoutSession(context.Background(), &WaffoPancakeCreateSessionParams{
|
||||
ProductID: "PROD_checkout_guard",
|
||||
BuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(1),
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Nil(t, session)
|
||||
require.Contains(t, err.Error(), "missing order merchant external id")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 1,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
UserId: 1,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
|
||||
Data: waffoPancakeWebhookData{
|
||||
OrderID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_5dXBtmF2HLlHfbPNm0Wcnz",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(topUp.UserId),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ORD_5dXBtmF2HLlHfbPNm0Wcnz", tradeNo)
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 42,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_identity_mismatch_case",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
// Webhook reports the right order but a different buyer — could be a
|
||||
// crossed-wires bug or a tampered payload. Either way: reject.
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_identity_mismatch_case",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
require.Contains(t, err.Error(), "buyer identity mismatch")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: 7,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "ORD_missing_identity",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
// An empty MerchantProvidedBuyerIdentity means the order was either created
|
||||
// via the (now-deprecated) anonymous flow or the field was stripped — also
|
||||
// reject so that we never credit anonymous orders to a specific user.
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "ORD_missing_identity",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
require.Contains(t, err.Error(), "buyer identity mismatch")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
@@ -91,67 +148,132 @@ func TestResolveWaffoPancakeTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.
|
||||
require.NoError(t, db.Create(user).Error)
|
||||
|
||||
topUp := &model.TopUp{
|
||||
UserId: user.Id,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
UserId: user.Id,
|
||||
Amount: 10,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE-42-123456-abc123",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(topUp).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&waffoPancakeWebhookEvent{
|
||||
Data: waffoPancakeWebhookData{
|
||||
OrderID: "ORD_unknown",
|
||||
BuyerEmail: user.Email,
|
||||
Amount: "29.00",
|
||||
tradeNo, err := ResolveWaffoPancakeTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE-unknown",
|
||||
BuyerEmail: user.Email,
|
||||
Amount: "29.00",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeWebhookEnvironment(t *testing.T) {
|
||||
originalSandbox := setting.WaffoPancakeSandbox
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoPancakeSandbox = originalSandbox
|
||||
// Parity tests for ResolveWaffoPancakeSubscriptionTradeNo — same four cases
|
||||
// as the TopUp resolver above, exercised against SubscriptionOrder records.
|
||||
// Drift between the two webhook flows is a real risk because they share
|
||||
// the same buyer-identity defence-in-depth pattern.
|
||||
|
||||
func TestResolveWaffoPancakeSubscriptionTradeNo_UsesWebhookOrderIDWhenLocalOrderExists(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: 1,
|
||||
PlanId: 5,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(order).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-1-1700000000-abc123",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(order.UserId),
|
||||
},
|
||||
})
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload string
|
||||
expected string
|
||||
sandbox bool
|
||||
}{
|
||||
{
|
||||
name: "test mode",
|
||||
payload: `{"mode":"test"}`,
|
||||
expected: "test",
|
||||
},
|
||||
{
|
||||
name: "prod mode",
|
||||
payload: `{"mode":"prod"}`,
|
||||
expected: "prod",
|
||||
},
|
||||
{
|
||||
name: "missing mode falls back to sandbox",
|
||||
payload: `{}`,
|
||||
expected: "test",
|
||||
sandbox: true,
|
||||
},
|
||||
{
|
||||
name: "invalid mode falls back to prod",
|
||||
payload: `{"mode":"staging"}`,
|
||||
expected: "prod",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setting.WaffoPancakeSandbox = tc.sandbox
|
||||
environment := resolveWaffoPancakeWebhookEnvironment(tc.payload)
|
||||
require.Equal(t, tc.expected, environment)
|
||||
})
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "WAFFO_PANCAKE_SUB-1-1700000000-abc123", tradeNo)
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsBuyerIdentityMismatch(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: 42,
|
||||
PlanId: 5,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE_SUB-42-mismatch",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(order).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderID: "ORD_internal_pancake_id",
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-42-mismatch",
|
||||
MerchantProvidedBuyerIdentity: WaffoPancakeBuyerIdentityFromUserID(99), // wrong user
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
require.Contains(t, err.Error(), "buyer identity mismatch")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeSubscriptionTradeNo_RejectsMissingBuyerIdentity(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: 7,
|
||||
PlanId: 5,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE_SUB-7-missing-identity",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(order).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-7-missing-identity",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
require.Contains(t, err.Error(), "buyer identity mismatch")
|
||||
}
|
||||
|
||||
func TestResolveWaffoPancakeSubscriptionTradeNo_FailsWhenWebhookOrderIDIsUnknown(t *testing.T) {
|
||||
db := setupWaffoPancakeTestDB(t)
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: 42,
|
||||
PlanId: 5,
|
||||
Money: 29,
|
||||
TradeNo: "WAFFO_PANCAKE_SUB-42-real-order",
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
require.NoError(t, db.Create(order).Error)
|
||||
|
||||
tradeNo, err := ResolveWaffoPancakeSubscriptionTradeNo(&WaffoPancakeWebhookEvent{
|
||||
Data: WaffoPancakeWebhookData{
|
||||
OrderMerchantExternalID: "WAFFO_PANCAKE_SUB-unknown",
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, tradeNo)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package setting
|
||||
|
||||
// Waffo Pancake hosted checkout configuration. Gateway is enabled once
|
||||
// MerchantID + PrivateKey + ProductID are populated (no separate Enabled
|
||||
// flag, matching Stripe / Creem). StoreID + ProductID are operator-bound
|
||||
// via SaveWaffoPancakeConfig.
|
||||
var (
|
||||
WaffoPancakeEnabled bool
|
||||
WaffoPancakeSandbox bool
|
||||
WaffoPancakeMerchantID string
|
||||
WaffoPancakePrivateKey string
|
||||
WaffoPancakeWebhookPublicKey string
|
||||
WaffoPancakeWebhookTestKey string
|
||||
WaffoPancakeStoreID string
|
||||
WaffoPancakeProductID string
|
||||
WaffoPancakeReturnURL string
|
||||
WaffoPancakeCurrency string = "USD"
|
||||
WaffoPancakeUnitPrice float64 = 1.0
|
||||
WaffoPancakeMinTopUp int = 1
|
||||
WaffoPancakeMerchantID string
|
||||
WaffoPancakePrivateKey string
|
||||
WaffoPancakeReturnURL string
|
||||
WaffoPancakeUnitPrice float64 = 1.0
|
||||
WaffoPancakeMinTopUp int = 1
|
||||
WaffoPancakeStoreID string
|
||||
WaffoPancakeProductID string
|
||||
)
|
||||
|
||||
@@ -71,6 +71,13 @@ var defaultCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 0.1,
|
||||
"claude-opus-4-7-medium": 0.1,
|
||||
"claude-opus-4-7-low": 0.1,
|
||||
"claude-opus-4-8": 0.1,
|
||||
"claude-opus-4-8-thinking": 0.1,
|
||||
"claude-opus-4-8-max": 0.1,
|
||||
"claude-opus-4-8-xhigh": 0.1,
|
||||
"claude-opus-4-8-high": 0.1,
|
||||
"claude-opus-4-8-medium": 0.1,
|
||||
"claude-opus-4-8-low": 0.1,
|
||||
}
|
||||
|
||||
var defaultCreateCacheRatio = map[string]float64{
|
||||
@@ -106,6 +113,13 @@ var defaultCreateCacheRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 1.25,
|
||||
"claude-opus-4-7-medium": 1.25,
|
||||
"claude-opus-4-7-low": 1.25,
|
||||
"claude-opus-4-8": 1.25,
|
||||
"claude-opus-4-8-thinking": 1.25,
|
||||
"claude-opus-4-8-max": 1.25,
|
||||
"claude-opus-4-8-xhigh": 1.25,
|
||||
"claude-opus-4-8-high": 1.25,
|
||||
"claude-opus-4-8-medium": 1.25,
|
||||
"claude-opus-4-8-low": 1.25,
|
||||
}
|
||||
|
||||
//var defaultCreateCacheRatio = map[string]float64{}
|
||||
|
||||
@@ -152,6 +152,12 @@ var defaultModelRatio = map[string]float64{
|
||||
"claude-opus-4-7-high": 2.5,
|
||||
"claude-opus-4-7-medium": 2.5,
|
||||
"claude-opus-4-7-low": 2.5,
|
||||
"claude-opus-4-8": 2.5,
|
||||
"claude-opus-4-8-max": 2.5,
|
||||
"claude-opus-4-8-xhigh": 2.5,
|
||||
"claude-opus-4-8-high": 2.5,
|
||||
"claude-opus-4-8-medium": 2.5,
|
||||
"claude-opus-4-8-low": 2.5,
|
||||
"claude-3-opus-20240229": 7.5, // $15 / 1M tokens
|
||||
"claude-opus-4-20250514": 7.5,
|
||||
"claude-opus-4-1-20250805": 7.5,
|
||||
|
||||
Vendored
+1245
-526
File diff suppressed because it is too large
Load Diff
Vendored
-2379
File diff suppressed because it is too large
Load Diff
Vendored
-1
@@ -24,6 +24,5 @@
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Vendored
+21
-20
@@ -4,30 +4,32 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-illustrations": "^2.69.1",
|
||||
"@douyinfe/semi-icons": "^2.63.1",
|
||||
"@douyinfe/semi-ui": "^2.69.1",
|
||||
"@lobehub/icons": "^2.0.0",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@visactor/react-vchart": "~1.8.8",
|
||||
"@visactor/vchart": "~1.8.8",
|
||||
"@visactor/vchart-semi-theme": "~1.8.8",
|
||||
"axios": "1.15.2",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.11",
|
||||
"axios": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"dayjs": "catalog:",
|
||||
"history": "^5.3.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.511.0",
|
||||
"marked": "^4.1.1",
|
||||
"mermaid": "^11.6.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-fireworks": "^1.0.4",
|
||||
"react-i18next": "^13.0.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-telegram-login": "^1.1.2",
|
||||
"react-toastify": "^9.0.8",
|
||||
@@ -35,20 +37,20 @@
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "catalog:",
|
||||
"remark-math": "^6.0.0",
|
||||
"sse.js": "^2.6.0",
|
||||
"sse.js": "catalog:",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-debounce": "^10.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"dev": "rsbuild dev",
|
||||
"build": "rsbuild build",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
|
||||
"eslint:fix": "bunx eslint \"**/*.{js,jsx}\" --fix --cache",
|
||||
"preview": "vite preview",
|
||||
"preview": "rsbuild preview",
|
||||
"i18n:extract": "bunx i18next-cli extract",
|
||||
"i18n:status": "bunx i18next-cli status",
|
||||
"i18n:sync": "bunx i18next-cli sync",
|
||||
@@ -73,20 +75,19 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@douyinfe/vite-plugin-semi": "^2.74.0-alpha.6",
|
||||
"@rsbuild/core": "^2.0.7",
|
||||
"@rsbuild/plugin-react": "^2.0.0",
|
||||
"@so1ve/prettier-config": "^3.1.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"code-inspector-plugin": "^1.3.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"i18next-cli": "^1.10.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.0.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"prettier": "catalog:",
|
||||
"tailwindcss": "^3",
|
||||
"typescript": "4.4.2",
|
||||
"vite": "^5.2.0"
|
||||
"typescript": "4.4.2"
|
||||
},
|
||||
"prettier": {
|
||||
"singleQuote": true,
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
|
||||
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
|
||||
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
|
||||
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
|
||||
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Vendored
+106
@@ -0,0 +1,106 @@
|
||||
import path from 'path'
|
||||
import { createRequire } from 'module'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { defineConfig, loadEnv } from '@rsbuild/core'
|
||||
import { pluginReact } from '@rsbuild/plugin-react'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const semiUiDir = path.resolve(
|
||||
path.dirname(require.resolve('@douyinfe/semi-ui')),
|
||||
'../..',
|
||||
)
|
||||
|
||||
export default defineConfig(({ envMode }) => {
|
||||
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
|
||||
const clientServerUrl =
|
||||
process.env.VITE_REACT_APP_SERVER_URL ||
|
||||
env.rawPublicVars.VITE_REACT_APP_SERVER_URL ||
|
||||
''
|
||||
const proxyServerUrl =
|
||||
clientServerUrl ||
|
||||
'http://localhost:3000'
|
||||
const isProd = envMode === 'production'
|
||||
const devProxy = Object.fromEntries(
|
||||
(['/api', '/mj', '/pg'] as const).map((key) => [
|
||||
key,
|
||||
{ target: proxyServerUrl, changeOrigin: true },
|
||||
]),
|
||||
) as Record<string, { target: string; changeOrigin: boolean }>
|
||||
|
||||
return {
|
||||
plugins: [pluginReact()],
|
||||
source: {
|
||||
entry: {
|
||||
index: './src/index.jsx',
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_REACT_APP_SERVER_URL': JSON.stringify(
|
||||
clientServerUrl,
|
||||
),
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
|
||||
semiUiDir,
|
||||
'dist/css/semi.css',
|
||||
),
|
||||
},
|
||||
},
|
||||
html: {
|
||||
template: './index.html',
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
minify: isProd,
|
||||
target: 'web',
|
||||
distPath: {
|
||||
root: 'dist',
|
||||
},
|
||||
},
|
||||
performance: {
|
||||
removeConsole: isProd ? ['log'] : false,
|
||||
buildCache: {
|
||||
cacheDigest: [process.env.VITE_REACT_APP_VERSION],
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
rspack: {
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /src[\\/].*\.js$/,
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: 'builtin:swc-loader',
|
||||
options: {
|
||||
jsc: {
|
||||
parser: {
|
||||
syntax: 'ecmascript',
|
||||
jsx: true,
|
||||
},
|
||||
transform: {
|
||||
react: {
|
||||
runtime: 'automatic',
|
||||
development: !isProd,
|
||||
refresh: !isProd,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
+1
-1
@@ -947,7 +947,7 @@ const LoginForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -104,7 +104,7 @@ const PasswordResetConfirm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -104,7 +104,7 @@ const PasswordResetForm = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
+1
-1
@@ -770,7 +770,7 @@ const RegisterForm = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='classic-page-fill relative overflow-hidden bg-gray-100 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div
|
||||
className='blur-ball blur-ball-indigo'
|
||||
|
||||
@@ -141,7 +141,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 显示加载状态
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen'>
|
||||
<div className='classic-page-fill flex justify-center items-center'>
|
||||
<Spin size='large' />
|
||||
</div>
|
||||
);
|
||||
@@ -150,7 +150,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果没有内容,显示空状态
|
||||
if (!content || content.trim() === '') {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={
|
||||
@@ -168,7 +168,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 URL,显示链接卡片
|
||||
if (isUrl(content)) {
|
||||
return (
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<div className='classic-page-fill flex justify-center items-center bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>
|
||||
@@ -196,7 +196,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
@@ -214,7 +214,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
|
||||
// 其他内容统一使用 Markdown 渲染器
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='classic-page-fill bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
|
||||
+10
-5
@@ -71,6 +71,7 @@ const PageLayout = () => {
|
||||
|
||||
const isConsoleRoute = location.pathname.startsWith('/console');
|
||||
const showSider = isConsoleRoute && (!isMobile || drawerOpen);
|
||||
const isFixedLayout = isConsoleRoute || location.pathname === '/pricing';
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile && drawerOpen && collapsed) {
|
||||
@@ -146,11 +147,11 @@ const PageLayout = () => {
|
||||
|
||||
return (
|
||||
<Layout
|
||||
className='app-layout'
|
||||
className={`app-layout${isFixedLayout ? ' app-layout-fixed' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: isMobile ? 'visible' : 'hidden',
|
||||
overflow: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
<Header
|
||||
@@ -171,9 +172,10 @@ const PageLayout = () => {
|
||||
</Header>
|
||||
<Layout
|
||||
style={{
|
||||
overflow: isMobile ? 'visible' : 'auto',
|
||||
overflow: isFixedLayout && !isMobile ? 'auto' : 'visible',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: '1 1 auto',
|
||||
}}
|
||||
>
|
||||
{showSider && (
|
||||
@@ -206,15 +208,18 @@ const PageLayout = () => {
|
||||
flex: '1 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
className={isFixedLayout ? undefined : 'public-page-content'}
|
||||
style={{
|
||||
flex: '1 0 auto',
|
||||
overflowY: isMobile ? 'visible' : 'hidden',
|
||||
flex: isFixedLayout ? '1 0 auto' : '1 1 auto',
|
||||
overflowY: isFixedLayout && !isMobile ? 'hidden' : 'visible',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
padding: shouldInnerPadding ? (isMobile ? '5px' : '24px') : '0',
|
||||
position: 'relative',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
|
||||
@@ -24,7 +24,6 @@ import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentG
|
||||
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
|
||||
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
|
||||
import SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
|
||||
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
|
||||
import { API, showError, showSuccess, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../common/modals/RiskAcknowledgementModal';
|
||||
@@ -53,16 +52,6 @@ const PaymentSetting = () => {
|
||||
StripeMinTopUp: 1,
|
||||
StripePromotionCodesEnabled: false,
|
||||
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1.0,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
'payment_setting.compliance_confirmed_at': 0,
|
||||
@@ -171,21 +160,8 @@ const PaymentSetting = () => {
|
||||
case 'MinTopUp':
|
||||
case 'StripeUnitPrice':
|
||||
case 'StripeMinTopUp':
|
||||
case 'WaffoPancakeUnitPrice':
|
||||
case 'WaffoPancakeMinTopUp':
|
||||
newInputs[item.key] = parseFloat(item.value);
|
||||
break;
|
||||
case 'WaffoPancakeMerchantID':
|
||||
case 'WaffoPancakePrivateKey':
|
||||
case 'WaffoPancakeStoreID':
|
||||
case 'WaffoPancakeProductID':
|
||||
case 'WaffoPancakeReturnURL':
|
||||
case 'WaffoPancakeCurrency':
|
||||
newInputs[item.key] = item.value;
|
||||
break;
|
||||
case 'WaffoPancakeSandbox':
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
break;
|
||||
default:
|
||||
if (item.key.endsWith('Enabled')) {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
@@ -327,13 +303,6 @@ const PaymentSetting = () => {
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
|
||||
{/* <SettingsPaymentGatewayWaffoPancake*/}
|
||||
{/* options={inputs}*/}
|
||||
{/* refresh={onRefresh}*/}
|
||||
{/* hideSectionTitle*/}
|
||||
{/* />*/}
|
||||
{/*</Tabs.TabPane>*/}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
+14
-3
@@ -47,6 +47,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { IconGift } from '@douyinfe/semi-icons';
|
||||
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
|
||||
import { useActualTheme } from '../../context/Theme';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
||||
|
||||
@@ -102,6 +103,7 @@ const RechargeCard = ({
|
||||
const redeemFormApiRef = useRef(null);
|
||||
const initialTabSetRef = useRef(false);
|
||||
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
||||
const actualTheme = useActualTheme();
|
||||
const [activeTab, setActiveTab] = useState('topup');
|
||||
const shouldShowSubscription =
|
||||
!subscriptionLoading && subscriptionPlans.length > 0;
|
||||
@@ -355,9 +357,18 @@ const RechargeCard = ({
|
||||
}}
|
||||
/>
|
||||
) : payMethod.type === 'waffo_pancake' ? (
|
||||
<CreditCard
|
||||
size={18}
|
||||
color='var(--semi-color-primary)'
|
||||
<img
|
||||
src={
|
||||
actualTheme === 'dark'
|
||||
? '/waffo-logo-dark.svg'
|
||||
: '/waffo-logo-light.svg'
|
||||
}
|
||||
alt='Waffo'
|
||||
style={{
|
||||
width: 18,
|
||||
height: 18,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CreditCard
|
||||
|
||||
+23
-2
@@ -40,6 +40,23 @@ import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
|
||||
// Reject non-navigable schemes (e.g. javascript:, data:) and relative URLs.
|
||||
// Only http / https are allowed for backend-provided redirect targets.
|
||||
// Mirrors isSafeHttpCheckoutUrl in the default frontend's
|
||||
// features/wallet/hooks/use-waffo-pancake-payment.ts.
|
||||
function isSafeHttpCheckoutUrl(value) {
|
||||
const trimmed = (value || '').trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const TopUp = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -454,8 +471,12 @@ const TopUp = () => {
|
||||
const { message, data } = res.data;
|
||||
if (message === 'success') {
|
||||
const checkoutUrl = data?.checkout_url || '';
|
||||
if (checkoutUrl) {
|
||||
window.open(checkoutUrl, '_blank');
|
||||
if (checkoutUrl && isSafeHttpCheckoutUrl(checkoutUrl)) {
|
||||
// In-tab redirect (not window.open) — popup blocker fires after
|
||||
// the await loses user-gesture context.
|
||||
window.location.href = checkoutUrl;
|
||||
} else if (checkoutUrl) {
|
||||
showError(t('支付跳转地址不安全'));
|
||||
} else {
|
||||
showError(t('支付请求失败'));
|
||||
}
|
||||
|
||||
Vendored
+43
-9
@@ -17,12 +17,46 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
export * from './channel.constants';
|
||||
export * from './user.constants';
|
||||
export * from './toast.constants';
|
||||
export * from './common.constant';
|
||||
export * from './dashboard.constants';
|
||||
export * from './playground.constants';
|
||||
export * from './redemption.constants';
|
||||
export * from './channel-affinity-template.constants';
|
||||
export * from './billing.constants';
|
||||
export {
|
||||
CHANNEL_OPTIONS,
|
||||
MODEL_FETCHABLE_CHANNEL_TYPES,
|
||||
MODEL_TABLE_PAGE_SIZE,
|
||||
} from './channel.constants';
|
||||
export { userConstants } from './user.constants';
|
||||
export { toastConstants } from './toast.constants';
|
||||
export {
|
||||
ITEMS_PER_PAGE,
|
||||
DEFAULT_ENDPOINT,
|
||||
TABLE_COMPACT_MODES_KEY,
|
||||
API_ENDPOINTS,
|
||||
TASK_ACTION_GENERATE,
|
||||
TASK_ACTION_TEXT_GENERATE,
|
||||
TASK_ACTION_FIRST_TAIL_GENERATE,
|
||||
TASK_ACTION_REFERENCE_GENERATE,
|
||||
TASK_ACTION_REMIX_GENERATE,
|
||||
} from './common.constant';
|
||||
export {
|
||||
REDEMPTION_STATUS,
|
||||
REDEMPTION_STATUS_MAP,
|
||||
REDEMPTION_ACTIONS,
|
||||
} from './redemption.constants';
|
||||
export {
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_HEADERS,
|
||||
CODEX_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CLAUDE_CLI_HEADER_PASSTHROUGH_TEMPLATE,
|
||||
CHANNEL_AFFINITY_RULE_TEMPLATES,
|
||||
cloneChannelAffinityTemplate,
|
||||
} from './channel-affinity-template.constants';
|
||||
export {
|
||||
BILLING_VARS,
|
||||
BILLING_VAR_KEYS,
|
||||
BILLING_PRICING_VARS,
|
||||
BILLING_EXTRA_VARS,
|
||||
BILLING_VAR_KEY_TO_FIELD,
|
||||
BILLING_VAR_FIELD_TO_LABEL,
|
||||
BILLING_VAR_FIELD_TO_SHORT_LABEL,
|
||||
BILLING_CACHE_VAR_MAP,
|
||||
BILLING_VAR_REGEX,
|
||||
BILLING_CONDITION_VARS,
|
||||
} from './billing.constants';
|
||||
|
||||
Vendored
+2
-2
@@ -94,7 +94,6 @@ import {
|
||||
SiGitlab,
|
||||
SiGoogle,
|
||||
SiKeycloak,
|
||||
SiLinkedin,
|
||||
SiNextcloud,
|
||||
SiNotion,
|
||||
SiOkta,
|
||||
@@ -106,6 +105,7 @@ import {
|
||||
SiWechat,
|
||||
SiX,
|
||||
} from 'react-icons/si';
|
||||
import { FaLinkedin } from 'react-icons/fa';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
export function getLucideIcon(key, selected = false) {
|
||||
@@ -509,7 +509,7 @@ const oauthProviderIconMap = {
|
||||
google: SiGoogle,
|
||||
discord: SiDiscord,
|
||||
facebook: SiFacebook,
|
||||
linkedin: SiLinkedin,
|
||||
linkedin: FaLinkedin,
|
||||
x: SiX,
|
||||
twitter: SiX,
|
||||
slack: SiSlack,
|
||||
|
||||
Vendored
+1
-1
@@ -123,7 +123,7 @@ export function showError(error) {
|
||||
console.error(error);
|
||||
if (error.message) {
|
||||
if (error.name === 'AxiosError') {
|
||||
switch (error.response.status) {
|
||||
switch (error.response?.status) {
|
||||
case 401:
|
||||
// 清除用户状态
|
||||
localStorage.removeItem('user');
|
||||
|
||||
Vendored
+1
@@ -1720,6 +1720,7 @@
|
||||
"支付渠道": "Payment Channels",
|
||||
"支付设置": "Payment",
|
||||
"支付请求失败": "Payment request failed",
|
||||
"支付跳转地址不安全": "Unsafe payment redirect URL",
|
||||
"支付返回地址": "Return URL",
|
||||
"支付金额": "Payment Amount",
|
||||
"支持 Ctrl+V 粘贴图片": "Supports Ctrl+V to paste images",
|
||||
|
||||
Vendored
+1
@@ -1154,6 +1154,7 @@
|
||||
"支付方式": "支付方式",
|
||||
"支付设置": "支付设置",
|
||||
"支付请求失败": "支付请求失败",
|
||||
"支付跳转地址不安全": "支付跳转地址不安全",
|
||||
"支付金额": "支付金额",
|
||||
"支持 Ctrl+V 粘贴图片": "支持 Ctrl+V 粘贴图片",
|
||||
"支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。": "支持6位TOTP验证码或8位备用码,可到`个人设置-安全设置-两步验证设置`配置或查看。",
|
||||
|
||||
Vendored
+28
-6
@@ -31,18 +31,40 @@ body {
|
||||
background-color: var(--semi-color-bg-0);
|
||||
}
|
||||
|
||||
/* 桌面端禁止 body 纵向滚动 - 防止 VChart tooltip 触发页面滚动条 */
|
||||
@media (min-width: 768px) {
|
||||
body {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
.app-layout-fixed {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.public-page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-page-fill {
|
||||
flex: 1 0 auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.classic-home-page,
|
||||
.classic-home-default {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.classic-home-default {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.classic-home-hero {
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
|
||||
.app-sider {
|
||||
height: calc(100vh - 64px);
|
||||
height: calc(100dvh - 64px);
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
import '@douyinfe/semi-ui/react19-adapter';
|
||||
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
|
||||
+8
-3
@@ -133,9 +133,9 @@ const About = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='mt-[60px] px-2'>
|
||||
<div className='classic-page-fill flex flex-col pt-[60px] px-2'>
|
||||
{aboutLoaded && about === '' ? (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='flex flex-1 justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
@@ -156,7 +156,12 @@ const About = () => {
|
||||
{about.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={about}
|
||||
style={{ width: '100%', height: '100vh', border: 'none' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const Forbidden = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNoAccess style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
Vendored
+6
-6
@@ -149,20 +149,20 @@ const Home = () => {
|
||||
}, [endpointItems.length]);
|
||||
|
||||
return (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-page-fill classic-home-page w-full overflow-x-hidden'>
|
||||
<NoticeModal
|
||||
visible={noticeVisible}
|
||||
onClose={() => setNoticeVisible(false)}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
{homePageContentLoaded && homePageContent === '' ? (
|
||||
<div className='w-full overflow-x-hidden'>
|
||||
<div className='classic-home-default w-full overflow-x-hidden'>
|
||||
{/* Banner 部分 */}
|
||||
<div className='w-full border-b border-semi-color-border min-h-[500px] md:min-h-[600px] lg:min-h-[700px] relative overflow-x-hidden'>
|
||||
<div className='classic-home-hero w-full border-b border-semi-color-border relative overflow-x-hidden'>
|
||||
{/* 背景模糊晕染球 */}
|
||||
<div className='blur-ball blur-ball-indigo' />
|
||||
<div className='blur-ball blur-ball-teal' />
|
||||
<div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'>
|
||||
<div className='flex items-center justify-center px-4 pt-24 pb-8'>
|
||||
{/* 居中内容区 */}
|
||||
<div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'>
|
||||
<div className='flex flex-col items-center justify-center mb-6 md:mb-8'>
|
||||
@@ -335,11 +335,11 @@ const Home = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-x-hidden w-full'>
|
||||
<div className='classic-page-fill overflow-x-hidden w-full'>
|
||||
{homePageContent.startsWith('https://') ? (
|
||||
<iframe
|
||||
src={homePageContent}
|
||||
className='w-full h-screen border-none'
|
||||
className='w-full h-full border-none'
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
const NotFound = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className='flex justify-center items-center h-screen p-8'>
|
||||
<div className='classic-page-fill flex justify-center items-center p-8'>
|
||||
<Empty
|
||||
image={<IllustrationNotFound style={{ width: 250, height: 250 }} />}
|
||||
darkModeImage={
|
||||
|
||||
@@ -208,7 +208,7 @@ export default function SettingGlobalModel(props) {
|
||||
<Row>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
label={t('禁用思考处理的模型列表')}
|
||||
label={t('不自动处理思考后缀的模型列表')}
|
||||
field={'global.thinking_model_blacklist'}
|
||||
placeholder={t('例如:') + '\n' + thinkingExample}
|
||||
rows={4}
|
||||
|
||||
+30
-239
@@ -26,25 +26,14 @@ import {
|
||||
showSuccess,
|
||||
} from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BookOpen, TriangleAlert } from 'lucide-react';
|
||||
import { BookOpen } from 'lucide-react';
|
||||
|
||||
const defaultInputs = {
|
||||
WaffoPancakeEnabled: false,
|
||||
WaffoPancakeSandbox: false,
|
||||
WaffoPancakeMerchantID: '',
|
||||
WaffoPancakePrivateKey: '',
|
||||
WaffoPancakeWebhookPublicKey: '',
|
||||
WaffoPancakeWebhookTestKey: '',
|
||||
WaffoPancakeStoreID: '',
|
||||
WaffoPancakeProductID: '',
|
||||
WaffoPancakeReturnURL: '',
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1.0,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
};
|
||||
|
||||
const toBoolean = (value) => value === true || value === 'true';
|
||||
|
||||
export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
const { t } = useTranslation();
|
||||
const sectionTitle = props.hideSectionTitle
|
||||
@@ -58,26 +47,9 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
if (!props.options || !formApiRef.current) return;
|
||||
|
||||
const currentInputs = {
|
||||
WaffoPancakeEnabled: toBoolean(props.options.WaffoPancakeEnabled),
|
||||
WaffoPancakeSandbox: toBoolean(props.options.WaffoPancakeSandbox),
|
||||
WaffoPancakeMerchantID: props.options.WaffoPancakeMerchantID || '',
|
||||
WaffoPancakePrivateKey: props.options.WaffoPancakePrivateKey || '',
|
||||
WaffoPancakeWebhookPublicKey:
|
||||
props.options.WaffoPancakeWebhookPublicKey || '',
|
||||
WaffoPancakeWebhookTestKey:
|
||||
props.options.WaffoPancakeWebhookTestKey || '',
|
||||
WaffoPancakeStoreID: props.options.WaffoPancakeStoreID || '',
|
||||
WaffoPancakeProductID: props.options.WaffoPancakeProductID || '',
|
||||
WaffoPancakeReturnURL: props.options.WaffoPancakeReturnURL || '',
|
||||
WaffoPancakeCurrency: props.options.WaffoPancakeCurrency || 'USD',
|
||||
WaffoPancakeUnitPrice:
|
||||
props.options.WaffoPancakeUnitPrice !== undefined
|
||||
? parseFloat(props.options.WaffoPancakeUnitPrice)
|
||||
: 1.0,
|
||||
WaffoPancakeMinTopUp:
|
||||
props.options.WaffoPancakeMinTopUp !== undefined
|
||||
? parseFloat(props.options.WaffoPancakeMinTopUp)
|
||||
: 1,
|
||||
};
|
||||
|
||||
setInputs(currentInputs);
|
||||
@@ -93,90 +65,23 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
...inputs,
|
||||
...(formApiRef.current?.getValues?.() || {}),
|
||||
};
|
||||
values.WaffoPancakeEnabled = toBoolean(values.WaffoPancakeEnabled);
|
||||
values.WaffoPancakeSandbox = toBoolean(values.WaffoPancakeSandbox);
|
||||
const currentWebhookField = values.WaffoPancakeSandbox
|
||||
? 'WaffoPancakeWebhookTestKey'
|
||||
: 'WaffoPancakeWebhookPublicKey';
|
||||
const currentWebhookLabel = values.WaffoPancakeSandbox
|
||||
? t('Webhook 公钥(测试环境)')
|
||||
: t('Webhook 公钥(生产环境)');
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeMerchantID.trim()) {
|
||||
showError(t('请输入商户 ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeStoreID.trim()) {
|
||||
showError(t('请输入 Store ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && !values.WaffoPancakeProductID.trim()) {
|
||||
showError(t('请输入 Product ID'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
values.WaffoPancakeEnabled &&
|
||||
!String(values[currentWebhookField] || '').trim()
|
||||
) {
|
||||
showError(currentWebhookLabel);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
values.WaffoPancakeEnabled &&
|
||||
Number(values.WaffoPancakeUnitPrice) <= 0
|
||||
) {
|
||||
showError(t('充值价格必须大于 0'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (values.WaffoPancakeEnabled && Number(values.WaffoPancakeMinTopUp) < 1) {
|
||||
showError(t('最低充值美元数量必须大于 0'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Classic admin only persists the three operator-typed fields.
|
||||
// Store/Product binding is handled exclusively by the default
|
||||
// frontend's catalog flow (see waffo-pancake-settings-section.tsx)
|
||||
// because picking entities from a live catalog needs the Select +
|
||||
// dependent-dropdown UX that the classic Semi-UI page doesn't have.
|
||||
const options = [
|
||||
{
|
||||
key: 'WaffoPancakeEnabled',
|
||||
value: values.WaffoPancakeEnabled ? 'true' : 'false',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeSandbox',
|
||||
value: values.WaffoPancakeSandbox ? 'true' : 'false',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeMerchantID',
|
||||
value: values.WaffoPancakeMerchantID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeStoreID',
|
||||
value: values.WaffoPancakeStoreID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeProductID',
|
||||
value: values.WaffoPancakeProductID || '',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeReturnURL',
|
||||
value: removeTrailingSlash(values.WaffoPancakeReturnURL || ''),
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeCurrency',
|
||||
value: values.WaffoPancakeCurrency || 'USD',
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeUnitPrice',
|
||||
value: String(values.WaffoPancakeUnitPrice),
|
||||
},
|
||||
{
|
||||
key: 'WaffoPancakeMinTopUp',
|
||||
value: String(values.WaffoPancakeMinTopUp),
|
||||
},
|
||||
];
|
||||
|
||||
if ((values.WaffoPancakePrivateKey || '').trim()) {
|
||||
@@ -186,20 +91,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
});
|
||||
}
|
||||
|
||||
if ((values.WaffoPancakeWebhookPublicKey || '').trim()) {
|
||||
options.push({
|
||||
key: 'WaffoPancakeWebhookPublicKey',
|
||||
value: values.WaffoPancakeWebhookPublicKey,
|
||||
});
|
||||
}
|
||||
|
||||
if ((values.WaffoPancakeWebhookTestKey || '').trim()) {
|
||||
options.push({
|
||||
key: 'WaffoPancakeWebhookTestKey',
|
||||
value: values.WaffoPancakeWebhookTestKey,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
options.map((opt) =>
|
||||
API.put('/api/option/', {
|
||||
@@ -237,103 +128,43 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
icon={<BookOpen size={16} />}
|
||||
description={
|
||||
<>
|
||||
Waffo Pancake 的商户、商品和签名密钥请
|
||||
Waffo Pancake 商户 ID 与私钥请在
|
||||
<a
|
||||
href='https://docs.waffo.ai'
|
||||
href='https://pancake.waffo.ai/merchant/dashboard'
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
点击此处
|
||||
Waffo Pancake 控制台
|
||||
</a>
|
||||
获取,建议先在测试环境完成联调。
|
||||
获取,保存后系统会自动在该商户名下创建 Store + Product,无需手动配置;
|
||||
环境(test / 生产)由你粘贴的 API 私钥本身决定。
|
||||
请在 Pancake 控制台把下面两个回调地址分别注册到 Test Mode 和 Production Mode
|
||||
两个 webhook 位置,分开走避免测试流量污染生产数据:
|
||||
<br />
|
||||
{t('回调地址')}:
|
||||
{t('Test 回调地址')}:
|
||||
{props.options.ServerAddress
|
||||
? removeTrailingSlash(props.options.ServerAddress)
|
||||
: t('网站地址')}
|
||||
/api/waffo-pancake/webhook
|
||||
/api/waffo-pancake/webhook/test
|
||||
<br />
|
||||
{t('Production 回调地址')}:
|
||||
{props.options.ServerAddress
|
||||
? removeTrailingSlash(props.options.ServerAddress)
|
||||
: t('网站地址')}
|
||||
/api/waffo-pancake/webhook/prod
|
||||
</>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<Banner
|
||||
type='warning'
|
||||
icon={<TriangleAlert size={16} />}
|
||||
description={t(
|
||||
'请确认 Merchant、Store、Product 和所选环境密钥一致。',
|
||||
)}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoPancakeEnabled'
|
||||
label={t('启用 Waffo Pancake')}
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Switch
|
||||
field='WaffoPancakeSandbox'
|
||||
label={t('沙盒模式')}
|
||||
checkedText='|'
|
||||
uncheckedText='〇'
|
||||
extraText={t('用于切换当前下单和回调校验所使用的环境')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeCurrency'
|
||||
label={t('货币')}
|
||||
placeholder='USD'
|
||||
extraText={t('默认使用 USD 结算')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeMerchantID'
|
||||
label={t('商户 ID')}
|
||||
placeholder={t('例如:MER_xxx')}
|
||||
extraText={t('请填写当前环境对应的商户 ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeStoreID'
|
||||
label={t('Store ID')}
|
||||
placeholder={t('例如:STO_xxx')}
|
||||
extraText={t('请填写当前环境对应的 Store ID')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
|
||||
<Form.Input
|
||||
field='WaffoPancakeProductID'
|
||||
label={t('Product ID')}
|
||||
placeholder={t('例如:PROD_xxx')}
|
||||
extraText={t('请填写当前环境对应的 Product ID')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakePrivateKey'
|
||||
label={t('API 私钥')}
|
||||
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
|
||||
extraText={t('保存后不会回显,请填写当前环境对应的 API 私钥')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
<Form.Input
|
||||
field='WaffoPancakeMerchantID'
|
||||
label={t('商户 ID')}
|
||||
placeholder={t('例如:MER_xxx')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
@@ -341,7 +172,6 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
field='WaffoPancakeReturnURL'
|
||||
label={t('支付返回地址')}
|
||||
placeholder={t('例如:https://example.com/console/topup')}
|
||||
extraText={t('留空则自动使用当前站点的默认充值页地址')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -350,55 +180,16 @@ export default function SettingsPaymentGatewayWaffoPancake(props) {
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Col xs={24}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakeWebhookPublicKey'
|
||||
label={t('Webhook 公钥(生产环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前生产环境 Webhook 公钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('用于校验生产环境的 Waffo Pancake Webhook 签名')}
|
||||
field='WaffoPancakePrivateKey'
|
||||
label={t('API 私钥')}
|
||||
placeholder={t('填写后覆盖当前私钥,留空表示保持当前不变')}
|
||||
extraText={t('⚠ 测试 / 生产环境由你粘进来的 API 私钥本身决定——集成阶段用 Test Key,正式上线时再换成 Production Key')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
|
||||
<Form.TextArea
|
||||
field='WaffoPancakeWebhookTestKey'
|
||||
label={t('Webhook 公钥(测试环境)')}
|
||||
placeholder={t(
|
||||
'填写后覆盖当前测试环境 Webhook 公钥,留空表示保持当前不变',
|
||||
)}
|
||||
extraText={t('用于校验测试环境的 Waffo Pancake Webhook 签名')}
|
||||
type='password'
|
||||
autosize={{ minRows: 4, maxRows: 8 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoPancakeUnitPrice'
|
||||
precision={2}
|
||||
label={t('充值价格(x元/美金)')}
|
||||
placeholder={t('例如:7,就是7元/美金')}
|
||||
extraText={t('按 1 美元对应的站内价格填写')}
|
||||
min={0}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={8} xl={8}>
|
||||
<Form.InputNumber
|
||||
field='WaffoPancakeMinTopUp'
|
||||
label={t('最低充值美元数量')}
|
||||
placeholder={t('例如:2,就是最低充值2$')}
|
||||
extraText={t('用户单次最少可充值的美元数量')}
|
||||
min={1}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Button onClick={submitWaffoPancakeSetting}>
|
||||
|
||||
Vendored
-107
@@ -1,107 +0,0 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig, transformWithEsbuild } from 'vite';
|
||||
import pkg from '@douyinfe/vite-plugin-semi';
|
||||
import path from 'path';
|
||||
import { codeInspectorPlugin } from 'code-inspector-plugin';
|
||||
const { vitePluginSemi } = pkg;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
codeInspectorPlugin({
|
||||
bundler: 'vite',
|
||||
}),
|
||||
{
|
||||
name: 'treat-js-files-as-jsx',
|
||||
async transform(code, id) {
|
||||
if (!/src\/.*\.js$/.test(id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use the exposed transform from vite, instead of directly
|
||||
// transforming with esbuild
|
||||
return transformWithEsbuild(code, id, {
|
||||
loader: 'jsx',
|
||||
jsx: 'automatic',
|
||||
});
|
||||
},
|
||||
},
|
||||
react(),
|
||||
vitePluginSemi({
|
||||
cssLayer: true,
|
||||
}),
|
||||
],
|
||||
optimizeDeps: {
|
||||
force: true,
|
||||
esbuildOptions: {
|
||||
loader: {
|
||||
'.js': 'jsx',
|
||||
'.json': 'json',
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'react-core': ['react', 'react-dom', 'react-router-dom'],
|
||||
'semi-ui': ['@douyinfe/semi-icons', '@douyinfe/semi-ui'],
|
||||
tools: ['axios', 'history', 'marked'],
|
||||
'react-components': [
|
||||
'react-dropzone',
|
||||
'react-fireworks',
|
||||
'react-telegram-login',
|
||||
'react-toastify',
|
||||
'react-turnstile',
|
||||
],
|
||||
i18n: [
|
||||
'i18next',
|
||||
'react-i18next',
|
||||
'i18next-browser-languagedetector',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/mj': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/pg': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Vendored
+69
-55
@@ -18,83 +18,97 @@
|
||||
"knip": "knip"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/lora": "^5.2.8",
|
||||
"@fontsource-variable/public-sans": "^5.2.7",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hugeicons/core-free-icons": "^4.1.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@hugeicons/core-free-icons": "^4.1.4",
|
||||
"@hugeicons/react": "^1.1.6",
|
||||
"@lobehub/icons": "^4.0.3",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.23",
|
||||
"@lobehub/icons": "catalog:",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"@visactor/react-vchart": "^2.0.13",
|
||||
"@visactor/vchart": "^2.0.13",
|
||||
"ai": "^6.0.27",
|
||||
"@tanstack/react-virtual": "^3.13.25",
|
||||
"@visactor/react-vchart": "^2.0.22",
|
||||
"@visactor/vchart": "^2.0.22",
|
||||
"ai": "^6.0.191",
|
||||
"auto-skeleton-react": "^1.0.5",
|
||||
"axios": "^1.13.6",
|
||||
"axios": "catalog:",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"i18next": "^25.7.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"date-fns": "^4.3.0",
|
||||
"dayjs": "catalog:",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.40.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.0",
|
||||
"react-i18next": "^16.5.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.11.0",
|
||||
"qrcode.react": "catalog:",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "catalog:",
|
||||
"react-markdown": "catalog:",
|
||||
"react-resizable-panels": "^4.11.2",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.8.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^4.0.2",
|
||||
"remark-gfm": "catalog:",
|
||||
"shiki": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sse.js": "^2.7.2",
|
||||
"streamdown": "^2.0.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"sse.js": "catalog:",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"use-stick-to-bottom": "^1.1.4",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@rsbuild/core": "^2.0.1",
|
||||
"@rsbuild/core": "^2.0.7",
|
||||
"@rsbuild/plugin-react": "^2.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.95.2",
|
||||
"@tanstack/react-query-devtools": "^5.95.2",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"@tanstack/router-plugin": "^1.167.23",
|
||||
"@tanstack/eslint-plugin-query": "^5.100.14",
|
||||
"@tanstack/react-query-devtools": "^5.100.14",
|
||||
"@tanstack/react-router-devtools": "^1.167.0",
|
||||
"@tanstack/router-plugin": "^1.168.11",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"knip": "^6.0.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"shadcn": "^3.7.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.2"
|
||||
"globals": "^17.6.0",
|
||||
"knip": "^6.14.2",
|
||||
"prettier": "catalog:",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"shadcn": "^4.8.0",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.4"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "2.1.1",
|
||||
"dompurify": "3.4.5",
|
||||
"fast-uri": "3.1.2",
|
||||
"hono": "4.12.22",
|
||||
"ip-address": "10.2.0",
|
||||
"js-cookie": "3.0.7",
|
||||
"mermaid": "11.15.0",
|
||||
"minimist": "1.2.8",
|
||||
"postcss": "8.5.15",
|
||||
"qs": "6.15.2",
|
||||
"uuid": "14.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8965 17.8787L11.2995 12.6132L10.1344 8.7762C9.97497 8.25193 9.4909 7.89355 8.94204 7.89355H4.41838C3.89937 7.89355 3.52838 8.39249 3.67767 8.88637L6.0675 16.7643C6.37941 17.7907 7.32849 18.4928 8.40398 18.4928H12.4398C12.7599 18.4922 12.9893 18.1845 12.8965 17.8787ZM7.47396 10.6301C7.11059 10.7302 6.71038 10.4345 6.58079 9.96909C6.4512 9.50371 6.64177 9.04403 7.00514 8.94399C7.36851 8.84395 7.76745 9.13964 7.89641 9.60502C8.026 10.0717 7.83733 10.5301 7.47396 10.6301Z" fill="white"/>
|
||||
<path d="M13.0281 18.269C12.8777 18.4077 12.6794 18.4926 12.4646 18.4927H12.4382C12.7588 18.4926 12.9887 18.1847 12.8962 17.8784L11.2996 12.6128L11.3054 12.5923L13.0281 18.269ZM14.5144 13.771V13.7729L13.2615 17.9028C13.2401 17.973 13.2071 18.0369 13.1697 18.0972L11.4021 12.271L12.4626 8.77588C12.6221 8.25169 13.1063 7.89317 13.655 7.89307H16.2976L14.5144 13.771Z" fill="white"/>
|
||||
<path d="M19.5133 18.4932H16.8707C16.3221 18.493 15.8378 18.135 15.6783 17.6104L14.61 14.0859L16.3883 8.19336L17.7311 12.6133L19.1617 7.89355H22.7291L19.5133 18.4932Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
+5
@@ -0,0 +1,5 @@
|
||||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8967 17.8789L11.2996 12.6135L10.1346 8.77644C9.97511 8.25218 9.49104 7.8938 8.94218 7.8938H4.4185C3.8995 7.8938 3.5285 8.39274 3.67779 8.88662L6.06763 16.7646C6.37954 17.7909 7.32862 18.4931 8.40411 18.4931H12.4399C12.7601 18.4925 12.9894 18.1848 12.8967 17.8789ZM7.47409 10.6304C7.11073 10.7304 6.71051 10.4347 6.58092 9.96934C6.45133 9.50396 6.64191 9.04428 7.00527 8.94423C7.36864 8.84419 7.76758 9.13989 7.89654 9.60527C8.02613 10.0719 7.83746 10.5303 7.47409 10.6304Z" fill="black"/>
|
||||
<path d="M13.0278 18.2703C12.8774 18.4086 12.679 18.4929 12.4643 18.4929H12.4379C12.7587 18.4929 12.9886 18.1851 12.8959 17.8787L11.2993 12.613L11.3051 12.5925L13.0278 18.2703ZM16.2973 7.89331L14.5151 13.7712V13.7732L13.2612 17.9031C13.2397 17.9736 13.207 18.0379 13.1694 18.0984L11.4018 12.2712L12.4633 8.77612C12.6228 8.25186 13.1068 7.89331 13.6557 7.89331H16.2973Z" fill="black"/>
|
||||
<path d="M22.7283 7.89478L19.5134 18.4934H16.8718C16.323 18.4934 15.8389 18.1355 15.6794 17.6106L14.6101 14.0862L16.3884 8.19458L17.7312 12.6145L19.1619 7.89478H22.7283Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
Vendored
+1
@@ -65,6 +65,7 @@ export default defineConfig(({ envMode }) => {
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
proxy: devProxy,
|
||||
},
|
||||
output: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user