ZHIWEI666 commited on
Commit
5a5bab3
·
verified ·
1 Parent(s): 082b9cb

Upload 7 files

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