Merge branch 'feat/wallet-system' into master

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

Closes #204
This commit is contained in:
fawney19
2026-03-08 00:06:05 +08:00
108 changed files with 13152 additions and 3372 deletions
+4
View File
@@ -21,6 +21,10 @@ JWT_SECRET_KEY=change-this-to-a-secure-random-string
# 注意:更换此密钥后需要在管理面板重新配置所有 Provider API Key
ENCRYPTION_KEY=change-this-to-another-secure-random-string
# 支付回调共享密钥(公开 /api/payment/callback/* 入口必须携带 x-payment-callback-token)
# 建议使用 32+ 位随机字符串
PAYMENT_CALLBACK_SECRET=change-this-to-a-secure-callback-secret
# 管理员账号(仅首次初始化时使用, 创建完成后可在系统内修改密码)
ADMIN_EMAIL=[email protected]
ADMIN_USERNAME=admin
+18 -11
View File
@@ -51,7 +51,7 @@ if config.config_file_name is not None:
target_metadata = Base.metadata
# PostgreSQL 全局迁移锁,避免多进程并发执行 Alembic 导致竞态(重复加列/索引等)
# 使用事务级 advisory lock(pg_advisory_xact_lock),在迁移事务结束后自动释放。
# 使用会话级 advisory lock(pg_advisory_lock),在迁移完成后手动释放。
# ID 由 crc32("aether-alembic-migration") 拼接生成,仅需全局唯一即可。
MIGRATION_ADVISORY_LOCK_ID = 582694137405821
@@ -90,20 +90,27 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True, # 比较列类型变更
compare_server_default=True, # 比较默认值变更
)
with context.begin_transaction():
try:
# 使用会话级 advisory lock(非事务级),避免干扰 Alembic 的事务管理。
# pg_advisory_lock 在会话结束时自动释放,不受 COMMIT/ROLLBACK 影响。
if connection.dialect.name == "postgresql":
connection.execute(
text("SELECT pg_advisory_xact_lock(:lock_id)"),
text("SELECT pg_advisory_lock(:lock_id)"),
{"lock_id": MIGRATION_ADVISORY_LOCK_ID},
)
context.run_migrations()
connection.commit()
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
except Exception:
raise
# 根据模式选择运行方式
@@ -0,0 +1,65 @@
"""remove standalone api key locking
Revision ID: 7c91d2e4f8a1
Revises: 6f7a8b9c0d1e
Create Date: 2026-03-05 17:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "7c91d2e4f8a1"
down_revision: str | None = "6f7a8b9c0d1e"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_CONSTRAINT_NAME = "ck_api_keys_standalone_not_locked"
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [c["name"] for c in insp.get_columns(table_name)]
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
def upgrade() -> None:
if not (
_column_exists("api_keys", "is_standalone")
and _column_exists("api_keys", "is_locked")
and _column_exists("api_keys", "is_active")
):
return
op.execute(sa.text("""
UPDATE api_keys
SET is_active = FALSE,
is_locked = FALSE
WHERE is_standalone IS TRUE AND is_locked IS TRUE
"""))
if not _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
op.create_check_constraint(
_CONSTRAINT_NAME,
"api_keys",
"(NOT is_standalone) OR (NOT is_locked)",
)
def downgrade() -> None:
if _check_constraint_exists("api_keys", _CONSTRAINT_NAME):
op.drop_constraint(_CONSTRAINT_NAME, "api_keys", type_="check")
@@ -0,0 +1,220 @@
"""tighten wallet transaction snapshots and remove wallet version
Revision ID: 8e71f2a4c9b0
Revises: 7c91d2e4f8a1
Create Date: 2026-03-07 13:00:00.000000+00:00
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "8e71f2a4c9b0"
down_revision: str | None = "7c91d2e4f8a1"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_WALLET_TX_BEFORE_CHECK = "ck_wallet_tx_balance_before_consistent"
_WALLET_TX_AFTER_CHECK = "ck_wallet_tx_balance_after_consistent"
_WALLET_LIMIT_MODE_INDEX = "idx_wallets_limit_mode"
def _table_exists(table_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return table_name in insp.get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return column_name in [c["name"] for c in insp.get_columns(table_name)]
def _index_exists(table_name: str, index_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(index.get("name") == index_name for index in insp.get_indexes(table_name))
def _check_constraint_exists(table_name: str, constraint_name: str) -> bool:
bind = op.get_bind()
insp = inspect(bind)
insp.clear_cache()
return any(c.get("name") == constraint_name for c in insp.get_check_constraints(table_name))
def _tighten_wallet_transaction_snapshots() -> None:
if not _table_exists("wallet_transactions"):
return
required_columns = {
"balance_before",
"balance_after",
"recharge_balance_before",
"recharge_balance_after",
"gift_balance_before",
"gift_balance_after",
}
existing_columns = {
column["name"] for column in inspect(op.get_bind()).get_columns("wallet_transactions")
}
if not required_columns.issubset(existing_columns):
return
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET recharge_balance_before = balance_before
WHERE recharge_balance_before IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET recharge_balance_after = balance_after
WHERE recharge_balance_after IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET gift_balance_before = 0
WHERE gift_balance_before IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET gift_balance_after = 0
WHERE gift_balance_after IS NULL
"""
)
)
op.execute(
sa.text(
"""
UPDATE wallet_transactions
SET balance_before = recharge_balance_before + gift_balance_before,
balance_after = recharge_balance_after + gift_balance_after
"""
)
)
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
op.create_check_constraint(
_WALLET_TX_BEFORE_CHECK,
"wallet_transactions",
"balance_before = recharge_balance_before + gift_balance_before",
)
if not _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
op.create_check_constraint(
_WALLET_TX_AFTER_CHECK,
"wallet_transactions",
"balance_after = recharge_balance_after + gift_balance_after",
)
op.alter_column(
"wallet_transactions",
"recharge_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"recharge_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"gift_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
op.alter_column(
"wallet_transactions",
"gift_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=False,
)
def _drop_wallet_cleanup_artifacts() -> None:
if not _table_exists("wallets"):
return
if _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
op.drop_index(_WALLET_LIMIT_MODE_INDEX, table_name="wallets")
if _column_exists("wallets", "version"):
op.drop_column("wallets", "version")
def upgrade() -> None:
_tighten_wallet_transaction_snapshots()
_drop_wallet_cleanup_artifacts()
def downgrade() -> None:
if _table_exists("wallets"):
if not _column_exists("wallets", "version"):
op.add_column(
"wallets",
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
)
if not _index_exists("wallets", _WALLET_LIMIT_MODE_INDEX):
op.create_index(_WALLET_LIMIT_MODE_INDEX, "wallets", ["limit_mode"])
if not _table_exists("wallet_transactions"):
return
if _column_exists("wallet_transactions", "recharge_balance_before"):
op.alter_column(
"wallet_transactions",
"recharge_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "recharge_balance_after"):
op.alter_column(
"wallet_transactions",
"recharge_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "gift_balance_before"):
op.alter_column(
"wallet_transactions",
"gift_balance_before",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _column_exists("wallet_transactions", "gift_balance_after"):
op.alter_column(
"wallet_transactions",
"gift_balance_after",
existing_type=sa.Numeric(20, 8),
nullable=True,
)
if _check_constraint_exists("wallet_transactions", _WALLET_TX_AFTER_CHECK):
op.drop_constraint(_WALLET_TX_AFTER_CHECK, "wallet_transactions", type_="check")
if _check_constraint_exists("wallet_transactions", _WALLET_TX_BEFORE_CHECK):
op.drop_constraint(_WALLET_TX_BEFORE_CHECK, "wallet_transactions", type_="check")
+1 -1
View File
@@ -15,7 +15,7 @@
### 用户系统
- **users**: 用户账户管理
- **api_keys**: API 密钥管理
- **user_quotas**: 用户配额管理
- **wallets**: 统一钱包账户(充值余额/赠款余额/无限制模式)
- **user_preferences**: 用户偏好设置
### Provider 三层架构
+93
View File
@@ -0,0 +1,93 @@
import apiClient from './client'
import type { PaymentOrder } from './wallet'
export interface PaymentCallbackRecord {
id: string
payment_order_id: string | null
payment_method: string
callback_key: string
order_no: string | null
gateway_order_id: string | null
payload_hash: string | null
signature_valid: boolean
status: string
payload: Record<string, unknown> | null
error_message: string | null
created_at: string
processed_at: string | null
}
export interface AdminPaymentOrderListResponse {
items: PaymentOrder[]
total: number
limit: number
offset: number
}
export interface AdminPaymentCallbacksResponse {
items: PaymentCallbackRecord[]
total: number
limit: number
offset: number
}
export interface AdminPaymentCreditRequest {
gateway_order_id?: string
pay_amount?: number
pay_currency?: string
exchange_rate?: number
gateway_response?: Record<string, unknown>
}
export const adminPaymentsApi = {
async listOrders(params?: {
status?: string
payment_method?: string
limit?: number
offset?: number
}): Promise<AdminPaymentOrderListResponse> {
const response = await apiClient.get<AdminPaymentOrderListResponse>('/api/admin/payments/orders', { params })
return response.data
},
async getOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.get<{ order: PaymentOrder }>(`/api/admin/payments/orders/${orderId}`)
return response.data
},
async expireOrder(orderId: string): Promise<{ order: PaymentOrder; expired: boolean }> {
const response = await apiClient.post<{ order: PaymentOrder; expired: boolean }>(
`/api/admin/payments/orders/${orderId}/expire`,
{}
)
return response.data
},
async failOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.post<{ order: PaymentOrder }>(
`/api/admin/payments/orders/${orderId}/fail`,
{}
)
return response.data
},
async creditOrder(
orderId: string,
payload: AdminPaymentCreditRequest
): Promise<{ order: PaymentOrder; credited: boolean }> {
const response = await apiClient.post<{ order: PaymentOrder; credited: boolean }>(
`/api/admin/payments/orders/${orderId}/credit`,
payload
)
return response.data
},
async listCallbacks(params?: {
payment_method?: string
limit?: number
offset?: number
}): Promise<AdminPaymentCallbacksResponse> {
const response = await apiClient.get<AdminPaymentCallbacksResponse>('/api/admin/payments/callbacks', { params })
return response.data
},
}
+247
View File
@@ -0,0 +1,247 @@
import apiClient from './client'
import type { RefundRequest, WalletSummary, WalletTransaction } from './wallet'
export interface AdminWallet extends WalletSummary {
user_id: string | null
api_key_id: string | null
owner_type: 'user' | 'api_key'
owner_name: string | null
created_at: string
}
export interface AdminWalletListResponse {
items: AdminWallet[]
total: number
limit: number
offset: number
}
export interface AdminWalletDetailResponse extends AdminWallet {
pending_refund_count: number
}
export interface AdminWalletTransactionsResponse {
wallet: AdminWallet
items: WalletTransaction[]
total: number
limit: number
offset: number
}
export interface AdminWalletRefundsResponse {
wallet: AdminWallet
items: RefundRequest[]
total: number
limit: number
offset: number
}
export interface AdminLedgerTransaction extends WalletTransaction {
wallet_id: string
owner_type: 'user' | 'api_key'
owner_name: string | null
wallet_status?: string | null
}
export interface AdminGlobalRefund extends RefundRequest {
wallet_id: string
owner_type: 'user' | 'api_key'
owner_name: string | null
wallet_status?: string | null
}
export interface AdminLedgerResponse {
items: AdminLedgerTransaction[]
total: number
limit: number
offset: number
}
export interface AdminGlobalRefundsListResponse {
items: AdminGlobalRefund[]
total: number
limit: number
offset: number
}
export interface ManualRechargeRequest {
amount_usd: number
payment_method?: string
description?: string
}
export interface WalletAdjustRequest {
amount_usd: number
balance_type?: 'recharge' | 'gift'
description?: string
}
export interface RefundFailRequest {
reason: string
}
export interface RefundCompleteRequest {
gateway_refund_id?: string
payout_reference?: string
payout_proof?: Record<string, unknown>
}
export const adminWalletApi = {
async listWallets(params?: {
status?: string
limit?: number
offset?: number
}): Promise<AdminWalletListResponse> {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', { params })
return response.data
},
async listAllWallets(params?: {
status?: string
}): Promise<AdminWallet[]> {
const items: AdminWallet[] = []
const limit = 200
const maxPages = 200
let offset = 0
let page = 0
while (page < maxPages) {
const response = await apiClient.get<AdminWalletListResponse>('/api/admin/wallets', {
params: {
...params,
limit,
offset,
},
})
const data = response.data
items.push(...data.items)
if (items.length >= data.total || data.items.length < limit) {
break
}
const nextOffset = offset + data.items.length
if (nextOffset <= offset) {
throw new Error('分页游标未前进,终止全量钱包拉取以避免死循环')
}
offset = nextOffset
page += 1
}
if (page >= maxPages) {
throw new Error(`钱包列表分页超过最大页数 ${maxPages},已中止请求`)
}
return items
},
async getWalletDetail(walletId: string): Promise<AdminWalletDetailResponse> {
const response = await apiClient.get<AdminWalletDetailResponse>(`/api/admin/wallets/${walletId}`)
return response.data
},
async listLedger(params?: {
category?: string
reason_code?: string
owner_type?: string
limit?: number
offset?: number
}): Promise<AdminLedgerResponse> {
const response = await apiClient.get<AdminLedgerResponse>('/api/admin/wallets/ledger', { params })
return response.data
},
async listGlobalRefunds(params?: {
status?: string
owner_type?: string
limit?: number
offset?: number
}): Promise<AdminGlobalRefundsListResponse> {
const response = await apiClient.get<AdminGlobalRefundsListResponse>('/api/admin/wallets/refund-requests', {
params,
})
return response.data
},
async getWalletTransactions(
walletId: string,
params?: { limit?: number; offset?: number }
): Promise<AdminWalletTransactionsResponse> {
const response = await apiClient.get<AdminWalletTransactionsResponse>(
`/api/admin/wallets/${walletId}/transactions`,
{ params }
)
return response.data
},
async getWalletRefunds(
walletId: string,
params?: { limit?: number; offset?: number }
): Promise<AdminWalletRefundsResponse> {
const response = await apiClient.get<AdminWalletRefundsResponse>(
`/api/admin/wallets/${walletId}/refunds`,
{ params }
)
return response.data
},
async rechargeWallet(walletId: string, payload: ManualRechargeRequest): Promise<{
wallet: AdminWallet
payment_order: {
id: string
order_no: string
amount_usd: number
payment_method: string
status: string
created_at: string
credited_at: string | null
}
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/recharge`, payload)
return response.data
},
async adjustWallet(walletId: string, payload: WalletAdjustRequest): Promise<{
wallet: AdminWallet
transaction: WalletTransaction
}> {
const response = await apiClient.post(`/api/admin/wallets/${walletId}/adjust`, payload)
return response.data
},
async processRefund(walletId: string, refundId: string): Promise<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction
}> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/process`,
{}
)
return response.data
},
async failRefund(walletId: string, refundId: string, payload: RefundFailRequest): Promise<{
wallet: AdminWallet
refund: RefundRequest
transaction: WalletTransaction | null
}> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/fail`,
payload
)
return response.data
},
async completeRefund(
walletId: string,
refundId: string,
payload: RefundCompleteRequest
): Promise<{ refund: RefundRequest }> {
const response = await apiClient.post(
`/api/admin/wallets/${walletId}/refunds/${refundId}/complete`,
payload
)
return response.data
},
}
+12 -30
View File
@@ -1,5 +1,6 @@
import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
// LDAP 配置导出结构
export interface LDAPConfigExport {
@@ -69,9 +70,8 @@ export interface UserExport {
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
model_capability_settings?: Record<string, Record<string, boolean>>
quota_usd?: number | null
used_usd?: number
total_usd?: number
unlimited?: boolean
wallet?: BillingSummary | null
is_active: boolean
api_keys: UserApiKeyExport[]
}
@@ -82,8 +82,6 @@ export interface UserApiKeyExport {
key_encrypted?: string | null
name?: string | null
is_standalone: boolean
balance_used_usd?: number
current_balance_usd?: number | null
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
allowed_models?: string[] | null
@@ -329,10 +327,7 @@ export interface AdminApiKey {
name?: string
key_display?: string // 脱敏后的密钥显示
is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额(仅独立Key)
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
total_requests?: number
total_tokens?: number
total_cost_usd?: number
@@ -354,7 +349,8 @@ export interface CreateStandaloneApiKeyRequest {
allowed_models?: string[] | null
rate_limit?: number | null // null = 无限制
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
initial_balance_usd: number // 初始余额,必须设置
initial_balance_usd: number | null // 初始余额,null = 无限制
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
auto_delete_on_expiry?: boolean // 过期后是否自动删除
}
@@ -502,7 +498,10 @@ export const adminApi = {
},
// 更新独立余额Key
async updateApiKey(keyId: string, data: Partial<CreateStandaloneApiKeyRequest>): Promise<AdminApiKey & { message: string }> {
async updateApiKey(
keyId: string,
data: Partial<CreateStandaloneApiKeyRequest>
): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.put<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}`,
data
@@ -526,27 +525,10 @@ export const adminApi = {
return response.data
},
// 切换API密钥锁定状态(锁定/解锁)
async toggleLockApiKey(keyId: string): Promise<ApiKeyLockResponse> {
// 切换用户普通 API Key 锁定状态(锁定/解锁)
async toggleUserApiKeyLock(userId: string, keyId: string): Promise<ApiKeyLockResponse> {
const response = await apiClient.patch<ApiKeyLockResponse>(
`/api/admin/api-keys/${keyId}/lock`
)
return response.data
},
// 为独立余额Key调整余额
async addApiKeyBalance(keyId: string, amountUsd: number): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}/balance`,
{ amount_usd: amountUsd }
)
return response.data
},
// 重置独立余额Key的已使用额度
async resetApiKeyUsage(keyId: string): Promise<AdminApiKey & { message: string }> {
const response = await apiClient.patch<AdminApiKey & { message: string }>(
`/api/admin/api-keys/${keyId}/reset-usage`
`/api/admin/users/${userId}/api-keys/${keyId}/lock`
)
return response.data
},
+18 -3
View File
@@ -89,15 +89,30 @@ export interface AuthSettingsResponse {
ldap_exclusive: boolean
}
export interface BillingSummary {
id?: string | null
balance: number
recharge_balance: number
gift_balance: number
refundable_balance: number
currency: string
status: string
limit_mode: 'finite' | 'unlimited'
unlimited: boolean
total_recharged: number
total_consumed: number
total_refunded: number
total_adjusted: number
updated_at?: string | null
}
export interface User {
id: string // UUID
username: string
email?: string
role: string // 'admin' or 'user'
is_active: boolean
quota_usd?: number | null
used_usd?: number
total_usd?: number
billing?: BillingSummary
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
allowed_models?: string[] | null // 允许使用的模型名称列表
+3 -5
View File
@@ -2,6 +2,7 @@ import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
export interface Profile {
id: string // UUID
@@ -9,9 +10,7 @@ export interface Profile {
username: string
role: string
is_active: boolean
quota_usd: number | null
used_usd: number
total_usd?: number // 累积消费总额
billing: BillingSummary
created_at: string
updated_at?: string
last_login_at?: string
@@ -103,8 +102,7 @@ export interface UsageResponse {
total_cost: number // 官方费率
total_actual_cost?: number // 倍率消耗(仅管理员可见)
avg_response_time: number
quota_usd: number | null
used_usd: number
billing: BillingSummary
summary_by_model: ModelSummary[]
summary_by_provider?: ProviderSummary[]
pagination?: {
+9 -10
View File
@@ -6,14 +6,13 @@ export interface User {
email: string
role: 'admin' | 'user'
is_active: boolean
quota_usd: number | null
used_usd: number
total_usd: number
unlimited: boolean
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
allowed_models: string[] | null // 允许使用的模型名称列表
created_at: string
updated_at?: string
last_login_at?: string | null
}
export interface CreateUserRequest {
@@ -21,7 +20,7 @@ export interface CreateUserRequest {
password: string
email: string
role?: 'admin' | 'user'
quota_usd?: number | null
initial_gift_usd?: number | null
unlimited?: boolean
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
@@ -32,7 +31,7 @@ export interface UpdateUserRequest {
email?: string
is_active?: boolean
role?: 'admin' | 'user'
quota_usd?: number | null
unlimited?: boolean
password?: string
allowed_providers?: string[] | null
allowed_api_formats?: string[] | null
@@ -50,8 +49,6 @@ export interface ApiKey {
is_active: boolean
is_locked: boolean // 管理员锁定标志
is_standalone: boolean // 是否为独立余额Key
balance_used_usd?: number // 已使用余额(仅独立Key)
current_balance_usd?: number | null // 当前余额(独立Key预付费模式,null表示无限制)
rate_limit?: number // 速率限制(请求/分钟)
total_requests?: number // 总请求数
total_cost_usd?: number // 总费用
@@ -96,10 +93,12 @@ export const usersApi = {
await apiClient.delete(`/api/admin/users/${userId}/api-keys/${keyId}`)
},
async resetUserQuota(userId: string): Promise<void> {
await apiClient.patch(`/api/admin/users/${userId}/quota`)
async getFullApiKey(userId: string, keyId: string): Promise<{ key: string }> {
const response = await apiClient.get<{ key: string }>(
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
)
return response.data
},
// 管理员统计
async getUsageStats(): Promise<Record<string, unknown>> {
const response = await apiClient.get('/api/admin/usage/stats')
+176
View File
@@ -0,0 +1,176 @@
import apiClient from './client'
export interface WalletSummary {
id: string
// balance = 总可用余额(充值余额 + 赠款余额)
balance: number
recharge_balance: number
gift_balance: number
refundable_balance: number
currency: string
status: string
limit_mode?: 'finite' | 'unlimited'
unlimited?: boolean
total_recharged: number
total_consumed: number
total_refunded: number
total_adjusted: number
updated_at: string
}
export interface WalletBalanceResponse {
wallet: WalletSummary | null
unlimited: boolean
limit_mode: 'finite' | 'unlimited'
// balance = 总可用余额(充值余额 + 赠款余额)
balance: number | null
recharge_balance?: number | null
gift_balance?: number | null
refundable_balance?: number | null
currency: string
pending_refund_count?: number
}
export interface WalletTransaction {
id: string
category: string
reason_code: string
amount: number
// 总可用余额(充值+赠款)快照
balance_before: number
balance_after: number
// 分账户快照
recharge_balance_before: number
recharge_balance_after: number
gift_balance_before: number
gift_balance_after: number
link_type?: string | null
link_id?: string | null
operator_id?: string | null
operator_name?: string | null
operator_email?: string | null
description?: string | null
created_at: string
}
export interface WalletTransactionsResponse extends WalletBalanceResponse {
items: WalletTransaction[]
total: number
limit: number
offset: number
}
export interface PaymentOrder {
id: string
order_no: string
wallet_id: string
user_id: string | null
amount_usd: number
pay_amount: number | null
pay_currency: string | null
exchange_rate: number | null
refunded_amount_usd: number
refundable_amount_usd: number
payment_method: string
gateway_order_id: string | null
gateway_response: Record<string, unknown> | null
status: string
created_at: string
paid_at: string | null
credited_at: string | null
expires_at: string | null
}
export interface RefundRequest {
id: string
refund_no: string
payment_order_id: string | null
source_type: string
source_id: string | null
refund_mode: string
amount_usd: number
status: string
reason: string | null
failure_reason: string | null
gateway_refund_id: string | null
payout_method: string | null
payout_reference: string | null
payout_proof: Record<string, unknown> | null
created_at: string
updated_at: string
processed_at: string | null
completed_at: string | null
}
export interface WalletRechargeCreateRequest {
amount_usd: number
payment_method: string
pay_amount?: number
pay_currency?: string
exchange_rate?: number
}
export interface WalletRefundCreateRequest {
amount_usd: number
payment_order_id?: string
source_type?: string
source_id?: string
refund_mode?: string
reason?: string
idempotency_key?: string
}
export const walletApi = {
async getBalance(): Promise<WalletBalanceResponse> {
const response = await apiClient.get<WalletBalanceResponse>('/api/wallet/balance')
return response.data
},
async getTransactions(params?: { limit?: number; offset?: number }): Promise<WalletTransactionsResponse> {
const response = await apiClient.get<WalletTransactionsResponse>('/api/wallet/transactions', { params })
return response.data
},
async createRechargeOrder(payload: WalletRechargeCreateRequest): Promise<{
order: PaymentOrder
payment_instructions: Record<string, unknown>
}> {
const response = await apiClient.post('/api/wallet/recharge', payload)
return response.data
},
async listRechargeOrders(params?: { limit?: number; offset?: number }): Promise<{
items: PaymentOrder[]
total: number
limit: number
offset: number
}> {
const response = await apiClient.get('/api/wallet/recharge', { params })
return response.data
},
async getRechargeOrder(orderId: string): Promise<{ order: PaymentOrder }> {
const response = await apiClient.get(`/api/wallet/recharge/${orderId}`)
return response.data
},
async listRefunds(params?: { limit?: number; offset?: number }): Promise<{
items: RefundRequest[]
total: number
limit: number
offset: number
}> {
const response = await apiClient.get('/api/wallet/refunds', { params })
return response.data
},
async getRefund(refundId: string): Promise<RefundRequest> {
const response = await apiClient.get<RefundRequest>(`/api/wallet/refunds/${refundId}`)
return response.data
},
async createRefund(payload: WalletRefundCreateRequest): Promise<RefundRequest> {
const response = await apiClient.post<RefundRequest>('/api/wallet/refunds', payload)
return response.data
},
}
+21 -4
View File
@@ -1,7 +1,7 @@
<template>
<Dialog
:model-value="modelValue"
:z-index="80"
:z-index="120"
@update:model-value="handleClose"
>
<template #header>
@@ -28,9 +28,8 @@
v-for="(line, index) in descriptionLines"
:key="index"
:class="getLineClass(index)"
>
{{ line }}
</p>
v-html="renderLine(line)"
/>
</div>
<!-- 自定义内容插槽 -->
@@ -103,6 +102,24 @@ const descriptionLines = computed(() => {
return props.description.split('\n').filter(line => line.trim())
})
function escapeHtml(raw: string): string {
return raw
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function renderLine(line: string): string {
const escaped = escapeHtml(line)
// 支持最小语法加粗:**text**
return escaped.replace(
/\*\*(.+?)\*\*/g,
'<strong class="font-semibold text-foreground">$1</strong>'
)
}
// 根据行索引获取样式(中间行高亮)
function getLineClass(index: number): string {
const total = descriptionLines.value.length
@@ -22,7 +22,7 @@
{{ isEditMode ? '编辑独立余额 API Key' : '创建独立余额 API Key' }}
</h3>
<p class="text-xs text-muted-foreground">
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,不关联用户配额,必须设置余额限制' }}
{{ isEditMode ? '修改密钥名称、有效期和访问限制' : '用于非注册用户调用接口,可设置初始余额或无限制额度' }}
</p>
</div>
</div>
@@ -34,7 +34,6 @@
<!-- 左侧:基础设置 -->
<div class="pr-6 space-y-4">
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
<Key class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">基础设置</span>
</div>
@@ -52,31 +51,6 @@
/>
</div>
<!-- 初始余额 - 仅创建模式显示 -->
<div
v-if="!isEditMode"
class="space-y-2"
>
<Label
for="form-balance"
class="text-sm font-medium"
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
<Input
id="form-balance"
:model-value="form.initial_balance_usd ?? ''"
type="number"
step="0.01"
min="0.01"
required
placeholder="10.00"
class="h-10"
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true }) ?? 10"
/>
<p class="text-xs text-muted-foreground">
独立Key必须设置余额限制,最小值 $0.01
</p>
</div>
<div class="space-y-2">
<Label
for="form-expires-at"
@@ -137,12 +111,49 @@
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
/>
</div>
<div class="space-y-2">
<Label class="text-sm font-medium">无限制额度</Label>
<div class="flex items-center gap-3">
<Switch v-model="form.unlimited_balance" />
<div class="flex flex-col">
<span class="text-sm text-foreground">
{{ form.unlimited_balance ? '已启用' : '已关闭' }}
</span>
<span class="text-xs text-muted-foreground">
{{ form.unlimited_balance ? '无限制:忽略钱包余额校验' : '有限制:按钱包余额校验' }}
</span>
</div>
</div>
</div>
<div
v-if="!isEditMode && !form.unlimited_balance"
class="space-y-2"
>
<Label
for="form-balance"
class="text-sm font-medium"
>初始余额 (USD) <span class="text-rose-500">*</span></Label>
<Input
id="form-balance"
:model-value="form.initial_balance_usd ?? ''"
type="number"
step="0.01"
min="0.01"
placeholder="10.00"
class="h-10"
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
/>
<p class="text-xs text-muted-foreground">
最小值 $0.01
</p>
</div>
</div>
<!-- 右侧:访问限制 -->
<div class="pl-6 space-y-4 border-l border-border">
<div class="flex items-center gap-2 pb-2 border-b border-border/60">
<Shield class="h-4 w-4 text-muted-foreground" />
<span class="text-sm font-medium">访问限制</span>
<span class="text-xs text-muted-foreground">(留空不限)</span>
</div>
@@ -279,8 +290,9 @@ import {
Button,
Input,
Label,
Switch,
} from '@/components/ui'
import { Plus, SquarePen, Key, Shield, ChevronDown, X } from 'lucide-vue-next'
import { Plus, SquarePen, ChevronDown, X } from 'lucide-vue-next'
import { useFormDialog } from '@/composables/useFormDialog'
import { ModelMultiSelect } from '@/components/common'
import { getProvidersSummary } from '@/api/endpoints/providers'
@@ -294,6 +306,7 @@ export interface StandaloneKeyFormData {
id?: string
name: string
initial_balance_usd?: number
unlimited_balance?: boolean
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
rate_limit?: number
auto_delete_on_expiry: boolean
@@ -328,6 +341,7 @@ const allApiFormats = ref<string[]>([])
const form = ref<StandaloneKeyFormData>({
name: '',
initial_balance_usd: 10,
unlimited_balance: false,
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
@@ -347,6 +361,7 @@ function resetForm() {
form.value = {
name: '',
initial_balance_usd: 10,
unlimited_balance: false,
expires_at: undefined,
rate_limit: undefined,
auto_delete_on_expiry: false,
@@ -364,6 +379,7 @@ function loadKeyData() {
id: props.apiKey.id,
name: props.apiKey.name || '',
initial_balance_usd: props.apiKey.initial_balance_usd,
unlimited_balance: props.apiKey.initial_balance_usd == null,
expires_at: props.apiKey.expires_at,
rate_limit: props.apiKey.rate_limit,
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
@@ -432,6 +448,17 @@ watch(isOpen, (val) => {
}
})
watch(
() => form.value.unlimited_balance,
(unlimited) => {
if (unlimited) {
form.value.initial_balance_usd = undefined
} else if (form.value.initial_balance_usd == null) {
form.value.initial_balance_usd = 10
}
}
)
defineExpose({
setSaving
})
@@ -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
})
File diff suppressed because it is too large Load Diff
+3
View File
@@ -363,6 +363,7 @@ import {
SunMoon,
ChevronRight,
Megaphone,
Wallet,
Menu,
X,
Puzzle,
@@ -521,6 +522,7 @@ const navigation = computed(() => {
{
title: '账户',
items: [
{ name: '钱包中心', href: '/dashboard/wallet', icon: Wallet },
{ name: '使用统计', href: '/dashboard/usage', icon: BarChart3 },
{ name: '异步任务', href: '/dashboard/async-tasks', icon: Zap },
]
@@ -579,6 +581,7 @@ const navigation = computed(() => {
{ name: '模型管理', href: '/admin/models', icon: Layers },
{ name: '号池管理', href: '/admin/pool', icon: Database },
{ name: '独立密钥', href: '/admin/keys', icon: Key },
{ name: '钱包管理', href: '/admin/wallets', icon: Wallet },
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
{ name: '使用记录', href: '/admin/usage', icon: BarChart3 },
]
+44 -33
View File
@@ -12,15 +12,47 @@ import type { ProviderWithEndpointsSummary, GlobalModelResponse } from '@/api/en
// ========== 用户数据 ==========
const MOCK_ADMIN_BILLING = {
id: 'wallet-demo-admin',
balance: 0,
recharge_balance: 0,
gift_balance: 0,
refundable_balance: 0,
currency: 'USD',
status: 'active',
limit_mode: 'unlimited' as const,
unlimited: true,
total_recharged: 0,
total_consumed: 1234.56,
total_refunded: 0,
total_adjusted: 0,
updated_at: new Date().toISOString(),
}
const MOCK_USER_BILLING = {
id: 'wallet-demo-user',
balance: 54.68,
recharge_balance: 40,
gift_balance: 14.68,
refundable_balance: 40,
currency: 'USD',
status: 'active',
limit_mode: 'finite' as const,
unlimited: false,
total_recharged: 100,
total_consumed: 45.32,
total_refunded: 0,
total_adjusted: 0,
updated_at: new Date().toISOString(),
}
export const MOCK_ADMIN_USER: User = {
id: 'demo-admin-uuid-0001',
username: 'Demo Admin',
email: '[email protected]',
role: 'admin',
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
billing: MOCK_ADMIN_BILLING,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -34,9 +66,7 @@ export const MOCK_NORMAL_USER: User = {
email: '[email protected]',
role: 'user',
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
billing: MOCK_USER_BILLING,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -74,9 +104,7 @@ export const MOCK_ADMIN_PROFILE: Profile = {
username: MOCK_ADMIN_USER.username,
role: 'admin',
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
billing: MOCK_ADMIN_BILLING,
created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
@@ -92,9 +120,7 @@ export const MOCK_USER_PROFILE: Profile = {
username: MOCK_NORMAL_USER.username,
role: 'user',
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
billing: MOCK_USER_BILLING,
created_at: '2024-06-01T00:00:00Z',
updated_at: new Date().toISOString(),
last_login_at: new Date().toISOString(),
@@ -269,10 +295,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
username: 'Demo Admin',
email: '[email protected]',
role: 'admin',
unlimited: true,
is_active: true,
quota_usd: null,
used_usd: 156.78,
total_usd: 1234.56,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -283,10 +307,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
username: 'Demo User',
email: '[email protected]',
role: 'user',
unlimited: false,
is_active: true,
quota_usd: 100,
used_usd: 45.32,
total_usd: 245.32,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -297,10 +319,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
username: 'Alice Wang',
email: '[email protected]',
role: 'user',
unlimited: false,
is_active: true,
quota_usd: 50,
used_usd: 23.45,
total_usd: 123.45,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -311,10 +331,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
username: 'Bob Zhang',
email: '[email protected]',
role: 'user',
unlimited: false,
is_active: true,
quota_usd: 200,
used_usd: 89.12,
total_usd: 589.12,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -325,10 +343,8 @@ export const MOCK_ALL_USERS: AdminUser[] = [
username: 'Charlie Li',
email: '[email protected]',
role: 'user',
unlimited: false,
is_active: false,
quota_usd: 30,
used_usd: 30.00,
total_usd: 30.00,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -387,8 +403,6 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
key_display: 'sk-sa...abc1',
is_active: true,
is_standalone: true,
balance_used_usd: 25.50,
current_balance_usd: 74.50,
total_requests: 500,
total_tokens: 1500000,
total_cost_usd: 25.50,
@@ -404,8 +418,6 @@ export const MOCK_ADMIN_API_KEYS: AdminApiKeysResponse = {
key_display: 'sk-sa...def2',
is_active: true,
is_standalone: true,
balance_used_usd: 45.00,
current_balance_usd: 55.00,
total_requests: 800,
total_tokens: 2400000,
total_cost_usd: 45.00,
@@ -808,8 +820,7 @@ export const MOCK_USAGE_RESPONSE: UsageResponse = {
total_cost: 45.67,
total_actual_cost: 33.33,
avg_response_time: 1.23,
quota_usd: 100,
used_usd: 45.32,
billing: MOCK_USER_BILLING,
summary_by_model: [
{ model: 'claude-sonnet-4-5-20250929', requests: 456, input_tokens: 650000, output_tokens: 250000, total_tokens: 900000, total_cost_usd: 18.50, actual_total_cost_usd: 13.50 },
{ model: 'gpt-5.1', requests: 312, input_tokens: 480000, output_tokens: 180000, total_tokens: 660000, total_cost_usd: 12.30, actual_total_cost_usd: 9.20 },
+17 -7
View File
@@ -588,8 +588,22 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
total_cost: Number((totalCost * 20).toFixed(2)),
total_actual_cost: Number((totalActualCost * 20).toFixed(2)),
avg_response_time: Number(avgResponseTime.toFixed(2)) || 1.23,
quota_usd: 100,
used_usd: Number((totalCost * 20).toFixed(2)),
billing: {
id: 'wallet-demo-user',
balance: Number((100 - totalCost * 20).toFixed(2)),
recharge_balance: Number((100 - totalCost * 20).toFixed(2)),
gift_balance: 0,
refundable_balance: Number((100 - totalCost * 20).toFixed(2)),
currency: 'USD',
status: 'active',
limit_mode: 'finite',
unlimited: false,
total_recharged: 100,
total_consumed: Number((totalCost * 20).toFixed(2)),
total_refunded: 0,
total_adjusted: 0,
updated_at: new Date().toISOString(),
},
activity_heatmap: heatmap,
summary_by_model: Array.from(modelStats.entries()).map(([model, stats]) => ({
model,
@@ -726,10 +740,8 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
username: body.username,
email: body.email,
role: body.role || 'user',
unlimited: Boolean(body.unlimited),
is_active: true,
quota_usd: body.quota_usd || null,
used_usd: 0,
total_usd: 0,
allowed_providers: null,
allowed_api_formats: null,
allowed_models: null,
@@ -757,8 +769,6 @@ const mockHandlers: Record<string, (config: AxiosRequestConfig) => Promise<Axios
key_display: 'sk-sa...demo',
is_active: true,
is_standalone: true,
balance_used_usd: 0,
current_balance_usd: body.initial_balance_usd || 100,
total_requests: 0,
created_at: new Date().toISOString()
}
+10
View File
@@ -122,6 +122,11 @@ const routes: RouteRecordRaw[] = [
name: 'Settings',
component: () => importWithRetry(() => import('@/views/user/Settings.vue'))
},
{
path: 'wallet',
name: 'WalletCenter',
component: () => importWithRetry(() => import('@/views/user/WalletCenter.vue'))
},
{
path: 'models',
name: 'ModelCatalog',
@@ -154,6 +159,11 @@ const routes: RouteRecordRaw[] = [
name: 'ApiKeys',
component: () => importWithRetry(() => import('@/views/admin/ApiKeys.vue'))
},
{
path: 'wallets',
name: 'WalletsManagement',
component: () => importWithRetry(() => import('@/views/admin/WalletsManagement.vue'))
},
{
path: 'management-tokens',
name: 'AdminManagementTokens',
+4 -11
View File
@@ -102,19 +102,12 @@ export const useUsersStore = defineStore('users', () => {
}
}
async function resetUserQuota(userId: string) {
loading.value = true
error.value = null
async function getFullApiKey(userId: string, keyId: string): Promise<{ key: string }> {
try {
await usersApi.resetUserQuota(userId)
// 刷新用户列表以获取最新数据
await fetchUsers()
return await usersApi.getFullApiKey(userId, keyId)
} catch (err: unknown) {
error.value = parseApiError(err, '重置配额失败')
error.value = parseApiError(err, '获取完整 API Key 失败')
throw err
} finally {
loading.value = false
}
}
@@ -129,6 +122,6 @@ export const useUsersStore = defineStore('users', () => {
getUserApiKeys,
createApiKey,
deleteApiKey,
resetUserQuota
getFullApiKey
}
})
+44
View File
@@ -1192,6 +1192,50 @@ body[theme-mode='dark'] .literary-annotation {
background-color: hsl(var(--muted-foreground) / 0.5);
}
/* Tabs styled as existing outline buttons (used by wallet/payment pages) */
.tabs-button-list {
background-color: transparent !important;
border: 0 !important;
padding: 0 !important;
height: auto !important;
gap: 0.5rem;
}
.tabs-button-list .tabs-indicator {
display: none !important;
}
.tabs-button-list button[data-value] {
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent) !important;
background-color: color-mix(in srgb, var(--card) 60%, transparent) !important;
color: var(--foreground) !important;
border-radius: 0.5rem !important;
backdrop-filter: blur(8px);
transition: all 0.2s ease !important;
}
.tabs-button-list button[data-state='active'] {
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
border-color: transparent !important;
box-shadow: var(--shadow-sm);
font-weight: 700;
}
.tabs-button-list button[data-state='active']:hover {
filter: brightness(0.95);
}
.tabs-button-list button[data-state='inactive'] {
color: var(--foreground) !important;
}
.tabs-button-list button[data-state='inactive']:hover {
border-color: rgb(var(--color-primary-rgb) / 0.6) !important;
color: var(--primary) !important;
background-color: rgb(var(--color-primary-rgb) / 0.1) !important;
}
/* Password masking without type="password" to prevent browser autofill */
.-webkit-text-security-disc {
-webkit-text-security: disc;
+153
View File
@@ -0,0 +1,153 @@
export function walletStatusLabel(status: string | null | undefined): string {
const labels: Record<string, string> = {
active: '正常',
suspended: '已冻结',
closed: '已关闭',
}
if (!status) return '未知'
return labels[status] || status
}
export function formatWalletCurrency(
value: number | null | undefined,
options?: { decimals?: number }
): string {
const decimals = options?.decimals ?? 2
const amount = Number(value ?? 0)
return `$${amount.toFixed(decimals)}`
}
export function walletStatusBadge(status: string | null | undefined): string {
if (status === 'active') return 'success'
if (status === 'suspended') return 'warning'
if (status === 'closed') return 'destructive'
return 'secondary'
}
export function walletTransactionCategoryLabel(category: string | null | undefined): string {
const labels: Record<string, string> = {
recharge: '充值',
gift: '赠款',
adjust: '调账',
refund: '退款',
}
if (!category) return '未知'
return labels[category] || category
}
export function walletTransactionReasonLabel(reasonCode: string | null | undefined): string {
const labels: Record<string, string> = {
topup_admin_manual: '人工充值',
topup_gateway: '支付充值',
topup_card_code: '卡密充值',
gift_initial: '初始赠款',
gift_campaign: '活动赠款',
gift_expire_reclaim: '赠款回收',
adjust_admin: '人工调账',
adjust_system: '系统调账',
refund_out: '退款扣减',
refund_revert: '退款回补',
}
if (!reasonCode) return '未知'
return labels[reasonCode] || reasonCode
}
export function paymentMethodLabel(method: string | null | undefined): string {
const labels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信支付',
admin_manual: '人工充值',
card_code: '充值卡',
gift_code: '礼品卡',
card_recharge: '卡密充值',
bank_transfer: '银行转账',
offline: '线下转账',
}
if (!method) return '-'
return labels[method] || method
}
export function paymentStatusLabel(status: string | null | undefined): string {
const labels: Record<string, string> = {
pending: '待支付',
paid: '已支付',
credited: '已到账',
failed: '支付失败',
expired: '已过期',
refunding: '退款中',
refunded: '已退款',
}
if (!status) return '未知'
return labels[status] || status
}
export function walletLinkTypeLabel(type: string | null | undefined): string {
const labels: Record<string, string> = {
payment_order: '充值订单',
refund_request: '退款申请',
admin_action: '后台操作',
system_task: '系统任务',
campaign: '活动批次',
usage: '用量记录',
}
if (!type) return '-'
return labels[type] || '其他'
}
export function paymentStatusBadge(status: string | null | undefined): string {
if (status === 'credited' || status === 'refunded') return 'success'
if (status === 'paid' || status === 'refunding') return 'outline'
if (status === 'pending') return 'secondary'
if (status === 'expired') return 'warning'
if (status === 'failed') return 'destructive'
return 'secondary'
}
export function refundModeLabel(mode: string | null | undefined): string {
const labels: Record<string, string> = {
original_channel: '原路退回',
offline_payout: '线下打款',
}
if (!mode) return '-'
return labels[mode] || mode
}
export function refundStatusLabel(status: string | null | undefined): string {
const labels: Record<string, string> = {
pending_approval: '待审批',
approved: '已审批',
processing: '处理中',
succeeded: '已完成',
failed: '已失败',
cancelled: '已取消',
}
if (!status) return '未知'
return labels[status] || status
}
export function refundStatusBadge(status: string | null | undefined): string {
if (status === 'succeeded') return 'success'
if (status === 'processing') return 'outline'
if (status === 'pending_approval' || status === 'approved') return 'secondary'
if (status === 'failed' || status === 'cancelled') return 'destructive'
return 'secondary'
}
export function callbackStatusLabel(status: string | null | undefined): string {
const labels: Record<string, string> = {
processed: '已处理',
duplicate: '重复回调',
ignored: '已忽略',
invalid_signature: '验签失败',
error: '处理失败',
}
if (!status) return '未知'
return labels[status] || status
}
export function callbackStatusBadge(status: string | null | undefined): string {
if (status === 'processed') return 'success'
if (status === 'duplicate' || status === 'ignored') return 'secondary'
if (status === 'invalid_signature' || status === 'error') return 'destructive'
return 'outline'
}
+320 -420
View File
@@ -89,7 +89,7 @@
<!-- 刷新按钮 -->
<RefreshButton
:loading="loading"
@click="loadApiKeys"
@click="refreshApiKeys"
/>
</div>
</div>
@@ -102,8 +102,8 @@
<TableHead class="w-[200px] h-12 font-semibold">
密钥信息
</TableHead>
<TableHead class="w-[160px] h-12 font-semibold">
余额 (已用/总额)
<TableHead class="w-[240px] h-12 font-semibold">
钱包
</TableHead>
<TableHead class="w-[130px] h-12 font-semibold">
使用统计
@@ -114,7 +114,7 @@
<TableHead class="w-[140px] h-12 font-semibold">
最近使用
</TableHead>
<TableHead class="w-[70px] h-12 font-semibold text-center">
<TableHead class="w-[180px] h-12 font-semibold">
状态
</TableHead>
<TableHead class="w-[130px] h-12 font-semibold text-center">
@@ -189,12 +189,28 @@
</div>
</TableCell>
<TableCell class="py-4">
<div class="text-xs">
<div class="flex items-center gap-1.5">
<span class="font-mono font-medium">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
<span class="text-muted-foreground">/</span>
<span :class="isBalanceLimited(apiKey) ? 'font-mono font-medium text-primary' : 'font-mono text-muted-foreground'">
{{ isBalanceLimited(apiKey) ? `$${(apiKey.current_balance_usd || 0).toFixed(2)}` : '无限' }}
<div class="space-y-1.5">
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
<span>余额:</span>
<Badge
v-if="isApiKeyUnlimited(apiKey)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<span
v-else
class="text-sm font-semibold tabular-nums"
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>
已消费:
<span class="font-medium tabular-nums text-foreground">${{ getApiKeyWalletConsumed(apiKey).toFixed(2) }}</span>
</span>
</div>
</div>
@@ -242,20 +258,20 @@
>暂无记录</span>
</div>
</TableCell>
<TableCell class="py-4 text-center">
<div class="flex flex-col items-center gap-1">
<TableCell class="py-4">
<div class="flex flex-col items-start gap-1.5">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="font-medium"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
v-if="getApiKeyWallet(apiKey.id)"
:variant="walletStatusBadge(getApiKeyWalletStatus(apiKey.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
已锁定
{{ walletStatusLabel(getApiKeyWalletStatus(apiKey.id)) }}
</Badge>
</div>
</TableCell>
@@ -274,36 +290,11 @@
variant="ghost"
size="icon"
class="h-8 w-8"
title="调整余额"
title="资金操作"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置额度"
@click="resetKeyUsage(apiKey)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:title="apiKey.is_locked ? '解锁' : '锁定'"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-4 w-4"
/>
<LockOpen
v-else
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
@@ -329,207 +320,207 @@
</Table>
</div>
<div class="xl:hidden divide-y divide-border/40">
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
<div
v-if="apiKeys.length === 0"
class="p-8 text-center"
v-if="filteredApiKeys.length === 0"
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
>
<Key class="h-12 w-12 mx-auto mb-3 text-muted-foreground/50" />
<p class="text-muted-foreground">
暂无独立余额 Key
<Key class="mx-auto mb-3 h-12 w-12 text-muted-foreground/50" />
<p class="text-sm font-medium text-foreground">
{{ hasActiveFilters ? '未找到匹配的 Key' : '暂无独立余额 Key' }}
</p>
<p
v-if="hasActiveFilters"
class="mt-1 text-xs text-muted-foreground"
>
尝试调整筛选条件
</p>
</div>
<div
v-for="apiKey in apiKeys"
:key="apiKey.id"
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
v-else
class="space-y-3.5"
>
<div class="space-y-4">
<div class="flex items-start justify-between gap-3">
<div class="space-y-2">
<div class="flex items-center gap-2">
<code class="inline-flex rounded-lg bg-muted px-3 py-1.5 text-xs font-mono font-semibold">
{{ apiKey.key_display || 'sk-****' }}
</code>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 hover:bg-muted flex-shrink-0"
title="复制完整密钥"
@click="copyKeyPrefix(apiKey)"
<div
v-for="apiKey in filteredApiKeys"
:key="apiKey.id"
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
>
<div class="space-y-4">
<div class="flex items-start gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-center gap-2">
<code class="inline-flex max-w-[190px] sm:max-w-[240px] truncate rounded-lg bg-muted px-3 py-1.5 text-[11px] font-mono font-semibold text-foreground/90">
{{ apiKey.key_display || 'sk-****' }}
</code>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 flex-shrink-0 hover:bg-muted"
title="复制完整密钥"
@click="copyKeyPrefix(apiKey)"
>
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<div
class="truncate text-sm font-semibold text-foreground"
:class="{ 'text-muted-foreground': !apiKey.name }"
:title="apiKey.name || '未命名 Key'"
>
<Copy class="h-3.5 w-3.5" />
</Button>
</div>
<div
class="text-sm font-semibold text-foreground"
:class="{ 'text-muted-foreground': !apiKey.name }"
>
{{ apiKey.name || '未命名 Key' }}
{{ apiKey.name || '未命名 Key' }}
</div>
</div>
</div>
<div class="flex flex-col items-end gap-1">
<div class="flex flex-wrap items-center gap-1.5">
<Badge
:variant="apiKey.is_active ? 'success' : 'destructive'"
class="text-xs flex-shrink-0"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ apiKey.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="apiKey.is_locked"
variant="secondary"
class="text-xs"
v-if="getApiKeyWallet(apiKey.id)"
:variant="walletStatusBadge(getApiKeyWalletStatus(apiKey.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
已锁定
{{ walletStatusLabel(getApiKeyWalletStatus(apiKey.id)) }}
</Badge>
<Badge
v-if="apiKey.auto_delete_on_expiry"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
过期自动删除
</Badge>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[11px] text-muted-foreground">
<span class="inline-flex items-center gap-1 rounded-full border border-border/60 px-2.5 py-0.5">
{{ isBalanceLimited(apiKey) ? '限额 Key' : '无限额度' }}
</span>
<span
v-if="apiKey.auto_delete_on_expiry"
class="inline-flex items-center gap-1 rounded-full bg-muted px-2.5 py-0.5"
>
过期自动删除
</span>
</div>
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
余额:
</p>
<Badge
v-if="isApiKeyUnlimited(apiKey)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<p
v-else
class="text-base font-semibold tabular-nums leading-none"
:class="isNegativeWalletAmount(getApiKeyWalletTotalBalance(apiKey)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatWalletAmount(getApiKeyWalletTotalBalance(apiKey), '-') }}
</p>
</div>
<div class="text-right">
<p class="text-[11px] text-muted-foreground">
已消费:
</p>
<p class="text-sm font-medium tabular-nums text-foreground">
${{ getApiKeyWalletConsumed(apiKey).toFixed(2) }}
</p>
</div>
</div>
</div>
<div class="space-y-2 p-3 bg-muted/50 rounded-lg text-xs">
<div class="flex items-center justify-between text-muted-foreground">
<span>已用</span>
<span class="font-semibold">${{ (apiKey.balance_used_usd || 0).toFixed(2) }}</span>
<div class="grid grid-cols-2 gap-2.5 text-xs">
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
速率限制
</div>
<div class="font-semibold text-foreground">
{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}
</div>
</div>
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
请求次数
</div>
<div class="font-semibold text-foreground">
{{ (apiKey.total_requests || 0).toLocaleString() }}
</div>
</div>
<div class="col-span-2 rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
有效期
</div>
<div class="font-semibold text-foreground">
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
</div>
<div
v-if="apiKey.expires_at"
class="text-[11px] text-muted-foreground"
>
{{ getRelativeTime(apiKey.expires_at) }}
</div>
</div>
</div>
<div class="flex items-center justify-between text-muted-foreground">
<span>剩余</span>
<span :class="getBalanceRemaining(apiKey) > 0 ? 'font-semibold text-emerald-600' : 'font-semibold text-rose-600'">
{{ isBalanceLimited(apiKey) ? `$${getBalanceRemaining(apiKey).toFixed(2)}` : '无限制' }}
</span>
</div>
<div class="flex items-center justify-between text-amber-600">
<span>总费用</span>
<span>${{ (apiKey.total_cost_usd || 0).toFixed(4) }}</span>
</div>
<div
v-if="isBalanceLimited(apiKey)"
class="h-1.5 rounded-full bg-background/40 overflow-hidden"
>
<div
class="h-full rounded-full bg-emerald-500"
:style="{ width: `${getBalanceProgress(apiKey)}%` }"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2 text-xs">
<div class="p-2 bg-muted/40 rounded-lg">
<div class="text-muted-foreground mb-1">
速率限制
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
<div class="flex items-center justify-between gap-2">
<span>创建</span>
<span class="font-medium text-foreground">{{ formatDate(apiKey.created_at) }}</span>
</div>
<div class="font-semibold">
{{ apiKey.rate_limit ? `${apiKey.rate_limit}/min` : '未设置' }}
</div>
</div>
<div class="p-2 bg-muted/40 rounded-lg">
<div class="text-muted-foreground mb-1">
请求次数
</div>
<div class="font-semibold">
{{ (apiKey.total_requests || 0).toLocaleString() }}
</div>
</div>
<div class="p-2 bg-muted/40 rounded-lg col-span-2">
<div class="text-muted-foreground mb-1">
有效期
</div>
<div class="font-semibold">
{{ apiKey.expires_at ? formatDate(apiKey.expires_at) : '永不过期' }}
<div class="mt-1 flex items-center justify-between gap-2">
<span>最近使用</span>
<span
v-if="apiKey.last_used_at"
class="font-medium text-foreground"
>{{ formatDate(apiKey.last_used_at) }}</span>
<span v-else>暂无记录</span>
</div>
<div
v-if="apiKey.expires_at"
class="text-[11px] text-muted-foreground"
class="mt-1 flex items-center justify-between gap-2"
>
{{ getRelativeTime(apiKey.expires_at) }}
<span>过期后</span>
<span>{{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}</span>
</div>
</div>
</div>
<div class="text-xs text-muted-foreground space-y-1">
<p>创建: {{ formatDate(apiKey.created_at) }}</p>
<p>
最近使用:
<span
v-if="apiKey.last_used_at"
class="font-medium text-foreground"
>{{ formatDate(apiKey.last_used_at) }}</span>
<span v-else>暂无记录</span>
</p>
<p v-if="apiKey.expires_at">
过期后: {{ apiKey.auto_delete_on_expiry ? '自动删除' : '仅禁用' }}
</p>
</div>
<div class="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
@click="editApiKey(apiKey)"
>
<SquarePen class="h-3.5 w-3.5 mr-1.5" />
编辑
</Button>
<Button
variant="outline"
size="sm"
class="text-blue-600"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
调整
</Button>
<Button
variant="outline"
size="sm"
class="text-amber-600"
@click="resetKeyUsage(apiKey)"
>
<RotateCcw class="h-3.5 w-3.5 mr-1.5" />
重置
</Button>
<Button
variant="outline"
size="sm"
@click="toggleLockApiKey(apiKey)"
>
<Lock
v-if="apiKey.is_locked"
class="h-3.5 w-3.5 mr-1.5"
/>
<LockOpen
v-else
class="h-3.5 w-3.5 mr-1.5"
/>
{{ apiKey.is_locked ? '解锁' : '锁定' }}
</Button>
<Button
variant="outline"
size="sm"
@click="toggleApiKey(apiKey)"
>
<Power class="h-3.5 w-3.5 mr-1.5" />
{{ apiKey.is_active ? '禁用' : '启用' }}
</Button>
<Button
variant="outline"
size="sm"
class="text-rose-600 col-span-2"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="h-3.5 w-3.5 mr-1.5" />
删除
</Button>
<div class="grid grid-cols-2 gap-2 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="editApiKey(apiKey)"
>
<SquarePen class="mr-1.5 h-3.5 w-3.5" />
编辑
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="openAddBalanceDialog(apiKey)"
>
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
资金
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="toggleApiKey(apiKey)"
>
<Power class="mr-1.5 h-3.5 w-3.5" />
{{ apiKey.is_active ? '禁用' : '启用' }}
</Button>
<Button
variant="outline"
size="sm"
class="col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
@click="deleteApiKey(apiKey)"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
删除
</Button>
</div>
</div>
</div>
</div>
@@ -611,96 +602,17 @@
</template>
</Dialog>
<!-- 余额调整对话框 -->
<Dialog
v-model="showAddBalanceDialog"
size="md"
>
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/30 flex-shrink-0">
<DollarSign class="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">
余额调整
</h3>
<p class="text-xs text-muted-foreground">
增加或扣除 API Key 余额
</p>
</div>
</div>
</div>
</template>
<div class="space-y-4">
<div class="p-3 bg-muted/50 rounded-lg text-sm">
<div class="font-medium mb-2">
当前余额信息
</div>
<div class="space-y-1 text-xs text-muted-foreground">
<div>已用: <span class="font-semibold text-foreground">${{ (addBalanceKey.balance_used_usd || 0).toFixed(2) }}</span></div>
<div>当前余额: <span class="font-semibold text-foreground">${{ (addBalanceKey.current_balance_usd || 0).toFixed(2) }}</span></div>
</div>
</div>
<div class="space-y-2">
<Label
for="addBalanceAmount"
class="text-sm font-medium"
>调整金额 (USD)</Label>
<Input
id="addBalanceAmount"
:model-value="addBalanceAmount ?? ''"
type="number"
step="0.01"
placeholder="正数为增加,负数为扣除"
class="h-11"
@update:model-value="(v) => addBalanceAmount = parseNumberInput(v, { allowFloat: true })"
/>
<p class="text-xs text-muted-foreground">
<span
v-if="addBalanceAmount && addBalanceAmount > 0"
class="text-emerald-600"
>
增加 ${{ addBalanceAmount.toFixed(2) }},调整后余额: ${{ ((addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
</span>
<span
v-else-if="addBalanceAmount && addBalanceAmount < 0"
class="text-rose-600"
>
扣除 ${{ Math.abs(addBalanceAmount).toFixed(2) }},调整后余额: ${{ Math.max(0, (addBalanceKey.current_balance_usd || 0) + addBalanceAmount).toFixed(2) }}
</span>
<span
v-else
class="text-muted-foreground"
>
输入正数增加余额,负数扣除余额
</span>
</p>
</div>
</div>
<template #footer>
<div class="flex gap-3 justify-end">
<Button
variant="outline"
class="h-10 px-5"
@click="showAddBalanceDialog = false"
>
取消
</Button>
<Button
:disabled="addingBalance || !addBalanceAmount || addBalanceAmount === 0"
class="h-10 px-5"
@click="handleAddBalance"
>
{{ addingBalance ? '调整中...' : '确认调整' }}
</Button>
</div>
</template>
</Dialog>
<WalletOpsDrawer
:open="showWalletActionDrawer"
:wallet="walletActionTarget?.wallet || null"
:owner-name="walletActionTarget?.apiKey.name || walletActionTarget?.apiKey.key_display || '未命名 Key'"
:owner-subtitle="walletActionTarget?.apiKey.key_display || walletActionTarget?.apiKey.username || ''"
context-label="独立密钥钱包"
accent="blue"
:show-refunds="false"
@close="closeWalletActionDrawer"
@changed="handleWalletDrawerChanged"
/>
</div>
</template>
@@ -710,6 +622,9 @@ import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { adminApi, type AdminApiKey, type CreateStandaloneApiKeyRequest } from '@/api/admin'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import {
Dialog,
@@ -743,14 +658,10 @@ import {
Copy,
CheckCircle,
SquarePen,
Search,
Lock,
LockOpen,
RotateCcw
Search
} from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
import { parseNumberInput } from '@/utils/form'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
@@ -759,6 +670,7 @@ const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const apiKeys = ref<AdminApiKey[]>([])
const apiKeyWalletMap = ref<Record<string, AdminWallet>>({})
const loading = ref(false)
const total = ref(0)
const currentPage = ref(1)
@@ -839,19 +751,11 @@ const filteredApiKeys = computed(() => {
return result
})
// 充值相关状态
const showAddBalanceDialog = ref(false)
const addBalanceKey = ref({
id: '',
name: '',
balance_used_usd: 0,
current_balance_usd: 0
})
const addBalanceAmount = ref<number | undefined>(undefined)
const addingBalance = ref(false)
const showWalletActionDrawer = ref(false)
const walletActionTarget = ref<{ apiKey: AdminApiKey; wallet: AdminWallet } | null>(null)
onMounted(async () => {
await loadApiKeys()
await refreshApiKeys()
})
async function loadApiKeys() {
@@ -871,9 +775,29 @@ async function loadApiKeys() {
}
}
async function loadApiKeyWallets() {
try {
const wallets = await adminWalletApi.listAllWallets()
apiKeyWalletMap.value = wallets
.filter((wallet) => wallet.owner_type === 'api_key' && !!wallet.api_key_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.api_key_id as string] = wallet
return acc
}, {})
} catch (err: unknown) {
log.error('加载独立 Key 钱包失败:', err)
}
}
async function refreshApiKeys() {
// 先拉取 Key 列表,再拉钱包,避免并发请求导致新钱包映射短暂缺失。
await loadApiKeys()
await loadApiKeyWallets()
}
function handlePageChange(page: number) {
currentPage.value = page
loadApiKeys()
refreshApiKeys()
}
async function toggleApiKey(apiKey: AdminApiKey) {
@@ -890,20 +814,6 @@ async function toggleApiKey(apiKey: AdminApiKey) {
}
}
async function toggleLockApiKey(apiKey: AdminApiKey) {
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
const index = apiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
apiKeys.value[index].is_locked = response.is_locked
}
success(response.message)
} catch (err: unknown) {
log.error('切换密钥锁定状态失败:', err)
error(parseApiError(err, '操作失败'))
}
}
async function deleteApiKey(apiKey: AdminApiKey) {
const confirmed = await confirmDanger(
`确定要删除这个独立余额 Key 吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n此操作无法撤销。`,
@@ -916,6 +826,7 @@ async function deleteApiKey(apiKey: AdminApiKey) {
const response = await adminApi.deleteApiKey(apiKey.id)
apiKeys.value = apiKeys.value.filter(k => k.id !== apiKey.id)
total.value = total.value - 1
delete apiKeyWalletMap.value[apiKey.id]
success(response.message)
} catch (err: unknown) {
log.error('删除密钥失败:', err)
@@ -936,6 +847,8 @@ function editApiKey(apiKey: AdminApiKey) {
editingKeyData.value = {
id: apiKey.id,
name: apiKey.name || '',
initial_balance_usd: isApiKeyUnlimited(apiKey) ? undefined : (getApiKeyWalletTotalBalance(apiKey) ?? undefined),
unlimited_balance: isApiKeyUnlimited(apiKey),
expires_at: expiresAt,
rate_limit: apiKey.rate_limit ?? undefined,
auto_delete_on_expiry: apiKey.auto_delete_on_expiry || false,
@@ -947,63 +860,73 @@ function editApiKey(apiKey: AdminApiKey) {
showKeyFormDialog.value = true
}
function getApiKeyWallet(apiKeyId: string): AdminWallet | null {
return apiKeyWalletMap.value[apiKeyId] || null
}
function isApiKeyUnlimited(apiKey: AdminApiKey): boolean {
const wallet = getApiKeyWallet(apiKey.id)
return wallet?.limit_mode === 'unlimited' || wallet?.unlimited === true
}
function getApiKeyWalletTotalBalance(apiKey: AdminApiKey): number | null {
if (isApiKeyUnlimited(apiKey)) {
return null
}
const wallet = getApiKeyWallet(apiKey.id)
return wallet ? wallet.balance : 0
}
function getApiKeyWalletConsumed(apiKey: AdminApiKey): number {
return getApiKeyWallet(apiKey.id)?.total_consumed ?? (apiKey.total_cost_usd || 0)
}
function getApiKeyWalletStatus(apiKeyId: string): string | null {
return getApiKeyWallet(apiKeyId)?.status ?? null
}
function formatWalletAmount(value: number | null, nullLabel = '无限制'): string {
if (value == null) {
return nullLabel
}
return `$${value.toFixed(2)}`
}
function isNegativeWalletAmount(value: number | null): boolean {
return typeof value === 'number' && value < 0
}
function openAddBalanceDialog(apiKey: AdminApiKey) {
addBalanceKey.value = {
id: apiKey.id,
name: apiKey.name || apiKey.key_display || 'sk-****',
balance_used_usd: apiKey.balance_used_usd || 0,
current_balance_usd: apiKey.current_balance_usd || 0
}
addBalanceAmount.value = undefined
showAddBalanceDialog.value = true
}
async function handleAddBalance() {
if (!addBalanceAmount.value || addBalanceAmount.value === 0) {
error('调整金额不能为 0')
const wallet = getApiKeyWallet(apiKey.id)
if (!wallet) {
error('该独立 Key 的钱包尚未初始化,暂时无法进行资金操作')
return
}
// 验证扣除金额不能超过当前余额
if (addBalanceAmount.value < 0 && Math.abs(addBalanceAmount.value) > (addBalanceKey.value.current_balance_usd || 0)) {
error('扣除金额不能超过当前余额')
walletActionTarget.value = {
apiKey,
wallet
}
showWalletActionDrawer.value = true
}
function closeWalletActionDrawer() {
showWalletActionDrawer.value = false
}
async function handleWalletDrawerChanged() {
await refreshApiKeys()
if (!walletActionTarget.value) {
return
}
addingBalance.value = true
try {
const response = await adminApi.addApiKeyBalance(addBalanceKey.value.id, addBalanceAmount.value)
// 重新加载列表
await loadApiKeys()
showAddBalanceDialog.value = false
const action = addBalanceAmount.value > 0 ? '增加' : '扣除'
const amount = Math.abs(addBalanceAmount.value).toFixed(2)
success(response.message || `余额${action}成功,${action} $${amount}`)
} catch (err: unknown) {
log.error('余额调整失败:', err)
error(parseApiError(err, '调整失败'))
} finally {
addingBalance.value = false
const latestKey = apiKeys.value.find((item) => item.id === walletActionTarget.value?.apiKey.id)
const latestWallet = getApiKeyWallet(walletActionTarget.value.apiKey.id)
if (latestKey) {
walletActionTarget.value.apiKey = latestKey
}
}
async function resetKeyUsage(apiKey: AdminApiKey) {
const confirmed = await confirmDanger(
`确定要重置此 Key 的已使用额度吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n已使用额度将归零,当前余额不变。`,
'重置使用额度'
)
if (!confirmed) return
try {
const response = await adminApi.resetApiKeyUsage(apiKey.id)
await loadApiKeys()
success(response.message)
} catch (err: unknown) {
log.error('重置使用额度失败:', err)
error(parseApiError(err, '重置失败'))
if (latestWallet) {
walletActionTarget.value.wallet = latestWallet
}
}
@@ -1032,27 +955,7 @@ function closeNewKeyDialog() {
}
function isBalanceLimited(apiKey: AdminApiKey): boolean {
return apiKey.current_balance_usd !== null && apiKey.current_balance_usd !== undefined
}
function getBalanceProgress(apiKey: AdminApiKey): number {
if (!isBalanceLimited(apiKey)) {
return 0
}
// 总额 = 当前余额 + 已使用
const used = apiKey.balance_used_usd || 0
const remaining = apiKey.current_balance_usd || 0
const total = used + remaining
if (total <= 0) {
return 0
}
// 进度条显示剩余比例(绿色部分)
const ratio = (remaining / total) * 100
const normalized = Number.isFinite(ratio) ? ratio : 0
return Math.max(0, Math.min(100, normalized))
return !isApiKeyUnlimited(apiKey)
}
function isExpiringSoon(apiKey: AdminApiKey): boolean {
@@ -1066,15 +969,6 @@ function isExpiringSoon(apiKey: AdminApiKey): boolean {
return diffDays > 0 && diffDays <= EXPIRY_SOON_DAYS
}
function getBalanceRemaining(apiKey: AdminApiKey): number {
// 计算剩余余额 = 当前余额 - 已使用余额
if (apiKey.current_balance_usd === null || apiKey.current_balance_usd === undefined) {
return 0
}
const remaining = apiKey.current_balance_usd - (apiKey.balance_used_usd || 0)
return Math.max(0, remaining) // 不能为负数
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
@@ -1133,6 +1027,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
// 更新
const updateData: Partial<CreateStandaloneApiKeyRequest> = {
name: data.name || undefined,
unlimited_balance: Boolean(data.unlimited_balance),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
@@ -1141,22 +1036,27 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
allowed_api_formats: data.allowed_api_formats,
allowed_models: data.allowed_models
}
const { message: _, ...updated } = await adminApi.updateApiKey(data.id, updateData)
// 局部更新:直接替换列表中对应的记录
const { message: _, wallet: __, ...updated } = await adminApi.updateApiKey(data.id, updateData)
// 局部更新:合并字段,避免覆盖丢失列表已有信息
const index = apiKeys.value.findIndex(k => k.id === data.id)
if (index !== -1) {
apiKeys.value[index] = updated
apiKeys.value[index] = {
...apiKeys.value[index],
...updated,
}
}
await loadApiKeyWallets()
success('API Key 更新成功')
} else {
// 创建
if (!data.initial_balance_usd || data.initial_balance_usd <= 0) {
const isUnlimited = Boolean(data.unlimited_balance)
if (!isUnlimited && (!data.initial_balance_usd || data.initial_balance_usd <= 0)) {
error('初始余额必须大于 0')
return
}
const createData: CreateStandaloneApiKeyRequest = {
name: data.name || undefined,
initial_balance_usd: data.initial_balance_usd,
initial_balance_usd: isUnlimited ? null : (data.initial_balance_usd as number),
rate_limit: data.rate_limit ?? null, // undefined = 无限制,显式传 null
expires_at: data.expires_at || null, // undefined/空 = 永不过期
auto_delete_on_expiry: data.auto_delete_on_expiry,
@@ -1169,7 +1069,7 @@ async function handleKeyFormSubmit(data: StandaloneKeyFormData) {
newKeyValue.value = response.key
showNewKeyDialog.value = true
success('独立 Key 创建成功')
await loadApiKeys()
await refreshApiKeys()
}
closeKeyFormDialog()
} catch (err: unknown) {
+2 -49
View File
@@ -56,7 +56,7 @@
<!-- 基础配置 -->
<BasicConfigSection
id="section-basic"
:default-user-quota-usd="systemConfig.default_user_quota_usd"
:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd"
:rate-limit-per-minute="systemConfig.rate_limit_per_minute"
:enable-registration="systemConfig.enable_registration"
:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys"
@@ -64,7 +64,7 @@
:loading="basicConfigLoading"
:has-changes="hasBasicConfigChanges"
@save="saveBasicConfig"
@update:default-user-quota-usd="systemConfig.default_user_quota_usd = $event"
@update:default-user-initial-gift-usd="systemConfig.default_user_initial_gift_usd = $event"
@update:rate-limit-per-minute="systemConfig.rate_limit_per_minute = $event"
@update:enable-registration="systemConfig.enable_registration = $event"
@update:auto-delete-expired-keys="systemConfig.auto_delete_expired_keys = $event"
@@ -113,15 +113,6 @@
<ScheduledTasksSection
id="section-scheduled"
:scheduled-tasks="scheduledTasks"
:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days"
:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days"
:standalone-key-reset-mode="systemConfig.standalone_key_quota_reset_mode"
:standalone-key-reset-key-ids="systemConfig.standalone_key_quota_reset_key_ids"
:standalone-keys="standaloneKeys"
@update:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days = $event"
@update:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days = $event"
@update:standalone-key-reset-mode="handleStandaloneKeyResetModeChange"
@toggle-standalone-key-reset-key-id="handleToggleStandaloneKeyResetKeyId"
/>
<!-- 系统版本信息 -->
@@ -201,7 +192,6 @@
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { PageHeader, PageContainer } from '@/components/layout'
import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { adminApi } from '@/api/admin'
// Composables
import { useSystemConfig } from './system-settings/composables/useSystemConfig'
@@ -344,50 +334,13 @@ const {
const {
scheduledTasks,
initPreviousValues,
saveStandaloneKeyResetMode,
saveStandaloneKeyResetKeyIds,
} = useScheduledTasks(systemConfig)
// 独立密钥列表(用于定时任务配置中的密钥选择)
const standaloneKeys = ref<Array<{ id: string; name?: string; key_display?: string; current_balance_usd?: number | null }>>([])
async function loadStandaloneKeys() {
try {
const result = await adminApi.getAllApiKeys({ limit: 2000 })
standaloneKeys.value = result.api_keys.map((k) => ({
id: k.id,
name: k.name,
key_display: k.key_display,
current_balance_usd: k.current_balance_usd,
}))
} catch {
// 加载失败不影响其他功能
}
}
function handleStandaloneKeyResetModeChange(mode: string) {
systemConfig.value.standalone_key_quota_reset_mode = mode
saveStandaloneKeyResetMode(mode)
}
function handleToggleStandaloneKeyResetKeyId(keyId: string) {
const ids = [...systemConfig.value.standalone_key_quota_reset_key_ids]
const idx = ids.indexOf(keyId)
if (idx >= 0) {
ids.splice(idx, 1)
} else {
ids.push(keyId)
}
systemConfig.value.standalone_key_quota_reset_key_ids = ids
saveStandaloneKeyResetKeyIds(ids)
}
onMounted(async () => {
await Promise.all([
loadSystemConfig(),
loadSystemVersion(),
proxyNodesStore.ensureLoaded(),
loadStandaloneKeys(),
])
// 配置加载完成后初始化定时任务的原始值
initPreviousValues()
+359 -207
View File
@@ -173,22 +173,19 @@
<Table>
<TableHeader>
<TableRow class="border-b border-border/60 hover:bg-transparent">
<TableHead class="w-[200px] h-12 font-semibold">
<TableHead class="w-[260px] h-12 font-semibold">
用户信息
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
邮箱
<TableHead class="w-[240px] h-12 font-semibold">
钱包
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
<TableHead class="w-[170px] h-12 font-semibold">
使用统计
</TableHead>
<TableHead class="w-[180px] h-12 font-semibold">
配额(美元)
</TableHead>
<TableHead class="w-[110px] h-12 font-semibold">
创建时间
</TableHead>
<TableHead class="w-[90px] h-12 font-semibold text-center">
<TableHead class="w-[180px] h-12 font-semibold">
状态
</TableHead>
<TableHead class="w-[220px] h-12 font-semibold text-center">
@@ -210,28 +207,55 @@
</AvatarFallback>
</Avatar>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-semibold mb-1"
:title="user.username"
>
{{ user.username }}
<div class="mb-1 flex items-center gap-1.5">
<div
class="truncate text-sm font-semibold"
:title="user.username"
>
{{ user.username }}
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="text-xs px-2 py-0.5"
<div
class="truncate text-xs text-muted-foreground"
:title="user.email || '-'"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
{{ user.email || '-' }}
</div>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<span
class="block truncate text-sm text-muted-foreground"
:title="user.email || '-'"
>
{{ user.email || '-' }}
</span>
<div class="space-y-1.5">
<div class="flex items-center gap-1 text-[11px] text-muted-foreground">
<span>余额:</span>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<span
v-else
class="text-sm font-semibold tabular-nums"
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-muted-foreground flex-wrap">
<span>
已消费:
<span class="font-medium tabular-nums text-foreground">${{ getUserWalletConsumed(user).toFixed(2) }}</span>
</span>
</div>
</div>
</TableCell>
<TableCell class="py-4">
<div
@@ -255,35 +279,25 @@
<span v-else>无数据</span>
</div>
</TableCell>
<TableCell class="py-4">
<div class="space-y-1.5 text-xs">
<div
v-if="user.quota_usd != null"
class="text-muted-foreground"
>
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium">${{ user.quota_usd.toFixed(2) }}</span>
</div>
<div
v-else
class="text-muted-foreground"
>
当前: <span class="font-semibold text-foreground">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="font-medium text-amber-600">无限制</span>
</div>
<div class="text-muted-foreground">
累计: <span class="font-medium text-foreground">${{ (user.total_usd || 0).toFixed(2) }}</span>
</div>
</div>
</TableCell>
<TableCell class="py-4 text-xs text-muted-foreground">
{{ formatDate(user.created_at) }}
</TableCell>
<TableCell class="py-4 text-center">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="font-medium px-3 py-1"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
<TableCell class="py-4">
<div class="flex flex-col items-start gap-1.5">
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="getUserWallet(user.id)"
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
</Badge>
</div>
</TableCell>
<TableCell class="py-4">
<div class="flex justify-center gap-1">
@@ -300,7 +314,16 @@
variant="ghost"
size="icon"
class="h-8 w-8"
title="查看API Keys"
title="资金操作"
@click="openWalletActionDialog(user)"
>
<DollarSign class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="API Keys"
@click="manageApiKeys(user)"
>
<Key class="h-4 w-4" />
@@ -321,15 +344,6 @@
class="h-4 w-4"
/>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置配额"
@click="resetQuota(user)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -347,145 +361,197 @@
</div>
<!-- 移动端卡片列表 -->
<div class="xl:hidden divide-y divide-border/40">
<div class="xl:hidden bg-muted/[0.14] p-3 sm:p-4">
<div
v-for="user in paginatedUsers"
:key="user.id"
class="p-4 sm:p-5 hover:bg-muted/30 transition-colors"
v-if="paginatedUsers.length === 0"
class="rounded-2xl border border-dashed border-border/60 bg-card/70 px-6 py-10 text-center"
>
<!-- 用户头部 -->
<div class="flex items-start justify-between mb-3 sm:mb-4">
<div class="flex items-center gap-2 sm:gap-3">
<Avatar class="h-10 w-10 sm:h-12 sm:w-12 ring-2 ring-background shadow-md flex-shrink-0">
<AvatarFallback class="bg-primary text-sm sm:text-base font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="min-w-0">
<div class="font-semibold text-sm sm:text-base mb-1 truncate">
{{ user.username }}
<Avatar class="mx-auto mb-3 h-12 w-12">
<AvatarFallback class="bg-muted text-base font-semibold text-muted-foreground">
U
</AvatarFallback>
</Avatar>
<p class="text-sm font-medium text-foreground">
{{ searchQuery || filterRole !== 'all' || filterStatus !== 'all' ? '未找到匹配的用户' : '暂无用户' }}
</p>
<p
v-if="searchQuery || filterRole !== 'all' || filterStatus !== 'all'"
class="mt-1 text-xs text-muted-foreground"
>
尝试调整筛选条件
</p>
</div>
<div
v-else
class="space-y-3.5"
>
<div
v-for="user in paginatedUsers"
:key="user.id"
class="rounded-2xl border border-border/60 bg-card/95 p-4 shadow-[0_10px_26px_-22px_hsl(var(--foreground))]"
>
<div class="space-y-4">
<div class="flex items-start gap-3">
<Avatar class="h-10 w-10 ring-2 ring-background shadow-md flex-shrink-0">
<AvatarFallback class="bg-primary text-sm font-bold text-white">
{{ user.username.charAt(0).toUpperCase() }}
</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1 space-y-1.5">
<div class="flex items-center gap-1.5">
<div
class="truncate text-sm font-semibold text-foreground"
:title="user.username"
>
{{ user.username }}
</div>
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="h-5 px-1.5 py-0 text-[10px] font-medium flex-shrink-0"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
</Badge>
</div>
<div
class="truncate text-[11px] text-muted-foreground"
:title="user.email || '-'"
>
{{ user.email || '-' }}
</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<Badge
:variant="user.role === 'admin' ? 'default' : 'secondary'"
class="text-xs"
:variant="user.is_active ? 'success' : 'destructive'"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ user.role === 'admin' ? '管理员' : '普通用户' }}
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
<Badge
v-if="getUserWallet(user.id)"
:variant="walletStatusBadge(getUserWalletStatus(user.id))"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
{{ walletStatusLabel(getUserWalletStatus(user.id)) }}
</Badge>
</div>
</div>
<Badge
:variant="user.is_active ? 'success' : 'destructive'"
class="font-medium text-xs flex-shrink-0"
>
{{ user.is_active ? '活跃' : '禁用' }}
</Badge>
</div>
<!-- 用户信息 -->
<div class="space-y-2 sm:space-y-3 mb-3 sm:mb-4">
<div class="text-xs sm:text-sm">
<span class="text-muted-foreground">邮箱:</span>
<span class="ml-2 text-foreground truncate block sm:inline">{{ user.email || '-' }}</span>
</div>
<div
v-if="userStats[user.id]"
class="grid grid-cols-2 gap-2 p-2 sm:p-3 bg-muted/50 rounded-lg text-xs"
>
<div>
<div class="text-muted-foreground mb-1">
请求次数
</div>
<div class="font-semibold text-sm text-foreground">
{{ formatNumber(userStats[user.id]?.request_count) }}
<div class="rounded-xl border border-border/60 bg-muted/40 p-3.5">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-[11px] text-muted-foreground">
余额:
</p>
<Badge
v-if="isUserUnlimited(user)"
variant="secondary"
class="h-5 px-1.5 py-0 text-[10px] font-medium"
>
无限额度
</Badge>
<p
v-else
class="text-base font-semibold tabular-nums leading-none"
:class="isNegativeWalletValue(getUserWalletTotalBalance(user)) ? 'text-rose-600' : 'text-foreground'"
>
{{ formatCurrencyValue(getUserWalletTotalBalance(user), '-') }}
</p>
</div>
<div class="text-right">
<p class="text-[11px] text-muted-foreground">
已消费:
</p>
<p class="text-sm font-medium tabular-nums text-foreground">
${{ getUserWalletConsumed(user).toFixed(2) }}
</p>
</div>
</div>
</div>
<div>
<div class="text-muted-foreground mb-1">
Tokens
<div class="grid grid-cols-2 gap-2.5 text-xs">
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
请求次数
</div>
<div class="font-semibold text-foreground">
{{ formatNumber(userStats[user.id]?.request_count) }}
</div>
</div>
<div class="font-semibold text-sm text-foreground">
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
<div class="rounded-lg border border-border/50 bg-background/70 p-2.5">
<div class="mb-1 text-muted-foreground">
Tokens
</div>
<div class="font-semibold text-foreground">
{{ formatTokens(userStats[user.id]?.total_tokens ?? 0) }}
</div>
</div>
</div>
</div>
<div class="p-2 sm:p-3 bg-muted/50 rounded-lg text-xs space-y-1">
<div v-if="user.quota_usd != null">
<span class="text-muted-foreground">当前配额:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / ${{ user.quota_usd.toFixed(2) }}
<div class="rounded-lg bg-muted/35 p-2.5 text-[11px] text-muted-foreground">
<div class="flex items-center justify-between gap-2">
<span>创建时间</span>
<span class="font-medium text-foreground">{{ formatDate(user.created_at) }}</span>
</div>
</div>
<div v-else>
<span class="text-muted-foreground">当前配额:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.used_usd || 0).toFixed(2) }}</span> / <span class="text-amber-600">无限制</span>
</div>
<div>
<span class="text-muted-foreground">累计消费:</span>
<span class="ml-2 font-semibold text-sm">${{ (user.total_usd || 0).toFixed(2) }}</span>
</div>
<div>
<span class="text-muted-foreground">创建时间:</span>
<span class="ml-2 text-sm">{{ formatDate(user.created_at) }}</span>
<div class="grid grid-cols-2 gap-2 pt-0.5">
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="editUser(user)"
>
<SquarePen class="mr-1.5 h-3.5 w-3.5" />
编辑
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="openWalletActionDialog(user)"
>
<DollarSign class="mr-1.5 h-3.5 w-3.5" />
资金
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="manageApiKeys(user)"
>
<Key class="mr-1.5 h-3.5 w-3.5" />
API Keys
</Button>
<Button
variant="outline"
size="sm"
class="h-8 text-xs"
@click="toggleUserStatus(user)"
>
<PauseCircle
v-if="user.is_active"
class="mr-1.5 h-3.5 w-3.5"
/>
<PlayCircle
v-else
class="mr-1.5 h-3.5 w-3.5"
/>
{{ user.is_active ? '禁用' : '启用' }}
</Button>
<Button
variant="outline"
size="sm"
class="col-span-2 h-8 border-rose-200 text-xs text-rose-600 hover:bg-rose-50 dark:border-rose-900/60 dark:hover:bg-rose-950/40"
@click="deleteUser(user)"
>
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
删除
</Button>
</div>
</div>
</div>
<!-- 操作按钮 - 响应式布局 -->
<div class="grid grid-cols-2 sm:flex sm:flex-wrap gap-1.5 sm:gap-2">
<Button
variant="outline"
size="sm"
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
@click="editUser(user)"
>
<SquarePen class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">编辑</span>
</Button>
<Button
variant="outline"
size="sm"
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[100px]"
@click="manageApiKeys(user)"
>
<Key class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">API Keys</span>
</Button>
<Button
variant="outline"
size="sm"
class="text-xs sm:text-sm h-8 sm:h-9 sm:flex-1 sm:min-w-[90px]"
:class="user.is_active ? 'text-amber-600' : 'text-emerald-600'"
@click="toggleUserStatus(user)"
>
<PauseCircle
v-if="user.is_active"
class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5"
/>
<PlayCircle
v-else
class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5"
/>
<span class="hidden sm:inline">{{ user.is_active ? '禁用' : '启用' }}</span>
</Button>
<Button
variant="outline"
size="sm"
class="text-xs sm:text-sm h-8 sm:h-9"
@click="resetQuota(user)"
>
<RotateCcw class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">重置</span>
</Button>
<Button
variant="outline"
size="sm"
class="col-span-2 text-xs sm:text-sm h-8 sm:h-9 text-rose-600 sm:col-span-1"
@click="deleteUser(user)"
>
<Trash2 class="h-3 w-3 sm:h-3.5 sm:w-3.5 sm:mr-1.5" />
<span class="hidden sm:inline">删除</span>
</Button>
</div>
</div>
</div>
@@ -659,6 +725,17 @@
</template>
</Dialog>
<WalletOpsDrawer
:open="showWalletActionDialogState"
:wallet="walletActionTarget?.wallet || null"
:owner-name="walletActionTarget?.user.username || ''"
:owner-subtitle="walletActionTarget?.user.email || '未设置邮箱'"
context-label="用户钱包"
accent="emerald"
@close="closeWalletActionDrawer"
@changed="handleWalletDrawerChanged"
/>
<!-- 新 API Key 显示对话框 -->
<Dialog
v-model="showNewApiKeyDialog"
@@ -720,11 +797,13 @@
import { ref, computed, onMounted, watch } from 'vue'
import { useUsersStore } from '@/stores/users'
import type { User, ApiKey } from '@/api/users'
import { adminWalletApi, type AdminWallet } from '@/api/admin-wallets'
import { useToast } from '@/composables/useToast'
import { useConfirm } from '@/composables/useConfirm'
import { useClipboard } from '@/composables/useClipboard'
import { usageApi, type UsageByUser } from '@/api/usage'
import { adminApi } from '@/api/admin'
import { walletStatusBadge, walletStatusLabel } from '@/utils/walletDisplay'
// UI 组件
import {
@@ -757,7 +836,7 @@ import {
Key,
PauseCircle,
PlayCircle,
RotateCcw,
DollarSign,
Trash2,
Copy,
Search,
@@ -768,11 +847,12 @@ import {
// 功能组件
import UserFormDialog, { type UserFormData } from '@/features/users/components/UserFormDialog.vue'
import WalletOpsDrawer from '@/features/wallet/components/WalletOpsDrawer.vue'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
const { success, error } = useToast()
const { confirmDanger, confirmWarning } = useConfirm()
const { confirmDanger } = useConfirm()
const { copyToClipboard } = useClipboard()
const usersStore = useUsersStore()
@@ -794,6 +874,10 @@ const apiKeyInput = ref<HTMLInputElement>()
const userStats = ref<Record<string, UsageByUser>>({})
const loadingStats = ref(false)
let userStatsRequestId = 0
const userWalletMap = ref<Record<string, AdminWallet>>({})
const showWalletActionDialogState = ref(false)
const walletActionTarget = ref<{ user: User; wallet: AdminWallet } | null>(null)
const searchQuery = ref('')
const filterRole = ref('all')
@@ -847,16 +931,14 @@ watch([searchQuery, filterRole, filterStatus], () => {
})
onMounted(async () => {
await Promise.all([
usersStore.fetchUsers(),
loadUserStats()
])
await refreshUsers()
})
async function refreshUsers() {
await Promise.all([
usersStore.fetchUsers(),
loadUserStats()
loadUserStats(),
loadUserWallets()
])
}
@@ -883,6 +965,20 @@ async function loadUserStats() {
}
}
async function loadUserWallets() {
try {
const wallets = await adminWalletApi.listAllWallets()
userWalletMap.value = wallets
.filter((wallet) => wallet.owner_type === 'user' && !!wallet.user_id)
.reduce<Record<string, AdminWallet>>((acc, wallet) => {
acc[wallet.user_id as string] = wallet
return acc
}, {})
} catch (err) {
log.error('加载用户钱包失败:', err)
}
}
function formatTokens(tokens: number): string {
if (tokens >= 1000000) {
return `${(tokens / 1000000).toFixed(1)}M`
@@ -897,6 +993,48 @@ function formatNumber(value?: number | null): string {
return numericValue.toLocaleString()
}
function getUserWallet(userId: string): AdminWallet | null {
return userWalletMap.value[userId] || null
}
function isUserUnlimited(user: User): boolean {
const wallet = getUserWallet(user.id)
if (wallet?.limit_mode === 'unlimited' || wallet?.unlimited === true) {
return true
}
return Boolean(user.unlimited)
}
function getUserWalletTotalBalance(user: User): number | null {
if (isUserUnlimited(user)) {
return null
}
const wallet = getUserWallet(user.id)
if (!wallet) {
return null
}
return wallet.balance
}
function getUserWalletConsumed(user: User): number {
return getUserWallet(user.id)?.total_consumed ?? 0
}
function getUserWalletStatus(userId: string): string | null {
return getUserWallet(userId)?.status ?? null
}
function formatCurrencyValue(value: number | null, nullLabel = '-'): string {
if (value == null) {
return nullLabel
}
return `$${value.toFixed(2)}`
}
function isNegativeWalletValue(value: number | null): boolean {
return typeof value === 'number' && value < 0
}
async function toggleUserStatus(user: User) {
const action = user.is_active ? '禁用' : '启用'
const confirmed = await confirmDanger(
@@ -928,7 +1066,7 @@ function editUser(user: User) {
id: user.id,
username: user.username,
email: user.email,
quota_usd: user.quota_usd,
unlimited: user.unlimited,
role: user.role,
is_active: user.is_active,
allowed_providers: [...(user.allowed_providers || [])],
@@ -943,7 +1081,7 @@ function closeUserFormDialog() {
editingUser.value = null
}
async function handleUserFormSubmit(data: UserFormData & { password?: string }) {
async function handleUserFormSubmit(data: UserFormData & { password?: string; unlimited?: boolean }) {
userFormDialogRef.value?.setSaving(true)
try {
if (data.id) {
@@ -951,7 +1089,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
const updateData: Record<string, unknown> = {
username: data.username,
email: data.email || undefined,
quota_usd: data.quota_usd,
unlimited: data.unlimited,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
@@ -961,6 +1099,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
updateData.password = data.password
}
await usersStore.updateUser(data.id, updateData)
await loadUserWallets()
success('用户信息已更新')
} else {
// 创建用户
@@ -968,8 +1107,8 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
username: data.username,
password: data.password ?? '',
email: data.email || undefined,
quota_usd: data.quota_usd,
unlimited: (data as Record<string, unknown>).unlimited as boolean | undefined,
initial_gift_usd: data.initial_gift_usd,
unlimited: data.unlimited,
role: data.role,
allowed_providers: data.allowed_providers,
allowed_api_formats: data.allowed_api_formats,
@@ -979,6 +1118,7 @@ async function handleUserFormSubmit(data: UserFormData & { password?: string })
if (data.is_active === false && newUser) {
await usersStore.updateUser(newUser.id, { is_active: false })
}
await loadUserWallets()
success('用户创建成功')
}
closeUserFormDialog()
@@ -1055,8 +1195,9 @@ async function deleteApiKey(apiKey: ApiKey) {
}
async function toggleLockApiKey(apiKey: ApiKey) {
if (!selectedUser.value) return
try {
const response = await adminApi.toggleLockApiKey(apiKey.id)
const response = await adminApi.toggleUserApiKeyLock(selectedUser.value.id, apiKey.id)
// 更新本地状态
const index = userApiKeys.value.findIndex(k => k.id === apiKey.id)
if (index !== -1) {
@@ -1070,9 +1211,9 @@ async function toggleLockApiKey(apiKey: ApiKey) {
}
async function copyFullKey(apiKey: ApiKey) {
if (!selectedUser.value) return
try {
// 调用后端 API 获取完整密钥
const response = await adminApi.getFullApiKey(apiKey.id)
const response = await usersStore.getFullApiKey(selectedUser.value.id, apiKey.id)
await copyToClipboard(response.key)
} catch (err: unknown) {
log.error('复制密钥失败:', err)
@@ -1080,19 +1221,30 @@ async function copyFullKey(apiKey: ApiKey) {
}
}
async function resetQuota(user: User) {
const confirmed = await confirmWarning(
`确定要重置用户 ${user.username} 的配额使用量吗?\n\n这将把已使用金额重置为0。`,
'重置配额'
)
function openWalletActionDialog(user: User) {
const wallet = getUserWallet(user.id)
if (!wallet) {
error('该用户的钱包尚未初始化,暂时无法进行资金操作')
return
}
if (!confirmed) return
walletActionTarget.value = {
user,
wallet,
}
showWalletActionDialogState.value = true
}
try {
await usersStore.resetUserQuota(user.id)
success('配额已重置')
} catch (err: unknown) {
error(parseApiError(err, '未知错误'), '重置配额失败')
function closeWalletActionDrawer() {
showWalletActionDialogState.value = false
}
async function handleWalletDrawerChanged() {
await loadUserWallets()
if (!walletActionTarget.value) return
const latestWallet = getUserWallet(walletActionTarget.value.user.id)
if (latestWallet) {
walletActionTarget.value.wallet = latestWallet
}
}
File diff suppressed because it is too large Load Diff
@@ -18,19 +18,19 @@
for="default-quota"
class="block text-sm font-medium"
>
默认用户配额(美元)
默认用户初始赠款(美元)
</Label>
<Input
id="default-quota"
:model-value="defaultUserQuotaUsd"
:model-value="defaultUserInitialGiftUsd"
type="number"
step="0.01"
placeholder="10.00"
class="mt-1"
@update:model-value="$emit('update:defaultUserQuotaUsd', Number($event))"
@update:model-value="$emit('update:defaultUserInitialGiftUsd', Number($event))"
/>
<p class="mt-1 text-xs text-muted-foreground">
新用户注册时的默认配额
新用户注册时的默认初始赠款
</p>
</div>
@@ -128,7 +128,7 @@ import Checkbox from '@/components/ui/checkbox.vue'
import { CardSection } from '@/components/layout'
defineProps<{
defaultUserQuotaUsd: number
defaultUserInitialGiftUsd: number
rateLimitPerMinute: number
enableRegistration: boolean
autoDeleteExpiredKeys: boolean
@@ -139,7 +139,7 @@ defineProps<{
defineEmits<{
save: []
'update:defaultUserQuotaUsd': [value: number]
'update:defaultUserInitialGiftUsd': [value: number]
'update:rateLimitPerMinute': [value: number]
'update:enableRegistration': [value: boolean]
'update:autoDeleteExpiredKeys': [value: boolean]
@@ -14,9 +14,7 @@
? 'border-primary/30 bg-primary/[0.02] shadow-sm shadow-primary/5'
: 'border-border bg-card hover:border-border/80'"
>
<!-- 主行 -->
<div class="flex items-center gap-4 p-4">
<!-- 左侧:开关 -->
<div class="shrink-0">
<Switch
:id="`enable-${task.id}`"
@@ -25,7 +23,6 @@
/>
</div>
<!-- 中间:图标、标题、描述 -->
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center shrink-0 transition-colors duration-300"
@@ -48,7 +45,6 @@
</div>
</div>
<!-- 右侧:时间选择器 + 保存按钮 -->
<div
v-if="task.enabled && task.hasTimeConfig"
class="flex items-center gap-2 shrink-0"
@@ -118,123 +114,6 @@
</template>
</div>
</div>
<!-- 额外配置区域(仅用户配额重置任务有) -->
<div
v-if="task.id === 'user-quota-reset' && task.enabled"
class="px-4 pb-4 pt-0"
>
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">重置周期</span>
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">每</span>
<Input
:model-value="quotaResetIntervalDays"
type="number"
min="1"
step="1"
class="w-14 h-7 text-xs text-center px-2"
@update:model-value="$emit('update:quotaResetIntervalDays', Number($event))"
/>
<span class="text-muted-foreground">天</span>
</div>
</div>
</div>
<p class="text-[11px] text-muted-foreground mt-2 ml-1">
滚动计算:距离上次成功执行满 N 天后再次执行
</p>
</div>
<!-- 独立密钥额度重置额外配置 -->
<div
v-if="task.id === 'standalone-key-quota-reset' && task.enabled"
class="px-4 pb-4 pt-0 space-y-3"
>
<!-- 重置周期 -->
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">重置周期</span>
<div class="flex items-center gap-1.5">
<span class="text-muted-foreground">每</span>
<Input
:model-value="standaloneKeyResetIntervalDays"
type="number"
min="1"
step="1"
class="w-14 h-7 text-xs text-center px-2"
@update:model-value="$emit('update:standaloneKeyResetIntervalDays', Number($event))"
/>
<span class="text-muted-foreground">天</span>
</div>
</div>
</div>
<!-- 重置范围 -->
<div class="p-3 rounded-lg bg-muted/30 border border-border/50 space-y-3">
<div class="flex items-center gap-2 text-sm">
<span class="text-muted-foreground">重置范围</span>
<Select
:model-value="standaloneKeyResetMode"
@update:model-value="$emit('update:standaloneKeyResetMode', $event)"
>
<SelectTrigger class="w-32 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
全部独立密钥
</SelectItem>
<SelectItem value="selected">
指定密钥
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 密钥多选列表 -->
<div
v-if="standaloneKeyResetMode === 'selected'"
class="space-y-2"
>
<div class="text-xs text-muted-foreground">
选择需要重置的密钥:
</div>
<div
v-if="standaloneKeys.length === 0"
class="text-xs text-muted-foreground/60 py-2"
>
暂无独立密钥
</div>
<div
v-else
class="max-h-48 overflow-y-auto space-y-1"
>
<label
v-for="key in standaloneKeys"
:key="key.id"
class="flex items-center gap-2 p-2 rounded hover:bg-muted/50 cursor-pointer text-xs"
>
<Checkbox
:model-value="standaloneKeyResetKeyIds.includes(key.id)"
@update:model-value="$emit('toggleStandaloneKeyResetKeyId', key.id)"
/>
<span class="truncate">{{ key.name || key.key_display || 'sk-****' }}</span>
<span
v-if="key.current_balance_usd != null"
class="text-muted-foreground ml-auto shrink-0"
>
${{ key.current_balance_usd.toFixed(2) }}
</span>
</label>
</div>
</div>
</div>
<p class="text-[11px] text-muted-foreground ml-1">
滚动计算:距离上次成功执行满 N 天后再次执行
</p>
</div>
</div>
</template>
</div>
@@ -244,9 +123,7 @@
<script setup lang="ts">
import { Clock, Check, Loader2, X } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue'
import Switch from '@/components/ui/switch.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue'
@@ -272,26 +149,7 @@ interface ScheduledTask {
onCancel: () => void
}
interface StandaloneKeyOption {
id: string
name?: string
key_display?: string
current_balance_usd?: number | null
}
defineProps<{
scheduledTasks: ScheduledTask[]
quotaResetIntervalDays: number
standaloneKeyResetIntervalDays: number
standaloneKeyResetMode: string
standaloneKeyResetKeyIds: string[]
standaloneKeys: StandaloneKeyOption[]
}>()
defineEmits<{
'update:quotaResetIntervalDays': [value: number]
'update:standaloneKeyResetIntervalDays': [value: number]
'update:standaloneKeyResetMode': [value: string]
'toggleStandaloneKeyResetKeyId': [keyId: string]
}>()
</script>
@@ -1,5 +1,5 @@
import { ref, computed, type Ref } from 'vue'
import { CalendarCheck, RotateCcw, RefreshCw, KeyRound } from 'lucide-vue-next'
import { CalendarCheck, RefreshCw } from 'lucide-vue-next'
import { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger'
@@ -9,25 +9,13 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
const { success, error } = useToast()
const checkinConfigLoading = ref(false)
const quotaResetConfigLoading = ref(false)
const standaloneKeyResetConfigLoading = ref(false)
// 签到时间的原始值(用于回滚)
const previousCheckinTime = ref('')
// 用户配额重置时间的原始值
const previousUserQuotaResetTime = ref('')
const previousUserQuotaResetIntervalDays = ref(1)
// 独立密钥额度重置的原始值
const previousStandaloneKeyResetTime = ref('')
const previousStandaloneKeyResetIntervalDays = ref(1)
// 初始化原始值(在配置加载完成后调用)
function initPreviousValues() {
previousCheckinTime.value = systemConfig.value.provider_checkin_time
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
previousStandaloneKeyResetTime.value = systemConfig.value.standalone_key_quota_reset_time
previousStandaloneKeyResetIntervalDays.value = systemConfig.value.standalone_key_quota_reset_interval_days
}
// 签到时间
@@ -51,70 +39,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
return systemConfig.value.provider_checkin_time !== previousCheckinTime.value
})
// 用户配额重置时间
const userQuotaResetHour = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '05'
return time.split(':')[0]
})
const userQuotaResetMinute = computed(() => {
const time = systemConfig.value.user_quota_reset_time
if (!time || !time.includes(':')) return '00'
return time.split(':')[1]
})
function updateUserQuotaResetTime(hour: string, minute: string) {
systemConfig.value.user_quota_reset_time = `${hour}:${minute}`
}
const hasUserQuotaResetTimeChanged = computed(() => {
return systemConfig.value.user_quota_reset_time !== previousUserQuotaResetTime.value
})
const hasUserQuotaResetIntervalChanged = computed(() => {
return (
systemConfig.value.user_quota_reset_interval_days !==
previousUserQuotaResetIntervalDays.value
)
})
const hasQuotaResetConfigChanged = computed(() => {
return hasUserQuotaResetTimeChanged.value || hasUserQuotaResetIntervalChanged.value
})
// 独立密钥额度重置时间
const standaloneKeyResetHour = computed(() => {
const time = systemConfig.value.standalone_key_quota_reset_time
if (!time || !time.includes(':')) return '05'
return time.split(':')[0]
})
const standaloneKeyResetMinute = computed(() => {
const time = systemConfig.value.standalone_key_quota_reset_time
if (!time || !time.includes(':')) return '00'
return time.split(':')[1]
})
function updateStandaloneKeyResetTime(hour: string, minute: string) {
systemConfig.value.standalone_key_quota_reset_time = `${hour}:${minute}`
}
const hasStandaloneKeyResetTimeChanged = computed(() => {
return systemConfig.value.standalone_key_quota_reset_time !== previousStandaloneKeyResetTime.value
})
const hasStandaloneKeyResetIntervalChanged = computed(() => {
return (
systemConfig.value.standalone_key_quota_reset_interval_days !==
previousStandaloneKeyResetIntervalDays.value
)
})
const hasStandaloneKeyResetConfigChanged = computed(() => {
return hasStandaloneKeyResetTimeChanged.value || hasStandaloneKeyResetIntervalChanged.value
})
// Toggle handlers
async function handleProviderCheckinToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_provider_checkin
@@ -133,23 +57,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
}
}
async function handleUserQuotaResetToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_user_quota_reset
systemConfig.value.enable_user_quota_reset = enabled
try {
await adminApi.updateSystemConfig(
'enable_user_quota_reset',
enabled,
'是否启用用户配额自动重置任务'
)
success(enabled ? '已启用用户配额自动重置' : '已禁用用户配额自动重置')
} catch (err) {
error('保存配置失败')
log.error('保存用户配额自动重置配置失败:', err)
systemConfig.value.enable_user_quota_reset = previousValue
}
}
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_oauth_token_refresh
systemConfig.value.enable_oauth_token_refresh = enabled
@@ -167,38 +74,11 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
}
}
async function handleStandaloneKeyResetToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_standalone_key_quota_reset
systemConfig.value.enable_standalone_key_quota_reset = enabled
try {
await adminApi.updateSystemConfig(
'enable_standalone_key_quota_reset',
enabled,
'是否启用独立密钥额度自动重置任务'
)
success(enabled ? '已启用独立密钥额度自动重置' : '已禁用独立密钥额度自动重置')
} catch (err) {
error('保存配置失败')
log.error('保存独立密钥额度自动重置配置失败:', err)
systemConfig.value.enable_standalone_key_quota_reset = previousValue
}
}
// Cancel handlers
function handleCheckinTimeCancel() {
systemConfig.value.provider_checkin_time = previousCheckinTime.value
}
function handleQuotaResetConfigCancel() {
systemConfig.value.user_quota_reset_time = previousUserQuotaResetTime.value
systemConfig.value.user_quota_reset_interval_days = previousUserQuotaResetIntervalDays.value
}
function handleStandaloneKeyResetConfigCancel() {
systemConfig.value.standalone_key_quota_reset_time = previousStandaloneKeyResetTime.value
systemConfig.value.standalone_key_quota_reset_interval_days = previousStandaloneKeyResetIntervalDays.value
}
// Save handlers
async function handleCheckinTimeSave() {
const newTime = systemConfig.value.provider_checkin_time
@@ -224,171 +104,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
}
}
async function handleQuotaResetConfigSave() {
const configItems: Array<{
key: string
value: unknown
description: string
onSuccess: () => void
}> = []
if (hasUserQuotaResetTimeChanged.value) {
const newTime = systemConfig.value.user_quota_reset_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
configItems.push({
key: 'user_quota_reset_time',
value: newTime,
description: '用户配额自动重置执行时间(HH:MM 格式)',
onSuccess: () => {
previousUserQuotaResetTime.value = newTime
},
})
}
if (hasUserQuotaResetIntervalChanged.value) {
let intervalDays = Number(systemConfig.value.user_quota_reset_interval_days)
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
intervalDays = Math.trunc(intervalDays)
systemConfig.value.user_quota_reset_interval_days = intervalDays
configItems.push({
key: 'user_quota_reset_interval_days',
value: intervalDays,
description: '用户配额重置周期(天数),滚动计算',
onSuccess: () => {
previousUserQuotaResetIntervalDays.value = intervalDays
},
})
}
if (configItems.length === 0) return
quotaResetConfigLoading.value = true
const failedKeys: string[] = []
try {
for (const item of configItems) {
try {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
item.onSuccess()
} catch (err) {
failedKeys.push(item.key)
log.error(`保存配额重置配置失败: ${item.key}`, err)
}
}
if (failedKeys.length > 0) {
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
return
}
success('配额重置配置已保存')
} finally {
quotaResetConfigLoading.value = false
}
}
async function handleStandaloneKeyResetConfigSave() {
const configItems: Array<{
key: string
value: unknown
description: string
onSuccess: () => void
}> = []
if (hasStandaloneKeyResetTimeChanged.value) {
const newTime = systemConfig.value.standalone_key_quota_reset_time
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
error('请输入有效的时间格式 (HH:MM)')
return
}
configItems.push({
key: 'standalone_key_quota_reset_time',
value: newTime,
description: '独立密钥额度自动重置执行时间(HH:MM 格式)',
onSuccess: () => {
previousStandaloneKeyResetTime.value = newTime
},
})
}
if (hasStandaloneKeyResetIntervalChanged.value) {
let intervalDays = Number(systemConfig.value.standalone_key_quota_reset_interval_days)
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
intervalDays = Math.trunc(intervalDays)
systemConfig.value.standalone_key_quota_reset_interval_days = intervalDays
configItems.push({
key: 'standalone_key_quota_reset_interval_days',
value: intervalDays,
description: '独立密钥额度重置周期(天数),滚动计算',
onSuccess: () => {
previousStandaloneKeyResetIntervalDays.value = intervalDays
},
})
}
if (configItems.length === 0) return
standaloneKeyResetConfigLoading.value = true
const failedKeys: string[] = []
try {
for (const item of configItems) {
try {
await adminApi.updateSystemConfig(item.key, item.value, item.description)
item.onSuccess()
} catch (err) {
failedKeys.push(item.key)
log.error(`保存独立密钥额度重置配置失败: ${item.key}`, err)
}
}
if (failedKeys.length > 0) {
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
return
}
success('独立密钥额度重置配置已保存')
} finally {
standaloneKeyResetConfigLoading.value = false
}
}
// 保存独立密钥额度重置模式和选中密钥
async function saveStandaloneKeyResetMode(mode: string) {
try {
await adminApi.updateSystemConfig(
'standalone_key_quota_reset_mode',
mode,
'独立密钥额度重置模式'
)
success('重置模式已保存')
} catch (err) {
error('保存重置模式失败')
log.error('保存独立密钥额度重置模式失败:', err)
}
}
async function saveStandaloneKeyResetKeyIds(keyIds: string[]) {
try {
await adminApi.updateSystemConfig(
'standalone_key_quota_reset_key_ids',
keyIds,
'独立密钥额度重置指定的密钥 ID 列表'
)
success('已保存选中密钥')
} catch (err) {
error('保存选中密钥失败')
log.error('保存独立密钥额度重置密钥列表失败:', err)
}
}
// 定时任务配置列表
const scheduledTasks = computed(() => [
{
@@ -407,22 +122,6 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
onSave: handleCheckinTimeSave,
onCancel: handleCheckinTimeCancel,
},
{
id: 'user-quota-reset',
icon: RotateCcw,
title: '用户配额自动重置',
description: '定时将用户已使用配额重置为零',
enabled: systemConfig.value.enable_user_quota_reset,
hasTimeConfig: true,
hour: userQuotaResetHour.value,
minute: userQuotaResetMinute.value,
updateTime: updateUserQuotaResetTime,
hasChanges: hasQuotaResetConfigChanged.value,
loading: quotaResetConfigLoading.value,
onToggle: handleUserQuotaResetToggle,
onSave: handleQuotaResetConfigSave,
onCancel: handleQuotaResetConfigCancel,
},
{
id: 'oauth-token-refresh',
icon: RefreshCw,
@@ -439,31 +138,11 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
onSave: () => {},
onCancel: () => {},
},
{
id: 'standalone-key-quota-reset',
icon: KeyRound,
title: '独立密钥额度自动重置',
description: '定时将独立密钥已使用额度重置为零',
enabled: systemConfig.value.enable_standalone_key_quota_reset,
hasTimeConfig: true,
hour: standaloneKeyResetHour.value,
minute: standaloneKeyResetMinute.value,
updateTime: updateStandaloneKeyResetTime,
hasChanges: hasStandaloneKeyResetConfigChanged.value,
loading: standaloneKeyResetConfigLoading.value,
onToggle: handleStandaloneKeyResetToggle,
onSave: handleStandaloneKeyResetConfigSave,
onCancel: handleStandaloneKeyResetConfigCancel,
},
])
return {
checkinConfigLoading,
quotaResetConfigLoading,
standaloneKeyResetConfigLoading,
scheduledTasks,
initPreviousValues,
saveStandaloneKeyResetMode,
saveStandaloneKeyResetKeyIds,
}
}
@@ -11,7 +11,7 @@ export interface SystemConfig {
// 网络代理
system_proxy_node_id: string | null
// 基础配置
default_user_quota_usd: number
default_user_initial_gift_usd: number
rate_limit_per_minute: number
enable_registration: boolean
// 独立余额 Key 过期管理
@@ -34,16 +34,7 @@ export interface SystemConfig {
// 定时任务
enable_provider_checkin: boolean
provider_checkin_time: string
enable_user_quota_reset: boolean
user_quota_reset_time: string
user_quota_reset_interval_days: number
enable_oauth_token_refresh: boolean
// 独立密钥额度重置
enable_standalone_key_quota_reset: boolean
standalone_key_quota_reset_time: string
standalone_key_quota_reset_interval_days: number
standalone_key_quota_reset_mode: string
standalone_key_quota_reset_key_ids: string[]
}
const CONFIG_KEYS = [
@@ -53,7 +44,7 @@ const CONFIG_KEYS = [
// 网络代理
'system_proxy_node_id',
// 基础配置
'default_user_quota_usd',
'default_user_initial_gift_usd',
'rate_limit_per_minute',
'enable_registration',
// 独立余额 Key 过期管理
@@ -76,16 +67,7 @@ const CONFIG_KEYS = [
// 定时任务
'enable_provider_checkin',
'provider_checkin_time',
'enable_user_quota_reset',
'user_quota_reset_time',
'user_quota_reset_interval_days',
'enable_oauth_token_refresh',
// 独立密钥额度重置
'enable_standalone_key_quota_reset',
'standalone_key_quota_reset_time',
'standalone_key_quota_reset_interval_days',
'standalone_key_quota_reset_mode',
'standalone_key_quota_reset_key_ids',
]
function createDefaultConfig(): SystemConfig {
@@ -96,7 +78,7 @@ function createDefaultConfig(): SystemConfig {
// 网络代理
system_proxy_node_id: null,
// 基础配置
default_user_quota_usd: 10.0,
default_user_initial_gift_usd: 10.0,
rate_limit_per_minute: 0,
enable_registration: false,
// 独立余额 Key 过期管理
@@ -119,16 +101,7 @@ function createDefaultConfig(): SystemConfig {
// 定时任务
enable_provider_checkin: true,
provider_checkin_time: '01:05',
enable_user_quota_reset: false,
user_quota_reset_time: '05:00',
user_quota_reset_interval_days: 1,
enable_oauth_token_refresh: true,
// 独立密钥额度重置
enable_standalone_key_quota_reset: false,
standalone_key_quota_reset_time: '05:00',
standalone_key_quota_reset_interval_days: 1,
standalone_key_quota_reset_mode: 'all',
standalone_key_quota_reset_key_ids: [],
}
}
@@ -164,7 +137,7 @@ export function useSystemConfig() {
const hasBasicConfigChanges = computed(() => {
if (!originalConfig.value) return false
return (
systemConfig.value.default_user_quota_usd !== originalConfig.value.default_user_quota_usd ||
systemConfig.value.default_user_initial_gift_usd !== originalConfig.value.default_user_initial_gift_usd ||
systemConfig.value.rate_limit_per_minute !== originalConfig.value.rate_limit_per_minute ||
systemConfig.value.enable_registration !== originalConfig.value.enable_registration ||
systemConfig.value.auto_delete_expired_keys !== originalConfig.value.auto_delete_expired_keys ||
@@ -234,7 +207,7 @@ export function useSystemConfig() {
; (systemConfig.value as Record<string, unknown>)[key] = response.value
}
} catch {
// 配置不存在时使用默认值,无需处理
// 单个配置项加载失败时忽略,使用默认值
}
}
originalConfig.value = JSON.parse(JSON.stringify(systemConfig.value))
@@ -309,9 +282,9 @@ export function useSystemConfig() {
try {
const configItems = [
{
key: 'default_user_quota_usd',
value: systemConfig.value.default_user_quota_usd,
description: '默认用户配额(美元)',
key: 'default_user_initial_gift_usd',
value: systemConfig.value.default_user_initial_gift_usd,
description: '默认用户初始赠款(美元)',
},
{
key: 'rate_limit_per_minute',
@@ -341,7 +314,7 @@ export function useSystemConfig() {
)
)
if (originalConfig.value) {
originalConfig.value.default_user_quota_usd = systemConfig.value.default_user_quota_usd
originalConfig.value.default_user_initial_gift_usd = systemConfig.value.default_user_initial_gift_usd
originalConfig.value.rate_limit_per_minute = systemConfig.value.rate_limit_per_minute
originalConfig.value.enable_registration = systemConfig.value.enable_registration
originalConfig.value.auto_delete_expired_keys =
+40 -25
View File
@@ -339,7 +339,7 @@
使用提醒
</Label>
<p class="text-xs text-muted-foreground mt-1">
当接近配额限制时提醒
当余额接近不足时提醒
</p>
</div>
<Switch
@@ -407,30 +407,45 @@
</div>
</Card>
<!-- 使用配额 -->
<!-- 钱包状态 -->
<Card class="p-6">
<h3 class="text-lg font-medium text-foreground mb-4">
使用配额
钱包状态
</h3>
<div class="space-y-4">
<div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">总余额</span>
<span class="text-foreground">
<template v-if="isUnlimitedBilling()">
无限制
</template>
<template v-else>
{{ formatCurrency(profile?.billing?.balance || 0) }}
</template>
</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">充值余额</span>
<span class="text-foreground">{{ formatCurrency(profile?.billing?.recharge_balance || 0) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">赠款余额</span>
<span class="text-foreground">{{ formatCurrency(profile?.billing?.gift_balance || 0) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-muted-foreground">累计消费</span>
<span class="text-foreground">{{ formatCurrency(profile?.billing?.total_consumed || 0) }}</span>
</div>
<div v-if="!isUnlimitedBilling()">
<div class="flex justify-between text-sm mb-1">
<span class="text-muted-foreground">配额使用(美元)</span>
<span class="text-foreground">
<template v-if="isUnlimitedQuota()">
{{ formatCurrency(profile?.used_usd || 0) }} /
<span class="text-warning">无限制</span>
</template>
<template v-else>
{{ formatCurrency(profile?.used_usd || 0) }} /
{{ formatCurrency(profile?.quota_usd || 0) }}
</template>
</span>
<span class="text-muted-foreground">累计消费占比</span>
<span class="text-foreground">{{ getBillingUsagePercentage().toFixed(1) }}%</span>
</div>
<div class="w-full bg-muted rounded-full h-2.5">
<div
class="bg-success h-2.5 rounded-full"
:style="`width: ${getUsagePercentage()}%`"
:style="`width: ${getBillingUsagePercentage()}%`"
/>
</div>
</div>
@@ -804,17 +819,17 @@ async function updatePreferences() {
}
}
function getUsagePercentage(): number {
if (!profile.value) return 0
const quota = profile.value.quota_usd
const used = profile.value.used_usd
if (quota == null || quota === 0) return 0
return Math.min(100, (used / quota) * 100)
function getBillingUsagePercentage(): number {
const billing = profile.value?.billing
if (!billing) return 0
const consumed = billing.total_consumed || 0
const denominator = consumed + (billing.balance || 0)
if (denominator <= 0) return 0
return Math.min(100, (consumed / denominator) * 100)
}
function isUnlimitedQuota(): boolean {
return profile.value?.quota_usd == null
function isUnlimitedBilling(): boolean {
return profile.value?.billing?.unlimited === true
}
function formatDate(dateString?: string): string {
+751
View File
@@ -0,0 +1,751 @@
<template>
<div class="space-y-6 pb-8">
<div
v-if="loadingInitial"
class="py-16"
>
<LoadingState message="正在加载钱包数据..." />
</div>
<template v-else>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
可用余额
</div>
<div class="text-3xl font-bold tabular-nums">
{{ formatCurrency(walletBalance?.balance) }}
</div>
<div class="text-xs text-muted-foreground">
充值余额: {{ formatCurrency(walletBalance?.wallet?.recharge_balance) }} · 赠款余额: {{ formatCurrency(walletBalance?.wallet?.gift_balance) }}
</div>
</Card>
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
累计充值 / 消费
</div>
<div class="text-lg font-semibold tabular-nums">
{{ formatCurrency(walletBalance?.wallet?.total_recharged) }}
<span class="text-muted-foreground font-normal mx-1">/</span>
{{ formatCurrency(walletBalance?.wallet?.total_consumed) }}
</div>
<div class="text-xs text-muted-foreground">
累计退款: {{ formatCurrency(walletBalance?.wallet?.total_refunded) }} · 可退款余额: {{ formatCurrency(walletBalance?.wallet?.refundable_balance) }}
</div>
</Card>
<Card class="p-5 space-y-2">
<div class="text-xs uppercase tracking-wider text-muted-foreground">
钱包状态
</div>
<div class="flex items-center gap-2">
<Badge :variant="walletStatusBadge(walletBalance?.wallet?.status)">
{{ walletStatusLabel(walletBalance?.wallet?.status) }}
</Badge>
</div>
<div
v-if="walletBalance?.unlimited"
class="text-xs text-amber-600 dark:text-amber-400"
>
当前账号处于无限制模式,余额仅用于账务统计。
</div>
<div class="text-xs text-muted-foreground">
待处理退款: {{ walletBalance?.pending_refund_count || 0 }}
</div>
</Card>
</div>
<!-- TODO(wallet): 充值/退款用户主动操作入口暂未启用,待支付链路联调完成后再开放 -->
<div
v-if="ENABLE_WALLET_ACTION_FORMS"
class="grid grid-cols-1 xl:grid-cols-2 gap-4"
>
<Card class="p-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
发起充值
</h3>
<RefreshButton
:loading="loadingOrders"
@click="loadOrders"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label>充值金额 (USD)</Label>
<Input
v-model.number="rechargeForm.amount_usd"
type="number"
min="0.01"
step="0.01"
placeholder="10"
/>
</div>
<div class="space-y-1.5">
<Label>支付方式</Label>
<Select v-model="rechargeForm.payment_method">
<SelectTrigger>
<SelectValue placeholder="选择支付方式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alipay">
支付宝
</SelectItem>
<SelectItem value="wechat">
微信支付
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button
class="w-full"
:disabled="submittingRecharge"
@click="submitRecharge"
>
{{ submittingRecharge ? '创建订单中...' : '创建充值订单' }}
</Button>
<div
v-if="latestRecharge"
class="rounded-xl border border-border/60 bg-muted/30 p-3 space-y-1.5"
>
<div class="text-xs text-muted-foreground">
最新订单: <span class="font-medium text-foreground">{{ latestRecharge.order.order_no }}</span>
</div>
<div class="text-xs text-muted-foreground">
状态:
<Badge
:variant="paymentStatusBadge(latestRecharge.order.status)"
class="ml-1"
>
{{ paymentStatusLabel(latestRecharge.order.status) }}
</Badge>
</div>
<a
v-if="latestRecharge.payment_instructions?.payment_url"
class="inline-flex text-xs text-primary hover:underline"
:href="String(latestRecharge.payment_instructions.payment_url)"
target="_blank"
rel="noopener noreferrer"
>
打开支付链接
</a>
<div
v-if="latestRecharge.payment_instructions?.qr_code"
class="text-xs text-muted-foreground break-all"
>
二维码标识: {{ latestRecharge.payment_instructions.qr_code }}
</div>
</div>
</Card>
<Card class="p-5 space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-base font-semibold">
申请退款
</h3>
<RefreshButton
:loading="loadingRefunds"
@click="loadRefunds"
/>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-1.5">
<Label>退款金额 (USD)</Label>
<Input
v-model.number="refundForm.amount_usd"
type="number"
min="0.01"
step="0.01"
placeholder="5"
/>
</div>
<div class="space-y-1.5">
<Label>退款模式</Label>
<Select v-model="refundForm.refund_mode">
<SelectTrigger>
<SelectValue placeholder="选择退款模式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="original_channel">
原路退回
</SelectItem>
<SelectItem value="offline_payout">
线下打款
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div class="space-y-1.5">
<Label>关联充值订单(可选)</Label>
<Select v-model="refundForm.payment_order_id">
<SelectTrigger>
<SelectValue placeholder="不指定订单,直接从钱包余额退款" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
不指定
</SelectItem>
<SelectItem
v-for="order in refundableOrders"
:key="order.id"
:value="order.id"
>
{{ order.order_no }} (可退 {{ formatCurrency(order.refundable_amount_usd) }})
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label>退款原因(可选)</Label>
<Textarea
v-model="refundForm.reason"
placeholder="填写退款原因,便于审核"
rows="3"
/>
</div>
<div class="rounded-xl border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
仅充值余额可退款,赠款余额不可退款。
</div>
<Button
class="w-full"
variant="outline"
:disabled="submittingRefund"
@click="submitRefund"
>
{{ submittingRefund ? '提交中...' : '提交退款申请' }}
</Button>
</Card>
</div>
<Card class="overflow-hidden">
<div class="px-5 pt-5 pb-2">
<Tabs v-model="activeTab">
<TabsList class="tabs-button-list grid grid-cols-3 w-full max-w-xl">
<TabsTrigger value="transactions">
资金流水
</TabsTrigger>
<TabsTrigger value="orders">
充值订单
</TabsTrigger>
<TabsTrigger value="refunds">
退款记录
</TabsTrigger>
</TabsList>
<TabsContent
value="transactions"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ txTotal }} 条
</div>
<RefreshButton
:loading="loadingTransactions"
@click="loadTransactions"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>时间</TableHead>
<TableHead>类型</TableHead>
<TableHead>变动</TableHead>
<TableHead>余额变化</TableHead>
<TableHead>说明</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="tx in transactions"
:key="tx.id"
>
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(tx.created_at) }}
</TableCell>
<TableCell>
<div class="space-y-1">
<Badge
variant="outline"
class="font-mono"
>
{{ walletTransactionCategoryLabel(tx.category) }}
</Badge>
<div class="text-[11px] text-muted-foreground">
{{ walletTransactionReasonLabel(tx.reason_code) }}
</div>
</div>
</TableCell>
<TableCell
:class="tx.amount >= 0 ? 'text-emerald-600 dark:text-emerald-400' : 'text-rose-600 dark:text-rose-400'"
>
{{ tx.amount >= 0 ? '+' : '' }}{{ tx.amount.toFixed(4) }}
</TableCell>
<TableCell class="text-xs tabular-nums">
{{ tx.balance_before.toFixed(4) }} → {{ tx.balance_after.toFixed(4) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ tx.description || '-' }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingTransactions && transactions.length === 0">
<TableCell
colspan="5"
class="py-10"
>
<EmptyState
title="暂无资金流水"
description="充值或退款后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="txPage"
:total="txTotal"
:page-size="txPageSize"
@update:current="handleTxPageChange"
@update:page-size="handleTxPageSizeChange"
/>
</TabsContent>
<TabsContent
value="orders"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ orderTotal }} 条
</div>
<RefreshButton
:loading="loadingOrders"
@click="loadOrders"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>订单号</TableHead>
<TableHead>金额</TableHead>
<TableHead>支付方式</TableHead>
<TableHead>状态</TableHead>
<TableHead>可退金额</TableHead>
<TableHead>创建时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="order in rechargeOrders"
:key="order.id"
>
<TableCell class="font-mono text-xs">
{{ order.order_no }}
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(order.amount_usd) }}
</TableCell>
<TableCell>{{ paymentMethodLabel(order.payment_method) }}</TableCell>
<TableCell>
<Badge :variant="paymentStatusBadge(order.status)">
{{ paymentStatusLabel(order.status) }}
</Badge>
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(order.refundable_amount_usd) }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(order.created_at) }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingOrders && rechargeOrders.length === 0">
<TableCell
colspan="6"
class="py-10"
>
<EmptyState
title="暂无充值订单"
description="发起充值后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="orderPage"
:total="orderTotal"
:page-size="orderPageSize"
@update:current="handleOrderPageChange"
@update:page-size="handleOrderPageSizeChange"
/>
</TabsContent>
<TabsContent
value="refunds"
class="mt-4 space-y-4"
>
<div class="px-5 flex items-center justify-between">
<div class="text-sm text-muted-foreground">
共 {{ refundTotal }} 条
</div>
<RefreshButton
:loading="loadingRefunds"
@click="loadRefunds"
/>
</div>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>退款单号</TableHead>
<TableHead>金额</TableHead>
<TableHead>模式</TableHead>
<TableHead>状态</TableHead>
<TableHead>原因</TableHead>
<TableHead>申请时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="refund in refunds"
:key="refund.id"
>
<TableCell class="font-mono text-xs">
{{ refund.refund_no }}
</TableCell>
<TableCell class="tabular-nums">
{{ formatCurrency(refund.amount_usd) }}
</TableCell>
<TableCell>{{ refundModeLabel(refund.refund_mode) }}</TableCell>
<TableCell>
<Badge :variant="refundStatusBadge(refund.status)">
{{ refundStatusLabel(refund.status) }}
</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground max-w-[220px] truncate">
{{ refund.reason || refund.failure_reason || '-' }}
</TableCell>
<TableCell class="text-xs text-muted-foreground">
{{ formatDateTime(refund.created_at) }}
</TableCell>
</TableRow>
<TableRow v-if="!loadingRefunds && refunds.length === 0">
<TableCell
colspan="6"
class="py-10"
>
<EmptyState
title="暂无退款记录"
description="提交退款申请后会在这里显示"
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Pagination
:current="refundPage"
:total="refundTotal"
:page-size="refundPageSize"
@update:current="handleRefundPageChange"
@update:page-size="handleRefundPageSizeChange"
/>
</TabsContent>
</Tabs>
</div>
</Card>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import {
Badge,
Button,
Card,
Input,
Label,
Pagination,
RefreshButton,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Textarea,
} from '@/components/ui'
import { EmptyState, LoadingState } from '@/components/common'
import {
walletApi,
type PaymentOrder,
type RefundRequest,
type WalletBalanceResponse,
type WalletTransaction,
} from '@/api/wallet'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
import { log } from '@/utils/logger'
import {
formatWalletCurrency as formatCurrency,
paymentMethodLabel,
paymentStatusBadge,
paymentStatusLabel,
refundModeLabel,
refundStatusBadge,
refundStatusLabel,
walletStatusBadge,
walletStatusLabel,
walletTransactionCategoryLabel,
walletTransactionReasonLabel,
} from '@/utils/walletDisplay'
const { success, error: showError } = useToast()
// TODO(wallet): 充值和退款前台入口尚未正式启用;联调完成后改为 true 即可恢复显示。
const ENABLE_WALLET_ACTION_FORMS = false
const loadingInitial = ref(true)
const loadingTransactions = ref(false)
const loadingOrders = ref(false)
const loadingRefunds = ref(false)
const submittingRecharge = ref(false)
const submittingRefund = ref(false)
const walletBalance = ref<WalletBalanceResponse | null>(null)
const latestRecharge = ref<{ order: PaymentOrder; payment_instructions: Record<string, unknown> } | null>(null)
const transactions = ref<WalletTransaction[]>([])
const txTotal = ref(0)
const txPage = ref(1)
const txPageSize = ref(20)
const rechargeOrders = ref<PaymentOrder[]>([])
const orderTotal = ref(0)
const orderPage = ref(1)
const orderPageSize = ref(20)
const refunds = ref<RefundRequest[]>([])
const refundTotal = ref(0)
const refundPage = ref(1)
const refundPageSize = ref(20)
const activeTab = ref('transactions')
const rechargeForm = reactive({
amount_usd: 10,
payment_method: 'alipay',
})
const refundForm = reactive({
amount_usd: 0,
payment_order_id: '__none__',
refund_mode: 'offline_payout',
reason: '',
})
const refundableOrders = computed(() =>
rechargeOrders.value.filter(o => (o.refundable_amount_usd || 0) > 0)
)
onMounted(async () => {
try {
await Promise.all([
loadBalance(),
loadTransactions(),
loadOrders(),
loadRefunds(),
])
} finally {
loadingInitial.value = false
}
})
async function loadBalance() {
walletBalance.value = await walletApi.getBalance()
}
async function loadTransactions() {
loadingTransactions.value = true
try {
const offset = (txPage.value - 1) * txPageSize.value
const resp = await walletApi.getTransactions({ limit: txPageSize.value, offset })
transactions.value = resp.items
txTotal.value = resp.total
} catch (error) {
log.error('加载钱包流水失败:', error)
showError(parseApiError(error, '加载钱包流水失败'))
} finally {
loadingTransactions.value = false
}
}
async function loadOrders() {
loadingOrders.value = true
try {
const offset = (orderPage.value - 1) * orderPageSize.value
const resp = await walletApi.listRechargeOrders({ limit: orderPageSize.value, offset })
rechargeOrders.value = resp.items
orderTotal.value = resp.total
} catch (error) {
log.error('加载充值订单失败:', error)
showError(parseApiError(error, '加载充值订单失败'))
} finally {
loadingOrders.value = false
}
}
async function loadRefunds() {
loadingRefunds.value = true
try {
const offset = (refundPage.value - 1) * refundPageSize.value
const resp = await walletApi.listRefunds({ limit: refundPageSize.value, offset })
refunds.value = resp.items
refundTotal.value = resp.total
} catch (error) {
log.error('加载退款记录失败:', error)
showError(parseApiError(error, '加载退款记录失败'))
} finally {
loadingRefunds.value = false
}
}
async function submitRecharge() {
if (!rechargeForm.amount_usd || rechargeForm.amount_usd <= 0) {
showError('请输入有效的充值金额')
return
}
submittingRecharge.value = true
try {
latestRecharge.value = await walletApi.createRechargeOrder({
amount_usd: rechargeForm.amount_usd,
payment_method: rechargeForm.payment_method,
})
success('充值订单创建成功')
await Promise.all([loadOrders(), loadBalance()])
activeTab.value = 'orders'
} catch (error) {
log.error('创建充值订单失败:', error)
showError(parseApiError(error, '创建充值订单失败'))
} finally {
submittingRecharge.value = false
}
}
async function submitRefund() {
if (!refundForm.amount_usd || refundForm.amount_usd <= 0) {
showError('请输入有效的退款金额')
return
}
const refundableBalance =
walletBalance.value?.wallet?.refundable_balance ?? walletBalance.value?.refundable_balance ?? null
if (refundableBalance !== null && refundForm.amount_usd > refundableBalance) {
showError(`退款金额超过可退款余额(当前可退 ${formatCurrency(refundableBalance)})`)
return
}
submittingRefund.value = true
try {
await walletApi.createRefund({
amount_usd: refundForm.amount_usd,
payment_order_id:
refundForm.payment_order_id && refundForm.payment_order_id !== '__none__'
? refundForm.payment_order_id
: undefined,
refund_mode: refundForm.refund_mode || undefined,
reason: refundForm.reason || undefined,
idempotency_key: `web_refund_${buildRefundIdempotencyKey()}`,
})
success('退款申请已提交')
refundForm.amount_usd = 0
refundForm.payment_order_id = '__none__'
refundForm.reason = ''
await Promise.all([loadRefunds(), loadBalance(), loadOrders(), loadTransactions()])
activeTab.value = 'refunds'
} catch (error) {
log.error('提交退款申请失败:', error)
showError(parseApiError(error, '提交退款申请失败'))
} finally {
submittingRefund.value = false
}
}
function buildRefundIdempotencyKey(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID().replaceAll('-', '')
}
return `${Date.now()}_${Math.random().toString(16).slice(2, 10)}`
}
function handleTxPageChange(page: number) {
txPage.value = page
void loadTransactions()
}
function handleTxPageSizeChange(size: number) {
txPageSize.value = size
txPage.value = 1
void loadTransactions()
}
function handleOrderPageChange(page: number) {
orderPage.value = page
void loadOrders()
}
function handleOrderPageSizeChange(size: number) {
orderPageSize.value = size
orderPage.value = 1
void loadOrders()
}
function handleRefundPageChange(page: number) {
refundPage.value = page
void loadRefunds()
}
function handleRefundPageSizeChange(size: number) {
refundPageSize.value = size
refundPage.value = 1
void loadRefunds()
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
</script>
+4
View File
@@ -9,6 +9,7 @@ from .endpoints import router as endpoints_router
from .models import router as models_router
from .modules import router as modules_router
from .monitoring import router as monitoring_router
from .payments import router as payments_router
from .pool import router as pool_router
from .provider_oauth import router as provider_oauth_router
from .provider_ops import router as provider_ops_router
@@ -21,6 +22,7 @@ from .system import router as system_router
from .usage import router as usage_router
from .users import router as users_router
from .video_tasks import router as video_tasks_router
from .wallets import router as wallets_router
router = APIRouter()
router.include_router(system_router)
@@ -30,6 +32,7 @@ router.include_router(api_keys_router)
router.include_router(billing_router)
router.include_router(usage_router)
router.include_router(monitoring_router)
router.include_router(payments_router)
router.include_router(endpoints_router)
router.include_router(provider_strategy_router)
router.include_router(provider_oauth_router)
@@ -42,6 +45,7 @@ router.include_router(modules_router)
router.include_router(pool_router)
router.include_router(provider_ops_router)
router.include_router(video_tasks_router)
router.include_router(wallets_router)
# 注意:以下路由已迁移到模块系统,由 ModuleRegistry 动态注册
# - ldap_router: 当 LDAP_AVAILABLE=true 时注册
+110 -247
View File
@@ -1,13 +1,13 @@
"""管理员独立余额 API Key 管理路由。
独立余额Key:不关联用户配额,有独立余额限制,用于给非注册用户使用。
独立余额Key:不关联用户配额,可配置独立余额限制或无限额度,用于给非注册用户使用。
"""
from __future__ import annotations
import os
from datetime import datetime, timedelta, timezone
from typing import Any
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -21,8 +21,9 @@ from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger
from src.database import get_db
from src.models.api import CreateApiKeyRequest
from src.models.database import ApiKey
from src.models.database import ApiKey, Wallet
from src.services.user.apikey import ApiKeyService
from src.services.wallet import WalletService
# 应用时区配置,默认为 Asia/Shanghai
APP_TIMEZONE = ZoneInfo(os.getenv("APP_TIMEZONE", "Asia/Shanghai"))
@@ -68,6 +69,23 @@ router = APIRouter(prefix="/api/admin/api-keys", tags=["Admin - API Keys (Standa
pipeline = ApiRequestPipeline()
def _ensure_standalone_wallet(
db: Session,
api_key: ApiKey,
*,
limit_mode: Literal["finite", "unlimited"] | None = None,
) -> Wallet:
"""确保独立 Key 已绑定钱包,并可选同步额度模式。"""
wallet = WalletService.get_or_create_wallet(db, api_key=api_key)
if wallet is None:
raise InvalidRequestException("独立密钥钱包初始化失败")
if limit_mode is not None and wallet.limit_mode != limit_mode:
wallet = WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=limit_mode)
return wallet
@router.get("")
async def list_standalone_api_keys(
request: Request,
@@ -88,10 +106,9 @@ async def list_standalone_api_keys(
- `is_active`: 可选,根据启用状态筛选(true/false)
**返回字段**:
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, current_balance_usd,
balance_used_usd, total_requests, total_cost_usd, rate_limit, allowed_providers,
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
auto_delete_on_expiry 等字段
- `api_keys`: API Key 列表,包含 id, name, key_display, is_active, is_standalone,
total_requests, total_cost_usd, rate_limit, allowed_providers, allowed_api_formats,
allowed_models, last_used_at, expires_at, created_at, updated_at, auto_delete_on_expiry 等字段
- `total`: 符合条件的总记录数
- `limit`: 当前分页限制
- `skip`: 当前分页偏移量
@@ -109,16 +126,16 @@ async def create_standalone_api_key(
"""
创建独立余额 API Key
创建一个新的独立余额 API Key。独立余额 Key 必须设置初始余额限制。
创建一个新的独立余额 API Key。独立余额 Key 可设置初始余额,或使用无限额度。
**请求体字段**:
- `name`: API Key 的名称
- `initial_balance_usd`: 必需,初始余额(美元),必须大于 0
- `initial_balance_usd`: 可选,初始余额(美元),null 表示无限制额度
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `rate_limit`: 可选,速率限制配置(请求数/秒)
- `expire_days`: 可选,过期天数(兼容旧版)
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days)
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
@@ -128,8 +145,7 @@ async def create_standalone_api_key(
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_standalone`: 是否为独立余额 Key(始终为 true)
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `created_at`: 创建时间
@@ -153,11 +169,12 @@ async def update_api_key(
**请求体字段**:
- `name`: 可选,API Key 的名称
- `unlimited_balance`: 可选,是否无限余额(true=无限,false=有限,不修改余额数值)
- `rate_limit`: 可选,速率限制配置(null 表示无限制)
- `allowed_providers`: 可选,允许使用的提供商列表
- `allowed_api_formats`: 可选,允许使用的 API 格式列表
- `allowed_models`: 可选,允许使用的模型列表
- `expire_days`: 可选,过期天数(兼容旧版)
- `expire_days`: 可选,过期天数(与 expires_at 二选一)
- `expires_at`: 可选,过期时间(ISO 格式或 YYYY-MM-DD 格式,优先级高于 expire_days,null 或空字符串表示永不过期)
- `auto_delete_on_expiry`: 可选,过期后是否自动删除
@@ -166,8 +183,7 @@ async def update_api_key(
- `name`: API Key 名称
- `key_display`: 脱敏显示的 Key
- `is_active`: 是否启用
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额
- `wallet`: 钱包摘要(总余额、充值余额、赠款余额、额度模式等)
- `rate_limit`: 速率限制配置
- `expires_at`: 过期时间
- `updated_at`: 更新时间
@@ -213,106 +229,6 @@ async def delete_api_key(key_id: str, request: Request, db: Session = Depends(ge
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/lock")
async def toggle_lock_api_key(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
"""
切换 API Key 锁定状态
锁定/解锁指定的 API Key。锁定后用户无法使用和操作此密钥。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `is_locked`: 新的锁定状态
- `message`: 提示信息
"""
adapter = AdminToggleLockApiKeyAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/balance")
async def add_balance_to_key(
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
调整独立余额 API Key 的余额
为指定的独立余额 API Key 增加或扣除余额。
**路径参数**:
- `key_id`: API Key ID
**请求体字段**:
- `amount_usd`: 调整金额(美元),正数为充值,负数为扣除
**返回字段**:
- `id`: API Key ID
- `name`: API Key 名称
- `current_balance_usd`: 调整后的当前余额
- `balance_used_usd`: 已使用余额
- `message`: 提示信息
"""
# 从请求体获取调整金额
body = await request.json()
amount_usd = body.get("amount_usd")
# 参数校验
if amount_usd is None:
raise HTTPException(status_code=400, detail="缺少必需参数: amount_usd")
if amount_usd == 0:
raise HTTPException(status_code=400, detail="调整金额不能为 0")
# 类型校验
try:
amount_usd = float(amount_usd)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="调整金额必须是有效数字")
# 如果是扣除操作,检查Key是否存在以及余额是否充足
if amount_usd < 0:
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
if not api_key.is_standalone:
raise HTTPException(status_code=400, detail="只能为独立余额Key调整余额")
if api_key.current_balance_usd is not None:
if abs(amount_usd) > api_key.current_balance_usd:
raise HTTPException(
status_code=400,
detail=f"扣除金额 ${abs(amount_usd):.2f} 超过当前余额 ${api_key.current_balance_usd:.2f}",
)
adapter = AdminAddBalanceAdapter(key_id=key_id, amount_usd=amount_usd)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{key_id}/reset-usage")
async def reset_api_key_usage(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
"""
重置独立余额 API Key 的已使用额度
将 balance_used_usd 重置为 0,不改变 current_balance_usd。
**路径参数**:
- `key_id`: API Key ID
**返回字段**:
- `id`: API Key ID
- `current_balance_usd`: 当前余额
- `balance_used_usd`: 已使用余额(重置后为 0)
- `message`: 提示信息
"""
adapter = AdminResetKeyUsageAdapter(key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{key_id}")
async def get_api_key_detail(
key_id: str,
@@ -333,9 +249,9 @@ async def get_api_key_detail(
**返回字段**:
- 当 include_key=false 时,返回基本信息:id, user_id, name, key_display, is_active,
is_standalone, current_balance_usd, balance_used_usd, total_requests, total_cost_usd,
rate_limit, allowed_providers, allowed_api_formats, allowed_models, last_used_at,
expires_at, created_at, updated_at
is_standalone, total_requests, total_cost_usd, rate_limit, allowed_providers,
allowed_api_formats, allowed_models, last_used_at, expires_at, created_at, updated_at,
wallet
- 当 include_key=true 时,返回完整密钥:key
"""
if include_key:
@@ -372,6 +288,18 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all()
)
# 保证返回的独立 Key 都已完成钱包初始化。
wallet_initialized = False
for api_key in api_keys:
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
if wallet is None:
_ensure_standalone_wallet(db, api_key)
wallet_initialized = True
if wallet_initialized:
db.commit()
for api_key in api_keys:
db.refresh(api_key)
context.add_audit_metadata(
action="list_standalone_api_keys",
filter_is_active=self.is_active,
@@ -388,10 +316,7 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),
"total_requests": api_key.total_requests,
"total_cost_usd": float(api_key.total_cost_usd or 0),
"rate_limit": api_key.rate_limit,
@@ -423,11 +348,14 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 独立Key必须设置初始余额
if not self.key_data.initial_balance_usd or self.key_data.initial_balance_usd <= 0:
# 独立Key支持无限制额度(initial_balance_usd = null)
if (
self.key_data.initial_balance_usd is not None
and self.key_data.initial_balance_usd <= 0
):
raise HTTPException(
status_code=400,
detail="创建独立余额Key必须设置有效的初始余额(initial_balance_usd > 0)",
detail="创建独立余额Key时,初始余额必须大于 0(或设置为 null 表示无限制)",
)
# 独立Key需要关联到管理员用户(从context获取)
@@ -445,13 +373,28 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
allowed_api_formats=self.key_data.allowed_api_formats,
allowed_models=self.key_data.allowed_models,
rate_limit=self.key_data.rate_limit, # None 表示不限制
expire_days=self.key_data.expire_days, # 兼容旧版
expire_days=self.key_data.expire_days,
expires_at=expires_at_dt, # 优先使用
initial_balance_usd=self.key_data.initial_balance_usd,
is_standalone=True, # 标记为独立Key
auto_delete_on_expiry=self.key_data.auto_delete_on_expiry,
)
# 钱包体系:独立 Key 初始化与用户钱包初始化统一走 WalletService。
# 独立 Key 不支持充值,初始余额通过系统调账入账(仅资金流水,无充值订单)。
wallet = WalletService.initialize_api_key_wallet(
db,
api_key=api_key,
initial_balance_usd=self.key_data.initial_balance_usd,
unlimited=self.key_data.initial_balance_usd is None,
operator_id=context.user.id if context.user else None,
description="独立密钥初始调账",
)
if wallet is None:
raise InvalidRequestException("独立密钥钱包初始化失败")
db.commit()
db.refresh(api_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
logger.info(
f"管理员创建独立余额Key: ID {api_key.id}, 初始余额 ${self.key_data.initial_balance_usd}"
)
@@ -468,11 +411,10 @@ class AdminCreateStandaloneKeyAdapter(AdminApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_standalone": True,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": 0.0,
"rate_limit": api_key.rate_limit,
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat(),
"wallet": wallet_summary,
"message": "独立余额Key创建成功,请妥善保存完整密钥,后续将无法查看",
}
@@ -489,6 +431,8 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持更新独立密钥")
# 构建更新数据
update_data = {}
@@ -518,7 +462,7 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
elif "expires_at" in self.key_data.model_fields_set:
# expires_at 明确传递为 null 或空字符串,设为永不过期
update_data["expires_at"] = None
# 兼容旧版 expire_days
# expire_days 作为按天数设置过期时间的输入方式
elif "expire_days" in self.key_data.model_fields_set:
if self.key_data.expire_days is not None and self.key_data.expire_days > 0:
update_data["expires_at"] = datetime.now(timezone.utc) + timedelta(
@@ -528,29 +472,50 @@ class AdminUpdateApiKeyAdapter(AdminApiAdapter):
# expire_days = None/0/负数 表示永不过期
update_data["expires_at"] = None
changed_fields = list(update_data.keys())
# 编辑独立 Key 不允许通过此接口改余额,统一走钱包操作接口。
if "initial_balance_usd" in self.key_data.model_fields_set:
raise InvalidRequestException("编辑独立密钥不支持修改余额,请使用钱包操作")
# 允许编辑独立 Key 的额度模式(不修改余额数值)。
if (
"unlimited_balance" in self.key_data.model_fields_set
and self.key_data.unlimited_balance is not None
):
wallet = _ensure_standalone_wallet(db, api_key)
desired_mode: Literal["finite", "unlimited"] = (
"unlimited" if self.key_data.unlimited_balance else "finite"
)
if wallet.limit_mode != desired_mode:
WalletService.set_wallet_limit_mode(db, wallet=wallet, limit_mode=desired_mode)
changed_fields.append("unlimited_balance")
# 使用 ApiKeyService 更新
updated_key = ApiKeyService.update_api_key(db, self.key_id, **update_data)
if not updated_key:
raise NotFoundException("更新失败", "api_key")
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {list(update_data.keys())}")
logger.info(f"管理员更新独立余额Key: ID {self.key_id}, 更新字段 {changed_fields}")
context.add_audit_metadata(
action="update_standalone_api_key",
key_id=self.key_id,
updated_fields=list(update_data.keys()),
updated_fields=changed_fields,
)
wallet = _ensure_standalone_wallet(db, updated_key)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
return {
"id": updated_key.id,
"name": updated_key.name,
"key_display": updated_key.get_display_key(),
"is_active": updated_key.is_active,
"current_balance_usd": updated_key.current_balance_usd,
"balance_used_usd": float(updated_key.balance_used_usd or 0),
"rate_limit": updated_key.rate_limit,
"expires_at": updated_key.expires_at.isoformat() if updated_key.expires_at else None,
"updated_at": updated_key.updated_at.isoformat() if updated_key.updated_at else None,
"wallet": wallet_summary,
"message": "API密钥已更新",
}
@@ -564,6 +529,8 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持操作独立密钥")
api_key.is_active = not api_key.is_active
api_key.updated_at = datetime.now(timezone.utc)
@@ -588,41 +555,6 @@ class AdminToggleApiKeyAdapter(AdminApiAdapter):
}
class AdminToggleLockApiKeyAdapter(AdminApiAdapter):
"""切换API密钥锁定状态"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
api_key.is_locked = not api_key.is_locked
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
logger.info(
f"管理员切换API密钥锁定状态: Key ID {self.key_id}, 新状态 {'锁定' if api_key.is_locked else '解锁'}"
)
context.add_audit_metadata(
action="toggle_lock_api_key",
target_key_id=api_key.id,
user_id=api_key.user_id,
new_lock_status="locked" if api_key.is_locked else "unlocked",
)
return {
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
}
class AdminDeleteApiKeyAdapter(AdminApiAdapter):
def __init__(self, key_id: str):
self.key_id = key_id
@@ -632,6 +564,8 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持删除独立密钥")
user = api_key.user
db.delete(api_key)
@@ -650,82 +584,6 @@ class AdminDeleteApiKeyAdapter(AdminApiAdapter):
return {"message": "API密钥已删除"}
class AdminAddBalanceAdapter(AdminApiAdapter):
"""为独立余额Key增加余额"""
def __init__(self, key_id: str, amount_usd: float):
self.key_id = key_id
self.amount_usd = amount_usd
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
# 使用 ApiKeyService 增加余额
updated_key = ApiKeyService.add_balance(db, self.key_id, self.amount_usd)
if not updated_key:
raise NotFoundException("余额充值失败:Key不存在或不是独立余额Key", "api_key")
logger.info(f"管理员为独立余额Key充值: ID {self.key_id}, 充值 ${self.amount_usd:.4f}")
context.add_audit_metadata(
action="add_balance_to_key",
key_id=self.key_id,
amount_usd=self.amount_usd,
new_current_balance=updated_key.current_balance_usd,
)
return {
"id": updated_key.id,
"name": updated_key.name,
"current_balance_usd": updated_key.current_balance_usd,
"balance_used_usd": float(updated_key.balance_used_usd or 0),
"message": f"余额充值成功,充值 ${self.amount_usd:.2f},当前余额 ${updated_key.current_balance_usd:.2f}",
}
class AdminResetKeyUsageAdapter(AdminApiAdapter):
"""重置独立余额Key的已使用额度"""
def __init__(self, key_id: str):
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("只能重置独立余额Key的使用额度")
previous_used = float(api_key.balance_used_usd or 0)
api_key.balance_used_usd = 0.0
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
logger.info(
f"管理员重置独立余额Key使用额度: Key ID {self.key_id}, "
f"重置前已使用 ${previous_used:.4f}"
)
context.add_audit_metadata(
action="reset_key_usage",
key_id=self.key_id,
current_balance_usd=api_key.current_balance_usd,
previous_balance_used_usd=previous_used,
)
return {
"id": api_key.id,
"name": api_key.name,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": 0.0,
"message": "使用额度已重置",
}
class AdminGetFullKeyAdapter(AdminApiAdapter):
"""获取完整的API密钥"""
@@ -741,6 +599,8 @@ class AdminGetFullKeyAdapter(AdminApiAdapter):
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持查看独立密钥")
# 解密完整密钥
if not api_key.key_encrypted:
@@ -777,6 +637,11 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
if not api_key:
raise NotFoundException("API密钥不存在", "api_key")
if not api_key.is_standalone:
raise InvalidRequestException("仅支持查看独立密钥")
wallet = WalletService.get_wallet(db, api_key_id=api_key.id)
wallet_summary = WalletService.serialize_wallet_summary(wallet)
context.add_audit_metadata(
action="get_api_key_detail",
@@ -789,10 +654,7 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
"name": api_key.name,
"key_display": api_key.get_display_key(),
"is_active": api_key.is_active,
"is_locked": api_key.is_locked,
"is_standalone": api_key.is_standalone,
"current_balance_usd": api_key.current_balance_usd,
"balance_used_usd": float(api_key.balance_used_usd or 0),
"total_requests": api_key.total_requests,
"total_cost_usd": float(api_key.total_cost_usd or 0),
"rate_limit": api_key.rate_limit,
@@ -803,4 +665,5 @@ class AdminGetKeyDetailAdapter(AdminApiAdapter):
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
"created_at": api_key.created_at.isoformat(),
"updated_at": api_key.updated_at.isoformat() if api_key.updated_at else None,
"wallet": wallet_summary,
}
+5
View File
@@ -0,0 +1,5 @@
"""Admin payment routes."""
from .routes import router
__all__ = ["router"]
+246
View File
@@ -0,0 +1,246 @@
"""管理员支付订单管理接口。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.api.serializers import serialize_payment_callback, serialize_payment_order
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db
from src.services.payment import PaymentService
router = APIRouter(prefix="/api/admin/payments", tags=["Admin - Payments"])
pipeline = ApiRequestPipeline()
class AdminPaymentOrderCreditPayload(BaseModel):
gateway_order_id: str | None = Field(default=None, max_length=128)
pay_amount: float | None = Field(default=None, gt=0)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0)
gateway_response: dict[str, Any] | None = None
def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseModel:
try:
return model_cls.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
@router.get("/orders")
async def list_payment_orders(
request: Request,
status: str | None = Query(None),
payment_method: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentOrderListAdapter(
status=status,
payment_method=payment_method,
limit=limit,
offset=offset,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/orders/{order_id}")
async def get_payment_order(
order_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentOrderDetailAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/orders/{order_id}/expire")
async def expire_payment_order(
order_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentOrderExpireAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/orders/{order_id}/credit")
async def credit_payment_order(
order_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentOrderCreditAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/orders/{order_id}/fail")
async def fail_payment_order(
order_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentOrderFailAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/callbacks")
async def list_payment_callbacks(
request: Request,
payment_method: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminPaymentCallbackListAdapter(
payment_method=payment_method,
limit=limit,
offset=offset,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@dataclass
class AdminPaymentOrderListAdapter(AdminApiAdapter):
status: str | None
payment_method: str | None
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
items, total, changed = PaymentService.list_orders(
context.db,
status=self.status,
payment_method=self.payment_method,
limit=self.limit,
offset=self.offset,
)
if changed:
context.db.commit()
return {
"items": [serialize_payment_order(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminPaymentOrderDetailAdapter(AdminApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order)}
@dataclass
class AdminPaymentOrderExpireAdapter(AdminApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated, expired = PaymentService.expire_order(
context.db,
order=order,
reason="admin_mark_expired",
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated), "expired": expired}
@dataclass
class AdminPaymentOrderCreditAdapter(AdminApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
raw_payload = context.ensure_json_body() if context.raw_body else {}
req = _parse_payload(AdminPaymentOrderCreditPayload, raw_payload)
gateway_response = dict(order.gateway_response or {})
if req.gateway_response:
gateway_response.update(req.gateway_response)
gateway_response["manual_credit"] = True
gateway_response["credited_by"] = context.user.id if context.user else None
try:
updated, credited = PaymentService.credit_order(
context.db,
order=order,
gateway_order_id=req.gateway_order_id,
gateway_response=gateway_response,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated), "credited": credited}
@dataclass
class AdminPaymentOrderFailAdapter(AdminApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
order = PaymentService.get_order(context.db, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
try:
updated = PaymentService.fail_order(
context.db,
order=order,
reason="admin_mark_failed",
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
return {"order": serialize_payment_order(updated)}
@dataclass
class AdminPaymentCallbackListAdapter(AdminApiAdapter):
payment_method: str | None
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
items, total = PaymentService.list_callbacks(
context.db,
payment_method=self.payment_method,
limit=self.limit,
offset=self.offset,
)
return {
"items": [serialize_payment_callback(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
+78 -40
View File
@@ -24,6 +24,7 @@ from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate
from src.services.provider_ops.types import SENSITIVE_CREDENTIAL_FIELDS
from src.services.system.config import SystemConfigService
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
@@ -678,26 +679,6 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
except Exception as e:
logger.warning(f"更新签到任务时间失败: {e}")
# 如果更新的是用户配额重置任务时间,动态更新调度器
if self.key == "user_quota_reset_time" and value:
try:
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
scheduler = get_maintenance_scheduler()
scheduler.update_user_quota_reset_time(value)
except Exception as e:
logger.warning(f"更新用户配额重置任务时间失败: {e}")
# 如果更新的是独立密钥额度重置任务时间,动态更新调度器
if self.key == "standalone_key_quota_reset_time" and value:
try:
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
scheduler = get_maintenance_scheduler()
scheduler.update_standalone_key_quota_reset_time(value)
except Exception as e:
logger.warning(f"更新独立密钥额度重置任务时间失败: {e}")
# 如果更新的是调度模式或优先级模式,立即更新当前 Worker 的 Scheduler 单例
if self.key in ("scheduling_mode", "provider_priority_mode"):
try:
@@ -1946,15 +1927,19 @@ class AdminImportConfigAdapter(AdminApiAdapter):
class AdminExportUsersAdapter(AdminApiAdapter):
@staticmethod
def _serialize_api_key(key: ApiKey, include_is_standalone: bool = False) -> dict[str, Any]:
def _serialize_api_key(
key: ApiKey, include_is_standalone: bool = False, db: Any = None,
) -> dict[str, Any]:
"""序列化用户 API Key 为导出格式。"""
from src.core.crypto import crypto_service
data = {
wallet = None
if db is not None and key.is_standalone:
wallet = WalletService.get_wallet(db, api_key_id=key.id)
data: dict[str, Any] = {
"key_hash": key.key_hash,
"name": key.name,
"balance_used_usd": key.balance_used_usd,
"current_balance_usd": key.current_balance_usd,
"allowed_providers": key.allowed_providers,
"allowed_api_formats": key.allowed_api_formats,
"allowed_models": key.allowed_models,
@@ -1966,6 +1951,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
"auto_delete_on_expiry": key.auto_delete_on_expiry,
"total_requests": key.total_requests,
"total_cost_usd": key.total_cost_usd,
"wallet": WalletService.serialize_wallet_summary(wallet) if wallet else None,
}
if key.key_encrypted:
@@ -1995,6 +1981,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
users = db.query(User).filter(User.is_deleted.is_(False), User.role != UserRole.ADMIN).all()
users_data = []
for user in users:
wallet = WalletService.get_wallet(db, user_id=user.id)
# 导出用户的 API Keys(排除独立余额Key,独立Key单独导出)
api_keys = (
db.query(ApiKey)
@@ -2016,9 +2003,8 @@ class AdminExportUsersAdapter(AdminApiAdapter):
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"model_capability_settings": user.model_capability_settings,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": user.total_usd,
"unlimited": WalletService.is_unlimited_wallet(wallet),
"wallet": WalletService.serialize_wallet_summary(wallet) if wallet else None,
"is_active": user.is_active,
"api_keys": api_keys_data,
}
@@ -2026,7 +2012,7 @@ class AdminExportUsersAdapter(AdminApiAdapter):
# 导出独立余额 Keys(管理员创建的,不属于普通用户)
standalone_keys = db.query(ApiKey).filter(ApiKey.is_standalone.is_(True)).all()
standalone_keys_data = [self._serialize_api_key(key) for key in standalone_keys]
standalone_keys_data = [self._serialize_api_key(key, db=db) for key in standalone_keys]
return {
"version": "1.2",
@@ -2119,8 +2105,6 @@ class AdminImportUsersAdapter(AdminApiAdapter):
key_encrypted=key_encrypted,
name=key_data.get("name"),
is_standalone=is_standalone or key_data.get("is_standalone", False),
balance_used_usd=key_data.get("balance_used_usd", 0.0),
current_balance_usd=key_data.get("current_balance_usd"),
allowed_providers=key_data.get("allowed_providers"),
allowed_api_formats=key_data.get("allowed_api_formats"),
allowed_models=key_data.get("allowed_models"),
@@ -2153,6 +2137,14 @@ class AdminImportUsersAdapter(AdminApiAdapter):
continue
existing_user = db.query(User).filter(User.email == import_email).first()
wallet_payload = (
user_data.get("wallet") if isinstance(user_data.get("wallet"), dict) else None
)
wallet_limit_mode = (
str(wallet_payload.get("limit_mode"))
if wallet_payload and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
else ("unlimited" if user_data.get("unlimited") else "finite")
)
if existing_user:
user_id = existing_user.id
@@ -2173,11 +2165,20 @@ class AdminImportUsersAdapter(AdminApiAdapter):
existing_user.model_capability_settings = user_data.get(
"model_capability_settings"
)
existing_user.quota_usd = user_data.get("quota_usd")
existing_user.used_usd = user_data.get("used_usd", 0.0)
existing_user.total_usd = user_data.get("total_usd", 0.0)
existing_user.is_active = user_data.get("is_active", True)
existing_user.updated_at = datetime.now(timezone.utc)
wallet = WalletService.get_or_create_wallet(db, user=existing_user)
if wallet is not None:
wallet.limit_mode = wallet_limit_mode
if wallet_payload:
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
wallet.status = wallet_payload.get("status", "active") or "active"
wallet.updated_at = datetime.now(timezone.utc)
stats["users"]["updated"] += 1
else:
# 创建新用户
@@ -2196,13 +2197,22 @@ class AdminImportUsersAdapter(AdminApiAdapter):
allowed_api_formats=user_data.get("allowed_api_formats"),
allowed_models=user_data.get("allowed_models"),
model_capability_settings=user_data.get("model_capability_settings"),
quota_usd=user_data.get("quota_usd"),
used_usd=user_data.get("used_usd", 0.0),
total_usd=user_data.get("total_usd", 0.0),
is_active=user_data.get("is_active", True),
)
db.add(new_user)
db.flush()
wallet = WalletService.get_or_create_wallet(db, user=new_user)
if wallet is not None:
wallet.limit_mode = wallet_limit_mode
if wallet_payload:
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
wallet.status = wallet_payload.get("status", "active") or "active"
wallet.updated_at = datetime.now(timezone.utc)
user_id = new_user.id
stats["users"]["created"] += 1
@@ -2229,6 +2239,38 @@ class AdminImportUsersAdapter(AdminApiAdapter):
)
if new_key:
db.add(new_key)
db.flush()
wallet = WalletService.get_or_create_wallet(db, api_key=new_key)
wallet_payload = (
key_data.get("wallet")
if isinstance(key_data.get("wallet"), dict)
else None
)
if wallet is not None:
wallet.limit_mode = (
str(wallet_payload.get("limit_mode"))
if wallet_payload
and wallet_payload.get("limit_mode")
in {"finite", "unlimited"}
else ("unlimited" if key_data.get("unlimited") else "finite")
)
if wallet_payload:
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
wallet.total_recharged = (
wallet_payload.get("total_recharged", 0) or 0
)
wallet.total_consumed = (
wallet_payload.get("total_consumed", 0) or 0
)
wallet.total_refunded = (
wallet_payload.get("total_refunded", 0) or 0
)
wallet.total_adjusted = (
wallet_payload.get("total_adjusted", 0) or 0
)
wallet.status = wallet_payload.get("status", "active") or "active"
wallet.updated_at = datetime.now(timezone.utc)
stats["standalone_keys"]["created"] += 1
elif status == "skipped":
stats["standalone_keys"]["skipped"] += 1
@@ -2655,15 +2697,11 @@ def _purge_stats_and_reset_counters(db: Session) -> None:
db.query(StatsSummary).delete()
db.query(StatsUserDaily).delete()
# 重置 User 上的累计统计字段
db.query(User).update({User.used_usd: 0.0, User.total_usd: 0.0}, synchronize_session=False)
# 重置 ApiKey 上的缓存统计字段
db.query(ApiKey).update(
{
ApiKey.total_requests: 0,
ApiKey.total_cost_usd: 0.0,
ApiKey.balance_used_usd: 0.0,
},
synchronize_session=False,
)
+195 -155
View File
@@ -2,13 +2,12 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import ValidationError
from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
@@ -23,12 +22,31 @@ from src.models.database import ApiKey, User, UserRole
from src.services.system.config import SystemConfigService
from src.services.user.apikey import ApiKeyService
from src.services.user.service import UserService
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/admin/users", tags=["Admin - Users"])
pipeline = ApiRequestPipeline()
def _serialize_user(db: Session, user: User) -> dict[str, Any]:
wallet = WalletService.get_wallet(db, user_id=user.id)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"unlimited": WalletService.is_unlimited_wallet(wallet),
"is_active": user.is_active,
"created_at": user.created_at.isoformat(),
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
}
# 管理员端点
@router.post("")
async def create_user_endpoint(request: Request, db: Session = Depends(get_db)) -> Any:
@@ -42,7 +60,8 @@ async def create_user_endpoint(request: Request, db: Session = Depends(get_db))
- `username`: 用户名
- `password`: 密码
- `role`: 角色(user/admin)
- `quota_usd`: 配额(USD)
- `initial_gift_usd`: 初始赠款(USD,可选)
- `unlimited`: 是否无限制
"""
adapter = AdminCreateUserAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -62,7 +81,7 @@ async def list_users(
分页获取用户列表,支持按角色和状态筛选。
**返回字段**: id, email, username, role, quota_usd, used_usd, is_active, created_at 等
**返回字段**: id, email, username, role, unlimited, is_active, created_at 等
"""
adapter = AdminListUsersAdapter(skip=skip, limit=limit, role=role, is_active=is_active)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -91,7 +110,7 @@ async def update_user(
"""
更新用户信息
更新指定用户的信息,包括角色、配额、权限等。
更新指定用户的信息,包括角色、无限制开关、权限等。
**路径参数**:
- `user_id`: 用户 ID (UUID)
@@ -100,7 +119,7 @@ async def update_user(
- `email`: 邮箱地址
- `username`: 用户名
- `role`: 角色
- `quota_usd`: 配额
- `unlimited`: 是否无限制
- `is_active`: 是否启用
- `allowed_providers`: 允许的提供商列表
- `allowed_models`: 允许的模型列表
@@ -123,20 +142,6 @@ async def delete_user(user_id: str, request: Request, db: Session = Depends(get_
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{user_id}/quota")
async def reset_user_quota(user_id: str, request: Request, db: Session = Depends(get_db)) -> None:
"""
重置用户配额
将用户的已用配额(used_usd)重置为 0。
**路径参数**:
- `user_id`: 用户 ID (UUID)
"""
adapter = AdminResetUserQuotaAdapter(user_id=user_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{user_id}/api-keys")
async def get_user_api_keys(
user_id: str,
@@ -203,6 +208,46 @@ async def delete_user_api_key(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.patch("/{user_id}/api-keys/{key_id}/lock")
async def toggle_user_api_key_lock(
user_id: str,
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
切换用户 API 密钥锁定状态
仅支持普通用户 Key(非独立 Key)。
**路径参数**:
- `user_id`: 用户 ID (UUID)
- `key_id`: 密钥 ID
"""
adapter = AdminToggleUserKeyLockAdapter(user_id=user_id, key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{user_id}/api-keys/{key_id}/full-key")
async def get_user_api_key_full_key(
user_id: str,
key_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
"""
获取用户 API 密钥完整值
仅支持普通用户 Key(非独立 Key)。
**路径参数**:
- `user_id`: 用户 ID (UUID)
- `key_id`: 密钥 ID
"""
adapter = AdminGetUserKeyFullKeyAdapter(user_id=user_id, key_id=key_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ============== 管理员适配器实现 ==============
@@ -224,13 +269,15 @@ class AdminCreateUserAdapter(AdminApiAdapter):
except (KeyError, AttributeError):
raise InvalidRequestException("角色参数不合法")
# 确定配额:unlimited 优先,其次是指定值,最后是系统默认
# 确定初始赠款:仅有限制用户才会发放初始赠款
if request.unlimited:
quota_usd = None # None 表示无限制
elif request.quota_usd is not None:
quota_usd = request.quota_usd
initial_gift_usd = None
elif request.initial_gift_usd is not None:
initial_gift_usd = request.initial_gift_usd
else:
quota_usd = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
initial_gift_usd = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 处理访问权限字段:空数组转为 None(表示无限制)
allowed_providers = request.allowed_providers if request.allowed_providers else None
@@ -244,7 +291,8 @@ class AdminCreateUserAdapter(AdminApiAdapter):
username=request.username,
password=request.password,
role=role,
quota_usd=quota_usd,
initial_gift_usd=initial_gift_usd,
unlimited=request.unlimited,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
allowed_models=allowed_models,
@@ -258,24 +306,11 @@ class AdminCreateUserAdapter(AdminApiAdapter):
target_email=user.email,
target_username=user.username,
target_role=user.role.value,
quota_usd=user.quota_usd,
initial_gift_usd=initial_gift_usd,
unlimited=request.unlimited,
is_active=user.is_active,
)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": getattr(user, "total_usd", 0),
"is_active": user.is_active,
"created_at": user.created_at.isoformat(),
}
return _serialize_user(db, user)
class AdminListUsersAdapter(AdminApiAdapter):
@@ -293,52 +328,12 @@ class AdminListUsersAdapter(AdminApiAdapter):
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
role_enum = None
if self.role:
try:
role_enum = UserRole[self.role.upper()]
except KeyError as exc:
raise InvalidRequestException("角色参数不合法") from exc
query = db.query(User).options(
load_only(
User.id,
User.email,
User.username,
User.role,
User.allowed_providers,
User.allowed_api_formats,
User.allowed_models,
User.quota_usd,
User.used_usd,
User.total_usd,
User.is_active,
User.created_at,
)
)
if role_enum:
query = query.filter(User.role == role_enum)
if self.is_active is not None:
query = query.filter(User.is_active == self.is_active)
users = query.order_by(User.created_at.desc()).offset(self.skip).limit(self.limit).all()
return [
{
"id": u.id,
"email": u.email,
"username": u.username,
"role": u.role.value,
"allowed_providers": u.allowed_providers,
"allowed_api_formats": u.allowed_api_formats,
"allowed_models": u.allowed_models,
"quota_usd": u.quota_usd,
"used_usd": u.used_usd,
"total_usd": getattr(u, "total_usd", 0),
"is_active": u.is_active,
"created_at": u.created_at.isoformat(),
}
for u in users
]
try:
role_enum = UserRole[self.role.upper()] if self.role else None
except KeyError as exc:
raise InvalidRequestException("角色参数不合法") from exc
users = UserService.list_users(db, self.skip, self.limit, role_enum, self.is_active)
return [_serialize_user(db, u) for u in users]
class AdminGetUserAdapter(AdminApiAdapter):
@@ -358,22 +353,7 @@ class AdminGetUserAdapter(AdminApiAdapter):
include_history=bool(user.last_login_at),
)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": getattr(user, "total_usd", 0),
"is_active": user.is_active,
"created_at": user.created_at.isoformat(),
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
}
return _serialize_user(db, user)
class AdminUpdateUserAdapter(AdminApiAdapter):
@@ -397,6 +377,11 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
update_data = request.model_dump(exclude_unset=True)
old_role = existing_user.role
existing_wallet = WalletService.get_or_create_wallet(db, user=existing_user)
unlimited_before = WalletService.is_unlimited_wallet(existing_wallet)
requested_unlimited = update_data.pop("unlimited", None)
if "role" in update_data and update_data["role"]:
if hasattr(update_data["role"], "value"):
update_data["role"] = update_data["role"]
@@ -414,31 +399,28 @@ class AdminUpdateUserAdapter(AdminApiAdapter):
await UsageService.clear_user_heatmap_cache(self.user_id)
changed_fields = list(update_data.keys())
if requested_unlimited is not None:
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is not None:
WalletService.set_wallet_limit_mode(
db,
wallet=wallet,
limit_mode="unlimited" if requested_unlimited else "finite",
)
changed_fields.append("unlimited")
context.add_audit_metadata(
action="update_user",
target_user_id=user.id,
updated_fields=changed_fields,
role_before=existing_user.role.value if existing_user.role else None,
role_after=user.role.value,
quota_usd=user.quota_usd,
unlimited_before=unlimited_before,
unlimited_after=(
requested_unlimited if requested_unlimited is not None else unlimited_before
),
is_active=user.is_active,
)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": getattr(user, "total_usd", 0),
"is_active": user.is_active,
"created_at": user.created_at.isoformat(),
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
}
return _serialize_user(db, user)
class AdminDeleteUserAdapter(AdminApiAdapter):
@@ -458,7 +440,10 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
if admin_count <= 1:
raise InvalidRequestException("不能删除最后一个管理员账户")
success = UserService.delete_user(db, self.user_id)
try:
success = UserService.delete_user(db, self.user_id)
except ValueError as exc:
raise InvalidRequestException(str(exc))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
@@ -472,38 +457,6 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
return {"message": "用户删除成功"}
class AdminResetUserQuotaAdapter(AdminApiAdapter):
def __init__(self, user_id: str):
self.user_id = user_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
user = UserService.get_user(db, self.user_id)
if not user:
raise NotFoundException("用户不存在", "user")
user.used_usd = 0.0
user.total_usd = getattr(user, "total_usd", 0)
user.updated_at = datetime.now(timezone.utc)
db.commit()
context.add_audit_metadata(
action="reset_user_quota",
target_user_id=user.id,
quota_usd=user.quota_usd,
used_usd=user.used_usd,
total_usd=user.total_usd,
)
return {
"message": "配额已重置",
"user_id": user.id,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": user.total_usd,
}
class AdminGetUserKeysAdapter(AdminApiAdapter):
"""获取用户的API Keys"""
@@ -584,7 +537,6 @@ class AdminCreateUserKeyAdapter(AdminApiAdapter):
allowed_models=key_data.allowed_models,
rate_limit=key_data.rate_limit, # None = 无限制
expire_days=key_data.expire_days,
initial_balance_usd=None, # 普通Key不设置余额限制
is_standalone=False, # 不是独立Key
)
@@ -644,3 +596,91 @@ class AdminDeleteUserKeyAdapter(AdminApiAdapter):
)
return {"message": "API Key已删除"}
class AdminToggleUserKeyLockAdapter(AdminApiAdapter):
"""切换用户普通 API Key 的锁定状态"""
def __init__(self, user_id: str, key_id: str):
self.user_id = user_id
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 只能锁定普通Key
)
.first()
)
if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
api_key.is_locked = not api_key.is_locked
db.commit()
db.refresh(api_key)
logger.info(
f"管理员切换用户API Key锁定状态: 用户ID {self.user_id}, Key ID {self.key_id}, "
f"新状态 {'锁定' if api_key.is_locked else '解锁'}"
)
context.add_audit_metadata(
action="toggle_user_api_key_lock",
target_user_id=self.user_id,
key_id=self.key_id,
new_lock_status="locked" if api_key.is_locked else "unlocked",
)
return {
"id": api_key.id,
"is_locked": api_key.is_locked,
"message": f"API密钥已{'锁定' if api_key.is_locked else '解锁'}",
}
class AdminGetUserKeyFullKeyAdapter(AdminApiAdapter):
"""获取用户普通 API Key 的完整密钥"""
def __init__(self, user_id: str, key_id: str):
self.user_id = user_id
self.key_id = key_id
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from src.core.crypto import crypto_service
db = context.db
api_key = (
db.query(ApiKey)
.filter(
ApiKey.id == self.key_id,
ApiKey.user_id == self.user_id,
ApiKey.is_standalone == False, # 仅普通用户Key
)
.first()
)
if not api_key:
raise NotFoundException("API Key不存在或不属于该用户", "api_key")
if not api_key.key_encrypted:
raise InvalidRequestException("该密钥没有存储完整密钥信息")
try:
full_key = crypto_service.decrypt(api_key.key_encrypted)
except Exception as exc:
logger.error(
f"解密用户API密钥失败: 用户ID {self.user_id}, Key ID {self.key_id}, 错误: {exc}"
)
raise HTTPException(status_code=500, detail="解密密钥失败")
context.add_audit_metadata(
action="view_user_api_key_full_key",
target_user_id=self.user_id,
key_id=self.key_id,
)
return {"key": full_key}
+5
View File
@@ -0,0 +1,5 @@
"""Admin wallet routes."""
from .routes import router
__all__ = ["router"]
+546
View File
@@ -0,0 +1,546 @@
"""管理员钱包与退款处理接口。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.orm import Session, joinedload
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.api.serializers import (
serialize_admin_wallet,
serialize_admin_wallet_refund,
serialize_admin_wallet_transaction,
)
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db
from src.models.database import RefundRequest, Wallet, WalletTransaction
from src.services.wallet import WalletService
router = APIRouter(prefix="/api/admin/wallets", tags=["Admin - Wallets"])
pipeline = ApiRequestPipeline()
class ManualRechargePayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_method: str = Field(default="admin_manual", max_length=30)
description: str | None = Field(default=None, max_length=500)
class WalletAdjustPayload(BaseModel):
amount_usd: float = Field(..., allow_inf_nan=False)
balance_type: str = Field(default="recharge", pattern="^(recharge|gift)$")
description: str | None = Field(default=None, max_length=500)
class RefundFailPayload(BaseModel):
reason: str = Field(..., min_length=1, max_length=500)
class RefundCompletePayload(BaseModel):
gateway_refund_id: str | None = Field(default=None, max_length=128)
payout_reference: str | None = Field(default=None, max_length=255)
payout_proof: dict[str, Any] | None = None
def _get_wallet_or_raise(db: Session, wallet_id: str) -> Wallet:
wallet = (
db.query(Wallet)
.options(joinedload(Wallet.user), joinedload(Wallet.api_key))
.filter(Wallet.id == wallet_id)
.first()
)
if wallet is None:
raise NotFoundException("Wallet not found")
return wallet
def _get_refund_or_raise(db: Session, wallet_id: str, refund_id: str) -> RefundRequest:
refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund_id, RefundRequest.wallet_id == wallet_id)
.first()
)
if refund is None:
raise NotFoundException("Refund request not found")
return refund
def _ensure_user_wallet_for_refund(wallet: Wallet) -> None:
if wallet.api_key_id is not None:
raise InvalidRequestException("独立密钥钱包不支持退款审批")
def _ensure_api_key_wallet_manual_recharge(wallet: Wallet, payment_method: str) -> None:
if wallet.api_key_id is not None:
raise InvalidRequestException("独立密钥钱包不支持充值,请使用调账")
def _parse_payload(model_cls: type[BaseModel], payload: dict[str, Any]) -> BaseModel:
try:
return model_cls.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
@router.get("")
async def list_wallets(
request: Request,
status: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletListAdapter(status=status, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/ledger")
async def list_wallet_ledger(
request: Request,
category: str | None = Query(None),
reason_code: str | None = Query(None),
owner_type: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletLedgerAdapter(
category=category,
reason_code=reason_code,
owner_type=owner_type,
limit=limit,
offset=offset,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refund-requests")
async def list_global_refunds(
request: Request,
status: str | None = Query(None),
owner_type: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletGlobalRefundsAdapter(
status=status,
owner_type=owner_type,
limit=limit,
offset=offset,
)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{wallet_id}")
async def get_wallet_detail(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminWalletDetailAdapter(wallet_id=wallet_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{wallet_id}/transactions")
async def get_wallet_transactions(
wallet_id: str,
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletTransactionsAdapter(wallet_id=wallet_id, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/{wallet_id}/refunds")
async def get_wallet_refunds(
wallet_id: str,
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletRefundsAdapter(wallet_id=wallet_id, limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{wallet_id}/recharge")
async def recharge_wallet(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminWalletRechargeAdapter(wallet_id=wallet_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{wallet_id}/adjust")
async def adjust_wallet(wallet_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminWalletAdjustAdapter(wallet_id=wallet_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{wallet_id}/refunds/{refund_id}/process")
async def process_refund(
wallet_id: str,
refund_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletRefundProcessAdapter(wallet_id=wallet_id, refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{wallet_id}/refunds/{refund_id}/fail")
async def fail_refund(
wallet_id: str,
refund_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletRefundFailAdapter(wallet_id=wallet_id, refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/{wallet_id}/refunds/{refund_id}/complete")
async def complete_refund(
wallet_id: str,
refund_id: str,
request: Request,
db: Session = Depends(get_db),
) -> Any:
adapter = AdminWalletRefundCompleteAdapter(wallet_id=wallet_id, refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@dataclass
class AdminWalletListAdapter(AdminApiAdapter):
status: str | None
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
q = context.db.query(Wallet).options(joinedload(Wallet.user), joinedload(Wallet.api_key))
if self.status:
q = q.filter(Wallet.status == self.status)
total = q.count()
items = q.order_by(Wallet.updated_at.desc()).offset(self.offset).limit(self.limit).all()
return {
"items": [serialize_admin_wallet(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminWalletDetailAdapter(AdminApiAdapter):
wallet_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
pending_refunds = (
context.db.query(RefundRequest)
.filter(
RefundRequest.wallet_id == wallet.id,
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
return {
**serialize_admin_wallet(wallet),
"pending_refund_count": pending_refunds,
}
@dataclass
class AdminWalletLedgerAdapter(AdminApiAdapter):
category: str | None
reason_code: str | None
owner_type: str | None
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
q = (
context.db.query(WalletTransaction)
.join(Wallet, WalletTransaction.wallet_id == Wallet.id)
.options(
joinedload(WalletTransaction.wallet).joinedload(Wallet.user),
joinedload(WalletTransaction.wallet).joinedload(Wallet.api_key),
joinedload(WalletTransaction.operator),
)
)
if self.category:
q = q.filter(WalletTransaction.category == self.category)
if self.reason_code:
q = q.filter(WalletTransaction.reason_code == self.reason_code)
if self.owner_type == "user":
q = q.filter(Wallet.user_id.isnot(None))
elif self.owner_type == "api_key":
q = q.filter(Wallet.api_key_id.isnot(None))
total = q.count()
items = (
q.order_by(WalletTransaction.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_admin_wallet_transaction(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminWalletTransactionsAdapter(AdminApiAdapter):
wallet_id: str
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
q = (
context.db.query(WalletTransaction)
.options(joinedload(WalletTransaction.operator))
.filter(WalletTransaction.wallet_id == wallet.id)
)
total = q.count()
items = (
q.order_by(WalletTransaction.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"wallet": serialize_admin_wallet(wallet),
"items": [serialize_admin_wallet_transaction(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminWalletRefundsAdapter(AdminApiAdapter):
wallet_id: str
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
q = context.db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = q.count()
items = (
q.order_by(RefundRequest.created_at.desc()).offset(self.offset).limit(self.limit).all()
)
return {
"wallet": serialize_admin_wallet(wallet),
"items": [serialize_admin_wallet_refund(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminWalletGlobalRefundsAdapter(AdminApiAdapter):
status: str | None
owner_type: str | None
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
if self.owner_type == "api_key":
raise InvalidRequestException("独立密钥钱包不支持退款审批")
q = (
context.db.query(RefundRequest)
.join(Wallet, RefundRequest.wallet_id == Wallet.id)
.options(
joinedload(RefundRequest.wallet).joinedload(Wallet.user),
joinedload(RefundRequest.wallet).joinedload(Wallet.api_key),
)
)
if self.status:
q = q.filter(RefundRequest.status == self.status)
q = q.filter(Wallet.user_id.isnot(None))
total = q.count()
items = (
q.order_by(RefundRequest.created_at.desc()).offset(self.offset).limit(self.limit).all()
)
return {
"items": [serialize_admin_wallet_refund(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class AdminWalletRechargeAdapter(AdminApiAdapter):
wallet_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = _parse_payload(ManualRechargePayload, context.ensure_json_body())
assert isinstance(payload, ManualRechargePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_api_key_wallet_manual_recharge(wallet, payload.payment_method)
try:
order = WalletService.create_manual_recharge_order(
context.db,
wallet=wallet,
amount_usd=payload.amount_usd,
payment_method=payload.payment_method,
operator_id=context.user.id if context.user else None,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"payment_order": {
"id": order.id,
"order_no": order.order_no,
"amount_usd": float(order.amount_usd or 0),
"payment_method": order.payment_method,
"status": order.status,
"created_at": order.created_at,
"credited_at": order.credited_at,
},
}
@dataclass
class AdminWalletAdjustAdapter(AdminApiAdapter):
wallet_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = _parse_payload(WalletAdjustPayload, context.ensure_json_body())
assert isinstance(payload, WalletAdjustPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
if wallet.api_key_id is not None and payload.balance_type == "gift":
raise InvalidRequestException("独立密钥钱包不支持赠款调账")
try:
tx = WalletService.admin_adjust_balance(
context.db,
wallet=wallet,
amount_usd=payload.amount_usd,
balance_type=payload.balance_type, # recharge | gift
operator_id=context.user.id if context.user else None,
description=payload.description,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
return {
"wallet": serialize_admin_wallet(wallet),
"transaction": serialize_admin_wallet_transaction(tx),
}
@dataclass
class AdminWalletRefundProcessAdapter(AdminApiAdapter):
wallet_id: str
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
try:
tx = WalletService.move_refund_to_processing(
context.db,
refund=refund,
operator_id=context.user.id if context.user else None,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
context.db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx),
}
@dataclass
class AdminWalletRefundFailAdapter(AdminApiAdapter):
wallet_id: str
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = _parse_payload(RefundFailPayload, context.ensure_json_body())
assert isinstance(payload, RefundFailPayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
try:
tx = WalletService.fail_refund(
context.db,
refund=refund,
reason=payload.reason,
operator_id=context.user.id if context.user else None,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(wallet)
context.db.refresh(refund)
return {
"wallet": serialize_admin_wallet(wallet),
"refund": serialize_admin_wallet_refund(refund),
"transaction": serialize_admin_wallet_transaction(tx) if tx is not None else None,
}
@dataclass
class AdminWalletRefundCompleteAdapter(AdminApiAdapter):
wallet_id: str
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
payload = _parse_payload(RefundCompletePayload, context.ensure_json_body())
assert isinstance(payload, RefundCompletePayload)
wallet = _get_wallet_or_raise(context.db, self.wallet_id)
_ensure_user_wallet_for_refund(wallet)
refund = _get_refund_or_raise(context.db, self.wallet_id, self.refund_id)
if refund.status != "processing":
raise InvalidRequestException("只有 processing 状态的退款可以标记完成")
try:
updated = WalletService.complete_refund(
context.db,
refund=refund,
gateway_refund_id=payload.gateway_refund_id,
payout_reference=payload.payout_reference,
payout_proof=payload.payout_proof,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
context.db.commit()
context.db.refresh(updated)
return {"refund": serialize_admin_wallet_refund(updated)}
+8 -8
View File
@@ -41,6 +41,7 @@ from src.services.rate_limit.ip_limiter import IPRateLimiter
from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService
from src.services.user.service import UserService
from src.services.wallet import WalletService
from src.utils.request_utils import get_client_ip, get_user_agent
@@ -166,7 +167,7 @@ async def get_current_user_info(request: Request, db: Session = Depends(get_db))
"""
获取当前用户信息
返回当前登录用户的基本信息,包括邮箱、用户名、角色、配额等。
返回当前登录用户的基本信息,包括邮箱、用户名、角色、钱包信息等。
需要 Bearer Token 认证。
"""
adapter = AuthCurrentUserAdapter()
@@ -519,9 +520,9 @@ class AuthRegisterAdapter(AuthPublicAdapter):
)
try:
# 读取系统配置的默认配额
default_quota = SystemConfigService.get_config(
db, "default_user_quota_usd", default=10.0
# 读取系统配置的默认初始赠款
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# email_verified 逻辑:
@@ -534,7 +535,7 @@ class AuthRegisterAdapter(AuthPublicAdapter):
username=register_request.username,
password=register_request.password,
role=UserRole.USER,
quota_usd=default_quota,
initial_gift_usd=default_initial_gift,
email_verified=bool(require_verification and email),
)
AuditService.log_event(
@@ -579,15 +580,14 @@ class AuthRegisterAdapter(AuthPublicAdapter):
class AuthCurrentUserAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
user = context.user
wallet = WalletService.get_wallet(context.db, user_id=user.id)
return {
"id": user.id,
"email": user.email,
"username": user.username,
"role": user.role.value,
"is_active": user.is_active,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"total_usd": user.total_usd,
"billing": WalletService.serialize_wallet_summary(wallet),
"allowed_providers": user.allowed_providers,
"allowed_api_formats": user.allowed_api_formats,
"allowed_models": user.allowed_models,
+1 -1
View File
@@ -34,7 +34,7 @@ class ApiRequestContext:
query_params: dict[str, str]
raw_body: bytes | None = None
json_body: dict[str, Any] | None = None
quota_remaining: float | None = None
balance_remaining: float | None = None
mode: str = "standard" # standard / proxy
api_format_hint: str | None = None
+17 -26
View File
@@ -10,12 +10,13 @@ from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.enums import UserRole
from src.core.exceptions import QuotaExceededException
from src.core.exceptions import BalanceInsufficientException
from src.core.logger import logger
from src.models.database import ApiKey, AuditEventType, User
from src.services.auth.service import AuthService
from src.services.system.audit import AuditService
from src.services.usage.service import UsageService
from src.services.wallet import WalletService
from src.utils.perf import PerfRecorder
if TYPE_CHECKING:
@@ -35,7 +36,7 @@ QUIET_POLLING_PATHS: set[str] = {
class ApiRequestPipeline:
"""负责统一执行认证、配额校验、上下文构建等通用逻辑的管道。"""
"""负责统一执行认证、余额校验、上下文构建等通用逻辑的管道。"""
def __init__(
self,
@@ -164,7 +165,8 @@ class ApiRequestPipeline:
# 存储 quiet 标志到 context,用于审计日志判断
context.quiet_logging = is_quiet
if mode != ApiMode.ADMIN and user:
context.quota_remaining = self._calculate_quota_remaining(user)
remaining = self._calculate_balance_remaining(db, user, api_key=api_key)
context.balance_remaining = remaining
# authorize 可能是异步的,需要检查并 await
authorize_start = PerfRecorder.start(force=perf_sampled)
try:
@@ -238,25 +240,11 @@ class ApiRequestPipeline:
request.state.user_id = user.id
request.state.api_key_id = api_key.id
# 检查配额或余额(支持独立Key)
quota_ok, message = self.usage_service.check_user_quota(db, user, api_key=api_key)
if not quota_ok:
# 根据Key类型计算剩余额度
if api_key.is_standalone:
# 独立Key:显示剩余余额
remaining = (
None
if api_key.current_balance_usd is None
else float(api_key.current_balance_usd - (api_key.balance_used_usd or 0))
)
else:
# 普通Key:显示用户配额剩余
remaining = (
None
if user.quota_usd is None or user.quota_usd < 0
else float(user.quota_usd - user.used_usd)
)
raise QuotaExceededException(quota_type="USD", remaining=remaining)
# 检查余额(支持独立 Key)
access_ok, _message = self.usage_service.check_request_balance(db, user, api_key=api_key)
if not access_ok:
remaining = self._calculate_balance_remaining(db, user, api_key=api_key)
raise BalanceInsufficientException(balance_type="USD", remaining=remaining)
return user, api_key
@@ -418,12 +406,15 @@ class ApiRequestPipeline:
detail="无效的 Token 格式,需要 Management Token",
)
def _calculate_quota_remaining(self, user: User | None) -> float | None:
def _calculate_balance_remaining(
self, db: Session, user: User | None, api_key: ApiKey | None = None
) -> float | None:
if not user:
return None
if user.quota_usd is None or user.quota_usd < 0:
balance = WalletService.get_balance_snapshot(db, user=user, api_key=api_key)
if balance is None:
return None
return max(float(user.quota_usd - user.used_usd), 0.0)
return float(balance)
def _record_audit_event(
self,
@@ -513,7 +504,7 @@ class ApiRequestPipeline:
"request_body_bytes": len(context.raw_body or b""),
"has_body": bool(context.raw_body),
"request_content_type": request.headers.get("content-type"),
"quota_remaining": context.quota_remaining,
"balance_remaining": context.balance_remaining,
"success": success,
# 传递 quiet_logging 标志给审计服务,用于抑制高频轮询日志
"quiet_logging": getattr(context, "quiet_logging", False),
+17 -18
View File
@@ -33,6 +33,7 @@ from src.services.system.stats_aggregator import (
query_time_series,
)
from src.services.system.time_range import TimeRangeParams
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/dashboard", tags=["Dashboard"])
@@ -102,7 +103,7 @@ async def get_dashboard_stats(request: Request, db: Session = Depends(get_db)) -
- `users`: 用户统计(total, active)
**返回字段(普通用户)**:
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、配额使用、总Token 等信息
- `stats`: 统计卡片数组,包含 API 密钥、本月请求、钱包状态、总Token 等信息
- `today`: 今日统计
- `token_breakdown`: Token 详细分类
- `cache_stats`: 缓存统计信息
@@ -770,20 +771,18 @@ class UserDashboardStatsAdapter(DashboardAdapter):
int(usage_stats.today_cache_read_tokens or 0) if usage_stats else 0
)
# 配额状态
if user.quota_usd is None:
quota_value = "无限制"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = False
elif user.quota_usd > 0:
percent = min(100, int((user.used_usd / user.quota_usd) * 100))
quota_value = f"${user.quota_usd:.0f}"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = percent > 80
wallet = WalletService.get_wallet(db, user_id=user.id)
billing = WalletService.serialize_wallet_summary(wallet)
wallet_balance = float(billing["balance"])
wallet_consumed = float(billing["total_consumed"])
if bool(billing["unlimited"]):
wallet_value = "无限制"
wallet_change = f"累计消费 ${wallet_consumed:.2f}"
wallet_high = False
else:
quota_value = "$0"
quota_change = f"已用 ${user.used_usd:.2f}"
quota_high = True
wallet_value = f"${wallet_balance:.2f}"
wallet_change = f"累计消费 ${wallet_consumed:.2f}"
wallet_high = wallet_balance <= 0
return {
"stats": [
@@ -804,10 +803,10 @@ class UserDashboardStatsAdapter(DashboardAdapter):
"icon": "Activity",
},
{
"name": "配额使用",
"value": quota_value,
"change": quota_change,
"changeType": "increase" if quota_high else "neutral",
"name": "钱包状态",
"value": wallet_value,
"change": wallet_change,
"changeType": "increase" if wallet_high else "neutral",
"icon": "TrendingUp",
},
{
+2 -2
View File
@@ -622,9 +622,9 @@ class BaseMessageHandler:
error: 异常对象
"""
from src.core.exceptions import (
BalanceInsufficientException,
ModelNotSupportedException,
ProviderException,
QuotaExceededException,
RateLimitException,
UpstreamClientException,
)
@@ -633,7 +633,7 @@ class BaseMessageHandler:
error,
(
ProviderException,
QuotaExceededException,
BalanceInsufficientException,
RateLimitException,
ModelNotSupportedException,
UpstreamClientException,
+6 -6
View File
@@ -28,13 +28,13 @@ from src.api.base.context import ApiRequestContext
from src.api.handlers.base.chat_handler_base import ChatHandlerBase
from src.api.handlers.base.handler_adapter_base import HandlerAdapterBase
from src.core.exceptions import (
BalanceInsufficientException,
InvalidRequestException,
ModelNotSupportedException,
ProviderAuthException,
ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException,
QuotaExceededException,
UpstreamClientException,
)
from src.core.logger import logger
@@ -63,7 +63,7 @@ class ChatAdapterBase(HandlerAdapterBase):
api_key = context.api_key
db = context.db
request_id = context.request_id
quota_remaining_value = context.quota_remaining
balance_remaining_value = context.balance_remaining
start_time = context.start_time
client_ip = context.client_ip
user_agent = context.user_agent
@@ -91,14 +91,14 @@ class ChatAdapterBase(HandlerAdapterBase):
context.add_audit_metadata(**audit_metadata)
# 格式化额度显示
quota_display = (
"unlimited" if quota_remaining_value is None else f"${quota_remaining_value:.2f}"
balance_display = (
"unlimited" if balance_remaining_value is None else f"${balance_remaining_value:.2f}"
)
# 请求开始日志
logger.info(
f"[REQ] {request_id[:8]} | {self.FORMAT_ID} | {getattr(api_key, 'name', 'unknown')} | "
f"{model} | {'stream' if stream else 'sync'} | quota:{quota_display}"
f"{model} | {'stream' if stream else 'sync'} | balance:{balance_display}"
)
try:
@@ -146,7 +146,7 @@ class ChatAdapterBase(HandlerAdapterBase):
except (
ModelNotSupportedException,
QuotaExceededException,
BalanceInsufficientException,
InvalidRequestException,
) as e:
logger.info(f"客户端请求错误: {e.error_type}")
+6 -6
View File
@@ -28,13 +28,13 @@ from src.api.handlers.base.cli_handler_base import CliMessageHandlerBase
from src.api.handlers.base.handler_adapter_base import HandlerAdapterBase
from src.core.api_format import EndpointKind
from src.core.exceptions import (
BalanceInsufficientException,
InvalidRequestException,
ModelNotSupportedException,
ProviderAuthException,
ProviderNotAvailableException,
ProviderRateLimitException,
ProviderTimeoutException,
QuotaExceededException,
UpstreamClientException,
)
from src.core.logger import logger
@@ -66,7 +66,7 @@ class CliAdapterBase(HandlerAdapterBase):
api_key = context.api_key
db = context.db
request_id = context.request_id
quota_remaining_value = context.quota_remaining
balance_remaining_value = context.balance_remaining
start_time = context.start_time
client_ip = context.client_ip
user_agent = context.user_agent
@@ -107,14 +107,14 @@ class CliAdapterBase(HandlerAdapterBase):
context.add_audit_metadata(**audit_metadata)
# 格式化额度显示
quota_display = (
"unlimited" if quota_remaining_value is None else f"${quota_remaining_value:.2f}"
balance_display = (
"unlimited" if balance_remaining_value is None else f"${balance_remaining_value:.2f}"
)
# 请求开始日志
logger.info(
f"[REQ] {request_id[:8]} | {self.FORMAT_ID} | {getattr(api_key, 'name', 'unknown')} | "
f"{model} | {'stream' if stream else 'sync'} | quota:{quota_display}"
f"{model} | {'stream' if stream else 'sync'} | balance:{balance_display}"
)
try:
@@ -163,7 +163,7 @@ class CliAdapterBase(HandlerAdapterBase):
except (
ModelNotSupportedException,
QuotaExceededException,
BalanceInsufficientException,
InvalidRequestException,
) as e:
logger.debug("客户端请求错误: {}", e.error_type)
+1 -1
View File
@@ -285,7 +285,7 @@ class GeminiVeoHandler(VideoHandlerBase):
base_url = self._get_request_base_url(http_request)
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
# 提交成功后立即结算 Usage(费用暂时为 0,轮询完成后更新)
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
response_time_ms = int((time.time() - self.start_time) * 1000)
try:
# 构建发送给上游的请求头(脱敏)
+1 -1
View File
@@ -289,7 +289,7 @@ class OpenAIVideoHandler(VideoHandlerBase):
)
response_body = self._normalizer.video_task_from_internal(internal_task)
# 提交成功后立即结算 Usage(费用暂时为 0,轮询完成后更新)
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
response_time_ms = int((time.time() - self.start_time) * 1000)
try:
# 构建发送给上游的请求头(脱敏)
+5
View File
@@ -0,0 +1,5 @@
"""Payment API routes."""
from .routes import router
__all__ = ["router"]
+125
View File
@@ -0,0 +1,125 @@
"""支付回调接口。"""
from __future__ import annotations
import secrets
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from src.config import config
from src.database import get_db
from src.services.payment import PaymentService
router = APIRouter(prefix="/api/payment", tags=["Payment"])
CALLBACK_TOKEN_HEADER = "x-payment-callback-token"
CALLBACK_SIGNATURE_HEADER = "x-payment-callback-signature"
class PaymentCallbackPayload(BaseModel):
callback_key: str = Field(..., min_length=1, max_length=128)
order_no: str | None = Field(default=None, max_length=64)
gateway_order_id: str | None = Field(default=None, max_length=128)
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
pay_amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0, allow_inf_nan=False)
payload: dict[str, Any] | None = None
def _verify_callback_request_auth(request: Request) -> None:
expected_token = config.payment_callback_secret
if not expected_token:
raise HTTPException(status_code=503, detail="payment callback is disabled")
provided_token = (request.headers.get(CALLBACK_TOKEN_HEADER) or "").strip()
if not provided_token or not secrets.compare_digest(provided_token, expected_token):
raise HTTPException(status_code=401, detail="invalid payment callback token")
async def _process_callback(
*,
payment_method: str,
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
if not payment_method:
raise HTTPException(status_code=400, detail="payment_method is required")
_verify_callback_request_auth(request)
callback_signature = (request.headers.get(CALLBACK_SIGNATURE_HEADER) or "").strip()
if not callback_signature:
raise HTTPException(status_code=401, detail="missing payment callback signature")
try:
callback_payload = payload.payload if payload.payload is not None else payload.model_dump()
result = PaymentService.handle_callback(
db,
payment_method=payment_method,
callback_key=payload.callback_key,
payload=callback_payload,
callback_signature=callback_signature,
callback_secret=config.payment_callback_secret,
order_no=payload.order_no,
gateway_order_id=payload.gateway_order_id,
amount_usd=payload.amount_usd,
pay_amount=payload.pay_amount,
pay_currency=payload.pay_currency,
exchange_rate=payload.exchange_rate,
)
db.commit()
return {
**result,
"payment_method": payment_method,
"request_path": request.url.path,
}
except Exception as exc:
db.rollback()
if isinstance(exc, HTTPException):
raise
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/callback/alipay")
async def handle_alipay_callback(
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method="alipay",
request=request,
payload=payload,
db=db,
)
@router.post("/callback/wechat")
async def handle_wechat_callback(
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method="wechat",
request=request,
payload=payload,
db=db,
)
@router.post("/callback/{payment_method}")
async def handle_payment_callback(
payment_method: str,
request: Request,
payload: PaymentCallbackPayload,
db: Session = Depends(get_db),
) -> dict[str, Any]:
return await _process_callback(
payment_method=payment_method,
request=request,
payload=payload,
db=db,
)
+19
View File
@@ -39,6 +39,7 @@ from src.services.auth.service import AuthService
from src.services.gemini_files_mapping import delete_file_key_mapping, store_file_key_mapping
from src.services.provider.transport import redact_url_for_log
from src.services.scheduling.aware_scheduler import CacheAwareScheduler, ProviderCandidate
from src.services.usage.service import UsageService
@dataclass
@@ -111,6 +112,22 @@ def _build_upstream_headers(
return headers
def _ensure_balance_access(db: Session, user: User, api_key: ApiKey) -> None:
access_ok, message = UsageService.check_request_balance(db, user, api_key=api_key)
if access_ok:
return
raise HTTPException(
status_code=429,
detail={
"error": {
"code": 429,
"message": message or "Insufficient balance",
"status": "RESOURCE_EXHAUSTED",
}
},
)
def _build_upstream_url(
base_url: str,
path: str,
@@ -276,6 +293,7 @@ async def _resolve_upstream_context(
)
user, user_api_key = auth_result
_ensure_balance_access(db, user, user_api_key)
model_name = _resolve_files_model_name(db, user_api_key, user)
if not model_name:
raise HTTPException(
@@ -728,6 +746,7 @@ async def download_file(
)
user, _user_api_key = auth_result
_ensure_balance_access(db, user, _user_api_key)
# 根据前缀判断处理方式
if file_id.startswith("aev_"):
+23
View File
@@ -0,0 +1,23 @@
from .wallet_payment import (
safe_gateway_response,
serialize_admin_wallet,
serialize_admin_wallet_refund,
serialize_admin_wallet_transaction,
serialize_payment_callback,
serialize_payment_order,
serialize_wallet_payload,
serialize_wallet_refund,
serialize_wallet_transaction,
)
__all__ = [
"safe_gateway_response",
"serialize_admin_wallet",
"serialize_admin_wallet_refund",
"serialize_admin_wallet_transaction",
"serialize_payment_callback",
"serialize_payment_order",
"serialize_wallet_payload",
"serialize_wallet_refund",
"serialize_wallet_transaction",
]
+250
View File
@@ -0,0 +1,250 @@
from __future__ import annotations
from typing import Any
from src.models.database import (
PaymentCallback,
PaymentOrder,
RefundRequest,
Wallet,
WalletTransaction,
)
from src.services.wallet import WalletService
def safe_gateway_response(raw: dict[str, Any] | None) -> dict[str, Any]:
if not isinstance(raw, dict):
return {}
allowed_keys = {
"gateway",
"display_name",
"gateway_order_id",
"payment_url",
"qr_code",
"expires_at",
"manual_credit",
}
return {key: raw[key] for key in allowed_keys if key in raw}
def serialize_payment_order(
order: PaymentOrder,
*,
sanitize_gateway_response: bool = False,
) -> dict[str, Any]:
return {
"id": order.id,
"order_no": order.order_no,
"wallet_id": order.wallet_id,
"user_id": order.user_id,
"amount_usd": float(order.amount_usd or 0),
"pay_amount": float(order.pay_amount or 0) if order.pay_amount is not None else None,
"pay_currency": order.pay_currency,
"exchange_rate": (
float(order.exchange_rate or 0) if order.exchange_rate is not None else None
),
"refunded_amount_usd": float(order.refunded_amount_usd or 0),
"refundable_amount_usd": float(order.refundable_amount_usd or 0),
"payment_method": order.payment_method,
"gateway_order_id": order.gateway_order_id,
"gateway_response": (
safe_gateway_response(order.gateway_response)
if sanitize_gateway_response
else order.gateway_response
),
"status": order.status,
"created_at": order.created_at,
"paid_at": order.paid_at,
"credited_at": order.credited_at,
"expires_at": order.expires_at,
}
def serialize_payment_callback(callback: PaymentCallback) -> dict[str, Any]:
return {
"id": callback.id,
"payment_order_id": callback.payment_order_id,
"payment_method": callback.payment_method,
"callback_key": callback.callback_key,
"order_no": callback.order_no,
"gateway_order_id": callback.gateway_order_id,
"payload_hash": callback.payload_hash,
"signature_valid": callback.signature_valid,
"status": callback.status,
"payload": callback.payload,
"error_message": callback.error_message,
"created_at": callback.created_at,
"processed_at": callback.processed_at,
}
def serialize_wallet_payload(wallet: Wallet | None) -> dict[str, Any]:
if wallet is None:
return {
"wallet": None,
"unlimited": False,
"limit_mode": "finite",
"balance": 0.0,
"recharge_balance": 0.0,
"gift_balance": 0.0,
"refundable_balance": 0.0,
"currency": "USD",
}
summary = WalletService.serialize_wallet_summary(wallet)
return {
"wallet": summary,
"unlimited": bool(summary["unlimited"]),
"limit_mode": summary["limit_mode"],
"balance": summary["balance"],
"recharge_balance": summary["recharge_balance"],
"gift_balance": summary["gift_balance"],
"refundable_balance": summary["refundable_balance"],
"currency": summary["currency"],
}
def serialize_wallet_transaction(tx: WalletTransaction) -> dict[str, Any]:
return {
"id": tx.id,
"category": tx.category,
"reason_code": tx.reason_code,
"amount": float(tx.amount or 0),
"balance_before": float(tx.balance_before or 0),
"balance_after": float(tx.balance_after or 0),
"recharge_balance_before": float(tx.recharge_balance_before),
"recharge_balance_after": float(tx.recharge_balance_after),
"gift_balance_before": float(tx.gift_balance_before),
"gift_balance_after": float(tx.gift_balance_after),
"link_type": tx.link_type,
"link_id": tx.link_id,
"operator_id": tx.operator_id,
"description": tx.description,
"created_at": tx.created_at,
}
def serialize_wallet_refund(refund: RefundRequest) -> dict[str, Any]:
return {
"id": refund.id,
"refund_no": refund.refund_no,
"payment_order_id": refund.payment_order_id,
"source_type": refund.source_type,
"source_id": refund.source_id,
"refund_mode": refund.refund_mode,
"amount_usd": float(refund.amount_usd or 0),
"status": refund.status,
"reason": refund.reason,
"failure_reason": refund.failure_reason,
"gateway_refund_id": refund.gateway_refund_id,
"payout_method": refund.payout_method,
"payout_reference": refund.payout_reference,
"payout_proof": refund.payout_proof,
"created_at": refund.created_at,
"updated_at": refund.updated_at,
"processed_at": refund.processed_at,
"completed_at": refund.completed_at,
}
def _wallet_owner(wallet: Wallet | None) -> tuple[str, str | None]:
if wallet is None:
return "unknown", None
owner_name: str | None = None
if wallet.user_id:
owner_name = wallet.user.username if wallet.user else None
return "user", owner_name
if wallet.api_key_id:
if wallet.api_key:
owner_name = wallet.api_key.name or f"Key-{wallet.api_key.id[:8]}"
else:
owner_name = f"Key-{wallet.api_key_id[:8]}"
return "api_key", owner_name
return "orphaned", None
def serialize_admin_wallet(wallet: Wallet) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(wallet)
summary = WalletService.serialize_wallet_summary(wallet)
return {
"id": wallet.id,
"user_id": wallet.user_id,
"api_key_id": wallet.api_key_id,
"owner_type": owner_type,
"owner_name": owner_name,
"balance": summary["balance"],
"recharge_balance": summary["recharge_balance"],
"gift_balance": summary["gift_balance"],
"refundable_balance": summary["refundable_balance"],
"currency": summary["currency"],
"status": summary["status"],
"limit_mode": summary["limit_mode"],
"unlimited": summary["unlimited"],
"total_recharged": summary["total_recharged"],
"total_consumed": summary["total_consumed"],
"total_refunded": summary["total_refunded"],
"total_adjusted": summary["total_adjusted"],
"created_at": wallet.created_at,
"updated_at": summary["updated_at"],
}
def serialize_admin_wallet_transaction(tx: WalletTransaction) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(tx.wallet)
wallet_status = tx.wallet.status if tx.wallet is not None else None
return {
"id": tx.id,
"wallet_id": tx.wallet_id,
"owner_type": owner_type,
"owner_name": owner_name,
"wallet_status": wallet_status,
"category": tx.category,
"reason_code": tx.reason_code,
"amount": float(tx.amount or 0),
"balance_before": float(tx.balance_before or 0),
"balance_after": float(tx.balance_after or 0),
"recharge_balance_before": float(tx.recharge_balance_before),
"recharge_balance_after": float(tx.recharge_balance_after),
"gift_balance_before": float(tx.gift_balance_before),
"gift_balance_after": float(tx.gift_balance_after),
"link_type": tx.link_type,
"link_id": tx.link_id,
"operator_id": tx.operator_id,
"operator_name": tx.operator.username if tx.operator else None,
"operator_email": tx.operator.email if tx.operator else None,
"description": tx.description,
"created_at": tx.created_at,
}
def serialize_admin_wallet_refund(refund: RefundRequest) -> dict[str, Any]:
owner_type, owner_name = _wallet_owner(refund.wallet)
wallet_status = refund.wallet.status if refund.wallet is not None else None
return {
"id": refund.id,
"refund_no": refund.refund_no,
"wallet_id": refund.wallet_id,
"owner_type": owner_type,
"owner_name": owner_name,
"wallet_status": wallet_status,
"user_id": refund.user_id,
"payment_order_id": refund.payment_order_id,
"source_type": refund.source_type,
"source_id": refund.source_id,
"refund_mode": refund.refund_mode,
"amount_usd": float(refund.amount_usd or 0),
"status": refund.status,
"reason": refund.reason,
"failure_reason": refund.failure_reason,
"gateway_refund_id": refund.gateway_refund_id,
"payout_method": refund.payout_method,
"payout_reference": refund.payout_reference,
"payout_proof": refund.payout_proof,
"requested_by": refund.requested_by,
"approved_by": refund.approved_by,
"processed_by": refund.processed_by,
"created_at": refund.created_at,
"updated_at": refund.updated_at,
"processed_at": refund.processed_at,
"completed_at": refund.completed_at,
}
+13 -9
View File
@@ -16,6 +16,7 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.crypto import crypto_service
from src.core.enums import UserRole
from src.core.exceptions import (
ForbiddenException,
InvalidRequestException,
@@ -46,6 +47,7 @@ from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService
from src.services.user.preference import PreferenceService
from src.services.wallet import WalletService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
@@ -80,7 +82,7 @@ async def get_my_profile(request: Request, db: Session = Depends(get_db)) -> Any
返回当前登录用户的完整信息,包括基本信息和偏好设置。
**返回字段**: id, email, username, role, is_active, quota_usd, used_usd, preferences 等
**返回字段**: id, email, username, role, is_active, billing, preferences 等
"""
adapter = MeProfileAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@@ -826,7 +828,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
# 管理员可以看到真实成本
total_actual_cost = 0.0
if user.role == "admin":
if user.role == UserRole.ADMIN:
total_actual_cost = (
sum(item.get("actual_total_cost_usd", 0.0) for item in filtered_summary)
if filtered_summary
@@ -845,7 +847,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"total_cost_usd": 0.0,
}
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
base_stats["actual_total_cost_usd"] = 0.0
stats = model_summary.setdefault(model_name, base_stats)
@@ -855,7 +857,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
stats["total_tokens"] += item["total_tokens"]
stats["total_cost_usd"] += item["total_cost_usd"]
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
stats["actual_total_cost_usd"] += item.get("actual_total_cost_usd", 0.0)
summary_by_model = sorted(model_summary.values(), key=lambda x: x["requests"], reverse=True)
@@ -983,6 +985,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
if total_success_response_count > 0
else 0.0
)
wallet = WalletService.get_wallet(db, user_id=user.id)
# 构建响应数据
response_data = {
@@ -992,8 +995,7 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"total_tokens": total_tokens,
"total_cost": total_cost,
"avg_response_time": avg_response_time,
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"billing": WalletService.serialize_wallet_summary(wallet),
"summary_by_model": summary_by_model,
# 分页信息
"pagination": {
@@ -1002,11 +1004,13 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
"offset": self.offset,
"has_more": self.offset + self.limit < total_records,
},
"records": self._build_usage_records(usage_records, is_admin=(user.role == "admin")),
"records": self._build_usage_records(
usage_records, is_admin=(user.role == UserRole.ADMIN)
),
}
# 管理员可以看到真实成本
if user.role == "admin":
if user.role == UserRole.ADMIN:
response_data["total_actual_cost"] = total_actual_cost
# 为每条记录添加真实成本和倍率信息
for i, (r, _, _) in enumerate(usage_records):
@@ -1146,7 +1150,7 @@ class GetMyActivityHeatmapAdapter(AuthenticatedApiAdapter):
result = await UsageService.get_cached_heatmap(
db=context.db,
user_id=user.id,
include_actual_cost=user.role == "admin",
include_actual_cost=user.role == UserRole.ADMIN,
)
context.add_audit_metadata(action="activity_heatmap")
return result
+5
View File
@@ -0,0 +1,5 @@
"""Wallet API routes."""
from .routes import router
__all__ = ["router"]
+416
View File
@@ -0,0 +1,416 @@
"""用户钱包与退款接口。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.api.serializers import (
safe_gateway_response,
serialize_payment_order,
serialize_wallet_payload,
serialize_wallet_refund,
serialize_wallet_transaction,
)
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db
from src.models.database import PaymentOrder, RefundRequest, Wallet, WalletTransaction
from src.services.payment import PaymentService
from src.services.wallet import WalletService
router = APIRouter(prefix="/api/wallet", tags=["Wallet"])
pipeline = ApiRequestPipeline()
class CreateRefundPayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_order_id: str | None = None
source_type: str | None = Field(default=None, max_length=30)
source_id: str | None = Field(default=None, max_length=100)
refund_mode: str | None = Field(default=None, max_length=30)
reason: str | None = Field(default=None, max_length=500)
idempotency_key: str | None = Field(default=None, max_length=128)
class CreateRechargePayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_method: str = Field(..., min_length=1, max_length=30)
pay_amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0, allow_inf_nan=False)
def _default_refund_mode_for_order(order: PaymentOrder) -> str:
if order.payment_method in {"admin_manual", "card_recharge", "card_code", "gift_code"}:
return "offline_payout"
return "original_channel"
def _build_refund_no() -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"rf_{ts}_{uuid4().hex[:8]}"
@router.get("/balance")
async def get_wallet_balance(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletBalanceAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/transactions")
async def list_wallet_transactions(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletTransactionsAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/recharge")
async def create_recharge_order(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge")
async def list_recharge_orders(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletRechargeListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge/{order_id}")
async def get_recharge_order(order_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeDetailAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds")
async def list_refunds(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletRefundListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds/{refund_id}")
async def get_refund_detail(refund_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundDetailAdapter(refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/refunds")
async def create_refund(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@dataclass
class WalletTransactionsAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {
"items": [],
"total": 0,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(None),
}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(WalletTransaction).filter(WalletTransaction.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(WalletTransaction.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_transaction(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(wallet),
}
class WalletBalanceAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return serialize_wallet_payload(None)
if existing_wallet is None:
db.commit()
db.refresh(wallet)
pending_refunds = (
db.query(RefundRequest)
.filter(
RefundRequest.wallet_id == wallet.id,
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
payload = serialize_wallet_payload(wallet)
payload["pending_refund_count"] = pending_refunds
return payload
class WalletRechargeCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRechargePayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
try:
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd=req.amount_usd,
payment_method=req.payment_method,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc))
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
@dataclass
class WalletRechargeListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
items, total, changed = PaymentService.list_user_orders(
context.db,
user_id=user.id,
limit=self.limit,
offset=self.offset,
)
if changed:
context.db.commit()
return {
"items": [
serialize_payment_order(item, sanitize_gateway_response=True) for item in items
],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class WalletRechargeDetailAdapter(AuthenticatedApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
order = PaymentService.get_user_order(context.db, user_id=user.id, order_id=self.order_id)
if order is None:
raise NotFoundException("Payment order not found")
if PaymentService.refresh_order_status(order):
context.db.commit()
return {"order": serialize_payment_order(order, sanitize_gateway_response=True)}
@dataclass
class WalletRefundListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {"items": [], "total": 0, "limit": self.limit, "offset": self.offset}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(RefundRequest.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_refund(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
}
@dataclass
class WalletRefundDetailAdapter(AuthenticatedApiAdapter):
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == self.refund_id, RefundRequest.user_id == user.id)
.first()
)
if refund is None:
raise NotFoundException("Refund request not found")
return serialize_wallet_refund(refund)
class WalletRefundCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRefundPayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise InvalidRequestException("当前账户尚未开通钱包,无法申请退款")
payment_order = None
source_type = req.source_type or "wallet_balance"
source_id = req.source_id
refund_mode = req.refund_mode or "offline_payout"
if req.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(
PaymentOrder.id == req.payment_order_id,
PaymentOrder.wallet_id == wallet.id,
)
.first()
)
if payment_order is None:
raise NotFoundException("Payment order not found")
source_type = "payment_order"
source_id = payment_order.id
refund_mode = req.refund_mode or _default_refund_mode_for_order(payment_order)
try:
refund = WalletService.create_refund_request(
db,
wallet=wallet,
user_id=user.id,
amount_usd=req.amount_usd,
refund_no=_build_refund_no(),
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
payment_order=payment_order,
reason=req.reason,
requested_by=user.id,
idempotency_key=req.idempotency_key,
)
db.commit()
db.refresh(refund)
return serialize_wallet_refund(refund)
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc)) from exc
except IntegrityError:
db.rollback()
if req.idempotency_key:
existing = (
db.query(RefundRequest)
.filter(
RefundRequest.idempotency_key == req.idempotency_key,
RefundRequest.user_id == user.id,
)
.first()
)
if existing is not None:
return serialize_wallet_refund(existing)
raise InvalidRequestException("退款申请重复,请勿重复提交")
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc))
+8
View File
@@ -103,6 +103,9 @@ class Config:
# API Key 配置
self.api_key_prefix = os.getenv("API_KEY_PREFIX", "sk")
# 支付回调安全配置(公开回调入口必须携带该共享密钥)
self.payment_callback_secret = os.getenv("PAYMENT_CALLBACK_SECRET", "").strip()
# LLM API 速率限制配置(每分钟请求数)
self.llm_api_rate_limit = int(os.getenv("LLM_API_RATE_LIMIT", "100"))
self.public_api_rate_limit = int(os.getenv("PUBLIC_API_RATE_LIMIT", "60"))
@@ -458,6 +461,11 @@ class Config:
# CORS 配置警告(生产环境)
if self.environment == "production" and not self.cors_origins:
logger.warning("生产环境 CORS 未配置,前端将无法访问 API。请设置 CORS_ORIGINS。")
if self.environment == "production" and not self.payment_callback_secret:
logger.warning(
"生产环境未设置 PAYMENT_CALLBACK_SECRET,支付回调将被拒绝。"
"如需启用支付回调,请配置共享密钥。"
)
def validate_security_config(self) -> list[str]:
"""
+16 -9
View File
@@ -63,7 +63,7 @@ FIELD_NAME_TRANSLATIONS = {
"username": "用户名",
"email": "邮箱",
"role": "角色",
"quota_usd": "配额",
"initial_gift_usd": "初始赠款",
"name": "名称",
"title": "标题",
"content": "内容",
@@ -261,18 +261,25 @@ class ProviderRateLimitException(ProviderException):
)
class QuotaExceededException(ProxyException):
"""配额超限"""
class BalanceInsufficientException(ProxyException):
"""余额或额度不足"""
def __init__(self, quota_type: str = "tokens", remaining: float | None = None):
message = f"{quota_type}配额已用尽"
if remaining is not None:
message += f"(剩余: {remaining})"
def __init__(self, balance_type: str = "tokens", remaining: float | None = None, **kwargs: Any):
# 兼容旧调用方使用 quota_type= 关键字参数
balance_type = kwargs.get("quota_type", balance_type)
if balance_type.upper() == "USD":
message = "余额不足"
if remaining is not None:
message += f"(剩余: ${remaining:.2f})"
else:
message = f"{balance_type}额度已用尽"
if remaining is not None:
message += f"(剩余: {remaining})"
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
error_type="quota_exceeded",
error_type="balance_exceeded",
message=message,
details={"quota_type": quota_type, "remaining": remaining},
details={"balance_type": balance_type, "remaining": remaining},
)
+1 -2
View File
@@ -2,7 +2,7 @@
数据库模块
"""
from ..models.database import ApiKey, Base, Usage, User, UserQuota
from ..models.database import ApiKey, Base, Usage, User
from .database import create_session, get_db, get_db_context, get_db_url, init_db, log_pool_status
__all__ = [
@@ -10,7 +10,6 @@ __all__ = [
"User",
"ApiKey",
"Usage",
"UserQuota",
"get_db",
"get_db_context",
"init_db",
+15 -3
View File
@@ -406,12 +406,21 @@ def init_admin_user(db: Session) -> None:
role=UserRole.ADMIN,
is_active=True,
)
admin.quota_usd = cast(Any, 1000.0)
admin.set_password(config.admin_password)
db.add(admin)
db.flush() # 分配ID,但不提交事务(由外层 init_db 统一 commit)
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=admin,
initial_gift_usd=0,
unlimited=True,
description="系统管理员初始化钱包",
)
logger.info(f"创建管理员账户成功: {admin.email} ({admin.username})")
except Exception as e:
logger.error(f"创建管理员账户失败: {e}")
@@ -429,9 +438,12 @@ def init_default_models(db: Session) -> None:
def init_system_configs(db: Session) -> None:
"""初始化系统配置"""
configs: list[dict[str, Any]] = [
{"key": "default_user_quota_usd", "value": 10.0, "description": "新用户默认美元配额"},
{
"key": "default_user_initial_gift_usd",
"value": 10.0,
"description": "新用户默认初始赠款(美元)",
},
{"key": "rate_limit_per_minute", "value": 60, "description": "每分钟请求限制"},
{"key": "enable_registration", "value": False, "description": "是否开放用户注册"},
{"key": "require_email_verification", "value": False, "description": "是否需要邮箱验证"},
+4
View File
@@ -20,8 +20,10 @@ from src.api.announcements import router as announcement_router
from src.api.auth import router as auth_router
from src.api.dashboard import router as dashboard_router
from src.api.monitoring import router as monitoring_router
from src.api.payment import router as payment_router
from src.api.public import router as public_router
from src.api.user_me import router as me_router
from src.api.wallet import router as wallet_router
from src.clients.http_client import HTTPClientPool, close_http_clients
# 核心模块
@@ -599,6 +601,8 @@ else:
app.include_router(auth_router) # 认证相关
app.include_router(admin_router) # 管理员端点
app.include_router(me_router) # 用户个人端点
app.include_router(wallet_router) # 钱包端点
app.include_router(payment_router) # 支付回调端点
app.include_router(announcement_router) # 公告系统
app.include_router(dashboard_router) # 仪表盘端点
app.include_router(public_router) # 公开API端点(用户可查看提供商和模型)
+1 -1
View File
@@ -689,7 +689,7 @@ class UpdateUserRequest(BaseModel):
password: str | None = Field(
None, min_length=6, max_length=128, description="新密码(留空保持不变)"
)
quota_usd: float | None = Field(None, ge=0)
unlimited: bool | None = Field(None, description="是否无限制(true=无限制,false=有限制)")
is_active: bool | None = None
role: str | None = None
allowed_providers: list[str] | None = Field(None, description="允许使用的提供商 ID 列表")
+14 -24
View File
@@ -241,8 +241,10 @@ class CreateUserRequest(BaseModel):
password: str = Field(..., min_length=6, max_length=128, description="密码")
email: str | None = Field(None, max_length=255, description="邮箱地址(可选)")
role: UserRole | None = Field(UserRole.USER, description="用户角色")
quota_usd: float | None = Field(default=None, description="USD配额,null表示使用系统默认配额")
unlimited: bool = Field(default=False, description="是否无限配额")
initial_gift_usd: float | None = Field(
default=None, description="初始赠款(USD),null 表示使用系统默认初始赠款"
)
unlimited: bool = Field(default=False, description="是否无限制")
# 访问限制字段
allowed_providers: list[str] | None = Field(
default=None, description="允许使用的提供商ID列表,null表示无限制"
@@ -254,16 +256,16 @@ class CreateUserRequest(BaseModel):
default=None, description="允许使用的模型名称列表,null表示无限制"
)
@field_validator("quota_usd", mode="before")
@field_validator("initial_gift_usd", mode="before")
@classmethod
def validate_quota_usd(cls, v: Any) -> Any:
"""验证配额值,null表示使用系统默认配额"""
def validate_initial_gift_usd(cls, v: Any) -> Any:
"""验证初始赠款金额,null 表示使用系统默认初始赠款。"""
if v is None:
return None
if isinstance(v, (int, float)) and v >= 0 and v <= 10000:
return float(v)
if isinstance(v, (int, float)):
raise ValueError("配额必须在 0-10000 范围内")
raise ValueError("初始赠款必须在 0-10000 范围内")
return v
@field_validator("email")
@@ -337,10 +339,10 @@ class UpdateUserRequest(BaseModel):
username: str | None = None
password: str | None = None
role: UserRole | None = None
unlimited: bool | None = None
allowed_providers: list[str] | None = None # 允许使用的提供商 ID 列表
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
quota_usd: float | None = None
is_active: bool | None = None
@field_validator("allowed_api_formats")
@@ -349,18 +351,6 @@ class UpdateUserRequest(BaseModel):
# 与 CreateUserRequest 保持一致
return CreateUserRequest.validate_allowed_api_formats(v)
@field_validator("quota_usd", mode="before")
@classmethod
def validate_quota_usd(cls, v: Any) -> Any:
"""验证配额值,允许null表示无限制"""
if v is None:
return None
if isinstance(v, (int, float)) and v >= 0 and v <= 10000:
return float(v)
if isinstance(v, (int, float)):
raise ValueError("配额必须在 0-10000 范围内")
return v
class CreateApiKeyRequest(BaseModel):
"""创建API密钥请求"""
@@ -370,11 +360,14 @@ class CreateApiKeyRequest(BaseModel):
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
rate_limit: int | None = None # None = 无限制
expire_days: int | None = None # None = 永不过期,数字 = 多少天后过期(兼容旧版)
expire_days: int | None = None # None = 永不过期,数字 = 多少天后过期
expires_at: str | None = None # ISO 日期字符串,如 "2025-12-31",优先于 expire_days
initial_balance_usd: float | None = Field(
None, description="初始余额(USD),仅用于独立Key,None = 无限制"
)
unlimited_balance: bool | None = Field(
None, description="是否无限余额(编辑独立Key时用于切换额度模式)"
)
is_standalone: bool = Field(False, description="是否为独立余额Key(给非注册用户使用)")
auto_delete_on_expiry: bool = Field(
False, description="过期后是否自动删除(True=物理删除,False=仅禁用)"
@@ -397,8 +390,7 @@ class UserResponse(BaseModel):
allowed_providers: list[str] | None = None # 允许使用的提供商 ID 列表
allowed_api_formats: list[str] | None = None # 允许使用的 API 格式列表
allowed_models: list[str] | None = None # 允许使用的模型名称列表
quota_usd: float
used_usd: float
unlimited: bool = False
is_active: bool
created_at: datetime
updated_at: datetime
@@ -421,8 +413,6 @@ class ApiKeyResponse(BaseModel):
rate_limit: int
is_active: bool
expires_at: datetime | None = None
balance_used_usd: float = 0.0
current_balance_usd: float | None = None # NULL = 无限制
is_standalone: bool = False
force_capabilities: dict[str, bool] | None = None # 强制开启的能力
created_at: datetime
+265 -53
View File
@@ -25,6 +25,7 @@ from sqlalchemy import (
Index,
Integer,
LargeBinary,
Numeric,
String,
Text,
UniqueConstraint,
@@ -71,13 +72,13 @@ class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# OAuth 用户可能没有邮箱;Postgres unique 允许多个 NULL
email = Column(String(255), unique=True, index=True, nullable=True)
# 注意:所有创建用户的入口必须显式写入 true/false,禁止依赖默认值
email_verified = Column(Boolean, nullable=False)
username = Column(String(100), unique=True, index=True, nullable=False)
# OAuth 用户可能没有本地密码(v1 仅做字段兼容)
# OAuth 用户可能没有本地密码
password_hash = Column(String(255), nullable=True)
role = Column(
Enum(
@@ -113,11 +114,6 @@ class User(Base):
model_capability_settings = Column(JSON, nullable=True) # 用户针对特定模型的能力配置
# 示例: {"claude-sonnet-4-20250514": {"cache_1h": true}}
# 配额管理
quota_usd = Column(Float, nullable=True) # 美元配额(NULL 表示无限制)
used_usd = Column(Float, default=0.0) # 当前周期已使用美元
total_usd = Column(Float, default=0.0) # 累积消费总额
# 状态
is_active = Column(Boolean, default=True, nullable=False)
is_deleted = Column(Boolean, default=False, nullable=False)
@@ -142,9 +138,6 @@ class User(Base):
preferences = relationship(
"UserPreference", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
quotas = relationship(
"UserQuota", back_populates="user", cascade="all, delete-orphan", passive_deletes=True
)
announcement_reads = relationship(
"AnnouncementRead",
back_populates="user",
@@ -154,6 +147,14 @@ class User(Base):
# 关系 - SET NULL: 保留历史记录,让数据库处理 SET NULL
usage_records = relationship("Usage", back_populates="user", passive_deletes=True)
wallet = relationship("Wallet", back_populates="user", uselist=False, passive_deletes=True)
payment_orders = relationship("PaymentOrder", back_populates="user", passive_deletes=True)
refund_requests = relationship(
"RefundRequest",
back_populates="user",
passive_deletes=True,
foreign_keys="RefundRequest.user_id",
)
authored_announcements = relationship(
"Announcement",
back_populates="author",
@@ -179,8 +180,14 @@ class ApiKey(Base):
"""API密钥模型"""
__tablename__ = "api_keys"
__table_args__ = (
CheckConstraint(
"(NOT is_standalone) OR (NOT is_locked)",
name="ck_api_keys_standalone_not_locked",
),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
key_hash = Column(String(64), unique=True, index=True, nullable=False) # API密钥的SHA256哈希
key_encrypted = Column(Text, nullable=True) # 加密后的完整密钥,用于查看
@@ -190,9 +197,7 @@ class ApiKey(Base):
total_requests = Column(Integer, default=0)
total_cost_usd = Column(Float, default=0.0)
# 余额管理(仅用于独立余额 Key)
balance_used_usd = Column(Float, default=0.0) # 已使用余额(USD),用于统计
current_balance_usd = Column(Float, nullable=True) # 当前余额(USD),NULL 表示无限制
# 钱包体系:余额/额度由 wallets 表统一管理
is_standalone = Column(
Boolean, default=False, nullable=False
) # 是否为独立余额 Key(给非注册用户使用)
@@ -210,7 +215,7 @@ class ApiKey(Base):
# 状态
is_active = Column(Boolean, default=True, nullable=False)
is_locked = Column(Boolean, default=False, nullable=False) # 管理员锁定,用户无法使用/操作
is_locked = Column(Boolean, default=False, nullable=False) # 仅普通用户Key可锁定
last_used_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间
auto_delete_on_expiry = Column(Boolean, default=False, nullable=False) # 过期后是否自动删除
@@ -229,6 +234,7 @@ class ApiKey(Base):
# 关系
user = relationship("User", back_populates="api_keys")
usage_records = relationship("Usage", back_populates="api_key")
wallet = relationship("Wallet", back_populates="api_key", uselist=False, passive_deletes=True)
provider_mappings = relationship(
"ApiKeyProviderMapping", back_populates="api_key", cascade="all, delete-orphan"
)
@@ -310,9 +316,10 @@ class Usage(Base):
Index("idx_usage_provider_key", "provider_id", "provider_api_key_id"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
api_key_id = Column(String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="SET NULL"), nullable=True)
# 请求信息
request_id = Column(String(100), unique=True, index=True, nullable=False)
@@ -345,7 +352,7 @@ class Usage(Base):
# 成本计算
input_cost_usd = Column(Float, default=0.0)
output_cost_usd = Column(Float, default=0.0)
cache_cost_usd = Column(Float, default=0.0) # 总缓存成本(兼容旧数据)
cache_cost_usd = Column(Float, default=0.0) # 总缓存成本
cache_creation_cost_usd = Column(Float, default=0.0) # 缓存创建成本
cache_read_cost_usd = Column(Float, default=0.0) # 缓存读取成本
request_cost_usd = Column(Float, default=0.0) # 按次计费成本
@@ -397,6 +404,12 @@ class Usage(Base):
# - void: 作废(不收费,如任务未开始就取消)
billing_status = Column(String(20), default="settled", nullable=False, index=True)
finalized_at = Column(DateTime(timezone=True), nullable=True) # 结算完成时间(可选)
wallet_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前可用总余额快照
wallet_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后可用总余额快照
wallet_recharge_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前充值余额
wallet_recharge_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后充值余额
wallet_gift_balance_before = Column(Numeric(20, 8), nullable=True) # 结算前赠款余额
wallet_gift_balance_after = Column(Numeric(20, 8), nullable=True) # 结算后赠款余额
# 完整请求和响应记录
request_headers = Column(JSON, nullable=True) # 客户端请求头
@@ -428,6 +441,7 @@ class Usage(Base):
# 关系
user = relationship("User", back_populates="usage_records")
api_key = relationship("ApiKey", back_populates="usage_records")
wallet = relationship("Wallet", back_populates="usage_records")
provider_obj = relationship("Provider") # 使用 provider_obj 避免与 provider 字段名冲突
provider_endpoint = relationship("ProviderEndpoint")
provider_api_key = relationship("ProviderAPIKey")
@@ -473,31 +487,45 @@ class Usage(Base):
return None
class UserQuota(Base):
"""用户配额历史记录"""
class Wallet(Base):
"""统一钱包模型(用户钱包 / 独立 API Key 钱包)"""
__tablename__ = "user_quotas"
__tablename__ = "wallets"
__table_args__ = (
CheckConstraint(
# 活跃钱包必须归属唯一 owner;owner 被删除后允许双 NULL(孤立钱包由清理策略回收)。
"(user_id IS NOT NULL AND api_key_id IS NULL) "
"OR (user_id IS NULL AND api_key_id IS NOT NULL) "
"OR (user_id IS NULL AND api_key_id IS NULL)",
name="ck_wallet_single_owner",
),
CheckConstraint("gift_balance >= 0", name="ck_wallets_gift_balance_non_negative"),
# user_id/api_key_id 的 unique=True 已隐含唯一索引,无需额外 Index
Index("idx_wallets_status", "status"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, unique=True
)
api_key_id = Column(
String(36), ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, unique=True
)
# 配额类型
quota_type = Column(String(50), nullable=False) # monthly, daily, custom
# balance: 充值余额(可退款余额)
balance = Column(Numeric(20, 8), nullable=False, default=0)
# gift_balance: 赠款余额(不可退款)
gift_balance = Column(Numeric(20, 8), nullable=False, default=0)
# finite: 按余额校验;unlimited: 忽略余额放行,但仍统计消费
limit_mode = Column(String(20), nullable=False, default="finite")
currency = Column(String(3), nullable=False, default="USD")
status = Column(String(20), nullable=False, default="active")
# 配额值
quota_usd = Column(Float, nullable=False)
total_recharged = Column(Numeric(20, 8), nullable=False, default=0)
total_consumed = Column(Numeric(20, 8), nullable=False, default=0)
total_refunded = Column(Numeric(20, 8), nullable=False, default=0)
total_adjusted = Column(Numeric(20, 8), nullable=False, default=0)
# 时间范围
period_start = Column(DateTime(timezone=True), nullable=False)
period_end = Column(DateTime(timezone=True), nullable=False)
# 使用情况
used_usd = Column(Float, default=0.0)
# 状态
is_active = Column(Boolean, default=True)
# 时间戳
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
@@ -508,8 +536,192 @@ class UserQuota(Base):
nullable=False,
)
# 关系
user = relationship("User", back_populates="quotas")
user = relationship("User", back_populates="wallet")
api_key = relationship("ApiKey", back_populates="wallet")
usage_records = relationship("Usage", back_populates="wallet")
transactions = relationship(
"WalletTransaction", back_populates="wallet", cascade="all, delete-orphan"
)
payment_orders = relationship("PaymentOrder", back_populates="wallet")
refund_requests = relationship("RefundRequest", back_populates="wallet")
class WalletTransaction(Base):
"""钱包资金流水(只记录资金动作,不重复记录每次请求消费)"""
__tablename__ = "wallet_transactions"
__table_args__ = (
CheckConstraint(
"balance_before = recharge_balance_before + gift_balance_before",
name="ck_wallet_tx_balance_before_consistent",
),
CheckConstraint(
"balance_after = recharge_balance_after + gift_balance_after",
name="ck_wallet_tx_balance_after_consistent",
),
Index("idx_wallet_tx_wallet_created", "wallet_id", "created_at"),
Index("idx_wallet_tx_link", "link_type", "link_id"),
Index("idx_wallet_tx_category_created", "category", "created_at"),
Index("idx_wallet_tx_reason_created", "reason_code", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="CASCADE"), nullable=False)
category = Column(String(20), nullable=False)
reason_code = Column(String(40), nullable=False)
amount = Column(Numeric(20, 8), nullable=False)
# 总可用余额(充值+赠款)快照
balance_before = Column(Numeric(20, 8), nullable=False)
balance_after = Column(Numeric(20, 8), nullable=False)
# 分账户快照(审计用)
recharge_balance_before = Column(Numeric(20, 8), nullable=False)
recharge_balance_after = Column(Numeric(20, 8), nullable=False)
gift_balance_before = Column(Numeric(20, 8), nullable=False)
gift_balance_after = Column(Numeric(20, 8), nullable=False)
link_type = Column(String(30), nullable=True)
link_id = Column(String(100), nullable=True)
operator_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
description = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
wallet = relationship("Wallet", back_populates="transactions")
operator = relationship("User")
class PaymentOrder(Base):
"""充值订单"""
__tablename__ = "payment_orders"
__table_args__ = (
UniqueConstraint("order_no", name="uq_payment_orders_order_no"),
Index("idx_payment_orders_wallet_created", "wallet_id", "created_at"),
Index("idx_payment_orders_user_created", "user_id", "created_at"),
Index("idx_payment_orders_status", "status"),
Index("idx_payment_orders_gateway_order_id", "gateway_order_id"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
order_no = Column(String(64), nullable=False)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
amount_usd = Column(Numeric(20, 8), nullable=False)
pay_amount = Column(Numeric(20, 2), nullable=True)
pay_currency = Column(String(3), nullable=True)
exchange_rate = Column(Numeric(18, 8), nullable=True)
refunded_amount_usd = Column(Numeric(20, 8), nullable=False, default=0)
refundable_amount_usd = Column(Numeric(20, 8), nullable=False, default=0)
payment_method = Column(String(30), nullable=False)
gateway_order_id = Column(String(128), nullable=True)
gateway_response = Column(JSONB, nullable=True)
status = Column(String(20), nullable=False, default="pending")
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
paid_at = Column(DateTime(timezone=True), nullable=True)
credited_at = Column(DateTime(timezone=True), nullable=True)
expires_at = Column(DateTime(timezone=True), nullable=True)
wallet = relationship("Wallet", back_populates="payment_orders")
user = relationship("User", back_populates="payment_orders")
callbacks = relationship("PaymentCallback", back_populates="payment_order")
refund_requests = relationship("RefundRequest", back_populates="payment_order")
class PaymentCallback(Base):
"""支付回调日志(幂等与审计)"""
__tablename__ = "payment_callbacks"
__table_args__ = (
UniqueConstraint("callback_key", name="uq_payment_callbacks_callback_key"),
Index("idx_payment_callbacks_order", "order_no"),
Index("idx_payment_callbacks_gateway_order", "gateway_order_id"),
Index("idx_payment_callbacks_created", "created_at"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
payment_method = Column(String(30), nullable=False)
callback_key = Column(String(128), nullable=False)
order_no = Column(String(64), nullable=True)
gateway_order_id = Column(String(128), nullable=True)
payload_hash = Column(String(128), nullable=True)
signature_valid = Column(Boolean, nullable=False, default=False)
status = Column(String(20), nullable=False, default="received")
payload = Column(JSONB, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
processed_at = Column(DateTime(timezone=True), nullable=True)
payment_order = relationship("PaymentOrder", back_populates="callbacks")
class RefundRequest(Base):
"""退款申请(原路退款 / 非原路人工打款)"""
__tablename__ = "refund_requests"
__table_args__ = (
UniqueConstraint("refund_no", name="uq_refund_requests_refund_no"),
UniqueConstraint("idempotency_key", name="uq_refund_requests_idempotency_key"),
Index("idx_refund_wallet_created", "wallet_id", "created_at"),
Index("idx_refund_user_created", "user_id", "created_at"),
Index("idx_refund_status", "status"),
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
refund_no = Column(String(64), nullable=False)
wallet_id = Column(String(36), ForeignKey("wallets.id", ondelete="RESTRICT"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
payment_order_id = Column(
String(36), ForeignKey("payment_orders.id", ondelete="SET NULL"), nullable=True
)
source_type = Column(String(30), nullable=False) # payment_order/manual_recharge/card_recharge
source_id = Column(String(100), nullable=True)
refund_mode = Column(String(30), nullable=False) # original_channel/offline_payout
amount_usd = Column(Numeric(20, 8), nullable=False)
status = Column(String(30), nullable=False, default="pending_approval")
reason = Column(Text, nullable=True)
requested_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
approved_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
processed_by = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
gateway_refund_id = Column(String(128), nullable=True)
payout_method = Column(String(50), nullable=True)
payout_reference = Column(String(255), nullable=True)
payout_proof = Column(JSONB, nullable=True)
failure_reason = Column(Text, nullable=True)
idempotency_key = Column(String(128), nullable=True)
created_at = Column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False
)
updated_at = Column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
nullable=False,
)
processed_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
wallet = relationship("Wallet", back_populates="refund_requests")
user = relationship("User", back_populates="refund_requests", foreign_keys=[user_id])
payment_order = relationship("PaymentOrder", back_populates="refund_requests")
class SystemConfig(Base):
@@ -517,7 +729,7 @@ class SystemConfig(Base):
__tablename__ = "system_configs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
key = Column(String(100), unique=True, nullable=False)
value = Column(JSON, nullable=False)
description = Column(Text, nullable=True)
@@ -703,7 +915,7 @@ class Provider(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True) # 提供商名称(唯一)
description = Column(Text, nullable=True) # 提供商描述
website = Column(String(500), nullable=True) # 主站网站
@@ -814,7 +1026,7 @@ class ProviderEndpoint(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
provider_id = Column(String(36), ForeignKey("providers.id", ondelete="CASCADE"), nullable=False)
# API 格式和配置
@@ -886,7 +1098,7 @@ class ProxyNode(Base):
__tablename__ = "proxy_nodes"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), nullable=False) # 节点名
ip = Column(String(512), nullable=False) # 公网 IP 或手动节点的主机名(含协议前缀)
port = Column(Integer, nullable=False) # 代理端口
@@ -1016,7 +1228,7 @@ class GlobalModel(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
name = Column(String(100), unique=True, nullable=False, index=True) # 统一模型名(唯一)
display_name = Column(String(100), nullable=False)
@@ -1116,7 +1328,7 @@ class Model(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
provider_id = Column(String(36), ForeignKey("providers.id"), nullable=False)
# 必须关联一个 GlobalModel
global_model_id = Column(String(36), ForeignKey("global_models.id"), nullable=False, index=True)
@@ -1540,7 +1752,7 @@ class ProviderAPIKey(ExportMixin, Base):
}
)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 外键关系 - 直接关联 Provider
provider_id = Column(
@@ -1548,7 +1760,7 @@ class ProviderAPIKey(ExportMixin, Base):
)
# API 格式支持列表(核心字段)
# None 表示支持所有格式(兼容历史数据),空列表 [] 表示不支持任何格式
# None 表示支持所有格式,空列表 [] 表示不支持任何格式
api_formats = Column(JSON, nullable=True, default=list) # ["claude:chat", "claude:cli"]
# 认证类型
@@ -1799,7 +2011,7 @@ class UserPreference(Base):
__tablename__ = "user_preferences"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False
)
@@ -1840,7 +2052,7 @@ class Announcement(Base):
__tablename__ = "announcements"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String(200), nullable=False)
content = Column(Text, nullable=False) # 支持 Markdown
type = Column(String(20), default="info") # info, warning, maintenance, important
@@ -1881,7 +2093,7 @@ class AnnouncementRead(Base):
__tablename__ = "announcement_reads"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
announcement_id = Column(String(36), ForeignKey("announcements.id"), nullable=False)
read_at = Column(
@@ -1945,7 +2157,7 @@ class ManagementToken(Base):
TOKEN_PREFIX = "ae_"
TOKEN_RANDOM_LENGTH = 40
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
# Token 信息
@@ -2119,7 +2331,7 @@ class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
event_type = Column(String(50), nullable=False, index=True)
user_id = Column(
String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
@@ -2696,7 +2908,7 @@ class GeminiFileMapping(Base):
__tablename__ = "gemini_file_mappings"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), index=True)
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
# 文件名(如 files/abc123xyz)
file_name = Column(String(255), nullable=False, unique=True, index=True)
+12 -8
View File
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.services.auth.service import AuthService
from src.services.usage.service import UsageService
from src.services.wallet import WalletService
from .base import AuthContext, AuthPlugin
@@ -62,8 +63,13 @@ class ApiKeyAuthPlugin(AuthPlugin):
user, api_key_obj = auth_result
# 检查用户配额或独立Key余额
quota_ok, message = UsageService.check_user_quota(db, user, api_key=api_key_obj)
# 检查用户或独立 Key 的钱包余额可用性
access_ok, message = UsageService.check_request_balance(db, user, api_key=api_key_obj)
billing_wallet = (
WalletService.get_wallet(db, api_key_id=api_key_obj.id)
if api_key_obj.is_standalone
else WalletService.get_wallet(db, user_id=user.id)
)
# 创建认证上下文
auth_context = AuthContext(
@@ -72,15 +78,13 @@ class ApiKeyAuthPlugin(AuthPlugin):
api_key_id=api_key_obj.id,
api_key_name=api_key_obj.name if hasattr(api_key_obj, "name") else None,
permissions={
"can_use_api": quota_ok,
"can_use_api": access_ok,
"is_admin": user.is_admin if hasattr(user, "is_admin") else False,
"is_standalone_key": api_key_obj.is_standalone, # 标记是否为独立余额Key
},
quota_info={
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"remaining_usd": None if user.quota_usd is None else user.quota_usd - user.used_usd,
"quota_ok": quota_ok,
billing_info={
"billing": WalletService.serialize_wallet_summary(billing_wallet),
"balance_ok": access_ok,
"message": message,
},
metadata={
+1 -1
View File
@@ -27,7 +27,7 @@ class AuthContext:
api_key_id: int | None = None
api_key_name: str | None = None
permissions: dict[str, bool] = None
quota_info: dict[str, Any] = None
billing_info: dict[str, Any] = None
metadata: dict[str, Any] = None
def __post_init__(self) -> None:
+12 -7
View File
@@ -13,6 +13,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import User
from src.services.auth.service import AuthService
from src.services.wallet import WalletService
from .base import AuthContext, AuthPlugin
@@ -82,18 +83,22 @@ class JwtAuthPlugin(AuthPlugin):
logger.warning("JWT认证失败 - Token身份校验失败")
return None
wallet_access = WalletService.check_request_allowed(db, user=user, api_key=None)
# 创建认证上下文
auth_context = AuthContext(
user_id=user.id,
user_name=user.username,
permissions={"can_use_api": True, "is_admin": user.role.value == "admin"},
quota_info={
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"remaining_usd": (
None if user.quota_usd is None else user.quota_usd - user.used_usd
permissions={
"can_use_api": wallet_access.allowed,
"is_admin": user.role.value == "admin",
},
billing_info={
"billing": WalletService.serialize_wallet_summary(
WalletService.get_wallet(db, user_id=user.id)
),
"quota_ok": True, # JWT用户通常已经通过前端验证
"balance_ok": wallet_access.allowed,
"message": wallet_access.message,
},
metadata={
"auth_method": "jwt",
+15 -2
View File
@@ -428,7 +428,9 @@ class OAuthService:
or (email.split("@", 1)[0] if email else None)
or f"user_{uuid.uuid4().hex[:8]}"
)
default_quota = SystemConfigService.get_config(db, "default_user_quota_usd", default=10.0)
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 生成唯一用户名 + 创建用户(简单重试)
user: User | None = None
@@ -445,9 +447,20 @@ class OAuthService:
role=UserRole.USER,
is_active=True,
last_login_at=now,
quota_usd=default_quota,
)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="OAuth 注册初始赠款",
)
db.commit()
db.refresh(user)
last_error = None
+28 -30
View File
@@ -31,7 +31,6 @@ if TYPE_CHECKING:
from src.models.database import ApiKey, User, UserRole
from src.services.auth.jwt_blacklist import JWTBlacklistService
from src.services.cache.user_cache import UserCacheService
from src.services.user.apikey import ApiKeyService
# API Key last_used_at 更新节流配置
# 同一个 API Key 在此时间间隔内只会更新一次 last_used_at
@@ -336,9 +335,9 @@ class AuthService:
username = f"{base_username}_ldap_{int(time.time())}{uuid.uuid4().hex[:4]}"
logger.info(f"LDAP 用户名冲突,使用新用户名: {ldap_user['username']} -> {username}")
# 读取系统配置的默认配额
default_quota = SystemConfigService.get_config(
db, "default_user_quota_usd", default=10.0
# 读取系统配置的默认初始赠款
default_initial_gift = SystemConfigService.get_config(
db, "default_user_initial_gift_usd", default=None
)
# 创建新用户
@@ -353,11 +352,22 @@ class AuthService:
role=UserRole.USER,
is_active=True,
last_login_at=datetime.now(timezone.utc),
quota_usd=default_quota,
)
try:
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=default_initial_gift,
unlimited=False,
description="LDAP 注册初始赠款",
)
db.commit()
db.refresh(user)
logger.info(f"LDAP 用户创建成功: {ldap_user['email']} (ID: {user.id})")
@@ -408,7 +418,7 @@ class AuthService:
logger.warning("API认证失败 - 密钥已禁用")
return None
if key_record.is_locked:
if key_record.is_locked and not key_record.is_standalone:
logger.warning("API认证失败 - 密钥已被管理员锁定")
raise ForbiddenException("该密钥已被管理员锁定,请联系管理员")
@@ -424,17 +434,6 @@ class AuthService:
logger.warning("API认证失败 - 密钥已过期")
return None
# 检查余额限制(仅独立Key)
is_balance_ok, remaining = ApiKeyService.check_balance(key_record)
if not is_balance_ok:
# 获取剩余余额用于日志
remaining_balance = ApiKeyService.get_remaining_balance(key_record)
logger.warning(
f"API认证失败 - 余额不足 "
f"(已用: ${key_record.balance_used_usd:.4f}, 剩余: ${remaining_balance:.4f})"
)
return None
# 获取用户
user = key_record.user
if not user.is_active:
@@ -467,23 +466,22 @@ class AuthService:
return user, key_record
@staticmethod
def check_user_quota(user: User, estimated_cost: float = 0) -> bool:
"""检查用户配额"""
if user.role == UserRole.ADMIN:
return True # 管理员无限制
def check_user_balance_access(user: User, estimated_cost: float = 0) -> bool:
"""按钱包余额/额度模式校验请求可用性。"""
from src.services.wallet import WalletService
# NULL 表示无限制
if user.quota_usd is None:
_ = estimated_cost
if user.role == UserRole.ADMIN:
return True
# 检查美元配额
if user.used_usd + estimated_cost > user.quota_usd:
logger.warning(
f"用户配额不足: {user.email} (已用: ${user.used_usd:.2f}, 配额: ${user.quota_usd:.2f})"
)
wallet = getattr(user, "wallet", None)
if wallet is None:
return False
return True
if wallet.status != "active":
return False
if WalletService.is_unlimited_wallet(wallet):
return True
return WalletService.get_spendable_balance_value(wallet) > 0
@staticmethod
def check_permission(user: User, required_role: UserRole = UserRole.USER) -> bool:
+5
View File
@@ -31,6 +31,11 @@ def to_decimal(value: float | int | str | Decimal | None) -> Decimal:
return Decimal(str(value))
def to_money_decimal(value: float | int | str | Decimal | None) -> Decimal:
"""Convert values to Decimal and quantize to billing storage precision."""
return quantize_cost(to_decimal(value))
def quantize_decimal(value: Decimal, *, precision: int) -> Decimal:
"""Quantize a Decimal to the given number of decimal places (ROUND_HALF_UP)."""
quantizer = Decimal(10) ** -precision
-6
View File
@@ -133,8 +133,6 @@ class UserCacheService:
"role": user.role.value if user.role else None,
"is_active": user.is_active,
"auth_source": user.auth_source.value if user.auth_source else None,
"quota_usd": float(user.quota_usd) if user.quota_usd is not None else None,
"used_usd": float(user.used_usd),
"created_at": user.created_at.isoformat() if user.created_at else None,
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
"model_capability_settings": user.model_capability_settings,
@@ -159,7 +157,6 @@ class UserCacheService:
email_verified=user_dict.get("email_verified", False),
username=user_dict["username"],
is_active=user_dict["is_active"],
used_usd=user_dict["used_usd"],
)
# 设置可选字段
@@ -169,9 +166,6 @@ class UserCacheService:
if user_dict.get("auth_source"):
user.auth_source = AuthSource(user_dict["auth_source"])
if user_dict.get("quota_usd") is not None:
user.quota_usd = user_dict["quota_usd"]
if user_dict.get("created_at"):
user.created_at = datetime.fromisoformat(user_dict["created_at"])
+3
View File
@@ -0,0 +1,3 @@
from src.services.payment.service import PaymentService
__all__ = ["PaymentService"]
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from src.services.payment.gateway.alipay import AlipayGateway
from src.services.payment.gateway.base import PaymentGateway
from src.services.payment.gateway.manual import ManualGateway
from src.services.payment.gateway.wechat import WeChatGateway
_GATEWAYS: dict[str, PaymentGateway] = {
"alipay": AlipayGateway(),
"wechat": WeChatGateway(),
"manual": ManualGateway(),
}
def get_payment_gateway(payment_method: str) -> PaymentGateway:
key = (payment_method or "").strip().lower()
gateway = _GATEWAYS.get(key)
if gateway is None:
raise ValueError(f"unsupported payment_method: {payment_method}")
return gateway
__all__ = ["PaymentGateway", "get_payment_gateway"]
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class AlipayGateway(PaymentGateway):
payment_method = "alipay"
display_name = "支付宝"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"ali_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": f"/pay/mock/alipay/{order.order_no}",
"qr_code": f"mock://alipay/{order.order_no}",
"expires_at": getattr(order, "expires_at", None),
}
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import hashlib
import hmac
import json
from abc import ABC, abstractmethod
from typing import Any
class PaymentGateway(ABC):
"""支付网关抽象。
当前阶段只提供统一结构和占位返回,便于后续接入真实 SDK。
"""
payment_method: str
display_name: str
@abstractmethod
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
"""为前端返回统一的支付指引结构。"""
@staticmethod
def build_callback_signature(
*,
payload: dict[str, Any] | None,
callback_secret: str | None,
) -> str | None:
if payload is None:
return None
if not callback_secret:
return None
canonical = json.dumps(
payload,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"),
default=str,
)
return hmac.new(
callback_secret.encode("utf-8"),
canonical.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def verify_callback_payload(
self,
*,
payload: dict[str, Any] | None,
callback_signature: str | None = None,
callback_secret: str | None = None,
) -> bool:
"""校验回调。
默认使用 HMAC-SHA256 对 payload 进行签名校验。
真实接入时可由各支付渠道覆写该方法使用官方 SDK 验签。
"""
expected_signature = self.build_callback_signature(
payload=payload,
callback_secret=callback_secret,
)
if expected_signature is None:
return False
provided = (callback_signature or "").strip()
if not provided:
return False
if provided.lower().startswith("sha256="):
provided = provided.split("=", 1)[1]
return hmac.compare_digest(provided.lower(), expected_signature.lower())
+22
View File
@@ -0,0 +1,22 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class ManualGateway(PaymentGateway):
payment_method = "manual"
display_name = "人工打款"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"manual_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": None,
"qr_code": None,
"instructions": "请线下确认到账后由管理员处理",
"expires_at": getattr(order, "expires_at", None),
}
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from typing import Any
from src.services.payment.gateway.base import PaymentGateway
class WeChatGateway(PaymentGateway):
payment_method = "wechat"
display_name = "微信支付"
def create_checkout_payload(self, *, order: Any) -> dict[str, Any]:
gateway_order_id = getattr(order, "gateway_order_id", None) or f"wx_{order.order_no}"
return {
"gateway": self.payment_method,
"display_name": self.display_name,
"gateway_order_id": gateway_order_id,
"payment_url": f"/pay/mock/wechat/{order.order_no}",
"qr_code": f"mock://wechat/{order.order_no}",
"expires_at": getattr(order, "expires_at", None),
}
+478
View File
@@ -0,0 +1,478 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from typing import Any
from uuid import uuid4
from sqlalchemy.orm import Session
from src.models.database import PaymentCallback, PaymentOrder, User, Wallet
from src.services.billing.precision import to_money_decimal
from src.services.payment.gateway import get_payment_gateway
from src.services.wallet import WalletService
class PaymentService:
"""支付订单与回调处理服务。
当前实现目标:
- 打通充值订单创建
- 打通支付回调幂等到账
- 真实网关签名/SDK 留给后续渠道适配层
"""
@staticmethod
def _build_order_no() -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"po_{ts}_{uuid4().hex[:12]}"
@staticmethod
def _build_payload_hash(payload: dict[str, Any] | None) -> str | None:
if payload is None:
return None
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode(
"utf-8"
)
return hashlib.sha256(encoded).hexdigest()
@classmethod
def create_recharge_order(
cls,
db: Session,
*,
user: User,
amount_usd: Decimal | float | int | str,
payment_method: str,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
expires_in_minutes: int = 30,
gateway_order_id: str | None = None,
gateway_response: dict[str, Any] | None = None,
) -> PaymentOrder:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("recharge amount must be positive")
if not payment_method:
raise ValueError("payment_method is required")
if payment_method == "admin_manual":
raise ValueError("admin_manual is reserved for admin recharge")
gateway = get_payment_gateway(payment_method)
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise ValueError("wallet not available")
if wallet.status != "active":
raise ValueError("wallet is not active")
now = datetime.now(timezone.utc)
order = PaymentOrder(
order_no=cls._build_order_no(),
wallet_id=wallet.id,
user_id=user.id,
amount_usd=amount,
pay_amount=to_money_decimal(pay_amount) if pay_amount is not None else None,
pay_currency=pay_currency,
exchange_rate=to_money_decimal(exchange_rate) if exchange_rate is not None else None,
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("0"),
payment_method=payment_method,
gateway_order_id=gateway_order_id,
gateway_response=gateway_response,
status="pending",
expires_at=now + timedelta(minutes=max(expires_in_minutes, 1)),
)
db.add(order)
db.flush()
checkout = gateway.create_checkout_payload(order=order)
order.gateway_order_id = order.gateway_order_id or checkout.get("gateway_order_id")
order.gateway_response = gateway_response if gateway_response is not None else checkout
return order
@classmethod
def refresh_order_status(cls, order: PaymentOrder | None) -> bool:
if order is None:
return False
if order.status != "pending":
return False
now = datetime.now(timezone.utc)
if order.expires_at is not None and order.expires_at < now:
order.status = "expired"
return True
return False
@staticmethod
def get_order(
db: Session,
*,
order_id: str | None = None,
order_no: str | None = None,
gateway_order_id: str | None = None,
) -> PaymentOrder | None:
if order_id:
return db.query(PaymentOrder).filter(PaymentOrder.id == order_id).first()
if order_no:
return db.query(PaymentOrder).filter(PaymentOrder.order_no == order_no).first()
if gateway_order_id:
return (
db.query(PaymentOrder)
.filter(PaymentOrder.gateway_order_id == gateway_order_id)
.first()
)
return None
@classmethod
def list_user_orders(
cls,
db: Session,
*,
user_id: str,
limit: int,
offset: int,
) -> tuple[list[PaymentOrder], int, bool]:
expired_count = cls.expire_overdue_pending_orders(db, user_id=user_id)
q = db.query(PaymentOrder).filter(PaymentOrder.user_id == user_id)
total = q.count()
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
return items, total, expired_count > 0
@classmethod
def list_orders(
cls,
db: Session,
*,
status: str | None = None,
payment_method: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PaymentOrder], int, bool]:
expired_count = 0
if status in {None, "pending", "expired"}:
expired_count = cls.expire_overdue_pending_orders(
db,
payment_method=payment_method,
)
q = db.query(PaymentOrder)
if status:
q = q.filter(PaymentOrder.status == status)
if payment_method:
q = q.filter(PaymentOrder.payment_method == payment_method)
total = q.count()
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
return items, total, expired_count > 0
@staticmethod
def expire_overdue_pending_orders(
db: Session,
*,
user_id: str | None = None,
payment_method: str | None = None,
) -> int:
now = datetime.now(timezone.utc)
q = db.query(PaymentOrder).filter(
PaymentOrder.status == "pending",
PaymentOrder.expires_at.isnot(None),
PaymentOrder.expires_at < now,
)
if user_id:
q = q.filter(PaymentOrder.user_id == user_id)
if payment_method:
q = q.filter(PaymentOrder.payment_method == payment_method)
return int(q.update({PaymentOrder.status: "expired"}, synchronize_session=False) or 0)
@staticmethod
def list_callbacks(
db: Session,
*,
payment_method: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PaymentCallback], int]:
q = db.query(PaymentCallback)
if payment_method:
q = q.filter(PaymentCallback.payment_method == payment_method)
total = q.count()
items = q.order_by(PaymentCallback.created_at.desc()).offset(offset).limit(limit).all()
return items, total
@staticmethod
def get_user_order(
db: Session,
*,
user_id: str,
order_id: str,
) -> PaymentOrder | None:
return (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order_id, PaymentOrder.user_id == user_id)
.first()
)
@classmethod
def fail_order(
cls,
db: Session,
*,
order: PaymentOrder,
reason: str | None = None,
) -> PaymentOrder:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
raise ValueError("credited order cannot be failed")
locked_order.status = "failed"
payload = dict(locked_order.gateway_response or {})
if reason:
payload["failure_reason"] = reason
payload["failed_at"] = datetime.now(timezone.utc).isoformat()
locked_order.gateway_response = payload
return locked_order
@classmethod
def expire_order(
cls,
db: Session,
*,
order: PaymentOrder,
reason: str | None = None,
) -> tuple[PaymentOrder, bool]:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
raise ValueError("credited order cannot be expired")
if locked_order.status == "expired":
return locked_order, False
if locked_order.status != "pending":
raise ValueError(f"only pending order can be expired: {locked_order.status}")
locked_order.status = "expired"
payload = dict(locked_order.gateway_response or {})
if reason:
payload["expire_reason"] = reason
payload["expired_at"] = datetime.now(timezone.utc).isoformat()
locked_order.gateway_response = payload
return locked_order, True
@classmethod
def log_callback(
cls,
db: Session,
*,
payment_method: str,
callback_key: str,
order_no: str | None = None,
gateway_order_id: str | None = None,
payload: dict[str, Any] | None = None,
signature_valid: bool = False,
status: str = "received",
payment_order: PaymentOrder | None = None,
error_message: str | None = None,
) -> tuple[PaymentCallback, bool]:
existing = (
db.query(PaymentCallback).filter(PaymentCallback.callback_key == callback_key).first()
)
if existing is not None:
return existing, False
callback = PaymentCallback(
payment_order_id=payment_order.id if payment_order else None,
payment_method=payment_method,
callback_key=callback_key,
order_no=order_no,
gateway_order_id=gateway_order_id,
payload_hash=cls._build_payload_hash(payload),
signature_valid=signature_valid,
status=status,
payload=payload,
error_message=error_message,
)
db.add(callback)
db.flush()
return callback, True
@classmethod
def credit_order(
cls,
db: Session,
*,
order: PaymentOrder,
gateway_order_id: str | None = None,
gateway_response: dict[str, Any] | None = None,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
) -> tuple[PaymentOrder, bool]:
locked_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == order.id)
.with_for_update()
.one_or_none()
)
if locked_order is None:
raise ValueError("payment order not found")
if locked_order.status == "credited":
return locked_order, False
if locked_order.status in {"failed", "expired", "refunded"}:
raise ValueError(f"payment order is not creditable: {locked_order.status}")
now = datetime.now(timezone.utc)
if locked_order.expires_at is not None and locked_order.expires_at < now:
locked_order.status = "expired"
raise ValueError("payment order expired")
wallet = db.query(Wallet).filter(Wallet.id == locked_order.wallet_id).first()
if wallet is None:
raise ValueError("wallet not found")
if wallet.status != "active":
raise ValueError("wallet is not active")
if gateway_order_id:
locked_order.gateway_order_id = gateway_order_id
if gateway_response is not None:
locked_order.gateway_response = gateway_response
if pay_amount is not None:
locked_order.pay_amount = to_money_decimal(pay_amount)
if pay_currency is not None:
locked_order.pay_currency = pay_currency
if exchange_rate is not None:
locked_order.exchange_rate = to_money_decimal(exchange_rate)
locked_order.status = "paid"
locked_order.paid_at = locked_order.paid_at or now
locked_order.refundable_amount_usd = to_money_decimal(locked_order.amount_usd)
WalletService.create_wallet_transaction(
db,
wallet=wallet,
category="recharge",
reason_code="topup_gateway",
amount=locked_order.amount_usd,
balance_type="recharge",
link_type="payment_order",
link_id=locked_order.id,
description=f"充值到账({locked_order.payment_method})",
)
locked_order.status = "credited"
locked_order.credited_at = now
return locked_order, True
@classmethod
def handle_callback(
cls,
db: Session,
*,
payment_method: str,
callback_key: str,
payload: dict[str, Any] | None,
callback_signature: str | None,
callback_secret: str | None,
order_no: str | None = None,
gateway_order_id: str | None = None,
amount_usd: Decimal | float | int | str | None = None,
pay_amount: Decimal | float | int | str | None = None,
pay_currency: str | None = None,
exchange_rate: Decimal | float | int | str | None = None,
) -> dict[str, Any]:
gateway = get_payment_gateway(payment_method)
verified = gateway.verify_callback_payload(
payload=payload,
callback_signature=callback_signature,
callback_secret=callback_secret,
)
callback, created = cls.log_callback(
db,
payment_method=payment_method,
callback_key=callback_key,
order_no=order_no,
gateway_order_id=gateway_order_id,
payload=payload,
signature_valid=verified,
)
if not created and callback.status == "processed":
return {
"ok": True,
"duplicate": True,
"credited": False,
"order_id": callback.payment_order_id,
}
if not verified:
callback.status = "failed"
callback.error_message = "invalid callback signature"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
order = cls.get_order(
db,
order_no=order_no or callback.order_no,
gateway_order_id=gateway_order_id or callback.gateway_order_id,
)
if order is None:
callback.status = "failed"
callback.error_message = "payment order not found"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
callback.payment_order_id = order.id
callback.order_no = order.order_no
callback.gateway_order_id = gateway_order_id or order.gateway_order_id
if amount_usd is None:
callback.status = "failed"
callback.error_message = "callback amount is required"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
expected = to_money_decimal(order.amount_usd)
actual = to_money_decimal(amount_usd)
if actual != expected:
callback.status = "failed"
callback.error_message = "callback amount mismatch"
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
try:
updated_order, credited = cls.credit_order(
db,
order=order,
gateway_order_id=gateway_order_id,
gateway_response=payload,
pay_amount=pay_amount,
pay_currency=pay_currency,
exchange_rate=exchange_rate,
)
except ValueError as exc:
callback.status = "failed"
callback.error_message = str(exc)
callback.processed_at = datetime.now(timezone.utc)
return {"ok": False, "duplicate": not created, "error": callback.error_message}
callback.status = "processed"
callback.error_message = None
callback.processed_at = datetime.now(timezone.utc)
return {
"ok": True,
"duplicate": not created,
"credited": credited,
"order_id": updated_order.id,
"order_no": updated_order.order_no,
"status": updated_order.status,
"wallet_id": updated_order.wallet_id,
}
+3 -2
View File
@@ -16,6 +16,7 @@ from typing import Any
from sqlalchemy.orm import Session
from src.core.enums import UserRole
from src.core.logger import logger
from src.database import create_session
@@ -80,7 +81,7 @@ class CacheWarmupService:
db = create_session()
# 获取一个管理员用户用于构造 context
admin_user = db.query(DBUser).filter(DBUser.role == "admin").first()
admin_user = db.query(DBUser).filter(DBUser.role == UserRole.ADMIN).first()
if not admin_user:
logger.info("缓存预热: 无管理员用户,跳过仪表盘统计预热")
return True
@@ -138,7 +139,7 @@ class CacheWarmupService:
db = create_session()
# 获取一个管理员用户
admin_user = db.query(DBUser).filter(DBUser.role == "admin").first()
admin_user = db.query(DBUser).filter(DBUser.role == UserRole.ADMIN).first()
if not admin_user:
logger.info("缓存预热: 无管理员用户,跳过每日统计预热")
return True
+4 -32
View File
@@ -94,6 +94,10 @@ class SystemConfigService:
"value": "AI Gateway",
"description": "站点副标题,显示在导航栏品牌名称下方",
},
"default_user_initial_gift_usd": {
"value": 10.0,
"description": "新用户默认初始赠款(美元)",
},
REQUEST_RECORD_LEVEL_KEY: {
"value": RequestRecordLevel.BASIC.value,
"description": "请求记录级别:basic(基本信息), headers(含请求/响应头), full(完整请求/响应)",
@@ -143,38 +147,6 @@ class SystemConfigService:
"value": "01:05",
"description": "Provider 自动签到执行时间(HH:MM 格式,24小时制)",
},
"enable_user_quota_reset": {
"value": False,
"description": "是否启用用户配额自动重置任务(按配置时间触发,按周期执行)",
},
"user_quota_reset_time": {
"value": "05:00",
"description": "用户配额自动重置执行时间(HH:MM 格式,24小时制)",
},
"user_quota_reset_interval_days": {
"value": 1,
"description": "用户配额重置周期(天数)",
},
"enable_standalone_key_quota_reset": {
"value": False,
"description": "是否启用独立密钥额度自动重置任务(按配置时间触发,按周期执行)",
},
"standalone_key_quota_reset_time": {
"value": "05:00",
"description": "独立密钥额度自动重置执行时间(HH:MM 格式,24小时制)",
},
"standalone_key_quota_reset_interval_days": {
"value": 1,
"description": "独立密钥额度重置周期(天数)",
},
"standalone_key_quota_reset_mode": {
"value": "all",
"description": "独立密钥额度重置模式:all(全部独立密钥) 或 selected(指定密钥)",
},
"standalone_key_quota_reset_key_ids": {
"value": [],
"description": "独立密钥额度重置指定的密钥 ID 列表(仅 mode=selected 时生效)",
},
"provider_priority_mode": {
"value": "provider",
"description": "优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)",
+1 -337
View File
@@ -25,7 +25,7 @@ from sqlalchemy import delete, text
from src.core.logger import logger
from src.database import create_session
from src.models.database import ApiKey, AuditLog, Provider, RequestCandidate, Usage
from src.models.database import AuditLog, Provider, RequestCandidate, Usage
from src.services.provider_ops.service import ProviderOpsService
from src.services.system.config import SystemConfigService
from src.services.system.scheduler import get_scheduler
@@ -39,10 +39,6 @@ class MaintenanceScheduler:
# 签到任务的 job_id
CHECKIN_JOB_ID = "provider_checkin"
# 用户配额重置任务的 job_id
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
# 独立密钥额度重置任务的 job_id
STANDALONE_KEY_QUOTA_RESET_JOB_ID = "standalone_key_quota_reset"
def __init__(self) -> None:
self.running = False
@@ -62,19 +58,6 @@ class MaintenanceScheduler:
finally:
db.close()
def _get_user_quota_reset_time(self) -> tuple[int, int]:
"""获取用户配额重置任务的执行时间
Returns:
(hour, minute) 元组
"""
db = create_session()
try:
time_str = SystemConfigService.get_config(db, "user_quota_reset_time", "05:00")
return self._parse_user_quota_reset_time_string(time_str)
finally:
db.close()
@staticmethod
def _parse_time_string(time_str: str) -> tuple[int, int]:
"""解析时间字符串为 (hour, minute) 元组
@@ -98,26 +81,6 @@ class MaintenanceScheduler:
except (ValueError, IndexError):
return (1, 5)
@staticmethod
def _parse_user_quota_reset_time_string(time_str: str) -> tuple[int, int]:
"""解析用户配额重置时间字符串为 (hour, minute) 元组
Returns:
(hour, minute) 元组,解析失败返回默认值 (5, 0)
"""
try:
if not time_str or ":" not in time_str:
return (5, 0)
parts = time_str.split(":")
hour = int(parts[0])
minute = int(parts[1])
# 验证范围
if 0 <= hour <= 23 and 0 <= minute <= 59:
return (hour, minute)
return (5, 0)
except (ValueError, IndexError):
return (5, 0)
def update_checkin_time(self, time_str: str) -> bool:
"""更新签到任务的执行时间
@@ -141,56 +104,6 @@ class MaintenanceScheduler:
return success
def update_user_quota_reset_time(self, time_str: str) -> bool:
"""更新用户配额重置任务的执行时间
Args:
time_str: HH:MM 格式的时间字符串
Returns:
是否成功更新
"""
hour, minute = self._parse_user_quota_reset_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.USER_QUOTA_RESET_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"用户配额重置任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def _get_standalone_key_quota_reset_time(self) -> tuple[int, int]:
"""获取独立密钥额度重置任务的执行时间"""
db = create_session()
try:
time_str = SystemConfigService.get_config(
db, "standalone_key_quota_reset_time", "05:00"
)
return self._parse_user_quota_reset_time_string(time_str)
finally:
db.close()
def update_standalone_key_quota_reset_time(self, time_str: str) -> bool:
"""更新独立密钥额度重置任务的执行时间"""
hour, minute = self._parse_user_quota_reset_time_string(time_str)
scheduler = get_scheduler()
success = scheduler.reschedule_cron_job(
self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
hour=hour,
minute=minute,
)
if success:
logger.info(f"独立密钥额度重置任务时间已更新为: {hour:02d}:{minute:02d}")
return success
def get_checkin_job_info(self) -> dict | None:
"""获取签到任务的信息
@@ -318,26 +231,6 @@ class MaintenanceScheduler:
name="Provider签到",
)
# 用户配额重置任务 - 根据配置时间执行(按周期配置决定是否执行)
quota_reset_hour, quota_reset_minute = self._get_user_quota_reset_time()
scheduler.add_cron_job(
self._scheduled_user_quota_reset,
hour=quota_reset_hour,
minute=quota_reset_minute,
job_id=self.USER_QUOTA_RESET_JOB_ID,
name="用户配额自动重置",
)
# 独立密钥额度重置任务 - 根据配置时间执行(按周期配置决定是否执行)
sk_reset_hour, sk_reset_minute = self._get_standalone_key_quota_reset_time()
scheduler.add_cron_job(
self._scheduled_standalone_key_quota_reset,
hour=sk_reset_hour,
minute=sk_reset_minute,
job_id=self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
name="独立密钥额度自动重置",
)
# 启动时执行一次初始化任务
asyncio.create_task(self._run_startup_tasks())
@@ -440,14 +333,6 @@ class MaintenanceScheduler:
"""Provider 签到任务(定时调用)"""
await self._perform_provider_checkin()
async def _scheduled_user_quota_reset(self) -> None:
"""用户配额重置任务(定时调用)"""
await self._perform_user_quota_reset()
async def _scheduled_standalone_key_quota_reset(self) -> None:
"""独立密钥额度重置任务(定时调用)"""
await self._perform_standalone_key_quota_reset()
# ========== 实际任务实现 ==========
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
@@ -858,227 +743,6 @@ class MaintenanceScheduler:
if db is not None:
db.close()
async def _perform_user_quota_reset(self) -> None:
"""执行用户配额自动重置任务
适用范围:
- 未删除(is_deleted=false)
- 仅对 quota_usd != NULL 的用户生效
"""
db = create_session()
try:
# 检查是否启用用户配额重置
if not SystemConfigService.get_config(db, "enable_user_quota_reset", False):
logger.info("用户配额自动重置已禁用,跳过任务")
return
# 重置周期(天数),不限制上限
interval_value = SystemConfigService.get_config(db, "user_quota_reset_interval_days", 1)
try:
interval_days = int(interval_value)
except Exception:
interval_days = 1
if interval_days < 1:
interval_days = 1
# 滚动计算:根据上次执行日(APP_TIMEZONE)判断是否到期
last_reset_at = SystemConfigService.get_config(db, "user_quota_last_reset_at")
should_run = True
if last_reset_at:
last_dt: datetime | None = None
try:
if isinstance(last_reset_at, str):
last_dt = datetime.fromisoformat(last_reset_at)
except Exception:
last_dt = None
if last_dt is None:
logger.warning("user_quota_last_reset_at 格式无效,视为需要执行一次")
else:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
from zoneinfo import ZoneInfo
from src.services.system.scheduler import APP_TIMEZONE
tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(tz)
last_local_date = last_dt.astimezone(tz).date()
days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0:
logger.warning("user_quota_last_reset_at 在未来,跳过本次用户配额自动重置")
should_run = False
elif days_since_reset < interval_days:
logger.info(
f"用户配额自动重置未到周期,跳过任务({days_since_reset}/{interval_days}天)"
)
should_run = False
if not should_run:
return
from src.models.database import User as DBUser
now_utc = datetime.now(timezone.utc)
reset_count = (
db.query(DBUser)
.filter(
DBUser.is_deleted.is_(False),
DBUser.quota_usd.isnot(None),
)
.update(
{
DBUser.used_usd: 0.0,
DBUser.updated_at: now_utc,
},
synchronize_session=False,
)
)
db.commit()
# 记录 last_reset_at(成功执行后更新,滚动计算用)
SystemConfigService.set_config(
db,
"user_quota_last_reset_at",
now_utc.isoformat(),
"用户配额自动重置的上次执行时间(UTC,内部使用)",
)
logger.info(
f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}"
)
except Exception as e:
logger.exception(f"用户配额自动重置任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_standalone_key_quota_reset(self) -> None:
"""执行独立密钥额度自动重置任务
适用范围:
- is_standalone=True 的密钥
- current_balance_usd != NULL(有限额的密钥)
- 支持 all(全部)和 selected(指定密钥)两种模式
"""
db = create_session()
try:
if not SystemConfigService.get_config(db, "enable_standalone_key_quota_reset", False):
logger.info("独立密钥额度自动重置已禁用,跳过任务")
return
# 重置周期
interval_value = SystemConfigService.get_config(
db, "standalone_key_quota_reset_interval_days", 1
)
try:
interval_days = int(interval_value)
except Exception:
interval_days = 1
if interval_days < 1:
interval_days = 1
# 滚动计算
last_reset_at = SystemConfigService.get_config(db, "standalone_key_quota_last_reset_at")
should_run = True
if last_reset_at:
last_dt: datetime | None = None
try:
if isinstance(last_reset_at, str):
last_dt = datetime.fromisoformat(last_reset_at)
except Exception:
last_dt = None
if last_dt is None:
logger.warning("standalone_key_quota_last_reset_at 格式无效,视为需要执行一次")
else:
if last_dt.tzinfo is None:
last_dt = last_dt.replace(tzinfo=timezone.utc)
from zoneinfo import ZoneInfo
from src.services.system.scheduler import APP_TIMEZONE
tz = ZoneInfo(APP_TIMEZONE)
now_local = datetime.now(tz)
last_local_date = last_dt.astimezone(tz).date()
days_since_reset = (now_local.date() - last_local_date).days
if days_since_reset < 0:
logger.warning("standalone_key_quota_last_reset_at 在未来,跳过本次重置")
should_run = False
elif days_since_reset < interval_days:
logger.info(
f"独立密钥额度自动重置未到周期,跳过任务"
f"({days_since_reset}/{interval_days}天)"
)
should_run = False
if not should_run:
return
# 确定重置范围
reset_mode = SystemConfigService.get_config(
db, "standalone_key_quota_reset_mode", "all"
)
now_utc = datetime.now(timezone.utc)
base_filter = [
ApiKey.is_standalone.is_(True),
ApiKey.current_balance_usd.isnot(None),
]
if reset_mode == "selected":
key_ids = SystemConfigService.get_config(
db, "standalone_key_quota_reset_key_ids", []
)
if not key_ids:
logger.info("独立密钥额度重置模式为 selected 但未选择任何密钥,跳过")
return
base_filter.append(ApiKey.id.in_(key_ids))
reset_count = (
db.query(ApiKey)
.filter(*base_filter)
.update(
{
ApiKey.balance_used_usd: 0.0,
ApiKey.updated_at: now_utc,
},
synchronize_session=False,
)
)
db.commit()
SystemConfigService.set_config(
db,
"standalone_key_quota_last_reset_at",
now_utc.isoformat(),
"独立密钥额度自动重置的上次执行时间(UTC,内部使用)",
)
logger.info(
f"独立密钥额度自动重置完成: mode={reset_mode}, "
f"interval_days={interval_days}, 重置密钥数={reset_count}"
)
except Exception as e:
logger.exception(f"独立密钥额度自动重置任务执行失败: {e}")
try:
db.rollback()
except Exception:
pass
finally:
db.close()
async def _perform_candidate_cleanup(self) -> None:
"""清理过期的 request_candidates 记录"""
+14 -13
View File
@@ -6,6 +6,7 @@ from typing import Any
from sqlalchemy.orm import Session
from src.models.database import ApiKey, Usage, User
from src.services.billing.precision import to_money_decimal
from src.services.system.config import SystemConfigService
from src.services.usage._types import UsageCostInfo
from src.services.usage.error_classifier import classify_error
@@ -206,19 +207,19 @@ def build_usage_params(
"cache_read_input_tokens": cache_read_input_tokens,
"cache_creation_input_tokens_5m": cache_creation_input_tokens_5m,
"cache_creation_input_tokens_1h": cache_creation_input_tokens_1h,
"input_cost_usd": input_cost,
"output_cost_usd": output_cost,
"cache_cost_usd": cache_cost,
"cache_creation_cost_usd": cache_creation_cost,
"cache_read_cost_usd": cache_read_cost,
"request_cost_usd": request_cost,
"total_cost_usd": total_cost,
"actual_input_cost_usd": actual_input_cost,
"actual_output_cost_usd": actual_output_cost,
"actual_cache_creation_cost_usd": actual_cache_creation_cost,
"actual_cache_read_cost_usd": actual_cache_read_cost,
"actual_request_cost_usd": actual_request_cost,
"actual_total_cost_usd": actual_total_cost,
"input_cost_usd": to_money_decimal(input_cost),
"output_cost_usd": to_money_decimal(output_cost),
"cache_cost_usd": to_money_decimal(cache_cost),
"cache_creation_cost_usd": to_money_decimal(cache_creation_cost),
"cache_read_cost_usd": to_money_decimal(cache_read_cost),
"request_cost_usd": to_money_decimal(request_cost),
"total_cost_usd": to_money_decimal(total_cost),
"actual_input_cost_usd": to_money_decimal(actual_input_cost),
"actual_output_cost_usd": to_money_decimal(actual_output_cost),
"actual_cache_creation_cost_usd": to_money_decimal(actual_cache_creation_cost),
"actual_cache_read_cost_usd": to_money_decimal(actual_cache_read_cost),
"actual_request_cost_usd": to_money_decimal(actual_request_cost),
"actual_total_cost_usd": to_money_decimal(actual_total_cost),
"rate_multiplier": actual_rate_multiplier,
"input_price_per_1m": input_price,
"output_price_per_1m": output_price,
+103 -132
View File
@@ -7,10 +7,12 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Usage, User
from src.services.billing.precision import to_money_decimal
from src.services.provider_keys.codex_quota_sync_dispatcher import (
dispatch_codex_quota_sync_from_response_headers,
)
from src.services.system.config import SystemConfigService
from src.services.wallet import WalletService
class UsageLifecycleMixin:
@@ -156,41 +158,32 @@ class UsageLifecycleMixin:
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
- 不在本方法内 commit,由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
cost = float(total_cost_usd)
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
cost = to_money_decimal(total_cost_usd)
request_cost = to_money_decimal(request_cost_usd) if request_cost_usd is not None else cost
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(
billing_status="settled",
finalized_at=now,
total_cost_usd=cost,
request_cost_usd=request_cost,
status=status,
status_code=status_code,
error_message=error_message,
response_time_ms=response_time_ms,
)
)
if result.rowcount != 1:
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
usage.billing_status = "settled"
usage.finalized_at = now
usage.total_cost_usd = cost
usage.request_cost_usd = request_cost
usage.status = status
usage.status_code = status_code
usage.error_message = error_message
usage.response_time_ms = response_time_ms
if cost > 0:
WalletService.apply_usage_charge(db, usage=usage, amount_usd=cost)
# 写入审计快照(只在本次 finalize 生效时执行)
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
if usage:
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
usage.request_metadata = cls._sanitize_request_metadata(metadata)
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
usage.request_metadata = cls._sanitize_request_metadata(metadata)
return True
@@ -210,27 +203,20 @@ class UsageLifecycleMixin:
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
- 不在本方法内 commit,由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(
billing_status="void",
finalized_at=now,
total_cost_usd=0.0,
request_cost_usd=0.0,
status="cancelled",
status_code=status_code,
error_message=reason,
response_time_ms=None,
)
)
return result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
usage.billing_status = "void"
usage.finalized_at = now
usage.total_cost_usd = to_money_decimal(0)
usage.request_cost_usd = to_money_decimal(0)
usage.status = "cancelled"
usage.status_code = status_code
usage.error_message = reason
usage.response_time_ms = None
return True
@classmethod
def finalize_submitted(
@@ -252,17 +238,13 @@ class UsageLifecycleMixin:
"""
异步任务提交成功时的幂等结算。
将 pending 使用记录标记为 settled,费用暂时为 0。
后续轮询完成后通过 update_settled_billing 更新实际费用。
将 pending 使用记录保留为 pending,仅补齐已知的 provider/响应信息。
后续轮询完成后通过 update_settled_billing 一次性写入实际费用并扣钱包。
约定:
- 仅当 billing_status='pending' 时才会生效(rowcount==1)
- 不在本方法内 commit,由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
# 处理响应头和响应体
should_log_headers = SystemConfigService.should_log_headers(db)
should_log_body = SystemConfigService.should_log_body(db)
@@ -284,11 +266,7 @@ class UsageLifecycleMixin:
)
values: dict[str, Any] = {
"billing_status": "settled",
"finalized_at": now,
"total_cost_usd": 0.0,
"request_cost_usd": 0.0,
"status": "completed",
"status": "pending",
"status_code": status_code,
"response_time_ms": response_time_ms,
"provider_name": provider_name,
@@ -305,15 +283,12 @@ class UsageLifecycleMixin:
if processed_response_body is not None:
values["response_body"] = processed_response_body
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(**values)
)
finalized = result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "pending":
return False
for key, value in values.items():
setattr(usage, key, value)
finalized = True
if finalized:
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -338,54 +313,51 @@ class UsageLifecycleMixin:
extra_metadata: dict[str, Any] | None = None,
) -> bool:
"""
更新已结算记录的计费信息(用于异步任务轮询完成后)。
写入异步任务最终账单(轮询完成后调用)。
与 finalize_settled 不同:
- finalize_settled: pending -> settled(首次结算)
- update_settled_billing: settled -> settled(更新费用)
语义:
- 正常路径:pending -> settled / void(首次最终结算)
- 补写路径:已写入 0 成本但尚未扣钱包的记录,可补写一次最终值
- 已 void 的记录不可再结算
- 已扣钱包(wallet_balance_after 已存在)的记录不可重复扣费
约定:
- 仅当 billing_status='settled' 时才会生效
- 不在本方法内 commit,由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
cost = float(total_cost_usd)
request_cost = float(request_cost_usd) if request_cost_usd is not None else cost
cost = to_money_decimal(total_cost_usd)
request_cost = to_money_decimal(request_cost_usd) if request_cost_usd is not None else cost
values: dict[str, Any] = {
"total_cost_usd": cost,
"request_cost_usd": request_cost,
"status": status,
"status_code": status_code,
}
if error_message is not None:
values["error_message"] = error_message
if response_time_ms is not None:
values["response_time_ms"] = response_time_ms
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "settled",
)
.values(**values)
)
if result.rowcount != 1:
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status == "void":
return False
if usage.billing_status == "settled" and usage.wallet_balance_after is not None:
return False
usage.total_cost_usd = cost
usage.request_cost_usd = request_cost
usage.status = status
usage.status_code = status_code
if error_message is not None:
usage.error_message = error_message
if response_time_ms is not None:
usage.response_time_ms = response_time_ms
usage.finalized_at = usage.finalized_at or now
if cost > 0:
usage.billing_status = "settled"
WalletService.apply_usage_charge(db, usage=usage, amount_usd=cost)
else:
usage.billing_status = "void" if status in {"failed", "cancelled"} else "settled"
# 写入审计快照
usage = db.query(Usage).filter(Usage.request_id == request_id).first()
if usage:
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
metadata["billing_updated_at"] = now.isoformat()
usage.request_metadata = cls._sanitize_request_metadata(metadata)
metadata = usage.request_metadata or {}
if billing_snapshot is not None:
metadata["billing_snapshot"] = billing_snapshot
if extra_metadata:
metadata.update(extra_metadata)
metadata["billing_updated_at"] = now.isoformat()
usage.request_metadata = cls._sanitize_request_metadata(metadata)
return True
@@ -409,26 +381,22 @@ class UsageLifecycleMixin:
- 仅当 billing_status='settled' 时才会生效
- 不在本方法内 commit,由调用方决定事务提交时机
"""
from sqlalchemy import update
now = datetime.now(timezone.utc)
result = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "settled",
)
.values(
billing_status="void",
finalized_at=now,
total_cost_usd=0.0,
request_cost_usd=0.0,
status="cancelled",
status_code=status_code,
error_message=reason,
)
)
return result.rowcount == 1
usage = db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
if not usage or usage.billing_status != "settled":
return False
if usage.wallet_balance_after is not None and to_money_decimal(usage.total_cost_usd) > 0:
# 已实际扣费的记录当前不做自动回滚,避免 silent inconsistency。
return False
usage.billing_status = "void"
usage.finalized_at = now
usage.total_cost_usd = to_money_decimal(0)
usage.request_cost_usd = to_money_decimal(0)
usage.status = "cancelled"
usage.status_code = status_code
usage.error_message = reason
return True
@classmethod
def update_usage_status(
@@ -549,11 +517,14 @@ class UsageLifecycleMixin:
db, provider_request_body, is_request=True
)
# 结算状态:当请求进入终态时,将 billing_status 标记为 settled
# 注意:取消是否应 VOID/部分结算由更高层策略决定;这里默认终态均视为已结算。
if status in ("completed", "failed", "cancelled"):
if getattr(usage, "billing_status", None) == "pending":
usage.billing_status = "settled"
# 仅在“明确不会收费”的终态下直接关闭账单。
# completed 的费用通常要由后续 record_usage / update_settled_billing 写入,
# 这里不能提前把 billing_status 置为 settled,否则会阻断真正扣费。
if (
status in ("failed", "cancelled")
and getattr(usage, "billing_status", None) == "pending"
):
usage.billing_status = "void"
if getattr(usage, "finalized_at", None) is None:
usage.finalized_at = datetime.now(timezone.utc)
+30 -52
View File
@@ -7,7 +7,7 @@ from sqlalchemy import case, func
from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Usage, User, UserRole
from src.models.database import ApiKey, Usage, User
class UsageQueryMixin:
@@ -125,66 +125,44 @@ class UsageQueryMixin:
return result
@staticmethod
def check_user_quota(
def check_request_balance(
db: Session,
user: User,
estimated_tokens: int = 0,
estimated_cost: float = 0,
api_key: ApiKey | None = None,
) -> tuple[bool, str]:
"""检查用户配额或独立Key余额
"""检查请求是否满足余额条件(支持独立 Key)。"""
from src.services.wallet import WalletService
Args:
db: 数据库会话
user: 用户对象
estimated_tokens: 预估token数
estimated_cost: 预估费用
api_key: API Key对象(用于检查独立余额Key)
wallet_access = WalletService.check_request_allowed(
db,
user=None if (api_key and api_key.is_standalone) else user,
api_key=api_key,
)
if wallet_access.allowed:
return True, "OK"
Returns:
(是否通过, 消息)
"""
if wallet_access.message == "钱包欠费,请先充值":
if api_key and api_key.is_standalone:
return False, "Key欠费,请先调账或充值"
return False, "账户欠费,请先充值"
# 如果是独立余额Key,检查Key的余额而不是用户配额
if wallet_access.message == "钱包不可用":
if api_key and api_key.is_standalone:
return False, "Key钱包不可用"
return False, "钱包不可用"
remaining = float(wallet_access.remaining) if wallet_access.remaining is not None else None
if api_key and api_key.is_standalone:
# 导入 ApiKeyService 以使用统一的余额计算方法
from src.services.user.apikey import ApiKeyService
if remaining is None:
return False, "Key余额不足"
return False, f"Key余额不足(剩余: ${remaining:.2f})"
# NULL 表示无限制
if api_key.current_balance_usd is None:
return True, "OK"
# 使用统一的余额计算方法
remaining_balance = ApiKeyService.get_remaining_balance(api_key)
if remaining_balance is None:
return True, "OK"
# 检查余额是否充足
if remaining_balance < estimated_cost:
return (
False,
f"Key余额不足(剩余: ${remaining_balance:.2f},需要: ${estimated_cost:.2f})",
)
return True, "OK"
# 普通Key:检查用户配额
# 管理员无限制
if user.role == UserRole.ADMIN:
return True, "OK"
# NULL 表示无限制
if user.quota_usd is None:
return True, "OK"
# 有配额限制,检查是否超额
used_usd = float(user.used_usd or 0)
quota_usd = float(user.quota_usd)
if used_usd + estimated_cost > quota_usd:
remaining = quota_usd - used_usd
return False, f"配额不足(剩余: ${remaining:.2f})"
return True, "OK"
# admin 已在 WalletService.check_request_allowed 中放行,此处不再重复检查
if remaining is None:
return False, wallet_access.message or "余额不足"
return False, f"余额不足(剩余: ${remaining:.2f})"
@staticmethod
def get_usage_summary(
@@ -210,14 +188,14 @@ class UsageQueryMixin:
if end_date:
query = query.filter(Usage.created_at < end_date)
# 使用跨数据库兼容的日期函数
# 使用跨数据库可用的日期函数
from src.utils.database_helpers import date_trunc_portable
# 检测数据库方言
bind = db.bind
dialect = bind.dialect.name if bind is not None else "sqlite"
# 根据分组类型选择日期函数(兼容多种数据库)
# 根据分组类型选择日期函数(适配多种数据库)
if group_by == "day":
date_func = date_trunc_portable(dialect, "day", Usage.created_at)
elif group_by == "week":
+232 -279
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger
from src.models.database import ApiKey, Provider, ProxyNode, Usage, User, UserModelUsageCount
from src.services.billing.precision import to_money_decimal
from src.services.provider_keys.codex_quota_sync_dispatcher import (
dispatch_codex_quota_sync_from_response_headers,
)
@@ -20,6 +21,7 @@ from src.services.usage._recording_helpers import (
update_existing_usage,
)
from src.services.usage._types import UsageCostInfo, UsageRecordParams
from src.services.wallet import WalletService
def _extract_manual_proxy_node_id(metadata: dict[str, Any] | None) -> str | None:
@@ -69,12 +71,12 @@ def _increment_proxy_node_requests(
class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""记录用量相关方法"""
# Metadata pruning configuration -- re-export from helpers for backward compatibility
# Metadata pruning configuration
_METADATA_PRUNE_KEYS: tuple[str, ...] = METADATA_PRUNE_KEYS
_METADATA_KEEP_KEYS: frozenset[str] = METADATA_KEEP_KEYS
# ------------------------------------------------------------------
# Backward-compatible thin wrappers
# Helper wrappers
# ------------------------------------------------------------------
@staticmethod
@@ -121,6 +123,56 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
"""元数据清理(委托到模块级函数)"""
return sanitize_request_metadata(metadata)
@staticmethod
def _is_terminal_status(status: str | None) -> bool:
return status in {"completed", "failed", "cancelled"}
@staticmethod
def _is_usage_finalized(usage: Usage) -> bool:
return (
getattr(usage, "billing_status", None) in {"settled", "void"}
and getattr(usage, "finalized_at", None) is not None
)
@classmethod
def _finalize_usage_billing(
cls,
db: Session,
*,
usage: Usage,
total_cost: float,
status: str | None,
finalized_at: datetime | None = None,
) -> tuple[bool, bool]:
"""完成 usage 的结算状态,并在需要时扣减钱包。
Returns:
(是否首次进入终态, 是否发生扣费)
"""
if not cls._is_terminal_status(status):
if getattr(usage, "billing_status", None) is None:
usage.billing_status = "pending"
return False, False
if (
getattr(usage, "billing_status", None) in {"settled", "void"}
and getattr(usage, "finalized_at", None) is not None
):
return False, False
now = finalized_at or datetime.now(timezone.utc)
charge_amount = to_money_decimal(total_cost)
usage.finalized_at = usage.finalized_at or now
if charge_amount > 0:
WalletService.apply_usage_charge(db, usage=usage, amount_usd=charge_amount)
usage.billing_status = "settled"
return True, True
usage.billing_status = "void" if status in {"failed", "cancelled"} else "settled"
return True, False
# ------------------------------------------------------------------
# Recording methods
# ------------------------------------------------------------------
@@ -221,7 +273,8 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
use_tiered_pricing=use_tiered_pricing,
target_model=target_model,
)
usage_params, _ = await cls._prepare_usage_record(params)
usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost)
# 创建 Usage 记录
usage = Usage(**usage_params)
@@ -243,17 +296,19 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 更新 Provider 月度使用量(原子操作)
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
# 结算标记:record_usage_async 写入的 Usage 通常为终态记录
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -363,10 +418,20 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
target_model=target_model,
)
usage_params, total_cost = await cls._prepare_usage_record(params)
total_cost = to_money_decimal(total_cost)
# 检查是否已存在相同 request_id 的记录
existing_usage = db.query(Usage).filter(Usage.request_id == request_id).first()
existing_usage = (
db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
)
if existing_usage:
if cls._is_usage_finalized(existing_usage):
logger.debug(
"request_id {} 已完成结算,跳过重复记账 (billing_status={})",
request_id,
getattr(existing_usage, "billing_status", None),
)
return existing_usage
logger.debug(
f"request_id {request_id} 已存在,更新现有记录 "
f"(status: {existing_usage.status} -> {status})"
@@ -389,75 +454,52 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 更新用户使用量(独立 Key 不计入创建者的使用记录)
if user and not (api_key and api_key.is_standalone):
db.execute(
update(UserModel)
.where(UserModel.id == user.id)
.values(
used_usd=UserModel.used_usd + total_cost,
total_usd=UserModel.total_usd + total_cost,
updated_at=sql_func.now(),
)
)
# 更新 API 密钥使用量
if api_key:
if api_key.is_standalone:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
balance_used_usd=ApiKeyModel.balance_used_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 更新 GlobalModel 使用计数
db.execute(
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
if accounted:
# 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + float(
to_money_decimal(total_cost)
)
db.execute(update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values))
# 更新 Provider 月度使用量
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
# 更新 GlobalModel 使用计数
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
# 更新手动代理节点请求计数(tunnel 节点由心跳上报,不在此处统计)
manual_node_id = _extract_manual_proxy_node_id(metadata)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
# 结算标记:终态请求写入 settled + finalized_at
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
# 更新 Provider 月度使用量(Provider 端真实成本,无论钱包是否扣费)
if provider_id:
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
# 更新手动代理节点请求计数(tunnel 节点由心跳上报,不在此处统计)
manual_node_id = _extract_manual_proxy_node_id(metadata)
if manual_node_id:
failed = {manual_node_id: 1} if status == "failed" else None
_increment_proxy_node_requests(db, {manual_node_id: 1}, failed)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -542,10 +584,14 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
cache_creation_cost = 0.0
cache_read_cost = 0.0
cache_cost = 0.0
request_cost = (
float(request_cost_usd) if request_cost_usd is not None else float(total_cost_usd)
request_cost_decimal = (
to_money_decimal(request_cost_usd)
if request_cost_usd is not None
else to_money_decimal(total_cost_usd)
)
total_cost = float(total_cost_usd)
total_cost_decimal = to_money_decimal(total_cost_usd)
request_cost = float(request_cost_decimal)
total_cost = float(total_cost_decimal)
usage_params = build_usage_params(
db=db,
@@ -598,10 +644,10 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
),
)
# Upsert(并发幂等:优先用 billing_status 作为结算闸门)
from sqlalchemy import update
existing_usage = db.query(Usage).filter(Usage.request_id == request_id).first()
# Upsert(并发幂等:锁定 request_id 对应行,避免重复结算)
existing_usage = (
db.query(Usage).filter(Usage.request_id == request_id).with_for_update().first()
)
if existing_usage:
# 避免重复记账:若已结算/作废,直接返回(防止并发重复加计数)
if getattr(existing_usage, "billing_status", None) in ("settled", "void"):
@@ -612,25 +658,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
)
return existing_usage
# 并发闸门:只有 billing_status='pending' 的那一次调用可以继续
now = datetime.now(timezone.utc)
claim = db.execute(
update(Usage)
.where(
Usage.request_id == request_id,
Usage.billing_status == "pending",
)
.values(billing_status="settled", finalized_at=now)
)
if claim.rowcount != 1:
# 已被其他 worker 抢先处理(或被 VOID)
latest = db.query(Usage).filter(Usage.request_id == request_id).first()
return latest or existing_usage
# 同步 ORM 对象(避免后续代码读到旧值)
existing_usage.billing_status = "settled"
existing_usage.finalized_at = now
cls._update_existing_usage(existing_usage, usage_params, target_model)
usage = existing_usage
else:
@@ -649,69 +676,46 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 更新用户使用量(独立 Key 不计入创建者)
if user and not (api_key and api_key.is_standalone):
db.execute(
update(UserModel)
.where(UserModel.id == user.id)
.values(
used_usd=UserModel.used_usd + total_cost,
total_usd=UserModel.total_usd + total_cost,
updated_at=sql_func.now(),
)
)
# 更新 API 密钥使用量
if api_key:
if api_key.is_standalone:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
balance_used_usd=ApiKeyModel.balance_used_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == api_key.id)
.values(
total_requests=ApiKeyModel.total_requests + 1,
total_cost_usd=ApiKeyModel.total_cost_usd + total_cost,
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 更新 GlobalModel 使用计数
db.execute(
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=status,
)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
if accounted:
# 更新 API 密钥使用量
if api_key:
values: dict[str, Any] = {
"total_requests": ApiKeyModel.total_requests + 1,
"last_used_at": sql_func.now(),
"updated_at": sql_func.now(),
}
if charge_applied:
values["total_cost_usd"] = ApiKeyModel.total_cost_usd + float(
total_cost_decimal
)
db.execute(update(ApiKeyModel).where(ApiKeyModel.id == api_key.id).values(**values))
# 更新 Provider 月度使用量(使用 actual_total_cost)
if provider_id:
actual_total_cost = usage_params["actual_total_cost_usd"]
# 更新 GlobalModel 使用计数
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
update(GlobalModel)
.where(GlobalModel.name == model)
.values(usage_count=GlobalModel.usage_count + 1)
)
# 结算标记:record_usage_with_custom_cost 写入/更新的 Usage 通常为终态记录
if status not in ("pending", "streaming"):
usage.billing_status = "settled"
usage.finalized_at = datetime.now(timezone.utc)
# 更新用户-模型调用次数计数器
cls._increment_user_model_usage(db, user, model)
# 更新 Provider 月度使用量(Provider 端真实成本,无论钱包是否扣费)
if provider_id:
actual_total_cost = float(usage_params["actual_total_cost_usd"])
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + actual_total_cost)
)
dispatch_codex_quota_sync_from_response_headers(
provider_api_key_id=provider_api_key_id,
@@ -770,7 +774,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
from src.models.database import ApiKey as ApiKeyModel
from src.models.database import GlobalModel
from src.models.database import User as UserModel
# 分离需要更新和需要新建的记录
request_ids = [r.get("request_id") for r in records if r.get("request_id")]
@@ -782,15 +785,17 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 查询已存在的 Usage 记录(包括 pending/streaming 状态)
from sqlalchemy.orm import selectinload
existing_records = (
existing_query = (
db.query(Usage)
.options(
selectinload(Usage.user),
selectinload(Usage.api_key),
)
.filter(Usage.request_id.in_(request_ids))
.all()
)
if hasattr(existing_query, "with_for_update"):
existing_query = existing_query.with_for_update()
existing_records = existing_query.all()
existing_usages = {u.request_id: u for u in existing_records}
for record in records:
@@ -826,7 +831,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
)
usages: list[Usage] = []
user_costs: dict[str, float] = defaultdict(float) # user_id -> total_cost
apikey_stats: dict[str, dict[str, Any]] = defaultdict(
lambda: {"requests": 0, "cost": 0.0, "is_standalone": False}
)
@@ -858,6 +862,7 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
skipped_count = 0
updated_count = 0
inserted_count = 0
total_count = len(all_records)
# 辅助函数:构建 UsageRecordParams
@@ -949,7 +954,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
insert_results = prepared_results[len(update_params_list) :]
finalized_at = datetime.now(timezone.utc)
terminal_statuses = {"completed", "failed", "cancelled"}
# 1. 处理需要更新的记录
for i, (record, request_id, params) in enumerate(update_params_list):
@@ -965,42 +969,40 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 更新已存在的 Usage 记录
cls._update_existing_usage(existing_usage, usage_params, record.get("target_model"))
# 结算标记:pending -> settled(幂等闸门由 prefilter 控制)
if (
usage_params.get("status") in terminal_statuses
and getattr(existing_usage, "billing_status", None) == "pending"
):
existing_usage.billing_status = "settled"
if getattr(existing_usage, "finalized_at", None) is None:
existing_usage.finalized_at = finalized_at
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=existing_usage,
total_cost=total_cost,
status=usage_params.get("status"),
finalized_at=finalized_at,
)
usages.append(existing_usage)
updated_count += 1
# 聚合统计
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
if accounted:
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
provider_id = record.get("provider_id")
if provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
provider_id = record.get("provider_id")
if charge_applied and provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
if user and not (api_key and api_key.is_standalone):
user_costs[str(user.id)] += total_cost
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
if charge_applied:
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers")
@@ -1016,10 +1018,7 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
logger.warning("批量记录中更新失败: {}, request_id={}", e, request_id)
continue
# 2. 处理需要新建的记录(批量插入)
insert_mappings: list[dict[str, Any]] = []
insert_request_ids: list[str] = []
# 2. 处理需要新建的记录
for i, (record, request_id, params) in enumerate(insert_params_list):
try:
usage_params, total_cost, exc = insert_results[i]
@@ -1029,45 +1028,43 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
user = params.user
api_key = params.api_key
# 终态记录:补齐 settled/finalized_at;非终态:确保 billing_status=pending
status = usage_params.get("status")
if status in terminal_statuses:
if usage_params.get("billing_status") in (None, "pending"):
usage_params["billing_status"] = "settled"
usage_params.setdefault("finalized_at", finalized_at)
elif usage_params.get("billing_status") is None:
usage_params["billing_status"] = "pending"
insert_mappings.append(usage_params)
insert_request_ids.append(request_id)
usage = Usage(**usage_params)
db.add(usage)
accounted, charge_applied = cls._finalize_usage_billing(
db,
usage=usage,
total_cost=total_cost,
status=usage_params.get("status"),
finalized_at=finalized_at,
)
usages.append(usage)
inserted_count += 1
# 聚合统计
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
if accounted:
model_name = record.get("model") or "unknown"
model_counts[model_name] += 1
if user:
user_model_counts[(str(user.id), model_name)] += 1
provider_id = record.get("provider_id")
if provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
provider_id = record.get("provider_id")
if charge_applied and provider_id:
actual_cost = usage_params.get("actual_total_cost_usd", 0)
provider_costs[provider_id] += actual_cost
# 用户统计(独立 Key 不计入创建者)
if user and not (api_key and api_key.is_standalone):
user_costs[str(user.id)] += total_cost
# API Key 统计
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
if charge_applied:
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
# API Key 统计
if api_key:
key_id = str(api_key.id)
apikey_stats[key_id]["requests"] += 1
apikey_stats[key_id]["cost"] += total_cost
apikey_stats[key_id]["is_standalone"] = api_key.is_standalone
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
manual_nid = _extract_manual_proxy_node_id(record.get("metadata"))
if manual_nid:
proxy_node_counts[manual_nid] += 1
if record.get("status") == "failed":
proxy_node_failed[manual_nid] += 1
provider_api_key_id = record.get("provider_api_key_id")
response_headers = record.get("response_headers")
@@ -1083,24 +1080,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
logger.warning("批量记录中跳过无效记录: {}, request_id={}", e, request_id)
continue
if insert_mappings:
try:
db.bulk_insert_mappings(Usage, insert_mappings)
# 仅用于保持返回值语义:将新建记录读回为 ORM 对象
inserted_records = (
db.query(Usage).filter(Usage.request_id.in_(insert_request_ids)).all()
)
inserted_map = {u.request_id: u for u in inserted_records}
for rid in insert_request_ids:
inserted_usage = inserted_map.get(rid)
if inserted_usage is not None:
usages.append(inserted_usage)
except Exception as e:
logger.error("批量插入 Usage 记录时出错: {}", e)
db.rollback()
raise
# 统计跳过的记录,失败率超过 10% 时提升日志级别
if skipped_count > 0:
skip_ratio = skipped_count / total_count if total_count > 0 else 0
@@ -1152,47 +1131,22 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
db.execute(
update(Provider)
.where(Provider.id == provider_id)
.values(monthly_used_usd=Provider.monthly_used_usd + cost)
)
# 批量更新用户使用量
for user_id, cost in user_costs.items():
if cost > 0:
db.execute(
update(UserModel)
.where(UserModel.id == user_id)
.values(
used_usd=UserModel.used_usd + cost,
total_usd=UserModel.total_usd + cost,
updated_at=sql_func.now(),
)
.values(monthly_used_usd=Provider.monthly_used_usd + float(cost))
)
# 批量更新 API Key 统计
for key_id, stats in apikey_stats.items():
if stats["is_standalone"]:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd + stats["cost"],
balance_used_usd=ApiKeyModel.balance_used_usd + stats["cost"],
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
else:
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd + stats["cost"],
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
db.execute(
update(ApiKeyModel)
.where(ApiKeyModel.id == key_id)
.values(
total_requests=ApiKeyModel.total_requests + stats["requests"],
total_cost_usd=ApiKeyModel.total_cost_usd
+ float(to_money_decimal(stats["cost"])),
last_used_at=sql_func.now(),
updated_at=sql_func.now(),
)
)
# 批量更新手动代理节点请求计数
_increment_proxy_node_requests(db, proxy_node_counts, proxy_node_failed)
@@ -1208,7 +1162,6 @@ class UsageRecordingMixin(UsageBillingIntegrationMixin):
# 单次提交所有更改
try:
db.commit()
inserted_count = len(insert_mappings)
total_written = updated_count + inserted_count
if updated_count > 0:
logger.debug("批量记录成功: 更新 {} 条, 新建 {} 条", updated_count, inserted_count)
+1 -99
View File
@@ -30,7 +30,6 @@ class ApiKeyService:
concurrent_limit: int = 5,
expire_days: int | None = None,
expires_at: datetime | None = None, # 直接传入过期时间,优先于 expire_days
initial_balance_usd: float | None = None,
is_standalone: bool = False,
auto_delete_on_expiry: bool = False,
) -> tuple[ApiKey, str]:
@@ -47,7 +46,6 @@ class ApiKeyService:
concurrent_limit: 并发限制
expire_days: 过期天数,None = 永不过期
expires_at: 直接指定过期时间,优先于 expire_days
initial_balance_usd: 初始余额(USD),仅用于独立Key,None = 无限制
is_standalone: 是否为独立余额Key(仅管理员可创建)
auto_delete_on_expiry: 过期后是否自动删除(True=物理删除,False=仅禁用)
"""
@@ -74,8 +72,6 @@ class ApiKeyService:
rate_limit=rate_limit,
concurrent_limit=concurrent_limit,
expires_at=final_expires_at,
balance_used_usd=0.0,
current_balance_usd=initial_balance_usd, # 直接使用初始余额,None = 无限制
is_standalone=is_standalone,
auto_delete_on_expiry=auto_delete_on_expiry,
is_active=True,
@@ -87,7 +83,7 @@ class ApiKeyService:
logger.info(
f"创建API密钥: 用户ID {user_id}, 密钥名 {api_key.name}, "
f"独立Key={is_standalone}, 初始余额={initial_balance_usd}"
f"独立Key={is_standalone}"
)
return api_key, key # 返回密钥对象和明文密钥
@@ -143,7 +139,6 @@ class ApiKeyService:
"concurrent_limit",
"is_active",
"expires_at",
"balance_limit_usd",
"auto_delete_on_expiry",
]
@@ -188,48 +183,6 @@ class ApiKeyService:
logger.info(f"删除API密钥: ID {key_id}")
return True
@staticmethod
def get_remaining_balance(api_key: ApiKey) -> float | None:
"""计算剩余余额(仅用于独立Key)
Returns:
剩余余额,None 表示无限制或非独立Key
"""
if not api_key.is_standalone:
return None
if api_key.current_balance_usd is None:
return None
# 剩余余额 = 当前余额 - 已使用余额
remaining = api_key.current_balance_usd - (api_key.balance_used_usd or 0)
return max(0, remaining) # 不能为负数
@staticmethod
def check_balance(api_key: ApiKey) -> tuple[bool, float | None]:
"""检查余额限制(仅用于独立Key)
Returns:
(is_allowed, remaining_balance): 是否允许请求,剩余余额(None表示无限制)
"""
if not api_key.is_standalone:
# 非独立Key不检查余额
return True, None
# 使用新的预付费模式: current_balance_usd
if api_key.current_balance_usd is None:
# 无余额限制
return True, None
# 使用统一的余额计算方法
remaining = ApiKeyService.get_remaining_balance(api_key)
is_allowed = remaining > 0 if remaining is not None else True
if not is_allowed:
logger.warning(f"API密钥余额不足: Key ID {api_key.id}, " f"剩余余额 ${remaining:.4f}")
return is_allowed, remaining
@staticmethod
def check_rate_limit(db: Session, api_key: ApiKey, window_minutes: int = 1) -> tuple[bool, int]:
"""检查速率限制
@@ -263,57 +216,6 @@ class ApiKeyService:
return is_allowed, api_key.rate_limit - request_count
@staticmethod
def add_balance(db: Session, key_id: str, amount_usd: float) -> ApiKey | None:
"""为独立余额Key调整余额
Args:
db: 数据库会话
key_id: API Key ID
amount_usd: 要调整的余额金额(USD),正数为增加,负数为扣除
Returns:
更新后的API Key对象,如果Key不存在或不是独立Key则返回None
"""
api_key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
if not api_key:
logger.warning(f"余额调整失败: Key ID {key_id} 不存在")
return None
if not api_key.is_standalone:
logger.warning(f"余额调整失败: Key ID {key_id} 不是独立余额Key")
return None
if amount_usd == 0:
logger.warning(f"余额调整失败: 调整金额不能为0,当前值 ${amount_usd}")
return None
# 如果是扣除(负数),检查是否超过当前余额
if amount_usd < 0:
current = api_key.current_balance_usd or 0
if abs(amount_usd) > current:
logger.warning(
f"余额扣除失败: 扣除金额 ${abs(amount_usd):.4f} 超过当前余额 ${current:.4f}"
)
return None
# 调整当前余额
if api_key.current_balance_usd is None:
api_key.current_balance_usd = amount_usd if amount_usd > 0 else 0
else:
api_key.current_balance_usd = max(0, api_key.current_balance_usd + amount_usd)
api_key.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(api_key)
action = "增加" if amount_usd > 0 else "扣除"
logger.info(
f"余额调整成功: Key ID {key_id}, {action} ${abs(amount_usd):.4f}, "
f"新余额 ${api_key.current_balance_usd:.4f}"
)
return api_key
@staticmethod
def cleanup_expired_keys(db: Session, auto_delete: bool = False) -> int:
"""清理过期的API密钥
+6 -5
View File
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from src.core.exceptions import NotFoundException
from src.core.logger import logger
from src.models.database import Provider, User, UserPreference
from src.services.wallet import WalletService
class PreferenceService:
@@ -98,6 +99,8 @@ class PreferenceService:
raise NotFoundException("User not found")
preferences = PreferenceService.get_or_create_preferences(db, user_id)
wallet = WalletService.get_wallet(db, user_id=user.id)
billing = WalletService.serialize_wallet_summary(wallet)
# 构建返回数据
user_data = {
@@ -125,12 +128,10 @@ class PreferenceService:
"announcements": preferences.announcement_notifications,
},
},
# 配额信息
"quota_usd": user.quota_usd,
"used_usd": user.used_usd,
"billing": billing,
"stats": {
"total_cost": user.used_usd,
"total_cost_all_time": user.total_usd,
"total_cost": billing["total_consumed"],
"total_cost_all_time": billing["total_consumed"],
"api_keys_count": len(user.api_keys),
},
}
+78 -41
View File
@@ -8,7 +8,7 @@ import asyncio
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import and_, func
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from src.core.logger import logger
@@ -30,13 +30,14 @@ class UserService:
username: str,
password: str,
role: UserRole = UserRole.USER,
quota_usd: float | None = 10.0,
initial_gift_usd: float | None = 10.0,
unlimited: bool = False,
email_verified: bool = False,
allowed_providers: list[str] | None = None,
allowed_api_formats: list[str] | None = None,
allowed_models: list[str] | None = None,
) -> User:
"""创建新用户,quota_usd 为 None 表示无限制,email 为 None 表示无邮箱"""
"""创建新用户。"""
# 验证邮箱格式(仅当提供邮箱时)
if email is not None:
@@ -66,7 +67,6 @@ class UserService:
email_verified=email_verified if email else False,
username=username,
role=role,
quota_usd=quota_usd,
is_active=True,
allowed_providers=allowed_providers,
allowed_api_formats=allowed_api_formats,
@@ -75,6 +75,18 @@ class UserService:
user.set_password(password)
db.add(user)
db.flush()
from src.services.wallet import WalletService
WalletService.initialize_user_wallet(
db,
user=user,
initial_gift_usd=initial_gift_usd,
unlimited=unlimited,
description="用户初始赠款",
)
db.commit() # 立即提交事务,释放数据库锁
db.refresh(user)
@@ -91,7 +103,8 @@ class UserService:
password: str,
api_key_name: str = "默认密钥",
role: UserRole = UserRole.USER,
quota_usd: float | None = 10.0,
initial_gift_usd: float | None = 10.0,
unlimited: bool = False,
concurrent_limit: int = 5,
) -> tuple[User, ApiKey]:
"""
@@ -104,7 +117,8 @@ class UserService:
password: 密码
api_key_name: API密钥名称
role: 用户角色
quota_usd: USD配额,None 表示无限制
initial_gift_usd: 初始赠款(USD)
unlimited: 是否无限制
concurrent_limit: 并发限制
Returns:
@@ -115,7 +129,13 @@ class UserService:
"""
# 创建用户
user = UserService.create_user(
db=db, email=email, username=username, password=password, role=role, quota_usd=quota_usd
db=db,
email=email,
username=username,
password=password,
role=role,
initial_gift_usd=initial_gift_usd,
unlimited=unlimited,
)
# 导入API密钥服务(避免循环导入)
@@ -173,7 +193,9 @@ class UserService:
if is_active is not None:
query = query.filter(User.is_active == is_active)
return query.offset(skip).limit(limit).all()
return (
query.order_by(User.created_at.desc(), User.id.desc()).offset(skip).limit(limit).all()
)
@staticmethod
@transactional()
@@ -187,7 +209,6 @@ class UserService:
updatable_fields = [
"email",
"username",
"quota_usd",
"is_active",
"role",
# 访问限制字段
@@ -198,7 +219,6 @@ class UserService:
# 允许设置为 None 的字段(表示无限制)
nullable_fields = [
"quota_usd",
"allowed_providers",
"allowed_api_formats",
"allowed_models",
@@ -237,16 +257,19 @@ class UserService:
"""删除用户(硬删除)
删除流程:
1. 手动删除关联的子记录(避免 SQLAlchemy ORM 与数据库 CASCADE 冲突)
2. 删除用户记录
3. 历史 Usage 记录保留,user_id 会被数据库设为 NULL
4. 新用户注册时会有新的 UUID,看不到旧用户的记录
1. 检查未完结账务,阻止删除
2. 手动删除 ORM cascade 冲突的子记录
3. 删除用户记录
4. 财务记录(Wallet/PaymentOrder/RefundRequest/WalletTransaction)和
Usage 记录保留,外键 SET NULL,由自动清理策略统一回收
"""
from src.models.database import (
AnnouncementRead,
ApiKey,
PaymentOrder,
RefundRequest,
UserPreference,
UserQuota,
Wallet,
)
user = db.query(User).filter(User.id == user_id).first()
@@ -256,16 +279,53 @@ class UserService:
# 记录删除信息用于日志
email = user.email
# 删除前阻断未完结账务,避免删除导致资金状态不一致。
wallet_ids = [
wallet_id
for (wallet_id,) in (
db.query(Wallet.id)
.outerjoin(ApiKey, Wallet.api_key_id == ApiKey.id)
.filter(or_(Wallet.user_id == user_id, ApiKey.user_id == user_id))
.all()
)
]
if wallet_ids:
pending_refund_count = (
db.query(RefundRequest)
.filter(
RefundRequest.wallet_id.in_(wallet_ids),
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
if pending_refund_count > 0:
raise ValueError("用户存在未完结退款,禁止删除")
pending_order_count = (
db.query(PaymentOrder)
.filter(
PaymentOrder.wallet_id.in_(wallet_ids),
PaymentOrder.status.in_(["pending", "paid"]),
)
.count()
)
if pending_order_count > 0:
raise ValueError("用户存在未完结充值订单,禁止删除")
# 手动删除子记录,避免 SQLAlchemy 的 ORM cascade 与数据库 CASCADE 冲突
# 这些表的数据库外键已经设置了 ON DELETE CASCADE,但 SQLAlchemy 会先尝试 UPDATE 设置为 NULL
# 所以我们手动删除来避免这个问题
# (UserPreference/AnnouncementRead 的数据库外键是 ON DELETE CASCADE,
# 但 SQLAlchemy 会先尝试 UPDATE SET NULL 导致冲突)
db.query(UserPreference).filter(UserPreference.user_id == user_id).delete(
synchronize_session=False
)
db.query(UserQuota).filter(UserQuota.user_id == user_id).delete(synchronize_session=False)
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
synchronize_session=False
)
# 财务记录(Wallet/WalletTransaction/PaymentOrder/RefundRequest/PaymentCallback)
# 和 Usage 记录全部保留,数据库外键 SET NULL 自动断开关联,
# 由自动清理策略统一回收。
api_key_count = int(
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user_id).scalar() or 0
)
@@ -326,29 +386,6 @@ class UserService:
logger.info(f"密码更改成功: 用户ID {user_id}")
return True, "密码更改成功"
@staticmethod
def update_user_quota(
db: Session,
user_id: str,
quota_usd: float | None = None,
) -> User | None:
"""更新用户配额"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
return None
if quota_usd is not None:
user.quota_usd = quota_usd
db.commit()
db.refresh(user)
# 清除用户缓存
asyncio.create_task(UserCacheService.invalidate_user_cache(user.id, user.email))
logger.debug(f"更新用户配额: {user.email} (USD: {quota_usd})")
return user
@staticmethod
def get_user_usage_stats(
db: Session,
+3
View File
@@ -0,0 +1,3 @@
from src.services.wallet.service import WalletAccessResult, WalletService
__all__ = ["WalletAccessResult", "WalletService"]
+942
View File
@@ -0,0 +1,942 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from typing import Literal
from uuid import uuid4
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.core.enums import UserRole
from src.models.database import (
ApiKey,
PaymentOrder,
RefundRequest,
Usage,
User,
Wallet,
WalletTransaction,
)
from src.services.billing.precision import to_money_decimal
WalletCategory = Literal["recharge", "gift", "adjust", "refund"]
WalletBalanceBucket = Literal["recharge", "gift"]
REASON_TOPUP_ADMIN_MANUAL = "topup_admin_manual"
REASON_TOPUP_GATEWAY = "topup_gateway"
REASON_TOPUP_CARD_CODE = "topup_card_code"
REASON_GIFT_INITIAL = "gift_initial"
REASON_GIFT_CAMPAIGN = "gift_campaign"
REASON_GIFT_EXPIRE_RECLAIM = "gift_expire_reclaim"
REASON_ADJUST_ADMIN = "adjust_admin"
REASON_ADJUST_SYSTEM = "adjust_system"
REASON_REFUND_OUT = "refund_out"
REASON_REFUND_REVERT = "refund_revert"
@dataclass(slots=True)
class WalletAccessResult:
allowed: bool
remaining: Decimal | None
message: str
wallet: Wallet | None = None
class WalletService:
"""统一钱包服务。"""
@staticmethod
def get_limit_mode(wallet: Wallet | None) -> str:
if wallet is None:
return "finite"
limit_mode = getattr(wallet, "limit_mode", None)
if limit_mode in {"finite", "unlimited"}:
return str(limit_mode)
return "finite"
@classmethod
def is_unlimited_wallet(cls, wallet: Wallet | None) -> bool:
return cls.get_limit_mode(wallet) == "unlimited"
@classmethod
def get_recharge_balance_value(cls, wallet: Wallet | None) -> Decimal:
if wallet is None:
return Decimal("0")
return to_money_decimal(wallet.balance)
@classmethod
def get_gift_balance_value(cls, wallet: Wallet | None) -> Decimal:
if wallet is None:
return Decimal("0")
return to_money_decimal(getattr(wallet, "gift_balance", None))
@classmethod
def get_spendable_balance_value(cls, wallet: Wallet | None) -> Decimal:
return cls.get_recharge_balance_value(wallet) + cls.get_gift_balance_value(wallet)
@classmethod
def get_refundable_balance_value(cls, wallet: Wallet | None) -> Decimal:
# 赠款余额不可退款,仅充值余额可退。
return cls.get_recharge_balance_value(wallet)
@classmethod
def serialize_wallet_summary(cls, wallet: Wallet | None) -> dict[str, object]:
recharge_balance = cls.get_recharge_balance_value(wallet)
gift_balance = cls.get_gift_balance_value(wallet)
spendable_balance = recharge_balance + gift_balance
limit_mode = cls.get_limit_mode(wallet)
return {
"id": wallet.id if wallet else None,
"balance": float(spendable_balance),
"recharge_balance": float(recharge_balance),
"gift_balance": float(gift_balance),
"refundable_balance": float(recharge_balance),
"currency": wallet.currency if wallet else "USD",
"status": wallet.status if wallet else "active",
"limit_mode": limit_mode,
"unlimited": limit_mode == "unlimited",
"total_recharged": float(wallet.total_recharged or 0) if wallet else 0.0,
"total_consumed": float(wallet.total_consumed or 0) if wallet else 0.0,
"total_refunded": float(wallet.total_refunded or 0) if wallet else 0.0,
"total_adjusted": float(wallet.total_adjusted or 0) if wallet else 0.0,
"updated_at": wallet.updated_at if wallet else None,
}
@staticmethod
def _build_order_no(prefix: str) -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"{prefix}_{ts}_{uuid4().hex[:12]}"
@classmethod
def initialize_user_wallet(
cls,
db: Session,
*,
user: User,
initial_gift_usd: Decimal | float | int | str | None,
unlimited: bool = False,
description: str = "用户初始赠款",
) -> Wallet | None:
"""初始化用户钱包,并按需要写入初始赠款。"""
if not user.id:
return None
wallet = cls.get_wallet(db, user_id=user.id)
if wallet is None:
wallet = Wallet(
user_id=user.id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="unlimited" if unlimited else "finite",
currency="USD",
status="active",
)
db.add(wallet)
db.flush()
else:
wallet.limit_mode = "unlimited" if unlimited else "finite"
gift_amount = to_money_decimal(initial_gift_usd)
if not unlimited and gift_amount > Decimal("0"):
cls.create_wallet_transaction(
db,
wallet=wallet,
category="gift",
reason_code=REASON_GIFT_INITIAL,
amount=gift_amount,
balance_type="gift",
link_type="system_task",
link_id=user.id,
description=description,
)
return wallet
@classmethod
def initialize_api_key_wallet(
cls,
db: Session,
*,
api_key: ApiKey,
initial_balance_usd: Decimal | float | int | str | None,
unlimited: bool = False,
operator_id: str | None = None,
description: str = "初始调账",
) -> Wallet | None:
"""初始化独立 Key 钱包,并按需执行初始调账。
设计目标:
- 初始化语义与用户钱包保持一致(均由 WalletService 统一入口完成)
- 独立 Key 不支持充值,余额变动统一通过调账流水实现
"""
if not api_key.id:
return None
wallet = cls.get_wallet(db, api_key_id=api_key.id)
if wallet is None:
wallet = Wallet(
api_key_id=api_key.id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="unlimited" if unlimited else "finite",
currency="USD",
status="active",
)
db.add(wallet)
db.flush()
else:
wallet.limit_mode = "unlimited" if unlimited else "finite"
initial_amount = to_money_decimal(initial_balance_usd)
if not unlimited and initial_amount > Decimal("0"):
cls.create_wallet_transaction(
db,
wallet=wallet,
category="adjust",
reason_code=REASON_ADJUST_SYSTEM,
amount=initial_amount,
balance_type="recharge",
link_type="system_task",
link_id=api_key.id,
operator_id=operator_id,
description=description,
)
return wallet
@classmethod
def get_wallet(
cls,
db: Session,
*,
user_id: str | None = None,
api_key_id: str | None = None,
) -> Wallet | None:
if api_key_id:
wallet = db.query(Wallet).filter(Wallet.api_key_id == api_key_id).first()
if wallet is not None:
return wallet
if user_id:
return db.query(Wallet).filter(Wallet.user_id == user_id).first()
return None
@classmethod
def get_or_create_wallet(
cls,
db: Session,
*,
user: User | None = None,
api_key: ApiKey | None = None,
user_id: str | None = None,
api_key_id: str | None = None,
) -> Wallet | None:
if user is None and user_id:
user = db.query(User).filter(User.id == user_id).first()
if api_key is None and api_key_id:
api_key = db.query(ApiKey).filter(ApiKey.id == api_key_id).first()
owner_user_id = user.id if user else user_id
owner_api_key_id = api_key.id if api_key else api_key_id
# owner 解析规则:
# - 独立 Key: 归属 API Key 钱包
# - 普通 Key + 用户: 归属用户钱包(避免 user_id/api_key_id 同时写入)
# - 仅提供 API Key: 归属 API Key 钱包
api_key_is_standalone = bool(getattr(api_key, "is_standalone", False)) if api_key else False
if owner_user_id is not None and not api_key_is_standalone:
owner_api_key_id = None
elif owner_api_key_id is not None:
owner_user_id = None
wallet = cls.get_wallet(db, user_id=owner_user_id, api_key_id=owner_api_key_id)
if wallet:
return wallet
if owner_user_id is None and owner_api_key_id is None:
return None
bootstrap = Wallet(
user_id=owner_user_id,
api_key_id=owner_api_key_id,
balance=Decimal("0"),
gift_balance=Decimal("0"),
total_recharged=Decimal("0"),
total_consumed=Decimal("0"),
total_refunded=Decimal("0"),
total_adjusted=Decimal("0"),
limit_mode="finite",
currency="USD",
status="active",
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
try:
with db.begin_nested():
db.add(bootstrap)
db.flush()
return bootstrap
except IntegrityError:
# 并发创建时可能触发唯一约束,回查已创建的钱包并复用。
wallet = cls.get_wallet(db, user_id=owner_user_id, api_key_id=owner_api_key_id)
if wallet is not None:
return wallet
raise
@classmethod
def check_request_allowed(
cls,
db: Session,
*,
user: User | None,
api_key: ApiKey | None = None,
) -> WalletAccessResult:
if user and user.role == UserRole.ADMIN:
return WalletAccessResult(True, None, "OK", None)
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
if wallet is None:
return WalletAccessResult(False, Decimal("0"), "钱包不存在", None)
remaining = cls.get_spendable_balance_value(wallet)
recharge_balance = cls.get_recharge_balance_value(wallet)
if wallet.status != "active":
return WalletAccessResult(False, remaining, "钱包不可用", wallet)
# 充值余额为负视为欠费,禁止继续消费(即使总可用余额仍为正)。
if recharge_balance < Decimal("0"):
return WalletAccessResult(False, recharge_balance, "钱包欠费,请先充值", wallet)
if cls.is_unlimited_wallet(wallet):
return WalletAccessResult(True, None, "OK", wallet)
if remaining <= Decimal("0"):
return WalletAccessResult(False, remaining, "钱包余额不足", wallet)
return WalletAccessResult(True, remaining, "OK", wallet)
@classmethod
def get_balance_snapshot(
cls,
db: Session,
*,
user: User | None,
api_key: ApiKey | None = None,
) -> Decimal | None:
wallet = cls.get_or_create_wallet(db, user=user, api_key=api_key)
if wallet is None:
return None
recharge_balance = cls.get_recharge_balance_value(wallet)
if recharge_balance < Decimal("0"):
return recharge_balance
if cls.is_unlimited_wallet(wallet):
return None
return cls.get_spendable_balance_value(wallet)
@classmethod
def _resolve_wallet_for_usage(cls, db: Session, usage: Usage) -> Wallet | None:
if usage.wallet_id:
wallet = db.query(Wallet).filter(Wallet.id == usage.wallet_id).first()
if wallet:
return wallet
api_key = None
if usage.api_key_id:
api_key = db.query(ApiKey).filter(ApiKey.id == usage.api_key_id).first()
if api_key and api_key.is_standalone:
return cls.get_or_create_wallet(db, api_key=api_key)
if usage.user_id:
user = db.query(User).filter(User.id == usage.user_id).first()
return cls.get_or_create_wallet(db, user=user, api_key=api_key)
return None
@classmethod
def apply_usage_charge(
cls,
db: Session,
*,
usage: Usage,
amount_usd: Decimal | float | int | str,
) -> tuple[Decimal | None, Decimal | None]:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
return None, None
wallet = cls._resolve_wallet_for_usage(db, usage)
if wallet is None:
return None, None
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
return None, None
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
if cls.is_unlimited_wallet(locked_wallet):
locked_wallet.total_consumed = to_money_decimal(locked_wallet.total_consumed) + amount
locked_wallet.updated_at = datetime.now(timezone.utc)
usage.wallet_id = locked_wallet.id
usage.wallet_balance_before = before_total
usage.wallet_balance_after = before_total
usage.wallet_recharge_balance_before = before_recharge
usage.wallet_recharge_balance_after = before_recharge
usage.wallet_gift_balance_before = before_gift
usage.wallet_gift_balance_after = before_gift
return before_total, before_total
# 赠款优先扣减:赠款不可退款,优先消耗可避免与充值余额混淆。
gift_deduction = min(max(before_gift, Decimal("0")), amount)
recharge_deduction = amount - gift_deduction
after_gift = before_gift - gift_deduction
after_recharge = before_recharge - recharge_deduction
after_total = after_recharge + after_gift
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.total_consumed = to_money_decimal(locked_wallet.total_consumed) + amount
locked_wallet.updated_at = datetime.now(timezone.utc)
usage.wallet_id = locked_wallet.id
usage.wallet_balance_before = before_total
usage.wallet_balance_after = after_total
usage.wallet_recharge_balance_before = before_recharge
usage.wallet_recharge_balance_after = after_recharge
usage.wallet_gift_balance_before = before_gift
usage.wallet_gift_balance_after = after_gift
return before_total, after_total
@classmethod
def set_wallet_limit_mode(
cls,
db: Session,
*,
wallet: Wallet,
limit_mode: Literal["finite", "unlimited"],
) -> Wallet:
if limit_mode not in {"finite", "unlimited"}:
raise ValueError("limit_mode must be finite or unlimited")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
locked_wallet.limit_mode = limit_mode
locked_wallet.updated_at = datetime.now(timezone.utc)
db.flush()
return locked_wallet
@classmethod
def create_wallet_transaction(
cls,
db: Session,
*,
wallet: Wallet,
category: WalletCategory,
reason_code: str,
amount: Decimal | float | int | str,
balance_type: WalletBalanceBucket | None = None,
link_type: str | None = None,
link_id: str | None = None,
operator_id: str | None = None,
description: str | None = None,
) -> WalletTransaction:
if category not in {"recharge", "gift", "adjust", "refund"}:
raise ValueError("category must be recharge/gift/adjust/refund")
if not reason_code:
raise ValueError("reason_code is required")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
delta = to_money_decimal(amount)
bucket = balance_type
if bucket is None:
bucket = "gift" if category == "gift" else "recharge"
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
after_recharge = before_recharge
after_gift = before_gift
if bucket == "recharge":
after_recharge = before_recharge + delta
else:
after_gift = before_gift + delta
after_total = after_recharge + after_gift
if category == "refund" and bucket != "recharge":
raise ValueError("refund transaction must use recharge balance")
if category == "refund" and delta < Decimal("0") and after_recharge < Decimal("0"):
raise ValueError("refund amount exceeds refundable recharge balance")
if bucket == "gift" and delta < Decimal("0") and after_gift < Decimal("0"):
raise ValueError("gift balance cannot be negative")
if bucket == "gift" and locked_wallet.api_key_id is not None:
raise ValueError("api key wallet does not support gift balance")
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.updated_at = datetime.now(timezone.utc)
if category == "recharge":
locked_wallet.total_recharged = to_money_decimal(locked_wallet.total_recharged) + delta
elif category == "refund":
# refund_out 为负值(累计退款增加);refund_revert 为正值(累计退款回退)。
next_total_refunded = to_money_decimal(locked_wallet.total_refunded) - delta
locked_wallet.total_refunded = max(next_total_refunded, Decimal("0"))
elif category in {"gift", "adjust"}:
locked_wallet.total_adjusted = to_money_decimal(locked_wallet.total_adjusted) + delta
tx = WalletTransaction(
wallet_id=locked_wallet.id,
category=category,
reason_code=reason_code,
amount=delta,
balance_before=before_total,
balance_after=after_total,
recharge_balance_before=before_recharge,
recharge_balance_after=after_recharge,
gift_balance_before=before_gift,
gift_balance_after=after_gift,
link_type=link_type,
link_id=link_id,
operator_id=operator_id,
description=description,
)
db.add(tx)
db.flush()
return tx
@classmethod
def create_manual_recharge_order(
cls,
db: Session,
*,
wallet: Wallet,
amount_usd: Decimal | float | int | str,
payment_method: str = "admin_manual",
operator_id: str | None = None,
description: str | None = None,
reason_code: str | None = None,
link_type: str = "payment_order",
link_id: str | None = None,
) -> PaymentOrder:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("recharge amount must be positive")
if wallet.api_key_id is not None:
raise ValueError("api key wallet does not support recharge, use adjust instead")
now = datetime.now(timezone.utc)
order = PaymentOrder(
order_no=cls._build_order_no("po"),
wallet_id=wallet.id,
user_id=wallet.user_id,
amount_usd=amount,
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=amount,
payment_method=payment_method,
status="credited",
paid_at=now,
credited_at=now,
gateway_response={
"source": "manual",
"operator_id": operator_id,
"description": description,
},
)
db.add(order)
db.flush()
tx_reason = reason_code
if tx_reason is None:
if payment_method in {"card_code", "gift_code", "card_recharge"}:
tx_reason = REASON_TOPUP_CARD_CODE
else:
tx_reason = REASON_TOPUP_ADMIN_MANUAL
cls.create_wallet_transaction(
db,
wallet=wallet,
category="recharge",
reason_code=tx_reason,
amount=amount,
balance_type="recharge",
link_type=link_type,
link_id=link_id or order.id,
operator_id=operator_id,
description=description or "管理员充值",
)
return order
@classmethod
def admin_adjust_balance(
cls,
db: Session,
*,
wallet: Wallet,
amount_usd: Decimal | float | int | str,
balance_type: Literal["recharge", "gift"] = "recharge",
operator_id: str | None = None,
description: str | None = None,
) -> WalletTransaction:
amount = to_money_decimal(amount_usd)
if amount == Decimal("0"):
raise ValueError("adjust amount must not be zero")
if balance_type not in {"recharge", "gift"}:
raise ValueError("balance_type must be recharge or gift")
if balance_type == "gift" and wallet.api_key_id is not None:
raise ValueError("api key wallet does not support gift balance")
# 正向调账:加给谁就加给谁,不做抵充。
if amount > Decimal("0"):
return cls.create_wallet_transaction(
db,
wallet=wallet,
category="adjust",
reason_code=REASON_ADJUST_ADMIN,
amount=amount,
balance_type=balance_type,
link_type="admin_action",
link_id=wallet.id,
operator_id=operator_id,
description=description or "管理员调账",
)
# 负向调账:先扣所选账户,再扣另一账户;若仍不足,继续计入充值余额(可为负)。
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
before_recharge = cls.get_recharge_balance_value(locked_wallet)
before_gift = cls.get_gift_balance_value(locked_wallet)
before_total = before_recharge + before_gift
after_recharge = before_recharge
after_gift = before_gift
remaining = -amount
def consume_positive_bucket(
balance: Decimal, to_consume: Decimal
) -> tuple[Decimal, Decimal]:
if to_consume <= Decimal("0"):
return balance, Decimal("0")
available = max(balance, Decimal("0"))
consumed = min(available, to_consume)
return balance - consumed, to_consume - consumed
if balance_type == "gift":
after_gift, remaining = consume_positive_bucket(after_gift, remaining)
after_recharge, remaining = consume_positive_bucket(after_recharge, remaining)
else:
after_recharge, remaining = consume_positive_bucket(after_recharge, remaining)
after_gift, remaining = consume_positive_bucket(after_gift, remaining)
if remaining > Decimal("0"):
after_recharge = after_recharge - remaining
if after_gift < Decimal("0"):
raise ValueError("gift balance cannot be negative")
after_total = after_recharge + after_gift
locked_wallet.balance = after_recharge
locked_wallet.gift_balance = after_gift
locked_wallet.updated_at = datetime.now(timezone.utc)
locked_wallet.total_adjusted = to_money_decimal(locked_wallet.total_adjusted) + amount
tx = WalletTransaction(
wallet_id=locked_wallet.id,
category="adjust",
reason_code=REASON_ADJUST_ADMIN,
amount=amount,
balance_before=before_total,
balance_after=after_total,
recharge_balance_before=before_recharge,
recharge_balance_after=after_recharge,
gift_balance_before=before_gift,
gift_balance_after=after_gift,
link_type="admin_action",
link_id=wallet.id,
operator_id=operator_id,
description=description or "管理员调账",
)
db.add(tx)
db.flush()
return tx
@classmethod
def _get_pending_refund_reserved_amount(
cls,
db: Session,
*,
wallet_id: str | None = None,
payment_order_id: str | None = None,
) -> Decimal:
query = db.query(func.coalesce(func.sum(RefundRequest.amount_usd), 0)).filter(
RefundRequest.status.in_(["pending_approval", "approved"])
)
if wallet_id is not None:
query = query.filter(RefundRequest.wallet_id == wallet_id)
if payment_order_id is not None:
query = query.filter(RefundRequest.payment_order_id == payment_order_id)
return to_money_decimal(query.scalar() or 0)
@classmethod
def create_refund_request(
cls,
db: Session,
*,
wallet: Wallet,
user_id: str | None,
amount_usd: Decimal | float | int | str,
refund_no: str,
source_type: str,
source_id: str | None,
refund_mode: str,
payment_order: PaymentOrder | None = None,
reason: str | None = None,
requested_by: str | None = None,
idempotency_key: str | None = None,
) -> RefundRequest:
amount = to_money_decimal(amount_usd)
if amount <= Decimal("0"):
raise ValueError("refund amount must be positive")
locked_wallet = (
db.query(Wallet).filter(Wallet.id == wallet.id).with_for_update().one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
refundable_balance = cls.get_refundable_balance_value(locked_wallet)
reserved_wallet_amount = cls._get_pending_refund_reserved_amount(
db,
wallet_id=locked_wallet.id,
)
available_refundable_balance = refundable_balance - reserved_wallet_amount
if amount > available_refundable_balance:
raise ValueError("refund amount exceeds available refundable recharge balance")
locked_payment_order = None
if payment_order is not None:
locked_payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == payment_order.id)
.with_for_update()
.one_or_none()
)
if locked_payment_order is None:
raise ValueError("payment order not found")
if locked_payment_order.wallet_id != locked_wallet.id:
raise ValueError("payment order does not belong to wallet")
if locked_payment_order.status != "credited":
raise ValueError("payment order is not refundable")
refundable_amount = to_money_decimal(locked_payment_order.refundable_amount_usd)
reserved_order_amount = cls._get_pending_refund_reserved_amount(
db,
payment_order_id=locked_payment_order.id,
)
available_refundable_amount = refundable_amount - reserved_order_amount
if amount > available_refundable_amount:
raise ValueError("refund amount exceeds available refundable amount")
refund = RefundRequest(
refund_no=refund_no,
wallet_id=locked_wallet.id,
user_id=user_id,
payment_order_id=locked_payment_order.id if locked_payment_order else None,
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
amount_usd=amount,
status="pending_approval",
reason=reason,
requested_by=requested_by,
idempotency_key=idempotency_key,
)
db.add(refund)
db.flush()
return refund
@classmethod
def move_refund_to_processing(
cls,
db: Session,
*,
refund: RefundRequest,
operator_id: str | None = None,
) -> WalletTransaction:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status not in {"approved", "pending_approval"}:
raise ValueError("refund status is not approvable")
locked_wallet = (
db.query(Wallet)
.filter(Wallet.id == locked_refund.wallet_id)
.with_for_update()
.one_or_none()
)
if locked_wallet is None:
raise ValueError("wallet not found")
payment_order = None
if locked_refund.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == locked_refund.payment_order_id)
.with_for_update()
.one_or_none()
)
if payment_order is None:
raise ValueError("payment order not found")
refund_amount = to_money_decimal(locked_refund.amount_usd)
refundable_amount = to_money_decimal(payment_order.refundable_amount_usd)
if refund_amount > refundable_amount:
raise ValueError("refund amount exceeds refundable amount")
tx = cls.create_wallet_transaction(
db,
wallet=locked_wallet,
category="refund",
reason_code=REASON_REFUND_OUT,
amount=-to_money_decimal(locked_refund.amount_usd),
balance_type="recharge",
link_type="refund_request",
link_id=locked_refund.id,
operator_id=operator_id,
description="退款占款",
)
if payment_order is not None:
delta = to_money_decimal(locked_refund.amount_usd)
payment_order.refunded_amount_usd = (
to_money_decimal(payment_order.refunded_amount_usd) + delta
)
payment_order.refundable_amount_usd = (
to_money_decimal(payment_order.refundable_amount_usd) - delta
)
locked_refund.status = "processing"
locked_refund.approved_by = operator_id
locked_refund.processed_by = operator_id
locked_refund.processed_at = datetime.now(timezone.utc)
locked_refund.updated_at = datetime.now(timezone.utc)
return tx
@classmethod
def fail_refund(
cls,
db: Session,
*,
refund: RefundRequest,
reason: str,
operator_id: str | None = None,
) -> WalletTransaction | None:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status in {"pending_approval", "approved"}:
locked_refund.status = "failed"
locked_refund.failure_reason = reason
locked_refund.updated_at = datetime.now(timezone.utc)
return None
if locked_refund.status != "processing":
raise ValueError(f"cannot fail refund in status: {locked_refund.status}")
wallet = db.query(Wallet).filter(Wallet.id == locked_refund.wallet_id).first()
if wallet is None:
raise ValueError("wallet not found")
tx = cls.create_wallet_transaction(
db,
wallet=wallet,
category="refund",
reason_code=REASON_REFUND_REVERT,
amount=to_money_decimal(locked_refund.amount_usd),
balance_type="recharge",
link_type="refund_request",
link_id=locked_refund.id,
operator_id=operator_id,
description="退款失败回补",
)
if locked_refund.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(PaymentOrder.id == locked_refund.payment_order_id)
.with_for_update()
.one_or_none()
)
if payment_order is not None:
delta = to_money_decimal(locked_refund.amount_usd)
payment_order.refunded_amount_usd = (
to_money_decimal(payment_order.refunded_amount_usd) - delta
)
payment_order.refundable_amount_usd = (
to_money_decimal(payment_order.refundable_amount_usd) + delta
)
locked_refund.status = "failed"
locked_refund.failure_reason = reason
locked_refund.updated_at = datetime.now(timezone.utc)
return tx
@classmethod
def complete_refund(
cls,
db: Session,
*,
refund: RefundRequest,
gateway_refund_id: str | None = None,
payout_reference: str | None = None,
payout_proof: dict | None = None,
) -> RefundRequest:
locked_refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == refund.id)
.with_for_update()
.one_or_none()
)
if locked_refund is None:
raise ValueError("refund not found")
if locked_refund.status != "processing":
raise ValueError("refund status must be processing before completion")
locked_refund.status = "succeeded"
locked_refund.gateway_refund_id = gateway_refund_id
locked_refund.payout_reference = payout_reference
locked_refund.payout_proof = payout_proof
locked_refund.completed_at = datetime.now(timezone.utc)
locked_refund.updated_at = datetime.now(timezone.utc)
return locked_refund
@@ -0,0 +1,289 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from src.api.admin.api_keys.routes import (
AdminGetFullKeyAdapter,
AdminToggleApiKeyAdapter,
router as admin_api_keys_router,
)
from src.api.admin.users.routes import (
AdminGetUserKeyFullKeyAdapter,
AdminToggleUserKeyLockAdapter,
router as admin_users_router,
)
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.database import get_db
def _build_context(db: MagicMock) -> SimpleNamespace:
return SimpleNamespace(
db=db,
add_audit_metadata=lambda **_: None,
)
def _mock_query_first(db: MagicMock, value: object | None) -> None:
db.query.return_value.filter.return_value.first.return_value = value
def _build_admin_users_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
app = FastAPI()
app.include_router(admin_users_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
ensure_json_body=lambda: {},
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.admin.users.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
def _build_admin_api_keys_app(db: MagicMock, monkeypatch: pytest.MonkeyPatch) -> TestClient:
app = FastAPI()
app.include_router(admin_api_keys_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id="admin-1"),
ensure_json_body=lambda: {},
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.admin.api_keys.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
@pytest.mark.asyncio
async def test_toggle_user_key_lock_adapter_success() -> None:
db = MagicMock()
api_key = SimpleNamespace(id="key-1", user_id="user-1", is_standalone=False, is_locked=False)
_mock_query_first(db, api_key)
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-1")
result = await adapter.handle(_build_context(db))
assert result["id"] == "key-1"
assert result["is_locked"] is True
assert "锁定" in result["message"]
db.commit.assert_called_once()
db.refresh.assert_called_once_with(api_key)
@pytest.mark.asyncio
async def test_toggle_user_key_lock_adapter_not_found_for_standalone_or_wrong_owner() -> None:
db = MagicMock()
_mock_query_first(db, None)
adapter = AdminToggleUserKeyLockAdapter(user_id="user-1", key_id="key-standalone")
with pytest.raises(NotFoundException):
await adapter.handle(_build_context(db))
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_success(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-2",
user_id="user-1",
is_standalone=False,
key_encrypted="encrypted-value",
)
_mock_query_first(db, api_key)
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-full-key")
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-2")
result = await adapter.handle(_build_context(db))
assert result == {"key": "sk-user-full-key"}
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_requires_encrypted_key() -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-3",
user_id="user-1",
is_standalone=False,
key_encrypted=None,
)
_mock_query_first(db, api_key)
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-3")
with pytest.raises(InvalidRequestException):
await adapter.handle(_build_context(db))
@pytest.mark.asyncio
async def test_get_user_key_full_key_adapter_returns_500_on_decrypt_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-4",
user_id="user-1",
is_standalone=False,
key_encrypted="encrypted-value",
)
_mock_query_first(db, api_key)
def _raise(_: str) -> str:
raise ValueError("decrypt failed")
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", _raise)
adapter = AdminGetUserKeyFullKeyAdapter(user_id="user-1", key_id="key-4")
with pytest.raises(HTTPException) as exc_info:
await adapter.handle(_build_context(db))
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_standalone_toggle_adapters_reject_normal_user_key() -> None:
db = MagicMock()
normal_key = SimpleNamespace(
id="key-user",
user_id="user-1",
is_standalone=False,
is_active=True,
is_locked=False,
key_encrypted="encrypted-value",
updated_at=datetime.now(timezone.utc),
)
_mock_query_first(db, normal_key)
context = _build_context(db)
with pytest.raises(InvalidRequestException):
await AdminToggleApiKeyAdapter(key_id="key-user").handle(context)
with pytest.raises(InvalidRequestException):
await AdminGetFullKeyAdapter(key_id="key-user").handle(context)
def test_user_key_lock_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(id="key-5", user_id="user-2", is_standalone=False, is_locked=False)
_mock_query_first(db, api_key)
client = _build_admin_users_app(db, monkeypatch)
response = client.patch("/api/admin/users/user-2/api-keys/key-5/lock")
assert response.status_code == 200
assert response.json()["id"] == "key-5"
assert response.json()["is_locked"] is True
def test_user_key_full_key_route_path_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="key-6",
user_id="user-2",
is_standalone=False,
key_encrypted="enc",
)
_mock_query_first(db, api_key)
monkeypatch.setattr("src.core.crypto.crypto_service.decrypt", lambda _v: "sk-user-route-key")
client = _build_admin_users_app(db, monkeypatch)
response = client.get("/api/admin/users/user-2/api-keys/key-6/full-key")
assert response.status_code == 200
assert response.json() == {"key": "sk-user-route-key"}
def test_standalone_lock_route_removed(monkeypatch: pytest.MonkeyPatch) -> None:
client = _build_admin_api_keys_app(MagicMock(), monkeypatch)
response = client.patch("/api/admin/api-keys/key-1/lock")
assert response.status_code == 404
def test_standalone_list_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="sa-key-1",
user_id="admin-1",
name="Standalone Key",
get_display_key=lambda: "sk-stand...1234",
is_active=True,
is_standalone=True,
total_requests=0,
total_cost_usd=0,
rate_limit=None,
allowed_providers=None,
allowed_api_formats=None,
allowed_models=None,
last_used_at=None,
expires_at=None,
created_at=datetime.now(timezone.utc),
updated_at=None,
auto_delete_on_expiry=False,
)
query = db.query.return_value.filter.return_value
query.count.return_value = 1
query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [api_key]
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.get_wallet",
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: SimpleNamespace(id="w-1"),
)
client = _build_admin_api_keys_app(db, monkeypatch)
response = client.get("/api/admin/api-keys")
assert response.status_code == 200
payload = response.json()
assert len(payload["api_keys"]) == 1
assert "is_locked" not in payload["api_keys"][0]
def test_standalone_detail_route_does_not_expose_is_locked(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
api_key = SimpleNamespace(
id="sa-key-2",
user_id="admin-1",
name="Standalone Key 2",
get_display_key=lambda: "sk-stand...5678",
is_active=True,
is_standalone=True,
total_requests=0,
total_cost_usd=0,
rate_limit=None,
allowed_providers=[],
allowed_api_formats=[],
allowed_models=[],
last_used_at=None,
expires_at=None,
created_at=datetime.now(timezone.utc),
updated_at=None,
)
_mock_query_first(db, api_key)
monkeypatch.setattr(
"src.api.admin.api_keys.routes.WalletService.get_wallet",
lambda _db, user_id=None, api_key_id=None, user=None, api_key=None: None,
)
client = _build_admin_api_keys_app(db, monkeypatch)
response = client.get("/api/admin/api-keys/sa-key-2")
assert response.status_code == 200
payload = response.json()
assert payload["id"] == "sa-key-2"
assert "is_locked" not in payload
+232
View File
@@ -0,0 +1,232 @@
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.admin.payments.routes import AdminPaymentOrderCreditAdapter
from src.api.payment.routes import router as payment_router
from src.config import config
from src.database import get_db
from src.models.database import PaymentOrder
from src.services.payment.gateway import get_payment_gateway
CALLBACK_SECRET = "test-callback-secret"
def _build_payment_app(db: MagicMock) -> TestClient:
app = FastAPI()
app.include_router(payment_router)
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
def _sign_payload(payload: dict[str, object]) -> str:
gateway = get_payment_gateway("alipay")
signature = gateway.build_callback_signature(payload=payload, callback_secret=CALLBACK_SECRET)
assert signature is not None
return signature
def test_specific_wechat_callback_route_is_not_shadowed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": True,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-wechat", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/wechat",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "wechat"
assert payload["payment_method_seen"] == "wechat"
assert payload["request_path"] == "/api/payment/callback/wechat"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
db.commit.assert_called_once()
def test_generic_payment_callback_route_still_handles_custom_methods(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
captured_kwargs: dict[str, object] = {}
def _fake_handle_callback(*args: object, **kwargs: object) -> dict[str, object]:
captured_kwargs.update(kwargs)
return {
"ok": True,
"credited": False,
"duplicate": False,
"payment_method_seen": kwargs["payment_method"],
}
monkeypatch.setattr("src.api.payment.routes.PaymentService.handle_callback", _fake_handle_callback)
callback_payload = {"callback_key": "cb-generic", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/mockpay",
json=callback_payload,
headers={
"x-payment-callback-token": CALLBACK_SECRET,
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["payment_method"] == "mockpay"
assert payload["payment_method_seen"] == "mockpay"
assert captured_kwargs["callback_signature"] == _sign_payload(callback_payload)
assert captured_kwargs["callback_secret"] == CALLBACK_SECRET
assert "signature_valid" not in captured_kwargs
def test_callback_requires_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-token", "amount_usd": 1.0},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_invalid_shared_token(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
callback_payload = {"callback_key": "cb-invalid-token", "amount_usd": 1.0}
response = client.post(
"/api/payment/callback/alipay",
json=callback_payload,
headers={
"x-payment-callback-token": "wrong-token",
"x-payment-callback-signature": _sign_payload(callback_payload),
},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_rejects_missing_signature(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", CALLBACK_SECRET)
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-missing-signature", "amount_usd": 1.0},
headers={"x-payment-callback-token": CALLBACK_SECRET},
)
assert response.status_code == 401
db.commit.assert_not_called()
def test_callback_disabled_when_secret_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
db = MagicMock()
client = _build_payment_app(db)
monkeypatch.setattr(config, "payment_callback_secret", "")
response = client.post(
"/api/payment/callback/alipay",
json={"callback_key": "cb-secret-missing", "amount_usd": 1.0},
)
assert response.status_code == 503
db.commit.assert_not_called()
@pytest.mark.asyncio
async def test_admin_payment_credit_adapter_marks_manual_credit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
order = PaymentOrder(
id="po-credit",
order_no="order-credit",
wallet_id="w1",
user_id="u1",
amount_usd=Decimal("8.00000000"),
refunded_amount_usd=Decimal("0"),
refundable_amount_usd=Decimal("8.00000000"),
payment_method="alipay",
status="pending",
gateway_response={"existing": True},
)
adapter = AdminPaymentOrderCreditAdapter(order_id=order.id)
context = SimpleNamespace(
db=db,
raw_body=b"{}",
ensure_json_body=lambda: {
"pay_amount": 58.0,
"pay_currency": "CNY",
"exchange_rate": 7.25,
},
user=SimpleNamespace(id="admin-1"),
)
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.get_order",
lambda _db, order_id: order if order_id == "po-credit" else None,
)
captured: dict[str, object] = {}
def _fake_credit_order(_db: MagicMock, **kwargs: object) -> tuple[PaymentOrder, bool]:
captured.update(kwargs)
return order, True
monkeypatch.setattr(
"src.api.admin.payments.routes.PaymentService.credit_order",
_fake_credit_order,
)
result = await adapter.handle(context)
assert result["credited"] is True
assert result["order"]["id"] == "po-credit"
gateway_response = captured["gateway_response"]
assert isinstance(gateway_response, dict)
assert gateway_response["existing"] is True
assert gateway_response["manual_credit"] is True
assert gateway_response["credited_by"] == "admin-1"
db.commit.assert_called_once()
+33 -44
View File
@@ -3,7 +3,7 @@ API Pipeline 测试
测试 ApiRequestPipeline 的核心功能:
- 认证流程(API Key、JWT Token)
- 配额计算
- 余额计算
- 审计日志记录
"""
@@ -17,56 +17,43 @@ from src.api.base.pipeline import ApiRequestPipeline
from src.core.enums import UserRole
class TestPipelineQuotaCalculation:
"""测试 Pipeline 配额计算"""
class TestPipelineBalanceCalculation:
"""测试 Pipeline 余额计算"""
@pytest.fixture
def pipeline(self) -> ApiRequestPipeline:
return ApiRequestPipeline()
def test_calculate_quota_remaining_with_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试有配额限制时计算剩余配额"""
def test_calculate_balance_remaining_with_balance(self, pipeline: ApiRequestPipeline) -> None:
"""测试有限制钱包时计算剩余余额"""
mock_user = MagicMock()
mock_user.quota_usd = 100.0
mock_user.used_usd = 30.0
mock_db = MagicMock()
remaining = pipeline._calculate_quota_remaining(mock_user)
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=70.0,
):
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
assert remaining == 70.0
def test_calculate_quota_remaining_no_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试无配额限制时返回 None"""
def test_calculate_balance_remaining_unlimited(self, pipeline: ApiRequestPipeline) -> None:
"""测试无限制钱包时返回 None"""
mock_user = MagicMock()
mock_user.quota_usd = None
mock_user.used_usd = 30.0
mock_db = MagicMock()
remaining = pipeline._calculate_quota_remaining(mock_user)
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=None,
):
remaining = pipeline._calculate_balance_remaining(mock_db, mock_user)
assert remaining is None
def test_calculate_quota_remaining_negative_quota(self, pipeline: ApiRequestPipeline) -> None:
"""测试负配额时返回 None"""
mock_user = MagicMock()
mock_user.quota_usd = -1
mock_user.used_usd = 0.0
remaining = pipeline._calculate_quota_remaining(mock_user)
assert remaining is None
def test_calculate_quota_remaining_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试配额已超时返回 0"""
mock_user = MagicMock()
mock_user.quota_usd = 100.0
mock_user.used_usd = 150.0
remaining = pipeline._calculate_quota_remaining(mock_user)
assert remaining == 0.0
def test_calculate_quota_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
def test_calculate_balance_remaining_none_user(self, pipeline: ApiRequestPipeline) -> None:
"""测试用户为 None 时返回 None"""
remaining = pipeline._calculate_quota_remaining(None)
mock_db = MagicMock()
remaining = pipeline._calculate_balance_remaining(mock_db, None)
assert remaining is None
@@ -266,12 +253,10 @@ class TestPipelineAuthentication:
assert exc_info.value.status_code == 401
def test_authenticate_client_quota_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试配额超限时抛出异常"""
def test_authenticate_client_balance_exceeded(self, pipeline: ApiRequestPipeline) -> None:
"""测试余额不足时抛出异常"""
mock_user = MagicMock()
mock_user.id = "user-123"
mock_user.quota_usd = 100.0
mock_user.used_usd = 100.0
mock_api_key = MagicMock()
mock_api_key.id = "key-123"
@@ -294,13 +279,17 @@ class TestPipelineAuthentication:
):
with patch.object(
pipeline.usage_service,
"check_user_quota",
return_value=(False, "配额不足"),
"check_request_balance",
return_value=(False, "余额不足"),
):
from src.core.exceptions import QuotaExceededException
with patch(
"src.api.base.pipeline.WalletService.get_balance_snapshot",
return_value=0.0,
):
from src.core.exceptions import BalanceInsufficientException
with pytest.raises(QuotaExceededException):
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
with pytest.raises(BalanceInsufficientException):
pipeline._authenticate_client(mock_request, mock_db, mock_adapter)
class TestPipelineAdminAuth:
+202
View File
@@ -0,0 +1,202 @@
from __future__ import annotations
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.wallet.routes import router as wallet_router
from src.database import get_db
def _build_wallet_app(
db: MagicMock,
monkeypatch: pytest.MonkeyPatch,
*,
payload: dict[str, object],
user_id: str = "user-1",
) -> TestClient:
app = FastAPI()
app.include_router(wallet_router)
app.dependency_overrides[get_db] = lambda: db
async def _fake_pipeline_run(*, adapter: object, http_request: object, db: MagicMock, mode: object) -> object:
_ = http_request, mode
context = SimpleNamespace(
db=db,
user=SimpleNamespace(id=user_id),
ensure_json_body=lambda: payload,
add_audit_metadata=lambda **_: None,
)
return await adapter.handle(context)
monkeypatch.setattr("src.api.wallet.routes.pipeline.run", _fake_pipeline_run)
return TestClient(app)
def test_create_refund_route_maps_uncredited_order_to_400(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-1"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
def _raise(*args: object, **kwargs: object) -> object:
raise ValueError("payment order is not refundable")
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 400
assert "not refundable" in response.json()["detail"]
db.rollback.assert_called_once()
db.commit.assert_not_called()
def test_create_refund_route_maps_reserved_wallet_amount_to_400(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
def _raise(*args: object, **kwargs: object) -> object:
raise ValueError("refund amount exceeds available refundable recharge balance")
monkeypatch.setattr("src.api.wallet.routes.WalletService.create_refund_request", _raise)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 400
assert "available refundable recharge balance" in response.json()["detail"]
db.rollback.assert_called_once()
db.commit.assert_not_called()
def test_create_refund_route_passes_default_order_refund_mode_and_commits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-1", "reason": "test"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-1", wallet_id="wallet-1", payment_method="alipay")
refund = SimpleNamespace(
id="refund-1",
refund_no="rf-1",
payment_order_id="order-1",
source_type="payment_order",
source_id="order-1",
refund_mode="original_channel",
amount_usd=Decimal("2.00000000"),
status="pending_approval",
reason="test",
failure_reason=None,
gateway_refund_id=None,
payout_method=None,
payout_reference=None,
payout_proof=None,
created_at="2026-03-07T00:00:00Z",
updated_at="2026-03-07T00:00:00Z",
processed_at=None,
completed_at=None,
)
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
captured: dict[str, object] = {}
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
captured.update(kwargs)
return refund
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.create_refund_request",
_create_refund_request,
)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 200
body = response.json()
assert body["id"] == "refund-1"
assert body["status"] == "pending_approval"
assert captured["refund_mode"] == "original_channel"
assert captured["source_type"] == "payment_order"
assert captured["source_id"] == "order-1"
assert captured["payment_order"] is payment_order
db.commit.assert_called_once()
db.refresh.assert_called_once_with(refund)
db.rollback.assert_not_called()
def test_create_refund_route_uses_offline_payout_for_manual_recharge(
monkeypatch: pytest.MonkeyPatch,
) -> None:
db = MagicMock()
payload = {"amount_usd": 2.0, "payment_order_id": "order-2"}
client = _build_wallet_app(db, monkeypatch, payload=payload)
wallet = SimpleNamespace(id="wallet-1")
payment_order = SimpleNamespace(id="order-2", wallet_id="wallet-1", payment_method="admin_manual")
refund = SimpleNamespace(
id="refund-2",
refund_no="rf-2",
payment_order_id="order-2",
source_type="payment_order",
source_id="order-2",
refund_mode="offline_payout",
amount_usd=Decimal("2.00000000"),
status="pending_approval",
reason=None,
failure_reason=None,
gateway_refund_id=None,
payout_method=None,
payout_reference=None,
payout_proof=None,
created_at="2026-03-07T00:00:00Z",
updated_at="2026-03-07T00:00:00Z",
processed_at=None,
completed_at=None,
)
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.get_or_create_wallet",
lambda _db, user: wallet,
)
db.query.return_value.filter.return_value.first.return_value = payment_order
captured: dict[str, object] = {}
def _create_refund_request(_db: MagicMock, **kwargs: object) -> object:
captured.update(kwargs)
return refund
monkeypatch.setattr(
"src.api.wallet.routes.WalletService.create_refund_request",
_create_refund_request,
)
response = client.post("/api/wallet/refunds", json=payload)
assert response.status_code == 200
assert captured["refund_mode"] == "offline_payout"
+8 -6
View File
@@ -38,15 +38,16 @@ async def test_video_cancel_openai_route_end_to_end(monkeypatch: pytest.MonkeyPa
"""
pipeline = ApiRequestPipeline()
# Pipeline auth/quota/audit shortcuts
user = SimpleNamespace(id="u1", username="u1", role="user", quota_usd=None, used_usd=0.0)
# Pipeline auth/balance/audit shortcuts
user = SimpleNamespace(id="u1", username="u1", role="user")
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
monkeypatch.setattr(
pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key)
)
monkeypatch.setattr(
pipeline.usage_service, "check_user_quota", lambda *_args, **_kwargs: (True, "ok")
pipeline.usage_service, "check_request_balance", lambda *_args, **_kwargs: (True, "ok")
)
monkeypatch.setattr(pipeline, "_calculate_balance_remaining", lambda *_args, **_kwargs: None)
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
# DB stubs used by TaskService.cancel
@@ -152,15 +153,16 @@ async def test_video_cancel_gemini_route_end_to_end(monkeypatch: pytest.MonkeyPa
"""
pipeline = ApiRequestPipeline()
# Pipeline auth/quota/audit shortcuts
user = SimpleNamespace(id="u1", username="u1", role="user", quota_usd=None, used_usd=0.0)
# Pipeline auth/balance/audit shortcuts
user = SimpleNamespace(id="u1", username="u1", role="user")
api_key = SimpleNamespace(id="ak1", user_id="u1", is_standalone=False)
monkeypatch.setattr(
pipeline.auth_service, "authenticate_api_key", lambda _db, _k: (user, api_key)
)
monkeypatch.setattr(
pipeline.usage_service, "check_user_quota", lambda *_args, **_kwargs: (True, "ok")
pipeline.usage_service, "check_request_balance", lambda *_args, **_kwargs: (True, "ok")
)
monkeypatch.setattr(pipeline, "_calculate_balance_remaining", lambda *_args, **_kwargs: None)
monkeypatch.setattr(pipeline.audit_service, "log_event", MagicMock())
# DB stubs used by TaskService.cancel
+48 -12
View File
@@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
from src.core.exceptions import ForbiddenException
from src.core.enums import AuthSource
from src.models.database import UserRole
from src.services.auth.service import (
@@ -251,7 +252,6 @@ class TestAPIKeyAuthentication:
mock_api_key.is_locked = False
mock_api_key.expires_at = None
mock_api_key.user = mock_user
mock_api_key.balance_used_usd = 0.0
mock_db = MagicMock()
mock_db.query.return_value.options.return_value.filter.return_value.first.return_value = (
@@ -259,11 +259,7 @@ class TestAPIKeyAuthentication:
)
with patch("src.services.auth.service.ApiKey.hash_key", return_value="hashed_key"):
with patch(
"src.services.auth.service.ApiKeyService.check_balance",
return_value=(True, 100.0),
):
result = AuthService.authenticate_api_key(mock_db, "sk-test-key")
result = AuthService.authenticate_api_key(mock_db, "sk-test-key")
assert result is not None
assert result[0] == mock_user
@@ -283,7 +279,6 @@ class TestAPIKeyAuthentication:
mock_api_key.is_locked = False
mock_api_key.expires_at = None
mock_api_key.user = mock_user
mock_api_key.balance_used_usd = 0.0
mock_db = MagicMock()
mock_db.expire_on_commit = True
@@ -298,11 +293,7 @@ class TestAPIKeyAuthentication:
with patch("src.services.auth.service._should_update_last_used", return_value=True):
with patch("src.services.auth.service.ApiKey.hash_key", return_value="hashed_key"):
with patch(
"src.services.auth.service.ApiKeyService.check_balance",
return_value=(True, 100.0),
):
result = AuthService.authenticate_api_key(mock_db, "sk-test-key")
result = AuthService.authenticate_api_key(mock_db, "sk-test-key")
assert result is not None
assert mock_db.expire_on_commit is True
@@ -336,6 +327,51 @@ class TestAPIKeyAuthentication:
assert result is None
def test_authenticate_api_key_locked_non_standalone_raises_forbidden(self) -> None:
"""测试普通用户 API Key 被锁定会拒绝认证"""
mock_api_key = MagicMock()
mock_api_key.is_active = True
mock_api_key.is_locked = True
mock_api_key.is_standalone = False
mock_db = MagicMock()
mock_db.query.return_value.options.return_value.filter.return_value.first.return_value = (
mock_api_key
)
with patch("src.services.auth.service.ApiKey.hash_key", return_value="hashed_key"):
with pytest.raises(ForbiddenException):
AuthService.authenticate_api_key(mock_db, "sk-locked-key")
def test_authenticate_api_key_locked_standalone_can_pass(self) -> None:
"""测试独立 Key 即使历史上被锁定也不因锁定字段拒绝认证"""
mock_user = MagicMock()
mock_user.id = "user-standalone"
mock_user.email = "[email protected]"
mock_user.is_active = True
mock_user.is_deleted = False
mock_api_key = MagicMock()
mock_api_key.id = "key-standalone"
mock_api_key.is_active = True
mock_api_key.is_locked = True
mock_api_key.is_standalone = True
mock_api_key.expires_at = None
mock_api_key.user = mock_user
mock_db = MagicMock()
mock_db.query.return_value.options.return_value.filter.return_value.first.return_value = (
mock_api_key
)
with patch("src.services.auth.service._should_update_last_used", return_value=False):
with patch("src.services.auth.service.ApiKey.hash_key", return_value="hashed_key"):
result = AuthService.authenticate_api_key(mock_db, "sk-standalone-key")
assert result is not None
assert result[0] == mock_user
assert result[1] == mock_api_key
def test_authenticate_api_key_expired(self) -> None:
"""测试 API Key 已过期"""
mock_api_key = MagicMock()
@@ -1,166 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import MagicMock
import pytest
from src.services.system.maintenance_scheduler import MaintenanceScheduler
@pytest.mark.asyncio
async def test_user_quota_reset_disabled(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return False
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
await scheduler._perform_user_quota_reset()
assert not mock_db.query.called
assert not mock_db.commit.called
assert not mock_set_config.called
@pytest.mark.asyncio
async def test_user_quota_reset_not_due_skips(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
last_reset_at = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return 2
if key == "user_quota_last_reset_at":
return last_reset_at
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
await scheduler._perform_user_quota_reset()
assert not mock_db.query.called
assert not mock_db.commit.called
assert not mock_set_config.called
@pytest.mark.asyncio
async def test_user_quota_reset_due_runs(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
last_reset_at = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return 2
if key == "user_quota_last_reset_at":
return last_reset_at
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.update.return_value = 7
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
await scheduler._perform_user_quota_reset()
mock_db.query.assert_called_once()
mock_filter.update.assert_called_once()
_, update_kwargs = mock_filter.update.call_args
assert update_kwargs["synchronize_session"] is False
mock_db.commit.assert_called_once()
mock_set_config.assert_called_once()
@pytest.mark.asyncio
async def test_user_quota_reset_invalid_interval_defaults_to_1(monkeypatch):
scheduler = MaintenanceScheduler()
mock_db = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.create_session",
lambda: mock_db,
)
def fake_get_config(cls, db, key, default=None):
if key == "enable_user_quota_reset":
return True
if key == "user_quota_reset_interval_days":
return "abc"
if key == "user_quota_last_reset_at":
return None
return default
mock_set_config = MagicMock()
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.get_config",
classmethod(fake_get_config),
)
monkeypatch.setattr(
"src.services.system.maintenance_scheduler.SystemConfigService.set_config",
mock_set_config,
)
mock_query = MagicMock()
mock_filter = MagicMock()
mock_filter.update.return_value = 1
mock_query.filter.return_value = mock_filter
mock_db.query.return_value = mock_query
await scheduler._perform_user_quota_reset()
mock_db.commit.assert_called_once()
mock_set_config.assert_called_once()

Some files were not shown because too many files have changed in this diff Show More