Merge branch 'Wei-Shaw:main' into vertex
This commit is contained in:
commit
3f05ef2ae3
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
docs/claude-relay-service/
|
||||
.codex
|
||||
|
||||
# ===================
|
||||
# Go 后端
|
||||
|
||||
@ -1 +1 @@
|
||||
0.1.118
|
||||
0.1.119
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
196
backend/internal/payment/provider/easypay_refund_test.go
Normal file
196
backend/internal/payment/provider/easypay_refund_test.go
Normal 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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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服务器地址
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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])
|
||||
|
||||
// 敏感信息直接返回,方便测试连接时使用
|
||||
|
||||
@ -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"`
|
||||
|
||||
17
backend/migrations/133_affiliate_rebate_freeze.sql
Normal file
17
backend/migrations/133_affiliate_rebate_freeze.sql
Normal 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;
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)}`
|
||||
|
||||
@ -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)}`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -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: '为指定用户设置专属邀请码或专属返利比例。仅展示已设置过专属配置的用户。',
|
||||
|
||||
@ -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
|
||||
|
||||
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal file
48
frontend/src/utils/__tests__/oauthAffiliate.spec.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
133
frontend/src/utils/oauthAffiliate.ts
Normal file
133
frontend/src/utils/oauthAffiliate.ts
Normal 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 } : {}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 }))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user