feat: add configurable verification and redemption codes
This commit is contained in:
@@ -60,6 +60,7 @@ IDEMPOTENCY_TTL_HOURS=24
|
||||
# 结果保留(匿名默认;登录用户按套餐 retention_days)
|
||||
ANON_RETENTION_HOURS=24
|
||||
|
||||
# 管理员初始账户(首启可自动创建)
|
||||
# 管理员初始账户(首启可自动创建;ADMIN_EMAIL 可留空)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=changeme123
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO(仅静态图片,支持格式转换)
|
||||
- **批量处理**:支持多图片同时上传和处理
|
||||
- **压缩率**:1-100(JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
|
||||
- **用户系统**:注册、登录、API Key 管理
|
||||
- **用户系统**:注册、登录、可开关邮箱验证、API Key 管理
|
||||
- **兑换码**:套餐卡、限时次数卡、批量生成、停用与兑换审计
|
||||
- **计费与用量**:套餐/订阅/配额/发票
|
||||
- **管理员后台**:用户管理、系统监控、配置管理、S3 多端点管理与连通性测试
|
||||
- **对象存储**: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
|
||||
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_USERNAME=admin
|
||||
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,
|
||||
"included_units": 10000,
|
||||
"bonus_units": 500,
|
||||
"total_units": 10500,
|
||||
"remaining_units": 10380
|
||||
"redeemed_units": 200,
|
||||
"total_units": 10700,
|
||||
"remaining_units": 10580
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -612,6 +613,23 @@ GET /billing/invoices?page=1&limit=20
|
||||
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(支付回调)
|
||||
@@ -713,6 +731,42 @@ Authorization: Bearer <admin_token>
|
||||
|
||||
凭据加密保存且不通过 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(网站任务进度)
|
||||
|
||||
@@ -594,7 +594,8 @@ VALUES
|
||||
### 7.2 默认系统配置
|
||||
```sql
|
||||
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}', '速率限制默认值'),
|
||||
('file_limits', '{"max_image_pixels": 40000000}', '图片安全限制(像素上限等)'),
|
||||
('mail', '{"enabled": true, "provider": "custom", "from": "noreply@example.com", "from_name": "ImageForge"}', '邮件服务配置(密码加密存储)');
|
||||
|
||||
@@ -37,6 +37,8 @@ docker compose \
|
||||
|
||||
API 健康后 Worker 才会启动,避免两个进程在首次部署时同时执行迁移。
|
||||
|
||||
管理员可用邮箱或用户名登录。首次启动时设置 `ADMIN_USERNAME` 和 `ADMIN_PASSWORD` 即可创建管理员;若未设置 `ADMIN_EMAIL`,系统会生成仅用于满足内部数据约束的 `用户名@local.invalid` 占位邮箱。确认账号创建后应从生产环境文件中移除 `ADMIN_PASSWORD`,避免每次重启都重置密码。
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f docker/docker-compose.prod.yml ps
|
||||
curl --fail http://127.0.0.1:8080/health
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
>
|
||||
订阅与额度
|
||||
</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
|
||||
to="/admin/integrations"
|
||||
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: 'tasks', name: 'admin-tasks', component: () => import('@/pages/admin/AdminTasksPage.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: 'storage', name: 'admin-storage', component: () => import('@/pages/admin/AdminStoragePage.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 {
|
||||
compressFile,
|
||||
getProfile,
|
||||
getSubscription,
|
||||
getUsage,
|
||||
sendVerification,
|
||||
@@ -62,9 +63,14 @@ onMounted(async () => {
|
||||
quotaLoading.value = true
|
||||
quotaError.value = null
|
||||
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
|
||||
subscription.value = s.subscription
|
||||
auth.updateUser(profile)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
quotaError.value = `[${err.code}] ${err.message}`
|
||||
@@ -316,6 +322,7 @@ async function resendVerification() {
|
||||
alert.value = null
|
||||
try {
|
||||
const resp = await sendVerification(auth.token)
|
||||
auth.updateUser(await getProfile(auth.token))
|
||||
alert.value = { type: 'success', message: resp.message }
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -38,7 +38,7 @@ async function submit() {
|
||||
<div class="mx-auto max-w-md">
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<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">
|
||||
{{ error }}
|
||||
|
||||
@@ -280,7 +280,8 @@ onMounted(async () => {
|
||||
<div v-if="creditResult" class="mt-2 text-xs text-slate-500">
|
||||
当前周期:{{ new Date(creditResult.period_start).toLocaleString() }} →
|
||||
{{ 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>
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getAuthConfig,
|
||||
getMailConfig,
|
||||
getStripeConfig,
|
||||
listAdminPlans,
|
||||
sendMailTest,
|
||||
updateAdminPlan,
|
||||
updateAuthConfig,
|
||||
updateMailConfig,
|
||||
updateStripeConfig,
|
||||
type AdminMailConfig,
|
||||
@@ -50,6 +52,11 @@ const mailBusy = ref(false)
|
||||
const mailMessage = 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 testBusy = ref(false)
|
||||
const testMessage = ref<string | null>(null)
|
||||
@@ -99,12 +106,14 @@ async function loadAll() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [stripe, mailCfg, planResp] = await Promise.all([
|
||||
const [stripe, authCfg, mailCfg, planResp] = await Promise.all([
|
||||
getStripeConfig(auth.token),
|
||||
getAuthConfig(auth.token),
|
||||
getMailConfig(auth.token),
|
||||
listAdminPlans(auth.token),
|
||||
])
|
||||
stripeConfig.value = stripe
|
||||
emailVerificationRequired.value = authCfg.email_verification_required
|
||||
mailConfig.value = mailCfg
|
||||
applyMailConfig(mailCfg)
|
||||
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() {
|
||||
if (!auth.token) return
|
||||
stripeBusy.value = true
|
||||
@@ -335,6 +364,22 @@ onMounted(loadAll)
|
||||
</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="text-sm font-medium text-slate-900">邮件服务配置</div>
|
||||
<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,
|
||||
listInvoices,
|
||||
listPlans,
|
||||
listUserRedemptions,
|
||||
redeemCode,
|
||||
type InvoiceView,
|
||||
type PlanView,
|
||||
type SubscriptionView,
|
||||
type UserRedemptionView,
|
||||
type UsageResponse,
|
||||
} from '@/services/api'
|
||||
import { ApiError } from '@/services/http'
|
||||
@@ -25,22 +28,31 @@ const plans = ref<PlanView[]>([])
|
||||
const subscription = ref<SubscriptionView | null>(null)
|
||||
const usage = ref<UsageResponse | null>(null)
|
||||
const invoices = ref<InvoiceView[]>([])
|
||||
const redemptions = ref<UserRedemptionView[]>([])
|
||||
|
||||
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
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [p, s, u, inv] = await Promise.all([
|
||||
const [p, s, u, inv, redeemed] = await Promise.all([
|
||||
listPlans(),
|
||||
getSubscription(auth.token),
|
||||
getUsage(auth.token),
|
||||
listInvoices(auth.token),
|
||||
listUserRedemptions(auth.token),
|
||||
])
|
||||
plans.value = p.plans
|
||||
subscription.value = s.subscription
|
||||
usage.value = u
|
||||
invoices.value = inv.invoices
|
||||
redemptions.value = redeemed.redemptions
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
error.value = `[${err.code}] ${err.message}`
|
||||
@@ -50,7 +62,37 @@ onMounted(async () => {
|
||||
} finally {
|
||||
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) {
|
||||
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">
|
||||
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
|
||||
</div>
|
||||
<div v-if="(usage?.redeemed_units ?? 0) > 0" class="mt-1 text-xs text-slate-500">
|
||||
另有 {{ usage?.redeemed_units ?? 0 }} 次限时兑换额度
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-5">
|
||||
<div class="text-xs font-medium text-slate-500">周期</div>
|
||||
@@ -138,6 +183,43 @@ async function openPortal() {
|
||||
</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="text-sm font-medium text-slate-900">充值额度 / 购买套餐</div>
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
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 { useAuthStore } from '@/stores/auth'
|
||||
|
||||
@@ -21,12 +21,20 @@ const sendingVerification = ref(false)
|
||||
onMounted(async () => {
|
||||
if (!auth.token) return
|
||||
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
|
||||
subscription.value = s.subscription
|
||||
auth.updateUser(profile)
|
||||
|
||||
if (route.query.welcome === '1') {
|
||||
alert.value = { type: 'success', message: '欢迎加入 ImageForge!请尽快完成邮箱验证。' }
|
||||
alert.value = {
|
||||
type: 'success',
|
||||
message: profile.email_verified ? '欢迎加入 ImageForge!' : '欢迎加入 ImageForge!请尽快完成邮箱验证。',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -45,6 +53,7 @@ async function resendVerification() {
|
||||
alert.value = null
|
||||
try {
|
||||
const resp = await sendVerification(auth.token)
|
||||
auth.updateUser(await getProfile(auth.token))
|
||||
alert.value = { type: 'success', message: resp.message }
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -56,6 +56,7 @@ async function resendVerification() {
|
||||
verificationError.value = null
|
||||
try {
|
||||
const resp = await sendVerification(auth.token)
|
||||
auth.updateUser(await getProfile(auth.token))
|
||||
verificationMessage.value = resp.message
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface UsageResponse {
|
||||
used_units: number
|
||||
included_units: number
|
||||
bonus_units: number
|
||||
redeemed_units: number
|
||||
total_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' })
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -411,6 +444,7 @@ export interface AdminCreditResponse {
|
||||
period_end: string
|
||||
used_units: number
|
||||
bonus_units: number
|
||||
redeemed_units: number
|
||||
total_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)
|
||||
}
|
||||
|
||||
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 {
|
||||
id: 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::mail;
|
||||
use crate::services::settings;
|
||||
use crate::services::settings::{MailConfigStored, MailCustomSmtp, StripeConfigStored};
|
||||
use crate::services::settings::{
|
||||
AuthConfigStored, MailConfigStored, MailCustomSmtp, StripeConfigStored,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
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", put(update_mail_config))
|
||||
.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", put(update_config))
|
||||
}
|
||||
@@ -180,7 +184,13 @@ async fn get_stats(
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用量统计失败").with_source(err))?;
|
||||
|
||||
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)
|
||||
.await
|
||||
@@ -689,8 +699,9 @@ struct GrantCreditsResponse {
|
||||
period_end: DateTime<Utc>,
|
||||
used_units: i32,
|
||||
bonus_units: i32,
|
||||
total_units: i32,
|
||||
remaining_units: i32,
|
||||
redeemed_units: i64,
|
||||
total_units: i64,
|
||||
remaining_units: i64,
|
||||
}
|
||||
|
||||
async fn grant_credits(
|
||||
@@ -722,6 +733,9 @@ async fn grant_credits(
|
||||
SELECT id, plan_id, current_period_start, current_period_end
|
||||
FROM subscriptions
|
||||
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
|
||||
LIMIT 1
|
||||
"#,
|
||||
@@ -781,6 +795,7 @@ async fn grant_credits(
|
||||
struct UsageRow {
|
||||
used_units: i32,
|
||||
bonus_units: i32,
|
||||
grant_used_units: i32,
|
||||
}
|
||||
|
||||
let usage = sqlx::query_as::<_, UsageRow>(
|
||||
@@ -789,7 +804,7 @@ async fn grant_credits(
|
||||
SET bonus_units = bonus_units + $1,
|
||||
updated_at = NOW()
|
||||
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)
|
||||
@@ -818,8 +833,27 @@ async fn grant_credits(
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
||||
|
||||
let total_units = plan_units + usage.bonus_units;
|
||||
let remaining = (total_units - usage.used_units).max(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(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 {
|
||||
success: true,
|
||||
@@ -829,6 +863,7 @@ async fn grant_credits(
|
||||
period_end,
|
||||
used_units: usage.used_units,
|
||||
bonus_units: usage.bonus_units,
|
||||
redeemed_units,
|
||||
total_units,
|
||||
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)]
|
||||
struct MailConfigView {
|
||||
enabled: bool,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::api::envelope::Envelope;
|
||||
use crate::auth;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::mail;
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
@@ -79,11 +80,13 @@ async fn register(
|
||||
validate_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>(
|
||||
r#"
|
||||
INSERT INTO users (email, username, password_hash)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO users (email, username, password_hash, email_verified_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING
|
||||
id,
|
||||
email,
|
||||
@@ -97,6 +100,7 @@ async fn register(
|
||||
.bind(req.email.to_lowercase())
|
||||
.bind(&req.username)
|
||||
.bind(password_hash)
|
||||
.bind(verified_at)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.map_err(map_unique_violation)?;
|
||||
@@ -108,34 +112,38 @@ async fn register(
|
||||
&user.role,
|
||||
)?;
|
||||
|
||||
let verification_token = generate_token();
|
||||
let token_hash = sha256_hex(&verification_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(24);
|
||||
if verification_required {
|
||||
let verification_token = generate_token();
|
||||
let token_hash = sha256_hex(&verification_token);
|
||||
let expires_at_db = Utc::now() + Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
||||
|
||||
let verification_url = format!(
|
||||
"{}/verify-email?token={}",
|
||||
state.config.public_base_url, verification_token
|
||||
);
|
||||
|
||||
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.bind(token_hash)
|
||||
.bind(expires_at_db)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
||||
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
|
||||
})?;
|
||||
|
||||
let verification_url = format!(
|
||||
"{}/verify-email?token={}",
|
||||
state.config.public_base_url, verification_token
|
||||
);
|
||||
|
||||
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
let body = RegisterResponse {
|
||||
user: UserView {
|
||||
id: user.id,
|
||||
@@ -145,7 +153,11 @@ async fn register(
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
},
|
||||
token,
|
||||
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
|
||||
message: if verification_required {
|
||||
"注册成功,验证邮件已发送至您的邮箱".to_string()
|
||||
} else {
|
||||
"注册成功".to_string()
|
||||
},
|
||||
};
|
||||
|
||||
Ok(Json(Envelope {
|
||||
@@ -213,6 +225,7 @@ async fn login(
|
||||
}
|
||||
|
||||
verify_password(&req.password, &user.password_hash)?;
|
||||
let verification_required = settings::email_verification_required(&state).await?;
|
||||
|
||||
let (token, expires_at) = auth::issue_jwt(
|
||||
&state.config.jwt_secret,
|
||||
@@ -231,7 +244,7 @@ async fn login(
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
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> {
|
||||
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
|
||||
let key = format!(
|
||||
"rate:send_verification:{}:{}",
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::idempotency;
|
||||
use crate::services::quota;
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -153,7 +154,9 @@ async fn get_subscription(
|
||||
FROM subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
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
|
||||
LIMIT 1
|
||||
"#,
|
||||
@@ -239,11 +242,12 @@ async fn get_subscription(
|
||||
struct UsageResponse {
|
||||
period_start: DateTime<Utc>,
|
||||
period_end: DateTime<Utc>,
|
||||
used_units: i32,
|
||||
included_units: i32,
|
||||
bonus_units: i32,
|
||||
total_units: i32,
|
||||
remaining_units: i32,
|
||||
used_units: i64,
|
||||
included_units: i64,
|
||||
bonus_units: i64,
|
||||
redeemed_units: i64,
|
||||
total_units: i64,
|
||||
remaining_units: i64,
|
||||
}
|
||||
|
||||
async fn get_usage(
|
||||
@@ -262,33 +266,7 @@ async fn get_usage(
|
||||
|
||||
let billing = billing::get_user_billing(&state, user_id).await?;
|
||||
|
||||
#[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(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);
|
||||
let usage = quota::user_usage_balance(&state, &billing).await?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
@@ -296,10 +274,11 @@ async fn get_usage(
|
||||
period_start: billing.period_start,
|
||||
period_end: billing.period_end,
|
||||
used_units: usage.used_units,
|
||||
included_units: included,
|
||||
included_units: usage.included_units,
|
||||
bonus_units: usage.bonus_units,
|
||||
total_units: total,
|
||||
remaining_units: remaining,
|
||||
redeemed_units: usage.redeemed_units,
|
||||
total_units: usage.total_units,
|
||||
remaining_units: usage.remaining_units,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1044,41 +1044,7 @@ async fn ensure_quota_available(
|
||||
ctx: &BillingContext,
|
||||
needed_units: i32,
|
||||
) -> Result<(), AppError> {
|
||||
if needed_units <= 0 {
|
||||
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(())
|
||||
quota::ensure_user_units(state, ctx, needed_units).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1252,48 +1218,7 @@ async fn charge_one_unit(
|
||||
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 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, "当期配额已用完"));
|
||||
}
|
||||
quota::consume_user_unit(tx, billing, bytes_in, bytes_out).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -130,10 +130,13 @@ async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result<Option<Princip
|
||||
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
|
||||
}
|
||||
|
||||
let verification_required =
|
||||
crate::services::settings::email_verification_required(state).await?;
|
||||
|
||||
Ok(Some(Principal::User {
|
||||
user_id: user.id,
|
||||
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)
|
||||
.await;
|
||||
|
||||
let verification_required =
|
||||
crate::services::settings::email_verification_required(state).await?;
|
||||
|
||||
Ok(Some(Principal::ApiKey {
|
||||
user_id: row.user_id,
|
||||
api_key_id: row.id,
|
||||
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 envelope;
|
||||
mod health;
|
||||
mod redemption;
|
||||
mod response;
|
||||
mod tasks;
|
||||
mod user;
|
||||
@@ -62,6 +63,7 @@ fn v1_router() -> Router<AppState> {
|
||||
.merge(billing::router())
|
||||
.merge(webhooks::router())
|
||||
.merge(user::router())
|
||||
.merge(redemption::router())
|
||||
.merge(admin::router())
|
||||
.merge(admin_storage::router())
|
||||
.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::{CompressionLevel, ImageFmt};
|
||||
use crate::services::idempotency;
|
||||
use crate::services::quota;
|
||||
use crate::services::storage;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -647,41 +648,7 @@ async fn ensure_quota_available(
|
||||
ctx: &BillingContext,
|
||||
needed_units: i32,
|
||||
) -> Result<(), AppError> {
|
||||
if needed_units <= 0 {
|
||||
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(())
|
||||
quota::ensure_user_units(state, ctx, needed_units).await
|
||||
}
|
||||
|
||||
async fn anonymous_remaining_units(
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::mail;
|
||||
use crate::services::settings;
|
||||
use crate::state::AppState;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
@@ -98,6 +99,8 @@ async fn get_profile(
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
let verification_required = settings::email_verification_required(&state).await?;
|
||||
|
||||
Ok(Json(Envelope {
|
||||
success: true,
|
||||
data: UserView {
|
||||
@@ -105,7 +108,7 @@ async fn get_profile(
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
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_username = user.username.clone();
|
||||
let mut email_changed = false;
|
||||
let verification_required = settings::email_verification_required(&state).await?;
|
||||
|
||||
if let Some(email) = req.email.as_ref() {
|
||||
let email = email.trim().to_lowercase();
|
||||
@@ -192,7 +196,7 @@ async fn update_profile(
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
email_verified: user.email_verified_at.is_some(),
|
||||
email_verified: user.email_verified_at.is_some() || !verification_required,
|
||||
},
|
||||
message: "暂无更新".to_string(),
|
||||
},
|
||||
@@ -205,8 +209,10 @@ async fn update_profile(
|
||||
.await
|
||||
.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
|
||||
} else if email_changed {
|
||||
Some(Utc::now())
|
||||
} else {
|
||||
user.email_verified_at
|
||||
};
|
||||
@@ -231,7 +237,7 @@ async fn update_profile(
|
||||
.map_err(map_unique_violation)?;
|
||||
|
||||
let mut verification_link: Option<String> = None;
|
||||
if email_changed {
|
||||
if email_changed && verification_required {
|
||||
let token = generate_token();
|
||||
let token_hash = sha256_hex(&token);
|
||||
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()
|
||||
} else {
|
||||
"资料已更新".to_string()
|
||||
@@ -283,7 +289,7 @@ async fn update_profile(
|
||||
email: updated.email,
|
||||
username: updated.username,
|
||||
role: updated.role,
|
||||
email_verified: updated.email_verified_at.is_some(),
|
||||
email_verified: updated.email_verified_at.is_some() || !verification_required,
|
||||
},
|
||||
message,
|
||||
},
|
||||
|
||||
@@ -48,6 +48,8 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result<Billing
|
||||
FROM subscriptions
|
||||
WHERE user_id = $1
|
||||
AND status IN ('active', 'trialing', 'past_due')
|
||||
AND current_period_start <= NOW()
|
||||
AND current_period_end > NOW()
|
||||
ORDER BY current_period_end DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
|
||||
@@ -12,44 +12,65 @@ static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
|
||||
#[derive(Debug, FromRow)]
|
||||
struct AdminRow {
|
||||
id: Uuid,
|
||||
email: String,
|
||||
username: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
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 {
|
||||
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_password = admin_password.trim().to_string();
|
||||
if admin_email.is_empty() || admin_password.is_empty() {
|
||||
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_username(&admin_username)?;
|
||||
validate_password(&admin_password)?;
|
||||
|
||||
let existing = sqlx::query_as::<_, AdminRow>(
|
||||
let mut matching = sqlx::query_as::<_, AdminRow>(
|
||||
r#"
|
||||
SELECT id, username, role::text AS role
|
||||
SELECT id, email, username, role::text AS role
|
||||
FROM users
|
||||
WHERE email = $1
|
||||
WHERE email = $1 OR username = $2
|
||||
ORDER BY (email = $1) DESC
|
||||
LIMIT 2
|
||||
"#,
|
||||
)
|
||||
.bind(&admin_email)
|
||||
.fetch_optional(&state.db)
|
||||
.bind(&admin_username)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.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)?;
|
||||
|
||||
if let Some(row) = existing {
|
||||
@@ -113,6 +134,9 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
|
||||
if row.role != "admin" {
|
||||
info!(admin_email = %admin_email, "管理员权限已启用");
|
||||
}
|
||||
if row.email != admin_email {
|
||||
info!(admin_username = %admin_username, "按用户名匹配到已有管理员,保留原邮箱");
|
||||
}
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
@@ -1,8 +1,247 @@
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing::BillingContext;
|
||||
use crate::state::AppState;
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
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(
|
||||
state: &AppState,
|
||||
@@ -71,3 +310,42 @@ fn utc8_date() -> String {
|
||||
let now = Utc::now() + Duration::hours(8);
|
||||
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>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct StripeConfigStored {
|
||||
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> {
|
||||
let Some(cfg) = load_system_config::<StripeConfigStored>(state, "stripe").await? else {
|
||||
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())
|
||||
.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_out: u64,
|
||||
) -> Result<(), AppError> {
|
||||
// Ensure usage period row exists.
|
||||
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, "当期配额已用完"));
|
||||
}
|
||||
quota::consume_user_unit(tx, billing, bytes_in, bytes_out).await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
|
||||
Reference in New Issue
Block a user