# -*- coding: utf-8 -*-
"""云服务器价格对比 - Flask 应用"""
import io
import json
import os
import re
from time import monotonic
from datetime import datetime, timezone
from email.utils import format_datetime
from urllib.parse import urlencode
from xml.sax.saxutils import escape as xml_escape
from flask import (
Flask,
abort,
jsonify,
make_response,
redirect,
render_template,
request,
send_file,
send_from_directory,
session,
url_for,
)
from werkzeug.middleware.proxy_fix import ProxyFix
from sqlalchemy import text, func, or_
from sqlalchemy.orm import joinedload
from markupsafe import Markup, escape
try:
import markdown as py_markdown
except Exception:
py_markdown = None
try:
import bleach
except Exception:
bleach = None
from config import Config
from extensions import db
from openpyxl import Workbook
from openpyxl import load_workbook
app = Flask(__name__)
app.config.from_object(Config)
# 部署在 Nginx 等反向代理后时,信任 X-Forwarded-* 头(HTTPS、真实 IP)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
db.init_app(app)
from models import (
VPSPlan,
Provider,
PriceHistory,
User,
ForumPost,
ForumComment,
ForumCategory,
ForumReport,
ForumNotification,
ForumPostLike,
ForumPostBookmark,
) # noqa: E402
def _ensure_mysql_columns():
"""为已有 MySQL 表添加缺失列,避免 1054 Unknown column。"""
try:
engine = db.engine
if engine.dialect.name != "mysql":
return
with engine.connect() as conn:
for col, spec in [
("traffic", "VARCHAR(64) NULL"),
("countries", "VARCHAR(255) NULL"),
("provider_id", "INT NULL"),
]:
try:
conn.execute(text("ALTER TABLE vps_plans ADD COLUMN {} {}".format(col, spec)))
conn.commit()
except Exception:
conn.rollback()
for col, spec in [
("name", "VARCHAR(128) NULL"),
("region", "VARCHAR(128) NULL"),
("price_cny", "DOUBLE NULL"),
("price_usd", "DOUBLE NULL"),
]:
try:
conn.execute(text("ALTER TABLE vps_plans MODIFY COLUMN {} {}".format(col, spec)))
conn.commit()
except Exception:
conn.rollback()
except Exception:
pass # 表不存在或非 MySQL 时忽略
def _ensure_forum_columns():
"""为已有论坛表补齐后续新增字段。"""
try:
engine = db.engine
dialect = engine.dialect.name
with engine.connect() as conn:
if dialect == "mysql":
alters = [
"ALTER TABLE forum_posts ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT '综合讨论'",
"ALTER TABLE forum_posts ADD COLUMN view_count INT NOT NULL DEFAULT 0",
]
else:
alters = [
"ALTER TABLE forum_posts ADD COLUMN category TEXT DEFAULT '综合讨论'",
"ALTER TABLE forum_posts ADD COLUMN view_count INTEGER DEFAULT 0",
]
for sql in alters:
try:
conn.execute(text(sql))
conn.commit()
except Exception:
conn.rollback()
except Exception:
pass
def _ensure_forum_manage_columns():
"""为用户与论坛帖子补齐管理字段(封禁/置顶/精华/锁帖)。"""
try:
engine = db.engine
dialect = engine.dialect.name
with engine.connect() as conn:
if dialect == "mysql":
alters = [
"ALTER TABLE users ADD COLUMN is_banned TINYINT(1) NOT NULL DEFAULT 0",
"ALTER TABLE users ADD COLUMN banned_at DATETIME NULL",
"ALTER TABLE users ADD COLUMN banned_reason VARCHAR(255) NULL",
"ALTER TABLE forum_posts ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0",
"ALTER TABLE forum_posts ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0",
"ALTER TABLE forum_posts ADD COLUMN is_locked TINYINT(1) NOT NULL DEFAULT 0",
]
else:
alters = [
"ALTER TABLE users ADD COLUMN is_banned INTEGER DEFAULT 0",
"ALTER TABLE users ADD COLUMN banned_at DATETIME",
"ALTER TABLE users ADD COLUMN banned_reason TEXT",
"ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0",
"ALTER TABLE forum_posts ADD COLUMN is_featured INTEGER DEFAULT 0",
"ALTER TABLE forum_posts ADD COLUMN is_locked INTEGER DEFAULT 0",
]
for sql in alters:
try:
conn.execute(text(sql))
conn.commit()
except Exception:
conn.rollback()
except Exception:
pass
DEFAULT_FORUM_CATEGORIES = [
"综合讨论",
"VPS 评测",
"优惠活动",
"运维经验",
"新手提问",
]
def _ensure_forum_categories_seed():
"""初始化论坛默认分类。"""
try:
if ForumCategory.query.count() > 0:
return
for idx, name in enumerate(DEFAULT_FORUM_CATEGORIES, start=1):
db.session.add(ForumCategory(
name=name,
sort_order=idx * 10,
is_active=True,
))
db.session.commit()
except Exception:
db.session.rollback()
def _ensure_price_history_baseline():
"""为历史数据补首条价格快照,便于后续计算涨跌。"""
try:
missing = (
db.session.query(VPSPlan)
.outerjoin(PriceHistory, PriceHistory.plan_id == VPSPlan.id)
.filter(PriceHistory.id.is_(None))
.all()
)
if not missing:
return
for p in missing:
if p.price_cny is None and p.price_usd is None:
continue
db.session.add(PriceHistory(
plan_id=p.id,
price_cny=p.price_cny,
price_usd=p.price_usd,
currency=(p.currency or "CNY"),
source="bootstrap",
))
db.session.commit()
except Exception:
db.session.rollback()
# 启动时自动创建表(若不存在),并为已有表补列
with app.app_context():
db.create_all()
_ensure_mysql_columns()
_ensure_forum_columns()
_ensure_forum_manage_columns()
_ensure_forum_categories_seed()
_ensure_price_history_baseline()
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
SITE_URL = app.config["SITE_URL"]
SITE_NAME = app.config["SITE_NAME"]
# 国家/区域标签,供后台表单选择
COUNTRY_TAGS = [
"中国大陆", "中国香港", "中国台湾", "美国", "日本", "新加坡", "韩国",
"德国", "英国", "法国", "荷兰", "加拿大", "澳大利亚", "印度", "其他",
]
PRICE_SOURCE_LABELS = {
"manual": "手工编辑",
"import": "Excel 导入",
"bootstrap": "基线",
}
FORUM_REPORT_REASONS = [
"垃圾广告",
"辱骂攻击",
"违法违规",
"虚假信息",
"其他",
]
FORUM_REPORT_STATUS_LABELS = {
"pending": "待处理",
"processed": "已处理",
"rejected": "已驳回",
}
FORUM_NOTIFICATION_TYPE_LABELS = {
"post_commented": "帖子新评论",
"thread_replied": "主题新回复",
"report_processed": "举报处理结果",
"content_removed": "内容处理通知",
}
# 论坛高频数据短时缓存(进程内)
_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",
"h1", "h2", "h3", "h4",
"strong", "em", "del",
"ul", "ol", "li",
"blockquote",
"pre", "code",
"a",
"table", "thead", "tbody", "tr", "th", "td",
]
_MARKDOWN_ALLOWED_ATTRS = {
"a": ["href", "title", "target", "rel"],
"code": ["class"],
"pre": ["class"],
}
_MARKDOWN_EXTENSIONS = [
"fenced_code",
"tables",
"sane_lists",
"nl2br",
]
FORUM_NOTIFICATION_TYPE_LABELS_EN = {
"post_commented": "New comment",
"thread_replied": "New reply",
"report_processed": "Report update",
"content_removed": "Content moderation",
}
# Sitemap 单个文件最大帖子条数(按语言拆分后可稳定低于 50k URL 上限)
SITEMAP_POSTS_PER_FILE = 25000
FORUM_CATEGORY_SEO_COPY = {
"综合讨论": {
"zh": "围绕 VPS 选型、采购和实践经验的综合讨论区。",
"en": "General discussions about VPS planning, buying, and operations.",
},
"VPS 评测": {
"zh": "集中查看 VPS 评测、性能体验与线路反馈。",
"en": "Hands-on VPS reviews, benchmarks, and network feedback.",
},
"优惠活动": {
"zh": "跟踪厂商促销、折扣活动与限时优惠。",
"en": "Track provider promotions, discounts, and limited-time deals.",
},
"运维经验": {
"zh": "分享部署、监控、故障排查与稳定性实践。",
"en": "Operations playbooks for deployment, monitoring, and troubleshooting.",
},
"新手提问": {
"zh": "面向新手的配置建议与入门答疑。",
"en": "Beginner-friendly Q&A for VPS setup and decision making.",
},
}
def _get_lang():
lang = (
request.args.get("lang")
or request.form.get("lang")
or session.get("lang")
or "zh"
)
lang = (lang or "zh").strip().lower()
if lang not in ("zh", "en"):
lang = "zh"
session["lang"] = lang
return lang
def _pick_lang(zh_text, en_text, lang=None):
active_lang = lang or _get_lang()
return en_text if active_lang == "en" else zh_text
def _lang_url(lang_code):
target_lang = (lang_code or "").strip().lower()
if target_lang not in ("zh", "en"):
target_lang = "zh"
params = {}
if request.view_args:
params.update(request.view_args)
params.update(request.args.to_dict(flat=True))
params["lang"] = target_lang
try:
if request.endpoint:
return url_for(request.endpoint, **params)
except Exception:
pass
return "{}?{}".format(request.path, urlencode(params))
def _site_root_url():
return (SITE_URL or "").rstrip("/")
def _absolute_url_for(endpoint, **values):
return "{}{}".format(_site_root_url(), url_for(endpoint, **values))
def _public_url(endpoint, lang="zh", **params):
values = {}
for key, value in params.items():
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
values[key] = value
if (lang or "zh").strip().lower() == "en":
values["lang"] = "en"
else:
values.pop("lang", None)
return _absolute_url_for(endpoint, **values)
def _alternate_lang_links(endpoint, **params):
return {
"zh-CN": _public_url(endpoint, lang="zh", **params),
"en-US": _public_url(endpoint, lang="en", **params),
"x-default": _public_url(endpoint, lang="zh", **params),
}
def _iso8601_utc(dt):
if not dt:
return None
if dt.tzinfo is None:
aware = dt.replace(tzinfo=timezone.utc)
else:
aware = dt.astimezone(timezone.utc)
return aware.strftime("%Y-%m-%dT%H:%M:%SZ")
def _rfc2822_utc(dt):
if not dt:
return None
if dt.tzinfo is None:
aware = dt.replace(tzinfo=timezone.utc)
else:
aware = dt.astimezone(timezone.utc)
return format_datetime(aware, usegmt=True)
def _plain_excerpt(text, limit=160):
raw = " ".join((text or "").split())
if len(raw) <= limit:
return raw
return "{}…".format(raw[:max(limit - 1, 0)].rstrip())
def _estimate_reading_minutes(text, lang="zh"):
raw = " ".join((text or "").split())
if not raw:
return 1
# 对中英混合文本做轻量估算:中文按字、英文按词处理。
token_count = len(re.findall(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]", raw))
if lang == "en":
rate = 220
else:
rate = 320
minutes = (token_count + rate - 1) // rate
return max(1, int(minutes))
def _forum_category_description(category_name, lang):
category = (category_name or "").strip()
if not category:
return _pick_lang(
"聚合 VPS 评测、运维经验与采购讨论,帮助团队完成云资源选型。",
"A VPS community for reviews, operations knowledge, and procurement discussions.",
lang,
)
preset = FORUM_CATEGORY_SEO_COPY.get(category) or {}
if lang == "en":
return preset.get("en") or "Community topics tagged '{}' for VPS reviews, operations, and buying decisions.".format(category)
return preset.get("zh") or "浏览“{}”分类下的 VPS 讨论、评测与采购经验。".format(category)
def _forum_index_keywords(lang, active_tab="latest", selected_category=None):
if lang == "en":
keywords = [
"VPS forum",
"VPS community",
"cloud server reviews",
"VPS buying guide",
"VPS operations",
]
tab_map = {
"latest": "latest VPS topics",
"new": "new VPS posts",
"hot": "popular VPS discussions",
}
else:
keywords = [
"VPS论坛",
"VPS社区",
"云服务器评测",
"VPS采购建议",
"VPS运维经验",
]
tab_map = {
"latest": "最新帖子",
"new": "新帖",
"hot": "热门讨论",
}
tab_keyword = tab_map.get(active_tab)
if tab_keyword:
keywords.append(tab_keyword)
if selected_category:
keywords.append(selected_category)
return ", ".join(dict.fromkeys(keywords))
def _forum_breadcrumb_schema(lang, selected_category=None, post=None, post_url=None):
items = [
{
"@type": "ListItem",
"position": 1,
"name": _pick_lang("首页", "Home", lang),
"item": _public_url("index", lang=lang),
},
{
"@type": "ListItem",
"position": 2,
"name": _pick_lang("论坛", "Forum", lang),
"item": _public_url("forum_index", lang=lang),
},
]
if selected_category:
items.append({
"@type": "ListItem",
"position": len(items) + 1,
"name": selected_category,
"item": _public_url("forum_index", lang=lang, category=selected_category),
})
if post and post_url:
items.append({
"@type": "ListItem",
"position": len(items) + 1,
"name": post.title,
"item": post_url,
})
return {
"@type": "BreadcrumbList",
"itemListElement": items,
}
def _sitemap_alternates(endpoint, **params):
links = _alternate_lang_links(endpoint, **params)
return [{"hreflang": k, "href": v} for k, v in links.items()]
def _build_sitemap_urlset_xml(url_items):
lines = [
'',
'',
]
for item in url_items:
lines.append(" ")
lines.append(" {}".format(xml_escape(item["loc"])))
if item.get("lastmod"):
lines.append(" {}".format(item["lastmod"]))
if item.get("changefreq"):
lines.append(" {}".format(item["changefreq"]))
if item.get("priority"):
lines.append(" {}".format(item["priority"]))
for alt in item.get("alternates") or []:
href = alt.get("href")
hreflang = alt.get("hreflang")
if not href or not hreflang:
continue
lines.append(
' '.format(
xml_escape(hreflang),
xml_escape(href),
)
)
lines.append(" ")
lines.append("")
return "\n".join(lines)
def _build_sitemap_index_xml(entries):
lines = [
'',
'',
]
for item in entries:
lines.append(" ")
lines.append(" {}".format(xml_escape(item["loc"])))
if item.get("lastmod"):
lines.append(" {}".format(item["lastmod"]))
lines.append(" ")
lines.append("")
return "\n".join(lines)
def _latest_forum_content_datetime():
return db.session.query(func.max(func.coalesce(ForumPost.updated_at, ForumPost.created_at))).scalar()
def _forum_sitemap_total_pages():
total_posts = ForumPost.query.count()
return max((total_posts + SITEMAP_POSTS_PER_FILE - 1) // SITEMAP_POSTS_PER_FILE, 1)
def _should_noindex_path(path):
target = path or ""
if target.startswith("/admin"):
return True
if target.startswith("/api/"):
return True
if target in {"/login", "/register", "/profile", "/me", "/notifications"}:
return True
if target.startswith("/notification/"):
return True
if target == "/forum/post/new":
return True
if target == "/forum/report":
return True
if target.startswith("/forum/post/") and target.endswith("/edit"):
return True
if target.startswith("/forum/comment/") and target.endswith("/edit"):
return True
return False
@app.after_request
def _append_response_headers(response):
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
if _should_noindex_path(request.path):
response.headers["X-Robots-Tag"] = "noindex, nofollow, noarchive"
return response
def _notification_type_label(notif_type, lang=None):
active_lang = lang or _get_lang()
if active_lang == "en":
return FORUM_NOTIFICATION_TYPE_LABELS_EN.get(notif_type, notif_type or "Notification")
return FORUM_NOTIFICATION_TYPE_LABELS.get(notif_type, notif_type or "通知")
@app.template_global("l")
def _template_pick_lang(zh_text, en_text):
active_lang = session.get("lang", "zh")
if active_lang not in ("zh", "en"):
active_lang = "zh"
return en_text if active_lang == "en" else zh_text
@app.template_global("lang_url")
def _template_lang_url(lang_code):
return _lang_url(lang_code)
def _render_markdown_html(text):
raw = (text or "").strip()
if not raw:
return Markup("")
if py_markdown is None or bleach is None:
# 依赖缺失时回退为安全纯文本显示,避免服务启动失败。
return Markup("
{}
".format(str(escape(raw)).replace("\n", "
")))
html = py_markdown.markdown(raw, extensions=_MARKDOWN_EXTENSIONS)
clean_html = bleach.clean(
html,
tags=_MARKDOWN_ALLOWED_TAGS,
attributes=_MARKDOWN_ALLOWED_ATTRS,
protocols=["http", "https", "mailto"],
strip=True,
)
return Markup(clean_html)
@app.template_filter("markdown_html")
def markdown_html_filter(text):
return _render_markdown_html(text)
def _get_current_user():
user_id = session.get("user_id")
if not user_id:
return None
user = db.session.get(User, user_id)
if not user:
session.pop("user_id", None)
return user
def _is_banned_user(user):
return bool(user and bool(user.is_banned))
def _user_ban_message(user):
if not user:
return "账号状态异常"
reason = (user.banned_reason or "").strip()
if reason:
return "账号已被封禁:{}".format(reason)
return "账号已被封禁"
def _is_valid_username(username):
if not username:
return False
if len(username) < 3 or len(username) > 20:
return False
return all(ch.isalnum() or ch == "_" for ch in username)
def _safe_next_url(default_endpoint):
nxt = (request.values.get("next") or "").strip()
if nxt.startswith("/") and not nxt.startswith("//"):
return nxt
return url_for(default_endpoint)
def _safe_form_next_url(default_url):
nxt = (request.form.get("next") or request.args.get("next") or "").strip()
if nxt.startswith("/") and not nxt.startswith("//"):
return nxt
return default_url
def _create_notification(
user_id,
notif_type,
message,
actor_id=None,
post_id=None,
comment_id=None,
report_id=None,
):
"""创建站内通知(由调用方控制事务提交)。"""
if not user_id or not message:
return
db.session.add(ForumNotification(
user_id=user_id,
actor_id=actor_id,
notif_type=notif_type,
post_id=post_id,
comment_id=comment_id,
report_id=report_id,
message=message[:255],
is_read=False,
))
_NOTIF_COUNT_CACHE.pop(user_id, None)
def _notification_target_url(notification):
# 避免通知列表页按条检查帖子存在性导致 N+1 查询。
if notification.post_id:
return url_for("forum_post_detail", post_id=notification.post_id)
return url_for("user_notifications")
def _load_forum_categories(active_only=True):
"""读取论坛分类(默认只读启用项)。"""
try:
q = ForumCategory.query
if active_only:
q = q.filter_by(is_active=True)
return q.order_by(ForumCategory.sort_order.asc(), ForumCategory.id.asc()).all()
except Exception:
return []
def _get_forum_category_names(active_only=True):
cache_key = "active" if active_only else "all"
now_ts = monotonic()
cached = _FORUM_CATEGORY_CACHE.get(cache_key)
if cached and cached[0] > now_ts:
return list(cached[1])
rows = _load_forum_categories(active_only=active_only)
names = [x.name for x in rows if x.name]
if names:
_FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return names
# 若全部被停用,前台仍回退到已存在分类,避免下拉为空。
if active_only:
rows = _load_forum_categories(active_only=False)
names = [x.name for x in rows if x.name]
if names:
_FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(names))
return names
fallback = list(DEFAULT_FORUM_CATEGORIES)
_FORUM_CATEGORY_CACHE[cache_key] = (now_ts + _FORUM_CACHE_TTL_CATEGORIES, tuple(fallback))
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 = _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")),
"forum_categories": _get_forum_category_names(active_only=True),
"forum_report_reasons": FORUM_REPORT_REASONS,
"notifications_unread_count": notifications_unread_count,
"lang": lang,
}
def _humanize_time(dt, lang=None):
if not dt:
return ""
active_lang = lang or session.get("lang", "zh")
if dt.tzinfo is None:
# 兼容历史“无时区”时间:按 UTC 解释后与当前 UTC 进行比较,避免 utcnow 弃用告警
dt = dt.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
else:
now = datetime.now(dt.tzinfo)
delta = now - dt
seconds = int(delta.total_seconds())
if seconds < 0:
return dt.strftime("%Y-%m-%d")
if seconds < 60:
return "just now" if active_lang == "en" else "刚刚"
if seconds < 3600:
mins = seconds // 60
return "{}m ago".format(mins) if active_lang == "en" else "{} 分钟前".format(mins)
if seconds < 86400:
hours = seconds // 3600
return "{}h ago".format(hours) if active_lang == "en" else "{} 小时前".format(hours)
if seconds < 86400 * 14:
days = seconds // 86400
return "{}d ago".format(days) if active_lang == "en" else "{} 天前".format(days)
return dt.strftime("%Y-%m-%d")
def _build_forum_post_cards(rows, lang=None):
"""将论坛查询结果行转换为列表卡片数据。"""
active_lang = lang or _get_lang()
cards = []
for post, reply_count, latest_activity, author_name, like_count, bookmark_count in rows:
latest_activity = latest_activity or post.created_at
username = author_name or _pick_lang("用户", "User", active_lang)
cards.append({
"post": post,
"reply_count": int(reply_count or 0),
"view_count": int(post.view_count or 0),
"like_count": int(like_count or 0),
"bookmark_count": int(bookmark_count or 0),
"latest_activity": latest_activity,
"latest_activity_text": _humanize_time(latest_activity, lang=active_lang),
"author_name": username,
"author_initial": (username[0] if username else "?").upper(),
})
return cards
def _build_forum_url(tab="latest", category=None, q=None, page=1, per_page=20, lang=None):
"""构建论坛列表页链接,并尽量保持 URL 简洁。"""
params = {}
if (tab or "latest") != "latest":
params["tab"] = tab
if category:
params["category"] = category
if q:
params["q"] = q
if page and int(page) > 1:
params["page"] = int(page)
if per_page:
size = int(per_page)
if size != 20:
params["per_page"] = size
active_lang = (lang or "").strip().lower()
if active_lang == "en":
params["lang"] = "en"
return url_for("forum_index", **params)
def _query_forum_post_rows(active_tab="latest", selected_category=None, search_query=None, author_id=None):
"""论坛列表查询:支持最新/新帖/热门 + 分类过滤 + 关键词搜索。"""
comment_stats_subq = (
db.session.query(
ForumComment.post_id.label("post_id"),
func.count(ForumComment.id).label("comment_count"),
func.max(ForumComment.created_at).label("latest_comment_at"),
)
.group_by(ForumComment.post_id)
.subquery()
)
comment_count_expr = func.coalesce(comment_stats_subq.c.comment_count, 0)
latest_activity_expr = func.coalesce(comment_stats_subq.c.latest_comment_at, ForumPost.created_at)
like_stats_subq = (
db.session.query(
ForumPostLike.post_id.label("post_id"),
func.count(ForumPostLike.id).label("like_count"),
)
.group_by(ForumPostLike.post_id)
.subquery()
)
bookmark_stats_subq = (
db.session.query(
ForumPostBookmark.post_id.label("post_id"),
func.count(ForumPostBookmark.id).label("bookmark_count"),
)
.group_by(ForumPostBookmark.post_id)
.subquery()
)
like_count_expr = func.coalesce(like_stats_subq.c.like_count, 0)
bookmark_count_expr = func.coalesce(bookmark_stats_subq.c.bookmark_count, 0)
q = (
db.session.query(
ForumPost,
comment_count_expr.label("comment_count"),
latest_activity_expr.label("latest_activity"),
User.username.label("author_name"),
like_count_expr.label("like_count"),
bookmark_count_expr.label("bookmark_count"),
)
.outerjoin(comment_stats_subq, comment_stats_subq.c.post_id == ForumPost.id)
.outerjoin(like_stats_subq, like_stats_subq.c.post_id == ForumPost.id)
.outerjoin(bookmark_stats_subq, bookmark_stats_subq.c.post_id == ForumPost.id)
.outerjoin(User, User.id == ForumPost.user_id)
)
if selected_category:
q = q.filter(ForumPost.category == selected_category)
if author_id is not None:
q = q.filter(ForumPost.user_id == author_id)
if search_query:
pattern = "%{}%".format(search_query)
q = q.filter(
or_(
ForumPost.title.ilike(pattern),
ForumPost.content.ilike(pattern),
User.username.ilike(pattern),
)
)
if active_tab == "hot":
q = q.order_by(
ForumPost.is_pinned.desc(),
comment_count_expr.desc(),
like_count_expr.desc(),
ForumPost.view_count.desc(),
latest_activity_expr.desc(),
ForumPost.id.desc(),
)
elif active_tab == "new":
q = q.order_by(ForumPost.is_pinned.desc(), ForumPost.created_at.desc(), ForumPost.id.desc())
else:
q = q.order_by(ForumPost.is_pinned.desc(), latest_activity_expr.desc(), ForumPost.id.desc())
return q
def _forum_sidebar_data():
now_ts = monotonic()
cached = _FORUM_SIDEBAR_CACHE.get("data")
if cached is not None and _FORUM_SIDEBAR_CACHE.get("expires_at", 0.0) > now_ts:
return dict(cached)
category_counts = (
db.session.query(ForumPost.category, func.count(ForumPost.id))
.group_by(ForumPost.category)
.order_by(func.count(ForumPost.id).desc())
.all()
)
active_users = (
db.session.query(User.username, func.count(ForumPost.id).label("post_count"))
.outerjoin(ForumPost, ForumPost.user_id == User.id)
.group_by(User.id)
.order_by(func.count(ForumPost.id).desc(), User.created_at.asc())
.limit(6)
.all()
)
data = {
"total_users": User.query.count(),
"total_posts": ForumPost.query.count(),
"total_comments": ForumComment.query.count(),
"category_counts": list(category_counts),
"active_users": list(active_users),
}
_FORUM_SIDEBAR_CACHE["data"] = data
_FORUM_SIDEBAR_CACHE["expires_at"] = now_ts + _FORUM_CACHE_TTL_SIDEBAR
return dict(data)
def _count_forum_posts(selected_category=None, search_query=None, author_id=None):
"""论坛列表总数查询:避免对重查询语句直接 count 导致慢查询。"""
q = (
db.session.query(func.count(ForumPost.id))
.select_from(ForumPost)
.outerjoin(User, User.id == ForumPost.user_id)
)
if selected_category:
q = q.filter(ForumPost.category == selected_category)
if author_id is not None:
q = q.filter(ForumPost.user_id == author_id)
if search_query:
pattern = "%{}%".format(search_query)
q = q.filter(
or_(
ForumPost.title.ilike(pattern),
ForumPost.content.ilike(pattern),
User.username.ilike(pattern),
)
)
return int(q.scalar() or 0)
def _currency_symbol(currency):
return "¥" if (currency or "CNY").upper() == "CNY" else "$"
def _format_money(currency, value):
return "{}{:.2f}".format(_currency_symbol(currency), float(value))
def _format_history_time(dt):
return dt.strftime("%Y-%m-%d %H:%M") if dt else ""
def _pick_price_pair(latest, previous=None):
if previous is None:
if latest.price_cny is not None:
return "CNY", float(latest.price_cny), None
if latest.price_usd is not None:
return "USD", float(latest.price_usd), None
return None, None, None
if latest.price_cny is not None and previous.price_cny is not None:
return "CNY", float(latest.price_cny), float(previous.price_cny)
if latest.price_usd is not None and previous.price_usd is not None:
return "USD", float(latest.price_usd), float(previous.price_usd)
return None, None, None
def _build_price_trend(latest, previous=None):
currency, current_value, previous_value = _pick_price_pair(latest, previous)
if currency is None or current_value is None:
return None
source = PRICE_SOURCE_LABELS.get(latest.source or "", latest.source or "未知来源")
meta = "当前 {} · {} · {}".format(
_format_money(currency, current_value),
_format_history_time(latest.captured_at),
source,
)
if previous_value is None:
return {
"direction": "new",
"delta_text": "首次记录",
"meta_text": meta,
}
diff = current_value - previous_value
if abs(diff) < 1e-9:
return {
"direction": "flat",
"delta_text": "→ 持平",
"meta_text": meta,
}
direction = "up" if diff > 0 else "down"
arrow = "↑" if diff > 0 else "↓"
sign = "+" if diff > 0 else "-"
delta_text = "{} {}{}{:,.2f}".format(arrow, sign, _currency_symbol(currency), abs(diff))
if abs(previous_value) > 1e-9:
pct = diff / previous_value * 100
delta_text += " ({:+.2f}%)".format(pct)
return {
"direction": direction,
"delta_text": delta_text,
"meta_text": meta,
}
def _build_plan_trend_map(plans):
plan_ids = [p.id for p in plans if p.id is not None]
if not plan_ids:
return {}
rows = (
PriceHistory.query
.filter(PriceHistory.plan_id.in_(plan_ids))
.order_by(PriceHistory.plan_id.asc(), PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.all()
)
grouped = {}
for row in rows:
bucket = grouped.setdefault(row.plan_id, [])
if len(bucket) < 2:
bucket.append(row)
result = {}
for plan_id, bucket in grouped.items():
latest = bucket[0] if bucket else None
previous = bucket[1] if len(bucket) > 1 else None
trend = _build_price_trend(latest, previous) if latest else None
if trend:
result[plan_id] = trend
return result
def _build_post_plan_recommendations(post, lang="zh", limit=5):
if not post:
return [], _pick_lang("暂无推荐方案", "No recommended plans yet.", lang)
raw_text = "{}\n{}".format(post.title or "", post.content or "")
text_lower = raw_text.lower()
matched_provider_ids = []
matched_provider_names = []
provider_rows = Provider.query.order_by(Provider.id.asc()).limit(200).all()
for provider in provider_rows:
name = (provider.name or "").strip()
if not name:
continue
if name.lower() in text_lower:
matched_provider_ids.append(provider.id)
matched_provider_names.append(name)
matched_regions = []
for region in COUNTRY_TAGS:
item = (region or "").strip()
if item and item in raw_text:
matched_regions.append(item)
if len(matched_regions) >= 3:
break
price_score_expr = func.coalesce(VPSPlan.price_cny, VPSPlan.price_usd * 7.2, 999999.0)
base_query = (
VPSPlan.query
.options(joinedload(VPSPlan.provider_rel))
.filter(or_(VPSPlan.price_cny.isnot(None), VPSPlan.price_usd.isnot(None)))
)
scoped_query = base_query
if matched_provider_ids:
scoped_query = scoped_query.filter(
or_(
VPSPlan.provider_id.in_(matched_provider_ids),
VPSPlan.provider.in_(matched_provider_names),
)
)
if matched_regions:
region_conds = []
for region in matched_regions:
region_conds.append(VPSPlan.countries.ilike("%{}%".format(region)))
region_conds.append(VPSPlan.region.ilike("%{}%".format(region)))
scoped_query = scoped_query.filter(or_(*region_conds))
ordered_scoped = scoped_query.order_by(
price_score_expr.asc(),
VPSPlan.vcpu.desc(),
VPSPlan.memory_gb.desc(),
VPSPlan.id.desc(),
)
picked = ordered_scoped.limit(limit).all()
seen_ids = {p.id for p in picked if p and p.id is not None}
if len(picked) < limit:
fallback_rows = (
base_query
.order_by(
price_score_expr.asc(),
VPSPlan.vcpu.desc(),
VPSPlan.memory_gb.desc(),
VPSPlan.id.desc(),
)
.limit(max(limit * 2, 12))
.all()
)
for row in fallback_rows:
if not row or row.id in seen_ids:
continue
picked.append(row)
seen_ids.add(row.id)
if len(picked) >= limit:
break
items = []
for plan in picked[:limit]:
if plan.price_cny is not None:
price_label = _format_money("CNY", plan.price_cny)
elif plan.price_usd is not None:
price_label = _format_money("USD", plan.price_usd)
else:
price_label = _pick_lang("待更新", "TBD", lang)
items.append({
"id": plan.id,
"provider": plan.provider_name or plan.provider or _pick_lang("未知厂商", "Unknown Provider", lang),
"name": plan.display_name or _pick_lang("未命名方案", "Unnamed Plan", lang),
"region": (plan.countries or plan.region or _pick_lang("区域未标注", "Region not specified", lang)).strip(),
"price_label": price_label,
"official_url": (plan.official_url or (plan.provider_rel.official_url if plan.provider_rel else "") or "").strip(),
})
if matched_provider_ids and matched_regions:
context_text = _pick_lang(
"依据帖子中的厂商与区域关键词推荐",
"Recommended based on provider and region keywords in this topic",
lang,
)
elif matched_provider_ids:
context_text = _pick_lang(
"依据帖子中的厂商关键词推荐",
"Recommended based on provider keywords in this topic",
lang,
)
elif matched_regions:
context_text = _pick_lang(
"依据帖子中的区域关键词推荐",
"Recommended based on region keywords in this topic",
lang,
)
else:
context_text = _pick_lang(
"按价格与规格综合排序推荐",
"Recommended by a combined price/spec ranking",
lang,
)
return items, context_text
def _build_post_detail_url(post_id, lang="zh", comment_page=1):
"""构建帖子详情页 URL(用于评论分页链接)。"""
page_num = 1
try:
page_num = int(comment_page or 1)
except Exception:
page_num = 1
if page_num < 1:
page_num = 1
params = {"post_id": post_id}
if page_num > 1:
params["cp"] = page_num
if (lang or "zh").strip().lower() == "en":
params["lang"] = "en"
return url_for("forum_post_detail", **params)
def _build_post_comment_page_links(post_id, total_pages, current_page, lang="zh"):
total = int(total_pages or 1)
current = int(current_page or 1)
if total <= 1:
return []
candidates = {1, total}
for n in range(current - 2, current + 3):
if 1 <= n <= total:
candidates.add(n)
ordered = sorted(candidates)
links = []
prev = None
for page in ordered:
if prev is not None and page - prev > 1:
links.append({"is_gap": True, "label": "…"})
links.append({
"is_gap": False,
"page": page,
"url": _build_post_detail_url(post_id, lang=lang, comment_page=page),
"active": page == current,
})
prev = page
return links
def _build_post_resource_links(post, lang="zh"):
if not post:
return []
category_name = (post.category or "").strip()
links = []
if category_name:
links.append({
"title": _pick_lang("继续看同分类主题", "More in This Category", lang),
"description": _pick_lang("同一分类下的最新讨论与经验汇总。", "Browse latest discussions in the same category.", lang),
"url": _build_forum_url(category=category_name, lang=lang),
"track_label": "resource_category",
})
links.extend([
{
"title": _pick_lang("论坛热门讨论", "Hot Forum Topics", lang),
"description": _pick_lang("优先阅读互动度高的帖子,快速获取高信号观点。", "Prioritize high-engagement threads for stronger signals.", lang),
"url": _build_forum_url(tab="hot", lang=lang),
"track_label": "resource_hot",
},
{
"title": _pick_lang("论坛最新动态", "Latest Forum Activity", lang),
"description": _pick_lang("追踪最新发布和最近活跃的主题。", "Track newly posted and recently active topics.", lang),
"url": _build_forum_url(tab="latest", lang=lang),
"track_label": "resource_latest",
},
{
"title": _pick_lang("VPS 价格总览", "VPS Pricing Console", lang),
"description": _pick_lang("按价格、地区、配置进行方案筛选。", "Filter plans by price, region, and specs.", lang),
"url": url_for("index", lang="en") if lang == "en" else url_for("index"),
"track_label": "resource_pricing",
},
{
"title": _pick_lang("论坛 RSS 订阅", "Forum RSS Feed", lang),
"description": _pick_lang("通过订阅持续跟进论坛更新。", "Follow forum updates through RSS subscription.", lang),
"url": url_for("forum_feed", lang="en") if lang == "en" else url_for("forum_feed"),
"track_label": "resource_feed",
},
])
deduped = []
seen = set()
for item in links:
u = item.get("url")
if not u or u in seen:
continue
seen.add(u)
deduped.append(item)
return deduped[:6]
def _build_post_faq_items(post, comments_count=0, read_minutes=1, plan_reco_context="", lang="zh"):
if not post:
return []
post_excerpt = _plain_excerpt(post.content or "", limit=180) or _pick_lang(
"本帖围绕 VPS 选型与采购决策展开讨论。",
"This topic discusses VPS shortlisting and procurement decisions.",
lang,
)
comments_val = max(int(comments_count or 0), 0)
read_val = max(int(read_minutes or 1), 1)
recommendation_line = (plan_reco_context or "").strip() or _pick_lang(
"按价格与规格综合排序推荐方案。",
"Plans are recommended by combined price and spec ranking.",
lang,
)
return [
{
"question": _pick_lang("这篇帖子主要讨论什么?", "What does this topic focus on?", lang),
"answer": post_excerpt,
},
{
"question": _pick_lang("我应该先看正文还是先看评论?", "Should I read content or comments first?", lang),
"answer": _pick_lang(
"建议先用约 {} 分钟读完正文,再结合 {} 条评论验证观点。".format(read_val, comments_val),
"Read the main post first in about {} minutes, then validate points with {} comments.".format(read_val, comments_val),
lang,
),
},
{
"question": _pick_lang("下一步如何落地选型?", "What is the next step for shortlisting?", lang),
"answer": _pick_lang(
"{} 随后进入价格页按地区、预算和配置筛选,再到厂商官网确认条款。".format(recommendation_line),
"{} Then use the pricing page filters (region, budget, specs) and confirm terms on official provider sites.".format(recommendation_line),
lang,
),
},
]
def _build_post_howto_schema(post, canonical_url, lang="zh", read_minutes=1, comments_count=0):
if not post or not canonical_url:
return None
comments_val = max(int(comments_count or 0), 0)
read_val = max(int(read_minutes or 1), 1)
pricing_url = _public_url("index", lang=lang)
post_new_url = _public_url("forum_post_new", lang=lang)
steps = [
{
"@type": "HowToStep",
"position": 1,
"name": _pick_lang("阅读主题与核心需求", "Read the topic and core requirement", lang),
"text": _pick_lang(
"先阅读标题和正文,明确业务目标、预算和区域要求。",
"Read title and content first to identify workload goals, budget, and region requirements.",
lang,
),
"url": canonical_url,
},
{
"@type": "HowToStep",
"position": 2,
"name": _pick_lang("核对评论反馈", "Validate with comments", lang),
"text": _pick_lang(
"结合约 {} 条评论判断观点可靠性与落地风险。".format(comments_val),
"Use around {} comments to validate reliability and delivery risks.".format(comments_val),
lang,
),
"url": "{}#comments-panel".format(canonical_url),
},
{
"@type": "HowToStep",
"position": 3,
"name": _pick_lang("进入价格页筛选方案", "Filter plans on pricing page", lang),
"text": _pick_lang(
"按地区、价格和配置过滤候选 VPS,建立短名单。",
"Filter candidates by region, price, and specs to build a shortlist.",
lang,
),
"url": pricing_url,
},
{
"@type": "HowToStep",
"position": 4,
"name": _pick_lang("补充需求并确认采购", "Publish requirement and finalize", lang),
"text": _pick_lang(
"若信息仍不足,可发布新主题补充业务约束并确认采购方案。",
"If signal is still insufficient, publish a follow-up topic and finalize the buying plan.",
lang,
),
"url": post_new_url,
},
]
return {
"@type": "HowTo",
"@id": "{}#howto".format(canonical_url),
"name": _pick_lang("如何从论坛主题完成 VPS 选型", "How to shortlist VPS from a forum topic", lang),
"description": _pick_lang(
"从阅读帖子到筛选方案再到确认采购的标准流程。",
"A practical workflow from reading a discussion to shortlisting and procurement.",
lang,
),
"inLanguage": "en-US" if lang == "en" else "zh-CN",
"totalTime": "PT{}M".format(max(3, read_val + 2)),
"step": steps,
}
def admin_required(f):
from functools import wraps
@wraps(f)
def wrapped(*args, **kwargs):
if not session.get("admin_logged_in"):
return redirect(url_for("admin_login"))
return f(*args, **kwargs)
return wrapped
def user_login_required(f):
from functools import wraps
@wraps(f)
def wrapped(*args, **kwargs):
user = _get_current_user()
if not user:
return redirect(url_for("user_login", next=request.path))
if _is_banned_user(user):
session.pop("user_id", None)
return redirect(url_for("user_login", next=request.path, error=_user_ban_message(user)))
return f(*args, **kwargs)
return wrapped
def _ensure_forum_interaction_user(user, post_id=None):
"""校验当前登录用户是否可进行论坛互动动作。"""
if not _is_banned_user(user):
return None
text = _user_ban_message(user)
if post_id:
return _forum_redirect_with_error(post_id, text)
return redirect(url_for("forum_index", error=text))
def _can_edit_post(user, post):
if not user or not post:
return False
return post.user_id == user.id
def _can_edit_comment(user, comment):
if not user or not comment:
return False
return comment.user_id == user.id
def _forum_redirect_with_error(post_id, text_msg):
return redirect(url_for("forum_post_detail", post_id=post_id, error=text_msg))
def _forum_redirect_with_msg(post_id, text_msg):
return redirect(url_for("forum_post_detail", post_id=post_id, msg=text_msg))
# 首页多语言文案(中文 / English)
I18N = {
"zh": {
"meta_title": "全球 VPS 价格与配置对比 | 云价眼",
"meta_description": "面向技术与采购团队的云服务器价格情报平台:统一对比主流厂商 VPS 月付价格、配置与区域,支持快速筛选并直达官方购买页。",
"meta_keywords": "VPS价格对比,云服务器采购,云主机报价,云厂商比价,企业云成本,阿里云腾讯云DigitalOceanVultr",
"og_title": "云价眼 | 全球 VPS 价格与配置决策台",
"og_description": "为团队采购与技术选型提供可比价的云服务器数据视图,快速定位成本与性能平衡点。",
"og_locale": "zh_CN",
"schema_webapp_description": "面向团队采购与技术选型的 VPS 价格与配置对比平台。",
"schema_table_about": "云价眼 - 全球 VPS 价格与配置决策台",
"schema_table_name": "VPS 价格与配置对比表",
"schema_table_description": "主流云厂商 VPS 方案的配置、区域与月付价格数据",
"tagline": "面向团队采购的云服务器价格情报",
"hero_kicker": "企业云资源采购情报",
"hero_title": "全球 VPS 价格与配置决策台",
"hero_lede": "聚合主流云厂商公开报价,统一月付口径与配置维度,帮助技术与采购团队更快完成方案筛选与预算评估。",
"hero_trust_1": "主流云厂商持续收录",
"hero_trust_2": "统一月付与配置口径",
"hero_trust_3": "直达官方购买与文档",
"metric_total_plans": "可比较方案",
"metric_providers": "覆盖厂商",
"metric_regions": "覆盖区域",
"metric_lowest": "筛选后最低月价",
"filters_title": "采购筛选控制台",
"filters_subtitle": "按厂商、区域、资源规格与预算快速收敛候选方案。",
"table_caption": "价格与配置根据筛选条件实时刷新,用于初步比选与预算评估。",
"filter_provider": "供应商",
"filter_region": "区域市场",
"filter_memory": "内存 ≥",
"filter_price": "价格区间",
"filter_currency": "计价货币",
"search_placeholder": "搜索供应商、方案或区域...",
"all": "全部",
"unlimited": "不限",
"btn_reset": "清空筛选",
"btn_visit": "查看官网",
"th_provider": "供应商",
"th_country": "区域",
"th_config": "实例规格",
"th_vcpu": "vCPU",
"th_memory": "内存",
"th_storage": "存储",
"th_bandwidth": "带宽",
"th_traffic": "流量",
"th_price": "月付参考价",
"th_action": "官方链接",
"disclaimer": "* 数据来自公开页面与规则换算,可能存在时差或促销偏差;下单前请以厂商官网实时价格与条款为准。",
"footer_note": "仅作采购调研参考 · 请以各云厂商官网实时价格为准",
"contact_label": "联系我们",
"empty_state": "未找到匹配的方案",
"load_error": "数据加载失败,请刷新页面重试",
"search_label": "关键词检索",
"result_count_pattern": "当前筛选:{visible} / {total} 个方案",
"price_under50": "< ¥50",
"price_50_100": "¥50-100",
"price_100_300": "¥100-300",
"price_300_500": "¥300-500",
"price_over500": "> ¥500",
"cny": "人民币 (¥)",
"usd": "美元 ($)",
"no_js_note": "已显示基础数据表;开启 JavaScript 后可使用实时筛选、排序和动态统计。",
"faq_title": "常见问题(采购前必看)",
"faq_intro": "以下信息用于预算与方案初筛,正式采购前请再次核对厂商官网。",
"faq_q1": "价格和配置数据多久更新一次?",
"faq_a1": "平台持续维护公开报价源,后台更新后会同步刷新展示与 API 缓存。",
"faq_q2": "表格价格能直接作为合同报价吗?",
"faq_a2": "不能。页面数据用于调研与比选,实际价格、账单周期与折扣条款请以厂商官网和销售合同为准。",
"faq_q3": "如何快速筛选适合企业业务的方案?",
"faq_a3": "建议先按区域和预算过滤,再结合 vCPU、内存、存储和带宽指标缩小候选范围,最后进入厂商官网确认 SLA 与网络质量。",
"cta_title": "需要更深度的采购建议?",
"cta_lede": "在社区论坛提交需求场景,或直接联系站点维护者获取更新建议。",
"cta_primary": "进入社区论坛",
"cta_secondary": "联系维护者",
},
"en": {
"meta_title": "Global VPS Pricing & Configuration Comparison | VPS Price",
"meta_description": "Pricing intelligence for engineering and procurement teams: compare VPS monthly costs, specs, and regions across major providers with normalized criteria.",
"meta_keywords": "VPS pricing comparison,cloud server procurement,provider pricing benchmark,cloud cost planning,infrastructure buying",
"og_title": "VPS Price | Global VPS Pricing Decision Console",
"og_description": "A procurement-ready view of VPS pricing and specs across major providers for faster, more confident infrastructure decisions.",
"og_locale": "en_US",
"schema_webapp_description": "A pricing and configuration comparison platform for VPS procurement and technical planning.",
"schema_table_about": "VPS Price - Global VPS Pricing Decision Console",
"schema_table_name": "VPS Pricing and Configuration Table",
"schema_table_description": "Comparable monthly pricing, specs, and region data across mainstream VPS providers",
"tagline": "Cloud pricing intelligence for engineering and procurement teams",
"hero_kicker": "Enterprise Infrastructure Intelligence",
"hero_title": "Global VPS Pricing Decision Console",
"hero_lede": "Aggregate public VPS offers, normalize monthly pricing and specs, and help engineering and procurement teams shortlist options faster.",
"hero_trust_1": "Major providers continuously tracked",
"hero_trust_2": "Normalized monthly pricing and specs",
"hero_trust_3": "Direct links to official purchase pages",
"metric_total_plans": "Comparable Plans",
"metric_providers": "Providers Covered",
"metric_regions": "Regions Covered",
"metric_lowest": "Lowest Monthly Price",
"filters_title": "Procurement Filter Console",
"filters_subtitle": "Narrow candidates by provider, region, resource profile, and budget range.",
"table_caption": "Pricing and specs refresh in real time based on active filters for quicker shortlist decisions.",
"filter_provider": "Provider",
"filter_region": "Region",
"filter_memory": "Memory ≥",
"filter_price": "Price range",
"filter_currency": "Currency",
"search_placeholder": "Search provider, plan, or region...",
"all": "All",
"unlimited": "Any",
"btn_reset": "Clear filters",
"btn_visit": "Visit Site",
"th_provider": "Provider",
"th_country": "Region",
"th_config": "Plan Spec",
"th_vcpu": "vCPU",
"th_memory": "Memory",
"th_storage": "Storage",
"th_bandwidth": "Bandwidth",
"th_traffic": "Traffic",
"th_price": "Monthly Price",
"th_action": "Official Link",
"disclaimer": "* Data is compiled from public sources and normalization rules. Final billing terms and live pricing are determined by each provider.",
"footer_note": "For research and shortlisting only. Always verify latest pricing on official provider websites.",
"contact_label": "Contact",
"empty_state": "No matching plans found",
"load_error": "Failed to load data. Please refresh.",
"search_label": "Keyword Search",
"result_count_pattern": "Showing {visible} of {total} plans",
"price_under50": "< 50",
"price_50_100": "50-100",
"price_100_300": "100-300",
"price_300_500": "300-500",
"price_over500": "> 500",
"cny": "CNY (¥)",
"usd": "USD ($)",
"no_js_note": "Base table data is already visible. Enable JavaScript for live filters, sorting, and dynamic metrics.",
"faq_title": "FAQ for Procurement Teams",
"faq_intro": "Use these answers for shortlisting. Re-check vendor websites before placing orders.",
"faq_q1": "How often are pricing and spec records updated?",
"faq_a1": "The platform continuously maintains public pricing sources. Admin updates refresh both page rendering and API cache.",
"faq_q2": "Can listed prices be treated as final contract quotes?",
"faq_a2": "No. This site is for research and shortlisting. Final pricing, billing cycles, and discounts are defined by each provider and contract.",
"faq_q3": "How should we shortlist plans for business workloads?",
"faq_a3": "Start with region and budget filters, then narrow by vCPU, memory, storage, and bandwidth. Validate SLA and network quality on the provider site.",
"cta_title": "Need Deeper Buying Guidance?",
"cta_lede": "Post your workload requirements in the community forum or contact the site maintainer directly.",
"cta_primary": "Open Community Forum",
"cta_secondary": "Contact Maintainer",
},
}
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
def _build_home_faq_items(t):
return [
{"question": t["faq_q1"], "answer": t["faq_a1"]},
{"question": t["faq_q2"], "answer": t["faq_a2"]},
{"question": t["faq_q3"], "answer": t["faq_a3"]},
]
def _build_home_schema(lang, t, canonical_url, plans_data, faq_items):
in_language = "en-US" if lang == "en" else "zh-CN"
site_root = _site_root_url()
logo_url = _absolute_url_for("static", filename="img/site-logo.svg")
og_image_url = _absolute_url_for("static", filename="img/site-logo-mark.svg")
item_list = []
for idx, plan in enumerate(plans_data[:30], start=1):
provider_name = (plan.get("provider") or "").strip()
plan_name = (plan.get("name") or "").strip()
product_name = "{} {}".format(provider_name, plan_name).strip() or "VPS Plan {}".format(idx)
product = {
"@type": "Product",
"name": product_name,
"brand": {"@type": "Brand", "name": provider_name or SITE_NAME},
}
region_name = (plan.get("countries") or "").strip()
if region_name:
product["category"] = region_name
official_url = (plan.get("official_url") or "").strip()
if official_url:
product["url"] = official_url
offer = {"@type": "Offer", "url": official_url or canonical_url}
if plan.get("price_cny") is not None:
offer["price"] = float(plan["price_cny"])
offer["priceCurrency"] = "CNY"
elif plan.get("price_usd") is not None:
offer["price"] = float(plan["price_usd"])
offer["priceCurrency"] = "USD"
if "price" in offer:
product["offers"] = offer
item_list.append({
"@type": "ListItem",
"position": idx,
"item": product,
})
faq_entities = [
{
"@type": "Question",
"name": item["question"],
"acceptedAnswer": {"@type": "Answer", "text": item["answer"]},
}
for item in faq_items
]
return {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "{}#org".format(site_root),
"name": SITE_NAME,
"url": site_root,
"logo": logo_url,
},
{
"@type": "WebSite",
"@id": "{}#website".format(site_root),
"url": site_root,
"name": SITE_NAME,
"inLanguage": in_language,
},
{
"@type": "WebPage",
"@id": "{}#home".format(canonical_url),
"url": canonical_url,
"name": t["meta_title"],
"description": t["meta_description"],
"inLanguage": in_language,
"primaryImageOfPage": og_image_url,
},
{
"@type": "ItemList",
"name": t["schema_table_name"],
"description": t["schema_table_description"],
"itemListElement": item_list,
},
{
"@type": "FAQPage",
"mainEntity": faq_entities,
},
],
}
@app.route("/")
def index():
lang = _get_lang()
t = I18N[lang]
plans = _query_plans_for_display()
plans_data = [p.to_dict() for p in plans]
canonical_url = _public_url("index", lang=lang)
alternate_links = _alternate_lang_links("index")
faq_items = _build_home_faq_items(t)
seo = {
"title": t["meta_title"],
"description": t["meta_description"],
"keywords": t["meta_keywords"],
"canonical_url": canonical_url,
"robots": "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
"og_type": "website",
"og_url": canonical_url,
"og_title": t["og_title"],
"og_description": t["og_description"],
"og_locale": t["og_locale"],
"og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"),
"twitter_card": "summary_large_image",
"twitter_title": t["og_title"],
"twitter_description": t["og_description"],
"alternate_links": alternate_links,
}
home_schema = _build_home_schema(
lang=lang,
t=t,
canonical_url=canonical_url,
plans_data=plans_data,
faq_items=faq_items,
)
return render_template(
"index.html",
site_url=_site_root_url(),
site_name=SITE_NAME,
initial_plans_json=plans_data,
faq_items=faq_items,
seo=seo,
seo_schema=home_schema,
lang=lang,
t=t,
)
@app.route("/assets/")
def legacy_assets(filename):
"""
兼容历史内容中的 /assets/* 链接:
- 若 static/assets 下存在目标文件则直接返回
- 否则回退到站点标识图,避免前端出现 404 噪音
"""
assets_dir = os.path.join(app.static_folder or "", "assets")
candidate = os.path.normpath(os.path.join(assets_dir, filename))
assets_dir_abs = os.path.abspath(assets_dir)
candidate_abs = os.path.abspath(candidate)
if candidate_abs.startswith(assets_dir_abs + os.sep) and os.path.isfile(candidate_abs):
rel_path = os.path.relpath(candidate_abs, assets_dir_abs)
return send_from_directory(assets_dir_abs, rel_path)
return redirect(url_for("static", filename="img/site-logo-mark.svg"), code=302)
@app.route("/api/plans")
def api_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
@app.route("/api/event/track", methods=["POST"])
def api_event_track():
payload = {}
if request.is_json:
payload = request.get_json(silent=True) or {}
if not payload:
payload = request.form.to_dict(flat=True)
event_name = (payload.get("event_name") or "").strip().lower()
if not re.match(r"^[a-z0-9_]{3,64}$", event_name or ""):
return ("", 204)
whitelist = {
"post_detail_cta_pricing",
"post_detail_cta_new_topic",
"post_detail_jump_comments",
"post_detail_related_click",
"post_detail_plan_click",
"post_detail_comment_submit",
"post_detail_sidebar_compare",
"post_detail_resource_click",
}
if event_name not in whitelist:
return ("", 204)
label = " ".join((payload.get("label") or "").strip().split())[:120]
page_path = " ".join((payload.get("page_path") or "").strip().split())[:255]
post_id = payload.get("post_id")
try:
post_id = int(post_id) if post_id is not None else None
except Exception:
post_id = None
if not page_path:
referer = (request.headers.get("Referer") or "").strip()
page_path = referer[:255]
user = _get_current_user()
event_data = {
"event_name": event_name,
"label": label,
"post_id": post_id,
"user_id": user.id if user else None,
"page_path": page_path,
"endpoint_path": request.path,
"referer": (request.headers.get("Referer") or "")[:255],
"ip": (request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120],
"at": _iso8601_utc(datetime.now(timezone.utc)),
}
app.logger.info("forum_track_event %s", json.dumps(event_data, ensure_ascii=False))
return ("", 204)
# ---------- 前台用户与论坛 ----------
@app.route("/register", methods=["GET", "POST"])
def user_register():
lang = _get_lang()
current = _get_current_user()
if current:
if _is_banned_user(current):
session.pop("user_id", None)
else:
return redirect(url_for("forum_index"))
error = None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username):
error = _pick_lang(
"用户名需为 3-20 位,仅支持字母、数字、下划线",
"Username must be 3-20 chars (letters, numbers, underscore).",
lang,
)
elif len(password) < 6:
error = _pick_lang("密码至少 6 位", "Password must be at least 6 characters.", lang)
elif password != confirm_password:
error = _pick_lang("两次输入的密码不一致", "Passwords do not match.", lang)
elif User.query.filter(func.lower(User.username) == username.lower()).first():
error = _pick_lang("用户名已存在", "Username already exists.", lang)
else:
user = User(username=username)
user.set_password(password)
user.last_login_at = datetime.now(timezone.utc)
db.session.add(user)
db.session.commit()
session["user_id"] = user.id
return redirect(_safe_next_url("forum_index"))
return render_template("auth/register.html", error=error)
@app.route("/login", methods=["GET", "POST"])
def user_login():
lang = _get_lang()
current = _get_current_user()
if current:
if _is_banned_user(current):
session.pop("user_id", None)
else:
return redirect(url_for("forum_index"))
error = (request.args.get("error") or "").strip() or None
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user = User.query.filter(func.lower(User.username) == username.lower()).first()
if not user or not user.check_password(password):
error = _pick_lang("用户名或密码错误", "Invalid username or password.", lang)
elif _is_banned_user(user):
error = _user_ban_message(user)
else:
user.last_login_at = datetime.now(timezone.utc)
db.session.commit()
session["user_id"] = user.id
return redirect(_safe_next_url("forum_index"))
return render_template("auth/login.html", error=error)
@app.route("/logout")
def user_logout():
session.pop("user_id", None)
return redirect(url_for("forum_index"))
@app.route("/profile")
def user_profile_redirect():
return redirect(url_for("user_profile"))
@app.route("/me", methods=["GET", "POST"])
@user_login_required
def user_profile():
lang = _get_lang()
user = _get_current_user()
tab = (request.args.get("tab") or "posts").strip().lower()
if tab not in {"posts", "comments", "likes", "bookmarks", "settings"}:
tab = "posts"
if request.method == "POST":
action = (request.form.get("action") or "").strip().lower()
if action == "profile":
username = (request.form.get("username") or "").strip()
if username == user.username:
return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("资料未变更", "No changes detected.", lang)))
if not _is_valid_username(username):
return redirect(url_for(
"user_profile",
tab="settings",
error=_pick_lang(
"用户名需为 3-20 位,仅支持字母、数字、下划线",
"Username must be 3-20 chars (letters, numbers, underscore).",
lang,
),
))
exists = (
User.query
.filter(func.lower(User.username) == username.lower(), User.id != user.id)
.first()
)
if exists:
return redirect(url_for("user_profile", tab="settings", error=_pick_lang("用户名已存在", "Username already exists.", lang)))
user.username = username
db.session.commit()
return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("用户名已更新", "Username updated.", lang)))
if action == "password":
current_password = request.form.get("current_password") or ""
new_password = request.form.get("new_password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not user.check_password(current_password):
return redirect(url_for("user_profile", tab="settings", error=_pick_lang("当前密码错误", "Current password is incorrect.", lang)))
if len(new_password) < 6:
return redirect(url_for("user_profile", tab="settings", error=_pick_lang("新密码至少 6 位", "New password must be at least 6 characters.", lang)))
if new_password != confirm_password:
return redirect(url_for("user_profile", tab="settings", error=_pick_lang("两次新密码输入不一致", "New passwords do not match.", lang)))
user.set_password(new_password)
db.session.commit()
return redirect(url_for("user_profile", tab="settings", msg=_pick_lang("密码已更新", "Password updated.", lang)))
return redirect(url_for("user_profile", tab="settings", error=_pick_lang("未知操作", "Unknown action.", lang)))
my_post_rows = (
_query_forum_post_rows(active_tab="latest", author_id=user.id)
.limit(60)
.all()
)
my_post_cards = _build_forum_post_cards(my_post_rows, lang=lang)
my_comment_rows = (
db.session.query(
ForumComment,
ForumPost.id.label("post_id"),
ForumPost.title.label("post_title"),
)
.join(ForumPost, ForumComment.post_id == ForumPost.id)
.filter(ForumComment.user_id == user.id)
.order_by(ForumComment.created_at.desc(), ForumComment.id.desc())
.limit(120)
.all()
)
my_comment_items = [
{
"comment": c,
"post_id": post_id,
"post_title": post_title,
}
for c, post_id, post_title in my_comment_rows
]
my_like_rows = (
db.session.query(
ForumPostLike,
ForumPost.id.label("post_id"),
ForumPost.title.label("post_title"),
ForumPost.category.label("post_category"),
ForumPost.created_at.label("post_created_at"),
)
.join(ForumPost, ForumPostLike.post_id == ForumPost.id)
.filter(ForumPostLike.user_id == user.id)
.order_by(ForumPostLike.created_at.desc(), ForumPostLike.id.desc())
.limit(120)
.all()
)
my_like_items = [
{
"like": like_row,
"post_id": post_id,
"post_title": post_title,
"post_category": post_category,
"post_created_at": post_created_at,
}
for like_row, post_id, post_title, post_category, post_created_at in my_like_rows
]
my_bookmark_rows = (
db.session.query(
ForumPostBookmark,
ForumPost.id.label("post_id"),
ForumPost.title.label("post_title"),
ForumPost.category.label("post_category"),
ForumPost.created_at.label("post_created_at"),
)
.join(ForumPost, ForumPostBookmark.post_id == ForumPost.id)
.filter(ForumPostBookmark.user_id == user.id)
.order_by(ForumPostBookmark.created_at.desc(), ForumPostBookmark.id.desc())
.limit(120)
.all()
)
my_bookmark_items = [
{
"bookmark": bookmark_row,
"post_id": post_id,
"post_title": post_title,
"post_category": post_category,
"post_created_at": post_created_at,
}
for bookmark_row, post_id, post_title, post_category, post_created_at in my_bookmark_rows
]
stats = {
"post_count": ForumPost.query.filter_by(user_id=user.id).count(),
"comment_count": ForumComment.query.filter_by(user_id=user.id).count(),
"like_count": ForumPostLike.query.filter_by(user_id=user.id).count(),
"bookmark_count": ForumPostBookmark.query.filter_by(user_id=user.id).count(),
"report_count": ForumReport.query.filter_by(reporter_id=user.id).count(),
"pending_report_count": ForumReport.query.filter_by(reporter_id=user.id, status="pending").count(),
"notification_count": ForumNotification.query.filter_by(user_id=user.id).count(),
"unread_notification_count": ForumNotification.query.filter_by(user_id=user.id, is_read=False).count(),
}
return render_template(
"forum/profile.html",
profile_user=user,
active_tab=tab,
my_post_cards=my_post_cards,
my_comment_items=my_comment_items,
my_like_items=my_like_items,
my_bookmark_items=my_bookmark_items,
stats=stats,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
)
@app.route("/notifications")
@user_login_required
def user_notifications():
lang = _get_lang()
user = _get_current_user()
status = (request.args.get("status") or "all").strip().lower()
if status not in {"all", "unread", "read"}:
status = "all"
q = (
ForumNotification.query
.filter_by(user_id=user.id)
.options(joinedload(ForumNotification.actor_rel))
)
if status == "unread":
q = q.filter_by(is_read=False)
elif status == "read":
q = q.filter_by(is_read=True)
rows = q.order_by(ForumNotification.created_at.desc(), ForumNotification.id.desc()).limit(300).all()
items = []
for n in rows:
items.append({
"notification": n,
"type_label": _notification_type_label(n.notif_type, lang=lang),
"actor_name": n.actor_rel.username if n.actor_rel else "",
"target_url": _notification_target_url(n),
"time_text": _humanize_time(n.created_at, lang=lang),
})
status_rows = (
db.session.query(ForumNotification.is_read, func.count(ForumNotification.id))
.filter_by(user_id=user.id)
.group_by(ForumNotification.is_read)
.all()
)
read_count = 0
unread_count = 0
for is_read, count_val in status_rows:
if bool(is_read):
read_count = int(count_val or 0)
else:
unread_count = int(count_val or 0)
return render_template(
"forum/notifications.html",
active_status=status,
notification_items=items,
unread_count=unread_count,
read_count=read_count,
total_count=unread_count + read_count,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
)
@app.route("/notification//go")
@user_login_required
def user_notification_go(notification_id):
lang = _get_lang()
user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.id:
return redirect(url_for("user_notifications", error=_pick_lang("无权访问该通知", "Permission denied for this notification.", lang)))
if not n.is_read:
n.is_read = True
db.session.commit()
return redirect(_notification_target_url(n))
@app.route("/notification//read", methods=["POST"])
@user_login_required
def user_notification_read(notification_id):
lang = _get_lang()
user = _get_current_user()
n = ForumNotification.query.get_or_404(notification_id)
if n.user_id != user.id:
return redirect(url_for("user_notifications", error=_pick_lang("无权操作该通知", "Permission denied for this notification.", lang)))
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)
return redirect(url_for("user_notifications", msg=_pick_lang("已标记为已读", "Marked as read.", lang)))
@app.route("/notifications/read-all", methods=["POST"])
@user_login_required
def user_notifications_read_all():
lang = _get_lang()
user = _get_current_user()
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))
@app.route("/forum")
def forum_index():
lang = _get_lang()
per_page_options = [10, 20, 30, 50]
active_tab = (request.args.get("tab") or "latest").strip().lower()
if active_tab not in {"latest", "new", "hot"}:
active_tab = "latest"
selected_category = (request.args.get("category") or "").strip() or None
if selected_category and len(selected_category) > 32:
selected_category = selected_category[:32]
search_query = (request.args.get("q") or "").strip()
if len(search_query) > 80:
search_query = search_query[:80]
page = request.args.get("page", type=int) or 1
if page < 1:
page = 1
per_page = request.args.get("per_page", type=int) or 20
if per_page not in per_page_options:
per_page = 20
rows_query = _query_forum_post_rows(
active_tab=active_tab,
selected_category=selected_category,
search_query=search_query or None,
)
total_posts = _count_forum_posts(
selected_category=selected_category,
search_query=search_query or None,
)
total_pages = max((total_posts + per_page - 1) // per_page, 1)
if page > total_pages:
page = total_pages
rows = rows_query.offset((page - 1) * per_page).limit(per_page).all()
post_cards = _build_forum_post_cards(rows, lang=lang)
sidebar = _forum_sidebar_data()
category_count_map = {name: int(count or 0) for name, count in (sidebar.get("category_counts") or [])}
category_names = list(_get_forum_category_names(active_only=True))
for name in category_count_map.keys():
if name and name not in category_names:
category_names.append(name)
if selected_category and selected_category not in category_names:
category_names.insert(0, selected_category)
tab_defs = [
("latest", _pick_lang("最新", "Latest", lang)),
("new", _pick_lang("新帖", "New", lang)),
("hot", _pick_lang("热门", "Top", lang)),
]
tab_links = [
{
"key": key,
"label": label,
"url": _build_forum_url(
tab=key,
category=selected_category,
q=search_query or None,
page=1,
per_page=per_page,
),
"active": active_tab == key,
}
for key, label in tab_defs
]
category_links = [
{
"name": _pick_lang("全部", "All", lang),
"count": None,
"url": _build_forum_url(
tab=active_tab,
category=None,
q=search_query or None,
page=1,
per_page=per_page,
),
"active": selected_category is None,
}
]
for name in category_names:
category_links.append({
"name": name,
"count": category_count_map.get(name, 0),
"url": _build_forum_url(
tab=active_tab,
category=name,
q=search_query or None,
page=1,
per_page=per_page,
),
"active": selected_category == name,
})
category_nav_url = _build_forum_url(
tab=active_tab,
category=selected_category or (category_names[0] if category_names else None),
q=search_query or None,
page=1,
per_page=per_page,
)
window_start = max(1, page - 2)
window_end = min(total_pages, page + 2)
page_links = [
{
"num": num,
"url": _build_forum_url(
tab=active_tab,
category=selected_category,
q=search_query or None,
page=num,
per_page=per_page,
),
"active": num == page,
}
for num in range(window_start, window_end + 1)
]
has_filters = bool(selected_category or search_query or active_tab != "latest")
if search_query and selected_category:
empty_hint = _pick_lang("当前分类下没有匹配关键词的帖子。", "No posts match your keywords in this category.", lang)
elif search_query:
empty_hint = _pick_lang("没有匹配关键词的帖子。", "No posts match your keywords.", lang)
elif selected_category:
empty_hint = _pick_lang("该分类暂时没有帖子。", "No posts in this category yet.", lang)
else:
empty_hint = _pick_lang("当前没有帖子,点击右上角按钮发布第一条内容。", "No posts yet. Create the first topic from the top-right button.", lang)
result_start = ((page - 1) * per_page + 1) if total_posts else 0
result_end = min(page * per_page, total_posts) if total_posts else 0
canonical_params = {
"tab": active_tab if active_tab != "latest" else None,
"category": selected_category,
"page": page if page > 1 else None,
}
canonical_url = _public_url("forum_index", lang=lang, **canonical_params)
alternate_links = _alternate_lang_links("forum_index", **canonical_params)
prev_canonical_url = None
next_canonical_url = None
if page > 1:
prev_params = dict(canonical_params)
prev_page = page - 1
prev_params["page"] = prev_page if prev_page > 1 else None
prev_canonical_url = _public_url("forum_index", lang=lang, **prev_params)
if page < total_pages:
next_params = dict(canonical_params)
next_params["page"] = page + 1
next_canonical_url = _public_url("forum_index", lang=lang, **next_params)
if selected_category:
forum_title = _pick_lang(
"{} 讨论区 | 云价眼论坛".format(selected_category),
"{} Discussions | VPS Forum".format(selected_category),
lang,
)
forum_heading = _pick_lang(
"{} · 论坛分类".format(selected_category),
"{} · Forum Category".format(selected_category),
lang,
)
else:
forum_title = _pick_lang("VPS 社区论坛 | 云价眼", "VPS Community Forum | VPS Price", lang)
forum_heading = _pick_lang("VPS 社区论坛", "VPS Community Forum", lang)
if page > 1:
forum_title = "{} - {}".format(
forum_title,
_pick_lang("第 {} 页".format(page), "Page {}".format(page), lang),
)
if search_query:
forum_description = _pick_lang(
"论坛搜索结果:{}。该页面主要用于站内检索。".format(search_query),
"Forum search results for '{}'. This page is intended for on-site search.".format(search_query),
lang,
)
forum_intro = _pick_lang(
"搜索词:{}。建议进一步按分类或标签缩小结果范围。".format(search_query),
"Search query: '{}'. Narrow down with categories or topic tags for better results.".format(search_query),
lang,
)
elif selected_category:
forum_description = _forum_category_description(selected_category, lang)
forum_intro = forum_description
else:
forum_description = _forum_category_description(None, lang)
tab_intro_map = {
"latest": _pick_lang(
"按最新活跃度浏览主题,快速跟进持续更新的讨论。",
"Browse by latest activity to track ongoing discussions.",
lang,
),
"new": _pick_lang(
"查看最近发布的新主题,及时参与新话题。",
"See newly published topics and join early conversations.",
lang,
),
"hot": _pick_lang(
"按热度排序,优先阅读高互动的热门讨论。",
"Sorted by engagement to surface high-signal discussions.",
lang,
),
}
forum_intro = tab_intro_map.get(active_tab) or forum_description
noindex_listing = bool(search_query or per_page != 20)
forum_feed_url = _public_url("forum_feed", lang=lang)
seo = {
"title": forum_title,
"description": forum_description,
"keywords": _forum_index_keywords(lang, active_tab=active_tab, selected_category=selected_category),
"canonical_url": canonical_url,
"prev_canonical_url": prev_canonical_url,
"next_canonical_url": next_canonical_url,
"robots": "noindex,follow" if noindex_listing else "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1",
"og_type": "website",
"og_url": canonical_url,
"og_title": forum_title,
"og_description": forum_description,
"og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"),
"twitter_card": "summary_large_image",
"twitter_title": forum_title,
"twitter_description": forum_description,
"alternate_links": alternate_links,
"feed_url": forum_feed_url,
}
list_items = []
latest_activity_at = None
for idx, card in enumerate(post_cards, start=1):
post_obj = card.get("post")
if not post_obj:
continue
post_url = _public_url("forum_post_detail", lang=lang, post_id=post_obj.id)
list_items.append({
"@type": "ListItem",
"position": idx,
"name": post_obj.title,
"url": post_url,
})
activity_at = card.get("latest_activity") or post_obj.updated_at or post_obj.created_at
if activity_at and (latest_activity_at is None or activity_at > latest_activity_at):
latest_activity_at = activity_at
breadcrumb_schema = _forum_breadcrumb_schema(lang=lang, selected_category=selected_category)
breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url)
collection_schema = {
"@type": "CollectionPage",
"@id": "{}#collection".format(canonical_url),
"name": forum_title,
"description": forum_description,
"url": canonical_url,
"inLanguage": "en-US" if lang == "en" else "zh-CN",
"breadcrumb": {"@id": breadcrumb_schema["@id"]},
"isPartOf": {"@type": "WebSite", "name": SITE_NAME, "url": _site_root_url()},
}
if latest_activity_at:
collection_schema["dateModified"] = _iso8601_utc(latest_activity_at)
if not search_query:
collection_schema["potentialAction"] = {
"@type": "SearchAction",
"target": "{}?q={{q}}".format(_public_url("forum_index", lang=lang)),
"query-input": "required name=q",
}
seo_graph = [collection_schema, breadcrumb_schema]
if list_items:
item_list_schema = {
"@type": "ItemList",
"@id": "{}#items".format(canonical_url),
"itemListElement": list_items,
}
collection_schema["mainEntity"] = {"@id": item_list_schema["@id"]}
seo_graph.append(item_list_schema)
seo_schema = {
"@context": "https://schema.org",
"@graph": seo_graph,
}
return render_template(
"forum/index.html",
post_cards=post_cards,
sidebar=sidebar,
active_tab=active_tab,
selected_category=selected_category,
search_query=search_query,
tab_links=tab_links,
category_links=category_links,
category_nav_url=category_nav_url,
total_posts=total_posts,
total_pages=total_pages,
current_page=page,
page_links=page_links,
has_prev=(page > 1),
has_next=(page < total_pages),
prev_page_url=_build_forum_url(
tab=active_tab,
category=selected_category,
q=search_query or None,
page=page - 1,
per_page=per_page,
),
next_page_url=_build_forum_url(
tab=active_tab,
category=selected_category,
q=search_query or None,
page=page + 1,
per_page=per_page,
),
clear_search_url=_build_forum_url(
tab=active_tab,
category=selected_category,
q=None,
page=1,
per_page=per_page,
),
clear_all_url=_build_forum_url(tab="latest", category=None, q=None, page=1, per_page=per_page),
has_filters=has_filters,
empty_hint=empty_hint,
result_start=result_start,
result_end=result_end,
per_page=per_page,
per_page_options=per_page_options,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
forum_heading=forum_heading,
forum_intro=forum_intro,
forum_feed_url=forum_feed_url,
seo=seo,
seo_schema=seo_schema,
)
@app.route("/forum/post/new", methods=["GET", "POST"])
@user_login_required
def forum_post_new():
lang = _get_lang()
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user)
if blocked_resp:
return blocked_resp
error = None
title = ""
content = ""
available_categories = _get_forum_category_names(active_only=True)
category = available_categories[0] if available_categories else "综合讨论"
if request.method == "POST":
title = (request.form.get("title") or "").strip()
content = (request.form.get("content") or "").strip()
category = (request.form.get("category") or "").strip() or category
if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5:
error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160:
error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10:
error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else:
post = ForumPost(
user_id=user.id,
category=category,
title=title,
content=content,
)
db.session.add(post)
db.session.commit()
return redirect(url_for("forum_post_detail", post_id=post.id))
return render_template(
"forum/post_form.html",
error=error,
title_val=title,
content_val=content,
category_val=category,
categories=available_categories,
page_title=_pick_lang("创建新主题", "Create Topic", lang),
submit_text=_pick_lang("发布主题", "Publish", lang),
action_url=url_for("forum_post_new"),
cancel_url=url_for("forum_index"),
form_mode="create",
)
@app.route("/forum/post//edit", methods=["GET", "POST"])
@user_login_required
def forum_post_edit(post_id):
lang = _get_lang()
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
if blocked_resp:
return blocked_resp
if not _can_edit_post(user, post):
return _forum_redirect_with_error(post.id, "你没有权限编辑该帖子")
error = None
title = post.title or ""
content = post.content or ""
available_categories = _get_forum_category_names(active_only=True)
if post.category and post.category not in available_categories:
available_categories.insert(0, post.category)
category = post.category or (available_categories[0] if available_categories else "综合讨论")
if request.method == "POST":
title = (request.form.get("title") or "").strip()
content = (request.form.get("content") or "").strip()
category = (request.form.get("category") or "").strip() or category
if category not in available_categories:
category = available_categories[0] if available_categories else "综合讨论"
if len(title) < 5:
error = _pick_lang("标题至少 5 个字符", "Title must be at least 5 characters.", lang)
elif len(title) > 160:
error = _pick_lang("标题不能超过 160 个字符", "Title must be 160 characters or fewer.", lang)
elif len(content) < 10:
error = _pick_lang("内容至少 10 个字符", "Content must be at least 10 characters.", lang)
else:
post.title = title
post.content = content
post.category = category
db.session.commit()
return _forum_redirect_with_msg(post.id, "帖子已更新")
return render_template(
"forum/post_form.html",
error=error,
title_val=title,
content_val=content,
category_val=category,
categories=available_categories,
page_title=_pick_lang("编辑主题", "Edit Topic", lang),
submit_text=_pick_lang("保存修改", "Save Changes", lang),
action_url=url_for("forum_post_edit", post_id=post.id),
cancel_url=url_for("forum_post_detail", post_id=post.id),
form_mode="edit",
)
@app.route("/forum/post//delete", methods=["POST"])
@user_login_required
def forum_post_delete(post_id):
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
if blocked_resp:
return blocked_resp
if not _can_edit_post(user, post):
return _forum_redirect_with_error(post.id, "你没有权限删除该帖子")
db.session.delete(post)
db.session.commit()
return redirect(url_for("forum_index"))
@app.route("/forum/post/")
def forum_post_detail(post_id):
lang = _get_lang()
post = ForumPost.query.get_or_404(post_id)
comment_per_page = 20
comment_page = request.args.get("cp", type=int) or 1
if comment_page < 1:
comment_page = 1
current_user = _get_current_user()
viewed_posts = session.get("viewed_posts") or []
if post.id not in viewed_posts:
post.view_count = int(post.view_count or 0) + 1
viewed_posts.append(post.id)
session["viewed_posts"] = viewed_posts[-200:]
db.session.commit()
comments_query = (
ForumComment.query
.options(joinedload(ForumComment.author_rel))
.filter_by(post_id=post.id)
.order_by(ForumComment.created_at.asc(), ForumComment.id.asc())
)
comments_count = comments_query.count()
comments_total_pages = max((comments_count + comment_per_page - 1) // comment_per_page, 1)
if comment_page > comments_total_pages:
comment_page = comments_total_pages
comments = (
comments_query
.offset((comment_page - 1) * comment_per_page)
.limit(comment_per_page)
.all()
)
schema_comments = comments
if comment_page > 1:
schema_comments = comments_query.limit(20).all()
like_count = ForumPostLike.query.filter_by(post_id=post.id).count()
bookmark_count = ForumPostBookmark.query.filter_by(post_id=post.id).count()
liked_by_me = False
bookmarked_by_me = False
can_interact = bool(current_user and not _is_banned_user(current_user))
if current_user:
# 一次查询同时得到当前用户是否点赞/收藏,减少请求次数
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()
related_rows = (
_query_forum_post_rows(active_tab="latest", selected_category=post.category or None)
.filter(ForumPost.id != post.id)
.limit(6)
.all()
)
if not related_rows:
related_rows = (
_query_forum_post_rows(active_tab="hot")
.filter(ForumPost.id != post.id)
.limit(6)
.all()
)
related_cards = _build_forum_post_cards(related_rows, lang=lang)
plan_recommendations, plan_reco_context = _build_post_plan_recommendations(
post=post,
lang=lang,
limit=5,
)
canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id)
post_excerpt = _plain_excerpt(post.content or "", limit=170)
if not post_excerpt:
post_excerpt = _pick_lang("论坛主题详情页。", "Discussion topic detail page.", lang)
post_category = post.category or _pick_lang("综合讨论", "General", lang)
post_keywords = ", ".join(dict.fromkeys([
post_category,
_pick_lang("VPS论坛", "VPS forum", lang),
_pick_lang("VPS讨论", "VPS discussion", lang),
_pick_lang("云服务器评测", "cloud server review", lang),
]))
published_time = _iso8601_utc(post.created_at)
modified_time = _iso8601_utc(post.updated_at or post.created_at)
read_minutes = _estimate_reading_minutes(post.content or "", lang=lang)
detail_resource_links = _build_post_resource_links(post=post, lang=lang)
detail_faq_items = _build_post_faq_items(
post=post,
comments_count=comments_count,
read_minutes=read_minutes,
plan_reco_context=plan_reco_context,
lang=lang,
)
comment_page_links = _build_post_comment_page_links(
post_id=post.id,
total_pages=comments_total_pages,
current_page=comment_page,
lang=lang,
)
comment_prev_url = None
comment_next_url = None
comment_prev_canonical_url = None
comment_next_canonical_url = None
if comment_page > 1:
comment_prev_url = _build_post_detail_url(post.id, lang=lang, comment_page=comment_page - 1)
prev_cp = (comment_page - 1) if (comment_page - 1) > 1 else None
comment_prev_canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id, cp=prev_cp)
if comment_page < comments_total_pages:
comment_next_url = _build_post_detail_url(post.id, lang=lang, comment_page=comment_page + 1)
comment_next_canonical_url = _public_url("forum_post_detail", lang=lang, post_id=post.id, cp=comment_page + 1)
query_keys = {str(k or "").strip().lower() for k in request.args.keys()}
query_keys.discard("")
indexable_query_keys = {"lang"}
has_non_canonical_query = any(
(key not in indexable_query_keys) or key.startswith("utm_")
for key in query_keys
)
forum_feed_url = _public_url("forum_feed", lang=lang)
seo_title = _pick_lang(
"{} - 论坛主题 | 云价眼".format(post.title),
"{} - Forum Topic | VPS Price".format(post.title),
lang,
)
seo = {
"title": seo_title,
"description": post_excerpt,
"keywords": post_keywords,
"canonical_url": canonical_url,
"robots": (
"noindex,follow"
if has_non_canonical_query
else "index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1"
),
"prev_canonical_url": comment_prev_canonical_url,
"next_canonical_url": comment_next_canonical_url,
"og_type": "article",
"og_url": canonical_url,
"og_title": seo_title,
"og_description": post_excerpt,
"og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"),
"twitter_card": "summary_large_image",
"twitter_title": seo_title,
"twitter_description": post_excerpt,
"article_published_time": published_time,
"article_modified_time": modified_time,
"article_section": post_category,
"feed_url": forum_feed_url,
"alternate_links": _alternate_lang_links("forum_post_detail", post_id=post.id),
}
author_name = (
post.author_rel.username
if post.author_rel and post.author_rel.username
else _pick_lang("已注销用户", "Deleted user", lang)
)
post_schema = {
"@type": "DiscussionForumPosting",
"@id": "{}#topic".format(canonical_url),
"headline": post.title,
"description": post_excerpt,
"articleSection": post_category,
"keywords": post_keywords,
"mainEntityOfPage": canonical_url,
"url": canonical_url,
"datePublished": published_time,
"dateModified": modified_time,
"author": {"@type": "Person", "name": author_name},
"publisher": {
"@type": "Organization",
"name": SITE_NAME,
"url": _site_root_url(),
"logo": {
"@type": "ImageObject",
"url": _absolute_url_for("static", filename="img/site-logo.svg"),
},
},
"commentCount": comments_count,
"interactionStatistic": [
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/ViewAction",
"userInteractionCount": int(post.view_count or 0),
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/CommentAction",
"userInteractionCount": comments_count,
},
{
"@type": "InteractionCounter",
"interactionType": "https://schema.org/LikeAction",
"userInteractionCount": int(like_count or 0),
},
],
"inLanguage": "en-US" if lang == "en" else "zh-CN",
"isPartOf": {"@type": "WebSite", "name": SITE_NAME, "url": _site_root_url()},
}
comment_entities = []
for c in schema_comments[:20]:
author = c.author_rel.username if c.author_rel and c.author_rel.username else _pick_lang("匿名用户", "Anonymous", lang)
text_excerpt = _plain_excerpt(c.content or "", limit=220)
if not text_excerpt:
continue
comment_item = {
"@type": "Comment",
"text": text_excerpt,
"dateCreated": _iso8601_utc(c.created_at),
"author": {"@type": "Person", "name": author},
}
if c.id:
comment_item["url"] = "{}#comment-{}".format(canonical_url, c.id)
comment_entities.append(comment_item)
if comment_entities:
post_schema["comment"] = comment_entities
breadcrumb_schema = _forum_breadcrumb_schema(
lang=lang,
selected_category=post.category,
post=post,
post_url=canonical_url,
)
breadcrumb_schema["@id"] = "{}#breadcrumb".format(canonical_url)
post_schema["breadcrumb"] = {"@id": breadcrumb_schema["@id"]}
faq_schema = None
if detail_faq_items:
faq_schema = {
"@type": "FAQPage",
"@id": "{}#faq".format(canonical_url),
"inLanguage": "en-US" if lang == "en" else "zh-CN",
"mainEntity": [
{
"@type": "Question",
"name": item.get("question"),
"acceptedAnswer": {
"@type": "Answer",
"text": item.get("answer"),
},
}
for item in detail_faq_items
if item.get("question") and item.get("answer")
],
}
if not faq_schema["mainEntity"]:
faq_schema = None
howto_schema = _build_post_howto_schema(
post=post,
canonical_url=canonical_url,
lang=lang,
read_minutes=read_minutes,
comments_count=comments_count,
)
seo_graph = [post_schema, breadcrumb_schema]
if faq_schema:
seo_graph.append(faq_schema)
if howto_schema:
seo_graph.append(howto_schema)
seo_schema = {
"@context": "https://schema.org",
"@graph": seo_graph,
}
return render_template(
"forum/post_detail.html",
post=post,
comments=comments,
like_count=like_count,
bookmark_count=bookmark_count,
liked_by_me=liked_by_me,
bookmarked_by_me=bookmarked_by_me,
can_interact=can_interact,
sidebar=sidebar,
related_cards=related_cards,
plan_recommendations=plan_recommendations,
plan_reco_context=plan_reco_context,
detail_resource_links=detail_resource_links,
detail_faq_items=detail_faq_items,
comments_count=comments_count,
read_minutes=read_minutes,
comment_page=comment_page,
comments_total_pages=comments_total_pages,
comment_page_links=comment_page_links,
comment_prev_url=comment_prev_url,
comment_next_url=comment_next_url,
message=request.args.get("msg") or "",
error=request.args.get("error") or "",
seo=seo,
seo_schema=seo_schema,
)
@app.route("/forum/post//like", methods=["POST"])
@user_login_required
def forum_post_like_toggle(post_id):
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
if blocked_resp:
return blocked_resp
exists = ForumPostLike.query.filter_by(post_id=post.id, user_id=user.id).first()
if exists:
db.session.delete(exists)
db.session.commit()
return redirect(_safe_form_next_url(url_for("forum_post_detail", post_id=post.id, msg="已取消点赞")))
db.session.add(ForumPostLike(post_id=post.id, user_id=user.id))
db.session.commit()
return redirect(_safe_form_next_url(url_for("forum_post_detail", post_id=post.id, msg="已点赞该帖子")))
@app.route("/forum/post//bookmark", methods=["POST"])
@user_login_required
def forum_post_bookmark_toggle(post_id):
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
if blocked_resp:
return blocked_resp
exists = ForumPostBookmark.query.filter_by(post_id=post.id, user_id=user.id).first()
if exists:
db.session.delete(exists)
db.session.commit()
return redirect(_safe_form_next_url(url_for("forum_post_detail", post_id=post.id, msg="已取消收藏")))
db.session.add(ForumPostBookmark(post_id=post.id, user_id=user.id))
db.session.commit()
return redirect(_safe_form_next_url(url_for("forum_post_detail", post_id=post.id, msg="已收藏该帖子")))
@app.route("/forum/post//comment", methods=["POST"])
@user_login_required
def forum_post_comment(post_id):
post = ForumPost.query.get_or_404(post_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=post.id)
if blocked_resp:
return blocked_resp
if post.is_locked:
return _forum_redirect_with_error(post.id, "该帖子已锁定,暂不允许新增评论")
content = (request.form.get("content") or "").strip()
if len(content) < 2:
return redirect(url_for("forum_post_detail", post_id=post.id, error="评论至少 2 个字符"))
comment = ForumComment(
post_id=post.id,
user_id=user.id,
content=content,
)
db.session.add(comment)
db.session.flush()
actor_name = user.username or "用户"
post_title = post.title or "主题"
if post.user_id and post.user_id != user.id:
_create_notification(
user_id=post.user_id,
notif_type="post_commented",
message="{} 评论了你的帖子《{}》".format(actor_name, post_title),
actor_id=user.id,
post_id=post.id,
comment_id=comment.id,
)
participant_rows = (
db.session.query(ForumComment.user_id)
.filter(
ForumComment.post_id == post.id,
ForumComment.user_id.isnot(None),
ForumComment.user_id != user.id,
)
.distinct()
.limit(50)
.all()
)
for (uid,) in participant_rows:
if not uid:
continue
if uid == post.user_id or uid == user.id:
continue
_create_notification(
user_id=uid,
notif_type="thread_replied",
message="{} 在你参与的主题《{}》有新回复".format(actor_name, post_title),
actor_id=user.id,
post_id=post.id,
comment_id=comment.id,
)
db.session.commit()
return redirect(url_for("forum_post_detail", post_id=post.id, msg="评论发布成功"))
@app.route("/forum/comment//edit", methods=["GET", "POST"])
@user_login_required
def forum_comment_edit(comment_id):
comment = ForumComment.query.get_or_404(comment_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=comment.post_id)
if blocked_resp:
return blocked_resp
if not _can_edit_comment(user, comment):
return _forum_redirect_with_error(comment.post_id, "你没有权限编辑该评论")
error = None
content = comment.content or ""
if request.method == "POST":
content = (request.form.get("content") or "").strip()
if len(content) < 2:
error = "评论至少 2 个字符"
else:
comment.content = content
db.session.commit()
return _forum_redirect_with_msg(comment.post_id, "评论已更新")
return render_template(
"forum/comment_form.html",
error=error,
comment=comment,
content_val=content,
action_url=url_for("forum_comment_edit", comment_id=comment.id),
cancel_url=url_for("forum_post_detail", post_id=comment.post_id),
)
@app.route("/forum/comment//delete", methods=["POST"])
@user_login_required
def forum_comment_delete(comment_id):
comment = ForumComment.query.get_or_404(comment_id)
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user, post_id=comment.post_id)
if blocked_resp:
return blocked_resp
if not _can_edit_comment(user, comment):
return _forum_redirect_with_error(comment.post_id, "你没有权限删除该评论")
post_id = comment.post_id
db.session.delete(comment)
db.session.commit()
return _forum_redirect_with_msg(post_id, "评论已删除")
@app.route("/forum/report", methods=["POST"])
@user_login_required
def forum_report_create():
user = _get_current_user()
blocked_resp = _ensure_forum_interaction_user(user)
if blocked_resp:
return blocked_resp
target_type = (request.form.get("target_type") or "").strip().lower()
target_id = request.form.get("target_id", type=int) or 0
reason = (request.form.get("reason") or "其他").strip()
detail = (request.form.get("detail") or "").strip()
if len(detail) > 500:
detail = detail[:500]
if reason not in FORUM_REPORT_REASONS:
reason = "其他"
report_post_id = None
target_owner_id = None
snapshot_title = None
snapshot_content = None
if target_type == "post":
target_post = db.session.get(ForumPost, target_id)
if target_post is None:
return redirect(url_for("forum_index"))
report_post_id = target_post.id
target_owner_id = target_post.user_id
snapshot_title = target_post.title
snapshot_content = target_post.content
elif target_type == "comment":
target_comment = db.session.get(ForumComment, target_id)
if target_comment is None:
return redirect(url_for("forum_index"))
report_post_id = target_comment.post_id
target_owner_id = target_comment.user_id
snapshot_title = target_comment.post_rel.title if target_comment.post_rel else None
snapshot_content = target_comment.content
else:
return redirect(url_for("forum_index"))
if target_owner_id == user.id:
return _forum_redirect_with_error(report_post_id, "不能举报自己的内容")
exists = ForumReport.query.filter_by(
reporter_id=user.id,
target_type=target_type,
target_id=target_id,
status="pending",
).first()
if exists:
return _forum_redirect_with_msg(report_post_id, "你已举报该内容,请等待处理")
db.session.add(ForumReport(
reporter_id=user.id,
target_type=target_type,
target_id=target_id,
reason=reason,
detail=detail or None,
snapshot_title=snapshot_title,
snapshot_content=snapshot_content,
status="pending",
))
db.session.commit()
return _forum_redirect_with_msg(report_post_id, "举报已提交,感谢反馈")
@app.route("/forum/feed")
@app.route("/forum/feed.xml/")
@app.route("/forum/feed.xml")
def forum_feed():
lang = _get_lang()
latest_activity_expr = func.coalesce(ForumPost.updated_at, ForumPost.created_at)
rows = (
db.session.query(
ForumPost,
User.username.label("author_name"),
)
.outerjoin(User, User.id == ForumPost.user_id)
.order_by(latest_activity_expr.desc(), ForumPost.id.desc())
.limit(120)
.all()
)
channel_title = _pick_lang("云价眼论坛最新主题", "VPS Price Forum Latest Topics", lang)
channel_description = _pick_lang(
"按最新活跃度输出论坛主题 RSS 订阅,便于跟踪 VPS 讨论更新。",
"RSS feed of the latest forum activity to track VPS discussions.",
lang,
)
channel_link = _public_url("forum_index", lang=lang)
self_feed_url = _public_url("forum_feed", lang=lang)
latest_time = None
if rows:
p = rows[0][0]
latest_time = p.updated_at or p.created_at
last_build_date = _rfc2822_utc(latest_time or datetime.now(timezone.utc))
lines = [
'',
'',
" ",
" {}".format(xml_escape(channel_title)),
" {}".format(xml_escape(channel_description)),
" {}".format(xml_escape(channel_link)),
" {}".format("en-us" if lang == "en" else "zh-cn"),
" {}".format(xml_escape(last_build_date)),
' '.format(xml_escape(self_feed_url)),
]
for post, author_name in rows:
post_url = _public_url("forum_post_detail", lang=lang, post_id=post.id)
pub_date = _rfc2822_utc(post.updated_at or post.created_at) or last_build_date
author = author_name or _pick_lang("匿名用户", "Anonymous", lang)
summary = _plain_excerpt(post.content or "", limit=260)
category = post.category or _pick_lang("综合讨论", "General", lang)
lines.extend([
" - ",
" {}".format(xml_escape(post.title or _pick_lang("未命名主题", "Untitled topic", lang))),
" {}".format(xml_escape(summary)),
" {}".format(xml_escape(post_url)),
" {}".format(xml_escape(post_url)),
" {}".format(xml_escape(author)),
" {}".format(xml_escape(category)),
" {}".format(xml_escape(pub_date)),
"
",
])
lines.extend([
" ",
"",
])
xml = "\n".join(lines)
resp = make_response(xml)
resp.mimetype = "application/rss+xml"
resp.headers["Cache-Control"] = "public, max-age=900"
return resp
# ---------- 法务页面 ----------
@app.route("/privacy")
def privacy_policy():
lang = _get_lang()
page_title = _pick_lang("隐私政策 | 云价眼", "Privacy Policy | VPS Price", lang)
page_description = _pick_lang(
"了解云价眼如何收集、使用和保护站点访客与论坛用户数据。",
"How VPS Price collects, uses, and protects visitor and forum user data.",
lang,
)
canonical_url = _public_url("privacy_policy", lang=lang)
seo = {
"title": page_title,
"description": page_description,
"canonical_url": canonical_url,
"robots": "index,follow,max-image-preview:large",
"og_type": "article",
"og_url": canonical_url,
"og_title": page_title,
"og_description": page_description,
"og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"),
"twitter_card": "summary",
"twitter_title": page_title,
"twitter_description": page_description,
"alternate_links": _alternate_lang_links("privacy_policy"),
}
seo_schema = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": page_title,
"description": page_description,
"url": canonical_url,
"inLanguage": "en-US" if lang == "en" else "zh-CN",
}
return render_template(
"privacy.html",
seo=seo,
seo_schema=seo_schema,
updated_on="2026-02-10",
)
@app.route("/terms")
def terms_of_service():
lang = _get_lang()
page_title = _pick_lang("服务条款 | 云价眼", "Terms of Service | VPS Price", lang)
page_description = _pick_lang(
"查看云价眼的服务范围、免责声明与论坛使用规范。",
"Read the VPS Price service scope, disclaimers, and forum usage rules.",
lang,
)
canonical_url = _public_url("terms_of_service", lang=lang)
seo = {
"title": page_title,
"description": page_description,
"canonical_url": canonical_url,
"robots": "index,follow,max-image-preview:large",
"og_type": "article",
"og_url": canonical_url,
"og_title": page_title,
"og_description": page_description,
"og_image": _absolute_url_for("static", filename="img/site-logo-mark.svg"),
"twitter_card": "summary",
"twitter_title": page_title,
"twitter_description": page_description,
"alternate_links": _alternate_lang_links("terms_of_service"),
}
seo_schema = {
"@context": "https://schema.org",
"@type": "WebPage",
"name": page_title,
"description": page_description,
"url": canonical_url,
"inLanguage": "en-US" if lang == "en" else "zh-CN",
}
return render_template(
"terms.html",
seo=seo,
seo_schema=seo_schema,
updated_on="2026-02-10",
)
# ---------- SEO ----------
@app.route("/sitemap.xml")
def sitemap():
latest_forum_dt = _latest_forum_content_datetime()
sitemap_lastmod = _iso8601_utc(latest_forum_dt)
total_pages = _forum_sitemap_total_pages()
entries = [{
"loc": _absolute_url_for("sitemap_static"),
"lastmod": sitemap_lastmod,
}]
for lang_code in ("zh", "en"):
for page in range(1, total_pages + 1):
entries.append({
"loc": _absolute_url_for("sitemap_forum_page", lang_code=lang_code, page=page),
"lastmod": sitemap_lastmod,
})
xml = _build_sitemap_index_xml(entries)
resp = make_response(xml)
resp.mimetype = "application/xml"
resp.headers["Cache-Control"] = "public, max-age=1800"
return resp
@app.route("/sitemap-static.xml")
def sitemap_static():
latest_forum_dt = _latest_forum_content_datetime()
latest_forum_lastmod = _iso8601_utc(latest_forum_dt)
urls = []
def add_page(endpoint, changefreq, priority, params=None, lastmod=None):
values = params or {}
alternates = _sitemap_alternates(endpoint, **values)
for lang_code in ("zh", "en"):
urls.append({
"loc": _public_url(endpoint, lang=lang_code, **values),
"changefreq": changefreq,
"priority": priority,
"lastmod": lastmod,
"alternates": alternates,
})
add_page("index", "daily", "1.0")
add_page("forum_index", "daily", "0.9", lastmod=latest_forum_lastmod)
add_page("forum_feed", "hourly", "0.4", lastmod=latest_forum_lastmod)
add_page("forum_index", "daily", "0.8", params={"tab": "new"}, lastmod=latest_forum_lastmod)
add_page("forum_index", "daily", "0.8", params={"tab": "hot"}, lastmod=latest_forum_lastmod)
add_page("privacy_policy", "monthly", "0.3", lastmod="2026-02-10T00:00:00Z")
add_page("terms_of_service", "monthly", "0.3", lastmod="2026-02-10T00:00:00Z")
category_rows = (
db.session.query(
ForumPost.category,
func.max(func.coalesce(ForumPost.updated_at, ForumPost.created_at)).label("latest_at"),
)
.filter(ForumPost.category.isnot(None), ForumPost.category != "")
.group_by(ForumPost.category)
.order_by(func.count(ForumPost.id).desc(), ForumPost.category.asc())
.limit(300)
.all()
)
for category_name, latest_at in category_rows:
add_page(
"forum_index",
"daily",
"0.75",
params={"category": category_name},
lastmod=_iso8601_utc(latest_at),
)
xml = _build_sitemap_urlset_xml(urls)
resp = make_response(xml)
resp.mimetype = "application/xml"
resp.headers["Cache-Control"] = "public, max-age=1800"
return resp
@app.route("/sitemap-forum--.xml")
def sitemap_forum_page(lang_code, page):
normalized_lang = (lang_code or "").strip().lower()
if normalized_lang not in {"zh", "en"}:
abort(404)
total_pages = _forum_sitemap_total_pages()
if page < 1 or page > total_pages:
abort(404)
offset = (page - 1) * SITEMAP_POSTS_PER_FILE
rows = (
db.session.query(ForumPost.id, ForumPost.updated_at, ForumPost.created_at)
.order_by(ForumPost.updated_at.desc(), ForumPost.id.desc())
.offset(offset)
.limit(SITEMAP_POSTS_PER_FILE)
.all()
)
urls = []
for post_id, updated_at, created_at in rows:
lastmod = _iso8601_utc(updated_at or created_at)
urls.append({
"loc": _public_url("forum_post_detail", lang=normalized_lang, post_id=post_id),
"changefreq": "weekly",
"priority": "0.8",
"lastmod": lastmod,
"alternates": _sitemap_alternates("forum_post_detail", post_id=post_id),
})
xml = _build_sitemap_urlset_xml(urls)
resp = make_response(xml)
resp.mimetype = "application/xml"
resp.headers["Cache-Control"] = "public, max-age=1800"
return resp
@app.route("/robots.txt")
def robots():
txt = """User-agent: *
Allow: /
Allow: /forum/feed.xml
Disallow: /admin/
Disallow: /login
Disallow: /register
Disallow: /profile
Disallow: /me
Disallow: /notifications
Disallow: /notification/
Disallow: /forum/post/new
Disallow: /forum/post/*/edit
Disallow: /forum/comment/*/edit
Disallow: /forum/report
Disallow: /api/
Disallow: /*?*q=
Sitemap: {}/sitemap.xml
""".format(_site_root_url())
resp = make_response(txt)
resp.mimetype = "text/plain"
resp.headers["Cache-Control"] = "public, max-age=3600"
return resp
@app.route("/ads.txt")
def ads_txt():
content = (os.environ.get("ADS_TXT_CONTENT") or "").strip()
if content:
from flask import make_response
body = content if content.endswith("\n") else "{}\n".format(content)
resp = make_response(body)
resp.mimetype = "text/plain"
resp.headers["Cache-Control"] = "public, max-age=3600"
return resp
ads_file = os.path.join(app.static_folder or "", "ads.txt")
if os.path.isfile(ads_file):
return send_from_directory(app.static_folder or "", "ads.txt")
from flask import make_response
resp = make_response("# Configure ADS_TXT_CONTENT or create static/ads.txt\n")
resp.mimetype = "text/plain"
resp.headers["Cache-Control"] = "public, max-age=600"
return resp
@app.route("/favicon.ico")
def favicon():
return redirect(url_for("static", filename="img/site-logo-mark.svg"))
# ---------- 后台 ----------
@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
if request.method == "POST":
password = request.form.get("password", "")
if password == ADMIN_PASSWORD:
session["admin_logged_in"] = True
return redirect(url_for("admin_dashboard"))
return render_template("admin/login.html", error="密码错误")
return render_template("admin/login.html")
@app.route("/admin/logout")
def admin_logout():
session.pop("admin_logged_in", None)
return redirect(url_for("index"))
@app.route("/admin/api/plan/")
@admin_required
def admin_api_plan(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
return jsonify({
"id": plan.id,
"provider_id": plan.provider_id,
"countries": plan.countries or "",
"vcpu": plan.vcpu,
"memory_gb": plan.memory_gb,
"storage_gb": plan.storage_gb,
"bandwidth_mbps": plan.bandwidth_mbps,
"traffic": plan.traffic or "",
"price_cny": float(plan.price_cny) if plan.price_cny is not None else None,
"price_usd": float(plan.price_usd) if plan.price_usd is not None else None,
"currency": plan.currency or "CNY",
"official_url": plan.official_url or "",
})
@app.route("/admin/api/plan//price-history")
@admin_required
def admin_api_plan_price_history(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
rows = (
PriceHistory.query
.filter_by(plan_id=plan.id)
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.limit(30)
.all()
)
return jsonify([
{
"id": r.id,
"price_cny": float(r.price_cny) if r.price_cny is not None else None,
"price_usd": float(r.price_usd) if r.price_usd is not None else None,
"currency": r.currency or "CNY",
"source": r.source or "",
"captured_at": r.captured_at.isoformat() if r.captured_at else "",
}
for r in rows
])
@app.route("/admin")
@admin_required
def admin_dashboard():
providers = Provider.query.order_by(Provider.name).all()
plans = _query_plans_for_display()
plan_trends = _build_plan_trend_map(plans)
return render_template(
"admin/dashboard.html",
providers=providers,
plans=plans,
plan_trends=plan_trends,
country_tags=COUNTRY_TAGS,
)
# ---------- 厂商管理 ----------
@app.route("/admin/providers")
@admin_required
def admin_providers():
providers = Provider.query.order_by(Provider.name).all()
return render_template("admin/providers.html", providers=providers)
@app.route("/admin/provider/new", methods=["GET", "POST"])
@admin_required
def admin_provider_new():
if request.method == "POST":
name = request.form.get("name", "").strip()
official_url = request.form.get("official_url", "").strip() or None
if not name:
return render_template("admin/provider_form.html", provider=None, error="请填写厂商名称")
if Provider.query.filter_by(name=name).first():
return render_template("admin/provider_form.html", provider=None, error="该厂商名称已存在")
p = Provider(name=name, official_url=official_url)
db.session.add(p)
db.session.commit()
return redirect(url_for("admin_provider_detail", provider_id=p.id))
return render_template("admin/provider_form.html", provider=None)
@app.route("/admin/provider/")
@admin_required
def admin_provider_detail(provider_id):
provider = Provider.query.get_or_404(provider_id)
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(
"admin/provider_detail.html",
provider=provider,
plans=plans,
plan_trends=plan_trends,
providers=providers,
country_tags=COUNTRY_TAGS,
)
@app.route("/admin/provider//edit", methods=["GET", "POST"])
@admin_required
def admin_provider_edit(provider_id):
provider = Provider.query.get_or_404(provider_id)
if request.method == "POST":
provider.name = request.form.get("name", "").strip()
provider.official_url = request.form.get("official_url", "").strip() or None
if not provider.name:
return render_template("admin/provider_form.html", provider=provider, error="请填写厂商名称")
db.session.commit()
return redirect(url_for("admin_provider_detail", provider_id=provider.id))
return render_template("admin/provider_form.html", provider=provider)
@app.route("/admin/provider//delete", methods=["POST"])
@admin_required
def admin_provider_delete(provider_id):
provider = Provider.query.get_or_404(provider_id)
# 将该厂商下的配置改为无厂商关联(保留配置,仅清空 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"))
def _parse_sort_order(raw, default=100):
s = (raw or "").strip()
if not s:
return default
try:
return int(s)
except ValueError:
return default
def _admin_user_counts(user_ids):
"""批量统计用户维度数据,减少列表页 N+1 查询。"""
if not user_ids:
return {
"posts": {},
"comments": {},
"reports": {},
"unread_notifications": {},
}
post_counts = {
uid: int(cnt or 0)
for uid, cnt in (
db.session.query(ForumPost.user_id, func.count(ForumPost.id))
.filter(ForumPost.user_id.in_(user_ids))
.group_by(ForumPost.user_id)
.all()
)
}
comment_counts = {
uid: int(cnt or 0)
for uid, cnt in (
db.session.query(ForumComment.user_id, func.count(ForumComment.id))
.filter(ForumComment.user_id.in_(user_ids))
.group_by(ForumComment.user_id)
.all()
)
}
report_counts = {
uid: int(cnt or 0)
for uid, cnt in (
db.session.query(ForumReport.reporter_id, func.count(ForumReport.id))
.filter(ForumReport.reporter_id.in_(user_ids))
.group_by(ForumReport.reporter_id)
.all()
)
}
unread_notification_counts = {
uid: int(cnt or 0)
for uid, cnt in (
db.session.query(ForumNotification.user_id, func.count(ForumNotification.id))
.filter(ForumNotification.user_id.in_(user_ids), ForumNotification.is_read.is_(False))
.group_by(ForumNotification.user_id)
.all()
)
}
return {
"posts": post_counts,
"comments": comment_counts,
"reports": report_counts,
"unread_notifications": unread_notification_counts,
}
def _admin_load_user_options(limit=400):
return (
User.query
.order_by(User.username.asc(), User.id.asc())
.limit(limit)
.all()
)
def _admin_load_post_options(limit=400):
return (
ForumPost.query
.order_by(ForumPost.created_at.desc(), ForumPost.id.desc())
.limit(limit)
.all()
)
def _admin_fill_post_and_user_options(post_options, selected_post_id, user_options, selected_user_id):
"""确保编辑场景中的当前值始终出现在下拉框中。"""
if selected_post_id and all(p.id != selected_post_id for p in post_options):
selected_post = db.session.get(ForumPost, selected_post_id)
if selected_post:
post_options = [selected_post] + post_options
if selected_user_id and all(u.id != selected_user_id for u in user_options):
selected_user = db.session.get(User, selected_user_id)
if selected_user:
user_options = [selected_user] + user_options
return post_options, user_options
# ---------- 论坛分类管理 ----------
@app.route("/admin/forum/categories")
@admin_required
def admin_forum_categories():
categories = _load_forum_categories(active_only=False)
posts_by_category = {
name: count
for name, count in (
db.session.query(ForumPost.category, func.count(ForumPost.id))
.group_by(ForumPost.category)
.all()
)
}
return render_template(
"admin/forum_categories.html",
categories=categories,
posts_by_category=posts_by_category,
msg=request.args.get("msg", ""),
error=request.args.get("error", ""),
)
@app.route("/admin/forum/category/new", methods=["GET", "POST"])
@admin_required
def admin_forum_category_new():
error = ""
name_val = ""
sort_order_val = 100
is_active_val = True
if request.method == "POST":
name_val = (request.form.get("name") or "").strip()
sort_order_val = _parse_sort_order(request.form.get("sort_order"), 100)
is_active_val = bool(request.form.get("is_active"))
if not name_val:
error = "请填写分类名称"
elif len(name_val) > 32:
error = "分类名称最多 32 个字符"
elif ForumCategory.query.filter(func.lower(ForumCategory.name) == name_val.lower()).first():
error = "分类名称已存在"
else:
db.session.add(ForumCategory(
name=name_val,
sort_order=sort_order_val,
is_active=is_active_val,
))
db.session.commit()
return redirect(url_for("admin_forum_categories", msg="已新增分类:{}".format(name_val)))
return render_template(
"admin/forum_category_form.html",
page_title="新增论坛分类",
submit_text="创建分类",
action_url=url_for("admin_forum_category_new"),
error=error,
name_val=name_val,
sort_order_val=sort_order_val,
is_active_val=is_active_val,
category_id=None,
)
@app.route("/admin/forum/category//edit", methods=["GET", "POST"])
@admin_required
def admin_forum_category_edit(category_id):
category = ForumCategory.query.get_or_404(category_id)
error = ""
name_val = category.name
sort_order_val = category.sort_order
is_active_val = bool(category.is_active)
if request.method == "POST":
name_val = (request.form.get("name") or "").strip()
sort_order_val = _parse_sort_order(request.form.get("sort_order"), category.sort_order)
is_active_val = bool(request.form.get("is_active"))
if not name_val:
error = "请填写分类名称"
elif len(name_val) > 32:
error = "分类名称最多 32 个字符"
elif category.is_active and not is_active_val and ForumCategory.query.filter_by(is_active=True).count() <= 1:
error = "至少保留一个启用分类"
else:
exists = (
ForumCategory.query
.filter(func.lower(ForumCategory.name) == name_val.lower(), ForumCategory.id != category.id)
.first()
)
if exists:
error = "分类名称已存在"
else:
old_name = category.name
category.name = name_val
category.sort_order = sort_order_val
category.is_active = is_active_val
if old_name != name_val:
ForumPost.query.filter_by(category=old_name).update({"category": name_val})
db.session.commit()
return redirect(url_for("admin_forum_categories", msg="已更新分类:{}".format(name_val)))
return render_template(
"admin/forum_category_form.html",
page_title="编辑论坛分类",
submit_text="保存修改",
action_url=url_for("admin_forum_category_edit", category_id=category.id),
error=error,
name_val=name_val,
sort_order_val=sort_order_val,
is_active_val=is_active_val,
category_id=category.id,
)
@app.route("/admin/forum/category//delete", methods=["POST"])
@admin_required
def admin_forum_category_delete(category_id):
category = ForumCategory.query.get_or_404(category_id)
total = ForumCategory.query.count()
if total <= 1:
return redirect(url_for("admin_forum_categories", error="至少保留一个分类,无法删除最后一个"))
if category.is_active and ForumCategory.query.filter_by(is_active=True).count() <= 1:
return redirect(url_for("admin_forum_categories", error="至少保留一个启用分类,无法删除最后一个启用项"))
replacement = (
ForumCategory.query
.filter(ForumCategory.id != category.id)
.order_by(ForumCategory.is_active.desc(), ForumCategory.sort_order.asc(), ForumCategory.id.asc())
.first()
)
if replacement is None:
return redirect(url_for("admin_forum_categories", error="未找到可替代分类"))
ForumPost.query.filter_by(category=category.name).update({"category": replacement.name})
db.session.delete(category)
db.session.commit()
return redirect(url_for("admin_forum_categories", msg="已删除分类,帖子迁移到:{}".format(replacement.name)))
def _get_report_target_info(report):
"""返回举报目标的展示信息。"""
info = {
"exists": False,
"post_id": None,
"title": report.snapshot_title or "",
"content": report.snapshot_content or "",
"author_name": "",
}
if report.target_type == "post":
post = db.session.get(ForumPost, report.target_id)
if post:
info.update({
"exists": True,
"post_id": post.id,
"title": post.title or info["title"],
"content": post.content or info["content"],
"author_name": post.author_rel.username if post.author_rel else "",
})
elif report.target_type == "comment":
comment = db.session.get(ForumComment, report.target_id)
if comment:
info.update({
"exists": True,
"post_id": comment.post_id,
"title": comment.post_rel.title if comment.post_rel else (info["title"] or ""),
"content": comment.content or info["content"],
"author_name": comment.author_rel.username if comment.author_rel else "",
})
if info["content"] and len(info["content"]) > 140:
info["content"] = info["content"][:140] + "..."
return info
@app.route("/admin/forum/reports")
@admin_required
def admin_forum_reports():
status = (request.args.get("status") or "pending").strip().lower()
if status not in {"pending", "processed", "rejected", "all"}:
status = "pending"
q = ForumReport.query.order_by(ForumReport.created_at.desc(), ForumReport.id.desc())
if status != "all":
q = q.filter_by(status=status)
reports = q.limit(300).all()
report_items = []
for r in reports:
report_items.append({
"report": r,
"target": _get_report_target_info(r),
"reporter_name": r.reporter_rel.username if r.reporter_rel else "用户",
})
grouped = (
db.session.query(ForumReport.status, func.count(ForumReport.id))
.group_by(ForumReport.status)
.all()
)
count_map = {k: int(v or 0) for k, v in grouped}
return render_template(
"admin/forum_reports.html",
status=status,
report_items=report_items,
status_count_map=count_map,
status_labels=FORUM_REPORT_STATUS_LABELS,
msg=request.args.get("msg", ""),
error=request.args.get("error", ""),
)
@app.route("/admin/forum/report//process", methods=["POST"])
@admin_required
def admin_forum_report_process(report_id):
report = ForumReport.query.get_or_404(report_id)
action = (request.form.get("action") or "").strip().lower()
review_note = (request.form.get("review_note") or "").strip()
if len(review_note) > 500:
review_note = review_note[:500]
if report.status != "pending":
return redirect(url_for("admin_forum_reports", error="该举报已处理"))
outcome = ""
target_owner_id = None
target_post_id = None
target_kind_label = "内容"
if action == "delete_target":
deleted = False
if report.target_type == "post":
target = db.session.get(ForumPost, report.target_id)
if target:
target_owner_id = target.user_id
target_post_id = target.id
target_kind_label = "帖子"
db.session.delete(target)
deleted = True
outcome = "已删除被举报帖子" if deleted else "目标帖子已不存在"
elif report.target_type == "comment":
target = db.session.get(ForumComment, report.target_id)
if target:
target_owner_id = target.user_id
target_post_id = target.post_id
target_kind_label = "评论"
db.session.delete(target)
deleted = True
outcome = "已删除被举报评论" if deleted else "目标评论已不存在"
else:
return redirect(url_for("admin_forum_reports", error="未知举报目标类型"))
report.status = "processed"
report.review_note = review_note or outcome
elif action == "keep":
report.status = "processed"
report.review_note = review_note or "审核后保留内容"
outcome = "已标记为保留"
elif action == "reject":
report.status = "rejected"
report.review_note = review_note or "举报不成立"
outcome = "已驳回举报"
else:
return redirect(url_for("admin_forum_reports", error="未知处理动作"))
report.reviewed_by = "admin"
report.reviewed_at = datetime.now(timezone.utc)
_create_notification(
user_id=report.reporter_id,
notif_type="report_processed",
message="你提交的举报(#{})处理结果:{}".format(report.id, outcome),
report_id=report.id,
post_id=target_post_id,
)
if action == "delete_target" and target_owner_id and target_owner_id != report.reporter_id:
_create_notification(
user_id=target_owner_id,
notif_type="content_removed",
message="你的{}因举报处理已被删除".format(target_kind_label),
report_id=report.id,
post_id=target_post_id,
)
db.session.commit()
return redirect(url_for("admin_forum_reports", msg=outcome))
@app.route("/admin/users")
@admin_required
def admin_users():
keyword = (request.args.get("q") or "").strip()
if len(keyword) > 50:
keyword = keyword[:50]
q = User.query
if keyword:
pattern = "%{}%".format(keyword)
q = q.filter(User.username.ilike(pattern))
users = q.order_by(User.created_at.desc(), User.id.desc()).limit(300).all()
user_ids = [u.id for u in users]
count_maps = _admin_user_counts(user_ids)
return render_template(
"admin/users.html",
users=users,
keyword=keyword,
post_count_map=count_maps["posts"],
comment_count_map=count_maps["comments"],
report_count_map=count_maps["reports"],
unread_notification_count_map=count_maps["unread_notifications"],
msg=request.args.get("msg", ""),
error=request.args.get("error", ""),
)
@app.route("/admin/user/new", methods=["GET", "POST"])
@admin_required
def admin_user_new():
error = ""
username_val = ""
if request.method == "POST":
username_val = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username_val):
error = "用户名需为 3-20 位,仅支持字母、数字、下划线"
elif len(password) < 6:
error = "密码至少 6 位"
elif password != confirm_password:
error = "两次输入的密码不一致"
elif User.query.filter(func.lower(User.username) == username_val.lower()).first():
error = "用户名已存在"
else:
user = User(username=username_val)
user.set_password(password)
db.session.add(user)
db.session.commit()
return redirect(url_for("admin_users", msg="已新增用户:{}".format(username_val)))
return render_template(
"admin/user_form.html",
page_title="新增用户",
submit_text="创建用户",
action_url=url_for("admin_user_new"),
error=error,
username_val=username_val,
user_id=None,
)
@app.route("/admin/user//edit", methods=["GET", "POST"])
@admin_required
def admin_user_edit(user_id):
user = User.query.get_or_404(user_id)
error = ""
username_val = user.username or ""
if request.method == "POST":
username_val = (request.form.get("username") or "").strip()
new_password = request.form.get("new_password") or ""
confirm_password = request.form.get("confirm_password") or ""
if not _is_valid_username(username_val):
error = "用户名需为 3-20 位,仅支持字母、数字、下划线"
elif (
User.query
.filter(func.lower(User.username) == username_val.lower(), User.id != user.id)
.first()
):
error = "用户名已存在"
elif new_password and len(new_password) < 6:
error = "新密码至少 6 位"
elif new_password and new_password != confirm_password:
error = "两次新密码输入不一致"
else:
old_username = user.username
user.username = username_val
changed = False
if old_username != username_val:
changed = True
if new_password:
user.set_password(new_password)
changed = True
if changed:
db.session.commit()
return redirect(url_for("admin_users", msg="已更新用户:{}".format(username_val)))
return redirect(url_for("admin_users", msg="未检测到变更"))
return render_template(
"admin/user_form.html",
page_title="编辑用户",
submit_text="保存修改",
action_url=url_for("admin_user_edit", user_id=user.id),
error=error,
username_val=username_val,
user_id=user.id,
)
@app.route("/admin/user//delete", methods=["POST"])
@admin_required
def admin_user_delete(user_id):
user = User.query.get_or_404(user_id)
if User.query.count() <= 1:
return redirect(url_for("admin_users", error="至少保留一个用户,无法删除最后一个"))
username = user.username or "用户"
try:
# 其他用户已收到的通知可能引用该用户为 actor,删除前置空避免外键冲突。
ForumNotification.query.filter_by(actor_id=user.id).update({"actor_id": None}, synchronize_session=False)
db.session.delete(user)
db.session.commit()
return redirect(url_for("admin_users", msg="已删除用户:{}".format(username)))
except Exception:
db.session.rollback()
return redirect(url_for("admin_users", error="删除失败,请稍后重试"))
@app.route("/admin/user//ban", methods=["POST"])
@admin_required
def admin_user_ban(user_id):
user = User.query.get_or_404(user_id)
reason = (request.form.get("reason") or "").strip()
if len(reason) > 255:
reason = reason[:255]
if user.is_banned:
return redirect(url_for("admin_users", msg="用户已处于封禁状态:{}".format(user.username)))
user.is_banned = True
user.banned_at = datetime.now(timezone.utc)
user.banned_reason = reason or "管理员封禁"
db.session.commit()
return redirect(url_for("admin_users", msg="已封禁用户:{}".format(user.username)))
@app.route("/admin/user//unban", methods=["POST"])
@admin_required
def admin_user_unban(user_id):
user = User.query.get_or_404(user_id)
if not user.is_banned:
return redirect(url_for("admin_users", msg="用户未被封禁:{}".format(user.username)))
user.is_banned = False
user.banned_at = None
user.banned_reason = None
db.session.commit()
return redirect(url_for("admin_users", msg="已解封用户:{}".format(user.username)))
@app.route("/admin/forum/posts")
@admin_required
def admin_forum_posts():
keyword = (request.args.get("q") or "").strip()
if len(keyword) > 80:
keyword = keyword[:80]
selected_category = (request.args.get("category") or "").strip() or None
selected_author_id = request.args.get("author_id", type=int)
comment_stats_subq = (
db.session.query(
ForumComment.post_id.label("post_id"),
func.count(ForumComment.id).label("comment_count"),
)
.group_by(ForumComment.post_id)
.subquery()
)
like_stats_subq = (
db.session.query(
ForumPostLike.post_id.label("post_id"),
func.count(ForumPostLike.id).label("like_count"),
)
.group_by(ForumPostLike.post_id)
.subquery()
)
bookmark_stats_subq = (
db.session.query(
ForumPostBookmark.post_id.label("post_id"),
func.count(ForumPostBookmark.id).label("bookmark_count"),
)
.group_by(ForumPostBookmark.post_id)
.subquery()
)
q = (
db.session.query(
ForumPost,
func.coalesce(comment_stats_subq.c.comment_count, 0).label("comment_count"),
User.username.label("author_name"),
func.coalesce(like_stats_subq.c.like_count, 0).label("like_count"),
func.coalesce(bookmark_stats_subq.c.bookmark_count, 0).label("bookmark_count"),
)
.outerjoin(comment_stats_subq, comment_stats_subq.c.post_id == ForumPost.id)
.outerjoin(like_stats_subq, like_stats_subq.c.post_id == ForumPost.id)
.outerjoin(bookmark_stats_subq, bookmark_stats_subq.c.post_id == ForumPost.id)
.outerjoin(User, User.id == ForumPost.user_id)
)
if selected_category:
q = q.filter(ForumPost.category == selected_category)
if selected_author_id:
q = q.filter(ForumPost.user_id == selected_author_id)
if keyword:
pattern = "%{}%".format(keyword)
q = q.filter(
or_(
ForumPost.title.ilike(pattern),
ForumPost.content.ilike(pattern),
User.username.ilike(pattern),
)
)
rows = q.order_by(ForumPost.is_pinned.desc(), ForumPost.created_at.desc(), ForumPost.id.desc()).limit(400).all()
category_names = list(_get_forum_category_names(active_only=False))
for (name,) in db.session.query(ForumPost.category).distinct().all():
if name and name not in category_names:
category_names.append(name)
if selected_category and selected_category not in category_names:
category_names.insert(0, selected_category)
author_rows = (
db.session.query(
User.id,
User.username,
func.count(ForumPost.id).label("post_count"),
)
.outerjoin(ForumPost, ForumPost.user_id == User.id)
.group_by(User.id)
.order_by(func.count(ForumPost.id).desc(), User.username.asc())
.limit(300)
.all()
)
return render_template(
"admin/forum_posts.html",
rows=rows,
category_names=category_names,
author_rows=author_rows,
keyword=keyword,
selected_category=selected_category,
selected_author_id=selected_author_id,
msg=request.args.get("msg", ""),
error=request.args.get("error", ""),
)
@app.route("/admin/forum/post//moderate", methods=["POST"])
@admin_required
def admin_forum_post_moderate(post_id):
post = ForumPost.query.get_or_404(post_id)
action = (request.form.get("action") or "").strip().lower()
if action == "pin":
post.is_pinned = True
elif action == "unpin":
post.is_pinned = False
elif action == "feature":
post.is_featured = True
elif action == "unfeature":
post.is_featured = False
elif action == "lock":
post.is_locked = True
elif action == "unlock":
post.is_locked = False
else:
return redirect(url_for("admin_forum_posts", error="未知帖子管理动作"))
db.session.commit()
return redirect(url_for("admin_forum_posts", msg="已更新帖子 #{} 状态".format(post.id)))
@app.route("/admin/forum/post/new", methods=["GET", "POST"])
@admin_required
def admin_forum_post_new():
error = ""
users = _admin_load_user_options(limit=400)
categories = _get_forum_category_names(active_only=False)
if not categories:
categories = list(DEFAULT_FORUM_CATEGORIES)
selected_author_id = request.args.get("author_id", type=int) or (users[0].id if users else None)
selected_category = request.args.get("category") or (categories[0] if categories else "综合讨论")
is_pinned_val = False
is_featured_val = False
is_locked_val = False
title_val = ""
content_val = ""
if request.method == "POST":
selected_author_id = request.form.get("author_id", type=int)
selected_category = (request.form.get("category") or "").strip() or selected_category
is_pinned_val = bool(request.form.get("is_pinned"))
is_featured_val = bool(request.form.get("is_featured"))
is_locked_val = bool(request.form.get("is_locked"))
title_val = (request.form.get("title") or "").strip()
content_val = (request.form.get("content") or "").strip()
author = db.session.get(User, selected_author_id or 0)
if not author:
error = "请选择有效作者"
elif selected_category not in categories:
error = "请选择有效分类"
elif len(title_val) < 5:
error = "标题至少 5 个字符"
elif len(title_val) > 160:
error = "标题不能超过 160 个字符"
elif len(content_val) < 10:
error = "内容至少 10 个字符"
else:
db.session.add(ForumPost(
user_id=author.id,
category=selected_category,
title=title_val,
content=content_val,
is_pinned=is_pinned_val,
is_featured=is_featured_val,
is_locked=is_locked_val,
))
db.session.commit()
return redirect(url_for("admin_forum_posts", msg="已新增帖子"))
if not users:
error = error or "当前没有可用用户,请先在用户管理中新增用户"
return render_template(
"admin/forum_post_form.html",
page_title="后台新增帖子",
submit_text="创建帖子",
action_url=url_for("admin_forum_post_new"),
cancel_url=url_for("admin_forum_posts"),
error=error,
users=users,
categories=categories,
selected_author_id=selected_author_id,
selected_category=selected_category,
is_pinned_val=is_pinned_val,
is_featured_val=is_featured_val,
is_locked_val=is_locked_val,
title_val=title_val,
content_val=content_val,
post_id=None,
)
@app.route("/admin/forum/post//edit", methods=["GET", "POST"])
@admin_required
def admin_forum_post_edit(post_id):
post = ForumPost.query.get_or_404(post_id)
error = ""
users = _admin_load_user_options(limit=400)
categories = _get_forum_category_names(active_only=False)
if post.category and post.category not in categories:
categories.insert(0, post.category)
selected_author_id = post.user_id
selected_category = post.category or (categories[0] if categories else "综合讨论")
is_pinned_val = bool(post.is_pinned)
is_featured_val = bool(post.is_featured)
is_locked_val = bool(post.is_locked)
title_val = post.title or ""
content_val = post.content or ""
if request.method == "POST":
selected_author_id = request.form.get("author_id", type=int)
selected_category = (request.form.get("category") or "").strip() or selected_category
is_pinned_val = bool(request.form.get("is_pinned"))
is_featured_val = bool(request.form.get("is_featured"))
is_locked_val = bool(request.form.get("is_locked"))
title_val = (request.form.get("title") or "").strip()
content_val = (request.form.get("content") or "").strip()
author = db.session.get(User, selected_author_id or 0)
if not author:
error = "请选择有效作者"
elif selected_category not in categories:
error = "请选择有效分类"
elif len(title_val) < 5:
error = "标题至少 5 个字符"
elif len(title_val) > 160:
error = "标题不能超过 160 个字符"
elif len(content_val) < 10:
error = "内容至少 10 个字符"
else:
post.user_id = author.id
post.category = selected_category
post.is_pinned = is_pinned_val
post.is_featured = is_featured_val
post.is_locked = is_locked_val
post.title = title_val
post.content = content_val
db.session.commit()
return redirect(url_for("admin_forum_posts", msg="已更新帖子 #{}".format(post.id)))
if not users:
error = error or "当前没有可用用户,请先在用户管理中新增用户"
return render_template(
"admin/forum_post_form.html",
page_title="后台编辑帖子",
submit_text="保存修改",
action_url=url_for("admin_forum_post_edit", post_id=post.id),
cancel_url=url_for("admin_forum_posts"),
error=error,
users=users,
categories=categories,
selected_author_id=selected_author_id,
selected_category=selected_category,
is_pinned_val=is_pinned_val,
is_featured_val=is_featured_val,
is_locked_val=is_locked_val,
title_val=title_val,
content_val=content_val,
post_id=post.id,
)
@app.route("/admin/forum/post//delete", methods=["POST"])
@admin_required
def admin_forum_post_delete(post_id):
post = ForumPost.query.get_or_404(post_id)
db.session.delete(post)
db.session.commit()
return redirect(url_for("admin_forum_posts", msg="已删除帖子 #{}".format(post_id)))
@app.route("/admin/forum/comments")
@admin_required
def admin_forum_comments():
keyword = (request.args.get("q") or "").strip()
if len(keyword) > 80:
keyword = keyword[:80]
selected_author_id = request.args.get("author_id", type=int)
selected_post_id = request.args.get("post_id", type=int)
q = (
db.session.query(
ForumComment,
ForumPost.title.label("post_title"),
User.username.label("author_name"),
)
.join(ForumPost, ForumComment.post_id == ForumPost.id)
.outerjoin(User, User.id == ForumComment.user_id)
)
if selected_post_id:
q = q.filter(ForumComment.post_id == selected_post_id)
if selected_author_id:
q = q.filter(ForumComment.user_id == selected_author_id)
if keyword:
pattern = "%{}%".format(keyword)
q = q.filter(
or_(
ForumComment.content.ilike(pattern),
ForumPost.title.ilike(pattern),
User.username.ilike(pattern),
)
)
rows = q.order_by(ForumComment.created_at.desc(), ForumComment.id.desc()).limit(500).all()
author_rows = (
db.session.query(
User.id,
User.username,
func.count(ForumComment.id).label("comment_count"),
)
.outerjoin(ForumComment, ForumComment.user_id == User.id)
.group_by(User.id)
.order_by(func.count(ForumComment.id).desc(), User.username.asc())
.limit(300)
.all()
)
post_rows = (
db.session.query(
ForumPost.id,
ForumPost.title,
)
.order_by(ForumPost.created_at.desc(), ForumPost.id.desc())
.limit(300)
.all()
)
if selected_post_id and all(pid != selected_post_id for pid, _ in post_rows):
selected_post = db.session.get(ForumPost, selected_post_id)
if selected_post:
post_rows = [(selected_post.id, selected_post.title)] + post_rows
return render_template(
"admin/forum_comments.html",
rows=rows,
author_rows=author_rows,
post_rows=post_rows,
keyword=keyword,
selected_author_id=selected_author_id,
selected_post_id=selected_post_id,
msg=request.args.get("msg", ""),
error=request.args.get("error", ""),
)
@app.route("/admin/forum/comment/new", methods=["GET", "POST"])
@admin_required
def admin_forum_comment_new():
error = ""
post_options = _admin_load_post_options(limit=400)
user_options = _admin_load_user_options(limit=400)
selected_post_id = request.args.get("post_id", type=int) or (post_options[0].id if post_options else None)
selected_user_id = request.args.get("user_id", type=int) or (user_options[0].id if user_options else None)
post_options, user_options = _admin_fill_post_and_user_options(
post_options,
selected_post_id,
user_options,
selected_user_id,
)
content_val = ""
if request.method == "POST":
selected_post_id = request.form.get("post_id", type=int)
selected_user_id = request.form.get("user_id", type=int)
content_val = (request.form.get("content") or "").strip()
post_options, user_options = _admin_fill_post_and_user_options(
post_options,
selected_post_id,
user_options,
selected_user_id,
)
target_post = db.session.get(ForumPost, selected_post_id or 0)
target_user = db.session.get(User, selected_user_id or 0)
if not target_post:
error = "请选择有效帖子"
elif not target_user:
error = "请选择有效用户"
elif len(content_val) < 2:
error = "评论至少 2 个字符"
else:
db.session.add(ForumComment(
post_id=target_post.id,
user_id=target_user.id,
content=content_val,
))
db.session.commit()
return redirect(url_for("admin_forum_comments", post_id=target_post.id, msg="已新增评论"))
if not post_options:
error = error or "暂无可评论的帖子,请先新增帖子"
elif not user_options:
error = error or "当前没有可用用户,请先在用户管理中新增用户"
return render_template(
"admin/forum_comment_form.html",
page_title="后台新增评论",
submit_text="创建评论",
action_url=url_for("admin_forum_comment_new"),
cancel_url=url_for("admin_forum_comments"),
error=error,
post_options=post_options,
user_options=user_options,
selected_post_id=selected_post_id,
selected_user_id=selected_user_id,
content_val=content_val,
comment_id=None,
)
@app.route("/admin/forum/comment//edit", methods=["GET", "POST"])
@admin_required
def admin_forum_comment_edit(comment_id):
comment = ForumComment.query.get_or_404(comment_id)
error = ""
post_options = _admin_load_post_options(limit=400)
user_options = _admin_load_user_options(limit=400)
selected_post_id = comment.post_id
selected_user_id = comment.user_id
post_options, user_options = _admin_fill_post_and_user_options(
post_options,
selected_post_id,
user_options,
selected_user_id,
)
content_val = comment.content or ""
if request.method == "POST":
selected_post_id = request.form.get("post_id", type=int)
selected_user_id = request.form.get("user_id", type=int)
content_val = (request.form.get("content") or "").strip()
post_options, user_options = _admin_fill_post_and_user_options(
post_options,
selected_post_id,
user_options,
selected_user_id,
)
target_post = db.session.get(ForumPost, selected_post_id or 0)
target_user = db.session.get(User, selected_user_id or 0)
if not target_post:
error = "请选择有效帖子"
elif not target_user:
error = "请选择有效用户"
elif len(content_val) < 2:
error = "评论至少 2 个字符"
else:
comment.post_id = target_post.id
comment.user_id = target_user.id
comment.content = content_val
db.session.commit()
return redirect(url_for("admin_forum_comments", post_id=target_post.id, msg="已更新评论 #{}".format(comment.id)))
if not post_options:
error = error or "暂无可评论的帖子,请先新增帖子"
elif not user_options:
error = error or "当前没有可用用户,请先在用户管理中新增用户"
return render_template(
"admin/forum_comment_form.html",
page_title="后台编辑评论",
submit_text="保存修改",
action_url=url_for("admin_forum_comment_edit", comment_id=comment.id),
cancel_url=url_for("admin_forum_comments", post_id=selected_post_id),
error=error,
post_options=post_options,
user_options=user_options,
selected_post_id=selected_post_id,
selected_user_id=selected_user_id,
content_val=content_val,
comment_id=comment.id,
)
@app.route("/admin/forum/comment//delete", methods=["POST"])
@admin_required
def admin_forum_comment_delete(comment_id):
comment = ForumComment.query.get_or_404(comment_id)
post_id = comment.post_id
db.session.delete(comment)
db.session.commit()
return redirect(url_for("admin_forum_comments", post_id=post_id, msg="已删除评论 #{}".format(comment_id)))
@app.route("/admin/plan/new", methods=["GET", "POST"])
@admin_required
def admin_plan_new():
provider_id = request.args.get("provider_id", type=int)
if request.method == "POST":
return _save_plan(None)
providers = Provider.query.order_by(Provider.name).all()
return render_template(
"admin/plan_form.html",
plan=None,
country_tags=COUNTRY_TAGS,
providers=providers,
preselected_provider_id=provider_id,
)
@app.route("/admin/plan//edit", methods=["GET", "POST"])
@admin_required
def admin_plan_edit(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
if request.method == "POST":
return _save_plan(plan)
return redirect(url_for("admin_dashboard"))
def _parse_optional_int(s):
s = (s or "").strip()
if not s:
return None
try:
return int(s)
except ValueError:
return None
def _parse_optional_float(s):
s = (s or "").strip()
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _save_plan(plan):
provider_id = request.form.get("provider_id", type=int)
countries = request.form.get("countries", "").strip() or None
vcpu = _parse_optional_int(request.form.get("vcpu"))
memory_gb = _parse_optional_int(request.form.get("memory_gb"))
storage_gb = _parse_optional_int(request.form.get("storage_gb"))
bandwidth_mbps = _parse_optional_int(request.form.get("bandwidth_mbps"))
traffic = request.form.get("traffic", "").strip() or None
price_cny = _parse_optional_float(request.form.get("price_cny"))
price_usd = _parse_optional_float(request.form.get("price_usd"))
currency = request.form.get("currency", "CNY").strip() or "CNY"
official_url = request.form.get("official_url", "").strip() or None
provider = None
if provider_id:
provider = db.session.get(Provider, provider_id)
if not provider:
providers = Provider.query.order_by(Provider.name).all()
return render_template(
"admin/plan_form.html",
plan=plan,
country_tags=COUNTRY_TAGS,
providers=providers,
preselected_provider_id=provider_id,
error="请选择厂商",
)
if plan is None:
plan = VPSPlan(
provider_id=provider.id,
provider=provider.name,
region=None,
name=None,
vcpu=vcpu,
memory_gb=memory_gb,
storage_gb=storage_gb,
bandwidth_mbps=bandwidth_mbps,
traffic=traffic,
price_cny=price_cny,
price_usd=price_usd,
currency=currency,
official_url=official_url,
countries=countries,
)
db.session.add(plan)
else:
plan.provider_id = provider.id
plan.provider = provider.name
plan.region = None
plan.name = None
plan.vcpu = vcpu
plan.memory_gb = memory_gb
plan.storage_gb = storage_gb
plan.bandwidth_mbps = bandwidth_mbps
plan.traffic = traffic
plan.price_cny = price_cny
plan.price_usd = price_usd
plan.currency = currency
plan.official_url = official_url
plan.countries = countries
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:
return redirect(url_for("admin_provider_detail", provider_id=from_provider_id))
return redirect(url_for("admin_dashboard"))
@app.route("/admin/plan//delete", methods=["POST"])
@admin_required
def admin_plan_delete(plan_id):
plan = VPSPlan.query.get_or_404(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"))
# ---------- Excel 导出 / 导入 ----------
EXCEL_HEADERS = [
"厂商", "厂商官网", "国家", "vCPU", "内存GB", "存储GB", "带宽Mbps", "流量",
"月付人民币", "月付美元", "货币", "配置官网",
]
@app.route("/admin/export/excel")
@admin_required
def admin_export_excel():
wb = Workbook()
ws = wb.active
ws.title = "配置"
ws.append(EXCEL_HEADERS)
plans = _query_plans_for_display()
for p in plans:
provider_url = (p.provider_rel.official_url if p.provider_rel else "") or ""
ws.append([
p.provider_name,
provider_url or "",
p.countries or "",
p.vcpu if p.vcpu is not None else "",
p.memory_gb if p.memory_gb is not None else "",
p.storage_gb if p.storage_gb is not None else "",
p.bandwidth_mbps if p.bandwidth_mbps is not None else "",
p.traffic or "",
p.price_cny if p.price_cny is not None else "",
p.price_usd if p.price_usd is not None else "",
p.currency or "CNY",
p.official_url or "",
])
buf = io.BytesIO()
wb.save(buf)
buf.seek(0)
return send_file(
buf,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
as_attachment=True,
download_name="vps_配置_导出.xlsx",
)
def _num(v):
if v is None or v == "":
return None
try:
return int(float(v))
except (ValueError, TypeError):
return None
def _float(v):
if v is None or v == "":
return None
try:
return float(v)
except (ValueError, TypeError):
return None
def _opt_text(v):
if v is None:
return None
s = str(v).strip()
return s or None
def _safe_str(v):
if v is None:
return ""
return str(v).strip()
def _eq_optional(a, b):
if a is None and b is None:
return True
if a is None or b is None:
return False
if isinstance(a, float) or isinstance(b, float):
return abs(float(a) - float(b)) < 1e-9
return a == b
def _record_price_history(plan, source):
if plan is None:
return
if plan.price_cny is None and plan.price_usd is None:
return
if plan.id is None:
db.session.flush()
latest = (
PriceHistory.query
.filter_by(plan_id=plan.id)
.order_by(PriceHistory.captured_at.desc(), PriceHistory.id.desc())
.first()
)
currency = _opt_text(plan.currency) or "CNY"
if latest:
same_currency = _safe_str(latest.currency).upper() == _safe_str(currency).upper()
if same_currency and _eq_optional(latest.price_cny, plan.price_cny) and _eq_optional(latest.price_usd, plan.price_usd):
return
db.session.add(PriceHistory(
plan_id=plan.id,
price_cny=plan.price_cny,
price_usd=plan.price_usd,
currency=currency,
source=source,
))
def _display_val(v):
if v is None or v == "":
return "—"
if isinstance(v, float):
s = "{:.2f}".format(v).rstrip("0").rstrip(".")
return s if s else "0"
return str(v)
def _row_identity_key(row):
return (
_safe_str(row.get("厂商")),
_num(row.get("vCPU")),
_num(row.get("内存GB")),
_num(row.get("存储GB")),
_num(row.get("带宽Mbps")),
_safe_str(row.get("国家")),
_safe_str(row.get("流量")),
)
def _plan_identity_key(plan):
return (
_safe_str(plan.provider_name),
plan.vcpu,
plan.memory_gb,
plan.storage_gb,
plan.bandwidth_mbps,
_safe_str(plan.countries),
_safe_str(plan.traffic),
)
def _plan_diff(plan, row):
"""返回导入行相对于现有 plan 的差异列表。"""
fields = [
("国家", "countries", _opt_text(row.get("国家"))),
("vCPU", "vcpu", _num(row.get("vCPU"))),
("内存GB", "memory_gb", _num(row.get("内存GB"))),
("存储GB", "storage_gb", _num(row.get("存储GB"))),
("带宽Mbps", "bandwidth_mbps", _num(row.get("带宽Mbps"))),
("流量", "traffic", _opt_text(row.get("流量"))),
("月付人民币", "price_cny", _float(row.get("月付人民币"))),
("月付美元", "price_usd", _float(row.get("月付美元"))),
("货币", "currency", _opt_text(row.get("货币")) or "CNY"),
("配置官网", "official_url", _opt_text(row.get("配置官网"))),
]
diffs = []
for label, attr, new_value in fields:
old_value = getattr(plan, attr)
if not _eq_optional(old_value, new_value):
diffs.append({
"label": label,
"old": old_value,
"new": new_value,
"old_display": _display_val(old_value),
"new_display": _display_val(new_value),
})
return diffs
def _upsert_provider_from_row(row):
provider_name = _safe_str(row.get("厂商"))
if not provider_name:
return None
imported_provider_url = _opt_text(row.get("厂商官网"))
provider = Provider.query.filter_by(name=provider_name).first()
if not provider:
provider = Provider(name=provider_name, official_url=imported_provider_url)
db.session.add(provider)
db.session.flush()
elif imported_provider_url and provider.official_url != imported_provider_url:
provider.official_url = imported_provider_url
return provider
def _fill_plan_from_row(plan, row, provider):
plan.provider_id = provider.id
plan.provider = provider.name
plan.region = None
plan.name = None
plan.vcpu = _num(row.get("vCPU"))
plan.memory_gb = _num(row.get("内存GB"))
plan.storage_gb = _num(row.get("存储GB"))
plan.bandwidth_mbps = _num(row.get("带宽Mbps"))
plan.traffic = _opt_text(row.get("流量"))
plan.price_cny = _float(row.get("月付人民币"))
plan.price_usd = _float(row.get("月付美元"))
plan.currency = _opt_text(row.get("货币")) or "CNY"
plan.official_url = _opt_text(row.get("配置官网"))
plan.countries = _opt_text(row.get("国家"))
@app.route("/admin/import", methods=["GET", "POST"])
@admin_required
def admin_import():
if request.method == "GET":
return render_template("admin/import.html")
f = request.files.get("file")
if not f or not f.filename:
return render_template("admin/import.html", error="请选择 Excel 文件")
if not f.filename.lower().endswith(".xlsx"):
return render_template("admin/import.html", error="请上传 .xlsx 文件")
try:
wb = load_workbook(io.BytesIO(f.read()), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(min_row=2, values_only=True))
except Exception as e:
return render_template("admin/import.html", error="解析失败: {}".format(str(e)))
headers = EXCEL_HEADERS
parsed = []
for row in rows:
if not any(cell is not None and str(cell).strip() for cell in row):
continue
d = {}
for i, h in enumerate(headers):
if i < len(row):
v = row[i]
if v is not None and hasattr(v, "strip"):
v = v.strip()
d[h] = v
else:
d[h] = None
parsed.append(d)
if not parsed:
return render_template("admin/import.html", error="文件中没有有效数据行")
plans = VPSPlan.query.all()
plan_index = {}
for p in plans:
key = _plan_identity_key(p)
if key not in plan_index:
plan_index[key] = p
seen_row_keys = set()
preview_items = []
for row in parsed:
key = _row_identity_key(row)
provider_name = key[0]
if not provider_name:
continue
if key in seen_row_keys:
continue
seen_row_keys.add(key)
matched = plan_index.get(key)
if not matched:
preview_items.append({
"action": "add",
"row": row,
"changes": [],
"provider_url_changed": False,
})
continue
changes = _plan_diff(matched, row)
imported_provider_url = _opt_text(row.get("厂商官网"))
old_provider_url = _opt_text(matched.provider_rel.official_url if matched.provider_rel else None)
provider_url_changed = bool(imported_provider_url and imported_provider_url != old_provider_url)
if changes or provider_url_changed:
preview_items.append({
"action": "update",
"plan_id": matched.id,
"row": row,
"changes": changes,
"provider_url_changed": provider_url_changed,
"provider_url_old": old_provider_url,
"provider_url_new": imported_provider_url,
})
session["import_preview"] = preview_items
return redirect(url_for("admin_import_preview"))
@app.route("/admin/import/preview", methods=["GET", "POST"])
@admin_required
def admin_import_preview():
preview_items = session.get("import_preview") or []
add_count = sum(1 for x in preview_items if x.get("action") == "add")
update_count = sum(1 for x in preview_items if x.get("action") == "update")
if request.method == "GET":
return render_template(
"admin/import_preview.html",
rows=list(enumerate(preview_items)),
add_count=add_count,
update_count=update_count,
)
selected = request.form.getlist("row_index")
if not selected:
return render_template(
"admin/import_preview.html",
rows=list(enumerate(preview_items)),
add_count=add_count,
update_count=update_count,
error="请至少勾选一行",
)
indices = sorted(set(int(x) for x in selected if x.isdigit()))
add_applied = 0
update_applied = 0
for i in indices:
if i < 0 or i >= len(preview_items):
continue
item = preview_items[i]
row = item.get("row") or {}
provider = _upsert_provider_from_row(row)
if not provider:
continue
action = item.get("action")
if action == "update":
plan = db.session.get(VPSPlan, item.get("plan_id"))
if not plan:
plan = VPSPlan()
db.session.add(plan)
add_applied += 1
else:
update_applied += 1
_fill_plan_from_row(plan, row, provider)
else:
plan = VPSPlan()
_fill_plan_from_row(plan, row, provider)
db.session.add(plan)
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}))
if __name__ == "__main__":
app.run(debug=True, port=5001)