diff --git a/app.py b/app.py index 88fbbc1..127ca5c 100644 --- a/app.py +++ b/app.py @@ -236,8 +236,10 @@ FORUM_NOTIFICATION_TYPE_LABELS = { # 论坛高频数据短时缓存(进程内) _FORUM_CACHE_TTL_CATEGORIES = 20.0 _FORUM_CACHE_TTL_SIDEBAR = 15.0 +_FORUM_CACHE_TTL_NOTIF_COUNT = 30.0 _FORUM_CATEGORY_CACHE = {} _FORUM_SIDEBAR_CACHE = {"expires_at": 0.0, "data": None} +_NOTIF_COUNT_CACHE = {} # user_id -> (count, expires_at) _MARKDOWN_ALLOWED_TAGS = [ "p", "br", "hr", @@ -416,6 +418,7 @@ def _create_notification( message=message[:255], is_read=False, )) + _NOTIF_COUNT_CACHE.pop(user_id, None) def _notification_target_url(notification): @@ -460,16 +463,24 @@ def _get_forum_category_names(active_only=True): return fallback +def _get_notifications_unread_count(user_id): + """已登录用户未读通知数,短时缓存减少每次请求的 count 查询。""" + if not user_id: + return 0 + now_ts = monotonic() + entry = _NOTIF_COUNT_CACHE.get(user_id) + if entry is not None and entry[1] > now_ts: + return entry[0] + count = ForumNotification.query.filter_by(user_id=user_id, is_read=False).count() + _NOTIF_COUNT_CACHE[user_id] = (count, now_ts + _FORUM_CACHE_TTL_NOTIF_COUNT) + return count + + @app.context_processor def inject_global_user(): lang = _get_lang() current_user = _get_current_user() - notifications_unread_count = 0 - if current_user: - notifications_unread_count = ForumNotification.query.filter_by( - user_id=current_user.id, - is_read=False, - ).count() + notifications_unread_count = _get_notifications_unread_count(current_user.id if current_user else None) return { "current_user": current_user, "admin_logged_in": bool(session.get("admin_logged_in")), @@ -891,16 +902,38 @@ I18N = { } +def _query_plans_for_display(): + """查询 VPS 方案列表并预加载 provider,避免 to_dict() 时 N+1。""" + return ( + VPSPlan.query + .options(joinedload(VPSPlan.provider_rel)) + .order_by(VPSPlan.provider, VPSPlan.price_cny) + .all() + ) + + +# /api/plans 短期缓存(秒) +_API_PLANS_CACHE_TTL = 60 +_API_PLANS_CACHE = {"data": None, "expires_at": 0.0} + + +def _invalidate_plans_cache(): + """后台增删改方案后调用,使 /api/plans 缓存失效。""" + _API_PLANS_CACHE["expires_at"] = 0.0 + + @app.route("/") def index(): lang = _get_lang() t = I18N[lang] - plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all() + plans = _query_plans_for_display() + plans_data = [p.to_dict() for p in plans] return render_template( "index.html", site_url=SITE_URL, site_name=SITE_NAME, - plans_json_ld=[p.to_dict() for p in plans], + plans_json_ld=plans_data, + initial_plans_json=plans_data, lang=lang, t=t, ) @@ -908,8 +941,19 @@ def index(): @app.route("/api/plans") def api_plans(): - plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all() - return jsonify([p.to_dict() for p in plans]) + now_ts = monotonic() + cached = _API_PLANS_CACHE.get("data") + if cached is not None and _API_PLANS_CACHE.get("expires_at", 0.0) > now_ts: + resp = jsonify(cached) + resp.headers["Cache-Control"] = "public, max-age=%d" % _API_PLANS_CACHE_TTL + return resp + plans = _query_plans_for_display() + data = [p.to_dict() for p in plans] + _API_PLANS_CACHE["data"] = data + _API_PLANS_CACHE["expires_at"] = now_ts + _API_PLANS_CACHE_TTL + resp = jsonify(data) + resp.headers["Cache-Control"] = "public, max-age=%d" % _API_PLANS_CACHE_TTL + return resp # ---------- 前台用户与论坛 ---------- @@ -1216,6 +1260,7 @@ def user_notification_read(notification_id): if not n.is_read: n.is_read = True db.session.commit() + _NOTIF_COUNT_CACHE.pop(user.id, None) next_url = (request.form.get("next") or "").strip() if next_url.startswith("/") and not next_url.startswith("//"): return redirect(next_url) @@ -1230,6 +1275,8 @@ def user_notifications_read_all(): unread = ForumNotification.query.filter_by(user_id=user.id, is_read=False) updated = unread.update({"is_read": True}, synchronize_session=False) db.session.commit() + if updated: + _NOTIF_COUNT_CACHE.pop(user.id, None) msg = _pick_lang("已全部标记为已读", "All notifications marked as read.", lang) if updated else _pick_lang("没有未读通知", "No unread notifications.", lang) return redirect(url_for("user_notifications", msg=msg)) @@ -1554,16 +1601,18 @@ def forum_post_detail(post_id): bookmarked_by_me = False can_interact = bool(current_user and not _is_banned_user(current_user)) if current_user: - liked_by_me = ( - ForumPostLike.query - .filter_by(post_id=post.id, user_id=current_user.id) - .first() is not None - ) - bookmarked_by_me = ( - ForumPostBookmark.query - .filter_by(post_id=post.id, user_id=current_user.id) - .first() is not None - ) + # 一次查询同时得到当前用户是否点赞/收藏,减少请求次数 + rows = db.session.execute( + text( + "(SELECT 'like' AS kind FROM forum_post_likes WHERE post_id=:pid AND user_id=:uid LIMIT 1) " + "UNION ALL " + "(SELECT 'bookmark' FROM forum_post_bookmarks WHERE post_id=:pid AND user_id=:uid LIMIT 1)" + ), + {"pid": post.id, "uid": current_user.id}, + ).fetchall() + kinds = {row[0] for row in rows} + liked_by_me = "like" in kinds + bookmarked_by_me = "bookmark" in kinds sidebar = _forum_sidebar_data() return render_template( "forum/post_detail.html", @@ -1901,7 +1950,7 @@ def admin_api_plan_price_history(plan_id): @admin_required def admin_dashboard(): providers = Provider.query.order_by(Provider.name).all() - plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all() + plans = _query_plans_for_display() plan_trends = _build_plan_trend_map(plans) return render_template( "admin/dashboard.html", @@ -1941,9 +1990,15 @@ def admin_provider_new(): @admin_required def admin_provider_detail(provider_id): provider = Provider.query.get_or_404(provider_id) - plans = VPSPlan.query.filter( - (VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name) - ).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all() + plans = ( + VPSPlan.query + .options(joinedload(VPSPlan.provider_rel)) + .filter( + (VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name) + ) + .order_by(VPSPlan.price_cny.asc(), VPSPlan.name) + .all() + ) providers = Provider.query.order_by(Provider.name).all() plan_trends = _build_plan_trend_map(plans) return render_template( @@ -1978,6 +2033,7 @@ def admin_provider_delete(provider_id): VPSPlan.query.filter_by(provider_id=provider_id).update({"provider_id": None}) db.session.delete(provider) db.session.commit() + _invalidate_plans_cache() return redirect(url_for("admin_providers")) @@ -3092,6 +3148,7 @@ def _save_plan(plan): db.session.flush() _record_price_history(plan, source="manual") db.session.commit() + _invalidate_plans_cache() # 若从厂商详情页进入添加,保存后返回该厂商详情 from_provider_id = request.form.get("from_provider_id", type=int) if from_provider_id: @@ -3106,6 +3163,7 @@ def admin_plan_delete(plan_id): PriceHistory.query.filter_by(plan_id=plan_id).delete() db.session.delete(plan) db.session.commit() + _invalidate_plans_cache() return redirect(url_for("admin_dashboard")) @@ -3123,7 +3181,7 @@ def admin_export_excel(): ws = wb.active ws.title = "配置" ws.append(EXCEL_HEADERS) - plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all() + plans = _query_plans_for_display() for p in plans: provider_url = (p.provider_rel.official_url if p.provider_rel else "") or "" ws.append([ @@ -3438,6 +3496,7 @@ def admin_import_preview(): add_applied += 1 _record_price_history(plan, source="import") db.session.commit() + _invalidate_plans_cache() session.pop("import_preview", None) msg = "已新增 {} 条,更新 {} 条配置".format(add_applied, update_applied) return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": msg})) diff --git a/deploy/nginx-vps.ddrwode.cn.conf b/deploy/nginx-vps.ddrwode.cn.conf index f48592e..28accd1 100644 --- a/deploy/nginx-vps.ddrwode.cn.conf +++ b/deploy/nginx-vps.ddrwode.cn.conf @@ -2,9 +2,27 @@ # 然后: sudo ln -sf /etc/nginx/sites-available/vps.ddrwode.cn /etc/nginx/sites-enabled/ # 先只保留下面这个 server(80),执行 certbot --nginx -d vps.ddrwode.cn 后会自动添加 443 和证书 +# 静态资源:长缓存 + 可选 gzip(若全局未开启) +# 若静态由 Flask 提供,则保留 proxy_pass;若改为 Nginx 直接托管,用 alias 指向项目 static 目录 server { listen 80; server_name vps.ddrwode.cn; + + gzip on; + gzip_vary on; + gzip_min_length 256; + gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml; + + # 静态资源缓存 7 天,减少重复请求与页面跳转后的再加载 + location /static/ { + proxy_pass http://127.0.0.1:5001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "public, max-age=604800"; + } + location / { proxy_pass http://127.0.0.1:5001; proxy_set_header Host $host; diff --git a/static/js/main-simple.js b/static/js/main-simple.js index 7ba8876..bb98e66 100644 --- a/static/js/main-simple.js +++ b/static/js/main-simple.js @@ -68,6 +68,13 @@ // ==================== 数据获取 ==================== function fetchData() { + // 优先使用服务端直出的数据,首屏无需再请求 /api/plans + if (window.__INITIAL_PLANS__ && Array.isArray(window.__INITIAL_PLANS__) && window.__INITIAL_PLANS__.length >= 0) { + allPlans = window.__INITIAL_PLANS__; + populateFilters(); + renderTable(); + return; + } fetch('/api/plans') .then(function(response) { if (!response.ok) throw new Error('Network error'); diff --git a/templates/index.html b/templates/index.html index 982b0dc..74b31fe 100644 --- a/templates/index.html +++ b/templates/index.html @@ -187,6 +187,8 @@ load_error: {{ t.load_error|tojson }}, btn_visit: {{ ('访问' if lang == 'zh' else 'Visit')|tojson }} }; + // 首屏直出数据,避免等待 /api/plans 再渲染表格,加快首屏 + window.__INITIAL_PLANS__ = {{ initial_plans_json|tojson }};