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:
Wesley Liddick
2026-03-01 18:24:15 +08:00
committed by GitHub
12 changed files with 534 additions and 61 deletions

View File

@@ -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: `docs/ADMIN_PAYMENT_INTEGRATION_API.md`
---

View File

@@ -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)

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 {

View 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`

View File

@@ -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 = {
render: () =>
h(
@@ -490,7 +510,7 @@ const userNavItems = computed(() => {
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
}
]
@@ -515,7 +535,7 @@ const personalNavItems = computed(() => {
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: CreditCardIcon,
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
}
]

View File

@@ -280,7 +280,7 @@ export default {
logout: 'Logout',
github: 'GitHub',
mySubscriptions: 'My Subscriptions',
buySubscription: 'Purchase Subscription',
buySubscription: 'Recharge / Subscription',
docs: 'Docs',
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'
},
purchase: {
title: 'Purchase Page',
description: 'Show a "Purchase Subscription" entry in the sidebar and open the configured URL in an iframe',
enabled: 'Show Purchase Entry',
title: 'Recharge / Subscription Page',
description: 'Show a "Recharge / Subscription" entry in the sidebar and open the configured URL in an iframe',
enabled: 'Show Recharge / Subscription Entry',
enabledHint: 'Only shown in standard mode (not simple mode)',
url: 'Purchase URL',
url: 'Recharge / Subscription URL',
urlPlaceholder: 'https://example.com/purchase',
urlHint: 'Must be an absolute http(s) URL',
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: {
title: 'Sora Client',
@@ -3882,16 +3884,16 @@ export default {
retry: 'Retry'
},
// Purchase Subscription Page
// Recharge / Subscription Page
purchase: {
title: 'Purchase Subscription',
description: 'Purchase a subscription via the embedded page',
title: 'Recharge / Subscription',
description: 'Recharge balance or purchase subscription via the embedded page',
openInNewTab: 'Open in new tab',
notEnabledTitle: 'Feature not enabled',
notEnabledDesc: 'The administrator has not enabled the purchase page. Please contact admin.',
notConfiguredTitle: 'Purchase URL not configured',
notEnabledDesc: 'The administrator has not enabled the recharge/subscription entry. Please contact admin.',
notConfiguredTitle: 'Recharge / Subscription URL not configured',
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

View File

@@ -280,7 +280,7 @@ export default {
logout: '退出登录',
github: 'GitHub',
mySubscriptions: '我的订阅',
buySubscription: '购买订阅',
buySubscription: '充值/订阅',
docs: '文档',
sora: 'Sora 创作'
},
@@ -3761,15 +3761,17 @@ export default {
hideCcsImportButtonHint: '启用后将在 API Keys 页面隐藏"导入 CCS"按钮'
},
purchase: {
title: '购买订阅页面',
description: '在侧边栏展示”购买订阅”入口,并在页面内通过 iframe 打开指定链接',
enabled: '显示购买订阅入口',
title: '充值/订阅页面',
description: '在侧边栏展示“充值/订阅”入口,并在页面内通过 iframe 打开指定链接',
enabled: '显示充值/订阅入口',
enabledHint: '仅在标准模式(非简单模式)下展示',
url: '购买页面 URL',
url: '充值/订阅页面 URL',
urlPlaceholder: 'https://example.com/purchase',
urlHint: '必须是完整的 http(s) 链接',
iframeWarning:
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSPframe-ancestors禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。'
'⚠️ iframe 提示:部分网站会通过 X-Frame-Options 或 CSPframe-ancestors禁止被 iframe 嵌入,出现空白时可引导用户使用”新窗口打开”。',
integrationDoc: '支付集成文档',
integrationDocHint: '包含接口说明、幂等语义及示例代码'
},
soraClient: {
title: 'Sora 客户端',
@@ -4052,15 +4054,15 @@ export default {
retry: '重试'
},
// Purchase Subscription Page
// Recharge / Subscription Page
purchase: {
title: '购买订阅',
description: '通过内嵌页面完成订阅购买',
title: '充值/订阅',
description: '通过内嵌页面完成充值/订阅',
openInNewTab: '新窗口打开',
notEnabledTitle: '该功能未开启',
notEnabledDesc: '管理员暂未开启购买订阅入口,请联系管理员。',
notConfiguredTitle: '购买链接未配置',
notConfiguredDesc: '管理员已开启入口,但尚未配置购买订阅链接,请联系管理员。'
notEnabledDesc: '管理员暂未开启充值/订阅入口,请联系管理员。',
notConfiguredTitle: '充值/订阅链接未配置',
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
},
// Announcements Page

View File

@@ -1020,6 +1020,26 @@
{{ t('admin.settings.purchase.iframeWarning') }}
</p>
</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>

View File

@@ -1,30 +1,6 @@
<template>
<AppLayout>
<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 v-if="loading" class="flex h-full items-center justify-center py-12">
<div
@@ -70,30 +46,84 @@
</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>
</AppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import { useAuthStore } from '@/stores/auth'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
const { t } = useI18n()
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 purchaseTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
const purchaseEnabled = computed(() => {
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(() => {
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(() => {
@@ -102,6 +132,18 @@ const isValidUrl = computed(() => {
})
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
loading.value = true
try {
@@ -110,12 +152,41 @@ onMounted(async () => {
loading.value = false
}
})
onUnmounted(() => {
if (themeObserver) {
themeObserver.disconnect()
themeObserver = null
}
})
</script>
<style scoped>
.purchase-page-layout {
@apply flex flex-col gap-6;
height: calc(100vh - 64px - 4rem); /* 减去 header + lg:p-8 的上下padding */
@apply flex flex-col;
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>