From 39ca192c41ea8e040aee99b015b7761bec6964d1 Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 1 Mar 2026 00:41:38 +0800 Subject: [PATCH] feat(admin): add create-and-redeem API and payment integration docs --- ADMIN_PAYMENT_INTEGRATION_API.md | 134 ++++++++++++++++++ README.md | 1 + backend/cmd/server/wire_gen.go | 2 +- .../admin/admin_basic_handlers_test.go | 2 +- .../internal/handler/admin/redeem_handler.go | 94 +++++++++++- backend/internal/server/routes/admin.go | 1 + backend/internal/service/redeem_service.go | 27 ++++ 7 files changed, 256 insertions(+), 5 deletions(-) create mode 100644 ADMIN_PAYMENT_INTEGRATION_API.md diff --git a/ADMIN_PAYMENT_INTEGRATION_API.md b/ADMIN_PAYMENT_INTEGRATION_API.md new file mode 100644 index 00000000..fa2ff2bc --- /dev/null +++ b/ADMIN_PAYMENT_INTEGRATION_API.md @@ -0,0 +1,134 @@ +# Sub2API Admin API: Payment Integration + +This document describes the minimum Admin API surface for external payment systems (for example, sub2apipay) to complete balance top-up and reconciliation. + +## Base URL + +- Production: `https://` +- Beta: `http://:8084` + +All endpoints below use: + +- Header: `x-api-key: admin-<64hex>` (recommended for server-to-server) +- Header: `Content-Type: application/json` + +Note: Admin JWT is also accepted by admin routes, but machine-to-machine integration should use admin API key. + +## 1) Create + Redeem in One Step + +`POST /api/v1/admin/redeem-codes/create-and-redeem` + +Purpose: + +- Atomically create a deterministic redeem code and redeem it to a target user. +- Typical usage: called after payment callback succeeds. + +Required headers: + +- `x-api-key` +- `Idempotency-Key` + +Request body: + +```json +{ + "code": "s2p_cm1234567890", + "type": "balance", + "value": 100.0, + "user_id": 123, + "notes": "sub2apipay order: cm1234567890" +} +``` + +Rules: + +- `code`: external deterministic order-mapped code. +- `type`: currently recommended `balance`. +- `value`: must be `> 0`. +- `user_id`: target user id. + +Idempotency semantics: + +- Same `code`, same `used_by` user: return `200` (idempotent replay). +- Same `code`, different `used_by` user: return `409` conflict. +- Missing `Idempotency-Key`: return `400` (`IDEMPOTENCY_KEY_REQUIRED`). + +Example: + +```bash +curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: pay-cm1234567890-success" \ + -H "Content-Type: application/json" \ + -d '{ + "code":"s2p_cm1234567890", + "type":"balance", + "value":100.00, + "user_id":123, + "notes":"sub2apipay order: cm1234567890" + }' +``` + +## 2) Query User (Optional Pre-check) + +`GET /api/v1/admin/users/:id` + +Purpose: + +- Check whether target user exists before payment finalize/retry. + +Example: + +```bash +curl -s "${BASE}/api/v1/admin/users/123" \ + -H "x-api-key: ${KEY}" +``` + +## 3) Balance Adjustment (Existing Interface) + +`POST /api/v1/admin/users/:id/balance` + +Purpose: + +- Existing reusable admin interface for manual correction. +- Supports `set`, `add`, `subtract`. + +Request body example (`subtract`): + +```json +{ + "balance": 100.0, + "operation": "subtract", + "notes": "manual correction" +} +``` + +Example: + +```bash +curl -X POST "${BASE}/api/v1/admin/users/123/balance" \ + -H "x-api-key: ${KEY}" \ + -H "Idempotency-Key: balance-subtract-cm1234567890" \ + -H "Content-Type: application/json" \ + -d '{ + "balance":100.00, + "operation":"subtract", + "notes":"manual correction" + }' +``` + +## 4) Error Handling Recommendations + +- Persist upstream payment result independently from recharge result. +- Mark payment success immediately after callback verification. +- If recharge fails after payment success, keep order retryable by admin operation. +- For retry, always reuse deterministic `code` + new `Idempotency-Key`. + +## 5) Suggested `doc_url` Setting + +Sub2API already supports `doc_url` in system settings. + +Recommended values: + +- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md` +- Direct download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md` diff --git a/README.md b/README.md index a5f680bf..6381b301 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot ## Documentation - Dependency Security: `docs/dependency-security.md` +- Admin Payment Integration API: `ADMIN_PAYMENT_INTEGRATION_API.md` --- diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 37ad5d9f..b8337733 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) proxyHandler := admin.NewProxyHandler(adminService) - adminRedeemHandler := admin.NewRedeemHandler(adminService) + adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService) promoHandler := admin.NewPromoHandler(promoService) opsRepository := repository.NewOpsRepository(db) pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig) diff --git a/backend/internal/handler/admin/admin_basic_handlers_test.go b/backend/internal/handler/admin/admin_basic_handlers_test.go index aeb4097f..4de10d3e 100644 --- a/backend/internal/handler/admin/admin_basic_handlers_test.go +++ b/backend/internal/handler/admin/admin_basic_handlers_test.go @@ -19,7 +19,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) { userHandler := NewUserHandler(adminSvc, nil) groupHandler := NewGroupHandler(adminSvc) proxyHandler := NewProxyHandler(adminSvc) - redeemHandler := NewRedeemHandler(adminSvc) + redeemHandler := NewRedeemHandler(adminSvc, nil) router.GET("/api/v1/admin/users", userHandler.List) router.GET("/api/v1/admin/users/:id", userHandler.GetByID) diff --git a/backend/internal/handler/admin/redeem_handler.go b/backend/internal/handler/admin/redeem_handler.go index 7073061d..0a932ee9 100644 --- a/backend/internal/handler/admin/redeem_handler.go +++ b/backend/internal/handler/admin/redeem_handler.go @@ -4,11 +4,13 @@ import ( "bytes" "context" "encoding/csv" + "errors" "fmt" "strconv" "strings" "github.com/Wei-Shaw/sub2api/internal/handler/dto" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" @@ -17,13 +19,15 @@ import ( // RedeemHandler handles admin redeem code management type RedeemHandler struct { - adminService service.AdminService + adminService service.AdminService + redeemService *service.RedeemService } // NewRedeemHandler creates a new admin redeem handler -func NewRedeemHandler(adminService service.AdminService) *RedeemHandler { +func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler { return &RedeemHandler{ - adminService: adminService, + adminService: adminService, + redeemService: redeemService, } } @@ -36,6 +40,15 @@ type GenerateRedeemCodesRequest struct { ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年 } +// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user. +type CreateAndRedeemCodeRequest struct { + Code string `json:"code" binding:"required,min=3,max=128"` + Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"` + Value float64 `json:"value" binding:"required,gt=0"` + UserID int64 `json:"user_id" binding:"required,gt=0"` + Notes string `json:"notes"` +} + // List handles listing all redeem codes with pagination // GET /api/v1/admin/redeem-codes func (h *RedeemHandler) List(c *gin.Context) { @@ -109,6 +122,81 @@ func (h *RedeemHandler) Generate(c *gin.Context) { }) } +// CreateAndRedeem creates a fixed redeem code and redeems it for a target user in one step. +// POST /api/v1/admin/redeem-codes/create-and-redeem +func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) { + if h.redeemService == nil { + response.InternalError(c, "redeem service not configured") + return + } + + var req CreateAndRedeemCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + req.Code = strings.TrimSpace(req.Code) + + executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) { + existing, err := h.redeemService.GetByCode(ctx, req.Code) + if err == nil { + return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID) + } + if !errors.Is(err, service.ErrRedeemCodeNotFound) { + return nil, err + } + + createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{ + Code: req.Code, + Type: req.Type, + Value: req.Value, + Status: service.StatusUnused, + Notes: req.Notes, + }) + if createErr != nil { + // Unique code race: if code now exists, use idempotent semantics by used_by. + existingAfterCreateErr, getErr := h.redeemService.GetByCode(ctx, req.Code) + if getErr == nil { + return h.resolveCreateAndRedeemExisting(ctx, existingAfterCreateErr, req.UserID) + } + return nil, createErr + } + + redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code) + if redeemErr != nil { + return nil, redeemErr + } + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil + }) +} + +func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, existing *service.RedeemCode, userID int64) (any, error) { + if existing == nil { + return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code conflict") + } + + // If previous run created the code but crashed before redeem, redeem it now. + if existing.CanUse() { + redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code) + if err == nil { + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil + } + if !errors.Is(err, service.ErrRedeemCodeUsed) { + return nil, err + } + latest, getErr := h.redeemService.GetByCode(ctx, existing.Code) + if getErr == nil { + existing = latest + } + } + + if existing.UsedBy != nil && *existing.UsedBy == userID { + return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(existing)}, nil + } + + return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code already used by another user") +} + // Delete handles deleting a redeem code // DELETE /api/v1/admin/redeem-codes/:id func (h *RedeemHandler) Delete(c *gin.Context) { diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 4d0a33c2..ade4d462 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -351,6 +351,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) { codes.GET("/stats", h.Admin.Redeem.GetStats) codes.GET("/export", h.Admin.Redeem.Export) codes.GET("/:id", h.Admin.Redeem.GetByID) + codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem) codes.POST("/generate", h.Admin.Redeem.Generate) codes.DELETE("/:id", h.Admin.Redeem.Delete) codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete) diff --git a/backend/internal/service/redeem_service.go b/backend/internal/service/redeem_service.go index ad277ca0..b22da752 100644 --- a/backend/internal/service/redeem_service.go +++ b/backend/internal/service/redeem_service.go @@ -174,6 +174,33 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ return codes, nil } +// CreateCode creates a redeem code with caller-provided code value. +// It is primarily used by admin integrations that require an external order ID +// to be mapped to a deterministic redeem code. +func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error { + if code == nil { + return errors.New("redeem code is required") + } + code.Code = strings.TrimSpace(code.Code) + if code.Code == "" { + return errors.New("code is required") + } + if code.Type == "" { + code.Type = RedeemTypeBalance + } + if code.Type != RedeemTypeInvitation && code.Value <= 0 { + return errors.New("value must be greater than 0") + } + if code.Status == "" { + code.Status = StatusUnused + } + + if err := s.redeemRepo.Create(ctx, code); err != nil { + return fmt.Errorf("create redeem code: %w", err) + } + return nil +} + // checkRedeemRateLimit 检查用户兑换错误次数是否超限 func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error { if s.cache == nil {