Merge branch 'Wei-Shaw:main' into vertex

This commit is contained in:
Oliver Li 2026-04-26 08:39:41 -04:00 committed by GitHub
commit 3f05ef2ae3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1330 additions and 138 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
docs/claude-relay-service/
.codex
# ===================
# Go 后端

View File

@ -1 +1 @@
0.1.118
0.1.119

View File

@ -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")
}

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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

View File

@ -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"`

View File

@ -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, "<!doctype html") || strings.HasPrefix(lower, "<html") ||
(strings.HasPrefix(lower, "<") && strings.Contains(lower, "html")) {
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
}
var resp struct {
Code int `json:"code"`
Code any `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse refund: %w", err)
return fmt.Errorf("easypay refund non-JSON response (HTTP %d): %s", status, summary)
}
if resp.Code != easypayCodeSuccess {
return nil, fmt.Errorf("easypay refund failed: %s", resp.Msg)
if !easyPayResponseCodeIsSuccess(resp.Code) {
msg := strings.TrimSpace(resp.Msg)
if msg == "" {
msg = summary
}
return fmt.Errorf("easypay refund failed (HTTP %d): %s", status, msg)
}
return &payment.RefundResponse{RefundID: req.TradeNo, Status: payment.ProviderStatusSuccess}, nil
return nil
}
func easyPayResponseCodeIsSuccess(code any) bool {
switch v := code.(type) {
case float64:
return int(v) == easypayCodeSuccess
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
return err == nil && n == easypayCodeSuccess
default:
return false
}
}
func summarizeEasyPayResponse(body []byte) string {
summary := strings.Join(strings.Fields(string(body)), " ")
if summary == "" {
return "<empty>"
}
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 {

View File

@ -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: "<html>bad config</html>", want: "non-JSON response (HTTP 200): <html>bad config</html>"},
{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): <empty>"},
}
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
}

View File

@ -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()

View File

@ -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":

View File

@ -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,

View File

@ -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")

View File

@ -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,

View File

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

View File

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

View File

@ -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

View File

@ -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)

View File

@ -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服务器地址

View File

@ -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

View File

@ -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])
// 敏感信息直接返回,方便测试连接时使用

View File

@ -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"`

View File

@ -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;

View File

@ -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')

View File

@ -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[];

View File

@ -564,9 +564,10 @@ export async function resetPassword(request: ResetPasswordRequest): Promise<Rese
*/
export async function completeLinuxDoOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
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<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision)
return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
}
export async function completeWeChatOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
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<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/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<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision)
return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
}
export async function createPendingOIDCOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision)
return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
}
export async function createPendingWeChatOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision)
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
}
export async function completePendingOAuthBindLogin(

View File

@ -42,9 +42,11 @@
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
withDefaults(defineProps<{
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
showDivider?: boolean
}>(), {
showDivider: true
@ -55,6 +57,7 @@ const { t } = useI18n()
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/linuxdo/start?redirect=${encodeURIComponent(redirectTo)}`

View File

@ -23,9 +23,11 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
providerName?: string
showDivider?: boolean
}>(), {
@ -45,6 +47,7 @@ const providerInitial = computed(() => normalizedProviderName.value.charAt(0).to
function startLogin(): void {
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const startURL = `${normalized}/auth/oauth/oidc/start?redirect=${encodeURIComponent(redirectTo)}`

View File

@ -33,9 +33,11 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { resolveWeChatOAuthStart } from '@/api/auth'
import { useAppStore } from '@/stores'
import { resolveAffiliateReferralCode, storeOAuthAffiliateCode } from '@/utils/oauthAffiliate'
const props = withDefaults(defineProps<{
disabled?: boolean
affCode?: string
showDivider?: boolean
}>(), {
showDivider: true,
@ -84,6 +86,7 @@ function startLogin(): void {
return
}
const redirectTo = (route.query.redirect as string) || '/dashboard'
storeOAuthAffiliateCode(resolveAffiliateReferralCode(props.affCode, route.query.aff, route.query.aff_code))
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const mode = resolvedStart.value.mode

View File

@ -989,6 +989,8 @@ export default {
rebateRateHint: 'What you earn each time an invitee recharges',
invitedUsers: 'Invited Users',
availableQuota: 'Available Rebate Quota',
frozenQuota: 'Frozen',
frozenQuotaHint: 'Recently earned rebates pending release',
totalQuota: 'Historical Rebate Quota'
},
transfer: {
@ -1005,6 +1007,7 @@ export default {
columns: {
email: 'Email',
username: 'Username',
rebate: 'Rebate',
joinedAt: 'Joined At'
}
},
@ -1012,7 +1015,8 @@ export default {
title: 'How It Works',
line1: 'Share your affiliate code or invite link with new users.',
line2: 'When invitees recharge, you receive {rate} of the recharge as rebate quota.',
line3: 'Transfer rebate quota to balance at any time.'
line3: 'Transfer rebate quota to balance at any time.',
line4: 'Newly earned rebates may have a waiting period before they can be transferred.'
}
},
@ -4788,6 +4792,12 @@ export default {
enabledHint: 'When off, the affiliate menu is hidden, the aff parameter is ignored at signup, and new recharges generate no rebate. Existing rebate balances can still be transferred.',
rebateRate: 'Global Rebate Rate',
rebateRateHint: 'Default percentage given back to the inviter on recharges (0-100, e.g. 10 = 10%).',
freezeHours: 'Rebate Freeze Period (hours)',
freezeHoursDesc: 'New rebates will be frozen for this period before becoming available for withdrawal. 0 = no freeze.',
durationDays: 'Rebate Duration (days)',
durationDaysDesc: 'Rebate relationship expires after this many days since invitee registration. 0 = permanent.',
perInviteeCap: 'Per-Invitee Rebate Cap',
perInviteeCapDesc: 'Maximum total rebate from a single invitee. 0 = no limit.',
customUsers: {
title: 'Per-User Overrides',
description: 'Set a custom invite code or exclusive rebate rate for specific users. Lists only users that have an override applied.',

View File

@ -993,6 +993,8 @@ export default {
rebateRateHint: '被邀请用户每次充值后你可获得的返利比例',
invitedUsers: '邀请人数',
availableQuota: '可转返利额度',
frozenQuota: '冻结中',
frozenQuotaHint: '新产生的返利正在冻结期中',
totalQuota: '历史返利额度'
},
transfer: {
@ -1009,6 +1011,7 @@ export default {
columns: {
email: '邮箱',
username: '用户名',
rebate: '返利明细',
joinedAt: '注册时间'
}
},
@ -1016,7 +1019,8 @@ export default {
title: '使用说明',
line1: '将邀请码或邀请链接分享给新用户。',
line2: '被邀请用户充值后,你可获得 {rate} 的返利额度。',
line3: '返利额度可随时转入账户余额。'
line3: '返利额度可随时转入账户余额。',
line4: '新产生的返利需要经过冻结期后才能提现。'
}
},
@ -4951,6 +4955,12 @@ export default {
enabledHint: '关闭后用户菜单中的邀请页面入口隐藏、注册时忽略邀请码、新充值不再产生返利。已有返利额度仍可转入余额。',
rebateRate: '全局返利比例',
rebateRateHint: '充值后返给邀请人的默认比例0-100%,例如填写 10 表示返利 10%)。',
freezeHours: '返利冻结期(小时)',
freezeHoursDesc: '新产生的返利将在冻结期内无法提现。0 = 不冻结。',
durationDays: '返利有效期(天)',
durationDaysDesc: '被邀请用户注册后多少天内的充值产生返利。0 = 永久有效。',
perInviteeCap: '单人返利上限',
perInviteeCapDesc: '每个被邀请用户最多产生的返利总额。0 = 无上限。',
customUsers: {
title: '专属用户配置',
description: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',

View File

@ -130,6 +130,7 @@ export interface AffiliateInvitee {
email: string
username: string
created_at?: string
total_rebate: number
}
export interface UserAffiliateDetail {
@ -138,6 +139,7 @@ export interface UserAffiliateDetail {
inviter_id?: number | null
aff_count: number
aff_quota: number
aff_frozen_quota: number
aff_history_quota: number
/** 当前用户作为邀请人时实际生效的返利比例专属覆盖全局。0-100。 */
effective_rebate_rate_percent: number

View File

@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearAffiliateReferralCode,
clearOAuthAffiliateCode,
loadAffiliateReferralCode,
loadOAuthAffiliateCode,
resolveAffiliateReferralCode,
storeAffiliateReferralCode,
storeOAuthAffiliateCode
} from '@/utils/oauthAffiliate'
describe('oauthAffiliate', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
vi.useRealTimers()
})
it('persists affiliate referral code across pages', () => {
expect(resolveAffiliateReferralCode(' 5579J7CFG9PF ')).toBe('5579J7CFG9PF')
expect(loadAffiliateReferralCode()).toBe('5579J7CFG9PF')
expect(resolveAffiliateReferralCode()).toBe('5579J7CFG9PF')
})
it('expires stale affiliate referral code', () => {
const now = Date.UTC(2026, 0, 1)
storeAffiliateReferralCode('AFF123', now)
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 - 1)).toBe('AFF123')
expect(loadAffiliateReferralCode(now + 30 * 24 * 60 * 60 * 1000 + 1)).toBe('')
expect(localStorage.getItem('affiliate_referral_code')).toBeNull()
})
it('keeps oauth transient code separate from persistent referral code', () => {
storeAffiliateReferralCode('PERSISTED')
storeOAuthAffiliateCode('OAUTH')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
expect(loadOAuthAffiliateCode()).toBe('OAUTH')
clearOAuthAffiliateCode()
expect(loadOAuthAffiliateCode()).toBe('')
expect(loadAffiliateReferralCode()).toBe('PERSISTED')
clearAffiliateReferralCode()
expect(loadAffiliateReferralCode()).toBe('')
})
})

View File

@ -0,0 +1,133 @@
const OAUTH_AFFILIATE_CODE_KEY = 'oauth_aff_code'
const AFFILIATE_REFERRAL_CODE_KEY = 'affiliate_referral_code'
const AFFILIATE_REFERRAL_TTL_MS = 30 * 24 * 60 * 60 * 1000
interface StoredAffiliateReferralCode {
code: string
expiresAt: number
}
export function normalizeOAuthAffiliateCode(value?: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return typeof raw === 'string' ? raw.trim() : ''
}
export function pickOAuthAffiliateCode(...values: unknown[]): string {
for (const value of values) {
const code = normalizeOAuthAffiliateCode(value)
if (code) {
return code
}
}
return ''
}
export function storeAffiliateReferralCode(value?: unknown, now = Date.now()): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
if (!code) {
return
}
try {
const payload: StoredAffiliateReferralCode = {
code,
expiresAt: now + AFFILIATE_REFERRAL_TTL_MS
}
window.localStorage.setItem(AFFILIATE_REFERRAL_CODE_KEY, JSON.stringify(payload))
} catch {
// 忽略浏览器存储异常。
}
}
export function loadAffiliateReferralCode(now = Date.now()): string {
if (typeof window === 'undefined') {
return ''
}
try {
const raw = window.localStorage.getItem(AFFILIATE_REFERRAL_CODE_KEY)
if (!raw) {
return ''
}
const parsed = JSON.parse(raw) as Partial<StoredAffiliateReferralCode>
const code = normalizeOAuthAffiliateCode(parsed.code)
const expiresAt = Number(parsed.expiresAt) || 0
if (!code || expiresAt <= now) {
clearAffiliateReferralCode()
return ''
}
return code
} catch {
clearAffiliateReferralCode()
return ''
}
}
export function clearAffiliateReferralCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.removeItem(AFFILIATE_REFERRAL_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function resolveAffiliateReferralCode(...values: unknown[]): string {
const code = pickOAuthAffiliateCode(...values)
if (code) {
storeAffiliateReferralCode(code)
return code
}
return loadAffiliateReferralCode()
}
export function storeOAuthAffiliateCode(value?: unknown): void {
if (typeof window === 'undefined') {
return
}
const code = normalizeOAuthAffiliateCode(value)
try {
if (code) {
window.sessionStorage.setItem(OAUTH_AFFILIATE_CODE_KEY, code)
} else {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
}
} catch {
// 忽略浏览器存储异常。
}
}
export function loadOAuthAffiliateCode(): string {
if (typeof window === 'undefined') {
return ''
}
try {
return normalizeOAuthAffiliateCode(window.sessionStorage.getItem(OAUTH_AFFILIATE_CODE_KEY))
} catch {
return ''
}
}
export function clearOAuthAffiliateCode(): void {
if (typeof window === 'undefined') {
return
}
try {
window.sessionStorage.removeItem(OAUTH_AFFILIATE_CODE_KEY)
} catch {
// 忽略浏览器存储异常。
}
}
export function clearAllAffiliateReferralCodes(): void {
clearOAuthAffiliateCode()
clearAffiliateReferralCode()
}
export function oauthAffiliatePayload(value?: unknown): { aff_code?: string } {
const code = normalizeOAuthAffiliateCode(value)
return code ? { aff_code: code } : {}
}

View File

@ -3898,6 +3898,56 @@
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.freezeHours') }}
</label>
<input
v-model.number="form.affiliate_rebate_freeze_hours"
type="number"
step="1"
min="0"
max="720"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.freezeHoursDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.durationDays') }}
</label>
<input
v-model.number="form.affiliate_rebate_duration_days"
type="number"
step="1"
min="0"
max="3650"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.durationDaysDesc') }}
</p>
</div>
<div>
<label class="input-label">
{{ t('admin.settings.features.affiliate.perInviteeCap') }}
</label>
<input
v-model.number="form.affiliate_rebate_per_invitee_cap"
type="number"
step="0.01"
min="0"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.affiliate.perInviteeCapDesc') }}
</p>
</div>
<!-- 专属用户管理 -->
<div class="border-t border-gray-100 pt-6 dark:border-dark-700">
<div class="mb-3 flex items-center justify-between">
@ -5333,6 +5383,9 @@ const form = reactive<SettingsForm>({
totp_encryption_key_configured: false,
default_balance: 0,
affiliate_rebate_rate: 20,
affiliate_rebate_freeze_hours: 0,
affiliate_rebate_duration_days: 0,
affiliate_rebate_per_invitee_cap: 0,
default_concurrency: 1,
default_subscriptions: [],
force_email_on_third_party_signup: false,
@ -6261,6 +6314,9 @@ async function saveSettings() {
100,
Math.max(0, Number(form.affiliate_rebate_rate) || 0),
),
affiliate_rebate_freeze_hours: Math.max(0, Math.min(720, Number(form.affiliate_rebate_freeze_hours) || 0)),
affiliate_rebate_duration_days: Math.max(0, Math.min(3650, Math.floor(Number(form.affiliate_rebate_duration_days) || 0))),
affiliate_rebate_per_invitee_cap: Math.max(0, Number(form.affiliate_rebate_per_invitee_cap) || 0),
default_concurrency: form.default_concurrency,
default_subscriptions: normalizedDefaultSubscriptions,
force_email_on_third_party_signup: form.force_email_on_third_party_signup,

View File

@ -167,6 +167,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
@ -261,7 +266,7 @@ onMounted(async () => {
initialTurnstileToken.value = registerData.turnstile_token || ''
promoCode.value = registerData.promo_code || ''
invitationCode.value = registerData.invitation_code || ''
affCode.value = registerData.aff_code || ''
affCode.value = registerData.aff_code || loadAffiliateReferralCode()
pendingAuthToken.value = registerData.pending_auth_token || activePendingSession?.token || ''
pendingAuthTokenField.value = registerData.pending_auth_token_field || activePendingSession?.token_field || 'pending_auth_token'
pendingProvider.value = registerData.pending_provider || activePendingSession?.provider || ''
@ -501,6 +506,7 @@ async function handleVerify(): Promise<void> {
password: password.value,
verify_code: verifyCode.value.trim(),
invitation_code: invitationCode.value || undefined,
...oauthAffiliatePayload(affCode.value || loadAffiliateReferralCode()),
adopt_display_name: pendingAdoptionDecision.value?.adoptDisplayName,
adopt_avatar: pendingAdoptionDecision.value?.adoptAvatar
}
@ -533,6 +539,7 @@ async function handleVerify(): Promise<void> {
// Clear session data
sessionStorage.removeItem('register_data')
clearAllAffiliateReferralCodes()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))

View File

@ -255,6 +255,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@ -568,6 +573,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@ -579,6 +585,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@ -627,18 +634,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: LinuxDoPendingActionResponse = legacyPendingOAuthToken.value
? (
await apiClient.post<LinuxDoPendingActionResponse>('/auth/oauth/linuxdo/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeLinuxDoOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeLinuxDoOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@ -673,6 +682,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@ -720,6 +730,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@ -743,6 +754,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return

View File

@ -186,6 +186,7 @@ import TurnstileWidget from '@/components/TurnstileWidget.vue'
import { useAuthStore, useAppStore } from '@/stores'
import { getPublicSettings, isTotp2FARequired, isWeChatWebOAuthEnabled } from '@/api/auth'
import type { TotpLoginResponse } from '@/types'
import { clearAllAffiliateReferralCodes } from '@/utils/oauthAffiliate'
const { t } = useI18n()
@ -355,6 +356,7 @@ async function handleLogin(): Promise<void> {
}
// Show success toast
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route
@ -397,6 +399,7 @@ async function handle2FAVerify(code: string): Promise<void> {
// Close modal and show success
show2FAModal.value = false
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
// Redirect to dashboard or intended route

View File

@ -264,6 +264,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@ -590,6 +595,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@ -601,6 +607,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@ -649,18 +656,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingOidcCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingOidcCompletion>('/auth/oauth/oidc/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeOIDCOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeOIDCOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@ -695,6 +704,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@ -742,6 +752,7 @@ async function handleSubmitTotpChallenge() {
totp_code: code
})
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@ -767,6 +778,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return

View File

@ -15,17 +15,20 @@
<LinuxDoOAuthSection
v-if="linuxdoOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<WechatOAuthSection
v-if="wechatOAuthEnabled"
:disabled="isLoading"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<OidcOAuthSection
v-if="oidcOAuthEnabled"
:disabled="isLoading"
:provider-name="oidcOAuthProviderName"
:aff-code="formData.aff_code"
:show-divider="false"
/>
<div class="flex items-center gap-3">
@ -293,6 +296,11 @@ import {
isRegistrationEmailSuffixAllowed,
normalizeRegistrationEmailSuffixWhitelist
} from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
resolveAffiliateReferralCode
} from '@/utils/oauthAffiliate'
const { t, locale } = useI18n()
@ -378,9 +386,19 @@ watch(validationToastMessage, (value, previousValue) => {
}
})
function syncAffiliateReferralCode(): string {
const code = resolveAffiliateReferralCode(route.query.aff, route.query.aff_code)
if (code) {
formData.aff_code = code
}
return code
}
// ==================== Lifecycle ====================
onMounted(async () => {
syncAffiliateReferralCode()
try {
const settings = await getPublicSettings()
registrationEnabled.value = settings.registration_enabled
@ -407,10 +425,7 @@ onMounted(async () => {
await validatePromoCodeDebounced(promoParam)
}
}
const affParam = (route.query.aff as string) || (route.query.aff_code as string)
if (affParam) {
formData.aff_code = affParam.trim()
}
syncAffiliateReferralCode()
} catch (error) {
console.error('Failed to load public settings:', error)
} finally {
@ -418,6 +433,13 @@ onMounted(async () => {
}
})
watch(
() => [route.query.aff, route.query.aff_code],
() => {
syncAffiliateReferralCode()
}
)
onUnmounted(() => {
if (promoValidateTimeout) {
clearTimeout(promoValidateTimeout)
@ -702,6 +724,11 @@ async function handleRegister(): Promise<void> {
isLoading.value = true
try {
const affCode = formData.aff_code.trim() || loadAffiliateReferralCode()
if (affCode) {
formData.aff_code = affCode
}
// If email verification is enabled, redirect to verification page
if (emailVerifyEnabled.value) {
// Store registration data in sessionStorage
@ -713,7 +740,7 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileToken.value,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
...(affCode ? { aff_code: affCode } : {})
})
)
@ -729,8 +756,9 @@ async function handleRegister(): Promise<void> {
turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined,
promo_code: formData.promo_code || undefined,
invitation_code: formData.invitation_code || undefined,
...(formData.aff_code ? { aff_code: formData.aff_code } : {})
...(affCode ? { aff_code: affCode } : {})
})
clearAffiliateReferralCode()
// Show success toast
appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value }))

View File

@ -340,6 +340,11 @@ import {
type OAuthTokenResponse,
type PendingOAuthExchangeResponse
} from '@/api/auth'
import {
clearAllAffiliateReferralCodes,
loadOAuthAffiliateCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
const route = useRoute()
const router = useRouter()
@ -802,6 +807,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
if (getOAuthCompletionKind(completion) === 'bind') {
const bindRedirect = sanitizeRedirectPath(completion.redirect || '/profile')
clearPendingAuthSession()
clearAllAffiliateReferralCodes()
appStore.showSuccess(bindSuccessMessage)
await router.replace(bindRedirect)
return
@ -813,6 +819,7 @@ async function finalizeCompletion(completion: PendingOAuthExchangeResponse, redi
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
}
@ -861,18 +868,20 @@ async function handleSubmitInvitation() {
isSubmitting.value = true
try {
const affCode = loadOAuthAffiliateCode()
const decision = currentAdoptionDecision()
const completion: PendingWeChatCompletion = legacyPendingOAuthToken.value
? (
await apiClient.post<PendingWeChatCompletion>('/auth/oauth/wechat/complete-registration', {
pending_oauth_token: legacyPendingOAuthToken.value,
invitation_code: invitationCode.value.trim(),
...serializeAdoptionDecision(currentAdoptionDecision())
...oauthAffiliatePayload(affCode),
...serializeAdoptionDecision(decision)
})
).data
: await completeWeChatOAuthRegistration(
invitationCode.value.trim(),
currentAdoptionDecision()
)
: affCode
? await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision, affCode)
: await completeWeChatOAuthRegistration(invitationCode.value.trim(), decision)
await finalizePendingAccountResponse(completion)
} catch (e: unknown) {
const err = e as { message?: string; response?: { data?: { message?: string } } }
@ -907,6 +916,7 @@ async function handleCreateAccount(payload: PendingOAuthCreateAccountPayload) {
password: payload.password,
verify_code: payload.verifyCode || undefined,
invitation_code: payload.invitationCode || undefined,
...oauthAffiliatePayload(loadOAuthAffiliateCode()),
...serializeAdoptionDecision(currentAdoptionDecision())
})
await finalizePendingAccountResponse(data)
@ -955,6 +965,7 @@ async function handleSubmitTotpChallenge() {
})
persistOAuthTokenContext(completion)
await authStore.setToken(completion.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirectTo.value)
} catch (e: unknown) {
@ -1015,6 +1026,7 @@ onMounted(async () => {
if (legacyLogin) {
persistOAuthTokenContext(legacyLogin)
await authStore.setToken(legacyLogin.access_token)
clearAllAffiliateReferralCodes()
appStore.showSuccess(t('auth.loginSuccess'))
await router.replace(redirect)
return

View File

@ -112,6 +112,7 @@ describe('EmailVerifyView', () => {
apiClientPostMock.mockReset()
authStoreState.pendingAuthSession = null
sessionStorage.clear()
localStorage.clear()
getPublicSettingsMock.mockResolvedValue({
turnstile_enabled: false,
@ -136,6 +137,7 @@ describe('EmailVerifyView', () => {
JSON.stringify({
email: 'fresh@example.com',
password: 'secret-123',
aff_code: 'AFF123',
})
)
@ -334,6 +336,7 @@ describe('EmailVerifyView', () => {
email: 'fresh@example.com',
password: 'secret-123',
verify_code: '123456',
aff_code: 'AFF123',
})
expect(persistOAuthTokenContextMock).toHaveBeenCalledWith({
access_token: 'oauth-access-token',

View File

@ -93,6 +93,7 @@ describe('LinuxDoCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {

View File

@ -97,6 +97,7 @@ describe('OidcCallbackView', () => {
})
window.location.hash = ''
localStorage.clear()
sessionStorage.clear()
})
it('accepts the legacy fragment token success callback without pending-session exchange', async () => {

View File

@ -172,6 +172,7 @@ describe('WechatCallbackView', () => {
appStoreState.cachedPublicSettings = null
appStoreState.publicSettingsLoaded = false
localStorage.clear()
sessionStorage.clear()
locationState.current = {
href: 'http://localhost/auth/wechat/callback',
hash: '',

View File

@ -9,21 +9,17 @@
<template v-else-if="detail">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- 返利比例用主色突出让用户一眼看到能拿多少 -->
<div class="card relative overflow-hidden p-5">
<div class="absolute -right-6 -top-6 h-24 w-24 rounded-full bg-primary-500/10"></div>
<div class="relative">
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<Icon name="dollar" size="sm" class="text-primary-500" />
{{ t('affiliate.stats.rebateRate') }}
</p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
<div class="card p-5">
<p class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dark-400">
<Icon name="dollar" size="sm" class="text-primary-500" />
{{ t('affiliate.stats.rebateRate') }}
</p>
<p class="mt-2 text-2xl font-semibold text-primary-600 dark:text-primary-400">
{{ formattedRebateRate }}<span class="ml-0.5 text-base font-medium">%</span>
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-dark-500">
{{ t('affiliate.stats.rebateRateHint') }}
</p>
</div>
<div class="card p-5">
<p class="text-sm text-gray-500 dark:text-dark-400">{{ t('affiliate.stats.invitedUsers') }}</p>
@ -42,6 +38,9 @@
<p class="mt-2 text-2xl font-semibold text-gray-900 dark:text-white">
{{ formatCurrency(detail.aff_history_quota) }}
</p>
<p v-if="detail.aff_frozen_quota > 0" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{ t('affiliate.stats.frozenQuota') }}: {{ formatCurrency(detail.aff_frozen_quota) }}
</p>
</div>
</div>
@ -79,6 +78,7 @@
<li>1. {{ t('affiliate.tips.line1') }}</li>
<li>2. {{ t('affiliate.tips.line2', { rate: `${formattedRebateRate}%` }) }}</li>
<li>3. {{ t('affiliate.tips.line3') }}</li>
<li v-if="detail.aff_frozen_quota > 0">4. {{ t('affiliate.tips.line4') }}</li>
</ul>
</div>
</div>
@ -115,6 +115,7 @@
<tr class="border-b border-gray-200 text-gray-500 dark:border-dark-700 dark:text-dark-400">
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.email') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.username') }}</th>
<th class="px-3 py-2 font-medium text-right">{{ t('affiliate.invitees.columns.rebate') }}</th>
<th class="px-3 py-2 font-medium">{{ t('affiliate.invitees.columns.joinedAt') }}</th>
</tr>
</thead>
@ -126,6 +127,7 @@
>
<td class="px-3 py-3 text-gray-900 dark:text-white">{{ item.email || '-' }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ item.username || '-' }}</td>
<td class="px-3 py-3 text-right font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(item.total_rebate) }}</td>
<td class="px-3 py-3 text-gray-700 dark:text-gray-300">{{ formatDateTime(item.created_at) || '-' }}</td>
</tr>
</tbody>