7591 lines
300 KiB
Python
7591 lines
300 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""云服务器价格对比 - Flask 应用"""
|
||
import io
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import csv
|
||
from time import monotonic
|
||
from datetime import datetime, timedelta, 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_, and_, case
|
||
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,
|
||
ForumTrackEvent,
|
||
ForumTrackDailySummary,
|
||
) # 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
|
||
|
||
|
||
def _ensure_forum_track_columns():
|
||
"""为论坛埋点表补齐后续新增字段。"""
|
||
try:
|
||
engine = db.engine
|
||
dialect = engine.dialect.name
|
||
with engine.connect() as conn:
|
||
if dialect == "mysql":
|
||
alters = [
|
||
"ALTER TABLE forum_track_events ADD COLUMN visitor_id VARCHAR(64) NULL",
|
||
"ALTER TABLE forum_track_events ADD COLUMN cta_variant VARCHAR(16) NULL",
|
||
"ALTER TABLE forum_track_events ADD COLUMN device_type VARCHAR(16) NULL",
|
||
]
|
||
else:
|
||
alters = [
|
||
"ALTER TABLE forum_track_events ADD COLUMN visitor_id TEXT",
|
||
"ALTER TABLE forum_track_events ADD COLUMN cta_variant TEXT",
|
||
"ALTER TABLE forum_track_events ADD COLUMN device_type TEXT",
|
||
]
|
||
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_track_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 = [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" '
|
||
'xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||
]
|
||
for item in url_items:
|
||
lines.append(" <url>")
|
||
lines.append(" <loc>{}</loc>".format(xml_escape(item["loc"])))
|
||
if item.get("lastmod"):
|
||
lines.append(" <lastmod>{}</lastmod>".format(item["lastmod"]))
|
||
if item.get("changefreq"):
|
||
lines.append(" <changefreq>{}</changefreq>".format(item["changefreq"]))
|
||
if item.get("priority"):
|
||
lines.append(" <priority>{}</priority>".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(
|
||
' <xhtml:link rel="alternate" hreflang="{}" href="{}" />'.format(
|
||
xml_escape(hreflang),
|
||
xml_escape(href),
|
||
)
|
||
)
|
||
lines.append(" </url>")
|
||
lines.append("</urlset>")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _build_sitemap_index_xml(entries):
|
||
lines = [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||
]
|
||
for item in entries:
|
||
lines.append(" <sitemap>")
|
||
lines.append(" <loc>{}</loc>".format(xml_escape(item["loc"])))
|
||
if item.get("lastmod"):
|
||
lines.append(" <lastmod>{}</lastmod>".format(item["lastmod"]))
|
||
lines.append(" </sitemap>")
|
||
lines.append("</sitemapindex>")
|
||
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("<p>{}</p>".format(str(escape(raw)).replace("\n", "<br>")))
|
||
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 _get_or_create_visitor_id():
|
||
visitor_id = (session.get("visitor_id") or "").strip().lower()
|
||
if re.match(r"^[a-f0-9]{16,64}$", visitor_id):
|
||
return visitor_id
|
||
visitor_id = os.urandom(16).hex()
|
||
session["visitor_id"] = visitor_id
|
||
return visitor_id
|
||
|
||
|
||
def _stable_pick_variant(experiment_key, subject_key, variants):
|
||
safe_variants = [str(v).strip().lower() for v in (variants or []) if str(v).strip()]
|
||
if not safe_variants:
|
||
return ""
|
||
digest = hashlib.sha256("{}|{}".format(experiment_key, subject_key).encode("utf-8")).hexdigest()
|
||
idx = int(digest[:8], 16) % len(safe_variants)
|
||
return safe_variants[idx]
|
||
|
||
|
||
def _resolve_cta_variant(current_user=None, requested_variant=None):
|
||
allowed = ("control", "intent")
|
||
requested = (requested_variant or "").strip().lower()
|
||
if requested in allowed:
|
||
return requested
|
||
if current_user and getattr(current_user, "id", None):
|
||
subject_key = "u:{}".format(current_user.id)
|
||
else:
|
||
subject_key = "v:{}".format(_get_or_create_visitor_id())
|
||
return _stable_pick_variant("forum_post_detail_cta_v1", subject_key, allowed)
|
||
|
||
|
||
def _normalize_cta_variant(value):
|
||
text_val = (value or "").strip().lower()
|
||
return text_val if text_val in {"control", "intent"} else ""
|
||
|
||
|
||
def _normalize_device_type(value):
|
||
text_val = (value or "").strip().lower()
|
||
return text_val if text_val in {"mobile", "desktop", "tablet"} else ""
|
||
|
||
|
||
def _guess_device_type_from_user_agent(raw_user_agent):
|
||
ua = (raw_user_agent or "").strip().lower()
|
||
if not ua:
|
||
return ""
|
||
if "ipad" in ua or "tablet" in ua or ("android" in ua and "mobile" not in ua):
|
||
return "tablet"
|
||
if any(x in ua for x in ("iphone", "ipod", "windows phone", "mobile")):
|
||
return "mobile"
|
||
return "desktop"
|
||
|
||
|
||
def _increment_track_daily_summary(event_name, cta_variant, event_dt=None):
|
||
if not event_name:
|
||
return
|
||
row_day = (event_dt or datetime.utcnow()).date()
|
||
row_variant = _normalize_cta_variant(cta_variant) or "unknown"
|
||
row = (
|
||
ForumTrackDailySummary.query
|
||
.filter_by(event_day=row_day, cta_variant=row_variant, event_name=event_name)
|
||
.first()
|
||
)
|
||
if row:
|
||
row.total = int(row.total or 0) + 1
|
||
return
|
||
db.session.add(ForumTrackDailySummary(
|
||
event_day=row_day,
|
||
cta_variant=row_variant,
|
||
event_name=event_name,
|
||
total=1,
|
||
))
|
||
|
||
|
||
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_pricing_prefill(post, lang="zh"):
|
||
pricing_url = url_for("index", lang="en") if lang == "en" else url_for("index")
|
||
if not post or not getattr(post, "id", None):
|
||
return {
|
||
"url": pricing_url,
|
||
"hint_text": _pick_lang(
|
||
"可在价格页按厂商、地区、配置继续筛选。",
|
||
"Use provider/region/spec filters on the pricing page.",
|
||
lang,
|
||
),
|
||
}
|
||
|
||
source_title = (post.title or "").strip()
|
||
raw_text = "{}\n{}".format(source_title, post.content or "")
|
||
text_lower = raw_text.lower()
|
||
|
||
provider_name = ""
|
||
provider_rows = Provider.query.order_by(Provider.id.asc()).limit(240).all()
|
||
for provider in provider_rows:
|
||
name = (provider.name or "").strip()
|
||
if name and name.lower() in text_lower:
|
||
provider_name = name
|
||
break
|
||
|
||
region_name = ""
|
||
for region in COUNTRY_TAGS:
|
||
token = (region or "").strip()
|
||
if token and token in raw_text:
|
||
region_name = token
|
||
break
|
||
|
||
memory_filter = 0
|
||
mem_match = re.search(r"(?:内存|memory|ram)\s*[::]?\s*(\d+(?:\.\d+)?)\s*(?:g|gb|gib)", raw_text, flags=re.IGNORECASE)
|
||
if not mem_match:
|
||
mem_match = re.search(r"(\d+(?:\.\d+)?)\s*(?:g|gb|gib)\s*(?:内存|memory|ram)", raw_text, flags=re.IGNORECASE)
|
||
if mem_match:
|
||
try:
|
||
mem_gb = float(mem_match.group(1))
|
||
except Exception:
|
||
mem_gb = 0.0
|
||
if mem_gb >= 8:
|
||
memory_filter = 8
|
||
elif mem_gb >= 4:
|
||
memory_filter = 4
|
||
elif mem_gb >= 2:
|
||
memory_filter = 2
|
||
elif mem_gb >= 1:
|
||
memory_filter = 1
|
||
|
||
price_filter = "0"
|
||
budget_match = re.search(
|
||
r"(?:预算|budget|price)\s*[::]?\s*(?:¥|¥|\$)?\s*(\d+(?:\.\d+)?)\s*(?:-|~|到|to)\s*(?:¥|¥|\$)?\s*(\d+(?:\.\d+)?)",
|
||
raw_text,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
if budget_match:
|
||
try:
|
||
budget_low = float(budget_match.group(1))
|
||
budget_high = float(budget_match.group(2))
|
||
if budget_high < budget_low:
|
||
budget_low, budget_high = budget_high, budget_low
|
||
if budget_high <= 50:
|
||
price_filter = "0-50"
|
||
elif budget_high <= 100:
|
||
price_filter = "50-100"
|
||
elif budget_high <= 300:
|
||
price_filter = "100-300"
|
||
elif budget_high <= 500:
|
||
price_filter = "300-500"
|
||
else:
|
||
price_filter = "500-99999"
|
||
except Exception:
|
||
price_filter = "0"
|
||
|
||
source_title_short = source_title[:72]
|
||
params = {
|
||
"source_post": int(post.id),
|
||
"source_title": source_title_short,
|
||
}
|
||
if provider_name:
|
||
params["provider"] = provider_name
|
||
if region_name:
|
||
params["region"] = region_name
|
||
if memory_filter:
|
||
params["memory"] = str(memory_filter)
|
||
if price_filter != "0":
|
||
params["price"] = price_filter
|
||
if source_title_short:
|
||
params["search"] = source_title_short[:40]
|
||
if lang == "en":
|
||
params["lang"] = "en"
|
||
|
||
hint_parts = []
|
||
if provider_name:
|
||
hint_parts.append(_pick_lang("厂商 {}".format(provider_name), "provider {}".format(provider_name), lang))
|
||
if region_name:
|
||
hint_parts.append(_pick_lang("地区 {}".format(region_name), "region {}".format(region_name), lang))
|
||
if memory_filter:
|
||
hint_parts.append(_pick_lang("内存≥{}GB".format(memory_filter), "memory ≥{}GB".format(memory_filter), lang))
|
||
if price_filter != "0":
|
||
hint_parts.append(_pick_lang("预算区间已预填", "budget range prefilled", lang))
|
||
|
||
if hint_parts:
|
||
hint_text = _pick_lang(
|
||
"已预填筛选:{}",
|
||
"Prefilled filters: {}",
|
||
lang,
|
||
).format(" / ".join(hint_parts[:3]))
|
||
else:
|
||
hint_text = _pick_lang(
|
||
"已带入标题关键词,可在价格页继续微调筛选。",
|
||
"Title keywords were carried over. Fine-tune filters on the pricing page.",
|
||
lang,
|
||
)
|
||
|
||
return {
|
||
"url": url_for("index", **params),
|
||
"hint_text": hint_text,
|
||
"provider": provider_name,
|
||
"region": region_name,
|
||
"memory": memory_filter,
|
||
"price": price_filter,
|
||
}
|
||
|
||
|
||
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_requirement_draft(post, lang="zh", cta_variant=""):
|
||
if not post or not getattr(post, "id", None):
|
||
return {}
|
||
source_title = (post.title or _pick_lang("论坛主题", "Forum Topic", lang)).strip()
|
||
if len(source_title) > 86:
|
||
source_title = source_title[:86]
|
||
source_excerpt = _plain_excerpt(post.content or "", limit=180)
|
||
source_url = _build_post_detail_url(post.id, lang=lang)
|
||
|
||
draft_title = _pick_lang(
|
||
"[需求补充] {}:预算/地区/用途".format(source_title),
|
||
"[Follow-up Need] {}: budget/region/workload".format(source_title),
|
||
lang,
|
||
)[:160]
|
||
if lang == "en":
|
||
draft_content = "\n".join([
|
||
"## Context",
|
||
"- Source topic: {}".format(source_title),
|
||
"- Source URL: {}".format(source_url),
|
||
"- Planned launch window:",
|
||
"",
|
||
"## Constraints",
|
||
"- Budget range:",
|
||
"- Target region/routes:",
|
||
"- Workload type (web/api/db/proxy):",
|
||
"- Acceptable latency/loss:",
|
||
"",
|
||
"## Expected Spec",
|
||
"- CPU:",
|
||
"- Memory:",
|
||
"- Storage:",
|
||
"- Bandwidth/traffic:",
|
||
"",
|
||
"## Extra Notes",
|
||
source_excerpt or "Please continue from the source topic and add measurable constraints.",
|
||
])
|
||
intro_text = "Prefill a requirement template from this topic and publish directly."
|
||
action_text = "Prefill and Publish Need"
|
||
guest_text = "Login to Publish Need"
|
||
tips = [
|
||
"Auto-carries source topic and context.",
|
||
"Fill budget/region/workload for better matching.",
|
||
"Keep quantitative constraints for faster replies.",
|
||
]
|
||
else:
|
||
draft_content = "\n".join([
|
||
"## 业务背景",
|
||
"- 参考帖子:{}".format(source_title),
|
||
"- 原帖链接:{}".format(source_url),
|
||
"- 预计上线时间:",
|
||
"",
|
||
"## 需求约束",
|
||
"- 预算区间:",
|
||
"- 目标地区/线路:",
|
||
"- 业务类型(Web/API/数据库/代理等):",
|
||
"- 可接受抖动与丢包:",
|
||
"",
|
||
"## 期望配置",
|
||
"- CPU:",
|
||
"- 内存:",
|
||
"- 存储:",
|
||
"- 流量/带宽:",
|
||
"",
|
||
"## 补充说明",
|
||
source_excerpt or "请基于原帖继续补充可量化的需求约束。",
|
||
])
|
||
intro_text = "基于本帖一键带入需求模板,补齐预算与约束后可直接发布。"
|
||
action_text = "一键带入并发布需求"
|
||
guest_text = "登录后发布需求"
|
||
tips = [
|
||
"自动带入原帖标题和背景。",
|
||
"优先填写预算、地区和业务类型。",
|
||
"约束越量化,回复质量越高。",
|
||
]
|
||
|
||
params = {
|
||
"title": draft_title,
|
||
"content": draft_content[:6000],
|
||
"from_post": int(post.id),
|
||
}
|
||
safe_variant = _normalize_cta_variant(cta_variant)
|
||
if safe_variant:
|
||
params["cta_variant"] = safe_variant
|
||
post_category = (post.category or "").strip()
|
||
if post_category:
|
||
params["category"] = post_category
|
||
if lang == "en":
|
||
params["lang"] = "en"
|
||
new_topic_url = url_for("forum_post_new", **params)
|
||
pricing_url = url_for("index", lang="en") if lang == "en" else url_for("index")
|
||
return {
|
||
"new_topic_url": new_topic_url,
|
||
"source_post_id": int(post.id),
|
||
"preview_title": draft_title,
|
||
"intro_text": intro_text,
|
||
"action_text_member": action_text,
|
||
"action_text_guest": guest_text,
|
||
"tips": tips,
|
||
"pricing_url": pricing_url,
|
||
}
|
||
|
||
|
||
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/<path:filename>")
|
||
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_cta_impression",
|
||
"post_detail_mobile_bar_impression",
|
||
"post_detail_jump_comments",
|
||
"post_detail_related_click",
|
||
"post_detail_plan_click",
|
||
"post_detail_comment_submit",
|
||
"post_detail_sidebar_compare",
|
||
"post_detail_resource_click",
|
||
"post_detail_copy_link",
|
||
"post_detail_copy_link_success",
|
||
"post_detail_copy_link_failed",
|
||
"post_detail_outline_click",
|
||
"post_detail_inline_plan_click",
|
||
"post_detail_inline_plan_view_all",
|
||
"post_detail_requirement_template_click",
|
||
"post_detail_requirement_template_submit",
|
||
}
|
||
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")
|
||
cta_variant = _normalize_cta_variant(payload.get("cta_variant"))
|
||
device_type = _normalize_device_type(payload.get("device_type"))
|
||
if not cta_variant and event_name == "post_detail_cta_impression":
|
||
cta_variant = _normalize_cta_variant(label)
|
||
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()
|
||
visitor_id = _get_or_create_visitor_id()
|
||
event_data = {
|
||
"event_name": event_name,
|
||
"label": label,
|
||
"post_id": post_id,
|
||
"user_id": user.id if user else None,
|
||
"visitor_id": visitor_id,
|
||
"cta_variant": cta_variant or None,
|
||
"device_type": device_type or 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)),
|
||
}
|
||
try:
|
||
now_dt = datetime.utcnow()
|
||
db.session.add(ForumTrackEvent(
|
||
event_name=event_name,
|
||
label=label or None,
|
||
post_id=post_id,
|
||
user_id=user.id if user else None,
|
||
visitor_id=visitor_id,
|
||
cta_variant=cta_variant or None,
|
||
device_type=device_type or None,
|
||
page_path=page_path or None,
|
||
endpoint_path=request.path,
|
||
referer=(request.headers.get("Referer") or "")[:255] or None,
|
||
ip=(request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120] or None,
|
||
created_at=now_dt,
|
||
))
|
||
_increment_track_daily_summary(
|
||
event_name=event_name,
|
||
cta_variant=cta_variant,
|
||
event_dt=now_dt,
|
||
)
|
||
db.session.commit()
|
||
except Exception:
|
||
db.session.rollback()
|
||
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/<int:notification_id>/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/<int:notification_id>/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 "综合讨论"
|
||
prefill_source_post_id = request.args.get("from_post", type=int) or 0
|
||
if prefill_source_post_id < 1:
|
||
prefill_source_post_id = 0
|
||
prefill_cta_variant = _normalize_cta_variant(request.args.get("cta_variant"))
|
||
prefill_applied = False
|
||
if request.method != "POST":
|
||
title = (request.args.get("title") or "").strip()
|
||
content = (request.args.get("content") or "").strip()
|
||
requested_category = (request.args.get("category") or "").strip()
|
||
if requested_category and requested_category in available_categories:
|
||
category = requested_category
|
||
if len(title) > 160:
|
||
title = title[:160]
|
||
if len(content) > 6000:
|
||
content = content[:6000]
|
||
prefill_applied = bool(title or content or prefill_source_post_id)
|
||
if request.method == "POST":
|
||
posted_source_post_id = request.form.get("from_post", type=int) or 0
|
||
if posted_source_post_id > 0:
|
||
prefill_source_post_id = posted_source_post_id
|
||
else:
|
||
prefill_source_post_id = 0
|
||
posted_variant = _normalize_cta_variant(request.form.get("cta_variant"))
|
||
if posted_variant:
|
||
prefill_cta_variant = posted_variant
|
||
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()
|
||
if prefill_source_post_id > 0:
|
||
try:
|
||
now_dt = datetime.utcnow()
|
||
db.session.add(ForumTrackEvent(
|
||
event_name="post_detail_requirement_template_submit",
|
||
label="from_post_{}_to_post_{}".format(prefill_source_post_id, post.id),
|
||
post_id=prefill_source_post_id,
|
||
user_id=user.id if user else None,
|
||
visitor_id=_get_or_create_visitor_id(),
|
||
cta_variant=prefill_cta_variant or None,
|
||
device_type=_guess_device_type_from_user_agent(request.headers.get("User-Agent")) or None,
|
||
page_path=_build_post_detail_url(prefill_source_post_id, lang=lang)[:255],
|
||
endpoint_path=request.path,
|
||
referer=(request.headers.get("Referer") or "")[:255] or None,
|
||
ip=(request.headers.get("X-Forwarded-For") or request.remote_addr or "")[:120] or None,
|
||
created_at=now_dt,
|
||
))
|
||
_increment_track_daily_summary(
|
||
event_name="post_detail_requirement_template_submit",
|
||
cta_variant=prefill_cta_variant,
|
||
event_dt=now_dt,
|
||
)
|
||
db.session.commit()
|
||
except Exception:
|
||
db.session.rollback()
|
||
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",
|
||
prefill_source_post_id=prefill_source_post_id,
|
||
prefill_cta_variant=prefill_cta_variant,
|
||
prefill_applied=prefill_applied,
|
||
)
|
||
|
||
|
||
@app.route("/forum/post/<int:post_id>/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/<int:post_id>/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/<int:post_id>")
|
||
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)
|
||
cta_variant = _resolve_cta_variant(
|
||
current_user=current_user,
|
||
requested_variant=request.args.get("cv"),
|
||
)
|
||
if cta_variant == "intent":
|
||
cta_copy = {
|
||
"headline": _pick_lang("30 秒筛出可落地 VPS 方案", "Shortlist Deployable VPS in 30 Seconds", lang),
|
||
"description": _pick_lang(
|
||
"用本帖结论作为筛选条件,先锁定预算与地区,再看稳定性和可交付能力。",
|
||
"Use this topic's conclusions as filters: lock budget and region first, then compare stability and deliverability.",
|
||
lang,
|
||
),
|
||
"primary_button": _pick_lang("开始快速筛选", "Start Shortlisting", lang),
|
||
"secondary_button_member": _pick_lang("发布预算与需求", "Post Budget & Needs", lang),
|
||
"secondary_button_guest": _pick_lang("登录后发布需求", "Login to Post Needs", lang),
|
||
"sidebar_button": _pick_lang("30 秒筛选 VPS", "30s VPS Shortlist", lang),
|
||
}
|
||
else:
|
||
cta_copy = {
|
||
"headline": _pick_lang("准备选型或采购 VPS?", "Ready to shortlist or buy VPS?", lang),
|
||
"description": _pick_lang(
|
||
"结合本帖讨论,去价格页快速筛选可落地方案。",
|
||
"Use insights from this topic and shortlist actionable plans on the pricing page.",
|
||
lang,
|
||
),
|
||
"primary_button": _pick_lang("去比价筛选", "Compare Plans", lang),
|
||
"secondary_button_member": _pick_lang("发布采购需求", "Post Requirement", lang),
|
||
"secondary_button_guest": _pick_lang("登录后发帖", "Login to Post", lang),
|
||
"sidebar_button": _pick_lang("立即筛选 VPS", "Shortlist VPS", lang),
|
||
}
|
||
cta_track_suffix = cta_variant
|
||
detail_resource_links = _build_post_resource_links(post=post, lang=lang)
|
||
pricing_prefill = _build_post_pricing_prefill(post=post, lang=lang)
|
||
requirement_draft = _build_post_requirement_draft(post=post, lang=lang, cta_variant=cta_variant)
|
||
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,
|
||
pricing_prefill=pricing_prefill,
|
||
requirement_draft=requirement_draft,
|
||
detail_faq_items=detail_faq_items,
|
||
comments_count=comments_count,
|
||
read_minutes=read_minutes,
|
||
cta_variant=cta_variant,
|
||
cta_copy=cta_copy,
|
||
cta_track_suffix=cta_track_suffix,
|
||
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/<int:post_id>/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/<int:post_id>/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/<int:post_id>/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/<int:comment_id>/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/<int:comment_id>/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 = [
|
||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
||
" <channel>",
|
||
" <title>{}</title>".format(xml_escape(channel_title)),
|
||
" <description>{}</description>".format(xml_escape(channel_description)),
|
||
" <link>{}</link>".format(xml_escape(channel_link)),
|
||
" <language>{}</language>".format("en-us" if lang == "en" else "zh-cn"),
|
||
" <lastBuildDate>{}</lastBuildDate>".format(xml_escape(last_build_date)),
|
||
' <atom:link href="{}" rel="self" type="application/rss+xml" />'.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([
|
||
" <item>",
|
||
" <title>{}</title>".format(xml_escape(post.title or _pick_lang("未命名主题", "Untitled topic", lang))),
|
||
" <description>{}</description>".format(xml_escape(summary)),
|
||
" <link>{}</link>".format(xml_escape(post_url)),
|
||
" <guid>{}</guid>".format(xml_escape(post_url)),
|
||
" <author>{}</author>".format(xml_escape(author)),
|
||
" <category>{}</category>".format(xml_escape(category)),
|
||
" <pubDate>{}</pubDate>".format(xml_escape(pub_date)),
|
||
" </item>",
|
||
])
|
||
lines.extend([
|
||
" </channel>",
|
||
"</rss>",
|
||
])
|
||
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-<lang_code>-<int:page>.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/<int:plan_id>")
|
||
@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/<int:plan_id>/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,
|
||
)
|
||
|
||
|
||
def _admin_tracking_days_variant():
|
||
days = request.args.get("days", type=int) or 14
|
||
days = max(1, min(days, 90))
|
||
selected_variant = (request.args.get("variant") or "all").strip().lower()
|
||
if selected_variant not in {"all", "control", "intent", "unknown"}:
|
||
selected_variant = "all"
|
||
return days, selected_variant
|
||
|
||
|
||
def _admin_tracking_selected_device(raw_value=None):
|
||
selected_device = ((raw_value if raw_value is not None else request.args.get("device")) or "all").strip().lower()
|
||
if selected_device not in {"all", "mobile", "desktop", "tablet", "unknown"}:
|
||
selected_device = "all"
|
||
return selected_device
|
||
|
||
|
||
def _admin_tracking_event_filters(start_at, selected_variant, selected_device="all"):
|
||
filters = [ForumTrackEvent.created_at >= start_at]
|
||
if selected_variant == "control":
|
||
filters.append(ForumTrackEvent.cta_variant == "control")
|
||
elif selected_variant == "intent":
|
||
filters.append(ForumTrackEvent.cta_variant == "intent")
|
||
elif selected_variant == "unknown":
|
||
filters.append(or_(ForumTrackEvent.cta_variant.is_(None), ForumTrackEvent.cta_variant == ""))
|
||
if selected_device == "mobile":
|
||
filters.append(ForumTrackEvent.device_type == "mobile")
|
||
elif selected_device == "desktop":
|
||
filters.append(ForumTrackEvent.device_type == "desktop")
|
||
elif selected_device == "tablet":
|
||
filters.append(ForumTrackEvent.device_type == "tablet")
|
||
elif selected_device == "unknown":
|
||
filters.append(or_(
|
||
ForumTrackEvent.device_type.is_(None),
|
||
ForumTrackEvent.device_type == "",
|
||
ForumTrackEvent.device_type == "unknown",
|
||
))
|
||
return filters
|
||
|
||
|
||
def _admin_tracking_daily_filters(start_day, selected_variant):
|
||
filters = [ForumTrackDailySummary.event_day >= start_day]
|
||
if selected_variant == "control":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "control")
|
||
elif selected_variant == "intent":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "intent")
|
||
elif selected_variant == "unknown":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "unknown")
|
||
return filters
|
||
|
||
|
||
def _admin_tracking_daily_filters_exact(day_value, selected_variant):
|
||
filters = [ForumTrackDailySummary.event_day == day_value]
|
||
if selected_variant == "control":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "control")
|
||
elif selected_variant == "intent":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "intent")
|
||
elif selected_variant == "unknown":
|
||
filters.append(ForumTrackDailySummary.cta_variant == "unknown")
|
||
return filters
|
||
|
||
|
||
def _admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device="all"):
|
||
filters = [
|
||
ForumTrackEvent.created_at >= start_at,
|
||
ForumTrackEvent.created_at < end_at,
|
||
]
|
||
if selected_variant == "control":
|
||
filters.append(ForumTrackEvent.cta_variant == "control")
|
||
elif selected_variant == "intent":
|
||
filters.append(ForumTrackEvent.cta_variant == "intent")
|
||
elif selected_variant == "unknown":
|
||
filters.append(or_(ForumTrackEvent.cta_variant.is_(None), ForumTrackEvent.cta_variant == ""))
|
||
if selected_device == "mobile":
|
||
filters.append(ForumTrackEvent.device_type == "mobile")
|
||
elif selected_device == "desktop":
|
||
filters.append(ForumTrackEvent.device_type == "desktop")
|
||
elif selected_device == "tablet":
|
||
filters.append(ForumTrackEvent.device_type == "tablet")
|
||
elif selected_device == "unknown":
|
||
filters.append(or_(
|
||
ForumTrackEvent.device_type.is_(None),
|
||
ForumTrackEvent.device_type == "",
|
||
ForumTrackEvent.device_type == "unknown",
|
||
))
|
||
return filters
|
||
|
||
|
||
def _admin_tracking_day_value():
|
||
today = datetime.utcnow().date()
|
||
default_day = today - timedelta(days=1)
|
||
raw_day = (request.args.get("day") or "").strip()
|
||
if not raw_day:
|
||
return default_day
|
||
try:
|
||
parsed_day = datetime.strptime(raw_day, "%Y-%m-%d").date()
|
||
except Exception:
|
||
return default_day
|
||
min_day = today - timedelta(days=365)
|
||
if parsed_day < min_day:
|
||
return min_day
|
||
if parsed_day > default_day:
|
||
return default_day
|
||
return parsed_day
|
||
|
||
|
||
def _admin_tracking_summary_from_event_map(event_map):
|
||
summary = {
|
||
"events": int(sum(int(v or 0) for v in event_map.values())),
|
||
"impressions": int(event_map.get("post_detail_cta_impression", 0) or 0),
|
||
"mobile_bar_impressions": int(event_map.get("post_detail_mobile_bar_impression", 0) or 0),
|
||
"mobile_pricing_clicks": 0,
|
||
"pricing_clicks": int(event_map.get("post_detail_cta_pricing", 0) or 0),
|
||
"new_topic_clicks": int(event_map.get("post_detail_cta_new_topic", 0) or 0),
|
||
"template_clicks": int(event_map.get("post_detail_requirement_template_click", 0) or 0),
|
||
"template_submits": int(event_map.get("post_detail_requirement_template_submit", 0) or 0),
|
||
"comment_submits": int(event_map.get("post_detail_comment_submit", 0) or 0),
|
||
"copy_success": int(event_map.get("post_detail_copy_link_success", 0) or 0),
|
||
"resource_clicks": int(event_map.get("post_detail_resource_click", 0) or 0),
|
||
"related_clicks": int(event_map.get("post_detail_related_click", 0) or 0),
|
||
"outline_clicks": int(event_map.get("post_detail_outline_click", 0) or 0),
|
||
}
|
||
impressions = summary["impressions"] or 0
|
||
template_clicks = summary["template_clicks"] or 0
|
||
rates = {
|
||
"pricing_ctr": round(summary["pricing_clicks"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"mobile_pricing_rate": 0.0,
|
||
"new_topic_rate": round(summary["new_topic_clicks"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_rate": round(summary["template_clicks"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(summary["template_submits"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(summary["template_submits"] * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(summary["comment_submits"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"copy_rate": round(summary["copy_success"] * 100.0 / impressions, 2) if impressions else 0.0,
|
||
}
|
||
return summary, rates
|
||
|
||
|
||
def _admin_tracking_event_map_for_day(day_value, selected_variant, selected_device="all"):
|
||
if selected_device == "all":
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackDailySummary.event_name,
|
||
func.sum(ForumTrackDailySummary.total).label("total"),
|
||
)
|
||
.filter(*_admin_tracking_daily_filters_exact(day_value, selected_variant))
|
||
.group_by(ForumTrackDailySummary.event_name)
|
||
.all()
|
||
)
|
||
return {row.event_name: int(row.total or 0) for row in rows}
|
||
|
||
start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
end_at = start_at + timedelta(days=1)
|
||
return _admin_tracking_event_map_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
|
||
|
||
def _admin_tracking_event_map_for_range(start_at, end_at, selected_variant, selected_device="all"):
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(ForumTrackEvent.event_name)
|
||
.all()
|
||
)
|
||
return {row.event_name: int(row.total or 0) for row in rows}
|
||
|
||
|
||
def _admin_tracking_variant_summary_for_day(day_value, selected_variant="all", selected_device="all"):
|
||
if selected_device == "all":
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackDailySummary.cta_variant.label("variant"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_pricing", ForumTrackDailySummary.total), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_new_topic", ForumTrackDailySummary.total), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_click", ForumTrackDailySummary.total), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_submit", ForumTrackDailySummary.total), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_comment_submit", ForumTrackDailySummary.total), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_daily_filters_exact(day_value, selected_variant))
|
||
.group_by(ForumTrackDailySummary.cta_variant)
|
||
.order_by(ForumTrackDailySummary.cta_variant.asc())
|
||
.all()
|
||
)
|
||
else:
|
||
start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
end_at = start_at + timedelta(days=1)
|
||
variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown")
|
||
rows = (
|
||
db.session.query(
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(variant_expr)
|
||
.order_by(variant_expr.asc())
|
||
.all()
|
||
)
|
||
items = []
|
||
for row in rows:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
new_topic_clicks = int(row.new_topic_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
items.append({
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"new_topic_clicks": new_topic_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
return items
|
||
|
||
|
||
def _admin_tracking_top_posts_for_range(start_at, end_at, selected_variant, selected_device="all", limit=12, sort_mode="pricing"):
|
||
if sort_mode == "template":
|
||
order_fields = [
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).desc(),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).desc(),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(),
|
||
ForumTrackEvent.post_id.desc(),
|
||
]
|
||
else:
|
||
order_fields = [
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(),
|
||
ForumTrackEvent.post_id.desc(),
|
||
]
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackEvent.post_id.label("post_id"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device), ForumTrackEvent.post_id.isnot(None))
|
||
.group_by(ForumTrackEvent.post_id)
|
||
.having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0)
|
||
.order_by(*order_fields)
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
post_ids = [int(row.post_id) for row in rows if row.post_id is not None]
|
||
title_map = {}
|
||
if post_ids:
|
||
title_map = {
|
||
pid: title
|
||
for pid, title in (
|
||
db.session.query(ForumPost.id, ForumPost.title)
|
||
.filter(ForumPost.id.in_(post_ids))
|
||
.all()
|
||
)
|
||
}
|
||
items = []
|
||
for row in rows:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
pid = int(row.post_id or 0)
|
||
items.append({
|
||
"post_id": pid,
|
||
"title": title_map.get(pid, "帖子已删除或不可见"),
|
||
"impressions": impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
return items
|
||
|
||
|
||
def _admin_tracking_top_posts_for_day(day_value, selected_variant, selected_device="all", limit=12, sort_mode="pricing"):
|
||
start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
end_at = start_at + timedelta(days=1)
|
||
return _admin_tracking_top_posts_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=limit,
|
||
sort_mode=sort_mode,
|
||
)
|
||
|
||
|
||
def _admin_tracking_top_labels_for_day(day_value, selected_variant, selected_device="all", limit=20):
|
||
start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
end_at = start_at + timedelta(days=1)
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
ForumTrackEvent.label,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(
|
||
*_admin_tracking_event_filters_exact(start_at, end_at, selected_variant, selected_device),
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
)
|
||
.group_by(ForumTrackEvent.event_name, ForumTrackEvent.label)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
return [{
|
||
"event_name": row.event_name,
|
||
"label": row.label,
|
||
"total": int(row.total or 0),
|
||
} for row in rows]
|
||
|
||
|
||
def _admin_tracking_mobile_pricing_clicks(start_at, end_at, selected_variant, selected_device="all"):
|
||
row = (
|
||
db.session.query(func.count(ForumTrackEvent.id).label("total"))
|
||
.filter(
|
||
*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
),
|
||
ForumTrackEvent.event_name == "post_detail_cta_pricing",
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
ForumTrackEvent.label.like("mobile_%"),
|
||
)
|
||
.first()
|
||
)
|
||
return int(getattr(row, "total", 0) or 0)
|
||
|
||
|
||
@app.route("/admin/forum/tracking")
|
||
@admin_required
|
||
def admin_forum_tracking():
|
||
days, selected_variant = _admin_tracking_days_variant()
|
||
selected_device = _admin_tracking_selected_device()
|
||
start_at = datetime.utcnow() - timedelta(days=days)
|
||
days_options = [3, 7, 14, 30, 60, 90]
|
||
device_options = ["all", "mobile", "desktop", "tablet", "unknown"]
|
||
|
||
summary = {
|
||
"events": 0,
|
||
"impressions": 0,
|
||
"mobile_bar_impressions": 0,
|
||
"mobile_pricing_clicks": 0,
|
||
"pricing_clicks": 0,
|
||
"new_topic_clicks": 0,
|
||
"template_clicks": 0,
|
||
"template_submits": 0,
|
||
"comment_submits": 0,
|
||
"copy_success": 0,
|
||
"resource_clicks": 0,
|
||
"related_clicks": 0,
|
||
"outline_clicks": 0,
|
||
}
|
||
summary_rates = {
|
||
"pricing_ctr": 0.0,
|
||
"mobile_pricing_rate": 0.0,
|
||
"new_topic_rate": 0.0,
|
||
"template_rate": 0.0,
|
||
"template_submit_rate": 0.0,
|
||
"template_completion_rate": 0.0,
|
||
"comment_rate": 0.0,
|
||
"copy_rate": 0.0,
|
||
}
|
||
mobile_funnel = {
|
||
"mobile_impressions": 0,
|
||
"mobile_bar_impressions": 0,
|
||
"mobile_pricing_clicks": 0,
|
||
"mobile_pricing_rate": 0.0,
|
||
"mobile_traffic_share": 0.0,
|
||
"mobile_click_share": 0.0,
|
||
}
|
||
variant_summary = []
|
||
device_summary = []
|
||
daily_rows = []
|
||
post_rows = []
|
||
template_post_rows = []
|
||
label_rows = []
|
||
recent_rows = []
|
||
error = ""
|
||
|
||
base_filters = _admin_tracking_event_filters(
|
||
start_at=start_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown")
|
||
device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown")
|
||
|
||
try:
|
||
agg_row = (
|
||
db.session.query(
|
||
func.count(ForumTrackEvent.id).label("events"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"),
|
||
func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_resource_click", 1), else_=0)).label("resource_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_related_click", 1), else_=0)).label("related_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_outline_click", 1), else_=0)).label("outline_clicks"),
|
||
)
|
||
.filter(*base_filters)
|
||
.first()
|
||
)
|
||
if agg_row:
|
||
for key in summary.keys():
|
||
summary[key] = int(getattr(agg_row, key, 0) or 0)
|
||
impressions = summary["impressions"] or 0
|
||
if impressions > 0:
|
||
summary_rates["pricing_ctr"] = round(summary["pricing_clicks"] * 100.0 / impressions, 2)
|
||
summary_rates["new_topic_rate"] = round(summary["new_topic_clicks"] * 100.0 / impressions, 2)
|
||
summary_rates["template_rate"] = round(summary["template_clicks"] * 100.0 / impressions, 2)
|
||
summary_rates["template_submit_rate"] = round(summary["template_submits"] * 100.0 / impressions, 2)
|
||
summary_rates["comment_rate"] = round(summary["comment_submits"] * 100.0 / impressions, 2)
|
||
summary_rates["copy_rate"] = round(summary["copy_success"] * 100.0 / impressions, 2)
|
||
template_clicks = summary["template_clicks"] or 0
|
||
if template_clicks > 0:
|
||
summary_rates["template_completion_rate"] = round(summary["template_submits"] * 100.0 / template_clicks, 2)
|
||
mobile_bar_impressions = summary["mobile_bar_impressions"] or 0
|
||
if mobile_bar_impressions > 0:
|
||
summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2)
|
||
|
||
variant_rows = (
|
||
db.session.query(
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"),
|
||
)
|
||
.filter(*base_filters)
|
||
.group_by(variant_expr)
|
||
.order_by(variant_expr.asc())
|
||
.all()
|
||
)
|
||
for row in variant_rows:
|
||
impression_count = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
new_topic_clicks = int(row.new_topic_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
copy_success = int(row.copy_success or 0)
|
||
variant_summary.append({
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impression_count,
|
||
"pricing_clicks": pricing_clicks,
|
||
"new_topic_clicks": new_topic_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"copy_success": copy_success,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"new_topic_rate": round(new_topic_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"copy_rate": round(copy_success * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
})
|
||
|
||
device_rows = (
|
||
db.session.query(
|
||
device_expr.label("device_type"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*base_filters)
|
||
.group_by(device_expr)
|
||
.order_by(device_expr.asc())
|
||
.all()
|
||
)
|
||
for row in device_rows:
|
||
impression_count = int(row.impressions or 0)
|
||
mobile_bar_impressions = int(row.mobile_bar_impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
device_summary.append({
|
||
"device_type": row.device_type or "unknown",
|
||
"impressions": impression_count,
|
||
"mobile_bar_impressions": mobile_bar_impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"mobile_pricing_clicks": mobile_pricing_clicks,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
})
|
||
mobile_device_impressions = 0
|
||
for row in device_summary:
|
||
if row.get("device_type") == "mobile":
|
||
mobile_device_impressions = int(row.get("impressions", 0) or 0)
|
||
break
|
||
mobile_funnel["mobile_impressions"] = mobile_device_impressions
|
||
mobile_funnel["mobile_bar_impressions"] = int(summary.get("mobile_bar_impressions", 0) or 0)
|
||
mobile_funnel["mobile_pricing_clicks"] = int(summary.get("mobile_pricing_clicks", 0) or 0)
|
||
mobile_funnel["mobile_pricing_rate"] = float(summary_rates.get("mobile_pricing_rate", 0.0) or 0.0)
|
||
if impressions > 0:
|
||
mobile_funnel["mobile_traffic_share"] = round(mobile_device_impressions * 100.0 / impressions, 2)
|
||
if summary["pricing_clicks"] > 0:
|
||
mobile_funnel["mobile_click_share"] = round(summary["mobile_pricing_clicks"] * 100.0 / summary["pricing_clicks"], 2)
|
||
|
||
day_rows_raw = (
|
||
db.session.query(
|
||
func.date(ForumTrackEvent.created_at).label("event_day"),
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*base_filters)
|
||
.group_by(func.date(ForumTrackEvent.created_at), variant_expr)
|
||
.order_by(func.date(ForumTrackEvent.created_at).desc(), variant_expr.asc())
|
||
.limit(100)
|
||
.all()
|
||
)
|
||
for row in day_rows_raw:
|
||
impression_count = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
daily_rows.append({
|
||
"event_day": row.event_day,
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impression_count,
|
||
"pricing_clicks": pricing_clicks,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
})
|
||
|
||
post_rows_raw = (
|
||
db.session.query(
|
||
ForumTrackEvent.post_id.label("post_id"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*base_filters, ForumTrackEvent.post_id.isnot(None))
|
||
.group_by(ForumTrackEvent.post_id)
|
||
.having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0)
|
||
.order_by(
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(),
|
||
ForumTrackEvent.post_id.desc(),
|
||
)
|
||
.limit(30)
|
||
.all()
|
||
)
|
||
post_id_list = [int(row.post_id) for row in post_rows_raw if row.post_id is not None]
|
||
post_title_map = {}
|
||
if post_id_list:
|
||
post_title_map = {
|
||
pid: title
|
||
for pid, title in (
|
||
db.session.query(ForumPost.id, ForumPost.title)
|
||
.filter(ForumPost.id.in_(post_id_list))
|
||
.all()
|
||
)
|
||
}
|
||
for row in post_rows_raw:
|
||
impression_count = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
post_rows.append({
|
||
"post_id": int(row.post_id or 0),
|
||
"title": post_title_map.get(int(row.post_id or 0), "帖子已删除或不可见"),
|
||
"impressions": impression_count,
|
||
"pricing_clicks": pricing_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impression_count, 2) if impression_count else 0.0,
|
||
})
|
||
template_post_rows = [
|
||
row
|
||
for row in sorted(
|
||
post_rows,
|
||
key=lambda item: (
|
||
int(item.get("template_submits", 0) or 0),
|
||
int(item.get("template_clicks", 0) or 0),
|
||
int(item.get("impressions", 0) or 0),
|
||
int(item.get("post_id", 0) or 0),
|
||
),
|
||
reverse=True,
|
||
)
|
||
if int(row.get("template_clicks", 0) or 0) > 0 or int(row.get("template_submits", 0) or 0) > 0
|
||
][:20]
|
||
|
||
label_rows_raw = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
ForumTrackEvent.label,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(
|
||
*base_filters,
|
||
ForumTrackEvent.event_name.in_({
|
||
"post_detail_cta_pricing",
|
||
"post_detail_cta_new_topic",
|
||
"post_detail_sidebar_compare",
|
||
"post_detail_resource_click",
|
||
"post_detail_related_click",
|
||
"post_detail_plan_click",
|
||
"post_detail_inline_plan_click",
|
||
"post_detail_requirement_template_click",
|
||
"post_detail_requirement_template_submit",
|
||
}),
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
)
|
||
.group_by(ForumTrackEvent.event_name, ForumTrackEvent.label)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(40)
|
||
.all()
|
||
)
|
||
label_rows = [
|
||
{
|
||
"event_name": row.event_name,
|
||
"label": row.label,
|
||
"total": int(row.total or 0),
|
||
}
|
||
for row in label_rows_raw
|
||
]
|
||
|
||
recent_rows = (
|
||
ForumTrackEvent.query
|
||
.filter(*base_filters)
|
||
.order_by(ForumTrackEvent.created_at.desc(), ForumTrackEvent.id.desc())
|
||
.limit(80)
|
||
.all()
|
||
)
|
||
except Exception:
|
||
db.session.rollback()
|
||
error = "埋点数据表尚未就绪或查询失败,请重启应用后重试。"
|
||
|
||
return render_template(
|
||
"admin/forum_tracking.html",
|
||
days=days,
|
||
days_options=days_options,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
device_options=device_options,
|
||
summary=summary,
|
||
summary_rates=summary_rates,
|
||
mobile_funnel=mobile_funnel,
|
||
variant_summary=variant_summary,
|
||
device_summary=device_summary,
|
||
daily_rows=daily_rows,
|
||
post_rows=post_rows,
|
||
template_post_rows=template_post_rows,
|
||
label_rows=label_rows,
|
||
recent_rows=recent_rows,
|
||
msg=request.args.get("msg", ""),
|
||
error=request.args.get("error", "") or error,
|
||
)
|
||
|
||
|
||
@app.route("/admin/forum/tracking/daily")
|
||
@admin_required
|
||
def admin_forum_tracking_daily():
|
||
selected_variant = (request.args.get("variant") or "all").strip().lower()
|
||
if selected_variant not in {"all", "control", "intent", "unknown"}:
|
||
selected_variant = "all"
|
||
selected_device = _admin_tracking_selected_device()
|
||
day_value = _admin_tracking_day_value()
|
||
prev_day = day_value - timedelta(days=1)
|
||
variant_options = ["all", "control", "intent", "unknown"]
|
||
device_options = ["all", "mobile", "desktop", "tablet", "unknown"]
|
||
error = ""
|
||
|
||
summary = {
|
||
"events": 0,
|
||
"impressions": 0,
|
||
"mobile_bar_impressions": 0,
|
||
"mobile_pricing_clicks": 0,
|
||
"pricing_clicks": 0,
|
||
"new_topic_clicks": 0,
|
||
"template_clicks": 0,
|
||
"template_submits": 0,
|
||
"comment_submits": 0,
|
||
"copy_success": 0,
|
||
"resource_clicks": 0,
|
||
"related_clicks": 0,
|
||
"outline_clicks": 0,
|
||
}
|
||
summary_rates = {
|
||
"pricing_ctr": 0.0,
|
||
"mobile_pricing_rate": 0.0,
|
||
"new_topic_rate": 0.0,
|
||
"template_rate": 0.0,
|
||
"template_submit_rate": 0.0,
|
||
"template_completion_rate": 0.0,
|
||
"comment_rate": 0.0,
|
||
"copy_rate": 0.0,
|
||
}
|
||
prev_summary = dict(summary)
|
||
prev_summary_rates = dict(summary_rates)
|
||
delta_rows = []
|
||
variant_rows = []
|
||
top_posts = []
|
||
template_top_posts = []
|
||
top_labels = []
|
||
|
||
try:
|
||
curr_event_map = _admin_tracking_event_map_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_event_map = _admin_tracking_event_map_for_day(
|
||
day_value=prev_day,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map)
|
||
prev_summary, prev_summary_rates = _admin_tracking_summary_from_event_map(prev_event_map)
|
||
day_start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
day_end_at = day_start_at + timedelta(days=1)
|
||
prev_start_at = datetime(prev_day.year, prev_day.month, prev_day.day)
|
||
prev_end_at = prev_start_at + timedelta(days=1)
|
||
summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=day_start_at,
|
||
end_at=day_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
curr_mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0)
|
||
prev_mobile_bar_impressions = int(prev_summary.get("mobile_bar_impressions", 0) or 0)
|
||
if curr_mobile_bar_impressions > 0:
|
||
summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / curr_mobile_bar_impressions, 2)
|
||
if prev_mobile_bar_impressions > 0:
|
||
prev_summary_rates["mobile_pricing_rate"] = round(prev_summary["mobile_pricing_clicks"] * 100.0 / prev_mobile_bar_impressions, 2)
|
||
|
||
for key, label in [
|
||
("impressions", "曝光"),
|
||
("mobile_bar_impressions", "移动底栏曝光"),
|
||
("mobile_pricing_clicks", "移动比价点击"),
|
||
("pricing_clicks", "比价点击"),
|
||
("comment_submits", "评论提交"),
|
||
("new_topic_clicks", "发需求点击"),
|
||
("template_clicks", "模板发帖点击"),
|
||
("template_submits", "模板发帖提交"),
|
||
("copy_success", "复制成功"),
|
||
]:
|
||
current_val = int(summary.get(key, 0) or 0)
|
||
prev_val = int(prev_summary.get(key, 0) or 0)
|
||
delta_val = current_val - prev_val
|
||
if prev_val > 0:
|
||
delta_pct = round(delta_val * 100.0 / prev_val, 2)
|
||
elif current_val > 0:
|
||
delta_pct = 100.0
|
||
else:
|
||
delta_pct = 0.0
|
||
if delta_val > 0:
|
||
direction = "up"
|
||
elif delta_val < 0:
|
||
direction = "down"
|
||
else:
|
||
direction = "flat"
|
||
delta_rows.append({
|
||
"label": label,
|
||
"current": current_val,
|
||
"previous": prev_val,
|
||
"delta": delta_val,
|
||
"delta_pct": delta_pct,
|
||
"direction": direction,
|
||
})
|
||
|
||
variant_rows = _admin_tracking_variant_summary_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
top_posts = _admin_tracking_top_posts_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="pricing",
|
||
)
|
||
template_top_posts = _admin_tracking_top_posts_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="template",
|
||
)
|
||
top_labels = _admin_tracking_top_labels_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=20,
|
||
)
|
||
except Exception:
|
||
db.session.rollback()
|
||
error = "日报数据聚合失败,请检查埋点数据表是否已创建。"
|
||
|
||
return render_template(
|
||
"admin/forum_tracking_daily.html",
|
||
day_value=day_value,
|
||
prev_day=prev_day,
|
||
selected_variant=selected_variant,
|
||
variant_options=variant_options,
|
||
selected_device=selected_device,
|
||
device_options=device_options,
|
||
summary=summary,
|
||
summary_rates=summary_rates,
|
||
prev_summary=prev_summary,
|
||
prev_summary_rates=prev_summary_rates,
|
||
delta_rows=delta_rows,
|
||
variant_rows=variant_rows,
|
||
top_posts=top_posts,
|
||
template_top_posts=template_top_posts,
|
||
top_labels=top_labels,
|
||
msg=request.args.get("msg", ""),
|
||
error=request.args.get("error", "") or error,
|
||
)
|
||
|
||
|
||
@app.route("/admin/forum/tracking/daily/export.md")
|
||
@admin_required
|
||
def admin_forum_tracking_daily_export_markdown():
|
||
selected_variant = (request.args.get("variant") or "all").strip().lower()
|
||
if selected_variant not in {"all", "control", "intent", "unknown"}:
|
||
selected_variant = "all"
|
||
selected_device = _admin_tracking_selected_device()
|
||
day_value = _admin_tracking_day_value()
|
||
prev_day = day_value - timedelta(days=1)
|
||
|
||
curr_event_map = _admin_tracking_event_map_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_event_map = _admin_tracking_event_map_for_day(
|
||
day_value=prev_day,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map)
|
||
prev_summary, _ = _admin_tracking_summary_from_event_map(prev_event_map)
|
||
day_start_at = datetime(day_value.year, day_value.month, day_value.day)
|
||
day_end_at = day_start_at + timedelta(days=1)
|
||
prev_start_at = datetime(prev_day.year, prev_day.month, prev_day.day)
|
||
prev_end_at = prev_start_at + timedelta(days=1)
|
||
summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=day_start_at,
|
||
end_at=day_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0)
|
||
if mobile_bar_impressions > 0:
|
||
summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2)
|
||
top_posts = _admin_tracking_top_posts_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=10,
|
||
sort_mode="pricing",
|
||
)
|
||
template_top_posts = _admin_tracking_top_posts_for_day(
|
||
day_value=day_value,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=10,
|
||
sort_mode="template",
|
||
)
|
||
|
||
def _delta_str(current_val, prev_val):
|
||
delta_val = int(current_val or 0) - int(prev_val or 0)
|
||
if int(prev_val or 0) > 0:
|
||
delta_pct = round(delta_val * 100.0 / int(prev_val), 2)
|
||
elif int(current_val or 0) > 0:
|
||
delta_pct = 100.0
|
||
else:
|
||
delta_pct = 0.0
|
||
sign = "+" if delta_val > 0 else ""
|
||
return "{}{} ({:+.2f}%)".format(sign, delta_val, delta_pct)
|
||
|
||
lines = [
|
||
"# Forum Tracking Daily Report",
|
||
"",
|
||
"- Day: `{}`".format(day_value.isoformat()),
|
||
"- Variant filter: `{}`".format(selected_variant),
|
||
"- Device filter: `{}`".format(selected_device),
|
||
"",
|
||
"## Summary",
|
||
"",
|
||
"- Impressions: **{}** ({})".format(summary["impressions"], _delta_str(summary["impressions"], prev_summary["impressions"])),
|
||
"- Mobile bar impressions: **{}** ({})".format(summary["mobile_bar_impressions"], _delta_str(summary["mobile_bar_impressions"], prev_summary["mobile_bar_impressions"])),
|
||
"- Mobile pricing clicks: **{}** ({})".format(summary["mobile_pricing_clicks"], _delta_str(summary["mobile_pricing_clicks"], prev_summary["mobile_pricing_clicks"])),
|
||
"- Mobile bar click-through: **{}%**".format(summary_rates["mobile_pricing_rate"]),
|
||
"- Pricing clicks: **{}** ({})".format(summary["pricing_clicks"], _delta_str(summary["pricing_clicks"], prev_summary["pricing_clicks"])),
|
||
"- Pricing CTR: **{}%**".format(summary_rates["pricing_ctr"]),
|
||
"- New topic clicks: **{}** ({})".format(summary["new_topic_clicks"], _delta_str(summary["new_topic_clicks"], prev_summary["new_topic_clicks"])),
|
||
"- Requirement template clicks: **{}** ({})".format(summary["template_clicks"], _delta_str(summary["template_clicks"], prev_summary["template_clicks"])),
|
||
"- Template click rate: **{}%**".format(summary_rates["template_rate"]),
|
||
"- Requirement template submits: **{}** ({})".format(summary["template_submits"], _delta_str(summary["template_submits"], prev_summary["template_submits"])),
|
||
"- Template submit rate: **{}%**".format(summary_rates["template_submit_rate"]),
|
||
"- Template completion rate: **{}%**".format(summary_rates["template_completion_rate"]),
|
||
"- Comment submits: **{}** ({})".format(summary["comment_submits"], _delta_str(summary["comment_submits"], prev_summary["comment_submits"])),
|
||
"- Copy success: **{}** ({})".format(summary["copy_success"], _delta_str(summary["copy_success"], prev_summary["copy_success"])),
|
||
"",
|
||
"## Top Posts",
|
||
"",
|
||
]
|
||
if top_posts:
|
||
for idx, row in enumerate(top_posts, start=1):
|
||
lines.append("{}. #{} {} | impressions={} pricing_clicks={} ctr={}%; template_clicks={} template_submits={} template_completion={}% ; comments={} comment_rate={}%".format(
|
||
idx,
|
||
row["post_id"],
|
||
row["title"],
|
||
row["impressions"],
|
||
row["pricing_clicks"],
|
||
row["pricing_ctr"],
|
||
row["template_clicks"],
|
||
row["template_submits"],
|
||
row["template_completion_rate"],
|
||
row["comment_submits"],
|
||
row["comment_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No post-level conversion data for this day.")
|
||
lines.extend([
|
||
"",
|
||
"## Top Template Conversion Posts",
|
||
"",
|
||
])
|
||
if template_top_posts:
|
||
for idx, row in enumerate(template_top_posts, start=1):
|
||
lines.append("{}. #{} {} | template_clicks={} template_submits={} template_completion={}%; impressions={} template_rate={}%; template_submit_rate={}%".format(
|
||
idx,
|
||
row["post_id"],
|
||
row["title"],
|
||
row["template_clicks"],
|
||
row["template_submits"],
|
||
row["template_completion_rate"],
|
||
row["impressions"],
|
||
row["template_rate"],
|
||
row["template_submit_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No template conversion posts for this day.")
|
||
|
||
body = "\n".join(lines) + "\n"
|
||
filename = "forum-tracking-daily-{}-{}-{}.md".format(day_value.isoformat(), selected_variant, selected_device)
|
||
resp = make_response(body)
|
||
resp.headers["Content-Type"] = "text/markdown; charset=utf-8"
|
||
resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename)
|
||
return resp
|
||
|
||
|
||
@app.route("/admin/forum/tracking/weekly")
|
||
@admin_required
|
||
def admin_forum_tracking_weekly():
|
||
selected_variant = (request.args.get("variant") or "all").strip().lower()
|
||
if selected_variant not in {"all", "control", "intent", "unknown"}:
|
||
selected_variant = "all"
|
||
selected_device = _admin_tracking_selected_device()
|
||
days = request.args.get("days", type=int) or 7
|
||
days = max(3, min(days, 30))
|
||
end_day = _admin_tracking_day_value()
|
||
end_at = datetime(end_day.year, end_day.month, end_day.day) + timedelta(days=1)
|
||
start_at = end_at - timedelta(days=days)
|
||
prev_end_at = start_at
|
||
prev_start_at = prev_end_at - timedelta(days=days)
|
||
days_options = [3, 7, 14, 21, 30]
|
||
variant_options = ["all", "control", "intent", "unknown"]
|
||
device_options = ["all", "mobile", "desktop", "tablet", "unknown"]
|
||
|
||
summary = {
|
||
"events": 0,
|
||
"impressions": 0,
|
||
"mobile_bar_impressions": 0,
|
||
"mobile_pricing_clicks": 0,
|
||
"pricing_clicks": 0,
|
||
"new_topic_clicks": 0,
|
||
"template_clicks": 0,
|
||
"template_submits": 0,
|
||
"comment_submits": 0,
|
||
"copy_success": 0,
|
||
"resource_clicks": 0,
|
||
"related_clicks": 0,
|
||
"outline_clicks": 0,
|
||
}
|
||
summary_rates = {
|
||
"pricing_ctr": 0.0,
|
||
"mobile_pricing_rate": 0.0,
|
||
"new_topic_rate": 0.0,
|
||
"template_rate": 0.0,
|
||
"template_submit_rate": 0.0,
|
||
"template_completion_rate": 0.0,
|
||
"comment_rate": 0.0,
|
||
"copy_rate": 0.0,
|
||
}
|
||
prev_summary = dict(summary)
|
||
prev_summary_rates = dict(summary_rates)
|
||
delta_rows = []
|
||
variant_rows = []
|
||
device_rows = []
|
||
device_variant_rows = []
|
||
top_posts = []
|
||
template_top_posts = []
|
||
top_labels = []
|
||
error = ""
|
||
|
||
try:
|
||
curr_event_map = _admin_tracking_event_map_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_event_map = _admin_tracking_event_map_for_range(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map)
|
||
prev_summary, prev_summary_rates = _admin_tracking_summary_from_event_map(prev_event_map)
|
||
summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
curr_mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0)
|
||
prev_mobile_bar_impressions = int(prev_summary.get("mobile_bar_impressions", 0) or 0)
|
||
if curr_mobile_bar_impressions > 0:
|
||
summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / curr_mobile_bar_impressions, 2)
|
||
if prev_mobile_bar_impressions > 0:
|
||
prev_summary_rates["mobile_pricing_rate"] = round(prev_summary["mobile_pricing_clicks"] * 100.0 / prev_mobile_bar_impressions, 2)
|
||
|
||
for key, label in [
|
||
("impressions", "曝光"),
|
||
("mobile_bar_impressions", "移动底栏曝光"),
|
||
("mobile_pricing_clicks", "移动比价点击"),
|
||
("pricing_clicks", "比价点击"),
|
||
("new_topic_clicks", "发需求点击"),
|
||
("template_clicks", "模板发帖点击"),
|
||
("template_submits", "模板发帖提交"),
|
||
("comment_submits", "评论提交"),
|
||
("copy_success", "复制成功"),
|
||
]:
|
||
current_val = int(summary.get(key, 0) or 0)
|
||
prev_val = int(prev_summary.get(key, 0) or 0)
|
||
delta_val = current_val - prev_val
|
||
if prev_val > 0:
|
||
delta_pct = round(delta_val * 100.0 / prev_val, 2)
|
||
elif current_val > 0:
|
||
delta_pct = 100.0
|
||
else:
|
||
delta_pct = 0.0
|
||
if delta_val > 0:
|
||
direction = "up"
|
||
elif delta_val < 0:
|
||
direction = "down"
|
||
else:
|
||
direction = "flat"
|
||
delta_rows.append({
|
||
"label": label,
|
||
"current": current_val,
|
||
"previous": prev_val,
|
||
"delta": delta_val,
|
||
"delta_pct": delta_pct,
|
||
"direction": direction,
|
||
})
|
||
|
||
variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown")
|
||
variant_rows_raw = (
|
||
db.session.query(
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(variant_expr)
|
||
.order_by(variant_expr.asc())
|
||
.all()
|
||
)
|
||
for row in variant_rows_raw:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
new_topic_clicks = int(row.new_topic_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
copy_success = int(row.copy_success or 0)
|
||
variant_rows.append({
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"new_topic_clicks": new_topic_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"copy_success": copy_success,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"copy_rate": round(copy_success * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
|
||
device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown")
|
||
device_rows_raw = (
|
||
db.session.query(
|
||
device_expr.label("device_type"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(device_expr)
|
||
.order_by(device_expr.asc())
|
||
.all()
|
||
)
|
||
for row in device_rows_raw:
|
||
impressions = int(row.impressions or 0)
|
||
mobile_bar_impressions = int(row.mobile_bar_impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
device_rows.append({
|
||
"device_type": row.device_type or "unknown",
|
||
"impressions": impressions,
|
||
"mobile_bar_impressions": mobile_bar_impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"mobile_pricing_clicks": mobile_pricing_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
|
||
device_variant_rows_raw = (
|
||
db.session.query(
|
||
device_expr.label("device_type"),
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(device_expr, variant_expr)
|
||
.order_by(device_expr.asc(), variant_expr.asc())
|
||
.all()
|
||
)
|
||
for row in device_variant_rows_raw:
|
||
impressions = int(row.impressions or 0)
|
||
mobile_bar_impressions = int(row.mobile_bar_impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
device_variant_rows.append({
|
||
"device_type": row.device_type or "unknown",
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impressions,
|
||
"mobile_bar_impressions": mobile_bar_impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"mobile_pricing_clicks": mobile_pricing_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
|
||
top_posts = _admin_tracking_top_posts_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="pricing",
|
||
)
|
||
template_top_posts = _admin_tracking_top_posts_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="template",
|
||
)
|
||
top_labels_raw = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
ForumTrackEvent.label,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(
|
||
*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
),
|
||
ForumTrackEvent.event_name.in_({
|
||
"post_detail_cta_pricing",
|
||
"post_detail_cta_new_topic",
|
||
"post_detail_requirement_template_click",
|
||
"post_detail_requirement_template_submit",
|
||
"post_detail_sidebar_compare",
|
||
"post_detail_resource_click",
|
||
"post_detail_related_click",
|
||
"post_detail_plan_click",
|
||
"post_detail_inline_plan_click",
|
||
}),
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
)
|
||
.group_by(ForumTrackEvent.event_name, ForumTrackEvent.label)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(20)
|
||
.all()
|
||
)
|
||
top_labels = [{
|
||
"event_name": row.event_name,
|
||
"label": row.label,
|
||
"total": int(row.total or 0),
|
||
} for row in top_labels_raw]
|
||
except Exception:
|
||
db.session.rollback()
|
||
error = "周报数据聚合失败,请检查埋点数据表是否已创建。"
|
||
|
||
range_start_day = start_at.date()
|
||
range_end_day = (end_at - timedelta(days=1)).date()
|
||
prev_start_day = prev_start_at.date()
|
||
prev_end_day = (prev_end_at - timedelta(days=1)).date()
|
||
|
||
return render_template(
|
||
"admin/forum_tracking_weekly.html",
|
||
days=days,
|
||
days_options=days_options,
|
||
selected_variant=selected_variant,
|
||
variant_options=variant_options,
|
||
selected_device=selected_device,
|
||
device_options=device_options,
|
||
end_day=end_day,
|
||
range_start_day=range_start_day,
|
||
range_end_day=range_end_day,
|
||
prev_start_day=prev_start_day,
|
||
prev_end_day=prev_end_day,
|
||
summary=summary,
|
||
summary_rates=summary_rates,
|
||
prev_summary=prev_summary,
|
||
prev_summary_rates=prev_summary_rates,
|
||
delta_rows=delta_rows,
|
||
variant_rows=variant_rows,
|
||
device_rows=device_rows,
|
||
device_variant_rows=device_variant_rows,
|
||
top_posts=top_posts,
|
||
template_top_posts=template_top_posts,
|
||
top_labels=top_labels,
|
||
msg=request.args.get("msg", ""),
|
||
error=request.args.get("error", "") or error,
|
||
)
|
||
|
||
|
||
@app.route("/admin/forum/tracking/weekly/export.md")
|
||
@admin_required
|
||
def admin_forum_tracking_weekly_export_markdown():
|
||
selected_variant = (request.args.get("variant") or "all").strip().lower()
|
||
if selected_variant not in {"all", "control", "intent", "unknown"}:
|
||
selected_variant = "all"
|
||
selected_device = _admin_tracking_selected_device()
|
||
days = request.args.get("days", type=int) or 7
|
||
days = max(3, min(days, 30))
|
||
end_day = _admin_tracking_day_value()
|
||
end_at = datetime(end_day.year, end_day.month, end_day.day) + timedelta(days=1)
|
||
start_at = end_at - timedelta(days=days)
|
||
prev_end_at = start_at
|
||
prev_start_at = prev_end_at - timedelta(days=days)
|
||
|
||
curr_event_map = _admin_tracking_event_map_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_event_map = _admin_tracking_event_map_for_range(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
summary, summary_rates = _admin_tracking_summary_from_event_map(curr_event_map)
|
||
prev_summary, _ = _admin_tracking_summary_from_event_map(prev_event_map)
|
||
summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
prev_summary["mobile_pricing_clicks"] = _admin_tracking_mobile_pricing_clicks(
|
||
start_at=prev_start_at,
|
||
end_at=prev_end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
mobile_bar_impressions = int(summary.get("mobile_bar_impressions", 0) or 0)
|
||
if mobile_bar_impressions > 0:
|
||
summary_rates["mobile_pricing_rate"] = round(summary["mobile_pricing_clicks"] * 100.0 / mobile_bar_impressions, 2)
|
||
|
||
variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown")
|
||
variant_rows_raw = (
|
||
db.session.query(
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(variant_expr)
|
||
.order_by(variant_expr.asc())
|
||
.all()
|
||
)
|
||
variant_rows = []
|
||
for row in variant_rows_raw:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
new_topic_clicks = int(row.new_topic_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
copy_success = int(row.copy_success or 0)
|
||
variant_rows.append({
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"new_topic_clicks": new_topic_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"copy_success": copy_success,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"new_topic_rate": round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"copy_rate": round(copy_success * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
|
||
device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown")
|
||
device_variant_rows_raw = (
|
||
db.session.query(
|
||
device_expr.label("device_type"),
|
||
variant_expr.label("variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_mobile_bar_impression", 1), else_=0)).label("mobile_bar_impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((and_(ForumTrackEvent.event_name == "post_detail_cta_pricing", ForumTrackEvent.label.like("mobile_%")), 1), else_=0)).label("mobile_pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
))
|
||
.group_by(device_expr, variant_expr)
|
||
.order_by(device_expr.asc(), variant_expr.asc())
|
||
.all()
|
||
)
|
||
device_variant_rows = []
|
||
for row in device_variant_rows_raw:
|
||
impressions = int(row.impressions or 0)
|
||
mobile_bar_impressions = int(row.mobile_bar_impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
mobile_pricing_clicks = int(row.mobile_pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
device_variant_rows.append({
|
||
"device_type": row.device_type or "unknown",
|
||
"variant": row.variant or "unknown",
|
||
"impressions": impressions,
|
||
"mobile_bar_impressions": mobile_bar_impressions,
|
||
"pricing_clicks": pricing_clicks,
|
||
"mobile_pricing_clicks": mobile_pricing_clicks,
|
||
"template_clicks": template_clicks,
|
||
"template_submits": template_submits,
|
||
"comment_submits": comment_submits,
|
||
"pricing_ctr": round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"mobile_pricing_rate": round(mobile_pricing_clicks * 100.0 / mobile_bar_impressions, 2) if mobile_bar_impressions else 0.0,
|
||
"template_rate": round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_submit_rate": round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
"template_completion_rate": round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
"comment_rate": round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
})
|
||
|
||
top_posts = _admin_tracking_top_posts_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="pricing",
|
||
)
|
||
template_top_posts = _admin_tracking_top_posts_for_range(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
limit=12,
|
||
sort_mode="template",
|
||
)
|
||
top_labels_raw = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
ForumTrackEvent.label,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(
|
||
*_admin_tracking_event_filters_exact(
|
||
start_at=start_at,
|
||
end_at=end_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
),
|
||
ForumTrackEvent.event_name.in_({
|
||
"post_detail_cta_pricing",
|
||
"post_detail_cta_new_topic",
|
||
"post_detail_requirement_template_click",
|
||
"post_detail_requirement_template_submit",
|
||
"post_detail_sidebar_compare",
|
||
"post_detail_resource_click",
|
||
"post_detail_related_click",
|
||
"post_detail_plan_click",
|
||
"post_detail_inline_plan_click",
|
||
}),
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
)
|
||
.group_by(ForumTrackEvent.event_name, ForumTrackEvent.label)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(20)
|
||
.all()
|
||
)
|
||
top_labels = [{
|
||
"event_name": row.event_name,
|
||
"label": row.label,
|
||
"total": int(row.total or 0),
|
||
} for row in top_labels_raw]
|
||
|
||
def _delta_str(current_val, prev_val):
|
||
delta_val = int(current_val or 0) - int(prev_val or 0)
|
||
if int(prev_val or 0) > 0:
|
||
delta_pct = round(delta_val * 100.0 / int(prev_val), 2)
|
||
elif int(current_val or 0) > 0:
|
||
delta_pct = 100.0
|
||
else:
|
||
delta_pct = 0.0
|
||
sign = "+" if delta_val > 0 else ""
|
||
return "{}{} ({:+.2f}%)".format(sign, delta_val, delta_pct)
|
||
|
||
range_start_day = start_at.date().isoformat()
|
||
range_end_day = (end_at - timedelta(days=1)).date().isoformat()
|
||
prev_start_day = prev_start_at.date().isoformat()
|
||
prev_end_day = (prev_end_at - timedelta(days=1)).date().isoformat()
|
||
lines = [
|
||
"# Forum Tracking Weekly Report",
|
||
"",
|
||
"- Window: `{}` ~ `{}` ({} days)".format(range_start_day, range_end_day, days),
|
||
"- Compare Window: `{}` ~ `{}` ({} days)".format(prev_start_day, prev_end_day, days),
|
||
"- Variant filter: `{}`".format(selected_variant),
|
||
"- Device filter: `{}`".format(selected_device),
|
||
"",
|
||
"## Summary",
|
||
"",
|
||
"- Impressions: **{}** ({})".format(summary["impressions"], _delta_str(summary["impressions"], prev_summary["impressions"])),
|
||
"- Mobile bar impressions: **{}** ({})".format(summary["mobile_bar_impressions"], _delta_str(summary["mobile_bar_impressions"], prev_summary["mobile_bar_impressions"])),
|
||
"- Mobile pricing clicks: **{}** ({})".format(summary["mobile_pricing_clicks"], _delta_str(summary["mobile_pricing_clicks"], prev_summary["mobile_pricing_clicks"])),
|
||
"- Mobile bar click-through: **{}%**".format(summary_rates["mobile_pricing_rate"]),
|
||
"- Pricing clicks: **{}** ({})".format(summary["pricing_clicks"], _delta_str(summary["pricing_clicks"], prev_summary["pricing_clicks"])),
|
||
"- Pricing CTR: **{}%**".format(summary_rates["pricing_ctr"]),
|
||
"- New topic clicks: **{}** ({})".format(summary["new_topic_clicks"], _delta_str(summary["new_topic_clicks"], prev_summary["new_topic_clicks"])),
|
||
"- Requirement template clicks: **{}** ({})".format(summary["template_clicks"], _delta_str(summary["template_clicks"], prev_summary["template_clicks"])),
|
||
"- Template click rate: **{}%**".format(summary_rates["template_rate"]),
|
||
"- Requirement template submits: **{}** ({})".format(summary["template_submits"], _delta_str(summary["template_submits"], prev_summary["template_submits"])),
|
||
"- Template submit rate: **{}%**".format(summary_rates["template_submit_rate"]),
|
||
"- Template completion rate: **{}%**".format(summary_rates["template_completion_rate"]),
|
||
"- Comment submits: **{}** ({})".format(summary["comment_submits"], _delta_str(summary["comment_submits"], prev_summary["comment_submits"])),
|
||
"- Copy success: **{}** ({})".format(summary["copy_success"], _delta_str(summary["copy_success"], prev_summary["copy_success"])),
|
||
"",
|
||
"## Variant Funnel",
|
||
"",
|
||
]
|
||
if variant_rows:
|
||
for row in variant_rows:
|
||
lines.append("- `{}` | impressions={} | pricing_ctr={}% | new_topic_rate={}% | template_rate={}% | template_submit_rate={}% | template_completion={}% | comment_rate={}% | copy_rate={}%".format(
|
||
row["variant"],
|
||
row["impressions"],
|
||
row["pricing_ctr"],
|
||
row["new_topic_rate"],
|
||
row["template_rate"],
|
||
row["template_submit_rate"],
|
||
row["template_completion_rate"],
|
||
row["comment_rate"],
|
||
row["copy_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No variant funnel data.")
|
||
lines.extend([
|
||
"",
|
||
"## Device x Variant Funnel",
|
||
"",
|
||
])
|
||
if device_variant_rows:
|
||
for row in device_variant_rows:
|
||
lines.append("- `{} / {}` | impressions={} | pricing_ctr={}% | mobile_bar_ctr={}% | template_rate={}% | template_submit_rate={}% | template_completion={}% | comment_rate={}%".format(
|
||
row["device_type"],
|
||
row["variant"],
|
||
row["impressions"],
|
||
row["pricing_ctr"],
|
||
row["mobile_pricing_rate"],
|
||
row["template_rate"],
|
||
row["template_submit_rate"],
|
||
row["template_completion_rate"],
|
||
row["comment_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No device x variant funnel data.")
|
||
lines.extend([
|
||
"",
|
||
"## Top Pricing Conversion Posts",
|
||
"",
|
||
])
|
||
if top_posts:
|
||
for idx, row in enumerate(top_posts, start=1):
|
||
lines.append("{}. #{} {} | impressions={} pricing_clicks={} ctr={}%; template_clicks={} template_submits={} template_completion={}% ; comments={} comment_rate={}%".format(
|
||
idx,
|
||
row["post_id"],
|
||
row["title"],
|
||
row["impressions"],
|
||
row["pricing_clicks"],
|
||
row["pricing_ctr"],
|
||
row["template_clicks"],
|
||
row["template_submits"],
|
||
row["template_completion_rate"],
|
||
row["comment_submits"],
|
||
row["comment_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No post-level conversion data.")
|
||
lines.extend([
|
||
"",
|
||
"## Top Template Conversion Posts",
|
||
"",
|
||
])
|
||
if template_top_posts:
|
||
for idx, row in enumerate(template_top_posts, start=1):
|
||
lines.append("{}. #{} {} | template_clicks={} template_submits={} template_completion={}%; impressions={} template_rate={}%; template_submit_rate={}%".format(
|
||
idx,
|
||
row["post_id"],
|
||
row["title"],
|
||
row["template_clicks"],
|
||
row["template_submits"],
|
||
row["template_completion_rate"],
|
||
row["impressions"],
|
||
row["template_rate"],
|
||
row["template_submit_rate"],
|
||
))
|
||
else:
|
||
lines.append("- No template conversion posts.")
|
||
lines.extend([
|
||
"",
|
||
"## Top Labels",
|
||
"",
|
||
])
|
||
if top_labels:
|
||
for idx, row in enumerate(top_labels, start=1):
|
||
lines.append("{}. `{}` | `{}` | {}".format(
|
||
idx,
|
||
row["event_name"],
|
||
row["label"],
|
||
row["total"],
|
||
))
|
||
else:
|
||
lines.append("- No high-frequency labels.")
|
||
|
||
body = "\n".join(lines) + "\n"
|
||
filename = "forum-tracking-weekly-{}-{}d-{}-{}.md".format(
|
||
range_end_day,
|
||
days,
|
||
selected_variant,
|
||
selected_device,
|
||
)
|
||
resp = make_response(body)
|
||
resp.headers["Content-Type"] = "text/markdown; charset=utf-8"
|
||
resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename)
|
||
return resp
|
||
|
||
|
||
@app.route("/admin/forum/tracking/export.csv")
|
||
@admin_required
|
||
def admin_forum_tracking_export():
|
||
days, selected_variant = _admin_tracking_days_variant()
|
||
selected_device = _admin_tracking_selected_device()
|
||
mode = (request.args.get("mode") or "recent").strip().lower()
|
||
if mode not in {"recent", "daily", "variants", "variant_funnel", "device_variants", "posts", "labels"}:
|
||
mode = "recent"
|
||
start_at = datetime.utcnow() - timedelta(days=days)
|
||
start_day = start_at.date()
|
||
limit = request.args.get("limit", type=int) or 3000
|
||
limit = max(100, min(limit, 10000))
|
||
variant_expr = func.coalesce(func.nullif(ForumTrackEvent.cta_variant, ""), "unknown")
|
||
event_filters = _admin_tracking_event_filters(
|
||
start_at=start_at,
|
||
selected_variant=selected_variant,
|
||
selected_device=selected_device,
|
||
)
|
||
daily_filters = _admin_tracking_daily_filters(start_day=start_day, selected_variant=selected_variant)
|
||
|
||
csv_buf = io.StringIO()
|
||
writer = csv.writer(csv_buf)
|
||
|
||
if mode == "daily":
|
||
writer.writerow(["event_day", "cta_variant", "event_name", "total"])
|
||
if selected_device == "all":
|
||
rows = (
|
||
ForumTrackDailySummary.query
|
||
.filter(*daily_filters)
|
||
.order_by(
|
||
ForumTrackDailySummary.event_day.desc(),
|
||
ForumTrackDailySummary.cta_variant.asc(),
|
||
ForumTrackDailySummary.event_name.asc(),
|
||
)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
writer.writerow([
|
||
row.event_day.isoformat() if row.event_day else "",
|
||
row.cta_variant or "unknown",
|
||
row.event_name or "",
|
||
int(row.total or 0),
|
||
])
|
||
else:
|
||
rows = (
|
||
db.session.query(
|
||
func.date(ForumTrackEvent.created_at).label("event_day"),
|
||
variant_expr.label("cta_variant"),
|
||
ForumTrackEvent.event_name,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(*event_filters)
|
||
.group_by(func.date(ForumTrackEvent.created_at), variant_expr, ForumTrackEvent.event_name)
|
||
.order_by(func.date(ForumTrackEvent.created_at).desc(), variant_expr.asc(), ForumTrackEvent.event_name.asc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
day_val = row.event_day.isoformat() if hasattr(row.event_day, "isoformat") else str(row.event_day or "")
|
||
writer.writerow([
|
||
day_val,
|
||
row.cta_variant or "unknown",
|
||
row.event_name or "",
|
||
int(row.total or 0),
|
||
])
|
||
elif mode == "variants":
|
||
writer.writerow(["cta_variant", "event_name", "total"])
|
||
if selected_device == "all":
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackDailySummary.cta_variant,
|
||
ForumTrackDailySummary.event_name,
|
||
func.sum(ForumTrackDailySummary.total).label("total"),
|
||
)
|
||
.filter(*daily_filters)
|
||
.group_by(ForumTrackDailySummary.cta_variant, ForumTrackDailySummary.event_name)
|
||
.order_by(func.sum(ForumTrackDailySummary.total).desc())
|
||
.all()
|
||
)
|
||
else:
|
||
rows = (
|
||
db.session.query(
|
||
variant_expr.label("cta_variant"),
|
||
ForumTrackEvent.event_name,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(*event_filters)
|
||
.group_by(variant_expr, ForumTrackEvent.event_name)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
writer.writerow([
|
||
row.cta_variant or "unknown",
|
||
row.event_name or "",
|
||
int(row.total or 0),
|
||
])
|
||
elif mode == "variant_funnel":
|
||
writer.writerow([
|
||
"cta_variant",
|
||
"impressions",
|
||
"pricing_clicks",
|
||
"pricing_ctr_pct",
|
||
"new_topic_clicks",
|
||
"new_topic_rate_pct",
|
||
"template_clicks",
|
||
"template_rate_pct",
|
||
"template_submits",
|
||
"template_submit_rate_pct",
|
||
"template_completion_rate_pct",
|
||
"comment_submits",
|
||
"comment_rate_pct",
|
||
"copy_success",
|
||
"copy_rate_pct",
|
||
])
|
||
if selected_device == "all":
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackDailySummary.cta_variant.label("cta_variant"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_pricing", ForumTrackDailySummary.total), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_new_topic", ForumTrackDailySummary.total), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_click", ForumTrackDailySummary.total), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_requirement_template_submit", ForumTrackDailySummary.total), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_comment_submit", ForumTrackDailySummary.total), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_copy_link_success", ForumTrackDailySummary.total), else_=0)).label("copy_success"),
|
||
)
|
||
.filter(*daily_filters)
|
||
.group_by(ForumTrackDailySummary.cta_variant)
|
||
.order_by(
|
||
func.sum(case((ForumTrackDailySummary.event_name == "post_detail_cta_impression", ForumTrackDailySummary.total), else_=0)).desc(),
|
||
ForumTrackDailySummary.cta_variant.asc(),
|
||
)
|
||
.all()
|
||
)
|
||
else:
|
||
rows = (
|
||
db.session.query(
|
||
variant_expr.label("cta_variant"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_new_topic", 1), else_=0)).label("new_topic_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_copy_link_success", 1), else_=0)).label("copy_success"),
|
||
)
|
||
.filter(*event_filters)
|
||
.group_by(variant_expr)
|
||
.order_by(
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(),
|
||
variant_expr.asc(),
|
||
)
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
new_topic_clicks = int(row.new_topic_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
copy_success = int(row.copy_success or 0)
|
||
writer.writerow([
|
||
row.cta_variant or "unknown",
|
||
impressions,
|
||
pricing_clicks,
|
||
round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
new_topic_clicks,
|
||
round(new_topic_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
template_clicks,
|
||
round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
template_submits,
|
||
round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
comment_submits,
|
||
round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
copy_success,
|
||
round(copy_success * 100.0 / impressions, 2) if impressions else 0.0,
|
||
])
|
||
elif mode == "device_variants":
|
||
writer.writerow(["device_type", "cta_variant", "event_name", "total"])
|
||
device_expr = func.coalesce(func.nullif(ForumTrackEvent.device_type, ""), "unknown")
|
||
rows = (
|
||
db.session.query(
|
||
device_expr.label("device_type"),
|
||
variant_expr.label("cta_variant"),
|
||
ForumTrackEvent.event_name,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(*event_filters)
|
||
.group_by(device_expr, variant_expr, ForumTrackEvent.event_name)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
writer.writerow([
|
||
row.device_type or "unknown",
|
||
row.cta_variant or "unknown",
|
||
row.event_name or "",
|
||
int(row.total or 0),
|
||
])
|
||
elif mode == "posts":
|
||
writer.writerow([
|
||
"post_id",
|
||
"title",
|
||
"impressions",
|
||
"pricing_clicks",
|
||
"pricing_ctr_pct",
|
||
"template_clicks",
|
||
"template_rate_pct",
|
||
"template_submits",
|
||
"template_submit_rate_pct",
|
||
"template_completion_rate_pct",
|
||
"comment_submits",
|
||
"comment_rate_pct",
|
||
])
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackEvent.post_id.label("post_id"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).label("impressions"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).label("pricing_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_click", 1), else_=0)).label("template_clicks"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_requirement_template_submit", 1), else_=0)).label("template_submits"),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_comment_submit", 1), else_=0)).label("comment_submits"),
|
||
)
|
||
.filter(*event_filters, ForumTrackEvent.post_id.isnot(None))
|
||
.group_by(ForumTrackEvent.post_id)
|
||
.having(func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)) > 0)
|
||
.order_by(
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_pricing", 1), else_=0)).desc(),
|
||
func.sum(case((ForumTrackEvent.event_name == "post_detail_cta_impression", 1), else_=0)).desc(),
|
||
ForumTrackEvent.post_id.desc(),
|
||
)
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
post_ids = [int(row.post_id) for row in rows if row.post_id is not None]
|
||
title_map = {}
|
||
if post_ids:
|
||
title_map = {
|
||
pid: title
|
||
for pid, title in (
|
||
db.session.query(ForumPost.id, ForumPost.title)
|
||
.filter(ForumPost.id.in_(post_ids))
|
||
.all()
|
||
)
|
||
}
|
||
for row in rows:
|
||
impressions = int(row.impressions or 0)
|
||
pricing_clicks = int(row.pricing_clicks or 0)
|
||
template_clicks = int(row.template_clicks or 0)
|
||
template_submits = int(row.template_submits or 0)
|
||
comment_submits = int(row.comment_submits or 0)
|
||
writer.writerow([
|
||
int(row.post_id or 0),
|
||
title_map.get(int(row.post_id or 0), "帖子已删除或不可见"),
|
||
impressions,
|
||
pricing_clicks,
|
||
round(pricing_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
template_clicks,
|
||
round(template_clicks * 100.0 / impressions, 2) if impressions else 0.0,
|
||
template_submits,
|
||
round(template_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
round(template_submits * 100.0 / template_clicks, 2) if template_clicks else 0.0,
|
||
comment_submits,
|
||
round(comment_submits * 100.0 / impressions, 2) if impressions else 0.0,
|
||
])
|
||
elif mode == "labels":
|
||
writer.writerow(["event_name", "label", "total"])
|
||
rows = (
|
||
db.session.query(
|
||
ForumTrackEvent.event_name,
|
||
ForumTrackEvent.label,
|
||
func.count(ForumTrackEvent.id).label("total"),
|
||
)
|
||
.filter(
|
||
*event_filters,
|
||
ForumTrackEvent.event_name.in_({
|
||
"post_detail_cta_pricing",
|
||
"post_detail_cta_new_topic",
|
||
"post_detail_sidebar_compare",
|
||
"post_detail_resource_click",
|
||
"post_detail_related_click",
|
||
"post_detail_plan_click",
|
||
"post_detail_inline_plan_click",
|
||
"post_detail_requirement_template_click",
|
||
"post_detail_requirement_template_submit",
|
||
}),
|
||
ForumTrackEvent.label.isnot(None),
|
||
ForumTrackEvent.label != "",
|
||
)
|
||
.group_by(ForumTrackEvent.event_name, ForumTrackEvent.label)
|
||
.order_by(func.count(ForumTrackEvent.id).desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
writer.writerow([row.event_name or "", row.label or "", int(row.total or 0)])
|
||
else:
|
||
writer.writerow([
|
||
"created_at",
|
||
"event_name",
|
||
"label",
|
||
"cta_variant",
|
||
"device_type",
|
||
"post_id",
|
||
"user_id",
|
||
"visitor_id",
|
||
"page_path",
|
||
"endpoint_path",
|
||
"referer",
|
||
])
|
||
rows = (
|
||
ForumTrackEvent.query
|
||
.filter(*event_filters)
|
||
.order_by(ForumTrackEvent.created_at.desc(), ForumTrackEvent.id.desc())
|
||
.limit(limit)
|
||
.all()
|
||
)
|
||
for row in rows:
|
||
writer.writerow([
|
||
row.created_at.strftime("%Y-%m-%d %H:%M:%S") if row.created_at else "",
|
||
row.event_name or "",
|
||
row.label or "",
|
||
row.cta_variant or "unknown",
|
||
row.device_type or "unknown",
|
||
row.post_id or "",
|
||
row.user_id or "",
|
||
row.visitor_id or "",
|
||
row.page_path or "",
|
||
row.endpoint_path or "",
|
||
row.referer or "",
|
||
])
|
||
|
||
stamp = datetime.utcnow().strftime("%Y%m%d%H%M%S")
|
||
filename = "forum-tracking-{}-{}d-{}-{}-{}.csv".format(mode, days, selected_variant, selected_device, stamp)
|
||
resp = make_response("\ufeff" + csv_buf.getvalue())
|
||
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
|
||
resp.headers["Content-Disposition"] = "attachment; filename={}".format(filename)
|
||
return resp
|
||
|
||
|
||
# ---------- 厂商管理 ----------
|
||
@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/<int:provider_id>")
|
||
@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/<int:provider_id>/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/<int:provider_id>/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/<int:category_id>/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/<int:category_id>/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/<int:report_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:user_id>/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/<int:post_id>/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/<int:post_id>/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/<int:post_id>/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/<int:comment_id>/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/<int:comment_id>/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/<int:plan_id>/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/<int:plan_id>/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)
|