Spaces:
Running
Running
新增提示词界面
Browse files- app.py +2 -0
- models.py +33 -1
- router_prompts.py +952 -0
- router_wallet.py +8 -1
- 数据库连接.py +2 -0
app.py
CHANGED
|
@@ -53,6 +53,7 @@ from router_messages import router as messages_router # ✉️ 私信系统
|
|
| 53 |
from router_wallet import router as wallet_router # 💰 钱包/提现
|
| 54 |
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 55 |
from router_posts import router as posts_router # 💬 讨论区
|
|
|
|
| 56 |
from router_tasks import router as tasks_router # 📝 任务榜
|
| 57 |
from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
|
| 58 |
from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
|
|
@@ -284,6 +285,7 @@ app.include_router(messages_router) # ✉️ 私信系统
|
|
| 284 |
app.include_router(wallet_router) # 💰 钱包/提现
|
| 285 |
app.include_router(proxy_router) # 🔗 代理下载
|
| 286 |
app.include_router(posts_router) # 💬 讨论区
|
|
|
|
| 287 |
app.include_router(tasks_router) # 📝 任务榜
|
| 288 |
app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
|
| 289 |
app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
|
|
|
|
| 53 |
from router_wallet import router as wallet_router # 💰 钱包/提现
|
| 54 |
from router_proxy import router as proxy_router # 🔗 代理下载
|
| 55 |
from router_posts import router as posts_router # 💬 讨论区
|
| 56 |
+
from router_prompts import router as prompts_router # ✨ 提示词市场
|
| 57 |
from router_tasks import router as tasks_router # 📝 任务榜
|
| 58 |
from router_open_api import router as open_api_router # 🌐 通用开放API(第三方插件)
|
| 59 |
from router_admin_plugins import router as admin_plugins_router # 🔒 管理后台 - 第三方插件管理API
|
|
|
|
| 285 |
app.include_router(wallet_router) # 💰 钱包/提现
|
| 286 |
app.include_router(proxy_router) # 🔗 代理下载
|
| 287 |
app.include_router(posts_router) # 💬 讨论区
|
| 288 |
+
app.include_router(prompts_router) # ✨ 提示词市场
|
| 289 |
app.include_router(tasks_router) # 📝 任务榜
|
| 290 |
app.include_router(open_api_router) # 🌐 通用开放API(供第三方插件调用)
|
| 291 |
app.include_router(admin_plugins_router) # 🔒 管理后台 - 第三方插件管理API
|
models.py
CHANGED
|
@@ -261,4 +261,36 @@ class PostUpdate(BaseModel):
|
|
| 261 |
images: Optional[List[str]] = None
|
| 262 |
is_original: Optional[bool] = None # 🎨 是否为原创作品
|
| 263 |
post_type: Optional[str] = None # "image" 或 "video"
|
| 264 |
-
video_url: Optional[str] = None # 视频 URL(仅 video 类型)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
images: Optional[List[str]] = None
|
| 262 |
is_original: Optional[bool] = None # 🎨 是否为原创作品
|
| 263 |
post_type: Optional[str] = None # "image" 或 "video"
|
| 264 |
+
video_url: Optional[str] = None # 视频 URL(仅 video 类型)
|
| 265 |
+
|
| 266 |
+
# ==========================================
|
| 267 |
+
# ✨ 提示词市场数据模型(图像/视频提示词)
|
| 268 |
+
# ==========================================
|
| 269 |
+
|
| 270 |
+
class PromptCreate(BaseModel):
|
| 271 |
+
""" 发布提示词 """
|
| 272 |
+
prompt_type: str # "image" 或 "video"(所属子界面)
|
| 273 |
+
category: str # 所属分类(人物/动作等)
|
| 274 |
+
title: str # 标题
|
| 275 |
+
content: str # 公开介绍文案
|
| 276 |
+
prompt_text: str # 提示词正文(付费内容,服务端脱敏下发)
|
| 277 |
+
cover_image: str # 封面图
|
| 278 |
+
media_type: str = "image" # "image" 或 "video"(示例媒体类型)
|
| 279 |
+
video_url: Optional[str] = None # 示例视频 URL(仅 media_type=video)
|
| 280 |
+
price: int = 0 # 定价(0=免费)
|
| 281 |
+
|
| 282 |
+
class PromptUpdate(BaseModel):
|
| 283 |
+
""" 更新提示词 """
|
| 284 |
+
category: Optional[str] = None
|
| 285 |
+
title: Optional[str] = None
|
| 286 |
+
content: Optional[str] = None
|
| 287 |
+
prompt_text: Optional[str] = None
|
| 288 |
+
cover_image: Optional[str] = None
|
| 289 |
+
media_type: Optional[str] = None
|
| 290 |
+
video_url: Optional[str] = None
|
| 291 |
+
price: Optional[int] = None
|
| 292 |
+
|
| 293 |
+
class PromptCategoryAdd(BaseModel):
|
| 294 |
+
""" 添加提示词分类 """
|
| 295 |
+
prompt_type: str # "image" 或 "video"
|
| 296 |
+
name: str # 分类名称(≤8字,去重)
|
router_prompts.py
ADDED
|
@@ -0,0 +1,952 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 云端Space代码/router_prompts.py
|
| 2 |
+
# ==========================================
|
| 3 |
+
# ✨ 提示词市场API路由(图像/视频提示词)
|
| 4 |
+
# ==========================================
|
| 5 |
+
# 功能:提示词发布、列表(分类筛选)、详情(付费内容脱敏)、
|
| 6 |
+
# 互动(点赞/收藏/评论/打赏)、购买解锁、分类管理
|
| 7 |
+
# 数据持久化:prompts.json / prompt_categories.json
|
| 8 |
+
# 与 posts.json 相同,经 数据库连接.py 自动同步至 HF Dataset
|
| 9 |
+
# 关联文件:
|
| 10 |
+
# - router_posts.py (实现模式参照)
|
| 11 |
+
# - router_wallet.py (钱包/交易逻辑参照)
|
| 12 |
+
# - models_sql.py (Wallet/Transaction/Ownership)
|
| 13 |
+
# ==========================================
|
| 14 |
+
|
| 15 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 16 |
+
from sqlalchemy.orm import Session
|
| 17 |
+
from models import PromptCreate, PromptUpdate, PromptCategoryAdd
|
| 18 |
+
import 数据库连接 as db
|
| 19 |
+
from 安全认证 import require_auth, optional_auth
|
| 20 |
+
from db_utils import record_view, sort_cache
|
| 21 |
+
from database_sql import get_db
|
| 22 |
+
from models_sql import Wallet, Transaction, Ownership
|
| 23 |
+
from notifications import add_notification
|
| 24 |
+
import time
|
| 25 |
+
import uuid
|
| 26 |
+
import hashlib
|
| 27 |
+
import datetime
|
| 28 |
+
import logging
|
| 29 |
+
|
| 30 |
+
# 🔒 P0安全优化:API限流(与 router_wallet.py 保持一致)
|
| 31 |
+
from slowapi import Limiter
|
| 32 |
+
from slowapi.util import get_remote_address
|
| 33 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 34 |
+
|
| 35 |
+
router = APIRouter()
|
| 36 |
+
|
| 37 |
+
# 📝 审计日志
|
| 38 |
+
logger = logging.getLogger("ComfyUI-Ranking.Prompts")
|
| 39 |
+
|
| 40 |
+
# 内置默认分类(首次读取 prompt_categories.json 不存在时自动初始化)
|
| 41 |
+
DEFAULT_CATEGORIES = {
|
| 42 |
+
"image": ["人物", "动物", "植物", "道具", "建筑"],
|
| 43 |
+
"video": ["动作", "表情变化", "天气变化", "光效变化", "运镜", "视觉风格"]
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
VALID_PROMPT_TYPES = ("image", "video")
|
| 47 |
+
|
| 48 |
+
# 内容长度上限
|
| 49 |
+
MAX_TITLE_LEN = 50
|
| 50 |
+
MAX_CONTENT_LEN = 2000
|
| 51 |
+
MAX_PROMPT_TEXT_LEN = 20000
|
| 52 |
+
MAX_PRICE = 10000
|
| 53 |
+
MAX_CATEGORY_LEN = 8
|
| 54 |
+
MAX_CATEGORY_PER_TYPE = 30
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def calculate_tx_hash(tx_id, account, tx_type, amount, prev_hash):
|
| 58 |
+
data = f"{tx_id}{account}{tx_type}{amount}{prev_hash}"
|
| 59 |
+
return hashlib.sha256(data.encode()).hexdigest()
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _load_categories() -> dict:
|
| 63 |
+
"""读取分类数据,不存在时用内置默认值初始化并持久化"""
|
| 64 |
+
categories = db.load_data("prompt_categories.json", default_data=None)
|
| 65 |
+
if not isinstance(categories, dict) or "image" not in categories or "video" not in categories:
|
| 66 |
+
categories = {
|
| 67 |
+
"image": list(DEFAULT_CATEGORIES["image"]),
|
| 68 |
+
"video": list(DEFAULT_CATEGORIES["video"])
|
| 69 |
+
}
|
| 70 |
+
db.save_data("prompt_categories.json", categories)
|
| 71 |
+
return categories
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _user_owns_prompt(db_session: Session, account: str, prompt_id: str) -> bool:
|
| 75 |
+
"""检查用户是否已购买(未退款)该提示词"""
|
| 76 |
+
if not account:
|
| 77 |
+
return False
|
| 78 |
+
ownership = db_session.query(Ownership).filter(
|
| 79 |
+
Ownership.account == account,
|
| 80 |
+
Ownership.item_id == prompt_id,
|
| 81 |
+
Ownership.is_refunded == False
|
| 82 |
+
).first()
|
| 83 |
+
return ownership is not None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _mask_prompt_text(prompt_data: dict, can_view: bool) -> dict:
|
| 87 |
+
"""
|
| 88 |
+
🔒 内容脱敏核心逻辑:
|
| 89 |
+
未授权用户一律不下发 prompt_text(服务端脱敏,不依赖前端隐藏)
|
| 90 |
+
"""
|
| 91 |
+
if can_view:
|
| 92 |
+
prompt_data["locked"] = False
|
| 93 |
+
else:
|
| 94 |
+
prompt_data["prompt_text"] = None
|
| 95 |
+
prompt_data["locked"] = True
|
| 96 |
+
return prompt_data
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ==========================================
|
| 100 |
+
# 📂 分类管理接口
|
| 101 |
+
# ==========================================
|
| 102 |
+
|
| 103 |
+
@router.get("/api/prompt-categories")
|
| 104 |
+
async def get_prompt_categories(prompt_type: str = None):
|
| 105 |
+
"""
|
| 106 |
+
获取提示词分类列表
|
| 107 |
+
- prompt_type: image / video,不传则返回全部
|
| 108 |
+
"""
|
| 109 |
+
categories = _load_categories()
|
| 110 |
+
if prompt_type in VALID_PROMPT_TYPES:
|
| 111 |
+
return {"status": "success", "data": categories.get(prompt_type, [])}
|
| 112 |
+
return {"status": "success", "data": categories}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.post("/api/prompt-categories")
|
| 116 |
+
@limiter.limit("5/minute") # 🔒 限流:防止分类刷屏
|
| 117 |
+
async def add_prompt_category(request: Request, body: PromptCategoryAdd, current_user: str = Depends(require_auth)):
|
| 118 |
+
"""
|
| 119 |
+
添加提示词分类(登录用户均可,重名去重)
|
| 120 |
+
"""
|
| 121 |
+
if body.prompt_type not in VALID_PROMPT_TYPES:
|
| 122 |
+
raise HTTPException(status_code=400, detail="分类所属类型必须是 image 或 video")
|
| 123 |
+
|
| 124 |
+
name = (body.name or "").strip()
|
| 125 |
+
if not name:
|
| 126 |
+
raise HTTPException(status_code=400, detail="分类名称不能为空")
|
| 127 |
+
if len(name) > MAX_CATEGORY_LEN:
|
| 128 |
+
raise HTTPException(status_code=400, detail=f"分类名称不能超过{MAX_CATEGORY_LEN}个字")
|
| 129 |
+
|
| 130 |
+
result_holder = {}
|
| 131 |
+
|
| 132 |
+
def updater(data):
|
| 133 |
+
if not isinstance(data, dict):
|
| 134 |
+
data = {
|
| 135 |
+
"image": list(DEFAULT_CATEGORIES["image"]),
|
| 136 |
+
"video": list(DEFAULT_CATEGORIES["video"])
|
| 137 |
+
}
|
| 138 |
+
cat_list = data.get(body.prompt_type, [])
|
| 139 |
+
if name in cat_list:
|
| 140 |
+
result_holder["error"] = "duplicate"
|
| 141 |
+
return
|
| 142 |
+
if len(cat_list) >= MAX_CATEGORY_PER_TYPE:
|
| 143 |
+
result_holder["error"] = "too_many"
|
| 144 |
+
return
|
| 145 |
+
cat_list.append(name)
|
| 146 |
+
data[body.prompt_type] = cat_list
|
| 147 |
+
result_holder["data"] = cat_list
|
| 148 |
+
|
| 149 |
+
db.atomic_update("prompt_categories.json", updater, default_data={})
|
| 150 |
+
|
| 151 |
+
if result_holder.get("error") == "duplicate":
|
| 152 |
+
raise HTTPException(status_code=400, detail="该分类已存在")
|
| 153 |
+
if result_holder.get("error") == "too_many":
|
| 154 |
+
raise HTTPException(status_code=400, detail=f"分类数量已达上限({MAX_CATEGORY_PER_TYPE})")
|
| 155 |
+
|
| 156 |
+
logger.info(f"PROMPT_CATEGORY_ADD | user={current_user} | type={body.prompt_type} | name={name}")
|
| 157 |
+
return {"status": "success", "data": result_holder["data"]}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ==========================================
|
| 161 |
+
# 📝 提示词CRUD接口
|
| 162 |
+
# ==========================================
|
| 163 |
+
|
| 164 |
+
@router.get("/api/prompts")
|
| 165 |
+
async def get_prompts(prompt_type: str = None, category: str = None,
|
| 166 |
+
page: int = 1, limit: int = 20, sort: str = "latest"):
|
| 167 |
+
"""
|
| 168 |
+
获取提示词列表(分页,支持类型/分类筛选与多种排序)
|
| 169 |
+
- prompt_type: image / video(子界面筛选)
|
| 170 |
+
- category: 分类筛选
|
| 171 |
+
- sort: latest / likes / favorites / views / daily_views / tips
|
| 172 |
+
⚠️ 列表接口永不下发 prompt_text(付费内容脱敏)
|
| 173 |
+
"""
|
| 174 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 175 |
+
users_db = db.load_data("users.json", default_data={})
|
| 176 |
+
user_map = users_db
|
| 177 |
+
|
| 178 |
+
# 🗂️ 使用排序缓存优化排序性能
|
| 179 |
+
cache_key = f"prompts:{sort}"
|
| 180 |
+
|
| 181 |
+
def sort_fn(data):
|
| 182 |
+
if sort == "likes":
|
| 183 |
+
data.sort(key=lambda x: x.get("likes", 0), reverse=True)
|
| 184 |
+
elif sort == "favorites":
|
| 185 |
+
data.sort(key=lambda x: x.get("favorites", 0), reverse=True)
|
| 186 |
+
elif sort == "views":
|
| 187 |
+
data.sort(key=lambda x: x.get("views", 0), reverse=True)
|
| 188 |
+
elif sort == "daily_views":
|
| 189 |
+
data.sort(key=lambda x: x.get("daily_views", 0), reverse=True)
|
| 190 |
+
elif sort == "tips":
|
| 191 |
+
data.sort(key=lambda x: sum(t.get("amount", 0) for t in x.get("tip_board", [])), reverse=True)
|
| 192 |
+
else: # latest 或其他默认
|
| 193 |
+
data.sort(key=lambda x: x.get("created_at", 0), reverse=True)
|
| 194 |
+
|
| 195 |
+
sorted_prompts = sort_cache.get_sorted(cache_key, prompts_db, sort_fn)
|
| 196 |
+
|
| 197 |
+
# 类型与分类过滤
|
| 198 |
+
if prompt_type in VALID_PROMPT_TYPES:
|
| 199 |
+
sorted_prompts = [p for p in sorted_prompts if p.get("prompt_type") == prompt_type]
|
| 200 |
+
if category:
|
| 201 |
+
sorted_prompts = [p for p in sorted_prompts if p.get("category") == category]
|
| 202 |
+
|
| 203 |
+
# 分页
|
| 204 |
+
total = len(sorted_prompts)
|
| 205 |
+
start = (page - 1) * limit
|
| 206 |
+
end = start + limit
|
| 207 |
+
paged_prompts = sorted_prompts[start:end]
|
| 208 |
+
|
| 209 |
+
# 附加作者信息,并过滤敏感字段
|
| 210 |
+
result = []
|
| 211 |
+
for prompt in paged_prompts:
|
| 212 |
+
author_info = user_map.get(prompt.get("author"), {})
|
| 213 |
+
prompt_data = {
|
| 214 |
+
**prompt,
|
| 215 |
+
"author_name": author_info.get("name", prompt.get("author")),
|
| 216 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 217 |
+
}
|
| 218 |
+
# 🔒 脱敏:列表永不下发提示词正文与浏览记录
|
| 219 |
+
prompt_data.pop("prompt_text", None)
|
| 220 |
+
prompt_data.pop("viewed_by", None)
|
| 221 |
+
result.append(prompt_data)
|
| 222 |
+
|
| 223 |
+
return {
|
| 224 |
+
"status": "success",
|
| 225 |
+
"data": result,
|
| 226 |
+
"total": total,
|
| 227 |
+
"page": page,
|
| 228 |
+
"limit": limit
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@router.get("/api/my-prompts")
|
| 233 |
+
async def get_my_prompts(current_user: str = Depends(require_auth)):
|
| 234 |
+
"""
|
| 235 |
+
获取我发布的提示词(个人中心用)
|
| 236 |
+
作者本人可见自己的 prompt_text
|
| 237 |
+
"""
|
| 238 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 239 |
+
|
| 240 |
+
my_prompts = [p for p in prompts_db if p.get("author") == current_user]
|
| 241 |
+
my_prompts = sorted(my_prompts, key=lambda x: x.get("created_at", 0), reverse=True)
|
| 242 |
+
|
| 243 |
+
result = []
|
| 244 |
+
for prompt in my_prompts:
|
| 245 |
+
prompt_data = dict(prompt)
|
| 246 |
+
prompt_data.pop("viewed_by", None)
|
| 247 |
+
result.append(prompt_data)
|
| 248 |
+
|
| 249 |
+
return {"status": "success", "data": result}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@router.get("/api/prompts/{prompt_id}")
|
| 253 |
+
async def get_prompt_detail(prompt_id: str, current_user: str = Depends(optional_auth),
|
| 254 |
+
db_session: Session = Depends(get_db)):
|
| 255 |
+
"""
|
| 256 |
+
获取提示词详情
|
| 257 |
+
🔒 仅当 ①免费 ②作者本人 ③已购买 时下发 prompt_text
|
| 258 |
+
"""
|
| 259 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 260 |
+
users_db = db.load_data("users.json", default_data={})
|
| 261 |
+
user_map = users_db
|
| 262 |
+
|
| 263 |
+
for prompt in prompts_db:
|
| 264 |
+
if prompt["id"] == prompt_id:
|
| 265 |
+
author_info = user_map.get(prompt.get("author"), {})
|
| 266 |
+
prompt_data = {
|
| 267 |
+
**prompt,
|
| 268 |
+
"author_name": author_info.get("name", prompt.get("author")),
|
| 269 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 270 |
+
}
|
| 271 |
+
prompt_data.pop("viewed_by", None)
|
| 272 |
+
|
| 273 |
+
price = int(prompt.get("price", 0) or 0)
|
| 274 |
+
is_author = current_user and current_user == prompt.get("author")
|
| 275 |
+
can_view = price <= 0 or is_author or _user_owns_prompt(db_session, current_user, prompt_id)
|
| 276 |
+
prompt_data["owned"] = bool(can_view)
|
| 277 |
+
_mask_prompt_text(prompt_data, can_view)
|
| 278 |
+
|
| 279 |
+
return {"status": "success", "data": prompt_data}
|
| 280 |
+
|
| 281 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@router.post("/api/prompts")
|
| 285 |
+
async def create_prompt(prompt: PromptCreate, current_user: str = Depends(require_auth)):
|
| 286 |
+
"""
|
| 287 |
+
发布提示词
|
| 288 |
+
"""
|
| 289 |
+
# 参数校验
|
| 290 |
+
if prompt.prompt_type not in VALID_PROMPT_TYPES:
|
| 291 |
+
raise HTTPException(status_code=400, detail="提示词类型必须是 image 或 video")
|
| 292 |
+
title = (prompt.title or "").strip()
|
| 293 |
+
content = (prompt.content or "").strip()
|
| 294 |
+
prompt_text = (prompt.prompt_text or "").strip()
|
| 295 |
+
category = (prompt.category or "").strip()
|
| 296 |
+
if not title or not content or not prompt_text:
|
| 297 |
+
raise HTTPException(status_code=400, detail="标题、介绍文案与提示词正文均不能为空")
|
| 298 |
+
if len(title) > MAX_TITLE_LEN:
|
| 299 |
+
raise HTTPException(status_code=400, detail=f"标题不能超过{MAX_TITLE_LEN}个字")
|
| 300 |
+
if len(content) > MAX_CONTENT_LEN:
|
| 301 |
+
raise HTTPException(status_code=400, detail=f"介绍文案不能超过{MAX_CONTENT_LEN}个字")
|
| 302 |
+
if len(prompt_text) > MAX_PROMPT_TEXT_LEN:
|
| 303 |
+
raise HTTPException(status_code=400, detail=f"提示词正文不能超过{MAX_PROMPT_TEXT_LEN}个字")
|
| 304 |
+
if not category:
|
| 305 |
+
raise HTTPException(status_code=400, detail="请选择分类")
|
| 306 |
+
|
| 307 |
+
# 分类必须存在于分类库
|
| 308 |
+
categories = _load_categories()
|
| 309 |
+
if category not in categories.get(prompt.prompt_type, []):
|
| 310 |
+
raise HTTPException(status_code=400, detail="所选分类不存在")
|
| 311 |
+
|
| 312 |
+
price = int(prompt.price or 0)
|
| 313 |
+
if price < 0 or price > MAX_PRICE:
|
| 314 |
+
raise HTTPException(status_code=400, detail=f"定价必须在0-{MAX_PRICE}之间")
|
| 315 |
+
|
| 316 |
+
media_type = prompt.media_type if prompt.media_type in ("image", "video") else "image"
|
| 317 |
+
video_url = prompt.video_url if media_type == "video" else None
|
| 318 |
+
if media_type == "video" and not video_url:
|
| 319 |
+
# 未上传示例视频时降级为图片展示
|
| 320 |
+
media_type = "image"
|
| 321 |
+
|
| 322 |
+
new_prompt = {
|
| 323 |
+
"id": f"prompt_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 324 |
+
"prompt_type": prompt.prompt_type,
|
| 325 |
+
"category": category,
|
| 326 |
+
"title": title,
|
| 327 |
+
"content": content,
|
| 328 |
+
"prompt_text": prompt_text,
|
| 329 |
+
"cover_image": prompt.cover_image,
|
| 330 |
+
"media_type": media_type,
|
| 331 |
+
"video_url": video_url,
|
| 332 |
+
"price": price,
|
| 333 |
+
"author": current_user,
|
| 334 |
+
"created_at": int(time.time()),
|
| 335 |
+
# 互动数据
|
| 336 |
+
"likes": 0,
|
| 337 |
+
"favorites": 0,
|
| 338 |
+
"comments": 0,
|
| 339 |
+
"liked_by": [],
|
| 340 |
+
"favorited_by": [],
|
| 341 |
+
"tip_board": [], # 打赏榜单
|
| 342 |
+
"views": 0,
|
| 343 |
+
"daily_views": 0,
|
| 344 |
+
"viewed_by": [],
|
| 345 |
+
"daily_views_date": ""
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 349 |
+
prompts_db.insert(0, new_prompt)
|
| 350 |
+
db.save_data("prompts.json", prompts_db)
|
| 351 |
+
# 🗂️ 清除排序缓存
|
| 352 |
+
sort_cache.invalidate("prompts:")
|
| 353 |
+
|
| 354 |
+
return {"status": "success", "data": new_prompt}
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
@router.put("/api/prompts/{prompt_id}")
|
| 358 |
+
async def update_prompt(prompt_id: str, update_data: PromptUpdate, current_user: str = Depends(require_auth)):
|
| 359 |
+
"""
|
| 360 |
+
更新提示词(仅作者可操作,原子操作,并发安全)
|
| 361 |
+
"""
|
| 362 |
+
result_holder = {}
|
| 363 |
+
|
| 364 |
+
def updater(data):
|
| 365 |
+
for prompt in data:
|
| 366 |
+
if prompt["id"] == prompt_id:
|
| 367 |
+
if prompt.get("author") != current_user:
|
| 368 |
+
result_holder["error"] = "forbidden"
|
| 369 |
+
return False
|
| 370 |
+
# 更新字段
|
| 371 |
+
if update_data.title is not None:
|
| 372 |
+
title = update_data.title.strip()
|
| 373 |
+
if not title or len(title) > MAX_TITLE_LEN:
|
| 374 |
+
result_holder["error"] = "invalid_title"
|
| 375 |
+
return False
|
| 376 |
+
prompt["title"] = title
|
| 377 |
+
if update_data.content is not None:
|
| 378 |
+
content = update_data.content.strip()
|
| 379 |
+
if len(content) > MAX_CONTENT_LEN:
|
| 380 |
+
result_holder["error"] = "invalid_content"
|
| 381 |
+
return False
|
| 382 |
+
prompt["content"] = content
|
| 383 |
+
if update_data.prompt_text is not None:
|
| 384 |
+
prompt_text = update_data.prompt_text.strip()
|
| 385 |
+
if not prompt_text or len(prompt_text) > MAX_PROMPT_TEXT_LEN:
|
| 386 |
+
result_holder["error"] = "invalid_prompt_text"
|
| 387 |
+
return False
|
| 388 |
+
prompt["prompt_text"] = prompt_text
|
| 389 |
+
if update_data.category is not None:
|
| 390 |
+
category = update_data.category.strip()
|
| 391 |
+
categories = _load_categories()
|
| 392 |
+
if category not in categories.get(prompt.get("prompt_type"), []):
|
| 393 |
+
result_holder["error"] = "invalid_category"
|
| 394 |
+
return False
|
| 395 |
+
prompt["category"] = category
|
| 396 |
+
if update_data.cover_image is not None:
|
| 397 |
+
prompt["cover_image"] = update_data.cover_image
|
| 398 |
+
if update_data.price is not None:
|
| 399 |
+
price = int(update_data.price)
|
| 400 |
+
if price < 0 or price > MAX_PRICE:
|
| 401 |
+
result_holder["error"] = "invalid_price"
|
| 402 |
+
return False
|
| 403 |
+
prompt["price"] = price
|
| 404 |
+
if update_data.media_type is not None:
|
| 405 |
+
if update_data.media_type not in ("image", "video"):
|
| 406 |
+
result_holder["error"] = "invalid_media_type"
|
| 407 |
+
return False
|
| 408 |
+
prompt["media_type"] = update_data.media_type
|
| 409 |
+
if update_data.media_type == "image":
|
| 410 |
+
prompt["video_url"] = None
|
| 411 |
+
if update_data.video_url is not None:
|
| 412 |
+
prompt["video_url"] = update_data.video_url
|
| 413 |
+
if update_data.video_url:
|
| 414 |
+
prompt["media_type"] = "video"
|
| 415 |
+
result_holder["prompt"] = prompt
|
| 416 |
+
return True
|
| 417 |
+
result_holder["error"] = "not_found"
|
| 418 |
+
return False
|
| 419 |
+
|
| 420 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 421 |
+
|
| 422 |
+
error_map = {
|
| 423 |
+
"forbidden": (403, "无权修改他人提示词"),
|
| 424 |
+
"not_found": (404, "提示词不存在"),
|
| 425 |
+
"invalid_title": (400, f"标题不能为空且不能超过{MAX_TITLE_LEN}个字"),
|
| 426 |
+
"invalid_content": (400, f"介绍文案不能超过{MAX_CONTENT_LEN}个字"),
|
| 427 |
+
"invalid_prompt_text": (400, "提示词正文不能为空且长度超限"),
|
| 428 |
+
"invalid_category": (400, "所选分类不存在"),
|
| 429 |
+
"invalid_price": (400, f"定价必须在0-{MAX_PRICE}之间"),
|
| 430 |
+
"invalid_media_type": (400, "媒体类型必须是 image 或 video")
|
| 431 |
+
}
|
| 432 |
+
error = result_holder.get("error")
|
| 433 |
+
if error:
|
| 434 |
+
code, detail = error_map.get(error, (400, "参数错误"))
|
| 435 |
+
raise HTTPException(status_code=code, detail=detail)
|
| 436 |
+
|
| 437 |
+
# 🗂️ 清除排序缓存
|
| 438 |
+
sort_cache.invalidate("prompts:")
|
| 439 |
+
return {"status": "success"}
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
@router.delete("/api/prompts/{prompt_id}")
|
| 443 |
+
async def delete_prompt(prompt_id: str, current_user: str = Depends(require_auth)):
|
| 444 |
+
"""
|
| 445 |
+
删除提示词(仅作者可操作)
|
| 446 |
+
"""
|
| 447 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 448 |
+
|
| 449 |
+
for i, prompt in enumerate(prompts_db):
|
| 450 |
+
if prompt["id"] == prompt_id:
|
| 451 |
+
if prompt.get("author") != current_user:
|
| 452 |
+
raise HTTPException(status_code=403, detail="无权删除他人提示词")
|
| 453 |
+
|
| 454 |
+
prompts_db.pop(i)
|
| 455 |
+
db.save_data("prompts.json", prompts_db)
|
| 456 |
+
# 🗂️ 清除排序缓存
|
| 457 |
+
sort_cache.invalidate("prompts:")
|
| 458 |
+
return {"status": "success"}
|
| 459 |
+
|
| 460 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
# ==========================================
|
| 464 |
+
# ❤️ 互动接口(点赞/收藏)
|
| 465 |
+
# ==========================================
|
| 466 |
+
|
| 467 |
+
@router.post("/api/prompts/{prompt_id}/like")
|
| 468 |
+
async def toggle_like(prompt_id: str, current_user: str = Depends(require_auth)):
|
| 469 |
+
"""
|
| 470 |
+
点赞/取消点赞(原子操作,并发安全)
|
| 471 |
+
"""
|
| 472 |
+
result_container = [None]
|
| 473 |
+
|
| 474 |
+
def updater(data):
|
| 475 |
+
for prompt in data:
|
| 476 |
+
if prompt["id"] == prompt_id:
|
| 477 |
+
liked_by = prompt.get("liked_by", [])
|
| 478 |
+
if current_user in liked_by:
|
| 479 |
+
liked_by.remove(current_user)
|
| 480 |
+
prompt["likes"] = max(0, prompt.get("likes", 0) - 1)
|
| 481 |
+
action = "unliked"
|
| 482 |
+
else:
|
| 483 |
+
liked_by.append(current_user)
|
| 484 |
+
prompt["likes"] = prompt.get("likes", 0) + 1
|
| 485 |
+
action = "liked"
|
| 486 |
+
prompt["liked_by"] = liked_by
|
| 487 |
+
result_container[0] = {"status": "success", "action": action, "likes": prompt["likes"]}
|
| 488 |
+
return
|
| 489 |
+
result_container[0] = None # 未找到提示词
|
| 490 |
+
|
| 491 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 492 |
+
|
| 493 |
+
if result_container[0] is None:
|
| 494 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 495 |
+
|
| 496 |
+
# 🗂️ 清除排序缓存(点赞数变化可能影响排序)
|
| 497 |
+
sort_cache.invalidate("prompts:")
|
| 498 |
+
|
| 499 |
+
return result_container[0]
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
@router.post("/api/prompts/{prompt_id}/favorite")
|
| 503 |
+
async def toggle_favorite(prompt_id: str, current_user: str = Depends(require_auth)):
|
| 504 |
+
"""
|
| 505 |
+
收藏/取消收藏(原子操作,并发安全)
|
| 506 |
+
"""
|
| 507 |
+
result_container = [None]
|
| 508 |
+
|
| 509 |
+
def updater(data):
|
| 510 |
+
for prompt in data:
|
| 511 |
+
if prompt["id"] == prompt_id:
|
| 512 |
+
favorited_by = prompt.get("favorited_by", [])
|
| 513 |
+
if current_user in favorited_by:
|
| 514 |
+
favorited_by.remove(current_user)
|
| 515 |
+
prompt["favorites"] = max(0, prompt.get("favorites", 0) - 1)
|
| 516 |
+
action = "unfavorited"
|
| 517 |
+
else:
|
| 518 |
+
favorited_by.append(current_user)
|
| 519 |
+
prompt["favorites"] = prompt.get("favorites", 0) + 1
|
| 520 |
+
action = "favorited"
|
| 521 |
+
prompt["favorited_by"] = favorited_by
|
| 522 |
+
result_container[0] = {"status": "success", "action": action, "favorites": prompt["favorites"]}
|
| 523 |
+
return
|
| 524 |
+
result_container[0] = None # 未找到提示词
|
| 525 |
+
|
| 526 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 527 |
+
|
| 528 |
+
if result_container[0] is None:
|
| 529 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 530 |
+
|
| 531 |
+
# 🗂️ 清除排序缓存(收藏数变化可能影响排序)
|
| 532 |
+
sort_cache.invalidate("prompts:")
|
| 533 |
+
|
| 534 |
+
return result_container[0]
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
# ==========================================
|
| 538 |
+
# 🎁 打赏接口
|
| 539 |
+
# ==========================================
|
| 540 |
+
|
| 541 |
+
@router.post("/api/prompts/{prompt_id}/tip")
|
| 542 |
+
async def tip_prompt(prompt_id: str, amount: int, is_anon: bool = False,
|
| 543 |
+
current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
|
| 544 |
+
"""
|
| 545 |
+
打赏提示词(原子操作,并发安全)- 使用 SQL Wallet 系统
|
| 546 |
+
"""
|
| 547 |
+
if amount <= 0:
|
| 548 |
+
raise HTTPException(status_code=400, detail="打赏金额必须大于0")
|
| 549 |
+
MAX_TIP_AMOUNT = 100000
|
| 550 |
+
if amount > MAX_TIP_AMOUNT:
|
| 551 |
+
raise HTTPException(status_code=400, detail=f"打赏金额必须在1-{MAX_TIP_AMOUNT}之间")
|
| 552 |
+
|
| 553 |
+
result_container = [None]
|
| 554 |
+
author_account = [None] # 用于在原子操作外获取作者账号
|
| 555 |
+
|
| 556 |
+
def updater(data):
|
| 557 |
+
# 在锁内查找提示词
|
| 558 |
+
target_prompt = None
|
| 559 |
+
for prompt in data:
|
| 560 |
+
if prompt["id"] == prompt_id:
|
| 561 |
+
target_prompt = prompt
|
| 562 |
+
break
|
| 563 |
+
|
| 564 |
+
if not target_prompt:
|
| 565 |
+
result_container[0] = {"error": "not_found"}
|
| 566 |
+
return
|
| 567 |
+
|
| 568 |
+
# 不能打赏自己
|
| 569 |
+
if target_prompt.get("author") == current_user:
|
| 570 |
+
result_container[0] = {"error": "self_tip"}
|
| 571 |
+
return
|
| 572 |
+
|
| 573 |
+
author_account[0] = target_prompt.get("author")
|
| 574 |
+
|
| 575 |
+
# 更新打赏榜单
|
| 576 |
+
tip_board = target_prompt.get("tip_board", [])
|
| 577 |
+
existing = next((t for t in tip_board if t["account"] == current_user), None)
|
| 578 |
+
if existing:
|
| 579 |
+
existing["amount"] += amount
|
| 580 |
+
else:
|
| 581 |
+
tip_board.append({"account": current_user, "amount": amount, "is_anon": is_anon})
|
| 582 |
+
tip_board.sort(key=lambda x: x["amount"], reverse=True)
|
| 583 |
+
target_prompt["tip_board"] = tip_board
|
| 584 |
+
|
| 585 |
+
result_container[0] = {"status": "success", "message": f"成功打赏 {amount} 积分"}
|
| 586 |
+
|
| 587 |
+
db.atomic_update("prompts.json", updater, default_data=[])
|
| 588 |
+
|
| 589 |
+
result = result_container[0]
|
| 590 |
+
if result is None or result.get("error") == "not_found":
|
| 591 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 592 |
+
if result.get("error") == "self_tip":
|
| 593 |
+
raise HTTPException(status_code=400, detail="不能打赏自己的提示词")
|
| 594 |
+
|
| 595 |
+
# 💳 使用 SQL Wallet 系统处理余额转账
|
| 596 |
+
try:
|
| 597 |
+
author = author_account[0]
|
| 598 |
+
if not author:
|
| 599 |
+
raise HTTPException(status_code=404, detail="作者账户不存在")
|
| 600 |
+
|
| 601 |
+
# 🔒 P1幂等性防护:检查最近5秒内是否存在相同交易
|
| 602 |
+
recent_cutoff = datetime.datetime.utcnow() - datetime.timedelta(seconds=5)
|
| 603 |
+
duplicate_tx = db_session.query(Transaction).filter(
|
| 604 |
+
Transaction.account == current_user,
|
| 605 |
+
Transaction.tx_type == "TIP_OUT",
|
| 606 |
+
Transaction.amount == -amount,
|
| 607 |
+
Transaction.related_account == author,
|
| 608 |
+
Transaction.created_at >= recent_cutoff
|
| 609 |
+
).first()
|
| 610 |
+
if duplicate_tx:
|
| 611 |
+
return {"status": "success", "message": "打赏已处理(重复请求)"}
|
| 612 |
+
|
| 613 |
+
# 🔒 并发安全:使用悲观锁获取双方钱包
|
| 614 |
+
tipper_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
|
| 615 |
+
author_wallet = db_session.query(Wallet).filter(Wallet.account == author).with_for_update().first()
|
| 616 |
+
|
| 617 |
+
if not tipper_wallet or tipper_wallet.balance < amount:
|
| 618 |
+
raise HTTPException(status_code=400, detail="余额不足")
|
| 619 |
+
if not author_wallet:
|
| 620 |
+
# 如果作者钱包不存在,创建一个
|
| 621 |
+
author_wallet = Wallet(account=author, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 622 |
+
db_session.add(author_wallet)
|
| 623 |
+
|
| 624 |
+
# 执行转账
|
| 625 |
+
tipper_wallet.balance -= amount
|
| 626 |
+
author_wallet.balance += amount # 实际收入进统一余额
|
| 627 |
+
author_wallet.tip_balance += amount # 累计打赏收益统计(只增不减)
|
| 628 |
+
|
| 629 |
+
# 创建交易记录
|
| 630 |
+
tx_id_tipper = f"TIP_OUT_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 631 |
+
tx_id_author = f"TIP_IN_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 632 |
+
|
| 633 |
+
# 获取最后交易记录的哈希
|
| 634 |
+
last_tx_tipper = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first()
|
| 635 |
+
last_tx_author = db_session.query(Transaction).filter(Transaction.account == author).order_by(Transaction.created_at.desc()).first()
|
| 636 |
+
prev_hash_tipper = last_tx_tipper.tx_hash if last_tx_tipper else "GENESIS_HASH"
|
| 637 |
+
prev_hash_author = last_tx_author.tx_hash if last_tx_author else "GENESIS_HASH"
|
| 638 |
+
|
| 639 |
+
# 获取用户信息和提示词标题
|
| 640 |
+
users_db = db.load_data("users.json", default_data={})
|
| 641 |
+
author_info = users_db.get(author, {})
|
| 642 |
+
tipper_info = users_db.get(current_user, {})
|
| 643 |
+
author_name = author_info.get("name", author)
|
| 644 |
+
tipper_name = tipper_info.get("name", current_user)
|
| 645 |
+
|
| 646 |
+
prompt_title = None
|
| 647 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 648 |
+
for prompt in prompts_db:
|
| 649 |
+
if prompt["id"] == prompt_id:
|
| 650 |
+
prompt_title = prompt.get("title")
|
| 651 |
+
break
|
| 652 |
+
|
| 653 |
+
# 打赏方交易记录 (TIP_OUT)
|
| 654 |
+
tx_tipper = Transaction(
|
| 655 |
+
tx_id=tx_id_tipper,
|
| 656 |
+
account=current_user,
|
| 657 |
+
tx_type="TIP_OUT",
|
| 658 |
+
amount=-amount,
|
| 659 |
+
related_account=author,
|
| 660 |
+
item_id=prompt_id,
|
| 661 |
+
prev_hash=prev_hash_tipper,
|
| 662 |
+
tx_hash=calculate_tx_hash(tx_id_tipper, current_user, "TIP_OUT", -amount, prev_hash_tipper),
|
| 663 |
+
description=f"打赏给 {author_name}" + (f" 的提示词《{prompt_title}》" if prompt_title else ""),
|
| 664 |
+
item_title=prompt_title,
|
| 665 |
+
item_type="prompt",
|
| 666 |
+
related_user_name=author_name
|
| 667 |
+
)
|
| 668 |
+
|
| 669 |
+
# 接收方交易记录 (TIP_IN)
|
| 670 |
+
tx_author = Transaction(
|
| 671 |
+
tx_id=tx_id_author,
|
| 672 |
+
account=author,
|
| 673 |
+
tx_type="TIP_IN",
|
| 674 |
+
amount=amount,
|
| 675 |
+
related_account=current_user,
|
| 676 |
+
item_id=prompt_id,
|
| 677 |
+
prev_hash=prev_hash_author,
|
| 678 |
+
tx_hash=calculate_tx_hash(tx_id_author, author, "TIP_IN", amount, prev_hash_author),
|
| 679 |
+
description=f"收到 {tipper_name} 的提示词打赏" + (f" ({prompt_title})" if prompt_title else ""),
|
| 680 |
+
item_title=prompt_title,
|
| 681 |
+
item_type="prompt",
|
| 682 |
+
related_user_name=tipper_name if not is_anon else "匿名用户"
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
db_session.add(tx_tipper)
|
| 686 |
+
db_session.add(tx_author)
|
| 687 |
+
db_session.commit()
|
| 688 |
+
|
| 689 |
+
# 📝 审计日志
|
| 690 |
+
logger.info(f"PROMPT_TIP | from={current_user} | to={author} | amount={amount} | prompt={prompt_id} | anon={is_anon}")
|
| 691 |
+
|
| 692 |
+
# 🔔 打赏通知(考虑匿名)
|
| 693 |
+
if not is_anon:
|
| 694 |
+
add_notification(author, {
|
| 695 |
+
"type": "tip",
|
| 696 |
+
"from_user": current_user,
|
| 697 |
+
"target_item_id": prompt_id,
|
| 698 |
+
"target_item_title": prompt_title or "",
|
| 699 |
+
"content": f"您收到来自 {tipper_name} 的 {amount} 积分提示词打赏"
|
| 700 |
+
})
|
| 701 |
+
else:
|
| 702 |
+
add_notification(author, {
|
| 703 |
+
"type": "tip",
|
| 704 |
+
"from_user": "anonymous",
|
| 705 |
+
"target_item_id": prompt_id,
|
| 706 |
+
"target_item_title": "",
|
| 707 |
+
"content": f"您收到了一份 {amount} 积分的匿名提示词打赏"
|
| 708 |
+
})
|
| 709 |
+
|
| 710 |
+
# 🗂️ 清除排序缓存(打赏可能影响排序)
|
| 711 |
+
sort_cache.invalidate("prompts:")
|
| 712 |
+
|
| 713 |
+
except HTTPException:
|
| 714 |
+
db_session.rollback()
|
| 715 |
+
raise
|
| 716 |
+
except Exception as e:
|
| 717 |
+
db_session.rollback()
|
| 718 |
+
logger.error(f"PROMPT_TIP_ERROR | from={current_user} | prompt={prompt_id} | amount={amount} | error={str(e)}")
|
| 719 |
+
raise HTTPException(status_code=500, detail="打赏处理失败,请稍后重试")
|
| 720 |
+
|
| 721 |
+
return result
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
# ==========================================
|
| 725 |
+
# 🛒 购买解锁接口
|
| 726 |
+
# ==========================================
|
| 727 |
+
|
| 728 |
+
@router.post("/api/prompts/{prompt_id}/purchase")
|
| 729 |
+
@limiter.limit("10/minute") # 🔒 P0安全优化:购买每分钟最多10次
|
| 730 |
+
async def purchase_prompt(request: Request, prompt_id: str,
|
| 731 |
+
current_user: str = Depends(require_auth), db_session: Session = Depends(get_db)):
|
| 732 |
+
"""
|
| 733 |
+
购买提示词解锁正文(与 /api/wallet/purchase 逻辑对齐)
|
| 734 |
+
- 扣费、所有权、PURCHASE/SALE 交易记录、通知一步到位
|
| 735 |
+
- 成功后直接返回 prompt_text,前端立即展示
|
| 736 |
+
"""
|
| 737 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 738 |
+
prompt = next((p for p in prompts_db if p["id"] == prompt_id), None)
|
| 739 |
+
|
| 740 |
+
if not prompt:
|
| 741 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 742 |
+
|
| 743 |
+
price = int(prompt.get("price", 0) or 0)
|
| 744 |
+
seller_account = prompt.get("author")
|
| 745 |
+
|
| 746 |
+
if price <= 0 or current_user == seller_account:
|
| 747 |
+
# ☁️ 免费提示词或作者本人,确保 Ownership 记录存在
|
| 748 |
+
existing_ownership = db_session.query(Ownership).filter(
|
| 749 |
+
Ownership.account == current_user,
|
| 750 |
+
Ownership.item_id == prompt_id,
|
| 751 |
+
Ownership.is_refunded == False
|
| 752 |
+
).first()
|
| 753 |
+
if not existing_ownership:
|
| 754 |
+
new_ownership = Ownership(
|
| 755 |
+
account=current_user,
|
| 756 |
+
item_id=prompt_id,
|
| 757 |
+
price_paid=0
|
| 758 |
+
)
|
| 759 |
+
db_session.add(new_ownership)
|
| 760 |
+
db_session.commit()
|
| 761 |
+
return {
|
| 762 |
+
"status": "success",
|
| 763 |
+
"already_owned": True,
|
| 764 |
+
"prompt_text": prompt.get("prompt_text", "")
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
# 检查是否已拥有(排除已退款的记录)
|
| 768 |
+
owned = db_session.query(Ownership).filter(
|
| 769 |
+
Ownership.account == current_user,
|
| 770 |
+
Ownership.item_id == prompt_id,
|
| 771 |
+
Ownership.is_refunded == False
|
| 772 |
+
).first()
|
| 773 |
+
if owned:
|
| 774 |
+
return {
|
| 775 |
+
"status": "success",
|
| 776 |
+
"already_owned": True,
|
| 777 |
+
"prompt_text": prompt.get("prompt_text", "")
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
# 🔒 P1并发安全:使用悲观锁+异常处理+事务回滚防止双花问题
|
| 781 |
+
try:
|
| 782 |
+
buyer_wallet = db_session.query(Wallet).filter(Wallet.account == current_user).with_for_update().first()
|
| 783 |
+
if not buyer_wallet or buyer_wallet.balance < price:
|
| 784 |
+
raise HTTPException(status_code=402, detail="余额不足,请先充值")
|
| 785 |
+
|
| 786 |
+
seller_wallet = db_session.query(Wallet).filter(Wallet.account == seller_account).with_for_update().first()
|
| 787 |
+
if not seller_wallet:
|
| 788 |
+
seller_wallet = Wallet(account=seller_account, balance=0, earn_balance=0, tip_balance=0, frozen_balance=0)
|
| 789 |
+
db_session.add(seller_wallet)
|
| 790 |
+
|
| 791 |
+
buyer_wallet.balance = int(buyer_wallet.balance or 0) - int(price)
|
| 792 |
+
seller_wallet.balance = int(seller_wallet.balance or 0) + int(price) # 实际收入进统一余额
|
| 793 |
+
seller_wallet.earn_balance = int(seller_wallet.earn_balance or 0) + int(price) # 累计销售收益统计(只增不减)
|
| 794 |
+
|
| 795 |
+
# 记录所有权与实付价格
|
| 796 |
+
new_ownership = Ownership(account=current_user, item_id=prompt_id, price_paid=price)
|
| 797 |
+
db_session.add(new_ownership)
|
| 798 |
+
|
| 799 |
+
tx_id = f"BUY_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 800 |
+
last_tx = db_session.query(Transaction).filter(Transaction.account == current_user).order_by(Transaction.created_at.desc()).first()
|
| 801 |
+
prev_hash = last_tx.tx_hash if last_tx else "GENESIS_HASH"
|
| 802 |
+
tx_hash = calculate_tx_hash(tx_id, current_user, "PURCHASE", -price, prev_hash)
|
| 803 |
+
|
| 804 |
+
# 获取卖家用户名
|
| 805 |
+
users_db = db.load_data("users.json", default_data={})
|
| 806 |
+
seller_info = users_db.get(seller_account, {})
|
| 807 |
+
seller_user_name = seller_info.get("name", seller_account)
|
| 808 |
+
|
| 809 |
+
# 创建交易记录 (字段名为 related_account,与 models_sql.py 中 Transaction 模型保持一致)
|
| 810 |
+
new_tx = Transaction(
|
| 811 |
+
tx_id=tx_id, account=current_user, tx_type="PURCHASE", amount=-price,
|
| 812 |
+
related_account=seller_account, item_id=prompt_id, prev_hash=prev_hash, tx_hash=tx_hash,
|
| 813 |
+
description=f"购买提示词 {prompt.get('title', '未知提示词')}",
|
| 814 |
+
item_title=prompt.get('title', ''),
|
| 815 |
+
item_type="prompt",
|
| 816 |
+
related_user_name=seller_user_name
|
| 817 |
+
)
|
| 818 |
+
db_session.add(new_tx)
|
| 819 |
+
|
| 820 |
+
# 为卖家生成销售收入记录
|
| 821 |
+
buyer_info = users_db.get(current_user, {})
|
| 822 |
+
buyer_user_name = buyer_info.get("name", current_user)
|
| 823 |
+
seller_tx_id = f"SALE_{int(time.time())}_{uuid.uuid4().hex[:6]}"
|
| 824 |
+
seller_tx = Transaction(
|
| 825 |
+
tx_id=seller_tx_id,
|
| 826 |
+
account=seller_account,
|
| 827 |
+
tx_type="SALE",
|
| 828 |
+
amount=price,
|
| 829 |
+
related_account=current_user,
|
| 830 |
+
item_id=prompt_id,
|
| 831 |
+
item_title=prompt.get('title', ''),
|
| 832 |
+
item_type="prompt",
|
| 833 |
+
related_user_name=buyer_user_name,
|
| 834 |
+
description=f"��售收入: {prompt.get('title', prompt_id)}"
|
| 835 |
+
)
|
| 836 |
+
db_session.add(seller_tx)
|
| 837 |
+
db_session.commit()
|
| 838 |
+
|
| 839 |
+
# 📝 P2优化:购买审计日志
|
| 840 |
+
logger.info(f"PROMPT_PURCHASE | buyer={current_user} | seller={seller_account} | prompt={prompt_id} | amount={price} | tx={tx_id}")
|
| 841 |
+
|
| 842 |
+
# 🔔 通知卖家提示词被购买
|
| 843 |
+
if seller_account and seller_account != current_user:
|
| 844 |
+
add_notification(seller_account, {
|
| 845 |
+
"type": "purchase",
|
| 846 |
+
"from_user": current_user,
|
| 847 |
+
"target_item_id": prompt_id,
|
| 848 |
+
"target_item_title": prompt.get("title", ""),
|
| 849 |
+
"content": f"您的提示词《{prompt.get('title', '')}》已被购买"
|
| 850 |
+
})
|
| 851 |
+
|
| 852 |
+
# 购买成功后直接返回提示词正文
|
| 853 |
+
return {
|
| 854 |
+
"status": "success",
|
| 855 |
+
"already_owned": False,
|
| 856 |
+
"prompt_text": prompt.get("prompt_text", "")
|
| 857 |
+
}
|
| 858 |
+
except HTTPException:
|
| 859 |
+
db_session.rollback()
|
| 860 |
+
raise
|
| 861 |
+
except Exception as e:
|
| 862 |
+
db_session.rollback()
|
| 863 |
+
logger.error(f"PROMPT_PURCHASE_ERROR | buyer={current_user} | prompt={prompt_id} | error={str(e)}")
|
| 864 |
+
raise HTTPException(status_code=500, detail="购买处理失败,请稍后重试")
|
| 865 |
+
|
| 866 |
+
|
| 867 |
+
# ==========================================
|
| 868 |
+
# 💬 评论接口(复用通用评论存储 comments.json)
|
| 869 |
+
# ==========================================
|
| 870 |
+
|
| 871 |
+
@router.get("/api/prompts/{prompt_id}/comments")
|
| 872 |
+
async def get_prompt_comments(prompt_id: str):
|
| 873 |
+
"""
|
| 874 |
+
获取提示词评论
|
| 875 |
+
"""
|
| 876 |
+
comments_db = db.load_data("comments.json", default_data={})
|
| 877 |
+
users_db = db.load_data("users.json", default_data={})
|
| 878 |
+
user_map = users_db
|
| 879 |
+
|
| 880 |
+
# comments_db 是 {item_id: [comments]} 格式
|
| 881 |
+
prompt_comments = comments_db.get(prompt_id, [])
|
| 882 |
+
|
| 883 |
+
# 附加用户信息
|
| 884 |
+
result = []
|
| 885 |
+
for c in prompt_comments:
|
| 886 |
+
author_info = user_map.get(c.get("author"), {})
|
| 887 |
+
result.append({
|
| 888 |
+
**c,
|
| 889 |
+
"author_name": author_info.get("name", c.get("author")),
|
| 890 |
+
"author_avatar": author_info.get("avatarDataUrl", "")
|
| 891 |
+
})
|
| 892 |
+
|
| 893 |
+
return {"status": "success", "data": result}
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
@router.post("/api/prompts/{prompt_id}/comments")
|
| 897 |
+
async def add_prompt_comment(prompt_id: str, content: str, current_user: str = Depends(require_auth)):
|
| 898 |
+
"""
|
| 899 |
+
添加提示词评论
|
| 900 |
+
"""
|
| 901 |
+
if not content or not content.strip():
|
| 902 |
+
raise HTTPException(status_code=400, detail="评论内容不能为空")
|
| 903 |
+
|
| 904 |
+
prompts_db = db.load_data("prompts.json", default_data=[])
|
| 905 |
+
comments_db = db.load_data("comments.json", default_data={})
|
| 906 |
+
|
| 907 |
+
# 检查提示词是否存在
|
| 908 |
+
prompt_exists = any(p["id"] == prompt_id for p in prompts_db)
|
| 909 |
+
if not prompt_exists:
|
| 910 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 911 |
+
|
| 912 |
+
new_comment = {
|
| 913 |
+
"id": f"comment_{int(time.time())}_{uuid.uuid4().hex[:6]}",
|
| 914 |
+
"author": current_user,
|
| 915 |
+
"content": content.strip(),
|
| 916 |
+
"created_at": int(time.time())
|
| 917 |
+
}
|
| 918 |
+
|
| 919 |
+
# comments_db 是 {item_id: [comments]} 格式
|
| 920 |
+
prompt_comments = comments_db.get(prompt_id, [])
|
| 921 |
+
prompt_comments.insert(0, new_comment)
|
| 922 |
+
comments_db[prompt_id] = prompt_comments
|
| 923 |
+
db.save_data("comments.json", comments_db)
|
| 924 |
+
|
| 925 |
+
# 🗂️ 清除排序缓存(评论数变化可能影响排序)
|
| 926 |
+
sort_cache.invalidate("prompts:")
|
| 927 |
+
|
| 928 |
+
# 更新提示词评论数
|
| 929 |
+
for prompt in prompts_db:
|
| 930 |
+
if prompt["id"] == prompt_id:
|
| 931 |
+
prompt["comments"] = prompt.get("comments", 0) + 1
|
| 932 |
+
break
|
| 933 |
+
db.save_data("prompts.json", prompts_db)
|
| 934 |
+
|
| 935 |
+
return {"status": "success", "data": new_comment}
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
@router.post("/api/prompts/{prompt_id}/view")
|
| 939 |
+
async def record_prompt_view(prompt_id: str, current_user: str = Depends(require_auth)):
|
| 940 |
+
"""
|
| 941 |
+
记录提示词访问量
|
| 942 |
+
👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加
|
| 943 |
+
"""
|
| 944 |
+
result = record_view("prompts.json", prompt_id, current_user)
|
| 945 |
+
|
| 946 |
+
if result is None:
|
| 947 |
+
raise HTTPException(status_code=404, detail="提示词不存在")
|
| 948 |
+
|
| 949 |
+
# 🗂️ 清除排序缓存(浏览量变化可能影响排序)
|
| 950 |
+
sort_cache.invalidate("prompts:")
|
| 951 |
+
|
| 952 |
+
return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]}
|
router_wallet.py
CHANGED
|
@@ -1057,7 +1057,14 @@ async def get_purchase_status(account: str, item_id: str, db: Session = Depends(
|
|
| 1057 |
# 读取商品,获取 allow_refund(旧数据默认 True)
|
| 1058 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 1059 |
item = next((i for i in items_db if i.get("id") == item_id), None)
|
| 1060 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1061 |
|
| 1062 |
# 计算是否在退款窗口内
|
| 1063 |
purchased_at = ownership.purchased_at
|
|
|
|
| 1057 |
# 读取商品,获取 allow_refund(旧数据默认 True)
|
| 1058 |
items_db = json_db.load_data("items.json", default_data=[])
|
| 1059 |
item = next((i for i in items_db if i.get("id") == item_id), None)
|
| 1060 |
+
if item:
|
| 1061 |
+
allow_refund = item.get("allow_refund", True)
|
| 1062 |
+
elif item_id.startswith("prompt_"):
|
| 1063 |
+
# ✨ 提示词:虚拟内容即时消费,不支持退款
|
| 1064 |
+
# 注:提示词被作者删除时所有权仍有效,照常返回 owned,仅禁止退款
|
| 1065 |
+
allow_refund = False
|
| 1066 |
+
else:
|
| 1067 |
+
allow_refund = True
|
| 1068 |
|
| 1069 |
# 计算是否在退款窗口内
|
| 1070 |
purchased_at = ownership.purchased_at
|
数据库连接.py
CHANGED
|
@@ -73,6 +73,8 @@ CACHE_TTL = {
|
|
| 73 |
"tasks.json": 60, # 任务数据 1 分钟
|
| 74 |
"users.json": 30, # 🔒 用户数据 30秒(含权限字段 is_admin,缩短 TTL 确保权限变更及时生效)
|
| 75 |
"posts.json": 120, # 帖子数据 2 分钟
|
|
|
|
|
|
|
| 76 |
"comments.json": 60, # 评论数据 1 分钟
|
| 77 |
"disputes.json": 60, # 申诉数据 1 分钟
|
| 78 |
"_default": 60 # 默认 1 分钟
|
|
|
|
| 73 |
"tasks.json": 60, # 任务数据 1 分钟
|
| 74 |
"users.json": 30, # 🔒 用户数据 30秒(含权限字段 is_admin,缩短 TTL 确保权限变更及时生效)
|
| 75 |
"posts.json": 120, # 帖子数据 2 分钟
|
| 76 |
+
"prompts.json": 120, # ✨ 提示词数据 2 分钟
|
| 77 |
+
"prompt_categories.json": 300, # ✨ 提示词分类 5 分钟
|
| 78 |
"comments.json": 60, # 评论数据 1 分钟
|
| 79 |
"disputes.json": 60, # 申诉数据 1 分钟
|
| 80 |
"_default": 60 # 默认 1 分钟
|