ZHIWEI666 commited on
Commit
7282cc7
·
verified ·
1 Parent(s): 4bcc697

Upload 5 files

Browse files
Files changed (3) hide show
  1. app.py +33 -8
  2. router_admin_plugins.py +428 -420
  3. router_open_api.py +702 -688
app.py CHANGED
@@ -31,7 +31,8 @@ from rate_limit import start_shared_work, stop_shared_work, close_shared_work
31
  from script_execution import start_admin_scripts, stop_admin_scripts, close_admin_scripts
32
  from comment_store import start_comment_reads, stop_comment_reads, close_comment_reads
33
  from periodic_tasks import start_periodic_task, request_periodic_stop, wait_periodic_tasks
34
- from owned_thread import run_owned_thread
 
35
  from resource_work import start_resource_work, stop_resource_work, close_resource_work
36
  from shutdown_lifecycle import (prepare_application_shutdown, run_application_shutdown,
37
  shutdown_cancel_requested, ShutdownFailures)
@@ -86,6 +87,25 @@ from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无
86
  from fastapi.exceptions import RequestValidationError
87
  from starlette.exceptions import HTTPException as StarletteHTTPException
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  app = FastAPI(
90
  title="ComfyUI Ranking API",
91
  description="ComfyUI 社区排名系统 API",
@@ -295,6 +315,7 @@ async def on_startup():
295
  start_identity_io()
296
  start_shared_work()
297
  start_open_sql()
 
298
  start_wallet_sql()
299
  start_resource_work()
300
  start_resource_reads()
@@ -365,7 +386,7 @@ async def _shutdown_resources():
365
  ('stop_wallet_sql', stop_wallet_sql),
366
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
367
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
368
- ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
369
  ('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
370
  ('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
371
  ('stop_comments', stop_comment_reads),
@@ -382,7 +403,7 @@ async def _shutdown_resources():
382
  ('close_wallet_sql', close_wallet_sql, ()),
383
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
384
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
385
- ('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
386
  ('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
387
  ('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
388
  ('close_scripts', close_admin_scripts, ()), ('close_comments', close_comment_reads, ()),
@@ -488,7 +509,7 @@ def _probe_skill_repository(item, token):
488
 
489
  @app.post("/api/validate_resource")
490
  @shared_limit(30)
491
- def validate_resource(req_data: ValidateResourceRequest, request: Request, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)):
492
  return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
493
  open_url=open_github_probe, token_selector=select_github_token,
494
  error_response=create_error_response, skill_probe=_probe_skill_repository)
@@ -609,7 +630,8 @@ def _save_system_config(config: dict, db: Session = None, updated_by: str = None
609
 
610
 
611
  @app.get("/api/admin/config/{config_key}")
612
- async def get_system_config(config_key: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
 
613
  """ 🔒 获取系统配置(仅管理员) """
614
  # 🔒 权限验证
615
  if not _is_admin(current_user):
@@ -644,7 +666,8 @@ DEFAULT_VERSION_CONFIG = {
644
 
645
 
646
  @app.get("/api/public/project-version")
647
- async def get_public_project_version(db: Session = Depends(get_db)):
 
648
  """获取项目版本信息(公开接口,无需认证)"""
649
  config = _load_system_config(db)
650
  version_data = config.get("project_version", DEFAULT_VERSION_CONFIG)
@@ -652,7 +675,8 @@ async def get_public_project_version(db: Session = Depends(get_db)):
652
 
653
 
654
  @app.get("/api/public/banner_config")
655
- async def get_public_banner_config(db: Session = Depends(get_db)):
 
656
  """公开接口:获取广告横幅配置(所有已登录/未登录用户均可访问)"""
657
  try:
658
  config = _load_system_config(db, strict=True)
@@ -680,7 +704,8 @@ async def get_public_banner_config(db: Session = Depends(get_db)):
680
 
681
 
682
  @app.put("/api/admin/config/{config_key}")
683
- async def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
 
684
  """ 🔒 设置系统配置(仅管理员) """
685
  # 🔒 权限验证
686
  if not _is_admin(current_user):
 
31
  from script_execution import start_admin_scripts, stop_admin_scripts, close_admin_scripts
32
  from comment_store import start_comment_reads, stop_comment_reads, close_comment_reads
33
  from periodic_tasks import start_periodic_task, request_periodic_stop, wait_periodic_tasks
34
+ from owned_thread import run_owned_thread, OwnedThreadGroup, owned_session_endpoint
35
+ from contextlib import contextmanager
36
  from resource_work import start_resource_work, stop_resource_work, close_resource_work
37
  from shutdown_lifecycle import (prepare_application_shutdown, run_application_shutdown,
38
  shutdown_cancel_requested, ShutdownFailures)
 
87
  from fastapi.exceptions import RequestValidationError
88
  from starlette.exceptions import HTTPException as StarletteHTTPException
89
 
90
+ MAX_CONFIG_SQL_WORK = 8
91
+ _config_sql_work = OwnedThreadGroup(MAX_CONFIG_SQL_WORK,
92
+ lambda: HTTPException(status_code=503, detail="service_unavailable"))
93
+ _config_sql_lock = threading.Lock()
94
+ start_config_sql = _config_sql_work.start
95
+ stop_config_sql = _config_sql_work.stop
96
+ close_config_sql = _config_sql_work.close
97
+
98
+
99
+ @contextmanager
100
+ def _config_sql_session():
101
+ with _config_sql_lock:
102
+ with SessionLocal() as session:
103
+ yield session
104
+
105
+
106
+ _config_endpoint = owned_session_endpoint(_config_sql_work, _config_sql_session, 'db')
107
+
108
+
109
  app = FastAPI(
110
  title="ComfyUI Ranking API",
111
  description="ComfyUI 社区排名系统 API",
 
315
  start_identity_io()
316
  start_shared_work()
317
  start_open_sql()
318
+ start_config_sql()
319
  start_wallet_sql()
320
  start_resource_work()
321
  start_resource_reads()
 
386
  ('stop_wallet_sql', stop_wallet_sql),
387
  ('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
388
  ('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io), ('stop_tasks', stop_tasks_io), ('stop_proxy_access', stop_proxy_access),
389
+ ('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_config_sql', stop_config_sql), ('stop_shared', stop_shared_work),
390
  ('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
391
  ('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
392
  ('stop_comments', stop_comment_reads),
 
403
  ('close_wallet_sql', close_wallet_sql, ()),
404
  ('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
405
  ('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()), ('close_tasks', close_tasks_io, ()), ('close_proxy_access', close_proxy_access, ()),
406
+ ('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_config_sql', close_config_sql, ()), ('close_shared', close_shared_work, ()),
407
  ('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
408
  ('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
409
  ('close_scripts', close_admin_scripts, ()), ('close_comments', close_comment_reads, ()),
 
509
 
510
  @app.post("/api/validate_resource")
511
  @shared_limit(30)
512
+ def validate_resource(req_data: ValidateResourceRequest, request: Request, current_user: str = Depends(require_auth)):
513
  return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
514
  open_url=open_github_probe, token_selector=select_github_token,
515
  error_response=create_error_response, skill_probe=_probe_skill_repository)
 
630
 
631
 
632
  @app.get("/api/admin/config/{config_key}")
633
+ @_config_endpoint
634
+ def get_system_config(config_key: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
635
  """ 🔒 获取系统配置(仅管理员) """
636
  # 🔒 权限验证
637
  if not _is_admin(current_user):
 
666
 
667
 
668
  @app.get("/api/public/project-version")
669
+ @_config_endpoint
670
+ def get_public_project_version(db: Session = Depends(get_db)):
671
  """获取项目版本信息(公开接口,无需认证)"""
672
  config = _load_system_config(db)
673
  version_data = config.get("project_version", DEFAULT_VERSION_CONFIG)
 
675
 
676
 
677
  @app.get("/api/public/banner_config")
678
+ @_config_endpoint
679
+ def get_public_banner_config(db: Session = Depends(get_db)):
680
  """公开接口:获取广告横幅配置(所有已登录/未登录用户均可访问)"""
681
  try:
682
  config = _load_system_config(db, strict=True)
 
704
 
705
 
706
  @app.put("/api/admin/config/{config_key}")
707
+ @_config_endpoint
708
+ def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
709
  """ 🔒 设置系统配置(仅管理员) """
710
  # 🔒 权限验证
711
  if not _is_admin(current_user):
router_admin_plugins.py CHANGED
@@ -1,420 +1,428 @@
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 hmac
17
- import hashlib
18
- import secrets
19
- import logging
20
- import datetime
21
- from typing import Optional, List
22
-
23
- from fastapi import APIRouter, Depends, HTTPException, Request
24
- from rate_limit import shared_limit
25
- from fastapi.responses import JSONResponse
26
- from sqlalchemy.orm import Session
27
- from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
28
-
29
- from database_sql import get_db
30
- from models_sql import RegisteredPlugin
31
- from plugin_permissions import DEFAULT_PERMISSIONS, parse_permissions
32
- from 安全认证 import require_auth, is_admin, JWT_SECRET
33
-
34
- logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
35
-
36
- router = APIRouter()
37
-
38
- # ==========================================
39
- # 🔧 工具函数
40
- # ==========================================
41
-
42
- # plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
43
- _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
44
-
45
- def _audit_actor_id(account: str) -> str:
46
- """稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
47
- return hmac.new(
48
- JWT_SECRET.encode("utf-8"), str(account).encode("utf-8"), hashlib.sha256
49
- ).hexdigest()[:16]
50
-
51
-
52
- def generate_plugin_key() -> str:
53
- """生成 plugin_key,格式: rk_plug_ + 32位hex随机串"""
54
- return f"rk_plug_{secrets.token_hex(16)}"
55
-
56
-
57
- def mask_key(key: str) -> str:
58
- """脱敏 plugin_key:保留前12位(rk_plug_ + 4位),其余以 *** 替代"""
59
- if not key:
60
- return "***"
61
- return key[:12] + "***" if len(key) > 12 else "***"
62
-
63
-
64
- def _require_admin(account: str):
65
- """工具函数:校验管理员权限,否则抛 403"""
66
- if not is_admin(account):
67
- raise HTTPException(status_code=403, detail="需要管理员权限")
68
-
69
-
70
- def _notify_registry_reload():
71
- """通知 open_api 模块重新加载插件注册表(热更新)"""
72
- try:
73
- from router_open_api import reload_plugin_registry
74
- reload_plugin_registry()
75
- logger.info("插件注册表热更新成功")
76
- except (ImportError, Exception) as e:
77
- logger.warning(f"插件注册表热更新失败: error_type={type(e).__name__}")
78
-
79
-
80
- def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict:
81
- """
82
- 序列化插件对象为字典
83
-
84
- 参数:
85
- p: RegisteredPlugin ORM 对象
86
- include_key: 是否返回完整 plugin_key(仅在创建/轮换时为 True)
87
- raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
88
- """
89
- try:
90
- permissions = parse_permissions(p.permissions)
91
- permissions_valid = True
92
- except ValueError:
93
- permissions = []
94
- permissions_valid = False
95
-
96
- data = {
97
- "plugin_id": p.plugin_id,
98
- "name": p.name,
99
- "description": p.description,
100
- "enabled": bool(p.enabled),
101
- "rate_limit": p.rate_limit,
102
- "max_deduct_per_tx": p.max_deduct_per_tx,
103
- "max_deduct_per_day": p.max_deduct_per_day,
104
- "permissions": permissions,
105
- "permissions_valid": permissions_valid,
106
- "created_at": p.created_at.isoformat() if p.created_at else None,
107
- "created_by": p.created_by,
108
- "updated_at": p.updated_at.isoformat() if p.updated_at else None,
109
- "last_rotated_at": p.last_rotated_at.isoformat() if p.last_rotated_at else None,
110
- }
111
-
112
- if include_key and raw_key:
113
- data["plugin_key"] = raw_key
114
- else:
115
- data["plugin_key_masked"] = mask_key(p.plugin_key)
116
-
117
- return data
118
-
119
-
120
- # ==========================================
121
- # 📥 请求模型
122
- # ==========================================
123
-
124
- class PluginCreateRequest(BaseModel):
125
- plugin_id: str = Field(..., min_length=3, max_length=50, description="插件唯一标识,仅允许小写字母、数字、下划线")
126
- name: str = Field(..., min_length=1, max_length=100, description="插件显示名")
127
- description: Optional[str] = Field(None, max_length=500)
128
- rate_limit: Optional[int] = Field(100, ge=1, le=10000)
129
- max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
130
- max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
131
- permissions: Optional[List[str]] = Field(default_factory=lambda: list(DEFAULT_PERMISSIONS))
132
-
133
- @field_validator("plugin_id")
134
- @classmethod
135
- def _validate_plugin_id(cls, v):
136
- if not _PLUGIN_ID_PATTERN.match(v):
137
- raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
138
- return v
139
-
140
- @field_validator("permissions")
141
- @classmethod
142
- def _validate_permissions(cls, v):
143
- if v is None:
144
- return list(DEFAULT_PERMISSIONS)
145
- if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
146
- raise ValueError("permissions 必须为字符串数组")
147
- return v
148
-
149
-
150
- class PluginUpdateRequest(BaseModel):
151
- name: Optional[str] = Field(None, min_length=1, max_length=100)
152
- description: Optional[str] = Field(None, max_length=500)
153
- enabled: Optional[bool] = None
154
- rate_limit: Optional[int] = Field(None, ge=1, le=10000)
155
- max_deduct_per_tx: Optional[int] = Field(None, ge=1, le=10000)
156
- max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
157
- permissions: Optional[List[str]] = None
158
-
159
- @field_validator("permissions")
160
- @classmethod
161
- def _validate_permissions(cls, v):
162
- if v is None:
163
- return v
164
- if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
165
- raise ValueError("permissions 必须为字符串数组")
166
- return v
167
-
168
-
169
- # ==========================================
170
- # 🌐 路由实现
171
- # ==========================================
172
-
173
- @router.get("/api/admin/plugins")
174
- async def list_plugins(
175
- account: str = Depends(require_auth),
176
- db: Session = Depends(get_db),
177
- ):
178
- """列出所有已注册插件(plugin_key 脱敏)"""
179
- _require_admin(account)
180
- try:
181
- plugins = db.query(RegisteredPlugin).order_by(RegisteredPlugin.created_at.desc()).all()
182
- return JSONResponse(content={
183
- "success": True,
184
- "total": len(plugins),
185
- "plugins": [_serialize_plugin(p) for p in plugins],
186
- })
187
- except HTTPException:
188
- raise
189
- except Exception as e:
190
- logger.error(f"列出插件失败: error_type={type(e).__name__}")
191
- return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"})
192
-
193
-
194
- @router.get("/api/admin/plugins/{plugin_id}")
195
- async def get_plugin(
196
- plugin_id: str,
197
- account: str = Depends(require_auth),
198
- db: Session = Depends(get_db),
199
- ):
200
- """获取插件详情(plugin_key 脱敏)"""
201
- _require_admin(account)
202
- plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
203
- if not plugin:
204
- return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
205
- return JSONResponse(content={"success": True, "plugin": _serialize_plugin(plugin)})
206
-
207
-
208
- @router.post("/api/admin/plugins")
209
- @shared_limit(20)
210
- async def create_plugin(
211
- request: Request,
212
- payload: PluginCreateRequest,
213
- account: str = Depends(require_auth),
214
- db: Session = Depends(get_db),
215
- ):
216
- """创建新插件(自动生成 plugin_key,仅本次返回明文 key)"""
217
- _require_admin(account)
218
-
219
- # 检查 plugin_id 是否已存在
220
- exists = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == payload.plugin_id).first()
221
- if exists:
222
- return JSONResponse(status_code=409, content={"success": False, "error": f"plugin_id '{payload.plugin_id}' 已存在"})
223
-
224
- plugin_key = generate_plugin_key()
225
- now = datetime.datetime.utcnow()
226
-
227
- plugin = RegisteredPlugin(
228
- plugin_id=payload.plugin_id,
229
- plugin_key=plugin_key,
230
- name=payload.name,
231
- description=payload.description,
232
- enabled=True,
233
- rate_limit=payload.rate_limit,
234
- max_deduct_per_tx=payload.max_deduct_per_tx,
235
- max_deduct_per_day=payload.max_deduct_per_day,
236
- permissions=json.dumps(payload.permissions, ensure_ascii=False),
237
- created_at=now,
238
- created_by=account,
239
- updated_at=now,
240
- )
241
-
242
- try:
243
- db.add(plugin)
244
- db.commit()
245
- db.refresh(plugin)
246
- except Exception as e:
247
- db.rollback()
248
- logger.error(f"创建插件失败: error_type={type(e).__name__}")
249
- return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
250
-
251
- logger.info(f"ADMIN_PLUGIN_CREATE | actor={_audit_actor_id(account)} | plugin_id={payload.plugin_id}")
252
- _notify_registry_reload()
253
-
254
- return JSONResponse(status_code=201, content={
255
- "success": True,
256
- "plugin_id": plugin.plugin_id,
257
- "plugin_key": plugin_key,
258
- "name": plugin.name,
259
- "message": "插件创建成功,请妥善保存 plugin_key(仅显示一次)",
260
- "plugin": _serialize_plugin(plugin, include_key=True, raw_key=plugin_key),
261
- })
262
-
263
-
264
- @router.put("/api/admin/plugins/{plugin_id}")
265
- @shared_limit(30)
266
- async def update_plugin(
267
- request: Request,
268
- plugin_id: str,
269
- payload: PluginUpdateRequest,
270
- account: str = Depends(require_auth),
271
- db: Session = Depends(get_db),
272
- ):
273
- """更新插件配置(不可修改 plugin_id 与 plugin_key)"""
274
- _require_admin(account)
275
-
276
- plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
277
- if not plugin:
278
- return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
279
-
280
- update_fields = payload.dict(exclude_unset=True)
281
- if not update_fields:
282
- return JSONResponse(status_code=400, content={"success": False, "error": "未提供任何可更新字段"})
283
-
284
- try:
285
- if "permissions" in update_fields and update_fields["permissions"] is not None:
286
- plugin.permissions = json.dumps(update_fields.pop("permissions"), ensure_ascii=False)
287
-
288
- for key, value in update_fields.items():
289
- if value is None:
290
- continue
291
- setattr(plugin, key, value)
292
-
293
- plugin.updated_at = datetime.datetime.utcnow()
294
- db.commit()
295
- db.refresh(plugin)
296
- except Exception as e:
297
- db.rollback()
298
- logger.error(f"更新插件失败: error_type={type(e).__name__}")
299
- return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
300
-
301
- logger.info(
302
- f"ADMIN_PLUGIN_UPDATE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
303
- f"fields={','.join(sorted(update_fields.keys()))}"
304
- )
305
- _notify_registry_reload()
306
-
307
- return JSONResponse(content={
308
- "success": True,
309
- "message": "插件更新成功",
310
- "plugin": _serialize_plugin(plugin),
311
- })
312
-
313
-
314
- @router.delete("/api/admin/plugins/{plugin_id}")
315
- @shared_limit(10)
316
- async def delete_plugin(
317
- request: Request,
318
- plugin_id: str,
319
- account: str = Depends(require_auth),
320
- db: Session = Depends(get_db),
321
- ):
322
- """删除插件"""
323
- _require_admin(account)
324
-
325
- plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
326
- if not plugin:
327
- return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
328
-
329
- try:
330
- db.delete(plugin)
331
- db.commit()
332
- except Exception as e:
333
- db.rollback()
334
- logger.error(f"删除插件失败: error_type={type(e).__name__}")
335
- return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
336
-
337
- logger.info(f"ADMIN_PLUGIN_DELETE | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
338
- _notify_registry_reload()
339
-
340
- return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id})
341
-
342
-
343
- @router.post("/api/admin/plugins/{plugin_id}/rotate-key")
344
- @shared_limit(5)
345
- async def rotate_plugin_key(
346
- request: Request,
347
- plugin_id: str,
348
- account: str = Depends(require_auth),
349
- db: Session = Depends(get_db),
350
- ):
351
- """轮换 plugin_key,返回新 key(仅本次返回明文)"""
352
- _require_admin(account)
353
-
354
- plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
355
- if not plugin:
356
- return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
357
-
358
- new_key = generate_plugin_key()
359
- now = datetime.datetime.utcnow()
360
-
361
- try:
362
- plugin.plugin_key = new_key
363
- plugin.last_rotated_at = now
364
- plugin.updated_at = now
365
- db.commit()
366
- db.refresh(plugin)
367
- except Exception as e:
368
- db.rollback()
369
- logger.error(f"轮换 plugin_key 失败: error_type={type(e).__name__}")
370
- return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
371
-
372
- logger.info(f"ADMIN_PLUGIN_ROTATE_KEY | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
373
- _notify_registry_reload()
374
-
375
- return JSONResponse(content={
376
- "success": True,
377
- "plugin_id": plugin.plugin_id,
378
- "plugin_key": new_key,
379
- "message": "plugin_key 已轮换,请妥善保存(仅显示一次)",
380
- "last_rotated_at": now.isoformat(),
381
- })
382
-
383
-
384
- @router.post("/api/admin/plugins/{plugin_id}/toggle")
385
- @shared_limit(20)
386
- async def toggle_plugin(
387
- request: Request,
388
- plugin_id: str,
389
- account: str = Depends(require_auth),
390
- db: Session = Depends(get_db),
391
- ):
392
- """启用/禁用切换"""
393
- _require_admin(account)
394
-
395
- plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
396
- if not plugin:
397
- return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
398
-
399
- try:
400
- plugin.enabled = not bool(plugin.enabled)
401
- plugin.updated_at = datetime.datetime.utcnow()
402
- db.commit()
403
- db.refresh(plugin)
404
- except Exception as e:
405
- db.rollback()
406
- logger.error(f"切换插件状态失败: error_type={type(e).__name__}")
407
- return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
408
-
409
- logger.info(
410
- f"ADMIN_PLUGIN_TOGGLE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
411
- f"enabled={bool(plugin.enabled)}"
412
- )
413
- _notify_registry_reload()
414
-
415
- return JSONResponse(content={
416
- "success": True,
417
- "plugin_id": plugin.plugin_id,
418
- "enabled": bool(plugin.enabled),
419
- "message": "插件已启用" if plugin.enabled else "插件已禁用",
420
- })
 
 
 
 
 
 
 
 
 
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 hmac
17
+ import hashlib
18
+ import secrets
19
+ import logging
20
+ import datetime
21
+ from typing import Optional, List
22
+
23
+ from fastapi import APIRouter, Depends, HTTPException, Request
24
+ from rate_limit import shared_limit
25
+ from fastapi.responses import JSONResponse
26
+ from sqlalchemy.orm import Session
27
+ from pydantic import BaseModel, Field, field_validator # 🧹 P2修复:pydantic v2 已废弃 v1 风格 validator
28
+
29
+ from database_sql import get_db
30
+ from router_open_api import open_sql_endpoint
31
+ from models_sql import RegisteredPlugin
32
+ from plugin_permissions import DEFAULT_PERMISSIONS, parse_permissions
33
+ from 安全认证 import require_auth, is_admin, JWT_SECRET
34
+
35
+ logger = logging.getLogger("ComfyUI-Ranking.AdminPlugins")
36
+
37
+ router = APIRouter()
38
+
39
+ # ==========================================
40
+ # 🔧 工具函数
41
+ # ==========================================
42
+
43
+ # plugin_id 合法性正则:3-50字符,仅允许小写字母、数字、下划线
44
+ _PLUGIN_ID_PATTERN = re.compile(r"^[a-z0-9_]{3,50}$")
45
+
46
+ def _audit_actor_id(account: str) -> str:
47
+ """稳定关联管理员审计事件,但不把可识别账号写入运行日志。"""
48
+ return hmac.new(
49
+ JWT_SECRET.encode("utf-8"), str(account).encode("utf-8"), hashlib.sha256
50
+ ).hexdigest()[:16]
51
+
52
+
53
+ def generate_plugin_key() -> str:
54
+ """生成 plugin_key,格式: rk_plug_ + 32位hex随机串"""
55
+ return f"rk_plug_{secrets.token_hex(16)}"
56
+
57
+
58
+ def mask_key(key: str) -> str:
59
+ """脱敏 plugin_key:保留前12位(rk_plug_ + 4位),其余以 *** 替代"""
60
+ if not key:
61
+ return "***"
62
+ return key[:12] + "***" if len(key) > 12 else "***"
63
+
64
+
65
+ def _require_admin(account: str):
66
+ """工具函数:校验管理员权限,否则抛 403"""
67
+ if not is_admin(account):
68
+ raise HTTPException(status_code=403, detail="需要管理员权限")
69
+
70
+
71
+ def _notify_registry_reload():
72
+ """通知 open_api 模块重新加载插件注册表(热更新)"""
73
+ try:
74
+ from router_open_api import reload_plugin_registry
75
+ reload_plugin_registry()
76
+ logger.info("插件注册表热更新成功")
77
+ except (ImportError, Exception) as e:
78
+ logger.warning(f"插件注册表热更新失败: error_type={type(e).__name__}")
79
+
80
+
81
+ def _serialize_plugin(p: RegisteredPlugin, include_key: bool = False, raw_key: Optional[str] = None) -> dict:
82
+ """
83
+ 序列化插件对象为字典
84
+
85
+ 参数:
86
+ p: RegisteredPlugin ORM 对象
87
+ include_key: 是否返回完整 plugin_key(仅在创建/轮换时为 True)
88
+ raw_key: 完整明文 key(仅在创建/轮换时传入;其他场景使用脱敏)
89
+ """
90
+ try:
91
+ permissions = parse_permissions(p.permissions)
92
+ permissions_valid = True
93
+ except ValueError:
94
+ permissions = []
95
+ permissions_valid = False
96
+
97
+ data = {
98
+ "plugin_id": p.plugin_id,
99
+ "name": p.name,
100
+ "description": p.description,
101
+ "enabled": bool(p.enabled),
102
+ "rate_limit": p.rate_limit,
103
+ "max_deduct_per_tx": p.max_deduct_per_tx,
104
+ "max_deduct_per_day": p.max_deduct_per_day,
105
+ "permissions": permissions,
106
+ "permissions_valid": permissions_valid,
107
+ "created_at": p.created_at.isoformat() if p.created_at else None,
108
+ "created_by": p.created_by,
109
+ "updated_at": p.updated_at.isoformat() if p.updated_at else None,
110
+ "last_rotated_at": p.last_rotated_at.isoformat() if p.last_rotated_at else None,
111
+ }
112
+
113
+ if include_key and raw_key:
114
+ data["plugin_key"] = raw_key
115
+ else:
116
+ data["plugin_key_masked"] = mask_key(p.plugin_key)
117
+
118
+ return data
119
+
120
+
121
+ # ==========================================
122
+ # 📥 请求模型
123
+ # ==========================================
124
+
125
+ class PluginCreateRequest(BaseModel):
126
+ plugin_id: str = Field(..., min_length=3, max_length=50, description="插件唯一标识,仅允许小写字母、数字、下划线")
127
+ name: str = Field(..., min_length=1, max_length=100, description="插件显示名")
128
+ description: Optional[str] = Field(None, max_length=500)
129
+ rate_limit: Optional[int] = Field(100, ge=1, le=10000)
130
+ max_deduct_per_tx: Optional[int] = Field(100, ge=1, le=10000)
131
+ max_deduct_per_day: Optional[int] = Field(1000, ge=1, le=100000)
132
+ permissions: Optional[List[str]] = Field(default_factory=lambda: list(DEFAULT_PERMISSIONS))
133
+
134
+ @field_validator("plugin_id")
135
+ @classmethod
136
+ def _validate_plugin_id(cls, v):
137
+ if not _PLUGIN_ID_PATTERN.match(v):
138
+ raise ValueError("plugin_id 仅允许 [a-z0-9_],长度 3-50")
139
+ return v
140
+
141
+ @field_validator("permissions")
142
+ @classmethod
143
+ def _validate_permissions(cls, v):
144
+ if v is None:
145
+ return list(DEFAULT_PERMISSIONS)
146
+ if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
147
+ raise ValueError("permissions 必须为字符串数组")
148
+ return v
149
+
150
+
151
+ class PluginUpdateRequest(BaseModel):
152
+ name: Optional[str] = Field(None, min_length=1, max_length=100)
153
+ description: Optional[str] = Field(None, max_length=500)
154
+ enabled: Optional[bool] = None
155
+ rate_limit: Optional[int] = Field(None, ge=1, le=10000)
156
+ max_deduct_per_tx: Optional[int] = Field(None, ge=1, le=10000)
157
+ max_deduct_per_day: Optional[int] = Field(None, ge=1, le=100000)
158
+ permissions: Optional[List[str]] = None
159
+
160
+ @field_validator("permissions")
161
+ @classmethod
162
+ def _validate_permissions(cls, v):
163
+ if v is None:
164
+ return v
165
+ if not isinstance(v, list) or any(not isinstance(x, str) for x in v):
166
+ raise ValueError("permissions 必须为字符串数组")
167
+ return v
168
+
169
+
170
+ # ==========================================
171
+ # 🌐 路由实现
172
+ # ==========================================
173
+
174
+ @router.get("/api/admin/plugins")
175
+ @open_sql_endpoint
176
+ def list_plugins(
177
+ account: str = Depends(require_auth),
178
+ db: Session = Depends(get_db),
179
+ ):
180
+ """列出所有已注册插件(plugin_key 脱敏)"""
181
+ _require_admin(account)
182
+ try:
183
+ plugins = db.query(RegisteredPlugin).order_by(RegisteredPlugin.created_at.desc()).all()
184
+ return JSONResponse(content={
185
+ "success": True,
186
+ "total": len(plugins),
187
+ "plugins": [_serialize_plugin(p) for p in plugins],
188
+ })
189
+ except HTTPException:
190
+ raise
191
+ except Exception as e:
192
+ logger.error(f"列出插件失败: error_type={type(e).__name__}")
193
+ return JSONResponse(status_code=500, content={"success": False, "error": "服务器内部错误"})
194
+
195
+
196
+ @router.get("/api/admin/plugins/{plugin_id}")
197
+ @open_sql_endpoint
198
+ def get_plugin(
199
+ plugin_id: str,
200
+ account: str = Depends(require_auth),
201
+ db: Session = Depends(get_db),
202
+ ):
203
+ """获取插件详情(plugin_key 脱敏)"""
204
+ _require_admin(account)
205
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
206
+ if not plugin:
207
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
208
+ return JSONResponse(content={"success": True, "plugin": _serialize_plugin(plugin)})
209
+
210
+
211
+ @router.post("/api/admin/plugins")
212
+ @shared_limit(20)
213
+ @open_sql_endpoint
214
+ def create_plugin(
215
+ request: Request,
216
+ payload: PluginCreateRequest,
217
+ account: str = Depends(require_auth),
218
+ db: Session = Depends(get_db),
219
+ ):
220
+ """创建新插件(自动生成 plugin_key,仅本次返回明文 key)"""
221
+ _require_admin(account)
222
+
223
+ # 检查 plugin_id 是否已存在
224
+ exists = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == payload.plugin_id).first()
225
+ if exists:
226
+ return JSONResponse(status_code=409, content={"success": False, "error": f"plugin_id '{payload.plugin_id}' 已存在"})
227
+
228
+ plugin_key = generate_plugin_key()
229
+ now = datetime.datetime.utcnow()
230
+
231
+ plugin = RegisteredPlugin(
232
+ plugin_id=payload.plugin_id,
233
+ plugin_key=plugin_key,
234
+ name=payload.name,
235
+ description=payload.description,
236
+ enabled=True,
237
+ rate_limit=payload.rate_limit,
238
+ max_deduct_per_tx=payload.max_deduct_per_tx,
239
+ max_deduct_per_day=payload.max_deduct_per_day,
240
+ permissions=json.dumps(payload.permissions, ensure_ascii=False),
241
+ created_at=now,
242
+ created_by=account,
243
+ updated_at=now,
244
+ )
245
+
246
+ try:
247
+ db.add(plugin)
248
+ db.commit()
249
+ db.refresh(plugin)
250
+ except Exception as e:
251
+ db.rollback()
252
+ logger.error(f"创建插件失败: error_type={type(e).__name__}")
253
+ return JSONResponse(status_code=500, content={"success": False, "error": "创建插件失败"})
254
+
255
+ logger.info(f"ADMIN_PLUGIN_CREATE | actor={_audit_actor_id(account)} | plugin_id={payload.plugin_id}")
256
+ _notify_registry_reload()
257
+
258
+ return JSONResponse(status_code=201, content={
259
+ "success": True,
260
+ "plugin_id": plugin.plugin_id,
261
+ "plugin_key": plugin_key,
262
+ "name": plugin.name,
263
+ "message": "插件创建成功,请妥善保存 plugin_key(仅显示一次)",
264
+ "plugin": _serialize_plugin(plugin, include_key=True, raw_key=plugin_key),
265
+ })
266
+
267
+
268
+ @router.put("/api/admin/plugins/{plugin_id}")
269
+ @shared_limit(30)
270
+ @open_sql_endpoint
271
+ def update_plugin(
272
+ request: Request,
273
+ plugin_id: str,
274
+ payload: PluginUpdateRequest,
275
+ account: str = Depends(require_auth),
276
+ db: Session = Depends(get_db),
277
+ ):
278
+ """更新插件配置(不可修改 plugin_id 与 plugin_key)"""
279
+ _require_admin(account)
280
+
281
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
282
+ if not plugin:
283
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
284
+
285
+ update_fields = payload.dict(exclude_unset=True)
286
+ if not update_fields:
287
+ return JSONResponse(status_code=400, content={"success": False, "error": "未提供任何可更新字段"})
288
+
289
+ try:
290
+ if "permissions" in update_fields and update_fields["permissions"] is not None:
291
+ plugin.permissions = json.dumps(update_fields.pop("permissions"), ensure_ascii=False)
292
+
293
+ for key, value in update_fields.items():
294
+ if value is None:
295
+ continue
296
+ setattr(plugin, key, value)
297
+
298
+ plugin.updated_at = datetime.datetime.utcnow()
299
+ db.commit()
300
+ db.refresh(plugin)
301
+ except Exception as e:
302
+ db.rollback()
303
+ logger.error(f"更新插件失败: error_type={type(e).__name__}")
304
+ return JSONResponse(status_code=500, content={"success": False, "error": "更新插件失败"})
305
+
306
+ logger.info(
307
+ f"ADMIN_PLUGIN_UPDATE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
308
+ f"fields={','.join(sorted(update_fields.keys()))}"
309
+ )
310
+ _notify_registry_reload()
311
+
312
+ return JSONResponse(content={
313
+ "success": True,
314
+ "message": "插件更新成功",
315
+ "plugin": _serialize_plugin(plugin),
316
+ })
317
+
318
+
319
+ @router.delete("/api/admin/plugins/{plugin_id}")
320
+ @shared_limit(10)
321
+ @open_sql_endpoint
322
+ def delete_plugin(
323
+ request: Request,
324
+ plugin_id: str,
325
+ account: str = Depends(require_auth),
326
+ db: Session = Depends(get_db),
327
+ ):
328
+ """删除插件"""
329
+ _require_admin(account)
330
+
331
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
332
+ if not plugin:
333
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
334
+
335
+ try:
336
+ db.delete(plugin)
337
+ db.commit()
338
+ except Exception as e:
339
+ db.rollback()
340
+ logger.error(f"删除插件失败: error_type={type(e).__name__}")
341
+ return JSONResponse(status_code=500, content={"success": False, "error": "删除插件失败"})
342
+
343
+ logger.info(f"ADMIN_PLUGIN_DELETE | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
344
+ _notify_registry_reload()
345
+
346
+ return JSONResponse(content={"success": True, "message": "插件已删除", "plugin_id": plugin_id})
347
+
348
+
349
+ @router.post("/api/admin/plugins/{plugin_id}/rotate-key")
350
+ @shared_limit(5)
351
+ @open_sql_endpoint
352
+ def rotate_plugin_key(
353
+ request: Request,
354
+ plugin_id: str,
355
+ account: str = Depends(require_auth),
356
+ db: Session = Depends(get_db),
357
+ ):
358
+ """轮换 plugin_key,返回新 key(仅本次返回明文)"""
359
+ _require_admin(account)
360
+
361
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
362
+ if not plugin:
363
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
364
+
365
+ new_key = generate_plugin_key()
366
+ now = datetime.datetime.utcnow()
367
+
368
+ try:
369
+ plugin.plugin_key = new_key
370
+ plugin.last_rotated_at = now
371
+ plugin.updated_at = now
372
+ db.commit()
373
+ db.refresh(plugin)
374
+ except Exception as e:
375
+ db.rollback()
376
+ logger.error(f"轮换 plugin_key 失败: error_type={type(e).__name__}")
377
+ return JSONResponse(status_code=500, content={"success": False, "error": "轮换 plugin_key 失败"})
378
+
379
+ logger.info(f"ADMIN_PLUGIN_ROTATE_KEY | actor={_audit_actor_id(account)} | plugin_id={plugin_id}")
380
+ _notify_registry_reload()
381
+
382
+ return JSONResponse(content={
383
+ "success": True,
384
+ "plugin_id": plugin.plugin_id,
385
+ "plugin_key": new_key,
386
+ "message": "plugin_key 已轮换,请妥善保存(仅显示一次)",
387
+ "last_rotated_at": now.isoformat(),
388
+ })
389
+
390
+
391
+ @router.post("/api/admin/plugins/{plugin_id}/toggle")
392
+ @shared_limit(20)
393
+ @open_sql_endpoint
394
+ def toggle_plugin(
395
+ request: Request,
396
+ plugin_id: str,
397
+ account: str = Depends(require_auth),
398
+ db: Session = Depends(get_db),
399
+ ):
400
+ """启用/禁用切换"""
401
+ _require_admin(account)
402
+
403
+ plugin = db.query(RegisteredPlugin).filter(RegisteredPlugin.plugin_id == plugin_id).first()
404
+ if not plugin:
405
+ return JSONResponse(status_code=404, content={"success": False, "error": "插件不存在"})
406
+
407
+ try:
408
+ plugin.enabled = not bool(plugin.enabled)
409
+ plugin.updated_at = datetime.datetime.utcnow()
410
+ db.commit()
411
+ db.refresh(plugin)
412
+ except Exception as e:
413
+ db.rollback()
414
+ logger.error(f"切换插件状态失败: error_type={type(e).__name__}")
415
+ return JSONResponse(status_code=500, content={"success": False, "error": "切换插件状态失败"})
416
+
417
+ logger.info(
418
+ f"ADMIN_PLUGIN_TOGGLE | actor={_audit_actor_id(account)} | plugin_id={plugin_id} | "
419
+ f"enabled={bool(plugin.enabled)}"
420
+ )
421
+ _notify_registry_reload()
422
+
423
+ return JSONResponse(content={
424
+ "success": True,
425
+ "plugin_id": plugin.plugin_id,
426
+ "enabled": bool(plugin.enabled),
427
+ "message": "插件已启用" if plugin.enabled else "插件已禁用",
428
+ })
router_open_api.py CHANGED
@@ -1,688 +1,702 @@
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 (SessionLocal)
10
- # - models_sql.py (Wallet, Transaction)
11
- # - 数据库连接.py (users.json 读写)
12
- # 安全机制:
13
- # - X-Plugin-Key Header 鉴权
14
- # - 基于 plugin_id 的 SQL 持久化限流(每分钟窗口,多 worker 共享)
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
- from typing import Optional
26
-
27
- from fastapi import APIRouter, Header, HTTPException, Query
28
- from fastapi.responses import JSONResponse
29
- from sqlalchemy.orm import Session
30
- from sqlalchemy import func, text
31
- from pydantic import BaseModel, Field
32
-
33
- from database_sql import SessionLocal
34
- from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
35
- from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
36
- from open_transaction import open_operation_identity, read_open_operation, save_open_operation
37
- from plugin_permissions import parse_permissions
38
- from tip_store import run_tip_effect
39
- from auth_io import run_identity_io
40
- from owned_thread import OwnedThreadGroup
41
- from 安全认证 import verify_token_with_fallback
42
- import 数据库连接 as json_db
43
-
44
- logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
45
-
46
- router = APIRouter(prefix="/api/open", tags=["open-api"])
47
-
48
-
49
- MAX_OPEN_SQL_WORK = 8
50
- _open_sql_work = OwnedThreadGroup(MAX_OPEN_SQL_WORK,
51
- lambda: HTTPException(status_code=503, detail="插件限流服务暂不可用"))
52
- start_open_sql = _open_sql_work.start
53
- stop_open_sql = _open_sql_work.stop
54
- close_open_sql = _open_sql_work.close
55
-
56
-
57
- async def _run_open_sql(function, *args, **kwargs):
58
- def execute():
59
- with SessionLocal() as session:
60
- return function(*args, db=session, **kwargs)
61
- return await _open_sql_work.run(execute)
62
-
63
-
64
- # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
65
- # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
66
- _PLUGIN_KEY_INDEX: dict = {}
67
- _INSECURE_LEGACY_PLUGIN_KEYS = frozenset({"nca_2026_nodecraft_ai_plugin"})
68
-
69
-
70
- def load_plugins_from_db():
71
- """启动时从数据库加载所有启用的插件到内存索引"""
72
- global _PLUGIN_KEY_INDEX
73
-
74
- db = None
75
- try:
76
- db = SessionLocal()
77
- # 首次启动时仅在显式配置高熵 Key 时 seed 默认插件,不再使用源码内固定凭证。
78
- if not db.query(RegisteredPlugin).first():
79
- default_key = os.environ.get("NCA_PLUGIN_KEY", "").strip()
80
- if len(default_key) >= 32 and default_key not in _INSECURE_LEGACY_PLUGIN_KEYS:
81
- default_plugin = RegisteredPlugin(
82
- plugin_id="nodecraft_ai",
83
- plugin_key=default_key,
84
- name="NodeCraft AI",
85
- enabled=True,
86
- rate_limit=100,
87
- max_deduct_per_tx=100,
88
- max_deduct_per_day=1000,
89
- permissions='["verify","balance","deduct","refund"]',
90
- created_by="system",
91
- )
92
- db.add(default_plugin)
93
- db.commit()
94
- logger.info("✅ 已 seed 默认插件: NodeCraft AI")
95
- else:
96
- logger.warning("NCA_PLUGIN_KEY 缺失或不符合安全要求,跳过默认插件 seed")
97
-
98
- plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all()
99
-
100
- # 构建内存索引
101
- new_index = {}
102
- for p in plugins:
103
- # 固定旧 Key 已随源代码泄露,不能继续接受;管理员轮换成新 Key 后即可恢复。
104
- if p.plugin_key in _INSECURE_LEGACY_PLUGIN_KEYS:
105
- logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
106
- continue
107
- try:
108
- perms = parse_permissions(p.permissions)
109
- except ValueError:
110
- logger.warning("OPEN_API_PLUGIN_PERMISSIONS_INVALID")
111
- continue
112
- new_index[p.plugin_key] = {
113
- "id": p.plugin_id,
114
- "key": p.plugin_key,
115
- "name": p.name,
116
- "rate_limit": p.rate_limit or 100,
117
- "permissions": perms,
118
- "max_deduct_per_tx": p.max_deduct_per_tx or 100,
119
- "max_deduct_per_day": p.max_deduct_per_day or 1000,
120
- }
121
-
122
- _PLUGIN_KEY_INDEX = new_index
123
- logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表")
124
- except Exception as e:
125
- # 数据源不可信/不可用时 fail closed:清空内存索引,撤销旧 Key 的继续访问能力。
126
- _PLUGIN_KEY_INDEX = {}
127
- logger.error(f"❌ 加载插件注册表失败: error_type={type(e).__name__}; 插件 API 已 fail closed")
128
- finally:
129
- if db is not None:
130
- try:
131
- db.close()
132
- except Exception as e:
133
- _PLUGIN_KEY_INDEX = {}
134
- logger.error(
135
- f"关闭插件注册表会话失败: error_type={type(e).__name__}; 插件 API 已 fail closed"
136
- )
137
-
138
-
139
- def reload_plugin_registry():
140
- """热更新插件注册表(管理操作后调用)"""
141
- load_plugins_from_db()
142
-
143
-
144
- # ==========================================
145
- def verify_plugin_key(plugin_key: str) -> dict:
146
- """
147
- 校验 Plugin Key 并返回插件配置。
148
-
149
- 参数:
150
- plugin_key: 请求头 X-Plugin-Key 的值
151
- 返回:
152
- 匹配到的插件配置字典(包含 id 字段)
153
- 异常:
154
- 401: 未提供 Plugin Key
155
- 403: Plugin Key 无效或未注册
156
- """
157
- if not plugin_key:
158
- raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header")
159
- info = _PLUGIN_KEY_INDEX.get(plugin_key)
160
- if not info:
161
- logger.warning("OPEN_API_INVALID_KEY")
162
- raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册")
163
- return info
164
-
165
-
166
- # ==========================================
167
- # 🚦 限流:SQL 分钟计数器,多 worker 共享
168
- # ==========================================
169
- def _check_rate_limit(plugin_info: dict, permission: str, db: Session) -> None:
170
- """
171
- 校验插件权限并执行限流。
172
-
173
- - 权限不足 -> 403
174
- - 触发限流 -> 429
175
- """
176
- plugin_id = plugin_info["id"]
177
-
178
- now = int(time.time())
179
- window_start = now - (now % 60)
180
- try:
181
- # 对配置列执行 no-op UPDATE:PostgreSQL 获取行锁,SQLite 也会取得写事务锁;
182
- # 再 SELECT FOR UPDATE,保证首次建桶和窗口递增串行,而不是只依赖进程锁。
183
- db.execute(
184
- text("UPDATE registered_plugins SET rate_limit = rate_limit WHERE plugin_id = :plugin_id"),
185
- {"plugin_id": plugin_id},
186
- )
187
- plugin_row = (db.query(RegisteredPlugin)
188
- .filter(RegisteredPlugin.plugin_id == plugin_id)
189
- .populate_existing()
190
- .with_for_update()
191
- .first())
192
- validate_live_plugin(plugin_row, plugin_info.get("key"), permission)
193
- rate_limit = positive_integer(plugin_row.rate_limit)
194
- bucket = (db.query(OpenApiRateLimitBucket)
195
- .filter(OpenApiRateLimitBucket.plugin_id == plugin_id)
196
- .with_for_update()
197
- .first())
198
- if not bucket:
199
- bucket = OpenApiRateLimitBucket(
200
- plugin_id=plugin_id, window_start=window_start, request_count=0
201
- )
202
- db.add(bucket)
203
- db.flush()
204
- if bucket.window_start != window_start:
205
- bucket.window_start = window_start
206
- bucket.request_count = 0
207
- if bucket.request_count >= rate_limit:
208
- db.rollback()
209
- raise HTTPException(
210
- status_code=429,
211
- detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试",
212
- )
213
- bucket.request_count += 1
214
- db.commit()
215
- except HTTPException:
216
- db.rollback()
217
- raise
218
- except Exception as exc:
219
- db.rollback()
220
- logger.error(f"OPEN_API_RATE_LIMIT_DB_FAIL | plugin={plugin_id} | error_type={type(exc).__name__}")
221
- raise HTTPException(status_code=503, detail="插件限流服务暂不可用")
222
-
223
-
224
- def _require_plugin(plugin_key: Optional[str], permission: str, db: Session) -> dict:
225
- """统一入口:校验 Plugin Key + 权限 + 限流"""
226
- info = verify_plugin_key(plugin_key or "")
227
- _check_rate_limit(info, permission, db)
228
- return info
229
-
230
-
231
- async def _resolve_user_from_token(token: str) -> str:
232
- """从 Token 中解析出账号;失败时抛 401"""
233
- if not token:
234
- raise HTTPException(status_code=401, detail="缺少用户 Token")
235
- is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, token)
236
- if not is_valid or not account:
237
- raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
238
- return account
239
-
240
-
241
- def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str:
242
- """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)"""
243
- data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
244
- return hashlib.sha256(data.encode()).hexdigest()
245
-
246
-
247
- # ==========================================
248
- # 📦 请求模型
249
- # ==========================================
250
- class VerifyTokenRequest(BaseModel):
251
- token: str = Field(..., description="待验证的用户 JWT Token")
252
-
253
-
254
- class DeductRequest(BaseModel):
255
- token: str = Field(..., description="用户 JWT Token")
256
- amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
257
- reason: str = Field(..., description="业务标识/扣款原因")
258
- reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
259
- request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
260
- pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次扣款重试使用相同请求标识")
261
-
262
-
263
- class RefundRequest(BaseModel):
264
- token: str = Field(..., description="用户 JWT Token")
265
- transaction_id: str = Field(..., description="原扣款交易 ID")
266
- amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
267
- request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
268
- pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次退款重试使用相同请求标识")
269
-
270
-
271
- # ==========================================
272
- # 1️⃣ Token 验证:POST /api/open/verify-token
273
- # ==========================================
274
- @router.post("/verify-token")
275
- async def open_verify_token(
276
- req: VerifyTokenRequest,
277
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
278
- ):
279
- """
280
- 供第三方插件验证用户 Token 有效性。
281
- 成功返回账号与基础资料(昵称、头像)。
282
- """
283
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
284
-
285
- is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, req.token)
286
- if not is_valid or not account:
287
- logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
288
- return {"valid": False, "error": error_msg or "Token 无效"}
289
-
290
- users_db = await run_identity_io(json_db.load_data, "users.json", default_data={})
291
- user = users_db.get(account, {}) or {}
292
-
293
- logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
294
- return {
295
- "valid": True,
296
- "account": account,
297
- "name": user.get("name", account),
298
- "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""),
299
- }
300
-
301
-
302
- # ==========================================
303
- # 2️⃣ 余额查询:GET /api/open/balance
304
- # ==========================================
305
- @router.get("/balance")
306
- async def open_get_balance(
307
- authorization: Optional[str] = Header(None, alias="Authorization"),
308
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
309
- ):
310
- """
311
- 查询用户钱包余额。
312
- 认证:Authorization: Bearer <user_token> + X-Plugin-Key
313
- """
314
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "balance")
315
-
316
- if not authorization:
317
- raise HTTPException(status_code=401, detail="缺少 Authorization Header")
318
- parts = authorization.split(" ", 1)
319
- if len(parts) != 2 or parts[0].lower() != "bearer":
320
- raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
321
-
322
- account = await _resolve_user_from_token(parts[1])
323
- return await _run_open_sql(_read_open_balance, account, plugin_info)
324
-
325
-
326
- # ==========================================
327
- # 3️⃣ 通用扣款:POST /api/open/deduct
328
- # ==========================================
329
- @router.post("/deduct")
330
- async def open_deduct(
331
- req: DeductRequest,
332
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
333
- ):
334
- """
335
- 通用扣款接口(从 wallet.balance 扣减)。
336
- 流程:
337
- 1. 校验 Plugin Key + 权限 + 限流
338
- 2. 解析 Token -> account
339
- 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
340
- 4. 扣款 + 写入 Transaction(含哈希链)
341
- """
342
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "deduct")
343
- account = await _resolve_user_from_token(req.token)
344
- return await _run_open_sql(_deduct_open_balance, req, account, plugin_info, x_plugin_key)
345
-
346
-
347
- # ==========================================
348
- # 4️⃣ 退款:POST /api/open/refund
349
- # ==========================================
350
- @router.post("/refund")
351
- async def open_refund(
352
- req: RefundRequest,
353
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
354
- ):
355
- """
356
- 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
357
-
358
- 校验:
359
- - 原交易必须存在且 tx_type == OPEN_DEDUCT
360
- - 原交易归属当前 token 用户
361
- - 原交易由当前插件发起(related_account == plugin_id)
362
- - 累计退款金额 ≤ 原扣款金额
363
- """
364
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "refund")
365
- account = await _resolve_user_from_token(req.token)
366
- return await _run_open_sql(_refund_open_balance, req, account, plugin_info, x_plugin_key)
367
-
368
-
369
- # ==========================================
370
- # 5️⃣ 消费记录查询:GET /api/open/transactions
371
- # ==========================================
372
- @router.get("/transactions")
373
- async def open_list_transactions(
374
- authorization: Optional[str] = Header(None, alias="Authorization"),
375
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
376
- page: int = Query(1, ge=1, description="页码(从 1 开始)"),
377
- page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
378
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
379
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
380
- tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
381
- ):
382
- """
383
- 查询当前用户由当前插件产生的消费/退款记录。
384
-
385
- 认证:Authorization: Bearer <user_token> + X-Plugin-Key
386
- 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
387
- """
388
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
389
-
390
- if not authorization:
391
- raise HTTPException(status_code=401, detail="缺少 Authorization Header")
392
- parts = authorization.split(" ", 1)
393
- if len(parts) != 2 or parts[0].lower() != "bearer":
394
- raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
395
- account = await _resolve_user_from_token(parts[1])
396
-
397
- return await _run_open_sql(_read_open_transactions, account, plugin_info, page, page_size, start_time, end_time, tx_type)
398
-
399
-
400
- # ==========================================
401
- # 6️⃣ 消费汇总:GET /api/open/usage-summary
402
- # ==========================================
403
- @router.get("/usage-summary")
404
- async def open_usage_summary(
405
- x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
406
- account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
407
- period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
408
- start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
409
- end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
410
- ):
411
- """
412
- 本插件维度的消费汇总统计。
413
-
414
- 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
415
- 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
416
- """
417
- plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
418
- return await _run_open_sql(_read_open_usage, plugin_info, account, period, start_time, end_time)
419
-
420
-
421
- def _read_open_balance(account, plugin_info, db):
422
- wallet = db.query(Wallet).filter(Wallet.account == account).first()
423
-
424
- if not wallet:
425
- result = {
426
- "account": account,
427
- "balance": 0,
428
- "earn_balance": 0,
429
- "tip_balance": 0,
430
- "task_balance": 0,
431
- "frozen_balance": 0,
432
- }
433
- else:
434
- result = {
435
- "account": account,
436
- "balance": int(wallet.balance or 0),
437
- "earn_balance": int(wallet.earn_balance or 0),
438
- "tip_balance": int(wallet.tip_balance or 0),
439
- "task_balance": int(wallet.task_balance or 0),
440
- "frozen_balance": int(wallet.frozen_balance or 0),
441
- }
442
-
443
- logger.info(f"OPEN_BALANCE | plugin={plugin_info['id']}")
444
- return result
445
-
446
-
447
- def _deduct_open_balance(req, account, plugin_info, x_plugin_key, db):
448
- amount = req.amount
449
- if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
450
- raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
451
- operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_DEDUCT',
452
- [amount, req.reason, req.reference_id])
453
- try:
454
- wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
455
- replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_DEDUCT', -amount, req.reference_id)
456
- if replay is not None:
457
- return replay
458
- max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
459
- max_daily = positive_integer(plugin_row.max_deduct_per_day)
460
- if amount > max_per_tx:
461
- db.rollback()
462
- return JSONResponse(status_code=403, content={
463
- "error": "AMOUNT_EXCEEDS_LIMIT", "message": f"单笔扣款不能超过{max_per_tx}积分", "max": max_per_tx})
464
- now = datetime.datetime.utcnow()
465
- day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
466
- used_today = db.query(func.coalesce(func.sum(func.abs(Transaction.amount)), 0)).filter(
467
- Transaction.tx_type == "OPEN_DEDUCT", Transaction.source_plugin == plugin_info["id"],
468
- Transaction.created_at >= day_start,
469
- Transaction.created_at < day_start + datetime.timedelta(days=1)).scalar()
470
- used_today = quota_total(used_today)
471
- if used_today + amount > max_daily:
472
- db.rollback()
473
- return JSONResponse(status_code=403, content={
474
- "error": "DAILY_LIMIT_EXCEEDED", "message": "今日扣款已达上限", "limit": max_daily, "used": used_today})
475
- if wallet is None:
476
- raise HTTPException(status_code=402, detail="余额不足")
477
- if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
478
- raise ValueError("invalid wallet balance")
479
- if wallet.balance < amount:
480
- raise HTTPException(status_code=402, detail="余额不足")
481
- wallet.balance -= amount
482
- tx_id = f"OPEN_DEDUCT_{operation[0] if operation is not None else uuid.uuid4().hex}"
483
- last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
484
- Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
485
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
486
- db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_DEDUCT", amount=-amount,
487
- related_account=plugin_info["id"], item_id=req.reference_id, prev_hash=prev_hash,
488
- tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
489
- description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
490
- response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
491
- save_open_operation(db, operation, response)
492
- db.commit()
493
- except HTTPException:
494
- db.rollback()
495
- raise
496
- except Exception as error:
497
- db.rollback()
498
- logger.error("OPEN_DEDUCT_FAIL | error_type=%s", type(error).__name__)
499
- raise HTTPException(status_code=500, detail="扣款失败,请稍后重试")
500
- run_tip_effect(lambda: logger.info("OPEN_DEDUCT_COMPLETED"))
501
- return response
502
-
503
-
504
- def _refund_open_balance(req, account, plugin_info, x_plugin_key, db):
505
- refund_amount = req.amount
506
- if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
507
- raise HTTPException(status_code=422, detail="退款金额必须为正整数")
508
- operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_REFUND',
509
- [refund_amount, req.transaction_id])
510
- try:
511
- wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
512
- replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_REFUND', refund_amount, req.transaction_id)
513
- if replay is not None:
514
- return replay
515
- original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
516
- if original_tx is None:
517
- raise HTTPException(status_code=404, detail="原交易不存在")
518
- if original_tx.account != account:
519
- raise HTTPException(status_code=403, detail="无权对该交易执行退款")
520
- if original_tx.tx_type != "OPEN_DEDUCT":
521
- raise HTTPException(status_code=400, detail="该交易类型不支持开放退款")
522
- if original_tx.related_account != plugin_info["id"] or original_tx.source_plugin not in (None, plugin_info["id"]):
523
- raise HTTPException(status_code=403, detail="不允许跨插件退款")
524
- if isinstance(original_tx.amount, bool) or not isinstance(original_tx.amount, int) or original_tx.amount >= 0:
525
- raise HTTPException(status_code=503, detail="原交易金额暂时无法核实")
526
- original_amount = -original_tx.amount
527
- # 校验全部关联退款,不能用 abs() 把损坏的负退款/跨账号记录视为合法。
528
- refunded = db.query(Transaction).filter(
529
- Transaction.tx_type == "OPEN_REFUND", Transaction.item_id == req.transaction_id).all()
530
- if any(row.account != account or row.related_account != plugin_info["id"] or
531
- row.source_plugin not in (None, plugin_info["id"]) or isinstance(row.amount, bool) or
532
- not isinstance(row.amount, int) or row.amount <= 0 for row in refunded):
533
- raise HTTPException(status_code=503, detail="退款流水暂时无法核实")
534
- already_refunded = sum(row.amount for row in refunded)
535
- if already_refunded + refund_amount > original_amount:
536
- raise HTTPException(status_code=400, detail="退款金额超过可退余额")
537
- if wallet is None:
538
- raise HTTPException(status_code=503, detail="钱包记录暂时无法核实")
539
- if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
540
- raise ValueError("invalid wallet balance")
541
- wallet.balance += refund_amount
542
- tx_id = f"OPEN_REFUND_{operation[0] if operation is not None else uuid.uuid4().hex}"
543
- last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
544
- Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
545
- prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
546
- db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_REFUND", amount=refund_amount,
547
- related_account=plugin_info["id"], item_id=req.transaction_id, prev_hash=prev_hash,
548
- tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
549
- description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
550
- response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
551
- save_open_operation(db, operation, response)
552
- db.commit()
553
- except HTTPException:
554
- db.rollback()
555
- raise
556
- except Exception as error:
557
- db.rollback()
558
- logger.error("OPEN_REFUND_FAIL | error_type=%s", type(error).__name__)
559
- raise HTTPException(status_code=500, detail="退款失败,请稍后重试")
560
- run_tip_effect(lambda: logger.info("OPEN_REFUND_COMPLETED"))
561
- return response
562
-
563
-
564
- def _read_open_transactions(account, plugin_info, page, page_size, start_time, end_time, tx_type, db):
565
- plugin_id = plugin_info["id"]
566
-
567
- # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
568
- q = db.query(Transaction).filter(
569
- Transaction.account == account,
570
- ((Transaction.source_plugin == plugin_id)
571
- | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
572
- )
573
-
574
- if tx_type:
575
- q = q.filter(Transaction.tx_type == tx_type)
576
- if start_time is not None:
577
- q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
578
- if end_time is not None:
579
- q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
580
-
581
- total = q.count()
582
- rows = (
583
- q.order_by(Transaction.created_at.desc())
584
- .offset((page - 1) * page_size)
585
- .limit(page_size)
586
- .all()
587
- )
588
-
589
- items = []
590
- for t in rows:
591
- created_ts = int(t.created_at.timestamp()) if t.created_at else 0
592
- # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录
593
- reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None
594
- items.append({
595
- "id": t.tx_id,
596
- "type": t.tx_type,
597
- "amount": int(t.amount or 0),
598
- "reason": reason,
599
- "reference_id": t.item_id,
600
- "created_at": created_ts,
601
- "description": t.description or "",
602
- })
603
-
604
- logger.info(f"OPEN_LIST_TX | plugin={plugin_id} | page={page}/{page_size} | total={total}")
605
- return {
606
- "success": True,
607
- "transactions": items,
608
- "total": total,
609
- "page": page,
610
- "page_size": page_size,
611
- }
612
-
613
-
614
- def _read_open_usage(plugin_info, account, period, start_time, end_time, db):
615
- plugin_id = plugin_info["id"]
616
-
617
- period = (period or "daily").lower()
618
- if period not in ("daily", "monthly", "total"):
619
- raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total")
620
-
621
- base = db.query(Transaction).filter(
622
- ((Transaction.source_plugin == plugin_id)
623
- | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
624
- Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]),
625
- )
626
- if account:
627
- base = base.filter(Transaction.account == account)
628
- if start_time is not None:
629
- base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
630
- if end_time is not None:
631
- base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
632
-
633
- rows = base.all()
634
-
635
- total_deduct = 0
636
- total_refund = 0
637
- total_count = len(rows)
638
- bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int}
639
-
640
- for t in rows:
641
- amt = int(t.amount or 0)
642
- if t.tx_type == "OPEN_DEDUCT":
643
- total_deduct += abs(amt)
644
- elif t.tx_type == "OPEN_REFUND":
645
- total_refund += abs(amt)
646
-
647
- if period == "total":
648
- continue
649
- if not t.created_at:
650
- continue
651
- if period == "daily":
652
- key = t.created_at.strftime("%Y-%m-%d")
653
- else: # monthly
654
- key = t.created_at.strftime("%Y-%m")
655
-
656
- b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0})
657
- if t.tx_type == "OPEN_DEDUCT":
658
- b["deduct"] += abs(amt)
659
- elif t.tx_type == "OPEN_REFUND":
660
- b["refund"] += abs(amt)
661
- b["count"] += 1
662
-
663
- breakdown = []
664
- if period != "total":
665
- for key in sorted(bucket.keys(), reverse=True):
666
- v = bucket[key]
667
- label = "date" if period == "daily" else "month"
668
- breakdown.append({
669
- label: key,
670
- "deduct": v["deduct"],
671
- "refund": v["refund"],
672
- "count": v["count"],
673
- })
674
-
675
- logger.info(f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | period={period} | tx_count={total_count}")
676
- return {
677
- "success": True,
678
- "plugin_id": plugin_id,
679
- "plugin_name": plugin_info.get("name", plugin_id),
680
- "summary": {
681
- "total_deduct": total_deduct,
682
- "total_refund": total_refund,
683
- "total_transactions": total_count,
684
- "net_consumption": total_deduct - total_refund,
685
- },
686
- "breakdown": breakdown,
687
- }
688
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (SessionLocal)
10
+ # - models_sql.py (Wallet, Transaction)
11
+ # - 数据库连接.py (users.json 读写)
12
+ # 安全机制:
13
+ # - X-Plugin-Key Header 鉴权
14
+ # - 基于 plugin_id 的 SQL 持久化限流(每分钟窗口,多 worker 共享)
15
+ # - 所有扣款/退款操作记录到 Transaction 流水(含哈希链)
16
+ # ==========================================
17
+
18
+ import os
19
+ import threading
20
+ import json
21
+ import time
22
+ import uuid
23
+ import datetime
24
+ import hashlib
25
+ import logging
26
+ from contextlib import contextmanager
27
+ from typing import Optional
28
+
29
+ from fastapi import APIRouter, Header, HTTPException, Query
30
+ from fastapi.responses import JSONResponse
31
+ from sqlalchemy.orm import Session
32
+ from sqlalchemy import func, text
33
+ from pydantic import BaseModel, Field
34
+
35
+ from database_sql import SessionLocal
36
+ from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
37
+ from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
38
+ from open_transaction import open_operation_identity, read_open_operation, save_open_operation
39
+ from plugin_permissions import parse_permissions
40
+ from tip_store import run_tip_effect
41
+ from auth_io import run_identity_io
42
+ from owned_thread import OwnedThreadGroup, owned_session_endpoint
43
+ from 安全认证 import verify_token_with_fallback
44
+ import 数据库连接 as json_db
45
+
46
+ logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
47
+
48
+ router = APIRouter(prefix="/api/open", tags=["open-api"])
49
+
50
+
51
+ MAX_OPEN_SQL_WORK = 8
52
+ _open_sql_work = OwnedThreadGroup(MAX_OPEN_SQL_WORK,
53
+ lambda: HTTPException(status_code=503, detail="插件限流服务暂不可用"))
54
+ start_open_sql = _open_sql_work.start
55
+ stop_open_sql = _open_sql_work.stop
56
+ close_open_sql = _open_sql_work.close
57
+
58
+
59
+ async def _run_open_sql(function, *args, **kwargs):
60
+ def execute():
61
+ with SessionLocal() as session:
62
+ return function(*args, db=session, **kwargs)
63
+ return await _open_sql_work.run(execute)
64
+
65
+
66
+ # ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
67
+ # 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
68
+ _PLUGIN_KEY_INDEX: dict = {}
69
+ _plugin_registry_lock = threading.RLock()
70
+ _INSECURE_LEGACY_PLUGIN_KEYS = frozenset({"nca_2026_nodecraft_ai_plugin"})
71
+
72
+
73
+ @contextmanager
74
+ def _plugin_admin_session():
75
+ with _plugin_registry_lock:
76
+ with SessionLocal() as session:
77
+ yield session
78
+
79
+
80
+ open_sql_endpoint = owned_session_endpoint(_open_sql_work, _plugin_admin_session, 'db')
81
+
82
+
83
+ def load_plugins_from_db():
84
+ """启动时从数据库加载所有启用的插件到内存索引"""
85
+ with _plugin_registry_lock:
86
+ global _PLUGIN_KEY_INDEX
87
+
88
+ db = None
89
+ try:
90
+ db = SessionLocal()
91
+ # 首次启动时仅在显式配置高熵 Key 时 seed 默认插件,不再使用源码内固定凭证。
92
+ if not db.query(RegisteredPlugin).first():
93
+ default_key = os.environ.get("NCA_PLUGIN_KEY", "").strip()
94
+ if len(default_key) >= 32 and default_key not in _INSECURE_LEGACY_PLUGIN_KEYS:
95
+ default_plugin = RegisteredPlugin(
96
+ plugin_id="nodecraft_ai",
97
+ plugin_key=default_key,
98
+ name="NodeCraft AI",
99
+ enabled=True,
100
+ rate_limit=100,
101
+ max_deduct_per_tx=100,
102
+ max_deduct_per_day=1000,
103
+ permissions='["verify","balance","deduct","refund"]',
104
+ created_by="system",
105
+ )
106
+ db.add(default_plugin)
107
+ db.commit()
108
+ logger.info("✅ 已 seed 默认插件: NodeCraft AI")
109
+ else:
110
+ logger.warning("NCA_PLUGIN_KEY 缺失或不符合安全要求,跳过默认插件 seed")
111
+
112
+ plugins = db.query(RegisteredPlugin).filter(RegisteredPlugin.enabled == True).all()
113
+
114
+ # 构建内存索引
115
+ new_index = {}
116
+ for p in plugins:
117
+ # 固定旧 Key 已随源代码泄露,不能继续接受;管理员轮换成新 Key 后即可恢复。
118
+ if p.plugin_key in _INSECURE_LEGACY_PLUGIN_KEYS:
119
+ logger.critical(f"拒绝加载已泄露的默认 Plugin Key: plugin_id={p.plugin_id}")
120
+ continue
121
+ try:
122
+ perms = parse_permissions(p.permissions)
123
+ except ValueError:
124
+ logger.warning("OPEN_API_PLUGIN_PERMISSIONS_INVALID")
125
+ continue
126
+ new_index[p.plugin_key] = {
127
+ "id": p.plugin_id,
128
+ "key": p.plugin_key,
129
+ "name": p.name,
130
+ "rate_limit": p.rate_limit or 100,
131
+ "permissions": perms,
132
+ "max_deduct_per_tx": p.max_deduct_per_tx or 100,
133
+ "max_deduct_per_day": p.max_deduct_per_day or 1000,
134
+ }
135
+
136
+ _PLUGIN_KEY_INDEX = new_index
137
+ logger.info(f"✅ 已加载 {len(new_index)} 个插件到注册表")
138
+ except Exception as e:
139
+ # 数据源不可信/不可用时 fail closed:清空内存索引,撤销旧 Key 的继续访问能力。
140
+ _PLUGIN_KEY_INDEX = {}
141
+ logger.error(f"❌ 加载插件注册表失败: error_type={type(e).__name__}; 插件 API 已 fail closed")
142
+ finally:
143
+ if db is not None:
144
+ try:
145
+ db.close()
146
+ except Exception as e:
147
+ _PLUGIN_KEY_INDEX = {}
148
+ logger.error(
149
+ f"关闭插件注册表会话失败: error_type={type(e).__name__}; 插件 API 已 fail closed"
150
+ )
151
+
152
+
153
+ def reload_plugin_registry():
154
+ """热更新插件注册表(管理操作后调用)"""
155
+ load_plugins_from_db()
156
+
157
+
158
+ # ==========================================
159
+ def verify_plugin_key(plugin_key: str) -> dict:
160
+ """
161
+ 校验 Plugin Key 并返回插件配置。
162
+
163
+ 参数:
164
+ plugin_key: 请求头 X-Plugin-Key 的值
165
+ 返回:
166
+ 匹配到的插件配置字典(包含 id 字段)
167
+ 异常:
168
+ 401: 未提供 Plugin Key
169
+ 403: Plugin Key 无效或未注册
170
+ """
171
+ if not plugin_key:
172
+ raise HTTPException(status_code=401, detail="缺少 X-Plugin-Key Header")
173
+ info = _PLUGIN_KEY_INDEX.get(plugin_key)
174
+ if not info:
175
+ logger.warning("OPEN_API_INVALID_KEY")
176
+ raise HTTPException(status_code=403, detail="Plugin Key 无效或未注册")
177
+ return info
178
+
179
+
180
+ # ==========================================
181
+ # 🚦 限流:SQL 分钟计数器,多 worker 共享
182
+ # ==========================================
183
+ def _check_rate_limit(plugin_info: dict, permission: str, db: Session) -> None:
184
+ """
185
+ 校验插件权限并执行限流。
186
+
187
+ - 权限不足 -> 403
188
+ - 触发限流 -> 429
189
+ """
190
+ plugin_id = plugin_info["id"]
191
+
192
+ now = int(time.time())
193
+ window_start = now - (now % 60)
194
+ try:
195
+ # 对配置列执行 no-op UPDATE:PostgreSQL 获取行锁,SQLite 也会取得写事务锁;
196
+ # 再 SELECT FOR UPDATE,保证首次建桶和窗口递增串行,而不是只依赖进程锁。
197
+ db.execute(
198
+ text("UPDATE registered_plugins SET rate_limit = rate_limit WHERE plugin_id = :plugin_id"),
199
+ {"plugin_id": plugin_id},
200
+ )
201
+ plugin_row = (db.query(RegisteredPlugin)
202
+ .filter(RegisteredPlugin.plugin_id == plugin_id)
203
+ .populate_existing()
204
+ .with_for_update()
205
+ .first())
206
+ validate_live_plugin(plugin_row, plugin_info.get("key"), permission)
207
+ rate_limit = positive_integer(plugin_row.rate_limit)
208
+ bucket = (db.query(OpenApiRateLimitBucket)
209
+ .filter(OpenApiRateLimitBucket.plugin_id == plugin_id)
210
+ .with_for_update()
211
+ .first())
212
+ if not bucket:
213
+ bucket = OpenApiRateLimitBucket(
214
+ plugin_id=plugin_id, window_start=window_start, request_count=0
215
+ )
216
+ db.add(bucket)
217
+ db.flush()
218
+ if bucket.window_start != window_start:
219
+ bucket.window_start = window_start
220
+ bucket.request_count = 0
221
+ if bucket.request_count >= rate_limit:
222
+ db.rollback()
223
+ raise HTTPException(
224
+ status_code=429,
225
+ detail=f"请求过于频繁,限流 {rate_limit}/分钟,请稍后再试",
226
+ )
227
+ bucket.request_count += 1
228
+ db.commit()
229
+ except HTTPException:
230
+ db.rollback()
231
+ raise
232
+ except Exception as exc:
233
+ db.rollback()
234
+ logger.error(f"OPEN_API_RATE_LIMIT_DB_FAIL | plugin={plugin_id} | error_type={type(exc).__name__}")
235
+ raise HTTPException(status_code=503, detail="插件限流服务暂不可用")
236
+
237
+
238
+ def _require_plugin(plugin_key: Optional[str], permission: str, db: Session) -> dict:
239
+ """统一入口:校验 Plugin Key + 权限 + 限流"""
240
+ info = verify_plugin_key(plugin_key or "")
241
+ _check_rate_limit(info, permission, db)
242
+ return info
243
+
244
+
245
+ async def _resolve_user_from_token(token: str) -> str:
246
+ """从 Token 中解析出账号;失败时抛 401"""
247
+ if not token:
248
+ raise HTTPException(status_code=401, detail="缺少用户 Token")
249
+ is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, token)
250
+ if not is_valid or not account:
251
+ raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
252
+ return account
253
+
254
+
255
+ def _calc_tx_hash(tx_id: str, account: str, tx_type: str, amount: int, prev_hash: str) -> str:
256
+ """计算交易哈希(与 router_wallet.calculate_tx_hash 算法一致)"""
257
+ data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
258
+ return hashlib.sha256(data.encode()).hexdigest()
259
+
260
+
261
+ # ==========================================
262
+ # 📦 请求模型
263
+ # ==========================================
264
+ class VerifyTokenRequest(BaseModel):
265
+ token: str = Field(..., description="待验证的用户 JWT Token")
266
+
267
+
268
+ class DeductRequest(BaseModel):
269
+ token: str = Field(..., description="用户 JWT Token")
270
+ amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
271
+ reason: str = Field(..., description="业务标识/扣款原因")
272
+ reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
273
+ request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
274
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次扣款重试使用相同请求标识")
275
+
276
+
277
+ class RefundRequest(BaseModel):
278
+ token: str = Field(..., description="用户 JWT Token")
279
+ transaction_id: str = Field(..., description="原扣款交易 ID")
280
+ amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
281
+ request_id: Optional[str] = Field(None, strict=True, min_length=8, max_length=128,
282
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]*$", description="同次退款重试使用相同请求标识")
283
+
284
+
285
+ # ==========================================
286
+ # 1️⃣ Token 验证:POST /api/open/verify-token
287
+ # ==========================================
288
+ @router.post("/verify-token")
289
+ async def open_verify_token(
290
+ req: VerifyTokenRequest,
291
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
292
+ ):
293
+ """
294
+ 供第三方插件验证用户 Token 有效性。
295
+ 成功返回账号与基础资料(昵称、头像)。
296
+ """
297
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
298
+
299
+ is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, req.token)
300
+ if not is_valid or not account:
301
+ logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
302
+ return {"valid": False, "error": error_msg or "Token 无效"}
303
+
304
+ users_db = await run_identity_io(json_db.load_data, "users.json", default_data={})
305
+ user = users_db.get(account, {}) or {}
306
+
307
+ logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
308
+ return {
309
+ "valid": True,
310
+ "account": account,
311
+ "name": user.get("name", account),
312
+ "avatar": user.get("avatarDataUrl", "") or user.get("avatar", ""),
313
+ }
314
+
315
+
316
+ # ==========================================
317
+ # 2️⃣ 余额查询:GET /api/open/balance
318
+ # ==========================================
319
+ @router.get("/balance")
320
+ async def open_get_balance(
321
+ authorization: Optional[str] = Header(None, alias="Authorization"),
322
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
323
+ ):
324
+ """
325
+ 查询用户钱包余额。
326
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
327
+ """
328
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "balance")
329
+
330
+ if not authorization:
331
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
332
+ parts = authorization.split(" ", 1)
333
+ if len(parts) != 2 or parts[0].lower() != "bearer":
334
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
335
+
336
+ account = await _resolve_user_from_token(parts[1])
337
+ return await _run_open_sql(_read_open_balance, account, plugin_info)
338
+
339
+
340
+ # ==========================================
341
+ # 3️⃣ 通用扣款:POST /api/open/deduct
342
+ # ==========================================
343
+ @router.post("/deduct")
344
+ async def open_deduct(
345
+ req: DeductRequest,
346
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
347
+ ):
348
+ """
349
+ 通用扣款接口(从 wallet.balance 扣减)。
350
+ 流程:
351
+ 1. 校验 Plugin Key + 权限 + 限流
352
+ 2. 解析 Token -> account
353
+ 3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
354
+ 4. 扣款 + 写入 Transaction(含哈希链)
355
+ """
356
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "deduct")
357
+ account = await _resolve_user_from_token(req.token)
358
+ return await _run_open_sql(_deduct_open_balance, req, account, plugin_info, x_plugin_key)
359
+
360
+
361
+ # ==========================================
362
+ # 4️⃣ 退款:POST /api/open/refund
363
+ # ==========================================
364
+ @router.post("/refund")
365
+ async def open_refund(
366
+ req: RefundRequest,
367
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
368
+ ):
369
+ """
370
+ 退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
371
+
372
+ 校验:
373
+ - 原交易必须存在且 tx_type == OPEN_DEDUCT
374
+ - 原交易归属当前 token 用户
375
+ - 原交易由当前插件发起(related_account == plugin_id)
376
+ - 累计退款金额 ≤ 原扣款金额
377
+ """
378
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "refund")
379
+ account = await _resolve_user_from_token(req.token)
380
+ return await _run_open_sql(_refund_open_balance, req, account, plugin_info, x_plugin_key)
381
+
382
+
383
+ # ==========================================
384
+ # 5️⃣ 消费记录查询:GET /api/open/transactions
385
+ # ==========================================
386
+ @router.get("/transactions")
387
+ async def open_list_transactions(
388
+ authorization: Optional[str] = Header(None, alias="Authorization"),
389
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
390
+ page: int = Query(1, ge=1, description="页码(从 1 开始)"),
391
+ page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
392
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
393
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
394
+ tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
395
+ ):
396
+ """
397
+ 查询当前用户由当前插件产生的消费/退款记录。
398
+
399
+ 认证:Authorization: Bearer <user_token> + X-Plugin-Key
400
+ 安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
401
+ """
402
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
403
+
404
+ if not authorization:
405
+ raise HTTPException(status_code=401, detail="缺少 Authorization Header")
406
+ parts = authorization.split(" ", 1)
407
+ if len(parts) != 2 or parts[0].lower() != "bearer":
408
+ raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
409
+ account = await _resolve_user_from_token(parts[1])
410
+
411
+ return await _run_open_sql(_read_open_transactions, account, plugin_info, page, page_size, start_time, end_time, tx_type)
412
+
413
+
414
+ # ==========================================
415
+ # 6️⃣ 消费汇总:GET /api/open/usage-summary
416
+ # ==========================================
417
+ @router.get("/usage-summary")
418
+ async def open_usage_summary(
419
+ x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
420
+ account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
421
+ period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
422
+ start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
423
+ end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
424
+ ):
425
+ """
426
+ 本插件维度的消费汇总统计。
427
+
428
+ 返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
429
+ 安全:仅统计 source_plugin == 当前 plugin_id 的记录。
430
+ """
431
+ plugin_info = await _run_open_sql(_require_plugin, x_plugin_key, "verify")
432
+ return await _run_open_sql(_read_open_usage, plugin_info, account, period, start_time, end_time)
433
+
434
+
435
+ def _read_open_balance(account, plugin_info, db):
436
+ wallet = db.query(Wallet).filter(Wallet.account == account).first()
437
+
438
+ if not wallet:
439
+ result = {
440
+ "account": account,
441
+ "balance": 0,
442
+ "earn_balance": 0,
443
+ "tip_balance": 0,
444
+ "task_balance": 0,
445
+ "frozen_balance": 0,
446
+ }
447
+ else:
448
+ result = {
449
+ "account": account,
450
+ "balance": int(wallet.balance or 0),
451
+ "earn_balance": int(wallet.earn_balance or 0),
452
+ "tip_balance": int(wallet.tip_balance or 0),
453
+ "task_balance": int(wallet.task_balance or 0),
454
+ "frozen_balance": int(wallet.frozen_balance or 0),
455
+ }
456
+
457
+ logger.info(f"OPEN_BALANCE | plugin={plugin_info['id']}")
458
+ return result
459
+
460
+
461
+ def _deduct_open_balance(req, account, plugin_info, x_plugin_key, db):
462
+ amount = req.amount
463
+ if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
464
+ raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
465
+ operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_DEDUCT',
466
+ [amount, req.reason, req.reference_id])
467
+ try:
468
+ wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
469
+ replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_DEDUCT', -amount, req.reference_id)
470
+ if replay is not None:
471
+ return replay
472
+ max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
473
+ max_daily = positive_integer(plugin_row.max_deduct_per_day)
474
+ if amount > max_per_tx:
475
+ db.rollback()
476
+ return JSONResponse(status_code=403, content={
477
+ "error": "AMOUNT_EXCEEDS_LIMIT", "message": f"单笔扣款不能超过{max_per_tx}积分", "max": max_per_tx})
478
+ now = datetime.datetime.utcnow()
479
+ day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
480
+ used_today = db.query(func.coalesce(func.sum(func.abs(Transaction.amount)), 0)).filter(
481
+ Transaction.tx_type == "OPEN_DEDUCT", Transaction.source_plugin == plugin_info["id"],
482
+ Transaction.created_at >= day_start,
483
+ Transaction.created_at < day_start + datetime.timedelta(days=1)).scalar()
484
+ used_today = quota_total(used_today)
485
+ if used_today + amount > max_daily:
486
+ db.rollback()
487
+ return JSONResponse(status_code=403, content={
488
+ "error": "DAILY_LIMIT_EXCEEDED", "message": "今日扣款已达上限", "limit": max_daily, "used": used_today})
489
+ if wallet is None:
490
+ raise HTTPException(status_code=402, detail="余额不足")
491
+ if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
492
+ raise ValueError("invalid wallet balance")
493
+ if wallet.balance < amount:
494
+ raise HTTPException(status_code=402, detail="余额不足")
495
+ wallet.balance -= amount
496
+ tx_id = f"OPEN_DEDUCT_{operation[0] if operation is not None else uuid.uuid4().hex}"
497
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
498
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
499
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
500
+ db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_DEDUCT", amount=-amount,
501
+ related_account=plugin_info["id"], item_id=req.reference_id, prev_hash=prev_hash,
502
+ tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
503
+ description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
504
+ response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
505
+ save_open_operation(db, operation, response)
506
+ db.commit()
507
+ except HTTPException:
508
+ db.rollback()
509
+ raise
510
+ except Exception as error:
511
+ db.rollback()
512
+ logger.error("OPEN_DEDUCT_FAIL | error_type=%s", type(error).__name__)
513
+ raise HTTPException(status_code=500, detail="扣款失败,请稍后重试")
514
+ run_tip_effect(lambda: logger.info("OPEN_DEDUCT_COMPLETED"))
515
+ return response
516
+
517
+
518
+ def _refund_open_balance(req, account, plugin_info, x_plugin_key, db):
519
+ refund_amount = req.amount
520
+ if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
521
+ raise HTTPException(status_code=422, detail="退款金额必须为正整数")
522
+ operation = open_operation_identity(req.request_id, account, plugin_info['id'], 'OPEN_REFUND',
523
+ [refund_amount, req.transaction_id])
524
+ try:
525
+ wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
526
+ replay = read_open_operation(db, operation, account, plugin_info['id'], 'OPEN_REFUND', refund_amount, req.transaction_id)
527
+ if replay is not None:
528
+ return replay
529
+ original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
530
+ if original_tx is None:
531
+ raise HTTPException(status_code=404, detail="原交易不存在")
532
+ if original_tx.account != account:
533
+ raise HTTPException(status_code=403, detail="无权对该交易执行退款")
534
+ if original_tx.tx_type != "OPEN_DEDUCT":
535
+ raise HTTPException(status_code=400, detail="该交易类型不支持开放退款")
536
+ if original_tx.related_account != plugin_info["id"] or original_tx.source_plugin not in (None, plugin_info["id"]):
537
+ raise HTTPException(status_code=403, detail="不允许跨插件退款")
538
+ if isinstance(original_tx.amount, bool) or not isinstance(original_tx.amount, int) or original_tx.amount >= 0:
539
+ raise HTTPException(status_code=503, detail="原交易金额暂时无法核实")
540
+ original_amount = -original_tx.amount
541
+ # 校验全部关联退款,不能用 abs() 把损坏的负退款/跨账号记录视为合法。
542
+ refunded = db.query(Transaction).filter(
543
+ Transaction.tx_type == "OPEN_REFUND", Transaction.item_id == req.transaction_id).all()
544
+ if any(row.account != account or row.related_account != plugin_info["id"] or
545
+ row.source_plugin not in (None, plugin_info["id"]) or isinstance(row.amount, bool) or
546
+ not isinstance(row.amount, int) or row.amount <= 0 for row in refunded):
547
+ raise HTTPException(status_code=503, detail="退款流水暂时无法核实")
548
+ already_refunded = sum(row.amount for row in refunded)
549
+ if already_refunded + refund_amount > original_amount:
550
+ raise HTTPException(status_code=400, detail="退款金额超过可退余额")
551
+ if wallet is None:
552
+ raise HTTPException(status_code=503, detail="钱包记录暂时无法核实")
553
+ if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
554
+ raise ValueError("invalid wallet balance")
555
+ wallet.balance += refund_amount
556
+ tx_id = f"OPEN_REFUND_{operation[0] if operation is not None else uuid.uuid4().hex}"
557
+ last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
558
+ Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
559
+ prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
560
+ db.add(Transaction(tx_id=tx_id, account=account, tx_type="OPEN_REFUND", amount=refund_amount,
561
+ related_account=plugin_info["id"], item_id=req.transaction_id, prev_hash=prev_hash,
562
+ tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
563
+ description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
564
+ response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
565
+ save_open_operation(db, operation, response)
566
+ db.commit()
567
+ except HTTPException:
568
+ db.rollback()
569
+ raise
570
+ except Exception as error:
571
+ db.rollback()
572
+ logger.error("OPEN_REFUND_FAIL | error_type=%s", type(error).__name__)
573
+ raise HTTPException(status_code=500, detail="退款失败,请稍后重试")
574
+ run_tip_effect(lambda: logger.info("OPEN_REFUND_COMPLETED"))
575
+ return response
576
+
577
+
578
+ def _read_open_transactions(account, plugin_info, page, page_size, start_time, end_time, tx_type, db):
579
+ plugin_id = plugin_info["id"]
580
+
581
+ # 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
582
+ q = db.query(Transaction).filter(
583
+ Transaction.account == account,
584
+ ((Transaction.source_plugin == plugin_id)
585
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
586
+ )
587
+
588
+ if tx_type:
589
+ q = q.filter(Transaction.tx_type == tx_type)
590
+ if start_time is not None:
591
+ q = q.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
592
+ if end_time is not None:
593
+ q = q.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
594
+
595
+ total = q.count()
596
+ rows = (
597
+ q.order_by(Transaction.created_at.desc())
598
+ .offset((page - 1) * page_size)
599
+ .limit(page_size)
600
+ .all()
601
+ )
602
+
603
+ items = []
604
+ for t in rows:
605
+ created_ts = int(t.created_at.timestamp()) if t.created_at else 0
606
+ # reason 从 description 中解析(去掉 [插件名] 前缀),兼容旧记录
607
+ reason = (t.description or "").replace(f"[{plugin_info['name']}] ", "").strip() or None
608
+ items.append({
609
+ "id": t.tx_id,
610
+ "type": t.tx_type,
611
+ "amount": int(t.amount or 0),
612
+ "reason": reason,
613
+ "reference_id": t.item_id,
614
+ "created_at": created_ts,
615
+ "description": t.description or "",
616
+ })
617
+
618
+ logger.info(f"OPEN_LIST_TX | plugin={plugin_id} | page={page}/{page_size} | total={total}")
619
+ return {
620
+ "success": True,
621
+ "transactions": items,
622
+ "total": total,
623
+ "page": page,
624
+ "page_size": page_size,
625
+ }
626
+
627
+
628
+ def _read_open_usage(plugin_info, account, period, start_time, end_time, db):
629
+ plugin_id = plugin_info["id"]
630
+
631
+ period = (period or "daily").lower()
632
+ if period not in ("daily", "monthly", "total"):
633
+ raise HTTPException(status_code=400, detail="period 仅支持 daily / monthly / total")
634
+
635
+ base = db.query(Transaction).filter(
636
+ ((Transaction.source_plugin == plugin_id)
637
+ | ((Transaction.source_plugin.is_(None)) & (Transaction.related_account == plugin_id))),
638
+ Transaction.tx_type.in_(["OPEN_DEDUCT", "OPEN_REFUND"]),
639
+ )
640
+ if account:
641
+ base = base.filter(Transaction.account == account)
642
+ if start_time is not None:
643
+ base = base.filter(Transaction.created_at >= datetime.datetime.utcfromtimestamp(int(start_time)))
644
+ if end_time is not None:
645
+ base = base.filter(Transaction.created_at <= datetime.datetime.utcfromtimestamp(int(end_time)))
646
+
647
+ rows = base.all()
648
+
649
+ total_deduct = 0
650
+ total_refund = 0
651
+ total_count = len(rows)
652
+ bucket: dict = {} # key -> {"deduct":int,"refund":int,"count":int}
653
+
654
+ for t in rows:
655
+ amt = int(t.amount or 0)
656
+ if t.tx_type == "OPEN_DEDUCT":
657
+ total_deduct += abs(amt)
658
+ elif t.tx_type == "OPEN_REFUND":
659
+ total_refund += abs(amt)
660
+
661
+ if period == "total":
662
+ continue
663
+ if not t.created_at:
664
+ continue
665
+ if period == "daily":
666
+ key = t.created_at.strftime("%Y-%m-%d")
667
+ else: # monthly
668
+ key = t.created_at.strftime("%Y-%m")
669
+
670
+ b = bucket.setdefault(key, {"deduct": 0, "refund": 0, "count": 0})
671
+ if t.tx_type == "OPEN_DEDUCT":
672
+ b["deduct"] += abs(amt)
673
+ elif t.tx_type == "OPEN_REFUND":
674
+ b["refund"] += abs(amt)
675
+ b["count"] += 1
676
+
677
+ breakdown = []
678
+ if period != "total":
679
+ for key in sorted(bucket.keys(), reverse=True):
680
+ v = bucket[key]
681
+ label = "date" if period == "daily" else "month"
682
+ breakdown.append({
683
+ label: key,
684
+ "deduct": v["deduct"],
685
+ "refund": v["refund"],
686
+ "count": v["count"],
687
+ })
688
+
689
+ logger.info(f"OPEN_USAGE_SUMMARY | plugin={plugin_id} | period={period} | tx_count={total_count}")
690
+ return {
691
+ "success": True,
692
+ "plugin_id": plugin_id,
693
+ "plugin_name": plugin_info.get("name", plugin_id),
694
+ "summary": {
695
+ "total_deduct": total_deduct,
696
+ "total_refund": total_refund,
697
+ "total_transactions": total_count,
698
+ "net_consumption": total_deduct - total_refund,
699
+ },
700
+ "breakdown": breakdown,
701
+ }
702
+