Spaces:
Running
Running
| # router_items.py | |
| from fastapi import APIRouter, HTTPException, Depends | |
| import time | |
| import uuid | |
| import datetime | |
| import os | |
| import urllib.request | |
| import urllib.error | |
| import json | |
| import asyncio | |
| import 数据库连接 as db | |
| from models import ItemCreate, ItemUpdate, RatingRequest | |
| from 安全认证 import require_auth, check_ownership | |
| from 数据库连接 import invalidate_cache | |
| from db_utils import record_view, sort_cache | |
| router = APIRouter() | |
| def _get_version_str(versions_db: dict, item_id: str) -> str: | |
| """兼容新旧格式获取版本hash字符串""" | |
| val = versions_db.get(item_id, "") | |
| if isinstance(val, dict): | |
| return val.get("hash", "") or "" | |
| return val or "" | |
| def _apply_effective_price(item: dict) -> None: | |
| """检查 pending_price 是否已过生效时间,若是则更新 price 字段供前端显示""" | |
| pending_price = item.get("pending_price") | |
| pending_effective = item.get("pending_price_effective_at") | |
| if pending_price is not None and pending_effective: | |
| try: | |
| effective_time = datetime.datetime.fromisoformat(pending_effective) | |
| if datetime.datetime.now() >= effective_time: | |
| item["price"] = pending_price | |
| item["pending_price"] = None | |
| item["pending_price_effective_at"] = None | |
| except (ValueError, TypeError): | |
| pass | |
| def get_last_6_months(): | |
| res = [] | |
| today = datetime.date.today() | |
| for i in range(5, -1, -1): | |
| m = today.month - i | |
| y = today.year | |
| while m <= 0: | |
| m += 12 | |
| y -= 1 | |
| res.append(f"{y}-{m:02d}") | |
| return res | |
| # 数据文件标识,用于排序缓存 | |
| data_file = "items.json" | |
| async def get_items(type: str = "tool", sort: str = "time", limit: int = 50): # 优化:默认限制调大至 50,提升前端列表体验 | |
| items_db = db.load_data("items.json", default_data=[]) | |
| comments_db = db.load_data("comments.json", default_data={}) | |
| versions_db = db.load_data("versions.json", default_data={}) # 读取版本库 | |
| # 如果是推荐榜,匹配所有 recommend 开头的子类型 | |
| if type == "recommend": | |
| filtered_items = [item for item in items_db if item.get("type", "").startswith("recommend")] | |
| else: | |
| filtered_items = [item for item in items_db if item.get("type") == type] | |
| # 🗂️ 使用排序缓存优化排序性能 | |
| cache_key = f"items:{type}:{sort}" | |
| def sort_fn(data): | |
| if sort == "likes": | |
| data.sort(key=lambda x: x.get("likes", 0), reverse=True) | |
| elif sort == "favorites": | |
| data.sort(key=lambda x: x.get("favorites", 0), reverse=True) | |
| elif sort == "downloads": | |
| data.sort(key=lambda x: x.get("uses", 0), reverse=True) | |
| elif sort == "tips": # 🚀 按近期打赏排序 | |
| current_month = datetime.date.today().strftime("%Y-%m") | |
| data.sort(key=lambda x: x.get("tip_history", {}).get(current_month, 0), reverse=True) | |
| elif sort == "views": # 👁️ 按总访问量排序 | |
| data.sort(key=lambda x: x.get("views", 0), reverse=True) | |
| elif sort == "daily_views": # 👁️ 按日访问量排序 | |
| data.sort(key=lambda x: x.get("daily_views", 0), reverse=True) | |
| elif sort == "rating": # ⭐ 按评分排序 | |
| data.sort(key=lambda x: (x.get("rating_avg", 0), x.get("rating_count", 0)), reverse=True) | |
| else: # time 或其他默认 | |
| data.sort(key=lambda x: x.get("created_at", 0), reverse=True) | |
| filtered_items = sort_cache.get_sorted(cache_key, filtered_items, sort_fn) | |
| price_updated = False | |
| for item in filtered_items: | |
| item["commentsData"] = comments_db.get(item["id"], []) | |
| item["comments"] = len(item["commentsData"]) | |
| item["latest_version"] = _get_version_str(versions_db, item["id"]) | |
| # 💰 检查延迟价格是否已生效 | |
| old_price = item.get("price", 0) | |
| _apply_effective_price(item) | |
| if item.get("price", 0) != old_price: | |
| price_updated = True | |
| # 🔴 【绝对核心防线】:在下发给前端前,强行在内存中抹除敏感信息! | |
| item["has_private_token"] = bool(item.get("github_token")) | |
| item.pop("github_token", None) | |
| item.pop("netdisk_password", None) # ☁️ 网盘密码不在列表中显示 | |
| item.pop("viewed_by", None) # 👁️ 访问者列表不暴露给前端 | |
| # 如果有价格生效了,持久化回 items.json | |
| if price_updated: | |
| db.save_data("items.json", items_db) | |
| invalidate_cache("items.json") | |
| return {"status": "success", "data": filtered_items[:limit]} | |
| def _build_creator_trend_data(account: str, u_items: list, months: list) -> dict: | |
| """ | |
| 构建创作者趋势数据 | |
| 提取为独立函数供列表接口和详情接口复用 | |
| """ | |
| trend_tools = {m: 0 for m in months} | |
| trend_apps = {m: 0 for m in months} | |
| trend_recommends = {m: 0 for m in months} | |
| for i in u_items: | |
| itype = i.get("type", "") | |
| history = i.get("use_history", {}) | |
| if itype == "tool" or itype == "recommend_tool": | |
| for m in months: trend_tools[m] += history.get(m, 0) | |
| elif itype == "app" or itype == "recommend_app": | |
| for m in months: trend_apps[m] += history.get(m, 0) | |
| elif itype.startswith("recommend"): | |
| for m in months: trend_recommends[m] += history.get(m, 0) | |
| return { | |
| "months": months, | |
| "tools": [trend_tools[m] for m in months], | |
| "apps": [trend_apps[m] for m in months], | |
| "recommends": [trend_recommends[m] for m in months] | |
| } | |
| async def search_creators(keyword: str, sort: str = "downloads", limit: int = 50): | |
| """ | |
| 搜索创作者 | |
| 根据关键词搜索创作者(name、account、shortDesc 不区分大小写子串匹配) | |
| """ | |
| users_db = db.load_data("users.json", default_data={}) | |
| items_db = db.load_data("items.json", default_data=[]) | |
| # 🚀 P1性能优化:预构建 author->items 索引 | |
| author_items_index = {} | |
| for item in items_db: | |
| author = item.get("author") | |
| if author: | |
| if author not in author_items_index: | |
| author_items_index[author] = [] | |
| author_items_index[author].append(item) | |
| creators = [] | |
| months = get_last_6_months() | |
| keyword_lower = keyword.lower() | |
| for account, u in users_db.items(): | |
| # 🚀 P1性能优化:直接从索引获取 | |
| u_items = author_items_index.get(account, []) | |
| # 获取搜索字段并转为小写 | |
| name = u.get("name", account) | |
| short_desc = u.get("shortDesc") or u.get("intro") or "" | |
| # 不区分大小写的子串匹配(同时覆盖 shortDesc 和 intro 两个字段) | |
| search_text = f"{name} {account} {short_desc} {u.get('intro') or ''} {u.get('shortDesc') or ''}".lower() | |
| if keyword_lower not in search_text: | |
| continue | |
| tools_count = 0 | |
| apps_count = 0 | |
| for i in u_items: | |
| itype = i.get("type", "") | |
| if itype == "tool": | |
| tools_count += 1 | |
| elif itype == "app": | |
| apps_count += 1 | |
| creators.append({ | |
| "account": account, "name": name, "avatar": u.get("avatarDataUrl", ""), | |
| "bannerUrl": u.get("bannerUrl"), | |
| "shortDesc": short_desc, "fullDesc": short_desc, | |
| "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items), | |
| "downloads": sum(i.get("uses", 0) for i in u_items), | |
| "views": sum(i.get("views", 0) for i in u_items), | |
| "daily_views": sum(i.get("daily_views", 0) for i in u_items), | |
| "toolsCount": tools_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0), | |
| "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), | |
| }) | |
| # 排序逻辑与 /api/creators 保持一致 | |
| if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True) | |
| elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True) | |
| elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True) | |
| elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) | |
| elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True) | |
| elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True) | |
| else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True) | |
| return {"status": "success", "data": creators[:limit]} | |
| async def get_creators(sort: str = "downloads", limit: int = 100): | |
| """ | |
| 获取创作者列表 | |
| 🚀 P1性能优化:预构建 author->items 索引,避免 N+1 查询 | |
| ⚡ P2性能优化:移除大字段(trendData、commentsData、tip_board),移至详情接口按需加载 | |
| """ | |
| users_db = db.load_data("users.json", default_data={}) | |
| items_db = db.load_data("items.json", default_data=[]) | |
| # 🚀 P1性能优化:预构建 author->items 索引,复杂度从 O(n*m) 降到 O(n+m) | |
| author_items_index = {} | |
| for item in items_db: | |
| author = item.get("author") | |
| if author: | |
| if author not in author_items_index: | |
| author_items_index[author] = [] | |
| author_items_index[author].append(item) | |
| creators = [] | |
| months = get_last_6_months() | |
| for account, u in users_db.items(): | |
| # 🚀 P1性能优化:直接从索引获取,而非遍历全表 | |
| u_items = author_items_index.get(account, []) | |
| tools_count = 0 | |
| apps_count = 0 | |
| for i in u_items: | |
| itype = i.get("type", "") | |
| if itype == "tool": | |
| tools_count += 1 | |
| elif itype == "app": | |
| apps_count += 1 | |
| creators.append({ | |
| "account": account, "name": u.get("name", account), "avatar": u.get("avatarDataUrl", ""), | |
| "bannerUrl": u.get("bannerUrl"), # 🖼️ 个人资料卡背景图 | |
| "shortDesc": u.get("shortDesc") or u.get("intro") or "", "fullDesc": u.get("shortDesc") or u.get("intro") or "", | |
| "likes": sum(i.get("likes", 0) for i in u_items), "favorites": sum(i.get("favorites", 0) for i in u_items), | |
| "downloads": sum(i.get("uses", 0) for i in u_items), | |
| "views": sum(i.get("views", 0) for i in u_items), | |
| "daily_views": sum(i.get("daily_views", 0) for i in u_items), | |
| "toolsCount": tools_count, "appsCount": apps_count, "followers": len(u.get("followers", [])), "created_at": u.get("created_at", 0), | |
| "recent_tips": u.get("tip_history", {}).get(datetime.date.today().strftime("%Y-%m"), 0), # 🚀 新增:本月收益统计 | |
| }) | |
| if sort == "likes": creators.sort(key=lambda x: x.get("likes", 0), reverse=True) | |
| elif sort == "favorites": creators.sort(key=lambda x: x.get("favorites", 0), reverse=True) | |
| elif sort == "downloads": creators.sort(key=lambda x: x.get("downloads", 0), reverse=True) | |
| elif sort == "tips": creators.sort(key=lambda x: x.get("recent_tips", 0), reverse=True) # 🚀 新增:按近期打赏排序 | |
| elif sort == "views": creators.sort(key=lambda x: x.get("views", 0), reverse=True) | |
| elif sort == "daily_views": creators.sort(key=lambda x: x.get("daily_views", 0), reverse=True) | |
| else: creators.sort(key=lambda x: x.get("created_at", 0), reverse=True) | |
| return {"status": "success", "data": creators[:limit]} | |
| async def get_creator_details(account: str): | |
| """ | |
| 获取单个创作者的详细数据 | |
| 供前端展开卡片时按需加载 | |
| 包含:trendData、commentsData、tip_board | |
| """ | |
| users_db = db.load_data("users.json", default_data={}) | |
| items_db = db.load_data("items.json", default_data=[]) | |
| comments_db = db.load_data("comments.json", default_data={}) | |
| # 检查用户是否存在 | |
| if account not in users_db: | |
| raise HTTPException(status_code=404, detail="创作者不存在") | |
| u = users_db[account] | |
| # 获取该用户的所有作品 | |
| u_items = [item for item in items_db if item.get("author") == account] | |
| # 构建趋势数据 | |
| months = get_last_6_months() | |
| trend_data = _build_creator_trend_data(account, u_items, months) | |
| return { | |
| "status": "success", | |
| "data": { | |
| "account": account, | |
| "trendData": trend_data, | |
| "commentsData": comments_db.get(account, []), | |
| "tip_board": u.get("tip_board", []) | |
| } | |
| } | |
| async def create_item(item: ItemCreate): | |
| if not item.type: | |
| raise HTTPException(status_code=400, detail="资源类型不能为空") | |
| item.price = int(item.price or 0) | |
| if item.price < 0: | |
| raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数") | |
| items_db = db.load_data("items.json", default_data=[]) | |
| new_item = { | |
| "id": f"{item.type}_{int(time.time())}_{uuid.uuid4().hex[:6]}", "type": item.type, "title": item.title, "author": item.author, | |
| "shortDesc": item.shortDesc, "fullDesc": item.fullDesc, "link": item.link, "coverUrl": item.coverUrl, | |
| "imageUrls": item.imageUrls or [], # 🖼️ 效果展示图列表 | |
| "price": item.price, | |
| "github_token": item.github_token, | |
| "netdisk_password": item.netdisk_password, # ☁️ 网盘密码 | |
| "is_netdisk": item.is_netdisk, # ☁️ 是否网盘资源 | |
| "is_original": item.is_original, # 🎨 是否为原创作品 | |
| "allow_refund": item.allow_refund, # 💸 是否支持退款 | |
| "likes": 0, "favorites": 0, "comments": 0, "uses": 0, "use_history": {}, "created_at": int(time.time()), "liked_by": [], "favorited_by": [], | |
| "views": 0, | |
| "daily_views": 0, | |
| "viewed_by": [], | |
| "daily_views_date": "", | |
| "rating_avg": 0.0, | |
| "rating_count": 0, | |
| "rating_dist": {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0}, | |
| "rated_by": {} | |
| } | |
| items_db.insert(0, new_item) | |
| db.save_data("items.json", items_db) | |
| # 🗂️ 清除排序缓存 | |
| sort_cache.invalidate("items:") | |
| # 🚀 首次发布立即触发版本检测与预缓存 | |
| try: | |
| link = item.link or "" | |
| if "github.com" in link: | |
| from 云端_定时版本检测引擎 import fetch_latest_github_hash, precache_github_zip | |
| async def _first_publish_precache(): | |
| try: | |
| token = item.github_token or os.environ.get("GITHUB_PAT", "") | |
| latest_hash = await fetch_latest_github_hash(link, token) | |
| if latest_hash: | |
| # 写入 versions.json | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| versions_db[new_item["id"]] = {"hash": latest_hash} | |
| db.save_data("versions.json", versions_db) | |
| # 触发预缓存 | |
| await precache_github_zip(link, token, new_item["id"], latest_hash) | |
| print(f"[缓存刷新] 首次发布预缓存完成: {new_item['id']}") | |
| except Exception as e: | |
| print(f"[缓存刷新] 首次发布预缓存失败(不影响发布): {e}") | |
| asyncio.create_task(_first_publish_precache()) | |
| except Exception as e: | |
| print(f"[缓存刷新] 触发首次发布缓存异常(不影响发布): {e}") | |
| return {"status": "success", "data": new_item} | |
| async def update_item(item_id: str, update_data: ItemUpdate, current_user: str = Depends(require_auth)): | |
| """ | |
| 更新内容接口 | |
| 🔒 P0安全修复:使用 JWT Token 验证用户身份,而非前端传入的 author 参数 | |
| 🔄 P7后悔模式:价格修改延迟24小时生效 | |
| 🔧 Bug修复:使用 atomic_update 避免并发竞态条件 | |
| """ | |
| if update_data.price is not None: | |
| update_data.price = int(update_data.price) | |
| if update_data.price < 0: | |
| raise HTTPException(status_code=400, detail="🚨 安全拦截:商品价格不能为负数") | |
| result_holder = {} | |
| def updater(items_db): | |
| for item in items_db: | |
| if item["id"] == item_id: | |
| # 🔒 P0安全修复:使用 JWT 解析出的真实用户账号进行校验 | |
| if item.get("author") != current_user: | |
| result_holder["error"] = "forbidden" | |
| return False | |
| price_change_info = None | |
| if update_data.title is not None: item["title"] = update_data.title | |
| if update_data.shortDesc is not None: item["shortDesc"] = update_data.shortDesc | |
| if update_data.fullDesc is not None: item["fullDesc"] = update_data.fullDesc | |
| old_link = item.get("link", "") | |
| if update_data.link is not None: item["link"] = update_data.link | |
| result_holder["link_changed"] = old_link != item.get("link", "") | |
| result_holder["new_link"] = item.get("link", "") | |
| if update_data.coverUrl is not None: item["coverUrl"] = update_data.coverUrl | |
| if update_data.imageUrls is not None: item["imageUrls"] = update_data.imageUrls # 🖼️ 效果展示图列表 | |
| # 🔄 P7后悔模式:价格修改延迟24小时生效 | |
| if update_data.price is not None: | |
| current_price = item.get("price", 0) | |
| new_price = update_data.price | |
| if current_price != new_price: | |
| # 设置待生效价格,24小时后生效 | |
| import datetime | |
| effective_time = datetime.datetime.now() + datetime.timedelta(hours=24) | |
| item["pending_price"] = new_price | |
| item["pending_price_effective_at"] = effective_time.isoformat() | |
| price_change_info = { | |
| "current_price": current_price, | |
| "new_price": new_price, | |
| "effective_at": effective_time.isoformat() | |
| } | |
| # 不立即修改 price,等待24小时后生效 | |
| else: | |
| # 价格未变,清除待生效价格 | |
| item["pending_price"] = None | |
| item["pending_price_effective_at"] = None | |
| if update_data.github_token is not None: item["github_token"] = update_data.github_token | |
| if update_data.netdisk_password is not None: item["netdisk_password"] = update_data.netdisk_password # ☁️ | |
| if update_data.is_netdisk is not None: item["is_netdisk"] = update_data.is_netdisk # ☁️ | |
| if update_data.is_original is not None: item["is_original"] = update_data.is_original # 🎨 | |
| if update_data.allow_refund is not None: item["allow_refund"] = update_data.allow_refund # 💸 | |
| result_holder["success"] = True | |
| result_holder["price_change_info"] = price_change_info | |
| result_holder["current_price"] = price_change_info.get("current_price") if price_change_info else None | |
| result_holder["new_price"] = price_change_info.get("new_price") if price_change_info else None | |
| return True | |
| result_holder["error"] = "not_found" | |
| return False | |
| db.atomic_update("items.json", updater, default_data=[]) | |
| if result_holder.get("error") == "forbidden": | |
| raise HTTPException(status_code=403, detail="无权修改他人发布的内容") | |
| if result_holder.get("error") == "not_found": | |
| raise HTTPException(status_code=404, detail="找不到该内容记录") | |
| # 🗂️ 清除排序缓存和主数据缓存 | |
| sort_cache.invalidate("items:") | |
| invalidate_cache("items.json") | |
| # 🔄 [缓存刷新] 资源 link 变更时清除 ZIP 缓存元信息 | |
| if result_holder.get("link_changed"): | |
| try: | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| entry = versions_db.get(item_id) | |
| if entry is not None: | |
| if isinstance(entry, str): | |
| entry = {"hash": entry} | |
| had_meta = "cached_at" in entry or "zip_size" in entry | |
| entry.pop("cached_at", None) | |
| entry.pop("zip_size", None) | |
| if had_meta: | |
| versions_db[item_id] = entry | |
| db.save_data("versions.json", versions_db) | |
| print(f"[缓存刷新] 已清除 item {item_id} 的 ZIP 缓存元信息") | |
| # 可选:触发异步预缓存 | |
| new_link = result_holder.get("new_link", "") | |
| if new_link and new_link.startswith("https://github.com/"): | |
| try: | |
| from 云端_定时版本检测引擎 import precache_github_zip | |
| # 获取当前 item 的 token 和 version_hash | |
| items_db = db.load_data("items.json", default_data=[]) | |
| token = None | |
| for it in items_db: | |
| if it["id"] == item_id: | |
| token = it.get("github_token") | |
| break | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| entry = versions_db.get(item_id, {}) | |
| version_hash = entry.get("hash", "") if isinstance(entry, dict) else entry | |
| if version_hash: | |
| asyncio.create_task(precache_github_zip(new_link, token, item_id, version_hash)) | |
| print(f"[缓存刷新] 已触发 item {item_id} 的异步预缓存") | |
| except ImportError: | |
| print(f"[缓存刷新] 预缓存模块导入失败,跳过异步预缓存") | |
| except Exception as e: | |
| print(f"[缓存刷新] 触发异步预缓存失败: {e}") | |
| except Exception as e: | |
| print(f"[缓存刷新] 缓存刷新失败(不影响主流程): {e}") | |
| result = {"status": "success"} | |
| if result_holder.get("price_change_info"): | |
| price_change_info = result_holder["price_change_info"] | |
| result["price_change"] = price_change_info | |
| result["message"] = f"价格将于24小时后从{price_change_info['current_price']}调整为{price_change_info['new_price']}积分" | |
| return result | |
| # ========================================== | |
| # 🚀 定时任务接口:检查 GitHub 仓库最新版本 | |
| # 可通过外部调度器 (cron-job.org / GitHub Actions) 每日 02:00 触发 | |
| # ========================================== | |
| async def check_github_updates(): | |
| """ | |
| 遍历所有 GitHub 类型的工具,获取最新 commit hash,存入 versions.json | |
| """ | |
| items_db = db.load_data("items.json", default_data=[]) | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| updated_count = 0 | |
| for item in items_db: | |
| # 只处理 GitHub 仓库类型的工具 | |
| link = item.get("link", "") | |
| if not link.startswith("https://github.com/"): | |
| continue | |
| item_id = item["id"] | |
| try: | |
| # 解析仓库信息 | |
| repo_parts = link.rstrip("/").replace(".git", "").split("/") | |
| if len(repo_parts) < 2: | |
| continue | |
| owner, repo = repo_parts[-2], repo_parts[-1] | |
| # 获取创作者 Token 或使用全局兜底 Token | |
| creator_token = item.get("github_token") | |
| fallback_token = os.environ.get("GITHUB_PAT") | |
| active_token = creator_token if creator_token else fallback_token | |
| # 请求 GitHub API 获取最新 commit | |
| api_url = f"https://api.github.com/repos/{owner}/{repo}/commits?per_page=1" | |
| headers = { | |
| "Accept": "application/vnd.github.v3+json", | |
| "User-Agent": "ComfyUI-Ranking-VersionChecker" | |
| } | |
| if active_token: | |
| headers["Authorization"] = f"Bearer {active_token}" | |
| req = urllib.request.Request(api_url, headers=headers) | |
| with urllib.request.urlopen(req, timeout=10) as response: | |
| commits = json.loads(response.read().decode("utf-8")) | |
| if commits and len(commits) > 0: | |
| latest_sha = commits[0].get("sha", "")[:7] # 取前7位作为版本标识 | |
| # 如果版本有变化,更新 versions.json | |
| old_version = versions_db.get(item_id, "") | |
| if latest_sha and latest_sha != old_version: | |
| versions_db[item_id] = latest_sha | |
| updated_count += 1 | |
| print(f"[版本更新] {item['title']}: {old_version} -> {latest_sha}") | |
| except urllib.error.HTTPError as e: | |
| print(f"[版本检查失败] {item.get('title', item_id)}: HTTP {e.code}") | |
| except Exception as e: | |
| print(f"[版本检查异常] {item.get('title', item_id)}: {str(e)}") | |
| # 保存更新后的版本库 | |
| db.save_data("versions.json", versions_db) | |
| return { | |
| "status": "success", | |
| "message": f"版本检查完成,共更新 {updated_count} 个工具的版本号", | |
| "updated_count": updated_count | |
| } | |
| async def get_item_by_id(item_id: str): | |
| """根据ID获取单个资源的详细信息""" | |
| items_db = db.load_data("items.json", default_data=[]) | |
| comments_db = db.load_data("comments.json", default_data={}) | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| for item in items_db: | |
| if item["id"] == item_id: | |
| # 添加关联数据 | |
| item["commentsData"] = comments_db.get(item_id, []) | |
| item["comments"] = len(item["commentsData"]) | |
| item["latest_version"] = _get_version_str(versions_db, item_id) | |
| # 💰 检查延迟价格是否已生效 | |
| old_price = item.get("price", 0) | |
| _apply_effective_price(item) | |
| if item.get("price", 0) != old_price: | |
| db.save_data("items.json", items_db) | |
| invalidate_cache("items.json") | |
| # 🔴 【安全防线】:抹除敏感信息 | |
| item["has_private_token"] = bool(item.get("github_token")) | |
| item.pop("github_token", None) | |
| item.pop("netdisk_password", None) | |
| item.pop("viewed_by", None) | |
| return {"status": "success", "data": item} | |
| raise HTTPException(status_code=404, detail="资源不存在") | |
| async def get_item_version(item_id: str): | |
| """获取单个资源的最新版本号""" | |
| versions_db = db.load_data("versions.json", default_data={}) | |
| return {"status": "success", "version": _get_version_str(versions_db, item_id)} | |
| async def delete_item(item_id: str, current_user: str = Depends(require_auth)): | |
| """ | |
| 删除内容(仅作者或管理员可操作) | |
| 🔧 Bug修复:使用 atomic_update 避免并发竞态条件 | |
| """ | |
| result_holder = {} | |
| def items_updater(items_db): | |
| for i, item in enumerate(items_db): | |
| if item["id"] == item_id: | |
| # 🔒 权限检查:仅作者或管理员可删除 | |
| if not check_ownership(item, current_user, owner_field="author", allow_admin=True): | |
| result_holder["error"] = "forbidden" | |
| return False | |
| # 1. 从 items.json 中删除该条目 | |
| items_db.pop(i) | |
| result_holder["item_deleted"] = True | |
| return True | |
| result_holder["error"] = "not_found" | |
| return False | |
| db.atomic_update("items.json", items_updater, default_data=[]) | |
| if result_holder.get("error") == "forbidden": | |
| raise HTTPException(status_code=403, detail="无权删除他人发布的内容") | |
| if result_holder.get("error") == "not_found": | |
| raise HTTPException(status_code=404, detail="找不到该内容记录") | |
| # 2. 清理关联评论:从 comments.json 中删除该内容的所有评论 | |
| def comments_updater(comments_db): | |
| if item_id in comments_db: | |
| del comments_db[item_id] | |
| return True | |
| return False | |
| db.atomic_update("comments.json", comments_updater, default_data={}) | |
| # 3. 清理缓存:使 items.json 和 comments.json 的缓存失效 | |
| invalidate_cache("items.json") | |
| invalidate_cache("comments.json") | |
| # 🗂️ 清除排序缓存 | |
| sort_cache.invalidate("items:") | |
| return {"status": "success", "message": "内容已删除"} | |
| async def record_item_view(item_id: str, current_user: str = Depends(require_auth)): | |
| """ | |
| 记录资源访问量 | |
| 👁️ 需要用户认证,每个用户只计算一次总访问量,日访问量每次调用都增加 | |
| """ | |
| result = record_view("items.json", item_id, current_user) | |
| if result is None: | |
| raise HTTPException(status_code=404, detail="找不到该内容记录") | |
| # 🗂️ 清除排序缓存(浏览量变化可能影响排序) | |
| sort_cache.invalidate("items:") | |
| return {"status": "success", "views": result["views"], "daily_views": result["daily_views"]} | |
| async def record_item_use(item_id: str, current_user: str = Depends(require_auth)): | |
| """ | |
| 记录资源使用量/下载量(原子操作,并发安全) | |
| 📥 每个用户对同一资源只计一次,防止重复计数 | |
| """ | |
| result_container = [None] | |
| current_month = datetime.date.today().strftime("%Y-%m") | |
| def updater(data): | |
| for item in data: | |
| if item["id"] == item_id: | |
| used_by = item.get("used_by", []) | |
| if current_user in used_by: | |
| # 用户已使用过,不重复计数 | |
| result_container[0] = {"status": "success", "action": "already_used", "uses": item.get("uses", 0)} | |
| return | |
| # 首次使用,增加计数 | |
| used_by.append(current_user) | |
| item["uses"] = item.get("uses", 0) + 1 | |
| # 更新月度使用历史 | |
| use_history = item.get("use_history", {}) | |
| use_history[current_month] = use_history.get(current_month, 0) + 1 | |
| item["use_history"] = use_history | |
| item["used_by"] = used_by | |
| result_container[0] = {"status": "success", "action": "recorded", "uses": item["uses"]} | |
| return | |
| result_container[0] = None # 未找到资源 | |
| db.atomic_update("items.json", updater, default_data=[]) | |
| if result_container[0] is None: | |
| raise HTTPException(status_code=404, detail="资源不存在") | |
| # 🗂️ 清除排序缓存(使用数变化可能影响排序) | |
| sort_cache.invalidate("items:") | |
| return result_container[0] | |
| # ========================================== | |
| # ❤️ 互动接口(点赞/收藏) | |
| # ========================================== | |
| async def rate_item(item_id: str, request: RatingRequest, current_user: str = Depends(require_auth)): | |
| """ | |
| 为资源评分(原子操作,并发安全) | |
| ⭐ score: 1-5 | |
| """ | |
| score = request.score | |
| if score < 1 or score > 5: | |
| raise HTTPException(status_code=400, detail="评分必须在1-5之间") | |
| result_container = [None] | |
| def updater(data): | |
| for item in data: | |
| if item["id"] == item_id: | |
| # 禁止自评 | |
| if item.get("author") == current_user: | |
| result_container[0] = {"error": "self_rating"} | |
| return | |
| # 初始化评分字段 | |
| if "rating_avg" not in item: | |
| item["rating_avg"] = 0.0 | |
| if "rating_count" not in item: | |
| item["rating_count"] = 0 | |
| if "rating_dist" not in item: | |
| item["rating_dist"] = {"1": 0, "2": 0, "3": 0, "4": 0, "5": 0} | |
| if "rated_by" not in item: | |
| item["rated_by"] = {} | |
| rated_by = item["rated_by"] | |
| rating_dist = item["rating_dist"] | |
| old_score = None | |
| if current_user in rated_by: | |
| old_score = rated_by[current_user]["score"] | |
| if old_score is not None: | |
| # 已评分,先减去旧分数分布 | |
| rating_dist[str(old_score)] = max(0, rating_dist.get(str(old_score), 0) - 1) | |
| rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1 | |
| else: | |
| # 未评分,增加计数 | |
| item["rating_count"] = item.get("rating_count", 0) + 1 | |
| rating_dist[str(score)] = rating_dist.get(str(score), 0) + 1 | |
| rated_by[current_user] = {"score": score, "time": int(time.time())} | |
| # 重新计算平均分 | |
| total = sum(int(k) * v for k, v in rating_dist.items()) | |
| count = item["rating_count"] | |
| item["rating_avg"] = round(total / count, 2) if count > 0 else 0.0 | |
| result_container[0] = { | |
| "status": "success", | |
| "rating_avg": item["rating_avg"], | |
| "rating_count": item["rating_count"], | |
| "rating_dist": item["rating_dist"], | |
| "user_score": score | |
| } | |
| return | |
| result_container[0] = None # 未找到资源 | |
| db.atomic_update("items.json", updater, default_data=[]) | |
| if result_container[0] is None: | |
| raise HTTPException(status_code=404, detail="资源不存在") | |
| if result_container[0].get("error") == "self_rating": | |
| raise HTTPException(status_code=400, detail="不能给自己发布的资源评分") | |
| # 🗂️ 清除排序缓存(评分变化可能影响排序) | |
| sort_cache.invalidate("items:") | |
| return result_container[0] | |
| async def toggle_item_like(item_id: str, current_user: str = Depends(require_auth)): | |
| """ | |
| 点赞/取消点赞(原子操作,并发安全) | |
| """ | |
| result_container = [None] | |
| def updater(data): | |
| for item in data: | |
| if item["id"] == item_id: | |
| liked_by = item.get("liked_by", []) | |
| if current_user in liked_by: | |
| liked_by.remove(current_user) | |
| item["likes"] = max(0, item.get("likes", 0) - 1) | |
| action = "unliked" | |
| else: | |
| liked_by.append(current_user) | |
| item["likes"] = item.get("likes", 0) + 1 | |
| action = "liked" | |
| item["liked_by"] = liked_by | |
| result_container[0] = {"status": "success", "action": action, "likes": item["likes"]} | |
| return | |
| result_container[0] = None # 未找到资源 | |
| db.atomic_update("items.json", updater, default_data=[]) | |
| if result_container[0] is None: | |
| raise HTTPException(status_code=404, detail="资源不存在") | |
| # 🗂️ 清除排序缓存(点赞数变化可能影响排序) | |
| sort_cache.invalidate("items:") | |
| return result_container[0] | |
| async def toggle_item_favorite(item_id: str, current_user: str = Depends(require_auth)): | |
| """ | |
| 收藏/取消收藏(原子操作,并发安全) | |
| """ | |
| result_container = [None] | |
| def updater(data): | |
| for item in data: | |
| if item["id"] == item_id: | |
| favorited_by = item.get("favorited_by", []) | |
| if current_user in favorited_by: | |
| favorited_by.remove(current_user) | |
| item["favorites"] = max(0, item.get("favorites", 0) - 1) | |
| action = "unfavorited" | |
| else: | |
| favorited_by.append(current_user) | |
| item["favorites"] = item.get("favorites", 0) + 1 | |
| action = "favorited" | |
| item["favorited_by"] = favorited_by | |
| result_container[0] = {"status": "success", "action": action, "favorites": item["favorites"]} | |
| return | |
| result_container[0] = None # 未找到资源 | |
| db.atomic_update("items.json", updater, default_data=[]) | |
| if result_container[0] is None: | |
| raise HTTPException(status_code=404, detail="资源不存在") | |
| # 🗂️ 清除排序缓存(收藏数变化可能影响排序) | |
| sort_cache.invalidate("items:") | |
| return result_container[0] |