ZHIWEI666 commited on
Commit
7c4a0ac
·
verified ·
1 Parent(s): 5413648

新增提示词界面

Browse files
Files changed (5) hide show
  1. app.py +2 -0
  2. models.py +33 -1
  3. router_prompts.py +952 -0
  4. router_wallet.py +8 -1
  5. 数据库连接.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
- allow_refund = item.get("allow_refund", True) if item else True
 
 
 
 
 
 
 
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 分钟