Files
pear-admin-flask/plugins/siteStats/view/stat.py
T
bwstudio 0ffc103e46 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_*)
2026-09-06 18:18:06 +08:00

203 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
访问统计插件:后台统计主页 + 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)