-
Notifications
You must be signed in to change notification settings - Fork 11.1k
feat(wallet): add affiliate withdrawal feature #5856
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ import ( | |
|
|
||
| func GetTopUpInfo(c *gin.Context) { | ||
| complianceConfirmed := operation_setting.IsPaymentComplianceConfirmed() | ||
| affiliateSetting := operation_setting.GetAffiliateSetting() | ||
|
|
||
| // 获取支付方式 | ||
| payMethods := operation_setting.PayMethods | ||
|
|
@@ -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() | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
🤖 Prompt for AI Agents |
||
| return | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
|
@@ -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": "", | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.goRepository: 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.goRepository: QuantumNous/new-api Length of output: 4747 Hide admin-only withdrawal fields from this endpoint. 🤖 Prompt for AI Agents |
||
|
|
||
| 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) | ||
|
|
@@ -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{}{ | ||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.goRepository: QuantumNous/new-api Length of output: 396 Restore the exact
🤖 Prompt for AI Agents |
||
|
|
||
| func main() { | ||
|
|
||
There was a problem hiding this comment.
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_percentvalidation lets"NaN"slip through.strconv.ParseFloatparses"NaN"successfully (no error), and since NaN comparisons are alwaysfalse,percent < 0 || percent > 100never trips for it. An admin (or a raw API call bypassing the frontend Zod validation, which does reject NaN) could persistaffiliate_setting.reward_percent = NaN, corrupting rebate percentage calculations that consume this setting downstream.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents