diff --git a/Dockerfile b/Dockerfile index d01ab3f0f038..56b7fd0d4620 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,25 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder +ARG BUN_REGISTRY=https://registry.npmjs.org + 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 +RUN bun install --frozen-lockfile --registry=${BUN_REGISTRY} 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 +ARG BUN_REGISTRY=https://registry.npmjs.org + 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 +RUN bun install --frozen-lockfile --registry=${BUN_REGISTRY} COPY ./web/classic ./classic COPY ./VERSION /build/VERSION RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build @@ -23,6 +27,8 @@ 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 +ARG GOPROXY=https://goproxy.cn,direct +ENV GOPROXY=${GOPROXY} ARG TARGETOS ARG TARGETARCH ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} @@ -40,8 +46,11 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$ FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ +ARG DEBIAN_MIRROR=mirrors.aliyun.com + +RUN sed -i "s|http://deb.debian.org|http://${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update \ + && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ && rm -rf /var/lib/apt/lists/* \ && update-ca-certificates diff --git a/Dockerfile.dev b/Dockerfile.dev index 81c221bf113c..ccfc7d5d5b93 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -4,6 +4,8 @@ FROM golang:1.26.1-alpine AS builder ENV GO111MODULE=on CGO_ENABLED=0 +ARG GOPROXY=https://goproxy.cn,direct +ENV GOPROXY=${GOPROXY} ARG TARGETOS ARG TARGETARCH ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} @@ -24,8 +26,11 @@ RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$ FROM debian:bookworm-slim -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates tzdata wget \ +ARG DEBIAN_MIRROR=mirrors.aliyun.com + +RUN sed -i "s|http://deb.debian.org|http://${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 update \ + && apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 install -y --no-install-recommends ca-certificates tzdata wget \ && rm -rf /var/lib/apt/lists/* \ && update-ca-certificates diff --git a/constant/channel.go b/constant/channel.go index 48502bedc52c..66e25414e0eb 100644 --- a/constant/channel.go +++ b/constant/channel.go @@ -55,6 +55,7 @@ const ( ChannelTypeSora = 55 ChannelTypeReplicate = 56 ChannelTypeCodex = 57 + ChannelTypeZLHubVideo = 58 ChannelTypeDummy // this one is only for count, do not add any channel after this ) @@ -118,6 +119,7 @@ var ChannelBaseURLs = []string{ "https://api.openai.com", //55 "https://api.replicate.com", //56 "https://chatgpt.com", //57 + "https://api.zlhub.cn", //58 } var ChannelTypeNames = map[int]string{ @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{ ChannelTypeSora: "Sora", ChannelTypeReplicate: "Replicate", ChannelTypeCodex: "Codex", + ChannelTypeZLHubVideo: "ZLHubVideo", } func GetChannelTypeName(channelType int) string { diff --git a/controller/channel-test.go b/controller/channel-test.go index 037b8496c88f..38fef586cabc 100644 --- a/controller/channel-test.go +++ b/controller/channel-test.go @@ -83,6 +83,7 @@ func testChannel(channel *model.Channel, testUserID int, testModel string, endpo constant.ChannelTypeKling, constant.ChannelTypeJimeng, constant.ChannelTypeDoubaoVideo, + constant.ChannelTypeZLHubVideo, constant.ChannelTypeVidu, } if lo.Contains(unsupportedTestChannelTypes, channel.Type) { diff --git a/controller/payment_webhook_availability.go b/controller/payment_webhook_availability.go index aa26e5acf5ae..6cf3f0181fc2 100644 --- a/controller/payment_webhook_availability.go +++ b/controller/payment_webhook_availability.go @@ -108,3 +108,17 @@ func isEpayWebhookConfigured() bool { func isEpayWebhookEnabled() bool { return isEpayTopUpEnabled() } + +func isAlipayTopUpEnabled() bool { + if !isPaymentComplianceConfirmed() { + return false + } + return strings.TrimSpace(setting.AlipayAppId) != "" && + strings.TrimSpace(setting.AlipayGateway) != "" && + strings.TrimSpace(setting.AlipayPrivateKey) != "" && + strings.TrimSpace(setting.AlipayPublicKey) != "" +} + +func isAlipayWebhookEnabled() bool { + return isAlipayTopUpEnabled() +} diff --git a/controller/payment_webhook_availability_test.go b/controller/payment_webhook_availability_test.go index 002428be0bd1..e163a6376f27 100644 --- a/controller/payment_webhook_availability_test.go +++ b/controller/payment_webhook_availability_test.go @@ -167,3 +167,29 @@ func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { operation_setting.PayMethods = nil require.False(t, isEpayWebhookEnabled()) } + +func TestAlipayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) { + confirmPaymentComplianceForTest(t) + originalAppID := setting.AlipayAppId + originalGateway := setting.AlipayGateway + originalPrivateKey := setting.AlipayPrivateKey + originalPublicKey := setting.AlipayPublicKey + t.Cleanup(func() { + setting.AlipayAppId = originalAppID + setting.AlipayGateway = originalGateway + setting.AlipayPrivateKey = originalPrivateKey + setting.AlipayPublicKey = originalPublicKey + }) + + setting.AlipayAppId = "2021001234567890" + setting.AlipayGateway = "https://openapi.alipay.com/gateway.do" + setting.AlipayPrivateKey = "" + setting.AlipayPublicKey = "alipay_public_key" + require.False(t, isAlipayWebhookEnabled()) + + setting.AlipayPrivateKey = "alipay_private_key" + require.True(t, isAlipayWebhookEnabled()) + + setting.AlipayPublicKey = "" + require.False(t, isAlipayWebhookEnabled()) +} diff --git a/controller/topup.go b/controller/topup.go index 69e1b5e304c4..98b59d8a7856 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -95,8 +95,28 @@ func GetTopUpInfo(c *gin.Context) { } } + enableAlipay := isAlipayTopUpEnabled() + if enableAlipay { + hasAlipayOfficial := false + for _, method := range payMethods { + if method["type"] == model.PaymentMethodAlipayOfficial { + hasAlipayOfficial = true + break + } + } + if !hasAlipayOfficial { + payMethods = append(payMethods, map[string]string{ + "name": "Alipay Official", + "type": model.PaymentMethodAlipayOfficial, + "color": "rgba(var(--semi-blue-5), 1)", + "min_topup": strconv.Itoa(operation_setting.MinTopUp), + }) + } + } + data := gin.H{ "enable_online_topup": isEpayTopUpEnabled(), + "enable_alipay_topup": enableAlipay, "enable_stripe_topup": isStripeTopUpEnabled(), "enable_creem_topup": isCreemTopUpEnabled(), "enable_waffo_topup": enableWaffo, diff --git a/controller/topup_alipay.go b/controller/topup_alipay.go new file mode 100644 index 000000000000..404c4190b403 --- /dev/null +++ b/controller/topup_alipay.go @@ -0,0 +1,194 @@ +package controller + +import ( + "fmt" + "net/http" + "strconv" + "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/operation_setting" + "github.com/gin-gonic/gin" + "github.com/samber/lo" + "github.com/shopspring/decimal" +) + +type AlipayPayRequest struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` +} + +func RequestAlipayPay(c *gin.Context) { + if !isAlipayTopUpEnabled() { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Alipay configuration is incomplete"}) + return + } + + var req AlipayPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"}) + return + } + if req.PaymentMethod != model.PaymentMethodAlipayOfficial { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "不支持的支付方式"}) + return + } + if req.Amount < getMinTopup() { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", getMinTopup())}) + return + } + + id := c.GetInt("id") + group, err := model.GetUserGroup(id, true) + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "获取用户分组失败"}) + return + } + + payMoney := getPayMoney(req.Amount, group) + if payMoney < 0.01 { + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + callbackAddress := service.GetCallbackAddress() + returnURL := paymentReturnPath("/console/log") + notifyURL := strings.TrimRight(callbackAddress, "/") + "/api/user/alipay/notify" + tradeNo := fmt.Sprintf("ALIPAYUSR%dNO%s%s", id, common.GetRandomString(6), strconv.FormatInt(time.Now().Unix(), 10)) + + gateway, params, err := service.BuildAlipayPagePay( + tradeNo, + payMoney, + fmt.Sprintf("TUC%d", req.Amount), + fmt.Sprintf("new-api topup amount=%d", req.Amount), + returnURL, + notifyURL, + ) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Alipay create payment params failed user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error())) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + dAmount := decimal.NewFromInt(amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + amount = dAmount.Div(dQuotaPerUnit).IntPart() + } + + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: tradeNo, + PaymentMethod: model.PaymentMethodAlipayOfficial, + PaymentProvider: model.PaymentProviderAlipay, + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Alipay create topup order failed user_id=%d trade_no=%s amount=%d error=%q", id, tradeNo, req.Amount, err.Error())) + c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + logger.LogInfo(c.Request.Context(), fmt.Sprintf( + "Alipay topup order created user_id=%d trade_no=%s amount=%d money=%.2f gateway=%q params=%q", + id, tradeNo, req.Amount, payMoney, gateway, common.GetJsonString(params), + )) + c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": gateway}) +} + +func AlipayNotify(c *gin.Context) { + if !isAlipayWebhookEnabled() { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Alipay webhook disabled path=%q client_ip=%s", c.Request.RequestURI, c.ClientIP())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + + var params map[string]string + if c.Request.Method == http.MethodPost { + if err := c.Request.ParseForm(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Alipay webhook parse form failed path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error())) + _, _ = c.Writer.Write([]byte("fail")) + return + } + params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.PostForm.Get(t) + return r + }, map[string]string{}) + } else { + params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string { + r[t] = c.Request.URL.Query().Get(t) + return r + }, map[string]string{}) + } + + if len(params) == 0 { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + if err := service.VerifyAlipayParams(params); err != nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Alipay webhook signature verification failed path=%q client_ip=%s error=%q params=%q", c.Request.RequestURI, c.ClientIP(), err.Error(), common.GetJsonString(params))) + _, _ = c.Writer.Write([]byte("fail")) + return + } + + tradeStatus := strings.ToUpper(strings.TrimSpace(params["trade_status"])) + tradeNo := strings.TrimSpace(params["out_trade_no"]) + if tradeNo == "" { + _, _ = c.Writer.Write([]byte("fail")) + return + } + + if tradeStatus != "TRADE_SUCCESS" && tradeStatus != "TRADE_FINISHED" { + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Alipay webhook ignored non-success status trade_no=%s trade_status=%s client_ip=%s params=%q", tradeNo, tradeStatus, c.ClientIP(), common.GetJsonString(params))) + _, _ = c.Writer.Write([]byte("success")) + return + } + + LockOrder(tradeNo) + defer UnlockOrder(tradeNo) + + topUp := model.GetTopUpByTradeNo(tradeNo) + if topUp == nil { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Alipay callback order not found trade_no=%s trade_status=%s client_ip=%s params=%q", tradeNo, tradeStatus, c.ClientIP(), common.GetJsonString(params))) + _, _ = c.Writer.Write([]byte("success")) + return + } + if topUp.PaymentProvider != model.PaymentProviderAlipay { + logger.LogWarn(c.Request.Context(), fmt.Sprintf("Alipay order provider mismatch trade_no=%s order_provider=%s trade_status=%s client_ip=%s", tradeNo, topUp.PaymentProvider, tradeStatus, c.ClientIP())) + _, _ = c.Writer.Write([]byte("success")) + return + } + + if topUp.Status == common.TopUpStatusPending { + topUp.Status = common.TopUpStatusSuccess + topUp.CompleteTime = common.GetTimestamp() + if err := topUp.Update(); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Alipay update topup order failed trade_no=%s user_id=%d client_ip=%s error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), err.Error(), common.GetJsonString(topUp))) + _, _ = c.Writer.Write([]byte("fail")) + return + } + + dAmount := decimal.NewFromInt(int64(topUp.Amount)) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if err := model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Alipay increase user quota failed trade_no=%s user_id=%d client_ip=%s quota_to_add=%d error=%q topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, err.Error(), common.GetJsonString(topUp))) + _, _ = c.Writer.Write([]byte("fail")) + return + } + + logger.LogInfo(c.Request.Context(), fmt.Sprintf("Alipay topup success trade_no=%s user_id=%d client_ip=%s quota_to_add=%d money=%.2f topup=%q", topUp.TradeNo, topUp.UserId, c.ClientIP(), quotaToAdd, topUp.Money, common.GetJsonString(topUp))) + model.RecordTopupLog(topUp.UserId, fmt.Sprintf("使用支付宝官方充值成功,充值金额: %v,支付金额:%f", logger.LogQuota(quotaToAdd), topUp.Money), c.ClientIP(), topUp.PaymentMethod, model.PaymentMethodAlipayOfficial) + } + + _, _ = c.Writer.Write([]byte("success")) +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e75befaeea8a..7ec15854d909 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,7 +23,7 @@ services: container_name: new-api-dev restart: unless-stopped ports: - - "3000:3000" + - "3004:3000" volumes: - dev_data:/data environment: diff --git a/docker-compose.yml b/docker-compose.yml index be8c885b186a..757e6440ff06 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ # # Quick Start: # 1. docker-compose up -d -# 2. Access at http://localhost:3000 +# 2. Access at http://localhost:3004 # # Using MySQL instead of PostgreSQL: # 1. Comment out the postgres service and SQL_DSN line 15 @@ -16,12 +16,13 @@ version: '3.4' # For compatibility with older Docker versions services: new-api: - image: calciumion/new-api:latest + build: . + image: new-api:prod container_name: new-api restart: always command: --log-dir /app/logs ports: - - "3000:3000" + - "3004:3000" volumes: - ./data:/data - ./logs:/app/logs diff --git a/model/option.go b/model/option.go index ed1af72ebb12..7f394dec1994 100644 --- a/model/option.go +++ b/model/option.go @@ -78,6 +78,10 @@ func InitOptionMap() { common.OptionMap["CustomCallbackAddress"] = "" common.OptionMap["EpayId"] = "" common.OptionMap["EpayKey"] = "" + common.OptionMap["AlipayAppId"] = "" + common.OptionMap["AlipayGateway"] = setting.AlipayGateway + common.OptionMap["AlipayPrivateKey"] = setting.AlipayPrivateKey + common.OptionMap["AlipayPublicKey"] = setting.AlipayPublicKey common.OptionMap["Price"] = strconv.FormatFloat(operation_setting.Price, 'f', -1, 64) common.OptionMap["USDExchangeRate"] = strconv.FormatFloat(operation_setting.USDExchangeRate, 'f', -1, 64) common.OptionMap["MinTopUp"] = strconv.Itoa(operation_setting.MinTopUp) @@ -392,6 +396,14 @@ func updateOptionMap(key string, value string) (err error) { operation_setting.EpayId = value case "EpayKey": operation_setting.EpayKey = value + case "AlipayAppId": + setting.AlipayAppId = value + case "AlipayGateway": + setting.AlipayGateway = value + case "AlipayPrivateKey": + setting.AlipayPrivateKey = value + case "AlipayPublicKey": + setting.AlipayPublicKey = value case "Price": operation_setting.Price, _ = strconv.ParseFloat(value, 64) case "USDExchangeRate": diff --git a/model/topup.go b/model/topup.go index 83c5990df212..a6236cc9b6cf 100644 --- a/model/topup.go +++ b/model/topup.go @@ -25,15 +25,17 @@ type TopUp struct { } const ( - PaymentMethodStripe = "stripe" - PaymentMethodCreem = "creem" - PaymentMethodWaffo = "waffo" - PaymentMethodWaffoPancake = "waffo_pancake" - PaymentMethodBalance = "balance" + PaymentMethodStripe = "stripe" + PaymentMethodAlipayOfficial = "alipay_official" + PaymentMethodCreem = "creem" + PaymentMethodWaffo = "waffo" + PaymentMethodWaffoPancake = "waffo_pancake" + PaymentMethodBalance = "balance" ) const ( PaymentProviderEpay = "epay" + PaymentProviderAlipay = "alipay" PaymentProviderStripe = "stripe" PaymentProviderCreem = "creem" PaymentProviderWaffo = "waffo" diff --git a/relay/channel/task/doubao/adaptor.go b/relay/channel/task/doubao/adaptor.go index a6dabb5f1086..d3504222a8f0 100644 --- a/relay/channel/task/doubao/adaptor.go +++ b/relay/channel/task/doubao/adaptor.go @@ -5,7 +5,9 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" + "strings" "time" "github.com/QuantumNous/new-api/common" @@ -65,6 +67,12 @@ type responsePayload struct { ID string `json:"id"` // task_id } +type responsePayloadEnvelope struct { + Code string `json:"code"` + Message string `json:"message"` + Data responsePayload `json:"data"` +} + type responseTask struct { ID string `json:"id"` Model string `json:"model"` @@ -96,6 +104,12 @@ type responseTask struct { UpdatedAt int64 `json:"updated_at"` } +type responseTaskEnvelope struct { + Code string `json:"code"` + Message string `json:"message"` + Data responseTask `json:"data"` +} + // ============================ // Adaptor implementation // ============================ @@ -105,9 +119,51 @@ type TaskAdaptor struct { ChannelType int apiKey string baseURL string + createPath string + queryPath string + modelList []string + channelName string +} + +func NewTaskAdaptor() *TaskAdaptor { + return &TaskAdaptor{ + createPath: OfficialCreatePath, + queryPath: OfficialQueryPath, + modelList: ModelList, + channelName: ChannelName, + } +} + +func NewZLHubTaskAdaptor() *TaskAdaptor { + return &TaskAdaptor{ + createPath: ZLHubCreatePath, + queryPath: ZLHubQueryPath, + modelList: ZLHubModelList, + channelName: ZLHubChannelName, + } +} + +func (a *TaskAdaptor) setDefaults() { + if a.createPath == "" { + a.createPath = OfficialCreatePath + } + if a.queryPath == "" { + a.queryPath = OfficialQueryPath + } + if a.modelList == nil { + a.modelList = ModelList + } + if a.channelName == "" { + a.channelName = ChannelName + } +} + +func buildTaskURL(baseURL, requestPath string) string { + return strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(requestPath, "/") } func (a *TaskAdaptor) Init(info *relaycommon.RelayInfo) { + a.setDefaults() a.ChannelType = info.ChannelType a.baseURL = info.ChannelBaseUrl a.apiKey = info.ApiKey @@ -121,7 +177,8 @@ func (a *TaskAdaptor) ValidateRequestAndSetAction(c *gin.Context, info *relaycom // BuildRequestURL constructs the upstream URL. func (a *TaskAdaptor) BuildRequestURL(_ *relaycommon.RelayInfo) (string, error) { - return fmt.Sprintf("%s/api/v3/contents/generations/tasks", a.baseURL), nil + a.setDefaults() + return buildTaskURL(a.baseURL, a.createPath), nil } // BuildRequestHeader sets required headers. @@ -213,8 +270,8 @@ func (a *TaskAdaptor) DoResponse(c *gin.Context, resp *http.Response, info *rela _ = resp.Body.Close() // Parse Doubao response - var dResp responsePayload - if err := common.Unmarshal(responseBody, &dResp); err != nil { + dResp, err := parseResponsePayload(responseBody) + if err != nil { taskErr = service.TaskErrorWrapper(errors.Wrapf(err, "body: %s", responseBody), "unmarshal_response_body_failed", http.StatusInternalServerError) return } @@ -241,7 +298,8 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy return nil, fmt.Errorf("invalid task_id") } - uri := fmt.Sprintf("%s/api/v3/contents/generations/tasks/%s", baseUrl, taskID) + a.setDefaults() + uri := buildTaskURL(baseUrl, fmt.Sprintf(a.queryPath, url.PathEscape(taskID))) req, err := http.NewRequest(http.MethodGet, uri, nil) if err != nil { @@ -260,11 +318,13 @@ func (a *TaskAdaptor) FetchTask(baseUrl, key string, body map[string]any, proxy } func (a *TaskAdaptor) GetModelList() []string { - return ModelList + a.setDefaults() + return a.modelList } func (a *TaskAdaptor) GetChannelName() string { - return ChannelName + a.setDefaults() + return a.channelName } func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (*requestPayload, error) { @@ -303,9 +363,38 @@ func (a *TaskAdaptor) convertToRequestPayload(req *relaycommon.TaskSubmitReq) (* return &r, nil } +func parseResponsePayload(respBody []byte) (responsePayload, error) { + var payload responsePayload + if err := common.Unmarshal(respBody, &payload); err != nil { + return responsePayload{}, err + } + if payload.ID != "" { + return payload, nil + } + + var envelope responsePayloadEnvelope + if err := common.Unmarshal(respBody, &envelope); err != nil { + return responsePayload{}, err + } + return envelope.Data, nil +} + +func parseResponseTask(respBody []byte) (responseTask, error) { + var envelope responseTaskEnvelope + if err := common.Unmarshal(respBody, &envelope); err == nil && envelope.Data.ID != "" { + return envelope.Data, nil + } + + var task responseTask + if err := common.Unmarshal(respBody, &task); err != nil { + return responseTask{}, err + } + return task, nil +} + func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) { - resTask := responseTask{} - if err := common.Unmarshal(respBody, &resTask); err != nil { + resTask, err := parseResponseTask(respBody) + if err != nil { return nil, errors.Wrap(err, "unmarshal task result failed") } @@ -342,8 +431,8 @@ func (a *TaskAdaptor) ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, e } func (a *TaskAdaptor) ConvertToOpenAIVideo(originTask *model.Task) ([]byte, error) { - var dResp responseTask - if err := common.Unmarshal(originTask.Data, &dResp); err != nil { + dResp, err := parseResponseTask(originTask.Data) + if err != nil { return nil, errors.Wrap(err, "unmarshal doubao task data failed") } diff --git a/relay/channel/task/doubao/adaptor_test.go b/relay/channel/task/doubao/adaptor_test.go new file mode 100644 index 000000000000..83b491bc3a5e --- /dev/null +++ b/relay/channel/task/doubao/adaptor_test.go @@ -0,0 +1,174 @@ +package doubao + +import ( + "io" + "net/http" + "net/http/httptest" + "slices" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" +) + +func TestBuildRequestURL(t *testing.T) { + tests := []struct { + name string + adaptor *TaskAdaptor + baseURL string + want string + }{ + { + name: "zero value uses official path", + adaptor: &TaskAdaptor{}, + baseURL: "https://ark.cn-beijing.volces.com/", + want: "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", + }, + { + name: "zlhub uses task create path", + adaptor: NewZLHubTaskAdaptor(), + baseURL: "https://api.zlhub.cn/", + want: "https://api.zlhub.cn/v1/task/create", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.adaptor.baseURL = tt.baseURL + got, err := tt.adaptor.BuildRequestURL(nil) + if err != nil { + t.Fatalf("BuildRequestURL() error = %v", err) + } + if got != tt.want { + t.Fatalf("BuildRequestURL() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestZLHubFetchTask(t *testing.T) { + service.InitHttpClient() + + var gotPath string + var gotAuthorization string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"id":"task-123","status":"queued"}`) + })) + defer server.Close() + + resp, err := NewZLHubTaskAdaptor().FetchTask(server.URL+"/", "test-key", map[string]any{ + "task_id": "task-123", + }, "") + if err != nil { + t.Fatalf("FetchTask() error = %v", err) + } + defer resp.Body.Close() + + if gotPath != "/v1/task/get/task-123" { + t.Fatalf("FetchTask() path = %q, want %q", gotPath, "/v1/task/get/task-123") + } + if gotAuthorization != "Bearer test-key" { + t.Fatalf("FetchTask() authorization = %q, want %q", gotAuthorization, "Bearer test-key") + } +} + +func TestZLHubAdaptorMetadata(t *testing.T) { + adaptor := NewZLHubTaskAdaptor() + + if got := adaptor.GetChannelName(); got != ZLHubChannelName { + t.Fatalf("GetChannelName() = %q, want %q", got, ZLHubChannelName) + } + if !slices.Contains(adaptor.GetModelList(), "doubao-seedance-2.0-fast") { + t.Fatalf("GetModelList() = %v, want doubao-seedance-2.0-fast", adaptor.GetModelList()) + } + if slices.Contains(adaptor.GetModelList(), "doubao-seedance-2-0-fast-260128") { + t.Fatalf("GetModelList() = %v, should not include official suffixed model", adaptor.GetModelList()) + } +} + +func TestParseResponsePayloadWithEnvelope(t *testing.T) { + payload, err := parseResponsePayload([]byte(`{ + "code": "success", + "data": { + "id": "cgt-test" + } + }`)) + if err != nil { + t.Fatalf("parseResponsePayload() error = %v", err) + } + if payload.ID != "cgt-test" { + t.Fatalf("parseResponsePayload() id = %q, want %q", payload.ID, "cgt-test") + } +} + +func TestParseTaskResultWithEnvelope(t *testing.T) { + respBody := []byte(`{ + "code": "success", + "data": { + "id": "cgt-test", + "status": "succeeded", + "content": { + "video_url": "https://example.com/video.mp4" + }, + "usage": { + "completion_tokens": 123, + "total_tokens": 456 + } + } + }`) + + taskResult, err := NewZLHubTaskAdaptor().ParseTaskResult(respBody) + if err != nil { + t.Fatalf("ParseTaskResult() error = %v", err) + } + if taskResult.Status != model.TaskStatusSuccess { + t.Fatalf("ParseTaskResult() status = %q, want %q", taskResult.Status, model.TaskStatusSuccess) + } + if taskResult.Url != "https://example.com/video.mp4" { + t.Fatalf("ParseTaskResult() url = %q, want %q", taskResult.Url, "https://example.com/video.mp4") + } + if taskResult.TotalTokens != 456 { + t.Fatalf("ParseTaskResult() total_tokens = %d, want %d", taskResult.TotalTokens, 456) + } +} + +func TestConvertToOpenAIVideoWithEnvelope(t *testing.T) { + originTask := &model.Task{ + TaskID: "task-local", + Status: model.TaskStatusSuccess, + Progress: "100%", + CreatedAt: 100, + UpdatedAt: 200, + Properties: model.Properties{ + OriginModelName: "doubao-seedance-2.0-fast", + }, + Data: []byte(`{ + "code": "success", + "data": { + "id": "cgt-test", + "status": "succeeded", + "content": { + "video_url": "https://example.com/video.mp4" + } + } + }`), + } + + data, err := NewZLHubTaskAdaptor().ConvertToOpenAIVideo(originTask) + if err != nil { + t.Fatalf("ConvertToOpenAIVideo() error = %v", err) + } + + var video dto.OpenAIVideo + if err := common.Unmarshal(data, &video); err != nil { + t.Fatalf("unmarshal OpenAIVideo error = %v", err) + } + if video.Metadata["url"] != "https://example.com/video.mp4" { + t.Fatalf("ConvertToOpenAIVideo() url = %q, want %q", video.Metadata["url"], "https://example.com/video.mp4") + } +} diff --git a/relay/channel/task/doubao/constants.go b/relay/channel/task/doubao/constants.go index d65773d3068c..dc589eaef86c 100644 --- a/relay/channel/task/doubao/constants.go +++ b/relay/channel/task/doubao/constants.go @@ -11,12 +11,28 @@ var ModelList = []string{ var ChannelName = "doubao-video" +var ZLHubModelList = []string{ + "doubao-seedance-2.0", + "doubao-seedance-2.0-fast", +} + +var ZLHubChannelName = "zlhub-video" + +const ( + OfficialCreatePath = "/api/v3/contents/generations/tasks" + OfficialQueryPath = "/api/v3/contents/generations/tasks/%s" + ZLHubCreatePath = "/v1/task/create" + ZLHubQueryPath = "/v1/task/get/%s" +) + // videoInputRatioMap 视频输入折扣比率(含视频单价 / 不含视频单价)。 // 管理员应将 ModelRatio 设置为"不含视频"的较高费率, // 系统在检测到视频输入时自动乘以此折扣。 var videoInputRatioMap = map[string]float64{ "doubao-seedance-2-0-260128": 28.0 / 46.0, // ~0.6087 "doubao-seedance-2-0-fast-260128": 22.0 / 37.0, // ~0.5946 + "doubao-seedance-2.0": 28.0 / 46.0, // ~0.6087 + "doubao-seedance-2.0-fast": 22.0 / 37.0, // ~0.5946 } func GetVideoInputRatio(modelName string) (float64, bool) { diff --git a/relay/relay_adaptor.go b/relay/relay_adaptor.go index 3139c9a2dd4a..e0cfed25670c 100644 --- a/relay/relay_adaptor.go +++ b/relay/relay_adaptor.go @@ -152,7 +152,9 @@ func GetTaskAdaptor(platform constant.TaskPlatform) channel.TaskAdaptor { case constant.ChannelTypeVidu: return &taskVidu.TaskAdaptor{} case constant.ChannelTypeDoubaoVideo, constant.ChannelTypeVolcEngine: - return &taskdoubao.TaskAdaptor{} + return taskdoubao.NewTaskAdaptor() + case constant.ChannelTypeZLHubVideo: + return taskdoubao.NewZLHubTaskAdaptor() case constant.ChannelTypeSora, constant.ChannelTypeOpenAI: return &tasksora.TaskAdaptor{} case constant.ChannelTypeGemini: diff --git a/router/api-router.go b/router/api-router.go index 381d2ccd0fbf..3350df57e371 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -54,6 +54,8 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) apiRouter.POST("/stripe/webhook", controller.StripeWebhook) + apiRouter.POST("/alipay/notify", controller.AlipayNotify) + apiRouter.GET("/alipay/notify", controller.AlipayNotify) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) // :env separates test vs prod URLs so the operator can register each @@ -74,6 +76,8 @@ func SetApiRouter(router *gin.Engine) { userRoute.GET("/logout", controller.Logout) userRoute.POST("/epay/notify", controller.EpayNotify) userRoute.GET("/epay/notify", controller.EpayNotify) + userRoute.POST("/alipay/notify", controller.AlipayNotify) + userRoute.GET("/alipay/notify", controller.AlipayNotify) userRoute.GET("/groups", controller.GetUserGroups) selfRoute := userRoute.Group("/") @@ -96,6 +100,7 @@ func SetApiRouter(router *gin.Engine) { selfRoute.GET("/topup/self", controller.GetUserTopUps) selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp) selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay) + selfRoute.POST("/alipay/pay", middleware.CriticalRateLimit(), controller.RequestAlipayPay) selfRoute.POST("/amount", controller.RequestAmount) selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay) selfRoute.POST("/stripe/amount", controller.RequestStripeAmount) diff --git a/service/alipay.go b/service/alipay.go new file mode 100644 index 000000000000..5ed4fec113c8 --- /dev/null +++ b/service/alipay.go @@ -0,0 +1,242 @@ +package service + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting" +) + +const ( + AlipayMethodPagePay = "alipay.trade.page.pay" + AlipayGatewayDefault = "https://openapi.alipay.com/gateway.do" + AlipaySignTypeRSA2 = "RSA2" +) + +type AlipayPagePayBizContent struct { + OutTradeNo string `json:"out_trade_no"` + TotalAmount string `json:"total_amount"` + Subject string `json:"subject"` + Body string `json:"body,omitempty"` + ProductCode string `json:"product_code"` + TimeoutExpress string `json:"timeout_express,omitempty"` +} + +func ResolveAlipayGateway() string { + gateway := strings.TrimSpace(setting.AlipayGateway) + if gateway == "" { + return AlipayGatewayDefault + } + return gateway +} + +func BuildAlipayPagePay(tradeNo string, amount float64, subject string, body string, returnURL string, notifyURL string) (string, map[string]string, error) { + cfg, err := resolveAlipayConfig() + if err != nil { + return "", nil, err + } + if tradeNo == "" { + return "", nil, errors.New("tradeNo is empty") + } + if amount <= 0 { + return "", nil, errors.New("amount must be greater than zero") + } + + bizContent := AlipayPagePayBizContent{ + OutTradeNo: tradeNo, + TotalAmount: fmt.Sprintf("%.2f", amount), + Subject: subject, + Body: body, + ProductCode: "FAST_INSTANT_TRADE_PAY", + TimeoutExpress: "15m", + } + + params := map[string]string{ + "app_id": cfg.AppID, + "biz_content": common.GetJsonString(bizContent), + "charset": "utf-8", + "format": "JSON", + "method": AlipayMethodPagePay, + "notify_url": notifyURL, + "return_url": returnURL, + "sign_type": AlipaySignTypeRSA2, + "timestamp": time.Now().Format("2006-01-02 15:04:05"), + "version": "1.0", + } + + sign, err := signAlipayParams(params, cfg.PrivateKey) + if err != nil { + return "", nil, err + } + params["sign"] = sign + return cfg.Gateway, params, nil +} + +func VerifyAlipayParams(params map[string]string) error { + cfg, err := resolveAlipayConfig() + if err != nil { + return err + } + if len(params) == 0 { + return errors.New("empty alipay params") + } + + if appID := strings.TrimSpace(params["app_id"]); appID != "" && appID != cfg.AppID { + return fmt.Errorf("app_id mismatch: %s", appID) + } + + if strings.TrimSpace(params["sign"]) == "" { + return errors.New("missing sign") + } + signType := strings.ToUpper(strings.TrimSpace(params["sign_type"])) + if signType != "" && signType != AlipaySignTypeRSA2 { + return fmt.Errorf("unsupported sign_type: %s", signType) + } + if strings.TrimSpace(params["trade_status"]) == "" { + return errors.New("missing trade_status") + } + + return verifyAlipayParams(params, cfg.PublicKey) +} + +type alipayConfig struct { + AppID string + Gateway string + PrivateKey *rsa.PrivateKey + PublicKey *rsa.PublicKey +} + +func resolveAlipayConfig() (*alipayConfig, error) { + appID := strings.TrimSpace(setting.AlipayAppId) + privateKeyStr := strings.TrimSpace(setting.AlipayPrivateKey) + publicKeyStr := strings.TrimSpace(setting.AlipayPublicKey) + gateway := ResolveAlipayGateway() + if appID == "" || privateKeyStr == "" || publicKeyStr == "" || gateway == "" { + return nil, errors.New("alipay config is incomplete") + } + + privateKey, err := parseAlipayPrivateKey(privateKeyStr) + if err != nil { + return nil, err + } + publicKey, err := parseAlipayPublicKey(publicKeyStr) + if err != nil { + return nil, err + } + + return &alipayConfig{ + AppID: appID, + Gateway: gateway, + PrivateKey: privateKey, + PublicKey: publicKey, + }, nil +} + +func signAlipayParams(params map[string]string, privateKey *rsa.PrivateKey) (string, error) { + content := buildAlipaySignContent(params) + hash := sha256.Sum256([]byte(content)) + signBytes, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hash[:]) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(signBytes), nil +} + +func verifyAlipayParams(params map[string]string, publicKey *rsa.PublicKey) error { + sign := strings.TrimSpace(params["sign"]) + content := buildAlipaySignContent(params) + signBytes, err := base64.StdEncoding.DecodeString(sign) + if err != nil { + return err + } + hash := sha256.Sum256([]byte(content)) + return rsa.VerifyPKCS1v15(publicKey, crypto.SHA256, hash[:], signBytes) +} + +func buildAlipaySignContent(params map[string]string) string { + keys := make([]string, 0, len(params)) + for key, value := range params { + if key == "sign" || key == "sign_type" { + continue + } + if strings.TrimSpace(value) == "" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, fmt.Sprintf("%s=%s", key, params[key])) + } + return strings.Join(parts, "&") +} + +func parseAlipayPrivateKey(raw string) (*rsa.PrivateKey, error) { + der, err := decodeAlipayKeyBytes(raw) + if err != nil { + return nil, err + } + if pk, err := x509.ParsePKCS8PrivateKey(der); err == nil { + if rsaKey, ok := pk.(*rsa.PrivateKey); ok { + return rsaKey, nil + } + return nil, errors.New("alipay private key is not rsa") + } + if rsaKey, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return rsaKey, nil + } + return nil, errors.New("failed to parse alipay private key") +} + +func parseAlipayPublicKey(raw string) (*rsa.PublicKey, error) { + der, err := decodeAlipayKeyBytes(raw) + if err != nil { + return nil, err + } + if pk, err := x509.ParsePKIXPublicKey(der); err == nil { + if rsaKey, ok := pk.(*rsa.PublicKey); ok { + return rsaKey, nil + } + return nil, errors.New("alipay public key is not rsa") + } + if rsaKey, err := x509.ParsePKCS1PublicKey(der); err == nil { + return rsaKey, nil + } + return nil, errors.New("failed to parse alipay public key") +} + +func decodeAlipayKeyBytes(raw string) ([]byte, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, errors.New("empty alipay key") + } + if block, _ := pem.Decode([]byte(trimmed)); block != nil { + return block.Bytes, nil + } + compact := strings.Map(func(r rune) rune { + switch r { + case '\n', '\r', '\t', ' ': + return -1 + default: + return r + } + }, trimmed) + der, err := base64.StdEncoding.DecodeString(compact) + if err != nil { + return nil, err + } + return der, nil +} diff --git a/setting/payment_alipay.go b/setting/payment_alipay.go new file mode 100644 index 000000000000..0768d985f125 --- /dev/null +++ b/setting/payment_alipay.go @@ -0,0 +1,10 @@ +package setting + +// Alipay official gateway configuration for browser checkout. +// Gateway is enabled once AppID + PrivateKey + PublicKey are populated. +var ( + AlipayAppId string + AlipayGateway string = "https://openapi.alipay.com/gateway.do" + AlipayPrivateKey string + AlipayPublicKey string +) diff --git a/setting/system_setting/theme.go b/setting/system_setting/theme.go index 44dfc142941d..2848ed10f327 100644 --- a/setting/system_setting/theme.go +++ b/setting/system_setting/theme.go @@ -10,7 +10,7 @@ type ThemeSettings struct { } var themeSettings = ThemeSettings{ - Frontend: "classic", + Frontend: "default", } func init() { diff --git a/web/classic/src/constants/channel.constants.js b/web/classic/src/constants/channel.constants.js index 9fa78779de8f..d1e105a248ec 100644 --- a/web/classic/src/constants/channel.constants.js +++ b/web/classic/src/constants/channel.constants.js @@ -189,6 +189,11 @@ export const CHANNEL_OPTIONS = [ color: 'blue', label: 'Codex (OpenAI OAuth)', }, + { + value: 58, + color: 'blue', + label: 'ZLHubVideo', + }, ]; // Channel types that support upstream model list fetching in UI. diff --git a/web/classic/src/helpers/render.jsx b/web/classic/src/helpers/render.jsx index f48fe1f85af0..5e21d051e750 100644 --- a/web/classic/src/helpers/render.jsx +++ b/web/classic/src/helpers/render.jsx @@ -403,6 +403,8 @@ export function getChannelIcon(channelType) { return ; case 54: // 豆包视频 Doubao Video return ; + case 58: // ZLHub Video + return ; case 56: // Replicate return ; case 8: // 自定义渠道 diff --git a/web/default/scripts/sync-i18n.mjs b/web/default/scripts/sync-i18n.mjs index a4e5da6d6e28..0cd719e544f5 100644 --- a/web/default/scripts/sync-i18n.mjs +++ b/web/default/scripts/sync-i18n.mjs @@ -102,6 +102,7 @@ const BRAND_AND_LITERAL_KEYS = new Set([ 'Worker URL', 'Xinference', 'Xunfei', + 'ZLHubVideo', 'Zhipu V4', '"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"', 'edit_this', diff --git a/web/default/src/components/language-switcher.tsx b/web/default/src/components/language-switcher.tsx index e7fdcf2fc839..6a8fe2ca89f7 100644 --- a/web/default/src/components/language-switcher.tsx +++ b/web/default/src/components/language-switcher.tsx @@ -34,9 +34,27 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +function parseUserSetting(setting: unknown): Record { + if (typeof setting === 'string') { + try { + const parsed = JSON.parse(setting) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed + : {} + } catch { + return {} + } + } + + return setting && typeof setting === 'object' && !Array.isArray(setting) + ? (setting as Record) + : {} +} + export function LanguageSwitcher() { const { i18n, t } = useTranslation() const user = useAuthStore((s) => s.auth.user) + const setUser = useAuthStore((s) => s.auth.setUser) const currentLanguage = normalizeInterfaceLanguage(i18n.language) const handleChangeLanguage = useCallback( @@ -45,12 +63,20 @@ export function LanguageSwitcher() { if (user) { try { await api.put('/api/user/self', { language: code }) + const existingSetting = parseUserSetting(user.setting) + setUser({ + ...user, + setting: JSON.stringify({ + ...existingSetting, + language: normalizeInterfaceLanguage(code), + }), + }) } catch { // Best-effort persistence; don't block the UI on failure } } }, - [i18n, user] + [i18n, setUser, user] ) return ( diff --git a/web/default/src/features/channels/constants.ts b/web/default/src/features/channels/constants.ts index 5fd88d554820..a5a21edec508 100644 --- a/web/default/src/features/channels/constants.ts +++ b/web/default/src/features/channels/constants.ts @@ -76,12 +76,13 @@ export const CHANNEL_TYPES = { 55: 'Sora', 56: 'Replicate', 57: 'Codex', + 58: 'ZLHubVideo', } as const const CHANNEL_TYPE_DISPLAY_ORDER: number[] = [ 1, 14, 33, 24, 43, 3, 41, 48, 42, 34, 20, 4, 40, 27, 25, 17, 26, 15, 46, 23, 18, 45, 31, 35, 49, 19, 47, 37, 38, 39, 11, 8, 57, 22, 21, 44, 2, 5, 36, 50, - 51, 52, 53, 54, 55, 56, + 51, 52, 53, 54, 58, 55, 56, ] export const CHANNEL_TYPE_OPTIONS: { value: number; label: string }[] = (() => { diff --git a/web/default/src/features/channels/lib/channel-type-config.ts b/web/default/src/features/channels/lib/channel-type-config.ts index 097f942f81ab..9497d30ac055 100644 --- a/web/default/src/features/channels/lib/channel-type-config.ts +++ b/web/default/src/features/channels/lib/channel-type-config.ts @@ -134,6 +134,17 @@ export const CHANNEL_TYPE_CONFIGS: Record = { baseUrl: 'Default: https://api.replicate.com', }, }, + 58: { + id: 58, + name: CHANNEL_TYPES[58], + icon: 'doubao', + defaultBaseUrl: 'https://api.zlhub.cn', + hints: { + key: 'ZLHub API Key', + models: 'doubao-seedance-2.0,doubao-seedance-2.0-fast', + baseUrl: 'Default: https://api.zlhub.cn', + }, + }, } /** diff --git a/web/default/src/features/channels/lib/channel-utils.ts b/web/default/src/features/channels/lib/channel-utils.ts index 3b55f15eb63c..8f76fda241cb 100644 --- a/web/default/src/features/channels/lib/channel-utils.ts +++ b/web/default/src/features/channels/lib/channel-utils.ts @@ -100,6 +100,7 @@ export function getChannelTypeIcon(type: number): string { 36: 'Suno', // SunoAPI 55: 'OpenAI', // Sora 54: 'Doubao', // DoubaoVideo + 58: 'Doubao', // ZLHubVideo 56: 'Replicate', // Replicate // Tools & Platforms diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index daad50668a92..86bbac918724 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -61,6 +61,10 @@ const defaultBillingSettings: BillingSettings = { PayAddress: '', EpayId: '', EpayKey: '', + AlipayAppId: '', + AlipayGateway: 'https://openapi.alipay.com/gateway.do', + AlipayPrivateKey: '', + AlipayPublicKey: '', Price: 7.3, MinTopUp: 1, CustomCallbackAddress: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 1a1dc8a2f6c7..7371e0878f87 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -135,6 +135,10 @@ const BILLING_SECTIONS = [ PayAddress: settings.PayAddress, EpayId: settings.EpayId, EpayKey: settings.EpayKey, + AlipayAppId: settings.AlipayAppId, + AlipayGateway: settings.AlipayGateway, + AlipayPrivateKey: settings.AlipayPrivateKey, + AlipayPublicKey: settings.AlipayPublicKey, Price: settings.Price, MinTopUp: settings.MinTopUp, CustomCallbackAddress: settings.CustomCallbackAddress, diff --git a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx index 0f40545fa2de..d07e73b526aa 100644 --- a/web/default/src/features/system-settings/integrations/payment-settings-section.tsx +++ b/web/default/src/features/system-settings/integrations/payment-settings-section.tsx @@ -86,6 +86,14 @@ const paymentSchema = z.object({ }, 'Provide a valid callback URL starting with http:// or https://'), EpayId: z.string(), EpayKey: z.string(), + AlipayAppId: z.string(), + AlipayGateway: z.string().refine((value) => { + const trimmed = value.trim() + if (!trimmed) return true + return /^https?:\/\//.test(trimmed) + }, 'Provide a valid gateway URL starting with http:// or https://'), + AlipayPrivateKey: z.string(), + AlipayPublicKey: z.string(), Price: z.coerce.number().min(0), MinTopUp: z.coerce.number().min(0), CustomCallbackAddress: z.string().refine((value) => { @@ -401,11 +409,15 @@ export function PaymentSettingsSection({ const onSubmit = async (values: PaymentFormValues) => { const sanitized = { PayAddress: removeTrailingSlash(values.PayAddress), - EpayId: values.EpayId.trim(), - EpayKey: values.EpayKey.trim(), - Price: values.Price, - MinTopUp: values.MinTopUp, - CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress), + EpayId: values.EpayId.trim(), + EpayKey: values.EpayKey.trim(), + AlipayAppId: values.AlipayAppId.trim(), + AlipayGateway: removeTrailingSlash(values.AlipayGateway.trim()), + AlipayPrivateKey: values.AlipayPrivateKey.trim(), + AlipayPublicKey: values.AlipayPublicKey.trim(), + Price: values.Price, + MinTopUp: values.MinTopUp, + CustomCallbackAddress: removeTrailingSlash(values.CustomCallbackAddress), PayMethods: values.PayMethods.trim(), AmountOptions: values.AmountOptions.trim(), AmountDiscount: values.AmountDiscount.trim(), @@ -445,6 +457,10 @@ export function PaymentSettingsSection({ PayAddress: removeTrailingSlash(initialRef.current.PayAddress), EpayId: initialRef.current.EpayId.trim(), EpayKey: initialRef.current.EpayKey.trim(), + AlipayAppId: initialRef.current.AlipayAppId.trim(), + AlipayGateway: removeTrailingSlash(initialRef.current.AlipayGateway.trim()), + AlipayPrivateKey: initialRef.current.AlipayPrivateKey.trim(), + AlipayPublicKey: initialRef.current.AlipayPublicKey.trim(), Price: initialRef.current.Price, MinTopUp: initialRef.current.MinTopUp, CustomCallbackAddress: removeTrailingSlash( @@ -502,6 +518,34 @@ export function PaymentSettingsSection({ updates.push({ key: 'EpayKey', value: sanitized.EpayKey }) } + if (sanitized.AlipayAppId !== initial.AlipayAppId) { + updates.push({ key: 'AlipayAppId', value: sanitized.AlipayAppId }) + } + + if (sanitized.AlipayGateway !== initial.AlipayGateway) { + updates.push({ key: 'AlipayGateway', value: sanitized.AlipayGateway }) + } + + if ( + sanitized.AlipayPrivateKey && + sanitized.AlipayPrivateKey !== initial.AlipayPrivateKey + ) { + updates.push({ + key: 'AlipayPrivateKey', + value: sanitized.AlipayPrivateKey, + }) + } + + if ( + sanitized.AlipayPublicKey && + sanitized.AlipayPublicKey !== initial.AlipayPublicKey + ) { + updates.push({ + key: 'AlipayPublicKey', + value: sanitized.AlipayPublicKey, + }) + } + if (sanitized.Price !== initial.Price) { updates.push({ key: 'Price', value: sanitized.Price }) } @@ -1183,6 +1227,133 @@ export function PaymentSettingsSection({ +
+
+

{t('Alipay Gateway')}

+

+ {t('Configuration for Alipay official payment integration')} +

+
+ +
+

{t('Webhook Configuration:')}

+ +
+ +
+ ( + + {t('Alipay App ID')} + + field.onChange(event.target.value)} + /> + + + {t('App ID assigned by Alipay Open Platform')} + + + + )} + /> + + ( + + {t('Alipay gateway')} + + field.onChange(event.target.value)} + /> + + + {t('Leave the default gateway unless you are using a custom endpoint')} + + + + )} + /> +
+ +
+ ( + + {t('Alipay private key')} + +