From c6e96464d3945dfe40bbc99cb47ee5bb2cb57ee2 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sat, 25 Jul 2026 13:57:39 +0800 Subject: [PATCH] feat: add configurable verification and redemption codes --- .env.example | 3 +- README.md | 3 +- docker/.env.production.example | 2 +- docs/api.md | 58 +- docs/database.md | 3 +- docs/deployment.md | 2 + frontend/src/app/layouts/AdminLayout.vue | 7 + frontend/src/app/router.ts | 1 + frontend/src/pages/HomePage.vue | 9 +- frontend/src/pages/RegisterPage.vue | 2 +- frontend/src/pages/admin/AdminBillingPage.vue | 3 +- .../src/pages/admin/AdminIntegrationsPage.vue | 47 +- .../src/pages/admin/AdminRedemptionPage.vue | 254 +++++ .../pages/dashboard/DashboardBillingPage.vue | 88 +- .../src/pages/dashboard/DashboardHomePage.vue | 15 +- .../pages/dashboard/DashboardSettingsPage.vue | 1 + frontend/src/services/api.ts | 121 +++ migrations/006_redemption_codes.sql | 82 ++ src/api/admin.rs | 112 ++- src/api/auth.rs | 76 +- src/api/billing.rs | 51 +- src/api/compress.rs | 79 +- src/api/context.rs | 10 +- src/api/mod.rs | 2 + src/api/redemption.rs | 883 ++++++++++++++++++ src/api/tasks.rs | 37 +- src/api/user.rs | 18 +- src/services/billing.rs | 2 + src/services/bootstrap.rs | 46 +- src/services/quota.rs | 280 +++++- src/services/settings.rs | 28 + src/worker/mod.rs | 44 +- 32 files changed, 2108 insertions(+), 261 deletions(-) create mode 100644 frontend/src/pages/admin/AdminRedemptionPage.vue create mode 100644 migrations/006_redemption_codes.sql create mode 100644 src/api/redemption.rs diff --git a/.env.example b/.env.example index 4aeaf41..73e2be3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 7202920..5c4b270 100644 --- a/README.md +++ b/README.md @@ -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 + 短期签名直连下载 diff --git a/docker/.env.production.example b/docker/.env.production.example index 595b992..99b5ef1 100644 --- a/docker/.env.production.example +++ b/docker/.env.production.example @@ -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 diff --git a/docs/api.md b/docs/api.md index 552480f..0f4d670 100644 --- a/docs/api.md +++ b/docs/api.md @@ -576,8 +576,9 @@ Authorization: Bearer "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 ``` +### 9.7 兑换套餐卡或次数卡 +```http +POST /redemptions/redeem +Authorization: Bearer +Content-Type: application/json + +{ "code": "IMG-XXXX-XXXX-XXXX-XXXX" } +``` + +### 9.8 获取自己的兑换记录 +```http +GET /redemptions +Authorization: Bearer +``` + +次数卡额度拥有独立有效期,扣减时优先使用更早到期的可用额度。套餐卡不会覆盖仍然有效的 Stripe 订阅。 + --- ## 10. Webhooks(支付回调) @@ -713,6 +731,42 @@ Authorization: Bearer 凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。活动端点不能直接编辑或删除。 +### 11.7 兑换码管理 + +```http +GET /admin/redemption-codes?page=1&limit=50 +Authorization: Bearer +``` + +```http +POST /admin/redemption-codes +Authorization: Bearer +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 +Content-Type: application/json + +{ "is_active": false } +``` + +### 11.8 邮箱验证开关 + +`GET /admin/auth` 和 `PUT /admin/auth` 的配置体包含 `email_verification_required`。该配置独立于 SMTP,修改后立即生效,不需要重启服务。 + --- ## 12. WebSocket(网站任务进度) diff --git a/docs/database.md b/docs/database.md index fd6b317..82d5b41 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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"}', '邮件服务配置(密码加密存储)'); diff --git a/docs/deployment.md b/docs/deployment.md index 87340ae..2bf5f29 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/frontend/src/app/layouts/AdminLayout.vue b/frontend/src/app/layouts/AdminLayout.vue index d2c4189..82c75e6 100644 --- a/frontend/src/app/layouts/AdminLayout.vue +++ b/frontend/src/app/layouts/AdminLayout.vue @@ -44,6 +44,13 @@ > 订阅与额度 + + 兑换码 + 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') }, diff --git a/frontend/src/pages/HomePage.vue b/frontend/src/pages/HomePage.vue index 2717b98..625c64f 100644 --- a/frontend/src/pages/HomePage.vue +++ b/frontend/src/pages/HomePage.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) { diff --git a/frontend/src/pages/RegisterPage.vue b/frontend/src/pages/RegisterPage.vue index aae8985..d5099c1 100644 --- a/frontend/src/pages/RegisterPage.vue +++ b/frontend/src/pages/RegisterPage.vue @@ -38,7 +38,7 @@ async function submit() {

注册

-

注册后必须验证邮箱才能使用登录态压缩与 API 能力。

+

邮箱是否需要验证由管理员设置,注册后会自动提示。

{{ error }} diff --git a/frontend/src/pages/admin/AdminBillingPage.vue b/frontend/src/pages/admin/AdminBillingPage.vue index 0f8339f..27a6782 100644 --- a/frontend/src/pages/admin/AdminBillingPage.vue +++ b/frontend/src/pages/admin/AdminBillingPage.vue @@ -280,7 +280,8 @@ onMounted(async () => {
当前周期:{{ 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 }}
diff --git a/frontend/src/pages/admin/AdminIntegrationsPage.vue b/frontend/src/pages/admin/AdminIntegrationsPage.vue index 845abb4..af9e4fd 100644 --- a/frontend/src/pages/admin/AdminIntegrationsPage.vue +++ b/frontend/src/pages/admin/AdminIntegrationsPage.vue @@ -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(null) const mailError = ref(null) +const emailVerificationRequired = ref(true) +const authBusy = ref(false) +const authMessage = ref(null) +const authError = ref(null) + const testEmail = ref('') const testBusy = ref(false) const testMessage = ref(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)
+
+
邮箱验证策略
+

此开关独立于 SMTP 配置,保存后立即影响注册、修改邮箱、压缩与 API Key 权限。

+ +
+ +
{{ authMessage }}
+
{{ authError }}
+
+
+
邮件服务配置
diff --git a/frontend/src/pages/admin/AdminRedemptionPage.vue b/frontend/src/pages/admin/AdminRedemptionPage.vue new file mode 100644 index 0000000..3fbc12d --- /dev/null +++ b/frontend/src/pages/admin/AdminRedemptionPage.vue @@ -0,0 +1,254 @@ + + + diff --git a/frontend/src/pages/dashboard/DashboardBillingPage.vue b/frontend/src/pages/dashboard/DashboardBillingPage.vue index 749e4d4..c4bf7e3 100644 --- a/frontend/src/pages/dashboard/DashboardBillingPage.vue +++ b/frontend/src/pages/dashboard/DashboardBillingPage.vue @@ -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([]) const subscription = ref(null) const usage = ref(null) const invoices = ref([]) +const redemptions = ref([]) const busy = ref(false) +const redemptionCode = ref('') +const redemptionBusy = ref(false) +const redemptionMessage = ref(null) +const redemptionError = ref(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() {
套餐额度 {{ usage?.included_units ?? 0 }} + 赠送 {{ usage?.bonus_units ?? 0 }}
+
+ 另有 {{ usage?.redeemed_units ?? 0 }} 次限时兑换额度 +
周期
@@ -138,6 +183,43 @@ async function openPortal() {
+
+
兑换套餐或次数
+

输入管理员发放的兑换码。次数卡会按最早到期顺序使用。

+
+ + +
+
{{ redemptionMessage }}
+
{{ redemptionError }}
+ +
+ + + + + + + + + + + + +
兑换码权益剩余有效期
{{ item.code_hint }}{{ item.benefit_kind === 'plan' ? item.plan_name : `${item.units ?? 0} 次` }}{{ item.benefit_kind === 'units' ? `${item.remaining_units ?? 0} 次` : '—' }}{{ new Date(item.benefit_expires_at).toLocaleString() }}
+
+
+
充值额度 / 购买套餐
diff --git a/frontend/src/pages/dashboard/DashboardHomePage.vue b/frontend/src/pages/dashboard/DashboardHomePage.vue index 461df32..9d5848c 100644 --- a/frontend/src/pages/dashboard/DashboardHomePage.vue +++ b/frontend/src/pages/dashboard/DashboardHomePage.vue @@ -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) { diff --git a/frontend/src/pages/dashboard/DashboardSettingsPage.vue b/frontend/src/pages/dashboard/DashboardSettingsPage.vue index 66d634b..0555edb 100644 --- a/frontend/src/pages/dashboard/DashboardSettingsPage.vue +++ b/frontend/src/pages/dashboard/DashboardSettingsPage.vue @@ -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) { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a737b77..187c9fa 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { + return apiJson('/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 { + return apiGet('/api/v1/admin/auth', token) +} + +export async function updateAuthConfig( + token: string, + emailVerificationRequired: boolean, +): Promise { + return apiJson( + '/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 diff --git a/migrations/006_redemption_codes.sql b/migrations/006_redemption_codes.sql new file mode 100644 index 0000000..871c724 --- /dev/null +++ b/migrations/006_redemption_codes.sql @@ -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; diff --git a/src/api/admin.rs b/src/api/admin.rs index 57d865f..9192578 100644 --- a/src/api/admin.rs +++ b/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 { .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, 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, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, 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::(&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, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, 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, diff --git a/src/api/auth.rs b/src/api/auth.rs index dd47849..5053502 100644 --- a/src/api/auth.rs +++ b/src/api/auth.rs @@ -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>, 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:{}:{}", diff --git a/src/api/billing.rs b/src/api/billing.rs index 82db557..09099fd 100644 --- a/src/api/billing.rs +++ b/src/api/billing.rs @@ -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, period_end: DateTime, - 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, }, })) } diff --git a/src/api/compress.rs b/src/api/compress.rs index 4512dfb..ed7747d 100644 --- a/src/api/compress.rs +++ b/src/api/compress.rs @@ -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 = 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#" diff --git a/src/api/context.rs b/src/api/context.rs index 9d34af9..7b042d4 100644 --- a/src/api/context.rs +++ b/src/api/context.rs @@ -130,10 +130,13 @@ async fn try_jwt(state: &AppState, headers: &HeaderMap) -> Result Router { .merge(billing::router()) .merge(webhooks::router()) .merge(user::router()) + .merge(redemption::router()) .merge(admin::router()) .merge(admin_storage::router()) .fallback(response::not_found) diff --git a/src/api/redemption.rs b/src/api/redemption.rs new file mode 100644 index 0000000..5c80a27 --- /dev/null +++ b/src/api/redemption.rs @@ -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 { + 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, + limit: Option, +} + +#[derive(Debug, FromRow, Serialize)] +struct AdminCodeView { + id: Uuid, + code_hint: String, + benefit_kind: String, + plan_id: Option, + plan_name: Option, + units: Option, + duration_days: i32, + redeem_before: Option>, + is_active: bool, + note: Option, + created_at: DateTime, + redeemed_at: Option>, + redeemed_by: Option, + redeemed_username: Option, +} + +#[derive(Debug, Serialize)] +struct AdminCodesResponse { + codes: Vec, + page: u32, + limit: u32, + total: i64, +} + +async fn list_admin_codes( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Query(query): Query, +) -> Result>, 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, + units: Option, + duration_days: i32, + redeem_before: Option>, + quantity: Option, + note: Option, +} + +#[derive(Debug, Serialize)] +struct GeneratedCodeView { + id: Uuid, + code: String, + code_hint: String, +} + +#[derive(Debug, Serialize)] +struct CreateCodesResponse { + message: String, + codes: Vec, +} + +async fn create_codes( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, 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, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Path(code_id): Path, + Json(req): Json, +) -> Result>, 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, + plan_name: Option, + units: Option, + benefit_starts_at: DateTime, + benefit_expires_at: DateTime, +} + +#[derive(Debug, FromRow)] +struct RedeemableCodeRow { + id: Uuid, + benefit_kind: String, + plan_id: Option, + plan_name: Option, + plan_is_active: Option, + units: Option, + duration_days: i32, + redeem_before: Option>, + is_active: bool, + redemption_id: Option, +} + +async fn redeem_code( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result>, 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, + current_period_end: DateTime, +} + +async fn apply_plan_benefit( + tx: &mut Transaction<'_, Postgres>, + user_id: Uuid, + plan_id: Uuid, + duration: Duration, + now: DateTime, +) -> Result<(DateTime, DateTime), 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, + end: DateTime, +) -> Result { + 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, + units: Option, + remaining_units: Option, + benefit_starts_at: DateTime, + benefit_expires_at: DateTime, + redeemed_at: DateTime, +} + +#[derive(Debug, Serialize)] +struct UserRedemptionsResponse { + redemptions: Vec, +} + +async fn list_user_redemptions( + State(state): State, + jar: axum_extra::extract::cookie::CookieJar, + ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, +) -> Result>, 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::(); + format!( + "{CODE_PREFIX}-{}-{}-{}-{}", + &payload[0..4], + &payload[4..8], + &payload[8..12], + &payload[12..16] + ) +} + +fn normalize_code(input: &str) -> Option { + let compact = input + .chars() + .filter(|ch| *ch != '-' && !ch.is_ascii_whitespace()) + .collect::() + .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"); + } +} diff --git a/src/api/tasks.rs b/src/api/tasks.rs index 499895c..a6a3d28 100644 --- a/src/api/tasks.rs +++ b/src/api/tasks.rs @@ -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( diff --git a/src/api/user.rs b/src/api/user.rs index e452547..42964bf 100644 --- a/src/api/user.rs +++ b/src/api/user.rs @@ -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 = 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, }, diff --git a/src/services/billing.rs b/src/services/billing.rs index ac6d8de..a57e5ca 100644 --- a/src/services/billing.rs +++ b/src/services/billing.rs @@ -48,6 +48,8 @@ pub async fn get_user_billing(state: &AppState, user_id: Uuid) -> Result NOW() ORDER BY current_period_end DESC LIMIT 1 "#, diff --git a/src/services/bootstrap.rs b/src/services/bootstrap.rs index eff0cb7..0bed3a1 100644 --- a/src/services/bootstrap.rs +++ b/src/services/bootstrap.rs @@ -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#" diff --git a/src/services/quota.rs b/src/services/quota.rs index ca0a9fd..a7e71fd 100644 --- a/src/services/quota.rs +++ b/src/services/quota.rs @@ -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, +} + +pub async fn user_usage_balance( + state: &AppState, + billing: &BillingContext, +) -> Result { + 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>, + plan_expires_at: DateTime, +) -> 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), + )); + } +} diff --git a/src/services/settings.rs b/src/services/settings.rs index 948153a..ed1efce 100644 --- a/src/services/settings.rs +++ b/src/services/settings.rs @@ -28,6 +28,16 @@ pub struct MailConfigStored { pub log_links_when_disabled: Option, } +#[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, @@ -117,6 +127,13 @@ pub async fn load_mail_settings(state: &AppState) -> Result })) } +pub async fn email_verification_required(state: &AppState) -> Result { + Ok(load_system_config::(state, "auth") + .await? + .map(|config| config.email_verification_required) + .unwrap_or(true)) +} + pub async fn load_stripe_secrets(state: &AppState) -> Result, AppError> { let Some(cfg) = load_system_config::(state, "stripe").await? else { return Ok(None); @@ -225,3 +242,14 @@ pub async fn get_stripe_webhook_secret(state: &AppState) -> Result 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 = 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#"