zlwu 8 months ago
parent
commit
64d40478bd
57 changed files with 3099 additions and 452 deletions
  1. 46 0
      .cursorrules
  2. 488 0
      Lyrria.sql
  3. 18 0
      backend/init_database.py
  4. 214 96
      backend/main.py
  5. 66 0
      backend/migrations/add_user_segment_org_and_user_events.sql
  6. 5 0
      backend/models.py
  7. 107 0
      backend/models_db.py
  8. 69 0
      backend/scenario_config.py
  9. 3 0
      backend/services/__init__.py
  10. 87 0
      backend/services/scenario_topic_service.py
  11. 60 2
      backend/services/user_service.py
  12. 192 92
      backend/websocket_handler.py
  13. 38 0
      dailyCount/README.md
  14. 2 0
      dailyCount/requirements.txt
  15. 176 0
      dailyCount/run_curr_daily.py
  16. 228 0
      dailyCount/run_user_daily_activity.py
  17. 18 0
      dailyCount/setting.conf.example
  18. 23 0
      dailyCount/sql/create_curr_daily.sql
  19. 28 0
      dailyCount/sql/create_user_daily_activity.sql
  20. 17 19
      frontend/src/components/ChatBotStaticShell.tsx
  21. 27 3
      frontend/src/components/IntimacyModal.tsx
  22. 16 2
      frontend/src/components/MissionEnergyStoneModal.tsx
  23. 2 2
      frontend/src/components/PageVisitReporter.tsx
  24. 16 2
      frontend/src/components/PrivacyPolicyModal.tsx
  25. 7 3
      frontend/src/components/RecordButton.tsx
  26. 26 0
      frontend/src/components/TrackedButton.tsx
  27. 16 2
      frontend/src/components/UserAgreementModal.tsx
  28. 14 17
      frontend/src/pages/BeginPage.tsx
  29. 171 49
      frontend/src/pages/ChatBotPage.tsx
  30. 2 1
      frontend/src/pages/ErrorPage.tsx
  31. 12 11
      frontend/src/pages/FirstLoginPage.tsx
  32. 8 7
      frontend/src/pages/FirstLoginRewardPage.tsx
  33. 9 16
      frontend/src/pages/GreetingPage.tsx
  34. 3 2
      frontend/src/pages/Level34WelcomePage.tsx
  35. 2 1
      frontend/src/pages/Level5WelcomePage.tsx
  36. 2 1
      frontend/src/pages/LoadingPage.tsx
  37. 10 11
      frontend/src/pages/LoginPage.tsx
  38. 10 7
      frontend/src/pages/LoginRegisterPage.tsx
  39. 8 7
      frontend/src/pages/LoginVerificationCodePage.tsx
  40. 13 12
      frontend/src/pages/LoginVerificationInputPage.tsx
  41. 5 4
      frontend/src/pages/LumiNamingPage.tsx
  42. 8 9
      frontend/src/pages/PhoneLogin.tsx
  43. 23 19
      frontend/src/pages/ProfilePage.tsx
  44. 2 1
      frontend/src/pages/ShareSuccessPage.tsx
  45. 4 2
      frontend/src/pages/TutorialStep10Page.tsx
  46. 4 2
      frontend/src/pages/TutorialStep9Page.tsx
  47. 9 8
      frontend/src/pages/VerificationCodePage.tsx
  48. 4 3
      frontend/src/pages/VerificationInputPage.tsx
  49. 8 13
      frontend/src/pages/WelcomePage.tsx
  50. 20 0
      frontend/src/types/index.ts
  51. 62 0
      frontend/src/utils/api.ts
  52. 18 19
      frontend/src/utils/storage.ts
  53. 144 0
      frontend/src/utils/tracking.ts
  54. 37 7
      frontend/src/utils/websocket.ts
  55. 110 0
      项目文档/埋点事件命名规范.md
  56. 241 0
      项目文档/数据聚合表与CURR开发改造方案.md
  57. 141 0
      项目文档/用户分群与事件表需求分析与表设计.md

+ 46 - 0
.cursorrules

@@ -410,3 +410,49 @@ console.time('Processing');
 console.timeEnd('Processing');
 ```
 
+---
+
+## 埋点事件命名规范(user_events)
+
+- **event_name 结构**:`<object>__<action>`,使用两个下划线分隔。
+- **object 结构**:`<object_type>.<object_id>[.<sub_id>...]`。
+  - 页面:`object_type = page`,例如 `page.chatbot`、`page.welcome`。
+  - 按钮:`object_type = button`,例如 `button.chatbot.send`、`button.welcome.start`。
+- **action 动作**:统一英文小写动词,例如:`enter`、`view`、`click`、`submit`、`open`、`close`、`back`、`next`。
+
+### 页面命名
+
+- 按路由定义 `page_id`,示例:
+  - `/chatbot` → `page.chatbot`
+  - `/welcome` → `page.welcome`
+  - `/begin` → `page.begin`
+  - `/login-register` → `page.login_register`
+  - `/email-login` → `page.email_login`
+  - `/phone-login` → `page.phone_login`
+  - `/tutorial-1` ~ `/tutorial-10` → `page.tutorial_1` ~ `page.tutorial_10`
+  - `/profile` → `page.profile`
+- 页面进入事件统一为:`event_name = "page.<page_id>__enter"`。
+
+### 按钮命名
+
+- 格式:`object = "button.<page_id>.<button_id>"`,事件为:`"button.<page_id>.<button_id>__click"`。
+- `button_id` 使用语义化英文 snake_case,例如:
+  - 登录注册页:`button.login_register.email_login__click`、`button.login_register.phone_login__click`
+  - ChatBot:`button.chatbot.send__click`、`button.chatbot.mic__click`、`button.chatbot.stop__click`
+  - Welcome:`button.welcome.start__click`
+
+### 跳转场景
+
+- 规则:**“点击 + 页面进入”记两条事件**。
+  - 示例:点击 Welcome 页按钮跳转到 Begin 页:
+    - `button.welcome.start__click`
+    - `page.begin__enter`(`properties.from_page = "welcome"`)
+
+### properties 建议字段
+
+- `page`: `<page_id>`,如 `chatbot`、`welcome`。
+- `from_page`: 跳转来源页面,例如 `welcome`。
+- `button_id`: `<button_id>`,如 `send`、`email_login`。
+- 其他与具体业务相关的扩展属性。
+
+

+ 488 - 0
Lyrria.sql

@@ -0,0 +1,488 @@
+/*
+ Navicat Premium Dump SQL
+
+ Source Server         : 101.201.60.170 Lyrria@Root@123456
+ Source Server Type    : MySQL
+ Source Server Version : 50739 (5.7.39)
+ Source Host           : 101.201.60.170:13306
+ Source Schema         : Lyrria
+
+ Target Server Type    : MySQL
+ Target Server Version : 50739 (5.7.39)
+ File Encoding         : 65001
+
+ Date: 02/03/2026 16:00:47
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for avatar_assets
+-- ----------------------------
+DROP TABLE IF EXISTS `avatar_assets`;
+CREATE TABLE `avatar_assets` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `avatar_id` varchar(64) NOT NULL COMMENT '形象ID',
+  `state` enum('default','positive','negative','talking','idle','sleep') NOT NULL DEFAULT 'default' COMMENT '动作/情绪状态',
+  `image_url` varchar(500) NOT NULL COMMENT 'GIF 资源 URL',
+  `thumbnail_url` varchar(500) DEFAULT NULL COMMENT '缩略图URL',
+  `metadata` json DEFAULT NULL COMMENT '扩展配置',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_avatar_asset_state` (`avatar_id`,`state`),
+  KEY `idx_avatar_asset_avatar_id` (`avatar_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COMMENT='形象动作资源表';
+
+-- ----------------------------
+-- Table structure for avatars
+-- ----------------------------
+DROP TABLE IF EXISTS `avatars`;
+CREATE TABLE `avatars` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `avatar_id` varchar(64) NOT NULL COMMENT '形象唯一标识',
+  `avatar_name` varchar(100) NOT NULL COMMENT '形象名称',
+  `description` varchar(255) DEFAULT NULL COMMENT '形象描述',
+  `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
+  `metadata` json DEFAULT NULL COMMENT '扩展配置',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `avatar_id` (`avatar_id`),
+  KEY `idx_avatar_is_active` (`is_active`),
+  KEY `idx_avatar_id` (`avatar_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='形象配置表';
+
+-- ----------------------------
+-- Table structure for background_images
+-- ----------------------------
+DROP TABLE IF EXISTS `background_images`;
+CREATE TABLE `background_images` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `image_id` varchar(64) NOT NULL COMMENT '背景图片唯一标识',
+  `image_name` varchar(100) NOT NULL COMMENT '背景图片名称',
+  `image_url` varchar(500) NOT NULL COMMENT '背景图片URL',
+  `thumbnail_url` varchar(500) DEFAULT NULL COMMENT '缩略图URL',
+  `unlock_type` enum('default','level','purchase','mission') NOT NULL DEFAULT 'default' COMMENT '解锁类型',
+  `unlock_level` int(11) DEFAULT NULL COMMENT '解锁所需等级(unlock_type=level时)',
+  `unlock_mission_type` varchar(64) DEFAULT NULL COMMENT '解锁所需任务类型(unlock_type=mission时)',
+  `display_order` int(11) NOT NULL DEFAULT '0' COMMENT '显示顺序',
+  `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
+  `description` varchar(255) DEFAULT NULL COMMENT '背景图片描述',
+  `metadata` json DEFAULT NULL COMMENT '扩展配置',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `image_id` (`image_id`),
+  KEY `idx_image_id` (`image_id`),
+  KEY `idx_unlock_type` (`unlock_type`),
+  KEY `idx_display_order` (`display_order`)
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='背景图片配置表';
+
+-- ----------------------------
+-- Table structure for conversation_energy_stones
+-- ----------------------------
+DROP TABLE IF EXISTS `conversation_energy_stones`;
+CREATE TABLE `conversation_energy_stones` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `stone_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '能量石ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `request_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发的请求ID',
+  `threshold_index` int(11) NOT NULL COMMENT '达成阈值的序号(1=30秒,2=60秒...)',
+  `total_duration_ms` bigint(20) NOT NULL COMMENT '累计说话时长(毫秒)',
+  `duration_increment_ms` int(11) NOT NULL COMMENT '本次新增时长(毫秒)',
+  `appear_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '出现时间',
+  `clicked_at` timestamp NULL DEFAULT NULL COMMENT '点击时间',
+  `auto_removed` tinyint(1) DEFAULT '0' COMMENT '是否自动消失',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `stone_id` (`stone_id`),
+  UNIQUE KEY `uk_conversation_stone_threshold` (`user_id`,`threshold_index`),
+  KEY `idx_stone_id` (`stone_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_conversation_stone_user_threshold` (`user_id`,`threshold_index`),
+  KEY `idx_appear_at` (`appear_at`)
+) ENGINE=InnoDB AUTO_INCREMENT=275 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话能量石表(按时长发放)';
+
+-- ----------------------------
+-- Table structure for conversation_summaries
+-- ----------------------------
+DROP TABLE IF EXISTS `conversation_summaries`;
+CREATE TABLE `conversation_summaries` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `summary_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '总结ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `start_round` int(11) NOT NULL COMMENT '起始轮次',
+  `end_round` int(11) NOT NULL COMMENT '结束轮次',
+  `summary_text` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '总结内容',
+  `summary_type` enum('auto','manual') COLLATE utf8mb4_unicode_ci DEFAULT 'auto' COMMENT '总结类型',
+  `message_count` int(11) DEFAULT NULL COMMENT '总结的消息数量',
+  `token_count` int(11) DEFAULT NULL COMMENT '总结文本Token数',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `summary_id` (`summary_id`) USING BTREE,
+  UNIQUE KEY `uk_user_round_range` (`user_id`,`start_round`,`end_round`) USING BTREE,
+  KEY `idx_user_id` (`user_id`) USING BTREE,
+  KEY `idx_user_end_round` (`user_id`,`end_round`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=69 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='对话总结表';
+
+-- ----------------------------
+-- Table structure for feedbacks
+-- ----------------------------
+DROP TABLE IF EXISTS `feedbacks`;
+CREATE TABLE `feedbacks` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `feedback_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '反馈ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '反馈内容',
+  `status` enum('pending','reviewed','resolved','closed') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending' COMMENT '反馈状态',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `feedback_id` (`feedback_id`),
+  KEY `idx_feedback_id` (`feedback_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_feedback_user_created` (`user_id`,`created_at`)
+) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户反馈表';
+
+-- ----------------------------
+-- Table structure for llm_prompts
+-- ----------------------------
+DROP TABLE IF EXISTS `llm_prompts`;
+CREATE TABLE `llm_prompts` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `prompt_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提示词记录ID',
+  `message_id` bigint(20) NOT NULL COMMENT '关联的消息ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `round_number` int(11) NOT NULL COMMENT '对话轮次',
+  `system_prompt` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统提示词(完整内容)',
+  `runtime_context` text COLLATE utf8mb4_unicode_ci COMMENT '运行时上下文',
+  `messages_json` json NOT NULL COMMENT '完整的messages数组(JSON格式)',
+  `model_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '模型名称',
+  `temperature` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '温度参数',
+  `max_tokens` int(11) DEFAULT NULL COMMENT '最大Token数',
+  `stream` tinyint(1) DEFAULT '1' COMMENT '是否流式输出',
+  `prompt_token_count` int(11) DEFAULT NULL COMMENT '提示词Token数',
+  `total_token_count` int(11) DEFAULT NULL COMMENT '总Token数(包含响应)',
+  `metadata_json` json DEFAULT NULL COMMENT '扩展信息(调用耗时、错误信息等)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `prompt_id` (`prompt_id`),
+  KEY `idx_prompt_id` (`prompt_id`),
+  KEY `idx_llm_prompt_message` (`message_id`),
+  KEY `idx_llm_prompt_user_round` (`user_id`,`round_number`),
+  KEY `idx_llm_prompt_created` (`created_at`),
+  CONSTRAINT `llm_prompts_ibfk_1` FOREIGN KEY (`message_id`) REFERENCES `messages` (`id`) ON DELETE CASCADE
+) ENGINE=InnoDB AUTO_INCREMENT=991 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='LLM提示词记录表';
+
+-- ----------------------------
+-- Table structure for messages
+-- ----------------------------
+DROP TABLE IF EXISTS `messages`;
+CREATE TABLE `messages` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `message_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息ID',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `role` enum('user','assistant','system') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色',
+  `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息内容',
+  `round_number` int(11) NOT NULL COMMENT '对话轮次(从1开始)',
+  `emotion` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '情感标签',
+  `message_metadata` json DEFAULT NULL COMMENT '扩展信息(音频路径、识别耗时等)',
+  `token_count` int(11) DEFAULT NULL COMMENT 'Token数量',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `message_id` (`message_id`) USING BTREE,
+  KEY `idx_user_id` (`user_id`) USING BTREE,
+  KEY `idx_user_round` (`user_id`,`round_number`) USING BTREE,
+  KEY `idx_created_at` (`created_at`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1988 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息表';
+
+-- ----------------------------
+-- Table structure for mission_energy_stones
+-- ----------------------------
+DROP TABLE IF EXISTS `mission_energy_stones`;
+CREATE TABLE `mission_energy_stones` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `stone_id` varchar(64) NOT NULL COMMENT '能量石ID',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `request_id` varchar(64) DEFAULT NULL COMMENT '触发请求ID',
+  `appear_reason` varchar(64) NOT NULL DEFAULT 'first_dialog' COMMENT '触发原因',
+  `mission_type` varchar(64) NOT NULL DEFAULT 'first_dialog' COMMENT '任务类型',
+  `appear_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '出现时间',
+  `clicked_at` timestamp NULL DEFAULT NULL COMMENT '点击时间',
+  `auto_removed` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否系统自动消失',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `uk_mission_stone_id` (`stone_id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=59 DEFAULT CHARSET=utf8mb4 COMMENT='任务能量石记录';
+
+-- ----------------------------
+-- Table structure for mission_types
+-- ----------------------------
+DROP TABLE IF EXISTS `mission_types`;
+CREATE TABLE `mission_types` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
+  `type_key` varchar(64) NOT NULL COMMENT '任务类型唯一标识',
+  `display_name` varchar(100) NOT NULL COMMENT '显示名称',
+  `description` varchar(255) DEFAULT NULL COMMENT '类型描述',
+  `is_repeatable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否允许重复触发',
+  `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
+  `metadata` json DEFAULT NULL COMMENT '扩展配置',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `uk_mission_type_key` (`type_key`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='任务类型配置';
+
+-- ----------------------------
+-- Table structure for page_visit_logs
+-- ----------------------------
+DROP TABLE IF EXISTS `page_visit_logs`;
+CREATE TABLE `page_visit_logs` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) DEFAULT NULL COMMENT '用户ID(未登录则为空)',
+  `path` varchar(512) NOT NULL COMMENT '访问路径',
+  `visited_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '访问时间',
+  PRIMARY KEY (`id`),
+  KEY `idx_page_visit_user_id` (`user_id`),
+  KEY `idx_page_visit_path` (`path`(255)),
+  KEY `idx_page_visit_visited_at` (`visited_at`)
+) ENGINE=InnoDB AUTO_INCREMENT=2238 DEFAULT CHARSET=utf8mb4 COMMENT='页面访问日志表';
+
+-- ----------------------------
+-- Table structure for share_images
+-- ----------------------------
+DROP TABLE IF EXISTS `share_images`;
+CREATE TABLE `share_images` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `code` varchar(64) NOT NULL,
+  `url` text NOT NULL,
+  `title` varchar(128) DEFAULT NULL,
+  `sort_order` int(11) NOT NULL DEFAULT '0',
+  `is_active` tinyint(1) NOT NULL DEFAULT '1',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `code` (`code`),
+  KEY `idx_share_images_sort` (`sort_order`)
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4;
+
+-- ----------------------------
+-- Table structure for user_avatars
+-- ----------------------------
+DROP TABLE IF EXISTS `user_avatars`;
+CREATE TABLE `user_avatars` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `avatar_id` varchar(64) NOT NULL COMMENT '形象ID',
+  `obtained_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
+  `obtained_type` enum('default','register','level_up','mission','purchase','gift','event') NOT NULL DEFAULT 'default' COMMENT '获得方式',
+  `obtained_source` varchar(64) DEFAULT NULL COMMENT '获得来源(等级/任务/订单等)',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_avatar` (`user_id`,`avatar_id`),
+  KEY `idx_user_avatar_user_obtained` (`user_id`,`obtained_at`),
+  KEY `idx_user_avatar_avatar_id` (`avatar_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=258 DEFAULT CHARSET=utf8mb4 COMMENT='用户已拥有的形象';
+
+-- ----------------------------
+-- Table structure for user_background_images
+-- ----------------------------
+DROP TABLE IF EXISTS `user_background_images`;
+CREATE TABLE `user_background_images` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `image_id` varchar(64) NOT NULL COMMENT '背景图片ID',
+  `obtained_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
+  `obtained_type` enum('default','level_up','purchase','mission','gift') NOT NULL DEFAULT 'default' COMMENT '获得方式',
+  `obtained_source` varchar(64) DEFAULT NULL COMMENT '获得来源(任务类型、等级等)',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_background_image` (`user_id`,`image_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_image_id` (`image_id`),
+  KEY `idx_user_obtained` (`user_id`,`obtained_at`)
+) ENGINE=InnoDB AUTO_INCREMENT=211 DEFAULT CHARSET=utf8mb4 COMMENT='用户背景图片关联表';
+
+-- ----------------------------
+-- Table structure for user_bonus_energy_stones
+-- ----------------------------
+DROP TABLE IF EXISTS `user_bonus_energy_stones`;
+CREATE TABLE `user_bonus_energy_stones` (
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID(关联 users.user_id)',
+  `bonus_conversation_count` int(11) NOT NULL DEFAULT '0' COMMENT '赠送的对话能量石个数',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`user_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户赠送对话能量石数量表';
+
+-- ----------------------------
+-- Table structure for user_community_choice
+-- ----------------------------
+DROP TABLE IF EXISTS `user_community_choice`;
+CREATE TABLE `user_community_choice` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `opted_out` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否选择退出早期共创社区',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `user_id` (`user_id`),
+  KEY `idx_user_id` (`user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=60 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户是否退出早期共创社区';
+
+-- ----------------------------
+-- Table structure for user_early_community
+-- ----------------------------
+DROP TABLE IF EXISTS `user_early_community`;
+CREATE TABLE `user_early_community` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID',
+  `email` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '加入早期共创时留的邮箱',
+  `popup_shown` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示过弹窗',
+  `joined_at` timestamp NULL DEFAULT NULL COMMENT '加入时间(留邮箱时)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `user_id` (`user_id`),
+  KEY `idx_user_id` (`user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=35 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户早期共创社区(手机号新用户留邮箱)';
+
+-- ----------------------------
+-- Table structure for user_intimacy
+-- ----------------------------
+DROP TABLE IF EXISTS `user_intimacy`;
+CREATE TABLE `user_intimacy` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `heart_count` int(11) NOT NULL DEFAULT '3' COMMENT '亲密度心数 0-3',
+  `last_visit_date_at_deduction` varchar(10) DEFAULT NULL COMMENT '上次扣心所依据的最后访问日期 YYYY-MM-DD',
+  `last_light_up_date` varchar(10) DEFAULT NULL COMMENT '上次手动点亮日期 YYYY-MM-DD,控制每天仅点亮一次',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_intimacy_user_id` (`user_id`),
+  KEY `idx_user_intimacy_user_id` (`user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=169 DEFAULT CHARSET=utf8mb4 COMMENT='用户亲密度表';
+
+-- ----------------------------
+-- Table structure for user_special_scenario_topic_logs
+-- ----------------------------
+DROP TABLE IF EXISTS `user_special_scenario_topic_logs`;
+CREATE TABLE `user_special_scenario_topic_logs` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户ID(关联 users.user_id)',
+  `scenario_key` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '场景: window|reading|eating',
+  `topic_key` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '话题键(如 weather|season|travel 等)',
+  `conversed_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '该场景该话题下首次对话时间',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_user_scenario_topic` (`user_id`,`scenario_key`,`topic_key`),
+  KEY `idx_user_scenario` (`user_id`,`scenario_key`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户情景话题对话记录(用于概率排除与不再触发)';
+
+-- ----------------------------
+-- Table structure for user_ui_states
+-- ----------------------------
+DROP TABLE IF EXISTS `user_ui_states`;
+CREATE TABLE `user_ui_states` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID(关联 users.user_id)',
+  `current_page` varchar(64) DEFAULT NULL COMMENT '当前业务流程页面标识',
+  `visited_routes` json DEFAULT NULL COMMENT '已访问路由列表',
+  `has_shown_tap_to_chat` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示 TapToChat 提示',
+  `has_shown_cancel_tooltip` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示取消按钮提示',
+  `has_shown_send_tooltip` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示发送按钮提示',
+  `has_shown_progress_bar_tooltip` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示进度条提示',
+  `avatar_c_unlocked` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已解锁 Avatar-C',
+  `has_shared_avatar` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已分享头像奖励',
+  `current_avatar_id` varchar(64) DEFAULT NULL COMMENT '当前使用的形象ID(关联 avatars.avatar_id)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  `has_shown_mission_stone_modal` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示过任务能量石弹窗',
+  `has_shown_conversation_stone_modal` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已展示过对话能量石弹窗',
+  `has_clicked_mission_stone` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已点击过任务能量石(控制光晕显示)',
+  `has_clicked_conversation_stone` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已点击过对话能量石(控制光晕显示)',
+  `subtitle_on` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否开启字幕显示',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `user_id` (`user_id`),
+  KEY `idx_user_ui_state_user_id` (`user_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=191 DEFAULT CHARSET=utf8mb4 COMMENT='用户前端可视化状态表';
+
+-- ----------------------------
+-- Table structure for users
+-- ----------------------------
+DROP TABLE IF EXISTS `users`;
+CREATE TABLE `users` (
+  `id` bigint(20) NOT NULL AUTO_INCREMENT,
+  `user_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户唯一标识(UUID)',
+  `username` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户名/昵称',
+  `user_type` enum('guest','registered') COLLATE utf8mb4_unicode_ci DEFAULT 'guest' COMMENT '用户类型',
+  `area_code` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机区号(如 +86)',
+  `bot_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '用户给机器人取的名字',
+  `phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号',
+  `email` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
+  `wechat_openid` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信OpenID',
+  `qq_openid` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'QQ OpenID',
+  `qq_unionid` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'QQ UnionID',
+  `google_email` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Google邮箱',
+  `is_verified` tinyint(1) DEFAULT '0' COMMENT '是否已认证',
+  `registered_at` timestamp NULL DEFAULT NULL COMMENT '注册时间',
+  `total_messages` int(11) DEFAULT '0' COMMENT '总消息数',
+  `total_rounds` int(11) DEFAULT '0' COMMENT '总对话轮次',
+  `total_speech_duration_ms` bigint(20) DEFAULT '0' COMMENT '累积说话时长(毫秒)',
+  `user_level` int(11) NOT NULL DEFAULT '0' COMMENT '用户等级(0-5)',
+  `preferred_language` varchar(8) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '首选语言(zh/en)',
+  `last_active_at` timestamp NULL DEFAULT NULL COMMENT '最后活跃时间',
+  `current_background_image_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前使用的背景图片ID',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`) USING BTREE,
+  UNIQUE KEY `user_id` (`user_id`) USING BTREE,
+  UNIQUE KEY `phone` (`phone`) USING BTREE,
+  UNIQUE KEY `wechat_openid` (`wechat_openid`) USING BTREE,
+  UNIQUE KEY `google_email` (`google_email`) USING BTREE,
+  UNIQUE KEY `email` (`email`),
+  UNIQUE KEY `qq_openid` (`qq_openid`),
+  UNIQUE KEY `qq_unionid` (`qq_unionid`),
+  KEY `idx_user_id` (`user_id`) USING BTREE,
+  KEY `idx_phone` (`phone`) USING BTREE,
+  KEY `idx_wechat_openid` (`wechat_openid`) USING BTREE,
+  KEY `idx_google_email` (`google_email`) USING BTREE,
+  KEY `idx_user_type` (`user_type`) USING BTREE,
+  KEY `idx_email` (`email`),
+  KEY `idx_qq_openid` (`qq_openid`),
+  KEY `idx_qq_unionid` (`qq_unionid`)
+) ENGINE=InnoDB AUTO_INCREMENT=191 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
+
+-- ----------------------------
+-- Table structure for verification_codes
+-- ----------------------------
+DROP TABLE IF EXISTS `verification_codes`;
+CREATE TABLE `verification_codes` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `code_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '验证码ID',
+  `area_code` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机区号(如 +86)',
+  `phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号',
+  `email` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
+  `code` varchar(10) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '验证码',
+  `send_type` enum('domestic_sms','international_sms','email') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送类型:国内短信、国外短信、邮箱',
+  `is_sent` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否已发送',
+  `is_valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否有效',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `sent_at` timestamp NULL DEFAULT NULL COMMENT '发送时间',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息(发送结果、错误信息等)',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `code_id` (`code_id`),
+  KEY `idx_code_id` (`code_id`),
+  KEY `idx_verification_code_phone` (`phone`,`is_valid`,`created_at`),
+  KEY `idx_verification_code_email` (`email`,`is_valid`,`created_at`),
+  KEY `idx_verification_code_send_type` (`send_type`,`is_sent`,`is_valid`)
+) ENGINE=InnoDB AUTO_INCREMENT=229 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='验证码表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 18 - 0
backend/init_database.py

@@ -215,6 +215,21 @@ CREATE TABLE IF NOT EXISTS user_bonus_energy_stones (
 COMMENT='用户赠送对话能量石数量表'
 """
 
+# 用户情景话题对话记录表(情景对话模式)
+CREATE_USER_SPECIAL_SCENARIO_TOPIC_LOGS_TABLE = """
+CREATE TABLE IF NOT EXISTS user_special_scenario_topic_logs (
+    id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+    user_id VARCHAR(64) NOT NULL COMMENT '用户ID(关联 users.user_id)',
+    scenario_key VARCHAR(32) NOT NULL COMMENT '场景: window|reading|eating',
+    topic_key VARCHAR(32) NOT NULL COMMENT '话题键',
+    conversed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '该场景该话题下首次对话时间',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
+    UNIQUE KEY uk_user_scenario_topic (user_id, scenario_key, topic_key),
+    INDEX idx_user_scenario (user_id, scenario_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+COMMENT='用户情景话题对话记录(用于概率排除与不再触发)'
+"""
+
 
 async def init_tables():
     """初始化数据库表"""
@@ -255,6 +270,9 @@ async def init_tables():
             # 创建用户赠送对话能量石表
             await cursor.execute(CREATE_USER_BONUS_ENERGY_STONES_TABLE)
 
+            # 创建用户情景话题对话记录表
+            await cursor.execute(CREATE_USER_SPECIAL_SCENARIO_TOPIC_LOGS_TABLE)
+
             # 写入默认任务类型
             await cursor.execute(
                 """

+ 214 - 96
backend/main.py

@@ -1,6 +1,8 @@
 """
 主应用入口
 """
+from __future__ import annotations
+
 import json
 import logging
 import urllib.parse
@@ -10,12 +12,13 @@ import random
 from datetime import datetime, timedelta
 from typing import Optional, List, Dict, Any
 from fastapi import FastAPI, WebSocket, HTTPException, Request, Query, Header, Depends
-from fastapi.responses import RedirectResponse, PlainTextResponse
+from fastapi.exceptions import RequestValidationError
+from fastapi.responses import RedirectResponse, PlainTextResponse, JSONResponse
 from fastapi.middleware.cors import CORSMiddleware
 from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.requests import Request as StarletteRequest
 from starlette.responses import Response
-from pydantic import BaseModel
+from pydantic import BaseModel, Field, field_validator, ValidationError as PydanticValidationError
 from contextlib import asynccontextmanager
 import httpx
 from xml.etree import ElementTree as ET
@@ -211,16 +214,186 @@ app.add_middleware(
 app.add_middleware(APIKeyMiddleware)
 
 
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
+    """422 时在服务端打印校验错误,便于排查(如 user_events 缺失字段)"""
+    errors = exc.errors()
+    for err in errors:
+        logger.warning("422 校验失败: loc=%s msg=%s type=%s", err.get("loc"), err.get("msg"), err.get("type"))
+    return JSONResponse(status_code=422, content={"detail": errors})
+
+
+async def require_user_session_from_headers(
+    request: Request,
+    header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
+    token: Optional[str] = Header(None, alias="X-User-Token"),
+) -> str:
+    """仅根据 X-User-Id + X-User-Token 校验会话,返回 user_id(用于无 path user_id 的接口如 POST /api/user-events)"""
+    if not header_user_id or not token:
+        logger.warning(
+            "require_user_session_from_headers: missing headers - path=%s",
+            request.url.path,
+        )
+        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
+    session_store = getattr(request.app.state, "session_store", None)
+    if not session_store:
+        logger.error(
+            "require_user_session_from_headers: SESSION_NOT_INITIALIZED - path=%s",
+            request.url.path,
+        )
+        raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
+    is_valid = await session_store.validate(header_user_id, token, refresh=True)
+    if not is_valid:
+        logger.warning(
+            "require_user_session_from_headers: SESSION_EXPIRED - path=%s, user_id=%s",
+            request.url.path,
+            header_user_id,
+        )
+        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
+    return header_user_id
+
+
+async def require_user_session(
+    request: Request,
+    user_id: str,
+    header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
+    token: Optional[str] = Header(None, alias="X-User-Token"),
+) -> None:
+    """
+    基于 user_id + token 的简单 HTTP 鉴权,复用 WebSocket 的 session_store。
+    仅用于需要用户身份的业务接口,保持单用户单活语义。
+    """
+    if not header_user_id or header_user_id != user_id:
+        logger.warning(
+            "require_user_session: UNAUTHORIZED_USER_MISMATCH - path=%s, param_user_id=%s, header_user_id=%s",
+            request.url.path,
+            user_id,
+            header_user_id,
+        )
+        raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
+
+    session_store = getattr(request.app.state, "session_store", None)
+    if not session_store:
+        logger.error(
+            "require_user_session: SESSION_NOT_INITIALIZED - path=%s, user_id=%s",
+            request.url.path,
+            header_user_id,
+        )
+        raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
+
+    if not token:
+        logger.warning(
+            "require_user_session: missing X-User-Token header - path=%s, user_id=%s",
+            request.url.path,
+            header_user_id,
+        )
+        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
+
+    is_valid = await session_store.validate(user_id, token, refresh=True)
+    if not is_valid:
+        try:
+            entry = await session_store.get_current_token(user_id)
+        except Exception as diag_exc:  # noqa: BLE001
+            logger.warning(
+                "require_user_session: validate failed and get_current_token raised - path=%s, user_id=%s, token_prefix=%s, error=%r",
+                request.url.path,
+                user_id,
+                str(token)[:8],
+                diag_exc,
+            )
+            raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
+
+        if entry is None:
+            logger.warning(
+                "require_user_session: SESSION_EXPIRED - no active session entry - path=%s, user_id=%s, token_prefix=%s",
+                request.url.path,
+                user_id,
+                str(token)[:8],
+            )
+        else:
+            try:
+                expires_iso = datetime.fromtimestamp(entry.expires_at).isoformat()
+            except Exception:
+                expires_iso = str(entry.expires_at)
+            logger.warning(
+                "require_user_session: SESSION_EXPIRED - token mismatch or expired - path=%s, user_id=%s, header_token_prefix=%s, stored_token_prefix=%s, stored_expires_at=%s, now=%s",
+                request.url.path,
+                user_id,
+                str(token)[:8],
+                str(entry.token)[:8],
+                expires_iso,
+                datetime.utcnow().isoformat(),
+            )
+
+        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
+
+
+async def optional_user_id(
+    request: Request,
+    header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
+    token: Optional[str] = Header(None, alias="X-User-Token"),
+) -> Optional[str]:
+    """可选鉴权:有且有效则返回 user_id,否则返回 None(用于页面访问等不强制登录的接口)"""
+    if not header_user_id or not token:
+        return None
+    session_store = getattr(request.app.state, "session_store", None)
+    if not session_store or not await session_store.validate(header_user_id, token, refresh=False):
+        return None
+    return header_user_id
+
+
 @app.get("/")
 async def root():
     """健康检查"""
     return {"status": "ok", "message": "Lyrria Backend API"}
 
 
+@app.post("/api/user-events")
+async def create_user_event(
+    http_request: Request,
+    auth_user_id: str = Depends(require_user_session_from_headers),
+) -> Dict[str, Any]:
+    """
+    前端上报单条用户事件
+
+    - 主要用于页面进入、按钮点击等埋点
+    - event_name 必须遵循 .cursorrules 中的“埋点事件命名规范”
+    - 从 body 手动读取并校验,避免与依赖中的 Request 冲突导致 body 被当成 query
+    """
+    try:
+        body = await http_request.json()
+    except Exception as e:
+        logger.warning("user_events 读取 body 失败: %s", e)
+        raise HTTPException(status_code=422, detail=[{"type": "value_error", "loc": ("body",), "msg": "Invalid JSON body"}])
+    try:
+        payload = UserEventPayload.model_validate(body)
+    except PydanticValidationError as val_err:
+        logger.warning("user_events 校验失败: %s", val_err.errors())
+        raise RequestValidationError(val_err.errors()) from val_err
+    if payload.user_id != auth_user_id:
+        raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
+    try:
+        ok = await user_service.save_user_event(payload.model_dump())
+        if not ok:
+            logger.warning("user_events 落库返回 False: event_id=%s, event_name=%s", payload.event_id, payload.event_name)
+            raise HTTPException(status_code=500, detail="failed_to_save_user_event")
+        logger.info("user_events 已写入: event_name=%s, user_id=%s", payload.event_name, payload.user_id)
+        return {"success": True}
+    except HTTPException:
+        raise
+    except Exception as e:  # noqa: BLE001
+        logger.error(f"❌ 保存用户事件失败: event_id={payload.event_id}, error={e}")
+        raise HTTPException(status_code=500, detail="failed_to_save_user_event") from e
+
+
 class CreateUserRequest(BaseModel):
     """创建用户请求"""
-    username: str = ""
-    bot_name: Optional[str] = None
+    username: str = Field("", description="用户名/昵称,可为空字符串")
+    bot_name: Optional[str] = Field(None, description="机器人名字,可选")
+    user_segment: str = Field("individual", description="用户分群:individual/school/internal等")
+    org_id: Optional[str] = Field(None, description="组织ID(学校/机构用户)")
+    is_test_user: bool = Field(False, description="是否测试账号")
+    region: Optional[str] = Field(None, description="国家/地区代码,如 CN/US")
 
 
 class UpdateUsernameRequest(BaseModel):
@@ -238,6 +411,38 @@ class UpdateBackgroundImageIdRequest(BaseModel):
     background_image_id: str
 
 
+class UserEventPayload(BaseModel):
+    """前端上报用户事件 payload(单条)"""
+
+    event_id: str = Field(..., description="事件ID,客户端或服务端生成,需全局唯一")
+    user_id: str = Field(..., description="用户ID(关联 users.user_id)")
+    event_name: str = Field(..., description="事件代码,如 page.chatbot__enter(遵循埋点命名规范)")
+    event_label: Optional[str] = Field(None, description="事件中文名称/描述")
+    event_time: datetime = Field(..., description="事件发生时间(UTC)")
+    session_id: Optional[str] = Field(None, description="会话ID,同一次前台停留共享")
+    platform: str = Field(..., description="平台:ios/android/web")
+    app_version: Optional[str] = Field(None, description="应用版本号,如 v0.1.x")
+    timezone_offset_minutes: Optional[int] = Field(
+        None,
+        description="用户本地相对UTC的分钟偏移量(分钟)",
+    )
+    properties: Optional[Dict[str, Any]] = Field(
+        None,
+        description="扩展属性(page、entry、button_id、duration_seconds 等)",
+    )
+
+    @field_validator("event_time", mode="before")
+    @classmethod
+    def parse_event_time(cls, v: Any) -> datetime:
+        """接受 ISO 字符串(含 Z 后缀),避免 422"""
+        if isinstance(v, datetime):
+            return v
+        if isinstance(v, str):
+            s = v.strip().replace("Z", "+00:00")
+            return datetime.fromisoformat(s)
+        raise ValueError("event_time 需为 datetime 或 ISO 字符串")
+
+
 class RegisterPhoneRequest(BaseModel):
     """手机号注册请求"""
     area_code: Optional[str] = None
@@ -382,97 +587,6 @@ class AvatarGifSet(BaseModel):
     talking: str
 
 
-async def require_user_session(
-    request: Request,
-    user_id: str,
-    header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
-    token: Optional[str] = Header(None, alias="X-User-Token"),
-) -> None:
-    """
-    基于 user_id + token 的简单 HTTP 鉴权,复用 WebSocket 的 session_store。
-    仅用于需要用户身份的业务接口,保持单用户单活语义。
-    """
-    if not header_user_id or header_user_id != user_id:
-        logger.warning(
-            "require_user_session: UNAUTHORIZED_USER_MISMATCH - path=%s, param_user_id=%s, header_user_id=%s",
-            request.url.path,
-            user_id,
-            header_user_id,
-        )
-        raise HTTPException(status_code=401, detail={"code": UNAUTHORIZED_USER_MISMATCH})
-
-    session_store = getattr(request.app.state, "session_store", None)
-    if not session_store:
-        logger.error(
-            "require_user_session: SESSION_NOT_INITIALIZED - path=%s, user_id=%s",
-            request.url.path,
-            header_user_id,
-        )
-        raise HTTPException(status_code=500, detail={"code": SESSION_NOT_INITIALIZED})
-
-    if not token:
-        logger.warning(
-            "require_user_session: missing X-User-Token header - path=%s, user_id=%s",
-            request.url.path,
-            header_user_id,
-        )
-        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
-
-    is_valid = await session_store.validate(user_id, token, refresh=True)
-    if not is_valid:
-        # 额外诊断日志:尝试读取当前会话,帮助排查是会话不存在、已过期还是 token 不匹配
-        try:
-            entry = await session_store.get_current_token(user_id)
-        except Exception as diag_exc:  # noqa: BLE001
-            logger.warning(
-                "require_user_session: validate failed and get_current_token raised - path=%s, user_id=%s, token_prefix=%s, error=%r",
-                request.url.path,
-                user_id,
-                str(token)[:8],
-                diag_exc,
-            )
-            raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
-
-        if entry is None:
-            logger.warning(
-                "require_user_session: SESSION_EXPIRED - no active session entry - path=%s, user_id=%s, token_prefix=%s",
-                request.url.path,
-                user_id,
-                str(token)[:8],
-            )
-        else:
-            try:
-                # entry.expires_at 是 Unix 时间戳(秒)
-                expires_iso = datetime.fromtimestamp(entry.expires_at).isoformat()
-            except Exception:
-                expires_iso = str(entry.expires_at)
-            logger.warning(
-                "require_user_session: SESSION_EXPIRED - token mismatch or expired - path=%s, user_id=%s, header_token_prefix=%s, stored_token_prefix=%s, stored_expires_at=%s, now=%s",
-                request.url.path,
-                user_id,
-                str(token)[:8],
-                str(entry.token)[:8],
-                expires_iso,
-                datetime.utcnow().isoformat(),
-            )
-
-        raise HTTPException(status_code=401, detail={"code": SESSION_EXPIRED})
-
-
-async def optional_user_id(
-    request: Request,
-    header_user_id: Optional[str] = Header(None, alias="X-User-Id"),
-    token: Optional[str] = Header(None, alias="X-User-Token"),
-) -> Optional[str]:
-    """可选鉴权:有且有效则返回 user_id,否则返回 None(用于页面访问等不强制登录的接口)"""
-    if not header_user_id or not token:
-        return None
-    session_store = getattr(request.app.state, "session_store", None)
-    if not session_store or not await session_store.validate(header_user_id, token, refresh=False):
-        return None
-    return header_user_id
-
-
 async def _generate_session_token(user_id: str) -> Dict[str, Any]:
     """
     为指定用户生成会话 token(复用 init_session 的逻辑)
@@ -533,7 +647,11 @@ async def create_user(request: CreateUserRequest):
     try:
         user = await user_service.create_guest_user(
             username=request.username,
-            bot_name=request.bot_name
+            bot_name=request.bot_name,
+            user_segment=request.user_segment,
+            org_id=request.org_id,
+            is_test_user=request.is_test_user,
+            region=request.region,
         )
         return {
             "success": True,

+ 66 - 0
backend/migrations/add_user_segment_org_and_user_events.sql

@@ -0,0 +1,66 @@
+/*
+  用户分群与事件表改造 SQL(MySQL 5.7)
+  - 为 users 表增加分群与组织等字段
+  - 新增 organizations 组织表
+  - 新增 user_events 用户事件表
+*/
+
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- 1. users 表新增字段(用户分群 / 组织 / 测试账号 / 地区)
+ALTER TABLE `users`
+  ADD COLUMN `user_segment` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'individual' COMMENT '用户/场景分群:individual/school/internal等' AFTER `user_type`,
+  ADD COLUMN `org_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '组织ID(关联 organizations.org_id)' AFTER `user_segment`,
+  ADD COLUMN `is_test_user` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否测试账号' AFTER `org_id`,
+  ADD COLUMN `region` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国家/地区(如 CN、US)' AFTER `is_test_user`,
+  ADD KEY `idx_user_segment` (`user_segment`),
+  ADD KEY `idx_org_id` (`org_id`),
+  ADD KEY `idx_is_test_user` (`is_test_user`),
+  ADD KEY `idx_region` (`region`);
+
+-- 2. 新增组织表 organizations
+DROP TABLE IF EXISTS `organizations`;
+CREATE TABLE `organizations` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `org_id` varchar(64) NOT NULL COMMENT '组织唯一标识',
+  `org_name` varchar(200) NOT NULL COMMENT '组织名称',
+  `org_type` varchar(32) DEFAULT NULL COMMENT '组织类型:school/institution/internal等',
+  `region` varchar(32) DEFAULT NULL COMMENT '组织所在地区',
+  `metadata` json DEFAULT NULL COMMENT '扩展信息',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_org_id` (`org_id`),
+  KEY `idx_org_id` (`org_id`),
+  KEY `idx_org_type` (`org_type`),
+  KEY `idx_org_region` (`region`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织表';
+
+-- 3. 新增用户事件表 user_events
+DROP TABLE IF EXISTS `user_events`;
+CREATE TABLE `user_events` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `event_id` varchar(64) NOT NULL COMMENT '事件ID(客户端或服务端生成,唯一)',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID(关联 users.user_id)',
+  `event_name` varchar(64) NOT NULL COMMENT '事件代码,如 page.chatbot__enter(遵循埋点命名规范)',
+  `event_label` varchar(128) DEFAULT NULL COMMENT '事件中文名称/描述(如“进入聊天页”、“欢迎页开始按钮点击”)',
+  `event_time` timestamp NOT NULL COMMENT '事件发生时间(UTC)',
+  `session_id` varchar(64) DEFAULT NULL COMMENT '会话ID,同一次前台停留共享',
+  `platform` varchar(16) NOT NULL COMMENT '平台:ios/android/web',
+  `app_version` varchar(32) DEFAULT NULL COMMENT '应用版本号,如 v0.1.x',
+  `timezone_offset_minutes` int(11) DEFAULT NULL COMMENT '用户本地相对UTC的分钟偏移量(分钟)',
+  `properties` json DEFAULT NULL COMMENT '扩展属性(page、entry、button_id、duration_seconds 等)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_event_id` (`event_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_event_time` (`event_time`),
+  KEY `idx_event_name` (`event_name`),
+  KEY `idx_user_event_time` (`user_id`,`event_time`),
+  KEY `idx_session_id` (`session_id`),
+  KEY `idx_event_time_name` (`event_time`,`event_name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户事件表';
+
+SET FOREIGN_KEY_CHECKS = 1;
+

+ 5 - 0
backend/models.py

@@ -69,6 +69,9 @@ class AudioChunkMessage(BaseMessage):
     channels: int = 1
     bitDepth: int = 16
     chunkIndex: int
+    # 情景对话:首条语音时携带当前场景/话题,供后端落库
+    scene: Optional[str] = None
+    topic: Optional[str] = None
 
 
 class AudioEndMessage(BaseMessage):
@@ -76,6 +79,8 @@ class AudioEndMessage(BaseMessage):
     type: Literal["audio_end"] = "audio_end"
     totalChunks: int
     speechDuration: Optional[int] = None  # 说话时长(毫秒)
+    scene: Optional[str] = None
+    topic: Optional[str] = None
 
 
 class CancelMessage(BaseMessage):

+ 107 - 0
backend/models_db.py

@@ -35,6 +35,10 @@ class User(Base):
     # 基本信息
     username = Column(String(100), nullable=False, comment="用户名/昵称")
     user_type = Column(Enum("guest", "registered", name="user_type"), default="guest", comment="用户类型")
+    user_segment = Column(String(32), nullable=False, default="individual", comment="用户/场景分群:individual/school/internal等")
+    org_id = Column(String(64), nullable=True, comment="组织ID(关联 organizations.org_id)")
+    is_test_user = Column(Boolean, nullable=False, default=False, comment="是否测试账号")
+    region = Column(String(32), nullable=True, comment="国家/地区(如 CN、US)")
     bot_name = Column(String(100), nullable=True, comment="用户给机器人取的名字")
     
     # 注册信息(可选)
@@ -70,6 +74,10 @@ class User(Base):
             "user_id": self.user_id,
             "username": self.username,
             "user_type": self.user_type,
+            "user_segment": self.user_segment,
+            "org_id": self.org_id,
+            "is_test_user": self.is_test_user,
+            "region": self.region,
             "bot_name": self.bot_name,
             "area_code": self.area_code,
             "phone": self.phone,
@@ -91,6 +99,38 @@ class User(Base):
         }
 
 
+class Organization(Base):
+    """组织表模型"""
+
+    __tablename__ = "organizations"
+
+    id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
+    org_id = Column(String(64), nullable=False, unique=True, index=True, comment="组织唯一标识")
+    org_name = Column(String(200), nullable=False, comment="组织名称")
+    org_type = Column(String(32), nullable=True, index=True, comment="组织类型:school/institution/internal等")
+    region = Column(String(32), nullable=True, index=True, comment="组织所在地区")
+    metadata_json = Column(JSON, nullable=True, comment="扩展信息")
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="创建时间")
+    updated_at = Column(
+        TIMESTAMP,
+        server_default=func.current_timestamp(),
+        onupdate=func.current_timestamp(),
+        nullable=False,
+        comment="更新时间",
+    )
+
+    def to_dict(self) -> dict:
+        return {
+            "org_id": self.org_id,
+            "org_name": self.org_name,
+            "org_type": self.org_type,
+            "region": self.region,
+            "metadata": self.metadata_json,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+        }
+
+
 class Message(Base):
     """消息表模型"""
     __tablename__ = "messages"
@@ -414,6 +454,73 @@ class PageVisitLog(Base):
         }
 
 
+class UserEvent(Base):
+    """用户事件表(前端埋点:页面/按钮/操作等)"""
+
+    __tablename__ = "user_events"
+
+    id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
+    event_id = Column(String(64), nullable=False, unique=True, index=True, comment="事件ID(客户端或服务端生成,唯一)")
+    user_id = Column(String(64), nullable=False, index=True, comment="用户ID(关联 users.user_id)")
+    event_name = Column(String(64), nullable=False, index=True, comment="事件代码,如 page.chatbot__enter(遵循埋点命名规范)")
+    event_label = Column(String(128), nullable=True, comment="事件中文名称/描述(如“进入聊天页”、“欢迎页开始按钮点击”)")
+    event_time = Column(TIMESTAMP, nullable=False, comment="事件发生时间(UTC)")
+    session_id = Column(String(64), nullable=True, index=True, comment="会话ID,同一次前台停留共享")
+    platform = Column(String(16), nullable=False, comment="平台:ios/android/web")
+    app_version = Column(String(32), nullable=True, comment="应用版本号,如 v0.1.x")
+    timezone_offset_minutes = Column(Integer, nullable=True, comment="用户本地相对UTC的分钟偏移量(分钟)")
+    properties = Column(JSON, nullable=True, comment="扩展属性(page、entry、button_id、duration_seconds 等)")
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="记录创建时间")
+
+    __table_args__ = (
+        Index("idx_user_event_time", "user_id", "event_time"),
+        Index("idx_event_time_name", "event_time", "event_name"),
+    )
+
+    def to_dict(self) -> dict:
+        return {
+            "event_id": self.event_id,
+            "user_id": self.user_id,
+            "event_name": self.event_name,
+            "event_label": self.event_label,
+            "event_time": self.event_time.isoformat() if self.event_time else None,
+            "session_id": self.session_id,
+            "platform": self.platform,
+            "app_version": self.app_version,
+            "timezone_offset_minutes": self.timezone_offset_minutes,
+            "properties": self.properties,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+
+
+class UserSpecialScenarioTopicLog(Base):
+    """用户情景话题对话记录(情景对话模式:用于概率排除与不再触发)"""
+
+    __tablename__ = "user_special_scenario_topic_logs"
+
+    id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
+    user_id = Column(String(64), nullable=False, index=True, comment="用户ID(关联 users.user_id)")
+    scenario_key = Column(String(32), nullable=False, index=True, comment="场景: window|reading|eating")
+    topic_key = Column(String(32), nullable=False, index=True, comment="话题键")
+    conversed_at = Column(TIMESTAMP, nullable=False, server_default=func.current_timestamp(), comment="该场景该话题下首次对话时间")
+    created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="记录创建时间")
+
+    __table_args__ = (
+        UniqueConstraint("user_id", "scenario_key", "topic_key", name="uk_user_scenario_topic"),
+        Index("idx_user_scenario", "user_id", "scenario_key"),
+    )
+
+    def to_dict(self) -> dict:
+        return {
+            "id": self.id,
+            "user_id": self.user_id,
+            "scenario_key": self.scenario_key,
+            "topic_key": self.topic_key,
+            "conversed_at": self.conversed_at.isoformat() if self.conversed_at else None,
+            "created_at": self.created_at.isoformat() if self.created_at else None,
+        }
+
+
 class UserIntimacy(Base):
     """用户亲密度表(三颗心,0-3)"""
 

+ 69 - 0
backend/scenario_config.py

@@ -0,0 +1,69 @@
+"""
+情景对话模式:场景与话题常量及首句模板
+参考:项目文档/情景对话模式设计与改造方案.md
+"""
+from typing import Dict, List, Tuple
+
+# 场景键
+SCENE_DEFAULT = "default"
+SCENE_WINDOW = "window"   # Lumi在窗前看风景
+SCENE_READING = "reading"  # Lumi在桌前看书
+SCENE_EATING = "eating"    # Lumi在桌前吃饭
+
+# 场景权重(用于加权随机):default 70%, 三个特殊各 10%
+SCENE_WEIGHTS: List[Tuple[str, int]] = [
+    (SCENE_DEFAULT, 70),
+    (SCENE_WINDOW, 10),
+    (SCENE_READING, 10),
+    (SCENE_EATING, 10),
+]
+
+# 每个特殊场景下的 3 个话题 key
+SCENARIO_TOPICS: Dict[str, List[str]] = {
+    SCENE_WINDOW: ["weather", "season", "travel"],
+    SCENE_READING: ["reading", "hobbies", "relaxation"],
+    SCENE_EATING: ["favourite_food", "hometown_food", "daily_meal"],
+}
+
+# 话题展示名 (labelEn | labelZh),用于 greeting_context.topics
+TOPIC_LABELS: Dict[str, Dict[str, str]] = {
+    "weather": {"labelEn": "Weather", "labelZh": "天气"},
+    "season": {"labelEn": "Season", "labelZh": "季节"},
+    "travel": {"labelEn": "Travel", "labelZh": "旅行"},
+    "reading": {"labelEn": "Reading", "labelZh": "阅读"},
+    "hobbies": {"labelEn": "Hobbies", "labelZh": "爱好"},
+    "relaxation": {"labelEn": "Relaxation", "labelZh": "放松"},
+    "favourite_food": {"labelEn": "Favourite food", "labelZh": "喜欢的食物"},
+    "hometown_food": {"labelEn": "Hometown food", "labelZh": "家乡的食物"},
+    "daily_meal": {"labelEn": "Daily meal", "labelZh": "日常饮食"},
+}
+
+# 首句模板:{name} 替换为用户名
+SPECIAL_TOPIC_FIRST_LINES: Dict[str, str] = {
+    "weather": "Hi, {name}! The sky is so clear today. How is the weather at your place?",
+    "season": "Hi {name}! The leaves are changing colour. It's fall. What is your favorite season?",
+    "travel": "Hi, {name}! I want to go somewhere far away. Where do you want to travel?",
+    "reading": "Hi, {name}! I am reading. This story is crazy! What book are you reading lately?",
+    "hobbies": "Hi, {name}! I am reading. I love it! What do you do in your free time?",
+    "relaxation": "Hi, {name}! I am reading. It is the only way to relax my brain. What is your way to escape?",
+    "favourite_food": "Hi, {name}! I am having snacks. It's so delicious. What is your favorite food?",
+    "hometown_food": "Hi, {name}! I am having snacks. It's so delicious. What is your hometown's best dish?",
+    "daily_meal": "Hi, {name}! I am having snacks. It's so delicious. What you ate today?",
+}
+
+
+def get_special_first_line(topic_key: str, user_name: str) -> str:
+    """获取特殊场景某话题的首句(已替换用户名)。"""
+    user_name = (user_name or "").strip() or "Neo"
+    template = SPECIAL_TOPIC_FIRST_LINES.get(topic_key)
+    if not template:
+        return f"Hi, {user_name}! How are you today?"
+    return template.format(name=user_name)
+
+
+def build_topics_payload(available_topic_keys: List[str]) -> List[Dict[str, str]]:
+    """构建 greeting_context.topics 数组:[{ key, labelEn, labelZh }]。"""
+    return [
+        {"key": k, **TOPIC_LABELS.get(k, {"labelEn": k, "labelZh": k})}
+        for k in available_topic_keys
+    ]

+ 3 - 0
backend/services/__init__.py

@@ -5,6 +5,7 @@ from .message_service import MessageService
 from .context_service import ContextService
 from .summary_service import SummaryService
 from .energy_stone_service import EnergyStoneService, energy_stone_service
+from .scenario_topic_service import ScenarioTopicService, scenario_topic_service
 
 __all__ = [
     'MessageService',
@@ -12,5 +13,7 @@ __all__ = [
     'SummaryService',
     'EnergyStoneService',
     'energy_stone_service',
+    'ScenarioTopicService',
+    'scenario_topic_service',
 ]
 

+ 87 - 0
backend/services/scenario_topic_service.py

@@ -0,0 +1,87 @@
+"""
+情景话题对话记录服务:查询已完成话题、写入对话记录
+"""
+import logging
+from typing import Dict, List
+
+from sqlalchemy import select, func
+from sqlalchemy.exc import IntegrityError
+
+from database import db_manager
+from models_db import UserSpecialScenarioTopicLog
+
+logger = logging.getLogger(__name__)
+
+
+class ScenarioTopicService:
+    """用户情景话题对话记录"""
+
+    def __init__(self):
+        self.db = db_manager
+
+    async def set_db_manager(self, manager):
+        self.db = manager
+
+    async def get_completed_count_per_scenario(self, user_id: str) -> Dict[str, int]:
+        """
+        按场景统计该用户已完成(已对话)的话题数。
+        Returns: {"window": 2, "reading": 0, "eating": 3},仅包含特殊场景。
+        """
+        try:
+            async with self.db.get_session() as session:
+                result = await session.execute(
+                    select(
+                        UserSpecialScenarioTopicLog.scenario_key,
+                        func.count(UserSpecialScenarioTopicLog.id).label("cnt"),
+                    )
+                    .where(UserSpecialScenarioTopicLog.user_id == user_id)
+                    .group_by(UserSpecialScenarioTopicLog.scenario_key)
+                )
+                rows = result.all()
+            return {row.scenario_key: row.cnt for row in rows}
+        except Exception as e:
+            logger.warning(f"[ScenarioTopic] get_completed_count_per_scenario failed: user_id={user_id}, e={e}")
+            return {}
+
+    async def get_done_topic_keys(self, user_id: str, scenario_key: str) -> List[str]:
+        """获取该用户在该场景下已对话过的话题 key 列表。"""
+        try:
+            async with self.db.get_session() as session:
+                result = await session.execute(
+                    select(UserSpecialScenarioTopicLog.topic_key).where(
+                        UserSpecialScenarioTopicLog.user_id == user_id,
+                        UserSpecialScenarioTopicLog.scenario_key == scenario_key,
+                    )
+                )
+                rows = result.all()
+            return [r.topic_key for r in rows]
+        except Exception as e:
+            logger.warning(f"[ScenarioTopic] get_done_topic_keys failed: user_id={user_id}, scenario={scenario_key}, e={e}")
+            return []
+
+    async def record_conversation(self, user_id: str, scenario_key: str, topic_key: str) -> bool:
+        """
+        记录用户在该场景该话题下进行了对话。同一 (user_id, scenario_key, topic_key) 只插入一次。
+        """
+        if not user_id or not scenario_key or not topic_key:
+            return False
+        try:
+            async with self.db.get_session() as session:
+                log = UserSpecialScenarioTopicLog(
+                    user_id=user_id,
+                    scenario_key=scenario_key,
+                    topic_key=topic_key,
+                )
+                session.add(log)
+                await session.commit()
+            logger.info(f"[ScenarioTopic] recorded: user_id={user_id}, scenario={scenario_key}, topic={topic_key}")
+            return True
+        except IntegrityError:
+            # 唯一约束冲突,说明已记录过,视为成功
+            return True
+        except Exception as e:
+            logger.warning(f"[ScenarioTopic] record_conversation failed: user_id={user_id}, scenario={scenario_key}, topic={topic_key}, e={e}")
+            return False
+
+
+scenario_topic_service = ScenarioTopicService()

+ 60 - 2
backend/services/user_service.py

@@ -11,7 +11,19 @@ from sqlalchemy import select, update, func, insert, desc
 from sqlalchemy.ext.asyncio import AsyncSession
 from database import db_manager
 from config import settings
-from models_db import User, UserUIState, UserBackgroundImage, BackgroundImage, UserAvatar, VerificationCode, UserCommunityChoice, UserEarlyCommunity, PageVisitLog, UserIntimacy
+from models_db import (
+    User,
+    UserUIState,
+    UserBackgroundImage,
+    BackgroundImage,
+    UserAvatar,
+    VerificationCode,
+    UserCommunityChoice,
+    UserEarlyCommunity,
+    PageVisitLog,
+    UserIntimacy,
+    UserEvent,
+)
 from error_codes import (
     PHONE_ALREADY_REGISTERED,
     USER_ID_NOT_FOUND,
@@ -204,7 +216,16 @@ class UserService:
         logger.error(f"❌ 临时用户名生成异常,使用最终备选方案: {temp_username}")
         return temp_username
     
-    async def create_guest_user(self, username: str = "", bot_name: Optional[str] = None) -> Dict:
+    async def create_guest_user(
+        self,
+        username: str = "",
+        bot_name: Optional[str] = None,
+        *,
+        user_segment: str = "individual",
+        org_id: Optional[str] = None,
+        is_test_user: bool = False,
+        region: Optional[str] = None,
+    ) -> Dict:
         """
         创建游客用户
         
@@ -230,6 +251,10 @@ class UserService:
                     user_id=user_id,
                     username=username,
                     user_type="guest",
+                    user_segment=user_segment or "individual",
+                    org_id=org_id,
+                    is_test_user=is_test_user,
+                    region=region,
                     bot_name=bot_name,
                     current_background_image_id="default",  # 设置默认背景图片
                 )
@@ -689,6 +714,39 @@ class UserService:
             return datetime.strptime(val[:10], "%Y-%m-%d").date()
         return val
 
+    async def save_user_event(self, payload: dict) -> bool:
+        """
+        保存单条用户事件(user_events)
+
+        payload 字段应与 UserEventPayload 对齐:
+        - event_id, user_id, event_name, event_label, event_time, session_id,
+          platform, app_version, timezone_offset_minutes, properties
+        """
+        try:
+            async with db_manager.get_session() as session:
+                event_time = payload.get("event_time")
+                if isinstance(event_time, str):
+                    event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
+
+                user_event = UserEvent(
+                    event_id=payload["event_id"],
+                    user_id=payload["user_id"],
+                    event_name=payload["event_name"],
+                    event_label=payload.get("event_label"),
+                    event_time=event_time or datetime.utcnow(),
+                    session_id=payload.get("session_id"),
+                    platform=payload["platform"],
+                    app_version=payload.get("app_version"),
+                    timezone_offset_minutes=payload.get("timezone_offset_minutes"),
+                    properties=payload.get("properties"),
+                )
+                session.add(user_event)
+                await session.commit()
+            return True
+        except Exception as e:  # noqa: BLE001
+            logger.warning(f"记录用户事件失败: event_id={payload.get('event_id')}, error={e}")
+            return False
+
     def _parse_effective_date(self) -> date:
         """解析配置中的亲密度规则生效日"""
         s = getattr(settings, "INTIMACY_RULE_EFFECTIVE_DATE", "2026-02-13") or "2026-02-13"

+ 192 - 92
backend/websocket_handler.py

@@ -47,6 +47,19 @@ from config import settings
 from stt_service import stt_service, STTSession
 from services.user_service import user_service
 from services.energy_stone_service import energy_stone_service
+from services.scenario_topic_service import scenario_topic_service
+from scenario_config import (
+    SCENE_DEFAULT,
+    SCENE_WINDOW,
+    SCENE_READING,
+    SCENE_EATING,
+    SCENE_WEIGHTS,
+    SCENARIO_TOPICS,
+    get_special_first_line,
+    build_topics_payload,
+)
+import random
+import uuid
 
 
 class WebSocketHandler:
@@ -90,6 +103,9 @@ class WebSocketHandler:
         self.pending_summaries = []
         self.summary_task: Optional[asyncio.Task] = None
         
+        # 情景对话:首条语音携带的 scene/topic,在进入 LLM 前落库
+        self._pending_scenario_log: Dict[str, Dict[str, str]] = {}
+        
     async def _init_services(self):
         """初始化服务(延迟加载,避免重复初始化)"""
         start_time = time.time()
@@ -116,6 +132,8 @@ class WebSocketHandler:
             if not energy_stone_service.db:
                 logger.debug(f"[{self.user_id}] 🔧 设置能量石服务数据库管理器...")
                 await energy_stone_service.set_db_manager(db_manager)
+            if not scenario_topic_service.db:
+                await scenario_topic_service.set_db_manager(db_manager)
             
             self.message_service = message_service
             self.context_service = context_service
@@ -226,8 +244,19 @@ class WebSocketHandler:
             await self.handle_energy_stone_event(EnergyStoneEventMessage(**message_data))
         
         elif message_type == "start_greeting":
-            # 由前端显式触发首句 greeting,避免在页面初始化阶段强行插入
-            await self._send_greeting_message()
+            # 由前端显式触发首句 greeting;支持 data.forceScene 测试用
+            data = message_data.get("data") or {}
+            force_scene = data.get("forceScene") if isinstance(data, dict) else None
+            await self._send_greeting_message(force_scene=force_scene)
+        
+        elif message_type == "play_topic_first_line":
+            # 情景对话:用户切换话题后请求播放该话题首句 TTS
+            data = message_data.get("data") or {}
+            if isinstance(data, dict) and data.get("topicKey"):
+                await self._play_topic_first_line(
+                    request_id=message_data.get("requestId") or f"topic_{uuid.uuid4().hex[:8]}",
+                    topic_key=data.get("topicKey"),
+                )
     
     async def handle_audio_chunk(self, message: AudioChunkMessage):
         """处理音频数据块 - 使用新的STT服务"""
@@ -246,6 +275,10 @@ class WebSocketHandler:
             current_time = time.time()
             self.request_start_times[request_id] = current_time
             
+            # 情景对话:首条语音携带的 scene/topic,进入 LLM 前落库
+            if getattr(message, "scene", None) and getattr(message, "topic", None):
+                self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
+            
             # 初始化性能指标
             self.performance_metrics[request_id] = PerformanceMetrics()
             self.performance_metrics[request_id].user_speech_start = current_time
@@ -281,6 +314,10 @@ class WebSocketHandler:
         """处理音频结束 - 使用新的STT服务"""
         request_id = message.requestId
         
+        # 情景对话:若首 chunk 未带 scene/topic,可在此补带(仅首条语音)
+        if getattr(message, "scene", None) and getattr(message, "topic", None) and request_id not in self._pending_scenario_log:
+            self._pending_scenario_log[request_id] = {"scene": message.scene, "topic": message.topic}
+        
         # 记录录音结束时间
         audio_end_time = time.time()
         self.audio_end_times[request_id] = audio_end_time
@@ -647,6 +684,13 @@ class WebSocketHandler:
             llm_first_text_time = None  # 🔥 LLM首个文本输出时间
             emotion_sent = False  # 标记是否已发送情感
             
+            # 情景对话:首条语音若携带 scene/topic,在此落库(仅记录一次)
+            pending = self._pending_scenario_log.pop(request_id, None)
+            if pending:
+                await scenario_topic_service.record_conversation(
+                    self.user_id, pending["scene"], pending["topic"]
+                )
+            
             # 构建Prompt(如果有上下文,使用上下文;否则使用原始文本)
             prompt_build_start = time.time()
             
@@ -1167,118 +1211,174 @@ class WebSocketHandler:
                 exc,
             )
 
-    async def _send_greeting_message(self):
+    async def _send_greeting_message(self, force_scene: Optional[str] = None):
         """
         发送首句(AI先说话)
-        - 新用户:固定首句
-        - 回归用户:动态生成登录问候
+        - 若 force_scene 有效则强制该场景;否则按权重随机场景(特殊场景需排除已聊完的)。
+        - default:新用户固定首句 / 回归用户动态登录问候。
+        - 特殊场景:未完成话题等概率选一,发 greeting_context 后发固定首句+TTS。
         """
+        request_id = f"greeting_{uuid.uuid4().hex[:8]}"
         try:
-            # 获取用户信息
             user_info = await user_service.get_user(self.user_id)
             if not user_info:
                 logger.warning(f"[{self.user_id}] ⚠️ 无法获取用户信息,跳过首句发送")
                 return
             
-            # 判断是否为新用户(total_rounds == 0)
+            user_name = (user_info.get("username") or "").strip() or "Neo"
             is_new_user = (user_info.get("total_rounds", 0) == 0)
             
-            # 生成首句
-            if is_new_user:
-                # 新用户:使用固定首句
-                user_name = (user_info.get("username") or "").strip() or "Neo"
-                lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
-                greeting_text = ai_service.first_meeting_line(user_name, lumi_name)
-                logger.info(f"[{self.user_id}] 🎯 新用户首句: {greeting_text}")
+            # 1) 决定本次场景
+            chosen_scene = SCENE_DEFAULT
+            valid_force = force_scene in (SCENE_DEFAULT, SCENE_WINDOW, SCENE_READING, SCENE_EATING)
+            if valid_force:
+                chosen_scene = force_scene
+                logger.info(f"[{self.user_id}] 🎯 首句强制场景: {chosen_scene}")
             else:
-                # 回归用户:动态生成登录问候
-                # 获取对话历史(用于上下文)
                 await self._init_services()
-                chat_history = []
-                if self.context_service:
-                    try:
-                        current_round = await self.redis_cache.get_current_round(self.user_id) if self.redis_cache else 1
-                        context = await self.context_service.get_context_for_round(
-                            user_id=self.user_id,
-                            current_round=current_round
-                        )
-                        if context and context.get("recent_messages"):
-                            for msg in context.get("recent_messages", []):
-                                role = "user" if msg.get("role") == "user" else "assistant"
-                                chat_history.append({"role": role, "content": msg.get("content", "")})
-                    except Exception as e:
-                        logger.warning(f"[{self.user_id}] ⚠️ 获取对话历史失败: {e}")
+                completed = await scenario_topic_service.get_completed_count_per_scenario(self.user_id)
+                candidates = []
+                for scene_key, weight in SCENE_WEIGHTS:
+                    if scene_key == SCENE_DEFAULT:
+                        candidates.append((scene_key, weight))
+                    else:
+                        if completed.get(scene_key, 0) < 3:
+                            candidates.append((scene_key, weight))
+                if not candidates:
+                    candidates = [(SCENE_DEFAULT, 70)]
+                total = sum(w for _, w in candidates)
+                r = random.uniform(0, total)
+                acc = 0
+                for sk, w in candidates:
+                    acc += w
+                    if r < acc:
+                        chosen_scene = sk
+                        break
+            
+            # 2) 默认场景:沿用原逻辑
+            if chosen_scene == SCENE_DEFAULT:
+                if is_new_user:
+                    lumi_name = (user_info.get("bot_name") or "").strip() or "Lumi"
+                    greeting_text = ai_service.first_meeting_line(user_name, lumi_name)
+                    logger.info(f"[{self.user_id}] 🎯 新用户首句: {greeting_text}")
+                else:
+                    await self._init_services()
+                    chat_history = []
+                    if self.context_service:
+                        try:
+                            current_round = await self.redis_cache.get_current_round(self.user_id) if self.redis_cache else 1
+                            context = await self.context_service.get_context_for_round(
+                                user_id=self.user_id, current_round=current_round
+                            )
+                            if context and context.get("recent_messages"):
+                                for msg in context.get("recent_messages", []):
+                                    role = "user" if msg.get("role") == "user" else "assistant"
+                                    chat_history.append({"role": role, "content": msg.get("content", "")})
+                        except Exception as e:
+                            logger.warning(f"[{self.user_id}] ⚠️ 获取对话历史失败: {e}")
+                    greeting_text = await ai_service.generate_login_greeting(
+                        user_info=user_info, chat_history=chat_history if chat_history else None
+                    )
+                    logger.info(f"[{self.user_id}] 🎯 回归用户登录问候: {greeting_text}")
                 
-                greeting_text = await ai_service.generate_login_greeting(
-                    user_info=user_info,
-                    chat_history=chat_history if chat_history else None
-                )
-                logger.info(f"[{self.user_id}] 🎯 回归用户登录问候: {greeting_text}")
-            
-            # 生成 request_id
-            import uuid
-            request_id = f"greeting_{uuid.uuid4().hex[:8]}"
-            
-            # ✅ v0.3: 新用户首句是固定的,不包含情感标签
-            # 对于新用户,使用默认情感 "friendly"
-            emotion: str = "friendly" if is_new_user else "neutral"
-            clean_text = greeting_text
-            
-            # 提取情感(如果是回归用户的登录问候,可能包含情感标签)
-            if not is_new_user and "[EMOTION:" in greeting_text:
-                emotion_start = greeting_text.find("[EMOTION:")
-                emotion_end = greeting_text.find("]", emotion_start)
-                if emotion_end > emotion_start:
-                    emotion_str = greeting_text[emotion_start + 9:emotion_end].strip().lower()
-                    valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
-                    if emotion_str in valid_emotions:
-                        emotion = emotion_str
-                    # 从情感标签结束后提取文本(跳过 ] 和可能的换行符)
-                    clean_text = greeting_text[emotion_end + 1:].strip()
-            
-            # 🧹 清理文本:移除特殊字符和表情符号
-            clean_text = clean_text.translate(str.maketrans('', '', '()[]')).strip()
-            clean_text = EMOJI_PATTERN.sub('', clean_text)  # 🔥 移除表情符号
-            
-            # 检查文本是否为空
-            if not clean_text or not clean_text.strip():
-                logger.warning(f"[{self.user_id}] ⚠️ 首句文本为空,使用默认文本")
-                clean_text = "Welcome back! How are you today?"
-            
-            # 发送情感
-            await self.send_emotion(request_id, emotion)
+                emotion = "friendly" if is_new_user else "neutral"
+                clean_text = greeting_text
+                if not is_new_user and "[EMOTION:" in greeting_text:
+                    emotion_start = greeting_text.find("[EMOTION:")
+                    emotion_end = greeting_text.find("]", emotion_start)
+                    if emotion_end > emotion_start:
+                        emotion_str = greeting_text[emotion_start + 9:emotion_end].strip().lower()
+                        valid_emotions = ["neutral", "happy", "sad", "excited", "angry", "surprised", "thoughtful", "encouraging", "confused", "worried", "satisfied", "friendly", "sorry"]
+                        if emotion_str in valid_emotions:
+                            emotion = emotion_str
+                        clean_text = greeting_text[emotion_end + 1:].strip()
+                clean_text = clean_text.translate(str.maketrans('', '', '()[]')).strip()
+                clean_text = EMOJI_PATTERN.sub('', clean_text)
+                if not clean_text or not clean_text.strip():
+                    clean_text = "Welcome back! How are you today?"
+                
+                await self.send_emotion(request_id, emotion)
+                await self.send_text_result(request_id, clean_text, is_final=True, source="assistant")
+                chunk_index = 0
+                async for audio_chunk in ai_service.text_to_speech_stream(clean_text, emotion):
+                    audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
+                    await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
+                    chunk_index += 1
+                await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
+                logger.info(f"[{self.user_id}] ✅ 首句发送完成: request_id={request_id}")
+                return
             
-            # 发送文本结果(作为识别结果,让前端显示)
-            await self.send_text_result(request_id, clean_text, is_final=True, source="assistant")
+            # 3) 特殊场景:选话题、发 greeting_context、再发首句+TTS
+            done_topics = await scenario_topic_service.get_done_topic_keys(self.user_id, chosen_scene)
+            all_topics = SCENARIO_TOPICS.get(chosen_scene, [])
+            available = [t for t in all_topics if t not in done_topics]
+            if not available:
+                logger.warning(f"[{self.user_id}] ⚠️ 特殊场景 {chosen_scene} 无可用话题,回退默认首句")
+                await self._send_greeting_message(force_scene=SCENE_DEFAULT)
+                return
             
-            # 生成TTS并发送音频
-            chunk_index = 0
-            async for audio_chunk in ai_service.text_to_speech_stream(clean_text, emotion):
-                # 将音频数据转换为base64
-                import base64
-                audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
-                await self.send_audio_response(
-                    request_id=request_id,
-                    data=audio_base64,
-                    chunk_index=chunk_index,
-                    is_last=False
-                )
-                chunk_index += 1
+            chosen_topic = random.choice(available)
+            first_line = get_special_first_line(chosen_topic, user_name)
+            topics_payload = build_topics_payload(available)
             
-            # 发送最后一个chunk(标记为is_last=True)
-            await self.send_audio_response(
+            await self._send_greeting_context(
                 request_id=request_id,
-                data="",
-                chunk_index=chunk_index,
-                is_last=True
+                scene=chosen_scene,
+                topic=chosen_topic,
+                topics=topics_payload,
+                first_line=first_line,
             )
-            
-            logger.info(f"[{self.user_id}] ✅ 首句发送完成: request_id={request_id}")
-            
+            emotion = "friendly"
+            await self.send_emotion(request_id, emotion)
+            await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
+            chunk_index = 0
+            async for audio_chunk in ai_service.text_to_speech_stream(first_line, emotion):
+                audio_base64 = base64.b64encode(audio_chunk).decode('utf-8')
+                await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
+                chunk_index += 1
+            await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
+            logger.info(f"[{self.user_id}] ✅ 特殊场景首句发送完成: scene={chosen_scene}, topic={chosen_topic}, request_id={request_id}")
         except Exception as e:
             logger.error(f"[{self.user_id}] ❌ 发送首句失败: {e}")
             logger.error(traceback.format_exc())
-            # 不抛出异常,避免影响WebSocket连接
+    
+    async def _send_greeting_context(
+        self,
+        request_id: str,
+        scene: str,
+        topic: str,
+        topics: list,
+        first_line: str,
+    ):
+        """发送 greeting_context 消息(仅特殊场景)"""
+        payload = {
+            "type": "greeting_context",
+            "requestId": request_id,
+            "timestamp": self._get_timestamp(),
+            "scene": scene,
+            "topic": topic,
+            "topics": topics,
+            "firstLine": first_line,
+        }
+        await self.websocket.send_text(json.dumps(payload))
+    
+    async def _play_topic_first_line(self, request_id: str, topic_key: str):
+        """情景对话:播放某话题的规范首句 TTS(用户切换话题后调用)"""
+        try:
+            user_info = await user_service.get_user(self.user_id)
+            user_name = (user_info.get("username") or "").strip() or "Neo" if user_info else "Neo"
+            first_line = get_special_first_line(topic_key, user_name)
+            emotion = "friendly"
+            await self.send_emotion(request_id, emotion)
+            await self.send_text_result(request_id, first_line, is_final=True, source="assistant")
+            chunk_index = 0
+            async for audio_chunk in ai_service.text_to_speech_stream(first_line, emotion):
+                audio_base64 = base64.b64encode(audio_chunk).decode("utf-8")
+                await self.send_audio_response(request_id=request_id, data=audio_base64, chunk_index=chunk_index, is_last=False)
+                chunk_index += 1
+            await self.send_audio_response(request_id=request_id, data="", chunk_index=chunk_index, is_last=True)
+        except Exception as e:
+            logger.warning(f"[{self.user_id}] play_topic_first_line failed: topic_key={topic_key}, e={e}")
     
     async def send_user_level_update(self, request_id: str, level: int, total_duration_ms: int):
         """发送用户等级更新消息"""

+ 38 - 0
dailyCount/README.md

@@ -0,0 +1,38 @@
+# dailyCount 日聚合与 CURR 脚本
+
+从 `user_events`、`messages`、`users` 汇总到 `user_daily_activity`,再计算 CURR 写入 `curr_daily`。
+
+## 配置
+
+1. 复制 `setting.conf.example` 为 `setting.conf`,按环境修改 MySQL 与 `active_events`。
+2. 安装依赖:`pip install -r requirements.txt`(或 `pip install pymysql`)。
+
+## 执行顺序
+
+先跑日聚合,再跑 CURR(CURR 依赖 `user_daily_activity`):
+
+```bash
+python run_user_daily_activity.py    # 当天(UTC) 聚合
+python run_curr_daily.py             # 昨天+今天 CURR
+```
+
+## 常用用法
+
+| 命令 | 说明 |
+|------|------|
+| `python run_user_daily_activity.py` | 仅跑当天(UTC) |
+| `python run_user_daily_activity.py --days 7` | 补跑最近 7 天 |
+| `python run_user_daily_activity.py --daemon` | 按 setting.conf 的 interval_minutes 循环执行 |
+| `python run_curr_daily.py` | 统计昨天与今天的 CURR(默认 --days 2) |
+| `python run_curr_daily.py --days 7` | 补跑最近 7 天 CURR |
+| `python run_curr_daily.py --daemon` | 循环执行 |
+
+## 定时建议
+
+crontab 每 60 分钟执行(先 activity 后 curr):
+
+```
+0 * * * * cd /path/to/Lyrria/dailyCount && python run_user_daily_activity.py && python run_curr_daily.py
+```
+
+或使用两脚本的 `--daemon` 模式(各开一个进程)。

+ 2 - 0
dailyCount/requirements.txt

@@ -0,0 +1,2 @@
+# dailyCount 脚本依赖(可单独 pip install -r requirements.txt)
+pymysql>=1.0.0

+ 176 - 0
dailyCount/run_curr_daily.py

@@ -0,0 +1,176 @@
+#!/usr/bin/env python3
+"""
+CURR 按日统计脚本:从 user_daily_activity 计算「昨日活跃中今日仍活跃」占比,写入 curr_daily。
+依赖:先运行 run_user_daily_activity.py 产出 user_daily_activity。
+配置:dailyCount/setting.conf(MySQL、interval_minutes)。
+用法:
+  python run_curr_daily.py              # 统计昨天与今天的 CURR
+  python run_curr_daily.py --days 7    # 补跑最近7天
+  python run_curr_daily.py --daemon    # 按 interval_minutes 循环执行
+"""
+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)
+    cfg = configparser.ConfigParser()
+    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 run_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()
+
+    # 昨日活跃用户数(active_flag=1)
+    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
+
+    # 昨日无活跃则只写 prev_day_active_count=0, retained_count=0, curr_rate_pct=NULL
+    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
+
+    # 当日 DAU
+    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 按日统计 -> curr_daily")
+    parser.add_argument("--config", default=DEFAULT_CONF, help="setting.conf 路径")
+    parser.add_argument("--days", type=int, default=2, help="统计最近 N 天(含今天),默认 2 表示昨天+今天")
+    parser.add_argument("--daemon", action="store_true", help="按 interval_minutes 循环执行")
+    args = parser.parse_args()
+
+    cfg = load_config(args.config)
+    mysql_kw = get_mysql_config(cfg)
+    interval_minutes = cfg.getint("schedule", "interval_minutes", fallback=60)
+
+    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]
+
+    def do_run() -> None:
+        conn = pymysql.connect(**mysql_kw)
+        try:
+            for d in dates:
+                run_once(conn, d)
+                print(f"[curr_daily] {d.isoformat()} 已更新")
+        finally:
+            conn.close()
+
+    if args.daemon:
+        import time
+        print(f"[curr_daily] 守护模式,每 {interval_minutes} 分钟执行一次")
+        while True:
+            do_run()
+            time.sleep(interval_minutes * 60)
+    else:
+        do_run()
+
+
+if __name__ == "__main__":
+    main()

+ 228 - 0
dailyCount/run_user_daily_activity.py

@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+用户日活跃聚合脚本:从 user_events、messages、users 汇总到 user_daily_activity。
+配置:dailyCount/setting.conf(MySQL、interval_minutes、active_events)。
+用法:
+  python run_user_daily_activity.py              # 仅跑当天(UTC)
+  python run_user_daily_activity.py --days 7     # 补跑最近7天
+  python run_user_daily_activity.py --daemon      # 按 interval_minutes 循环执行
+"""
+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)
+
+# 脚本所在目录,用于定位 setting.conf
+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)
+    cfg = configparser.ConfigParser()
+    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", []
+    # 若任一含 % 则整体按 LIKE 处理(通常只配一个 pattern,如 page.%__enter)
+    if any("%" in p for p in parts):
+        return "like", [parts[0]]
+    return "in", parts
+
+
+def build_active_events_where(cursor, 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_once(conn, activity_date: date, active_events_where: str, active_events_args: list) -> int:
+    """对给定 UTC 日期执行一次聚合,返回写入/更新的行数。"""
+    date_str = activity_date.isoformat()
+    cursor = conn.cursor()
+
+    # 统一使用 UTC 日期:先设置会话时区
+    cursor.execute("SET SESSION time_zone = '+00:00'")
+
+    # 1) 当天有事件或消息的所有 user_id
+    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:
+        return 0
+
+    # 2) 活跃事件统计:active_flag、session_count(仅统计活跃事件且 session_id 非空)
+    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()}
+
+    # 3) 当天用户消息数 (role='user')
+    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()}
+
+    # 4) 用户快照:first_seen_date, user_segment, org_id
+    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()}
+
+    # 5) 合并并写入
+    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 main() -> None:
+    parser = argparse.ArgumentParser(description="用户日活跃聚合 -> user_daily_activity")
+    parser.add_argument("--config", default=DEFAULT_CONF, help="setting.conf 路径")
+    parser.add_argument("--days", type=int, default=0, help="补跑最近 N 天(含今天),0 表示仅今天")
+    parser.add_argument("--daemon", 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(None, 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]
+            total = 0
+            for d in dates:
+                n = run_once(conn, d, active_where, active_args)
+                total += n
+                print(f"[user_daily_activity] {d.isoformat()} 写入/更新 {n} 条")
+            if total > 0:
+                print(f"[user_daily_activity] 合计 {total} 条")
+        finally:
+            conn.close()
+
+    if args.daemon:
+        import time
+        print(f"[user_daily_activity] 守护模式,每 {interval_minutes} 分钟执行一次")
+        while True:
+            do_run()
+            time.sleep(interval_minutes * 60)
+    else:
+        do_run()
+
+
+if __name__ == "__main__":
+    main()

+ 18 - 0
dailyCount/setting.conf.example

@@ -0,0 +1,18 @@
+# dailyCount 脚本配置示例
+# 复制为 setting.conf 并按环境修改,勿将 setting.conf 提交到版本库(可加入 .gitignore)
+
+[mysql]
+host = 47.99.164.66
+port = 3306
+user = root
+password =
+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

+ 23 - 0
dailyCount/sql/create_curr_daily.sql

@@ -0,0 +1,23 @@
+/*
+  CURR 按日统计表 curr_daily
+  昨日活跃用户中今日仍活跃的占比,由 dailyCount 脚本每小时从 user_daily_activity 计算
+*/
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+DROP TABLE IF EXISTS `curr_daily`;
+CREATE TABLE `curr_daily` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `activity_date` date NOT NULL COMMENT '统计日期(当日)',
+  `prev_day_active_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '昨日活跃用户数',
+  `retained_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '昨日活跃中今日仍活跃人数',
+  `curr_rate_pct` decimal(5,2) DEFAULT NULL COMMENT 'CURR百分比(retained/prev*100),昨日无活跃为NULL',
+  `total_active_today` int(10) unsigned DEFAULT NULL COMMENT '当日DAU(可选)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_date` (`activity_date`),
+  KEY `idx_activity_date` (`activity_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CURR按日统计表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 28 - 0
dailyCount/sql/create_user_daily_activity.sql

@@ -0,0 +1,28 @@
+/*
+  用户日活跃聚合表 user_daily_activity
+  数据来源:user_events、messages、users,由 dailyCount 脚本每小时汇总
+*/
+SET NAMES utf8mb4;
+SET FOREIGN_KEY_CHECKS = 0;
+
+DROP TABLE IF EXISTS `user_daily_activity`;
+CREATE TABLE `user_daily_activity` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `activity_date` date NOT NULL COMMENT '统计日期(UTC日)',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `active_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当天是否有≥1次活跃判定事件:1=是',
+  `session_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '当天去重session数(基于活跃事件)',
+  `message_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '当天用户消息条数(role=user)',
+  `first_seen_date` date DEFAULT NULL COMMENT '用户首次出现日期(来自users.created_at,首次写入后不覆盖)',
+  `user_segment_snapshot` varchar(32) DEFAULT NULL COMMENT 'users.user_segment快照',
+  `org_id_snapshot` varchar(64) DEFAULT NULL COMMENT 'users.org_id快照',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_date_user` (`activity_date`,`user_id`),
+  KEY `idx_activity_date` (`activity_date`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_first_seen_date` (`first_seen_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户日活跃聚合表';
+
+SET FOREIGN_KEY_CHECKS = 1;

+ 17 - 19
frontend/src/components/ChatBotStaticShell.tsx

@@ -5,6 +5,7 @@ import { getUserId, getStorage, STORAGE_KEYS } from '@/utils/storage';
 import { getIntimacyStatus, getAvatarGifs, type AvatarGifSet } from '@/utils/api';
 import { getOssUrl } from '@/utils/oss';
 import { px2vmin, px2vw } from '@/utils/viewport';
+import { wrapTrackedClick } from '@/utils/tracking';
 import './MissionEnergyStoneModal.css';
 
 // 兜底 GIF 资源,与 ChatBotPage 保持一致
@@ -60,7 +61,10 @@ interface ChatBotStaticShellProps {
   step: number;
 }
 
+const getTutorialPageId = (step: number) => `tutorial_${step}`;
+
 export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step }) => {
+  const pageId = getTutorialPageId(step);
   const { t: tCommon, i18n } = useTranslation('common');
   const { t: tProfile } = useTranslation('profile');
   const { t: tWelcome } = useTranslation('welcome');
@@ -269,9 +273,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             cursor: 'pointer',
             zIndex: 2,
           }}
-          onClick={() => {
-            navigate('/tutorial-2');
-          }}
+          onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-2'))}
         >
           <div
             style={{
@@ -351,9 +353,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             zIndex: 30,
             cursor: 'pointer',
           }}
-          onClick={() => {
-            navigate('/tutorial-3');
-          }}
+          onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-3'))}
         >
           <img
             src={
@@ -431,9 +431,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               zIndex: 6,
               cursor: 'pointer',
             }}
-            onClick={() => {
-              navigate('/tutorial-4');
-            }}
+            onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-4'))}
           >
             <img
               src={
@@ -686,7 +684,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
             height: i18n.language?.startsWith('zh') ? px2vmin(99, 'v2') : px2vmin(123, 'v2'),
             zIndex: 6,
           }}
-          onClick={() => navigate('/tutorial-9')}
+          onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-9'))}
         >
           <img
             src={
@@ -1025,7 +1023,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                     height: px2vmin(85),
                     transform: 'translateX(10px)',
                   }}
-                  onClick={() => navigate('/tutorial-7')}
+                  onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-7'))}
                 >
                   <img
                     src={
@@ -1085,7 +1083,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               width: px2vmin(139),
               height: px2vmin(85),
             }}
-            onClick={() => navigate('/tutorial-6')}
+            onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-6'))}
           >
             <img
               src={
@@ -1161,7 +1159,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                 justifyContent: 'center',
               }}
               aria-label={tCommon('a11y.menu')}
-              onClick={() => setShowFuncMenuPopup((prev) => !prev)}
+              onClick={wrapTrackedClick(pageId, 'settings', () => setShowFuncMenuPopup((prev) => !prev))}
             >
               <img
                 src={settingIcon}
@@ -1180,7 +1178,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                 className="fixed inset-0 z-30"
                 style={{ left: 0, right: 0, top: 0, bottom: 0 }}
                 aria-hidden
-                onClick={() => setShowFuncMenuPopup(false)}
+                onClick={wrapTrackedClick(pageId, 'settings_close', () => setShowFuncMenuPopup(false))}
               />
 
               {/* 功能菜单弹窗主体 */}
@@ -1208,10 +1206,10 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                     label: tCommon('profile'),
                     icon: profileMenuIcon,
                     enabled: true,
-                    onClick: () => {
+                    onClick: wrapTrackedClick(pageId, 'profile', () => {
                       setShowFuncMenuPopup(false);
                       navigate('/profile');
-                    },
+                    }),
                   },
                   {
                     key: 'shop',
@@ -1305,7 +1303,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                   left: `calc(-${px2vmin(20)})`,
                   height: px2vmin(75),
                 }}
-                onClick={() => navigate('/tutorial-8')}
+                onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-8'))}
               >
                 <img
                   src={
@@ -1362,7 +1360,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
                   width: i18n.language?.startsWith('zh') ? px2vmin(150) : px2vmin(189),
                   height: px2vmin(75),
                 }}
-                onClick={() => navigate('/tutorial-5')}
+                onClick={wrapTrackedClick(pageId, 'next', () => navigate('/tutorial-5'))}
               >
                 <img
                   src={
@@ -1445,7 +1443,7 @@ export const ChatBotStaticShell: React.FC<ChatBotStaticShellProps> = ({ step })
               zIndex: 9998,
               animation: 'fadeIn 0.3s ease-out',
             }}
-            onClick={() => setShowBondLevelModal(false)}
+            onClick={wrapTrackedClick(pageId, 'bond_level_modal_close', () => setShowBondLevelModal(false))}
             aria-hidden
           />
           <div

+ 27 - 3
frontend/src/components/IntimacyModal.tsx

@@ -1,7 +1,8 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { px2vw, px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 import './IntimacyModal.css';
 
 const heartFilled = getOssUrl('Heart.png');
@@ -18,6 +19,12 @@ interface IntimacyModalProps {
   onClose: () => void;
   onLightUp: () => void;
   loading?: boolean;
+  /** 埋点用页面 ID,传入时关闭/点亮会上报 */
+  trackingPageId?: string;
+  /** 关闭遮罩埋点 ID,默认 intimacy_close */
+  trackingCloseId?: string;
+  /** 点亮按钮埋点 ID,默认 intimacy_light_up */
+  trackingLightUpId?: string;
 }
 
 export const IntimacyModal: React.FC<IntimacyModalProps> = ({
@@ -28,7 +35,24 @@ export const IntimacyModal: React.FC<IntimacyModalProps> = ({
   onClose,
   onLightUp,
   loading = false,
+  trackingPageId,
+  trackingCloseId = 'intimacy_close',
+  trackingLightUpId = 'intimacy_light_up',
 }) => {
+  const handleClose = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, trackingCloseId, onClose)
+        : onClose,
+    [trackingPageId, trackingCloseId, onClose]
+  );
+  const handleLightUp = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, trackingLightUpId, onLightUp)
+        : onLightUp,
+    [trackingPageId, trackingLightUpId, onLightUp]
+  );
   const { t } = useTranslation('common');
 
   useEffect(() => {
@@ -50,7 +74,7 @@ export const IntimacyModal: React.FC<IntimacyModalProps> = ({
     <>
       <div
         className="intimacy-modal-mask"
-        onClick={onClose}
+        onClick={handleClose}
         style={{
           position: 'fixed',
           top: 0,
@@ -120,7 +144,7 @@ export const IntimacyModal: React.FC<IntimacyModalProps> = ({
           className="intimacy-modal-btn"
           onClick={() => {
             if (!loading && canLightUpToday) {
-              onLightUp();
+              handleLightUp();
             }
           }}
           disabled={loading || !canLightUpToday}

+ 16 - 2
frontend/src/components/MissionEnergyStoneModal.tsx

@@ -1,8 +1,9 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import './MissionEnergyStoneModal.css';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const missionEnergyStoneImage = getOssUrl('Mission-Energy-Stone.png');
 const conversationEnergyStoneImage = getOssUrl('Conversation-Energy-Stone.png');
@@ -12,6 +13,10 @@ interface MissionEnergyStoneModalProps {
   onClose: () => void;
   stoneType?: 'mission' | 'conversation';
   children?: React.ReactNode;
+  /** 埋点用页面 ID,传入时关闭遮罩会上报 button.<pageId>.<closeId>__click */
+  trackingPageId?: string;
+  /** 关闭按钮埋点 ID,默认 energy_stone_close */
+  trackingCloseId?: string;
 }
 
 export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = ({
@@ -19,7 +24,16 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
   onClose,
   stoneType = 'mission',
   children,
+  trackingPageId,
+  trackingCloseId = 'energy_stone_close',
 }) => {
+  const handleClose = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, trackingCloseId, onClose)
+        : onClose,
+    [trackingPageId, trackingCloseId, onClose]
+  );
   const { t } = useTranslation('profile');
   const imageSrc = stoneType === 'mission' ? missionEnergyStoneImage : conversationEnergyStoneImage;
   const title = stoneType === 'mission' ? t('missionStoneTitle') : t('chatStoneTitle');
@@ -46,7 +60,7 @@ export const MissionEnergyStoneModal: React.FC<MissionEnergyStoneModalProps> = (
       {/* 遮罩层 */}
       <div
         className="mission-energy-stone-modal-mask"
-        onClick={onClose}
+        onClick={handleClose}
         style={{
           position: 'fixed',
           top: 0,

+ 2 - 2
frontend/src/components/PageVisitReporter.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 import { useLocation } from 'react-router-dom';
-import { reportPageVisit } from '@/utils/api';
+import { trackPageEnter } from '@/utils/tracking';
 
 /**
  * 路由级页面访问上报:每次进入任意界面时上报 path 到后端并落库
@@ -10,7 +10,7 @@ export const PageVisitReporter = () => {
   const location = useLocation();
 
   useEffect(() => {
-    reportPageVisit(location.pathname || '/');
+    trackPageEnter(location.pathname || '/');
   }, [location.pathname]);
 
   return null;

+ 16 - 2
frontend/src/components/PrivacyPolicyModal.tsx

@@ -1,16 +1,30 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 interface PrivacyPolicyModalProps {
   visible: boolean;
   onClose: () => void;
+  /** 埋点用页面 ID,传入时关闭遮罩会上报 button.<pageId>.<closeId>__click */
+  trackingPageId?: string;
+  /** 关闭按钮埋点 ID,默认 privacy_close */
+  trackingCloseId?: string;
 }
 
 export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
   visible,
   onClose,
+  trackingPageId,
+  trackingCloseId = 'privacy_close',
 }) => {
+  const handleClose = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, trackingCloseId, onClose)
+        : onClose,
+    [trackingPageId, trackingCloseId, onClose]
+  );
   const { i18n } = useTranslation();
 
   useEffect(() => {
@@ -38,7 +52,7 @@ export const PrivacyPolicyModal: React.FC<PrivacyPolicyModalProps> = ({
     <>
       {/* 遮罩层 */}
       <div
-        onClick={onClose}
+        onClick={handleClose}
         style={{
           position: 'fixed',
           top: 0,

+ 7 - 3
frontend/src/components/RecordButton.tsx

@@ -4,6 +4,7 @@ import { SendOutlined, CloseOutlined } from '@ant-design/icons';
 import type { ClientState } from '@/types';
 import { px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const progressRing = getOssUrl('progress-ring.svg');
 const micIcon = getOssUrl('mic-icon.svg');
@@ -13,6 +14,8 @@ interface RecordButtonProps {
   onStartRecord: () => void;
   onSend: () => void;
   onCancel: () => void;
+  /** 埋点用页面 ID,默认 chatbot;教程页传 tutorial_1 ~ tutorial_10 */
+  pageId?: string;
 }
 
 /**
@@ -24,6 +27,7 @@ export const RecordButton: React.FC<RecordButtonProps> = ({
   onStartRecord,
   onSend,
   onCancel,
+  pageId = 'chatbot',
 }) => {
   // 空闲或错误状态:仅显示麦克风按钮(隐藏 Tap to chat 提示)
   if (state === 'idle' || state === 'error') {
@@ -48,7 +52,7 @@ export const RecordButton: React.FC<RecordButtonProps> = ({
 
           {/* 麦克风按钮背景 (96x96) - 响应式 */}
           <button
-          onClick={onStartRecord}
+          onClick={wrapTrackedClick(pageId, 'mic', onStartRecord)}
             className="relative rounded-full flex items-center justify-center cursor-pointer transition-all duration-300 hover:scale-105 active:scale-95 focus:outline-none touch-manipulation"
             style={{
               width: px2vw(96),
@@ -113,7 +117,7 @@ export const RecordButton: React.FC<RecordButtonProps> = ({
           <Button 
             size="large"
             icon={<CloseOutlined />} 
-            onClick={onCancel}
+            onClick={wrapTrackedClick(pageId, 'stop', onCancel)}
             className="bg-white/20 border-white/30 text-white hover:bg-white/30 touch-manipulation"
             style={{
               width: px2vw(90),
@@ -129,7 +133,7 @@ export const RecordButton: React.FC<RecordButtonProps> = ({
             type="primary" 
             size="large"
             icon={<SendOutlined />} 
-            onClick={onSend}
+            onClick={wrapTrackedClick(pageId, 'send', onSend)}
             className="touch-manipulation"
             style={{
               width: px2vw(90),

+ 26 - 0
frontend/src/components/TrackedButton.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { Button } from 'antd';
+import type { ButtonProps } from 'antd';
+import { trackButtonClick } from '@/utils/tracking';
+
+export interface TrackedButtonProps extends Omit<ButtonProps, 'onClick'> {
+  pageId: string;
+  buttonId: string;
+  eventLabel?: string;
+  onClick?: ButtonProps['onClick'];
+}
+
+/** 带埋点的 Ant Design Button,点击时上报 button.<pageId>.<buttonId>__click */
+export const TrackedButton: React.FC<TrackedButtonProps> = ({
+  pageId,
+  buttonId,
+  eventLabel,
+  onClick,
+  ...rest
+}) => {
+  const handleClick: ButtonProps['onClick'] = (e) => {
+    trackButtonClick(pageId, buttonId, { label: eventLabel });
+    onClick?.(e);
+  };
+  return <Button {...rest} onClick={handleClick} />;
+};

+ 16 - 2
frontend/src/components/UserAgreementModal.tsx

@@ -1,16 +1,30 @@
-import React, { useEffect } from 'react';
+import React, { useEffect, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 import { px2vmin, px2vh, px2vw } from '@/utils/viewport';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 interface UserAgreementModalProps {
   visible: boolean;
   onClose: () => void;
+  /** 埋点用页面 ID,传入时关闭遮罩会上报 button.<pageId>.<closeId>__click */
+  trackingPageId?: string;
+  /** 关闭按钮埋点 ID,默认 agreement_close */
+  trackingCloseId?: string;
 }
 
 export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
   visible,
   onClose,
+  trackingPageId,
+  trackingCloseId = 'agreement_close',
 }) => {
+  const handleClose = useMemo(
+    () =>
+      trackingPageId
+        ? wrapTrackedClick(trackingPageId, trackingCloseId, onClose)
+        : onClose,
+    [trackingPageId, trackingCloseId, onClose]
+  );
   const { i18n } = useTranslation();
 
   useEffect(() => {
@@ -38,7 +52,7 @@ export const UserAgreementModal: React.FC<UserAgreementModalProps> = ({
     <>
       {/* 遮罩层 */}
       <div
-        onClick={onClose}
+        onClick={handleClose}
         style={{
           position: 'fixed',
           top: 0,

+ 14 - 17
frontend/src/pages/BeginPage.tsx

@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserId, setUserLevel } from '@/utils/storage';
-import { updateUserUIState, getUserInfo, ensureSession } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserLevel } from '@/utils/storage';
+import { updateUserUIState, getUserInfo } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 import { px2vmin } from '@/utils/viewport';
 import { UserAgreementModal } from '@/components/UserAgreementModal';
 import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
@@ -356,20 +357,14 @@ export const BeginPage = () => {
         // 🚀 在视频播放期间预加载用户数据,避免进入 naming 页面时卡顿
         const preloadUserData = async () => {
           try {
-            // 确保存在有效 userId + token(单活),仅在无会话时初始化
-            let userId = getUserId();
-            try {
-              const session = await ensureSession();
-              userId = session.userId;
-              setUserId(userId);
-            } catch (error) {
-              console.error('[WelcomePage] 预加载:ensureSession 失败', error);
-            }
+            // 仅使用已登录用户的 userId(不再通过会话初始化创建匿名用户)
+            const userId = getUserId();
+            if (!userId) return;
 
             // 如果 localStorage 中没有保存的用户名,从后端获取
             const savedName = getStorage(STORAGE_KEYS.USER_NAME);
             if (!savedName) {
-              const user_id = userId || getUserId();
+              const user_id = userId;
               if (user_id) {
                 try {
                   const response = await getUserInfo(user_id);
@@ -746,7 +741,7 @@ export const BeginPage = () => {
                 zIndex: 2,
                 objectFit: 'contain',
               }}
-              onClick={() => navigate('/email-login')}
+              onClick={wrapTrackedClick('begin', 'to_email_login', () => navigate('/email-login'))}
               onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
               onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
               onMouseDown={(e) => { e.currentTarget.style.opacity = '0.8'; }}
@@ -779,7 +774,7 @@ export const BeginPage = () => {
                 zIndex: 2,
                 objectFit: 'contain',
               }}
-              onClick={() => navigate('/naming')}
+              onClick={wrapTrackedClick('begin', 'to_naming', () => navigate('/naming'))}
               onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
               onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
               onMouseDown={(e) => { e.currentTarget.style.opacity = '0.8'; }}
@@ -830,7 +825,7 @@ export const BeginPage = () => {
                     alignItems: 'center',
                     justifyContent: 'center',
                   }}
-                  onClick={() => setAgreementChecked(!agreementChecked)}
+                  onClick={wrapTrackedClick('begin', 'agree_terms', () => setAgreementChecked(!agreementChecked))}
                 >
                   {agreementChecked && (
                     <div
@@ -860,7 +855,7 @@ export const BeginPage = () => {
                       cursor: 'pointer',
                       transition: 'opacity 0.2s ease',
                     }}
-                    onClick={() => setShowAgreementModal(true)}
+                    onClick={wrapTrackedClick('begin', 'show_agreement', () => setShowAgreementModal(true))}
                     onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
                     onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
                   >
@@ -874,7 +869,7 @@ export const BeginPage = () => {
                       cursor: 'pointer',
                       transition: 'opacity 0.2s ease',
                     }}
-                    onClick={() => setShowPrivacyPolicyModal(true)}
+                    onClick={wrapTrackedClick('begin', 'show_privacy', () => setShowPrivacyPolicyModal(true))}
                     onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
                     onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
                   >
@@ -1029,12 +1024,14 @@ export const BeginPage = () => {
       <UserAgreementModal
         visible={showAgreementModal}
         onClose={() => setShowAgreementModal(false)}
+        trackingPageId="begin"
       />
 
       {/* 隐私政策弹窗 */}
       <PrivacyPolicyModal
         visible={showPrivacyPolicyModal}
         onClose={() => setShowPrivacyPolicyModal(false)}
+        trackingPageId="begin"
       />
     </>
   );

+ 171 - 49
frontend/src/pages/ChatBotPage.tsx

@@ -26,6 +26,7 @@ import {
   getUserToken,
   setUserToken,
   clearUserSessionAndRedirectToBegin,
+  hasValidSession,
   ROUTE_ORDER,
 } from '@/utils/storage';
 import { onScreenResize, calculateLayout, type CalculatedLayout, updateTooSmallFlag } from '@/utils/screenSize';
@@ -39,14 +40,14 @@ import {
   lightUpIntimacy,
   getIntimacyStatus,
   type AvatarGifSet,
-  ensureSession,
   getApiBaseUrl,
   getApiHeaders,
 } from '@/utils/api';
 import { px2vmin } from '@/utils/viewport';
-import type { Message, EnergyStoneSpawnMessage } from '@/types';
+import type { Message, EnergyStoneSpawnMessage, GreetingContextMessage } from '@/types';
 import '@/styles/chatbot-fix.css';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick, trackButtonClick } from '@/utils/tracking';
 
 const tapToChat = getOssUrl('TapToChat.svg');
 const cancelIcon = getOssUrl('cancel-icon.png');
@@ -71,6 +72,7 @@ const shopMenuIcon = getOssUrl('shop-icon.png');
 const worldMenuIcon = getOssUrl('world-icon.png');
 const diaryMenuIcon = getOssUrl('diary-icon.png');
 const ccCloseIcon = getOssUrl('cc-close-icon.png');
+const toggleIconBtn = getOssUrl('toggle-icon-btn.svg');
 const ccOpenIcon = getOssUrl('cc-open-icon.png');
 const missionEnergyStone = getOssUrl('Mission-Energy-Stone.png');
 const conversationEnergyStone = getOssUrl('Conversation-Energy-Stone.png');
@@ -149,9 +151,10 @@ const getEmotionGifDuration = (_avatarId: string | null, emotionCategory: 'posit
 };
 
 export const ChatBotPage = () => {
-  const { t: tCommon } = useTranslation('common');
+  const { t: tCommon, i18n } = useTranslation('common');
   const { t: tProfile } = useTranslation('profile');
   const { t: tWelcome } = useTranslation('welcome');
+  const isZh = i18n.language?.startsWith('zh') ?? true;
   const navigate = useNavigate();
   const [searchParams] = useSearchParams();
   const location = useLocation();
@@ -192,6 +195,19 @@ export const ChatBotPage = () => {
   const mobileAudioPreparedRef = useRef(false);
   // 如果亲密度弹窗正在展示,则不播放首句 greeting(直接丢弃相关消息)
   const delayGreetingRef = useRef(false);
+  // 情景对话:后端下发的场景/话题(仅特殊场景有)
+  const [greetingContext, setGreetingContext] = useState<{
+    scene: string;
+    topic: string;
+    topics: Array<{ key: string; labelEn?: string; labelZh?: string }>;
+    firstLine: string;
+  } | null>(null);
+  const [currentTopicIndex, setCurrentTopicIndex] = useState(0);
+  const hasSentScenarioContextRef = useRef(false);
+  const greetingContextRef = useRef(greetingContext);
+  useEffect(() => {
+    greetingContextRef.current = greetingContext;
+  }, [greetingContext]);
   // 字幕相关状态(默认从 localStorage 读取,若无则为 false)
   const [subtitleOn, setSubtitleOn] = useState(() => {
     return getStorage(STORAGE_KEYS.SUBTITLE_ON) === 'true';
@@ -270,10 +286,13 @@ export const ChatBotPage = () => {
     pendingGreetingRef.current = false;
     hasGreetingStartedRef.current = true;
     const requestId = `greeting_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
-    debugLog('[首句] 发送 start_greeting 消息', { requestId });
+    const forceScene = searchParams.get('scene');
+    const validScene = ['default', 'window', 'reading', 'eating'].includes(forceScene || '');
+    debugLog('[首句] 发送 start_greeting 消息', { requestId, forceScene: validScene ? forceScene : undefined });
     ws.send({
       type: 'start_greeting',
       requestId,
+      ...(validScene && forceScene ? { data: { forceScene } } : {}),
     });
   };
   const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
@@ -2033,36 +2052,25 @@ export const ChatBotPage = () => {
         });
         playerRef.current = player;
 
-        // 初始化WebSocket前确保会话已就绪(支持用户直接刷新/直达聊天页)
-        // 🔧 如果 URL 中有 OAuth 回调参数,说明正在处理登录回调,不调用 ensureSession(避免创建新用户)
-        const oauthFromUrl = searchParams.get('oauth');
-        const userIdFromUrl = searchParams.get('user_id');
-        
-        if (!oauthFromUrl || !userIdFromUrl) {
-          // 只有在非 OAuth 回调时才调用 ensureSession
-          try {
-            await ensureSession();
-          } catch (error) {
-            console.error('[ChatBotPage] ensureSession 失败,无法初始化 WebSocket:', error);
-            clearUserSessionAndRedirectToBegin();
-            return;
-          }
-        } else {
-          console.log(`[ChatBotPage] 检测到 OAuth 回调,跳过 ensureSession - oauth=${oauthFromUrl}, user_id=${userIdFromUrl}`);
+        // 初始化 WebSocket 前要求已有有效会话(老用户先登录再使用,不再创建匿名用户)
+        if (!hasValidSession()) {
+          clearUserSessionAndRedirectToBegin();
+          return;
         }
 
         // 初始化WebSocket(添加 user_id + token 查询参数,单活)
         const ws = new WebSocketManager();
+        const wsUserId = getUserId();
         const token = getUserToken();
         const params = new URLSearchParams();
-        if (user_id) {
-          params.append('user_id', user_id);
+        if (wsUserId) {
+          params.append('user_id', wsUserId);
         }
         if (token) {
           params.append('token', token);
         }
         ws.setAuth(
-          { userId: user_id || undefined, token: token || undefined },
+          { userId: wsUserId || undefined, token: token || undefined },
           async () => {
             // 服务端明确标记会话失效时(如在其他设备登录),直接清理本地状态并跳转到 /begin
             clearUserSessionAndRedirectToBegin();
@@ -2210,8 +2218,10 @@ export const ChatBotPage = () => {
     const activeRequestId = activeRequestIdRef.current;
     const isCancelledRequest = cancelledRequestIdsRef.current.has(requestId);
     
-    // 🔥 首句消息特殊处理(requestId 以 "greeting_" 开头)
+    // 🔥 首句消息特殊处理(requestId 以 "greeting_" 或 "topic_" 开头)
     const isGreetingMessage = requestId.startsWith('greeting_');
+    const isTopicFirstLineMessage = requestId.startsWith('topic_');
+    const isGreetingOrTopicFirstLine = isGreetingMessage || isTopicFirstLineMessage;
 
     // 如果亲密度弹窗正在展示,则不处理任何首句相关消息(文本/情绪/音频)
     if (isGreetingMessage && delayGreetingRef.current) {
@@ -2229,7 +2239,7 @@ export const ChatBotPage = () => {
       greetingRequestIdRef.current = requestId;
     }
 
-    // 如果用户已经开始录音且选择“以用户为准”,则直接丢弃所有首句相关消息
+    // 如果用户已经开始录音且选择“以用户为准”,则直接丢弃所有首句相关消息(topic_ 切换话题 TTS 仍可播放)
     if (isGreetingMessage && greetingSuppressedRef.current) {
       debugLog(`[首句] [${requestId.substring(0, 8)}] 已被用户录音打断,忽略首句消息: ${message.type}`);
       return;
@@ -2244,14 +2254,32 @@ export const ChatBotPage = () => {
     }
     
     switch (message.type) {
+      case 'greeting_context': {
+        const ctx = message as unknown as GreetingContextMessage;
+        if (ctx.scene && ctx.scene !== 'default' && Array.isArray(ctx.topics) && ctx.topics.length > 0) {
+          setGreetingContext({
+            scene: ctx.scene,
+            topic: ctx.topic,
+            topics: ctx.topics,
+            firstLine: ctx.firstLine ?? '',
+          });
+          const idx = ctx.topics.findIndex((t) => t.key === ctx.topic);
+          setCurrentTopicIndex(idx >= 0 ? idx : 0);
+          hasSentScenarioContextRef.current = false;
+          if (ctx.firstLine) setRecognizedText(ctx.firstLine);
+        } else {
+          setGreetingContext(null);
+        }
+        break;
+      }
       case 'text_result': {
         // 仅拼接 AI 返回的文本到字幕;用户 STT(source=user)不参与字幕
         if (message.source === 'user') {
           break;
         }
         const cleaned = cleanSubtitleText(message.text);
-        // 🔥 首句消息:直接处理,不检查 activeRequestId
-        if (isGreetingMessage) {
+        // 🔥 首句/切换话题首句消息:直接处理,不检查 activeRequestId
+        if (isGreetingOrTopicFirstLine) {
           if (message.isFinal) {
             debugLog(`[首句文本] [${requestId.substring(0, 8)}] ${cleaned}`);
             if (subtitleAccumulatorRequestIdRef.current !== requestId) {
@@ -2343,8 +2371,8 @@ export const ChatBotPage = () => {
         break;
 
       case 'emotion':
-        // 🔥 首句消息:直接处理,不检查 activeRequestId
-        if (isGreetingMessage) {
+        // 🔥 首句/切换话题首句:直接处理,不检查 activeRequestId
+        if (isGreetingOrTopicFirstLine) {
           performanceLogger.recordEmotion(requestId, message.emotion, message.timestamp);
           setEmotion(message.emotion);
           // 🔥 仅缓存情绪,不立即切换 GIF,等待首句音频真正开始播放时再驱动动画
@@ -2366,9 +2394,9 @@ export const ChatBotPage = () => {
         break;
 
       case 'audio_response':
-        // 🔥 首句消息:直接处理,不检查状态和 activeRequestId
-        if (isGreetingMessage) {
-          // 首句音频:自动播放
+        // 🔥 首句/切换话题首句:直接处理,不检查状态和 activeRequestId
+        if (isGreetingOrTopicFirstLine) {
+          // 首句/话题首句音频:自动播放
           if (currentState !== 'playing') {
             const now = Date.now();
             emotionGifShownForStreamRef.current = false; // 新流开始,允许本段显示情绪 GIF
@@ -2405,7 +2433,7 @@ export const ChatBotPage = () => {
             performanceLogger.recordLastAudio(requestId);
             playerRef.current?.markStreamEnd();
             isGreetingActiveRef.current = false;
-            greetingRequestIdRef.current = null;
+            if (isGreetingMessage) greetingRequestIdRef.current = null;
           }
           break;
         }
@@ -2641,7 +2669,18 @@ export const ChatBotPage = () => {
         }
         const uint8Array = new Uint8Array(pcmData.buffer);
         const base64 = btoa(String.fromCharCode(...Array.from(uint8Array)));
-        wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current);
+        const ctx = greetingContextRef.current;
+        const scenarioContext =
+          chunkIndexRef.current === 0 &&
+          ctx &&
+          ctx.scene !== 'default' &&
+          !hasSentScenarioContextRef.current
+            ? (() => {
+                hasSentScenarioContextRef.current = true;
+                return { scene: ctx.scene, topic: ctx.topic };
+              })()
+            : undefined;
+        wsRef.current?.sendAudioChunk(requestId, base64, chunkIndexRef.current, scenarioContext);
         chunkIndexRef.current++;
       }, pushFrequencyFrame);
       
@@ -2742,8 +2781,21 @@ export const ChatBotPage = () => {
     recorderRef.current?.stop();
     resetFrequencyVisualization();
 
-    // 发送音频结束消息,包含说话时长信息
-    wsRef.current?.sendAudioEnd(currentRequestId, chunkIndexRef.current, speechDuration);
+    // 发送音频结束消息,包含说话时长信息;情景对话下若首包未带 scene/topic 则在此补带
+    const ctx = greetingContextRef.current;
+    const scenarioContext =
+      ctx && ctx.scene !== 'default' && !hasSentScenarioContextRef.current
+        ? (() => {
+            hasSentScenarioContextRef.current = true;
+            return { scene: ctx.scene, topic: ctx.topic };
+          })()
+        : undefined;
+    wsRef.current?.sendAudioEnd(
+      currentRequestId,
+      chunkIndexRef.current,
+      speechDuration,
+      scenarioContext
+    );
     setState('sending');
     
     // 设置超时保护:10秒后如果还在 sending 或 playing 状态,自动重置
@@ -2919,10 +2971,10 @@ export const ChatBotPage = () => {
           role="button"
           tabIndex={0}
           aria-label={tWelcome('bondLevelTitle')}
-          onClick={(e) => {
-            e.stopPropagation();
+          onClick={wrapTrackedClick('chatbot', 'bond_level', (e) => {
+            e?.stopPropagation?.();
             setShowBondLevelModal(true);
-          }}
+          })}
           onKeyDown={(e) => {
             if (e.key === 'Enter' || e.key === ' ') {
               e.preventDefault();
@@ -3094,6 +3146,7 @@ export const ChatBotPage = () => {
                     pointerEvents: stone.fading ? 'none' : 'auto',
                   }}
                   onClick={async () => {
+                    trackButtonClick('chatbot', stone.stoneType === 'mission' ? 'mission_stone' : 'conversation_stone');
                     // 更新点击状态(控制光晕显示)
                     const clickedStorageKey = stone.stoneType === 'mission'
                       ? STORAGE_KEYS.HAS_CLICKED_MISSION_STONE
@@ -3236,6 +3289,73 @@ export const ChatBotPage = () => {
         />
       </div>
 
+      {/* 情景对话:字幕区域上方话题名称 + 切换话题按钮(仅特殊场景展示),与字幕左对齐 */}
+      {greetingContext && greetingContext.scene !== 'default' && greetingContext.topics.length > 0 && (() => {
+        const currentTopic = greetingContext.topics[currentTopicIndex];
+        const topicLabel = currentTopic
+          ? (isZh ? (currentTopic.labelZh ?? currentTopic.labelEn ?? currentTopic.key) : (currentTopic.labelEn ?? currentTopic.labelZh ?? currentTopic.key))
+          : '';
+        return (
+          <div
+            className="absolute flex items-center"
+            style={{
+              top: px2vmin(530 - 32 - 8, 'v2'),
+              left: '50%',
+              transform: 'translateX(-50%)',
+              width: px2vmin(312, 'v2'),
+              gap: px2vmin(10, 'v2'),
+              zIndex: 20,
+            }}
+          >
+            <div
+              className="flex items-center flex-shrink-0"
+              style={{
+                height: px2vmin(32, 'v2'),
+                paddingLeft: px2vmin(8, 'v2'),
+                paddingRight: px2vmin(8, 'v2'),
+                backgroundColor: 'rgba(61, 21, 129, 0.5)',
+                color: '#FFFFFF',
+                fontSize: px2vmin(11, 'v2'),
+                lineHeight: px2vmin(16, 'v2'),
+                borderRadius: px2vmin(60, 'v2'),
+              }}
+            >
+              {topicLabel}
+            </div>
+            <button
+              type="button"
+              aria-label="Switch topic"
+              className="touch-manipulation cursor-pointer border-0 bg-transparent p-0 flex items-center justify-center"
+              style={{
+                width: px2vmin(32, 'v2'),
+                height: px2vmin(32, 'v2'),
+                minWidth: px2vmin(32, 'v2'),
+                minHeight: px2vmin(32, 'v2'),
+              }}
+              onClick={wrapTrackedClick('chatbot', 'topic_toggle', () => {
+                const next = (currentTopicIndex + 1) % greetingContext.topics.length;
+                setCurrentTopicIndex(next);
+                const nextTopic = greetingContext.topics[next];
+                if (nextTopic && wsRef.current) {
+                  setGreetingContext((prev) =>
+                    prev ? { ...prev, topic: nextTopic.key, firstLine: '' } : null
+                  );
+                  setRecognizedText('');
+                  const reqId = `topic_${uuidv4().replace(/-/g, '').slice(0, 8)}`;
+                  wsRef.current.sendPlayTopicFirstLine(reqId, nextTopic.key);
+                }
+              })}
+            >
+              <img
+                src={toggleIconBtn}
+                alt=""
+                className="w-full h-full object-contain pointer-events-none"
+                draggable={false}
+              />
+            </button>
+        </div>
+        );
+      })()}
       {/* 字幕区域 - 设计稿 393×852:top 500,高 92,宽 312,左右居中 */}
       {subtitleOn && subtitleVisible && recognizedText && (
         <div
@@ -3358,7 +3478,7 @@ export const ChatBotPage = () => {
               <button
                 type="button"
                 className="touch-manipulation cursor-pointer border-0 bg-transparent p-0"
-                onClick={() => setShowFuncMenuPopup((prev) => !prev)}
+                onClick={wrapTrackedClick('chatbot', 'settings', () => setShowFuncMenuPopup((prev) => !prev))}
                 style={{
                   width: px2vmin(48),
                   height: px2vmin(47),
@@ -3381,7 +3501,7 @@ export const ChatBotPage = () => {
                     className="fixed inset-0 z-30"
                     style={{ left: 0, right: 0, top: 0, bottom: 0 }}
                     aria-hidden
-                    onClick={() => setShowFuncMenuPopup(false)}
+                    onClick={wrapTrackedClick('chatbot', 'settings_close', () => setShowFuncMenuPopup(false))}
                   />
                   <div
                     className="absolute z-40 flex flex-col rounded-lg"
@@ -3407,10 +3527,10 @@ export const ChatBotPage = () => {
                         label: tCommon('profile'),
                         icon: profileMenuIcon,
                         enabled: true,
-                        onClick: () => {
+                        onClick: wrapTrackedClick('chatbot', 'profile', () => {
                           setShowFuncMenuPopup(false);
                           navigate('/profile');
-                        },
+                        }),
                       },
                       {
                         key: 'shop',
@@ -3499,7 +3619,7 @@ export const ChatBotPage = () => {
           {currentState === 'recording' && (
             <div
               className="chatbot-action-buttons flex items-center justify-center cursor-pointer touch-manipulation relative"
-              onClick={handleCancel}
+              onClick={wrapTrackedClick('chatbot', 'stop', handleCancel)}
               style={{
                 width: px2vmin(56),
                 height: px2vmin(54),
@@ -3538,7 +3658,7 @@ export const ChatBotPage = () => {
           {currentState !== 'recording' && (
             <div
               className={`chatbot-record-button touch-manipulation flex items-center justify-center ${isRecordDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
-              onClick={handleStartRecord}
+              onClick={wrapTrackedClick('chatbot', 'mic', handleStartRecord)}
               style={{
                 width: px2vmin(84),
                 height: px2vmin(82),
@@ -3595,7 +3715,7 @@ export const ChatBotPage = () => {
             <button
               type="button"
               className="touch-manipulation cursor-pointer border-0 bg-transparent p-0 flex items-center justify-center"
-              onClick={() => {
+              onClick={wrapTrackedClick('chatbot', 'subtitle_toggle', () => {
                 setSubtitleOn((prev) => {
                   const next = !prev;
                   // 持久化到 localStorage
@@ -3609,7 +3729,7 @@ export const ChatBotPage = () => {
                   }
                   return next;
                 });
-              }}
+              })}
               style={{
                 width: px2vmin(48),
                 height: px2vmin(47),
@@ -3627,7 +3747,7 @@ export const ChatBotPage = () => {
           {currentState === 'recording' && (
             <div
               className="chatbot-action-buttons flex items-center justify-center cursor-pointer touch-manipulation relative"
-              onClick={handleSend}
+              onClick={wrapTrackedClick('chatbot', 'send', handleSend)}
               style={{
                 width: px2vmin(56),
                 height: px2vmin(54),
@@ -3660,6 +3780,7 @@ export const ChatBotPage = () => {
         visible={showMissionStoneModal}
         onClose={handleMissionStoneModalClose}
         stoneType={energyStoneModalType}
+        trackingPageId="chatbot"
       />
 
       {/* 羁绊指数底部弹窗:点击亲密度心形时展示,中英文随语言切换 */}
@@ -3676,7 +3797,7 @@ export const ChatBotPage = () => {
               zIndex: 9998,
               animation: 'fadeIn 0.3s ease-out',
             }}
-            onClick={() => setShowBondLevelModal(false)}
+            onClick={wrapTrackedClick('chatbot', 'bond_level_modal_close', () => setShowBondLevelModal(false))}
             aria-hidden
           />
           <div
@@ -3768,6 +3889,7 @@ export const ChatBotPage = () => {
         daysSinceLastVisit={intimacyDaysSinceLastVisit}
         canLightUpToday={canLightUpToday}
         loading={intimacyLoading}
+        trackingPageId="chatbot"
         onClose={() => {
           const uid = getUserId();
           // 没有 userId 时无法调用后端,直接关闭并触发首句即可

+ 2 - 1
frontend/src/pages/ErrorPage.tsx

@@ -2,6 +2,7 @@ import React from 'react';
 import { useNavigate } from 'react-router-dom';
 import { px2vw, px2vh, px2fluidFontSize } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const lumiImage = getOssUrl('Lumi-image.png');
 import './WelcomePage.css';
@@ -50,7 +51,7 @@ export const ErrorPage: React.FC = () => {
             {/* 刷新尝试按钮 - 在文字下方 */}
             <button
               type="button"
-              onClick={handleRefresh}
+              onClick={wrapTrackedClick('error', 'refresh', handleRefresh)}
               className="relative text-white font-semibold tracking-[0.5px] transition-all touch-manipulation"
               style={{
                 width: px2vw(163),

+ 12 - 11
frontend/src/pages/FirstLoginPage.tsx

@@ -7,6 +7,7 @@ import { updateUserUIState, getQQAuthUrl, getUserInfo, getUserUIState, ApiError
 import { px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
 // const wechatLogin = getOssUrl('wechat-login.png');
@@ -220,10 +221,10 @@ export const FirstLoginPage = () => {
               >
                 <button
                   type="button"
-                  onClick={() => {
+                  onClick={wrapTrackedClick('first_login', 'area_code_toggle', () => {
                     setIsAreaCodeOpen((prev) => !prev);
                     setIsAreaCodeFocused(true);
-                  }}
+                  })}
                   className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
                   style={{
                     paddingLeft: px2vmin(12),
@@ -276,11 +277,11 @@ export const FirstLoginPage = () => {
                       <button
                         key={item.code}
                         type="button"
-                        onClick={() => {
+                        onClick={wrapTrackedClick('first_login', 'area_code_select', () => {
                           setAreaCode(item.code);
                           setIsAreaCodeOpen(false);
                           setIsAreaCodeFocused(false);
-                        }}
+                        })}
                         className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
                         style={{
                           padding: `${px2vmin(10)} ${px2vmin(12)}`,
@@ -343,7 +344,7 @@ export const FirstLoginPage = () => {
                     height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
-                  onClick={() => setPhoneNumber('')}
+                  onClick={wrapTrackedClick('first_login', 'clear_phone', () => setPhoneNumber(''))}
                   aria-label="清除"
                 >
                   <img
@@ -409,7 +410,7 @@ export const FirstLoginPage = () => {
                   height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
-                onClick={() => setEmail('')}
+                onClick={wrapTrackedClick('first_login', 'clear_email', () => setEmail(''))}
                 aria-label="清除"
               >
                 <img
@@ -451,7 +452,7 @@ export const FirstLoginPage = () => {
       {/* Continue 按钮 */}
       <button
         type="button"
-        onClick={() => {
+        onClick={wrapTrackedClick('first_login', 'continue', () => {
           // 手机号码或邮箱登录时,跳转到验证码页面
           if (selectedLoginType === 'phone' && phoneNumber) {
             // 校验手机号
@@ -491,7 +492,7 @@ export const FirstLoginPage = () => {
               message.error(tCommon('qqLoginFailed'));
             }
           }
-        }}
+        })}
         style={{
           position: 'absolute',
           left: '50%',
@@ -574,7 +575,7 @@ export const FirstLoginPage = () => {
           <img
             src={qqLogin}
             alt="qq login"
-            onClick={() => setSelectedLoginType('qq')}
+            onClick={wrapTrackedClick('first_login', 'select_qq', () => setSelectedLoginType('qq'))}
             style={{
               width: px2vmin(40),
               height: px2vmin(40),
@@ -586,7 +587,7 @@ export const FirstLoginPage = () => {
           <img
             src={phoneLogin}
             alt="phone login"
-            onClick={() => setSelectedLoginType('phone')}
+            onClick={wrapTrackedClick('first_login', 'select_phone', () => setSelectedLoginType('phone'))}
             style={{
               width: px2vmin(40),
               height: px2vmin(40),
@@ -598,7 +599,7 @@ export const FirstLoginPage = () => {
           <img
             src={emailLogin}
             alt="email login"
-            onClick={() => setSelectedLoginType('email')}
+            onClick={wrapTrackedClick('first_login', 'select_email', () => setSelectedLoginType('email'))}
             style={{
               width: px2vmin(40),
               height: px2vmin(40),

+ 8 - 7
frontend/src/pages/FirstLoginRewardPage.tsx

@@ -15,6 +15,7 @@ import {
 import { updateUserUIState, claimShareReward, getUserUIState, getUserInfo, ApiError } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 import primaryDefaultButton from '@/assets-bak/primary-default.png';
 
 const backButton = getOssUrl('backbutn.png');
@@ -270,7 +271,7 @@ export const FirstLoginRewardPage = () => {
       {/* 顶部返回按钮 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('first_login_reward', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vmin(16),
@@ -376,7 +377,7 @@ export const FirstLoginRewardPage = () => {
           {/* 分享按钮 */}
           <button
             type="button"
-            onClick={handleShareClick}
+            onClick={wrapTrackedClick('first_login_reward', 'share', handleShareClick)}
             style={{
               position: 'absolute',
               left: '50%',
@@ -466,7 +467,7 @@ export const FirstLoginRewardPage = () => {
                   {activeShareIndex > 0 && (
                     <button
                       type="button"
-                      onClick={handlePreviousImage}
+                      onClick={wrapTrackedClick('first_login_reward', 'prev_image', handlePreviousImage)}
                       style={{
                         position: 'absolute',
                         left: px2vmin(-12),
@@ -499,7 +500,7 @@ export const FirstLoginRewardPage = () => {
                   )}
                   <button
                     type="button"
-                    onClick={handleShareClose}
+                    onClick={wrapTrackedClick('first_login_reward', 'share_close', handleShareClose)}
                     style={{
                       position: 'absolute',
                       right: 0,
@@ -600,7 +601,7 @@ export const FirstLoginRewardPage = () => {
                   {activeShareIndex < shareImages.length - 1 && (
                     <button
                       type="button"
-                      onClick={handleNextImage}
+                      onClick={wrapTrackedClick('first_login_reward', 'next_image', handleNextImage)}
                       style={{
                         position: 'absolute',
                         right: px2vmin(-12),
@@ -655,7 +656,7 @@ export const FirstLoginRewardPage = () => {
                       key={item.id}
                       type="button"
                       aria-label={`Select share image ${index + 1}`}
-                      onClick={() => setActiveShareIndex(index)}
+                      onClick={wrapTrackedClick('first_login_reward', 'share_index', () => setActiveShareIndex(index))}
                       style={{
                         width: isActive ? px2vmin(24) : px2vmin(6),
                         height: px2vmin(6),
@@ -730,7 +731,7 @@ export const FirstLoginRewardPage = () => {
                 {/* 领取奖励按钮 */}
                 <button
                   type="button"
-                  onClick={handleSaveImage}
+                  onClick={wrapTrackedClick('first_login_reward', 'save_image', handleSaveImage)}
                   style={{
                     position: 'relative',
                     border: 'none',

+ 9 - 16
frontend/src/pages/GreetingPage.tsx

@@ -11,9 +11,10 @@ import {
   getVisitedRoutes,
   setUserLevel,
 } from '../utils/storage';
-import { getUserInfo, updateUsername, updateUserUIState, ensureSession } from '../utils/api';
+import { getUserInfo, updateUsername, updateUserUIState } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { getOssUrl } from '../utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const primaryBtn = getOssUrl('primary-default.png');
 const XIconImage = getOssUrl('x-icon.png');
@@ -72,16 +73,8 @@ export const GreetingPage = () => {
         return;
       }
       
-      // 如果数据未预加载,则确保存在有效会话(只在无 token 时真正初始化)
-      let currentUserId = userId;
-      try {
-        const session = await ensureSession();
-        currentUserId = session.userId;
-      } catch (error) {
-        console.error('[GreetingPage] ensureSession 失败:', error);
-        antMessage.warning(tWelcome('responseTimeoutRetry'));
-        currentUserId = null;
-      }
+      // 仅使用已登录用户的 userId(不再通过会话初始化创建匿名用户)
+      const currentUserId = userId;
 
       // 更新用户 UI 状态(非阻塞)
       if (currentUserId) {
@@ -226,7 +219,7 @@ export const GreetingPage = () => {
               width: px2vmin(48),
               height: px2vmin(48),
             }}
-            onClick={handleBack}
+            onClick={wrapTrackedClick('naming', 'back', handleBack)}
             aria-label={tCommon('a11y.back')}
           >
             <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
@@ -241,7 +234,7 @@ export const GreetingPage = () => {
               lineHeight: px2vmin(48),
               fontSize: '14PX', // 固定字号 14px
             }}
-            onClick={handleReplayIntro}
+            onClick={wrapTrackedClick('naming', 'replay_intro', handleReplayIntro)}
           >
             {tWelcome('replayIntro')}
           </button>
@@ -342,10 +335,10 @@ export const GreetingPage = () => {
                 height: px2vmin(24),
                 opacity: isFocused && userName ? 1 : 0.6,
               }}
-              onClick={() => {
+              onClick={wrapTrackedClick('naming', 'clear_name', () => {
                 setHasUserEdited(true);
                 setUserName('');
-              }}
+              })}
               aria-label={tCommon('a11y.clear')}
             >
               <img
@@ -411,7 +404,7 @@ export const GreetingPage = () => {
         >
           <button
             type="button"
-            onClick={handleSubmit}
+            onClick={wrapTrackedClick('naming', 'submit', handleSubmit)}
             style={{
               width: '100%',
               height: px2vh(56),

+ 3 - 2
frontend/src/pages/Level34WelcomePage.tsx

@@ -4,6 +4,7 @@ import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, getUserLevel } f
 import { updateUserUIState } from '@/utils/api';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
 const registerForGift = getOssUrl('register-for-gift.png');
@@ -54,7 +55,7 @@ export const Level34WelcomePage = () => {
       {/* 顶部返回按钮 - 从顶部下落动画 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('level34_welcome', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vmin(16),
@@ -170,7 +171,7 @@ export const Level34WelcomePage = () => {
       {/* 注册按钮 - 左右居中,宽度279,top 656,从底部滑入动画 */}
       <button
         type="button"
-        onClick={handleRegisterClick}
+        onClick={wrapTrackedClick('level34_welcome', 'register', handleRegisterClick)}
         style={{
           position: 'absolute',
           left: '50%',

+ 2 - 1
frontend/src/pages/Level5WelcomePage.tsx

@@ -4,6 +4,7 @@ import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/s
 import { updateUserUIState } from '@/utils/api';
 import { px2vw, px2vh, px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const giftProgressBar = getOssUrl('gift-progress-bar.png');
 const registerForGift = getOssUrl('register-for-gift.png');
@@ -138,7 +139,7 @@ export const Level5WelcomePage = () => {
       {/* 注册按钮 - 左右居中,宽度279,top 656,从底部滑入动画 */}
       <button
         type="button"
-        onClick={handleRegisterClick}
+        onClick={wrapTrackedClick('level5_welcome', 'register', handleRegisterClick)}
         style={{
           position: 'absolute',
           left: '50%',

+ 2 - 1
frontend/src/pages/LoadingPage.tsx

@@ -1,6 +1,7 @@
 import React, { useEffect, useState } from 'react';
 import { useNavigate } from 'react-router-dom';
 import { px2vw, px2vh, px2fluidFontSize } from '../utils/viewport';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const LoadingPage: React.FC = () => {
   const navigate = useNavigate();
@@ -152,7 +153,7 @@ const LoadingPage: React.FC = () => {
               fontSize: px2fluidFontSize(18),
               borderRadius: px2vw(50),
             }}
-            onClick={handleEnter}
+            onClick={wrapTrackedClick('loading', 'enter', handleEnter)}
           >
             点击进入
           </button>

+ 10 - 11
frontend/src/pages/LoginPage.tsx

@@ -7,6 +7,7 @@ import { updateUserUIState, getQQAuthUrl, sendCode, getCommunityChoice, ApiError
 import { px2vmin, px2vw, px2vh, type ViewportDesign } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const DESIGN: ViewportDesign = 'v2';
 
@@ -191,7 +192,7 @@ export const LoginPage = () => {
       {/* 顶部返回按钮:与 phone-login 一致 393×852 下 宽48 高48 左24 上125 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('email_login', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vw(24, DESIGN),
@@ -271,9 +272,7 @@ export const LoginPage = () => {
               >
                 <button
                   type="button"
-                  onClick={() => {
-                    setIsAreaCodeOpen((prev) => !prev);
-                  }}
+                  onClick={wrapTrackedClick('email_login', 'area_code_toggle', () => setIsAreaCodeOpen((prev) => !prev))}
                   className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
                   style={{
                     paddingLeft: px2vmin(12, DESIGN),
@@ -326,10 +325,10 @@ export const LoginPage = () => {
                         <button
                           key={item.code}
                           type="button"
-                          onClick={() => {
+                          onClick={wrapTrackedClick('email_login', 'area_code_select', () => {
                             setAreaCode(item.code);
                             setIsAreaCodeOpen(false);
-                          }}
+                          })}
                           className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
                           style={{
                             padding: `${px2vmin(10, DESIGN)} ${px2vmin(12, DESIGN)}`,
@@ -389,7 +388,7 @@ export const LoginPage = () => {
                     height: px2vmin(24, DESIGN),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
-                  onClick={() => setPhoneNumber('')}
+                  onClick={wrapTrackedClick('email_login', 'clear_phone', () => setPhoneNumber(''))}
                   aria-label={t('clear')}
                 >
                   <img
@@ -445,7 +444,7 @@ export const LoginPage = () => {
                   height: px2vmin(24, DESIGN),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
-                onClick={() => setEmail('')}
+                onClick={wrapTrackedClick('email_login', 'clear_email', () => setEmail(''))}
                 aria-label={t('clear')}
               >
                 <img
@@ -483,7 +482,7 @@ export const LoginPage = () => {
         {/* 下一步按钮:与 phone-login 一致 primary-default,Ag title-medium 16/24 */}
         <button
           type="button"
-          onClick={handleContinue}
+          onClick={wrapTrackedClick('email_login', 'submit', handleContinue)}
           disabled={isChecking}
           style={{
             width: '100%',
@@ -541,7 +540,7 @@ export const LoginPage = () => {
                 alignItems: 'center',
                 justifyContent: 'center',
               }}
-              onClick={() => setOptOutEarlyCommunity(!optOutEarlyCommunity)}
+              onClick={wrapTrackedClick('email_login', 'opt_out_community', () => setOptOutEarlyCommunity(!optOutEarlyCommunity))}
               onKeyDown={(e) => {
                 if (e.key === 'Enter' || e.key === ' ') {
                   e.preventDefault();
@@ -569,7 +568,7 @@ export const LoginPage = () => {
                 userSelect: 'none',
                 cursor: 'pointer',
               }}
-              onClick={() => setOptOutEarlyCommunity(!optOutEarlyCommunity)}
+              onClick={wrapTrackedClick('email_login', 'opt_out_community', () => setOptOutEarlyCommunity(!optOutEarlyCommunity))}
             >
               {t('leaveCommunity')}
             </span>

+ 10 - 7
frontend/src/pages/LoginRegisterPage.tsx

@@ -12,6 +12,7 @@ const DESIGN: ViewportDesign = 'v2';
 import { getOssUrl } from '@/utils/oss';
 import { UserAgreementModal } from '@/components/UserAgreementModal';
 import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const lumiImage = getOssUrl('01-Default.gif');
 const primaryBtn = getOssUrl('primary-default.png');
@@ -59,7 +60,7 @@ const LoginLanguageSelector: React.FC<LoginLanguageSelectorProps> = ({ design })
       <div style={{ position: 'relative', width: '100%', height: '100%' }}>
         <button
           type="button"
-          onClick={() => setOpen((prev) => !prev)}
+          onClick={wrapTrackedClick('login_register', 'lang_toggle', () => setOpen((prev) => !prev))}
           aria-haspopup="listbox"
           aria-expanded={open}
           style={{
@@ -122,7 +123,7 @@ const LoginLanguageSelector: React.FC<LoginLanguageSelectorProps> = ({ design })
           >
             <button
               type="button"
-              onClick={() => handleChange(otherOptionCode)}
+              onClick={wrapTrackedClick('login_register', 'lang_select', () => handleChange(otherOptionCode))}
               style={{
                 width: '100%',
                 border: 'none',
@@ -258,7 +259,7 @@ export const LoginRegisterPage = () => {
               color: '#fff',
               WebkitTapHighlightColor: 'transparent',
             }}
-            onClick={handleEmail}
+            onClick={wrapTrackedClick('login_register', 'email_login', handleEmail)}
             onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleEmail(); } }}
           >
             {t('continueEmail')}
@@ -291,7 +292,7 @@ export const LoginRegisterPage = () => {
               color: '#fff',
               WebkitTapHighlightColor: 'transparent',
             }}
-            onClick={handlePhone}
+            onClick={wrapTrackedClick('login_register', 'phone_login', handlePhone)}
             onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handlePhone(); } }}
           >
             {t('continuePhone')}
@@ -328,7 +329,7 @@ export const LoginRegisterPage = () => {
                   alignItems: 'center',
                   justifyContent: 'center',
                 }}
-                onClick={() => setAgreeTerms(!agreeTerms)}
+                onClick={wrapTrackedClick('login_register', 'agree_terms', () => setAgreeTerms(!agreeTerms))}
               >
                 {agreeTerms && (
                   <div
@@ -357,7 +358,7 @@ export const LoginRegisterPage = () => {
                     cursor: 'pointer',
                     transition: 'opacity 0.2s ease',
                   }}
-                  onClick={() => setShowAgreementModal(true)}
+                  onClick={wrapTrackedClick('login_register', 'show_agreement', () => setShowAgreementModal(true))}
                   onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
                   onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
                 >
@@ -370,7 +371,7 @@ export const LoginRegisterPage = () => {
                     cursor: 'pointer',
                     transition: 'opacity 0.2s ease',
                   }}
-                  onClick={() => setShowPrivacyPolicyModal(true)}
+                  onClick={wrapTrackedClick('login_register', 'show_privacy', () => setShowPrivacyPolicyModal(true))}
                   onMouseEnter={(e) => { e.currentTarget.style.opacity = '0.8'; }}
                   onMouseLeave={(e) => { e.currentTarget.style.opacity = '1'; }}
                 >
@@ -385,10 +386,12 @@ export const LoginRegisterPage = () => {
       <UserAgreementModal
         visible={showAgreementModal}
         onClose={() => setShowAgreementModal(false)}
+        trackingPageId="login_register"
       />
       <PrivacyPolicyModal
         visible={showPrivacyPolicyModal}
         onClose={() => setShowPrivacyPolicyModal(false)}
+        trackingPageId="login_register"
       />
     </>
   );

+ 8 - 7
frontend/src/pages/LoginVerificationCodePage.tsx

@@ -7,6 +7,7 @@ import { updateUserUIState, sendCode, ApiError } from '@/utils/api';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const primaryBtn = getOssUrl('primary-default.png');
 const backButton = getOssUrl('backbutn-icon.png');
@@ -156,7 +157,7 @@ export const LoginVerificationCodePage = () => {
       {/* 顶部返回按钮:设计稿 393×852 下 宽48 高48 左24 上125 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('login_verification_code', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vw(w393(24)),
@@ -235,7 +236,7 @@ export const LoginVerificationCodePage = () => {
               >
                 <button
                   type="button"
-                  onClick={() => setIsAreaCodeOpen((prev) => !prev)}
+                  onClick={wrapTrackedClick('login_verification_code', 'area_code_toggle', () => setIsAreaCodeOpen((prev) => !prev))}
                   className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
                   style={{
                     paddingLeft: px2vmin(12),
@@ -288,10 +289,10 @@ export const LoginVerificationCodePage = () => {
                         <button
                           key={item.code}
                           type="button"
-                          onClick={() => {
+                          onClick={wrapTrackedClick('login_verification_code', 'area_code_select', () => {
                             setAreaCode(item.code);
                             setIsAreaCodeOpen(false);
-                          }}
+                          })}
                           className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
                           style={{
                           padding: `${px2vmin(10)} ${px2vmin(12)}`,
@@ -352,7 +353,7 @@ export const LoginVerificationCodePage = () => {
                     height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
-                  onClick={() => setPhoneNumber('')}
+                  onClick={wrapTrackedClick('login_verification_code', 'clear_phone', () => setPhoneNumber(''))}
                   aria-label="清除"
                 >
                   <img
@@ -406,7 +407,7 @@ export const LoginVerificationCodePage = () => {
                   height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
-                onClick={() => setEmail('')}
+                onClick={wrapTrackedClick('login_verification_code', 'clear_email', () => setEmail(''))}
                 aria-label="清除"
               >
                 <img
@@ -444,7 +445,7 @@ export const LoginVerificationCodePage = () => {
         {/* 发送验证码按钮:primary-default,Ag title-medium 16/24,与 phone-login 下一步按钮一致 */}
         <button
           type="button"
-          onClick={handleSendCode}
+          onClick={wrapTrackedClick('login_verification_code', 'send_code', handleSendCode)}
           disabled={
             !((selectedLoginType === 'phone' && phoneNumber) || (selectedLoginType === 'email' && email))
           }

+ 13 - 12
frontend/src/pages/LoginVerificationInputPage.tsx

@@ -7,6 +7,7 @@ import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName, get
 import { updateUserUIState, verifyAndAuth, updateCommunityChoice, sendCode, getUserInfo, getUserUIState, getEarlyCommunity, submitEarlyCommunity, ApiError } from '@/utils/api';
 import { px2vmin, px2vw, px2vh, type ViewportDesign } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const primaryBtn = getOssUrl('primary-default.png');
 const backButton = getOssUrl('backbutn-icon.png');
@@ -311,7 +312,7 @@ export const LoginVerificationInputPage = () => {
                   const uiRes = await getUserUIState(userId);
                   if (uiRes?.success && uiRes.data) {
                     syncUserDataFromDatabase(userInfoData ?? { user_id: userId }, uiRes.data);
-                    const target = getLastOnboardingRoute(uiRes.data.visited_routes);
+                    const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
                     navigate(target, { replace: true });
                   } else {
                     navigate('/chatbot', { replace: true });
@@ -427,7 +428,7 @@ export const LoginVerificationInputPage = () => {
       {/* 返回按钮:设计稿 393×852 下 48×48,左24,上125 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('login_verification_input', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vw(24, DESIGN),
@@ -557,7 +558,7 @@ export const LoginVerificationInputPage = () => {
           return (
             <button
               type="button"
-              onClick={handleSendCode}
+              onClick={wrapTrackedClick('login_verification_input', 'send_code', handleSendCode)}
               disabled={buttonDisabled}
               style={{
                 flexShrink: 0,
@@ -576,7 +577,7 @@ export const LoginVerificationInputPage = () => {
                 fontWeight: 500,
                 fontSize: px2vmin(16, DESIGN),
                 lineHeight: px2vmin(24, DESIGN),
-                color: isCodeComplete && !hasCodeError ? '#FFFFFF' : 'rgba(245, 243, 240, 0.6)',
+                color: buttonDisabled ? 'rgba(245, 243, 240, 0.6)' : '#FFFFFF',
                 WebkitTapHighlightColor: 'transparent',
               }}
             >
@@ -604,7 +605,7 @@ export const LoginVerificationInputPage = () => {
                 const uiRes = await getUserUIState(pending.userId);
                 if (uiRes?.success && uiRes.data) {
                   syncUserDataFromDatabase(pending.userInfoData ?? { user_id: pending.userId }, uiRes.data);
-                  const target = getLastOnboardingRoute(uiRes.data.visited_routes);
+                  const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
                   navigate(target, { replace: true });
                 } else {
                   navigate('/chatbot', { replace: true });
@@ -637,7 +638,7 @@ export const LoginVerificationInputPage = () => {
           {/* 左上角关闭:不参与,x-icon.png 24×24,距左8距顶8 */}
           <button
             type="button"
-            onClick={() => {
+            onClick={wrapTrackedClick('login_verification_input', 'early_community_close', () => {
               const uid = getUserId();
               if (uid) submitEarlyCommunity(uid).catch(() => {});
               setEarlyCommunityModalVisible(false);
@@ -652,7 +653,7 @@ export const LoginVerificationInputPage = () => {
                     const uiRes = await getUserUIState(pending.userId);
                     if (uiRes?.success && uiRes.data) {
                       syncUserDataFromDatabase(pending.userInfoData ?? { user_id: pending.userId }, uiRes.data);
-                      const target = getLastOnboardingRoute(uiRes.data.visited_routes);
+                      const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
                       navigate(target, { replace: true });
                     } else {
                       navigate('/chatbot', { replace: true });
@@ -662,7 +663,7 @@ export const LoginVerificationInputPage = () => {
                   }
                 })();
               }
-            }}
+            })}
             style={{
               position: 'absolute',
               left: px2vw(8, DESIGN),
@@ -747,7 +748,7 @@ export const LoginVerificationInputPage = () => {
                     background: 'none',
                     cursor: 'pointer',
                   }}
-                  onClick={() => setEarlyCommunityEmail('')}
+                  onClick={wrapTrackedClick('login_verification_input', 'clear_early_email', () => setEarlyCommunityEmail(''))}
                   aria-label={tLogin('clear')}
                 >
                   <img
@@ -762,7 +763,7 @@ export const LoginVerificationInputPage = () => {
           </div>
           <button
             type="button"
-            onClick={() => {
+            onClick={wrapTrackedClick('login_verification_input', 'early_community_submit', () => {
               const uid = getUserId();
               if (uid) {
                 const email = earlyCommunityEmail.trim() || undefined;
@@ -780,7 +781,7 @@ export const LoginVerificationInputPage = () => {
                     const uiRes = await getUserUIState(pending.userId);
                     if (uiRes?.success && uiRes.data) {
                       syncUserDataFromDatabase(pending.userInfoData ?? { user_id: pending.userId }, uiRes.data);
-                      const target = getLastOnboardingRoute(uiRes.data.visited_routes);
+                      const target = getLastOnboardingRoute(uiRes.data.visited_routes, uiRes.data.current_page ?? undefined);
                       navigate(target, { replace: true });
                     } else {
                       navigate('/chatbot', { replace: true });
@@ -790,7 +791,7 @@ export const LoginVerificationInputPage = () => {
                   }
                 })();
               }
-            }}
+            })}
             style={{
               width: '100%',
               height: px2vh(48, DESIGN),

+ 5 - 4
frontend/src/pages/LumiNamingPage.tsx

@@ -5,6 +5,7 @@ import { getStorage, setStorage, STORAGE_KEYS, markRouteVisited, getUserId, getV
 import { updateBotName, updateUserUIState } from '../utils/api';
 import { px2vw, px2vh, px2vmin } from '../utils/viewport';
 import { getOssUrl } from '../utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const primaryBtn = getOssUrl('primary-default.png');
 const XIconImage = getOssUrl('x-icon.png');
@@ -115,7 +116,7 @@ export const LumiNamingPage = () => {
               width: px2vmin(48),
               height: px2vmin(48),
             }}
-            onClick={handleBack}
+            onClick={wrapTrackedClick('lumi_naming', 'back', handleBack)}
             aria-label={tCommon('a11y.back')}
           >
             <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
@@ -130,7 +131,7 @@ export const LumiNamingPage = () => {
               lineHeight: px2vmin(48),
               fontSize: '14PX',
             }}
-            onClick={handleReplayIntro}
+            onClick={wrapTrackedClick('lumi_naming', 'replay_intro', handleReplayIntro)}
           >
             Replay intro
           </button> */}
@@ -219,7 +220,7 @@ export const LumiNamingPage = () => {
                 height: px2vmin(24),
                 opacity: isFocused && lumiName ? 1 : 0.6,
               }}
-              onClick={() => setLumiName('')}
+              onClick={wrapTrackedClick('lumi_naming', 'clear_name', () => setLumiName(''))}
               aria-label={tCommon('a11y.clear')}
             >
               <img
@@ -285,7 +286,7 @@ export const LumiNamingPage = () => {
         >
           <button
             type="button"
-            onClick={handleSubmit}
+            onClick={wrapTrackedClick('lumi_naming', 'submit', handleSubmit)}
             style={{
               width: '100%',
               height: px2vh(56),

+ 8 - 9
frontend/src/pages/PhoneLogin.tsx

@@ -7,6 +7,7 @@ import { updateUserUIState, getQQAuthUrl, sendCode, ApiError } from '@/utils/api
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 const primaryBtn = getOssUrl('primary-default.png');
 const backButton = getOssUrl('backbutn-icon.png');
@@ -168,7 +169,7 @@ export const PhoneLogin = () => {
       {/* 顶部返回按钮:设计稿 393×852 下 宽48 高48 左24 上125 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('phone_login', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vw(w393(24)),
@@ -248,9 +249,7 @@ export const PhoneLogin = () => {
               >
                 <button
                   type="button"
-                  onClick={() => {
-                    setIsAreaCodeOpen((prev) => !prev);
-                  }}
+                  onClick={wrapTrackedClick('phone_login', 'area_code_toggle', () => setIsAreaCodeOpen((prev) => !prev))}
                   className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
                   style={{
                     paddingLeft: px2vmin(12),
@@ -303,10 +302,10 @@ export const PhoneLogin = () => {
                         <button
                           key={item.code}
                           type="button"
-                          onClick={() => {
+                          onClick={wrapTrackedClick('phone_login', 'area_code_select', () => {
                             setAreaCode(item.code);
                             setIsAreaCodeOpen(false);
-                          }}
+                          })}
                           className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
                           style={{
                           padding: `${px2vmin(10)} ${px2vmin(12)}`,
@@ -367,7 +366,7 @@ export const PhoneLogin = () => {
                     height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
-                  onClick={() => setPhoneNumber('')}
+                  onClick={wrapTrackedClick('phone_login', 'clear_phone', () => setPhoneNumber(''))}
                   aria-label={t('clear')}
                 >
                   <img
@@ -424,7 +423,7 @@ export const PhoneLogin = () => {
                   height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
-                onClick={() => setEmail('')}
+                onClick={wrapTrackedClick('phone_login', 'clear_email', () => setEmail(''))}
                 aria-label={t('clear')}
               >
                 <img
@@ -469,7 +468,7 @@ export const PhoneLogin = () => {
         {/* 下一步按钮:primary-default.png,Ag title-medium 16/24,文字居中;无数据时 #F5F3F0 0.6 透明度,有数据时白色 */}
         <button
           type="button"
-          onClick={handleContinue}
+          onClick={wrapTrackedClick('phone_login', 'submit', handleContinue)}
           disabled={isChecking}
           style={{
             width: '100%',

+ 23 - 19
frontend/src/pages/ProfilePage.tsx

@@ -23,6 +23,7 @@ import {
   clearUserSessionAndRedirectToBegin,
 } from '@/utils/storage';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 import { MissionEnergyStoneModal } from '@/components/MissionEnergyStoneModal';
 import { UserAgreementModal } from '@/components/UserAgreementModal';
 import { PrivacyPolicyModal } from '@/components/PrivacyPolicyModal';
@@ -481,7 +482,7 @@ export const ProfilePage = () => {
       <img
         src={closeBtn}
         alt="Close"
-        onClick={handleClose}
+        onClick={wrapTrackedClick('profile', 'close', handleClose)}
         style={{
           width: px2vmin(30),
           height: px2vmin(30),
@@ -518,7 +519,7 @@ export const ProfilePage = () => {
         <img
           src={editIcon}
           alt="Edit"
-          onClick={handleEditUsername}
+          onClick={wrapTrackedClick('profile', 'edit_username', handleEditUsername)}
           style={{
             width: px2vmin(20),
             height: px2vmin(20),
@@ -544,7 +545,7 @@ export const ProfilePage = () => {
       >
         {/* 对话能量石:放在等级行左侧 */}
         <div
-          onClick={() => handleEnergyStoneClick('conversation')}
+          onClick={wrapTrackedClick('profile', 'energy_stone_conversation', () => handleEnergyStoneClick('conversation'))}
           style={{
             position: 'relative',
             width: px2vmin(70),
@@ -766,7 +767,7 @@ export const ProfilePage = () => {
           <img
             src={editIcon}
             alt="Edit"
-            onClick={handleEditBotName}
+            onClick={wrapTrackedClick('profile', 'edit_bot_name', handleEditBotName)}
             style={{
               width: px2vmin(20),
               height: px2vmin(20),
@@ -811,7 +812,7 @@ export const ProfilePage = () => {
             >
               {/* 108*70 边框区域 */}
               <div
-                onClick={handleHeadClick}
+                onClick={wrapTrackedClick('profile', 'head_click', handleHeadClick)}
                 style={{
                   width: px2vmin(108),
                   height: px2vmin(70),
@@ -933,7 +934,7 @@ export const ProfilePage = () => {
       >
         {/* 意见反馈 & 帮助 */}
         <div
-          onClick={handleOpenFeedbackModal}
+          onClick={wrapTrackedClick('profile', 'open_feedback', handleOpenFeedbackModal)}
           style={{
             display: 'flex',
             alignItems: 'center',
@@ -967,7 +968,7 @@ export const ProfilePage = () => {
 
         {/* 使用协议 */}
         <div
-          onClick={() => setShowAgreementModal(true)}
+          onClick={wrapTrackedClick('profile', 'show_agreement', () => setShowAgreementModal(true))}
           style={{
             display: 'flex',
             alignItems: 'center',
@@ -1001,7 +1002,7 @@ export const ProfilePage = () => {
 
         {/* 隐私政策 */}
         <div
-          onClick={() => setShowPrivacyPolicyModal(true)}
+          onClick={wrapTrackedClick('profile', 'show_privacy', () => setShowPrivacyPolicyModal(true))}
           style={{
             display: 'flex',
             alignItems: 'center',
@@ -1039,7 +1040,7 @@ export const ProfilePage = () => {
         <>
           {/* 遮罩层 */}
           <div
-            onClick={handleCloseUsernameModal}
+            onClick={wrapTrackedClick('profile', 'close_username_modal', handleCloseUsernameModal)}
             style={{
               position: 'fixed',
               top: 0,
@@ -1155,7 +1156,7 @@ export const ProfilePage = () => {
                       opacity: isUsernameFocused ? 1 : 0.6,
                       zIndex: 1,
                     }}
-                    onClick={() => setTempUsername('')}
+                    onClick={wrapTrackedClick('profile', 'clear_temp_username', () => setTempUsername(''))}
                     onMouseDown={(e) => e.preventDefault()}
                     aria-label={tCommon('a11y.clear')}
                   >
@@ -1184,7 +1185,7 @@ export const ProfilePage = () => {
               }}
             >
               <button
-                onClick={handleCloseUsernameModal}
+                onClick={wrapTrackedClick('profile', 'close_username_modal', handleCloseUsernameModal)}
                 style={{
                   width: px2vmin(80),
                   height: px2vmin(42),
@@ -1209,7 +1210,7 @@ export const ProfilePage = () => {
                 </span>
               </button>
               <button
-                onClick={handleSaveUsername}
+                onClick={wrapTrackedClick('profile', 'save_username', handleSaveUsername)}
                 style={{
                   width: px2vmin(84),
                   height: px2vmin(42),
@@ -1245,7 +1246,7 @@ export const ProfilePage = () => {
         <>
           {/* 遮罩层 */}
           <div
-            onClick={handleCloseBotNameModal}
+            onClick={wrapTrackedClick('profile', 'close_botname_modal', handleCloseBotNameModal)}
             style={{
               position: 'fixed',
               top: 0,
@@ -1360,7 +1361,7 @@ export const ProfilePage = () => {
                       opacity: isBotNameFocused ? 1 : 0.6,
                       zIndex: 1,
                     }}
-                    onClick={() => setTempBotName('')}
+                    onClick={wrapTrackedClick('profile', 'clear_temp_botname', () => setTempBotName(''))}
                     onMouseDown={(e) => e.preventDefault()}
                     aria-label={tCommon('a11y.clear')}
                   >
@@ -1389,7 +1390,7 @@ export const ProfilePage = () => {
               }}
             >
               <button
-                onClick={handleCloseBotNameModal}
+                onClick={wrapTrackedClick('profile', 'close_botname_modal', handleCloseBotNameModal)}
                 style={{
                   width: px2vmin(80),
                   height: px2vmin(42),
@@ -1414,7 +1415,7 @@ export const ProfilePage = () => {
                 </span>
               </button>
               <button
-                onClick={handleSaveBotName}
+                onClick={wrapTrackedClick('profile', 'save_botname', handleSaveBotName)}
                 style={{
                   width: px2vmin(84),
                   height: px2vmin(42),
@@ -1450,6 +1451,7 @@ export const ProfilePage = () => {
         visible={showEnergyStoneModal}
         onClose={handleCloseEnergyStoneModal}
         stoneType={energyStoneModalType}
+        trackingPageId="profile"
       />
 
       {/* 意见反馈弹窗 */}
@@ -1457,7 +1459,7 @@ export const ProfilePage = () => {
         <>
           {/* 遮罩层 */}
           <div
-            onClick={handleCloseFeedbackModal}
+            onClick={wrapTrackedClick('profile', 'close_feedback_modal', handleCloseFeedbackModal)}
             style={{
               position: 'fixed',
               top: 0,
@@ -1558,7 +1560,7 @@ export const ProfilePage = () => {
               }}
             >
               <button
-                onClick={handleCloseFeedbackModal}
+                onClick={wrapTrackedClick('profile', 'close_feedback_modal', handleCloseFeedbackModal)}
                 disabled={isSubmittingFeedback}
                 style={{
                   width: px2vmin(84),
@@ -1585,7 +1587,7 @@ export const ProfilePage = () => {
                 </span>
               </button>
               <button
-                onClick={handleSubmitFeedback}
+                onClick={wrapTrackedClick('profile', 'submit_feedback', handleSubmitFeedback)}
                 disabled={!feedbackText.trim() || isSubmittingFeedback}
                 style={{
                   width: px2vmin(84),
@@ -1620,12 +1622,14 @@ export const ProfilePage = () => {
       <UserAgreementModal
         visible={showAgreementModal}
         onClose={() => setShowAgreementModal(false)}
+        trackingPageId="profile"
       />
 
       {/* 隐私政策弹窗 */}
       <PrivacyPolicyModal
         visible={showPrivacyPolicyModal}
         onClose={() => setShowPrivacyPolicyModal(false)}
+        trackingPageId="profile"
       />
     </div>
     </>

+ 2 - 1
frontend/src/pages/ShareSuccessPage.tsx

@@ -5,6 +5,7 @@ import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, loadBackgroundIm
 import { updateUserUIState } from '@/utils/api';
 import { px2vmin } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 import primaryDefaultButton from '@/assets-bak/primary-default.png';
 
 // const fullProgressBar = getOssUrl('full-progress-bar.png'); // Unused
@@ -152,7 +153,7 @@ export const ShareSuccessPage = () => {
       {/* 继续对话按钮 */}
       <button
         type="button"
-        onClick={handleContinueChat}
+        onClick={wrapTrackedClick('share_success', 'continue_chat', handleContinueChat)}
         style={{
           position: 'absolute',
           left: '50%',

+ 4 - 2
frontend/src/pages/TutorialStep10Page.tsx

@@ -9,6 +9,7 @@ import {
   getUserId,
 } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 export const TutorialStep10Page: React.FC = () => {
   const navigate = useNavigate();
@@ -32,6 +33,7 @@ export const TutorialStep10Page: React.FC = () => {
   const handleClick = useCallback(() => {
     navigate('/chatbot');
   }, [navigate]);
+  const handleTrackedClick = wrapTrackedClick('tutorial_10', 'next', handleClick);
 
   return (
     <>
@@ -39,8 +41,8 @@ export const TutorialStep10Page: React.FC = () => {
       <div
         role="button"
         tabIndex={0}
-        onClick={handleClick}
-        onKeyDown={(e) => e.key === 'Enter' && handleClick()}
+        onClick={handleTrackedClick}
+        onKeyDown={(e) => e.key === 'Enter' && handleTrackedClick()}
         style={{
           position: 'fixed',
           inset: 0,

+ 4 - 2
frontend/src/pages/TutorialStep9Page.tsx

@@ -9,6 +9,7 @@ import {
   getUserId,
 } from '@/utils/storage';
 import { updateUserUIState } from '@/utils/api';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 export const TutorialStep9Page: React.FC = () => {
   const navigate = useNavigate();
@@ -32,6 +33,7 @@ export const TutorialStep9Page: React.FC = () => {
   const handleClick = useCallback(() => {
     navigate('/tutorial-10');
   }, [navigate]);
+  const handleTrackedClick = wrapTrackedClick('tutorial_9', 'next', handleClick);
 
   return (
     <>
@@ -39,8 +41,8 @@ export const TutorialStep9Page: React.FC = () => {
       <div
         role="button"
         tabIndex={0}
-        onClick={handleClick}
-        onKeyDown={(e) => e.key === 'Enter' && handleClick()}
+        onClick={handleTrackedClick}
+        onKeyDown={(e) => e.key === 'Enter' && handleTrackedClick()}
         style={{
           position: 'fixed',
           inset: 0,

+ 9 - 8
frontend/src/pages/VerificationCodePage.tsx

@@ -7,6 +7,7 @@ import { updateUserUIState, sendCode, ApiError } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
 import { validatePhoneNumber, isValidEmail } from '@/utils/validation';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
 const sendCodeNo = getOssUrl('send-code-no.png');
@@ -152,7 +153,7 @@ export const VerificationCodePage = () => {
       {/* 顶部返回按钮 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('verification_code', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vmin(16),
@@ -254,10 +255,10 @@ export const VerificationCodePage = () => {
               >
               <button
                 type="button"
-                onClick={() => {
+                onClick={wrapTrackedClick('verification_code', 'area_code_toggle', () => {
                   setIsAreaCodeOpen((prev) => !prev);
                   setIsAreaCodeFocused(true);
-                }}
+                })}
                 className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
                 style={{
                   paddingLeft: px2vmin(12),
@@ -310,11 +311,11 @@ export const VerificationCodePage = () => {
                       <button
                         key={item.code}
                         type="button"
-                        onClick={() => {
+                        onClick={wrapTrackedClick('verification_code', 'area_code_select', () => {
                           setAreaCode(item.code);
                           setIsAreaCodeOpen(false);
                           setIsAreaCodeFocused(false);
-                        }}
+                        })}
                         className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
                         style={{
                           padding: `${px2vmin(10)} ${px2vmin(12)}`,
@@ -377,7 +378,7 @@ export const VerificationCodePage = () => {
                     height: px2vmin(24),
                     opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
                   }}
-                  onClick={() => setPhoneNumber('')}
+                  onClick={wrapTrackedClick('verification_code', 'clear_phone', () => setPhoneNumber(''))}
                   aria-label="清除"
                 >
                   <img
@@ -443,7 +444,7 @@ export const VerificationCodePage = () => {
                   height: px2vmin(24),
                   opacity: isEmailFocused && email ? 1 : 0.6,
                 }}
-                onClick={() => setEmail('')}
+                onClick={wrapTrackedClick('verification_code', 'clear_email', () => setEmail(''))}
                 aria-label="清除"
               >
                 <img
@@ -485,7 +486,7 @@ export const VerificationCodePage = () => {
       {/* 发送验证码按钮 */}
       <button
         type="button"
-        onClick={handleSendCode}
+        onClick={wrapTrackedClick('verification_code', 'send_code', handleSendCode)}
         style={{
           position: 'absolute',
           left: '50%',

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

@@ -6,6 +6,7 @@ import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } fr
 import { updateUserUIState, verifyCode, sendCode, registerWithPhone, registerWithEmail, getUserInfo, getAvatarGifs, ApiError } from '@/utils/api';
 import { px2vmin, px2vw } from '@/utils/viewport';
 import { getOssUrl } from '@/utils/oss';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
 const sendCodeWaitNo = getOssUrl('send-code-wait-no.png');
@@ -415,7 +416,7 @@ export const VerificationInputPage = () => {
       {/* 顶部返回按钮 */}
       <button
         type="button"
-        onClick={handleBack}
+        onClick={wrapTrackedClick('verification_input', 'back', handleBack)}
         style={{
           position: 'absolute',
           left: px2vmin(16),
@@ -569,7 +570,7 @@ export const VerificationInputPage = () => {
       {isCodeValid ? (
         <button
           type="button"
-          onClick={handleRegisterForGift}
+          onClick={wrapTrackedClick('verification_input', 'register_for_gift', handleRegisterForGift)}
           style={{
             position: 'absolute',
             left: '50%',
@@ -594,7 +595,7 @@ export const VerificationInputPage = () => {
       ) : (
         <button
           type="button"
-          onClick={handleSendCode}
+          onClick={wrapTrackedClick('verification_input', 'send_code', handleSendCode)}
           disabled={countdown > 0}
           style={{
             position: 'absolute',

+ 8 - 13
frontend/src/pages/WelcomePage.tsx

@@ -3,11 +3,12 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
 import { useTranslation } from 'react-i18next';
 import { message as antMessage } from 'antd';
 import videojs from 'video.js';
-import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserId, setUserLevel } from '@/utils/storage';
-import { updateUserUIState, getUserInfo, ensureSession } from '@/utils/api';
+import { setStorage, STORAGE_KEYS, markRouteVisited, getVisitedRoutes, getUserId, getStorage, setUserLevel } from '@/utils/storage';
+import { updateUserUIState, getUserInfo } from '@/utils/api';
 import 'video.js/dist/video-js.css';
 import { getOssUrl } from '@/utils/oss';
 import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
+import { wrapTrackedClick } from '@/utils/tracking';
 
 // 判断是否为后端生成的默认用户名
 const isBackendGeneratedName = (name: string): boolean => /^User\d+$/.test(name.trim());
@@ -382,20 +383,14 @@ export const WelcomePage = () => {
         // 🚀 在视频播放期间预加载用户数据,避免进入 naming 页面时卡顿
         const preloadUserData = async () => {
           try {
-            // 确保存在有效 userId + token(单活),仅在无会话时初始化
-            let userId = getUserId();
-            try {
-              const session = await ensureSession();
-              userId = session.userId;
-              setUserId(userId);
-            } catch (error) {
-              console.error('[WelcomePage] 预加载:ensureSession 失败', error);
-            }
+            // 仅使用已登录用户的 userId(不再通过会话初始化创建匿名用户)
+            const userId = getUserId();
+            if (!userId) return;
 
             // 如果 localStorage 中没有保存的用户名,从后端获取
             const savedName = getStorage(STORAGE_KEYS.USER_NAME);
             if (!savedName) {
-              const user_id = userId || getUserId();
+              const user_id = userId;
               if (user_id) {
                 try {
                   const response = await getUserInfo(user_id);
@@ -818,7 +813,7 @@ export const WelcomePage = () => {
                 />
                 {/* 按钮 */}
                 <button
-                  onClick={handleStartButtonClick}
+                  onClick={wrapTrackedClick('welcome', 'start', handleStartButtonClick)}
                   style={{
                     position: 'absolute',
                     top: px2vh(616),

+ 20 - 0
frontend/src/types/index.ts

@@ -10,6 +10,8 @@ export type MessageType =
   | 'ping'
   | 'pong'
   | 'start_greeting'
+  | 'play_topic_first_line'
+  | 'greeting_context'
   | 'energy_stone_spawn'
   | 'energy_stone_event'
   | 'user_level';
@@ -70,6 +72,9 @@ export interface AudioChunkMessage extends BaseMessage {
   channels: 1;
   bitDepth: 16;
   chunkIndex: number;
+  /** 情景对话:首条语音时携带,供后端落库 */
+  scene?: string;
+  topic?: string;
 }
 
 // 音频结束消息
@@ -77,6 +82,8 @@ export interface AudioEndMessage extends BaseMessage {
   type: 'audio_end';
   totalChunks: number;
   speechDuration?: number; // 说话时长(毫秒)
+  scene?: string;
+  topic?: string;
 }
 
 // 取消消息
@@ -131,6 +138,18 @@ export interface PongMessage extends BaseMessage {
 // 首句 greeting 启动消息(由前端主动触发)
 export interface StartGreetingMessage extends BaseMessage {
   type: 'start_greeting';
+  data?: { forceScene?: 'default' | 'window' | 'reading' | 'eating' };
+}
+
+// 情景对话:后端下发的场景与话题上下文(特殊场景首句前)
+export interface GreetingContextMessage {
+  type: 'greeting_context';
+  requestId: string;
+  timestamp: number;
+  scene: 'default' | 'window' | 'reading' | 'eating';
+  topic: string;
+  topics: Array<{ key: string; labelEn?: string; labelZh?: string }>;
+  firstLine: string;
 }
 
 export type EnergyStoneType = 'mission' | 'conversation';
@@ -175,6 +194,7 @@ export type Message =
   | PingMessage
   | PongMessage
   | StartGreetingMessage
+  | GreetingContextMessage
   | EnergyStoneSpawnMessage
   | EnergyStoneEventMessage
   | UserLevelMessage;

+ 62 - 0
frontend/src/utils/api.ts

@@ -163,6 +163,64 @@ export const reportPageVisit = (path: string): void => {
   }).catch(() => {});
 };
 
+export interface UserEventPayload {
+  event_id: string;
+  user_id: string;
+  event_name: string;
+  event_label?: string;
+  event_time: string;
+  session_id?: string;
+  platform: 'ios' | 'android' | 'web';
+  app_version?: string;
+  timezone_offset_minutes?: number;
+  properties?: Record<string, unknown>;
+}
+
+/**
+ * 上报单条用户事件(user_events)
+ * - 不抛异常,失败静默
+ * - 开发环境会打印请求与响应状态,便于排查未落库问题
+ */
+export const reportUserEvent = (payload: UserEventPayload): void => {
+  const apiBaseUrl = getApiBaseUrl();
+  const url = `${apiBaseUrl}/api/user-events`;
+  const headers = getApiHeaders();
+  if (import.meta.env.DEV) {
+    const hasAuth = !!(headers['X-User-Id'] && headers['X-User-Token']);
+    const keys = Object.keys(payload);
+    const required = ['event_id', 'user_id', 'event_name', 'event_time', 'platform'];
+    const missing = required.filter((k) => !(k in payload) || payload[k as keyof UserEventPayload] == null);
+    if (missing.length) {
+      console.warn('[user_events] 请求体缺少必填字段:', missing, '当前 keys:', keys);
+    }
+    console.log('[user_events] 上报:', payload.event_name, { hasAuth, user_id: payload.user_id, keys });
+  }
+  fetch(url, {
+    method: 'POST',
+    headers,
+    body: JSON.stringify(payload),
+  })
+    .then(async (res) => {
+      if (import.meta.env.DEV && !res.ok) {
+        const body = await res.text();
+        try {
+          const parsed = JSON.parse(body);
+          const details = Array.isArray(parsed?.detail) ? parsed.detail : parsed?.detail ? [parsed.detail] : [];
+          const first = details[0];
+          const loc = first?.loc;
+          console.warn('[user_events] 上报失败:', res.status, payload.event_name, '缺失/错误字段 loc:', loc, 'msg:', first?.msg, '详情:', details);
+        } catch {
+          console.warn('[user_events] 上报失败:', res.status, res.statusText, payload.event_name, body?.slice(0, 200));
+        }
+      }
+    })
+    .catch((err) => {
+      if (import.meta.env.DEV) {
+        console.warn('[user_events] 请求异常:', err?.message ?? err, payload.event_name);
+      }
+    });
+};
+
 /**
  * 创建用户
  */
@@ -266,6 +324,10 @@ export interface GetUserResponse {
     user_id: string;
     username: string;
     user_type: string;
+    user_segment?: string;
+    org_id?: string | null;
+    is_test_user?: boolean;
+    region?: string | null;
     bot_name?: string;
     total_speech_duration_ms?: number;
     user_level?: number;

+ 18 - 19
frontend/src/utils/storage.ts

@@ -196,36 +196,35 @@ export const getNextUnvisitedRoute = (): RouteType | null => {
 };
 
 /**
- * 获取用户上次在 onboarding 流程中访问的最后一个路由(用于再次登录时跳转)
- * 顺序:/welcome → /naming → /lumi-naming → /chatbot
+ * 获取用户上次在 onboarding 流程中应跳转的路由(用于再次登录时跳转)
+ * - 未到 /chatbot:按 visited_routes 中顺序的最后一页跳转(只访问到 /naming 就进 /naming)
+ * - 已到 /chatbot:按已访问的最高 tutorial 跳转(tutorial-10 则进 /chatbot)
  * @param visitedRoutes 可选,不传则从 getVisitedRoutes() 读取;可传 API 返回的 string[]
+ * @param _currentPage 保留参数兼容调用方,逻辑上以 visited_routes 为准
  */
-export const getLastOnboardingRoute = (visitedRoutes?: RouteType[] | string[]): RouteType => {
+export const getLastOnboardingRoute = (
+  visitedRoutes?: RouteType[] | string[],
+  _currentPage?: string | null
+): RouteType => {
   const visited = visitedRoutes != null ? sanitizeVisitedRoutes(visitedRoutes) : getVisitedRoutes();
 
   const hasChatbotVisited = visited.includes('/chatbot');
   const hasTutorialVisited = TUTORIAL_ROUTES.some(route => visited.includes(route));
 
-  // 1. 兼容老用户:已经进入过 chatbot,但从未看过任何教程时,
-  //    新版本上线后强制从 tutorial-1 开始完整教程流程。
-  if (hasChatbotVisited && !hasTutorialVisited) {
-    return '/tutorial-1';
-  }
-
-  // 2. 用户已访问过 /chatbot(即已完成教程并进入聊天页),下次登录应直接进 /chatbot
+  // 1. 老用户已访问过 /chatbot:按已访问的最高 tutorial 决定跳转
   if (hasChatbotVisited) {
-    return '/chatbot';
-  }
-
-  // 3. 对已经进入过教程但尚未到 chatbot 的用户:从最近访问的教程页面继续
-  if (hasTutorialVisited) {
-    const lastTutorial = TUTORIAL_ROUTES.filter(route => visited.includes(route)).pop();
-    if (lastTutorial) {
-      return lastTutorial;
+    if (!hasTutorialVisited) {
+      return '/tutorial-1';
+    }
+    const visitedTutorials = TUTORIAL_ROUTES.filter(route => visited.includes(route));
+    const highestTutorial = visitedTutorials[visitedTutorials.length - 1];
+    if (highestTutorial === '/tutorial-10') {
+      return '/chatbot';
     }
+    return highestTutorial;
   }
 
-  // 4. 其他情况(例如全新用户),保持原有行为:按 ROUTE_ORDER 取最后访问的路由
+  // 2. 未到 chatbot:按访问顺序的最后一页跳转(仅访问到 /welcome 进 /welcome,仅到 /naming 进 /naming,以此类推)
   const last = ROUTE_ORDER.filter(route => visited.includes(route)).pop();
   return last ?? ROUTE_ORDER[0];
 };

+ 144 - 0
frontend/src/utils/tracking.ts

@@ -0,0 +1,144 @@
+import type { MouseEvent as ReactMouseEvent } from 'react';
+import { v4 as uuidv4 } from 'uuid';
+import { reportUserEvent, type UserEventPayload } from '@/utils/api';
+import { getUserId } from '@/utils/storage';
+
+// 路由 path → page_id 映射,需与 .cursorrules 中的命名约定保持一致
+const PATH_TO_PAGE_ID: Record<string, string> = {
+  '/welcome': 'welcome',
+  '/begin': 'begin',
+  '/login-register': 'login_register',
+  '/loading': 'loading',
+  '/naming': 'naming',
+  '/lumi-naming': 'lumi_naming',
+  '/chatbot': 'chatbot',
+  '/tutorial-1': 'tutorial_1',
+  '/tutorial-2': 'tutorial_2',
+  '/tutorial-3': 'tutorial_3',
+  '/tutorial-4': 'tutorial_4',
+  '/tutorial-5': 'tutorial_5',
+  '/tutorial-6': 'tutorial_6',
+  '/tutorial-7': 'tutorial_7',
+  '/tutorial-8': 'tutorial_8',
+  '/tutorial-9': 'tutorial_9',
+  '/tutorial-10': 'tutorial_10',
+  '/level5-welcome': 'level5_welcome',
+  '/level34-welcome': 'level34_welcome',
+  '/first-login': 'first_login',
+  '/verification-code': 'verification_code',
+  '/verification-input': 'verification_input',
+  '/login-verification-code': 'login_verification_code',
+  '/login-verification-input': 'login_verification_input',
+  '/first-login-reward': 'first_login_reward',
+  '/share-success': 'share_success',
+  '/email-login': 'email_login',
+  '/phone-login': 'phone_login',
+  '/profile': 'profile',
+  '/error': 'error',
+};
+
+const getPageIdFromPath = (path: string): string | null => {
+  if (!path) return null;
+  const clean = path.split('?')[0];
+  return PATH_TO_PAGE_ID[clean] ?? null;
+};
+
+const getPlatform = (): UserEventPayload['platform'] => 'web';
+
+const getTimezoneOffsetMinutes = (): number => -new Date().getTimezoneOffset();
+
+const getAppVersion = (): string | undefined => import.meta.env.VITE_APP_VERSION;
+
+const SESSION_ID_KEY = 'lyrria_session_id';
+
+function getOrCreateSessionId(): string {
+  try {
+    let sid = sessionStorage.getItem(SESSION_ID_KEY);
+    if (!sid) {
+      sid = uuidv4();
+      sessionStorage.setItem(SESSION_ID_KEY, sid);
+    }
+    return sid;
+  } catch {
+    return uuidv4();
+  }
+}
+
+export const trackPageEnter = (path: string): void => {
+  const userId = getUserId();
+  const pageId = getPageIdFromPath(path);
+  if (!userId || !pageId) {
+    if (import.meta.env.DEV) {
+      console.log('[tracking] trackPageEnter 跳过:', { path, hasUserId: !!userId, pageId });
+    }
+    return;
+  }
+
+  const event: UserEventPayload = {
+    event_id: uuidv4(),
+    user_id: userId,
+    event_name: `page.${pageId}__enter`,
+    event_label: `进入页面:${pageId}`,
+    event_time: new Date().toISOString(),
+    session_id: getOrCreateSessionId(),
+    platform: getPlatform(),
+    app_version: getAppVersion(),
+    timezone_offset_minutes: getTimezoneOffsetMinutes(),
+    properties: {
+      page: pageId,
+      path,
+    },
+  };
+
+  reportUserEvent(event);
+};
+
+export const trackButtonClick = (
+  pageId: string,
+  buttonId: string,
+  options?: {
+    label?: string;
+    properties?: Record<string, unknown>;
+  },
+): void => {
+  const userId = getUserId();
+  if (!userId) {
+    if (import.meta.env.DEV) {
+      console.log('[tracking] trackButtonClick 跳过: 未登录', { pageId, buttonId });
+    }
+    return;
+  }
+
+  const eventName = `button.${pageId}.${buttonId}__click`;
+  const event: UserEventPayload = {
+    event_id: uuidv4(),
+    user_id: userId,
+    event_name: eventName,
+    event_label: options?.label ?? `按钮点击:${pageId}.${buttonId}`,
+    event_time: new Date().toISOString(),
+    session_id: getOrCreateSessionId(),
+    platform: getPlatform(),
+    app_version: getAppVersion(),
+    timezone_offset_minutes: getTimezoneOffsetMinutes(),
+    properties: {
+      page: pageId,
+      button_id: buttonId,
+      ...(options?.properties ?? {}),
+    },
+  };
+
+  reportUserEvent(event);
+};
+
+/** 包装点击回调:先上报 button 埋点再执行原 onClick(用于原生 button/div 等) */
+export function wrapTrackedClick(
+  pageId: string,
+  buttonId: string,
+  onClick?: (e?: ReactMouseEvent<HTMLElement>) => void,
+  options?: { label?: string; properties?: Record<string, unknown> },
+): (e?: ReactMouseEvent<HTMLElement>) => void {
+  return (e?: ReactMouseEvent<HTMLElement>) => {
+    trackButtonClick(pageId, buttonId, options);
+    onClick?.(e);
+  };
+}

+ 37 - 7
frontend/src/utils/websocket.ts

@@ -166,7 +166,8 @@ export class WebSocketManager {
     return (
       messageType === 'audio_chunk' ||
       messageType === 'audio_end' ||
-      messageType === 'start_greeting'
+      messageType === 'start_greeting' ||
+      messageType === 'play_topic_first_line'
     );
   }
 
@@ -189,8 +190,13 @@ export class WebSocketManager {
     this.doSend(message);
   }
 
-  sendAudioChunk(requestId: string, data: string, chunkIndex: number): void {
-    this.send({
+  sendAudioChunk(
+    requestId: string,
+    data: string,
+    chunkIndex: number,
+    scenarioContext?: { scene: string; topic: string }
+  ): void {
+    const payload: Record<string, unknown> = {
       type: 'audio_chunk',
       requestId,
       data,
@@ -198,16 +204,31 @@ export class WebSocketManager {
       channels: 1,
       bitDepth: 16,
       chunkIndex,
-    });
+    };
+    if (scenarioContext?.scene && scenarioContext?.topic) {
+      payload.scene = scenarioContext.scene;
+      payload.topic = scenarioContext.topic;
+    }
+    this.send(payload);
   }
 
-  sendAudioEnd(requestId: string, totalChunks: number, speechDuration?: number): void {
-    this.send({
+  sendAudioEnd(
+    requestId: string,
+    totalChunks: number,
+    speechDuration?: number,
+    scenarioContext?: { scene: string; topic: string }
+  ): void {
+    const payload: Record<string, unknown> = {
       type: 'audio_end',
       requestId,
       totalChunks,
       speechDuration,
-    });
+    };
+    if (scenarioContext?.scene && scenarioContext?.topic) {
+      payload.scene = scenarioContext.scene;
+      payload.topic = scenarioContext.topic;
+    }
+    this.send(payload);
   }
 
   sendCancel(requestId: string): void {
@@ -217,6 +238,15 @@ export class WebSocketManager {
     });
   }
 
+  /** 情景对话:切换话题后请求播放该话题首句 TTS */
+  sendPlayTopicFirstLine(requestId: string, topicKey: string): void {
+    this.send({
+      type: 'play_topic_first_line',
+      requestId,
+      data: { topicKey },
+    });
+  }
+
   private startHeartbeat(): void {
     this.pingInterval = setInterval(() => {
       this.send({

+ 110 - 0
项目文档/埋点事件命名规范.md

@@ -0,0 +1,110 @@
+# 埋点事件命名规范(user_events)
+
+## 1. event_name 总体结构
+
+- 统一格式:`<object>__<action>`(中间两个下划线)。
+- **object**:`<object_type>.<object_id>[.<sub_id>...]`。
+- **action**:统一使用英文小写动词,例如:`enter`、`view`、`click`、`submit`、`open`、`close`、`back`、`next`。
+
+示例:
+- `page.chatbot__enter`:进入 ChatBot 页面。
+- `button.chatbot.send__click`:在 ChatBot 页面点击发送按钮。
+
+---
+
+## 2. 页面对象命名
+
+### 2.1 object_type = page
+
+- 页面对象统一命名为:`page.<page_id>`。
+- `<page_id>` 与路由语义保持一致,使用英文 + 下划线,不使用中文。
+
+常见页面约定:
+
+| 路由 path | 含义 | page_id | 页面对象 |
+|-----------|------|---------|----------|
+| `/welcome` | 欢迎页 | `welcome` | `page.welcome` |
+| `/begin` | 引导开始页 | `begin` | `page.begin` |
+| `/login-register` | 登录/注册入口 | `login_register` | `page.login_register` |
+| `/email-login` | 邮箱登录 | `email_login` | `page.email_login` |
+| `/phone-login` | 手机登录 | `phone_login` | `page.phone_login` |
+| `/chatbot` | 主对话页 | `chatbot` | `page.chatbot` |
+| `/tutorial-1` ~ `/tutorial-10` | 教程 1–10 步 | `tutorial_1` ~ `tutorial_10` | `page.tutorial_1` ~ `page.tutorial_10` |
+| `/profile` | 个人中心 | `profile` | `page.profile` |
+
+### 2.2 页面进入事件
+
+- 统一使用:`event_name = "page.<page_id>__enter"`。
+- 建议在 `properties` 中补充:
+  - `page`: `<page_id>`(如 `chatbot`)。
+  - `path`: 实际路由 path(如 `/chatbot`)。
+  - `entry`: 入口来源(如 `welcome_start_button`)。
+
+示例:
+- 进入 ChatBot:`page.chatbot__enter`,`properties.page = "chatbot"`。
+
+---
+
+## 3. 按钮对象命名
+
+### 3.1 object_type = button
+
+- 按钮对象统一命名为:`button.<page_id>.<button_id>`。
+- `<button_id>` 使用语义化英文 snake_case,例如:`start`、`email_login`、`phone_login`、`send`、`mic`、`stop`、`next`、`skip` 等。
+
+### 3.2 按钮点击事件
+
+- 统一使用:`event_name = "button.<page_id>.<button_id>__click"`。
+
+示例:
+- Welcome 页“开始体验”按钮:`button.welcome.start__click`。
+- 登录注册页选择邮箱登录:`button.login_register.email_login__click`。
+- ChatBot 发送消息按钮:`button.chatbot.send__click`。
+
+### 3.3 按钮常见命名示例
+
+- **WelcomePage (`/welcome`)**
+  - `button.welcome.start__click`:开始体验。
+- **LoginRegisterPage (`/login-register`)**
+  - `button.login_register.email_login__click`:选择邮箱登录。
+  - `button.login_register.phone_login__click`:选择手机登录。
+- **EmailLogin (`/email-login`)**
+  - `button.email_login.send_code__click`:发送验证码。
+  - `button.email_login.submit__click`:提交登录。
+- **PhoneLogin (`/phone-login`)**
+  - `button.phone_login.send_code__click`。
+  - `button.phone_login.submit__click`。
+- **ChatBotPage (`/chatbot`)**
+  - `button.chatbot.mic__click`:开始录音/说话。
+  - `button.chatbot.send__click`:发送文本消息。
+  - `button.chatbot.stop__click`:停止/取消当前对话。
+
+---
+
+## 4. 跳转场景(点击 + 页面进入)
+
+**规则:点击触发页面跳转时,记录两条事件。**
+
+示例:在 Welcome 页点击按钮跳转到 Begin 页:
+
+1. `button.welcome.start__click`
+2. `page.begin__enter`,并在 `properties` 中记录:`from_page = "welcome"`。
+
+同理,在某页面 A 点击按钮跳转到页面 B:
+
+1. `button.a_page.<button_id>__click`
+2. `page.b_page__enter`(`properties.from_page = "a_page"`)。
+
+---
+
+## 5. properties 建议字段
+
+在 `user_events.properties` 中,推荐统一包含以下字段(按需扩展):
+
+- `page`: `<page_id>`,例如 `chatbot`、`welcome`。
+- `from_page`: 跳转来源页面,例如 `welcome`。
+- `button_id`: `<button_id>`,例如 `send`、`email_login`。
+- 其他业务相关字段,例如:
+  - `duration_seconds`: 页面停留时长(用于 page_view 类事件)。
+  - `entry`: 页面/功能的入口来源。
+

+ 241 - 0
项目文档/数据聚合表与CURR开发改造方案.md

@@ -0,0 +1,241 @@
+# 数据聚合表与 CURR 开发改造方案
+
+## 一、改造范围总览
+
+| 类别 | 内容 |
+|------|------|
+| 需求一 | 新增日聚合表 `user_daily_activity`,每小时从 `user_events` / `messages` / `users` 汇总,便于看盘 DAU/分层等 |
+| 需求二 | 新增 CURR 表,按天统计「昨日活跃用户中今日仍活跃」的占比,每小时执行统计脚本 |
+| 脚本与配置 | Python 脚本统一放在 `dailyCount/`,数据库与执行间隔由 `dailyCount/setting.conf` 配置,默认 60 分钟执行一次 |
+
+---
+
+## 二、需求一:数据聚合表 `user_daily_activity`
+
+### 2.1 表用途
+
+- 按天聚合用户活跃与行为强度,避免看盘时每次扫描 `user_events`、`messages` 全表。
+- 可支撑 DAU 趋势、New/Current/Reactivated/Resurrected/At-risk/Dormant 等分层(分层逻辑可基于本表 + 首日/末次活跃等另行计算)。
+
+### 2.2 表结构设计
+
+| 字段名 | 类型 | 可空 | 说明 |
+|--------|------|------|------|
+| `id` | `bigint unsigned AUTO_INCREMENT` | NOT NULL | 主键 |
+| `activity_date` | `date` | NOT NULL | 统计日期,按 UTC 日:`DATE(event_time)`,便于按日查询 |
+| `user_id` | `varchar(64)` | NOT NULL | 用户 ID(关联 `users.user_id`) |
+| `active_flag` | `tinyint(1)` | NOT NULL | 当天是否有「活跃判定事件」:≥1 次则为 1,否则 0 |
+| `session_count` | `int unsigned` | NOT NULL | 当天 `COUNT(DISTINCT session_id)`(仅统计参与活跃判定的那些事件对应的 session) |
+| `message_count` | `int unsigned` | NOT NULL | 当天用户消息条数,建议只计 `messages` 表中 `role='user'` 的条数(对话轮次/行为强度) |
+| `first_seen_date` | `date` | NULL | 用户「首次出现」日期,来自 `users.created_at` 的日期;首次写入后不覆盖 |
+| `user_segment_snapshot` | `varchar(32)` | NULL | 写入时的 `users.user_segment` 快照,避免后续身份变化影响历史统计 |
+| `org_id_snapshot` | `varchar(64)` | NULL | 写入时的 `users.org_id` 快照 |
+| `created_at` | `timestamp` | NOT NULL | 记录创建时间 |
+| `updated_at` | `timestamp` | NOT NULL | 记录更新时间(每次脚本覆盖当日/当用户时更新) |
+
+**约束与索引建议**:
+
+- `UNIQUE(activity_date, user_id)`:每个用户每天一条汇总记录。
+- `KEY idx_activity_date (activity_date)`:按日查 DAU、按日做 CURR。
+- `KEY idx_user_id (user_id)`、`KEY idx_first_seen (first_seen_date)`:分层与首日分析。
+
+### 2.3 数据来源与计算规则
+
+| 指标 | 数据来源 | 规则 |
+|------|----------|------|
+| `activity_date` | 脚本运行日或补偿历史日期 | 按 UTC 的日期,如 `DATE(UTC_NOW())` 或 `DATE(event_time)` |
+| `user_id` | `user_events.user_id` / `messages.user_id` | 当天有事件或消息的用户 |
+| `active_flag` | `user_events` | 当天该用户有 ≥1 次「活跃判定事件」则为 1,否则 0。活跃事件可在 `setting.conf` 中配置(见下) |
+| `session_count` | `user_events` | 仅针对「活跃判定事件」所在记录,`COUNT(DISTINCT session_id)`,且 `session_id IS NOT NULL` |
+| `message_count` | `messages` | 当天、该用户、`role='user'` 的 `COUNT(*)`,按 `DATE(created_at)` 或 `DATE(CONVERT_TZ(created_at, @@session.time_zone, '+00:00'))` 与 `activity_date` 对齐(建议统一 UTC 日) |
+| `first_seen_date` | `users.created_at` | `DATE(users.created_at)`(UTC);脚本侧逻辑:若该用户在该 `activity_date` 已有行则不再改写 `first_seen_date`,仅首次写入时带出 |
+| `user_segment_snapshot` | `users.user_segment` | 脚本执行时从 `users` 表读取并写入,每次覆盖当日该用户的快照 |
+| `org_id_snapshot` | `users.org_id` | 同上 |
+
+**活跃判定事件(active_flag)**:  
+当前前端多为 `page.<page_id>__enter`,暂无独立 `app_open`。建议在 `setting.conf` 中配置「视为活跃」的事件名列表或模式,例如:
+
+- 配置为 `app_open`:若后续增加该埋点,则仅该事件计活跃。
+- 配置为 `page.%__enter` 或列出 `page.chatbot__enter` 等:即「任意页面进入」计活跃。
+
+脚本按配置从 `user_events` 统计当天是否有匹配事件,决定 `active_flag`;并仅用这些事件的 `session_id` 算 `session_count`。
+
+### 2.4 更新策略
+
+- **频率**:每 60 分钟执行一次(可在 `setting.conf` 中修改)。
+- **范围**:建议至少更新「当天」的汇总;若有补跑需求,可支持按日期区间(如最近 N 天)重算。
+- **幂等**:按 `(activity_date, user_id)` 做 INSERT ... ON DUPLICATE KEY UPDATE,或先 DELETE 当日再 INSERT,保证同一用户同一天一条记录。
+
+### 2.5 建表 SQL(需求一)
+
+见本文「五、建表 SQL 汇总」中的 `user_daily_activity` 部分。
+
+---
+
+## 三、需求二:CURR 表(当日留存率)
+
+### 3.1 指标定义
+
+- **CURR(Current User Retention Rate)**:按天统计「昨日活跃用户中,今日仍活跃」的比例。
+- 公式:`CURR(当日) = (昨日活跃用户中今日仍活跃的人数) / (昨日活跃用户数) * 100%`。
+- 若昨日活跃用户为 0,则 CURR 可记为 NULL 或 0,由业务约定。
+
+示例:
+
+- 第 1 天:100 人活跃 → 无「昨日」,CURR 不适用或记为 NULL。
+- 第 2 天:昨日 100 人中有 50 人今日仍活跃 → CURR = 50%;若当日另有 100 名新活跃,则当日 DAU=150。
+- 第 3 天:昨日 150 人中有 50 人今日仍活跃 → CURR = 50/150 ≈ 33.33%。
+
+### 3.2 表结构设计
+
+| 字段名 | 类型 | 可空 | 说明 |
+|--------|------|------|------|
+| `id` | `bigint unsigned AUTO_INCREMENT` | NOT NULL | 主键 |
+| `activity_date` | `date` | NOT NULL | 统计日期(当日),CURR 表示「这一天的留存率」:即「昨日活跃且今日仍活跃」占「昨日活跃」的比例 |
+| `prev_day_active_count` | `int unsigned` | NOT NULL | 昨日活跃用户数(昨日 `user_daily_activity` 中 `active_flag=1` 的去重 user_id 数) |
+| `retained_count` | `int unsigned` | NOT NULL | 昨日活跃用户中今日仍活跃的人数(昨日活跃 ∩ 今日活跃) |
+| `curr_rate_pct` | `decimal(5,2)` | NULL | CURR 百分比,`retained_count / prev_day_active_count * 100`;昨日无活跃时为 NULL |
+| `total_active_today` | `int unsigned` | NULL | 当日 DAU(可选,便于看盘:当日活跃去重人数) |
+| `created_at` | `timestamp` | NOT NULL | 记录创建时间 |
+| `updated_at` | `timestamp` | NOT NULL | 记录更新时间 |
+
+**约束与索引**:
+
+- `UNIQUE(activity_date)`:每天一行。
+- `KEY idx_activity_date (activity_date)`:按日查询趋势。
+
+### 3.3 计算逻辑
+
+1. 依赖表:`user_daily_activity`(需先有需求一的脚本产出)。
+2. 对每个「有昨日数据」的 `activity_date`:
+   - `prev_day_active_count` = 昨日(`activity_date - 1`)中 `active_flag=1` 的 `COUNT(DISTINCT user_id)`。
+   - `retained_count` = 昨日 `active_flag=1` 的 user_id 集合 ∩ 今日 `active_flag=1` 的 user_id 集合 的个数。
+   - `curr_rate_pct` = `retained_count / prev_day_active_count * 100`(若 `prev_day_active_count = 0` 则置 NULL)。
+   - `total_active_today`(可选)= 当日 `active_flag=1` 的 `COUNT(DISTINCT user_id)`。
+3. 写入方式:按 `activity_date` 做 REPLACE 或 INSERT ... ON DUPLICATE KEY UPDATE,保证每天一行。
+
+### 3.4 更新策略
+
+- **频率**:每 60 分钟执行一次(与需求一脚本间隔可在 `setting.conf` 中统一或分开配置)。
+- **顺序**:建议先跑需求一的 `user_daily_activity` 再跑 CURR,以便 CURR 使用最新日聚合数据。
+- **范围**:至少更新「昨天」和「今天」的 CURR(今天的数据会随当日活跃变化而变,每小时刷新);可选支持历史日期补算。
+
+### 3.5 建表 SQL(需求二)
+
+见本文「五、建表 SQL 汇总」中的 `curr_daily` 部分。
+
+---
+
+## 四、脚本与配置统一约定
+
+### 4.1 目录与文件
+
+| 路径 | 说明 |
+|------|------|
+| `dailyCount/setting.conf` | 数据库连接、执行间隔等配置(见下) |
+| `dailyCount/setting.conf.example` | 配置示例,复制为 setting.conf 后修改 |
+| `dailyCount/run_user_daily_activity.py` | 需求一:从 DB 汇总写入 `user_daily_activity` |
+| `dailyCount/run_curr_daily.py` | 需求二:从 `user_daily_activity` 计算并写入 CURR 表 |
+| `dailyCount/sql/create_user_daily_activity.sql` | 需求一建表 SQL,可单独执行 |
+| `dailyCount/sql/create_curr_daily.sql` | 需求二建表 SQL,可单独执行 |
+
+### 4.2 setting.conf 配置项建议
+
+| 配置项 | 说明 | 示例/默认 |
+|--------|------|-----------|
+| `mysql.host` | MySQL 主机 | `47.99.164.66` |
+| `mysql.port` | 端口 | `3306` |
+| `mysql.user` | 用户名 | `root` |
+| `mysql.password` | 密码 | 空或密文 |
+| `mysql.database` | 库名 | `Lyrria` |
+| `schedule.interval_minutes` | 统计任务执行间隔(分钟) | `60` |
+| `user_daily_activity.active_events` | 判定「活跃」的事件名,多个用逗号或配置为前缀匹配规则 | `page.%__enter` 或 `app_open,page.chatbot__enter` |
+
+脚本读取方式:可用 Python 标准库 `configparser` 读 INI,或简单 key=value 解析;若需与现有 backend 一致,可改为读 `backend/.env`(需约定路径)。
+
+### 4.3 执行方式
+
+- **开发/单次**:直接执行 `python run_user_daily_activity.py`、`python run_curr_daily.py`。
+- **定时**:用 crontab 或系统定时任务,例如每 60 分钟执行一次两个脚本(先 activity 后 curr),或由脚本内 `schedule.interval_minutes` 做循环 sleep 定时执行。
+
+---
+
+## 五、建表 SQL 汇总
+
+### 5.1 user_daily_activity
+
+```sql
+-- 数据聚合表:按用户按日汇总活跃与行为,便于 DAU/分层 看盘
+SET NAMES utf8mb4;
+
+DROP TABLE IF EXISTS `user_daily_activity`;
+CREATE TABLE `user_daily_activity` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `activity_date` date NOT NULL COMMENT '统计日期(UTC日)',
+  `user_id` varchar(64) NOT NULL COMMENT '用户ID',
+  `active_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当天是否有≥1次活跃判定事件:1=是',
+  `session_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '当天去重session数(基于活跃事件)',
+  `message_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '当天用户消息条数(role=user)',
+  `first_seen_date` date DEFAULT NULL COMMENT '用户首次出现日期(来自users.created_at,首次写入后不覆盖)',
+  `user_segment_snapshot` varchar(32) DEFAULT NULL COMMENT 'users.user_segment快照',
+  `org_id_snapshot` varchar(64) DEFAULT NULL COMMENT 'users.org_id快照',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_date_user` (`activity_date`,`user_id`),
+  KEY `idx_activity_date` (`activity_date`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_first_seen_date` (`first_seen_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户日活跃聚合表';
+```
+
+### 5.2 curr_daily(CURR 按日)
+
+```sql
+-- CURR 按日:昨日活跃用户中今日仍活跃的占比
+SET NAMES utf8mb4;
+
+DROP TABLE IF EXISTS `curr_daily`;
+CREATE TABLE `curr_daily` (
+  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
+  `activity_date` date NOT NULL COMMENT '统计日期(当日)',
+  `prev_day_active_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '昨日活跃用户数',
+  `retained_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '昨日活跃中今日仍活跃人数',
+  `curr_rate_pct` decimal(5,2) DEFAULT NULL COMMENT 'CURR百分比(retained/prev*100),昨日无活跃为NULL',
+  `total_active_today` int(10) unsigned DEFAULT NULL COMMENT '当日DAU(可选)',
+  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_activity_date` (`activity_date`),
+  KEY `idx_activity_date` (`activity_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='CURR按日统计表';
+```
+
+---
+
+## 六、实施顺序与注意事项
+
+| 顺序 | 步骤 | 说明 |
+|------|------|------|
+| 1 | 执行建表 SQL | 先建 `user_daily_activity`,再建 `curr_daily` |
+| 2 | 创建 `dailyCount/setting.conf` | 配置数据库与 `schedule.interval_minutes`、`active_events` |
+| 3 | 实现并跑通 `run_user_daily_activity.py` | 确认数据来源(user_events/messages/users)与时区(建议统一 UTC 日) |
+| 4 | 实现并跑通 `run_curr_daily.py` | 依赖 `user_daily_activity` 已更新 |
+| 5 | 配置定时执行 | crontab 或脚本内循环,先 activity 后 curr,间隔 60 分钟 |
+
+**注意**:
+
+- 所有「按天」统计建议统一使用 UTC 日期,避免时区混用导致 DAU/CURR 偏差。
+- `user_events.event_time` 若已是 UTC,则 `DATE(event_time)` 即为 UTC 日;`messages.created_at` 需确认库表时区,必要时用 `CONVERT_TZ(..., @@session.time_zone, '+00:00')` 再取 DATE。
+- `first_seen_date` 仅首次写入时从 `users.created_at` 带出,后续更新同一用户同一天记录时不要覆盖该字段。
+
+---
+
+## 七、小结
+
+| 需求 | 表名 | 主要数据来源 | 更新频率 |
+|------|------|--------------|----------|
+| 需求一 | `user_daily_activity` | user_events、messages、users | 每 60 分钟 |
+| 需求二 | `curr_daily` | user_daily_activity | 每 60 分钟 |
+
+脚本统一放在 `dailyCount/`,数据库与执行间隔由 `dailyCount/setting.conf` 配置,默认 60 分钟执行一次统计;建表 SQL 见第五节,实施顺序见第六节。

+ 141 - 0
项目文档/用户分群与事件表需求分析与表设计.md

@@ -0,0 +1,141 @@
+# 用户分群与事件表需求分析与表设计
+
+## 一、需求摘要
+
+| 类别 | 内容 |
+|------|------|
+| users 表扩展 | 新增 `user_segment`、`org_id`、`is_test_user`、`region` 四字段 |
+| 新表 | 组织表(organizations)、用户事件表(user_events) |
+| 事件表用途 | 记录访问界面、按钮操作、停留时长、页面跳转等,用于分析与看板 |
+
+---
+
+## 二、改造范围总览
+
+| 层级 | 涉及内容 | 说明 |
+|------|----------|------|
+| **数据库** | `Lyrria.sql`、迁移脚本 | 表结构变更与新建表 |
+| **ORM** | `backend/models_db.py` | User 增字段,新增 Organization、UserEvent 模型 |
+| **初始化** | `backend/init_database.py` | 若有建表/同步逻辑需纳入新表与 users 新列 |
+| **后端服务** | 用户服务、事件上报 API | 读写 users 新字段、写入 user_events、按 org_id 查组织 |
+| **前端** | 埋点 SDK/工具、登录/注册/设置 | 上报 event(含 session_id、properties、timezone 等),设置页或注册流可展示/写入 region 等 |
+| **看板/分析** | 统计与筛选 | 按 user_segment、org_id、region、is_test_user 筛选;按 user_events 做漏斗、停留时长、路径分析 |
+
+---
+
+## 三、表结构设计
+
+### 3.1 users 表新增字段
+
+| 字段名 | 类型 | 默认值 | 可空 | 说明 |
+|--------|------|--------|------|------|
+| `user_segment` | `varchar(32)` 或 `enum('individual','school','internal')` | `'individual'` | NOT NULL | 用户/场景分群,看板筛选与功能分组 |
+| `org_id` | `varchar(64)` | NULL | YES | 学校/机构 ID,关联组织表主键 |
+| `is_test_user` | `tinyint(1)` | 0 | NOT NULL | 是否测试账号 |
+| `region` | `varchar(32)` 或 `varchar(64)` | NULL | YES | 国家/地区(如 CN、US,或 ISO 3166-1) |
+
+**建议**:
+- `user_segment` 用 `varchar(32)` 更易扩展新分群,若确定仅三种则用 `enum`。
+- `org_id` 先不建 DB 外键,避免组织表未就绪时迁移阻塞;组织表就绪后可选择性加 FK。
+- 索引:`user_segment`、`org_id`、`is_test_user` 看板筛选建议加索引;`region` 按需。
+
+---
+
+### 3.2 组织表 organizations(新表)
+
+| 字段名 | 类型 | 默认值 | 可空 | 说明 |
+|--------|------|--------|------|------|
+| `id` | `bigint unsigned AUTO_INCREMENT` | - | NOT NULL | 主键 |
+| `org_id` | `varchar(64)` | - | NOT NULL | 组织唯一标识(业务主键) |
+| `org_name` | `varchar(200)` | - | NOT NULL | 组织名称(如学校/机构名) |
+| `org_type` | `varchar(32)` | NULL | YES | 类型:school / institution / internal 等 |
+| `region` | `varchar(32)` | NULL | YES | 组织所在地区 |
+| `metadata` | `json` | NULL | YES | 扩展信息 |
+| `created_at` | `timestamp` | CURRENT_TIMESTAMP | NOT NULL | 创建时间 |
+| `updated_at` | `timestamp` | CURRENT_TIMESTAMP ON UPDATE | NOT NULL | 更新时间 |
+
+**约束与索引**:
+- `UNIQUE(org_id)`,`KEY idx_org_id (org_id)`,按需 `KEY idx_org_type (org_type)`、`KEY idx_region (region)`。
+
+**与 users 关系**:`users.org_id` 逻辑上关联 `organizations.org_id`(同库可后续加 FK)。
+
+---
+
+### 3.3 用户事件表 user_events(新表)
+
+参考需求中的“事件名 + 扩展属性”,设计如下。
+
+| 字段名 | 类型 | 默认值 | 可空 | 说明 |
+|--------|------|--------|------|------|
+| `id` | `bigint unsigned AUTO_INCREMENT` | - | NOT NULL | 自增主键 |
+| `event_id` | `varchar(64)` | - | NOT NULL | 唯一事件 ID(客户端或服务端生成) |
+| `user_id` | `varchar(64)` | - | NOT NULL | 事件归属用户(未登录可考虑用 device_id 或空,看产品) |
+| `event_name` | `varchar(64)` | - | NOT NULL | 事件代码(如 `page.chatbot__enter`,遵循埋点命名规范) |
+| `event_label` | `varchar(128)` | NULL | YES | 事件中文名称/描述(如“进入聊天页”、“欢迎页开始按钮点击”),便于看板与排查 |
+| `event_time` | `timestamp` | - | NOT NULL | 事件发生时间(建议存 UTC) |
+| `session_id` | `varchar(64)` | NULL | YES | 客户端生成的会话 ID,同一次前台停留共享 |
+| `platform` | `varchar(16)` | - | NOT NULL | ios / android / web |
+| `app_version` | `varchar(32)` | NULL | YES | 应用版本,如 v0.1.x,用于版本分析与排障 |
+| `timezone_offset_minutes` | `int(11)` | NULL | YES | 用户本地相对 UTC 的分钟偏移 |
+| `properties` | `json` | NULL | YES | 扩展属性,见下表 |
+
+**properties 常见结构(示例)**:
+
+| 事件名 | 典型 keys | 说明 |
+|--------|-----------|------|
+| `page_view` | `page`, `entry`, `duration_seconds` | 页面标识、入口来源、停留时长(秒) |
+| `button_click` | `page`, `button_id`, `button_name` | 所在页、按钮标识/名称 |
+| `message_send` | (可选)`round_number` 等 | 发过一句即记一条,可带轮次等 |
+| `app_open` | `entry` | 冷启动/从后台恢复等 |
+
+**约束与索引**:
+- `UNIQUE(event_id)`,避免重复上报。
+- `KEY idx_user_id (user_id)`,`KEY idx_event_time (event_time)`,`KEY idx_event_name (event_name)`。
+- 分析常用“按用户+时间”“按会话”:  
+  `KEY idx_user_event_time (user_id, event_time)`,`KEY idx_session_id (session_id)`(若按 session 查多)。
+- 若按天/看板聚合多:可考虑 `KEY idx_event_time_name (event_time, event_name)`。
+
+**session_id 语义建议**:
+- **建议**:退出 App/关闭页签后再进入算**新 session**。  
+  即:同一“前台一次连续使用”共享一个 session_id;由前端在 App 启动/页面首次加载时生成(如 UUID),存内存或 sessionStorage,不持久化到长期存储,这样“关闭再开”自然换新 ID。
+- 若后续要做“跨天/跨设备”分析,可在分析层用 user_id + 时间窗口做近似 session 聚合,而不必改表结构。
+
+---
+
+## 四、与现有 page_visit_logs 的关系
+
+| 对比项 | page_visit_logs(现有) | user_events(新) |
+|--------|-------------------------|-------------------|
+| 定位 | 简单“访问路径+时间” | 完整事件模型:事件类型、会话、版本、时区、扩展属性 |
+| 字段 | user_id, path, visited_at | event_id, user_id, event_name, event_time, session_id, platform, app_version, timezone_offset_minutes, properties |
+| 能力 | 仅路径+时间 | 页面、按钮、停留时长、入口、版本、会话维度分析 |
+
+**建议**:
+- **短期**:两表并存。新埋点写入 `user_events`;现有 `page_visit_logs` 的写入可保留(或仅保留历史),避免影响已有统计。
+- **中期**:若全量切到事件体系,可将“访问类”统计迁移到 `user_events`(event_name=page_view),再评估下线或归档 `page_visit_logs`。
+- 若希望“访问”只存一处:也可在事件表上线后,将原 `page_visit_logs` 的调用改为写入 `user_events`(event_name=page_view,path 放入 properties.page 或 properties.path),表本身暂不删。
+
+---
+
+## 五、涉及文件与改动点(实施时参考)
+
+| 文件/目录 | 改动要点 |
+|-----------|----------|
+| `Lyrria.sql` | users 增加 4 列;新增 `organizations`、`user_events` 建表语句 |
+| `backend/models_db.py` | User 增加 4 个 Column;新增 `Organization`、`UserEvent` 模型;User.to_dict 增加新字段(若对外暴露) |
+| `backend/init_database.py` | 若存在按模型建表/校验逻辑,需包含新表及 users 新列 |
+| `backend/migrations/` | 新增迁移:users 加列;create organizations;create user_events |
+| 用户相关 API/服务 | 注册/更新用户时写入 user_segment、org_id、is_test_user、region;查询/列表支持按这些字段筛选 |
+| 事件上报 API | 新增接口:接收前端事件(event_id、event_name、event_time、session_id、platform、app_version、timezone_offset_minutes、properties),校验后写入 user_events(user_id 从登录态取) |
+| 前端 | 埋点封装:生成/复用 session_id、采集 app_version、timezone_offset、platform;page_view 带 page/entry/duration;button_click 带 page/button_id;统一请求事件上报 API |
+
+---
+
+## 六、小结
+
+- **users**:增加 `user_segment`(默认 individual)、`org_id`、`is_test_user`(默认 false)、`region`,便于分群与看板筛选。
+- **organizations**:新建组织表,用 `org_id` 与 users 逻辑关联,支持学校/机构维度的用户管理。
+- **user_events**:新建统一事件表,覆盖访问、点击、发消息等,含 session、平台、版本、时区与 properties,与现有 page_visit_logs 可并存再逐步迁移。
+- **session_id**:建议由前端在“每次前台会话”开始时生成,退出再进算新 session;表结构无需为 session 单独改字段。
+
+按上述设计即可在“暂不修改代码”的前提下锁定改造范围与表模型,后续按迁移脚本 → ORM → 后端 API → 前端埋点的顺序实施即可。