Files
vps_web/app.py

549 lines
18 KiB
Python
Raw Normal View History

2026-02-09 14:18:42 +08:00
# -*- coding: utf-8 -*-
"""云服务器价格对比 - Flask 应用"""
import io
from urllib.parse import urlencode
from flask import Flask, render_template, jsonify, request, redirect, url_for, session, send_file
2026-02-09 14:28:56 +08:00
from werkzeug.middleware.proxy_fix import ProxyFix
2026-02-09 14:18:42 +08:00
from sqlalchemy import text
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)
from models import VPSPlan, Provider # noqa: E402
def _ensure_mysql_columns():
"""为已有 MySQL 表添加缺失列,避免 1054 Unknown column。"""
try:
engine = db.engine
if engine.dialect.name != "mysql":
return
with engine.connect() as conn:
for col, spec in [
("traffic", "VARCHAR(64) NULL"),
("countries", "VARCHAR(255) NULL"),
("provider_id", "INT NULL"),
]:
try:
conn.execute(text("ALTER TABLE vps_plans ADD COLUMN {} {}".format(col, spec)))
conn.commit()
except Exception:
conn.rollback()
for col, spec in [
("name", "VARCHAR(128) NULL"),
("region", "VARCHAR(128) NULL"),
("price_cny", "DOUBLE NULL"),
("price_usd", "DOUBLE NULL"),
]:
try:
conn.execute(text("ALTER TABLE vps_plans MODIFY COLUMN {} {}".format(col, spec)))
conn.commit()
except Exception:
conn.rollback()
except Exception:
pass # 表不存在或非 MySQL 时忽略
# 启动时自动创建表(若不存在),并为已有表补列
with app.app_context():
db.create_all()
_ensure_mysql_columns()
ADMIN_PASSWORD = app.config["ADMIN_PASSWORD"]
SITE_URL = app.config["SITE_URL"]
SITE_NAME = app.config["SITE_NAME"]
# 国家/区域标签,供后台表单选择
COUNTRY_TAGS = [
"中国大陆", "中国香港", "中国台湾", "美国", "日本", "新加坡", "韩国",
"德国", "英国", "法国", "荷兰", "加拿大", "澳大利亚", "印度", "其他",
]
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
@app.route("/")
def index():
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
return render_template(
"index.html",
site_url=SITE_URL,
site_name=SITE_NAME,
plans_json_ld=[p.to_dict() for p in plans],
)
@app.route("/api/plans")
def api_plans():
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
return jsonify([p.to_dict() for p in plans])
# ---------- SEO ----------
@app.route("/sitemap.xml")
def sitemap():
from flask import make_response
url = SITE_URL.rstrip("/")
xml = f'''<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>{url}/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>'''
resp = make_response(xml)
resp.mimetype = "application/xml"
return resp
@app.route("/robots.txt")
def robots():
from flask import make_response
url = SITE_URL.rstrip("/")
txt = f"""User-agent: *
Allow: /
Sitemap: {url}/sitemap.xml
"""
resp = make_response(txt)
resp.mimetype = "text/plain"
return resp
# ---------- 后台 ----------
@app.route("/admin/login", methods=["GET", "POST"])
def admin_login():
if request.method == "POST":
password = request.form.get("password", "")
if password == ADMIN_PASSWORD:
session["admin_logged_in"] = True
return redirect(url_for("admin_dashboard"))
return render_template("admin/login.html", error="密码错误")
return render_template("admin/login.html")
@app.route("/admin/logout")
def admin_logout():
session.pop("admin_logged_in", None)
return redirect(url_for("index"))
@app.route("/admin/api/plan/<int:plan_id>")
@admin_required
def admin_api_plan(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
return jsonify({
"id": plan.id,
"provider_id": plan.provider_id,
"countries": plan.countries or "",
"vcpu": plan.vcpu,
"memory_gb": plan.memory_gb,
"storage_gb": plan.storage_gb,
"bandwidth_mbps": plan.bandwidth_mbps,
"traffic": plan.traffic or "",
"price_cny": float(plan.price_cny) if plan.price_cny is not None else None,
"price_usd": float(plan.price_usd) if plan.price_usd is not None else None,
"currency": plan.currency or "CNY",
"official_url": plan.official_url or "",
})
@app.route("/admin")
@admin_required
def admin_dashboard():
providers = Provider.query.order_by(Provider.name).all()
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
return render_template(
"admin/dashboard.html",
providers=providers,
plans=plans,
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)
plans = VPSPlan.query.filter(
(VPSPlan.provider_id == provider_id) | (VPSPlan.provider == provider.name)
).order_by(VPSPlan.price_cny.asc(), VPSPlan.name).all()
providers = Provider.query.order_by(Provider.name).all()
return render_template(
"admin/provider_detail.html",
provider=provider,
plans=plans,
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()
return redirect(url_for("admin_providers"))
@app.route("/admin/plan/new", methods=["GET", "POST"])
@admin_required
def admin_plan_new():
provider_id = request.args.get("provider_id", type=int)
if request.method == "POST":
return _save_plan(None)
providers = Provider.query.order_by(Provider.name).all()
return render_template(
"admin/plan_form.html",
plan=None,
country_tags=COUNTRY_TAGS,
providers=providers,
preselected_provider_id=provider_id,
)
@app.route("/admin/plan/<int:plan_id>/edit", methods=["GET", "POST"])
@admin_required
def admin_plan_edit(plan_id):
plan = VPSPlan.query.get_or_404(plan_id)
if request.method == "POST":
return _save_plan(plan)
return redirect(url_for("admin_dashboard"))
def _parse_optional_int(s):
s = (s or "").strip()
if not s:
return None
try:
return int(s)
except ValueError:
return None
def _parse_optional_float(s):
s = (s or "").strip()
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _save_plan(plan):
provider_id = request.form.get("provider_id", type=int)
countries = request.form.get("countries", "").strip() or None
vcpu = _parse_optional_int(request.form.get("vcpu"))
memory_gb = _parse_optional_int(request.form.get("memory_gb"))
storage_gb = _parse_optional_int(request.form.get("storage_gb"))
bandwidth_mbps = _parse_optional_int(request.form.get("bandwidth_mbps"))
traffic = request.form.get("traffic", "").strip() or None
price_cny = _parse_optional_float(request.form.get("price_cny"))
price_usd = _parse_optional_float(request.form.get("price_usd"))
currency = request.form.get("currency", "CNY").strip() or "CNY"
official_url = request.form.get("official_url", "").strip() or None
provider = None
if provider_id:
provider = Provider.query.get(provider_id)
if not provider:
providers = Provider.query.order_by(Provider.name).all()
return render_template(
"admin/plan_form.html",
plan=plan,
country_tags=COUNTRY_TAGS,
providers=providers,
preselected_provider_id=provider_id,
error="请选择厂商",
)
if plan is None:
plan = VPSPlan(
provider_id=provider.id,
provider=provider.name,
region=None,
name=None,
vcpu=vcpu,
memory_gb=memory_gb,
storage_gb=storage_gb,
bandwidth_mbps=bandwidth_mbps,
traffic=traffic,
price_cny=price_cny,
price_usd=price_usd,
currency=currency,
official_url=official_url,
countries=countries,
)
db.session.add(plan)
else:
plan.provider_id = provider.id
plan.provider = provider.name
plan.region = None
plan.name = None
plan.vcpu = vcpu
plan.memory_gb = memory_gb
plan.storage_gb = storage_gb
plan.bandwidth_mbps = bandwidth_mbps
plan.traffic = traffic
plan.price_cny = price_cny
plan.price_usd = price_usd
plan.currency = currency
plan.official_url = official_url
plan.countries = countries
db.session.commit()
# 若从厂商详情页进入添加,保存后返回该厂商详情
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)
db.session.delete(plan)
db.session.commit()
return redirect(url_for("admin_dashboard"))
# ---------- Excel 导出 / 导入 ----------
EXCEL_HEADERS = [
"厂商", "厂商官网", "国家", "vCPU", "内存GB", "存储GB", "带宽Mbps", "流量",
"月付人民币", "月付美元", "货币", "配置官网",
]
@app.route("/admin/export/excel")
@admin_required
def admin_export_excel():
wb = Workbook()
ws = wb.active
ws.title = "配置"
ws.append(EXCEL_HEADERS)
plans = VPSPlan.query.order_by(VPSPlan.provider, VPSPlan.price_cny).all()
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 _plan_match(plan, row):
"""判断 DB 中的 plan 与导入行是否同一配置(厂商+配置+价格)。"""
def eq(a, b):
if a is None and b in (None, ""):
return True
if a is None or b in (None, ""):
return a == b
return a == b
return (
plan.provider_name == (row.get("厂商") or "").strip()
and eq(plan.vcpu, _num(row.get("vCPU")))
and eq(plan.memory_gb, _num(row.get("内存GB")))
and eq(plan.storage_gb, _num(row.get("存储GB")))
and eq(plan.bandwidth_mbps, _num(row.get("带宽Mbps")))
and (plan.countries or "").strip() == (row.get("国家") or "").strip()
and eq(plan.price_cny, _float(row.get("月付人民币")))
and eq(plan.price_usd, _float(row.get("月付美元")))
)
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
@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()
to_add = []
for row in parsed:
provider_name = (row.get("厂商") or "").strip()
if not provider_name:
continue
found = False
for p in plans:
if _plan_match(p, row):
found = True
break
if not found:
to_add.append(row)
session["import_preview"] = to_add
return redirect(url_for("admin_import_preview"))
@app.route("/admin/import/preview", methods=["GET", "POST"])
@admin_required
def admin_import_preview():
to_add = session.get("import_preview") or []
if request.method == "GET":
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)))
selected = request.form.getlist("row_index")
if not selected:
to_add = session.get("import_preview") or []
return render_template("admin/import_preview.html", rows=list(enumerate(to_add)), error="请至少勾选一行")
to_add = session.get("import_preview") or []
indices = set(int(x) for x in selected if x.isdigit())
for i in indices:
if i < 0 or i >= len(to_add):
continue
row = to_add[i]
provider_name = (row.get("厂商") or "").strip()
if not provider_name:
continue
provider = Provider.query.filter_by(name=provider_name).first()
if not provider:
provider = Provider(name=provider_name, official_url=(row.get("厂商官网") or "").strip() or None)
db.session.add(provider)
db.session.flush()
plan = VPSPlan(
provider_id=provider.id,
provider=provider.name,
region=None,
name=None,
vcpu=_num(row.get("vCPU")),
memory_gb=_num(row.get("内存GB")),
storage_gb=_num(row.get("存储GB")),
bandwidth_mbps=_num(row.get("带宽Mbps")),
traffic=(row.get("流量") or "").strip() or None,
price_cny=_float(row.get("月付人民币")),
price_usd=_float(row.get("月付美元")),
currency=(row.get("货币") or "CNY").strip() or "CNY",
official_url=(row.get("配置官网") or "").strip() or None,
countries=(row.get("国家") or "").strip() or None,
)
db.session.add(plan)
db.session.commit()
session.pop("import_preview", None)
return redirect(url_for("admin_dashboard") + "?" + urlencode({"msg": "已导入 {} 条配置".format(len(indices))}))
if __name__ == "__main__":
app.run(debug=True, port=5001)