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 的全部记录 - 移动端抽屉、顶部导航同步加「我常访问」入口 - 底部隐私说明更新
This commit is contained in:
@@ -6,13 +6,14 @@ URL 前缀:/system/stats (与框架原路径一致);埋点用 before_req
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
|
||||
from flask import Blueprint, render_template, request, jsonify
|
||||
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
|
||||
from applications.models import VisitLog, PageStat, NavClick, Nav
|
||||
|
||||
dir_path = os.path.dirname(os.path.abspath(__file__))
|
||||
bp = Blueprint(
|
||||
@@ -159,6 +160,59 @@ def record_visit():
|
||||
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 一次
|
||||
@@ -191,7 +245,8 @@ def nav_click(nav_id):
|
||||
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]))
|
||||
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
|
||||
@@ -200,3 +255,95 @@ def nav_click(nav_id):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user