Merge pull request #685 from touwaeriol/pr/admin-create-and-redeem-docs
feat(admin): add create-and-redeem endpoint for payment integrations
This commit is contained in:
@@ -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: `docs/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"
|
||||||
|
|
||||||
@@ -17,13 +19,15 @@ 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 {
|
||||||
|
|||||||
241
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
241
docs/ADMIN_PAYMENT_INTEGRATION_API.md
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
# ADMIN_PAYMENT_INTEGRATION_API
|
||||||
|
|
||||||
|
> 单文件中英双语文档 / Single-file bilingual documentation (Chinese + English)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 中文
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
本文档用于对接外部支付系统(如 `sub2apipay`)与 Sub2API 的 Admin API,覆盖:
|
||||||
|
- 支付成功后充值
|
||||||
|
- 用户查询
|
||||||
|
- 人工余额修正
|
||||||
|
- 前端购买页参数透传
|
||||||
|
|
||||||
|
### 基础地址
|
||||||
|
- 生产:`https://<your-domain>`
|
||||||
|
- Beta:`http://<your-server-ip>:8084`
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
推荐使用:
|
||||||
|
- `x-api-key: admin-<64hex>`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- 幂等接口额外传:`Idempotency-Key`
|
||||||
|
|
||||||
|
说明:管理员 JWT 也可访问 admin 路由,但服务间调用建议使用 Admin API Key。
|
||||||
|
|
||||||
|
### 1) 一步完成创建并兑换
|
||||||
|
`POST /api/v1/admin/redeem-codes/create-and-redeem`
|
||||||
|
|
||||||
|
用途:原子完成“创建兑换码 + 兑换到指定用户”。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
- `x-api-key`
|
||||||
|
- `Idempotency-Key`
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "s2p_cm1234567890",
|
||||||
|
"type": "balance",
|
||||||
|
"value": 100.0,
|
||||||
|
"user_id": 123,
|
||||||
|
"notes": "sub2apipay order: cm1234567890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
幂等语义:
|
||||||
|
- 同 `code` 且 `used_by` 一致:`200`
|
||||||
|
- 同 `code` 但 `used_by` 不一致:`409`
|
||||||
|
- 缺少 `Idempotency-Key`:`400`(`IDEMPOTENCY_KEY_REQUIRED`)
|
||||||
|
|
||||||
|
curl 示例:
|
||||||
|
```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) 查询用户(可选前置校验)
|
||||||
|
`GET /api/v1/admin/users/:id`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "${BASE}/api/v1/admin/users/123" \
|
||||||
|
-H "x-api-key: ${KEY}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) 余额调整(已有接口)
|
||||||
|
`POST /api/v1/admin/users/:id/balance`
|
||||||
|
|
||||||
|
用途:人工补偿 / 扣减,支持 `set` / `add` / `subtract`。
|
||||||
|
|
||||||
|
请求体示例(扣减):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"balance": 100.0,
|
||||||
|
"operation": "subtract",
|
||||||
|
"notes": "manual correction"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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) 购买页 URL Query 透传(iframe / 新窗口一致)
|
||||||
|
当 Sub2API 打开 `purchase_subscription_url` 时,会统一追加:
|
||||||
|
- `user_id`
|
||||||
|
- `token`
|
||||||
|
- `theme`(`light` / `dark`)
|
||||||
|
- `ui_mode`(固定 `embedded`)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```text
|
||||||
|
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&ui_mode=embedded
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5) 失败处理建议
|
||||||
|
- 支付成功与充值成功分状态落库
|
||||||
|
- 回调验签成功后立即标记“支付成功”
|
||||||
|
- 支付成功但充值失败的订单允许后续重试
|
||||||
|
- 重试保持相同 `code`,并使用新的 `Idempotency-Key`
|
||||||
|
|
||||||
|
### 6) `doc_url` 配置建议
|
||||||
|
- 查看链接:`https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||||
|
- 下载链接:`https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## English
|
||||||
|
|
||||||
|
### Purpose
|
||||||
|
This document describes the minimal Sub2API Admin API surface for external payment integrations (for example, `sub2apipay`), including:
|
||||||
|
- Recharge after payment success
|
||||||
|
- User lookup
|
||||||
|
- Manual balance correction
|
||||||
|
- Purchase page query parameter forwarding
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
- Production: `https://<your-domain>`
|
||||||
|
- Beta: `http://<your-server-ip>:8084`
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
Recommended headers:
|
||||||
|
- `x-api-key: admin-<64hex>`
|
||||||
|
- `Content-Type: application/json`
|
||||||
|
- `Idempotency-Key` for idempotent endpoints
|
||||||
|
|
||||||
|
Note: Admin JWT can also access admin routes, but Admin API Key is recommended for server-to-server integration.
|
||||||
|
|
||||||
|
### 1) Create and Redeem in one step
|
||||||
|
`POST /api/v1/admin/redeem-codes/create-and-redeem`
|
||||||
|
|
||||||
|
Use case: atomically create a redeem code and redeem it to a target user.
|
||||||
|
|
||||||
|
Headers:
|
||||||
|
- `x-api-key`
|
||||||
|
- `Idempotency-Key`
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "s2p_cm1234567890",
|
||||||
|
"type": "balance",
|
||||||
|
"value": 100.0,
|
||||||
|
"user_id": 123,
|
||||||
|
"notes": "sub2apipay order: cm1234567890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Idempotency behavior:
|
||||||
|
- Same `code` and same `used_by`: `200`
|
||||||
|
- Same `code` but different `used_by`: `409`
|
||||||
|
- Missing `Idempotency-Key`: `400` (`IDEMPOTENCY_KEY_REQUIRED`)
|
||||||
|
|
||||||
|
curl 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`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "${BASE}/api/v1/admin/users/123" \
|
||||||
|
-H "x-api-key: ${KEY}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Balance Adjustment (existing API)
|
||||||
|
`POST /api/v1/admin/users/:id/balance`
|
||||||
|
|
||||||
|
Use case: manual correction with `set` / `add` / `subtract`.
|
||||||
|
|
||||||
|
Request body example (`subtract`):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"balance": 100.0,
|
||||||
|
"operation": "subtract",
|
||||||
|
"notes": "manual correction"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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) Purchase URL query forwarding (iframe and new tab)
|
||||||
|
When Sub2API opens `purchase_subscription_url`, it appends:
|
||||||
|
- `user_id`
|
||||||
|
- `token`
|
||||||
|
- `theme` (`light` / `dark`)
|
||||||
|
- `ui_mode` (fixed: `embedded`)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```text
|
||||||
|
https://pay.example.com/pay?user_id=123&token=<jwt>&theme=light&ui_mode=embedded
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5) Failure handling recommendations
|
||||||
|
- Persist payment success and recharge success as separate states
|
||||||
|
- Mark payment as successful immediately after verified callback
|
||||||
|
- Allow retry for orders with payment success but recharge failure
|
||||||
|
- Keep the same `code` for retry, and use a new `Idempotency-Key`
|
||||||
|
|
||||||
|
### 6) Recommended `doc_url`
|
||||||
|
- View URL: `https://github.com/Wei-Shaw/sub2api/blob/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||||
|
- Download URL: `https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/ADMIN_PAYMENT_INTEGRATION_API.md`
|
||||||
@@ -290,6 +290,26 @@ const CreditCardIcon = {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RechargeSubscriptionIcon = {
|
||||||
|
render: () =>
|
||||||
|
h(
|
||||||
|
'svg',
|
||||||
|
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||||
|
[
|
||||||
|
h('path', {
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
d: 'M2.25 7.5A2.25 2.25 0 014.5 5.25h15A2.25 2.25 0 0121.75 7.5v9A2.25 2.25 0 0119.5 18.75h-15A2.25 2.25 0 012.25 16.5v-9z'
|
||||||
|
}),
|
||||||
|
h('path', {
|
||||||
|
'stroke-linecap': 'round',
|
||||||
|
'stroke-linejoin': 'round',
|
||||||
|
d: 'M6.75 12h3m4.5 0h3m-3-3v6'
|
||||||
|
})
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const GlobeIcon = {
|
const GlobeIcon = {
|
||||||
render: () =>
|
render: () =>
|
||||||
h(
|
h(
|
||||||
@@ -490,7 +510,7 @@ const userNavItems = computed(() => {
|
|||||||
{
|
{
|
||||||
path: '/purchase',
|
path: '/purchase',
|
||||||
label: t('nav.buySubscription'),
|
label: t('nav.buySubscription'),
|
||||||
icon: CreditCardIcon,
|
icon: RechargeSubscriptionIcon,
|
||||||
hideInSimpleMode: true
|
hideInSimpleMode: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -515,7 +535,7 @@ const personalNavItems = computed(() => {
|
|||||||
{
|
{
|
||||||
path: '/purchase',
|
path: '/purchase',
|
||||||
label: t('nav.buySubscription'),
|
label: t('nav.buySubscription'),
|
||||||
icon: CreditCardIcon,
|
icon: RechargeSubscriptionIcon,
|
||||||
hideInSimpleMode: true
|
hideInSimpleMode: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export default {
|
|||||||
logout: 'Logout',
|
logout: 'Logout',
|
||||||
github: 'GitHub',
|
github: 'GitHub',
|
||||||
mySubscriptions: 'My Subscriptions',
|
mySubscriptions: 'My Subscriptions',
|
||||||
buySubscription: 'Purchase Subscription',
|
buySubscription: 'Recharge / Subscription',
|
||||||
docs: 'Docs',
|
docs: 'Docs',
|
||||||
sora: 'Sora Studio'
|
sora: 'Sora Studio'
|
||||||
},
|
},
|
||||||
@@ -3590,15 +3590,17 @@ export default {
|
|||||||
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
hideCcsImportButtonHint: 'When enabled, the "Import to CCS" button will be hidden on the API Keys page'
|
||||||
},
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
title: 'Purchase Page',
|
title: 'Recharge / Subscription Page',
|
||||||
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
|
description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe',
|
||||||
enabled: 'Show Purchase Entry',
|
enabled: 'Show Recharge / Subscription Entry',
|
||||||
enabledHint: 'Only shown in standard mode (not simple mode)',
|
enabledHint: 'Only shown in standard mode (not simple mode)',
|
||||||
url: 'Purchase URL',
|
url: 'Recharge / Subscription URL',
|
||||||
urlPlaceholder: 'https://example.com/purchase',
|
urlPlaceholder: 'https://example.com/purchase',
|
||||||
urlHint: 'Must be an absolute http(s) URL',
|
urlHint: 'Must be an absolute http(s) URL',
|
||||||
iframeWarning:
|
iframeWarning:
|
||||||
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.'
|
'⚠️ iframe note: Some websites block embedding via X-Frame-Options or CSP (frame-ancestors). If the page is blank, provide an "Open in new tab" alternative.',
|
||||||
|
integrationDoc: 'Payment Integration Docs',
|
||||||
|
integrationDocHint: 'Covers endpoint specs, idempotency semantics, and code samples'
|
||||||
},
|
},
|
||||||
soraClient: {
|
soraClient: {
|
||||||
title: 'Sora Client',
|
title: 'Sora Client',
|
||||||
@@ -3882,16 +3884,16 @@ export default {
|
|||||||
retry: 'Retry'
|
retry: 'Retry'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase Subscription Page
|
// Recharge / Subscription Page
|
||||||
purchase: {
|
purchase: {
|
||||||
title: 'Purchase Subscription',
|
title: 'Recharge / Subscription',
|
||||||
description: 'Purchase a subscription via the embedded page',
|
description: 'Recharge balance or purchase subscription via the embedded page',
|
||||||
openInNewTab: 'Open in new tab',
|
openInNewTab: 'Open in new tab',
|
||||||
notEnabledTitle: 'Feature not enabled',
|
notEnabledTitle: 'Feature not enabled',
|
||||||
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
|
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
|
||||||
notConfiguredTitle: 'Purchase URL not configured',
|
notConfiguredTitle: 'Recharge / Subscription URL not configured',
|
||||||
notConfiguredDesc:
|
notConfiguredDesc:
|
||||||
'The administrator enabled the entry but has not configured a purchase URL. Please contact admin.'
|
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Announcements Page
|
// Announcements Page
|
||||||
|
|||||||
@@ -280,7 +280,7 @@ export default {
|
|||||||
logout: '退出登录',
|
logout: '退出登录',
|
||||||
github: 'GitHub',
|
github: 'GitHub',
|
||||||
mySubscriptions: '我的订阅',
|
mySubscriptions: '我的订阅',
|
||||||
buySubscription: '购买订阅',
|
buySubscription: '充值/订阅',
|
||||||
docs: '文档',
|
docs: '文档',
|
||||||
sora: 'Sora 创作'
|
sora: 'Sora 创作'
|
||||||
},
|
},
|
||||||
@@ -3761,15 +3761,17 @@ export default {
|
|||||||
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
|
||||||
},
|
},
|
||||||
purchase: {
|
purchase: {
|
||||||
title: '购买订阅页面',
|
title: '充值/订阅页面',
|
||||||
description: '在侧边栏展示”购买订阅”入口,并在页面内通过 iframe 打开指定链接',
|
description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接',
|
||||||
enabled: '显示购买订阅入口',
|
enabled: '显示充值/订阅入口',
|
||||||
enabledHint: '仅在标准模式(非简单模式)下展示',
|
enabledHint: '仅在标准模式(非简单模式)下展示',
|
||||||
url: '购买页面 URL',
|
url: '充值/订阅页面 URL',
|
||||||
urlPlaceholder: 'https://example.com/purchase',
|
urlPlaceholder: 'https://example.com/purchase',
|
||||||
urlHint: '必须是完整的 http(s) 链接',
|
urlHint: '必须是完整的 http(s) 链接',
|
||||||
iframeWarning:
|
iframeWarning:
|
||||||
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。'
|
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSP(frame-ancestors)禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。',
|
||||||
|
integrationDoc: '支付集成文档',
|
||||||
|
integrationDocHint: '包含接口说明、幂等语义及示例代码'
|
||||||
},
|
},
|
||||||
soraClient: {
|
soraClient: {
|
||||||
title: 'Sora 客户端',
|
title: 'Sora 客户端',
|
||||||
@@ -4052,15 +4054,15 @@ export default {
|
|||||||
retry: '重试'
|
retry: '重试'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Purchase Subscription Page
|
// Recharge / Subscription Page
|
||||||
purchase: {
|
purchase: {
|
||||||
title: '购买订阅',
|
title: '充值/订阅',
|
||||||
description: '通过内嵌页面完成订阅购买',
|
description: '通过内嵌页面完成充值/订阅',
|
||||||
openInNewTab: '新窗口打开',
|
openInNewTab: '新窗口打开',
|
||||||
notEnabledTitle: '该功能未开启',
|
notEnabledTitle: '该功能未开启',
|
||||||
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
|
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
|
||||||
notConfiguredTitle: '购买链接未配置',
|
notConfiguredTitle: '充值/订阅链接未配置',
|
||||||
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
|
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
|
||||||
},
|
},
|
||||||
|
|
||||||
// Announcements Page
|
// Announcements Page
|
||||||
|
|||||||
@@ -1020,6 +1020,26 @@
|
|||||||
{{ t('admin.settings.purchase.iframeWarning') }}
|
{{ t('admin.settings.purchase.iframeWarning') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Integration Docs -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<a
|
||||||
|
href="https://raw.githubusercontent.com/Wei-Shaw/sub2api/main/docs/ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-blue-600 hover:underline dark:text-blue-400"
|
||||||
|
download="ADMIN_PAYMENT_INTEGRATION_API.md"
|
||||||
|
>
|
||||||
|
{{ t('admin.settings.purchase.integrationDoc') }}
|
||||||
|
</a>
|
||||||
|
<span class="text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.settings.purchase.integrationDocHint') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="purchase-page-layout">
|
<div class="purchase-page-layout">
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ t('purchase.title') }}
|
|
||||||
</h2>
|
|
||||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
|
||||||
{{ t('purchase.description') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
v-if="isValidUrl"
|
|
||||||
:href="purchaseUrl"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="btn btn-secondary btn-sm"
|
|
||||||
>
|
|
||||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
|
||||||
{{ t('purchase.openInNewTab') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||||
<div
|
<div
|
||||||
@@ -70,30 +46,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<iframe v-else :src="purchaseUrl" class="h-full w-full border-0" allowfullscreen></iframe>
|
<div v-else class="purchase-embed-shell">
|
||||||
|
<a
|
||||||
|
:href="purchaseUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-secondary btn-sm purchase-open-fab"
|
||||||
|
>
|
||||||
|
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||||
|
{{ t('purchase.openInNewTab') }}
|
||||||
|
</a>
|
||||||
|
<iframe
|
||||||
|
:src="purchaseUrl"
|
||||||
|
class="purchase-embed-frame"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores'
|
import { useAppStore } from '@/stores'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const PURCHASE_USER_ID_QUERY_KEY = 'user_id'
|
||||||
|
const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token'
|
||||||
|
const PURCHASE_THEME_QUERY_KEY = 'theme'
|
||||||
|
const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode'
|
||||||
|
const PURCHASE_UI_MODE_EMBEDDED = 'embedded'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const purchaseTheme = ref<'light' | 'dark'>('light')
|
||||||
|
let themeObserver: MutationObserver | null = null
|
||||||
|
|
||||||
const purchaseEnabled = computed(() => {
|
const purchaseEnabled = computed(() => {
|
||||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function detectTheme(): 'light' | 'dark' {
|
||||||
|
if (typeof document === 'undefined') return 'light'
|
||||||
|
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPurchaseUrl(
|
||||||
|
baseUrl: string,
|
||||||
|
userId?: number,
|
||||||
|
authToken?: string | null,
|
||||||
|
theme: 'light' | 'dark' = 'light',
|
||||||
|
): string {
|
||||||
|
if (!baseUrl) return baseUrl
|
||||||
|
try {
|
||||||
|
const url = new URL(baseUrl)
|
||||||
|
if (userId) {
|
||||||
|
url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId))
|
||||||
|
}
|
||||||
|
if (authToken) {
|
||||||
|
url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken)
|
||||||
|
}
|
||||||
|
url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme)
|
||||||
|
url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED)
|
||||||
|
return url.toString()
|
||||||
|
} catch {
|
||||||
|
return baseUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const purchaseUrl = computed(() => {
|
const purchaseUrl = computed(() => {
|
||||||
return (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||||
|
return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const isValidUrl = computed(() => {
|
const isValidUrl = computed(() => {
|
||||||
@@ -102,6 +132,18 @@ const isValidUrl = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
purchaseTheme.value = detectTheme()
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
themeObserver = new MutationObserver(() => {
|
||||||
|
purchaseTheme.value = detectTheme()
|
||||||
|
})
|
||||||
|
themeObserver.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (appStore.publicSettingsLoaded) return
|
if (appStore.publicSettingsLoaded) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -110,12 +152,41 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (themeObserver) {
|
||||||
|
themeObserver.disconnect()
|
||||||
|
themeObserver = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.purchase-page-layout {
|
.purchase-page-layout {
|
||||||
@apply flex flex-col gap-6;
|
@apply flex flex-col;
|
||||||
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
|
height: calc(100vh - 64px - 4rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-embed-shell {
|
||||||
|
@apply relative;
|
||||||
|
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||||
|
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-open-fab {
|
||||||
|
@apply absolute right-3 top-3 z-10;
|
||||||
|
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.purchase-embed-frame {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user