""" 访问统计插件:后台统计主页 + 3 个 JSON 接口 + 全站 before_request 埋点。 URL 前缀:/system/stats (与框架原路径一致);埋点用 before_request 挂在框架 app 上。 """ import datetime import os import time import uuid from collections import defaultdict from flask import Blueprint, render_template, request, jsonify, g from applications.common.utils.rights import authorize from applications.extensions import db from applications.models import VisitLog, PageStat, NavClick, Nav 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() # ---------- 匿名访客 ID(方案 C 的服务端一半)---------- # # 设计原则:完全无感。访客不需要登录、不需要点任何按钮,后端在第一次响应里 # 静默下发一个 uuid4 cookie(1 年),之后所有点击都带上它。 # 前端另有 localStorage 做「秒级」本地累计,两者取 max 合并展示。 ANON_COOKIE = 'wk_anon' ANON_MAX_AGE = 365 * 24 * 3600 # 1 年 _HEX = set('0123456789abcdef') def _valid_anon_id(s: str) -> bool: s = (s or '').strip().lower() return len(s) == 32 and all(c in _HEX for c in s) def current_anon_id(create: bool = True) -> str: """取当前请求的匿名 ID。 - 浏览器已有合法 cookie → 直接复用 - 没有 → 现场生成 uuid4().hex,并在 after_request 里静默种下 - create=False:只读取,不生成(用于「清除记录」这类不该凭空造身份的接口) """ got = getattr(g, 'anon_id', '') if got: return got aid = (request.cookies.get(ANON_COOKIE) or '').strip() if not _valid_anon_id(aid): if not create: return '' aid = uuid.uuid4().hex g.anon_new = True g.anon_id = aid return aid @site_bp.after_app_request def _plant_anon_cookie(resp): """全站响应后处理:首次访问时把匿名 ID 写回浏览器。""" try: if getattr(g, 'anon_new', False) and getattr(g, 'anon_id', ''): resp.set_cookie( ANON_COOKIE, g.anon_id, max_age=ANON_MAX_AGE, path='/', httponly=True, samesite='Lax', ) except Exception: pass return resp # ---------- 导航卡片点击埋点 ---------- # 进程级内存缓存,避免每个聚合页请求都 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') anon = current_anon_id() # 没有就现场生成,响应里种 cookie db.session.add(NavClick(nav_id=nav_id, day=day, ip=ip[:64], anon_id=anon)) 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) # ---------- 「我常访问」:基于匿名 ID 的个人榜单 ---------- _MINE_ROWS = 400 # 每个匿名 ID 最多回溯多少条点击(防止老用户全表扫) def get_my_nav_scores(anon_id: str, limit: int = 8): """返回 [(nav_id, score, count), ...],按 score 降序。 score 带时间衰减:7 天内权重 1.0,30 天内 0.5,更早 0.2。 这样「常访问」会随着习惯变化自动换血,不会被半年前的一次点击钉死。 """ if not anon_id: return [] rows = ( NavClick.query .filter(NavClick.anon_id == anon_id) .order_by(NavClick.clicked_at.desc()) .limit(_MINE_ROWS) .all() ) now = datetime.datetime.now() agg = {} for r in rows: hit = r.clicked_at or now try: age = (now - hit).total_seconds() / 86400.0 except Exception: age = 0.0 if age <= 7: w = 1.0 elif age <= 30: w = 0.5 else: w = 0.2 score, cnt = agg.get(r.nav_id, (0.0, 0)) agg[r.nav_id] = (score + w, cnt + 1) out = [(nid, round(s, 2), c) for nid, (s, c) in agg.items()] out.sort(key=lambda t: (-t[1], t[0])) return out[:limit] def my_top_navs(limit: int = 8) -> "list[dict]": """当前访客的常用网址(有效且启用的),供首页服务端渲染。""" scores = get_my_nav_scores(current_anon_id(), limit) if not scores: return [] nav_ids = [nid for nid, _, _ in scores] rows = Nav.query.filter(Nav.id.in_(nav_ids), Nav.enable == 1).all() by_id = {n.id: n.to_dict() for n in rows} out = [] for nid, score, cnt in scores: d = by_id.get(nid) if not d: continue d = dict(d) d['mine_score'] = score d['mine_count'] = cnt out.append(d) return out @site_bp.get('/site/api/my-top') def api_my_top(): """前端异步拉取个人榜单(localStorage 合并用)。""" try: limit = max(1, min(12, int(request.args.get('limit', 8)))) except (TypeError, ValueError): limit = 8 aid = current_anon_id(create=False) return jsonify({ 'code': 0, 'msg': '请求成功', 'data': { 'anon': bool(aid), 'items': my_top_navs(limit), } }) @site_bp.post('/site/api/my-clear') def api_my_clear(): """清除「我」的点击记录(服务端部分;前端同时清 localStorage)。""" aid = current_anon_id(create=False) if aid: try: NavClick.query.filter(NavClick.anon_id == aid).delete() db.session.commit() except Exception: db.session.rollback() return ('', 204)