Spaces:
Running
Running
File size: 14,313 Bytes
00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 9dbe21e 00d2c12 5edb87f 9dbe21e 00d2c12 ea44972 00d2c12 ea44972 00d2c12 ea44972 00d2c12 9dbe21e 00d2c12 5edb87f 00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 4ffa05a 00d2c12 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | """API theo dõi người dùng.
Hai nhóm endpoint:
- POST /api/events — app gửi log lên (không cần token).
- GET /api/analytics/admin/* — quản trị đọc báo cáo (cần
VIDEO_ADMIN_TOKEN, dùng chung mật khẩu với tab "Quản lý người dùng").
Vì app đăng nhập bằng tên (không có token phiên), endpoint nhận log KHÔNG xác
thực được người gửi — đây là tool nội bộ nên chấp nhận, nhưng có chặn kích
thước lô để không ai bơm được cả biển dữ liệu trong một lần gọi.
"""
from __future__ import annotations
import csv
import io
import json as _json
import time
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from ..config import settings
from ..services.analytics import etl
from ..services.analytics import labels
from ..services.analytics import storage as store
from ..services.auth.storage import list_codes, list_users
router = APIRouter(tags=["analytics"])
# Trần số event một lần POST (frontend gửi lô ~20).
MAX_BATCH = 200
# Dọn log quá hạn nhiều nhất 1 lần/giờ, đi ké request có sẵn (khỏi cần cron).
_last_purge = 0.0
_PURGE_EVERY = 3600.0
def _maybe_purge():
global _last_purge
now = time.time()
if now - _last_purge < _PURGE_EVERY:
return
_last_purge = now
try:
store.purge_old()
except Exception as ex: # dọn dẹp lỗi thì kệ, không được làm hỏng request
print(f"[analytics] purge error: {ex}")
def _check(token: str):
if not settings.VIDEO_ADMIN_TOKEN or token != settings.VIDEO_ADMIN_TOKEN:
raise HTTPException(403, "Not authorized")
def _tz():
return timezone(timedelta(hours=settings.ANALYTICS_TZ_OFFSET))
def _range(days: int, date_from: str, date_to: str) -> tuple[float, float]:
"""Đổi bộ lọc ngày (theo múi giờ báo cáo) thành khoảng epoch [from, to)."""
tz = _tz()
if date_from or date_to:
try:
start = (datetime.strptime(date_from, "%Y-%m-%d").replace(tzinfo=tz).timestamp()
if date_from else 0.0)
end = ((datetime.strptime(date_to, "%Y-%m-%d").replace(tzinfo=tz) + timedelta(days=1)).timestamp()
if date_to else time.time() + 60)
return start, end
except ValueError:
raise HTTPException(400, "Ngày phải theo dạng YYYY-MM-DD")
days = max(1, min(int(days or 30), 365))
now = datetime.now(tz)
start = (now - timedelta(days=days - 1)).replace(hour=0, minute=0, second=0, microsecond=0)
return start.timestamp(), time.time() + 60
def _name_map() -> dict:
"""Mã U00N -> tên người dùng, để dashboard hiện TÊN thay vì chỉ mã.
Log lưu user_id là mã (đã lowercase khi chuẩn hoá) nên key map cũng lowercase.
"""
out = {}
try:
for r in list_codes():
out[(r["code"] or "").lower()] = r["username"]
except Exception:
pass
return out
def _granted_accounts() -> list:
"""Danh sách tài khoản đã được duyệt (bảng phân quyền), lỗi -> rỗng."""
try:
return list_users()
except Exception:
return []
# ── Nhận log ─────────────────────────────────────────────────────
@router.post("/api/events")
async def ingest(request: Request):
"""Nhận một lô event. Chấp cả `{"events":[...]}` lẫn mảng trần.
`sendBeacon` lúc đóng tab gửi Content-Type lạ nên đọc body thủ công.
"""
try:
body = await request.json()
except Exception:
return {"ok": False, "error": "Body không phải JSON", "saved": 0}
items = body.get("events") if isinstance(body, dict) else body
if not isinstance(items, list):
return {"ok": False, "error": "Thiếu mảng events", "saved": 0}
if len(items) > MAX_BATCH:
items = items[:MAX_BATCH]
now = time.time()
rows = [store.normalize(it, now) for it in items if isinstance(it, dict)]
# CHỈNH LỆCH ĐỒNG HỒ: dời cả lô theo đúng MỘT hằng số. Hiệu số giữa các dòng
# (thời gian mỗi bước) giữ nguyên, nhưng mốc tuyệt đối khớp với dòng log do
# backend ghi -> ghép chung một dòng thời gian được.
#
# LẤY GÌ LÀM MỐC ĐO LỆCH:
# - Có `sent_at` (app desktop gửi kèm) -> giờ máy khách LÚC BẤM GỬI. App
# có hàng đợi lưu đĩa: mất mạng thì gom lại, mở lần sau gửi bù. Nếu vẫn
# lấy event mới nhất làm mốc thì cả lô cũ bị kéo về hiện tại và rơi sai
# NGÀY (báo cáo gộp ngày theo server_ts). Dựa vào `sent_at` thì log cũ
# giữ đúng tuổi của nó, mà đồng hồ máy sai vẫn được nắn.
# - Không có (bản web, gửi mỗi 5 giây) -> giữ nguyên cách cũ: lấy event
# mới nhất, sai số chỉ còn độ trễ mạng.
sent_ts = store.parse_iso(str(body.get("sent_at") or "")) if isinstance(body, dict) else None
if rows:
base = sent_ts if sent_ts is not None else max(r["event_ts"] for r in rows)
skew = now - base
if abs(skew) > 1.0:
for r in rows:
r["event_ts"] += skew
# GỬI BÙ: kéo cả `server_ts` về đúng lúc việc xảy ra.
#
# Nắn `event_ts` thôi là CHƯA ĐỦ. Báo cáo gộp "theo ngày" và lọc khoảng
# ngày đều đọc `server_ts` (xem etl.local_date) — để nguyên là lúc nhận
# thì việc làm tối qua bị đếm sang hôm nay, biểu đồ hoạt động hàng ngày
# sai ngày dù thời lượng vẫn đúng.
#
# Chỉ làm với lô có `sent_at`, tức app desktop có hàng đợi lưu đĩa. Bản
# web gửi mỗi 5 giây, không có `sent_at`, giữ nguyên hành vi cũ.
#
# An toàn: `event_ts` lúc này đã được neo theo giờ máy chủ nên tin được;
# vẫn chặn hai đầu — không cho lùi quá hạn lưu trữ (dọn log sẽ xoá ngay
# cái vừa ghi) và không cho vượt quá hiện tại.
if sent_ts is not None:
keep = settings.ANALYTICS_RETENTION_DAYS
floor = (now - keep * 86400 + 3600) if keep and keep > 0 else 0.0
for r in rows:
if r["event_ts"] < now - 60: # thật sự là log cũ
r["server_ts"] = max(floor, min(r["event_ts"], now))
saved = store.insert(rows)
_maybe_purge()
return {"ok": True, "saved": saved, "received": len(rows)}
# ── Báo cáo cho quản trị ─────────────────────────────────────────
@router.get("/api/analytics/admin/summary")
def admin_summary(token: str = "", days: int = 30, date_from: str = "",
date_to: str = "", feature: str = "", user_id: str = ""):
"""Toàn bộ số liệu cho tab "Quản lý thời lượng"."""
_check(token)
start, end = _range(days, date_from, date_to)
events = store.query(start, end, user_id=user_id, feature=feature)
data = etl.summarize(events)
data["range"] = {"from_ts": start, "to_ts": end, "days": days,
"tz_offset": settings.ANALYTICS_TZ_OFFSET}
data["storage"] = store.stats()
data["user_names"] = _name_map()
# Số tài khoản ĐƯỢC CẤP (khác với số người thật sự vào dùng) — đếm bảng
# tài khoản đã duyệt, đúng bằng số dòng tab "Quản lý người dùng".
#
# KHÔNG đếm bảng mã U00N: mã chỉ sinh khi người đó đăng nhập lần đầu (hoặc
# khi quản trị tự tay thêm), còn duyệt từ hàng chờ thì không cấp mã — nên
# đếm mã ra số người đã từng vào, ít hơn hẳn số tài khoản được cấp.
data["granted_accounts"] = len(_granted_accounts())
return {"ok": True, **data}
@router.get("/api/analytics/admin/user")
def admin_user_detail(token: str = "", user_id: str = "", days: int = 30,
date_from: str = "", date_to: str = ""):
"""Chi tiết một người: từng phiên -> từng task -> từng bước."""
_check(token)
if not user_id.strip():
raise HTTPException(400, "Thiếu user_id")
start, end = _range(days, date_from, date_to)
events = store.query(start, end, user_id=user_id)
detail = etl.user_detail(events, user_id.strip().lower())
detail["name"] = _name_map().get(user_id.strip().lower(), "")
return {"ok": True, **detail}
@router.get("/api/analytics/admin/events")
def admin_raw_events(token: str = "", days: int = 7, date_from: str = "",
date_to: str = "", user_id: str = "", feature: str = "",
task_id: str = "", limit: int = 500):
"""Log thô — để soi đúng dòng khi số liệu trông lạ.
Kèm sẵn bản tiếng Việt của từng dòng (`vi_*`) để màn hình chỉ việc in ra:
bảng dịch nằm một chỗ ở backend, khỏi chép đôi sang frontend rồi lệch nhau.
"""
_check(token)
start, end = _range(days, date_from, date_to)
rows = store.query(start, end, user_id=user_id, feature=feature,
task_id=task_id, limit=max(1, min(int(limit or 500), 5000)),
newest_first=True)
names = _name_map()
for r in rows:
uid = r.get("user_id", "")
r["vi_user"] = names.get(uid, uid)
r["vi_feature"] = labels.feature_label(r.get("feature", ""))
r["vi_step"] = labels.step_label(r.get("step", ""))
r["vi_event"] = labels.event_label(r.get("event_name", ""))
r["vi_value"] = labels.value_label(r.get("event_value", ""))
r["vi_latency"] = labels.duration_label(r.get("latency_ms"))
r["vi_detail"] = labels.describe_props(r.get("properties") or {})
return {"ok": True, "events": rows, "count": len(rows)}
@router.get("/api/analytics/admin/export.csv")
def admin_export_csv(token: str = "", days: int = 30, date_from: str = "",
date_to: str = "", user_id: str = "", feature: str = "",
raw: int = 0):
"""Xuất log ra CSV.
Mặc định xuất bản ĐỌC ĐƯỢC: mỗi cột là một câu tiếng Việt (tên người, tên
tool, tên bước, tên hành động, thời gian máy chạy), cục `properties_json`
được bung thành "Bước máy: Bóc lời & dịch · Lần bấm chạy thứ: 1". Ba cột mã
(`Mã việc`/`Mã phiên`/`Mã dòng`) và cột JSON gốc vẫn nằm ở cuối để còn dò
ngược được khi số liệu trông lạ.
`?raw=1` -> bản 11 cột đúng `raw_events_schema_v2.xlsx` (cho bên nào cần
nạp thẳng vào công cụ khác, đừng đổi thứ tự cột của nó).
"""
_check(token)
start, end = _range(days, date_from, date_to)
rows = store.query(start, end, user_id=user_id, feature=feature)
names = _name_map()
buf = io.StringIO()
w = csv.writer(buf)
if raw:
w.writerow(["event_id", "event_time", "user_id", "session_id", "task_id",
"feature", "step", "event_name", "event_value", "latency_ms",
"properties_json"])
for r in rows:
w.writerow([
r.get("event_id", ""), r.get("event_time", ""), r.get("user_id", ""),
r.get("session_id", ""), r.get("task_id", ""), r.get("feature", ""),
r.get("step", ""), r.get("event_name", ""), r.get("event_value", ""),
"" if r.get("latency_ms") is None else r["latency_ms"],
_json.dumps(r.get("properties") or {}, ensure_ascii=False),
])
else:
w.writerow(["STT", "Ngày", "Giờ", "Người dùng", "Mã người dùng", "Tool",
"Bước", "Hành động", "Giá trị", "Máy chạy", "Chi tiết",
"Mã việc", "Mã phiên", "Mã dòng", "Thời gian gốc (ISO)",
"Chi tiết gốc (JSON)"])
tz = _tz()
for i, r in enumerate(rows, 1):
uid = r.get("user_id", "")
dt = datetime.fromtimestamp(r.get("event_ts") or 0.0, tz)
props = r.get("properties") or {}
w.writerow([
i,
dt.strftime("%d/%m/%Y"),
dt.strftime("%H:%M:%S"),
names.get(uid, uid),
uid.upper(),
labels.feature_label(r.get("feature", "")),
labels.step_label(r.get("step", "")),
labels.event_label(r.get("event_name", "")),
labels.value_label(r.get("event_value", "")),
labels.duration_label(r.get("latency_ms")),
labels.describe_props(props),
r.get("task_id", ""),
r.get("session_id", ""),
r.get("event_id", ""),
r.get("event_time", ""),
_json.dumps(props, ensure_ascii=False) if props else "",
])
buf.seek(0)
kind = "raw" if raw else "doc_duoc"
fname = f"nhat_ky_su_dung_{kind}_{datetime.now(_tz()).strftime('%Y%m%d')}.csv"
return StreamingResponse(
# BOM để Excel mở tiếng Việt không lỗi phông
iter(["" + buf.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
@router.get("/api/analytics/admin/storage")
def admin_storage(token: str = ""):
_check(token)
return {"ok": True, **store.stats()}
@router.post("/api/analytics/admin/purge")
def admin_purge(body: dict):
"""Dọn tay log cũ hơn `days` ngày."""
_check(body.get("token") or "")
days = body.get("days")
deleted = store.purge_old(int(days) if days else None)
return {"ok": True, "deleted": deleted}
|