mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
feat(wallet): 钱包系统替代配额系统,新增支付与退款机制
- 新增钱包余额管理、充值、扣费、退款完整流程 - 新增支付网关抽象层(支持手动/支付宝/微信) - 用量计费从配额系统迁移到钱包余额扣费 - 新增管理员钱包管理与支付订单管理页面 - 新增用户钱包中心页面 - 移除独立 Key 锁定机制,统一由钱包余额控制 - 新增相关 API 路由、序列化器与数据库迁移 - 新增钱包、支付、退款相关测试
This commit is contained in:
@@ -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")
|
||||
@@ -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 三层架构
|
||||
|
||||
Reference in New Issue
Block a user