zlwu 7 mesi fa
parent
commit
22b4036340

BIN
backend.zip


BIN
dailyCount.zip


+ 9 - 7
dailyCount/run_daily_stats_pipeline.py

@@ -86,15 +86,16 @@ def build_active_events_where(active_events_str: str) -> tuple[str, list]:
 def run_user_daily_activity_once(
 def run_user_daily_activity_once(
     conn, activity_date: date, active_events_where: str, active_events_args: list
     conn, activity_date: date, active_events_where: str, active_events_args: list
 ) -> int:
 ) -> int:
-    """对给定 UTC 日期执行一次 user_daily_activity 聚合,返回写入/更新的行数。"""
+    """对给定北京时间日期执行一次 user_daily_activity 聚合,返回写入/更新的行数。"""
     date_str = activity_date.isoformat()
     date_str = activity_date.isoformat()
     cursor = conn.cursor()
     cursor = conn.cursor()
 
 
-    cursor.execute("SET SESSION time_zone = '+00:00'")
+    # 按北京时间自然日统计
+    cursor.execute("SET SESSION time_zone = '+08:00'")
 
 
     cursor.execute(
     cursor.execute(
         """
         """
-        SELECT user_id FROM user_events WHERE DATE(event_time) = %s
+        SELECT user_id FROM user_events WHERE DATE(created_at) = %s
         UNION
         UNION
         SELECT user_id FROM messages WHERE DATE(created_at) = %s AND role = 'user'
         SELECT user_id FROM messages WHERE DATE(created_at) = %s AND role = 'user'
         """,
         """,
@@ -105,7 +106,7 @@ def run_user_daily_activity_once(
         cursor.close()
         cursor.close()
         return 0
         return 0
 
 
-    active_where = " DATE(event_time) = %s " + active_events_where
+    active_where = " DATE(created_at) = %s " + active_events_where
     active_params = [date_str] + active_events_args
     active_params = [date_str] + active_events_args
     cursor.execute(
     cursor.execute(
         """
         """
@@ -318,11 +319,12 @@ def main() -> None:
     def do_run() -> None:
     def do_run() -> None:
         conn = pymysql.connect(**mysql_kw)
         conn = pymysql.connect(**mysql_kw)
         try:
         try:
-            today_utc = datetime.now(timezone.utc).date()
+            beijing_tz = timezone(timedelta(hours=8))
+            today_bjt = datetime.now(beijing_tz).date()
             if args.days > 0:
             if args.days > 0:
-                dates = [today_utc - timedelta(days=i) for i in range(args.days)]
+                dates = [today_bjt - timedelta(days=i) for i in range(args.days)]
             else:
             else:
-                dates = [today_utc]
+                dates = [today_bjt]
             for d in dates:
             for d in dates:
                 n = run_user_daily_activity_once(conn, d, active_where, active_args)
                 n = run_user_daily_activity_once(conn, d, active_where, active_args)
                 print(f"[pipeline] [user_daily_activity] {d.isoformat()} 写入/更新 {n} 条")
                 print(f"[pipeline] [user_daily_activity] {d.isoformat()} 写入/更新 {n} 条")

+ 197 - 40
dashboard/streamlit_growth_dashboard.py

@@ -10,6 +10,7 @@ import streamlit as st
 
 
 try:
 try:
     from sqlalchemy import create_engine, text
     from sqlalchemy import create_engine, text
+    from sqlalchemy.exc import OperationalError
 except ImportError:
 except ImportError:
     st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
     st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
     st.error("请安装: pip install sqlalchemy pymysql")
     st.error("请安装: pip install sqlalchemy pymysql")
@@ -61,7 +62,20 @@ def _make_engine_url() -> str:
 
 
 @st.cache_resource
 @st.cache_resource
 def _get_engine():
 def _get_engine():
-    engine = create_engine(_make_engine_url())
+    # 使用连接保活与周期回收,减少 MySQL 空闲断连(2013/2006)问题
+    engine = create_engine(
+        _make_engine_url(),
+        pool_pre_ping=True,
+        pool_recycle=1800,
+        pool_size=5,
+        max_overflow=10,
+        pool_timeout=30,
+        connect_args={
+            "connect_timeout": 10,
+            "read_timeout": 120,
+            "write_timeout": 120,
+        },
+    )
     with engine.connect() as c:
     with engine.connect() as c:
         c.execute(text("SELECT 1"))
         c.execute(text("SELECT 1"))
     return engine
     return engine
@@ -75,38 +89,137 @@ except Exception as e:
 
 
 
 
 def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
 def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
-    return pd.read_sql_query(sql, engine, params=params)
+    try:
+        return pd.read_sql_query(sql, engine, params=params)
+    except OperationalError:
+        # 连接中断时主动释放连接池并重试一次
+        try:
+            engine.dispose()
+        except Exception:
+            pass
+        return pd.read_sql_query(sql, engine, params=params)
+
+
+@st.cache_data(ttl=300)
+def read_community_members_df() -> pd.DataFrame:
+    """读取参与共创社区的成员列表(包含邮箱来源)"""
+    return read_df(
+        """
+        SELECT
+            u.user_id,
+            COALESCE(NULLIF(TRIM(u.email), ''), NULLIF(TRIM(uec.email), '')) AS community_email,
+            CASE
+                WHEN u.email IS NOT NULL AND TRIM(u.email) <> '' THEN 'users.email'
+                ELSE 'user_early_community.email'
+            END AS email_source,
+            u.phone,
+            COALESCE(ucc.opted_out, 0) AS opted_out,
+            uec.joined_at
+        FROM users u
+        LEFT JOIN user_community_choice ucc
+            ON ucc.user_id = u.user_id
+        LEFT JOIN user_early_community uec
+            ON uec.user_id = u.user_id
+        WHERE
+            COALESCE(ucc.opted_out, 0) = 0
+            AND (
+                (u.email IS NOT NULL AND TRIM(u.email) <> '')
+                OR (
+                    (u.email IS NULL OR TRIM(u.email) = '')
+                    AND u.phone IS NOT NULL AND TRIM(u.phone) <> ''
+                    AND uec.email IS NOT NULL AND TRIM(uec.email) <> ''
+                )
+            )
+        ORDER BY community_email, u.user_id
+        """
+    )
+
+
+def _render_community_members_content():
+    members_df = read_community_members_df()
+    st.caption("规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。")
+    st.metric("参与成员数", f"{len(members_df):,}")
+    if members_df.empty:
+        st.info("当前没有符合条件的共创社区成员。")
+        return
+    st.dataframe(
+        members_df,
+        use_container_width=True,
+        hide_index=True,
+    )
+
+
+def _apply_dialog_extra_wide_style():
+    st.markdown(
+        """
+        <style>
+        div[data-testid="stDialog"] div[role="dialog"] {
+            width: min(96vw, 1600px) !important;
+            max-width: min(96vw, 1600px) !important;
+        }
+        </style>
+        """,
+        unsafe_allow_html=True,
+    )
+
+
+if hasattr(st, "dialog"):
+    @st.dialog("共创社区成员", width="large")
+    def show_community_members_dialog():
+        _apply_dialog_extra_wide_style()
+        _render_community_members_content()
+else:
+    def show_community_members_dialog():
+        st.subheader("共创社区成员")
+        _render_community_members_content()
 
 
 st.title("Lyrria Growth Dashboard")
 st.title("Lyrria Growth Dashboard")
 
 
 # ---- Sidebar ----
 # ---- Sidebar ----
 st.sidebar.header("Filters")
 st.sidebar.header("Filters")
 
 
-segments = read_df("SELECT DISTINCT user_segment_snapshot AS seg FROM user_daily_activity ORDER BY seg")["seg"].dropna().tolist()
+segments = read_df("SELECT DISTINCT user_segment AS seg FROM users WHERE user_segment IS NOT NULL ORDER BY seg")["seg"].dropna().tolist()
 orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
 orgs = read_df("SELECT DISTINCT org_id_snapshot AS org FROM user_daily_activity WHERE org_id_snapshot IS NOT NULL ORDER BY org")["org"].tolist()
 
 
 segment_default = [s for s in segments if s != "internal"] if segments else []
 segment_default = [s for s in segments if s != "internal"] if segments else []
-segment_sel = st.sidebar.multiselect("user_segment_snapshot", options=segments, default=segment_default)
+segment_sel = st.sidebar.multiselect("users.user_segment", options=segments, default=segment_default)
 org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
 org_sel = st.sidebar.multiselect("org_id_snapshot (school)", options=orgs, default=[])
 
 
 exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
 exclude_test = st.sidebar.checkbox("Exclude users.is_test_user = 1", value=True)
 
 
-# Date bounds (from daily table)
+# Date bounds
 max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
 max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
 min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
 min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
+today = date.today()
+
+# UX: 默认选择今天,允许选择今天之前所有日期(若库里最小日期晚于今天,则回退为该最小日期)
+target_day = st.sidebar.date_input(
+    "Target date",
+    value=today if today >= min_day else min_day,
+    min_value=min_day,
+    max_value=today if today >= min_day else min_day,
+)
 
 
-target_day = st.sidebar.date_input("Target date", value=max_day, min_value=min_day, max_value=max_day)
-lookback_days = st.sidebar.selectbox("Trend window (days)", [14, 30, 60, 90], index=1)
+st.sidebar.markdown("---")
+if st.sidebar.button("查看共创社区成员", use_container_width=True):
+    show_community_members_dialog()
 
 
 # ---- Load users (for universe + test filter) ----
 # ---- Load users (for universe + test filter) ----
-users = read_df("SELECT user_id, is_test_user FROM users")
+users = read_df("SELECT user_id, is_test_user, phone, email, user_segment FROM users")
 if exclude_test:
 if exclude_test:
     users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
     users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
+# Base eligibility: phone/email at least one non-empty.
+phone_non_empty = users["phone"].fillna("").astype(str).str.strip() != ""
+email_non_empty = users["email"].fillna("").astype(str).str.strip() != ""
+users = users[phone_non_empty | email_non_empty].copy()
+if segment_sel:
+    users = users[users["user_segment"].isin(segment_sel)].copy()
 universe_users = set(users["user_id"].astype(str).tolist())
 universe_users = set(users["user_id"].astype(str).tolist())
 
 
 # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
 # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
-# We only need recent for rolling-window sets & trend chart.
-start_recent = target_day - timedelta(days=max(lookback_days, 35))
+# We only need recent history for rolling-window sets (7/30d) and trend chart(月内).
+history_days = 35
+start_recent = target_day - timedelta(days=history_days)
 
 
 daily = read_df(
 daily = read_df(
     """
     """
@@ -122,35 +235,48 @@ daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
 daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
 daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
 
 
 # Apply snapshot filters
 # Apply snapshot filters
-if segment_sel:
-    daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
 if org_sel:
 if org_sel:
     daily = daily[daily["org_id_snapshot"].isin(org_sel)]
     daily = daily[daily["org_id_snapshot"].isin(org_sel)]
 
 
 # Apply user universe filter (test users removed)
 # Apply user universe filter (test users removed)
 daily = daily[daily["user_id"].isin(universe_users)]
 daily = daily[daily["user_id"].isin(universe_users)]
 
 
-# If segment/org filters are applied, universe becomes "users within that cohort".
-# We derive cohort universe from snapshots (all users who ever appeared in daily within recent window).
-# In production, if you need cohort-universe across all time, build a cohort-user table or widen this query.
-if segment_sel or org_sel:
-    cohort_universe = set(daily["user_id"].unique().tolist())
+# Cohort base comes from users table (test/contact/segment filters).
+# If org filter is selected, intersect with users ever seen in that org (full history).
+if org_sel:
+    org_placeholders = ",".join(["%s"] * len(org_sel))
+    org_df = read_df(
+        f"""
+        SELECT DISTINCT user_id
+        FROM user_daily_activity
+        WHERE org_id_snapshot IN ({org_placeholders})
+        """,
+        tuple(org_sel),
+    )
+    org_users = set(org_df["user_id"].astype(str).tolist())
+    cohort_universe = universe_users & org_users
 else:
 else:
     cohort_universe = universe_users
     cohort_universe = universe_users
 
 
 # ---- Trend (DAU, by day) ----
 # ---- Trend (DAU, by day) ----
 st.subheader("DAU trend")
 st.subheader("DAU trend")
-trend = (
+trend_start = target_day.replace(day=1)
+dau_raw = (
     daily[
     daily[
-        (daily["activity_date"] >= (target_day - timedelta(days=lookback_days)))
+        (daily["activity_date"] >= trend_start)
         & (daily["activity_date"] <= target_day)
         & (daily["activity_date"] <= target_day)
+        & (daily["active_flag"] == 1)
     ]
     ]
     .groupby("activity_date")["user_id"]
     .groupby("activity_date")["user_id"]
     .nunique()
     .nunique()
     .reset_index(name="dau")
     .reset_index(name="dau")
 )
 )
-# 使用字符串日期 + Altair,控制 X 轴标签为横向
-trend["activity_date"] = pd.to_datetime(trend["activity_date"]).dt.date
+# 补齐日期轴到 target_day:无数据的日期显示 0
+trend_days = pd.DataFrame(
+    {"activity_date": pd.date_range(start=trend_start, end=target_day, freq="D").date}
+)
+trend = trend_days.merge(dau_raw, on="activity_date", how="left")
+trend["dau"] = trend["dau"].fillna(0).astype(int)
 trend["day"] = trend["activity_date"].astype(str)
 trend["day"] = trend["activity_date"].astype(str)
 
 
 chart = (
 chart = (
@@ -164,6 +290,48 @@ chart = (
 )
 )
 st.altair_chart(chart, use_container_width=True)
 st.altair_chart(chart, use_container_width=True)
 
 
+# ---- Trend (CURR, by day from curr_daily) ----
+st.subheader("CURR trend")
+curr_start = target_day.replace(day=1)
+curr_df = read_df(
+    """
+    SELECT activity_date, prev_day_active_count, retained_count, curr_rate_pct, total_active_today
+    FROM curr_daily
+    WHERE activity_date >= %s AND activity_date <= %s
+    ORDER BY activity_date
+    """,
+    (curr_start.isoformat(), target_day.isoformat()),
+)
+if not curr_df.empty:
+    curr_df["activity_date"] = pd.to_datetime(curr_df["activity_date"]).dt.date
+
+# 补齐日期轴到 target_day:无数据的日期显示 0
+curr_days = pd.DataFrame(
+    {"activity_date": pd.date_range(start=curr_start, end=target_day, freq="D").date}
+)
+curr_plot = curr_days.merge(curr_df, on="activity_date", how="left")
+for col in ["prev_day_active_count", "retained_count", "curr_rate_pct", "total_active_today"]:
+    if col in curr_plot.columns:
+        curr_plot[col] = curr_plot[col].fillna(0)
+curr_plot["day"] = curr_plot["activity_date"].astype(str)
+
+curr_chart = (
+    alt.Chart(curr_plot)
+    .mark_line(point=True, color="#f59e0b")
+    .encode(
+        x=alt.X("day:N", axis=alt.Axis(title="Date", labelAngle=0)),
+        y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title="CURR (%)", format=".2f")),
+        tooltip=[
+            "day",
+            alt.Tooltip("curr_rate_pct:Q", title="CURR (%)", format=".2f"),
+            alt.Tooltip("prev_day_active_count:Q", title="Prev-day active"),
+            alt.Tooltip("retained_count:Q", title="Retained today"),
+            alt.Tooltip("total_active_today:Q", title="DAU today"),
+        ],
+    )
+)
+st.altair_chart(curr_chart, use_container_width=True)
+
 # ---- Segment computation on target_day ----
 # ---- Segment computation on target_day ----
 d0 = target_day
 d0 = target_day
 d7 = d0 - timedelta(days=7)
 d7 = d0 - timedelta(days=7)
@@ -174,7 +342,7 @@ active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_fla
 active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
 active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
 active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
 active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
 
 
-# --- IMPORTANT: Dormant/Resurrected require "last active before today" using FULL history ---
+# --- IMPORTANT: Dormant requires "last active before today" using FULL history ---
 # We compute:
 # We compute:
 # prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
 # prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
 #
 #
@@ -216,12 +384,6 @@ new_users = {u for u in active_today if first_seen_map.get(u) == d0}
 current_users = {u for u in active_today if u in active_past_7}
 current_users = {u for u in active_today if u in active_past_7}
 reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
 reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
 
 
-# Resurrected: active today AND previous last active < d0-30 (strictly before last 30 days)
-resurrected_users = {
-    u for u in active_today
-    if (prev_last_active_date(u) is not None) and (prev_last_active_date(u) < d30)
-}
-
 # Inactive segments
 # Inactive segments
 at_risk_wau = {u for u in active_past_7 if u not in active_today}
 at_risk_wau = {u for u in active_past_7 if u not in active_today}
 at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
 at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
@@ -235,15 +397,10 @@ dormant_users = {
 # ---- Segments visualization (Duolingo-like, no tables) ----
 # ---- Segments visualization (Duolingo-like, no tables) ----
 st.subheader(f"Segments on {d0.isoformat()}")
 st.subheader(f"Segments on {d0.isoformat()}")
 
 
-# Active buckets (composition of DAU). We add an "Other active" bucket as a safe remainder.
-other_active = active_today - new_users - current_users - reactivated_users - resurrected_users
-
 active_buckets = [
 active_buckets = [
     ("New", len(new_users)),
     ("New", len(new_users)),
     ("Current", len(current_users)),
     ("Current", len(current_users)),
     ("Reactivated", len(reactivated_users)),
     ("Reactivated", len(reactivated_users)),
-    ("Resurrected", len(resurrected_users)),
-    ("Other active", len(other_active)),
 ]
 ]
 active_total = len(active_today)
 active_total = len(active_today)
 inactive_total = max(0, len(cohort_universe) - len(active_today))
 inactive_total = max(0, len(cohort_universe) - len(active_today))
@@ -256,8 +413,12 @@ inactive_buckets = [
 def segment_card(label: str, value: int, denom: int, hint: str):
 def segment_card(label: str, value: int, denom: int, hint: str):
     pct = (value / denom) if denom > 0 else 0.0
     pct = (value / denom) if denom > 0 else 0.0
     st.markdown(
     st.markdown(
-        f"**{label}**"
-        f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>",
+        (
+            "<div style='display:flex; align-items:baseline; gap:8px;'>"
+            f"<span style='font-weight:700;'>{label}</span>"
+            f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>"
+            "</div>"
+        ),
         unsafe_allow_html=True,
         unsafe_allow_html=True,
     )
     )
     st.progress(min(max(pct, 0.0), 1.0))
     st.progress(min(max(pct, 0.0), 1.0))
@@ -288,10 +449,8 @@ with left:
     with t2:
     with t2:
         st.metric("DAU", len(active_today))
         st.metric("DAU", len(active_today))
 
 
-    st.caption("How today's DAU is composed (buckets sum to DAU, with 'Other active' as remainder).")
+    st.caption("How today's DAU is composed.")
     for label, val in active_buckets:
     for label, val in active_buckets:
-        if val == 0:
-            continue
         segment_card(label, val, active_total, "Share of DAU.")
         segment_card(label, val, active_total, "Share of DAU.")
 
 
 with right:
 with right:
@@ -303,8 +462,6 @@ with right:
 
 
     st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
     st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
     for label, val in inactive_buckets:
     for label, val in inactive_buckets:
-        if val == 0:
-            continue
         segment_card(label, val, inactive_total, "Share of users not active today.")
         segment_card(label, val, inactive_total, "Share of users not active today.")
 
 
 
 

+ 46 - 0
frontend/docs/chatbot加载与资源缓存分析.md

@@ -0,0 +1,46 @@
+# /chatbot 加载与图片资源缓存分析
+
+## 一、前端对图片/GIF 的缓存情况
+
+| 项目 | 说明 |
+|------|------|
+| **是否有前端缓存层** | 无。未使用 Service Worker、Cache API 或内存缓存。 |
+| **资源来源** | 全部通过 `getOssUrl()` 指向 OSS(默认 `https://www.lyrria.com/img/`)。 |
+| **实际缓存方** | 依赖**浏览器 HTTP 缓存**,由 OSS/CDN 的 `Cache-Control`、`ETag` 等响应头决定。 |
+| **建议** | 在 OSS 或 CDN 为静态资源配置较长 `max-age`(如 1 年),并启用 ETag,以利用浏览器缓存。 |
+
+结论:**前端没有对图片/GIF 做额外缓存**,仅依赖浏览器对 OSS 响应的缓存策略。
+
+---
+
+## 二、/chatbot 加载慢的主要原因
+
+| 原因 | 说明 |
+|------|------|
+| **1. ChatBot 页面体量大** | 单文件 4000+ 行,模块顶层 50+ 个 `getOssUrl()`,chunk 下载与解析耗时。 |
+| **2. 首屏多图同域并发** | 主形象、凳子、树木、特殊场景等同时请求 OSS,易争抢带宽,首帧主形象易被拖慢。 |
+| **3. Avatar 多表情同时预加载** | 原 `preloadAvatarGifs` 一次性预加载 default/positive/negative/talking,与首帧争抢。 |
+| **4. 录音/特殊场景资源首屏即占位** | 若未做优先级与延迟,audio-wave、特殊场景 GIF 等会参与首屏请求。 |
+
+---
+
+## 三、已做或建议的优化
+
+| 优化项 | 状态/建议 |
+|--------|-----------|
+| 路由懒加载 | 已做:`ChatBotPage` 使用 `React.lazy` 代码分割。 |
+| 关键资源 preload | 已做:`main.tsx` 预加载 `01-Default.gif`、`chatbot-bg.svg`、`stool.png`。 |
+| 首屏图片优先级 | 已做:主形象双 `<img>` 使用 `fetchPriority="high"`、`loading="eager"`。 |
+| 次要图降低优先级/懒加载 | 已做:树木/凳子 `fetchPriority="low"`;树木 `loading="lazy"`;特殊场景 GIF `loading="lazy"`。 |
+| Avatar 预加载分阶段 | 已做:先预加载 default,情绪 GIF 用 `requestIdleCallback`(或 setTimeout)延迟加载。 |
+| 录音时再加载 audio-wave | 已做:在点击录音、`setState('recording')` 前预加载 `audio-wave.gif`。 |
+| OSS 缓存头 | 建议:在 OSS/CDN 为 `/img/*` 配置长缓存 + ETag。 |
+
+---
+
+## 四、相关文件
+
+- 路由与懒加载:`src/router/index.tsx`
+- OSS URL:`src/utils/oss.ts`
+- ChatBot 资源与预加载:`src/pages/ChatBotPage.tsx`(顶层常量、`preloadAvatarGifs`)
+- 全局 preload:`src/main.tsx`

+ 6 - 2
frontend/src/components/ChatBotStaticShell.tsx

@@ -220,9 +220,13 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
       }
       }
     })();
     })();
 
 
-    // 头像 GIF:根据当前 avatar_id 选择 default 状态 GIF
+    // 头像 GIF:tutorial-2~tutorial-9 固定使用 01-Default.gif;其余步骤根据 avatar_id 选择
     (async () => {
     (async () => {
       try {
       try {
+        if (step >= 2 && step <= 9) {
+          setAvatarGif(FALLBACK_DEFAULT_AVATAR_GIF);
+          return;
+        }
         const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
         const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
         const res = await getAvatarGifs();
         const res = await getAvatarGifs();
         if (!res.success || !res.data) {
         if (!res.success || !res.data) {
@@ -247,7 +251,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
         // 兜底:保持默认 GIF
         // 兜底:保持默认 GIF
       }
       }
     })();
     })();
-  }, []);
+  }, [step]);
 
 
   // 教学步骤 1 & 10:使用专用背景 + 静态欢迎布局
   // 教学步骤 1 & 10:使用专用背景 + 静态欢迎布局
   if (step === 1 || step === 10) {
   if (step === 1 || step === 10) {

+ 4 - 3
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -425,7 +425,7 @@ export const LoginVerificationInputPage = () => {
 
 
   return (
   return (
     <div>
     <div>
-      {/* 返回按钮:设计稿 393×852 下 48×48,左24,上125 */}
+      {/* 返回按钮:与 phone-login 一致 393×852 下 48×48 左24 上125,圆形 */}
       <button
       <button
         type="button"
         type="button"
         onClick={wrapTrackedClick('login_verification_input', 'back', handleBack)}
         onClick={wrapTrackedClick('login_verification_input', 'back', handleBack)}
@@ -433,11 +433,12 @@ export const LoginVerificationInputPage = () => {
           position: 'absolute',
           position: 'absolute',
           left: px2vw(24, DESIGN),
           left: px2vw(24, DESIGN),
           top: px2vh(125, DESIGN),
           top: px2vh(125, DESIGN),
-          width: px2vw(48, DESIGN),
-          height: px2vh(48, DESIGN),
+          width: px2vmin(48, DESIGN),
+          height: px2vmin(48, DESIGN),
           padding: 0,
           padding: 0,
           border: 'none',
           border: 'none',
           background: 'none',
           background: 'none',
+          borderRadius: '50%',
           zIndex: 10,
           zIndex: 10,
           cursor: 'pointer',
           cursor: 'pointer',
         }}
         }}

+ 105 - 0
项目文档/Dashboard与库表对接分析.md

@@ -0,0 +1,105 @@
+# Dashboard 与库表对接分析
+
+**依据**:客户 Dashboard(`dashboard/streamlit_growth_dashboard.py`)+ **最新数据库模型**(Lyrria.sql,如 2026-03-05 导出)。
+
+---
+
+## 一、客户 Dashboard 功能概览
+
+| 模块 | 说明 |
+|------|------|
+| 数据源 | 当前为 **SQLite**(`demo_growth.db`),需改为接入我方 **MySQL** |
+| 筛选 | 按 `user_segment_snapshot`、`org_id_snapshot`、是否排除测试用户、目标日期、趋势窗口(14/30/60/90天) |
+| DAU 趋势 | 按日去重 user_id 数量,折线图 |
+| 当日分群 | 基于目标日及前 7/30 天活跃,计算 New / Current / Reactivated / Resurrected / At-risk / Dormant 等 |
+| 下钻 | 展示各分群前 50 个 user_id |
+
+---
+
+## 二、Dashboard 依赖的库表与字段
+
+### 2.1 表 `user_daily_activity`
+
+Dashboard 使用的 SQL 及字段如下:
+
+| 用途 | 使用字段 |
+|------|----------|
+| 筛选项 | `user_segment_snapshot`、`org_id_snapshot` |
+| 日期范围 | `activity_date`(MIN/MAX 取边界) |
+| 主查询 | `activity_date`, `user_id`, `active_flag`, `session_count`, `message_count`, `first_seen_date`, `user_segment_snapshot`, `org_id_snapshot` |
+| 昨日前最后活跃日 | `user_id`, `activity_date`, `active_flag`(用于 `MAX(activity_date)` 且 `activity_date < target_day`) |
+
+### 2.2 表 `users`
+
+| 用途 | 使用字段 |
+|------|----------|
+| 排除测试用户 | `user_id`, `is_test_user` |
+
+---
+
+## 三、我方库表与 Dashboard 需求对照
+
+### 3.1 `user_daily_activity`(dailyCount 已存在)
+
+| Dashboard 需要 | 我方表字段 | 结论 |
+|----------------|------------|------|
+| activity_date | activity_date (date) | ✅ 一致 |
+| user_id | user_id (varchar(64)) | ✅ 一致 |
+| active_flag | active_flag (tinyint) | ✅ 一致 |
+| session_count | session_count (int) | ✅ 一致 |
+| message_count | message_count (int) | ✅ 一致 |
+| first_seen_date | first_seen_date (date),来自 users.created_at 且 ON DUPLICATE 用 IFNULL 不覆盖 | ✅ 一致 |
+| user_segment_snapshot | user_segment_snapshot (varchar(32)),来自 users.user_segment | ✅ 一致 |
+| org_id_snapshot | org_id_snapshot (varchar(64)),来自 users.org_id | ✅ 一致 |
+
+**结论:`user_daily_activity` 表结构及写入逻辑完全满足 Dashboard 需求,无需改表。**
+
+### 3.2 `users`(最新 Lyrria.sql 已包含所需字段)
+
+| Dashboard 需要 | 最新 users 表字段 | 结论 |
+|----------------|-------------------|------|
+| user_id | user_id varchar(64) NOT NULL | ✅ 有 |
+| is_test_user | is_test_user tinyint(1) NOT NULL DEFAULT '0' | ✅ 有 |
+| (快照来源)user_segment / org_id | user_segment varchar(32) NOT NULL DEFAULT 'individual',org_id varchar(64) | ✅ 有 |
+
+**结论:最新数据库模型中 `users` 已包含 `user_id`、`is_test_user`、`user_segment`、`org_id`,无需再执行迁移,完全满足 Dashboard。**
+
+### 3.3 `curr_daily`(CURR 按日统计)
+
+| Dashboard 使用 | 说明 |
+|----------------|------|
+| 未使用 | 当前 Dashboard 未读 `curr_daily`;DAU 与分群均从 `user_daily_activity` 计算 |
+
+**结论:Dashboard 不依赖 `curr_daily`。若客户后续要在界面展示「CURR 趋势」或「昨日活跃今日留存率」,再在 Dashboard 中增加对 `curr_daily` 的查询即可,表结构已满足。**
+
+---
+
+## 四、需要修改/确认的点
+
+### 4.1 必须项(仅 Dashboard 侧)
+
+| 项 | 说明 |
+|----|------|
+| Dashboard 接入 MySQL | 当前 Dashboard 使用 `sqlite3` + 环境变量 `DEMO_DB_PATH`。接入我方数据库需改为 **MySQL** 连接(如 `pymysql` 或 `sqlalchemy`),并配置 host/port/user/password/database(建议从环境变量或配置文件读取,勿写死)。 |
+
+**库表侧**:按最新 Lyrria.sql,`users`、`user_daily_activity`、`curr_daily` 均已满足,**无需改表或补迁移**。
+
+### 4.2 可选项(按客户需求)
+
+| 项 | 说明 |
+|----|------|
+| 在 Dashboard 中展示 CURR | 若需要,可增加从 `curr_daily` 读取 `activity_date`、`prev_day_active_count`、`retained_count`、`curr_rate_pct`、`total_active_today` 做图表或指标卡。 |
+| 大数据量下 last_active 查询 | Dashboard 用 `user_id IN (...)` 做「昨日前最后活跃日」查询;若 cohort 极大,可改为临时表或子查询,避免 IN 列表过长。 |
+
+---
+
+## 五、总结(基于最新数据库模型)
+
+| 维度 | 结论 |
+|------|------|
+| **user_daily_activity** | 字段与语义完全符合 Dashboard,无需修改。 |
+| **users** | 最新 Lyrria.sql 已包含 `user_id`、`is_test_user`、`user_segment`、`org_id`,完全满足,无需改表或补迁移。 |
+| **curr_daily** | Dashboard 当前未使用;表结构已满足,若后续要展示 CURR 再在 Dashboard 中加查询即可。 |
+| **Dashboard 改造** | 唯一必须项:将数据源从 SQLite 改为 MySQL,并做好连接配置与权限。 |
+
+**结论:在采用最新数据库模型(Lyrria.sql)的前提下,现状库表已完全满足客户 Dashboard 的数据需求;只需在 Dashboard 侧接入 MySQL 即可使用。**