ZHIWEI666 commited on
Commit
ee88a99
·
verified ·
1 Parent(s): 5640f93

支持第三方扣费

Browse files
Files changed (5) hide show
  1. app.py +22 -1
  2. database_sql.py +265 -259
  3. models_sql.py +121 -98
  4. router_admin_plugins.py +391 -0
  5. router_open_api.py +740 -0
app.py CHANGED
@@ -54,6 +54,8 @@ from router_wallet import router as wallet_router # 💰 钱包/提现
54
  from router_proxy import router as proxy_router # 🔗 代理下载
55
  from router_posts import router as posts_router # 💬 讨论区
56
  from router_tasks import router as tasks_router # 📝 任务榜
 
 
57
 
58
  from database_sql import init_sql_db, get_db, SessionLocal
59
  from models_sql import Ownership, SystemConfig
@@ -212,6 +214,14 @@ async def on_startup():
212
  except Exception as e:
213
  logger.error(f"❌ SQL 数据库初始化失败: {e}")
214
  # 不抛出异常,允许降级运行
 
 
 
 
 
 
 
 
215
 
216
  # ========== 预热检查:JSON 数据库 ==========
217
  try:
@@ -275,6 +285,8 @@ app.include_router(wallet_router) # 💰 钱包/提现
275
  app.include_router(proxy_router) # 🔗 代理下载
276
  app.include_router(posts_router) # 💬 讨论区
277
  app.include_router(tasks_router) # 📝 任务榜
 
 
278
 
279
  # ==========================================
280
  # 🟢 私有图床代理中心 (Image Proxy)
@@ -634,4 +646,13 @@ async def set_system_config(config_key: str, value: dict, current_user: str = De
634
  if not _is_admin(current_user):
635
  raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
636
  _save_system_config_to_db(db, config_key, value, updated_by=current_user)
637
- return {"status": "success", "message": "配置已更新"}
 
 
 
 
 
 
 
 
 
 
54
  from router_proxy import router as proxy_router # 🔗 代理下载
55
  from router_posts import router as posts_router # 💬 讨论区
56
  from router_tasks import router as tasks_router # 📝 任务榜
57
+ from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
58
+ from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
59
 
60
  from database_sql import init_sql_db, get_db, SessionLocal
61
  from models_sql import Ownership, SystemConfig
 
214
  except Exception as e:
215
  logger.error(f"❌ SQL 数据库初始化失败: {e}")
216
  # 不抛出异常,允许降级运行
217
+
218
+ # ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
219
+ try:
220
+ from router_open_api import load_plugins_from_db
221
+ load_plugins_from_db()
222
+ logger.info("✅ 第三方插件注册表已加载")
223
+ except Exception as e:
224
+ logger.warning(f"⚠️ 第三方插件注册表加载失败: {e}")
225
 
226
  # ========== 预热检查:JSON 数据库 ==========
227
  try:
 
285
  app.include_router(proxy_router) # 🔗 代理下载
286
  app.include_router(posts_router) # 💬 讨论区
287
  app.include_router(tasks_router) # 📝 任务榜
288
+ app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
289
+ app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
290
 
291
  # ==========================================
292
  # 🟢 私有图床代理中心 (Image Proxy)
 
646
  if not _is_admin(current_user):
647
  raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
648
  _save_system_config_to_db(db, config_key, value, updated_by=current_user)
649
+ return {"status": "success", "message": "配置已更新"}
650
+
651
+
652
+ # ==========================================
653
+ # 🖥️ 管理界面静态文件挂载
654
+ # ⚠️ 必须放在所有 API 路由注册之后,避免拦截 API
655
+ # 访问 /admin/ 会自动返回 管理界面/index.html
656
+ # ==========================================
657
+ from fastapi.staticfiles import StaticFiles
658
+ app.mount("/admin", StaticFiles(directory="管理界面", html=True), name="admin")
database_sql.py CHANGED
@@ -1,260 +1,266 @@
1
- # database_sql.py
2
- # ==========================================
3
- # 🛡️ 稳定性优化:SQL 数据库连接模块
4
- # ==========================================
5
- import os
6
- import time
7
- import logging
8
- from functools import wraps
9
- from sqlalchemy import create_engine, text
10
- from sqlalchemy.orm import sessionmaker
11
- from sqlalchemy.pool import QueuePool, NullPool
12
- from sqlalchemy.exc import OperationalError, InterfaceError, DBAPIError
13
- from models_sql import Base, SystemConfig
14
-
15
- logger = logging.getLogger("ComfyUI-Ranking.Database")
16
-
17
- # 核心:优先读取环境变量中的 PostgreSQL 数据库连接
18
- SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////tmp/comfy_financial.db")
19
-
20
- # 🚀 P2优化:连接池配置支持环境变量
21
- POOL_SIZE = int(os.environ.get("DB_POOL_SIZE", "5"))
22
- POOL_OVERFLOW = int(os.environ.get("DB_POOL_OVERFLOW", "10"))
23
-
24
- # 🚀 P1性能优化:根据数据库类型配置连接池
25
- if "sqlite" in SQLALCHEMY_DATABASE_URL:
26
- # SQLite:使用空连接池,单线程模式
27
- connect_args = {"check_same_thread": False}
28
- engine = create_engine(
29
- SQLALCHEMY_DATABASE_URL,
30
- connect_args=connect_args,
31
- poolclass=NullPool # SQLite 不需要连接池
32
- )
33
- logger.info(f"数据库引擎初始化完成 (SQLite)")
34
- else:
35
- # PostgreSQL/MySQL:配置连接池参数
36
- connect_args = {}
37
- if "postgresql" in SQLALCHEMY_DATABASE_URL or "postgres" in SQLALCHEMY_DATABASE_URL:
38
- sslmode = os.environ.get("DB_SSLMODE", "require")
39
- connect_args["sslmode"] = sslmode
40
- connect_args["connect_timeout"] = int(os.environ.get("DB_CONNECT_TIMEOUT", "10"))
41
- logger.info(f"PostgreSQL SSL 配置: sslmode={sslmode}, connect_timeout={connect_args['connect_timeout']}")
42
- engine = create_engine(
43
- SQLALCHEMY_DATABASE_URL,
44
- poolclass=QueuePool,
45
- pool_size=POOL_SIZE, # 核心连接数(支持环境变量配置)
46
- max_overflow=POOL_OVERFLOW, # 超出 pool_size 后可创建的最大连接数(支持环境变量配置)
47
- pool_timeout=30, # 获取连接超时(秒)
48
- pool_recycle=1800, # 连接回收时间(30分钟)
49
- pool_pre_ping=True, # 使用前检测连接有效性
50
- connect_args=connect_args
51
- )
52
- logger.info(f"数据库引擎初始化完成 (PostgreSQL/MySQL) - 连接池配置: pool_size={POOL_SIZE}, max_overflow={POOL_OVERFLOW}")
53
-
54
- SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
55
-
56
-
57
- # ==========================================
58
- # 🛡️ 稳定性优化:数据库操作重试装饰器
59
- # ==========================================
60
- def db_retry(max_retries: int = 3, delay: float = 0.5):
61
- """
62
- 数据库操作重试装饰器
63
-
64
- 用法:
65
- @db_retry(max_retries=3)
66
- def my_db_function(db):
67
- ...
68
- """
69
- def decorator(func):
70
- @wraps(func)
71
- def wrapper(*args, **kwargs):
72
- last_error = None
73
- for attempt in range(max_retries):
74
- try:
75
- return func(*args, **kwargs)
76
- except (OperationalError, InterfaceError, DBAPIError) as e:
77
- last_error = e
78
- if attempt < max_retries - 1:
79
- wait_time = delay * (2 ** attempt) # 指数退避
80
- logger.warning(f"DB 操作失败 (第{attempt+1}次):{e}, {wait_time}秒后重试")
81
- time.sleep(wait_time)
82
- else:
83
- logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): {e}")
84
- raise last_error
85
- return wrapper
86
- return decorator
87
-
88
-
89
- def init_sql_db():
90
- """初始化数据库,包含重试机制"""
91
- for attempt in range(3):
92
- try:
93
- Base.metadata.create_all(bind=engine)
94
-
95
- # 🔄 P7后悔模式:自动迁移新增字段
96
- _auto_migrate_p7_fields()
97
-
98
- logger.info("数据库初始化成功")
99
- return
100
- except Exception as e:
101
- if attempt < 2:
102
- logger.warning(f"数据库初始化失败 (第{attempt+1}次): {e}")
103
- time.sleep(2 ** attempt)
104
- else:
105
- logger.error(f"数据库初始化失败 (已重试3次): {e}")
106
- raise
107
-
108
-
109
- def _auto_migrate_p7_fields():
110
- """
111
- 🔄 P7后悔模式:自动迁移新增字段
112
- 检查并添加 ownerships 表和 transactions 表的新字段
113
- 所有 ALTER TABLE ADD COLUMN 均使用 IF NOT EXISTS 或 inspector 预检,避免重复执行报错
114
- """
115
- from sqlalchemy import inspect
116
-
117
- try:
118
- inspector = inspect(engine)
119
-
120
- # ========== 1. 迁移 ownerships 表 ==========
121
- if 'ownerships' in inspector.get_table_names():
122
- columns = [col['name'] for col in inspector.get_columns('ownerships')]
123
-
124
- with engine.connect() as conn:
125
- if 'price_paid' not in columns:
126
- try:
127
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
128
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
129
- else:
130
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
131
- logger.info("迁移完成: 添加 ownerships.price_paid 字段")
132
- except Exception as e:
133
- logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}")
134
-
135
- if 'is_refunded' not in columns:
136
- try:
137
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
138
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
139
- else:
140
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
141
- logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
142
- except Exception as e:
143
- logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}")
144
-
145
- if 'refunded_at' not in columns:
146
- try:
147
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
148
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
149
- else:
150
- conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
151
- logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
152
- except Exception as e:
153
- logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}")
154
-
155
- conn.commit()
156
-
157
- # ========== 2. 迁移 wallets 表(新增 task_balance 字段) ==========
158
- if 'wallets' in inspector.get_table_names():
159
- columns = [col['name'] for col in inspector.get_columns('wallets')]
160
-
161
- with engine.connect() as conn:
162
- if 'task_balance' not in columns:
163
- try:
164
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
165
- conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
166
- else:
167
- conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
168
- logger.info("[DB Migration] 添加列 wallets.task_balance")
169
- except Exception as e:
170
- logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}")
171
-
172
- conn.commit()
173
-
174
- # ========== 3. 迁移 transactions 表(提现相关新字段) ==========
175
- if 'transactions' in inspector.get_table_names():
176
- columns = [col['name'] for col in inspector.get_columns('transactions')]
177
-
178
- with engine.connect() as conn:
179
- # 定义 transactions 表的新列
180
- new_columns = {
181
- 'alipay_account': 'VARCHAR',
182
- 'real_name': 'VARCHAR',
183
- 'withdraw_status': 'VARCHAR',
184
- 'payment_order_id': 'VARCHAR',
185
- 'net_amount': 'INTEGER',
186
- 'description': 'VARCHAR',
187
- 'item_title': 'VARCHAR',
188
- 'item_type': 'VARCHAR',
189
- 'related_user_name': 'VARCHAR',
190
- }
191
-
192
- for col_name, col_type in new_columns.items():
193
- if col_name not in columns:
194
- try:
195
- # VARCHAR 类型添加 DEFAULT NULL 避免 PostgreSQL NOT NULL 冲突
196
- if col_type == 'VARCHAR':
197
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
198
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type} DEFAULT NULL"))
199
- else:
200
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type} DEFAULT NULL"))
201
- else:
202
- if 'sqlite' in SQLALCHEMY_DATABASE_URL:
203
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type}"))
204
- else:
205
- conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
206
- logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
207
- except Exception as e:
208
- logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}")
209
-
210
- conn.commit()
211
-
212
- # ========== 4. 回填旧提现记录数据 ==========
213
- # 将旧 WITHDRAW 记录的 withdraw_status 从 NULL 更新为 "completed"
214
- result = conn.execute(text(
215
- "UPDATE transactions SET withdraw_status = 'completed' "
216
- "WHERE tx_type = 'WITHDRAW' AND withdraw_status IS NULL"
217
- ))
218
- if result.rowcount > 0:
219
- logger.info(f"[DB Migration] 回填旧提现记录: 更新 {result.rowcount} 条记录的 withdraw_status 为 'completed'")
220
-
221
- # 将旧记录的 net_amount 从 NULL 更新为 ABS(amount)
222
- result = conn.execute(text(
223
- "UPDATE transactions SET net_amount = ABS(amount) "
224
- "WHERE tx_type = 'WITHDRAW' AND net_amount IS NULL"
225
- ))
226
- if result.rowcount > 0:
227
- logger.info(f"[DB Migration] 回填旧提现记录: 更新 {result.rowcount} 条记录的 net_amount 为 ABS(amount)")
228
-
229
- conn.commit()
230
-
231
- except Exception as e:
232
- logger.warning(f"字段迁移跳过 (可能已存在): {e}")
233
-
234
-
235
- def get_db():
236
- """获取数据库会话,带连接有效性检测"""
237
- db = SessionLocal()
238
- try:
239
- yield db
240
- except (OperationalError, InterfaceError) as e:
241
- logger.error(f"数据库连接错误: {e}")
242
- db.rollback()
243
- raise
244
- finally:
245
- db.close()
246
-
247
-
248
- def check_db_connection() -> bool:
249
- """
250
- 检查数据库连接是否正常
251
- 用于健康检查接口
252
- """
253
- try:
254
- db = SessionLocal()
255
- db.execute(text("SELECT 1"))
256
- db.close()
257
- return True
258
- except Exception as e:
259
- logger.error(f"数据库连接检查失败: {e}")
 
 
 
 
 
 
260
  return False
 
1
+ # database_sql.py
2
+ # ==========================================
3
+ # 🛡️ 稳定性优化:SQL 数据库连接模块
4
+ # ==========================================
5
+ import os
6
+ import time
7
+ import logging
8
+ from functools import wraps
9
+ from sqlalchemy import create_engine, text
10
+ from sqlalchemy.orm import sessionmaker
11
+ from sqlalchemy.pool import QueuePool, NullPool
12
+ from sqlalchemy.exc import OperationalError, InterfaceError, DBAPIError
13
+ from models_sql import Base, SystemConfig, RegisteredPlugin
14
+
15
+ logger = logging.getLogger("ComfyUI-Ranking.Database")
16
+
17
+ # 核心:优先读取环境变量中的 PostgreSQL 数据库连接
18
+ SQLALCHEMY_DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////tmp/comfy_financial.db")
19
+
20
+ # 🚀 P2优化:连接池配置支持环境变量
21
+ POOL_SIZE = int(os.environ.get("DB_POOL_SIZE", "5"))
22
+ POOL_OVERFLOW = int(os.environ.get("DB_POOL_OVERFLOW", "10"))
23
+
24
+ # 🚀 P1性能优化:根据数据库类型配置连接池
25
+ if "sqlite" in SQLALCHEMY_DATABASE_URL:
26
+ # SQLite:使用空连接池,单线程模式
27
+ connect_args = {"check_same_thread": False}
28
+ engine = create_engine(
29
+ SQLALCHEMY_DATABASE_URL,
30
+ connect_args=connect_args,
31
+ poolclass=NullPool # SQLite 不需要连接池
32
+ )
33
+ logger.info(f"数据库引擎初始化完成 (SQLite)")
34
+ else:
35
+ # PostgreSQL/MySQL:配置连接池参数
36
+ connect_args = {}
37
+ if "postgresql" in SQLALCHEMY_DATABASE_URL or "postgres" in SQLALCHEMY_DATABASE_URL:
38
+ sslmode = os.environ.get("DB_SSLMODE", "require")
39
+ connect_args["sslmode"] = sslmode
40
+ connect_args["connect_timeout"] = int(os.environ.get("DB_CONNECT_TIMEOUT", "10"))
41
+ logger.info(f"PostgreSQL SSL 配置: sslmode={sslmode}, connect_timeout={connect_args['connect_timeout']}")
42
+ engine = create_engine(
43
+ SQLALCHEMY_DATABASE_URL,
44
+ poolclass=QueuePool,
45
+ pool_size=POOL_SIZE, # 核心连接数(支持环境变量配置)
46
+ max_overflow=POOL_OVERFLOW, # 超出 pool_size 后可创建的最大连接数(支持环境变量配置)
47
+ pool_timeout=30, # 获取连接超时(秒)
48
+ pool_recycle=1800, # 连接回收时间(30分钟)
49
+ pool_pre_ping=True, # 使用前检测连接有效性
50
+ connect_args=connect_args
51
+ )
52
+ logger.info(f"数据库引擎初始化完成 (PostgreSQL/MySQL) - 连接池配置: pool_size={POOL_SIZE}, max_overflow={POOL_OVERFLOW}")
53
+
54
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
55
+
56
+
57
+ # ==========================================
58
+ # 🛡️ 稳定性优化:数据库操作重试装饰器
59
+ # ==========================================
60
+ def db_retry(max_retries: int = 3, delay: float = 0.5):
61
+ """
62
+ 数据库操作重试装饰器
63
+
64
+ 用法:
65
+ @db_retry(max_retries=3)
66
+ def my_db_function(db):
67
+ ...
68
+ """
69
+ def decorator(func):
70
+ @wraps(func)
71
+ def wrapper(*args, **kwargs):
72
+ last_error = None
73
+ for attempt in range(max_retries):
74
+ try:
75
+ return func(*args, **kwargs)
76
+ except (OperationalError, InterfaceError, DBAPIError) as e:
77
+ last_error = e
78
+ if attempt < max_retries - 1:
79
+ wait_time = delay * (2 ** attempt) # 指数退避
80
+ logger.warning(f"DB 操作失败 (第{attempt+1}次):{e}, {wait_time}秒后重试")
81
+ time.sleep(wait_time)
82
+ else:
83
+ logger.error(f"DB 操作失败 (重试{max_retries}次后放弃): {e}")
84
+ raise last_error
85
+ return wrapper
86
+ return decorator
87
+
88
+
89
+ def init_sql_db():
90
+ """初始化数据库,包含重试机制"""
91
+ for attempt in range(3):
92
+ try:
93
+ Base.metadata.create_all(bind=engine)
94
+
95
+ # 🔄 P7后悔模式:自动迁移新增字段
96
+ _auto_migrate_p7_fields()
97
+
98
+ logger.info("数据库初始化成功")
99
+ return
100
+ except Exception as e:
101
+ if attempt < 2:
102
+ logger.warning(f"数据库初始化失败 (第{attempt+1}次): {e}")
103
+ time.sleep(2 ** attempt)
104
+ else:
105
+ logger.error(f"数据库初始化失败 (已重试3次): {e}")
106
+ raise
107
+
108
+
109
+ def _auto_migrate_p7_fields():
110
+ """
111
+ 🔄 P7后悔模式:自动迁移新增字段
112
+ 检查并添加 ownerships 表和 transactions 表的新字段
113
+ 所有 ALTER TABLE ADD COLUMN 均使用 IF NOT EXISTS 或 inspector 预检,避免重复执行报错
114
+ """
115
+ from sqlalchemy import inspect
116
+
117
+ try:
118
+ inspector = inspect(engine)
119
+
120
+ # ========== 1. 迁移 ownerships 表 ==========
121
+ if 'ownerships' in inspector.get_table_names():
122
+ columns = [col['name'] for col in inspector.get_columns('ownerships')]
123
+
124
+ with engine.connect() as conn:
125
+ if 'price_paid' not in columns:
126
+ try:
127
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
128
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN price_paid INTEGER DEFAULT 0"))
129
+ else:
130
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS price_paid INTEGER DEFAULT 0"))
131
+ logger.info("迁移完成: 添加 ownerships.price_paid 字段")
132
+ except Exception as e:
133
+ logger.debug(f"跳过 ownerships.price_paid (可能已存在): {e}")
134
+
135
+ if 'is_refunded' not in columns:
136
+ try:
137
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
138
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN is_refunded BOOLEAN DEFAULT 0"))
139
+ else:
140
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS is_refunded BOOLEAN DEFAULT FALSE"))
141
+ logger.info("迁移完成: 添加 ownerships.is_refunded 字段")
142
+ except Exception as e:
143
+ logger.debug(f"跳过 ownerships.is_refunded (可能已存在): {e}")
144
+
145
+ if 'refunded_at' not in columns:
146
+ try:
147
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
148
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN refunded_at TIMESTAMP"))
149
+ else:
150
+ conn.execute(text("ALTER TABLE ownerships ADD COLUMN IF NOT EXISTS refunded_at TIMESTAMP"))
151
+ logger.info("迁移完成: 添加 ownerships.refunded_at 字段")
152
+ except Exception as e:
153
+ logger.debug(f"跳过 ownerships.refunded_at (可能已存在): {e}")
154
+
155
+ conn.commit()
156
+
157
+ # ========== 2. 迁移 wallets 表(新增 task_balance 字段) ==========
158
+ if 'wallets' in inspector.get_table_names():
159
+ columns = [col['name'] for col in inspector.get_columns('wallets')]
160
+
161
+ with engine.connect() as conn:
162
+ if 'task_balance' not in columns:
163
+ try:
164
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
165
+ conn.execute(text("ALTER TABLE wallets ADD COLUMN task_balance INTEGER DEFAULT 0"))
166
+ else:
167
+ conn.execute(text("ALTER TABLE wallets ADD COLUMN IF NOT EXISTS task_balance INTEGER DEFAULT 0"))
168
+ logger.info("[DB Migration] 添加列 wallets.task_balance")
169
+ except Exception as e:
170
+ logger.debug(f"跳过 wallets.task_balance (可能已存在): {e}")
171
+
172
+ conn.commit()
173
+
174
+ # ========== 3. 迁移 transactions 表(提现相关新字段) ==========
175
+ if 'transactions' in inspector.get_table_names():
176
+ columns = [col['name'] for col in inspector.get_columns('transactions')]
177
+
178
+ with engine.connect() as conn:
179
+ # 定义 transactions 表的新列
180
+ new_columns = {
181
+ 'alipay_account': 'VARCHAR',
182
+ 'real_name': 'VARCHAR',
183
+ 'withdraw_status': 'VARCHAR',
184
+ 'payment_order_id': 'VARCHAR',
185
+ 'net_amount': 'INTEGER',
186
+ 'description': 'VARCHAR',
187
+ 'item_title': 'VARCHAR',
188
+ 'item_type': 'VARCHAR',
189
+ 'related_user_name': 'VARCHAR',
190
+ 'source_plugin': 'VARCHAR(50)', # 🌐 开放API:来源插件标识
191
+ }
192
+
193
+ for col_name, col_type in new_columns.items():
194
+ if col_name not in columns:
195
+ try:
196
+ # VARCHAR 类型添加 DEFAULT NULL 避免 PostgreSQL NOT NULL 冲突
197
+ if col_type == 'VARCHAR':
198
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
199
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type} DEFAULT NULL"))
200
+ else:
201
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type} DEFAULT NULL"))
202
+ else:
203
+ if 'sqlite' in SQLALCHEMY_DATABASE_URL:
204
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN {col_name} {col_type}"))
205
+ else:
206
+ conn.execute(text(f"ALTER TABLE transactions ADD COLUMN IF NOT EXISTS {col_name} {col_type}"))
207
+ logger.info(f"[DB Migration] 添加列 transactions.{col_name}")
208
+ except Exception as e:
209
+ logger.debug(f"跳过 transactions.{col_name} (可能已存在): {e}")
210
+
211
+ conn.commit()
212
+
213
+ # ========== 4. 回填旧提现记录数据 ==========
214
+ # 将旧 WITHDRAW 记录的 withdraw_status 从 NULL 更新为 "completed"
215
+ result = conn.execute(text(
216
+ "UPDATE transactions SET withdraw_status = 'completed' "
217
+ "WHERE tx_type = 'WITHDRAW' AND withdraw_status IS NULL"
218
+ ))
219
+ if result.rowcount > 0:
220
+ logger.info(f"[DB Migration] 回填旧提现记录: 更新 {result.rowcount} 条记录的 withdraw_status 为 'completed'")
221
+
222
+ # 将旧记录的 net_amount 从 NULL 更新为 ABS(amount)
223
+ result = conn.execute(text(
224
+ "UPDATE transactions SET net_amount = ABS(amount) "
225
+ "WHERE tx_type = 'WITHDRAW' AND net_amount IS NULL"
226
+ ))
227
+ if result.rowcount > 0:
228
+ logger.info(f"[DB Migration] 回填旧提现记录: 更新 {result.rowcount} 条记录的 net_amount 为 ABS(amount)")
229
+
230
+ conn.commit()
231
+
232
+ # ========== 5. 确保 registered_plugins 表存在 ==========
233
+ if not inspect(engine).has_table("registered_plugins"):
234
+ RegisteredPlugin.__table__.create(engine)
235
+ logger.info("✅ 已创建 registered_plugins 表")
236
+
237
+ except Exception as e:
238
+ logger.warning(f"字段迁移跳过 (可能已存在): {e}")
239
+
240
+
241
+ def get_db():
242
+ """获取数据库会话,带连接有效性检测"""
243
+ db = SessionLocal()
244
+ try:
245
+ yield db
246
+ except (OperationalError, InterfaceError) as e:
247
+ logger.error(f"数据库连接错误: {e}")
248
+ db.rollback()
249
+ raise
250
+ finally:
251
+ db.close()
252
+
253
+
254
+ def check_db_connection() -> bool:
255
+ """
256
+ 检查数据库连接是否正常
257
+ 用于健康检查接口
258
+ """
259
+ try:
260
+ db = SessionLocal()
261
+ db.execute(text("SELECT 1"))
262
+ db.close()
263
+ return True
264
+ except Exception as e:
265
+ logger.error(f"数据库连接检查失败: {e}")
266
  return False
models_sql.py CHANGED
@@ -1,98 +1,121 @@
1
- # models_sql.py
2
- # ==========================================
3
- # 🗄️ SQL 数据库模型定义
4
- # ==========================================
5
- # 🚀 P1性能优化:添加复合索引加速查询
6
- # ==========================================
7
-
8
- from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func
9
- from sqlalchemy.orm import declarative_base
10
- import datetime
11
-
12
- Base = declarative_base()
13
-
14
- class Wallet(Base):
15
- """用户钱包表"""
16
- __tablename__ = "wallets"
17
-
18
- account = Column(String, primary_key=True, index=True)
19
- balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得)
20
- earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得)
21
- tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得)
22
- task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额
23
- frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额
24
-
25
- # 乐观锁版本号,防止并发扣款被击穿
26
- version = Column(Integer, default=1)
27
-
28
- class Ownership(Base):
29
- """资源所有权表"""
30
- __tablename__ = "ownerships"
31
-
32
- id = Column(Integer, primary_key=True, autoincrement=True)
33
- account = Column(String, index=True)
34
- item_id = Column(String, index=True)
35
- purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
36
- # 🔄 P7后悔模式增强
37
- price_paid = Column(Integer, default=0) # 购买时支付的价格
38
- is_refunded = Column(Boolean, default=False) # 是否已退款
39
- refunded_at = Column(DateTime, nullable=True) # 退款时间
40
-
41
- # 🚀 P1性能优化:复合索引
42
- __table_args__ = (
43
- Index('ix_ownerships_account_item', 'account', 'item_id'),
44
- Index('ix_ownerships_account_refunded', 'account', 'is_refunded'),
45
- )
46
-
47
- class Refund(Base):
48
- """🔄 P7后悔模式:退款记录表"""
49
- __tablename__ = "refunds"
50
-
51
- id = Column(Integer, primary_key=True, autoincrement=True)
52
- account = Column(String, index=True) # 退款用户
53
- item_id = Column(String, index=True) # 退款商品
54
- amount = Column(Integer) # 退款金额
55
- refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
56
- ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
57
-
58
- class Transaction(Base):
59
- """交易流水表"""
60
- __tablename__ = "transactions"
61
-
62
- tx_id = Column(String, primary_key=True)
63
- account = Column(String, index=True)
64
- tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, SALE, TIP_OUT, TIP_IN, WITHDRAW, WITHDRAW_FEE, REFUND, TASK_FREEZE, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND, TASK_CANCEL_REFUND
65
- amount = Column(Integer)
66
- related_account = Column(String, nullable=True)
67
- item_id = Column(String, nullable=True)
68
- created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引
69
- prev_hash = Column(String) # 上一笔订单的哈希
70
- tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
71
-
72
- # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用)
73
- alipay_account = Column(String, nullable=True) # 收款支付宝账号
74
- real_name = Column(String, nullable=True) # 收款人姓名
75
- withdraw_status = Column(String, nullable=True) # pending / completed
76
- payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号
77
- net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减)
78
-
79
- # 交易详情增强字段
80
- description = Column(String, nullable=True) # 交易自描述文本
81
- item_title = Column(String, nullable=True) # 资源/任务标题
82
- item_type = Column(String, nullable=True) # 资源类型 (tool/app/recommend/post/task)
83
- related_user_name = Column(String, nullable=True) # 对方用户名
84
-
85
- # 🚀 P1性能优化:复合索引
86
- __table_args__ = (
87
- Index('ix_transactions_account_type', 'account', 'tx_type'),
88
- Index('ix_transactions_account_created', 'account', 'created_at'),
89
- )
90
-
91
- class SystemConfig(Base):
92
- """系统配置表 - 持久化存储,替代 /tmp/system_config.json"""
93
- __tablename__ = "system_configs"
94
-
95
- config_key = Column(String, primary_key=True)
96
- config_value = Column(String, nullable=False, default="{}")
97
- updated_at = Column(DateTime, default=datetime.datetime.utcnow)
98
- updated_by = Column(String, nullable=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # models_sql.py
2
+ # ==========================================
3
+ # 🗄️ SQL 数据库模型定义
4
+ # ==========================================
5
+ # 🚀 P1性能优化:添加复合索引加速查询
6
+ # ==========================================
7
+
8
+ from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func
9
+ from sqlalchemy.orm import declarative_base
10
+ import datetime
11
+
12
+ Base = declarative_base()
13
+
14
+ class Wallet(Base):
15
+ """用户钱包表"""
16
+ __tablename__ = "wallets"
17
+
18
+ account = Column(String, primary_key=True, index=True)
19
+ balance = Column(Integer, default=0, nullable=False) # 用于消费的余额 (充值获得)
20
+ earn_balance = Column(Integer, default=0, nullable=False) # 创作者销售收益余额 (别人购买获得)
21
+ tip_balance = Column(Integer, default=0, nullable=False) # 【新增】创作者打赏收益余额 (粉丝赞助获得)
22
+ task_balance = Column(Integer, default=0, nullable=False) # 【新增】任务相关余额
23
+ frozen_balance = Column(Integer, default=0, nullable=False) # 提现审核冻结中的余额
24
+
25
+ # 乐观锁版本号,防止并发扣款被击穿
26
+ version = Column(Integer, default=1)
27
+
28
+ class Ownership(Base):
29
+ """资源所有权表"""
30
+ __tablename__ = "ownerships"
31
+
32
+ id = Column(Integer, primary_key=True, autoincrement=True)
33
+ account = Column(String, index=True)
34
+ item_id = Column(String, index=True)
35
+ purchased_at = Column(DateTime, default=datetime.datetime.utcnow)
36
+ # 🔄 P7后悔模式增强
37
+ price_paid = Column(Integer, default=0) # 购买时支付的价格
38
+ is_refunded = Column(Boolean, default=False) # 是否已退款
39
+ refunded_at = Column(DateTime, nullable=True) # 退款时间
40
+
41
+ # 🚀 P1性能优化:复合索引
42
+ __table_args__ = (
43
+ Index('ix_ownerships_account_item', 'account', 'item_id'),
44
+ Index('ix_ownerships_account_refunded', 'account', 'is_refunded'),
45
+ )
46
+
47
+ class Refund(Base):
48
+ """🔄 P7后悔模式:退款记录表"""
49
+ __tablename__ = "refunds"
50
+
51
+ id = Column(Integer, primary_key=True, autoincrement=True)
52
+ account = Column(String, index=True) # 退款用户
53
+ item_id = Column(String, index=True) # 退款商品
54
+ amount = Column(Integer) # 退款金额
55
+ refunded_at = Column(DateTime, default=datetime.datetime.utcnow)
56
+ ban_until = Column(DateTime) # 禁止购买截止时间(30天后)
57
+
58
+ class Transaction(Base):
59
+ """交易流水表"""
60
+ __tablename__ = "transactions"
61
+
62
+ tx_id = Column(String, primary_key=True)
63
+ account = Column(String, index=True)
64
+ tx_type = Column(String) # 枚举: RECHARGE, PURCHASE, SALE, TIP_OUT, TIP_IN, WITHDRAW, WITHDRAW_FEE, REFUND, TASK_FREEZE, TASK_DEPOSIT, TASK_PAYMENT, TASK_INCOME, TASK_REFUND, TASK_CANCEL_REFUND
65
+ amount = Column(Integer)
66
+ related_account = Column(String, nullable=True)
67
+ item_id = Column(String, nullable=True)
68
+ created_at = Column(DateTime, default=datetime.datetime.utcnow, index=True) # 🚀 P1: 添加时间索引
69
+ prev_hash = Column(String) # 上一笔订单的哈希
70
+ tx_hash = Column(String) # 本笔订单的哈希 (防篡改)
71
+
72
+ # 💰 提现相关字段 (仅 WITHDRAW_APPLY 类型交易使用)
73
+ alipay_account = Column(String, nullable=True) # 收款支付宝账号
74
+ real_name = Column(String, nullable=True) # 收款人姓名
75
+ withdraw_status = Column(String, nullable=True) # pending / completed
76
+ payment_order_id = Column(String, nullable=True) # 管理员填写的打款订单号
77
+ net_amount = Column(Integer, nullable=True) # 实际到账金额(用于解冻时准确扣减)
78
+
79
+ # 交易详情增强字段
80
+ description = Column(String, nullable=True) # 交易自描述文本
81
+ item_title = Column(String, nullable=True) # 资源/任务标题
82
+ item_type = Column(String, nullable=True) # 资源类型 (tool/app/recommend/post/task)
83
+ related_user_name = Column(String, nullable=True) # 对方用户名
84
+
85
+ # 🌐 开放API:来源插件标识(如 "nodecraft_ai"),用于按工具结构化追踪消费
86
+ source_plugin = Column(String(50), nullable=True, index=True)
87
+
88
+ # 🚀 P1性能优化:复合索引
89
+ __table_args__ = (
90
+ Index('ix_transactions_account_type', 'account', 'tx_type'),
91
+ Index('ix_transactions_account_created', 'account', 'created_at'),
92
+ Index('ix_transactions_source_plugin_created', 'source_plugin', 'created_at'),
93
+ )
94
+
95
+ class SystemConfig(Base):
96
+ """系统配置表 - 持久化存储,替代 /tmp/system_config.json"""
97
+ __tablename__ = "system_configs"
98
+
99
+ config_key = Column(String, primary_key=True)
100
+ config_value = Column(String, nullable=False, default="{}")
101
+ updated_at = Column(DateTime, default=datetime.datetime.utcnow)
102
+ updated_by = Column(String, nullable=True)
103
+
104
+
105
+ class RegisteredPlugin(Base):
106
+ """🌐 开放API:第三方插件注册信息表"""
107
+ __tablename__ = "registered_plugins"
108
+
109
+ plugin_id = Column(String(50), primary_key=True) # 英文标识如 "nodecraft_ai"
110
+ plugin_key = Column(String(100), unique=True, nullable=False, index=True)
111
+ name = Column(String(100), nullable=False) # 显示名
112
+ description = Column(String(500), nullable=True)
113
+ enabled = Column(Boolean, default=True, nullable=False)
114
+ rate_limit = Column(Integer, default=100) # 每分钟最大请求数
115
+ max_deduct_per_tx = Column(Integer, default=100) # 单笔扣款上限
116
+ max_deduct_per_day = Column(Integer, default=1000) # 日扣款上限
117
+ permissions = Column(String(200), default='["verify","balance","deduct","refund"]') # JSON数组
118
+ created_at = Column(DateTime, default=func.now())
119
+ created_by = Column(String(50)) # 创建者管理员账号
120
+ updated_at = Column(DateTime, onupdate=func.now())
121
+ last_rotated_at = Column(DateTime, nullable=True) # 上次轮换key时间
router_admin_plugins.py ADDED
@@ -0,0 +1,391 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # router_admin_plugins.py
2
+ # ==========================================
3
+ # 🌐 管理员插件管理路由模块
4
+ # ==========================================
5
+ # 作用:提供第三方插件的注册、查询、更新、删除、Key轮换等管理API
6
+ # 关联文件:
7
+ # - models_sql.py (RegisteredPlugin 模型)
8
+ # - database_sql.py (SQL数据库连接 get_db)
9
+ # - 安全认证.py (require_auth / is_admin)
10
+ # - router_open_api.py (reload_plugin_registry 热更新)
11
+ # 权限:所有端点均需管理员权限(is_admin() == True)
12
+ # ==========================================
13
+
14
+ import re
15
+ import json
16
+ import secrets
17
+ import logging
18
+ import datetime
19
+ from typing import Optional, List
20
+
21
+ from fastapi import APIRouter, Depends, HTTPException
22
+ from fastapi.responses import JSONResponse
23
+ from sqlalchemy.orm import Session
24
+ from pydantic import BaseModel, Field, validator
25
+
26
+ from database_sql import get_db
27
+ from models_sql import RegisteredPlugin
28
+ from 安全认证 import require_auth, is_admin
29
+
30
+ logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
31
+
32
+ router = APIRouter()
33
+
34
+ # ==========================================
35
+ # 🔧 工具函数
36
+ # ==========================================
37
+
38
+ # plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
39
+ _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
40
+
41
+ # 默认权限列表
42
+ _DEFAULT_PERMISSIONS = ["verify", "balance", "deduct", "refund"]
43
+
44
+
45
+ def generate_plugin_key() -> str:
46
+ """生成 plugin_key,格式: rk_plug_ + 32位hex随机串"""
47
+ return f"rk_plug_{secrets.token_hex(16)}"
48
+
49
+
50
+ def mask_key(key: str) -> str:
51
+ """脱敏 plugin_key:保留前12位(rk_plug_ + 4位),其余以 *** 替代"""
52
+ if not key:
53
+ return "***"
54
+ return key[:12] + "***" if len(key) > 12 else "***"
55
+
56
+
57
+ def _require_admin(account: str):
58
+ """工具函数:校验管理员权限,否则抛 403"""
59
+ if not is_admin(account):
60
+ raise HTTPException(status_code=403, detail="需要管理员权限")
61
+
62
+
63
+ def _notify_registry_reload():
64
+ """通知 open_api 模块重新加载插件注册表(热更新)"""
65
+ try:
66
+ from router_open_api import reload_plugin_registry
67
+ reload_plugin_registry()
68
+ logger.info("插件注册表热更新成功")
69
+ except (ImportError, Exception) as e:
70
+ logger.warning(f"插件注册表热更新失败: {e}")
71
+
72
+
73
+ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict:
74
+ """
75
+ 序列化插件对象为字典
76
+
77
+ 参数:
78
+ p: RegisteredPlugin ORM 对象
79
+ include_key: 是否返回完整 plugin_key(仅在创建/轮换时为 True)
80
+ raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
81
+ """
82
+ try:
83
+ permissions = json.loads(p.permissions) if p.permissions else _DEFAULT_PERMISSIONS
84
+ except Exception:
85
+ permissions = _DEFAULT_PERMISSIONS
86
+
87
+ data = {
88
+ "plugin_id": p.plugin_id,
89
+ "name": p.name,
90
+ "description": p.description,
91
+ "enabled": bool(p.enabled),
92
+ "rate_limit": p.rate_limit,
93
+ "max_deduct_per_tx": p.max_deduct_per_tx,
94
+ "max_deduct_per_day": p.max_deduct_per_day,
95
+ "permissions": permissions,
96
+ "created_at": p.created_at.isoformat() if p.created_at else None,
97
+ "created_by": p.created_by,
98
+ "updated_at": p.updated_at.isoformat() if p.updated_at else None,
99
+ "last_rotated_at": p.last_rotated_at.isoformat() if p.last_rotated_at else None,
100
+ }
101
+
102
+ if include_key and raw_key:
103
+ data["plugin_key"] = raw_key
104
+ else:
105
+ data["plugin_key_masked"] = mask_key(p.plugin_key)
106
+
107
+ return data
108
+
109
+
110
+ # ==========================================
111
+ # 📥 请求模型
112
+ # ==========================================
113
+
114
+ class PluginCreateRequest(BaseModel):
115
+ plugin_id: str = Field(..., min_length=3, max_length=50, description="插件唯一标识,仅允许小写字母、数字、下划线")
116
+ name: str = Field(..., min_length=1, max_length=100, description="插件显示名")
117
+ description: Optional[str] = Field(None, max_length=500)
118
+ rate_limit: Optional[int] = Field(100, ge=1, le=10000)
119
+ max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
120
+ max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
121
+ permissions: Optional[List[str]] = Field(default=None)
122
+
123
+ @validator("plugin_id")
124
+ def _validate_plugin_id(cls, v):
125
+ if not _PLUGIN_ID_PATTERN.match(v):
126
+ raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
127
+ return v
128
+
129
+ @validator("permissions", always=True)
130
+ def _validate_permissions(cls, v):
131
+ if v is None:
132
+ return _DEFAULT_PERMISSIONS
133
+ if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
134
+ raise ValueError("permissions 必���为字符串数组")
135
+ return v
136
+
137
+
138
+ class PluginUpdateRequest(BaseModel):
139
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
140
+ description: Optional[str] = Field(None, max_length=500)
141
+ enabled: Optional[bool] = None
142
+ rate_limit: Optional[int] = Field(None, ge=1, le=10000)
143
+ max_deduct_per_tx: Optional[int] = Field(None, ge=1, le=10000)
144
+ max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
145
+ permissions: Optional[List[str]] = None
146
+
147
+ @validator("permissions")
148
+ def _validate_permissions(cls, v):
149
+ if v is None:
150
+ return v
151
+ if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
152
+ raise ValueError("permissions 必须为字符串数组")
153
+ return v
154
+
155
+
156
+ # ==========================================
157
+ # 🌐 路由实现
158
+ # ==========================================
159
+
160
+ @router.get("/api/admin/plugins")
161
+ async def list_plugins(
162
+ account: str = Depends(require_auth),
163
+ db: Session = Depends(get_db),
164
+ ):
165
+ """列出所有已注册插件(plugin_key 脱敏)"""
166
+ _require_admin(account)
167
+ try:
168
+ plugins = db.query(RegisteredPlugin).order_by(RegisteredPlugin.created_at.desc()).all()
169
+ return JSONResponse(content={
170
+ "success": True,
171
+ "total": len(plugins),
172
+ "plugins": [_serialize_plugin(p) for p in plugins],
173
+ })
174
+ except HTTPException:
175
+ raise
176
+ except Exception as e:
177
+ logger.exception(f"列出插件失败: {e}")
178
+ return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"})
179
+
180
+
181
+ @router.get("/api/admin/plugins/{plugin_id}")
182
+ async def get_plugin(
183
+ plugin_id: str,
184
+ account: str = Depends(require_auth),
185
+ db: Session = Depends(get_db),
186
+ ):
187
+ """获取插件详情(plugin_key 脱敏)"""
188
+ _require_admin(account)
189
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
190
+ if not plugin:
191
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
192
+ return JSONResponse(content={"success": True, "plugin": _serialize_plugin(plugin)})
193
+
194
+
195
+ @router.post("/api/admin/plugins")
196
+ async def create_plugin(
197
+ payload: PluginCreateRequest,
198
+ account: str = Depends(require_auth),
199
+ db: Session = Depends(get_db),
200
+ ):
201
+ """创建新插件(自动生成 plugin_key,仅本次返回明文 key)"""
202
+ _require_admin(account)
203
+
204
+ # 检查 plugin_id 是否已存在
205
+ exists = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == payload.plugin_id).first()
206
+ if exists:
207
+ return JSONResponse(status_code=409, content={"success": False, "error": f"plugin_id '{payload.plugin_id}' 已存在"})
208
+
209
+ plugin_key = generate_plugin_key()
210
+ now = datetime.datetime.utcnow()
211
+
212
+ plugin = RegisteredPlugin(
213
+ plugin_id=payload.plugin_id,
214
+ plugin_key=plugin_key,
215
+ name=payload.name,
216
+ description=payload.description,
217
+ enabled=True,
218
+ rate_limit=payload.rate_limit,
219
+ max_deduct_per_tx=payload.max_deduct_per_tx,
220
+ max_deduct_per_day=payload.max_deduct_per_day,
221
+ permissions=json.dumps(payload.permissions, ensure_ascii=False),
222
+ created_at=now,
223
+ created_by=account,
224
+ updated_at=now,
225
+ )
226
+
227
+ try:
228
+ db.add(plugin)
229
+ db.commit()
230
+ db.refresh(plugin)
231
+ except Exception as e:
232
+ db.rollback()
233
+ logger.exception(f"创建插件失败: {e}")
234
+ return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
235
+
236
+ logger.info(f"管理员 {account} 创建插件 plugin_id={payload.plugin_id}")
237
+ _notify_registry_reload()
238
+
239
+ return JSONResponse(status_code=201, content={
240
+ "success": True,
241
+ "plugin_id": plugin.plugin_id,
242
+ "plugin_key": plugin_key,
243
+ "name": plugin.name,
244
+ "message": "插件创建成功,请妥善保存 plugin_key(仅显示一次)",
245
+ "plugin": _serialize_plugin(plugin, include_key=True, raw_key=plugin_key),
246
+ })
247
+
248
+
249
+ @router.put("/api/admin/plugins/{plugin_id}")
250
+ async def update_plugin(
251
+ plugin_id: str,
252
+ payload: PluginUpdateRequest,
253
+ account: str = Depends(require_auth),
254
+ db: Session = Depends(get_db),
255
+ ):
256
+ """更新插件配置(不可修改 plugin_id 与 plugin_key)"""
257
+ _require_admin(account)
258
+
259
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
260
+ if not plugin:
261
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
262
+
263
+ update_fields = payload.dict(exclude_unset=True)
264
+ if not update_fields:
265
+ return JSONResponse(status_code=400, content={"success": False, "error": "未提供任何可更新字段"})
266
+
267
+ try:
268
+ if "permissions" in update_fields and update_fields["permissions"] is not None:
269
+ plugin.permissions = json.dumps(update_fields.pop("permissions"), ensure_ascii=False)
270
+
271
+ for key, value in update_fields.items():
272
+ if value is None:
273
+ continue
274
+ setattr(plugin, key, value)
275
+
276
+ plugin.updated_at = datetime.datetime.utcnow()
277
+ db.commit()
278
+ db.refresh(plugin)
279
+ except Exception as e:
280
+ db.rollback()
281
+ logger.exception(f"更新插件失败: {e}")
282
+ return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
283
+
284
+ logger.info(f"管理员 {account} 更新插件 plugin_id={plugin_id}, fields={list(update_fields.keys())}")
285
+ _notify_registry_reload()
286
+
287
+ return JSONResponse(content={
288
+ "success": True,
289
+ "message": "插件更新成功",
290
+ "plugin": _serialize_plugin(plugin),
291
+ })
292
+
293
+
294
+ @router.delete("/api/admin/plugins/{plugin_id}")
295
+ async def delete_plugin(
296
+ plugin_id: str,
297
+ account: str = Depends(require_auth),
298
+ db: Session = Depends(get_db),
299
+ ):
300
+ """删除插件"""
301
+ _require_admin(account)
302
+
303
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
304
+ if not plugin:
305
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
306
+
307
+ try:
308
+ db.delete(plugin)
309
+ db.commit()
310
+ except Exception as e:
311
+ db.rollback()
312
+ logger.exception(f"删除插件失败: {e}")
313
+ return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
314
+
315
+ logger.info(f"管理员 {account} 删除插件 plugin_id={plugin_id}")
316
+ _notify_registry_reload()
317
+
318
+ return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id})
319
+
320
+
321
+ @router.post("/api/admin/plugins/{plugin_id}/rotate-key")
322
+ async def rotate_plugin_key(
323
+ plugin_id: str,
324
+ account: str = Depends(require_auth),
325
+ db: Session = Depends(get_db),
326
+ ):
327
+ """轮换 plugin_key,返回新 key(仅本次返回明文)"""
328
+ _require_admin(account)
329
+
330
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
331
+ if not plugin:
332
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
333
+
334
+ new_key = generate_plugin_key()
335
+ now = datetime.datetime.utcnow()
336
+
337
+ try:
338
+ plugin.plugin_key = new_key
339
+ plugin.last_rotated_at = now
340
+ plugin.updated_at = now
341
+ db.commit()
342
+ db.refresh(plugin)
343
+ except Exception as e:
344
+ db.rollback()
345
+ logger.exception(f"轮换 plugin_key 失败: {e}")
346
+ return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
347
+
348
+ logger.info(f"管理员 {account} 轮换插件 plugin_id={plugin_id} 的 plugin_key")
349
+ _notify_registry_reload()
350
+
351
+ return JSONResponse(content={
352
+ "success": True,
353
+ "plugin_id": plugin.plugin_id,
354
+ "plugin_key": new_key,
355
+ "message": "plugin_key 已轮换,请妥善保存(仅显示一次)",
356
+ "last_rotated_at": now.isoformat(),
357
+ })
358
+
359
+
360
+ @router.post("/api/admin/plugins/{plugin_id}/toggle")
361
+ async def toggle_plugin(
362
+ plugin_id: str,
363
+ account: str = Depends(require_auth),
364
+ db: Session = Depends(get_db),
365
+ ):
366
+ """启用/禁用切换"""
367
+ _require_admin(account)
368
+
369
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
370
+ if not plugin:
371
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
372
+
373
+ try:
374
+ plugin.enabled = not bool(plugin.enabled)
375
+ plugin.updated_at = datetime.datetime.utcnow()
376
+ db.commit()
377
+ db.refresh(plugin)
378
+ except Exception as e:
379
+ db.rollback()
380
+ logger.exception(f"切换插件状态失败: {e}")
381
+ return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
382
+
383
+ logger.info(f"管理员 {account} 切换插件 plugin_id={plugin_id} 状态为 enabled={plugin.enabled}")
384
+ _notify_registry_reload()
385
+
386
+ return JSONResponse(content={
387
+ "success": True,
388
+ "plugin_id": plugin.plugin_id,
389
+ "enabled": bool(plugin.enabled),
390
+ "message": "插件已启用" if plugin.enabled else "插件已禁用",
391
+ })
router_open_api.py ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # router_open_api.py
2
+ # ==========================================
3
+ # 🌐 通用开放 API 模块(供第三方插件调用)
4
+ # ==========================================
5
+ # 作用:对外提供受 Plugin Key 保护的通用接口,
6
+ # 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。
7
+ # 关联文件:
8
+ # - 安全认证.py (verify_token_with_fallback, require_auth)
9
+ # - database_sql.py (get_db, SessionLocal)
10
+ # - models_sql.py (Wallet, Transaction)
11
+ # - 数据库连接.py (users.json 读写)
12
+ # 安全机制:
13
+ # - X-Plugin-Key Header 鉴权
14
+ # - 基于 plugin_key 的内存限流(每分钟重置)
15
+ # - 所有扣款/退款操作记录到 Transaction 流水(含哈希链)
16
+ # ==========================================
17
+
18
+ import os
19
+ import json
20
+ import time
21
+ import uuid
22
+ import datetime
23
+ import hashlib
24
+ import logging
25
+ import threading
26
+ from datetime import date
27
+ from typing import Optional
28
+
29
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query
30
+ from fastapi.responses import JSONResponse
31
+ from sqlalchemy.orm import Session
32
+ from pydantic import BaseModel, Field
33
+
34
+ from database_sql import get_db, SessionLocal
35
+ from models_sql import Wallet, Transaction, RegisteredPlugin
36
+ from 安全认证 import verify_token_with_fallback
37
+ import 数据库连接 as json_db
38
+
39
+ logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
40
+
41
+ router = APIRouter(prefix="/api/open", tags=["open-api"])
42
+
43
+
44
+ # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
45
+ # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
46
+ _PLUGIN_KEY_INDEX: dict = {}
47
+
48
+
49
+ def load_plugins_from_db():
50
+ """启动时从数据库加载所有启用的插件到内存索引"""
51
+ global _PLUGIN_KEY_INDEX
52
+
53
+ db = SessionLocal()
54
+ try:
55
+ # 首次启动时 seed 默认插件
56
+ if not db.query(RegisteredPlugin).first():
57
+ default_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin")
58
+ default_plugin = RegisteredPlugin(
59
+ plugin_id="nodecraft_ai",
60
+ plugin_key=default_key,
61
+ name="NodeCraft AI",
62
+ enabled=True,
63
+ rate_limit=100,
64
+ max_deduct_per_tx=100,
65
+ max_deduct_per_day=1000,
66
+ permissions='["verify","balance","deduct","refund"]',
67
+ created_by="system",
68
+ )
69
+ db.add(default_plugin)
70
+ db.commit()
71
+ logger.info("✅ 已 seed 默认插件: NodeCraft AI")
72
+
73
+ plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all()
74
+
75
+ # 构建内存索引
76
+ new_index = {}
77
+ for p in plugins:
78
+ try:
79
+ perms = json.loads(p.permissions) if p.permissions else ["verify", "balance", "deduct", "refund"]
80
+ except Exception:
81
+ perms = ["verify", "balance", "deduct", "refund"]
82
+ new_index[p.plugin_key] = {
83
+ "id": p.plugin_id,
84
+ "key": p.plugin_key,
85
+ "name": p.name,
86
+ "rate_limit": p.rate_limit or 100,
87
+ "permissions": perms,
88
+ "max_deduct_per_tx": p.max_deduct_per_tx or 100,
89
+ "max_deduct_per_day": p.max_deduct_per_day or 1000,
90
+ }
91
+
92
+ _PLUGIN_KEY_INDEX = new_index
93
+ logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表")
94
+ except Exception as e:
95
+ logger.error(f"❌ 加载插件注册表失败: {e}")
96
+ # 如果加载失败且索引为空,使用环境变量 fallback
97
+ if not _PLUGIN_KEY_INDEX:
98
+ fallback_key = os.environ.get("NCA_PLUGIN_KEY", "nca_2026_nodecraft_ai_plugin")
99
+ _PLUGIN_KEY_INDEX = {
100
+ fallback_key: {
101
+ "id": "nodecraft_ai",
102
+ "key": fallback_key,
103
+ "name": "NodeCraft AI",
104
+ "rate_limit": 100,
105
+ "permissions": ["verify", "balance", "deduct", "refund"],
106
+ "max_deduct_per_tx": 100,
107
+ "max_deduct_per_day": 1000,
108
+ }
109
+ }
110
+ logger.warning("⚠️ 使用 fallback 插件配置")
111
+ finally:
112
+ db.close()
113
+
114
+
115
+ def reload_plugin_registry():
116
+ """热更新插件注册表(管理操作后调用)"""
117
+ load_plugins_from_db()
118
+
119
+
120
+ # ==========================================
121
+ # 💰 日扣款总额限制:内存计数器(每自然日重置)
122
+ # ==========================================
123
+ _daily_deduct_lock = threading.Lock()
124
+ _daily_deduct_counters: dict = {} # {plugin_id: {"date": "2026-06-17", "total": 500}}
125
+
126
+
127
+ def _peek_daily_used(plugin_id: str) -> int:
128
+ """读取插件当日已使用的扣款总额(不修改计数)。跨日自动归零。"""
129
+ today = str(date.today())
130
+ with _daily_deduct_lock:
131
+ counter = _daily_deduct_counters.get(plugin_id)
132
+ if not counter or counter.get("date") != today:
133
+ return 0
134
+ return int(counter.get("total", 0))
135
+
136
+
137
+ def _commit_daily_used(plugin_id: str, amount: int) -> int:
138
+ """扣款成功后将本次金额累加到当日计数,返回累加后的总额。"""
139
+ today = str(date.today())
140
+ with _daily_deduct_lock:
141
+ counter = _daily_deduct_counters.get(plugin_id)
142
+ if not counter or counter.get("date") != today:
143
+ counter = {"date": today, "total": 0}
144
+ counter["total"] = int(counter.get("total", 0)) + int(amount)
145
+ _daily_deduct_counters[plugin_id] = counter
146
+ return counter["total"]
147
+
148
+
149
+ def verify_plugin_key(plugin_key: str) -> dict:
150
+ """
151
+ 校验 Plugin Key 并返回插件配置。
152
+
153
+ 参数:
154
+ plugin_key: 请求头 X-Plugin-Key 的值
155
+ 返回:
156
+ 匹配到的插件配置字典(包含 id 字段)
157
+ 异常:
158
+ 401: 未提供 Plugin Key
159
+ 403: Plugin Key 无效或未注册
160
+ """
161
+ if not plugin_key:
162
+ raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header")
163
+ info = _PLUGIN_KEY_INDEX.get(plugin_key)
164
+ if not info:
165
+ logger.warning(f"OPEN_API_INVALID_KEY | key_prefix={plugin_key[:8]}")
166
+ raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册")
167
+ return info
168
+
169
+
170
+ # ==========================================
171
+ # 🚦 限流:基于 plugin_id 的内存计数器(每分钟自动重置)
172
+ # ==========================================
173
+ _rate_lock = threading.Lock()
174
+ _rate_buckets: dict = {} # {plugin_id: {"window_start": int, "count": int}}
175
+
176
+
177
+ def _check_rate_limit(plugin_info: dict, permission: str) -> None:
178
+ """
179
+ 校验插件权限并执行限流。
180
+
181
+ - 权限不足 -> 403
182
+ - 触发限流 -> 429
183
+ """
184
+ plugin_id = plugin_info["id"]
185
+ rate_limit = int(plugin_info.get("rate_limit", 60))
186
+ perms = plugin_info.get("permissions", [])
187
+ if permission not in perms:
188
+ raise HTTPException(
189
+ status_code=403,
190
+ detail=f"插件 {plugin_id} 无权限调用 {permission} 接口",
191
+ )
192
+
193
+ now = int(time.time())
194
+ window_start = now - (now % 60)
195
+
196
+ with _rate_lock:
197
+ bucket = _rate_buckets.get(plugin_id)
198
+ if not bucket or bucket["window_start"] != window_start:
199
+ bucket = {"window_start": window_start, "count": 0}
200
+ _rate_buckets[plugin_id] = bucket
201
+ if bucket["count"] >= rate_limit:
202
+ raise HTTPException(
203
+ status_code=429,
204
+ detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试",
205
+ )
206
+ bucket["count"] += 1
207
+
208
+
209
+ def _require_plugin(plugin_key: Optional[str], permission: str) -> dict:
210
+ """统一入口:校验 Plugin Key + 权限 + 限流"""
211
+ info = verify_plugin_key(plugin_key or "")
212
+ _check_rate_limit(info, permission)
213
+ return info
214
+
215
+
216
+ def _resolve_user_from_token(token: str) -> str:
217
+ """从 Token 中解析出账号;失败时抛 401"""
218
+ if not token:
219
+ raise HTTPException(status_code=401, detail="缺少用户 Token")
220
+ is_valid, account, error_msg = verify_token_with_fallback(token)
221
+ if not is_valid or not account:
222
+ raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
223
+ return account
224
+
225
+
226
+ def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str:
227
+ """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)"""
228
+ data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
229
+ return hashlib.sha256(data.encode()).hexdigest()
230
+
231
+
232
+ # ==========================================
233
+ # 📦 请求模型
234
+ # ==========================================
235
+ class VerifyTokenRequest(BaseModel):
236
+ token: str = Field(..., description="待验证的用户 JWT Token")
237
+
238
+
239
+ class DeductRequest(BaseModel):
240
+ token: str = Field(..., description="用户 JWT Token")
241
+ amount: int = Field(..., gt=0, description="扣款金额(正整数)")
242
+ reason: str = Field(..., description="业务标识/扣款原因")
243
+ reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
244
+
245
+
246
+ class RefundRequest(BaseModel):
247
+ token: str = Field(..., description="用户 JWT Token")
248
+ transaction_id: str = Field(..., description="原扣款交易 ID")
249
+ amount: int = Field(..., gt=0, description="退款金额(正整数)")
250
+
251
+
252
+ # ==========================================
253
+ # 1️⃣ Token 验证:POST /api/open/verify-token
254
+ # ==========================================
255
+ @router.post("/verify-token")
256
+ async def open_verify_token(
257
+ req: VerifyTokenRequest,
258
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
259
+ ):
260
+ """
261
+ 供第三方插件验证用户 Token 有效性。
262
+ 成功返回账号与基础资料(昵称、头像)。
263
+ """
264
+ plugin_info = _require_plugin(x_plugin_key, "verify")
265
+
266
+ is_valid, account, error_msg = verify_token_with_fallback(req.token)
267
+ if not is_valid or not account:
268
+ logger.info(
269
+ f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | reason={error_msg}"
270
+ )
271
+ return {"valid": False, "error": error_msg or "Token 无效"}
272
+
273
+ users_db = json_db.load_data("users.json", default_data={})
274
+ user = users_db.get(account, {}) or {}
275
+
276
+ logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']} | account={account}")
277
+ return {
278
+ "valid": True,
279
+ "account": account,
280
+ "name": user.get("name", account),
281
+ "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""),
282
+ }
283
+
284
+
285
+ # ==========================================
286
+ # 2️⃣ 余额查询:GET /api/open/balance
287
+ # ==========================================
288
+ @router.get("/balance")
289
+ async def open_get_balance(
290
+ authorization: Optional[str] = Header(None, alias="Authorization"),
291
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
292
+ db: Session = Depends(get_db),
293
+ ):
294
+ """
295
+ 查询用户钱包余额。
296
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
297
+ """
298
+ plugin_info = _require_plugin(x_plugin_key, "balance")
299
+
300
+ if not authorization:
301
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
302
+ parts = authorization.split(" ", 1)
303
+ if len(parts) != 2 or parts[0].lower() != "bearer":
304
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
305
+
306
+ account = _resolve_user_from_token(parts[1])
307
+ wallet = db.query(Wallet).filter(Wallet.account == account).first()
308
+
309
+ if not wallet:
310
+ result = {
311
+ "account": account,
312
+ "balance": 0,
313
+ "earn_balance": 0,
314
+ "tip_balance": 0,
315
+ "task_balance": 0,
316
+ "frozen_balance": 0,
317
+ }
318
+ else:
319
+ result = {
320
+ "account": account,
321
+ "balance": int(wallet.balance or 0),
322
+ "earn_balance": int(wallet.earn_balance or 0),
323
+ "tip_balance": int(wallet.tip_balance or 0),
324
+ "task_balance": int(wallet.task_balance or 0),
325
+ "frozen_balance": int(wallet.frozen_balance or 0),
326
+ }
327
+
328
+ logger.info(
329
+ f"OPEN_BALANCE | plugin={plugin_info['id']} | account={account} | balance={result['balance']}"
330
+ )
331
+ return result
332
+
333
+
334
+ # ==========================================
335
+ # 3️⃣ 通用扣款:POST /api/open/deduct
336
+ # ==========================================
337
+ @router.post("/deduct")
338
+ async def open_deduct(
339
+ req: DeductRequest,
340
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
341
+ db: Session = Depends(get_db),
342
+ ):
343
+ """
344
+ 通用扣款接口(从 wallet.balance 扣减)。
345
+ 流程:
346
+ 1. 校验 Plugin Key + 权限 + 限流
347
+ 2. 解析 Token -> account
348
+ 3. 悲观锁锁定钱包,检查余额
349
+ 4. 扣款 + 写入 Transaction(含哈希链)
350
+ """
351
+ plugin_info = _require_plugin(x_plugin_key, "deduct")
352
+ account = _resolve_user_from_token(req.token)
353
+ amount = int(req.amount)
354
+
355
+ # 🛡️ 单笔扣款上限校验(在余额检查之前快速拒绝)
356
+ max_per_tx = int(plugin_info.get("max_deduct_per_tx", 100))
357
+ if amount > max_per_tx:
358
+ logger.warning(
359
+ f"OPEN_DEDUCT_TX_LIMIT | plugin={plugin_info['id']} | account={account} | "
360
+ f"amount={amount} | max={max_per_tx}"
361
+ )
362
+ return JSONResponse(status_code=403, content={
363
+ "error": "AMOUNT_EXCEEDS_LIMIT",
364
+ "message": f"单笔扣款不能超过{max_per_tx}积分",
365
+ "max": max_per_tx,
366
+ })
367
+
368
+ # 🛡️ 日扣款总额校验(仅检查不累加,扣款成功后再累加)
369
+ max_daily = int(plugin_info.get("max_deduct_per_day", 1000))
370
+ used_today = _peek_daily_used(plugin_info["id"])
371
+ if used_today + amount > max_daily:
372
+ logger.warning(
373
+ f"OPEN_DEDUCT_DAILY_LIMIT | plugin={plugin_info['id']} | account={account} | "
374
+ f"amount={amount} | used={used_today} | limit={max_daily}"
375
+ )
376
+ return JSONResponse(status_code=403, content={
377
+ "error": "DAILY_LIMIT_EXCEEDED",
378
+ "message": "今日扣款已达上限",
379
+ "limit": max_daily,
380
+ "used": used_today,
381
+ })
382
+
383
+ try:
384
+ wallet = (
385
+ db.query(Wallet)
386
+ .filter(Wallet.account == account)
387
+ .with_for_update()
388
+ .first()
389
+ )
390
+ if not wallet or int(wallet.balance or 0) < amount:
391
+ raise HTTPException(status_code=402, detail="余额不足")
392
+
393
+ wallet.balance = int(wallet.balance or 0) - amount
394
+
395
+ tx_id = f"OPEN_DEDUCT_{int(time.time())}_{uuid.uuid4().hex[:8]}"
396
+ last_tx = (
397
+ db.query(Transaction)
398
+ .filter(Transaction.account == account)
399
+ .order_by(Transaction.created_at.desc())
400
+ .first()
401
+ )
402
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
403
+ tx_hash = _calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash)
404
+
405
+ new_tx = Transaction(
406
+ tx_id=tx_id,
407
+ account=account,
408
+ tx_type="OPEN_DEDUCT",
409
+ amount=-amount,
410
+ related_account=plugin_info["id"],
411
+ item_id=req.reference_id,
412
+ prev_hash=prev_hash,
413
+ tx_hash=tx_hash,
414
+ description=f"[{plugin_info['name']}] {req.reason}",
415
+ source_plugin=plugin_info["id"], # 🌐 来源插件标识,用于按工具结构化追踪
416
+ )
417
+ db.add(new_tx)
418
+ db.commit()
419
+ db.refresh(wallet)
420
+ except HTTPException:
421
+ db.rollback()
422
+ raise
423
+ except Exception as e:
424
+ db.rollback()
425
+ logger.error(
426
+ f"OPEN_DEDUCT_FAIL | plugin={plugin_info['id']} | account={account} | err={e}"
427
+ )
428
+ raise HTTPException(status_code=500, detail=f"扣款失败: {e}")
429
+
430
+ # ✅ 扣款成功后再累加日计数器,避免余额不足等失败场景占用额度
431
+ daily_total = _commit_daily_used(plugin_info["id"], amount)
432
+
433
+ logger.info(
434
+ f"OPEN_DEDUCT | plugin={plugin_info['id']} | account={account} | "
435
+ f"amount={amount} | tx={tx_id} | reason={req.reason} | ref={req.reference_id} | "
436
+ f"daily_used={daily_total}/{max_daily}"
437
+ )
438
+ return {
439
+ "success": True,
440
+ "remaining_balance": int(wallet.balance or 0),
441
+ "transaction_id": tx_id,
442
+ }
443
+
444
+
445
+ # ==========================================
446
+ # 4️⃣ 退款:POST /api/open/refund
447
+ # ==========================================
448
+ @router.post("/refund")
449
+ async def open_refund(
450
+ req: RefundRequest,
451
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
452
+ db: Session = Depends(get_db),
453
+ ):
454
+ """
455
+ 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
456
+
457
+ 校验:
458
+ - 原交易必须存在且 tx_type == OPEN_DEDUCT
459
+ - 原交易归属当前 token 用户
460
+ - 原交易由当前插件发起(related_account == plugin_id)
461
+ - 累计退款金额 ≤ 原扣款金额
462
+ """
463
+ plugin_info = _require_plugin(x_plugin_key, "refund")
464
+ account = _resolve_user_from_token(req.token)
465
+ refund_amount = int(req.amount)
466
+
467
+ original_tx = (
468
+ db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).first()
469
+ )
470
+ if not original_tx:
471
+ raise HTTPException(status_code=404, detail="原交易不存在")
472
+ if original_tx.account != account:
473
+ raise HTTPException(status_code=403, detail="无权对该交易执行退款")
474
+ if original_tx.tx_type != "OPEN_DEDUCT":
475
+ raise HTTPException(status_code=400, detail="该交易类型不支持开放退款")
476
+ if (original_tx.related_account or "") != plugin_info["id"]:
477
+ raise HTTPException(status_code=403, detail="不允许跨插件退款")
478
+
479
+ original_amount = abs(int(original_tx.amount or 0))
480
+ if refund_amount > original_amount:
481
+ raise HTTPException(status_code=400, detail="退款金额超过原扣款金额")
482
+
483
+ # 累计已退金额校验(防止重复退款超额)
484
+ refunded_records = (
485
+ db.query(Transaction)
486
+ .filter(
487
+ Transaction.tx_type == "OPEN_REFUND",
488
+ Transaction.related_account == plugin_info["id"],
489
+ Transaction.item_id == req.transaction_id,
490
+ )
491
+ .all()
492
+ )
493
+ already_refunded = sum(abs(int(t.amount or 0)) for t in refunded_records)
494
+ if already_refunded + refund_amount > original_amount:
495
+ raise HTTPException(
496
+ status_code=400,
497
+ detail=f"退款金额超过可退余额(已退 {already_refunded}/{original_amount})",
498
+ )
499
+
500
+ try:
501
+ wallet = (
502
+ db.query(Wallet)
503
+ .filter(Wallet.account == account)
504
+ .with_for_update()
505
+ .first()
506
+ )
507
+ if not wallet:
508
+ wallet = Wallet(
509
+ account=account,
510
+ balance=0,
511
+ earn_balance=0,
512
+ tip_balance=0,
513
+ frozen_balance=0,
514
+ )
515
+ db.add(wallet)
516
+ db.flush()
517
+ wallet.balance = int(wallet.balance or 0) + refund_amount
518
+
519
+ tx_id = f"OPEN_REFUND_{int(time.time())}_{uuid.uuid4().hex[:8]}"
520
+ last_tx = (
521
+ db.query(Transaction)
522
+ .filter(Transaction.account == account)
523
+ .order_by(Transaction.created_at.desc())
524
+ .first()
525
+ )
526
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
527
+ tx_hash = _calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash)
528
+
529
+ new_tx = Transaction(
530
+ tx_id=tx_id,
531
+ account=account,
532
+ tx_type="OPEN_REFUND",
533
+ amount=refund_amount,
534
+ related_account=plugin_info["id"],
535
+ item_id=req.transaction_id, # 关联原扣款交易
536
+ prev_hash=prev_hash,
537
+ tx_hash=tx_hash,
538
+ description=f"[{plugin_info['name']}] 退款 {req.transaction_id}",
539
+ source_plugin=plugin_info["id"], # 🌐 来源插件标识
540
+ )
541
+ db.add(new_tx)
542
+ db.commit()
543
+ db.refresh(wallet)
544
+ except HTTPException:
545
+ db.rollback()
546
+ raise
547
+ except Exception as e:
548
+ db.rollback()
549
+ logger.error(
550
+ f"OPEN_REFUND_FAIL | plugin={plugin_info['id']} | account={account} | err={e}"
551
+ )
552
+ raise HTTPException(status_code=500, detail=f"退款失败: {e}")
553
+
554
+ logger.info(
555
+ f"OPEN_REFUND | plugin={plugin_info['id']} | account={account} | "
556
+ f"amount={refund_amount} | tx={tx_id} | original={req.transaction_id}"
557
+ )
558
+ return {
559
+ "success": True,
560
+ "remaining_balance": int(wallet.balance or 0),
561
+ "transaction_id": tx_id,
562
+ }
563
+
564
+
565
+ # ==========================================
566
+ # 5️⃣ 消费记录查询:GET /api/open/transactions
567
+ # ==========================================
568
+ @router.get("/transactions")
569
+ async def open_list_transactions(
570
+ authorization: Optional[str] = Header(None, alias="Authorization"),
571
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
572
+ page: int = Query(1, ge=1, description="页码(从 1 开始)"),
573
+ page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
574
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
575
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
576
+ tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
577
+ db: Session = Depends(get_db),
578
+ ):
579
+ """
580
+ 查询当前用户由当前插件产生的消费/退款记录。
581
+
582
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
583
+ 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
584
+ """
585
+ plugin_info = _require_plugin(x_plugin_key, "verify")
586
+
587
+ if not authorization:
588
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
589
+ parts = authorization.split(" ", 1)
590
+ if len(parts) != 2 or parts[0].lower() != "bearer":
591
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
592
+ account = _resolve_user_from_token(parts[1])
593
+
594
+ plugin_id = plugin_info["id"]
595
+
596
+ # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
597
+ q = db.query(Transaction).filter(
598
+ Transaction.account == account,
599
+ ((Transaction.source_plugin == plugin_id)
600
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
601
+ )
602
+
603
+ if tx_type:
604
+ q = q.filter(Transaction.tx_type == tx_type)
605
+ if start_time is not None:
606
+ q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
607
+ if end_time is not None:
608
+ q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
609
+
610
+ total = q.count()
611
+ rows = (
612
+ q.order_by(Transaction.created_at.desc())
613
+ .offset((page - 1) * page_size)
614
+ .limit(page_size)
615
+ .all()
616
+ )
617
+
618
+ items = []
619
+ for t in rows:
620
+ created_ts = int(t.created_at.timestamp()) if t.created_at else 0
621
+ # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录
622
+ reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None
623
+ items.append({
624
+ "id": t.tx_id,
625
+ "type": t.tx_type,
626
+ "amount": int(t.amount or 0),
627
+ "reason": reason,
628
+ "reference_id": t.item_id,
629
+ "created_at": created_ts,
630
+ "description": t.description or "",
631
+ })
632
+
633
+ logger.info(
634
+ f"OPEN_LIST_TX | plugin={plugin_id} | account={account} | "
635
+ f"page={page}/{page_size} | total={total}"
636
+ )
637
+ return {
638
+ "success": True,
639
+ "transactions": items,
640
+ "total": total,
641
+ "page": page,
642
+ "page_size": page_size,
643
+ }
644
+
645
+
646
+ # ==========================================
647
+ # 6️⃣ 消费汇总:GET /api/open/usage-summary
648
+ # ==========================================
649
+ @router.get("/usage-summary")
650
+ async def open_usage_summary(
651
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
652
+ account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
653
+ period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
654
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
655
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
656
+ db: Session = Depends(get_db),
657
+ ):
658
+ """
659
+ 本插件维度的消费汇总统计。
660
+
661
+ 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
662
+ 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
663
+ """
664
+ plugin_info = _require_plugin(x_plugin_key, "verify")
665
+ plugin_id = plugin_info["id"]
666
+
667
+ period = (period or "daily").lower()
668
+ if period not in ("daily", "monthly", "total"):
669
+ raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total")
670
+
671
+ base = db.query(Transaction).filter(
672
+ ((Transaction.source_plugin == plugin_id)
673
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
674
+ Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]),
675
+ )
676
+ if account:
677
+ base = base.filter(Transaction.account == account)
678
+ if start_time is not None:
679
+ base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
680
+ if end_time is not None:
681
+ base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
682
+
683
+ rows = base.all()
684
+
685
+ total_deduct = 0
686
+ total_refund = 0
687
+ total_count = len(rows)
688
+ bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int}
689
+
690
+ for t in rows:
691
+ amt = int(t.amount or 0)
692
+ if t.tx_type == "OPEN_DEDUCT":
693
+ total_deduct += abs(amt)
694
+ elif t.tx_type == "OPEN_REFUND":
695
+ total_refund += abs(amt)
696
+
697
+ if period == "total":
698
+ continue
699
+ if not t.created_at:
700
+ continue
701
+ if period == "daily":
702
+ key = t.created_at.strftime("%Y-%m-%d")
703
+ else: # monthly
704
+ key = t.created_at.strftime("%Y-%m")
705
+
706
+ b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0})
707
+ if t.tx_type == "OPEN_DEDUCT":
708
+ b["deduct"] += abs(amt)
709
+ elif t.tx_type == "OPEN_REFUND":
710
+ b["refund"] += abs(amt)
711
+ b["count"] += 1
712
+
713
+ breakdown = []
714
+ if period != "total":
715
+ for key in sorted(bucket.keys(), reverse=True):
716
+ v = bucket[key]
717
+ label = "date" if period == "daily" else "month"
718
+ breakdown.append({
719
+ label: key,
720
+ "deduct": v["deduct"],
721
+ "refund": v["refund"],
722
+ "count": v["count"],
723
+ })
724
+
725
+ logger.info(
726
+ f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | account={account or '*'} | "
727
+ f"period={period} | tx_count={total_count}"
728
+ )
729
+ return {
730
+ "success": True,
731
+ "plugin_id": plugin_id,
732
+ "plugin_name": plugin_info.get("name", plugin_id),
733
+ "summary": {
734
+ "total_deduct": total_deduct,
735
+ "total_refund": total_refund,
736
+ "total_transactions": total_count,
737
+ "net_consumption": total_deduct - total_refund,
738
+ },
739
+ "breakdown": breakdown,
740
+ }