feat: add configurable verification and redemption codes

This commit is contained in:
237899745
2026-07-25 13:57:39 +08:00
parent d1f093685d
commit c6e96464d3
32 changed files with 2108 additions and 261 deletions

View File

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

View File

@@ -9,7 +9,8 @@
- **图片压缩**:支持 PNG/JPG/JPEG/WebP/AVIF/GIF/BMP/TIFF/ICO仅静态图片支持格式转换
- **批量处理**:支持多图片同时上传和处理
- **压缩率**1-100JPEG/WebP/AVIF 以该比例为体积上限;无损格式按安全方式尽力优化)
- **用户系统**注册、登录、API Key 管理
- **用户系统**:注册、登录、可开关邮箱验证、API Key 管理
- **兑换码**:套餐卡、限时次数卡、批量生成、停用与兑换审计
- **计费与用量**:套餐/订阅/配额/发票
- **管理员后台**用户管理、系统监控、配置管理、S3 多端点管理与连通性测试
- **对象存储**Garage/MinIO/AWS S3 兼容,私有 Bucket + 短期签名直连下载

View File

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

View File

@@ -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网站任务进度

View File

@@ -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"}', '邮件服务配置(密码加密存储)');

View File

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

View File

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

View File

@@ -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') },

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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:{}:{}",

View File

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

View File

@@ -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#"

View File

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

View File

@@ -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
View 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(&note)
.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");
}
}

View File

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

View File

@@ -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,
},

View File

@@ -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
"#,

View File

@@ -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#"

View File

@@ -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),
));
}
}

View File

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

View File

@@ -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#"