diff --git a/.gitignore b/.gitignore index bf7ee064..a61f406d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ docs/claude-relay-service/ +.codex # =================== # Go 后端 diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 1fcba8fa..841597f0 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.118 +0.1.119 diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 40bf1c69..320dbd6b 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -186,6 +186,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { DefaultConcurrency: settings.DefaultConcurrency, DefaultBalance: settings.DefaultBalance, AffiliateRebateRate: settings.AffiliateRebateRate, + AffiliateRebateFreezeHours: settings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: settings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: settings.AffiliateRebatePerInviteeCap, DefaultUserRPMLimit: settings.DefaultUserRPMLimit, DefaultSubscriptions: defaultSubscriptions, EnableModelFallback: settings.EnableModelFallback, @@ -342,6 +345,9 @@ type UpdateSettingsRequest struct { DefaultConcurrency int `json:"default_concurrency"` DefaultBalance float64 `json:"default_balance"` AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"` DefaultUserRPMLimit int `json:"default_user_rpm_limit"` DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"` AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"` @@ -485,6 +491,33 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { if affiliateRebateRate > service.AffiliateRebateRateMax { affiliateRebateRate = service.AffiliateRebateRateMax } + affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours + if req.AffiliateRebateFreezeHours != nil { + affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours + } + if affiliateRebateFreezeHours < 0 { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault + } + if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax { + affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax + } + affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays + if req.AffiliateRebateDurationDays != nil { + affiliateRebateDurationDays = *req.AffiliateRebateDurationDays + } + if affiliateRebateDurationDays < 0 { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault + } + if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax { + affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax + } + affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap + if req.AffiliateRebatePerInviteeCap != nil { + affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap + } + if affiliateRebatePerInviteeCap < 0 { + affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault + } // 通用表格配置:兼容旧客户端未传字段时保留当前值。 if req.TableDefaultPageSize <= 0 { req.TableDefaultPageSize = previousSettings.TableDefaultPageSize @@ -1137,6 +1170,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DefaultConcurrency: req.DefaultConcurrency, DefaultBalance: req.DefaultBalance, AffiliateRebateRate: affiliateRebateRate, + AffiliateRebateFreezeHours: affiliateRebateFreezeHours, + AffiliateRebateDurationDays: affiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap, DefaultUserRPMLimit: req.DefaultUserRPMLimit, DefaultSubscriptions: defaultSubscriptions, EnableModelFallback: req.EnableModelFallback, @@ -1458,6 +1494,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { DefaultConcurrency: updatedSettings.DefaultConcurrency, DefaultBalance: updatedSettings.DefaultBalance, AffiliateRebateRate: updatedSettings.AffiliateRebateRate, + AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours, + AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays, + AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap, DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit, DefaultSubscriptions: updatedDefaultSubscriptions, EnableModelFallback: updatedSettings.EnableModelFallback, @@ -1768,6 +1807,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.AffiliateRebateRate != after.AffiliateRebateRate { changed = append(changed, "affiliate_rebate_rate") } + if before.AffiliateRebateFreezeHours != after.AffiliateRebateFreezeHours { + changed = append(changed, "affiliate_rebate_freeze_hours") + } + if before.AffiliateRebateDurationDays != after.AffiliateRebateDurationDays { + changed = append(changed, "affiliate_rebate_duration_days") + } + if before.AffiliateRebatePerInviteeCap != after.AffiliateRebatePerInviteeCap { + changed = append(changed, "affiliate_rebate_per_invitee_cap") + } if !equalDefaultSubscriptions(before.DefaultSubscriptions, after.DefaultSubscriptions) { changed = append(changed, "default_subscriptions") } diff --git a/backend/internal/handler/auth_linuxdo_oauth.go b/backend/internal/handler/auth_linuxdo_oauth.go index 2ef05963..7df4abfd 100644 --- a/backend/internal/handler/auth_linuxdo_oauth.go +++ b/backend/internal/handler/auth_linuxdo_oauth.go @@ -435,6 +435,7 @@ func (h *AuthHandler) createLinuxDoOAuthChoicePendingSession( type completeLinuxDoOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -518,7 +519,7 @@ func (h *AuthHandler) CompleteLinuxDoOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_oauth_pending_flow.go b/backend/internal/handler/auth_oauth_pending_flow.go index 604ad903..490afd0f 100644 --- a/backend/internal/handler/auth_oauth_pending_flow.go +++ b/backend/internal/handler/auth_oauth_pending_flow.go @@ -67,6 +67,7 @@ type createPendingOAuthAccountRequest struct { VerifyCode string `json:"verify_code,omitempty"` Password string `json:"password" binding:"required,min=6"` InvitationCode string `json:"invitation_code,omitempty"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -1751,6 +1752,7 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string) user, strings.TrimSpace(req.InvitationCode), strings.TrimSpace(session.ProviderType), + strings.TrimSpace(req.AffCode), ); err != nil { _ = tx.Rollback() if rollbackCreatedUser(err) { diff --git a/backend/internal/handler/auth_oidc_oauth.go b/backend/internal/handler/auth_oidc_oauth.go index 0ac8871b..4264002d 100644 --- a/backend/internal/handler/auth_oidc_oauth.go +++ b/backend/internal/handler/auth_oidc_oauth.go @@ -582,6 +582,7 @@ func (h *AuthHandler) createOIDCOAuthChoicePendingSession( type completeOIDCOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -665,7 +666,7 @@ func (h *AuthHandler) CompleteOIDCOAuthRegistration(c *gin.Context) { response.ErrorFrom(c, err) return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/auth_wechat_oauth.go b/backend/internal/handler/auth_wechat_oauth.go index efee4cc0..34e70ed0 100644 --- a/backend/internal/handler/auth_wechat_oauth.go +++ b/backend/internal/handler/auth_wechat_oauth.go @@ -481,6 +481,7 @@ func (h *AuthHandler) wechatPaymentResumeService() *service.PaymentResumeService type completeWeChatOAuthRequest struct { InvitationCode string `json:"invitation_code" binding:"required"` + AffCode string `json:"aff_code,omitempty"` AdoptDisplayName *bool `json:"adopt_display_name,omitempty"` AdoptAvatar *bool `json:"adopt_avatar,omitempty"` } @@ -547,7 +548,7 @@ func (h *AuthHandler) CompleteWeChatOAuthRegistration(c *gin.Context) { return } - tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode) + tokenPair, user, err := h.authService.LoginOrRegisterOAuthWithTokenPair(c.Request.Context(), email, username, req.InvitationCode, req.AffCode) if err != nil { response.ErrorFrom(c, err) return diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 051fab18..92ae4dc6 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -106,11 +106,14 @@ type SystemSettings struct { CustomMenuItems []CustomMenuItem `json:"custom_menu_items"` CustomEndpoints []CustomEndpoint `json:"custom_endpoints"` - DefaultConcurrency int `json:"default_concurrency"` - DefaultBalance float64 `json:"default_balance"` - AffiliateRebateRate float64 `json:"affiliate_rebate_rate"` - DefaultUserRPMLimit int `json:"default_user_rpm_limit"` - DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` + DefaultConcurrency int `json:"default_concurrency"` + DefaultBalance float64 `json:"default_balance"` + AffiliateRebateRate float64 `json:"affiliate_rebate_rate"` + AffiliateRebateFreezeHours int `json:"affiliate_rebate_freeze_hours"` + AffiliateRebateDurationDays int `json:"affiliate_rebate_duration_days"` + AffiliateRebatePerInviteeCap float64 `json:"affiliate_rebate_per_invitee_cap"` + DefaultUserRPMLimit int `json:"default_user_rpm_limit"` + DefaultSubscriptions []DefaultSubscriptionSetting `json:"default_subscriptions"` // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 37bd38b2..e7d8aab9 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -25,6 +25,7 @@ const ( easypayStatusPaid = 1 easypayHTTPTimeout = 10 * time.Second maxEasypayResponseSize = 1 << 20 // 1MB + maxEasypayErrorSummary = 512 tradeStatusSuccess = "TRADE_SUCCESS" signTypeMD5 = "MD5" paymentModePopup = "popup" @@ -42,17 +43,55 @@ type EasyPay struct { // config keys: pid, pkey, apiBase, notifyUrl, returnUrl, cid, cidAlipay, cidWxpay func NewEasyPay(instanceID string, config map[string]string) (*EasyPay, error) { for _, k := range []string{"pid", "pkey", "apiBase", "notifyUrl", "returnUrl"} { - if config[k] == "" { + if strings.TrimSpace(config[k]) == "" { return nil, fmt.Errorf("easypay config missing required key: %s", k) } } + cfg := make(map[string]string, len(config)) + for k, v := range config { + cfg[k] = v + } + cfg["apiBase"] = normalizeEasyPayAPIBase(cfg["apiBase"]) return &EasyPay{ instanceID: instanceID, - config: config, + config: cfg, httpClient: &http.Client{Timeout: easypayHTTPTimeout}, }, nil } +func normalizeEasyPayAPIBase(apiBase string) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return "" + } + if parsed, err := url.Parse(base); err == nil && parsed.Scheme != "" && parsed.Host != "" { + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.RawPath = "" + parsed.Path = trimEasyPayEndpointPath(parsed.Path) + return strings.TrimRight(parsed.String(), "/") + } + return strings.TrimRight(trimEasyPayEndpointPath(base), "/") +} + +func trimEasyPayEndpointPath(path string) string { + path = strings.TrimRight(strings.TrimSpace(path), "/") + lower := strings.ToLower(path) + for _, endpoint := range []string{"/submit.php", "/mapi.php", "/api.php"} { + if strings.HasSuffix(lower, endpoint) { + return strings.TrimRight(path[:len(path)-len(endpoint)], "/") + } + } + return path +} + +func (e *EasyPay) apiBase() string { + if e == nil { + return "" + } + return normalizeEasyPayAPIBase(e.config["apiBase"]) +} + func (e *EasyPay) Name() string { return "EasyPay" } func (e *EasyPay) ProviderKey() string { return payment.TypeEasyPay } func (e *EasyPay) SupportedTypes() []payment.PaymentType { @@ -104,8 +143,7 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym for k, v := range params { q.Set(k, v) } - base := strings.TrimRight(e.config["apiBase"], "/") - payURL := base + "/submit.php?" + q.Encode() + payURL := e.apiBase() + "/submit.php?" + q.Encode() return &payment.CreatePaymentResponse{PayURL: payURL}, nil } @@ -127,7 +165,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen params["sign"] = easyPaySign(params, e.config["pkey"]) params["sign_type"] = signTypeMD5 - body, err := e.post(ctx, strings.TrimRight(e.config["apiBase"], "/")+"/mapi.php", params) + body, err := e.post(ctx, e.apiBase()+"/mapi.php", params) if err != nil { return nil, fmt.Errorf("easypay create: %w", err) } @@ -171,7 +209,7 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer "act": "order", "pid": e.config["pid"], "key": e.config["pkey"], "out_trade_no": tradeNo, } - body, err := e.post(ctx, e.config["apiBase"]+"/api.php", params) + body, err := e.post(ctx, e.apiBase()+"/api.php", params) if err != nil { return nil, fmt.Errorf("easypay query: %w", err) } @@ -234,25 +272,128 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st } func (e *EasyPay) Refund(ctx context.Context, req payment.RefundRequest) (*payment.RefundResponse, error) { - params := map[string]string{ - "pid": e.config["pid"], "key": e.config["pkey"], - "trade_no": req.TradeNo, "out_trade_no": req.OrderID, "money": req.Amount, + attempts := e.refundAttempts(req) + if len(attempts) == 0 { + return nil, fmt.Errorf("easypay refund missing order identifier") } - body, err := e.post(ctx, e.config["apiBase"]+"/api.php?act=refund", params) - if err != nil { - return nil, fmt.Errorf("easypay refund: %w", err) + var firstErr error + for i, attempt := range attempts { + body, status, err := e.postRaw(ctx, e.apiBase()+"/api.php?act=refund", attempt.params) + if err != nil { + return nil, fmt.Errorf("easypay refund request: %w", err) + } + if err := parseEasyPayRefundResponse(status, body); err != nil { + if firstErr == nil { + firstErr = err + } + if i+1 < len(attempts) && isEasyPayRefundOrderNotFound(err) { + continue + } + return nil, err + } + return &payment.RefundResponse{RefundID: attempt.refundID, Status: payment.ProviderStatusSuccess}, nil } + return nil, firstErr +} + +type easyPayRefundAttempt struct { + params map[string]string + refundID string +} + +func (e *EasyPay) refundAttempts(req payment.RefundRequest) []easyPayRefundAttempt { + base := map[string]string{ + "pid": e.config["pid"], "key": e.config["pkey"], "money": req.Amount, + } + var attempts []easyPayRefundAttempt + if orderID := strings.TrimSpace(req.OrderID); orderID != "" { + params := cloneStringMap(base) + params["out_trade_no"] = orderID + attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: orderID}) + } + if tradeNo := strings.TrimSpace(req.TradeNo); tradeNo != "" { + params := cloneStringMap(base) + params["trade_no"] = tradeNo + attempts = append(attempts, easyPayRefundAttempt{params: params, refundID: tradeNo}) + } + return attempts +} + +func cloneStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func isEasyPayRefundOrderNotFound(err error) bool { + if err == nil { + return false + } + msg := err.Error() + lower := strings.ToLower(msg) + return strings.Contains(msg, "订单编号不存在") || + strings.Contains(msg, "订单不存在") || + strings.Contains(lower, "order not found") || + strings.Contains(lower, "not exist") +} + +func parseEasyPayRefundResponse(status int, body []byte) error { + summary := summarizeEasyPayResponse(body) + if status < http.StatusOK || status >= http.StatusMultipleChoices { + return fmt.Errorf("easypay refund HTTP %d: %s", status, summary) + } + + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return fmt.Errorf("easypay refund empty response (HTTP %d): %s", status, summary) + } + + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "" + } + if len(summary) > maxEasypayErrorSummary { + return summary[:maxEasypayErrorSummary] + "..." + } + return summary } func (e *EasyPay) resolveCID(paymentType string) string { @@ -269,21 +410,34 @@ func (e *EasyPay) resolveCID(paymentType string) string { } func (e *EasyPay) post(ctx context.Context, endpoint string, params map[string]string) ([]byte, error) { + body, _, err := e.postRaw(ctx, endpoint, params) + return body, err +} + +func (e *EasyPay) postRaw(ctx context.Context, endpoint string, params map[string]string) ([]byte, int, error) { form := url.Values{} for k, v := range params { form.Set(k, v) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { - return nil, err + return nil, 0, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := e.httpClient.Do(req) + client := e.httpClient + if client == nil { + client = &http.Client{Timeout: easypayHTTPTimeout} + } + resp, err := client.Do(req) if err != nil { - return nil, err + return nil, 0, err } defer func() { _ = resp.Body.Close() }() - return io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize)) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxEasypayResponseSize)) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil } func easyPaySign(params map[string]string, pkey string) string { diff --git a/backend/internal/payment/provider/easypay_refund_test.go b/backend/internal/payment/provider/easypay_refund_test.go new file mode 100644 index 00000000..9e0e4942 --- /dev/null +++ b/backend/internal/payment/provider/easypay_refund_test.go @@ -0,0 +1,196 @@ +package provider + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/payment" +) + +func TestNormalizeEasyPayAPIBase(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + want string + }{ + {input: "https://zpayz.cn", want: "https://zpayz.cn"}, + {input: "https://zpayz.cn/", want: "https://zpayz.cn"}, + {input: "https://zpayz.cn/mapi.php", want: "https://zpayz.cn"}, + {input: "https://zpayz.cn/submit.php", want: "https://zpayz.cn"}, + {input: "https://zpayz.cn/api.php", want: "https://zpayz.cn"}, + {input: "https://zpayz.cn/api.php?act=refund", want: "https://zpayz.cn"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + t.Parallel() + if got := normalizeEasyPayAPIBase(tt.input); got != tt.want { + t.Fatalf("normalizeEasyPayAPIBase(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestEasyPayRefundNormalizesAPIBaseAndSendsOutTradeNoOnly(t *testing.T) { + t.Parallel() + + var gotPath string + var gotQuery url.Values + var gotForm url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.Query() + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + } + gotForm = r.PostForm + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`)) + })) + defer server.Close() + + provider := newTestEasyPay(t, server.URL+"/mapi.php") + resp, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "trade-123", + OrderID: "out-456", + Amount: "1.50", + }) + if err != nil { + t.Fatalf("Refund returned error: %v", err) + } + if resp == nil || resp.Status != payment.ProviderStatusSuccess { + t.Fatalf("Refund response = %+v, want success", resp) + } + if gotPath != "/api.php" { + t.Fatalf("refund path = %q, want /api.php", gotPath) + } + if gotQuery.Get("act") != "refund" { + t.Fatalf("refund act query = %q, want refund", gotQuery.Get("act")) + } + for key, want := range map[string]string{ + "pid": "pid-1", + "key": "pkey-1", + "out_trade_no": "out-456", + "money": "1.50", + } { + if got := gotForm.Get(key); got != want { + t.Fatalf("form[%s] = %q, want %q (form=%v)", key, got, want, gotForm) + } + } + if got := gotForm.Get("trade_no"); got != "" { + t.Fatalf("form[trade_no] = %q, want empty (form=%v)", got, gotForm) + } +} + +func TestEasyPayRefundRetriesWithTradeNoWhenOutTradeNoNotFound(t *testing.T) { + t.Parallel() + + var gotForms []url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api.php" { + t.Errorf("refund path = %q, want /api.php", r.URL.Path) + } + if r.URL.Query().Get("act") != "refund" { + t.Errorf("refund act query = %q, want refund", r.URL.Query().Get("act")) + } + if err := r.ParseForm(); err != nil { + t.Errorf("ParseForm: %v", err) + } + gotForms = append(gotForms, r.PostForm) + w.Header().Set("Content-Type", "application/json") + if len(gotForms) == 1 { + _, _ = w.Write([]byte(`{"code":0,"msg":"订单编号不存在!"}`)) + return + } + _, _ = w.Write([]byte(`{"code":1,"msg":"ok"}`)) + })) + defer server.Close() + + provider := newTestEasyPay(t, server.URL+"/mapi.php") + resp, err := provider.Refund(context.Background(), payment.RefundRequest{ + TradeNo: "trade-123", + OrderID: "out-456", + Amount: "1.50", + }) + if err != nil { + t.Fatalf("Refund returned error: %v", err) + } + if resp == nil || resp.Status != payment.ProviderStatusSuccess || resp.RefundID != "trade-123" { + t.Fatalf("Refund response = %+v, want success with trade refund id", resp) + } + if len(gotForms) != 2 { + t.Fatalf("refund attempts = %d, want 2", len(gotForms)) + } + if got := gotForms[0].Get("out_trade_no"); got != "out-456" { + t.Fatalf("first form[out_trade_no] = %q, want out-456 (form=%v)", got, gotForms[0]) + } + if got := gotForms[0].Get("trade_no"); got != "" { + t.Fatalf("first form[trade_no] = %q, want empty (form=%v)", got, gotForms[0]) + } + if got := gotForms[1].Get("trade_no"); got != "trade-123" { + t.Fatalf("second form[trade_no] = %q, want trade-123 (form=%v)", got, gotForms[1]) + } + if got := gotForms[1].Get("out_trade_no"); got != "" { + t.Fatalf("second form[out_trade_no] = %q, want empty (form=%v)", got, gotForms[1]) + } +} + +func TestEasyPayRefundResponseErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + body string + want string + }{ + {name: "html response", statusCode: http.StatusOK, body: "bad config", want: "non-JSON response (HTTP 200): bad config"}, + {name: "non json response", statusCode: http.StatusOK, body: "not json", want: "non-JSON response (HTTP 200): not json"}, + {name: "non 2xx response", statusCode: http.StatusBadGateway, body: "bad gateway", want: "HTTP 502: bad gateway"}, + {name: "empty response", statusCode: http.StatusOK, body: "", want: "empty response (HTTP 200): "}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + provider := newTestEasyPay(t, server.URL) + _, err := provider.Refund(context.Background(), payment.RefundRequest{ + OrderID: "out-456", + Amount: "1.50", + }) + if err == nil { + t.Fatal("Refund returned nil error") + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("Refund error = %q, want substring %q", err.Error(), tt.want) + } + }) + } +} + +func newTestEasyPay(t *testing.T, apiBase string) *EasyPay { + t.Helper() + + provider, err := NewEasyPay("test-instance", map[string]string{ + "pid": "pid-1", + "pkey": "pkey-1", + "apiBase": apiBase, + "notifyUrl": "https://example.com/notify", + "returnUrl": "https://example.com/return", + }) + if err != nil { + t.Fatalf("NewEasyPay: %v", err) + } + return provider +} diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index 095305c2..c35b51b6 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -181,6 +181,55 @@ func TestResponsesToAnthropic_TextOnly(t *testing.T) { assert.Equal(t, 5, anth.Usage.OutputTokens) } +func TestResponsesToAnthropic_CachedTokensUseAnthropicInputSemantics(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_cached", + Model: "gpt-5.2", + Status: "completed", + Output: []ResponsesOutput{ + { + Type: "message", + Content: []ResponsesContentPart{ + {Type: "output_text", Text: "Cached response"}, + }, + }, + }, + Usage: &ResponsesUsage{ + InputTokens: 54006, + OutputTokens: 123, + TotalTokens: 54129, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 50688, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-sonnet-4-5-20250929") + assert.Equal(t, 3318, anth.Usage.InputTokens) + assert.Equal(t, 50688, anth.Usage.CacheReadInputTokens) + assert.Equal(t, 123, anth.Usage.OutputTokens) +} + +func TestResponsesToAnthropic_CachedTokensClampInputTokens(t *testing.T) { + resp := &ResponsesResponse{ + ID: "resp_cached_clamp", + Model: "gpt-5.2", + Status: "completed", + Usage: &ResponsesUsage{ + InputTokens: 100, + OutputTokens: 5, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 150, + }, + }, + } + + anth := ResponsesToAnthropic(resp, "claude-sonnet-4-5-20250929") + assert.Equal(t, 0, anth.Usage.InputTokens) + assert.Equal(t, 150, anth.Usage.CacheReadInputTokens) + assert.Equal(t, 5, anth.Usage.OutputTokens) +} + func TestResponsesToAnthropic_ToolUse(t *testing.T) { resp := &ResponsesResponse{ ID: "resp_456", @@ -343,6 +392,36 @@ func TestStreamingTextOnly(t *testing.T) { assert.Equal(t, "message_stop", events[1].Type) } +func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) { + state := NewResponsesEventToAnthropicState() + ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_cached_stream", Model: "gpt-5.2"}, + }, state) + + events := ResponsesEventToAnthropicEvents(&ResponsesStreamEvent{ + Type: "response.completed", + Response: &ResponsesResponse{ + Status: "completed", + Usage: &ResponsesUsage{ + InputTokens: 54006, + OutputTokens: 123, + TotalTokens: 54129, + InputTokensDetails: &ResponsesInputTokensDetails{ + CachedTokens: 50688, + }, + }, + }, + }, state) + + require.Len(t, events, 2) + assert.Equal(t, "message_delta", events[0].Type) + assert.Equal(t, 3318, events[0].Usage.InputTokens) + assert.Equal(t, 50688, events[0].Usage.CacheReadInputTokens) + assert.Equal(t, 123, events[0].Usage.OutputTokens) + assert.Equal(t, "message_stop", events[1].Type) +} + func TestStreamingToolCall(t *testing.T) { state := NewResponsesEventToAnthropicState() diff --git a/backend/internal/pkg/apicompat/responses_to_anthropic.go b/backend/internal/pkg/apicompat/responses_to_anthropic.go index 5409a0f4..40bed302 100644 --- a/backend/internal/pkg/apicompat/responses_to_anthropic.go +++ b/backend/internal/pkg/apicompat/responses_to_anthropic.go @@ -84,18 +84,34 @@ func ResponsesToAnthropic(resp *ResponsesResponse, model string) *AnthropicRespo out.StopReason = responsesStatusToAnthropicStopReason(resp.Status, resp.IncompleteDetails, blocks) if resp.Usage != nil { - out.Usage = AnthropicUsage{ - InputTokens: resp.Usage.InputTokens, - OutputTokens: resp.Usage.OutputTokens, - } - if resp.Usage.InputTokensDetails != nil { - out.Usage.CacheReadInputTokens = resp.Usage.InputTokensDetails.CachedTokens - } + out.Usage = anthropicUsageFromResponsesUsage(resp.Usage) } return out } +func anthropicUsageFromResponsesUsage(usage *ResponsesUsage) AnthropicUsage { + if usage == nil { + return AnthropicUsage{} + } + + cachedTokens := 0 + if usage.InputTokensDetails != nil { + cachedTokens = usage.InputTokensDetails.CachedTokens + } + + inputTokens := usage.InputTokens - cachedTokens + if inputTokens < 0 { + inputTokens = 0 + } + + return AnthropicUsage{ + InputTokens: inputTokens, + OutputTokens: usage.OutputTokens, + CacheReadInputTokens: cachedTokens, + } +} + func responsesStatusToAnthropicStopReason(status string, details *ResponsesIncompleteDetails, blocks []AnthropicContentBlock) string { switch status { case "incomplete": @@ -466,11 +482,10 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo stopReason := "end_turn" if evt.Response != nil { if evt.Response.Usage != nil { - state.InputTokens = evt.Response.Usage.InputTokens - state.OutputTokens = evt.Response.Usage.OutputTokens - if evt.Response.Usage.InputTokensDetails != nil { - state.CacheReadInputTokens = evt.Response.Usage.InputTokensDetails.CachedTokens - } + usage := anthropicUsageFromResponsesUsage(evt.Response.Usage) + state.InputTokens = usage.InputTokens + state.OutputTokens = usage.OutputTokens + state.CacheReadInputTokens = usage.CacheReadInputTokens } switch evt.Response.Status { case "incomplete": diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index e3dd56b8..ef89e5b6 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -86,17 +86,21 @@ func (r *affiliateRepository) BindInviter(ctx context.Context, userID, inviterID return bound, nil } -func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) { +func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) { if amount <= 0 { return false, nil } var applied bool err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { - res, err := txClient.ExecContext(txCtx, - "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2", - amount, inviterID, - ) + // freezeHours > 0: add to frozen quota; == 0: add to available quota directly + var updateSQL string + if freezeHours > 0 { + updateSQL = "UPDATE user_affiliates SET aff_frozen_quota = aff_frozen_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2" + } else { + updateSQL = "UPDATE user_affiliates SET aff_quota = aff_quota + $1, aff_history_quota = aff_history_quota + $1, updated_at = NOW() WHERE user_id = $2" + } + res, err := txClient.ExecContext(txCtx, updateSQL, amount, inviterID) if err != nil { return err } @@ -106,10 +110,19 @@ func (r *affiliateRepository) AccrueQuota(ctx context.Context, inviterID, invite return nil } - if _, err = txClient.ExecContext(txCtx, ` + if freezeHours > 0 { + if _, err = txClient.ExecContext(txCtx, ` +INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, frozen_until, created_at, updated_at) +VALUES ($1, 'accrue', $2, $3, NOW() + make_interval(hours => $4), NOW(), NOW())`, + inviterID, amount, inviteeUserID, freezeHours); err != nil { + return fmt.Errorf("insert affiliate accrue ledger: %w", err) + } + } else { + if _, err = txClient.ExecContext(txCtx, ` INSERT INTO user_affiliate_ledger (user_id, action, amount, source_user_id, created_at, updated_at) VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); err != nil { - return fmt.Errorf("insert affiliate accrue ledger: %w", err) + return fmt.Errorf("insert affiliate accrue ledger: %w", err) + } } applied = true @@ -121,6 +134,76 @@ VALUES ($1, 'accrue', $2, $3, NOW(), NOW())`, inviterID, amount, inviteeUserID); return applied, nil } +func (r *affiliateRepository) GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) { + client := clientFromContext(ctx, r.client) + rows, err := client.QueryContext(ctx, + `SELECT COALESCE(SUM(amount), 0)::double precision FROM user_affiliate_ledger WHERE user_id = $1 AND source_user_id = $2 AND action = 'accrue'`, + inviterID, inviteeUserID) + if err != nil { + return 0, fmt.Errorf("query accrued rebate from invitee: %w", err) + } + defer func() { _ = rows.Close() }() + var total float64 + if rows.Next() { + if err := rows.Scan(&total); err != nil { + return 0, err + } + } + return total, rows.Close() +} + +func (r *affiliateRepository) ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) { + var thawed float64 + err := r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { + var err error + thawed, err = thawFrozenQuotaTx(txCtx, txClient, userID) + return err + }) + return thawed, err +} + +// thawFrozenQuotaTx moves matured frozen quota to available quota within an existing tx. +func thawFrozenQuotaTx(txCtx context.Context, txClient *dbent.Client, userID int64) (float64, error) { + rows, err := txClient.QueryContext(txCtx, ` +WITH matured AS ( + UPDATE user_affiliate_ledger + SET frozen_until = NULL, updated_at = NOW() + WHERE user_id = $1 + AND frozen_until IS NOT NULL + AND frozen_until <= NOW() + RETURNING amount +) +SELECT COALESCE(SUM(amount), 0) FROM matured`, userID) + if err != nil { + return 0, fmt.Errorf("thaw frozen quota: %w", err) + } + defer func() { _ = rows.Close() }() + + var thawed float64 + if rows.Next() { + if err := rows.Scan(&thawed); err != nil { + return 0, err + } + } + if err := rows.Close(); err != nil { + return 0, err + } + if thawed <= 0 { + return 0, nil + } + + _, err = txClient.ExecContext(txCtx, ` +UPDATE user_affiliates +SET aff_quota = aff_quota + $1, + aff_frozen_quota = GREATEST(aff_frozen_quota - $1, 0), + updated_at = NOW() +WHERE user_id = $2`, thawed, userID) + if err != nil { + return 0, fmt.Errorf("move thawed quota: %w", err) + } + return thawed, nil +} + func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) { var transferred float64 var newBalance float64 @@ -130,6 +213,11 @@ func (r *affiliateRepository) TransferQuotaToBalance(ctx context.Context, userID return err } + // Thaw any matured frozen quota before transfer. + if _, err := thawFrozenQuotaTx(txCtx, txClient, userID); err != nil { + return fmt.Errorf("thaw before transfer: %w", err) + } + rows, err := txClient.QueryContext(txCtx, ` WITH claimed AS ( SELECT aff_quota::double precision AS amount @@ -211,10 +299,16 @@ func (r *affiliateRepository) ListInvitees(ctx context.Context, inviterID int64, SELECT ua.user_id, COALESCE(u.email, ''), COALESCE(u.username, ''), - ua.created_at + ua.created_at, + COALESCE(SUM(ual.amount), 0)::double precision AS total_rebate FROM user_affiliates ua LEFT JOIN users u ON u.id = ua.user_id +LEFT JOIN user_affiliate_ledger ual + ON ual.user_id = $1 + AND ual.source_user_id = ua.user_id + AND ual.action = 'accrue' WHERE ua.inviter_id = $1 +GROUP BY ua.user_id, u.email, u.username, ua.created_at ORDER BY ua.created_at DESC LIMIT $2`, inviterID, limit) if err != nil { @@ -226,7 +320,7 @@ LIMIT $2`, inviterID, limit) for rows.Next() { var item service.AffiliateInvitee var createdAt time.Time - if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt); err != nil { + if err := rows.Scan(&item.UserID, &item.Email, &item.Username, &createdAt, &item.TotalRebate); err != nil { return nil, err } item.CreatedAt = &createdAt @@ -299,6 +393,7 @@ SELECT user_id, inviter_id, aff_count, aff_quota::double precision, + aff_frozen_quota::double precision, aff_history_quota::double precision, created_at, updated_at @@ -326,6 +421,7 @@ WHERE user_id = $1`, userID) &inviterID, &out.AffCount, &out.AffQuota, + &out.AffFrozenQuota, &out.AffHistoryQuota, &out.CreatedAt, &out.UpdatedAt, @@ -351,6 +447,7 @@ SELECT user_id, inviter_id, aff_count, aff_quota::double precision, + aff_frozen_quota::double precision, aff_history_quota::double precision, created_at, updated_at @@ -380,6 +477,7 @@ LIMIT 1`, strings.ToUpper(strings.TrimSpace(code))) &inviterID, &out.AffCount, &out.AffQuota, + &out.AffFrozenQuota, &out.AffHistoryQuota, &out.CreatedAt, &out.UpdatedAt, diff --git a/backend/internal/repository/affiliate_repo_integration_test.go b/backend/internal/repository/affiliate_repo_integration_test.go index 369f57cf..697a193b 100644 --- a/backend/internal/repository/affiliate_repo_integration_test.go +++ b/backend/internal/repository/affiliate_repo_integration_test.go @@ -125,7 +125,7 @@ func TestAffiliateRepository_AccrueQuota_ReusesOuterTransaction(t *testing.T) { require.NoError(t, err) require.True(t, bound, "invitee must bind to inviter") - applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5) + applied, err := repo.AccrueQuota(txCtx, inviter.ID, invitee.ID, 3.5, 0) require.NoError(t, err) require.True(t, applied, "AccrueQuota must report applied=true") diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 39286cbf..ca6fd0cc 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -716,6 +716,9 @@ func TestAPIContracts(t *testing.T) { "default_concurrency": 5, "default_balance": 1.25, "affiliate_rebate_rate": 20, + "affiliate_rebate_freeze_hours": 0, + "affiliate_rebate_duration_days": 0, + "affiliate_rebate_per_invitee_cap": 0, "default_user_rpm_limit": 0, "default_subscriptions": [], "enable_model_fallback": false, @@ -898,6 +901,9 @@ func TestAPIContracts(t *testing.T) { "default_concurrency": 0, "default_balance": 0, "affiliate_rebate_rate": 20, + "affiliate_rebate_freeze_hours": 0, + "affiliate_rebate_duration_days": 0, + "affiliate_rebate_per_invitee_cap": 0, "default_user_rpm_limit": 0, "default_subscriptions": [], "enable_model_fallback": false, diff --git a/backend/internal/service/affiliate_service.go b/backend/internal/service/affiliate_service.go index aca32076..5a4e91e7 100644 --- a/backend/internal/service/affiliate_service.go +++ b/backend/internal/service/affiliate_service.go @@ -65,16 +65,18 @@ type AffiliateSummary struct { InviterID *int64 `json:"inviter_id,omitempty"` AffCount int `json:"aff_count"` AffQuota float64 `json:"aff_quota"` + AffFrozenQuota float64 `json:"aff_frozen_quota"` AffHistoryQuota float64 `json:"aff_history_quota"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type AffiliateInvitee struct { - UserID int64 `json:"user_id"` - Email string `json:"email"` - Username string `json:"username"` - CreatedAt *time.Time `json:"created_at,omitempty"` + UserID int64 `json:"user_id"` + Email string `json:"email"` + Username string `json:"username"` + CreatedAt *time.Time `json:"created_at,omitempty"` + TotalRebate float64 `json:"total_rebate"` } type AffiliateDetail struct { @@ -83,6 +85,7 @@ type AffiliateDetail struct { InviterID *int64 `json:"inviter_id,omitempty"` AffCount int `json:"aff_count"` AffQuota float64 `json:"aff_quota"` + AffFrozenQuota float64 `json:"aff_frozen_quota"` AffHistoryQuota float64 `json:"aff_history_quota"` // EffectiveRebateRatePercent 是当前用户作为邀请人时实际生效的返利比例: // 优先用户自己的专属比例(aff_rebate_rate_percent),否则回退到全局比例。 @@ -95,7 +98,9 @@ type AffiliateRepository interface { EnsureUserAffiliate(ctx context.Context, userID int64) (*AffiliateSummary, error) GetAffiliateByCode(ctx context.Context, code string) (*AffiliateSummary, error) BindInviter(ctx context.Context, userID, inviterID int64) (bool, error) - AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64) (bool, error) + AccrueQuota(ctx context.Context, inviterID, inviteeUserID int64, amount float64, freezeHours int) (bool, error) + GetAccruedRebateFromInvitee(ctx context.Context, inviterID, inviteeUserID int64) (float64, error) + ThawFrozenQuota(ctx context.Context, userID int64) (float64, error) TransferQuotaToBalance(ctx context.Context, userID int64) (float64, float64, error) ListInvitees(ctx context.Context, inviterID int64, limit int) ([]AffiliateInvitee, error) @@ -160,6 +165,12 @@ func (s *AffiliateService) EnsureUserAffiliate(ctx context.Context, userID int64 } func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) (*AffiliateDetail, error) { + // Lazy thaw: move any matured frozen quota to available before reading. + if s != nil && s.repo != nil { + // best-effort: thaw failure is non-fatal + _, _ = s.repo.ThawFrozenQuota(ctx, userID) + } + summary, err := s.EnsureUserAffiliate(ctx, userID) if err != nil { return nil, err @@ -174,6 +185,7 @@ func (s *AffiliateService) GetAffiliateDetail(ctx context.Context, userID int64) InviterID: summary.InviterID, AffCount: summary.AffCount, AffQuota: summary.AffQuota, + AffFrozenQuota: summary.AffFrozenQuota, AffHistoryQuota: summary.AffHistoryQuota, EffectiveRebateRatePercent: s.resolveRebateRatePercent(ctx, summary), Invitees: invitees, @@ -250,13 +262,43 @@ func (s *AffiliateService) AccrueInviteRebate(ctx context.Context, inviteeUserID if err != nil { return 0, err } + // 有效期检查:超过返利有效期后不再产生返利 + if s.settingService != nil { + if durationDays := s.settingService.GetAffiliateRebateDurationDays(ctx); durationDays > 0 { + if time.Now().After(inviteeSummary.CreatedAt.AddDate(0, 0, durationDays)) { + return 0, nil + } + } + } + rebateRatePercent := s.resolveRebateRatePercent(ctx, inviterSummary) rebate := roundTo(baseRechargeAmount*(rebateRatePercent/100), 8) if rebate <= 0 { return 0, nil } - applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate) + // 单人上限检查:精确截断到剩余额度 + if s.settingService != nil { + if perInviteeCap := s.settingService.GetAffiliateRebatePerInviteeCap(ctx); perInviteeCap > 0 { + existing, err := s.repo.GetAccruedRebateFromInvitee(ctx, *inviteeSummary.InviterID, inviteeUserID) + if err != nil { + return 0, err + } + if existing >= perInviteeCap { + return 0, nil + } + if remaining := perInviteeCap - existing; rebate > remaining { + rebate = roundTo(remaining, 8) + } + } + } + + var freezeHours int + if s.settingService != nil { + freezeHours = s.settingService.GetAffiliateRebateFreezeHours(ctx) + } + + applied, err := s.repo.AccrueQuota(ctx, *inviteeSummary.InviterID, inviteeUserID, rebate, freezeHours) if err != nil { return 0, err } diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index a18cf39c..9815f31b 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -175,6 +175,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount( user *User, invitationCode string, signupSource string, + affiliateCode string, ) error { if s == nil || user == nil || user.ID <= 0 { return ErrServiceUnavailable @@ -194,6 +195,7 @@ func (s *AuthService) FinalizeOAuthEmailAccount( s.updateOAuthSignupSource(ctx, user.ID, signupSource) grantPlan := s.resolveSignupGrantPlan(ctx, signupSource) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) return nil } diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go index 08b0f4b7..b1adf071 100644 --- a/backend/internal/service/auth_service.go +++ b/backend/internal/service/auth_service.go @@ -563,7 +563,8 @@ func (s *AuthService) LoginOrRegisterOAuth(ctx context.Context, email, username // LoginOrRegisterOAuthWithTokenPair 用于第三方 OAuth/SSO 登录,返回完整的 TokenPair。 // 与 LoginOrRegisterOAuth 功能相同,但返回 TokenPair 而非单个 token。 // invitationCode 仅在邀请码注册模式下新用户注册时使用;已有账号登录时忽略。 -func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode string) (*TokenPair, *User, error) { +// affiliateCode 用于邀请返利绑定,仅在新用户注册时使用。 +func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, email, username, invitationCode, affiliateCode string) (*TokenPair, *User, error) { // 检查 refreshTokenCache 是否可用 if s.refreshTokenCache == nil { return nil, nil, errors.New("refresh token cache not configured") @@ -666,6 +667,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema user = newUser s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) } } else { if err := s.userRepo.Create(ctx, newUser); err != nil { @@ -683,6 +685,7 @@ func (s *AuthService) LoginOrRegisterOAuthWithTokenPair(ctx context.Context, ema user = newUser s.postAuthUserBootstrap(ctx, user, signupSource, false) s.assignSubscriptions(ctx, user.ID, grantPlan.Subscriptions, "auto assigned by signup defaults") + s.bindOAuthAffiliate(ctx, user.ID, affiliateCode) if invitationRedeemCode != nil { if err := s.redeemRepo.Use(ctx, invitationRedeemCode.ID, user.ID); err != nil { return nil, nil, ErrInvitationCodeInvalid @@ -777,6 +780,22 @@ func authSourceSignupSettings(defaults *AuthSourceDefaultSettings, signupSource } } +// bindOAuthAffiliate initializes the affiliate profile and binds the inviter +// for an OAuth-registered user. Failures are logged but never block registration. +func (s *AuthService) bindOAuthAffiliate(ctx context.Context, userID int64, affiliateCode string) { + if s.affiliateService == nil || userID <= 0 { + return + } + if _, err := s.affiliateService.EnsureUserAffiliate(ctx, userID); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to initialize affiliate profile for user %d: %v", userID, err) + } + if code := strings.TrimSpace(affiliateCode); code != "" { + if err := s.affiliateService.BindInviterByCode(ctx, userID, code); err != nil { + logger.LegacyPrintf("service.auth", "[Auth] Failed to bind affiliate inviter for user %d: %v", userID, err) + } + } +} + func (s *AuthService) postAuthUserBootstrap(ctx context.Context, user *User, signupSource string, touchLogin bool) { if user == nil || user.ID <= 0 { return diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go index c1ad6240..acc44a38 100644 --- a/backend/internal/service/auth_service_register_test.go +++ b/backend/internal/service/auth_service_register_test.go @@ -622,7 +622,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_UsesLinuxDoAuthSourceDefa service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), "linuxdo-123@linuxdo-connect.invalid", "linuxdo_user", "", "") require.NoError(t, err) require.NotNil(t, tokenPair) require.NotNil(t, user) @@ -658,7 +658,7 @@ func TestAuthService_LoginOrRegisterOAuthWithTokenPair_ExistingUserDoesNotGrantA service.defaultSubAssigner = assigner service.refreshTokenCache = &refreshTokenCacheStub{} - tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "") + tokenPair, user, err := service.LoginOrRegisterOAuthWithTokenPair(context.Background(), existing.Email, "linuxdo_user", "", "") require.NoError(t, err) require.NotNil(t, tokenPair) require.Equal(t, existing.ID, user.ID) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index e3d3a872..75128876 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -20,10 +20,15 @@ const ( // Affiliate rebate settings const ( - AffiliateRebateRateDefault = 20.0 - AffiliateRebateRateMin = 0.0 - AffiliateRebateRateMax = 100.0 - AffiliateEnabledDefault = false // 邀请返利总开关默认关闭 + AffiliateRebateRateDefault = 20.0 + AffiliateRebateRateMin = 0.0 + AffiliateRebateRateMax = 100.0 + AffiliateEnabledDefault = false // 邀请返利总开关默认关闭 + AffiliateRebateFreezeHoursDefault = 0 // 0 = 不冻结(向后兼容) + AffiliateRebateFreezeHoursMax = 720 // 最大 30 天 + AffiliateRebateDurationDaysDefault = 0 // 0 = 永久有效 + AffiliateRebateDurationDaysMax = 3650 // ~10 年 + AffiliateRebatePerInviteeCapDefault = 0.0 // 0 = 无上限 ) // Platform constants @@ -98,6 +103,9 @@ const ( SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关 SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100) + SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结) + SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久) + SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限) // 邮件服务设置 SettingKeySMTPHost = "smtp_host" // SMTP服务器地址 diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index c6167447..5df69aea 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -269,7 +269,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e switch action { case redeemActionSkipCompleted: - s.applyAffiliateRebateForOrder(ctx, o) + if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { + return err + } // Code already created and redeemed — just mark completed return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") case redeemActionCreate: @@ -283,7 +285,9 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { return fmt.Errorf("redeem balance: %w", err) } - s.applyAffiliateRebateForOrder(ctx, o) + if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { + return err + } return s.markCompleted(ctx, o, "RECHARGE_SUCCESS") } @@ -361,12 +365,12 @@ func (s *PaymentService) hasAuditLog(ctx context.Context, orderID int64, action return c > 0 } -func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) { +func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *dbent.PaymentOrder) error { if o == nil || o.OrderType != payment.OrderTypeBalance || o.Amount <= 0 { - return + return nil } if s.affiliateService == nil { - return + return nil } tx, err := s.entClient.Tx(ctx) @@ -374,7 +378,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("begin affiliate rebate tx: %v", err), }) - return + return fmt.Errorf("begin affiliate rebate tx: %w", err) } defer func() { _ = tx.Rollback() }() @@ -384,10 +388,10 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("claim affiliate rebate audit: %w", err) } if !claimed { - return + return nil } rebateAmount, err := s.affiliateService.AccrueInviteRebate(txCtx, o.UserID, o.Amount) @@ -395,7 +399,7 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("accrue affiliate rebate: %w", err) } if rebateAmount <= 0 { @@ -406,14 +410,15 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("update affiliate rebate skipped audit: %w", err) } if err := tx.Commit(); err != nil { s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err), }) + return fmt.Errorf("commit affiliate rebate tx: %w", err) } - return + return nil } if err := s.updateClaimedAffiliateRebateAudit(txCtx, tx.Client(), o.ID, "AFFILIATE_REBATE_APPLIED", map[string]any{ @@ -423,14 +428,16 @@ func (s *PaymentService) applyAffiliateRebateForOrder(ctx context.Context, o *db s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": err.Error(), }) - return + return fmt.Errorf("update affiliate rebate applied audit: %w", err) } if err := tx.Commit(); err != nil { s.writeAuditLog(ctx, o.ID, "AFFILIATE_REBATE_FAILED", "system", map[string]any{ "error": fmt.Sprintf("commit affiliate rebate tx: %v", err), }) + return fmt.Errorf("commit affiliate rebate tx: %w", err) } + return nil } func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, client *dbent.Client, orderID int64, baseAmount float64) (bool, error) { @@ -444,11 +451,11 @@ func (s *PaymentService) tryClaimAffiliateRebateAudit(ctx context.Context, clien }) rows, err := client.QueryContext(ctx, ` INSERT INTO payment_audit_logs (order_id, action, detail, operator, created_at) -SELECT $1, 'AFFILIATE_REBATE_APPLIED', $2, 'system', NOW() +SELECT $1::text, 'AFFILIATE_REBATE_APPLIED', $2::text, 'system', NOW() WHERE NOT EXISTS ( SELECT 1 FROM payment_audit_logs - WHERE order_id = $1 + WHERE order_id = $1::text AND action IN ('AFFILIATE_REBATE_APPLIED', 'AFFILIATE_REBATE_SKIPPED') ) ON CONFLICT (order_id, action) DO NOTHING diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f871ee85..33316031 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -1175,6 +1175,24 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64) settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate) updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64) + if settings.AffiliateRebateFreezeHours < 0 { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault + } + if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax { + settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax + } + updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours) + if settings.AffiliateRebateDurationDays < 0 { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault + } + if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax { + settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax + } + updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays) + if settings.AffiliateRebatePerInviteeCap < 0 { + settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault + } + updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64) updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit) defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions) if err != nil { @@ -1512,6 +1530,54 @@ func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) floa return clampAffiliateRebateRate(rate) } +// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。 +// 返回 0 表示不冻结(向后兼容)。 +func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours) + if err != nil { + return AffiliateRebateFreezeHoursDefault + } + hours, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || hours < 0 { + return AffiliateRebateFreezeHoursDefault + } + if hours > AffiliateRebateFreezeHoursMax { + return AffiliateRebateFreezeHoursMax + } + return hours +} + +// GetAffiliateRebateDurationDays 返回返利有效期(天)。 +// 返回 0 表示永久有效。 +func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays) + if err != nil { + return AffiliateRebateDurationDaysDefault + } + days, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || days < 0 { + return AffiliateRebateDurationDaysDefault + } + if days > AffiliateRebateDurationDaysMax { + return AffiliateRebateDurationDaysMax + } + return days +} + +// GetAffiliateRebatePerInviteeCap 返回单人返利上限。 +// 返回 0 表示无上限。 +func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 { + raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap) + if err != nil { + return AffiliateRebatePerInviteeCapDefault + } + cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) { + return AffiliateRebatePerInviteeCapDefault + } + return cap +} + // IsPasswordResetEnabled 检查是否启用密码重置功能 // 要求:必须同时开启邮件验证 func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool { @@ -1755,6 +1821,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency), SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64), SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64), + SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault), + SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault), + SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64), SettingKeyDefaultUserRPMLimit: "0", SettingKeyDefaultSubscriptions: "[]", SettingKeyAuthSourceDefaultEmailBalance: "0", @@ -1890,6 +1959,21 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } else { result.AffiliateRebateRate = AffiliateRebateRateDefault } + if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 { + if freezeHours > AffiliateRebateFreezeHoursMax { + freezeHours = AffiliateRebateFreezeHoursMax + } + result.AffiliateRebateFreezeHours = freezeHours + } + if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 { + if durationDays > AffiliateRebateDurationDaysMax { + durationDays = AffiliateRebateDurationDaysMax + } + result.AffiliateRebateDurationDays = durationDays + } + if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 { + result.AffiliateRebatePerInviteeCap = perInviteeCap + } result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions]) // 敏感信息直接返回,方便测试连接时使用 diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 70d8efc3..5ec7d313 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -104,12 +104,15 @@ type SystemSettings struct { CustomMenuItems string // JSON array of custom menu items CustomEndpoints string // JSON array of custom endpoints - DefaultConcurrency int - DefaultBalance float64 - AffiliateEnabled bool - AffiliateRebateRate float64 - DefaultUserRPMLimit int - DefaultSubscriptions []DefaultSubscriptionSetting + DefaultConcurrency int + DefaultBalance float64 + AffiliateEnabled bool + AffiliateRebateRate float64 + AffiliateRebateFreezeHours int + AffiliateRebateDurationDays int + AffiliateRebatePerInviteeCap float64 + DefaultUserRPMLimit int + DefaultSubscriptions []DefaultSubscriptionSetting // Model fallback configuration EnableModelFallback bool `json:"enable_model_fallback"` diff --git a/backend/migrations/133_affiliate_rebate_freeze.sql b/backend/migrations/133_affiliate_rebate_freeze.sql new file mode 100644 index 00000000..b87d59b7 --- /dev/null +++ b/backend/migrations/133_affiliate_rebate_freeze.sql @@ -0,0 +1,17 @@ +-- 1) Add frozen quota column to user_affiliates for rebate freeze period. +ALTER TABLE user_affiliates + ADD COLUMN IF NOT EXISTS aff_frozen_quota DECIMAL(20,8) NOT NULL DEFAULT 0; + +COMMENT ON COLUMN user_affiliates.aff_frozen_quota IS 'Rebate quota currently frozen (pending thaw after freeze period)'; + +-- 2) Add frozen_until column to user_affiliate_ledger for per-entry freeze tracking. +-- NULL = no freeze (or already thawed); non-NULL = frozen until this timestamp. +ALTER TABLE user_affiliate_ledger + ADD COLUMN IF NOT EXISTS frozen_until TIMESTAMPTZ NULL; + +COMMENT ON COLUMN user_affiliate_ledger.frozen_until IS 'Rebate frozen until this time; NULL means already thawed or never frozen'; + +-- 3) Partial index for efficient thaw queries (only rows still frozen). +CREATE INDEX IF NOT EXISTS idx_ual_frozen_thaw + ON user_affiliate_ledger (user_id, frozen_until) + WHERE frozen_until IS NOT NULL; diff --git a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts index a484d7ed..07a68c03 100644 --- a/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts +++ b/frontend/src/api/__tests__/auth-oauth-adoption.spec.ts @@ -74,6 +74,26 @@ describe('oauth adoption auth api', () => { }) }) + it('posts affiliate code when completing linuxdo oauth registration', async () => { + const { completeLinuxDoOAuthRegistration } = await import('@/api/auth') + + await completeLinuxDoOAuthRegistration( + 'invite-code', + { + adoptDisplayName: true, + adoptAvatar: false + }, + ' AFF123 ' + ) + + expect(post).toHaveBeenCalledWith('/auth/oauth/linuxdo/complete-registration', { + invitation_code: 'invite-code', + aff_code: 'AFF123', + adopt_display_name: true, + adopt_avatar: false + }) + }) + it('posts oidc invitation completion with adoption decisions', async () => { const { completeOIDCOAuthRegistration } = await import('@/api/auth') @@ -134,6 +154,26 @@ describe('oauth adoption auth api', () => { }) }) + it('posts affiliate code when creating pending wechat oauth account', async () => { + const { createPendingWeChatOAuthAccount } = await import('@/api/auth') + + await createPendingWeChatOAuthAccount( + 'invite-code', + { + adoptDisplayName: false, + adoptAvatar: true + }, + 'WXAFF' + ) + + expect(post).toHaveBeenCalledWith('/auth/oauth/wechat/complete-registration', { + invitation_code: 'invite-code', + aff_code: 'WXAFF', + adopt_display_name: false, + adopt_avatar: true + }) + }) + it('classifies oauth completion results as login or bind', async () => { const { getOAuthCompletionKind } = await import('@/api/auth') diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 0d98c9e9..defbab43 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -309,6 +309,9 @@ export interface SystemSettings { // Default settings default_balance: number; affiliate_rebate_rate: number; + affiliate_rebate_freeze_hours: number; + affiliate_rebate_duration_days: number; + affiliate_rebate_per_invitee_cap: number; default_concurrency: number; default_user_rpm_limit: number; default_subscriptions: DefaultSubscriptionSetting[]; @@ -494,6 +497,9 @@ export interface UpdateSettingsRequest { totp_enabled?: boolean; // TOTP 双因素认证 default_balance?: number; affiliate_rebate_rate?: number; + affiliate_rebate_freeze_hours?: number; + affiliate_rebate_duration_days?: number; + affiliate_rebate_per_invitee_cap?: number; default_concurrency?: number; default_user_rpm_limit?: number; default_subscriptions?: DefaultSubscriptionSetting[]; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index f49f3a1f..bb990fc4 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise { - return createPendingLinuxDoOAuthAccount(invitationCode, decision) + return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode) } /** @@ -576,27 +577,32 @@ export async function completeLinuxDoOAuthRegistration( */ export async function completeOIDCOAuthRegistration( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOIDCOAuthAccount(invitationCode, decision) + return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode) } export async function completeWeChatOAuthRegistration( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingWeChatOAuthAccount(invitationCode, decision) + return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode) } async function createPendingOAuthAccount( provider: 'linuxdo' | 'oidc' | 'wechat', invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { + const normalizedAffiliateCode = affiliateCode?.trim() const { data } = await apiClient.post( `/auth/oauth/${provider}/complete-registration`, { invitation_code: invitationCode, + ...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}), ...serializeOAuthAdoptionDecision(decision) } ) @@ -605,23 +611,26 @@ async function createPendingOAuthAccount( export async function createPendingLinuxDoOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('linuxdo', invitationCode, decision) + return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode) } export async function createPendingOIDCOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('oidc', invitationCode, decision) + return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode) } export async function createPendingWeChatOAuthAccount( invitationCode: string, - decision?: OAuthAdoptionDecision + decision?: OAuthAdoptionDecision, + affiliateCode?: string ): Promise { - return createPendingOAuthAccount('wechat', invitationCode, decision) + return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode) } export async function completePendingOAuthBindLogin( diff --git a/frontend/src/components/auth/LinuxDoOAuthSection.vue b/frontend/src/components/auth/LinuxDoOAuthSection.vue index c740d06f..6b245123 100644 --- a/frontend/src/components/auth/LinuxDoOAuthSection.vue +++ b/frontend/src/components/auth/LinuxDoOAuthSection.vue @@ -42,9 +42,11 @@