This commit is contained in:
ddrwode
2026-02-10 13:57:46 +08:00
parent 036a19f28c
commit 6b309fb03f
4 changed files with 111 additions and 25 deletions

109
app.py
View File

@@ -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}))

View File

@@ -2,9 +2,27 @@
# 然后: sudo ln -sf /etc/nginx/sites-available/vps.ddrwode.cn /etc/nginx/sites-enabled/
# 先只保留下面这个 server80执行 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;

View File

@@ -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');

View File

@@ -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 }};
</script>
<script src="/static/js/main-simple.js"></script>
</body>