Spaces:
Running
Running
Upload 30 files
Browse files- app.py +708 -676
- auth_io.py +24 -0
- github_versions.py +89 -89
- models_sql.py +26 -1
- open_transaction.py +53 -1
- owned_thread.py +56 -0
- rate_limit.py +13 -4
- request_body_guard.py +127 -0
- resource_work.py +88 -0
- router_items.py +807 -786
- router_messages.py +196 -188
- router_open_api.py +148 -100
- router_users_auth.py +27 -26
- shutdown_lifecycle.py +127 -0
- startup_warmup.py +82 -0
- verification_io.py +17 -0
- verify_code_engine.py +12 -23
- 安全认证.py +2 -2
app.py
CHANGED
|
@@ -1,676 +1,708 @@
|
|
| 1 |
-
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
-
from runtime_log_privacy import install_uvicorn_privacy_filters, route_label, RequestTraceMiddleware
|
| 3 |
-
install_uvicorn_privacy_filters()
|
| 4 |
-
|
| 5 |
-
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 6 |
-
from rate_limit import shared_limit
|
| 7 |
-
from response_privacy import requires_no_store
|
| 8 |
-
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
-
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 10 |
-
from sqlalchemy.orm import Session
|
| 11 |
-
from pydantic import BaseModel
|
| 12 |
-
from huggingface_hub import hf_hub_download, HfApi
|
| 13 |
-
import uuid
|
| 14 |
-
import urllib.parse
|
| 15 |
-
import urllib.request
|
| 16 |
-
import urllib.error
|
| 17 |
-
import os
|
| 18 |
-
import json
|
| 19 |
-
import logging
|
| 20 |
-
import time
|
| 21 |
-
import datetime
|
| 22 |
-
import threading
|
| 23 |
-
import 数据库连接 as db
|
| 24 |
-
import asyncio
|
| 25 |
-
from 安全认证 import require_auth
|
| 26 |
-
from profile_io import start_profile_io, close_profile_io
|
| 27 |
-
from
|
| 28 |
-
from
|
| 29 |
-
from
|
| 30 |
-
from
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
from
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
#
|
| 59 |
-
#
|
| 60 |
-
#
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
from
|
| 64 |
-
from
|
| 65 |
-
from
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
from
|
| 71 |
-
|
| 72 |
-
from
|
| 73 |
-
from
|
| 74 |
-
|
| 75 |
-
from
|
| 76 |
-
from
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
"
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
# 检查
|
| 217 |
-
try:
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
#
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
)
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
app.
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
app.
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
#
|
| 405 |
-
#
|
| 406 |
-
# ==========================================
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
#
|
| 410 |
-
#
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
if strict:
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
if
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
}
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
if
|
| 646 |
-
raise
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
#
|
| 672 |
-
if
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ⚙️ 后端逻辑/核心服务端.py (Hugging Face Spaces app.py)
|
| 2 |
+
from runtime_log_privacy import install_uvicorn_privacy_filters, route_label, RequestTraceMiddleware
|
| 3 |
+
install_uvicorn_privacy_filters()
|
| 4 |
+
|
| 5 |
+
from fastapi import FastAPI, File, UploadFile, Form, Depends, HTTPException, Request
|
| 6 |
+
from rate_limit import shared_limit
|
| 7 |
+
from response_privacy import requires_no_store
|
| 8 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.responses import Response, JSONResponse, FileResponse
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
from huggingface_hub import hf_hub_download, HfApi
|
| 13 |
+
import uuid
|
| 14 |
+
import urllib.parse
|
| 15 |
+
import urllib.request
|
| 16 |
+
import urllib.error
|
| 17 |
+
import os
|
| 18 |
+
import json
|
| 19 |
+
import logging
|
| 20 |
+
import time
|
| 21 |
+
import datetime
|
| 22 |
+
import threading
|
| 23 |
+
import 数据库连接 as db
|
| 24 |
+
import asyncio
|
| 25 |
+
from 安全认证 import require_auth
|
| 26 |
+
from profile_io import start_profile_io, close_profile_io
|
| 27 |
+
from verification_io import start_verification_io, stop_verification_io, close_verification_io
|
| 28 |
+
from auth_io import start_auth_io, stop_auth_io, close_auth_io
|
| 29 |
+
from auth_io import start_identity_io, stop_identity_io, close_identity_io
|
| 30 |
+
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)
|
| 38 |
+
from startup_warmup import (prepare_startup_warmup, run_startup_warmup, stop_startup_warmup,
|
| 39 |
+
wait_startup_warmup, startup_warmup_stopped, startup_shutdown_requested)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# ==========================================
|
| 43 |
+
# 📝 P2优化:日志配置
|
| 44 |
+
# ==========================================
|
| 45 |
+
logging.basicConfig(
|
| 46 |
+
level=logging.INFO,
|
| 47 |
+
format='%(asctime)s | %(levelname)s | %(name)s | %(message)s',
|
| 48 |
+
datefmt='%Y-%m-%d %H:%M:%S'
|
| 49 |
+
)
|
| 50 |
+
logger = logging.getLogger("ComfyUI-Ranking")
|
| 51 |
+
_UPLOAD_CONCURRENCY = max(1, min(int(os.environ.get("MAX_CONCURRENT_UPLOADS", "4")), 16))
|
| 52 |
+
_UPLOAD_SEMAPHORE = threading.BoundedSemaphore(_UPLOAD_CONCURRENCY)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
from 云端_定时版本检测引擎 import daily_version_check_task, reset_daily_views_task
|
| 56 |
+
|
| 57 |
+
# ==========================================
|
| 58 |
+
# 👥 用户模块 (拆分为3个子模块)
|
| 59 |
+
# ==========================================
|
| 60 |
+
# router_users_auth.py - 🔐 登录/注册/密码重置/验证码
|
| 61 |
+
# router_users_profile.py - 👤 获取/更新用户资料
|
| 62 |
+
# router_users_social.py - 🤝 关注/隐私设置
|
| 63 |
+
from router_users_auth import router as users_auth_router
|
| 64 |
+
from router_users_profile import router as users_profile_router
|
| 65 |
+
from router_users_social import router as users_social_router
|
| 66 |
+
|
| 67 |
+
# ==========================================
|
| 68 |
+
# 其他业务模块
|
| 69 |
+
# ==========================================
|
| 70 |
+
from router_items import router as items_router # 📦 内容管理(工具/Skill/应用/推荐)
|
| 71 |
+
from router_comments import router as comments_router # 💬 评论系统
|
| 72 |
+
from router_messages import (router as messages_router, start_message_io, stop_message_io, close_message_io) # ✉️ 私信系统
|
| 73 |
+
from router_wallet import router as wallet_router # 💰 钱包/提现
|
| 74 |
+
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 75 |
+
from router_posts import router as posts_router # 💬 讨论区
|
| 76 |
+
from router_prompts import router as prompts_router # ✨ 提示词市场
|
| 77 |
+
from router_tasks import router as tasks_router # 📝 任务榜
|
| 78 |
+
from router_open_api import start_open_sql, stop_open_sql, close_open_sql
|
| 79 |
+
from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
|
| 80 |
+
from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
|
| 81 |
+
|
| 82 |
+
from database_sql import init_sql_db, get_db, SessionLocal
|
| 83 |
+
from models_sql import SystemConfig # 🧹 P2清理:Ownership 在本文件无引用
|
| 84 |
+
|
| 85 |
+
from fastapi.exceptions import RequestValidationError
|
| 86 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 87 |
+
|
| 88 |
+
app = FastAPI(
|
| 89 |
+
title="ComfyUI Ranking API",
|
| 90 |
+
description="ComfyUI 社区排名系统 API",
|
| 91 |
+
version="2.0.0",
|
| 92 |
+
docs_url="/api/docs",
|
| 93 |
+
redoc_url="/api/redoc"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
# ==========================================
|
| 98 |
+
# 🛡️ 稳定性优化:全局异常处理器
|
| 99 |
+
# ==========================================
|
| 100 |
+
# 作用:捕获所有未处理异常,防止单个请求崩溃整个服务
|
| 101 |
+
# 🚀 P3优化:统一错误响应格式
|
| 102 |
+
|
| 103 |
+
def create_error_response(status_code: int, detail: str, error_type: str = None, error_id: str = None, extra: dict = None):
|
| 104 |
+
"""🚀 P3优化:统一错误响应构造器"""
|
| 105 |
+
response = {
|
| 106 |
+
"success": False,
|
| 107 |
+
"status": "error",
|
| 108 |
+
"code": status_code,
|
| 109 |
+
"detail": detail,
|
| 110 |
+
}
|
| 111 |
+
if error_type:
|
| 112 |
+
response["type"] = error_type
|
| 113 |
+
if error_id:
|
| 114 |
+
response["error_id"] = error_id
|
| 115 |
+
if extra:
|
| 116 |
+
response.update(extra)
|
| 117 |
+
return response
|
| 118 |
+
|
| 119 |
+
@app.exception_handler(StarletteHTTPException)
|
| 120 |
+
async def http_exception_handler(request, exc):
|
| 121 |
+
"""🚀 P3优化:HTTP 异常处理,返回统一格式"""
|
| 122 |
+
logger.warning(f"HTTP {exc.status_code} | {route_label(request)} | detail_type={type(exc.detail).__name__}")
|
| 123 |
+
|
| 124 |
+
# 根据状态码确定错误类型
|
| 125 |
+
error_type_map = {
|
| 126 |
+
400: "bad_request",
|
| 127 |
+
401: "unauthorized",
|
| 128 |
+
403: "forbidden",
|
| 129 |
+
404: "not_found",
|
| 130 |
+
409: "conflict",
|
| 131 |
+
429: "rate_limited",
|
| 132 |
+
502: "upstream_error",
|
| 133 |
+
}
|
| 134 |
+
error_type = error_type_map.get(exc.status_code, "http_error")
|
| 135 |
+
|
| 136 |
+
return JSONResponse(
|
| 137 |
+
status_code=exc.status_code,
|
| 138 |
+
content=create_error_response(exc.status_code, str(exc.detail), error_type),
|
| 139 |
+
headers={"X-Ranking-Error-Scope": "endpoint"} if exc.status_code == 503 else None,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
from request_body_guard import RequestBodyGuard
|
| 143 |
+
app.add_middleware(RequestBodyGuard, error_response=create_error_response)
|
| 144 |
+
|
| 145 |
+
from upload_body_guard import UploadBodyGuard
|
| 146 |
+
app.add_middleware(UploadBodyGuard, capacity=_UPLOAD_CONCURRENCY, error_response=create_error_response)
|
| 147 |
+
|
| 148 |
+
from public_list_etag import PublicListETag
|
| 149 |
+
app.add_middleware(PublicListETag)
|
| 150 |
+
|
| 151 |
+
@app.middleware("http")
|
| 152 |
+
async def private_response_cache_control(request: Request, call_next):
|
| 153 |
+
response = await call_next(request)
|
| 154 |
+
if requires_no_store(request.url.path, request.headers.get("Authorization")):
|
| 155 |
+
response.headers["Cache-Control"] = "no-store"
|
| 156 |
+
vary = [name.strip() for name in response.headers.get("Vary", "").split(",") if name.strip()]
|
| 157 |
+
if not any(name.lower() in {"authorization", "*"} for name in vary):
|
| 158 |
+
vary.append("Authorization")
|
| 159 |
+
response.headers["Vary"] = ", ".join(vary)
|
| 160 |
+
return response
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@app.exception_handler(RequestValidationError)
|
| 164 |
+
async def validation_exception_handler(request, exc):
|
| 165 |
+
"""🚀 P3优化:请求参数校验失败处理"""
|
| 166 |
+
logger.warning("Validation Error | %s | count=%d", route_label(request), len(exc.errors()))
|
| 167 |
+
|
| 168 |
+
# 提取简洁的错误信息
|
| 169 |
+
errors = []
|
| 170 |
+
for err in exc.errors():
|
| 171 |
+
field = ".".join(str(loc) for loc in err["loc"][1:]) # 跳过 'body'
|
| 172 |
+
errors.append({"field": field, "message": "该字段格式不正确", "type": err["type"]})
|
| 173 |
+
|
| 174 |
+
return JSONResponse(
|
| 175 |
+
status_code=422,
|
| 176 |
+
content=create_error_response(
|
| 177 |
+
422,
|
| 178 |
+
"请求参数格式错误",
|
| 179 |
+
"validation_error",
|
| 180 |
+
extra={"errors": errors}
|
| 181 |
+
)
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
@app.exception_handler(Exception)
|
| 185 |
+
async def global_exception_handler(request, exc):
|
| 186 |
+
"""🚀 P3优化:全局异常处理,捕获所有未预期异常"""
|
| 187 |
+
error_id = getattr(request.state, 'ranking_request_id', None) or uuid.uuid4().hex
|
| 188 |
+
logger.error(f"Unhandled Exception [{error_id}] | {route_label(request)} | error_type={type(exc).__name__}")
|
| 189 |
+
return JSONResponse(
|
| 190 |
+
status_code=500,
|
| 191 |
+
content=create_error_response(
|
| 192 |
+
500,
|
| 193 |
+
"服务器内部错误,请稍后重试",
|
| 194 |
+
"internal_error",
|
| 195 |
+
error_id=error_id
|
| 196 |
+
),
|
| 197 |
+
headers={"X-Ranking-Request-Id": error_id}
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# ==========================================
|
| 202 |
+
# ❤️ 健康检查接口 (增强版)
|
| 203 |
+
# ==========================================
|
| 204 |
+
@app.get("/")
|
| 205 |
+
def health_check():
|
| 206 |
+
return {"status": "ok", "message": "ComfyUI Ranking API is running perfectly!"}
|
| 207 |
+
|
| 208 |
+
@app.get("/health")
|
| 209 |
+
def detailed_health_check():
|
| 210 |
+
"""详细健康检查,含依赖状态"""
|
| 211 |
+
health_status = {
|
| 212 |
+
"status": "ok",
|
| 213 |
+
"components": {}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
# 检查 SQL 数据库
|
| 217 |
+
try:
|
| 218 |
+
from database_sql import check_db_connection
|
| 219 |
+
if check_db_connection():
|
| 220 |
+
health_status["components"]["sql_database"] = "ok"
|
| 221 |
+
else:
|
| 222 |
+
health_status["components"]["sql_database"] = "error: connection failed"
|
| 223 |
+
health_status["status"] = "degraded"
|
| 224 |
+
except Exception as e:
|
| 225 |
+
logger.warning(f"健康检查 SQL 数据库异常: error_type={type(e).__name__}")
|
| 226 |
+
health_status["components"]["sql_database"] = "error: connection failed"
|
| 227 |
+
health_status["status"] = "degraded"
|
| 228 |
+
|
| 229 |
+
# 检查 JSON 文件访问
|
| 230 |
+
try:
|
| 231 |
+
import 数据库连接 as json_db
|
| 232 |
+
json_db.load_data("users.json", default_data={})
|
| 233 |
+
health_status["components"]["json_storage"] = "ok"
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.warning(f"健康检查 JSON 存储异常: error_type={type(e).__name__}")
|
| 236 |
+
health_status["components"]["json_storage"] = "error: storage unavailable"
|
| 237 |
+
health_status["status"] = "degraded"
|
| 238 |
+
|
| 239 |
+
return health_status
|
| 240 |
+
|
| 241 |
+
def _warmup_dependencies():
|
| 242 |
+
if startup_warmup_stopped(app.state):
|
| 243 |
+
return
|
| 244 |
+
# ========== 预热检查:SQL 数据库 ==========
|
| 245 |
+
try:
|
| 246 |
+
init_sql_db()
|
| 247 |
+
logger.info("✅ SQL 数据库初始化完成")
|
| 248 |
+
except Exception as e:
|
| 249 |
+
logger.error(f"❌ SQL 数据库初始化失败: error_type={type(e).__name__}")
|
| 250 |
+
# 不抛出异常,允许降级运行
|
| 251 |
+
|
| 252 |
+
if startup_warmup_stopped(app.state):
|
| 253 |
+
return
|
| 254 |
+
# ========== 加载第三方插件注册表(依赖 SQL 数据库) ==========
|
| 255 |
+
try:
|
| 256 |
+
from router_open_api import load_plugins_from_db
|
| 257 |
+
load_plugins_from_db()
|
| 258 |
+
logger.info("✅ 第三方插件注册表已加载")
|
| 259 |
+
except Exception as e:
|
| 260 |
+
logger.warning(f"⚠️ 第三方插件注册表加载失败: error_type={type(e).__name__}")
|
| 261 |
+
|
| 262 |
+
if startup_warmup_stopped(app.state):
|
| 263 |
+
return
|
| 264 |
+
# ========== 预热检查:JSON 数据库 ==========
|
| 265 |
+
try:
|
| 266 |
+
db.load_data("users.json", default_data={})
|
| 267 |
+
if startup_warmup_stopped(app.state):
|
| 268 |
+
return
|
| 269 |
+
db.load_data("items.json", default_data=[])
|
| 270 |
+
logger.info("✅ JSON 数据库访问正常")
|
| 271 |
+
except Exception as e:
|
| 272 |
+
logger.warning(f"⚠️ JSON 数据库访问异常: error_type={type(e).__name__}")
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# ==========================================
|
| 276 |
+
# 🚀 应用启动事件
|
| 277 |
+
# ==========================================
|
| 278 |
+
# 作用:初始化数据库 + 启动定时版本检测后台任务 + 预热检查
|
| 279 |
+
@app.on_event("startup")
|
| 280 |
+
async def on_startup():
|
| 281 |
+
logger.info("🚀 ComfyUI-Ranking API 启动中...")
|
| 282 |
+
if getattr(app.state, "_ranking_starting", False) or getattr(app.state, "_ranking_closers", 0):
|
| 283 |
+
raise RuntimeError("application lifecycle is already in progress")
|
| 284 |
+
if getattr(app.state, "_ranking_started", False):
|
| 285 |
+
return
|
| 286 |
+
app.state._ranking_starting = True
|
| 287 |
+
try:
|
| 288 |
+
prepare_startup_warmup(app.state)
|
| 289 |
+
prepare_application_shutdown(app.state)
|
| 290 |
+
start_profile_io()
|
| 291 |
+
try:
|
| 292 |
+
start_verification_io()
|
| 293 |
+
start_auth_io()
|
| 294 |
+
start_identity_io()
|
| 295 |
+
start_shared_work()
|
| 296 |
+
start_open_sql()
|
| 297 |
+
start_resource_work()
|
| 298 |
+
start_message_io()
|
| 299 |
+
start_comment_reads()
|
| 300 |
+
start_admin_scripts()
|
| 301 |
+
|
| 302 |
+
await run_startup_warmup(app.state, _warmup_dependencies)
|
| 303 |
+
|
| 304 |
+
# SQL资金已冻结但JSON发布失败的任务,重启后继续投影;持有句柄供停机收尾。
|
| 305 |
+
from task_publication import publication_worker
|
| 306 |
+
from database_sql import engine
|
| 307 |
+
from db_utils import sort_cache
|
| 308 |
+
start_periodic_task(app.state, "task_publication",
|
| 309 |
+
lambda stop: publication_worker(engine, db, sort_cache.invalidate, stop))
|
| 310 |
+
|
| 311 |
+
from resource_publication import publication_worker as resource_publication_worker
|
| 312 |
+
start_periodic_task(app.state, "resource_publication",
|
| 313 |
+
lambda stop: resource_publication_worker(engine, db, sort_cache.invalidate, stop))
|
| 314 |
+
|
| 315 |
+
from resource_deletion import resource_deletion_worker
|
| 316 |
+
start_periodic_task(app.state, "resource_deletion",
|
| 317 |
+
lambda stop: resource_deletion_worker(db, stop, sort_cache.invalidate))
|
| 318 |
+
|
| 319 |
+
# ========== 启动后台任务 ==========
|
| 320 |
+
start_periodic_task(app.state, "daily_version", daily_version_check_task)
|
| 321 |
+
logger.info("✅ 定时版本检测任务已挂载")
|
| 322 |
+
|
| 323 |
+
start_periodic_task(app.state, "daily_views", reset_daily_views_task)
|
| 324 |
+
logger.info("✅ daily_views 每日重置任务已挂载")
|
| 325 |
+
|
| 326 |
+
# ========== 启动 HF 批量同步定时器 ==========
|
| 327 |
+
db.start_batch_sync()
|
| 328 |
+
logger.info("✅ HF 同步调度初始化已完成")
|
| 329 |
+
|
| 330 |
+
app.state._ranking_started = True
|
| 331 |
+
logger.info("🎉 ComfyUI-Ranking API 启动完成!")
|
| 332 |
+
except BaseException:
|
| 333 |
+
# 启动可能只登记了部分句柄;正常关闭按已登记对象收尾。
|
| 334 |
+
if not startup_shutdown_requested(app.state):
|
| 335 |
+
try:
|
| 336 |
+
await on_shutdown()
|
| 337 |
+
except BaseException as error:
|
| 338 |
+
logger.error("启动收尾失败: error_type=%s", type(error).__name__)
|
| 339 |
+
raise
|
| 340 |
+
finally:
|
| 341 |
+
app.state._ranking_starting = False
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@app.on_event("shutdown")
|
| 345 |
+
async def on_shutdown():
|
| 346 |
+
return await run_application_shutdown(app.state, _shutdown_resources)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
async def _shutdown_resources():
|
| 350 |
+
"""逐项收尾,错误在其它组件得到关闭机会后传播。"""
|
| 351 |
+
app.state._ranking_started = False
|
| 352 |
+
app.state._ranking_shutdown_incomplete = True
|
| 353 |
+
failures = ShutdownFailures(logger)
|
| 354 |
+
failures.call('log_start', logger.info, "🛑 ComfyUI-Ranking API 正在关闭...")
|
| 355 |
+
failures.call('stop_warmup', stop_startup_warmup, app.state)
|
| 356 |
+
for stage, stop in (
|
| 357 |
+
('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
|
| 358 |
+
('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
|
| 359 |
+
('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
|
| 360 |
+
('stop_comments', stop_comment_reads),
|
| 361 |
+
):
|
| 362 |
+
failures.call(stage, stop)
|
| 363 |
+
workers = []
|
| 364 |
+
for name in ('daily_version', 'daily_views', 'task_publication', 'resource_publication', 'resource_deletion'):
|
| 365 |
+
worker = failures.call('capture_' + name, getattr, app.state, name + '_worker', None)
|
| 366 |
+
if worker is not None:
|
| 367 |
+
workers.append(worker)
|
| 368 |
+
failures.call('stop_' + name, request_periodic_stop, app.state, (name,))
|
| 369 |
+
for stage, close, args in (
|
| 370 |
+
('wait_warmup', wait_startup_warmup, (app.state,)),
|
| 371 |
+
('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
|
| 372 |
+
('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
|
| 373 |
+
('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
|
| 374 |
+
('close_scripts', close_admin_scripts, ()), ('close_comments', close_comment_reads, ()),
|
| 375 |
+
('wait_periodic', wait_periodic_tasks, (workers,)),
|
| 376 |
+
):
|
| 377 |
+
await failures.wait(stage, close, *args)
|
| 378 |
+
app.state._ranking_shutdown_incomplete = failures.incomplete
|
| 379 |
+
failures.raise_if_failed(cancel_requested=shutdown_cancel_requested(app.state))
|
| 380 |
+
await run_owned_thread(db.flush_sync)
|
| 381 |
+
logger.info("✅ HF 关闭前同步尝试已结束")
|
| 382 |
+
logger.info("✅ 关闭完成")
|
| 383 |
+
|
| 384 |
+
# 🛡️ CORS 配置:默认通配符(插件前端运行于用户本地 ComfyUI 页面,origin 为
|
| 385 |
+
# 127.0.0.1/localhost/局域网 IP/自定义端口等无法穷举;本 API 通过 Authorization 头
|
| 386 |
+
# 传递 Token 且 allow_credentials=False 不依赖 Cookie,通配风险可控)。
|
| 387 |
+
# 如需收紧可在环境变量 ALLOWED_ORIGINS 中逗号分隔指定域名。
|
| 388 |
+
ALLOWED_ORIGINS = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
|
| 389 |
+
logger.info(
|
| 390 |
+
f"CORS 配置已加载: origin_count={len(ALLOWED_ORIGINS)} | "
|
| 391 |
+
f"wildcard={len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == '*'}"
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
app.add_middleware(
|
| 395 |
+
CORSMiddleware,
|
| 396 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 397 |
+
allow_credentials=False,
|
| 398 |
+
allow_methods=["*"],
|
| 399 |
+
allow_headers=["*"],
|
| 400 |
+
expose_headers=["ETag", "X-Ranking-Error-Scope", "X-Ranking-Request-Id"],
|
| 401 |
+
)
|
| 402 |
+
app.add_middleware(RequestTraceMiddleware)
|
| 403 |
+
|
| 404 |
+
# ==========================================
|
| 405 |
+
# 路由挂载
|
| 406 |
+
# ==========================================
|
| 407 |
+
# 用户模块 (3个子模块)
|
| 408 |
+
app.include_router(users_auth_router) # 🔐 登录/注册/密码重置
|
| 409 |
+
app.include_router(users_profile_router) # 👤 用户资料
|
| 410 |
+
app.include_router(users_social_router) # 🤝 关注/隐私
|
| 411 |
+
|
| 412 |
+
# 其他业务模块
|
| 413 |
+
app.include_router(items_router) # 📦 内容管理
|
| 414 |
+
app.include_router(comments_router) # 💬 评论系统
|
| 415 |
+
app.include_router(messages_router) # ✉️ 私信系统
|
| 416 |
+
app.include_router(wallet_router) # 💰 钱包/提现
|
| 417 |
+
app.include_router(proxy_router) # 🔗 代理下载
|
| 418 |
+
app.include_router(posts_router) # 💬 讨论区
|
| 419 |
+
app.include_router(prompts_router) # ✨ 提示词市场
|
| 420 |
+
app.include_router(tasks_router) # 📝 任务榜
|
| 421 |
+
app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
|
| 422 |
+
app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
|
| 423 |
+
|
| 424 |
+
# ==========================================
|
| 425 |
+
# 🟢 私有图床代理中心 (Image Proxy)
|
| 426 |
+
# 解决 Private 仓库下,本地客户端报 401 Unauthorized 的终极方案
|
| 427 |
+
# ==========================================
|
| 428 |
+
from media_proxy_service import read_public_media
|
| 429 |
+
|
| 430 |
+
@app.get("/api/image_proxy")
|
| 431 |
+
def proxy_hf_image(url: str = None, path: str = None):
|
| 432 |
+
return read_public_media(url, path, store=db, download=hf_hub_download,
|
| 433 |
+
file_response=FileResponse, error_response=create_error_response)
|
| 434 |
+
|
| 435 |
+
# ==========================================
|
| 436 |
+
# 上传接口 (将返回的 URL 替换为 Proxy 代理链接)
|
| 437 |
+
# 🔒 P0安全优化:集成图片内容审核
|
| 438 |
+
# ==========================================
|
| 439 |
+
from image_moderation import moderate_image_sync, ModerationResult
|
| 440 |
+
|
| 441 |
+
# ==========================================
|
| 442 |
+
# 🎵 音频安全净化:重编码压缩,剥离元数据与隐藏附加信息
|
| 443 |
+
# ==========================================
|
| 444 |
+
from upload_service import process_upload
|
| 445 |
+
|
| 446 |
+
@app.post("/api/upload")
|
| 447 |
+
@shared_limit(30)
|
| 448 |
+
def upload_file(request: Request, file: UploadFile = File(...), file_type: str = Form(...), current_user: str = Depends(require_auth)):
|
| 449 |
+
return process_upload(file, file_type, current_user, store=db, storage_factory=HfApi,
|
| 450 |
+
moderate=moderate_image_sync, limiter=_UPLOAD_SEMAPHORE,
|
| 451 |
+
error_response=create_error_response)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
from github_source import select_github_token
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
class ValidateResourceRequest(BaseModel):
|
| 459 |
+
url: str
|
| 460 |
+
item_id: str
|
| 461 |
+
account: str
|
| 462 |
+
|
| 463 |
+
from resource_probe_service import probe_resource
|
| 464 |
+
from github_probe_transport import open_github_probe
|
| 465 |
+
from skill_preflight import verify_skill_repository
|
| 466 |
+
from router_proxy import _send_with_validated_redirects
|
| 467 |
+
from safe_http_transport import ValidatedAsyncHTTPTransport
|
| 468 |
+
import httpx
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _probe_skill_repository(item, token):
|
| 472 |
+
return verify_skill_repository(item, token, send=_send_with_validated_redirects,
|
| 473 |
+
client_factory=httpx.AsyncClient, transport_factory=ValidatedAsyncHTTPTransport)
|
| 474 |
+
|
| 475 |
+
@app.post("/api/validate_resource")
|
| 476 |
+
@shared_limit(30)
|
| 477 |
+
def validate_resource(req_data: ValidateResourceRequest, request: Request, sql_db: Session = Depends(get_db), current_user: str = Depends(require_auth)):
|
| 478 |
+
return probe_resource(req_data.item_id, store=db, download=hf_hub_download,
|
| 479 |
+
open_url=open_github_probe, token_selector=select_github_token,
|
| 480 |
+
error_response=create_error_response, skill_probe=_probe_skill_repository)
|
| 481 |
+
|
| 482 |
+
# ==========================================
|
| 483 |
+
# 🔒 管理员:系统配置 API
|
| 484 |
+
# ==========================================
|
| 485 |
+
from 安全认证 import require_auth, ADMIN_ACCOUNTS
|
| 486 |
+
|
| 487 |
+
def _is_admin(account: str) -> bool:
|
| 488 |
+
"""检查账号是否为管理员"""
|
| 489 |
+
from 安全认证 import is_admin as _check_admin
|
| 490 |
+
return _check_admin(account)
|
| 491 |
+
|
| 492 |
+
# 配置存储文件
|
| 493 |
+
SYSTEM_CONFIG_FILE = "/tmp/system_config.json" # 保留作为降级方案
|
| 494 |
+
|
| 495 |
+
def _load_system_config_from_db(db: Session, *, strict: bool = False) -> dict:
|
| 496 |
+
"""从数据库加载所有系统配置"""
|
| 497 |
+
try:
|
| 498 |
+
configs = db.query(SystemConfig).all()
|
| 499 |
+
result = {}
|
| 500 |
+
for cfg in configs:
|
| 501 |
+
try:
|
| 502 |
+
result[cfg.config_key] = json.loads(cfg.config_value)
|
| 503 |
+
except (json.JSONDecodeError, TypeError):
|
| 504 |
+
result[cfg.config_key] = cfg.config_value
|
| 505 |
+
return result
|
| 506 |
+
except Exception as e:
|
| 507 |
+
logger.warning(f"从数据库加载系统配置失败,降级到文件: error_type={type(e).__name__}")
|
| 508 |
+
if strict:
|
| 509 |
+
return _load_system_config_from_file(strict=True)
|
| 510 |
+
return _load_system_config_from_file()
|
| 511 |
+
|
| 512 |
+
def _load_system_config_from_file(*, strict: bool = False) -> dict:
|
| 513 |
+
"""降级方案;严格读取不把故障或缺失文件冒充空配置。"""
|
| 514 |
+
if strict:
|
| 515 |
+
with open(SYSTEM_CONFIG_FILE, "r", encoding="utf-8") as stream:
|
| 516 |
+
config = json.load(stream)
|
| 517 |
+
if not isinstance(config, dict):
|
| 518 |
+
raise ValueError("invalid system configuration root")
|
| 519 |
+
return config
|
| 520 |
+
try:
|
| 521 |
+
if os.path.exists(SYSTEM_CONFIG_FILE):
|
| 522 |
+
with open(SYSTEM_CONFIG_FILE, "r") as f:
|
| 523 |
+
return json.load(f)
|
| 524 |
+
except Exception as e:
|
| 525 |
+
logger.debug(f"加载系统配置文件失败: error_type={type(e).__name__}")
|
| 526 |
+
return {}
|
| 527 |
+
|
| 528 |
+
def _save_system_config_to_db(db: Session, config_key: str, value, updated_by: str = None, *, strict: bool = False):
|
| 529 |
+
"""保存单个配置项到数据库"""
|
| 530 |
+
try:
|
| 531 |
+
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == config_key).first()
|
| 532 |
+
if not cfg:
|
| 533 |
+
cfg = SystemConfig(config_key=config_key)
|
| 534 |
+
db.add(cfg)
|
| 535 |
+
cfg.config_value = json.dumps(value, ensure_ascii=False, allow_nan=not strict)
|
| 536 |
+
cfg.updated_by = updated_by
|
| 537 |
+
cfg.updated_at = datetime.datetime.utcnow()
|
| 538 |
+
db.commit()
|
| 539 |
+
except Exception as e:
|
| 540 |
+
if strict:
|
| 541 |
+
# commit异常可能发生在真实提交之后;回滚不能证明尚未保存。
|
| 542 |
+
try:
|
| 543 |
+
db.rollback()
|
| 544 |
+
except Exception:
|
| 545 |
+
logger.warning("SYSTEM_CONFIG_ROLLBACK_UNCONFIRMED")
|
| 546 |
+
raise
|
| 547 |
+
db.rollback()
|
| 548 |
+
logger.warning(f"保存系统配置到数据库失败,降级到文件: error_type={type(e).__name__}")
|
| 549 |
+
# 降级:保存到文件
|
| 550 |
+
config = _load_system_config_from_file()
|
| 551 |
+
config[config_key] = value
|
| 552 |
+
try:
|
| 553 |
+
with open(SYSTEM_CONFIG_FILE, "w") as f:
|
| 554 |
+
json.dump(config, f, ensure_ascii=False)
|
| 555 |
+
except Exception as fe:
|
| 556 |
+
logger.warning(f"降级保存系统配置文件也失败: error_type={type(fe).__name__}")
|
| 557 |
+
|
| 558 |
+
# 兼容旧调用的包装函数
|
| 559 |
+
def _load_system_config(db: Session = None, *, strict: bool = False) -> dict:
|
| 560 |
+
"""加载系统配置(优先数据库,降级文件);严格模式保留故障。"""
|
| 561 |
+
if strict:
|
| 562 |
+
if db is not None:
|
| 563 |
+
return _load_system_config_from_db(db, strict=True)
|
| 564 |
+
temp_db = SessionLocal()
|
| 565 |
+
try:
|
| 566 |
+
return _load_system_config_from_db(temp_db, strict=True)
|
| 567 |
+
finally:
|
| 568 |
+
temp_db.close()
|
| 569 |
+
if db:
|
| 570 |
+
return _load_system_config_from_db(db)
|
| 571 |
+
# 无 db session 时(如公开端点),创建临时 session
|
| 572 |
+
temp_db = SessionLocal()
|
| 573 |
+
try:
|
| 574 |
+
return _load_system_config_from_db(temp_db)
|
| 575 |
+
except Exception:
|
| 576 |
+
return _load_system_config_from_file()
|
| 577 |
+
finally:
|
| 578 |
+
temp_db.close()
|
| 579 |
+
|
| 580 |
+
def _save_system_config(config: dict, db: Session = None, updated_by: str = None):
|
| 581 |
+
"""保存系统配置(优先数据库,降级文件)"""
|
| 582 |
+
if db:
|
| 583 |
+
for key, value in config.items():
|
| 584 |
+
_save_system_config_to_db(db, key, value, updated_by)
|
| 585 |
+
return
|
| 586 |
+
# 无 db session 时,创建临时 session
|
| 587 |
+
temp_db = SessionLocal()
|
| 588 |
+
try:
|
| 589 |
+
for key, value in config.items():
|
| 590 |
+
_save_system_config_to_db(temp_db, key, value, updated_by)
|
| 591 |
+
except Exception:
|
| 592 |
+
pass
|
| 593 |
+
finally:
|
| 594 |
+
temp_db.close()
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
@app.get("/api/admin/config/{config_key}")
|
| 598 |
+
async def get_system_config(config_key: str, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 599 |
+
""" 🔒 获取系统配置(仅管理员) """
|
| 600 |
+
# 🔒 权限验证
|
| 601 |
+
if not _is_admin(current_user):
|
| 602 |
+
raise HTTPException(status_code=403, detail="无权访问系统配置,仅管理员可操作")
|
| 603 |
+
|
| 604 |
+
def reject_constant(_value):
|
| 605 |
+
raise ValueError("invalid configuration constant")
|
| 606 |
+
try:
|
| 607 |
+
# 管理回读用于确认SQL保存结果,不能用另一个来源的旧值代替。
|
| 608 |
+
storage_key = "image_moderation_enabled" if config_key == "image_moderation" else config_key
|
| 609 |
+
row = db.query(SystemConfig).filter(SystemConfig.config_key == storage_key).first()
|
| 610 |
+
value = None if row is None else json.loads(row.config_value, parse_constant=reject_constant)
|
| 611 |
+
if config_key == "image_moderation":
|
| 612 |
+
if row is None:
|
| 613 |
+
from image_moderation import _is_moderation_enabled
|
| 614 |
+
value = _is_moderation_enabled()
|
| 615 |
+
if type(value) is not bool:
|
| 616 |
+
raise ValueError("invalid moderation configuration")
|
| 617 |
+
value = {"enabled": value}
|
| 618 |
+
except Exception:
|
| 619 |
+
raise HTTPException(status_code=503, detail="config_read_unavailable") from None
|
| 620 |
+
return {"status": "success", "data": value}
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
# 🏷️ 版本配置默认值
|
| 624 |
+
DEFAULT_VERSION_CONFIG = {
|
| 625 |
+
"stage": "beta",
|
| 626 |
+
"major": 2,
|
| 627 |
+
"minor": 0,
|
| 628 |
+
"patch": 0
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
@app.get("/api/public/project-version")
|
| 633 |
+
async def get_public_project_version(db: Session = Depends(get_db)):
|
| 634 |
+
"""获取项目版本信息(公开接口,无需认证)"""
|
| 635 |
+
config = _load_system_config(db)
|
| 636 |
+
version_data = config.get("project_version", DEFAULT_VERSION_CONFIG)
|
| 637 |
+
return {"status": "success", "data": version_data}
|
| 638 |
+
|
| 639 |
+
|
| 640 |
+
@app.get("/api/public/banner_config")
|
| 641 |
+
async def get_public_banner_config(db: Session = Depends(get_db)):
|
| 642 |
+
"""公开接口:获取广告横幅配置(所有已登录/未登录用户均可访问)"""
|
| 643 |
+
try:
|
| 644 |
+
config = _load_system_config(db, strict=True)
|
| 645 |
+
if not isinstance(config, dict):
|
| 646 |
+
raise ValueError("invalid system configuration root")
|
| 647 |
+
banner = config.get("banner_config")
|
| 648 |
+
if banner is None:
|
| 649 |
+
return {"status": "success", "data": None}
|
| 650 |
+
if not isinstance(banner, dict) or type(banner.get("enabled")) is not bool:
|
| 651 |
+
raise ValueError("invalid banner configuration")
|
| 652 |
+
if not banner["enabled"]:
|
| 653 |
+
return {"status": "success", "data": None}
|
| 654 |
+
# 只投影展示字段,异常配置不能触发浏览器清除旧横幅。
|
| 655 |
+
result = {"enabled": True}
|
| 656 |
+
for field in ("bannerImage", "title", "description", "detailImage", "detailContent", "link"):
|
| 657 |
+
value = banner.get(field, "")
|
| 658 |
+
if not isinstance(value, str):
|
| 659 |
+
raise ValueError("invalid banner display field")
|
| 660 |
+
result[field] = value
|
| 661 |
+
if not result["bannerImage"].strip():
|
| 662 |
+
raise ValueError("missing banner image")
|
| 663 |
+
return {"status": "success", "data": result}
|
| 664 |
+
except Exception:
|
| 665 |
+
raise HTTPException(status_code=503, detail="banner_config_unavailable") from None
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
@app.put("/api/admin/config/{config_key}")
|
| 669 |
+
async def set_system_config(config_key: str, value: dict, current_user: str = Depends(require_auth), db: Session = Depends(get_db)):
|
| 670 |
+
""" 🔒 设置系统配置(仅管理员) """
|
| 671 |
+
# 🔒 权限验证
|
| 672 |
+
if not _is_admin(current_user):
|
| 673 |
+
raise HTTPException(status_code=403, detail="无权修改系统配置,仅管理员可操作")
|
| 674 |
+
storage_key = config_key
|
| 675 |
+
storage_value = value
|
| 676 |
+
if config_key == "image_moderation":
|
| 677 |
+
if set(value) != {"enabled"} or type(value["enabled"]) is not bool:
|
| 678 |
+
raise HTTPException(status_code=400, detail="invalid_moderation_configuration")
|
| 679 |
+
storage_key = "image_moderation_enabled"
|
| 680 |
+
storage_value = value["enabled"]
|
| 681 |
+
try:
|
| 682 |
+
_save_system_config_to_db(db, storage_key, storage_value, updated_by=current_user, strict=True)
|
| 683 |
+
except Exception:
|
| 684 |
+
raise HTTPException(status_code=503, detail="config_save_unconfirmed") from None
|
| 685 |
+
return {"status": "success", "message": "配置已更新"}
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
# ==========================================
|
| 689 |
+
# 🖥️ 管理界面静态文件挂载
|
| 690 |
+
# ⚠️ 必须放在所有 API 路由注册之后,避免拦截 API
|
| 691 |
+
# 访问 /admin/ 会自动返回 管理界面/index.html
|
| 692 |
+
# ==========================================
|
| 693 |
+
from fastapi.staticfiles import StaticFiles
|
| 694 |
+
app.mount("/admin", StaticFiles(directory="管理界面", html=True), name="admin")
|
| 695 |
+
|
| 696 |
+
# ==========================================
|
| 697 |
+
# 📱 移动端网页静态挂载(复用前端的 Web 版)
|
| 698 |
+
# 部署约定:同步 HF Space 时,除本目录内容外,
|
| 699 |
+
# 须将仓库根目录的 前端页面/ 整个目录一并复制到 Space 仓库根目录
|
| 700 |
+
# /m/assets 提供共享前端模块;/m 返回 移动端网页/index.html
|
| 701 |
+
# ⚠️ 必须放在所有 API 路由之后;先挂载更具体的 /m/assets,再挂载 /m
|
| 702 |
+
# 容错:目录缺失时仅跳过挂载并警告,不阻断 API 服务启动(移动端页面将 404)
|
| 703 |
+
# ==========================================
|
| 704 |
+
if os.path.isdir("前端页面"):
|
| 705 |
+
app.mount("/m/assets", StaticFiles(directory="前端页面"), name="web_assets")
|
| 706 |
+
app.mount("/m", StaticFiles(directory="移动端网页", html=True), name="mobile_web")
|
| 707 |
+
else:
|
| 708 |
+
logger.warning("⚠️ 未找到 前端页面/ 目录,移动端网页 /m/ 未挂载(部署时须将仓库根目录的 前端页面/ 复制到 Space 仓库根目录),API 服务不受影响")
|
auth_io.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""账号同步工作的有界准入与关闭等待。"""
|
| 2 |
+
from fastapi import HTTPException
|
| 3 |
+
from owned_thread import OwnedThreadGroup
|
| 4 |
+
|
| 5 |
+
MAX_AUTH_IO = 8
|
| 6 |
+
MAX_IDENTITY_IO = 32
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class AuthIO(OwnedThreadGroup):
|
| 10 |
+
def __init__(self, capacity=MAX_AUTH_IO):
|
| 11 |
+
super().__init__(capacity, lambda: HTTPException(status_code=503, detail='用户数据暂不可用'))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
_auth_io = AuthIO()
|
| 15 |
+
run_auth_io = _auth_io.run
|
| 16 |
+
start_auth_io = _auth_io.start
|
| 17 |
+
stop_auth_io = _auth_io.stop
|
| 18 |
+
close_auth_io = _auth_io.close
|
| 19 |
+
|
| 20 |
+
_identity_io = AuthIO(MAX_IDENTITY_IO)
|
| 21 |
+
run_identity_io = _identity_io.run
|
| 22 |
+
start_identity_io = _identity_io.start
|
| 23 |
+
stop_identity_io = _identity_io.stop
|
| 24 |
+
close_identity_io = _identity_io.close
|
github_versions.py
CHANGED
|
@@ -1,89 +1,89 @@
|
|
| 1 |
-
"""每资源版本检测票据和局部发布;拒绝较早检测的迟到结果。"""
|
| 2 |
-
import
|
| 3 |
-
import uuid
|
| 4 |
-
from github_archive import commit_sha, source_signature
|
| 5 |
-
from github_source import select_github_token
|
| 6 |
-
from proxy_access import read_proxy_item
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
def _entry(versions, item_id):
|
| 10 |
-
if not isinstance(versions, dict):
|
| 11 |
-
raise ValueError('invalid version source')
|
| 12 |
-
entry = versions.get(item_id)
|
| 13 |
-
if entry is None:
|
| 14 |
-
return {}
|
| 15 |
-
if isinstance(entry, str):
|
| 16 |
-
return {'hash': entry}
|
| 17 |
-
if not isinstance(entry, dict):
|
| 18 |
-
raise ValueError('invalid version record')
|
| 19 |
-
return dict(entry)
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def _without_cache(entry):
|
| 23 |
-
return {key: value for key, value in entry.items()
|
| 24 |
-
if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')}
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
def begin_probe(store, item_id):
|
| 28 |
-
item = read_proxy_item(store, item_id)
|
| 29 |
-
token = select_github_token(item)
|
| 30 |
-
source = source_signature(item, token)
|
| 31 |
-
ticket = uuid.uuid4().hex
|
| 32 |
-
def update(versions):
|
| 33 |
-
entry = _entry(versions, item_id)
|
| 34 |
-
entry['_probe_ticket'] = ticket
|
| 35 |
-
entry['_probe_source'] = source
|
| 36 |
-
versions[item_id] = entry
|
| 37 |
-
store.atomic_update('versions.json', update, default_data={})
|
| 38 |
-
return dict(item=item, token=token, source=source, ticket=ticket)
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
def finish_probe(store, operation, sha):
|
| 42 |
-
sha = commit_sha(sha)
|
| 43 |
-
item_id = operation['item']['id']
|
| 44 |
-
current = read_proxy_item(store, item_id)
|
| 45 |
-
if source_signature(current, select_github_token(current)) != operation['source']:
|
| 46 |
-
return None
|
| 47 |
-
def update(versions):
|
| 48 |
-
entry = _entry(versions, item_id)
|
| 49 |
-
if entry.get('_probe_ticket') != operation['ticket'] or entry.get('_probe_source') != operation['source']:
|
| 50 |
-
return None
|
| 51 |
-
changed = entry.get('hash') != sha[:7]
|
| 52 |
-
if (changed or entry.get('version_source') not in (None, operation['source'])
|
| 53 |
-
or entry.get('version_commit') not in (None, sha)
|
| 54 |
-
or entry.get('archive_commit') not in (None, sha)):
|
| 55 |
-
entry = _without_cache(entry)
|
| 56 |
-
entry.update(hash=sha[:7], version_commit=sha, version_source=operation['source'])
|
| 57 |
-
entry.pop('_probe_ticket', None); entry.pop('_probe_source', None)
|
| 58 |
-
versions[item_id] = entry
|
| 59 |
-
return dict(item=current, token=operation['token'], commit=sha, changed=changed)
|
| 60 |
-
return store.atomic_update('versions.json', update, default_data={})
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def invalidate_source(store, item_id):
|
| 64 |
-
"""旧编辑回调也读取最新资源;已属于最新来源的确认记录保持。"""
|
| 65 |
-
current = read_proxy_item(store, item_id)
|
| 66 |
-
try:
|
| 67 |
-
source = source_signature(current, select_github_token(current))
|
| 68 |
-
except ValueError:
|
| 69 |
-
source = None
|
| 70 |
-
def update(versions):
|
| 71 |
-
entry = _entry(versions, item_id)
|
| 72 |
-
if source is not None and entry.get('version_source') == source:
|
| 73 |
-
return False
|
| 74 |
-
entry = _without_cache(entry)
|
| 75 |
-
for key in ('version_commit', '_probe_ticket', '_probe_source'):
|
| 76 |
-
entry.pop(key, None)
|
| 77 |
-
entry.update(hash='', version_source=source)
|
| 78 |
-
versions[item_id] = entry
|
| 79 |
-
return True
|
| 80 |
-
return store.atomic_update('versions.json', update, default_data={})
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
async def refresh_resource_version(store, item_id):
|
| 84 |
-
from 云端_定时版本检测引擎 import fetch_github_commit
|
| 85 |
-
operation = await
|
| 86 |
-
sha = await fetch_github_commit(operation['item']['link'], operation['token'])
|
| 87 |
-
if not sha:
|
| 88 |
-
return None
|
| 89 |
-
return await
|
|
|
|
| 1 |
+
"""每资源版本检测票据和局部发布;拒绝较早检测的迟到结果。"""
|
| 2 |
+
from owned_thread import run_owned_thread
|
| 3 |
+
import uuid
|
| 4 |
+
from github_archive import commit_sha, source_signature
|
| 5 |
+
from github_source import select_github_token
|
| 6 |
+
from proxy_access import read_proxy_item
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _entry(versions, item_id):
|
| 10 |
+
if not isinstance(versions, dict):
|
| 11 |
+
raise ValueError('invalid version source')
|
| 12 |
+
entry = versions.get(item_id)
|
| 13 |
+
if entry is None:
|
| 14 |
+
return {}
|
| 15 |
+
if isinstance(entry, str):
|
| 16 |
+
return {'hash': entry}
|
| 17 |
+
if not isinstance(entry, dict):
|
| 18 |
+
raise ValueError('invalid version record')
|
| 19 |
+
return dict(entry)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _without_cache(entry):
|
| 23 |
+
return {key: value for key, value in entry.items()
|
| 24 |
+
if key not in ('cached_at', 'zip_size') and not key.startswith('archive_')}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def begin_probe(store, item_id):
|
| 28 |
+
item = read_proxy_item(store, item_id)
|
| 29 |
+
token = select_github_token(item)
|
| 30 |
+
source = source_signature(item, token)
|
| 31 |
+
ticket = uuid.uuid4().hex
|
| 32 |
+
def update(versions):
|
| 33 |
+
entry = _entry(versions, item_id)
|
| 34 |
+
entry['_probe_ticket'] = ticket
|
| 35 |
+
entry['_probe_source'] = source
|
| 36 |
+
versions[item_id] = entry
|
| 37 |
+
store.atomic_update('versions.json', update, default_data={})
|
| 38 |
+
return dict(item=item, token=token, source=source, ticket=ticket)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def finish_probe(store, operation, sha):
|
| 42 |
+
sha = commit_sha(sha)
|
| 43 |
+
item_id = operation['item']['id']
|
| 44 |
+
current = read_proxy_item(store, item_id)
|
| 45 |
+
if source_signature(current, select_github_token(current)) != operation['source']:
|
| 46 |
+
return None
|
| 47 |
+
def update(versions):
|
| 48 |
+
entry = _entry(versions, item_id)
|
| 49 |
+
if entry.get('_probe_ticket') != operation['ticket'] or entry.get('_probe_source') != operation['source']:
|
| 50 |
+
return None
|
| 51 |
+
changed = entry.get('hash') != sha[:7]
|
| 52 |
+
if (changed or entry.get('version_source') not in (None, operation['source'])
|
| 53 |
+
or entry.get('version_commit') not in (None, sha)
|
| 54 |
+
or entry.get('archive_commit') not in (None, sha)):
|
| 55 |
+
entry = _without_cache(entry)
|
| 56 |
+
entry.update(hash=sha[:7], version_commit=sha, version_source=operation['source'])
|
| 57 |
+
entry.pop('_probe_ticket', None); entry.pop('_probe_source', None)
|
| 58 |
+
versions[item_id] = entry
|
| 59 |
+
return dict(item=current, token=operation['token'], commit=sha, changed=changed)
|
| 60 |
+
return store.atomic_update('versions.json', update, default_data={})
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def invalidate_source(store, item_id):
|
| 64 |
+
"""旧编辑回调也读取最新资源;已属于最新来源的确认记录保持。"""
|
| 65 |
+
current = read_proxy_item(store, item_id)
|
| 66 |
+
try:
|
| 67 |
+
source = source_signature(current, select_github_token(current))
|
| 68 |
+
except ValueError:
|
| 69 |
+
source = None
|
| 70 |
+
def update(versions):
|
| 71 |
+
entry = _entry(versions, item_id)
|
| 72 |
+
if source is not None and entry.get('version_source') == source:
|
| 73 |
+
return False
|
| 74 |
+
entry = _without_cache(entry)
|
| 75 |
+
for key in ('version_commit', '_probe_ticket', '_probe_source'):
|
| 76 |
+
entry.pop(key, None)
|
| 77 |
+
entry.update(hash='', version_source=source)
|
| 78 |
+
versions[item_id] = entry
|
| 79 |
+
return True
|
| 80 |
+
return store.atomic_update('versions.json', update, default_data={})
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def refresh_resource_version(store, item_id):
|
| 84 |
+
from 云端_定时版本检测引擎 import fetch_github_commit
|
| 85 |
+
operation = await run_owned_thread(begin_probe, store, item_id)
|
| 86 |
+
sha = await fetch_github_commit(operation['item']['link'], operation['token'])
|
| 87 |
+
if not sha:
|
| 88 |
+
return None
|
| 89 |
+
return await run_owned_thread(finish_probe, store, operation, sha)
|
models_sql.py
CHANGED
|
@@ -5,13 +5,38 @@
|
|
| 5 |
# 🚀 P1性能优化:添加复合索引加速查询
|
| 6 |
# ==========================================
|
| 7 |
|
| 8 |
-
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func, Text
|
| 9 |
from sqlalchemy.orm import declarative_base
|
| 10 |
import datetime
|
| 11 |
|
| 12 |
Base = declarative_base()
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
class ResourcePublication(Base):
|
| 16 |
"""同次资源发布的持久原始请求和恢复内容;不参与资金变更。"""
|
| 17 |
__tablename__ = "resource_publications"
|
|
|
|
| 5 |
# 🚀 P1性能优化:添加复合索引加速查询
|
| 6 |
# ==========================================
|
| 7 |
|
| 8 |
+
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Index, func, Text, DDL, event
|
| 9 |
from sqlalchemy.orm import declarative_base
|
| 10 |
import datetime
|
| 11 |
|
| 12 |
Base = declarative_base()
|
| 13 |
|
| 14 |
|
| 15 |
+
class OpenApiOperation(Base):
|
| 16 |
+
__tablename__ = "open_api_operations"
|
| 17 |
+
request_key = Column(String(64), primary_key=True)
|
| 18 |
+
request_hash = Column(String(64), nullable=False)
|
| 19 |
+
transaction_id = Column(String(100), nullable=False, unique=True)
|
| 20 |
+
response_payload = Column(Text, nullable=False)
|
| 21 |
+
created_at = Column(DateTime, default=datetime.datetime.utcnow, nullable=False)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
event.listen(OpenApiOperation.__table__, "after_create",
|
| 25 |
+
DDL("ALTER TABLE %(fullname)s ENABLE ROW LEVEL SECURITY").execute_if(dialect="postgresql"))
|
| 26 |
+
event.listen(OpenApiOperation.__table__, "after_create",
|
| 27 |
+
DDL("REVOKE ALL ON TABLE %(fullname)s FROM PUBLIC").execute_if(dialect="postgresql"))
|
| 28 |
+
event.listen(OpenApiOperation.__table__, "after_create", DDL("""
|
| 29 |
+
DO $$ BEGIN
|
| 30 |
+
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'anon') THEN
|
| 31 |
+
REVOKE ALL ON TABLE %(fullname)s FROM anon;
|
| 32 |
+
END IF;
|
| 33 |
+
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'authenticated') THEN
|
| 34 |
+
REVOKE ALL ON TABLE %(fullname)s FROM authenticated;
|
| 35 |
+
END IF;
|
| 36 |
+
END; $$
|
| 37 |
+
""").execute_if(dialect="postgresql"))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
class ResourcePublication(Base):
|
| 41 |
"""同次资源发布的持久原始请求和恢复内容;不参与资金变更。"""
|
| 42 |
__tablename__ = "resource_publications"
|
open_transaction.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
| 1 |
"""开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
|
| 2 |
import hmac
|
|
|
|
|
|
|
|
|
|
| 3 |
from decimal import Decimal
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
-
from models_sql import RegisteredPlugin
|
| 7 |
from plugin_permissions import parse_permissions
|
| 8 |
from tip_transaction import lock_wallet_accounts
|
| 9 |
|
|
@@ -43,3 +46,52 @@ def quota_total(value):
|
|
| 43 |
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
| 44 |
raise ValueError("invalid quota history")
|
| 45 |
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""开放资金接口的实时插件授权与钱包写锁;事务由调用方提交/回滚。"""
|
| 2 |
import hmac
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
from decimal import Decimal
|
| 7 |
|
| 8 |
from fastapi import HTTPException
|
| 9 |
+
from models_sql import RegisteredPlugin, OpenApiOperation, Transaction
|
| 10 |
from plugin_permissions import parse_permissions
|
| 11 |
from tip_transaction import lock_wallet_accounts
|
| 12 |
|
|
|
|
| 46 |
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
| 47 |
raise ValueError("invalid quota history")
|
| 48 |
return value
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def open_operation_identity(request_id, account, plugin_id, kind, payload):
|
| 52 |
+
if request_id is None:
|
| 53 |
+
return None
|
| 54 |
+
if not isinstance(request_id, str) or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{7,127}", request_id) is None:
|
| 55 |
+
raise HTTPException(status_code=422, detail="OPEN_REQUEST_ID_INVALID")
|
| 56 |
+
encode = lambda value: json.dumps(value, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8')
|
| 57 |
+
key = hashlib.sha256(encode([plugin_id, account, kind, request_id])).hexdigest()
|
| 58 |
+
fingerprint = hashlib.sha256(encode(payload)).hexdigest()
|
| 59 |
+
return key, fingerprint
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def read_open_operation(db, identity, account, plugin_id, kind, amount, reference_id):
|
| 63 |
+
if identity is None:
|
| 64 |
+
return None
|
| 65 |
+
key, fingerprint = identity
|
| 66 |
+
row = db.get(OpenApiOperation, key)
|
| 67 |
+
if row is None:
|
| 68 |
+
if db.get(Transaction, f'{kind}_{key}') is not None:
|
| 69 |
+
raise HTTPException(status_code=503, detail="OPEN_OPERATION_UNAVAILABLE")
|
| 70 |
+
return None
|
| 71 |
+
if row.request_hash != fingerprint:
|
| 72 |
+
raise HTTPException(status_code=409, detail="OPEN_REQUEST_CONFLICT")
|
| 73 |
+
try:
|
| 74 |
+
response = json.loads(row.response_payload)
|
| 75 |
+
transaction = db.get(Transaction, row.transaction_id)
|
| 76 |
+
if (not isinstance(response, dict) or set(response) != {'success', 'remaining_balance', 'transaction_id'}
|
| 77 |
+
or response['success'] is not True or type(response['remaining_balance']) is not int
|
| 78 |
+
or response['transaction_id'] != row.transaction_id or row.transaction_id != f'{kind}_{key}'
|
| 79 |
+
or transaction is None or transaction.account != account or transaction.tx_type != kind
|
| 80 |
+
or transaction.source_plugin != plugin_id or transaction.related_account != plugin_id
|
| 81 |
+
or type(transaction.amount) is not int or transaction.amount != amount
|
| 82 |
+
or transaction.item_id != reference_id
|
| 83 |
+
or not isinstance(transaction.prev_hash, str)
|
| 84 |
+
or transaction.tx_hash != hashlib.sha256(
|
| 85 |
+
f"{transaction.tx_id}{account}{kind}{amount}{transaction.prev_hash}".encode()).hexdigest()):
|
| 86 |
+
raise ValueError('invalid open operation')
|
| 87 |
+
except Exception:
|
| 88 |
+
raise HTTPException(status_code=503, detail="OPEN_OPERATION_UNAVAILABLE")
|
| 89 |
+
return response
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def save_open_operation(db, identity, response):
|
| 93 |
+
if identity is not None:
|
| 94 |
+
key, fingerprint = identity
|
| 95 |
+
db.add(OpenApiOperation(request_key=key, request_hash=fingerprint,
|
| 96 |
+
transaction_id=response['transaction_id'],
|
| 97 |
+
response_payload=json.dumps(response, ensure_ascii=False, separators=(',', ':'), allow_nan=False)))
|
owned_thread.py
CHANGED
|
@@ -73,3 +73,59 @@ async def run_owned_thread(function, *args, **kwargs):
|
|
| 73 |
if not succeeded:
|
| 74 |
raise result
|
| 75 |
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
if not succeeded:
|
| 74 |
raise result
|
| 75 |
return result
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class OwnedThreadGroup:
|
| 79 |
+
def __init__(self, capacity, unavailable):
|
| 80 |
+
if type(capacity) is not int or capacity < 1:
|
| 81 |
+
raise ValueError('invalid owned thread capacity')
|
| 82 |
+
self._capacity = threading.BoundedSemaphore(capacity)
|
| 83 |
+
self._unavailable = unavailable
|
| 84 |
+
self._gate = threading.Lock()
|
| 85 |
+
self._accepting = True
|
| 86 |
+
self._pending = 0
|
| 87 |
+
self._closers = 0
|
| 88 |
+
self._drained = threading.Event()
|
| 89 |
+
self._drained.set()
|
| 90 |
+
|
| 91 |
+
def start(self):
|
| 92 |
+
with self._gate:
|
| 93 |
+
if self._pending or self._closers:
|
| 94 |
+
raise RuntimeError('owned work is still pending')
|
| 95 |
+
self._accepting = True
|
| 96 |
+
|
| 97 |
+
def stop(self):
|
| 98 |
+
with self._gate:
|
| 99 |
+
self._accepting = False
|
| 100 |
+
|
| 101 |
+
async def close(self):
|
| 102 |
+
with self._gate:
|
| 103 |
+
self._accepting = False
|
| 104 |
+
self._closers += 1
|
| 105 |
+
try:
|
| 106 |
+
cancelled = False
|
| 107 |
+
while not self._drained.is_set():
|
| 108 |
+
try:
|
| 109 |
+
await asyncio.sleep(.01)
|
| 110 |
+
except asyncio.CancelledError:
|
| 111 |
+
cancelled = True
|
| 112 |
+
if cancelled:
|
| 113 |
+
raise asyncio.CancelledError
|
| 114 |
+
finally:
|
| 115 |
+
with self._gate:
|
| 116 |
+
self._closers -= 1
|
| 117 |
+
|
| 118 |
+
async def run(self, function, *args, **kwargs):
|
| 119 |
+
with self._gate:
|
| 120 |
+
if not self._accepting or not self._capacity.acquire(blocking=False):
|
| 121 |
+
raise self._unavailable()
|
| 122 |
+
self._pending += 1
|
| 123 |
+
self._drained.clear()
|
| 124 |
+
try:
|
| 125 |
+
return await run_owned_thread(function, *args, **kwargs)
|
| 126 |
+
finally:
|
| 127 |
+
with self._gate:
|
| 128 |
+
self._capacity.release()
|
| 129 |
+
self._pending -= 1
|
| 130 |
+
if not self._pending:
|
| 131 |
+
self._drained.set()
|
rate_limit.py
CHANGED
|
@@ -11,7 +11,8 @@ import time
|
|
| 11 |
|
| 12 |
from fastapi import Request
|
| 13 |
from fastapi import HTTPException
|
| 14 |
-
from
|
|
|
|
| 15 |
from sqlalchemy import case, delete
|
| 16 |
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
| 17 |
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
|
@@ -26,6 +27,14 @@ logger = logging.getLogger("ComfyUI-Ranking.RateLimit")
|
|
| 26 |
_cleanup_lock = threading.Lock()
|
| 27 |
_last_cleanup_at = 0.0
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
def get_remote_address(request: Request) -> str:
|
| 31 |
"""仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
|
|
@@ -181,11 +190,11 @@ def shared_limit(limit: int):
|
|
| 181 |
request = next((arg for arg in args if isinstance(arg, Request)), None)
|
| 182 |
if request is None:
|
| 183 |
raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
|
| 184 |
-
identity = await
|
| 185 |
-
await
|
| 186 |
if is_async:
|
| 187 |
return await endpoint(*args, **kwargs)
|
| 188 |
-
return await
|
| 189 |
|
| 190 |
return wrapped
|
| 191 |
|
|
|
|
| 11 |
|
| 12 |
from fastapi import Request
|
| 13 |
from fastapi import HTTPException
|
| 14 |
+
from auth_io import run_identity_io
|
| 15 |
+
from owned_thread import OwnedThreadGroup
|
| 16 |
from sqlalchemy import case, delete
|
| 17 |
from sqlalchemy.dialects.mysql import insert as mysql_insert
|
| 18 |
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
|
|
|
|
| 27 |
_cleanup_lock = threading.Lock()
|
| 28 |
_last_cleanup_at = 0.0
|
| 29 |
|
| 30 |
+
MAX_SHARED_WORK = 40
|
| 31 |
+
_shared_work = OwnedThreadGroup(MAX_SHARED_WORK,
|
| 32 |
+
lambda: HTTPException(status_code=503, detail='限流服务暂不可用'))
|
| 33 |
+
run_shared_work = _shared_work.run
|
| 34 |
+
start_shared_work = _shared_work.start
|
| 35 |
+
stop_shared_work = _shared_work.stop
|
| 36 |
+
close_shared_work = _shared_work.close
|
| 37 |
+
|
| 38 |
|
| 39 |
def get_remote_address(request: Request) -> str:
|
| 40 |
"""仅信任 ASGI transport 提供的客户端地址,不直接信任可伪造的代理头。"""
|
|
|
|
| 190 |
request = next((arg for arg in args if isinstance(arg, Request)), None)
|
| 191 |
if request is None:
|
| 192 |
raise RuntimeError(f"{scope} requires a FastAPI Request parameter")
|
| 193 |
+
identity = await run_identity_io(get_rate_limit_key, request)
|
| 194 |
+
await run_shared_work(_consume_shared_limit, identity, scope, limit)
|
| 195 |
if is_async:
|
| 196 |
return await endpoint(*args, **kwargs)
|
| 197 |
+
return await run_shared_work(endpoint, *args, **kwargs)
|
| 198 |
|
| 199 |
return wrapped
|
| 200 |
|
request_body_guard.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""普通API提交在解析前有界接收,原始字节由请求持有到应用结束。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import math
|
| 4 |
+
import re
|
| 5 |
+
import threading
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
from starlette.responses import JSONResponse
|
| 9 |
+
|
| 10 |
+
REQUEST_BODY_MAX_BYTES = 2 * 1024 * 1024
|
| 11 |
+
REQUEST_BODY_CAPACITY = 32
|
| 12 |
+
REQUEST_BODY_IDLE_SECONDS = 10
|
| 13 |
+
REQUEST_BODY_TOTAL_SECONDS = 30
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def _receive_owned(receive, timeout):
|
| 17 |
+
owner = asyncio.current_task()
|
| 18 |
+
initial_cancels = owner.cancelling()
|
| 19 |
+
task = asyncio.create_task(receive())
|
| 20 |
+
cancelled = None
|
| 21 |
+
expired = False
|
| 22 |
+
try:
|
| 23 |
+
done, _ = await asyncio.wait((task,), timeout=timeout)
|
| 24 |
+
if not done:
|
| 25 |
+
expired = True
|
| 26 |
+
task.cancel()
|
| 27 |
+
except asyncio.CancelledError as error:
|
| 28 |
+
cancelled = error
|
| 29 |
+
task.cancel()
|
| 30 |
+
while not task.done():
|
| 31 |
+
try:
|
| 32 |
+
await asyncio.shield(task)
|
| 33 |
+
except asyncio.CancelledError as error:
|
| 34 |
+
if owner.cancelling() > initial_cancels:
|
| 35 |
+
cancelled = error
|
| 36 |
+
except BaseException:
|
| 37 |
+
break
|
| 38 |
+
if not task.cancelled():
|
| 39 |
+
task.exception()
|
| 40 |
+
if cancelled is not None:
|
| 41 |
+
raise cancelled
|
| 42 |
+
if expired:
|
| 43 |
+
raise TimeoutError
|
| 44 |
+
return task.result()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class RequestBodyGuard:
|
| 48 |
+
def __init__(self, app, *, capacity=REQUEST_BODY_CAPACITY, max_bytes=REQUEST_BODY_MAX_BYTES,
|
| 49 |
+
idle_seconds=REQUEST_BODY_IDLE_SECONDS, total_seconds=REQUEST_BODY_TOTAL_SECONDS,
|
| 50 |
+
error_response=None):
|
| 51 |
+
if (type(capacity) is not int or not 1 <= capacity <= 128
|
| 52 |
+
or type(max_bytes) is not int or max_bytes <= 0
|
| 53 |
+
or any(type(value) not in (int, float) or not math.isfinite(value) or value <= 0
|
| 54 |
+
for value in (idle_seconds, total_seconds))):
|
| 55 |
+
raise ValueError('invalid request body budget')
|
| 56 |
+
self.app, self.max_bytes = app, max_bytes
|
| 57 |
+
self.idle_seconds, self.total_seconds = idle_seconds, total_seconds
|
| 58 |
+
self.slots = threading.BoundedSemaphore(capacity)
|
| 59 |
+
self.error_response = error_response
|
| 60 |
+
|
| 61 |
+
async def reject(self, scope, receive, send, status, detail, category):
|
| 62 |
+
content = (self.error_response(status, detail, category) if self.error_response else
|
| 63 |
+
{'success': False, 'status': 'error', 'code': status, 'detail': detail, 'type': category})
|
| 64 |
+
await JSONResponse(content, status_code=status, headers={
|
| 65 |
+
'Cache-Control': 'no-store', 'Vary': 'Authorization'})(scope, receive, send)
|
| 66 |
+
|
| 67 |
+
async def __call__(self, scope, receive, send):
|
| 68 |
+
path = scope.get('path', '')
|
| 69 |
+
if (scope['type'] != 'http' or scope.get('method') not in {'POST', 'PUT', 'PATCH', 'DELETE'}
|
| 70 |
+
or not path.startswith('/api/') or path.rstrip('/') == '/api/upload'):
|
| 71 |
+
return await self.app(scope, receive, send)
|
| 72 |
+
headers = scope.get('headers', ())
|
| 73 |
+
lengths = [value.strip() for name, value in headers if name.lower() == b'content-length']
|
| 74 |
+
transfers = [value.strip().lower() for name, value in headers if name.lower() == b'transfer-encoding']
|
| 75 |
+
encodings = [value.strip().lower() for name, value in headers if name.lower() == b'content-encoding']
|
| 76 |
+
if (len(lengths) > 1 or lengths and (re.fullmatch(rb'[0-9]{1,20}', lengths[0]) is None or transfers)
|
| 77 |
+
or len(transfers) > 1 or transfers and transfers[0] != b'chunked'):
|
| 78 |
+
return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
|
| 79 |
+
if len(encodings) > 1 or encodings and encodings[0] != b'identity':
|
| 80 |
+
return await self.reject(scope, receive, send, 415, 'request_body_encoding', 'unsupported_media_type')
|
| 81 |
+
declared = int(lengths[0]) if lengths else None
|
| 82 |
+
if declared is not None and declared > self.max_bytes:
|
| 83 |
+
return await self.reject(scope, receive, send, 413, 'request_body_too_large', 'payload_too_large')
|
| 84 |
+
if not self.slots.acquire(blocking=False):
|
| 85 |
+
return await self.reject(scope, receive, send, 429, 'request_body_busy', 'rate_limited')
|
| 86 |
+
try:
|
| 87 |
+
buffer = bytearray()
|
| 88 |
+
deadline = time.monotonic() + self.total_seconds
|
| 89 |
+
while True:
|
| 90 |
+
remaining = deadline - time.monotonic()
|
| 91 |
+
if remaining <= 0:
|
| 92 |
+
return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
|
| 93 |
+
try:
|
| 94 |
+
message = await _receive_owned(receive, min(remaining, self.idle_seconds))
|
| 95 |
+
except TimeoutError:
|
| 96 |
+
return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
|
| 97 |
+
except ConnectionError:
|
| 98 |
+
return await self.reject(scope, receive, send, 400, 'request_body_disconnected', 'bad_request')
|
| 99 |
+
if time.monotonic() > deadline:
|
| 100 |
+
return await self.reject(scope, receive, send, 408, 'request_body_timeout', 'request_timeout')
|
| 101 |
+
if message.get('type') == 'http.disconnect':
|
| 102 |
+
return await self.reject(scope, receive, send, 400, 'request_body_disconnected', 'bad_request')
|
| 103 |
+
chunk = message.get('body', b'')
|
| 104 |
+
more = message.get('more_body', False)
|
| 105 |
+
if message.get('type') != 'http.request' or type(chunk) is not bytes or type(more) is not bool:
|
| 106 |
+
return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
|
| 107 |
+
if len(buffer) + len(chunk) > self.max_bytes:
|
| 108 |
+
return await self.reject(scope, receive, send, 413, 'request_body_too_large', 'payload_too_large')
|
| 109 |
+
if declared is not None and len(buffer) + len(chunk) > declared:
|
| 110 |
+
return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
|
| 111 |
+
buffer.extend(chunk)
|
| 112 |
+
if not more:
|
| 113 |
+
break
|
| 114 |
+
if declared is not None and len(buffer) != declared:
|
| 115 |
+
return await self.reject(scope, receive, send, 400, 'request_body_invalid', 'bad_request')
|
| 116 |
+
raw = bytes(buffer)
|
| 117 |
+
buffer.clear()
|
| 118 |
+
delivered = False
|
| 119 |
+
async def buffered_receive():
|
| 120 |
+
nonlocal delivered
|
| 121 |
+
if not delivered:
|
| 122 |
+
delivered = True
|
| 123 |
+
return {'type': 'http.request', 'body': raw, 'more_body': False}
|
| 124 |
+
return await receive()
|
| 125 |
+
return await self.app(scope, buffered_receive, send)
|
| 126 |
+
finally:
|
| 127 |
+
self.slots.release()
|
resource_work.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""资源写入与可选预缓存的准入和关闭归属。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
from owned_thread import OwnedThreadGroup
|
| 7 |
+
|
| 8 |
+
MAX_RESOURCE_WRITES = 8
|
| 9 |
+
MAX_RESOURCE_BACKGROUND = 2
|
| 10 |
+
logger = logging.getLogger('ComfyUI-Ranking.ResourceWork')
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ResourceWork:
|
| 14 |
+
def __init__(self, capacity=MAX_RESOURCE_WRITES, background_capacity=MAX_RESOURCE_BACKGROUND):
|
| 15 |
+
if type(background_capacity) is not int or background_capacity < 1:
|
| 16 |
+
raise ValueError('invalid resource background capacity')
|
| 17 |
+
self.writes = OwnedThreadGroup(capacity,
|
| 18 |
+
lambda: HTTPException(status_code=503, detail='资源保存暂时无法核实,请稍后核对'))
|
| 19 |
+
self.background_capacity = background_capacity
|
| 20 |
+
self._tasks = set()
|
| 21 |
+
self._accepting = True
|
| 22 |
+
self._closers = 0
|
| 23 |
+
|
| 24 |
+
def start(self):
|
| 25 |
+
if self._tasks or self._closers:
|
| 26 |
+
raise RuntimeError('resource work is still pending')
|
| 27 |
+
self.writes.start()
|
| 28 |
+
self._accepting = True
|
| 29 |
+
|
| 30 |
+
def stop(self):
|
| 31 |
+
self._accepting = False
|
| 32 |
+
self.writes.stop()
|
| 33 |
+
|
| 34 |
+
async def run(self, function, *args, **kwargs):
|
| 35 |
+
return await self.writes.run(function, *args, **kwargs)
|
| 36 |
+
|
| 37 |
+
def background(self, factory):
|
| 38 |
+
if not self._accepting or len(self._tasks) >= self.background_capacity:
|
| 39 |
+
return False
|
| 40 |
+
coroutine = None
|
| 41 |
+
try:
|
| 42 |
+
coroutine = factory()
|
| 43 |
+
task = asyncio.create_task(coroutine)
|
| 44 |
+
except BaseException:
|
| 45 |
+
if coroutine is not None:
|
| 46 |
+
coroutine.close()
|
| 47 |
+
raise
|
| 48 |
+
self._tasks.add(task)
|
| 49 |
+
task.add_done_callback(self._completed)
|
| 50 |
+
return True
|
| 51 |
+
|
| 52 |
+
def _completed(self, task):
|
| 53 |
+
self._tasks.discard(task)
|
| 54 |
+
if task.cancelled():
|
| 55 |
+
return
|
| 56 |
+
error = task.exception()
|
| 57 |
+
if error is not None:
|
| 58 |
+
try:
|
| 59 |
+
logger.error('RESOURCE_BACKGROUND_FAILED | error_type=%s', type(error).__name__)
|
| 60 |
+
except Exception:
|
| 61 |
+
pass
|
| 62 |
+
|
| 63 |
+
async def close(self):
|
| 64 |
+
self.stop()
|
| 65 |
+
self._closers += 1
|
| 66 |
+
cancelled = False
|
| 67 |
+
try:
|
| 68 |
+
try:
|
| 69 |
+
await self.writes.close()
|
| 70 |
+
except asyncio.CancelledError:
|
| 71 |
+
cancelled = True
|
| 72 |
+
while self._tasks:
|
| 73 |
+
try:
|
| 74 |
+
await asyncio.sleep(.01)
|
| 75 |
+
except asyncio.CancelledError:
|
| 76 |
+
cancelled = True
|
| 77 |
+
if cancelled:
|
| 78 |
+
raise asyncio.CancelledError
|
| 79 |
+
finally:
|
| 80 |
+
self._closers -= 1
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
_resource_work = ResourceWork()
|
| 84 |
+
run_resource_write = _resource_work.run
|
| 85 |
+
start_resource_background = _resource_work.background
|
| 86 |
+
start_resource_work = _resource_work.start
|
| 87 |
+
stop_resource_work = _resource_work.stop
|
| 88 |
+
close_resource_work = _resource_work.close
|
router_items.py
CHANGED
|
@@ -1,786 +1,807 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
from
|
| 4 |
-
import
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
| 10 |
-
import urllib.
|
| 11 |
-
import
|
| 12 |
-
import
|
| 13 |
-
import
|
| 14 |
-
|
| 15 |
-
from
|
| 16 |
-
from
|
| 17 |
-
from
|
| 18 |
-
from
|
| 19 |
-
from
|
| 20 |
-
from
|
| 21 |
-
from
|
| 22 |
-
from
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
item['
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
reason
|
| 63 |
-
kinds.get(type(item.get('
|
| 64 |
-
kinds.get(type(item.get('
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
item_data["
|
| 132 |
-
item_data["
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
item_data.
|
| 137 |
-
item_data.pop("
|
| 138 |
-
item_data.pop("
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
"
|
| 229 |
-
"
|
| 230 |
-
"
|
| 231 |
-
"
|
| 232 |
-
"
|
| 233 |
-
"
|
| 234 |
-
"
|
| 235 |
-
"
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
elif sort == "
|
| 242 |
-
elif sort == "
|
| 243 |
-
elif sort == "
|
| 244 |
-
elif sort == "
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
"
|
| 292 |
-
"
|
| 293 |
-
"
|
| 294 |
-
"
|
| 295 |
-
"
|
| 296 |
-
"
|
| 297 |
-
"
|
| 298 |
-
"
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
elif sort == "
|
| 304 |
-
elif sort == "
|
| 305 |
-
elif sort == "
|
| 306 |
-
elif sort == "
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
"
|
| 339 |
-
|
| 340 |
-
"
|
| 341 |
-
"
|
| 342 |
-
"
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
"
|
| 358 |
-
|
| 359 |
-
"
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
@
|
| 537 |
-
async def
|
| 538 |
-
"""
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
"""
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
#
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
""
|
| 605 |
-
|
| 606 |
-
if
|
| 607 |
-
raise HTTPException(status_code=
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from resource_work import run_resource_write, start_resource_background
|
| 2 |
+
# router_items.py
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 4 |
+
from rate_limit import shared_limit
|
| 5 |
+
import time
|
| 6 |
+
import uuid
|
| 7 |
+
import datetime
|
| 8 |
+
import os
|
| 9 |
+
import re
|
| 10 |
+
import urllib.request
|
| 11 |
+
import urllib.error
|
| 12 |
+
import json
|
| 13 |
+
import asyncio
|
| 14 |
+
import 数据库连接 as db
|
| 15 |
+
from models import ItemCreate, ItemUpdate, RatingRequest
|
| 16 |
+
from 安全认证 import require_auth, check_ownership, is_admin
|
| 17 |
+
from 数据库连接 import invalidate_cache
|
| 18 |
+
from db_utils import read_public_source, record_view, sort_cache
|
| 19 |
+
from github_source import repository_parts, select_github_token
|
| 20 |
+
from github_versions import refresh_resource_version, invalidate_source
|
| 21 |
+
from resource_store import publish_resource, published_response, edit_resource, ResourceStoreError
|
| 22 |
+
from purchase_transaction import effective_resource_price
|
| 23 |
+
from resource_deletion import delete_resource
|
| 24 |
+
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
def safe_str(val):
|
| 28 |
+
"""安全转换为字符串"""
|
| 29 |
+
if val is None:
|
| 30 |
+
return ""
|
| 31 |
+
return str(val).lower()
|
| 32 |
+
|
| 33 |
+
def _get_version_str(versions_db: dict, item_id: str) -> str:
|
| 34 |
+
"""兼容新旧格式获取版本hash字符串"""
|
| 35 |
+
val = versions_db.get(item_id, "")
|
| 36 |
+
if isinstance(val, dict):
|
| 37 |
+
return val.get("hash", "") or ""
|
| 38 |
+
return val or ""
|
| 39 |
+
|
| 40 |
+
def _apply_effective_price(item: dict) -> None:
|
| 41 |
+
"""计算当前有效价格;调用者须传响应副本或处于编辑锁内。"""
|
| 42 |
+
try:
|
| 43 |
+
price = effective_resource_price(item)
|
| 44 |
+
pending = item.get('pending_price')
|
| 45 |
+
effective = item.get('pending_price_effective_at')
|
| 46 |
+
if type(pending) in (int, float) and pending == price and effective:
|
| 47 |
+
when = datetime.datetime.fromisoformat(effective)
|
| 48 |
+
now = datetime.datetime.now(when.tzinfo) if when.tzinfo else datetime.datetime.now()
|
| 49 |
+
if now >= when:
|
| 50 |
+
item['pending_price'] = None
|
| 51 |
+
item['pending_price_effective_at'] = None
|
| 52 |
+
item['price'] = price
|
| 53 |
+
except (ValueError, TypeError) as error:
|
| 54 |
+
import logging
|
| 55 |
+
kinds = {type(None): 'null', bool: 'boolean', int: 'integer', float: 'number',
|
| 56 |
+
str: 'string', list: 'array', dict: 'object'}
|
| 57 |
+
reason = ('invalid_effective_price' if len(error.args) == 1
|
| 58 |
+
and type(error.args[0]) is str and error.args[0] == 'invalid resource price'
|
| 59 |
+
else 'invalid_effective_time')
|
| 60 |
+
try:
|
| 61 |
+
logging.getLogger('ComfyUI-Ranking.Items').warning(
|
| 62 |
+
'RESOURCE_PRICE_INVALID | reason=%s | price_type=%s | pending_price_type=%s | effective_time_type=%s',
|
| 63 |
+
reason, kinds.get(type(item.get('price', 0)), 'unknown'),
|
| 64 |
+
kinds.get(type(item.get('pending_price')), 'unknown'),
|
| 65 |
+
kinds.get(type(item.get('pending_price_effective_at')), 'unknown'))
|
| 66 |
+
except Exception:
|
| 67 |
+
pass
|
| 68 |
+
raise HTTPException(status_code=503, detail="资源价格暂时无法核实") from None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def get_last_6_months():
|
| 72 |
+
res = []
|
| 73 |
+
today = datetime.date.today()
|
| 74 |
+
for i in range(5, -1, -1):
|
| 75 |
+
m = today.month - i
|
| 76 |
+
y = today.year
|
| 77 |
+
while m <= 0:
|
| 78 |
+
m += 12
|
| 79 |
+
y -= 1
|
| 80 |
+
res.append(f"{y}-{m:02d}")
|
| 81 |
+
return res
|
| 82 |
+
|
| 83 |
+
# 数据文件标识,用于排序缓存
|
| 84 |
+
data_file = "items.json"
|
| 85 |
+
|
| 86 |
+
@router.get("/api/items")
|
| 87 |
+
async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
|
| 88 |
+
read_epoch = sort_cache.capture_read_epoch()
|
| 89 |
+
items_db = read_public_source(db, "items.json", default_data=[])
|
| 90 |
+
comments_db = read_public_source(db, "comments.json", default_data={})
|
| 91 |
+
versions_db = read_public_source(db, "versions.json", default_data={}) # 读取版本库
|
| 92 |
+
|
| 93 |
+
# 如果是推荐榜,匹配所有 recommend 开头的子类型
|
| 94 |
+
if type == "recommend":
|
| 95 |
+
filtered_items = [item for item in items_db if item.get("type", "").startswith("recommend")]
|
| 96 |
+
else:
|
| 97 |
+
filtered_items = [item for item in items_db if item.get("type") == type]
|
| 98 |
+
|
| 99 |
+
# 🗂️ 使用排序缓存优化排序性能
|
| 100 |
+
cache_sort = sort if sort in ("time", "likes", "favorites", "downloads", "tips", "views", "daily_views", "rating") else "time"
|
| 101 |
+
cache_key = f"items:{type}:{cache_sort}"
|
| 102 |
+
|
| 103 |
+
current_month = datetime.date.today().strftime("%Y-%m") if sort == "tips" else None
|
| 104 |
+
def order_key(item):
|
| 105 |
+
if sort == "likes":
|
| 106 |
+
return item.get("likes", 0)
|
| 107 |
+
elif sort == "favorites":
|
| 108 |
+
return item.get("favorites", 0)
|
| 109 |
+
elif sort == "downloads":
|
| 110 |
+
return item.get("uses", 0)
|
| 111 |
+
elif sort == "tips": # 🚀 按近期打赏排序
|
| 112 |
+
return item.get("tip_history", {}).get(current_month, 0)
|
| 113 |
+
elif sort == "views": # 👁️ 按总访问量排序
|
| 114 |
+
return item.get("views", 0)
|
| 115 |
+
elif sort == "daily_views": # 👁️ 按日访问量排序
|
| 116 |
+
return item.get("daily_views", 0)
|
| 117 |
+
elif sort == "rating": # ⭐ 按评分排序
|
| 118 |
+
return (item.get("rating_avg", 0), item.get("rating_count", 0))
|
| 119 |
+
else: # time 或其他默认
|
| 120 |
+
return item.get("created_at", 0)
|
| 121 |
+
|
| 122 |
+
def sort_fn(data):
|
| 123 |
+
data.sort(key=order_key, reverse=True)
|
| 124 |
+
|
| 125 |
+
filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn, read_epoch=read_epoch, fresh=True)
|
| 126 |
+
|
| 127 |
+
result_items = []
|
| 128 |
+
for item in filtered_items:
|
| 129 |
+
item_data = {**item}
|
| 130 |
+
_apply_effective_price(item_data)
|
| 131 |
+
item_data["commentsData"] = comments_db.get(item_data["id"], [])
|
| 132 |
+
item_data["comments"] = len(item_data["commentsData"])
|
| 133 |
+
item_data["latest_version"] = _get_version_str(versions_db, item_data["id"])
|
| 134 |
+
|
| 135 |
+
# 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息!
|
| 136 |
+
item_data["has_private_token"] = bool(item_data.get("github_token"))
|
| 137 |
+
item_data.pop("github_token", None)
|
| 138 |
+
item_data.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示
|
| 139 |
+
item_data.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端
|
| 140 |
+
result_items.append(item_data)
|
| 141 |
+
|
| 142 |
+
return {"status": "success", "data": result_items[:limit]}
|
| 143 |
+
|
| 144 |
+
def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict:
|
| 145 |
+
"""
|
| 146 |
+
构建创作者趋势数据
|
| 147 |
+
提取为独立函数供列表接口和详情接口复用
|
| 148 |
+
"""
|
| 149 |
+
trend_tools = {m: 0 for m in months}
|
| 150 |
+
trend_skills = {m: 0 for m in months}
|
| 151 |
+
trend_apps = {m: 0 for m in months}
|
| 152 |
+
trend_recommends = {m: 0 for m in months}
|
| 153 |
+
|
| 154 |
+
for i in u_items:
|
| 155 |
+
itype = i.get("type", "")
|
| 156 |
+
history = i.get("use_history", {})
|
| 157 |
+
if itype == "tool" or itype == "recommend_tool":
|
| 158 |
+
for m, v in history.items():
|
| 159 |
+
if m in trend_tools: trend_tools[m] += v
|
| 160 |
+
elif itype == "skill" or itype == "recommend_skill":
|
| 161 |
+
for m, v in history.items():
|
| 162 |
+
if m in trend_skills: trend_skills[m] += v
|
| 163 |
+
elif itype == "app" or itype == "recommend_app":
|
| 164 |
+
for m, v in history.items():
|
| 165 |
+
if m in trend_apps: trend_apps[m] += v
|
| 166 |
+
elif itype.startswith("recommend"):
|
| 167 |
+
for m, v in history.items():
|
| 168 |
+
if m in trend_recommends: trend_recommends[m] += v
|
| 169 |
+
|
| 170 |
+
return {
|
| 171 |
+
"months": months,
|
| 172 |
+
"tools": [trend_tools[m] for m in months],
|
| 173 |
+
"skills": [trend_skills[m] for m in months],
|
| 174 |
+
"apps": [trend_apps[m] for m in months],
|
| 175 |
+
"recommends": [trend_recommends[m] for m in months]
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
@router.get("/api/creators/search")
|
| 180 |
+
async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50):
|
| 181 |
+
"""
|
| 182 |
+
搜索创作者
|
| 183 |
+
根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配)
|
| 184 |
+
"""
|
| 185 |
+
users_db = read_public_source(db, "users.json", default_data={})
|
| 186 |
+
items_db = read_public_source(db, "items.json", default_data=[])
|
| 187 |
+
|
| 188 |
+
# 🚀 P1性能优化:预构建 author->items 索引
|
| 189 |
+
author_items_index = {}
|
| 190 |
+
for item in items_db:
|
| 191 |
+
author = item.get("author")
|
| 192 |
+
if author:
|
| 193 |
+
if author not in author_items_index:
|
| 194 |
+
author_items_index[author] = []
|
| 195 |
+
author_items_index[author].append(item)
|
| 196 |
+
|
| 197 |
+
creators = []
|
| 198 |
+
months = get_last_6_months()
|
| 199 |
+
keyword_lower = keyword.lower()
|
| 200 |
+
|
| 201 |
+
for account, u in users_db.items():
|
| 202 |
+
# 🚀 P1性能优化:直接从索引获取
|
| 203 |
+
u_items = author_items_index.get(account, [])
|
| 204 |
+
|
| 205 |
+
# 获取搜索字段并转为小写
|
| 206 |
+
name = u.get("name", account)
|
| 207 |
+
short_desc = u.get("shortDesc") or u.get("intro") or ""
|
| 208 |
+
|
| 209 |
+
# 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段)
|
| 210 |
+
search_text = f"{safe_str(name)} {safe_str(account)} {safe_str(short_desc)} {safe_str(u.get('intro'))} {safe_str(u.get('shortDesc'))}"
|
| 211 |
+
if keyword_lower not in search_text:
|
| 212 |
+
continue
|
| 213 |
+
|
| 214 |
+
tools_count = 0
|
| 215 |
+
skills_count = 0
|
| 216 |
+
apps_count = 0
|
| 217 |
+
|
| 218 |
+
for i in u_items:
|
| 219 |
+
itype = i.get("type", "")
|
| 220 |
+
if itype == "tool":
|
| 221 |
+
tools_count += 1
|
| 222 |
+
elif itype == "skill":
|
| 223 |
+
skills_count += 1
|
| 224 |
+
elif itype == "app":
|
| 225 |
+
apps_count += 1
|
| 226 |
+
|
| 227 |
+
creators.append({
|
| 228 |
+
"account": account, "name": name, "avatar": u.get("avatarDataUrl", ""),
|
| 229 |
+
"bannerUrl": u.get("bannerUrl"),
|
| 230 |
+
"shortDesc": short_desc, "fullDesc": short_desc,
|
| 231 |
+
"likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
|
| 232 |
+
"downloads": sum(i.get("uses", 0) for i in u_items),
|
| 233 |
+
"views": sum(i.get("views", 0) for i in u_items),
|
| 234 |
+
"daily_views": sum(i.get("daily_views", 0) for i in u_items),
|
| 235 |
+
"toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
|
| 236 |
+
"recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0),
|
| 237 |
+
})
|
| 238 |
+
|
| 239 |
+
# 排序逻辑与 /api/creators 保持一致
|
| 240 |
+
if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 241 |
+
elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 242 |
+
elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
|
| 243 |
+
elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True)
|
| 244 |
+
elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
|
| 245 |
+
elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
|
| 246 |
+
else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 247 |
+
|
| 248 |
+
return {"status": "success", "data": creators[:limit]}
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
@router.get("/api/creators")
|
| 252 |
+
async def get_creators(sort: str = "downloads", limit: int = 100):
|
| 253 |
+
"""
|
| 254 |
+
获取创作者列表
|
| 255 |
+
🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询
|
| 256 |
+
⚡ P2性能优化:移除大字段(trendData、commentsData、tip_board),移至详情接口按需加载
|
| 257 |
+
"""
|
| 258 |
+
users_db = read_public_source(db, "users.json", default_data={})
|
| 259 |
+
items_db = read_public_source(db, "items.json", default_data=[])
|
| 260 |
+
|
| 261 |
+
# 🚀 P1性能优化:预构建 author->items 索引,复杂度从 O(n*m) 降到 O(n+m)
|
| 262 |
+
author_items_index = {}
|
| 263 |
+
for item in items_db:
|
| 264 |
+
author = item.get("author")
|
| 265 |
+
if author:
|
| 266 |
+
if author not in author_items_index:
|
| 267 |
+
author_items_index[author] = []
|
| 268 |
+
author_items_index[author].append(item)
|
| 269 |
+
|
| 270 |
+
creators = []
|
| 271 |
+
months = get_last_6_months()
|
| 272 |
+
|
| 273 |
+
for account, u in users_db.items():
|
| 274 |
+
# 🚀 P1性能优化:直接从索引获取,而非遍历全表
|
| 275 |
+
u_items = author_items_index.get(account, [])
|
| 276 |
+
|
| 277 |
+
tools_count = 0
|
| 278 |
+
skills_count = 0
|
| 279 |
+
apps_count = 0
|
| 280 |
+
|
| 281 |
+
for i in u_items:
|
| 282 |
+
itype = i.get("type", "")
|
| 283 |
+
if itype == "tool":
|
| 284 |
+
tools_count += 1
|
| 285 |
+
elif itype == "skill":
|
| 286 |
+
skills_count += 1
|
| 287 |
+
elif itype == "app":
|
| 288 |
+
apps_count += 1
|
| 289 |
+
|
| 290 |
+
creators.append({
|
| 291 |
+
"account": account, "name": u.get("name", account), "avatar": u.get("avatarDataUrl", ""),
|
| 292 |
+
"bannerUrl": u.get("bannerUrl"), # 🖼️ 个人资料卡背景图
|
| 293 |
+
"shortDesc": u.get("shortDesc") or u.get("intro") or "", "fullDesc": u.get("shortDesc") or u.get("intro") or "",
|
| 294 |
+
"likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items),
|
| 295 |
+
"downloads": sum(i.get("uses", 0) for i in u_items),
|
| 296 |
+
"views": sum(i.get("views", 0) for i in u_items),
|
| 297 |
+
"daily_views": sum(i.get("daily_views", 0) for i in u_items),
|
| 298 |
+
"toolsCount": tools_count, "skillsCount": skills_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0),
|
| 299 |
+
"recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), # 🚀 新增:本月收益统计
|
| 300 |
+
})
|
| 301 |
+
|
| 302 |
+
if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 303 |
+
elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 304 |
+
elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True)
|
| 305 |
+
elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) # 🚀 新增:按近期打赏排序
|
| 306 |
+
elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True)
|
| 307 |
+
elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
|
| 308 |
+
else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 309 |
+
|
| 310 |
+
return {"status": "success", "data": creators[:limit]}
|
| 311 |
+
|
| 312 |
+
|
| 313 |
+
@router.get("/api/creators/{account}/details")
|
| 314 |
+
async def get_creator_details(account: str):
|
| 315 |
+
"""
|
| 316 |
+
获取单个创作者的详细数据
|
| 317 |
+
供前端展开卡片时按需加载
|
| 318 |
+
包含:trendData、commentsData、tip_board
|
| 319 |
+
"""
|
| 320 |
+
users_db = read_public_source(db, "users.json", default_data={})
|
| 321 |
+
items_db = read_public_source(db, "items.json", default_data=[])
|
| 322 |
+
comments_db = read_public_source(db, "comments.json", default_data={})
|
| 323 |
+
|
| 324 |
+
# 检查用户是否存在
|
| 325 |
+
if account not in users_db:
|
| 326 |
+
raise HTTPException(status_code=404, detail="创作者不存在")
|
| 327 |
+
|
| 328 |
+
u = users_db[account]
|
| 329 |
+
|
| 330 |
+
# 获取该用户的所有作品
|
| 331 |
+
u_items = [item for item in items_db if item.get("author") == account]
|
| 332 |
+
|
| 333 |
+
# 构建趋势数据
|
| 334 |
+
months = get_last_6_months()
|
| 335 |
+
trend_data = _build_creator_trend_data(account, u_items, months)
|
| 336 |
+
|
| 337 |
+
return {
|
| 338 |
+
"status": "success",
|
| 339 |
+
"data": {
|
| 340 |
+
"account": account,
|
| 341 |
+
"trendData": trend_data,
|
| 342 |
+
"commentsData": comments_db.get(account, []),
|
| 343 |
+
"tip_board": u.get("tip_board", [])
|
| 344 |
+
}
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
def _publish_item_record(new_item, request_id, payload_fields):
|
| 348 |
+
if request_id is not None:
|
| 349 |
+
from database_sql import engine
|
| 350 |
+
from resource_publication import publish_once
|
| 351 |
+
payload = {key: new_item[key] for key in payload_fields}
|
| 352 |
+
new_item = publish_once(engine, db, new_item, request_id, payload)
|
| 353 |
+
else:
|
| 354 |
+
new_item = publish_resource(db, new_item)
|
| 355 |
+
# 已提交内容不因派生排序缓存故障而误报发布失败。
|
| 356 |
+
try:
|
| 357 |
+
sort_cache.invalidate("items:")
|
| 358 |
+
except Exception as error:
|
| 359 |
+
print(f"[资源发布] 缓存失效失败: error_type={type(error).__name__}")
|
| 360 |
+
return new_item
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _edit_item_record(item_id, current_user, changes):
|
| 364 |
+
result_holder = edit_resource(db, item_id, current_user, changes)
|
| 365 |
+
# 编辑已经提交,派生缓存故障不改写主操作的成功结果。
|
| 366 |
+
for invalidate, key in ((sort_cache.invalidate, "items:"), (invalidate_cache, "items.json")):
|
| 367 |
+
try:
|
| 368 |
+
invalidate(key)
|
| 369 |
+
except Exception as error:
|
| 370 |
+
print(f"[资源编辑] 缓存失效失败: error_type={type(error).__name__}")
|
| 371 |
+
refresh_source = False
|
| 372 |
+
if result_holder.get('source_changed'):
|
| 373 |
+
try:
|
| 374 |
+
invalidate_source(db, item_id)
|
| 375 |
+
refresh_source = True
|
| 376 |
+
except Exception as error:
|
| 377 |
+
print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
|
| 378 |
+
return result_holder, refresh_source
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _delete_item_record(item_id, current_user, administrator):
|
| 382 |
+
result = delete_resource(db, item_id, current_user, administrator)
|
| 383 |
+
for invalidate, key in ((invalidate_cache, "items.json"), (invalidate_cache, "comments.json"),
|
| 384 |
+
(invalidate_cache, "versions.json"), (sort_cache.invalidate, "items:")):
|
| 385 |
+
try:
|
| 386 |
+
invalidate(key)
|
| 387 |
+
except Exception as error:
|
| 388 |
+
print(f"[资源删除] 缓存失效失败: error_type={type(error).__name__}")
|
| 389 |
+
return result
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
@router.post("/api/items")
|
| 393 |
+
@shared_limit(5)
|
| 394 |
+
async def create_item(request: Request, item: ItemCreate, current_user: str = Depends(require_auth)):
|
| 395 |
+
# 🔒 P0安全修复:使用 JWT 认证的用户账号作为作者,防止伪造
|
| 396 |
+
item.author = current_user
|
| 397 |
+
if not re.fullmatch(r'[A-Za-z0-9_-]{1,50}', item.type or ''):
|
| 398 |
+
raise HTTPException(status_code=400, detail="资源类型无效")
|
| 399 |
+
new_item = {
|
| 400 |
+
"id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex}", "type": item.type, "title": item.title, "author": item.author,
|
| 401 |
+
"shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl,
|
| 402 |
+
"imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表
|
| 403 |
+
"price": item.price,
|
| 404 |
+
"github_token": item.github_token,
|
| 405 |
+
"netdisk_password": item.netdisk_password, # ☁️ 网盘密码
|
| 406 |
+
"is_netdisk": item.is_netdisk, # ☁️ 是否网盘资源
|
| 407 |
+
"is_original": item.is_original, # 🎨 是否为原创作品
|
| 408 |
+
"allow_refund": item.allow_refund, # 💸 是否支持退款
|
| 409 |
+
"likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": [],
|
| 410 |
+
"views": 0,
|
| 411 |
+
"daily_views": 0,
|
| 412 |
+
"viewed_by": [],
|
| 413 |
+
"daily_views_date": "",
|
| 414 |
+
"rating_avg": 0.0,
|
| 415 |
+
"rating_count": 0,
|
| 416 |
+
"rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
|
| 417 |
+
"rated_by": {}
|
| 418 |
+
}
|
| 419 |
+
try:
|
| 420 |
+
new_item = await run_resource_write(_publish_item_record, new_item, item.request_id,
|
| 421 |
+
item.model_dump(exclude={'request_id'}))
|
| 422 |
+
except ResourceStoreError as error:
|
| 423 |
+
raise HTTPException(status_code=error.status_code, detail=error.message) from None
|
| 424 |
+
|
| 425 |
+
# 🚀 首次发布立即触发版本检测与预缓存
|
| 426 |
+
try:
|
| 427 |
+
link = item.link or ""
|
| 428 |
+
if "github.com" in link:
|
| 429 |
+
from 云端_定时版本检测引擎 import precache_github_zip
|
| 430 |
+
|
| 431 |
+
async def _first_publish_precache():
|
| 432 |
+
try:
|
| 433 |
+
refreshed = await refresh_resource_version(db, new_item['id'])
|
| 434 |
+
if refreshed:
|
| 435 |
+
await precache_github_zip(refreshed['item']['link'], refreshed['token'], new_item['id'], refreshed['commit'])
|
| 436 |
+
except Exception as e:
|
| 437 |
+
print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
|
| 438 |
+
|
| 439 |
+
start_resource_background(_first_publish_precache)
|
| 440 |
+
except Exception as e:
|
| 441 |
+
print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): item_id={new_item['id']}, error_type={type(e).__name__}")
|
| 442 |
+
|
| 443 |
+
return {"status": "success", "data": published_response(new_item)}
|
| 444 |
+
|
| 445 |
+
@router.put("/api/items/{item_id}")
|
| 446 |
+
@shared_limit(10)
|
| 447 |
+
async def update_item(request: Request, item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)):
|
| 448 |
+
"""
|
| 449 |
+
更新内容接口
|
| 450 |
+
🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数
|
| 451 |
+
🔄 P7后悔模式:价格修改延迟24小时生效
|
| 452 |
+
🔧 Bug修复:使用 atomic_update 避免并发竞态条件
|
| 453 |
+
"""
|
| 454 |
+
try:
|
| 455 |
+
result_holder, refresh_source = await run_resource_write(_edit_item_record, item_id, current_user,
|
| 456 |
+
update_data.model_dump(exclude_unset=True))
|
| 457 |
+
except ResourceStoreError as error:
|
| 458 |
+
raise HTTPException(status_code=error.status_code, detail=error.message) from None
|
| 459 |
+
if refresh_source:
|
| 460 |
+
try:
|
| 461 |
+
async def refresh_changed_source():
|
| 462 |
+
try:
|
| 463 |
+
refreshed = await refresh_resource_version(db, item_id)
|
| 464 |
+
if refreshed:
|
| 465 |
+
from 云端_定时版本检测引擎 import precache_github_zip
|
| 466 |
+
await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
|
| 467 |
+
except Exception as error:
|
| 468 |
+
print(f"[缓存刷新] 来源检测失败: error_type={type(error).__name__}")
|
| 469 |
+
start_resource_background(refresh_changed_source)
|
| 470 |
+
except Exception as error:
|
| 471 |
+
print(f"[缓存刷新] 来源撤销失败: error_type={type(error).__name__}")
|
| 472 |
+
|
| 473 |
+
result = {"status": "success", "edit_revision": result_holder["edit_revision"]}
|
| 474 |
+
if result_holder.get("price_change_info"):
|
| 475 |
+
price_change_info = result_holder["price_change_info"]
|
| 476 |
+
result["price_change"] = price_change_info
|
| 477 |
+
result["message"] = f"价格将于24小时后从{price_change_info['current_price']}调整为{price_change_info['new_price']}积分"
|
| 478 |
+
return result
|
| 479 |
+
|
| 480 |
+
# ==========================================
|
| 481 |
+
# 🚀 定时任务接口:检查 GitHub 仓库最新版本
|
| 482 |
+
# 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发
|
| 483 |
+
# ==========================================
|
| 484 |
+
@router.post("/api/check_updates")
|
| 485 |
+
@shared_limit(30)
|
| 486 |
+
async def check_github_updates(request: Request, current_user: str = Depends(require_auth)):
|
| 487 |
+
"""
|
| 488 |
+
遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json
|
| 489 |
+
"""
|
| 490 |
+
if not is_admin(current_user):
|
| 491 |
+
raise HTTPException(status_code=403, detail="无权限")
|
| 492 |
+
items_db = await asyncio.to_thread(db.load_data, "items.json", default_data=[], strict=True, skip_cache=True)
|
| 493 |
+
updated_count = 0
|
| 494 |
+
for item in items_db:
|
| 495 |
+
if not item.get('link', '').startswith('https://github.com/'):
|
| 496 |
+
continue
|
| 497 |
+
try:
|
| 498 |
+
refreshed = await refresh_resource_version(db, item['id'])
|
| 499 |
+
if refreshed and refreshed['changed']:
|
| 500 |
+
updated_count += 1
|
| 501 |
+
except Exception as error:
|
| 502 |
+
print(f"[版本检查异常] error_type={type(error).__name__}")
|
| 503 |
+
|
| 504 |
+
return {
|
| 505 |
+
"status": "success",
|
| 506 |
+
"message": f"版本检查完成,共更新 {updated_count} 个工具的版本号",
|
| 507 |
+
"updated_count": updated_count
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
@router.get("/api/items/{item_id}")
|
| 511 |
+
async def get_item_by_id(item_id: str):
|
| 512 |
+
"""根据ID获取单个资源的详细信息"""
|
| 513 |
+
items_db = read_public_source(db, "items.json", default_data=[])
|
| 514 |
+
comments_db = read_public_source(db, "comments.json", default_data={})
|
| 515 |
+
versions_db = read_public_source(db, "versions.json", default_data={})
|
| 516 |
+
|
| 517 |
+
for item in items_db:
|
| 518 |
+
if item["id"] == item_id:
|
| 519 |
+
item_data = {**item}
|
| 520 |
+
_apply_effective_price(item_data)
|
| 521 |
+
# 添加关联数据
|
| 522 |
+
item_data["commentsData"] = comments_db.get(item_id, [])
|
| 523 |
+
item_data["comments"] = len(item_data["commentsData"])
|
| 524 |
+
item_data["latest_version"] = _get_version_str(versions_db, item_id)
|
| 525 |
+
|
| 526 |
+
# 🔴 【安全防线】:抹除敏感信息(在拷贝上操作)
|
| 527 |
+
item_data["has_private_token"] = bool(item_data.get("github_token"))
|
| 528 |
+
item_data.pop("github_token", None)
|
| 529 |
+
item_data.pop("netdisk_password", None)
|
| 530 |
+
item_data.pop("viewed_by", None)
|
| 531 |
+
|
| 532 |
+
return {"status": "success", "data": item_data}
|
| 533 |
+
|
| 534 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 535 |
+
|
| 536 |
+
@router.get("/api/item/{item_id}/version")
|
| 537 |
+
async def get_item_version(item_id: str):
|
| 538 |
+
"""获取单个资源的最新版本号"""
|
| 539 |
+
versions_db = read_public_source(db, "versions.json", default_data={})
|
| 540 |
+
return {"status": "success", "version": _get_version_str(versions_db, item_id)}
|
| 541 |
+
|
| 542 |
+
@router.delete("/api/items/{item_id}")
|
| 543 |
+
@shared_limit(10)
|
| 544 |
+
async def delete_item(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 545 |
+
"""
|
| 546 |
+
删除内容(仅作者或管理员可操作)
|
| 547 |
+
🔧 Bug修复:使用 atomic_update 避免并发竞态条件
|
| 548 |
+
"""
|
| 549 |
+
try:
|
| 550 |
+
result = await run_resource_write(_delete_item_record, item_id, current_user, is_admin(current_user))
|
| 551 |
+
except ResourceStoreError as error:
|
| 552 |
+
raise HTTPException(status_code=error.status_code, detail=error.message) from None
|
| 553 |
+
return {"status": "success", "message": "内容已删除", **result}
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
@router.post("/api/items/{item_id}/view")
|
| 557 |
+
@shared_limit(60)
|
| 558 |
+
async def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 559 |
+
"""
|
| 560 |
+
记录资源访问量
|
| 561 |
+
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
| 562 |
+
"""
|
| 563 |
+
result = record_view("items.json", item_id, current_user)
|
| 564 |
+
|
| 565 |
+
if result is None:
|
| 566 |
+
raise HTTPException(status_code=404, detail="找不到该内容记录")
|
| 567 |
+
|
| 568 |
+
# 🗂️ 清除排序缓存(浏览量变化可能影响排序)
|
| 569 |
+
sort_cache.invalidate("items:")
|
| 570 |
+
|
| 571 |
+
return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
@router.post("/api/items/{item_id}/use")
|
| 575 |
+
@shared_limit(30)
|
| 576 |
+
async def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 577 |
+
"""
|
| 578 |
+
记录资源使用量/下载量(原子操作,并发安全)
|
| 579 |
+
📥 每个用户对同一资源只计一次,防止重复计数
|
| 580 |
+
"""
|
| 581 |
+
result_container = [None]
|
| 582 |
+
current_month = datetime.date.today().strftime("%Y-%m")
|
| 583 |
+
|
| 584 |
+
def updater(data):
|
| 585 |
+
for item in data:
|
| 586 |
+
if item["id"] == item_id:
|
| 587 |
+
used_by = item.get("used_by", [])
|
| 588 |
+
if current_user in used_by:
|
| 589 |
+
# 用户已使用过,不重复计数
|
| 590 |
+
result_container[0] = {"status": "success", "action": "already_used", "uses": item.get("uses", 0)}
|
| 591 |
+
return
|
| 592 |
+
# 首次使用,增加计数
|
| 593 |
+
used_by.append(current_user)
|
| 594 |
+
item["uses"] = item.get("uses", 0) + 1
|
| 595 |
+
# 更新月度使用历史
|
| 596 |
+
use_history = item.get("use_history", {})
|
| 597 |
+
use_history[current_month] = use_history.get(current_month, 0) + 1
|
| 598 |
+
item["use_history"] = use_history
|
| 599 |
+
item["used_by"] = used_by
|
| 600 |
+
result_container[0] = {"status": "success", "action": "recorded", "uses": item["uses"]}
|
| 601 |
+
return
|
| 602 |
+
result_container[0] = None # 未找到资源
|
| 603 |
+
|
| 604 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 605 |
+
|
| 606 |
+
if result_container[0] is None:
|
| 607 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 608 |
+
|
| 609 |
+
# 🗂️ 清除排序缓存(使用数变化可能影响排序)
|
| 610 |
+
sort_cache.invalidate("items:")
|
| 611 |
+
|
| 612 |
+
return result_container[0]
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
# ==========================================
|
| 616 |
+
# ❤️ 互动接口(点赞/收藏)
|
| 617 |
+
# ==========================================
|
| 618 |
+
|
| 619 |
+
@router.post("/api/items/{item_id}/rating")
|
| 620 |
+
@shared_limit(20)
|
| 621 |
+
async def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
|
| 622 |
+
"""
|
| 623 |
+
为资源评分(原子操作,并发安全)
|
| 624 |
+
⭐ score: 1-5
|
| 625 |
+
"""
|
| 626 |
+
score = payload.score
|
| 627 |
+
if score < 1 or score > 5:
|
| 628 |
+
raise HTTPException(status_code=400, detail="评分必须在1-5之间")
|
| 629 |
+
|
| 630 |
+
result_container = [None]
|
| 631 |
+
|
| 632 |
+
def updater(data):
|
| 633 |
+
for item in data:
|
| 634 |
+
if item["id"] == item_id:
|
| 635 |
+
# 禁止自评
|
| 636 |
+
if item.get("author") == current_user:
|
| 637 |
+
result_container[0] = {"error": "self_rating"}
|
| 638 |
+
return
|
| 639 |
+
# 初始化评分字段
|
| 640 |
+
if "rating_avg" not in item:
|
| 641 |
+
item["rating_avg"] = 0.0
|
| 642 |
+
if "rating_count" not in item:
|
| 643 |
+
item["rating_count"] = 0
|
| 644 |
+
if "rating_dist" not in item:
|
| 645 |
+
item["rating_dist"] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
|
| 646 |
+
if "rated_by" not in item:
|
| 647 |
+
item["rated_by"] = {}
|
| 648 |
+
|
| 649 |
+
rated_by = item["rated_by"]
|
| 650 |
+
rating_dist = item["rating_dist"]
|
| 651 |
+
old_score = None
|
| 652 |
+
if current_user in rated_by:
|
| 653 |
+
old_score = rated_by[current_user]["score"]
|
| 654 |
+
|
| 655 |
+
if old_score is not None:
|
| 656 |
+
# 已评分,先减去旧分数分布
|
| 657 |
+
rating_dist[str(old_score)] = max(0, rating_dist.get(str(old_score), 0) - 1)
|
| 658 |
+
rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
|
| 659 |
+
else:
|
| 660 |
+
# 未评分,增加计数
|
| 661 |
+
item["rating_count"] = item.get("rating_count", 0) + 1
|
| 662 |
+
rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
|
| 663 |
+
|
| 664 |
+
rated_by[current_user] = {"score": score, "time": int(time.time())}
|
| 665 |
+
|
| 666 |
+
# 重新计算平均分
|
| 667 |
+
total = sum(int(k) * v for k, v in rating_dist.items())
|
| 668 |
+
count = item["rating_count"]
|
| 669 |
+
item["rating_avg"] = round(total / count, 2) if count > 0 else 0.0
|
| 670 |
+
|
| 671 |
+
result_container[0] = {
|
| 672 |
+
"status": "success",
|
| 673 |
+
"rating_avg": item["rating_avg"],
|
| 674 |
+
"rating_count": item["rating_count"],
|
| 675 |
+
"rating_dist": item["rating_dist"],
|
| 676 |
+
"user_score": score
|
| 677 |
+
}
|
| 678 |
+
return
|
| 679 |
+
result_container[0] = None # 未找到资源
|
| 680 |
+
|
| 681 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 682 |
+
|
| 683 |
+
if result_container[0] is None:
|
| 684 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 685 |
+
if result_container[0].get("error") == "self_rating":
|
| 686 |
+
raise HTTPException(status_code=400, detail="不能给自己发布的资源评分")
|
| 687 |
+
|
| 688 |
+
# 🗂️ 清除排序缓存(评分变化可能影响排序)
|
| 689 |
+
sort_cache.invalidate("items:")
|
| 690 |
+
|
| 691 |
+
return result_container[0]
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
@router.post("/api/items/{item_id}/like")
|
| 695 |
+
@shared_limit(30)
|
| 696 |
+
async def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 697 |
+
"""
|
| 698 |
+
点赞/取消点赞(原子操作,并发安全)
|
| 699 |
+
"""
|
| 700 |
+
result_container = [None]
|
| 701 |
+
|
| 702 |
+
def updater(data):
|
| 703 |
+
for item in data:
|
| 704 |
+
if item["id"] == item_id:
|
| 705 |
+
liked_by = item.get("liked_by", [])
|
| 706 |
+
if current_user in liked_by:
|
| 707 |
+
liked_by.remove(current_user)
|
| 708 |
+
item["likes"] = max(0, item.get("likes", 0) - 1)
|
| 709 |
+
action = "unliked"
|
| 710 |
+
else:
|
| 711 |
+
liked_by.append(current_user)
|
| 712 |
+
item["likes"] = item.get("likes", 0) + 1
|
| 713 |
+
action = "liked"
|
| 714 |
+
item["liked_by"] = liked_by
|
| 715 |
+
result_container[0] = {"status": "success", "action": action, "likes": item["likes"]}
|
| 716 |
+
return
|
| 717 |
+
result_container[0] = None # 未找到资源
|
| 718 |
+
|
| 719 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 720 |
+
|
| 721 |
+
if result_container[0] is None:
|
| 722 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 723 |
+
|
| 724 |
+
# 🗂️ 清除排序缓存(点赞数变化可能影响排序)
|
| 725 |
+
sort_cache.invalidate("items:")
|
| 726 |
+
|
| 727 |
+
return result_container[0]
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
@router.post("/api/items/{item_id}/favorite")
|
| 731 |
+
@shared_limit(30)
|
| 732 |
+
async def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 733 |
+
"""
|
| 734 |
+
收藏/取消收藏(原子操作,并发安全)
|
| 735 |
+
"""
|
| 736 |
+
result_container = [None]
|
| 737 |
+
|
| 738 |
+
def updater(data):
|
| 739 |
+
for item in data:
|
| 740 |
+
if item["id"] == item_id:
|
| 741 |
+
favorited_by = item.get("favorited_by", [])
|
| 742 |
+
if current_user in favorited_by:
|
| 743 |
+
favorited_by.remove(current_user)
|
| 744 |
+
item["favorites"] = max(0, item.get("favorites", 0) - 1)
|
| 745 |
+
action = "unfavorited"
|
| 746 |
+
else:
|
| 747 |
+
favorited_by.append(current_user)
|
| 748 |
+
item["favorites"] = item.get("favorites", 0) + 1
|
| 749 |
+
action = "favorited"
|
| 750 |
+
item["favorited_by"] = favorited_by
|
| 751 |
+
result_container[0] = {"status": "success", "action": action, "favorites": item["favorites"]}
|
| 752 |
+
return
|
| 753 |
+
result_container[0] = None # 未找到资源
|
| 754 |
+
|
| 755 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 756 |
+
|
| 757 |
+
if result_container[0] is None:
|
| 758 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 759 |
+
|
| 760 |
+
# 🗂️ 清除排序缓存(收藏数变化可能影响排序)
|
| 761 |
+
sort_cache.invalidate("items:")
|
| 762 |
+
|
| 763 |
+
return result_container[0]
|
| 764 |
+
|
| 765 |
+
|
| 766 |
+
# ==========================================
|
| 767 |
+
# 🔄 手动触发预缓存刷新接口
|
| 768 |
+
# ==========================================
|
| 769 |
+
@router.post("/api/items/{item_id}/refresh_cache")
|
| 770 |
+
@shared_limit(3)
|
| 771 |
+
async def refresh_item_cache(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 772 |
+
"""手动触发指定资源的 ZIP 预缓存刷新,仅限作者调用"""
|
| 773 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 774 |
+
|
| 775 |
+
# 查找目标 item
|
| 776 |
+
target_item = None
|
| 777 |
+
for item in items_db:
|
| 778 |
+
if item["id"] == item_id:
|
| 779 |
+
target_item = item
|
| 780 |
+
break
|
| 781 |
+
|
| 782 |
+
if not target_item:
|
| 783 |
+
raise HTTPException(status_code=404, detail="资源不存在")
|
| 784 |
+
|
| 785 |
+
# 权限校验:仅作者可操作
|
| 786 |
+
if target_item.get("author") != current_user:
|
| 787 |
+
raise HTTPException(status_code=403, detail="仅作者可触发缓存刷新")
|
| 788 |
+
|
| 789 |
+
# 仅支持 GitHub 仓库类型
|
| 790 |
+
link = target_item.get("link", "")
|
| 791 |
+
if "github.com" not in link:
|
| 792 |
+
raise HTTPException(status_code=400, detail="仅支持 GitHub 仓库类型的资源")
|
| 793 |
+
|
| 794 |
+
try:
|
| 795 |
+
from 云端_定时版本检测引擎 import precache_github_zip
|
| 796 |
+
except ImportError:
|
| 797 |
+
raise HTTPException(status_code=500, detail="预缓存模块不可用")
|
| 798 |
+
|
| 799 |
+
refreshed = await refresh_resource_version(db, item_id)
|
| 800 |
+
if not refreshed:
|
| 801 |
+
raise HTTPException(status_code=503, detail="资源版本暂时无法核实,请稍后重试")
|
| 802 |
+
success = await precache_github_zip(refreshed['item']['link'], refreshed['token'], item_id, refreshed['commit'])
|
| 803 |
+
|
| 804 |
+
if success:
|
| 805 |
+
return {"status": "success", "message": "缓存刷新完成"}
|
| 806 |
+
else:
|
| 807 |
+
return {"status": "error", "message": "缓存刷新失败,请查看云端日志获取详细信息"}
|
router_messages.py
CHANGED
|
@@ -1,188 +1,196 @@
|
|
| 1 |
-
# router_messages.py
|
| 2 |
-
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
-
from rate_limit import shared_limit
|
| 4 |
-
from pydantic import BaseModel
|
| 5 |
-
import
|
| 6 |
-
import time
|
| 7 |
-
import uuid
|
| 8 |
-
import subprocess
|
| 9 |
-
import logging
|
| 10 |
-
from script_execution import run_admin_script_process, ScriptExecutionBusy
|
| 11 |
-
import os
|
| 12 |
-
import 数据库连接 as db
|
| 13 |
-
import chat_store
|
| 14 |
-
import notification_store
|
| 15 |
-
from models import PrivateMessage, IdempotentPrivateMessage
|
| 16 |
-
from 安全认证 import require_auth, is_admin
|
| 17 |
-
|
| 18 |
-
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 19 |
-
|
| 20 |
-
logger = logging.getLogger("ComfyUI-Ranking.AdminScripts")
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
#
|
| 32 |
-
#
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
#
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
#
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
""
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
# 🔒 P0安全修复:
|
| 76 |
-
if
|
| 77 |
-
raise HTTPException(status_code=
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
if
|
| 81 |
-
raise HTTPException(
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
if
|
| 114 |
-
output =
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
"
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
"
|
| 127 |
-
"output":
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return {"status": "success", "data":
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
@
|
| 180 |
-
async def
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_messages.py
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
+
from rate_limit import shared_limit
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from owned_thread import OwnedThreadGroup
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
import subprocess
|
| 9 |
+
import logging
|
| 10 |
+
from script_execution import run_admin_script_process, ScriptExecutionBusy
|
| 11 |
+
import os
|
| 12 |
+
import 数据库连接 as db
|
| 13 |
+
import chat_store
|
| 14 |
+
import notification_store
|
| 15 |
+
from models import PrivateMessage, IdempotentPrivateMessage
|
| 16 |
+
from 安全认证 import require_auth, is_admin
|
| 17 |
+
|
| 18 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("ComfyUI-Ranking.AdminScripts")
|
| 21 |
+
MAX_MESSAGE_IO = 16
|
| 22 |
+
_message_io = OwnedThreadGroup(MAX_MESSAGE_IO,
|
| 23 |
+
lambda: HTTPException(status_code=503, detail="消息服务暂不可用"))
|
| 24 |
+
run_message_io = _message_io.run
|
| 25 |
+
start_message_io = _message_io.start
|
| 26 |
+
stop_message_io = _message_io.stop
|
| 27 |
+
close_message_io = _message_io.close
|
| 28 |
+
|
| 29 |
+
router = APIRouter()
|
| 30 |
+
|
| 31 |
+
# ==========================================
|
| 32 |
+
# 新增:系统公告请求体模型
|
| 33 |
+
# ==========================================
|
| 34 |
+
class SystemAnnouncement(BaseModel):
|
| 35 |
+
admin_account: str
|
| 36 |
+
content: str
|
| 37 |
+
|
| 38 |
+
# ==========================================
|
| 39 |
+
# 新增:发布系统公告接口 (仅限管理员,使用JWT验证)
|
| 40 |
+
# ==========================================
|
| 41 |
+
@router.post("/api/system/announcement")
|
| 42 |
+
@shared_limit(5)
|
| 43 |
+
async def publish_announcement(request: Request, ann: SystemAnnouncement, current_user: str = Depends(require_auth)):
|
| 44 |
+
# 🔒 P0安全修复:使用环境变量配置的管理员列表
|
| 45 |
+
if not is_admin(current_user):
|
| 46 |
+
raise HTTPException(status_code=403, detail="无权发布系统公告,仅管理员可操作")
|
| 47 |
+
|
| 48 |
+
await run_message_io(notification_store.publish_announcement, current_user, ann.content)
|
| 49 |
+
return {"status": "success"}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ==========================================
|
| 53 |
+
# 管理员调试:执行 Python 脚本
|
| 54 |
+
# ==========================================
|
| 55 |
+
class AdminScriptRequest(BaseModel):
|
| 56 |
+
admin_account: str
|
| 57 |
+
script_name: str
|
| 58 |
+
|
| 59 |
+
# 🔒 P0安全修复:脚本白名单(仅允许执行指定的脚本)
|
| 60 |
+
# 警告:添加新脚本前请确保其安全性
|
| 61 |
+
ALLOWED_SCRIPTS = {
|
| 62 |
+
"密码迁移.py", # 用户密码哈希化迁移
|
| 63 |
+
"测试脚本.py", # 接口测试工具
|
| 64 |
+
"迁移_余额合并.py", # 一次性余额合并迁移(执行后可移除)
|
| 65 |
+
"迁移_补全历史SALE记录.py", # 补全历史SALE记录迁移
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
@router.post("/api/admin/run-script")
|
| 69 |
+
@shared_limit(3)
|
| 70 |
+
async def run_admin_script(request: Request, req: AdminScriptRequest, current_user: str = Depends(require_auth)):
|
| 71 |
+
"""
|
| 72 |
+
管理员专属:执行指定的 Python 脚本
|
| 73 |
+
🔒 P0安全修复:白名单 + 路径穿越防护
|
| 74 |
+
"""
|
| 75 |
+
# 🔒 P0安全修复:使用环境变量配置的管理员列表
|
| 76 |
+
if not is_admin(current_user):
|
| 77 |
+
raise HTTPException(status_code=403, detail="无权执行此操作,仅管理员可操作")
|
| 78 |
+
|
| 79 |
+
script_name = req.script_name.strip()
|
| 80 |
+
if not script_name:
|
| 81 |
+
raise HTTPException(status_code=400, detail="脚本名称不能为空")
|
| 82 |
+
|
| 83 |
+
# 🔒 P0安全修复:路径穿越攻击防护
|
| 84 |
+
if ".." in script_name or "/" in script_name or "\\" in script_name:
|
| 85 |
+
raise HTTPException(status_code=400, detail="🚨 安全拦截:脚本名称包含非法字符")
|
| 86 |
+
|
| 87 |
+
# 🔒 P0安全修复:白名单检查
|
| 88 |
+
if script_name not in ALLOWED_SCRIPTS:
|
| 89 |
+
raise HTTPException(
|
| 90 |
+
status_code=403,
|
| 91 |
+
detail=f"🚨 安全拦截:脚本 [{script_name}] 不在白名单中。允许的脚本: {list(ALLOWED_SCRIPTS)}"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
# 获取当前工作目录
|
| 95 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 96 |
+
script_path = os.path.join(current_dir, script_name)
|
| 97 |
+
|
| 98 |
+
# 检查文件是否存在
|
| 99 |
+
if not os.path.exists(script_path):
|
| 100 |
+
return {
|
| 101 |
+
"status": "error",
|
| 102 |
+
"output": f"❌ 脚本文件不存在: {script_name}\n\n白名单脚本: {list(ALLOWED_SCRIPTS)}"
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
# 清理敏感环境变量,防止泄露到子进程
|
| 107 |
+
sensitive_prefixes = ('GITHUB', 'JWT', 'HF_', 'DATABASE', 'ALIPAY', 'PASSWORD')
|
| 108 |
+
clean_env = {k: v for k, v in os.environ.items()
|
| 109 |
+
if not any(k.startswith(prefix) for prefix in sensitive_prefixes)}
|
| 110 |
+
result = await run_admin_script_process(script_path, cwd=current_dir, env=clean_env)
|
| 111 |
+
|
| 112 |
+
output = ""
|
| 113 |
+
if result.stdout:
|
| 114 |
+
output += f"📝 标准输出:\n{result.stdout}\n"
|
| 115 |
+
if result.stderr:
|
| 116 |
+
output += f"\n⚠️ 错误输出:\n{result.stderr}"
|
| 117 |
+
if not output:
|
| 118 |
+
output = "✅ 脚本执行完成,无输出"
|
| 119 |
+
|
| 120 |
+
# 限制输出大小防止返回大量数据
|
| 121 |
+
if len(output) > 1000:
|
| 122 |
+
output = output[:1000] + "\n...(输出已截断)"
|
| 123 |
+
|
| 124 |
+
return {
|
| 125 |
+
"status": "success" if result.returncode == 0 else "error",
|
| 126 |
+
"return_code": result.returncode,
|
| 127 |
+
"output": output
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
except ScriptExecutionBusy:
|
| 131 |
+
raise HTTPException(status_code=503, detail="脚本执行繁忙,请先确认当前执行状态") from None
|
| 132 |
+
except subprocess.TimeoutExpired:
|
| 133 |
+
return {
|
| 134 |
+
"status": "error",
|
| 135 |
+
"output": "❌ 脚本执行超时 (30秒)"
|
| 136 |
+
}
|
| 137 |
+
except Exception as error:
|
| 138 |
+
logger.error("ADMIN_SCRIPT_ERROR | error_type=%s", type(error).__name__)
|
| 139 |
+
return {
|
| 140 |
+
"status": "error",
|
| 141 |
+
"output": "脚本执行暂不可用,请先确认当前状态"
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
# ==========================================
|
| 145 |
+
# 原有功能:私信与聊天
|
| 146 |
+
# ==========================================
|
| 147 |
+
@router.post("/api/messages/private")
|
| 148 |
+
@shared_limit(30) # 🔒 安全限流:防止私信骚扰/刷屏(聊天节奏快,阈值比发帖宽松)
|
| 149 |
+
async def send_private_message(request: Request, msg: PrivateMessage, current_user: str = Depends(require_auth)):
|
| 150 |
+
await run_message_io(chat_store.send_message, current_user, msg.receiver, msg.content)
|
| 151 |
+
return {"status": "success"}
|
| 152 |
+
|
| 153 |
+
@router.post("/api/messages/private/idempotent")
|
| 154 |
+
@shared_limit(30)
|
| 155 |
+
async def send_idempotent_private_message(request: Request, msg: IdempotentPrivateMessage, current_user: str = Depends(require_auth)):
|
| 156 |
+
receipt = await run_message_io(chat_store.send_message, current_user, msg.receiver, msg.content, msg.client_request_id)
|
| 157 |
+
return {"status": "success", "data": receipt}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@router.get("/api/chats/{account}")
|
| 161 |
+
async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
|
| 162 |
+
if account != current_user:
|
| 163 |
+
raise HTTPException(status_code=403, detail="无权访问他人私信")
|
| 164 |
+
data = await run_message_io(chat_store.chat_list, account)
|
| 165 |
+
return {"status": "success", "data": data}
|
| 166 |
+
|
| 167 |
+
@router.get("/api/chats/{account}/{target_account}")
|
| 168 |
+
async def get_chat_history(account: str, target_account: str, current_user: str = Depends(require_auth)):
|
| 169 |
+
if account != current_user:
|
| 170 |
+
raise HTTPException(status_code=403, detail="无权访问他人聊天记录")
|
| 171 |
+
data = await run_message_io(chat_store.chat_history, account, target_account)
|
| 172 |
+
return {"status": "success", "data": data}
|
| 173 |
+
|
| 174 |
+
# ==========================================
|
| 175 |
+
# 改造:获取通知列表 (加入系统公告懒加载注入)
|
| 176 |
+
# 使用 atomic_update 避免并发覆盖问题
|
| 177 |
+
# 🔥 性能优化:先用只读方式检查是否有实际变更,避免无意义的写入和HF上传
|
| 178 |
+
# ==========================================
|
| 179 |
+
@router.get("/api/messages/{account}")
|
| 180 |
+
async def get_messages(account: str, count_only: bool = False, current_user: str = Depends(require_auth)):
|
| 181 |
+
# 🔒 P0安全加固:拦截越权读取他人消息(含 count_only 轮询)
|
| 182 |
+
if account != current_user:
|
| 183 |
+
raise HTTPException(status_code=403, detail="无权访问他人消息")
|
| 184 |
+
return await run_message_io(notification_store.get_messages, account, count_only)
|
| 185 |
+
|
| 186 |
+
@router.post("/api/messages/{account}/read")
|
| 187 |
+
@shared_limit(60)
|
| 188 |
+
async def mark_messages_read(request: Request, account: str, current_user: str = Depends(require_auth)):
|
| 189 |
+
"""
|
| 190 |
+
标记消息为已读(原子操作,并发安全)
|
| 191 |
+
"""
|
| 192 |
+
# 🔒 P0安全加固:补全鉴权,防止任意用户篡改他人消息已读状态
|
| 193 |
+
if account != current_user:
|
| 194 |
+
raise HTTPException(status_code=403, detail="无权操作他人消息")
|
| 195 |
+
await run_message_io(notification_store.mark_messages_read, account)
|
| 196 |
+
return {"status": "success"}
|
router_open_api.py
CHANGED
|
@@ -1,4 +1,3 @@
|
|
| 1 |
-
import asyncio
|
| 2 |
# router_open_api.py
|
| 3 |
# ==========================================
|
| 4 |
# 🌐 通用开放 API 模块(供第三方插件调用)
|
|
@@ -7,7 +6,7 @@ import asyncio
|
|
| 7 |
# 供 NodeCraft AI 等第三方插件复用 ComfyUI-Ranking 账号体系。
|
| 8 |
# 关联文件:
|
| 9 |
# - 安全认证.py (verify_token_with_fallback, require_auth)
|
| 10 |
-
# - database_sql.py (
|
| 11 |
# - models_sql.py (Wallet, Transaction)
|
| 12 |
# - 数据库连接.py (users.json 读写)
|
| 13 |
# 安全机制:
|
|
@@ -25,17 +24,20 @@ import hashlib
|
|
| 25 |
import logging
|
| 26 |
from typing import Optional
|
| 27 |
|
| 28 |
-
from fastapi import APIRouter,
|
| 29 |
from fastapi.responses import JSONResponse
|
| 30 |
from sqlalchemy.orm import Session
|
| 31 |
from sqlalchemy import func, text
|
| 32 |
from pydantic import BaseModel, Field
|
| 33 |
|
| 34 |
-
from database_sql import
|
| 35 |
from models_sql import Wallet, Transaction, RegisteredPlugin, OpenApiRateLimitBucket
|
| 36 |
from open_transaction import lock_open_wallet, positive_integer, validate_live_plugin, quota_total
|
|
|
|
| 37 |
from plugin_permissions import parse_permissions
|
| 38 |
from tip_store import run_tip_effect
|
|
|
|
|
|
|
| 39 |
from 安全认证 import verify_token_with_fallback
|
| 40 |
import 数据库连接 as json_db
|
| 41 |
|
|
@@ -44,6 +46,21 @@ logger = logging.getLogger("ComfyUI-Ranking.OpenAPI")
|
|
| 44 |
router = APIRouter(prefix="/api/open", tags=["open-api"])
|
| 45 |
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
# ═══════════════ 插件注册表(从数据库动态加载) ═══════════════
|
| 48 |
# 反向索引:plugin_key -> {id, key, name, rate_limit, permissions, max_deduct_per_tx, max_deduct_per_day}
|
| 49 |
_PLUGIN_KEY_INDEX: dict = {}
|
|
@@ -215,7 +232,7 @@ async def _resolve_user_from_token(token: str) -> str:
|
|
| 215 |
"""从 Token 中解析出账号;失败时抛 401"""
|
| 216 |
if not token:
|
| 217 |
raise HTTPException(status_code=401, detail="缺少用户 Token")
|
| 218 |
-
is_valid, account, error_msg = await
|
| 219 |
if not is_valid or not account:
|
| 220 |
raise HTTPException(status_code=401, detail=error_msg or "Token 无效")
|
| 221 |
return account
|
|
@@ -239,12 +256,16 @@ class DeductRequest(BaseModel):
|
|
| 239 |
amount: int = Field(..., gt=0, strict=True, description="扣款金额(正整数)")
|
| 240 |
reason: str = Field(..., description="业务标识/扣款原因")
|
| 241 |
reference_id: Optional[str] = Field(None, description="业务侧参考 ID")
|
|
|
|
|
|
|
| 242 |
|
| 243 |
|
| 244 |
class RefundRequest(BaseModel):
|
| 245 |
token: str = Field(..., description="用户 JWT Token")
|
| 246 |
transaction_id: str = Field(..., description="原扣款交易 ID")
|
| 247 |
amount: int = Field(..., gt=0, strict=True, description="退款金额(正整数)")
|
|
|
|
|
|
|
| 248 |
|
| 249 |
|
| 250 |
# ==========================================
|
|
@@ -254,20 +275,19 @@ class RefundRequest(BaseModel):
|
|
| 254 |
async def open_verify_token(
|
| 255 |
req: VerifyTokenRequest,
|
| 256 |
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 257 |
-
db: Session = Depends(get_db),
|
| 258 |
):
|
| 259 |
"""
|
| 260 |
供第三方插件验证用户 Token 有效性。
|
| 261 |
成功返回账号与基础资料(昵称、头像)。
|
| 262 |
"""
|
| 263 |
-
plugin_info =
|
| 264 |
|
| 265 |
-
is_valid, account, error_msg = await
|
| 266 |
if not is_valid or not account:
|
| 267 |
logger.info(f"OPEN_VERIFY_TOKEN_FAIL | plugin={plugin_info['id']} | category=invalid_token")
|
| 268 |
return {"valid": False, "error": error_msg or "Token 无效"}
|
| 269 |
|
| 270 |
-
users_db = json_db.load_data
|
| 271 |
user = users_db.get(account, {}) or {}
|
| 272 |
|
| 273 |
logger.info(f"OPEN_VERIFY_TOKEN | plugin={plugin_info['id']}")
|
|
@@ -286,13 +306,12 @@ async def open_verify_token(
|
|
| 286 |
async def open_get_balance(
|
| 287 |
authorization: Optional[str] = Header(None, alias="Authorization"),
|
| 288 |
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 289 |
-
db: Session = Depends(get_db),
|
| 290 |
):
|
| 291 |
"""
|
| 292 |
查询用户钱包余额。
|
| 293 |
认证:Authorization: Bearer <user_token> + X-Plugin-Key
|
| 294 |
"""
|
| 295 |
-
plugin_info =
|
| 296 |
|
| 297 |
if not authorization:
|
| 298 |
raise HTTPException(status_code=401, detail="缺少 Authorization Header")
|
|
@@ -301,6 +320,105 @@ async def open_get_balance(
|
|
| 301 |
raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
|
| 302 |
|
| 303 |
account = await _resolve_user_from_token(parts[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
wallet = db.query(Wallet).filter(Wallet.account == account).first()
|
| 305 |
|
| 306 |
if not wallet:
|
|
@@ -326,30 +444,17 @@ async def open_get_balance(
|
|
| 326 |
return result
|
| 327 |
|
| 328 |
|
| 329 |
-
|
| 330 |
-
# 3️⃣ 通用扣款:POST /api/open/deduct
|
| 331 |
-
# ==========================================
|
| 332 |
-
@router.post("/deduct")
|
| 333 |
-
async def open_deduct(
|
| 334 |
-
req: DeductRequest,
|
| 335 |
-
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 336 |
-
db: Session = Depends(get_db),
|
| 337 |
-
):
|
| 338 |
-
"""
|
| 339 |
-
通用扣款接口(从 wallet.balance 扣减)。
|
| 340 |
-
流程:
|
| 341 |
-
1. 校验 Plugin Key + 权限 + 限流
|
| 342 |
-
2. 解析 Token -> account
|
| 343 |
-
3. 共用写锁锁定钱包与实时插件配置,检查额度/余额
|
| 344 |
-
4. 扣款 + 写入 Transaction(含哈希链)
|
| 345 |
-
"""
|
| 346 |
-
plugin_info = _require_plugin(x_plugin_key, "deduct", db)
|
| 347 |
-
account = await _resolve_user_from_token(req.token)
|
| 348 |
amount = req.amount
|
| 349 |
if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
|
| 350 |
raise HTTPException(status_code=422, detail="扣款金额必须为正整数")
|
|
|
|
|
|
|
| 351 |
try:
|
| 352 |
wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "deduct")
|
|
|
|
|
|
|
|
|
|
| 353 |
max_per_tx = positive_integer(plugin_row.max_deduct_per_tx)
|
| 354 |
max_daily = positive_integer(plugin_row.max_deduct_per_day)
|
| 355 |
if amount > max_per_tx:
|
|
@@ -374,7 +479,7 @@ async def open_deduct(
|
|
| 374 |
if wallet.balance < amount:
|
| 375 |
raise HTTPException(status_code=402, detail="余额不足")
|
| 376 |
wallet.balance -= amount
|
| 377 |
-
tx_id = f"OPEN_DEDUCT_{uuid.uuid4().hex}"
|
| 378 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
|
| 379 |
Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
|
| 380 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
@@ -383,6 +488,7 @@ async def open_deduct(
|
|
| 383 |
tx_hash=_calc_tx_hash(tx_id, account, "OPEN_DEDUCT", -amount, prev_hash),
|
| 384 |
description=f"[{plugin_row.name}] {req.reason}", source_plugin=plugin_info["id"]))
|
| 385 |
response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
|
|
|
|
| 386 |
db.commit()
|
| 387 |
except HTTPException:
|
| 388 |
db.rollback()
|
|
@@ -395,31 +501,17 @@ async def open_deduct(
|
|
| 395 |
return response
|
| 396 |
|
| 397 |
|
| 398 |
-
|
| 399 |
-
# 4️⃣ 退款:POST /api/open/refund
|
| 400 |
-
# ==========================================
|
| 401 |
-
@router.post("/refund")
|
| 402 |
-
async def open_refund(
|
| 403 |
-
req: RefundRequest,
|
| 404 |
-
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 405 |
-
db: Session = Depends(get_db),
|
| 406 |
-
):
|
| 407 |
-
"""
|
| 408 |
-
退款接口:将之前 OPEN_DEDUCT 的金额(部分或全额)退还到用户钱包。
|
| 409 |
-
|
| 410 |
-
校验:
|
| 411 |
-
- 原交易必须存在且 tx_type == OPEN_DEDUCT
|
| 412 |
-
- 原交易归属当前 token 用户
|
| 413 |
-
- 原交易由当前插件发起(related_account == plugin_id)
|
| 414 |
-
- 累计退款金额 ≤ 原扣款金额
|
| 415 |
-
"""
|
| 416 |
-
plugin_info = _require_plugin(x_plugin_key, "refund", db)
|
| 417 |
-
account = await _resolve_user_from_token(req.token)
|
| 418 |
refund_amount = req.amount
|
| 419 |
if isinstance(refund_amount, bool) or not isinstance(refund_amount, int) or refund_amount <= 0:
|
| 420 |
raise HTTPException(status_code=422, detail="退款金额必须为正整数")
|
|
|
|
|
|
|
| 421 |
try:
|
| 422 |
wallet, plugin_row = lock_open_wallet(db, account, plugin_info["id"], x_plugin_key, "refund")
|
|
|
|
|
|
|
|
|
|
| 423 |
original_tx = db.query(Transaction).filter(Transaction.tx_id == req.transaction_id).populate_existing().with_for_update().first()
|
| 424 |
if original_tx is None:
|
| 425 |
raise HTTPException(status_code=404, detail="原交易不存在")
|
|
@@ -447,7 +539,7 @@ async def open_refund(
|
|
| 447 |
if isinstance(wallet.balance, bool) or not isinstance(wallet.balance, int):
|
| 448 |
raise ValueError("invalid wallet balance")
|
| 449 |
wallet.balance += refund_amount
|
| 450 |
-
tx_id = f"OPEN_REFUND_{uuid.uuid4().hex}"
|
| 451 |
last_tx = db.query(Transaction).filter(Transaction.account == account).order_by(
|
| 452 |
Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
|
| 453 |
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
|
@@ -456,6 +548,7 @@ async def open_refund(
|
|
| 456 |
tx_hash=_calc_tx_hash(tx_id, account, "OPEN_REFUND", refund_amount, prev_hash),
|
| 457 |
description=f"[{plugin_row.name}] 退款 {req.transaction_id}", source_plugin=plugin_info["id"]))
|
| 458 |
response = {"success": True, "remaining_balance": wallet.balance, "transaction_id": tx_id}
|
|
|
|
| 459 |
db.commit()
|
| 460 |
except HTTPException:
|
| 461 |
db.rollback()
|
|
@@ -468,35 +561,7 @@ async def open_refund(
|
|
| 468 |
return response
|
| 469 |
|
| 470 |
|
| 471 |
-
|
| 472 |
-
# 5️⃣ 消费记录查询:GET /api/open/transactions
|
| 473 |
-
# ==========================================
|
| 474 |
-
@router.get("/transactions")
|
| 475 |
-
async def open_list_transactions(
|
| 476 |
-
authorization: Optional[str] = Header(None, alias="Authorization"),
|
| 477 |
-
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 478 |
-
page: int = Query(1, ge=1, description="页码(从 1 开始)"),
|
| 479 |
-
page_size: int = Query(20, ge=1, le=100, description="每页条数(1-100)"),
|
| 480 |
-
start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
|
| 481 |
-
end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
|
| 482 |
-
tx_type: Optional[str] = Query(None, description="交易类型,如 OPEN_DEDUCT / OPEN_REFUND"),
|
| 483 |
-
db: Session = Depends(get_db),
|
| 484 |
-
):
|
| 485 |
-
"""
|
| 486 |
-
查询当前用户由当前插件产生的消费/退款记录。
|
| 487 |
-
|
| 488 |
-
认证:Authorization: Bearer <user_token> + X-Plugin-Key
|
| 489 |
-
安全:仅返回 source_plugin == 当前 plugin_id 的记录,防止跨插件读取。
|
| 490 |
-
"""
|
| 491 |
-
plugin_info = _require_plugin(x_plugin_key, "verify", db)
|
| 492 |
-
|
| 493 |
-
if not authorization:
|
| 494 |
-
raise HTTPException(status_code=401, detail="缺少 Authorization Header")
|
| 495 |
-
parts = authorization.split(" ", 1)
|
| 496 |
-
if len(parts) != 2 or parts[0].lower() != "bearer":
|
| 497 |
-
raise HTTPException(status_code=401, detail="Authorization 格式错误,请使用 Bearer Token")
|
| 498 |
-
account = await _resolve_user_from_token(parts[1])
|
| 499 |
-
|
| 500 |
plugin_id = plugin_info["id"]
|
| 501 |
|
| 502 |
# 构造查询:按 source_plugin 过滤;兼容历史 NULL 数据走 related_account 兜底
|
|
@@ -546,25 +611,7 @@ async def open_list_transactions(
|
|
| 546 |
}
|
| 547 |
|
| 548 |
|
| 549 |
-
|
| 550 |
-
# 6️⃣ 消费汇总:GET /api/open/usage-summary
|
| 551 |
-
# ==========================================
|
| 552 |
-
@router.get("/usage-summary")
|
| 553 |
-
async def open_usage_summary(
|
| 554 |
-
x_plugin_key: Optional[str] = Header(None, alias="X-Plugin-Key"),
|
| 555 |
-
account: Optional[str] = Query(None, description="指定用户账号,不传则统计所有用户"),
|
| 556 |
-
period: str = Query("daily", description="聚合粒度:daily / monthly / total"),
|
| 557 |
-
start_time: Optional[int] = Query(None, description="起始时间(unix 秒)"),
|
| 558 |
-
end_time: Optional[int] = Query(None, description="结束时间(unix 秒)"),
|
| 559 |
-
db: Session = Depends(get_db),
|
| 560 |
-
):
|
| 561 |
-
"""
|
| 562 |
-
本插件维度的消费汇总统计。
|
| 563 |
-
|
| 564 |
-
返回:总扣款 / 总退款 / 总笔数 / 净消费 + 按 period 分桶的明细。
|
| 565 |
-
安全:仅统计 source_plugin == 当前 plugin_id 的记录。
|
| 566 |
-
"""
|
| 567 |
-
plugin_info = _require_plugin(x_plugin_key, "verify", db)
|
| 568 |
plugin_id = plugin_info["id"]
|
| 569 |
|
| 570 |
period = (period or "daily").lower()
|
|
@@ -638,3 +685,4 @@ async def open_usage_summary(
|
|
| 638 |
},
|
| 639 |
"breakdown": breakdown,
|
| 640 |
}
|
|
|
|
|
|
|
|
|
| 1 |
# router_open_api.py
|
| 2 |
# ==========================================
|
| 3 |
# 🌐 通用开放 API 模块(供第三方插件调用)
|
|
|
|
| 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 |
# 安全机制:
|
|
|
|
| 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 |
|
|
|
|
| 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 = {}
|
|
|
|
| 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
|
|
|
|
| 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 |
# ==========================================
|
|
|
|
| 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']}")
|
|
|
|
| 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")
|
|
|
|
| 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:
|
|
|
|
| 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:
|
|
|
|
| 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"
|
|
|
|
| 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()
|
|
|
|
| 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="原交易不存在")
|
|
|
|
| 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"
|
|
|
|
| 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()
|
|
|
|
| 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 兜底
|
|
|
|
| 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()
|
|
|
|
| 685 |
},
|
| 686 |
"breakdown": breakdown,
|
| 687 |
}
|
| 688 |
+
|
router_users_auth.py
CHANGED
|
@@ -12,7 +12,6 @@
|
|
| 12 |
|
| 13 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 14 |
from rate_limit import shared_limit, store_verification_code, consume_verification_code
|
| 15 |
-
import asyncio
|
| 16 |
import time
|
| 17 |
import re
|
| 18 |
import secrets
|
|
@@ -21,6 +20,8 @@ import logging
|
|
| 21 |
import 数据库连接 as db
|
| 22 |
from models import UserRegister, UserLogin, SendCodeRequest
|
| 23 |
from verify_code_engine import send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown
|
|
|
|
|
|
|
| 24 |
|
| 25 |
# 🔒 P0安全增强:导入密码哈希和 JWT 工具
|
| 26 |
from 安全认证 import hash_password, verify_password, create_token, require_password_match, require_auth
|
|
@@ -59,7 +60,7 @@ async def _check_code_contact(req, request=None):
|
|
| 59 |
account = await require_auth(request.headers.get("Authorization"))
|
| 60 |
if req.account is not None and req.account != account:
|
| 61 |
raise HTTPException(status_code=403, detail="无权为其他账号请求提现验证码")
|
| 62 |
-
user = await
|
| 63 |
contact = user.get("email") or user.get("phone", "")
|
| 64 |
contact_type = "email" if user.get("email") else "phone"
|
| 65 |
if (not isinstance(contact, str) or not contact.strip() or
|
|
@@ -67,12 +68,12 @@ async def _check_code_contact(req, request=None):
|
|
| 67 |
raise HTTPException(status_code=400, detail="联系方式与当前账号绑定信息不匹配")
|
| 68 |
return True
|
| 69 |
if req.action_type == "recover":
|
| 70 |
-
account, _ = await
|
| 71 |
return account is not None
|
| 72 |
if req.action_type == "reset":
|
| 73 |
if not req.account:
|
| 74 |
raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
|
| 75 |
-
user = await
|
| 76 |
if user.get(req.contact_type) != req.contact:
|
| 77 |
raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
|
| 78 |
return True
|
|
@@ -100,7 +101,7 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
|
|
| 100 |
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 101 |
|
| 102 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 103 |
-
|
| 104 |
|
| 105 |
# 生成6位随机验证码
|
| 106 |
code = str(secrets.randbelow(900000) + 100000)
|
|
@@ -112,13 +113,13 @@ async def send_verify_code(request: Request, req: SendCodeRequest, bg_tasks: Bac
|
|
| 112 |
cache_key = f"{req.contact}:{req.action_type}"
|
| 113 |
|
| 114 |
# 将验证码安全摘要写入 SQL,有效期5分钟
|
| 115 |
-
await
|
| 116 |
|
| 117 |
# 根据联系方式类型,添加后台发送任务
|
| 118 |
if req.contact_type == "email":
|
| 119 |
-
bg_tasks.add_task(send_email_code, req.contact, code, req.action_type)
|
| 120 |
elif req.contact_type == "phone":
|
| 121 |
-
bg_tasks.add_task(send_sms_code, req.contact, code, req.action_type)
|
| 122 |
else:
|
| 123 |
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
| 124 |
|
|
@@ -138,7 +139,7 @@ async def send_code_api(request: Request, req: SendCodeRequest):
|
|
| 138 |
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 139 |
|
| 140 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 141 |
-
|
| 142 |
|
| 143 |
# 生成6位随机验证码
|
| 144 |
code = str(secrets.randbelow(900000) + 100000)
|
|
@@ -146,11 +147,11 @@ async def send_code_api(request: Request, req: SendCodeRequest):
|
|
| 146 |
key = f"{req.contact}:{req.action_type}"
|
| 147 |
|
| 148 |
# 存入缓存,有效期5分钟
|
| 149 |
-
await
|
| 150 |
|
| 151 |
if req.contact_type == "email":
|
| 152 |
try:
|
| 153 |
-
await
|
| 154 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 155 |
except Exception as e:
|
| 156 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
|
@@ -159,7 +160,7 @@ async def send_code_api(request: Request, req: SendCodeRequest):
|
|
| 159 |
|
| 160 |
elif req.contact_type == "phone":
|
| 161 |
try:
|
| 162 |
-
await
|
| 163 |
return {"status": "success", "message": "验证码已成功发送至手机"}
|
| 164 |
except Exception as error:
|
| 165 |
logger.error("验证码短信发送失败: action=%s, error_type=%s", req.action_type, type(error).__name__)
|
|
@@ -209,11 +210,11 @@ async def recover_account(request: Request):
|
|
| 209 |
# ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
|
| 210 |
cache_key = f"{email}:recover"
|
| 211 |
# 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态
|
| 212 |
-
if not await
|
| 213 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 214 |
|
| 215 |
# ========== 第三步:按邮箱反查账号 ==========
|
| 216 |
-
matched_account, matched_user = await
|
| 217 |
|
| 218 |
if not matched_account:
|
| 219 |
# 理论上验证码已验证邮箱存在,此处为防御性分支
|
|
@@ -250,7 +251,7 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 250 |
- code: 验证码
|
| 251 |
- intro: 个人介绍(可选,最多100字)
|
| 252 |
"""
|
| 253 |
-
await
|
| 254 |
RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
|
| 255 |
if user.account.lower() in RESERVED_ACCOUNTS:
|
| 256 |
raise HTTPException(status_code=400, detail="该账号名不可用")
|
|
@@ -260,7 +261,7 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 260 |
cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register"
|
| 261 |
# 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
|
| 262 |
# 校验验证码是否正确且未过期
|
| 263 |
-
if not await
|
| 264 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 265 |
|
| 266 |
# ========== 第三步:格式校验 ==========
|
|
@@ -286,7 +287,7 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 286 |
new_user["avatarDataUrl"] = ""
|
| 287 |
|
| 288 |
# 🔒 P0安全增强:密码哈希化存储(不再存储明文密码)
|
| 289 |
-
new_user["password"] = await
|
| 290 |
|
| 291 |
new_user.update({
|
| 292 |
"created_at": int(time.time()), # 注册时间戳
|
|
@@ -296,7 +297,7 @@ async def register_user(request: Request, user: UserRegister):
|
|
| 296 |
})
|
| 297 |
|
| 298 |
# 保存到数据库
|
| 299 |
-
public_user = await
|
| 300 |
return {"status": "success", "message": "注册成功", "data": public_user}
|
| 301 |
|
| 302 |
|
|
@@ -317,18 +318,18 @@ async def login_user(request: Request, user: UserLogin):
|
|
| 317 |
- account: 账号
|
| 318 |
- password: 密码
|
| 319 |
"""
|
| 320 |
-
user_data = await
|
| 321 |
stored_password = user_data.get("password") or ""
|
| 322 |
if not isinstance(stored_password, str):
|
| 323 |
raise HTTPException(status_code=503, detail="用户认证数据暂不可用")
|
| 324 |
password_version = get_password_version(user_data)
|
| 325 |
-
if not await
|
| 326 |
raise HTTPException(status_code=401, detail="密码错误")
|
| 327 |
migrated = None
|
| 328 |
if stored_password and not stored_password.startswith(('$2b$', '$2a$')):
|
| 329 |
-
migrated = await
|
| 330 |
# bcrypt 运行期间可能发生密码重置,不能用旧密码升级覆盖新密码。
|
| 331 |
-
user_data = await
|
| 332 |
|
| 333 |
# 🔒 P0安全增强:生成 JWT Token(替代 mock_token)
|
| 334 |
# 获取password_version用于Token生成(如不存在则默认为0)
|
|
@@ -399,13 +400,13 @@ async def reset_password(request: Request):
|
|
| 399 |
raise HTTPException(status_code=400, detail="请求参数格式不正确")
|
| 400 |
if verify_type not in ("email", "phone"):
|
| 401 |
raise HTTPException(status_code=400, detail="联系方式类型不正确")
|
| 402 |
-
user = await
|
| 403 |
if user.get(verify_type) != verify_contact:
|
| 404 |
raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
|
| 405 |
|
| 406 |
# 校验验证码(原子性获取+删除)
|
| 407 |
cache_key = f"{verify_contact}:reset"
|
| 408 |
-
if not await
|
| 409 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 410 |
|
| 411 |
# 校验新密码格式
|
|
@@ -414,9 +415,9 @@ async def reset_password(request: Request):
|
|
| 414 |
if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password):
|
| 415 |
raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符")
|
| 416 |
|
| 417 |
-
password_hash = await
|
| 418 |
# 保存最新记录时再次检查绑定信息,版本严格递增(同秒重置也撤销旧凭证)。
|
| 419 |
-
version = await
|
| 420 |
new_token = create_token(account, extra_data={"pwd_ver": version})
|
| 421 |
|
| 422 |
return {"status": "success", "message": "密码修改成功", "token": new_token}
|
|
|
|
| 12 |
|
| 13 |
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request
|
| 14 |
from rate_limit import shared_limit, store_verification_code, consume_verification_code
|
|
|
|
| 15 |
import time
|
| 16 |
import re
|
| 17 |
import secrets
|
|
|
|
| 20 |
import 数据库连接 as db
|
| 21 |
from models import UserRegister, UserLogin, SendCodeRequest
|
| 22 |
from verify_code_engine import send_email_code, send_sms_code, cleanup_expired_codes, check_send_cooldown
|
| 23 |
+
from verification_io import run_verification_io
|
| 24 |
+
from auth_io import run_auth_io
|
| 25 |
|
| 26 |
# 🔒 P0安全增强:导入密码哈希和 JWT 工具
|
| 27 |
from 安全认证 import hash_password, verify_password, create_token, require_password_match, require_auth
|
|
|
|
| 60 |
account = await require_auth(request.headers.get("Authorization"))
|
| 61 |
if req.account is not None and req.account != account:
|
| 62 |
raise HTTPException(status_code=403, detail="无权为其他账号请求提现验证码")
|
| 63 |
+
user = await run_verification_io(read_user, account)
|
| 64 |
contact = user.get("email") or user.get("phone", "")
|
| 65 |
contact_type = "email" if user.get("email") else "phone"
|
| 66 |
if (not isinstance(contact, str) or not contact.strip() or
|
|
|
|
| 68 |
raise HTTPException(status_code=400, detail="联系方式与当前账号绑定信息不匹配")
|
| 69 |
return True
|
| 70 |
if req.action_type == "recover":
|
| 71 |
+
account, _ = await run_verification_io(_find_contact_account, req.contact, req.contact_type)
|
| 72 |
return account is not None
|
| 73 |
if req.action_type == "reset":
|
| 74 |
if not req.account:
|
| 75 |
raise HTTPException(status_code=400, detail="找回密码需先填写当前账号")
|
| 76 |
+
user = await run_verification_io(read_user, req.account)
|
| 77 |
if user.get(req.contact_type) != req.contact:
|
| 78 |
raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
|
| 79 |
return True
|
|
|
|
| 101 |
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 102 |
|
| 103 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 104 |
+
await run_verification_io(check_send_cooldown, req.contact)
|
| 105 |
|
| 106 |
# 生成6位随机验证码
|
| 107 |
code = str(secrets.randbelow(900000) + 100000)
|
|
|
|
| 113 |
cache_key = f"{req.contact}:{req.action_type}"
|
| 114 |
|
| 115 |
# 将验证码安全摘要写入 SQL,有效期5分钟
|
| 116 |
+
await run_verification_io(store_verification_code, cache_key, code, int(time.time()) + 300)
|
| 117 |
|
| 118 |
# 根据联系方式类型,添加后台发送任务
|
| 119 |
if req.contact_type == "email":
|
| 120 |
+
bg_tasks.add_task(run_verification_io, send_email_code, req.contact, code, req.action_type)
|
| 121 |
elif req.contact_type == "phone":
|
| 122 |
+
bg_tasks.add_task(run_verification_io, send_sms_code, req.contact, code, req.action_type)
|
| 123 |
else:
|
| 124 |
raise HTTPException(status_code=400, detail="不支持的验证方式")
|
| 125 |
|
|
|
|
| 139 |
return {"status": "success", "message": "如果该联系方式已绑定账号,验证码已发送"}
|
| 140 |
|
| 141 |
# 🔒 检查发送频率限制(同一联系方式60秒内只能发送1条)
|
| 142 |
+
await run_verification_io(check_send_cooldown, req.contact)
|
| 143 |
|
| 144 |
# 生成6位随机验证码
|
| 145 |
code = str(secrets.randbelow(900000) + 100000)
|
|
|
|
| 147 |
key = f"{req.contact}:{req.action_type}"
|
| 148 |
|
| 149 |
# 存入缓存,有效期5分钟
|
| 150 |
+
await run_verification_io(store_verification_code, key, code, int(time.time()) + 300)
|
| 151 |
|
| 152 |
if req.contact_type == "email":
|
| 153 |
try:
|
| 154 |
+
await run_verification_io(send_email_code, req.contact, code, req.action_type)
|
| 155 |
return {"status": "success", "message": "验证码已成功发送至邮箱"}
|
| 156 |
except Exception as e:
|
| 157 |
# 🔒 P0安全修复:仅服务端记录详细错误,不暴露给前端
|
|
|
|
| 160 |
|
| 161 |
elif req.contact_type == "phone":
|
| 162 |
try:
|
| 163 |
+
await run_verification_io(send_sms_code, req.contact, code, req.action_type)
|
| 164 |
return {"status": "success", "message": "验证码已成功发送至手机"}
|
| 165 |
except Exception as error:
|
| 166 |
logger.error("验证码短信发送失败: action=%s, error_type=%s", req.action_type, type(error).__name__)
|
|
|
|
| 210 |
# ========== 第二步:验证码校验(原子性一次性消费,并发安全) ==========
|
| 211 |
cache_key = f"{email}:recover"
|
| 212 |
# 🛡️ 防枚举:验证码错误与邮箱未注册统一报错,不暴露账号注册状态
|
| 213 |
+
if not await run_auth_io(consume_verification_code, cache_key, code):
|
| 214 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 215 |
|
| 216 |
# ========== 第三步:按邮箱反查账号 ==========
|
| 217 |
+
matched_account, matched_user = await run_auth_io(_find_contact_account, email, "email")
|
| 218 |
|
| 219 |
if not matched_account:
|
| 220 |
# 理论上验证码已验证邮箱存在,此处为防御性分支
|
|
|
|
| 251 |
- code: 验证码
|
| 252 |
- intro: 个人介绍(可选,最多100字)
|
| 253 |
"""
|
| 254 |
+
await run_auth_io(check_registration, user.account, user.email, user.phone)
|
| 255 |
RESERVED_ACCOUNTS = {"admin", "system", "root", "test", "anonymous", "administrator"}
|
| 256 |
if user.account.lower() in RESERVED_ACCOUNTS:
|
| 257 |
raise HTTPException(status_code=400, detail="该账号名不可用")
|
|
|
|
| 261 |
cache_key = f"{user.email}:register" if user.email else f"{user.phone}:register"
|
| 262 |
# 🔒 P0安全修复:验证码一次性使用,并发安全键防止并发重用
|
| 263 |
# 校验验证码是否正确且未过期
|
| 264 |
+
if not await run_auth_io(consume_verification_code, cache_key, user.code):
|
| 265 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 266 |
|
| 267 |
# ========== 第三步:格式校验 ==========
|
|
|
|
| 287 |
new_user["avatarDataUrl"] = ""
|
| 288 |
|
| 289 |
# 🔒 P0安全增强:密码哈希化存储(不再存储明文密码)
|
| 290 |
+
new_user["password"] = await run_auth_io(hash_password, new_user["password"])
|
| 291 |
|
| 292 |
new_user.update({
|
| 293 |
"created_at": int(time.time()), # 注册时间戳
|
|
|
|
| 297 |
})
|
| 298 |
|
| 299 |
# 保存到数据库
|
| 300 |
+
public_user = await run_auth_io(create_user, new_user)
|
| 301 |
return {"status": "success", "message": "注册成功", "data": public_user}
|
| 302 |
|
| 303 |
|
|
|
|
| 318 |
- account: 账号
|
| 319 |
- password: 密码
|
| 320 |
"""
|
| 321 |
+
user_data = await run_auth_io(read_user, user.account)
|
| 322 |
stored_password = user_data.get("password") or ""
|
| 323 |
if not isinstance(stored_password, str):
|
| 324 |
raise HTTPException(status_code=503, detail="用户认证数据暂不可用")
|
| 325 |
password_version = get_password_version(user_data)
|
| 326 |
+
if not await run_auth_io(require_password_match, stored_password, user.password):
|
| 327 |
raise HTTPException(status_code=401, detail="密码错误")
|
| 328 |
migrated = None
|
| 329 |
if stored_password and not stored_password.startswith(('$2b$', '$2a$')):
|
| 330 |
+
migrated = await run_auth_io(hash_password, user.password)
|
| 331 |
# bcrypt 运行期间可能发生密码重置,不能用旧密码升级覆盖新密码。
|
| 332 |
+
user_data = await run_auth_io(finish_login, user.account, stored_password, password_version, migrated)
|
| 333 |
|
| 334 |
# 🔒 P0安全增强:生成 JWT Token(替代 mock_token)
|
| 335 |
# 获取password_version用于Token生成(如不存在则默认为0)
|
|
|
|
| 400 |
raise HTTPException(status_code=400, detail="请求参数格式不正确")
|
| 401 |
if verify_type not in ("email", "phone"):
|
| 402 |
raise HTTPException(status_code=400, detail="联系方式类型不正确")
|
| 403 |
+
user = await run_auth_io(read_user, account)
|
| 404 |
if user.get(verify_type) != verify_contact:
|
| 405 |
raise HTTPException(status_code=400, detail="填写的联系方式与该账号绑定信息不匹配")
|
| 406 |
|
| 407 |
# 校验验证码(原子性获取+删除)
|
| 408 |
cache_key = f"{verify_contact}:reset"
|
| 409 |
+
if not await run_auth_io(consume_verification_code, cache_key, code):
|
| 410 |
raise HTTPException(status_code=400, detail="验证码不正确或已过期")
|
| 411 |
|
| 412 |
# 校验新密码格式
|
|
|
|
| 415 |
if not re.match(r'^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]{6,}$', new_password):
|
| 416 |
raise HTTPException(status_code=400, detail="新密码包含不支持的特殊字符")
|
| 417 |
|
| 418 |
+
password_hash = await run_auth_io(hash_password, new_password)
|
| 419 |
# 保存最新记录时再次检查绑定信息,版本严格递增(同秒重置也撤销旧凭证)。
|
| 420 |
+
version = await run_auth_io(reset_user_password, account, verify_type, verify_contact, password_hash)
|
| 421 |
new_token = create_token(account, extra_data={"pwd_ver": version})
|
| 422 |
|
| 423 |
return {"status": "success", "message": "密码修改成功", "token": new_token}
|
shutdown_lifecycle.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""同一应用代次共用一个关闭拥有者和真实终态。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import threading
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def prepare_application_shutdown(state):
|
| 7 |
+
previous = getattr(state, '_ranking_shutdown_future', None)
|
| 8 |
+
if previous is not None and not previous.done():
|
| 9 |
+
raise RuntimeError('application shutdown is still pending')
|
| 10 |
+
if getattr(state, '_ranking_shutdown_incomplete', False):
|
| 11 |
+
raise RuntimeError('application cleanup is incomplete')
|
| 12 |
+
state._ranking_shutdown_future = None
|
| 13 |
+
state._ranking_shutdown_owner = None
|
| 14 |
+
state._ranking_shutdown_cancelled = False
|
| 15 |
+
state._ranking_shutdown_stop = threading.Event()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def shutdown_cancel_requested(state):
|
| 19 |
+
if getattr(state, '_ranking_shutdown_cancelled', False):
|
| 20 |
+
return True
|
| 21 |
+
owner = getattr(state, '_ranking_shutdown_owner', None)
|
| 22 |
+
return owner is not None and owner.cancelling() > state._ranking_shutdown_initial_cancels
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def _wait_shutdown(state, future):
|
| 26 |
+
caller = asyncio.current_task()
|
| 27 |
+
initial_cancels = caller.cancelling()
|
| 28 |
+
cancelled = None
|
| 29 |
+
while not future.done():
|
| 30 |
+
try:
|
| 31 |
+
await asyncio.shield(future)
|
| 32 |
+
except asyncio.CancelledError as error:
|
| 33 |
+
if caller.cancelling() > initial_cancels:
|
| 34 |
+
if getattr(state, '_ranking_shutdown_future', None) is future:
|
| 35 |
+
state._ranking_shutdown_cancelled = True
|
| 36 |
+
cancelled = error
|
| 37 |
+
except BaseException:
|
| 38 |
+
break
|
| 39 |
+
if cancelled is not None:
|
| 40 |
+
raise cancelled
|
| 41 |
+
return future.result()
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def run_application_shutdown(state, operation):
|
| 45 |
+
owner = asyncio.current_task()
|
| 46 |
+
previous = getattr(state, '_ranking_shutdown_future', None)
|
| 47 |
+
retry = (previous is not None and previous.done()
|
| 48 |
+
and (previous.cancelled() or previous.exception() is not None))
|
| 49 |
+
if previous is not None and not retry:
|
| 50 |
+
if not previous.done() and getattr(state, '_ranking_shutdown_owner', None) is owner:
|
| 51 |
+
raise RuntimeError('application shutdown reentry')
|
| 52 |
+
return await _wait_shutdown(state, previous)
|
| 53 |
+
future = asyncio.get_running_loop().create_future()
|
| 54 |
+
stop = getattr(state, '_ranking_shutdown_stop', None)
|
| 55 |
+
if stop is None:
|
| 56 |
+
stop = threading.Event()
|
| 57 |
+
stop.set()
|
| 58 |
+
state._ranking_shutdown_stop = stop
|
| 59 |
+
state._ranking_shutdown_future = future
|
| 60 |
+
state._ranking_shutdown_owner = owner
|
| 61 |
+
state._ranking_shutdown_initial_cancels = owner.cancelling()
|
| 62 |
+
state._ranking_shutdown_cancelled = False
|
| 63 |
+
state._ranking_closers = getattr(state, '_ranking_closers', 0) + 1
|
| 64 |
+
try:
|
| 65 |
+
result = await operation()
|
| 66 |
+
if shutdown_cancel_requested(state):
|
| 67 |
+
raise asyncio.CancelledError
|
| 68 |
+
except BaseException as error:
|
| 69 |
+
if not future.done():
|
| 70 |
+
if isinstance(error, asyncio.CancelledError):
|
| 71 |
+
future.cancel()
|
| 72 |
+
else:
|
| 73 |
+
future.set_exception(error)
|
| 74 |
+
future.exception()
|
| 75 |
+
raise
|
| 76 |
+
else:
|
| 77 |
+
if not future.done():
|
| 78 |
+
future.set_result(result)
|
| 79 |
+
return result
|
| 80 |
+
finally:
|
| 81 |
+
state._ranking_shutdown_owner = None
|
| 82 |
+
state._ranking_closers -= 1
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ShutdownFailures:
|
| 86 |
+
def __init__(self, logger):
|
| 87 |
+
self.logger = logger
|
| 88 |
+
self.error = None
|
| 89 |
+
self.cancelled = None
|
| 90 |
+
self.incomplete = False
|
| 91 |
+
|
| 92 |
+
def _record(self, stage, error, *, incomplete):
|
| 93 |
+
self.incomplete |= incomplete
|
| 94 |
+
if isinstance(error, asyncio.CancelledError):
|
| 95 |
+
if self.cancelled is None:
|
| 96 |
+
self.cancelled = error
|
| 97 |
+
return
|
| 98 |
+
if self.error is None:
|
| 99 |
+
self.error = error
|
| 100 |
+
try:
|
| 101 |
+
self.logger.error('SHUTDOWN_STEP_FAILED | stage=%s | error_type=%s', stage, type(error).__name__)
|
| 102 |
+
except Exception:
|
| 103 |
+
pass
|
| 104 |
+
|
| 105 |
+
def call(self, stage, function, *args):
|
| 106 |
+
try:
|
| 107 |
+
return function(*args)
|
| 108 |
+
except asyncio.CancelledError as error:
|
| 109 |
+
self._record(stage, error, incomplete=True)
|
| 110 |
+
except Exception as error:
|
| 111 |
+
self._record(stage, error, incomplete=True)
|
| 112 |
+
|
| 113 |
+
async def wait(self, stage, function, *args):
|
| 114 |
+
try:
|
| 115 |
+
return await function(*args)
|
| 116 |
+
except asyncio.CancelledError as error:
|
| 117 |
+
self._record(stage, error, incomplete=False)
|
| 118 |
+
except Exception as error:
|
| 119 |
+
self._record(stage, error, incomplete=True)
|
| 120 |
+
|
| 121 |
+
def raise_if_failed(self, *, cancel_requested):
|
| 122 |
+
if self.cancelled is not None:
|
| 123 |
+
raise self.cancelled
|
| 124 |
+
if cancel_requested:
|
| 125 |
+
raise asyncio.CancelledError
|
| 126 |
+
if self.error is not None:
|
| 127 |
+
raise self.error
|
startup_warmup.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""启动预热的线程归属、停止信号与关闭等待。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
import threading
|
| 4 |
+
|
| 5 |
+
from owned_thread import run_owned_thread
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def prepare_startup_warmup(state):
|
| 9 |
+
previous = getattr(state, '_ranking_warmup_task', None)
|
| 10 |
+
if previous is not None and not previous.done():
|
| 11 |
+
raise RuntimeError('startup work is still pending')
|
| 12 |
+
state._ranking_warmup_stop = threading.Event()
|
| 13 |
+
state._ranking_warmup_shutdown = False
|
| 14 |
+
state._ranking_warmup_task = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def startup_warmup_stopped(state):
|
| 18 |
+
stop = getattr(state, '_ranking_warmup_stop', None)
|
| 19 |
+
shutdown = getattr(state, '_ranking_shutdown_stop', None)
|
| 20 |
+
return (stop is not None and stop.is_set()) or (shutdown is not None and shutdown.is_set())
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def stop_startup_warmup(state):
|
| 24 |
+
state._ranking_warmup_shutdown = True
|
| 25 |
+
stop = getattr(state, '_ranking_warmup_stop', None)
|
| 26 |
+
if stop is None:
|
| 27 |
+
stop = threading.Event()
|
| 28 |
+
state._ranking_warmup_stop = stop
|
| 29 |
+
stop.set()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def startup_shutdown_requested(state):
|
| 33 |
+
shutdown = getattr(state, '_ranking_shutdown_stop', None)
|
| 34 |
+
return getattr(state, '_ranking_warmup_shutdown', False) or (shutdown is not None and shutdown.is_set())
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def _wait_task(task, *, result, on_cancel=None):
|
| 38 |
+
owner = asyncio.current_task()
|
| 39 |
+
initial_cancels = owner.cancelling()
|
| 40 |
+
cancelled = None
|
| 41 |
+
while not task.done():
|
| 42 |
+
try:
|
| 43 |
+
await asyncio.shield(task)
|
| 44 |
+
except asyncio.CancelledError as error:
|
| 45 |
+
if owner.cancelling() > initial_cancels:
|
| 46 |
+
cancelled = error
|
| 47 |
+
if on_cancel is not None:
|
| 48 |
+
on_cancel()
|
| 49 |
+
except BaseException:
|
| 50 |
+
break
|
| 51 |
+
if not task.cancelled():
|
| 52 |
+
task.exception()
|
| 53 |
+
if cancelled is not None:
|
| 54 |
+
raise cancelled
|
| 55 |
+
if result:
|
| 56 |
+
return task.result()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def run_startup_warmup(state, function):
|
| 60 |
+
if startup_warmup_stopped(state):
|
| 61 |
+
raise asyncio.CancelledError
|
| 62 |
+
previous = getattr(state, '_ranking_warmup_task', None)
|
| 63 |
+
if previous is not None and not previous.done():
|
| 64 |
+
raise RuntimeError('startup work is still pending')
|
| 65 |
+
coroutine = run_owned_thread(function)
|
| 66 |
+
try:
|
| 67 |
+
task = asyncio.create_task(coroutine)
|
| 68 |
+
except BaseException:
|
| 69 |
+
coroutine.close()
|
| 70 |
+
raise
|
| 71 |
+
state._ranking_warmup_task = task
|
| 72 |
+
try:
|
| 73 |
+
return await _wait_task(task, result=True, on_cancel=state._ranking_warmup_stop.set)
|
| 74 |
+
finally:
|
| 75 |
+
if startup_warmup_stopped(state):
|
| 76 |
+
raise asyncio.CancelledError
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
async def wait_startup_warmup(state):
|
| 80 |
+
task = getattr(state, '_ranking_warmup_task', None)
|
| 81 |
+
if task is not None:
|
| 82 |
+
await _wait_task(task, result=False)
|
verification_io.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""发码同步工作的有界准入与关闭等待。"""
|
| 2 |
+
from fastapi import HTTPException
|
| 3 |
+
from owned_thread import OwnedThreadGroup
|
| 4 |
+
|
| 5 |
+
MAX_VERIFICATION_IO = 8
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class VerificationIO(OwnedThreadGroup):
|
| 9 |
+
def __init__(self, capacity=MAX_VERIFICATION_IO):
|
| 10 |
+
super().__init__(capacity, lambda: HTTPException(status_code=503, detail='验证码服务暂不可用'))
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
_verification_io = VerificationIO()
|
| 14 |
+
run_verification_io = _verification_io.run
|
| 15 |
+
start_verification_io = _verification_io.start
|
| 16 |
+
stop_verification_io = _verification_io.stop
|
| 17 |
+
close_verification_io = _verification_io.close
|
verify_code_engine.py
CHANGED
|
@@ -32,21 +32,8 @@ _CLEANUP_INTERVAL = 300 # 5分钟清理一次
|
|
| 32 |
# 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
|
| 33 |
|
| 34 |
|
| 35 |
-
def
|
| 36 |
-
"""
|
| 37 |
-
🔒 联系方式脱敏(邮箱 / 手机号统一处理),仅用于日志输出
|
| 38 |
-
参数:contact - 原始邮箱或手机号
|
| 39 |
-
返回:脱敏后的字符串,保留足够辨识度以便排障,但不足以定位到具体用户
|
| 40 |
-
"""
|
| 41 |
-
if not contact:
|
| 42 |
-
return ""
|
| 43 |
-
contact = str(contact)
|
| 44 |
-
if "@" in contact:
|
| 45 |
-
name, _, domain = contact.partition("@")
|
| 46 |
-
return f"{name[:1]}***@{domain}" if len(name) > 1 else f"***@{domain}"
|
| 47 |
-
if len(contact) >= 7:
|
| 48 |
-
return contact[:3] + "****" + contact[-2:]
|
| 49 |
-
return "***"
|
| 50 |
|
| 51 |
|
| 52 |
def check_send_cooldown(contact: str):
|
|
@@ -91,6 +78,8 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 91 |
if not webhook_url:
|
| 92 |
raise RuntimeError("verification_delivery_unavailable")
|
| 93 |
|
|
|
|
|
|
|
| 94 |
# 根据动作类型生成不同的邮件标题
|
| 95 |
if action == "register":
|
| 96 |
action_str = "注册账号"
|
|
@@ -132,14 +121,13 @@ def send_email_code(to_email: str, code: str, action: str):
|
|
| 132 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 133 |
if not 200 <= response.status < 300:
|
| 134 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 135 |
-
|
| 136 |
-
print(f"✅ 成功触发 Webhook 发送验证码邮件至 {_mask_contact(to_email)}(动作: {action})")
|
| 137 |
except Exception as e:
|
| 138 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
| 139 |
if not isinstance(status, int):
|
| 140 |
status = getattr(e, "code", None)
|
| 141 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 142 |
-
print(f"❌ Webhook 触发失败: action={
|
| 143 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 144 |
|
| 145 |
|
|
@@ -157,6 +145,7 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 157 |
- 环境变量 ALIYUN_AK, ALIYUN_SK, ALIYUN_SIGN_NAME (阿里云配置)
|
| 158 |
- router_users_auth.py 的 send_verify_code() 异步调用此函数
|
| 159 |
"""
|
|
|
|
| 160 |
if action == "register":
|
| 161 |
action_str = "注册账号"
|
| 162 |
elif action == "withdraw":
|
|
@@ -192,13 +181,13 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 192 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 193 |
if not 200 <= response.status < 300:
|
| 194 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 195 |
-
print(f"✅ Twilio 短信
|
| 196 |
except Exception as e:
|
| 197 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
| 198 |
if not isinstance(status, int):
|
| 199 |
status = getattr(e, "code", None)
|
| 200 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 201 |
-
print(f"❌ Twilio 发送失败:
|
| 202 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 203 |
return
|
| 204 |
|
|
@@ -240,12 +229,12 @@ def send_sms_code(phone: str, code: str, action: str):
|
|
| 240 |
try:
|
| 241 |
response = client.send_sms(send_req)
|
| 242 |
if response.body.code == "OK":
|
| 243 |
-
print(f"✅ 阿里云短信
|
| 244 |
else:
|
| 245 |
-
print(f"❌ 阿里云下发失败:
|
| 246 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 247 |
except Exception as e:
|
| 248 |
-
print(f"❌ 阿里云请求异常:
|
| 249 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 250 |
return
|
| 251 |
|
|
|
|
| 32 |
# 作用:HF Spaces 的运行日志对外可见,验证码与联系方式明文入日志等同于泄露账号
|
| 33 |
|
| 34 |
|
| 35 |
+
def _delivery_log_action(action: str) -> str:
|
| 36 |
+
return action if type(action) is str and action in ("register", "reset", "recover", "withdraw") else "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def check_send_cooldown(contact: str):
|
|
|
|
| 78 |
if not webhook_url:
|
| 79 |
raise RuntimeError("verification_delivery_unavailable")
|
| 80 |
|
| 81 |
+
log_action = _delivery_log_action(action)
|
| 82 |
+
|
| 83 |
# 根据动作类型生成不同的邮件标题
|
| 84 |
if action == "register":
|
| 85 |
action_str = "注册账号"
|
|
|
|
| 121 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 122 |
if not 200 <= response.status < 300:
|
| 123 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 124 |
+
print(f"✅ Webhook 验证码邮件发送成功: action={log_action}")
|
|
|
|
| 125 |
except Exception as e:
|
| 126 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
| 127 |
if not isinstance(status, int):
|
| 128 |
status = getattr(e, "code", None)
|
| 129 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 130 |
+
print(f"❌ Webhook 触发失败: action={log_action}, error_type={type(e).__name__}{status_text}")
|
| 131 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 132 |
|
| 133 |
|
|
|
|
| 145 |
- 环境变量 ALIYUN_AK, ALIYUN_SK, ALIYUN_SIGN_NAME (阿里云配置)
|
| 146 |
- router_users_auth.py 的 send_verify_code() 异步调用此函数
|
| 147 |
"""
|
| 148 |
+
log_action = _delivery_log_action(action)
|
| 149 |
if action == "register":
|
| 150 |
action_str = "注册账号"
|
| 151 |
elif action == "withdraw":
|
|
|
|
| 181 |
with urllib.request.urlopen(req, timeout=10) as response:
|
| 182 |
if not 200 <= response.status < 300:
|
| 183 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 184 |
+
print(f"✅ Twilio 短信发送成功: action={log_action}")
|
| 185 |
except Exception as e:
|
| 186 |
status = getattr(getattr(e, "response", None), "status_code", None)
|
| 187 |
if not isinstance(status, int):
|
| 188 |
status = getattr(e, "code", None)
|
| 189 |
status_text = f", status={status}" if isinstance(status, int) and 100 <= status <= 599 else ""
|
| 190 |
+
print(f"❌ Twilio 发送失败: action={log_action}, error_type={type(e).__name__}{status_text}")
|
| 191 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 192 |
return
|
| 193 |
|
|
|
|
| 229 |
try:
|
| 230 |
response = client.send_sms(send_req)
|
| 231 |
if response.body.code == "OK":
|
| 232 |
+
print(f"✅ 阿里云短信发送成功: action={log_action}")
|
| 233 |
else:
|
| 234 |
+
print(f"❌ 阿里云下发失败: action={log_action}")
|
| 235 |
raise RuntimeError("verification_delivery_unconfirmed")
|
| 236 |
except Exception as e:
|
| 237 |
+
print(f"❌ 阿里云请求异常: action={log_action}, error_type={type(e).__name__}")
|
| 238 |
raise RuntimeError("verification_delivery_unconfirmed") from None
|
| 239 |
return
|
| 240 |
|
安全认证.py
CHANGED
|
@@ -10,7 +10,6 @@
|
|
| 10 |
# ==========================================
|
| 11 |
|
| 12 |
import os
|
| 13 |
-
import asyncio
|
| 14 |
import math
|
| 15 |
import hashlib
|
| 16 |
import hmac
|
|
@@ -20,6 +19,7 @@ import time
|
|
| 20 |
from typing import Optional, Tuple
|
| 21 |
from fastapi import HTTPException, Header
|
| 22 |
from user_store import read_user, get_password_version
|
|
|
|
| 23 |
|
| 24 |
# 🔒 P0安全增强:引入bcrypt进行密码哈希
|
| 25 |
import bcrypt
|
|
@@ -335,7 +335,7 @@ async def require_auth(authorization: str = Header(None, alias="Authorization"))
|
|
| 335 |
token = parts[1]
|
| 336 |
|
| 337 |
# 验证 Token(严格 JWT 验证,不再有降级信任)
|
| 338 |
-
is_valid, account, error_msg = await
|
| 339 |
|
| 340 |
if not is_valid:
|
| 341 |
raise HTTPException(status_code=401, detail=error_msg)
|
|
|
|
| 10 |
# ==========================================
|
| 11 |
|
| 12 |
import os
|
|
|
|
| 13 |
import math
|
| 14 |
import hashlib
|
| 15 |
import hmac
|
|
|
|
| 19 |
from typing import Optional, Tuple
|
| 20 |
from fastapi import HTTPException, Header
|
| 21 |
from user_store import read_user, get_password_version
|
| 22 |
+
from auth_io import run_identity_io
|
| 23 |
|
| 24 |
# 🔒 P0安全增强:引入bcrypt进行密码哈希
|
| 25 |
import bcrypt
|
|
|
|
| 335 |
token = parts[1]
|
| 336 |
|
| 337 |
# 验证 Token(严格 JWT 验证,不再有降级信任)
|
| 338 |
+
is_valid, account, error_msg = await run_identity_io(verify_token_with_fallback, token)
|
| 339 |
|
| 340 |
if not is_valid:
|
| 341 |
raise HTTPException(status_code=401, detail=error_msg)
|