run_user_daily_activity.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. #!/usr/bin/env python3
  2. """
  3. 用户日活跃聚合脚本:从 user_events、messages、users 汇总到 user_daily_activity。
  4. 配置:dailyCount/setting.conf(MySQL、interval_minutes、active_events)。
  5. 用法:
  6. python run_user_daily_activity.py # 仅跑当天(UTC)
  7. python run_user_daily_activity.py --days 7 # 补跑最近7天
  8. python run_user_daily_activity.py --daemon # 按 interval_minutes 循环执行
  9. """
  10. import argparse
  11. import configparser
  12. import os
  13. import sys
  14. from datetime import date, datetime, timedelta, timezone
  15. try:
  16. import pymysql
  17. except ImportError:
  18. print("请安装 pymysql: pip install pymysql", file=sys.stderr)
  19. sys.exit(1)
  20. # 脚本所在目录,用于定位 setting.conf
  21. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  22. DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
  23. def load_config(conf_path: str) -> configparser.ConfigParser:
  24. if not os.path.isfile(conf_path):
  25. print(f"配置文件不存在: {conf_path}", file=sys.stderr)
  26. sys.exit(1)
  27. # 关闭默认的 % 插值功能,避免像 "page.%__enter" 这样的值触发插值错误
  28. cfg = configparser.ConfigParser(interpolation=None)
  29. cfg.read(conf_path, encoding="utf-8")
  30. return cfg
  31. def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
  32. return {
  33. "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
  34. "port": cfg.getint("mysql", "port", fallback=3306),
  35. "user": cfg.get("mysql", "user", fallback="root"),
  36. "password": cfg.get("mysql", "password", fallback=""),
  37. "database": cfg.get("mysql", "database", fallback="Lyrria"),
  38. "charset": "utf8mb4",
  39. }
  40. def parse_active_events(active_events_str: str) -> tuple[str, list]:
  41. """
  42. 解析 active_events 配置。
  43. 返回 (sql_condition_type, args):
  44. - ("like", [pattern]) 表示 event_name LIKE %s
  45. - ("in", [name1, name2, ...]) 表示 event_name IN (%s, %s, ...)
  46. """
  47. raw = (active_events_str or "").strip()
  48. if not raw:
  49. return "in", [] # 无配置则无匹配
  50. parts = [p.strip() for p in raw.split(",") if p.strip()]
  51. if not parts:
  52. return "in", []
  53. # 若任一含 % 则整体按 LIKE 处理(通常只配一个 pattern,如 page.%__enter)
  54. if any("%" in p for p in parts):
  55. return "like", [parts[0]]
  56. return "in", parts
  57. def build_active_events_where(cursor, active_events_str: str) -> tuple[str, list]:
  58. """返回 (WHERE 片段, 参数列表),用于 user_events 的活跃事件过滤。"""
  59. cond_type, args = parse_active_events(active_events_str)
  60. if cond_type == "like":
  61. return " AND event_name LIKE %s", args
  62. if cond_type == "in" and args:
  63. placeholders = ", ".join(["%s"] * len(args))
  64. return f" AND event_name IN ({placeholders})", args
  65. return " AND 1=0", [] # 无匹配条件
  66. def run_once(conn, activity_date: date, active_events_where: str, active_events_args: list) -> int:
  67. """对给定 UTC 日期执行一次聚合,返回写入/更新的行数。"""
  68. date_str = activity_date.isoformat()
  69. cursor = conn.cursor()
  70. # 统一使用 UTC 日期:先设置会话时区
  71. cursor.execute("SET SESSION time_zone = '+00:00'")
  72. # 1) 当天有事件或消息的所有 user_id
  73. cursor.execute(
  74. """
  75. SELECT user_id FROM user_events WHERE DATE(event_time) = %s
  76. UNION
  77. SELECT user_id FROM messages WHERE DATE(created_at) = %s AND role = 'user'
  78. """,
  79. (date_str, date_str),
  80. )
  81. user_ids = [row[0] for row in cursor.fetchall()]
  82. if not user_ids:
  83. return 0
  84. # 2) 活跃事件统计:active_flag、session_count(仅统计活跃事件且 session_id 非空)
  85. active_where = " DATE(event_time) = %s " + active_events_where
  86. active_params = [date_str] + active_events_args
  87. cursor.execute(
  88. """
  89. SELECT user_id,
  90. LEAST(1, COUNT(*)) AS active_flag,
  91. COUNT(DISTINCT session_id) AS session_count
  92. FROM user_events
  93. WHERE """ + active_where + """
  94. GROUP BY user_id
  95. """,
  96. active_params,
  97. )
  98. active_map = {row[0]: {"active_flag": int(row[1]), "session_count": int(row[2])} for row in cursor.fetchall()}
  99. # 3) 当天用户消息数 (role='user')
  100. cursor.execute(
  101. """
  102. SELECT user_id, COUNT(*) AS message_count
  103. FROM messages
  104. WHERE DATE(created_at) = %s AND role = 'user'
  105. GROUP BY user_id
  106. """,
  107. (date_str,),
  108. )
  109. message_map = {row[0]: int(row[1]) for row in cursor.fetchall()}
  110. # 4) 用户快照:first_seen_date, user_segment, org_id
  111. placeholders = ", ".join(["%s"] * len(user_ids))
  112. cursor.execute(
  113. f"""
  114. SELECT user_id,
  115. DATE(created_at) AS first_seen_date,
  116. user_segment,
  117. org_id
  118. FROM users
  119. WHERE user_id IN ({placeholders})
  120. """,
  121. user_ids,
  122. )
  123. user_snapshot = {row[0]: {"first_seen_date": row[1], "user_segment": row[2], "org_id": row[3]} for row in cursor.fetchall()}
  124. # 5) 合并并写入
  125. rows = []
  126. for uid in user_ids:
  127. active = active_map.get(uid, {"active_flag": 0, "session_count": 0})
  128. msg_count = message_map.get(uid, 0)
  129. snap = user_snapshot.get(uid, {})
  130. first_seen = snap.get("first_seen_date")
  131. first_seen_str = first_seen.isoformat() if first_seen else None
  132. seg = snap.get("user_segment") or None
  133. org = snap.get("org_id") or None
  134. rows.append(
  135. (
  136. date_str,
  137. uid,
  138. active["active_flag"],
  139. active["session_count"],
  140. msg_count,
  141. first_seen_str,
  142. seg,
  143. org,
  144. )
  145. )
  146. insert_sql = """
  147. INSERT INTO user_daily_activity
  148. (activity_date, user_id, active_flag, session_count, message_count,
  149. first_seen_date, user_segment_snapshot, org_id_snapshot)
  150. VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
  151. ON DUPLICATE KEY UPDATE
  152. active_flag = VALUES(active_flag),
  153. session_count = VALUES(session_count),
  154. message_count = VALUES(message_count),
  155. user_segment_snapshot = VALUES(user_segment_snapshot),
  156. org_id_snapshot = VALUES(org_id_snapshot),
  157. first_seen_date = IFNULL(first_seen_date, VALUES(first_seen_date)),
  158. updated_at = CURRENT_TIMESTAMP
  159. """
  160. cursor.executemany(insert_sql, rows)
  161. conn.commit()
  162. count = cursor.rowcount if cursor.rowcount else len(rows)
  163. cursor.close()
  164. return count
  165. def main() -> None:
  166. parser = argparse.ArgumentParser(description="用户日活跃聚合 -> user_daily_activity")
  167. parser.add_argument("--config", default=DEFAULT_CONF, help="setting.conf 路径")
  168. parser.add_argument("--days", type=int, default=0, help="补跑最近 N 天(含今天),0 表示仅今天")
  169. parser.add_argument(
  170. "--once",
  171. action="store_true",
  172. help="仅执行一次(默认按 interval_minutes 守护循环执行)",
  173. )
  174. args = parser.parse_args()
  175. cfg = load_config(args.config)
  176. mysql_kw = get_mysql_config(cfg)
  177. active_events_str = cfg.get("user_daily_activity", "active_events", fallback="page.%__enter")
  178. interval_minutes = cfg.getint("schedule", "interval_minutes", fallback=60)
  179. active_where, active_args = build_active_events_where(None, active_events_str)
  180. def do_run() -> None:
  181. conn = pymysql.connect(**mysql_kw)
  182. try:
  183. today_utc = datetime.now(timezone.utc).date()
  184. if args.days > 0:
  185. dates = [today_utc - timedelta(days=i) for i in range(args.days)]
  186. else:
  187. dates = [today_utc]
  188. total = 0
  189. for d in dates:
  190. n = run_once(conn, d, active_where, active_args)
  191. total += n
  192. print(f"[user_daily_activity] {d.isoformat()} 写入/更新 {n} 条")
  193. if total > 0:
  194. print(f"[user_daily_activity] 合计 {total} 条")
  195. finally:
  196. conn.close()
  197. if args.once:
  198. # 仅执行一次(可结合 --days 使用)
  199. do_run()
  200. else:
  201. # 默认守护模式:根据 interval_minutes 周期性执行
  202. import time
  203. print(f"[user_daily_activity] 守护模式,每 {interval_minutes} 分钟执行一次")
  204. while True:
  205. do_run()
  206. time.sleep(interval_minutes * 60)
  207. if __name__ == "__main__":
  208. main()