mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-02 15:52:26 +08:00
refactor: 重构邮箱验证模块并修复代码审查问题
- 重构: 将 verification 模块重命名为 email,目录结构更清晰 - 新增: 独立的邮件配置管理页面 (EmailSettings.vue) - 新增: 邮件模板管理功能(支持自定义 HTML 模板和预览) - 新增: 查询验证状态 API,支持页面刷新后恢复验证流程 - 新增: 注册邮箱后缀白名单/黑名单限制功能 - 修复: 统一密码最小长度为 6 位(前后端一致) - 修复: SMTP 连接添加 30 秒超时配置,防止 worker 挂起 - 修复: 邮件模板变量添加 HTML 转义,防止 XSS - 修复: 验证状态清除改为 db.commit 后执行,避免竞态条件 - 优化: RegisterDialog 重写验证码输入组件,提升用户体验 - 优化: Input 组件支持 disableAutofill 属性
This commit is contained in:
@@ -124,6 +124,37 @@ export interface ModelExport {
|
||||
config?: any
|
||||
}
|
||||
|
||||
// 邮件模板接口
|
||||
export interface EmailTemplateInfo {
|
||||
type: string
|
||||
name: string
|
||||
variables: string[]
|
||||
subject: string
|
||||
html: string
|
||||
is_custom: boolean
|
||||
default_subject?: string
|
||||
default_html?: string
|
||||
}
|
||||
|
||||
export interface EmailTemplatesResponse {
|
||||
templates: EmailTemplateInfo[]
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
html: string
|
||||
variables: Record<string, string>
|
||||
}
|
||||
|
||||
export interface EmailTemplateResetResponse {
|
||||
message: string
|
||||
template: {
|
||||
type: string
|
||||
name: string
|
||||
subject: string
|
||||
html: string
|
||||
}
|
||||
}
|
||||
|
||||
// Provider 模型查询响应
|
||||
export interface ProviderModelsQueryResponse {
|
||||
success: boolean
|
||||
@@ -395,5 +426,52 @@ export const adminApi = {
|
||||
config
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 邮件模板相关
|
||||
// 获取所有邮件模板
|
||||
async getEmailTemplates(): Promise<EmailTemplatesResponse> {
|
||||
const response = await apiClient.get<EmailTemplatesResponse>('/api/admin/system/email/templates')
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取指定类型的邮件模板
|
||||
async getEmailTemplate(templateType: string): Promise<EmailTemplateInfo> {
|
||||
const response = await apiClient.get<EmailTemplateInfo>(
|
||||
`/api/admin/system/email/templates/${templateType}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 更新邮件模板
|
||||
async updateEmailTemplate(
|
||||
templateType: string,
|
||||
data: { subject?: string; html?: string }
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.put<{ message: string }>(
|
||||
`/api/admin/system/email/templates/${templateType}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 预览邮件模板
|
||||
async previewEmailTemplate(
|
||||
templateType: string,
|
||||
data?: { html?: string } & Record<string, string>
|
||||
): Promise<EmailTemplatePreviewResponse> {
|
||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/preview`,
|
||||
data || {}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 重置邮件模板为默认值
|
||||
async resetEmailTemplate(templateType: string): Promise<EmailTemplateResetResponse> {
|
||||
const response = await apiClient.post<EmailTemplateResetResponse>(
|
||||
`/api/admin/system/email/templates/${templateType}/reset`
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,18 @@ export interface VerifyEmailResponse {
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface VerificationStatusRequest {
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface VerificationStatusResponse {
|
||||
email: string
|
||||
has_pending_code: boolean
|
||||
is_verified: boolean
|
||||
cooldown_remaining: number | null
|
||||
code_expires_in: number | null
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string
|
||||
username: string
|
||||
@@ -67,7 +79,6 @@ export interface RegisterResponse {
|
||||
export interface RegistrationSettingsResponse {
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
verification_code_expire_minutes?: number
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -154,5 +165,13 @@ export const authApi = {
|
||||
'/api/auth/registration-settings'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getVerificationStatus(email: string): Promise<VerificationStatusResponse> {
|
||||
const response = await apiClient.post<VerificationStatusResponse>(
|
||||
'/api/auth/verification-status',
|
||||
{ email }
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- 内容区域:统一添加 padding -->
|
||||
<div class="px-6 py-3">
|
||||
<!-- 内容区域:可选添加 padding -->
|
||||
<div :class="noPadding ? '' : 'px-6 py-3'">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -105,6 +105,7 @@ const props = defineProps<{
|
||||
icon?: Component // Lucide icon component
|
||||
iconClass?: string // Custom icon color class
|
||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
:class="inputClass"
|
||||
:value="modelValue"
|
||||
:autocomplete="autocompleteAttr"
|
||||
:data-lpignore="disableAutofill ? 'true' : undefined"
|
||||
:data-1p-ignore="disableAutofill ? 'true' : undefined"
|
||||
:data-form-type="disableAutofill ? 'other' : undefined"
|
||||
v-bind="$attrs"
|
||||
@input="handleInput"
|
||||
>
|
||||
@@ -16,6 +19,7 @@ interface Props {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
autocomplete?: string
|
||||
disableAutofill?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -23,7 +27,12 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
|
||||
const autocompleteAttr = computed(() => {
|
||||
if (props.disableAutofill) {
|
||||
return 'one-time-code'
|
||||
}
|
||||
return props.autocomplete ?? 'off'
|
||||
})
|
||||
|
||||
const inputClass = computed(() =>
|
||||
cn(
|
||||
|
||||
@@ -3,42 +3,41 @@
|
||||
v-model:open="isOpen"
|
||||
size="lg"
|
||||
>
|
||||
<DialogContent>
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-full border-2 border-primary/20 flex items-center justify-center bg-primary/5"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Logo 和标题 -->
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="mb-4 rounded-3xl border border-primary/30 dark:border-[#cc785c]/30 bg-primary/5 dark:bg-transparent p-4 shadow-inner shadow-white/40 dark:shadow-[#cc785c]/10">
|
||||
<img
|
||||
src="@/assets/logo.svg"
|
||||
src="/aether_adaptive.svg"
|
||||
alt="Logo"
|
||||
class="w-10 h-10"
|
||||
class="h-16 w-16"
|
||||
>
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold text-slate-900 dark:text-white">
|
||||
注册新账户
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
请填写您的邮箱和个人信息完成注册
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle class="text-center text-2xl">
|
||||
注册新账户
|
||||
</DialogTitle>
|
||||
<DialogDescription class="text-center">
|
||||
请填写您的邮箱和个人信息完成注册
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<form
|
||||
class="space-y-4 mt-4"
|
||||
class="space-y-4"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<!-- Email -->
|
||||
<div class="space-y-2">
|
||||
<Label for="register-email">邮箱</Label>
|
||||
<Label for="reg-email">邮箱 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="register-email"
|
||||
id="reg-email"
|
||||
v-model="formData.email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
placeholder="hello@example.com"
|
||||
required
|
||||
disable-autofill
|
||||
:disabled="isLoading || emailVerified"
|
||||
/>
|
||||
</div>
|
||||
@@ -46,110 +45,127 @@
|
||||
<!-- Verification Code Section -->
|
||||
<div
|
||||
v-if="requireEmailVerification"
|
||||
class="space-y-2"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label for="verification-code">验证码</Label>
|
||||
<Label>验证码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="h-auto p-0 text-xs"
|
||||
:disabled="isLoading || !canSendCode || emailVerified"
|
||||
:disabled="isSendingCode || !canSendCode || emailVerified"
|
||||
@click="handleSendCode"
|
||||
>
|
||||
{{ sendCodeButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
<VerificationCodeInput
|
||||
ref="codeInputRef"
|
||||
v-model="formData.verificationCode"
|
||||
:has-error="verificationError"
|
||||
:length="6"
|
||||
@complete="handleCodeComplete"
|
||||
/>
|
||||
<p
|
||||
v-if="verificationError"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
验证码错误,请重新输入
|
||||
</p>
|
||||
<p
|
||||
v-if="emailVerified"
|
||||
class="text-xs text-green-600"
|
||||
>
|
||||
✓ 邮箱验证成功
|
||||
</p>
|
||||
<div class="flex justify-center gap-2">
|
||||
<!-- 发送中显示 loading -->
|
||||
<div
|
||||
v-if="isSendingCode"
|
||||
class="flex items-center justify-center gap-2 h-14 text-muted-foreground"
|
||||
>
|
||||
<svg
|
||||
class="animate-spin h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
/>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">正在发送验证码...</span>
|
||||
</div>
|
||||
<!-- 验证码输入框 -->
|
||||
<template v-else>
|
||||
<input
|
||||
v-for="(_, index) in 6"
|
||||
:key="index"
|
||||
:ref="(el) => setCodeInputRef(index, el as HTMLInputElement)"
|
||||
v-model="codeDigits[index]"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="1"
|
||||
autocomplete="off"
|
||||
data-form-type="other"
|
||||
class="w-12 h-14 text-center text-xl font-semibold border-2 rounded-lg bg-background transition-all focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||
:class="verificationError ? 'border-destructive' : 'border-border focus:border-primary'"
|
||||
:disabled="emailVerified"
|
||||
@input="handleCodeInput(index, $event)"
|
||||
@keydown="handleCodeKeyDown(index, $event)"
|
||||
@paste="handleCodePaste"
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username -->
|
||||
<div class="space-y-2">
|
||||
<Label for="register-username">用户名</Label>
|
||||
<Label for="reg-uname">用户名 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="register-username"
|
||||
id="reg-uname"
|
||||
v-model="formData.username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
required
|
||||
disable-autofill
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="register-password">密码</Label>
|
||||
<Label :for="`pwd-${formNonce}`">密码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="register-password"
|
||||
:id="`pwd-${formNonce}`"
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
placeholder="至少 8 位字符"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:name="`pwd-${formNonce}`"
|
||||
placeholder="至少 6 个字符"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
密码长度至少 8 位
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<div class="space-y-2">
|
||||
<Label for="register-confirm-password">确认密码</Label>
|
||||
<Label :for="`pwd-confirm-${formNonce}`">确认密码 <span class="text-muted-foreground">*</span></Label>
|
||||
<Input
|
||||
id="register-confirm-password"
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="formData.confirmPassword"
|
||||
type="password"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
:name="`pwd-confirm-${formNonce}`"
|
||||
placeholder="再次输入密码"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="isLoading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
:disabled="isLoading || !canSubmit"
|
||||
>
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span class="animate-spin">⏳</span>
|
||||
{{ loadingText }}
|
||||
</span>
|
||||
<span v-else>注册</span>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 text-center text-sm">
|
||||
<!-- 登录链接 -->
|
||||
<div class="text-center text-sm">
|
||||
已有账户?
|
||||
<Button
|
||||
variant="link"
|
||||
@@ -159,26 +175,37 @@
|
||||
立即登录
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="w-full sm:w-auto border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:text-primary hover:border-primary/50 hover:bg-primary/5 dark:hover:text-primary dark:hover:border-primary/50 dark:hover:bg-primary/10"
|
||||
:disabled="isLoading"
|
||||
@click="handleCancel"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
class="w-full sm:w-auto bg-primary hover:bg-primary/90 text-white border-0"
|
||||
:disabled="isLoading || !canSubmit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ isLoading ? loadingText : '注册' }}
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { ref, computed, watch, onUnmounted, nextTick } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Button,
|
||||
Input,
|
||||
Label
|
||||
} from '@/components/ui'
|
||||
import VerificationCodeInput from '@/components/VerificationCodeInput.vue'
|
||||
import { Dialog } from '@/components/ui'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
@@ -197,9 +224,95 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
const { showToast, success, error: showError } = useToast()
|
||||
const { success, error: showError } = useToast()
|
||||
|
||||
const codeInputRef = ref<InstanceType<typeof VerificationCodeInput> | null>(null)
|
||||
// Form nonce for password fields (prevent autofill)
|
||||
const formNonce = ref(createFormNonce())
|
||||
|
||||
function createFormNonce(): string {
|
||||
return Math.random().toString(36).slice(2, 10)
|
||||
}
|
||||
|
||||
// Verification code inputs
|
||||
const codeInputRefs = ref<(HTMLInputElement | null)[]>([])
|
||||
const codeDigits = ref<string[]>(['', '', '', '', '', ''])
|
||||
|
||||
const setCodeInputRef = (index: number, el: HTMLInputElement | null) => {
|
||||
codeInputRefs.value[index] = el
|
||||
}
|
||||
|
||||
// Handle verification code input
|
||||
const handleCodeInput = (index: number, event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const value = input.value
|
||||
|
||||
// Only allow digits
|
||||
if (!/^\d*$/.test(value)) {
|
||||
input.value = codeDigits.value[index]
|
||||
return
|
||||
}
|
||||
|
||||
codeDigits.value[index] = value
|
||||
|
||||
// Auto-focus next input
|
||||
if (value && index < 5) {
|
||||
codeInputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
|
||||
// Check if all digits are filled
|
||||
const fullCode = codeDigits.value.join('')
|
||||
if (fullCode.length === 6 && /^\d+$/.test(fullCode)) {
|
||||
handleCodeComplete(fullCode)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeKeyDown = (index: number, event: KeyboardEvent) => {
|
||||
// Handle backspace
|
||||
if (event.key === 'Backspace') {
|
||||
if (!codeDigits.value[index] && index > 0) {
|
||||
// If current input is empty, move to previous and clear it
|
||||
codeInputRefs.value[index - 1]?.focus()
|
||||
codeDigits.value[index - 1] = ''
|
||||
} else {
|
||||
// Clear current input
|
||||
codeDigits.value[index] = ''
|
||||
}
|
||||
}
|
||||
// Handle arrow keys
|
||||
else if (event.key === 'ArrowLeft' && index > 0) {
|
||||
codeInputRefs.value[index - 1]?.focus()
|
||||
} else if (event.key === 'ArrowRight' && index < 5) {
|
||||
codeInputRefs.value[index + 1]?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodePaste = (event: ClipboardEvent) => {
|
||||
event.preventDefault()
|
||||
const pastedData = event.clipboardData?.getData('text') || ''
|
||||
const cleanedData = pastedData.replace(/\D/g, '').slice(0, 6)
|
||||
|
||||
if (cleanedData) {
|
||||
// Fill digits
|
||||
for (let i = 0; i < 6; i++) {
|
||||
codeDigits.value[i] = cleanedData[i] || ''
|
||||
}
|
||||
|
||||
// Focus the next empty input or the last input
|
||||
const nextEmptyIndex = codeDigits.value.findIndex((d) => !d)
|
||||
const focusIndex = nextEmptyIndex >= 0 ? nextEmptyIndex : 5
|
||||
codeInputRefs.value[focusIndex]?.focus()
|
||||
|
||||
// Check if all digits are filled
|
||||
if (cleanedData.length === 6) {
|
||||
handleCodeComplete(cleanedData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearCodeInputs = () => {
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
codeInputRefs.value[0]?.focus()
|
||||
}
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
@@ -216,11 +329,12 @@ const formData = ref({
|
||||
|
||||
const isLoading = ref(false)
|
||||
const loadingText = ref('注册中...')
|
||||
const isSendingCode = ref(false)
|
||||
const emailVerified = ref(false)
|
||||
const verificationError = ref(false)
|
||||
const codeSentAt = ref<number | null>(null)
|
||||
const cooldownSeconds = ref(0)
|
||||
const expireMinutes = ref(30)
|
||||
const expireMinutes = ref(5)
|
||||
const cooldownTimer = ref<number | null>(null)
|
||||
|
||||
// Send code cooldown timer
|
||||
@@ -231,7 +345,8 @@ const canSendCode = computed(() => {
|
||||
})
|
||||
|
||||
const sendCodeButtonText = computed(() => {
|
||||
if (emailVerified.value) return '已验证'
|
||||
if (isSendingCode.value) return '发送中...'
|
||||
if (emailVerified.value) return '验证成功'
|
||||
if (cooldownSeconds.value > 0) return `${cooldownSeconds.value}秒后重试`
|
||||
if (codeSentAt.value) return '重新发送验证码'
|
||||
return '发送验证码'
|
||||
@@ -257,13 +372,73 @@ const canSubmit = computed(() => {
|
||||
}
|
||||
|
||||
// Check password length
|
||||
if (formData.value.password.length < 8) {
|
||||
if (formData.value.password.length < 6) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 查询并恢复验证状态
|
||||
const checkAndRestoreVerificationStatus = async (email: string) => {
|
||||
if (!email || !props.requireEmailVerification) return
|
||||
|
||||
try {
|
||||
const status = await authApi.getVerificationStatus(email)
|
||||
|
||||
// 注意:不恢复 is_verified 状态
|
||||
// 刷新页面后需要重新发送验证码并验证,防止验证码被他人使用
|
||||
// 只恢复"有待验证验证码"的状态(冷却时间)
|
||||
if (status.has_pending_code) {
|
||||
codeSentAt.value = Date.now()
|
||||
verificationError.value = false
|
||||
|
||||
// 恢复冷却时间
|
||||
if (status.cooldown_remaining && status.cooldown_remaining > 0) {
|
||||
startCooldown(status.cooldown_remaining)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 查询失败时静默处理,不影响用户体验
|
||||
}
|
||||
}
|
||||
|
||||
// 邮箱查询防抖定时器
|
||||
let emailCheckTimer: number | null = null
|
||||
|
||||
// 监听邮箱变化,查询验证状态
|
||||
watch(
|
||||
() => formData.value.email,
|
||||
(newEmail, oldEmail) => {
|
||||
// 邮箱变化时重置验证状态
|
||||
if (newEmail !== oldEmail) {
|
||||
emailVerified.value = false
|
||||
verificationError.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
}
|
||||
|
||||
// 清除之前的定时器
|
||||
if (emailCheckTimer !== null) {
|
||||
clearTimeout(emailCheckTimer)
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(newEmail)) return
|
||||
|
||||
// 防抖:500ms 后查询验证状态
|
||||
emailCheckTimer = window.setTimeout(() => {
|
||||
checkAndRestoreVerificationStatus(newEmail)
|
||||
}, 500)
|
||||
}
|
||||
)
|
||||
|
||||
// Reset form when dialog opens
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
@@ -295,6 +470,9 @@ onUnmounted(() => {
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
}
|
||||
if (emailCheckTimer !== null) {
|
||||
clearTimeout(emailCheckTimer)
|
||||
}
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -307,16 +485,21 @@ const resetForm = () => {
|
||||
}
|
||||
emailVerified.value = false
|
||||
verificationError.value = false
|
||||
isSendingCode.value = false
|
||||
codeSentAt.value = null
|
||||
cooldownSeconds.value = 0
|
||||
|
||||
// Reset password field nonce
|
||||
formNonce.value = createFormNonce()
|
||||
|
||||
// Clear timer
|
||||
if (cooldownTimer.value !== null) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
|
||||
codeInputRef.value?.clear()
|
||||
// Clear verification code inputs
|
||||
codeDigits.value = ['', '', '', '', '', '']
|
||||
}
|
||||
|
||||
const handleSendCode = async () => {
|
||||
@@ -332,8 +515,7 @@ const handleSendCode = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '发送中...'
|
||||
isSendingCode.value = true
|
||||
|
||||
try {
|
||||
const response = await authApi.sendVerificationCode(formData.value.email)
|
||||
@@ -349,23 +531,30 @@ const handleSendCode = async () => {
|
||||
// Start 60 second cooldown
|
||||
startCooldown(60)
|
||||
|
||||
// Focus the verification code input
|
||||
setTimeout(() => {
|
||||
codeInputRef.value?.focus()
|
||||
}, 100)
|
||||
// Focus the first verification code input
|
||||
nextTick(() => {
|
||||
codeInputRefs.value[0]?.focus()
|
||||
})
|
||||
} else {
|
||||
showError(response.message || '请稍后重试', '发送失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || error.message || '网络错误,请重试', '发送失败')
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '网络错误,请重试'
|
||||
showError(errorMsg, '发送失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isSendingCode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCodeComplete = async (code: string) => {
|
||||
if (!formData.value.email || code.length !== 6) return
|
||||
|
||||
// 如果已经验证成功,不再重复验证
|
||||
if (emailVerified.value) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingText.value = '验证中...'
|
||||
verificationError.value = false
|
||||
@@ -380,13 +569,17 @@ const handleCodeComplete = async (code: string) => {
|
||||
verificationError.value = true
|
||||
showError(response.message || '验证码错误', '验证失败')
|
||||
// Clear the code input
|
||||
codeInputRef.value?.clear()
|
||||
clearCodeInputs()
|
||||
}
|
||||
} catch (error: any) {
|
||||
verificationError.value = true
|
||||
showError(error.response?.data?.detail || error.message || '验证码错误,请重试', '验证失败')
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '验证码错误,请重试'
|
||||
showError(errorMsg, '验证失败')
|
||||
// Clear the code input
|
||||
codeInputRef.value?.clear()
|
||||
clearCodeInputs()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -400,8 +593,8 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (formData.value.password.length < 8) {
|
||||
showError('密码长度至少 8 位', '密码过短')
|
||||
if (formData.value.password.length < 6) {
|
||||
showError('密码长度至少 6 位', '密码过短')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -426,7 +619,11 @@ const handleSubmit = async () => {
|
||||
emit('success')
|
||||
isOpen.value = false
|
||||
} catch (error: any) {
|
||||
showError(error.response?.data?.detail || error.message || '注册失败,请重试', '注册失败')
|
||||
const errorMsg = error.response?.data?.detail
|
||||
|| error.response?.data?.error?.message
|
||||
|| error.message
|
||||
|| '注册失败,请重试'
|
||||
showError(errorMsg, '注册失败')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -320,6 +320,7 @@ import {
|
||||
Megaphone,
|
||||
Menu,
|
||||
X,
|
||||
Mail,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -421,6 +422,7 @@ const navigation = computed(() => {
|
||||
{ name: '缓存监控', href: '/admin/cache-monitoring', icon: Gauge },
|
||||
{ name: 'IP 安全', href: '/admin/ip-security', icon: Shield },
|
||||
{ name: '审计日志', href: '/admin/audit-logs', icon: AlertTriangle },
|
||||
{ name: '邮件配置', href: '/admin/email', icon: Mail },
|
||||
{ name: '系统设置', href: '/admin/system', icon: Cog },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -106,6 +106,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'SystemSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/SystemSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'email',
|
||||
name: 'EmailSettings',
|
||||
component: () => importWithRetry(() => import('@/views/admin/EmailSettings.vue'))
|
||||
},
|
||||
{
|
||||
path: 'audit-logs',
|
||||
name: 'AuditLogs',
|
||||
|
||||
@@ -1191,4 +1191,11 @@ body[theme-mode='dark'] .literary-annotation {
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
|
||||
/* Password masking without type="password" to prevent browser autofill */
|
||||
.-webkit-text-security-disc {
|
||||
-webkit-text-security: disc;
|
||||
-moz-text-security: disc;
|
||||
text-security: disc;
|
||||
}
|
||||
}
|
||||
|
||||
856
frontend/src/views/admin/EmailSettings.vue
Normal file
856
frontend/src/views/admin/EmailSettings.vue
Normal file
@@ -0,0 +1,856 @@
|
||||
<template>
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="邮件配置"
|
||||
description="配置邮件发送服务和注册邮箱限制"
|
||||
/>
|
||||
|
||||
<div class="mt-6 space-y-6">
|
||||
<!-- SMTP 邮件配置 -->
|
||||
<CardSection
|
||||
title="SMTP 邮件配置"
|
||||
description="配置 SMTP 服务用于发送验证码邮件"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="testSmtpLoading"
|
||||
@click="handleTestSmtp"
|
||||
>
|
||||
{{ testSmtpLoading ? '测试中...' : '测试连接' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="smtpSaveLoading"
|
||||
@click="saveSmtpConfig"
|
||||
>
|
||||
{{ smtpSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-host"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
v-model="emailConfig.smtp_host"
|
||||
type="text"
|
||||
placeholder="smtp.gmail.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮件服务器地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-port"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
v-model.number="emailConfig.smtp_port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
常用端口: 587 (TLS), 465 (SSL), 25 (无加密)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-user"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 用户名
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-user"
|
||||
v-model="emailConfig.smtp_user"
|
||||
type="text"
|
||||
placeholder="your-email@example.com"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
通常是您的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 密码
|
||||
</Label>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="smtp-password"
|
||||
v-model="emailConfig.smtp_password"
|
||||
type="text"
|
||||
:placeholder="smtpPasswordIsSet ? '已设置(留空保持不变)' : '请输入密码'"
|
||||
class="-webkit-text-security-disc"
|
||||
:class="smtpPasswordIsSet ? 'pr-8' : ''"
|
||||
autocomplete="one-time-code"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
data-form-type="other"
|
||||
/>
|
||||
<button
|
||||
v-if="smtpPasswordIsSet"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="清除已保存的密码"
|
||||
@click="handleClearSmtpPassword"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮箱密码或应用专用密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-email"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-email"
|
||||
v-model="emailConfig.smtp_from_email"
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-name"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人名称
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-name"
|
||||
v-model="emailConfig.smtp_from_name"
|
||||
type="text"
|
||||
placeholder="Aether"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的名称
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-encryption"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
加密方式
|
||||
</Label>
|
||||
<Select
|
||||
v-model="smtpEncryption"
|
||||
v-model:open="smtpEncryptionSelectOpen"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="smtp-encryption"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ssl">
|
||||
SSL (隐式加密)
|
||||
</SelectItem>
|
||||
<SelectItem value="tls">
|
||||
TLS / STARTTLS
|
||||
</SelectItem>
|
||||
<SelectItem value="none">
|
||||
无加密
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Gmail 等服务推荐使用 SSL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 邮件模板配置 -->
|
||||
<CardSection
|
||||
title="邮件模板"
|
||||
description="配置不同类型邮件的 HTML 模板"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="templateSaveLoading"
|
||||
@click="handleSaveTemplate"
|
||||
>
|
||||
{{ templateSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<!-- 模板类型选择 -->
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
v-for="tpl in templateTypes"
|
||||
:key="tpl.type"
|
||||
class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors"
|
||||
:class="activeTemplateType === tpl.type
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'"
|
||||
@click="handleTemplateTypeChange(tpl.type)"
|
||||
>
|
||||
{{ tpl.name }}
|
||||
<span
|
||||
v-if="tpl.is_custom"
|
||||
class="ml-1 text-xs opacity-70"
|
||||
>(已自定义)</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 当前模板编辑区 -->
|
||||
<div
|
||||
v-if="currentTemplate"
|
||||
class="space-y-4"
|
||||
>
|
||||
<!-- 可用变量提示 -->
|
||||
<div class="text-xs text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
|
||||
可用变量:
|
||||
<code
|
||||
v-for="(v, i) in currentTemplate.variables"
|
||||
:key="v"
|
||||
class="mx-1 px-1.5 py-0.5 bg-background rounded text-foreground"
|
||||
>{{ formatVariable(v) }}<span v-if="i < currentTemplate.variables.length - 1">,</span></code>
|
||||
</div>
|
||||
|
||||
<!-- 邮件主题 -->
|
||||
<div>
|
||||
<Label
|
||||
for="template-subject"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
邮件主题
|
||||
</Label>
|
||||
<Input
|
||||
id="template-subject"
|
||||
v-model="templateSubject"
|
||||
type="text"
|
||||
:placeholder="currentTemplate.default_subject || '验证码'"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- HTML 模板编辑 -->
|
||||
<div>
|
||||
<Label
|
||||
for="template-html"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
HTML 模板
|
||||
</Label>
|
||||
<textarea
|
||||
id="template-html"
|
||||
v-model="templateHtml"
|
||||
rows="16"
|
||||
class="mt-1 w-full font-mono text-sm bg-muted/30 border border-border rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y"
|
||||
:placeholder="currentTemplate.default_html || '<!DOCTYPE html>...'"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="previewLoading"
|
||||
@click="handlePreviewTemplate"
|
||||
>
|
||||
{{ previewLoading ? '加载中...' : '预览' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="!currentTemplate.is_custom"
|
||||
@click="handleResetTemplate"
|
||||
>
|
||||
重置为默认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载中状态 -->
|
||||
<div
|
||||
v-else-if="templateLoading"
|
||||
class="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
正在加载模板...
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 预览对话框 -->
|
||||
<Dialog
|
||||
v-model:open="previewDialogOpen"
|
||||
no-padding
|
||||
max-width="xl"
|
||||
>
|
||||
<!-- 自定义窗口布局 -->
|
||||
<div class="flex flex-col max-h-[80vh]">
|
||||
<!-- 窗口标题栏 -->
|
||||
<div class="flex items-center justify-between px-4 py-2.5 bg-muted/50 border-b border-border/50 flex-shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="flex gap-1.5 group"
|
||||
title="关闭"
|
||||
@click="previewDialogOpen = false"
|
||||
>
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-red-400/80 group-hover:bg-red-500" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-yellow-400/80" />
|
||||
<div class="w-2.5 h-2.5 rounded-full bg-green-400/80" />
|
||||
</button>
|
||||
<span class="text-sm font-medium text-foreground/80">邮件预览</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground font-mono">
|
||||
{{ currentTemplate?.name || '模板' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件头部信息 -->
|
||||
<div class="px-4 py-3 bg-muted/30 border-b border-border/30 space-y-1.5 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground w-14">主题:</span>
|
||||
<span class="font-medium text-foreground">{{ templateSubject || '(无主题)' }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground w-14">收件人:</span>
|
||||
<span class="text-foreground/80">example@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件内容区域 - 直接显示邮件模板 -->
|
||||
<div class="flex-1 overflow-auto">
|
||||
<iframe
|
||||
v-if="previewHtml"
|
||||
ref="previewIframe"
|
||||
:srcdoc="previewHtml"
|
||||
class="w-full border-0"
|
||||
style="min-height: 400px;"
|
||||
sandbox="allow-same-origin"
|
||||
@load="adjustIframeHeight"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- 注册邮箱限制 -->
|
||||
<CardSection
|
||||
title="注册邮箱限制"
|
||||
description="控制允许注册的邮箱后缀,支持白名单或黑名单模式"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="emailSuffixSaveLoading"
|
||||
@click="saveEmailSuffixConfig"
|
||||
>
|
||||
{{ emailSuffixSaveLoading ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<Label
|
||||
for="email-suffix-mode"
|
||||
class="block text-sm font-medium mb-2"
|
||||
>
|
||||
限制模式
|
||||
</Label>
|
||||
<Select
|
||||
v-model="emailConfig.email_suffix_mode"
|
||||
v-model:open="emailSuffixModeSelectOpen"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="email-suffix-mode"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
不限制 - 允许所有邮箱
|
||||
</SelectItem>
|
||||
<SelectItem value="whitelist">
|
||||
白名单 - 仅允许列出的后缀
|
||||
</SelectItem>
|
||||
<SelectItem value="blacklist">
|
||||
黑名单 - 拒绝列出的后缀
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
<template v-if="emailConfig.email_suffix_mode === 'none'">
|
||||
不限制邮箱后缀,所有邮箱均可注册
|
||||
</template>
|
||||
<template v-else-if="emailConfig.email_suffix_mode === 'whitelist'">
|
||||
仅允许下方列出后缀的邮箱注册
|
||||
</template>
|
||||
<template v-else>
|
||||
拒绝下方列出后缀的邮箱注册
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="emailConfig.email_suffix_mode !== 'none'">
|
||||
<Label
|
||||
for="email-suffix-list"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
邮箱后缀列表
|
||||
</Label>
|
||||
<Input
|
||||
id="email-suffix-list"
|
||||
v-model="emailSuffixListStr"
|
||||
placeholder="gmail.com, outlook.com, qq.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
逗号分隔,例如: gmail.com, outlook.com, qq.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import { PageHeader, PageContainer, CardSection } from '@/components/layout'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi, type EmailTemplateInfo } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const { success, error } = useToast()
|
||||
|
||||
interface EmailConfig {
|
||||
// SMTP 邮件配置
|
||||
smtp_host: string | null
|
||||
smtp_port: number
|
||||
smtp_user: string | null
|
||||
smtp_password: string | null
|
||||
smtp_use_tls: boolean
|
||||
smtp_use_ssl: boolean
|
||||
smtp_from_email: string | null
|
||||
smtp_from_name: string
|
||||
// 注册邮箱限制
|
||||
email_suffix_mode: 'none' | 'whitelist' | 'blacklist'
|
||||
email_suffix_list: string[]
|
||||
}
|
||||
|
||||
const smtpSaveLoading = ref(false)
|
||||
const emailSuffixSaveLoading = ref(false)
|
||||
const smtpEncryptionSelectOpen = ref(false)
|
||||
const emailSuffixModeSelectOpen = ref(false)
|
||||
const testSmtpLoading = ref(false)
|
||||
const smtpPasswordIsSet = ref(false)
|
||||
|
||||
// 邮件模板相关状态
|
||||
const templateLoading = ref(false)
|
||||
const templateSaveLoading = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const previewDialogOpen = ref(false)
|
||||
const previewHtml = ref('')
|
||||
const templateTypes = ref<EmailTemplateInfo[]>([])
|
||||
const activeTemplateType = ref('verification')
|
||||
const templateSubject = ref('')
|
||||
const templateHtml = ref('')
|
||||
const previewIframe = ref<HTMLIFrameElement | null>(null)
|
||||
|
||||
// 当前选中的模板
|
||||
const currentTemplate = computed(() => {
|
||||
return templateTypes.value.find(t => t.type === activeTemplateType.value)
|
||||
})
|
||||
|
||||
// 格式化变量显示(避免 Vue 模板中的双花括号语法冲突)
|
||||
function formatVariable(name: string): string {
|
||||
return `{{${name}}}`
|
||||
}
|
||||
|
||||
// 调整 iframe 高度以适应内容
|
||||
function adjustIframeHeight() {
|
||||
if (previewIframe.value) {
|
||||
try {
|
||||
const doc = previewIframe.value.contentDocument || previewIframe.value.contentWindow?.document
|
||||
if (doc && doc.body) {
|
||||
// 获取内容实际高度,添加一点余量
|
||||
const height = doc.body.scrollHeight + 20
|
||||
// 限制最大高度为视口的 70%
|
||||
const maxHeight = window.innerHeight * 0.7
|
||||
previewIframe.value.style.height = `${Math.min(height, maxHeight)}px`
|
||||
}
|
||||
} catch {
|
||||
// 跨域限制时使用默认高度
|
||||
previewIframe.value.style.height = '500px'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailConfig = ref<EmailConfig>({
|
||||
// SMTP 邮件配置
|
||||
smtp_host: null,
|
||||
smtp_port: 587,
|
||||
smtp_user: null,
|
||||
smtp_password: null,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_from_email: null,
|
||||
smtp_from_name: 'Aether',
|
||||
// 注册邮箱限制
|
||||
email_suffix_mode: 'none',
|
||||
email_suffix_list: [],
|
||||
})
|
||||
|
||||
// 计算属性:邮箱后缀列表数组和字符串之间的转换
|
||||
const emailSuffixListStr = computed({
|
||||
get: () => emailConfig.value.email_suffix_list.join(', '),
|
||||
set: (val: string) => {
|
||||
emailConfig.value.email_suffix_list = val
|
||||
.split(',')
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => s.length > 0)
|
||||
}
|
||||
})
|
||||
|
||||
// 计算属性:SMTP 加密方式(ssl/tls/none)
|
||||
const smtpEncryption = computed({
|
||||
get: () => {
|
||||
if (emailConfig.value.smtp_use_ssl) return 'ssl'
|
||||
if (emailConfig.value.smtp_use_tls) return 'tls'
|
||||
return 'none'
|
||||
},
|
||||
set: (val: string) => {
|
||||
emailConfig.value.smtp_use_ssl = val === 'ssl'
|
||||
emailConfig.value.smtp_use_tls = val === 'tls'
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadEmailConfig(),
|
||||
loadEmailTemplates()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadEmailTemplates() {
|
||||
templateLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.getEmailTemplates()
|
||||
templateTypes.value = response.templates
|
||||
|
||||
// 设置第一个模板为当前模板
|
||||
if (response.templates.length > 0) {
|
||||
const firstTemplate = response.templates[0]
|
||||
activeTemplateType.value = firstTemplate.type
|
||||
templateSubject.value = firstTemplate.subject
|
||||
templateHtml.value = firstTemplate.html
|
||||
}
|
||||
} catch (err) {
|
||||
error('加载邮件模板失败')
|
||||
log.error('加载邮件模板失败:', err)
|
||||
} finally {
|
||||
templateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemplateTypeChange(type: string) {
|
||||
activeTemplateType.value = type
|
||||
const template = templateTypes.value.find(t => t.type === type)
|
||||
if (template) {
|
||||
templateSubject.value = template.subject
|
||||
templateHtml.value = template.html
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveTemplate() {
|
||||
templateSaveLoading.value = true
|
||||
try {
|
||||
await adminApi.updateEmailTemplate(activeTemplateType.value, {
|
||||
subject: templateSubject.value,
|
||||
html: templateHtml.value
|
||||
})
|
||||
|
||||
// 更新本地状态
|
||||
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||
if (idx !== -1) {
|
||||
templateTypes.value[idx].subject = templateSubject.value
|
||||
templateTypes.value[idx].html = templateHtml.value
|
||||
templateTypes.value[idx].is_custom = true
|
||||
}
|
||||
|
||||
success('模板保存成功')
|
||||
} catch (err) {
|
||||
error('保存模板失败')
|
||||
log.error('保存模板失败:', err)
|
||||
} finally {
|
||||
templateSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewTemplate() {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const response = await adminApi.previewEmailTemplate(activeTemplateType.value, {
|
||||
html: templateHtml.value
|
||||
})
|
||||
previewHtml.value = response.html
|
||||
previewDialogOpen.value = true
|
||||
} catch (err) {
|
||||
error('预览模板失败')
|
||||
log.error('预览模板失败:', err)
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetTemplate() {
|
||||
try {
|
||||
const response = await adminApi.resetEmailTemplate(activeTemplateType.value)
|
||||
|
||||
// 更新本地状态
|
||||
const idx = templateTypes.value.findIndex(t => t.type === activeTemplateType.value)
|
||||
if (idx !== -1) {
|
||||
templateTypes.value[idx].subject = response.template.subject
|
||||
templateTypes.value[idx].html = response.template.html
|
||||
templateTypes.value[idx].is_custom = false
|
||||
}
|
||||
|
||||
templateSubject.value = response.template.subject
|
||||
templateHtml.value = response.template.html
|
||||
|
||||
success('模板已重置为默认值')
|
||||
} catch (err) {
|
||||
error('重置模板失败')
|
||||
log.error('重置模板失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmailConfig() {
|
||||
try {
|
||||
const configs = [
|
||||
// SMTP 邮件配置
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_user',
|
||||
'smtp_password',
|
||||
'smtp_use_tls',
|
||||
'smtp_use_ssl',
|
||||
'smtp_from_email',
|
||||
'smtp_from_name',
|
||||
// 注册邮箱限制
|
||||
'email_suffix_mode',
|
||||
'email_suffix_list',
|
||||
]
|
||||
|
||||
for (const key of configs) {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
// 特殊处理敏感字段:只记录是否已设置,不填充值
|
||||
if (key === 'smtp_password') {
|
||||
smtpPasswordIsSet.value = response.is_set === true
|
||||
// 不设置 smtp_password 的值,保持为 null
|
||||
} else if (response.value !== null && response.value !== undefined) {
|
||||
(emailConfig.value as any)[key] = response.value
|
||||
}
|
||||
} catch {
|
||||
// 配置不存在时使用默认值,无需处理
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error('加载邮件配置失败')
|
||||
log.error('加载邮件配置失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 SMTP 配置
|
||||
async function saveSmtpConfig() {
|
||||
smtpSaveLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'smtp_host',
|
||||
value: emailConfig.value.smtp_host,
|
||||
description: 'SMTP 服务器地址'
|
||||
},
|
||||
{
|
||||
key: 'smtp_port',
|
||||
value: emailConfig.value.smtp_port,
|
||||
description: 'SMTP 端口'
|
||||
},
|
||||
{
|
||||
key: 'smtp_user',
|
||||
value: emailConfig.value.smtp_user,
|
||||
description: 'SMTP 用户名'
|
||||
},
|
||||
// 只有输入了新密码才提交(空值表示保持原密码)
|
||||
...(emailConfig.value.smtp_password
|
||||
? [{
|
||||
key: 'smtp_password',
|
||||
value: emailConfig.value.smtp_password,
|
||||
description: 'SMTP 密码'
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
key: 'smtp_use_tls',
|
||||
value: emailConfig.value.smtp_use_tls,
|
||||
description: '是否使用 TLS 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_use_ssl',
|
||||
value: emailConfig.value.smtp_use_ssl,
|
||||
description: '是否使用 SSL 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_email',
|
||||
value: emailConfig.value.smtp_from_email,
|
||||
description: '发件人邮箱'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_name',
|
||||
value: emailConfig.value.smtp_from_name,
|
||||
description: '发件人名称'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
success('SMTP 配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存 SMTP 配置失败:', err)
|
||||
} finally {
|
||||
smtpSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存邮箱后缀限制配置
|
||||
async function saveEmailSuffixConfig() {
|
||||
emailSuffixSaveLoading.value = true
|
||||
try {
|
||||
const configItems = [
|
||||
{
|
||||
key: 'email_suffix_mode',
|
||||
value: emailConfig.value.email_suffix_mode,
|
||||
description: '邮箱后缀限制模式(none/whitelist/blacklist)'
|
||||
},
|
||||
{
|
||||
key: 'email_suffix_list',
|
||||
value: emailConfig.value.email_suffix_list,
|
||||
description: '邮箱后缀列表'
|
||||
},
|
||||
]
|
||||
|
||||
const promises = configItems.map(item =>
|
||||
adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
)
|
||||
|
||||
await Promise.all(promises)
|
||||
success('邮箱限制配置已保存')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存邮箱限制配置失败:', err)
|
||||
} finally {
|
||||
emailSuffixSaveLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 清除 SMTP 密码
|
||||
async function handleClearSmtpPassword() {
|
||||
try {
|
||||
await adminApi.deleteSystemConfig('smtp_password')
|
||||
smtpPasswordIsSet.value = false
|
||||
emailConfig.value.smtp_password = null
|
||||
success('SMTP 密码已清除')
|
||||
} catch (err) {
|
||||
error('清除密码失败')
|
||||
log.error('清除 SMTP 密码失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 SMTP 连接
|
||||
async function handleTestSmtp() {
|
||||
testSmtpLoading.value = true
|
||||
|
||||
try {
|
||||
// 如果没有输入新密码,不发送(后端会使用数据库中的密码)
|
||||
const result = await adminApi.testSmtpConnection({
|
||||
smtp_host: emailConfig.value.smtp_host,
|
||||
smtp_port: emailConfig.value.smtp_port,
|
||||
smtp_user: emailConfig.value.smtp_user,
|
||||
smtp_password: emailConfig.value.smtp_password || undefined,
|
||||
smtp_use_tls: emailConfig.value.smtp_use_tls,
|
||||
smtp_use_ssl: emailConfig.value.smtp_use_ssl,
|
||||
smtp_from_email: emailConfig.value.smtp_from_email,
|
||||
smtp_from_name: emailConfig.value.smtp_from_name
|
||||
})
|
||||
if (result.success) {
|
||||
success('SMTP 连接测试成功')
|
||||
} else {
|
||||
error(result.message || '未知错误', 'SMTP 连接测试失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.error('SMTP 连接测试失败:', err)
|
||||
const errMsg = err.response?.data?.detail || err.message || '未知错误'
|
||||
error(errMsg, 'SMTP 连接测试失败')
|
||||
} finally {
|
||||
testSmtpLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -185,218 +185,6 @@
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- SMTP 邮件配置 -->
|
||||
<CardSection
|
||||
title="SMTP 邮件配置"
|
||||
description="配置 SMTP 服务用于发送验证码邮件"
|
||||
>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-host"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 服务器地址
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-host"
|
||||
v-model="systemConfig.smtp_host"
|
||||
type="text"
|
||||
placeholder="smtp.gmail.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮件服务器地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-port"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 端口
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-port"
|
||||
v-model.number="systemConfig.smtp_port"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
常用端口: 587 (TLS), 465 (SSL), 25 (非加密)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-user"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 用户名
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-user"
|
||||
v-model="systemConfig.smtp_user"
|
||||
type="text"
|
||||
placeholder="your-email@example.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
通常是您的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-password"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
SMTP 密码
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-password"
|
||||
v-model="systemConfig.smtp_password"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
邮箱密码或应用专用密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-email"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-email"
|
||||
v-model="systemConfig.smtp_from_email"
|
||||
type="email"
|
||||
placeholder="noreply@example.com"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的邮箱地址
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-from-name"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
发件人名称
|
||||
</Label>
|
||||
<Input
|
||||
id="smtp-from-name"
|
||||
v-model="systemConfig.smtp_from_name"
|
||||
type="text"
|
||||
placeholder="Aether"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
显示为发件人的名称
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="verification-code-expire"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
验证码有效期(分钟)
|
||||
</Label>
|
||||
<Input
|
||||
id="verification-code-expire"
|
||||
v-model.number="systemConfig.verification_code_expire_minutes"
|
||||
type="number"
|
||||
placeholder="30"
|
||||
class="mt-1"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
验证码的有效时间
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="smtp-use-tls"
|
||||
v-model:checked="systemConfig.smtp_use_tls"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-use-tls"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
使用 TLS 加密
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
推荐开启以提高安全性
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center h-full">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="smtp-use-ssl"
|
||||
v-model:checked="systemConfig.smtp_use_ssl"
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
for="smtp-use-ssl"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
使用 SSL 加密 (465)
|
||||
</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
部分服务需要隐式 SSL,一般使用端口 465
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
:disabled="testSmtpLoading"
|
||||
@click="handleTestSmtp"
|
||||
>
|
||||
{{ testSmtpLoading ? '测试中...' : '测试 SMTP 连接' }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="smtpTestResult"
|
||||
class="mt-4 p-4 rounded-lg"
|
||||
:class="smtpTestResult.success ? 'bg-green-50 dark:bg-green-950' : 'bg-destructive/10'"
|
||||
>
|
||||
<p
|
||||
class="text-sm font-medium"
|
||||
:class="smtpTestResult.success ? 'text-green-700 dark:text-green-300' : 'text-destructive'"
|
||||
>
|
||||
{{ smtpTestResult.success ? '✓ SMTP 连接测试成功' : '✗ SMTP 连接测试失败' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="smtpTestResult.message"
|
||||
class="text-xs mt-1"
|
||||
:class="smtpTestResult.success ? 'text-green-600 dark:text-green-400' : 'text-destructive'"
|
||||
>
|
||||
{{ smtpTestResult.message }}
|
||||
</p>
|
||||
</div>
|
||||
</CardSection>
|
||||
|
||||
<!-- 独立余额 Key 过期管理 -->
|
||||
<CardSection
|
||||
title="独立余额 Key 过期管理"
|
||||
@@ -981,7 +769,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Download, Upload } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -1009,16 +797,6 @@ interface SystemConfig {
|
||||
// 用户注册
|
||||
enable_registration: boolean
|
||||
require_email_verification: boolean
|
||||
// SMTP 邮件配置
|
||||
smtp_host: string | null
|
||||
smtp_port: number
|
||||
smtp_user: string | null
|
||||
smtp_password: string | null
|
||||
smtp_use_tls: boolean
|
||||
smtp_use_ssl: boolean
|
||||
smtp_from_email: string | null
|
||||
smtp_from_name: string
|
||||
verification_code_expire_minutes: number
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: boolean
|
||||
// 日志记录
|
||||
@@ -1038,8 +816,6 @@ interface SystemConfig {
|
||||
|
||||
const loading = ref(false)
|
||||
const logLevelSelectOpen = ref(false)
|
||||
const testSmtpLoading = ref(false)
|
||||
const smtpTestResult = ref<{ success: boolean; message?: string } | null>(null)
|
||||
|
||||
// 导出/导入相关
|
||||
const exportLoading = ref(false)
|
||||
@@ -1070,16 +846,6 @@ const systemConfig = ref<SystemConfig>({
|
||||
// 用户注册
|
||||
enable_registration: false,
|
||||
require_email_verification: false,
|
||||
// SMTP 邮件配置
|
||||
smtp_host: null,
|
||||
smtp_port: 587,
|
||||
smtp_user: null,
|
||||
smtp_password: null,
|
||||
smtp_use_tls: true,
|
||||
smtp_use_ssl: false,
|
||||
smtp_from_email: null,
|
||||
smtp_from_name: 'Aether',
|
||||
verification_code_expire_minutes: 30,
|
||||
// 独立余额 Key 过期管理
|
||||
auto_delete_expired_keys: false,
|
||||
// 日志记录
|
||||
@@ -1136,16 +902,6 @@ async function loadSystemConfig() {
|
||||
// 用户注册
|
||||
'enable_registration',
|
||||
'require_email_verification',
|
||||
// SMTP 邮件配置
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_user',
|
||||
'smtp_password',
|
||||
'smtp_use_tls',
|
||||
'smtp_use_ssl',
|
||||
'smtp_from_email',
|
||||
'smtp_from_name',
|
||||
'verification_code_expire_minutes',
|
||||
// 独立余额 Key 过期管理
|
||||
'auto_delete_expired_keys',
|
||||
// 日志记录
|
||||
@@ -1205,52 +961,6 @@ async function saveSystemConfig() {
|
||||
value: systemConfig.value.require_email_verification,
|
||||
description: '是否需要邮箱验证'
|
||||
},
|
||||
// SMTP 邮件配置
|
||||
{
|
||||
key: 'smtp_host',
|
||||
value: systemConfig.value.smtp_host,
|
||||
description: 'SMTP 服务器地址'
|
||||
},
|
||||
{
|
||||
key: 'smtp_port',
|
||||
value: systemConfig.value.smtp_port,
|
||||
description: 'SMTP 端口'
|
||||
},
|
||||
{
|
||||
key: 'smtp_user',
|
||||
value: systemConfig.value.smtp_user,
|
||||
description: 'SMTP 用户名'
|
||||
},
|
||||
{
|
||||
key: 'smtp_password',
|
||||
value: systemConfig.value.smtp_password,
|
||||
description: 'SMTP 密码'
|
||||
},
|
||||
{
|
||||
key: 'smtp_use_tls',
|
||||
value: systemConfig.value.smtp_use_tls,
|
||||
description: '是否使用 TLS 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_use_ssl',
|
||||
value: systemConfig.value.smtp_use_ssl,
|
||||
description: '是否使用 SSL 加密'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_email',
|
||||
value: systemConfig.value.smtp_from_email,
|
||||
description: '发件人邮箱'
|
||||
},
|
||||
{
|
||||
key: 'smtp_from_name',
|
||||
value: systemConfig.value.smtp_from_name,
|
||||
description: '发件人名称'
|
||||
},
|
||||
{
|
||||
key: 'verification_code_expire_minutes',
|
||||
value: systemConfig.value.verification_code_expire_minutes,
|
||||
description: '验证码有效期(分钟)'
|
||||
},
|
||||
// 独立余额 Key 过期管理
|
||||
{
|
||||
key: 'auto_delete_expired_keys',
|
||||
@@ -1330,41 +1040,6 @@ async function saveSystemConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试 SMTP 连接
|
||||
async function handleTestSmtp() {
|
||||
testSmtpLoading.value = true
|
||||
smtpTestResult.value = null
|
||||
|
||||
try {
|
||||
const result = await adminApi.testSmtpConnection({
|
||||
smtp_host: systemConfig.value.smtp_host,
|
||||
smtp_port: systemConfig.value.smtp_port,
|
||||
smtp_user: systemConfig.value.smtp_user,
|
||||
smtp_password: systemConfig.value.smtp_password,
|
||||
smtp_use_tls: systemConfig.value.smtp_use_tls,
|
||||
smtp_use_ssl: systemConfig.value.smtp_use_ssl,
|
||||
smtp_from_email: systemConfig.value.smtp_from_email,
|
||||
smtp_from_name: systemConfig.value.smtp_from_name
|
||||
})
|
||||
smtpTestResult.value = result
|
||||
|
||||
if (result.success) {
|
||||
success('SMTP 连接测试成功')
|
||||
} else {
|
||||
error('SMTP 连接测试失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.error('SMTP 连接测试失败:', err)
|
||||
smtpTestResult.value = {
|
||||
success: false,
|
||||
message: err.response?.data?.detail || err.message || 'SMTP 连接测试失败'
|
||||
}
|
||||
error('SMTP 连接测试失败')
|
||||
} finally {
|
||||
testSmtpLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导出配置
|
||||
async function handleExportConfig() {
|
||||
exportLoading.value = true
|
||||
|
||||
@@ -477,8 +477,8 @@ async function changePassword() {
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
showError('密码长度至少8位')
|
||||
if (passwordForm.value.new_password.length < 6) {
|
||||
showError('密码长度至少6位')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.core.exceptions import InvalidRequestException, NotFoundException, tran
|
||||
from src.database import get_db
|
||||
from src.models.api import SystemSettingsRequest, SystemSettingsResponse
|
||||
from src.models.database import ApiKey, Provider, Usage, User
|
||||
from src.services.email.email_template import EmailTemplate
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
|
||||
@@ -126,6 +127,52 @@ async def test_smtp(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 邮件模板 API --------
|
||||
|
||||
|
||||
@router.get("/email/templates")
|
||||
async def get_email_templates(request: Request, db: Session = Depends(get_db)):
|
||||
"""获取所有邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplatesAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/email/templates/{template_type}")
|
||||
async def get_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取指定类型的邮件模板(管理员)"""
|
||||
adapter = AdminGetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/email/templates/{template_type}")
|
||||
async def update_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新邮件模板(管理员)"""
|
||||
adapter = AdminUpdateEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/preview")
|
||||
async def preview_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""预览邮件模板(管理员)"""
|
||||
adapter = AdminPreviewEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/email/templates/{template_type}/reset")
|
||||
async def reset_email_template(
|
||||
template_type: str, request: Request, db: Session = Depends(get_db)
|
||||
):
|
||||
"""重置邮件模板为默认值(管理员)"""
|
||||
adapter = AdminResetEmailTemplateAdapter(template_type=template_type)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# -------- 系统设置适配器 --------
|
||||
|
||||
|
||||
@@ -203,10 +250,16 @@ class AdminGetAllConfigsAdapter(AdminApiAdapter):
|
||||
class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 敏感配置项,不返回实际值
|
||||
SENSITIVE_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
value = SystemConfigService.get_config(context.db, self.key)
|
||||
if value is None:
|
||||
raise NotFoundException(f"配置项 '{self.key}' 不存在")
|
||||
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
||||
if self.key in self.SENSITIVE_KEYS:
|
||||
return {"key": self.key, "value": None, "is_set": bool(value)}
|
||||
return {"key": self.key, "value": value}
|
||||
|
||||
|
||||
@@ -214,18 +267,31 @@ class AdminGetSystemConfigAdapter(AdminApiAdapter):
|
||||
class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||
key: str
|
||||
|
||||
# 需要加密存储的配置项
|
||||
ENCRYPTED_KEYS = {"smtp_password"}
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
payload = context.ensure_json_body()
|
||||
value = payload.get("value")
|
||||
|
||||
# 对敏感配置进行加密
|
||||
if self.key in self.ENCRYPTED_KEYS and value:
|
||||
from src.core.crypto import crypto_service
|
||||
value = crypto_service.encrypt(value)
|
||||
|
||||
config = SystemConfigService.set_config(
|
||||
context.db,
|
||||
self.key,
|
||||
payload.get("value"),
|
||||
value,
|
||||
payload.get("description"),
|
||||
)
|
||||
|
||||
# 返回时不暴露加密后的值
|
||||
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
||||
|
||||
return {
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"value": display_value,
|
||||
"description": config.description,
|
||||
"updated_at": config.updated_at.isoformat(),
|
||||
}
|
||||
@@ -1096,28 +1162,40 @@ class AdminImportUsersAdapter(AdminApiAdapter):
|
||||
class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""测试 SMTP 连接"""
|
||||
from src.services.system.config import ConfigService
|
||||
from src.services.verification.email_sender import EmailSenderService
|
||||
from src.core.crypto import crypto_service
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.email.email_sender import EmailSenderService
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取密码:优先使用前端传入的明文密码,否则从数据库获取并解密
|
||||
smtp_password = payload.get("smtp_password")
|
||||
if not smtp_password:
|
||||
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||
if encrypted_password:
|
||||
try:
|
||||
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||
except Exception:
|
||||
# 解密失败,可能是旧的未加密密码
|
||||
smtp_password = encrypted_password
|
||||
|
||||
# 前端可传入未保存的配置,优先使用前端值,否则回退数据库
|
||||
config = {
|
||||
"smtp_host": payload.get("smtp_host") or ConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": payload.get("smtp_port") or ConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": payload.get("smtp_user") or ConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": payload.get("smtp_password") or ConfigService.get_config(db, "smtp_password"),
|
||||
"smtp_host": payload.get("smtp_host") or SystemConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": payload.get("smtp_port") or SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": payload.get("smtp_user") or SystemConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_use_tls": payload.get("smtp_use_tls")
|
||||
if payload.get("smtp_use_tls") is not None
|
||||
else ConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
else SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": payload.get("smtp_use_ssl")
|
||||
if payload.get("smtp_use_ssl") is not None
|
||||
else ConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
else SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": payload.get("smtp_from_email")
|
||||
or ConfigService.get_config(db, "smtp_from_email"),
|
||||
or SystemConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": payload.get("smtp_from_name")
|
||||
or ConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
|
||||
# 验证必要配置
|
||||
@@ -1144,10 +1222,200 @@ class AdminTestSmtpAdapter(AdminApiAdapter):
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 连接测试失败: {error_msg}"
|
||||
"message": error_msg
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"SMTP 连接测试失败: {str(e)}"
|
||||
"message": str(e)
|
||||
}
|
||||
|
||||
|
||||
# -------- 邮件模板适配器 --------
|
||||
|
||||
|
||||
class AdminGetEmailTemplatesAdapter(AdminApiAdapter):
|
||||
"""获取所有邮件模板"""
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
db = context.db
|
||||
templates = []
|
||||
|
||||
for template_type, type_info in EmailTemplate.TEMPLATE_TYPES.items():
|
||||
# 获取自定义模板或默认模板
|
||||
template = EmailTemplate.get_template(db, template_type)
|
||||
default_template = EmailTemplate.get_default_template(template_type)
|
||||
|
||||
# 检查是否使用了自定义模板
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
templates.append(
|
||||
{
|
||||
"type": template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
}
|
||||
)
|
||||
|
||||
return {"templates": templates}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""获取指定类型的邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
|
||||
is_custom = (
|
||||
template["subject"] != default_template["subject"]
|
||||
or template["html"] != default_template["html"]
|
||||
)
|
||||
|
||||
return {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"variables": type_info["variables"],
|
||||
"subject": template["subject"],
|
||||
"html": template["html"],
|
||||
"is_custom": is_custom,
|
||||
"default_subject": default_template["subject"],
|
||||
"default_html": default_template["html"],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminUpdateEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""更新邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
subject = payload.get("subject")
|
||||
html = payload.get("html")
|
||||
|
||||
# 至少需要提供一个字段
|
||||
if subject is None and html is None:
|
||||
raise InvalidRequestException("请提供 subject 或 html")
|
||||
|
||||
# 保存模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
if subject is not None:
|
||||
if subject:
|
||||
SystemConfigService.set_config(db, subject_key, subject)
|
||||
else:
|
||||
# 空字符串表示删除自定义值,恢复默认
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
|
||||
if html is not None:
|
||||
if html:
|
||||
SystemConfigService.set_config(db, html_key, html)
|
||||
else:
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
return {"message": "模板保存成功"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminPreviewEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""预览邮件模板"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
payload = context.ensure_json_body() or {}
|
||||
|
||||
# 获取模板 HTML(优先使用请求体中的,否则使用数据库中的)
|
||||
html = payload.get("html")
|
||||
if not html:
|
||||
template = EmailTemplate.get_template(db, self.template_type)
|
||||
html = template["html"]
|
||||
|
||||
# 获取预览变量
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
# 构建预览变量,使用请求中的值或默认示例值
|
||||
preview_variables = {}
|
||||
default_values = {
|
||||
"app_name": SystemConfigService.get_config(db, "email_app_name")
|
||||
or SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
"code": "123456",
|
||||
"expire_minutes": "30",
|
||||
"email": "example@example.com",
|
||||
"reset_link": "https://example.com/reset?token=abc123",
|
||||
}
|
||||
|
||||
for var in type_info["variables"]:
|
||||
preview_variables[var] = payload.get(var, default_values.get(var, f"{{{{{var}}}}}"))
|
||||
|
||||
# 渲染模板
|
||||
rendered_html = EmailTemplate.render_template(html, preview_variables)
|
||||
|
||||
return {
|
||||
"html": rendered_html,
|
||||
"variables": preview_variables,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminResetEmailTemplateAdapter(AdminApiAdapter):
|
||||
"""重置邮件模板为默认值"""
|
||||
|
||||
template_type: str
|
||||
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
# 验证模板类型
|
||||
if self.template_type not in EmailTemplate.TEMPLATE_TYPES:
|
||||
raise NotFoundException(f"模板类型 '{self.template_type}' 不存在")
|
||||
|
||||
db = context.db
|
||||
|
||||
# 删除自定义模板
|
||||
subject_key = f"email_template_{self.template_type}_subject"
|
||||
html_key = f"email_template_{self.template_type}_html"
|
||||
|
||||
SystemConfigService.delete_config(db, subject_key)
|
||||
SystemConfigService.delete_config(db, html_key)
|
||||
|
||||
# 返回默认模板
|
||||
default_template = EmailTemplate.get_default_template(self.template_type)
|
||||
type_info = EmailTemplate.TEMPLATE_TYPES[self.template_type]
|
||||
|
||||
return {
|
||||
"message": "模板已重置为默认值",
|
||||
"template": {
|
||||
"type": self.template_type,
|
||||
"name": type_info["name"],
|
||||
"subject": default_template["subject"],
|
||||
"html": default_template["html"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
认证相关API端点
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
@@ -26,6 +26,8 @@ from src.models.api import (
|
||||
RegistrationSettingsResponse,
|
||||
SendVerificationCodeRequest,
|
||||
SendVerificationCodeResponse,
|
||||
VerificationStatusRequest,
|
||||
VerificationStatusResponse,
|
||||
VerifyEmailRequest,
|
||||
VerifyEmailResponse,
|
||||
)
|
||||
@@ -33,12 +35,57 @@ from src.models.database import AuditEventType, User, UserRole
|
||||
from src.services.auth.service import AuthService
|
||||
from src.services.rate_limit.ip_limiter import IPRateLimiter
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.system.config import ConfigService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.user.service import UserService
|
||||
from src.services.verification import EmailSenderService, EmailVerificationService
|
||||
from src.services.email import EmailSenderService, EmailVerificationService
|
||||
from src.utils.request_utils import get_client_ip, get_user_agent
|
||||
|
||||
|
||||
def validate_email_suffix(db: Session, email: str) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
验证邮箱后缀是否允许注册
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
email: 邮箱地址
|
||||
|
||||
Returns:
|
||||
(是否允许, 错误信息)
|
||||
"""
|
||||
# 获取邮箱后缀限制配置
|
||||
mode = SystemConfigService.get_config(db, "email_suffix_mode", default="none")
|
||||
|
||||
if mode == "none":
|
||||
return True, None
|
||||
|
||||
# 获取邮箱后缀列表
|
||||
suffix_list = SystemConfigService.get_config(db, "email_suffix_list", default=[])
|
||||
if not suffix_list:
|
||||
# 没有配置后缀列表时,不限制
|
||||
return True, None
|
||||
|
||||
# 确保 suffix_list 是列表类型
|
||||
if isinstance(suffix_list, str):
|
||||
suffix_list = [s.strip().lower() for s in suffix_list.split(",") if s.strip()]
|
||||
|
||||
# 获取邮箱后缀
|
||||
if "@" not in email:
|
||||
return False, "邮箱格式无效"
|
||||
|
||||
email_suffix = email.split("@")[1].lower()
|
||||
|
||||
if mode == "whitelist":
|
||||
# 白名单模式:只允许列出的后缀
|
||||
if email_suffix not in suffix_list:
|
||||
return False, f"该邮箱后缀不在允许列表中,仅支持: {', '.join(suffix_list)}"
|
||||
elif mode == "blacklist":
|
||||
# 黑名单模式:拒绝列出的后缀
|
||||
if email_suffix in suffix_list:
|
||||
return False, f"该邮箱后缀 ({email_suffix}) 不允许注册"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
pipeline = ApiRequestPipeline()
|
||||
@@ -103,6 +150,13 @@ async def verify_email(request: Request, db: Session = Depends(get_db)):
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/verification-status", response_model=VerificationStatusResponse)
|
||||
async def verification_status(request: Request, db: Session = Depends(get_db)):
|
||||
"""查询邮箱验证状态"""
|
||||
adapter = AuthVerificationStatusAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
# ============== 适配器实现 ==============
|
||||
|
||||
|
||||
@@ -242,16 +296,12 @@ class AuthRegistrationSettingsAdapter(AuthPublicAdapter):
|
||||
"""公开返回注册相关配置"""
|
||||
db = context.db
|
||||
|
||||
enable_registration = ConfigService.get_config(db, "enable_registration", default=False)
|
||||
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
|
||||
expire_minutes = ConfigService.get_config(
|
||||
db, "verification_code_expire_minutes", default=30
|
||||
)
|
||||
enable_registration = SystemConfigService.get_config(db, "enable_registration", default=False)
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
return RegistrationSettingsResponse(
|
||||
enable_registration=bool(enable_registration),
|
||||
require_email_verification=bool(require_verification),
|
||||
verification_code_expire_minutes=expire_minutes,
|
||||
).model_dump()
|
||||
|
||||
|
||||
@@ -287,8 +337,26 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="系统暂不开放注册")
|
||||
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, register_request.email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"注册失败:邮箱后缀不允许: {register_request.email}")
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type=AuditEventType.UNAUTHORIZED_ACCESS,
|
||||
description=f"Registration attempt rejected - email suffix not allowed: {register_request.email}",
|
||||
ip_address=client_ip,
|
||||
user_agent=user_agent,
|
||||
metadata={"email": register_request.email, "reason": "email_suffix_not_allowed"},
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 检查是否需要邮箱验证
|
||||
require_verification = ConfigService.get_config(db, "require_email_verification", default=False)
|
||||
require_verification = SystemConfigService.get_config(db, "require_email_verification", default=False)
|
||||
|
||||
if require_verification:
|
||||
# 检查邮箱是否已验证
|
||||
@@ -318,12 +386,15 @@ class AuthRegisterAdapter(AuthPublicAdapter):
|
||||
metadata={"email": user.email, "username": user.username, "role": user.role.value},
|
||||
)
|
||||
|
||||
# 注册成功后清除验证状态 - 在 commit 之前清理,避免竞态条件
|
||||
if require_verification:
|
||||
await EmailVerificationService.clear_verification(register_request.email)
|
||||
|
||||
db.commit()
|
||||
|
||||
# 注册成功后清除验证状态(在 commit 后清理,即使清理失败也不影响注册结果)
|
||||
if require_verification:
|
||||
try:
|
||||
await EmailVerificationService.clear_verification(register_request.email)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理验证状态失败: {e}")
|
||||
|
||||
return RegisterResponse(
|
||||
user_id=user.id,
|
||||
email=user.email,
|
||||
@@ -373,8 +444,8 @@ class AuthChangePasswordAdapter(AuthenticatedApiAdapter):
|
||||
user = context.user
|
||||
if not user.verify_password(old_password):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="旧密码错误")
|
||||
if len(new_password) < 8:
|
||||
raise InvalidRequestException("密码长度至少8位")
|
||||
if len(new_password) < 6:
|
||||
raise InvalidRequestException("密码长度至少6位")
|
||||
user.set_password(new_password)
|
||||
context.db.commit()
|
||||
logger.info(f"用户修改密码: {user.email}")
|
||||
@@ -447,25 +518,26 @@ class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 获取验证码过期时间配置
|
||||
expire_minutes = ConfigService.get_config(
|
||||
db, "verification_code_expire_minutes", default=30
|
||||
)
|
||||
|
||||
# 检查邮箱是否已注册 - 静默处理,不暴露邮箱注册状态
|
||||
# 检查邮箱是否已注册
|
||||
existing_user = db.query(User).filter(User.email == email).first()
|
||||
if existing_user:
|
||||
# 不发送验证码,但返回成功信息,防止邮箱枚举攻击
|
||||
logger.warning(f"尝试为已注册邮箱发送验证码: {email}")
|
||||
return SendVerificationCodeResponse(
|
||||
success=True,
|
||||
message="验证码已发送",
|
||||
expire_minutes=expire_minutes,
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱已被注册,请直接登录或使用其他邮箱",
|
||||
)
|
||||
|
||||
# 生成并发送验证码
|
||||
# 检查邮箱后缀是否允许
|
||||
suffix_allowed, suffix_error = validate_email_suffix(db, email)
|
||||
if not suffix_allowed:
|
||||
logger.warning(f"邮箱后缀不允许: {email}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=suffix_error,
|
||||
)
|
||||
|
||||
# 生成并发送验证码(使用服务中的默认配置)
|
||||
success, code_or_error, error_detail = await EmailVerificationService.send_verification_code(
|
||||
email, expire_minutes=expire_minutes
|
||||
email
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -476,6 +548,7 @@ class AuthSendVerificationCodeAdapter(AuthPublicAdapter):
|
||||
)
|
||||
|
||||
# 发送邮件
|
||||
expire_minutes = EmailVerificationService.DEFAULT_CODE_EXPIRE_MINUTES
|
||||
email_success, email_error = await EmailSenderService.send_verification_code(
|
||||
db=db, to_email=email, code=code_or_error, expire_minutes=expire_minutes
|
||||
)
|
||||
@@ -537,3 +610,54 @@ class AuthVerifyEmailAdapter(AuthPublicAdapter):
|
||||
logger.info(f"邮箱验证成功: {email}")
|
||||
|
||||
return VerifyEmailResponse(message="邮箱验证成功", success=True).model_dump()
|
||||
|
||||
|
||||
class AuthVerificationStatusAdapter(AuthPublicAdapter):
|
||||
async def handle(self, context): # type: ignore[override]
|
||||
"""查询邮箱验证状态"""
|
||||
payload = context.ensure_json_body()
|
||||
|
||||
try:
|
||||
status_request = VerificationStatusRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
errors.append(f"{field}: {error['msg']}")
|
||||
raise InvalidRequestException("输入验证失败: " + "; ".join(errors))
|
||||
|
||||
client_ip = get_client_ip(context.request)
|
||||
email = status_request.email
|
||||
|
||||
# IP 速率限制检查(验证状态查询:20次/分钟)
|
||||
allowed, remaining, reset_after = await IPRateLimiter.check_limit(
|
||||
client_ip, "verification_status", limit=20
|
||||
)
|
||||
if not allowed:
|
||||
logger.warning(f"验证状态查询请求超过速率限制: IP={client_ip}, 剩余={remaining}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"请求过于频繁,请在 {reset_after} 秒后重试",
|
||||
)
|
||||
|
||||
# 获取验证状态
|
||||
status_data = await EmailVerificationService.get_verification_status(email)
|
||||
|
||||
# 计算冷却剩余时间
|
||||
cooldown_remaining = None
|
||||
if status_data.get("has_pending_code") and status_data.get("created_at"):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
created_at = datetime.fromisoformat(status_data["created_at"])
|
||||
elapsed = (datetime.now(timezone.utc) - created_at).total_seconds()
|
||||
cooldown = EmailVerificationService.SEND_COOLDOWN_SECONDS - int(elapsed)
|
||||
if cooldown > 0:
|
||||
cooldown_remaining = cooldown
|
||||
|
||||
return VerificationStatusResponse(
|
||||
email=email,
|
||||
has_pending_code=status_data.get("has_pending_code", False),
|
||||
is_verified=status_data.get("is_verified", False),
|
||||
cooldown_remaining=cooldown_remaining,
|
||||
code_expires_in=status_data.get("code_expires_in"),
|
||||
).model_dump()
|
||||
|
||||
@@ -173,6 +173,16 @@ class Config:
|
||||
"GEMINI_CLI_USER_AGENT", "gemini-cli/0.1.0"
|
||||
)
|
||||
|
||||
# 邮箱验证配置
|
||||
# VERIFICATION_CODE_EXPIRE_MINUTES: 验证码有效期(分钟)
|
||||
# VERIFICATION_SEND_COOLDOWN: 发送冷却时间(秒)
|
||||
self.verification_code_expire_minutes = int(
|
||||
os.getenv("VERIFICATION_CODE_EXPIRE_MINUTES", "5")
|
||||
)
|
||||
self.verification_send_cooldown = int(
|
||||
os.getenv("VERIFICATION_SEND_COOLDOWN", "60")
|
||||
)
|
||||
|
||||
# 验证连接池配置
|
||||
self._validate_pool_config()
|
||||
|
||||
|
||||
@@ -161,8 +161,8 @@ class VerifyEmailRequest(BaseModel):
|
||||
raise ValueError("邮箱格式无效")
|
||||
return v.lower()
|
||||
|
||||
@classmethod
|
||||
@field_validator("code")
|
||||
@classmethod
|
||||
def validate_code(cls, v):
|
||||
"""验证验证码格式"""
|
||||
v = v.strip()
|
||||
@@ -180,12 +180,39 @@ class VerifyEmailResponse(BaseModel):
|
||||
success: bool
|
||||
|
||||
|
||||
class VerificationStatusRequest(BaseModel):
|
||||
"""验证状态查询请求"""
|
||||
|
||||
email: str = Field(..., min_length=3, max_length=255, description="邮箱地址")
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def validate_email(cls, v):
|
||||
"""验证邮箱格式"""
|
||||
v = v.strip().lower()
|
||||
if not v:
|
||||
raise ValueError("邮箱不能为空")
|
||||
email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
|
||||
if not re.match(email_pattern, v):
|
||||
raise ValueError("邮箱格式无效")
|
||||
return v
|
||||
|
||||
|
||||
class VerificationStatusResponse(BaseModel):
|
||||
"""验证状态响应"""
|
||||
|
||||
email: str
|
||||
has_pending_code: bool = Field(description="是否有待验证的验证码")
|
||||
is_verified: bool = Field(description="邮箱是否已验证")
|
||||
cooldown_remaining: Optional[int] = Field(None, description="发送冷却剩余秒数")
|
||||
code_expires_in: Optional[int] = Field(None, description="验证码剩余有效秒数")
|
||||
|
||||
|
||||
class RegistrationSettingsResponse(BaseModel):
|
||||
"""注册设置响应(公开接口返回)"""
|
||||
|
||||
enable_registration: bool
|
||||
require_email_verification: bool
|
||||
verification_code_expire_minutes: Optional[int] = 30
|
||||
|
||||
|
||||
# ========== 用户管理 ==========
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import asyncio
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from typing import Optional, Tuple
|
||||
@@ -17,10 +18,22 @@ except ImportError:
|
||||
AIOSMTPLIB_AVAILABLE = False
|
||||
aiosmtplib = None
|
||||
|
||||
|
||||
def _create_ssl_context() -> ssl.SSLContext:
|
||||
"""创建 SSL 上下文,使用 certifi 证书或系统默认证书"""
|
||||
try:
|
||||
import certifi
|
||||
|
||||
context = ssl.create_default_context(cafile=certifi.where())
|
||||
except ImportError:
|
||||
context = ssl.create_default_context()
|
||||
return context
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.crypto import crypto_service
|
||||
from src.core.logger import logger
|
||||
from src.services.system.config import ConfigService
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
from .email_template import EmailTemplate
|
||||
|
||||
@@ -28,6 +41,9 @@ from .email_template import EmailTemplate
|
||||
class EmailSenderService:
|
||||
"""邮件发送服务"""
|
||||
|
||||
# SMTP 超时配置(秒)
|
||||
SMTP_TIMEOUT = 30
|
||||
|
||||
@staticmethod
|
||||
def _get_smtp_config(db: Session) -> dict:
|
||||
"""
|
||||
@@ -39,15 +55,25 @@ class EmailSenderService:
|
||||
Returns:
|
||||
SMTP 配置字典
|
||||
"""
|
||||
# 获取加密的密码并解密
|
||||
encrypted_password = SystemConfigService.get_config(db, "smtp_password")
|
||||
smtp_password = None
|
||||
if encrypted_password:
|
||||
try:
|
||||
smtp_password = crypto_service.decrypt(encrypted_password, silent=True)
|
||||
except Exception:
|
||||
# 解密失败,可能是旧的未加密密码,直接使用
|
||||
smtp_password = encrypted_password
|
||||
|
||||
config = {
|
||||
"smtp_host": ConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": ConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": ConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": ConfigService.get_config(db, "smtp_password"),
|
||||
"smtp_use_tls": ConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": ConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": ConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": ConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
"smtp_host": SystemConfigService.get_config(db, "smtp_host"),
|
||||
"smtp_port": SystemConfigService.get_config(db, "smtp_port", default=587),
|
||||
"smtp_user": SystemConfigService.get_config(db, "smtp_user"),
|
||||
"smtp_password": smtp_password,
|
||||
"smtp_use_tls": SystemConfigService.get_config(db, "smtp_use_tls", default=True),
|
||||
"smtp_use_ssl": SystemConfigService.get_config(db, "smtp_use_ssl", default=False),
|
||||
"smtp_from_email": SystemConfigService.get_config(db, "smtp_from_email"),
|
||||
"smtp_from_name": SystemConfigService.get_config(db, "smtp_from_name", default="Aether"),
|
||||
}
|
||||
return config
|
||||
|
||||
@@ -96,16 +122,18 @@ class EmailSenderService:
|
||||
return False, error
|
||||
|
||||
# 生成邮件内容
|
||||
app_name = ConfigService.get_config(db, "smtp_from_name", default="Aether")
|
||||
support_email = ConfigService.get_config(db, "smtp_support_email")
|
||||
# 优先使用 email_app_name,否则回退到 smtp_from_name
|
||||
app_name = SystemConfigService.get_config(db, "email_app_name", default=None)
|
||||
if not app_name:
|
||||
app_name = SystemConfigService.get_config(db, "smtp_from_name", default="Aether")
|
||||
|
||||
html_body = EmailTemplate.get_verification_code_html(
|
||||
code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email
|
||||
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
|
||||
)
|
||||
text_body = EmailTemplate.get_verification_code_text(
|
||||
code=code, expire_minutes=expire_minutes, app_name=app_name, support_email=support_email
|
||||
code=code, expire_minutes=expire_minutes, db=db, app_name=app_name, email=to_email
|
||||
)
|
||||
subject = EmailTemplate.get_subject("verification")
|
||||
subject = EmailTemplate.get_subject("verification", db=db)
|
||||
|
||||
# 发送邮件
|
||||
return await EmailSenderService._send_email(
|
||||
@@ -179,14 +207,17 @@ class EmailSenderService:
|
||||
message.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# 发送邮件
|
||||
ssl_context = _create_ssl_context()
|
||||
if config["smtp_use_ssl"]:
|
||||
await aiosmtplib.send(
|
||||
message,
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
use_tls=True,
|
||||
tls_context=ssl_context,
|
||||
username=config["smtp_user"],
|
||||
password=config["smtp_password"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
await aiosmtplib.send(
|
||||
@@ -194,8 +225,10 @@ class EmailSenderService:
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
start_tls=config["smtp_use_tls"],
|
||||
tls_context=ssl_context if config["smtp_use_tls"] else None,
|
||||
username=config["smtp_user"],
|
||||
password=config["smtp_password"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
|
||||
logger.info(f"验证码邮件发送成功: {to_email}")
|
||||
@@ -270,13 +303,23 @@ class EmailSenderService:
|
||||
|
||||
# 连接 SMTP 服务器
|
||||
server = None
|
||||
ssl_context = _create_ssl_context()
|
||||
try:
|
||||
if config["smtp_use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"])
|
||||
server = smtplib.SMTP_SSL(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
context=ssl_context,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(config["smtp_host"], config["smtp_port"])
|
||||
server = smtplib.SMTP(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
if config["smtp_use_tls"]:
|
||||
server.starttls()
|
||||
server.starttls(context=ssl_context)
|
||||
|
||||
# 登录
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
@@ -326,18 +369,24 @@ class EmailSenderService:
|
||||
return False, error
|
||||
|
||||
try:
|
||||
ssl_context = _create_ssl_context()
|
||||
if AIOSMTPLIB_AVAILABLE:
|
||||
# 使用异步方式测试
|
||||
# 注意: use_tls=True 表示隐式 SSL (端口 465)
|
||||
# start_tls=True 表示 STARTTLS (端口 587)
|
||||
use_ssl = config["smtp_use_ssl"]
|
||||
use_starttls = config["smtp_use_tls"] and not use_ssl
|
||||
|
||||
smtp = aiosmtplib.SMTP(
|
||||
hostname=config["smtp_host"],
|
||||
port=config["smtp_port"],
|
||||
use_tls=config["smtp_use_ssl"],
|
||||
use_tls=use_ssl,
|
||||
start_tls=use_starttls,
|
||||
tls_context=ssl_context if (use_ssl or use_starttls) else None,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
await smtp.connect()
|
||||
|
||||
if config["smtp_use_tls"] and not config["smtp_use_ssl"]:
|
||||
await smtp.starttls()
|
||||
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
await smtp.login(config["smtp_user"], config["smtp_password"])
|
||||
|
||||
@@ -345,11 +394,20 @@ class EmailSenderService:
|
||||
else:
|
||||
# 使用同步方式测试
|
||||
if config["smtp_use_ssl"]:
|
||||
server = smtplib.SMTP_SSL(config["smtp_host"], config["smtp_port"])
|
||||
server = smtplib.SMTP_SSL(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
context=ssl_context,
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
else:
|
||||
server = smtplib.SMTP(config["smtp_host"], config["smtp_port"])
|
||||
server = smtplib.SMTP(
|
||||
config["smtp_host"],
|
||||
config["smtp_port"],
|
||||
timeout=EmailSenderService.SMTP_TIMEOUT,
|
||||
)
|
||||
if config["smtp_use_tls"]:
|
||||
server.starttls()
|
||||
server.starttls(context=ssl_context)
|
||||
|
||||
if config["smtp_user"] and config["smtp_password"]:
|
||||
server.login(config["smtp_user"], config["smtp_password"])
|
||||
@@ -360,6 +418,56 @@ class EmailSenderService:
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"SMTP 连接测试失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
error_msg = _translate_smtp_error(str(e))
|
||||
logger.error(f"SMTP 连接测试失败: {error_msg}")
|
||||
return False, error_msg
|
||||
|
||||
|
||||
def _translate_smtp_error(error: str) -> str:
|
||||
"""将 SMTP 错误信息转换为用户友好的中文提示"""
|
||||
error_lower = error.lower()
|
||||
|
||||
# 认证相关错误
|
||||
if "username and password not accepted" in error_lower:
|
||||
return "用户名或密码错误,请检查 SMTP 凭据"
|
||||
if "authentication failed" in error_lower:
|
||||
return "认证失败,请检查用户名和密码"
|
||||
if "invalid credentials" in error_lower or "badcredentials" in error_lower:
|
||||
return "凭据无效,请检查用户名和密码"
|
||||
if "smtp auth extension is not supported" in error_lower:
|
||||
return "服务器不支持认证,请尝试使用 TLS 或 SSL 加密"
|
||||
|
||||
# 连接相关错误
|
||||
if "connection refused" in error_lower:
|
||||
return "连接被拒绝,请检查服务器地址和端口"
|
||||
if "connection timed out" in error_lower or "timed out" in error_lower:
|
||||
return "连接超时,请检查网络或服务器地址"
|
||||
if "name or service not known" in error_lower or "getaddrinfo failed" in error_lower:
|
||||
return "无法解析服务器地址,请检查 SMTP 服务器地址"
|
||||
if "network is unreachable" in error_lower:
|
||||
return "网络不可达,请检查网络连接"
|
||||
|
||||
# SSL/TLS 相关错误
|
||||
if "certificate verify failed" in error_lower:
|
||||
return "SSL 证书验证失败,请检查服务器证书或尝试其他加密方式"
|
||||
if "ssl" in error_lower and "wrong version" in error_lower:
|
||||
return "SSL 版本不匹配,请尝试其他加密方式"
|
||||
if "starttls" in error_lower:
|
||||
return "STARTTLS 握手失败,请检查加密设置"
|
||||
|
||||
# 其他常见错误
|
||||
if "sender address rejected" in error_lower:
|
||||
return "发件人地址被拒绝,请检查发件人邮箱设置"
|
||||
if "relay access denied" in error_lower:
|
||||
return "中继访问被拒绝,请检查 SMTP 服务器配置"
|
||||
|
||||
# 返回原始错误(简化格式)
|
||||
# 去掉错误码前缀,如 "(535, '5.7.8 ..."
|
||||
if error.startswith("(") and "'" in error:
|
||||
# 提取引号内的内容
|
||||
start = error.find("'") + 1
|
||||
end = error.rfind("'")
|
||||
if start > 0 and end > start:
|
||||
return error[start:end].replace("\\n", " ").strip()
|
||||
|
||||
return error
|
||||
442
src/services/email/email_template.py
Normal file
442
src/services/email/email_template.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""
|
||||
邮件模板
|
||||
提供验证码邮件的 HTML 和纯文本模板,支持从数据库加载自定义模板
|
||||
"""
|
||||
|
||||
import html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.services.system.config import SystemConfigService
|
||||
|
||||
|
||||
class HTMLToTextParser(HTMLParser):
|
||||
"""HTML 转纯文本解析器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text_parts = []
|
||||
self.skip_data = False
|
||||
|
||||
def handle_starttag(self, tag, attrs): # noqa: ARG002
|
||||
if tag in ("script", "style", "head"):
|
||||
self.skip_data = True
|
||||
elif tag == "br":
|
||||
self.text_parts.append("\n")
|
||||
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
self.text_parts.append("\n")
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ("script", "style", "head"):
|
||||
self.skip_data = False
|
||||
elif tag in ("p", "div", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "td"):
|
||||
self.text_parts.append("\n")
|
||||
|
||||
def handle_data(self, data):
|
||||
if not self.skip_data:
|
||||
text = data.strip()
|
||||
if text:
|
||||
self.text_parts.append(text)
|
||||
|
||||
|
||||
class EmailTemplate:
|
||||
"""邮件模板类"""
|
||||
|
||||
# 模板类型定义
|
||||
TEMPLATE_VERIFICATION = "verification"
|
||||
TEMPLATE_PASSWORD_RESET = "password_reset"
|
||||
|
||||
# 支持的模板类型及其变量
|
||||
TEMPLATE_TYPES = {
|
||||
TEMPLATE_VERIFICATION: {
|
||||
"name": "注册验证码",
|
||||
"variables": ["app_name", "code", "expire_minutes", "email"],
|
||||
"default_subject": "验证码",
|
||||
},
|
||||
TEMPLATE_PASSWORD_RESET: {
|
||||
"name": "找回密码",
|
||||
"variables": ["app_name", "reset_link", "expire_minutes", "email"],
|
||||
"default_subject": "密码重置",
|
||||
},
|
||||
}
|
||||
|
||||
# Literary Tech 主题色 - 与网页保持一致
|
||||
PRIMARY_COLOR = "#c96442" # book-cloth
|
||||
PRIMARY_LIGHT = "#e4b2a0" # kraft
|
||||
BG_WARM = "#faf9f5" # ivory-light
|
||||
BG_MEDIUM = "#e9e6dc" # ivory-medium / cloud-medium
|
||||
TEXT_DARK = "#3d3929" # slate-dark
|
||||
TEXT_MUTED = "#6c695c" # slate-medium
|
||||
BORDER_COLOR = "rgba(61, 57, 41, 0.12)"
|
||||
|
||||
@staticmethod
|
||||
def get_default_verification_html() -> str:
|
||||
"""获取默认的验证码邮件 HTML 模板 - Literary Tech 风格"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>验证码</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 0 32px; text-align: center;">
|
||||
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
|
||||
{{app_name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 48px 40px;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
|
||||
验证码
|
||||
</h1>
|
||||
|
||||
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
|
||||
您正在注册账户,请使用以下验证码完成验证。
|
||||
</p>
|
||||
|
||||
<!-- Code Box -->
|
||||
<div style="background-color: #faf9f5; border: 1px solid rgba(61, 57, 41, 0.08); border-radius: 4px; padding: 32px 20px; text-align: center; margin-bottom: 32px;">
|
||||
<div style="font-size: 40px; font-weight: 500; color: #c96442; letter-spacing: 12px; font-family: 'SF Mono', Monaco, 'Courier New', monospace;">
|
||||
{{code}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
|
||||
验证码将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 32px 0 0; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
|
||||
如果这不是您的操作,请忽略此邮件。
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
|
||||
此邮件由系统自动发送,请勿回复
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@staticmethod
|
||||
def get_default_password_reset_html() -> str:
|
||||
"""获取默认的密码重置邮件 HTML 模板 - Literary Tech 风格"""
|
||||
return """<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>密码重置</title>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #faf9f5; font-family: Georgia, 'Times New Roman', 'Songti SC', 'STSong', serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #faf9f5; padding: 40px 20px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="max-width: 480px;">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td style="padding: 0 0 32px; text-align: center;">
|
||||
<div style="font-size: 13px; font-family: 'SF Mono', Monaco, 'Courier New', monospace; color: #6c695c; letter-spacing: 0.15em; text-transform: uppercase;">
|
||||
{{app_name}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Main Card -->
|
||||
<tr>
|
||||
<td>
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border: 1px solid rgba(61, 57, 41, 0.1); border-radius: 6px;">
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td style="padding: 48px 40px;">
|
||||
<h1 style="margin: 0 0 24px; font-size: 24px; font-weight: 500; color: #3d3929; text-align: center; letter-spacing: -0.02em;">
|
||||
重置密码
|
||||
</h1>
|
||||
|
||||
<p style="margin: 0 0 32px; font-size: 15px; color: #6c695c; line-height: 1.7; text-align: center;">
|
||||
您正在重置账户密码,请点击下方按钮完成操作。
|
||||
</p>
|
||||
|
||||
<!-- Button -->
|
||||
<div style="text-align: center; margin-bottom: 32px;">
|
||||
<a href="{{reset_link}}" style="display: inline-block; padding: 14px 36px; background-color: #c96442; color: #ffffff; text-decoration: none; border-radius: 4px; font-size: 15px; font-weight: 500; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;">
|
||||
重置密码
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="margin: 0; font-size: 14px; color: #6c695c; line-height: 1.6; text-align: center;">
|
||||
链接将在 <span style="color: #3d3929; font-weight: 500;">{{expire_minutes}} 分钟</span>后失效
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td style="padding: 32px 0 0; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 12px; color: #6c695c;">
|
||||
如果您没有请求重置密码,请忽略此邮件。
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 11px; color: rgba(108, 105, 92, 0.6);">
|
||||
此邮件由系统自动发送,请勿回复
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
@staticmethod
|
||||
def get_default_template(template_type: str) -> Dict[str, str]:
|
||||
"""
|
||||
获取默认模板
|
||||
|
||||
Args:
|
||||
template_type: 模板类型
|
||||
|
||||
Returns:
|
||||
包含 subject 和 html 的字典
|
||||
"""
|
||||
if template_type == EmailTemplate.TEMPLATE_VERIFICATION:
|
||||
return {
|
||||
"subject": "验证码",
|
||||
"html": EmailTemplate.get_default_verification_html(),
|
||||
}
|
||||
elif template_type == EmailTemplate.TEMPLATE_PASSWORD_RESET:
|
||||
return {
|
||||
"subject": "密码重置",
|
||||
"html": EmailTemplate.get_default_password_reset_html(),
|
||||
}
|
||||
else:
|
||||
return {"subject": "通知", "html": ""}
|
||||
|
||||
@staticmethod
|
||||
def get_template(db: Session, template_type: str) -> Dict[str, str]:
|
||||
"""
|
||||
从数据库获取模板,如果不存在则返回默认模板
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
template_type: 模板类型
|
||||
|
||||
Returns:
|
||||
包含 subject 和 html 的字典
|
||||
"""
|
||||
default = EmailTemplate.get_default_template(template_type)
|
||||
|
||||
# 从数据库获取自定义模板
|
||||
subject_key = f"email_template_{template_type}_subject"
|
||||
html_key = f"email_template_{template_type}_html"
|
||||
|
||||
custom_subject = SystemConfigService.get_config(db, subject_key, default=None)
|
||||
custom_html = SystemConfigService.get_config(db, html_key, default=None)
|
||||
|
||||
return {
|
||||
"subject": custom_subject if custom_subject else default["subject"],
|
||||
"html": custom_html if custom_html else default["html"],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def render_template(template_html: str, variables: Dict[str, Any]) -> str:
|
||||
"""
|
||||
渲染模板,替换 {{variable}} 格式的变量
|
||||
|
||||
Args:
|
||||
template_html: HTML 模板
|
||||
variables: 变量字典
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
result = template_html
|
||||
for key, value in variables.items():
|
||||
# HTML 转义变量值,防止 XSS
|
||||
escaped_value = html.escape(str(value))
|
||||
# 替换 {{key}} 格式的变量
|
||||
pattern = r"\{\{\s*" + re.escape(key) + r"\s*\}\}"
|
||||
result = re.sub(pattern, escaped_value, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def html_to_text(html: str) -> str:
|
||||
"""
|
||||
从 HTML 提取纯文本
|
||||
|
||||
Args:
|
||||
html: HTML 内容
|
||||
|
||||
Returns:
|
||||
纯文本内容
|
||||
"""
|
||||
parser = HTMLToTextParser()
|
||||
parser.feed(html)
|
||||
text = " ".join(parser.text_parts)
|
||||
# 清理多余空白
|
||||
text = re.sub(r"\n\s*\n", "\n\n", text)
|
||||
text = re.sub(r" +", " ", text)
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_html(
|
||||
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取验证码邮件 HTML
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话(用于获取自定义模板)
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
email = kwargs.get("email", "")
|
||||
|
||||
# 获取模板
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_VERIFICATION)
|
||||
else:
|
||||
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_VERIFICATION)
|
||||
|
||||
# 渲染变量
|
||||
variables = {
|
||||
"app_name": app_name,
|
||||
"code": code,
|
||||
"expire_minutes": expire_minutes,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
return EmailTemplate.render_template(template["html"], variables)
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_text(
|
||||
code: str, expire_minutes: int = 5, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取验证码邮件纯文本(从 HTML 自动生成)
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
纯文本邮件内容
|
||||
"""
|
||||
html = EmailTemplate.get_verification_code_html(code, expire_minutes, db, **kwargs)
|
||||
return EmailTemplate.html_to_text(html)
|
||||
|
||||
@staticmethod
|
||||
def get_password_reset_html(
|
||||
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取密码重置邮件 HTML
|
||||
|
||||
Args:
|
||||
reset_link: 重置链接
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
渲染后的 HTML
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
email = kwargs.get("email", "")
|
||||
|
||||
# 获取模板
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, EmailTemplate.TEMPLATE_PASSWORD_RESET)
|
||||
else:
|
||||
template = EmailTemplate.get_default_template(EmailTemplate.TEMPLATE_PASSWORD_RESET)
|
||||
|
||||
# 渲染变量
|
||||
variables = {
|
||||
"app_name": app_name,
|
||||
"reset_link": reset_link,
|
||||
"expire_minutes": expire_minutes,
|
||||
"email": email,
|
||||
}
|
||||
|
||||
return EmailTemplate.render_template(template["html"], variables)
|
||||
|
||||
@staticmethod
|
||||
def get_password_reset_text(
|
||||
reset_link: str, expire_minutes: int = 30, db: Optional[Session] = None, **kwargs
|
||||
) -> str:
|
||||
"""
|
||||
获取密码重置邮件纯文本(从 HTML 自动生成)
|
||||
|
||||
Args:
|
||||
reset_link: 重置链接
|
||||
expire_minutes: 过期时间(分钟)
|
||||
db: 数据库会话
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
纯文本邮件内容
|
||||
"""
|
||||
html = EmailTemplate.get_password_reset_html(reset_link, expire_minutes, db, **kwargs)
|
||||
return EmailTemplate.html_to_text(html)
|
||||
|
||||
@staticmethod
|
||||
def get_subject(
|
||||
template_type: str = "verification", db: Optional[Session] = None
|
||||
) -> str:
|
||||
"""
|
||||
获取邮件主题
|
||||
|
||||
Args:
|
||||
template_type: 模板类型
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
邮件主题
|
||||
"""
|
||||
if db:
|
||||
template = EmailTemplate.get_template(db, template_type)
|
||||
return template["subject"]
|
||||
|
||||
default_subjects = {
|
||||
"verification": "验证码",
|
||||
"welcome": "欢迎加入",
|
||||
"password_reset": "密码重置",
|
||||
}
|
||||
return default_subjects.get(template_type, "通知")
|
||||
@@ -9,22 +9,23 @@ from datetime import datetime, timezone
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from src.clients.redis_client import get_redis_client
|
||||
from src.config.settings import Config
|
||||
from src.core.logger import logger
|
||||
|
||||
# 从环境变量加载配置
|
||||
_config = Config()
|
||||
|
||||
|
||||
class EmailVerificationService:
|
||||
"""邮箱验证码服务"""
|
||||
|
||||
# Redis key 前缀
|
||||
VERIFICATION_PREFIX = "email:verification:"
|
||||
SEND_LIMIT_PREFIX = "email:send_limit:"
|
||||
VERIFIED_PREFIX = "email:verified:"
|
||||
|
||||
# 默认配置
|
||||
DEFAULT_CODE_EXPIRE_MINUTES = 30
|
||||
DEFAULT_MAX_ATTEMPTS = 5
|
||||
SEND_COOLDOWN_SECONDS = 60
|
||||
SEND_LIMIT_PER_HOUR = 5
|
||||
# 从环境变量读取配置
|
||||
DEFAULT_CODE_EXPIRE_MINUTES = _config.verification_code_expire_minutes
|
||||
SEND_COOLDOWN_SECONDS = _config.verification_send_cooldown
|
||||
|
||||
@staticmethod
|
||||
def _generate_code() -> str:
|
||||
@@ -40,7 +41,8 @@ class EmailVerificationService:
|
||||
|
||||
@staticmethod
|
||||
async def send_verification_code(
|
||||
email: str, expire_minutes: Optional[int] = None
|
||||
email: str,
|
||||
expire_minutes: Optional[int] = None,
|
||||
) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
发送验证码(生成并存储到 Redis)
|
||||
@@ -59,16 +61,6 @@ class EmailVerificationService:
|
||||
return False, "系统错误", "Redis 服务不可用"
|
||||
|
||||
try:
|
||||
# 检查发送频率限制
|
||||
send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}"
|
||||
send_count = await redis_client.get(send_limit_key)
|
||||
|
||||
if send_count:
|
||||
send_count = int(send_count)
|
||||
if send_count >= EmailVerificationService.SEND_LIMIT_PER_HOUR:
|
||||
logger.warning(f"邮箱 {email} 发送验证码次数超限: {send_count}")
|
||||
return False, "发送次数过多", "每小时最多发送 5 次验证码"
|
||||
|
||||
# 检查冷却时间
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
existing_data = await redis_client.get(verification_key)
|
||||
@@ -90,7 +82,6 @@ class EmailVerificationService:
|
||||
# 存储验证码数据
|
||||
verification_data = {
|
||||
"code": code,
|
||||
"attempts": 0,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@@ -99,12 +90,6 @@ class EmailVerificationService:
|
||||
verification_key, expire_time * 60, json.dumps(verification_data)
|
||||
)
|
||||
|
||||
# 更新发送计数器(1 小时过期)- 使用原子操作
|
||||
current_count = await redis_client.incr(send_limit_key)
|
||||
# 如果是第一次设置,需要设置过期时间
|
||||
if current_count == 1:
|
||||
await redis_client.expire(send_limit_key, 3600)
|
||||
|
||||
logger.info(f"验证码已生成并存储: {email}, 有效期: {expire_time} 分钟")
|
||||
return True, code, None
|
||||
|
||||
@@ -140,25 +125,10 @@ class EmailVerificationService:
|
||||
|
||||
data = json.loads(data_str)
|
||||
|
||||
# 检查尝试次数
|
||||
if data["attempts"] >= EmailVerificationService.DEFAULT_MAX_ATTEMPTS:
|
||||
logger.warning(f"验证码尝试次数过多: {email}")
|
||||
await redis_client.delete(verification_key)
|
||||
return False, "验证码尝试次数过多,请重新发送"
|
||||
|
||||
# 增加尝试次数
|
||||
data["attempts"] += 1
|
||||
|
||||
# 验证码比对 - 使用常量时间比较防止时序攻击
|
||||
if not secrets.compare_digest(code, data["code"]):
|
||||
# 更新尝试次数
|
||||
ttl = await redis_client.ttl(verification_key)
|
||||
if ttl > 0:
|
||||
await redis_client.setex(verification_key, ttl, json.dumps(data))
|
||||
|
||||
remaining_attempts = EmailVerificationService.DEFAULT_MAX_ATTEMPTS - data["attempts"]
|
||||
logger.warning(f"验证码错误: {email}, 剩余尝试次数: {remaining_attempts}")
|
||||
return False, f"验证码错误,剩余尝试次数: {remaining_attempts}"
|
||||
logger.warning(f"验证码错误: {email}")
|
||||
return False, "验证码错误"
|
||||
|
||||
# 验证成功:删除验证码,标记邮箱已验证
|
||||
await redis_client.delete(verification_key)
|
||||
@@ -251,12 +221,10 @@ class EmailVerificationService:
|
||||
try:
|
||||
verification_key = f"{EmailVerificationService.VERIFICATION_PREFIX}{email}"
|
||||
verified_key = f"{EmailVerificationService.VERIFIED_PREFIX}{email}"
|
||||
send_limit_key = f"{EmailVerificationService.SEND_LIMIT_PREFIX}{email}"
|
||||
|
||||
# 获取各个状态
|
||||
verification_data = await redis_client.get(verification_key)
|
||||
is_verified = await redis_client.exists(verified_key)
|
||||
send_count = await redis_client.get(send_limit_key)
|
||||
verification_ttl = await redis_client.ttl(verification_key)
|
||||
verified_ttl = await redis_client.ttl(verified_key)
|
||||
|
||||
@@ -264,14 +232,12 @@ class EmailVerificationService:
|
||||
"email": email,
|
||||
"has_pending_code": bool(verification_data),
|
||||
"is_verified": bool(is_verified),
|
||||
"send_count_this_hour": int(send_count) if send_count else 0,
|
||||
"code_expires_in": verification_ttl if verification_ttl > 0 else None,
|
||||
"verified_expires_in": verified_ttl if verified_ttl > 0 else None,
|
||||
}
|
||||
|
||||
if verification_data:
|
||||
data = json.loads(verification_data)
|
||||
status["attempts"] = data.get("attempts", 0)
|
||||
status["created_at"] = data.get("created_at")
|
||||
|
||||
return status
|
||||
@@ -168,19 +168,28 @@ class SystemConfigService:
|
||||
db, "default_provider", provider_name, "系统默认提供商,当用户未设置个人提供商时使用"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_configs(db: Session) -> list:
|
||||
# 敏感配置项,不返回实际值
|
||||
SENSITIVE_KEYS = {"smtp_password"}
|
||||
|
||||
@classmethod
|
||||
def get_all_configs(cls, db: Session) -> list:
|
||||
"""获取所有系统配置"""
|
||||
configs = db.query(SystemConfig).all()
|
||||
return [
|
||||
{
|
||||
result = []
|
||||
for config in configs:
|
||||
item = {
|
||||
"key": config.key,
|
||||
"value": config.value,
|
||||
"description": config.description,
|
||||
"updated_at": config.updated_at.isoformat(),
|
||||
}
|
||||
for config in configs
|
||||
]
|
||||
# 对敏感配置,只返回是否已设置的标志,不返回实际值
|
||||
if config.key in cls.SENSITIVE_KEYS:
|
||||
item["value"] = None
|
||||
item["is_set"] = bool(config.value)
|
||||
else:
|
||||
item["value"] = config.value
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def delete_config(cls, db: Session, key: str) -> bool:
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
"""
|
||||
邮件模板
|
||||
提供验证码邮件的 HTML 和纯文本模板
|
||||
"""
|
||||
|
||||
from typing import Dict
|
||||
|
||||
|
||||
class EmailTemplate:
|
||||
"""邮件模板类"""
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_html(code: str, expire_minutes: int = 30, **kwargs) -> str:
|
||||
"""
|
||||
获取验证码邮件 HTML 模板
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
HTML 邮件内容
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
support_email = kwargs.get("support_email", "")
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>邮箱验证码</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
background-color: #f5f5f5;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}}
|
||||
.container {{
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: #ffffff;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
.header h1 {{
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.content {{
|
||||
padding: 40px 30px;
|
||||
}}
|
||||
.greeting {{
|
||||
font-size: 18px;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}}
|
||||
.message {{
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.8;
|
||||
}}
|
||||
.code-container {{
|
||||
background-color: #f8f9fa;
|
||||
border: 2px dashed #667eea;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}}
|
||||
.code-label {{
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}}
|
||||
.code {{
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
color: #667eea;
|
||||
letter-spacing: 8px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
margin: 10px 0;
|
||||
}}
|
||||
.expire-info {{
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
color: #856404;
|
||||
}}
|
||||
.footer {{
|
||||
background-color: #f8f9fa;
|
||||
padding: 20px 30px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}}
|
||||
.footer a {{
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}}
|
||||
.divider {{
|
||||
height: 1px;
|
||||
background-color: #e9ecef;
|
||||
margin: 30px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{app_name}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="greeting">您好!</div>
|
||||
<div class="message">
|
||||
感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程:
|
||||
</div>
|
||||
|
||||
<div class="code-container">
|
||||
<div class="code-label">验证码</div>
|
||||
<div class="code">{code}</div>
|
||||
<div class="expire-info">
|
||||
验证码有效期:{expire_minutes} 分钟
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>安全提示:</strong>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>请勿将此验证码透露给任何人</li>
|
||||
<li>如果您没有请求此验证码,请忽略此邮件</li>
|
||||
<li>验证码在 {expire_minutes} 分钟后自动失效</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="message" style="font-size: 14px;">
|
||||
如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>此邮件由系统自动发送,请勿直接回复。</p>
|
||||
{f'<p>需要帮助?联系我们:<a href="mailto:{support_email}">{support_email}</a></p>' if support_email else ''}
|
||||
<p>© {app_name}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html.strip()
|
||||
|
||||
@staticmethod
|
||||
def get_verification_code_text(code: str, expire_minutes: int = 30, **kwargs) -> str:
|
||||
"""
|
||||
获取验证码邮件纯文本模板
|
||||
|
||||
Args:
|
||||
code: 验证码
|
||||
expire_minutes: 过期时间(分钟)
|
||||
**kwargs: 其他模板变量
|
||||
|
||||
Returns:
|
||||
纯文本邮件内容
|
||||
"""
|
||||
app_name = kwargs.get("app_name", "Aether")
|
||||
support_email = kwargs.get("support_email", "")
|
||||
|
||||
text = f"""
|
||||
{app_name} - 邮箱验证码
|
||||
{'=' * 50}
|
||||
|
||||
您好!
|
||||
|
||||
感谢您注册 {app_name}。为了验证您的邮箱地址,请使用以下验证码完成注册流程:
|
||||
|
||||
验证码:{code}
|
||||
|
||||
验证码有效期:{expire_minutes} 分钟
|
||||
|
||||
{'=' * 50}
|
||||
|
||||
安全提示:
|
||||
- 请勿将此验证码透露给任何人
|
||||
- 如果您没有请求此验证码,请忽略此邮件
|
||||
- 验证码在 {expire_minutes} 分钟后自动失效
|
||||
|
||||
{'=' * 50}
|
||||
|
||||
如果您在注册过程中遇到任何问题,请随时联系我们的支持团队。
|
||||
{f'联系邮箱:{support_email}' if support_email else ''}
|
||||
|
||||
此邮件由系统自动发送,请勿直接回复。
|
||||
|
||||
© {app_name}. All rights reserved.
|
||||
"""
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def get_subject(template_type: str = "verification") -> str:
|
||||
"""
|
||||
获取邮件主题
|
||||
|
||||
Args:
|
||||
template_type: 模板类型
|
||||
|
||||
Returns:
|
||||
邮件主题
|
||||
"""
|
||||
subjects = {
|
||||
"verification": "邮箱验证码 - 请完成验证",
|
||||
"welcome": "欢迎加入 Aether",
|
||||
"password_reset": "密码重置验证码",
|
||||
}
|
||||
return subjects.get(template_type, "Aether 通知")
|
||||
Reference in New Issue
Block a user