From ceee2d058459e32e0903b98acf31cdefcf34a7f2 Mon Sep 17 00:00:00 2001 From: erio Date: Thu, 9 Apr 2026 21:29:49 +0800 Subject: [PATCH] fix: audit fixes - magic strings to constants, frontend any/catch, LB tests Backend: - Define OrderTypeBalance/Subscription, EntityStatusActive, DeductionType*, NotificationStatus* constants in payment/types.go - Replace all magic strings in payment_order, payment_fulfillment, payment_refund - Add local constants in easypay.go (tradeStatusSuccess, signTypeMD5) - Add 27 unit tests for load balancer (filterByLimits, pickLeastAmount, getInstanceChannelLimits, startOfDay) Frontend: - Remove all `any` types in SettingsView.vue (18 catch blocks + 1 payload) - Fix bare catch blocks in PaymentResultView, PaymentView - Add `unknown` type annotation to all catch blocks chore: bump version to 0.1.108.140 --- backend/cmd/server/VERSION | 2 +- .../internal/payment/load_balancer_test.go | 387 ++++++++++++++++++ backend/internal/payment/provider/easypay.go | 8 +- backend/internal/payment/types.go | 24 ++ .../internal/service/payment_fulfillment.go | 6 +- backend/internal/service/payment_order.go | 14 +- backend/internal/service/payment_refund.go | 16 +- frontend/src/views/admin/SettingsView.vue | 83 ++-- frontend/src/views/user/PaymentResultView.vue | 4 +- frontend/src/views/user/PaymentView.vue | 2 +- frontend/src/views/user/UserOrdersView.vue | 2 +- 11 files changed, 472 insertions(+), 76 deletions(-) diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 417cd44107..e534f2aacd 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -0.1.108.139 +0.1.108.140 diff --git a/backend/internal/payment/load_balancer_test.go b/backend/internal/payment/load_balancer_test.go index e8bc4c8652..568b56a30d 100644 --- a/backend/internal/payment/load_balancer_test.go +++ b/backend/internal/payment/load_balancer_test.go @@ -1,7 +1,13 @@ +//go:build unit + package payment import ( + "encoding/json" "testing" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" ) func TestInstanceSupportsType(t *testing.T) { @@ -85,3 +91,384 @@ func TestInstanceSupportsType(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// Helper to build test PaymentProviderInstance values +// --------------------------------------------------------------------------- + +func testInstance(id int64, providerKey, limits string) *dbent.PaymentProviderInstance { + return &dbent.PaymentProviderInstance{ + ID: id, + ProviderKey: providerKey, + Limits: limits, + Enabled: true, + } +} + +// makeLimitsJSON builds a limits JSON string for a single payment type. +func makeLimitsJSON(paymentType string, cl ChannelLimits) string { + m := map[string]ChannelLimits{paymentType: cl} + b, _ := json.Marshal(m) + return string(b) +} + +// --------------------------------------------------------------------------- +// filterByLimits +// --------------------------------------------------------------------------- + +func TestFilterByLimits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + candidates []instanceCandidate + paymentType PaymentType + orderAmount float64 + wantIDs []int64 // expected surviving instance IDs + }{ + { + name: "order below SingleMin is filtered out", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0}, + }, + paymentType: "alipay", + orderAmount: 5, + wantIDs: nil, + }, + { + name: "order at exact SingleMin boundary passes", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10})), dailyUsed: 0}, + }, + paymentType: "alipay", + orderAmount: 10, + wantIDs: []int64{1}, + }, + { + name: "order above SingleMax is filtered out", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0}, + }, + paymentType: "alipay", + orderAmount: 150, + wantIDs: nil, + }, + { + name: "order at exact SingleMax boundary passes", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 100})), dailyUsed: 0}, + }, + paymentType: "alipay", + orderAmount: 100, + wantIDs: []int64{1}, + }, + { + name: "daily used + orderAmount exceeding dailyLimit is filtered out", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480}, + }, + paymentType: "alipay", + orderAmount: 30, + wantIDs: nil, // 480+30=510 > 500 + }, + { + name: "daily used + orderAmount equal to dailyLimit passes (strict greater-than)", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 480}, + }, + paymentType: "alipay", + orderAmount: 20, + wantIDs: []int64{1}, // 480+20=500, 500 > 500 is false → passes + }, + { + name: "daily used + orderAmount below dailyLimit passes", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 500})), dailyUsed: 400}, + }, + paymentType: "alipay", + orderAmount: 50, + wantIDs: []int64{1}, + }, + { + name: "no limits configured passes through", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", ""), dailyUsed: 99999}, + }, + paymentType: "alipay", + orderAmount: 100, + wantIDs: []int64{1}, + }, + { + name: "multiple candidates with partial filtering", + candidates: []instanceCandidate{ + // singleMax=50, order=80 → filtered out + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMax: 50})), dailyUsed: 0}, + // no limits → passes + {inst: testInstance(2, "easypay", ""), dailyUsed: 0}, + // singleMin=100, order=80 → filtered out + {inst: testInstance(3, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 100})), dailyUsed: 0}, + // daily limit ok → passes (500+80=580 < 1000) + {inst: testInstance(4, "easypay", makeLimitsJSON("alipay", ChannelLimits{DailyLimit: 1000})), dailyUsed: 500}, + }, + paymentType: "alipay", + orderAmount: 80, + wantIDs: []int64{2, 4}, + }, + { + name: "zero SingleMin and SingleMax means no single-transaction limit", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 0, SingleMax: 0, DailyLimit: 0})), dailyUsed: 0}, + }, + paymentType: "alipay", + orderAmount: 99999, + wantIDs: []int64{1}, + }, + { + name: "all limits combined - order passes all checks", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500}, + }, + paymentType: "alipay", + orderAmount: 50, + wantIDs: []int64{1}, + }, + { + name: "all limits combined - order fails SingleMin", + candidates: []instanceCandidate{ + {inst: testInstance(1, "easypay", makeLimitsJSON("alipay", ChannelLimits{SingleMin: 10, SingleMax: 200, DailyLimit: 1000})), dailyUsed: 500}, + }, + paymentType: "alipay", + orderAmount: 5, + wantIDs: nil, + }, + { + name: "empty candidates returns empty", + candidates: nil, + paymentType: "alipay", + orderAmount: 10, + wantIDs: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := filterByLimits(tt.candidates, tt.paymentType, tt.orderAmount) + gotIDs := make([]int64, len(got)) + for i, c := range got { + gotIDs[i] = c.inst.ID + } + if !int64SliceEqual(gotIDs, tt.wantIDs) { + t.Fatalf("filterByLimits() returned IDs %v, want %v", gotIDs, tt.wantIDs) + } + }) + } +} + +// --------------------------------------------------------------------------- +// pickLeastAmount +// --------------------------------------------------------------------------- + +func TestPickLeastAmount(t *testing.T) { + t.Parallel() + + t.Run("picks candidate with lowest dailyUsed", func(t *testing.T) { + t.Parallel() + candidates := []instanceCandidate{ + {inst: testInstance(1, "easypay", ""), dailyUsed: 300}, + {inst: testInstance(2, "easypay", ""), dailyUsed: 100}, + {inst: testInstance(3, "easypay", ""), dailyUsed: 200}, + } + got := pickLeastAmount(candidates) + if got.inst.ID != 2 { + t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID) + } + }) + + t.Run("with equal dailyUsed picks the first one", func(t *testing.T) { + t.Parallel() + candidates := []instanceCandidate{ + {inst: testInstance(1, "easypay", ""), dailyUsed: 100}, + {inst: testInstance(2, "easypay", ""), dailyUsed: 100}, + {inst: testInstance(3, "easypay", ""), dailyUsed: 200}, + } + got := pickLeastAmount(candidates) + if got.inst.ID != 1 { + t.Fatalf("pickLeastAmount() picked instance %d, want 1 (first with lowest)", got.inst.ID) + } + }) + + t.Run("single candidate returns that candidate", func(t *testing.T) { + t.Parallel() + candidates := []instanceCandidate{ + {inst: testInstance(42, "easypay", ""), dailyUsed: 999}, + } + got := pickLeastAmount(candidates) + if got.inst.ID != 42 { + t.Fatalf("pickLeastAmount() picked instance %d, want 42", got.inst.ID) + } + }) + + t.Run("zero usage among non-zero picks zero", func(t *testing.T) { + t.Parallel() + candidates := []instanceCandidate{ + {inst: testInstance(1, "easypay", ""), dailyUsed: 500}, + {inst: testInstance(2, "easypay", ""), dailyUsed: 0}, + {inst: testInstance(3, "easypay", ""), dailyUsed: 300}, + } + got := pickLeastAmount(candidates) + if got.inst.ID != 2 { + t.Fatalf("pickLeastAmount() picked instance %d, want 2", got.inst.ID) + } + }) +} + +// --------------------------------------------------------------------------- +// getInstanceChannelLimits +// --------------------------------------------------------------------------- + +func TestGetInstanceChannelLimits(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inst *dbent.PaymentProviderInstance + paymentType PaymentType + want ChannelLimits + }{ + { + name: "empty limits string returns zero ChannelLimits", + inst: testInstance(1, "easypay", ""), + paymentType: "alipay", + want: ChannelLimits{}, + }, + { + name: "invalid JSON returns zero ChannelLimits", + inst: testInstance(1, "easypay", "not-json{"), + paymentType: "alipay", + want: ChannelLimits{}, + }, + { + name: "valid JSON with matching payment type", + inst: testInstance(1, "easypay", + `{"alipay":{"singleMin":5,"singleMax":200,"dailyLimit":1000}}`), + paymentType: "alipay", + want: ChannelLimits{SingleMin: 5, SingleMax: 200, DailyLimit: 1000}, + }, + { + name: "payment type not in limits returns zero ChannelLimits", + inst: testInstance(1, "easypay", + `{"alipay":{"singleMin":5,"singleMax":200}}`), + paymentType: "wxpay", + want: ChannelLimits{}, + }, + { + name: "stripe provider uses stripe lookup key regardless of payment type", + inst: testInstance(1, "stripe", + `{"stripe":{"singleMin":10,"singleMax":500,"dailyLimit":5000}}`), + paymentType: "alipay", + want: ChannelLimits{SingleMin: 10, SingleMax: 500, DailyLimit: 5000}, + }, + { + name: "stripe provider ignores payment type key even if present", + inst: testInstance(1, "stripe", + `{"stripe":{"singleMin":10,"singleMax":500},"alipay":{"singleMin":1,"singleMax":100}}`), + paymentType: "alipay", + want: ChannelLimits{SingleMin: 10, SingleMax: 500}, + }, + { + name: "non-stripe provider uses payment type as lookup key", + inst: testInstance(1, "easypay", + `{"alipay":{"singleMin":5},"wxpay":{"singleMin":10}}`), + paymentType: "wxpay", + want: ChannelLimits{SingleMin: 10}, + }, + { + name: "valid JSON with partial limits (only dailyLimit)", + inst: testInstance(1, "easypay", + `{"alipay":{"dailyLimit":800}}`), + paymentType: "alipay", + want: ChannelLimits{DailyLimit: 800}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := getInstanceChannelLimits(tt.inst, tt.paymentType) + if got != tt.want { + t.Fatalf("getInstanceChannelLimits() = %+v, want %+v", got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// startOfDay +// --------------------------------------------------------------------------- + +func TestStartOfDay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in time.Time + want time.Time + }{ + { + name: "midday returns midnight of same day", + in: time.Date(2025, 6, 15, 14, 30, 45, 123456789, time.UTC), + want: time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC), + }, + { + name: "midnight returns same time", + in: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + want: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + name: "last second of day returns midnight of same day", + in: time.Date(2025, 12, 31, 23, 59, 59, 999999999, time.UTC), + want: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + }, + { + name: "preserves timezone location", + in: time.Date(2025, 3, 10, 15, 0, 0, 0, time.FixedZone("CST", 8*3600)), + want: time.Date(2025, 3, 10, 0, 0, 0, 0, time.FixedZone("CST", 8*3600)), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := startOfDay(tt.in) + if !got.Equal(tt.want) { + t.Fatalf("startOfDay(%v) = %v, want %v", tt.in, got, tt.want) + } + // Also verify location is preserved. + if got.Location().String() != tt.want.Location().String() { + t.Fatalf("startOfDay() location = %v, want %v", got.Location(), tt.want.Location()) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// int64SliceEqual compares two int64 slices for equality. +// Both nil and empty slices are treated as equal. +func int64SliceEqual(a, b []int64) bool { + if len(a) == 0 && len(b) == 0 { + return true + } + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/backend/internal/payment/provider/easypay.go b/backend/internal/payment/provider/easypay.go index 8e060bce88..3fa5928335 100644 --- a/backend/internal/payment/provider/easypay.go +++ b/backend/internal/payment/provider/easypay.go @@ -25,6 +25,8 @@ const ( easypayStatusPaid = 1 easypayHTTPTimeout = 10 * time.Second maxEasypayResponseSize = 1 << 20 // 1MB + tradeStatusSuccess = "TRADE_SUCCESS" + signTypeMD5 = "MD5" ) // EasyPay implements payment.Provider for the EasyPay aggregation platform. @@ -80,7 +82,7 @@ func (e *EasyPay) createRedirectPayment(req payment.CreatePaymentRequest) (*paym params["cid"] = cid } params["sign"] = easyPaySign(params, e.config["pkey"]) - params["sign_type"] = "MD5" + params["sign_type"] = signTypeMD5 q := url.Values{} for k, v := range params { @@ -107,7 +109,7 @@ func (e *EasyPay) createAPIPayment(ctx context.Context, req payment.CreatePaymen params["device"] = "mobile" } params["sign"] = easyPaySign(params, e.config["pkey"]) - params["sign_type"] = "MD5" + params["sign_type"] = signTypeMD5 body, err := e.post(ctx, strings.TrimRight(e.config["apiBase"], "/")+"/mapi.php", params) if err != nil { @@ -184,7 +186,7 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st return nil, fmt.Errorf("invalid signature") } status := payment.ProviderStatusFailed - if params["trade_status"] == "TRADE_SUCCESS" { + if params["trade_status"] == tradeStatusSuccess { status = payment.ProviderStatusSuccess } amount, _ := strconv.ParseFloat(params["money"], 64) diff --git a/backend/internal/payment/types.go b/backend/internal/payment/types.go index ab27dfeda0..c413d8f3ee 100644 --- a/backend/internal/payment/types.go +++ b/backend/internal/payment/types.go @@ -35,6 +35,30 @@ const ( OrderStatusRefundFailed = "REFUND_FAILED" ) +// Order types distinguish balance recharges from subscription purchases. +const ( + OrderTypeBalance = "balance" + OrderTypeSubscription = "subscription" +) + +// Entity statuses shared across users, groups, etc. +const ( + EntityStatusActive = "active" +) + +// Deduction types for refund flow. +const ( + DeductionTypeBalance = "balance" + DeductionTypeSubscription = "subscription" + DeductionTypeNone = "none" +) + +// Payment notification status values. +const ( + NotificationStatusSuccess = "success" + NotificationStatusPaid = "paid" +) + // Provider-level status constants returned by provider implementations // to the service layer (lowercase, distinct from OrderStatus uppercase constants). const ( diff --git a/backend/internal/service/payment_fulfillment.go b/backend/internal/service/payment_fulfillment.go index 3149781e44..f08e36ff05 100644 --- a/backend/internal/service/payment_fulfillment.go +++ b/backend/internal/service/payment_fulfillment.go @@ -18,7 +18,7 @@ import ( // --- Payment Notification & Fulfillment --- func (s *PaymentService) HandlePaymentNotification(ctx context.Context, n *payment.PaymentNotification, pk string) error { - if n.Status != "success" { + if n.Status != payment.NotificationStatusSuccess { return nil } // Look up order by out_trade_no (the external order ID we sent to the provider) @@ -120,7 +120,7 @@ func (s *PaymentService) executeFulfillment(ctx context.Context, oid int64) erro if err != nil { return fmt.Errorf("get order: %w", err) } - if o.OrderType == "subscription" { + if o.OrderType == payment.OrderTypeSubscription { return s.ExecuteSubscriptionFulfillment(ctx, oid) } return s.ExecuteBalanceFulfillment(ctx, oid) @@ -246,7 +246,7 @@ func (s *PaymentService) doSub(ctx context.Context, o *dbent.PaymentOrder) error gid := *o.SubscriptionGroupID days := *o.SubscriptionDays g, err := s.groupRepo.GetByID(ctx, gid) - if err != nil || g.Status != "active" { + if err != nil || g.Status != payment.EntityStatusActive { return fmt.Errorf("group %d no longer exists or inactive", gid) } _, _, err = s.subscriptionSvc.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{UserID: o.UserID, GroupID: gid, ValidityDays: days, AssignedBy: 0, Notes: fmt.Sprintf("payment order %d", o.ID)}) diff --git a/backend/internal/service/payment_order.go b/backend/internal/service/payment_order.go index 6e8adc5467..d61a0d889c 100644 --- a/backend/internal/service/payment_order.go +++ b/backend/internal/service/payment_order.go @@ -21,7 +21,7 @@ import ( func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*CreateOrderResponse, error) { if req.OrderType == "" { - req.OrderType = "balance" + req.OrderType = payment.OrderTypeBalance } cfg, err := s.configService.GetPaymentConfig(ctx) if err != nil { @@ -41,7 +41,7 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest if err != nil { return nil, fmt.Errorf("get user: %w", err) } - if user.Status != "active" { + if user.Status != payment.EntityStatusActive { return nil, infraerrors.Forbidden("USER_INACTIVE", "user account is disabled") } amount := req.Amount @@ -66,10 +66,10 @@ func (s *PaymentService) CreateOrder(ctx context.Context, req CreateOrderRequest } func (s *PaymentService) validateOrderInput(ctx context.Context, req CreateOrderRequest, cfg *PaymentConfig) (*dbent.SubscriptionPlan, error) { - if req.OrderType == "balance" && cfg.BalanceDisabled { + if req.OrderType == payment.OrderTypeBalance && cfg.BalanceDisabled { return nil, infraerrors.Forbidden("BALANCE_PAYMENT_DISABLED", "balance recharge has been disabled") } - if req.OrderType == "subscription" { + if req.OrderType == payment.OrderTypeSubscription { return s.validateSubOrder(ctx, req) } if (cfg.MinAmount > 0 && req.Amount < cfg.MinAmount) || (cfg.MaxAmount > 0 && req.Amount > cfg.MaxAmount) { @@ -88,7 +88,7 @@ func (s *PaymentService) validateSubOrder(ctx context.Context, req CreateOrderRe return nil, infraerrors.NotFound("PLAN_NOT_AVAILABLE", "plan not found or not for sale") } group, err := s.groupRepo.GetByID(ctx, plan.GroupID) - if err != nil || group.Status != "active" { + if err != nil || group.Status != payment.EntityStatusActive { return nil, infraerrors.NotFound("GROUP_NOT_FOUND", "subscription group is no longer available") } if !group.IsSubscriptionType() { @@ -426,8 +426,8 @@ func (s *PaymentService) checkPaid(ctx context.Context, o *dbent.PaymentOrder) s slog.Warn("query upstream failed", "orderID", o.ID, "error", err) return "" } - if resp.Status == "paid" { - _ = s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: "success"}, prov.ProviderKey()) + if resp.Status == payment.ProviderStatusPaid { + _ = s.HandlePaymentNotification(ctx, &payment.PaymentNotification{TradeNo: o.PaymentTradeNo, OrderID: o.OutTradeNo, Amount: resp.Amount, Status: payment.ProviderStatusSuccess}, prov.ProviderKey()) return "already_paid" } if cp, ok := prov.(payment.CancelableProvider); ok { diff --git a/backend/internal/service/payment_refund.go b/backend/internal/service/payment_refund.go index c074ae2ad2..f3d2050993 100644 --- a/backend/internal/service/payment_refund.go +++ b/backend/internal/service/payment_refund.go @@ -32,7 +32,7 @@ func (s *PaymentService) RequestRefund(ctx context.Context, oid, uid int64, reas nr := strings.TrimSpace(reason) now := time.Now() by := fmt.Sprintf("%d", uid) - c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ("balance")).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx) + c, err := s.entClient.PaymentOrder.Update().Where(paymentorder.IDEQ(oid), paymentorder.UserIDEQ(uid), paymentorder.StatusEQ(OrderStatusCompleted), paymentorder.OrderTypeEQ(payment.OrderTypeBalance)).SetStatus(OrderStatusRefundRequested).SetRefundRequestedAt(now).SetRefundRequestReason(nr).SetRefundRequestedBy(by).SetRefundAmount(o.Amount).Save(ctx) if err != nil { return fmt.Errorf("update: %w", err) } @@ -51,7 +51,7 @@ func (s *PaymentService) validateRefundRequest(ctx context.Context, oid, uid int if o.UserID != uid { return nil, infraerrors.Forbidden("FORBIDDEN", "no permission") } - if o.OrderType != "balance" { + if o.OrderType != payment.OrderTypeBalance { return nil, infraerrors.BadRequest("INVALID_ORDER_TYPE", "only balance orders can request refund") } if o.Status != OrderStatusCompleted { @@ -86,7 +86,7 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float if rr == "" { rr = fmt.Sprintf("refund order:%d", o.ID) } - p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: "none"} + p := &RefundPlan{OrderID: oid, Order: o, RefundAmount: amt, GatewayAmount: ga, Reason: rr, Force: force, DeductBalance: deduct, DeductionType: payment.DeductionTypeNone} if deduct { if er := s.prepDeduct(ctx, o, p, force); er != nil { return nil, er, nil @@ -96,8 +96,8 @@ func (s *PaymentService) PrepareRefund(ctx context.Context, oid int64, amt float } func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, p *RefundPlan, force bool) *RefundResult { - if o.OrderType == "subscription" { - p.DeductionType = "subscription" + if o.OrderType == payment.OrderTypeSubscription { + p.DeductionType = payment.DeductionTypeSubscription return nil } u, err := s.userRepo.GetByID(ctx, o.UserID) @@ -107,7 +107,7 @@ func (s *PaymentService) prepDeduct(ctx context.Context, o *dbent.PaymentOrder, } return nil } - p.DeductionType = "balance" + p.DeductionType = payment.DeductionTypeBalance p.BalanceToDeduct = math.Min(p.RefundAmount, u.Balance) return nil } @@ -120,7 +120,7 @@ func (s *PaymentService) ExecuteRefund(ctx context.Context, p *RefundPlan) (*Ref if c == 0 { return nil, infraerrors.Conflict("CONFLICT", "order status changed") } - if p.DeductionType == "balance" && p.BalanceToDeduct > 0 { + if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { if err := s.userRepo.DeductBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { s.restoreStatus(ctx, p) return nil, fmt.Errorf("deduction: %w", err) @@ -173,7 +173,7 @@ func (s *PaymentService) markRefundOk(ctx context.Context, p *RefundPlan) (*Refu } func (s *PaymentService) RollbackRefund(ctx context.Context, p *RefundPlan, gErr error) bool { - if p.DeductionType == "balance" && p.BalanceToDeduct > 0 { + if p.DeductionType == payment.DeductionTypeBalance && p.BalanceToDeduct > 0 { if err := s.userRepo.UpdateBalance(ctx, p.Order.UserID, p.BalanceToDeduct); err != nil { slog.Error("[CRITICAL] rollback failed", "orderID", p.OrderID, "amount", p.BalanceToDeduct, "error", err) s.writeAuditLog(ctx, p.OrderID, "REFUND_ROLLBACK_FAILED", "admin", map[string]any{"gatewayError": psErrMsg(gErr), "rollbackError": psErrMsg(err), "balanceDeducted": p.BalanceToDeduct}) diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 932daabe1e..20f9318c26 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -2359,7 +2359,7 @@ async function loadSettings() { loadFailed.value = false try { const settings = await adminAPI.settings.getSettings() - ;(settings as any).payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin' + settings.payment_load_balance_strategy = settings.payment_load_balance_strategy || 'round-robin' // Only assign non-null values from backend (null means unconfigured, keep defaults) for (const [key, value] of Object.entries(settings)) { if (value !== null && value !== undefined) { @@ -2383,11 +2383,9 @@ async function loadSettings() { smtpPasswordManuallyEdited.value = false form.turnstile_secret_key = '' form.linuxdo_connect_client_secret = '' - } catch (error: any) { + } catch (error: unknown) { loadFailed.value = true - appStore.showError( - t('admin.settings.failedToLoad') + ': ' + (error.message || t('common.unknownError')) - ) + appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToLoad'))) } finally { loading.value = false } @@ -2399,8 +2397,7 @@ async function loadSubscriptionGroups() { subscriptionGroups.value = groups.filter( (group) => group.subscription_type === 'subscription' && group.status === 'active' ) - } catch (error) { - console.error('Failed to load subscription groups:', error) + } catch (_error: unknown) { subscriptionGroups.value = [] } } @@ -2551,10 +2548,8 @@ async function saveSettings() { await appStore.fetchPublicSettings(true) await adminSettingsStore.fetch(true) appStore.showSuccess(t('admin.settings.settingsSaved')) - } catch (error: any) { - appStore.showError( - t('admin.settings.failedToSave') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSave'))) } finally { saving.value = false } @@ -2573,10 +2568,8 @@ async function testSmtpConnection() { }) // API returns { message: "..." } on success, errors are thrown as exceptions appStore.showSuccess(result.message || t('admin.settings.smtpConnectionSuccess')) - } catch (error: any) { - appStore.showError( - t('admin.settings.failedToTestSmtp') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToTestSmtp'))) } finally { testingSmtp.value = false } @@ -2603,10 +2596,8 @@ async function sendTestEmail() { }) // API returns { message: "..." } on success, errors are thrown as exceptions appStore.showSuccess(result.message || t('admin.settings.testEmailSent')) - } catch (error: any) { - appStore.showError( - t('admin.settings.failedToSendTestEmail') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.failedToSendTestEmail'))) } finally { sendingTestEmail.value = false } @@ -2619,8 +2610,8 @@ async function loadAdminApiKey() { const status = await adminAPI.settings.getAdminApiKey() adminApiKeyExists.value = status.exists adminApiKeyMasked.value = status.masked_key - } catch (error: any) { - console.error('Failed to load admin API key status:', error) + } catch (_error: unknown) { + // Silent fail - admin API key status is non-critical } finally { adminApiKeyLoading.value = false } @@ -2634,8 +2625,8 @@ async function createAdminApiKey() { adminApiKeyExists.value = true adminApiKeyMasked.value = result.key.substring(0, 10) + '...' + result.key.slice(-4) appStore.showSuccess(t('admin.settings.adminApiKey.keyGenerated')) - } catch (error: any) { - appStore.showError(error.message || t('common.error')) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('common.error'))) } finally { adminApiKeyOperating.value = false } @@ -2655,8 +2646,8 @@ async function deleteAdminApiKey() { adminApiKeyMasked.value = '' newAdminApiKey.value = '' appStore.showSuccess(t('admin.settings.adminApiKey.keyDeleted')) - } catch (error: any) { - appStore.showError(error.message || t('common.error')) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('common.error'))) } finally { adminApiKeyOperating.value = false } @@ -2679,8 +2670,8 @@ async function loadOverloadCooldownSettings() { try { const settings = await adminAPI.settings.getOverloadCooldownSettings() Object.assign(overloadCooldownForm, settings) - } catch (error: any) { - console.error('Failed to load overload cooldown settings:', error) + } catch (_error: unknown) { + // Silent fail - settings will use defaults } finally { overloadCooldownLoading.value = false } @@ -2695,10 +2686,8 @@ async function saveOverloadCooldownSettings() { }) Object.assign(overloadCooldownForm, updated) appStore.showSuccess(t('admin.settings.overloadCooldown.saved')) - } catch (error: any) { - appStore.showError( - t('admin.settings.overloadCooldown.saveFailed') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.overloadCooldown.saveFailed'))) } finally { overloadCooldownSaving.value = false } @@ -2710,8 +2699,8 @@ async function loadStreamTimeoutSettings() { try { const settings = await adminAPI.settings.getStreamTimeoutSettings() Object.assign(streamTimeoutForm, settings) - } catch (error: any) { - console.error('Failed to load stream timeout settings:', error) + } catch (_error: unknown) { + // Silent fail - settings will use defaults } finally { streamTimeoutLoading.value = false } @@ -2729,10 +2718,8 @@ async function saveStreamTimeoutSettings() { }) Object.assign(streamTimeoutForm, updated) appStore.showSuccess(t('admin.settings.streamTimeout.saved')) - } catch (error: any) { - appStore.showError( - t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.streamTimeout.saveFailed'))) } finally { streamTimeoutSaving.value = false } @@ -2748,8 +2735,8 @@ async function loadRectifierSettings() { if (!Array.isArray(rectifierForm.apikey_signature_patterns)) { rectifierForm.apikey_signature_patterns = [] } - } catch (error: any) { - console.error('Failed to load rectifier settings:', error) + } catch (_error: unknown) { + // Silent fail - settings will use defaults } finally { rectifierLoading.value = false } @@ -2772,10 +2759,8 @@ async function saveRectifierSettings() { rectifierForm.apikey_signature_patterns = [] } appStore.showSuccess(t('admin.settings.rectifier.saved')) - } catch (error: any) { - appStore.showError( - t('admin.settings.rectifier.saveFailed') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.rectifier.saveFailed'))) } finally { rectifierSaving.value = false } @@ -2809,8 +2794,8 @@ async function loadBetaPolicySettings() { try { const settings = await adminAPI.settings.getBetaPolicySettings() betaPolicyForm.rules = settings.rules - } catch (error: any) { - console.error('Failed to load beta policy settings:', error) + } catch (_error: unknown) { + // Silent fail - settings will use defaults } finally { betaPolicyLoading.value = false } @@ -2824,10 +2809,8 @@ async function saveBetaPolicySettings() { }) betaPolicyForm.rules = updated.rules appStore.showSuccess(t('admin.settings.betaPolicy.saved')) - } catch (error: any) { - appStore.showError( - t('admin.settings.betaPolicy.saveFailed') + ': ' + (error.message || t('common.unknownError')) - ) + } catch (error: unknown) { + appStore.showError(extractApiErrorMessage(error, t('admin.settings.betaPolicy.saveFailed'))) } finally { betaPolicySaving.value = false } @@ -2932,7 +2915,7 @@ function openEditProvider(provider: ProviderInstance) { showProviderDialog.value = true } -async function handleSaveProvider(payload: any) { +async function handleSaveProvider(payload: Partial) { providerSaving.value = true try { if (editingProvider.value) { diff --git a/frontend/src/views/user/PaymentResultView.vue b/frontend/src/views/user/PaymentResultView.vue index 8c5447b795..cf0bf37365 100644 --- a/frontend/src/views/user/PaymentResultView.vue +++ b/frontend/src/views/user/PaymentResultView.vue @@ -142,7 +142,7 @@ onMounted(async () => { try { const result = await paymentAPI.verifyOrder(outTradeNo) order.value = result.data - } catch { + } catch (_err: unknown) { // Verification failed, fall through to normal order lookup } } @@ -151,7 +151,7 @@ onMounted(async () => { if (!order.value && orderId) { try { order.value = await paymentStore.pollOrderStatus(orderId) - } catch { + } catch (_err: unknown) { // Order lookup failed, will show returnInfo fallback } } diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 60f329422a..e2dbffd0d8 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -497,7 +497,7 @@ onMounted(async () => { if (checkout.value.balance_disabled) { activeTab.value = 'subscription' } - } catch (err: unknown) { console.error('Failed to load checkout info:', err) } + } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } finally { loading.value = false } // Fetch active subscriptions (uses cache, non-blocking) subscriptionStore.fetchActiveSubscriptions().catch(() => {}) diff --git a/frontend/src/views/user/UserOrdersView.vue b/frontend/src/views/user/UserOrdersView.vue index 57c45eff57..fc0c2fc235 100644 --- a/frontend/src/views/user/UserOrdersView.vue +++ b/frontend/src/views/user/UserOrdersView.vue @@ -161,7 +161,7 @@ async function fetchOrders() { }) orders.value = res.data.items || [] pagination.total = res.data.total || 0 - } catch (err) { + } catch (err: unknown) { appStore.showError(extractApiErrorMessage(err, t('common.error'))) } finally { loading.value = false