zlwu пре 8 месеци
родитељ
комит
f288653f33
3 измењених фајлова са 378 додато и 0 уклоњено
  1. 344 0
      dailyCount/run_daily_stats_pipeline.py
  2. 18 0
      dailyCount/setting.conf
  3. 16 0
      frontend/src/constants/chatbot.ts

+ 344 - 0
dailyCount/run_daily_stats_pipeline.py

@@ -0,0 +1,344 @@
+#!/usr/bin/env python3
+"""
+综合按日统计流水线脚本:
+1) 从 user_events、messages、users 汇总到 user_daily_activity
+2) 基于 user_daily_activity 计算 CURR,写入 curr_daily
+
+配置:dailyCount/setting.conf(MySQL、interval_minutes、active_events)
+用法:
+  python run_daily_stats_pipeline.py                # 默认守护模式,按 interval_minutes 周期执行
+  python run_daily_stats_pipeline.py --once         # 仅执行一次(只跑当天)
+  python run_daily_stats_pipeline.py --once --days 7  # 补跑最近 7 天(含今天),依次跑 user_daily_activity 与 curr_daily
+"""
+
+import argparse
+import configparser
+import os
+import sys
+from datetime import date, datetime, timedelta, timezone
+
+try:
+    import pymysql
+except ImportError:
+    print("请安装 pymysql: pip install pymysql", file=sys.stderr)
+    sys.exit(1)
+
+
+SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
+DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
+
+
+def load_config(conf_path: str) -> configparser.ConfigParser:
+    if not os.path.isfile(conf_path):
+        print(f"配置文件不存在: {conf_path}", file=sys.stderr)
+        sys.exit(1)
+    # 关闭默认的 % 插值功能,避免像 "page.%__enter" 这样的值触发插值错误
+    cfg = configparser.ConfigParser(interpolation=None)
+    cfg.read(conf_path, encoding="utf-8")
+    return cfg
+
+
+def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
+    return {
+        "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
+        "port": cfg.getint("mysql", "port", fallback=3306),
+        "user": cfg.get("mysql", "user", fallback="root"),
+        "password": cfg.get("mysql", "password", fallback=""),
+        "database": cfg.get("mysql", "database", fallback="Lyrria"),
+        "charset": "utf8mb4",
+    }
+
+
+def parse_active_events(active_events_str: str) -> tuple[str, list]:
+    """
+    解析 active_events 配置。
+    返回 (sql_condition_type, args):
+      - ("like", [pattern]) 表示 event_name LIKE %s
+      - ("in", [name1, name2, ...]) 表示 event_name IN (%s, %s, ...)
+    """
+    raw = (active_events_str or "").strip()
+    if not raw:
+        return "in", []
+    parts = [p.strip() for p in raw.split(",") if p.strip()]
+    if not parts:
+        return "in", []
+    if any("%" in p for p in parts):
+        return "like", [parts[0]]
+    return "in", parts
+
+
+def build_active_events_where(active_events_str: str) -> tuple[str, list]:
+    """返回 (WHERE 片段, 参数列表),用于 user_events 的活跃事件过滤。"""
+    cond_type, args = parse_active_events(active_events_str)
+    if cond_type == "like":
+        return " AND event_name LIKE %s", args
+    if cond_type == "in" and args:
+        placeholders = ", ".join(["%s"] * len(args))
+        return f" AND event_name IN ({placeholders})", args
+    return " AND 1=0", []
+
+
+def run_user_daily_activity_once(
+    conn, activity_date: date, active_events_where: str, active_events_args: list
+) -> int:
+    """对给定 UTC 日期执行一次 user_daily_activity 聚合,返回写入/更新的行数。"""
+    date_str = activity_date.isoformat()
+    cursor = conn.cursor()
+
+    cursor.execute("SET SESSION time_zone = '+00:00'")
+
+    cursor.execute(
+        """
+        SELECT user_id FROM user_events WHERE DATE(event_time) = %s
+        UNION
+        SELECT user_id FROM messages WHERE DATE(created_at) = %s AND role = 'user'
+        """,
+        (date_str, date_str),
+    )
+    user_ids = [row[0] for row in cursor.fetchall()]
+    if not user_ids:
+        cursor.close()
+        return 0
+
+    active_where = " DATE(event_time) = %s " + active_events_where
+    active_params = [date_str] + active_events_args
+    cursor.execute(
+        """
+        SELECT user_id,
+               LEAST(1, COUNT(*)) AS active_flag,
+               COUNT(DISTINCT session_id) AS session_count
+        FROM user_events
+        WHERE """
+        + active_where
+        + """
+        GROUP BY user_id
+        """,
+        active_params,
+    )
+    active_map = {
+        row[0]: {"active_flag": int(row[1]), "session_count": int(row[2])}
+        for row in cursor.fetchall()
+    }
+
+    cursor.execute(
+        """
+        SELECT user_id, COUNT(*) AS message_count
+        FROM messages
+        WHERE DATE(created_at) = %s AND role = 'user'
+        GROUP BY user_id
+        """,
+        (date_str,),
+    )
+    message_map = {row[0]: int(row[1]) for row in cursor.fetchall()}
+
+    placeholders = ", ".join(["%s"] * len(user_ids))
+    cursor.execute(
+        f"""
+        SELECT user_id,
+               DATE(created_at) AS first_seen_date,
+               user_segment,
+               org_id
+        FROM users
+        WHERE user_id IN ({placeholders})
+        """,
+        user_ids,
+    )
+    user_snapshot = {
+        row[0]: {
+            "first_seen_date": row[1],
+            "user_segment": row[2],
+            "org_id": row[3],
+        }
+        for row in cursor.fetchall()
+    }
+
+    rows = []
+    for uid in user_ids:
+        active = active_map.get(uid, {"active_flag": 0, "session_count": 0})
+        msg_count = message_map.get(uid, 0)
+        snap = user_snapshot.get(uid, {})
+        first_seen = snap.get("first_seen_date")
+        first_seen_str = first_seen.isoformat() if first_seen else None
+        seg = snap.get("user_segment") or None
+        org = snap.get("org_id") or None
+        rows.append(
+            (
+                date_str,
+                uid,
+                active["active_flag"],
+                active["session_count"],
+                msg_count,
+                first_seen_str,
+                seg,
+                org,
+            )
+        )
+
+    insert_sql = """
+    INSERT INTO user_daily_activity
+    (activity_date, user_id, active_flag, session_count, message_count,
+     first_seen_date, user_segment_snapshot, org_id_snapshot)
+    VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+    ON DUPLICATE KEY UPDATE
+        active_flag = VALUES(active_flag),
+        session_count = VALUES(session_count),
+        message_count = VALUES(message_count),
+        user_segment_snapshot = VALUES(user_segment_snapshot),
+        org_id_snapshot = VALUES(org_id_snapshot),
+        first_seen_date = IFNULL(first_seen_date, VALUES(first_seen_date)),
+        updated_at = CURRENT_TIMESTAMP
+    """
+    cursor.executemany(insert_sql, rows)
+    conn.commit()
+    count = cursor.rowcount if cursor.rowcount else len(rows)
+    cursor.close()
+    return count
+
+
+def run_curr_daily_once(conn, activity_date: date) -> bool:
+    """
+    对给定日期计算 CURR 并写入 curr_daily。
+    CURR(activity_date) = 昨日活跃且今日仍活跃人数 / 昨日活跃人数 * 100。
+    返回是否写入了该日。
+    """
+    cursor = conn.cursor()
+    date_str = activity_date.isoformat()
+    prev_date = activity_date - timedelta(days=1)
+    prev_str = prev_date.isoformat()
+
+    cursor.execute(
+        """
+        SELECT COUNT(DISTINCT user_id) FROM user_daily_activity
+        WHERE activity_date = %s AND active_flag = 1
+        """,
+        (prev_str,),
+    )
+    prev_count = cursor.fetchone()[0] or 0
+
+    if prev_count == 0:
+        cursor.execute(
+            """
+            SELECT COUNT(DISTINCT user_id) FROM user_daily_activity
+            WHERE activity_date = %s AND active_flag = 1
+            """,
+            (date_str,),
+        )
+        total_today = cursor.fetchone()[0] or 0
+        cursor.execute(
+            """
+            INSERT INTO curr_daily
+            (activity_date, prev_day_active_count, retained_count, curr_rate_pct, total_active_today)
+            VALUES (%s, 0, 0, NULL, %s)
+            ON DUPLICATE KEY UPDATE
+                prev_day_active_count = 0,
+                retained_count = 0,
+                curr_rate_pct = NULL,
+                total_active_today = VALUES(total_active_today),
+                updated_at = CURRENT_TIMESTAMP
+            """,
+            (date_str, total_today),
+        )
+        conn.commit()
+        cursor.close()
+        return True
+
+    cursor.execute(
+        """
+        SELECT COUNT(DISTINCT a.user_id)
+        FROM user_daily_activity a
+        INNER JOIN user_daily_activity b ON a.user_id = b.user_id AND b.activity_date = %s AND b.active_flag = 1
+        WHERE a.activity_date = %s AND a.active_flag = 1
+        """,
+        (date_str, prev_str),
+    )
+    retained = cursor.fetchone()[0] or 0
+
+    cursor.execute(
+        """
+        SELECT COUNT(DISTINCT user_id) FROM user_daily_activity
+        WHERE activity_date = %s AND active_flag = 1
+        """,
+        (date_str,),
+    )
+    total_today = cursor.fetchone()[0] or 0
+
+    curr_pct = round(100.0 * retained / prev_count, 2) if prev_count else None
+
+    cursor.execute(
+        """
+        INSERT INTO curr_daily
+        (activity_date, prev_day_active_count, retained_count, curr_rate_pct, total_active_today)
+        VALUES (%s, %s, %s, %s, %s)
+        ON DUPLICATE KEY UPDATE
+            prev_day_active_count = VALUES(prev_day_active_count),
+            retained_count = VALUES(retained_count),
+            curr_rate_pct = VALUES(curr_rate_pct),
+            total_active_today = VALUES(total_active_today),
+            updated_at = CURRENT_TIMESTAMP
+        """,
+        (date_str, prev_count, retained, curr_pct, total_today),
+    )
+    conn.commit()
+    cursor.close()
+    return True
+
+
+def main() -> None:
+    parser = argparse.ArgumentParser(
+        description="用户日活跃 + CURR 综合按日统计流水线 -> user_daily_activity & curr_daily"
+    )
+    parser.add_argument("--config", default=DEFAULT_CONF, help="setting.conf 路径")
+    parser.add_argument(
+        "--days",
+        type=int,
+        default=0,
+        help="补跑最近 N 天(含今天),0 表示仅今天",
+    )
+    parser.add_argument(
+        "--once",
+        action="store_true",
+        help="仅执行一次(默认按 interval_minutes 守护循环执行)",
+    )
+    args = parser.parse_args()
+
+    cfg = load_config(args.config)
+    mysql_kw = get_mysql_config(cfg)
+    active_events_str = cfg.get(
+        "user_daily_activity", "active_events", fallback="page.%__enter"
+    )
+    interval_minutes = cfg.getint("schedule", "interval_minutes", fallback=60)
+
+    active_where, active_args = build_active_events_where(active_events_str)
+
+    def do_run() -> None:
+        conn = pymysql.connect(**mysql_kw)
+        try:
+            today_utc = datetime.now(timezone.utc).date()
+            if args.days > 0:
+                dates = [today_utc - timedelta(days=i) for i in range(args.days)]
+            else:
+                dates = [today_utc]
+            for d in dates:
+                n = run_user_daily_activity_once(conn, d, active_where, active_args)
+                print(f"[pipeline] [user_daily_activity] {d.isoformat()} 写入/更新 {n} 条")
+                run_curr_daily_once(conn, d)
+                print(f"[pipeline] [curr_daily] {d.isoformat()} 已更新")
+        finally:
+            conn.close()
+
+    if args.once:
+        do_run()
+    else:
+        import time
+
+        print(
+            f"[pipeline] 守护模式,每 {interval_minutes} 分钟依次执行 user_daily_activity 与 curr_daily"
+        )
+        while True:
+            do_run()
+            time.sleep(interval_minutes * 60)
+
+
+if __name__ == "__main__":
+    main()
+

+ 18 - 0
dailyCount/setting.conf

@@ -0,0 +1,18 @@
+# dailyCount 脚本配置示例
+# 复制为 setting.conf 并按环境修改,勿将 setting.conf 提交到版本库(可加入 .gitignore)
+
+[mysql]
+host = 101.201.60.170
+port = 13306
+user = root
+password = Lyrria@Root@123456
+database = Lyrria
+
+[schedule]
+# 统计任务执行间隔(分钟),默认 60
+interval_minutes = 60
+
+[user_daily_activity]
+# 判定「活跃」的事件名:支持多个用逗号分隔,或前缀匹配如 page.%__enter(脚本内用 LIKE 解析)
+# 当前前端多为 page.xxx__enter,可配置为 page.%__enter;若后续增加 app_open 可改为 app_open
+active_events = page.%__enter

+ 16 - 0
frontend/src/constants/chatbot.ts

@@ -0,0 +1,16 @@
+/**
+ * /chatbot 凳子布局(设计稿 px)
+ * 凳子图与特殊场景 GIF 均基于此计算,修改此处即可同步两处位置
+ */
+export const STOOL_LAYOUT = {
+  left: 101,
+  top: 356,
+  width: 102,
+  height: 45,
+  /** 特殊场景凳子上 GIF 的宽高 */
+  sceneGifSize: 185,
+  /** 特殊场景 GIF 向下偏移量(正数=更靠近凳子,像坐在凳子上) */
+  sceneGifOffsetY: 24,
+  /** 按场景覆盖垂直偏移:window 负值让 GIF 离凳子再远一点 */
+  sceneGifOffsetYByScene: { window: -48 } as Record<string, number>,
+} as const;