""" 访问统计插件:后台统计主页 + 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//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//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)