feat(wallet): 钱包系统替代配额系统,新增支付与退款机制

- 新增钱包余额管理、充值、扣费、退款完整流程
- 新增支付网关抽象层(支持手动/支付宝/微信)
- 用量计费从配额系统迁移到钱包余额扣费
- 新增管理员钱包管理与支付订单管理页面
- 新增用户钱包中心页面
- 移除独立 Key 锁定机制,统一由钱包余额控制
- 新增相关 API 路由、序列化器与数据库迁移
- 新增钱包、支付、退款相关测试
This commit is contained in:
LewisPen
2026-03-08 00:05:48 +08:00
committed by fawney19
parent 9cdcce1b5f
commit 783f654953
108 changed files with 13152 additions and 3372 deletions

View File

@@ -142,51 +142,16 @@
/>
</div>
<div class="space-y-2">
<Label
for="form-quota"
class="text-sm font-medium"
>配额(美元)</Label>
<div class="flex items-center space-x-3">
<Input
id="form-quota"
v-model.number="form.quota"
type="number"
step="0.01"
min="0"
max="10000"
:placeholder="isEditMode ? '10' : '使用系统默认'"
:disabled="form.unlimited"
:class="form.unlimited ? 'flex-1 h-10 opacity-50' : 'flex-1 h-10'"
/>
<div class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24">
<input
id="form-unlimited"
v-model="form.unlimited"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
>
<Label
for="form-unlimited"
class="whitespace-nowrap cursor-pointer text-sm"
>无限制</Label>
</div>
</div>
</div>
<div class="space-y-2">
<Label
for="form-role"
class="text-sm font-medium"
>用户角色</Label>
<div class="flex items-center gap-3">
<Select
v-model="form.role"
class="flex-1"
>
<div class="w-full">
<Select v-model="form.role">
<SelectTrigger
id="form-role"
class="h-10"
class="h-10 w-full text-sm"
>
<SelectValue />
</SelectTrigger>
@@ -199,20 +164,29 @@
</SelectItem>
</SelectContent>
</Select>
<div
v-if="!isEditMode"
class="flex items-center justify-center gap-2 border rounded-lg px-3 py-2 bg-muted/50 w-24"
>
<input
id="form-active"
v-model="form.is_active"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
>
<Label
for="form-active"
class="whitespace-nowrap cursor-pointer text-sm"
>启用用户</Label>
</div>
</div>
<div
v-if="!isEditMode"
class="space-y-2"
>
<Label
for="form-active"
class="text-sm font-medium"
>启用用户</Label>
<div class="flex items-center gap-3">
<Switch
id="form-active"
v-model="form.is_active"
/>
<div class="flex flex-col">
<span class="text-sm text-foreground">
{{ form.is_active ? '已启用' : '已禁用' }}
</span>
<span class="text-xs text-muted-foreground">
{{ form.is_active ? '允许登录与请求' : '阻止登录与请求' }}
</span>
</div>
</div>
</div>
@@ -332,6 +306,44 @@
v-model="form.allowed_models"
:models="globalModels"
/>
<div class="space-y-2">
<Label class="text-sm font-medium">无限制额度</Label>
<div class="flex items-center gap-3">
<Switch v-model="form.unlimited" />
<div class="flex flex-col">
<span class="text-sm text-foreground">
{{ form.unlimited ? '已启用' : '已关闭' }}
</span>
<span class="text-xs text-muted-foreground">
{{ form.unlimited ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
</span>
</div>
</div>
</div>
<div
v-if="!isEditMode && !form.unlimited"
class="space-y-2"
>
<Label
for="form-initial-gift"
class="text-sm font-medium"
>初始赠款额度 (USD) <span class="text-muted-foreground">*</span></Label>
<Input
id="form-initial-gift"
:model-value="form.initial_gift_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="10.00"
class="h-10"
@update:model-value="(v) => form.initial_gift_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<p class="text-xs text-muted-foreground">
最小值 $0.01
</p>
</div>
</div>
</div>
</form>
@@ -363,6 +375,7 @@ import {
Button,
Input,
Label,
Switch,
Select,
SelectTrigger,
SelectValue,
@@ -376,13 +389,15 @@ import { getProvidersSummary } from '@/api/endpoints/providers'
import { getGlobalModels } from '@/api/global-models'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
import { parseNumberInput } from '@/utils/form'
import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/endpoints/types'
export interface UserFormData {
id?: string
username: string
email: string
quota_usd?: number | null
initial_gift_usd?: number | null
unlimited?: boolean
role: 'admin' | 'user'
is_active?: boolean
allowed_providers?: string[] | null
@@ -397,7 +412,7 @@ const props = defineProps<{
const emit = defineEmits<{
close: []
submit: [data: UserFormData & { password?: string }]
submit: [data: UserFormData & { password?: string; unlimited?: boolean }]
}>()
const isOpen = computed(() => props.open)
@@ -420,7 +435,7 @@ const form = ref({
password: '',
confirmPassword: '',
email: '',
quota: null as number | null,
initial_gift_usd: 10 as number | undefined,
role: 'user' as 'admin' | 'user',
unlimited: false,
is_active: true,
@@ -441,7 +456,7 @@ function resetForm() {
password: '',
confirmPassword: '',
email: '',
quota: null,
initial_gift_usd: 10,
role: 'user',
unlimited: false,
is_active: true,
@@ -461,9 +476,9 @@ function loadUserData() {
password: '',
confirmPassword: '',
email: props.user.email || '',
quota: props.user.quota_usd == null ? 10 : props.user.quota_usd,
initial_gift_usd: undefined,
role: props.user.role,
unlimited: props.user.quota_usd == null,
unlimited: props.user.unlimited ?? false,
is_active: props.user.is_active ?? true,
allowed_providers: [...(props.user.allowed_providers || [])],
allowed_api_formats: [...(props.user.allowed_api_formats || [])],
@@ -491,16 +506,33 @@ const usernameError = computed(() => {
return ''
})
function getPasswordValidationError(password: string): string | null {
if (password.length < 8) return '密码长度至少为8个字符'
if (!/[A-Z]/.test(password)) return '密码必须包含至少一个大写字母'
if (!/[a-z]/.test(password)) return '密码必须包含至少一个小写字母'
if (!/[0-9]/.test(password)) return '密码必须包含至少一个数字'
return null
}
// 表单验证
const isFormValid = computed(() => {
const hasUsername = form.value.username.trim().length > 0
const usernameValid = !usernameError.value
const hasPassword = isEditMode.value || form.value.password.length >= 6
// 编辑模式下如果填写了密码,必须确认密码一致
const passwordConfirmed = !isEditMode.value || form.value.password.length === 0 || form.value.password === form.value.confirmPassword
return hasUsername && usernameValid && hasPassword && passwordConfirmed
const passwordFilled = form.value.password.length > 0
const passwordValid = passwordFilled
? !getPasswordValidationError(form.value.password)
: isEditMode.value
// 编辑模式下可留空;填写时必须确认一致。创建模式不展示确认输入框。
const passwordConfirmed = isEditMode.value
? !passwordFilled || form.value.password === form.value.confirmPassword
: true
const initialGiftValid = isEditMode.value ||
form.value.unlimited ||
(typeof form.value.initial_gift_usd === 'number' && form.value.initial_gift_usd >= 0.01)
return hasUsername && usernameValid && passwordValid && passwordConfirmed && initialGiftValid
})
// 加载访问控制选项
async function loadAccessControlOptions(): Promise<void> {
try {
@@ -532,27 +564,25 @@ function toggleSelection(field: 'allowed_providers' | 'allowed_api_formats' | 'a
async function handleSubmit() {
saving.value = true
try {
const data: UserFormData & { password?: string; unlimited?: boolean } = {
const data: UserFormData & { password?: string; unlimited: boolean } = {
username: form.value.username,
email: form.value.email.trim() || '',
quota_usd: form.value.unlimited ? null : form.value.quota,
unlimited: form.value.unlimited,
role: form.value.role,
allowed_providers: form.value.allowed_providers.length > 0 ? form.value.allowed_providers : null,
allowed_api_formats: form.value.allowed_api_formats.length > 0 ? form.value.allowed_api_formats : null,
allowed_models: form.value.allowed_models.length > 0 ? form.value.allowed_models : null
}
// 创建模式下传递 unlimited 字段
if (!isEditMode.value) {
data.unlimited = form.value.unlimited
}
if (isEditMode.value && props.user?.id) {
data.id = props.user.id
}
if (!isEditMode.value) {
data.is_active = form.value.is_active
if (!form.value.unlimited && form.value.initial_gift_usd != null) {
data.initial_gift_usd = form.value.initial_gift_usd
}
}
if (form.value.password) {
@@ -580,6 +610,20 @@ watch(isOpen, (val) => {
}
})
watch(
() => form.value.unlimited,
(unlimited) => {
if (isEditMode.value) {
return
}
if (unlimited) {
form.value.initial_gift_usd = undefined
} else if (form.value.initial_gift_usd == null) {
form.value.initial_gift_usd = 10
}
}
)
defineExpose({
setSaving
})