Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
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

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}
Expand All @@ -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

Expand Down
9 changes: 7 additions & 2 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions constant/channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -175,6 +177,7 @@ var ChannelTypeNames = map[int]string{
ChannelTypeSora: "Sora",
ChannelTypeReplicate: "Replicate",
ChannelTypeCodex: "Codex",
ChannelTypeZLHubVideo: "ZLHubVideo",
}

func GetChannelTypeName(channelType int) string {
Expand Down
1 change: 1 addition & 0 deletions controller/channel-test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions controller/payment_webhook_availability.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
26 changes: 26 additions & 0 deletions controller/payment_webhook_availability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
20 changes: 20 additions & 0 deletions controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
194 changes: 194 additions & 0 deletions controller/topup_alipay.go
Original file line number Diff line number Diff line change
@@ -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)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid logging full Alipay callback params.

These logs serialize the entire webhook payload, which can include sensitive user/payment metadata. Log only minimal identifiers (e.g., trade_no, trade_status) and redact/mask the rest.

Minimal redaction example
- logger.LogWarn(..., common.GetJsonString(params)))
+ safe := map[string]string{
+   "out_trade_no": strings.TrimSpace(params["out_trade_no"]),
+   "trade_status": strings.TrimSpace(params["trade_status"]),
+ }
+ logger.LogWarn(..., common.GetJsonString(safe)))

Also applies to: 151-151, 161-161

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/topup_alipay.go` at line 138, The warning currently logs the full
Alipay callback params (logger.LogWarn call in topup_alipay.go) which may
contain sensitive data; update the LogWarn invocations (the one at signature
verification and the similar calls around the other occurrences) to extract and
log only minimal identifiers such as params["trade_no"] and
params["trade_status"] and, if necessary, a short masked summary (e.g., mask all
other fields or replace with "[REDACTED]") instead of using
common.GetJsonString(params); ensure you still include context like request URI
and client IP but remove or redact full payload content when constructing the
log message.

_, _ = 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
}
Comment on lines +171 to +187

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Make order state transition and quota credit atomic.

topUp.Status is persisted before quota credit. If quota update fails, the order remains success and later retries won’t credit the user. This can permanently lose funds/credits. Use a single DB transaction with a conditional pending→success update and quota increment in the same transaction.

Suggested direction
- if topUp.Status == common.TopUpStatusPending {
-   topUp.Status = common.TopUpStatusSuccess
-   topUp.CompleteTime = common.GetTimestamp()
-   if err := topUp.Update(); err != nil { ... }
-   ...
-   if err := model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true); err != nil { ... }
- }
+ // Use a transactional model method that:
+ // 1) locks/conditionally updates pending->success
+ // 2) increments user quota
+ // 3) commits or rolls back as one unit
+ // 4) returns idempotent success when already completed
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@controller/topup_alipay.go` around lines 171 - 187, The code persists
topUp.Status = TopUpStatusSuccess via topUp.Update() before calling
model.IncreaseUserQuota, which can leave the order marked success if quota
credit fails; instead perform both the conditional pending→success update and
the quota increment inside a single DB transaction: open a transaction, run an
UPDATE on the topup row (or call a transactional method) that sets Status and
CompleteTime only where Status == TopUpStatusPending and check affected rows >
0, then call model.IncreaseUserQuota (or a new IncreaseUserQuotaTx) using the
same transaction; if any step fails rollback and return "fail", otherwise commit
and return success. Ensure to reference topUp.Update, topUp.Status/CompleteTime,
and model.IncreaseUserQuota (or implement a transactional variant) so the
transition + quota credit are atomic.


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"))
}
2 changes: 1 addition & 1 deletion docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ services:
container_name: new-api-dev
restart: unless-stopped
ports:
- "3000:3000"
- "3004:3000"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Port change breaks the documented dev proxy flow.

The backend container is now published on host port 3004, but the Rsbuild dev server proxies /api, /mj, /pg to serverUrl which defaults to http://localhost:3000 (web/default/rsbuild.config.ts), and the comment on Line 6 still states "API auto-proxied to :3000". After this change, API requests from the dev server (run on host via bun run dev) will hit localhost:3000 where nothing is listening, so the dev workflow breaks unless the user manually sets VITE_REACT_APP_SERVER_URL=http://localhost:3004.

Either keep the dev mapping at "3000:3000", or update the proxy default and the Line 6 comment to :3004 consistently.

Proposed fix (keep dev backend on :3000)
     ports:
-      - "3004:3000"
+      - "3000:3000"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.dev.yml` at line 26, The docker-compose port change exposes
the backend on host port 3004 but the dev proxy (web/default/rsbuild.config.ts)
and the comment still assume http://localhost:3000, breaking the dev proxy flow;
fix by either reverting the docker-compose mapping back to "3000:3000" so the
container publishes to host 3000, or update the default serverUrl in
rsbuild.config.ts (and the comment on Line 6) to use http://localhost:3004 so
the dev server proxies to the new host port consistently; ensure the chosen
option updates both the docker-compose port mapping ("3000:3000" vs "3004:3000")
or the serverUrl/default comment in rsbuild.config.ts accordingly.

volumes:
- dev_data:/data
environment:
Expand Down
Loading