feat: add configurable verification and redemption codes
This commit is contained in:
@@ -60,6 +60,7 @@ IDEMPOTENCY_TTL_HOURS=24
|
|||||||
# 结果保留(匿名默认;登录用户按套餐 retention_days)
|
# 结果保留(匿名默认;登录用户按套餐 retention_days)
|
||||||
ANON_RETENTION_HOURS=24
|
ANON_RETENTION_HOURS=24
|
||||||
|
|
||||||
# 管理员初始账户(首启可自动创建)
|
# 管理员初始账户(首启可自动创建;ADMIN_EMAIL 可留空)
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=changeme123
|
ADMIN_PASSWORD=changeme123
|
||||||
|
|||||||
@@ -9,7 +9,8 @@
|
|||||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(仅静态图片,支持格式转换)
|
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(仅静态图片,支持格式转换)
|
||||||
- **批量处理**:支持多图片同时上传和处理
|
- **批量处理**:支持多图片同时上传和处理
|
||||||
- **压缩率**:1-100(JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
|
- **压缩率**:1-100(JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
|
||||||
- **用户系统**:注册、登录、API Key 管理
|
- **用户系统**:注册、登录、可开关邮箱验证、API Key 管理
|
||||||
|
- **兑换码**:套餐卡、限时次数卡、批量生成、停用与兑换审计
|
||||||
- **计费与用量**:套餐/订阅/配额/发票
|
- **计费与用量**:套餐/订阅/配额/发票
|
||||||
- **管理员后台**:用户管理、系统监控、配置管理、S3 多端点管理与连通性测试
|
- **管理员后台**:用户管理、系统监控、配置管理、S3 多端点管理与连通性测试
|
||||||
- **对象存储**:Garage/MinIO/AWS S3 兼容,私有 Bucket + 短期签名直连下载
|
- **对象存储**:Garage/MinIO/AWS S3 兼容,私有 Bucket + 短期签名直连下载
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ POSTGRES_PASSWORD=replace-with-a-long-random-password
|
|||||||
JWT_SECRET=replace-with-at-least-32-random-bytes
|
JWT_SECRET=replace-with-at-least-32-random-bytes
|
||||||
API_KEY_PEPPER=replace-with-an-independent-random-secret
|
API_KEY_PEPPER=replace-with-an-independent-random-secret
|
||||||
|
|
||||||
# Initial administrator created on first startup.
|
# Initial administrator created on first startup. ADMIN_EMAIL is optional.
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
||||||
|
|||||||
58
docs/api.md
58
docs/api.md
@@ -576,8 +576,9 @@ Authorization: Bearer <token>
|
|||||||
"used_units": 120,
|
"used_units": 120,
|
||||||
"included_units": 10000,
|
"included_units": 10000,
|
||||||
"bonus_units": 500,
|
"bonus_units": 500,
|
||||||
"total_units": 10500,
|
"redeemed_units": 200,
|
||||||
"remaining_units": 10380
|
"total_units": 10700,
|
||||||
|
"remaining_units": 10580
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -612,6 +613,23 @@ GET /billing/invoices?page=1&limit=20
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 9.7 兑换套餐卡或次数卡
|
||||||
|
```http
|
||||||
|
POST /redemptions/redeem
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "code": "IMG-XXXX-XXXX-XXXX-XXXX" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.8 获取自己的兑换记录
|
||||||
|
```http
|
||||||
|
GET /redemptions
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
次数卡额度拥有独立有效期,扣减时优先使用更早到期的可用额度。套餐卡不会覆盖仍然有效的 Stripe 订阅。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 10. Webhooks(支付回调)
|
## 10. Webhooks(支付回调)
|
||||||
@@ -713,6 +731,42 @@ Authorization: Bearer <admin_token>
|
|||||||
|
|
||||||
凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。活动端点不能直接编辑或删除。
|
凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。活动端点不能直接编辑或删除。
|
||||||
|
|
||||||
|
### 11.7 兑换码管理
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /admin/redemption-codes?page=1&limit=50
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /admin/redemption-codes
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"benefit_kind": "units",
|
||||||
|
"units": 100,
|
||||||
|
"duration_days": 30,
|
||||||
|
"quantity": 10,
|
||||||
|
"redeem_before": "2026-12-31T15:59:59Z",
|
||||||
|
"note": "活动批次"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
套餐卡使用 `benefit_kind: "plan"` 并传入 `plan_id`。完整兑换码只在创建响应中返回一次,数据库只保存 HMAC 哈希和脱敏标识。
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /admin/redemption-codes/{code_id}
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "is_active": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.8 邮箱验证开关
|
||||||
|
|
||||||
|
`GET /admin/auth` 和 `PUT /admin/auth` 的配置体包含 `email_verification_required`。该配置独立于 SMTP,修改后立即生效,不需要重启服务。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 12. WebSocket(网站任务进度)
|
## 12. WebSocket(网站任务进度)
|
||||||
|
|||||||
@@ -594,7 +594,8 @@ VALUES
|
|||||||
### 7.2 默认系统配置
|
### 7.2 默认系统配置
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO system_config (key, value, description) VALUES
|
INSERT INTO system_config (key, value, description) VALUES
|
||||||
('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true, "email_verification_required": true}', '功能开关'),
|
('features', '{"registration_enabled": true, "api_key_enabled": true, "anonymous_upload_enabled": true}', '功能开关'),
|
||||||
|
('auth', '{"email_verification_required": true}', '认证功能开关'),
|
||||||
('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'),
|
('rate_limits', '{"anonymous_per_minute": 10, "anonymous_units_per_day": 10, "user_per_minute": 60, "api_key_per_minute": 100}', '速率限制默认值'),
|
||||||
('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)'),
|
('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)'),
|
||||||
('mail', '{"enabled": true, "provider": "custom", "from": "noreply@example.com", "from_name": "ImageForge"}', '邮件服务配置(密码加密存储)');
|
('mail', '{"enabled": true, "provider": "custom", "from": "noreply@example.com", "from_name": "ImageForge"}', '邮件服务配置(密码加密存储)');
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ docker compose \
|
|||||||
|
|
||||||
API 健康后 Worker 才会启动,避免两个进程在首次部署时同时执行迁移。
|
API 健康后 Worker 才会启动,避免两个进程在首次部署时同时执行迁移。
|
||||||
|
|
||||||
|
管理员可用邮箱或用户名登录。首次启动时设置 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 即可创建管理员;若未设置 `ADMIN_EMAIL`,系统会生成仅用于满足内部数据约束的 `用户名@local.invalid` 占位邮箱。确认账号创建后应从生产环境文件中移除 `ADMIN_PASSWORD`,避免每次重启都重置密码。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
|
docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
|
||||||
curl --fail http://127.0.0.1:8080/health
|
curl --fail http://127.0.0.1:8080/health
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
>
|
>
|
||||||
订阅与额度
|
订阅与额度
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/admin/redemptions"
|
||||||
|
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||||
|
active-class="bg-slate-100 text-slate-900"
|
||||||
|
>
|
||||||
|
兑换码
|
||||||
|
</router-link>
|
||||||
<router-link
|
<router-link
|
||||||
to="/admin/integrations"
|
to="/admin/integrations"
|
||||||
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
class="block rounded-md px-3 py-2 hover:bg-slate-100"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function createAppRouter(pinia: Pinia) {
|
|||||||
{ path: 'users', name: 'admin-users', component: () => import('@/pages/admin/AdminUsersPage.vue') },
|
{ path: 'users', name: 'admin-users', component: () => import('@/pages/admin/AdminUsersPage.vue') },
|
||||||
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.vue') },
|
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.vue') },
|
||||||
{ path: 'billing', name: 'admin-billing', component: () => import('@/pages/admin/AdminBillingPage.vue') },
|
{ path: 'billing', name: 'admin-billing', component: () => import('@/pages/admin/AdminBillingPage.vue') },
|
||||||
|
{ path: 'redemptions', name: 'admin-redemptions', component: () => import('@/pages/admin/AdminRedemptionPage.vue') },
|
||||||
{ path: 'integrations', name: 'admin-integrations', component: () => import('@/pages/admin/AdminIntegrationsPage.vue') },
|
{ path: 'integrations', name: 'admin-integrations', component: () => import('@/pages/admin/AdminIntegrationsPage.vue') },
|
||||||
{ path: 'storage', name: 'admin-storage', component: () => import('@/pages/admin/AdminStoragePage.vue') },
|
{ path: 'storage', name: 'admin-storage', component: () => import('@/pages/admin/AdminStoragePage.vue') },
|
||||||
{ path: 'config', name: 'admin-config', component: () => import('@/pages/admin/AdminConfigPage.vue') },
|
{ path: 'config', name: 'admin-config', component: () => import('@/pages/admin/AdminConfigPage.vue') },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { zipSync } from 'fflate'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
compressFile,
|
compressFile,
|
||||||
|
getProfile,
|
||||||
getSubscription,
|
getSubscription,
|
||||||
getUsage,
|
getUsage,
|
||||||
sendVerification,
|
sendVerification,
|
||||||
@@ -62,9 +63,14 @@ onMounted(async () => {
|
|||||||
quotaLoading.value = true
|
quotaLoading.value = true
|
||||||
quotaError.value = null
|
quotaError.value = null
|
||||||
try {
|
try {
|
||||||
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
|
const [u, s, profile] = await Promise.all([
|
||||||
|
getUsage(auth.token),
|
||||||
|
getSubscription(auth.token),
|
||||||
|
getProfile(auth.token),
|
||||||
|
])
|
||||||
usage.value = u
|
usage.value = u
|
||||||
subscription.value = s.subscription
|
subscription.value = s.subscription
|
||||||
|
auth.updateUser(profile)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
quotaError.value = `[${err.code}] ${err.message}`
|
quotaError.value = `[${err.code}] ${err.message}`
|
||||||
@@ -316,6 +322,7 @@ async function resendVerification() {
|
|||||||
alert.value = null
|
alert.value = null
|
||||||
try {
|
try {
|
||||||
const resp = await sendVerification(auth.token)
|
const resp = await sendVerification(auth.token)
|
||||||
|
auth.updateUser(await getProfile(auth.token))
|
||||||
alert.value = { type: 'success', message: resp.message }
|
alert.value = { type: 'success', message: resp.message }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ async function submit() {
|
|||||||
<div class="mx-auto max-w-md">
|
<div class="mx-auto max-w-md">
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||||
<h1 class="text-xl font-semibold text-slate-900">注册</h1>
|
<h1 class="text-xl font-semibold text-slate-900">注册</h1>
|
||||||
<p class="mt-1 text-sm text-slate-600">注册后必须验证邮箱才能使用登录态压缩与 API 能力。</p>
|
<p class="mt-1 text-sm text-slate-600">邮箱是否需要验证由管理员设置,注册后会自动提示。</p>
|
||||||
|
|
||||||
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
<div v-if="error" class="mt-4 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
|
|||||||
@@ -280,7 +280,8 @@ onMounted(async () => {
|
|||||||
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
|
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
|
||||||
当前周期:{{ new Date(creditResult.period_start).toLocaleString() }} →
|
当前周期:{{ new Date(creditResult.period_start).toLocaleString() }} →
|
||||||
{{ new Date(creditResult.period_end).toLocaleString() }},已用 {{ creditResult.used_units }} /
|
{{ new Date(creditResult.period_end).toLocaleString() }},已用 {{ creditResult.used_units }} /
|
||||||
{{ creditResult.total_units }}(含赠送 {{ creditResult.bonus_units }}),剩余 {{ creditResult.remaining_units }}
|
{{ creditResult.total_units }}(含赠送 {{ creditResult.bonus_units }}、兑换 {{ creditResult.redeemed_units }}),剩余
|
||||||
|
{{ creditResult.remaining_units }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
getAuthConfig,
|
||||||
getMailConfig,
|
getMailConfig,
|
||||||
getStripeConfig,
|
getStripeConfig,
|
||||||
listAdminPlans,
|
listAdminPlans,
|
||||||
sendMailTest,
|
sendMailTest,
|
||||||
updateAdminPlan,
|
updateAdminPlan,
|
||||||
|
updateAuthConfig,
|
||||||
updateMailConfig,
|
updateMailConfig,
|
||||||
updateStripeConfig,
|
updateStripeConfig,
|
||||||
type AdminMailConfig,
|
type AdminMailConfig,
|
||||||
@@ -50,6 +52,11 @@ const mailBusy = ref(false)
|
|||||||
const mailMessage = ref<string | null>(null)
|
const mailMessage = ref<string | null>(null)
|
||||||
const mailError = ref<string | null>(null)
|
const mailError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const emailVerificationRequired = ref(true)
|
||||||
|
const authBusy = ref(false)
|
||||||
|
const authMessage = ref<string | null>(null)
|
||||||
|
const authError = ref<string | null>(null)
|
||||||
|
|
||||||
const testEmail = ref('')
|
const testEmail = ref('')
|
||||||
const testBusy = ref(false)
|
const testBusy = ref(false)
|
||||||
const testMessage = ref<string | null>(null)
|
const testMessage = ref<string | null>(null)
|
||||||
@@ -99,12 +106,14 @@ async function loadAll() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const [stripe, mailCfg, planResp] = await Promise.all([
|
const [stripe, authCfg, mailCfg, planResp] = await Promise.all([
|
||||||
getStripeConfig(auth.token),
|
getStripeConfig(auth.token),
|
||||||
|
getAuthConfig(auth.token),
|
||||||
getMailConfig(auth.token),
|
getMailConfig(auth.token),
|
||||||
listAdminPlans(auth.token),
|
listAdminPlans(auth.token),
|
||||||
])
|
])
|
||||||
stripeConfig.value = stripe
|
stripeConfig.value = stripe
|
||||||
|
emailVerificationRequired.value = authCfg.email_verification_required
|
||||||
mailConfig.value = mailCfg
|
mailConfig.value = mailCfg
|
||||||
applyMailConfig(mailCfg)
|
applyMailConfig(mailCfg)
|
||||||
plans.value = planResp.plans
|
plans.value = planResp.plans
|
||||||
@@ -119,6 +128,26 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveAuthConfig() {
|
||||||
|
if (!auth.token) return
|
||||||
|
authBusy.value = true
|
||||||
|
authMessage.value = null
|
||||||
|
authError.value = null
|
||||||
|
try {
|
||||||
|
const resp = await updateAuthConfig(auth.token, emailVerificationRequired.value)
|
||||||
|
emailVerificationRequired.value = resp.email_verification_required
|
||||||
|
authMessage.value = '邮箱验证策略已保存并立即生效'
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
authError.value = `[${err.code}] ${err.message}`
|
||||||
|
} else {
|
||||||
|
authError.value = '更新失败,请稍后再试'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
authBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveStripe() {
|
async function saveStripe() {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
stripeBusy.value = true
|
stripeBusy.value = true
|
||||||
@@ -335,6 +364,22 @@ onMounted(loadAll)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="text-sm font-medium text-slate-900">邮箱验证策略</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">此开关独立于 SMTP 配置,保存后立即影响注册、修改邮箱、压缩与 API Key 权限。</p>
|
||||||
|
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input v-model="emailVerificationRequired" type="checkbox" class="h-4 w-4 rounded border-slate-300" />
|
||||||
|
注册及更换邮箱后必须验证
|
||||||
|
</label>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<button type="button" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="authBusy" @click="saveAuthConfig">
|
||||||
|
{{ authBusy ? '保存中…' : '保存验证策略' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="authMessage" class="text-xs text-emerald-700">{{ authMessage }}</div>
|
||||||
|
<div v-if="authError" class="text-xs text-rose-700">{{ authError }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<div class="text-sm font-medium text-slate-900">邮件服务配置</div>
|
<div class="text-sm font-medium text-slate-900">邮件服务配置</div>
|
||||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
|||||||
254
frontend/src/pages/admin/AdminRedemptionPage.vue
Normal file
254
frontend/src/pages/admin/AdminRedemptionPage.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createAdminRedemptionCodes,
|
||||||
|
listAdminPlans,
|
||||||
|
listAdminRedemptionCodes,
|
||||||
|
updateAdminRedemptionCode,
|
||||||
|
type AdminPlanView,
|
||||||
|
type AdminRedemptionCodeView,
|
||||||
|
type GeneratedRedemptionCode,
|
||||||
|
type RedemptionKind,
|
||||||
|
} from '@/services/api'
|
||||||
|
import { ApiError } from '@/services/http'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const plans = ref<AdminPlanView[]>([])
|
||||||
|
const codes = ref<AdminRedemptionCodeView[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const limit = 50
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
benefit_kind: 'plan' as RedemptionKind,
|
||||||
|
plan_id: '',
|
||||||
|
units: 100,
|
||||||
|
duration_days: 30,
|
||||||
|
redeem_before: '',
|
||||||
|
quantity: 1,
|
||||||
|
note: '',
|
||||||
|
})
|
||||||
|
const generating = ref(false)
|
||||||
|
const generated = ref<GeneratedRedemptionCode[]>([])
|
||||||
|
const generateMessage = ref<string | null>(null)
|
||||||
|
const generateError = ref<string | null>(null)
|
||||||
|
const updatingId = ref<string | null>(null)
|
||||||
|
const copyMessage = ref<string | null>(null)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||||
|
const generatedText = computed(() => generated.value.map((item) => item.code).join('\n'))
|
||||||
|
|
||||||
|
function errorText(err: unknown, fallback: string) {
|
||||||
|
return err instanceof ApiError ? `[${err.code}] ${err.message}` : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(targetPage = page.value) {
|
||||||
|
if (!auth.token) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [planResp, codeResp] = await Promise.all([
|
||||||
|
listAdminPlans(auth.token),
|
||||||
|
listAdminRedemptionCodes(auth.token, targetPage, limit),
|
||||||
|
])
|
||||||
|
plans.value = planResp.plans.filter((plan) => plan.is_active)
|
||||||
|
codes.value = codeResp.codes
|
||||||
|
total.value = codeResp.total
|
||||||
|
page.value = codeResp.page
|
||||||
|
if (!form.value.plan_id && plans.value.length > 0) {
|
||||||
|
form.value.plan_id = plans.value[0]?.id ?? ''
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorText(err, '加载兑换码失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
if (!auth.token) return
|
||||||
|
generating.value = true
|
||||||
|
generated.value = []
|
||||||
|
generateMessage.value = null
|
||||||
|
generateError.value = null
|
||||||
|
copyMessage.value = null
|
||||||
|
try {
|
||||||
|
const payload: {
|
||||||
|
benefit_kind: RedemptionKind
|
||||||
|
plan_id?: string
|
||||||
|
units?: number
|
||||||
|
duration_days: number
|
||||||
|
redeem_before?: string
|
||||||
|
quantity: number
|
||||||
|
note?: string
|
||||||
|
} = {
|
||||||
|
benefit_kind: form.value.benefit_kind,
|
||||||
|
duration_days: form.value.duration_days,
|
||||||
|
quantity: form.value.quantity,
|
||||||
|
}
|
||||||
|
if (form.value.benefit_kind === 'plan') payload.plan_id = form.value.plan_id
|
||||||
|
if (form.value.benefit_kind === 'units') payload.units = form.value.units
|
||||||
|
if (form.value.redeem_before) payload.redeem_before = new Date(form.value.redeem_before).toISOString()
|
||||||
|
if (form.value.note.trim()) payload.note = form.value.note.trim()
|
||||||
|
|
||||||
|
const resp = await createAdminRedemptionCodes(auth.token, payload)
|
||||||
|
generated.value = resp.codes
|
||||||
|
generateMessage.value = resp.message
|
||||||
|
await load(1)
|
||||||
|
} catch (err) {
|
||||||
|
generateError.value = errorText(err, '生成兑换码失败')
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyGenerated() {
|
||||||
|
if (!generatedText.value) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(generatedText.value)
|
||||||
|
copyMessage.value = '已复制全部兑换码'
|
||||||
|
} catch {
|
||||||
|
copyMessage.value = '自动复制失败,请手动选择文本'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCode(item: AdminRedemptionCodeView) {
|
||||||
|
if (!auth.token || item.redeemed_at) return
|
||||||
|
updatingId.value = item.id
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
await updateAdminRedemptionCode(auth.token, item.id, !item.is_active)
|
||||||
|
item.is_active = !item.is_active
|
||||||
|
} catch (err) {
|
||||||
|
error.value = errorText(err, '更新兑换码失败')
|
||||||
|
} finally {
|
||||||
|
updatingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(item: AdminRedemptionCodeView) {
|
||||||
|
if (item.redeemed_at) return '已兑换'
|
||||||
|
if (!item.is_active) return '已停用'
|
||||||
|
if (item.redeem_before && new Date(item.redeem_before).getTime() <= Date.now()) return '已过期'
|
||||||
|
return '待兑换'
|
||||||
|
}
|
||||||
|
|
||||||
|
function benefitLabel(item: AdminRedemptionCodeView) {
|
||||||
|
return item.benefit_kind === 'plan' ? item.plan_name ?? '未知套餐' : `${item.units ?? 0} 次`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => load())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">兑换码</h2>
|
||||||
|
<p class="text-sm text-slate-600">生成套餐卡或限时次数卡。完整兑换码只在生成后显示一次。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-900">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="text-sm font-medium text-slate-900">生成兑换码</div>
|
||||||
|
<div class="mt-4 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">卡类型</div>
|
||||||
|
<select v-model="form.benefit_kind" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm">
|
||||||
|
<option value="plan">套餐卡</option>
|
||||||
|
<option value="units">次数卡</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-if="form.benefit_kind === 'plan'" class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">对应套餐</div>
|
||||||
|
<select v-model="form.plan_id" class="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm" required>
|
||||||
|
<option v-for="plan in plans" :key="plan.id" :value="plan.id">{{ plan.name }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label v-else class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">可用次数</div>
|
||||||
|
<input v-model.number="form.units" type="number" min="1" max="10000000" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">权益有效天数</div>
|
||||||
|
<input v-model.number="form.duration_days" type="number" min="1" max="3650" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">生成数量</div>
|
||||||
|
<input v-model.number="form.quantity" type="number" min="1" max="200" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1">
|
||||||
|
<div class="text-xs font-medium text-slate-600">兑换截止时间(可选)</div>
|
||||||
|
<input v-model="form.redeem_before" type="datetime-local" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" />
|
||||||
|
</label>
|
||||||
|
<label class="space-y-1 md:col-span-3">
|
||||||
|
<div class="text-xs font-medium text-slate-600">备注(可选)</div>
|
||||||
|
<input v-model="form.note" type="text" maxlength="500" class="w-full rounded-md border border-slate-200 px-3 py-2 text-sm" placeholder="例如:活动批次、渠道或用途" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mt-4 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="generating" @click="generate">
|
||||||
|
{{ generating ? '生成中…' : '生成兑换码' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="generateError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ generateError }}</div>
|
||||||
|
|
||||||
|
<div v-if="generated.length > 0" class="mt-4 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium text-amber-950">{{ generateMessage }}</div>
|
||||||
|
<div class="text-xs text-amber-800">离开或刷新页面后无法再次查看完整兑换码。</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="rounded-md border border-amber-300 bg-white px-3 py-1.5 text-xs text-amber-900" @click="copyGenerated">复制全部</button>
|
||||||
|
</div>
|
||||||
|
<textarea :value="generatedText" readonly class="mt-3 h-36 w-full rounded-md border border-amber-200 bg-white px-3 py-2 font-mono text-sm text-slate-800"></textarea>
|
||||||
|
<div v-if="copyMessage" class="mt-2 text-xs text-amber-900">{{ copyMessage }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="text-sm font-medium text-slate-900">兑换码记录({{ total }})</div>
|
||||||
|
<button type="button" class="text-xs text-indigo-600 hover:text-indigo-700" @click="load(page)">刷新</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="mt-4 text-sm text-slate-600">加载中…</div>
|
||||||
|
<div v-else-if="codes.length === 0" class="mt-4 text-sm text-slate-600">暂无兑换码</div>
|
||||||
|
<div v-else class="mt-4 overflow-auto">
|
||||||
|
<table class="min-w-full text-left text-sm">
|
||||||
|
<thead class="text-xs text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 pr-4">标识</th><th class="py-2 pr-4">权益</th><th class="py-2 pr-4">时长</th>
|
||||||
|
<th class="py-2 pr-4">状态</th><th class="py-2 pr-4">兑换人</th><th class="py-2 pr-4">截止时间</th>
|
||||||
|
<th class="py-2 pr-4">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-slate-700">
|
||||||
|
<tr v-for="item in codes" :key="item.id" class="border-t border-slate-100">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">{{ item.code_hint }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ benefitLabel(item) }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ item.duration_days }} 天</td>
|
||||||
|
<td class="py-2 pr-4">{{ statusLabel(item) }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ item.redeemed_username ?? '—' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ item.redeem_before ? new Date(item.redeem_before).toLocaleString() : '不限' }}</td>
|
||||||
|
<td class="py-2 pr-4">
|
||||||
|
<button v-if="!item.redeemed_at" type="button" class="text-xs text-indigo-600 disabled:opacity-50" :disabled="updatingId === item.id" @click="toggleCode(item)">
|
||||||
|
{{ item.is_active ? '停用' : '启用' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-slate-400">不可修改</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page <= 1 || loading" @click="load(page - 1)">上一页</button>
|
||||||
|
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
|
<button type="button" class="rounded border border-slate-200 px-3 py-1.5 disabled:opacity-40" :disabled="page >= totalPages || loading" @click="load(page + 1)">下一页</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
getUsage,
|
getUsage,
|
||||||
listInvoices,
|
listInvoices,
|
||||||
listPlans,
|
listPlans,
|
||||||
|
listUserRedemptions,
|
||||||
|
redeemCode,
|
||||||
type InvoiceView,
|
type InvoiceView,
|
||||||
type PlanView,
|
type PlanView,
|
||||||
type SubscriptionView,
|
type SubscriptionView,
|
||||||
|
type UserRedemptionView,
|
||||||
type UsageResponse,
|
type UsageResponse,
|
||||||
} from '@/services/api'
|
} from '@/services/api'
|
||||||
import { ApiError } from '@/services/http'
|
import { ApiError } from '@/services/http'
|
||||||
@@ -25,22 +28,31 @@ const plans = ref<PlanView[]>([])
|
|||||||
const subscription = ref<SubscriptionView | null>(null)
|
const subscription = ref<SubscriptionView | null>(null)
|
||||||
const usage = ref<UsageResponse | null>(null)
|
const usage = ref<UsageResponse | null>(null)
|
||||||
const invoices = ref<InvoiceView[]>([])
|
const invoices = ref<InvoiceView[]>([])
|
||||||
|
const redemptions = ref<UserRedemptionView[]>([])
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
|
const redemptionCode = ref('')
|
||||||
|
const redemptionBusy = ref(false)
|
||||||
|
const redemptionMessage = ref<string | null>(null)
|
||||||
|
const redemptionError = ref<string | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
async function loadAll() {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const [p, s, u, inv] = await Promise.all([
|
const [p, s, u, inv, redeemed] = await Promise.all([
|
||||||
listPlans(),
|
listPlans(),
|
||||||
getSubscription(auth.token),
|
getSubscription(auth.token),
|
||||||
getUsage(auth.token),
|
getUsage(auth.token),
|
||||||
listInvoices(auth.token),
|
listInvoices(auth.token),
|
||||||
|
listUserRedemptions(auth.token),
|
||||||
])
|
])
|
||||||
plans.value = p.plans
|
plans.value = p.plans
|
||||||
subscription.value = s.subscription
|
subscription.value = s.subscription
|
||||||
usage.value = u
|
usage.value = u
|
||||||
invoices.value = inv.invoices
|
invoices.value = inv.invoices
|
||||||
|
redemptions.value = redeemed.redemptions
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
error.value = `[${err.code}] ${err.message}`
|
error.value = `[${err.code}] ${err.message}`
|
||||||
@@ -50,7 +62,37 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
onMounted(loadAll)
|
||||||
|
|
||||||
|
async function submitRedemption() {
|
||||||
|
if (!auth.token || !redemptionCode.value.trim()) return
|
||||||
|
redemptionBusy.value = true
|
||||||
|
redemptionMessage.value = null
|
||||||
|
redemptionError.value = null
|
||||||
|
try {
|
||||||
|
const resp = await redeemCode(auth.token, redemptionCode.value.trim())
|
||||||
|
redemptionMessage.value = `${resp.message},有效至 ${new Date(resp.benefit_expires_at).toLocaleString()}`
|
||||||
|
redemptionCode.value = ''
|
||||||
|
const [s, u, redeemed] = await Promise.all([
|
||||||
|
getSubscription(auth.token),
|
||||||
|
getUsage(auth.token),
|
||||||
|
listUserRedemptions(auth.token),
|
||||||
|
])
|
||||||
|
subscription.value = s.subscription
|
||||||
|
usage.value = u
|
||||||
|
redemptions.value = redeemed.redemptions
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
redemptionError.value = `[${err.code}] ${err.message}`
|
||||||
|
} else {
|
||||||
|
redemptionError.value = '兑换失败,请稍后再试'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
redemptionBusy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openCheckout(planId: string) {
|
async function openCheckout(planId: string) {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
@@ -117,6 +159,9 @@ async function openPortal() {
|
|||||||
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
|
<div v-if="(usage?.bonus_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
|
||||||
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
|
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="(usage?.redeemed_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
|
||||||
|
另有 {{ usage?.redeemed_units ?? 0 }} 次限时兑换额度
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<div class="text-xs font-medium text-slate-500">周期</div>
|
<div class="text-xs font-medium text-slate-500">周期</div>
|
||||||
@@ -138,6 +183,43 @@ async function openPortal() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
|
<div class="text-sm font-medium text-slate-900">兑换套餐或次数</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">输入管理员发放的兑换码。次数卡会按最早到期顺序使用。</p>
|
||||||
|
<form class="mt-3 flex flex-col gap-2 sm:flex-row" @submit.prevent="submitRedemption">
|
||||||
|
<input
|
||||||
|
v-model="redemptionCode"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="min-w-0 flex-1 rounded-md border border-slate-200 bg-white px-3 py-2 font-mono text-sm uppercase text-slate-800"
|
||||||
|
placeholder="IMG-XXXX-XXXX-XXXX-XXXX"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="submit" class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:opacity-50" :disabled="redemptionBusy">
|
||||||
|
{{ redemptionBusy ? '兑换中…' : '立即兑换' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div v-if="redemptionMessage" class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-900">{{ redemptionMessage }}</div>
|
||||||
|
<div v-if="redemptionError" class="mt-3 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-900">{{ redemptionError }}</div>
|
||||||
|
|
||||||
|
<div v-if="redemptions.length > 0" class="mt-4 overflow-auto">
|
||||||
|
<table class="min-w-full text-left text-sm">
|
||||||
|
<thead class="text-xs text-slate-500">
|
||||||
|
<tr><th class="py-2 pr-4">兑换码</th><th class="py-2 pr-4">权益</th><th class="py-2 pr-4">剩余</th><th class="py-2 pr-4">有效期</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="text-slate-700">
|
||||||
|
<tr v-for="item in redemptions" :key="item.id" class="border-t border-slate-100">
|
||||||
|
<td class="py-2 pr-4 font-mono text-xs">{{ item.code_hint }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ item.benefit_kind === 'plan' ? item.plan_name : `${item.units ?? 0} 次` }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ item.benefit_kind === 'units' ? `${item.remaining_units ?? 0} 次` : '—' }}</td>
|
||||||
|
<td class="py-2 pr-4">{{ new Date(item.benefit_expires_at).toLocaleString() }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
<div class="text-sm font-medium text-slate-900">充值额度 / 购买套餐</div>
|
<div class="text-sm font-medium text-slate-900">充值额度 / 购买套餐</div>
|
||||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { getSubscription, getUsage, sendVerification } from '@/services/api'
|
import { getProfile, getSubscription, getUsage, sendVerification } from '@/services/api'
|
||||||
import { ApiError } from '@/services/http'
|
import { ApiError } from '@/services/http'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
@@ -21,12 +21,20 @@ const sendingVerification = ref(false)
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
try {
|
try {
|
||||||
const [u, s] = await Promise.all([getUsage(auth.token), getSubscription(auth.token)])
|
const [u, s, profile] = await Promise.all([
|
||||||
|
getUsage(auth.token),
|
||||||
|
getSubscription(auth.token),
|
||||||
|
getProfile(auth.token),
|
||||||
|
])
|
||||||
usage.value = u
|
usage.value = u
|
||||||
subscription.value = s.subscription
|
subscription.value = s.subscription
|
||||||
|
auth.updateUser(profile)
|
||||||
|
|
||||||
if (route.query.welcome === '1') {
|
if (route.query.welcome === '1') {
|
||||||
alert.value = { type: 'success', message: '欢迎加入 ImageForge!请尽快完成邮箱验证。' }
|
alert.value = {
|
||||||
|
type: 'success',
|
||||||
|
message: profile.email_verified ? '欢迎加入 ImageForge!' : '欢迎加入 ImageForge!请尽快完成邮箱验证。',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
@@ -45,6 +53,7 @@ async function resendVerification() {
|
|||||||
alert.value = null
|
alert.value = null
|
||||||
try {
|
try {
|
||||||
const resp = await sendVerification(auth.token)
|
const resp = await sendVerification(auth.token)
|
||||||
|
auth.updateUser(await getProfile(auth.token))
|
||||||
alert.value = { type: 'success', message: resp.message }
|
alert.value = { type: 'success', message: resp.message }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ async function resendVerification() {
|
|||||||
verificationError.value = null
|
verificationError.value = null
|
||||||
try {
|
try {
|
||||||
const resp = await sendVerification(auth.token)
|
const resp = await sendVerification(auth.token)
|
||||||
|
auth.updateUser(await getProfile(auth.token))
|
||||||
verificationMessage.value = resp.message
|
verificationMessage.value = resp.message
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface UsageResponse {
|
|||||||
used_units: number
|
used_units: number
|
||||||
included_units: number
|
included_units: number
|
||||||
bonus_units: number
|
bonus_units: number
|
||||||
|
redeemed_units: number
|
||||||
total_units: number
|
total_units: number
|
||||||
remaining_units: number
|
remaining_units: number
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,38 @@ export async function createPortal(token: string): Promise<{ url: string }> {
|
|||||||
return apiJson<{ url: string }>('/api/v1/billing/portal', undefined, token, { method: 'POST' })
|
return apiJson<{ url: string }>('/api/v1/billing/portal', undefined, token, { method: 'POST' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RedemptionKind = 'plan' | 'units'
|
||||||
|
|
||||||
|
export interface UserRedemptionView {
|
||||||
|
id: string
|
||||||
|
code_hint: string
|
||||||
|
benefit_kind: RedemptionKind
|
||||||
|
plan_name?: string | null
|
||||||
|
units?: number | null
|
||||||
|
remaining_units?: number | null
|
||||||
|
benefit_starts_at: string
|
||||||
|
benefit_expires_at: string
|
||||||
|
redeemed_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemCodeResponse {
|
||||||
|
message: string
|
||||||
|
benefit_kind: RedemptionKind
|
||||||
|
plan_id?: string | null
|
||||||
|
plan_name?: string | null
|
||||||
|
units?: number | null
|
||||||
|
benefit_starts_at: string
|
||||||
|
benefit_expires_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function redeemCode(token: string, code: string): Promise<RedeemCodeResponse> {
|
||||||
|
return apiJson<RedeemCodeResponse>('/api/v1/redemptions/redeem', { code }, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUserRedemptions(token: string): Promise<{ redemptions: UserRedemptionView[] }> {
|
||||||
|
return apiGet<{ redemptions: UserRedemptionView[] }>('/api/v1/redemptions', token)
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiKeyView {
|
export interface ApiKeyView {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -411,6 +444,7 @@ export interface AdminCreditResponse {
|
|||||||
period_end: string
|
period_end: string
|
||||||
used_units: number
|
used_units: number
|
||||||
bonus_units: number
|
bonus_units: number
|
||||||
|
redeemed_units: number
|
||||||
total_units: number
|
total_units: number
|
||||||
remaining_units: number
|
remaining_units: number
|
||||||
}
|
}
|
||||||
@@ -525,6 +559,93 @@ export async function sendMailTest(token: string, to?: string): Promise<{ messag
|
|||||||
return apiJson<{ message: string }>('/api/v1/admin/mail/test', { to }, token)
|
return apiJson<{ message: string }>('/api/v1/admin/mail/test', { to }, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminAuthConfig {
|
||||||
|
email_verification_required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuthConfig(token: string): Promise<AdminAuthConfig> {
|
||||||
|
return apiGet<AdminAuthConfig>('/api/v1/admin/auth', token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAuthConfig(
|
||||||
|
token: string,
|
||||||
|
emailVerificationRequired: boolean,
|
||||||
|
): Promise<AdminAuthConfig> {
|
||||||
|
return apiJson<AdminAuthConfig>(
|
||||||
|
'/api/v1/admin/auth',
|
||||||
|
{ email_verification_required: emailVerificationRequired },
|
||||||
|
token,
|
||||||
|
{ method: 'PUT' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRedemptionCodeView {
|
||||||
|
id: string
|
||||||
|
code_hint: string
|
||||||
|
benefit_kind: RedemptionKind
|
||||||
|
plan_id?: string | null
|
||||||
|
plan_name?: string | null
|
||||||
|
units?: number | null
|
||||||
|
duration_days: number
|
||||||
|
redeem_before?: string | null
|
||||||
|
is_active: boolean
|
||||||
|
note?: string | null
|
||||||
|
created_at: string
|
||||||
|
redeemed_at?: string | null
|
||||||
|
redeemed_by?: string | null
|
||||||
|
redeemed_username?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedRedemptionCode {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
code_hint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAdminRedemptionCodes(
|
||||||
|
token: string,
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }> {
|
||||||
|
const qs = new URLSearchParams({ page: String(page), limit: String(limit) }).toString()
|
||||||
|
return apiGet<{ codes: AdminRedemptionCodeView[]; page: number; limit: number; total: number }>(
|
||||||
|
`/api/v1/admin/redemption-codes?${qs}`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminRedemptionCodes(
|
||||||
|
token: string,
|
||||||
|
payload: {
|
||||||
|
benefit_kind: RedemptionKind
|
||||||
|
plan_id?: string
|
||||||
|
units?: number
|
||||||
|
duration_days: number
|
||||||
|
redeem_before?: string
|
||||||
|
quantity?: number
|
||||||
|
note?: string
|
||||||
|
},
|
||||||
|
): Promise<{ message: string; codes: GeneratedRedemptionCode[] }> {
|
||||||
|
return apiJson<{ message: string; codes: GeneratedRedemptionCode[] }>(
|
||||||
|
'/api/v1/admin/redemption-codes',
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminRedemptionCode(
|
||||||
|
token: string,
|
||||||
|
codeId: string,
|
||||||
|
isActive: boolean,
|
||||||
|
): Promise<{ message: string }> {
|
||||||
|
return apiJson<{ message: string }>(
|
||||||
|
`/api/v1/admin/redemption-codes/${codeId}`,
|
||||||
|
{ is_active: isActive },
|
||||||
|
token,
|
||||||
|
{ method: 'PUT' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStorageEndpoint {
|
export interface AdminStorageEndpoint {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
82
migrations/006_redemption_codes.sql
Normal file
82
migrations/006_redemption_codes.sql
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
BEGIN;
|
||||||
|
|
||||||
|
INSERT INTO system_config (key, value, description)
|
||||||
|
VALUES ('auth', '{"email_verification_required": true}', '认证功能开关')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE usage_periods
|
||||||
|
ADD COLUMN IF NOT EXISTS grant_used_units INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE usage_periods
|
||||||
|
ADD CONSTRAINT usage_periods_grant_used_units_nonnegative
|
||||||
|
CHECK (grant_used_units >= 0 AND grant_used_units <= used_units);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS redemption_codes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code_hash VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
code_hint VARCHAR(32) NOT NULL,
|
||||||
|
benefit_kind VARCHAR(16) NOT NULL CHECK (benefit_kind IN ('plan', 'units')),
|
||||||
|
plan_id UUID REFERENCES plans(id),
|
||||||
|
units INTEGER,
|
||||||
|
duration_days INTEGER NOT NULL,
|
||||||
|
redeem_before TIMESTAMPTZ,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
note TEXT,
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CHECK (duration_days BETWEEN 1 AND 3650),
|
||||||
|
CHECK (
|
||||||
|
(benefit_kind = 'plan' AND plan_id IS NOT NULL AND units IS NULL)
|
||||||
|
OR
|
||||||
|
(benefit_kind = 'units' AND plan_id IS NULL AND units BETWEEN 1 AND 10000000)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_at
|
||||||
|
ON redemption_codes(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemption_codes_active_deadline
|
||||||
|
ON redemption_codes(is_active, redeem_before);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS redemption_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
code_id UUID NOT NULL UNIQUE REFERENCES redemption_codes(id),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
benefit_kind VARCHAR(16) NOT NULL CHECK (benefit_kind IN ('plan', 'units')),
|
||||||
|
plan_id UUID REFERENCES plans(id),
|
||||||
|
units INTEGER,
|
||||||
|
benefit_starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
benefit_expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
redeemed_ip INET,
|
||||||
|
redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CHECK (benefit_expires_at > benefit_starts_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redemption_records_user_time
|
||||||
|
ON redemption_records(user_id, redeemed_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS unit_grants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
redemption_record_id UUID NOT NULL UNIQUE REFERENCES redemption_records(id) ON DELETE CASCADE,
|
||||||
|
total_units INTEGER NOT NULL CHECK (total_units > 0),
|
||||||
|
remaining_units INTEGER NOT NULL CHECK (remaining_units >= 0 AND remaining_units <= total_units),
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CHECK (expires_at > starts_at)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_unit_grants_available
|
||||||
|
ON unit_grants(user_id, expires_at, created_at)
|
||||||
|
WHERE remaining_units > 0;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
112
src/api/admin.rs
112
src/api/admin.rs
@@ -4,7 +4,9 @@ use crate::error::{AppError, ErrorCode};
|
|||||||
use crate::services::billing;
|
use crate::services::billing;
|
||||||
use crate::services::mail;
|
use crate::services::mail;
|
||||||
use crate::services::settings;
|
use crate::services::settings;
|
||||||
use crate::services::settings::{MailConfigStored, MailCustomSmtp, StripeConfigStored};
|
use crate::services::settings::{
|
||||||
|
AuthConfigStored, MailConfigStored, MailCustomSmtp, StripeConfigStored,
|
||||||
|
};
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||||
@@ -36,6 +38,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
.route("/admin/mail", get(get_mail_config))
|
.route("/admin/mail", get(get_mail_config))
|
||||||
.route("/admin/mail", put(update_mail_config))
|
.route("/admin/mail", put(update_mail_config))
|
||||||
.route("/admin/mail/test", post(test_mail))
|
.route("/admin/mail/test", post(test_mail))
|
||||||
|
.route("/admin/auth", get(get_auth_config))
|
||||||
|
.route("/admin/auth", put(update_auth_config))
|
||||||
.route("/admin/config", get(get_config))
|
.route("/admin/config", get(get_config))
|
||||||
.route("/admin/config", put(update_config))
|
.route("/admin/config", put(update_config))
|
||||||
}
|
}
|
||||||
@@ -180,7 +184,13 @@ async fn get_stats(
|
|||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?;
|
||||||
|
|
||||||
let active_subscriptions: i64 = sqlx::query_scalar(
|
let active_subscriptions: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM subscriptions WHERE status IN ('active', 'trialing', 'past_due')",
|
r#"
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE status IN ('active', 'trialing', 'past_due')
|
||||||
|
AND current_period_start <= NOW()
|
||||||
|
AND current_period_end > NOW()
|
||||||
|
"#,
|
||||||
)
|
)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
@@ -689,8 +699,9 @@ struct GrantCreditsResponse {
|
|||||||
period_end: DateTime<Utc>,
|
period_end: DateTime<Utc>,
|
||||||
used_units: i32,
|
used_units: i32,
|
||||||
bonus_units: i32,
|
bonus_units: i32,
|
||||||
total_units: i32,
|
redeemed_units: i64,
|
||||||
remaining_units: i32,
|
total_units: i64,
|
||||||
|
remaining_units: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grant_credits(
|
async fn grant_credits(
|
||||||
@@ -722,6 +733,9 @@ async fn grant_credits(
|
|||||||
SELECT id, plan_id, current_period_start, current_period_end
|
SELECT id, plan_id, current_period_start, current_period_end
|
||||||
FROM subscriptions
|
FROM subscriptions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
AND status IN ('active', 'trialing')
|
||||||
|
AND current_period_start <= NOW()
|
||||||
|
AND current_period_end > NOW()
|
||||||
ORDER BY current_period_end DESC
|
ORDER BY current_period_end DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
@@ -781,6 +795,7 @@ async fn grant_credits(
|
|||||||
struct UsageRow {
|
struct UsageRow {
|
||||||
used_units: i32,
|
used_units: i32,
|
||||||
bonus_units: i32,
|
bonus_units: i32,
|
||||||
|
grant_used_units: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
let usage = sqlx::query_as::<_, UsageRow>(
|
let usage = sqlx::query_as::<_, UsageRow>(
|
||||||
@@ -789,7 +804,7 @@ async fn grant_credits(
|
|||||||
SET bonus_units = bonus_units + $1,
|
SET bonus_units = bonus_units + $1,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE user_id = $2 AND period_start = $3 AND period_end = $4
|
WHERE user_id = $2 AND period_start = $3 AND period_end = $4
|
||||||
RETURNING used_units, bonus_units
|
RETURNING used_units, bonus_units, grant_used_units
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(req.units)
|
.bind(req.units)
|
||||||
@@ -818,8 +833,27 @@ async fn grant_credits(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
let total_units = plan_units + usage.bonus_units;
|
let redeemed_units: i64 = sqlx::query_scalar(
|
||||||
let remaining = (total_units - usage.used_units).max(0);
|
r#"
|
||||||
|
SELECT COALESCE(SUM(remaining_units), 0)::bigint
|
||||||
|
FROM unit_grants
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND starts_at <= NOW()
|
||||||
|
AND expires_at > NOW()
|
||||||
|
AND remaining_units > 0
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?;
|
||||||
|
let base_total = i64::from(plan_units.saturating_add(usage.bonus_units));
|
||||||
|
let base_used = i64::from(usage.used_units.saturating_sub(usage.grant_used_units));
|
||||||
|
let remaining = base_total
|
||||||
|
.saturating_sub(base_used)
|
||||||
|
.max(0)
|
||||||
|
.saturating_add(redeemed_units);
|
||||||
|
let total_units = i64::from(usage.used_units).saturating_add(remaining);
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok(Json(Envelope {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -829,6 +863,7 @@ async fn grant_credits(
|
|||||||
period_end,
|
period_end,
|
||||||
used_units: usage.used_units,
|
used_units: usage.used_units,
|
||||||
bonus_units: usage.bonus_units,
|
bonus_units: usage.bonus_units,
|
||||||
|
redeemed_units,
|
||||||
total_units,
|
total_units,
|
||||||
remaining_units: remaining,
|
remaining_units: remaining,
|
||||||
},
|
},
|
||||||
@@ -1274,6 +1309,69 @@ async fn update_stripe_config(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AuthConfigView {
|
||||||
|
email_verification_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_auth_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Envelope<AuthConfigView>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, _admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let config = settings::load_system_config::<AuthConfigStored>(&state, "auth")
|
||||||
|
.await?
|
||||||
|
.unwrap_or(AuthConfigStored {
|
||||||
|
email_verification_required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: AuthConfigView {
|
||||||
|
email_verification_required: config.email_verification_required,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct AuthConfigRequest {
|
||||||
|
email_verification_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_auth_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<AuthConfigRequest>,
|
||||||
|
) -> Result<Json<Envelope<AuthConfigView>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let config = AuthConfigStored {
|
||||||
|
email_verification_required: req.email_verification_required,
|
||||||
|
};
|
||||||
|
settings::upsert_system_config(
|
||||||
|
&state,
|
||||||
|
"auth",
|
||||||
|
serde_json::to_value(&config).map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "序列化认证配置失败").with_source(err)
|
||||||
|
})?,
|
||||||
|
Some("认证功能开关"),
|
||||||
|
Some(admin_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: AuthConfigView {
|
||||||
|
email_verification_required: config.email_verification_required,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct MailConfigView {
|
struct MailConfigView {
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::api::envelope::Envelope;
|
|||||||
use crate::auth;
|
use crate::auth;
|
||||||
use crate::error::{AppError, ErrorCode};
|
use crate::error::{AppError, ErrorCode};
|
||||||
use crate::services::mail;
|
use crate::services::mail;
|
||||||
|
use crate::services::settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
@@ -79,11 +80,13 @@ async fn register(
|
|||||||
validate_password(&req.password)?;
|
validate_password(&req.password)?;
|
||||||
|
|
||||||
let password_hash = hash_password(&req.password)?;
|
let password_hash = hash_password(&req.password)?;
|
||||||
|
let verification_required = settings::email_verification_required(&state).await?;
|
||||||
|
let verified_at = (!verification_required).then(Utc::now);
|
||||||
|
|
||||||
let user = sqlx::query_as::<_, UserRow>(
|
let user = sqlx::query_as::<_, UserRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO users (email, username, password_hash)
|
INSERT INTO users (email, username, password_hash, email_verified_at)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING
|
RETURNING
|
||||||
id,
|
id,
|
||||||
email,
|
email,
|
||||||
@@ -97,6 +100,7 @@ async fn register(
|
|||||||
.bind(req.email.to_lowercase())
|
.bind(req.email.to_lowercase())
|
||||||
.bind(&req.username)
|
.bind(&req.username)
|
||||||
.bind(password_hash)
|
.bind(password_hash)
|
||||||
|
.bind(verified_at)
|
||||||
.fetch_one(&state.db)
|
.fetch_one(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(map_unique_violation)?;
|
.map_err(map_unique_violation)?;
|
||||||
@@ -108,6 +112,7 @@ async fn register(
|
|||||||
&user.role,
|
&user.role,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
if verification_required {
|
||||||
let verification_token = generate_token();
|
let verification_token = generate_token();
|
||||||
let token_hash = sha256_hex(&verification_token);
|
let token_hash = sha256_hex(&verification_token);
|
||||||
let expires_at_db = Utc::now() + Duration::hours(24);
|
let expires_at_db = Utc::now() + Duration::hours(24);
|
||||||
@@ -123,7 +128,9 @@ async fn register(
|
|||||||
.bind(expires_at_db)
|
.bind(expires_at_db)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
.map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
|
||||||
|
})?;
|
||||||
|
|
||||||
let verification_url = format!(
|
let verification_url = format!(
|
||||||
"{}/verify-email?token={}",
|
"{}/verify-email?token={}",
|
||||||
@@ -135,6 +142,7 @@ async fn register(
|
|||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
||||||
})?;
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
let body = RegisterResponse {
|
let body = RegisterResponse {
|
||||||
user: UserView {
|
user: UserView {
|
||||||
@@ -145,7 +153,11 @@ async fn register(
|
|||||||
email_verified: user.email_verified_at.is_some(),
|
email_verified: user.email_verified_at.is_some(),
|
||||||
},
|
},
|
||||||
token,
|
token,
|
||||||
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
|
message: if verification_required {
|
||||||
|
"注册成功,验证邮件已发送至您的邮箱".to_string()
|
||||||
|
} else {
|
||||||
|
"注册成功".to_string()
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok(Json(Envelope {
|
||||||
@@ -213,6 +225,7 @@ async fn login(
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify_password(&req.password, &user.password_hash)?;
|
verify_password(&req.password, &user.password_hash)?;
|
||||||
|
let verification_required = settings::email_verification_required(&state).await?;
|
||||||
|
|
||||||
let (token, expires_at) = auth::issue_jwt(
|
let (token, expires_at) = auth::issue_jwt(
|
||||||
&state.config.jwt_secret,
|
&state.config.jwt_secret,
|
||||||
@@ -231,7 +244,7 @@ async fn login(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
email_verified: user.email_verified_at.is_some(),
|
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
@@ -248,6 +261,15 @@ async fn send_verification(
|
|||||||
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||||
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
|
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
|
||||||
|
|
||||||
|
if !settings::email_verification_required(&state).await? {
|
||||||
|
return Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: MessageResponse {
|
||||||
|
message: "邮箱验证功能当前已关闭,无需验证".to_string(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limit: 1 per minute per user
|
// Rate limit: 1 per minute per user
|
||||||
let key = format!(
|
let key = format!(
|
||||||
"rate:send_verification:{}:{}",
|
"rate:send_verification:{}:{}",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
|
|||||||
use crate::error::{AppError, ErrorCode};
|
use crate::error::{AppError, ErrorCode};
|
||||||
use crate::services::billing;
|
use crate::services::billing;
|
||||||
use crate::services::idempotency;
|
use crate::services::idempotency;
|
||||||
|
use crate::services::quota;
|
||||||
use crate::services::settings;
|
use crate::services::settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -153,7 +154,9 @@ async fn get_subscription(
|
|||||||
FROM subscriptions s
|
FROM subscriptions s
|
||||||
JOIN plans p ON p.id = s.plan_id
|
JOIN plans p ON p.id = s.plan_id
|
||||||
WHERE s.user_id = $1
|
WHERE s.user_id = $1
|
||||||
AND s.status IN ('active', 'trialing', 'past_due', 'canceled', 'incomplete')
|
AND s.status IN ('active', 'trialing', 'past_due')
|
||||||
|
AND s.current_period_start <= NOW()
|
||||||
|
AND s.current_period_end > NOW()
|
||||||
ORDER BY s.current_period_end DESC
|
ORDER BY s.current_period_end DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
@@ -239,11 +242,12 @@ async fn get_subscription(
|
|||||||
struct UsageResponse {
|
struct UsageResponse {
|
||||||
period_start: DateTime<Utc>,
|
period_start: DateTime<Utc>,
|
||||||
period_end: DateTime<Utc>,
|
period_end: DateTime<Utc>,
|
||||||
used_units: i32,
|
used_units: i64,
|
||||||
included_units: i32,
|
included_units: i64,
|
||||||
bonus_units: i32,
|
bonus_units: i64,
|
||||||
total_units: i32,
|
redeemed_units: i64,
|
||||||
remaining_units: i32,
|
total_units: i64,
|
||||||
|
remaining_units: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_usage(
|
async fn get_usage(
|
||||||
@@ -262,33 +266,7 @@ async fn get_usage(
|
|||||||
|
|
||||||
let billing = billing::get_user_billing(&state, user_id).await?;
|
let billing = billing::get_user_billing(&state, user_id).await?;
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
let usage = quota::user_usage_balance(&state, &billing).await?;
|
||||||
struct UsageRow {
|
|
||||||
used_units: i32,
|
|
||||||
bonus_units: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = sqlx::query_as::<_, UsageRow>(
|
|
||||||
r#"
|
|
||||||
SELECT used_units, bonus_units
|
|
||||||
FROM usage_periods
|
|
||||||
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(user_id)
|
|
||||||
.bind(billing.period_start)
|
|
||||||
.bind(billing.period_end)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
|
|
||||||
.unwrap_or(UsageRow {
|
|
||||||
used_units: 0,
|
|
||||||
bonus_units: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let included = billing.plan.included_units_per_period;
|
|
||||||
let total = included + usage.bonus_units;
|
|
||||||
let remaining = (total - usage.used_units).max(0);
|
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok(Json(Envelope {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -296,10 +274,11 @@ async fn get_usage(
|
|||||||
period_start: billing.period_start,
|
period_start: billing.period_start,
|
||||||
period_end: billing.period_end,
|
period_end: billing.period_end,
|
||||||
used_units: usage.used_units,
|
used_units: usage.used_units,
|
||||||
included_units: included,
|
included_units: usage.included_units,
|
||||||
bonus_units: usage.bonus_units,
|
bonus_units: usage.bonus_units,
|
||||||
total_units: total,
|
redeemed_units: usage.redeemed_units,
|
||||||
remaining_units: remaining,
|
total_units: usage.total_units,
|
||||||
|
remaining_units: usage.remaining_units,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1044,41 +1044,7 @@ async fn ensure_quota_available(
|
|||||||
ctx: &BillingContext,
|
ctx: &BillingContext,
|
||||||
needed_units: i32,
|
needed_units: i32,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
if needed_units <= 0 {
|
quota::ensure_user_units(state, ctx, needed_units).await
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
|
||||||
struct UsageRow {
|
|
||||||
used_units: i32,
|
|
||||||
bonus_units: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = sqlx::query_as::<_, UsageRow>(
|
|
||||||
r#"
|
|
||||||
SELECT used_units, bonus_units
|
|
||||||
FROM usage_periods
|
|
||||||
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(ctx.user_id)
|
|
||||||
.bind(ctx.period_start)
|
|
||||||
.bind(ctx.period_end)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
|
|
||||||
.unwrap_or(UsageRow {
|
|
||||||
used_units: 0,
|
|
||||||
bonus_units: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let total_units = ctx.plan.included_units_per_period + usage.bonus_units;
|
|
||||||
let remaining = total_units - usage.used_units;
|
|
||||||
if remaining < needed_units {
|
|
||||||
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -1252,48 +1218,7 @@ async fn charge_one_unit(
|
|||||||
bytes_in: u64,
|
bytes_in: u64,
|
||||||
bytes_out: u64,
|
bytes_out: u64,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
sqlx::query(
|
quota::consume_user_unit(tx, billing, bytes_in, bytes_out).await?;
|
||||||
r#"
|
|
||||||
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(billing.user_id)
|
|
||||||
.bind(billing.subscription_id)
|
|
||||||
.bind(billing.period_start)
|
|
||||||
.bind(billing.period_end)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?;
|
|
||||||
|
|
||||||
let updated: Option<i32> = sqlx::query_scalar(
|
|
||||||
r#"
|
|
||||||
UPDATE usage_periods
|
|
||||||
SET used_units = used_units + 1,
|
|
||||||
bytes_in = bytes_in + $1,
|
|
||||||
bytes_out = bytes_out + $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE user_id = $3
|
|
||||||
AND period_start = $4
|
|
||||||
AND period_end = $5
|
|
||||||
AND used_units + 1 <= $6 + bonus_units
|
|
||||||
RETURNING used_units
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(bytes_in as i64)
|
|
||||||
.bind(bytes_out as i64)
|
|
||||||
.bind(billing.user_id)
|
|
||||||
.bind(billing.period_start)
|
|
||||||
.bind(billing.period_end)
|
|
||||||
.bind(billing.plan.included_units_per_period)
|
|
||||||
.fetch_optional(&mut **tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减配额失败").with_source(err))?;
|
|
||||||
|
|
||||||
if updated.is_none() {
|
|
||||||
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -130,10 +130,13 @@ async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result<Option<Princip
|
|||||||
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
|
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let verification_required =
|
||||||
|
crate::services::settings::email_verification_required(state).await?;
|
||||||
|
|
||||||
Ok(Some(Principal::User {
|
Ok(Some(Principal::User {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
email_verified: user.email_verified_at.is_some(),
|
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,11 +208,14 @@ async fn try_api_key(
|
|||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
let verification_required =
|
||||||
|
crate::services::settings::email_verification_required(state).await?;
|
||||||
|
|
||||||
Ok(Some(Principal::ApiKey {
|
Ok(Some(Principal::ApiKey {
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
api_key_id: row.id,
|
api_key_id: row.id,
|
||||||
role: row.user_role,
|
role: row.user_role,
|
||||||
email_verified: row.email_verified_at.is_some(),
|
email_verified: row.email_verified_at.is_some() || !verification_required,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod context;
|
|||||||
mod downloads;
|
mod downloads;
|
||||||
mod envelope;
|
mod envelope;
|
||||||
mod health;
|
mod health;
|
||||||
|
mod redemption;
|
||||||
mod response;
|
mod response;
|
||||||
mod tasks;
|
mod tasks;
|
||||||
mod user;
|
mod user;
|
||||||
@@ -62,6 +63,7 @@ fn v1_router() -> Router<AppState> {
|
|||||||
.merge(billing::router())
|
.merge(billing::router())
|
||||||
.merge(webhooks::router())
|
.merge(webhooks::router())
|
||||||
.merge(user::router())
|
.merge(user::router())
|
||||||
|
.merge(redemption::router())
|
||||||
.merge(admin::router())
|
.merge(admin::router())
|
||||||
.merge(admin_storage::router())
|
.merge(admin_storage::router())
|
||||||
.fallback(response::not_found)
|
.fallback(response::not_found)
|
||||||
|
|||||||
883
src/api/redemption.rs
Normal file
883
src/api/redemption.rs
Normal file
@@ -0,0 +1,883 @@
|
|||||||
|
use crate::api::envelope::Envelope;
|
||||||
|
use crate::api::{admin, context};
|
||||||
|
use crate::error::{AppError, ErrorCode};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::routing::{get, post, put};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use rand::RngCore;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const CODE_PREFIX: &str = "IMG";
|
||||||
|
const CODE_ALPHABET: &[u8; 32] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/redemptions", get(list_user_redemptions))
|
||||||
|
.route("/redemptions/redeem", post(redeem_code))
|
||||||
|
.route(
|
||||||
|
"/admin/redemption-codes",
|
||||||
|
get(list_admin_codes).post(create_codes),
|
||||||
|
)
|
||||||
|
.route("/admin/redemption-codes/{code_id}", put(update_code_status))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PagingQuery {
|
||||||
|
page: Option<u32>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow, Serialize)]
|
||||||
|
struct AdminCodeView {
|
||||||
|
id: Uuid,
|
||||||
|
code_hint: String,
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
plan_name: Option<String>,
|
||||||
|
units: Option<i32>,
|
||||||
|
duration_days: i32,
|
||||||
|
redeem_before: Option<DateTime<Utc>>,
|
||||||
|
is_active: bool,
|
||||||
|
note: Option<String>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
redeemed_at: Option<DateTime<Utc>>,
|
||||||
|
redeemed_by: Option<Uuid>,
|
||||||
|
redeemed_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct AdminCodesResponse {
|
||||||
|
codes: Vec<AdminCodeView>,
|
||||||
|
page: u32,
|
||||||
|
limit: u32,
|
||||||
|
total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_admin_codes(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Query(query): Query<PagingQuery>,
|
||||||
|
) -> Result<Json<Envelope<AdminCodesResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, _admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let page = query.page.unwrap_or(1).max(1);
|
||||||
|
let limit = query.limit.unwrap_or(50).clamp(1, 200);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM redemption_codes")
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
|
let codes = sqlx::query_as::<_, AdminCodeView>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.code_hint,
|
||||||
|
c.benefit_kind,
|
||||||
|
c.plan_id,
|
||||||
|
p.name AS plan_name,
|
||||||
|
c.units,
|
||||||
|
c.duration_days,
|
||||||
|
c.redeem_before,
|
||||||
|
c.is_active,
|
||||||
|
c.note,
|
||||||
|
c.created_at,
|
||||||
|
r.redeemed_at,
|
||||||
|
r.user_id AS redeemed_by,
|
||||||
|
u.username AS redeemed_username
|
||||||
|
FROM redemption_codes c
|
||||||
|
LEFT JOIN plans p ON p.id = c.plan_id
|
||||||
|
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||||
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
ORDER BY c.created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(i64::from(limit))
|
||||||
|
.bind(i64::from(offset))
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: AdminCodesResponse {
|
||||||
|
codes,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateCodesRequest {
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
units: Option<i32>,
|
||||||
|
duration_days: i32,
|
||||||
|
redeem_before: Option<DateTime<Utc>>,
|
||||||
|
quantity: Option<u32>,
|
||||||
|
note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GeneratedCodeView {
|
||||||
|
id: Uuid,
|
||||||
|
code: String,
|
||||||
|
code_hint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateCodesResponse {
|
||||||
|
message: String,
|
||||||
|
codes: Vec<GeneratedCodeView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_codes(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<CreateCodesRequest>,
|
||||||
|
) -> Result<Json<Envelope<CreateCodesResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let quantity = req.quantity.unwrap_or(1);
|
||||||
|
if !(1..=200).contains(&quantity) {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"quantity 需在 1-200 之间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !(1..=3650).contains(&req.duration_days) {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"duration_days 需在 1-3650 之间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if req
|
||||||
|
.redeem_before
|
||||||
|
.is_some_and(|deadline| deadline <= Utc::now())
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"兑换截止时间必须晚于当前时间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let benefit_kind = req.benefit_kind.trim().to_ascii_lowercase();
|
||||||
|
let (plan_id, units) = match benefit_kind.as_str() {
|
||||||
|
"plan" => {
|
||||||
|
let plan_id = req
|
||||||
|
.plan_id
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "套餐卡必须选择套餐"))?;
|
||||||
|
let available: bool = sqlx::query_scalar(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM plans WHERE id = $1 AND is_active = true)",
|
||||||
|
)
|
||||||
|
.bind(plan_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询套餐失败").with_source(err))?;
|
||||||
|
if !available {
|
||||||
|
return Err(AppError::new(ErrorCode::NotFound, "套餐不存在或已停用"));
|
||||||
|
}
|
||||||
|
(Some(plan_id), None)
|
||||||
|
}
|
||||||
|
"units" => {
|
||||||
|
let units = req
|
||||||
|
.units
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "次数卡必须填写次数"))?;
|
||||||
|
if !(1..=10_000_000).contains(&units) {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"units 需在 1-10000000 之间",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, Some(units))
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"benefit_kind 仅支持 plan/units",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let note = req
|
||||||
|
.note
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
if note
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|value| value.chars().count() > 500)
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"备注不能超过 500 字",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
let mut generated = Vec::with_capacity(quantity as usize);
|
||||||
|
|
||||||
|
for _ in 0..quantity {
|
||||||
|
let code = generate_code();
|
||||||
|
let compact = normalize_code(&code)
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::Internal, "生成兑换码格式失败"))?;
|
||||||
|
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||||
|
let code_hint = code_hint(&compact);
|
||||||
|
let id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO redemption_codes (
|
||||||
|
code_hash, code_hint, benefit_kind, plan_id, units,
|
||||||
|
duration_days, redeem_before, note, created_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(code_hash)
|
||||||
|
.bind(&code_hint)
|
||||||
|
.bind(&benefit_kind)
|
||||||
|
.bind(plan_id)
|
||||||
|
.bind(units)
|
||||||
|
.bind(req.duration_days)
|
||||||
|
.bind(req.redeem_before)
|
||||||
|
.bind(¬e)
|
||||||
|
.bind(admin_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "生成兑换码失败").with_source(err))?;
|
||||||
|
|
||||||
|
generated.push(GeneratedCodeView {
|
||||||
|
id,
|
||||||
|
code,
|
||||||
|
code_hint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (user_id, action, resource_type, details, ip_address)
|
||||||
|
VALUES ($1, 'redemption_codes_created', 'redemption_code', $2, $3::inet)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(admin_id)
|
||||||
|
.bind(serde_json::json!({
|
||||||
|
"benefit_kind": benefit_kind,
|
||||||
|
"plan_id": plan_id,
|
||||||
|
"units": units,
|
||||||
|
"duration_days": req.duration_days,
|
||||||
|
"redeem_before": req.redeem_before,
|
||||||
|
"quantity": quantity,
|
||||||
|
"note": note,
|
||||||
|
}))
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: CreateCodesResponse {
|
||||||
|
message: "兑换码已生成,完整码仅显示本次".to_string(),
|
||||||
|
codes: generated,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct UpdateCodeStatusRequest {
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct MessageResponse {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_code_status(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(code_id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateCodeStatusRequest>,
|
||||||
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, admin_id) = admin::require_admin(&state, jar, &headers, ip).await?;
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
let updated =
|
||||||
|
sqlx::query("UPDATE redemption_codes SET is_active = $2, updated_at = NOW() WHERE id = $1")
|
||||||
|
.bind(code_id)
|
||||||
|
.bind(req.is_active)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "更新兑换码失败").with_source(err))?;
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
return Err(AppError::new(ErrorCode::NotFound, "兑换码不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||||
|
VALUES ($1, 'redemption_code_status', 'redemption_code', $2, $3, $4::inet)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(admin_id)
|
||||||
|
.bind(code_id)
|
||||||
|
.bind(serde_json::json!({ "is_active": req.is_active }))
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: MessageResponse {
|
||||||
|
message: if req.is_active {
|
||||||
|
"兑换码已启用".to_string()
|
||||||
|
} else {
|
||||||
|
"兑换码已停用".to_string()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct RedeemCodeRequest {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct RedeemCodeResponse {
|
||||||
|
message: String,
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
plan_name: Option<String>,
|
||||||
|
units: Option<i32>,
|
||||||
|
benefit_starts_at: DateTime<Utc>,
|
||||||
|
benefit_expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct RedeemableCodeRow {
|
||||||
|
id: Uuid,
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_id: Option<Uuid>,
|
||||||
|
plan_name: Option<String>,
|
||||||
|
plan_is_active: Option<bool>,
|
||||||
|
units: Option<i32>,
|
||||||
|
duration_days: i32,
|
||||||
|
redeem_before: Option<DateTime<Utc>>,
|
||||||
|
is_active: bool,
|
||||||
|
redemption_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn redeem_code(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(req): Json<RedeemCodeRequest>,
|
||||||
|
) -> Result<Json<Envelope<RedeemCodeResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||||
|
let user_id = match principal {
|
||||||
|
context::Principal::User { user_id, .. } => user_id,
|
||||||
|
_ => return Err(AppError::new(ErrorCode::Unauthorized, "请先登录")),
|
||||||
|
};
|
||||||
|
enforce_redeem_rate_limit(&state, user_id).await?;
|
||||||
|
|
||||||
|
if req.code.len() > 64 {
|
||||||
|
return Err(AppError::new(ErrorCode::InvalidRequest, "兑换码无效"));
|
||||||
|
}
|
||||||
|
let compact = normalize_code(&req.code)
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "兑换码无效"))?;
|
||||||
|
let code_hash = context::api_key_hash(&compact, &state.config.api_key_pepper)?;
|
||||||
|
let mut tx = state
|
||||||
|
.db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
let _: Uuid = sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换用户失败").with_source(err))?;
|
||||||
|
|
||||||
|
let code = sqlx::query_as::<_, RedeemableCodeRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.benefit_kind,
|
||||||
|
c.plan_id,
|
||||||
|
p.name AS plan_name,
|
||||||
|
p.is_active AS plan_is_active,
|
||||||
|
c.units,
|
||||||
|
c.duration_days,
|
||||||
|
c.redeem_before,
|
||||||
|
c.is_active,
|
||||||
|
r.id AS redemption_id
|
||||||
|
FROM redemption_codes c
|
||||||
|
LEFT JOIN plans p ON p.id = c.plan_id
|
||||||
|
LEFT JOIN redemption_records r ON r.code_id = c.id
|
||||||
|
WHERE c.code_hash = $1
|
||||||
|
FOR UPDATE OF c
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(code_hash)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换码失败").with_source(err))?
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "兑换码无效"))?;
|
||||||
|
|
||||||
|
let already_redeemed: bool =
|
||||||
|
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM redemption_records WHERE code_id = $1)")
|
||||||
|
.bind(code.id)
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "检查兑换码状态失败").with_source(err)
|
||||||
|
})?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
if !code.is_active
|
||||||
|
|| code.redemption_id.is_some()
|
||||||
|
|| already_redeemed
|
||||||
|
|| code.redeem_before.is_some_and(|deadline| deadline <= now)
|
||||||
|
{
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"兑换码无效、已使用或已过期",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let duration = Duration::days(i64::from(code.duration_days));
|
||||||
|
let (benefit_starts_at, benefit_expires_at) = match code.benefit_kind.as_str() {
|
||||||
|
"plan" => {
|
||||||
|
if code.plan_is_active != Some(true) {
|
||||||
|
return Err(AppError::new(ErrorCode::Forbidden, "兑换码对应套餐已停用"));
|
||||||
|
}
|
||||||
|
let plan_id = code
|
||||||
|
.plan_id
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::Internal, "套餐兑换码数据不完整"))?;
|
||||||
|
apply_plan_benefit(&mut tx, user_id, plan_id, duration, now).await?
|
||||||
|
}
|
||||||
|
"units" => (now, now + duration),
|
||||||
|
_ => return Err(AppError::new(ErrorCode::Internal, "兑换码类型错误")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let record_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO redemption_records (
|
||||||
|
code_id, user_id, benefit_kind, plan_id, units,
|
||||||
|
benefit_starts_at, benefit_expires_at, redeemed_ip
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::inet)
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(code.id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(&code.benefit_kind)
|
||||||
|
.bind(code.plan_id)
|
||||||
|
.bind(code.units)
|
||||||
|
.bind(benefit_starts_at)
|
||||||
|
.bind(benefit_expires_at)
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.fetch_one(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "记录兑换结果失败").with_source(err))?;
|
||||||
|
|
||||||
|
if code.benefit_kind == "units" {
|
||||||
|
let units = code
|
||||||
|
.units
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::Internal, "次数兑换码数据不完整"))?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO unit_grants (
|
||||||
|
user_id, redemption_record_id, total_units, remaining_units,
|
||||||
|
starts_at, expires_at
|
||||||
|
) VALUES ($1, $2, $3, $3, $4, $5)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(record_id)
|
||||||
|
.bind(units)
|
||||||
|
.bind(benefit_starts_at)
|
||||||
|
.bind(benefit_expires_at)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "发放次数额度失败").with_source(err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO audit_logs (user_id, action, resource_type, resource_id, details, ip_address)
|
||||||
|
VALUES ($1, 'redemption_code_redeemed', 'redemption_code', $2, $3, $4::inet)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(code.id)
|
||||||
|
.bind(serde_json::json!({
|
||||||
|
"benefit_kind": code.benefit_kind,
|
||||||
|
"plan_id": code.plan_id,
|
||||||
|
"units": code.units,
|
||||||
|
"benefit_expires_at": benefit_expires_at,
|
||||||
|
}))
|
||||||
|
.bind(ip.to_string())
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "写入审计日志失败").with_source(err))?;
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||||
|
|
||||||
|
let message = if code.benefit_kind == "plan" {
|
||||||
|
"套餐兑换成功"
|
||||||
|
} else {
|
||||||
|
"次数额度兑换成功"
|
||||||
|
};
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: RedeemCodeResponse {
|
||||||
|
message: message.to_string(),
|
||||||
|
benefit_kind: code.benefit_kind,
|
||||||
|
plan_id: code.plan_id,
|
||||||
|
plan_name: code.plan_name,
|
||||||
|
units: code.units,
|
||||||
|
benefit_starts_at,
|
||||||
|
benefit_expires_at,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct ActiveSubscriptionRow {
|
||||||
|
id: Uuid,
|
||||||
|
plan_id: Uuid,
|
||||||
|
provider: String,
|
||||||
|
current_period_start: DateTime<Utc>,
|
||||||
|
current_period_end: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn apply_plan_benefit(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
user_id: Uuid,
|
||||||
|
plan_id: Uuid,
|
||||||
|
duration: Duration,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
) -> Result<(DateTime<Utc>, DateTime<Utc>), AppError> {
|
||||||
|
let active = sqlx::query_as::<_, ActiveSubscriptionRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, plan_id, provider, current_period_start, current_period_end
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
|
AND current_period_start <= $2
|
||||||
|
AND current_period_end > $2
|
||||||
|
ORDER BY current_period_end DESC
|
||||||
|
FOR UPDATE
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(now)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询当前套餐失败").with_source(err))?;
|
||||||
|
|
||||||
|
if let Some(active) = active {
|
||||||
|
if active.provider == "stripe" {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::Forbidden,
|
||||||
|
"当前存在有效的 Stripe 订阅,请在订阅到期后兑换套餐卡",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_end = active.current_period_end + duration;
|
||||||
|
if active.plan_id == plan_id {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE usage_periods
|
||||||
|
SET period_end = $2, updated_at = NOW()
|
||||||
|
WHERE subscription_id = $1
|
||||||
|
AND period_start = $3
|
||||||
|
AND period_end = $4
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(active.id)
|
||||||
|
.bind(new_end)
|
||||||
|
.bind(active.current_period_start)
|
||||||
|
.bind(active.current_period_end)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
AppError::new(ErrorCode::Internal, "延长用量周期失败").with_source(err)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE subscriptions SET current_period_end = $2, updated_at = NOW() WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(active.id)
|
||||||
|
.bind(new_end)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "延长套餐失败").with_source(err))?;
|
||||||
|
return Ok((now, new_end));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE subscriptions
|
||||||
|
SET status = 'canceled', canceled_at = NOW(), updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(active.id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "切换套餐失败").with_source(err))?;
|
||||||
|
|
||||||
|
create_redemption_subscription(tx, user_id, plan_id, now, new_end).await?;
|
||||||
|
return Ok((now, new_end));
|
||||||
|
}
|
||||||
|
|
||||||
|
let end = now + duration;
|
||||||
|
create_redemption_subscription(tx, user_id, plan_id, now, end).await?;
|
||||||
|
Ok((now, end))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_redemption_subscription(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
user_id: Uuid,
|
||||||
|
plan_id: Uuid,
|
||||||
|
start: DateTime<Utc>,
|
||||||
|
end: DateTime<Utc>,
|
||||||
|
) -> Result<Uuid, AppError> {
|
||||||
|
let subscription_id: Uuid = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
INSERT INTO subscriptions (
|
||||||
|
user_id, plan_id, status, current_period_start, current_period_end,
|
||||||
|
cancel_at_period_end, provider, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, 'active', $3, $4, false, 'redemption', NOW(), NOW())
|
||||||
|
RETURNING id
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(plan_id)
|
||||||
|
.bind(start)
|
||||||
|
.bind(end)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开通兑换套餐失败").with_source(err))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(subscription_id)
|
||||||
|
.bind(start)
|
||||||
|
.bind(end)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化兑换套餐用量失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(subscription_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow, Serialize)]
|
||||||
|
struct UserRedemptionView {
|
||||||
|
id: Uuid,
|
||||||
|
code_hint: String,
|
||||||
|
benefit_kind: String,
|
||||||
|
plan_name: Option<String>,
|
||||||
|
units: Option<i32>,
|
||||||
|
remaining_units: Option<i32>,
|
||||||
|
benefit_starts_at: DateTime<Utc>,
|
||||||
|
benefit_expires_at: DateTime<Utc>,
|
||||||
|
redeemed_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct UserRedemptionsResponse {
|
||||||
|
redemptions: Vec<UserRedemptionView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_user_redemptions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: axum_extra::extract::cookie::CookieJar,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<Envelope<UserRedemptionsResponse>>, AppError> {
|
||||||
|
let ip = context::client_ip(&headers, addr.ip());
|
||||||
|
let (_jar, principal) = context::authenticate(&state, jar, &headers, ip).await?;
|
||||||
|
let user_id = match principal {
|
||||||
|
context::Principal::User { user_id, .. } => user_id,
|
||||||
|
_ => return Err(AppError::new(ErrorCode::Unauthorized, "请先登录")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let redemptions = sqlx::query_as::<_, UserRedemptionView>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
c.code_hint,
|
||||||
|
r.benefit_kind,
|
||||||
|
p.name AS plan_name,
|
||||||
|
r.units,
|
||||||
|
CASE WHEN g.expires_at > NOW() THEN g.remaining_units ELSE 0 END AS remaining_units,
|
||||||
|
r.benefit_starts_at,
|
||||||
|
r.benefit_expires_at,
|
||||||
|
r.redeemed_at
|
||||||
|
FROM redemption_records r
|
||||||
|
JOIN redemption_codes c ON c.id = r.code_id
|
||||||
|
LEFT JOIN plans p ON p.id = r.plan_id
|
||||||
|
LEFT JOIN unit_grants g ON g.redemption_record_id = r.id
|
||||||
|
WHERE r.user_id = $1
|
||||||
|
ORDER BY r.redeemed_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换记录失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(Json(Envelope {
|
||||||
|
success: true,
|
||||||
|
data: UserRedemptionsResponse { redemptions },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_code() -> String {
|
||||||
|
let mut random = [0u8; 16];
|
||||||
|
rand::rngs::OsRng.fill_bytes(&mut random);
|
||||||
|
let payload = random
|
||||||
|
.iter()
|
||||||
|
.map(|byte| CODE_ALPHABET[usize::from(*byte & 31)] as char)
|
||||||
|
.collect::<String>();
|
||||||
|
format!(
|
||||||
|
"{CODE_PREFIX}-{}-{}-{}-{}",
|
||||||
|
&payload[0..4],
|
||||||
|
&payload[4..8],
|
||||||
|
&payload[8..12],
|
||||||
|
&payload[12..16]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_code(input: &str) -> Option<String> {
|
||||||
|
let compact = input
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| *ch != '-' && !ch.is_ascii_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase();
|
||||||
|
if compact.len() != CODE_PREFIX.len() + 16 || !compact.starts_with(CODE_PREFIX) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !compact[CODE_PREFIX.len()..]
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| CODE_ALPHABET.contains(&byte))
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(compact)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn code_hint(compact: &str) -> String {
|
||||||
|
format!("{}-...-{}", CODE_PREFIX, &compact[15..19])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enforce_redeem_rate_limit(state: &AppState, user_id: Uuid) -> Result<(), AppError> {
|
||||||
|
let key = format!(
|
||||||
|
"rate:redemption:{}:{}",
|
||||||
|
user_id,
|
||||||
|
Utc::now().format("%Y%m%d%H%M")
|
||||||
|
);
|
||||||
|
let mut redis = state.redis.clone();
|
||||||
|
let count: i64 = redis::cmd("INCR")
|
||||||
|
.arg(&key)
|
||||||
|
.query_async(&mut redis)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?;
|
||||||
|
if count == 1 {
|
||||||
|
let _: () = redis::cmd("EXPIRE")
|
||||||
|
.arg(&key)
|
||||||
|
.arg(60)
|
||||||
|
.query_async(&mut redis)
|
||||||
|
.await
|
||||||
|
.unwrap_or(());
|
||||||
|
}
|
||||||
|
if count > 20 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::RateLimited,
|
||||||
|
"兑换尝试过于频繁,请稍后再试",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_code_round_trips_through_normalization() {
|
||||||
|
let code = generate_code();
|
||||||
|
let compact = normalize_code(&code).unwrap();
|
||||||
|
assert_eq!(compact.len(), 19);
|
||||||
|
assert!(code.starts_with("IMG-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalization_accepts_lowercase_and_spaces() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_code(" img-abcd-efgh-jkmn-pqrs "),
|
||||||
|
Some("IMGABCDEFGHJKMNPQRS".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalization_rejects_ambiguous_characters() {
|
||||||
|
assert!(normalize_code("IMG-ABCI-EFGH-JKMN-PQRS").is_none());
|
||||||
|
assert!(normalize_code("IMG-ABCO-EFGH-JKMN-PQRS").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn code_hint_only_exposes_the_last_group() {
|
||||||
|
assert_eq!(code_hint("IMGABCDEFGHJKMNPQRS"), "IMG-...-PQRS");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use crate::services::billing::{BillingContext, Plan};
|
|||||||
use crate::services::compress;
|
use crate::services::compress;
|
||||||
use crate::services::compress::{CompressionLevel, ImageFmt};
|
use crate::services::compress::{CompressionLevel, ImageFmt};
|
||||||
use crate::services::idempotency;
|
use crate::services::idempotency;
|
||||||
|
use crate::services::quota;
|
||||||
use crate::services::storage;
|
use crate::services::storage;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -647,41 +648,7 @@ async fn ensure_quota_available(
|
|||||||
ctx: &BillingContext,
|
ctx: &BillingContext,
|
||||||
needed_units: i32,
|
needed_units: i32,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
if needed_units <= 0 {
|
quota::ensure_user_units(state, ctx, needed_units).await
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
|
||||||
struct UsageRow {
|
|
||||||
used_units: i32,
|
|
||||||
bonus_units: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
let usage = sqlx::query_as::<_, UsageRow>(
|
|
||||||
r#"
|
|
||||||
SELECT used_units, bonus_units
|
|
||||||
FROM usage_periods
|
|
||||||
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(ctx.user_id)
|
|
||||||
.bind(ctx.period_start)
|
|
||||||
.bind(ctx.period_end)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
|
|
||||||
.unwrap_or(UsageRow {
|
|
||||||
used_units: 0,
|
|
||||||
bonus_units: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
let total_units = ctx.plan.included_units_per_period + usage.bonus_units;
|
|
||||||
let remaining = total_units - usage.used_units;
|
|
||||||
if remaining < needed_units {
|
|
||||||
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn anonymous_remaining_units(
|
async fn anonymous_remaining_units(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
|
|||||||
use crate::error::{AppError, ErrorCode};
|
use crate::error::{AppError, ErrorCode};
|
||||||
use crate::services::billing;
|
use crate::services::billing;
|
||||||
use crate::services::mail;
|
use crate::services::mail;
|
||||||
|
use crate::services::settings;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||||
@@ -98,6 +99,8 @@ async fn get_profile(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||||
|
|
||||||
|
let verification_required = settings::email_verification_required(&state).await?;
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok(Json(Envelope {
|
||||||
success: true,
|
success: true,
|
||||||
data: UserView {
|
data: UserView {
|
||||||
@@ -105,7 +108,7 @@ async fn get_profile(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
email_verified: user.email_verified_at.is_some(),
|
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -165,6 +168,7 @@ async fn update_profile(
|
|||||||
let mut next_email = user.email.clone();
|
let mut next_email = user.email.clone();
|
||||||
let mut next_username = user.username.clone();
|
let mut next_username = user.username.clone();
|
||||||
let mut email_changed = false;
|
let mut email_changed = false;
|
||||||
|
let verification_required = settings::email_verification_required(&state).await?;
|
||||||
|
|
||||||
if let Some(email) = req.email.as_ref() {
|
if let Some(email) = req.email.as_ref() {
|
||||||
let email = email.trim().to_lowercase();
|
let email = email.trim().to_lowercase();
|
||||||
@@ -192,7 +196,7 @@ async fn update_profile(
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
email_verified: user.email_verified_at.is_some(),
|
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||||||
},
|
},
|
||||||
message: "暂无更新".to_string(),
|
message: "暂无更新".to_string(),
|
||||||
},
|
},
|
||||||
@@ -205,8 +209,10 @@ async fn update_profile(
|
|||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
||||||
|
|
||||||
let email_verified_at = if email_changed {
|
let email_verified_at = if email_changed && verification_required {
|
||||||
None
|
None
|
||||||
|
} else if email_changed {
|
||||||
|
Some(Utc::now())
|
||||||
} else {
|
} else {
|
||||||
user.email_verified_at
|
user.email_verified_at
|
||||||
};
|
};
|
||||||
@@ -231,7 +237,7 @@ async fn update_profile(
|
|||||||
.map_err(map_unique_violation)?;
|
.map_err(map_unique_violation)?;
|
||||||
|
|
||||||
let mut verification_link: Option<String> = None;
|
let mut verification_link: Option<String> = None;
|
||||||
if email_changed {
|
if email_changed && verification_required {
|
||||||
let token = generate_token();
|
let token = generate_token();
|
||||||
let token_hash = sha256_hex(&token);
|
let token_hash = sha256_hex(&token);
|
||||||
let expires_at = Utc::now() + Duration::hours(24);
|
let expires_at = Utc::now() + Duration::hours(24);
|
||||||
@@ -269,7 +275,7 @@ async fn update_profile(
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = if email_changed {
|
let message = if email_changed && verification_required {
|
||||||
"资料已更新,请验证新邮箱".to_string()
|
"资料已更新,请验证新邮箱".to_string()
|
||||||
} else {
|
} else {
|
||||||
"资料已更新".to_string()
|
"资料已更新".to_string()
|
||||||
@@ -283,7 +289,7 @@ async fn update_profile(
|
|||||||
email: updated.email,
|
email: updated.email,
|
||||||
username: updated.username,
|
username: updated.username,
|
||||||
role: updated.role,
|
role: updated.role,
|
||||||
email_verified: updated.email_verified_at.is_some(),
|
email_verified: updated.email_verified_at.is_some() || !verification_required,
|
||||||
},
|
},
|
||||||
message,
|
message,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<Billing
|
|||||||
FROM subscriptions
|
FROM subscriptions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND status IN ('active', 'trialing', 'past_due')
|
AND status IN ('active', 'trialing', 'past_due')
|
||||||
|
AND current_period_start <= NOW()
|
||||||
|
AND current_period_end > NOW()
|
||||||
ORDER BY current_period_end DESC
|
ORDER BY current_period_end DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"#,
|
"#,
|
||||||
|
|||||||
@@ -12,44 +12,65 @@ static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
|||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
struct AdminRow {
|
struct AdminRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
|
email: String,
|
||||||
username: String,
|
username: String,
|
||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
|
pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
|
||||||
let Some(admin_email) = env_string("ADMIN_EMAIL") else {
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
let Some(admin_password) = env_string("ADMIN_PASSWORD") else {
|
let Some(admin_password) = env_string("ADMIN_PASSWORD") else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let configured_email = env_string("ADMIN_EMAIL");
|
||||||
|
let configured_username = env_string("ADMIN_USERNAME");
|
||||||
|
if configured_email.is_none() && configured_username.is_none() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let admin_username = configured_username.unwrap_or_else(|| {
|
||||||
|
configured_email
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|email| email.split('@').next())
|
||||||
|
.unwrap_or("admin")
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
let admin_username = admin_username.trim().to_string();
|
||||||
|
let admin_email = configured_email
|
||||||
|
.unwrap_or_else(|| format!("{}@local.invalid", admin_username.to_ascii_lowercase()));
|
||||||
let admin_email = admin_email.trim().to_lowercase();
|
let admin_email = admin_email.trim().to_lowercase();
|
||||||
let admin_password = admin_password.trim().to_string();
|
let admin_password = admin_password.trim().to_string();
|
||||||
if admin_email.is_empty() || admin_password.is_empty() {
|
if admin_email.is_empty() || admin_password.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let admin_username = env_string("ADMIN_USERNAME")
|
|
||||||
.unwrap_or_else(|| admin_email.split('@').next().unwrap_or("admin").to_string());
|
|
||||||
let admin_username = admin_username.trim().to_string();
|
|
||||||
|
|
||||||
validate_email(&admin_email)?;
|
validate_email(&admin_email)?;
|
||||||
validate_username(&admin_username)?;
|
validate_username(&admin_username)?;
|
||||||
validate_password(&admin_password)?;
|
validate_password(&admin_password)?;
|
||||||
|
|
||||||
let existing = sqlx::query_as::<_, AdminRow>(
|
let mut matching = sqlx::query_as::<_, AdminRow>(
|
||||||
r#"
|
r#"
|
||||||
SELECT id, username, role::text AS role
|
SELECT id, email, username, role::text AS role
|
||||||
FROM users
|
FROM users
|
||||||
WHERE email = $1
|
WHERE email = $1 OR username = $2
|
||||||
|
ORDER BY (email = $1) DESC
|
||||||
|
LIMIT 2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&admin_email)
|
.bind(&admin_email)
|
||||||
.fetch_optional(&state.db)
|
.bind(&admin_username)
|
||||||
|
.fetch_all(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询管理员账号失败").with_source(err))?;
|
||||||
|
|
||||||
|
if matching.len() > 1 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"管理员邮箱和用户名分别属于不同账号",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let existing = matching.pop();
|
||||||
|
|
||||||
let password_hash = hash_password(&admin_password)?;
|
let password_hash = hash_password(&admin_password)?;
|
||||||
|
|
||||||
if let Some(row) = existing {
|
if let Some(row) = existing {
|
||||||
@@ -113,6 +134,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
|
|||||||
if row.role != "admin" {
|
if row.role != "admin" {
|
||||||
info!(admin_email = %admin_email, "管理员权限已启用");
|
info!(admin_email = %admin_email, "管理员权限已启用");
|
||||||
}
|
}
|
||||||
|
if row.email != admin_email {
|
||||||
|
info!(admin_username = %admin_username, "按用户名匹配到已有管理员,保留原邮箱");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -1,8 +1,247 @@
|
|||||||
use crate::error::{AppError, ErrorCode};
|
use crate::error::{AppError, ErrorCode};
|
||||||
|
use crate::services::billing::BillingContext;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
|
use sqlx::{FromRow, Postgres, Transaction};
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct UserUsageBalance {
|
||||||
|
pub used_units: i64,
|
||||||
|
pub included_units: i64,
|
||||||
|
pub bonus_units: i64,
|
||||||
|
pub redeemed_units: i64,
|
||||||
|
pub total_units: i64,
|
||||||
|
pub remaining_units: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct UsagePeriodRow {
|
||||||
|
used_units: i32,
|
||||||
|
bonus_units: i32,
|
||||||
|
grant_used_units: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromRow)]
|
||||||
|
struct AvailableGrantRow {
|
||||||
|
id: Uuid,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_usage_balance(
|
||||||
|
state: &AppState,
|
||||||
|
billing: &BillingContext,
|
||||||
|
) -> Result<UserUsageBalance, AppError> {
|
||||||
|
let usage = sqlx::query_as::<_, UsagePeriodRow>(
|
||||||
|
r#"
|
||||||
|
SELECT used_units, bonus_units, grant_used_units
|
||||||
|
FROM usage_periods
|
||||||
|
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.bind(billing.period_start)
|
||||||
|
.bind(billing.period_end)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量失败").with_source(err))?
|
||||||
|
.unwrap_or(UsagePeriodRow {
|
||||||
|
used_units: 0,
|
||||||
|
bonus_units: 0,
|
||||||
|
grant_used_units: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let redeemed_units: i64 = sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT COALESCE(SUM(remaining_units), 0)::bigint
|
||||||
|
FROM unit_grants
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND starts_at <= NOW()
|
||||||
|
AND expires_at > NOW()
|
||||||
|
AND remaining_units > 0
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.fetch_one(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询兑换额度失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(calculate_user_balance(
|
||||||
|
billing.plan.included_units_per_period,
|
||||||
|
usage.used_units,
|
||||||
|
usage.bonus_units,
|
||||||
|
usage.grant_used_units,
|
||||||
|
redeemed_units,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_user_units(
|
||||||
|
state: &AppState,
|
||||||
|
billing: &BillingContext,
|
||||||
|
needed_units: i32,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if needed_units <= 0 {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let balance = user_usage_balance(state, billing).await?;
|
||||||
|
if balance.remaining_units < i64::from(needed_units) {
|
||||||
|
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn consume_user_unit(
|
||||||
|
tx: &mut Transaction<'_, Postgres>,
|
||||||
|
billing: &BillingContext,
|
||||||
|
bytes_in: u64,
|
||||||
|
bytes_out: u64,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.bind(billing.subscription_id)
|
||||||
|
.bind(billing.period_start)
|
||||||
|
.bind(billing.period_end)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?;
|
||||||
|
|
||||||
|
let usage = sqlx::query_as::<_, UsagePeriodRow>(
|
||||||
|
r#"
|
||||||
|
SELECT used_units, bonus_units, grant_used_units
|
||||||
|
FROM usage_periods
|
||||||
|
WHERE user_id = $1 AND period_start = $2 AND period_end = $3
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.bind(billing.period_start)
|
||||||
|
.bind(billing.period_end)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用量周期失败").with_source(err))?;
|
||||||
|
|
||||||
|
let plan_used = usage.used_units.saturating_sub(usage.grant_used_units);
|
||||||
|
let plan_capacity = billing
|
||||||
|
.plan
|
||||||
|
.included_units_per_period
|
||||||
|
.saturating_add(usage.bonus_units);
|
||||||
|
let plan_available = plan_used < plan_capacity;
|
||||||
|
let grant = sqlx::query_as::<_, AvailableGrantRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, expires_at
|
||||||
|
FROM unit_grants
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND starts_at <= NOW()
|
||||||
|
AND expires_at > NOW()
|
||||||
|
AND remaining_units > 0
|
||||||
|
ORDER BY expires_at ASC, created_at ASC
|
||||||
|
FOR UPDATE
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定兑换额度失败").with_source(err))?;
|
||||||
|
|
||||||
|
let use_grant = should_consume_grant(
|
||||||
|
plan_available,
|
||||||
|
grant.as_ref().map(|grant| grant.expires_at),
|
||||||
|
billing.period_end,
|
||||||
|
);
|
||||||
|
|
||||||
|
if !plan_available && !use_grant {
|
||||||
|
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if use_grant {
|
||||||
|
let grant_id = grant
|
||||||
|
.ok_or_else(|| AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"))?
|
||||||
|
.id;
|
||||||
|
|
||||||
|
let updated = sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE unit_grants
|
||||||
|
SET remaining_units = remaining_units - 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
AND remaining_units > 0
|
||||||
|
AND expires_at > NOW()
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(grant_id)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减兑换额度失败").with_source(err))?;
|
||||||
|
|
||||||
|
if updated.rows_affected() != 1 {
|
||||||
|
return Err(AppError::new(ErrorCode::QuotaExceeded, "可用配额已用完"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE usage_periods
|
||||||
|
SET used_units = used_units + 1,
|
||||||
|
grant_used_units = grant_used_units + $1,
|
||||||
|
bytes_in = bytes_in + $2,
|
||||||
|
bytes_out = bytes_out + $3,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE user_id = $4 AND period_start = $5 AND period_end = $6
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(use_grant as i32)
|
||||||
|
.bind(bytes_in as i64)
|
||||||
|
.bind(bytes_out as i64)
|
||||||
|
.bind(billing.user_id)
|
||||||
|
.bind(billing.period_start)
|
||||||
|
.bind(billing.period_end)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "记录用量失败").with_source(err))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calculate_user_balance(
|
||||||
|
included_units: i32,
|
||||||
|
used_units: i32,
|
||||||
|
bonus_units: i32,
|
||||||
|
grant_used_units: i32,
|
||||||
|
redeemed_units: i64,
|
||||||
|
) -> UserUsageBalance {
|
||||||
|
let base_capacity = i64::from(included_units.saturating_add(bonus_units));
|
||||||
|
let base_used = i64::from(used_units.saturating_sub(grant_used_units));
|
||||||
|
let base_remaining = base_capacity.saturating_sub(base_used).max(0);
|
||||||
|
let remaining_units = base_remaining.saturating_add(redeemed_units.max(0));
|
||||||
|
let used_units = i64::from(used_units.max(0));
|
||||||
|
|
||||||
|
UserUsageBalance {
|
||||||
|
used_units,
|
||||||
|
included_units: i64::from(included_units),
|
||||||
|
bonus_units: i64::from(bonus_units),
|
||||||
|
redeemed_units: redeemed_units.max(0),
|
||||||
|
total_units: used_units.saturating_add(remaining_units),
|
||||||
|
remaining_units,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_consume_grant(
|
||||||
|
plan_available: bool,
|
||||||
|
grant_expires_at: Option<DateTime<Utc>>,
|
||||||
|
plan_expires_at: DateTime<Utc>,
|
||||||
|
) -> bool {
|
||||||
|
grant_expires_at.is_some_and(|expires_at| !plan_available || expires_at <= plan_expires_at)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn consume_anonymous_units(
|
pub async fn consume_anonymous_units(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -71,3 +310,42 @@ fn utc8_date() -> String {
|
|||||||
let now = Utc::now() + Duration::hours(8);
|
let now = Utc::now() + Duration::hours(8);
|
||||||
now.format("%Y-%m-%d").to_string()
|
now.format("%Y-%m-%d").to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn balance_keeps_redeemed_units_separate_from_plan_usage() {
|
||||||
|
let balance = calculate_user_balance(10, 13, 0, 3, 7);
|
||||||
|
assert_eq!(balance.remaining_units, 7);
|
||||||
|
assert_eq!(balance.total_units, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn balance_uses_plan_capacity_before_redeemed_units() {
|
||||||
|
let balance = calculate_user_balance(10, 4, 2, 0, 5);
|
||||||
|
assert_eq!(balance.remaining_units, 13);
|
||||||
|
assert_eq!(balance.total_units, 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn earlier_expiring_entitlement_is_consumed_first() {
|
||||||
|
let now = Utc::now();
|
||||||
|
assert!(should_consume_grant(
|
||||||
|
true,
|
||||||
|
Some(now + Duration::days(2)),
|
||||||
|
now + Duration::days(20),
|
||||||
|
));
|
||||||
|
assert!(!should_consume_grant(
|
||||||
|
true,
|
||||||
|
Some(now + Duration::days(30)),
|
||||||
|
now + Duration::days(20),
|
||||||
|
));
|
||||||
|
assert!(should_consume_grant(
|
||||||
|
false,
|
||||||
|
Some(now + Duration::days(30)),
|
||||||
|
now + Duration::days(20),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ pub struct MailConfigStored {
|
|||||||
pub log_links_when_disabled: Option<bool>,
|
pub log_links_when_disabled: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AuthConfigStored {
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub email_verification_required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StripeConfigStored {
|
pub struct StripeConfigStored {
|
||||||
pub secret_key_encrypted: Option<String>,
|
pub secret_key_encrypted: Option<String>,
|
||||||
@@ -117,6 +127,13 @@ pub async fn load_mail_settings(state: &AppState) -> Result<Option<MailSettings>
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn email_verification_required(state: &AppState) -> Result<bool, AppError> {
|
||||||
|
Ok(load_system_config::<AuthConfigStored>(state, "auth")
|
||||||
|
.await?
|
||||||
|
.map(|config| config.email_verification_required)
|
||||||
|
.unwrap_or(true))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecrets>, AppError> {
|
pub async fn load_stripe_secrets(state: &AppState) -> Result<Option<StripeSecrets>, AppError> {
|
||||||
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
|
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -225,3 +242,14 @@ pub async fn get_stripe_webhook_secret(state: &AppState) -> Result<String, AppEr
|
|||||||
.filter(|v| !v.trim().is_empty())
|
.filter(|v| !v.trim().is_empty())
|
||||||
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret"))
|
.ok_or_else(|| AppError::new(ErrorCode::InvalidRequest, "未配置 Stripe Webhook Secret"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_config_defaults_to_requiring_verification() {
|
||||||
|
let config: AuthConfigStored = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||||
|
assert!(config.email_verification_required);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -791,49 +791,7 @@ async fn charge_one_unit(
|
|||||||
bytes_in: u64,
|
bytes_in: u64,
|
||||||
bytes_out: u64,
|
bytes_out: u64,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// Ensure usage period row exists.
|
quota::consume_user_unit(tx, billing, bytes_in, bytes_out).await?;
|
||||||
sqlx::query(
|
|
||||||
r#"
|
|
||||||
INSERT INTO usage_periods (user_id, subscription_id, period_start, period_end)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (user_id, period_start, period_end) DO NOTHING
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(billing.user_id)
|
|
||||||
.bind(billing.subscription_id)
|
|
||||||
.bind(billing.period_start)
|
|
||||||
.bind(billing.period_end)
|
|
||||||
.execute(&mut **tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "初始化用量周期失败").with_source(err))?;
|
|
||||||
|
|
||||||
let updated: Option<i32> = sqlx::query_scalar(
|
|
||||||
r#"
|
|
||||||
UPDATE usage_periods
|
|
||||||
SET used_units = used_units + 1,
|
|
||||||
bytes_in = bytes_in + $1,
|
|
||||||
bytes_out = bytes_out + $2,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE user_id = $3
|
|
||||||
AND period_start = $4
|
|
||||||
AND period_end = $5
|
|
||||||
AND used_units + 1 <= $6 + bonus_units
|
|
||||||
RETURNING used_units
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(bytes_in as i64)
|
|
||||||
.bind(bytes_out as i64)
|
|
||||||
.bind(billing.user_id)
|
|
||||||
.bind(billing.period_start)
|
|
||||||
.bind(billing.period_end)
|
|
||||||
.bind(billing.plan.included_units_per_period)
|
|
||||||
.fetch_optional(&mut **tx)
|
|
||||||
.await
|
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "扣减配额失败").with_source(err))?;
|
|
||||||
|
|
||||||
if updated.is_none() {
|
|
||||||
return Err(AppError::new(ErrorCode::QuotaExceeded, "当期配额已用完"));
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
Reference in New Issue
Block a user