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}