feat(plugins): 4 个业务插件代码 + 前台公开页面改造

plugins/navManager:导航 CRUD + 导航分类 CRUD(独立 blueprint)
plugins/friendManager:友情链接 CRUD
plugins/aboutManager:关于本站 CRUD
plugins/siteStats:访问统计主页 + JSON 接口 + /site/* GET 埋点 + nav 卡片点击埋点

applications/view/public/nav.py:导航聚合页 / 分类详情 / JSON 接口,支持 visit_count 注入
applications/view/public/about.py:关于本站公开页
applications/view/public/friend.py:友情链接公开页

templates/public/base.html:响应式首页(搜索框 / 分类抽屉 / 5 主题切换 / footer 隐私小字)
templates/public/index.html / category.html:卡片右下角访问次数
templates/public/about.html / friend.html:关于 + 友链前台页

docs/plugins-development.md:插件开发完整指南(生命周期 + 4 个示例 + FAQ + framework 迁移)

scripts/:探针与端到端验证脚本(probe_*/verify_*/test_*)
This commit is contained in:
bwstudio
2026-09-06 18:18:06 +08:00
parent 1ea69c2c34
commit 0ffc103e46
53 changed files with 4705 additions and 89 deletions
+202
View File
@@ -0,0 +1,202 @@
"""
访问统计插件:后台统计主页 + 3 个 JSON 接口 + 全站 before_request 埋点。
URL 前缀:/system/stats (与框架原路径一致);埋点用 before_request 挂在框架 app 上。
"""
import datetime
import os
import time
from collections import defaultdict
from flask import Blueprint, render_template, request, jsonify
from applications.common.utils.rights import authorize
from applications.extensions import db
from applications.models import VisitLog, PageStat, NavClick
dir_path = os.path.dirname(os.path.abspath(__file__))
bp = Blueprint(
'site_stats', __name__,
url_prefix='/system/stats',
template_folder=os.path.join(dir_path, '..', 'templates'),
)
# 公开埋点 blueprint:无 url_prefix,路径就是 /site/nav/<id>/click
site_bp = Blueprint(
'site_stats_public', __name__,
template_folder=os.path.join(dir_path, '..', 'templates'),
)
@bp.get('/')
@authorize("site:stats:main")
def main():
return render_template('system/stat/main.html')
@bp.get('/summary')
@authorize("site:stats:main")
def summary():
today = datetime.date.today().strftime('%Y-%m-%d')
pv_total = db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0)).scalar() or 0
today_pv = (db.session.query(db.func.coalesce(db.func.sum(PageStat.pv), 0))
.filter(PageStat.day == today).scalar()) or 0
today_uv_set = set()
for r in PageStat.query.filter(PageStat.day == today, PageStat.uv_ip_set != '').all():
for ip in (r.uv_ip_set or '').split(','):
ip = ip.strip()
if ip:
today_uv_set.add(ip)
today_uv = len(today_uv_set)
all_uv_set = set()
for r in PageStat.query.filter(PageStat.uv_ip_set != '').all():
for ip in (r.uv_ip_set or '').split(','):
ip = ip.strip()
if ip:
all_uv_set.add(ip)
uv_all = len(all_uv_set)
latest = VisitLog.query.order_by(VisitLog.visit_at.desc()).limit(5).all()
latest_rows = [{
'path': r.path,
'ip': r.ip or '-',
'visit_at': r.visit_at.strftime('%Y-%m-%d %H:%M:%S') if r.visit_at else '',
} for r in latest]
return jsonify({
'code': 0,
'msg': '请求成功',
'data': {
'pv_total': int(pv_total),
'uv_total': uv_all,
'today_pv': int(today_pv),
'today_uv': today_uv,
'latest': latest_rows,
}
})
@bp.get('/days')
@authorize("site:stats:main")
def days():
try:
days = max(1, min(30, int(request.args.get('days', 7))))
except (TypeError, ValueError):
days = 7
cutoff = datetime.date.today() - datetime.timedelta(days=days - 1)
cutoff_str = cutoff.strftime('%Y-%m-%d')
rows = PageStat.query.filter(PageStat.day >= cutoff_str).all()
pv_by_day = defaultdict(int)
uv_by_day = defaultdict(set)
for r in rows:
pv_by_day[r.day] += r.pv
for ip in (r.uv_ip_set or '').split(','):
ip = ip.strip()
if ip:
uv_by_day[r.day].add(ip)
out = []
for i in range(days):
d = (cutoff + datetime.timedelta(days=i)).strftime('%Y-%m-%d')
out.append({
'day': d,
'pv': pv_by_day.get(d, 0),
'uv': len(uv_by_day.get(d, set())),
})
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
@bp.get('/paths')
@authorize("site:stats:main")
def paths():
rows = PageStat.query.filter(PageStat.path != '').order_by(PageStat.pv.desc()).limit(10).all()
out = []
for r in rows:
out.append({
'path': r.path,
'pv': r.pv or 0,
'last_hit': r.last_hit.strftime('%Y-%m-%d %H:%M:%S') if r.last_hit else '',
})
return jsonify({'code': 0, 'msg': '请求成功', 'data': out})
def record_visit():
"""before_request 钩子调用:埋点 /site/* GET。"""
if not request.path.startswith('/site'):
return
if request.method != 'GET':
return
path = request.path
day = datetime.date.today().strftime('%Y-%m-%d')
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or '')
ua_short = (request.user_agent.string or '')[:255]
referer = request.headers.get('Referer', '')[:255]
try:
log = VisitLog(path=path, referer=referer, ua=ua_short, ip=ip, day=day)
db.session.add(log)
stat = PageStat.query.filter_by(path=path, day=day).first()
if stat is None:
stat = PageStat(path=path, day=day, pv=1, uv=1 if ip else 0,
uv_ip_set=(ip + ',') if ip else '',
last_hit=datetime.datetime.now())
db.session.add(stat)
else:
stat.pv = (stat.pv or 0) + 1
stat.last_hit = datetime.datetime.now()
existing = (stat.uv_ip_set or '').split(',')
existing = [x.strip() for x in existing if x.strip()]
if ip and ip not in existing:
existing.append(ip)
stat.uv_ip_set = ','.join(existing) + ','
stat.uv = len(existing)
db.session.commit()
except Exception:
db.session.rollback()
# ---------- 导航卡片点击埋点 ----------
# 进程级内存缓存,避免每个聚合页请求都 GROUP BY 一次
# 结构:{nav_id: count}
_visit_count_cache = {}
_visit_count_cache_at = 0.0
_VISIT_COUNT_TTL = 60.0 # 秒
def get_nav_visit_counts():
"""返回 {nav_id: total_clicks}60 秒内走缓存。"""
global _visit_count_cache, _visit_count_cache_at
now = time.time()
if _visit_count_cache and (now - _visit_count_cache_at) < _VISIT_COUNT_TTL:
return _visit_count_cache
rows = db.session.query(
NavClick.nav_id,
db.func.count(NavClick.id),
).group_by(NavClick.nav_id).all()
counts = {int(nid): int(c) for nid, c in rows}
_visit_count_cache = counts
_visit_count_cache_at = now
return counts
@site_bp.post('/site/nav/<int:nav_id>/click')
def nav_click(nav_id):
"""导航卡片点击埋点:JS sendBeacon 调用,返回 204 无副作用。"""
try:
ip = (request.headers.get('X-Forwarded-For', '').split(',')[0].strip()
or request.remote_addr or '')
day = datetime.date.today().strftime('%Y-%m-%d')
db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64]))
db.session.commit()
# 让缓存尽快失效(最多延迟 60s 也无所谓,统计本来就不需要实时)
global _visit_count_cache_at
_visit_count_cache_at = 0.0
except Exception:
db.session.rollback()
# 204 No Content:无响应体,beacon 友好
return ('', 204)