zlwu 7 ay önce
ebeveyn
işleme
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(
     conn, activity_date: date, active_events_where: str, active_events_args: list
 ) -> int:
-    """对给定 UTC 日期执行一次 user_daily_activity 聚合,返回写入/更新的行数。"""
+    """对给定北京时间日期执行一次 user_daily_activity 聚合,返回写入/更新的行数。"""
     date_str = activity_date.isoformat()
     cursor = conn.cursor()
 
-    cursor.execute("SET SESSION time_zone = '+00:00'")
+    # 按北京时间自然日统计
+    cursor.execute("SET SESSION time_zone = '+08:00'")
 
     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
         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()
         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
     cursor.execute(
         """
@@ -318,11 +319,12 @@ def main() -> None:
     def do_run() -> None:
         conn = pymysql.connect(**mysql_kw)
         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:
-                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:
-                dates = [today_utc]
+                dates = [today_bjt]
             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} 条")

+ 197 - 40
dashboard/streamlit_growth_dashboard.py

@@ -10,6 +10,7 @@ import streamlit as st
 
 try:
     from sqlalchemy import create_engine, text
+    from sqlalchemy.exc import OperationalError
 except ImportError:
     st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
     st.error("请安装: pip install sqlalchemy pymysql")
@@ -61,7 +62,20 @@ def _make_engine_url() -> str:
 
 @st.cache_resource
 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:
         c.execute(text("SELECT 1"))
     return engine
@@ -75,38 +89,137 @@ except Exception as e:
 
 
 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")
 
 # ---- Sidebar ----
 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()
 
 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=[])
 
 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()
 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) ----
-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:
     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())
 
 # ---- 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(
     """
@@ -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
 
 # Apply snapshot filters
-if segment_sel:
-    daily = daily[daily["user_segment_snapshot"].isin(segment_sel)]
 if org_sel:
     daily = daily[daily["org_id_snapshot"].isin(org_sel)]
 
 # Apply user universe filter (test users removed)
 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:
     cohort_universe = universe_users
 
 # ---- Trend (DAU, by day) ----
 st.subheader("DAU trend")
-trend = (
+trend_start = target_day.replace(day=1)
+dau_raw = (
     daily[
-        (daily["activity_date"] >= (target_day - timedelta(days=lookback_days)))
+        (daily["activity_date"] >= trend_start)
         & (daily["activity_date"] <= target_day)
+        & (daily["active_flag"] == 1)
     ]
     .groupby("activity_date")["user_id"]
     .nunique()
     .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)
 
 chart = (
@@ -164,6 +290,48 @@ chart = (
 )
 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 ----
 d0 = target_day
 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_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:
 # 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}
 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
 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)}
@@ -235,15 +397,10 @@ dormant_users = {
 # ---- Segments visualization (Duolingo-like, no tables) ----
 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 = [
     ("New", len(new_users)),
     ("Current", len(current_users)),
     ("Reactivated", len(reactivated_users)),
-    ("Resurrected", len(resurrected_users)),
-    ("Other active", len(other_active)),
 ]
 active_total = 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):
     pct = (value / denom) if denom > 0 else 0.0
     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,
     )
     st.progress(min(max(pct, 0.0), 1.0))
@@ -288,10 +449,8 @@ with left:
     with t2:
         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:
-        if val == 0:
-            continue
         segment_card(label, val, active_total, "Share of DAU.")
 
 with right:
@@ -303,8 +462,6 @@ with right:
 
     st.caption("Breakdown of users who are not active today (within the current filtered cohort).")
     for label, val in inactive_buckets:
-        if val == 0:
-            continue
         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 () => {
       try {
+        if (step >= 2 && step <= 9) {
+          setAvatarGif(FALLBACK_DEFAULT_AVATAR_GIF);
+          return;
+        }
         const currentAvatarId = getStorage(STORAGE_KEYS.CURRENT_AVATAR_ID);
         const res = await getAvatarGifs();
         if (!res.success || !res.data) {
@@ -247,7 +251,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
         // 兜底:保持默认 GIF
       }
     })();
-  }, []);
+  }, [step]);
 
   // 教学步骤 1 & 10:使用专用背景 + 静态欢迎布局
   if (step === 1 || step === 10) {

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

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