Spaces:
Running
Running
Upload 7 files
Browse files- app.py +12 -4
- owned_thread.py +144 -131
- resource_work.py +2 -1
- router_comments.py +138 -126
- router_items.py +32 -12
- router_posts.py +648 -629
- router_prompts.py +988 -967
app.py
CHANGED
|
@@ -67,13 +67,13 @@ from router_users_social import router as users_social_router
|
|
| 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(第三方插件)
|
|
@@ -295,7 +295,11 @@ async def on_startup():
|
|
| 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 |
|
|
@@ -354,6 +358,8 @@ async def _shutdown_resources():
|
|
| 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),
|
|
@@ -368,6 +374,8 @@ async def _shutdown_resources():
|
|
| 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, ()),
|
|
|
|
| 67 |
# ==========================================
|
| 68 |
# 其他业务模块
|
| 69 |
# ==========================================
|
| 70 |
+
from router_items import (router as items_router, start_resource_reads, stop_resource_reads, close_resource_reads) # 📦 内容管理(工具/Skill/应用/推荐)
|
| 71 |
+
from router_comments import (router as comments_router, start_comment_writes, stop_comment_writes, close_comment_writes) # 💬 评论系统
|
| 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, start_posts_io, stop_posts_io, close_posts_io) # 💬 讨论区
|
| 76 |
+
from router_prompts import (router as prompts_router, start_prompts_io, stop_prompts_io, close_prompts_io) # ✨ 提示词市场
|
| 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(第三方插件)
|
|
|
|
| 295 |
start_shared_work()
|
| 296 |
start_open_sql()
|
| 297 |
start_resource_work()
|
| 298 |
+
start_resource_reads()
|
| 299 |
+
start_comment_writes()
|
| 300 |
start_message_io()
|
| 301 |
+
start_posts_io()
|
| 302 |
+
start_prompts_io()
|
| 303 |
start_comment_reads()
|
| 304 |
start_admin_scripts()
|
| 305 |
|
|
|
|
| 358 |
failures.call('log_start', logger.info, "🛑 ComfyUI-Ranking API 正在关闭...")
|
| 359 |
failures.call('stop_warmup', stop_startup_warmup, app.state)
|
| 360 |
for stage, stop in (
|
| 361 |
+
('stop_resource_reads', stop_resource_reads), ('stop_comment_writes', stop_comment_writes),
|
| 362 |
+
('stop_posts', stop_posts_io), ('stop_prompts', stop_prompts_io),
|
| 363 |
('stop_messages', stop_message_io), ('stop_resources', stop_resource_work), ('stop_open_sql', stop_open_sql), ('stop_shared', stop_shared_work),
|
| 364 |
('stop_identity', stop_identity_io), ('stop_auth', stop_auth_io),
|
| 365 |
('stop_verification', stop_verification_io), ('stop_scripts', stop_admin_scripts),
|
|
|
|
| 374 |
failures.call('stop_' + name, request_periodic_stop, app.state, (name,))
|
| 375 |
for stage, close, args in (
|
| 376 |
('wait_warmup', wait_startup_warmup, (app.state,)),
|
| 377 |
+
('close_resource_reads', close_resource_reads, ()), ('close_comment_writes', close_comment_writes, ()),
|
| 378 |
+
('close_posts', close_posts_io, ()), ('close_prompts', close_prompts_io, ()),
|
| 379 |
('close_messages', close_message_io, ()), ('close_resources', close_resource_work, ()), ('close_open_sql', close_open_sql, ()), ('close_shared', close_shared_work, ()),
|
| 380 |
('close_identity', close_identity_io, ()), ('close_auth', close_auth_io, ()),
|
| 381 |
('close_verification', close_verification_io, ()), ('close_profile', close_profile_io, ()),
|
owned_thread.py
CHANGED
|
@@ -1,131 +1,144 @@
|
|
| 1 |
-
"""等待所属同步工作真实终态;内部取消也不能遗留仍读写资源的线程。"""
|
| 2 |
-
import asyncio
|
| 3 |
-
import
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
except
|
| 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 |
-
if not
|
| 74 |
-
raise
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
self.
|
| 85 |
-
self.
|
| 86 |
-
self.
|
| 87 |
-
self.
|
| 88 |
-
self.
|
| 89 |
-
self.
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
self.
|
| 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 |
-
self.
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""等待所属同步工作真实终态;内部取消也不能遗留仍读写资源的线程。"""
|
| 2 |
+
import asyncio
|
| 3 |
+
from functools import wraps
|
| 4 |
+
from inspect import iscoroutinefunction, isasyncgenfunction, isgeneratorfunction
|
| 5 |
+
import threading
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def run_owned_thread(function, *args, **kwargs):
|
| 9 |
+
ownership = threading.Lock()
|
| 10 |
+
finished = threading.Event()
|
| 11 |
+
started = False
|
| 12 |
+
outcome = None
|
| 13 |
+
|
| 14 |
+
def abandon_unstarted(result):
|
| 15 |
+
nonlocal outcome
|
| 16 |
+
with ownership:
|
| 17 |
+
if not started and not finished.is_set():
|
| 18 |
+
outcome = result
|
| 19 |
+
finished.set()
|
| 20 |
+
|
| 21 |
+
def execute():
|
| 22 |
+
nonlocal started, outcome
|
| 23 |
+
with ownership:
|
| 24 |
+
if finished.is_set():
|
| 25 |
+
return
|
| 26 |
+
started = True
|
| 27 |
+
try:
|
| 28 |
+
result = (True, function(*args, **kwargs))
|
| 29 |
+
except BaseException as error:
|
| 30 |
+
# 同步异常作为数据交回;取消shield不暴露私有异常正文。
|
| 31 |
+
result = (False, error)
|
| 32 |
+
with ownership:
|
| 33 |
+
outcome = result
|
| 34 |
+
finished.set()
|
| 35 |
+
|
| 36 |
+
async def finish():
|
| 37 |
+
try:
|
| 38 |
+
await asyncio.to_thread(execute)
|
| 39 |
+
except asyncio.CancelledError:
|
| 40 |
+
raise
|
| 41 |
+
except BaseException as error:
|
| 42 |
+
abandon_unstarted((False, error))
|
| 43 |
+
|
| 44 |
+
coroutine = finish()
|
| 45 |
+
try:
|
| 46 |
+
task = asyncio.create_task(coroutine)
|
| 47 |
+
except BaseException:
|
| 48 |
+
coroutine.close()
|
| 49 |
+
raise
|
| 50 |
+
|
| 51 |
+
def consume(completed):
|
| 52 |
+
if completed.cancelled():
|
| 53 |
+
# 取消和线程启动竞争时,只有尚未开始的工作能被撤销。
|
| 54 |
+
abandon_unstarted((False, asyncio.CancelledError()))
|
| 55 |
+
else:
|
| 56 |
+
error = completed.exception()
|
| 57 |
+
if error is not None:
|
| 58 |
+
abandon_unstarted((False, error))
|
| 59 |
+
task.add_done_callback(consume)
|
| 60 |
+
|
| 61 |
+
cancelled = None
|
| 62 |
+
while not finished.is_set():
|
| 63 |
+
try:
|
| 64 |
+
if not task.done():
|
| 65 |
+
await asyncio.shield(task)
|
| 66 |
+
else:
|
| 67 |
+
# 内部task已取消但线程仍在运行,不占线程池等待其终态。
|
| 68 |
+
await asyncio.sleep(0.01)
|
| 69 |
+
except asyncio.CancelledError as error:
|
| 70 |
+
cancelled = error
|
| 71 |
+
with ownership:
|
| 72 |
+
succeeded, result = outcome
|
| 73 |
+
if cancelled is not None:
|
| 74 |
+
raise cancelled
|
| 75 |
+
if not succeeded:
|
| 76 |
+
raise result
|
| 77 |
+
return result
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class OwnedThreadGroup:
|
| 81 |
+
def __init__(self, capacity, unavailable):
|
| 82 |
+
if type(capacity) is not int or capacity < 1:
|
| 83 |
+
raise ValueError('invalid owned thread capacity')
|
| 84 |
+
self._capacity = threading.BoundedSemaphore(capacity)
|
| 85 |
+
self._unavailable = unavailable
|
| 86 |
+
self._gate = threading.Lock()
|
| 87 |
+
self._accepting = True
|
| 88 |
+
self._pending = 0
|
| 89 |
+
self._closers = 0
|
| 90 |
+
self._drained = threading.Event()
|
| 91 |
+
self._drained.set()
|
| 92 |
+
|
| 93 |
+
def start(self):
|
| 94 |
+
with self._gate:
|
| 95 |
+
if self._pending or self._closers:
|
| 96 |
+
raise RuntimeError('owned work is still pending')
|
| 97 |
+
self._accepting = True
|
| 98 |
+
|
| 99 |
+
def stop(self):
|
| 100 |
+
with self._gate:
|
| 101 |
+
self._accepting = False
|
| 102 |
+
|
| 103 |
+
async def close(self):
|
| 104 |
+
with self._gate:
|
| 105 |
+
self._accepting = False
|
| 106 |
+
self._closers += 1
|
| 107 |
+
try:
|
| 108 |
+
cancelled = False
|
| 109 |
+
while not self._drained.is_set():
|
| 110 |
+
try:
|
| 111 |
+
await asyncio.sleep(.01)
|
| 112 |
+
except asyncio.CancelledError:
|
| 113 |
+
cancelled = True
|
| 114 |
+
if cancelled:
|
| 115 |
+
raise asyncio.CancelledError
|
| 116 |
+
finally:
|
| 117 |
+
with self._gate:
|
| 118 |
+
self._closers -= 1
|
| 119 |
+
|
| 120 |
+
async def run(self, function, *args, **kwargs):
|
| 121 |
+
with self._gate:
|
| 122 |
+
if not self._accepting or not self._capacity.acquire(blocking=False):
|
| 123 |
+
raise self._unavailable()
|
| 124 |
+
self._pending += 1
|
| 125 |
+
self._drained.clear()
|
| 126 |
+
try:
|
| 127 |
+
return await run_owned_thread(function, *args, **kwargs)
|
| 128 |
+
finally:
|
| 129 |
+
with self._gate:
|
| 130 |
+
self._capacity.release()
|
| 131 |
+
self._pending -= 1
|
| 132 |
+
if not self._pending:
|
| 133 |
+
self._drained.set()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def owned_sync_endpoint(group):
|
| 137 |
+
def decorate(endpoint):
|
| 138 |
+
if iscoroutinefunction(endpoint) or isasyncgenfunction(endpoint) or isgeneratorfunction(endpoint):
|
| 139 |
+
raise TypeError('owned endpoint must be a synchronous function')
|
| 140 |
+
@wraps(endpoint)
|
| 141 |
+
async def invoke(*args, **kwargs):
|
| 142 |
+
return await group.run(endpoint, *args, **kwargs)
|
| 143 |
+
return invoke
|
| 144 |
+
return decorate
|
resource_work.py
CHANGED
|
@@ -3,7 +3,7 @@ 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
|
|
@@ -82,6 +82,7 @@ class ResourceWork:
|
|
| 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
|
|
|
|
| 3 |
import logging
|
| 4 |
|
| 5 |
from fastapi import HTTPException
|
| 6 |
+
from owned_thread import OwnedThreadGroup, owned_sync_endpoint
|
| 7 |
|
| 8 |
MAX_RESOURCE_WRITES = 8
|
| 9 |
MAX_RESOURCE_BACKGROUND = 2
|
|
|
|
| 82 |
|
| 83 |
_resource_work = ResourceWork()
|
| 84 |
run_resource_write = _resource_work.run
|
| 85 |
+
resource_write_endpoint = owned_sync_endpoint(_resource_work)
|
| 86 |
start_resource_background = _resource_work.background
|
| 87 |
start_resource_work = _resource_work.start
|
| 88 |
stop_resource_work = _resource_work.stop
|
router_comments.py
CHANGED
|
@@ -1,126 +1,138 @@
|
|
| 1 |
-
# router_comments.py
|
| 2 |
-
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
-
import time
|
| 4 |
-
import uuid
|
| 5 |
-
from html import escape
|
| 6 |
-
import 数据库连接 as db
|
| 7 |
-
from comment_store import COMMENT_MAX_LENGTH, append_comment, delete_comment
|
| 8 |
-
from notifications import add_notification
|
| 9 |
-
from models import InteractionToggle, CommentCreate
|
| 10 |
-
from 安全认证 import require_auth, is_admin
|
| 11 |
-
|
| 12 |
-
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 13 |
-
from rate_limit import shared_limit
|
| 14 |
-
from db_utils import sort_cache
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
if
|
| 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 |
-
notification = (comment.
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# router_comments.py
|
| 2 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from html import escape
|
| 6 |
+
import 数据库连接 as db
|
| 7 |
+
from comment_store import COMMENT_MAX_LENGTH, append_comment, delete_comment
|
| 8 |
+
from notifications import add_notification
|
| 9 |
+
from models import InteractionToggle, CommentCreate
|
| 10 |
+
from 安全认证 import require_auth, is_admin
|
| 11 |
+
|
| 12 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 13 |
+
from rate_limit import shared_limit
|
| 14 |
+
from db_utils import sort_cache
|
| 15 |
+
from owned_thread import OwnedThreadGroup, owned_sync_endpoint
|
| 16 |
+
|
| 17 |
+
MAX_COMMENT_WRITES = 8
|
| 18 |
+
_comment_writes = OwnedThreadGroup(MAX_COMMENT_WRITES,
|
| 19 |
+
lambda: HTTPException(status_code=503, detail="评论数据暂不可用"))
|
| 20 |
+
start_comment_writes = _comment_writes.start
|
| 21 |
+
stop_comment_writes = _comment_writes.stop
|
| 22 |
+
close_comment_writes = _comment_writes.close
|
| 23 |
+
_comment_endpoint = owned_sync_endpoint(_comment_writes)
|
| 24 |
+
|
| 25 |
+
router = APIRouter()
|
| 26 |
+
|
| 27 |
+
@router.post("/api/interactions/toggle")
|
| 28 |
+
@shared_limit(30)
|
| 29 |
+
@_comment_endpoint
|
| 30 |
+
def toggle_interaction(request: Request, interaction: InteractionToggle, current_user: str = Depends(require_auth)):
|
| 31 |
+
interaction.user_id = current_user # 强制使用当前用户身份,防止伪造
|
| 32 |
+
|
| 33 |
+
list_key = f"{interaction.action_type}d_by"
|
| 34 |
+
count_key = "likes" if interaction.action_type == "like" else "favorites"
|
| 35 |
+
|
| 36 |
+
# 使用闭包容器传递原子操作结果
|
| 37 |
+
result_container = {}
|
| 38 |
+
|
| 39 |
+
def updater(items_db):
|
| 40 |
+
target_item = next((item for item in items_db if item["id"] == interaction.item_id), None)
|
| 41 |
+
if not target_item:
|
| 42 |
+
result_container["error"] = "目标存在问题"
|
| 43 |
+
return
|
| 44 |
+
|
| 45 |
+
if list_key not in target_item:
|
| 46 |
+
target_item[list_key] = []
|
| 47 |
+
|
| 48 |
+
user_list = target_item[list_key]
|
| 49 |
+
if interaction.is_active:
|
| 50 |
+
# 双重检查:防止并发重复添加
|
| 51 |
+
if interaction.user_id not in user_list:
|
| 52 |
+
user_list.append(interaction.user_id)
|
| 53 |
+
else:
|
| 54 |
+
if interaction.user_id in user_list:
|
| 55 |
+
user_list.remove(interaction.user_id)
|
| 56 |
+
|
| 57 |
+
# 基于实际列表长度计算 count,即使并发重复也不会导致数量错误
|
| 58 |
+
target_item[count_key] = len(target_item[list_key])
|
| 59 |
+
result_container["count"] = target_item[count_key]
|
| 60 |
+
result_container["author"] = target_item.get("author", "")
|
| 61 |
+
result_container["item_id"] = target_item["id"]
|
| 62 |
+
result_container["title"] = target_item.get("title", "")
|
| 63 |
+
|
| 64 |
+
db.atomic_update("items.json", updater, default_data=[])
|
| 65 |
+
|
| 66 |
+
if result_container.get("error"):
|
| 67 |
+
raise HTTPException(status_code=404, detail=result_container["error"])
|
| 68 |
+
|
| 69 |
+
# 统一互动接口也会改变资源排序,成功提交后失效派生排序缓存。
|
| 70 |
+
sort_cache.invalidate("items:")
|
| 71 |
+
|
| 72 |
+
# 通知在原子操作外发送(通知失败不影响主流程)
|
| 73 |
+
if interaction.is_active:
|
| 74 |
+
add_notification(result_container.get("author", ""), {"type": interaction.action_type, "from_user": interaction.user_id, "target_item_id": result_container["item_id"], "target_item_title": result_container["title"]})
|
| 75 |
+
|
| 76 |
+
return {"status": "success", "new_count": result_container["count"]}
|
| 77 |
+
|
| 78 |
+
@router.post("/api/comments")
|
| 79 |
+
@shared_limit(10) # 🔒 安全限流:防止刷评论洗版(正常用户不会一分钟发 10 条以上)
|
| 80 |
+
@_comment_endpoint
|
| 81 |
+
def post_comment(request: Request, comment: CommentCreate, current_user: str = Depends(require_auth)):
|
| 82 |
+
comment.author = current_user # 强制使用认证用户身份,防止伪造
|
| 83 |
+
# 评论内容长度校验
|
| 84 |
+
if not comment.content or len(comment.content) > COMMENT_MAX_LENGTH:
|
| 85 |
+
raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
|
| 86 |
+
# HTML 转义,防止 XSS
|
| 87 |
+
comment.content = escape(comment.content)
|
| 88 |
+
users_db = db.load_data("users.json", default_data={})
|
| 89 |
+
author_info = users_db.get(comment.author, {})
|
| 90 |
+
reply_name = users_db.get(comment.reply_to_user, {}).get("name", comment.reply_to_user) if comment.reply_to_user else None
|
| 91 |
+
|
| 92 |
+
new_comment = {
|
| 93 |
+
"id": f"c_{int(time.time())}_{uuid.uuid4().hex[:6]}", "author": comment.author,
|
| 94 |
+
"authorName": author_info.get("name", comment.author), "avatar": author_info.get("avatarDataUrl", ""),
|
| 95 |
+
"content": comment.content, "replyToUser": comment.reply_to_user, "replyToUserName": reply_name,
|
| 96 |
+
"isDeleted": False, "replies": [], "created_at": int(time.time())
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
notification = None
|
| 100 |
+
if comment.parent_id:
|
| 101 |
+
if comment.reply_to_user:
|
| 102 |
+
notification = (comment.reply_to_user, {"type": "reply", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "收到新的回复", "content": comment.content})
|
| 103 |
+
else:
|
| 104 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 105 |
+
posts_db = db.load_data("posts.json", default_data=[])
|
| 106 |
+
target_item = next((item for item in items_db if item["id"] == comment.item_id), None)
|
| 107 |
+
target_post = next((post for post in posts_db if post["id"] == comment.item_id), None)
|
| 108 |
+
if target_item:
|
| 109 |
+
notification = (target_item.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_item["title"], "content": comment.content})
|
| 110 |
+
elif target_post:
|
| 111 |
+
notification = (target_post.get("author", ""), {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": target_post.get("title", "") or "", "content": comment.content})
|
| 112 |
+
elif comment.item_id in users_db:
|
| 113 |
+
notification = (comment.item_id, {"type": "comment", "from_user": comment.author, "target_item_id": comment.item_id, "target_item_title": "您的个人留言板", "content": comment.content})
|
| 114 |
+
|
| 115 |
+
append_comment(comment.item_id, new_comment, parent_id=comment.parent_id)
|
| 116 |
+
if notification:
|
| 117 |
+
add_notification(*notification)
|
| 118 |
+
return {"status": "success", "data": new_comment}
|
| 119 |
+
|
| 120 |
+
@router.delete("/api/comments/{item_id}/{comment_id}")
|
| 121 |
+
@shared_limit(10)
|
| 122 |
+
@_comment_endpoint
|
| 123 |
+
def soft_delete_comment(request: Request, item_id: str, comment_id: str, account: str = Depends(require_auth)):
|
| 124 |
+
"""
|
| 125 |
+
软删除评论
|
| 126 |
+
权限规则:评论作者可删除自己的评论,帖子/内容作者可删除其帖子下的评论,管理员可删除任何评论
|
| 127 |
+
"""
|
| 128 |
+
items_db = db.load_data("items.json", default_data=[])
|
| 129 |
+
posts_db = db.load_data("posts.json", default_data=[])
|
| 130 |
+
users_db = db.load_data("users.json", default_data={})
|
| 131 |
+
tasks_db = db.load_data("tasks.json", default_data=[])
|
| 132 |
+
is_content_author = any(entry.get("id") == item_id and entry.get("author") == account
|
| 133 |
+
for entry in posts_db + items_db)
|
| 134 |
+
is_content_author = is_content_author or any(entry.get("id") == item_id and entry.get("publisher") == account
|
| 135 |
+
for entry in tasks_db)
|
| 136 |
+
is_content_author = is_content_author or (item_id == account and item_id in users_db)
|
| 137 |
+
delete_comment(item_id, comment_id, account, can_moderate=is_content_author or is_admin(account))
|
| 138 |
+
return {"status": "success"}
|
router_items.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 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
|
|
@@ -22,6 +23,14 @@ from resource_store import publish_resource, published_response, edit_resource,
|
|
| 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):
|
|
@@ -84,7 +93,8 @@ def get_last_6_months():
|
|
| 84 |
data_file = "items.json"
|
| 85 |
|
| 86 |
@router.get("/api/items")
|
| 87 |
-
|
|
|
|
| 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={})
|
|
@@ -177,7 +187,8 @@ def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict
|
|
| 177 |
|
| 178 |
|
| 179 |
@router.get("/api/creators/search")
|
| 180 |
-
|
|
|
|
| 181 |
"""
|
| 182 |
搜索创作者
|
| 183 |
根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配)
|
|
@@ -249,7 +260,8 @@ async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50
|
|
| 249 |
|
| 250 |
|
| 251 |
@router.get("/api/creators")
|
| 252 |
-
|
|
|
|
| 253 |
"""
|
| 254 |
获取创作者列表
|
| 255 |
🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询
|
|
@@ -311,7 +323,8 @@ async def get_creators(sort: str = "downloads", limit: int = 100):
|
|
| 311 |
|
| 312 |
|
| 313 |
@router.get("/api/creators/{account}/details")
|
| 314 |
-
|
|
|
|
| 315 |
"""
|
| 316 |
获取单个创作者的详细数据
|
| 317 |
供前端展开卡片时按需加载
|
|
@@ -508,7 +521,8 @@ async def check_github_updates(request: Request, current_user: str = Depends(req
|
|
| 508 |
}
|
| 509 |
|
| 510 |
@router.get("/api/items/{item_id}")
|
| 511 |
-
|
|
|
|
| 512 |
"""根据ID获取单个资源的详细信息"""
|
| 513 |
items_db = read_public_source(db, "items.json", default_data=[])
|
| 514 |
comments_db = read_public_source(db, "comments.json", default_data={})
|
|
@@ -534,7 +548,8 @@ async def get_item_by_id(item_id: str):
|
|
| 534 |
raise HTTPException(status_code=404, detail="资源不存在")
|
| 535 |
|
| 536 |
@router.get("/api/item/{item_id}/version")
|
| 537 |
-
|
|
|
|
| 538 |
"""获取单个资源的最新版本号"""
|
| 539 |
versions_db = read_public_source(db, "versions.json", default_data={})
|
| 540 |
return {"status": "success", "version": _get_version_str(versions_db, item_id)}
|
|
@@ -555,7 +570,8 @@ async def delete_item(request: Request, item_id: str, current_user: str = Depend
|
|
| 555 |
|
| 556 |
@router.post("/api/items/{item_id}/view")
|
| 557 |
@shared_limit(60)
|
| 558 |
-
|
|
|
|
| 559 |
"""
|
| 560 |
记录资源访问量
|
| 561 |
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
|
@@ -573,7 +589,8 @@ async def record_item_view(request: Request, item_id: str, current_user: str = D
|
|
| 573 |
|
| 574 |
@router.post("/api/items/{item_id}/use")
|
| 575 |
@shared_limit(30)
|
| 576 |
-
|
|
|
|
| 577 |
"""
|
| 578 |
记录资源使用量/下载量(原子操作,并发安全)
|
| 579 |
📥 每个用户对同一资源只计一次,防止重复计数
|
|
@@ -618,7 +635,8 @@ async def record_item_use(request: Request, item_id: str, current_user: str = De
|
|
| 618 |
|
| 619 |
@router.post("/api/items/{item_id}/rating")
|
| 620 |
@shared_limit(20)
|
| 621 |
-
|
|
|
|
| 622 |
"""
|
| 623 |
为资源评分(原子操作,并发安全)
|
| 624 |
⭐ score: 1-5
|
|
@@ -693,7 +711,8 @@ async def rate_item(request: Request, item_id: str, payload: RatingRequest, curr
|
|
| 693 |
|
| 694 |
@router.post("/api/items/{item_id}/like")
|
| 695 |
@shared_limit(30)
|
| 696 |
-
|
|
|
|
| 697 |
"""
|
| 698 |
点赞/取消点赞(原子操作,并发安全)
|
| 699 |
"""
|
|
@@ -729,7 +748,8 @@ async def toggle_item_like(request: Request, item_id: str, current_user: str = D
|
|
| 729 |
|
| 730 |
@router.post("/api/items/{item_id}/favorite")
|
| 731 |
@shared_limit(30)
|
| 732 |
-
|
|
|
|
| 733 |
"""
|
| 734 |
收藏/取消收藏(原子操作,并发安全)
|
| 735 |
"""
|
|
|
|
| 1 |
+
from resource_work import run_resource_write, start_resource_background, resource_write_endpoint
|
| 2 |
+
from owned_thread import OwnedThreadGroup, owned_sync_endpoint
|
| 3 |
# router_items.py
|
| 4 |
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 5 |
from rate_limit import shared_limit
|
|
|
|
| 23 |
from purchase_transaction import effective_resource_price
|
| 24 |
from resource_deletion import delete_resource
|
| 25 |
|
| 26 |
+
MAX_RESOURCE_READS = 16
|
| 27 |
+
_resource_reads = OwnedThreadGroup(MAX_RESOURCE_READS,
|
| 28 |
+
lambda: HTTPException(status_code=503, detail="ranking_data_unavailable"))
|
| 29 |
+
start_resource_reads = _resource_reads.start
|
| 30 |
+
stop_resource_reads = _resource_reads.stop
|
| 31 |
+
close_resource_reads = _resource_reads.close
|
| 32 |
+
_resource_read_endpoint = owned_sync_endpoint(_resource_reads)
|
| 33 |
+
|
| 34 |
router = APIRouter()
|
| 35 |
|
| 36 |
def safe_str(val):
|
|
|
|
| 93 |
data_file = "items.json"
|
| 94 |
|
| 95 |
@router.get("/api/items")
|
| 96 |
+
@_resource_read_endpoint
|
| 97 |
+
def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验
|
| 98 |
read_epoch = sort_cache.capture_read_epoch()
|
| 99 |
items_db = read_public_source(db, "items.json", default_data=[])
|
| 100 |
comments_db = read_public_source(db, "comments.json", default_data={})
|
|
|
|
| 187 |
|
| 188 |
|
| 189 |
@router.get("/api/creators/search")
|
| 190 |
+
@_resource_read_endpoint
|
| 191 |
+
def search_creators(keyword: str, sort: str = "downloads", limit: int = 50):
|
| 192 |
"""
|
| 193 |
搜索创作者
|
| 194 |
根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配)
|
|
|
|
| 260 |
|
| 261 |
|
| 262 |
@router.get("/api/creators")
|
| 263 |
+
@_resource_read_endpoint
|
| 264 |
+
def get_creators(sort: str = "downloads", limit: int = 100):
|
| 265 |
"""
|
| 266 |
获取创作者列表
|
| 267 |
🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询
|
|
|
|
| 323 |
|
| 324 |
|
| 325 |
@router.get("/api/creators/{account}/details")
|
| 326 |
+
@_resource_read_endpoint
|
| 327 |
+
def get_creator_details(account: str):
|
| 328 |
"""
|
| 329 |
获取单个创作者的详细数据
|
| 330 |
供前端展开卡片时按需加载
|
|
|
|
| 521 |
}
|
| 522 |
|
| 523 |
@router.get("/api/items/{item_id}")
|
| 524 |
+
@_resource_read_endpoint
|
| 525 |
+
def get_item_by_id(item_id: str):
|
| 526 |
"""根据ID获取单个资源的详细信息"""
|
| 527 |
items_db = read_public_source(db, "items.json", default_data=[])
|
| 528 |
comments_db = read_public_source(db, "comments.json", default_data={})
|
|
|
|
| 548 |
raise HTTPException(status_code=404, detail="资源不存在")
|
| 549 |
|
| 550 |
@router.get("/api/item/{item_id}/version")
|
| 551 |
+
@_resource_read_endpoint
|
| 552 |
+
def get_item_version(item_id: str):
|
| 553 |
"""获取单个资源的最新版本号"""
|
| 554 |
versions_db = read_public_source(db, "versions.json", default_data={})
|
| 555 |
return {"status": "success", "version": _get_version_str(versions_db, item_id)}
|
|
|
|
| 570 |
|
| 571 |
@router.post("/api/items/{item_id}/view")
|
| 572 |
@shared_limit(60)
|
| 573 |
+
@resource_write_endpoint
|
| 574 |
+
def record_item_view(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 575 |
"""
|
| 576 |
记录资源访问量
|
| 577 |
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
|
|
|
| 589 |
|
| 590 |
@router.post("/api/items/{item_id}/use")
|
| 591 |
@shared_limit(30)
|
| 592 |
+
@resource_write_endpoint
|
| 593 |
+
def record_item_use(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 594 |
"""
|
| 595 |
记录资源使用量/下载量(原子操作,并发安全)
|
| 596 |
📥 每个用户对同一资源只计一次,防止重复计数
|
|
|
|
| 635 |
|
| 636 |
@router.post("/api/items/{item_id}/rating")
|
| 637 |
@shared_limit(20)
|
| 638 |
+
@resource_write_endpoint
|
| 639 |
+
def rate_item(request: Request, item_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
|
| 640 |
"""
|
| 641 |
为资源评分(原子操作,并发安全)
|
| 642 |
⭐ score: 1-5
|
|
|
|
| 711 |
|
| 712 |
@router.post("/api/items/{item_id}/like")
|
| 713 |
@shared_limit(30)
|
| 714 |
+
@resource_write_endpoint
|
| 715 |
+
def toggle_item_like(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 716 |
"""
|
| 717 |
点赞/取消点赞(原子操作,并发安全)
|
| 718 |
"""
|
|
|
|
| 748 |
|
| 749 |
@router.post("/api/items/{item_id}/favorite")
|
| 750 |
@shared_limit(30)
|
| 751 |
+
@resource_write_endpoint
|
| 752 |
+
def toggle_item_favorite(request: Request, item_id: str, current_user: str = Depends(require_auth)):
|
| 753 |
"""
|
| 754 |
收藏/取消收藏(原子操作,并发安全)
|
| 755 |
"""
|
router_posts.py
CHANGED
|
@@ -1,629 +1,648 @@
|
|
| 1 |
-
from publication_identity import publication_identity
|
| 2 |
-
# 云端Space代码/router_posts.py
|
| 3 |
-
# ==========================================
|
| 4 |
-
# 💬 讨论区API路由(小红书风格图文社区)
|
| 5 |
-
# ==========================================
|
| 6 |
-
# 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
|
| 7 |
-
# ==========================================
|
| 8 |
-
|
| 9 |
-
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 10 |
-
from sqlalchemy.orm import Session
|
| 11 |
-
from models import PostCreate, PostUpdate, RatingRequest
|
| 12 |
-
import 数据库连接 as db
|
| 13 |
-
from comment_store import COMMENT_MAX_LENGTH, append_comment, increment_comment_count, attach_comment_counts, read_comment_view
|
| 14 |
-
from 安全认证 import require_auth
|
| 15 |
-
from db_utils import read_public_source, record_view, sort_cache
|
| 16 |
-
from database_sql import get_db
|
| 17 |
-
from models_sql import Wallet, Transaction
|
| 18 |
-
from notifications import add_notification
|
| 19 |
-
from tip_transaction import lock_tip_wallets
|
| 20 |
-
from tip_store import run_tip_effect
|
| 21 |
-
import time
|
| 22 |
-
import
|
| 23 |
-
from post_store import publish_post, edit_post, delete_post as remove_post, PostStoreError
|
| 24 |
-
import uuid
|
| 25 |
-
import hashlib
|
| 26 |
-
import datetime
|
| 27 |
-
import logging
|
| 28 |
-
|
| 29 |
-
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 30 |
-
from rate_limit import shared_limit
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 218 |
-
"
|
| 219 |
-
"
|
| 220 |
-
|
| 221 |
-
"
|
| 222 |
-
"
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
return {"status": "success"}
|
| 241 |
-
|
| 242 |
-
@router.
|
| 243 |
-
@shared_limit(10)
|
| 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 |
-
if
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
rated_by
|
| 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 |
-
result_container =
|
| 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 |
-
|
| 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 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
# 🗂️ 清除排序缓存(
|
| 627 |
-
sort_cache.invalidate("posts:")
|
| 628 |
-
|
| 629 |
-
return {"status": "success", "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from publication_identity import publication_identity
|
| 2 |
+
# 云端Space代码/router_posts.py
|
| 3 |
+
# ==========================================
|
| 4 |
+
# 💬 讨论区API路由(小红书风格图文社区)
|
| 5 |
+
# ==========================================
|
| 6 |
+
# 功能:帖子发布、列表、详情、互动(点赞/收藏/评论/打赏)
|
| 7 |
+
# ==========================================
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 10 |
+
from sqlalchemy.orm import Session
|
| 11 |
+
from models import PostCreate, PostUpdate, RatingRequest
|
| 12 |
+
import 数据库连接 as db
|
| 13 |
+
from comment_store import COMMENT_MAX_LENGTH, append_comment, increment_comment_count, attach_comment_counts, read_comment_view
|
| 14 |
+
from 安全认证 import require_auth
|
| 15 |
+
from db_utils import read_public_source, record_view, sort_cache
|
| 16 |
+
from database_sql import get_db
|
| 17 |
+
from models_sql import Wallet, Transaction
|
| 18 |
+
from notifications import add_notification
|
| 19 |
+
from tip_transaction import lock_tip_wallets
|
| 20 |
+
from tip_store import run_tip_effect
|
| 21 |
+
import time
|
| 22 |
+
from owned_thread import OwnedThreadGroup, owned_sync_endpoint
|
| 23 |
+
from post_store import publish_post, edit_post, delete_post as remove_post, PostStoreError
|
| 24 |
+
import uuid
|
| 25 |
+
import hashlib
|
| 26 |
+
import datetime
|
| 27 |
+
import logging
|
| 28 |
+
|
| 29 |
+
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
| 30 |
+
from rate_limit import shared_limit
|
| 31 |
+
|
| 32 |
+
MAX_POSTS_IO = 16
|
| 33 |
+
_posts_io = OwnedThreadGroup(MAX_POSTS_IO,
|
| 34 |
+
lambda: HTTPException(status_code=503, detail="帖子数据暂不可用"))
|
| 35 |
+
start_posts_io = _posts_io.start
|
| 36 |
+
stop_posts_io = _posts_io.stop
|
| 37 |
+
close_posts_io = _posts_io.close
|
| 38 |
+
_posts_endpoint = owned_sync_endpoint(_posts_io)
|
| 39 |
+
|
| 40 |
+
router = APIRouter()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _post_mutation(operation, *args):
|
| 44 |
+
try:
|
| 45 |
+
return operation(*args)
|
| 46 |
+
except PostStoreError as error:
|
| 47 |
+
raise HTTPException(status_code=error.status_code, detail=error.message) from None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _invalidate_posts():
|
| 51 |
+
try:
|
| 52 |
+
sort_cache.invalidate("posts:")
|
| 53 |
+
except Exception as error:
|
| 54 |
+
logging.getLogger(__name__).warning("帖子排序缓存失效失败: error_type=%s", type(error).__name__)
|
| 55 |
+
|
| 56 |
+
# ==========================================
|
| 57 |
+
# 📝 帖子CRUD接口
|
| 58 |
+
# ==========================================
|
| 59 |
+
|
| 60 |
+
@router.get("/api/posts")
|
| 61 |
+
@_posts_endpoint
|
| 62 |
+
def get_posts(page: int = 1, limit: int = 20, sort: str = "latest"):
|
| 63 |
+
"""
|
| 64 |
+
获取帖子列表(分页,支持多种排序方式)
|
| 65 |
+
- sort=latest: 按创建时间降序(默认)
|
| 66 |
+
- sort=likes: 按点赞数降序
|
| 67 |
+
- sort=favorites: 按收藏数降序
|
| 68 |
+
- sort=views: 按总访问量降序
|
| 69 |
+
- sort=daily_views: 按日访问量降序
|
| 70 |
+
- sort=tips: 按打赏总额降序
|
| 71 |
+
"""
|
| 72 |
+
read_epoch = sort_cache.capture_read_epoch()
|
| 73 |
+
posts_db = read_public_source(db, "posts.json", default_data=[])
|
| 74 |
+
users_db = read_public_source(db, "users.json", default_data={})
|
| 75 |
+
|
| 76 |
+
# users_db 已经是 {account: user_info} 格式,直接使用
|
| 77 |
+
user_map = users_db
|
| 78 |
+
|
| 79 |
+
# 🗂️ 使用排序缓存优化排序性能
|
| 80 |
+
cache_sort = sort if sort in ("latest", "likes", "favorites", "views", "daily_views", "tips", "rating") else "latest"
|
| 81 |
+
cache_key = f"posts:{cache_sort}"
|
| 82 |
+
|
| 83 |
+
def order_key(item):
|
| 84 |
+
if sort == "likes":
|
| 85 |
+
return item.get("likes", 0)
|
| 86 |
+
elif sort == "favorites":
|
| 87 |
+
return item.get("favorites", 0)
|
| 88 |
+
elif sort == "views":
|
| 89 |
+
return item.get("views", 0)
|
| 90 |
+
elif sort == "daily_views":
|
| 91 |
+
return item.get("daily_views", 0)
|
| 92 |
+
elif sort == "tips":
|
| 93 |
+
return sum(t.get("amount", 0) for t in item.get("tip_board", []))
|
| 94 |
+
elif sort == "rating": # ⭐ 按评分排序
|
| 95 |
+
return (item.get("rating_avg", 0), item.get("rating_count", 0))
|
| 96 |
+
else: # latest 或其他默认
|
| 97 |
+
return item.get("created_at", 0)
|
| 98 |
+
|
| 99 |
+
def sort_fn(data):
|
| 100 |
+
data.sort(key=order_key, reverse=True)
|
| 101 |
+
|
| 102 |
+
sorted_posts = sort_cache.get_sorted(cache_key, posts_db, sort_fn, read_epoch=read_epoch, fresh=True)
|
| 103 |
+
|
| 104 |
+
# 分页
|
| 105 |
+
start = (page - 1) * limit
|
| 106 |
+
end = start + limit
|
| 107 |
+
paged_posts = sorted_posts[start:end]
|
| 108 |
+
|
| 109 |
+
# 附加作者信息,并过滤敏感字段
|
| 110 |
+
result = []
|
| 111 |
+
for post in paged_posts:
|
| 112 |
+
author_info = user_map.get(post.get("author"), {})
|
| 113 |
+
post_data = {
|
| 114 |
+
**post,
|
| 115 |
+
"author_name": author_info.get("name", post.get("author")),
|
| 116 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 117 |
+
}
|
| 118 |
+
# 过滤敏感字段(列表接口仅过滤 viewed_by,保留 liked_by 和 favorited_by)
|
| 119 |
+
post_data.pop("viewed_by", None)
|
| 120 |
+
result.append(post_data)
|
| 121 |
+
|
| 122 |
+
attach_comment_counts(result)
|
| 123 |
+
return {
|
| 124 |
+
"status": "success",
|
| 125 |
+
"data": result,
|
| 126 |
+
"total": len(posts_db),
|
| 127 |
+
"page": page,
|
| 128 |
+
"limit": limit
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
@router.get("/api/my-posts")
|
| 132 |
+
@_posts_endpoint
|
| 133 |
+
def get_my_posts(current_user: str = Depends(require_auth)):
|
| 134 |
+
"""
|
| 135 |
+
获取我的帖子列表
|
| 136 |
+
"""
|
| 137 |
+
posts_db = db.load_data("posts.json", default_data=[])
|
| 138 |
+
|
| 139 |
+
# 筛选当前用户的帖子
|
| 140 |
+
my_posts = [p for p in posts_db if p.get("author") == current_user]
|
| 141 |
+
|
| 142 |
+
# 按创建时间倒序
|
| 143 |
+
my_posts = sorted(my_posts, key=lambda x: x.get("created_at", 0), reverse=True)
|
| 144 |
+
|
| 145 |
+
# 过滤敏感字段(列表接口仅过滤 viewed_by,保留 liked_by 和 favorited_by)
|
| 146 |
+
result = []
|
| 147 |
+
for post in my_posts:
|
| 148 |
+
post_data = dict(post)
|
| 149 |
+
post_data.pop("viewed_by", None)
|
| 150 |
+
result.append(post_data)
|
| 151 |
+
|
| 152 |
+
attach_comment_counts(result)
|
| 153 |
+
return {
|
| 154 |
+
"status": "success",
|
| 155 |
+
"data": result
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@router.get("/api/posts/{post_id}")
|
| 159 |
+
@_posts_endpoint
|
| 160 |
+
def get_post_detail(post_id: str):
|
| 161 |
+
"""
|
| 162 |
+
获取帖子详情
|
| 163 |
+
"""
|
| 164 |
+
posts_db = db.load_data("posts.json", default_data=[])
|
| 165 |
+
users_db = db.load_data("users.json", default_data={})
|
| 166 |
+
|
| 167 |
+
# users_db 已经是 {account: user_info} 格式,直接使用
|
| 168 |
+
user_map = users_db
|
| 169 |
+
|
| 170 |
+
for post in posts_db:
|
| 171 |
+
if post["id"] == post_id:
|
| 172 |
+
author_info = user_map.get(post.get("author"), {})
|
| 173 |
+
post_data = {
|
| 174 |
+
**post,
|
| 175 |
+
"author_name": author_info.get("name", post.get("author")),
|
| 176 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 177 |
+
}
|
| 178 |
+
# 过滤敏感字段
|
| 179 |
+
post_data.pop("viewed_by", None)
|
| 180 |
+
attach_comment_counts([post_data])
|
| 181 |
+
return {
|
| 182 |
+
"status": "success",
|
| 183 |
+
"data": post_data
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 187 |
+
|
| 188 |
+
@router.post("/api/posts")
|
| 189 |
+
@shared_limit(5) # 🔒 安全限流:防止刷帖洗版(一次发布含多图上传,但建帖只调一次)
|
| 190 |
+
@_posts_endpoint
|
| 191 |
+
def create_post(request: Request, post: PostCreate, current_user: str = Depends(require_auth)):
|
| 192 |
+
"""
|
| 193 |
+
发布帖子
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
stable_id = publication_identity('post', current_user, getattr(request, 'headers', {}).get('Idempotency-Key'))
|
| 197 |
+
except ValueError:
|
| 198 |
+
raise HTTPException(status_code=400, detail='发布请求身份无效') from None
|
| 199 |
+
|
| 200 |
+
# 限制图片数量
|
| 201 |
+
images = (post.images or [])[:9]
|
| 202 |
+
post_type = post.post_type if hasattr(post, "post_type") else "image"
|
| 203 |
+
video_url = post.video_url if hasattr(post, "video_url") else None
|
| 204 |
+
|
| 205 |
+
# 视频帖:images 置为空列表
|
| 206 |
+
if post_type == "video":
|
| 207 |
+
images = []
|
| 208 |
+
|
| 209 |
+
new_post = {
|
| 210 |
+
"id": stable_id or f"post_{int(time.time())}_{uuid.uuid4().hex}",
|
| 211 |
+
"title": post.title,
|
| 212 |
+
"content": post.content,
|
| 213 |
+
"cover_image": post.cover_image,
|
| 214 |
+
"images": images,
|
| 215 |
+
"author": current_user,
|
| 216 |
+
"created_at": int(time.time()),
|
| 217 |
+
"is_original": post.is_original if post.is_original is not None else False, # 🎨 原创作品标记
|
| 218 |
+
"post_type": post_type,
|
| 219 |
+
"video_url": video_url,
|
| 220 |
+
# 互动数据
|
| 221 |
+
"likes": 0,
|
| 222 |
+
"favorites": 0,
|
| 223 |
+
"comments": 0,
|
| 224 |
+
"liked_by": [],
|
| 225 |
+
"favorited_by": [],
|
| 226 |
+
"tip_board": [], # 打赏榜单
|
| 227 |
+
"views": 0,
|
| 228 |
+
"daily_views": 0,
|
| 229 |
+
"viewed_by": [],
|
| 230 |
+
"daily_views_date": "",
|
| 231 |
+
"rating_avg": 0.0,
|
| 232 |
+
"rating_count": 0,
|
| 233 |
+
"rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0},
|
| 234 |
+
"rated_by": {}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
new_post = _post_mutation(publish_post, db, new_post, stable_id is not None)
|
| 238 |
+
_invalidate_posts()
|
| 239 |
+
|
| 240 |
+
return {"status": "success", "data": new_post}
|
| 241 |
+
|
| 242 |
+
@router.put("/api/posts/{post_id}")
|
| 243 |
+
@shared_limit(10)
|
| 244 |
+
@_posts_endpoint
|
| 245 |
+
def update_post(request: Request, post_id: str, update_data: PostUpdate, current_user: str = Depends(require_auth)):
|
| 246 |
+
"""
|
| 247 |
+
更新帖子(仅作者可操作,原子操作,并发安全)
|
| 248 |
+
"""
|
| 249 |
+
changes = {key: getattr(update_data, key) for key in (
|
| 250 |
+
"title", "content", "cover_image", "images", "is_original", "post_type", "video_url")}
|
| 251 |
+
_post_mutation(edit_post, db, post_id, current_user, changes)
|
| 252 |
+
_invalidate_posts()
|
| 253 |
+
return {"status": "success"}
|
| 254 |
+
|
| 255 |
+
@router.delete("/api/posts/{post_id}")
|
| 256 |
+
@shared_limit(10)
|
| 257 |
+
@_posts_endpoint
|
| 258 |
+
def delete_post(request: Request, post_id: str, current_user: str = Depends(require_auth)):
|
| 259 |
+
"""删除帖子;锁内确认最新作者,只删除目标主记录。"""
|
| 260 |
+
_post_mutation(remove_post, db, post_id, current_user)
|
| 261 |
+
_invalidate_posts()
|
| 262 |
+
return {"status": "success"}
|
| 263 |
+
|
| 264 |
+
# ==========================================
|
| 265 |
+
# ❤️ 互动接口(点赞/收藏)
|
| 266 |
+
# ==========================================
|
| 267 |
+
|
| 268 |
+
@router.post("/api/posts/{post_id}/rating")
|
| 269 |
+
@shared_limit(20)
|
| 270 |
+
@_posts_endpoint
|
| 271 |
+
def rate_post(request: Request, post_id: str, payload: RatingRequest, current_user: str = Depends(require_auth)):
|
| 272 |
+
"""
|
| 273 |
+
为帖子评分(原子操作,并发安全)
|
| 274 |
+
⭐ score: 1-5
|
| 275 |
+
"""
|
| 276 |
+
score = payload.score
|
| 277 |
+
if score < 1 or score > 5:
|
| 278 |
+
raise HTTPException(status_code=400, detail="评分必须在1-5之间")
|
| 279 |
+
|
| 280 |
+
result_container = [None]
|
| 281 |
+
|
| 282 |
+
def updater(data):
|
| 283 |
+
for post in data:
|
| 284 |
+
if post["id"] == post_id:
|
| 285 |
+
# 禁止自评
|
| 286 |
+
if post.get("author") == current_user:
|
| 287 |
+
result_container[0] = {"error": "self_rating"}
|
| 288 |
+
return
|
| 289 |
+
# 初始化评分字段
|
| 290 |
+
if "rating_avg" not in post:
|
| 291 |
+
post["rating_avg"] = 0.0
|
| 292 |
+
if "rating_count" not in post:
|
| 293 |
+
post["rating_count"] = 0
|
| 294 |
+
if "rating_dist" not in post:
|
| 295 |
+
post["rating_dist"] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}
|
| 296 |
+
if "rated_by" not in post:
|
| 297 |
+
post["rated_by"] = {}
|
| 298 |
+
|
| 299 |
+
rated_by = post["rated_by"]
|
| 300 |
+
rating_dist = post["rating_dist"]
|
| 301 |
+
old_score = None
|
| 302 |
+
if current_user in rated_by:
|
| 303 |
+
old_score = rated_by[current_user]["score"]
|
| 304 |
+
|
| 305 |
+
if old_score is not None:
|
| 306 |
+
# 已评分,先减去旧分数分布
|
| 307 |
+
rating_dist[str(old_score)] = max(0, rating_dist.get(str(old_score), 0) - 1)
|
| 308 |
+
rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
|
| 309 |
+
else:
|
| 310 |
+
# 未评分,增加计数
|
| 311 |
+
post["rating_count"] = post.get("rating_count", 0) + 1
|
| 312 |
+
rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1
|
| 313 |
+
|
| 314 |
+
rated_by[current_user] = {"score": score, "time": int(time.time())}
|
| 315 |
+
|
| 316 |
+
# 重新计算平均分
|
| 317 |
+
total = sum(int(k) * v for k, v in rating_dist.items())
|
| 318 |
+
count = post["rating_count"]
|
| 319 |
+
post["rating_avg"] = round(total / count, 2) if count > 0 else 0.0
|
| 320 |
+
|
| 321 |
+
result_container[0] = {
|
| 322 |
+
"status": "success",
|
| 323 |
+
"rating_avg": post["rating_avg"],
|
| 324 |
+
"rating_count": post["rating_count"],
|
| 325 |
+
"rating_dist": post["rating_dist"],
|
| 326 |
+
"user_score": score
|
| 327 |
+
}
|
| 328 |
+
return
|
| 329 |
+
result_container[0] = None # 未找到帖子
|
| 330 |
+
|
| 331 |
+
db.atomic_update("posts.json", updater, default_data=[])
|
| 332 |
+
|
| 333 |
+
if result_container[0] is None:
|
| 334 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 335 |
+
if result_container[0].get("error") == "self_rating":
|
| 336 |
+
raise HTTPException(status_code=400, detail="不能给自己发布的帖子评分")
|
| 337 |
+
|
| 338 |
+
# 🗂️ 清除排序缓存(评分变化可能影响排序)
|
| 339 |
+
sort_cache.invalidate("posts:")
|
| 340 |
+
|
| 341 |
+
return result_container[0]
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
@router.post("/api/posts/{post_id}/like")
|
| 345 |
+
@shared_limit(30)
|
| 346 |
+
@_posts_endpoint
|
| 347 |
+
def toggle_like(request: Request, post_id: str, current_user: str = Depends(require_auth)):
|
| 348 |
+
"""
|
| 349 |
+
点赞/取消点赞(原子操作,并发安全)
|
| 350 |
+
"""
|
| 351 |
+
result_container = [None]
|
| 352 |
+
|
| 353 |
+
def updater(data):
|
| 354 |
+
for post in data:
|
| 355 |
+
if post["id"] == post_id:
|
| 356 |
+
liked_by = post.get("liked_by", [])
|
| 357 |
+
if current_user in liked_by:
|
| 358 |
+
liked_by.remove(current_user)
|
| 359 |
+
post["likes"] = max(0, post.get("likes", 0) - 1)
|
| 360 |
+
action = "unliked"
|
| 361 |
+
else:
|
| 362 |
+
liked_by.append(current_user)
|
| 363 |
+
post["likes"] = post.get("likes", 0) + 1
|
| 364 |
+
action = "liked"
|
| 365 |
+
post["liked_by"] = liked_by
|
| 366 |
+
result_container[0] = {"status": "success", "action": action, "likes": post["likes"]}
|
| 367 |
+
return
|
| 368 |
+
result_container[0] = None # 未找到帖子
|
| 369 |
+
|
| 370 |
+
db.atomic_update("posts.json", updater, default_data=[])
|
| 371 |
+
|
| 372 |
+
if result_container[0] is None:
|
| 373 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 374 |
+
|
| 375 |
+
# 🗂️ 清除排序缓存(点赞数变化可能影响排序)
|
| 376 |
+
sort_cache.invalidate("posts:")
|
| 377 |
+
|
| 378 |
+
return result_container[0]
|
| 379 |
+
|
| 380 |
+
@router.post("/api/posts/{post_id}/favorite")
|
| 381 |
+
@shared_limit(30)
|
| 382 |
+
@_posts_endpoint
|
| 383 |
+
def toggle_favorite(request: Request, post_id: str, current_user: str = Depends(require_auth)):
|
| 384 |
+
"""
|
| 385 |
+
收藏/取消收藏(原子操作,并发安全)
|
| 386 |
+
"""
|
| 387 |
+
result_container = [None]
|
| 388 |
+
|
| 389 |
+
def updater(data):
|
| 390 |
+
for post in data:
|
| 391 |
+
if post["id"] == post_id:
|
| 392 |
+
favorited_by = post.get("favorited_by", [])
|
| 393 |
+
if current_user in favorited_by:
|
| 394 |
+
favorited_by.remove(current_user)
|
| 395 |
+
post["favorites"] = max(0, post.get("favorites", 0) - 1)
|
| 396 |
+
action = "unfavorited"
|
| 397 |
+
else:
|
| 398 |
+
favorited_by.append(current_user)
|
| 399 |
+
post["favorites"] = post.get("favorites", 0) + 1
|
| 400 |
+
action = "favorited"
|
| 401 |
+
post["favorited_by"] = favorited_by
|
| 402 |
+
result_container[0] = {"status": "success", "action": action, "favorites": post["favorites"]}
|
| 403 |
+
return
|
| 404 |
+
result_container[0] = None # 未找到帖子
|
| 405 |
+
|
| 406 |
+
db.atomic_update("posts.json", updater, default_data=[])
|
| 407 |
+
|
| 408 |
+
if result_container[0] is None:
|
| 409 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 410 |
+
|
| 411 |
+
# 🗂️ 清除排序缓存(收藏数变化可能影响排序)
|
| 412 |
+
sort_cache.invalidate("posts:")
|
| 413 |
+
|
| 414 |
+
return result_container[0]
|
| 415 |
+
|
| 416 |
+
# ==========================================
|
| 417 |
+
# 🎁 打赏接口
|
| 418 |
+
# ==========================================
|
| 419 |
+
|
| 420 |
+
# 📝 审计日志
|
| 421 |
+
logger = logging.getLogger("ComfyUI-Ranking.Posts")
|
| 422 |
+
|
| 423 |
+
def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
| 424 |
+
data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
|
| 425 |
+
return hashlib.sha256(data.encode()).hexdigest()
|
| 426 |
+
|
| 427 |
+
@router.post("/api/posts/{post_id}/tip")
|
| 428 |
+
@shared_limit(10)
|
| 429 |
+
async def tip_post(request: Request, post_id: str, amount: int, is_anon: bool = False, current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
|
| 430 |
+
"""
|
| 431 |
+
打赏帖子(原子操作,并发安全)- 使用 SQL Wallet 系统
|
| 432 |
+
"""
|
| 433 |
+
if amount <= 0:
|
| 434 |
+
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 435 |
+
MAX_TIP_AMOUNT = 100000
|
| 436 |
+
if amount > MAX_TIP_AMOUNT:
|
| 437 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 438 |
+
|
| 439 |
+
# 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
|
| 440 |
+
# 避免余额不足/重复请求时榜单已被更新的数据不一致
|
| 441 |
+
posts_db_ro = db.load_data("posts.json", default_data=[])
|
| 442 |
+
target_post = next((p for p in posts_db_ro if p["id"] == post_id), None)
|
| 443 |
+
if not target_post:
|
| 444 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 445 |
+
if target_post.get("author") == current_user:
|
| 446 |
+
raise HTTPException(status_code=400, detail="不能打赏自己的帖子")
|
| 447 |
+
author = target_post.get("author")
|
| 448 |
+
if not author:
|
| 449 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 450 |
+
post_title = target_post.get("title")
|
| 451 |
+
|
| 452 |
+
# 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
|
| 453 |
+
try:
|
| 454 |
+
|
| 455 |
+
# 🔒 并发安全:使用悲观锁获取双方钱包
|
| 456 |
+
tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author)
|
| 457 |
+
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 458 |
+
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
| 459 |
+
duplicate_tx = db_session.query(Transaction).filter(
|
| 460 |
+
Transaction.account == current_user,
|
| 461 |
+
Transaction.tx_type == "TIP_OUT",
|
| 462 |
+
Transaction.amount == -amount,
|
| 463 |
+
Transaction.related_account == author,
|
| 464 |
+
Transaction.item_id == post_id,
|
| 465 |
+
Transaction.item_type == "post",
|
| 466 |
+
Transaction.created_at >= recent_cutoff
|
| 467 |
+
).with_for_update().first()
|
| 468 |
+
if duplicate_tx:
|
| 469 |
+
db_session.rollback() # 重复请求释放锁,不执行任何后续操作
|
| 470 |
+
return {"status": "success", "message": "打赏已处理(重复请求)"}
|
| 471 |
+
|
| 472 |
+
if not tipper_wallet or tipper_wallet.balance < amount:
|
| 473 |
+
raise HTTPException(status_code=400, detail="余额不足")
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
# 执行转账
|
| 477 |
+
tipper_wallet.balance -= amount
|
| 478 |
+
author_wallet.balance += amount # 实际收入进统一余额
|
| 479 |
+
author_wallet.tip_balance += amount # 累计打赏收益统计(只增不减)
|
| 480 |
+
|
| 481 |
+
# 创建交易记录
|
| 482 |
+
tx_id_tipper = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 483 |
+
tx_id_author = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 484 |
+
|
| 485 |
+
# 获取最后交易记录的哈希
|
| 486 |
+
last_tx_tipper = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first()
|
| 487 |
+
last_tx_author = db_session.query(Transaction).filter(Transaction.account == author).order_by(Transaction.created_at.desc()).first()
|
| 488 |
+
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 489 |
+
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 490 |
+
|
| 491 |
+
# 获取用户信息(post_title 已在预校验阶段取得)
|
| 492 |
+
users_db = db.load_data("users.json", default_data={})
|
| 493 |
+
author_info = users_db.get(author, {})
|
| 494 |
+
tipper_info = users_db.get(current_user, {})
|
| 495 |
+
author_name = author_info.get("name", author)
|
| 496 |
+
tipper_name = tipper_info.get("name", current_user)
|
| 497 |
+
|
| 498 |
+
# 打赏方交易记录 (TIP_OUT)
|
| 499 |
+
tx_tipper = Transaction(
|
| 500 |
+
tx_id=tx_id_tipper,
|
| 501 |
+
account=current_user,
|
| 502 |
+
tx_type="TIP_OUT",
|
| 503 |
+
amount=-amount,
|
| 504 |
+
related_account=author,
|
| 505 |
+
item_id=post_id,
|
| 506 |
+
prev_hash=prev_hash_tipper,
|
| 507 |
+
tx_hash=calculate_tx_hash(tx_id_tipper, current_user, "TIP_OUT", -amount, prev_hash_tipper),
|
| 508 |
+
description=f"打赏给 {author_name}" + (f" 的帖子《{post_title}》" if post_title else ""),
|
| 509 |
+
item_title=post_title,
|
| 510 |
+
item_type="post",
|
| 511 |
+
related_user_name=author_name
|
| 512 |
+
)
|
| 513 |
+
|
| 514 |
+
# 接收方交易记录 (TIP_IN)
|
| 515 |
+
tx_author = Transaction(
|
| 516 |
+
tx_id=tx_id_author,
|
| 517 |
+
account=author,
|
| 518 |
+
tx_type="TIP_IN",
|
| 519 |
+
amount=amount,
|
| 520 |
+
related_account=current_user,
|
| 521 |
+
item_id=post_id,
|
| 522 |
+
prev_hash=prev_hash_author,
|
| 523 |
+
tx_hash=calculate_tx_hash(tx_id_author, author, "TIP_IN", amount, prev_hash_author),
|
| 524 |
+
description=f"收到 {tipper_name} 的帖子打赏" + (f" ({post_title})" if post_title else ""),
|
| 525 |
+
item_title=post_title,
|
| 526 |
+
item_type="post",
|
| 527 |
+
related_user_name=tipper_name if not is_anon else "匿名用户"
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
db_session.add(tx_tipper)
|
| 531 |
+
db_session.add(tx_author)
|
| 532 |
+
db_session.commit()
|
| 533 |
+
|
| 534 |
+
# 📝 审计日志
|
| 535 |
+
logger.info("POST_TIP")
|
| 536 |
+
|
| 537 |
+
except HTTPException:
|
| 538 |
+
db_session.rollback()
|
| 539 |
+
raise
|
| 540 |
+
except Exception as e:
|
| 541 |
+
db_session.rollback()
|
| 542 |
+
logger.error("POST_TIP_ERROR | error_type=%s", type(e).__name__)
|
| 543 |
+
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 544 |
+
|
| 545 |
+
def notify_tip():
|
| 546 |
+
# 🔔 打赏通知(考虑匿名)
|
| 547 |
+
if not is_anon:
|
| 548 |
+
add_notification(author, {
|
| 549 |
+
"type": "tip",
|
| 550 |
+
"from_user": current_user,
|
| 551 |
+
"target_item_id": post_id,
|
| 552 |
+
"target_item_title": post_title or "",
|
| 553 |
+
"content": f"您收到来自 {tipper_name} 的 {amount} 积分帖子打赏"
|
| 554 |
+
})
|
| 555 |
+
else:
|
| 556 |
+
add_notification(author, {
|
| 557 |
+
"type": "tip",
|
| 558 |
+
"from_user": "anonymous",
|
| 559 |
+
"target_item_id": post_id,
|
| 560 |
+
"target_item_title": "",
|
| 561 |
+
"content": f"您收到了一份 {amount} 积分的匿名帖子打赏"
|
| 562 |
+
})
|
| 563 |
+
|
| 564 |
+
run_tip_effect(notify_tip)
|
| 565 |
+
run_tip_effect(lambda: sort_cache.invalidate("posts:"))
|
| 566 |
+
|
| 567 |
+
# 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
|
| 568 |
+
def board_updater(data):
|
| 569 |
+
for post in data:
|
| 570 |
+
if post["id"] == post_id:
|
| 571 |
+
tip_board = post.get("tip_board", [])
|
| 572 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 573 |
+
if existing:
|
| 574 |
+
existing["amount"] += amount
|
| 575 |
+
else:
|
| 576 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 577 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 578 |
+
post["tip_board"] = tip_board
|
| 579 |
+
break
|
| 580 |
+
|
| 581 |
+
try:
|
| 582 |
+
db.atomic_update("posts.json", board_updater, default_data=[])
|
| 583 |
+
except Exception as e:
|
| 584 |
+
logger.error("POST_TIP_BOARD_ERROR | error_type=%s", type(e).__name__)
|
| 585 |
+
|
| 586 |
+
return {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 587 |
+
|
| 588 |
+
# ==========================================
|
| 589 |
+
# 💬 评论接口(复用通用评论系统)
|
| 590 |
+
# ==========================================
|
| 591 |
+
|
| 592 |
+
@router.get("/api/posts/{post_id}/comments")
|
| 593 |
+
async def get_post_comments(post_id: str):
|
| 594 |
+
"""获取评论;有界后台读取并保留原作者附加字段。"""
|
| 595 |
+
return {"status": "success", "data": await read_comment_view(post_id)}
|
| 596 |
+
|
| 597 |
+
@router.post("/api/posts/{post_id}/comments")
|
| 598 |
+
@shared_limit(10)
|
| 599 |
+
@_posts_endpoint
|
| 600 |
+
def add_post_comment(request: Request, post_id: str, content: str, current_user: str = Depends(require_auth)):
|
| 601 |
+
"""
|
| 602 |
+
添加帖子评论
|
| 603 |
+
"""
|
| 604 |
+
if not content or not content.strip():
|
| 605 |
+
raise HTTPException(status_code=400, detail="评论内容不能为空")
|
| 606 |
+
if len(content) > COMMENT_MAX_LENGTH:
|
| 607 |
+
raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
|
| 608 |
+
|
| 609 |
+
posts_db = db.load_data("posts.json", default_data=[])
|
| 610 |
+
|
| 611 |
+
# 检查帖子是否存在
|
| 612 |
+
post_exists = any(p["id"] == post_id for p in posts_db)
|
| 613 |
+
if not post_exists:
|
| 614 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 615 |
+
|
| 616 |
+
new_comment = {
|
| 617 |
+
"id": f"comment_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 618 |
+
"author": current_user,
|
| 619 |
+
"content": content.strip(),
|
| 620 |
+
"created_at": int(time.time())
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
append_comment(post_id, new_comment, prepend=True)
|
| 624 |
+
increment_comment_count("posts.json", post_id)
|
| 625 |
+
|
| 626 |
+
# 🗂️ 清除排序缓存(评论数变化可能影响排序)
|
| 627 |
+
sort_cache.invalidate("posts:")
|
| 628 |
+
|
| 629 |
+
return {"status": "success", "data": new_comment}
|
| 630 |
+
|
| 631 |
+
|
| 632 |
+
@router.post("/api/posts/{post_id}/view")
|
| 633 |
+
@shared_limit(60)
|
| 634 |
+
@_posts_endpoint
|
| 635 |
+
def record_post_view(request: Request, post_id: str, current_user: str = Depends(require_auth)):
|
| 636 |
+
"""
|
| 637 |
+
记录帖子访问量
|
| 638 |
+
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
| 639 |
+
"""
|
| 640 |
+
result = record_view("posts.json", post_id, current_user)
|
| 641 |
+
|
| 642 |
+
if result is None:
|
| 643 |
+
raise HTTPException(status_code=404, detail="帖子不存在")
|
| 644 |
+
|
| 645 |
+
# 🗂️ 清除排序缓存(浏览量变化可能影响排序)
|
| 646 |
+
sort_cache.invalidate("posts:")
|
| 647 |
+
|
| 648 |
+
return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
|
router_prompts.py
CHANGED
|
@@ -1,967 +1,988 @@
|
|
| 1 |
-
from publication_identity import publication_identity
|
| 2 |
-
# 云端Space代码/router_prompts.py
|
| 3 |
-
# ==========================================
|
| 4 |
-
# ✨ 提示词市场API路由(图像/视频提示词)
|
| 5 |
-
# ==========================================
|
| 6 |
-
# 功能:提示词发布、列表(分类筛选)、详情(付费内容脱敏)、
|
| 7 |
-
# 互动(点赞/收藏/评论/打赏)、购买解锁、分类管理
|
| 8 |
-
# 数据持久化:prompts.json / prompt_categories.json
|
| 9 |
-
# 与 posts.json 相同,经 数据库连接.py 自动同步至 HF Dataset
|
| 10 |
-
# 关联文件:
|
| 11 |
-
# - router_posts.py (实现模式参照)
|
| 12 |
-
# - router_wallet.py (钱包/交易逻辑参照)
|
| 13 |
-
# - models_sql.py (Wallet/Transaction/Ownership)
|
| 14 |
-
# ==========================================
|
| 15 |
-
|
| 16 |
-
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 17 |
-
from sqlalchemy.orm import Session
|
| 18 |
-
from models import PromptCreate, PromptUpdate, PromptCategoryAdd, PromptPurchaseRequest
|
| 19 |
-
from typing import Optional
|
| 20 |
-
import 数据库连接 as db
|
| 21 |
-
from comment_store import COMMENT_MAX_LENGTH, append_comment, increment_comment_count, attach_comment_counts, read_comment_view
|
| 22 |
-
from 安全认证 import require_auth, optional_auth, is_admin
|
| 23 |
-
from db_utils import read_public_source, record_view, sort_cache
|
| 24 |
-
from database_sql import get_db
|
| 25 |
-
from models_sql import Wallet, Transaction, Ownership
|
| 26 |
-
from notifications import add_notification
|
| 27 |
-
from tip_transaction import lock_tip_wallets
|
| 28 |
-
from purchase_transaction import effective_resource_price, lock_resource_purchase_wallets
|
| 29 |
-
from tip_store import run_tip_effect
|
| 30 |
-
import time
|
| 31 |
-
import
|
| 32 |
-
from prompt_store import (PromptStoreError, publish_prompt, edit_prompt,
|
| 33 |
-
delete_prompt as remove_prompt, project_price, load_categories, modify_category, read_prompts)
|
| 34 |
-
import uuid
|
| 35 |
-
import hashlib
|
| 36 |
-
import datetime
|
| 37 |
-
import logging
|
| 38 |
-
|
| 39 |
-
# 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致)
|
| 40 |
-
from rate_limit import shared_limit
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
if
|
| 408 |
-
raise HTTPException(status_code=400, detail=f"
|
| 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 |
-
prompt["
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
prompt["
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
):
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
db_session.
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from publication_identity import publication_identity
|
| 2 |
+
# 云端Space代码/router_prompts.py
|
| 3 |
+
# ==========================================
|
| 4 |
+
# ✨ 提示词市场API路由(图像/视频提示词)
|
| 5 |
+
# ==========================================
|
| 6 |
+
# 功能:提示词发布、列表(分类筛选)、详情(付费内容脱敏)、
|
| 7 |
+
# 互动(点赞/收藏/评论/打赏)、购买解锁、分类管理
|
| 8 |
+
# 数据持久化:prompts.json / prompt_categories.json
|
| 9 |
+
# 与 posts.json 相同,经 数据库连接.py 自动同步至 HF Dataset
|
| 10 |
+
# 关联文件:
|
| 11 |
+
# - router_posts.py (实现模式参照)
|
| 12 |
+
# - router_wallet.py (钱包/交易逻辑参照)
|
| 13 |
+
# - models_sql.py (Wallet/Transaction/Ownership)
|
| 14 |
+
# ==========================================
|
| 15 |
+
|
| 16 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 17 |
+
from sqlalchemy.orm import Session
|
| 18 |
+
from models import PromptCreate, PromptUpdate, PromptCategoryAdd, PromptPurchaseRequest
|
| 19 |
+
from typing import Optional
|
| 20 |
+
import 数据库连接 as db
|
| 21 |
+
from comment_store import COMMENT_MAX_LENGTH, append_comment, increment_comment_count, attach_comment_counts, read_comment_view
|
| 22 |
+
from 安全认证 import require_auth, optional_auth, is_admin
|
| 23 |
+
from db_utils import read_public_source, record_view, sort_cache
|
| 24 |
+
from database_sql import get_db
|
| 25 |
+
from models_sql import Wallet, Transaction, Ownership
|
| 26 |
+
from notifications import add_notification
|
| 27 |
+
from tip_transaction import lock_tip_wallets
|
| 28 |
+
from purchase_transaction import effective_resource_price, lock_resource_purchase_wallets
|
| 29 |
+
from tip_store import run_tip_effect
|
| 30 |
+
import time
|
| 31 |
+
from owned_thread import OwnedThreadGroup, owned_sync_endpoint
|
| 32 |
+
from prompt_store import (PromptStoreError, publish_prompt, edit_prompt,
|
| 33 |
+
delete_prompt as remove_prompt, project_price, load_categories, modify_category, read_prompts)
|
| 34 |
+
import uuid
|
| 35 |
+
import hashlib
|
| 36 |
+
import datetime
|
| 37 |
+
import logging
|
| 38 |
+
|
| 39 |
+
# 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致)
|
| 40 |
+
from rate_limit import shared_limit
|
| 41 |
+
|
| 42 |
+
MAX_PROMPTS_IO = 16
|
| 43 |
+
_prompts_io = OwnedThreadGroup(MAX_PROMPTS_IO,
|
| 44 |
+
lambda: HTTPException(status_code=503, detail="提示词数据暂不可用"))
|
| 45 |
+
start_prompts_io = _prompts_io.start
|
| 46 |
+
stop_prompts_io = _prompts_io.stop
|
| 47 |
+
close_prompts_io = _prompts_io.close
|
| 48 |
+
_prompts_endpoint = owned_sync_endpoint(_prompts_io)
|
| 49 |
+
|
| 50 |
+
router = APIRouter()
|
| 51 |
+
|
| 52 |
+
# 📝 审计日志
|
| 53 |
+
logger = logging.getLogger("ComfyUI-Ranking.Prompts")
|
| 54 |
+
|
| 55 |
+
# 内置默认分类(首次读取 prompt_categories.json 不存在时自动初始化)
|
| 56 |
+
DEFAULT_CATEGORIES = {
|
| 57 |
+
"image": ["人物", "动物", "植物", "道具", "建筑"],
|
| 58 |
+
"video": ["动作", "表情变化", "天气变化", "光效变化", "运镜", "视觉风格"],
|
| 59 |
+
"music": ["中国风", "流行", "说唱", "摇滚", "电音", "纯音乐"]
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
VALID_PROMPT_TYPES = ("image", "video", "music")
|
| 63 |
+
|
| 64 |
+
# 内容长度上限
|
| 65 |
+
MAX_TITLE_LEN = 50
|
| 66 |
+
MAX_CONTENT_LEN = 2000
|
| 67 |
+
MAX_PROMPT_TEXT_LEN = 20000
|
| 68 |
+
MAX_PRICE = 10000
|
| 69 |
+
MAX_CATEGORY_LEN = 8
|
| 70 |
+
MAX_CATEGORY_PER_TYPE = 30
|
| 71 |
+
MAX_TAGS = 10 # 单个提示词最多标签数
|
| 72 |
+
MAX_TAG_LEN = 12 # 单个标签最大字数
|
| 73 |
+
MAX_IMAGES = 9 # 图像提示词最多图片数(与讨论区一致)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _sanitize_images(images) -> list:
|
| 77 |
+
"""清洗图片列表:过滤非法元素,最多 MAX_IMAGES 张"""
|
| 78 |
+
if not isinstance(images, list):
|
| 79 |
+
return []
|
| 80 |
+
cleaned = []
|
| 81 |
+
for url in images:
|
| 82 |
+
if not isinstance(url, str):
|
| 83 |
+
continue
|
| 84 |
+
u = url.strip()
|
| 85 |
+
if u:
|
| 86 |
+
cleaned.append(u)
|
| 87 |
+
if len(cleaned) >= MAX_IMAGES:
|
| 88 |
+
break
|
| 89 |
+
return cleaned
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
| 93 |
+
data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
|
| 94 |
+
return hashlib.sha256(data.encode()).hexdigest()
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _sanitize_tags(tags) -> list:
|
| 98 |
+
"""清洗标签:去空白/去重/截断,最多 MAX_TAGS 个,每个≤ MAX_TAG_LEN 字"""
|
| 99 |
+
if not isinstance(tags, list):
|
| 100 |
+
return []
|
| 101 |
+
cleaned = []
|
| 102 |
+
for tag in tags:
|
| 103 |
+
if not isinstance(tag, str):
|
| 104 |
+
continue
|
| 105 |
+
name = tag.strip()[:MAX_TAG_LEN]
|
| 106 |
+
if name and name not in cleaned:
|
| 107 |
+
cleaned.append(name)
|
| 108 |
+
if len(cleaned) >= MAX_TAGS:
|
| 109 |
+
break
|
| 110 |
+
return cleaned
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _prompt_operation(operation, *args, **kwargs):
|
| 114 |
+
try:
|
| 115 |
+
return operation(*args, **kwargs)
|
| 116 |
+
except PromptStoreError as error:
|
| 117 |
+
raise HTTPException(status_code=error.status_code, detail=error.message) from None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _invalidate_prompts():
|
| 121 |
+
try:
|
| 122 |
+
sort_cache.invalidate("prompts:")
|
| 123 |
+
except Exception as error:
|
| 124 |
+
logger.warning("提示词排序缓存失效失败: error_type=%s", type(error).__name__)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _user_owns_prompt(db_session: Session, account: str, prompt_id: str) -> bool:
|
| 128 |
+
"""检查用户是否已购买(未退款)该提示词"""
|
| 129 |
+
if not account:
|
| 130 |
+
return False
|
| 131 |
+
ownership = db_session.query(Ownership).filter(
|
| 132 |
+
Ownership.account == account,
|
| 133 |
+
Ownership.item_id == prompt_id,
|
| 134 |
+
Ownership.is_refunded == False
|
| 135 |
+
).first()
|
| 136 |
+
return ownership is not None
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _mask_prompt_text(prompt_data: dict, can_view: bool) -> dict:
|
| 140 |
+
"""
|
| 141 |
+
🔒 内容脱敏核心逻辑:
|
| 142 |
+
未授权用户一律不下发 prompt_text(服务端脱敏,不依赖前端隐藏)
|
| 143 |
+
"""
|
| 144 |
+
if can_view:
|
| 145 |
+
prompt_data["locked"] = False
|
| 146 |
+
else:
|
| 147 |
+
prompt_data["prompt_text"] = None
|
| 148 |
+
prompt_data["locked"] = True
|
| 149 |
+
return prompt_data
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ==========================================
|
| 153 |
+
# 📂 分类管理接口
|
| 154 |
+
# ==========================================
|
| 155 |
+
|
| 156 |
+
@router.get("/api/prompt-categories")
|
| 157 |
+
@_prompts_endpoint
|
| 158 |
+
def get_prompt_categories(prompt_type: str = None):
|
| 159 |
+
"""
|
| 160 |
+
获取提示词分类列表
|
| 161 |
+
- prompt_type: image / video / music,不传则返回全部
|
| 162 |
+
"""
|
| 163 |
+
categories = _prompt_operation(load_categories, db, DEFAULT_CATEGORIES)
|
| 164 |
+
if prompt_type in VALID_PROMPT_TYPES:
|
| 165 |
+
return {"status": "success", "data": categories.get(prompt_type, [])}
|
| 166 |
+
return {"status": "success", "data": categories}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@router.post("/api/prompt-categories")
|
| 170 |
+
@shared_limit(5) # 🔒 限流:防止分类刷屏
|
| 171 |
+
@_prompts_endpoint
|
| 172 |
+
def add_prompt_category(request: Request, body: PromptCategoryAdd, current_user: str = Depends(require_auth)):
|
| 173 |
+
"""
|
| 174 |
+
添加提示词分类(仅管理员,入口在云端管理界面,重名去重)
|
| 175 |
+
"""
|
| 176 |
+
if not is_admin(current_user):
|
| 177 |
+
raise HTTPException(status_code=403, detail="需要管理员权限")
|
| 178 |
+
if body.prompt_type not in VALID_PROMPT_TYPES:
|
| 179 |
+
raise HTTPException(status_code=400, detail="分类所属类型必须是 image、video 或 music")
|
| 180 |
+
|
| 181 |
+
name = (body.name or "").strip()
|
| 182 |
+
if not name:
|
| 183 |
+
raise HTTPException(status_code=400, detail="分类名称不能为空")
|
| 184 |
+
if len(name) > MAX_CATEGORY_LEN:
|
| 185 |
+
raise HTTPException(status_code=400, detail=f"分类名称不能超过{MAX_CATEGORY_LEN}个字")
|
| 186 |
+
|
| 187 |
+
values = _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, body.prompt_type, name, maximum=MAX_CATEGORY_PER_TYPE)
|
| 188 |
+
logger.info("PROMPT_CATEGORY_ADD | type=%s", body.prompt_type)
|
| 189 |
+
return {"status": "success", "data": values}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@router.delete("/api/prompt-categories")
|
| 193 |
+
@shared_limit(5) # 🔒 限流:与添加分类一致
|
| 194 |
+
@_prompts_endpoint
|
| 195 |
+
def delete_prompt_category(request: Request, prompt_type: str = None, name: str = None,
|
| 196 |
+
current_user: str = Depends(require_auth)):
|
| 197 |
+
"""
|
| 198 |
+
删除提示词分类(仅管理员)
|
| 199 |
+
- prompt_type: image / video / music
|
| 200 |
+
- name: 分类名称
|
| 201 |
+
⚠️ 已使用该分类的提示词不受影响(分类字段保留原文本,仅从可选列表移除)
|
| 202 |
+
"""
|
| 203 |
+
if not is_admin(current_user):
|
| 204 |
+
raise HTTPException(status_code=403, detail="需要管理员权限")
|
| 205 |
+
if prompt_type not in VALID_PROMPT_TYPES:
|
| 206 |
+
raise HTTPException(status_code=400, detail="分类所属类型必须是 image、video 或 music")
|
| 207 |
+
|
| 208 |
+
name = (name or "").strip()
|
| 209 |
+
if not name:
|
| 210 |
+
raise HTTPException(status_code=400, detail="分类名称不能为空")
|
| 211 |
+
|
| 212 |
+
values = _prompt_operation(modify_category, db, DEFAULT_CATEGORIES, prompt_type, name, delete=True)
|
| 213 |
+
logger.info("PROMPT_CATEGORY_DELETE | type=%s", prompt_type)
|
| 214 |
+
return {"status": "success", "data": values}
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ==========================================
|
| 218 |
+
# 📝 提示词CRUD接口
|
| 219 |
+
# ==========================================
|
| 220 |
+
|
| 221 |
+
@router.get("/api/prompt-tags")
|
| 222 |
+
@_prompts_endpoint
|
| 223 |
+
def get_prompt_tags():
|
| 224 |
+
"""
|
| 225 |
+
🏷️ 聚合全站标签(发布表单推荐用,按使用次数降序,最多返回100个)
|
| 226 |
+
"""
|
| 227 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 228 |
+
counter = {}
|
| 229 |
+
for prompt in prompts_db:
|
| 230 |
+
for tag in (prompt.get("tags") or []):
|
| 231 |
+
if isinstance(tag, str) and tag.strip():
|
| 232 |
+
counter[tag] = counter.get(tag, 0) + 1
|
| 233 |
+
ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))[:100]
|
| 234 |
+
return {"status": "success", "data": [{"name": n, "count": c} for n, c in ranked]}
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
@router.get("/api/prompts")
|
| 238 |
+
@_prompts_endpoint
|
| 239 |
+
def get_prompts(prompt_type: str = None, category: str = None,
|
| 240 |
+
page: int = 1, limit: int = 20, sort: str = "latest"):
|
| 241 |
+
"""
|
| 242 |
+
获取提示词列表(分页,支持类型/分类筛选与多种排序)
|
| 243 |
+
- prompt_type: image / video / music(子界面筛选)
|
| 244 |
+
- category: 分类筛选
|
| 245 |
+
- sort: latest / likes / favorites / views / daily_views / tips
|
| 246 |
+
⚠️ 列表接口永不下发 prompt_text(付费内容脱敏)
|
| 247 |
+
"""
|
| 248 |
+
read_epoch = sort_cache.capture_read_epoch()
|
| 249 |
+
prompts_db = _prompt_operation(read_prompts, db)
|
| 250 |
+
users_db = read_public_source(db, "users.json", default_data={})
|
| 251 |
+
user_map = users_db
|
| 252 |
+
|
| 253 |
+
# 🗂️ 使用排序缓存优化排序性能
|
| 254 |
+
cache_sort = sort if sort in ("latest", "likes", "favorites", "views", "daily_views", "tips") else "latest"
|
| 255 |
+
cache_key = f"prompts:{cache_sort}"
|
| 256 |
+
|
| 257 |
+
def order_key(item):
|
| 258 |
+
if sort == "likes":
|
| 259 |
+
return item.get("likes", 0)
|
| 260 |
+
elif sort == "favorites":
|
| 261 |
+
return item.get("favorites", 0)
|
| 262 |
+
elif sort == "views":
|
| 263 |
+
return item.get("views", 0)
|
| 264 |
+
elif sort == "daily_views":
|
| 265 |
+
return item.get("daily_views", 0)
|
| 266 |
+
elif sort == "tips":
|
| 267 |
+
return sum(t.get("amount", 0) for t in item.get("tip_board", []))
|
| 268 |
+
else: # latest 或其他默认
|
| 269 |
+
return item.get("created_at", 0)
|
| 270 |
+
|
| 271 |
+
def sort_fn(data):
|
| 272 |
+
data.sort(key=order_key, reverse=True)
|
| 273 |
+
|
| 274 |
+
sorted_prompts = sort_cache.get_sorted(cache_key, prompts_db, sort_fn, read_epoch=read_epoch, fresh=True)
|
| 275 |
+
|
| 276 |
+
# 类型与分类过滤
|
| 277 |
+
if prompt_type in VALID_PROMPT_TYPES:
|
| 278 |
+
sorted_prompts = [p for p in sorted_prompts if p.get("prompt_type") == prompt_type]
|
| 279 |
+
if category:
|
| 280 |
+
sorted_prompts = [p for p in sorted_prompts if p.get("category") == category]
|
| 281 |
+
|
| 282 |
+
# 分页
|
| 283 |
+
total = len(sorted_prompts)
|
| 284 |
+
start = (page - 1) * limit
|
| 285 |
+
end = start + limit
|
| 286 |
+
paged_prompts = sorted_prompts[start:end]
|
| 287 |
+
|
| 288 |
+
# 附加作者信息,并过滤敏感字段
|
| 289 |
+
result = []
|
| 290 |
+
for prompt in paged_prompts:
|
| 291 |
+
try:
|
| 292 |
+
prompt = project_price(prompt)
|
| 293 |
+
except Exception:
|
| 294 |
+
raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None
|
| 295 |
+
author_info = user_map.get(prompt.get("author"), {})
|
| 296 |
+
prompt_data = {
|
| 297 |
+
**prompt,
|
| 298 |
+
"author_name": author_info.get("name", prompt.get("author")),
|
| 299 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 300 |
+
}
|
| 301 |
+
# 🔒 脱敏:列表永不下发提示词正文与浏览记录
|
| 302 |
+
prompt_data.pop("prompt_text", None)
|
| 303 |
+
prompt_data.pop("viewed_by", None)
|
| 304 |
+
result.append(prompt_data)
|
| 305 |
+
|
| 306 |
+
attach_comment_counts(result)
|
| 307 |
+
return {
|
| 308 |
+
"status": "success",
|
| 309 |
+
"data": result,
|
| 310 |
+
"total": total,
|
| 311 |
+
"page": page,
|
| 312 |
+
"limit": limit
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
@router.get("/api/my-prompts")
|
| 317 |
+
@_prompts_endpoint
|
| 318 |
+
def get_my_prompts(current_user: str = Depends(require_auth)):
|
| 319 |
+
"""
|
| 320 |
+
获取我发布的提示词(个人中心用)
|
| 321 |
+
作者本人可见自己的 prompt_text
|
| 322 |
+
"""
|
| 323 |
+
prompts_db = _prompt_operation(read_prompts, db)
|
| 324 |
+
|
| 325 |
+
my_prompts = [p for p in prompts_db if p.get("author") == current_user]
|
| 326 |
+
my_prompts = sorted(my_prompts, key=lambda x: x.get("created_at", 0), reverse=True)
|
| 327 |
+
|
| 328 |
+
result = []
|
| 329 |
+
for prompt in my_prompts:
|
| 330 |
+
try:
|
| 331 |
+
prompt = project_price(prompt)
|
| 332 |
+
except Exception:
|
| 333 |
+
raise HTTPException(status_code=503, detail="提示词暂时不可读取") from None
|
| 334 |
+
prompt_data = dict(prompt)
|
| 335 |
+
prompt_data.pop("viewed_by", None)
|
| 336 |
+
result.append(prompt_data)
|
| 337 |
+
|
| 338 |
+
attach_comment_counts(result)
|
| 339 |
+
return {"status": "success", "data": result}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
@router.get("/api/prompts/{prompt_id}")
|
| 343 |
+
async def get_prompt_detail(prompt_id: str, current_user: str = Depends(optional_auth),
|
| 344 |
+
db_session: Session = Depends(get_db)):
|
| 345 |
+
"""
|
| 346 |
+
获取提示词详情
|
| 347 |
+
🔒 仅当 ①免费 ②作者本人 ③已购买 时下发 prompt_text
|
| 348 |
+
"""
|
| 349 |
+
try:
|
| 350 |
+
prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True)
|
| 351 |
+
if not isinstance(prompts_db, list):
|
| 352 |
+
raise ValueError("invalid prompt collection")
|
| 353 |
+
except Exception:
|
| 354 |
+
raise HTTPException(status_code=503, detail="提示词暂时不可读取")
|
| 355 |
+
users_db = db.load_data("users.json", default_data={})
|
| 356 |
+
user_map = users_db
|
| 357 |
+
|
| 358 |
+
for prompt in prompts_db:
|
| 359 |
+
if isinstance(prompt, dict) and prompt.get("id") == prompt_id:
|
| 360 |
+
try:
|
| 361 |
+
price = effective_resource_price(prompt)
|
| 362 |
+
except Exception:
|
| 363 |
+
raise HTTPException(status_code=503, detail="提示词暂时不可读取")
|
| 364 |
+
|
| 365 |
+
author_info = user_map.get(prompt.get("author"), {})
|
| 366 |
+
prompt_data = {
|
| 367 |
+
**prompt,
|
| 368 |
+
"price": price,
|
| 369 |
+
"author_name": author_info.get("name", prompt.get("author")),
|
| 370 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 371 |
+
}
|
| 372 |
+
prompt_data.pop("viewed_by", None)
|
| 373 |
+
|
| 374 |
+
is_author = current_user and current_user == prompt.get("author")
|
| 375 |
+
can_view = price <= 0 or is_author or _user_owns_prompt(db_session, current_user, prompt_id)
|
| 376 |
+
prompt_data["owned"] = bool(can_view)
|
| 377 |
+
prompt_data["price_confirmation_supported"] = True
|
| 378 |
+
_mask_prompt_text(prompt_data, can_view)
|
| 379 |
+
|
| 380 |
+
attach_comment_counts([prompt_data])
|
| 381 |
+
return {"status": "success", "data": prompt_data}
|
| 382 |
+
|
| 383 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
@router.post("/api/prompts")
|
| 387 |
+
@shared_limit(5)
|
| 388 |
+
@_prompts_endpoint
|
| 389 |
+
def create_prompt(request: Request, prompt: PromptCreate, current_user: str = Depends(require_auth)):
|
| 390 |
+
"""
|
| 391 |
+
发布提示词
|
| 392 |
+
"""
|
| 393 |
+
try:
|
| 394 |
+
stable_id = publication_identity('prompt', current_user, getattr(request, 'headers', {}).get('Idempotency-Key'))
|
| 395 |
+
except ValueError:
|
| 396 |
+
raise HTTPException(status_code=400, detail='发布请求身份无效') from None
|
| 397 |
+
|
| 398 |
+
# 参数校验
|
| 399 |
+
if prompt.prompt_type not in VALID_PROMPT_TYPES:
|
| 400 |
+
raise HTTPException(status_code=400, detail="提示词类型必须是 image、video 或 music")
|
| 401 |
+
title = (prompt.title or "").strip()
|
| 402 |
+
content = (prompt.content or "").strip()
|
| 403 |
+
prompt_text = (prompt.prompt_text or "").strip()
|
| 404 |
+
category = (prompt.category or "").strip()
|
| 405 |
+
if not title or not content or not prompt_text:
|
| 406 |
+
raise HTTPException(status_code=400, detail="标题、介绍文案与提示词正文均不能为空")
|
| 407 |
+
if len(title) > MAX_TITLE_LEN:
|
| 408 |
+
raise HTTPException(status_code=400, detail=f"标题不能超过{MAX_TITLE_LEN}个字")
|
| 409 |
+
if len(content) > MAX_CONTENT_LEN:
|
| 410 |
+
raise HTTPException(status_code=400, detail=f"介绍文案不能超过{MAX_CONTENT_LEN}个字")
|
| 411 |
+
if len(prompt_text) > MAX_PROMPT_TEXT_LEN:
|
| 412 |
+
raise HTTPException(status_code=400, detail=f"提示词正文不能超过{MAX_PROMPT_TEXT_LEN}个字")
|
| 413 |
+
if not category:
|
| 414 |
+
raise HTTPException(status_code=400, detail="请选择分类")
|
| 415 |
+
|
| 416 |
+
# 分类必须存在于分类库
|
| 417 |
+
categories = _prompt_operation(load_categories, db, DEFAULT_CATEGORIES)
|
| 418 |
+
if category not in categories.get(prompt.prompt_type, []):
|
| 419 |
+
raise HTTPException(status_code=400, detail="所选分类不存在")
|
| 420 |
+
|
| 421 |
+
price = prompt.price
|
| 422 |
+
if type(price) is not int or not 0 <= price <= MAX_PRICE:
|
| 423 |
+
raise HTTPException(status_code=400, detail=f"定价必须在0-{MAX_PRICE}之间")
|
| 424 |
+
|
| 425 |
+
media_type = prompt.media_type if prompt.media_type in ("image", "video") else "image"
|
| 426 |
+
video_url = prompt.video_url if media_type == "video" else None
|
| 427 |
+
if media_type == "video" and not video_url:
|
| 428 |
+
# 未上传示例视频时降级为图片展示
|
| 429 |
+
media_type = "image"
|
| 430 |
+
|
| 431 |
+
# 图像提示词的图片列表(首张即封面);视频提示词不保留图片列表
|
| 432 |
+
images = _sanitize_images(prompt.images) if media_type == "image" else []
|
| 433 |
+
|
| 434 |
+
# 🎵 音乐提示词支持音频示例(图片+音频方案,视频可选);非音乐类型不保留
|
| 435 |
+
audio_url = (prompt.audio_url or "").strip() or None
|
| 436 |
+
if prompt.prompt_type != "music":
|
| 437 |
+
audio_url = None
|
| 438 |
+
|
| 439 |
+
new_prompt = {
|
| 440 |
+
"id": stable_id or f"prompt_{int(time.time())}_{uuid.uuid4().hex}",
|
| 441 |
+
"prompt_type": prompt.prompt_type,
|
| 442 |
+
"category": category,
|
| 443 |
+
"title": title,
|
| 444 |
+
"content": content,
|
| 445 |
+
"prompt_text": prompt_text,
|
| 446 |
+
"cover_image": prompt.cover_image,
|
| 447 |
+
"media_type": media_type,
|
| 448 |
+
"video_url": video_url,
|
| 449 |
+
"audio_url": audio_url,
|
| 450 |
+
"images": images,
|
| 451 |
+
"is_original": bool(prompt.is_original),
|
| 452 |
+
"price": price,
|
| 453 |
+
"author": current_user,
|
| 454 |
+
"created_at": int(time.time()),
|
| 455 |
+
# 互动数据
|
| 456 |
+
"likes": 0,
|
| 457 |
+
"favorites": 0,
|
| 458 |
+
"comments": 0,
|
| 459 |
+
"liked_by": [],
|
| 460 |
+
"favorited_by": [],
|
| 461 |
+
"tip_board": [], # 打赏榜单
|
| 462 |
+
"views": 0,
|
| 463 |
+
"daily_views": 0,
|
| 464 |
+
"viewed_by": [],
|
| 465 |
+
"daily_views_date": "",
|
| 466 |
+
"tags": _sanitize_tags(prompt.tags)
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
new_prompt = _prompt_operation(publish_prompt, db, new_prompt, stable_id is not None)
|
| 470 |
+
_invalidate_prompts()
|
| 471 |
+
|
| 472 |
+
return {"status": "success", "data": new_prompt}
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
@router.put("/api/prompts/{prompt_id}")
|
| 476 |
+
@shared_limit(10)
|
| 477 |
+
@_prompts_endpoint
|
| 478 |
+
def update_prompt(request: Request, prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)):
|
| 479 |
+
"""
|
| 480 |
+
更新提示词(仅作者可操作,原子操作,并发安全)
|
| 481 |
+
"""
|
| 482 |
+
error_map = {
|
| 483 |
+
"forbidden": (403, "无权修改他人提示词"),
|
| 484 |
+
"not_found": (404, "提示词不存在"),
|
| 485 |
+
"invalid_title": (400, f"标题不能为空且不能超过{MAX_TITLE_LEN}个字"),
|
| 486 |
+
"invalid_content": (400, f"介绍文案不能超过{MAX_CONTENT_LEN}个字"),
|
| 487 |
+
"invalid_prompt_text": (400, "提示词正文不能为空且长度超限"),
|
| 488 |
+
"invalid_category": (400, "所选分类不存在"),
|
| 489 |
+
"invalid_price": (400, f"定价必须在0-{MAX_PRICE}之间"),
|
| 490 |
+
"invalid_media_type": (400, "媒体类型必须是 image 或 video")
|
| 491 |
+
}
|
| 492 |
+
categories = _prompt_operation(load_categories, db, DEFAULT_CATEGORIES) if update_data.category is not None else None
|
| 493 |
+
|
| 494 |
+
def apply(prompt):
|
| 495 |
+
# 更新字段
|
| 496 |
+
if update_data.title is not None:
|
| 497 |
+
title = update_data.title.strip()
|
| 498 |
+
if not title or len(title) > MAX_TITLE_LEN:
|
| 499 |
+
raise PromptStoreError(*error_map["invalid_title"])
|
| 500 |
+
prompt["title"] = title
|
| 501 |
+
if update_data.content is not None:
|
| 502 |
+
content = update_data.content.strip()
|
| 503 |
+
if len(content) > MAX_CONTENT_LEN:
|
| 504 |
+
raise PromptStoreError(*error_map["invalid_content"])
|
| 505 |
+
prompt["content"] = content
|
| 506 |
+
if update_data.prompt_text is not None:
|
| 507 |
+
prompt_text = update_data.prompt_text.strip()
|
| 508 |
+
if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN:
|
| 509 |
+
raise PromptStoreError(*error_map["invalid_prompt_text"])
|
| 510 |
+
prompt["prompt_text"] = prompt_text
|
| 511 |
+
if update_data.category is not None:
|
| 512 |
+
category = update_data.category.strip()
|
| 513 |
+
if category not in categories.get(prompt.get("prompt_type"), []):
|
| 514 |
+
raise PromptStoreError(*error_map["invalid_category"])
|
| 515 |
+
prompt["category"] = category
|
| 516 |
+
if update_data.cover_image is not None:
|
| 517 |
+
prompt["cover_image"] = update_data.cover_image
|
| 518 |
+
if update_data.price is not None:
|
| 519 |
+
price = update_data.price
|
| 520 |
+
if type(price) is not int or not 0 <= price <= MAX_PRICE:
|
| 521 |
+
raise PromptStoreError(*error_map["invalid_price"])
|
| 522 |
+
current_price = effective_resource_price(prompt)
|
| 523 |
+
if current_price != price:
|
| 524 |
+
# 设置待生效价格,24小时后生效(与资源改价机制同构;提示词不支持退款,无需禁购配套)
|
| 525 |
+
effective_time = datetime.datetime.now() + datetime.timedelta(hours=24)
|
| 526 |
+
prompt["pending_price"] = price
|
| 527 |
+
prompt["pending_price_effective_at"] = effective_time.isoformat()
|
| 528 |
+
# 不立即修改 price,等待24小时后生效
|
| 529 |
+
else:
|
| 530 |
+
# 价格未变,清除待生效价格
|
| 531 |
+
prompt["pending_price"] = None
|
| 532 |
+
prompt["pending_price_effective_at"] = None
|
| 533 |
+
if update_data.media_type is not None:
|
| 534 |
+
if update_data.media_type not in ("image", "video"):
|
| 535 |
+
raise PromptStoreError(*error_map["invalid_media_type"])
|
| 536 |
+
prompt["media_type"] = update_data.media_type
|
| 537 |
+
if update_data.media_type == "image":
|
| 538 |
+
# 图像类型不保留视频(与 create 逻辑一致)
|
| 539 |
+
prompt["video_url"] = None
|
| 540 |
+
if update_data.video_url is not None:
|
| 541 |
+
prompt["video_url"] = update_data.video_url
|
| 542 |
+
if update_data.video_url:
|
| 543 |
+
prompt["media_type"] = "video"
|
| 544 |
+
if update_data.audio_url is not None or "audio_url" in getattr(update_data, "model_fields_set", set()):
|
| 545 |
+
# 🎵 音频仅音乐提示词保留;显式空字符串/null 清除,未传保持
|
| 546 |
+
audio_url = (update_data.audio_url or "").strip() or None
|
| 547 |
+
prompt["audio_url"] = audio_url if prompt.get("prompt_type") == "music" else None
|
| 548 |
+
if update_data.images is not None:
|
| 549 |
+
prompt["images"] = _sanitize_images(update_data.images)
|
| 550 |
+
if update_data.is_original is not None:
|
| 551 |
+
prompt["is_original"] = bool(update_data.is_original)
|
| 552 |
+
if update_data.tags is not None:
|
| 553 |
+
prompt["tags"] = _sanitize_tags(update_data.tags)
|
| 554 |
+
if prompt.get("media_type") == "image":
|
| 555 |
+
prompt["video_url"] = None
|
| 556 |
+
else:
|
| 557 |
+
prompt["images"] = []
|
| 558 |
+
|
| 559 |
+
_prompt_operation(edit_prompt, db, prompt_id, current_user, apply)
|
| 560 |
+
_invalidate_prompts()
|
| 561 |
+
return {"status": "success"}
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
@router.delete("/api/prompts/{prompt_id}")
|
| 565 |
+
@shared_limit(10)
|
| 566 |
+
@_prompts_endpoint
|
| 567 |
+
def delete_prompt(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
|
| 568 |
+
"""
|
| 569 |
+
删除提示词(仅作者可操作)
|
| 570 |
+
"""
|
| 571 |
+
_prompt_operation(remove_prompt, db, prompt_id, current_user)
|
| 572 |
+
_invalidate_prompts()
|
| 573 |
+
return {"status": "success"}
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
# ==========================================
|
| 577 |
+
# ❤️ 互动接口(点赞/收藏)
|
| 578 |
+
# ==========================================
|
| 579 |
+
|
| 580 |
+
@router.post("/api/prompts/{prompt_id}/like")
|
| 581 |
+
@shared_limit(30)
|
| 582 |
+
@_prompts_endpoint
|
| 583 |
+
def toggle_like(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
|
| 584 |
+
"""
|
| 585 |
+
点赞/取消点赞(原子操作,并发安全)
|
| 586 |
+
"""
|
| 587 |
+
result_container = [None]
|
| 588 |
+
|
| 589 |
+
def updater(data):
|
| 590 |
+
for prompt in data:
|
| 591 |
+
if prompt["id"] == prompt_id:
|
| 592 |
+
liked_by = prompt.get("liked_by", [])
|
| 593 |
+
if current_user in liked_by:
|
| 594 |
+
liked_by.remove(current_user)
|
| 595 |
+
prompt["likes"] = max(0, prompt.get("likes", 0) - 1)
|
| 596 |
+
action = "unliked"
|
| 597 |
+
else:
|
| 598 |
+
liked_by.append(current_user)
|
| 599 |
+
prompt["likes"] = prompt.get("likes", 0) + 1
|
| 600 |
+
action = "liked"
|
| 601 |
+
prompt["liked_by"] = liked_by
|
| 602 |
+
result_container[0] = {"status": "success", "action": action, "likes": prompt["likes"]}
|
| 603 |
+
return
|
| 604 |
+
result_container[0] = None # 未找到提示词
|
| 605 |
+
|
| 606 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 607 |
+
|
| 608 |
+
if result_container[0] is None:
|
| 609 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 610 |
+
|
| 611 |
+
# 🗂️ 清除排序缓存(点赞数变化可能影响排序)
|
| 612 |
+
sort_cache.invalidate("prompts:")
|
| 613 |
+
|
| 614 |
+
return result_container[0]
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
@router.post("/api/prompts/{prompt_id}/favorite")
|
| 618 |
+
@shared_limit(30)
|
| 619 |
+
@_prompts_endpoint
|
| 620 |
+
def toggle_favorite(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
|
| 621 |
+
"""
|
| 622 |
+
收藏/取消收藏(原子操作,并发安全)
|
| 623 |
+
"""
|
| 624 |
+
result_container = [None]
|
| 625 |
+
|
| 626 |
+
def updater(data):
|
| 627 |
+
for prompt in data:
|
| 628 |
+
if prompt["id"] == prompt_id:
|
| 629 |
+
favorited_by = prompt.get("favorited_by", [])
|
| 630 |
+
if current_user in favorited_by:
|
| 631 |
+
favorited_by.remove(current_user)
|
| 632 |
+
prompt["favorites"] = max(0, prompt.get("favorites", 0) - 1)
|
| 633 |
+
action = "unfavorited"
|
| 634 |
+
else:
|
| 635 |
+
favorited_by.append(current_user)
|
| 636 |
+
prompt["favorites"] = prompt.get("favorites", 0) + 1
|
| 637 |
+
action = "favorited"
|
| 638 |
+
prompt["favorited_by"] = favorited_by
|
| 639 |
+
result_container[0] = {"status": "success", "action": action, "favorites": prompt["favorites"]}
|
| 640 |
+
return
|
| 641 |
+
result_container[0] = None # 未找到提示词
|
| 642 |
+
|
| 643 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 644 |
+
|
| 645 |
+
if result_container[0] is None:
|
| 646 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 647 |
+
|
| 648 |
+
# 🗂️ 清除排序缓存(收藏数变化可能影响排序)
|
| 649 |
+
sort_cache.invalidate("prompts:")
|
| 650 |
+
|
| 651 |
+
return result_container[0]
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
# ==========================================
|
| 655 |
+
# 🎁 打赏接口
|
| 656 |
+
# ==========================================
|
| 657 |
+
|
| 658 |
+
@router.post("/api/prompts/{prompt_id}/tip")
|
| 659 |
+
@shared_limit(10)
|
| 660 |
+
async def tip_prompt(request: Request, prompt_id: str, amount: int, is_anon: bool = False,
|
| 661 |
+
current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
|
| 662 |
+
"""
|
| 663 |
+
打赏提示词(原子操作,并发安全)- 使用 SQL Wallet 系统
|
| 664 |
+
"""
|
| 665 |
+
if amount <= 0:
|
| 666 |
+
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 667 |
+
MAX_TIP_AMOUNT = 100000
|
| 668 |
+
if amount > MAX_TIP_AMOUNT:
|
| 669 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 670 |
+
|
| 671 |
+
# 🔒 P0数据修复(时序):先只读校验 → SQL转账扣款 → 提交成功后再写打赏榜
|
| 672 |
+
# 避���余额不足/重复请求时榜单已被更新的数据不一致
|
| 673 |
+
prompts_db_ro = db.load_data("prompts.json", default_data=[])
|
| 674 |
+
target_prompt = next((p for p in prompts_db_ro if p["id"] == prompt_id), None)
|
| 675 |
+
if not target_prompt:
|
| 676 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 677 |
+
if target_prompt.get("author") == current_user:
|
| 678 |
+
raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
|
| 679 |
+
author = target_prompt.get("author")
|
| 680 |
+
if not author:
|
| 681 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 682 |
+
prompt_title = target_prompt.get("title")
|
| 683 |
+
|
| 684 |
+
# 💳 使用 SQL Wallet 系统处理余额转账(先扣款)
|
| 685 |
+
try:
|
| 686 |
+
|
| 687 |
+
# 🔒 并发安全:使用悲观锁获取双方钱包
|
| 688 |
+
tipper_wallet, author_wallet = lock_tip_wallets(db_session, current_user, author)
|
| 689 |
+
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 690 |
+
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
| 691 |
+
duplicate_tx = db_session.query(Transaction).filter(
|
| 692 |
+
Transaction.account == current_user,
|
| 693 |
+
Transaction.tx_type == "TIP_OUT",
|
| 694 |
+
Transaction.amount == -amount,
|
| 695 |
+
Transaction.related_account == author,
|
| 696 |
+
Transaction.item_id == prompt_id,
|
| 697 |
+
Transaction.item_type == "prompt",
|
| 698 |
+
Transaction.created_at >= recent_cutoff
|
| 699 |
+
).with_for_update().first()
|
| 700 |
+
if duplicate_tx:
|
| 701 |
+
db_session.rollback() # 重复请求释放锁,不执行任何后续操作
|
| 702 |
+
return {"status": "success", "message": "打赏已处理(重复请求)"}
|
| 703 |
+
|
| 704 |
+
if not tipper_wallet or tipper_wallet.balance < amount:
|
| 705 |
+
raise HTTPException(status_code=400, detail="余额不足")
|
| 706 |
+
|
| 707 |
+
# 执行转账
|
| 708 |
+
tipper_wallet.balance -= amount
|
| 709 |
+
author_wallet.balance += amount # 实际收入进统一余额
|
| 710 |
+
author_wallet.tip_balance += amount # 累计打赏收益统计(只增不减)
|
| 711 |
+
|
| 712 |
+
# 创建交易记录
|
| 713 |
+
tx_id_tipper = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 714 |
+
tx_id_author = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 715 |
+
|
| 716 |
+
# 获取最后交易记录的哈希
|
| 717 |
+
last_tx_tipper = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first()
|
| 718 |
+
last_tx_author = db_session.query(Transaction).filter(Transaction.account == author).order_by(Transaction.created_at.desc()).first()
|
| 719 |
+
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 720 |
+
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 721 |
+
|
| 722 |
+
# 获取用户信息(prompt_title 已在预校验阶段取得)
|
| 723 |
+
users_db = db.load_data("users.json", default_data={})
|
| 724 |
+
author_info = users_db.get(author, {})
|
| 725 |
+
tipper_info = users_db.get(current_user, {})
|
| 726 |
+
author_name = author_info.get("name", author)
|
| 727 |
+
tipper_name = tipper_info.get("name", current_user)
|
| 728 |
+
|
| 729 |
+
# 打赏方交易记录 (TIP_OUT)
|
| 730 |
+
tx_tipper = Transaction(
|
| 731 |
+
tx_id=tx_id_tipper,
|
| 732 |
+
account=current_user,
|
| 733 |
+
tx_type="TIP_OUT",
|
| 734 |
+
amount=-amount,
|
| 735 |
+
related_account=author,
|
| 736 |
+
item_id=prompt_id,
|
| 737 |
+
prev_hash=prev_hash_tipper,
|
| 738 |
+
tx_hash=calculate_tx_hash(tx_id_tipper, current_user, "TIP_OUT", -amount, prev_hash_tipper),
|
| 739 |
+
description=f"打赏给 {author_name}" + (f" 的提示词《{prompt_title}》" if prompt_title else ""),
|
| 740 |
+
item_title=prompt_title,
|
| 741 |
+
item_type="prompt",
|
| 742 |
+
related_user_name=author_name
|
| 743 |
+
)
|
| 744 |
+
|
| 745 |
+
# 接收方交易记录 (TIP_IN)
|
| 746 |
+
tx_author = Transaction(
|
| 747 |
+
tx_id=tx_id_author,
|
| 748 |
+
account=author,
|
| 749 |
+
tx_type="TIP_IN",
|
| 750 |
+
amount=amount,
|
| 751 |
+
related_account=current_user,
|
| 752 |
+
item_id=prompt_id,
|
| 753 |
+
prev_hash=prev_hash_author,
|
| 754 |
+
tx_hash=calculate_tx_hash(tx_id_author, author, "TIP_IN", amount, prev_hash_author),
|
| 755 |
+
description=f"收到 {tipper_name} 的提示词打赏" + (f" ({prompt_title})" if prompt_title else ""),
|
| 756 |
+
item_title=prompt_title,
|
| 757 |
+
item_type="prompt",
|
| 758 |
+
related_user_name=tipper_name if not is_anon else "匿名用户"
|
| 759 |
+
)
|
| 760 |
+
|
| 761 |
+
db_session.add(tx_tipper)
|
| 762 |
+
db_session.add(tx_author)
|
| 763 |
+
db_session.commit()
|
| 764 |
+
|
| 765 |
+
# 📝 审计日志
|
| 766 |
+
logger.info("PROMPT_TIP")
|
| 767 |
+
|
| 768 |
+
except HTTPException:
|
| 769 |
+
db_session.rollback()
|
| 770 |
+
raise
|
| 771 |
+
except Exception as e:
|
| 772 |
+
db_session.rollback()
|
| 773 |
+
logger.error("PROMPT_TIP_ERROR | error_type=%s", type(e).__name__)
|
| 774 |
+
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 775 |
+
|
| 776 |
+
def notify_tip():
|
| 777 |
+
# 🔔 打赏通知(考虑匿名)
|
| 778 |
+
if not is_anon:
|
| 779 |
+
add_notification(author, {
|
| 780 |
+
"type": "tip",
|
| 781 |
+
"from_user": current_user,
|
| 782 |
+
"target_item_id": prompt_id,
|
| 783 |
+
"target_item_title": prompt_title or "",
|
| 784 |
+
"content": f"您收到来自 {tipper_name} 的 {amount} 积分提示词打赏"
|
| 785 |
+
})
|
| 786 |
+
else:
|
| 787 |
+
add_notification(author, {
|
| 788 |
+
"type": "tip",
|
| 789 |
+
"from_user": "anonymous",
|
| 790 |
+
"target_item_id": prompt_id,
|
| 791 |
+
"target_item_title": "",
|
| 792 |
+
"content": f"您收到了一份 {amount} 积分的匿名提示词打赏"
|
| 793 |
+
})
|
| 794 |
+
|
| 795 |
+
run_tip_effect(notify_tip)
|
| 796 |
+
run_tip_effect(lambda: sort_cache.invalidate("prompts:"))
|
| 797 |
+
|
| 798 |
+
# 🏆 扣款成功后再写打赏榜(写榜失败仅记录日志,不影响资金安全)
|
| 799 |
+
def board_updater(data):
|
| 800 |
+
for prompt in data:
|
| 801 |
+
if prompt["id"] == prompt_id:
|
| 802 |
+
tip_board = prompt.get("tip_board", [])
|
| 803 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 804 |
+
if existing:
|
| 805 |
+
existing["amount"] += amount
|
| 806 |
+
else:
|
| 807 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 808 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 809 |
+
prompt["tip_board"] = tip_board
|
| 810 |
+
break
|
| 811 |
+
|
| 812 |
+
try:
|
| 813 |
+
db.atomic_update("prompts.json", board_updater, default_data=[])
|
| 814 |
+
except Exception as e:
|
| 815 |
+
logger.error("PROMPT_TIP_BOARD_ERROR | error_type=%s", type(e).__name__)
|
| 816 |
+
|
| 817 |
+
return {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 818 |
+
|
| 819 |
+
|
| 820 |
+
# ==========================================
|
| 821 |
+
# 🛒 购买解锁接口
|
| 822 |
+
# ==========================================
|
| 823 |
+
|
| 824 |
+
@router.post("/api/prompts/{prompt_id}/purchase")
|
| 825 |
+
@shared_limit(10) # 🔒 P0安全优化:购买每分钟最多10次
|
| 826 |
+
async def purchase_prompt(request: Request, prompt_id: str,
|
| 827 |
+
purchase: Optional[PromptPurchaseRequest] = None,
|
| 828 |
+
current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
|
| 829 |
+
"""资金与所有权原子提交;通知失败不改变已提交的购买结果。"""
|
| 830 |
+
try:
|
| 831 |
+
prompts_db = db.load_data("prompts.json", default_data=[], strict=True, skip_cache=True)
|
| 832 |
+
if not isinstance(prompts_db, list):
|
| 833 |
+
raise ValueError("invalid prompt collection")
|
| 834 |
+
prompt = next((p for p in prompts_db if isinstance(p, dict) and p.get("id") == prompt_id), None)
|
| 835 |
+
if prompt is None:
|
| 836 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 837 |
+
price = effective_resource_price(prompt)
|
| 838 |
+
seller_account = prompt.get("author")
|
| 839 |
+
prompt_text = prompt.get("prompt_text")
|
| 840 |
+
if not isinstance(seller_account, str) or not seller_account.strip() or not isinstance(prompt_text, str):
|
| 841 |
+
raise ValueError("invalid prompt delivery")
|
| 842 |
+
response = {"status": "success", "already_owned": True, "prompt_text": prompt_text}
|
| 843 |
+
except HTTPException:
|
| 844 |
+
raise
|
| 845 |
+
except Exception as error:
|
| 846 |
+
logger.error("PROMPT_PURCHASE_SOURCE_ERROR | error_type=%s", type(error).__name__)
|
| 847 |
+
raise HTTPException(status_code=503, detail="提示词暂时不可购买")
|
| 848 |
+
|
| 849 |
+
# 非资金展示资料在写锁外读取;失败不影响正文交付或交易。
|
| 850 |
+
try:
|
| 851 |
+
users_db = db.load_data("users.json", default_data={})
|
| 852 |
+
seller_info = users_db.get(seller_account, {})
|
| 853 |
+
buyer_info = users_db.get(current_user, {})
|
| 854 |
+
seller_user_name = seller_info.get("name", seller_account)
|
| 855 |
+
buyer_user_name = buyer_info.get("name", current_user)
|
| 856 |
+
except Exception:
|
| 857 |
+
seller_user_name, buyer_user_name = seller_account, current_user
|
| 858 |
+
|
| 859 |
+
free = price == 0 or current_user == seller_account
|
| 860 |
+
try:
|
| 861 |
+
# 必须是本次事务的第一项 SQL;SQLite 使用 BEGIN IMMEDIATE。
|
| 862 |
+
buyer_wallet, seller_wallet = lock_resource_purchase_wallets(
|
| 863 |
+
db_session, current_user, seller_account, free)
|
| 864 |
+
owned = db_session.query(Ownership).filter(
|
| 865 |
+
Ownership.account == current_user,
|
| 866 |
+
Ownership.item_id == prompt_id,
|
| 867 |
+
Ownership.is_refunded == False
|
| 868 |
+
).first()
|
| 869 |
+
if owned:
|
| 870 |
+
db_session.rollback()
|
| 871 |
+
return response
|
| 872 |
+
if free:
|
| 873 |
+
db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=0))
|
| 874 |
+
db_session.commit()
|
| 875 |
+
return response
|
| 876 |
+
expected_price = getattr(purchase, "expected_price", None)
|
| 877 |
+
if expected_price is None:
|
| 878 |
+
raise HTTPException(status_code=428, detail="purchase_price_confirmation_required")
|
| 879 |
+
if isinstance(expected_price, bool) or not isinstance(expected_price, int) or expected_price < 0:
|
| 880 |
+
raise HTTPException(status_code=422, detail="purchase_price_invalid")
|
| 881 |
+
if expected_price != price:
|
| 882 |
+
raise HTTPException(status_code=409, detail="purchase_price_changed")
|
| 883 |
+
if buyer_wallet is None or buyer_wallet.balance < price:
|
| 884 |
+
raise HTTPException(status_code=402, detail="余额不足,请先充值")
|
| 885 |
+
for amount in (buyer_wallet.balance, seller_wallet.balance, seller_wallet.earn_balance):
|
| 886 |
+
if isinstance(amount, bool) or not isinstance(amount, int):
|
| 887 |
+
raise ValueError("invalid wallet amount")
|
| 888 |
+
buyer_wallet.balance -= price
|
| 889 |
+
seller_wallet.balance += price
|
| 890 |
+
seller_wallet.earn_balance += price
|
| 891 |
+
db_session.add(Ownership(account=current_user, item_id=prompt_id, price_paid=price))
|
| 892 |
+
|
| 893 |
+
for account, other, tx_type, amount, user_name, description in (
|
| 894 |
+
(current_user, seller_account, "PURCHASE", -price, seller_user_name,
|
| 895 |
+
f"购买提示词 {prompt.get('title', '未知提示词')}"),
|
| 896 |
+
(seller_account, current_user, "SALE", price, buyer_user_name,
|
| 897 |
+
f"销售收入: {prompt.get('title', prompt_id)}")
|
| 898 |
+
):
|
| 899 |
+
last_tx = db_session.query(Transaction).filter(Transaction.account == account).order_by(
|
| 900 |
+
Transaction.created_at.desc(), Transaction.tx_id.desc()).with_for_update().first()
|
| 901 |
+
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 902 |
+
tx_id = f"{tx_type}_{uuid.uuid4().hex}"
|
| 903 |
+
db_session.add(Transaction(
|
| 904 |
+
tx_id=tx_id, account=account, tx_type=tx_type, amount=amount,
|
| 905 |
+
related_account=other, item_id=prompt_id, prev_hash=prev_hash,
|
| 906 |
+
tx_hash=calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash),
|
| 907 |
+
description=description, item_title=prompt.get("title", ""),
|
| 908 |
+
item_type="prompt", related_user_name=user_name))
|
| 909 |
+
response["already_owned"] = False
|
| 910 |
+
db_session.commit()
|
| 911 |
+
except HTTPException:
|
| 912 |
+
db_session.rollback()
|
| 913 |
+
raise
|
| 914 |
+
except Exception as error:
|
| 915 |
+
db_session.rollback()
|
| 916 |
+
logger.error("PROMPT_PURCHASE_ERROR | error_type=%s", type(error).__name__)
|
| 917 |
+
raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
|
| 918 |
+
|
| 919 |
+
run_tip_effect(lambda: logger.info("PROMPT_PURCHASE_COMPLETED"))
|
| 920 |
+
run_tip_effect(lambda: add_notification(seller_account, {
|
| 921 |
+
"type": "purchase", "from_user": current_user,
|
| 922 |
+
"target_item_id": prompt_id, "target_item_title": prompt.get("title", ""),
|
| 923 |
+
"content": f"您的提示词《{prompt.get('title', '')}》已被购买"
|
| 924 |
+
}))
|
| 925 |
+
return response
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
# ==========================================
|
| 929 |
+
# 💬 评论接口(复用通用评论存储 comments.json)
|
| 930 |
+
# ==========================================
|
| 931 |
+
|
| 932 |
+
@router.get("/api/prompts/{prompt_id}/comments")
|
| 933 |
+
async def get_prompt_comments(prompt_id: str):
|
| 934 |
+
"""获取评论;有界后台读取并保留原作者附加字段。"""
|
| 935 |
+
return {"status": "success", "data": await read_comment_view(prompt_id)}
|
| 936 |
+
|
| 937 |
+
@router.post("/api/prompts/{prompt_id}/comments")
|
| 938 |
+
@shared_limit(10)
|
| 939 |
+
@_prompts_endpoint
|
| 940 |
+
def add_prompt_comment(request: Request, prompt_id: str, content: str, current_user: str = Depends(require_auth)):
|
| 941 |
+
"""
|
| 942 |
+
添加提示词评论
|
| 943 |
+
"""
|
| 944 |
+
if not content or not content.strip():
|
| 945 |
+
raise HTTPException(status_code=400, detail="评论内容不能为空")
|
| 946 |
+
if len(content) > COMMENT_MAX_LENGTH:
|
| 947 |
+
raise HTTPException(status_code=400, detail="评论长度必须在1-5000字符之间")
|
| 948 |
+
|
| 949 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 950 |
+
|
| 951 |
+
# 检查提示词是否存在
|
| 952 |
+
prompt_exists = any(p["id"] == prompt_id for p in prompts_db)
|
| 953 |
+
if not prompt_exists:
|
| 954 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 955 |
+
|
| 956 |
+
new_comment = {
|
| 957 |
+
"id": f"comment_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 958 |
+
"author": current_user,
|
| 959 |
+
"content": content.strip(),
|
| 960 |
+
"created_at": int(time.time())
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
append_comment(prompt_id, new_comment, prepend=True)
|
| 964 |
+
increment_comment_count("prompts.json", prompt_id)
|
| 965 |
+
|
| 966 |
+
# 🗂️ 清除排序缓存(评论数变化可能影响排序)
|
| 967 |
+
sort_cache.invalidate("prompts:")
|
| 968 |
+
|
| 969 |
+
return {"status": "success", "data": new_comment}
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
@router.post("/api/prompts/{prompt_id}/view")
|
| 973 |
+
@shared_limit(60)
|
| 974 |
+
@_prompts_endpoint
|
| 975 |
+
def record_prompt_view(request: Request, prompt_id: str, current_user: str = Depends(require_auth)):
|
| 976 |
+
"""
|
| 977 |
+
记录提示词访问量
|
| 978 |
+
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
| 979 |
+
"""
|
| 980 |
+
result = record_view("prompts.json", prompt_id, current_user)
|
| 981 |
+
|
| 982 |
+
if result is None:
|
| 983 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 984 |
+
|
| 985 |
+
# 🗂️ 清除排序缓存(浏览量变化可能影响排序)
|
| 986 |
+
sort_cache.invalidate("prompts:")
|
| 987 |
+
|
| 988 |
+
return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
|