Files
pear-admin-flask/plugins/siteStats/view/stat.py
T
bwstudio 34e8010830 feat(site): 方案 C - 「我常访问」个人榜(匿名 ID + localStorage 合并)
- NavClick 加 anon_id 字段(已为历史表自动迁移)
- 后端静默下发 wk_anon cookie(uuid4,1 年,HttpOnly)
- 点击埋点写入 anon_id;新增 GET /site/api/my-top 与 POST /site/api/my-clear
- 首页服务端渲染「我常访问」区(时间衰减加权分),与「热门网址」共显
- 前端 localStorage 累计 + 与服务端榜取 max 合并;冷启动阈值 2 个网址
- 一键清除:清 localStorage + 服务端删该匿名 ID 的全部记录
- 移动端抽屉、顶部导航同步加「我常访问」入口
- 底部隐私说明更新
2026-09-06 19:16:45 +08:00

350 lines
11 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
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/<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()
# ---------- 匿名访客 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/<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')
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)