Skip to content
Open
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
15 changes: 15 additions & 0 deletions controller/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ func UpdateOption(c *gin.Context) {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
case "affiliate_setting.enabled", "affiliate_setting.redemption_enabled", "affiliate_setting.withdraw_enabled":
if option.Value == "true" && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
Comment on lines +151 to +160

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

reward_percent validation lets "NaN" slip through.

strconv.ParseFloat parses "NaN" successfully (no error), and since NaN comparisons are always false, percent < 0 || percent > 100 never trips for it. An admin (or a raw API call bypassing the frontend Zod validation, which does reject NaN) could persist affiliate_setting.reward_percent = NaN, corrupting rebate percentage calculations that consume this setting downstream.

🐛 Proposed fix
 	case "affiliate_setting.reward_percent":
 		percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
-		if err != nil || percent < 0 || percent > 100 {
+		if err != nil || math.IsNaN(percent) || percent < 0 || percent > 100 {
 			common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
 			return
 		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
case "affiliate_setting.reward_percent":
percent, err := strconv.ParseFloat(strings.TrimSpace(option.Value.(string)), 64)
if err != nil || math.IsNaN(percent) || percent < 0 || percent > 100 {
common.ApiErrorMsg(c, "返利比例必须在 0 到 100 之间")
return
}
if percent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
common.ApiErrorI18n(c, i18n.MsgPaymentComplianceRequired)
return
}
🤖 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/option.go` around lines 151 - 160, The
`affiliate_setting.reward_percent` validation in `controller/option.go` accepts
`NaN` because `strconv.ParseFloat` succeeds and the existing range checks in the
`case "affiliate_setting.reward_percent"` block do not reject non-finite values.
Update this branch to explicitly reject `NaN` (and any other non-finite numeric
input) before the 0–100 range check, while keeping the existing compliance check
and error handling intact. Use the `option.Value` parsing path and the
`common.ApiErrorMsg` / `common.ApiErrorI18n` responses so invalid values are
blocked consistently.

default:
if isPaymentComplianceOptionKey(option.Key) {
common.ApiErrorMsg(c, "合规确认字段不允许通过通用设置接口修改")
Expand Down
6 changes: 6 additions & 0 deletions controller/topup.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

func GetTopUpInfo(c *gin.Context) {
complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed()
affiliateSetting := operation_setting.GetAffiliateSetting()

// 获取支付方式
payMethods := operation_setting.PayMethods
Expand Down Expand Up @@ -104,6 +105,8 @@ func GetTopUpInfo(c *gin.Context) {
"enable_redemption": complianceConfirmed,
"payment_compliance_confirmed": complianceConfirmed,
"payment_compliance_terms_version": operation_setting.CurrentComplianceTermsVersion,
"affiliate_enabled": complianceConfirmed && affiliateSetting.Enabled,
"affiliate_withdraw_enabled": complianceConfirmed && affiliateSetting.Enabled && affiliateSetting.WithdrawEnabled,
"waffo_pay_methods": func() interface{} {
if enableWaffo {
return setting.GetWaffoPayMethods()
Expand Down Expand Up @@ -399,6 +402,9 @@ func EpayNotify(c *gin.Context) {
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
quotaToAdd := int(dAmount.Mul(dQuotaPerUnit).IntPart())
err = model.IncreaseUserQuota(topUp.UserId, quotaToAdd, true)
if err == nil {
err = model.CreateAffiliateRebateForTopUp(topUp, quotaToAdd)
}
if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("易支付 更新用户额度失败 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)))
Comment on lines +405 to 409

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Don’t let an affiliate-only failure leave Epay accounting partially applied.

IncreaseUserQuota commits before CreateAffiliateRebateForTopUp, and the webhook has already returned "success". If rebate creation fails, the user is credited but the inviter rebate is missing with no provider retry. Move top-up status, quota credit, and rebate creation into one model transaction before acknowledging, or persist a retryable affiliate-rebate job instead.

🤖 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.go` around lines 405 - 409, The top-up flow in
IncreaseUserQuota currently applies the user quota and then calls
CreateAffiliateRebateForTopUp after the Epay success response path, which can
leave accounting partially applied if the rebate fails. Move the topUp status
update, quota credit, and affiliate rebate creation into a single model
transaction before returning success, or otherwise enqueue a retryable
affiliate-rebate job so the webhook is not acknowledged until both the top-up
and rebate steps are safely persisted.

return
Expand Down
141 changes: 139 additions & 2 deletions controller/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,47 @@ func canManageTargetRole(myRole int, targetRole int) bool {
return myRole == common.RoleRootUser || myRole > targetRole
}

func getAffiliateRulePayload(userId int) (*model.AffiliateUserRulePayload, error) {
global := operation_setting.GetAffiliateSetting()
payload := &model.AffiliateUserRulePayload{
Enabled: global.Enabled,
RewardPercent: global.RewardPercent,
SettleAfterInviteeConsumed: global.SettleAfterInviteeConsumed,
}
rule, found, err := model.GetAffiliateUserRule(userId)
if err != nil {
return nil, err
}
if found {
payload.Custom = true
payload.Enabled = rule.Enabled
payload.RewardPercent = rule.RewardPercent
payload.SettleAfterInviteeConsumed = rule.SettleAfterInviteeConsumed
}
return payload, nil
}

func updateAffiliateRuleForUserInTx(tx *gorm.DB, userId int, payload *model.AffiliateUserRulePayload) error {
if payload == nil {
return nil
}
if !payload.Custom {
return model.DeleteAffiliateUserRuleWithTx(tx, userId)
}
if payload.RewardPercent < 0 || payload.RewardPercent > 100 {
return errors.New("affiliate reward percent must be between 0 and 100")
}
if payload.Enabled && payload.RewardPercent > 0 && !operation_setting.IsPaymentComplianceConfirmed() {
return model.ErrAffiliateRuleDisabled
}
return model.SaveAffiliateUserRuleWithTx(tx, &model.AffiliateUserRule{
UserId: userId,
Enabled: payload.Enabled,
RewardPercent: payload.RewardPercent,
SettleAfterInviteeConsumed: payload.SettleAfterInviteeConsumed,
})
}

func GetUser(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
Expand All @@ -337,6 +378,11 @@ func GetUser(c *gin.Context) {
return
}
user.AdminPermissions = authz.Capabilities(user.Id, user.Role)
user.AffiliateRule, err = getAffiliateRulePayload(user.Id)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
Expand Down Expand Up @@ -408,6 +454,83 @@ func TransferAffQuota(c *gin.Context) {
common.ApiSuccessI18n(c, i18n.MsgUserTransferSuccess, nil)
}

type AffiliateWithdrawalRequest struct {
Amount int `json:"amount"`
PaymentMethod string `json:"payment_method"`
Account string `json:"account"`
Remark string `json:"remark"`
}

func CreateAffiliateWithdrawal(c *gin.Context) {
var req AffiliateWithdrawalRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
req.PaymentMethod = strings.TrimSpace(req.PaymentMethod)
req.Account = strings.TrimSpace(req.Account)
req.Remark = strings.TrimSpace(req.Remark)
if req.Amount <= 0 || req.PaymentMethod == "" || req.Account == "" {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
withdrawal, err := model.CreateAffiliateWithdrawal(c.GetInt("id"), req.Amount, req.PaymentMethod, req.Account, req.Remark)
if err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, withdrawal)
}

func GetAffiliateWithdrawals(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
withdrawals, total, err := model.GetUserAffiliateWithdrawals(c.GetInt("id"), pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(withdrawals)
common.ApiSuccess(c, pageInfo)
}
Comment on lines +485 to +495

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect AffiliateWithdrawal struct json tags for AdminRemark/ProcessedBy
rg -n -A20 'type AffiliateWithdrawal struct' model/affiliate.go

Repository: QuantumNous/new-api

Length of output: 1073


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== model.GetUserAffiliateWithdrawals =="
rg -n -A40 'func GetUserAffiliateWithdrawals' model/affiliate.go

echo
echo "== page info response shape =="
rg -n -A40 'type PageInfo struct|type Page' common model controller | head -n 120

echo
echo "== controller GetAffiliateWithdrawals =="
rg -n -A20 'func GetAffiliateWithdrawals' controller/user.go

Repository: QuantumNous/new-api

Length of output: 4747


Hide admin-only withdrawal fields from this endpoint. GetAffiliateWithdrawals returns *model.AffiliateWithdrawal directly, so admin_remark and processed_by are serialized to the requester. Return a user-facing DTO or exclude those fields here.

🤖 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/user.go` around lines 485 - 495, GetAffiliateWithdrawals is
exposing admin-only fields because it returns model.AffiliateWithdrawal objects
directly. Update this handler to map the results from
model.GetUserAffiliateWithdrawals into a user-facing DTO or filtered response
before calling common.ApiSuccess, ensuring admin_remark and processed_by are not
serialized. Use GetAffiliateWithdrawals and the model.AffiliateWithdrawal type
as the key spots to adjust.


type AffiliateWithdrawalProcessRequest struct {
Status string `json:"status"`
AdminRemark string `json:"admin_remark"`
}

func GetAllAffiliateWithdrawals(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
withdrawals, total, err := model.GetAllAffiliateWithdrawals(pageInfo)
if err != nil {
common.ApiError(c, err)
return
}
pageInfo.SetTotal(int(total))
pageInfo.SetItems(withdrawals)
common.ApiSuccess(c, pageInfo)
}

func ProcessAffiliateWithdrawal(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
var req AffiliateWithdrawalProcessRequest
if err := common.DecodeJson(c.Request.Body, &req); err != nil {
common.ApiErrorI18n(c, i18n.MsgInvalidParams)
return
}
req.Status = strings.TrimSpace(req.Status)
req.AdminRemark = strings.TrimSpace(req.AdminRemark)
if err := model.UpdateAffiliateWithdrawalStatus(id, req.Status, req.AdminRemark, c.GetInt("id")); err != nil {
common.ApiError(c, err)
return
}
common.ApiSuccess(c, nil)
}

func GetAffCode(c *gin.Context) {
id := c.GetInt("id")
user, err := model.GetUserById(id, true)
Expand Down Expand Up @@ -450,6 +573,16 @@ func GetSelf(c *gin.Context) {

// 获取用户设置并提取sidebar_modules
userSetting := user.GetSetting()
pendingAffiliateQuota, err := model.GetPendingAffiliateQuota(user.Id)
if err != nil {
common.SysLog(fmt.Sprintf("failed to get pending affiliate quota for user %d: %s", user.Id, err.Error()))
pendingAffiliateQuota = 0
}
affiliateInviteCount, err := model.GetAffiliateInviteCount(user.Id)
if err != nil {
common.SysLog(fmt.Sprintf("failed to get affiliate invite count for user %d: %s", user.Id, err.Error()))
affiliateInviteCount = user.AffCount
}

// 构建响应数据,包含用户信息和权限
responseData := map[string]interface{}{
Expand All @@ -469,8 +602,9 @@ func GetSelf(c *gin.Context) {
"used_quota": user.UsedQuota,
"request_count": user.RequestCount,
"aff_code": user.AffCode,
"aff_count": user.AffCount,
"aff_count": affiliateInviteCount,
"aff_quota": user.AffQuota,
"aff_pending_quota": pendingAffiliateQuota,
"aff_history_quota": user.AffHistoryQuota,
"inviter_id": user.InviterId,
"linux_do_id": user.LinuxDOId,
Expand Down Expand Up @@ -664,7 +798,10 @@ func UpdateUser(c *gin.Context) {
}
touched, err := updateAdminPermissionsForUserInTx(c, tx, updatedUser.Id, originUser.Role, updatedUser.AdminPermissions)
authzTouched = touched
return err
if err != nil {
return err
}
return updateAffiliateRuleForUserInTx(tx, updatedUser.Id, updatedUser.AffiliateRule)
}); err != nil {
common.ApiError(c, err)
return
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2000,8 +2000,6 @@ github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmF
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
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-go v1.3.2 h1:HCaG7hPcj4vGIW5rJ4J8DI6BHuvO4Nt0ChsQc39pazs=
github.com/waffo-com/waffo-go v1.3.2/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc=
Expand Down
8 changes: 4 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ import (
_ "net/http/pprof"
)

//go:embed web/default/dist
// go:embed web/default/dist
var buildFS embed.FS

//go:embed web/default/dist/index.html
// go:embed web/default/dist/index.html
var indexPage []byte

//go:embed web/classic/dist
// go:embed web/classic/dist
var classicBuildFS embed.FS

//go:embed web/classic/dist/index.html
// go:embed web/classic/dist/index.html
var classicIndexPage []byte
Comment on lines +39 to 49

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm the embed directives use the exact `//go:embed` form.
sed -n '39,49p' main.go

Repository: QuantumNous/new-api

Length of output: 396


Restore the exact //go:embed syntax.

// go:embed is a plain comment, so these assets won’t be embedded and the web entrypoint will fail to load its static files.

🤖 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 `@main.go` around lines 39 - 49, The embed directives in main.go are written as
plain comments instead of active directives, so the static assets will not be
included. Update the declarations for buildFS, indexPage, classicBuildFS, and
classicIndexPage to use the exact //go:embed syntax so the web entrypoint can
load its files correctly.


func main() {
Expand Down
Loading
Loading