feat(admin): add create-and-redeem API and payment integration docs
This commit is contained in:
134
ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
134
ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
@@ -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://<your-domain>`
|
||||||
|
- Beta: `http://<your-server-ip>: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`
|
||||||
@@ -54,6 +54,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- Dependency Security: `docs/dependency-security.md`
|
- Dependency Security: `docs/dependency-security.md`
|
||||||
|
- Admin Payment Integration API: `ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||||
proxyHandler := admin.NewProxyHandler(adminService)
|
proxyHandler := admin.NewProxyHandler(adminService)
|
||||||
adminRedeemHandler := admin.NewRedeemHandler(adminService)
|
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
|
||||||
promoHandler := admin.NewPromoHandler(promoService)
|
promoHandler := admin.NewPromoHandler(promoService)
|
||||||
opsRepository := repository.NewOpsRepository(db)
|
opsRepository := repository.NewOpsRepository(db)
|
||||||
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
|
pricingRemoteClient := repository.ProvidePricingRemoteClient(configConfig)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
|||||||
userHandler := NewUserHandler(adminSvc, nil)
|
userHandler := NewUserHandler(adminSvc, nil)
|
||||||
groupHandler := NewGroupHandler(adminSvc)
|
groupHandler := NewGroupHandler(adminSvc)
|
||||||
proxyHandler := NewProxyHandler(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", userHandler.List)
|
||||||
router.GET("/api/v1/admin/users/:id", userHandler.GetByID)
|
router.GET("/api/v1/admin/users/:id", userHandler.GetByID)
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
"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/pkg/response"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
|
||||||
@@ -18,12 +20,14 @@ import (
|
|||||||
// RedeemHandler handles admin redeem code management
|
// RedeemHandler handles admin redeem code management
|
||||||
type RedeemHandler struct {
|
type RedeemHandler struct {
|
||||||
adminService service.AdminService
|
adminService service.AdminService
|
||||||
|
redeemService *service.RedeemService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRedeemHandler creates a new admin redeem handler
|
// NewRedeemHandler creates a new admin redeem handler
|
||||||
func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
|
func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler {
|
||||||
return &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年
|
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
|
// List handles listing all redeem codes with pagination
|
||||||
// GET /api/v1/admin/redeem-codes
|
// GET /api/v1/admin/redeem-codes
|
||||||
func (h *RedeemHandler) List(c *gin.Context) {
|
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 handles deleting a redeem code
|
||||||
// DELETE /api/v1/admin/redeem-codes/:id
|
// DELETE /api/v1/admin/redeem-codes/:id
|
||||||
func (h *RedeemHandler) Delete(c *gin.Context) {
|
func (h *RedeemHandler) Delete(c *gin.Context) {
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
|||||||
codes.GET("/stats", h.Admin.Redeem.GetStats)
|
codes.GET("/stats", h.Admin.Redeem.GetStats)
|
||||||
codes.GET("/export", h.Admin.Redeem.Export)
|
codes.GET("/export", h.Admin.Redeem.Export)
|
||||||
codes.GET("/:id", h.Admin.Redeem.GetByID)
|
codes.GET("/:id", h.Admin.Redeem.GetByID)
|
||||||
|
codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem)
|
||||||
codes.POST("/generate", h.Admin.Redeem.Generate)
|
codes.POST("/generate", h.Admin.Redeem.Generate)
|
||||||
codes.DELETE("/:id", h.Admin.Redeem.Delete)
|
codes.DELETE("/:id", h.Admin.Redeem.Delete)
|
||||||
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
|
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)
|
||||||
|
|||||||
@@ -174,6 +174,33 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
|
|||||||
return codes, nil
|
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 检查用户兑换错误次数是否超限
|
// checkRedeemRateLimit 检查用户兑换错误次数是否超限
|
||||||
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
|
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
|
||||||
if s.cache == nil {
|
if s.cache == nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user