- 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 的全部记录 - 移动端抽屉、顶部导航同步加「我常访问」入口 - 底部隐私说明更新
350 lines
11 KiB
Python
350 lines
11 KiB
Python
"""
|
||
访问统计插件:后台统计主页 + 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)
|