2026-02-09 14:18:42 +08:00
# -*- coding: utf-8 -*-
""" 云服务器价格对比 - Flask 应用 """
import io
2026-02-10 18:33:57 +08:00
import json
2026-02-10 16:54:06 +08:00
import os
2026-02-10 18:33:57 +08:00
import re
2026-02-10 13:48:58 +08:00
from time import monotonic
2026-02-10 11:07:04 +08:00
from datetime import datetime , timezone
2026-02-10 17:54:22 +08:00
from email . utils import format_datetime
2026-02-09 14:18:42 +08:00
from urllib . parse import urlencode
2026-02-10 17:54:22 +08:00
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 ,
)
2026-02-09 14:28:56 +08:00
from werkzeug . middleware . proxy_fix import ProxyFix
2026-02-10 11:07:04 +08:00
from sqlalchemy import text , func , or_
2026-02-10 13:48:58 +08:00
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
2026-02-09 14:18:42 +08:00
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 )
2026-02-09 14:28:56 +08:00
# 部署在 Nginx 等反向代理后时,信任 X-Forwarded-* 头( HTTPS、真实 IP)
app . wsgi_app = ProxyFix ( app . wsgi_app , x_for = 1 , x_proto = 1 , x_host = 1 )
2026-02-09 14:18:42 +08:00
db . init_app ( app )
2026-02-10 11:07:04 +08:00
from models import (
VPSPlan ,
Provider ,
PriceHistory ,
User ,
ForumPost ,
ForumComment ,
ForumCategory ,
ForumReport ,
ForumNotification ,
ForumPostLike ,
ForumPostBookmark ,
) # noqa: E402
2026-02-09 14:18:42 +08:00
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 时忽略
2026-02-10 11:07:04 +08:00
def _ensure_forum_columns ( ) :
""" 为已有论坛表补齐后续新增字段。 """
try :
engine = db . engine
dialect = engine . dialect . name
with engine . connect ( ) as conn :
if dialect == " mysql " :
alters = [
" ALTER TABLE forum_posts ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT ' 综合讨论 ' " ,
" ALTER TABLE forum_posts ADD COLUMN view_count INT NOT NULL DEFAULT 0 " ,
]
else :
alters = [
" ALTER TABLE forum_posts ADD COLUMN category TEXT DEFAULT ' 综合讨论 ' " ,
" ALTER TABLE forum_posts ADD COLUMN view_count INTEGER DEFAULT 0 " ,
]
for sql in alters :
try :
conn . execute ( text ( sql ) )
conn . commit ( )
except Exception :
conn . rollback ( )
except Exception :
pass
def _ensure_forum_manage_columns ( ) :
""" 为用户与论坛帖子补齐管理字段(封禁/置顶/精华/锁帖)。 """
try :
engine = db . engine
dialect = engine . dialect . name
with engine . connect ( ) as conn :
if dialect == " mysql " :
alters = [
" ALTER TABLE users ADD COLUMN is_banned TINYINT(1) NOT NULL DEFAULT 0 " ,
" ALTER TABLE users ADD COLUMN banned_at DATETIME NULL " ,
" ALTER TABLE users ADD COLUMN banned_reason VARCHAR(255) NULL " ,
" ALTER TABLE forum_posts ADD COLUMN is_pinned TINYINT(1) NOT NULL DEFAULT 0 " ,
" ALTER TABLE forum_posts ADD COLUMN is_featured TINYINT(1) NOT NULL DEFAULT 0 " ,
" ALTER TABLE forum_posts ADD COLUMN is_locked TINYINT(1) NOT NULL DEFAULT 0 " ,
]
else :
alters = [
" ALTER TABLE users ADD COLUMN is_banned INTEGER DEFAULT 0 " ,
" ALTER TABLE users ADD COLUMN banned_at DATETIME " ,
" ALTER TABLE users ADD COLUMN banned_reason TEXT " ,
" ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0 " ,
" ALTER TABLE forum_posts ADD COLUMN is_featured INTEGER DEFAULT 0 " ,
" ALTER TABLE forum_posts ADD COLUMN is_locked INTEGER DEFAULT 0 " ,
]
for sql in alters :
try :
conn . execute ( text ( sql ) )
conn . commit ( )
except Exception :
conn . rollback ( )
except Exception :
pass
DEFAULT_FORUM_CATEGORIES = [
" 综合讨论 " ,
" VPS 评测 " ,
" 优惠活动 " ,
" 运维经验 " ,
" 新手提问 " ,
]
def _ensure_forum_categories_seed ( ) :
""" 初始化论坛默认分类。 """
try :
if ForumCategory . query . count ( ) > 0 :
return
for idx , name in enumerate ( DEFAULT_FORUM_CATEGORIES , start = 1 ) :
db . session . add ( ForumCategory (
name = name ,
sort_order = idx * 10 ,
is_active = True ,
) )
db . session . commit ( )
except Exception :
db . session . rollback ( )
2026-02-09 22:36:32 +08:00
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 ( )
2026-02-09 14:18:42 +08:00
# 启动时自动创建表(若不存在),并为已有表补列
with app . app_context ( ) :
db . create_all ( )
_ensure_mysql_columns ( )
2026-02-10 11:07:04 +08:00
_ensure_forum_columns ( )
_ensure_forum_manage_columns ( )
_ensure_forum_categories_seed ( )
2026-02-09 22:36:32 +08:00
_ensure_price_history_baseline ( )
2026-02-09 14:18:42 +08:00
ADMIN_PASSWORD = app . config [ " ADMIN_PASSWORD " ]
SITE_URL = app . config [ " SITE_URL " ]
SITE_NAME = app . config [ " SITE_NAME " ]
# 国家/区域标签,供后台表单选择
COUNTRY_TAGS = [
" 中国大陆 " , " 中国香港 " , " 中国台湾 " , " 美国 " , " 日本 " , " 新加坡 " , " 韩国 " ,
" 德国 " , " 英国 " , " 法国 " , " 荷兰 " , " 加拿大 " , " 澳大利亚 " , " 印度 " , " 其他 " ,
]
2026-02-09 22:36:32 +08:00
PRICE_SOURCE_LABELS = {
" manual " : " 手工编辑 " ,
" import " : " Excel 导入 " ,
" bootstrap " : " 基线 " ,
}
2026-02-10 11:07:04 +08:00
FORUM_REPORT_REASONS = [
" 垃圾广告 " ,
" 辱骂攻击 " ,
" 违法违规 " ,
" 虚假信息 " ,
" 其他 " ,
]
FORUM_REPORT_STATUS_LABELS = {
" pending " : " 待处理 " ,
" processed " : " 已处理 " ,
" rejected " : " 已驳回 " ,
}
FORUM_NOTIFICATION_TYPE_LABELS = {
" post_commented " : " 帖子新评论 " ,
" thread_replied " : " 主题新回复 " ,
" report_processed " : " 举报处理结果 " ,
" content_removed " : " 内容处理通知 " ,
}
2026-02-09 22:36:32 +08:00
2026-02-10 13:48:58 +08:00
# 论坛高频数据短时缓存(进程内)
_FORUM_CACHE_TTL_CATEGORIES = 20.0
_FORUM_CACHE_TTL_SIDEBAR = 15.0
2026-02-10 13:57:46 +08:00
_FORUM_CACHE_TTL_NOTIF_COUNT = 30.0
2026-02-10 13:48:58 +08:00
_FORUM_CATEGORY_CACHE = { }
_FORUM_SIDEBAR_CACHE = { " expires_at " : 0.0 , " data " : None }
2026-02-10 13:57:46 +08:00
_NOTIF_COUNT_CACHE = { } # user_id -> (count, expires_at)
2026-02-10 13:48:58 +08:00
_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 " ,
}
2026-02-10 17:54:22 +08:00
# 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. " ,
} ,
}
2026-02-10 13:48:58 +08:00
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 ) )
2026-02-10 17:54:22 +08:00
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- %d T % 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 ( ) )
2026-02-10 18:33:57 +08:00
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 ) )
2026-02-10 17:54:22 +08:00
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
2026-02-10 13:48:58 +08:00
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 )
2026-02-09 22:36:32 +08:00
def _get_current_user ( ) :
user_id = session . get ( " user_id " )
if not user_id :
return None
2026-02-10 11:07:04 +08:00
user = db . session . get ( User , user_id )
2026-02-09 22:36:32 +08:00
if not user :
session . pop ( " user_id " , None )
return user
2026-02-10 11:07:04 +08:00
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 " 账号已被封禁 "
2026-02-09 22:36:32 +08:00
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 )
2026-02-10 11:07:04 +08:00
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 ,
) )
2026-02-10 13:57:46 +08:00
_NOTIF_COUNT_CACHE . pop ( user_id , None )
2026-02-10 11:07:04 +08:00
def _notification_target_url ( notification ) :
2026-02-10 13:48:58 +08:00
# 避免通知列表页按条检查帖子存在性导致 N+1 查询。
2026-02-10 11:07:04 +08:00
if notification . post_id :
2026-02-10 13:48:58 +08:00
return url_for ( " forum_post_detail " , post_id = notification . post_id )
2026-02-10 11:07:04 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
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 ] )
2026-02-10 11:07:04 +08:00
rows = _load_forum_categories ( active_only = active_only )
names = [ x . name for x in rows if x . name ]
if names :
2026-02-10 13:48:58 +08:00
_FORUM_CATEGORY_CACHE [ cache_key ] = ( now_ts + _FORUM_CACHE_TTL_CATEGORIES , tuple ( names ) )
2026-02-10 11:07:04 +08:00
return names
# 若全部被停用,前台仍回退到已存在分类,避免下拉为空。
if active_only :
rows = _load_forum_categories ( active_only = False )
names = [ x . name for x in rows if x . name ]
if names :
2026-02-10 13:48:58 +08:00
_FORUM_CATEGORY_CACHE [ cache_key ] = ( now_ts + _FORUM_CACHE_TTL_CATEGORIES , tuple ( names ) )
2026-02-10 11:07:04 +08:00
return names
2026-02-10 13:48:58 +08:00
fallback = list ( DEFAULT_FORUM_CATEGORIES )
_FORUM_CATEGORY_CACHE [ cache_key ] = ( now_ts + _FORUM_CACHE_TTL_CATEGORIES , tuple ( fallback ) )
return fallback
2026-02-10 11:07:04 +08:00
2026-02-10 13:57:46 +08:00
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
2026-02-09 22:36:32 +08:00
@app.context_processor
def inject_global_user ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
current_user = _get_current_user ( )
2026-02-10 13:57:46 +08:00
notifications_unread_count = _get_notifications_unread_count ( current_user . id if current_user else None )
2026-02-09 22:36:32 +08:00
return {
2026-02-10 11:07:04 +08:00
" current_user " : current_user ,
2026-02-09 22:36:32 +08:00
" admin_logged_in " : bool ( session . get ( " admin_logged_in " ) ) ,
2026-02-10 11:07:04 +08:00
" forum_categories " : _get_forum_category_names ( active_only = True ) ,
" forum_report_reasons " : FORUM_REPORT_REASONS ,
" notifications_unread_count " : notifications_unread_count ,
2026-02-10 13:48:58 +08:00
" lang " : lang ,
2026-02-10 11:07:04 +08:00
}
2026-02-10 13:48:58 +08:00
def _humanize_time ( dt , lang = None ) :
2026-02-10 11:07:04 +08:00
if not dt :
return " "
2026-02-10 13:48:58 +08:00
active_lang = lang or session . get ( " lang " , " zh " )
2026-02-10 11:07:04 +08:00
if dt . tzinfo is None :
2026-02-10 16:54:06 +08:00
# 兼容历史“无时区”时间:按 UTC 解释后与当前 UTC 进行比较,避免 utcnow 弃用告警
dt = dt . replace ( tzinfo = timezone . utc )
now = datetime . now ( timezone . utc )
2026-02-10 11:07:04 +08:00
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 :
2026-02-10 13:48:58 +08:00
return " just now " if active_lang == " en " else " 刚刚 "
2026-02-10 11:07:04 +08:00
if seconds < 3600 :
2026-02-10 13:48:58 +08:00
mins = seconds / / 60
return " {} m ago " . format ( mins ) if active_lang == " en " else " {} 分钟前 " . format ( mins )
2026-02-10 11:07:04 +08:00
if seconds < 86400 :
2026-02-10 13:48:58 +08:00
hours = seconds / / 3600
return " {} h ago " . format ( hours ) if active_lang == " en " else " {} 小时前 " . format ( hours )
2026-02-10 11:07:04 +08:00
if seconds < 86400 * 14 :
2026-02-10 13:48:58 +08:00
days = seconds / / 86400
return " {} d ago " . format ( days ) if active_lang == " en " else " {} 天前 " . format ( days )
2026-02-10 11:07:04 +08:00
return dt . strftime ( " % Y- % m- %d " )
2026-02-10 13:48:58 +08:00
def _build_forum_post_cards ( rows , lang = None ) :
2026-02-10 11:07:04 +08:00
""" 将论坛查询结果行转换为列表卡片数据。 """
2026-02-10 13:48:58 +08:00
active_lang = lang or _get_lang ( )
2026-02-10 11:07:04 +08:00
cards = [ ]
for post , reply_count , latest_activity , author_name , like_count , bookmark_count in rows :
latest_activity = latest_activity or post . created_at
2026-02-10 13:48:58 +08:00
username = author_name or _pick_lang ( " 用户 " , " User " , active_lang )
2026-02-10 11:07:04 +08:00
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 ,
2026-02-10 13:48:58 +08:00
" latest_activity_text " : _humanize_time ( latest_activity , lang = active_lang ) ,
2026-02-10 11:07:04 +08:00
" author_name " : username ,
" author_initial " : ( username [ 0 ] if username else " ? " ) . upper ( ) ,
} )
return cards
2026-02-10 18:33:57 +08:00
def _build_forum_url ( tab = " latest " , category = None , q = None , page = 1 , per_page = 20 , lang = None ) :
2026-02-10 11:07:04 +08:00
""" 构建论坛列表页链接,并尽量保持 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 )
2026-02-10 13:48:58 +08:00
if per_page :
size = int ( per_page )
if size != 20 :
params [ " per_page " ] = size
2026-02-10 18:33:57 +08:00
active_lang = ( lang or " " ) . strip ( ) . lower ( )
if active_lang == " en " :
params [ " lang " ] = " en "
2026-02-10 11:07:04 +08:00
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 ( ) :
2026-02-10 13:48:58 +08:00
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 )
2026-02-10 11:07:04 +08:00
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 ( )
)
2026-02-10 13:48:58 +08:00
data = {
2026-02-10 11:07:04 +08:00
" 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 ) ,
2026-02-09 22:36:32 +08:00
}
2026-02-10 13:48:58 +08:00
_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 )
2026-02-09 22:36:32 +08:00
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
2026-02-09 14:18:42 +08:00
2026-02-10 18:33:57 +08:00
def _build_post_plan_recommendations ( post , lang = " zh " , limit = 5 ) :
if not post :
return [ ] , _pick_lang ( " 暂无推荐方案 " , " No recommended plans yet. " , lang )
raw_text = " {} \n {} " . format ( post . title or " " , post . content or " " )
text_lower = raw_text . lower ( )
matched_provider_ids = [ ]
matched_provider_names = [ ]
provider_rows = Provider . query . order_by ( Provider . id . asc ( ) ) . limit ( 200 ) . all ( )
for provider in provider_rows :
name = ( provider . name or " " ) . strip ( )
if not name :
continue
if name . lower ( ) in text_lower :
matched_provider_ids . append ( provider . id )
matched_provider_names . append ( name )
matched_regions = [ ]
for region in COUNTRY_TAGS :
item = ( region or " " ) . strip ( )
if item and item in raw_text :
matched_regions . append ( item )
if len ( matched_regions ) > = 3 :
break
price_score_expr = func . coalesce ( VPSPlan . price_cny , VPSPlan . price_usd * 7.2 , 999999.0 )
base_query = (
VPSPlan . query
. options ( joinedload ( VPSPlan . provider_rel ) )
. filter ( or_ ( VPSPlan . price_cny . isnot ( None ) , VPSPlan . price_usd . isnot ( None ) ) )
)
scoped_query = base_query
if matched_provider_ids :
scoped_query = scoped_query . filter (
or_ (
VPSPlan . provider_id . in_ ( matched_provider_ids ) ,
VPSPlan . provider . in_ ( matched_provider_names ) ,
)
)
if matched_regions :
region_conds = [ ]
for region in matched_regions :
region_conds . append ( VPSPlan . countries . ilike ( " % {} % " . format ( region ) ) )
region_conds . append ( VPSPlan . region . ilike ( " % {} % " . format ( region ) ) )
scoped_query = scoped_query . filter ( or_ ( * region_conds ) )
ordered_scoped = scoped_query . order_by (
price_score_expr . asc ( ) ,
VPSPlan . vcpu . desc ( ) ,
VPSPlan . memory_gb . desc ( ) ,
VPSPlan . id . desc ( ) ,
)
picked = ordered_scoped . limit ( limit ) . all ( )
seen_ids = { p . id for p in picked if p and p . id is not None }
if len ( picked ) < limit :
fallback_rows = (
base_query
. order_by (
price_score_expr . asc ( ) ,
VPSPlan . vcpu . desc ( ) ,
VPSPlan . memory_gb . desc ( ) ,
VPSPlan . id . desc ( ) ,
)
. limit ( max ( limit * 2 , 12 ) )
. all ( )
)
for row in fallback_rows :
if not row or row . id in seen_ids :
continue
picked . append ( row )
seen_ids . add ( row . id )
if len ( picked ) > = limit :
break
items = [ ]
for plan in picked [ : limit ] :
if plan . price_cny is not None :
price_label = _format_money ( " CNY " , plan . price_cny )
elif plan . price_usd is not None :
price_label = _format_money ( " USD " , plan . price_usd )
else :
price_label = _pick_lang ( " 待更新 " , " TBD " , lang )
items . append ( {
" id " : plan . id ,
" provider " : plan . provider_name or plan . provider or _pick_lang ( " 未知厂商 " , " Unknown Provider " , lang ) ,
" name " : plan . display_name or _pick_lang ( " 未命名方案 " , " Unnamed Plan " , lang ) ,
" region " : ( plan . countries or plan . region or _pick_lang ( " 区域未标注 " , " Region not specified " , lang ) ) . strip ( ) ,
" price_label " : price_label ,
" official_url " : ( plan . official_url or ( plan . provider_rel . official_url if plan . provider_rel else " " ) or " " ) . strip ( ) ,
} )
if matched_provider_ids and matched_regions :
context_text = _pick_lang (
" 依据帖子中的厂商与区域关键词推荐 " ,
" Recommended based on provider and region keywords in this topic " ,
lang ,
)
elif matched_provider_ids :
context_text = _pick_lang (
" 依据帖子中的厂商关键词推荐 " ,
" Recommended based on provider keywords in this topic " ,
lang ,
)
elif matched_regions :
context_text = _pick_lang (
" 依据帖子中的区域关键词推荐 " ,
" Recommended based on region keywords in this topic " ,
lang ,
)
else :
context_text = _pick_lang (
" 按价格与规格综合排序推荐 " ,
" Recommended by a combined price/spec ranking " ,
lang ,
)
return items , context_text
def _build_post_detail_url ( post_id , lang = " zh " , comment_page = 1 ) :
""" 构建帖子详情页 URL( 用于评论分页链接) 。 """
page_num = 1
try :
page_num = int ( comment_page or 1 )
except Exception :
page_num = 1
if page_num < 1 :
page_num = 1
params = { " post_id " : post_id }
if page_num > 1 :
params [ " cp " ] = page_num
if ( lang or " zh " ) . strip ( ) . lower ( ) == " en " :
params [ " lang " ] = " en "
return url_for ( " forum_post_detail " , * * params )
def _build_post_comment_page_links ( post_id , total_pages , current_page , lang = " zh " ) :
total = int ( total_pages or 1 )
current = int ( current_page or 1 )
if total < = 1 :
return [ ]
candidates = { 1 , total }
for n in range ( current - 2 , current + 3 ) :
if 1 < = n < = total :
candidates . add ( n )
ordered = sorted ( candidates )
links = [ ]
prev = None
for page in ordered :
if prev is not None and page - prev > 1 :
links . append ( { " is_gap " : True , " label " : " … " } )
links . append ( {
" is_gap " : False ,
" page " : page ,
" url " : _build_post_detail_url ( post_id , lang = lang , comment_page = page ) ,
" active " : page == current ,
} )
prev = page
return links
def _build_post_resource_links ( post , lang = " zh " ) :
if not post :
return [ ]
category_name = ( post . category or " " ) . strip ( )
links = [ ]
if category_name :
links . append ( {
" title " : _pick_lang ( " 继续看同分类主题 " , " More in This Category " , lang ) ,
" description " : _pick_lang ( " 同一分类下的最新讨论与经验汇总。 " , " Browse latest discussions in the same category. " , lang ) ,
" url " : _build_forum_url ( category = category_name , lang = lang ) ,
" track_label " : " resource_category " ,
} )
links . extend ( [
{
" title " : _pick_lang ( " 论坛热门讨论 " , " Hot Forum Topics " , lang ) ,
" description " : _pick_lang ( " 优先阅读互动度高的帖子,快速获取高信号观点。 " , " Prioritize high-engagement threads for stronger signals. " , lang ) ,
" url " : _build_forum_url ( tab = " hot " , lang = lang ) ,
" track_label " : " resource_hot " ,
} ,
{
" title " : _pick_lang ( " 论坛最新动态 " , " Latest Forum Activity " , lang ) ,
" description " : _pick_lang ( " 追踪最新发布和最近活跃的主题。 " , " Track newly posted and recently active topics. " , lang ) ,
" url " : _build_forum_url ( tab = " latest " , lang = lang ) ,
" track_label " : " resource_latest " ,
} ,
{
" title " : _pick_lang ( " VPS 价格总览 " , " VPS Pricing Console " , lang ) ,
" description " : _pick_lang ( " 按价格、地区、配置进行方案筛选。 " , " Filter plans by price, region, and specs. " , lang ) ,
" url " : url_for ( " index " , lang = " en " ) if lang == " en " else url_for ( " index " ) ,
" track_label " : " resource_pricing " ,
} ,
{
" title " : _pick_lang ( " 论坛 RSS 订阅 " , " Forum RSS Feed " , lang ) ,
" description " : _pick_lang ( " 通过订阅持续跟进论坛更新。 " , " Follow forum updates through RSS subscription. " , lang ) ,
" url " : url_for ( " forum_feed " , lang = " en " ) if lang == " en " else url_for ( " forum_feed " ) ,
" track_label " : " resource_feed " ,
} ,
] )
deduped = [ ]
seen = set ( )
for item in links :
u = item . get ( " url " )
if not u or u in seen :
continue
seen . add ( u )
deduped . append ( item )
return deduped [ : 6 ]
def _build_post_faq_items ( post , comments_count = 0 , read_minutes = 1 , plan_reco_context = " " , lang = " zh " ) :
if not post :
return [ ]
post_excerpt = _plain_excerpt ( post . content or " " , limit = 180 ) or _pick_lang (
" 本帖围绕 VPS 选型与采购决策展开讨论。 " ,
" This topic discusses VPS shortlisting and procurement decisions. " ,
lang ,
)
comments_val = max ( int ( comments_count or 0 ) , 0 )
read_val = max ( int ( read_minutes or 1 ) , 1 )
recommendation_line = ( plan_reco_context or " " ) . strip ( ) or _pick_lang (
" 按价格与规格综合排序推荐方案。 " ,
" Plans are recommended by combined price and spec ranking. " ,
lang ,
)
return [
{
" question " : _pick_lang ( " 这篇帖子主要讨论什么? " , " What does this topic focus on? " , lang ) ,
" answer " : post_excerpt ,
} ,
{
" question " : _pick_lang ( " 我应该先看正文还是先看评论? " , " Should I read content or comments first? " , lang ) ,
" answer " : _pick_lang (
" 建议先用约 {} 分钟读完正文,再结合 {} 条评论验证观点。 " . format ( read_val , comments_val ) ,
" Read the main post first in about {} minutes, then validate points with {} comments. " . format ( read_val , comments_val ) ,
lang ,
) ,
} ,
{
" question " : _pick_lang ( " 下一步如何落地选型? " , " What is the next step for shortlisting? " , lang ) ,
" answer " : _pick_lang (
" {} 随后进入价格页按地区、预算和配置筛选,再到厂商官网确认条款。 " . format ( recommendation_line ) ,
" {} Then use the pricing page filters (region, budget, specs) and confirm terms on official provider sites. " . format ( recommendation_line ) ,
lang ,
) ,
} ,
]
def _build_post_howto_schema ( post , canonical_url , lang = " zh " , read_minutes = 1 , comments_count = 0 ) :
if not post or not canonical_url :
return None
comments_val = max ( int ( comments_count or 0 ) , 0 )
read_val = max ( int ( read_minutes or 1 ) , 1 )
pricing_url = _public_url ( " index " , lang = lang )
post_new_url = _public_url ( " forum_post_new " , lang = lang )
steps = [
{
" @type " : " HowToStep " ,
" position " : 1 ,
" name " : _pick_lang ( " 阅读主题与核心需求 " , " Read the topic and core requirement " , lang ) ,
" text " : _pick_lang (
" 先阅读标题和正文,明确业务目标、预算和区域要求。 " ,
" Read title and content first to identify workload goals, budget, and region requirements. " ,
lang ,
) ,
" url " : canonical_url ,
} ,
{
" @type " : " HowToStep " ,
" position " : 2 ,
" name " : _pick_lang ( " 核对评论反馈 " , " Validate with comments " , lang ) ,
" text " : _pick_lang (
" 结合约 {} 条评论判断观点可靠性与落地风险。 " . format ( comments_val ) ,
" Use around {} comments to validate reliability and delivery risks. " . format ( comments_val ) ,
lang ,
) ,
" url " : " {} #comments-panel " . format ( canonical_url ) ,
} ,
{
" @type " : " HowToStep " ,
" position " : 3 ,
" name " : _pick_lang ( " 进入价格页筛选方案 " , " Filter plans on pricing page " , lang ) ,
" text " : _pick_lang (
" 按地区、价格和配置过滤候选 VPS, 建立短名单。 " ,
" Filter candidates by region, price, and specs to build a shortlist. " ,
lang ,
) ,
" url " : pricing_url ,
} ,
{
" @type " : " HowToStep " ,
" position " : 4 ,
" name " : _pick_lang ( " 补充需求并确认采购 " , " Publish requirement and finalize " , lang ) ,
" text " : _pick_lang (
" 若信息仍不足,可发布新主题补充业务约束并确认采购方案。 " ,
" If signal is still insufficient, publish a follow-up topic and finalize the buying plan. " ,
lang ,
) ,
" url " : post_new_url ,
} ,
]
return {
" @type " : " HowTo " ,
" @id " : " {} #howto " . format ( canonical_url ) ,
" name " : _pick_lang ( " 如何从论坛主题完成 VPS 选型 " , " How to shortlist VPS from a forum topic " , lang ) ,
" description " : _pick_lang (
" 从阅读帖子到筛选方案再到确认采购的标准流程。 " ,
" A practical workflow from reading a discussion to shortlisting and procurement. " ,
lang ,
) ,
" inLanguage " : " en-US " if lang == " en " else " zh-CN " ,
" totalTime " : " PT {} M " . format ( max ( 3 , read_val + 2 ) ) ,
" step " : steps ,
}
2026-02-09 14:18:42 +08:00
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
2026-02-09 22:36:32 +08:00
def user_login_required ( f ) :
from functools import wraps
@wraps ( f )
def wrapped ( * args , * * kwargs ) :
2026-02-10 11:07:04 +08:00
user = _get_current_user ( )
if not user :
2026-02-09 22:36:32 +08:00
return redirect ( url_for ( " user_login " , next = request . path ) )
2026-02-10 11:07:04 +08:00
if _is_banned_user ( user ) :
session . pop ( " user_id " , None )
return redirect ( url_for ( " user_login " , next = request . path , error = _user_ban_message ( user ) ) )
2026-02-09 22:36:32 +08:00
return f ( * args , * * kwargs )
return wrapped
2026-02-10 11:07:04 +08:00
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 ) )
2026-02-10 11:49:01 +08:00
# 首页多语言文案(中文 / English)
I18N = {
" zh " : {
2026-02-10 16:54:06 +08:00
" 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 " : " 区域市场 " ,
2026-02-10 11:49:01 +08:00
" filter_memory " : " 内存 ≥ " ,
" filter_price " : " 价格区间 " ,
2026-02-10 16:54:06 +08:00
" filter_currency " : " 计价货币 " ,
" search_placeholder " : " 搜索供应商、方案或区域... " ,
2026-02-10 11:49:01 +08:00
" all " : " 全部 " ,
" unlimited " : " 不限 " ,
2026-02-10 16:54:06 +08:00
" btn_reset " : " 清空筛选 " ,
" btn_visit " : " 查看官网 " ,
" th_provider " : " 供应商 " ,
" th_country " : " 区域 " ,
" th_config " : " 实例规格 " ,
2026-02-10 11:49:01 +08:00
" th_vcpu " : " vCPU " ,
" th_memory " : " 内存 " ,
" th_storage " : " 存储 " ,
" th_bandwidth " : " 带宽 " ,
" th_traffic " : " 流量 " ,
2026-02-10 16:54:06 +08:00
" th_price " : " 月付参考价 " ,
" th_action " : " 官方链接 " ,
" disclaimer " : " * 数据来自公开页面与规则换算,可能存在时差或促销偏差;下单前请以厂商官网实时价格与条款为准。 " ,
" footer_note " : " 仅作采购调研参考 · 请以各云厂商官网实时价格为准 " ,
2026-02-10 11:49:01 +08:00
" contact_label " : " 联系我们 " ,
" empty_state " : " 未找到匹配的方案 " ,
" load_error " : " 数据加载失败,请刷新页面重试 " ,
2026-02-10 16:54:06 +08:00
" search_label " : " 关键词检索 " ,
" result_count_pattern " : " 当前筛选: {visible} / {total} 个方案 " ,
2026-02-10 11:49:01 +08:00
" price_under50 " : " < ¥50 " ,
" price_50_100 " : " ¥50-100 " ,
" price_100_300 " : " ¥100-300 " ,
" price_300_500 " : " ¥300-500 " ,
" price_over500 " : " > ¥500 " ,
" cny " : " 人民币 (¥) " ,
" usd " : " 美元 ($) " ,
2026-02-10 17:54:22 +08:00
" 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 " : " 联系维护者 " ,
2026-02-10 11:49:01 +08:00
} ,
" en " : {
2026-02-10 16:54:06 +08:00
" 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. " ,
2026-02-10 11:49:01 +08:00
" filter_provider " : " Provider " ,
" filter_region " : " Region " ,
" filter_memory " : " Memory ≥ " ,
" filter_price " : " Price range " ,
" filter_currency " : " Currency " ,
2026-02-10 16:54:06 +08:00
" search_placeholder " : " Search provider, plan, or region... " ,
2026-02-10 11:49:01 +08:00
" all " : " All " ,
" unlimited " : " Any " ,
2026-02-10 16:54:06 +08:00
" btn_reset " : " Clear filters " ,
" btn_visit " : " Visit Site " ,
2026-02-10 11:49:01 +08:00
" th_provider " : " Provider " ,
2026-02-10 16:54:06 +08:00
" th_country " : " Region " ,
" th_config " : " Plan Spec " ,
2026-02-10 11:49:01 +08:00
" th_vcpu " : " vCPU " ,
" th_memory " : " Memory " ,
" th_storage " : " Storage " ,
" th_bandwidth " : " Bandwidth " ,
" th_traffic " : " Traffic " ,
2026-02-10 16:54:06 +08:00
" 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. " ,
2026-02-10 11:49:01 +08:00
" contact_label " : " Contact " ,
" empty_state " : " No matching plans found " ,
" load_error " : " Failed to load data. Please refresh. " ,
2026-02-10 16:54:06 +08:00
" search_label " : " Keyword Search " ,
" result_count_pattern " : " Showing {visible} of {total} plans " ,
2026-02-10 11:49:01 +08:00
" 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 ($) " ,
2026-02-10 17:54:22 +08:00
" 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 " ,
2026-02-10 11:49:01 +08:00
} ,
}
2026-02-10 13:57:46 +08:00
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
2026-02-10 17:54:22 +08:00
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 ,
} ,
] ,
}
2026-02-09 14:18:42 +08:00
@app.route ( " / " )
def index ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:49:01 +08:00
t = I18N [ lang ]
2026-02-10 13:57:46 +08:00
plans = _query_plans_for_display ( )
plans_data = [ p . to_dict ( ) for p in plans ]
2026-02-10 17:54:22 +08:00
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 ,
)
2026-02-09 14:18:42 +08:00
return render_template (
" index.html " ,
2026-02-10 17:54:22 +08:00
site_url = _site_root_url ( ) ,
2026-02-09 14:18:42 +08:00
site_name = SITE_NAME ,
2026-02-10 13:57:46 +08:00
initial_plans_json = plans_data ,
2026-02-10 17:54:22 +08:00
faq_items = faq_items ,
seo = seo ,
seo_schema = home_schema ,
2026-02-10 11:49:01 +08:00
lang = lang ,
t = t ,
2026-02-09 14:18:42 +08:00
)
2026-02-10 16:54:06 +08:00
@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 )
2026-02-09 14:18:42 +08:00
@app.route ( " /api/plans " )
def api_plans ( ) :
2026-02-10 13:57:46 +08:00
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
2026-02-09 14:18:42 +08:00
2026-02-10 18:33:57 +08:00
@app.route ( " /api/event/track " , methods = [ " POST " ] )
def api_event_track ( ) :
payload = { }
if request . is_json :
payload = request . get_json ( silent = True ) or { }
if not payload :
payload = request . form . to_dict ( flat = True )
event_name = ( payload . get ( " event_name " ) or " " ) . strip ( ) . lower ( )
if not re . match ( r " ^[a-z0-9_] { 3,64}$ " , event_name or " " ) :
return ( " " , 204 )
whitelist = {
" post_detail_cta_pricing " ,
" post_detail_cta_new_topic " ,
" post_detail_jump_comments " ,
" post_detail_related_click " ,
" post_detail_plan_click " ,
" post_detail_comment_submit " ,
" post_detail_sidebar_compare " ,
" post_detail_resource_click " ,
}
if event_name not in whitelist :
return ( " " , 204 )
label = " " . join ( ( payload . get ( " label " ) or " " ) . strip ( ) . split ( ) ) [ : 120 ]
page_path = " " . join ( ( payload . get ( " page_path " ) or " " ) . strip ( ) . split ( ) ) [ : 255 ]
post_id = payload . get ( " post_id " )
try :
post_id = int ( post_id ) if post_id is not None else None
except Exception :
post_id = None
if not page_path :
referer = ( request . headers . get ( " Referer " ) or " " ) . strip ( )
page_path = referer [ : 255 ]
user = _get_current_user ( )
event_data = {
" event_name " : event_name ,
" label " : label ,
" post_id " : post_id ,
" user_id " : user . id if user else None ,
" page_path " : page_path ,
" endpoint_path " : request . path ,
" referer " : ( request . headers . get ( " Referer " ) or " " ) [ : 255 ] ,
" ip " : ( request . headers . get ( " X-Forwarded-For " ) or request . remote_addr or " " ) [ : 120 ] ,
" at " : _iso8601_utc ( datetime . now ( timezone . utc ) ) ,
}
app . logger . info ( " forum_track_event %s " , json . dumps ( event_data , ensure_ascii = False ) )
return ( " " , 204 )
2026-02-09 22:36:32 +08:00
# ---------- 前台用户与论坛 ----------
@app.route ( " /register " , methods = [ " GET " , " POST " ] )
def user_register ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
current = _get_current_user ( )
if current :
if _is_banned_user ( current ) :
session . pop ( " user_id " , None )
else :
return redirect ( url_for ( " forum_index " ) )
2026-02-09 22:36:32 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
error = _pick_lang (
" 用户名需为 3-20 位,仅支持字母、数字、下划线 " ,
" Username must be 3-20 chars (letters, numbers, underscore). " ,
lang ,
)
2026-02-09 22:36:32 +08:00
elif len ( password ) < 6 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 密码至少 6 位 " , " Password must be at least 6 characters. " , lang )
2026-02-09 22:36:32 +08:00
elif password != confirm_password :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 两次输入的密码不一致 " , " Passwords do not match. " , lang )
2026-02-09 22:36:32 +08:00
elif User . query . filter ( func . lower ( User . username ) == username . lower ( ) ) . first ( ) :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 用户名已存在 " , " Username already exists. " , lang )
2026-02-09 22:36:32 +08:00
else :
user = User ( username = username )
user . set_password ( password )
2026-02-10 11:07:04 +08:00
user . last_login_at = datetime . now ( timezone . utc )
2026-02-09 22:36:32 +08:00
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 ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
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
2026-02-09 22:36:32 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 用户名或密码错误 " , " Invalid username or password. " , lang )
2026-02-10 11:07:04 +08:00
elif _is_banned_user ( user ) :
error = _user_ban_message ( user )
2026-02-09 22:36:32 +08:00
else :
2026-02-10 11:07:04 +08:00
user . last_login_at = datetime . now ( timezone . utc )
2026-02-09 22:36:32 +08:00
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 " ) )
2026-02-10 11:07:04 +08:00
@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 ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
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 :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , msg = _pick_lang ( " 资料未变更 " , " No changes detected. " , lang ) ) )
2026-02-10 11:07:04 +08:00
if not _is_valid_username ( username ) :
2026-02-10 13:48:58 +08:00
return redirect ( url_for (
" user_profile " ,
tab = " settings " ,
error = _pick_lang (
" 用户名需为 3-20 位,仅支持字母、数字、下划线 " ,
" Username must be 3-20 chars (letters, numbers, underscore). " ,
lang ,
) ,
) )
2026-02-10 11:07:04 +08:00
exists = (
User . query
. filter ( func . lower ( User . username ) == username . lower ( ) , User . id != user . id )
. first ( )
)
if exists :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , error = _pick_lang ( " 用户名已存在 " , " Username already exists. " , lang ) ) )
2026-02-10 11:07:04 +08:00
user . username = username
db . session . commit ( )
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , msg = _pick_lang ( " 用户名已更新 " , " Username updated. " , lang ) ) )
2026-02-10 11:07:04 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , error = _pick_lang ( " 当前密码错误 " , " Current password is incorrect. " , lang ) ) )
2026-02-10 11:07:04 +08:00
if len ( new_password ) < 6 :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , error = _pick_lang ( " 新密码至少 6 位 " , " New password must be at least 6 characters. " , lang ) ) )
2026-02-10 11:07:04 +08:00
if new_password != confirm_password :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , error = _pick_lang ( " 两次新密码输入不一致 " , " New passwords do not match. " , lang ) ) )
2026-02-10 11:07:04 +08:00
user . set_password ( new_password )
db . session . commit ( )
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , msg = _pick_lang ( " 密码已更新 " , " Password updated. " , lang ) ) )
2026-02-10 11:07:04 +08:00
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_profile " , tab = " settings " , error = _pick_lang ( " 未知操作 " , " Unknown action. " , lang ) ) )
2026-02-10 11:07:04 +08:00
my_post_rows = (
_query_forum_post_rows ( active_tab = " latest " , author_id = user . id )
. limit ( 60 )
. all ( )
)
2026-02-10 13:48:58 +08:00
my_post_cards = _build_forum_post_cards ( my_post_rows , lang = lang )
2026-02-10 11:07:04 +08:00
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 ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
user = _get_current_user ( )
status = ( request . args . get ( " status " ) or " all " ) . strip ( ) . lower ( )
if status not in { " all " , " unread " , " read " } :
status = " all "
2026-02-10 13:48:58 +08:00
q = (
ForumNotification . query
. filter_by ( user_id = user . id )
. options ( joinedload ( ForumNotification . actor_rel ) )
)
2026-02-10 11:07:04 +08:00
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 ,
2026-02-10 13:48:58 +08:00
" type_label " : _notification_type_label ( n . notif_type , lang = lang ) ,
2026-02-10 11:07:04 +08:00
" actor_name " : n . actor_rel . username if n . actor_rel else " " ,
" target_url " : _notification_target_url ( n ) ,
2026-02-10 13:48:58 +08:00
" time_text " : _humanize_time ( n . created_at , lang = lang ) ,
2026-02-10 11:07:04 +08:00
} )
2026-02-10 13:48:58 +08:00
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 )
2026-02-10 11:07:04 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
user = _get_current_user ( )
n = ForumNotification . query . get_or_404 ( notification_id )
if n . user_id != user . id :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_notifications " , error = _pick_lang ( " 无权访问该通知 " , " Permission denied for this notification. " , lang ) ) )
2026-02-10 11:07:04 +08:00
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 ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
user = _get_current_user ( )
n = ForumNotification . query . get_or_404 ( notification_id )
if n . user_id != user . id :
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_notifications " , error = _pick_lang ( " 无权操作该通知 " , " Permission denied for this notification. " , lang ) ) )
2026-02-10 11:07:04 +08:00
if not n . is_read :
n . is_read = True
db . session . commit ( )
2026-02-10 13:57:46 +08:00
_NOTIF_COUNT_CACHE . pop ( user . id , None )
2026-02-10 11:07:04 +08:00
next_url = ( request . form . get ( " next " ) or " " ) . strip ( )
if next_url . startswith ( " / " ) and not next_url . startswith ( " // " ) :
return redirect ( next_url )
2026-02-10 13:48:58 +08:00
return redirect ( url_for ( " user_notifications " , msg = _pick_lang ( " 已标记为已读 " , " Marked as read. " , lang ) ) )
2026-02-10 11:07:04 +08:00
@app.route ( " /notifications/read-all " , methods = [ " POST " ] )
@user_login_required
def user_notifications_read_all ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
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 ( )
2026-02-10 13:57:46 +08:00
if updated :
_NOTIF_COUNT_CACHE . pop ( user . id , None )
2026-02-10 13:48:58 +08:00
msg = _pick_lang ( " 已全部标记为已读 " , " All notifications marked as read. " , lang ) if updated else _pick_lang ( " 没有未读通知 " , " No unread notifications. " , lang )
2026-02-10 11:07:04 +08:00
return redirect ( url_for ( " user_notifications " , msg = msg ) )
2026-02-09 22:36:32 +08:00
@app.route ( " /forum " )
def forum_index ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
per_page_options = [ 10 , 20 , 30 , 50 ]
2026-02-10 11:07:04 +08:00
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
2026-02-10 13:48:58 +08:00
per_page = request . args . get ( " per_page " , type = int ) or 20
if per_page not in per_page_options :
per_page = 20
2026-02-10 11:07:04 +08:00
rows_query = _query_forum_post_rows (
active_tab = active_tab ,
selected_category = selected_category ,
search_query = search_query or None ,
)
2026-02-10 13:48:58 +08:00
total_posts = _count_forum_posts (
selected_category = selected_category ,
search_query = search_query or None ,
)
2026-02-10 11:07:04 +08:00
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 ( )
2026-02-10 13:48:58 +08:00
post_cards = _build_forum_post_cards ( rows , lang = lang )
2026-02-10 11:07:04 +08:00
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 = [
2026-02-10 13:48:58 +08:00
( " latest " , _pick_lang ( " 最新 " , " Latest " , lang ) ) ,
( " new " , _pick_lang ( " 新帖 " , " New " , lang ) ) ,
( " hot " , _pick_lang ( " 热门 " , " Top " , lang ) ) ,
2026-02-10 11:07:04 +08:00
]
tab_links = [
{
" key " : key ,
" label " : label ,
" url " : _build_forum_url (
tab = key ,
category = selected_category ,
q = search_query or None ,
page = 1 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
) ,
" active " : active_tab == key ,
}
for key , label in tab_defs
]
category_links = [
{
2026-02-10 13:48:58 +08:00
" name " : _pick_lang ( " 全部 " , " All " , lang ) ,
2026-02-10 11:07:04 +08:00
" count " : None ,
2026-02-10 13:48:58 +08:00
" url " : _build_forum_url (
tab = active_tab ,
category = None ,
q = search_query or None ,
page = 1 ,
per_page = per_page ,
) ,
2026-02-10 11:07:04 +08:00
" active " : selected_category is None ,
}
]
for name in category_names :
category_links . append ( {
" name " : name ,
" count " : category_count_map . get ( name , 0 ) ,
2026-02-10 13:48:58 +08:00
" url " : _build_forum_url (
tab = active_tab ,
category = name ,
q = search_query or None ,
page = 1 ,
per_page = per_page ,
) ,
2026-02-10 11:07:04 +08:00
" 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 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
)
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 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
) ,
" 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 :
2026-02-10 13:48:58 +08:00
empty_hint = _pick_lang ( " 当前分类下没有匹配关键词的帖子。 " , " No posts match your keywords in this category. " , lang )
2026-02-10 11:07:04 +08:00
elif search_query :
2026-02-10 13:48:58 +08:00
empty_hint = _pick_lang ( " 没有匹配关键词的帖子。 " , " No posts match your keywords. " , lang )
2026-02-10 11:07:04 +08:00
elif selected_category :
2026-02-10 13:48:58 +08:00
empty_hint = _pick_lang ( " 该分类暂时没有帖子。 " , " No posts in this category yet. " , lang )
2026-02-10 11:07:04 +08:00
else :
2026-02-10 13:48:58 +08:00
empty_hint = _pick_lang ( " 当前没有帖子,点击右上角按钮发布第一条内容。 " , " No posts yet. Create the first topic from the top-right button. " , lang )
2026-02-10 11:07:04 +08:00
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
2026-02-10 17:54:22 +08:00
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 ,
}
2026-02-10 11:07:04 +08:00
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 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
) ,
next_page_url = _build_forum_url (
tab = active_tab ,
category = selected_category ,
q = search_query or None ,
page = page + 1 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
) ,
clear_search_url = _build_forum_url (
tab = active_tab ,
category = selected_category ,
q = None ,
page = 1 ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
2026-02-10 11:07:04 +08:00
) ,
2026-02-10 13:48:58 +08:00
clear_all_url = _build_forum_url ( tab = " latest " , category = None , q = None , page = 1 , per_page = per_page ) ,
2026-02-10 11:07:04 +08:00
has_filters = has_filters ,
empty_hint = empty_hint ,
result_start = result_start ,
result_end = result_end ,
2026-02-10 13:48:58 +08:00
per_page = per_page ,
per_page_options = per_page_options ,
2026-02-10 11:07:04 +08:00
message = request . args . get ( " msg " ) or " " ,
error = request . args . get ( " error " ) or " " ,
2026-02-10 17:54:22 +08:00
forum_heading = forum_heading ,
forum_intro = forum_intro ,
forum_feed_url = forum_feed_url ,
seo = seo ,
seo_schema = seo_schema ,
2026-02-10 11:07:04 +08:00
)
2026-02-09 22:36:32 +08:00
@app.route ( " /forum/post/new " , methods = [ " GET " , " POST " ] )
@user_login_required
def forum_post_new ( ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-09 22:36:32 +08:00
user = _get_current_user ( )
2026-02-10 11:07:04 +08:00
blocked_resp = _ensure_forum_interaction_user ( user )
if blocked_resp :
return blocked_resp
2026-02-09 22:36:32 +08:00
error = None
title = " "
content = " "
2026-02-10 11:07:04 +08:00
available_categories = _get_forum_category_names ( active_only = True )
category = available_categories [ 0 ] if available_categories else " 综合讨论 "
2026-02-09 22:36:32 +08:00
if request . method == " POST " :
title = ( request . form . get ( " title " ) or " " ) . strip ( )
content = ( request . form . get ( " content " ) or " " ) . strip ( )
2026-02-10 11:07:04 +08:00
category = ( request . form . get ( " category " ) or " " ) . strip ( ) or category
if category not in available_categories :
category = available_categories [ 0 ] if available_categories else " 综合讨论 "
2026-02-09 22:36:32 +08:00
if len ( title ) < 5 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 标题至少 5 个字符 " , " Title must be at least 5 characters. " , lang )
2026-02-09 22:36:32 +08:00
elif len ( title ) > 160 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 标题不能超过 160 个字符 " , " Title must be 160 characters or fewer. " , lang )
2026-02-09 22:36:32 +08:00
elif len ( content ) < 10 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 内容至少 10 个字符 " , " Content must be at least 10 characters. " , lang )
2026-02-09 22:36:32 +08:00
else :
post = ForumPost (
user_id = user . id ,
2026-02-10 11:07:04 +08:00
category = category ,
2026-02-09 22:36:32 +08:00
title = title ,
content = content ,
)
db . session . add ( post )
db . session . commit ( )
return redirect ( url_for ( " forum_post_detail " , post_id = post . id ) )
2026-02-10 11:07:04 +08:00
return render_template (
" forum/post_form.html " ,
error = error ,
title_val = title ,
content_val = content ,
category_val = category ,
categories = available_categories ,
2026-02-10 13:48:58 +08:00
page_title = _pick_lang ( " 创建新主题 " , " Create Topic " , lang ) ,
submit_text = _pick_lang ( " 发布主题 " , " Publish " , lang ) ,
2026-02-10 11:07:04 +08:00
action_url = url_for ( " forum_post_new " ) ,
cancel_url = url_for ( " forum_index " ) ,
form_mode = " create " ,
)
@app.route ( " /forum/post/<int:post_id>/edit " , methods = [ " GET " , " POST " ] )
@user_login_required
def forum_post_edit ( post_id ) :
2026-02-10 13:48:58 +08:00
lang = _get_lang ( )
2026-02-10 11:07:04 +08:00
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 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 标题至少 5 个字符 " , " Title must be at least 5 characters. " , lang )
2026-02-10 11:07:04 +08:00
elif len ( title ) > 160 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 标题不能超过 160 个字符 " , " Title must be 160 characters or fewer. " , lang )
2026-02-10 11:07:04 +08:00
elif len ( content ) < 10 :
2026-02-10 13:48:58 +08:00
error = _pick_lang ( " 内容至少 10 个字符 " , " Content must be at least 10 characters. " , lang )
2026-02-10 11:07:04 +08:00
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 ,
2026-02-10 13:48:58 +08:00
page_title = _pick_lang ( " 编辑主题 " , " Edit Topic " , lang ) ,
submit_text = _pick_lang ( " 保存修改 " , " Save Changes " , lang ) ,
2026-02-10 11:07:04 +08:00
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 " ) )
2026-02-09 22:36:32 +08:00
@app.route ( " /forum/post/<int:post_id> " )
def forum_post_detail ( post_id ) :
2026-02-10 17:54:22 +08:00
lang = _get_lang ( )
2026-02-09 22:36:32 +08:00
post = ForumPost . query . get_or_404 ( post_id )
2026-02-10 18:33:57 +08:00
comment_per_page = 20
comment_page = request . args . get ( " cp " , type = int ) or 1
if comment_page < 1 :
comment_page = 1
2026-02-10 11:07:04 +08:00
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 ( )
2026-02-10 18:33:57 +08:00
comments_query = (
2026-02-09 22:36:32 +08:00
ForumComment . query
2026-02-10 13:48:58 +08:00
. options ( joinedload ( ForumComment . author_rel ) )
2026-02-09 22:36:32 +08:00
. filter_by ( post_id = post . id )
. order_by ( ForumComment . created_at . asc ( ) , ForumComment . id . asc ( ) )
2026-02-10 18:33:57 +08:00
)
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 )
2026-02-09 22:36:32 +08:00
. all ( )
)
2026-02-10 18:33:57 +08:00
schema_comments = comments
if comment_page > 1 :
schema_comments = comments_query . limit ( 20 ) . all ( )
2026-02-10 11:07:04 +08:00
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 :
2026-02-10 13:57:46 +08:00
# 一次查询同时得到当前用户是否点赞/收藏,减少请求次数
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
2026-02-10 11:07:04 +08:00
sidebar = _forum_sidebar_data ( )
2026-02-10 18:33:57 +08:00
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 ,
)
2026-02-10 17:54:22 +08:00
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 )
2026-02-10 18:33:57 +08:00
read_minutes = _estimate_reading_minutes ( post . content or " " , lang = lang )
detail_resource_links = _build_post_resource_links ( post = post , lang = lang )
detail_faq_items = _build_post_faq_items (
post = post ,
comments_count = comments_count ,
read_minutes = read_minutes ,
plan_reco_context = plan_reco_context ,
lang = lang ,
)
comment_page_links = _build_post_comment_page_links (
post_id = post . id ,
total_pages = comments_total_pages ,
current_page = comment_page ,
lang = lang ,
)
comment_prev_url = None
comment_next_url = None
comment_prev_canonical_url = None
comment_next_canonical_url = None
if comment_page > 1 :
comment_prev_url = _build_post_detail_url ( post . id , lang = lang , comment_page = comment_page - 1 )
prev_cp = ( comment_page - 1 ) if ( comment_page - 1 ) > 1 else None
comment_prev_canonical_url = _public_url ( " forum_post_detail " , lang = lang , post_id = post . id , cp = prev_cp )
if comment_page < comments_total_pages :
comment_next_url = _build_post_detail_url ( post . id , lang = lang , comment_page = comment_page + 1 )
comment_next_canonical_url = _public_url ( " forum_post_detail " , lang = lang , post_id = post . id , cp = comment_page + 1 )
query_keys = { str ( k or " " ) . strip ( ) . lower ( ) for k in request . args . keys ( ) }
query_keys . discard ( " " )
indexable_query_keys = { " lang " }
has_non_canonical_query = any (
( key not in indexable_query_keys ) or key . startswith ( " utm_ " )
for key in query_keys
)
2026-02-10 17:54:22 +08:00
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 ,
2026-02-10 18:33:57 +08:00
" 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 ,
2026-02-10 17:54:22 +08:00
" 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 = [ ]
2026-02-10 18:33:57 +08:00
for c in schema_comments [ : 20 ] :
2026-02-10 17:54:22 +08:00
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 " ] }
2026-02-10 18:33:57 +08:00
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 )
2026-02-10 17:54:22 +08:00
seo_schema = {
" @context " : " https://schema.org " ,
2026-02-10 18:33:57 +08:00
" @graph " : seo_graph ,
2026-02-10 17:54:22 +08:00
}
2026-02-09 22:36:32 +08:00
return render_template (
" forum/post_detail.html " ,
post = post ,
comments = comments ,
2026-02-10 11:07:04 +08:00
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 ,
2026-02-10 18:33:57 +08:00
related_cards = related_cards ,
plan_recommendations = plan_recommendations ,
plan_reco_context = plan_reco_context ,
detail_resource_links = detail_resource_links ,
detail_faq_items = detail_faq_items ,
comments_count = comments_count ,
read_minutes = read_minutes ,
comment_page = comment_page ,
comments_total_pages = comments_total_pages ,
comment_page_links = comment_page_links ,
comment_prev_url = comment_prev_url ,
comment_next_url = comment_next_url ,
2026-02-09 22:36:32 +08:00
message = request . args . get ( " msg " ) or " " ,
error = request . args . get ( " error " ) or " " ,
2026-02-10 17:54:22 +08:00
seo = seo ,
seo_schema = seo_schema ,
2026-02-09 22:36:32 +08:00
)
2026-02-10 11:07:04 +08:00
@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 = " 已收藏该帖子 " ) ) )
2026-02-09 22:36:32 +08:00
@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 ( )
2026-02-10 11:07:04 +08:00
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 , " 该帖子已锁定,暂不允许新增评论 " )
2026-02-09 22:36:32 +08:00
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 )
2026-02-10 11:07:04 +08:00
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 ,
)
2026-02-09 22:36:32 +08:00
db . session . commit ( )
return redirect ( url_for ( " forum_post_detail " , post_id = post . id , msg = " 评论发布成功 " ) )
2026-02-10 11:07:04 +08:00
@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 , " 举报已提交,感谢反馈 " )
2026-02-10 18:33:57 +08:00
@app.route ( " /forum/feed " )
@app.route ( " /forum/feed.xml/ " )
2026-02-10 17:54:22 +08:00
@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 " ,
)
2026-02-10 11:07:04 +08:00
# ---------- SEO ----------
@app.route ( " /sitemap.xml " )
def sitemap ( ) :
2026-02-10 17:54:22 +08:00
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 )
2026-02-09 14:18:42 +08:00
resp = make_response ( xml )
resp . mimetype = " application/xml "
2026-02-10 17:54:22 +08:00
resp . headers [ " Cache-Control " ] = " public, max-age=1800 "
2026-02-09 14:18:42 +08:00
return resp
@app.route ( " /robots.txt " )
def robots ( ) :
2026-02-10 17:54:22 +08:00
txt = """ User-agent: *
2026-02-09 14:18:42 +08:00
Allow : /
2026-02-10 17:54:22 +08:00
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())
2026-02-09 14:18:42 +08:00
resp = make_response ( txt )
resp . mimetype = " text/plain "
2026-02-10 17:54:22 +08:00
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 "
2026-02-09 14:18:42 +08:00
return resp
2026-02-10 13:48:58 +08:00
@app.route ( " /favicon.ico " )
def favicon ( ) :
return redirect ( url_for ( " static " , filename = " img/site-logo-mark.svg " ) )
2026-02-09 14:18:42 +08:00
# ---------- 后台 ----------
@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 " " ,
} )
2026-02-09 22:36:32 +08:00
@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
] )
2026-02-09 14:18:42 +08:00
@app.route ( " /admin " )
@admin_required
def admin_dashboard ( ) :
providers = Provider . query . order_by ( Provider . name ) . all ( )
2026-02-10 13:57:46 +08:00
plans = _query_plans_for_display ( )
2026-02-09 22:36:32 +08:00
plan_trends = _build_plan_trend_map ( plans )
2026-02-09 14:18:42 +08:00
return render_template (
" admin/dashboard.html " ,
providers = providers ,
plans = plans ,
2026-02-09 22:36:32 +08:00
plan_trends = plan_trends ,
2026-02-09 14:18:42 +08:00
country_tags = COUNTRY_TAGS ,
)
# ---------- 厂商管理 ----------
@app.route ( " /admin/providers " )
@admin_required
def admin_providers ( ) :
providers = Provider . query . order_by ( Provider . name ) . all ( )
return render_template ( " admin/providers.html " , providers = providers )
@app.route ( " /admin/provider/new " , methods = [ " GET " , " POST " ] )
@admin_required
def admin_provider_new ( ) :
if request . method == " POST " :
name = request . form . get ( " name " , " " ) . strip ( )
official_url = request . form . get ( " official_url " , " " ) . strip ( ) or None
if not name :
return render_template ( " admin/provider_form.html " , provider = None , error = " 请填写厂商名称 " )
if Provider . query . filter_by ( name = name ) . first ( ) :
return render_template ( " admin/provider_form.html " , provider = None , error = " 该厂商名称已存在 " )
p = Provider ( name = name , official_url = official_url )
db . session . add ( p )
db . session . commit ( )
return redirect ( url_for ( " admin_provider_detail " , provider_id = p . id ) )
return render_template ( " admin/provider_form.html " , provider = None )
@app.route ( " /admin/provider/<int:provider_id> " )
@admin_required
def admin_provider_detail ( provider_id ) :
provider = Provider . query . get_or_404 ( provider_id )
2026-02-10 13:57:46 +08:00
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 ( )
)
2026-02-09 14:18:42 +08:00
providers = Provider . query . order_by ( Provider . name ) . all ( )
2026-02-09 22:36:32 +08:00
plan_trends = _build_plan_trend_map ( plans )
2026-02-09 14:18:42 +08:00
return render_template (
" admin/provider_detail.html " ,
provider = provider ,
plans = plans ,
2026-02-09 22:36:32 +08:00
plan_trends = plan_trends ,
2026-02-09 14:18:42 +08:00
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 ( )
2026-02-10 13:57:46 +08:00
_invalidate_plans_cache ( )
2026-02-09 14:18:42 +08:00
return redirect ( url_for ( " admin_providers " ) )
2026-02-10 11:07:04 +08:00
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 ) ) )
2026-02-09 14:18:42 +08:00
@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 :
2026-02-10 11:07:04 +08:00
provider = db . session . get ( Provider , provider_id )
2026-02-09 14:18:42 +08:00
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
2026-02-09 22:36:32 +08:00
db . session . flush ( )
_record_price_history ( plan , source = " manual " )
2026-02-09 14:18:42 +08:00
db . session . commit ( )
2026-02-10 13:57:46 +08:00
_invalidate_plans_cache ( )
2026-02-09 14:18:42 +08:00
# 若从厂商详情页进入添加,保存后返回该厂商详情
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 )
2026-02-09 22:36:32 +08:00
PriceHistory . query . filter_by ( plan_id = plan_id ) . delete ( )
2026-02-09 14:18:42 +08:00
db . session . delete ( plan )
db . session . commit ( )
2026-02-10 13:57:46 +08:00
_invalidate_plans_cache ( )
2026-02-09 14:18:42 +08:00
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 )
2026-02-10 13:57:46 +08:00
plans = _query_plans_for_display ( )
2026-02-09 14:18:42 +08:00
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
2026-02-09 22:36:32 +08:00
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 ( " 国家 " ) )
2026-02-09 14:18:42 +08:00
@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 ( )
2026-02-09 22:36:32 +08:00
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 = [ ]
2026-02-09 14:18:42 +08:00
for row in parsed :
2026-02-09 22:36:32 +08:00
key = _row_identity_key ( row )
provider_name = key [ 0 ]
2026-02-09 14:18:42 +08:00
if not provider_name :
continue
2026-02-09 22:36:32 +08:00
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
2026-02-09 14:18:42 +08:00
return redirect ( url_for ( " admin_import_preview " ) )
@app.route ( " /admin/import/preview " , methods = [ " GET " , " POST " ] )
@admin_required
def admin_import_preview ( ) :
2026-02-09 22:36:32 +08:00
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 " )
2026-02-09 14:18:42 +08:00
if request . method == " GET " :
2026-02-09 22:36:32 +08:00
return render_template (
" admin/import_preview.html " ,
rows = list ( enumerate ( preview_items ) ) ,
add_count = add_count ,
update_count = update_count ,
)
2026-02-09 14:18:42 +08:00
selected = request . form . getlist ( " row_index " )
if not selected :
2026-02-09 22:36:32 +08:00
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
2026-02-09 14:18:42 +08:00
for i in indices :
2026-02-09 22:36:32 +08:00
if i < 0 or i > = len ( preview_items ) :
2026-02-09 14:18:42 +08:00
continue
2026-02-09 22:36:32 +08:00
item = preview_items [ i ]
row = item . get ( " row " ) or { }
provider = _upsert_provider_from_row ( row )
2026-02-09 14:18:42 +08:00
if not provider :
2026-02-09 22:36:32 +08:00
continue
action = item . get ( " action " )
if action == " update " :
2026-02-10 11:07:04 +08:00
plan = db . session . get ( VPSPlan , item . get ( " plan_id " ) )
2026-02-09 22:36:32 +08:00
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 " )
2026-02-09 14:18:42 +08:00
db . session . commit ( )
2026-02-10 13:57:46 +08:00
_invalidate_plans_cache ( )
2026-02-09 14:18:42 +08:00
session . pop ( " import_preview " , None )
2026-02-09 22:36:32 +08:00
msg = " 已新增 {} 条,更新 {} 条配置 " . format ( add_applied , update_applied )
return redirect ( url_for ( " admin_dashboard " ) + " ? " + urlencode ( { " msg " : msg } ) )
2026-02-09 14:18:42 +08:00
if __name__ == " __main__ " :
app . run ( debug = True , port = 5001 )