mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Merge branch 'feat/wallet-system' into master
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制 Closes #204
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
# 根据模式选择运行方式
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
+220
@@ -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")
|
||||
@@ -15,7 +15,7 @@
|
||||
### 用户系统
|
||||
- **users**: 用户账户管理
|
||||
- **api_keys**: API 密钥管理
|
||||
- **user_quotas**: 用户配额管理
|
||||
- **wallets**: 统一钱包账户(充值余额/赠款余额/无限制模式)
|
||||
- **user_preferences**: 用户偏好设置
|
||||
|
||||
### Provider 三层架构
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 // 允许使用的模型名称列表
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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 },
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Admin payment routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Admin wallet routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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)}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
# 构建发送给上游的请求头(脱敏)
|
||||
|
||||
@@ -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:
|
||||
# 构建发送给上游的请求头(脱敏)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Payment API routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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_"):
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Wallet API routes."""
|
||||
|
||||
from .routes import router
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -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))
|
||||
@@ -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
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "是否需要邮箱验证"},
|
||||
|
||||
@@ -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端点(用户可查看提供商和模型)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
-6
@@ -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"])
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.services.payment.service import PaymentService
|
||||
|
||||
__all__ = ["PaymentService"]
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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优先模式)",
|
||||
|
||||
@@ -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 记录"""
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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密钥
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.services.wallet.service import WalletAccessResult, WalletService
|
||||
|
||||
__all__ = ["WalletAccessResult", "WalletService"]
|
||||
@@ -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
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user