Skip to content

Commit 39ca192

Browse files
committed
feat(admin): add create-and-redeem API and payment integration docs
1 parent f7fa71b commit 39ca192

7 files changed

Lines changed: 256 additions & 5 deletions

File tree

ADMIN_PAYMENT_INTEGRATION_API.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
# Sub2API Admin API: Payment Integration
2+
3+
This document describes the minimum Admin API surface for external payment systems (for example, sub2apipay) to complete balance top-up and reconciliation.
4+
5+
## Base URL
6+
7+
- Production: `https://<your-domain>`
8+
- Beta: `http://<your-server-ip>:8084`
9+
10+
All endpoints below use:
11+
12+
- Header: `x-api-key: admin-<64hex>` (recommended for server-to-server)
13+
- Header: `Content-Type: application/json`
14+
15+
Note: Admin JWT is also accepted by admin routes, but machine-to-machine integration should use admin API key.
16+
17+
## 1) Create + Redeem in One Step
18+
19+
`POST /api/v1/admin/redeem-codes/create-and-redeem`
20+
21+
Purpose:
22+
23+
- Atomically create a deterministic redeem code and redeem it to a target user.
24+
- Typical usage: called after payment callback succeeds.
25+
26+
Required headers:
27+
28+
- `x-api-key`
29+
- `Idempotency-Key`
30+
31+
Request body:
32+
33+
```json
34+
{
35+
"code": "s2p_cm1234567890",
36+
"type": "balance",
37+
"value": 100.0,
38+
"user_id": 123,
39+
"notes": "sub2apipay order: cm1234567890"
40+
}
41+
```
42+
43+
Rules:
44+
45+
- `code`: external deterministic order-mapped code.
46+
- `type`: currently recommended `balance`.
47+
- `value`: must be `> 0`.
48+
- `user_id`: target user id.
49+
50+
Idempotency semantics:
51+
52+
- Same `code`, same `used_by` user: return `200` (idempotent replay).
53+
- Same `code`, different `used_by` user: return `409` conflict.
54+
- Missing `Idempotency-Key`: return `400` (`IDEMPOTENCY_KEY_REQUIRED`).
55+
56+
Example:
57+
58+
```bash
59+
curl -X POST "${BASE}/api/v1/admin/redeem-codes/create-and-redeem" \
60+
-H "x-api-key: ${KEY}" \
61+
-H "Idempotency-Key: pay-cm1234567890-success" \
62+
-H "Content-Type: application/json" \
63+
-d '{
64+
"code":"s2p_cm1234567890",
65+
"type":"balance",
66+
"value":100.00,
67+
"user_id":123,
68+
"notes":"sub2apipay order: cm1234567890"
69+
}'
70+
```
71+
72+
## 2) Query User (Optional Pre-check)
73+
74+
`GET /api/v1/admin/users/:id`
75+
76+
Purpose:
77+
78+
- Check whether target user exists before payment finalize/retry.
79+
80+
Example:
81+
82+
```bash
83+
curl -s "${BASE}/api/v1/admin/users/123" \
84+
-H "x-api-key: ${KEY}"
85+
```
86+
87+
## 3) Balance Adjustment (Existing Interface)
88+
89+
`POST /api/v1/admin/users/:id/balance`
90+
91+
Purpose:
92+
93+
- Existing reusable admin interface for manual correction.
94+
- Supports `set`, `add`, `subtract`.
95+
96+
Request body example (`subtract`):
97+
98+
```json
99+
{
100+
"balance": 100.0,
101+
"operation": "subtract",
102+
"notes": "manual correction"
103+
}
104+
```
105+
106+
Example:
107+
108+
```bash
109+
curl -X POST "${BASE}/api/v1/admin/users/123/balance" \
110+
-H "x-api-key: ${KEY}" \
111+
-H "Idempotency-Key: balance-subtract-cm1234567890" \
112+
-H "Content-Type: application/json" \
113+
-d '{
114+
"balance":100.00,
115+
"operation":"subtract",
116+
"notes":"manual correction"
117+
}'
118+
```
119+
120+
## 4) Error Handling Recommendations
121+
122+
- Persist upstream payment result independently from recharge result.
123+
- Mark payment success immediately after callback verification.
124+
- If recharge fails after payment success, keep order retryable by admin operation.
125+
- For retry, always reuse deterministic `code` + new `Idempotency-Key`.
126+
127+
## 5) Suggested `doc_url` Setting
128+
129+
Sub2API already supports `doc_url` in system settings.
130+
131+
Recommended values:
132+
133+
- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
134+
- Direct download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
5454
## Documentation
5555

5656
- Dependency Security: `docs/dependency-security.md`
57+
- Admin Payment Integration API: `ADMIN_PAYMENT_INTEGRATION_API.md`
5758

5859
---
5960

backend/cmd/server/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/internal/handler/admin/admin_basic_handlers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
1919
userHandler := NewUserHandler(adminSvc, nil)
2020
groupHandler := NewGroupHandler(adminSvc)
2121
proxyHandler := NewProxyHandler(adminSvc)
22-
redeemHandler := NewRedeemHandler(adminSvc)
22+
redeemHandler := NewRedeemHandler(adminSvc, nil)
2323

2424
router.GET("/api/v1/admin/users", userHandler.List)
2525
router.GET("/api/v1/admin/users/:id", userHandler.GetByID)

backend/internal/handler/admin/redeem_handler.go

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"bytes"
55
"context"
66
"encoding/csv"
7+
"errors"
78
"fmt"
89
"strconv"
910
"strings"
1011

1112
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
13+
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
1214
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
1315
"github.com/Wei-Shaw/sub2api/internal/service"
1416

@@ -17,13 +19,15 @@ import (
1719

1820
// RedeemHandler handles admin redeem code management
1921
type RedeemHandler struct {
20-
adminService service.AdminService
22+
adminService service.AdminService
23+
redeemService *service.RedeemService
2124
}
2225

2326
// NewRedeemHandler creates a new admin redeem handler
24-
func NewRedeemHandler(adminService service.AdminService) *RedeemHandler {
27+
func NewRedeemHandler(adminService service.AdminService, redeemService *service.RedeemService) *RedeemHandler {
2528
return &RedeemHandler{
26-
adminService: adminService,
29+
adminService: adminService,
30+
redeemService: redeemService,
2731
}
2832
}
2933

@@ -36,6 +40,15 @@ type GenerateRedeemCodesRequest struct {
3640
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年
3741
}
3842

43+
// CreateAndRedeemCodeRequest represents creating a fixed code and redeeming it for a target user.
44+
type CreateAndRedeemCodeRequest struct {
45+
Code string `json:"code" binding:"required,min=3,max=128"`
46+
Type string `json:"type" binding:"required,oneof=balance concurrency subscription invitation"`
47+
Value float64 `json:"value" binding:"required,gt=0"`
48+
UserID int64 `json:"user_id" binding:"required,gt=0"`
49+
Notes string `json:"notes"`
50+
}
51+
3952
// List handles listing all redeem codes with pagination
4053
// GET /api/v1/admin/redeem-codes
4154
func (h *RedeemHandler) List(c *gin.Context) {
@@ -109,6 +122,81 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
109122
})
110123
}
111124

125+
// CreateAndRedeem creates a fixed redeem code and redeems it for a target user in one step.
126+
// POST /api/v1/admin/redeem-codes/create-and-redeem
127+
func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
128+
if h.redeemService == nil {
129+
response.InternalError(c, "redeem service not configured")
130+
return
131+
}
132+
133+
var req CreateAndRedeemCodeRequest
134+
if err := c.ShouldBindJSON(&req); err != nil {
135+
response.BadRequest(c, "Invalid request: "+err.Error())
136+
return
137+
}
138+
req.Code = strings.TrimSpace(req.Code)
139+
140+
executeAdminIdempotentJSON(c, "admin.redeem_codes.create_and_redeem", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
141+
existing, err := h.redeemService.GetByCode(ctx, req.Code)
142+
if err == nil {
143+
return h.resolveCreateAndRedeemExisting(ctx, existing, req.UserID)
144+
}
145+
if !errors.Is(err, service.ErrRedeemCodeNotFound) {
146+
return nil, err
147+
}
148+
149+
createErr := h.redeemService.CreateCode(ctx, &service.RedeemCode{
150+
Code: req.Code,
151+
Type: req.Type,
152+
Value: req.Value,
153+
Status: service.StatusUnused,
154+
Notes: req.Notes,
155+
})
156+
if createErr != nil {
157+
// Unique code race: if code now exists, use idempotent semantics by used_by.
158+
existingAfterCreateErr, getErr := h.redeemService.GetByCode(ctx, req.Code)
159+
if getErr == nil {
160+
return h.resolveCreateAndRedeemExisting(ctx, existingAfterCreateErr, req.UserID)
161+
}
162+
return nil, createErr
163+
}
164+
165+
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
166+
if redeemErr != nil {
167+
return nil, redeemErr
168+
}
169+
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
170+
})
171+
}
172+
173+
func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, existing *service.RedeemCode, userID int64) (any, error) {
174+
if existing == nil {
175+
return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code conflict")
176+
}
177+
178+
// If previous run created the code but crashed before redeem, redeem it now.
179+
if existing.CanUse() {
180+
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
181+
if err == nil {
182+
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
183+
}
184+
if !errors.Is(err, service.ErrRedeemCodeUsed) {
185+
return nil, err
186+
}
187+
latest, getErr := h.redeemService.GetByCode(ctx, existing.Code)
188+
if getErr == nil {
189+
existing = latest
190+
}
191+
}
192+
193+
if existing.UsedBy != nil && *existing.UsedBy == userID {
194+
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(existing)}, nil
195+
}
196+
197+
return nil, infraerrors.Conflict("REDEEM_CODE_CONFLICT", "redeem code already used by another user")
198+
}
199+
112200
// Delete handles deleting a redeem code
113201
// DELETE /api/v1/admin/redeem-codes/:id
114202
func (h *RedeemHandler) Delete(c *gin.Context) {

backend/internal/server/routes/admin.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ func registerRedeemCodeRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
351351
codes.GET("/stats", h.Admin.Redeem.GetStats)
352352
codes.GET("/export", h.Admin.Redeem.Export)
353353
codes.GET("/:id", h.Admin.Redeem.GetByID)
354+
codes.POST("/create-and-redeem", h.Admin.Redeem.CreateAndRedeem)
354355
codes.POST("/generate", h.Admin.Redeem.Generate)
355356
codes.DELETE("/:id", h.Admin.Redeem.Delete)
356357
codes.POST("/batch-delete", h.Admin.Redeem.BatchDelete)

backend/internal/service/redeem_service.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,33 @@ func (s *RedeemService) GenerateCodes(ctx context.Context, req GenerateCodesRequ
174174
return codes, nil
175175
}
176176

177+
// CreateCode creates a redeem code with caller-provided code value.
178+
// It is primarily used by admin integrations that require an external order ID
179+
// to be mapped to a deterministic redeem code.
180+
func (s *RedeemService) CreateCode(ctx context.Context, code *RedeemCode) error {
181+
if code == nil {
182+
return errors.New("redeem code is required")
183+
}
184+
code.Code = strings.TrimSpace(code.Code)
185+
if code.Code == "" {
186+
return errors.New("code is required")
187+
}
188+
if code.Type == "" {
189+
code.Type = RedeemTypeBalance
190+
}
191+
if code.Type != RedeemTypeInvitation && code.Value <= 0 {
192+
return errors.New("value must be greater than 0")
193+
}
194+
if code.Status == "" {
195+
code.Status = StatusUnused
196+
}
197+
198+
if err := s.redeemRepo.Create(ctx, code); err != nil {
199+
return fmt.Errorf("create redeem code: %w", err)
200+
}
201+
return nil
202+
}
203+
177204
// checkRedeemRateLimit 检查用户兑换错误次数是否超限
178205
func (s *RedeemService) checkRedeemRateLimit(ctx context.Context, userID int64) error {
179206
if s.cache == nil {

0 commit comments

Comments
 (0)