Commit c520de11 authored by qingyuzhang's avatar qingyuzhang
Browse files

Merge branch 'main' of github.com:Wei-Shaw/sub2api into qingyu/fix-smooth-sidebar-collapse

# Conflicts:
#	frontend/src/components/layout/AppSidebar.vue
parents 07d2add6 97f14b7a
......@@ -245,7 +245,12 @@ func (h *ChannelHandler) List(c *gin.Context) {
search = search[:100]
}
channels, pag, err := h.channelService.List(c.Request.Context(), pagination.PaginationParams{Page: page, PageSize: pageSize}, status, search)
channels, pag, err := h.channelService.List(c.Request.Context(), pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}, status, search)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -162,6 +162,8 @@ func (h *GroupHandler) List(c *gin.Context) {
search = search[:100]
}
isExclusiveStr := c.Query("is_exclusive")
sortBy := c.DefaultQuery("sort_by", "sort_order")
sortOrder := c.DefaultQuery("sort_order", "asc")
var isExclusive *bool
if isExclusiveStr != "" {
......@@ -169,7 +171,7 @@ func (h *GroupHandler) List(c *gin.Context) {
isExclusive = &val
}
groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive)
groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// PaymentHandler handles admin payment management.
type PaymentHandler struct {
paymentService *service.PaymentService
configService *service.PaymentConfigService
}
// NewPaymentHandler creates a new admin PaymentHandler.
func NewPaymentHandler(paymentService *service.PaymentService, configService *service.PaymentConfigService) *PaymentHandler {
return &PaymentHandler{
paymentService: paymentService,
configService: configService,
}
}
// --- Dashboard ---
// GetDashboard returns payment dashboard statistics.
// GET /api/v1/admin/payment/dashboard
func (h *PaymentHandler) GetDashboard(c *gin.Context) {
days := 30
if d := c.Query("days"); d != "" {
if v, err := strconv.Atoi(d); err == nil && v > 0 {
days = v
}
}
stats, err := h.paymentService.GetDashboardStats(c.Request.Context(), days)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, stats)
}
// --- Orders ---
// ListOrders returns a paginated list of all payment orders.
// GET /api/v1/admin/payment/orders
func (h *PaymentHandler) ListOrders(c *gin.Context) {
page, pageSize := response.ParsePagination(c)
var userID int64
if uid := c.Query("user_id"); uid != "" {
if v, err := strconv.ParseInt(uid, 10, 64); err == nil {
userID = v
}
}
orders, total, err := h.paymentService.AdminListOrders(c.Request.Context(), userID, service.OrderListParams{
Page: page,
PageSize: pageSize,
Status: c.Query("status"),
OrderType: c.Query("order_type"),
PaymentType: c.Query("payment_type"),
Keyword: c.Query("keyword"),
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, orders, int64(total), page, pageSize)
}
// GetOrderDetail returns detailed information about a single order.
// GET /api/v1/admin/payment/orders/:id
func (h *PaymentHandler) GetOrderDetail(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
order, err := h.paymentService.GetOrderByID(c.Request.Context(), orderID)
if err != nil {
response.ErrorFrom(c, err)
return
}
auditLogs, _ := h.paymentService.GetOrderAuditLogs(c.Request.Context(), orderID)
response.Success(c, gin.H{"order": order, "auditLogs": auditLogs})
}
// CancelOrder cancels a pending order (admin).
// POST /api/v1/admin/payment/orders/:id/cancel
func (h *PaymentHandler) CancelOrder(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
msg, err := h.paymentService.AdminCancelOrder(c.Request.Context(), orderID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": msg})
}
// RetryFulfillment retries fulfillment for a paid order.
// POST /api/v1/admin/payment/orders/:id/retry
func (h *PaymentHandler) RetryFulfillment(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.paymentService.RetryFulfillment(c.Request.Context(), orderID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "fulfillment retried"})
}
// AdminProcessRefundRequest is the request body for admin refund processing.
type AdminProcessRefundRequest struct {
Amount float64 `json:"amount"`
Reason string `json:"reason"`
Force bool `json:"force"`
DeductBalance bool `json:"deduct_balance"`
}
// ProcessRefund processes a refund for an order (admin).
// POST /api/v1/admin/payment/orders/:id/refund
func (h *PaymentHandler) ProcessRefund(c *gin.Context) {
orderID, ok := parseIDParam(c, "id")
if !ok {
return
}
var req AdminProcessRefundRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
plan, earlyResult, err := h.paymentService.PrepareRefund(c.Request.Context(), orderID, req.Amount, req.Reason, req.Force, req.DeductBalance)
if err != nil {
response.ErrorFrom(c, err)
return
}
if earlyResult != nil {
response.Success(c, earlyResult)
return
}
result, err := h.paymentService.ExecuteRefund(c.Request.Context(), plan)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// --- Subscription Plans ---
// ListPlans returns all subscription plans.
// GET /api/v1/admin/payment/plans
func (h *PaymentHandler) ListPlans(c *gin.Context) {
plans, err := h.configService.ListPlans(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plans)
}
// CreatePlan creates a new subscription plan.
// POST /api/v1/admin/payment/plans
func (h *PaymentHandler) CreatePlan(c *gin.Context) {
var req service.CreatePlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
plan, err := h.configService.CreatePlan(c.Request.Context(), req)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Created(c, plan)
}
// UpdatePlan updates an existing subscription plan.
// PUT /api/v1/admin/payment/plans/:id
func (h *PaymentHandler) UpdatePlan(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req service.UpdatePlanRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
plan, err := h.configService.UpdatePlan(c.Request.Context(), id, req)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plan)
}
// DeletePlan deletes a subscription plan.
// DELETE /api/v1/admin/payment/plans/:id
func (h *PaymentHandler) DeletePlan(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.configService.DeletePlan(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "deleted"})
}
// --- Provider Instances ---
// ListProviders returns all payment provider instances.
// GET /api/v1/admin/payment/providers
func (h *PaymentHandler) ListProviders(c *gin.Context) {
providers, err := h.configService.ListProviderInstancesWithConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, providers)
}
// CreateProvider creates a new payment provider instance.
// POST /api/v1/admin/payment/providers
func (h *PaymentHandler) CreateProvider(c *gin.Context) {
var req service.CreateProviderInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
inst, err := h.configService.CreateProviderInstance(c.Request.Context(), req)
if err != nil {
response.ErrorFrom(c, err)
return
}
h.paymentService.RefreshProviders(c.Request.Context())
response.Created(c, inst)
}
// UpdateProvider updates an existing payment provider instance.
// PUT /api/v1/admin/payment/providers/:id
func (h *PaymentHandler) UpdateProvider(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req service.UpdateProviderInstanceRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
inst, err := h.configService.UpdateProviderInstance(c.Request.Context(), id, req)
if err != nil {
response.ErrorFrom(c, err)
return
}
h.paymentService.RefreshProviders(c.Request.Context())
response.Success(c, inst)
}
// DeleteProvider deletes a payment provider instance.
// DELETE /api/v1/admin/payment/providers/:id
func (h *PaymentHandler) DeleteProvider(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.configService.DeleteProviderInstance(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
h.paymentService.RefreshProviders(c.Request.Context())
response.Success(c, gin.H{"message": "deleted"})
}
// parseIDParam parses an int64 path parameter.
// Returns the parsed ID and true on success; on failure it writes a BadRequest response and returns false.
func parseIDParam(c *gin.Context, paramName string) (int64, bool) {
id, err := strconv.ParseInt(c.Param(paramName), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid "+paramName)
return 0, false
}
return id, true
}
// --- Config ---
// GetConfig returns the payment configuration (admin view).
// GET /api/v1/admin/payment/config
func (h *PaymentHandler) GetConfig(c *gin.Context) {
cfg, err := h.configService.GetPaymentConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, cfg)
}
// UpdateConfig updates the payment configuration.
// PUT /api/v1/admin/payment/config
func (h *PaymentHandler) UpdateConfig(c *gin.Context) {
var req service.UpdatePaymentConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.configService.UpdatePaymentConfig(c.Request.Context(), req); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "updated"})
}
......@@ -57,6 +57,8 @@ func (h *PromoHandler) List(c *gin.Context) {
params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
codes, paginationResult, err := h.promoService.List(c.Request.Context(), params, status, search)
......
......@@ -33,11 +33,13 @@ func (h *ProxyHandler) ExportData(c *gin.Context) {
protocol := c.Query("protocol")
status := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
sortBy := c.DefaultQuery("sort_by", "id")
sortOrder := c.DefaultQuery("sort_order", "desc")
if len(search) > 100 {
search = search[:100]
}
proxies, err = h.listProxiesFiltered(ctx, protocol, status, search)
proxies, err = h.listProxiesFiltered(ctx, protocol, status, search, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......@@ -89,7 +91,7 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
ctx := c.Request.Context()
result := DataImportResult{}
existingProxies, err := h.listProxiesFiltered(ctx, "", "", "")
existingProxies, err := h.listProxiesFiltered(ctx, "", "", "", "id", "desc")
if err != nil {
response.ErrorFrom(c, err)
return
......@@ -220,12 +222,26 @@ func parseProxyIDs(c *gin.Context) ([]int64, error) {
return ids, nil
}
func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search string) ([]service.Proxy, error) {
func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status, search, sortBy, sortOrder string) ([]service.Proxy, error) {
page := 1
pageSize := dataPageCap
var out []service.Proxy
sortBy = strings.TrimSpace(sortBy)
useAccountCountSort := strings.EqualFold(sortBy, "account_count")
for {
items, total, err := h.adminService.ListProxies(ctx, page, pageSize, protocol, status, search)
if useAccountCountSort {
items, total, err := h.adminService.ListProxiesWithAccountCount(ctx, page, pageSize, protocol, status, search, sortBy, sortOrder)
if err != nil {
return nil, err
}
for i := range items {
out = append(out, items[i].Proxy)
}
if len(out) >= int(total) || len(items) == 0 {
break
}
} else {
items, total, err := h.adminService.ListProxies(ctx, page, pageSize, protocol, status, search, sortBy, sortOrder)
if err != nil {
return nil, err
}
......@@ -233,6 +249,7 @@ func (h *ProxyHandler) listProxiesFiltered(ctx context.Context, protocol, status
if len(out) >= int(total) || len(items) == 0 {
break
}
}
page++
}
return out, nil
......
......@@ -74,6 +74,10 @@ func TestProxyExportDataRespectsFilters(t *testing.T) {
require.Len(t, resp.Data.Proxies, 1)
require.Len(t, resp.Data.Accounts, 0)
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
require.Equal(t, 1, adminSvc.lastListProxies.calls)
require.Equal(t, "https", adminSvc.lastListProxies.protocol)
require.Equal(t, "id", adminSvc.lastListProxies.sortBy)
require.Equal(t, "desc", adminSvc.lastListProxies.sortOrder)
}
func TestProxyExportDataWithSelectedIDs(t *testing.T) {
......@@ -113,6 +117,96 @@ func TestProxyExportDataWithSelectedIDs(t *testing.T) {
require.Len(t, resp.Data.Proxies, 1)
require.Equal(t, "https", resp.Data.Proxies[0].Protocol)
require.Equal(t, "10.0.0.2", resp.Data.Proxies[0].Host)
require.Equal(t, 0, adminSvc.lastListProxies.calls)
}
func TestProxyExportDataPassesSortParams(t *testing.T) {
router, adminSvc := setupProxyDataRouter()
adminSvc.proxies = []service.Proxy{
{
ID: 1,
Name: "proxy-a",
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
Username: "user",
Password: "pass",
Status: service.StatusActive,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?protocol=http&status=active&search=proxy&sort_by=name&sort_order=asc", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, adminSvc.lastListProxies.calls)
require.Equal(t, "http", adminSvc.lastListProxies.protocol)
require.Equal(t, "active", adminSvc.lastListProxies.status)
require.Equal(t, "proxy", adminSvc.lastListProxies.search)
require.Equal(t, "name", adminSvc.lastListProxies.sortBy)
require.Equal(t, "asc", adminSvc.lastListProxies.sortOrder)
}
func TestProxyExportDataSortByAccountCountUsesAccountCountListing(t *testing.T) {
router, adminSvc := setupProxyDataRouter()
adminSvc.proxies = []service.Proxy{
{
ID: 1,
Name: "proxy-id-1",
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
Status: service.StatusActive,
},
{
ID: 2,
Name: "proxy-id-2",
Protocol: "http",
Host: "127.0.0.2",
Port: 8081,
Status: service.StatusActive,
},
}
adminSvc.proxyCounts = []service.ProxyWithAccountCount{
{
Proxy: service.Proxy{
ID: 2,
Name: "proxy-count-high",
Protocol: "http",
Host: "127.0.0.2",
Port: 8081,
Status: service.StatusActive,
},
AccountCount: 9,
},
{
Proxy: service.Proxy{
ID: 1,
Name: "proxy-count-low",
Protocol: "http",
Host: "127.0.0.1",
Port: 8080,
Status: service.StatusActive,
},
AccountCount: 1,
},
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/proxies/data?sort_by=account_count&sort_order=desc", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp proxyDataResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, 0, resp.Code)
require.Len(t, resp.Data.Proxies, 2)
require.Equal(t, "proxy-count-high", resp.Data.Proxies[0].Name)
require.Equal(t, "proxy-count-low", resp.Data.Proxies[1].Name)
require.Equal(t, 0, adminSvc.lastListProxies.calls)
}
func TestProxyImportDataReusesAndTriggersLatencyProbe(t *testing.T) {
......
......@@ -52,13 +52,15 @@ func (h *ProxyHandler) List(c *gin.Context) {
protocol := c.Query("protocol")
status := c.Query("status")
search := c.Query("search")
sortBy := c.DefaultQuery("sort_by", "id")
sortOrder := c.DefaultQuery("sort_order", "desc")
// 标准化和验证 search 参数
search = strings.TrimSpace(search)
if len(search) > 100 {
search = search[:100]
}
proxies, total, err := h.adminService.ListProxiesWithAccountCount(c.Request.Context(), page, pageSize, protocol, status, search)
proxies, total, err := h.adminService.ListProxiesWithAccountCount(c.Request.Context(), page, pageSize, protocol, status, search, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......
package admin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func setupRedeemExportRouter() (*gin.Engine, *stubAdminService) {
gin.SetMode(gin.TestMode)
router := gin.New()
adminSvc := newStubAdminService()
h := NewRedeemHandler(adminSvc, nil)
router.GET("/api/v1/admin/redeem-codes/export", h.Export)
return router, adminSvc
}
func TestRedeemExportPassesSearchAndSort(t *testing.T) {
router, adminSvc := setupRedeemExportRouter()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/redeem-codes/export?type=balance&status=unused&search=ABC&sort_by=value&sort_order=asc", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, adminSvc.lastListRedeemCodes.calls)
require.Equal(t, "balance", adminSvc.lastListRedeemCodes.codeType)
require.Equal(t, "unused", adminSvc.lastListRedeemCodes.status)
require.Equal(t, "ABC", adminSvc.lastListRedeemCodes.search)
require.Equal(t, "value", adminSvc.lastListRedeemCodes.sortBy)
require.Equal(t, "asc", adminSvc.lastListRedeemCodes.sortOrder)
}
func TestRedeemExportSortDefaults(t *testing.T) {
router, adminSvc := setupRedeemExportRouter()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/redeem-codes/export", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 1, adminSvc.lastListRedeemCodes.calls)
require.Equal(t, "id", adminSvc.lastListRedeemCodes.sortBy)
require.Equal(t, "desc", adminSvc.lastListRedeemCodes.sortOrder)
}
......@@ -59,13 +59,15 @@ func (h *RedeemHandler) List(c *gin.Context) {
codeType := c.Query("type")
status := c.Query("status")
search := c.Query("search")
sortBy := c.DefaultQuery("sort_by", "id")
sortOrder := c.DefaultQuery("sort_order", "desc")
// 标准化和验证 search 参数
search = strings.TrimSpace(search)
if len(search) > 100 {
search = search[:100]
}
codes, total, err := h.adminService.ListRedeemCodes(c.Request.Context(), page, pageSize, codeType, status, search)
codes, total, err := h.adminService.ListRedeemCodes(c.Request.Context(), page, pageSize, codeType, status, search, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......@@ -300,9 +302,15 @@ func (h *RedeemHandler) GetStats(c *gin.Context) {
func (h *RedeemHandler) Export(c *gin.Context) {
codeType := c.Query("type")
status := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
sortBy := c.DefaultQuery("sort_by", "id")
sortOrder := c.DefaultQuery("sort_order", "desc")
if len(search) > 100 {
search = search[:100]
}
// Get all codes without pagination (use large page size)
codes, _, err := h.adminService.ListRedeemCodes(c.Request.Context(), 1, 10000, codeType, status, "")
codes, _, err := h.adminService.ListRedeemCodes(c.Request.Context(), 1, 10000, codeType, status, search, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -50,15 +50,19 @@ type SettingHandler struct {
emailService *service.EmailService
turnstileService *service.TurnstileService
opsService *service.OpsService
paymentConfigService *service.PaymentConfigService
paymentService *service.PaymentService
}
// NewSettingHandler 创建系统设置处理器
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService) *SettingHandler {
func NewSettingHandler(settingService *service.SettingService, emailService *service.EmailService, turnstileService *service.TurnstileService, opsService *service.OpsService, paymentConfigService *service.PaymentConfigService, paymentService *service.PaymentService) *SettingHandler {
return &SettingHandler{
settingService: settingService,
emailService: emailService,
turnstileService: turnstileService,
opsService: opsService,
paymentConfigService: paymentConfigService,
paymentService: paymentService,
}
}
......@@ -81,6 +85,15 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
})
}
// Load payment config
var paymentCfg *service.PaymentConfig
if h.paymentConfigService != nil {
paymentCfg, _ = h.paymentConfigService.GetPaymentConfig(c.Request.Context())
}
if paymentCfg == nil {
paymentCfg = &service.PaymentConfig{}
}
response.Success(c, dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
......@@ -137,6 +150,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DefaultConcurrency: settings.DefaultConcurrency,
......@@ -160,6 +175,24 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
PaymentEnabled: paymentCfg.Enabled,
PaymentMinAmount: paymentCfg.MinAmount,
PaymentMaxAmount: paymentCfg.MaxAmount,
PaymentDailyLimit: paymentCfg.DailyLimit,
PaymentOrderTimeoutMin: paymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: paymentCfg.MaxPendingOrders,
PaymentEnabledTypes: paymentCfg.EnabledTypes,
PaymentBalanceDisabled: paymentCfg.BalanceDisabled,
PaymentLoadBalanceStrat: paymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: paymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: paymentCfg.ProductNameSuffix,
PaymentHelpImageURL: paymentCfg.HelpImageURL,
PaymentHelpText: paymentCfg.HelpText,
PaymentCancelRateLimitEnabled: paymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: paymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
})
}
......@@ -230,6 +263,8 @@ type UpdateSettingsRequest struct {
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
......@@ -268,6 +303,28 @@ type UpdateSettingsRequest struct {
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
EnableCCHSigning *bool `json:"enable_cch_signing"`
// Payment configuration (integrated into settings, full replace)
PaymentEnabled *bool `json:"payment_enabled"`
PaymentMinAmount *float64 `json:"payment_min_amount"`
PaymentMaxAmount *float64 `json:"payment_max_amount"`
PaymentDailyLimit *float64 `json:"payment_daily_limit"`
PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"`
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
PaymentHelpImageURL *string `json:"payment_help_image_url"`
PaymentHelpText *string `json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"`
PaymentCancelRateLimitMax *int `json:"payment_cancel_rate_limit_max"`
PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"`
}
// UpdateSettings 更新系统设置
......@@ -292,6 +349,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.DefaultBalance < 0 {
req.DefaultBalance = 0
}
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
if req.TableDefaultPageSize <= 0 {
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
}
if req.TablePageSizeOptions == nil {
req.TablePageSizeOptions = previousSettings.TablePageSizeOptions
}
req.SMTPHost = strings.TrimSpace(req.SMTPHost)
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
......@@ -757,6 +821,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
TableDefaultPageSize: req.TableDefaultPageSize,
TablePageSizeOptions: req.TablePageSizeOptions,
CustomMenuItems: customMenuJSON,
CustomEndpoints: customEndpointsJSON,
DefaultConcurrency: req.DefaultConcurrency,
......@@ -822,6 +888,39 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
// Update payment configuration (integrated into system settings).
// Skip if no payment fields were provided (prevents accidental wipe).
if h.paymentConfigService != nil && hasPaymentFields(req) {
paymentReq := service.UpdatePaymentConfigRequest{
Enabled: req.PaymentEnabled,
MinAmount: req.PaymentMinAmount,
MaxAmount: req.PaymentMaxAmount,
DailyLimit: req.PaymentDailyLimit,
OrderTimeoutMin: req.PaymentOrderTimeoutMin,
MaxPendingOrders: req.PaymentMaxPendingOrders,
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
ProductNameSuffix: req.PaymentProductNameSuffix,
HelpImageURL: req.PaymentHelpImageURL,
HelpText: req.PaymentHelpText,
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
}
if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil {
response.ErrorFrom(c, err)
return
}
// Refresh in-memory provider registry so config changes take effect immediately
if h.paymentService != nil {
h.paymentService.RefreshProviders(c.Request.Context())
}
}
h.auditSettingsUpdate(c, previousSettings, settings, req)
// 重新获取设置返回
......@@ -838,6 +937,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
})
}
// Reload payment config for response
var updatedPaymentCfg *service.PaymentConfig
if h.paymentConfigService != nil {
updatedPaymentCfg, _ = h.paymentConfigService.GetPaymentConfig(c.Request.Context())
}
if updatedPaymentCfg == nil {
updatedPaymentCfg = &service.PaymentConfig{}
}
response.Success(c, dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
......@@ -894,6 +1002,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
HideCcsImportButton: updatedSettings.HideCcsImportButton,
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
TableDefaultPageSize: updatedSettings.TableDefaultPageSize,
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
DefaultConcurrency: updatedSettings.DefaultConcurrency,
......@@ -917,9 +1027,40 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
EnableCCHSigning: updatedSettings.EnableCCHSigning,
PaymentEnabled: updatedPaymentCfg.Enabled,
PaymentMinAmount: updatedPaymentCfg.MinAmount,
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
PaymentDailyLimit: updatedPaymentCfg.DailyLimit,
PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin,
PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders,
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL,
PaymentHelpText: updatedPaymentCfg.HelpText,
PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled,
PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax,
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
})
}
// hasPaymentFields returns true if any payment-related field was explicitly provided.
func hasPaymentFields(req UpdateSettingsRequest) bool {
return req.PaymentEnabled != nil || req.PaymentMinAmount != nil ||
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil ||
req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil
}
func (h *SettingHandler) auditSettingsUpdate(c *gin.Context, before *service.SystemSettings, after *service.SystemSettings, req UpdateSettingsRequest) {
if before == nil || after == nil {
return
......@@ -1152,6 +1293,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.PurchaseSubscriptionURL != after.PurchaseSubscriptionURL {
changed = append(changed, "purchase_subscription_url")
}
if before.TableDefaultPageSize != after.TableDefaultPageSize {
changed = append(changed, "table_default_page_size")
}
if !equalIntSlice(before.TablePageSizeOptions, after.TablePageSizeOptions) {
changed = append(changed, "table_page_size_options")
}
if before.CustomMenuItems != after.CustomMenuItems {
changed = append(changed, "custom_menu_items")
}
......@@ -1208,6 +1355,18 @@ func equalDefaultSubscriptions(a, b []service.DefaultSubscriptionSetting) bool {
return true
}
func equalIntSlice(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestSMTPRequest 测试SMTP连接请求
type TestSMTPRequest struct {
SMTPHost string `json:"smtp_host"`
......
......@@ -165,7 +165,12 @@ func (h *UsageHandler) List(c *gin.Context) {
endTime = &t
}
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
......@@ -339,7 +344,7 @@ func (h *UsageHandler) SearchUsers(c *gin.Context) {
}
// Limit to 30 results
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword})
users, _, err := h.adminService.ListUsers(c.Request.Context(), 1, 30, service.UserListFilters{Search: keyword}, "email", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -15,11 +15,13 @@ import (
type adminUsageRepoCapture struct {
service.UsageLogRepository
listParams pagination.PaginationParams
listFilters usagestats.UsageLogFilters
statsFilters usagestats.UsageLogFilters
}
func (s *adminUsageRepoCapture) ListWithFilters(ctx context.Context, params pagination.PaginationParams, filters usagestats.UsageLogFilters) ([]service.UsageLog, *pagination.PaginationResult, error) {
s.listParams = params
s.listFilters = filters
return []service.UsageLog{}, &pagination.PaginationResult{
Total: 0,
......
package admin
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdminUsageListSortParams(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/admin/usage?sort_by=model&sort_order=ASC", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "model", repo.listParams.SortBy)
require.Equal(t, "ASC", repo.listParams.SortOrder)
}
func TestAdminUsageListSortDefaults(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/admin/usage", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "created_at", repo.listParams.SortBy)
require.Equal(t, "desc", repo.listParams.SortOrder)
}
......@@ -91,12 +91,14 @@ func (h *UserHandler) List(c *gin.Context) {
GroupName: strings.TrimSpace(c.Query("group_name")),
Attributes: parseAttributeFilters(c),
}
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
if raw, ok := c.GetQuery("include_subscriptions"); ok {
includeSubscriptions := parseBoolQueryWithDefault(raw, true)
filters.IncludeSubscriptions = &includeSubscriptions
}
users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters)
users, total, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, filters, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......@@ -290,8 +292,10 @@ func (h *UserHandler) GetUserAPIKeys(c *gin.Context) {
}
page, pageSize := response.ParsePagination(c)
sortBy := c.DefaultQuery("sort_by", "created_at")
sortOrder := c.DefaultQuery("sort_order", "desc")
keys, total, err := h.adminService.GetUserAPIKeys(c.Request.Context(), userID, page, pageSize)
keys, total, err := h.adminService.GetUserAPIKeys(c.Request.Context(), userID, page, pageSize, sortBy, sortOrder)
if err != nil {
response.ErrorFrom(c, err)
return
......
......@@ -72,7 +72,12 @@ func (h *APIKeyHandler) List(c *gin.Context) {
}
page, pageSize := response.ParsePagination(c)
params := pagination.PaginationParams{Page: page, PageSize: pageSize}
params := pagination.PaginationParams{
Page: page,
PageSize: pageSize,
SortBy: c.DefaultQuery("sort_by", "created_at"),
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
// Parse filter parameters
var filters service.APIKeyListFilters
......
......@@ -84,6 +84,8 @@ type SystemSettings struct {
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
......@@ -121,6 +123,28 @@ type SystemSettings struct {
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
EnableCCHSigning bool `json:"enable_cch_signing"`
// Payment configuration
PaymentEnabled bool `json:"payment_enabled"`
PaymentMinAmount float64 `json:"payment_min_amount"`
PaymentMaxAmount float64 `json:"payment_max_amount"`
PaymentDailyLimit float64 `json:"payment_daily_limit"`
PaymentOrderTimeoutMin int `json:"payment_order_timeout_minutes"`
PaymentMaxPendingOrders int `json:"payment_max_pending_orders"`
PaymentEnabledTypes []string `json:"payment_enabled_types"`
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
PaymentLoadBalanceStrat string `json:"payment_load_balance_strategy"`
PaymentProductNamePrefix string `json:"payment_product_name_prefix"`
PaymentProductNameSuffix string `json:"payment_product_name_suffix"`
PaymentHelpImageURL string `json:"payment_help_image_url"`
PaymentHelpText string `json:"payment_help_text"`
// Cancel rate limit
PaymentCancelRateLimitEnabled bool `json:"payment_cancel_rate_limit_enabled"`
PaymentCancelRateLimitMax int `json:"payment_cancel_rate_limit_max"`
PaymentCancelRateLimitWindow int `json:"payment_cancel_rate_limit_window"`
PaymentCancelRateLimitUnit string `json:"payment_cancel_rate_limit_unit"`
PaymentCancelRateLimitMode string `json:"payment_cancel_rate_limit_window_mode"`
}
type DefaultSubscriptionSetting struct {
......@@ -148,6 +172,8 @@ type PublicSettings struct {
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
......@@ -155,6 +181,7 @@ type PublicSettings struct {
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
}
......
......@@ -31,6 +31,7 @@ type AdminHandlers struct {
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Channel *admin.ChannelHandler
Payment *admin.PaymentHandler
}
// Handlers contains all HTTP handlers
......@@ -47,6 +48,8 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler
Totp *TotpHandler
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
}
// BuildInfo contains build-time information
......
package handler
import (
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// PaymentHandler handles user-facing payment requests.
type PaymentHandler struct {
channelService *service.ChannelService
paymentService *service.PaymentService
configService *service.PaymentConfigService
}
// NewPaymentHandler creates a new PaymentHandler.
func NewPaymentHandler(paymentService *service.PaymentService, configService *service.PaymentConfigService, channelService *service.ChannelService) *PaymentHandler {
return &PaymentHandler{
channelService: channelService,
paymentService: paymentService,
configService: configService,
}
}
// GetPaymentConfig returns the payment system configuration.
// GET /api/v1/payment/config
func (h *PaymentHandler) GetPaymentConfig(c *gin.Context) {
cfg, err := h.configService.GetPaymentConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, cfg)
}
// GetPlans returns subscription plans available for sale.
// GET /api/v1/payment/plans
func (h *PaymentHandler) GetPlans(c *gin.Context) {
plans, err := h.configService.ListPlansForSale(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// Enrich plans with group platform for frontend color coding
type planWithPlatform struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features string `json:"features"`
ProductName string `json:"product_name"`
ForSale bool `json:"for_sale"`
SortOrder int `json:"sort_order"`
}
platformMap := h.configService.GetGroupPlatformMap(c.Request.Context(), plans)
result := make([]planWithPlatform, 0, len(plans))
for _, p := range plans {
result = append(result, planWithPlatform{
ID: int64(p.ID), GroupID: p.GroupID, GroupPlatform: platformMap[p.GroupID],
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: p.Features,
ProductName: p.ProductName, ForSale: p.ForSale, SortOrder: p.SortOrder,
})
}
response.Success(c, result)
}
// GetChannels returns enabled payment channels.
// GET /api/v1/payment/channels
func (h *PaymentHandler) GetChannels(c *gin.Context) {
channels, _, err := h.channelService.List(c.Request.Context(), pagination.PaginationParams{Page: 1, PageSize: 1000}, "active", "")
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, channels)
}
// GetCheckoutInfo returns all data the payment page needs in a single call:
// payment methods with limits, subscription plans, and configuration.
// GET /api/v1/payment/checkout-info
func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
ctx := c.Request.Context()
// Fetch limits (methods + global range)
limitsResp, err := h.configService.GetAvailableMethodLimits(ctx)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Fetch payment config
cfg, err := h.configService.GetPaymentConfig(ctx)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Fetch plans with group info
plans, _ := h.configService.ListPlansForSale(ctx)
groupInfo := h.configService.GetGroupInfoMap(ctx, plans)
planList := make([]checkoutPlan, 0, len(plans))
for _, p := range plans {
gi := groupInfo[p.GroupID]
planList = append(planList, checkoutPlan{
ID: int64(p.ID), GroupID: p.GroupID,
GroupPlatform: gi.Platform, GroupName: gi.Name,
RateMultiplier: gi.RateMultiplier, DailyLimitUSD: gi.DailyLimitUSD,
WeeklyLimitUSD: gi.WeeklyLimitUSD, MonthlyLimitUSD: gi.MonthlyLimitUSD,
ModelScopes: gi.ModelScopes,
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
ValidityDays: p.ValidityDays, ValidityUnit: p.ValidityUnit, Features: parseFeatures(p.Features),
ProductName: p.ProductName,
})
}
response.Success(c, checkoutInfoResponse{
Methods: limitsResp.Methods,
GlobalMin: limitsResp.GlobalMin,
GlobalMax: limitsResp.GlobalMax,
Plans: planList,
BalanceDisabled: cfg.BalanceDisabled,
HelpText: cfg.HelpText,
HelpImageURL: cfg.HelpImageURL,
StripePublishableKey: cfg.StripePublishableKey,
})
}
type checkoutInfoResponse struct {
Methods map[string]service.MethodLimits `json:"methods"`
GlobalMin float64 `json:"global_min"`
GlobalMax float64 `json:"global_max"`
Plans []checkoutPlan `json:"plans"`
BalanceDisabled bool `json:"balance_disabled"`
HelpText string `json:"help_text"`
HelpImageURL string `json:"help_image_url"`
StripePublishableKey string `json:"stripe_publishable_key"`
}
type checkoutPlan struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform"`
GroupName string `json:"group_name"`
RateMultiplier float64 `json:"rate_multiplier"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
ModelScopes []string `json:"supported_model_scopes"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features []string `json:"features"`
ProductName string `json:"product_name"`
}
// parseFeatures splits a newline-separated features string into a string slice.
func parseFeatures(raw string) []string {
if raw == "" {
return []string{}
}
var out []string
for _, line := range strings.Split(raw, "\n") {
if s := strings.TrimSpace(line); s != "" {
out = append(out, s)
}
}
if out == nil {
return []string{}
}
return out
}
// GetLimits returns per-payment-type limits derived from enabled provider instances.
// GET /api/v1/payment/limits
func (h *PaymentHandler) GetLimits(c *gin.Context) {
resp, err := h.configService.GetAvailableMethodLimits(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, resp)
}
// CreateOrderRequest is the request body for creating a payment order.
type CreateOrderRequest struct {
Amount float64 `json:"amount"`
PaymentType string `json:"payment_type" binding:"required"`
OrderType string `json:"order_type"`
PlanID int64 `json:"plan_id"`
}
// CreateOrder creates a new payment order.
// POST /api/v1/payment/orders
func (h *PaymentHandler) CreateOrder(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
var req CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
result, err := h.paymentService.CreateOrder(c.Request.Context(), service.CreateOrderRequest{
UserID: subject.UserID,
Amount: req.Amount,
PaymentType: req.PaymentType,
ClientIP: c.ClientIP(),
IsMobile: isMobile(c),
SrcHost: c.Request.Host,
SrcURL: c.Request.Referer(),
OrderType: req.OrderType,
PlanID: req.PlanID,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// GetMyOrders returns the authenticated user's orders.
// GET /api/v1/payment/orders/my
func (h *PaymentHandler) GetMyOrders(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
page, pageSize := response.ParsePagination(c)
orders, total, err := h.paymentService.GetUserOrders(c.Request.Context(), subject.UserID, service.OrderListParams{
Page: page,
PageSize: pageSize,
Status: c.Query("status"),
OrderType: c.Query("order_type"),
PaymentType: c.Query("payment_type"),
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Paginated(c, orders, int64(total), page, pageSize)
}
// GetOrder returns a single order for the authenticated user.
// GET /api/v1/payment/orders/:id
func (h *PaymentHandler) GetOrder(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid order ID")
return
}
order, err := h.paymentService.GetOrder(c.Request.Context(), orderID, subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, order)
}
// CancelOrder cancels a pending order for the authenticated user.
// POST /api/v1/payment/orders/:id/cancel
func (h *PaymentHandler) CancelOrder(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid order ID")
return
}
msg, err := h.paymentService.CancelOrder(c.Request.Context(), orderID, subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": msg})
}
// RefundRequestBody is the request body for requesting a refund.
type RefundRequestBody struct {
Reason string `json:"reason"`
}
// RequestRefund submits a refund request for a completed order.
// POST /api/v1/payment/orders/:id/refund-request
func (h *PaymentHandler) RequestRefund(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
orderID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid order ID")
return
}
var req RefundRequestBody
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.paymentService.RequestRefund(c.Request.Context(), orderID, subject.UserID, req.Reason); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "refund requested"})
}
// VerifyOrderRequest is the request body for verifying a payment order.
type VerifyOrderRequest struct {
OutTradeNo string `json:"out_trade_no" binding:"required"`
}
// VerifyOrder actively queries the upstream payment provider to check
// if payment was made, and processes it if so.
// POST /api/v1/payment/orders/verify
func (h *PaymentHandler) VerifyOrder(c *gin.Context) {
subject, ok := requireAuth(c)
if !ok {
return
}
var req VerifyOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
order, err := h.paymentService.VerifyOrderByOutTradeNo(c.Request.Context(), req.OutTradeNo, subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, order)
}
// PublicOrderResult is the limited order info returned by the public verify endpoint.
// No user details are exposed — only payment status information.
type PublicOrderResult struct {
ID int64 `json:"id"`
OutTradeNo string `json:"out_trade_no"`
Amount float64 `json:"amount"`
PayAmount float64 `json:"pay_amount"`
PaymentType string `json:"payment_type"`
Status string `json:"status"`
}
// VerifyOrderPublic verifies payment status without requiring authentication.
// Returns limited order info (no user details) to prevent information leakage.
// POST /api/v1/payment/public/orders/verify
func (h *PaymentHandler) VerifyOrderPublic(c *gin.Context) {
var req VerifyOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
order, err := h.paymentService.VerifyOrderPublic(c.Request.Context(), req.OutTradeNo)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, PublicOrderResult{
ID: order.ID,
OutTradeNo: order.OutTradeNo,
Amount: order.Amount,
PayAmount: order.PayAmount,
PaymentType: order.PaymentType,
Status: order.Status,
})
}
// requireAuth extracts the authenticated subject from the context.
// Returns the subject and true on success; on failure it writes an Unauthorized response and returns false.
func requireAuth(c *gin.Context) (middleware2.AuthSubject, bool) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return middleware2.AuthSubject{}, false
}
return subject, true
}
// isMobile detects mobile user agents.
func isMobile(c *gin.Context) bool {
ua := strings.ToLower(c.GetHeader("User-Agent"))
for _, kw := range []string{"mobile", "android", "iphone", "ipad", "ipod"} {
if strings.Contains(ua, kw) {
return true
}
}
return false
}
package handler
import (
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// PaymentWebhookHandler handles payment provider webhook callbacks.
type PaymentWebhookHandler struct {
paymentService *service.PaymentService
registry *payment.Registry
}
// maxWebhookBodySize is the maximum allowed webhook request body size (1 MB).
const maxWebhookBodySize = 1 << 20
// webhookLogTruncateLen is the maximum length of raw body logged on verify failure.
const webhookLogTruncateLen = 200
// NewPaymentWebhookHandler creates a new PaymentWebhookHandler.
func NewPaymentWebhookHandler(paymentService *service.PaymentService, registry *payment.Registry) *PaymentWebhookHandler {
return &PaymentWebhookHandler{
paymentService: paymentService,
registry: registry,
}
}
// EasyPayNotify handles EasyPay payment notifications.
// POST /api/v1/payment/webhook/easypay
func (h *PaymentWebhookHandler) EasyPayNotify(c *gin.Context) {
h.handleNotify(c, payment.TypeEasyPay)
}
// AlipayNotify handles Alipay payment notifications.
// POST /api/v1/payment/webhook/alipay
func (h *PaymentWebhookHandler) AlipayNotify(c *gin.Context) {
h.handleNotify(c, payment.TypeAlipay)
}
// WxpayNotify handles WeChat Pay payment notifications.
// POST /api/v1/payment/webhook/wxpay
func (h *PaymentWebhookHandler) WxpayNotify(c *gin.Context) {
h.handleNotify(c, payment.TypeWxpay)
}
// StripeWebhook handles Stripe webhook events.
// POST /api/v1/payment/webhook/stripe
func (h *PaymentWebhookHandler) StripeWebhook(c *gin.Context) {
h.handleNotify(c, payment.TypeStripe)
}
// handleNotify is the shared logic for all provider webhook handlers.
func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string) {
var rawBody string
if c.Request.Method == http.MethodGet {
// GET callbacks (e.g. EasyPay) pass params as URL query string
rawBody = c.Request.URL.RawQuery
} else {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBodySize))
if err != nil {
slog.Error("[Payment Webhook] failed to read body", "provider", providerKey, "error", err)
c.String(http.StatusBadRequest, "failed to read body")
return
}
rawBody = string(body)
}
// Extract out_trade_no to look up the order's specific provider instance.
// This is needed when multiple instances of the same provider exist (e.g. multiple EasyPay accounts).
outTradeNo := extractOutTradeNo(rawBody, providerKey)
provider, err := h.paymentService.GetWebhookProvider(c.Request.Context(), providerKey, outTradeNo)
if err != nil {
slog.Warn("[Payment Webhook] provider not found", "provider", providerKey, "outTradeNo", outTradeNo, "error", err)
writeSuccessResponse(c, providerKey)
return
}
headers := make(map[string]string)
for k := range c.Request.Header {
headers[strings.ToLower(k)] = c.GetHeader(k)
}
notification, err := provider.VerifyNotification(c.Request.Context(), rawBody, headers)
if err != nil {
truncatedBody := rawBody
if len(truncatedBody) > webhookLogTruncateLen {
truncatedBody = truncatedBody[:webhookLogTruncateLen] + "...(truncated)"
}
slog.Error("[Payment Webhook] verify failed", "provider", providerKey, "error", err, "method", c.Request.Method, "bodyLen", len(rawBody))
slog.Debug("[Payment Webhook] verify failed body", "provider", providerKey, "rawBody", truncatedBody)
c.String(http.StatusBadRequest, "verify failed")
return
}
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
if notification == nil {
writeSuccessResponse(c, providerKey)
return
}
if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, providerKey); err != nil {
slog.Error("[Payment Webhook] handle notification failed", "provider", providerKey, "error", err)
c.String(http.StatusInternalServerError, "handle failed")
return
}
writeSuccessResponse(c, providerKey)
}
// extractOutTradeNo parses the webhook body to find the out_trade_no.
// This allows looking up the correct provider instance before verification.
func extractOutTradeNo(rawBody, providerKey string) string {
switch providerKey {
case payment.TypeEasyPay:
values, err := url.ParseQuery(rawBody)
if err == nil {
return values.Get("out_trade_no")
}
}
// For other providers (Stripe, Alipay direct, WxPay direct), the registry
// typically has only one instance, so no instance lookup is needed.
return ""
}
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
type wxpaySuccessResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
// WeChat Pay webhook success response constants.
const (
wxpaySuccessCode = "SUCCESS"
wxpaySuccessMessage = "成功"
)
// writeSuccessResponse sends the provider-specific success response.
// WeChat Pay requires JSON {"code":"SUCCESS","message":"成功"};
// Stripe expects an empty 200; others accept plain text "success".
func writeSuccessResponse(c *gin.Context, providerKey string) {
switch providerKey {
case payment.TypeWxpay:
c.JSON(http.StatusOK, wxpaySuccessResponse{Code: wxpaySuccessCode, Message: wxpaySuccessMessage})
case payment.TypeStripe:
c.String(http.StatusOK, "")
default:
c.String(http.StatusOK, "success")
}
}
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteSuccessResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
providerKey string
wantCode int
wantContentType string
wantBody string
checkJSON bool
wantJSONCode string
wantJSONMessage string
}{
{
name: "wxpay returns JSON with code SUCCESS",
providerKey: "wxpay",
wantCode: http.StatusOK,
wantContentType: "application/json",
checkJSON: true,
wantJSONCode: "SUCCESS",
wantJSONMessage: "成功",
},
{
name: "stripe returns empty 200",
providerKey: "stripe",
wantCode: http.StatusOK,
wantContentType: "text/plain",
wantBody: "",
},
{
name: "easypay returns plain text success",
providerKey: "easypay",
wantCode: http.StatusOK,
wantContentType: "text/plain",
wantBody: "success",
},
{
name: "alipay returns plain text success",
providerKey: "alipay",
wantCode: http.StatusOK,
wantContentType: "text/plain",
wantBody: "success",
},
{
name: "unknown provider returns plain text success",
providerKey: "unknown_provider",
wantCode: http.StatusOK,
wantContentType: "text/plain",
wantBody: "success",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
writeSuccessResponse(c, tt.providerKey)
assert.Equal(t, tt.wantCode, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), tt.wantContentType)
if tt.checkJSON {
var resp wxpaySuccessResponse
err := json.Unmarshal(w.Body.Bytes(), &resp)
require.NoError(t, err, "response body should be valid JSON")
assert.Equal(t, tt.wantJSONCode, resp.Code)
assert.Equal(t, tt.wantJSONMessage, resp.Message)
} else {
assert.Equal(t, tt.wantBody, w.Body.String())
}
})
}
}
func TestWebhookConstants(t *testing.T) {
t.Run("maxWebhookBodySize is 1MB", func(t *testing.T) {
assert.Equal(t, int64(1<<20), int64(maxWebhookBodySize))
})
t.Run("webhookLogTruncateLen is 200", func(t *testing.T) {
assert.Equal(t, 200, webhookLogTruncateLen)
})
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment